2026-08-01 10:32:32 +02:00
|
|
|
"""The board shows a phase running, and shows it stopping (task 50).
|
|
|
|
|
|
|
|
|
|
Card 49 built the runner; this one is the interface it does not inherit
|
|
|
|
|
from being a card — an action to start and stop it, a header chip while it
|
|
|
|
|
runs, and the same chip in `--alarm` when it halts.
|
|
|
|
|
|
|
|
|
|
Two halves, tested the way each can be: the runner's own half (a hold that
|
|
|
|
|
records itself, a run-state read back off the phase log, the toast a halt
|
|
|
|
|
fires) runs for real on a real repo through `tests.test_phase_runs`'
|
|
|
|
|
harness; the page's half is source-level invariants over board.html, with
|
|
|
|
|
its pure functions lifted out and run under node — board.html has no test
|
|
|
|
|
runner, and a chip that quietly shows one of two running phases is exactly
|
|
|
|
|
the kind of thing that would otherwise reach a person at 2am.
|
|
|
|
|
|
|
|
|
|
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 phases # noqa: E402
|
|
|
|
|
import state # noqa: E402
|
|
|
|
|
|
|
|
|
|
from tests.test_phase_runs import (DIES, ONE, PHASE, PHASE_BRANCH, TWO, # noqa: E402
|
|
|
|
|
PhaseCase, card, wait_for)
|
|
|
|
|
|
|
|
|
|
BOARD = REPO / "manager" / "core" / "board.html"
|
|
|
|
|
NODE = shutil.which("node")
|
|
|
|
|
|
|
|
|
|
# An agent that is still working when the hold arrives.
|
|
|
|
|
SLEEPS = """#!/usr/bin/env python3
|
|
|
|
|
import time
|
|
|
|
|
time.sleep(60)
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class HoldingAPhase(PhaseCase):
|
|
|
|
|
"""`‖ hold` means here what it means everywhere else: the work stops
|
|
|
|
|
and nothing is lost."""
|
|
|
|
|
|
|
|
|
|
def running_agents(self) -> list[dict]:
|
|
|
|
|
return [r for r in state.AGENTS.values() if r["status"] == "running"]
|
|
|
|
|
|
|
|
|
|
def test_the_hold_is_recorded_where_the_beat_reads_it(self):
|
|
|
|
|
self.start()
|
|
|
|
|
|
|
|
|
|
snapshot = phases.stop_phase(PHASE, "in-progress")
|
|
|
|
|
|
|
|
|
|
self.assertTrue(any(e.startswith("stopped — held by") for e in self.log()))
|
|
|
|
|
self.assertFalse(snapshot["running"])
|
|
|
|
|
self.assertTrue(snapshot["stopped"])
|
|
|
|
|
self.assertTrue(any("held by" in s for s in self.summaries()))
|
|
|
|
|
|
|
|
|
|
def test_the_beat_stands_down_afterwards(self):
|
|
|
|
|
self.start()
|
|
|
|
|
phases.stop_phase(PHASE, "in-progress")
|
|
|
|
|
state.BOARD_EVENTS.clear()
|
|
|
|
|
|
|
|
|
|
snapshot = self.advance()
|
|
|
|
|
|
|
|
|
|
self.assertEqual(self.summaries(), [], "a held phase is quiet")
|
|
|
|
|
self.assertEqual(self.merges_on(PHASE_BRANCH), [],
|
|
|
|
|
"and merges nothing more")
|
|
|
|
|
self.assertFalse(self.branch_exists(f"task/{TWO[:-3]}"),
|
|
|
|
|
"and launches nothing more")
|
|
|
|
|
self.assertTrue(snapshot["stopped"])
|
|
|
|
|
|
|
|
|
|
def test_the_phase_branch_and_what_landed_on_it_are_untouched(self):
|
|
|
|
|
self.start()
|
|
|
|
|
self.advance() # 31 merges, 32 runs
|
|
|
|
|
merges, tip = self.merges_on(PHASE_BRANCH), self.tip(PHASE_BRANCH)
|
|
|
|
|
|
|
|
|
|
phases.stop_phase(PHASE, "in-progress")
|
|
|
|
|
|
|
|
|
|
self.assertEqual(self.merges_on(PHASE_BRANCH), merges)
|
|
|
|
|
self.assertEqual(self.tip(PHASE_BRANCH), tip, "a hold is not an undo")
|
|
|
|
|
self.assertTrue(self.branch_exists(f"task/{ONE[:-3]}"))
|
|
|
|
|
self.assertTrue((config.WORKTREES / PHASE[:-3]).is_dir())
|
|
|
|
|
|
|
|
|
|
def test_the_member_in_flight_is_held_with_it(self):
|
|
|
|
|
"""A phase that said it had stopped while the agent it launched
|
|
|
|
|
kept working would be lying about the one thing it was asked."""
|
|
|
|
|
self.adapter_is(SLEEPS)
|
|
|
|
|
phases.start_phase(PHASE, "in-progress")
|
|
|
|
|
self.assertTrue(wait_for(lambda: any(r["task"] == ONE for r in
|
|
|
|
|
self.running_agents())),
|
|
|
|
|
"the phase never launched its first member")
|
|
|
|
|
|
|
|
|
|
phases.stop_phase(PHASE, "in-progress")
|
|
|
|
|
|
|
|
|
|
self.assertTrue(wait_for(lambda: not self.running_agents()),
|
|
|
|
|
"the member's agent was left running")
|
|
|
|
|
self.assertTrue(any(ONE in s and "stopped with it" in s
|
|
|
|
|
for s in self.summaries()),
|
|
|
|
|
"and the ticker says which cards stopped with it")
|
|
|
|
|
|
|
|
|
|
def test_running_it_again_after_a_hold_carries_on(self):
|
|
|
|
|
self.start()
|
|
|
|
|
phases.stop_phase(PHASE, "in-progress")
|
|
|
|
|
|
|
|
|
|
phases.start_phase(PHASE, "in-progress")
|
|
|
|
|
self.settle()
|
|
|
|
|
|
|
|
|
|
self.assertIn(f"31 merged into {PHASE_BRANCH}", self.log())
|
|
|
|
|
self.assertTrue(self.branch_exists(f"task/{TWO[:-3]}"),
|
|
|
|
|
"the next member started")
|
|
|
|
|
|
|
|
|
|
def test_a_halted_phase_can_be_held_rather_than_run_again(self):
|
|
|
|
|
"""The halt holds until the phase is resumed *or* stopped: someone
|
|
|
|
|
who has read it and does not want to carry on says so here."""
|
|
|
|
|
self.adapter_is(DIES)
|
|
|
|
|
self.start()
|
|
|
|
|
self.advance()
|
|
|
|
|
self.assertIsNotNone(self.advance()["halted"])
|
|
|
|
|
|
|
|
|
|
snapshot = phases.stop_phase(PHASE, "in-progress")
|
|
|
|
|
|
|
|
|
|
self.assertIsNone(snapshot["halted"], "the alarm is settled")
|
|
|
|
|
self.assertTrue(snapshot["stopped"])
|
|
|
|
|
|
|
|
|
|
def test_holding_a_phase_that_never_ran_refuses(self):
|
|
|
|
|
with self.assertRaises(ValueError) as caught:
|
|
|
|
|
phases.stop_phase(PHASE, "in-progress")
|
|
|
|
|
|
|
|
|
|
self.assertIn("not running", str(caught.exception))
|
|
|
|
|
|
|
|
|
|
def test_holding_it_twice_refuses(self):
|
|
|
|
|
self.start()
|
|
|
|
|
phases.stop_phase(PHASE, "in-progress")
|
|
|
|
|
|
|
|
|
|
with self.assertRaises(ValueError) as caught:
|
|
|
|
|
phases.stop_phase(PHASE, "in-progress")
|
|
|
|
|
|
|
|
|
|
self.assertIn("not running", str(caught.exception))
|
|
|
|
|
|
|
|
|
|
def test_an_ordinary_card_is_not_held_as_a_phase(self):
|
|
|
|
|
self.write(ONE, card("31 — Stand up site/", status="In Progress"),
|
|
|
|
|
"in-progress")
|
|
|
|
|
(self.tasks / "backlog" / ONE).unlink()
|
|
|
|
|
|
|
|
|
|
with self.assertRaises(ValueError) as caught:
|
|
|
|
|
phases.stop_phase(ONE, "in-progress")
|
|
|
|
|
|
|
|
|
|
self.assertIn("not a phase", str(caught.exception))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class WhatTheChipIsToldFrom(PhaseCase):
|
|
|
|
|
"""The snapshot the API carries has to answer the chip's questions —
|
|
|
|
|
which phase, how far, on which card, and what stopped it — without the
|
|
|
|
|
page inferring any of them."""
|
|
|
|
|
|
|
|
|
|
def test_a_running_phase_names_itself_and_where_it_is(self):
|
|
|
|
|
self.adapter_is(SLEEPS) # so the member is still in flight
|
|
|
|
|
phases.start_phase(PHASE, "in-progress")
|
|
|
|
|
self.addCleanup(phases.stop_phase, PHASE, "in-progress")
|
|
|
|
|
|
|
|
|
|
snapshot = phases.public_state()[PHASE]
|
|
|
|
|
|
|
|
|
|
self.assertTrue(snapshot["running"])
|
|
|
|
|
self.assertIsNone(snapshot["halted"])
|
|
|
|
|
self.assertEqual(snapshot["title"], "40 — Ship the site")
|
|
|
|
|
self.assertEqual(snapshot["number"], "40")
|
|
|
|
|
self.assertEqual(snapshot["branch"], PHASE_BRANCH)
|
|
|
|
|
self.assertEqual([m["number"] for m in snapshot["members"]], ["31", "32"])
|
|
|
|
|
self.assertEqual([m["state"] for m in snapshot["members"]],
|
|
|
|
|
["running", "pending"])
|
|
|
|
|
|
|
|
|
|
def test_a_halt_arrives_in_its_parts(self):
|
|
|
|
|
self.adapter_is(DIES)
|
|
|
|
|
self.start()
|
|
|
|
|
|
|
|
|
|
snapshot = self.advance()
|
|
|
|
|
|
|
|
|
|
self.assertEqual(snapshot["haltedAt"], "31")
|
|
|
|
|
self.assertIn("without reaching review/", snapshot["haltedWhy"])
|
|
|
|
|
self.assertFalse(snapshot["running"])
|
|
|
|
|
# the one line the log's reader gets, still said the old way
|
|
|
|
|
self.assertTrue(snapshot["halted"].startswith("31: "))
|
|
|
|
|
|
|
|
|
|
def test_a_halt_is_a_toast_as_well_as_a_state_and_a_line(self):
|
|
|
|
|
"""Rare and actionable: the person who is not watching the ticker
|
|
|
|
|
is told once, the way a dead run tells them."""
|
|
|
|
|
self.adapter_is(DIES)
|
|
|
|
|
self.start()
|
|
|
|
|
self.sent.clear()
|
|
|
|
|
|
|
|
|
|
phases.advance_all() # the pass halts; it launches nothing
|
|
|
|
|
|
|
|
|
|
toasts = [m for m in self.sent if m.get("type") == "toast"]
|
|
|
|
|
self.assertEqual(len(toasts), 1, "said once, not once a beat")
|
|
|
|
|
self.assertTrue(toasts[0]["error"])
|
|
|
|
|
self.assertIn(PHASE, toasts[0]["message"])
|
|
|
|
|
self.assertIn("halted at 31", toasts[0]["message"])
|
|
|
|
|
|
|
|
|
|
def test_a_second_beat_repeats_neither_the_toast_nor_the_line(self):
|
|
|
|
|
self.adapter_is(DIES)
|
|
|
|
|
self.start()
|
|
|
|
|
self.advance()
|
|
|
|
|
self.sent.clear()
|
|
|
|
|
|
|
|
|
|
phases.advance_all()
|
|
|
|
|
|
|
|
|
|
self.assertEqual([m for m in self.sent if m.get("type") == "toast"], [])
|
|
|
|
|
|
|
|
|
|
def test_two_phases_are_two_snapshots(self):
|
|
|
|
|
"""Two phases could in principle run on one board — the API says so
|
|
|
|
|
rather than leaving the header to show one of them."""
|
|
|
|
|
self.write("41-second-phase.md",
|
|
|
|
|
card("41 — A second phase", status="In Progress", kind="Phase",
|
|
|
|
|
cards="- 77 — On its own\n"), "in-progress")
|
|
|
|
|
self.write("77-alone.md", card("77 — On its own"))
|
|
|
|
|
self.start()
|
|
|
|
|
phases.start_phase("41-second-phase.md", "in-progress")
|
|
|
|
|
self.settle()
|
|
|
|
|
|
|
|
|
|
live = phases.public_state()
|
|
|
|
|
|
|
|
|
|
self.assertEqual(sorted(live), [PHASE, "41-second-phase.md"])
|
|
|
|
|
self.assertTrue(all(p["running"] for p in live.values()))
|
|
|
|
|
|
|
|
|
|
def test_a_phase_out_of_in_progress_leaves_the_header(self):
|
|
|
|
|
self.start()
|
|
|
|
|
self.advance()
|
|
|
|
|
self.advance() # every member merged: the card moves to review/
|
|
|
|
|
self.advance() # the next beat, with nothing in in-progress/
|
|
|
|
|
|
|
|
|
|
self.assertEqual(self.stage_of(PHASE), "review")
|
|
|
|
|
self.assertEqual(phases.public_state(), {},
|
|
|
|
|
"nothing is in flight, so there is nothing to show")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class EveryAdvanceIsNarrated(PhaseCase):
|
|
|
|
|
"""A phase that advances silently is a phase nobody can debug
|
|
|
|
|
afterwards: what finished, what merged, what started."""
|
|
|
|
|
|
|
|
|
|
def test_the_ticker_names_all_three(self):
|
|
|
|
|
self.start()
|
|
|
|
|
state.BOARD_EVENTS.clear()
|
|
|
|
|
|
|
|
|
|
self.advance()
|
|
|
|
|
|
|
|
|
|
said = " | ".join(self.summaries())
|
|
|
|
|
self.assertIn("31 — 31 — Stand up site/ is green", said)
|
|
|
|
|
self.assertIn(f"merged 31 into {PHASE_BRANCH}", said)
|
|
|
|
|
self.assertIn("started 32", said)
|
|
|
|
|
|
|
|
|
|
def test_the_events_are_phase_events(self):
|
|
|
|
|
self.start()
|
|
|
|
|
|
|
|
|
|
mine = [e for e in state.BOARD_EVENTS if e["file"] == PHASE]
|
|
|
|
|
|
|
|
|
|
self.assertTrue(mine, "the phase narrated nothing at all")
|
|
|
|
|
self.assertEqual({e["kind"] for e in mine}, {"phase"},
|
|
|
|
|
"the runner's narration is its own kind of event")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TheLogSaysWhereAPhaseIs(unittest.TestCase):
|
|
|
|
|
"""`run_state` is the whole of a restarted board's memory of a run, and
|
|
|
|
|
now of the header's too. The last line that says something wins."""
|
|
|
|
|
|
|
|
|
|
def entries(self, *lines: str) -> list[str]:
|
|
|
|
|
body = "\n".join(f"- 2026-08-01 09:0{i} · {line}"
|
|
|
|
|
for i, line in enumerate(lines))
|
|
|
|
|
return phases.log_entries(f"# 40\n\n## Phase log\n\n{body}\n")
|
|
|
|
|
|
|
|
|
|
def state_of(self, *lines: str) -> str:
|
|
|
|
|
return phases.run_state(self.entries(*lines))["state"]
|
|
|
|
|
|
|
|
|
|
def test_a_card_that_has_never_run_is_idle(self):
|
|
|
|
|
self.assertEqual(phases.run_state([])["state"], "idle")
|
|
|
|
|
|
|
|
|
|
def test_a_started_run_is_running(self):
|
|
|
|
|
self.assertEqual(self.state_of("run started on phase/40-x", "31 started"),
|
|
|
|
|
"running")
|
|
|
|
|
|
|
|
|
|
def test_a_halt_stops_it(self):
|
|
|
|
|
where = phases.run_state(self.entries("run started on phase/40-x",
|
|
|
|
|
"halted at 31 — its CI is red"))
|
|
|
|
|
self.assertEqual(where["state"], "halted")
|
|
|
|
|
self.assertEqual(where["at"], "31")
|
|
|
|
|
self.assertEqual(where["reason"], "its CI is red")
|
|
|
|
|
|
|
|
|
|
def test_a_hold_stops_it(self):
|
|
|
|
|
where = phases.run_state(self.entries("run started on phase/40-x",
|
|
|
|
|
"stopped — held by ronald"))
|
|
|
|
|
self.assertEqual(where["state"], "stopped")
|
|
|
|
|
self.assertEqual(where["reason"], "held by ronald")
|
|
|
|
|
|
|
|
|
|
def test_a_hold_after_a_halt_settles_it(self):
|
|
|
|
|
self.assertEqual(self.state_of("halted at 31 — its CI is red",
|
|
|
|
|
"stopped — held by ronald"), "stopped")
|
|
|
|
|
self.assertIsNone(phases._halt_reason(
|
|
|
|
|
self.entries("halted at 31 — its CI is red", "stopped — held by ronald")))
|
|
|
|
|
|
|
|
|
|
def test_running_it_again_clears_either(self):
|
|
|
|
|
self.assertEqual(self.state_of("stopped — held by ronald",
|
|
|
|
|
"run started on phase/40-x"), "running")
|
|
|
|
|
self.assertEqual(self.state_of("halted at 31 — its CI is red",
|
|
|
|
|
"run started on phase/40-x"), "running")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TheHeaderChip(unittest.TestCase):
|
|
|
|
|
"""board.html has no test runner, so these are source-level invariants:
|
|
|
|
|
the chip exists, it is absent when there is nothing to say, it wears
|
|
|
|
|
the design system's colours and it is one chip per phase."""
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def setUpClass(cls):
|
|
|
|
|
cls.html = BOARD.read_text(encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
def func(self, name: str) -> str:
|
|
|
|
|
match = re.search(rf"^function {name}\(.*?\n\}}\n", self.html,
|
|
|
|
|
re.M | re.S)
|
|
|
|
|
self.assertIsNotNone(match, f"board.html lost its {name}()")
|
|
|
|
|
return match.group(0)
|
|
|
|
|
|
|
|
|
|
def test_the_chip_has_a_home_beside_the_agents_chip(self):
|
|
|
|
|
header = re.search(r"<header>.*?</header>", self.html, re.S).group(0)
|
|
|
|
|
self.assertIn('id="phasechips"', header)
|
|
|
|
|
self.assertLess(header.index('id="phasechips"'), header.index('id="livechip"'),
|
|
|
|
|
"the phase chip sits with the header's other live chips")
|
|
|
|
|
|
|
|
|
|
def test_it_is_rendered_on_every_frame(self):
|
|
|
|
|
self.assertIn("renderPhases();", self.func("render"))
|
|
|
|
|
|
|
|
|
|
def test_nothing_running_is_no_chip_at_all(self):
|
|
|
|
|
body = self.func("renderPhases")
|
|
|
|
|
self.assertIn("el.hidden = !live.length", body,
|
|
|
|
|
"an empty chip or a placeholder is not the answer")
|
|
|
|
|
|
|
|
|
|
def test_only_a_run_or_a_halt_is_in_flight(self):
|
|
|
|
|
self.assertIn("p.running || p.halted", self.func("phasesInFlight"))
|
|
|
|
|
|
|
|
|
|
def test_a_halt_leads(self):
|
|
|
|
|
self.assertIn("sort(", self.func("phasesInFlight"))
|
|
|
|
|
self.assertIn("b.halted", self.func("phasesInFlight"))
|
|
|
|
|
|
|
|
|
|
def test_every_phase_in_flight_gets_its_own_chip(self):
|
|
|
|
|
body = self.func("renderPhases")
|
|
|
|
|
self.assertIn("live.map(", body,
|
|
|
|
|
"one chip per phase — never one of two, silently")
|
|
|
|
|
|
|
|
|
|
def test_the_colours_mean_what_they_mean(self):
|
|
|
|
|
body = self.func("renderPhases")
|
|
|
|
|
self.assertIn("dot live", body, "a run alive breathes in --accent")
|
|
|
|
|
self.assertIn("var(--alarm)", body, "a halt holds in --alarm")
|
|
|
|
|
|
|
|
|
|
def test_the_chip_opens_the_phase_card(self):
|
|
|
|
|
body = self.func("renderPhases")
|
|
|
|
|
self.assertIn("data-phasechip", body)
|
|
|
|
|
self.assertIn("showDetail(task)", body)
|
|
|
|
|
|
|
|
|
|
def test_the_page_still_parses(self):
|
|
|
|
|
scripts = re.findall(r"<script[^>]*>(.*?)</script>", self.html, re.S)
|
|
|
|
|
self.assertTrue(scripts, "board.html has no inline script")
|
|
|
|
|
if not NODE:
|
|
|
|
|
self.skipTest("node is needed to parse the page")
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@unittest.skipUnless(NODE, "node is needed to run the page's own functions")
|
|
|
|
|
class WhatTheChipSays(unittest.TestCase):
|
|
|
|
|
"""`phaseChipDetail` is a pure function of the snapshot, so — as with
|
|
|
|
|
`md()` and `phaseLabel()` — it is lifted out of the page and run."""
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def setUpClass(cls):
|
|
|
|
|
html = BOARD.read_text(encoding="utf-8")
|
|
|
|
|
source = ""
|
|
|
|
|
for name in ("phaseProgress", "clip", "phaseChipDetail", "phasesInFlight"):
|
|
|
|
|
match = re.search(rf"^function {name}\(.*?\n\}}\n", html, re.M | re.S)
|
|
|
|
|
assert match, f"board.html lost its {name}()"
|
|
|
|
|
source += match.group(0)
|
|
|
|
|
cls._dir = tempfile.TemporaryDirectory()
|
|
|
|
|
cls.js = Path(cls._dir.name) / "chip.js"
|
|
|
|
|
cls.js.write_text(
|
|
|
|
|
"const input = JSON.parse(require('fs').readFileSync(0, 'utf8'));\n"
|
|
|
|
|
"const S = { state: { phases: input.phases || {} } };\n" + source +
|
|
|
|
|
"process.stdout.write(JSON.stringify(input.phase "
|
|
|
|
|
"? phaseChipDetail(input.phase) "
|
|
|
|
|
": phasesInFlight().map(p => p.file)));\n", encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def tearDownClass(cls):
|
|
|
|
|
cls._dir.cleanup()
|
|
|
|
|
|
|
|
|
|
def run_js(self, payload: dict):
|
|
|
|
|
out = subprocess.run([NODE, str(self.js)], input=json.dumps(payload),
|
|
|
|
|
capture_output=True, text=True)
|
|
|
|
|
self.assertEqual(out.returncode, 0, out.stderr)
|
|
|
|
|
return json.loads(out.stdout)
|
|
|
|
|
|
|
|
|
|
def detail(self, **phase) -> str:
|
|
|
|
|
return self.run_js({"phase": phase})
|
|
|
|
|
|
|
|
|
|
def members(self, *states) -> list[dict]:
|
|
|
|
|
return [{"number": str(30 + i), "file": f"{30 + i}-c.md", "state": s}
|
|
|
|
|
for i, s in enumerate(states, start=1)]
|
|
|
|
|
|
|
|
|
|
def test_a_run_says_how_far_and_which_card(self):
|
|
|
|
|
"""Progress is what has landed on the phase branch, not where the
|
|
|
|
|
pointer is — `2/5 · on #33` says two different things, which is the
|
|
|
|
|
whole reason both are on the chip."""
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
self.detail(running=True,
|
|
|
|
|
members=self.members("merged", "merged", "running",
|
|
|
|
|
"pending", "pending")),
|
|
|
|
|
"2/5 · on #33")
|
|
|
|
|
|
|
|
|
|
def test_a_member_waiting_on_its_checks_is_still_the_card_in_flight(self):
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
self.detail(running=True, members=self.members("merged", "waiting")),
|
|
|
|
|
"1/2 · on #32")
|
|
|
|
|
|
|
|
|
|
def test_a_dependency_holding_the_phase_is_named(self):
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
self.detail(running=True, waitingOn=["99"],
|
|
|
|
|
members=self.members("merged", "pending")),
|
|
|
|
|
"1/2 · waiting on #99")
|
|
|
|
|
|
|
|
|
|
def test_between_passes_the_progress_stands_alone(self):
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
self.detail(running=True, members=self.members("merged", "pending")),
|
|
|
|
|
"1/2")
|
|
|
|
|
|
|
|
|
|
def test_a_halt_names_the_card_and_the_reason(self):
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
self.detail(halted="35: it is not ready", haltedAt="35",
|
|
|
|
|
haltedWhy="it is not ready", members=self.members("merged")),
|
|
|
|
|
"halted at #35 — it is not ready")
|
|
|
|
|
|
|
|
|
|
def test_a_long_reason_is_clipped_for_the_header(self):
|
|
|
|
|
detail = self.detail(halted="x", haltedAt="35", members=self.members(),
|
|
|
|
|
haltedWhy="its run ended without reaching review/ — "
|
|
|
|
|
"API Error: 500 overloaded, the model said")
|
|
|
|
|
self.assertTrue(detail.endswith("…"), detail)
|
|
|
|
|
self.assertLessEqual(len(detail), 64, "a header chip is not the place "
|
|
|
|
|
"a reason gets to run long")
|
|
|
|
|
|
|
|
|
|
def test_a_halt_with_no_member_still_says_what_stopped(self):
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
self.detail(halted="the run could not continue: no", members=[],
|
|
|
|
|
haltedWhy="the run could not continue: no"),
|
|
|
|
|
"halted — the run could not continue: no")
|
|
|
|
|
|
|
|
|
|
def test_a_quiet_board_has_nothing_in_flight(self):
|
|
|
|
|
self.assertEqual(self.run_js({"phases": {
|
|
|
|
|
"40-a.md": {"file": "40-a.md", "running": False, "halted": None},
|
|
|
|
|
}}), [])
|
|
|
|
|
|
|
|
|
|
def test_a_halted_phase_leads_the_running_one(self):
|
|
|
|
|
self.assertEqual(self.run_js({"phases": {
|
|
|
|
|
"40-a.md": {"file": "40-a.md", "running": True, "halted": None},
|
|
|
|
|
"41-b.md": {"file": "41-b.md", "running": False, "halted": "31: red"},
|
|
|
|
|
}}), ["41-b.md", "40-a.md"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TheCardsActions(unittest.TestCase):
|
|
|
|
|
"""▸ run phase sits in the slot ▸ start work has on every other card,
|
|
|
|
|
and ‖ hold replaces it while the phase runs — both on the one action
|
|
|
|
|
machine, so both arm before they fire."""
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def setUpClass(cls):
|
|
|
|
|
cls.html = BOARD.read_text(encoding="utf-8")
|
|
|
|
|
block = re.search(r"if \(task\.stage === 'in-progress' && task\.isPhase\)"
|
|
|
|
|
r" \{.*?\n \} else if \(task\.stage === 'in-progress'\)",
|
|
|
|
|
cls.html, re.S)
|
|
|
|
|
assert block, "board.html lost the phase card's actions"
|
|
|
|
|
cls.block = block.group(0)
|
|
|
|
|
|
|
|
|
|
def test_the_action_only_exists_on_an_in_progress_phase_card(self):
|
|
|
|
|
self.assertIn("task.stage === 'in-progress' && task.isPhase", self.html)
|
|
|
|
|
|
|
|
|
|
def test_running_a_phase_arms_like_every_other_launch(self):
|
|
|
|
|
self.assertIn("label: 'run phase'", self.block)
|
|
|
|
|
self.assertIn("confirm: 'run it?'", self.block)
|
|
|
|
|
self.assertIn("busy: 'starting…'", self.block)
|
|
|
|
|
|
|
|
|
|
def test_a_phase_someone_else_holds_is_taken_over_deliberately(self):
|
|
|
|
|
self.assertIn("label: 'take over'", self.block)
|
|
|
|
|
self.assertIn("takeover: true", self.block)
|
|
|
|
|
|
|
|
|
|
def test_hold_is_the_word_while_it_runs(self):
|
|
|
|
|
self.assertIn("ph.running) actions.push(hold)", self.block)
|
|
|
|
|
self.assertIn("label: 'hold'", self.block)
|
|
|
|
|
self.assertIn("confirm: 'hold it?'", self.block)
|
|
|
|
|
|
2026-08-02 08:55:17 +02:00
|
|
|
def test_a_halt_offers_the_way_out_and_the_way_to_the_reason(self):
|
|
|
|
|
"""A halt still holds until the phase is run again or held, and
|
|
|
|
|
holding is here. Running it again moved to the Phases view with
|
|
|
|
|
card 57: it sits under the reason the phase stopped, because
|
|
|
|
|
clearing a halt should mean having read what caused it. So this
|
|
|
|
|
card carries ⟶ phases instead — the way to that reading."""
|
|
|
|
|
self.assertIn("ph.halted) actions.push(room, hold)", self.block)
|
|
|
|
|
self.assertIn("setView('phases')", self.block)
|
|
|
|
|
self.assertNotIn("ph.halted) actions.push(start", self.block)
|
2026-08-01 10:32:32 +02:00
|
|
|
|
|
|
|
|
def test_the_slot_never_holds_three(self):
|
|
|
|
|
self.assertIn("else if (actions.length < 2) {", self.html,
|
|
|
|
|
"the relevance check gives way rather than stacking")
|
|
|
|
|
|
|
|
|
|
def test_both_go_through_the_api(self):
|
|
|
|
|
run = re.search(r"async function runPhase.*?\n\}", self.html, re.S).group(0)
|
|
|
|
|
hold = re.search(r"async function holdPhase.*?\n\}", self.html, re.S).group(0)
|
|
|
|
|
self.assertIn("'/api/phase/run'", run)
|
|
|
|
|
self.assertIn("'/api/phase/stop'", hold)
|
|
|
|
|
for body in (run, hold):
|
|
|
|
|
self.assertIn("return res.ok", body,
|
|
|
|
|
"fireAction can only unlock on error if it is told")
|
|
|
|
|
self.assertIn("toast(", body)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ThePhaseCardsSheet(unittest.TestCase):
|
|
|
|
|
"""Opening a phase card answers "where is this up to" without a hunt
|
|
|
|
|
across five columns."""
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def setUpClass(cls):
|
|
|
|
|
cls.html = BOARD.read_text(encoding="utf-8")
|
|
|
|
|
cls.block = re.search(r"function phaseMembers\(t\) \{.*?\n\}\n",
|
|
|
|
|
cls.html, re.S).group(0)
|
|
|
|
|
|
|
|
|
|
def test_the_sheet_lists_the_members(self):
|
|
|
|
|
self.assertIn("phaseMembers(t) +", self.html,
|
|
|
|
|
"the list belongs in the drawer, above the card's text")
|
|
|
|
|
|
|
|
|
|
def test_only_a_phase_card_gets_one(self):
|
|
|
|
|
self.assertIn("if (!t.isPhase) return '';", self.block)
|
|
|
|
|
|
|
|
|
|
def test_run_order_is_the_order_it_lists(self):
|
|
|
|
|
self.assertIn("t.members.map((m, i)", self.block)
|
|
|
|
|
self.assertIn("${i + 1}", self.block)
|
|
|
|
|
|
|
|
|
|
def test_each_member_carries_its_stage(self):
|
|
|
|
|
self.assertIn("m.stage", self.block)
|
|
|
|
|
|
|
|
|
|
def test_the_runners_reading_is_shown_when_there_is_one(self):
|
|
|
|
|
self.assertIn("S.state.phases || {}", self.block)
|
|
|
|
|
self.assertIn("MEMBER_STATE", self.block)
|
|
|
|
|
for word in ("merged in", "working", "stopped here"):
|
|
|
|
|
self.assertIn(word, self.html)
|
|
|
|
|
|
|
|
|
|
def test_a_row_opens_that_card(self):
|
|
|
|
|
self.assertIn("data-member", self.block)
|
|
|
|
|
self.assertIn("findTask(row.dataset.member)", self.html)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TheTickerReadsPhaseEvents(unittest.TestCase):
|
|
|
|
|
@classmethod
|
|
|
|
|
def setUpClass(cls):
|
|
|
|
|
cls.html = BOARD.read_text(encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
def test_a_phase_event_has_a_glyph_of_its_own(self):
|
|
|
|
|
glyphs = re.search(r"const GLYPHS = \{.*?\};", self.html, re.S).group(0)
|
|
|
|
|
self.assertIn("phase: '⟶'", glyphs)
|
|
|
|
|
|
|
|
|
|
def test_phase_events_are_in_the_moves_filter(self):
|
|
|
|
|
moves = re.search(r"\['moves', 'Moves', new Set\((\[[^\]]*\])\)\]", self.html)
|
|
|
|
|
self.assertIsNotNone(moves, "board.html lost its Moves filter")
|
|
|
|
|
self.assertIn("'phase'", moves.group(1),
|
|
|
|
|
"a phase's narration is board-level, like a move or a sync")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TheApiHasAStop(unittest.TestCase):
|
|
|
|
|
def test_the_route_exists(self):
|
|
|
|
|
source = (REPO / "manager" / "core" / "httpd.py").read_text(encoding="utf-8")
|
|
|
|
|
self.assertIn('"/api/phase/stop"', source)
|
|
|
|
|
self.assertIn("phases.stop_phase", source)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TheDocumentedPromise(unittest.TestCase):
|
|
|
|
|
"""What the board does is documented in AGENTS.md; an interface that is
|
|
|
|
|
not written down is one the next reader has to find by clicking."""
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def setUpClass(cls):
|
|
|
|
|
cls.doc = (REPO / "AGENTS.md").read_text(encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
def test_the_actions_are_named(self):
|
|
|
|
|
self.assertIn("▸ run phase", self.doc)
|
|
|
|
|
|
|
|
|
|
def test_the_chip_is_described(self):
|
|
|
|
|
self.assertIn("halted", self.doc)
|
|
|
|
|
self.assertIn("header", self.doc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main()
|