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