Files
dj/huobao-source/backend/tests/storyboard-coverage.test.ts
T
2026-11-13 11:46:25 +08:00

73 lines
5.4 KiB
TypeScript

import test from 'node:test'
import assert from 'node:assert/strict'
import {buildStoryboardPlan,dialogueTimingProblems,sectionBatchProblems,sectionCoverage,spokenLiterals,splitSpeech,speechSeconds,fitDraftDialogueTiming} from '../src/utils/storyboard-coverage.js'
const dialogue='请先检查每一扇窗户是否关好,再把桌上的图纸按照编号摆放整齐,确认材料数量以后我们才能开始修理屋顶。'
test('long dialogue cannot fit in a four-second timeline slot even with a long total duration',()=>{
assert.ok(dialogueTimingProblems({shot_number:1,duration:15,video_prompt:`0-4秒:老师说:「${dialogue}」\n4-15秒:大家检查工具。`}).some(s=>s.includes('对白至少需要')))
assert.deepEqual(dialogueTimingProblems({shot_number:1,duration:15,video_prompt:`0-14秒:老师说:「${dialogue}」\n14-15秒:大家点头。`}),[])
})
test('nested quotes counted once and offscreen speech counted as dialogue',()=>{
assert.deepEqual(spokenLiterals('老师(画外):「请回答『准备好了』,然后开门。」'),['请回答『准备好了』,然后开门。'])
assert.deepEqual(spokenLiterals('特写:门牌写着「图书室」。'),[])
})
test('plan retains each scene and paragraph in order',()=>{
const plan=buildStoryboardPlan(`## S01 | 工作室 | 白天\n\n甲检查窗户。\n\n甲:(认真)${dialogue}\n\n## S02 | 屋顶 | 白天\n\n乙走上屋顶。\n\n乙:工具已经准备好了,我们现在可以开始修理了。`)
assert.equal(plan.length,2);assert.ok(plan[0].units.length>=3)
assert.equal(plan[0].units.map(u=>u.dialogue||'').join(''),dialogue)
assert.ok(plan[0].minSeconds>=Math.ceil(dialogue.replace(/[^\p{Script=Han}]/gu,'').length/4.5))
const skipped=sectionBatchProblems(plan[0],[{shot_number:1,duration:12,video_prompt:'0-12秒:修理屋顶。',source_ids:['S02P01']}])
assert.ok(skipped.some(s=>s.includes('不能跳场')))
})
test('ending sentence does not bypass missing source, speech or duration',()=>{
const [section]=buildStoryboardPlan(`## S01 | 工作室 | 白天\n\n甲检查窗户。\n\n甲:${dialogue}\n\n乙:工具已经准备好了,我们现在可以开始修理了。`)
const rushed=[{shot_number:1,duration:12,source_ids:section.units.map(u=>u.id),video_prompt:'0-12秒:乙说:「工具已经准备好了,我们现在可以开始修理了。」'}]
const coverage=sectionCoverage(section,rushed)
assert.equal(coverage.ready,false);assert.ok(coverage.missingDialogue.length)
})
test('dialogue can span shots while all source units and minimum duration remain required',()=>{
const [section]=buildStoryboardPlan(`## S01 | 工作室 | 白天\n\n甲:${dialogue}`)
const [a,b]=dialogue.split('确认')
const shots=[{shot_number:1,duration:12,source_ids:section.units.map(u=>u.id),video_prompt:`0-12秒:甲说:「${a}」`},{shot_number:2,duration:12,source_ids:section.units.map(u=>u.id),video_prompt:`0-12秒:甲说:「确认${b}」`}]
assert.equal(sectionCoverage(section,shots).ready,true)
})
test('adjacent lines without blank separators still inventory dialogue',()=>{
const [section]=buildStoryboardPlan('大家来到工作室。\n老师:'+dialogue)
assert.ok(section.units.length>=3);assert.equal(section.units.map(u=>u.dialogue||'').join(''),dialogue)
assert.equal(sectionCoverage(section,[{shot_number:1,duration:15,source_ids:section.units.map(u=>u.id),video_prompt:'0-15秒:大家检查工具。'}]).ready,false)
})
test('long speech chunks preserve every character and fit their declared speaking budget',()=>{
for(const text of [dialogue.repeat(3),'Please check every window and put the drawings in order before we begin repairing the roof. '.repeat(5),'没有标点的长台词'.repeat(20)]) {
const chunks=splitSpeech(text)
assert.ok(chunks.length>1);assert.equal(chunks.join(''),text)
assert.ok(chunks.every(c=>speechSeconds(c)<=10))
}
})
test('retiming preserves all speech and actions with identical Chinese and English time ranges',()=>{
const cn=`0-2秒:老师打开窗户。\n2-6秒:老师说:「${dialogue}」\n6-9秒:大家点头。`
const en=`0-2s: The teacher opens a window.\n2-6s: The teacher says: 「${dialogue}」\n6-9s: Everyone nods.`
const fixed=fitDraftDialogueTiming({shot_number:1,duration:9,video_prompt:cn,video_prompt_en:en})
assert.deepEqual(dialogueTimingProblems(fixed),[])
assert.ok(fixed.duration<=15&&fixed.duration>9)
const strip=(s:string)=>s.replace(/^\d+-\d+(?:秒:|s:)/gm,'')
assert.equal(strip(fixed.video_prompt),strip(cn));assert.equal(strip(fixed.video_prompt_en),strip(en))
const ranges=(s:string)=>s.match(/^\d+-\d+/gm)
assert.deepEqual(ranges(fixed.video_prompt),ranges(fixed.video_prompt_en))
})
test('retiming never shortens speech to squeeze an oversized line into one shot',()=>{
const shot={shot_number:1,duration:15,video_prompt:`0-15秒:老师说:「${dialogue.repeat(3)}」`}
assert.deepEqual(fitDraftDialogueTiming(shot),shot)
assert.ok(dialogueTimingProblems(shot).length)
})
test('coverage requires repeated speech in original order',()=>{
const [section]=buildStoryboardPlan('甲:准备好了。\n乙:检查窗户。\n甲:准备好了。')
const shot={shot_number:1,duration:15,source_ids:section.units.map(u=>u.id),video_prompt:'0-15秒:甲说:「准备好了。」乙说:「检查窗户。」'}
assert.equal(sectionCoverage(section,[shot]).ready,false)
shot.video_prompt+='甲说:「准备好了。」'
assert.equal(sectionCoverage(section,[shot]).ready,true)
})