Files
CTStruct/tests/test_emoji_markup_parser.py
gm a68a79411f feat: slim JSON, EmojiMarkupRenderer, configurable delimiters
- JSONRenderer(slim=True): null·기본값·빈 attributes 필드 제거
- EmojiMarkupRenderer: CTTree → 이모지 마크업 텍스트 왕복 변환 지원
  - title_mark / section_marks 커스터마이징 가능
  - names_per_line 옵션으로 줄당 이름 수 제한
- EmojiMarkupParser: 구분자를 생성자 파라미터로 주입 가능
  - section_marks 길이 제한 없음 (임의 깊이 섹션 지원)
  - 긴 마커 우선 매칭으로 prefix 충돌 방지
- 테스트 39개 추가 (커스텀 구분자·렌더러·slim 검증)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 18:30:13 +09:00

318 lines
13 KiB
Python

"""
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