A phase is a card that lists its cards
`**Type:** Phase` plus a `## Cards` section is the whole model. The phase card is the only authority on membership: it lists its members in run order, and a member's phase and position are derived from that list rather than stored on the member — one direction, one place to edit. - taskfiles.py reads the section (numbers only; `31`, `#31` and `031` are one card, indented lines are a member's own continuation) and parses `Depends on` at last, into the numbers it names. Nothing acts on it: the list says what runs next, the line says whether it may. - collect() weaves the board: members resolved in document order, each member given its phase and 3/5 position. What will not resolve is flagged rather than skipped — a number no card has, a card two phases both list (both flagged), a card listed twice, a line naming no number, a phase listed by a phase. - board.html gives a member card a `⟶ <phase> 3/5` chip in the footer row beside CI and PR ↗, opening the phase card, and wears phase drift as a pill next to status drift. Nothing runs yet — no branch, no launches, no advancing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 `⟶ <phase> 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.
|
||||
|
||||
+31
-3
@@ -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) {
|
||||
'<span class="spacer"></span>',
|
||||
high ? '<span class="high">HIGH</span>' : '',
|
||||
task.statusMismatch ? `<span class="pill drift" title="File says ${esc(task.declaredStatus)}">drift</span>` : '',
|
||||
// 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
|
||||
? `<span class="pill drift" title="${esc(task.phaseDrift.join(' · '))}">phase drift</span>` : '',
|
||||
`<span class="pill status${completing ? ' breathing' : ''}" style="background:${pill.bg};color:${pill.tint}"` +
|
||||
`${pill.title ? ` title="${esc(pill.title)}"` : ''}>${pill.text}</span>`,
|
||||
];
|
||||
@@ -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
|
||||
? '<div class="chiprow">' + chips.map(c => {
|
||||
const g = c.glyph ? `<span class="g2">${c.glyph}</span>` : '';
|
||||
if (c.href) return `<a class="chip2 ${c.cls}" href="${esc(c.href)}" target="_blank" rel="noopener" title="${esc(c.title)}">${esc(c.label)}${g}</a>`;
|
||||
if (c.act) return `<button class="chip2 ${c.cls}" data-drive="${c.act}" title="${esc(c.title)}">${esc(c.label)}${g}</button>`;
|
||||
const p = c.pre ? `<span class="g2">${c.pre}</span>` : '';
|
||||
if (c.href) return `<a class="chip2 ${c.cls}" href="${esc(c.href)}" target="_blank" rel="noopener" title="${esc(c.title)}">${p}${esc(c.label)}${g}</a>`;
|
||||
if (c.act) return `<button class="chip2 ${c.cls}" data-drive="${c.act}" title="${esc(c.title)}">${p}${esc(c.label)}${g}</button>`;
|
||||
if (c.phase) return `<button class="chip2 ${c.cls}" data-phase="${esc(c.phase)}" title="${esc(c.title)}">${p}${esc(c.label)}${g}</button>`;
|
||||
if (c.cmd) return `<button class="chip2 ${c.cls}" data-cmd="${esc(c.cmd)}" title="${esc(c.title)}">${actLabel(c.label, 'run it?', 'running…')}${g}</button>`;
|
||||
return `<span class="chip2 ${c.cls}" title="${esc(c.title)}">${esc(c.label)}${g}</span>`;
|
||||
return `<span class="chip2 ${c.cls}" title="${esc(c.title)}">${p}${esc(c.label)}${g}</span>`;
|
||||
}).join('') + '</div>'
|
||||
: '';
|
||||
|
||||
@@ -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();
|
||||
|
||||
+132
-1
@@ -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"):
|
||||
|
||||
@@ -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\(''\) \+ '</div>'",
|
||||
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"<script[^>]*>(.*?)</script>", 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)
|
||||
Reference in New Issue
Block a user