diff options
Diffstat (limited to 'scripts/tests/test_seed_pearl_workspace.py')
| -rw-r--r-- | scripts/tests/test_seed_pearl_workspace.py | 198 |
1 files changed, 146 insertions, 52 deletions
diff --git a/scripts/tests/test_seed_pearl_workspace.py b/scripts/tests/test_seed_pearl_workspace.py index fdf967b..5f4147e 100644 --- a/scripts/tests/test_seed_pearl_workspace.py +++ b/scripts/tests/test_seed_pearl_workspace.py @@ -1,17 +1,18 @@ """Tests for seed_pearl_workspace. The seeding logic splits into a pure planner (given a workspace snapshot, decide -what to create or reconcile) and a thin Linear GraphQL client (the only network -boundary). The planner carries the behavior and gets exhaustive pure tests; the -client and seed() orchestration get fake-transport tests. +what to create, recreate, or reconcile) and a thin Linear GraphQL client (the +only network boundary). The planner carries the behavior and gets exhaustive +pure tests; the client and seed() orchestration get fake-transport tests. The state reconcile is shaped by what Linear actually allows, verified live: teamCreate seeds its own default columns, a team must keep an unstarted state, -and the Duplicate state is reserved. So the script never archives -- it renames -Linear's defaults into the six Agile columns (repurposing the unstarted Todo as -Triage), creates only the genuinely-new Icebox, and leaves Duplicate -alone. Positions are set with distinct nonzero values, since Linear ignores a -0.0 position and appends new states at a high one. +the Duplicate state is reserved, and a state's type is immutable. Because the +board orders columns by type category first, the columns need specific types +(Icebox and Triage backlog, Backlog unstarted) to render in board order, and a +column found with the wrong type has to be recreated -- create with the right +type, move its issues, archive the old one. Positions are set with distinct +nonzero values, since Linear ignores a 0.0 position and appends new states high. """ import os @@ -31,11 +32,13 @@ def test_target_states_are_the_six_agile_columns_in_order(): "Icebox", "Triage", "Backlog", "In Progress", "Done", "Canceled"] -def test_target_state_types_and_distinct_nonzero_positions(): +def test_target_state_types_put_backlog_last_in_the_pre_work_group(): by_name = {s["name"]: s for s in seed.TARGET_STATES} + # Icebox and Triage are backlog-type; Backlog is the unstarted state, which + # Linear renders after the backlog columns -> board order Icebox, Triage, Backlog. assert by_name["Icebox"]["type"] == "backlog" - assert by_name["Triage"]["type"] == "unstarted" # repurposed from Todo - assert by_name["Backlog"]["type"] == "backlog" + assert by_name["Triage"]["type"] == "backlog" + assert by_name["Backlog"]["type"] == "unstarted" assert by_name["In Progress"]["type"] == "started" assert by_name["Done"]["type"] == "completed" assert by_name["Canceled"]["type"] == "canceled" @@ -45,9 +48,12 @@ def test_target_state_types_and_distinct_nonzero_positions(): assert len(set(positions)) == 6 # distinct -def test_triage_queue_sources_the_default_todo(): - tq = next(s for s in seed.TARGET_STATES if s["name"] == "Triage") - assert "Todo" in tq["sources"] +def test_sources_take_over_the_right_defaults_on_a_fresh_team(): + by_name = {s["name"]: s for s in seed.TARGET_STATES} + # On a fresh team Icebox takes over the default Backlog and Backlog takes + # over the unstarted Todo (keeping its type). + assert "Backlog" in by_name["Icebox"]["sources"] + assert "Todo" in by_name["Backlog"]["sources"] def test_view_specs_names_and_project_scope(): @@ -82,38 +88,66 @@ LINEAR_DEFAULTS = [ ] -def _plan_by_target(existing): - return {p["target"]["name"]: p["match_id"] - for p in seed.reconcile_state_plan(existing)} - - -def test_reconcile_against_linear_defaults_renames_and_creates(): - plan = _plan_by_target(LINEAR_DEFAULTS) - # Icebox is genuinely new; the rest map onto a default (Triage <- Todo). - assert plan["Icebox"] is None - assert plan["Triage"] == "d2" # the unstarted Todo - assert plan["Backlog"] == "d1" - assert plan["In Progress"] == "d3" - assert plan["Done"] == "d4" - assert plan["Canceled"] == "d5" - - -def test_reconcile_is_idempotent_against_already_named_columns(): - existing = [{"id": f"s{i}", "name": n, "type": "x"} for i, n in enumerate( - ["Icebox", "Triage", "Backlog", "In Progress", "Done", "Canceled"])] - plan = _plan_by_target(existing) - assert all(mid is not None for mid in plan.values()) # everything matches, nothing created +def _entries_by_target(existing): + return {p["target"]["name"]: p for p in seed.reconcile_state_plan(existing)} + + +def test_reconcile_against_linear_defaults_reuses_defaults_and_creates_triage(): + plan = _entries_by_target(LINEAR_DEFAULTS) + # Icebox takes the default backlog Backlog; Backlog takes the unstarted Todo + # (both type-correct -> update). Triage is genuinely new. + assert plan["Icebox"]["action"] == "update" and plan["Icebox"]["match_id"] == "d1" + assert plan["Backlog"]["action"] == "update" and plan["Backlog"]["match_id"] == "d2" + assert plan["Triage"]["action"] == "create" + assert plan["In Progress"]["match_id"] == "d3" + assert plan["Done"]["match_id"] == "d4" + assert plan["Canceled"]["match_id"] == "d5" + + +def test_reconcile_exact_name_wins_over_a_source_alias(): + # A live "Backlog" must be claimed by the Backlog target, not stolen by + # Icebox's "Backlog" source alias. + existing = [{"id": "s1", "name": "Backlog", "type": "unstarted"}, + {"id": "s2", "name": "Icebox", "type": "backlog"}] + plan = _entries_by_target(existing) + assert plan["Backlog"]["match_id"] == "s1" + assert plan["Icebox"]["match_id"] == "s2" + + +def test_reconcile_marks_a_wrong_type_match_for_recreate(): + # The live workspace before the fix: Triage is unstarted, Backlog is backlog + # -- both the wrong type, so both must be recreated. + existing = [ + {"id": "s1", "name": "Icebox", "type": "backlog"}, + {"id": "s2", "name": "Triage", "type": "unstarted"}, + {"id": "s3", "name": "Backlog", "type": "backlog"}, + {"id": "s4", "name": "In Progress", "type": "started"}, + {"id": "s5", "name": "Done", "type": "completed"}, + {"id": "s6", "name": "Canceled", "type": "canceled"}, + ] + plan = _entries_by_target(existing) + assert plan["Triage"]["action"] == "recreate" and plan["Triage"]["match_id"] == "s2" + assert plan["Backlog"]["action"] == "recreate" and plan["Backlog"]["match_id"] == "s3" + assert plan["Icebox"]["action"] == "update" + + +def test_reconcile_is_idempotent_against_correctly_typed_columns(): + correct = [("Icebox", "backlog"), ("Triage", "backlog"), ("Backlog", "unstarted"), + ("In Progress", "started"), ("Done", "completed"), ("Canceled", "canceled")] + existing = [{"id": f"s{i}", "name": n, "type": t} for i, (n, t) in enumerate(correct)] + plan = _entries_by_target(existing) + assert all(e["action"] == "update" for e in plan.values()) def test_reconcile_matches_case_insensitively(): - # the half-seeded board carried a lowercase "icebox" existing = [{"id": "s1", "name": "icebox", "type": "backlog"}] - plan = _plan_by_target(existing) - assert plan["Icebox"] == "s1" + plan = _entries_by_target(existing) + assert plan["Icebox"]["match_id"] == "s1" def test_leftover_state_names_reports_unmatched(): - # Duplicate isn't a target and stays; report it for the user. + # Duplicate isn't a target and stays; Todo is claimed (by Backlog), so only + # Duplicate is reported. assert seed.leftover_state_names(LINEAR_DEFAULTS) == ["Duplicate"] @@ -157,33 +191,93 @@ def test_seed_on_empty_workspace_reconciles_then_builds(): summary = seed.seed(seed.LinearClient("key", transport=fake)) ops = fake.ops() assert ops.count("TeamCreate") == 1 - assert ops.count("StateCreate") == 1 # only Icebox is new - assert ops.count("StateUpdate") == 6 # six columns positioned/renamed + assert ops.count("StateCreate") == 1 # only Triage is new + assert ops.count("StateUpdate") == 6 # six columns named/positioned + assert ops.count("StateArchive") == 0 # nothing to recreate on a fresh team assert ops.count("TeamUpdate") == 1 # default new-issue state -> Triage assert ops.count("ProjectCreate") == 1 assert ops.count("CustomViewCreate") == 3 assert summary["team"] == "t1" assert summary["project"] == "p1" + assert summary["states_created"] == ["Triage"] + assert summary["states_recreated"] == [] assert summary["leftover_states"] == ["Duplicate"] assert summary["default_issue_state"] == "Triage" - # the default points at Triage (which reconciled from the unstarted Todo, d2) + # the default points at the freshly-created Triage team_update = next(v for op, v in fake.calls if op == "TeamUpdate") - assert team_update["input"]["defaultIssueStateId"] == "d2" - assert "workflowStateArchive" not in ops # never archives + assert team_update["input"]["defaultIssueStateId"] == "new1" + + +def test_seed_heals_type_drift_by_recreating_and_migrating(): + # The live workspace before the fix: Triage unstarted (the old default, + # empty), Backlog backlog (holds two issues). Both wrong type -> recreate. + live_states = [ + {"id": "s1", "name": "Icebox", "type": "backlog", "position": 1.0}, + {"id": "s2", "name": "Triage", "type": "unstarted", "position": 2.0}, + {"id": "s3", "name": "Backlog", "type": "backlog", "position": 3.0}, + {"id": "s4", "name": "In Progress", "type": "started", "position": 4.0}, + {"id": "s5", "name": "Done", "type": "completed", "position": 5.0}, + {"id": "s6", "name": "Canceled", "type": "canceled", "position": 6.0}, + {"id": "s7", "name": "Duplicate", "type": "duplicate", "position": 5.0}, + ] + new_ids = iter(["nTriage", "nBacklog"]) + + def make_state(_v): + return _ok("workflowStateCreate", {"workflowState": {"id": next(new_ids)}}) + + def state_issues(v): + nodes = [{"id": "i1"}, {"id": "i2"}] if v["id"] == "s3" else [] + return {"data": {"workflowState": {"issues": { + "nodes": nodes, "pageInfo": {"hasNextPage": False, "endCursor": None}}}}} + fake = FakeTransport({ + "Teams": {"data": {"teams": {"nodes": [ + {"id": "t1", "key": "PEARL", "name": "Pearl", + "defaultIssueState": {"id": "s2"}, + "states": {"nodes": live_states}}]}}}, + "StateUpdate": _ok("workflowStateUpdate", {"workflowState": {"id": "u"}}), + "StateCreate": make_state, + "StateIssues": state_issues, + "IssueMove": _ok("issueUpdate", {"issue": {"id": "i"}}), + "StateArchive": _ok("workflowStateArchive", {}), + "TeamUpdate": _ok("teamUpdate", {}), + "Projects": {"data": {"projects": {"nodes": [{"id": "p1", "name": "Pearl"}]}}}, + "CustomViews": {"data": {"customViews": {"nodes": [ + {"id": "v1", "name": "Pearl Open Issues"}, + {"id": "v2", "name": "Pearl Icebox"}, + {"id": "v3", "name": "Pearl Inbox"}]}}}, + }) + summary = seed.seed(seed.LinearClient("key", transport=fake)) + ops = fake.ops() + assert ops.count("StateCreate") == 2 # new Triage + new Backlog + assert ops.count("StateArchive") == 2 # old Triage + old Backlog + assert ops.count("IssueMove") == 2 # the two issues off old Backlog + # every create happens before any archive, so a new unstarted state exists + # before the old unstarted one is archived + assert max(i for i, op in enumerate(ops) if op == "StateCreate") \ + < min(i for i, op in enumerate(ops) if op == "StateArchive") + assert summary["states_recreated"] == ["Triage", "Backlog"] + assert summary["issues_moved"] == 2 + # the issues land on the new Backlog, and the default moves to the new Triage + moves = [v for op, v in fake.calls if op == "IssueMove"] + assert all(m["state"] == "nBacklog" for m in moves) + team_update = next(v for op, v in fake.calls if op == "TeamUpdate") + assert team_update["input"]["defaultIssueStateId"] == "nTriage" -def test_seed_is_idempotent_against_a_seeded_workspace(): - seeded_states = [{"id": f"s{i}", "name": n, "type": "x"} for i, n in enumerate( - ["Icebox", "Triage", "Backlog", "In Progress", "Done", "Canceled", "Duplicate"])] + +def test_seed_is_idempotent_against_a_correctly_seeded_workspace(): + correct = [("Icebox", "backlog"), ("Triage", "backlog"), ("Backlog", "unstarted"), + ("In Progress", "started"), ("Done", "completed"), + ("Canceled", "canceled"), ("Duplicate", "duplicate")] + seeded_states = [{"id": f"s{i}", "name": n, "type": t, "position": float(i + 1)} + for i, (n, t) in enumerate(correct)] fake = FakeTransport({ # default already points at Triage (s1), so no team update fires either "Teams": {"data": {"teams": {"nodes": [ {"id": "t1", "key": "PEARL", "name": "Pearl", "defaultIssueState": {"id": "s1"}, "states": {"nodes": seeded_states}}]}}}, - "TeamStates": {"data": {"team": {"states": {"nodes": seeded_states}}}}, "StateUpdate": _ok("workflowStateUpdate", {"workflowState": {"id": "u"}}), - "TeamUpdate": _ok("teamUpdate", {}), "Projects": {"data": {"projects": {"nodes": [{"id": "p1", "name": "Pearl"}]}}}, "CustomViews": {"data": {"customViews": {"nodes": [ {"id": "v1", "name": "Pearl Open Issues"}, @@ -192,9 +286,9 @@ def test_seed_is_idempotent_against_a_seeded_workspace(): }) seed.seed(seed.LinearClient("key", transport=fake)) ops = fake.ops() - for create_op in ("TeamCreate", "StateCreate", "ProjectCreate", "CustomViewCreate"): - assert create_op not in ops # nothing re-created - assert "TeamUpdate" not in ops # default already correct + for absent in ("TeamCreate", "StateCreate", "StateArchive", "ProjectCreate", + "CustomViewCreate", "TeamUpdate"): + assert absent not in ops # nothing created, recreated, or repointed def test_client_raises_on_graphql_errors(): |
