Files
hyungi_document_server/frontend/src/lib/utils/headingPath.test.ts
T
hyungi c5bc1f773d fix(docpage): 비인접 window 를 parent_id 로 split-parent 에 흡수 (빈 본문 절 수정)
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>
2026-06-14 07:46:18 +09:00

193 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 순수함수 회귀 테스트. 실행(로컬, 의존성 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);
});