Files
feuerwerk/tests/ui/svg-icons.test.ts
T
adminandClaude Opus 5.5 53fa63441b
game-maker / gate (push) Successful in 3m58s
game-maker / suite (push) Failing after 9m58s
Group scene objects in the hierarchy, for the editor only (#241)
A scene with a hundred crates fills the hierarchy with a hundred rows.
Groups bundle top-level objects under one folder row that folds away.
They exist for the editor only. Parenting, positions and the game do
not see them.

- Model: a scene or container template holds a list of groups, an
  object may carry a groupId. Old projects load unchanged. Loading
  drops empty groups and members that are no longer top level.
- Only top-level objects can join a group. Parenting a member takes it
  out of the group, and Undo puts it back. There are no groups in
  groups.
- Order: treeOrder lists the members together, where the first one
  stands. The same order decides drawing at equal z, in the editor and
  in the game, so the hierarchy and the picture cannot disagree. The
  stored object list stays untouched when grouping.
- Hierarchy: a group row shows a folder icon, the name and the member
  count. Clicking it selects all members and keeps it folded. Selecting
  a single member elsewhere unfolds it. Folding lasts for the session.
- Create with "Group..." in the object menu. A name dialog asks first,
  so creating is one Undo step and Cancel creates nothing.
- The group menu has Select members, Rename... and Ungroup. The object
  menu adds "Remove from group".
- Drag and drop: onto the group row appends, into a gap between members
  inserts there, next to a row outside leaves the group. Dragging the
  group row moves all members as a block. The drop rules live in
  hierarchyDrop.ts as a pure function.
- Copy and paste keeps a top-level copy in the group of its original.
  A container built from group members takes their place in the group.
- Clicking an object that is already selected together with others now
  keeps only that object selected. ListView narrows the selection on
  release, so a drag starting on such a row keeps the selection.

Not checked in the browser: groups in the container tab, "Remove from
group", Ctrl-click on a group row and a drag out of a multi-selection.
The code path covers them.

Tests:
- check.sh: 177 files, 2552 tests, CHECK EXIT 0
- e2e 1, 51, 52: ALL PASSED, 31 checks (new module 52)
- 19 new unit tests in object-groups.test.ts and hierarchy-drop.test.ts
- 9 counter-probes, all fail as expected (7 unit, 2 in the browser)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 01:17:46 +02:00

182 lines
8.1 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.
import { describe, expect, it } from 'vitest';
import { ASSET_TYPES } from '../../src/core/model/AssetTypes';
import { DECLARED_PORT_KINDS, FIELD_KINDS, kindMark, VARIABLE_KINDS } from '../../src/core/properties/kinds';
import {
ASSET_ICON_SVG,
GAME_ICON_SVG,
GROUP_ICON_SVG,
LIST_ICON_SVG,
PLUGINS_ICON_SVG,
USERS_ICON_SVG,
} from '../../src/editor/panels/assetIcons';
import { HEADER_ICON_SVG } from '../../src/editor/panels/headerIcons';
import { ARROW_ICON_SVG, KIND_ICON_SVG, ROW_ACTION_ICON_SVG } from '../../src/editor/panels/markIcons';
import { TOOLBAR_ICON_SVG, VISIBILITY_ICON_SVG } from '../../src/editor/panels/toolbarIcons';
import { UI_ICON_SVG } from '../../src/ui/icons';
import { tintSvg } from '../../src/ui/svg';
import { SYMBOL_SVG } from '../../src/viewport/symbolIcons';
/** Opening tags as name + attributes; the files are flat, so no real XML parser is needed. */
function tags(svg: string): { name: string; attrs: Record<string, string> }[] {
return [...svg.matchAll(/<([a-z]+)([^>]*?)\/?>/g)].map(([, name, rest]) => ({
name: name!,
attrs: Object.fromEntries([...rest!.matchAll(/([a-zA-Z-]+)="([^"]*)"/g)].map(([, k, v]) => [k!, v!])),
}));
}
describe('icon files', () => {
it('has a scene view symbol for every symbol (#175)', () => {
expect(Object.keys(SYMBOL_SVG).sort()).toEqual([
'behavior',
'camera',
'collider',
'container',
'particles',
'rigidbody',
]);
});
it('has an asset panel icon for every asset type, each its own picture (#176)', () => {
expect(Object.keys(ASSET_ICON_SVG).sort()).toEqual([...ASSET_TYPES].sort());
expect(new Set(Object.values(ASSET_ICON_SVG)).size).toBe(ASSET_TYPES.length);
});
const icons = [
...Object.entries(SYMBOL_SVG).map(([name, svg]) => [`symbol ${name}`, svg]),
...Object.entries(ASSET_ICON_SVG).map(([name, svg]) => [`asset ${name}`, svg]),
['list tab', LIST_ICON_SVG],
['game tab', GAME_ICON_SVG],
['hierarchy group', GROUP_ICON_SVG],
['plugins tab', PLUGINS_ICON_SVG],
['user management tab', USERS_ICON_SVG],
...Object.entries(TOOLBAR_ICON_SVG).map(([name, svg]) => [`toolbar ${name}`, svg]),
...Object.entries(VISIBILITY_ICON_SVG).map(([name, svg]) => [`visibility ${name}`, svg]),
...Object.entries(HEADER_ICON_SVG).map(([name, svg]) => [`header ${name}`, svg]),
...Object.entries(UI_ICON_SVG).map(([name, svg]) => [`ui ${name}`, svg]),
...Object.entries(KIND_ICON_SVG).map(([name, svg]) => [`kind ${name}`, svg]),
...Object.entries(ROW_ACTION_ICON_SVG).map(([name, svg]) => [`row action ${name}`, svg]),
...Object.entries(ARROW_ICON_SVG).map(([name, svg]) => [`arrow ${name}`, svg]),
];
// The symbols that were characters until #214: the font the editor ships has no such glyphs.
it('marks every kind a user can pick once – by a picture, or by letters where it has none (#214)', () => {
const picked = [...new Set([...VARIABLE_KINDS, ...FIELD_KINDS, ...DECLARED_PORT_KINDS])];
const marks = picked.map((kind) => {
const icon = KIND_ICON_SVG[kind];
const letters = kindMark(kind);
expect(Boolean(icon) !== Boolean(letters), `${kind}: a picture or letters, not both`).toBe(true);
return icon ?? letters;
});
expect(new Set(marks).size).toBe(picked.length);
});
// A test finds a picture by the id of its file (`locateIcon`), so every id names one file.
it('names each picture a test looks for after its own file (#214)', () => {
const named = icons.map(([, svg]) => tags(svg!)[0]!.attrs.id).filter((id) => id !== undefined);
expect(new Set(named).size).toBe(named.length);
for (const svg of [
...Object.values(UI_ICON_SVG),
...Object.values(KIND_ICON_SVG),
...Object.values(ROW_ACTION_ICON_SVG),
...Object.values(ARROW_ICON_SVG),
])
expect(tags(svg)[0]!.attrs.id).toBeDefined();
});
// The magnet stands alone in the header, without the "Snap" it replaced (#194): a picture another
// button already wears would say that button's thing.
it('draws the snap switch as a magnet of its own, bent without arcs (#194)', () => {
const others = [
...Object.values(ASSET_ICON_SVG),
...Object.values(TOOLBAR_ICON_SVG),
LIST_ICON_SVG,
GAME_ICON_SVG,
PLUGINS_ICON_SVG,
];
expect(others).not.toContain(HEADER_ICON_SVG.snap);
expect(HEADER_ICON_SVG.snap).toContain('C');
for (const { attrs } of tags(HEADER_ICON_SVG.snap).slice(1)) expect(attrs.d ?? '').not.toMatch(/[Aa]/);
});
it('has one icon per button of the top bar, each its own picture (#183)', () => {
expect(Object.keys(TOOLBAR_ICON_SVG).sort()).toEqual([
'eye',
'menu',
'pause',
'play',
'redo',
'stop',
'undo',
]);
expect(new Set(Object.values(TOOLBAR_ICON_SVG)).size).toBe(7);
});
it('has one picture per switch behind the eye', () => {
expect(Object.keys(VISIBILITY_ICON_SVG).sort()).toEqual(['label', 'shape', 'symbol']);
expect(new Set(Object.values(VISIBILITY_ICON_SVG)).size).toBe(3);
});
// The bar carries no captions any more, so a picture shared with a tab or an asset type would be
// the only thing left telling the two apart – and it would not.
it('draws the top bar in pictures of its own, none of them an asset icon (#183)', () => {
const others = [...Object.values(ASSET_ICON_SVG), LIST_ICON_SVG, GAME_ICON_SVG, PLUGINS_ICON_SVG];
for (const svg of Object.values(TOOLBAR_ICON_SVG)) expect(others).not.toContain(svg);
});
// Pixi's parser draws an A in the middle of a path as a spike (the puzzle piece of #119); undo
// and redo are the arcs of this set, so they are the ones that have to keep clear of it.
it('bends the top bar arrows with cubic curves rather than arcs (#183)', () => {
for (const svg of [TOOLBAR_ICON_SVG.undo, TOOLBAR_ICON_SVG.redo]) {
expect(svg).toContain('C');
for (const { attrs } of tags(svg).slice(1)) expect(attrs.d ?? '').not.toMatch(/[Aa]/);
}
});
// Pixi draws the file's coordinates as they are and ignores the viewBox, so the viewBox only
// shows the file right elsewhere – centred on the origin, it matches what the editor draws.
it.each(icons)('%s is centred on its origin', (_, svg) => {
const root = tags(svg)[0]!;
expect(root.name).toBe('svg');
const [x, y, w, h] = root.attrs.viewBox!.split(' ').map(Number);
expect([x, y]).toEqual([-w! / 2, -h! / 2]);
expect([Number(root.attrs.width), Number(root.attrs.height)]).toEqual([w, h]);
});
it('draws the asset icons in the 16 units a list row and a button reserve', () => {
for (const svg of [
...Object.values(ASSET_ICON_SVG),
LIST_ICON_SVG,
GAME_ICON_SVG,
PLUGINS_ICON_SVG,
USERS_ICON_SVG,
])
expect(tags(svg)[0]!.attrs.viewBox).toBe('-8 -8 16 16');
});
it('gives the List, Game, Plugins and User management tabs a picture no asset type has (#176, #119, #179)', () => {
const tabs = [LIST_ICON_SVG, GAME_ICON_SVG, PLUGINS_ICON_SVG, USERS_ICON_SVG];
for (const svg of tabs) expect(Object.values(ASSET_ICON_SVG)).not.toContain(svg);
expect(new Set(tabs).size).toBe(tabs.length);
});
it.each(icons)('%s uses only what the Pixi parser draws like a browser', (_, svg) => {
for (const { name, attrs } of tags(svg).slice(1)) {
// polygon and polyline read their points as whole numbers; groups would carry transforms,
// which the parser ignores.
expect(['path', 'circle', 'rect', 'ellipse', 'line']).toContain(name);
expect(attrs.transform).toBeUndefined();
// Without a fill a browser fills black, Pixi does not fill at all once there is a stroke.
expect(attrs.fill, `${name} states its fill`).toBeDefined();
for (const paint of [attrs.fill, attrs.stroke].filter((p) => p !== undefined))
expect(paint).toMatch(/^(none|currentColor|#[0-9a-f]{6})$/);
}
});
it('puts the theme colour where the file says currentColor', () => {
expect(tintSvg('<path fill="currentColor" stroke="currentColor"/>', 0x0000ff)).toBe(
'<path fill="#0000ff" stroke="#0000ff"/>',
);
for (const [, svg] of icons) expect(tintSvg(svg!, 0xb066ff)).not.toContain('currentColor');
});
});