Phases are meant to arrive whole — members listed, dependencies filled in, readable in a diff before any of it runs. This is the other case: the card you decide belongs after all. ⟶ phase sits on backlog/ and to-do/ cards that are not already in a phase and are not phase cards themselves, and opens a sheet naming the phase cards waiting in to-do/ with what each already holds. Picking one appends `- <n> — <title>` to the end of that phase's ## Cards — the way a person writes it, because the section is authored and read by hand and a machine-shaped line is how a format stops being pleasant. Only to-do/. A phase in in-progress/ is running: its branch exists and its members are being worked in the order the list had when it started, so appending mid-flight is a different feature with different questions. Offer it and someone finds that out the hard way. No phase waiting there and the action is absent rather than present and empty. Nothing else moves. One line into the phase card, nothing at all into the card added — membership runs one direction and joining a phase is not a commitment to start it. The append goes out through append_to_section, the same door the phase log uses, so it commits itself under BOARD_COMMIT_MOVES, reaches the other boards, and reads the phase card off the disk rather than off a render: two boards adding to one phase produce two lines, not a lost one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
468 lines
20 KiB
Python
468 lines
20 KiB
Python
"""Adding a card to a phase without opening the file (task 51).
|
|
|
|
The action writes one line into the phase card and nothing at all into the
|
|
card being added — membership lives in one place (48) and joining a phase
|
|
moves nothing. So these cases hold three things: what the line looks like
|
|
and where it lands, what the board refuses (a running phase, a card
|
|
already in one, a phase card), and that the write reaches git the way
|
|
every other board-made write to a task file does.
|
|
|
|
python3 -m unittest discover -s tests -v
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
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 state # noqa: E402
|
|
import taskfiles # noqa: E402
|
|
|
|
BOARD = REPO / "manager" / "core" / "board.html"
|
|
NODE = shutil.which("node")
|
|
|
|
|
|
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\n{cards}"
|
|
return text
|
|
|
|
|
|
class AddingCase(unittest.TestCase):
|
|
"""One throwaway tasks/ directory per test, read the way the board reads
|
|
it. Nothing here is a git repo — the commit gate has its own case."""
|
|
|
|
def setUp(self):
|
|
tmp = Path(tempfile.mkdtemp(prefix="bench-join-")).resolve()
|
|
self.addCleanup(shutil.rmtree, tmp, True)
|
|
self.tasks = tmp / "tasks"
|
|
for slug in config.STAGE_DIRS:
|
|
(self.tasks / slug).mkdir(parents=True)
|
|
self.patch(TASKS=self.tasks, TM_ROOT=tmp, COMMIT_MOVES=False)
|
|
|
|
def patch(self, **values) -> None:
|
|
for attr, value in values.items():
|
|
self.addCleanup(setattr, config, attr, getattr(config, attr))
|
|
setattr(config, attr, value)
|
|
|
|
def write(self, filename: str, text: str, stage: str = "backlog") -> None:
|
|
(self.tasks / stage / filename).write_text(text, encoding="utf-8")
|
|
|
|
def read(self, filename: str, stage: str = "to-do") -> str:
|
|
return (self.tasks / stage / filename).read_text(encoding="utf-8")
|
|
|
|
def board(self) -> dict[str, dict]:
|
|
return {task["file"]: task
|
|
for stage in taskfiles.collect()["stages"]
|
|
for task in stage["tasks"]}
|
|
|
|
def phase(self, cards: str | None = None, stage: str = "to-do",
|
|
filename: str = "40-the-site.md") -> str:
|
|
self.write(filename, card("40 — Ship the site", kind="Phase", cards=cards),
|
|
stage)
|
|
return filename
|
|
|
|
|
|
class TheLineItWrites(AddingCase):
|
|
def test_the_card_lands_at_the_end_of_the_list(self):
|
|
self.write("51-the-afterthought.md", card("51 — The afterthought"))
|
|
self.write("31-stand-up-site.md", card("31 — Stand up site/"))
|
|
self.phase(cards="- 31 — Stand up site/\n")
|
|
|
|
taskfiles.add_to_phase("51-the-afterthought.md", "backlog", "40-the-site.md")
|
|
|
|
self.assertIn("- 31 — Stand up site/\n- 51 — The afterthought\n",
|
|
self.read("40-the-site.md"))
|
|
|
|
def test_the_line_reads_the_way_a_person_writes_it(self):
|
|
"""`- 51 — The afterthought`: the number the list is parsed by, and
|
|
the title without the copy of the number it opens with."""
|
|
self.write("51-the-afterthought.md", card("51 — The afterthought"))
|
|
self.phase(cards="")
|
|
|
|
result = taskfiles.add_to_phase("51-the-afterthought.md", "backlog",
|
|
"40-the-site.md")
|
|
|
|
self.assertEqual(result["line"], "- 51 — The afterthought")
|
|
self.assertEqual(result["entry"], "51 — The afterthought",
|
|
"the same thing without the bullet, for the ticker")
|
|
|
|
def test_a_title_that_does_not_repeat_its_number_is_used_whole(self):
|
|
self.write("51-the-afterthought.md", card("The afterthought"))
|
|
self.phase(cards="")
|
|
|
|
taskfiles.add_to_phase("51-the-afterthought.md", "backlog", "40-the-site.md")
|
|
|
|
self.assertIn("- 51 — The afterthought", self.read("40-the-site.md"))
|
|
|
|
def test_the_phase_reads_the_new_card_as_its_last_member(self):
|
|
"""The point of the whole action: the phase's list resolves and the
|
|
card wears the member chip 48 gives it."""
|
|
self.write("31-stand-up-site.md", card("31 — Stand up site/"))
|
|
self.write("51-the-afterthought.md", card("51 — The afterthought"))
|
|
self.phase(cards="- 31 — Stand up site/\n")
|
|
|
|
taskfiles.add_to_phase("51-the-afterthought.md", "backlog", "40-the-site.md")
|
|
|
|
cards = self.board()
|
|
self.assertEqual([m["file"] for m in cards["40-the-site.md"]["members"]],
|
|
["31-stand-up-site.md", "51-the-afterthought.md"])
|
|
self.assertEqual(cards["51-the-afterthought.md"]["phase"]["index"], 2)
|
|
self.assertEqual(cards["51-the-afterthought.md"]["phase"]["total"], 2)
|
|
self.assertEqual(cards["40-the-site.md"]["phaseDrift"], [])
|
|
|
|
def test_a_card_in_to_do_joins_from_where_it_stands(self):
|
|
self.write("51-the-afterthought.md", card("51 — The afterthought",
|
|
status="To Do"), "to-do")
|
|
self.phase(cards="")
|
|
|
|
taskfiles.add_to_phase("51-the-afterthought.md", "to-do", "40-the-site.md")
|
|
|
|
self.assertEqual(self.board()["51-the-afterthought.md"]["phase"]["index"], 1)
|
|
|
|
def test_nothing_else_moves(self):
|
|
"""Joining a phase is not a commitment to start it: the card stays in
|
|
its stage and says nothing at all about the phase that now holds it."""
|
|
original = card("51 — The afterthought")
|
|
self.write("51-the-afterthought.md", original)
|
|
self.phase(cards="")
|
|
|
|
taskfiles.add_to_phase("51-the-afterthought.md", "backlog", "40-the-site.md")
|
|
|
|
self.assertEqual(self.read("51-the-afterthought.md", "backlog"), original)
|
|
self.assertTrue((self.tasks / "backlog" / "51-the-afterthought.md").is_file())
|
|
|
|
def test_the_rest_of_the_phase_card_is_untouched(self):
|
|
self.write("51-the-afterthought.md", card("51 — The afterthought"))
|
|
text = card("40 — Ship the site", kind="Phase", cards="- 31 — Stand up site/\n") \
|
|
+ "\n## Notes\n\nWhy this phase exists.\n"
|
|
self.write("40-the-site.md", text, "to-do")
|
|
self.write("31-stand-up-site.md", card("31 — Stand up site/"))
|
|
|
|
taskfiles.add_to_phase("51-the-afterthought.md", "backlog", "40-the-site.md")
|
|
|
|
after = self.read("40-the-site.md")
|
|
self.assertIn("# 40 — Ship the site", after)
|
|
self.assertIn("**Type:** Phase", after)
|
|
self.assertIn("## Notes\n\nWhy this phase exists.\n", after)
|
|
self.assertIn("- 31 — Stand up site/\n- 51 — The afterthought\n", after)
|
|
self.assertEqual(self.board()["40-the-site.md"]["phaseDrift"], [])
|
|
|
|
def test_a_phase_with_no_cards_section_gains_one(self):
|
|
"""Not appended to the end of the file as loose prose: the section
|
|
is what the list is, so it is created."""
|
|
self.write("51-the-afterthought.md", card("51 — The afterthought"))
|
|
self.phase() # written with no ## Cards section at all
|
|
|
|
taskfiles.add_to_phase("51-the-afterthought.md", "backlog", "40-the-site.md")
|
|
|
|
after = self.read("40-the-site.md")
|
|
self.assertIn("## Cards\n\n- 51 — The afterthought", after)
|
|
self.assertEqual([m["number"] for m in self.board()["40-the-site.md"]["members"]],
|
|
["51"])
|
|
|
|
def test_the_second_add_reads_the_file_as_it_is_on_disk(self):
|
|
"""Two boards adding to one phase produce two lines, not a lost one:
|
|
the append re-reads the card rather than trusting a render."""
|
|
self.write("51-the-afterthought.md", card("51 — The afterthought"))
|
|
self.write("52-the-other-one.md", card("52 — The other one"))
|
|
self.phase(cards="")
|
|
stale = self.read("40-the-site.md")
|
|
|
|
taskfiles.add_to_phase("51-the-afterthought.md", "backlog", "40-the-site.md")
|
|
# the other board wrote in between; this one never saw it
|
|
self.assertNotIn("51", stale)
|
|
taskfiles.add_to_phase("52-the-other-one.md", "backlog", "40-the-site.md")
|
|
|
|
self.assertEqual([m["number"] for m in self.board()["40-the-site.md"]["members"]],
|
|
["51", "52"])
|
|
|
|
|
|
class WhatItRefuses(AddingCase):
|
|
"""Each refusal is a state the action is not offered in either — the
|
|
server is the backstop behind a card face that went stale."""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.write("51-the-afterthought.md", card("51 — The afterthought"))
|
|
|
|
def add(self, phase_file: str = "40-the-site.md", stage: str = "backlog") -> str:
|
|
with self.assertRaises(ValueError) as caught:
|
|
taskfiles.add_to_phase("51-the-afterthought.md", stage, phase_file)
|
|
return str(caught.exception)
|
|
|
|
def test_a_phase_that_is_running_takes_nothing(self):
|
|
"""in-progress/ means the branch exists and the members are being
|
|
worked in the order the list had when it started."""
|
|
self.phase(cards="", stage="in-progress")
|
|
|
|
self.assertIn("to-do", self.add())
|
|
|
|
def test_a_phase_in_review_or_done_takes_nothing(self):
|
|
for stage in ("review", "done"):
|
|
with self.subTest(stage=stage):
|
|
self.phase(cards="", stage=stage, filename=f"4{stage[0]}-late.md")
|
|
self.assertIn("to-do", self.add(f"4{stage[0]}-late.md"))
|
|
|
|
def test_a_card_that_is_not_a_phase_takes_nothing(self):
|
|
self.write("40-ordinary.md", card("40 — An ordinary card", kind="Feature",
|
|
cards=""), "to-do")
|
|
|
|
self.assertIn("not a phase", self.add("40-ordinary.md"))
|
|
|
|
def test_a_card_already_in_this_phase_is_not_listed_twice(self):
|
|
self.phase(cards="- 51 — The afterthought\n")
|
|
|
|
self.assertIn("already", self.add())
|
|
|
|
def test_a_card_another_phase_holds_stays_where_it_is(self):
|
|
self.write("41-the-docs.md", card("41 — Ship the docs", kind="Phase",
|
|
cards="- 51 — The afterthought\n"), "to-do")
|
|
self.phase(cards="")
|
|
|
|
message = self.add()
|
|
|
|
self.assertIn("41", message)
|
|
self.assertNotIn("51", self.read("40-the-site.md"))
|
|
|
|
def test_a_phase_does_not_join_a_phase(self):
|
|
self.write("41-the-docs.md", card("41 — Ship the docs", kind="Phase",
|
|
cards=""), "backlog")
|
|
self.phase(cards="")
|
|
|
|
with self.assertRaises(ValueError) as caught:
|
|
taskfiles.add_to_phase("41-the-docs.md", "backlog", "40-the-site.md")
|
|
|
|
self.assertIn("nest", str(caught.exception))
|
|
|
|
def test_a_card_at_work_does_not_join(self):
|
|
"""The action lives on the two unstarted stages; the stages past
|
|
them are refused rather than quietly accepted."""
|
|
self.phase(cards="")
|
|
for stage in ("in-progress", "review", "done"):
|
|
with self.subTest(stage=stage):
|
|
self.write("51-the-afterthought.md", card("51 — The afterthought"),
|
|
stage)
|
|
self.assertIn("backlog", self.add(stage=stage))
|
|
|
|
def test_a_card_with_no_number_says_so(self):
|
|
self.write("a-nameless-card.md", card("A nameless card"))
|
|
self.phase(cards="")
|
|
|
|
with self.assertRaises(ValueError) as caught:
|
|
taskfiles.add_to_phase("a-nameless-card.md", "backlog", "40-the-site.md")
|
|
|
|
self.assertIn("number", str(caught.exception))
|
|
|
|
def test_a_card_that_has_moved_since_the_page_rendered(self):
|
|
self.phase(cards="")
|
|
|
|
with self.assertRaises(ValueError) as caught:
|
|
taskfiles.add_to_phase("51-the-afterthought.md", "to-do", "40-the-site.md")
|
|
|
|
self.assertIn("refresh the board", str(caught.exception))
|
|
|
|
def test_a_path_is_not_a_filename(self):
|
|
self.phase(cards="")
|
|
|
|
for bad in ("../../etc/passwd.md", "to-do/40-the-site.md"):
|
|
with self.subTest(name=bad):
|
|
self.assertIn("bad filename", self.add(bad))
|
|
|
|
|
|
def git(cwd: Path, *args: str) -> subprocess.CompletedProcess:
|
|
return subprocess.run(["git", "-C", str(cwd), *args],
|
|
capture_output=True, text=True)
|
|
|
|
|
|
class TheCommitGate(unittest.TestCase):
|
|
"""An addition that never leaves one working tree is not an addition the
|
|
phase will run — so it commits like every other board-made write, and
|
|
with the gate off it does not commit at all."""
|
|
|
|
NAME = "Mover One"
|
|
|
|
def setUp(self):
|
|
# resolve(): macOS tempdirs sit behind /var → /private/var and git
|
|
# reports the resolved path, so absolute pathspecs must resolve too.
|
|
tmp = Path(tempfile.mkdtemp(prefix="bench-join-git-")).resolve()
|
|
self.addCleanup(shutil.rmtree, tmp, True)
|
|
self.repo = tmp / "repo"
|
|
for slug in config.STAGE_DIRS:
|
|
(self.repo / "tasks" / slug).mkdir(parents=True)
|
|
subprocess.run(["git", "init", "-q", "-b", "main", str(self.repo)],
|
|
check=True, capture_output=True)
|
|
git(self.repo, "config", "user.name", self.NAME)
|
|
git(self.repo, "config", "user.email", "mover@example.com")
|
|
|
|
for attr, value in {"TASKS": self.repo / "tasks", "TM_ROOT": self.repo,
|
|
"REPO": self.repo, "SESSIONS_DIR": tmp / "sessions",
|
|
"COMMIT_MOVES": True}.items():
|
|
self.addCleanup(setattr, config, attr, getattr(config, attr))
|
|
setattr(config, attr, value)
|
|
state.BOARD_EVENTS.clear()
|
|
|
|
(self.repo / "tasks" / "backlog" / "51-the-afterthought.md").write_text(
|
|
card("51 — The afterthought"), encoding="utf-8")
|
|
(self.repo / "tasks" / "to-do" / "40-the-site.md").write_text(
|
|
card("40 — Ship the site", kind="Phase", status="To Do",
|
|
cards="- 31 — Stand up site/\n"), encoding="utf-8")
|
|
git(self.repo, "add", "-A")
|
|
git(self.repo, "commit", "-q", "-m", "root")
|
|
self.baseline = self.commits()
|
|
|
|
def commits(self) -> int:
|
|
return int(git(self.repo, "rev-list", "--count", "HEAD").stdout.strip())
|
|
|
|
def head_message(self) -> str:
|
|
return git(self.repo, "log", "-1", "--pretty=%s").stdout.strip()
|
|
|
|
def head_files(self) -> list[str]:
|
|
out = git(self.repo, "show", "--name-only", "--pretty=format:", "HEAD").stdout
|
|
return sorted(line for line in out.splitlines() if line.strip())
|
|
|
|
def add(self) -> dict:
|
|
return taskfiles.add_to_phase("51-the-afterthought.md", "backlog",
|
|
"40-the-site.md")
|
|
|
|
def test_the_append_commits_itself_messaged_like_the_bookkeeping_it_is(self):
|
|
self.add()
|
|
|
|
self.assertEqual(self.commits(), self.baseline + 1)
|
|
self.assertEqual(self.head_message(), f"board: 40 gained 51 ({self.NAME})")
|
|
self.assertEqual(self.head_files(), ["tasks/to-do/40-the-site.md"],
|
|
"the phase card and nothing else")
|
|
self.assertEqual(git(self.repo, "status", "--porcelain").stdout, "",
|
|
"nothing is left behind for a human to find later")
|
|
|
|
def test_the_commit_is_published_like_every_other_one(self):
|
|
published: list[str] = []
|
|
state.COMMIT_HOOKS.append(published.append)
|
|
self.addCleanup(state.COMMIT_HOOKS.remove, published.append)
|
|
|
|
self.add()
|
|
|
|
self.assertEqual(published, ["40-the-site.md"],
|
|
"sync publishes the card that changed")
|
|
|
|
def test_with_the_gate_off_the_file_is_simply_edited(self):
|
|
config.COMMIT_MOVES = False
|
|
|
|
self.add()
|
|
|
|
self.assertEqual(self.commits(), self.baseline)
|
|
self.assertIn("- 51 — The afterthought",
|
|
(self.repo / "tasks" / "to-do" / "40-the-site.md")
|
|
.read_text(encoding="utf-8"))
|
|
self.assertIn("40-the-site.md",
|
|
git(self.repo, "status", "--porcelain").stdout)
|
|
|
|
|
|
class TheActionOnTheCard(unittest.TestCase):
|
|
"""board.html has no test runner, so these are source-level invariants:
|
|
the action is offered exactly where it can do something, and picking a
|
|
phase goes through the sheet the board already uses for a choice."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.html = BOARD.read_text(encoding="utf-8")
|
|
|
|
def offer(self) -> str:
|
|
match = re.search(r"function joinablePhases\(task\) \{.*?\n\}", self.html, re.S)
|
|
self.assertIsNotNone(match, "board.html lost joinablePhases()")
|
|
return match.group(0)
|
|
|
|
def sheet(self) -> str:
|
|
match = re.search(r"function phaseSheet\(task\) \{.*?\n\}", self.html, re.S)
|
|
self.assertIsNotNone(match, "board.html lost phaseSheet()")
|
|
return match.group(0)
|
|
|
|
def test_only_phases_waiting_in_to_do_are_offered(self):
|
|
offer = self.offer()
|
|
self.assertIn("'to-do'", offer)
|
|
self.assertIn("t.isPhase", offer)
|
|
self.assertNotIn("in-progress", offer,
|
|
"a running phase is not on the list")
|
|
|
|
def test_a_card_already_in_a_phase_offers_nothing(self):
|
|
self.assertIn("task.phase ||", self.offer())
|
|
|
|
def test_a_phase_card_does_not_offer_to_join_one(self):
|
|
self.assertIn("task.isPhase", self.offer())
|
|
|
|
def test_the_action_is_absent_when_there_is_no_phase_to_join(self):
|
|
"""Present and empty is the thing this must not be."""
|
|
self.assertIn("} else if (joinablePhases(task).length) {", self.html)
|
|
|
|
def test_the_action_wears_the_phase_glyph_and_opens_the_sheet(self):
|
|
block = re.search(r"joinablePhases\(task\)\.length\) \{.*?\n \}",
|
|
self.html, re.S)
|
|
self.assertIsNotNone(block, "the action's own block is gone")
|
|
self.assertIn("⟶", block.group(0))
|
|
self.assertIn("phaseSheet(task)", block.group(0))
|
|
|
|
def test_the_sheet_names_each_phase_and_what_it_holds(self):
|
|
sheet = self.sheet()
|
|
self.assertIn("p.cards", sheet, "each option says how many cards it holds")
|
|
self.assertIn("esc(p.title)", sheet)
|
|
self.assertIn("sheet", sheet)
|
|
|
|
def test_the_sheet_can_be_left_without_writing_anything(self):
|
|
self.assertIn("sh-nophase", self.sheet())
|
|
self.assertIn("closeSheet", self.sheet())
|
|
|
|
def test_picking_a_phase_posts_it_and_reloads_the_board(self):
|
|
match = re.search(r"async function addToPhase\(task, phase\) \{.*?\n\}",
|
|
self.html, re.S)
|
|
self.assertIsNotNone(match, "board.html lost addToPhase()")
|
|
post = match.group(0)
|
|
self.assertIn("'/api/phase/add'", post)
|
|
self.assertIn("phase: phase.file", post)
|
|
self.assertIn("stage: task.stage", post)
|
|
self.assertIn("loadState()", post)
|
|
self.assertIn("data.error", post, "a refusal has to reach the toast")
|
|
|
|
@unittest.skipUnless(NODE, "node is needed to parse the page")
|
|
def test_the_page_still_parses(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
for index, script in enumerate(
|
|
re.findall(r"<script[^>]*>(.*?)</script>", self.html, re.S)):
|
|
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)
|
|
|
|
|
|
class TheRouteBehindIt(unittest.TestCase):
|
|
"""The one place the action reaches the write path."""
|
|
|
|
def test_the_route_narrates_what_it_wrote(self):
|
|
source = (REPO / "manager" / "core" / "httpd.py").read_text(encoding="utf-8")
|
|
match = re.search(r'elif path == "/api/phase/add":.*?self\._json\(200, result\)',
|
|
source, re.S)
|
|
self.assertIsNotNone(match, "httpd.py has no /api/phase/add route")
|
|
route = match.group(0)
|
|
self.assertIn("taskfiles.add_to_phase", route)
|
|
self.assertIn("record_board_event", route)
|
|
self.assertIn('"kind": "phase"', route)
|
|
self.assertIn("result['entry']", route,
|
|
"the ticker says what landed, in the words the file got")
|
|
self.assertIn('state.broadcast({"type": "board"})', route,
|
|
"every open tab has to see the phase's new member")
|