diff --git a/frontend/src/lib/utils/headingPath.test.ts b/frontend/src/lib/utils/headingPath.test.ts index adb5613..af0e57b 100644 --- a/frontend/src/lib/utils/headingPath.test.ts +++ b/frontend/src/lib/utils/headingPath.test.ts @@ -7,6 +7,7 @@ import { pathSegments, collapseWindows, groupOrFlat, + buildPartOutline, sectionTypeLabel, type DocumentSection, } from './headingPath.ts'; @@ -190,3 +191,61 @@ test('groupOrFlat: 빈 입력 → flat, 항목 0', () => { 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: 항목코드 제목(path 없음)도 본문 시작 인식', () => { + const sections = [ + sec({ heading_path: 'FOREWORD', section_title: 'FOREWORD' }), + sec({ heading_path: null, section_title: 'U-1 적용범위' }), + ]; + const o = buildPartOutline(sections); + assert.equal(o.hasParts, true); + assert.equal(o.frontMatter.length, 1); // FOREWORD +}); diff --git a/frontend/src/lib/utils/headingPath.ts b/frontend/src/lib/utils/headingPath.ts index 8d22215..f438481 100644 --- a/frontend/src/lib/utils/headingPath.ts +++ b/frontend/src/lib/utils/headingPath.ts @@ -84,6 +84,9 @@ export function sectionTypeLabel(t: string | null | undefined): string | null { export function cleanHeading(raw: string | null | undefined): string { if (!raw) return ''; return raw + // D9(read-time): ASME 개정바 ðNÞ(`ð**25**Þ`) 통째 제거 — 개별 sup strip 전에. + // (일반 sup strip 이 먼저면 가운데 '25'(개정 연도)만 남아 'ð25Þ PG-5.4' → '25 PG-5.4' 오염) + .replace(/\s*ð\s*<\/sup>.*?\s*Þ\s*<\/sup>/gi, '') .replace(/.*?<\/sup>/gi, '') // 각주 위첨자 .replace(/.*?<\/sub>/gi, '') .replace(/<[^>]+>/g, '') // 잔여 HTML 태그 @@ -231,3 +234,63 @@ export function groupOrFlat(sections: DocumentSection[]): OutlineLayout { })); return { mode: 'group', items: [], groups }; } + +// ── D7/D8 (asme-item-decomp read-time): front-matter 억제 + Part 계층 그룹 ── +// 긴 구조화 코드(ASME)의 절뷰가 flat 1030 으로 길어지는 문제(front-matter 240 + 다중 PART)를 +// 표현 계층에서 해결. 빌더/재분해 무접촉 — sections 엔드포인트가 주는 heading_path 만으로 산출. + +/** 본문 시작(content part) top-segment 패턴: 'PART PG …' / 'SUBSECTION A …'. */ +const CONTENT_PART_RE = /^(PART|SUBSECTION)\s+\S/i; +/** 본문 항목 코드(예: PG-1, UG-27, U-1) — content part 가 없는 문서의 폴백 경계. */ +const ITEM_CODE_RE = /^[A-Z]{1,4}-\d/; + +function isContentStart(s: DocumentSection): boolean { + const segs = pathSegments(s.heading_path); + if (segs.length && CONTENT_PART_RE.test(segs[0])) return true; + // path 없이 항목 코드가 제목인 경우(드뭄)도 본문 시작으로 + const t = cleanHeading(s.section_title); + return ITEM_CODE_RE.test(t); +} + +export interface PartOutline { + /** PART PG / PART PW … 전(前) front-matter(TOC·위원회·인명) — 단일 접이 그룹용. */ + frontMatter: OutlineItem[]; + /** 본문 Part 그룹들(heading_path 첫 세그먼트 = PART 기준). 기본 접힘은 렌더(D8)에서. */ + groups: OutlineGroup[]; + /** content part 경계를 못 찾으면 false → 기존 groupOrFlat 폴백 권장. */ + hasParts: boolean; +} + +/** + * front-matter 경계(첫 content part) 분리 + 본문을 PART(heading_path 첫 세그먼트)로 그룹. + * 정렬(chunk_index) 유지. window/_split 도 heading_path 가 PART 를 가리키므로 같은 PART 그룹에 + * 들어가 collapseWindows 가 split-parent 에 정상 흡수(topSegment 의 window→OTHER 라우팅 미사용). + */ +export function buildPartOutline(sections: DocumentSection[]): PartOutline { + let boundary = -1; + for (let i = 0; i < sections.length; i++) { + if (isContentStart(sections[i])) { boundary = i; break; } + } + if (boundary < 0) { + return { frontMatter: [], groups: [], hasParts: false }; + } + const front = sections.slice(0, boundary); + const body = sections.slice(boundary); + const frontMatter = front.length ? collapseWindows(front) : []; + + const order: string[] = []; + const map = new Map(); + for (const s of body) { + // PART 그룹 키 = heading_path 첫 세그먼트(있으면). window/_split 포함 — 같은 PART 로 모음. + const segs = pathSegments(s.heading_path); + const key = segs.length ? segs[0] : OTHER; + if (!map.has(key)) { map.set(key, []); order.push(key); } + map.get(key)!.push(s); + } + const groups: OutlineGroup[] = order.map((key) => ({ + key: key === OTHER ? '기타' : key, + isOther: key === OTHER, + items: collapseWindows(map.get(key)!), + })); + return { frontMatter, groups, hasParts: true }; +}