"
# the assembly order inside the push template: wrap, table, thead,
# (tbody), close — pinned on the quoted source literals (the header
# comment mentions the shapes out of order on purpose)
wrap_lit = "'\\n'"
table_lit = "'
\\n'"
assert js.find(wrap_lit) != -1, "the template must open the .md-table-wrap div"
assert js.find(table_lit) != -1, "the template must open the table inside the wrap"
assert js.find(wrap_lit) < js.find(table_lit), "the wrap opens before the table"
assert '
\\n
' in js, "the template closes the table, then the wrap"
def test_header_and_body_rows_pad_and_truncate_to_header_width() -> None:
"""Defensive ragged rows: short rows are padded with empty s,
long rows are truncated to the header width (mock and real answers
are well-formed; this only guards the parser)."""
js = _text(MARKDOWN_JS)
assert re.search(r"cells\.push\(\"\"\)", js), "short rows must be padded"
assert re.search(r"slice\(0,\s*width\)", js), "long rows must be truncated"
assert "const width = header.length;" in js, "the header row sets the column count"
def test_placeholder_restored_alongside_code_blocks() -> None:
"""Step 4 is the ONLY place placeholders re-expand — the TABLE
placeholders are restored there, next to the CODE ones, so the
final table HTML never re-enters the escape/paragraph passes."""
js = _text(MARKDOWN_JS)
fn = js.find("function renderMarkdown")
assert fn != -1
body = js[fn:]
# step 4 is the ONLY restore point and it restores BOTH placeholder
# kinds, indexing their arrays
assert r"\u0000TABLE(\d+)\u0000/g" in body, (
"step 4 must restore the \\u0000TABLEn\\u0000 placeholders"
)
assert "tables[Number(i)]" in body, "restoration must index the tables array"
assert r"\u0000CODE(\d+)\u0000/g" in body, "the CODE restore stays in step 4"
# the TABLE restore is AFTER the paragraph pass — the restored final
# HTML never re-enters it
assert body.find(r"\u0000TABLE(\d+)\u0000/g") > body.find("split(/\\n{2,}/)"), (
"placeholders are restored AFTER the paragraph pass"
)
def test_alignment_colons_are_parsed_but_ignored() -> None:
"""Owner decision (2026-08-27): the separator's alignment colons
match the cell rule (so ':---:' is a valid separator) but are never
turned into CSS alignment — the CSS pins every cell to left."""
js = _text(MARKDOWN_JS)
assert r"^\s*:?-+:?\s*$" in js, "colons must be accepted in separators"
css = _text(STYLES_CSS)
th_td = re.search(r"\.md-table th, .md-table td \{([^}]*)\}", css)
assert th_td, "styles.css must style .md-table th/td"
assert "text-align: left" in th_td.group(1), "all cells render left-aligned"
def test_header_comment_notes_the_table_pass() -> None:
"""The file's header comment records the extension (2026-08-27,
TODO.md L6) — the ~60-line no-CDN renderer now also does tables."""
header = _text(MARKDOWN_JS).split("*/", 1)[0]
assert "Phase 44" in header
assert "2026-08-27" in header
assert "TODO.md L6" in header
assert re.search(r"table", header, re.I), "the header comment must mention tables"
assert "no CDN" in header, "the no-CDN contract stays stated (A11)"
# ---------- styles.css: the .md-table / .md-table-wrap rules ----------
def test_table_wrap_is_the_horizontal_scroller() -> None:
""".md-table-wrap { overflow-x: auto } — the wrapper (not the
table) is the scroller, so a wide table scrolls inside the bubble
instead of breaking the 46rem column (story AC5)."""
css = _text(STYLES_CSS)
block = re.search(r"\.md-table-wrap\s*\{([^}]*)\}", css)
assert block, "styles.css must define .md-table-wrap"
assert re.search(r"overflow-x:\s*auto", block.group(1)), (
"the wrapper must scroll horizontally"
)
def test_table_rule_and_cell_borders_use_the_palette() -> None:
""".md-table is border-collapse, full-width (min-width: max-content
keeps a wide table at its natural width so the wrap scrolls — phase
44 task 03), 0.9rem; th/td carry the --line 1px borders, 0.4rem
0.6rem padding, left alignment, top vertical alignment; the thead is
tinted from the PLAIN surface family (PLAN §7.2: --ink on --surface
is 14.5:1) — never the brand tokens."""
css = _text(STYLES_CSS)
table = re.search(r"(?= 4.5:1)"
assert "--brand" not in thead_body, "never the brand tokens on the thead"
def test_table_rules_carry_no_motion() -> None:
"""Reduced-motion consistency: the table rules are static content —
no animation/transition for prefers-reduced-motion to still (the
phase-44 comment states this, like the phase-08/22/36 static
components)."""
css = _text(STYLES_CSS)
rules = (
r"\.md-table-wrap",
r"(? None:
"""Placement (task 01 step 2): the table rules sit with the
markdown/content styling — right after the .bubble code rules,
before the .msg.user block — and are unscoped so the shared
renderer's tables in the thinking scratchpad and the document
viewer (.doc-md) get the same treatment."""
css = _text(STYLES_CSS)
assert css.find('.bubble pre code {') < css.find(".md-table-wrap"), (
"the table rules follow the bubble markdown content rules"
)
assert css.find(".md-table thead th") < css.find(".msg.user {"), (
"the table rules stay in the Messages section"
)
# no .bubble-scoped table variant: one rule set serves all consumers
assert re.search(r"\.bubble\s+\.md-table", css) is None, (
"the table rules must be unscoped (shared renderer consumers)"
)
# ---------- behavior (node): the story's acceptance shapes ----------
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_table_renders_semantic_xss_safe_and_fences_win() -> None:
"""The four story shapes executed under node: (1) a pipe table with
leading/trailing pipes + inline markdown in cells renders the
shape with
headers and padded ragged rows, and NO raw
separator line survives; (2) a cell with an HTML tag is escaped;
(3) a |-heavy fenced block stays ; (4) a lone pipe in
prose, a separator without a header, and a 1-line 'table' stay
text."""
js = _text(MARKDOWN_JS)
cases = {
"table": (
"Here:\n\n| Service | Port |\n|:---|---:|\n| **api** | 8000 |\n| web |",
[
'',
' ',
"| Service | Port | ",
"| api | 8000 | "
"| web | | ",
" \n ",
],
["|:---|---:|", "| **api** |"],
),
"xss": (
"| c |\n|---|\n| & co |",
["<img src=x onerror=alert(1)> & co"],
["![]() | a | b |"],
["a | b prose", "--- ", "| only a line ", "end |---| "],
["
| |