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 .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"]

View File

@@ -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})"
) )

View File

@@ -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"]

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 .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"]

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

View File

@@ -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))

View File

@@ -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" # 사용자 정의 타입

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