Files
TheLastGarden/src/save/migrations.test.ts
T
josh fe99058040 feat(02-01): Zustand store + V1Payload extension + save lifecycle hooks
- Zustand 5 vanilla createStore composes 4 slices (garden / memory /
  narrative / session); useAppStore React hook re-renders on selector
  change; getState() works without React (Phaser ↔ React bridge per D-32)
- simAdapter exposes drainCommands / applyTilesAndUnlocks /
  applyHarvestedFragments / applyLuraProgress / applyTickCount; sim
  never imports the store (CORE-10)
- V1Payload extended in place per D-34: tickCount (BLOCKER 3 monotonic
  counter), unlockedPlantTypes, luraBeatProgress, offlineEvents,
  settings.persistenceToastShown — CURRENT_SCHEMA_VERSION stays 1, no
  migrations[2] sneaked in (regression-defense test pins this)
- migrations[1] populates all new field defaults; tickCount: 0 means
  fresh sims always start at sim-tick 0
- registerSaveLifecycleHooks (UX-10): visibilitychange→hidden,
  beforeunload, plus saveOnSeasonTransition() — Vitest covers all three
- Phaser EventBus singleton seeded per the Phaser 4 React-template pattern
- Install @testing-library/react as devDep so the React-hook test can
  exercise the real renderHook surface
- 27 new tests across store / migrations / lifecycle all green; full
  npm run ci is 126/126
2026-05-09 09:18:43 -04:00

117 lines
4.2 KiB
TypeScript

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.
//
// Phase 2 (CONTEXT D-34) extends V1Payload IN PLACE rather than introducing
// migrations[2] — Phase 1's v1 has shipped no production saves, so adding
// fields with sensible defaults is preferable. The block of "new field
// default" tests below pins the extension contract.
describe('CURRENT_SCHEMA_VERSION', () => {
it('is 1 (Phase 2 extends v1 in place per D-34, no migrations[2])', () => {
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;
}
});
});
describe('Phase 2 V1Payload extension defaults (CONTEXT D-34)', () => {
// After D-34 every v0 → v1 migration MUST populate the new fields.
// These tests pin the contract so a future regression that drops a
// default is caught.
it('migrations[1] populates unlockedPlantTypes as []', () => {
const out = migrations[1]({ garden: ['x'] }) as Record<string, unknown>;
expect(out.unlockedPlantTypes).toEqual([]);
});
it('migrations[1] populates luraBeatProgress with all-false defaults', () => {
const out = migrations[1]({ garden: ['x'] }) as Record<string, unknown>;
expect(out.luraBeatProgress).toEqual({
arrived: false,
mid: false,
farewell: false,
pending: null,
});
});
it('migrations[1] populates offlineEvents as null (no events on a fresh save)', () => {
const out = migrations[1]({ garden: ['x'] }) as Record<string, unknown>;
expect(out.offlineEvents).toBeNull();
});
it('migrations[1] populates settings.persistenceToastShown as false (D-30 toast not yet seen)', () => {
const out = migrations[1]({ garden: ['x'] }) as { settings: { persistenceToastShown: boolean } };
expect(out.settings.persistenceToastShown).toBe(false);
});
it('migrations[1] preserves existing audio volume defaults (musicVolume 0.7)', () => {
const out = migrations[1]({ garden: ['x'] }) as { settings: { musicVolume: number } };
expect(out.settings.musicVolume).toBe(0.7);
});
it('BLOCKER 3: migrations[1] populates tickCount as 0 (sim-internal counter starts fresh)', () => {
const out = migrations[1]({ garden: ['x'] }) as Record<string, unknown>;
expect(out.tickCount).toBe(0);
});
});
describe('migration registry shape (D-34 regression defense)', () => {
it('only migrations[1] is registered (no migrations[2] sneakily added)', () => {
expect(Object.keys(migrations).sort()).toEqual(['1']);
});
});