Files
feuerwerk/tests/editor/frame-preview.test.ts
T
adminandClaude Opus 5.5 6d79cf59a3
game-maker / gate (push) Successful in 2m44s
game-maker / suite (push) Successful in 13m55s
Move a large graph instead of drawing it again (#292)
In a graph of many nodes the frame rate dropped while panning, and
more so while one node was dragged. Every pointer move rebuilt every
box, every text and every curve, and a drag also announced a changed
project, so the panels rebuilt along with it.

- Panning and zooming move the picture. GraphCanvas.applyView sets
  position and scale and draws anew only the grid and the rubber
  band. It used to call redraw().
- A node draws its box and makes its texts only when what they show
  has changed. NodeView keeps a signature of what redraw reads and
  one of what rebuildTexts reads, and returns early while they hold.
- A drag does not announce a changed project. GraphDrags writes the
  model on every pointer move and asks the canvas once a frame to
  follow (GraphCanvasHost.moved). state.preview() waits for the
  command of the release.
- Nodes outside the picture are not drawn. inView in graphView.ts is
  the rule, cull() applies it after every redraw and every change of
  the view.
- The graph is a render group, and box and pins go into the batch.
  The mask sits on a container above the group, on the group itself
  it would be panned and zoomed along.
- The rubber band and the drop mark of a dragged behavior are drawn
  on their own (drawBand), without a redraw of the graph.

Measured in the CI image with 388 nodes and 540 links, fitted to the
canvas, 240 pointer moves 16 ms apart:

- Pan, script per move: 25.2 ms before, 2.0 ms after
- Pan: 4.4 frames per second before, 18.7 after
- Drag a node: 2.7 frames per second before, 22.4 after
- The still picture: 9 frames per second before, 21 after

Not solved: the still picture of such a graph stays at 21 frames per
second in the test browser. What is left is the drawing itself, and
the frame rates are those of that browser, not of a desktop GPU.

Tests:
- Gate on melektaus-atx: 207 files, 3121 tests (3114 + 7), exit 0
- e2e 4,6,18,19,20,23,24,26,28,31,36,43,51,53 on melektaus-atx: 542
  pass, 1 fail. The fail is module 51 after module 43, an order the
  suite does not have, and it fails the same way without this
  change. Module 51 alone: 12 checks, all pass.
- No full run and no smokes. The sum of 1660 (1656 + 4) is reckoned,
  and the CI workflow is switched off.
- Seven unit tests: six for inView in tests/editor/graph-view.test.ts
  and one in frame-preview.test.ts
- Four e2e checks in block 6z-xii of module 06: pan, drag, zoom in
  and fit, a changed value
- Seven browser counter-probes fail 2 to 3 checks each

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

381 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';
import { allShapes } from '../../src/core/model/vectorTree';
/** 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,
moved: vi.fn(),
} as unknown as GraphCanvasHost;
return { state, host, node: asset.graph.nodes[0]!, drags: new GraphDrags(host) };
}
it('moves the node with every pointer move, and the canvas follows once a frame', () => {
const tick = frames();
const { host, 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(host.moved).not.toHaveBeenCalled();
tick();
expect(host.moved).toHaveBeenCalledTimes(1);
});
it('does not announce a changed project while the drag runs (#292)', () => {
const tick = frames();
const { state, drags } = rig();
drags.grabNodes({ x: 10, y: 10 });
drags.move({ x: 50, y: 30 });
tick();
expect(state.preview).not.toHaveBeenCalled();
});
it('leaves nothing for the frame after the release: the command has the last word', () => {
const tick = frames();
const { state, host, 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(host.moved).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.layers[0]!.items.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 = () => (allShapes(asset)[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);
});
});