test(01-03): add failing tests for save core (checksum, envelope, migrations) [RED]
- checksum.test.ts: 6 tests covering crc32hex determinism + 8-char-hex format + canonicalJSON recursive key sort + array-order preservation (Pitfall 3) - envelope.test.ts: 9 tests covering wrap/unwrap round-trip + tamper detection + Zod schema validation (incl synthetic v0 schemaVersion 0) - migrations.test.ts: 6 tests covering CURRENT_SCHEMA_VERSION = 1 + the load-bearing synthetic v0 -> v1 shape per CONTEXT D-04 + future/negative version throws + spy-confirmed registry invocation (RESEARCH Pitfall 7) RED phase per TDD plan-level gate. Tests fail because impl files do not exist yet.
This commit is contained in:
@@ -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]');
|
||||
});
|
||||
});
|
||||
@@ -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<unknown> = { ...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<unknown> = { ...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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user