diff --git a/AGENTS.md b/AGENTS.md index 26b9523..9f5f7f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -295,7 +295,10 @@ top of `manager/board.html`. The board has three views (header switcher): -- **Board** — the kanban, live. Active Claude Code sessions appear as chips in +- **Board** — the kanban, live, and only what you are holding yourself: a + phase's members are drawn elsewhere, with the phase card standing for + them and each column saying how many it is not showing (see "A phase's + members leave the Board view"). Active Claude Code sessions appear as chips in the header; a card an agent is working on carries a live activity line; the bottom ticker narrates the latest events and every move is attributed (`you` / `agent` / `disk`, or the teammate's git name when a sync brought @@ -949,6 +952,34 @@ stage — and, while a phase is in flight, the runner's own reading of each (merged in, working, checking, stopped here) — so the card answers "where is this up to" without a hunt across five columns. +### A phase's members leave the Board view + +The Board is the work you are personally holding, in five columns that +fit, so a phase's members are **not drawn there**. Nothing is deleted, +moved or marked: a member keeps its stage, its file, its agent and its +actions, and the Board simply stops listing it. A column's number is +therefore the number of cards you can see in it — true by construction +rather than true-with-a-footnote — with `+2 in phases` beside it saying +where the rest went, and only where members are actually hidden. A board +with no phase card on it renders exactly what it always did. + +Membership is the only thing that hides a card, so *removing* membership +is the un-hiding, with no sweep and no second rule: archive the phase +card, drop a number from its `## Cards` list, or let the phase reach +`done/` — it holds nothing once it is over — and its former members are +back in the columns they are genuinely in. A membership that did not +resolve hides nothing either: a card wearing `phase drift` stays on the +board, because an authoring mistake must not make work vanish. + +That leaves the phase card standing for all of it, so it carries the +summary it owes: a `⟶ 1 of 2 merged` chip in the footer row that opens +its own sheet (the member list, in run order, with each one's stage), the +member in flight on its activity line, and a run that halted worn in +`--alarm` like any other stopped work. Only the Board hides. Sessions and +Focus are about runs, not stages, and a phase member's agent is an agent +like any other — it is counted by the header's live chip and by the tab +title wherever it is working. + Filtering the board to a phase's cards, and a Focus view for a phase, are separate cards. diff --git a/manager/core/board.html b/manager/core/board.html index d7e81a3..22109a9 100644 --- a/manager/core/board.html +++ b/manager/core/board.html @@ -173,6 +173,9 @@ /* the last run on this card died: the same terracotta, worn until the next launch replaces it or the card moves stage */ .card.run-failed{border-color:color-mix(in oklab, var(--alarm) 55%, var(--border))} + /* a phase halted: the same terracotta, on the card that now stands for + every member this view no longer draws */ + .card.halted{border-color:color-mix(in oklab, var(--alarm) 55%, var(--border))} /* merge & clean up is running on this card: the working vocabulary, because that is what is happening — the board is disassembling its branch. No new colour, no new animation, and nothing to grab: the card @@ -1112,6 +1115,52 @@ function render() { /* ── board ────────────────────────────────────────────────────────────── */ +/* A phase member is not drawn on the Board view. It keeps its stage, its + file, its agent and its actions — this view simply stops listing it, + because the phase card stands for it here. The Board goes back to + meaning one thing: the work you are personally holding, in five columns + that fit. + + Membership is the only thing that hides a card, so *removing* membership + is the un-hiding — no sweep, no migration, no second rule. A phase card + archived, or a number dropped from its `## Cards` list, stops deriving + the membership at all (the server weaves it fresh on every read), and a + phase that has reached done/ is over: it holds nothing and hands its + cards straight back into the columns they are genuinely in. + + A membership that did not resolve hides nothing either: an authoring + mistake must not make work vanish, so a card wearing `phase drift` stays + where it can be seen. */ +function heldByPhase(task) { + if (!task.phase || (task.phaseDrift || []).length) return false; + const card = findTask(task.phase.file); + return !!card && card.stage !== 'done'; +} + +/* What a column draws, and what its header says about what it does not. + The count is the number of cards you can see — true by construction + rather than true-with-a-footnote — and `+2 in phases` beside it is a + signpost to where the rest went, in the register the existing note uses. + It appears only where members are actually hidden, so a board with no + phases on it reads exactly as it did before they existed. + + The agents note counts the agents on the cards this column draws; + counting every agent anywhere is the header's live chip's job, and that + chip still sees an agent working on a hidden member. */ +function columnCards(stage) { + return stage.tasks.filter(t => !heldByPhase(t)); +} + +function columnNote(stage, shown) { + const agentsHere = shown.filter(t => agentOnTask(t.file)).length; + const hidden = stage.tasks.length - shown.length; + const notes = []; + if (agentsHere) notes.push(`${agentsHere} agent${agentsHere > 1 ? 's' : ''}`); + else if (STAGE_NOTE[stage.slug]) notes.push(STAGE_NOTE[stage.slug]); + if (hidden) notes.push(`+${hidden} in phases`); + return notes.join(' · '); +} + function renderBoard() { const board = $('#board'); // where you were looking, taken before the wipe throws the nodes away: @@ -1124,17 +1173,19 @@ function renderBoard() { for (const stage of S.state.board.stages) { const col = document.createElement('section'); col.className = 'kcol'; - const agentsHere = stage.tasks.filter(t => agentOnTask(t.file)).length; - const note = agentsHere ? `${agentsHere} agent${agentsHere > 1 ? 's' : ''}` : (STAGE_NOTE[stage.slug] || ''); + const shown = columnCards(stage); col.innerHTML = `

` + - `${stage.label}${stage.tasks.length}` + - `${esc(note)}

`; + `${stage.label}${shown.length}` + + `${esc(columnNote(stage, shown))}`; const drop = document.createElement('div'); drop.className = 'drop'; drop.dataset.stage = stage.slug; - if (!stage.tasks.length) drop.innerHTML = '
Nothing here. Good.
'; - for (const task of stage.tasks) drop.appendChild(cardFor(task)); + // a column emptied by a phase says so rather than congratulating you + if (!shown.length) drop.innerHTML = '
' + + (stage.tasks.length ? 'Everything here is in a phase.' : 'Nothing here. Good.') + + '
'; + for (const task of shown) drop.appendChild(cardFor(task)); drop.addEventListener('dragover', (e) => { e.preventDefault(); drop.classList.add('over'); }); drop.addEventListener('dragleave', () => drop.classList.remove('over')); drop.addEventListener('drop', (e) => { @@ -1164,6 +1215,40 @@ function phaseLabel(phase) { return name.length > 22 ? name.slice(0, 21) + '…' : name; } +/* The phase card is now the only thing standing for its members on this + view, so it carries the summary it owes them: how far the run has got. + Progress is what has landed on the phase branch, which only a run knows + — before there is one the card says how much it holds, which is the + honest reading of a phase nobody has started. */ +function phaseSummary(task) { + const snap = (S.state.phases || {})[task.file]; + if (snap) { + const at = phaseProgress(snap); + return `${at.done} of ${at.total} merged`; + } + const total = (task.members || []).length; + return `${total} card${total === 1 ? '' : 's'}`; +} + +/* …and what it is doing right now: the member in flight while a run is on, + the halt while one is held. Null when the phase has no story yet — a + phase waiting in to-do/ is just a card. */ +function phaseFlight(task) { + const snap = (S.state.phases || {})[task.file]; + if (!snap) return null; + if (snap.halted) { + return { bad: true, line: 'halted' + (snap.haltedAt ? ` at #${snap.haltedAt}` : '') + + ' — ' + (snap.haltedWhy || snap.halted) }; + } + if (!snap.running) return null; + const at = phaseProgress(snap); + if (at.on) return { bad: false, line: `on #${at.on.number || at.on.file} — ${at.on.title}` }; + if ((snap.waitingOn || []).length) { + return { bad: false, line: `waiting on #${snap.waitingOn[0]}` }; + } + return { bad: false, line: 'running' }; +} + /* Which phases this card could join, which is also whether the action is there at all: the phase cards waiting in to-do/, and none whatsoever for a card already in a phase, a phase card itself (they do not nest), or a @@ -1188,7 +1273,10 @@ function canArchive(task) { function cardFor(task) { const el = document.createElement('article'); const agent = agentOnTask(task.file); - const working = agent && agent.mode !== 'review'; + // a running phase has an agent alive on a card this view no longer draws, + // so the phase card wears that state on its members' behalf + const flight = task.isPhase ? phaseFlight(task) : null; + const working = !!(agent && agent.mode !== 'review') || !!(flight && !flight.bad); const verdict = task.stage === 'review' ? prVerdict(task) : null; const failure = failedRun(task); // the server holds this, not the tab that clicked: while merge & clean up @@ -1199,6 +1287,7 @@ function cardFor(task) { + (working ? ' running' : '') + (verdict === 'green' ? ' verdict-good' : verdict === 'red' ? ' verdict-bad' : '') + (failure ? ' run-failed' : '') + + (flight && flight.bad ? ' halted' : '') + (completing ? ' completing' : '') + (task.stage === 'done' ? ' done-dim' : ''); el.draggable = !completing; @@ -1221,6 +1310,13 @@ function cardFor(task) { title: failure.excerpt }; tint = 'var(--alarm)'; } + if (flight && flight.bad) { + // a halt is held, not scrolled past — and on this view the phase card + // is where a person meets it, the member that stopped being hidden + pill = { text: 'halted', tint: 'var(--alarm)', bg: mix('var(--alarm)', 16), + title: flight.line }; + tint = 'var(--alarm)'; + } if (completing) { // live work outranks every settled reading: the branch behind an // 'approved' pill is being merged away as you look at it @@ -1388,6 +1484,12 @@ function cardFor(task) { liveLine = `
` + `·rc=${esc(failure.rc)} · ` + `${esc(whyFailed(failure))}
`; + } else if (flight) { + // the member in flight, on the card that stands for it — the same line + // the header chip carries, at the altitude a person is already reading + liveLine = `
·` + + `${esc(flight.line)}` + + `${flight.bad ? '' : ''}
`; } // tool chips: destinations, not statuses — they live in the card's footer @@ -1395,6 +1497,15 @@ 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 = []; + // what the phase card now stands for: its progress, and the way through + // to the cards themselves — its own sheet lists them in run order with + // the stage each is in, which is where they are read until the Phases + // view draws them + if (task.isPhase) { + chips.push({ label: phaseSummary(task), pre: '⟶', cls: '', phase: task.file, + title: 'The cards in this phase, in run order — they are not drawn on ' + + 'the board while it holds them' }); + } // 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) { diff --git a/tests/test_phase_members_hidden.py b/tests/test_phase_members_hidden.py new file mode 100644 index 0000000..960c0ad --- /dev/null +++ b/tests/test_phase_members_hidden.py @@ -0,0 +1,512 @@ +"""Phase members leave the main board (task 56). + +The Board view stops drawing a phase's members: the phase card stands for +them there, and the columns go back to counting what you can actually see. +Nothing is deleted, moved or marked — membership, which the server already +derives on every read, is the only thing that hides a card, so removing it +is what brings the card back. + +Two halves, as elsewhere for board.html. The behaviour is exercised for +real: `taskfiles.collect()` reads a throwaway tasks/ directory exactly as +the board does, and the page's own rules are lifted out of board.html and +run in node over that reading. The wiring — that renderBoard() draws and +counts the same list, and that no other view calls the hiding rule — is a +source-level invariant, board.html being a single file with inline JS and +no frontend test runner. + + 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") + +# The rules the Board view draws by, lifted from the page as written. +PARTS = ( + r"const STAGE_NOTE = \{[^\n]*\};", + r"function agentOnTask\(file\) \{.*?\n\}", + r"function runningAgents\(\) \{.*?\n\}", + r"function allTasks\(\) \{[^\n]*\}", + r"function findTask\(file\) \{[^\n]*\}", + r"function phaseProgress\(p\) \{.*?\n\}", + r"function heldByPhase\(task\) \{.*?\n\}", + r"function columnCards\(stage\) \{.*?\n\}", + r"function columnNote\(stage, shown\) \{.*?\n\}", + r"function phaseSummary\(task\) \{.*?\n\}", + r"function phaseFlight\(task\) \{.*?\n\}", +) + + +def harness() -> str: + html = BOARD.read_text(encoding="utf-8") + out = [] + for pattern in PARTS: + match = re.search(pattern, html, re.S) + if match is None: + raise AssertionError(f"board.html no longer defines {pattern!r}") + out.append(match.group(0)) + return "\n".join(out) + + +def card(title: str, *, kind: 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" + text += "\nWhat this card is for.\n" + if cards is not None: + text += f"\n## Cards\n{cards}\n" + return text + + +@unittest.skipUnless(NODE, "node is needed to run the page's own rules") +class BoardViewCase(unittest.TestCase): + """One tasks/ directory per test, read as the board reads it, then + rendered by the page's own functions.""" + + @classmethod + def setUpClass(cls): + cls.src = harness() + + def setUp(self): + tmp = Path(tempfile.mkdtemp(prefix="bench-hide-")).resolve() + self.addCleanup(shutil.rmtree, tmp, True) + self.tasks = tmp / "tasks" + for slug in (*config.STAGE_DIRS, "archive"): + (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 relocate(self, filename: str, source: str, target: str) -> None: + (self.tasks / source / filename).rename(self.tasks / target / filename) + + def state(self, **extra) -> dict: + return {"board": taskfiles.collect(), "agents": [], "phases": {}, **extra} + + def run_js(self, expression: str, **extra) -> object: + script = (self.src + "\nvar S = { state: " + json.dumps(self.state(**extra)) + + " };\nconsole.log(JSON.stringify(" + expression + "));\n") + out = subprocess.run([NODE, "-e", script], capture_output=True, text=True) + self.assertEqual(out.returncode, 0, out.stderr) + return json.loads(out.stdout) + + def columns(self, **extra) -> dict[str, dict]: + """What each column draws and says — renderBoard()'s own reading.""" + drawn = self.run_js( + "S.state.board.stages.map(s => { const shown = columnCards(s);" + " return { slug: s.slug, count: shown.length," + " note: columnNote(s, shown), files: shown.map(t => t.file) }; })", + **extra) + return {column["slug"]: column for column in drawn} + + # the shape used by most of these: a phase card and the two cards it + # lists, spread over the stages they are genuinely in + def a_phase_of_two(self, phase_stage: str = "in-progress", + listed: str = "- 31 — Stand up site/\n- 32 — Serve it\n") -> None: + self.write("31-stand-up-site.md", card("31 — Stand up site/", + status="Review"), "review") + self.write("32-serve-it.md", card("32 — Serve it", status="To Do"), "to-do") + self.write("40-the-site.md", + card("40 — Ship the site", kind="Phase", cards=listed, + status=config.STAGE_LABELS[phase_stage]), phase_stage) + + +class MembersLeaveTheBoard(BoardViewCase): + def test_the_members_are_absent_and_the_phase_card_is_present(self): + self.a_phase_of_two() + + columns = self.columns() + + drawn = [f for column in columns.values() for f in column["files"]] + self.assertEqual(drawn, ["40-the-site.md"], + "a phase's members are not drawn on the Board view") + + def test_the_column_that_held_a_member_counts_one_fewer_and_says_where(self): + self.a_phase_of_two() + + columns = self.columns() + + self.assertEqual(columns["review"]["count"], 0) + self.assertIn("+1 in phases", columns["review"]["note"]) + self.assertEqual(columns["to-do"]["count"], 0) + self.assertIn("+1 in phases", columns["to-do"]["note"]) + + def test_the_note_is_a_signpost_beside_the_stages_own_word(self): + """`+1 in phases` says where the work went; it does not correct the + number or replace what the column has always called itself. "your + move" is now a claim about the cards you can see — see the card's + own note on that.""" + self.a_phase_of_two() + + self.assertEqual(self.columns()["review"]["note"], "your move · +1 in phases") + + def test_two_hidden_in_one_column_are_counted_together(self): + self.write("31-stand-up-site.md", card("31 — Stand up site/")) + self.write("32-serve-it.md", card("32 — Serve it")) + self.write("33-landing.md", card("33 — The landing page")) + self.write("40-the-site.md", card("40 — Ship the site", kind="Phase", + cards="- 31 — Stand up site/\n- 32 — Serve it\n")) + + columns = self.columns() + + self.assertEqual(columns["backlog"]["files"], + ["33-landing.md", "40-the-site.md"]) + self.assertEqual(columns["backlog"]["count"], 2) + self.assertEqual(columns["backlog"]["note"], "+2 in phases") + + def test_a_column_with_no_hidden_members_shows_no_such_note(self): + self.a_phase_of_two() + + columns = self.columns() + + for slug in ("backlog", "in-progress", "done"): + with self.subTest(stage=slug): + self.assertNotIn("in phases", columns[slug]["note"]) + self.assertEqual(columns["in-progress"]["note"], "") + self.assertEqual(columns["done"]["note"], "") + + def test_the_count_is_what_you_can_see_in_every_column(self): + """The rule that keeps the header honest, stated as a whole-board + invariant rather than one column's arithmetic.""" + self.a_phase_of_two() + + for column in self.columns().values(): + with self.subTest(stage=column["slug"]): + self.assertEqual(column["count"], len(column["files"])) + + +class RemovingMembershipUnhides(BoardViewCase): + """Membership is the only thing that hides a card, so there is nothing + to sweep or migrate: each of these simply stops deriving it.""" + + def test_a_phase_that_reached_done_hands_its_cards_back(self): + self.a_phase_of_two(phase_stage="done") + + columns = self.columns() + + self.assertEqual(columns["review"]["files"], ["31-stand-up-site.md"]) + self.assertEqual(columns["to-do"]["files"], ["32-serve-it.md"]) + self.assertEqual(columns["done"]["files"], ["40-the-site.md"]) + for slug in ("review", "to-do"): + self.assertNotIn("in phases", columns[slug]["note"]) + + def test_a_phase_moved_to_done_while_you_watch_hands_them_back(self): + """The same board, redrawn: nothing else changes and no other rule + runs — the members reappear in the stages they were always in.""" + self.a_phase_of_two(phase_stage="review") + before = self.columns() + self.assertEqual(before["review"]["files"], ["40-the-site.md"]) + + self.relocate("40-the-site.md", "review", "done") + + after = self.columns() + self.assertEqual(after["review"]["files"], ["31-stand-up-site.md"]) + self.assertEqual(after["to-do"]["files"], ["32-serve-it.md"]) + + def test_an_archived_phase_hands_its_cards_back(self): + self.a_phase_of_two(phase_stage="to-do") + + self.relocate("40-the-site.md", "to-do", "archive") + + columns = self.columns() + self.assertEqual(columns["review"]["files"], ["31-stand-up-site.md"]) + self.assertEqual(columns["to-do"]["files"], ["32-serve-it.md"]) + self.assertNotIn("in phases", columns["to-do"]["note"]) + + def test_a_number_removed_from_the_list_brings_that_card_back(self): + self.a_phase_of_two(listed="- 31 — Stand up site/\n- 32 — Serve it\n") + self.assertEqual(self.columns()["to-do"]["files"], []) + + self.write("40-the-site.md", card("40 — Ship the site", kind="Phase", + cards="- 31 — Stand up site/\n", + status="In Progress"), "in-progress") + + columns = self.columns() + self.assertEqual(columns["to-do"]["files"], ["32-serve-it.md"], + "the card the list no longer names is on the board again") + self.assertNotIn("in phases", columns["to-do"]["note"]) + self.assertEqual(columns["review"]["files"], []) + self.assertIn("+1 in phases", columns["review"]["note"]) + + +class AuthoringMistakesHideNothing(BoardViewCase): + """A card that vanishes is worse than a card that is flagged.""" + + def test_a_card_two_phases_claim_is_still_drawn(self): + self.write("32-serve-it.md", card("32 — Serve it", status="To Do"), "to-do") + self.write("40-the-site.md", card("40 — Ship the site", kind="Phase", + cards="- 32 — Serve it\n")) + self.write("41-the-docs.md", card("41 — Ship the docs", kind="Phase", + cards="- 32 — Serve it\n")) + + columns = self.columns() + + self.assertEqual(columns["to-do"]["files"], ["32-serve-it.md"], + "a card wearing phase drift must not disappear") + self.assertEqual(columns["to-do"]["note"], "next up") + + def test_a_number_no_card_has_hides_nothing(self): + self.write("32-serve-it.md", card("32 — Serve it", status="To Do"), "to-do") + self.write("40-the-site.md", card("40 — Ship the site", kind="Phase", + cards="- 99 — a card that is not here\n")) + + columns = self.columns() + + self.assertEqual(columns["to-do"]["files"], ["32-serve-it.md"]) + self.assertEqual(columns["backlog"]["files"], ["40-the-site.md"]) + + def test_a_phase_listing_a_phase_hides_neither(self): + 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="- 41 — Ship the docs\n")) + + columns = self.columns() + + self.assertEqual(columns["backlog"]["files"], + ["40-the-site.md", "41-the-docs.md"]) + + +class HidingCardsDoesNotHideActivity(BoardViewCase): + def test_the_live_agents_chip_still_counts_an_agent_on_a_hidden_member(self): + self.a_phase_of_two() + agents = [{"task": "32-serve-it.md", "status": "running", "started": 1}] + + self.assertEqual(self.run_js("runningAgents().length", agents=agents), 1, + "the header counts agents, not cards this view draws") + # …and the card is still hidden while its agent works + self.assertEqual(self.columns(agents=agents)["to-do"]["files"], []) + + def test_the_column_note_counts_the_agents_you_can_see(self): + """The count and its note describe the same visible cards, so a + column drawing no agent never claims one; the header chip above is + what says an agent is alive somewhere.""" + self.a_phase_of_two() + agents = [{"task": "32-serve-it.md", "status": "running", "started": 1}] + + note = self.columns(agents=agents)["to-do"]["note"] + self.assertNotIn("agent", note) + self.assertIn("+1 in phases", note) + + def test_a_visible_agent_still_reads_as_it_did(self): + self.write("32-serve-it.md", card("32 — Serve it", status="In Progress"), + "in-progress") + agents = [{"task": "32-serve-it.md", "status": "running", "started": 1}] + + self.assertEqual(self.columns(agents=agents)["in-progress"]["note"], + "1 agent") + + +class WithNoPhasesNothingChanged(BoardViewCase): + """The edge case that keeps this card from being a redesign: a board + with no phase card on it renders exactly what it rendered before.""" + + def board_as_before(self) -> dict[str, dict]: + """The rule board.html applied until this card: every task drawn, + the count is the directory's length, the note is the agents here or + the stage's own word.""" + notes = {"to-do": "next up", "review": "your move"} + columns = {} + for stage in taskfiles.collect()["stages"]: + columns[stage["slug"]] = { + "slug": stage["slug"], "count": len(stage["tasks"]), + "note": notes.get(stage["slug"], ""), + "files": [task["file"] for task in stage["tasks"]]} + return columns + + def test_every_column_reads_as_it_did(self): + self.write("31-stand-up-site.md", card("31 — Stand up site/")) + self.write("32-serve-it.md", card("32 — Serve it", status="To Do"), "to-do") + self.write("33-landing.md", card("33 — The landing page", status="Review"), + "review") + self.write("34-done.md", card("34 — Something finished", status="Done"), "done") + + self.assertEqual(self.columns(), self.board_as_before()) + + def test_an_empty_board_reads_as_it_did(self): + self.assertEqual(self.columns(), self.board_as_before()) + + +class ThePhaseCardsSummary(BoardViewCase): + """It is the only thing standing for that work on this view, so it says + how far the run has got and what it is doing.""" + + def snapshot(self, **extra) -> dict: + """What the runner's last pass would have published for the phase.""" + members = [{"number": "31", "file": "31-stand-up-site.md", + "title": "31 — Stand up site/", "stage": "review", + "state": "merged", "why": ""}, + {"number": "32", "file": "32-serve-it.md", + "title": "32 — Serve it", "stage": "in-progress", + "state": "running", "why": ""}] + return {"40-the-site.md": {"file": "40-the-site.md", "members": members, + "running": True, "halted": "", "haltedAt": None, + "haltedWhy": None, **extra}} + + def summary(self, **extra) -> str: + return self.run_js("phaseSummary(findTask('40-the-site.md'))", **extra) + + def flight(self, **extra): + return self.run_js("phaseFlight(findTask('40-the-site.md'))", **extra) + + def test_progress_is_what_has_landed_on_the_phase_branch(self): + self.a_phase_of_two() + + self.assertEqual(self.summary(phases=self.snapshot()), "1 of 2 merged") + + def test_a_phase_nobody_has_started_says_what_it_holds(self): + """No run, no progress to report — so it says how many cards it has + rather than reporting a merge count that means nothing yet.""" + self.a_phase_of_two(phase_stage="to-do") + + self.assertEqual(self.summary(), "2 cards") + + def test_one_card_is_not_pluralised(self): + self.a_phase_of_two(listed="- 31 — Stand up site/\n", phase_stage="to-do") + + self.assertEqual(self.summary(), "1 card") + + def test_the_member_in_flight_is_named(self): + self.a_phase_of_two() + + self.assertEqual(self.flight(phases=self.snapshot()), + {"bad": False, "line": "on #32 — 32 — Serve it"}) + + def test_a_dependency_holding_the_phase_is_named(self): + snapshot = self.snapshot() + snapshot["40-the-site.md"]["members"][1]["state"] = "pending" + snapshot["40-the-site.md"]["waitingOn"] = ["7"] + self.a_phase_of_two() + + self.assertEqual(self.flight(phases=snapshot), + {"bad": False, "line": "waiting on #7"}) + + def test_a_halt_reaches_the_card_that_stands_for_the_member(self): + self.a_phase_of_two() + halted = self.snapshot(running=False, halted="halted at 32 — it is not ready", + haltedAt="32", haltedWhy="it is not ready") + + self.assertEqual(self.flight(phases=halted), + {"bad": True, "line": "halted at #32 — it is not ready"}) + + def test_a_phase_with_no_run_has_nothing_to_say(self): + self.a_phase_of_two(phase_stage="to-do") + + self.assertIsNone(self.flight()) + + def test_a_phase_whose_run_ended_has_nothing_to_say(self): + self.a_phase_of_two() + + self.assertIsNone(self.flight(phases=self.snapshot(running=False))) + + +class Wiring(unittest.TestCase): + """board.html's own half: what keeps the count and the cards from + drifting apart, and the hiding from leaking into the other views.""" + + @classmethod + def setUpClass(cls): + cls.html = BOARD.read_text(encoding="utf-8") + + def body(self, pattern: str) -> str: + match = re.search(pattern, self.html, re.S) + self.assertIsNotNone(match, f"board.html no longer defines {pattern!r}") + return match.group(0) + + def test_the_column_draws_and_counts_one_list(self): + """The count is the number of cards drawn because it is the length + of the list drawn — not a second reading that could disagree.""" + render = self.body(r"function renderBoard\(\) \{.*?\n\}") + self.assertIn("const shown = columnCards(stage);", render) + self.assertIn('${shown.length}', render) + self.assertIn("for (const task of shown)", render) + self.assertNotIn("stage.tasks.length}", render, + "the header must not count what the column does not draw") + self.assertNotIn("for (const task of stage.tasks)", render) + + def test_the_note_is_built_where_the_column_is(self): + render = self.body(r"function renderBoard\(\) \{.*?\n\}") + self.assertIn("columnNote(stage, shown)", render) + note = self.body(r"function columnNote\(stage, shown\) \{.*?\n\}") + self.assertIn("in phases", note) + self.assertIn("agentOnTask", note) + + def test_only_the_board_view_hides(self): + """Sessions and Focus are about runs and sessions, not stages, and a + phase member's agent is an agent like any other.""" + self.assertEqual(len(re.findall(r"heldByPhase\(", self.html)), 2, + "heldByPhase() is defined once and asked once, by columnCards()") + self.assertEqual(len(re.findall(r"columnCards\(", self.html)), 2, + "columnCards() is defined once and used once, by renderBoard()") + for view in (r"function renderFlight\(\) \{.*?\n\}", + r"function renderFocus\(\) \{.*?\n\}"): + body = self.body(view) + self.assertNotIn("heldByPhase", body) + self.assertNotIn("columnCards", body) + + def test_drift_is_the_guard_inside_the_hiding_rule(self): + held = self.body(r"function heldByPhase\(task\) \{.*?\n\}") + self.assertIn("phaseDrift", held, + "an unresolved membership must not hide a card") + self.assertIn("'done'", held, + "a phase that has reached done/ holds nothing") + + def test_the_summary_chip_sits_in_the_footer_row_and_opens_the_card(self): + block = self.html[self.html.index("if (task.isPhase) {"):][:400] + self.assertIn("chips.push", block) + self.assertIn("phaseSummary(task)", block) + self.assertIn("phase: task.file", block, + "the chip has to name the card it opens — its own") + self.assertIn("⟶", block) + + def test_the_phase_card_wears_a_running_phase_and_a_halted_one(self): + card_fn = self.body(r"function cardFor\(task\) \{.*?\n\}") + self.assertIn("const flight = task.isPhase ? phaseFlight(task) : null;", card_fn) + self.assertIn("flight && !flight.bad", card_fn, + "a phase in flight breathes like any other live work") + self.assertIn("' halted'", card_fn) + self.assertIn(".card.halted{", self.html, + "the halted card needs the alarm border it wears") + + def test_an_emptied_column_says_why_it_is_empty(self): + render = self.body(r"function renderBoard\(\) \{.*?\n\}") + self.assertIn("Everything here is in a phase.", render) + self.assertIn("Nothing here. Good.", render, + "a genuinely empty column keeps the line it always had") + + +@unittest.skipUnless(NODE, "node is needed to parse the page") +class ThePageStillParses(unittest.TestCase): + def test_the_inline_script_parses(self): + html = BOARD.read_text(encoding="utf-8") + scripts = re.findall(r"]*>(.*?)", html, re.S) + self.assertTrue(scripts, "board.html has no inline script") + with tempfile.TemporaryDirectory() as tmp: + for index, script in enumerate(scripts): + source = Path(tmp) / 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)