Consolidate duplicated editor code, trim comments, refresh README
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>
This commit is contained in:
1 parent
9212e61c4c
commit
8f92a06c84
96 files changed
+999
-1261
No files matched your search
@@ -121,7 +121,6 @@ export async function inspector(t) {
|
||||
check('viewport drag moved the red crate', o1Now.x > -150, JSON.stringify(o1Now));
|
||||
await shot('06-dragged');
|
||||
|
||||
// 5. Autosave → server.
|
||||
await sleep(1800);
|
||||
const saved = await api(`projects/${projectId}`);
|
||||
const renamed = sceneOf(saved).objects.find((o) => o.id === 'o2');
|
||||
|
||||
+24
-3
@@ -8,6 +8,7 @@ export async function graph(t) {
|
||||
sleep,
|
||||
evaluate,
|
||||
json,
|
||||
find,
|
||||
shot,
|
||||
click,
|
||||
clickText,
|
||||
@@ -70,9 +71,8 @@ export async function graph(t) {
|
||||
`${axisUndone.x},${axisUndone.y}`,
|
||||
);
|
||||
|
||||
// 13b. Regression: typing a query and then *clicking* a row must add exactly one node.
|
||||
// (The search field's blur-commit once picked the first match and the row click picked
|
||||
// it again → two nodes.)
|
||||
// 13b. Typing a query and then *clicking* a row adds exactly one node: the search field's
|
||||
// blur-commit must not pick the first match as well.
|
||||
const gcenter = await json('__gm.graph.center()');
|
||||
const beforeClickPick = await graphCounts();
|
||||
await click(gcenter.x + 250, gcenter.y + 250, 'right');
|
||||
@@ -742,6 +742,27 @@ export async function graph(t) {
|
||||
await undo();
|
||||
await sleep(200);
|
||||
|
||||
const forkedNow = async () =>
|
||||
(await asset('mover')).graph.edges.filter(
|
||||
(e) => e.from.node === edge.from.node && e.from.port === edge.from.port,
|
||||
).length;
|
||||
const portNow = await json(
|
||||
`__gm.graph.portAt(${JSON.stringify(edge.from.node)}, ${JSON.stringify(edge.from.port)}, true)`,
|
||||
);
|
||||
await click(portNow.x, portNow.y, 'right');
|
||||
await sleep(150);
|
||||
const offered = await find('Disconnect 2 links');
|
||||
if (offered) await click(offered.x, offered.y);
|
||||
await sleep(250);
|
||||
const cut = await forkedNow();
|
||||
await undo();
|
||||
await sleep(250);
|
||||
check(
|
||||
'disconnecting both links at a port is one undo step',
|
||||
Boolean(offered) && cut === 0 && (await forkedNow()) === 2,
|
||||
JSON.stringify({ offered: Boolean(offered), cut, after: await forkedNow() }),
|
||||
);
|
||||
|
||||
// 13q. Collapsing a node: the title bar alone, every link meeting at one point per side. No new
|
||||
// link lands on it until it is expanded, and the state is saved with the project.
|
||||
await select({ kind: 'asset', assetId: 'mover' });
|
||||
|
||||
+22
-13
@@ -2,13 +2,8 @@ import { instanceChild } from '../lib/builders.mjs';
|
||||
import { same } from '../lib/harness.mjs';
|
||||
|
||||
/**
|
||||
* Multi-selection and placed containers.
|
||||
*
|
||||
* A container placed in a scene is not copied into it: the scene stores the container object
|
||||
* alone and its content is resolved from the template on every read, so a template edit reaches
|
||||
* every instance at once (see `src/core/model/instance.ts`). That is what the checks below are
|
||||
* about – what the scene *holds* against what the editor *lists*, deviations kept per instance,
|
||||
* and the structure of resolved content being the template's business and nothing else's.
|
||||
* Multi-selection and placed containers: what the scene *holds* (the container object alone) against
|
||||
* what the editor *lists* (content resolved from the template), and deviations kept per instance.
|
||||
*/
|
||||
export async function containers(t) {
|
||||
const {
|
||||
@@ -16,6 +11,7 @@ export async function containers(t) {
|
||||
sleep,
|
||||
evaluate,
|
||||
json,
|
||||
has,
|
||||
shot,
|
||||
click,
|
||||
clickText,
|
||||
@@ -41,7 +37,6 @@ export async function containers(t) {
|
||||
projectId,
|
||||
} = t;
|
||||
const center = await json('__gm.viewportCenter()');
|
||||
const has = (text) => evaluate(`Boolean(__gm.locate(${JSON.stringify(text)}))`);
|
||||
/** Types a value into an inspector field, which runs the real command behind that row. */
|
||||
const setField = async (label, value) => {
|
||||
const row = await locate(label);
|
||||
@@ -129,10 +124,8 @@ export async function containers(t) {
|
||||
await undo();
|
||||
check('undo restores all of them', (await objects()).length === listedBeforeDelete);
|
||||
|
||||
// 16d. Create container: both crates, with their colliders and the Mover, become the content
|
||||
// of a new container asset and *leave the scene*. The scene stores one object in their
|
||||
// stead; the five members are only resolved from the template from now on, which is the
|
||||
// whole difference to copying them in – see `src/core/model/instance.ts`.
|
||||
// 16d. Create container: both crates, their colliders and the Mover *leave the scene*; one stored
|
||||
// container object stands in their stead and the five are resolved from its template.
|
||||
const assetsBefore = (await json('__gm.state.project.assets')).length;
|
||||
const storedBefore = (await storedObjects()).length;
|
||||
const listedBefore = (await objects()).length;
|
||||
@@ -306,7 +299,23 @@ export async function containers(t) {
|
||||
await selectObject(redA);
|
||||
check('the inspector offers no collider for resolved content', !(await has('+ Add collider')));
|
||||
await selectObject(instanceA.id);
|
||||
check('but it does for the stored container object', await has('+ Add collider'));
|
||||
check(
|
||||
'nor for the placed container itself, which the command refuses as the hierarchy menu does',
|
||||
!(await has('+ Add collider')),
|
||||
);
|
||||
const plainId = await json(`(() => {
|
||||
const p = __gm.state.project;
|
||||
const sprite = __gm.state.activeScene.objects.find(
|
||||
(o) => p.assets.find((a) => a.id === o.assetId)?.type === 'sprite',
|
||||
);
|
||||
return sprite ? sprite.id : null;
|
||||
})()`);
|
||||
await selectObject(plainId);
|
||||
check(
|
||||
'while a plain sprite still gets it',
|
||||
Boolean(plainId) && (await has('+ Add collider')),
|
||||
JSON.stringify({ plainId }),
|
||||
);
|
||||
await selectObject(redA);
|
||||
let noticeAfter = await notice();
|
||||
await press('Delete');
|
||||
|
||||
@@ -5,6 +5,7 @@ export async function textAndEnabled(t) {
|
||||
sleep,
|
||||
evaluate,
|
||||
json,
|
||||
has,
|
||||
shot,
|
||||
click,
|
||||
clickText,
|
||||
@@ -16,7 +17,6 @@ export async function textAndEnabled(t) {
|
||||
stop,
|
||||
typeText,
|
||||
} = t;
|
||||
const has = (text) => evaluate(`Boolean(__gm.locate(${JSON.stringify(text)}))`);
|
||||
|
||||
// 17. Text assets: created through the + menu, placed, and rendered as real text in the scene.
|
||||
await clickText('+');
|
||||
|
||||
@@ -43,10 +43,8 @@ export async function menus(t) {
|
||||
await shot('31-long-parent-menu');
|
||||
|
||||
// 18a. The entries scroll under the wheel; the search box above them does not move.
|
||||
// Anchored on a filler row: "None" is also what an empty dropdown reads, so locating it could
|
||||
// find an inspector field behind the menu instead – one that never moves. Which filler is asked
|
||||
// for is read off the open menu: the capped list only draws the rows it shows, and how many
|
||||
// objects come before them depends on what the earlier steps left in the scene.
|
||||
// Anchored on a filler row read off the open menu: "None" could match an inspector field behind it,
|
||||
// and which fillers are drawn depends on what earlier steps left in the scene.
|
||||
const visibleFillers = (await json('__gm.overlayTexts()')).filter((e) => e.startsWith('Filler '));
|
||||
const anchor = visibleFillers[visibleFillers.length - 1];
|
||||
check('the picker lists the filler objects', Boolean(anchor), JSON.stringify(visibleFillers));
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
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.
|
||||
@@ -36,13 +38,6 @@ const PASTE_SPARE = `(async () => {
|
||||
window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true }));
|
||||
})()`;
|
||||
|
||||
function colourDistance(a, b) {
|
||||
const channels = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
|
||||
const [ar, ag, ab] = channels(a);
|
||||
const [br, bg, bb] = channels(b);
|
||||
return Math.abs(ar - br) + Math.abs(ag - bg) + Math.abs(ab - bb);
|
||||
}
|
||||
|
||||
export async function textures(t) {
|
||||
const { check, sleep, json, evaluate, shot, mutate, press } = t;
|
||||
const { api, click, clickText, doubleClick, locate, undo } = t;
|
||||
@@ -143,8 +138,7 @@ export async function textures(t) {
|
||||
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`: the property was always assigned correctly, it was the
|
||||
// GPU sampler behind it that used to survive the change.
|
||||
// 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};
|
||||
@@ -162,10 +156,8 @@ export async function textures(t) {
|
||||
}
|
||||
return points;
|
||||
};
|
||||
// Read the pixels first, then ask the driver what it actually has: a failure then says by
|
||||
// itself whether the model never switched, the switch missed the GPU sampler, or the pixels
|
||||
// were measured wrong. This check has fallen over sporadically without any of the three ever
|
||||
// being distinguishable from the message.
|
||||
// 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)}`,
|
||||
@@ -184,9 +176,7 @@ export async function textures(t) {
|
||||
);
|
||||
|
||||
const soft = await sample('linear');
|
||||
// The drift — that the picture actually changed — is what makes this a test of the switch.
|
||||
// Were the switch to hang, both readings would be the blurred one, and "some pixel is blended"
|
||||
// alone would pass and hide the failure of the check above. Measured on the recorded readings:
|
||||
// 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(
|
||||
@@ -219,10 +209,8 @@ export async function textures(t) {
|
||||
);
|
||||
}
|
||||
|
||||
// 19e. "+ → Texture" offers the pictures the server already holds instead of the machine's
|
||||
// file system. Nothing collects unreferenced files, so a picture one project dropped is
|
||||
// still there for the next – which is exactly what this drives: paste a second picture,
|
||||
// undo the asset (the file stays), then pick that file out of the dialog.
|
||||
// 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)");
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { colourDistance } from '../lib/harness.mjs';
|
||||
|
||||
/**
|
||||
* The Texture tab: cutting frames out of a sheet, and a sprite wearing one.
|
||||
*
|
||||
@@ -5,13 +7,6 @@
|
||||
* of its own – that is what makes "which part of the sheet is on screen" a `pixelAt` question.
|
||||
*/
|
||||
|
||||
function colourDistance(a, b) {
|
||||
const channels = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
|
||||
const [ar, ag, ab] = channels(a);
|
||||
const [br, bg, bb] = channels(b);
|
||||
return Math.abs(ar - br) + Math.abs(ag - bg) + Math.abs(ab - bb);
|
||||
}
|
||||
|
||||
/** The four quadrants of the sheet the texture step pastes, plus the sprite's own white. */
|
||||
const PALETTE = {
|
||||
red: '#e63c3c',
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
/**
|
||||
* Labels must be shortened to fit their control, not written past it. Runs last because it
|
||||
* renames assets to absurd lengths to provoke the case.
|
||||
*
|
||||
* `__gm.textOverflows()` walks every visible text and reports the ones drawn wider than their
|
||||
* widget; against a build without the fix it reported the graph picker at 106 px over.
|
||||
* Labels must be shortened to fit their control, not written past it. Runs last because it renames
|
||||
* assets to absurd lengths to provoke the case.
|
||||
*/
|
||||
|
||||
/** A name far wider than any 200 px picker, so a control that does not truncate must spill. */
|
||||
@@ -31,8 +28,7 @@ export async function overflow(t) {
|
||||
__gm.state.select(null)`);
|
||||
await sleep(600);
|
||||
|
||||
// 21. The pickers keep the width they were given. `width` used to set only a minimum, so a
|
||||
// picker grew with whatever name it happened to show and the header reflowed on load.
|
||||
// 21. The pickers keep the width they were given instead of growing with the name they show.
|
||||
const after = await pickerWidths();
|
||||
for (const tab of ['graph', 'texture', 'container']) {
|
||||
check(
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
/**
|
||||
* The ↔ / ↕ buttons beside a sprite's Width and Height: fit that side to the proportions of the
|
||||
* picture the sprite wears, set both sides to its pixel size, and not exist at all while it wears
|
||||
* no picture. With both on offer the button is the menu form "↔ ▾", which is what the checks
|
||||
* below distinguish.
|
||||
*
|
||||
* Runs on the sheet the texture steps imported (64 × 48) and adds a frame with a deliberately
|
||||
* different shape, which is what makes "sheet or frame?" a question the numbers can answer.
|
||||
* The ↔ / ↕ buttons beside a sprite's Width and Height (absent without a picture). Runs on the imported
|
||||
* 64 × 48 sheet plus a frame of a different shape, so "sheet or frame?" shows in the numbers.
|
||||
*/
|
||||
|
||||
/** The rows are read as numbers, so a float from a division needs a tolerance. */
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { instanceChild } from '../lib/builders.mjs';
|
||||
|
||||
/**
|
||||
* The Container tab: editing a container's template with the scene view's own tools.
|
||||
*
|
||||
* Runs after the container step, which created a container asset from two crates and left two
|
||||
* instances of it in the scene. Neither of them holds a copy: what they show is resolved from
|
||||
* the template on every read, which is what makes "which instance follows a template edit"
|
||||
* a question worth asking.
|
||||
* The Container tab: editing a template with the scene view's tools. Relies on the container step
|
||||
* having left two instances of a two-crate container in the scene.
|
||||
*/
|
||||
|
||||
const NAME = 'Crate pair';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { edge, node } from '../lib/builders.mjs';
|
||||
import { MAKE_OBJECT } from './lib/scaffold.mjs';
|
||||
|
||||
/**
|
||||
* "Run in editor": a behavior that keeps running while the project is not playing, and whose
|
||||
@@ -49,7 +50,6 @@ export async function editorRun(t) {
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
const scene = __gm.state.activeScene;
|
||||
const proto = scene.objects[0];
|
||||
const asset = structuredClone(p.assets.find((a) => a.id === 'mover'));
|
||||
Object.assign(asset, {
|
||||
id: 'drifter',
|
||||
@@ -59,20 +59,8 @@ export async function editorRun(t) {
|
||||
graph: ${JSON.stringify(driftGraph)},
|
||||
});
|
||||
p.assets.push(asset);
|
||||
const make = (id, assetId, parentId, y) => {
|
||||
const o = structuredClone(proto);
|
||||
Object.assign(o, {
|
||||
id,
|
||||
assetId,
|
||||
name: id === 'er-box' ? 'Drift box' : 'Drifter',
|
||||
position: { x: 0, y, z: 0 },
|
||||
parentId,
|
||||
overrides: {},
|
||||
instanceEdits: {},
|
||||
});
|
||||
return o;
|
||||
};
|
||||
scene.objects.push(make('er-box', 'crate', '', 400), make('er-logic', 'drifter', 'er-box', 0));
|
||||
${MAKE_OBJECT}
|
||||
scene.objects.push(make('er-box', 'crate', '', 400, 'Drift box'), make('er-logic', 'drifter', 'er-box', 0, 'Drifter'));
|
||||
`);
|
||||
await sleep(400);
|
||||
|
||||
@@ -166,10 +154,8 @@ export async function editorRun(t) {
|
||||
`${JSON.stringify(stopped)} x=${stoppedAt}`,
|
||||
);
|
||||
|
||||
// 18f. Undo covers the two clicks on the switch – but not a millimetre of what the behavior did
|
||||
// while it was on. That is the price of editing the model directly, better stated here than
|
||||
// discovered later. Note the first undo takes back the *switching off*, so the behavior
|
||||
// briefly runs again between the two: undo rewinds the switch, never the clock.
|
||||
// 18f. Undo covers the two clicks on the switch but nothing the behavior did while on. The first
|
||||
// undo re-enables it, so it briefly runs again: undo rewinds the switch, never the clock.
|
||||
await undo();
|
||||
await undo();
|
||||
await sleep(300);
|
||||
@@ -188,20 +174,7 @@ export async function editorRun(t) {
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
const scene = __gm.state.activeScene;
|
||||
const proto = scene.objects[0];
|
||||
const make = (id, assetId, parentId, y) => {
|
||||
const o = structuredClone(proto);
|
||||
Object.assign(o, {
|
||||
id,
|
||||
assetId,
|
||||
name: '',
|
||||
position: { x: 0, y, z: 0 },
|
||||
parentId,
|
||||
overrides: {},
|
||||
instanceEdits: {},
|
||||
});
|
||||
return o;
|
||||
};
|
||||
${MAKE_OBJECT}
|
||||
p.assets.push({
|
||||
id: 'er-level',
|
||||
type: 'container',
|
||||
@@ -246,26 +219,13 @@ export async function editorRun(t) {
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
const scene = __gm.state.activeScene;
|
||||
const proto = scene.objects[0];
|
||||
const asset = structuredClone(p.assets.find((a) => a.id === 'drifter'));
|
||||
Object.assign(asset, { id: 'walker', name: 'Walker', graph: ${JSON.stringify(walkGraph)} });
|
||||
p.assets.push(asset);
|
||||
const make = (id, assetId, parentId, y, overrides) => {
|
||||
const o = structuredClone(proto);
|
||||
Object.assign(o, {
|
||||
id,
|
||||
assetId,
|
||||
name: '',
|
||||
position: { x: 0, y, z: 0 },
|
||||
parentId,
|
||||
overrides,
|
||||
instanceEdits: {},
|
||||
});
|
||||
return o;
|
||||
};
|
||||
${MAKE_OBJECT}
|
||||
scene.objects.push(
|
||||
make('er-walkbox', 'crate', '', 1200, {}),
|
||||
make('er-walker', 'walker', 'er-walkbox', 0, { runInEditor: true }),
|
||||
make('er-walkbox', 'crate', '', 1200),
|
||||
Object.assign(make('er-walker', 'walker', 'er-walkbox', 0), { overrides: { runInEditor: true } }),
|
||||
);
|
||||
`);
|
||||
await sleep(600);
|
||||
|
||||
+5
-10
@@ -2,12 +2,8 @@ import { edge, node } from '../lib/builders.mjs';
|
||||
import { LANES, scaffoldBehavior } from './lib/scaffold.mjs';
|
||||
|
||||
/**
|
||||
* Pausing and resuming play: the world stops where it is, the clock stops with it, and input that
|
||||
* arrives while it is held is dropped instead of firing all at once on resume.
|
||||
*
|
||||
* The step brings its own box and its own behavior. The fixture's Mover needs the arrow keys to
|
||||
* move at all, so a hold would be indistinguishable from simply not pressing anything; this one
|
||||
* drifts on its own. Everything it adds is removed again at the end.
|
||||
* Pausing freezes world and clock and drops input instead of replaying it on resume. Brings its own
|
||||
* self-drifting box, since the fixture's Mover only moves on arrow keys and a pause would look like idling.
|
||||
*/
|
||||
|
||||
const DRIFT_PER_SECOND = 100;
|
||||
@@ -35,7 +31,7 @@ const pauseGraph = {
|
||||
};
|
||||
|
||||
export async function pause(t) {
|
||||
const { check, sleep, json, shot, clickText, press, entity, world, stop } = t;
|
||||
const { check, sleep, json, has, shot, clickText, press, entity, world, stop } = t;
|
||||
const playInfo = () => json('__gm.playInfo()');
|
||||
const boxX = async () => (await entity('p-box')).x;
|
||||
const keyHits = async () => (await world()).log.filter((line) => line.includes(KEY_LOG)).length;
|
||||
@@ -82,11 +78,10 @@ export async function pause(t) {
|
||||
);
|
||||
// `locate` throws on a missing label, so the button that must be *gone* is asked for through
|
||||
// the hook, which answers with null.
|
||||
const labelShown = (text) => json(`__gm.locate(${JSON.stringify(text)}) !== null`);
|
||||
check(
|
||||
'the button offers to resume while it is held, and no longer to pause',
|
||||
(await labelShown('▶ Resume')) === true && (await labelShown('❚❚ Pause')) === false,
|
||||
`resume=${await labelShown('▶ Resume')} pause=${await labelShown('❚❚ Pause')}`,
|
||||
(await has('▶ Resume')) === true && (await has('❚❚ Pause')) === false,
|
||||
`resume=${await has('▶ Resume')} pause=${await has('❚❚ Pause')}`,
|
||||
);
|
||||
await shot('36-paused');
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { edge, node, port } from '../lib/builders.mjs';
|
||||
import { MAKE_OBJECT } from './lib/scaffold.mjs';
|
||||
|
||||
/**
|
||||
* A behavior used as a node inside another behavior's graph: the ports it grows from the other
|
||||
@@ -72,7 +73,6 @@ export async function nestedBehavior(t) {
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
const scene = __gm.state.activeScene;
|
||||
const proto = scene.objects[0];
|
||||
const mover = p.assets.find((a) => a.id === 'mover');
|
||||
// At the front of the list, not the end: these get dragged out of the asset panel below, and
|
||||
// by this point in the suite the list is long enough to scroll a row at the end out of reach.
|
||||
@@ -84,16 +84,8 @@ export async function nestedBehavior(t) {
|
||||
behavior('nb-double', 'Double', ${JSON.stringify(doubleGraph)});
|
||||
behavior('nb-caller', 'Caller', ${JSON.stringify(callerGraph)});
|
||||
behavior('nb-empty', 'Empty', { nodes: [], edges: [] });
|
||||
const make = (id, assetId, parentId, name) => {
|
||||
const o = structuredClone(proto);
|
||||
Object.assign(o, {
|
||||
id, assetId, name,
|
||||
position: { x: 0, y: 700, z: 0 },
|
||||
parentId, overrides: {}, instanceEdits: {},
|
||||
});
|
||||
return o;
|
||||
};
|
||||
scene.objects.push(make('nb-box', 'crate', '', 'Nested box'), make('nb-logic', 'nb-caller', 'nb-box', 'Caller'));
|
||||
${MAKE_OBJECT}
|
||||
scene.objects.push(make('nb-box', 'crate', '', 700, 'Nested box'), make('nb-logic', 'nb-caller', 'nb-box', 700, 'Caller'));
|
||||
`);
|
||||
await sleep(400);
|
||||
|
||||
@@ -235,8 +227,7 @@ export async function nestedBehavior(t) {
|
||||
`dropped at ${JSON.stringify(droppedAt)}, let go at ${JSON.stringify(dropAt)}`,
|
||||
);
|
||||
|
||||
// 19h. The question from the note: a behavior with no start node can still be added – and now it
|
||||
// says why nothing happens instead of sitting there looking healthy.
|
||||
// 19h. A behavior with no start node can still be added, but says why nothing happens.
|
||||
const beforeEmpty = await nodeIds();
|
||||
const emptyRow = await json(`__gm.assetRow('nb-empty')`);
|
||||
await t.dragAsset('nb-empty', spot(0.6, 0.75).x, spot(0.6, 0.75).y);
|
||||
@@ -275,7 +266,7 @@ export async function nestedBehavior(t) {
|
||||
`nodes ${beforeSelf.length} → ${afterSelf.length}, notice=${JSON.stringify(notice)} row=${JSON.stringify(selfRow)} dragging=${JSON.stringify(dragging)}`,
|
||||
);
|
||||
|
||||
// 19j. Double-clicking a call opens the behavior it runs – the other half of the note.
|
||||
// 19j. Double-clicking a call opens the behavior it runs.
|
||||
const callNode = await json(`__gm.graph.nodeAt('nb-call')`);
|
||||
check('the graph shown is still the caller', (await evaluate('__gm.graph.current()')) === 'nb-caller');
|
||||
await t.doubleClick(callNode.x, callNode.y);
|
||||
|
||||
@@ -2,12 +2,8 @@ import { edge, node } from '../lib/builders.mjs';
|
||||
import { LANES, scaffoldBehavior } from './lib/scaffold.mjs';
|
||||
|
||||
/**
|
||||
* The auto-repeat of `On key down`: a held key fires again after a delay and then on its own
|
||||
* interval, which is what lets a Tetris piece slide sideways on one press.
|
||||
*
|
||||
* The step brings its own box and behavior with *two* `On key down` nodes on the same key – one
|
||||
* plain, one repeating. One hold then measures both at once, so "it repeated" cannot be confused
|
||||
* with "the key arrived more often than expected". Everything it adds is removed again at the end.
|
||||
* The auto-repeat of `On key down`. A plain and a repeating node on the same key are measured in one
|
||||
* hold, so "it repeated" cannot be confused with "the key arrived more often than expected".
|
||||
*/
|
||||
|
||||
const PLAIN = 'plain hit';
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
* tab, and there a row per item edits, adds and removes them with the inspector's own widgets.
|
||||
*/
|
||||
export async function listTab(t) {
|
||||
const { check, sleep, json, shot, click, clickText, press, typeText, mutate, undo } = t;
|
||||
const { check, sleep, json, find, shot, click, clickText, press, typeText, mutate, undo } = t;
|
||||
|
||||
/** `locate` throws when the text is absent, which is the wrong shape for "is it there?". */
|
||||
const find = (text) => json(`__gm.locate(${JSON.stringify(text)}, false)`);
|
||||
|
||||
// A throwaway behavior carrying one list variable; removed again at the end of the step. Its
|
||||
// items are two digits so that no value can be confused with a row's index label.
|
||||
@@ -30,7 +29,6 @@ export async function listTab(t) {
|
||||
check('the editor is still on the scene tab', (await json('__gm.tab()')) === 'scene');
|
||||
await shot('01-count-button');
|
||||
|
||||
// 2. Clicking it opens the List tab on that very list.
|
||||
await click(button.x, button.y);
|
||||
await sleep(200);
|
||||
check('clicking it switches to the List tab', (await json('__gm.tab()')) === 'list');
|
||||
@@ -75,7 +73,6 @@ export async function listTab(t) {
|
||||
JSON.stringify(shortRows),
|
||||
);
|
||||
|
||||
// 5. Adding and removing whole items.
|
||||
await clickText('+ Item');
|
||||
await sleep(200);
|
||||
info = await json('__gm.list.info()');
|
||||
@@ -249,8 +246,8 @@ export async function listTab(t) {
|
||||
JSON.stringify(await cellsOf()),
|
||||
);
|
||||
|
||||
// 9. A node's unconnected `list` input carries its value in the very same params bag a setting
|
||||
// does, so it is a count button too – before this it offered a number field.
|
||||
// 9. A node's unconnected `list` input lives in the same params bag as a setting, so it is a count
|
||||
// button too.
|
||||
await mutate(`
|
||||
const a = __gm.state.project.assets.find((x) => x.id === 'celly');
|
||||
a.graph.nodes.push({ id: 'counter', type: 'Count', x: 40, y: 40, params: {} });
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
* same row, and writing one field keeps the others – the trap a shared snapshot would fall into.
|
||||
*/
|
||||
export async function structVariable(t) {
|
||||
const { check, sleep, json, shot, click, clickText, press, typeText, mutate, select, undo } = t;
|
||||
const { check, sleep, json, find, shot, click, clickText, press, typeText, mutate, select, undo } = t;
|
||||
|
||||
/** `locate` throws when the text is absent, which is the wrong shape for "is it there?". */
|
||||
const find = (text) => json(`__gm.locate(${JSON.stringify(text)}, false)`);
|
||||
const piece = () => json("__gm.state.project.assets.find((a) => a.id === 'structy').props.piece");
|
||||
/** Left edge of each row's control – one column grid means one number, in the browser's own font. */
|
||||
const leftsOf = (labels) =>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { edge, node, port, variable } from '../lib/builders.mjs';
|
||||
import { MAKE_OBJECT } from './lib/scaffold.mjs';
|
||||
|
||||
/**
|
||||
* Get variables / Set variables: one node for several of them. Their ports are the behavior's
|
||||
@@ -56,7 +57,6 @@ export async function variableNodes(t) {
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
const scene = __gm.state.activeScene;
|
||||
const proto = scene.objects[0];
|
||||
const mover = p.assets.find((a) => a.id === 'mover');
|
||||
const a = structuredClone(mover);
|
||||
Object.assign(a, {
|
||||
@@ -67,16 +67,8 @@ export async function variableNodes(t) {
|
||||
graph: ${JSON.stringify(logicGraph)},
|
||||
});
|
||||
p.assets.unshift(a);
|
||||
const make = (id, assetId, parentId, name) => {
|
||||
const o = structuredClone(proto);
|
||||
Object.assign(o, {
|
||||
id, assetId, name,
|
||||
position: { x: 0, y: 900, z: 0 },
|
||||
parentId, overrides: {}, instanceEdits: {},
|
||||
});
|
||||
return o;
|
||||
};
|
||||
scene.objects.push(make('vp-box', 'crate', '', 'Speedy box'), make('vp-brain', 'vp-logic', 'vp-box', 'Speeds'));
|
||||
${MAKE_OBJECT}
|
||||
scene.objects.push(make('vp-box', 'crate', '', 900, 'Speedy box'), make('vp-brain', 'vp-logic', 'vp-box', 900, 'Speeds'));
|
||||
`);
|
||||
await sleep(400);
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
/**
|
||||
* Steps 17–20 each need the same throwaway rig in the fixture scene: a behavior asset cloned off
|
||||
* `mover` carrying the step's own graph, a crate to hold it, and a logic child that runs it.
|
||||
* Spelled out per step it drifted four ways – `push` vs `unshift`, three different `make`
|
||||
* signatures, and y-offsets nothing kept apart.
|
||||
* The throwaway rig steps 17–20 share in the fixture scene: a behavior asset cloned off `mover` with the
|
||||
* step's graph, a crate to hold it, and a logic child that runs it.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -38,11 +36,8 @@ export const MAKE_OBJECT = `
|
||||
};`;
|
||||
|
||||
/**
|
||||
* Builds the rig and hands back the ids it created, plus `remove()` which takes them out again
|
||||
* and returns what is left – so a step's cleanup cannot fall out of step with what it made.
|
||||
*
|
||||
* `front` puts the asset at the head of the project list: the asset panel is long by this point
|
||||
* in the suite, and a row appended at the end scrolls out of reach of `dragAsset`.
|
||||
* Returns the created ids plus `remove()`, so a step's cleanup matches what it made. `front` puts the
|
||||
* asset at the head of the list: a row appended at the end scrolls out of `dragAsset`'s reach.
|
||||
*/
|
||||
export async function scaffoldBehavior(t, options) {
|
||||
const {
|
||||
|
||||
Reference in new issue
Block a user