Files
our-claude-skills/custom-skills/32-notion-writer/code/scripts/test_parser.py
2026-06-27 11:13:31 +09:00

493 lines
19 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_columns_two():
md = """::: columns
::: column
left content
:::
::: column
right content
:::
:::"""
blocks = markdown_to_notion_blocks(md)
_assert(len(blocks) == 1, "one block emitted")
_assert(blocks[0]["type"] == "column_list", "block type is column_list")
cols = blocks[0]["column_list"]["children"]
_assert(len(cols) == 2, "two columns")
_assert(cols[0]["type"] == "column", "child 0 is column")
_assert(cols[1]["type"] == "column", "child 1 is column")
left_para = cols[0]["column"]["children"][0]["paragraph"]["rich_text"][0]["text"]["content"]
right_para = cols[1]["column"]["children"][0]["paragraph"]["rich_text"][0]["text"]["content"]
_assert(left_para == "left content", "left column content preserved")
_assert(right_para == "right content", "right column content preserved")
def test_columns_with_blocks():
"""Columns can hold any block type via per-column _parse_lines recursion."""
md = """::: columns
::: column
- bullet a
- bullet b
:::
::: column
```python
print(1)
```
:::
:::"""
blocks = markdown_to_notion_blocks(md)
_assert(blocks[0]["type"] == "column_list", "outer is column_list")
cols = blocks[0]["column_list"]["children"]
_assert(len(cols) == 2, "two columns")
left_types = [c["type"] for c in cols[0]["column"]["children"]]
right_types = [c["type"] for c in cols[1]["column"]["children"]]
_assert(left_types.count("bulleted_list_item") == 2, "left column has 2 bullets")
_assert("code" in right_types, "right column has code block")
def test_columns_single_degrades():
"""Single-column ::: columns block degrades to paragraphs (Notion requires >= 2)."""
md = """::: columns
::: column
only one column here
:::
:::"""
blocks = markdown_to_notion_blocks(md)
types = [b["type"] for b in blocks]
_assert("column_list" not in types, "no column_list emitted for single column")
paragraph_texts = [
b["paragraph"]["rich_text"][0]["text"]["content"]
for b in blocks if b["type"] == "paragraph"
]
joined = " ".join(paragraph_texts)
_assert("only one column here" in joined, "content preserved as paragraph")
def test_mention_id():
"""@[Title](32-hex-id) produces a mention rich-text span with page reference."""
raw_id = "abcdef0123456789abcdef0123456789"
spans = parse_rich_text(f"see @[Roadmap]({raw_id}) for plans")
mention_spans = [s for s in spans if s.get("type") == "mention"]
_assert(len(mention_spans) == 1, "one mention span emitted")
m = mention_spans[0]
_assert(m["mention"]["type"] == "page", "mention type is page")
_assert(m["mention"]["page"]["id"].replace("-", "") == raw_id, "page id resolved")
_assert(m.get("plain_text") == "Roadmap", "plain_text is the title")
def test_mention_url():
"""@[Title](https://notion.so/Page-Title-id) extracts ID from URL."""
raw_id = "abcdef0123456789abcdef0123456789"
url = f"https://notion.so/My-Page-{raw_id}"
spans = parse_rich_text(f"check @[My Page]({url})")
mention_spans = [s for s in spans if s.get("type") == "mention"]
_assert(len(mention_spans) == 1, "one mention span emitted from URL form")
_assert(mention_spans[0]["mention"]["page"]["id"].replace("-", "") == raw_id,
"ID extracted from Notion URL")
def test_mention_invalid_falls_back():
"""A non-resolvable target degrades to plain text '@Title' with no link."""
spans = parse_rich_text("ping @[Bob](not-a-real-id) please")
mention_spans = [s for s in spans if s.get("type") == "mention"]
_assert(len(mention_spans) == 0, "no mention span for invalid id")
plain_text_joined = "".join(
s["text"]["content"] for s in spans if s.get("type") == "text"
)
_assert("@Bob" in plain_text_joined, "title rendered as plain '@Bob'")
_assert("not-a-real-id" not in plain_text_joined, "invalid id stripped from output")
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 test_image_remote_external():
blocks = markdown_to_notion_blocks("![a chart](https://ex.com/c.png)")
assert len(blocks) == 1
b = blocks[0]
assert b["type"] == "image"
assert b["image"]["type"] == "external"
assert b["image"]["external"]["url"] == "https://ex.com/c.png"
assert b["image"]["caption"][0]["text"]["content"] == "a chart"
def test_image_local_external_shape_preupload():
blocks = markdown_to_notion_blocks("![local](./pics/x.png)")
assert len(blocks) == 1
assert blocks[0]["image"]["external"]["url"] == "./pics/x.png"
def test_image_only_when_standalone():
# An inline bang-bracket inside prose is NOT an image block.
blocks = markdown_to_notion_blocks("see ![x](y.png) inline")
assert blocks[0]["type"] == "paragraph"
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_columns_two,
test_columns_with_blocks,
test_columns_single_degrades,
test_mention_id,
test_mention_url,
test_mention_invalid_falls_back,
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,
test_image_remote_external,
test_image_local_external_shape_preupload,
test_image_only_when_standalone,
]
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()