# /// script # requires-python = ">=3.10" # dependencies = ["pytest>=8.0", "ruamel.yaml>=0.18"] # /// """Corruption-critical tests for sprint-status.py. Each test runs the script as a subprocess via ``uv run`` against a temp copy of an inline fixture, then re-reads the file to assert comments and formatting survive and punctuation-heavy action values round-trip intact. Run: uv run scripts/tests/test_sprint_status.py or: uv run --with pytest --with ruamel.yaml -m pytest scripts/tests/test_sprint_status.py """ import importlib.util import json import os import re import stat import subprocess import sys from pathlib import Path import pytest from ruamel.yaml import YAML SCRIPT = Path(__file__).resolve().parents[1] / "sprint_status.py" # Vendored copy of bmad-sprint-planning's sprint-status-template.yaml — skills # must not path into each other's directories (PATH-05). The repo-level # test/test-template-sync.js keeps this fixture identical to the source. TEMPLATE = Path(__file__).resolve().parent / "fixtures" / "sprint-status-template.yaml" FIXTURE = """\ # Sprint Status Tracking # STATUS DEFINITIONS: # backlog - not yet started # ready-for-dev - ready to be implemented # done - completed generated: "01-01-2026 09:00" last_updated: "01-01-2026 09:00" project: "Demo Project" project_key: "DEMO" tracking_system: "file" story_location: "docs/stories" development_status: epic-1: backlog 1-1-user-authentication: done 1-2-account-management: done epic-1-retrospective: optional epic-2: backlog 2-1-dashboard: backlog """ # Two epics' worth of items, because the flag's headline use is epic N's retro # closing epic N-1's items: nothing may scope a selector to --epic. The two # "Scripted item" entries share their action text and differ only by epic, so the # legacy epic+action selector has to discriminate on the epic to resolve at all. ACTION_FIXTURE = """\ # Sprint Status Tracking # STATUS DEFINITIONS: # open - committed during a retrospective, not yet addressed generated: "01-01-2026 09:00" last_updated: "01-01-2026 09:00" development_status: 1-1-a: done epic-1-retrospective: optional 2-1-b: done epic-2-retrospective: optional # Action items committed during retrospectives action_items: - id: "epic-1-retro-item-1-x" epic: 1 action: "Scripted item" owner: "Amelia" status: "open" ref: "docs/epic-1-retro.md" - epic: 1 action: "Pre-existing item" owner: "Charlie" status: open - id: "epic-2-retro-item-1-y" epic: 2 action: "Scripted item" owner: "Dana" status: "in-progress" ref: "docs/epic-2-retro.md" """ # Three spellings of the same scalar, to pin that a status write never re-styles # the line it lands on. STYLE_FIXTURE = """\ last_updated: "01-01-2026 09:00" development_status: 1-1-a: done epic-1-retrospective: optional action_items: - id: "double" epic: 1 action: "Double" status: "open" - id: "single" epic: 1 action: "Single" status: 'open' - id: "plain" epic: 1 action: "Plain" status: open """ def _run(args): cmd = ["uv", "run", str(SCRIPT), *args] # LC_ALL=C keeps os.strerror text stable so error-string assertions do not # depend on the developer's locale. return subprocess.run( cmd, capture_output=True, text=True, env={**os.environ, "LC_ALL": "C"} ) def _module(): """Import the script as a module, for the few properties that cannot be triggered through the CLI (a failure after the temp file already exists).""" spec = importlib.util.spec_from_file_location("sprint_status", SCRIPT) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod def _write_fixture(tmp_path): target = tmp_path / "sprint-status.yaml" target.write_text(FIXTURE, encoding="utf-8") return target def _write_action_fixture(tmp_path): target = tmp_path / "sprint-status.yaml" target.write_text(ACTION_FIXTURE, encoding="utf-8") return target def _load(path): yaml = YAML(typ="rt") with open(path, "r", encoding="utf-8") as fh: return yaml.load(fh) def _json(proc): """Parse the JSON-only stdout contract, surfacing a crash instead of hiding it behind a JSONDecodeError.""" assert proc.stdout, f"empty stdout; stderr was: {proc.stderr}" assert "Traceback" not in proc.stderr, proc.stderr return json.loads(proc.stdout) def test_detect_epic(tmp_path): target = _write_fixture(tmp_path) proc = _run(["detect-epic", "--file", str(target)]) assert proc.returncode == 0, proc.stderr out = json.loads(proc.stdout) assert out["epic"] == 1 assert out["story_count"] == 2 assert out["retro_key"] == "epic-1-retrospective" assert out["retro_status"] == "optional" assert set(out["done_stories"]) == { "1-1-user-authentication", "1-2-account-management", } def test_detect_epic_rejects_typed_retrospective_status_as_json(tmp_path): fixture = ( "development_status:\n" " 1-1-a: done\n" " epic-1-retrospective: 2026-01-01\n" ) target = tmp_path / "sprint-status.yaml" target.write_text(fixture, encoding="utf-8") proc = _run(["detect-epic", "--file", str(target)]) assert proc.returncode == 1 out = _json(proc) assert out["ok"] is False assert out["error"] == "epic-1-retrospective status must be a string or null" assert "restored" not in out assert target.read_text(encoding="utf-8") == fixture # --- pending_stories: the unfinished-epic gate -------------------------------- def test_pending_stories_lists_the_selected_epics_unfinished_keys(tmp_path): # The gate the skill branches on before Phase 1: an epic whose highest done # story selected it, but which is not actually finished. Document order, so # the listing the user confirms matches the file they can open. fixture = ( "development_status:\n" " epic-2: backlog\n" " 2-1-a: done\n" " 2-2-b: backlog\n" " 2-3-c: ready-for-dev\n" ) target = tmp_path / "sprint-status.yaml" target.write_text(fixture, encoding="utf-8") proc = _run(["detect-epic", "--file", str(target)]) assert proc.returncode == 0, proc.stderr out = _json(proc) assert out["epic"] == 2 assert out["pending_stories"] == ["2-2-b", "2-3-c"] # A non-story key sitting beside them never leaks in: STORY_RE gates entry. assert "epic-2" not in out["pending_stories"] def test_pending_stories_is_empty_for_a_complete_epic(tmp_path): fixture = ( "development_status:\n" " 2-1-a: done\n" " 2-2-b: done\n" " epic-2-retrospective: optional\n" ) target = tmp_path / "sprint-status.yaml" target.write_text(fixture, encoding="utf-8") proc = _run(["detect-epic", "--file", str(target)]) assert proc.returncode == 0, proc.stderr out = _json(proc) assert out["epic"] == 2 assert out["pending_stories"] == [] def test_pending_stories_ignores_other_epics(tmp_path): # FIXTURE detects epic 1 while 2-1-dashboard sits at backlog. The key is # scoped to the *selected* epic -- unlike done_stories, which spans the whole # file -- so another epic's unfinished work must never block this retro. target = _write_fixture(tmp_path) proc = _run(["detect-epic", "--file", str(target)]) assert proc.returncode == 0, proc.stderr out = _json(proc) assert out["epic"] == 1 assert out["pending_stories"] == [] # done_stories keeps its whole-file scope, unchanged. assert set(out["done_stories"]) == { "1-1-user-authentication", "1-2-account-management", } def test_pending_stories_present_when_no_epic_is_detected(tmp_path): # No done story anywhere: the shape stays uniform so a caller can read # pending_stories without first branching on epic. fixture = ( "development_status:\n" " 1-1-a: backlog\n" " 2-1-b: ready-for-dev\n" ) target = tmp_path / "sprint-status.yaml" target.write_text(fixture, encoding="utf-8") proc = _run(["detect-epic", "--file", str(target)]) assert proc.returncode == 0, proc.stderr out = _json(proc) assert out["epic"] is None assert out["story_count"] == 0 assert out["pending_stories"] == [] def test_detect_epic_flag_aims_pending_stories_at_a_supplied_epic(tmp_path): # Auto-detect would pick epic 2 (highest with a done story). --epic 1 aims # the unfinished-epic gate at the orchestrator's explicit choice instead — # the -H path that previously had no pending_stories at all. fixture = ( "development_status:\n" " 1-1-a: done\n" " 1-2-b: backlog\n" " 1-3-c: review\n" " 2-1-a: done\n" " 2-2-b: done\n" " epic-1-retrospective: optional\n" ) target = tmp_path / "sprint-status.yaml" target.write_text(fixture, encoding="utf-8") proc = _run(["detect-epic", "--file", str(target), "--epic", "1"]) assert proc.returncode == 0, proc.stderr out = _json(proc) assert out["epic"] == 1 assert out["retro_key"] == "epic-1-retrospective" assert out["retro_status"] == "optional" assert out["pending_stories"] == ["1-2-b", "1-3-c"] # done_stories keeps its whole-file scope. assert set(out["done_stories"]) == {"1-1-a", "2-1-a", "2-2-b"} def test_detect_epic_flag_lists_pending_when_no_story_is_done(tmp_path): # Without --epic, no done story means epic is null. With --epic, an # unfinished epic that never landed a done story is still addressable — # every story key of that epic is pending. fixture = ( "development_status:\n" " 3-1-a: backlog\n" " 3-2-b: ready-for-dev\n" ) target = tmp_path / "sprint-status.yaml" target.write_text(fixture, encoding="utf-8") proc = _run(["detect-epic", "--file", str(target), "--epic", "3"]) assert proc.returncode == 0, proc.stderr out = _json(proc) assert out["epic"] == 3 assert out["pending_stories"] == ["3-1-a", "3-2-b"] assert out["retro_key"] == "epic-3-retrospective" assert out["retro_status"] is None def test_detect_epic_flag_empty_pending_for_a_complete_supplied_epic(tmp_path): fixture = ( "development_status:\n" " 1-1-a: done\n" " 1-2-b: done\n" " 2-1-a: backlog\n" ) target = tmp_path / "sprint-status.yaml" target.write_text(fixture, encoding="utf-8") proc = _run(["detect-epic", "--file", str(target), "--epic", "1"]) assert proc.returncode == 0, proc.stderr out = _json(proc) assert out["epic"] == 1 assert out["pending_stories"] == [] def test_detect_epic_flag_zero_story_count_marks_a_nonexistent_epic(tmp_path): # --epic 9 against a file that has no epic-9 stories: pending_stories is # empty exactly as it is for a finished epic, so story_count is the only # signal separating "complete" from "typo'd". The gate reads 0 as suspect, # never as done. fixture = ( "development_status:\n" " 1-1-a: done\n" " 1-2-b: done\n" ) target = tmp_path / "sprint-status.yaml" target.write_text(fixture, encoding="utf-8") proc = _run(["detect-epic", "--file", str(target), "--epic", "9"]) assert proc.returncode == 0, proc.stderr out = _json(proc) assert out["epic"] == 9 assert out["story_count"] == 0 assert out["pending_stories"] == [] assert out["retro_status"] is None # The finished epic it could be confused with reports its real count. proc = _run(["detect-epic", "--file", str(target), "--epic", "1"]) out = _json(proc) assert out["story_count"] == 2 assert out["pending_stories"] == [] def test_detect_epic_flag_rejects_non_positive_epic(tmp_path): target = _write_fixture(tmp_path) for bad in ("0", "-3"): proc = _run(["detect-epic", "--file", str(target), "--epic", bad]) assert proc.returncode == 1, proc.stderr out = _json(proc) assert out["ok"] is False assert "epic" in out["error"] assert "restored" not in out def test_update_rejects_non_positive_epic(tmp_path): target = _write_fixture(tmp_path) for bad in ("0", "-3"): proc = _run(["update", "--file", str(target), "--epic", bad]) assert proc.returncode == 1, proc.stderr assert "positive integer" in _json(proc)["error"] assert target.read_text(encoding="utf-8") == FIXTURE # --- The JSON-only contract covers the help paths ---------------------------- @pytest.mark.parametrize( "args", [["-h"], ["--help"], ["detect-epic", "-h"], ["detect-epic", "--help"]], ids=["top-short", "top-long", "sub-short", "sub-long"], ) def test_help_flags_emit_json_not_usage(args): # argparse's built-in help action bypasses error() -- it prints usage text # to stdout and exits 0, which is exactly the contract this script sells. # add_help=False turns -h into an ordinary unrecognized argument instead. proc = _run(args) # Exit 2 specifically: the module docstring reserves 2 for argument errors # and 1 for I/O failures, and retro-document.md teaches callers to tell the # two apart, so collapsing them must fail here. assert proc.returncode == 2 assert "usage:" not in proc.stdout out = _json(proc) assert out["ok"] is False assert out["error"] # An argparse rejection speaks for no file, so it carries no "restored" -- # the same rule test_only_the_write_path_reports_restored pins for exit 1. assert "restored" not in out @pytest.mark.parametrize("flag", ["-h", "--help"]) def test_update_help_flag_emits_json_not_usage(tmp_path, flag): # The update subparser too, driven with its required arguments present so # nothing but the help flag itself can be what argparse objects to. target = _write_fixture(tmp_path) proc = _run(["update", "--file", str(target), "--epic", "1", flag]) assert proc.returncode == 2 assert "usage:" not in proc.stdout out = _json(proc) assert out["ok"] is False assert flag in out["error"] assert "restored" not in out # A rejected invocation must not have written anything. assert target.read_text(encoding="utf-8") == FIXTURE def test_update_sets_retro_and_appends_action(tmp_path): target = _write_fixture(tmp_path) payload = '[{"action":"Fix #42: colons: and # hashes","owner":"Amelia"}]' proc = _run( [ "update", "--file", str(target), "--epic", "1", "--set-retro-done", "--add-action", payload, ] ) assert proc.returncode == 0, proc.stderr out = json.loads(proc.stdout) assert out["ok"] is True assert out["retro_key_found"] is True assert out["retro_status_after"] == "done" assert out["action_items_added"] == 1 # File must still parse cleanly (punctuation did not corrupt it). data = _load(target) assert data is not None # STATUS DEFINITIONS comment survived. raw = target.read_text(encoding="utf-8") assert "STATUS DEFINITIONS" in raw # Retro status flipped to done. assert data["development_status"]["epic-1-retrospective"] == "done" # The action value round-trips with literal '#' and ':' intact. action = data["action_items"][0] assert action["action"] == "Fix #42: colons: and # hashes" assert action["owner"] == "Amelia" assert action["epic"] == 1 assert action["status"] == "open" def test_update_rejects_typed_retrospective_status_before_writing(tmp_path): fixture = ( "last_updated: 01-01-2026 09:00\n" "development_status:\n" " 1-1-a: done\n" " epic-1-retrospective: 2026-01-01\n" ) target = tmp_path / "sprint-status.yaml" target.write_text(fixture, encoding="utf-8") proc = _run( [ "update", "--file", str(target), "--epic", "1", "--set-retro-done", "--add-action", '[{"action":"Must not be appended","owner":"Amelia"}]', ] ) assert proc.returncode == 1 out = _json(proc) assert out["ok"] is False assert out["restored"] is True assert out["error"] == "epic-1-retrospective status must be a string or null" assert target.read_text(encoding="utf-8") == fixture def test_detect_epic_matches_split_story_keys(tmp_path): # A split-story key like 2-6a-... is first-class in BMAD (an oversized story # split into 2-6a / 2-6b) and must not be invisible to detection — otherwise # an epic whose only done stories are splits is silently skipped. fixture = ( "development_status:\n" " 1-1-first: done\n" " 2-6a-split-auth: done\n" " epic-2-retrospective: optional\n" ) target = tmp_path / "sprint-status.yaml" target.write_text(fixture, encoding="utf-8") proc = _run(["detect-epic", "--file", str(target)]) assert proc.returncode == 0, proc.stderr out = json.loads(proc.stdout) assert out["epic"] == 2 assert "2-6a-split-auth" in out["done_stories"] assert out["retro_key"] == "epic-2-retrospective" def test_update_rejects_non_list_action_items(tmp_path): # A hand-corrupted action_items must fail on the JSON contract, not crash. fixture = ( "development_status:\n" " 1-1-a: done\n" " epic-1-retrospective: optional\n" 'action_items: "oops-not-a-list"\n' ) target = tmp_path / "sprint-status.yaml" target.write_text(fixture, encoding="utf-8") proc = _run( ["update", "--file", str(target), "--epic", "1", "--add-action", '[{"action":"x","owner":"y"}]'] ) assert proc.returncode == 1 out = json.loads(proc.stdout) # must be JSON, not a traceback assert out["ok"] is False assert "action_items" in out["error"] def test_appended_items_carry_id_and_ref(tmp_path): target = _write_fixture(tmp_path) ref = "docs/stories/epic-1-retro-2026-07-21.md" proc = _run( ["update", "--file", str(target), "--epic", "1", "--set-retro-done", "--add-action", '[{"action":"Fix the seam","owner":"Amelia"}]', "--ref", ref, "--verdict", "accepted-with-open-items"] ) assert proc.returncode == 0, proc.stderr out = json.loads(proc.stdout) assert out["verdict"] == "accepted-with-open-items" # echoed, not written to a key item = _load(target)["action_items"][0] assert item["id"].startswith("epic-1-retro-item-1-") assert item["ref"] == ref # The retro key value stays "done" — verdict is not encoded into it. assert _load(target)["development_status"]["epic-1-retrospective"] == "done" def test_free_spelled_verdict_is_rejected_before_the_file_is_touched(tmp_path): # The SKILL's prose verdict ("accepted with open items") and the frontmatter # token (accepted-with-open-items) used to be two spellings of one value; an # orchestrator branching on the echo would fall through both. Only the # frontmatter vocabulary passes; anything else fails with the file intact. target = _write_fixture(tmp_path) for bad in ("accepted with open items", "ship it", "ACCEPTED"): proc = _run( ["update", "--file", str(target), "--epic", "1", "--set-retro-done", "--verdict", bad] ) assert proc.returncode == 1, f"accepted {bad!r}" out = _json(proc) assert out["ok"] is False and "--verdict" in out["error"] assert out["restored"] is True assert target.read_text(encoding="utf-8") == FIXTURE def test_explicit_item_id_is_preserved(tmp_path): target = _write_fixture(tmp_path) proc = _run( ["update", "--file", str(target), "--epic", "1", "--add-action", '[{"action":"a","owner":"o","id":"custom-id-7"}]'] ) assert proc.returncode == 0, proc.stderr assert _load(target)["action_items"][0]["id"] == "custom-id-7" @pytest.mark.skipif( hasattr(os, "geteuid") and os.geteuid() == 0, reason="root bypasses file permission bits", ) def test_write_failure_reports_restore_status(tmp_path): # If the write cannot happen, the caller must be told whether the original # was restored — a silent failure defeats the script's core guarantee. # # The write is atomic (temp file + os.replace), and os.replace needs write # permission on the *directory*, not on the target — a read-only target is # now replaceable. Making the containing directory read-only is what blocks # the write: mkstemp fails, while the restore write to the still-writable # target succeeds. holder = tmp_path / "holder" holder.mkdir() target = _write_fixture(holder) os.chmod(holder, 0o555) try: proc = _run( ["update", "--file", str(target), "--epic", "1", "--set-retro-done"] ) finally: os.chmod(holder, 0o755) assert proc.returncode == 1 out = _json(proc) assert out["ok"] is False assert out["restored"] is True # The file was never touched: the temp file could not even be created. assert target.read_text(encoding="utf-8") == FIXTURE assert [p.name for p in holder.iterdir()] == ["sprint-status.yaml"] def test_punctuation_does_not_corrupt_file(tmp_path): # Explicit re-parse guarantee for YAML-breaking punctuation. target = _write_fixture(tmp_path) payload = '[{"action":"weird: value # with: hashes","owner":"Bob # Smith"}]' proc = _run( [ "update", "--file", str(target), "--epic", "1", "--add-action", payload, ] ) assert proc.returncode == 0, proc.stderr # Re-parse must succeed and preserve the literal punctuation. data = _load(target) assert data["action_items"][0]["action"] == "weird: value # with: hashes" assert data["action_items"][0]["owner"] == "Bob # Smith" # --- Formatting fidelity ----------------------------------------------------- def test_template_round_trip_changes_only_last_updated(tmp_path): # The repo's own sprint-status template is the shape every generated file # inherits: 2-space sequence indent and a mid-file comment above # action_items. An update must touch nothing but last_updated — a re-indent # of a pre-existing, untouched entry defeats the preservation guarantee that # motivates "do not hand-edit this file". source = TEMPLATE.read_text(encoding="utf-8") target = tmp_path / "sprint-status.yaml" target.write_text(source, encoding="utf-8") proc = _run( ["update", "--file", str(target), "--epic", "1", "--date", "01-01-2026 09:00"] ) assert proc.returncode == 0, proc.stderr assert _json(proc)["ok"] is True before = source.splitlines() after = target.read_text(encoding="utf-8").splitlines() assert len(before) == len(after) changed = [(b, a) for b, a in zip(before, after) if b != a] assert len(changed) == 1, changed assert changed[0][1] == "last_updated: 01-01-2026 09:00" # The pre-existing action item keeps its 2-space sequence indent. assert " - epic: 1" in after def test_mid_file_comment_survives_update(tmp_path): fixture = ( "# header\n" "development_status:\n" " 1-1-a: done\n" " epic-1-retrospective: optional\n" "\n" "# Action items committed during retrospectives\n" "action_items:\n" " - epic: 1\n" ' action: "Pre-existing item"\n' ' owner: "Charlie"\n' " status: open\n" ) target = tmp_path / "sprint-status.yaml" target.write_text(fixture, encoding="utf-8") proc = _run( ["update", "--file", str(target), "--epic", "1", "--set-retro-done", "--add-action", '[{"action":"New item","owner":"Amelia"}]'] ) assert proc.returncode == 0, proc.stderr raw = target.read_text(encoding="utf-8") assert "# Action items committed during retrospectives" in raw assert "# header" in raw assert ' action: "Pre-existing item"' in raw def test_legacy_offset_zero_file_is_canonicalized(tmp_path): # Files the previous version of this script wrote carry action_items at # column 0. The indent pin re-indents them to the template's shape on the # next write. That is a deliberate one-time canonicalization, not a silent # failure: the update still succeeds and no comment is lost. fixture = ( "# header\n" "development_status:\n" " 1-1-a: done\n" " epic-1-retrospective: optional\n" "action_items:\n" '- id: "legacy"\n' " epic: 1\n" ' action: "written by the old code"\n' " status: open\n" ) target = tmp_path / "sprint-status.yaml" target.write_text(fixture, encoding="utf-8") proc = _run(["update", "--file", str(target), "--epic", "1", "--set-retro-done"]) assert proc.returncode == 0, proc.stderr raw = target.read_text(encoding="utf-8") assert ' - id: "legacy"' in raw assert ' action: "written by the old code"' in raw assert "# header" in raw def test_lost_comment_fails_with_restore(tmp_path): # A standalone comment inside a flow collection is genuinely dropped by the # round-trip. The leading-block check never saw it; the full multiset does, # and the original bytes must come back. fixture = ( "# header\n" "development_status:\n" " 1-1-a: done\n" " epic-1-retrospective: optional\n" "tags: [\n" " # a standalone comment the round-trip drops\n" ' "alpha",\n' ' "beta",\n' "]\n" ) target = tmp_path / "sprint-status.yaml" target.write_text(fixture, encoding="utf-8") proc = _run( ["update", "--file", str(target), "--epic", "1", "--set-retro-done"] ) assert proc.returncode == 1 out = _json(proc) assert out["ok"] is False assert out["restored"] is True assert "comment line lost" in out["error"] assert "a standalone comment the round-trip drops" in out["error"] assert target.read_text(encoding="utf-8") == fixture # --- Malformed input stays on the JSON contract ------------------------------ @pytest.mark.parametrize("command", ["detect-epic", "update"]) def test_non_mapping_root_is_json_error(tmp_path, command): target = tmp_path / "sprint-status.yaml" target.write_text("- a\n- b\n", encoding="utf-8") args = ["--file", str(target)] + (["--epic", "1"] if command == "update" else []) proc = _run([command, *args]) assert proc.returncode == 1 out = _json(proc) assert out["ok"] is False assert "root document is not a mapping" in out["error"] assert target.read_text(encoding="utf-8") == "- a\n- b\n" @pytest.mark.parametrize("command", ["detect-epic", "update"]) @pytest.mark.parametrize( "body", ['development_status: "not-a-mapping"\n', "development_status:\n - a\n - b\n"], ids=["scalar", "list"], ) def test_non_mapping_development_status_is_json_error(tmp_path, command, body): target = tmp_path / "sprint-status.yaml" target.write_text(body, encoding="utf-8") args = ["--file", str(target)] + (["--epic", "1"] if command == "update" else []) proc = _run([command, *args]) # update used to report ok:true here while silently doing nothing. assert proc.returncode == 1 out = _json(proc) assert out["ok"] is False assert "development_status is not a mapping" in out["error"] @pytest.mark.parametrize("command", ["detect-epic", "update"]) def test_directory_target_is_json_error(tmp_path, command): target = tmp_path / "a-directory" target.mkdir() args = ["--file", str(target)] + (["--epic", "1"] if command == "update" else []) proc = _run([command, *args]) assert proc.returncode == 1 out = _json(proc) assert out["ok"] is False assert out["error"] @pytest.mark.skipif( hasattr(os, "geteuid") and os.geteuid() == 0, reason="root bypasses file permission bits", ) @pytest.mark.parametrize("command", ["detect-epic", "update"]) def test_unreadable_target_is_json_error(tmp_path, command): # The other half of the OSError widening: PermissionError, not just # IsADirectoryError, has to stay on the JSON contract. target = _write_fixture(tmp_path) os.chmod(target, 0o000) args = ["--file", str(target)] + (["--epic", "1"] if command == "update" else []) try: proc = _run([command, *args]) finally: os.chmod(target, 0o644) assert proc.returncode == 1 out = _json(proc) assert out["ok"] is False assert "denied" in out["error"].lower() @pytest.mark.parametrize("command", ["detect-epic", "update"]) def test_invalid_utf8_is_json_error(tmp_path, command): target = tmp_path / "sprint-status.yaml" target.write_bytes(b"development_status:\n 1-1-a: d\xffone\n") args = ["--file", str(target)] + (["--epic", "1"] if command == "update" else []) proc = _run([command, *args]) assert proc.returncode == 1 out = _json(proc) assert out["ok"] is False assert "utf-8" in out["error"].lower() # --- Atomic write ------------------------------------------------------------ def test_atomic_write_failure_leaves_target_byte_identical(tmp_path, monkeypatch): # The failure the atomic write exists for: something goes wrong after the # temp file has been written. Nothing may reach the target and no temp file # may survive. No CLI path reaches here -- a read-only directory fails at # mkstemp instead -- so this drives the helper directly. mod = _module() target = _write_fixture(tmp_path) def boom(*args, **kwargs): raise OSError(28, "No space left on device") monkeypatch.setattr(mod.os, "replace", boom) with pytest.raises(OSError): mod._atomic_write(str(target), b"replacement bytes\n", 0o644) assert target.read_text(encoding="utf-8") == FIXTURE assert [p.name for p in tmp_path.iterdir()] == ["sprint-status.yaml"] def test_dir_fsync_failure_after_rename_is_not_a_write_failure(tmp_path, monkeypatch): # Once os.replace has returned, the new bytes ARE the file. The directory # fsync that follows is durability polish; if it raised, cmd_update would # emit "restored": true about a write that in fact landed — the one lie the # restored contract exists to prevent. Deny opening the directory (the only # thing _atomic_write opens by path after the rename) and require success. mod = _module() target = _write_fixture(tmp_path) directory = os.path.dirname(os.path.realpath(str(target))) real_open = os.open def deny_directory_open(p, *args, **kwargs): if p == directory: raise OSError(5, "Input/output error") return real_open(p, *args, **kwargs) monkeypatch.setattr(mod.os, "open", deny_directory_open) mod._atomic_write(str(target), b"replacement bytes\n", 0o644) assert target.read_bytes() == b"replacement bytes\n" assert [p.name for p in tmp_path.iterdir()] == ["sprint-status.yaml"] def test_restore_is_atomic(tmp_path, monkeypatch): # _restore is the rollback the reference sells as the safety net. A # truncating rewrite that dies halfway would destroy the very bytes it is # putting back, which is how a full disk used to corrupt the file. mod = _module() target = tmp_path / "sprint-status.yaml" target.write_text("damaged\n", encoding="utf-8") def boom(*args, **kwargs): raise OSError(28, "No space left on device") monkeypatch.setattr(mod.os, "replace", boom) assert mod._restore(str(target), FIXTURE.encode("utf-8"), 0o644) is False # It reported failure honestly and left the file no worse than it found it. assert target.read_text(encoding="utf-8") == "damaged\n" assert [p.name for p in tmp_path.iterdir()] == ["sprint-status.yaml"] def test_symlinked_target_is_written_through(tmp_path): # os.replace onto a symlink would detach the link and leave the real file # stale while reporting ok:true. real = tmp_path / "real-sprint-status.yaml" real.write_text(FIXTURE, encoding="utf-8") link = tmp_path / "sprint-status.yaml" link.symlink_to(real) proc = _run(["update", "--file", str(link), "--epic", "1", "--set-retro-done"]) assert proc.returncode == 0, proc.stderr assert link.is_symlink(), "the symlink was replaced by a regular file" assert _load(real)["development_status"]["epic-1-retrospective"] == "done" def test_atomic_write_preserves_mode_and_leaves_no_temp_file(tmp_path): # mkstemp creates 0600; without carrying the target's mode over, every # update would silently narrow the file. holder = tmp_path / "holder" holder.mkdir() target = _write_fixture(holder) os.chmod(target, 0o640) proc = _run(["update", "--file", str(target), "--epic", "1", "--set-retro-done"]) assert proc.returncode == 0, proc.stderr assert stat.S_IMODE(target.stat().st_mode) == 0o640 assert [p.name for p in holder.iterdir()] == ["sprint-status.yaml"] # --- Result-JSON precision --------------------------------------------------- def test_retro_key_found_is_null_without_the_flag(tmp_path): # No development_status key at all: the update must not conjure one, and # retro_key_found must say "not asked" rather than "absent". fixture = 'project: "Demo"\nlast_updated: "01-01-2026 09:00"\n' target = tmp_path / "sprint-status.yaml" target.write_text(fixture, encoding="utf-8") proc = _run(["update", "--file", str(target), "--epic", "1"]) assert proc.returncode == 0, proc.stderr out = _json(proc) assert out["ok"] is True assert out["retro_key_found"] is None assert "development_status" not in target.read_text(encoding="utf-8") def test_retro_key_found_is_false_when_the_key_is_absent(tmp_path): target = _write_fixture(tmp_path) proc = _run(["update", "--file", str(target), "--epic", "2", "--set-retro-done"]) assert proc.returncode == 0, proc.stderr out = _json(proc) assert out["ok"] is True assert out["retro_key_found"] is False # Nothing was written into the mapping. assert "epic-2-retrospective" not in _load(target)["development_status"] @pytest.mark.parametrize("command", ["detect-epic", "update"]) def test_only_the_write_path_reports_restored(tmp_path, command): # "restored" speaks to the state of a file the command may have written. # detect-epic never writes, so inventing the key there would mislead callers # that branch on it. target = tmp_path / "sprint-status.yaml" target.write_text("- a\n- b\n", encoding="utf-8") args = ["--file", str(target)] + (["--epic", "1"] if command == "update" else []) out = _json(_run([command, *args])) assert out["ok"] is False assert ("restored" in out) is (command == "update") def test_pre_write_failure_reports_restored(tmp_path): # retro-document.md teaches callers that ok:false carries restored:true; # a failure before the write must not read as "the file may be incomplete". target = _write_fixture(tmp_path) proc = _run( ["update", "--file", str(target), "--epic", "1", "--add-action", "{not json"] ) assert proc.returncode == 1 out = _json(proc) assert out["ok"] is False assert out["restored"] is True assert target.read_text(encoding="utf-8") == FIXTURE # --- Action-item validation and identity ------------------------------------- def test_non_latin_action_keeps_its_text_in_the_id(tmp_path): target = _write_fixture(tmp_path) payload = json.dumps( [{"action": "Улучшить обработку ошибок", "owner": "Amelia"}], ensure_ascii=False, ) proc = _run(["update", "--file", str(target), "--epic", "1", "--add-action", payload]) assert proc.returncode == 0, proc.stderr item_id = _load(target)["action_items"][0]["id"] assert item_id == "epic-1-retro-item-1-улучшить-обработку-ошибок" def test_unsluggable_action_falls_back_to_a_hash(tmp_path): target = _write_fixture(tmp_path) payload = json.dumps([{"action": "!!! 🎉", "owner": "Amelia"}], ensure_ascii=False) proc = _run(["update", "--file", str(target), "--epic", "1", "--add-action", payload]) assert proc.returncode == 0, proc.stderr item_id = _load(target)["action_items"][0]["id"] assert not item_id.endswith("-item") assert re.fullmatch(r"epic-1-retro-item-1-[0-9a-f]{8}", item_id), item_id def test_empty_action_is_rejected(tmp_path): target = _write_fixture(tmp_path) proc = _run( ["update", "--file", str(target), "--epic", "1", "--add-action", '[{"action":" ","owner":"x"}]'] ) assert proc.returncode == 1 out = _json(proc) assert out["ok"] is False assert out["restored"] is True assert "action" in out["error"] assert target.read_text(encoding="utf-8") == FIXTURE def test_non_string_action_is_rejected(tmp_path): # A JSON null would otherwise be str()'d into a literal "None" and written # as a real action item, which the new emptiness check alone lets through. target = _write_fixture(tmp_path) proc = _run( ["update", "--file", str(target), "--epic", "1", "--add-action", '[{"action":null,"owner":"x"}]'] ) assert proc.returncode == 1 out = _json(proc) assert out["ok"] is False assert out["restored"] is True assert target.read_text(encoding="utf-8") == FIXTURE def test_date_is_normalized_to_the_canonical_format(tmp_path): # strptime accepts unpadded spellings; writing those through would defeat # the point of validating the format. target = _write_fixture(tmp_path) proc = _run( ["update", "--file", str(target), "--epic", "1", "--date", "1-2-2026 9:05"] ) assert proc.returncode == 0, proc.stderr assert _json(proc)["last_updated"] == "01-02-2026 09:05" assert _load(target)["last_updated"] == "01-02-2026 09:05" def test_malformed_date_is_rejected(tmp_path): target = _write_fixture(tmp_path) proc = _run( ["update", "--file", str(target), "--epic", "1", "--date", "not-a-date"] ) assert proc.returncode == 1 out = _json(proc) assert out["ok"] is False assert out["restored"] is True assert "--date" in out["error"] assert target.read_text(encoding="utf-8") == FIXTURE # --- Action-item status transitions ------------------------------------------ def test_set_action_status_applies_both_selector_forms(tmp_path): # The whole point of the flag: an item written by this script (selected by # id) and a legacy item that predates ids (selected by epic + exact action # text) both move off "open" in a single call. target = _write_action_fixture(tmp_path) payload = json.dumps( [ {"id": "epic-1-retro-item-1-x", "status": "done"}, {"epic": 1, "action": "Pre-existing item", "status": "in-progress"}, ] ) proc = _run( ["update", "--file", str(target), "--epic", "1", "--set-action-status", payload] ) assert proc.returncode == 0, proc.stderr out = _json(proc) assert out["ok"] is True assert out["action_items_updated"] == 2 assert out["action_items_added"] == 0 items = _load(target)["action_items"] assert items[0]["status"] == "done" assert items[1]["status"] == "in-progress" # Every other key of both items survived untouched, in place. assert items[0]["id"] == "epic-1-retro-item-1-x" assert items[0]["epic"] == 1 assert items[0]["action"] == "Scripted item" assert items[0]["owner"] == "Amelia" assert items[0]["ref"] == "docs/epic-1-retro.md" assert "id" not in items[1] assert items[1]["epic"] == 1 assert items[1]["action"] == "Pre-existing item" assert items[1]["owner"] == "Charlie" # The epic-2 item shares its action text with items[0]; the epic-1 selector # must not have touched it. assert items[2]["status"] == "in-progress" assert items[2]["id"] == "epic-2-retro-item-1-y" assert len(items) == 3 def test_set_action_status_changes_exactly_one_line(tmp_path): # The status write is surgical: it must not re-style neighbouring lines, and # an item whose status was quoted keeps its quoting. last_updated is rewritten # with the same text it already held, so the whole file differs by one line. target = _write_action_fixture(tmp_path) proc = _run( ["update", "--file", str(target), "--epic", "1", "--date", "01-01-2026 09:00", "--set-action-status", '[{"id":"epic-1-retro-item-1-x","status":"done"}]'] ) assert proc.returncode == 0, proc.stderr before = ACTION_FIXTURE.splitlines() after = target.read_text(encoding="utf-8").splitlines() assert len(before) == len(after) changed = [(b, a) for b, a in zip(before, after) if b != a] assert changed == [(' status: "open"', ' status: "done"')], changed def test_set_action_status_composes_with_retro_done_and_add_action(tmp_path): target = _write_action_fixture(tmp_path) proc = _run( ["update", "--file", str(target), "--epic", "1", "--set-retro-done", "--add-action", '[{"action":"Brand new item","owner":"Amelia"}]', "--set-action-status", '[{"epic":1,"action":"Pre-existing item","status":"done"}]'] ) assert proc.returncode == 0, proc.stderr out = _json(proc) assert out["ok"] is True assert out["retro_status_after"] == "done" assert out["action_items_added"] == 1 assert out["action_items_updated"] == 1 data = _load(target) assert data["development_status"]["epic-1-retrospective"] == "done" items = data["action_items"] assert len(items) == 4 assert items[1]["status"] == "done" # the targeted pre-existing item assert items[3]["action"] == "Brand new item" assert items[3]["status"] == "open" # the appended item is always open def test_set_action_status_cannot_target_an_item_added_in_the_same_run(tmp_path): # Selectors resolve against action_items as loaded, so the append cannot be # observed by the same invocation. Silently succeeding here would make the # flag a back door for writing a non-open status onto a brand-new item. target = _write_action_fixture(tmp_path) proc = _run( ["update", "--file", str(target), "--epic", "1", "--add-action", '[{"action":"Brand new","owner":"A","id":"brand-new"}]', "--set-action-status", '[{"id":"brand-new","status":"done"}]'] ) assert proc.returncode == 1 out = _json(proc) assert out["ok"] is False assert out["restored"] is True assert "no action item matches" in out["error"] assert target.read_text(encoding="utf-8") == ACTION_FIXTURE def test_set_action_status_rejects_unknown_id(tmp_path): target = _write_action_fixture(tmp_path) proc = _run( ["update", "--file", str(target), "--epic", "1", "--set-action-status", '[{"id":"not-in-the-file","status":"done"}]'] ) assert proc.returncode == 1 out = _json(proc) assert out["ok"] is False assert out["restored"] is True assert "not-in-the-file" in out["error"] assert target.read_text(encoding="utf-8") == ACTION_FIXTURE def test_set_action_status_rejects_selector_when_action_items_is_absent(tmp_path): # No action_items key at all must read as "no match", not as a crash. target = _write_fixture(tmp_path) proc = _run( ["update", "--file", str(target), "--epic", "1", "--set-action-status", '[{"id":"anything","status":"done"}]'] ) assert proc.returncode == 1 out = _json(proc) assert out["ok"] is False assert out["restored"] is True assert "no action item matches" in out["error"] assert target.read_text(encoding="utf-8") == FIXTURE def test_set_action_status_rejects_ambiguous_selector(tmp_path): # Two legacy items with identical epic + action text: guessing between them # would write the wrong row half the time. fixture = ( "development_status:\n" " 1-1-a: done\n" " epic-1-retrospective: optional\n" "action_items:\n" " - epic: 1\n" ' action: "Same text"\n' ' owner: "Charlie"\n' " status: open\n" " - epic: 1\n" ' action: "Same text"\n' ' owner: "Dana"\n' " status: open\n" ) target = tmp_path / "sprint-status.yaml" target.write_text(fixture, encoding="utf-8") proc = _run( ["update", "--file", str(target), "--epic", "1", "--set-action-status", '[{"epic":1,"action":"Same text","status":"done"}]'] ) assert proc.returncode == 1 out = _json(proc) assert out["ok"] is False assert out["restored"] is True assert "ambiguous" in out["error"] assert "Same text" in out["error"] assert "2 matches" in out["error"] assert target.read_text(encoding="utf-8") == fixture def test_set_action_status_rejects_two_entries_hitting_the_same_item(tmp_path): # The id form and the epic/action form can name the same row; applying both # would overcount action_items_updated and hide a conflicting pair. target = _write_action_fixture(tmp_path) payload = json.dumps( [ {"id": "epic-1-retro-item-1-x", "status": "done"}, {"epic": 1, "action": "Scripted item", "status": "in-progress"}, ] ) proc = _run( ["update", "--file", str(target), "--epic", "1", "--set-action-status", payload] ) assert proc.returncode == 1 out = _json(proc) assert out["ok"] is False assert out["restored"] is True assert "same action item" in out["error"] assert target.read_text(encoding="utf-8") == ACTION_FIXTURE def test_entry_with_both_selector_forms_uses_the_id(tmp_path): # A caller that copied a whole item through supplies both. The id is the # precise form and wins; the extra keys are ignored, not rejected. The two # forms are pointed at *different* rows so the precedence is observable: # the id names items[0], the epic/action pair names items[1]. target = _write_action_fixture(tmp_path) payload = json.dumps( [ { "id": "epic-1-retro-item-1-x", "epic": 1, "action": "Pre-existing item", "owner": "Charlie", "status": "done", } ] ) proc = _run( ["update", "--file", str(target), "--epic", "1", "--set-action-status", payload] ) assert proc.returncode == 0, proc.stderr assert _json(proc)["action_items_updated"] == 1 items = _load(target)["action_items"] assert items[0]["status"] == "done" # the id's item assert items[1]["status"] == "open" # the epic/action item, untouched def test_set_action_status_rejects_invalid_status(tmp_path): target = _write_action_fixture(tmp_path) proc = _run( ["update", "--file", str(target), "--epic", "1", "--set-action-status", '[{"id":"epic-1-retro-item-1-x","status":"closed"}]'] ) assert proc.returncode == 1 out = _json(proc) assert out["ok"] is False assert out["restored"] is True assert "closed" in out["error"] # The allowed vocabulary is named so the caller can correct the call. assert "open, in-progress, done" in out["error"] assert target.read_text(encoding="utf-8") == ACTION_FIXTURE def test_set_action_status_rejects_malformed_json(tmp_path): target = _write_action_fixture(tmp_path) proc = _run( ["update", "--file", str(target), "--epic", "1", "--set-action-status", "{not json"] ) assert proc.returncode == 1 out = _json(proc) assert out["ok"] is False assert out["restored"] is True assert "invalid --set-action-status JSON" in out["error"] assert target.read_text(encoding="utf-8") == ACTION_FIXTURE _SELECTOR_SHAPE_ERROR = ( "each --set-action-status entry must have a non-empty string id, " "or an integer epic and a non-empty string action" ) @pytest.mark.parametrize( ("payload", "expected_error"), [ ( '{"id":"epic-1-retro-item-1-x","status":"done"}', "--set-action-status must be a JSON array", ), ( '["epic-1-retro-item-1-x"]', "each --set-action-status entry must be an object", ), ('[{"status":"done"}]', _SELECTOR_SHAPE_ERROR), ( '[{"id":"","status":"done"}]', "each --set-action-status id must be a non-empty string", ), ( '[{"id":42,"status":"done"}]', "each --set-action-status id must be a non-empty string", ), ( '[{"epic":"1","action":"Pre-existing item","status":"done"}]', _SELECTOR_SHAPE_ERROR, ), ( '[{"epic":true,"action":"Pre-existing item","status":"done"}]', _SELECTOR_SHAPE_ERROR, ), ('[{"epic":1,"action":" ","status":"done"}]', _SELECTOR_SHAPE_ERROR), ( # No status key at all: the status validator runs before the selector # validator, so this is the status branch, not the selector branch. '[{"epic":1,"action":"Pre-existing item"}]', "invalid --set-action-status status None", ), ( '[{"id":"epic-1-retro-item-1-x","status":3}]', "invalid --set-action-status status 3", ), ], ids=[ "not-a-list", "entry-not-an-object", "no-selector", "empty-id", "non-string-id", "string-epic", "bool-epic", "blank-action", "no-status-key", "non-string-status", ], ) def test_set_action_status_rejects_bad_shapes(tmp_path, payload, expected_error): # Each case pins its own message: collapsing the branches into one generic # error would leave a caller unable to tell which part of the array is wrong. target = _write_action_fixture(tmp_path) proc = _run( ["update", "--file", str(target), "--epic", "1", "--set-action-status", payload] ) assert proc.returncode == 1 out = _json(proc) assert out["ok"] is False assert out["restored"] is True assert expected_error in out["error"], out["error"] assert target.read_text(encoding="utf-8") == ACTION_FIXTURE def test_selectors_are_not_scoped_to_the_epic_flag(tmp_path): # The flag's headline use: epic 2's retro closing epic 1's items. --epic only # names the retro key and stamps appended items; scoping selectors to it would # silently break the documented cross-epic workflow while every same-epic test # kept passing. target = _write_action_fixture(tmp_path) payload = json.dumps( [ {"id": "epic-1-retro-item-1-x", "status": "done"}, {"epic": 1, "action": "Pre-existing item", "status": "done"}, ] ) proc = _run( ["update", "--file", str(target), "--epic", "2", "--set-retro-done", "--set-action-status", payload] ) assert proc.returncode == 0, proc.stderr assert _json(proc)["action_items_updated"] == 2 data = _load(target) assert data["development_status"]["epic-2-retrospective"] == "done" items = data["action_items"] assert items[0]["status"] == "done" assert items[1]["status"] == "done" # Epic 2's own item is not swept along. assert items[2]["status"] == "in-progress" def test_legacy_selector_discriminates_on_the_epic(tmp_path): # Two items share the action text "Scripted item" and differ only by epic, so # an epic-blind text match would be ambiguous -- or worse, silently pick one. target = _write_action_fixture(tmp_path) proc = _run( ["update", "--file", str(target), "--epic", "1", "--set-action-status", '[{"epic":2,"action":"Scripted item","status":"done"}]'] ) assert proc.returncode == 0, proc.stderr assert _json(proc)["action_items_updated"] == 1 items = _load(target)["action_items"] assert items[2]["status"] == "done" assert items[0]["status"] == "open" # the epic-1 namesake, untouched def test_non_mapping_action_item_does_not_crash_the_selector(tmp_path): # A hand-edited scalar in the action_items list must be skipped, not # AttributeError'd into an empty stdout with a traceback. fixture = ( "development_status:\n" " 1-1-a: done\n" " epic-1-retrospective: optional\n" "action_items:\n" ' - "a bare string someone hand-edited in"\n' ' - id: "real"\n' " epic: 1\n" ' action: "Real item"\n' " status: open\n" ) target = tmp_path / "sprint-status.yaml" target.write_text(fixture, encoding="utf-8") proc = _run( ["update", "--file", str(target), "--epic", "1", "--set-action-status", '[{"id":"real","status":"done"}]'] ) assert proc.returncode == 0, proc.stderr assert _json(proc)["action_items_updated"] == 1 items = _load(target)["action_items"] assert items[0] == "a bare string someone hand-edited in" assert items[1]["status"] == "done" def test_non_mapping_action_item_stays_on_the_json_contract_when_unmatched(tmp_path): # Same guard, reject path: the scalar must not be dereferenced while looking # for a selector that is not there. fixture = ( "development_status:\n" " 1-1-a: done\n" "action_items:\n" " - 42\n" ) target = tmp_path / "sprint-status.yaml" target.write_text(fixture, encoding="utf-8") proc = _run( ["update", "--file", str(target), "--epic", "1", "--set-action-status", '[{"epic":1,"action":"Nothing","status":"done"}]'] ) assert proc.returncode == 1 out = _json(proc) # asserts stdout is JSON and stderr carries no traceback assert out["ok"] is False assert out["restored"] is True assert "no action item matches" in out["error"] assert target.read_text(encoding="utf-8") == fixture def test_boolean_epic_in_the_file_does_not_match_epic_one(tmp_path): # True == 1 in Python, so without the file-side bool guard a hand-edited # "epic: true" row would be silently rewritten by a selector aimed at epic 1. fixture = ( "development_status:\n" " 1-1-a: done\n" "action_items:\n" " - epic: true\n" ' action: "Boolean epic"\n' " status: open\n" ) target = tmp_path / "sprint-status.yaml" target.write_text(fixture, encoding="utf-8") proc = _run( ["update", "--file", str(target), "--epic", "1", "--set-action-status", '[{"epic":1,"action":"Boolean epic","status":"done"}]'] ) assert proc.returncode == 1 out = _json(proc) assert out["ok"] is False assert out["restored"] is True assert "no action item matches" in out["error"] assert target.read_text(encoding="utf-8") == fixture def test_status_write_preserves_every_scalar_style(tmp_path): # The status write must land on the line without re-styling it, whichever way # the file spells the scalar. target = tmp_path / "sprint-status.yaml" target.write_text(STYLE_FIXTURE, encoding="utf-8") payload = json.dumps( [ {"id": "double", "status": "done"}, {"id": "single", "status": "done"}, {"id": "plain", "status": "done"}, ] ) proc = _run( ["update", "--file", str(target), "--epic", "1", "--date", "01-01-2026 09:00", "--set-action-status", payload] ) assert proc.returncode == 0, proc.stderr assert _json(proc)["action_items_updated"] == 3 before = STYLE_FIXTURE.splitlines() after = target.read_text(encoding="utf-8").splitlines() assert len(before) == len(after) changed = [(b, a) for b, a in zip(before, after) if b != a] assert changed == [ (' status: "open"', ' status: "done"'), (" status: 'open'", " status: 'done'"), (" status: open", " status: done"), ], changed def test_action_status_vocabulary_is_exactly_the_three(): # bmad-sprint-planning is the authority. Widening this tuple would let the # script write a value sprint-planning's status view reports as illegal. assert _module().ACTION_STATUSES == ("open", "in-progress", "done") def test_empty_status_array_is_a_no_op(tmp_path): target = _write_action_fixture(tmp_path) proc = _run( ["update", "--file", str(target), "--epic", "1", "--date", "01-01-2026 09:00", "--set-action-status", "[]"] ) assert proc.returncode == 0, proc.stderr assert _json(proc)["action_items_updated"] == 0 assert target.read_text(encoding="utf-8") == ACTION_FIXTURE def test_in_progress_item_transitions_to_done(tmp_path): # Every other success case starts from "open"; the middle of the lifecycle # has to work too. target = _write_action_fixture(tmp_path) proc = _run( ["update", "--file", str(target), "--epic", "2", "--set-action-status", '[{"id":"epic-2-retro-item-1-y","status":"done"}]'] ) assert proc.returncode == 0, proc.stderr assert _json(proc)["action_items_updated"] == 1 assert _load(target)["action_items"][2]["status"] == "done" def test_action_items_updated_is_always_reported(tmp_path): # Consumers read the counter unconditionally, so it must be present even when # the flag was not passed. target = _write_fixture(tmp_path) proc = _run(["update", "--file", str(target), "--epic", "1", "--set-retro-done"]) assert proc.returncode == 0, proc.stderr assert _json(proc)["action_items_updated"] == 0 def test_post_write_status_mismatch_restores(tmp_path, monkeypatch, capsys): # The last line of defence: the written file is re-parsed and every targeted # item is checked. No CLI path can fake a mismatch, so the re-parse is # doctored directly. mod = _module() target = _write_action_fixture(tmp_path) real_load_yaml = mod._load_yaml calls = {"n": 0} def flaky(path): yaml, data = real_load_yaml(path) calls["n"] += 1 if calls["n"] == 2: # the post-write re-parse data["action_items"][0]["status"] = "open" return yaml, data monkeypatch.setattr(mod, "_load_yaml", flaky) args = mod.build_parser().parse_args( ["update", "--file", str(target), "--epic", "1", "--date", "01-01-2026 09:00", "--set-action-status", '[{"id":"epic-1-retro-item-1-x","status":"done"}]'] ) with pytest.raises(SystemExit) as excinfo: mod.cmd_update(args) assert excinfo.value.code == 1 out = json.loads(capsys.readouterr().out) assert out["ok"] is False assert out["restored"] is True assert "after write" in out["error"] assert target.read_text(encoding="utf-8") == ACTION_FIXTURE if __name__ == "__main__": sys.exit(pytest.main([__file__, "-q"]))