diff --git a/src/save/checksum.test.ts b/src/save/checksum.test.ts new file mode 100644 index 0000000..20f53d4 --- /dev/null +++ b/src/save/checksum.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { crc32hex, canonicalJSON } from './checksum'; + +// Tests for the pure-function save core: deterministic CRC-32 + canonical JSON. +// Both functions are load-bearing for envelope checksums (see envelope.test.ts). + +describe('crc32hex', () => { + it('is deterministic — same input always returns same output', () => { + expect(crc32hex('hello')).toBe(crc32hex('hello')); + }); + + it('returns 8-char lowercase hex', () => { + expect(crc32hex('hello')).toMatch(/^[0-9a-f]{8}$/); + }); + + it('differs for different inputs', () => { + expect(crc32hex('hello')).not.toBe(crc32hex('world')); + }); +}); + +describe('canonicalJSON', () => { + it('produces byte-identical output for objects with same keys in any order', () => { + expect(canonicalJSON({ b: 1, a: 2 })).toBe(canonicalJSON({ a: 2, b: 1 })); + }); + + it('sorts nested object keys recursively', () => { + expect(canonicalJSON({ b: { z: 1, a: 2 }, a: 1 })).toBe( + canonicalJSON({ a: 1, b: { a: 2, z: 1 } }), + ); + }); + + it('does NOT sort arrays — order is meaningful', () => { + expect(canonicalJSON([3, 1, 2])).toBe('[3,1,2]'); + }); +}); diff --git a/src/save/envelope.test.ts b/src/save/envelope.test.ts new file mode 100644 index 0000000..52b7504 --- /dev/null +++ b/src/save/envelope.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from 'vitest'; +import { + wrap, + unwrap, + SaveCorruptError, + SaveEnvelopeSchema, + type SaveEnvelope, +} from './envelope'; + +// Tests for the SaveEnvelope wrap/unwrap pair. The envelope is the load-bearing +// shape from CLAUDE.md: `{schemaVersion, payload, checksum}`. Tampering or +// lossy-storage corruption is detected via CRC-32 mismatch on unwrap. + +describe('wrap', () => { + it('returns an envelope with schemaVersion, payload, and 8-char hex checksum', () => { + const env = wrap({ foo: 'bar' }, 1); + expect(env.schemaVersion).toBe(1); + expect(env.payload).toEqual({ foo: 'bar' }); + expect(env.checksum).toMatch(/^[0-9a-f]{8}$/); + }); +}); + +describe('unwrap', () => { + it('round-trips several payload shapes', () => { + const shapes: unknown[] = [ + { foo: 'bar' }, + { nested: { a: 1, b: { c: [1, 2, 3] } } }, + { garden: { tiles: [{ id: 'tile-1' }] }, plants: [] }, + [1, 2, 3], + { empty: {} }, + ]; + for (const p of shapes) { + expect(unwrap(wrap(p, 1))).toEqual(p); + } + }); + + it('throws SaveCorruptError when checksum is tampered', () => { + const env = wrap({ x: 1 }, 1); + const tampered: SaveEnvelope = { ...env, checksum: 'deadbeef' }; + let caught: unknown = null; + try { + unwrap(tampered); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(SaveCorruptError); + const err = caught as SaveCorruptError; + expect(err.expected).toBe('deadbeef'); + expect(err.actual).toBe(env.checksum); + }); + + it('throws SaveCorruptError when payload is tampered (checksum mismatch)', () => { + const env = wrap({ x: 1 }, 1); + const tampered: SaveEnvelope = { ...env, payload: { x: 2 } }; + expect(() => unwrap(tampered)).toThrow(SaveCorruptError); + }); +}); + +describe('SaveEnvelopeSchema', () => { + it('accepts a valid envelope', () => { + const env = wrap({ foo: 'bar' }, 1); + expect(SaveEnvelopeSchema.safeParse(env).success).toBe(true); + }); + + it('accepts schemaVersion 0 (synthetic v0 per CONTEXT D-05)', () => { + const env = { schemaVersion: 0, payload: {}, checksum: '00000000' }; + expect(SaveEnvelopeSchema.safeParse(env).success).toBe(true); + }); + + it('rejects malformed envelopes (missing keys)', () => { + const noChecksum = { schemaVersion: 1, payload: {} }; + const noVersion = { payload: {}, checksum: '00000000' }; + expect(SaveEnvelopeSchema.safeParse(noChecksum).success).toBe(false); + expect(SaveEnvelopeSchema.safeParse(noVersion).success).toBe(false); + }); + + it('rejects malformed envelopes (non-hex checksum)', () => { + const bad = { schemaVersion: 1, payload: {}, checksum: 'NOT-HEX!' }; + expect(SaveEnvelopeSchema.safeParse(bad).success).toBe(false); + }); + + it('rejects negative schemaVersion', () => { + const bad = { schemaVersion: -1, payload: {}, checksum: '00000000' }; + expect(SaveEnvelopeSchema.safeParse(bad).success).toBe(false); + }); +}); diff --git a/src/save/migrations.test.ts b/src/save/migrations.test.ts new file mode 100644 index 0000000..8e77867 --- /dev/null +++ b/src/save/migrations.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi } from 'vitest'; +import { migrate, CURRENT_SCHEMA_VERSION, migrations } from './migrations'; + +// Tests for the forward-only migration registry. The synthetic v0 → v1 +// migration (CONTEXT D-05) is the load-bearing one — Phase 4's real +// migrate_v1_to_v2 will follow the exact same shape. + +describe('CURRENT_SCHEMA_VERSION', () => { + it('is 1 in Phase 1 (sanity)', () => { + expect(CURRENT_SCHEMA_VERSION).toBe(1); + }); +}); + +describe('migrate (synthetic v0 → v1 per CONTEXT D-04 + D-05)', () => { + it('synthetic v0 payload migrates to v1 shape', () => { + const v0 = { garden: [{ id: 'tile-1' }, { id: 'tile-2' }] }; + const result = migrate(v0, 0); + expect(result.toVersion).toBe(1); + expect(result.payload).toMatchObject({ + garden: { tiles: [{ id: 'tile-1' }, { id: 'tile-2' }] }, + plants: [], + harvestedFragmentIds: [], + lastTickAt: expect.any(Number), + settings: { + musicVolume: expect.any(Number), + ambientVolume: expect.any(Number), + sfxVolume: expect.any(Number), + }, + }); + }); + + it('migrating from v1 is a no-op (returns payload unchanged at toVersion 1)', () => { + const v1 = { + garden: { tiles: [] }, + plants: [], + harvestedFragmentIds: [], + lastTickAt: 1234567890, + settings: { musicVolume: 0.7, ambientVolume: 0.5, sfxVolume: 0.8 }, + }; + const result = migrate(v1, 1); + expect(result.toVersion).toBe(1); + expect(result.payload).toEqual(v1); + }); + + it('throws when fromVersion is in the future (no migration registered)', () => { + expect(() => migrate({}, 99)).toThrow(); + }); + + it('throws when fromVersion is negative', () => { + expect(() => migrate({}, -1)).toThrow(); + }); + + it('invokes migrations[1] exactly once when migrating v0 → v1', () => { + const original = migrations[1]; + const spy = vi.fn(original); + migrations[1] = spy; + try { + migrate({ garden: [] }, 0); + expect(spy).toHaveBeenCalledTimes(1); + } finally { + migrations[1] = original; + } + }); +});