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
This commit is contained in:
2026-05-09 09:18:43 -04:00
parent 58db53227c
commit fe99058040
17 changed files with 838 additions and 10 deletions
+53 -1
View File
@@ -4,9 +4,14 @@ 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 in Phase 1 (sanity)', () => {
it('is 1 (Phase 2 extends v1 in place per D-34, no migrations[2])', () => {
expect(CURRENT_SCHEMA_VERSION).toBe(1);
});
});
@@ -62,3 +67,50 @@ describe('migrate (synthetic v0 → v1 per CONTEXT D-04 + D-05)', () => {
}
});
});
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']);
});
});