fe99058040
- 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
117 lines
4.2 KiB
TypeScript
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']);
|
|
});
|
|
});
|