- 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>
318 lines
13 KiB
Python
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
|