HERMES-GIELIE/tests/test_renderer_comprehensive.py

367 lines
15 KiB
Python
Raw Normal View History

v0.50.216: compression chains, renderer fixes, HTML preview, approval z-index, /steer fix, reasoning chip (#1075) * fix(workspace): add .html/.htm to MIME_MAP so HTML preview renders correctly MIME_MAP was missing entries for .html and .htm. The server fell back to Content-Type: application/octet-stream, which browsers refuse to render as HTML in an iframe — causing a blank white preview. The rest of the pipeline was already correct: the iframe exists in static/index.html, openFile() in static/workspace.js routes .html to showPreview('html'), and _handle_file_raw() in api/routes.py sets the correct CSP sandbox header when ?inline=1 is present. The only missing piece was the MIME type. * test(workspace): lock in MIME_MAP entry for .html/.htm PR #1070 added .html/.htm → text/html to MIME_MAP in api/config.py to fix the blank workspace HTML preview iframe. Without a direct assertion on the MIME_MAP entries, the fix could silently regress (the existing test_779_html_preview.py tests cover the iframe wiring, the inline=1 query handling, and the CSP sandbox header — but none of them touch MIME_MAP itself). Add a single regression test that asserts MIME_MAP['.html'] and MIME_MAP['.htm'] are both 'text/html' so any future removal of those entries fails CI immediately. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(composer): raise .approval-card.visible z-index above .queue-card .queue-card has z-index:2. .approval-card.visible had no z-index, so the queue flyout would render on top of the approval card when both were visible simultaneously — obscuring the Allow/Deny buttons. Fix: add z-index:3 to .approval-card.visible so approvals always render above the queue flyout. Approval is a blocking, security-relevant interaction and must never be obscured by passive UI elements. * test(composer): pin approval-card z-index > queue-card invariant PR #1071 raises .approval-card.visible to z-index:3 so the security- relevant Allow / Deny buttons stay clickable when the queue flyout is also open. Without a regression test, a future CSS edit could silently drop the z-index back below queue-card (z-index:2) and reintroduce the bug — there is no automated UI test covering this stacking interaction. Add a focused regex check that pins the invariant: .approval-card.visible z-index must be strictly greater than .queue-card z-index. Modeled on the existing CSS-regex regression style in tests/test_mobile_layout.py (test_profile_dropdown_not_clipped_by_overflow). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: intercept /steer /interrupt /queue before busy-mode routing in send() Root cause: slash commands entered while the agent is busy never reached the command dispatcher. send() enters the busy block and returns early at line ~50, so the slash-command intercept (~line 56) is never reached. The text was queued as a plain message. When it drained after the turn ended, cmdSteer / cmdInterrupt ran on an idle session, saw no active stream, and showed "No active task to stop." Fix: at the top of the busy block, before checking busyMode, check if the text starts with / and is one of the three control commands. If so, dispatch the handler immediately and return. This lets the user type /steer, /interrupt, or /queue at any time — including while the agent is mid-stream — and have them execute against the live session. Two new regression tests added: - test_slash_commands_intercepted_before_busymode_routing: verifies the intercept appears before the busyMode routing in the busy block - test_steer_intercept_calls_handler_directly: verifies the intercept calls _bc.fn(_pc.args) and returns, not queues * test(busy-intercept): pin sync input-clear before await in slash intercept PR #1072's intercept clears the msg input before awaiting the handler. Order matters: if the await happens first (or if the clear is moved inside the handler), the input still shows '/steer foo' for the duration of the await. A reflexive second Enter press during that window — common while waiting for the toast — re-runs send(): either re-fires the handler (double-steer) or, if the turn just ended, falls through to the non-busy slash dispatcher and drops a confusing "No active task to stop." Add test_steer_intercept_clears_input_before_await pinning the order so this UX invariant cannot silently regress. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: update steer i18n and settings copy — steer no longer interrupts With the real /steer implementation (agent.steer() via /api/chat/steer), steer injects a correction mid-turn WITHOUT interrupting the current stream. The previous copy said "falls back to interrupt", "Steer (interrupt + send)", etc. — accurate only for the old placeholder, not the real implementation. Changes across all 6 locales (en/ru/es/de/zh/zh-Hant): cmd_steer: "falls back to interrupt" removed settings_busy_input_mode_steer: "interrupt + send" → "mid-turn correction" cmd_steer_fallback: "interrupted" → "queued for next turn" busy_steer_fallback: "interrupted instead" → "queued for next turn" settings_desc_busy_input_mode: "currently falls back to interrupt" removed Also: static/index.html: inline fallback text updated to match static/commands.js: internal comment clarified (fallback = queue+cancel, not "interrupt mode" which implies the primary action) * fix(renderer): group consecutive blockquote lines into single element Root cause: the old rule `s.replace(/^> (.+)$/gm, ...)` had three bugs: 1. `.+` required at least one character — bare `>` lines (blank continuation lines) did not match and passed through as literal `>` 2. Each matching line became its own `<blockquote>` element — a 10-line blockquote produced 10 stacked `<blockquote>` tags with no grouping 3. When a fenced code block sat inside a blockquote, the fence-stash pass consumed the code content and left orphaned `>` lines that the old `.+` pattern could not match Fix: replace the single-line regex with a group-based approach that matches one or more consecutive `>` lines as a single block, strips the `>` prefix from each line, passes each non-empty line through inlineMd(), turns blank `>` lines into `<br>`, and wraps the entire group in one `<blockquote>`. 14 regression tests added covering: - Single-line blockquotes (regression) - Multi-line grouping (2 and 10 lines) - Two separate blockquotes staying separate - Bare `>` and `>text` (no space) edge cases - Blank continuation lines → <br> - Bold / italic / inline-code inside blockquotes - Blockquote followed by normal paragraph * fix(renderer): drop empty trailing line from blockquote match The new group-based blockquote rule introduced in this PR captures the trailing newline in its (?:\n|$) clause. After block.split('\n') that trailing newline produces an empty final element. The original filter only dropped lone bare '>' artifacts on the last line, so the empty final element survived, and the .map(blank → '<br>') step turned it into a phantom <br> immediately before </blockquote>. Visible symptom: any blockquote whose source ends with \n (the common case — a quote followed by another paragraph or end-of-message) renders with an extra blank line at the bottom of the quote. Reproducer: '> Hello\n\nThe rest of the message.' → '<blockquote>Hello\n<br></blockquote>\nThe rest of the message.' ^^^ phantom <br> Fix: replace the single-line filter with a while-loop that pops trailing lines while they are either empty OR a bare '>'. This matches the intent the Python test mirror in tests/test_blockquote_rendering.py already had (the mirror was correct; the JS was not — that's why the original tests passed despite the bug). Also add four new regression tests in TestNoPhantomTrailingBr that pin the no-trailing-<br> invariant for the common shapes: - input ending with \n - quote followed by paragraph (the real-world case) - multi-line quote ending with \n - quote with blank continuation + trailing \n (internal <br> stays, trailing <br> does not) Verified end-to-end with node against the actual JS regex. 244 renderer-adjacent tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(renderer): comprehensive markdown fixes — strikethrough, task lists, CRLF, nested blockquotes Five additional fixes on top of the blockquote grouping from the initial commit: 1. CRLF normalisation: strip \r\n → \n at start of renderMd so Windows line endings do not produce stray \r characters in rendered output 2. Strikethrough: ~~text~~ → <del>text</del> in both inlineMd() (for use inside blockquotes/lists) and the outer pass (for plain paragraphs). Added <del> to SAFE_TAGS and SAFE_INLINE so it is not HTML-escaped. 3. Task lists: - [x] / - [ ] items in unordered lists render as ✅/☐ via task-done/task-todo span wrappers. Checks [X] (uppercase) too. 4. Nested blockquotes: >> / >>> etc. now recurse so each level gets its own <blockquote> element rather than passing through as literal >. Implemented by extracting the blockquote rule into _applyBlockquotes() which calls itself recursively on the stripped inner content. 5. Lists inside blockquotes: > - item now renders <ul><li> inside the blockquote instead of a literal "- item" string. Task list items work inside blockquotes too (> - [x] done → ✅ inside <blockquote><ul>). Also fixed test_issue342.py search window (5000→10000 chars) — the CRLF strip at the top of renderMd pushed the autolink regex past the old limit. 68 new tests in test_renderer_comprehensive.py + test_blockquote_rendering.py covering all constructs, edge cases, and combinations. * fix(renderer): restore space in blockquote prefix-strip regex Commit 04e7b53 changed the blockquote prefix-strip regex from /^>[ \t]?/ (consume "> ", "\t>", or just ">") to /^>[\t]?/ (only consume "\t>" or just ">") The space character was dropped from the character class. Since practically every blockquote an LLM produces is "> " (greater-than followed by a space), this leaves a leading space artifact on every stripped blockquote line. Worse, the leading space breaks the list-detection regex `^(?: )?[-*+] ` inside the new `_applyBlockquotes` helper — that regex requires either zero or two leading spaces, never one — so the new "list inside blockquote" feature never fired for the canonical input shape `> - item`. Reproducer (against the actual ui.js via node, before the fix): > Hello world → <blockquote> Hello world</blockquote> ^ phantom leading space > Steps: → <blockquote>Steps: > - one - one > - two - two</blockquote> ^ literal text, NOT a <ul>; lists-in-quote feature broken > - [x] done → blockquote with literal "[x] done", no checkbox span Tests passed despite the bug because tests/test_blockquote_rendering.py and tests/test_renderer_comprehensive.py validate against a Python mirror (`_apply_blockquotes`) whose strip regex is `^>[ \t]?` — i.e. the mirror is correct, the JS is not, and the static-mirror tests can't catch the divergence. Same shape of bug as commit 94d63d0 (phantom <br> in trailing line) where the mirror was right and the JS was wrong. Fix: restore the space character in the strip regex's character class. Add tests/test_renderer_js_behaviour.py — 11 tests that drive the ACTUAL renderMd via node and assert on rendered output for the most common LLM shapes (single-line quote, multi-line quote, list inside quote, task list inside quote, nested >>>, strikethrough inside and outside quote, top-level task list, quote followed by heading, multi-paragraph quote with list, CRLF normalisation). Verified: the buggy regex makes 6 of those 11 tests fail; the corrected regex makes all 11 pass. Suite: 2354 passed, 0 new failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Collapse agent session compression chains * Restore upstream changelog entries * fix(agent_sessions): bubble active compression chains to top by tip last_activity The original PR merge kept the chain head's id/title/started_at and overrode id/model/message_count/ended_at/end_reason from the tip — but did NOT override last_activity. Since the projected list is sorted by last_activity DESC and the WebUI sidebar surfaces updated_at = last_activity, an actively-used compression chain whose tip is being edited NOW would sort by the ROOT's old last_activity and fall below recently touched standalone sessions. Reproducer (with the harness against actual code, before the fix): - root: started 30 days ago, last msg 30 days ago - tip: started 28 days ago (parent_session_id=root), last msg 5 seconds ago - standalone: last msg 2 days ago Sidebar order with original PR: [0] standalone (48h ago) [1] active_tip (last_activity=root's 720h ago) ← wrong Sidebar order after fix: [0] active_tip (last_activity=tip's 0h ago) ← correct [1] standalone (48h ago) This matches Hermes Agent's own list_sessions_rich projection at hermes_state.py:903-909, which overrides "last_active" from the tip exactly so that the agent CLI's session list orders the same way. Add ``last_activity`` to the merge-from-tip key list, update the existing test_compression_chain_collapses_to_latest_tip_in_sidebar assertion to expect tip-derived updated_at, and add test_compression_chain_bubbles_to_top_by_tip_activity locking in the bubble-to-top invariant — without this regression test the previous behaviour passed CI because no test exercised the sort order against a mixed set of chains and standalone sessions. The chain head's started_at (created_at) and title remain preserved, so users can still find the conversation by its original date and name. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: v0.50.216 release notes and version bump Compression chains, renderer fixes, HTML preview, approval z-index, /steer fix. * chore: gitignore local-only review harness directory Adds .local-review/ to .gitignore so renderer drivers, sample inputs, fixture builders, and other reviewer scratch files do not accidentally get committed. Nothing under that path is ever shared in the repo; keeping the entry tracked makes the boundary explicit for any future contributor who creates the directory locally. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Keep reasoning chip visible for None effort * test(reasoning): pin chip render output via node, not just source regex The PR's static checks in test_reasoning_chip_btw_fixes.py validate the shape of _applyReasoningChip (no display='none' literal, the right classList.toggle call exists, the right label literals are in the function body) but pass even if the runtime detail is wrong — for example if `inactive` were inverted, _normalizeReasoningEffort mishandled whitespace, or _formatReasoningEffortLabel returned the wrong literal for an unknown input. Add tests/test_reasoning_chip_js_behaviour.py — 11 tests that drive the actual _applyReasoningChip() via node and assert on the rendered DOM state for each effort value: TestChipAlwaysVisible - empty / null -> "Default" label, inactive=true - "none" -> "None" label, inactive=true - "low"/"high" -> verbatim label, inactive=false TestNormalizationEdgeCases - "NONE" -> normalises to "None" - " none " -> trims and normalises - unknown junk -> falls through visible, never hidden TestTitleAttributeAccessibility - title attribute carries the human-readable label for tooltip / screen-reader use Sanity-checked against master's pre-fix ui.js: 11/11 fail (bug caught). Against this PR's ui.js: 11/11 pass. This pattern (drive the actual JS via node) caught two regex-only regressions in PR #1073 where the Python mirror was correct while the JS was broken. Same protection added here so the chip-visibility contract can't silently break in a future refactor. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: add #1074 to v0.50.216 changelog, bump test count to 2428 * fix(i18n): restore broken Unicode in Russian and Spanish steer strings Commit 56c7a14 (fix: update steer i18n and settings copy) accidentally stripped the `\u` prefix from Unicode escape sequences in two locales, producing garbled literal hex strings visible to users: Spanish (es): - cmd_steer: correcci00f3n → corrección - cmd_steer_fallback: 2014 en cola → — en cola - busy_steer_fallback: 2014 en cola → — en cola - settings_desc_busy_input_mode: qu00e9, est00e1, correcci00f3n → qué, está, corrección - settings_busy_input_mode_steer: correcci00f3n → corrección Russian (ru): - settings_desc_busy_input_mode: the entire Cyrillic string was replaced with raw 4-hex-char code-points without the \u prefix (041e043f... instead of actual Cyrillic). Decoded: "Определяет поведение при отправке сообщения во время работы агента. Очередь ждёт; Прерывание отменяет и начинает заново; Steer внедряет коррекцию без прерывания." Fix: write the correct characters directly (UTF-8 is the file encoding so embedding them literally is cleaner than \u escapes for long text). All other locales (en, de, zh, zh-Hant) were not affected — confirmed by grepping for bare hex run-ons in the updated file. Verified: node --check static/i18n.js passes; full pytest suite green (2365 passed, 47 skipped). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: remove duplicate compression chain entry from [Unreleased] --------- Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Frank Song <franksong2702@gmail.com>
2026-04-26 04:06:31 +00:00
"""Comprehensive renderer audit tests for static/ui.js renderMd().
This file covers the full suite of markdown constructs an LLM might produce,
with a focus on edge cases and combinations. Tests are grouped by construct.
Python mirrors the renderMd/inlineMd pipeline at the level needed for each
test — either source-level assertions (checking the JS source directly) or
behavioural assertions (checking rendered HTML via a Python mirror).
"""
import re
import pathlib
UI_JS = (pathlib.Path(__file__).parent.parent / "static" / "ui.js").read_text(encoding="utf-8")
import html as _html
def _esc(s):
return _html.escape(str(s), quote=True)
def _inline_md(t):
"""Mirror of inlineMd() in ui.js — processes one line of text."""
_code_stash = []
t = re.sub(r"`([^`\n]+)`",
lambda m: (_code_stash.append(f"<code>{_esc(m.group(1))}</code>")
or f"\x00C{len(_code_stash)-1}\x00"), t)
t = re.sub(r"\*\*\*(.+?)\*\*\*", lambda m: f"<strong><em>{_esc(m.group(1))}</em></strong>", t)
t = re.sub(r"\*\*(.+?)\*\*", lambda m: f"<strong>{_esc(m.group(1))}</strong>", t)
t = re.sub(r"\*([^*\n]+)\*", lambda m: f"<em>{_esc(m.group(1))}</em>", t)
t = re.sub(r"~~(.+?)~~", lambda m: f"<del>{_esc(m.group(1))}</del>", t)
t = re.sub(r"\x00C(\d+)\x00", lambda m: _code_stash[int(m.group(1))], t)
return t
def _apply_blockquotes(src):
"""Mirror of _applyBlockquotes() — handles nested + lists + blank lines."""
def replacer(m):
block = m.group(0)
lines = block.split("\n")
while lines and (lines[-1].strip() in (">", "")):
if lines[-1].strip() == ">":
lines.pop(); break
lines.pop()
stripped = [re.sub(r"^>[ \t]?", "", l) for l in lines]
inner_raw = "\n".join(stripped)
if re.search(r"^>", inner_raw, re.MULTILINE):
inner = _apply_blockquotes(inner_raw)
elif re.search(r"^( )?[-*+] .+", inner_raw, re.MULTILINE):
def inner_list(lb):
ll = lb.strip().split("\n"); h = "<ul>"
for li in ll:
txt = re.sub(r"^ {0,4}[-*+] ", "", li)
if re.match(r"\[x\] ", txt, re.I): ih = f"✅ {_inline_md(txt[4:])}"
elif txt.startswith("[ ] "): ih = f"☐ {_inline_md(txt[4:])}"
else: ih = _inline_md(txt)
h += f"<li>{ih}</li>"
return h + "</ul>"
inner = re.sub(r"((?:^(?: )?[-*+] .+\n?)+)", lambda m2: inner_list(m2.group(0)),
inner_raw, flags=re.MULTILINE)
else:
inner = "\n".join("<br>" if l.strip() == "" else _inline_md(l) for l in stripped)
return f"<blockquote>{inner}</blockquote>"
return re.sub(r"((?:^>[^\n]*(?:\n|$))+)", replacer, src, flags=re.MULTILINE)
# ─────────────────────────────────────────────────────────────────────────────
# Source-level structural checks (JS must contain these patterns)
# ─────────────────────────────────────────────────────────────────────────────
class TestSourceStructure:
"""Verify key patterns are present in ui.js."""
def test_crlf_normalisation_present(self):
assert ".replace(/\\r\\n/g,'\\n').replace(/\\r/g,'\\n')" in UI_JS, (
"renderMd must normalise \\r\\n and bare \\r to \\n at the start"
)
def test_strikethrough_in_inline_md(self):
assert "~~(.+?)~~" in UI_JS and "<del>" in UI_JS, (
"inlineMd must handle ~~strikethrough~~ → <del>"
)
def test_del_in_safe_tags(self):
assert "del" in UI_JS and "SAFE_TAGS" in UI_JS, (
"<del> must be in SAFE_TAGS so it is not HTML-escaped"
)
def test_del_in_safe_inline(self):
# SAFE_INLINE is used inside inlineMd
safe_inline_idx = UI_JS.find("SAFE_INLINE")
assert safe_inline_idx >= 0
window = UI_JS[safe_inline_idx: safe_inline_idx + 100]
assert "del" in window, "<del> must be in SAFE_INLINE"
def test_task_list_checked_handled(self):
assert "task-done" in UI_JS or "\\u2705" in UI_JS or "✅" in UI_JS, (
"Checked task list items [x] must produce a ✅ or task-done class"
)
def test_task_list_unchecked_handled(self):
assert "task-todo" in UI_JS or "\\u2610" in UI_JS or "☐" in UI_JS, (
"Unchecked task list items [ ] must produce ☐ or task-todo class"
)
def test_nested_blockquote_recurse(self):
assert "_applyBlockquotes" in UI_JS, (
"Blockquote handler must use a named function for recursive nesting"
)
def test_blockquote_handler_is_function(self):
assert "function _applyBlockquotes" in UI_JS, (
"Must define _applyBlockquotes as a named inner function for recursion"
)
def test_old_single_line_blockquote_removed(self):
assert "replace(/^> (.+)$/gm" not in UI_JS, (
"Old single-line blockquote rule must be removed"
)
def test_h1_h2_h3_handled(self):
for h in ("h1", "h2", "h3"):
assert f"<{h}>" in UI_JS or f"`<{h}>" in UI_JS
def test_ordered_list_value_attr(self):
assert 'value=' in UI_JS, "Ordered list items must use value= to preserve numbering"
def test_table_handler_present(self):
assert "<table>" in UI_JS and "<thead>" in UI_JS
def test_fenced_code_lang_header(self):
assert "pre-header" in UI_JS
def test_autolink_present(self):
# JS stores regex slashes as \/ — search for both forms
assert ("https?:\\/\\/" in UI_JS or "https?://" in UI_JS) and "target=\"_blank\"" in UI_JS
# ─────────────────────────────────────────────────────────────────────────────
# Behavioural: inline formatting
# ─────────────────────────────────────────────────────────────────────────────
class TestInlineFormatting:
def test_bold(self):
assert _inline_md("**bold**") == "<strong>bold</strong>"
def test_italic(self):
assert _inline_md("*italic*") == "<em>italic</em>"
def test_bold_italic(self):
out = _inline_md("***bi***")
assert "<strong><em>" in out
def test_strikethrough(self):
out = _inline_md("~~deleted~~")
assert "<del>deleted</del>" == out
def test_strikethrough_inline(self):
out = _inline_md("keep ~~remove~~ keep")
assert "<del>remove</del>" in out
assert "keep" in out
def test_inline_code(self):
out = _inline_md("`git status`")
assert "<code>git status</code>" in out
def test_strikethrough_inside_code_not_processed(self):
out = _inline_md("`~~not deleted~~`")
assert "<del>" not in out
assert "~~not deleted~~" in out
def test_bold_with_inline_code(self):
# **`code`** → <strong><code>code</code></strong>
out = _inline_md("**`code`**")
# The code stash protects the backtick span from bold regex
assert "<code>" in out
def test_xss_in_bold(self):
out = _inline_md("**<script>alert(1)</script>**")
assert "<script>" not in out
def test_xss_in_strikethrough(self):
out = _inline_md("~~<img onerror=alert(1)>~~")
assert "onerror" not in out.lower() or "&lt;" in out
# ─────────────────────────────────────────────────────────────────────────────
# Behavioural: blockquotes
# ─────────────────────────────────────────────────────────────────────────────
class TestBlockquotes:
def test_single_line(self):
out = _apply_blockquotes("> Hello")
assert out.count("<blockquote>") == 1
assert "Hello" in out
def test_multi_line_grouped(self):
out = _apply_blockquotes("> Line one\n> Line two\n> Line three")
assert out.count("<blockquote>") == 1
def test_blank_continuation_no_literal_gt(self):
out = _apply_blockquotes("> Para one\n>\n> Para two")
assert out.count("<blockquote>") == 1
text = re.sub(r"<[^>]+>", "", out)
assert ">" not in text, f"Literal > in output: {text!r}"
def test_blank_continuation_becomes_br(self):
out = _apply_blockquotes("> Para one\n>\n> Para two")
assert "<br>" in out
def test_bare_gt_no_space(self):
out = _apply_blockquotes(">no space after")
assert out.count("<blockquote>") == 1
assert "no space after" in out
def test_two_separate_blockquotes(self):
out = _apply_blockquotes("> First\n\n> Second")
assert out.count("<blockquote>") == 2
def test_inline_markdown_in_blockquote(self):
out = _apply_blockquotes("> **bold** and *italic*")
assert "<strong>" in out and "<em>" in out and "<blockquote>" in out
def test_inline_code_in_blockquote(self):
out = _apply_blockquotes("> run `git status` first")
assert "<code>" in out and "<blockquote>" in out
def test_strikethrough_in_blockquote(self):
out = _apply_blockquotes("> ~~old~~ new")
assert "<del>" in out and "<blockquote>" in out
def test_nested_blockquote_double(self):
out = _apply_blockquotes(">> deeply nested")
assert out.count("<blockquote>") == 2
def test_nested_blockquote_outer_and_inner(self):
out = _apply_blockquotes("> outer\n>> inner line")
assert out.count("<blockquote>") == 2
def test_list_inside_blockquote(self):
out = _apply_blockquotes("> - item one\n> - item two")
assert "<ul>" in out and "<li>" in out and "<blockquote>" in out
def test_task_list_inside_blockquote(self):
out = _apply_blockquotes("> - [x] done\n> - [ ] todo")
assert "✅" in out or "task-done" in out
assert "☐" in out or "task-todo" in out
assert "<blockquote>" in out
def test_blockquote_followed_by_paragraph(self):
out = _apply_blockquotes("> Quoted\n\nNormal text")
assert out.count("<blockquote>") == 1
after = out[out.index("</blockquote>"):]
assert "Normal text" in after
# ─────────────────────────────────────────────────────────────────────────────
# Behavioural: task lists
# ─────────────────────────────────────────────────────────────────────────────
class TestTaskLists:
def _apply_list(self, block):
lines = block.strip().split("\n")
html = "<ul>"
for l in lines:
text = re.sub(r"^ {0,4}[-*+] ", "", l)
if re.match(r"\[x\] ", text, re.I):
html += f"<li>✅ {_inline_md(text[4:])}</li>"
elif text.startswith("[ ] "):
html += f"<li>☐ {_inline_md(text[4:])}</li>"
else:
html += f"<li>{_inline_md(text)}</li>"
return html + "</ul>"
def test_checked_item(self):
out = self._apply_list("- [x] done task")
assert "✅" in out and "done task" in out
def test_checked_uppercase_X(self):
out = self._apply_list("- [X] also done")
assert "✅" in out
def test_unchecked_item(self):
out = self._apply_list("- [ ] pending task")
assert "☐" in out and "pending task" in out
def test_mixed_task_and_normal(self):
out = self._apply_list("- [x] done\n- [ ] todo\n- normal")
assert "✅" in out and "☐" in out
assert "<li>" in out
def test_task_item_with_bold(self):
out = self._apply_list("- [x] **important** task")
assert "✅" in out and "<strong>" in out
def test_non_task_list_unaffected(self):
out = self._apply_list("- regular item\n- another item")
assert "✅" not in out and "☐" not in out
# ─────────────────────────────────────────────────────────────────────────────
# Behavioural: strikethrough edge cases
# ─────────────────────────────────────────────────────────────────────────────
class TestStrikethrough:
def test_basic(self):
assert _inline_md("~~text~~") == "<del>text</del>"
def test_multiword(self):
out = _inline_md("~~multiple words here~~")
assert "<del>multiple words here</del>" == out
def test_inside_bold(self):
# **~~text~~** — outer bold picks up the raw ~~ which inlineMd then handles
# In practice bold runs first in the JS, then ~~ — let's verify the pattern exists
out = _inline_md("~~inside strikethrough~~")
assert "<del>" in out
def test_xss_escaped(self):
out = _inline_md("~~<b>bad</b>~~")
assert "<b>" not in out or "&lt;b&gt;" in out
# ─────────────────────────────────────────────────────────────────────────────
# Edge-case combinations
# ─────────────────────────────────────────────────────────────────────────────
class TestEdgeCases:
def test_empty_string(self):
out = _apply_blockquotes("")
assert out == ""
def test_no_blockquote(self):
s = "just normal text"
assert _apply_blockquotes(s) == s
def test_crlf_in_blockquote(self):
# \r\n should not produce literal \r in output
src = "> line one\r\n> line two"
# First normalise \r\n (as renderMd does)
src = src.replace("\r\n", "\n")
out = _apply_blockquotes(src)
assert "\r" not in out
assert out.count("<blockquote>") == 1
def test_blockquote_with_code_and_nested(self):
src = "> `code`\n>> nested"
out = _apply_blockquotes(src)
# Outer blockquote wraps everything
assert out.count("<blockquote>") >= 2
def test_deeply_nested_blockquote(self):
src = ">>> triple nested"
out = _apply_blockquotes(src)
assert out.count("<blockquote>") == 3
def test_task_list_normal_list_mixed(self):
src = "> - [x] done\n> - normal item\n> - [ ] todo"
out = _apply_blockquotes(src)
assert "<blockquote>" in out
assert "<ul>" in out