- `.gitignore` said `index.html` unanchored, which also caught the Vite source page; a fresh clone could neither `dev` nor `build`. Anchored to `/index.html`, page added. - Removed GM_REMOTE (tools/remote/gm-browser.ps1 and the ssh/scp branch in browser.mjs): the Windows test machine is gone and the only flaky check that lived on it is closed (#37). - Removed `npm run preview`, undocumented and unused. - Comments: wrong number in collider.test.ts (250 → 200), the "declarations used to…" history in listItems.ts and friends, the 24 px/28 contradiction, section banners in nodes/index.ts, node-catalog.test.ts and api/index.php, name paraphrases, and the stale step numbers in tools/e2e/11-…23- from the old global count. - README: asset types include `animation`, kind order Number/Text/Boolean, tagged colour and texture items, 6/5 kinds, the `Texture` node, `./build.sh`, PROJECT_FORMAT_VERSION, and the file tree (kinds.ts, listItems.ts, propertyPorts.ts, Breadcrumb.ts, beads.ts, tools/assets/). - tests/helpers.ts takes the duplicated `findObject` and `sceneWithCamera`. Proof: ./check.sh 797 tests, full e2e ALL PASSED 633 checks against this bundle. Co-Authored-By: Claude Fable 5.1 <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);
|
||
|
||
// 12a. 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 }),
|
||
);
|
||
|
||
// 12b. 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')),
|
||
);
|
||
|
||
// 12c. 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);
|
||
|
||
// 12d. 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 }),
|
||
);
|
||
|
||
// 12e. 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 }),
|
||
);
|
||
}
|
||
|
||
// 12f. "+ → 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,
|
||
);
|
||
|
||
// 12g. 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,
|
||
);
|
||
}
|
||
}
|