Split GraphCanvas (1076 -> 831 lines) into EdgeLayer and graphMenus; the port and marker menus were the same list and are now one function. Value specs of the inspector and the list tab share valueSpecs.ts, and the remaining copies (compound commands, type glyphs, typed asset lookup, name rule, play/editor world setup, marquee) are merged. Four copies had already drifted apart; merging them fixes: - "Disconnect N links" in the port menu is one undo step, not N - "+ Add collider" is hidden on a placed container instance, as in the hierarchy menu, instead of always failing - a bool property coerced from the text "false" becomes false - a port command on an unknown node type reports instead of crashing Comment-only edits leave the bundle byte-identical. README corrected in 35 places (step limit, jump-run clock, suite duration, 409 body, ...). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
361 lines
15 KiB
JavaScript
361 lines
15 KiB
JavaScript
import { colourDistance } from '../lib/harness.mjs';
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
|
||
/** Paints a four-quadrant PNG in the page and pastes it. The quadrants make the mapping readable. */
|
||
const PASTE_IMAGE = `(async () => {
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = 64;
|
||
canvas.height = 48;
|
||
const g = canvas.getContext('2d');
|
||
g.fillStyle = '#e63c3c'; g.fillRect(0, 0, 32, 24);
|
||
g.fillStyle = '#3cc85a'; g.fillRect(32, 0, 32, 24);
|
||
g.fillStyle = '#466ef0'; g.fillRect(0, 24, 32, 24);
|
||
g.fillStyle = '#f5f5f5'; g.fillRect(32, 24, 32, 24);
|
||
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
|
||
const transfer = new DataTransfer();
|
||
transfer.items.add(new File([blob], 'pasted.png', { type: 'image/png' }));
|
||
window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true }));
|
||
})()`;
|
||
|
||
/**
|
||
* A second, plainly different picture. Its only job is to exist on the server under a hash of
|
||
* its own, so the picker has something to offer that the project does not already wear.
|
||
*/
|
||
const PASTE_SPARE = `(async () => {
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = 24;
|
||
canvas.height = 24;
|
||
const g = canvas.getContext('2d');
|
||
g.fillStyle = '#ffcc00'; g.fillRect(0, 0, 24, 24);
|
||
g.fillStyle = '#202020'; g.fillRect(6, 6, 12, 12);
|
||
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
|
||
const transfer = new DataTransfer();
|
||
transfer.items.add(new File([blob], 'spare.png', { type: 'image/png' }));
|
||
window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true }));
|
||
})()`;
|
||
|
||
export async function textures(t) {
|
||
const { check, sleep, json, evaluate, shot, mutate, press } = t;
|
||
const { api, click, clickText, doubleClick, locate, undo } = t;
|
||
|
||
// 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);
|
||
|
||
// 19. Pasting an image imports it: uploaded, stored under its hash, and made an asset.
|
||
await evaluate(PASTE_IMAGE);
|
||
await sleep(2500);
|
||
|
||
const imported = await json(
|
||
"__gm.state.project.assets.filter((a) => a.type === 'texture').map((a) => ({ id: a.id, name: a.name, file: a.file, w: a.width, h: a.height }))",
|
||
);
|
||
check('pasting an image creates a texture asset', imported.length === 1, JSON.stringify(imported));
|
||
const texture = imported[0] ?? {};
|
||
check(
|
||
'the file is named after its content hash',
|
||
/^[a-f0-9]{64}\.png$/.test(texture.file ?? ''),
|
||
String(texture.file),
|
||
);
|
||
check(
|
||
'the pixel size comes back from the server',
|
||
texture.w === 64 && texture.h === 48,
|
||
JSON.stringify({ w: texture.w, h: texture.h }),
|
||
);
|
||
|
||
// 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(
|
||
"__gm.state.project.assets.filter((a) => a.type === 'texture').map((a) => a.file)",
|
||
);
|
||
check(
|
||
'the same image imported twice stays one asset on one file',
|
||
again.length === 1,
|
||
JSON.stringify(again),
|
||
);
|
||
check(
|
||
'and the notice says it was already there',
|
||
(await json('__gm.state.notice')) === 'That image is already in the project.',
|
||
JSON.stringify(await json('__gm.state.notice')),
|
||
);
|
||
|
||
// 19b. The picture is actually drawn – a sprite wearing it is no longer one flat colour.
|
||
// 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'));
|
||
sprite.id = 'tex-sprite';
|
||
sprite.name = 'Textured';
|
||
sprite.props = { ...sprite.props, width: 160, height: 120, color: 0xffffff, texture: ${JSON.stringify(texture.id)} };
|
||
project.assets.push(sprite);
|
||
|
||
const scene = __gm.state.activeScene;
|
||
const object = structuredClone(scene.objects.find((o) => o.id === 'otext'));
|
||
object.id = 'otex';
|
||
object.name = 'Textured';
|
||
object.assetId = 'tex-sprite';
|
||
object.overrides = {};
|
||
object.position = { ...object.position, z: 900 };
|
||
scene.objects.push(object);
|
||
__gm.state.select(null)`);
|
||
await sleep(1200);
|
||
|
||
const box = await json("__gm.objectRect('otex')");
|
||
check('the textured sprite is on screen', Boolean(box) && box.w > 8, JSON.stringify(box));
|
||
if (box) {
|
||
// The quadrants sit at the corners of the sprite's box; sample well inside each one.
|
||
const at = async (fx, fy) =>
|
||
json(`__gm.pixelAt(${Math.round(box.x + fx * box.w)}, ${Math.round(box.y + fy * box.h)})`);
|
||
const [topLeft, topRight, bottomLeft, bottomRight] = [
|
||
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 sampled = { topLeft, topRight, bottomLeft, bottomRight };
|
||
check(
|
||
'each quadrant of the picture lands where the picture has it',
|
||
colourDistance(topLeft, '#e63c3c') < 60 &&
|
||
colourDistance(topRight, '#3cc85a') < 60 &&
|
||
colourDistance(bottomLeft, '#466ef0') < 60 &&
|
||
colourDistance(bottomRight, '#f5f5f5') < 60,
|
||
JSON.stringify(sampled),
|
||
);
|
||
check(
|
||
'the sprite is a picture rather than one flat colour',
|
||
new Set(Object.values(sampled)).size === 4,
|
||
JSON.stringify(sampled),
|
||
);
|
||
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 GPU sampler can miss a correctly assigned value.
|
||
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;
|
||
};
|
||
// Pixels plus what the driver has, so a failure says whether the model, the GPU sampler or the
|
||
// measurement is wrong.
|
||
const sample = async (value) => {
|
||
await mutate(
|
||
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)}).props.filter = ${JSON.stringify(value)}`,
|
||
);
|
||
await sleep(600);
|
||
const pixels = await acrossBorder();
|
||
return { pixels, filter: await json(`__gm.textureFilter(${JSON.stringify(texture.file)})`) };
|
||
};
|
||
|
||
const hard = await sample('nearest');
|
||
const pure = (c) => colourDistance(c, '#e63c3c') < 60 || colourDistance(c, '#3cc85a') < 60;
|
||
check(
|
||
'pixel-art filtering keeps the border between two quadrants hard',
|
||
hard.pixels.every(pure),
|
||
JSON.stringify(hard),
|
||
);
|
||
|
||
const soft = await sample('linear');
|
||
// The drift proves the switch: with a hung one, "some pixel is blended" alone would pass. Measured:
|
||
// a working switch drifts 425, a hung one 0, and 30 even if every channel were off by two.
|
||
const drift = hard.pixels.reduce((sum, c, i) => sum + colourDistance(c, soft.pixels[i]), 0);
|
||
check(
|
||
'switching the filter back to smooth blends the border without a reload',
|
||
soft.pixels.some((c) => !pure(c)) && drift > 150,
|
||
JSON.stringify({ drift, 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);
|
||
const tinted = await at(0.3, 0.3);
|
||
check(
|
||
'the sprite colour tints the picture',
|
||
colourDistance(tinted, '#f5f5f5') > 100 && colourDistance(tinted, '#3366ff') < 90,
|
||
JSON.stringify({ was: bottomRight, tinted }),
|
||
);
|
||
|
||
// 19d. A reference that no longer resolves falls back to the plain colour instead of failing.
|
||
await mutate("__gm.state.project.assets.find((a) => a.id === 'tex-sprite').props.texture = 'gone'");
|
||
await sleep(600);
|
||
const fallback = await at(0.3, 0.3);
|
||
check(
|
||
'a dangling texture falls back to the plain colour',
|
||
colourDistance(fallback, '#3366ff') < 60,
|
||
JSON.stringify({ fallback }),
|
||
);
|
||
}
|
||
|
||
// 19e. "+ → Texture" offers the pictures the server already holds, unreferenced ones included:
|
||
// paste a second picture, undo the asset (the file stays), then pick that file in the dialog.
|
||
await evaluate(PASTE_SPARE);
|
||
await sleep(2500);
|
||
const two = await json("__gm.state.project.assets.filter((a) => a.type === 'texture').map((a) => a.file)");
|
||
const spare = two[1] ?? '';
|
||
check(
|
||
'a second picture is stored under a hash of its own',
|
||
two.length === 2 && spare !== two[0],
|
||
JSON.stringify(two),
|
||
);
|
||
|
||
await undo();
|
||
await sleep(300);
|
||
const afterUndo = await json(
|
||
"__gm.state.project.assets.filter((a) => a.type === 'texture').map((a) => a.file)",
|
||
);
|
||
check(
|
||
'undoing the import leaves the file on the server with no asset naming it',
|
||
afterUndo.length === 1 && !afterUndo.includes(spare),
|
||
JSON.stringify(afterUndo),
|
||
);
|
||
|
||
await clickText('+');
|
||
// By position, not by text: the menu entry "Texture" shares its label with the centre tab, and
|
||
// `locate` would hand back whichever it walked into first.
|
||
const entry = await locate('Texture', true);
|
||
await click(entry.x, entry.y);
|
||
await sleep(1500);
|
||
const picker = await json('__gm.imagePicker()');
|
||
const stored = (await api('textures')).map((item) => item.file);
|
||
check(
|
||
'the Texture entry opens the image picker instead of the file dialog',
|
||
picker !== null,
|
||
JSON.stringify(picker),
|
||
);
|
||
check(
|
||
'it offers exactly the files the server holds',
|
||
JSON.stringify(picker?.files ?? []) === JSON.stringify(stored),
|
||
JSON.stringify({ shown: picker?.files.length, stored: stored.length }),
|
||
);
|
||
check(
|
||
'the picture the project wears is marked as already in it, the dropped one is not',
|
||
(picker?.used ?? []).includes(texture.file) && !(picker?.used ?? []).includes(spare),
|
||
JSON.stringify(picker?.used),
|
||
);
|
||
await shot('34-image-picker');
|
||
|
||
const tile = await json(`__gm.imagePickerTile(${JSON.stringify(spare)})`);
|
||
await click(tile.x, tile.y);
|
||
check(
|
||
'clicking a tile picks that image',
|
||
(await json('__gm.imagePicker()'))?.selected === spare,
|
||
JSON.stringify(await json('__gm.imagePicker()')),
|
||
);
|
||
await shot('35-image-picked');
|
||
|
||
await clickText('Use image');
|
||
await sleep(600);
|
||
const adopted = await json(
|
||
"__gm.state.project.assets.filter((a) => a.type === 'texture').map((a) => ({ name: a.name, file: a.file, w: a.width, h: a.height }))",
|
||
);
|
||
check('the dialog closes on the pick', (await json('__gm.imagePicker()')) === null);
|
||
check(
|
||
'the picked file becomes a texture asset, sized from the server record',
|
||
adopted.length === 2 && adopted[1]?.file === spare && adopted[1]?.w === 24 && adopted[1]?.h === 24,
|
||
JSON.stringify(adopted),
|
||
);
|
||
check(
|
||
'and the notice says it was added rather than imported',
|
||
(await json('__gm.state.notice')) === 'Added the image to the project.',
|
||
JSON.stringify(await json('__gm.state.notice')),
|
||
);
|
||
|
||
// Reusing a file already in the project must not make a second asset on it – two assets on one
|
||
// picture would disagree about its filter.
|
||
await clickText('+');
|
||
const entryAgain = await locate('Texture', true);
|
||
await click(entryAgain.x, entryAgain.y);
|
||
await sleep(1500);
|
||
const reopened = await json('__gm.imagePicker()');
|
||
check(
|
||
'the adopted file is marked as in the project when the dialog is opened again',
|
||
(reopened?.used ?? []).includes(spare),
|
||
JSON.stringify(reopened?.used),
|
||
);
|
||
const sameTile = await json(`__gm.imagePickerTile(${JSON.stringify(spare)})`);
|
||
await doubleClick(sameTile.x, sameTile.y);
|
||
await sleep(600);
|
||
const afterSecondPick = await json(
|
||
`__gm.state.project.assets.filter((a) => a.file === ${JSON.stringify(spare)}).length`,
|
||
);
|
||
check(
|
||
'double-clicking a picture the project already has keeps it at one asset',
|
||
afterSecondPick === 1 && (await json('__gm.state.notice')) === 'That image is already in the project.',
|
||
JSON.stringify({ assets: afterSecondPick, notice: await json('__gm.state.notice') }),
|
||
);
|
||
|
||
// Back to one texture asset, the way the later steps expect the fixture.
|
||
await undo();
|
||
await sleep(300);
|
||
check(
|
||
'undo takes the adopted asset back out',
|
||
(await json("__gm.state.project.assets.filter((a) => a.type === 'texture').length")) === 1,
|
||
);
|
||
|
||
// 19f. Right-clicking a texture asset makes a sprite wearing it at the picture's pixel size.
|
||
const entryText = 'Create sprite from texture';
|
||
const spriteRow = await json("__gm.assetRow('crate')");
|
||
await click(spriteRow.x, spriteRow.y, 'right');
|
||
await sleep(200);
|
||
check(
|
||
'a sprite asset does not offer making a sprite from it',
|
||
(await json(`__gm.locate(${JSON.stringify(entryText)}, false)`)) === null,
|
||
);
|
||
await press('Escape');
|
||
await sleep(150);
|
||
|
||
const spritesBefore = await json("__gm.state.project.assets.filter((a) => a.type === 'sprite').length");
|
||
const textureRow = await json(`__gm.assetRow(${JSON.stringify(texture.id)})`);
|
||
await click(textureRow.x, textureRow.y, 'right');
|
||
await sleep(200);
|
||
const createEntry = await json(`__gm.locate(${JSON.stringify(entryText)}, false)`);
|
||
check('a texture asset offers making a sprite from it', createEntry !== null);
|
||
if (createEntry) {
|
||
await click(createEntry.x, createEntry.y);
|
||
await sleep(400);
|
||
const made = await json(`(() => {
|
||
const s = __gm.state.selection;
|
||
const a = s && s.kind === 'asset' ? __gm.state.project.assets.find((x) => x.id === s.assetId) : null;
|
||
return a ? { type: a.type, name: a.name, props: a.props } : null;
|
||
})()`);
|
||
check(
|
||
'the new sprite is selected and wears the texture at its pixel size',
|
||
made?.type === 'sprite' &&
|
||
made.props.texture === texture.id &&
|
||
made.props.width === 64 &&
|
||
made.props.height === 48,
|
||
JSON.stringify(made),
|
||
);
|
||
check('it is named after the texture', made?.name === `${texture.name} 2`, JSON.stringify(made?.name));
|
||
await shot('35b-sprite-from-texture');
|
||
await undo();
|
||
check(
|
||
'undo takes the new sprite back out',
|
||
(await json("__gm.state.project.assets.filter((a) => a.type === 'sprite').length")) === spritesBefore,
|
||
);
|
||
}
|
||
}
|