248 lines
8.6 KiB
Python
248 lines
8.6 KiB
Python
|
|
"""Tests for ``app.afk.phase_checklist`` — the live progress checklist.
|
||
|
|
|
||
|
|
``render(current, meta)`` is PURE: same inputs → byte-identical markdown, no I/O.
|
||
|
|
It draws the seven-phase lifecycle (worktree → tests-red → green → pushed → CI →
|
||
|
|
deployed → done) as a markdown task list, with phases *before* ``current`` checked
|
||
|
|
off, ``current`` marked in-progress, and later phases left empty.
|
||
|
|
|
||
|
|
Style matches the existing suite: plain ``assert`` functions, parametrized cases,
|
||
|
|
and a couple of full-output snapshots so the rendered shape is pinned, not just
|
||
|
|
its line count.
|
||
|
|
"""
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from app.afk.phase_checklist import render
|
||
|
|
from app.afk.types import Phase
|
||
|
|
|
||
|
|
|
||
|
|
# Lifecycle order, mirrored from the contract so a reordering of the enum that
|
||
|
|
# the renderer didn't track shows up as a test failure rather than silent drift.
|
||
|
|
PHASES_IN_ORDER = [
|
||
|
|
Phase.WORKTREE,
|
||
|
|
Phase.TESTS_RED,
|
||
|
|
Phase.GREEN,
|
||
|
|
Phase.PUSHED,
|
||
|
|
Phase.CI,
|
||
|
|
Phase.DEPLOYED,
|
||
|
|
Phase.DONE,
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# Structure: one line per phase, in order, always all seven.
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
def _checklist_lines(out: str) -> list[str]:
|
||
|
|
"""The markdown task-list lines (``- [ ]`` / ``- [x]`` ...), in order."""
|
||
|
|
return [ln for ln in out.splitlines() if ln.lstrip().startswith("- [")]
|
||
|
|
|
||
|
|
|
||
|
|
def test_renders_a_string():
|
||
|
|
assert isinstance(render(Phase.WORKTREE, {}), str)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("current", PHASES_IN_ORDER)
|
||
|
|
def test_every_phase_has_exactly_one_checklist_line(current):
|
||
|
|
lines = _checklist_lines(render(current, {}))
|
||
|
|
assert len(lines) == len(PHASES_IN_ORDER)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("current", PHASES_IN_ORDER)
|
||
|
|
def test_checklist_lines_are_in_lifecycle_order(current):
|
||
|
|
lines = _checklist_lines(render(current, {}))
|
||
|
|
# Each phase's human label appears, and in the lifecycle order.
|
||
|
|
positions = [
|
||
|
|
next(i for i, ln in enumerate(lines) if _has_label(ln, phase))
|
||
|
|
for phase in PHASES_IN_ORDER
|
||
|
|
]
|
||
|
|
assert positions == sorted(positions)
|
||
|
|
|
||
|
|
|
||
|
|
def _has_label(line: str, phase: Phase) -> bool:
|
||
|
|
"""Whether a checklist line carries ``phase``'s headline word (case-insensitive
|
||
|
|
substring — the test asserts the label is *present*, not its exact decoration)."""
|
||
|
|
return _phase_label(phase).lower() in line.lower()
|
||
|
|
|
||
|
|
|
||
|
|
def _phase_label(phase: Phase) -> str:
|
||
|
|
"""The headline word(s) the renderer must use for a phase. Loose on purpose:
|
||
|
|
the test asserts the label is *present*, not the exact decoration."""
|
||
|
|
return {
|
||
|
|
Phase.WORKTREE: "worktree",
|
||
|
|
Phase.TESTS_RED: "test",
|
||
|
|
Phase.GREEN: "green",
|
||
|
|
Phase.PUSHED: "push",
|
||
|
|
Phase.CI: "CI",
|
||
|
|
Phase.DEPLOYED: "deploy",
|
||
|
|
Phase.DONE: "done",
|
||
|
|
}[phase]
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# Check/in-progress/empty partitioning around ``current``.
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
def _classify(line: str) -> str:
|
||
|
|
"""Bucket a checklist line by its marker: 'done' ``[x]``, 'todo' ``[ ]``, or
|
||
|
|
'active' (anything else, e.g. an in-progress glyph)."""
|
||
|
|
body = line.lstrip()
|
||
|
|
if body.startswith("- [x]"):
|
||
|
|
return "done"
|
||
|
|
if body.startswith("- [ ]"):
|
||
|
|
return "todo"
|
||
|
|
return "active"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("idx,current", list(enumerate(PHASES_IN_ORDER)))
|
||
|
|
def test_earlier_checked_current_active_later_empty(idx, current):
|
||
|
|
lines = _checklist_lines(render(current, {}))
|
||
|
|
buckets = [_classify(ln) for ln in lines]
|
||
|
|
|
||
|
|
# Everything strictly before the current phase is checked off.
|
||
|
|
assert all(b == "done" for b in buckets[:idx]), buckets
|
||
|
|
|
||
|
|
if current is Phase.DONE:
|
||
|
|
# Terminal phase: the whole list is checked, nothing left active/empty.
|
||
|
|
assert all(b == "done" for b in buckets), buckets
|
||
|
|
else:
|
||
|
|
# The current phase is the single in-progress marker...
|
||
|
|
assert buckets[idx] == "active", buckets
|
||
|
|
assert buckets.count("active") == 1, buckets
|
||
|
|
# ...and every phase after it is still an empty checkbox.
|
||
|
|
assert all(b == "todo" for b in buckets[idx + 1 :]), buckets
|
||
|
|
|
||
|
|
|
||
|
|
def test_first_phase_has_nothing_checked_before_it():
|
||
|
|
lines = _checklist_lines(render(Phase.WORKTREE, {}))
|
||
|
|
assert _classify(lines[0]) == "active"
|
||
|
|
assert "done" not in [_classify(ln) for ln in lines]
|
||
|
|
|
||
|
|
|
||
|
|
def test_done_checks_every_phase_including_done():
|
||
|
|
lines = _checklist_lines(render(Phase.DONE, {}))
|
||
|
|
assert all(_classify(ln) == "done" for ln in lines)
|
||
|
|
# The DONE line itself is checked, not merely the ones before it.
|
||
|
|
done_line = next(ln for ln in lines if _has_label(ln, Phase.DONE))
|
||
|
|
assert _classify(done_line) == "done"
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# Active-phase emphasis: the current phase is visually distinguishable.
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
@pytest.mark.parametrize("current", [p for p in PHASES_IN_ORDER if p is not Phase.DONE])
|
||
|
|
def test_active_phase_line_differs_from_todo_and_done_markers(current):
|
||
|
|
lines = _checklist_lines(render(current, {}))
|
||
|
|
active = [ln for ln in lines if _classify(ln) == "active"]
|
||
|
|
assert len(active) == 1
|
||
|
|
# Not a plain checkbox in either state.
|
||
|
|
assert not active[0].lstrip().startswith("- [x]")
|
||
|
|
assert not active[0].lstrip().startswith("- [ ]")
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# meta rendering: optional context is surfaced, omission never explodes.
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
def test_meta_empty_does_not_raise_and_still_lists_phases():
|
||
|
|
out = render(Phase.GREEN, {})
|
||
|
|
assert _checklist_lines(out) # non-empty
|
||
|
|
|
||
|
|
|
||
|
|
def test_meta_issue_and_repo_appear_in_output():
|
||
|
|
out = render(Phase.GREEN, {"repo": "infra", "issue": 42})
|
||
|
|
assert "infra" in out
|
||
|
|
assert "42" in out
|
||
|
|
|
||
|
|
|
||
|
|
def test_meta_thread_id_appears_when_present():
|
||
|
|
out = render(Phase.PUSHED, {"thread_id": "thread-7"})
|
||
|
|
assert "thread-7" in out
|
||
|
|
|
||
|
|
|
||
|
|
def test_meta_thread_id_absent_is_silent():
|
||
|
|
out = render(Phase.PUSHED, {})
|
||
|
|
assert "thread-" not in out
|
||
|
|
|
||
|
|
|
||
|
|
def test_meta_fix_forward_attempt_surfaced():
|
||
|
|
out = render(Phase.CI, {"fix_forward_attempts": 3})
|
||
|
|
assert "3" in out
|
||
|
|
|
||
|
|
|
||
|
|
def test_meta_unknown_keys_are_ignored():
|
||
|
|
# An unexpected key must not crash or leak its raw value as a stray line.
|
||
|
|
out = render(Phase.WORKTREE, {"totally_unknown_field": "should-not-appear"})
|
||
|
|
assert "should-not-appear" not in out
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# Determinism + idempotence (it's pure).
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
@pytest.mark.parametrize("current", PHASES_IN_ORDER)
|
||
|
|
def test_render_is_deterministic(current):
|
||
|
|
meta = {"repo": "infra", "issue": 9, "thread_id": "thread-1"}
|
||
|
|
assert render(current, meta) == render(current, meta)
|
||
|
|
|
||
|
|
|
||
|
|
def test_render_does_not_mutate_meta():
|
||
|
|
meta = {"repo": "infra", "issue": 1}
|
||
|
|
before = dict(meta)
|
||
|
|
render(Phase.GREEN, meta)
|
||
|
|
assert meta == before
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# Snapshots: pin the exact rendered shape for two representative phases. If the
|
||
|
|
# format changes intentionally, update these strings; an accidental change to
|
||
|
|
# wording/markers/order fails here loudly.
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
WORKTREE_SNAPSHOT = """\
|
||
|
|
### infra#7 — AFK run progress
|
||
|
|
|
||
|
|
- [~] Worktree created
|
||
|
|
- [ ] Failing test written (TDD red)
|
||
|
|
- [ ] Implementation passing (TDD green)
|
||
|
|
- [ ] Pushed to master
|
||
|
|
- [ ] CI green on pushed commit
|
||
|
|
- [ ] Deployed / rolled out
|
||
|
|
- [ ] Done — issue closed
|
||
|
|
"""
|
||
|
|
|
||
|
|
|
||
|
|
def test_snapshot_worktree_phase():
|
||
|
|
out = render(Phase.WORKTREE, {"repo": "infra", "issue": 7})
|
||
|
|
assert out == WORKTREE_SNAPSHOT
|
||
|
|
|
||
|
|
|
||
|
|
CI_SNAPSHOT = """\
|
||
|
|
### infra#7 — AFK run progress (thread thread-3)
|
||
|
|
|
||
|
|
- [x] Worktree created
|
||
|
|
- [x] Failing test written (TDD red)
|
||
|
|
- [x] Implementation passing (TDD green)
|
||
|
|
- [x] Pushed to master
|
||
|
|
- [~] CI green on pushed commit
|
||
|
|
- [ ] Deployed / rolled out
|
||
|
|
- [ ] Done — issue closed
|
||
|
|
"""
|
||
|
|
|
||
|
|
|
||
|
|
def test_snapshot_ci_phase_with_thread():
|
||
|
|
out = render(Phase.CI, {"repo": "infra", "issue": 7, "thread_id": "thread-3"})
|
||
|
|
assert out == CI_SNAPSHOT
|
||
|
|
|
||
|
|
|
||
|
|
DONE_SNAPSHOT = """\
|
||
|
|
### infra#7 — AFK run progress
|
||
|
|
|
||
|
|
- [x] Worktree created
|
||
|
|
- [x] Failing test written (TDD red)
|
||
|
|
- [x] Implementation passing (TDD green)
|
||
|
|
- [x] Pushed to master
|
||
|
|
- [x] CI green on pushed commit
|
||
|
|
- [x] Deployed / rolled out
|
||
|
|
- [x] Done — issue closed
|
||
|
|
"""
|
||
|
|
|
||
|
|
|
||
|
|
def test_snapshot_done_phase():
|
||
|
|
out = render(Phase.DONE, {"repo": "infra", "issue": 7})
|
||
|
|
assert out == DONE_SNAPSHOT
|