aboutsummaryrefslogtreecommitdiff
path: root/scripts/tests/test_seed_pearl_workspace.py
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/tests/test_seed_pearl_workspace.py')
-rw-r--r--scripts/tests/test_seed_pearl_workspace.py198
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():