diff --git a/README.md b/README.md index 8ea26059..6869536a 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1667 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1668 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/editor/debug/renderHooks.ts b/src/editor/debug/renderHooks.ts index 675c0bfc..6260aa27 100644 --- a/src/editor/debug/renderHooks.ts +++ b/src/editor/debug/renderHooks.ts @@ -130,6 +130,8 @@ export function renderHooks({ app, state, editor }: HookContext) { kept: texture !== null, }; }, + /** Drawn pictures whose revision moved on while a shader still named them (#293); freed on a later ask. */ + picturesWaiting: () => textureLibrary.waitingPictures(), fps: () => editor.fps, /** * What the render loop did: frames actually drawn, and whether it is currently awake. The diff --git a/src/editor/inspector/shapeSection.ts b/src/editor/inspector/shapeSection.ts index afc16ab4..992ac852 100644 --- a/src/editor/inspector/shapeSection.ts +++ b/src/editor/inspector/shapeSection.ts @@ -10,7 +10,6 @@ */ import { AddVectorLayerCommand, - AddVectorShapeCommand, CombineVectorShapesCommand, DuplicateVectorShapeCommand, MoveVectorLayerCommand, @@ -66,7 +65,7 @@ import { removeShapes } from '../actions'; import { FramePreview } from '../framePreview'; import { listFold } from '../sectionFolds'; import { type EditorState, shapeSelection } from '../state'; -import { activeLayerOf, activeVectorLayer, pickVectorLayer } from '../vectorart/activeLayer'; +import { activeVectorLayer, pickVectorLayer } from '../vectorart/activeLayer'; import { groupBlocker, groupShapes, groupsToDissolve, ungroupShapes } from '../vectorart/grouping'; import { GradientBar } from '../vectorart/GradientBar'; import { ShapeList } from '../vectorart/ShapeList'; @@ -276,8 +275,8 @@ export function buildShapeListSection(host: InspectorHost, asset: VectorArtAsset addLayerList(host, asset, ''); addShapeList(host, asset, '', []); addNote(host, 'The top row is drawn over the ones below it.', 'small'); - addGap(host); - host.body.add(addShapeButton(host, asset.id)); + // No button adds a shape (#293): one without points could not be given any. + addNote(host, 'The Pen draws a new shape.', 'small'); } /** @@ -358,16 +357,17 @@ export function buildShapeSection( ); } else addNote(host, 'Without an outline only the fill is drawn.', 'small'); - addGap(host); - // One shape goes by the cross on its row; several at once only from here. - if (shapeIds.length > 1) + // One shape goes by the cross on its row; several at once only from here. There is no button + // that adds one (#293): a shape without points could not be given any, the Pen draws a new one. + if (shapeIds.length > 1) { + addGap(host); host.body.add( new Button('Delete shapes', { variant: 'danger', onClick: () => removeShapes(host.state, assetId, shapeIds), }), ); - host.body.add(addShapeButton(host, assetId)); + } } /** @@ -596,17 +596,6 @@ function removeShape(host: InspectorHost, assetId: string, itemId: string): void host.state.select(rest.length > 0 ? shapeSelection(assetId, rest) : { kind: 'asset', assetId }); } -function addShapeButton(host: InspectorHost, assetId: string): Button { - return new Button('Add shape', { - onClick: () => { - const asset = findAssetOf(host.state.project, assetId, 'vectorart'); - const layer = asset && activeLayerOf(host.state, asset); - const command = new AddVectorShapeCommand(assetId, [], undefined, layer?.id); - if (host.state.execute(command)) host.state.select(shapeSelection(assetId, [command.shapeId])); - }, - }); -} - /** * The name row. Not `addNameField`: the shape's name also stands in the list right above it, and a * blank one is refused by the command, which cannot put the old text back into the field. diff --git a/src/editor/vectorart/ShapeList.ts b/src/editor/vectorart/ShapeList.ts index e796881b..0cafd699 100644 --- a/src/editor/vectorart/ShapeList.ts +++ b/src/editor/vectorart/ShapeList.ts @@ -42,7 +42,7 @@ export class ShapeList extends SortableList { * the row pressed and whether it was pressed with Ctrl or Shift, which adds the shape to the * selection or takes it out (#187). `onDrop` is told where a dragged row landed. `onDuplicate` and * `onRemove` are the marks at the end of a row, `onRename` a name edited in its row. There is no - * plus: a shape is drawn with the Pen, and "Add shape" stays below. + * plus: a shape is drawn with the Pen. */ constructor( private readonly state: EditorState, diff --git a/src/viewport/TextureLibrary.ts b/src/viewport/TextureLibrary.ts index f88c214a..8dee0aac 100644 --- a/src/viewport/TextureLibrary.ts +++ b/src/viewport/TextureLibrary.ts @@ -29,10 +29,15 @@ export class TextureLibrary extends FileLibrary { /** Frames cut from their sheets, keyed by file and rectangle; kept like the files. */ private readonly crops = new Map(); /** Pictures drawn instead of uploaded (#185); a new revision replaces the one before it. */ - private readonly generated = new GeneratedPictures((texture, key) => { - this.dropCropsOf(key); - texture.destroy(true); - }); + private readonly generated = new GeneratedPictures( + (texture, key) => { + this.dropCropsOf(key); + texture.destroy(true); + }, + boundToShader, + // After the redraw that asked: every mesh it synced wears the new picture by then. + (retry) => queueMicrotask(retry), + ); constructor() { super('/game-maker/data/textures', 'textures'); @@ -97,6 +102,11 @@ export class TextureLibrary extends FileLibrary { return this.loaded.get(file) ?? this.generated.peek(file); } + /** The keys of drawn pictures that are replaced but still bound to a shader (#293, test hook). */ + waitingPictures(): string[] { + return this.generated.waitingKeys(); + } + /** * A frame as a texture of its own, cut the first time it is asked for. Drawn from the sheet * through a fill matrix it showed a row of the neighbouring cell along any edge that fell @@ -160,6 +170,17 @@ export class TextureLibrary extends FileLibrary { } } +/** + * Whether a shader still names the picture (#293). An object with a shader asset keeps its picture + * bound until its next sync, and so does Pixi's own shader for whatever mesh it drew last without + * the batcher; destroying the source under either makes Pixi warn "destroyed while still bound to + * a shader", for the source and for its sampler. Under WebGL the bind groups are the only ones + * listening for `change` on a source, which is how one is told from here. + */ +function boundToShader(texture: Texture): boolean { + return texture.source.listenerCount('change') > 0; +} + function cropKey(file: string, rect: FrameRect): string { return `${file}|${rect.x},${rect.y},${rect.w},${rect.h}`; } diff --git a/src/viewport/generatedPictures.ts b/src/viewport/generatedPictures.ts index 515a8ff1..55706056 100644 --- a/src/viewport/generatedPictures.ts +++ b/src/viewport/generatedPictures.ts @@ -5,6 +5,11 @@ * One picture per asset, keyed by `gen:@`. A new revision is a new key, and * making it frees the one the asset had before – that is the whole invalidation rule. The library * owns what a generator hands it: it destroys the picture when the revision moves on. + * + * One that is still drawn with at that moment waits (#293): whoever wears a picture learns of the + * new revision by asking for it, so the old one is always still in its hands when the new one is + * made. It is freed once it has been let go: right after the ask as a rule, on a later ask where + * the wearer was not part of that redraw. */ import type { Texture } from 'pixi.js'; import type { Asset } from '../core/model/Asset'; @@ -17,9 +22,21 @@ export type PictureFactory = (assetId: string) => T | null; export class GeneratedPictures { private readonly kept = new Map(); private factory: PictureFactory | null = null; + /** Pictures whose revision has moved on while something still drew with them. */ + private waiting: { key: string; picture: T }[] = []; + private asked = false; - /** `free` gives a picture back, by key, once its asset has moved on to a newer revision. */ - constructor(private readonly free: (picture: T, key: string) => void) {} + /** + * `free` gives a picture back, by key, once its asset has moved on to a newer revision. `inUse` + * says that it cannot go yet; it is asked again on every later `get` until it can. `soon` runs + * a retry once whoever asked is done – the wearer that made the picture wait has taken the new + * one by then, so the old one does not lie about until the next redraw. + */ + constructor( + private readonly free: (picture: T, key: string) => void, + private readonly inUse: (picture: T) => boolean = () => false, + private readonly soon: (retry: () => void) => void = () => {}, + ) {} /** Installs who draws; a session change swaps it, and everything drawn for the old one goes. */ setFactory(factory: PictureFactory | null): void { @@ -29,6 +46,7 @@ export class GeneratedPictures { /** The picture for this key, drawn on the first ask. Null for anything that is not a key. */ get(key: string): T | null { + if (this.waiting.length > 0) this.freeWaiting(); const kept = this.kept.get(key); if (kept) return kept.picture; const parsed = parseGeneratedKey(key); @@ -51,10 +69,23 @@ export class GeneratedPictures { return [...this.kept.keys()]; } + /** The keys of the pictures that wait to be freed, oldest first – what a test reads. */ + waitingKeys(): string[] { + return this.waiting.map((held) => held.key); + } + clear(): void { for (const key of this.keys()) this.drop(key); } + private freeWaiting(): void { + this.waiting = this.waiting.filter((held) => { + if (this.inUse(held.picture)) return true; + this.free(held.picture, held.key); + return false; + }); + } + /** Every revision of one asset, however old. */ private release(assetId: string): void { for (const [key, held] of this.kept) if (held.assetId === assetId) this.drop(key); @@ -64,7 +95,14 @@ export class GeneratedPictures { const held = this.kept.get(key); if (!held) return; this.kept.delete(key); - this.free(held.picture, key); + if (!this.inUse(held.picture)) return this.free(held.picture, key); + this.waiting.push({ key, picture: held.picture }); + if (this.asked) return; + this.asked = true; + this.soon(() => { + this.asked = false; + this.freeWaiting(); + }); } } diff --git a/tests/editor/shape-rows.test.ts b/tests/editor/shape-rows.test.ts index 4bcf271e..4c4ebd3d 100644 --- a/tests/editor/shape-rows.test.ts +++ b/tests/editor/shape-rows.test.ts @@ -212,7 +212,9 @@ describe('shape rows', () => { buildShapeListSection(host, findAssetOf(host.state.project, assetId, 'vectorart')!); // The list carries its "SHAPES" header itself. expect(currentShapeList()?.shownIds).toHaveLength(2); + // The way to add is the Pen, said in the note: there is no button for it (#293). expect(texts(host)).toContain('The top row is drawn over the ones below it.'); + expect(texts(host)).toContain('The Pen draws a new shape.'); }); it('an empty drawing says so instead of showing an empty list', () => { diff --git a/tests/editor/vector-layers.test.ts b/tests/editor/vector-layers.test.ts index 409c59a8..a4123fe9 100644 --- a/tests/editor/vector-layers.test.ts +++ b/tests/editor/vector-layers.test.ts @@ -188,15 +188,18 @@ describe('the lists of the inspector', () => { expect(r.state.selection).toEqual({ kind: 'asset', assetId: r.asset.id }); }); - it('puts a shape added from the inspector onto the layer worked on', () => { + it('offers no button that adds a shape: the Pen draws one (#293)', () => { const r = rig(); - const base = r.asset.layers[0]!.id; - r.addLayer(); - pickVectorLayer(r.state, r.asset.id, base); r.build(); - r.button('Add shape').onClick!(); - expect(r.asset.layers[0]!.items).toHaveLength(4); - expect(r.asset.layers[1]!.items).toHaveLength(0); + expect(r.button('Add shape')).toBeUndefined(); + expect(r.texts().some((line) => line.includes('The Pen draws a new shape'))).toBe(true); + r.state.select(shapeSelection(r.asset.id, ['a'])); + r.build(); + expect(r.button('Add shape')).toBeUndefined(); + // Several shapes still go at once from here. + r.state.select(shapeSelection(r.asset.id, ['a', 'b'])); + r.build(); + expect(r.button('Delete shapes')).toBeDefined(); }); }); diff --git a/tests/viewport/bound-pictures.test.ts b/tests/viewport/bound-pictures.test.ts new file mode 100644 index 00000000..26ca9992 --- /dev/null +++ b/tests/viewport/bound-pictures.test.ts @@ -0,0 +1,108 @@ +import { BindGroup, Texture, TextureSource } from 'pixi.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { TextureLibrary } from '../../src/viewport/TextureLibrary'; + +/** + * #293: Pixi warned "[BindGroup] a 'textureSource' was destroyed while still bound to a shader" + * with every change to a drawing that an object wore through a shader asset. The mesh's shader + * names the picture until its next sync, and the library destroyed the old revision the moment it + * drew the new one – which is before any wearer can have heard of it. + * + * Measured with Pixi's own bind group, which is what a shader keeps its resources in. + */ +function rig() { + const library = new TextureLibrary(); + const drawn: Texture[] = []; + library.setPictureFactory(() => { + const texture = new Texture({ source: new TextureSource({ width: 4, height: 4 }) }); + drawn.push(texture); + return texture; + }); + const warned = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const warnings = () => + warned.mock.calls.map((call) => call.join(' ')).filter((line) => line.includes('BindGroup')); + /** What a shader does with a picture it wears. */ + const bind = (group: BindGroup, texture: Texture): void => { + group.setResource(texture.source, 0); + group.setResource(texture.source.style, 1); + }; + return { library, drawn, warnings, bind }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('a drawn picture a shader still names (#293)', () => { + it('is told by the bind group listening on its source', () => { + const r = rig(); + const picture = r.library.load('gen:a@1')!; + expect(picture.source.listenerCount('change')).toBe(0); + const group = new BindGroup({ 0: picture.source, 1: picture.source.style }); + expect(picture.source.listenerCount('change')).toBe(1); + r.bind(group, Texture.WHITE); + expect(picture.source.listenerCount('change')).toBe(0); + }); + + it('is not destroyed under the shader when its drawing changes, and nothing is warned', () => { + const r = rig(); + const first = r.library.load('gen:a@1')!; + const shader = new BindGroup({ 0: first.source, 1: first.source.style }); + + const second = r.library.load('gen:a@2')!; + expect(second).not.toBe(first); + expect(first.destroyed).toBe(false); + expect(first.source.destroyed).toBe(false); + expect(r.library.waitingPictures()).toEqual(['gen:a@1']); + // It is no longer what the library hands out. + expect(r.library.peek('gen:a@1')).toBeNull(); + expect(r.warnings()).toEqual([]); + + // The mesh syncs: its shader takes the new picture. The next ask frees the old one. + r.bind(shader, second); + expect(r.library.load('gen:a@2')).toBe(second); + expect(first.destroyed).toBe(true); + expect(r.library.waitingPictures()).toEqual([]); + expect(second.destroyed).toBe(false); + expect(r.warnings()).toEqual([]); + }); + + it('is freed right after the redraw in which the shader took the new picture', async () => { + const r = rig(); + const first = r.library.load('gen:a@1')!; + const shader = new BindGroup({ 0: first.source, 1: first.source.style }); + // One redraw: the mesh asks for its picture and hands it to its shader. + r.bind(shader, r.library.load('gen:a@2')!); + expect(first.destroyed).toBe(false); + await Promise.resolve(); + expect(first.destroyed).toBe(true); + expect(r.library.waitingPictures()).toEqual([]); + expect(r.warnings()).toEqual([]); + }); + + it('stays through any number of changes while the shader does not sync, and only that one', () => { + const r = rig(); + const first = r.library.load('gen:a@1')!; + const shader = new BindGroup({ 0: first.source, 1: first.source.style }); + // The scene is not on screen: nobody syncs, the drawing is edited three times. + r.library.load('gen:a@2'); + r.library.load('gen:a@3'); + r.library.load('gen:a@4'); + expect(r.drawn.map((texture) => texture.destroyed)).toEqual([false, true, true, false]); + expect(r.library.waitingPictures()).toEqual(['gen:a@1']); + expect(r.warnings()).toEqual([]); + shader.destroy(); + r.library.load('gen:a@4'); + expect(first.destroyed).toBe(true); + expect(r.warnings()).toEqual([]); + }); + + it('is destroyed right away when no shader names it', () => { + const r = rig(); + const first = r.library.load('gen:a@1')!; + r.library.load('gen:a@2'); + expect(first.destroyed).toBe(true); + expect(r.library.waitingPictures()).toEqual([]); + expect(r.warnings()).toEqual([]); + }); +}); diff --git a/tests/viewport/generated-pictures.test.ts b/tests/viewport/generated-pictures.test.ts index 46b41b04..02225029 100644 --- a/tests/viewport/generated-pictures.test.ts +++ b/tests/viewport/generated-pictures.test.ts @@ -82,6 +82,113 @@ describe('generated pictures kept in memory', () => { expect(f.pictures.get('gen:a@1')).toBeNull(); }); + describe('a picture something still draws with (#293)', () => { + /** Like `library`, with pictures that can be held on to. */ + function held() { + const freed: string[] = []; + const busy = new Set(); + const pictures = new GeneratedPictures( + (picture, key) => freed.push(`${picture} ${key}`), + (picture) => busy.has(picture), + ); + let revision = 0; + pictures.setFactory((assetId) => `${assetId}${++revision}`); + return { pictures, freed, busy }; + } + + it('keeps the old revision while it is in use and frees it on the next ask after', () => { + const f = held(); + f.pictures.get('gen:a@1'); + f.busy.add('a1'); + expect(f.pictures.get('gen:a@2')).toBe('a2'); + // Gone from what is handed out, but not freed. + expect(f.pictures.keys()).toEqual(['gen:a@2']); + expect(f.pictures.peek('gen:a@1')).toBeNull(); + expect(f.freed).toEqual([]); + expect(f.pictures.waitingKeys()).toEqual(['gen:a@1']); + // Still held: asking again changes nothing. + f.pictures.get('gen:a@2'); + expect(f.freed).toEqual([]); + // Let go: any ask frees it, also one for a picture that is already there. + f.busy.delete('a1'); + f.pictures.get('gen:a@2'); + expect(f.freed).toEqual(['a1 gen:a@1']); + expect(f.pictures.waitingKeys()).toEqual([]); + // And only once. + f.pictures.get('gen:a@2'); + f.pictures.get('gen:a@3'); + expect(f.freed).toEqual(['a1 gen:a@1', 'a2 gen:a@2']); + }); + + it('frees what waits in the order it was replaced, and leaves the one still held', () => { + const f = held(); + f.pictures.get('gen:a@1'); + f.busy.add('a1'); + f.pictures.get('gen:a@2'); + f.busy.add('a2'); + f.pictures.get('gen:a@3'); + expect(f.pictures.waitingKeys()).toEqual(['gen:a@1', 'gen:a@2']); + f.busy.delete('a2'); + f.pictures.get('gen:a@3'); + expect(f.freed).toEqual(['a2 gen:a@2']); + expect(f.pictures.waitingKeys()).toEqual(['gen:a@1']); + }); + + it('frees a picture nobody holds right away', () => { + const f = held(); + f.pictures.get('gen:a@1'); + f.pictures.get('gen:a@2'); + expect(f.freed).toEqual(['a1 gen:a@1']); + expect(f.pictures.waitingKeys()).toEqual([]); + }); + + it('retries once whoever asked is done, and asks for that only once per wait', () => { + const freed: string[] = []; + const busy = new Set(); + const later: (() => void)[] = []; + const pictures = new GeneratedPictures( + (picture) => freed.push(picture), + (picture) => busy.has(picture), + (retry) => later.push(retry), + ); + let revision = 0; + pictures.setFactory((assetId) => `${assetId}${++revision}`); + pictures.get('gen:a@1'); + pictures.get('gen:b@1'); + busy.add('a1').add('b2'); + pictures.get('gen:a@2'); + pictures.get('gen:b@2'); + expect(later).toHaveLength(1); + // The wearer of a took the new picture during the redraw, the one of b did not. + busy.delete('a1'); + later.shift()!(); + expect(freed).toEqual(['a1']); + expect(pictures.waitingKeys()).toEqual(['gen:b@1']); + // What still waits is not retried on a timer: the next ask does that. + expect(later).toHaveLength(0); + busy.delete('b2'); + pictures.get('gen:b@2'); + expect(freed).toEqual(['a1', 'b2']); + // A new wait asks for a retry again. + busy.add('a3'); + pictures.get('gen:a@3'); + expect(later).toHaveLength(1); + }); + + it('lets a held picture wait through a change of session too', () => { + const f = held(); + f.pictures.get('gen:a@1'); + f.pictures.get('gen:b@1'); + f.busy.add('a1'); + f.pictures.setFactory((assetId) => `${assetId}-next`); + expect(f.freed).toEqual(['b2 gen:b@1']); + expect(f.pictures.keys()).toEqual([]); + f.busy.delete('a1'); + expect(f.pictures.get('gen:c@1')).toBe('c-next'); + expect(f.freed).toEqual(['b2 gen:b@1', 'a1 gen:a@1']); + }); + }); + it('answers peek only with what has been drawn', () => { const f = library(); expect(f.pictures.peek('gen:a@1')).toBeNull(); diff --git a/tools/e2e/47-vectorart.mjs b/tools/e2e/47-vectorart.mjs index 4d7bd056..dca12528 100644 --- a/tools/e2e/47-vectorart.mjs +++ b/tools/e2e/47-vectorart.mjs @@ -176,6 +176,46 @@ export async function vectorArt(t) { const repainted = await at(48, 200); check('the sprite shows the changed fill', colourDistance(repainted, REDRAWN) < 14, repainted); + // 47b-ii. The object wears the drawing through a shader asset (#293). Its shader names the picture + // until the mesh syncs, which is after the new revision was drawn: destroyed there, Pixi + // warned for the source and for its sampler with every single change. + const boundLogFrom = t.consoleLog.length; + await mutate(` + Object.assign(__gm.state.activeScene.objects.find((o) => o.id === 'va-object').overrides, + { shader: 'adjust-colors', saturation: 0 })`); + await settle(); + await sleep(300); + const waiting = []; + for (const fill of [0x3050a0, 0x70b030, REDRAWN.replace('#', '0x')]) { + await mutate(` + const art = __gm.state.project.assets.find((a) => a.id === 'va-art'); + art.layers[0].items[0].fill = ${fill}`); + await settle(); + await sleep(200); + waiting.push((await json('__gm.picturesWaiting()')).length); + } + const boundWarnings = t.consoleLog.slice(boundLogFrom).filter((line) => line.includes('BindGroup')); + const afterShaded = await json("__gm.drawnPicture('va-art')"); + // Saturation 0 turns the yellow field grey: the shader is what draws the sprite here. + const grey = await at(48, 200); + const [red, green, blue] = [1, 3, 5].map((i) => parseInt(grey.slice(i, i + 2), 16)); + const shaded = Math.max(red, green, blue) - Math.min(red, green, blue) < 12; + check( + 'a drawing worn through a shader is redrawn without Pixi warning of a destroyed texture', + shaded && + boundWarnings.length === 0 && + // The replaced picture is freed once the shader took the new one: none is left lying. + waiting.every((count) => count === 0) && + afterShaded.kept, + JSON.stringify({ grey, warnings: boundWarnings.length, waiting, file: afterShaded?.file }), + ); + await mutate(` + const shadedObject = __gm.state.activeScene.objects.find((o) => o.id === 'va-object'); + delete shadedObject.overrides.shader; + delete shadedObject.overrides.saturation`); + await settle(); + await sleep(300); + // 47c. The tab: the pen draws a triangle and closes it. await evaluate("__gm.openDocument('asset:va-art')"); await settle(); @@ -424,18 +464,28 @@ export async function vectorArt(t) { JSON.stringify(await shapeIds()), ); + // A spare shape for the two checks below: the duplicate mark makes one and selects it. The + // inspector has no button that adds an empty shape any more (#293) – the Pen draws a new one. const before47e = (await shapeIds()).length; - await clickRowButton('Add shape'); - await settle(); - const added = await shapeIds(); + const spareCopy = async () => { + const known = await shapeIds(); + const mark = await json("__gm.vectorArt.shapeDuplicateAt('va-badge')"); + if (mark) await click(mark.x, mark.y); + await settle(); + return (await shapeIds()).find((id) => !known.includes(id)) ?? null; + }; + const added = await spareCopy(); check( - 'Add shape puts an empty shape on top and selects it', - added.length === before47e + 1 && (await json('__gm.vectorArt.selected()')) === added.at(-1), - JSON.stringify({ added: added.length, selected: await json('__gm.vectorArt.selected()') }), + 'the inspector offers no Add shape button', + added !== null && + (await json('__gm.vectorArt.selected()')) === added && + (await json("__gm.inspectorButton('Add shape')")) === null && + (await json("__gm.inspectorButton('To front')")) !== null, + JSON.stringify({ added, selected: await json('__gm.vectorArt.selected()') }), ); // The cross on its row takes that shape out again, and the selection with it. - const cross = await json(`__gm.vectorArt.shapeRemoveAt(${JSON.stringify(added.at(-1))})`); + const cross = await json(`__gm.vectorArt.shapeRemoveAt(${JSON.stringify(added)})`); if (cross) await click(cross.x, cross.y); await settle(); const afterCross = await shapeIds(); @@ -443,16 +493,14 @@ export async function vectorArt(t) { 'the cross on a shape row removes that shape and nothing else', cross !== null && afterCross.length === before47e && - !afterCross.includes(added.at(-1)) && + !afterCross.includes(added) && (await json('__gm.vectorArt.selected()')) === '', JSON.stringify({ cross, afterCross, selected: await json('__gm.vectorArt.selected()') }), ); // Delete takes a shape away however it was picked. A row of the list leaves nothing focused, so the // key reaches the editor's shortcuts rather than the canvas. - await clickRowButton('Add shape'); - await settle(); - const spare = (await shapeIds()).at(-1); + const spare = await spareCopy(); const spareRow = await json(`__gm.vectorArt.shapeRowAt(${JSON.stringify(spare)})`); await click(spareRow.x + spareRow.w / 2, spareRow.y + spareRow.h / 2); await settle();