// 순수함수 회귀 테스트. 실행(로컬, 의존성 0): node --test src/lib/utils/headingPath.test.ts // (Node ≥23 또는 22.6+ --experimental-strip-types — TS 타입 네이티브 strip.) import { test } from 'node:test'; import assert from 'node:assert/strict'; import { cleanHeading, pathSegments, collapseWindows, groupOrFlat, buildPartOutline, partitionOutlineItems, partGroupViews, groupKeyByChunkId, FRONT_MATTER_KEY, FRONT_MATTER_LABEL, sectionTypeLabel, type DocumentSection, } from './headingPath.ts'; let _id = 0; function sec(p: Partial): DocumentSection { return { chunk_id: ++_id, section_title: null, heading_path: null, level: null, node_type: null, is_leaf: true, section_type: null, summary: null, confidence: null, ...p, }; } test('cleanHeading: 마크다운/HTML 잔재 strip', () => { assert.equal(cleanHeading('**UG-5 PLATE**2'), 'UG-5 PLATE'); assert.equal(cleanHeading(' **DESIGN** '), 'DESIGN'); assert.equal(cleanHeading('a b\tc'), 'a b c'); assert.equal(cleanHeading(null), ''); assert.equal(cleanHeading(''), ''); }); test('pathSegments: > 분할 + 정제', () => { assert.deepEqual(pathSegments('**A** > **B**1 > C'), ['A', 'B', 'C']); assert.deepEqual(pathSegments(null), []); assert.deepEqual(pathSegments(' '), []); }); test('sectionTypeLabel: 한글 매핑 + passthrough', () => { assert.equal(sectionTypeLabel('requirement'), '요건'); assert.equal(sectionTypeLabel('unknown_type'), 'unknown_type'); assert.equal(sectionTypeLabel(null), null); }); test('collapseWindows: 연속 동일 heading window 만 dedupe, 순서 유지', () => { const input = [ sec({ heading_path: 'Intro', node_type: null }), sec({ heading_path: 'Pearson', node_type: 'window' }), sec({ heading_path: 'Pearson', node_type: 'window' }), sec({ heading_path: 'Pearson', node_type: 'window' }), sec({ heading_path: 'Conf', node_type: null }), sec({ heading_path: 'Pearson', node_type: 'window' }), // 비연속 → 새 항목 ]; const out = collapseWindows(input); assert.equal(out.length, 4); assert.equal(out[0].fragmentCount, 1); // Intro assert.equal(out[1].fragmentCount, 3); // Pearson ×3 합침 assert.equal(out[2].fragmentCount, 1); // Conf assert.equal(out[3].fragmentCount, 1); // 비연속 Pearson // 순서 보존 assert.deepEqual( out.map((o) => cleanHeading(o.section.heading_path)), ['Intro', 'Pearson', 'Conf', 'Pearson'], ); }); test('[C2] collapseWindows: split-parent + window 들 → rail 1행, 대표=split-parent(char_start 보유)', () => { const input = [ sec({ section_title: 'Article 5', heading_path: 'Article 5', node_type: 'chapter_split', is_leaf: false, char_start: 120 }), sec({ section_title: 'Article 5', heading_path: 'Article 5', node_type: 'window', is_leaf: true, char_start: null }), sec({ section_title: 'Article 5', heading_path: 'Article 5', node_type: 'window', is_leaf: true, char_start: null }), ]; const out = collapseWindows(input); assert.equal(out.length, 1, 'split-parent + 2 window → rail 1행'); // 대표 = split-parent (char_start 보유) → jump 성립 assert.equal(out[0].section.node_type, 'chapter_split'); assert.equal(out[0].section.char_start, 120); assert.equal(out[0].fragmentCount, 2, 'window 조각 수 = 2 (split-parent 자신 제외)'); }); test('collapseWindows: bodyText — 정상 leaf 는 자기 본문, split-parent 는 window 본문만 이어붙임', () => { // 정상 leaf → 자기 text 가 본문 const leaf = collapseWindows([sec({ heading_path: 'Intro', node_type: null, text: '서론 본문' })]); assert.equal(leaf[0].bodyText, '서론 본문'); // split-parent(heading 줄뿐) + window 2개 → window 본문만 순서대로 합침(헤딩 제외) const split = collapseWindows([ sec({ heading_path: 'Article 5', node_type: 'chapter_split', is_leaf: false, char_start: 120, text: '# Article 5' }), sec({ heading_path: 'Article 5', node_type: 'window', is_leaf: true, text: '본문 조각1' }), sec({ heading_path: 'Article 5', node_type: 'window', is_leaf: true, text: '본문 조각2' }), ]); assert.equal(split.length, 1); assert.equal(split[0].bodyText, '본문 조각1\n\n본문 조각2', 'split-parent heading 제외, window 본문만 합침'); // legacy window 런(선행 split-parent 없음) → 첫 window 자기 본문 + 흡수 조각 const legacy = collapseWindows([ sec({ heading_path: 'Pearson', node_type: 'window', text: 'p1' }), sec({ heading_path: 'Pearson', node_type: 'window', text: 'p2' }), ]); assert.equal(legacy.length, 1); assert.equal(legacy[0].bodyText, 'p1\n\np2'); }); test('collapseWindows: 절-레벨 분석 집계 — windowed 절은 window 멤버에서 type 다수결/conf 평균/summaries 합본', () => { // split-parent(분석 없음) + window 3개(요약·유형·신뢰도 보유) → 대표에 집계 const out = collapseWindows([ sec({ heading_path: 'Sec A', node_type: 'section_split', is_leaf: false, char_start: 10, text: '# Sec A', section_type: null, summary: null, confidence: null }), sec({ heading_path: 'Sec A', node_type: 'window', text: 'b1', section_type: 'requirement', summary: '요약1', confidence: 0.9 }), sec({ heading_path: 'Sec A', node_type: 'window', text: 'b2', section_type: 'requirement', summary: '요약2', confidence: 0.8 }), sec({ heading_path: 'Sec A', node_type: 'window', text: 'b3', section_type: 'overview', summary: '', confidence: 1.0 }), ]); assert.equal(out.length, 1); assert.equal(out[0].sectionType, 'requirement', '다수결 = requirement(2) > overview(1)'); assert.ok(Math.abs(out[0].confidence! - 0.9) < 1e-9, '평균 (0.9+0.8+1.0)/3 = 0.9'); assert.deepEqual(out[0].summaries, ['요약1', '요약2'], '빈 요약 제외, 순서 유지'); // 단일 leaf 는 대표 자신의 분석 const single = collapseWindows([sec({ heading_path: 'X', node_type: null, text: 'body', section_type: 'definition', summary: '정의 요약', confidence: 0.7 })]); assert.equal(single[0].sectionType, 'definition'); assert.equal(single[0].confidence, 0.7); assert.deepEqual(single[0].summaries, ['정의 요약']); // 분석 전혀 없는 절 → null/빈 const none = collapseWindows([sec({ heading_path: 'Y', node_type: null, text: 'body' })]); assert.equal(none[0].sectionType, null); assert.equal(none[0].confidence, null); assert.deepEqual(none[0].summaries, []); }); test('collapseWindows: 비인접 window 도 parent_id 로 split-parent 에 흡수 (빈 split 행 방지)', () => { // 실데이터 버그: split-parent(chunk_index 1143)와 그 window(1233~)가 비인접 → 인접 흡수 실패로 // 빈 split 행 + 별도 window-그룹 행 2개로 쪼개짐. parent_id 링크로 정확히 합친다. const out = collapseWindows([ sec({ chunk_id: 10, heading_path: 'FOREWORD', node_type: 'section_split', is_leaf: false, char_start: 5, text: '# FOREWORD' }), sec({ chunk_id: 11, heading_path: 'POLICY', node_type: null, text: '정책 본문' }), // 사이에 낀 다른 절 sec({ chunk_id: 12, heading_path: 'FOREWORD', node_type: 'window', parent_id: 10, text: '서문 조각1', section_type: 'overview', summary: '요약A', confidence: 0.9 }), sec({ chunk_id: 13, heading_path: 'FOREWORD', node_type: 'window', parent_id: 10, text: '서문 조각2', section_type: 'overview', summary: '요약B', confidence: 0.8 }), ]); assert.equal(out.length, 2, 'FOREWORD(split, window 흡수) + POLICY = 2행 (빈 split 행 없음)'); assert.equal(out[0].section.chunk_id, 10, '대표 = split-parent(char_start 보유)'); assert.equal(out[0].bodyText, '서문 조각1\n\n서문 조각2', '비인접 window 본문을 split-parent 에 흡수'); assert.equal(out[0].fragmentCount, 2); assert.equal(out[0].sectionType, 'overview'); assert.deepEqual(out[0].summaries, ['요약A', '요약B']); assert.equal(out[1].section.chunk_id, 11, '사이 낀 절은 별도 행 유지'); assert.equal(out[1].bodyText, '정책 본문'); }); test('groupOrFlat: 적은 그룹 + 낮은 기타% → group (5140-류)', () => { // 3 top segment × 4 = 12절, window 없음 → group_count 3, 기타 0% const sections: DocumentSection[] = []; for (const top of ['장1', '장2', '장3']) { for (let i = 0; i < 4; i++) sections.push(sec({ heading_path: `${top} > 절${i}` })); } const layout = groupOrFlat(sections); assert.equal(layout.mode, 'group'); assert.equal(layout.groups.length, 3); assert.deepEqual(layout.groups.map((g) => g.key), ['장1', '장2', '장3']); // 등장순서 assert.equal(layout.groups[0].items.length, 4); }); test('groupOrFlat: 기타% ≥ 50 → flat 강등 (5186/5225-류)', () => { const sections: DocumentSection[] = [ sec({ heading_path: 'A > a1' }), sec({ heading_path: 'B > b1' }), sec({ node_type: 'window', heading_path: 'W1' }), sec({ node_type: 'window', heading_path: 'W2' }), sec({ node_type: 'section_split', heading_path: 'S1' }), sec({ node_type: 'window', heading_path: 'W3' }), // 기타 4/6 = 66.7% ]; const layout = groupOrFlat(sections); assert.equal(layout.mode, 'flat'); assert.ok(layout.items.length > 0); }); test('groupOrFlat: group_count > 30 → flat 강등', () => { const sections: DocumentSection[] = []; for (let i = 0; i < 31; i++) sections.push(sec({ heading_path: `seg${i} > x` })); const layout = groupOrFlat(sections); assert.equal(layout.mode, 'flat'); }); test('groupOrFlat: 빈 입력 → flat, 항목 0', () => { const layout = groupOrFlat([]); assert.equal(layout.mode, 'flat'); assert.equal(layout.items.length, 0); }); // ── D9: cleanHeading ASME 개정바 ðNÞ strip ── test('cleanHeading: ASME 개정바 ðNÞ 통째 제거 (가운데 25 안 남김)', () => { assert.equal( cleanHeading('ð**25**Þ **PG-5.4 Size Limits**'), 'PG-5.4 Size Limits', ); // 개정바 없는 일반 제목은 그대로 (회귀) assert.equal(cleanHeading('#### **PG-2 SERVICE LIMITATIONS**'.replace(/^#+\s*/, '')), 'PG-2 SERVICE LIMITATIONS'); }); // ── D7: buildPartOutline — front-matter 분리 + PART 그룹 ── test('buildPartOutline: front-matter 분리 + PART 그룹', () => { const sections = [ sec({ heading_path: 'TABLE OF CONTENTS', section_title: 'TABLE OF CONTENTS' }), sec({ heading_path: 'Honors and Awards Committee', section_title: 'Honors and Awards Committee' }), sec({ heading_path: 'PART PG GENERAL > PG-1 SCOPE', section_title: 'PG-1 SCOPE' }), sec({ heading_path: 'PART PG GENERAL > PG-2 SERVICE', section_title: 'PG-2 SERVICE' }), sec({ heading_path: 'PART PW > PW-1 SCOPE', section_title: 'PW-1 SCOPE' }), ]; const o = buildPartOutline(sections); assert.equal(o.hasParts, true); assert.equal(o.frontMatter.length, 2); // TOC + Committee assert.equal(o.groups.length, 2); // PART PG, PART PW assert.equal(o.groups[0].key, 'PART PG GENERAL'); assert.equal(o.groups[0].items.length, 2); // PG-1, PG-2 assert.equal(o.groups[1].key, 'PART PW'); assert.equal(o.groups[1].items.length, 1); }); test('buildPartOutline: split-parent + window 가 같은 PART 그룹에서 1항목으로 흡수', () => { const sections = [ sec({ heading_path: 'PART PG GENERAL > PG-27 CYL', section_title: 'PG-27 CYL', node_type: 'section_split', chunk_id: 100, text: 'PG-27 CYL' }), sec({ heading_path: 'PART PG GENERAL > PG-27 CYL', section_title: 'PG-27 CYL', node_type: 'window', parent_id: 100, text: 'body part 1' }), sec({ heading_path: 'PART PG GENERAL > PG-27 CYL', section_title: 'PG-27 CYL', node_type: 'window', parent_id: 100, text: 'body part 2' }), ]; const o = buildPartOutline(sections); assert.equal(o.hasParts, true); assert.equal(o.groups.length, 1); assert.equal(o.groups[0].items.length, 1); // split-parent + 2 window → 1 항목 assert.equal(o.groups[0].items[0].fragmentCount, 2); }); test('buildPartOutline: content part 없으면 hasParts=false (폴백 신호)', () => { const o = buildPartOutline([sec({ heading_path: 'Intro', section_title: 'Intro' })]); assert.equal(o.hasParts, false); assert.equal(o.groups.length, 0); }); test('buildPartOutline: PART/SUBSECTION 마커 없으면(항목코드만) hasParts=false → 폴백', () => { // 실 ASME 코드(5180/5210)는 PART/SUBSECTION 마커를 갖는다. PART 가 0 인 문서(항목코드만)는 // 접을 PART 가 없으므로 hasParts=false → 호출자가 groupOrFlat/flat 으로 폴백. const o = buildPartOutline([ sec({ heading_path: 'FOREWORD', section_title: 'FOREWORD' }), sec({ heading_path: null, section_title: 'U-1 적용범위' }), ]); assert.equal(o.hasParts, false); assert.equal(o.groups.length, 0); }); test('buildPartOutline: (NON)MANDATORY APPENDIX 도 최상위 섹션 경계 — 마지막 PART 흡수 방지', () => { // 5180 실측: 부록을 마커로 안 잡으면 마지막 PART(PHRSG)가 부록 289항목을 carry-forward 흡수(=300). const o = buildPartOutline([ sec({ heading_path: 'PART PHRSG REQUIREMENTS > PHRSG-1', section_title: 'PHRSG-1' }), sec({ heading_path: 'PHRSG-2 SCOPE', section_title: 'PHRSG-2' }), // PHRSG 로 carry sec({ heading_path: 'MANDATORY APPENDIX IV LOCAL THIN AREAS', section_title: '...' }), sec({ heading_path: 'IV-1 GENERAL', section_title: 'IV-1' }), // APPENDIX IV 로 carry sec({ heading_path: 'NONMANDATORY APPENDIX A EXPLANATION', section_title: '...' }), ]); assert.deepEqual(o.groups.map((g) => [g.key.slice(0, 24), g.items.length]), [ ['PART PHRSG REQUIREMENTS', 2], // PHRSG-1 + PHRSG-2(carry), 부록 안 섞임 ['MANDATORY APPENDIX IV LO', 2], // 부록 헤딩 + IV-1(carry) ['NONMANDATORY APPENDIX A ', 1], ]); }); test('buildPartOutline: 본문 cross-ref/문장 false PART 차단 (5210 stale 패턴)', () => { // 혼합대소문자 'Part D…' · 코드 뒤 비대문자(한글) 문장 'PART UW 규정은…' · 비대문자 코드 'PART 층이…' // = 전부 본문이라 PART 아님. 깨끗한 PART 0 → hasParts=false → flat 폴백(가짜 그룹 0). const o = buildPartOutline([ sec({ heading_path: 'Part D, Subpart 3의 해당 재료', section_title: 'Part D…' }), sec({ heading_path: 'PART UW 규정은 용접에 의해 제작되는', section_title: 'PART UW 규정은…' }), sec({ heading_path: 'PART 층이 진 구조로 조립되는', section_title: 'PART 층이…' }), ]); assert.equal(o.hasParts, false); }); test('buildPartOutline: SUBSECTION 마커도 PART 경계로 인식(Sec VIII)', () => { const o = buildPartOutline([ sec({ heading_path: 'TOC', section_title: 'TOC' }), sec({ heading_path: 'SUBSECTION A GENERAL > UG-1', section_title: 'UG-1' }), sec({ heading_path: 'SUBSECTION B > UW-1', section_title: 'UW-1' }), ]); assert.equal(o.hasParts, true); assert.equal(o.frontMatter.length, 1); assert.deepEqual(o.groups.map((g) => g.key), ['SUBSECTION A GENERAL', 'SUBSECTION B']); }); // ── D8: partitionOutlineItems — 이미 collapse 된 OutlineItem 재배치(인스턴스 보존) ── test('partitionOutlineItems: flat outline 의 인스턴스를 그대로 재배치(재-collapse 없음)', () => { const sections = [ sec({ heading_path: 'TABLE OF CONTENTS', section_title: 'TABLE OF CONTENTS' }), sec({ heading_path: 'PART PG GENERAL > PG-1 SCOPE', section_title: 'PG-1 SCOPE' }), sec({ heading_path: 'PART PG GENERAL > PG-2 SERVICE', section_title: 'PG-2 SERVICE' }), sec({ heading_path: 'PART PW > PW-1 SCOPE', section_title: 'PW-1 SCOPE' }), ]; const flat = collapseWindows(sections); // 컴포넌트의 outline 과 동일 경로 const o = partitionOutlineItems(flat); assert.equal(o.hasParts, true); assert.equal(o.frontMatter.length, 1); assert.equal(o.groups.length, 2); // ★ 인스턴스 동일성: 재배치된 item 이 flat outline 의 바로 그 객체여야 selectedSectionId 정합. assert.ok(o.frontMatter[0] === flat[0], 'front-matter item = flat[0] 인스턴스'); assert.ok(o.groups[0].items[0] === flat[1], 'PART PG 첫 item = flat[1] 인스턴스'); assert.ok(o.groups[1].items[0] === flat[3], 'PART PW item = flat[3] 인스턴스'); // chunk_id 집합이 flat 과 정확히 일치(클릭→selectedSectionId 조회 실패 없음). const flatIds = flat.map((it) => it.section.chunk_id).sort(); const partIds = [...o.frontMatter, ...o.groups.flatMap((g) => g.items)] .map((it) => it.section.chunk_id).sort(); assert.deepEqual(partIds, flatIds); }); test('partitionOutlineItems: 비-PART top-segment 항목은 직전 PART 로 carry-forward (marker 트리 불규칙 흡수)', () => { // ★ 5180 실측 패턴: PART 아래 직접 중첩 안 된 항목('PG-28'·'GENERAL')의 top-segment 가 PART 가 // 아니다 → 단순 segs[0] 그룹핑이면 가짜 그룹 폭발. carry-forward 가 직전 PART 로 흡수해야 한다. const items = collapseWindows([ sec({ heading_path: 'TOC', section_title: 'TOC' }), sec({ heading_path: 'PART PG GENERAL > PG-1', section_title: 'PG-1' }), sec({ heading_path: 'PG-28 EXTERNAL PRESSURE', section_title: 'PG-28' }), // top-seg ≠ PART → carry sec({ heading_path: 'OPENINGS AND COMPENSATION', section_title: 'OPENINGS' }), // carry sec({ heading_path: 'PART PW > PW-1', section_title: 'PW-1' }), sec({ heading_path: 'GENERAL', section_title: 'GENERAL' }), // PART PW 로 carry ]); const o = partitionOutlineItems(items); assert.equal(o.hasParts, true); assert.equal(o.frontMatter.length, 1); assert.equal(o.groups.length, 2, 'PART PG / PART PW 단 2그룹(가짜 그룹 0)'); assert.equal(o.groups[0].key, 'PART PG GENERAL'); assert.equal(o.groups[0].items.length, 3, 'PG-1 + PG-28 + OPENINGS carry'); assert.equal(o.groups[1].key, 'PART PW'); assert.equal(o.groups[1].items.length, 2, 'PW-1 + GENERAL carry'); // carry 된 항목도 인스턴스 보존(클릭 정합) assert.ok(o.groups[0].items[1].section.section_title === 'PG-28'); }); test('partitionOutlineItems: buildPartOutline 과 그룹 구조 동치(collapse→partition == partition∘collapse)', () => { const sections = [ sec({ heading_path: 'PART PG > PG-27 CYL', section_title: 'PG-27 CYL', node_type: 'section_split', chunk_id: 100, text: 'PG-27 CYL' }), sec({ heading_path: 'PART PG > PG-27 CYL', section_title: 'PG-27 CYL', node_type: 'window', parent_id: 100, text: 'b1' }), sec({ heading_path: 'PART PG > PG-27 CYL', section_title: 'PG-27 CYL', node_type: 'window', parent_id: 100, text: 'b2' }), sec({ heading_path: 'PART PW > PW-1', section_title: 'PW-1' }), ]; const viaBuild = buildPartOutline(sections); const viaPartition = partitionOutlineItems(collapseWindows(sections)); assert.equal(viaBuild.hasParts, viaPartition.hasParts); assert.deepEqual(viaBuild.groups.map((g) => [g.key, g.items.length]), viaPartition.groups.map((g) => [g.key, g.items.length])); // window 흡수 후 PART PG 는 1 항목(fragmentCount 2). assert.equal(viaPartition.groups[0].items.length, 1); assert.equal(viaPartition.groups[0].items[0].fragmentCount, 2); }); // ── D8: partGroupViews / groupKeyByChunkId — 렌더 그룹 평탄화 + auto-expand 역인덱스 ── test('partGroupViews: front-matter 를 첫 그룹(sentinel key)으로, 이어 PART 그룹', () => { const sections = [ sec({ heading_path: 'TOC', section_title: 'TOC' }), sec({ heading_path: 'PART PG > PG-1', section_title: 'PG-1' }), sec({ heading_path: 'PART PW > PW-1', section_title: 'PW-1' }), ]; const views = partGroupViews(buildPartOutline(sections)); assert.equal(views.length, 3); assert.equal(views[0].key, FRONT_MATTER_KEY); assert.equal(views[0].label, FRONT_MATTER_LABEL); assert.equal(views[0].isFrontMatter, true); assert.equal(views[1].key, 'PART PG'); assert.equal(views[1].label, 'PART PG'); assert.equal(views[1].isFrontMatter, false); assert.equal(views[2].key, 'PART PW'); // 모든 key 유일(Svelte each key 안전) const keys = views.map((v) => v.key); assert.equal(new Set(keys).size, keys.length); }); test('partGroupViews: front-matter 없으면 PART 그룹만(첫 그룹 sentinel 없음)', () => { const sections = [ sec({ heading_path: 'PART PG > PG-1', section_title: 'PG-1' }), sec({ heading_path: 'PART PW > PW-1', section_title: 'PW-1' }), ]; const views = partGroupViews(buildPartOutline(sections)); assert.equal(views.length, 2); assert.ok(views.every((v) => !v.isFrontMatter)); assert.equal(views[0].key, 'PART PG'); }); test('groupKeyByChunkId: 대표 chunk_id → 소속 group key (auto-expand 역인덱스)', () => { const sections = [ sec({ chunk_id: 1, heading_path: 'TOC', section_title: 'TOC' }), sec({ chunk_id: 2, heading_path: 'PART PG > PG-1', section_title: 'PG-1' }), sec({ chunk_id: 3, heading_path: 'PART PG > PG-2', section_title: 'PG-2' }), sec({ chunk_id: 4, heading_path: 'PART PW > PW-1', section_title: 'PW-1' }), ]; const views = partGroupViews(buildPartOutline(sections)); const idx = groupKeyByChunkId(views); assert.equal(idx.get(1), FRONT_MATTER_KEY); assert.equal(idx.get(2), 'PART PG'); assert.equal(idx.get(3), 'PART PG'); assert.equal(idx.get(4), 'PART PW'); assert.equal(idx.get(999), undefined); });