diff --git a/AGENTS.md b/AGENTS.md index f75f6e9..01457f7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -737,7 +737,7 @@ Every header field, and who writes it: | --- | --- | --- | --- | | **Status** | yes | `Backlog` · `To Do` · `In Progress` · `Review` · `Done` (`Archived` for a card in `tasks/archive/`) | you, or the board on a move | | **Priority** | yes | `High` · `Medium` · `Low`, optionally followed by a short justification | you | -| **Type** | no | `Discovery` · `Bug` · `Feature` · `Refactor` · `Chore` | you | +| **Type** | no | `Discovery` · `Bug` · `Feature` · `Refactor` · `Chore` · `Phase` | you | | **Assignee** | no | a name, taken from `git config user.name` | the board on a claiming move, or you by hand | | **Depends on** | no | task numbers or external preconditions, comma-separated | you | | **PR** | no | the pull request url | the board when it opens one | @@ -751,13 +751,50 @@ An optional **Type** line can record what kind of work the task is, when that isn't obvious from the title: ```markdown -**Type:** Discovery | Bug | Feature | Refactor | Chore +**Type:** Discovery | Bug | Feature | Refactor | Chore | Phase ``` Type is orthogonal to status. A discovery task — research, scoping, spiking an approach — moves through the same five stages as everything else; "discovery" describes the work, not where it sits on the board. +### A phase is a card that lists its cards + +A **phase** is a group of related tasks meant to run one after another. It is +not a directory, a stage or a registry — it is a task card like any other, +marked `**Type:** Phase`, with a `## Cards` section naming its members in the +order they run: + +```markdown +## Cards + +- 31 — Stand up site/ and its build +- 32 — Serve it from a Cloudflare Worker +- 33 — The landing page +``` + +The name of the phase is the card itself, its number and title, so nothing is +named twice. Document order is run order. The number is what is parsed — `31`, +`#31` and `031` are the same card — and whatever follows it is for the reader, +never matched against anything. + +Membership runs one direction only: the phase card lists its members, and a +member card says nothing about phases. So membership cannot disagree with +itself, there is exactly one place to edit when it changes, and a member's +phase and position are *derived* — a `⟶ 3/5` chip in the card's footer +row, beside `CI` and `PR ↗`, opening the phase card. + +What the list does not resolve is flagged on the card, in the same spirit as +`status drift`: a number no card has, a card two phase cards both list (both +are flagged), a card one phase lists twice, a line naming no number, and a +phase listed by a phase — phases do not nest. Each is an authoring mistake +that would otherwise surface later as a runner behaving oddly. + +`**Depends on:**` is the other half, and it guards rather than orders: the +list says what runs next, a member's dependencies say whether it *may*. The +board parses the numbers out of the line and shows them; acting on them +belongs to whatever runs a phase. + An optional **Assignee** line records who holds the card: ```markdown @@ -777,7 +814,9 @@ prose asides: **Depends on:** 03, 05 ``` -The board does not enforce it; it informs whoever picks the next card. +The board parses the task numbers out of the line (prose preconditions are +left for the reader) and nothing enforces them yet: they inform whoever picks +the next card, and guard what a phase may start. The rest of the file is freeform — description, research findings, approach, open questions, whatever is relevant to the current stage. diff --git a/manager/core/board.html b/manager/core/board.html index 9fb67cc..2e1cab7 100644 --- a/manager/core/board.html +++ b/manager/core/board.html @@ -976,6 +976,14 @@ function renderBoard() { renderDrawer(); } +/* A phase is named by its card, so the chip says what the card says — + minus the number it usually opens with (the chip's own tooltip carries + that), and clipped to a chip's width. The whole name is one hover away. */ +function phaseLabel(phase) { + const name = phase.title ? phase.title.replace(/^\s*\d+\s*[—–-]\s*/, '') : phase.file; + return name.length > 22 ? name.slice(0, 21) + '…' : name; +} + function cardFor(task) { const el = document.createElement('article'); const agent = agentOnTask(task.file); @@ -1026,6 +1034,10 @@ function cardFor(task) { '', high ? 'HIGH' : '', task.statusMismatch ? `drift` : '', + // a phase's list that does not resolve is an authoring mistake, and it + // is flagged in the same breath as status drift rather than swallowed + (task.phaseDrift || []).length + ? `phase drift` : '', `${pill.text}`, ]; @@ -1145,6 +1157,14 @@ function cardFor(task) { const detail = prState && prState.detail ? prState.detail : 'open the PR'; const hasBranch = (S.state.branches || []).includes(task.file.replace(/\.md$/, '')); const chips = []; + // where this card sits in its phase — derived from the phase card's own + // list, and a destination like the rest of the row: it opens that card + if (task.phase) { + chips.push({ label: `${phaseLabel(task.phase)} ${task.phase.index}/${task.phase.total}`, + pre: '⟶', cls: '', phase: task.phase.file, + title: `Card ${task.phase.index} of ${task.phase.total} in phase ` + + `${task.phase.number ? '#' + task.phase.number + ' — ' : ''}${task.phase.title}` }); + } if (prState && prState.ci) { chips.push({ label: 'CI', glyph: { pass: '✓', fail: '✕', running: '◌' }[prState.ci], cls: { pass: 'ok', fail: 'bad', running: 'accent' }[prState.ci], title: detail }); @@ -1208,10 +1228,12 @@ function cardFor(task) { const chipRow = chips.length ? '
' + chips.map(c => { const g = c.glyph ? `${c.glyph}` : ''; - if (c.href) return `${esc(c.label)}${g}`; - if (c.act) return ``; + const p = c.pre ? `${c.pre}` : ''; + if (c.href) return `${p}${esc(c.label)}${g}`; + if (c.act) return ``; + if (c.phase) return ``; if (c.cmd) return ``; - return `${esc(c.label)}${g}`; + return `${p}${esc(c.label)}${g}`; }).join('') + '
' : ''; @@ -1224,6 +1246,12 @@ function cardFor(task) { chipRow + driveWell + liveLine; el.querySelectorAll('a.chip2').forEach(a => a.addEventListener('click', (e) => e.stopPropagation())); + el.querySelectorAll('[data-phase]').forEach(btn => + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const phase = findTask(btn.dataset.phase); + if (phase) showDetail(phase); + })); el.querySelectorAll('[data-drive]').forEach(btn => btn.addEventListener('click', (e) => { e.stopPropagation(); diff --git a/manager/core/taskfiles.py b/manager/core/taskfiles.py index 515e997..0ffbf64 100644 --- a/manager/core/taskfiles.py +++ b/manager/core/taskfiles.py @@ -29,8 +29,17 @@ ASSIGNEE_RE = re.compile(r"^\*\*Assignee:\*\*\s*(.+?)\s*$", re.MULTILINE) ASSIGNEE_LINE_RE = re.compile(r"^\*\*Assignee:\*\*[^\n]*\n?", re.MULTILINE) PR_RE = re.compile(r"^\*\*PR:\*\*\s*(\S+)\s*$", re.MULTILINE) PR_VERDICT_RE = re.compile(r"^PR REVIEW:\s*(APPROVE|REQUEST CHANGES)", re.MULTILINE) +DEPENDS_RE = re.compile(r"^\*\*Depends on:\*\*\s*(.+?)\s*$", re.MULTILINE) NUMBER_RE = re.compile(r"^(\d+)[-_]") +# A phase is a card that lists its cards: `**Type:** Phase` plus a `## Cards` +# section naming its members, one per line, in the order they run. +PHASE_TYPE = "phase" +CARDS_SECTION_RE = re.compile(r"^##\s+Cards\s*$(.*?)(?=^##\s|\Z)", + re.MULTILINE | re.DOTALL) +CARD_ITEM_RE = re.compile(r"^(?:[-*+]\s+)?#?0*(\d+)\b") +DEPENDS_ITEM_RE = re.compile(r"#?\s*0*(\d+)") + STAGE_ORDER = {slug: index for index, (slug, _) in enumerate(config.STAGES)} CLAIM_FROM = {"backlog", "to-do"} # the unstarted stages: leaving one claims @@ -48,6 +57,53 @@ def _split_reason(value: str | None) -> tuple[str | None, str | None]: return parts[0].strip(), (parts[1].strip() if len(parts) > 1 else None) +def canonical_number(number: str) -> str: + """`07`, `7` and `#007` are one card. Numbers are written by hand in + prose (a `## Cards` line, a `Depends on:` header) and read against + numbers taken from filenames, so both ends canonicalise the same way.""" + return str(int(number)) + + +def _depends_on(text: str) -> list[str]: + """The `**Depends on:**` header, parsed at last — into the task numbers + it names, and nothing else. The line may also carry external + preconditions in prose ("the API key"); those are for the reader, so + only comma-separated items that are entirely a number are taken. + """ + line = _first(DEPENDS_RE, text) + if not line: + return [] + numbers = [] + for part in line.split(","): + match = DEPENDS_ITEM_RE.fullmatch(part.strip()) + if match: + numbers.append(canonical_number(match.group(1))) + return numbers + + +def _listed_cards(text: str) -> tuple[list[str], list[str]]: + """A phase card's `## Cards` section: the numbers it lists in document + order (which is run order), and the unindented lines that name none. + + The number is what is parsed; whatever follows it is for the reader and + is never matched against anything. Indented lines are a member's own + continuation, so they are neither members nor mistakes. + """ + section = CARDS_SECTION_RE.search(text) + if not section: + return [], [] + numbers, unreadable = [], [] + for line in section.group(1).splitlines(): + if not line.strip() or line[:1].isspace(): + continue + match = CARD_ITEM_RE.match(line.strip()) + if match: + numbers.append(canonical_number(match.group(1))) + else: + unreadable.append(line.strip()) + return numbers, unreadable + + def read_task(path: Path, stage: str) -> dict: text = path.read_text(encoding="utf-8", errors="replace") priority, priority_note = _split_reason(_first(PRIORITY_RE, text)) @@ -55,6 +111,11 @@ def read_task(path: Path, stage: str) -> dict: declared = _first(STATUS_RE, text) # the latest appended `PR REVIEW:` marker wins — reviews accumulate verdicts = PR_VERDICT_RE.findall(text) + kind = _split_reason(_first(TYPE_RE, text))[0] + is_phase = (kind or "").lower() == PHASE_TYPE + # a `## Cards` section means membership on a phase card and nothing at + # all anywhere else — one direction, one authority + listed, unreadable = _listed_cards(text) if is_phase else ([], []) return { "pr": _first(PR_RE, text), # who holds the card — written by the board when a move claims it @@ -67,7 +128,19 @@ def read_task(path: Path, stage: str) -> dict: "title": _first(TITLE_RE, text) or path.stem, "priority": priority, "priorityNote": priority_note, - "type": _split_reason(_first(TYPE_RE, text))[0], + "type": kind, + # What runs next is the phase's list; what may run is this line — + # parsed here, acted on nowhere yet. + "dependsOn": _depends_on(text), + # A phase and its members: `cards` is what this card claims (empty + # for everything that is not a phase), `members`, `phase` and the + # drift between them are derived across the whole board by + # `collect` — a member card says nothing about its phase. + "isPhase": is_phase, + "cards": listed, + "members": [], + "phase": None, + "phaseDrift": [f'"{line}" names no card number' for line in unreadable], # Flagged in the UI when the file's own Status line contradicts the # directory it is in — the board should never quietly paper over that. "declaredStatus": declared, @@ -79,6 +152,63 @@ def read_task(path: Path, stage: str) -> dict: } +def _phase_name(phase: dict) -> str: + return f"{phase['number'] or phase['file']} — {phase['title']}" + + +def weave_phases(stages: list[dict]) -> None: + """Resolve every phase card's list against the board it sits on. + + Membership is derived, never stored twice: the phase card lists its + members and this is where a member learns which phase holds it and + where in the run it sits. What cannot be resolved is *flagged* rather + than skipped — a number no card has, a card two phases both claim, a + card one phase lists twice — because each is an authoring mistake that + would otherwise surface much later as a runner behaving oddly. + """ + tasks = [task for stage in stages for task in stage["tasks"]] + by_number: dict[str, dict] = {} + for task in tasks: + if task["number"]: + by_number.setdefault(canonical_number(task["number"]), task) + + held: dict[str, dict] = {} # card number → the phase that already lists it + phases = sorted((t for t in tasks if t["isPhase"]), + key=lambda t: (int(t["number"]) if t["number"] else 9999, t["file"])) + for phase in phases: + members, seen = [], set() + for number in phase["cards"]: + member = by_number.get(number) + if member is None: + phase["phaseDrift"].append(f"{number} is listed here but no card has that number") + continue + if number in seen: + phase["phaseDrift"].append(f"{number} is listed twice by this phase") + continue + seen.add(number) + if member["isPhase"]: + phase["phaseDrift"].append(f"{number} is itself a phase — phases do not nest") + continue + owner = held.get(number) + if owner is not None: + # both phase cards wear it: from either one, the reader can + # see the collision without hunting for the other list + note = (f"{number} is listed by two phases — " + f"{_phase_name(owner)} and {_phase_name(phase)}") + owner["phaseDrift"].append(note) + phase["phaseDrift"].append(note) + member["phaseDrift"].append(note) + continue + held[number] = phase + members.append(member) + phase["members"] = [{"number": m["number"], "file": m["file"], + "title": m["title"], "stage": m["stage"]} for m in members] + for index, member in enumerate(members, start=1): + member["phase"] = {"file": phase["file"], "number": phase["number"], + "title": phase["title"], + "index": index, "total": len(members)} + + def collect() -> dict: stages = [] for slug, label in config.STAGES: @@ -89,6 +219,7 @@ def collect() -> dict: tasks.append(read_task(path, slug)) tasks.sort(key=lambda t: (int(t["number"]) if t["number"] else 9999, t["file"])) stages.append({"slug": slug, "label": label, "tasks": tasks}) + weave_phases(stages) extras = {} for slug in ("plans", "reference"): diff --git a/tests/test_phase_cards.py b/tests/test_phase_cards.py new file mode 100644 index 0000000..70549e9 --- /dev/null +++ b/tests/test_phase_cards.py @@ -0,0 +1,432 @@ +"""A phase is a card that lists its cards (task 48). + +`**Type:** Phase` plus a `## Cards` section is the whole model: the phase +card is the only authority on membership, a member's phase and position +are derived from it, and what the list does not resolve is flagged rather +than skipped. These tests run `taskfiles.collect()` over a throwaway +tasks/ directory — the same reading the board does on every request — and +then hold board.html to rendering what that reading produces. + + python3 -m unittest discover -s tests -v +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO / "manager" / "core")) + +import config # noqa: E402 +import taskfiles # noqa: E402 + +BOARD = REPO / "manager" / "core" / "board.html" +NODE = shutil.which("node") + + +def card(title: str, *, kind: str | None = None, depends: str | None = None, + cards: str | None = None, status: str = "Backlog") -> str: + """A task file as a person would write it.""" + text = f"# {title}\n\n**Status:** {status}\n**Priority:** Medium\n" + if kind: + text += f"**Type:** {kind}\n" + if depends: + text += f"**Depends on:** {depends}\n" + text += "\nWhat this card is for.\n" + if cards is not None: + text += f"\n## Cards\n{cards}\n" + return text + + +class PhaseReadingCase(unittest.TestCase): + """One tasks/ directory per test, read exactly as the board reads it.""" + + def setUp(self): + tmp = Path(tempfile.mkdtemp(prefix="bench-phase-")).resolve() + self.addCleanup(shutil.rmtree, tmp, True) + self.tasks = tmp / "tasks" + for slug in config.STAGE_DIRS: + (self.tasks / slug).mkdir(parents=True) + self.addCleanup(setattr, config, "TASKS", config.TASKS) + config.TASKS = self.tasks + self.addCleanup(setattr, config, "TM_ROOT", config.TM_ROOT) + config.TM_ROOT = tmp + + def write(self, filename: str, text: str, stage: str = "backlog") -> None: + (self.tasks / stage / filename).write_text(text, encoding="utf-8") + + def board(self) -> dict[str, dict]: + """Every card the board would show, keyed by filename.""" + return {task["file"]: task + for stage in taskfiles.collect()["stages"] + for task in stage["tasks"]} + + def members(self) -> None: + """Three ordinary cards, out of the order a phase will run them.""" + self.write("31-stand-up-site.md", card("31 — Stand up site/")) + self.write("32-serve-it.md", card("32 — Serve it from a Worker")) + self.write("33-landing.md", card("33 — The landing page")) + + +class ReadingAPhase(PhaseReadingCase): + def test_a_phase_lists_its_members_in_the_order_it_names_them(self): + self.members() + self.write("40-the-site.md", card("40 — Ship the site", kind="Phase", cards=( + "- 33 — The landing page\n" + "- 31 — Stand up site/\n" + "- 32 — Serve it from a Worker\n"))) + + phase = self.board()["40-the-site.md"] + + self.assertTrue(phase["isPhase"]) + self.assertEqual([m["file"] for m in phase["members"]], + ["33-landing.md", "31-stand-up-site.md", "32-serve-it.md"], + "document order is run order, not the board's own order") + self.assertEqual(phase["phaseDrift"], []) + self.assertIsNone(phase["phase"], "a phase card is not a member of anything") + + def test_a_member_learns_its_phase_and_its_position(self): + self.members() + self.write("40-the-site.md", card("40 — Ship the site", kind="Phase", cards=( + "- 31 — Stand up site/\n- 32 — Serve it from a Worker\n- 33 — The landing page\n"))) + + cards = self.board() + + self.assertEqual(cards["32-serve-it.md"]["phase"], + {"file": "40-the-site.md", "number": "40", + "title": "40 — Ship the site", "index": 2, "total": 3}) + self.assertEqual(cards["31-stand-up-site.md"]["phase"]["index"], 1) + self.assertEqual(cards["33-landing.md"]["phase"]["index"], 3) + + def test_membership_survives_the_cards_being_in_different_stages(self): + self.write("31-stand-up-site.md", card("31 — Stand up site/", status="Done"), "done") + self.write("32-serve-it.md", card("32 — Serve it", status="Review"), "review") + self.write("40-the-site.md", card("40 — Ship the site", kind="Phase", + cards="- 31 — Stand up site/\n- 32 — Serve it\n")) + + cards = self.board() + + self.assertEqual([m["stage"] for m in cards["40-the-site.md"]["members"]], + ["done", "review"]) + self.assertEqual(cards["32-serve-it.md"]["phase"]["index"], 2) + + def test_a_card_in_no_phase_is_unchanged(self): + self.members() + + loose = self.board()["31-stand-up-site.md"] + + self.assertIsNone(loose["phase"]) + self.assertEqual(loose["phaseDrift"], []) + self.assertFalse(loose["isPhase"]) + self.assertEqual(loose["members"], []) + self.assertEqual(loose["cards"], []) + + def test_every_way_of_writing_a_number_finds_the_same_card(self): + self.write("07-early.md", card("07 — An early card")) + self.write("31-stand-up-site.md", card("31 — Stand up site/")) + self.write("32-serve-it.md", card("32 — Serve it")) + self.write("40-the-site.md", card("40 — Ship the site", kind="Phase", cards=( + "- #31 — hash and dash\n" + "- 32 — em dash only\n" + "- 7\n"))) + + phase = self.board()["40-the-site.md"] + + self.assertEqual([m["file"] for m in phase["members"]], + ["31-stand-up-site.md", "32-serve-it.md", "07-early.md"]) + self.assertEqual(phase["phaseDrift"], []) + + def test_a_bare_list_without_bullets_reads_the_same(self): + self.members() + self.write("40-the-site.md", card("40 — Ship the site", kind="Phase", + cards="31\n32\n33\n")) + + self.assertEqual([m["number"] for m in self.board()["40-the-site.md"]["members"]], + ["31", "32", "33"]) + + def test_an_indented_continuation_is_neither_member_nor_mistake(self): + self.members() + self.write("40-the-site.md", card("40 — Ship the site", kind="Phase", cards=( + "- 31 — Stand up site/\n" + " the build has to land before anything is served\n" + "- 32 — Serve it\n"))) + + phase = self.board()["40-the-site.md"] + + self.assertEqual([m["number"] for m in phase["members"]], ["31", "32"]) + self.assertEqual(phase["phaseDrift"], []) + + def test_a_cards_section_on_an_ordinary_card_means_nothing(self): + self.members() + self.write("40-not-a-phase.md", card("40 — Just a card", kind="Feature", + cards="- 31 — Stand up site/\n")) + + cards = self.board() + + self.assertFalse(cards["40-not-a-phase.md"]["isPhase"]) + self.assertEqual(cards["40-not-a-phase.md"]["cards"], []) + self.assertIsNone(cards["31-stand-up-site.md"]["phase"]) + + +class EmptyPhases(PhaseReadingCase): + """A phase with nothing in it is a phase, not a failure.""" + + def test_a_phase_with_no_cards_section_reads_as_empty(self): + self.write("40-the-site.md", card("40 — Ship the site", kind="Phase")) + + phase = self.board()["40-the-site.md"] + + self.assertTrue(phase["isPhase"]) + self.assertEqual(phase["members"], []) + self.assertEqual(phase["phaseDrift"], []) + + def test_an_empty_cards_section_reads_as_empty(self): + self.write("40-the-site.md", card("40 — Ship the site", kind="Phase", cards="\n")) + + phase = self.board()["40-the-site.md"] + + self.assertTrue(phase["isPhase"]) + self.assertEqual(phase["members"], []) + self.assertEqual(phase["phaseDrift"], []) + + def test_the_section_ends_where_the_next_heading_begins(self): + self.members() + text = card("40 — Ship the site", kind="Phase", + cards="- 31 — Stand up site/\n") + "\n## Notes\n\n- 32 — not a member\n" + self.write("40-the-site.md", text) + + phase = self.board()["40-the-site.md"] + + self.assertEqual([m["number"] for m in phase["members"]], ["31"]) + self.assertEqual(phase["phaseDrift"], []) + + +class DriftIsFlagged(PhaseReadingCase): + """Every unresolvable line is an authoring mistake with a name.""" + + def test_a_number_no_card_has_is_flagged(self): + self.members() + self.write("40-the-site.md", card("40 — Ship the site", kind="Phase", + cards="- 31 — Stand up site/\n- 99 — nothing here\n")) + + phase = self.board()["40-the-site.md"] + + self.assertEqual([m["number"] for m in phase["members"]], ["31"]) + self.assertEqual(len(phase["phaseDrift"]), 1) + self.assertIn("99", phase["phaseDrift"][0]) + + def test_the_same_card_listed_twice_by_one_phase_is_flagged_once(self): + self.members() + self.write("40-the-site.md", card("40 — Ship the site", kind="Phase", cards=( + "- 31 — Stand up site/\n- 32 — Serve it\n- 31 — again\n"))) + + phase = self.board()["40-the-site.md"] + + self.assertEqual([m["number"] for m in phase["members"]], ["31", "32"]) + self.assertEqual(len(phase["phaseDrift"]), 1) + self.assertIn("twice", phase["phaseDrift"][0]) + + def test_a_card_two_phases_both_claim_flags_both(self): + self.members() + self.write("40-the-site.md", card("40 — Ship the site", kind="Phase", + cards="- 31 — Stand up site/\n- 32 — Serve it\n")) + self.write("41-the-docs.md", card("41 — Ship the docs", kind="Phase", + cards="- 32 — Serve it\n- 33 — The landing page\n")) + + cards = self.board() + + for filename in ("40-the-site.md", "41-the-docs.md"): + with self.subTest(phase=filename): + self.assertEqual(len(cards[filename]["phaseDrift"]), 1) + self.assertIn("two phases", cards[filename]["phaseDrift"][0]) + # the first phase to list it keeps it, so the member's chip is not + # a coin toss — and the member wears the collision too + self.assertEqual(cards["32-serve-it.md"]["phase"]["file"], "40-the-site.md") + self.assertEqual(len(cards["32-serve-it.md"]["phaseDrift"]), 1) + self.assertEqual([m["number"] for m in cards["41-the-docs.md"]["members"]], ["33"]) + + def test_a_line_naming_no_number_is_flagged(self): + self.members() + self.write("40-the-site.md", card("40 — Ship the site", kind="Phase", + cards="- 31 — Stand up site/\n- and then the rest\n")) + + phase = self.board()["40-the-site.md"] + + self.assertEqual([m["number"] for m in phase["members"]], ["31"]) + self.assertEqual(len(phase["phaseDrift"]), 1) + self.assertIn("and then the rest", phase["phaseDrift"][0]) + + def test_a_phase_listing_a_phase_is_flagged_rather_than_nested(self): + self.members() + self.write("41-the-docs.md", card("41 — Ship the docs", kind="Phase", + cards="- 33 — The landing page\n")) + self.write("40-the-site.md", card("40 — Ship the site", kind="Phase", + cards="- 31 — Stand up site/\n- 41 — Ship the docs\n")) + + cards = self.board() + + self.assertEqual([m["number"] for m in cards["40-the-site.md"]["members"]], ["31"]) + self.assertIn("nest", cards["40-the-site.md"]["phaseDrift"][0]) + self.assertIsNone(cards["41-the-docs.md"]["phase"]) + + +class DependsOnIsParsed(PhaseReadingCase): + """The guard, read at last: what may run, not what runs next.""" + + def test_the_line_becomes_a_list_of_numbers(self): + self.write("32-serve-it.md", card("32 — Serve it", depends="03, 05")) + + self.assertEqual(self.board()["32-serve-it.md"]["dependsOn"], ["3", "5"]) + + def test_a_hash_and_a_single_dependency_read_the_same(self): + self.write("32-serve-it.md", card("32 — Serve it", depends="#31")) + + self.assertEqual(self.board()["32-serve-it.md"]["dependsOn"], ["31"]) + + def test_prose_preconditions_are_left_for_the_reader(self): + self.write("32-serve-it.md", + card("32 — Serve it", depends="31, a Cloudflare account")) + + self.assertEqual(self.board()["32-serve-it.md"]["dependsOn"], ["31"]) + + def test_no_line_is_an_empty_list(self): + self.write("32-serve-it.md", card("32 — Serve it")) + + self.assertEqual(self.board()["32-serve-it.md"]["dependsOn"], []) + + def test_nothing_acts_on_it_yet(self): + """A member whose dependency is unfinished is still a member in the + position its phase gives it — card 49 decides what may start.""" + self.write("31-stand-up-site.md", card("31 — Stand up site/")) + self.write("32-serve-it.md", card("32 — Serve it", depends="31")) + self.write("40-the-site.md", card("40 — Ship the site", kind="Phase", + cards="- 31 — Stand up site/\n- 32 — Serve it\n")) + + cards = self.board() + + self.assertEqual(cards["32-serve-it.md"]["phase"]["index"], 2) + self.assertEqual(cards["40-the-site.md"]["phaseDrift"], []) + + +class TheChipOnAMemberCard(unittest.TestCase): + """board.html has no test runner, so these are source-level invariants: + the chip is built from the derived membership, sits in the footer row + with the other destinations, and opens the phase card.""" + + @classmethod + def setUpClass(cls): + cls.html = BOARD.read_text(encoding="utf-8") + + def chip_block(self) -> str: + start = self.html.index("if (task.phase) {") + return self.html[start:start + 600] + + def chip_row(self) -> str: + row = re.search(r"const chipRow = chips\.length.*?\.join\(''\) \+ ''", + self.html, re.S) + self.assertIsNotNone(row, "board.html lost its chip row") + return row.group(0) + + def test_the_chip_is_pushed_into_the_footer_chip_row(self): + block = self.chip_block() + self.assertIn("chips.push", block, "the phase chip belongs in the chip row") + self.assertIn("⟶", block) + self.assertIn("task.phase.index", block) + self.assertIn("task.phase.total", block) + + def test_the_chip_is_only_built_for_a_card_that_has_a_phase(self): + """A card in no phase shows no chip: the push is guarded.""" + self.assertIn("if (task.phase) {", self.html) + + def test_the_chip_opens_the_phase_card(self): + self.assertIn("phase: task.phase.file", self.chip_block(), + "the chip has to name the card it opens") + self.assertIn("data-phase", self.chip_row()) + handler = re.search(r"querySelectorAll\('\[data-phase\]'\).*?\}\)\);", + self.html, re.S) + self.assertIsNotNone(handler, "nothing wires the phase chip's click") + self.assertIn("findTask(btn.dataset.phase)", handler.group(0)) + self.assertIn("showDetail(", handler.group(0)) + self.assertIn("stopPropagation", handler.group(0), + "clicking the chip must not also open its own card") + + def test_a_chips_leading_glyph_is_rendered(self): + """The chip reads ⟶ phase n/total, so the row renders a glyph + before the label as well as after it.""" + row = self.chip_row() + self.assertIn("c.pre", row) + self.assertIn("${p}", row, "the leading glyph has to reach the markup") + + def test_phase_drift_is_flagged_beside_status_drift(self): + top = re.search(r"const top = \[.*?\];", self.html, re.S) + self.assertIsNotNone(top, "board.html lost the card's top row") + self.assertIn("phaseDrift", top.group(0)) + self.assertIn("phase drift", top.group(0)) + self.assertIn('class="pill drift"', top.group(0), + "phase drift wears the same flag status drift does") + + +@unittest.skipUnless(NODE, "node is needed to run the page's own phaseLabel()") +class TheNameOnTheChip(unittest.TestCase): + """`phaseLabel()` is a pure function of the phase, so — as with the + drawer's `md()` — it is lifted out of the page and run for real.""" + + @classmethod + def setUpClass(cls): + html = BOARD.read_text(encoding="utf-8") + match = re.search(r"^function phaseLabel\(phase\) \{\n.*?\n\}\n", html, + re.M | re.S) + assert match, "board.html lost its phaseLabel()" + cls._dir = tempfile.TemporaryDirectory() + cls.js = Path(cls._dir.name) / "label.js" + cls.js.write_text(match.group(0) + "process.stdout.write(phaseLabel(" + "JSON.parse(require('fs').readFileSync(0, 'utf8'))));\n", + encoding="utf-8") + + @classmethod + def tearDownClass(cls): + cls._dir.cleanup() + + def label(self, **phase) -> str: + out = subprocess.run([NODE, str(self.js)], input=json.dumps(phase), + capture_output=True, text=True) + self.assertEqual(out.returncode, 0, out.stderr) + return out.stdout + + def test_the_number_the_title_opens_with_is_left_to_the_tooltip(self): + self.assertEqual(self.label(title="40 — Ship the site", file="40-site.md"), + "Ship the site") + + def test_a_title_without_a_number_is_used_whole(self): + self.assertEqual(self.label(title="Ship the site", file="40-site.md"), + "Ship the site") + + def test_a_long_name_is_clipped_to_a_chips_width(self): + label = self.label(title="40 — Ship the site and everything around it", + file="40-site.md") + self.assertTrue(label.endswith("…"), label) + self.assertLessEqual(len(label), 22) + self.assertTrue("Ship the site".startswith(label[:13])) + + def test_a_phase_with_no_title_falls_back_to_its_filename(self): + self.assertEqual(self.label(title="", file="40-site.md"), "40-site.md") + + def test_the_page_still_parses(self): + """board.html has no runner, so a stray brace in its inline script + would reach the browser silently. Parsing costs nothing here.""" + html = BOARD.read_text(encoding="utf-8") + scripts = re.findall(r"]*>(.*?)", html, re.S) + self.assertTrue(scripts, "board.html has no inline script") + for index, script in enumerate(scripts): + source = Path(self._dir.name) / f"page-{index}.js" + source.write_text(script, encoding="utf-8") + out = subprocess.run([NODE, "--check", str(source)], + capture_output=True, text=True) + self.assertEqual(out.returncode, 0, out.stderr)