#!/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 = "
\nClick to expand\n\nInner paragraph.\n
" 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 = """
Debug log - step one - step two ```python print("hello") ```
""" 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
inside
.""" md = """
outer
inner inner content
""" 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
at EOF degrades to paragraphs, no crash.""" md = "
\noops\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
") 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()