""" tests/test_emoji_markup_parser.py EmojiMarkupParser / EmojiMarkupRenderer / JSONRenderer(slim) 단위 테스트 """ import json import pytest from ctstruct import NodeType from ctstruct.parsers import EmojiMarkupParser from ctstruct.renderers import EmojiMarkupRenderer, JSONRenderer SAMPLE = """\ ✅예시 양식 🔹1번 항목 홍길동 홍길순 홍길영 홍길준 홍길호 홍길윤 🔹2번 항목 🔸2-1 항목 (건강) 홍길찬 홍길만 🔸2-2 항목 (직장) 홍길주 """ @pytest.fixture def tree(): return EmojiMarkupParser().parse(SAMPLE) # ── 문서 제목 ───────────────────────────────────────────────────────────────── class TestTitle: def test_title_set(self, tree): assert tree.root.attr("title") == "예시 양식" # ── 섹션 구조 ───────────────────────────────────────────────────────────────── class TestSections: def test_two_top_sections(self, tree): secs = [n for n in tree.root.children if n.node_type == NodeType.SECTION] assert len(secs) == 2 def test_section1_name(self, tree): sec = tree.root.children[0] assert sec.content == "1번 항목" assert sec.attr("level") == 1 def test_section2_name(self, tree): sec = tree.root.children[1] assert sec.content == "2번 항목" assert sec.attr("level") == 1 def test_subsections_under_section2(self, tree): sec2 = tree.root.children[1] subsecs = [n for n in sec2.children if n.node_type == NodeType.SECTION] assert len(subsecs) == 2 assert subsecs[0].content == "2-1 항목" assert subsecs[1].content == "2-2 항목" def test_subsection_level(self, tree): sec2 = tree.root.children[1] for sub in sec2.children: assert sub.attr("level") == 2 # ── 명단 ───────────────────────────────────────────────────────────────────── class TestNameLists: def test_section1_has_one_list(self, tree): sec1 = tree.root.children[0] lists = [n for n in sec1.children if n.node_type == NodeType.LIST] assert len(lists) == 1 def test_section1_names_across_lines(self, tree): sec1 = tree.root.children[0] lst = next(n for n in sec1.children if n.node_type == NodeType.LIST) names = [item.content for item in lst.children] assert names == ["홍길동", "홍길순", "홍길영", "홍길준", "홍길호", "홍길윤"] def test_section1_no_category(self, tree): sec1 = tree.root.children[0] lst = next(n for n in sec1.children if n.node_type == NodeType.LIST) assert lst.attr("category") is None def test_subsec_21_category(self, tree): sub21 = tree.root.children[1].children[0] lst = next(n for n in sub21.children if n.node_type == NodeType.LIST) assert lst.attr("category") == "건강" def test_subsec_21_names(self, tree): sub21 = tree.root.children[1].children[0] lst = next(n for n in sub21.children if n.node_type == NodeType.LIST) names = [item.content for item in lst.children] assert names == ["홍길찬", "홍길만"] def test_subsec_22_category(self, tree): sub22 = tree.root.children[1].children[1] lst = next(n for n in sub22.children if n.node_type == NodeType.LIST) assert lst.attr("category") == "직장" def test_subsec_22_names(self, tree): sub22 = tree.root.children[1].children[1] lst = next(n for n in sub22.children if n.node_type == NodeType.LIST) names = [item.content for item in lst.children] assert names == ["홍길주"] def test_all_list_items_are_leaf(self, tree): for item in tree.find(NodeType.LIST_ITEM): assert item.is_leaf # ── 전체 통계 ───────────────────────────────────────────────────────────────── class TestStats: def test_total_names(self, tree): assert len(tree.find(NodeType.LIST_ITEM)) == 9 def test_total_lists(self, tree): assert len(tree.find(NodeType.LIST)) == 3 def test_total_sections(self, tree): secs = tree.find(NodeType.SECTION) assert len(secs) == 4 # 🔹2 + 🔸2 # ── 엣지 케이스 ─────────────────────────────────────────────────────────────── class TestEdgeCases: def test_empty_string(self): tree = EmojiMarkupParser().parse("") assert tree.root.attr("title") is None assert len(tree.root.children) == 0 def test_title_only(self): tree = EmojiMarkupParser().parse("✅제목만") assert tree.root.attr("title") == "제목만" assert len(tree.root.children) == 0 def test_multiple_categories_in_section(self): text = "🔹항목\n(A) 홍길동\n\n(B) 홍길순\n" tree = EmojiMarkupParser().parse(text) sec = tree.root.children[0] lists = [n for n in sec.children if n.node_type == NodeType.LIST] assert len(lists) == 2 assert lists[0].attr("category") == "A" assert lists[1].attr("category") == "B" def test_names_without_section(self): tree = EmojiMarkupParser().parse("홍길동 홍길순\n홍길영\n") lst = tree.find(NodeType.LIST) assert len(lst) == 1 assert len(lst[0].children) == 3 def test_category_names_on_separate_line(self): text = "🔹항목\n(건강)\n홍길동 홍길순\n" tree = EmojiMarkupParser().parse(text) sec = tree.root.children[0] lst = next(n for n in sec.children if n.node_type == NodeType.LIST) assert lst.attr("category") == "건강" assert len(lst.children) == 2 # ── 커스텀 구분자 ───────────────────────────────────────────────────────────── class TestCustomDelimiters: def test_markdown_style_delimiters(self): text = "# 제목\n## 1단계\n홍길동 홍길순\n## 2단계\n홍길영\n" parser = EmojiMarkupParser(title_mark="# ", section_marks=["## "]) tree = parser.parse(text) assert tree.root.attr("title") == "제목" secs = tree.find(NodeType.SECTION) assert len(secs) == 2 assert secs[0].content == "1단계" def test_custom_string_delimiters(self): text = "[제목] 기도 명단\n[1] 항목A\n홍길동\n[2] 소항목\n홍길순\n" parser = EmojiMarkupParser(title_mark="[제목] ", section_marks=["[1] ", "[2] "]) tree = parser.parse(text) assert tree.root.attr("title") == "기도 명단" assert tree.root.children[0].content == "항목A" # 항목A 하위: LIST(홍길동) 다음에 SECTION level=2 "소항목" level2 = next(n for n in tree.find(NodeType.SECTION) if n.attr("level") == 2) assert level2.content == "소항목" def test_three_level_sections(self): text = "✅문서\n🔹A\n🔸B\n🔻C\n홍길동\n" parser = EmojiMarkupParser(section_marks=["🔹", "🔸", "🔻"]) tree = parser.parse(text) level1 = tree.root.children[0] level2 = level1.children[0] level3 = level2.children[0] assert level1.attr("level") == 1 assert level2.attr("level") == 2 assert level3.attr("level") == 3 assert level3.children[0].node_type == NodeType.LIST def test_prefix_conflict_longer_wins(self): # "##"이 "#"보다 먼저 매칭되어야 함 text = "# 제목\n## 2단계\n홍길동\n# 다른제목\n" parser = EmojiMarkupParser(title_mark="TITLE:", section_marks=["#", "##"]) tree = parser.parse(text) # "## 2단계"는 level=2 "##"로 매칭 (긴 것 우선) all_secs = tree.find(NodeType.SECTION) level2_secs = [s for s in all_secs if s.attr("level") == 2] assert any(s.content == "2단계" for s in level2_secs) def test_default_delimiters_unchanged(self): # 기본 구분자가 바뀌지 않았는지 확인 tree = EmojiMarkupParser().parse(SAMPLE) assert tree.root.attr("title") == "예시 양식" assert len(tree.find(NodeType.LIST_ITEM)) == 9 # ── EmojiMarkupRenderer ─────────────────────────────────────────────────────── class TestEmojiMarkupRenderer: def test_title_rendered(self, tree): out = EmojiMarkupRenderer().render(tree) assert out.startswith("✅예시 양식") def test_section_marks_rendered(self, tree): out = EmojiMarkupRenderer().render(tree) assert "🔹1번 항목" in out assert "🔹2번 항목" in out assert "🔸2-1 항목" in out assert "🔸2-2 항목" in out def test_names_rendered(self, tree): out = EmojiMarkupRenderer().render(tree) assert "홍길동" in out assert "홍길순" in out def test_category_rendered(self, tree): out = EmojiMarkupRenderer().render(tree) assert "(건강)" in out assert "(직장)" in out def test_roundtrip(self, tree): out = EmojiMarkupRenderer().render(tree) tree2 = EmojiMarkupParser().parse(out) assert len(tree.find(NodeType.LIST_ITEM)) == len(tree2.find(NodeType.LIST_ITEM)) assert len(tree.find(NodeType.SECTION)) == len(tree2.find(NodeType.SECTION)) assert tree.root.attr("title") == tree2.root.attr("title") def test_custom_marks_renderer(self, tree): out = EmojiMarkupRenderer(title_mark="# ", section_marks=["## ", "### "]).render(tree) assert out.startswith("# 예시 양식") assert "## 1번 항목" in out assert "### 2-1 항목" in out def test_names_per_line(self, tree): out = EmojiMarkupRenderer(names_per_line=3).render(tree) # 6명이 3명씩 두 줄로 쪼개져야 함 lines = [l for l in out.splitlines() if "홍길" in l and "(" not in l] assert len(lines) == 2 assert len(lines[0].split()) == 3 assert len(lines[1].split()) == 3 # ── JSONRenderer slim 모드 ──────────────────────────────────────────────────── class TestJSONRendererSlim: def test_slim_removes_null_fields(self, tree): data = json.loads(JSONRenderer(slim=True).render(tree)) def check(node): for k, v in node.items(): assert v is not None, f"null field found: {k}" if isinstance(v, list): for item in v: if isinstance(item, dict): check(item) check(data) def test_slim_removes_default_data_type(self, tree): data = json.loads(JSONRenderer(slim=True).render(tree)) def check(node): assert "data_type" not in node or node["data_type"] != "unset" for child in node.get("children", []): check(child) check(data) def test_slim_removes_empty_attributes(self, tree): data = json.loads(JSONRenderer(slim=True).render(tree)) def check(node): if "attributes" in node: assert node["attributes"] != {}, "empty attributes should be removed" for child in node.get("children", []): check(child) check(data) def test_slim_preserves_content(self, tree): slim = json.loads(JSONRenderer(slim=True).render(tree)) full = json.loads(JSONRenderer(slim=False).render(tree)) def collect_names(node): names = [] if node.get("type") == "LIST_ITEM": names.append(node.get("content")) for child in node.get("children", []): names.extend(collect_names(child)) return names assert collect_names(slim) == collect_names(full) def test_non_slim_keeps_null_fields(self, tree): data = json.loads(JSONRenderer(slim=False).render(tree)) # ROOT 노드에 content: null 이 있어야 함 assert "content" in data assert data["content"] is None