| 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 |