import { beforeEach, describe, expect, it, vi } from 'vitest'; import { applyClassroomStageAndScenes, commitMigratedAgentConfigsToStore, discardRestoredMediaTasks, mergeLegacyAgentFallbacks, resetLegacyAgentFallbackProbes, rosterNeedsLegacyFallback, runClassroomLoad, } from '@/lib/classroom/load-classroom'; import { claimStageSceneLoadToken, clearStoreForDeletedStage, discardPendingStageChanges, flushStageSave, isCurrentStageSceneLoadToken, restorePendingStageChanges, snapshotPendingStageChangesForDeletion, useStageStore, } from '@/lib/store/stage'; import { beginStageDeletionCascade, isStageDeleted, markStageDeleted, settleStageDeletionCascade, unmarkStageDeleted, } from '@/lib/utils/deleted-stages'; import { loadStageData, saveStageDataIncremental } from '@/lib/utils/stage-storage'; import type { GeneratedAgentConfig, Scene, Stage } from '@/lib/types/stage'; // The store flush path imports stage-storage dynamically; mock it so a pending // mark scheduled by commitMigratedAgentConfigsToStore can never reach a real // (or jsdom) IndexedDB. vi.mock('@/lib/utils/stage-storage', () => ({ saveStageData: vi.fn().mockResolvedValue(undefined), saveStageDataIncremental: vi.fn().mockResolvedValue({ failedChanges: [] }), loadStageData: vi.fn().mockResolvedValue(null), })); // Omitting `generatedAgentConfigs` models a document written before roster // persistence existed (field absent) — distinct from an explicit `[]`, which // is an authoritative empty roster. function makeStage(id: string, generatedAgentConfigs?: Stage['generatedAgentConfigs']): Stage { return { id, name: id, createdAt: 1, updatedAt: 1, ...(generatedAgentConfigs !== undefined ? { generatedAgentConfigs } : {}), }; } function makeAgentConfig(id: string, extra: Partial = {}) { return { id, name: `Agent ${id}`, role: 'teacher', persona: 'Teach', avatar: 'A', color: '#000', priority: 1, ...extra, } satisfies GeneratedAgentConfig; } function makeScene(id: string, stageId: string): Scene { return { id, stageId, type: 'slide', title: id, order: 1, content: { type: 'slide', canvas: { id: `canvas-${id}`, viewportSize: 1000, viewportRatio: 0.5625, theme: { backgroundColor: '#fff', themeColors: ['#000'], fontColor: '#000', fontName: 'Inter', }, elements: [], }, }, }; } function deferred() { let resolve!: (value: T) => void; let reject!: (error: unknown) => void; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve, reject }; } function makeDeps(overrides: Partial[0]> = {}) { let current = true; let stage: Stage | null = null; const settings = { agentMode: 'auto' as const, selectedAgentIds: [] as string[], agentSelectionIsUserSet: false, setAgentMode: vi.fn(), setSelectedAgentIds: vi.fn(), setAgentSelectionIsUserSet: vi.fn(), }; const deps: Parameters[0] = { classroomId: 'stage-a', loadToken: 1, isCurrent: () => current, loadFromStorage: vi.fn().mockResolvedValue(undefined), getCurrentStage: () => stage, fetchClassroom: vi.fn().mockResolvedValue(null), applyFallbackScenes: vi.fn().mockResolvedValue(false), loadRestoredMediaTasks: vi.fn().mockResolvedValue({}), applyRestoredMediaTasks: vi.fn(), discardRestoredMediaTasks: vi.fn(), loadLegacyAgentFallbacks: vi.fn().mockResolvedValue([]), commitMigratedAgentConfigs: vi.fn(), applyGeneratedAgents: vi.fn().mockReturnValue([]), getSettings: () => settings, getAgent: vi.fn().mockReturnValue(undefined), restoreAgentSelection: vi.fn().mockReturnValue({ selection: { mode: 'preset', selectedAgentIds: ['default-1', 'default-2', 'default-3'] }, isUserSet: false, }), setError: vi.fn(), setLoading: vi.fn(), log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), }, ...overrides, }; return { deps, settings, setCurrent(next: boolean) { current = next; }, setStage(next: Stage | null) { stage = next; }, }; } // The fruitless-probe memo is session-scoped module state; isolate tests. beforeEach(() => { resetLegacyAgentFallbackProbes(); }); describe('runClassroomLoad', () => { it('keeps the current load token valid when fallback scenes are committed', () => { useStageStore.getState().clearStore(); const loadToken = claimStageSceneLoadToken(); const stage = makeStage('stage-a'); const scene = makeScene('scene-a', 'stage-a'); applyClassroomStageAndScenes(stage, [scene], { persist: false }); expect(isCurrentStageSceneLoadToken(loadToken)).toBe(true); expect(useStageStore.getState().stage?.id).toBe('stage-a'); expect(useStageStore.getState().scenes).toEqual([scene]); expect(useStageStore.getState().currentSceneId).toBe('scene-a'); useStageStore.getState().clearStore(); }); it('lifts a same-session deletion tombstone on explicit server-copy restore, and edits persist again', async () => { // Deletion only removes client-side data; revisiting the classroom URL // restores the server copy under the SAME id. Without lifting the // tombstone, every edit of the restored classroom would be silently // dropped until a reload. useStageStore.getState().clearStore(); vi.mocked(saveStageDataIncremental).mockClear(); markStageDeleted('stage-revived'); try { applyClassroomStageAndScenes( makeStage('stage-revived'), [makeScene('scene-r', 'stage-revived')], { persist: false }, ); expect(isStageDeleted('stage-revived')).toBe(false); // An edit after the restore reaches storage again. useStageStore.getState().setCurrentSceneId('scene-r'); await flushStageSave(); expect(saveStageDataIncremental).toHaveBeenCalledWith( 'stage-revived', expect.arrayContaining([{ kind: 'currentScene' }]), expect.anything(), expect.any(Number), ); } finally { unmarkStageDeleted('stage-revived'); useStageStore.getState().clearStore(); } }); it('restores a deleted classroom whose ghost is still warm in the store (back-button path)', async () => { // Round-2 pinned the restore with a direct applyClassroomStageAndScenes // call — the cold-store cell. The natural path is warmer: open classroom → // navigate home → delete → browser Back. loadFromStorage used to // short-circuit on the warm store, so the server-fallback restore (the // only unmark point) never ran and the classroom rendered editable while // every edit was silently dropped. This drives the REAL loadFromStorage. useStageStore.getState().clearStore(); vi.mocked(saveStageDataIncremental).mockClear(); const warmStage = makeStage('stage-warm-ghost'); applyClassroomStageAndScenes(warmStage, [makeScene('scene-w', 'stage-warm-ghost')], { persist: false, }); // Home-page delete: deletion marked while the store stays warm (the // deletion path also evicts the store; a partially failed cascade can // leave the ghost, which this path must still heal). markStageDeleted('stage-warm-ghost'); try { const serverStage = makeStage('stage-warm-ghost'); const serverScene = makeScene('scene-server', 'stage-warm-ghost'); const { deps } = makeDeps({ classroomId: 'stage-warm-ghost', loadFromStorage: (id, token) => useStageStore.getState().loadFromStorage(id, token), getCurrentStage: () => useStageStore.getState().stage, fetchClassroom: vi.fn().mockResolvedValue({ stage: serverStage, scenes: [serverScene] }), applyFallbackScenes: vi.fn().mockImplementation(async ({ stage, scenes }) => { applyClassroomStageAndScenes(stage, scenes, { persist: false }); return true; }), }); await runClassroomLoad(deps); // The warm ghost did not starve the restore: the server fallback ran, // the restored classroom is live, and the deletion was lifted. expect(deps.fetchClassroom).toHaveBeenCalledExactlyOnceWith('stage-warm-ghost'); expect(deps.applyFallbackScenes).toHaveBeenCalledOnce(); expect(useStageStore.getState().stage?.id).toBe('stage-warm-ghost'); expect(useStageStore.getState().scenes.map((s) => s.id)).toEqual(['scene-server']); expect(isStageDeleted('stage-warm-ghost')).toBe(false); // …and edits persist again end-to-end through the scheduler. useStageStore.getState().setCurrentSceneId('scene-server'); await flushStageSave(); expect(saveStageDataIncremental).toHaveBeenCalledWith( 'stage-warm-ghost', expect.arrayContaining([{ kind: 'currentScene' }]), expect.anything(), expect.any(Number), ); } finally { unmarkStageDeleted('stage-warm-ghost'); useStageStore.getState().clearStore(); } }); it('restores a deleted classroom whose warm ghost has ZERO scenes (identity, not scene count, gates the ghost handling)', async () => { // The ghost handling must key on stage identity alone: a scene-less // classroom (its cascade failed AFTER removing the document, so the // eviction never ran) leaves a warm ghost with zero scenes. Gating the // deleted-warm handling on `scenes.length > 0` would skip the discard, // the cold load would find nothing and leave the ghost in the store, and // the server-fallback gate (`!getCurrentStage()`) would never run — a // fully editable classroom whose every edit is silently dropped. useStageStore.getState().clearStore(); vi.mocked(saveStageDataIncremental).mockClear(); applyClassroomStageAndScenes(makeStage('stage-zero-ghost'), [], { persist: false }); // Settled-deleted world with the ghost left warm (eviction skipped by the // post-removal cascade failure). markStageDeleted('stage-zero-ghost'); try { const serverStage = makeStage('stage-zero-ghost'); const serverScene = makeScene('scene-server', 'stage-zero-ghost'); const { deps } = makeDeps({ classroomId: 'stage-zero-ghost', loadFromStorage: (id, token) => useStageStore.getState().loadFromStorage(id, token), getCurrentStage: () => useStageStore.getState().stage, fetchClassroom: vi.fn().mockResolvedValue({ stage: serverStage, scenes: [serverScene] }), applyFallbackScenes: vi.fn().mockImplementation(async ({ stage, scenes }) => { applyClassroomStageAndScenes(stage, scenes, { persist: false }); return true; }), }); await runClassroomLoad(deps); // The zero-scene ghost was discarded and the server restore ran. expect(deps.fetchClassroom).toHaveBeenCalledExactlyOnceWith('stage-zero-ghost'); expect(deps.applyFallbackScenes).toHaveBeenCalledOnce(); expect(useStageStore.getState().stage?.id).toBe('stage-zero-ghost'); expect(useStageStore.getState().scenes.map((s) => s.id)).toEqual(['scene-server']); expect(isStageDeleted('stage-zero-ghost')).toBe(false); // …and edits persist again end-to-end through the scheduler. useStageStore.getState().setCurrentSceneId('scene-server'); await flushStageSave(); expect(saveStageDataIncremental).toHaveBeenCalledWith( 'stage-zero-ghost', expect.arrayContaining([{ kind: 'currentScene' }]), expect.anything(), expect.any(Number), ); } finally { unmarkStageDeleted('stage-zero-ghost'); useStageStore.getState().clearStore(); } }); it('parks a mid-cascade Back until the deletion settles; a failed delete keeps the warm classroom editable', async () => { // Browser Back lands while the home-page delete is still mid-cascade. The // warm state (plus the deletion path's dirt snapshot) is the only copy of // the pre-delete edits — but completing the load before the outcome is // known would expose a classroom whose edits are refused and unrecorded. // The load must WAIT for settlement, then keep the warm state when the // delete failed (flag lifted, dirt restored). useStageStore.getState().clearStore(); vi.mocked(saveStageDataIncremental).mockClear(); vi.mocked(loadStageData).mockClear(); applyClassroomStageAndScenes( makeStage('stage-mid-delete'), [makeScene('scene-m', 'stage-mid-delete')], { persist: false }, ); // A pre-delete edit sitting in the debounce window. useStageStore.getState().setCurrentSceneId('scene-m'); try { // Same ordering as deleteStageData: snapshot → mark → begin → discard. const discarded = snapshotPendingStageChangesForDeletion('stage-mid-delete'); expect(discarded).toEqual([{ kind: 'currentScene' }]); markStageDeleted('stage-mid-delete'); beginStageDeletionCascade('stage-mid-delete'); discardPendingStageChanges('stage-mid-delete'); // Back lands mid-cascade: the load parks on settlement instead of // completing (no resolution, warm state untouched, no IndexedDB read). const token = claimStageSceneLoadToken(); let loadSettled = false; const loading = useStageStore .getState() .loadFromStorage('stage-mid-delete', token) .then(() => { loadSettled = true; }); await Promise.resolve(); await Promise.resolve(); expect(loadSettled).toBe(false); expect(useStageStore.getState().stage?.id).toBe('stage-mid-delete'); expect(loadStageData).not.toHaveBeenCalled(); // The cascade fails before removing the document — deleteStageData's // failure path lifts the flag, restores the dirt, then settles. unmarkStageDeleted('stage-mid-delete'); restorePendingStageChanges('stage-mid-delete', discarded); settleStageDeletionCascade('stage-mid-delete'); // The parked load resumes and keeps the warm (live again) classroom. await loading; expect(useStageStore.getState().stage?.id).toBe('stage-mid-delete'); expect(loadStageData).not.toHaveBeenCalled(); // The pre-delete edit reaches durability again through the scheduler. await flushStageSave(); expect(saveStageDataIncremental).toHaveBeenCalledWith( 'stage-mid-delete', expect.arrayContaining([{ kind: 'currentScene' }]), expect.anything(), expect.any(Number), ); } finally { settleStageDeletionCascade('stage-mid-delete'); unmarkStageDeleted('stage-mid-delete'); useStageStore.getState().clearStore(); } }); it('completes a mid-cascade Back with a cold server restore when the deletion succeeds', async () => { // Back during delete → the deletion succeeds. The parked load must // observe the settled outcome, let the settlement eviction stand, and run // the full cold load so the server-restore path recovers the route — // instead of leaving the user on a blanked classroom nothing reloads. useStageStore.getState().clearStore(); vi.mocked(loadStageData).mockClear(); applyClassroomStageAndScenes( makeStage('stage-del-success'), [makeScene('scene-s', 'stage-del-success')], { persist: false }, ); markStageDeleted('stage-del-success'); beginStageDeletionCascade('stage-del-success'); try { const serverStage = makeStage('stage-del-success'); const serverScene = makeScene('scene-server', 'stage-del-success'); const token = claimStageSceneLoadToken(); const { deps } = makeDeps({ classroomId: 'stage-del-success', loadToken: token, loadFromStorage: (id, t) => useStageStore.getState().loadFromStorage(id, t), getCurrentStage: () => useStageStore.getState().stage, fetchClassroom: vi.fn().mockResolvedValue({ stage: serverStage, scenes: [serverScene] }), applyFallbackScenes: vi.fn().mockImplementation(async ({ stage, scenes }) => { applyClassroomStageAndScenes(stage, scenes, { persist: false }); return true; }), }); const loading = runClassroomLoad(deps); await Promise.resolve(); await Promise.resolve(); // Parked: the classroom is not handed over mid-window. expect(deps.fetchClassroom).not.toHaveBeenCalled(); expect(useStageStore.getState().stage?.id).toBe('stage-del-success'); // The cascade completes: deleteStageData settles, then evicts — same // synchronous ordering as its finally + success tail. settleStageDeletionCascade('stage-del-success'); clearStoreForDeletedStage('stage-del-success'); expect(useStageStore.getState().stage).toBeNull(); await loading; // The parked load fell through to a cold load (finding no local data) // and the server fallback restored the classroom, lifting the deletion. expect(loadStageData).toHaveBeenCalledWith('stage-del-success'); expect(deps.fetchClassroom).toHaveBeenCalledExactlyOnceWith('stage-del-success'); expect(useStageStore.getState().stage?.id).toBe('stage-del-success'); expect(useStageStore.getState().scenes.map((s) => s.id)).toEqual(['scene-server']); expect(isStageDeleted('stage-del-success')).toBe(false); expect(deps.setLoading).toHaveBeenCalledWith(false); } finally { settleStageDeletionCascade('stage-del-success'); unmarkStageDeleted('stage-del-success'); useStageStore.getState().clearStore(); } }); it('does not land the parked load when navigation moves on during deletion settlement', async () => { // The user hits Back mid-cascade (load parks on settlement), then // navigates to another classroom before the deletion settles. When the // settlement releases the parked load, the token guard must keep it from // touching the store the newer navigation now owns. useStageStore.getState().clearStore(); vi.mocked(loadStageData).mockClear(); applyClassroomStageAndScenes( makeStage('stage-await-nav'), [makeScene('scene-n', 'stage-await-nav')], { persist: false }, ); markStageDeleted('stage-await-nav'); beginStageDeletionCascade('stage-await-nav'); try { const token = claimStageSceneLoadToken(); const loading = useStageStore.getState().loadFromStorage('stage-await-nav', token); // A newer navigation claims the token and owns the store. claimStageSceneLoadToken(); applyClassroomStageAndScenes( makeStage('stage-other'), [makeScene('scene-o', 'stage-other')], { persist: false }, ); // The deletion settles successfully (flag kept) — releasing the parked // load, which must now be a no-op. settleStageDeletionCascade('stage-await-nav'); await loading; expect(useStageStore.getState().stage?.id).toBe('stage-other'); expect(useStageStore.getState().scenes.map((s) => s.id)).toEqual(['scene-o']); expect(loadStageData).not.toHaveBeenCalled(); } finally { settleStageDeletionCascade('stage-await-nav'); unmarkStageDeleted('stage-await-nav'); useStageStore.getState().clearStore(); } }); it('cold-loads after a failed delete when the store was replaced during the park (no false keep-warm)', async () => { // Back parks on stage A's deletion settlement; while parked, a tokenless // store writer (a database import applying another classroom through // applyClassroomStageAndScenes) replaces the store contents WITHOUT // claiming the load token. When the deletion then FAILS, there is no warm // state of A left to keep — the keep-warm branch must re-verify store // identity (mirroring the success path) and fall through to the cold // load, instead of reporting the imported classroom as A's live state. useStageStore.getState().clearStore(); vi.mocked(loadStageData).mockClear(); const localStage = makeStage('stage-park-replaced'); const localScene = makeScene('scene-p', 'stage-park-replaced'); applyClassroomStageAndScenes(localStage, [localScene], { persist: false }); markStageDeleted('stage-park-replaced'); beginStageDeletionCascade('stage-park-replaced'); try { // The failed delete leaves A's document in place: the cold path re-reads it. vi.mocked(loadStageData).mockResolvedValueOnce({ stage: localStage, scenes: [localScene], currentSceneId: localScene.id, chats: [], }); const token = claimStageSceneLoadToken(); const loading = useStageStore.getState().loadFromStorage('stage-park-replaced', token); await Promise.resolve(); await Promise.resolve(); // Parked: no IndexedDB read yet. expect(loadStageData).not.toHaveBeenCalled(); // An import applies ANOTHER stage mid-park, leaving the token untouched. applyClassroomStageAndScenes( makeStage('stage-imported'), [makeScene('scene-i', 'stage-imported')], { persist: false }, ); // The deletion fails before removing the document: flag lifted, settled. unmarkStageDeleted('stage-park-replaced'); settleStageDeletionCascade('stage-park-replaced'); await loading; // Not a false keep-warm of the imported stage: the parked load fell // through to the cold path and handed the route back to A. expect(loadStageData).toHaveBeenCalledExactlyOnceWith('stage-park-replaced'); expect(useStageStore.getState().stage?.id).toBe('stage-park-replaced'); expect(useStageStore.getState().scenes.map((s) => s.id)).toEqual(['scene-p']); } finally { settleStageDeletionCascade('stage-park-replaced'); unmarkStageDeleted('stage-park-replaced'); useStageStore.getState().clearStore(); } }); it('does not evict a classroom restored (and unmarked) during the cascade tail', () => { // Cascade-tail race: deleteStageData succeeded, and before its synchronous // continuation ran the eviction, a same-id restore completed and unmarked // the deletion. The eviction must recognize the warm state as the RESTORED // classroom, not the deleted ghost. useStageStore.getState().clearStore(); markStageDeleted('stage-tail-restore'); try { applyClassroomStageAndScenes( makeStage('stage-tail-restore'), [makeScene('scene-t', 'stage-tail-restore')], { persist: false }, ); expect(isStageDeleted('stage-tail-restore')).toBe(false); clearStoreForDeletedStage('stage-tail-restore'); expect(useStageStore.getState().stage?.id).toBe('stage-tail-restore'); } finally { unmarkStageDeleted('stage-tail-restore'); useStageStore.getState().clearStore(); } }); it('keeps dropping in-flight writes for a deleted classroom that was NOT restored', async () => { useStageStore.getState().clearStore(); vi.mocked(saveStageDataIncremental).mockClear(); applyClassroomStageAndScenes( makeStage('stage-doomed'), [makeScene('scene-d', 'stage-doomed')], { persist: false, }, ); try { markStageDeleted('stage-doomed'); useStageStore.getState().setCurrentSceneId('scene-d'); await flushStageSave(); expect(saveStageDataIncremental).not.toHaveBeenCalled(); } finally { unmarkStageDeleted('stage-doomed'); useStageStore.getState().clearStore(); } }); it('resets caller-bound chat authority when fallback replaces the classroom', () => { useStageStore.setState({ stage: makeStage('stage-old'), chats: [], chatSnapshot: { sessions: [], restoreMarker: 'chat-restore-marker:stage-old:marker' }, }); applyClassroomStageAndScenes(makeStage('stage-new'), [], { persist: false }); expect(useStageStore.getState().chatSnapshot).toEqual({ sessions: [], restoreMarker: null, }); useStageStore.getState().clearStore(); }); it('commits runtime chats hydrated for a server fallback', () => { const hydratedChat = { id: 'runtime-chat', type: 'qa' as const, title: 'Runtime chat', status: 'completed' as const, messages: [], config: { agentIds: [] }, toolCalls: [], pendingToolCalls: [], createdAt: 1_000, updatedAt: 2_000, }; const chatSnapshot = { sessions: [hydratedChat], restoreMarker: null }; applyClassroomStageAndScenes(makeStage('stage-runtime-chat'), [], { persist: false, chats: [hydratedChat], chatSnapshot, }); expect(useStageStore.getState().chats).toEqual([hydratedChat]); expect(useStageStore.getState().chatSnapshot).toEqual(chatSnapshot); useStageStore.getState().clearStore(); }); it('does not run stale restore phases after a newer navigation wins', async () => { const loadStorage = deferred(); const { deps, setCurrent, setStage } = makeDeps({ loadFromStorage: vi.fn().mockReturnValue(loadStorage.promise), }); const loading = runClassroomLoad(deps); await vi.waitFor(() => expect(deps.loadFromStorage).toHaveBeenCalled()); setCurrent(false); setStage(makeStage('stage-b')); loadStorage.resolve(); await loading; expect(deps.fetchClassroom).not.toHaveBeenCalled(); expect(deps.applyFallbackScenes).not.toHaveBeenCalled(); expect(deps.loadRestoredMediaTasks).not.toHaveBeenCalled(); expect(deps.applyGeneratedAgents).not.toHaveBeenCalled(); expect(deps.setLoading).not.toHaveBeenCalled(); }); it('stops after fetch when the load is superseded', async () => { const fetched = deferred<{ stage: Stage; scenes: Scene[] } | null>(); const { deps, setCurrent } = makeDeps({ fetchClassroom: vi.fn().mockReturnValue(fetched.promise), }); const loading = runClassroomLoad(deps); await vi.waitFor(() => expect(deps.fetchClassroom).toHaveBeenCalled()); setCurrent(false); fetched.resolve({ stage: makeStage('stage-a'), scenes: [makeScene('scene-a', 'stage-a')] }); await loading; expect(deps.applyFallbackScenes).not.toHaveBeenCalled(); expect(deps.loadRestoredMediaTasks).not.toHaveBeenCalled(); expect(deps.applyGeneratedAgents).not.toHaveBeenCalled(); expect(deps.setLoading).not.toHaveBeenCalled(); }); it('stops after fallback apply when the load is superseded', async () => { const applied = deferred(); const { deps, setCurrent } = makeDeps({ fetchClassroom: vi.fn().mockResolvedValue({ stage: makeStage('stage-a', [makeAgentConfig('agent-a')]), scenes: [makeScene('scene-a', 'stage-a')], }), applyFallbackScenes: vi.fn().mockReturnValue(applied.promise), }); const loading = runClassroomLoad(deps); await vi.waitFor(() => expect(deps.applyFallbackScenes).toHaveBeenCalled()); setCurrent(false); applied.resolve(true); await loading; expect(deps.loadRestoredMediaTasks).not.toHaveBeenCalled(); expect(deps.applyGeneratedAgents).not.toHaveBeenCalled(); expect(deps.setLoading).not.toHaveBeenCalled(); }); it('does not apply media tasks when superseded after the media read', async () => { const mediaRead = deferred>(); const { deps, setCurrent, setStage } = makeDeps({ loadRestoredMediaTasks: vi.fn().mockReturnValue(mediaRead.promise), }); setStage(makeStage('stage-a')); const loading = runClassroomLoad(deps); await vi.waitFor(() => expect(deps.loadRestoredMediaTasks).toHaveBeenCalled()); setCurrent(false); mediaRead.resolve({ image: { elementId: 'image' } }); await loading; expect(deps.applyRestoredMediaTasks).not.toHaveBeenCalled(); expect(deps.discardRestoredMediaTasks).toHaveBeenCalledWith({ image: { elementId: 'image' }, }); expect(deps.applyGeneratedAgents).not.toHaveBeenCalled(); expect(deps.setLoading).not.toHaveBeenCalled(); }); it('hydrates the registry from document configs without consulting the mirror', async () => { const configs = [ makeAgentConfig('agent-a', { voiceDesign: { identity: 'warm mentor', texture: 'low', delivery: 'calm' }, }), ]; const { deps, settings, setStage } = makeDeps({ applyGeneratedAgents: vi.fn().mockReturnValue(['agent-a']), restoreAgentSelection: vi.fn().mockReturnValue({ selection: { mode: 'auto', selectedAgentIds: ['agent-a'] }, isUserSet: false, }), }); setStage(makeStage('stage-a', configs)); await runClassroomLoad(deps); expect(deps.loadLegacyAgentFallbacks).not.toHaveBeenCalled(); expect(deps.commitMigratedAgentConfigs).not.toHaveBeenCalled(); expect(deps.applyGeneratedAgents).toHaveBeenCalledExactlyOnceWith('stage-a', configs); expect(settings.setSelectedAgentIds).toHaveBeenCalledWith(['agent-a']); expect(deps.setLoading).toHaveBeenCalledWith(false); }); it('backfills missing voice fields from the legacy mirror and commits the merge', async () => { const configs = [makeAgentConfig('agent-a')]; const voiceDesign = { identity: 'bright', texture: 'clear', delivery: 'lively' }; const { deps, setStage } = makeDeps({ loadLegacyAgentFallbacks: vi .fn() .mockResolvedValue([makeAgentConfig('agent-a', { voiceDesign })]), applyGeneratedAgents: vi.fn().mockReturnValue(['agent-a']), }); setStage(makeStage('stage-a', configs)); await runClassroomLoad(deps); const merged = [makeAgentConfig('agent-a', { voiceDesign })]; expect(deps.commitMigratedAgentConfigs).toHaveBeenCalledExactlyOnceWith('stage-a', merged); expect(deps.applyGeneratedAgents).toHaveBeenCalledExactlyOnceWith('stage-a', merged); }); it('lifts a roster missing from the document entirely from the legacy mirror', async () => { const fallbacks = [ makeAgentConfig('gen-student', { role: 'student', priority: 5 }), makeAgentConfig('gen-teacher', { role: 'teacher', priority: 10 }), ]; const { deps, setStage } = makeDeps({ loadLegacyAgentFallbacks: vi.fn().mockResolvedValue(fallbacks), applyGeneratedAgents: vi.fn().mockReturnValue(['gen-teacher', 'gen-student']), }); // No roster field at all: the document predates roster persistence. setStage(makeStage('stage-a')); await runClassroomLoad(deps); const lifted = [fallbacks[1], fallbacks[0]]; // priority-desc, teacher first expect(deps.commitMigratedAgentConfigs).toHaveBeenCalledExactlyOnceWith('stage-a', lifted); expect(deps.applyGeneratedAgents).toHaveBeenCalledExactlyOnceWith('stage-a', lifted); }); it('treats an explicitly persisted empty roster as authoritative: stale mirror rows are not resurrected', async () => { // The mirror is never cleaned except on stage deletion, so a device can // hold stale rows for a stage whose document explicitly carries `[]`. // Collapsing absent and empty would lift the whole stale roster back on // every load (the merge reports `changed: true`, so not even the // fruitless-probe memo would stop it). The explicit `[]` must win. const { deps, setStage } = makeDeps({ loadLegacyAgentFallbacks: vi.fn().mockResolvedValue([makeAgentConfig('stale-mirror-row')]), applyGeneratedAgents: vi.fn().mockReturnValue([]), }); setStage(makeStage('stage-a', [])); await runClassroomLoad(deps); expect(deps.loadLegacyAgentFallbacks).not.toHaveBeenCalled(); expect(deps.commitMigratedAgentConfigs).not.toHaveBeenCalled(); expect(deps.applyGeneratedAgents).toHaveBeenCalledExactlyOnceWith('stage-a', []); }); it('remembers a fruitless mirror probe and skips it on later loads of the same classroom', async () => { // Server-generated classrooms have voiceless rosters by design: the probe // finds nothing to merge, and without the memo it would re-query the // mirror on every single load, forever. const { deps, setStage } = makeDeps({ loadLegacyAgentFallbacks: vi.fn().mockResolvedValue([]), applyGeneratedAgents: vi.fn().mockReturnValue(['agent-a']), }); setStage(makeStage('stage-a', [makeAgentConfig('agent-a')])); await runClassroomLoad(deps); expect(deps.loadLegacyAgentFallbacks).toHaveBeenCalledTimes(1); await runClassroomLoad(deps); expect(deps.loadLegacyAgentFallbacks).toHaveBeenCalledTimes(1); expect(deps.commitMigratedAgentConfigs).not.toHaveBeenCalled(); // The roster itself still hydrates the registry on every load. expect(deps.applyGeneratedAgents).toHaveBeenCalledTimes(2); }); it('does not memoize a FAILED mirror read: the next load retries the probe', async () => { // `null` = the read failed (vs. `[]` = mirror confirmed empty). A transient // IndexedDB error must not become a session-long migration skip. const { deps, setStage } = makeDeps({ loadLegacyAgentFallbacks: vi .fn() .mockResolvedValueOnce(null) .mockResolvedValue([] as GeneratedAgentConfig[]), applyGeneratedAgents: vi.fn().mockReturnValue(['agent-a']), }); setStage(makeStage('stage-a', [makeAgentConfig('agent-a')])); // First load: the read fails — nothing merged, nothing memoized. await runClassroomLoad(deps); expect(deps.loadLegacyAgentFallbacks).toHaveBeenCalledTimes(1); expect(deps.commitMigratedAgentConfigs).not.toHaveBeenCalled(); // Second load: retried; the read now succeeds empty — memoized. await runClassroomLoad(deps); expect(deps.loadLegacyAgentFallbacks).toHaveBeenCalledTimes(2); // Third load: the successful empty probe is remembered. await runClassroomLoad(deps); expect(deps.loadLegacyAgentFallbacks).toHaveBeenCalledTimes(2); }); it('still hydrates the registry from document configs when the mirror read fails', async () => { const configs = [makeAgentConfig('agent-a')]; const { deps, setStage } = makeDeps({ loadLegacyAgentFallbacks: vi.fn().mockResolvedValue(null), applyGeneratedAgents: vi.fn().mockReturnValue(['agent-a']), }); setStage(makeStage('stage-a', configs)); await runClassroomLoad(deps); expect(deps.applyGeneratedAgents).toHaveBeenCalledExactlyOnceWith('stage-a', configs); expect(deps.setLoading).toHaveBeenCalledWith(false); }); it('scopes the fruitless-probe memo per classroom', async () => { const first = makeDeps({ loadLegacyAgentFallbacks: vi.fn().mockResolvedValue([]) }); first.setStage(makeStage('stage-a', [makeAgentConfig('agent-a')])); await runClassroomLoad(first.deps); expect(first.deps.loadLegacyAgentFallbacks).toHaveBeenCalledTimes(1); const second = makeDeps({ classroomId: 'stage-b', loadLegacyAgentFallbacks: vi.fn().mockResolvedValue([]), }); second.setStage(makeStage('stage-b', [makeAgentConfig('agent-b')])); await runClassroomLoad(second.deps); expect(second.deps.loadLegacyAgentFallbacks).toHaveBeenCalledTimes(1); }); it('keeps probing until a successful merge is durably on the document (no memo on merges)', async () => { // A merge that changed the roster is committed dirty; if its flush failed, // the next load must probe and merge again rather than being memoized. // This harness never updates the document copy, standing in for exactly // that failed-flush case. const voiceDesign = { identity: 'bright', texture: 'clear', delivery: 'lively' }; const { deps, setStage } = makeDeps({ loadLegacyAgentFallbacks: vi .fn() .mockResolvedValue([makeAgentConfig('agent-a', { voiceDesign })]), applyGeneratedAgents: vi.fn().mockReturnValue(['agent-a']), }); setStage(makeStage('stage-a', [makeAgentConfig('agent-a')])); await runClassroomLoad(deps); await runClassroomLoad(deps); expect(deps.loadLegacyAgentFallbacks).toHaveBeenCalledTimes(2); expect(deps.commitMigratedAgentConfigs).toHaveBeenCalledTimes(2); }); it('does not commit or apply a merged roster when superseded during the mirror read', async () => { const fallbackRead = deferred(); const { deps, setCurrent, setStage } = makeDeps({ loadLegacyAgentFallbacks: vi.fn().mockReturnValue(fallbackRead.promise), }); setStage(makeStage('stage-a', [makeAgentConfig('agent-a')])); const loading = runClassroomLoad(deps); await vi.waitFor(() => expect(deps.loadLegacyAgentFallbacks).toHaveBeenCalled()); setCurrent(false); fallbackRead.resolve([ makeAgentConfig('agent-a', { voiceDesign: { identity: 'x', texture: 'y', delivery: 'z' }, }), ]); await loading; expect(deps.commitMigratedAgentConfigs).not.toHaveBeenCalled(); expect(deps.applyGeneratedAgents).not.toHaveBeenCalled(); expect(deps.setLoading).not.toHaveBeenCalled(); }); it('runs all phases and clears loading for the current navigation', async () => { const stage = makeStage('stage-a', [makeAgentConfig('agent-a')]); const scene = makeScene('scene-a', 'stage-a'); const mediaTasks = { image: { elementId: 'image' } }; const { deps, settings, setStage } = makeDeps({ fetchClassroom: vi.fn().mockResolvedValue({ stage, scenes: [scene] }), loadRestoredMediaTasks: vi.fn().mockResolvedValue(mediaTasks), applyGeneratedAgents: vi.fn().mockReturnValue(['agent-a']), restoreAgentSelection: vi.fn().mockReturnValue({ selection: { mode: 'auto', selectedAgentIds: ['agent-a'] }, isUserSet: false, }), }); const applyFallbackScenes = vi.fn().mockImplementation(async () => { // A committed fallback puts the fetched stage into the store. setStage(stage); return true; }); deps.applyFallbackScenes = applyFallbackScenes; await runClassroomLoad(deps); expect(deps.loadFromStorage).toHaveBeenCalledWith('stage-a', 1); expect(applyFallbackScenes).toHaveBeenCalledWith({ loadToken: 1, stage, scenes: [scene], }); expect(deps.applyRestoredMediaTasks).toHaveBeenCalledWith(mediaTasks); expect(deps.applyGeneratedAgents).toHaveBeenCalledWith('stage-a', stage.generatedAgentConfigs); expect(settings.setSelectedAgentIds).toHaveBeenCalledWith(['agent-a']); expect(deps.setLoading).toHaveBeenCalledWith(false); }); it('stops side effects when the component unmounts while loading', async () => { const loadStorage = deferred(); const { deps, setCurrent } = makeDeps({ loadFromStorage: vi.fn().mockReturnValue(loadStorage.promise), }); const loading = runClassroomLoad(deps); await vi.waitFor(() => expect(deps.loadFromStorage).toHaveBeenCalled()); setCurrent(false); loadStorage.resolve(); await loading; expect(deps.loadRestoredMediaTasks).not.toHaveBeenCalled(); expect(deps.applyGeneratedAgents).not.toHaveBeenCalled(); expect(deps.setError).not.toHaveBeenCalled(); expect(deps.setLoading).not.toHaveBeenCalled(); }); }); describe('rosterNeedsLegacyFallback', () => { it('is true when the document carries no roster field (it may predate roster persistence)', () => { expect(rosterNeedsLegacyFallback(undefined)).toBe(true); }); it('is false for an explicitly persisted empty roster (authoritative; never lifted)', () => { expect(rosterNeedsLegacyFallback([])).toBe(false); }); it('is true when any agent lacks both voice fields', () => { expect( rosterNeedsLegacyFallback([ makeAgentConfig('a', { voiceConfig: { providerId: 'p', voiceId: 'v' } }), makeAgentConfig('b'), ]), ).toBe(true); }); it('is false once every agent carries a voice field', () => { expect( rosterNeedsLegacyFallback([ makeAgentConfig('a', { voiceConfig: { providerId: 'p', voiceId: 'v' } }), makeAgentConfig('b', { voiceDesign: { identity: 'i', texture: 't', delivery: 'd' }, }), ]), ).toBe(false); }); }); describe('mergeLegacyAgentFallbacks', () => { const voiceDesign = { identity: 'i', texture: 't', delivery: 'd' }; const voiceConfig = { providerId: 'p', voiceId: 'v' }; it('backfills voice fields by id and reports the change', () => { const { configs, changed } = mergeLegacyAgentFallbacks( [makeAgentConfig('a'), makeAgentConfig('b')], [makeAgentConfig('a', { voiceDesign, voiceConfig })], ); expect(changed).toBe(true); expect(configs[0]).toEqual(makeAgentConfig('a', { voiceDesign, voiceConfig })); expect(configs[1]).toEqual(makeAgentConfig('b')); }); it('never overwrites document-held voice fields', () => { const docConfig = makeAgentConfig('a', { voiceDesign }); const { configs, changed } = mergeLegacyAgentFallbacks( [docConfig], [makeAgentConfig('a', { voiceDesign: { identity: 'x', texture: 'y', delivery: 'z' } })], ); expect(changed).toBe(false); expect(configs[0]).toBe(docConfig); }); it('is idempotent: a second merge over the committed result changes nothing', () => { const fallbacks = [makeAgentConfig('a', { voiceDesign })]; const first = mergeLegacyAgentFallbacks([makeAgentConfig('a')], fallbacks); expect(first.changed).toBe(true); const second = mergeLegacyAgentFallbacks(first.configs, fallbacks); expect(second.changed).toBe(false); expect(second.configs).toEqual(first.configs); }); it('lifts a full roster when the document has none, priority-descending', () => { const { configs, changed } = mergeLegacyAgentFallbacks( [], [makeAgentConfig('low', { priority: 3 }), makeAgentConfig('high', { priority: 9 })], ); expect(changed).toBe(true); expect(configs.map((c) => c.id)).toEqual(['high', 'low']); }); it('reports no change when both sides are empty', () => { expect(mergeLegacyAgentFallbacks([], [])).toEqual({ configs: [], changed: false }); }); }); describe('commitMigratedAgentConfigsToStore', () => { it('commits onto the matching stage and leaves a different stage untouched', () => { const configs = [makeAgentConfig('agent-a')]; useStageStore.setState({ stage: makeStage('stage-a') }); commitMigratedAgentConfigsToStore('stage-a', configs); expect(useStageStore.getState().stage?.generatedAgentConfigs).toEqual(configs); // Simulate a classroom switch racing the commit: the stale commit no-ops. useStageStore.setState({ stage: makeStage('stage-b') }); commitMigratedAgentConfigsToStore('stage-a', [makeAgentConfig('stale')]); expect(useStageStore.getState().stage?.id).toBe('stage-b'); expect(useStageStore.getState().stage?.generatedAgentConfigs).toBeUndefined(); useStageStore.getState().clearStore(); }); }); describe('discardRestoredMediaTasks', () => { it('revokes restored media URLs that never enter the store', () => { const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined); discardRestoredMediaTasks({ image: { elementId: 'image', type: 'image', status: 'done', prompt: 'image', params: {}, objectUrl: 'blob:image', poster: 'blob:poster', retryCount: 0, stageId: 'stage-a', }, }); expect(revokeObjectURL).toHaveBeenCalledWith('blob:image'); expect(revokeObjectURL).toHaveBeenCalledWith('blob:poster'); revokeObjectURL.mockRestore(); }); });