Files
feuerwerk/tests/editor/shape-rows.test.ts
T
adminandClaude Opus 5 5b9b57fd19 Layer, frame and shape lists all reorder by drag and drop
Reordering shapes by dragging worked well in the vector tab. Pixel
art layers and frames and tilemap layers still used up/down buttons.
All three lists now share one widget built from the shape list.

- SortableList in the UI toolkit, with the drag rules next to it in
  src/ui/sortable.ts. A row has an optional thumbnail, a name, a hint
  on the right and an eye toggle. Every drop is one undo step.
- The shape list is a thin wrapper around it. shapeRows.ts is gone,
  its tests moved to the new rules.
- Pixel art layers show the layer alone on the current frame, frames
  show the whole frame. Up/Down, Hide, Earlier and Later are gone.
- Tilemap layers get the eye instead of the checkbox. The per-row
  up, down and delete buttons are replaced by Delete layer next to
  Add layer.
- A row is selected on release instead of press. Selecting rebuilt
  the inspector or tilemap panel and cancelled a drag that started
  on a row not yet selected.

Tests:
- ./check.sh: 138 files, 2052 tests, exit 0 (before 137 / 2051).
- GM_STEPS=39,40,47,48 (fresh seed): ALL PASSED, 154 checks.
- 5 new e2e checks. Check total 1264 in the README is computed
  (1259 + 5), CI never measured 1259.
- Counter-checks bite: dropMove ignoring reversed fails a unit test,
  a tilemap drop not applied fails the drag check, a layer thumbnail
  showing all layers fails the thumbnail check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 18:48:50 +02:00

343 lines
14 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,
refreshShapeSection,
setFillTexture,
setPaintKind,
shapeSignature,
} from '../../src/editor/inspector/shapeSection';
import { EditorState, shapeSelection } from '../../src/editor/state';
import { PaintCache } from '../../src/viewport/vectorArtPicture';
/**
* 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) =>
findAssetOf(host.state.project, assetId, 'vectorart')!.shapes[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;
}
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(['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 = widgets(host, NumberField)[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([
'name',
'fillType',
'fill',
'opacity',
'outline',
'lineType',
'strokeColor',
'strokeWidth',
]);
const width = widgets(host, NumberField)[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);
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')!);
expect(texts(host)).toContain('SHAPES');
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(findAssetOf(host.state.project, assetId, 'vectorart')!.shapes.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([
'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);
widgets(host, NumberField)[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' });
});
});