scheduler.test.js
9.25 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
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()
})
})