Trim needless comments and fix the picker width

Remove comments that only restate the code, keeping the section banners
and the notes that carry a reason, a measured fact or an external
contract.

ButtonOptions.width claimed to be a fixed width but only set minWidth,
so the Graph and Texture pickers grew with the name they showed. It is
now what it promises, with a separate minWidth for the buttons that have
two forms. Narrow buttons shrink their padding, otherwise a 24px icon
button had 8px left for its glyph and truncate dropped it entirely.

__gm.labelsDropped() guards that class: the overflow guard only saw text
that was too wide, never a label truncated to nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-08 15:52:05 +02:00
1 parent f16801aef3
commit 4d84231b9f
115 files changed
+707 -1564

No files matched your search

+3 -5
View File
@@ -19,11 +19,9 @@ export async function assets(t) {
check('+ menu creates a sprite asset', names.includes('Sprite'), JSON.stringify(names));
await shot('12-new-asset');
// 12. Asset defaults: the Crate asset deviates in two properties – height 60 (schema default
// 100) and its red colour (the sprite default is white, i.e. "no tint") – so the rows
// carry two resets. Resetting height puts it back to 100 and removes *its* button while
// the colour keeps one; undo brings 60 back. Rows run Enabled, Width, Height, Color,
// Texture, so height's reset is the first and colour's the last.
// 12. Asset defaults: the Crate deviates in height (60 against the schema's 100) and colour,
// so two rows carry a reset. Rows run Enabled, Width, Height, Color, Texture, so height's
// reset is the first on screen and colour's the last.
await select({ kind: 'asset', assetId: 'crate' });
await sleep(200);
const heightReset = await evaluate(`__gm.locate('↺')`);
+3 -5
View File
@@ -186,9 +186,8 @@ export async function graph(t) {
await undo();
check('undo removes the variable', (await variableKeys()) === 'speed', await variableKeys());
// 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.
// 13h. The tab keeps its subject: selecting something else leaves the graph alone, and the
// header picker switches behaviors without any selection at all.
const size = await graphCounts();
const readout = `${size.nodes} nodes · ${size.edges} links`;
check(
@@ -208,8 +207,7 @@ export async function graph(t) {
'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.
// The picker is the *last* "Mover" on screen: the assets panel shows one too.
const pickerAt = await locate('Mover', true);
await click(pickerAt.x, pickerAt.y);
await sleep(150);
+6 -12
View File
@@ -38,23 +38,17 @@ export async function menus(t) {
await shot('31-long-parent-menu');
// 18a. The entries scroll under the wheel; the search box above them does not move.
//
// Anchored on a filler row rather than the list's "None" entry: "None" is also what an empty
// dropdown reads, so `locate('None', true)` could just as well find a *field* in the
// inspector behind the menu – one that never moves, whatever the wheel does. "Filler 1"
// exists only inside this list.
// Anchored on a filler row: "None" is also what an empty dropdown reads, so locating it could
// find an inspector field behind the menu instead – one that never moves.
const firstBefore = await locate('Filler 1', true);
const searchBefore = await locate('Search…', true);
// Move onto the list before turning the wheel. A wheel event is delivered to whatever is
// *hovered*, and the last thing the pointer touched was the field above the menu – without
// this the scroll is routed at that field instead, which is what a user could never do.
// Move onto the list first: a wheel event goes to whatever is hovered, and the pointer last
// touched the field above the menu.
await mouse('mouseMoved', rect.x + rect.w / 2, rect.y + rect.h / 2);
await sleep(50);
await mouse('mouseWheel', rect.x + rect.w / 2, rect.y + rect.h / 2, 'none', { deltaX: 0, deltaY: 240 });
// Read twice, and wait properly. The scroll lands a frame or two later than one might
// expect: measured here, the list had not moved 40 ms after the wheel and had moved at
// 340 ms. A single early reading made this check fail in the full suite while passing on
// its own — the two readings keep that visible instead of turning it back into a mystery.
// Measured here: the list had not moved 40 ms after the wheel but had at 340 ms. Both
// readings are reported so an early one cannot look like a mystery failure again.
await sleep(40);
const rightAfter = await locate('Filler 1', true);
await sleep(300);
+10 -26
View File
@@ -1,10 +1,6 @@
/**
* Uploading a picture and wearing it. Runs last because it adds an asset and repaints the
* scene; the checks that count assets live in earlier steps.
*
* The image is built in the page and handed over as a paste, which is a real user gesture and
* exercises the whole chain: decode → upload → the server storing it under its content hash →
* the asset → the renderer. Nothing here is stubbed.
*/
/** Paints a four-quadrant PNG in the page and pastes it. The quadrants make the mapping readable. */
@@ -23,7 +19,6 @@ const PASTE_IMAGE = `(async () => {
window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true }));
})()`;
/** How far apart two `#rrggbb` colours are, summed over the channels. */
function colourDistance(a, b) {
const channels = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
const [ar, ag, ab] = channels(a);
@@ -34,9 +29,8 @@ function colourDistance(a, b) {
export async function textures(t) {
const { check, sleep, json, evaluate, shot, mutate, press } = t;
// The previous step leaves a picker open and a text field focused. Both matter here: paste is
// deliberately ignored while an input has focus, so that Ctrl+V inside a field still edits
// text. Closing and blurring is what clicking the canvas would do.
// The previous step leaves a picker open and a text field focused, and paste is deliberately
// ignored while an input has focus, so that Ctrl+V inside a field still edits text.
await press('Escape');
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
await sleep(200);
@@ -61,9 +55,8 @@ export async function textures(t) {
JSON.stringify({ w: texture.w, h: texture.h }),
);
// 19a. Importing the same picture again is a no-op with an explanation: the server hands back
// the file it already has (the name is the hash), so the existing asset is reused rather
// than a second one made for the same bytes.
// 19a. Importing the same picture again reuses the asset: the server hands back the file it
// already has, since the name is the content hash.
await evaluate(PASTE_IMAGE);
await sleep(2500);
const again = await json(
@@ -81,15 +74,9 @@ export async function textures(t) {
);
// 19b. The picture is actually drawn – a sprite wearing it is no longer one flat colour.
//
// On a sprite of its own, deliberately: eleven steps of edits have been through the fixture's
// objects by now (resized, re-parented, cloned forty times, covered by colliders and gizmos),
// and a colour read off the screen must not depend on any of that. This one is white (so the
// picture shows its own colours instead of a tint), large enough that a quarter-width sample
// sits far from every quadrant border, and on top of everything else.
// Both are clones of things that already exist rather than hand-written literals: a scene
// object has fields a literal keeps forgetting (`editorOffset` was the one that bit here –
// the position went NaN and the object was culled without an error).
// On a fresh white sprite of its own, so the sample does not depend on eleven steps of edits
// to the fixture. Cloned rather than written as a literal: a literal missed `editorOffset`,
// which sent the position to NaN and culled the object without an error.
await mutate(`
const project = __gm.state.project;
const sprite = structuredClone(project.assets.find((a) => a.type === 'sprite'));
@@ -138,12 +125,9 @@ 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.
// 19b-i. Switching the Filter property takes effect at once, without a reload. Read off the
// screen rather than off `scaleMode`: the property was always assigned correctly, it was the
// GPU sampler behind it that used to survive the change.
const enlarge = (w, h) => `
const sprite = __gm.state.project.assets.find((a) => a.id === 'tex-sprite');
sprite.props.width = ${w};
+13 -33
View File
@@ -1,13 +1,10 @@
/**
* The Texture tab: cutting frames out of a sheet, and a sprite wearing one.
*
* Runs after the texture step, whose four-quadrant 64x48 picture is what makes this checkable
* at all – a frame over one quadrant has a colour of its own, so "which part of the sheet is
* on screen" is a question `pixelAt` can answer. Nothing here is stubbed: the frame is made by
* dragging on the canvas, and the sprite is read back off the rendered pixels.
* Runs after the texture step, whose four-quadrant 64x48 picture gives every quadrant a colour
* of its own – that is what makes "which part of the sheet is on screen" a `pixelAt` question.
*/
/** How far apart two `#rrggbb` colours are, summed over the channels. */
function colourDistance(a, b) {
const channels = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
const [ar, ag, ab] = channels(a);
@@ -21,21 +18,12 @@ const PALETTE = {
green: '#3cc85a',
blue: '#466ef0',
white: '#f5f5f5',
/**
* The sprite's own colour for the fallback check, deliberately a colour the sheet does not
* contain. The sheet's fourth quadrant is near-white, so leaving the sprite white would make
* "it fell back to its colour" and "it is showing that quadrant" the same measurement.
*/
plain: '#ff00ff',
};
/**
* Which of the known colours a sampled pixel is: the nearest one by channel distance.
*
* Asked this way rather than "is it exactly green" on purpose. The scene carries editor gizmos
* by this point – a collider's fill is drawn over its body at 12 % alpha – so a sample can be
* tinted by a few percent without anything being wrong. That shifts a colour slightly; it
* cannot make one quadrant of the sheet read as another, which is the only question here.
* Nearest known colour rather than an exact match: editor gizmos (a collider fill at 12 % alpha)
* tint a sample by a few percent, which still cannot make one quadrant read as another.
*/
function nearestColour(sample) {
let best = '';
@@ -68,9 +56,8 @@ export async function frames(t) {
);
await shot('33-texture-tab');
// 21. Dragging on the picture cuts a frame. The drag is aimed at the top-right quadrant of
// the sheet (32,0)-(64,24), inset by a pixel so the press cannot land on the edge of a
// neighbouring frame and be read as a resize instead of a new rectangle.
// 21. Dragging on the picture cuts a frame. Aimed at the top-right quadrant (32,0)-(64,24),
// inset by a pixel: a press on a frame edge is read as a resize, not a new rectangle.
const from = await json('__gm.texture.pointAt(33, 1)');
const to = await json('__gm.texture.pointAt(63, 23)');
check('the sheet is on screen', Boolean(from) && Boolean(to), JSON.stringify({ from, to }));
@@ -111,11 +98,7 @@ export async function frames(t) {
await t.redo();
await sleep(400);
// 22. A sprite wearing the frame shows that part of the sheet and nothing else. This is the
// check the whole feature exists for: the model difference between "whole picture" and
// "this frame" is one id, so only the drawn pixels can tell them apart.
// The sprite from the texture step is reused, white and unparented, but its texture
// reference was left dangling there – it is pointed at the frame here.
// 22. A sprite wearing the frame shows that part of the sheet and nothing else.
await evaluate(`(() => {
const sprite = __gm.state.project.assets.find((a) => a.id === 'tex-sprite');
sprite.props.color = 0xffffff;
@@ -132,9 +115,8 @@ export async function frames(t) {
json(`__gm.pixelAt(${Math.round(box.x + fx * box.w)}, ${Math.round(box.y + fy * box.h)})`);
const corners = [await at(-0.3, -0.3), await at(0.3, -0.3), await at(-0.3, 0.3), await at(0.3, 0.3)];
const read = corners.map(nearestColour);
// Without the frame these four corners read red / green / blue / white – that is what the
// texture step asserts one screen earlier. Reading green four times is therefore the exact
// statement that the fill was confined to one quadrant of the sheet.
// Without the frame these corners read red / green / blue / white, as the texture step
// asserts – so four greens is the statement that the fill stayed inside one quadrant.
check(
'the whole sprite shows the one quadrant the frame cuts out',
read.every((c) => c === 'green'),
@@ -143,10 +125,9 @@ export async function frames(t) {
await shot('35-sprite-wearing-a-frame');
}
// 23. Slicing offers a live count before it commits – the readout is what makes the numbers
// answerable without undoing.
// Repainted magenta before slicing: with the frame gone the sprite falls back to this, and
// magenta is nowhere in the sheet, so the fallback cannot be confused with a part of it.
// 23. Slicing offers a live count before it commits.
// Repainted magenta first: magenta is nowhere in the sheet, so the fallback check below
// cannot be confused with a part of the picture.
await evaluate(`(() => {
__gm.state.project.assets.find((a) => a.id === 'tex-sprite').props.color = 0xff00ff;
__gm.state.preview();
@@ -180,8 +161,7 @@ export async function frames(t) {
check('and numbers them in reading order', sliced[0]?.startsWith('Frame 1 '), JSON.stringify(sliced[0]));
await shot('36-sliced-sheet');
// 23a. The sprite pointed at the frame that slicing removed falls back to its colour rather
// than to a wrong part of the picture.
// 23a. A sprite pointed at a frame slicing removed falls back to its colour.
await evaluate("__gm.showTab('scene')");
await sleep(700);
if (box) {
+49 -8
View File
@@ -2,10 +2,8 @@
* 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.
* `__gm.textOverflows()` walks every visible text and reports the ones drawn wider than their
* widget; against a build without the fix it reported the graph picker at 106 px over.
*/
/** A name far wider than any 200 px picker, so a control that does not truncate must spill. */
@@ -14,6 +12,17 @@ const LONG_NAME = 'Player movement with jumping and coyote time';
export async function overflow(t) {
const { check, sleep, json, evaluate, shot, mutate } = t;
const pickerWidths = async () => {
const out = {};
for (const tab of ['graph', 'texture']) {
await evaluate(`__gm.showTab(${JSON.stringify(tab)})`);
await sleep(300);
out[tab] = await json(`__gm.${tab}.pickerWidth()`);
}
return out;
};
const before = await pickerWidths();
await mutate(`
for (const asset of __gm.state.project.assets) {
if (asset.type === 'behavior' || asset.type === 'texture') asset.name = ${JSON.stringify(LONG_NAME)};
@@ -21,8 +30,18 @@ export async function overflow(t) {
__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.
// 21. The pickers keep the width they were given. `width` used to set only a minimum, so a
// picker grew with whatever name it happened to show and the header reflowed on load.
const after = await pickerWidths();
for (const tab of ['graph', 'texture']) {
check(
`the ${tab} picker keeps its width when the name gets long`,
before[tab] === 200 && after[tab] === 200,
`before ${before[tab]}, after ${after[tab]}`,
);
}
// 21a. Every tab, because each one owns a different set of controls.
for (const tab of ['scene', 'graph', 'texture', 'game']) {
await evaluate(`__gm.showTab(${JSON.stringify(tab)})`);
await sleep(400);
@@ -34,7 +53,7 @@ export async function overflow(t) {
);
}
// 21a. Shortened, not simply dropped: the value must still be readable and marked as cut.
// 21b. 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'));
@@ -45,7 +64,7 @@ export async function overflow(t) {
);
await shot('36-long-labels');
// 21b. An inspector full of dropdowns – the parent row and the enum fields – with a selection
// 21c. 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);
@@ -53,4 +72,26 @@ export async function overflow(t) {
await sleep(500);
const inspector = await json('__gm.textOverflows()');
check('no label spills in the inspector', inspector.length === 0, JSON.stringify(inspector));
// 21d. The other direction: a control narrow enough to swallow its whole label draws nothing
// at all, which the overflow walk cannot see – an empty text spills over no edge.
for (const tab of ['scene', 'graph', 'texture']) {
await evaluate(`__gm.showTab(${JSON.stringify(tab)})`);
await sleep(400);
const dropped = await json('__gm.labelsDropped()');
check(
`no button is left without its label in the ${tab} tab`,
dropped.length === 0,
JSON.stringify(dropped),
);
}
// The narrowest buttons in the editor sit on a behaviour's variable rows.
await evaluate("__gm.state.select({ kind: 'asset', assetId: 'mover' })");
await sleep(400);
const onVariables = await json('__gm.labelsDropped()');
check(
'the variable rows keep their buttons labelled',
onVariables.length === 0,
JSON.stringify(onVariables),
);
}