Compare commits
32 Commits
3f26cbb687
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cc8d320b63 | |||
| 39f48dca4b | |||
| 2d6be54c02 | |||
| 41c73fecd5 | |||
| e332e357b9 | |||
| c215f28a2f | |||
| f64f8f3eb8 | |||
| c860035822 | |||
| 5a86d52813 | |||
| c38d071a86 | |||
| 97e2406044 | |||
| 45677d7b18 | |||
|
|
65e946b4e3 | ||
|
|
8b0842eb80 | ||
|
|
309c45886e | ||
|
|
b6f54d0187 | ||
|
|
3449df10bc | ||
|
|
28e52e9ce0 | ||
|
|
702eb918ee | ||
|
|
63cb612a6e | ||
|
|
df011a5fcc | ||
|
|
881b3d1f10 | ||
|
|
65f5d4b422 | ||
|
|
839a3222ec | ||
|
|
33a1296e8b | ||
|
|
3940d0c0ea | ||
|
|
7a7ff84a2d | ||
|
|
f81fb09e3b | ||
|
|
bbee99a225 | ||
|
|
a68a79411f | ||
|
|
daa590117d | ||
| fd98a4c705 |
@@ -4,7 +4,7 @@ CTStruct - 텍스트 양식을 구조화하는 트리 자료형 라이브러리
|
|||||||
|
|
||||||
from .node import CTNode
|
from .node import CTNode
|
||||||
from .tree import CTTree
|
from .tree import CTTree
|
||||||
from .types import NodeType, ListStyle, Alignment
|
from .types import NodeType, ListStyle, Alignment, DataType
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.1.0"
|
||||||
__all__ = ["CTNode", "CTTree", "NodeType", "ListStyle", "Alignment"]
|
__all__ = ["CTNode", "CTTree", "NodeType", "ListStyle", "Alignment", "DataType"]
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
100
ctstruct/node.py
100
ctstruct/node.py
@@ -9,7 +9,7 @@ import uuid
|
|||||||
from typing import Any, Iterator, Optional
|
from typing import Any, Iterator, Optional
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
from .types import NodeType
|
from .types import NodeType, DataType
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -17,15 +17,33 @@ class CTNode:
|
|||||||
"""
|
"""
|
||||||
CTStruct 트리의 기본 노드.
|
CTStruct 트리의 기본 노드.
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ [header] │
|
||||||
|
│ prefix ←── content / value ──► suffix │
|
||||||
|
│ [footer] │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
node_type : 노드의 의미적 타입
|
node_type : 노드의 의미적 타입 (NodeType)
|
||||||
content : 노드가 직접 보유하는 텍스트 값 (없으면 None)
|
content : 노드가 직접 보유하는 원문 텍스트 (없으면 None)
|
||||||
attributes : 노드 메타정보 (level, lang, href, …)
|
attributes : 노드 메타정보 (level, lang, href, …)
|
||||||
node_id : 고유 식별자 (자동 생성)
|
node_id : 고유 식별자 (자동 생성, 8자리 hex)
|
||||||
parent : 부모 노드 (루트는 None)
|
parent : 부모 노드 (루트는 None)
|
||||||
children : 자식 노드 목록
|
children : 자식 노드 목록
|
||||||
|
|
||||||
|
── 텍스트 장식 ──────────────────────────────────────
|
||||||
|
header : 머리글 — 노드 블록 앞에 오는 텍스트/레이블
|
||||||
|
footer : 꼬리글 — 노드 블록 뒤에 오는 텍스트/레이블
|
||||||
|
prefix : 접두사 — content/value 직전 인라인 텍스트
|
||||||
|
suffix : 접미사 — content/value 직후 인라인 텍스트
|
||||||
|
|
||||||
|
── 타입 값 ──────────────────────────────────────────
|
||||||
|
data_type : 값의 자료형 힌트 (DataType enum)
|
||||||
|
value : 구조화된 값 (str·int·float·bool·list·dict 등)
|
||||||
|
content 는 원문 텍스트, value 는 해석된 값
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# ── 기본 필드 ─────────────────────────────────────────────
|
||||||
node_type : NodeType
|
node_type : NodeType
|
||||||
content : Optional[str] = None
|
content : Optional[str] = None
|
||||||
attributes : dict[str, Any] = field(default_factory=dict)
|
attributes : dict[str, Any] = field(default_factory=dict)
|
||||||
@@ -33,7 +51,17 @@ class CTNode:
|
|||||||
parent : Optional["CTNode"] = field(default=None, repr=False, compare=False)
|
parent : Optional["CTNode"] = field(default=None, repr=False, compare=False)
|
||||||
children : list["CTNode"] = field(default_factory=list)
|
children : list["CTNode"] = field(default_factory=list)
|
||||||
|
|
||||||
# ── 자식 관리 ────────────────────────────────────────────
|
# ── 텍스트 장식 필드 ──────────────────────────────────────
|
||||||
|
header : Optional[str] = None # 머리글
|
||||||
|
footer : Optional[str] = None # 꼬리글
|
||||||
|
prefix : Optional[str] = None # 접두사
|
||||||
|
suffix : Optional[str] = None # 접미사
|
||||||
|
|
||||||
|
# ── 타입 값 필드 ──────────────────────────────────────────
|
||||||
|
data_type : DataType = field(default=DataType.UNSET)
|
||||||
|
value : Any = None # 해석된 값
|
||||||
|
|
||||||
|
# ── 자식 관리 ─────────────────────────────────────────────
|
||||||
|
|
||||||
def append(self, child: "CTNode") -> "CTNode":
|
def append(self, child: "CTNode") -> "CTNode":
|
||||||
"""자식 노드를 추가하고 self 반환 (체이닝 지원)."""
|
"""자식 노드를 추가하고 self 반환 (체이닝 지원)."""
|
||||||
@@ -64,7 +92,7 @@ class CTNode:
|
|||||||
self.parent.remove(self)
|
self.parent.remove(self)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
# ── 탐색 ─────────────────────────────────────────────────
|
# ── 탐색 ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def depth(self) -> int:
|
def depth(self) -> int:
|
||||||
@@ -134,13 +162,13 @@ class CTNode:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def find_by_id(self, node_id: str) -> Optional["CTNode"]:
|
def find_by_id(self, node_id: str) -> Optional["CTNode"]:
|
||||||
"""node_id로 노드 탐색."""
|
"""node_id 로 노드 탐색."""
|
||||||
for n in self.walk():
|
for n in self.walk():
|
||||||
if n.node_id == node_id:
|
if n.node_id == node_id:
|
||||||
return n
|
return n
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# ── 속성 접근 헬퍼 ───────────────────────────────────────
|
# ── 속성 접근 헬퍼 ────────────────────────────────────────
|
||||||
|
|
||||||
def attr(self, key: str, default: Any = None) -> Any:
|
def attr(self, key: str, default: Any = None) -> Any:
|
||||||
return self.attributes.get(key, default)
|
return self.attributes.get(key, default)
|
||||||
@@ -149,34 +177,64 @@ class CTNode:
|
|||||||
self.attributes[key] = value
|
self.attributes[key] = value
|
||||||
return self
|
return self
|
||||||
|
|
||||||
# ── 표현 ─────────────────────────────────────────────────
|
# ── 텍스트 조합 ───────────────────────────────────────────
|
||||||
|
|
||||||
|
def full_text(self) -> str:
|
||||||
|
"""
|
||||||
|
header + prefix + content + suffix + footer 를 순서대로 이어 반환.
|
||||||
|
|
||||||
|
렌더러 없이 노드의 완전한 텍스트 표현을 빠르게 얻을 때 사용.
|
||||||
|
"""
|
||||||
|
parts: list[str] = []
|
||||||
|
if self.header is not None: parts.append(self.header)
|
||||||
|
if self.prefix is not None: parts.append(self.prefix)
|
||||||
|
if self.content is not None: parts.append(self.content)
|
||||||
|
if self.suffix is not None: parts.append(self.suffix)
|
||||||
|
if self.footer is not None: parts.append(self.footer)
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
def text_content(self) -> str:
|
def text_content(self) -> str:
|
||||||
"""
|
"""
|
||||||
자신과 모든 하위 노드의 텍스트를 이어 붙여 반환.
|
자신과 모든 하위 노드의 full_text() 를 이어 붙여 반환.
|
||||||
렌더러 없이 순수 텍스트 추출에 사용.
|
렌더러 없이 순수 텍스트 추출에 사용.
|
||||||
"""
|
"""
|
||||||
parts: list[str] = []
|
return "".join(n.full_text() for n in self.walk("pre"))
|
||||||
for node in self.walk("pre"):
|
|
||||||
if node.content:
|
# ── 표현 ──────────────────────────────────────────────────
|
||||||
parts.append(node.content)
|
|
||||||
return "".join(parts)
|
|
||||||
|
|
||||||
def pretty(self, indent: int = 2) -> str:
|
def pretty(self, indent: int = 2) -> str:
|
||||||
"""트리 구조를 사람이 읽기 좋은 형태로 출력."""
|
"""트리 구조를 사람이 읽기 좋은 형태로 출력."""
|
||||||
pad = " " * (self.depth * indent)
|
pad = " " * (self.depth * indent)
|
||||||
tag = self.node_type.name
|
tag = self.node_type.name
|
||||||
preview = repr(self.content[:20] + "…") if self.content and len(self.content) > 20 else repr(self.content)
|
dtype = f" dtype={self.data_type.value}" if self.data_type != DataType.UNSET else ""
|
||||||
attrs = ""
|
|
||||||
|
def _clip(s: Optional[str]) -> str:
|
||||||
|
if s is None:
|
||||||
|
return "None"
|
||||||
|
return repr(s[:20] + "…") if len(s) > 20 else repr(s)
|
||||||
|
|
||||||
|
# 핵심 필드만 한 줄로 표시
|
||||||
|
core = f"content={_clip(self.content)}"
|
||||||
|
extras: list[str] = []
|
||||||
|
if self.header is not None: extras.append(f"header={_clip(self.header)}")
|
||||||
|
if self.footer is not None: extras.append(f"footer={_clip(self.footer)}")
|
||||||
|
if self.prefix is not None: extras.append(f"prefix={_clip(self.prefix)}")
|
||||||
|
if self.suffix is not None: extras.append(f"suffix={_clip(self.suffix)}")
|
||||||
|
if self.value is not None: extras.append(f"value={self.value!r}")
|
||||||
if self.attributes:
|
if self.attributes:
|
||||||
attrs = " " + " ".join(f"{k}={v!r}" for k, v in self.attributes.items())
|
for k, v in self.attributes.items():
|
||||||
line = f"{pad}[{self.node_id}] {tag}{attrs}: {preview}"
|
extras.append(f"{k}={v!r}")
|
||||||
|
|
||||||
|
extra_str = (" | " + ", ".join(extras)) if extras else ""
|
||||||
|
line = f"{pad}[{self.node_id}] {tag}{dtype}: {core}{extra_str}"
|
||||||
|
|
||||||
child_lines = "\n".join(c.pretty(indent) for c in self.children)
|
child_lines = "\n".join(c.pretty(indent) for c in self.children)
|
||||||
return f"{line}\n{child_lines}" if child_lines else line
|
return f"{line}\n{child_lines}" if child_lines else line
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return (
|
return (
|
||||||
f"CTNode(id={self.node_id!r}, type={self.node_type.name}, "
|
f"CTNode(id={self.node_id!r}, type={self.node_type.name}, "
|
||||||
|
f"dtype={self.data_type.value}, "
|
||||||
f"children={len(self.children)}, content={self.content!r})"
|
f"children={len(self.children)}, content={self.content!r})"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from .base import BaseParser
|
from .base import BaseParser
|
||||||
from .markdown import MarkdownParser
|
from .markdown import MarkdownParser
|
||||||
|
from .emoji_markup import EmojiMarkupParser
|
||||||
|
|
||||||
__all__ = ["BaseParser", "MarkdownParser"]
|
__all__ = ["BaseParser", "MarkdownParser", "EmojiMarkupParser"]
|
||||||
|
|||||||
142
ctstruct/parsers/emoji_markup.py
Normal file
142
ctstruct/parsers/emoji_markup.py
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
"""
|
||||||
|
ctstruct/parsers/emoji_markup.py
|
||||||
|
이모지 마크업 포맷 → CTTree 파서
|
||||||
|
|
||||||
|
지원 포맷:
|
||||||
|
<title_mark>제목 → 문서 제목 (root 속성)
|
||||||
|
<section_marks[0]>1단계 → SECTION (level=1)
|
||||||
|
<section_marks[1]>2단계 → SECTION (level=2)
|
||||||
|
…
|
||||||
|
(카테고리) 이름 → 카테고리 태그가 붙은 LIST + LIST_ITEM들
|
||||||
|
이름1 이름2 → 카테고리 없는 LIST + LIST_ITEM들 (줄 연속 가능)
|
||||||
|
|
||||||
|
기본 구분자:
|
||||||
|
title_mark = "✅"
|
||||||
|
section_marks = ["🔹", "🔸"]
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from ..node import CTNode
|
||||||
|
from ..tree import CTTree
|
||||||
|
from ..types import NodeType
|
||||||
|
from .base import BaseParser
|
||||||
|
|
||||||
|
|
||||||
|
class EmojiMarkupParser(BaseParser):
|
||||||
|
"""
|
||||||
|
임의 구분자로 계층을 표현하는 텍스트 포맷 파서.
|
||||||
|
|
||||||
|
구분자는 이모지, 특수문자, 문자열 등 어떤 값도 사용할 수 있습니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title_mark: 문서 제목 줄 앞에 붙는 마커 (기본 "✅")
|
||||||
|
section_marks: 섹션 레벨별 마커 목록 (기본 ["🔹", "🔸"])
|
||||||
|
인덱스 0 → level 1, 인덱스 1 → level 2, …
|
||||||
|
더 긴 마커가 먼저 매칭되도록 내부에서 길이 내림차순 정렬됩니다.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
# 기본 이모지 구분자
|
||||||
|
parser = EmojiMarkupParser()
|
||||||
|
|
||||||
|
# 마크다운 스타일 구분자
|
||||||
|
parser = EmojiMarkupParser(title_mark="# ", section_marks=["## ", "### "])
|
||||||
|
|
||||||
|
# 커스텀 구분자
|
||||||
|
parser = EmojiMarkupParser(title_mark="[제목]", section_marks=["[1]", "[2]", "[3]"])
|
||||||
|
"""
|
||||||
|
|
||||||
|
_RE_CAT = re.compile(r'^\(([^)]+)\)\s*(.*)', re.DOTALL)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
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:
|
||||||
|
tree = CTTree()
|
||||||
|
|
||||||
|
# 각 레벨별 현재 활성 섹션 노드 (인덱스 = level-1)
|
||||||
|
section_stack: list[CTNode | None] = [None] * len(self.section_marks)
|
||||||
|
|
||||||
|
pending_names: list[str] = []
|
||||||
|
pending_category: str | None = None
|
||||||
|
pending_parent: CTNode | None = None
|
||||||
|
|
||||||
|
def flush() -> None:
|
||||||
|
nonlocal pending_names, pending_category, pending_parent
|
||||||
|
if not pending_names:
|
||||||
|
return
|
||||||
|
attrs = {"category": pending_category} if pending_category else {}
|
||||||
|
lst = CTNode(NodeType.LIST, attributes=attrs)
|
||||||
|
for name in pending_names:
|
||||||
|
lst.append(CTNode(NodeType.LIST_ITEM, name))
|
||||||
|
target = pending_parent if pending_parent is not None else tree.root
|
||||||
|
target.append(lst)
|
||||||
|
pending_names.clear()
|
||||||
|
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():
|
||||||
|
line = raw.strip()
|
||||||
|
|
||||||
|
if not line:
|
||||||
|
flush()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 제목 마커
|
||||||
|
if line.startswith(self.title_mark):
|
||||||
|
flush()
|
||||||
|
tree.root.set_attr("title", line[len(self.title_mark):].strip())
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 섹션 마커 (긴 것 우선 매칭)
|
||||||
|
matched = False
|
||||||
|
for mark, level in self._sorted_marks:
|
||||||
|
if line.startswith(mark):
|
||||||
|
flush()
|
||||||
|
content = line[len(mark):].strip()
|
||||||
|
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
|
||||||
|
|
||||||
|
# 카테고리 마커 → 새 그룹 시작
|
||||||
|
m = self._RE_CAT.match(line)
|
||||||
|
if m:
|
||||||
|
flush()
|
||||||
|
pending_category = m.group(1).strip()
|
||||||
|
rest = m.group(2).strip()
|
||||||
|
if rest:
|
||||||
|
pending_names.extend(rest.split())
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 이름 목록 (연속 줄은 같은 그룹으로 병합)
|
||||||
|
pending_names.extend(line.split())
|
||||||
|
|
||||||
|
flush()
|
||||||
|
return tree
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from __future__ import annotations
|
|||||||
from typing import Any, Callable, Iterator, Optional
|
from typing import Any, Callable, Iterator, Optional
|
||||||
|
|
||||||
from .node import CTNode
|
from .node import CTNode
|
||||||
from .types import NodeType
|
from .types import NodeType, DataType
|
||||||
|
|
||||||
|
|
||||||
class CTTree:
|
class CTTree:
|
||||||
@@ -150,11 +150,19 @@ class CTTree:
|
|||||||
"""트리 전체를 Python dict 로 직렬화."""
|
"""트리 전체를 Python dict 로 직렬화."""
|
||||||
def _serialize(node: CTNode) -> dict:
|
def _serialize(node: CTNode) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": node.node_id,
|
"id": node.node_id,
|
||||||
"type": node.node_type.name,
|
"type": node.node_type.name,
|
||||||
"content": node.content,
|
"content": node.content,
|
||||||
"attributes": node.attributes,
|
"attributes": node.attributes,
|
||||||
"children": [_serialize(c) for c in node.children],
|
# ── 텍스트 장식 ──
|
||||||
|
"header": node.header,
|
||||||
|
"footer": node.footer,
|
||||||
|
"prefix": node.prefix,
|
||||||
|
"suffix": node.suffix,
|
||||||
|
# ── 타입 값 ──
|
||||||
|
"data_type": node.data_type.value,
|
||||||
|
"value": node.value,
|
||||||
|
"children": [_serialize(c) for c in node.children],
|
||||||
}
|
}
|
||||||
return _serialize(self.root)
|
return _serialize(self.root)
|
||||||
|
|
||||||
@@ -164,11 +172,23 @@ class CTTree:
|
|||||||
tree = cls()
|
tree = cls()
|
||||||
|
|
||||||
def _deserialize(d: dict) -> CTNode:
|
def _deserialize(d: dict) -> CTNode:
|
||||||
|
raw_dtype = d.get("data_type", DataType.UNSET.value)
|
||||||
|
try:
|
||||||
|
dtype = DataType(raw_dtype)
|
||||||
|
except ValueError:
|
||||||
|
dtype = DataType.CUSTOM
|
||||||
|
|
||||||
node = CTNode(
|
node = CTNode(
|
||||||
node_type=NodeType[d["type"]],
|
node_type = NodeType[d["type"]],
|
||||||
content=d.get("content"),
|
content = d.get("content"),
|
||||||
attributes=d.get("attributes", {}),
|
attributes = d.get("attributes", {}),
|
||||||
node_id=d.get("id", ""),
|
node_id = d.get("id", ""),
|
||||||
|
header = d.get("header"),
|
||||||
|
footer = d.get("footer"),
|
||||||
|
prefix = d.get("prefix"),
|
||||||
|
suffix = d.get("suffix"),
|
||||||
|
data_type = dtype,
|
||||||
|
value = d.get("value"),
|
||||||
)
|
)
|
||||||
for child_data in d.get("children", []):
|
for child_data in d.get("children", []):
|
||||||
node.append(_deserialize(child_data))
|
node.append(_deserialize(child_data))
|
||||||
|
|||||||
@@ -53,3 +53,40 @@ class Alignment(Enum):
|
|||||||
CENTER = "center"
|
CENTER = "center"
|
||||||
RIGHT = "right"
|
RIGHT = "right"
|
||||||
NONE = "none"
|
NONE = "none"
|
||||||
|
|
||||||
|
|
||||||
|
class DataType(Enum):
|
||||||
|
"""
|
||||||
|
CTNode.value 의 자료형 힌트.
|
||||||
|
|
||||||
|
파서·렌더러가 값을 해석할 때 참조하는 타입 태그입니다.
|
||||||
|
추후 타입이 추가될 수 있으며, 미지정 시 UNSET 을 사용합니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
UNSET = "unset" # 자료형 미지정 (기본값)
|
||||||
|
|
||||||
|
# ── 스칼라 ────────────────────────────────────────────────
|
||||||
|
STRING = "string" # 문자열
|
||||||
|
INTEGER = "integer" # 정수
|
||||||
|
FLOAT = "float" # 부동소수점
|
||||||
|
BOOLEAN = "boolean" # 참/거짓
|
||||||
|
NULL = "null" # 빈 값
|
||||||
|
|
||||||
|
# ── 날짜/시간 ─────────────────────────────────────────────
|
||||||
|
DATE = "date" # 날짜 (YYYY-MM-DD)
|
||||||
|
DATETIME = "datetime" # 날짜+시간 (ISO 8601)
|
||||||
|
TIME = "time" # 시간 (HH:MM:SS)
|
||||||
|
|
||||||
|
# ── 컬렉션 ────────────────────────────────────────────────
|
||||||
|
LIST = "list" # 배열/목록
|
||||||
|
DICT = "dict" # 키-값 맵
|
||||||
|
SET = "set" # 집합
|
||||||
|
|
||||||
|
# ── 텍스트 특화 ───────────────────────────────────────────
|
||||||
|
MARKDOWN = "markdown" # Markdown 서식 문자열
|
||||||
|
HTML = "html" # HTML 서식 문자열
|
||||||
|
JSON = "json" # JSON 직렬화 문자열
|
||||||
|
CODE = "code" # 소스 코드 문자열
|
||||||
|
|
||||||
|
# ── 확장 ──────────────────────────────────────────────────
|
||||||
|
CUSTOM = "custom" # 사용자 정의 타입
|
||||||
|
|||||||
317
tests/test_emoji_markup_parser.py
Normal file
317
tests/test_emoji_markup_parser.py
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
194
tests/test_node_fields.py
Normal file
194
tests/test_node_fields.py
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
"""
|
||||||
|
tests/test_node_fields.py
|
||||||
|
CTNode 의 6개 신규 필드(header, footer, prefix, suffix, data_type, value) 테스트
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from ctstruct import CTNode, CTTree, NodeType, DataType
|
||||||
|
|
||||||
|
|
||||||
|
class TestNewFields:
|
||||||
|
"""신규 필드 기본 동작 확인."""
|
||||||
|
|
||||||
|
def test_default_values(self):
|
||||||
|
"""미지정 시 모두 None / UNSET 이어야 한다."""
|
||||||
|
node = CTNode(NodeType.PARAGRAPH, "hello")
|
||||||
|
assert node.header is None
|
||||||
|
assert node.footer is None
|
||||||
|
assert node.prefix is None
|
||||||
|
assert node.suffix is None
|
||||||
|
assert node.data_type == DataType.UNSET
|
||||||
|
assert node.value is None
|
||||||
|
|
||||||
|
def test_set_on_construction(self):
|
||||||
|
"""생성자에서 모든 필드를 지정할 수 있어야 한다."""
|
||||||
|
node = CTNode(
|
||||||
|
node_type = NodeType.PARAGRAPH,
|
||||||
|
content = "본문",
|
||||||
|
header = "== 시작 ==",
|
||||||
|
footer = "== 끝 ==",
|
||||||
|
prefix = "[",
|
||||||
|
suffix = "]",
|
||||||
|
data_type = DataType.STRING,
|
||||||
|
value = "본문",
|
||||||
|
)
|
||||||
|
assert node.header == "== 시작 =="
|
||||||
|
assert node.footer == "== 끝 =="
|
||||||
|
assert node.prefix == "["
|
||||||
|
assert node.suffix == "]"
|
||||||
|
assert node.data_type == DataType.STRING
|
||||||
|
assert node.value == "본문"
|
||||||
|
|
||||||
|
def test_set_after_construction(self):
|
||||||
|
"""생성 후 직접 속성 할당도 가능해야 한다."""
|
||||||
|
node = CTNode(NodeType.PARAGRAPH, "숫자")
|
||||||
|
node.header = "머리글"
|
||||||
|
node.footer = "꼬리글"
|
||||||
|
node.prefix = "₩"
|
||||||
|
node.suffix = "원"
|
||||||
|
node.data_type = DataType.INTEGER
|
||||||
|
node.value = 42
|
||||||
|
assert node.value == 42
|
||||||
|
assert node.data_type == DataType.INTEGER
|
||||||
|
|
||||||
|
def test_value_types(self):
|
||||||
|
"""value 필드는 어떤 파이썬 타입도 허용해야 한다."""
|
||||||
|
for val, dtype in [
|
||||||
|
(123, DataType.INTEGER),
|
||||||
|
(3.14, DataType.FLOAT),
|
||||||
|
(True, DataType.BOOLEAN),
|
||||||
|
(None, DataType.NULL),
|
||||||
|
([1, 2, 3], DataType.LIST),
|
||||||
|
({"k": "v"}, DataType.DICT),
|
||||||
|
("텍스트", DataType.STRING),
|
||||||
|
]:
|
||||||
|
node = CTNode(NodeType.CUSTOM, data_type=dtype, value=val)
|
||||||
|
assert node.value == val
|
||||||
|
assert node.data_type == dtype
|
||||||
|
|
||||||
|
|
||||||
|
class TestFullText:
|
||||||
|
"""full_text() — header/prefix/content/suffix/footer 조합 확인."""
|
||||||
|
|
||||||
|
def test_content_only(self):
|
||||||
|
node = CTNode(NodeType.PARAGRAPH, "hello")
|
||||||
|
assert node.full_text() == "hello"
|
||||||
|
|
||||||
|
def test_prefix_suffix(self):
|
||||||
|
node = CTNode(NodeType.PARAGRAPH, "world", prefix="(", suffix=")")
|
||||||
|
assert node.full_text() == "(world)"
|
||||||
|
|
||||||
|
def test_header_footer(self):
|
||||||
|
node = CTNode(NodeType.PARAGRAPH, "body", header="HEAD\n", footer="\nFOOT")
|
||||||
|
assert node.full_text() == "HEAD\nbody\nFOOT"
|
||||||
|
|
||||||
|
def test_all_fields(self):
|
||||||
|
node = CTNode(
|
||||||
|
NodeType.PARAGRAPH,
|
||||||
|
content = "내용",
|
||||||
|
header = "H|",
|
||||||
|
footer = "|F",
|
||||||
|
prefix = "→",
|
||||||
|
suffix = "←",
|
||||||
|
)
|
||||||
|
assert node.full_text() == "H|→내용←|F"
|
||||||
|
|
||||||
|
def test_none_fields_skipped(self):
|
||||||
|
"""None 필드는 빈 문자열로 취급되지 않고 생략되어야 한다."""
|
||||||
|
node = CTNode(NodeType.PARAGRAPH, content=None, prefix=">>")
|
||||||
|
assert node.full_text() == ">>"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSerialization:
|
||||||
|
"""to_dict / from_dict 에 신규 필드가 포함되는지 확인."""
|
||||||
|
|
||||||
|
def _make_node(self) -> CTNode:
|
||||||
|
return CTNode(
|
||||||
|
NodeType.PARAGRAPH,
|
||||||
|
content = "직렬화 테스트",
|
||||||
|
header = "머리글",
|
||||||
|
footer = "꼬리글",
|
||||||
|
prefix = ">>",
|
||||||
|
suffix = "<<",
|
||||||
|
data_type = DataType.MARKDOWN,
|
||||||
|
value = "**bold**",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_to_dict_contains_fields(self):
|
||||||
|
tree = CTTree()
|
||||||
|
tree.root.append(self._make_node())
|
||||||
|
d = tree.to_dict()
|
||||||
|
child = d["children"][0]
|
||||||
|
assert child["header"] == "머리글"
|
||||||
|
assert child["footer"] == "꼬리글"
|
||||||
|
assert child["prefix"] == ">>"
|
||||||
|
assert child["suffix"] == "<<"
|
||||||
|
assert child["data_type"] == DataType.MARKDOWN.value
|
||||||
|
assert child["value"] == "**bold**"
|
||||||
|
|
||||||
|
def test_roundtrip(self):
|
||||||
|
"""직렬화 → 역직렬화 후 모든 신규 필드가 보존되어야 한다."""
|
||||||
|
tree = CTTree()
|
||||||
|
tree.root.append(self._make_node())
|
||||||
|
|
||||||
|
restored = CTTree.from_dict(tree.to_dict())
|
||||||
|
para = restored.find(NodeType.PARAGRAPH)[0]
|
||||||
|
|
||||||
|
assert para.header == "머리글"
|
||||||
|
assert para.footer == "꼬리글"
|
||||||
|
assert para.prefix == ">>"
|
||||||
|
assert para.suffix == "<<"
|
||||||
|
assert para.data_type == DataType.MARKDOWN
|
||||||
|
assert para.value == "**bold**"
|
||||||
|
|
||||||
|
def test_unknown_data_type_falls_back_to_custom(self):
|
||||||
|
"""알 수 없는 data_type 문자열은 CUSTOM 으로 폴백되어야 한다."""
|
||||||
|
d = {
|
||||||
|
"id": "test0001",
|
||||||
|
"type": "PARAGRAPH",
|
||||||
|
"content": "?",
|
||||||
|
"attributes": {},
|
||||||
|
"header": None, "footer": None, "prefix": None, "suffix": None,
|
||||||
|
"data_type": "not_a_real_type",
|
||||||
|
"value": None,
|
||||||
|
"children": [],
|
||||||
|
}
|
||||||
|
tree = CTTree()
|
||||||
|
tree.root = CTTree.from_dict(d).root
|
||||||
|
# ROOT 복원 확인 — 타입은 ROOT가 됨
|
||||||
|
# 직접 역직렬화 테스트
|
||||||
|
from ctstruct.types import DataType as DT
|
||||||
|
from ctstruct.node import CTNode as CN
|
||||||
|
from ctstruct.types import NodeType as NT
|
||||||
|
raw_dtype = d.get("data_type", DT.UNSET.value)
|
||||||
|
try:
|
||||||
|
dtype = DT(raw_dtype)
|
||||||
|
except ValueError:
|
||||||
|
dtype = DT.CUSTOM
|
||||||
|
assert dtype == DT.CUSTOM
|
||||||
|
|
||||||
|
|
||||||
|
class TestPretty:
|
||||||
|
"""pretty() 출력에 신규 필드가 표시되는지 확인."""
|
||||||
|
|
||||||
|
def test_pretty_shows_new_fields(self):
|
||||||
|
node = CTNode(
|
||||||
|
NodeType.PARAGRAPH,
|
||||||
|
content = "텍스트",
|
||||||
|
header = "H",
|
||||||
|
prefix = "P",
|
||||||
|
data_type = DataType.STRING,
|
||||||
|
value = "v",
|
||||||
|
)
|
||||||
|
p = node.pretty()
|
||||||
|
assert "header='H'" in p
|
||||||
|
assert "prefix='P'" in p
|
||||||
|
assert "value='v'" in p
|
||||||
|
|
||||||
|
def test_dtype_shown_when_set(self):
|
||||||
|
node = CTNode(NodeType.PARAGRAPH, data_type=DataType.INTEGER, value=7)
|
||||||
|
assert "dtype=integer" in node.pretty()
|
||||||
|
|
||||||
|
def test_dtype_hidden_when_unset(self):
|
||||||
|
node = CTNode(NodeType.PARAGRAPH, "hi")
|
||||||
|
assert "dtype" not in node.pretty()
|
||||||
1889
ui/builder.html
Normal file
1889
ui/builder.html
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user