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:
@@ -3,11 +3,16 @@ ctstruct/parsers/emoji_markup.py
|
|||||||
이모지 마크업 포맷 → CTTree 파서
|
이모지 마크업 포맷 → CTTree 파서
|
||||||
|
|
||||||
지원 포맷:
|
지원 포맷:
|
||||||
✅제목 → 문서 제목 (root 속성)
|
<title_mark>제목 → 문서 제목 (root 속성)
|
||||||
🔹1단계 섹션 → SECTION (level=1)
|
<section_marks[0]>1단계 → SECTION (level=1)
|
||||||
🔸2단계 섹션 → SECTION (level=2)
|
<section_marks[1]>2단계 → SECTION (level=2)
|
||||||
(카테고리) 이름 → 카테고리 태그가 붙은 LIST + LIST_ITEM들
|
…
|
||||||
이름1 이름2 → 카테고리 없는 LIST + LIST_ITEM들 (줄 연속 가능)
|
(카테고리) 이름 → 카테고리 태그가 붙은 LIST + LIST_ITEM들
|
||||||
|
이름1 이름2 → 카테고리 없는 LIST + LIST_ITEM들 (줄 연속 가능)
|
||||||
|
|
||||||
|
기본 구분자:
|
||||||
|
title_mark = "✅"
|
||||||
|
section_marks = ["🔹", "🔸"]
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -21,36 +26,55 @@ from .base import BaseParser
|
|||||||
|
|
||||||
class EmojiMarkupParser(BaseParser):
|
class EmojiMarkupParser(BaseParser):
|
||||||
"""
|
"""
|
||||||
✅ / 🔹 / 🔸 이모지로 계층을 표현하는 텍스트 포맷 파서.
|
임의 구분자로 계층을 표현하는 텍스트 포맷 파서.
|
||||||
|
|
||||||
명단은 공백으로 구분된 이름 목록이며, 빈 줄 또는 새로운 섹션/카테고리
|
구분자는 이모지, 특수문자, 문자열 등 어떤 값도 사용할 수 있습니다.
|
||||||
마커가 나올 때까지 같은 그룹으로 묶입니다.
|
|
||||||
|
Args:
|
||||||
|
title_mark: 문서 제목 줄 앞에 붙는 마커 (기본 "✅")
|
||||||
|
section_marks: 섹션 레벨별 마커 목록 (기본 ["🔹", "🔸"])
|
||||||
|
인덱스 0 → level 1, 인덱스 1 → level 2, …
|
||||||
|
더 긴 마커가 먼저 매칭되도록 내부에서 길이 내림차순 정렬됩니다.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
>>> parser = EmojiMarkupParser()
|
# 기본 이모지 구분자
|
||||||
>>> tree = parser.parse(text)
|
parser = EmojiMarkupParser()
|
||||||
>>> tree.pretty()
|
|
||||||
|
# 마크다운 스타일 구분자
|
||||||
|
parser = EmojiMarkupParser(title_mark="# ", section_marks=["## ", "### "])
|
||||||
|
|
||||||
|
# 커스텀 구분자
|
||||||
|
parser = EmojiMarkupParser(title_mark="[제목]", section_marks=["[1]", "[2]", "[3]"])
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_TITLE = "✅"
|
_RE_CAT = re.compile(r'^\(([^)]+)\)\s*(.*)', re.DOTALL)
|
||||||
_SEC1 = "🔹"
|
|
||||||
_SEC2 = "🔸"
|
def __init__(
|
||||||
_RE_CAT = re.compile(r'^\(([^)]+)\)\s*(.*)')
|
self,
|
||||||
|
title_mark: str = "✅",
|
||||||
|
section_marks: list[str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.title_mark = title_mark
|
||||||
|
self.section_marks = section_marks if section_marks is not None else ["🔹", "🔸"]
|
||||||
|
|
||||||
|
# 긴 마커를 먼저 검사해 prefix 충돌 방지 (예: "##" vs "#")
|
||||||
|
self._sorted_marks: list[tuple[str, int]] = sorted(
|
||||||
|
((m, i + 1) for i, m in enumerate(self.section_marks)),
|
||||||
|
key=lambda x: len(x[0]),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
|
||||||
def parse(self, text: str) -> CTTree:
|
def parse(self, text: str) -> CTTree:
|
||||||
tree = CTTree()
|
tree = CTTree()
|
||||||
|
|
||||||
# 현재 컨텍스트 추적
|
# 각 레벨별 현재 활성 섹션 노드 (인덱스 = level-1)
|
||||||
sec1: CTNode | None = None
|
section_stack: list[CTNode | None] = [None] * len(self.section_marks)
|
||||||
sec2: CTNode | None = None
|
|
||||||
|
|
||||||
# 아직 트리에 붙이지 않은 이름 버퍼
|
pending_names: list[str] = []
|
||||||
pending_names: list[str] = []
|
pending_category: str | None = None
|
||||||
pending_category: str | None = None
|
pending_parent: CTNode | None = None
|
||||||
pending_parent: CTNode | None = None # 이름 그룹이 붙을 부모 노드
|
|
||||||
|
|
||||||
def flush() -> None:
|
def flush() -> None:
|
||||||
"""버퍼에 쌓인 이름을 LIST로 만들어 부모에 붙인다."""
|
|
||||||
nonlocal pending_names, pending_category, pending_parent
|
nonlocal pending_names, pending_category, pending_parent
|
||||||
if not pending_names:
|
if not pending_names:
|
||||||
return
|
return
|
||||||
@@ -60,9 +84,16 @@ class EmojiMarkupParser(BaseParser):
|
|||||||
lst.append(CTNode(NodeType.LIST_ITEM, name))
|
lst.append(CTNode(NodeType.LIST_ITEM, name))
|
||||||
target = pending_parent if pending_parent is not None else tree.root
|
target = pending_parent if pending_parent is not None else tree.root
|
||||||
target.append(lst)
|
target.append(lst)
|
||||||
pending_names = []
|
pending_names.clear()
|
||||||
pending_category = None
|
pending_category = None
|
||||||
|
|
||||||
|
def nearest_parent(level: int) -> CTNode:
|
||||||
|
"""level 보다 얕은 가장 가까운 활성 섹션, 없으면 root."""
|
||||||
|
for i in range(level - 2, -1, -1):
|
||||||
|
if section_stack[i] is not None:
|
||||||
|
return section_stack[i] # type: ignore[return-value]
|
||||||
|
return tree.root
|
||||||
|
|
||||||
for raw in text.splitlines():
|
for raw in text.splitlines():
|
||||||
line = raw.strip()
|
line = raw.strip()
|
||||||
|
|
||||||
@@ -70,31 +101,31 @@ class EmojiMarkupParser(BaseParser):
|
|||||||
flush()
|
flush()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# ✅ 문서 제목
|
# 제목 마커
|
||||||
if line.startswith(self._TITLE):
|
if line.startswith(self.title_mark):
|
||||||
flush()
|
flush()
|
||||||
tree.root.set_attr("title", line[len(self._TITLE):].strip())
|
tree.root.set_attr("title", line[len(self.title_mark):].strip())
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 🔹 1단계 섹션
|
# 섹션 마커 (긴 것 우선 매칭)
|
||||||
if line.startswith(self._SEC1):
|
matched = False
|
||||||
flush()
|
for mark, level in self._sorted_marks:
|
||||||
sec1 = CTNode(NodeType.SECTION, line[len(self._SEC1):].strip(), {"level": 1})
|
if line.startswith(mark):
|
||||||
sec2 = None
|
flush()
|
||||||
pending_parent = sec1
|
content = line[len(mark):].strip()
|
||||||
tree.root.append(sec1)
|
node = CTNode(NodeType.SECTION, content, {"level": level})
|
||||||
|
# 현재 레벨 이하의 스택 초기화
|
||||||
|
for i in range(level - 1, len(section_stack)):
|
||||||
|
section_stack[i] = None
|
||||||
|
section_stack[level - 1] = node
|
||||||
|
pending_parent = node
|
||||||
|
nearest_parent(level).append(node)
|
||||||
|
matched = True
|
||||||
|
break
|
||||||
|
if matched:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 🔸 2단계 섹션
|
# 카테고리 마커 → 새 그룹 시작
|
||||||
if line.startswith(self._SEC2):
|
|
||||||
flush()
|
|
||||||
sec2 = CTNode(NodeType.SECTION, line[len(self._SEC2):].strip(), {"level": 2})
|
|
||||||
pending_parent = sec2
|
|
||||||
parent = sec1 if sec1 is not None else tree.root
|
|
||||||
parent.append(sec2)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# (카테고리) 이름들 — 새 카테고리이면 기존 버퍼를 먼저 flush
|
|
||||||
m = self._RE_CAT.match(line)
|
m = self._RE_CAT.match(line)
|
||||||
if m:
|
if m:
|
||||||
flush()
|
flush()
|
||||||
@@ -104,7 +135,7 @@ class EmojiMarkupParser(BaseParser):
|
|||||||
pending_names.extend(rest.split())
|
pending_names.extend(rest.split())
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 이름 목록 줄 (카테고리 없이 연속)
|
# 이름 목록 (연속 줄은 같은 그룹으로 병합)
|
||||||
pending_names.extend(line.split())
|
pending_names.extend(line.split())
|
||||||
|
|
||||||
flush()
|
flush()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from .base import BaseRenderer
|
from .base import BaseRenderer
|
||||||
from .markdown import MarkdownRenderer
|
from .markdown import MarkdownRenderer
|
||||||
from .json_renderer import JSONRenderer
|
from .json_renderer import JSONRenderer
|
||||||
|
from .emoji_markup import EmojiMarkupRenderer
|
||||||
|
|
||||||
__all__ = ["BaseRenderer", "MarkdownRenderer", "JSONRenderer"]
|
__all__ = ["BaseRenderer", "MarkdownRenderer", "JSONRenderer", "EmojiMarkupRenderer"]
|
||||||
|
|||||||
83
ctstruct/renderers/emoji_markup.py
Normal file
83
ctstruct/renderers/emoji_markup.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"""
|
||||||
|
ctstruct/renderers/emoji_markup.py
|
||||||
|
CTTree → 이모지 마크업 텍스트 렌더러
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ..node import CTNode
|
||||||
|
from ..tree import CTTree
|
||||||
|
from ..types import NodeType
|
||||||
|
from .base import BaseRenderer
|
||||||
|
|
||||||
|
|
||||||
|
class EmojiMarkupRenderer(BaseRenderer):
|
||||||
|
"""
|
||||||
|
CTTree 를 이모지 마크업 텍스트로 렌더링.
|
||||||
|
|
||||||
|
EmojiMarkupParser 와 같은 구분자를 사용하면 완전한 왕복 변환(roundtrip)이 보장됩니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title_mark: 문서 제목 앞에 붙는 마커 (기본 "✅")
|
||||||
|
section_marks: 섹션 레벨별 마커 목록 (기본 ["🔹", "🔸"])
|
||||||
|
인덱스 0 → level 1, 인덱스 1 → level 2, …
|
||||||
|
names_per_line: 한 줄에 출력할 최대 이름 수 (기본 0 = 제한 없음)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
title_mark: str = "✅",
|
||||||
|
section_marks: list[str] | None = None,
|
||||||
|
names_per_line: int = 0,
|
||||||
|
) -> None:
|
||||||
|
self.title_mark = title_mark
|
||||||
|
self.section_marks = section_marks if section_marks is not None else ["🔹", "🔸"]
|
||||||
|
self.names_per_line = names_per_line
|
||||||
|
|
||||||
|
def render(self, tree: CTTree) -> str:
|
||||||
|
lines: list[str] = []
|
||||||
|
|
||||||
|
title = tree.root.attr("title")
|
||||||
|
if title:
|
||||||
|
lines.append(f"{self.title_mark}{title}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
for child in tree.root.children:
|
||||||
|
lines.extend(self._render_node(child))
|
||||||
|
|
||||||
|
return "\n".join(lines).rstrip() + "\n"
|
||||||
|
|
||||||
|
# ── 내부 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _render_node(self, node: CTNode) -> list[str]:
|
||||||
|
if node.node_type == NodeType.SECTION:
|
||||||
|
return self._render_section(node)
|
||||||
|
if node.node_type == NodeType.LIST:
|
||||||
|
return self._render_list(node)
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _render_section(self, node: CTNode) -> list[str]:
|
||||||
|
level = node.attr("level", 1)
|
||||||
|
mark = self.section_marks[level - 1] if level <= len(self.section_marks) else ""
|
||||||
|
out = [f"{mark}{node.content or ''}"]
|
||||||
|
for child in node.children:
|
||||||
|
out.extend(self._render_node(child))
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _render_list(self, node: CTNode) -> list[str]:
|
||||||
|
names = [item.content or "" for item in node.children
|
||||||
|
if item.node_type == NodeType.LIST_ITEM]
|
||||||
|
category = node.attr("category")
|
||||||
|
prefix = f"({category}) " if category else ""
|
||||||
|
|
||||||
|
if self.names_per_line > 0:
|
||||||
|
chunks = [names[i:i + self.names_per_line]
|
||||||
|
for i in range(0, len(names), self.names_per_line)]
|
||||||
|
lines = [prefix + " ".join(chunk) for chunk in chunks]
|
||||||
|
# prefix는 첫 줄에만
|
||||||
|
if len(lines) > 1:
|
||||||
|
lines = [lines[0]] + [" ".join(chunk) for chunk in chunks[1:]]
|
||||||
|
else:
|
||||||
|
lines = [prefix + " ".join(names)] if names else []
|
||||||
|
|
||||||
|
return lines + [""] # 목록 뒤 빈 줄
|
||||||
@@ -6,20 +6,53 @@ CTTree → JSON 렌더러
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from typing import Any
|
||||||
from ..tree import CTTree
|
from ..tree import CTTree
|
||||||
from .base import BaseRenderer
|
from .base import BaseRenderer
|
||||||
|
|
||||||
|
|
||||||
class JSONRenderer(BaseRenderer):
|
class JSONRenderer(BaseRenderer):
|
||||||
"""CTTree 를 JSON 문자열로 직렬화."""
|
"""
|
||||||
|
CTTree 를 JSON 문자열로 직렬화.
|
||||||
|
|
||||||
def __init__(self, indent: int = 2, ensure_ascii: bool = False) -> None:
|
Args:
|
||||||
|
indent: 들여쓰기 공백 수 (기본 2)
|
||||||
|
ensure_ascii: ASCII 강제 인코딩 여부 (기본 False)
|
||||||
|
slim: True 이면 null·기본값 필드를 제거해 출력을 간소화
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
indent: int = 2,
|
||||||
|
ensure_ascii: bool = False,
|
||||||
|
slim: bool = False,
|
||||||
|
) -> None:
|
||||||
self.indent = indent
|
self.indent = indent
|
||||||
self.ensure_ascii = ensure_ascii
|
self.ensure_ascii = ensure_ascii
|
||||||
|
self.slim = slim
|
||||||
|
|
||||||
def render(self, tree: CTTree) -> str:
|
def render(self, tree: CTTree) -> str:
|
||||||
return json.dumps(
|
data = tree.to_dict()
|
||||||
tree.to_dict(),
|
if self.slim:
|
||||||
indent=self.indent,
|
data = self._strip(data)
|
||||||
ensure_ascii=self.ensure_ascii,
|
return json.dumps(data, indent=self.indent, ensure_ascii=self.ensure_ascii)
|
||||||
)
|
|
||||||
|
# ── slim 헬퍼 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _strip(obj: Any) -> Any:
|
||||||
|
"""None·기본값 필드를 재귀적으로 제거."""
|
||||||
|
if isinstance(obj, list):
|
||||||
|
return [JSONRenderer._strip(v) for v in obj]
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
for k, v in obj.items():
|
||||||
|
if v is None:
|
||||||
|
continue
|
||||||
|
if k == "data_type" and v == "unset":
|
||||||
|
continue
|
||||||
|
if k == "attributes" and v == {}:
|
||||||
|
continue
|
||||||
|
result[k] = JSONRenderer._strip(v)
|
||||||
|
return result
|
||||||
|
return obj
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
"""
|
"""
|
||||||
tests/test_emoji_markup_parser.py
|
tests/test_emoji_markup_parser.py
|
||||||
EmojiMarkupParser 단위 테스트
|
EmojiMarkupParser / EmojiMarkupRenderer / JSONRenderer(slim) 단위 테스트
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import pytest
|
import pytest
|
||||||
from ctstruct import NodeType
|
from ctstruct import NodeType
|
||||||
from ctstruct.parsers import EmojiMarkupParser
|
from ctstruct.parsers import EmojiMarkupParser
|
||||||
|
from ctstruct.renderers import EmojiMarkupRenderer, JSONRenderer
|
||||||
|
|
||||||
|
|
||||||
SAMPLE = """\
|
SAMPLE = """\
|
||||||
@@ -155,10 +157,161 @@ class TestEdgeCases:
|
|||||||
assert len(lst[0].children) == 3
|
assert len(lst[0].children) == 3
|
||||||
|
|
||||||
def test_category_names_on_separate_line(self):
|
def test_category_names_on_separate_line(self):
|
||||||
# 카테고리 줄 다음에 이름이 없고 다음 줄에 이름이 있는 경우
|
|
||||||
text = "🔹항목\n(건강)\n홍길동 홍길순\n"
|
text = "🔹항목\n(건강)\n홍길동 홍길순\n"
|
||||||
tree = EmojiMarkupParser().parse(text)
|
tree = EmojiMarkupParser().parse(text)
|
||||||
sec = tree.root.children[0]
|
sec = tree.root.children[0]
|
||||||
lst = next(n for n in sec.children if n.node_type == NodeType.LIST)
|
lst = next(n for n in sec.children if n.node_type == NodeType.LIST)
|
||||||
assert lst.attr("category") == "건강"
|
assert lst.attr("category") == "건강"
|
||||||
assert len(lst.children) == 2
|
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