Files
feuerwerk/tools/e2e/12-textures.mjs
T
adminandClaude Opus 5.5 14972d057f
game-maker / gate (push) Successful in 3m53s
game-maker / suite (push) Successful in 11m55s
Refactoring round 8: hide data files, safe saves, Esc as Cancel (#85)
Recurring cleanup round over the code since round 7 (bc688f3). The
issue now also asks to fix bugs and small inconsistencies found on the
way, so unlike the rounds before, this one changes behaviour in a few
places. Duplicated code in api went from 23 clones with 228 lines to
none (jscpd, at least 50 tokens). In src it stays at 22 clones with
271 lines, the same before and after.

Bugs:
- nginx served project and snapshot files as they were, so anyone who
  knew a project id could read it without signing in. Every file the
  API writes now goes through AtomicFile (temp file, mode, rename).
  Projects, snapshots, releases, accounts and sessions are 0640, and
  nginx answers 403 for them.
- Any signed-in account could delete a shared image, font or sound,
  which projects of other accounts may wear. Deleting one is now for
  administrators only. The editor never deleted a file, so the
  client's delete is gone.
- Two saves of one project could run at once. The lock file was
  removed after each save, so a waiting save and the next one locked
  different files. The lock file now stays, and deleting a project
  takes the lock as well.
- Bytes that are no picture got a 500 instead of a 422, because the
  error handler also threw for warnings silenced with @.
- A session file was rewritten in place. A request reading it at that
  moment found it empty and was signed out. It is replaced atomically.
- Delete did nothing for a shape picked in the vector art list. Delete
  now goes through removeSelection, one case per kind of selection,
  and a kind without a case fails the type check.

Inconsistencies:
- Escape on a question does what its Cancel does. Cancel on "Delete
  project?" and "Restore snapshot?" leads back to the picker or the
  list like the other questions did, and so does deleting a project.
- Editor texts use American spelling throughout, like the port names:
  48 British spellings in 45 lines (colour, centre, initialised).
- objectName and groupName replace eleven copies of "own name, else
  the asset's" and three of "Group". One copy fell back to the object
  id instead of "?".

Duplicates, dead code, comments:
- routeFiles serves images, fonts and sounds instead of three copies.
  HashedFileStore holds what the three stores shared: keeping a file
  under its hash with its name, listing and deleting. The stores keep
  only which bytes they take and what a record says.
- AtomicFile replaces nine copies of temp file plus rename.
- removeShapes is the one way the canvas, the inspector button and
  Delete take shapes out.
- GraphInterpreter.fire was called by tests only. The 54 calls use a
  helper in tests/helpers.ts now.
- FileStore.delete, HttpFileStore.delete and the PluginApi type were
  unused. A doc comment in actions.ts sat above the wrong function.

Checked and left as is:
- solidCells is used by tests only. It is the per-cell view before the
  merge into rectangles, the one place the largest-scale rule shows.
- An undo checker ran all 124 commands of the unit tests and compared
  the project before and after undo. The only differences are empty
  ports, groups and notes lists where there were none, which the file
  format drops anyway.

Docs:
- README: the check sum.
- Wiki, own commit: API (admin-only deletes, 422, file modes, lock
  file), Home (the "Not yet" list named three things that exist),
  Concepts, Keyboard, Vector art, Architecture and Development, and
  the 38 characters Gitea flagged as ambiguous on eight pages.
- .gitignore: the lock files, and pasted-*.png in any folder, which
  keeps the screenshot archive out.

Tests:
- ./check.sh: 195 files, 2817 tests, exit 0 (+2 for the name helpers).
- PHP: accounts 52 in the web container and 53 in the CI image with
  the race test (+2), snapshots 15 (+1), releases 20.
- Full e2e run: ALL PASSED, 1586 checks (+6 in 12, 47, 49 and 50).
- Smokes jumprun, tetris, actor: 24, 19 and 21 checks. The example
  project files were restored byte for byte afterwards.
- The file stores of HEAD and of this commit give byte-identical
  output for the live folders and for store, list and delete in a
  throwaway folder.
- Counter-checks, all fail as expected: a 500 for bad bytes fails 12,
  no admin check fails 49, removing the lock file again loses saves
  in 3 of 3 runs (151 to 170 of 321), Delete ignoring shapes fails 47,
  Escape that only closes fails 50, and dropping the shape case fails
  the type check (TS2366).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:12:55 +02:00

501 lines
23 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.
import { colourDistance } from '../lib/harness.mjs';
import { inView } from './lib/scaffold.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, play, stop } = 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(
'nearest 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 linear 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');
// 12c-ii. A shader draws in the sprite's own pass (#77) and samples the texture itself. At a
// camera zoom that puts the picture on a fractional pixel grid, it must find the very same
// nearest-filtered border as the plain draw. Measured in play, where the camera's zoom is the
// world scale; one play per setting, since the play world is built from the project when it starts.
await mutate(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)}).props.filter = 'nearest'`,
);
const placed = await json(
"(() => { const o = __gm.state.activeScene.objects.find((o) => o.id === 'otex'); return { x: o.position.x, y: o.position.y }; })()",
);
await mutate(`
const o = __gm.state.activeScene.objects.find((o) => o.id === 'otex');
o.position.x = 0; o.position.y = 0;
__gm.state.project.assets.find((a) => a.id === 'cam').props.zoom = 1.37`);
const played = async (patch) => {
await mutate(
`Object.assign(__gm.state.project.assets.find((a) => a.id === 'tex-sprite').props, ${JSON.stringify(patch)})`,
);
await play();
await sleep(500);
const rect = await json("__gm.gameRect('otex')");
const y = Math.round(rect.y - rect.h * 0.25);
const pixels = [];
for (let dx = -3; dx <= 3; dx++)
pixels.push(await json(`__gm.pixelAt(${Math.round(rect.x + dx)}, ${y})`));
await stop();
return { pixels, scale: rect.w / 320 };
};
const bare = await played({ shader: '' });
const filtered = await played({ shader: 'adjust-colors' });
const darkened = await played({ shader: 'adjust-colors', brightness: 0.5 });
check(
'at a fractional camera zoom the nearest border is still hard in play',
Number.isInteger(bare.scale) === false && bare.pixels.every(pure),
JSON.stringify(bare),
);
check(
'a neutral shader draws the very same pixels there: the shader does not resample',
JSON.stringify(filtered.pixels) === JSON.stringify(bare.pixels),
JSON.stringify({ bare: bare.pixels, filtered: filtered.pixels }),
);
// Without this, "the same" could also mean the shader never ran on that path.
check(
'and the shader does run on that path: brightness 0.5 darkens every one of them',
darkened.pixels.every((c, i) => colourDistance(c, bare.pixels[i]) > 100 && !pure(c)),
JSON.stringify(darkened),
);
// The sprite's picture is a mesh next to its node: put under the Graphics instead, Pixi warns
// once per page, through `groupCollapsed`, which the panel mirrors since step 27b-ii.
const complaints = (await json('__gm.console.lines()')).filter((line) => /Deprecation/.test(line.text));
check('drawing sprites this way raises no Pixi deprecation warning', complaints.length === 0, complaints);
await mutate(`
const o = __gm.state.activeScene.objects.find((o) => o.id === 'otex');
o.position.x = ${placed.x}; o.position.y = ${placed.y};
__gm.state.project.assets.find((a) => a.id === 'cam').props.zoom = 1;
const sprite = __gm.state.project.assets.find((a) => a.id === 'tex-sprite');
sprite.props.shader = ''; delete sprite.props.brightness;
__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)}).props.filter = 'linear'`);
await mutate(enlarge(160, 120));
// 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 }),
);
}
// 12e-ii. A texture field offers pictures only (#143). Since #97 an animation is played, not worn,
// so the Texture row of a sprite no longer lists one, and an animation id left in the
// field by an older project reads as dangling rather than passing for a picture.
await mutate(`
__gm.state.project.assets.push(
{ id: 'tex-anim', type: 'animation', name: 'Walk', props: {}, root: '', tracks: [] });
const worn = __gm.state.project.assets.find((a) => a.id === 'tex-sprite').props;
window.__texBefore = worn.texture;
worn.texture = 'tex-anim';
__gm.state.select({ kind: 'asset', assetId: 'tex-sprite' })`);
await sleep(400);
const textureField = await json("__gm.inspectorField('Texture', true)");
const dangling = await json("__gm.locateAll('tex-anim?')");
check(
'an animation id in the Texture row reads as a dangling reference',
Boolean(textureField) && dangling.length === 1 && Math.abs(dangling[0].y - textureField.y) < 12,
JSON.stringify({ textureField, dangling }),
);
await click(textureField.x, textureField.y);
await sleep(300);
const offered = (await json('__gm.menuRows()')) ?? [];
check(
'the Texture row offers the picture and no animation',
offered.includes(texture.name) && !offered.some((row) => row?.includes('(animation)') || row === 'Walk'),
JSON.stringify(offered),
);
await press('Escape');
await mutate(`
const assets = __gm.state.project.assets;
assets.splice(assets.findIndex((a) => a.id === 'tex-anim'), 1);
assets.find((a) => a.id === 'tex-sprite').props.texture = window.__texBefore;
__gm.state.select(null)`);
await sleep(300);
// 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();
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 evaluate('__gm.console.clear()');
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),
);
// The caption: what the project calls a picture it wears, otherwise the name the file was
// uploaded under (#83). Before that a file nobody wore read as its pixel size and nothing else.
const captionOf = (file) => (picker?.captions ?? [])[(picker?.files ?? []).indexOf(file)];
check(
'an unused file is captioned by its original name, a used one by its asset name',
captionOf(spare) === 'spare.png' && captionOf(texture.file) === texture.name,
JSON.stringify({ spare: captionOf(spare), used: captionOf(texture.file), asset: texture.name }),
);
await shot('34-image-picker');
// A tile is a container holding its background, not a Graphics with children: Pixi v8 warns
// about the latter, and the warning is the only sign that the tiles are built the wrong way.
const tileComplaints = (await json('__gm.console.lines()')).filter((line) =>
/Deprecation/i.test(JSON.stringify(line)),
);
check('building the tiles draws no complaint from Pixi', tileComplaints.length === 0, tileComplaints);
// Scrolled into view first: the server holds every picture ever uploaded, so on a long-lived
// one the spare's tile sits below the dialog's fold, where a click lands on nothing.
const tileOf = (file) =>
inView(t, '__gm.imagePickerRect()', `__gm.imagePickerTile(${JSON.stringify(file)})`);
const tile = await tileOf(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 it is named after the file it was uploaded as, not "Texture"',
adopted[1]?.name === 'spare',
JSON.stringify(adopted.map((a) => a.name)),
);
// 12g. The name beside the file, straight against the API: uploading the same bytes again under
// a new name renames it (the user's rule), a path is stripped to its last part, and an
// upload carrying no name at all leaves the known one alone rather than erasing it.
const renamed = await evaluate(`(async () => {
const bytes = await fetch('/game-maker/data/textures/${spare}').then((r) => r.blob());
const post = (query) => fetch('/game-maker/api/index.php?r=textures' + query,
{ method: 'POST', headers: { 'Content-Type': 'image/png' }, body: bytes }).then((r) => r.json());
const evil = await post('&name=' + encodeURIComponent('../../evil/renamed.png'));
const nameless = await post('');
await post('&name=spare.png');
return { evil: evil.name, file: evil.file, nameless: nameless.name };
})()`);
check(
'a second upload renames the file, a path is stripped, and a nameless upload keeps the name',
renamed.evil === 'renamed.png' && renamed.file === spare && renamed.nameless === 'renamed.png',
JSON.stringify(renamed),
);
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')),
);
// Bytes that are no picture are the caller's mistake: a 422 that says so, not a server error.
const refusedUpload = await evaluate(`fetch('/game-maker/api/index.php?r=textures&name=x.png',
{ method: 'POST', headers: { 'Content-Type': 'image/png' }, body: 'hello' })
.then(async (r) => ({ status: r.status, error: (await r.json()).error }))`);
check(
'bytes that are no picture are turned away with 422, not a server error',
refusedUpload.status === 422 && refusedUpload.error === 'Not a readable image',
JSON.stringify(refusedUpload),
);
// 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 tileOf(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();
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');
check(
'a sprite asset does not offer making a sprite from it',
(await json(`__gm.locate(${JSON.stringify(entryText)}, false)`)) === null,
);
await press('Escape');
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');
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);
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,
);
}
// Saved before the next step: one that navigates would hang at the unsaved-changes prompt.
await t.saved();
}