Files
feuerwerk/tests/editor/frame-preview.test.ts
T
adminandClaude Opus 5.5 d6171fd386
game-maker / gate (push) Successful in 2m37s
game-maker / suite (push) Successful in 13m24s
Preview pixel art and curve fields once per frame too (#274)
The previous commit left two findings open: in the pixel art tab the
palette colour and the opacity of a layer still told the editor about
every input, and so did the place fields of the curve tab. They now
follow the same rule as the inspector rows.

- PixelArtSide writes colour and opacity into the model on every
  input and requests the preview through a FramePreview. Both commits
  cancel a waiting preview first.
- CurveCanvas.movePicked does the same for the t and v fields of the
  picked point. Showing another curve cancels a waiting preview.
- Also fixed: when the colour picker ended on the colour it started
  from, the model was put back but nobody was told, so the previewed
  colour stayed on screen. commitColor now says so, as the opacity
  already did.

The gain was not measured for either tab, only the behaviour is
tested.

Tests:
- Gate: 198 files, 2911 tests (2907 + 4), exit 0
- e2e 47,48,53: 117 checks passed
- Four new unit tests in tests/editor/frame-preview.test.ts: colour,
  opacity, curve fields and the return to the starting colour
- Seven counter-probes fail, one test each. They ran on stein2, not
  in the container.
- No new e2e check, the sum stays at 1616. Neither the full suite nor
  the smokes ran for this state, and there was no counter-probe in
  the browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 12:02:12 +02:00

370 lines
13 KiB
TypeScript

/**
* A drag writes the model on every pointer move but tells the editor once per frame (#213): the
* model event rebuilds panels and pictures, and a mouse reports faster than the screen draws.
*/
import { type Application, CanvasTextMetrics } from 'pixi.js';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildAsset } from '../../src/core/commands/assetCommands';
import type { Command } from '../../src/core/commands/Command';
import type {
CurveAsset,
GraphAsset,
PaletteAsset,
PixelArtAsset,
VectorArtAsset,
VectorGradient,
} from '../../src/core/model/Asset';
import { PALETTE_KEY } from '../../src/core/model/pixelart';
import { createEmptyProject } from '../../src/core/model/Project';
import { CurveCanvas } from '../../src/editor/curve/CurveCanvas';
import { FramePreview } from '../../src/editor/framePreview';
import { GraphDrags } from '../../src/editor/graph/graphDrags';
import type { GraphCanvasHost } from '../../src/editor/graph/graphHost';
import type { InspectorHost } from '../../src/editor/inspector/host';
import { previewingHandlers } from '../../src/editor/inspector/previewHandlers';
import { buildShapeSection, currentGradientBar } from '../../src/editor/inspector/shapeSection';
import { PixelArtCanvas } from '../../src/editor/pixelart/PixelArtCanvas';
import { PixelArtSide } from '../../src/editor/pixelart/PixelArtSide';
import { PixelThumbs } from '../../src/editor/pixelart/PixelThumbs';
import { EditorState } from '../../src/editor/state';
import { VStack } from '../../src/ui';
import { initRenderLoop, resetRenderLoop } from '../../src/ui/render';
import { PaintCache } from '../../src/viewport/vectorArtPicture';
/** One frame of the render loop, the way the ticker runs it. */
function frames(): () => void {
const added: Array<() => void> = [];
const app = {
render(): void {},
ticker: {
add: (fn: () => void) => added.push(fn),
remove: () => undefined,
},
};
initRenderLoop(app as unknown as Application);
return () => added.forEach((fn) => fn());
}
function fakeState() {
return {
selectedAnnotationIds: [] as string[],
selectedNodeIds: ['n1'],
preview: vi.fn(),
execute: vi.fn(() => true),
};
}
describe('FramePreview', () => {
beforeEach(() => resetRenderLoop());
it('turns any number of requests in one frame into a single preview', () => {
const tick = frames();
const state = fakeState();
const preview = new FramePreview(state, 'drag');
for (let i = 0; i < 5; i++) preview.request();
expect(state.preview).not.toHaveBeenCalled();
tick();
expect(state.preview).toHaveBeenCalledTimes(1);
tick();
expect(state.preview).toHaveBeenCalledTimes(1);
});
it('drops the waiting preview on cancel', () => {
const tick = frames();
const state = fakeState();
const preview = new FramePreview(state, 'drag');
preview.request();
preview.cancel();
tick();
expect(state.preview).not.toHaveBeenCalled();
});
it('keeps two gestures of the same name apart', () => {
const tick = frames();
const a = fakeState();
const b = fakeState();
new FramePreview(a, 'drag').request();
new FramePreview(b, 'drag').request();
tick();
expect(a.preview).toHaveBeenCalledTimes(1);
expect(b.preview).toHaveBeenCalledTimes(1);
});
});
describe('dragging a node in the graph', () => {
beforeEach(() => resetRenderLoop());
function rig() {
const state = fakeState();
const asset = {
id: 'b',
graph: { nodes: [{ id: 'n1', type: 'Add', x: 0, y: 0, params: {} }], edges: [] },
} as unknown as GraphAsset;
const host = {
state: state as unknown as EditorState,
assetId: () => 'b',
asset: () => asset,
nodeBoxes: () => [{ id: 'n1', x: 0, y: 0, w: 100, h: 40 }],
toGraph: (p: { x: number; y: number }) => ({ x: p.x, y: p.y }),
zoom: () => 1,
snapping: () => false,
} as unknown as GraphCanvasHost;
return { state, node: asset.graph.nodes[0]!, drags: new GraphDrags(host) };
}
it('moves the node with every pointer move and previews once a frame', () => {
const tick = frames();
const { state, node, drags } = rig();
drags.grabNodes({ x: 10, y: 10 });
for (let i = 1; i <= 5; i++) {
drags.move({ x: 10 + i * 10, y: 10 });
expect(node.x).toBe(i * 10);
}
expect(state.preview).not.toHaveBeenCalled();
tick();
expect(state.preview).toHaveBeenCalledTimes(1);
});
it('leaves nothing for the frame after the release: the command has the last word', () => {
const tick = frames();
const { state, node, drags } = rig();
drags.grabNodes({ x: 10, y: 10 });
drags.move({ x: 50, y: 30 });
drags.releaseNodes();
expect(state.execute).toHaveBeenCalledTimes(1);
expect(node.x).toBe(0);
tick();
expect(state.preview).not.toHaveBeenCalled();
});
});
describe('editing an inspector row (#274)', () => {
beforeEach(() => resetRenderLoop());
function rig() {
const state = fakeState();
const target = { x: 1 };
const commit = vi.fn((value: number) => ({ value }) as unknown as Command);
const handlers = previewingHandlers<typeof target, number>(
{ state: state as unknown as EditorState },
{ target: () => target, read: (t) => t.x, write: (t, value) => (t.x = value), commit },
);
return { state, target, commit, handlers };
}
it('writes every input into the model and previews once a frame', () => {
const tick = frames();
const { state, target, handlers } = rig();
for (let i = 2; i <= 6; i++) {
handlers.onInput(i);
expect(target.x).toBe(i);
}
expect(state.preview).not.toHaveBeenCalled();
tick();
expect(state.preview).toHaveBeenCalledTimes(1);
});
it('commits from the value before the scrub and leaves nothing for the next frame', () => {
const tick = frames();
const { state, target, commit, handlers } = rig();
handlers.onInput(5);
handlers.onChange(7);
expect(target.x).toBe(1);
expect(commit).toHaveBeenCalledWith(7);
expect(state.execute).toHaveBeenCalledTimes(1);
tick();
expect(state.preview).not.toHaveBeenCalled();
});
});
describe('dragging a colour stop of a gradient (#274)', () => {
beforeAll(() => {
CanvasTextMetrics.measureText = (() => ({ width: 10, height: 10, lines: [], lineWidths: [] })) as never;
PaintCache.prototype.gradient = (() => null) as never;
});
beforeEach(() => resetRenderLoop());
function rig() {
const state = new EditorState();
state.loadProject(createEmptyProject('p', 'P'), 1);
const asset = buildAsset('vectorart', 'Drawing') as VectorArtAsset;
asset.shapes.push({
id: 's',
name: 'Sky',
fill: 0x0000ff,
opacity: 1,
rings: [],
paint: {
type: 'linear',
from: { x: 0, y: 0 },
to: { x: 100, y: 0 },
stops: [
{ offset: 0, color: 0x0000ff, alpha: 1 },
{ offset: 1, color: 0xffffff, alpha: 1 },
],
},
});
state.project.assets.push(asset);
const host: InspectorHost = {
state,
body: new VStack(),
fields: new Map(),
nameField: null,
project: () => state.project,
liveVariables: () => null,
};
buildShapeSection(host, asset.id, 's', ['s']);
const bar = currentGradientBar('fill')!;
// 100 px between the outermost markers, which stand 6 px in from either end.
bar.resize(112, 32);
const pointer = (type: string, x: number) =>
bar.emit(type, { button: 0, global: { x, y: 25 }, stopPropagation: () => undefined });
const offsets = () => (asset.shapes[0]!.paint as VectorGradient).stops.map((stop) => stop.offset);
return { state, preview: vi.spyOn(state, 'preview'), pointer, offsets };
}
it('moves the stop with every pointer move and previews once a frame', () => {
const tick = frames();
const { preview, pointer, offsets } = rig();
pointer('pointerdown', 6);
for (let i = 1; i <= 5; i++) {
pointer('globalpointermove', 6 + i * 10);
expect(offsets()[0]).toBeCloseTo(i / 10);
}
expect(preview).not.toHaveBeenCalled();
tick();
expect(preview).toHaveBeenCalledTimes(1);
});
it('leaves nothing for the frame after the release, and the drag is one undo step', () => {
const tick = frames();
const { state, preview, pointer, offsets } = rig();
pointer('pointerdown', 6);
pointer('globalpointermove', 56);
pointer('pointerup', 56);
expect(offsets()[0]).toBeCloseTo(0.5);
tick();
expect(preview).not.toHaveBeenCalled();
state.undo();
expect(offsets()[0]).toBe(0);
});
});
describe('the side panel of the pixel art tab (#274)', () => {
beforeAll(() => {
CanvasTextMetrics.measureText = (() => ({ width: 10, height: 10, lines: [], lineWidths: [] })) as never;
// Canvas and thumbnails paint onto browser canvases, which Node has none of.
PixelArtCanvas.prototype.redraw = () => undefined;
PixelThumbs.prototype.paint = () => undefined;
});
beforeEach(() => resetRenderLoop());
function rig() {
const state = new EditorState();
state.loadProject(createEmptyProject('p', 'P'), 1);
const palette = buildAsset('palette', 'Palette') as PaletteAsset;
const art = buildAsset('pixelart', 'Art') as PixelArtAsset;
art.props[PALETTE_KEY] = palette.id;
state.project.assets.push(palette, art);
const canvas = new PixelArtCanvas(state);
const side = new PixelArtSide(state, canvas);
canvas.show(art.id);
canvas.setColor(1);
const layer = art.layers[0]!;
// The row's number field calls these two; the list keeps them to itself.
const opacity = side as unknown as {
previewOpacity(layerId: string, opacity: number): void;
commitOpacity(layerId: string, opacity: number): void;
};
return { state, palette, art, layer, side, opacity, preview: vi.spyOn(state, 'preview') };
}
it('writes every colour under the picker into the palette and previews once a frame', () => {
const tick = frames();
const { state, palette, side, preview } = rig();
const first = palette.colors[0]!;
for (const color of [0x111111, 0x222222, 0x333333]) {
side.colorField.onInput?.(color);
expect(palette.colors[0]).toBe(color);
}
expect(preview).not.toHaveBeenCalled();
tick();
expect(preview).toHaveBeenCalledTimes(1);
side.colorField.onInput?.(0x444444);
side.colorField.onChange?.(0x555555);
tick();
expect(preview).toHaveBeenCalledTimes(1);
expect(palette.colors[0]).toBe(0x555555);
state.undo();
expect(palette.colors[0]).toBe(first);
});
it('says so when the picker ends on the colour it started from', () => {
const tick = frames();
const { palette, side, preview } = rig();
const first = palette.colors[0]!;
side.colorField.onInput?.(0x111111);
tick();
side.colorField.onChange?.(first);
expect(palette.colors[0]).toBe(first);
expect(preview).toHaveBeenCalledTimes(2);
});
it('writes every step of an opacity scrub into the layer and previews once a frame', () => {
const tick = frames();
const { state, art, layer, opacity, preview } = rig();
for (const value of [0.9, 0.8, 0.7]) {
opacity.previewOpacity(layer.id, value);
expect(layer.opacity).toBe(value);
}
expect(preview).not.toHaveBeenCalled();
tick();
expect(preview).toHaveBeenCalledTimes(1);
opacity.previewOpacity(layer.id, 0.6);
opacity.commitOpacity(layer.id, 0.5);
tick();
expect(preview).toHaveBeenCalledTimes(1);
expect(art.layers[0]!.opacity).toBe(0.5);
state.undo();
expect(art.layers[0]!.opacity).toBe(1);
});
});
describe('the place fields of the curve tab (#274)', () => {
beforeAll(() => {
CanvasTextMetrics.measureText = (() => ({ width: 10, height: 10, lines: [], lineWidths: [] })) as never;
});
beforeEach(() => resetRenderLoop());
function rig() {
const state = new EditorState();
state.loadProject(createEmptyProject('p', 'P'), 1);
const curve = buildAsset('curve', 'Ease') as CurveAsset;
state.project.assets.push(curve);
const canvas = new CurveCanvas(state);
canvas.show(curve.id);
(canvas as unknown as { picked: number }).picked = 1;
return { state, curve, canvas, preview: vi.spyOn(state, 'preview') };
}
it('moves the picked point with every input and previews once a frame', () => {
const tick = frames();
const { state, curve, canvas, preview } = rig();
const before = curve.points[1]!.y;
for (const v of [0.9, 0.8, 0.7]) {
canvas.movePicked({ x: 1, y: v }, false);
expect(canvas.pickedPlace?.y).toBeCloseTo(v);
}
expect(preview).not.toHaveBeenCalled();
tick();
expect(preview).toHaveBeenCalledTimes(1);
canvas.movePicked({ x: 1, y: 0.6 }, false);
canvas.movePicked({ x: 1, y: 0.5 }, true);
tick();
expect(preview).toHaveBeenCalledTimes(1);
expect(curve.points[1]!.y).toBeCloseTo(0.5);
state.undo();
expect(curve.points[1]!.y).toBe(before);
});
});