Files
2026-08-14 17:08:24 +02:00

525 lines
21 KiB
Python

# /// script
# requires-python = ">=3.10"
# dependencies = ["pytest>=8.0", "ruamel.yaml>=0.18"]
# ///
"""Tests for sprint_plan.py — deterministic sprint-status generation.
Run: uv run scripts/tests/test_sprint_plan.py
or: uv run --with pytest --with ruamel.yaml -m pytest scripts/tests/test_sprint_plan.py
"""
import importlib.util
import json
import sys
from pathlib import Path
import pytest
from ruamel.yaml import YAML
SCRIPT = Path(__file__).resolve().parents[1] / "sprint_plan.py"
TEMPLATE = Path(__file__).resolve().parents[2] / "sprint-status-template.yaml"
spec = importlib.util.spec_from_file_location("sprint_plan", SCRIPT)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
EPICS_FIXTURE = """\
# Project Epics
## Epic 1: Foundation
Some prose.
### Story 1.1: User Authentication
Acceptance criteria...
### Story 1.2: Account Management
## Epic 2: Chat
### Story 2.1: Personality System
### Story 2.6a: Split Story, With Punctuation!
"""
DATE = "08-01-2026 14:30"
def run_generate(tmp_path, epics_text=EPICS_FIXTURE, existing=None, stories=(), extra=()):
epic_file = tmp_path / "epics.md"
epic_file.write_text(epics_text, encoding="utf-8")
status_file = tmp_path / "impl" / "sprint-status.yaml"
if existing is not None:
status_file.parent.mkdir(parents=True, exist_ok=True)
status_file.write_text(existing, encoding="utf-8")
stories_dir = tmp_path / "impl"
stories_dir.mkdir(parents=True, exist_ok=True)
for name in stories:
(stories_dir / f"{name}.md").write_text("story", encoding="utf-8")
argv = [
"generate", "--epic-file", str(epic_file), "--status-file", str(status_file),
"--stories-dir", str(stories_dir), "--project", "My Project", "--date", DATE,
*extra,
]
mod.main(argv)
return status_file
def load(status_file):
yaml = YAML()
with open(status_file, encoding="utf-8") as fh:
return yaml.load(fh)
def out_json(capsys):
return json.loads(capsys.readouterr().out)
def test_fresh_generate_orders_and_defaults(tmp_path, capsys):
status_file = run_generate(tmp_path)
result = out_json(capsys)
data = load(status_file)
keys = list(data["development_status"].keys())
assert keys == [
"epic-1", "1-1-user-authentication", "1-2-account-management", "epic-1-retrospective",
"epic-2", "2-1-personality-system", "2-6a-split-story-with-punctuation", "epic-2-retrospective",
]
assert data["development_status"]["epic-1"] == "backlog"
assert data["development_status"]["1-1-user-authentication"] == "backlog"
assert data["development_status"]["epic-1-retrospective"] == "optional"
assert data["project"] == "My Project"
assert data["generated"] == DATE and data["last_updated"] == DATE
assert result["ok"] and result["epics"] == 2 and result["stories"] == 4
text = status_file.read_text(encoding="utf-8")
assert "STATUS DEFINITIONS" in text
assert "\n\n epic-2:" in text # blank line between epic groups
def test_header_comment_matches_template():
"""The template's STATUS DEFINITIONS block and the script's HEADER_COMMENT
are two copies of one contract; this pins them together."""
lines = TEMPLATE.read_text(encoding="utf-8").splitlines()
start = lines.index("# STATUS DEFINITIONS:")
block = []
for line in lines[start:]:
if not line.startswith("#"):
break
block.append(line[2:] if line.startswith("# ") else line[1:])
assert "\n".join(block) + "\n" == mod.HEADER_COMMENT
EXISTING = """\
generated: 01-01-2026 09:00
last_updated: 01-01-2026 09:00
project: My Project
project_key: NOKEY
tracking_system: file-system
story_location: impl
development_status:
epic-1: in-progress
1-1-user-authentication: done
1-2-account-management: backlog
epic-1-retrospective: optional
9-9-ghost-story: done
action_items:
- epic: 1
action: "Add error-handling review; watch: quotes, commas"
owner: "Charlie"
status: open
"""
def test_merge_preserves_and_never_downgrades(tmp_path, capsys):
status_file = run_generate(tmp_path, existing=EXISTING)
result = out_json(capsys)
data = load(status_file)
assert data["development_status"]["epic-1"] == "in-progress"
assert data["development_status"]["1-1-user-authentication"] == "done"
assert data["generated"] == "01-01-2026 09:00"
assert data["last_updated"] == DATE
assert result["dropped_orphans"] == [{"key": "9-9-ghost-story", "status": "done"}]
assert "9-9-ghost-story" not in data["development_status"]
def test_legacy_statuses_merge_by_meaning_not_reset(tmp_path, capsys):
existing = (EXISTING
.replace("1-2-account-management: backlog", "1-2-account-management: drafted")
.replace("epic-1: in-progress", "epic-1: contexted"))
status_file = run_generate(tmp_path, existing=existing)
result = out_json(capsys)
data = load(status_file)
assert data["development_status"]["1-2-account-management"] == "ready-for-dev"
assert data["development_status"]["epic-1"] == "in-progress"
assert {"key": "1-2-account-management", "from": "drafted", "to": "ready-for-dev"} in result["legacy_mapped"]
assert not any("illegal" in w for w in result["warnings"])
assert result["illegal"] == []
def test_metadata_preserved_when_flags_omitted(tmp_path, capsys):
existing = (EXISTING
.replace("project_key: NOKEY", "project_key: JIRA-PROJ")
.replace("tracking_system: file-system", "tracking_system: jira")
.replace("story_location: impl", "story_location: /custom/stories"))
status_file = run_generate(tmp_path, existing=existing)
out_json(capsys)
data = load(status_file)
assert data["project_key"] == "JIRA-PROJ"
assert data["tracking_system"] == "jira"
assert data["story_location"] == "/custom/stories"
def test_unknown_keys_and_comments_survive_regenerate(tmp_path, capsys):
existing = EXISTING + "\n# my own note\nsprint_goal: Ship the beta\n"
status_file = run_generate(tmp_path, existing=existing)
out_json(capsys)
data = load(status_file)
assert data["sprint_goal"] == "Ship the beta"
assert "# my own note" in status_file.read_text(encoding="utf-8")
def test_fresh_rebuild_ignores_existing_statuses(tmp_path, capsys):
status_file = run_generate(tmp_path, existing=EXISTING, extra=("--fresh",))
result = out_json(capsys)
data = load(status_file)
assert data["development_status"]["1-1-user-authentication"] == "backlog"
assert data["development_status"]["epic-1"] == "backlog"
assert result["fresh"] is True
# action_items are retro history, not tracking state — carried even on --fresh
assert data["action_items"][0]["status"] == "open"
def test_set_applies_explicit_statuses_even_downgrades(tmp_path, capsys):
status_file = run_generate(
tmp_path, existing=EXISTING,
extra=("--fresh", "--set", "1-1-user-authentication=in-progress", "--set", "epic-1=in-progress"),
)
result = out_json(capsys)
data = load(status_file)
assert data["development_status"]["1-1-user-authentication"] == "in-progress"
assert data["development_status"]["epic-1"] == "in-progress"
assert "1-1-user-authentication=in-progress" in result["explicit_set"]
def test_set_rejects_unknown_key_and_illegal_status(tmp_path, capsys):
with pytest.raises(SystemExit) as excinfo:
run_generate(tmp_path, extra=("--set", "9-9-nope=done"))
assert excinfo.value.code == 1
assert out_json(capsys)["ok"] is False
with pytest.raises(SystemExit):
run_generate(tmp_path, extra=("--set", "epic-1=review"))
assert out_json(capsys)["ok"] is False
def test_action_items_carried_verbatim(tmp_path):
status_file = run_generate(tmp_path, existing=EXISTING)
data = load(status_file)
assert data["action_items"][0]["action"] == "Add error-handling review; watch: quotes, commas"
assert data["action_items"][0]["status"] == "open"
def test_story_file_on_disk_floors_ready_for_dev(tmp_path, capsys):
status_file = run_generate(tmp_path, stories=["1-2-account-management"])
result = out_json(capsys)
data = load(status_file)
assert data["development_status"]["1-2-account-management"] == "ready-for-dev"
assert data["development_status"]["1-1-user-authentication"] == "backlog"
assert result["upgraded_from_disk"] == ["1-2-account-management"]
def test_story_file_never_downgrades_done(tmp_path):
status_file = run_generate(tmp_path, existing=EXISTING, stories=["1-1-user-authentication"])
data = load(status_file)
assert data["development_status"]["1-1-user-authentication"] == "done"
def test_illegal_existing_status_warns_and_resets(tmp_path, capsys):
existing = EXISTING.replace("1-2-account-management: backlog", "1-2-account-management: shipped")
status_file = run_generate(tmp_path, existing=existing)
result = out_json(capsys)
data = load(status_file)
assert data["development_status"]["1-2-account-management"] == "backlog"
assert any("illegal status 'shipped'" in w for w in result["warnings"])
assert {"key": "1-2-account-management", "status": "shipped"} in result["illegal"]
def test_fenced_code_blocks_are_not_parsed(tmp_path, capsys):
text = EPICS_FIXTURE + "\n```\n## Epic 9: Example Format\n### Story 9.1: Sample\n```\n"
status_file = run_generate(tmp_path, epics_text=text)
result = out_json(capsys)
data = load(status_file)
assert "epic-9" not in data["development_status"]
assert result["epics"] == 2
assert not any("Epic 9" in w for w in result["warnings"])
def test_non_ascii_titles_keep_distinct_keys(tmp_path, capsys):
text = "## Epic 1: 基础\n### Story 1.1: 用户认证\n### Story 1.2: 账户管理\n"
status_file = run_generate(tmp_path, epics_text=text)
out_json(capsys)
keys = list(load(status_file)["development_status"].keys())
assert "1-1-用户认证" in keys and "1-2-账户管理" in keys
def test_suspect_heading_is_reported(tmp_path, capsys):
text = EPICS_FIXTURE + "\n### Story Two point one: Bad Format\n"
run_generate(tmp_path, epics_text=text)
result = out_json(capsys)
assert any("unparsed Epic/Story-like heading" in w for w in result["warnings"])
def test_dry_run_writes_nothing_and_reports_drift(tmp_path, capsys):
epic_file = tmp_path / "epics.md"
epic_file.write_text(EPICS_FIXTURE, encoding="utf-8")
status_file = tmp_path / "sprint-status.yaml"
status_file.write_text(EXISTING, encoding="utf-8")
original = status_file.read_bytes()
mod.main([
"generate", "--epic-file", str(epic_file), "--status-file", str(status_file),
"--stories-dir", str(tmp_path), "--project", "P", "--date", DATE, "--dry-run",
])
result = out_json(capsys)
assert result["dry_run"] is True and result["ok"] is True
assert result["in_sync"] is False
assert "epic-2" in result["new_entries"]
assert result["dropped_orphans"] == [{"key": "9-9-ghost-story", "status": "done"}]
assert status_file.read_bytes() == original
def test_dry_run_in_sync_after_generate(tmp_path, capsys):
status_file = run_generate(tmp_path)
capsys.readouterr()
mod.main([
"generate", "--epic-file", str(tmp_path / "epics.md"), "--status-file", str(status_file),
"--stories-dir", str(tmp_path / "impl"), "--project", "My Project", "--date", DATE,
"--dry-run",
])
result = out_json(capsys)
assert result["in_sync"] is True
assert result["new_entries"] == [] and result["dropped_orphans"] == [] and result["illegal"] == []
def test_no_epics_fails_with_json(tmp_path, capsys):
epic_file = tmp_path / "notes.md"
epic_file.write_text("just prose, no epics", encoding="utf-8")
with pytest.raises(SystemExit) as excinfo:
mod.main([
"generate", "--epic-file", str(epic_file), "--status-file", str(tmp_path / "s.yaml"),
"--stories-dir", str(tmp_path), "--project", "P", "--date", DATE,
])
assert excinfo.value.code == 1
assert out_json(capsys)["ok"] is False
def test_non_mapping_yaml_fails_with_json(tmp_path, capsys):
epic_file = tmp_path / "epics.md"
epic_file.write_text(EPICS_FIXTURE, encoding="utf-8")
status_file = tmp_path / "sprint-status.yaml"
status_file.write_text("- just\n- a\n- list\n", encoding="utf-8")
with pytest.raises(SystemExit) as excinfo:
mod.main([
"generate", "--epic-file", str(epic_file), "--status-file", str(status_file),
"--stories-dir", str(tmp_path), "--project", "P", "--date", DATE,
])
assert excinfo.value.code == 1
assert "not a mapping" in out_json(capsys)["error"]
with pytest.raises(SystemExit):
mod.main(["status", "--status-file", str(status_file)])
assert "not a mapping" in out_json(capsys)["error"]
def test_argument_errors_emit_json(capsys):
with pytest.raises(SystemExit) as excinfo:
mod.main(["generate"])
assert excinfo.value.code == 2
assert out_json(capsys)["ok"] is False
with pytest.raises(SystemExit) as excinfo:
mod.main(["-h"])
assert excinfo.value.code == 2
assert out_json(capsys)["ok"] is False
STATUS_FIXTURE = """\
generated: 01-01-2026 09:00
last_updated: 07-30-2026 09:00
project: My Project
project_key: NOKEY
tracking_system: file-system
story_location: impl
development_status:
epic-1: in-progress
1-1-user-authentication: done
1-2-account-management: drafted
epic-1-retrospective: optional
epic-2: backlog
2-1-personality-system: backlog
epic-2-retrospective: optional
action_items:
- epic: 1
action: "Tighten error handling"
owner: "Charlie"
status: open
- epic: 1
action: "Old item"
owner: "Charlie"
status: done
"""
def run_status(tmp_path, capsys, fixture=STATUS_FIXTURE, extra=()):
status_file = tmp_path / "sprint-status.yaml"
status_file.write_text(fixture, encoding="utf-8")
mod.main(["status", "--status-file", str(status_file), "--date", DATE, *extra])
return json.loads(capsys.readouterr().out)
def test_status_counts_and_recommendation(tmp_path, capsys):
result = run_status(tmp_path, capsys)
assert result["stories"] == {"done": 1, "ready-for-dev": 1, "backlog": 1}
assert result["epics"] == {"in-progress": 1, "backlog": 1}
assert result["retrospectives"] == {"optional": 2}
assert result["recommendation"]["skill"] == "bmad-build"
assert result["recommendation"]["story_key"] == "1-2-account-management"
assert result["all_done"] is False
def test_status_maps_legacy_values(tmp_path, capsys):
result = run_status(tmp_path, capsys)
assert {"key": "1-2-account-management", "from": "drafted", "to": "ready-for-dev"} in result["legacy_mapped"]
def test_status_open_action_items(tmp_path, capsys):
result = run_status(tmp_path, capsys)
assert len(result["open_action_items"]) == 1
assert result["open_action_items"][0]["action"] == "Tighten error handling"
def test_status_malformed_action_items_are_flagged_not_dropped(tmp_path, capsys):
fixture = STATUS_FIXTURE + " - \"just a string\"\n - epic: 2\n action: \"No status\"\n"
result = run_status(tmp_path, capsys, fixture=fixture)
assert any("not a mapping" in w for w in result["warnings"])
assert any("missing or unknown status" in w for w in result["warnings"])
assert len(result["open_action_items"]) == 1
def test_status_review_beats_ready(tmp_path, capsys):
fixture = STATUS_FIXTURE.replace("2-1-personality-system: backlog", "2-1-personality-system: review")
result = run_status(tmp_path, capsys, fixture=fixture)
assert result["recommendation"]["skill"] == "bmad-code-review"
assert result["recommendation"]["story_key"] == "2-1-personality-system"
assert any("review" in r for r in result["risks"])
def test_status_in_progress_beats_all(tmp_path, capsys):
fixture = STATUS_FIXTURE.replace("2-1-personality-system: backlog", "2-1-personality-system: in-progress")
result = run_status(tmp_path, capsys, fixture=fixture)
assert result["recommendation"]["skill"] == "bmad-build"
assert result["recommendation"]["story_key"] == "2-1-personality-system"
assert result["recommendation"]["reason"] == "resume the in-progress story"
def test_status_staleness_and_orphan_risks(tmp_path, capsys):
fixture = (STATUS_FIXTURE
.replace("last_updated: 07-30-2026 09:00", "last_updated: 01-02-2026 09:00")
.replace(" epic-2-retrospective: optional",
" epic-2-retrospective: optional\n 5-1-ghost: backlog"))
result = run_status(tmp_path, capsys, fixture=fixture)
assert any("stale" in r for r in result["risks"])
assert any("orphaned story '5-1-ghost'" in r for r in result["risks"])
def test_status_unparseable_timestamp_warns_instead_of_silence(tmp_path, capsys):
fixture = STATUS_FIXTURE.replace("last_updated: 07-30-2026 09:00", "last_updated: whenever")
result = run_status(tmp_path, capsys, fixture=fixture)
assert any("staleness check skipped" in w for w in result["warnings"])
def test_status_iso_and_date_typed_stamps_do_not_crash(tmp_path, capsys):
fixture = (STATUS_FIXTURE
.replace("generated: 01-01-2026 09:00", "generated: 2026-01-01")
.replace("last_updated: 07-30-2026 09:00", "last_updated: 2026-01-02"))
result = run_status(tmp_path, capsys, fixture=fixture)
assert result["ok"] is True
assert result["generated"] == "2026-01-01"
assert any("stale" in r for r in result["risks"]) # ISO stamp still parses
def test_status_all_done_recommends_retro_then_nothing(tmp_path, capsys):
fixture = (STATUS_FIXTURE
.replace("1-2-account-management: drafted", "1-2-account-management: done")
.replace("2-1-personality-system: backlog", "2-1-personality-system: done"))
result = run_status(tmp_path, capsys, fixture=fixture)
assert result["recommendation"]["skill"] == "bmad-retrospective"
assert "epic-1-retrospective" in result["recommendation"]["reason"]
fixture_done = fixture.replace("epic-1-retrospective: optional", "epic-1-retrospective: done") \
.replace("epic-2-retrospective: optional", "epic-2-retrospective: done")
result = run_status(tmp_path, capsys, fixture=fixture_done)
assert result["all_done"] is True and result["recommendation"] is None
def test_status_odd_retro_key_reports_instead_of_crashing(tmp_path, capsys):
fixture = (STATUS_FIXTURE
.replace("1-2-account-management: drafted", "1-2-account-management: done")
.replace("2-1-personality-system: backlog", "2-1-personality-system: done")
.replace("epic-1-retrospective: optional", "epic-1-retrospective: done")
.replace("epic-2-retrospective: optional",
"epic-2-retrospective: done\n epic-abc-retrospective: optional"))
result = run_status(tmp_path, capsys, fixture=fixture)
assert result["ok"] is True
assert {"key": "epic-abc-retrospective", "status": "optional"} in result["unrecognized"]
assert any("unrecognized key" in r for r in result["risks"])
def test_status_illegal_status_reported(tmp_path, capsys):
fixture = STATUS_FIXTURE.replace("2-1-personality-system: backlog", "2-1-personality-system: shipped")
result = run_status(tmp_path, capsys, fixture=fixture)
assert {"key": "2-1-personality-system", "status": "shipped"} in result["illegal"]
def test_status_missing_file_fails_json(tmp_path, capsys):
with pytest.raises(SystemExit) as excinfo:
mod.main(["status", "--status-file", str(tmp_path / "nope.yaml")])
assert excinfo.value.code == 1
assert json.loads(capsys.readouterr().out)["ok"] is False
def run_validate(tmp_path, capsys, content):
status_file = tmp_path / "sprint-status.yaml"
if content is not None:
status_file.write_text(content, encoding="utf-8")
mod.main(["validate", "--status-file", str(status_file)])
return json.loads(capsys.readouterr().out)
def test_validate_clean_file(tmp_path, capsys):
clean = STATUS_FIXTURE.replace("1-2-account-management: drafted", "1-2-account-management: backlog")
result = run_validate(tmp_path, capsys, clean)
assert result["valid"] is True and result["problems"] == []
def test_validate_reports_problems_without_crashing(tmp_path, capsys):
broken = (STATUS_FIXTURE
.replace("2-1-personality-system: backlog", "2-1-personality-system: shipped")
.replace("epic-2-retrospective: optional",
"epic-2-retrospective: optional\n weird-key: done")
.replace("last_updated: 07-30-2026 09:00", "last_updated: whenever"))
result = run_validate(tmp_path, capsys, broken)
assert result["valid"] is False
assert any("illegal story status 'shipped'" in p for p in result["problems"])
assert any("unrecognized key 'weird-key'" in p for p in result["problems"])
assert any("'last_updated' timestamp" in p for p in result["problems"])
assert {"key": "1-2-account-management", "from": "drafted", "to": "ready-for-dev"} in result["legacy_mapped"]
def test_validate_missing_file_and_bad_yaml(tmp_path, capsys):
result = run_validate(tmp_path, capsys, None)
assert result["valid"] is False and "does not exist" in result["problems"][0]
result = run_validate(tmp_path, capsys, "development_status: [unclosed\n")
assert result["valid"] is False and "not valid YAML" in result["problems"][0]
result = run_validate(tmp_path, capsys, "- a\n- b\n")
assert result["valid"] is False and any("not a mapping" in p for p in result["problems"])
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-q"]))