Files
feuerwerk/tests/editor/shape-rows.test.ts
T
adminandClaude Opus 5.5 345289982b
game-maker / gate (push) Successful in 2m57s
game-maker / suite (push) Successful in 14m25s
Scale, turn and mirror the shapes of a vector drawing (#291)
A shape could only be moved and reshaped point by point. Whoever
wanted it larger or turned pulled every point alone, and for several
shapes or a group there was no way at all. The answers in the issue
settle the rest: the change is baked into the points, nothing of it
is remembered, and the line width stays.

- With Select the selection stands in a frame of eight squares and a
  knob, drawn like the one of the scene view. It holds one shape,
  several shapes or a group, the same selection that is dragged as
  one.
- A corner square scales from the opposite corner, a square on an
  edge in its own direction. Shift keeps the proportions, Alt scales
  about the middle.
- The knob turns round the middle of the frame, with Shift in steps
  of 15 degrees. After the pull the frame stands upright again.
- The inspector has a band TRANSFORM with X, Y, Width, Height and
  Rotate by, and the buttons Flip horizontal and Flip vertical.
- Points, handles, gradients and texture tiles are taken through the
  same matrix. The rule is a pure function in vectorTransform.ts,
  transformShapesCommand makes one undo step of any number of
  shapes.
- A press goes to a point or a handle first, then to the frame, then
  to a line. shapeFrame.ts holds where the handles are and what a
  pull of each means.

Decided here:

- The frame stands 15 pixels off the shapes. The points of a shape
  lie on its bounds, so a square on the bounds would cover them.
- The knob only turns. In the scene view it scales as well.
- A side cannot be pulled through the opposite one, the frame keeps
  at least 1 pixel.
- Coordinates are rounded to a thousandth of a pixel, so a quarter
  turn is exact.
- A texture tile stays upright under a turn and grows to the bounds
  of the turned tile. The bands of a gradient stay square to its
  axis under an uneven scale.
- The switch Scale stroke of the proposal is not built.

Found on the way:

- The inspector of a shape is now taller than the window. Two checks
  of module 47 no longer found their row or button on the screen and
  use the new hook inspectorButton, which scrolls there first.
- Not solved: with a shape at the top edge of the view the knob lies
  outside the tab. The wiki names it under the limits.

Taken over from #217: the e2e check for two selected shapes without
a group, and unit tests for the snap of the common offset and for
the gradients of the shapes dragged along.

Tests:
- Gate on melektaus-atx: 209 files, 3176 tests (3121 + 55), exit 0
- e2e 47,48 on melektaus-atx: 112 checks, all pass
- No full run and no smokes. The sum of 1664 (1660 + 4) is reckoned,
  1660 is what CI counted for 6d79cf5.
- 55 unit tests: 25 in tests/core/vector-transform.test.ts and 30 in
  tests/editor/shape-frame.test.ts
- Four e2e checks in block 47k of module 47: pull a corner, turn
  with Shift, type width and rotation, drag two shapes without a
  group
- 47 unit counter-probes and four browser counter-probes all fail
  their tests. Four of the unit ones did not at first: two showed
  dead code, which is gone, two got tests.
- Not checked in the browser: the edge squares, Alt, the cursors,
  the flip buttons and the rows X, Y and Height

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

360 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The inspector side of vector art (#129, stage 4): the arithmetic of the shape list, which rows a
* shape shows, and what editing one of them writes into the model. The rows are driven through the
* widgets' own callbacks – that is the path a click takes, `setValue` only shows a value.
*/
import { beforeAll, describe, expect, it } from 'vitest';
import { CanvasTextMetrics } from 'pixi.js';
import {
AddVectorShapeCommand,
RemoveVectorShapeCommand,
SetVectorShapeStyleCommand,
} from '../../src/core/commands/vectorArtCommands';
import type { VectorArtAsset, VectorGradient } from '../../src/core/model/Asset';
import { buildAsset } from '../../src/core/commands/assetCommands';
import { createEmptyProject, findAssetOf } from '../../src/core/model/Project';
import {
Button,
Checkbox,
ColorField,
Label,
NumberField,
TextField,
toHex,
VStack,
type Widget,
} from '../../src/ui';
import type { InspectorHost } from '../../src/editor/inspector/host';
import {
buildShapeListSection,
buildShapeSection,
currentShapeList,
refreshShapeSection,
setFillTexture,
setPaintKind,
shapeSignature,
} from '../../src/editor/inspector/shapeSection';
import { EditorState, shapeSelection } from '../../src/editor/state';
import { PaintCache } from '../../src/viewport/vectorArtPicture';
import { allShapes } from '../../src/core/model/vectorTree';
/**
* These rows really are built, and a widget repaints itself one microtask after a value goes into
* it – text width included, which Pixi measures on a canvas of the browser. The suite runs on node,
* so the measure is answered here instead of the whole test avoiding the widgets.
*/
beforeAll(() => {
CanvasTextMetrics.measureText = (() => ({ width: 10, height: 10, lines: [], lineWidths: [] })) as never;
// A gradient (#186) paints itself onto a browser canvas too; the rows under test do not need it.
PaintCache.prototype.gradient = (() => null) as never;
});
function makeHost(): InspectorHost {
const state = new EditorState();
state.loadProject(createEmptyProject('p', 'P'), 1);
return {
state,
body: new VStack(),
fields: new Map(),
nameField: null,
project: () => state.project,
liveVariables: () => null,
};
}
/** A vector art asset with `count` triangles in it, and a host to build its rows into. */
function withShapes(count: number): { host: InspectorHost; assetId: string; ids: string[] } {
const host = makeHost();
const asset = buildAsset('vectorart', 'Drawing') as VectorArtAsset;
host.state.project.assets.push(asset);
const ids: string[] = [];
for (let i = 0; i < count; i++) {
const command = new AddVectorShapeCommand(asset.id, [
{
closed: true,
points: [
{ x: i, y: 0 },
{ x: i + 20, y: 0 },
{ x: i, y: 20 },
],
},
]);
host.state.execute(command);
ids.push(command.shapeId);
}
return { host, assetId: asset.id, ids };
}
const shapeOf = (host: InspectorHost, assetId: string, index = 0) =>
allShapes(findAssetOf(host.state.project, assetId, 'vectorart')!)[index]!;
function widgets<T extends Widget>(host: InspectorHost, type: new (...args: never[]) => T): T[] {
const found: T[] = [];
const walk = (widget: Widget): void => {
if (widget instanceof type) found.push(widget);
for (const child of widget.children) walk(child as Widget);
};
walk(host.body);
return found;
}
/** The TRANSFORM rows (#291) stand over the shape's own, five number fields ahead of every other. */
const FRAME_KEYS = ['frameX', 'frameY', 'frameWidth', 'frameHeight', 'frameTurn'];
const numberFields = (host: InspectorHost): NumberField[] =>
widgets(host, NumberField).slice(FRAME_KEYS.length);
const texts = (host: InspectorHost): string[] => widgets(host, Label).map((label) => label.text);
describe('shape rows', () => {
it('shows fill, opacity and an outline switch, and no line rows without an outline', () => {
const { host, assetId, ids } = withShapes(1);
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
expect([...host.fields.keys()]).toEqual([
...FRAME_KEYS,
'name',
'fillType',
'fill',
'opacity',
'outline',
]);
expect(texts(host)).toContain('Without an outline only the fill is drawn.');
});
it('writes a fill as one undo step, a dragged colour previewing without one', () => {
const { host, assetId, ids } = withShapes(1);
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
const before = shapeOf(host, assetId).fill;
const fill = widgets(host, ColorField)[0]!;
fill.onInput!(0xff0000);
expect(shapeOf(host, assetId).fill).toBe(0xff0000);
fill.onChange!(0x00ff00);
expect(shapeOf(host, assetId).fill).toBe(0x00ff00);
host.state.undo();
expect(shapeOf(host, assetId).fill).toBe(before);
});
it('writes the opacity, clamped to what an opacity can be', () => {
const { host, assetId, ids } = withShapes(1);
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
// A NumberField reads text: its own `onChange` is the one a typed value goes through.
const opacity = numberFields(host)[0]!;
opacity.onChange!('0.4');
expect(shapeOf(host, assetId).opacity).toBeCloseTo(0.4);
opacity.onChange!('5');
expect(shapeOf(host, assetId).opacity).toBe(1);
});
it('the outline switch adds a line and takes it away again', () => {
const { host, assetId, ids } = withShapes(1);
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
widgets(host, Checkbox)[0]!.toggle();
expect(shapeOf(host, assetId).stroke).toEqual({ color: 0x000000, width: 2 });
host.state.undo();
expect(shapeOf(host, assetId).stroke).toBeUndefined();
});
it('shows the line rows once there is a line, and a width of nothing is not one', () => {
const { host, assetId, ids } = withShapes(1);
shapeOf(host, assetId).stroke = { color: 0x112233, width: 3 };
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
expect([...host.fields.keys()]).toEqual([
...FRAME_KEYS,
'name',
'fillType',
'fill',
'opacity',
'outline',
'lineType',
'strokeColor',
'strokeWidth',
]);
const width = numberFields(host)[1]!;
// The command refuses a line of no width, so the row never sends one.
width.onChange!('0');
expect(shapeOf(host, assetId).stroke?.width).toBe(0.1);
widgets(host, ColorField)[1]!.onChange!(0xabcdef);
expect(shapeOf(host, assetId).stroke).toEqual({ color: 0xabcdef, width: 0.1 });
});
it('names the shapes and the outline in its signature, so both rebuild the rows', () => {
const { host, assetId, ids } = withShapes(2);
const before = shapeSignature(host.state.project, assetId, ids[0]!, [ids[0]!]);
shapeOf(host, assetId).name = 'Hat';
const renamed = shapeSignature(host.state.project, assetId, ids[0]!, [ids[0]!]);
expect(renamed).not.toBe(before);
shapeOf(host, assetId).stroke = { color: 0, width: 1 };
expect(shapeSignature(host.state.project, assetId, ids[0]!, [ids[0]!])).not.toBe(renamed);
});
it('a shape that is gone asks for a rebuild instead of refreshing', () => {
const { host, assetId, ids } = withShapes(1);
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
expect(refreshShapeSection(host, assetId, ids[0]!)).toBe(true);
expect(refreshShapeSection(host, assetId, 'gone')).toBe(false);
expect(shapeSignature(host.state.project, assetId, 'gone', ['gone'])).toBe('missing');
});
it('refreshing pushes the model values back into the rows', () => {
const { host, assetId, ids } = withShapes(1);
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
const shape = shapeOf(host, assetId);
shape.fill = 0x123456;
shape.name = 'Cloud';
refreshShapeSection(host, assetId, ids[0]!);
// The name field first, then the hex text inside the fill row – a name is a field, not a label.
const fields = widgets(host, TextField).slice(FRAME_KEYS.length);
expect(fields[0]!.value).toBe('Cloud');
expect(fields[1]!.value).toBe(toHex(0x123456));
});
it('the asset itself shows the list and the way to add to it', () => {
const { host, assetId } = withShapes(2);
buildShapeListSection(host, findAssetOf(host.state.project, assetId, 'vectorart')!);
// The list carries its "SHAPES" header itself.
expect(currentShapeList()?.shownIds).toHaveLength(2);
expect(texts(host)).toContain('The top row is drawn over the ones below it.');
});
it('an empty drawing says so instead of showing an empty list', () => {
const { host, assetId } = withShapes(0);
buildShapeListSection(host, findAssetOf(host.state.project, assetId, 'vectorart')!);
expect(texts(host).some((t) => t.startsWith('Nothing drawn yet'))).toBe(true);
});
});
describe('combining from the inspector (#187)', () => {
const button = (host: InspectorHost, text: string) => widgets(host, Button).find((b) => b.text === text)!;
it('Ctrl+click adds a shape and takes it out again; emptied, the drawing stays selected', () => {
const { host, assetId, ids } = withShapes(2);
host.state.select(shapeSelection(assetId, [ids[0]!]));
host.state.toggleShape(assetId, ids[1]!);
expect(host.state.selection).toMatchObject({ shapeId: ids[1], shapeIds: [ids[0], ids[1]] });
host.state.toggleShape(assetId, ids[1]!);
host.state.toggleShape(assetId, ids[0]!);
expect(host.state.selection).toEqual({ kind: 'asset', assetId });
});
it('switches the four buttons off for one shape and says how to get a second', () => {
const { host, assetId, ids } = withShapes(2);
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
expect(['Union', 'Subtract', 'Intersect', 'Exclude'].map((t) => button(host, t).eventMode)).toEqual([
'none',
'none',
'none',
'none',
]);
expect(texts(host)).toContain('Ctrl+click a second shape to combine.');
});
it('combines the selected shapes into the lowest one and selects it', () => {
const { host, assetId, ids } = withShapes(3);
host.state.select(shapeSelection(assetId, [ids[2]!, ids[1]!]));
buildShapeSection(host, assetId, ids[1]!, [ids[2]!, ids[1]!]);
expect(texts(host)).toContain('The result keeps "Shape 2", the lowest.');
button(host, 'Union').onClick!();
expect(allShapes(findAssetOf(host.state.project, assetId, 'vectorart')!).map((s) => s.id)).toEqual([
ids[0],
ids[1],
]);
expect(host.state.selection).toMatchObject({ shapeId: ids[1], shapeIds: [ids[1]] });
});
it('drops a selected shape that is gone and keeps the rest', () => {
const { host, assetId, ids } = withShapes(3);
host.state.select(shapeSelection(assetId, [ids[0]!, ids[2]!]));
host.state.execute(new RemoveVectorShapeCommand(assetId, ids[2]!));
expect(host.state.selection).toMatchObject({ shapeId: ids[0], shapeIds: [ids[0]] });
});
it('names the selected shapes in the signature, so a Ctrl+click rebuilds the rows', () => {
const { host, assetId, ids } = withShapes(2);
const project = host.state.project;
expect(shapeSignature(project, assetId, ids[0]!, [ids[0]!])).not.toBe(
shapeSignature(project, assetId, ids[0]!, [ids[1]!, ids[0]!]),
);
});
});
describe('fill and line types (#186)', () => {
const button = (host: InspectorHost, text: string) => widgets(host, Button).find((b) => b.text === text)!;
const rebuild = (host: InspectorHost, assetId: string, shapeId: string) => {
host.body.removeChildren();
host.fields.clear();
buildShapeSection(host, assetId, shapeId, [shapeId]);
};
it('switches the fill to a gradient in one undo step, and the colour row makes way for the stops', () => {
const { host, assetId, ids } = withShapes(1);
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
setPaintKind(host.state, assetId, ids[0]!, 'fill', 'linear');
expect(shapeOf(host, assetId).paint).toMatchObject({
type: 'linear',
stops: [{ offset: 0 }, { offset: 1 }],
});
const signature = shapeSignature(host.state.project, assetId, ids[0]!, [ids[0]!]);
expect(signature).toContain('linear/none');
rebuild(host, assetId, ids[0]!);
expect([...host.fields.keys()]).toEqual([
...FRAME_KEYS,
'name',
'fillType',
'fillStopColor',
'fillStopAlpha',
'fillStopOffset',
'opacity',
'outline',
]);
host.state.undo();
expect(shapeOf(host, assetId).paint).toBeUndefined();
});
it('edits the picked stop, and "Remove stop" only works while three are left', () => {
const { host, assetId, ids } = withShapes(1);
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
setPaintKind(host.state, assetId, ids[0]!, 'fill', 'radial');
rebuild(host, assetId, ids[0]!);
// The stop's colour is the only colour row left; opacity of the stop is the first number row.
widgets(host, ColorField)[0]!.onChange!(0xff0000);
numberFields(host)[0]!.onChange!('0.5');
const paint = shapeOf(host, assetId).paint as VectorGradient;
expect(paint.stops[0]).toEqual({ offset: 0, color: 0xff0000, alpha: 0.5 });
expect(button(host, 'Remove stop').eventMode).toBe('none');
host.state.execute(
new SetVectorShapeStyleCommand(assetId, ids[0]!, {
paint: { ...paint, stops: [...paint.stops, { offset: 0.5, color: 0, alpha: 1 }] },
}),
);
rebuild(host, assetId, ids[0]!);
expect(button(host, 'Remove stop').eventMode).not.toBe('none');
button(host, 'Remove stop').onClick!();
expect((shapeOf(host, assetId).paint as VectorGradient).stops.map((s) => s.offset)).toEqual([1, 0.5]);
});
it('gives the outline a type of its own, with the line colour as its first stop', () => {
const { host, assetId, ids } = withShapes(1);
shapeOf(host, assetId).stroke = { color: 0x112233, width: 3 };
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
setPaintKind(host.state, assetId, ids[0]!, 'stroke', 'linear');
expect(shapeOf(host, assetId).stroke).toMatchObject({
color: 0x112233,
width: 3,
paint: { type: 'linear' },
});
expect((shapeOf(host, assetId).stroke!.paint as VectorGradient).stops[0]?.color).toBe(0x112233);
setPaintKind(host.state, assetId, ids[0]!, 'stroke', 'solid');
expect(shapeOf(host, assetId).stroke).toEqual({ color: 0x112233, width: 3 });
});
it('a texture fill shows the picker, and picking writes the id into the paint', () => {
const { host, assetId, ids } = withShapes(1);
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
setPaintKind(host.state, assetId, ids[0]!, 'fill', 'texture');
rebuild(host, assetId, ids[0]!);
expect(host.fields.has('fillTexture')).toBe(true);
setFillTexture(host.state, assetId, ids[0]!, 't1');
expect(shapeOf(host, assetId).paint).toMatchObject({ type: 'texture', texture: 't1' });
});
});