diff --git a/manager/core/board.html b/manager/core/board.html index c4376b1..a97927f 100644 --- a/manager/core/board.html +++ b/manager/core/board.html @@ -447,6 +447,17 @@ #drawer .dbody h2{font-size:14px;margin:18px 0 6px} #drawer .dbody h3{font-size:13px;margin:14px 0 4px} #drawer .dbody p,#drawer .dbody li{font-size:12.5px;color:var(--muted)} + #drawer .dbody ul,#drawer .dbody ol{margin:6px 0;padding-left:18px} + #drawer .dbody li{margin:3px 0} + #drawer .dbody li>ul,#drawer .dbody li>ol{margin:3px 0} + /* task-list items: a glyph, not an input — the file is the source of truth */ + #drawer .dbody li.tick{list-style:none;margin-left:-18px} + #drawer .dbody li.tick .box{ + display:inline-block;width:11px;height:11px;margin-right:7px; + border:1px solid var(--border);border-radius:3px; + font-size:9px;line-height:10px;text-align:center; + } + #drawer .dbody li.tick.on .box{border-color:var(--calm);color:var(--calm)} #drawer .dbody strong{color:var(--text)} #drawer .dbody code{font-family:var(--mono);font-size:11.5px;background:var(--sunken);padding:1px 4px;border-radius:3px} #drawer .dbody pre{background:var(--sunken);border-radius:8px;padding:10px 12px;overflow-x:auto} @@ -1900,7 +1911,8 @@ async function openExtra(dir, name) { } } -// Small markdown renderer — enough for these task files, no dependencies. +// Small markdown renderer — for these task files, which are hard-wrapped +// prose: a logical line spans several source lines. No dependencies. function md(src) { const blocks = esc(src).split(/\n{2,}/); const inline = (t) => t @@ -1909,6 +1921,56 @@ function md(src) { .replace(/(^|[\s(])\*([^*\n]+)\*/g, '$1$2') .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + /* Lists: group physical lines into logical items before rendering. A new + item begins only at a marker; a line without one is continuation text + joined to the item above with a space — that is what stops a wrapped + item from sprouting a second bullet. A marker indented past the level + it sits in opens a nested list, and a shallower one closes back to the + level that fits, so children indent under their parent instead of + flattening beside it. */ + const MARKER = /^([ \t]*)(?:[-*]|\d+\.)[ \t]+(.*)$/; + const ORDERED = /^[ \t]*\d+\./; + const TICK = /^\[([ xX])\]\s*(.*)$/; // - [ ] / - [x], read-only here + + const listTree = (lines) => { + const root = { ordered: ORDERED.test(lines[0]), items: [] }; + const stack = [{ indent: null, list: root }]; + let last = null; + for (const line of lines) { + const m = line.match(MARKER); + if (!m) { // continuation of the item above + if (last && line.trim()) last.text += ' ' + line.trim(); + continue; + } + const indent = m[1].replace(/\t/g, ' ').length; + const top = () => stack[stack.length - 1]; + if (top().indent === null) top().indent = indent; + while (stack.length > 1 && indent < top().indent) stack.pop(); + if (last && indent > top().indent) { + last.sub = { ordered: ORDERED.test(line), items: [] }; + stack.push({ indent, list: last.sub }); + } + last = { text: m[2], sub: null }; + top().list.items.push(last); + } + return root; + }; + + const listHtml = (list) => { + const tag = list.ordered ? 'ol' : 'ul'; + return `<${tag}>` + list.items.map((it) => { + const t = it.text.match(TICK); + const body = inline(t ? t[2] : it.text) + (it.sub ? listHtml(it.sub) : ''); + if (!t) return `
  • ${body}
  • `; + // A glyph, never an input: the task file is the source of truth and + // the drawer is not an editor. Done reads as settled, open is neutral. + const done = t[1] !== ' '; + return `
  • ` + + `` + + `${done ? '✓' : ''}${body}
  • `; + }).join('') + ``; + }; + let inFence = false, fenced = []; const out = []; for (const block of blocks) { @@ -1923,7 +1985,8 @@ function md(src) { } continue; } - const lines = block.split('\n'); + const text = block.replace(/\s+$/, ''); // the split leaves the last block a trailing newline + const lines = text.split('\n'); if (lines.length >= 2 && /^\s*\|.*\|\s*$/.test(lines[0]) && /^\s*\|[\s:|-]+\|\s*$/.test(lines[1])) { const cells = (l) => l.trim().replace(/^\||\|$/g, '').split('|').map(c => inline(c.trim())); const head = cells(lines[0]); @@ -1932,21 +1995,20 @@ function md(src) { head.map(h => `${h}`).join('') + '' + rows.map(r => '' + r.map(c => `${c}`).join('') + '').join('') + ''); - } else if (/^#{1,6}\s/.test(block)) { + } else if (/^#{1,6}\s/.test(text)) { out.push(lines.map(l => { const m = l.match(/^(#{1,6})\s+(.*)$/); return m ? `${inline(m[2])}` : `

    ${inline(l)}

    `; }).join('')); - } else if (/^\s*[-*]\s/.test(block)) { - out.push(''); - } else if (/^\s*\d+\.\s/.test(block)) { - out.push('
      ' + lines.map(l => `
    1. ${inline(l.replace(/^\s*\d+\.\s+/, ''))}
    2. `).join('') + '
    '); - } else if (/^>/.test(block)) { - out.push('
    ' + inline(block.replace(/^>\s?/gm, '')).replace(/\n/g, '
    ') + '
    '); - } else if (/^-{3,}$/.test(block.trim())) { + } else if (MARKER.test(lines[0])) { + out.push(listHtml(listTree(lines))); + } else if (/^>/.test(text)) { + // reflow, don't preserve the author's wrap column + out.push('
    ' + inline(text.replace(/^>\s?/gm, '')).replace(/\n/g, ' ') + '
    '); + } else if (/^-{3,}$/.test(text.trim())) { out.push('
    '); } else { - out.push(`

    ${inline(block).replace(/\n/g, '
    ')}

    `); + out.push(`

    ${inline(text).replace(/\n/g, ' ')}

    `); } } if (fenced.length) out.push(`
    ${fenced.join('\n\n')}
    `); diff --git a/tests/test_drawer_markdown.py b/tests/test_drawer_markdown.py new file mode 100644 index 0000000..db78df6 --- /dev/null +++ b/tests/test_drawer_markdown.py @@ -0,0 +1,387 @@ +"""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 +`
    `. + +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)>).)*?)", re.S) + + def items(self, html: str) -> list[str]: + """The text of each top-level
  • : nested lists dropped, markup + stripped, so a test can assert on what the reader sees.""" + body = re.sub(r"\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'', "", body) # the tick glyph + return [re.sub(r"<[^>]+>", "", li).strip() + for li in re.findall(r"]*>(.*?)
  • ", 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("", 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("
      "), 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"]*)>", 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(' 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("]*)>", 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
      • child a
      • child b
      ") + + 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("
        "), 2) + self.assertEqual(html.count("
      "), 2) + self.assertTrue(html.endswith("")) + + 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("
    1. the child wraps across two source lines
    2. ", 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("
        "), html.count("
      ")) + for text in ("a", "b", "c", "d"): + self.assertIn(f"
    3. {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("
      1. first
      2. second
      ", 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("
      ", html) + self.assertIn("task files are hard-wrapped", html) + + def test_a_blockquote_reflows_too(self): + html = self.render("> a quoted line\n> and its continuation\n" + .replace(">", ">")) + self.assertNotIn("
      ", 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("
      ", 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("'), html.count("(.*?)", 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("
      "), 1)
      +        self.assertNotIn("
    4. ", html) + self.assertIn("

      after

      ", 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("", html) + self.assertIn("", html) + self.assertIn("", html) + self.assertEqual(html.count("What to build", html) + self.assertIn("
      ", html) + self.assertIn("
    5. item
    6. ", html) + + def test_html_in_the_source_is_still_escaped(self): + html = self.render("- an item with in it\n") + self.assertNotIn("
      Turn 1 saysbench.toml