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:
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user