a850745f85
flat 1030 절뷰를 read-time 표현계층에서 front-matter 단일 접이그룹 + PART/APPENDIX 접이그룹 (기본 전부 접힘)으로. 빌더/재분해 무접촉, 검색 무관(in_corpus=false 불변). - partitionOutlineItems: 순서기반 carry-forward 그룹핑(비-PART top-segment 항목은 직전 PART 흡수). buildPartOutline = partitionOutlineItems∘collapseWindows 로 통일. PART_MARKER_RE = case-sensitive PART/SUBSECTION/APPENDIX(+대문자제목 가드) — 본문 cross-ref/문장 false match 차단 (5210 'Part D…'·'PART UW 규정은…' 거부). 한글제목 PART 미인식은 D3 재정련(주석 박제). - partGroupViews/groupKeyByChunkId: front-matter 첫 그룹 평탄화 + auto-expand 역인덱스. - SectionOutline.svelte: Part 접이 모드 + groupOrFlat 폴백 + activeKey auto-expand. - [id]/+page.svelte: treeNav 그룹 접이(treeNode 스니펫·d3 시안 보존) + 기본선택=첫 본문 Part + selectedSectionId auto-expand. 데스크탑/모바일 treeNav 공유. - 리뷰 반영: rail max-height calc() 공백 fix / treeNode a11y role 조건부 / 문서 전환 접이상태 리셋 / 모바일 본문 스코프 주석. real-data 검증(prod read-only): 5180 → front-matter231 + 15 PART + 6 APPENDIX = 22 접이그룹· 커버리지 1030/1030·PG-27 정상. 5210(D3 재분해 전 stale) → 깨끗 PART 0 → hasParts=false → flat 폴백(무회귀). 단위 26/26, vite build PASS. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
407 lines
21 KiB
TypeScript
407 lines
21 KiB
TypeScript
// 순수함수 회귀 테스트. 실행(로컬, 의존성 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>): 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**<sup>2</sup>'), '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**<sup>1</sup> > 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('<sup>ð</sup>**25**<sup>Þ</sup> **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);
|
||
});
|