139 lines
5.2 KiB
JavaScript
139 lines
5.2 KiB
JavaScript
// @ts-check
|
|
import { describe, it, expect } from 'vitest'
|
|
import { PlaybackClock, readEnvelope } from '@cozy-games/replay'
|
|
import { createMoveLog } from '@cozy-games/move-log'
|
|
|
|
const VERSION = 'mnswpr-moves/1'
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
// A canonical `@cozy-games/move-log` envelope.
|
|
const canonical = () => createMoveLog(VERSION, [
|
|
{ seq: 1, clientTs: 0, type: 'reveal', payload: { r: 0, c: 0 } },
|
|
{ seq: 2, clientTs: 100, type: 'flag', payload: { r: 1, c: 1 } },
|
|
{ seq: 3, clientTs: 250, type: 'chord', payload: { r: 2, c: 2 } }
|
|
])
|
|
|
|
describe('canonical move-log envelopes play directly', () => {
|
|
it('replays a canonical envelope through readEnvelope (not a bypass)', () => {
|
|
const s = fakeScheduler()
|
|
const clock = new PlaybackClock(canonical(), s)
|
|
const seen = []
|
|
clock.on(r => seen.push(r.type))
|
|
clock.play()
|
|
s.advance(250)
|
|
expect(seen).toEqual(['reveal', 'flag', 'chord'])
|
|
})
|
|
|
|
it('readEnvelope returns the canonical records', () => {
|
|
const records = readEnvelope(canonical())
|
|
expect(records.map(r => r.seq)).toEqual([1, 2, 3])
|
|
expect(records[0].type).toBe('reveal')
|
|
expect(records[0].payload).toEqual({ r: 0, c: 0 })
|
|
})
|
|
|
|
it('is game-agnostic: any valid string schema_version plays (no allow-list)', () => {
|
|
// A different game's vocabulary version — the engine plays it without any
|
|
// per-game registration; genericness, not gatekeeping.
|
|
const otherGame = createMoveLog('some-other-game/3', [
|
|
{ seq: 1, clientTs: 0, type: 'jump', payload: { x: 1 } }
|
|
])
|
|
const records = readEnvelope(otherGame)
|
|
expect(records.map(r => r.type)).toEqual(['jump'])
|
|
})
|
|
})
|
|
|
|
describe('malformed envelopes fail loudly', () => {
|
|
it('throws for a non-string schema_version', () => {
|
|
const bad = { schema_version: 99, events: [{ seq: 1, clientTs: 0, type: 'x', payload: {} }] }
|
|
expect(() => new PlaybackClock(/** @type {any} */ (bad), fakeScheduler())).toThrow(TypeError)
|
|
expect(() => readEnvelope(bad)).toThrow(/schema_version must be a non-empty string/)
|
|
})
|
|
|
|
it('throws for a missing schema_version', () => {
|
|
expect(() => readEnvelope({ events: [] })).toThrow(/schema_version must be a non-empty string/)
|
|
})
|
|
|
|
it('throws for a valid version but malformed events (not a canonical log)', () => {
|
|
expect(() => readEnvelope({ schema_version: VERSION })).toThrow(/events must be an array/)
|
|
})
|
|
|
|
it('rejects a non-object envelope', () => {
|
|
// @ts-expect-error — deliberately wrong type
|
|
expect(() => readEnvelope(null)).toThrow(TypeError)
|
|
})
|
|
})
|
|
|
|
describe('foreign generations normalize via a supplied reader (keyed by version string)', () => {
|
|
// A toy FOREIGN format defined ENTIRELY in the test: a different field layout
|
|
// that a normalizer maps back to canonical { seq, clientTs, type, payload }.
|
|
// Adding support is one reader entry, keyed by its schema_version string.
|
|
const v2Envelope = {
|
|
schema_version: 'toy-v2/1',
|
|
log: [
|
|
{ n: 1, ts: 0, kind: 'reveal', data: { r: 0, c: 0 } },
|
|
{ n: 2, ts: 120, kind: 'flag', data: { r: 1, c: 1 } }
|
|
]
|
|
}
|
|
const readV2 = env => env.log.map(e => ({ seq: e.n, clientTs: e.ts, type: e.kind, payload: e.data }))
|
|
|
|
it('replays a foreign fixture via a supplied reader (same code path)', () => {
|
|
const s = fakeScheduler()
|
|
const clock = new PlaybackClock(v2Envelope, s, {}, { readers: { 'toy-v2/1': readV2 } })
|
|
const seen = []
|
|
clock.on(r => seen.push(r.type))
|
|
clock.play()
|
|
s.advance(120)
|
|
expect(seen).toEqual(['reveal', 'flag'])
|
|
})
|
|
|
|
it('readEnvelope normalizes the foreign format to canonical records', () => {
|
|
const records = readEnvelope(v2Envelope, { 'toy-v2/1': readV2 })
|
|
expect(records).toEqual([
|
|
{ seq: 1, clientTs: 0, type: 'reveal', payload: { r: 0, c: 0 } },
|
|
{ seq: 2, clientTs: 120, type: 'flag', payload: { r: 1, c: 1 } }
|
|
])
|
|
})
|
|
|
|
it('fails loudly for the foreign format when no reader is registered', () => {
|
|
// Default path validates it AS a canonical log — it has no `events`, so it
|
|
// fails with the field-specific error, not a silent parse.
|
|
expect(() => readEnvelope(v2Envelope)).toThrow(/events must be an array/)
|
|
})
|
|
|
|
it('validates a reader that returns a malformed (non-canonical) log', () => {
|
|
// A buggy reader whose output breaks the monotonic-seq invariant is caught.
|
|
const badReader = () => [
|
|
{ seq: 2, clientTs: 0, type: 'a', payload: {} },
|
|
{ seq: 1, clientTs: 1, type: 'b', payload: {} }
|
|
]
|
|
expect(() => readEnvelope({ schema_version: 'toy-bad/1' }, { 'toy-bad/1': badReader })).toThrow(RangeError)
|
|
})
|
|
})
|