Files
feuerwerk/tools/e2e/lib/scaffold.mjs
T
adminandClaude Opus 5 6371576d37
game-maker / gate (push) Successful in 2m9s
game-maker / suite (push) Successful in 7m26s
A vector art asset is a picture now, which is stage 2 of #129 and the first stage that shows something in the browser: the asset sits in the picture picker, a sprite carries it in its Texture row and what the sprite shows has been rasterized from the shapes, the generator of #185 being registered for the type vectorart - installVectorArtPictures(app.renderer) hooks the source (core) and the generator (viewport) in once per page from App.ts, by a call and not by a module side effect, so a test may install it or leave it out; the rule lives in core and the painting in viewport - the new core/model/vectorPath.ts turns a shape into plain drawing commands (move/line/curve/close), closed rings being filled while all rings are stroked, a segment staying a line as long as neither of its ends carries a handle - a single handle is enough for a curve, the missing control point sitting on its own end, so a corner may stand next to a round side - and the way back to the first point is only drawn when it is curved, close doing it otherwise; holes go through one door only, Pixi recognising them from a GraphicsPath with checkForHoles, which tests containment rather than even-odd depth, which is enough for a ring inside a ring while without the flag the inner ring simply paints over the outer one (measured, see the counter-checks); the oversampling is the 2x from the plan - RenderTexture.create({resolution: 2}) puts 512 px on the GPU for a 256 canvas while texture.width stays the canvas size, which is the measure the sprite hangs on, and the render texture is the clip itself, so what juts out over the canvas is not in it and needs no mask; drawn pictures get the same sampler state as uploaded ones, setPictureFactory wrapping the factory for it, mirroring keep(); two measuring traps cost me runs - __gm.objectRect(id) gives the centre of the object and not its corner, so the first pass read four pixels next to the sprite and reported background while the rasterizer was correct, and the grid lines of the scene cross exactly in the centre point, so the hole check read #363940 and failed for the right reason, measuring beside it now; renderer.render() runs inside the picture request, which is safe here because every caller asks during its own widget redraw and the frame is drawn on the ticker afterwards, a nested render inside a running render pass would not be - that warning stands in the module head and in the notes; new module 47 with 8 own checks in its own lane (the picture on the GPU at double resolution measuring the canvas, the sprite showing the fill, the inner ring being a hole with the scene shining through, the later shape on top, the stroke running along the ring, a changed drawing getting a new key with the old one freed, and the sprite showing the new colour), the shapes being put into the model by mutate because the tab that draws them is stage 3, and 21 new unit tests in tests/core/vector-path.test.ts; three counter-checks hold: oversampling back to 1 fails the GPU check, checkForHoles off fails the hole check, and not drawing the curved way back fails a unit test; the full suite ran locally instead of only in CI because the change sits in TextureLibrary and touches every picture: ALL PASSED - 1165 checks, measured and carried into README and the wiki (#129)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 20:51:54 +02:00

231 lines
7.6 KiB
JavaScript
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 throwaway rig steps 17–20 share in the fixture scene: a behavior asset cloned off `mover` with the
* step's graph, a crate to hold it, and a logic child that runs it.
*/
/**
* Where each step parks its box. The fixture scene is shared, so two steps at the same y would
* overlap and each would fail for reasons belonging to the other.
*/
export const LANES = {
editorRun: 400,
editorRunContainer: 800,
editorRunEdits: 1200,
pause: 300,
nested: 700,
keyRepeat: -300,
stateMachine: -700,
console: -1100,
transform: -1500,
font: -1900,
particles: -2300,
sound: -2700,
tilemap: -3100,
physics: -3500,
shapes: -3900,
queries: -4300,
symbols: -4700,
vectorArt: -5100,
};
/**
* Source for a `make(id, assetId, parentId, y, name)` factory, to paste inside a `mutate()`
* string. It clones `scene.objects[0]`, so a new object carries whatever shape the fixture has.
*/
export const MAKE_OBJECT = `
const make = (id, assetId, parentId, y, name = '') => {
const o = structuredClone(scene.objects[0]);
Object.assign(o, {
id,
assetId,
name,
position: { x: 0, y, z: 0 },
parentId,
overrides: {},
instanceEdits: {},
});
return o;
};`;
/**
* Returns the created ids plus `remove()`, so a step's cleanup matches what it made. `front` puts the
* asset at the head of the list: a row appended at the end scrolls out of `dragAsset`'s reach.
*/
export async function scaffoldBehavior(t, options) {
const {
assetId,
name,
graph,
boxId,
boxName = 'Box',
logicId,
y,
front = false,
props = { enabled: true, runInEditor: false },
} = options;
await t.mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
const asset = structuredClone(p.assets.find((a) => a.id === 'mover'));
Object.assign(asset, {
id: ${JSON.stringify(assetId)},
name: ${JSON.stringify(name)},
props: ${JSON.stringify(props)},
variables: [],
graph: ${JSON.stringify(graph)},
});
p.assets.${front ? 'unshift' : 'push'}(asset);
${MAKE_OBJECT}
scene.objects.push(
make(${JSON.stringify(boxId)}, 'crate', '', ${y}, ${JSON.stringify(boxName)}),
make(${JSON.stringify(logicId)}, ${JSON.stringify(assetId)}, ${JSON.stringify(boxId)}, 0, ${JSON.stringify(name)}),
);
`);
return {
assetId,
boxId,
logicId,
/** Removes everything this rig added and reports what survived (should be all zeroes). */
async remove() {
await t.evaluate('__gm.state.select(null)');
await t.mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
const objects = ${JSON.stringify([boxId, logicId])};
const assets = ${JSON.stringify([assetId])};
scene.objects = scene.objects.filter((o) => !objects.includes(o.id));
p.assets = p.assets.filter((a) => !assets.includes(a.id));
`);
return t.json(`{
objects: __gm.sceneObjects().filter((o) => ${JSON.stringify([boxId, logicId])}.includes(o.id)).length,
assets: __gm.state.project.assets.filter((a) => a.id === ${JSON.stringify(assetId)}).length,
}`);
},
};
}
/** What each tab button reads – the text a check has to click to open it. */
export const TAB_LABELS = {
scene: 'Scene',
game: 'Game',
graph: 'Behavior',
statemachine: 'State machine',
shader: 'Shader',
texture: 'Texture',
animation: 'Animation',
tile: 'Tile',
container: 'Container',
list: 'List',
particles: 'Particles',
plugins: 'Plugins',
// Added at start-up by the demo plugin (plugins/demo-plugin.js), after every built-in tab.
'plugin:assets': 'Assets',
};
/**
* Opens a tab by clicking it, through the `»` menu when the row had to hide it. Since the tenth tab
* (#73) that is the normal case at the test window's width: a plain `clickText('Container')` found
* nothing and took the whole suite down with it.
*/
export async function openTab(t, tab) {
// A kind of document has no tab of its own since #171; the hook shows the one the selection is about.
if (DOCUMENT_KINDS.includes(tab)) {
await t.evaluate(`__gm.showTab(${JSON.stringify(tab)})`);
return;
}
const label = TAB_LABELS[tab];
const row = await t.json('__gm.tabRow()');
if (row.hidden.includes(tab)) {
await t.clickText('»');
await t.clickText(label);
return;
}
// The label alone is not the tab: the toolbar labels its scene selector "Scene" too, and being
// drawn first that is what `clickText` had been hitting – so the tab stayed where it was
// (measured 2026-09-17: step 39 ended on the Autotile tab – now Tile – although it asks for the Scene tab).
// The hit is therefore picked by the box the tab row reports for that tab.
const entry = row.shown.find((e) => e.tab === tab);
const hits = entry ? await t.json(`__gm.locateAll(${JSON.stringify(label)})`) : [];
const hit = hits.find(
(p) => p.x >= entry.x && p.x <= entry.right && p.y >= entry.y && p.y <= entry.y + entry.h,
);
if (hit) await t.click(hit.x, hit.y);
else await t.clickText(label);
}
const DOCUMENT_KINDS = [
'graph',
'statemachine',
'shader',
'particles',
'texture',
'animation',
'tile',
'container',
'list',
];
/**
* Clicks the tab of an open document (`asset:<id>` or `list`), through the `»` menu when the row
* hides it. The press lands left of the ×, on the name.
*/
export async function clickDocumentTab(t, doc) {
const row = await t.json('__gm.tabRow()');
const entry = row.shown.find((e) => e.tab === doc);
if (entry) {
await t.click(entry.x + (entry.close.x - entry.x) / 2, entry.y + entry.h / 2);
return;
}
if (!row.hidden.includes(doc)) throw new Error(`no tab for ${doc}`);
const label = await t.json(`(() => {
const id = ${JSON.stringify(doc)};
if (id === 'list') return null;
const asset = __gm.state.project.assets.find((a) => 'asset:' + a.id === id);
return asset ? asset.name : null;
})()`);
await t.clickText('»');
const item = (await t.json('__gm.menuRows()')).find((r) => r === label);
const at = await t.locate(item, true);
await t.click(at.x, at.y);
}
/**
* Picks an asset type from the already-open `+` menu. Since #43 the menu may grow to the height of
* the canvas, so on this window every type is usually on screen at once; the scroll stays in as
* insurance for a shorter window, where entries below the mask edge are still found by `locate`
* but a click there lands on nothing. That is what the fourteenth type (`Variables`, #91) did to
* `Font` and to step 30.
*/
export async function pickAssetType(t, label) {
const box = await t.json('__gm.overlayRect()');
let p = await t.locate(label, true);
let scrolled = false;
if (box && (!p || p.y > box.y + box.h - 4)) {
await t.wheel(box.x + box.w / 2, box.y + box.h / 2, 300);
p = await t.locate(label, true);
scrolled = true;
}
await t.click(p.x, p.y);
return { box, p, scrolled };
}
/**
* A point of a panel (`panel` and `point` are hook expressions), scrolled into view first: a
* collider's material rows sit below the fold of the inspector, and after the earlier steps the
* hierarchy is long enough to hide this step's objects. A click beside the panel would land on
* whatever is drawn there instead.
*/
export async function inView(t, panel, point) {
for (let i = 0; i < 15; i++) {
const rect = await t.json(panel);
const at = await t.json(point);
if (!rect) return null;
if (at && at.y > rect.y + 8 && at.y < rect.y + rect.h - 8) return at;
await t.wheel(rect.x + rect.w / 2, rect.y + rect.h / 2, at && at.y < rect.y ? -200 : 200);
await t.settle();
}
return null;
}