Files
bench/tests/test_drawer_markdown.py
istosandClaude Opus 5 d25221bb74 The drawer reads a wrapped list as one item
md() split a block into physical lines and made each one a unit. Task
files are hard-wrapped at ~74 columns, so the second line of an item
became its own bullet, `- [ ]` rendered as a literal bracket pair,
nested lists flattened, and prose kept the author's ragged edge as
<br>. "Enough for these task files" was exactly what it was not.

Lists are now grouped into logical items before rendering: a new item
begins only at a marker, and a line without one is continuation text
joined with a space. Indentation is honoured — a marker past its level
opens a nested list, a shallower one closes back to the level that
fits — and one entry point serves both bullets and ordered lists, so
an <ol> nests under a <ul> the same way. Task-list items render as a
glyph in a span, never an <input>: the file is the source of truth and
the drawer is not an editor. A ticked box reads as settled (--calm);
an open one stays neutral. Paragraphs and blockquotes join their
source lines with a space, so prose reflows to the drawer's width.

Fences, tables, headings and rules are untouched, including the fence
state machine that spans blocks.

The tests lift esc() and md() out of the page and run them under node,
because the renderer is a pure function and its output is what to
assert on; node is not a bench dependency, so those checks skip when
it is absent and source-level invariants cover the shape of the fix.
One check renders every card on the board plus AGENTS.md and asserts
one bullet per source marker — the acceptance criterion applied to the
whole corpus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 15:01:26 +02:00

388 lines
16 KiB
Python

"""The drawer renders a wrapped list item as one item (task 41).
`md()` in board.html used to treat every *physical* line inside a block as
a unit. Task files are hard-wrapped at ~74 columns, so the second line of
an item became its own bullet, `- [ ]` rendered as a literal bracket pair,
nested lists flattened, and paragraphs kept the author's ragged edge via
`<br>`.
board.html is a single file with inline JS and no frontend test runner, so
this suite lifts `esc()` and `md()` straight out of the page and runs them
under node — the renderer is a pure function of its input, so its actual
output is what to assert on. Node is not a dependency of bench itself, so
those checks skip when it is absent; the source-level invariants at the
bottom always run and are in the same style as the board's other
`test_*.py` checks on board.html.
python3 -m unittest discover -s tests -v
"""
from __future__ import annotations
import re
import shutil
import subprocess
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
BOARD = ROOT / "manager" / "core" / "board.html"
NODE = shutil.which("node")
HTML = BOARD.read_text(encoding="utf-8")
def lift(pattern: str, what: str) -> str:
m = re.search(pattern, HTML, re.M | re.S)
assert m, f"board.html lost {what}"
return m.group(0)
HARNESS = (
lift(r"^const esc = \(s\) =>.*?\n.*?\n", "its esc() helper")
+ lift(r"^function md\(src\) \{\n.*?\n\}\n", "its md() renderer")
+ "process.stdout.write(md(require('fs').readFileSync(0, 'utf8')));\n"
)
class RendererCase(unittest.TestCase):
"""Base: run the page's own md() over a markdown string."""
@classmethod
def setUpClass(cls):
if not NODE:
return
cls._dir = tempfile.TemporaryDirectory()
cls.js = Path(cls._dir.name) / "md.js"
cls.js.write_text(HARNESS, encoding="utf-8")
@classmethod
def tearDownClass(cls):
if NODE:
cls._dir.cleanup()
def render(self, src: str) -> str:
out = subprocess.run([NODE, str(self.js)], input=src, text=True,
capture_output=True)
self.assertEqual(out.returncode, 0, out.stderr)
return out.stdout
INNERMOST = re.compile(r"<(ul|ol)>((?:(?!<(?:ul|ol)>).)*?)</\1>", re.S)
def items(self, html: str) -> list[str]:
"""The text of each top-level <li>: nested lists dropped, markup
stripped, so a test can assert on what the reader sees."""
body = re.sub(r"</(ul|ol)>\s*$", "", re.sub(r"^\s*<(ul|ol)>", "", html))
while self.INNERMOST.search(body): # peel nested lists off
body = self.INNERMOST.sub("", body)
body = re.sub(r'<span class="box".*?</span>', "", body) # the tick glyph
return [re.sub(r"<[^>]+>", "", li).strip()
for li in re.findall(r"<li[^>]*>(.*?)</li>", body, re.S)]
@unittest.skipUnless(NODE, "node is needed to run the page's own md()")
class WrappedItemsTests(RendererCase):
"""One bullet per item, however the author wrapped it."""
def test_a_wrapped_item_is_one_item(self):
"""The live bug: 'serves the built landing page' / 'over' were two
bullets because the source line broke between them."""
html = self.render(
"- Given a request for the site, when it is served, then the\n"
" worker serves the built landing page\n"
"- A second item\n")
self.assertEqual(html.count("<li"), 2)
self.assertEqual(
self.items(html),
["Given a request for the site, when it is served, then the "
"worker serves the built landing page", "A second item"])
def test_continuation_lines_join_with_a_space(self):
"""Three source lines, one item, and no word welded to its
neighbour across the join."""
items = self.items(self.render(
"- one two\n three four\n five six\n"))
self.assertEqual(items, ["one two three four five six"])
def test_a_continuation_starting_with_a_word_is_not_a_bullet(self):
"""'and' opening a wrapped line is prose, not a new item."""
items = self.items(self.render(
"- The renderer is line-based\n and the task files are hard-wrapped\n"))
self.assertEqual(items, ["The renderer is line-based "
"and the task files are hard-wrapped"])
def test_inline_code_with_a_hyphen_stays_one_item(self):
"""A hyphen inside backticks is not a marker; the item keeps its
code span and does not split."""
html = self.render(
"- The strip is `/^\\s*[-*]\\s+/` and it does\n nothing here\n")
self.assertEqual(html.count("<li"), 1)
self.assertIn("<code>", html)
def test_ordered_lists_group_the_same_way(self):
html = self.render(
"1. The board creates a git worktree on a new\n"
" branch from the newest main it can see\n"
"2. The agent works in the worktree\n")
self.assertTrue(html.startswith("<ol>"), html[:40])
self.assertEqual(len(self.items(html)), 2)
@unittest.skipUnless(NODE, "node is needed to run the page's own md()")
class TaskListTests(RendererCase):
"""`- [ ]` / `- [x]` become checkboxes, and only ever glyphs."""
SRC = ("- [ ] Given an Acceptance list whose items wrap, then there\n"
" is exactly one bullet per item\n"
"- [x] Fenced code blocks are unchanged\n")
def test_no_bracket_survives_as_text(self):
html = self.render(self.SRC)
text = " ".join(self.items(html))
self.assertNotIn("[", text)
self.assertNotIn("]", text)
def test_the_item_text_survives_beside_the_box(self):
self.assertEqual(
self.items(self.render(self.SRC)),
["Given an Acceptance list whose items wrap, then there "
"is exactly one bullet per item",
"Fenced code blocks are unchanged"])
def test_ticked_and_unticked_are_distinguishable(self):
html = self.render(self.SRC)
lis = re.findall(r"<li([^>]*)>", html)
self.assertEqual(len(lis), 2)
self.assertIn('class="tick"', lis[0]) # open: neutral
self.assertIn('class="tick on"', lis[1]) # done: settled
self.assertEqual(html.count('<span class="box"'), 2)
def test_a_checkbox_is_not_interactive(self):
"""A span, never an <input> and never a handler: clicking it can
do nothing, so it cannot quietly edit the file."""
html = self.render(self.SRC + "\n- [X] upper case counts as ticked\n")
self.assertNotIn("<input", html)
self.assertNotIn("onclick", html)
self.assertNotIn("contenteditable", html)
self.assertIn('class="tick on"', re.findall(r"<li([^>]*)>", html)[-1])
def test_a_bracket_that_is_not_a_checkbox_is_left_alone(self):
items = self.items(self.render("- [see the spec](../ref.md) explains it\n"))
self.assertEqual(items, ["see the spec explains it"])
self.assertIn('href="../ref.md"', self.render(
"- [see the spec](../ref.md) explains it\n"))
@unittest.skipUnless(NODE, "node is needed to run the page's own md()")
class NestingTests(RendererCase):
"""Children indent under their parent instead of flattening beside it."""
def test_a_nested_list_is_a_child_of_its_parent_item(self):
html = self.render(
"- parent one\n"
" - child a\n"
" - child b\n"
"- parent two\n")
self.assertEqual(self.items(html), ["parent one", "parent two"])
self.assertRegex(html, r"parent one<ul><li>child a</li><li>child b</li></ul></li>")
def test_the_nesting_closes_when_the_indent_returns(self):
html = self.render(
"- parent one\n"
" - child a\n"
"- parent two\n")
self.assertEqual(html.count("<ul>"), 2)
self.assertEqual(html.count("</ul>"), 2)
self.assertTrue(html.endswith("</ul>"))
def test_a_wrapped_child_is_still_one_child(self):
html = self.render(
"- parent\n"
" - the child wraps across\n"
" two source lines\n")
self.assertIn("<li>the child wraps across two source lines</li>", html)
def test_deeper_nesting_degrades_rather_than_breaks(self):
html = self.render(
"- a\n - b\n - c\n- d\n")
self.assertEqual(html.count("<ul>"), html.count("</ul>"))
for text in ("a", "b", "c", "d"):
self.assertIn(f"<li>{text}", html)
def test_a_nested_ordered_list_under_a_bullet_keeps_its_tag(self):
html = self.render("- parent\n 1. first\n 2. second\n")
self.assertIn("<ol><li>first</li><li>second</li></ol>", html)
@unittest.skipUnless(NODE, "node is needed to run the page's own md()")
class ReflowTests(RendererCase):
"""Prose wraps to the drawer, not to the author's editor."""
def test_a_paragraph_has_no_hard_break(self):
html = self.render(
"The renderer is line-based and the task files are\n"
"hard-wrapped, so almost every list on the board comes\n"
"out wrong.\n")
self.assertNotIn("<br>", html)
self.assertIn("task files are hard-wrapped", html)
def test_a_blockquote_reflows_too(self):
html = self.render("&gt; a quoted line\n&gt; and its continuation\n"
.replace("&gt;", ">"))
self.assertNotIn("<br>", html)
self.assertIn("a quoted line and its continuation", html)
def test_no_br_survives_anywhere_in_the_corpus(self):
"""Every card on the board plus AGENTS.md: the author's wrap column
must not reach the browser."""
docs = sorted(ROOT.glob("tasks/*/*.md")) + [ROOT / "AGENTS.md"]
self.assertGreater(len(docs), 5, "no task files found to render")
for doc in docs:
with self.subTest(doc=doc.relative_to(ROOT).as_posix()):
self.assertNotIn("<br>", self.render(
doc.read_text(encoding="utf-8")))
def source_bullets(src: str) -> int:
"""How many logical list items a document contains, counted from the
source the way a reader counts them: markers only, fences skipped, and
only in blocks that actually open with one."""
marker = re.compile(r"^[ \t]*(?:[-*]|\d+\.)[ \t]+")
total, fence = 0, False
for block in re.split(r"\n{2,}", src):
ticks = block.count("```")
if fence or block.startswith("```"):
fence = (ticks % 2 == 0) if fence else (ticks % 2 == 1)
continue
lines = block.rstrip().split("\n")
if len(lines) >= 2 and re.match(r"^\s*\|.*\|\s*$", lines[0]) \
and re.match(r"^\s*\|[\s:|-]+\|\s*$", lines[1]):
continue # a table, not a list
if marker.match(lines[0]):
total += sum(1 for l in lines if marker.match(l))
return total
@unittest.skipUnless(NODE, "node is needed to run the page's own md()")
class CorpusTests(RendererCase):
"""The whole board, not a fixture: one bullet per marker, no more."""
def docs(self) -> list[Path]:
found = sorted(ROOT.glob("tasks/*/*.md")) + [ROOT / "AGENTS.md"]
self.assertGreater(len(found), 5, "no task files found to render")
return found
def test_every_document_renders_one_bullet_per_marker(self):
"""Before the fix a hard-wrapped item produced a bullet per source
line; this is the acceptance criterion applied to every card."""
for doc in self.docs():
with self.subTest(doc=doc.relative_to(ROOT).as_posix()):
src = doc.read_text(encoding="utf-8")
self.assertEqual(self.render(src).count("<li"),
source_bullets(src))
def test_this_card_acceptance_list_is_checkboxes(self):
"""Task 41's own Acceptance section — the live case in the bug
report — comes out as unticked boxes, no literal brackets."""
cards = sorted(ROOT.glob("tasks/*/41-*.md")) # whatever stage it sits in
self.assertTrue(cards, "task 41's card is missing from the board")
section = cards[0].read_text(encoding="utf-8").split("## Acceptance")[1]
html = self.render(section.split("## Notes")[0].strip())
self.assertEqual(html.count('<li class="tick">'), html.count("<li"))
self.assertGreaterEqual(html.count("<li"), 8)
items = self.items(html)
for item in items: # the marker itself is consumed
self.assertNotRegex(item, r"^\[[ xX]\]")
self.assertIn("no bullet begins mid-sentence", items[0])
@unittest.skipUnless(NODE, "node is needed to run the page's own md()")
class UntouchedNeighboursTests(RendererCase):
"""Fences, tables, headings and rules kept working."""
TREE = ("```\n"
".task-manager/\n"
"├── AGENTS.md ← This file\n"
"│ ├── VERSION, board.py\n"
"└── manager/\n"
"```\n")
def test_a_fenced_block_keeps_its_line_breaks(self):
"""The ASCII directory tree in AGENTS.md goes through the file
viewer; every newline inside the fence has to survive."""
html = self.render(self.TREE)
body = re.search(r"<pre><code>(.*?)</code></pre>", html, re.S).group(1)
self.assertEqual(body.rstrip("\n").split("\n"), [
".task-manager/",
"├── AGENTS.md ← This file",
"│ ├── VERSION, board.py",
"└── manager/",
])
def test_a_fence_spanning_blank_lines_still_closes(self):
"""The fence state machine spans blocks — a blank line inside a
fence must not end it, and a bullet inside must stay literal."""
html = self.render("```\nfirst\n\n- not a bullet\n```\n\nafter\n")
self.assertEqual(html.count("<pre>"), 1)
self.assertNotIn("<li>", html)
self.assertIn("<p>after</p>", html)
def test_a_table_after_a_list_is_still_a_table(self):
"""Card 30's wrong/right table is the live case."""
html = self.render(
"- a bullet that wraps\n onto a second line\n\n"
"| Turn 1 says | bench actually |\n| --- | --- |\n"
"| `bench.toml` | `manager/local/.env` |\n")
self.assertIn("<table>", html)
self.assertIn("<th>Turn 1 says</th>", html)
self.assertIn("<td><code>bench.toml</code></td>", html)
self.assertEqual(html.count("<li"), 1)
def test_headings_and_rules_are_unchanged(self):
html = self.render("## What to build\n\n---\n\n- item\n")
self.assertIn("<h2>What to build</h2>", html)
self.assertIn("<hr>", html)
self.assertIn("<li>item</li>", html)
def test_html_in_the_source_is_still_escaped(self):
html = self.render("- an item with <script>alert(1)</script> in it\n")
self.assertNotIn("<script>", html)
self.assertIn("&lt;script&gt;", html)
class SourceInvariantTests(unittest.TestCase):
"""Always-run checks on board.html itself, for the case where node is
not installed: the shape of the fix, not its output."""
def test_md_no_longer_maps_physical_lines_to_items(self):
"""`lines.map(l => '<li>…')` is the bug; if it comes back, every
wrapped item sprouts a phantom bullet again."""
md = lift(r"^function md\(src\) \{\n.*?\n\}\n", "its md() renderer")
self.assertNotRegex(md, r"lines\.map\([^)]*<li>")
def test_paragraphs_and_blockquotes_do_not_emit_br(self):
md = lift(r"^function md\(src\) \{\n.*?\n\}\n", "its md() renderer")
self.assertNotIn("<br>", md,
"md() must reflow prose, not preserve the author's "
"wrap column")
def test_the_task_list_glyph_has_its_own_css(self):
"""Colour only ever means state: done reads as --calm, open stays
neutral, and neither reads as an alarm."""
rule = re.search(r"#drawer \.dbody li\.tick\.on \.box\{([^}]*)\}", HTML)
self.assertIsNotNone(rule, "board.html lost the ticked-box rule")
self.assertIn("var(--calm)", rule.group(1))
self.assertNotIn("var(--alarm)", HTML[HTML.index("li.tick"):
HTML.index("li.tick") + 600])
def test_the_renderer_is_still_dependency_free(self):
"""board.html makes no network requests; this stays a function in
it, not a library."""
self.assertNotRegex(HTML, r"<script[^>]+src=")
self.assertNotIn("cdn.", HTML)
if __name__ == "__main__":
unittest.main()