Add ESLint and Prettier, and close the node test gaps
Tooling: - Prettier as the single formatting authority (110 columns, single quotes, measured against the existing style); Markdown excluded because table-cell padding turns the README glossary into 1000-character lines. - Type-aware ESLint ending in eslint-config-prettier, so a rule always means "this is a bug", never "this looks different". - .editorconfig and an `npm run check` that runs typecheck, lint, format and tests in one go. Findings fixed on the first run: three more duplicate imports, four String(unknown) calls (parameter types narrowed instead of casts), two dead initialisers, two redundant casts, a console.log for a real error. Two warnings were false here and are disabled with a reason in place. Tests 132 -> 269: - node-catalog.test.ts covers every node with a run(), with a guard that fails when a new node has no row. - behavior-events.test.ts covers the ten event nodes through a running World, checking timing rather than existence. - Input's state machine (press/release/releaseAll) is reachable without DOM events now; the DOM handlers are thin adapters. Compare fell back to == without a configured operator although the schema default is <; aligned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f140c2fd77
commit
d54a620ebd
113 files changed
+5049
-666
No files matched your search
@@ -11,7 +11,11 @@ export async function hierarchy(t) {
|
||||
const rows = async () => JSON.stringify(await rowIds());
|
||||
const selectionBefore = (await state()).selection;
|
||||
|
||||
check('hierarchy starts collapsed – only top-level objects are listed', same(await rowIds(), ['o1', 'o2', 'o3', 'o5']), await rows());
|
||||
check(
|
||||
'hierarchy starts collapsed – only top-level objects are listed',
|
||||
same(await rowIds(), ['o1', 'o2', 'o3', 'o5']),
|
||||
await rows(),
|
||||
);
|
||||
check('a leaf row offers no chevron', (await evaluate(`__gm.hierarchyChevron('o3')`)) === null);
|
||||
const chevron = await json(`__gm.hierarchyChevron('o2')`);
|
||||
await click(chevron.x, chevron.y);
|
||||
@@ -22,10 +26,18 @@ export async function hierarchy(t) {
|
||||
);
|
||||
await shot('01a-hierarchy-unfolded');
|
||||
await click(chevron.x, chevron.y);
|
||||
check('clicking it again folds the subtree away', same(await rowIds(), ['o1', 'o2', 'o3', 'o5']), await rows());
|
||||
check(
|
||||
'clicking it again folds the subtree away',
|
||||
same(await rowIds(), ['o1', 'o2', 'o3', 'o5']),
|
||||
await rows(),
|
||||
);
|
||||
await select({ kind: 'object', sceneId: 'scene1', objectId: 'oc1' });
|
||||
await sleep(150);
|
||||
check('selecting a nested object unfolds its ancestors', same(await rowIds(), ['o1', 'o4', 'oc1', 'o2', 'o3', 'o5']), await rows());
|
||||
check(
|
||||
'selecting a nested object unfolds its ancestors',
|
||||
same(await rowIds(), ['o1', 'o4', 'oc1', 'o2', 'o3', 'o5']),
|
||||
await rows(),
|
||||
);
|
||||
await evaluate('__gm.state.select(null); __gm.hierarchyCollapseAll()');
|
||||
await sleep(150);
|
||||
check('Collapse all folds everything back', same(await rowIds(), ['o1', 'o2', 'o3', 'o5']), await rows());
|
||||
|
||||
@@ -3,12 +3,34 @@
|
||||
* the server, and undo/redo of all of it.
|
||||
*/
|
||||
export async function inspector(t) {
|
||||
const { check, sleep, json, locate, shot, click, clickText, drag, press, typeText, undo, redo, state, object, api, sceneOf, projectId } = t;
|
||||
const {
|
||||
check,
|
||||
sleep,
|
||||
json,
|
||||
locate,
|
||||
shot,
|
||||
click,
|
||||
clickText,
|
||||
drag,
|
||||
press,
|
||||
typeText,
|
||||
undo,
|
||||
redo,
|
||||
state,
|
||||
object,
|
||||
api,
|
||||
sceneOf,
|
||||
projectId,
|
||||
} = t;
|
||||
|
||||
// 1. 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));
|
||||
check(
|
||||
'hierarchy click selects object',
|
||||
s.selection?.kind === 'object' && s.selection.objectId === 'o2',
|
||||
JSON.stringify(s.selection),
|
||||
);
|
||||
await shot('02-object-selected');
|
||||
|
||||
// 2. Rename via inspector: the name field sits right of the "Name" label.
|
||||
@@ -19,7 +41,11 @@ export async function inspector(t) {
|
||||
await shot('03-typing');
|
||||
await press('Enter');
|
||||
await sleep(200);
|
||||
check('typing + Enter renamed the object', (await object('o2')).name === 'Renamed crate', JSON.stringify((await object('o2')).name));
|
||||
check(
|
||||
'typing + Enter renamed the object',
|
||||
(await object('o2')).name === 'Renamed crate',
|
||||
JSON.stringify((await object('o2')).name),
|
||||
);
|
||||
await shot('04-renamed');
|
||||
|
||||
// 3. Scrub the X field (right of the "X" label) by 100 px.
|
||||
@@ -37,7 +63,11 @@ export async function inspector(t) {
|
||||
await click(center.x, center.y + 250);
|
||||
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)}`);
|
||||
check(
|
||||
'typed X survives clicking into the viewport',
|
||||
xTyped === 300 && s.selection === null,
|
||||
`x=${xTyped} selection=${JSON.stringify(s.selection)}`,
|
||||
);
|
||||
|
||||
// 4. 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.
|
||||
@@ -59,7 +89,11 @@ export async function inspector(t) {
|
||||
await click(center.x, center.y + 200); // 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));
|
||||
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');
|
||||
|
||||
+89
-15
@@ -7,7 +7,25 @@ const FIXTURE_IDS = ['o1', 'o2', 'o3', 'o4', 'o5', 'oc1', 'oc2'];
|
||||
* camera.
|
||||
*/
|
||||
export async function play(t) {
|
||||
const { check, sleep, evaluate, json, shot, clickText, press, holdKey, undo, state, object, objects, world, entity, select, mutate, stop } = t;
|
||||
const {
|
||||
check,
|
||||
sleep,
|
||||
evaluate,
|
||||
json,
|
||||
shot,
|
||||
clickText,
|
||||
press,
|
||||
holdKey,
|
||||
undo,
|
||||
state,
|
||||
object,
|
||||
objects,
|
||||
world,
|
||||
entity,
|
||||
select,
|
||||
mutate,
|
||||
stop,
|
||||
} = t;
|
||||
const gameInfo = () => json('__gm.gameInfo()');
|
||||
const play = t.play;
|
||||
|
||||
@@ -20,40 +38,72 @@ export async function play(t) {
|
||||
let s = await state();
|
||||
check('play mode entered', s.mode === 'play', JSON.stringify(s));
|
||||
let info = await gameInfo();
|
||||
check('play renders the camera frame with both crates', info.rendering && info.visibleItems === 2, JSON.stringify(info));
|
||||
check(
|
||||
'play renders the camera frame with both crates',
|
||||
info.rendering && info.visibleItems === 2,
|
||||
JSON.stringify(info),
|
||||
);
|
||||
await shot('08-playing');
|
||||
await holdKey('ArrowRight', 400);
|
||||
const movedCrate = await entity('o1');
|
||||
check('behavior graph moved its owner while ArrowRight was held', movedCrate.x > -150 + 30, `world x=${movedCrate.x}`);
|
||||
check(
|
||||
'behavior graph moved its owner while ArrowRight was held',
|
||||
movedCrate.x > -150 + 30,
|
||||
`world x=${movedCrate.x}`,
|
||||
);
|
||||
await holdKey('ArrowLeft', 250);
|
||||
const afterLeft = await entity('o1');
|
||||
check('ArrowLeft (axis x = −1) moved the owner back left', afterLeft.x < movedCrate.x, `world x=${afterLeft.x}`);
|
||||
check(
|
||||
'ArrowLeft (axis x = −1) moved the owner back left',
|
||||
afterLeft.x < movedCrate.x,
|
||||
`world x=${afterLeft.x}`,
|
||||
);
|
||||
await stop();
|
||||
s = await state();
|
||||
check('escape stopped play', s.mode === 'edit', JSON.stringify(s));
|
||||
await shot('09-stopped');
|
||||
const o2AfterPlay = (await object('o2')).position.x;
|
||||
const o1AfterPlay = (await object('o1')).position.x;
|
||||
check('play did not write back to the model', o2AfterPlay === xBeforePlay && o1AfterPlay === -150, `${o2AfterPlay} vs ${xBeforePlay}, o1=${o1AfterPlay}`);
|
||||
check(
|
||||
'play did not write back to the model',
|
||||
o2AfterPlay === xBeforePlay && o1AfterPlay === -150,
|
||||
`${o2AfterPlay} vs ${xBeforePlay}, o1=${o1AfterPlay}`,
|
||||
);
|
||||
|
||||
// 7a. Editing while playing: Play opens the Game tab, the panels stay open and edit a runtime
|
||||
// copy. Moving the blue crate (model hook + preview, as a scrub does) and deleting it with
|
||||
// the Delete key change the world at once, undo brings it back, Esc restores the saved model.
|
||||
await play();
|
||||
check('play opens the Game tab', (await evaluate('__gm.tab()')) === 'game');
|
||||
check('panels stay visible while playing', Boolean(await evaluate(`__gm.locate('INSPECTOR')`)) && (await evaluate('__gm.hierarchyExpandAll(); __gm.hierarchyOrder().length')) === 7);
|
||||
check(
|
||||
'panels stay visible while playing',
|
||||
Boolean(await evaluate(`__gm.locate('INSPECTOR')`)) &&
|
||||
(await evaluate('__gm.hierarchyExpandAll(); __gm.hierarchyOrder().length')) === 7,
|
||||
);
|
||||
await select({ kind: 'object', sceneId: 'scene1', objectId: 'o2' });
|
||||
await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = 400`);
|
||||
let liveO2 = await entity('o2');
|
||||
check('an edit while playing moves the entity in the world', liveO2 && liveO2.x === 400, JSON.stringify(liveO2));
|
||||
check(
|
||||
'an edit while playing moves the entity in the world',
|
||||
liveO2 && liveO2.x === 400,
|
||||
JSON.stringify(liveO2),
|
||||
);
|
||||
await shot('08b-editing-while-playing');
|
||||
await press('Delete');
|
||||
await sleep(150);
|
||||
const liveIds = (await world()).entities.map((e) => e.objectId);
|
||||
check('Delete while playing removes the entity and its collider child from the world', !liveIds.includes('o2') && !liveIds.includes('oc2') && liveIds.length === 5, JSON.stringify(liveIds));
|
||||
check(
|
||||
'Delete while playing removes the entity and its collider child from the world',
|
||||
!liveIds.includes('o2') && !liveIds.includes('oc2') && liveIds.length === 5,
|
||||
JSON.stringify(liveIds),
|
||||
);
|
||||
await undo();
|
||||
liveO2 = await entity('o2');
|
||||
check('undo while playing brings the entity back into the world', liveO2 && liveO2.x === 400, JSON.stringify(liveO2));
|
||||
check(
|
||||
'undo while playing brings the entity back into the world',
|
||||
liveO2 && liveO2.x === 400,
|
||||
JSON.stringify(liveO2),
|
||||
);
|
||||
|
||||
// 7a-ii. Spawning: the "Spawner" behavior places a Crate at (0, 150) on Space. The new object
|
||||
// shows up in the runtime scene (hierarchy), in the world and in the Game view at once;
|
||||
@@ -65,18 +115,38 @@ export async function play(t) {
|
||||
const spawned = spawnedObjects.find((o) => !FIXTURE_IDS.includes(o.id));
|
||||
check(
|
||||
'Spawn node adds a crate object to the runtime scene at (0, 150)',
|
||||
spawned && spawned.assetId === 'crate' && spawned.parentId === '' && spawned.position.x === 0 && spawned.position.y === 150 && spawnedObjects.length === 8,
|
||||
spawned &&
|
||||
spawned.assetId === 'crate' &&
|
||||
spawned.parentId === '' &&
|
||||
spawned.position.x === 0 &&
|
||||
spawned.position.y === 150 &&
|
||||
spawnedObjects.length === 8,
|
||||
JSON.stringify(spawned),
|
||||
);
|
||||
const spawnWorld = await world();
|
||||
check('the spawned entity lives in the world and its name reached the Log node', spawnWorld.entities.length === 8 && spawnWorld.log.includes('Crate'), JSON.stringify({ n: spawnWorld.entities.length, log: spawnWorld.log }));
|
||||
check('hierarchy lists the spawned object', (await evaluate('__gm.hierarchyExpandAll(); __gm.hierarchyOrder().length')) === 8);
|
||||
check(
|
||||
'the spawned entity lives in the world and its name reached the Log node',
|
||||
spawnWorld.entities.length === 8 && spawnWorld.log.includes('Crate'),
|
||||
JSON.stringify({ n: spawnWorld.entities.length, log: spawnWorld.log }),
|
||||
);
|
||||
check(
|
||||
'hierarchy lists the spawned object',
|
||||
(await evaluate('__gm.hierarchyExpandAll(); __gm.hierarchyOrder().length')) === 8,
|
||||
);
|
||||
info = await gameInfo();
|
||||
check('the Game view renders the spawned crate', info.visibleItems === visibleBeforeSpawn + 1, `${JSON.stringify(info)} before=${visibleBeforeSpawn}`);
|
||||
check(
|
||||
'the Game view renders the spawned crate',
|
||||
info.visibleItems === visibleBeforeSpawn + 1,
|
||||
`${JSON.stringify(info)} before=${visibleBeforeSpawn}`,
|
||||
);
|
||||
await shot('08c-spawned');
|
||||
await stop();
|
||||
const o2AfterEdit = (await object('o2')).position.x;
|
||||
check('stop restores the saved model untouched by play-mode edits (spawned object gone)', o2AfterEdit === xBeforePlay && (await objects()).length === 7, `x=${o2AfterEdit}`);
|
||||
check(
|
||||
'stop restores the saved model untouched by play-mode edits (spawned object gone)',
|
||||
o2AfterEdit === xBeforePlay && (await objects()).length === 7,
|
||||
`x=${o2AfterEdit}`,
|
||||
);
|
||||
check('stop returns to the Scene tab', (await evaluate('__gm.tab()')) === 'scene');
|
||||
|
||||
// 7b. Culling: move the blue crate far outside the camera frame → only one item visible.
|
||||
@@ -95,7 +165,11 @@ export async function play(t) {
|
||||
await play();
|
||||
info = await gameInfo();
|
||||
s = await state();
|
||||
check('play without camera renders nothing', s.mode === 'play' && !info.rendering && info.visibleItems === 0, JSON.stringify(info));
|
||||
check(
|
||||
'play without camera renders nothing',
|
||||
s.mode === 'play' && !info.rendering && info.visibleItems === 0,
|
||||
JSON.stringify(info),
|
||||
);
|
||||
check('play without camera shows a notice', s.notice.includes('no camera'), s.notice);
|
||||
await shot('10-play-no-camera');
|
||||
await stop();
|
||||
|
||||
+10
-2
@@ -28,10 +28,18 @@ export async function assets(t) {
|
||||
check('asset inspector shows a reset for the non-default height', Boolean(resetButton));
|
||||
await click(resetButton.x, resetButton.y);
|
||||
await sleep(100);
|
||||
check('reset puts the asset property back to its default', (await asset('crate')).props.height === 100, `height=${(await asset('crate')).props.height}`);
|
||||
check(
|
||||
'reset puts the asset property back to its default',
|
||||
(await asset('crate')).props.height === 100,
|
||||
`height=${(await asset('crate')).props.height}`,
|
||||
);
|
||||
check('reset button hidden once the value is at default', !(await evaluate(`__gm.locate('↺')`)));
|
||||
await shot('15-asset-reset');
|
||||
await undo();
|
||||
check('undo restores the asset value', (await asset('crate')).props.height === 60, `height=${(await asset('crate')).props.height}`);
|
||||
check(
|
||||
'undo restores the asset value',
|
||||
(await asset('crate')).props.height === 60,
|
||||
`height=${(await asset('crate')).props.height}`,
|
||||
);
|
||||
check('reset button visible again after undo', Boolean(await evaluate(`__gm.locate('↺')`)));
|
||||
}
|
||||
@@ -13,13 +13,23 @@ export async function drawing(t) {
|
||||
crate.position.x = 0; crate.position.y = 0;
|
||||
__gm.state.select(null)`);
|
||||
const layers = await json(`[__gm.layerOf('o3'), __gm.layerOf('o1'), __gm.layerOf('o2')]`);
|
||||
check('camera symbol is drawn in the overlay layer above sprites', JSON.stringify(layers) === '["overlay","objects","objects"]', JSON.stringify(layers));
|
||||
check(
|
||||
'camera symbol is drawn in the overlay layer above sprites',
|
||||
JSON.stringify(layers) === '["overlay","objects","objects"]',
|
||||
JSON.stringify(layers),
|
||||
);
|
||||
await shot('13-symbol-on-top');
|
||||
|
||||
// 11. Z-order: put both crates on the origin. With equal z the hierarchy order wins
|
||||
// (o1 before o2 → o2 on top); raising o1's z draws it above o2; undo restores it.
|
||||
await mutate(`const o2 = __gm.state.activeScene.objects.find(o => o.id === 'o2'); o2.position.x = 0; o2.position.y = 0`);
|
||||
check('equal z keeps hierarchy order', JSON.stringify(await orderOfCrates()) === '["o1","o2"]', JSON.stringify(await orderOfCrates()));
|
||||
await mutate(
|
||||
`const o2 = __gm.state.activeScene.objects.find(o => o.id === 'o2'); o2.position.x = 0; o2.position.y = 0`,
|
||||
);
|
||||
check(
|
||||
'equal z keeps hierarchy order',
|
||||
JSON.stringify(await orderOfCrates()) === '["o1","o2"]',
|
||||
JSON.stringify(await orderOfCrates()),
|
||||
);
|
||||
await select({ kind: 'object', sceneId: 'scene1', objectId: 'o1' });
|
||||
await sleep(200);
|
||||
const zLabel = await locate('Z');
|
||||
@@ -27,8 +37,16 @@ export async function drawing(t) {
|
||||
await typeText('5');
|
||||
await press('Enter');
|
||||
await sleep(200);
|
||||
check('Z field sets position.z', (await object('o1')).position.z === 5, `z=${(await object('o1')).position.z}`);
|
||||
check('higher z is drawn on top', JSON.stringify(await orderOfCrates()) === '["o2","o1"]', JSON.stringify(await orderOfCrates()));
|
||||
check(
|
||||
'Z field sets position.z',
|
||||
(await object('o1')).position.z === 5,
|
||||
`z=${(await object('o1')).position.z}`,
|
||||
);
|
||||
check(
|
||||
'higher z is drawn on top',
|
||||
JSON.stringify(await orderOfCrates()) === '["o2","o1"]',
|
||||
JSON.stringify(await orderOfCrates()),
|
||||
);
|
||||
await shot('14-z-order');
|
||||
await undo();
|
||||
check('undo restores z', (await object('o1')).position.z === 0, `z=${(await object('o1')).position.z}`);
|
||||
|
||||
+88
-16
@@ -3,7 +3,25 @@
|
||||
* and Enter), connecting ports, deleting, the live highlight while playing, variables.
|
||||
*/
|
||||
export async function graph(t) {
|
||||
const { check, sleep, evaluate, json, shot, click, clickText, drag, locate, typeText, press, holdKey, undo, state, select, stop, asset } = t;
|
||||
const {
|
||||
check,
|
||||
sleep,
|
||||
evaluate,
|
||||
json,
|
||||
shot,
|
||||
click,
|
||||
clickText,
|
||||
drag,
|
||||
locate,
|
||||
typeText,
|
||||
press,
|
||||
holdKey,
|
||||
undo,
|
||||
state,
|
||||
select,
|
||||
stop,
|
||||
asset,
|
||||
} = t;
|
||||
const nodeOf = async (id) => (await asset('mover')).graph.nodes.find((n) => n.id === id);
|
||||
const graphCounts = async () => {
|
||||
const g = (await asset('mover')).graph;
|
||||
@@ -15,7 +33,10 @@ export async function graph(t) {
|
||||
await clickText('Mover');
|
||||
await evaluate(`__gm.showTab('graph')`);
|
||||
await sleep(250);
|
||||
check('graph editor shows the selected behavior', (await evaluate(`__gm.graph.current()`)) === 'mover' && (await evaluate('__gm.tab()')) === 'graph');
|
||||
check(
|
||||
'graph editor shows the selected behavior',
|
||||
(await evaluate(`__gm.graph.current()`)) === 'mover' && (await evaluate('__gm.tab()')) === 'graph',
|
||||
);
|
||||
await shot('16-graph');
|
||||
|
||||
// 13a. Drag a node: clicking its title selects it, dragging moves it (undoable).
|
||||
@@ -26,13 +47,24 @@ export async function graph(t) {
|
||||
const axisAfter = await nodeOf('n-axis');
|
||||
check(
|
||||
'dragging a node selects and moves it',
|
||||
s.selection?.kind === 'node' && s.selection.nodeId === 'n-axis' && axisAfter.x === axisBefore.x + 60 && axisAfter.y === axisBefore.y + 40,
|
||||
s.selection?.kind === 'node' &&
|
||||
s.selection.nodeId === 'n-axis' &&
|
||||
axisAfter.x === axisBefore.x + 60 &&
|
||||
axisAfter.y === axisBefore.y + 40,
|
||||
`${JSON.stringify(s.selection)} ${axisBefore.x},${axisBefore.y} → ${axisAfter.x},${axisAfter.y}`,
|
||||
);
|
||||
check('node inspector shows the node', Boolean(await evaluate(`__gm.locate('Axis input')`)) && Boolean(await evaluate(`__gm.locate('DATA NODE')`)));
|
||||
check(
|
||||
'node inspector shows the node',
|
||||
Boolean(await evaluate(`__gm.locate('Axis input')`)) &&
|
||||
Boolean(await evaluate(`__gm.locate('DATA NODE')`)),
|
||||
);
|
||||
await undo();
|
||||
const axisUndone = await nodeOf('n-axis');
|
||||
check('undo moves the node back', axisUndone.x === axisBefore.x && axisUndone.y === axisBefore.y, `${axisUndone.x},${axisUndone.y}`);
|
||||
check(
|
||||
'undo moves the node back',
|
||||
axisUndone.x === axisBefore.x && axisUndone.y === axisBefore.y,
|
||||
`${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
|
||||
@@ -48,7 +80,11 @@ export async function graph(t) {
|
||||
await click(stopRow.x, stopRow.y);
|
||||
await sleep(100);
|
||||
const afterClickPick = await graphCounts();
|
||||
check('clicking a palette row adds exactly one node', afterClickPick.nodes === beforeClickPick.nodes + 1, JSON.stringify({ beforeClickPick, afterClickPick }));
|
||||
check(
|
||||
'clicking a palette row adds exactly one node',
|
||||
afterClickPick.nodes === beforeClickPick.nodes + 1,
|
||||
JSON.stringify({ beforeClickPick, afterClickPick }),
|
||||
);
|
||||
check('palette closed after row click', !(await evaluate(`__gm.locate('Search nodes…')`)));
|
||||
await undo(); // so the next step finds the same empty spot and the expected graph
|
||||
check('undo removes the clicked node again', (await graphCounts()).nodes === beforeClickPick.nodes);
|
||||
@@ -65,7 +101,11 @@ export async function graph(t) {
|
||||
const after13 = await graphCounts();
|
||||
s = await state();
|
||||
const newNode = s.selection?.kind === 'node' ? await nodeOf(s.selection.nodeId) : null;
|
||||
check('palette adds a Log node and selects it', after13.nodes === before13.nodes + 1 && newNode?.type === 'Log', JSON.stringify({ after13, newNode }));
|
||||
check(
|
||||
'palette adds a Log node and selects it',
|
||||
after13.nodes === before13.nodes + 1 && newNode?.type === 'Log',
|
||||
JSON.stringify({ after13, newNode }),
|
||||
);
|
||||
await shot('17-node-added');
|
||||
|
||||
// 13d. Connect: drag from the Move node's exec output onto the Log node's exec input. An
|
||||
@@ -74,10 +114,17 @@ export async function graph(t) {
|
||||
const toPort = await json(`__gm.graph.portAt(${JSON.stringify(newNode.id)}, 'exec', false)`);
|
||||
await drag(fromPort.x, fromPort.y, toPort.x, toPort.y, 16);
|
||||
const connected = await graphCounts();
|
||||
check('dragging port to port connects the nodes', connected.edges === before13.edges + 1, JSON.stringify(connected));
|
||||
check(
|
||||
'dragging port to port connects the nodes',
|
||||
connected.edges === before13.edges + 1,
|
||||
JSON.stringify(connected),
|
||||
);
|
||||
const dataPort = await json(`__gm.graph.portAt(${JSON.stringify(newNode.id)}, 'message', false)`);
|
||||
await drag(fromPort.x, fromPort.y, dataPort.x, dataPort.y, 16);
|
||||
check('incompatible ports are rejected', (await graphCounts()).edges === before13.edges + 1 && s.mode === 'edit');
|
||||
check(
|
||||
'incompatible ports are rejected',
|
||||
(await graphCounts()).edges === before13.edges + 1 && s.mode === 'edit',
|
||||
);
|
||||
await shot('18-connected');
|
||||
|
||||
// 13e. Delete removes the selected node together with its edge; undo brings both back.
|
||||
@@ -85,22 +132,37 @@ export async function graph(t) {
|
||||
await press('Delete');
|
||||
await sleep(150);
|
||||
const deleted = await graphCounts();
|
||||
check('Delete removes the node and its edge', deleted.nodes === before13.nodes && deleted.edges === before13.edges, JSON.stringify(deleted));
|
||||
check(
|
||||
'Delete removes the node and its edge',
|
||||
deleted.nodes === before13.nodes && deleted.edges === before13.edges,
|
||||
JSON.stringify(deleted),
|
||||
);
|
||||
await undo();
|
||||
const restored = await graphCounts();
|
||||
check('undo restores node and edge', restored.nodes === before13.nodes + 1 && restored.edges === before13.edges + 1, JSON.stringify(restored));
|
||||
check(
|
||||
'undo restores node and edge',
|
||||
restored.nodes === before13.nodes + 1 && restored.edges === before13.edges + 1,
|
||||
JSON.stringify(restored),
|
||||
);
|
||||
|
||||
// 13f. The fps readout runs in both modes. Play always starts on the Game tab; switching to
|
||||
// Graph while playing shows the live highlight: hold ArrowRight → the exec edge is hot.
|
||||
const fpsEdit = await evaluate('__gm.fps()');
|
||||
check('fps readout is measured in edit mode', typeof fpsEdit === 'number' && fpsEdit > 0, `fps=${fpsEdit}`);
|
||||
// Single evaluate: the readout refreshes twice a second, so read value and label together.
|
||||
check('fps label is visible in the toolbar', (await evaluate(`!!__gm.locate(__gm.fps() + ' fps')`)) === true);
|
||||
check(
|
||||
'fps label is visible in the toolbar',
|
||||
(await evaluate(`!!__gm.locate(__gm.fps() + ' fps')`)) === true,
|
||||
);
|
||||
await t.play();
|
||||
check('play switches to the game tab', (await evaluate('__gm.tab()')) === 'game');
|
||||
await sleep(600);
|
||||
const fpsPlay = await evaluate('__gm.fps()');
|
||||
check('fps readout keeps running in play mode', typeof fpsPlay === 'number' && fpsPlay > 0, `fps=${fpsPlay}`);
|
||||
check(
|
||||
'fps readout keeps running in play mode',
|
||||
typeof fpsPlay === 'number' && fpsPlay > 0,
|
||||
`fps=${fpsPlay}`,
|
||||
);
|
||||
await evaluate(`__gm.showTab('graph')`);
|
||||
await sleep(100);
|
||||
const holding = holdKey('ArrowRight', 300);
|
||||
@@ -115,7 +177,11 @@ export async function graph(t) {
|
||||
await clickText('+ Variable ▾');
|
||||
await clickText('Number');
|
||||
await sleep(100);
|
||||
check('adding a variable declares it on the behavior', (await variableKeys()) === 'speed,var1', await variableKeys());
|
||||
check(
|
||||
'adding a variable declares it on the behavior',
|
||||
(await variableKeys()) === 'speed,var1',
|
||||
await variableKeys(),
|
||||
);
|
||||
await shot('20-variable-added');
|
||||
await undo();
|
||||
check('undo removes the variable', (await variableKeys()) === 'speed', await variableKeys());
|
||||
@@ -126,9 +192,15 @@ export async function graph(t) {
|
||||
check('graph editor empties when nothing is selected', (await evaluate(`__gm.graph.current()`)) === null);
|
||||
await select({ kind: 'asset', assetId: 'cam' });
|
||||
await sleep(100);
|
||||
check('graph editor stays empty for a non-behavior asset', (await evaluate(`__gm.graph.current()`)) === null);
|
||||
check(
|
||||
'graph editor stays empty for a non-behavior asset',
|
||||
(await evaluate(`__gm.graph.current()`)) === null,
|
||||
);
|
||||
await select({ kind: 'asset', assetId: 'mover' });
|
||||
await sleep(100);
|
||||
check('graph editor rebinds when a behavior is selected again', (await evaluate(`__gm.graph.current()`)) === 'mover');
|
||||
check(
|
||||
'graph editor rebinds when a behavior is selected again',
|
||||
(await evaluate(`__gm.graph.current()`)) === 'mover',
|
||||
);
|
||||
await evaluate(`__gm.showTab('scene')`);
|
||||
}
|
||||
@@ -6,7 +6,24 @@ import { same } from '../lib/harness.mjs';
|
||||
* position is kept every time.
|
||||
*/
|
||||
export async function parenting(t) {
|
||||
const { check, sleep, json, shot, click, clickText, drag, locate, typeText, press, undo, mutate, select, object, entity, stop } = t;
|
||||
const {
|
||||
check,
|
||||
sleep,
|
||||
json,
|
||||
shot,
|
||||
click,
|
||||
clickText,
|
||||
drag,
|
||||
locate,
|
||||
typeText,
|
||||
press,
|
||||
undo,
|
||||
mutate,
|
||||
select,
|
||||
object,
|
||||
entity,
|
||||
stop,
|
||||
} = t;
|
||||
|
||||
// 14. Drag the blue crate's row onto the red crate's row: it becomes a child, keeps its world
|
||||
// position (local becomes the difference) and is listed right below its parent.
|
||||
@@ -21,11 +38,19 @@ export async function parenting(t) {
|
||||
await drag(blueRow.x, blueRow.y, redRow.x, redRow.y);
|
||||
await sleep(100);
|
||||
let o2 = await object('o2');
|
||||
check('dropping a row onto another parents it, keeping the world position', o2.parentId === 'o1' && o2.position.x === 270 && o2.position.y === -80, JSON.stringify(o2));
|
||||
check(
|
||||
'dropping a row onto another parents it, keeping the world position',
|
||||
o2.parentId === 'o1' && o2.position.x === 270 && o2.position.y === -80,
|
||||
JSON.stringify(o2),
|
||||
);
|
||||
const order = await json('(__gm.hierarchyExpandAll(), __gm.hierarchyOrder())');
|
||||
// o1's subtree: o2 (list order before o4) with its collider child, then o4, then o1's collider.
|
||||
const o1At = order.indexOf('o1');
|
||||
check('children are listed directly below their parent', same(order.slice(o1At + 1, o1At + 5), ['o2', 'oc2', 'o4', 'oc1']), JSON.stringify(order));
|
||||
check(
|
||||
'children are listed directly below their parent',
|
||||
same(order.slice(o1At + 1, o1At + 5), ['o2', 'oc2', 'o4', 'oc1']),
|
||||
JSON.stringify(order),
|
||||
);
|
||||
await shot('20-parented');
|
||||
|
||||
// 14a. Moving the parent (X field of the red crate) drags the child along in world space.
|
||||
@@ -39,19 +64,31 @@ export async function parenting(t) {
|
||||
o2 = await object('o2');
|
||||
const parent = await object('o2').then((o) => object(o.parentId));
|
||||
const worldOfO2 = { x: o2.position.x + parent.position.x, y: o2.position.y + parent.position.y };
|
||||
check('moving the parent moves the child in world space (local unchanged)', o2.position.x === 270 && same(worldOfO2, { x: 220, y: -80 }), `${JSON.stringify(o2.position)} world=${JSON.stringify(worldOfO2)}`);
|
||||
check(
|
||||
'moving the parent moves the child in world space (local unchanged)',
|
||||
o2.position.x === 270 && same(worldOfO2, { x: 220, y: -80 }),
|
||||
`${JSON.stringify(o2.position)} world=${JSON.stringify(worldOfO2)}`,
|
||||
);
|
||||
|
||||
// 14b. Play: entities mirror the hierarchy; undo restores parent and position.
|
||||
await select(null);
|
||||
await press('Enter', { ctrl: true });
|
||||
await sleep(300);
|
||||
const e2 = await entity('o2');
|
||||
check('play instantiates the parent link and world positions', e2 && e2.parent === 'o1' && e2.worldX === 220 && e2.x === 270, JSON.stringify(e2));
|
||||
check(
|
||||
'play instantiates the parent link and world positions',
|
||||
e2 && e2.parent === 'o1' && e2.worldX === 220 && e2.x === 270,
|
||||
JSON.stringify(e2),
|
||||
);
|
||||
await stop();
|
||||
await undo(); // move
|
||||
await undo(); // parent
|
||||
o2 = await object('o2');
|
||||
check('undo restores top level and the original local position', o2.parentId === '' && o2.position.x === 120 && o2.position.y === -80, JSON.stringify(o2));
|
||||
check(
|
||||
'undo restores top level and the original local position',
|
||||
o2.parentId === '' && o2.position.x === 120 && o2.position.y === -80,
|
||||
JSON.stringify(o2),
|
||||
);
|
||||
|
||||
// 14c. Parent via the inspector menu, then "Unparent" from the hierarchy context menu.
|
||||
await clickText('Blue crate');
|
||||
@@ -60,11 +97,19 @@ export async function parenting(t) {
|
||||
await click(menuEntry.x, menuEntry.y);
|
||||
await sleep(100);
|
||||
o2 = await object('o2');
|
||||
check('inspector Parent menu parents the object', o2.parentId === 'o1' && o2.position.x === 270, JSON.stringify(o2));
|
||||
check(
|
||||
'inspector Parent menu parents the object',
|
||||
o2.parentId === 'o1' && o2.position.x === 270,
|
||||
JSON.stringify(o2),
|
||||
);
|
||||
const blueRow2 = await locate('Blue crate');
|
||||
await click(blueRow2.x, blueRow2.y, 'right');
|
||||
await clickText('Unparent (move to top level)');
|
||||
await sleep(100);
|
||||
o2 = await object('o2');
|
||||
check('Unparent moves the object to the top level, keeping its world position', o2.parentId === '' && o2.position.x === 120 && o2.position.y === -80, JSON.stringify(o2));
|
||||
check(
|
||||
'Unparent moves the object to the top level, keeping its world position',
|
||||
o2.parentId === '' && o2.position.x === 120 && o2.position.y === -80,
|
||||
JSON.stringify(o2),
|
||||
);
|
||||
}
|
||||
+66
-10
@@ -3,7 +3,28 @@
|
||||
* behavior, and Move with "Collide with solids" – blocking, triggers, no tunnelling.
|
||||
*/
|
||||
export async function colliders(t) {
|
||||
const { check, sleep, evaluate, json, shot, click, clickText, locate, press, holdKey, undo, mutate, select, state, object, objects, asset, world, entity, stop } = t;
|
||||
const {
|
||||
check,
|
||||
sleep,
|
||||
evaluate,
|
||||
json,
|
||||
shot,
|
||||
click,
|
||||
clickText,
|
||||
locate,
|
||||
press,
|
||||
holdKey,
|
||||
undo,
|
||||
mutate,
|
||||
select,
|
||||
state,
|
||||
object,
|
||||
objects,
|
||||
asset,
|
||||
world,
|
||||
entity,
|
||||
stop,
|
||||
} = t;
|
||||
|
||||
// 15. Right-click the red crate → "Add collider": a collider asset sized like the crate
|
||||
// (100×60) is placed as its child at the centre, selected and drawn in the overlay. The
|
||||
@@ -17,13 +38,26 @@ export async function colliders(t) {
|
||||
const s = await state();
|
||||
const colObj = s.selection?.kind === 'object' ? await object(s.selection.objectId) : null;
|
||||
const colAsset = colObj ? await asset(colObj.assetId) : null;
|
||||
check('Add collider creates a collider child sized like the sprite', colObj?.parentId === 'o1' && colAsset?.type === 'collider' && colAsset.props.width === 100 && colAsset.props.height === 60, JSON.stringify({ colObj, colAsset }));
|
||||
check('collider is drawn in the overlay layer', colObj && (await evaluate(`__gm.layerOf('${colObj.id}')`)) === 'overlay');
|
||||
check(
|
||||
'Add collider creates a collider child sized like the sprite',
|
||||
colObj?.parentId === 'o1' &&
|
||||
colAsset?.type === 'collider' &&
|
||||
colAsset.props.width === 100 &&
|
||||
colAsset.props.height === 60,
|
||||
JSON.stringify({ colObj, colAsset }),
|
||||
);
|
||||
check(
|
||||
'collider is drawn in the overlay layer',
|
||||
colObj && (await evaluate(`__gm.layerOf('${colObj.id}')`)) === 'overlay',
|
||||
);
|
||||
check('collider inspector shows the shape menu', Boolean(await evaluate(`__gm.locate('Box ▾')`)));
|
||||
await shot('21-collider');
|
||||
await undo();
|
||||
const colliderAssetGone = colAsset ? !(await asset(colAsset.id)) : false;
|
||||
check('undo removes the added collider object and its asset', (await objects()).length === objectsBefore && colliderAssetGone);
|
||||
check(
|
||||
'undo removes the added collider object and its asset',
|
||||
(await objects()).length === objectsBefore && colliderAssetGone,
|
||||
);
|
||||
|
||||
// 15a. Wire "On collision enter → Log(other)" into the Mover (owner = red crate) and play with
|
||||
// the crates overlapping: the log names the blue crate exactly once (enter, not stay),
|
||||
@@ -39,10 +73,20 @@ export async function colliders(t) {
|
||||
await press('Enter', { ctrl: true });
|
||||
await sleep(500);
|
||||
const collisionWorld = await world();
|
||||
check('On collision enter fired once with the other body', collisionWorld.log.filter((l) => l === 'Blue crate').length === 1, JSON.stringify(collisionWorld.log));
|
||||
const spriteCount = await evaluate(`__gm.state.activeScene.objects.filter(o => __gm.state.project.assets.find(a => a.id === o.assetId)?.type === 'sprite').length`);
|
||||
check(
|
||||
'On collision enter fired once with the other body',
|
||||
collisionWorld.log.filter((l) => l === 'Blue crate').length === 1,
|
||||
JSON.stringify(collisionWorld.log),
|
||||
);
|
||||
const spriteCount = await evaluate(
|
||||
`__gm.state.activeScene.objects.filter(o => __gm.state.project.assets.find(a => a.id === o.assetId)?.type === 'sprite').length`,
|
||||
);
|
||||
const info = await json('__gm.gameInfo()');
|
||||
check('colliders are not rendered in the game view', info.visibleItems === spriteCount, `${JSON.stringify(info)} sprites=${spriteCount}`);
|
||||
check(
|
||||
'colliders are not rendered in the game view',
|
||||
info.visibleItems === spriteCount,
|
||||
`${JSON.stringify(info)} sprites=${spriteCount}`,
|
||||
);
|
||||
await stop();
|
||||
|
||||
// 15b. Solid vs trigger. Put the blue crate 150 to the right of the red one (red 100 wide, the
|
||||
@@ -65,11 +109,23 @@ export async function colliders(t) {
|
||||
return red;
|
||||
};
|
||||
const blocked = await playAgainstBlue(false);
|
||||
check("Move with \"Collide with solids\" stops the red crate flush against the blue crate's solid collider", blocked && Math.abs(blocked.x - -120) < 0.01, `world x=${blocked?.x}`);
|
||||
check(
|
||||
'Move with "Collide with solids" stops the red crate flush against the blue crate\'s solid collider',
|
||||
blocked && Math.abs(blocked.x - -120) < 0.01,
|
||||
`world x=${blocked?.x}`,
|
||||
);
|
||||
const passed = await playAgainstBlue(true);
|
||||
check('a trigger collider does not block (the crate passes through)', passed && passed.x > -120 + 20, `world x=${passed?.x}`);
|
||||
check(
|
||||
'a trigger collider does not block (the crate passes through)',
|
||||
passed && passed.x > -120 + 20,
|
||||
`world x=${passed?.x}`,
|
||||
);
|
||||
const fast = await playAgainstBlue(false, 40000);
|
||||
check('a very fast mover cannot tunnel through a solid collider', fast && Math.abs(fast.x - -120) < 0.5, `world x=${fast?.x}`);
|
||||
check(
|
||||
'a very fast mover cannot tunnel through a solid collider',
|
||||
fast && Math.abs(fast.x - -120) < 0.5,
|
||||
`world x=${fast?.x}`,
|
||||
);
|
||||
await mutate(`__gm.state.project.assets.find(a => a.id === 'mover').props.speed = 200`); // later steps expect the seeded speed
|
||||
await select({ kind: 'object', objectId: 'oc2' });
|
||||
await sleep(200);
|
||||
|
||||
+153
-30
@@ -6,7 +6,28 @@ import { same } from '../lib/harness.mjs';
|
||||
* instance values against the template and the asset.
|
||||
*/
|
||||
export async function containers(t) {
|
||||
const { check, sleep, evaluate, json, shot, click, clickText, drag, locate, press, undo, mutate, select, state, object, objects, asset, world, play, stop } = t;
|
||||
const {
|
||||
check,
|
||||
sleep,
|
||||
evaluate,
|
||||
json,
|
||||
shot,
|
||||
click,
|
||||
clickText,
|
||||
drag,
|
||||
locate,
|
||||
press,
|
||||
undo,
|
||||
mutate,
|
||||
select,
|
||||
state,
|
||||
object,
|
||||
objects,
|
||||
asset,
|
||||
world,
|
||||
play,
|
||||
stop,
|
||||
} = t;
|
||||
const center = await json('__gm.viewportCenter()');
|
||||
const has = (text) => evaluate(`Boolean(__gm.locate(${JSON.stringify(text)}))`);
|
||||
|
||||
@@ -23,12 +44,22 @@ export async function containers(t) {
|
||||
const blueRow = await locate('Blue crate');
|
||||
await click(blueRow.x, blueRow.y, 'left', { ctrl: true });
|
||||
let s = await state();
|
||||
check('Ctrl-click in the hierarchy adds to the selection', s.selection?.kind === 'object' && same(s.selection.objectIds, ['o1', 'o2']) && s.selection.objectId === 'o2', JSON.stringify(s.selection));
|
||||
check(
|
||||
'Ctrl-click in the hierarchy adds to the selection',
|
||||
s.selection?.kind === 'object' &&
|
||||
same(s.selection.objectIds, ['o1', 'o2']) &&
|
||||
s.selection.objectId === 'o2',
|
||||
JSON.stringify(s.selection),
|
||||
);
|
||||
check('inspector shows the multi-selection', await has('2 OBJECTS SELECTED'));
|
||||
await shot('23-multi-selected');
|
||||
await click(blueRow.x, blueRow.y, 'left', { ctrl: true });
|
||||
s = await state();
|
||||
check('Ctrl-click again removes the object from the selection', s.selection?.kind === 'object' && same(s.selection.objectIds, ['o1']), JSON.stringify(s.selection));
|
||||
check(
|
||||
'Ctrl-click again removes the object from the selection',
|
||||
s.selection?.kind === 'object' && same(s.selection.objectIds, ['o1']),
|
||||
JSON.stringify(s.selection),
|
||||
);
|
||||
await click(blueRow.x, blueRow.y, 'left', { ctrl: true });
|
||||
|
||||
// 16a. Create container: both crates (with their colliders and the Mover) become the content
|
||||
@@ -44,26 +75,48 @@ export async function containers(t) {
|
||||
const containerAsset = containerObj ? await asset(containerObj.assetId) : null;
|
||||
check(
|
||||
'Create container adds one container asset and one placed container, selected',
|
||||
containerAsset?.type === 'container' && s.selection.objectIds.length === 1 && (await json('__gm.state.project.assets')).length === assetsBefore + 1 && (await objects()).length === objectsBefore + 1,
|
||||
containerAsset?.type === 'container' &&
|
||||
s.selection.objectIds.length === 1 &&
|
||||
(await json('__gm.state.project.assets')).length === assetsBefore + 1 &&
|
||||
(await objects()).length === objectsBefore + 1,
|
||||
JSON.stringify({ containerObj, type: containerAsset?.type }),
|
||||
);
|
||||
check('the container sits at the centre of the selection', containerObj && containerObj.parentId === '' && same(containerObj.position, { x: -15, y: -40, z: 0 }), JSON.stringify(containerObj?.position));
|
||||
check(
|
||||
'the container sits at the centre of the selection',
|
||||
containerObj && containerObj.parentId === '' && same(containerObj.position, { x: -15, y: -40, z: 0 }),
|
||||
JSON.stringify(containerObj?.position),
|
||||
);
|
||||
let o1c = await object('o1');
|
||||
const o2c = await object('o2');
|
||||
check(
|
||||
'the crates are re-parented below the container with local positions relative to its centre',
|
||||
o1c.parentId === containerObj?.id && o1c.position.x === -135 && o1c.position.y === 40 && o2c.parentId === containerObj?.id && o2c.position.x === 135 && o2c.position.y === -40,
|
||||
o1c.parentId === containerObj?.id &&
|
||||
o1c.position.x === -135 &&
|
||||
o1c.position.y === 40 &&
|
||||
o2c.parentId === containerObj?.id &&
|
||||
o2c.position.x === 135 &&
|
||||
o2c.position.y === -40,
|
||||
JSON.stringify({ o1: o1c.position, o2: o2c.position }),
|
||||
);
|
||||
check(
|
||||
'the template holds the whole subtree (2 crates, 2 colliders, the Mover)',
|
||||
containerAsset?.objects.length === 5 && containerAsset.objects.filter((o) => o.parentId === '').length === 2 && o1c.templateId && containerAsset.objects.some((o) => o.id === o1c.templateId),
|
||||
containerAsset?.objects.length === 5 &&
|
||||
containerAsset.objects.filter((o) => o.parentId === '').length === 2 &&
|
||||
o1c.templateId &&
|
||||
containerAsset.objects.some((o) => o.id === o1c.templateId),
|
||||
JSON.stringify(containerAsset?.objects.map((o) => [o.assetId, o.parentId === '' ? 'root' : 'child'])),
|
||||
);
|
||||
const orderC = await json('(__gm.hierarchyExpandAll(), __gm.hierarchyOrder())');
|
||||
const cAt = orderC.indexOf(containerObj?.id);
|
||||
check('hierarchy lists the container with its content indented below', cAt >= 0 && same(orderC.slice(cAt), [containerObj.id, 'o1', 'o4', 'oc1', 'o2', 'oc2']), JSON.stringify(orderC));
|
||||
check('container symbol is drawn in the overlay layer', containerObj && (await evaluate(`__gm.layerOf('${containerObj.id}')`)) === 'overlay');
|
||||
check(
|
||||
'hierarchy lists the container with its content indented below',
|
||||
cAt >= 0 && same(orderC.slice(cAt), [containerObj.id, 'o1', 'o4', 'oc1', 'o2', 'oc2']),
|
||||
JSON.stringify(orderC),
|
||||
);
|
||||
check(
|
||||
'container symbol is drawn in the overlay layer',
|
||||
containerObj && (await evaluate(`__gm.layerOf('${containerObj.id}')`)) === 'overlay',
|
||||
);
|
||||
await shot('24-container-created');
|
||||
|
||||
// 16b. Dragging the container symbol in the viewport moves the whole group; undo restores it.
|
||||
@@ -71,7 +124,11 @@ export async function containers(t) {
|
||||
await drag(cScreen.x, cScreen.y, cScreen.x + 100, cScreen.y);
|
||||
const containerMoved = await object(containerObj.id);
|
||||
o1c = await object('o1');
|
||||
check('dragging the container moves the group (children keep their local position)', containerMoved.position.x === 85 && o1c.position.x === -135, JSON.stringify({ container: containerMoved.position, o1: o1c.position }));
|
||||
check(
|
||||
'dragging the container moves the group (children keep their local position)',
|
||||
containerMoved.position.x === 85 && o1c.position.x === -135,
|
||||
JSON.stringify({ container: containerMoved.position, o1: o1c.position }),
|
||||
);
|
||||
await undo();
|
||||
check('undo moves the container back', (await object(containerObj.id)).position.x === -15);
|
||||
|
||||
@@ -80,7 +137,11 @@ export async function containers(t) {
|
||||
// (at y = 110), not the camera (at the origin).
|
||||
await drag(center.x - 230, center.y - 50, center.x - 90, center.y + 50);
|
||||
s = await state();
|
||||
check('rubber-band selection picks the objects inside it', s.selection?.kind === 'object' && same([...s.selection.objectIds].sort(), ['o1', 'oc1']), JSON.stringify(s.selection));
|
||||
check(
|
||||
'rubber-band selection picks the objects inside it',
|
||||
s.selection?.kind === 'object' && same([...s.selection.objectIds].sort(), ['o1', 'oc1']),
|
||||
JSON.stringify(s.selection),
|
||||
);
|
||||
await shot('25-marquee');
|
||||
|
||||
// 16d. Dragging one of several selected objects moves all of them as one undo step.
|
||||
@@ -89,7 +150,11 @@ export async function containers(t) {
|
||||
await drag(redScreen.x, redScreen.y + 20, redScreen.x + 50, redScreen.y + 20);
|
||||
o1c = await object('o1');
|
||||
const o2d = await object('o2');
|
||||
check('dragging a multi-selection moves every selected object', o1c.position.x === -85 && o2d.position.x === 185, JSON.stringify({ o1: o1c.position, o2: o2d.position }));
|
||||
check(
|
||||
'dragging a multi-selection moves every selected object',
|
||||
o1c.position.x === -85 && o2d.position.x === 185,
|
||||
JSON.stringify({ o1: o1c.position, o2: o2d.position }),
|
||||
);
|
||||
await undo();
|
||||
o1c = await object('o1');
|
||||
check('one undo restores both', o1c.position.x === -135 && (await object('o2')).position.x === 135);
|
||||
@@ -99,7 +164,10 @@ export async function containers(t) {
|
||||
await select({ kind: 'object', sceneId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] });
|
||||
await press('Delete');
|
||||
await sleep(150);
|
||||
check('Delete removes the whole multi-selection with its subtrees', (await objects()).length === objectsBeforeDelete - 5);
|
||||
check(
|
||||
'Delete removes the whole multi-selection with its subtrees',
|
||||
(await objects()).length === objectsBeforeDelete - 5,
|
||||
);
|
||||
await undo();
|
||||
check('undo restores all of them', (await objects()).length === objectsBeforeDelete);
|
||||
|
||||
@@ -110,14 +178,25 @@ export async function containers(t) {
|
||||
await sleep(100);
|
||||
const afterPlace = await objects();
|
||||
const secondRoot = afterPlace.find((o) => o.assetId === containerAsset.id && o.id !== containerObj.id);
|
||||
const secondCrate = secondRoot && afterPlace.find((o) => o.parentId === secondRoot.id && o.templateId === o1c.templateId);
|
||||
check('placing the container asset instantiates its content below a new container object', afterPlace.length === objectsBeforeDelete + 6 && secondCrate && secondCrate.position.x === -135 && secondCrate.position.y === 40, JSON.stringify({ count: afterPlace.length, secondCrate }));
|
||||
const secondCrate =
|
||||
secondRoot && afterPlace.find((o) => o.parentId === secondRoot.id && o.templateId === o1c.templateId);
|
||||
check(
|
||||
'placing the container asset instantiates its content below a new container object',
|
||||
afterPlace.length === objectsBeforeDelete + 6 &&
|
||||
secondCrate &&
|
||||
secondCrate.position.x === -135 &&
|
||||
secondCrate.position.y === 40,
|
||||
JSON.stringify({ count: afterPlace.length, secondCrate }),
|
||||
);
|
||||
await shot('26-container-placed');
|
||||
|
||||
// 16f-ii. Spawning the container at runtime instantiates its content the same way: point the
|
||||
// Spawner at the container asset, press Space while playing – a whole instance appears
|
||||
// in the runtime copy and in the world, and Stop throws it away with the rest.
|
||||
const spawnParam = (assetId) => mutate(`__gm.state.project.assets.find(a => a.id === 'spawner').graph.nodes.find(n => n.id === 's-spawn').params.asset = ${JSON.stringify(assetId)}`);
|
||||
const spawnParam = (assetId) =>
|
||||
mutate(
|
||||
`__gm.state.project.assets.find(a => a.id === 'spawner').graph.nodes.find(n => n.id === 's-spawn').params.asset = ${JSON.stringify(assetId)}`,
|
||||
);
|
||||
await spawnParam(containerAsset.id);
|
||||
const beforeSpawn = await objects();
|
||||
await play();
|
||||
@@ -127,11 +206,17 @@ export async function containers(t) {
|
||||
const spawnedRoot = fresh.find((o) => o.parentId === '');
|
||||
check(
|
||||
'Spawn of a container asset creates the container object plus its content',
|
||||
fresh.length === 6 && spawnedRoot?.assetId === containerAsset.id && fresh.filter((o) => o.templateId).length === 5,
|
||||
fresh.length === 6 &&
|
||||
spawnedRoot?.assetId === containerAsset.id &&
|
||||
fresh.filter((o) => o.templateId).length === 5,
|
||||
JSON.stringify(fresh.map((o) => ({ id: o.id, assetId: o.assetId, templateId: o.templateId }))),
|
||||
);
|
||||
const spawnWorld = await world();
|
||||
check('the spawned instance lives in the world and its name reached the Log node', spawnWorld.entities.length === beforeSpawn.length + 6 && spawnWorld.log.includes(containerAsset.name), JSON.stringify({ n: spawnWorld.entities.length, log: spawnWorld.log.slice(-1) }));
|
||||
check(
|
||||
'the spawned instance lives in the world and its name reached the Log node',
|
||||
spawnWorld.entities.length === beforeSpawn.length + 6 && spawnWorld.log.includes(containerAsset.name),
|
||||
JSON.stringify({ n: spawnWorld.entities.length, log: spawnWorld.log.slice(-1) }),
|
||||
);
|
||||
await stop();
|
||||
check('stop discards the spawned instance', (await objects()).length === beforeSpawn.length);
|
||||
await spawnParam('crate');
|
||||
@@ -151,10 +236,17 @@ export async function containers(t) {
|
||||
await shot('27-apply-buttons');
|
||||
await click(applyButton.x, applyButton.y);
|
||||
await sleep(100);
|
||||
check('apply writes x into the template and the unmodified copy follows', (await templateA()).position.x === -100 && (await object(secondCrate.id)).position.x === -100, JSON.stringify({ template: (await templateA()).position, copy: (await object(secondCrate.id)).position }));
|
||||
check(
|
||||
'apply writes x into the template and the unmodified copy follows',
|
||||
(await templateA()).position.x === -100 && (await object(secondCrate.id)).position.x === -100,
|
||||
JSON.stringify({ template: (await templateA()).position, copy: (await object(secondCrate.id)).position }),
|
||||
);
|
||||
check('apply hides the buttons again', !(await has('↑')));
|
||||
await undo();
|
||||
check('undo restores template and copy', (await templateA()).position.x === -135 && (await object(secondCrate.id)).position.x === -135);
|
||||
check(
|
||||
'undo restores template and copy',
|
||||
(await templateA()).position.x === -135 && (await object(secondCrate.id)).position.x === -135,
|
||||
);
|
||||
const revertButton = await evaluate(`__gm.locate('↺')`);
|
||||
await click(revertButton.x, revertButton.y);
|
||||
await sleep(100);
|
||||
@@ -165,41 +257,72 @@ export async function containers(t) {
|
||||
const applyMenu = await evaluate(`__gm.locate('↑ ▾')`);
|
||||
check('an overridden property on an instance shows the apply menu button', Boolean(applyMenu));
|
||||
await click(applyMenu.x, applyMenu.y);
|
||||
check('the menu offers container and asset', (await has('Apply to container')) && (await has('Apply to asset')));
|
||||
check(
|
||||
'the menu offers container and asset',
|
||||
(await has('Apply to container')) && (await has('Apply to asset')),
|
||||
);
|
||||
await shot('28-apply-menu');
|
||||
await clickText('Apply to asset');
|
||||
await sleep(100);
|
||||
const crateAfterApply = (await asset('crate')).props;
|
||||
check('Apply to asset writes the value into the asset and drops the override', crateAfterApply.width === 77 && (await object('o1')).overrides.width === undefined, JSON.stringify(crateAfterApply));
|
||||
check(
|
||||
'Apply to asset writes the value into the asset and drops the override',
|
||||
crateAfterApply.width === 77 && (await object('o1')).overrides.width === undefined,
|
||||
JSON.stringify(crateAfterApply),
|
||||
);
|
||||
await undo();
|
||||
check('undo restores asset and override', (await asset('crate')).props.width === 100 && (await object('o1')).overrides.width === 77);
|
||||
check(
|
||||
'undo restores asset and override',
|
||||
(await asset('crate')).props.width === 100 && (await object('o1')).overrides.width === 77,
|
||||
);
|
||||
await click(applyMenu.x, applyMenu.y);
|
||||
await clickText('Apply to container');
|
||||
await sleep(100);
|
||||
check('Apply to container writes the override into the template and the copy', (await templateA()).overrides.width === 77 && (await object(secondCrate.id)).overrides.width === 77);
|
||||
check(
|
||||
'Apply to container writes the override into the template and the copy',
|
||||
(await templateA()).overrides.width === 77 && (await object(secondCrate.id)).overrides.width === 77,
|
||||
);
|
||||
|
||||
// 16i. o1 now matches its template (both 77) but still overrides the asset (100). Only bases
|
||||
// the value really differs from are offered, so "Reset to container" is left out and the
|
||||
// menu collapses to a direct ↺. Deviating from both brings the menu back.
|
||||
await sleep(200);
|
||||
check('a reset that would change nothing is left out – a direct button instead of a menu', (await has('↺')) && !(await has('↺ ▾')) && !(await has('↑ ▾')));
|
||||
check(
|
||||
'a reset that would change nothing is left out – a direct button instead of a menu',
|
||||
(await has('↺')) && !(await has('↺ ▾')) && !(await has('↑ ▾')),
|
||||
);
|
||||
await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o1').overrides.width = 55`);
|
||||
const resetMenu = await evaluate(`__gm.locate('↺ ▾')`);
|
||||
check('deviating from template and asset brings the reset menu back', Boolean(resetMenu));
|
||||
await click(resetMenu.x, resetMenu.y);
|
||||
check('the reset menu offers container and asset', (await has('Reset to container')) && (await has('Reset to asset')));
|
||||
check(
|
||||
'the reset menu offers container and asset',
|
||||
(await has('Reset to container')) && (await has('Reset to asset')),
|
||||
);
|
||||
await shot('28b-reset-menu');
|
||||
await clickText('Reset to container');
|
||||
await sleep(100);
|
||||
check('Reset to container puts the override back on the template value', (await object('o1')).overrides.width === 77);
|
||||
check(
|
||||
'Reset to container puts the override back on the template value',
|
||||
(await object('o1')).overrides.width === 77,
|
||||
);
|
||||
const singleReset = await evaluate(`__gm.locate('↺')`);
|
||||
check('with only the asset left to differ from, the menu is a single button again', Boolean(singleReset) && !(await has('↺ ▾')));
|
||||
check(
|
||||
'with only the asset left to differ from, the menu is a single button again',
|
||||
Boolean(singleReset) && !(await has('↺ ▾')),
|
||||
);
|
||||
await click(singleReset.x, singleReset.y);
|
||||
await sleep(100);
|
||||
check('↺ drops the override so the object follows the asset again', (await object('o1')).overrides.width === undefined && (await templateA()).overrides.width === 77);
|
||||
check(
|
||||
'↺ drops the override so the object follows the asset again',
|
||||
(await object('o1')).overrides.width === undefined && (await templateA()).overrides.width === 77,
|
||||
);
|
||||
// Dropping the override left the object on the asset value (100) while the template still
|
||||
// says 77 – that is a real deviation, so exactly one entry remains: "Reset to container".
|
||||
check('the remaining deviation from the template keeps a single button, not a menu', (await has('↺')) && !(await has('↺ ▾')));
|
||||
check(
|
||||
'the remaining deviation from the template keeps a single button, not a menu',
|
||||
(await has('↺')) && !(await has('↺ ▾')),
|
||||
);
|
||||
await undo();
|
||||
check('undo restores the override', (await object('o1')).overrides.width === 77);
|
||||
}
|
||||
@@ -29,10 +29,21 @@ export async function textAndEnabled(t) {
|
||||
await click(enabledRow.x + 55, enabledRow.y);
|
||||
await sleep(100);
|
||||
const textObject = await object('otext');
|
||||
check('clicking it writes an override instead of changing the asset', textObject.overrides.enabled === false, JSON.stringify(textObject.overrides));
|
||||
check('a switched-off object is not drawn in the scene view', !(await json('__gm.drawOrder()')).includes('otext'));
|
||||
check(
|
||||
'clicking it writes an override instead of changing the asset',
|
||||
textObject.overrides.enabled === false,
|
||||
JSON.stringify(textObject.overrides),
|
||||
);
|
||||
check(
|
||||
'a switched-off object is not drawn in the scene view',
|
||||
!(await json('__gm.drawOrder()')).includes('otext'),
|
||||
);
|
||||
const visibleAfter = (await json('__gm.viewportInfo()')).visibleItems;
|
||||
check('the scene view renders one item less', visibleAfter === visibleBefore - 1, `${visibleBefore} → ${visibleAfter}`);
|
||||
check(
|
||||
'the scene view renders one item less',
|
||||
visibleAfter === visibleBefore - 1,
|
||||
`${visibleBefore} → ${visibleAfter}`,
|
||||
);
|
||||
check('it stays selectable in the hierarchy, so its switch stays reachable', await has('Enabled'));
|
||||
await shot('30-disabled');
|
||||
|
||||
@@ -44,7 +55,11 @@ export async function textAndEnabled(t) {
|
||||
await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'otext').overrides.enabled = true`);
|
||||
await sleep(200);
|
||||
const onItems = (await json('__gm.gameInfo()')).visibleItems;
|
||||
check('the game renders one item more once the object is switched on', onItems === offItems + 1, `${offItems} → ${onItems}`);
|
||||
check(
|
||||
'the game renders one item more once the object is switched on',
|
||||
onItems === offItems + 1,
|
||||
`${offItems} → ${onItems}`,
|
||||
);
|
||||
await stop();
|
||||
check('stop leaves the play-mode switch behind', (await object('otext')).overrides.enabled === false);
|
||||
}
|
||||
+12
-1
@@ -14,4 +14,15 @@ import { colliders } from './08-colliders.mjs';
|
||||
import { containers } from './09-containers.mjs';
|
||||
import { textAndEnabled } from './10-text-and-enabled.mjs';
|
||||
|
||||
export const STEPS = [hierarchy, inspector, play, assets, drawing, graph, parenting, colliders, containers, textAndEnabled];
|
||||
export const STEPS = [
|
||||
hierarchy,
|
||||
inspector,
|
||||
play,
|
||||
assets,
|
||||
drawing,
|
||||
graph,
|
||||
parenting,
|
||||
colliders,
|
||||
containers,
|
||||
textAndEnabled,
|
||||
];
|
||||
+16
-4
@@ -4,7 +4,11 @@
|
||||
*/
|
||||
|
||||
/** A scene object (also a container template entry). Position defaults to the origin. */
|
||||
export const sceneObject = (id, assetId, { name = '', x = 0, y = 0, z = 0, overrides = {}, parentId = '', templateId = '', editorOffset } = {}) => ({
|
||||
export const sceneObject = (
|
||||
id,
|
||||
assetId,
|
||||
{ name = '', x = 0, y = 0, z = 0, overrides = {}, parentId = '', templateId = '', editorOffset } = {},
|
||||
) => ({
|
||||
id,
|
||||
assetId,
|
||||
name,
|
||||
@@ -16,7 +20,11 @@ export const sceneObject = (id, assetId, { name = '', x = 0, y = 0, z = 0, overr
|
||||
});
|
||||
|
||||
/** A graph edge from `from.fromPort` to `to.toPort`. */
|
||||
export const edge = (id, from, fromPort, to, toPort) => ({ id, from: { node: from, port: fromPort }, to: { node: to, port: toPort } });
|
||||
export const edge = (id, from, fromPort, to, toPort) => ({
|
||||
id,
|
||||
from: { node: from, port: fromPort },
|
||||
to: { node: to, port: toPort },
|
||||
});
|
||||
|
||||
/** A graph node; `params` holds settings and inline input values alike. */
|
||||
export const node = (id, type, x, y, params = {}) => ({ id, type, x, y, params });
|
||||
@@ -41,10 +49,14 @@ export function containerFactory(templates) {
|
||||
for (const t of template) {
|
||||
const id = `${hostId}-${t.id}`;
|
||||
objects.push({ ...t, id, parentId: t.parentId ? `${hostId}-${t.parentId}` : hostId, templateId: t.id });
|
||||
if (templates[t.assetId] && !template.some((o) => o.parentId === t.id)) objects.push(...expand(t.assetId, id));
|
||||
if (templates[t.assetId] && !template.some((o) => o.parentId === t.id))
|
||||
objects.push(...expand(t.assetId, id));
|
||||
}
|
||||
return objects;
|
||||
};
|
||||
const instantiate = (id, assetId, options = {}) => [sceneObject(id, assetId, options), ...expand(assetId, id)];
|
||||
const instantiate = (id, assetId, options = {}) => [
|
||||
sceneObject(id, assetId, options),
|
||||
...expand(assetId, id),
|
||||
];
|
||||
return { expand, instantiate };
|
||||
}
|
||||
+89
-20
@@ -67,7 +67,9 @@ async function waitForChrome(port) {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/json/version`);
|
||||
if (res.ok) return res.json();
|
||||
} catch {}
|
||||
} catch {
|
||||
// Chromium has not opened the debugging port yet – retry.
|
||||
}
|
||||
await sleep(200);
|
||||
}
|
||||
throw new Error('chromium did not start');
|
||||
@@ -75,7 +77,9 @@ async function waitForChrome(port) {
|
||||
|
||||
/** The toolbox handed to a check body – everything a step needs to drive and inspect the editor. */
|
||||
function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
|
||||
const evaluate = async (expression) => (await cdp.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true })).result.value;
|
||||
const evaluate = async (expression) =>
|
||||
(await cdp.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true })).result
|
||||
.value;
|
||||
/** Evaluates an expression in the page and returns it as plain JSON; `undefined` comes back as null. */
|
||||
const json = async (expression) => JSON.parse(await evaluate(`JSON.stringify((${expression}) ?? null)`));
|
||||
const locate = async (text, last = false) => {
|
||||
@@ -91,7 +95,14 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
|
||||
// --- mouse ---------------------------------------------------------------------------------
|
||||
|
||||
const mouse = (type, x, y, button = 'left', extra = {}) =>
|
||||
cdp.send('Input.dispatchMouseEvent', { type, x, y, button, clickCount: type === 'mousePressed' || type === 'mouseReleased' ? 1 : 0, ...extra });
|
||||
cdp.send('Input.dispatchMouseEvent', {
|
||||
type,
|
||||
x,
|
||||
y,
|
||||
button,
|
||||
clickCount: type === 'mousePressed' || type === 'mouseReleased' ? 1 : 0,
|
||||
...extra,
|
||||
});
|
||||
const click = async (x, y, button = 'left', modifiers = {}) => {
|
||||
const extra = { modifiers: modifierBits(modifiers) };
|
||||
await mouse('mouseMoved', x, y);
|
||||
@@ -107,7 +118,9 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
|
||||
await mouse('mouseMoved', x1, y1);
|
||||
await mouse('mousePressed', x1, y1);
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
await mouse('mouseMoved', x1 + ((x2 - x1) * i) / steps, y1 + ((y2 - y1) * i) / steps, 'left', { buttons: 1 });
|
||||
await mouse('mouseMoved', x1 + ((x2 - x1) * i) / steps, y1 + ((y2 - y1) * i) / steps, 'left', {
|
||||
buttons: 1,
|
||||
});
|
||||
await sleep(16);
|
||||
}
|
||||
await mouse('mouseReleased', x2, y2);
|
||||
@@ -147,7 +160,10 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
|
||||
// --- editor state --------------------------------------------------------------------------
|
||||
|
||||
/** Selection, mode, save status and notice – the facts most checks look at. */
|
||||
const state = () => json('{ selection: __gm.state.selection, mode: __gm.state.mode, save: __gm.state.saveStatus, notice: __gm.state.notice }');
|
||||
const state = () =>
|
||||
json(
|
||||
'{ selection: __gm.state.selection, mode: __gm.state.mode, save: __gm.state.saveStatus, notice: __gm.state.notice }',
|
||||
);
|
||||
const objects = () => json('__gm.state.activeScene.objects');
|
||||
const object = (id) => json(`__gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(id)})`);
|
||||
const asset = (id) => json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)})`);
|
||||
@@ -185,21 +201,59 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
|
||||
};
|
||||
|
||||
return {
|
||||
cdp, projectId, baseUrl, consoleLog, exceptions, check, sleep,
|
||||
evaluate, json, locate, shot,
|
||||
mouse, click, clickText, drag,
|
||||
press, holdKey, typeText, undo, redo,
|
||||
state, objects, object, asset, world, entity, select, mutate, play, stop,
|
||||
api, sceneOf, navigate,
|
||||
cdp,
|
||||
projectId,
|
||||
baseUrl,
|
||||
consoleLog,
|
||||
exceptions,
|
||||
check,
|
||||
sleep,
|
||||
evaluate,
|
||||
json,
|
||||
locate,
|
||||
shot,
|
||||
mouse,
|
||||
click,
|
||||
clickText,
|
||||
drag,
|
||||
press,
|
||||
holdKey,
|
||||
typeText,
|
||||
undo,
|
||||
redo,
|
||||
state,
|
||||
objects,
|
||||
object,
|
||||
asset,
|
||||
world,
|
||||
entity,
|
||||
select,
|
||||
mutate,
|
||||
play,
|
||||
stop,
|
||||
api,
|
||||
sceneOf,
|
||||
navigate,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runChecks(options, body) {
|
||||
const { projectId, baseUrl, port = 9333, shotPrefix = 'gm-e2e', width = 1280, height = 800 } = options;
|
||||
const chrome = spawn('chromium', [
|
||||
'--headless', '--no-sandbox', '--disable-gpu', '--use-gl=swiftshader', '--enable-unsafe-swiftshader',
|
||||
`--remote-debugging-port=${port}`, `--window-size=${width},${height}`, '--hide-scrollbars', 'about:blank',
|
||||
], { stdio: 'ignore' });
|
||||
const chrome = spawn(
|
||||
'chromium',
|
||||
[
|
||||
'--headless',
|
||||
'--no-sandbox',
|
||||
'--disable-gpu',
|
||||
'--use-gl=swiftshader',
|
||||
'--enable-unsafe-swiftshader',
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--window-size=${width},${height}`,
|
||||
'--hide-scrollbars',
|
||||
'about:blank',
|
||||
],
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
|
||||
const consoleLog = [];
|
||||
let failures = 0;
|
||||
@@ -210,16 +264,28 @@ export async function runChecks(options, body) {
|
||||
|
||||
try {
|
||||
await waitForChrome(port);
|
||||
const target = await (await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: 'PUT' })).json();
|
||||
const target = await (
|
||||
await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: 'PUT' })
|
||||
).json();
|
||||
const ws = new WebSocket(target.webSocketDebuggerUrl);
|
||||
await new Promise((r) => ws.addEventListener('open', r));
|
||||
const cdp = new CDP(ws);
|
||||
await cdp.send('Page.enable');
|
||||
await cdp.send('Runtime.enable');
|
||||
await cdp.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false });
|
||||
await cdp.send('Emulation.setDeviceMetricsOverride', {
|
||||
width,
|
||||
height,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: false,
|
||||
});
|
||||
cdp.on((msg) => {
|
||||
if (msg.method === 'Runtime.consoleAPICalled') consoleLog.push(msg.params.args.map((a) => a.value ?? a.description).join(' '));
|
||||
if (msg.method === 'Runtime.exceptionThrown') consoleLog.push('EXCEPTION ' + (msg.params.exceptionDetails.exception?.description ?? msg.params.exceptionDetails.text));
|
||||
if (msg.method === 'Runtime.consoleAPICalled')
|
||||
consoleLog.push(msg.params.args.map((a) => a.value ?? a.description).join(' '));
|
||||
if (msg.method === 'Runtime.exceptionThrown')
|
||||
consoleLog.push(
|
||||
'EXCEPTION ' +
|
||||
(msg.params.exceptionDetails.exception?.description ?? msg.params.exceptionDetails.text),
|
||||
);
|
||||
});
|
||||
await body(makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }));
|
||||
ws.close();
|
||||
@@ -230,7 +296,10 @@ export async function runChecks(options, body) {
|
||||
await new Promise((resolve) => {
|
||||
if (chrome.exitCode !== null) return resolve();
|
||||
const timer = setTimeout(() => chrome.kill('SIGKILL'), 5000);
|
||||
chrome.once('exit', () => { clearTimeout(timer); resolve(); });
|
||||
chrome.once('exit', () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
chrome.kill('SIGTERM');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -39,8 +39,15 @@ export async function writeProject(projectId, baseUrl, project) {
|
||||
process.exit(1);
|
||||
}
|
||||
const doc = await current.json();
|
||||
const body = { revision: doc.revision, project: { ...project, id: projectId, name: doc.name ?? project.name } };
|
||||
const res = await fetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
const body = {
|
||||
revision: doc.revision,
|
||||
project: { ...project, id: projectId, name: doc.name ?? project.name },
|
||||
};
|
||||
const res = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const result = await res.json();
|
||||
if (!res.ok) {
|
||||
console.error('PUT failed', res.status, result);
|
||||
|
||||
+20
-3
@@ -61,7 +61,12 @@ const project = {
|
||||
props: { backgroundColor: 0x22252c },
|
||||
objects: [
|
||||
sceneObject('o1', 'crate', { x: -150 }),
|
||||
sceneObject('o2', 'crate', { name: 'Blue crate', x: 120, y: -80, overrides: { width: 140, color: 0x4488ff } }),
|
||||
sceneObject('o2', 'crate', {
|
||||
name: 'Blue crate',
|
||||
x: 120,
|
||||
y: -80,
|
||||
overrides: { width: 140, color: 0x4488ff },
|
||||
}),
|
||||
sceneObject('o3', 'cam'),
|
||||
// The Mover is parented to the red crate (its owner); its position is local to it.
|
||||
sceneObject('o4', 'mover', { y: 110, parentId: 'o1' }),
|
||||
@@ -75,8 +80,20 @@ const project = {
|
||||
},
|
||||
{ id: 'crate', type: 'sprite', name: 'Crate', props: { width: 100, height: 60, color: 0xe0553f } },
|
||||
{ id: 'cam', type: 'camera', name: 'Main camera', props: { width: 640, height: 360, zoom: 1 } },
|
||||
{ id: 'collider', type: 'collider', name: 'Crate collider', props: { shape: 'box', width: 100, height: 60, radius: 50, isTrigger: false } },
|
||||
{ id: 'mover', type: 'behavior', name: 'Mover', props: { speed: 200 }, variables: [variable('speed', 'number', 0)], graph: moverGraph },
|
||||
{
|
||||
id: 'collider',
|
||||
type: 'collider',
|
||||
name: 'Crate collider',
|
||||
props: { shape: 'box', width: 100, height: 60, radius: 50, isTrigger: false },
|
||||
},
|
||||
{
|
||||
id: 'mover',
|
||||
type: 'behavior',
|
||||
name: 'Mover',
|
||||
props: { speed: 200 },
|
||||
variables: [variable('speed', 'number', 0)],
|
||||
graph: moverGraph,
|
||||
},
|
||||
{ id: 'spawner', type: 'behavior', name: 'Spawner', props: {}, variables: [], graph: spawnerGraph },
|
||||
],
|
||||
};
|
||||
|
||||
+83
-13
@@ -40,8 +40,21 @@ const solid = (id, x, y, w, h, color) => [
|
||||
sceneObject(`${id}-c`, 'solid', { parentId: id, overrides: { width: w, height: h } }),
|
||||
];
|
||||
|
||||
const text = (id, x, y, content, { size = 28, color = WHITE, align = 'center', name = '', parentId = '', z = 1 } = {}) =>
|
||||
sceneObject(id, 'label', { name, x, y, z, parentId, overrides: { text: content, fontSize: size, color, align } });
|
||||
const text = (
|
||||
id,
|
||||
x,
|
||||
y,
|
||||
content,
|
||||
{ size = 28, color = WHITE, align = 'center', name = '', parentId = '', z = 1 } = {},
|
||||
) =>
|
||||
sceneObject(id, 'label', {
|
||||
name,
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
parentId,
|
||||
overrides: { text: content, fontSize: size, color, align },
|
||||
});
|
||||
|
||||
/** One placed coin: the container object; its content comes from the coin template. */
|
||||
const coin = (id, x, y) => sceneObject(id, 'coin', { name: 'Coin', x, y });
|
||||
@@ -88,8 +101,16 @@ const screenTemplate = (prefix, headline, hint, buttonLabel, behaviorId) => [
|
||||
sceneObject(`${prefix}-panel`, 'box', { overrides: { width: 640, height: 360, color: PANEL } }),
|
||||
text(`${prefix}-title`, 0, -70, headline, { size: 52 }),
|
||||
text(`${prefix}-hint`, 0, -10, hint, { size: 20, color: HINT }),
|
||||
sceneObject(`${prefix}-button`, 'box', { y: 80, z: 1, overrides: { width: 240, height: 60, color: BUTTON } }),
|
||||
text(`${prefix}-button-label`, 0, 0, buttonLabel, { size: 26, color: BUTTON_TEXT, parentId: `${prefix}-button` }),
|
||||
sceneObject(`${prefix}-button`, 'box', {
|
||||
y: 80,
|
||||
z: 1,
|
||||
overrides: { width: 240, height: 60, color: BUTTON },
|
||||
}),
|
||||
text(`${prefix}-button-label`, 0, 0, buttonLabel, {
|
||||
size: 26,
|
||||
color: BUTTON_TEXT,
|
||||
parentId: `${prefix}-button`,
|
||||
}),
|
||||
sceneObject(`${prefix}-button-logic`, behaviorId, { parentId: `${prefix}-button`, y: -60 }),
|
||||
];
|
||||
|
||||
@@ -350,20 +371,55 @@ const project = {
|
||||
...instantiate('L', 'level', { name: 'Level' }),
|
||||
// All three screens sit on the camera frame; the Game behavior decides on `On start` which
|
||||
// one is up. Editor offsets pull them apart in the scene view so they do not cover the level.
|
||||
...instantiate('T', 'title', { name: 'Title', z: 10, overrides: { enabled: true }, editorOffset: { x: -750, y: -500 } }),
|
||||
...instantiate('W', 'win', { name: 'Win', z: 10, overrides: { enabled: true }, editorOffset: { x: 0, y: -500 } }),
|
||||
...instantiate('B', 'lose', { name: 'Lose', z: 10, overrides: { enabled: true }, editorOffset: { x: 750, y: -500 } }),
|
||||
...instantiate('T', 'title', {
|
||||
name: 'Title',
|
||||
z: 10,
|
||||
overrides: { enabled: true },
|
||||
editorOffset: { x: -750, y: -500 },
|
||||
}),
|
||||
...instantiate('W', 'win', {
|
||||
name: 'Win',
|
||||
z: 10,
|
||||
overrides: { enabled: true },
|
||||
editorOffset: { x: 0, y: -500 },
|
||||
}),
|
||||
...instantiate('B', 'lose', {
|
||||
name: 'Lose',
|
||||
z: 10,
|
||||
overrides: { enabled: true },
|
||||
editorOffset: { x: 750, y: -500 },
|
||||
}),
|
||||
sceneObject('ocam', 'cam', { name: 'Main Camera' }),
|
||||
sceneObject('ogame', 'game', { name: 'Game', x: -200, y: -220 }),
|
||||
],
|
||||
},
|
||||
{ id: 'cam', type: 'camera', name: 'Main Camera', props: { width: 640, height: 360, zoom: 1 } },
|
||||
{ id: 'box', type: 'sprite', name: 'Box', props: { width: 100, height: 100, color: STONE } },
|
||||
{ id: 'player-sprite', type: 'sprite', name: 'Player Sprite', props: { width: 30, height: 40, color: PLAYER } },
|
||||
{
|
||||
id: 'player-sprite',
|
||||
type: 'sprite',
|
||||
name: 'Player Sprite',
|
||||
props: { width: 30, height: 40, color: PLAYER },
|
||||
},
|
||||
{ id: 'coin-sprite', type: 'sprite', name: 'Coin Sprite', props: { width: 24, height: 24, color: COIN } },
|
||||
{ id: 'label', type: 'text', name: 'Label', props: { text: 'Text', fontSize: 28, color: WHITE, align: 'center' } },
|
||||
{ id: 'solid', type: 'collider', name: 'Solid Collider', props: { shape: 'box', width: 100, height: 100, radius: 50, isTrigger: false } },
|
||||
{ id: 'trigger', type: 'collider', name: 'Coin Trigger', props: { shape: 'box', width: 24, height: 24, radius: 12, isTrigger: true } },
|
||||
{
|
||||
id: 'label',
|
||||
type: 'text',
|
||||
name: 'Label',
|
||||
props: { text: 'Text', fontSize: 28, color: WHITE, align: 'center' },
|
||||
},
|
||||
{
|
||||
id: 'solid',
|
||||
type: 'collider',
|
||||
name: 'Solid Collider',
|
||||
props: { shape: 'box', width: 100, height: 100, radius: 50, isTrigger: false },
|
||||
},
|
||||
{
|
||||
id: 'trigger',
|
||||
type: 'collider',
|
||||
name: 'Coin Trigger',
|
||||
props: { shape: 'box', width: 24, height: 24, radius: 12, isTrigger: true },
|
||||
},
|
||||
{
|
||||
id: 'player',
|
||||
type: 'behavior',
|
||||
@@ -391,8 +447,22 @@ const project = {
|
||||
],
|
||||
graph: gameGraph,
|
||||
},
|
||||
{ id: 'start-button', type: 'behavior', name: 'Start Button', props: {}, variables: [], graph: startButtonGraph },
|
||||
{ id: 'restart-button', type: 'behavior', name: 'Restart Button', props: {}, variables: [], graph: restartButtonGraph },
|
||||
{
|
||||
id: 'start-button',
|
||||
type: 'behavior',
|
||||
name: 'Start Button',
|
||||
props: {},
|
||||
variables: [],
|
||||
graph: startButtonGraph,
|
||||
},
|
||||
{
|
||||
id: 'restart-button',
|
||||
type: 'behavior',
|
||||
name: 'Restart Button',
|
||||
props: {},
|
||||
variables: [],
|
||||
graph: restartButtonGraph,
|
||||
},
|
||||
{ id: 'coin', type: 'container', name: 'Coin', props: {}, objects: coinTemplate },
|
||||
{ id: 'level', type: 'container', name: 'Level', props: {}, objects: levelTemplate },
|
||||
{ id: 'title', type: 'container', name: 'Title screen', props: {}, objects: templates.title },
|
||||
|
||||
+86
-17
@@ -18,7 +18,23 @@ const GRAPHS = { player: [35, 38], game: [38, 43], 'start-button': [9, 10], 'res
|
||||
const GROUND_Y = 130;
|
||||
|
||||
await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async (t) => {
|
||||
const { check, sleep, evaluate, json, shot, click, holdKey, press, entity, world, mutate, play, stop, navigate, exceptions } = t;
|
||||
const {
|
||||
check,
|
||||
sleep,
|
||||
evaluate,
|
||||
json,
|
||||
shot,
|
||||
click,
|
||||
holdKey,
|
||||
press,
|
||||
entity,
|
||||
world,
|
||||
mutate,
|
||||
play,
|
||||
stop,
|
||||
navigate,
|
||||
exceptions,
|
||||
} = t;
|
||||
|
||||
/** Variables of the "Game" behavior instance, live from the running world. */
|
||||
const gameVars = async () => (await entity('ogame')).props;
|
||||
@@ -32,15 +48,20 @@ await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async (
|
||||
};
|
||||
/** Edits the play copy (never the saved project) and pushes it into the running world. */
|
||||
const setVariable = (objectId, key, value) =>
|
||||
mutate(`__gm.state.activeScene.objects.find((x) => x.id === ${JSON.stringify(objectId)}).overrides[${JSON.stringify(key)}] = ${JSON.stringify(value)}`);
|
||||
mutate(
|
||||
`__gm.state.activeScene.objects.find((x) => x.id === ${JSON.stringify(objectId)}).overrides[${JSON.stringify(key)}] = ${JSON.stringify(value)}`,
|
||||
);
|
||||
|
||||
await navigate();
|
||||
|
||||
// 1. Nothing was dropped by the loader: every node and edge of the four behaviors survived.
|
||||
const graphs = await json(`__gm.state.project.assets.filter((a) => a.type === 'behavior').map((a) => ({ id: a.id, n: a.graph.nodes.length, e: a.graph.edges.length }))`);
|
||||
const graphs = await json(
|
||||
`__gm.state.project.assets.filter((a) => a.type === 'behavior').map((a) => ({ id: a.id, n: a.graph.nodes.length, e: a.graph.edges.length }))`,
|
||||
);
|
||||
check(
|
||||
'all behavior graphs load complete (no unknown node types, no dropped edges)',
|
||||
graphs.length === 4 && graphs.every((g) => GRAPHS[g.id] && g.n === GRAPHS[g.id][0] && g.e === GRAPHS[g.id][1]),
|
||||
graphs.length === 4 &&
|
||||
graphs.every((g) => GRAPHS[g.id] && g.n === GRAPHS[g.id][0] && g.e === GRAPHS[g.id][1]),
|
||||
JSON.stringify(graphs),
|
||||
);
|
||||
const objectCount = await evaluate('__gm.state.activeScene.objects.length');
|
||||
@@ -54,39 +75,70 @@ await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async (
|
||||
check('the title screen is up', await visible('JUMP & RUN'));
|
||||
const level = await entity('L');
|
||||
const title = await entity('T');
|
||||
check('the Game behavior switched the level off and the title on', level.props.enabled === false && title.props.enabled === true, JSON.stringify({ level: level.props.enabled, title: title.props.enabled }));
|
||||
check(
|
||||
'the Game behavior switched the level off and the title on',
|
||||
level.props.enabled === false && title.props.enabled === true,
|
||||
JSON.stringify({ level: level.props.enabled, title: title.props.enabled }),
|
||||
);
|
||||
await shot('02-title');
|
||||
|
||||
// 3. The Start button is a click target in the running game (On click + hit test).
|
||||
await clickObject('T-title-button');
|
||||
await sleep(400);
|
||||
check('clicking Start hides the title and shows the level', (await entity('L')).props.enabled === true && (await entity('T')).props.enabled === false);
|
||||
check(
|
||||
'clicking Start hides the title and shows the level',
|
||||
(await entity('L')).props.enabled === true && (await entity('T')).props.enabled === false,
|
||||
);
|
||||
const vars = await gameVars();
|
||||
check('the run starts with a full clock and no coins', vars.running === true && vars.coins === 0 && vars.timeLeft > 28, JSON.stringify(vars));
|
||||
check(
|
||||
'the run starts with a full clock and no coins',
|
||||
vars.running === true && vars.coins === 0 && vars.timeLeft > 28,
|
||||
JSON.stringify(vars),
|
||||
);
|
||||
await shot('03-running');
|
||||
|
||||
// 4. Gravity: the player rests on the ground (its 40-high box on the ground's top edge, y = 150).
|
||||
const grounded = await entity('L-player');
|
||||
check('gravity dropped the player onto the ground (top edge at y = 150)', Math.abs(grounded.worldY - GROUND_Y) < 1.5, `world y ${grounded.worldY.toFixed(2)}`);
|
||||
check(
|
||||
'gravity dropped the player onto the ground (top edge at y = 150)',
|
||||
Math.abs(grounded.worldY - GROUND_Y) < 1.5,
|
||||
`world y ${grounded.worldY.toFixed(2)}`,
|
||||
);
|
||||
|
||||
// 5. Jumping: Space lifts the player (y gets smaller) and gravity brings it back down.
|
||||
await press('Space');
|
||||
await sleep(200);
|
||||
const airborne = await entity('L-player');
|
||||
check('Space makes the player jump', airborne.worldY < grounded.worldY - 25, `y ${grounded.worldY.toFixed(1)} → ${airborne.worldY.toFixed(1)}`);
|
||||
check(
|
||||
'Space makes the player jump',
|
||||
airborne.worldY < grounded.worldY - 25,
|
||||
`y ${grounded.worldY.toFixed(1)} → ${airborne.worldY.toFixed(1)}`,
|
||||
);
|
||||
await sleep(1200);
|
||||
const landed = await entity('L-player');
|
||||
check('the player falls back onto the ground', Math.abs(landed.worldY - GROUND_Y) < 1.5, `y ${landed.worldY.toFixed(2)}`);
|
||||
check(
|
||||
'the player falls back onto the ground',
|
||||
Math.abs(landed.worldY - GROUND_Y) < 1.5,
|
||||
`y ${landed.worldY.toFixed(2)}`,
|
||||
);
|
||||
await shot('04-jumped');
|
||||
|
||||
// 6. Walking and collecting: running into a coin removes it and counts up.
|
||||
const coinsBefore = await coins();
|
||||
await holdKey('ArrowRight', 1500);
|
||||
const walked = await entity('L-player');
|
||||
check('arrow keys walk the player', walked.worldX > landed.worldX + 40, `x ${landed.worldX.toFixed(1)} → ${walked.worldX.toFixed(1)}`);
|
||||
check(
|
||||
'arrow keys walk the player',
|
||||
walked.worldX > landed.worldX + 40,
|
||||
`x ${landed.worldX.toFixed(1)} → ${walked.worldX.toFixed(1)}`,
|
||||
);
|
||||
const afterCoin = await gameVars();
|
||||
const coinsAfter = await coins();
|
||||
check('running into a coin collects it', afterCoin.coins >= 1 && coinsAfter < coinsBefore, JSON.stringify({ coins: afterCoin.coins, before: coinsBefore, after: coinsAfter }));
|
||||
check(
|
||||
'running into a coin collects it',
|
||||
afterCoin.coins >= 1 && coinsAfter < coinsBefore,
|
||||
JSON.stringify({ coins: afterCoin.coins, before: coinsBefore, after: coinsAfter }),
|
||||
);
|
||||
check('the HUD shows the counter', await visible('Coins 1/10'));
|
||||
check('the clock is counting down', afterCoin.timeLeft < 28, `timeLeft ${afterCoin.timeLeft}`);
|
||||
await shot('05-collected');
|
||||
@@ -94,13 +146,21 @@ await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async (
|
||||
// 7. Winning: with all ten coins the Game behavior swaps the level for the win screen.
|
||||
await setVariable('ogame', 'coins', 10);
|
||||
await sleep(300);
|
||||
check('collecting everything wins the game', (await visible('YOU WIN!')) && (await entity('W')).props.enabled === true && (await entity('L')).props.enabled === false);
|
||||
check(
|
||||
'collecting everything wins the game',
|
||||
(await visible('YOU WIN!')) &&
|
||||
(await entity('W')).props.enabled === true &&
|
||||
(await entity('L')).props.enabled === false,
|
||||
);
|
||||
await shot('06-won');
|
||||
|
||||
// 8. Restart game: back to a fresh run at the title screen, coins and clock reset.
|
||||
await clickObject('W-win-button');
|
||||
await sleep(1200);
|
||||
check('Play again restarts the scene at the title screen', (await visible('JUMP & RUN')) && (await evaluate('__gm.state.mode')) === 'play');
|
||||
check(
|
||||
'Play again restarts the scene at the title screen',
|
||||
(await visible('JUMP & RUN')) && (await evaluate('__gm.state.mode')) === 'play',
|
||||
);
|
||||
check('the restarted run has all ten coins back', (await coins()) === 10, `${await coins()} coins`);
|
||||
|
||||
// 9. Losing: start again, run the clock out.
|
||||
@@ -108,13 +168,22 @@ await runChecks({ projectId, baseUrl, port: 9336, shotPrefix: 'gm-jr' }, async (
|
||||
await sleep(300);
|
||||
await setVariable('ogame', 'timeLeft', 0.2);
|
||||
await sleep(600);
|
||||
check('the clock running out loses the game', (await visible('TIME UP')) && (await entity('B')).props.enabled === true);
|
||||
check(
|
||||
'the clock running out loses the game',
|
||||
(await visible('TIME UP')) && (await entity('B')).props.enabled === true,
|
||||
);
|
||||
await shot('07-lost');
|
||||
|
||||
// 10. Stop throws the run away – the saved project is untouched.
|
||||
await stop();
|
||||
await sleep(200);
|
||||
const saved = await json('{ objects: __gm.state.activeScene.objects.length, mode: __gm.state.mode, tab: __gm.tab() }');
|
||||
check('stop restores the edited scene', saved.objects === OBJECT_COUNT && saved.mode === 'edit' && saved.tab === 'scene', JSON.stringify(saved));
|
||||
const saved = await json(
|
||||
'{ objects: __gm.state.activeScene.objects.length, mode: __gm.state.mode, tab: __gm.tab() }',
|
||||
);
|
||||
check(
|
||||
'stop restores the edited scene',
|
||||
saved.objects === OBJECT_COUNT && saved.mode === 'edit' && saved.tab === 'scene',
|
||||
JSON.stringify(saved),
|
||||
);
|
||||
check('no exceptions during the run', exceptions().length === 0, exceptions().slice(0, 3).join(' | '));
|
||||
});
|
||||
Reference in new issue
Block a user