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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user