493 lines
19 KiB
Python
493 lines
19 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_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("")
|
||
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("")
|
||
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  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()
|