Three stores, three importers and three pickers that differ only in their type are one of each (#85)
game-maker / gate (push) Successful in 1m33s
game-maker / suite (push) Successful in 5m46s

Refactoring round one: HttpFontStore/HttpSoundStore/HttpTextureStore collapse into a generic
HttpFileStore over a FileStore contract; fontImport/soundImport/textureImport share assetImport
with an ImportKind per sort; ImagePicker/FontPicker/SoundPicker sit on FilePicker and describe
only their row. The group/comment drag logic leaves GraphCanvas for annotationDrag.ts (with its
own unit test), declaredRows.ts replaces the duplicated row build in particles.ts and shaders.ts,
and dead code in font/sound/compile/emission/transform/actions is gone. README: asset list,
check count 958, the two-job CI, the Scenes wiki page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Fable 5.1 committed 2026-09-16 17:38:54 +02:00
1 parent 15bdfdf07a
commit cc6db5f350
42 files changed
+1375 -1650

No files matched your search

+8 -8
View File
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { HttpProjectStore } from '../../src/core/store/HttpProjectStore';
import { HttpTextureStore } from '../../src/core/store/HttpTextureStore';
import { HttpFileStore } from '../../src/core/store/HttpFileStore';
import { StaleRevisionError } from '../../src/core/store/ProjectStore';
import { makeProject } from '../helpers';
@@ -46,11 +46,11 @@ describe('HttpProjectStore', () => {
});
});
describe('HttpTextureStore', () => {
describe('HttpFileStore', () => {
it('uploads the raw bytes and returns what the server stored', async () => {
const mock = stubFetch(JSON.stringify({ file: FILE, width: 2, height: 3, bytes: 4 }), { status: 201 });
const bytes = new Blob([new Uint8Array([1, 2, 3, 4])]);
const stored = await new HttpTextureStore('/api').upload(bytes, 'image/png');
const stored = await new HttpFileStore('/api', 'textures').upload(bytes, 'image/png');
expect(stored).toEqual({ file: FILE, width: 2, height: 3, bytes: 4 });
const [url, init] = mock.mock.calls[0]!;
@@ -66,19 +66,19 @@ describe('HttpTextureStore', () => {
const bytes = new Blob([new Uint8Array(4)]);
let mock = stubFetch(answer, { status: 201 });
await new HttpTextureStore('/api').upload(bytes, 'image/png', 'hero idle.png');
await new HttpFileStore('/api', 'textures').upload(bytes, 'image/png', 'hero idle.png');
// The body stays the raw image, so the name travels in the query – encoded, not as a path.
expect(mock.mock.calls[0]![0]).toBe('/api?r=textures&name=hero%20idle.png');
mock = stubFetch(answer, { status: 201 });
await new HttpTextureStore('/api').upload(bytes, 'image/png');
await new HttpFileStore('/api', 'textures').upload(bytes, 'image/png');
expect(mock.mock.calls[0]![0]).toBe('/api?r=textures');
});
it('reports the nginx size limit as a size problem', async () => {
stubFetch(NGINX_413, { status: 413, statusText: 'Request Entity Too Large' });
const bytes = new Blob([new Uint8Array(8)]);
await expect(new HttpTextureStore('/api').upload(bytes, 'image/png')).rejects.toThrow(
await expect(new HttpFileStore('/api', 'textures').upload(bytes, 'image/png')).rejects.toThrow(
/too large \(413\)/i,
);
});
@@ -87,12 +87,12 @@ describe('HttpTextureStore', () => {
stubFetch(JSON.stringify([{ file: FILE, width: 8, height: 4, bytes: 12, updatedAt: 'now' }]), {
status: 200,
});
await expect(new HttpTextureStore('/api').list()).resolves.toEqual([
await expect(new HttpFileStore('/api', 'textures').list()).resolves.toEqual([
{ file: FILE, width: 8, height: 4, bytes: 12, updatedAt: 'now' },
]);
const mock = stubFetch(null, { status: 204 });
await new HttpTextureStore('/api').delete(FILE);
await new HttpFileStore('/api', 'textures').delete(FILE);
expect(mock.mock.calls[0]![0]).toBe(`/api?r=${encodeURIComponent(`textures/${FILE}`)}`);
expect(mock.mock.calls[0]![1]?.method).toBe('DELETE');
});
+13 -9
View File
@@ -8,13 +8,15 @@ import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/comm
import { AddNodeCommand, ConnectCommand, MoveNodeCommand } from '../../src/core/commands/graphCommands';
import { isPlaceableType } from '../../src/core/model/AssetTypes';
import { findAssetOf } from '../../src/core/model/Project';
import { graphFingerprint } from '../../src/core/graph/Graph';
import { compileParticles, emptyParticleOut, type ParticleOut } from '../../src/core/particles/compile';
import {
compileParticles,
emptyParticleOut,
type ParticleOut,
particlesFingerprint,
} from '../../src/core/particles/compile';
import { birthTime, fieldTime, liveCount, liveRange, prewarmOffset } from '../../src/core/particles/emission';
birthTime,
fieldTime,
type LiveRange,
liveRange,
prewarmOffset,
} from '../../src/core/particles/emission';
import {
hashId,
mixColor,
@@ -222,11 +224,11 @@ describe('a particle asset', () => {
const output = p.add(PARTICLE_OUTPUT);
const n = p.add('ParticleNumber', { value: 2 });
p.link(n, 'value', output, 'x');
const before = particlesFingerprint(p.asset().graph);
const before = graphFingerprint(p.asset().graph);
f.stack.execute(new MoveNodeCommand(p.id, n, 120, 80));
expect(particlesFingerprint(p.asset().graph)).toBe(before);
expect(graphFingerprint(p.asset().graph)).toBe(before);
f.stack.execute(new AddNodeCommand(p.id, 'ParticleNumber', 0, 0, { value: 5 }));
expect(particlesFingerprint(p.asset().graph)).not.toBe(before);
expect(graphFingerprint(p.asset().graph)).not.toBe(before);
});
it('survives a round trip through the project file, graph and rows intact', () => {
@@ -270,6 +272,8 @@ describe('a particle asset', () => {
});
});
const liveCount = (range: LiveRange): number => Math.max(0, range.to - range.from + 1);
describe('when particles are alive', () => {
const settings = {
mode: 'continuous' as const,
+6 -5
View File
@@ -34,7 +34,8 @@ import {
shaderParamDefs,
} from '../../src/core/properties/shaders';
import { inlineInputs } from '../../src/core/graph/NodeRegistry';
import { compileShader, shaderFingerprint } from '../../src/core/shader/compile';
import { graphFingerprint } from '../../src/core/graph/Graph';
import { compileShader } from '../../src/core/shader/compile';
import {
DEFAULT_EDGE,
EDGE_PARAM,
@@ -720,16 +721,16 @@ describe('the fingerprint the shader library caches on', () => {
const f = makeProject();
const s = shaderEditor(f);
const n = buildAdjustColors(s);
const before = shaderFingerprint(s.asset().graph);
const before = graphFingerprint(s.asset().graph);
f.stack.execute(new MoveNodeCommand(s.id, n.bright, 300, 200));
f.stack.execute(new CollapseNodesCommand(s.id, [n.bright, n.luma], true));
f.stack.execute(new FoldEdgeCommand(s.id, s.asset().graph.edges[0]!.id, true));
expect(s.asset().graph.nodes.find((x) => x.id === n.bright)).toMatchObject({ x: 300, collapsed: true });
expect(shaderFingerprint(s.asset().graph)).toBe(before);
expect(graphFingerprint(s.asset().graph)).toBe(before);
f.stack.execute(new SetNodeParamCommand(s.id, n.shifted, 'b', 0.25));
const withParam = shaderFingerprint(s.asset().graph);
const withParam = graphFingerprint(s.asset().graph);
expect(withParam).not.toBe(before);
s.link(n.brightness, 'value', n.output, 'alpha');
expect(shaderFingerprint(s.asset().graph)).not.toBe(withParam);
expect(graphFingerprint(s.asset().graph)).not.toBe(withParam);
});
});
+88
View File
@@ -0,0 +1,88 @@
/**
* A drag in the graph moves everything it carries exactly once. A group carries the nodes centred
* in it and the boxes inside it; a band may have caught a group *and* one of its nodes – that node
* is claimed twice and must move once, or it would end up twice as far as the rest.
*/
import { describe, expect, it } from 'vitest';
import type { Graph, GraphNode } from '../../src/core/graph';
import {
annotationBoxes,
boxSeeds,
placeTravellers,
recordTravellers,
travellers,
} from '../../src/editor/graph/annotationDrag';
import type { Box } from '../../src/editor/framing';
import { makeBehavior, makeProject } from '../helpers';
const node = (id: string, x: number, y: number): GraphNode => ({ id, type: 'Add', x, y, params: {} });
const NODE = { w: 100, h: 50 };
const drawn = (graph: Graph): Box[] => graph.nodes.map((n) => ({ id: n.id, x: n.x, y: n.y, ...NODE }));
/** An outer group holding an inner group and a comment; `in` sits in both groups, `far` in neither. */
function graph(): Graph {
return {
nodes: [node('in', 120, 120), node('edge', 20, 20), node('far', 900, 900)],
edges: [],
groups: [
{ id: 'outer', x: 0, y: 0, w: 400, h: 300, text: 'Outer' },
{ id: 'inner', x: 100, y: 100, w: 150, h: 100, text: 'Inner' },
],
notes: [{ id: 'note', x: 300, y: 200, w: 80, h: 60, text: 'Careful' }],
};
}
describe('what a group drag carries', () => {
it('takes the nodes centred in it, the boxes inside it, and their nodes – each once', () => {
const g = graph();
const along = travellers(g, drawn(g), boxSeeds(g, ['outer']), []);
expect(along.boxes.map((b) => b.id).sort()).toEqual(['inner', 'note', 'outer']);
// `edge` is centred at (70, 45), inside; `in` is inside both groups but listed once; `far` is not.
expect(along.nodes.map((n) => n.nodeId).sort()).toEqual(['edge', 'in']);
});
it('claims a node once even when the band caught both it and its group', () => {
const g = graph();
const along = travellers(g, drawn(g), boxSeeds(g, ['outer']), ['in', 'far']);
expect(along.nodes.map((n) => n.nodeId).sort()).toEqual(['edge', 'far', 'in']);
placeTravellers(g, along, () => ({ x: 10, y: 0 }));
expect(g.nodes.map((n) => n.x)).toEqual([130, 30, 910]);
expect(g.groups?.map((b) => b.x)).toEqual([10, 110]);
expect(g.notes?.map((b) => b.x)).toEqual([310]);
});
it('carries nothing along for a comment, which frames nothing', () => {
const g = graph();
const along = travellers(g, drawn(g), boxSeeds(g, ['note']), []);
expect(along.boxes.map((b) => b.id)).toEqual(['note']);
expect(along.nodes).toEqual([]);
});
it('leaves out the grabbed box when asked, so it is not seeded twice', () => {
const g = graph();
expect(boxSeeds(g, ['outer', 'note'], 'outer').map((b) => b.id)).toEqual(['note']);
expect(annotationBoxes(g).map((b) => b.id)).toEqual(['outer', 'inner', 'note']);
});
});
describe('writing a drag back', () => {
it('records one command per traveller from the start to where the preview left it', () => {
const f = makeProject();
const behavior = makeBehavior(f, 'B');
const g = behavior.graph;
g.nodes.push(node('a', 0, 0));
g.groups = [{ id: 'grp', x: -50, y: -50, w: 200, h: 200, text: 'G' }];
const along = travellers(g, drawn(g), boxSeeds(g, ['grp']), []);
placeTravellers(g, along, () => ({ x: 24, y: 48 }));
const commands = recordTravellers(g, behavior.id, along);
// The preview is undone in the model, so the commands own the whole change…
expect(g.nodes[0]).toMatchObject({ x: 0, y: 0 });
expect(g.groups[0]).toMatchObject({ x: -50, y: -50 });
// …and running them lands everything where the drag ended.
for (const command of commands) command.execute(f.project);
expect(g.nodes[0]).toMatchObject({ x: 24, y: 48 });
expect(g.groups[0]).toMatchObject({ x: -26, y: -2, w: 200, h: 200 });
expect(commands).toHaveLength(2);
});
});
+1 -1
View File
@@ -8,7 +8,7 @@ import { createEmptyProject } from '../../src/core/model/Project';
import type { FontStore } from '../../src/core/store/FontStore';
import { importFonts, importStoredFont, isFontFileName } from '../../src/editor/fontImport';
import { EditorState } from '../../src/editor/state';
import { importNotice } from '../../src/editor/textureImport';
import { importNotice } from '../../src/editor/assetImport';
const FILE = `${'a'.repeat(64)}.ttf`;
const font = { file: FILE, name: '', bytes: 380_660 };
+1 -3
View File
@@ -3,11 +3,9 @@ import type { TextureAsset } from '../../src/core/model/Asset';
import { createEmptyProject } from '../../src/core/model/Project';
import type { TextureStore } from '../../src/core/store/TextureStore';
import { EditorState } from '../../src/editor/state';
import { assetNameForFile, type ImportOutcome, importNotice } from '../../src/editor/assetImport';
import {
assetNameForFile,
encodeWithinBudget,
type ImportOutcome,
importNotice,
importStoredImage,
MAX_TEXTURE_SIDE,
MAX_UPLOAD_BYTES,