返回 oh-my-ppt
document-outline-scan.test.ts
根目录 / tests / unit / io / document-outline-scan.test.ts
1 import { describe, expect, it } from 'vitest'
2 import {
3 deriveOutlinePageCandidates,
4 estimateOutlinePageCount,
5 formatDocumentOutlineScanForPrompt,
6 scanDocumentOutline,
7 scanHasMultipleSlideCandidates
8 } from '../../../src/main/io/document-outline-scan'
9
10 describe('document outline scan', () => {
11 it('extracts markdown heading structure and density hints', () => {
12 const scan = scanDocumentOutline(
13 [
14 '# AI Animation Report',
15 '',
16 'Intro text.',
17 '',
18 '## Market Size',
19 '- Global growth reached 15%',
20 '- China market expanded in 2026',
21 '',
22 '### Global Growth',
23 'Revenue grew 15% YoY.',
24 '',
25 '## Production Workflow',
26 '| Stage | Tool |',
27 '| --- | --- |',
28 '| Script | LLM |'
29 ].join('\n')
30 )
31
32 expect(scan.headingCount).toBe(4)
33 expect(scan.topLevelTitle).toBe('AI Animation Report')
34 expect(scan.sectionTree[0].children.map((node) => node.title)).toEqual([
35 'Market Size',
36 'Production Workflow'
37 ])
38 expect(scan.sectionTree[0].children[0].hasMetrics).toBe(true)
39 expect(scan.sectionTree[0].children[1].tableCount).toBeGreaterThan(0)
40 expect(scan.recommendedSplitHints.join('\n')).toContain(
41 'Substantial level-3+ sections can be standalone slides'
42 )
43 expect(scan.recommendedSplitHints.join('\n')).toContain('### Global Growth')
44 expect(scanHasMultipleSlideCandidates(scan)).toBe(true)
45 })
46
47 it('derives an authoritative page candidate skeleton in source order', () => {
48 const scan = scanDocumentOutline(
49 [
50 '# Growth Manual',
51 '',
52 '# 第一篇:认知篇',
53 '## 1.1 行业现状',
54 'Details.',
55 '### 核心变化',
56 '- 消费链路变化',
57 '',
58 '# 第二篇:实操篇',
59 '## 2.1 账号定位',
60 'Details.'
61 ].join('\n')
62 )
63 const candidates = deriveOutlinePageCandidates(scan)
64 const promptText = formatDocumentOutlineScanForPrompt(scan)
65
66 expect(candidates.map((candidate) => candidate.sourceHeading)).toEqual([
67 '# 第一篇:认知篇',
68 '### 核心变化',
69 '# 第二篇:实操篇',
70 '## 2.1 账号定位'
71 ])
72 expect(candidates.map((candidate) => candidate.role)).toEqual([
73 'chapter-divider',
74 'content',
75 'chapter-divider',
76 'content'
77 ])
78 expect(promptText).toContain('Page candidate skeleton (4 slides)')
79 expect(promptText).toContain('[chapter-divider] # 第一篇:认知篇')
80 expect(promptText).toContain('[content] ### 核心变化')
81 expect(promptText).toContain('authoritative first-pass outline')
82 })
83
84 it('uses level-2 sections as pages without adding a synthetic contents page', () => {
85 const scan = scanDocumentOutline(
86 ['# ss', '', '## dd', 'Details.', '', '## Ff', 'More.'].join('\n')
87 )
88 const candidates = deriveOutlinePageCandidates(scan)
89
90 expect(candidates.map((candidate) => candidate.title)).toEqual(['dd', 'Ff'])
91 expect(candidates[0]).toMatchObject({
92 role: 'content',
93 sourceHeading: '## dd',
94 headingLevel: 2,
95 lineStart: 3,
96 lineEnd: 5
97 })
98 expect(estimateOutlinePageCount(scan)?.preferredPageCount).toBe(2)
99 })
100
101 it('keeps a level-2 section as one page when it has only one direct level-3 child', () => {
102 const scan = scanDocumentOutline(
103 [
104 '# Quarterly Report',
105 '',
106 '## Market Review',
107 'Intro.',
108 '',
109 '### Channel Metrics',
110 '- GMV grew 15%',
111 '- Conversion improved 8%',
112 '',
113 '## Next Steps',
114 'Follow-up actions.'
115 ].join('\n')
116 )
117 const candidates = deriveOutlinePageCandidates(scan)
118
119 expect(candidates.map((candidate) => candidate.sourceHeading)).toEqual([
120 '## Market Review',
121 '## Next Steps'
122 ])
123 expect(candidates[0]).toMatchObject({
124 headingLevel: 2,
125 lineStart: 3,
126 lineEnd: 9,
127 reason: 'top-level ## section in a structured document outline'
128 })
129 expect(candidates[0].reason).not.toContain('Section agenda page')
130 expect(estimateOutlinePageCount(scan)?.preferredPageCount).toBe(2)
131 })
132
133 it('marks truncated section agenda child lists', () => {
134 const source = [
135 '# Large Agenda',
136 '',
137 '## Detailed Chapter',
138 ...Array.from({ length: 13 }, (_, index) => [
139 `### Topic ${index + 1}`,
140 `Details ${index + 1}.`
141 ]).flat()
142 ].join('\n')
143 const candidates = deriveOutlinePageCandidates(scanDocumentOutline(source))
144
145 expect(candidates[0].reason).toContain('Topic 12')
146 expect(candidates[0].reason).not.toContain('Topic 13')
147 expect(candidates[0].reason).toContain('13 child topics in total')
148 })
149
150 it('uses level-2 chapters with direct level-3 children as section agenda pages', () => {
151 const scan = scanDocumentOutline(
152 [
153 '# 2026 AI动漫的发展与未来:数据驱动下的产业变革',
154 '',
155 '## 一、2026年全球AI动漫产业关键数据(预测/推演)',
156 '| 指标 | 2023年实际 | 2026年(预估) |',
157 '| --- | --- | --- |',
158 '| 全球动漫市场规模 | 342亿美元 | 528亿美元 |',
159 '',
160 '## 二、技术参数与技术效率明细',
161 '### 2.1 主流AI动漫工具性能对比(2026版)',
162 '| 工具名称 | 主要用途 |',
163 '| --- | --- |',
164 '| AniDiffu X4 | 中割生成 |',
165 '### 2.2 训练数据规模(头部动漫AI模型)',
166 '- 训练使用的动漫帧数:约 1.2亿张',
167 '### 2.3 效率实证:传统流程 vs AI辅助流程',
168 '| 工序 | 传统人力工时 | AI辅助工时 |',
169 '| --- | --- | --- |',
170 '| 脚本/大纲 | 40小时 | 12小时 |',
171 '',
172 '## 三、市场与观众数据洞察',
173 '### 3.1 观众对AI动漫的认知与接受度调查',
174 '| 问题 | 是(%) | 否(%) |',
175 '| --- | --- | --- |',
176 '| 能接受AI辅助中割/上色的动漫 | 78.4 | 9.2 |',
177 '### 3.2 B站/AI类动漫标签表现',
178 '| 标签 | 作品数 | 总播放量 |',
179 '| --- | --- | --- |',
180 '| AI辅助 | 340部 | 28.7亿 |',
181 '',
182 '## 四、行业就业与经济结构数据',
183 '### 4.1 日本动画师岗位变化',
184 '| 岗位类型 | 2022年人数 | 2026年人数 |',
185 '| --- | --- | --- |',
186 '| 原画师 | 4,200 | 4,950 |',
187 '### 4.2 薪资对比',
188 '| 国家 | 传统动画师 | AI辅助动画师 |',
189 '| --- | --- | --- |',
190 '| 日本 | 3.2 | 4.8 |',
191 '',
192 '## 五、版权争议与法律数据(2024–2026上半年)',
193 '| 争议类型 | 案件数量 |',
194 '| --- | --- |',
195 '| 使用未授权动画帧训练AI | 日本37件 |',
196 '',
197 '## 六、未来量化预测(2027–2029)',
198 '| 年份 | 预测事件 |',
199 '| --- | --- |',
200 '| 2027 | 实时AI转绘VR动画设备普及 |',
201 '',
202 '## 七、结论与关键洞察',
203 '- 效率与成本是最大驱动力',
204 '- 观众并非一概拒绝AI'
205 ].join('\n')
206 )
207 const candidates = deriveOutlinePageCandidates(scan)
208 const estimate = estimateOutlinePageCount(scan)
209
210 expect(candidates.map((candidate) => candidate.sourceHeading)).toEqual([
211 '## 一、2026年全球AI动漫产业关键数据(预测/推演)',
212 '## 二、技术参数与技术效率明细',
213 '### 2.1 主流AI动漫工具性能对比(2026版)',
214 '### 2.2 训练数据规模(头部动漫AI模型)',
215 '### 2.3 效率实证:传统流程 vs AI辅助流程',
216 '## 三、市场与观众数据洞察',
217 '### 3.1 观众对AI动漫的认知与接受度调查',
218 '### 3.2 B站/AI类动漫标签表现',
219 '## 四、行业就业与经济结构数据',
220 '### 4.1 日本动画师岗位变化',
221 '### 4.2 薪资对比',
222 '## 五、版权争议与法律数据(2024–2026上半年)',
223 '## 六、未来量化预测(2027–2029)',
224 '## 七、结论与关键洞察'
225 ])
226 expect(candidates).toHaveLength(14)
227 expect(candidates.map((candidate) => candidate.headingLevel)).toEqual([
228 2, 2, 3, 3, 3, 2, 3, 3, 2, 3, 3, 2, 2, 2
229 ])
230 expect(candidates[0]).toMatchObject({
231 title: '一、2026年全球AI动漫产业关键数据(预测/推演)',
232 lineStart: 3,
233 lineEnd: 7
234 })
235 expect(candidates[1]).toMatchObject({
236 title: '二、技术参数与技术效率明细',
237 lineStart: 8,
238 lineEnd: 8
239 })
240 expect(candidates[1].reason).toContain('2.1 主流AI动漫工具性能对比')
241 expect(candidates[1].reason).toContain('2.2 训练数据规模')
242 expect(candidates[1].reason).toContain('2.3 效率实证')
243 expect(estimate?.preferredPageCount).toBe(14)
244 expect(estimate?.basis).toContain('7 top-level level-2 document sections')
245 expect(estimate?.basis).toContain('3 section agenda pages')
246 expect(estimate?.basis).toContain('7 direct level-3 content pages')
247 })
248
249 it('uses GFM task lists as standalone slide signals', () => {
250 const scan = scanDocumentOutline(
251 [
252 '# Launch Checklist',
253 '',
254 '## Team Setup',
255 '',
256 '### Day 1 Checklist',
257 '- [x] Register account',
258 '- [ ] Configure profile',
259 '- [ ] Publish first video'
260 ].join('\n')
261 )
262
263 const h3 = scan.sectionTree[0].children[0].children[0]
264 expect(h3.title).toBe('Day 1 Checklist')
265 expect(h3.taskListCount).toBe(3)
266 expect(scan.recommendedSplitHints.join('\n')).toContain('### Day 1 Checklist')
267 })
268
269 it('keeps parent heading ranges across nested child sections', () => {
270 const scan = scanDocumentOutline(
271 [
272 '# Guide',
273 '',
274 '## Part A',
275 'Intro.',
276 '### Step 1',
277 'Details.',
278 '### Step 2',
279 'More details.',
280 '## Part B',
281 'Done.'
282 ].join('\n')
283 )
284
285 const partA = scan.sectionTree[0].children[0]
286 const step1 = partA.children[0]
287 expect(partA.lineStart).toBe(3)
288 expect(partA.lineEnd).toBe(8)
289 expect(step1.lineStart).toBe(5)
290 expect(step1.lineEnd).toBe(6)
291 })
292
293 it('marks truncated heading maps so agents grep the rest', () => {
294 const source = [
295 '# Large Guide',
296 ...Array.from({ length: 85 }, (_, index) => [
297 `## Section ${index + 1}`,
298 `Content ${index + 1}.`
299 ]).flat()
300 ].join('\n')
301 const promptText = formatDocumentOutlineScanForPrompt(scanDocumentOutline(source))
302
303 expect(promptText).toContain('Markdown headings detected: 86')
304 expect(promptText).toContain('Heading map truncated: 6 additional headings')
305 expect(promptText).toContain('single-shot parse prompt')
306 })
307
308 it('keeps substantial level-2 body content before standalone child sections', () => {
309 const overview = '市场概述'.repeat(45)
310 const scan = scanDocumentOutline(
311 [
312 '# Market Manual',
313 '',
314 '## 市场分析',
315 overview,
316 '',
317 '### 增长指标',
318 '- GMV grew 15%',
319 '- Conversion improved 8%',
320 '',
321 '### 渠道策略',
322 '- Short video',
323 '- Live commerce'
324 ].join('\n')
325 )
326 const candidates = deriveOutlinePageCandidates(scan)
327
328 expect(candidates.map((candidate) => candidate.sourceHeading)).toEqual([
329 '## 市场分析',
330 '### 增长指标',
331 '### 渠道策略'
332 ])
333 expect(candidates[0]).toMatchObject({
334 lineStart: 3,
335 lineEnd: 5,
336 reason:
337 '章节目录页:概览本章下的子主题,包括:增长指标、渠道策略。'
338 })
339 })
340
341 it('keeps a single direct level-3 child inside its level-2 page', () => {
342 const deepDetails = 'implementation detail '.repeat(18)
343 const scan = scanDocumentOutline(
344 [
345 '# Engineering Guide',
346 '',
347 '## Deployment',
348 '',
349 '### Runtime',
350 '',
351 '#### Canary Strategy',
352 deepDetails
353 ].join('\n')
354 )
355 const candidates = deriveOutlinePageCandidates(scan)
356
357 expect(candidates.map((candidate) => candidate.sourceHeading)).toEqual(['## Deployment'])
358 expect(candidates[0]).toMatchObject({
359 headingLevel: 2,
360 lineStart: 3,
361 lineEnd: 8
362 })
363 })
364
365 it('keeps large candidate skeleton counts aligned with the prompt-visible target', () => {
366 const source = [
367 '# Large Manual',
368 ...Array.from({ length: 150 }, (_, index) => [
369 `## Section ${index + 1}`,
370 `Operational content ${index + 1}.`
371 ]).flat()
372 ].join('\n')
373 const scan = scanDocumentOutline(source)
374 const estimate = estimateOutlinePageCount(scan)
375 const promptText = formatDocumentOutlineScanForPrompt(scan)
376
377 expect(deriveOutlinePageCandidates(scan)).toHaveLength(150)
378 expect(estimate?.preferredPageCount).toBe(150)
379 expect(promptText).toContain('Page candidate skeleton (150 slides)')
380 expect(promptText).not.toContain('Page candidate skeleton truncated')
381 })
382
383 it('caps extremely large candidate skeletons to the visible parse target', () => {
384 const source = [
385 '# Very Large Manual',
386 ...Array.from({ length: 520 }, (_, index) => [
387 `## Section ${index + 1}`,
388 `Operational content ${index + 1}.`
389 ]).flat()
390 ].join('\n')
391 const scan = scanDocumentOutline(source)
392 const estimate = estimateOutlinePageCount(scan)
393 const promptText = formatDocumentOutlineScanForPrompt(scan)
394
395 expect(deriveOutlinePageCandidates(scan)).toHaveLength(520)
396 expect(estimate?.preferredPageCount).toBe(500)
397 expect(estimate?.basis).toContain('capped to 500 visible page candidates')
398 expect(promptText).toContain('Page candidate skeleton (500 visible of 520 candidates)')
399 expect(promptText).toContain('Return pageCount=500')
400 })
401
402 it('estimates a stable slide count for large multi-section manuals', () => {
403 const source = [
404 '# Dealer Growth Guide',
405 ...Array.from({ length: 40 }, (_, index) => [
406 `## Section ${index + 1}`,
407 `Operational content ${index + 1}.`,
408 `### Checklist ${index + 1}`,
409 '- Step one',
410 '- Step two',
411 '- Step three'
412 ]).flat()
413 ].join('\n')
414 const scan = scanDocumentOutline(source)
415 const estimate = estimateOutlinePageCount(scan)
416 const promptText = formatDocumentOutlineScanForPrompt(scan)
417
418 expect(estimate?.preferredPageCount).toBe(40)
419 expect(estimate?.minPageCount).toBeLessThanOrEqual(40)
420 expect(estimate?.maxPageCount).toBeGreaterThanOrEqual(40)
421 expect(promptText).toContain('Deterministic slide-count estimate: prefer 40 slides')
422 })
423
424 it('counts major level-1 headings as standalone chapter divider slides', () => {
425 const scan = scanDocumentOutline(
426 [
427 '# Growth Manual',
428 '',
429 '# 第一篇:认知篇',
430 '## 1.1 行业现状',
431 'Details.',
432 '## 1.2 用户行为',
433 'Details.',
434 '',
435 '# 第二篇:账号搭建定平台',
436 '## 2.1 矩阵认知',
437 'Details.',
438 '## 2.2 平台差异化',
439 'Details.',
440 '',
441 '# 第三篇:账号定位及内容方向',
442 '## 3.1 个人号',
443 'Details.',
444 '## 3.2 蓝V',
445 'Details.'
446 ].join('\n')
447 )
448 const estimate = estimateOutlinePageCount(scan)
449 const promptText = formatDocumentOutlineScanForPrompt(scan)
450
451 expect(estimate?.preferredPageCount).toBe(9)
452 expect(estimate?.basis).toContain('3 chapter divider headings')
453 expect(promptText).toContain('Chapter divider slides: # 第一篇:认知篇; # 第二篇:账号搭建定平台; # 第三篇:账号定位及内容方向')
454 expect(promptText).toContain('Keep these as standalone section-divider pages')
455 })
456
457 it('ignores headings inside fenced code blocks', () => {
458 const scan = scanDocumentOutline(
459 [
460 '# Real Title',
461 '```md',
462 '# Fake Heading',
463 '## Also Fake',
464 '```',
465 '## Real Section'
466 ].join('\n')
467 )
468
469 expect(scan.headingCount).toBe(2)
470 expect(formatDocumentOutlineScanForPrompt(scan)).toContain('## Real Section')
471 expect(formatDocumentOutlineScanForPrompt(scan)).not.toContain('Fake Heading')
472 })
473
474 it('formats no-heading documents as paragraph/list fallback', () => {
475 const scan = scanDocumentOutline('First paragraph.\n\n- one\n- two', 'text')
476 const promptText = formatDocumentOutlineScanForPrompt(scan)
477
478 expect(scan.headingCount).toBe(0)
479 expect(promptText).toContain('No heading hierarchy was detected')
480 expect(promptText).toContain('split by paragraphs')
481 })
482 })
483
483 lines TYPESCRIPT