import { describe, it, expect } from 'vitest' import { createScheduler } from '../src/scheduler.js' import { AnimationState } from '../src/anim-state.js' import { createCamera } from '../src/camera.js' import { makeClipLib } from './fixtures.js' // ───────────────────────── 假时钟(虚拟时间) ───────────────────────── // 维护待唤醒队列;advance(ms) 推进虚拟钟,resolve 到期的 sleep。多个并发 sleep // 同步推进,使 Promise.all 取 max 可被确定性断言。 function makeClock() { let now = 0 /** @type {{at:number,res:()=>void}[]} */ let waiters = [] return { clock: { sleep(ms) { return new Promise((res) => { waiters.push({ at: now + Math.max(0, ms), res }) }) }, }, now: () => now, async advance(ms) { // 先把当前挂起的非时钟 await(如 tts.synthesize)跑完,让 sleep 注册进队列 for (let i = 0; i < 5; i++) await Promise.resolve() const target = now + ms // 逐个唤醒到期者,给微任务机会链上下一个 sleep // 反复扫描直到没有 <= target 的待唤醒项 for (;;) { waiters.sort((a, b) => a.at - b.at) const next = waiters.find((w) => w.at <= target) if (!next) break now = next.at waiters = waiters.filter((w) => w !== next) next.res() await Promise.resolve() await Promise.resolve() } now = target }, } } // ───────────────────────── 假三接口(记录调用 + 时间戳) ───────────────────────── function makePorts(nowFn) { /** @type {{t:number,call:string,args:any[]}[]} */ const log = [] const rec = (call) => (...args) => log.push({ t: nowFn(), call, args }) const renderer = { applyActorPose: rec('applyActorPose'), applyFace: rec('applyFace'), applyFacing: rec('applyFacing'), applyCamera: rec('applyCamera'), setActorWorldPos: rec('setActorWorldPos'), playEffect: rec('playEffect'), stopEffect: rec('stopEffect'), resetActor: rec('resetActor'), runTransition: async (...a) => { log.push({ t: nowFn(), call: 'runTransition', args: a }) }, } const audio = { play: (...a) => { log.push({ t: nowFn(), call: 'audio.play', args: a }) return { stop: rec('audio.stop'), durationMs: Promise.resolve(1000) } }, stopAll: rec('audio.stopAll'), } const tts = { synthesize: async (req) => { log.push({ t: nowFn(), call: 'tts.synthesize', args: [req] }) return { audio: new ArrayBuffer(8), durationMs: 1000 } }, } return { renderer, audio, tts, log } } function makeCtx() { const anim = new AnimationState(makeClipLib()) return { actors: { hero: true }, animStateOf: (a) => (a === 'hero' ? anim : null), resolvePoint: (n) => (n === 'door' ? { x: 500, y: 0 } : null), getActorWorldPos: () => ({ x: 0, y: 0 }), focusLookup: () => ({ x: 0, y: 0 }), castVoice: () => ({ voice: 'v1' }), _anim: anim, } } describe('scheduler 并行/阻塞调度', () => { it('say+moveTo 并发取 max,cam/fx 行首即发不延长,wait 叠加(验收 7)', async () => { const { clock, advance, now } = makeClock() const { renderer, audio, tts, log } = makePorts(now) const sch = createScheduler({ renderer, audio, tts, clock }) const ctx = makeCtx() const line = { who: 'hero', say: 'hello there', // voice → TTS durationMs=1000 moveTo: 'door', moveDur: 600, // 走位 600ms cam: { type: 'cut', target: { x: 1, y: 1 } }, fx: 'sparkle', wait: 200, } const p = sch.playLine(line, ctx) // cam/fx 在行首即发(虚拟时间 0) expect(log.find((e) => e.call === 'playEffect' && e.args[0] === 'sparkle').t).toBe(0) expect(log.find((e) => e.call === 'tts.synthesize').t).toBe(0) expect(log.find((e) => e.call === 'setActorWorldPos').t).toBe(0) await advance(600) // 走位结束,但 say(1000) 仍阻塞 await advance(400) // say 到 1000ms await advance(200) // wait 叠加 await p // audio.play 发生过;say 等满 1000;总时长 = max(600,1000)+200 = 1200 const stopAll0 = log.filter((e) => e.call === 'audio.stopAll') expect(stopAll0.length).toBe(0) // 正常播完不调 stopAll expect(log.some((e) => e.call === 'audio.play')).toBe(true) }) it('say 有 voice 走 tts.synthesize→audio.play 并驱动 applyFace(talking)(验收 7)', async () => { const { clock, advance, now } = makeClock() const { renderer, audio, tts, log } = makePorts(now) const sch = createScheduler({ renderer, audio, tts, clock }) const ctx = makeCtx() const p = sch.playLine({ who: 'hero', say: 'hi', voice: 'v1' }, ctx) // 行首:applyFace(talking=true) const faceTrue = log.find((e) => e.call === 'applyFace' && e.args[2] === true) expect(faceTrue).toBeDefined() await advance(1000) await p // 收尾:applyFace(talking=false) expect(log.some((e) => e.call === 'applyFace' && e.args[2] === false)).toBe(true) expect(log.some((e) => e.call === 'tts.synthesize')).toBe(true) expect(log.some((e) => e.call === 'audio.play')).toBe(true) }) it('无 cast voice 时回退默认音色并走 TTS,按 TTS duration 阻塞(验收 7)', async () => { const { clock, advance, now } = makeClock() const { renderer, audio, tts, log } = makePorts(now) const sch = createScheduler({ renderer, audio, tts, clock }) const ctx = makeCtx() ctx.castVoice = () => ({}) // 无 voice,走默认音色兜底 let done = false const p = sch.playLine({ who: 'hero', say: 'x' }, ctx).then(() => { done = true }) await advance(100) expect(done).toBe(false) // TTS duration 1000ms,还没到 expect(log.filter((e) => e.call === 'tts.synthesize').length).toBe(1) expect(log.find((e) => e.call === 'tts.synthesize').args[0].speaker).toBe('S_5wA0nUX42') await advance(1000) await p expect(done).toBe(true) }) it('interrupt 调 audio.stopAll + resetActor + camera.reset,后续行不执行(验收 8)', async () => { const { clock, advance, now } = makeClock() const { renderer, audio, tts, log } = makePorts(now) const camera = createCamera({ getViewport: () => ({ w: 100, h: 100 }) }) const sch = createScheduler({ renderer, audio, tts, camera, clock }) const ctx = makeCtx() const lines = [ { who: 'hero', say: 'long line', voice: 'v1' }, { who: 'hero', say: 'second line', voice: 'v1' }, ] const p = sch.play(lines, ctx) await advance(200) // 第一行播到中途 sch.interrupt() await advance(5000) await p expect(log.some((e) => e.call === 'audio.stopAll')).toBe(true) expect(log.some((e) => e.call === 'resetActor' && e.args[0] === 'hero')).toBe(true) // 第二行不应执行:tts.synthesize 只被第一行调用一次 expect(log.filter((e) => e.call === 'tts.synthesize').length).toBe(1) }) it('transition 阻塞走 runTransition + 换班底(验收/§3.4)', async () => { const { clock, advance, now } = makeClock() const { renderer, audio, tts, log } = makePorts(now) const sch = createScheduler({ renderer, audio, tts, clock }) let newCast = null const ctx = makeCtx() ctx.applyCast = (c) => { newCast = c } const p = sch.playLine( { transition: { to: 'scene2', type: 'fade', duration: 400 }, cast: [{ alias: 'villain' }] }, ctx, ) await advance(0) await p expect(log.some((e) => e.call === 'runTransition')).toBe(true) expect(newCast).toEqual([{ alias: 'villain' }]) }) it('transition 行若同时带 say,也会在切场后继续播台词而不是静音跳过', async () => { const { clock, advance, now } = makeClock() const { renderer, audio, tts, log } = makePorts(now) const sch = createScheduler({ renderer, audio, tts, clock }) const ctx = makeCtx() const p = sch.playLine( { who: 'hero', transition: { to: 'scene2', type: 'fade', duration: 400 }, cast: [{ alias: 'hero', voice: 'v1' }], say: 'after transition', }, ctx, ) await advance(1000) await p expect(log.some((e) => e.call === 'runTransition')).toBe(true) expect(log.some((e) => e.call === 'tts.synthesize')).toBe(true) expect(log.some((e) => e.call === 'audio.play')).toBe(true) }) it('cast 中空字符串 speechRate 不会污染 tts.synthesize(转场加人不再静音)', async () => { const { clock, advance, now } = makeClock() const { renderer, audio, tts, log } = makePorts(now) const sch = createScheduler({ renderer, audio, tts, clock }) const ctx = makeCtx() ctx.castVoice = () => ({ voice: 'v1', speechRate: '' }) const p = sch.playLine({ who: 'hero', say: 'after transition', transition: { to: 'scene2', type: 'fade', duration: 200 } }, ctx) await advance(1000) await p const call = log.find((e) => e.call === 'tts.synthesize') expect(call).toBeDefined() expect(call.args[0].speechRate).toBeUndefined() }) it('createScheduler 缺依赖即抛(装配时崩,§10.3)', () => { expect(() => createScheduler({ renderer: {}, audio: {} })).toThrow() }) })