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.py249
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 } } }", {})