Files
adminandClaude Opus 5.5 7e829f737e
game-maker / gate (push) Successful in 3m26s
game-maker / suite (push) Failing after 9m6s
Load every picture of a project ahead so animated tiles don't flicker
A texture was fetched only when it was drawn for the first time. Until
the file arrived, the tilemap skipped the cell for that frame. An
animated tile whose next step sat in another picture therefore blinked
away once on its first cycle, and sprite animations did the same.

- FileLibrary gets preload(files). It requests every file that is not
  held, loading or known to have failed. Each library reports what it
  holds through holds().
- src/core/model/projectFiles.ts lists the files a project names per
  library: uploaded textures, fonts and pixel art cels.
- App.ts runs the preload on every project event. That covers opening
  a project, new assets and edits from a collaborator.
- A new test hook __gm.heldPictures() reports which textures of the
  project are in memory.

Not done on purpose:
- The tilemap keeps no fallback frame while a step is still loading.
- Sounds still load on first play.

Not checked: the flicker itself was not reproduced in the browser, the
e2e check measures "held in memory". Modules 42, 44 and 47 read
textures from the project and were not run locally.

Tests:
- ./check.sh: 174 files, 2518 tests, CHECK EXIT 0
- e2e 39 + 40: ALL PASSED, 96 checks
- 3 new unit tests for preload and the project file list
- New e2e check 40k-ii: an unplaced picture is held after a reload
- Counter-probe: without the preload 40k-ii fails with
  "Autotiles: held false, unworn: held false"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 23:50:49 +02:00

89 lines
2.9 KiB
TypeScript

/**
* The base of the picture and font libraries: a file asked for is fetched once, and the listeners
* hear when it lands. The libraries are module singletons and outlive an editor that was signed out
* of, so a listener can belong to a widget that is gone (#196).
*/
import { describe, expect, it, vi } from 'vitest';
import { FileLibrary } from '../../src/viewport/FileLibrary';
/** A library whose loads a test resolves by hand. */
class HandLibrary extends FileLibrary<string> {
readonly kept = new Map<string, string>();
private readonly pending: (() => void)[] = [];
constructor() {
super('/files', 'test');
}
ask(file: string): void {
if (!this.kept.has(file)) this.startLoading(file);
}
land(): Promise<void> {
for (const resolve of this.pending.splice(0)) resolve();
return new Promise((resolve) => setTimeout(resolve, 0));
}
protected fetchFile(url: string): Promise<string> {
return new Promise((resolve) => this.pending.push(() => resolve(url)));
}
protected keep(file: string, loaded: string): void {
this.kept.set(file, loaded);
}
protected holds(file: string): boolean {
return this.kept.has(file);
}
get waiting(): number {
return this.pending.length;
}
}
describe('the file library', () => {
it('tells every listener when a file lands, even after one of them threw', async () => {
const library = new HandLibrary();
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
// First in line, as the canvas of an editor signed out of is: its graphics are destroyed.
library.onChange(() => {
throw new TypeError("Cannot read properties of null (reading 'clear')");
});
const later = vi.fn();
library.onChange(later);
library.ask('a.png');
await library.land();
expect(library.kept.get('a.png')).toBe('/files/a.png');
expect(later).toHaveBeenCalledTimes(1);
// Not swallowed: the broken listener still shows in the console.
expect(error).toHaveBeenCalledWith('[test] a listener failed', expect.any(TypeError));
error.mockRestore();
});
it('forgets a listener once its unsubscribe has run', async () => {
const library = new HandLibrary();
const gone = vi.fn();
const stop = library.onChange(gone);
stop();
library.ask('a.png');
await library.land();
expect(gone).not.toHaveBeenCalled();
});
it('preloads every file once, before anything draws with it', async () => {
const library = new HandLibrary();
library.preload(['a.png', 'b.png', 'a.png', '']);
expect(library.waiting).toBe(2);
// Asked again while on its way, and again once it is held: no second fetch either time.
library.preload(['a.png', 'b.png']);
expect(library.waiting).toBe(2);
await library.land();
expect([...library.kept.keys()]).toEqual(['a.png', 'b.png']);
library.preload(['a.png', 'b.png', 'c.png']);
expect(library.waiting).toBe(1);
});
});