148 lines
5.1 KiB
TypeScript
148 lines
5.1 KiB
TypeScript
/**
|
||
* 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<number, () => 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<typeof fakeWindow>;
|
||
|
||
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);
|
||
});
|
||
});
|