Files
feuerwerk/tests/viewport/generated-pictures.test.ts
T
adminandClaude Opus 5 cb1804dcf2 A picture in the editor may now be drawn instead of uploaded, which is the piece #129 (Vector art) and #130 (Pixel editor) both need and neither could build alone: TextureRef.file has been a file name on the server, isTextureFile() checking a sha256 hash plus an extension and TextureLibrary fetching it over HTTP, so an asset that paints its own image had nowhere to put itself - the key now also takes the form gen:<assetId>@<revision>, which an uploaded file can never look like, and everything that only passes a ref along (sprite, tile, tilemap mesh, particle source, shaders, aspect ratio) is untouched because it still sees a Texture; the revision is a string, not a counter, which is the one deviation from the plan in the issue and costs nothing - gen:a1@7 and gen:a1@c0ffee go through the same door, so a generator may fingerprint its own content instead of relying on every command to remember to bump a number; two registries keyed by asset type as a plain string, so a plugin may bring its own type - registerPictureSource(type, read) in the new core/model/generated.ts tells the model canvas size and revision and resolvePicture() builds the ref from it (no rect, no slice, smooth from props.filter like a texture), and registerPictureGenerator(type, draw) in the new viewport/generatedPictures.ts hands out the finished texture, projectPictureFactory(() => state.project) tying the two together per session in App.ts; invalidation is a rule instead of guesswork at twenty call sites: GeneratedPictures<T> holds one picture per asset keyed on the revision and frees the old one as soon as the new is drawn, TextureLibrary destroying it and dropping the crops cut from it, while a generator that cannot draw yet is not remembered but asked again next time - the class imports Pixi as a type only, so the whole bookkeeping is measurable without a GPU; carried along are the picture picker (a drawn asset sits among the textures by name, without frames), the delete warning (counts tiles and animations for it too) and the asset context menu, where "Create sprite from picture" now covers it while "Create animation from texture" stays texture-only because it needs frames; one finding while probing - crop() could not have cut a generated texture at all, a RenderTexture having no source.resource to draw back from and drawImage running onto null, so it is handed out whole, a drawn picture having no sheet whose neighbouring row could bleed in; 18 new unit tests, 10 in tests/core/generated-picture.test.ts (building and splitting the key, an uploaded file is not one, no registry means no picture, resolving to canvas size, a new revision is a new key, a canvas without a size, the filter, the sprite size, picker order) and 8 in tests/viewport/generated-pictures.test.ts (drawing once, freeing the old revision and only for its own asset, null is not remembered, no key means nothing drawn, a session change gives everything back, peek), and three counter-checks hold: dropping generated assets from resolvePicture fails 5 of the 10 core tests, not freeing the old revision fails 1 viewport test, and narrowing the picker back to textures fails 3; no e2e check and the check sum stays 1157, because without a generator the groundwork shows nothing in the browser - that evidence comes with #129 (#185)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 20:09:42 +02:00

128 lines
4.3 KiB
TypeScript

import type { Texture } from 'pixi.js';
import { afterEach, describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { registerPictureSource } from '../../src/core/model/generated';
import {
GeneratedPictures,
projectPictureFactory,
registerPictureGenerator,
} from '../../src/viewport/generatedPictures';
import { makeSpriteScene } from '../helpers';
/** The pictures are plain strings here: what is measured is who is kept and who is let go. */
function library() {
const freed: string[] = [];
const drawn: string[] = [];
const pictures = new GeneratedPictures<string>((picture) => freed.push(picture));
let answer: (assetId: string) => string | null = (assetId) => assetId;
pictures.setFactory((assetId) => {
drawn.push(assetId);
return answer(assetId);
});
return {
pictures,
freed,
drawn,
answerWith: (fn: (assetId: string) => string | null) => {
answer = fn;
},
};
}
describe('generated pictures kept in memory', () => {
it('draws a key once and hands the same picture out again', () => {
const f = library();
f.answerWith(() => 'first');
expect(f.pictures.get('gen:a@1')).toBe('first');
expect(f.pictures.get('gen:a@1')).toBe('first');
expect(f.drawn).toEqual(['a']);
expect(f.freed).toEqual([]);
});
it('lets the old revision go the moment the new one is drawn', () => {
const f = library();
f.answerWith((id) => `${id}-one`);
f.pictures.get('gen:a@1');
f.answerWith((id) => `${id}-two`);
expect(f.pictures.get('gen:a@2')).toBe('a-two');
expect(f.freed).toEqual(['a-one']);
expect(f.pictures.keys()).toEqual(['gen:a@2']);
// Only that asset's own older picture goes; a neighbour keeps hers.
f.answerWith((id) => `${id}-b`);
f.pictures.get('gen:b@1');
f.pictures.get('gen:a@3');
expect(f.freed).toEqual(['a-one', 'a-two']);
expect(f.pictures.keys()).toEqual(['gen:b@1', 'gen:a@3']);
});
it('asks again for a picture that could not be drawn yet', () => {
const f = library();
f.answerWith(() => null);
expect(f.pictures.get('gen:a@1')).toBeNull();
expect(f.pictures.has('gen:a@1')).toBe(false);
f.answerWith(() => 'ready');
expect(f.pictures.get('gen:a@1')).toBe('ready');
expect(f.drawn).toEqual(['a', 'a']);
});
it('draws nothing for a name that is not a key', () => {
const f = library();
expect(f.pictures.get(`${'ab'.repeat(32)}.png`)).toBeNull();
expect(f.pictures.get('gen:a')).toBeNull();
expect(f.drawn).toEqual([]);
});
it('gives everything back when another session takes over', () => {
const f = library();
f.pictures.get('gen:a@1');
f.pictures.get('gen:b@1');
f.pictures.setFactory(null);
expect(f.freed).toEqual(['a', 'b']);
expect(f.pictures.keys()).toEqual([]);
expect(f.pictures.get('gen:a@1')).toBeNull();
});
it('answers peek only with what has been drawn', () => {
const f = library();
expect(f.pictures.peek('gen:a@1')).toBeNull();
f.pictures.get('gen:a@1');
expect(f.pictures.peek('gen:a@1')).toBe('a');
expect(f.drawn).toEqual(['a']);
});
});
describe('the factory the editor runs with', () => {
const registered: (() => void)[] = [];
afterEach(() => {
for (const undo of registered.splice(0)) undo();
});
it('hands an asset to the generator of its type and nothing else to anyone', () => {
const f = makeSpriteScene();
const sound = new CreateAssetCommand('sound', 'Beep');
f.stack.execute(sound);
registered.push(registerPictureSource('sound', () => ({ width: 8, height: 8, revision: 1 })));
const seen: string[] = [];
const picture = {} as Texture;
registered.push(
registerPictureGenerator('sound', (asset) => {
seen.push(asset.name);
return picture;
}),
);
const factory = projectPictureFactory(() => f.project);
expect(factory(sound.asset.id)).toBe(picture);
expect(seen).toEqual(['Beep']);
// A sprite has no generator, and an id that is gone has no asset.
expect(factory(f.spriteId)).toBeNull();
expect(factory('nobody')).toBeNull();
expect(seen).toEqual(['Beep']);
});
it('draws nothing while no project is open', () => {
const factory = projectPictureFactory(() => null);
expect(factory('a')).toBeNull();
});
});