/** * agentVoiceOverrides + agentSelectionIsUserSet: persisted UI preferences (the * home for AgentBar choices — registry agent records are reset from * code/IndexedDB on every load and must not own user picks). * * localStorage is stubbed and the store imported per-test so the persist * rehydration path (merge of an existing blob) is exercised for real. A * pre-existing blob is seeded into the KVStore `account` scope — the store * reads only the KV scope and does not migrate any legacy raw key. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { BrowserKVStore } from '@openmaic/storage'; const storage = new Map(); const localStorageStub = { getItem: (k: string) => storage.get(k) ?? null, setItem: (k: string, v: string) => void storage.set(k, v), removeItem: (k: string) => void storage.delete(k), clear: () => void storage.clear(), key: () => null, length: 0, }; vi.stubGlobal('localStorage', localStorageStub); // Several init paths guard on `typeof window` before touching storage. vi.stubGlobal('window', { localStorage: localStorageStub }); // The blob is written and read through the KVStore's `account` scope, so seed // and read it back through the same primitive instead of guessing its key // layout. There is no legacy-key migration, so a pre-existing blob has to be // in the KV scope to be picked up. const persistKv = new BrowserKVStore({ storage: localStorageStub as unknown as Storage }); async function freshStore(persistedState?: Record) { vi.resetModules(); storage.clear(); if (persistedState) { await persistKv.set('settings-storage', { state: persistedState, version: 4 }, 'account'); } const { useSettingsStore } = await import('@/lib/store/settings'); // persist hydrates asynchronously now that it reads through the KVStore — // await it so the assertions never race the rehydrate. await useSettingsStore.persist.rehydrate(); return useSettingsStore; } async function readPersistedState(): Promise> { return await vi.waitFor(async () => { const blob = await persistKv.get<{ state: Record }>( 'settings-storage', 'account', ); expect(blob).not.toBeNull(); return blob!.state; }); } describe('agentVoiceOverrides', () => { beforeEach(() => storage.clear()); it('defaults to an empty map', async () => { const store = await freshStore(); expect(store.getState().agentVoiceOverrides).toEqual({}); }); it('setAgentVoiceOverride adds, replaces, and deletes entries per agent id', async () => { const store = await freshStore(); const { setAgentVoiceOverride } = store.getState(); setAgentVoiceOverride('default-2', { providerId: 'qwen-tts', voiceId: 'Dylan' }); setAgentVoiceOverride('default-3', { providerId: 'qwen-tts', modelId: 'qwen3-tts-flash', voiceId: 'Cherry', }); setAgentVoiceOverride('default-2', { providerId: 'qwen-tts', voiceId: 'Serena' }); expect(store.getState().agentVoiceOverrides).toEqual({ 'default-2': { providerId: 'qwen-tts', voiceId: 'Serena' }, 'default-3': { providerId: 'qwen-tts', modelId: 'qwen3-tts-flash', voiceId: 'Cherry' }, }); // undefined deletes — the symmetric way back to deterministic auto-assignment setAgentVoiceOverride('default-2', undefined); expect(store.getState().agentVoiceOverrides).toEqual({ 'default-3': { providerId: 'qwen-tts', modelId: 'qwen3-tts-flash', voiceId: 'Cherry' }, }); // The write must actually reach storage (would break if a future // partialize omits the field). Poll on the assertion, not on a blob // merely existing: hydration may already have left one there. await vi.waitFor(async () => { expect((await readPersistedState()).agentVoiceOverrides).toEqual({ 'default-3': { providerId: 'qwen-tts', modelId: 'qwen3-tts-flash', voiceId: 'Cherry' }, }); }); }); it('survives persist rehydration of an existing blob', async () => { const store = await freshStore({ agentVoiceOverrides: { 'default-2': { providerId: 'qwen-tts', voiceId: 'Dylan' } }, agentSelectionIsUserSet: true, }); expect(store.getState().agentVoiceOverrides).toEqual({ 'default-2': { providerId: 'qwen-tts', voiceId: 'Dylan' }, }); expect(store.getState().agentSelectionIsUserSet).toBe(true); }); it('defaults to {} when rehydrating a pre-existing blob without the field', async () => { const store = await freshStore({ ttsSpeed: 1.25 }); expect(store.getState().agentVoiceOverrides).toEqual({}); expect(store.getState().agentSelectionIsUserSet).toBe(false); expect(store.getState().ttsSpeed).toBe(1.25); }); }); describe('agentSelectionIsUserSet', () => { it('defaults to false and is settable', async () => { const store = await freshStore(); expect(store.getState().agentSelectionIsUserSet).toBe(false); store.getState().setAgentSelectionIsUserSet(true); expect(store.getState().agentSelectionIsUserSet).toBe(true); await vi.waitFor(async () => { expect((await readPersistedState()).agentSelectionIsUserSet).toBe(true); }); store.getState().setAgentSelectionIsUserSet(false); expect(store.getState().agentSelectionIsUserSet).toBe(false); }); });