Add a decimals option, fix dropdown overflow and the texture filter

- NumberField takes a decimals option (default 3); frame rects and the
  slice dialog use 0, matching their integer model.
- Button truncates its label to the drawn width; Dropdown reserves the
  chevron slot. NodeView reuses the shared truncate().
- Setting scaleMode leaves the style's shared resource id in place, so
  the old GPU sampler stayed in use until a reload. Call style.update().
- Graph gets a header bar like the Texture tab: GraphEditor is now the
  frame, the former editor is GraphCanvas. A selection the tab does not
  concern no longer unloads the behavior.
- New e2e step 14 guards text overflow across all widgets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-08 15:05:28 +02:00
1 parent 35a1a23af8
commit f16801aef3
22 files changed
+874 -493

No files matched your search

+29 -4
View File
@@ -186,15 +186,40 @@ export async function graph(t) {
await undo();
check('undo removes the variable', (await variableKeys()) === 'speed', await variableKeys());
// 13h. The graph strictly mirrors the selection: nothing / a non-behavior → empty editor.
// 13h. The tab keeps its subject. A selection about something else (nothing, a camera) leaves
// the graph alone – clicking a sprite to look at it must not empty the editor you are
// working in – and the header picker switches behaviors without any selection at all.
const size = await graphCounts();
const readout = `${size.nodes} nodes · ${size.edges} links`;
check(
'the header reports the size of the shown graph',
Boolean(await evaluate(`__gm.locate(${JSON.stringify(readout)})`)),
readout,
);
await select(null);
await sleep(100);
check('graph editor empties when nothing is selected', (await evaluate(`__gm.graph.current()`)) === null);
check(
'graph editor keeps its behavior when nothing is selected',
(await evaluate(`__gm.graph.current()`)) === 'mover',
);
await select({ kind: 'asset', assetId: 'cam' });
await sleep(100);
check(
'graph editor stays empty for a non-behavior asset',
(await evaluate(`__gm.graph.current()`)) === null,
'graph editor keeps its behavior for a non-behavior asset',
(await evaluate(`__gm.graph.current()`)) === 'mover',
);
// The picker is the *last* "Mover" on screen: the assets panel shows one too, and the
// inspector is on the camera.
const pickerAt = await locate('Mover', true);
await click(pickerAt.x, pickerAt.y);
await sleep(150);
const spawnerRow = await locate('Spawner', true);
await click(spawnerRow.x, spawnerRow.y);
await sleep(200);
check(
'the header picker switches the graph to another behavior',
(await evaluate(`__gm.graph.current()`)) === 'spawner',
String(await evaluate(`__gm.graph.current()`)),
);
await select({ kind: 'asset', assetId: 'mover' });
await sleep(100);
+50
View File
@@ -138,6 +138,56 @@ export async function textures(t) {
);
await shot('32-textured-sprite');
// 19b-i. Switching the Filter property takes effect at once, without a reload.
//
// Read off the screen rather than off `scaleMode`, because the property was always assigned
// correctly – what used to survive the change was the GPU sampler behind it. The measurement
// is what "smooth" means: across the red/green border, nearest sampling shows only the two
// pure colours, linear sampling blends them into something that is neither.
const enlarge = (w, h) => `
const sprite = __gm.state.project.assets.find((a) => a.id === 'tex-sprite');
sprite.props.width = ${w};
sprite.props.height = ${h}`;
await mutate(enlarge(320, 240));
await sleep(600);
const wide = await json("__gm.objectRect('otex')");
// Sampled in the upper half so the line crosses only the red/green border, not the corner
// where all four quadrants meet.
const acrossBorder = async () => {
const y = Math.round(wide.y - wide.h * 0.25);
const points = [];
for (let dx = -2; dx <= 2; dx++) {
points.push(await json(`__gm.pixelAt(${Math.round(wide.x + dx)}, ${y})`));
}
return points;
};
const setFilter = (value) =>
mutate(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)}).props.filter = ${JSON.stringify(value)}`,
);
await setFilter('nearest');
await sleep(600);
const hard = await acrossBorder();
const pure = (c) => colourDistance(c, '#e63c3c') < 60 || colourDistance(c, '#3cc85a') < 60;
check(
'pixel-art filtering keeps the border between two quadrants hard',
hard.every(pure),
JSON.stringify(hard),
);
await setFilter('linear');
await sleep(600);
const soft = await acrossBorder();
check(
'switching the filter back to smooth blends the border without a reload',
soft.some((c) => !pure(c)),
JSON.stringify({ nearest: hard, linear: soft }),
);
await shot('33-texture-filter-smooth');
await mutate(enlarge(160, 120));
await sleep(400);
// 19c. The colour multiplies the picture, so a tinted white quadrant stops being white.
await mutate("__gm.state.project.assets.find((a) => a.id === 'tex-sprite').props.color = 0x3366ff");
await sleep(600);
+56
View File
@@ -0,0 +1,56 @@
/**
* Labels must be shortened to fit their control, not written past it. Runs last because it
* renames assets to absurd lengths to provoke the case.
*
* `__gm.textOverflows()` is a general guard rather than a check on one widget: it walks every
* visible text and reports the ones drawn wider than the widget that owns them. It was verified
* to actually catch this class of bug – against a build without the fix it reported the graph
* picker at 106 px over, and nothing else.
*/
/** A name far wider than any 200 px picker, so a control that does not truncate must spill. */
const LONG_NAME = 'Player movement with jumping and coyote time';
export async function overflow(t) {
const { check, sleep, json, evaluate, shot, mutate } = t;
await mutate(`
for (const asset of __gm.state.project.assets) {
if (asset.type === 'behavior' || asset.type === 'texture') asset.name = ${JSON.stringify(LONG_NAME)};
}
__gm.state.select(null)`);
await sleep(600);
// 21. Every tab, because each one owns a different set of controls: the two header bars with
// their pickers, the inspector's dropdowns, and the toolbar.
for (const tab of ['scene', 'graph', 'texture', 'game']) {
await evaluate(`__gm.showTab(${JSON.stringify(tab)})`);
await sleep(400);
const spills = await json('__gm.textOverflows()');
check(
`no label is drawn past its control in the ${tab} tab`,
spills.length === 0,
JSON.stringify(spills),
);
}
// 21a. Shortened, not simply dropped: the value must still be readable and marked as cut.
await evaluate("__gm.showTab('graph')");
await sleep(400);
const shown = (await json('__gm.textOverflows(true)')).find((e) => e.text.startsWith('Player movement'));
check(
'a value too wide for its dropdown is cut with an ellipsis',
Boolean(shown) && shown.text.endsWith('…') && shown.text.length > 8,
JSON.stringify(shown),
);
await shot('36-long-labels');
// 21b. An inspector full of dropdowns – the parent row and the enum fields – with a selection
// whose asset name is now overlong too.
await evaluate("__gm.showTab('scene')");
await sleep(300);
await evaluate("__gm.state.select({ kind: 'object', objectId: 'o1' })");
await sleep(500);
const inspector = await json('__gm.textOverflows()');
check('no label spills in the inspector', inspector.length === 0, JSON.stringify(inspector));
}
+2
View File
@@ -16,6 +16,7 @@ import { textAndEnabled } from './10-text-and-enabled.mjs';
import { menus } from './11-menus.mjs';
import { textures } from './12-textures.mjs';
import { frames } from './13-frames.mjs';
import { overflow } from './14-overflow.mjs';
export const STEPS = [
hierarchy,
@@ -31,4 +32,5 @@ export const STEPS = [
menus,
textures,
frames,
overflow,
];