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>
This commit is contained in:
gm
2026-06-01 18:30:13 +09:00
parent daa590117d
commit a68a79411f
5 changed files with 356 additions and 55 deletions

View File

@@ -1,11 +1,13 @@
"""
tests/test_emoji_markup_parser.py
EmojiMarkupParser 단위 테스트
EmojiMarkupParser / EmojiMarkupRenderer / JSONRenderer(slim) 단위 테스트
"""
import json
import pytest
from ctstruct import NodeType
from ctstruct.parsers import EmojiMarkupParser
from ctstruct.renderers import EmojiMarkupRenderer, JSONRenderer
SAMPLE = """\
@@ -155,10 +157,161 @@ class TestEdgeCases:
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