scheduler.test.js 9.25 KB
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()
  })
})