Files
our-claude-skills/custom-skills/32-notion-writer/code/scripts/test_parser.py
Andrew Yim e0f93e6add feat(notion-writer): add GitHub-alert callout blocks
Adds support for > [!NOTE] / > [!TIP] / > [!IMPORTANT] / > [!WARNING] /
> [!CAUTION] callouts. Each alert type maps to a Notion callout block
with an emoji icon and matching colored background. Unknown alert types
(e.g. > [!BOGUS]) fall through to the existing quote handler with the
marker preserved.

Also restores the "must come before generic bullet match" comment on
the todo branch (load-bearing ordering note).

+6 tests, 22 passing total.

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

291 lines
11 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_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_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()