Files
our-claude-skills/custom-skills/32-notion-writer/code/scripts/test_parser.py
Andrew Yim aac1082bb7 feat(notion-writer): add toggle blocks via HTML5 <details>
Multi-line <details>...</details> with optional <summary> emits a Notion
toggle block. Body recurses through _parse_lines so any block type
(lists, code, nested toggles) is supported inside. Depth tracking lets
<details> nest inside <details>. Unclosed <details> at EOF degrades to
plain paragraphs with a stderr warning instead of crashing.

+4 tests, 26 passing total.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 11:20:21 +09:00

364 lines
14 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Parser tests for notion_writer.py — run with `python test_parser.py`."""
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from notion_writer import markdown_to_notion_blocks, parse_rich_text
FIXTURE = """# Top Heading
## Section With **Bold** And `code`
Plain paragraph with *italic*, **bold**, `inline`, a [link](https://example.com), and ~~strike~~.
- First bullet
- Second bullet with **bold**
* Alt bullet marker
1. First numbered
2. Second numbered
- [ ] Unchecked task
- [x] Checked task
> A quote with *emphasis*
```python
print("hello")
```
---
| Task | Priority | Status |
|------|----------|--------|
| Fix HTML reporter | P0 | Pending |
| Korean encoding | **P1** | In Progress |
| Type hints | P2 | [link](https://example.com/types) |
"""
def _assert(cond, msg):
if not cond:
print(f" ✗ FAIL: {msg}")
raise SystemExit(1)
print(f"{msg}")
def test_rich_text_plain():
spans = parse_rich_text("hello world")
_assert(len(spans) == 1, "plain text emits one span")
_assert(spans[0]["text"]["content"] == "hello world", "plain content preserved")
_assert("annotations" not in spans[0], "plain text has no annotations")
def test_rich_text_bold():
spans = parse_rich_text("a **bold** b")
_assert(len(spans) == 3, "bold splits into 3 spans (pre / bold / post)")
_assert(spans[0]["text"]["content"] == "a ", "pre-text preserved")
_assert(spans[1]["text"]["content"] == "bold", "bold content stripped of markers")
_assert(spans[1].get("annotations", {}).get("bold") is True, "bold annotation set")
_assert(spans[2]["text"]["content"] == " b", "post-text preserved")
def test_rich_text_italic():
spans = parse_rich_text("x *em* y")
_assert(any(s.get("annotations", {}).get("italic") for s in spans), "italic annotation set")
def test_rich_text_code():
spans = parse_rich_text("run `npm test` now")
code_spans = [s for s in spans if s.get("annotations", {}).get("code")]
_assert(len(code_spans) == 1, "code span emitted")
_assert(code_spans[0]["text"]["content"] == "npm test", "code content stripped of backticks")
def test_rich_text_link():
spans = parse_rich_text("see [the docs](https://example.com/docs) please")
link_spans = [s for s in spans if s["text"].get("link")]
_assert(len(link_spans) == 1, "link span emitted")
_assert(link_spans[0]["text"]["link"]["url"] == "https://example.com/docs", "link URL preserved")
_assert(link_spans[0]["text"]["content"] == "the docs", "link text preserved")
def test_rich_text_strike():
spans = parse_rich_text("~~gone~~ now")
strike = [s for s in spans if s.get("annotations", {}).get("strikethrough")]
_assert(len(strike) == 1, "strikethrough annotation set")
def test_rich_text_mixed():
spans = parse_rich_text("**bold** and *italic* and `code` and [link](https://x.com)")
kinds = set()
for s in spans:
ann = s.get("annotations") or {}
if ann.get("bold"): kinds.add("bold")
if ann.get("italic"): kinds.add("italic")
if ann.get("code"): kinds.add("code")
if s["text"].get("link"): kinds.add("link")
_assert(kinds == {"bold", "italic", "code", "link"}, "all four inline types present")
def test_blocks_headings():
blocks = markdown_to_notion_blocks(FIXTURE)
types = [b["type"] for b in blocks]
_assert("heading_1" in types, "H1 emitted")
_assert("heading_2" in types, "H2 emitted")
def test_blocks_bullets_and_numbers():
blocks = markdown_to_notion_blocks(FIXTURE)
types = [b["type"] for b in blocks]
_assert(types.count("bulleted_list_item") == 3, "3 bulleted items (incl. * marker)")
_assert(types.count("numbered_list_item") == 2, "2 numbered items")
def test_blocks_todo():
blocks = markdown_to_notion_blocks(FIXTURE)
todos = [b for b in blocks if b["type"] == "to_do"]
_assert(len(todos) == 2, "2 to_do blocks")
_assert(todos[0]["to_do"]["checked"] is False, "first todo unchecked")
_assert(todos[1]["to_do"]["checked"] is True, "second todo checked")
_assert(todos[0]["to_do"]["rich_text"][0]["text"]["content"] == "Unchecked task",
"todo text stripped of '- [ ]' marker")
def test_blocks_quote_code_divider():
blocks = markdown_to_notion_blocks(FIXTURE)
types = [b["type"] for b in blocks]
_assert("quote" in types, "quote block emitted")
_assert("code" in types, "code block emitted")
_assert("divider" in types, "divider block emitted")
code_block = next(b for b in blocks if b["type"] == "code")
_assert(code_block["code"]["language"] == "python", "code language preserved")
def test_blocks_table():
blocks = markdown_to_notion_blocks(FIXTURE)
tables = [b for b in blocks if b["type"] == "table"]
_assert(len(tables) == 1, "exactly one table block emitted")
t = tables[0]["table"]
_assert(t["table_width"] == 3, "table width = 3 columns")
_assert(t["has_column_header"] is True, "column header flag set")
_assert(len(t["children"]) == 4, "4 rows (1 header + 3 body)")
# Header cell content
header_row = t["children"][0]["table_row"]["cells"]
_assert(header_row[0][0]["text"]["content"] == "Task", "header cell 0 = 'Task'")
_assert(header_row[1][0]["text"]["content"] == "Priority", "header cell 1 = 'Priority'")
# Body row inline formatting
row_bold = t["children"][2]["table_row"]["cells"] # "| Korean encoding | **P1** | In Progress |"
bold_cell = row_bold[1]
_assert(bold_cell[0].get("annotations", {}).get("bold") is True,
"bold inline formatting preserved inside table cell")
row_link = t["children"][3]["table_row"]["cells"] # "| Type hints | P2 | [link](...) |"
link_cell = row_link[2]
_assert(link_cell[0]["text"].get("link", {}).get("url") == "https://example.com/types",
"link inline formatting preserved inside table cell")
def test_callout_note():
md = "> [!NOTE]\n> This is a note.\n> Spans multiple lines."
blocks = markdown_to_notion_blocks(md)
_assert(len(blocks) == 1, "exactly one block emitted")
_assert(blocks[0]["type"] == "callout", "block type is callout")
callout = blocks[0]["callout"]
_assert(callout["icon"] == {"type": "emoji", "emoji": ""}, "NOTE icon is ")
_assert(callout["color"] == "blue_background", "NOTE color is blue_background")
body_text = "".join(s["text"]["content"] for s in callout["rich_text"])
_assert("This is a note." in body_text, "body line 1 preserved")
_assert("Spans multiple lines." in body_text, "body line 2 preserved")
def test_callout_tip():
blocks = markdown_to_notion_blocks("> [!TIP]\n> Use this trick.")
_assert(blocks[0]["type"] == "callout", "TIP block is callout")
_assert(blocks[0]["callout"]["icon"]["emoji"] == "💡", "TIP icon is 💡")
_assert(blocks[0]["callout"]["color"] == "green_background", "TIP color is green")
def test_callout_important():
blocks = markdown_to_notion_blocks("> [!IMPORTANT]\n> Read this.")
_assert(blocks[0]["callout"]["icon"]["emoji"] == "☝️", "IMPORTANT icon is ☝️")
_assert(blocks[0]["callout"]["color"] == "purple_background", "IMPORTANT color is purple")
def test_callout_warning():
blocks = markdown_to_notion_blocks("> [!WARNING]\n> Be careful.")
_assert(blocks[0]["callout"]["icon"]["emoji"] == "⚠️", "WARNING icon is ⚠️")
_assert(blocks[0]["callout"]["color"] == "yellow_background", "WARNING color is yellow")
def test_callout_caution():
blocks = markdown_to_notion_blocks("> [!CAUTION]\n> Do not proceed.")
_assert(blocks[0]["callout"]["icon"]["emoji"] == "🚨", "CAUTION icon is 🚨")
_assert(blocks[0]["callout"]["color"] == "red_background", "CAUTION color is red")
def test_callout_unknown_falls_through():
"""An unrecognized alert type renders as a plain quote with the marker preserved."""
blocks = markdown_to_notion_blocks("> [!BOGUS]\n> some content")
_assert(blocks[0]["type"] == "quote", "unknown alert renders as quote, not callout")
quote_text = blocks[0]["quote"]["rich_text"][0]["text"]["content"]
_assert("[!BOGUS]" in quote_text, "[!BOGUS] marker preserved in quote text")
def test_toggle_basic():
md = "<details>\n<summary>Click to expand</summary>\n\nInner paragraph.\n</details>"
blocks = markdown_to_notion_blocks(md)
_assert(len(blocks) == 1, "one block emitted")
_assert(blocks[0]["type"] == "toggle", "block type is toggle")
summary_text = blocks[0]["toggle"]["rich_text"][0]["text"]["content"]
_assert(summary_text == "Click to expand", "summary text preserved")
children = blocks[0]["toggle"]["children"]
_assert(len(children) == 1, "one child block")
_assert(children[0]["type"] == "paragraph", "child is paragraph")
def test_toggle_nested_blocks():
"""Toggle body can hold any block type via _parse_lines recursion."""
md = """<details>
<summary>Debug log</summary>
- step one
- step two
```python
print("hello")
```
</details>"""
blocks = markdown_to_notion_blocks(md)
_assert(blocks[0]["type"] == "toggle", "block is toggle")
children = blocks[0]["toggle"]["children"]
types = [c["type"] for c in children]
_assert("bulleted_list_item" in types, "list child preserved")
_assert("code" in types, "code child preserved")
code_block = next(c for c in children if c["type"] == "code")
_assert(code_block["code"]["language"] == "python", "code language preserved")
def test_toggle_nested_toggle():
"""details_depth tracking allows <details> inside <details>."""
md = """<details>
<summary>outer</summary>
<details>
<summary>inner</summary>
inner content
</details>
</details>"""
blocks = markdown_to_notion_blocks(md)
_assert(blocks[0]["type"] == "toggle", "outer block is toggle")
outer_children = blocks[0]["toggle"]["children"]
_assert(len(outer_children) == 1, "outer has exactly one child")
_assert(outer_children[0]["type"] == "toggle", "outer child is also toggle")
inner_summary = outer_children[0]["toggle"]["rich_text"][0]["text"]["content"]
_assert(inner_summary == "inner", "inner summary preserved")
def test_toggle_unclosed_falls_through():
"""Missing </details> at EOF degrades to paragraphs, no crash."""
md = "<details>\n<summary>oops</summary>\n\nbody line"
blocks = markdown_to_notion_blocks(md)
types = [b["type"] for b in blocks]
_assert("toggle" not in types, "no toggle emitted on unclosed <details>")
paragraph_texts = [
b["paragraph"]["rich_text"][0]["text"]["content"]
for b in blocks if b["type"] == "paragraph"
]
joined = " ".join(paragraph_texts)
_assert("oops" in joined, "summary preserved as paragraph")
_assert("body line" in joined, "body preserved as paragraph")
def test_rich_text_anchor_link_becomes_bold():
"""TOC-style fragment links must not be sent as Notion link annotations."""
spans = parse_rich_text("see [Section A](#section-a) below")
bold_spans = [s for s in spans if s.get("annotations", {}).get("bold")]
_assert(len(bold_spans) == 1, "anchor-only link converted to bold span")
_assert(bold_spans[0]["text"]["content"] == "Section A", "anchor link text preserved")
_assert("link" not in bold_spans[0]["text"], "no link annotation on fragment URL")
def test_rich_text_relative_link_becomes_plain():
"""Relative paths can't be Notion links either; render plain text."""
spans = parse_rich_text("see [the spec](../spec.md) please")
spec_span = next(s for s in spans if s["text"]["content"] == "the spec")
_assert("link" not in spec_span["text"], "relative-path link annotation dropped")
_assert("annotations" not in spec_span, "no spurious bold/italic on plain text")
def test_rich_text_absolute_link_preserved():
"""Regression: absolute URLs must still produce a real Notion link."""
spans = parse_rich_text("[docs](https://example.com/x) and [mail](mailto:a@b.co)")
urls = [s["text"].get("link", {}).get("url") for s in spans if s["text"].get("link")]
_assert("https://example.com/x" in urls, "https URL preserved as link")
_assert("mailto:a@b.co" in urls, "mailto URL preserved as link")
def test_no_literal_markers_leak():
"""Regression: before the rich-text rewrite, ** and [ ] leaked as literal text."""
blocks = markdown_to_notion_blocks("A **bold** word and a [link](https://x.com).")
para = blocks[0]["paragraph"]["rich_text"]
joined = "".join(s["text"]["content"] for s in para)
_assert("**" not in joined, "no literal ** in paragraph")
_assert("[link]" not in joined, "no literal [link] in paragraph")
_assert("bold" in joined and "link" in joined, "visible words preserved")
def run_all():
tests = [
test_rich_text_plain,
test_rich_text_bold,
test_rich_text_italic,
test_rich_text_code,
test_rich_text_link,
test_rich_text_strike,
test_rich_text_mixed,
test_blocks_headings,
test_blocks_bullets_and_numbers,
test_blocks_todo,
test_blocks_quote_code_divider,
test_blocks_table,
test_callout_note,
test_callout_tip,
test_callout_important,
test_callout_warning,
test_callout_caution,
test_callout_unknown_falls_through,
test_toggle_basic,
test_toggle_nested_blocks,
test_toggle_nested_toggle,
test_toggle_unclosed_falls_through,
test_rich_text_anchor_link_becomes_bold,
test_rich_text_relative_link_becomes_plain,
test_rich_text_absolute_link_preserved,
test_no_literal_markers_leak,
]
for t in tests:
print(f"\n{t.__name__}")
t()
print("\n" + "=" * 50)
print(f"✅ All {len(tests)} tests passed")
print("=" * 50)
# Dump the fixture's block types for visual sanity check
blocks = markdown_to_notion_blocks(FIXTURE)
print("\nFixture produces block types:")
for b in blocks:
print(f" - {b['type']}")
if __name__ == "__main__":
run_all()