253 lines
8.5 KiB
JavaScript
253 lines
8.5 KiB
JavaScript
// @ts-check
|
|
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { dirname, join } from 'node:path'
|
|
|
|
// Imported via the PACKAGE NAME to prove the new workspace module resolves.
|
|
import { PlaybackClock } from '@cozy-games/replay'
|
|
import { createMoveLog } from '@cozy-games/move-log'
|
|
|
|
/**
|
|
* A hand-rolled deterministic scheduler — the injected-clock seam in action, with
|
|
* zero reliance on vi internals. `advance(ms)` fires due timers in time order,
|
|
* picking up timers scheduled from within a firing callback.
|
|
*/
|
|
function fakeScheduler(start = 0) {
|
|
let now = start
|
|
let nextId = 1
|
|
const timers = new Map()
|
|
return {
|
|
clock: () => now,
|
|
setTimeout: (fn, ms) => {
|
|
const id = nextId++
|
|
timers.set(id, { at: now + Math.max(0, ms), fn })
|
|
return id
|
|
},
|
|
clearTimeout: (id) => { timers.delete(id) },
|
|
advance(ms) {
|
|
const target = now + ms
|
|
for (;;) {
|
|
let due = null
|
|
for (const [id, timer] of timers) {
|
|
if (timer.at <= target && (due === null || timer.at < due.at)) due = { id, ...timer }
|
|
}
|
|
if (!due) break
|
|
timers.delete(due.id)
|
|
now = due.at
|
|
due.fn()
|
|
}
|
|
now = target
|
|
}
|
|
}
|
|
}
|
|
|
|
// Events at offsets 0, 100, 350 (t rebased from 1000). Payload is opaque to the clock.
|
|
function envelope() {
|
|
return createMoveLog([
|
|
{ seq: 1, t: 1000, event: { type: 'reveal', r: 0, c: 0 } },
|
|
{ seq: 2, t: 1100, event: { type: 'flag', r: 1, c: 2 } },
|
|
{ seq: 3, t: 1350, event: { type: 'chord', r: 4, c: 4 } }
|
|
])
|
|
}
|
|
|
|
const typesOf = records => records.map(r => r.event.type)
|
|
|
|
describe('PlaybackClock — construction & shape', () => {
|
|
it('rebases to offsets: duration is the last offset, first event at 0', () => {
|
|
const clock = new PlaybackClock(envelope(), fakeScheduler())
|
|
expect(clock.duration).toBe(350)
|
|
expect(clock.position()).toBe(0)
|
|
expect(clock.isPlaying()).toBe(false)
|
|
})
|
|
|
|
it('validates the envelope (rejects a non-envelope)', () => {
|
|
expect(() => new PlaybackClock(/** @type {any} */ (null))).toThrow()
|
|
expect(() => new PlaybackClock(/** @type {any} */ ({ schema_version: 2, events: [] }))).toThrow()
|
|
})
|
|
|
|
it('handles an empty envelope gracefully', () => {
|
|
const clock = new PlaybackClock(createMoveLog([]), fakeScheduler())
|
|
const seen = []
|
|
clock.on(r => seen.push(r))
|
|
expect(clock.duration).toBe(0)
|
|
clock.play()
|
|
expect(clock.isPlaying()).toBe(false) // nothing to play → ends immediately
|
|
expect(seen).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('PlaybackClock — play / pause with an injected scheduler', () => {
|
|
it('fires events at their recorded offsets, exactly', () => {
|
|
const s = fakeScheduler()
|
|
const clock = new PlaybackClock(envelope(), s)
|
|
const seen = []
|
|
clock.on(r => seen.push({ type: r.event.type, at: s.clock() }))
|
|
|
|
clock.play()
|
|
// offset-0 event fires synchronously on play
|
|
expect(seen).toEqual([{ type: 'reveal', at: 0 }])
|
|
|
|
s.advance(100)
|
|
expect(seen[1]).toEqual({ type: 'flag', at: 100 })
|
|
|
|
s.advance(250) // reach offset 350
|
|
expect(seen[2]).toEqual({ type: 'chord', at: 350 })
|
|
expect(clock.isPlaying()).toBe(false) // ended
|
|
expect(clock.position()).toBe(350)
|
|
})
|
|
|
|
it('pause freezes position and stops delivery', () => {
|
|
const s = fakeScheduler()
|
|
const clock = new PlaybackClock(envelope(), s)
|
|
const seen = []
|
|
clock.on(r => seen.push(r.event.type))
|
|
|
|
clock.play()
|
|
s.advance(150) // past offset 100, between 100 and 350
|
|
clock.pause()
|
|
expect(clock.position()).toBe(150)
|
|
expect(seen).toEqual(['reveal', 'flag'])
|
|
|
|
s.advance(1000) // no timers should fire while paused
|
|
expect(seen).toEqual(['reveal', 'flag'])
|
|
expect(clock.position()).toBe(150)
|
|
})
|
|
|
|
it('resumes from the paused position', () => {
|
|
const s = fakeScheduler()
|
|
const clock = new PlaybackClock(envelope(), s)
|
|
const seen = []
|
|
clock.on(r => seen.push({ type: r.event.type, at: s.clock() }))
|
|
|
|
clock.play()
|
|
s.advance(150)
|
|
clock.pause()
|
|
clock.play() // resume at 150; next event at 350 ⇒ 200ms away
|
|
s.advance(200)
|
|
expect(seen.map(e => e.type)).toEqual(['reveal', 'flag', 'chord'])
|
|
expect(seen[2].at).toBe(350) // still fires at its true offset
|
|
})
|
|
})
|
|
|
|
describe('PlaybackClock — seek determinism', () => {
|
|
it('seek forward delivers exactly the events at offset <= t, in order, once', () => {
|
|
const clock = new PlaybackClock(envelope(), fakeScheduler())
|
|
const seen = []
|
|
clock.on(r => seen.push(r))
|
|
|
|
clock.seek(200) // offsets 0 and 100 are <= 200; 350 is not
|
|
expect(typesOf(seen)).toEqual(['reveal', 'flag'])
|
|
expect(clock.position()).toBe(200)
|
|
|
|
clock.seek(200) // no movement ⇒ no new deliveries
|
|
expect(typesOf(seen)).toEqual(['reveal', 'flag'])
|
|
})
|
|
|
|
it('seek boundary is inclusive (offset === t fires)', () => {
|
|
const clock = new PlaybackClock(envelope(), fakeScheduler())
|
|
const seen = []
|
|
clock.on(r => seen.push(r))
|
|
clock.seek(100)
|
|
expect(typesOf(seen)).toEqual(['reveal', 'flag']) // offset 100 included
|
|
})
|
|
|
|
it('seek backward re-schedules with no duplicate or dropped events', () => {
|
|
const clock = new PlaybackClock(envelope(), fakeScheduler())
|
|
const seen = []
|
|
clock.on(r => seen.push(r))
|
|
|
|
clock.seek(400) // deliver all three
|
|
expect(typesOf(seen)).toEqual(['reveal', 'flag', 'chord'])
|
|
|
|
clock.seek(50) // rewind — no delivery; only offset-0 stays "passed"
|
|
expect(typesOf(seen)).toEqual(['reveal', 'flag', 'chord']) // unchanged
|
|
|
|
clock.seek(400) // forward again re-delivers the re-crossed events, once each
|
|
expect(typesOf(seen)).toEqual(['reveal', 'flag', 'chord', 'flag', 'chord'])
|
|
})
|
|
|
|
it('seek while playing re-anchors and keeps firing correctly', () => {
|
|
const s = fakeScheduler()
|
|
const clock = new PlaybackClock(envelope(), s)
|
|
const seen = []
|
|
clock.on(r => seen.push(r.event.type))
|
|
|
|
clock.play()
|
|
s.advance(50) // only offset-0 delivered so far
|
|
expect(seen).toEqual(['reveal'])
|
|
|
|
clock.seek(120) // jump forward while playing ⇒ deliver offset-100 event
|
|
expect(seen).toEqual(['reveal', 'flag'])
|
|
|
|
s.advance(230) // reach 350 ⇒ final event
|
|
expect(seen).toEqual(['reveal', 'flag', 'chord'])
|
|
expect(clock.isPlaying()).toBe(false)
|
|
})
|
|
|
|
it('never delivers an event twice within a single forward pass', () => {
|
|
const s = fakeScheduler()
|
|
const clock = new PlaybackClock(envelope(), s)
|
|
const seqs = []
|
|
clock.on(r => seqs.push(r.seq))
|
|
clock.play()
|
|
s.advance(1000)
|
|
expect(seqs).toEqual([1, 2, 3]) // each once, in order
|
|
})
|
|
})
|
|
|
|
describe('PlaybackClock — with vi fake timers', () => {
|
|
afterEach(() => { vi.useRealTimers() })
|
|
|
|
it('play/pause/seek work under vi.useFakeTimers()', () => {
|
|
vi.useFakeTimers()
|
|
vi.setSystemTime(0)
|
|
// Default deps ⇒ Date.now + global setTimeout, both faked by vi.
|
|
const clock = new PlaybackClock(envelope())
|
|
const seen = []
|
|
clock.on(r => seen.push(r.event.type))
|
|
|
|
clock.play()
|
|
expect(seen).toEqual(['reveal']) // offset 0 immediate
|
|
vi.advanceTimersByTime(100)
|
|
expect(seen).toEqual(['reveal', 'flag'])
|
|
clock.pause()
|
|
vi.advanceTimersByTime(1000)
|
|
expect(seen).toEqual(['reveal', 'flag']) // paused ⇒ frozen
|
|
clock.play()
|
|
vi.advanceTimersByTime(250)
|
|
expect(seen).toEqual(['reveal', 'flag', 'chord'])
|
|
})
|
|
})
|
|
|
|
describe('game-agnosticism guard (envelope only, no game imports)', () => {
|
|
const pkgDir = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
const GAME_REFERENCES = /mnswpr|minesweeper/i
|
|
|
|
it('manifest depends only on the envelope, never a game package', () => {
|
|
const pkg = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'))
|
|
const deps = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.peerDependencies }
|
|
expect(Object.keys(deps).filter(name => GAME_REFERENCES.test(name))).toEqual([])
|
|
})
|
|
|
|
it('no source file imports or references a game package', () => {
|
|
const offenders = []
|
|
walk(pkgDir, file => {
|
|
if (!file.endsWith('.js') || file.includes('/test/')) return
|
|
const code = readFileSync(file, 'utf8')
|
|
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
.replace(/\/\/.*$/gm, '')
|
|
if (GAME_REFERENCES.test(code)) offenders.push(file)
|
|
})
|
|
expect(offenders).toEqual([])
|
|
})
|
|
})
|
|
|
|
function walk(dir, fn) {
|
|
for (const name of readdirSync(dir)) {
|
|
if (name === 'node_modules') continue
|
|
const p = join(dir, name)
|
|
if (statSync(p).isDirectory()) walk(p, fn)
|
|
else fn(p)
|
|
}
|
|
}
|