Files
our-claude-skills/custom-skills/32-notion-writer/code/scripts/test_parser.py
Andrew Yim 665fe22201 feat(notion-writer): add table blocks, inline rich-text parsing, and parser tests
Extend markdown_to_notion_blocks with GFM table support and rewrite
parse_rich_text to emit Notion annotations for bold/italic/code/link/strike
instead of dropping them as literal text. Move checkbox match ahead of the
bullet match so "- [ ]" lines become to_do blocks, not bullets.

Add test_parser.py with 13 regression tests covering rich-text spans,
block types, and table cell formatting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:00:12 +09:00

211 lines
7.2 KiB
Python

#!/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_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_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()