c5bc1f773d
split-parent(절 헤딩)와 그 window 조각이 chunk_index 상 비인접인 경우(예: 5180 FOREWORD 헤딩 idx 1143, window idx 1233~)가 있어, 인접 흡수만 하던 collapseWindows 가 split-parent 를 빈 본문 행으로 남기고 window 들은 따로 대표 행을 만들어 "같은 제목 2행(빈 것 + 본문 있는 것)" 이 됐다. 사용자가 "본문 없는 절" 로 본 것. - /sections API 에 parent_id 반환 (window.parent_id = 그 split-parent chunk_id, 100% _split 링크) - collapseWindows 가 window 를 parent_id 로 split-parent 대표에 흡수(비인접 허용), 인접 heading fallback 유지(legacy window). 흡수 멤버에서 본문/분석 집계. - 회귀 테스트: 비인접 parent_id 흡수 (12/12 pass) 실데이터 검증(빈 본문→0): 5180 outline 85→58·5210 318→277·5178 73→49·5151 45→40, 전부 EMPTY_BODY=0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
193 lines
9.4 KiB
TypeScript
193 lines
9.4 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,
|
||
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);
|
||
});
|