Autosave only keeps the latest revision. Before a risky change there
was no way to put a known good state aside and go back to it later.
The main menu now has "Snapshots..." to create, restore and delete
named copies of the open project.
- Server: SnapshotStore.php keeps each snapshot as a copy of the
project JSON under data/snapshots/<project>/ (git-ignored). Four
routes under projects/{id}/snapshots use the same owner check as the
project, so an admin also sees foreign snapshots.
- Create writes the autosave first. With unsaved edits (play with open
edits, failed save, conflict) no snapshot is made and a message says
why. The rule is the pure function in src/editor/snapshots.ts.
- An empty name falls back to the local date and time.
- Restore asks first, then keeps the current state as "Before
restoring <name>". It writes the snapshot back as a new revision and
reloads the project, so the undo list is empty. A restore can itself
be undone by restoring that snapshot.
- Deleting a project deletes its snapshots too. Images, sounds, fonts
and pixel art are content-addressed and never deleted, so a snapshot
can always reach them.
- Client: SnapshotStore and HttpSnapshotStore in src/core/store, the
SnapshotsDialog, and the flows in ProjectSession. A stale revision
(409) becomes StaleRevisionError.
- CI runs the new PHP test in the gate job next to accounts.
Not covered by e2e: the lock during play and the conflict case. Both
are unit tests only.
Tests:
- check.sh: 164 files, 2401 tests, CHECK EXIT 0.
- tests/php/snapshots.test.php: 14 checks, all passed.
- tests/editor/snapshots.test.ts: 5 tests for the time format, the
blocker rule, the routes and 409.
- New e2e module 50 on a throwaway project: create, rename, restore,
undo the restore, delete, then delete the project and get 404 on
its snapshots. 46b expects the new menu entry. e2e 46,50: 20 checks
passed. The check sum goes from 1385 to 1393 (computed).
- Counter-checks, originals restored and compared with cmp: blocker
always allowed fails 1 unit test. No sorting fails 2 PHP checks.
Restore without keeping the old state fails 50c and aborts 50d.
- The first sort test with 2 snapshots passed by chance half the time
because of random ids. It now uses 8.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
172 lines
7.2 KiB
JavaScript
172 lines
7.2 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 and nothing to pause',
|
||
buttons.play === 'play' &&
|
||
buttons.pause === 'pause' &&
|
||
buttons.playEnabled === true &&
|
||
buttons.pauseEnabled === false,
|
||
JSON.stringify(buttons),
|
||
);
|
||
// The pair is centred on the bar itself. The row cannot do that on its own: the spacers around
|
||
// the two buttons share the free space with the flexible notice and so get a third of it each,
|
||
// which used to leave the pair well left of the middle.
|
||
const play = await json('__gm.toolbarAt("play")');
|
||
const pause = await json('__gm.toolbarAt("pause")');
|
||
const barCentre = await json('window.innerWidth / 2');
|
||
check(
|
||
'play and pause sit on the middle of the bar, not on the middle of what is left over',
|
||
Math.abs((play.x + pause.x) / 2 - barCentre) <= 2,
|
||
JSON.stringify({ middle: (play.x + pause.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…',
|
||
'Project settings…',
|
||
'Clear saved data',
|
||
null,
|
||
'Plugins',
|
||
];
|
||
check(
|
||
'the menu button opens new project, the project picker, the settings, clearing saves 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'),
|
||
);
|
||
|
||
// 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 }),
|
||
);
|
||
await t.saved();
|
||
}
|