Files
feuerwerk/tests/editor/shape-rows.test.ts
T
adminandClaude Opus 5 ae0247d86f Lists add and delete items right in the list header and rows
The layer, frame and shape lists still had their add and delete
buttons scattered below the list, each in its own layout. They now
live in the shared SortableList, so every list works the same way.

- SortableList gets an optional header with the title on the left
  and a + on the right, plus an optional x per row next to the eye.
- The keepOne option hides the x on the last remaining row, so a
  pixel art image and a tilemap always keep one layer.
- Pixel art layers and frames and tilemap layers use header, + and
  x. Only Merge down and Duplicate stay below the lists.
- Vector art shapes get the x but no +. Add shape stays below the
  list and Delete shapes stays for a multi-selection.
- The section headings in the pixel art column use the same style
  as the list titles and the inspector.

Findings:

- The wait loop added to check 47i for #196 only waited for the
  right half of the shape. If the texture landed between the two
  reads, the left half was still blue and the check failed. It now
  waits for both halves.

Tests:

- ./check.sh: 138 files, 2052 tests, exit 0.
- GM_STEPS=39,40,47,48 (fresh): ALL PASSED, 156 checks (was 154).
- GM_STEPS=47,48 (fresh, final): ALL PASSED, 86 checks.
- Two new e2e checks: x on a shape (47) and x on a frame (48). The
  tilemap check in 40 now uses the x instead of Delete layer.
- Counter-check: with the x not reporting to its owner, exactly the
  two new checks fail.
- Check total 1266 in README and wiki is computed (1264 + 2).

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

345 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,
currentShapeList,
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')!);
// 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(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' });
});
});