Phase members leave the Board view

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 see. Nothing is deleted, moved or marked — a member keeps its stage, its file, its agent and its actions.

heldByPhase()/columnCards()/columnNote() in board.html: the count is the length of the list drawn, and '+2 in phases' beside it says where the rest went, only where members are actually hidden. Membership is the only thing that hides a card, so removing it is the un-hiding: an archived phase card and an edited '## Cards' list stop deriving it, and a phase in done/ holds nothing. A card wearing phase drift is never hidden. The phase card carries the summary it now owes: a '1 of 2 merged' chip opening its own sheet, the member in flight on its activity line, and a halt worn in --alarm. Only the Board hides; Sessions, Focus, the header's live chip and the tab title still see an agent working on a hidden member.

Covered by tests/test_phase_members_hidden.py — collect() reads a throwaway tasks/ tree and the page's own rules run over that reading in node, plus source-level wiring invariants. Full suite green.
This commit is contained in:
istos
2026-08-02 07:56:59 +02:00
parent dee9bbb1a4
commit 3365919ca4
3 changed files with 662 additions and 8 deletions
+32 -1
View File
@@ -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.
+118 -7
View File
@@ -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 =
`<h2><span class="tickmark" style="background:${STAGE_TINT[stage.slug]}"></span>` +
`<span>${stage.label}</span><span class="count">${stage.tasks.length}</span>` +
`<span class="note">${esc(note)}</span></h2>`;
`<span>${stage.label}</span><span class="count">${shown.length}</span>` +
`<span class="note">${esc(columnNote(stage, shown))}</span></h2>`;
const drop = document.createElement('div');
drop.className = 'drop';
drop.dataset.stage = stage.slug;
if (!stage.tasks.length) drop.innerHTML = '<div class="empty">Nothing here. Good.</div>';
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 = '<div class="empty">' +
(stage.tasks.length ? 'Everything here is in a phase.' : 'Nothing here. Good.') +
'</div>';
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 = `<div class="well bad" title="${esc(failure.excerpt)}">` +
`<span class="lead">·</span><span class="wbody">rc=${esc(failure.rc)} · ` +
`${esc(whyFailed(failure))}</span></div>`;
} 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 = `<div class="well${flight.bad ? ' bad' : ''}"><span class="lead">·</span>` +
`<span class="wbody">${esc(flight.line)}` +
`${flight.bad ? '' : '<span class="caret"></span>'}</span></div>`;
}
// 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) {
+512
View File
@@ -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('<span class="count">${shown.length}</span>', render)
self.assertIn("for (const task of shown)", render)
self.assertNotIn("stage.tasks.length}</span>", 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"<script[^>]*>(.*?)</script>", 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)