The editor gets a name of its own that fits beside Brandmauer, Glutspeicher and Feuerwehr. This is stage 1, 2 and 4 of #422: the name, the URL and the stored state in the browser. Repository, CI runners, containers and the GAME_MAKER_* variables follow in stage 3. - All paths come from SITE_PATH in src/core/site.ts: the API endpoint, the data folders, the play page and the base in vite.config.ts. A later move is one line instead of fifteen. - The window title, the sign-in screen, the playground and the README say Feuerwerk. The package, __feuerwerkPlay, the take worklet and the writing app in Ogg and Matroska follow. - The session cookie is now feuerwerk_session, so everyone signs in once more. - localStorage keys are now feuerwerk.*. moveOldStoredKeys copies the old game-maker.* keys over on the first start, so settings, panel sizes and save games survive. - tools/ci/serve.sh serves the build under /feuerwerk/. The variable GAME_MAKER_BASE_URL keeps its name until stage 3. Outside the repository, nockal.com/feuerwerk/ is a second symlink to the same tree. nginx sends the bare /game-maker/ folder there with a 301. Everything else under /game-maker/ is still served directly, so shared play links and the API keep working. Finding: when nginx redirects a path without a trailing slash or with a query, the Brandmauer answers with its own 307 to the same URL plus a slash, placed after the query. A play link would lose its token that way, which is why only the bare folder is redirected. Tests: - Gate on melektaus-atx: CHECK EXIT 0, 307 files, 4756 tests (4 new in site.test.ts). - Counter-check: when the migration misses the old prefix, 3 of the 4 new tests fail. - e2e 60 under /feuerwerk/: ALL PASSED, 20 checks. - From outside: /feuerwerk/ 200, /game-maker/ 301, an old play link 200. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
545 lines
24 KiB
JavaScript
545 lines
24 KiB
JavaScript
/**
|
||
* The top bar after its rework (#183): five icon buttons and no captions at all. What it used to
|
||
* spell out moved into the one menu – the project list into "Open project…", the project name and
|
||
* the start-scene switch into "Project settings…" next to the collision layers.
|
||
*
|
||
* Runs last, and on purpose: the project picker is opened here and left again with Cancel. Opening
|
||
* or deleting a project for real would pull the project out from under every step before this one.
|
||
*/
|
||
export async function topbar(t) {
|
||
const { check, json, has, shot, clickText, clickToolbar, typeText, press, sleep, waitUntil } = t;
|
||
|
||
await press('Escape');
|
||
|
||
// 46a. Nothing in the bar is written any more. The five words it used to carry are asked for as
|
||
// drawn text: the buttons are still there, only their captions are gone.
|
||
const captions = {
|
||
project: await has('Project'),
|
||
undo: await has('Undo'),
|
||
redo: await has('Redo'),
|
||
play: await has('Play'),
|
||
pause: await has('Pause'),
|
||
};
|
||
check(
|
||
'the top bar carries no captions any more – menu, undo, redo, play and pause are icons',
|
||
Object.values(captions).every((shown) => shown === false),
|
||
JSON.stringify(captions),
|
||
);
|
||
const buttons = await json('__gm.runButtons()');
|
||
check(
|
||
'in edit mode the run buttons offer play, pause as a switch for the start, and no step',
|
||
buttons.play === 'play' &&
|
||
buttons.pause === 'pause' &&
|
||
buttons.playEnabled === true &&
|
||
buttons.pauseEnabled === true &&
|
||
buttons.pauseToggled === false &&
|
||
buttons.stepEnabled === false,
|
||
JSON.stringify(buttons),
|
||
);
|
||
// The group is centred on the bar itself. The row cannot do that on its own: the spacers around
|
||
// the buttons share the free space with the flexible notice and so get a third of it each, which
|
||
// used to leave the group well left of the middle.
|
||
const play = await json('__gm.toolbarAt("play")');
|
||
const step = await json('__gm.toolbarAt("step")');
|
||
const barCentre = await json('window.innerWidth / 2');
|
||
check(
|
||
'play, pause and next frame sit on the middle of the bar, not on the middle of what is left over',
|
||
Math.abs((play.x + step.x) / 2 - barCentre) <= 2,
|
||
JSON.stringify({ middle: (play.x + step.x) / 2, barCentre }),
|
||
);
|
||
await shot('46-topbar');
|
||
|
||
// 46b. The one menu, with the separator between what opens a project and what changes the one
|
||
// that is open. Clearing the saved data is checked in 32.
|
||
await clickToolbar('menu');
|
||
const rows = await json('__gm.menuRows()');
|
||
const expected = [
|
||
'New project',
|
||
'Open project…',
|
||
null,
|
||
'Snapshots…',
|
||
'Release…',
|
||
'Project settings…',
|
||
'Master routing',
|
||
'Clear saved data',
|
||
null,
|
||
'Start recording…',
|
||
null,
|
||
'General settings…',
|
||
'Plugins',
|
||
];
|
||
check(
|
||
'the menu button opens new project, the project picker, the settings, the master routing, clearing saves, recording the editor, the general settings and the plugins',
|
||
JSON.stringify(rows) === JSON.stringify(expected),
|
||
JSON.stringify(rows),
|
||
);
|
||
|
||
// 46c. The picker lists what the account owns, with the open project picked, and Cancel leaves
|
||
// that project exactly where it was.
|
||
await clickText('Open project…');
|
||
// The picker fetches the list before it can open, so it is waited for rather than read at once.
|
||
await waitUntil('__gm.projectPicker() !== null', { label: 'the project picker opened' });
|
||
const picker = await json('__gm.projectPicker()');
|
||
const projectId = await json('__gm.state.project.id');
|
||
const projectName = await json('__gm.state.project.name');
|
||
check(
|
||
'"Open project…" lists the projects with the open one picked',
|
||
picker !== null && picker.picked === projectId && picker.names.includes(projectName),
|
||
JSON.stringify({ picker, projectId, projectName }),
|
||
);
|
||
await clickText('Cancel');
|
||
check(
|
||
'Cancel closes the picker and leaves the project loaded',
|
||
(await json('__gm.projectPicker()')) === null && (await json('__gm.state.project.id')) === projectId,
|
||
JSON.stringify({ picker: await json('__gm.projectPicker()'), id: await json('__gm.state.project.id') }),
|
||
);
|
||
|
||
// 46d. The settings dialog holds the name, the start-scene switch and the collision layers of
|
||
// #152 – the three things the bar itself used to carry.
|
||
await clickToolbar('menu');
|
||
await clickText('Project settings…');
|
||
const settings = await json('__gm.projectSettings()');
|
||
const layers = await json('__gm.collisionLayers()');
|
||
check(
|
||
'"Project settings…" opens on the project as it stands',
|
||
settings?.name === projectName &&
|
||
(settings.playFrom === 'start' || settings.playFrom === 'active') &&
|
||
JSON.stringify(layers.dialog) === JSON.stringify(layers.project),
|
||
JSON.stringify({ settings, layers }),
|
||
);
|
||
|
||
// 46e. Renaming through it, and the icon buttons undoing and redoing it. The name is read off the
|
||
// project rather than written down here: CI seeds the same project under a different name.
|
||
const nameField = await t.locate(projectName);
|
||
await t.doubleClick(nameField.x, nameField.y);
|
||
await press('a', { ctrl: true });
|
||
await typeText('Renamed by 46');
|
||
await press('Enter');
|
||
await clickText('Apply');
|
||
await sleep(200);
|
||
check(
|
||
'Apply renames the project',
|
||
(await json('__gm.state.project.name')) === 'Renamed by 46',
|
||
await json('__gm.state.project.name'),
|
||
);
|
||
await clickToolbar('undo');
|
||
await sleep(200);
|
||
const undone = await json('__gm.state.project.name');
|
||
await clickToolbar('redo');
|
||
await sleep(200);
|
||
const redone = await json('__gm.state.project.name');
|
||
check(
|
||
'the undo and redo buttons of the bar walk the rename back and forth',
|
||
undone === projectName && redone === 'Renamed by 46',
|
||
JSON.stringify({ undone, redone, was: projectName }),
|
||
);
|
||
// Back to the name the rest of the world knows it by.
|
||
await clickToolbar('undo');
|
||
await sleep(200);
|
||
check(
|
||
'the project is handed back under its own name',
|
||
(await json('__gm.state.project.name')) === projectName,
|
||
await json('__gm.state.project.name'),
|
||
);
|
||
|
||
// 46h. The icon buttons say what they do (#209). The pointer has to rest for the sign to show; it
|
||
// names the step redo would take and the keys, and sits under its button, inside the window.
|
||
const tooltip = () => json('__gm.tooltip()');
|
||
const redoAt = await json('__gm.toolbarAt("redo")');
|
||
const redoLabel = await json('__gm.state.commands.redoLabel');
|
||
await t.mouse('mouseMoved', redoAt.x - 1, redoAt.y);
|
||
await t.mouse('mouseMoved', redoAt.x, redoAt.y);
|
||
const atOnce = await tooltip();
|
||
await waitUntil('__gm.tooltip() !== null', { label: 'the tooltip of the redo button' });
|
||
const redoTip = await tooltip();
|
||
const windowSize = await json('({ w: window.innerWidth, h: window.innerHeight })');
|
||
await shot('46h-tooltip');
|
||
check(
|
||
'resting on the redo button shows "Redo <step> (Ctrl+Y)" under it, after a wait and not at once',
|
||
atOnce === null &&
|
||
typeof redoLabel === 'string' &&
|
||
redoLabel.length > 0 &&
|
||
redoTip.text === `Redo ${redoLabel} (Ctrl+Y)` &&
|
||
redoTip.box.y >= redoTip.anchor.y + redoTip.anchor.h &&
|
||
redoTip.box.x === Math.round(redoTip.anchor.x) &&
|
||
redoTip.box.x + redoTip.box.w <= windowSize.w &&
|
||
(await has(redoTip.text)) === false,
|
||
JSON.stringify({ atOnce, redoLabel, redoTip }),
|
||
);
|
||
// Off the button it goes. The wheel takes it away as well, and the button stays quiet until the
|
||
// pointer has left it; a click does the same, here with the menu opening under it.
|
||
await t.mouse('mouseMoved', redoAt.x + 90, redoAt.y);
|
||
await sleep(100);
|
||
const afterLeaving = await tooltip();
|
||
const menuAt = await json('__gm.toolbarAt("menu")');
|
||
const restOnMenu = async () => {
|
||
await t.mouse('mouseMoved', menuAt.x - 1, menuAt.y);
|
||
await t.mouse('mouseMoved', menuAt.x, menuAt.y);
|
||
await waitUntil('__gm.tooltip() !== null', { label: 'the tooltip of the menu button' });
|
||
return tooltip();
|
||
};
|
||
const menuTip = await restOnMenu();
|
||
await t.wheel(menuAt.x, menuAt.y, 40);
|
||
const afterWheel = await tooltip();
|
||
await t.mouse('mouseMoved', menuAt.x + 1, menuAt.y);
|
||
await sleep(800);
|
||
const afterWaiting = await tooltip();
|
||
await t.mouse('mouseMoved', menuAt.x + 200, menuAt.y);
|
||
const again = await restOnMenu();
|
||
await clickToolbar('menu');
|
||
const afterClick = await tooltip();
|
||
const menuOpen = (await json('__gm.menuRows()')) !== null;
|
||
await press('Escape');
|
||
check(
|
||
'the tooltip goes when the pointer leaves, and the wheel or a click takes it away until the pointer comes back',
|
||
afterLeaving === null &&
|
||
menuTip.text === 'Menu' &&
|
||
afterWheel === null &&
|
||
afterWaiting === null &&
|
||
again.text === 'Menu' &&
|
||
afterClick === null &&
|
||
menuOpen === true,
|
||
JSON.stringify({ afterLeaving, menuTip, afterWheel, afterWaiting, again, afterClick, menuOpen }),
|
||
);
|
||
// At the right border the sign turns left: flush with the right edge of its button. The button
|
||
// nearest that border is the reset of an inspector row, which an override brings up.
|
||
const edgeObject = await json(`(() => {
|
||
const p = __gm.state.project;
|
||
const scene = __gm.state.activeScene;
|
||
const o = scene.objects.find((o) => p.assets.find((a) => a.id === o.assetId)?.type === 'sprite');
|
||
return o ? { scene: scene.id, id: o.id, overrides: o.overrides } : null;
|
||
})()`);
|
||
const setOverrides = (overrides) =>
|
||
t.mutate(
|
||
`__gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(edgeObject.id)}).overrides = ${JSON.stringify(overrides)}`,
|
||
);
|
||
await setOverrides({ ...edgeObject.overrides, width: 77 });
|
||
await t.select({ kind: 'object', holderId: edgeObject.scene, objectId: edgeObject.id });
|
||
const resetAt = await t.findIcon('reset', false);
|
||
await t.mouse('mouseMoved', resetAt.x - 1, resetAt.y);
|
||
await t.mouse('mouseMoved', resetAt.x, resetAt.y);
|
||
await waitUntil('__gm.tooltip() !== null', { label: 'the tooltip of the reset button' });
|
||
const edgeTip = await tooltip();
|
||
await shot('46h-tooltip-edge');
|
||
check(
|
||
'a tooltip that would leave the window on the right turns left, and names the one thing its button does',
|
||
edgeTip.text === 'Reset to asset' &&
|
||
edgeTip.anchor.x + edgeTip.box.w > windowSize.w &&
|
||
edgeTip.box.x + edgeTip.box.w === Math.round(edgeTip.anchor.x + edgeTip.anchor.w) &&
|
||
edgeTip.box.x + edgeTip.box.w <= windowSize.w,
|
||
JSON.stringify({ edgeTip, windowSize }),
|
||
);
|
||
await t.mouse('mouseMoved', windowSize.w / 2, windowSize.h / 2);
|
||
await setOverrides(edgeObject.overrides);
|
||
await t.select(null);
|
||
|
||
// 46f. The symbol scale in the same dialog: 1 by default, typed down to 0.5, and every icon of the
|
||
// scene drawn at half its side (18 → 9 world units), undone in one step.
|
||
const iconSize = () =>
|
||
json(`(() => {
|
||
const p = __gm.state.project;
|
||
const scene = p.assets.find((a) => a.id === p.activeSceneId);
|
||
const id = (scene?.objects ?? []).map((o) => o.id).find((id) => __gm.sceneIcon(id)?.visible);
|
||
return id ? __gm.sceneIcon(id).size : null;
|
||
})()`);
|
||
await clickToolbar('menu');
|
||
await clickText('Project settings…');
|
||
const opened = await json('__gm.projectSettings()');
|
||
const field = await json('__gm.projectSettingsSymbolScaleAt()');
|
||
await t.click(field.x, field.y);
|
||
await press('a', { ctrl: true });
|
||
await typeText('0.5');
|
||
await press('Enter');
|
||
await clickText('Apply');
|
||
await sleep(200);
|
||
const scaled = { project: await json('__gm.state.project.symbolScale'), icon: await iconSize() };
|
||
await clickToolbar('undo');
|
||
await sleep(200);
|
||
const restored = { project: await json('__gm.state.project.symbolScale'), icon: await iconSize() };
|
||
check(
|
||
'the symbol scale starts at 1, Apply halves every scene icon, and undo gives the size back',
|
||
opened?.symbolScale === 1 &&
|
||
scaled.project === 0.5 &&
|
||
Math.abs(scaled.icon - 9) < 1e-9 &&
|
||
restored.project === 1 &&
|
||
Math.abs(restored.icon - 18) < 1e-9,
|
||
JSON.stringify({ opened, scaled, restored }),
|
||
);
|
||
|
||
// 46g. The eye in the scene tab's header: every placeable type with a switch for its symbol and its name, and a
|
||
// outline of a collider and of a tilemap's solid cells besides. Sprite, Text and Tilemap have no symbol, so that switch is
|
||
// greyed out. Real clicks flip the scene view, and the switches are saved but no undo step.
|
||
await json('(__gm.showTab("scene"), true)');
|
||
const firstOf = (type) =>
|
||
json(`(() => {
|
||
const p = __gm.state.project;
|
||
const scene = p.assets.find((a) => a.id === p.activeSceneId);
|
||
const o = (scene?.objects ?? []).find((o) => p.assets.find((a) => a.id === o.assetId)?.type === ${JSON.stringify(type)} && o.enabled !== false);
|
||
return o ? o.id : null;
|
||
})()`);
|
||
const ids = {
|
||
sprite: await firstOf('sprite'),
|
||
collider: 'vis-col-obj',
|
||
behavior: await firstOf('behavior'),
|
||
tilemap: 'vis-map-obj',
|
||
};
|
||
// A map of its own with one solid row, laid over the sprite so it is on screen: the seed has none.
|
||
// A collider of its own there too: in the full run the first one in the scene is a leftover of an
|
||
// earlier module, off screen, and its outline reads as hidden before any switch is touched.
|
||
await t.mutate(`
|
||
const scene = __gm.state.activeScene;
|
||
const assets = __gm.state.project.assets;
|
||
const at = scene.objects.find((o) => o.id === ${JSON.stringify(ids.sprite)}).position;
|
||
assets.push({ id: 'vis-wall', type: 'tile', name: 'Vis wall', props: { kind: 'none', solid: true }, pictures: [] });
|
||
assets.push({ id: 'vis-map', type: 'tilemap', name: 'Vis map', props: { columns: 3, rows: 1, tileSize: 32 },
|
||
layers: [{ id: 'vis-layer', name: 'Layer 1', cells: { '0,0': 'vis-wall', '1,0': 'vis-wall', '2,0': 'vis-wall' } }] });
|
||
const o = structuredClone(scene.objects[0]);
|
||
Object.assign(o, { id: 'vis-map-obj', assetId: 'vis-map', name: 'Vis map', position: { ...at },
|
||
parentId: '', overrides: {}, instanceEdits: {} });
|
||
scene.objects.push(o);
|
||
assets.push({ id: 'vis-col', type: 'collider', name: 'Vis collider',
|
||
props: { shape: 'box', width: 40, height: 40, radius: 20, isTrigger: false } });
|
||
scene.objects.push(Object.assign(structuredClone(o), { id: 'vis-col-obj', assetId: 'vis-col', name: 'Vis collider' }));
|
||
__gm.state.select(null)`);
|
||
const undoBefore = await json('__gm.state.commands.canUndo');
|
||
await t.showTab('scene');
|
||
const eye = await json(`__gm.sceneHeaderAt('visibility')`);
|
||
await t.click(eye.x, eye.y);
|
||
const popup = await json('__gm.visibilityPopup()');
|
||
const entry = (list, type, part) => list?.find((e) => e.type === type && e.part === part);
|
||
const types = [...new Set((popup ?? []).map((e) => e.type))];
|
||
check(
|
||
'the eye lists every placeable type, Symbol greyed out where there is none and Shape on collider, tilemap and clip area',
|
||
JSON.stringify(types) ===
|
||
JSON.stringify([
|
||
'behavior',
|
||
'camera',
|
||
'clip',
|
||
'collider',
|
||
'composition',
|
||
'container',
|
||
'joint',
|
||
'particles',
|
||
'rigidbody',
|
||
'sprite',
|
||
'text',
|
||
'tilemap',
|
||
]) &&
|
||
entry(popup, 'sprite', 'symbol')?.enabled === false &&
|
||
entry(popup, 'tilemap', 'label')?.enabled === true &&
|
||
entry(popup, 'sprite', 'label')?.on === false &&
|
||
entry(popup, 'collider', 'shape')?.on === true &&
|
||
entry(popup, 'tilemap', 'shape')?.on === true &&
|
||
entry(popup, 'clip', 'shape')?.on === true &&
|
||
popup.filter((e) => e.part === 'shape').length === 3,
|
||
JSON.stringify(popup),
|
||
);
|
||
const flip = async (type, part) => {
|
||
const at = entry(await json('__gm.visibilityPopup()'), type, part);
|
||
await t.click(at.x, at.y);
|
||
await sleep(150);
|
||
};
|
||
const labelIds = async () => (await json('__gm.sceneLabels()')).map((l) => l.id);
|
||
const mapLooks = async () => ({
|
||
solids: (await json(`__gm.tilemap.solids(${JSON.stringify(ids.tilemap)})`))?.visible ?? null,
|
||
drawn: (await json(`__gm.tilemap.info(${JSON.stringify(ids.tilemap)})`)) !== null,
|
||
});
|
||
const before = {
|
||
shape: await json(`__gm.sceneShape(${JSON.stringify(ids.collider)})`),
|
||
labels: await labelIds(),
|
||
icon: (await json(`__gm.sceneIcon(${JSON.stringify(ids.behavior)})`))?.visible,
|
||
map: await mapLooks(),
|
||
};
|
||
await flip('collider', 'shape');
|
||
await flip('tilemap', 'shape');
|
||
await flip('behavior', 'label');
|
||
await flip('behavior', 'symbol');
|
||
await flip('sprite', 'label');
|
||
const after = {
|
||
shape: await json(`__gm.sceneShape(${JSON.stringify(ids.collider)})`),
|
||
labels: await labelIds(),
|
||
icon: (await json(`__gm.sceneIcon(${JSON.stringify(ids.behavior)})`))?.visible,
|
||
popup: await json('__gm.visibilityPopup()'),
|
||
saved: await json('__gm.state.project.sceneVisibility'),
|
||
map: await mapLooks(),
|
||
};
|
||
await shot('46g-visibility');
|
||
check(
|
||
"a tilemap's Shape hides the outline of its solid cells and leaves the map itself drawn",
|
||
before.map.solids === true &&
|
||
before.map.drawn === true &&
|
||
after.map.solids === false &&
|
||
after.map.drawn === true &&
|
||
after.saved.tilemap.shape === false,
|
||
JSON.stringify({ before: before.map, after: after.map }),
|
||
);
|
||
check(
|
||
'switching hides the collider outline and the behavior icon and name, and puts the sprite name on',
|
||
before.shape?.visible === true &&
|
||
after.shape?.visible === false &&
|
||
after.shape?.clickable === false &&
|
||
before.icon === true &&
|
||
after.icon === false &&
|
||
before.labels.includes(ids.behavior) &&
|
||
!after.labels.includes(ids.behavior) &&
|
||
!before.labels.includes(ids.sprite) &&
|
||
after.labels.includes(ids.sprite) &&
|
||
entry(after.popup, 'collider', 'shape')?.on === false,
|
||
JSON.stringify({ ids, before, after: { ...after, popup: undefined } }),
|
||
);
|
||
check(
|
||
'the switches go into the project and not onto the undo stack',
|
||
after.saved.collider.shape === false &&
|
||
after.saved.behavior.label === false &&
|
||
after.saved.sprite.label === true &&
|
||
(await json('__gm.state.commands.canUndo')) === undoBefore,
|
||
JSON.stringify({ saved: after.saved, undoBefore }),
|
||
);
|
||
// Handed back as found, for whatever runs after this.
|
||
await flip('collider', 'shape');
|
||
await flip('tilemap', 'shape');
|
||
await flip('behavior', 'label');
|
||
await flip('behavior', 'symbol');
|
||
await flip('sprite', 'label');
|
||
await press('Escape');
|
||
await t.mutate(`
|
||
const scene = __gm.state.activeScene;
|
||
scene.objects = scene.objects.filter((o) => o.id !== 'vis-map-obj' && o.id !== 'vis-col-obj');
|
||
const assets = __gm.state.project.assets;
|
||
for (const id of ['vis-map', 'vis-wall', 'vis-col']) assets.splice(assets.findIndex((a) => a.id === id), 1)`);
|
||
await t.saved();
|
||
|
||
// 46i. "General settings…" (#309): the zoom transition in ms, kept in this browser. The harness
|
||
// sets 0 on load. At 1000 ms one wheel step in the scene view is sampled on its way – the zoom
|
||
// between the two steps, the world point under the pointer still under it – and it lands on
|
||
// the very view a jump of 0 ms reaches.
|
||
await clickToolbar('menu');
|
||
await clickText('General settings…');
|
||
const general = await json('__gm.generalSettingsDialog()');
|
||
const msField = await json('__gm.generalSettingsZoomAt()');
|
||
await t.click(msField.x, msField.y);
|
||
await press('a', { ctrl: true });
|
||
await typeText('1000');
|
||
await press('Enter');
|
||
await clickText('Apply');
|
||
await sleep(100);
|
||
const kept = {
|
||
dialog: await json('__gm.generalSettingsDialog()'),
|
||
ms: await json('__gm.zoomTransition()'),
|
||
stored: await json(`JSON.parse(localStorage.getItem('feuerwerk.general-settings') ?? 'null')`),
|
||
};
|
||
check(
|
||
'"General settings…" opens on the kept transition, and Apply keeps the one typed in the browser',
|
||
general?.zoomTransitionMs === 0 &&
|
||
kept.dialog === null &&
|
||
kept.ms === 1000 &&
|
||
kept.stored?.zoomTransitionMs === 1000,
|
||
JSON.stringify({ general, kept }),
|
||
);
|
||
|
||
// 46i-ii. The custom mouse pointer, on by default: the canvas shows the cursor file an editor
|
||
// recording draws as its CSS cursor (Chrome took the value, the picture loads, the keyword
|
||
// stays at the end). Switched off in the dialog the system's keyword is back, on again the file.
|
||
// `json` would stringify the promise itself: this one hands back its JSON once it is settled.
|
||
const pictureLoads = async () =>
|
||
JSON.parse(
|
||
await t.evaluate(`(async () => {
|
||
const url = /^url\\("([^"]*)"\\)/.exec(__gm.canvasCursor())?.[1];
|
||
if (!url) return 'null';
|
||
const image = new Image();
|
||
image.src = url;
|
||
try { await image.decode(); return JSON.stringify([image.naturalWidth, image.naturalHeight]); }
|
||
catch { return '"broken"'; }
|
||
})()`),
|
||
);
|
||
const switchCursor = async () => {
|
||
await clickToolbar('menu');
|
||
await clickText('General settings…');
|
||
const box = await json('__gm.generalSettingsCustomCursorAt()');
|
||
await t.click(box.x, box.y);
|
||
await clickText('Apply');
|
||
await sleep(100);
|
||
};
|
||
const cursorOn = { css: await json('__gm.canvasCursor()'), size: await pictureLoads() };
|
||
await switchCursor();
|
||
const cursorOff = { css: await json('__gm.canvasCursor()') };
|
||
const storedOff = await json(`JSON.parse(localStorage.getItem('feuerwerk.general-settings')).customCursor`);
|
||
await switchCursor();
|
||
const cursorBack = await json('__gm.canvasCursor()');
|
||
check(
|
||
'the custom mouse pointer is on by default as the CSS cursor, General settings switch it off and on',
|
||
general?.customCursor === true &&
|
||
/^url\("data:image\/svg\+xml,.*"\) \d+ \d+, default$/.test(cursorOn.css) &&
|
||
Array.isArray(cursorOn.size) &&
|
||
cursorOn.size[0] > 0 &&
|
||
!cursorOff.css.startsWith('url(') &&
|
||
storedOff === false &&
|
||
cursorBack === cursorOn.css,
|
||
JSON.stringify({ cursorOn: { ...cursorOn, css: cursorOn.css.slice(0, 40) }, cursorOff, storedOff }),
|
||
);
|
||
|
||
await t.showTab('scene');
|
||
const rect = await json('__gm.viewportRect()');
|
||
const at = { x: Math.round(rect.x + rect.w * 0.7), y: Math.round(rect.y + rect.h * 0.4) };
|
||
const viewNow = async () => ({
|
||
...(await json('__gm.sceneView()')),
|
||
world: await json(`__gm.sceneWorldAt(${at.x}, ${at.y})`),
|
||
});
|
||
await t.evaluate('__gm.zoomTransition(0)');
|
||
const start = await viewNow();
|
||
await t.wheel(at.x, at.y, -100);
|
||
const jumped = await viewNow();
|
||
await t.wheel(at.x, at.y, 100);
|
||
const back = await viewNow();
|
||
await t.evaluate('__gm.zoomTransition(1000)');
|
||
await t.mouse('mouseMoved', at.x, at.y);
|
||
await t.mouse('mouseWheel', at.x, at.y, 'none', { deltaX: 0, deltaY: -100 });
|
||
await t.settle(2);
|
||
const midway = await viewNow();
|
||
await waitUntil('!__gm.sceneView().moving', { label: 'the zoom transition ended' });
|
||
const landed = await viewNow();
|
||
const samePoint = (a, b) => Math.abs(a.x - b.x) < 1e-6 && Math.abs(a.y - b.y) < 1e-6;
|
||
check(
|
||
'a frame after the wheel the scene view is on its way: zoom between the steps, the point under the pointer kept',
|
||
back.zoom === start.zoom &&
|
||
jumped.zoom > start.zoom &&
|
||
midway.moving === true &&
|
||
midway.zoom > start.zoom &&
|
||
midway.zoom < jumped.zoom &&
|
||
samePoint(midway.world, start.world),
|
||
JSON.stringify({ start, jumped, back, midway }),
|
||
);
|
||
check(
|
||
'the transition lands on the view a jump reaches: the same step and the same centre',
|
||
landed.moving === false &&
|
||
landed.zoom === jumped.zoom &&
|
||
samePoint(landed, jumped) &&
|
||
samePoint(landed.world, start.world),
|
||
JSON.stringify({ jumped, landed }),
|
||
);
|
||
// A pan during the transition takes it along instead of jumping to its end.
|
||
const by = { x: 40, y: 30 };
|
||
const moved = { x: at.x + by.x, y: at.y + by.y };
|
||
await t.mouse('mouseMoved', at.x, at.y);
|
||
await t.mouse('mouseWheel', at.x, at.y, 'none', { deltaX: 0, deltaY: -100 });
|
||
await t.drag(at.x, at.y, moved.x, moved.y, { steps: 4, button: 'middle' });
|
||
const panned = await json('__gm.sceneView()');
|
||
await waitUntil('!__gm.sceneView().moving', { label: 'the zoom transition ended after the pan' });
|
||
const settled = await json('__gm.sceneView()');
|
||
const heldAfter = await json(`__gm.sceneWorldAt(${moved.x}, ${moved.y})`);
|
||
check(
|
||
'a pan during the transition carries it on: still on its way, then landed one step further, moved by the pan',
|
||
panned.moving === true &&
|
||
panned.zoom > landed.zoom &&
|
||
panned.zoom < settled.zoom &&
|
||
settled.zoom > landed.zoom &&
|
||
samePoint(heldAfter, start.world),
|
||
JSON.stringify({ landed, panned, settled, heldAfter, world: start.world }),
|
||
);
|
||
// Handed back as found: no transition for the steps after this one, and the view it had.
|
||
await t.evaluate('__gm.zoomTransition(0)');
|
||
await t.drag(moved.x, moved.y, at.x, at.y, { steps: 4, button: 'middle' });
|
||
await t.wheel(at.x, at.y, 100);
|
||
await t.wheel(at.x, at.y, 100);
|
||
}
|