Compare commits

...

32 Commits

Author SHA1 Message Date
gm
cc8d320b63 fix: parseInstance에서 공백으로 내용 줄을 분리하는 버그 수정
입력 텍스트의 한 줄을 \s+로 split해 여러 토큰으로 만들던 동작 제거.
공백은 템플릿에 정의되지 않은 구분자이므로 각 줄을 하나의 항목으로 처리.
per_line 설정으로 출력 줄당 항목 수를 별도로 제어.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 18:31:39 +09:00
gm
39f48dca4b fix: extractSectionMarks가 collect 노드 스키마 자식 마크를 섹션 구분자로 포함하는 버그 수정
수집 노드의 직접 자식(스키마 inline 마크)은 섹션 경계가 아니므로
sectionMarks에서 제외. parseInstance에서 김양모12시 같은
레코드 데이터가 새 섹션 헤더로 오파싱되던 문제 해결.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 18:26:13 +09:00
gm
2d6be54c02 ui: node drag-to-move, drag-to-child, and copy (v1.0)
- 드래그 핸들(⠿): 노드 카드 헤더 hover 시 표시, 마우스다운으로 드래그 시작
- 드래그 동작:
  - 상단 30% 위 → 해당 노드 앞에 삽입 (파란 상단 선)
  - 하단 30% 아래 → 해당 노드 뒤에 삽입 (파란 하단 선)
  - 중앙 40% → 해당 노드의 하위 노드로 편입 (초록 테두리)
- 복사(⊕): 캔버스 버튼 + detail panel 버튼, 하위 구조 전체 deep clone

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 18:01:55 +09:00
gm
41c73fecd5 ui: structured record collection — collect node schema parsing (v0.9)
수집 노드의 하위 노드를 레코드 필드 스키마로 인식:
- buildSchemaMap(): 수집 섹션의 child sections → 파싱 마크 맵
- parseRecordLine(): 한 줄을 inline 마크 기준으로 분리 → {mark: value} 레코드
- parseInstance(): 스키마 있으면 레코드 파싱, 없으면 기존 토큰 분리
- mergedToText(): 레코드 → inline 재구성 (김양모12시), 단순 → per_line 토큰
- mergedToJSON(): 레코드 → 첫 필드=부모SECTION, 나머지=자식SECTION 계층 구조
- renderFillerTemplateView(): 스키마 필드를 칩 안에 인라인 배지로 표시

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 17:53:25 +09:00
gm
e332e357b9 ui: per_line 전용 UI 블록 분리 (노드 설정 내 별도 섹션)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 17:25:41 +09:00
gm
c215f28a2f ui: add per_line dedicated control in Builder detail panel (v0.8)
- 노드 설정에 '한 줄에 N개씩' 숫자 입력란 추가 (어트리뷰트 수동 입력 불필요)
- 캔버스 카드 footer에 ×N 배지로 per_line 값 표시
- setPerLine() 함수: 빈값/0이면 속성 자동 제거

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 17:17:50 +09:00
gm
f64f8f3eb8 ui: add Tab 3 '구조 추출' — text form to node structure extractor (v0.7)
- 텍스트 양식 붙여넣기 → 섹션 구조 실시간 추출 + 트리 미리보기
- 구분자 수동 입력 및 자동 감지 (첫 등장 순서로 L1·L2·... 결정)
- '내용 포함' 토글: 섹션 헤더만 or 내용도 값으로 포함
- '← Builder에 적용' 버튼으로 추출된 구조를 Tab 1에 로드

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 16:59:11 +09:00
gm
c860035822 ui: add collect/fixed role distinction to nodes (v0.6)
- Builder: 역할 토글(🔒고정/📥수집) in detail panel struct settings
- Canvas: collect 노드 헤더 파란 배경 + 📥 배지
- Tab2 template view: selective mode일 때 수집/고정 배지 표시
- Tab2 merge: collect 섹션만 토큰 수집, 고정 섹션은 템플릿 값 그대로 출력
- Backwards compatible: collect 섹션이 없으면 기존처럼 전체 수집

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 15:32:59 +09:00
gm
5a86d52813 ui: redesign Tab 2 as text-paste form merger (Option A)
Workflow:
  1. Load template JSON (defines section structure)
  2. Paste multiple text-format forms into textarea
  3. Parser splits by title mark, collects tokens per section key
  4. Merged output in text or JSON format

Key changes:
- splitIntoInstances(): split at each title-mark line boundary
- parseInstance(): match section headers by mark+name, tokenize value lines
- mergedToText(): walk template tree, output tokens with per_line chunking
- mergedToJSON(): reconstruct ROOT/SECTION/VALUE tree with merged tokens
- Template view: compact inline chip display of section structure
- Instance counter badge shows how many forms were detected
- JSON export: each token becomes a separate VALUE node (data_type:string)
- per_line attribute on SECTION controls line wrapping in text output

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 14:11:59 +09:00
gm
c38d071a86 ui: add content filler tab (Step 3)
- New '내용 채우기' tab with full JSON template import workflow
- Load Builder JSON (slim or full) → parse ROOT/SECTION/VALUE tree
- Render form: each SECTION as a card, VALUE nodes as input fields
  (string → textarea, other types → input)
- Nested sections auto-indent with accent left-border via CSS
- Real-time text/JSON preview as values are typed
- Copy button for filled text or filled JSON output
- fillerToText() mirrors toText() logic using stored FV values
- fillerToJSON() reconstructs full JSON with FV values substituted
- Escape key + backdrop click close filler import modal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 12:47:45 +09:00
gm
97e2406044 ui: 2D canvas tree builder (Step 2)
- Replace list-based editor with 2D canvas tree visualization
- Nodes laid out by depth column + subtree-height algorithm
- Bezier curves connect parent→child (accent-colored when selected)
- Click node to select; detail panel on right shows editable fields
- Canvas toolbar holds doc title/mark + add/import/new actions
- Inline renderNode() removed; renderValue() for node-type shows reference widget with → navigate button
- addTopNode()/addChildNode() auto-select the new node
- Preview panel stays in right-panel stacked under detail

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 12:31:36 +09:00
gm
45677d7b18 ui: add main tab navigation skeleton (Step 1)
- Add top-level tab bar (양식 만들기 / 내용 채우기)
- Wrap existing builder in panel-builder tab panel
- Move action buttons (불러오기, 새 문서) into editor panel header
- Add placeholder panel-filler tab panel (Step 3 예정)
- Add switchTab() for tab switching
- Bump version badge to v0.5

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 11:35:28 +09:00
gm
65e946b4e3 fix(ui): 노드 사이 기본 줄바꿈 제거
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 02:02:10 +09:00
gm
8b0842eb80 feat(ui): prefix/suffix에서 \n \t \r 이스케이프 시퀀스 지원
unesc(): 입력값 \n→실제줄바꿈, \t→탭, \r→CR
reesc(): 상태값을 표시용 이스케이프 문자열로 역변환
renderSetting 입력 필드에 적용, placeholder에 사용법 표시.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 02:01:24 +09:00
gm
309c45886e fix(ui): prefix/suffix 기본값 null 복원 (빈 문자열로 초기화)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 01:59:48 +09:00
gm
b6f54d0187 refactor(ui): 노드 구조 추가 정리
- buildNode 템플릿에서 header/footer 완전 제거
- prefix/suffix 추가 시 기본값 '\n' (줄바꿈)으로 초기화
- 노드 간 구분자 제거: join('\n') → join('') (기본적으로 아무것도 없음)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 01:56:19 +09:00
gm
3449df10bc refactor(ui): 구조 대폭 간소화
- 자료형: node/string/int/bool/date 5개만 유지, integer→int, boolean→bool
- 리스트 타입 완전 제거 (기존 데이터 → string으로 자동 마이그레이션)
- nextSep/childSep 제거 (노드 간 구분은 항상 줄바꿈)
- header/footer 제거
- prefix = 노드 전체 앞 문자열, suffix = 노드 전체 뒤 문자열로 재정의
- toText(): prefix/suffix 렌더링, list 로직 제거, 구조 단순화
- toJSON(): list/nextSep/childSep/header/footer 제거
- parseJsonSection: 동일하게 단순화, 구버전 LIST → string 호환
- doImport: flush() → string value 생성
- CSS: list 전용 스타일 제거

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 01:53:50 +09:00
gm
28e52e9ce0 feat(ui): JSON에 nextSep/childSep/nameNewline 포함 및 복원
toJSON() serNode에 nextSep, childSep, nameNewline 필드 추가.
parseJsonSection에서 해당 필드 읽어 노드에 복원.
JSON slim/full 양쪽 모두 포함됨 (값이 null이 아니므로 stripNulls 통과).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 01:30:11 +09:00
gm
702eb918ee feat(ui): JSON → 노드 불러오기 탭 추가
불러오기 모달에 텍스트/JSON 탭 구분 추가.
JSON 탭: ROOT→SECTION→LIST/VALUE 구조를 노드 트리로 복원.
- SECTION: mark, content, header/footer/prefix/suffix, attributes 복원
- LIST: LIST_ITEM들을 names로 결합, category 복원
- SECTION 중첩: node-type value로 재귀 복원
- VALUE: data_type/value → scalar val 복원
- nextSep/childSep/nameNewline은 기본값으로 초기화

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 01:14:52 +09:00
gm
63cb612a6e feat(ui): 이름 줄바꿈 토글 및 구분자 옵션을 노드 설정으로 이동
- 노드에 nameNewline(bool, default:true) 속성 추가
  - true: 이름 다음 줄바꿈 후 값 출력 (기존 동작)
  - false: 이름 바로 뒤에 값 연결 (인라인)
  - 첫 번째 list/scalar에만 적용, 이후는 항상 '\n' 사용
- 구분자 설정 바(sep-bar)를 노드 설정 섹션 내부로 이동
  - 다음 노드 / 하위 노드 / 이름 뒤 3개 행 항상 표시
  - 선택적 설정(header 등)은 드롭다운으로 하단에 추가
- renderSepBar → renderStructSettings 리네임

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 00:54:12 +09:00
gm
df011a5fcc feat(ui): 인라인 구분자 지원 및 리스트 자료형 제거
- VALUE_TYPES에서 '리스트 (List)' 제거
- SEP_CFG에 '줄바꿈' 프리셋 추가 (없음/줄바꿈/빈줄)
- toText() 재작성: lines 배열 → string 직접 결합 방식으로 변경
  - 인라인 구분자(예: '/') 사용 시 a/b/c 형태로 한 줄 출력
  - ss() 함수: none='' / newline='\n' / blank='\n\n' / 직접입력=그대로
  - pendingSep 패턴으로 연속 child node 간 구분자 처리

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 00:28:03 +09:00
gm
881b3d1f10 feat(ui): 노드 구분자 설정 추가 (다음 노드 / 하위 노드)
각 노드 카드 바디 상단에 구분자 설정 바 추가.
- 다음 노드 (nextSep): 이 노드 뒤~다음 형제 노드 사이 구분
- 하위 노드 (childSep): 이 노드 헤더~첫 자식 노드 사이 구분
- 프리셋: 없음 / 빈 줄 / 직접 입력
- toText()에 applySep() 적용, list 뒤 빈 줄은 기존 유지
- 기존 저장 데이터 normalizeState()로 자동 마이그레이션

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 00:07:31 +09:00
gm
65f5d4b422 feat(ui): 어트리뷰트 키를 드롭다운 선택으로 변경
ATTR_KEYS 목록 정의 (lang/id/class/href/align/order/required/readonly/
hidden/weight/style + 직접 입력). 이미 추가된 키는 드롭다운에서 제외.
키는 배지 레이블로 표시, 값만 자유 입력. 직접 입력 선택 시 prompt()로
키 이름 수동 입력 지원.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 23:59:41 +09:00
gm
839a3222ec refactor(ui): 노드 중심 전면 개편 v0.4
- 노드 헤더: 구분자·이름·숨기기 토글·이동·삭제
- 값 섹션: 드롭다운으로 list/node/string/integer 등 타입별 값 추가, 복수 지원
  - list: 이름목록·카테고리·구분자·줄당 N명
  - node: 하위 노드 카드 인라인 중첩 렌더링
  - 스칼라: 단순 입력(textarea는 markdown/html/json/code)
- 노드 설정 섹션: 드롭다운으로 필요한 옵션만 추가, 미선택=기본값
  - header/footer/prefix/suffix/attributes
- 데이터 모델: sections[]+groups[]+children[] → nodes[]+values[] 통합
- 구버전 localStorage 자동 마이그레이션

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 23:54:11 +09:00
gm
33a1296e8b refactor(ui): per-node delimiter, node settings panel consolidation
각 노드가 독립적인 구분자를 가지도록 전면 개편. 전역 구분자 설정 카드 제거.
노드 카드 구조: 구분자·제목·노드설정·하위노드. 이름 목록(LIST 그룹)을
노드설정 패널 안으로 통합. 불러오기 모달에 구분자 직접 입력 추가.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 23:21:16 +09:00
gm
3940d0c0ea refactor(ui): node-centric settings panel redesign
데이터 모델
- sec.name → sec.content (CTNode content 필드와 통일)
- grp.cat → grp.attributes.category (어트리뷰트 편집기로 통합)
- 모든 노드(SECTION, LIST)에 CTNode 기본 필드 추가:
  header / footer / prefix / suffix / data_type / value

노드 설정 패널 (▶ 노드 설정, 접이식)
- 텍스트 장식: header / footer / prefix / suffix
- 어트리뷰트: key-value 동적 추가·수정·삭제 (level은 자동 관리)
- 값: DataType 드롭다운(17종) + value 입력
- 비기본값 설정 시 파란 점(●) 표시
- openSettings Set으로 접이 상태 렌더 간 유지

기타
- mkSec / mkGrp / nodeDefaults 팩토리로 기본값 통일
- migrate()로 구버전 localStorage 자동 변환
- toJSON: header/footer/prefix/suffix/data_type/value 포함
- 구분자·한줄 옵션은 그룹 하단에 유지

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 00:28:56 +09:00
gm
7a7ff84a2d feat(ui): per-group separator and namesPerLine settings
- 전역 namesPerLine 제거 → 그룹별 개별 설정으로 전환
- 각 이름 그룹 카드에 두 가지 옵션 추가:
  - 구분자: 공백 / ", " / " / " / 줄바꿈(↵) 칩 + 직접 입력
  - 한 줄 N개: 0=제한없음, 줄바꿈 모드 시 자동 비활성화
- mkGrp() 헬퍼로 그룹 기본값(sep=' ', namesPerLine=0) 통일

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 23:56:08 +09:00
gm
f81fb09e3b feat(ui): add namesPerLine setting to builder
- 구분자 설정 카드에 '한 줄 N개' 입력란 추가
- 0 = 제한 없음 (기존 동작 유지)
- N > 0 이면 이름을 N개씩 줄 분할, 카테고리 prefix는 첫 줄에만
- localStorage 저장/복원 포함

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 23:43:21 +09:00
gm
bbee99a225 feat(ui): add standalone HTML form builder
- 좌측: 시각적 편집기 (섹션/이름그룹 추가·삭제·순서변경)
- 우측: 텍스트/JSON slim/JSON full 실시간 미리보기 + 복사
- 구분자 자유 설정 (이모지·문자열·임의 개수 레벨)
- 텍스트 불러오기 (EmojiMarkupParser JS 포트)
- localStorage 자동저장

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 22:41:50 +09:00
gm
a68a79411f 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>
2026-06-01 18:30:13 +09:00
gm
daa590117d feat(parser): add EmojiMarkupParser for /🔹/🔸 format
-  → root title, 🔹 → SECTION(level=1), 🔸 → SECTION(level=2)
- (카테고리) 이름들 → LIST with category attribute + LIST_ITEM children
- 카테고리 없는 이름 줄은 빈 줄/새 섹션 전까지 동일 LIST로 묶음
- 22개 테스트 추가 (구조, 명단, 엣지케이스)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 18:14:04 +09:00
gm
fd98a4c705 feat(node): add header, footer, prefix, suffix, data_type, value fields
- CTNode에 6개 신규 필드 추가
  - header    : 머리글 (노드 블록 앞 텍스트)
  - footer    : 꼬리글 (노드 블록 뒤 텍스트)
  - prefix    : 접두사 (content 직전 인라인)
  - suffix    : 접미사 (content 직후 인라인)
  - data_type : 값의 자료형 힌트 (DataType enum)
  - value     : 해석된 값 (Any — str/int/float/bool/list/dict 등)
- types.py: DataType enum 추가 (UNSET, STRING~CODE, CUSTOM)
- node.py: full_text() 메서드 추가 (6개 필드 순서대로 조합)
- tree.py: to_dict / from_dict 직렬화에 신규 필드 포함
- 테스트: test_node_fields.py 신규 추가 (15개 케이스)
- 전체 테스트 42/42 통과
2026-05-30 21:13:22 +09:00
16 changed files with 2816 additions and 41 deletions

View File

@@ -4,7 +4,7 @@ CTStruct - 텍스트 양식을 구조화하는 트리 자료형 라이브러리
from .node import CTNode
from .tree import CTTree
from .types import NodeType, ListStyle, Alignment
from .types import NodeType, ListStyle, Alignment, DataType
__version__ = "0.1.0"
__all__ = ["CTNode", "CTTree", "NodeType", "ListStyle", "Alignment"]
__all__ = ["CTNode", "CTTree", "NodeType", "ListStyle", "Alignment", "DataType"]

View File

@@ -9,7 +9,7 @@ import uuid
from typing import Any, Iterator, Optional
from dataclasses import dataclass, field
from .types import NodeType
from .types import NodeType, DataType
@dataclass
@@ -17,15 +17,33 @@ class CTNode:
"""
CTStruct 트리의 기본 노드.
┌─────────────────────────────────────────────────────────┐
│ [header] │
│ prefix ←── content / value ──► suffix │
│ [footer] │
└─────────────────────────────────────────────────────────┘
Attributes:
node_type : 노드의 의미적 타입
content : 노드가 직접 보유하는 텍스트 (없으면 None)
node_type : 노드의 의미적 타입 (NodeType)
content : 노드가 직접 보유하는 원문 텍스트 (없으면 None)
attributes : 노드 메타정보 (level, lang, href, …)
node_id : 고유 식별자 (자동 생성)
node_id : 고유 식별자 (자동 생성, 8자리 hex)
parent : 부모 노드 (루트는 None)
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
content : Optional[str] = None
attributes : dict[str, Any] = field(default_factory=dict)
@@ -33,7 +51,17 @@ class CTNode:
parent : Optional["CTNode"] = field(default=None, repr=False, compare=False)
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":
"""자식 노드를 추가하고 self 반환 (체이닝 지원)."""
@@ -64,7 +92,7 @@ class CTNode:
self.parent.remove(self)
return self
# ── 탐색 ─────────────────────────────────────────────────
# ── 탐색 ─────────────────────────────────────────────────
@property
def depth(self) -> int:
@@ -134,13 +162,13 @@ class CTNode:
return None
def find_by_id(self, node_id: str) -> Optional["CTNode"]:
"""node_id로 노드 탐색."""
"""node_id 로 노드 탐색."""
for n in self.walk():
if n.node_id == node_id:
return n
return None
# ── 속성 접근 헬퍼 ───────────────────────────────────────
# ── 속성 접근 헬퍼 ───────────────────────────────────────
def attr(self, key: str, default: Any = None) -> Any:
return self.attributes.get(key, default)
@@ -149,34 +177,64 @@ class CTNode:
self.attributes[key] = value
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:
"""
자신과 모든 하위 노드의 텍스트를 이어 붙여 반환.
자신과 모든 하위 노드의 full_text() 를 이어 붙여 반환.
렌더러 없이 순수 텍스트 추출에 사용.
"""
parts: list[str] = []
for node in self.walk("pre"):
if node.content:
parts.append(node.content)
return "".join(parts)
return "".join(n.full_text() for n in self.walk("pre"))
# ── 표현 ──────────────────────────────────────────────────
def pretty(self, indent: int = 2) -> str:
"""트리 구조를 사람이 읽기 좋은 형태로 출력."""
pad = " " * (self.depth * indent)
tag = self.node_type.name
preview = repr(self.content[:20] + "") if self.content and len(self.content) > 20 else repr(self.content)
attrs = ""
pad = " " * (self.depth * indent)
tag = self.node_type.name
dtype = f" dtype={self.data_type.value}" if self.data_type != DataType.UNSET else ""
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:
attrs = " " + " ".join(f"{k}={v!r}" for k, v in self.attributes.items())
line = f"{pad}[{self.node_id}] {tag}{attrs}: {preview}"
for k, v in self.attributes.items():
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)
return f"{line}\n{child_lines}" if child_lines else line
def __repr__(self) -> str:
return (
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})"
)

View File

@@ -1,4 +1,5 @@
from .base import BaseParser
from .markdown import MarkdownParser
from .emoji_markup import EmojiMarkupParser
__all__ = ["BaseParser", "MarkdownParser"]
__all__ = ["BaseParser", "MarkdownParser", "EmojiMarkupParser"]

View 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

View File

@@ -1,5 +1,6 @@
from .base import BaseRenderer
from .markdown import MarkdownRenderer
from .json_renderer import JSONRenderer
from .emoji_markup import EmojiMarkupRenderer
__all__ = ["BaseRenderer", "MarkdownRenderer", "JSONRenderer"]
__all__ = ["BaseRenderer", "MarkdownRenderer", "JSONRenderer", "EmojiMarkupRenderer"]

View 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 + [""] # 목록 뒤 빈 줄

View File

@@ -6,20 +6,53 @@ CTTree → JSON 렌더러
from __future__ import annotations
import json
from typing import Any
from ..tree import CTTree
from .base import 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.ensure_ascii = ensure_ascii
self.slim = slim
def render(self, tree: CTTree) -> str:
return json.dumps(
tree.to_dict(),
indent=self.indent,
ensure_ascii=self.ensure_ascii,
)
data = tree.to_dict()
if self.slim:
data = self._strip(data)
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

View File

@@ -8,7 +8,7 @@ from __future__ import annotations
from typing import Any, Callable, Iterator, Optional
from .node import CTNode
from .types import NodeType
from .types import NodeType, DataType
class CTTree:
@@ -150,11 +150,19 @@ class CTTree:
"""트리 전체를 Python dict 로 직렬화."""
def _serialize(node: CTNode) -> dict:
return {
"id": node.node_id,
"type": node.node_type.name,
"content": node.content,
"id": node.node_id,
"type": node.node_type.name,
"content": node.content,
"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)
@@ -164,11 +172,23 @@ class CTTree:
tree = cls()
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_type=NodeType[d["type"]],
content=d.get("content"),
attributes=d.get("attributes", {}),
node_id=d.get("id", ""),
node_type = NodeType[d["type"]],
content = d.get("content"),
attributes = d.get("attributes", {}),
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", []):
node.append(_deserialize(child_data))

View File

@@ -53,3 +53,40 @@ class Alignment(Enum):
CENTER = "center"
RIGHT = "right"
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" # 사용자 정의 타입

View 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
View 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

File diff suppressed because it is too large Load Diff