"""Unit: the shared renderer's markdown blockquote pass (phase 138, task 01). Browser behavior is E2E-covered by ``tests/e2e/test_markdown_blockquotes.py`` (task 03); here we pin the source-level contract — the pass ordering (fences → tables → escape+inline → blockquotes → paragraphs → restore), the group rule (maximal run of ``>``-prefixed lines; blank line terminates; nested collapses — locked A1), the exact output marker, the extended paragraph guard, and the ``.md-quote`` CSS rule (palette vars only — task 02's pin) — so a silent regression is caught without a browser (the established frontend pattern). A small node-driven behavioral check (skipped when node is unavailable, like the phase-44 table check) proves the story acceptance shapes: single-line quote, multi-line group, nested collapse, XSS quote inert, fence-wins, table-cell untouched, blank-line termination, and non-quote input byte-identical to the pre-phase renderer (fixture string checked into the test, per the phase-44 shape). """ from __future__ import annotations import json import re import shutil import subprocess from pathlib import Path import pytest FRONTEND = Path(__file__).resolve().parents[2] / "frontend" ASSETS = FRONTEND / "assets" MARKDOWN_JS = ASSETS / "markdown.js" STYLES_CSS = ASSETS / "styles.css" HAVE_NODE = shutil.which("node") is not None MARKER = '
' #: The non-quote input (a paragraph + list + a pipe table) and its #: byte-EXACT pre-phase renderer output (the fixture string checked #: into the test, per the phase-44 shape) — the byte-identity contract #: (phase 138 design): anything without a quote line renders exactly #: as it did before the blockquote pass existed. (The blank line before #: the list is absorbed by the pre-existing ``^\\s*[-*] `` list rule — #: the fixture pins that too.) NO_QUOTE_INPUT = ( "A plain paragraph about the homelab.\n" "\n" "- item one\n" "- item two\n" "\n" "| Col A | Col B |\n" "|---|---|\n" "| 1 | 2 |" ) NO_QUOTE_PRE_PHASE_OUTPUT = ( "A plain paragraph about the homelab.
" "" '
- item one
- item two
\n' '" ) def _text(path: Path) -> str: assert path.is_file(), f"missing frontend file: {path}" return path.read_text(encoding="utf-8") def _run_node(script: str) -> str: proc = subprocess.run(["node", "-e", script], capture_output=True, text=True, timeout=60) assert proc.returncode == 0, f"node failed: {proc.stderr}" return proc.stdout # ---------- markdown.js: the blockquote pass (step 2b) ---------- def test_blockquote_pass_runs_after_inline_and_before_paragraphs() -> None: """The pass order is fences → tables → escape+inline → blockquotes → paragraphs → restore (phase 138 design). The blockquote pass sits between step 2 (``inline(escapeHtml(text))`` — after it, a quote line starts with the escaped ``>`` marker) and step 3 (the paragraph split), so inline code/strong/em already work inside quotes for free and the paragraph guard leaves the block alone.""" js = _text(MARKDOWN_JS) inline_step = "text = inline(escapeHtml(text));" quote_pass = 'const quoteLines = text.split("\\n");' para_split = "split(/\\n{2,}/)" assert inline_step in js, "step 2 still escapes+inlines the whole text" assert quote_pass in js, "the blockquote pass splits the text into lines" assert js.find(inline_step) < js.find(quote_pass), ( "the blockquote pass must run AFTER the escape+inline step " "(a quote line only starts with > once escaped)" ) assert js.find(quote_pass) < js.find(para_split), ( "the blockquote pass must run BEFORE the paragraph split" ) def test_paragraph_guard_includes_blockquote() -> None: """The step-3 block-element guard extends to ``/^<(h\\d|ul|ol|pre|li|blockquote)/`` so the paragraph pass leaves the emitted\n' '
\n\n' " Col A Col B \n" " 1 2 alone (nowrap) — the pre-phase guard (without blockquote) must be gone.""" js = _text(MARKDOWN_JS) assert r"/^<(h\d|ul|ol|pre|li|blockquote)/" in js, ( "the step-3 guard must include blockquote" ) assert r"/^<(h\d|ul|ol|pre|li)/" not in js, ( "the pre-phase guard (without blockquote) must be replaced" ) def test_blockquote_marker_and_group_rule() -> None: """The exact output marker string and the group rule: the maximal run of consecutive lines matching ``^\\s*(?:>)+ ?`` (leading whitespace allowed, the nested ``>>``/``>>>`` run allowed, ONE optional space after the run) is ONE ``
`` — the marker run + one optional space is stripped from each member, members join with ``
`` (the paragraph convention).""" js = _text(MARKDOWN_JS) assert MARKER in js, "the exact marker string must be emitted" group_rule = r"^\s*(?:>)+ ?" assert js.count(group_rule) >= 2, ( "the group rule ^\\s*(?:>)+ ? must both TEST and STRIP " "(one occurrence in the test, one in the strip replace)" ) assert re.search(r'members\.join\("
"\)', js), ( "group members must be joined with
" ) # the strip replaces the leading run (+ one optional space) — the # nested >> collapses to one level (locked A1) assert r'replace(/^\s*(?:>)+ ?/, "")' in js, ( "the strip must remove the > run plus ONE optional space" ) def test_md_quote_css_references_palette_vars_only() -> None: """Task 02's pin: the ``.md-quote`` rule (and the user-bubble override, if present) reference :root palette vars ONLY — no hex/rgb/hsl color literal (the :root-drift unit test parses the built-ins from this file, so a literal here would be a second source of truth), and the rule block carries a >=4.5:1 contrast note per the house comment convention.""" css = _text(STYLES_CSS) root = re.search(r":root\s*\{", css) assert root, "styles.css must define :root" root_body = css[root.end() : css.find("}", root.end())] palette = set(re.findall(r"(--[a-z0-9-]+)\s*:", root_body)) assert palette, "the :root palette must declare variables" rule_bodies = re.findall(r"(? None: """The story shapes executed under node: (1) a single-line quote renders ONEwith no literal > (the bug, TODO.md L3); (2) a 3-line group is ONE blockquote with
joins; (3) nested >> / >>> collapses to one level (locked A1); (4) a blank line terminates the group (two blockquotes); (5) an ", [], ["