diff options
Diffstat (limited to 'scripts/tests/test_seed_pearl_workspace.py')
| -rw-r--r-- | scripts/tests/test_seed_pearl_workspace.py | 249 |
1 files changed, 113 insertions, 136 deletions
diff --git a/scripts/tests/test_seed_pearl_workspace.py b/scripts/tests/test_seed_pearl_workspace.py index 2e7cb4a..ea22230 100644 --- a/scripts/tests/test_seed_pearl_workspace.py +++ b/scripts/tests/test_seed_pearl_workspace.py @@ -1,10 +1,17 @@ """Tests for seed_pearl_workspace. The seeding logic splits into a pure planner (given a workspace snapshot, decide -what to create) and a thin Linear GraphQL client (the only network boundary). -The planner carries the behavior, so it gets exhaustive pure tests; the client -and the seed() orchestration get fake-transport tests that assert the right -mutations go out and that a second run is a no-op (idempotence). +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. + +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 Queue), 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. """ import os @@ -20,31 +27,27 @@ import seed_pearl_workspace as seed # --- pure: target definitions ------------------------------------------------- def test_target_states_are_the_six_agile_columns_in_order(): - names = [s["name"] for s in seed.TARGET_STATES] - assert names == ["icebox", "triage queue", "backlog", - "in-progress", "done", "cancelled"] - - -def test_early_columns_share_backlog_type_with_ascending_positions(): - # icebox / triage queue / backlog all sort by position within one type, so - # pearl's (type-rank, position) grouping reproduces the board column order. - early = [s for s in seed.TARGET_STATES - if s["name"] in ("icebox", "triage queue", "backlog")] - assert all(s["type"] == "backlog" for s in early) - positions = [s["position"] for s in early] + assert [s["name"] for s in seed.TARGET_STATES] == [ + "Icebox", "Triage Queue", "Backlog", "In Progress", "Done", "Canceled"] + + +def test_target_state_types_and_distinct_nonzero_positions(): + by_name = {s["name"]: s for s in seed.TARGET_STATES} + assert by_name["Icebox"]["type"] == "backlog" + assert by_name["Triage Queue"]["type"] == "unstarted" # repurposed from Todo + assert by_name["Backlog"]["type"] == "backlog" + assert by_name["In Progress"]["type"] == "started" + assert by_name["Done"]["type"] == "completed" + assert by_name["Canceled"]["type"] == "canceled" + positions = [s["position"] for s in seed.TARGET_STATES] assert positions == sorted(positions) - assert len({s["position"] for s in seed.TARGET_STATES}) == 6 # all distinct + assert all(p > 0 for p in positions) # Linear ignores a 0.0 position + assert len(set(positions)) == 6 # distinct -def test_later_columns_map_to_distinct_linear_types(): - by_name = {s["name"]: s["type"] for s in seed.TARGET_STATES} - assert by_name["in-progress"] == "started" - assert by_name["done"] == "completed" - assert by_name["cancelled"] == "canceled" - - -def test_every_state_has_a_color(): - assert all(s.get("color") for s in seed.TARGET_STATES) +def test_triage_queue_sources_the_default_todo(): + tq = next(s for s in seed.TARGET_STATES if s["name"] == "Triage Queue") + assert "Todo" in tq["sources"] def test_view_specs_names_and_project_scope(): @@ -56,68 +59,68 @@ def test_view_specs_names_and_project_scope(): def test_open_view_excludes_completed_and_cancelled(): - open_view = next(v for v in seed.desired_views("p") - if v["name"] == "Pearl Open Issues") + open_view = next(v for v in seed.desired_views("p") if v["name"] == "Pearl Open Issues") nin = open_view["filterData"]["state"]["type"]["nin"] assert "completed" in nin and "canceled" in nin -def test_icebox_and_inbox_views_filter_to_their_state(): +def test_icebox_and_inbox_views_filter_to_their_column(): views = {v["name"]: v for v in seed.desired_views("p")} - assert views["Pearl Icebox"]["filterData"]["state"]["name"]["eq"] == "icebox" - assert views["Pearl Inbox"]["filterData"]["state"]["name"]["eq"] == "triage queue" + assert views["Pearl Icebox"]["filterData"]["state"]["name"]["eq"] == "Icebox" + assert views["Pearl Inbox"]["filterData"]["state"]["name"]["eq"] == "Triage Queue" -# --- pure: the planner -------------------------------------------------------- +# --- pure: the state reconcile planner ---------------------------------------- -EMPTY = {"team": None, "states": [], "project": None, "views": []} +LINEAR_DEFAULTS = [ + {"id": "d1", "name": "Backlog", "type": "backlog"}, + {"id": "d2", "name": "Todo", "type": "unstarted"}, + {"id": "d3", "name": "In Progress", "type": "started"}, + {"id": "d4", "name": "Done", "type": "completed"}, + {"id": "d5", "name": "Canceled", "type": "canceled"}, + {"id": "d6", "name": "Duplicate", "type": "duplicate"}, +] -def test_plan_on_empty_workspace_creates_everything(): - plan = seed.plan_seed(EMPTY) - assert plan.create_team is True - assert [s["name"] for s in plan.create_states] == [s["name"] for s in seed.TARGET_STATES] - assert plan.create_project is True - assert [v["name"] for v in plan.create_views] == [ - "Pearl Open Issues", "Pearl Icebox", "Pearl Inbox"] +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 Queue <- Todo). + assert plan["Icebox"] is None + assert plan["Triage Queue"] == "d2" # the unstarted Todo + assert plan["Backlog"] == "d1" + assert plan["In Progress"] == "d3" + assert plan["Done"] == "d4" + assert plan["Canceled"] == "d5" -def test_plan_is_idempotent_when_everything_exists(): - current = { - "team": {"id": "t1", "name": "Pearl"}, - "states": [{"name": n} for n in - ["icebox", "triage queue", "backlog", "in-progress", "done", "cancelled"]], - "project": {"id": "p1", "name": "Pearl"}, - "views": [{"name": n} for n in - ["Pearl Open Issues", "Pearl Icebox", "Pearl Inbox"]], - } - plan = seed.plan_seed(current) - assert plan.create_team is False - assert plan.create_states == [] - assert plan.create_project is False - assert plan.create_views == [] - - -def test_plan_creates_only_missing_states_and_views(): - current = { - "team": {"id": "t1", "name": "Pearl"}, - "states": [{"name": "backlog"}, {"name": "done"}], - "project": {"id": "p1", "name": "Pearl"}, - "views": [{"name": "Pearl Icebox"}], - } - plan = seed.plan_seed(current) - assert plan.create_team is False - assert plan.create_project is False - assert [s["name"] for s in plan.create_states] == [ - "icebox", "triage queue", "in-progress", "cancelled"] - assert [v["name"] for v in plan.create_views] == [ - "Pearl Open Issues", "Pearl Inbox"] +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 Queue", "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 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" + + +def test_leftover_state_names_reports_unmatched(): + # Duplicate isn't a target and stays; report it for the user. + assert seed.leftover_state_names(LINEAR_DEFAULTS) == ["Duplicate"] # --- boundary: a fake GraphQL transport --------------------------------------- class FakeTransport: - """Records GraphQL calls and replies from a scripted op->response map.""" + """Records GraphQL calls and replies from a scripted op-name -> response map.""" def __init__(self, responses): self.responses = responses @@ -133,85 +136,59 @@ class FakeTransport: return [op for op, _ in self.calls] -def test_seed_on_empty_workspace_issues_creates_in_order(): - state_n = {"i": 0} +def _ok(field, payload): + return {"data": {field: {"success": True, **payload}}} - def make_state(_v): - state_n["i"] += 1 - return {"data": {"workflowStateCreate": - {"success": True, "workflowState": {"id": f"s{state_n['i']}"}}}} +def test_seed_on_empty_workspace_reconciles_then_builds(): fake = FakeTransport({ - # snapshot queries: empty workspace - "teams": {"data": {"teams": {"nodes": []}}}, - "teamCreate": {"data": {"teamCreate": {"success": True, - "team": {"id": "t1", "key": "PEARL", "name": "Pearl", - "states": {"nodes": []}}}}}, - "workflowStateCreate": make_state, - "projects": {"data": {"projects": {"nodes": []}}}, - "projectCreate": {"data": {"projectCreate": - {"success": True, "project": {"id": "p1", "name": "Pearl"}}}}, - "customViews": {"data": {"customViews": {"nodes": []}}}, - "customViewCreate": {"data": {"customViewCreate": - {"success": True, "customView": {"id": "v1"}}}}, + "Teams": {"data": {"teams": {"nodes": []}}}, + "TeamCreate": _ok("teamCreate", {"team": {"id": "t1", "key": "PEARL", "name": "Pearl"}}), + # states re-fetched after create: Linear's defaults + "TeamStates": {"data": {"team": {"states": {"nodes": LINEAR_DEFAULTS}}}}, + "StateCreate": _ok("workflowStateCreate", {"workflowState": {"id": "new1"}}), + "StateUpdate": _ok("workflowStateUpdate", {"workflowState": {"id": "u"}}), + "Projects": {"data": {"projects": {"nodes": []}}}, + "ProjectCreate": _ok("projectCreate", {"project": {"id": "p1", "name": "Pearl"}}), + "CustomViews": {"data": {"customViews": {"nodes": []}}}, + "CustomViewCreate": _ok("customViewCreate", {"customView": {"id": "v1"}}), }) - client = seed.LinearClient("key", transport=fake) - summary = seed.seed(client) - - assert fake.ops().count("teamCreate") == 1 - assert fake.ops().count("workflowStateCreate") == 6 - assert fake.ops().count("projectCreate") == 1 - assert fake.ops().count("customViewCreate") == 3 + 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("ProjectCreate") == 1 + assert ops.count("CustomViewCreate") == 3 assert summary["team"] == "t1" assert summary["project"] == "p1" + assert summary["leftover_states"] == ["Duplicate"] + assert "workflowStateArchive" not in ops # never archives -def test_seed_is_idempotent_against_a_populated_workspace(): - populated = { - "teams": {"data": {"teams": {"nodes": [ - {"id": "t1", "key": "PEARL", "name": "Pearl", - "states": {"nodes": [{"id": f"s{i}", "name": n} - for i, n in enumerate( - ["icebox", "triage queue", "backlog", - "in-progress", "done", "cancelled"])]}}]}}}, - "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"}]}}}, - } - fake = FakeTransport(populated) - client = seed.LinearClient("key", transport=fake) - seed.seed(client) - for create_op in ("teamCreate", "workflowStateCreate", - "projectCreate", "customViewCreate"): - assert create_op not in fake.ops() - - -def test_seed_reports_leftover_default_states_without_archiving(): - # An existing team carrying the six columns plus Linear's leftover defaults: - # seed creates nothing and surfaces the defaults for manual tidy-up, and - # never issues an archive. - populated = { - "teams": {"data": {"teams": {"nodes": [ +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 Queue", "Backlog", "In Progress", "Done", "Canceled", "Duplicate"])] + fake = FakeTransport({ + "Teams": {"data": {"teams": {"nodes": [ {"id": "t1", "key": "PEARL", "name": "Pearl", - "states": {"nodes": [{"id": f"s{i}", "name": n} for i, n in enumerate( - ["icebox", "triage queue", "backlog", "in-progress", "done", - "cancelled", "Todo", "In Review"])]}}]}}}, - "projects": {"data": {"projects": {"nodes": [{"id": "p1", "name": "Pearl"}]}}}, - "customViews": {"data": {"customViews": {"nodes": [ + "states": {"nodes": seeded_states}}]}}}, + "TeamStates": {"data": {"team": {"states": {"nodes": seeded_states}}}}, + "StateUpdate": _ok("workflowStateUpdate", {"workflowState": {"id": "u"}}), + "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"}]}}}, - } - fake = FakeTransport(populated) - summary = seed.seed(seed.LinearClient("key", transport=fake)) - assert summary["leftover_states"] == ["Todo", "In Review"] - assert "workflowStateArchive" not in fake.ops() + }) + 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 def test_client_raises_on_graphql_errors(): - fake = FakeTransport({"teams": {"errors": [{"message": "bad key"}]}}) + fake = FakeTransport({"Teams": {"errors": [{"message": "bad key"}]}}) client = seed.LinearClient("key", transport=fake) with pytest.raises(seed.LinearError, match="bad key"): - client.execute("query { teams { nodes { id } } }", {}) + client.execute("query Teams { teams { nodes { id } } }", {}) |
