/** * Selecting, renaming and scrubbing in the inspector, dragging in the viewport, autosave to * the server, and undo/redo of all of it. */ export async function inspector(t) { const { check, sleep, json, locate, shot, click, mouse, evaluate, settle, doubleClick, clickText, drag, press, typeText, mutate, undo, redo, state, object, api, sceneOf, projectId, } = t; // 2a. Select the blue crate in the hierarchy. await clickText('Blue crate'); let s = await state(); check( 'hierarchy click selects object', s.selection?.kind === 'object' && s.selection.objectId === 'o2', JSON.stringify(s.selection), ); await shot('02-object-selected'); // 2a-ii. Groups with a band as their head (#290). The band runs from edge to edge of the panel in // the header colour, the rows under it stand on the panel's own; the switches have a group. const section = (title) => json(`__gm.section(${JSON.stringify(title)})`); const panel = await json(`__gm.inspectorRect()`); let transform = await section('TRANSFORM'); const switches = await section('SWITCHES'); const position = await section('POSITION'); const enabledRow = await json(`__gm.inspectorField('Enabled')`); const colors = transform ? await json( `[__gm.pixelAt(${Math.round(transform.rect.x + 3)}, ${Math.round(transform.y)}),` + ` __gm.pixelAt(${Math.round(transform.rect.x + transform.rect.w - 6)}, ${Math.round(transform.y)}),` + ` __gm.pixelAt(${Math.round(transform.rect.x + 3)}, ${Math.round(transform.rect.y + transform.rect.h + 12)}),` + ` __gm.themeColor('panelHeader'), __gm.themeColor('panel')]`, ) : []; check( 'a group head is a band from edge to edge, in a colour the rows under it do not have', transform && transform.rect.x === panel.x && panel.w - transform.rect.w <= 4 && colors[0] === colors[3] && colors[1] === colors[3] && colors[2] === colors[4] && colors[3] !== colors[4], JSON.stringify({ panel, rect: transform?.rect, colors }), ); check( 'the switches of an object stand in a group of their own, above its position', switches && position && enabledRow && switches.y < enabledRow.y && enabledRow.y < position.y, JSON.stringify({ switches: switches?.y, enabled: enabledRow?.y, position: position?.y }), ); // A click on the band folds the group: its rows go, and the next band moves up against it. const folds = () => json(`localStorage.getItem('game-maker.folded-sections')`); await click(transform.x, transform.y); await settle(); transform = await section('TRANSFORM'); let offset = await section('EDITOR OFFSET (SCENE VIEW ONLY)'); const folded = { collapsed: transform.collapsed, rotation: await json(`__gm.inspectorField('Rotation')`), gap: offset.rect.y - (transform.rect.y + transform.rect.h), stored: await folds(), }; await shot('02-section-folded'); // Folded once, the group is folded on the next object too. await evaluate( `__gm.state.select({ kind: 'object', holderId: 'scene1', objectId: 'o1', objectIds: ['o1'] })`, ); await settle(); const onNext = (await section('TRANSFORM'))?.collapsed; await clickText('Blue crate'); transform = await section('TRANSFORM'); await click(transform.x, transform.y); await settle(); transform = await section('TRANSFORM'); offset = await section('EDITOR OFFSET (SCENE VIEW ONLY)'); const rotationBack = await json(`__gm.inspectorField('Rotation')`); check( 'a click on a band folds its group away, for every object, and another click brings it back', folded.collapsed === true && folded.rotation === null && folded.gap === -1 && String(folded.stored).includes('inspector:TRANSFORM') && onNext === true && transform.collapsed === false && rotationBack !== null && offset.rect.y > rotationBack.y && !String(await folds()).includes('inspector:TRANSFORM'), JSON.stringify({ folded, onNext: onNext ?? null, open: transform.collapsed, rotationBack, stored: await folds(), }), ); // 2b. Deviations are shown in gold – on the inspector label and on the hierarchy row. The blue // crate overrides width and color, the camera and the red crate's subtree nothing. const GOLD = '#f2c84b'; const PLAIN = '#e6e8ee'; const rowColor = (id) => json(`__gm.hierarchyRowColor('${id}')`); check('an overridden object is marked gold in the hierarchy', (await rowColor('o2')) === GOLD); check('an untouched object is not', (await rowColor('o3')) === PLAIN, await rowColor('o3')); check('nor is a parent whose subtree is untouched', (await rowColor('o1')) === PLAIN); check( 'the overridden field is gold, an inherited one is not', (await json(`__gm.textColor('Width')`)) === GOLD && (await json(`__gm.textColor('Height')`)) === '#8a90a0', JSON.stringify(await json(`[__gm.textColor('Width'), __gm.textColor('Height')]`)), ); // The mark also travels up: with the crate's own overrides taken away the row must stay gold // for the sake of its collider child, which keeps one. Put back right after, byte for byte. await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').overrides = {}`); check('a parent carries the mark of a deviating child', (await rowColor('o2')) === GOLD); await mutate( `__gm.state.activeScene.objects.find(o => o.id === 'o2').overrides = { width: 140, color: 0x4488ff }`, ); // 2c. Rename via inspector: the name field sits right of the "Name" label. const nameLabel = await locate('Name'); await click(nameLabel.x + 160, nameLabel.y); await press('a', { ctrl: true }); await typeText('Renamed crate'); await shot('03-typing'); // The focus border runs all the way round, not only through the rounded corners: the field's // clip once took in its own border and cut the straight edges away. const field = await json(`__gm.focusedRect()`); const edges = field ? await json( `[[${field.x + field.w / 2}, ${field.y}], [${field.x + field.w / 2}, ${field.y + field.h - 1}],` + ` [${field.x}, ${field.y + field.h / 2}], [${field.x + field.w - 1}, ${field.y + field.h / 2}]]` + `.map(([x, y]) => __gm.pixelAt(Math.round(x), Math.round(y)))`, ) : []; check( 'the focused field is outlined along all four edges', edges.length === 4 && edges.every((c) => c === '#4c8dff'), JSON.stringify({ field, edges }), ); await press('Enter'); check( 'typing + Enter renamed the object', (await object('o2')).name === 'Renamed crate', JSON.stringify((await object('o2')).name), ); await shot('04-renamed'); // 2d. A double-click into a text field selects its content, so the next keystroke replaces the // whole name instead of inserting into it. Renamed back, since later checks go by the name. await doubleClick(nameLabel.x + 160, nameLabel.y); await typeText('Twice'); await press('Enter'); check( 'double-clicking a text field selects all – typing replaces the name', (await object('o2')).name === 'Twice', JSON.stringify((await object('o2')).name), ); // Undone rather than typed back: check 2h further down counts its undos, so this must not // leave a step on the stack. await undo(); check( 'undo takes that rename back', (await object('o2')).name === 'Renamed crate', JSON.stringify((await object('o2')).name), ); // 2d-ii. A drag from right to left selects the whole name (#236). The hidden input reported its // range ordered, and its selectionchange echo made the caret the anchor after the first move. await click(nameLabel.x + 160, nameLabel.y); const nameField = await json('__gm.focusedRect()'); await press('Escape'); const midY = nameField.y + nameField.h / 2; await drag(nameField.x + nameField.w - 4, midY, nameField.x + 3, midY); const backward = await json( '({ start: document.activeElement?.selectionStart, end: document.activeElement?.selectionEnd,' + ' direction: document.activeElement?.selectionDirection })', ); await press('Escape'); check( 'dragging from right to left selects the whole name', backward.start === 0 && backward.end === 'Renamed crate'.length && backward.direction === 'backward', JSON.stringify(backward), ); // 2e. Scrub the X field (right of the "X" label) by 100 px. The model follows every move, the // editor hears of it once per frame at most (#274): counted while the button is still held. const xLabel = await locate('X'); await evaluate(`(() => { const seen = (window.__scrub02 = { events: 0, frames: 0, done: false }); seen.stop = __gm.state.on('project', () => (seen.events += 1)); const frame = () => { if (seen.done) return; seen.frames += 1; requestAnimationFrame(frame); }; requestAnimationFrame(frame); })()`); await mouse('mouseMoved', xLabel.x + 160, xLabel.y); await mouse('mousePressed', xLabel.x + 160, xLabel.y); for (let i = 1; i <= 25; i++) { await mouse('mouseMoved', xLabel.x + 160 + i * 4, xLabel.y, 'left', { buttons: 1 }); await sleep(8); } await settle(); const held = await json( '(() => { const s = window.__scrub02; s.done = true; s.stop(); return { events: s.events, frames: s.frames }; })()', ); const xHeld = (await object('o2')).position.x; await mouse('mouseReleased', xLabel.x + 260, xLabel.y); await settle(); const xNow = (await object('o2')).position.x; check('scrubbing X moved the object', xNow > 120, `x=${xNow}`); check( 'a held scrub writes every move and previews once per frame at most', xHeld === 220 && xNow === 220 && held.events >= 1 && held.events <= held.frames, JSON.stringify({ xHeld, xNow, ...held }), ); await shot('05-scrubbed'); // 2f. Type into X, then click straight into the empty viewport: the typed value must be // committed (not reverted) even though the click deselects and rebuilds the inspector. const center = await json('__gm.viewportCenter()'); // Searched, not guessed: with the console panel below, a fixed offset from the centre can // land on an object instead of empty space. const emptySpot = await json('__gm.emptySpot()'); await click(xLabel.x + 160, xLabel.y); // plain click → text editing with everything selected await typeText('300'); await click(emptySpot.x, emptySpot.y); const xTyped = (await object('o2')).position.x; s = await state(); check( 'typed X survives clicking into the viewport', xTyped === 300 && s.selection === null, `x=${xTyped} selection=${JSON.stringify(s.selection)}`, ); // 2g. Drag the red crate in the viewport. It is at world (-150,0); the view is centred // on the origin, so compute its screen position from the viewport centre. Pressed right of // its middle: the crate wears a collider, whose icon stands on the middle. await drag(center.x - 120, center.y, center.x - 20, center.y - 70); const o1Now = (await object('o1')).position; check('viewport drag moved the red crate', o1Now.x > -150, JSON.stringify(o1Now)); await shot('06-dragged'); await sleep(1800); const saved = await api(`projects/${projectId}`); const renamed = sceneOf(saved).objects.find((o) => o.id === 'o2'); check('rename persisted', renamed.name === 'Renamed crate', renamed.name); check('scrub persisted', renamed.position.x > 120, JSON.stringify(renamed.position)); check('override kept', renamed.overrides.color === 4491519, JSON.stringify(renamed.overrides)); check('save status is saved', (await state()).save === 'saved'); // 2h. Undo four times (drag, typed X, scrub, rename) with Ctrl+Z, then redo once. await click(emptySpot.x, emptySpot.y); // click empty viewport → deselect, drop focus for (let i = 0; i < 4; i++) await undo(); const afterUndo = (await t.objects()).map((o) => ({ id: o.id, name: o.name, x: o.position.x })); check( 'undo reverted drag, typed X, scrub and rename', afterUndo[0].x === -150 && afterUndo[1].x === 120 && afterUndo[1].name === 'Blue crate', JSON.stringify(afterUndo), ); await redo(); check('redo restored the rename', (await object('o2')).name === 'Renamed crate'); await shot('07-undo-redo'); // 2i. A change to the *structure* of what is shown normally rebuilds the inspector, which // destroys the widget being typed in. While a field in here has focus the rebuild has to // wait – otherwise a field whose value decides the structure cannot be typed into at all. await clickText('Renamed crate'); const xRow = await locate('X'); await click(xRow.x + 160, xRow.y); // The value it already has: Escape cancels the widget but not the preview it wrote, so // typing anything else here would leave that value behind for the later steps. await typeText('120'); const before = await json('__gm.inspectorInfo()'); // The parent is part of the object's structure signature, so this asks for a rebuild. await mutate("__gm.state.activeScene.objects.find(o => o.id === 'o2').parentId = 'o1'"); const during = await json('__gm.inspectorInfo()'); const stillTyping = await json( '({ tag: document.activeElement?.tagName, value: document.activeElement?.value })', ); check( 'a structure change while typing does not rebuild the inspector', during.rebuilds === before.rebuilds && during.postponed, JSON.stringify(during), ); check( 'the field being typed in survives it', stillTyping.tag === 'INPUT' && stillTyping.value === '120', JSON.stringify(stillTyping), ); await press('Escape'); const after = await json('__gm.inspectorInfo()'); check( 'leaving the field runs the postponed rebuild', after.rebuilds === before.rebuilds + 1 && !after.postponed, JSON.stringify(after), ); await mutate("__gm.state.activeScene.objects.find(o => o.id === 'o2').parentId = ''"); }