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; expect(out.unlockedPlantTypes).toEqual([]); }); it('migrations[1] populates luraBeatProgress with all-false defaults', () => { const out = migrations[1]({ garden: ['x'] }) as Record; 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; 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; 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']); }); });