Finding out what a game does in its first frames, or right at one spot of a flow, needed a quick hand on the pause button. Now the editor can start a play paused, step it frame by frame and let a behavior pause it. - The pause button can be pressed in edit mode. Then Play starts paused, and every further Play does too until it is released. A reload of the page clears it. - A third button, Next frame, right of Pause, or Ctrl+Shift+Enter. It works only while paused and runs exactly one frame of 1/60 s, whatever the frame took on screen. Hierarchy and inspector follow. - The Break node pauses the play like the button, at the end of the frame it ran in. The status line names the behavior's object. It is the editor's pause, not Pause game. - Break reaches the editor through a service only the editor hands to a play. In play.html and Run in editor the node does nothing. - Restart game keeps the running play's state. Start paused only counts when Play is pressed. - Wiki Concepts, Behaviors, Keyboard: the buttons, the node and the shortcut. Not tried: sound, free-running videos and a running recording while stepping. Sound and videos stay held during the pause. Tests: - tsc, eslint and Prettier are clean. - Unit pause, node-catalog, tooltip: 465 green. svg-icons now expects the step icon too. - e2e 18 and 46 on stein2 (melektaus-atx was off): ALL PASSED, 63 checks, 7 new in 18j. A step moves the box by 1.667 px at 100 px/s, Break stops the play with "Break in Breaker". - Counter-checks: a session that ignores the step turns 18j red. A Break that does not report its object turns the unit test red. - Gate on stein2: tsc, eslint and Prettier clean, 305 files, 4722 tests, 6 more than before. One was red at first, the icon list of the top bar did not know step yet. Fixed in svg-icons.test.ts and run again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
186 lines
8.2 KiB
TypeScript
186 lines
8.2 KiB
TypeScript
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',
|
||
'composition',
|
||
'container',
|
||
'joint',
|
||
'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',
|
||
'record',
|
||
'redo',
|
||
'step',
|
||
'stop',
|
||
'undo',
|
||
]);
|
||
expect(new Set(Object.values(TOOLBAR_ICON_SVG)).size).toBe(9);
|
||
});
|
||
|
||
it('has one picture per switch behind the eye', () => {
|
||
expect(Object.keys(VISIBILITY_ICON_SVG).sort()).toEqual(['label', 'shader', 'shape', 'symbol']);
|
||
expect(new Set(Object.values(VISIBILITY_ICON_SVG)).size).toBe(4);
|
||
});
|
||
|
||
// 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');
|
||
});
|
||
});
|