/** * What has to let go when a session ends (#177): signing out takes the editor down while the page * stays up, so everything it registered outside itself needs a way back – the autosave's timer and * its `beforeunload` listener, and the handlers panels push into the focus manager. A handler left * behind would go on typing into an editor nobody can see. * * The editor as a whole is torn down in the browser (e2e step 45); the pieces are here. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; import { createEmptyProject, type Project } from '../../src/core/model/Project'; import type { ProjectStore } from '../../src/core/store/ProjectStore'; import { attachAutosave } from '../../src/editor/autosave'; import { EditorState } from '../../src/editor/state'; /** Just enough `window` for the two modules under test; vitest runs these in node. */ function fakeWindow() { let next = 1; const timers = new Map void>(); const listeners: [string, unknown][] = []; return { listeners, /** Runs every timer that is still scheduled, newest first, as the browser eventually would. */ fire: () => { for (const run of [...timers.values()]) run(); timers.clear(); }, setTimeout: (fn: () => void) => { const id = next++; timers.set(id, fn); return id; }, clearTimeout: (id: number) => void timers.delete(id), addEventListener: (type: string, fn: unknown) => void listeners.push([type, fn]), removeEventListener: (type: string, fn: unknown) => { const index = listeners.findIndex(([t, f]) => t === type && f === fn); if (index >= 0) listeners.splice(index, 1); }, }; } function savingStore(saves: Project[]): ProjectStore { return { list: () => Promise.resolve([]), load: () => Promise.reject(new Error('not used')), save: (project, revision) => { saves.push(project); return Promise.resolve(revision + 1); }, create: () => Promise.reject(new Error('not used')), delete: () => Promise.resolve(), }; } describe('autosave', () => { let win: ReturnType; beforeEach(() => { win = fakeWindow(); vi.stubGlobal('window', win); }); afterEach(() => vi.unstubAllGlobals()); const dirtyState = () => { const state = new EditorState(); state.loadProject(createEmptyProject('p1', 'Teardown'), 1); state.execute(new CreateAssetCommand('sprite', 'Crate')); return state; }; it('writes what is pending when it is flushed', async () => { const saves: Project[] = []; const state = dirtyState(); const autosave = attachAutosave(state, savingStore(saves)); expect(state.saveStatus).toBe('dirty'); await autosave.flush(); expect(saves).toHaveLength(1); expect(state.saveStatus).toBe('saved'); expect(state.revision).toBe(2); }); it('stops for good: no pending save fires, and the page listener is gone', async () => { const saves: Project[] = []; const state = dirtyState(); const autosave = attachAutosave(state, savingStore(saves)); expect(win.listeners.map(([type]) => type)).toEqual(['beforeunload']); autosave.stop(); win.fire(); await Promise.resolve(); expect(saves).toEqual([]); expect(win.listeners).toEqual([]); }); it('a change after the stop schedules nothing any more', async () => { const saves: Project[] = []; const state = dirtyState(); const autosave = attachAutosave(state, savingStore(saves)); await autosave.flush(); autosave.stop(); state.execute(new CreateAssetCommand('sprite', 'Box')); win.fire(); await Promise.resolve(); expect(saves).toHaveLength(1); }); }); describe('focus manager handlers', () => { beforeEach(() => vi.stubGlobal('window', fakeWindow())); afterEach(() => vi.unstubAllGlobals()); it('hands back the call that removes a global key handler', async () => { const { FocusManager } = await import('../../src/ui/FocusManager'); const { Container } = await import('pixi.js'); const focus = new FocusManager(new Container()); const seen: string[] = []; const remove = focus.onGlobalKey((event) => { seen.push(event.key); return false; }); expect(focus.globalHandlers).toHaveLength(1); remove(); expect(focus.globalHandlers).toEqual([]); // A second call is harmless – a widget may be destroyed after the editor already let go. remove(); expect(seen).toEqual([]); }); it('hands back the call that removes a focus-change handler', async () => { const { FocusManager } = await import('../../src/ui/FocusManager'); const { Container } = await import('pixi.js'); const focus = new FocusManager(new Container()); let runs = 0; const remove = focus.onFocusChange(() => runs++); const other = focus.onFocusChange(() => runs++); expect(focus.changeHandlers).toHaveLength(2); remove(); expect(focus.changeHandlers).toHaveLength(1); other(); expect(focus.changeHandlers).toEqual([]); expect(runs).toBe(0); }); });