Files
feuerwerk/tools/e2e/53-curves.mjs
T
adminandClaude Opus 5.5 0f85476b93
game-maker / gate (push) Successful in 2m39s
game-maker / suite (push) Successful in 13m8s
Add points by double-click and delete the picked one (#288)
Four editors edit points, and all of them put a point down on a single
press on the line and took one away on a double-click. A stray click
added a point, and there was no way to see which point a key would hit.
All four now work the same way: the Curve tab, the Vector art tab, a
polygon collider in the scene view and a frame's collision outline in
the Texture tab.

- A double-click on a line, edge or segment puts a point there, in the
  Curve tab on free space too. The point is picked and stays in place.
  It can be dragged with the next press, as answered in the issue.
- A click on a point picks it, and it is drawn filled. A double-click
  on a point takes nothing away any more.
- Delete or Backspace takes the picked point away. Without a picked
  point the key removes the shape, the object or the outline as before.
- A right-click on a point picks it and opens a menu with Delete
  point. In the Curve and Vector art tab the four point types stand
  below it. Alt-click stays.
- A polygon keeps three corners and a curve keeps its ends. The entry
  is greyed out then.
- The pick ends with a click beside the points, with another object,
  shape or frame, and after the point is gone.
- editor/pointGestures.ts holds the rule as a pure function, and
  editor/pointMenu.ts builds the menu for all four.

Decided here:

- A single click on a line no longer adds anything. In Vector art it
  drags the shape, on a polygon it drops the pick, and in the Texture
  tab it makes that outline the active one and drops the pick.
- In the Texture tab a click inside the frame still appends a corner,
  and that corner is picked. Delete takes the last corner first. To
  remove the whole outline, click one of its edges before.
- The point the Pen put down last is picked, so Delete takes it and
  not the shape.
- Delete hits the picked point also when the tab has lost the keyboard
  focus, after a click in the shape list or after the menu.
- A vector shape that loses its last point is removed whole. Before,
  an empty shape stayed in the list.

Findings:

- Fixed: the new hint line in the Texture tab was cut off at the edge.
  The shorter text was not looked at in a picture again.
- Not fixed: in the scene view the collider's name label covers a
  corner that sits near the centre. That was so before.

Tests:
- Gate: 200 files, 2967 tests (2944 + 23), exit 0
- e2e, full run: 1629 checks passed (1625 + 4), exit 0
- Smokes: 24 / 19 / 21, exit 0
- After the full run an unused parameter and a comment were tidied.
  For that final state the gate and e2e 42,47,53 (105 checks) ran
  again, the full run did not.
- 23 unit tests in tests/editor/point-gestures.test.ts, for the rule
  and for each of the four editors
- Four e2e checks with a real mouse, two in module 42 and one each in
  47 and 53. The existing checks for adding and removing a point use
  the new gestures.
- 20 unit counter-probes all fail tests, run on stein2
- Two browser counter-probes fail, the build went through both times.
  Without the Delete wiring the key removes the collider. Without the
  right-click hook the menu is missing, and that run stops in module
  42, so 47 and 53 were not reached with it.
- Three screenshots looked at, the picked point is filled. None of
  the Vector art tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:36:13 +02:00

537 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { edge, node } from '../lib/builders.mjs';
/**
* Curve assets (#247): made from the + menu and opened by its row, bent in the Curve tab with the
* real mouse – a point put down on free space by a double-click, turned smooth through the header, a
* handle pulled, a point taken away by Delete and by its menu –
* and then picked where a built-in ease is picked, on an animation key and on a `Lerp` node. The
* values those two produce are compared with the curve worked out here, by walking its Bezier
* segments, not with the editor's own solver.
*
* Everything it adds carries a `cv-` id or is the curve it made, and goes again at the end.
*/
/** The curve's value at `t`, from its segments walked finely and read off in between. */
function expectedAt(points, t) {
for (let i = 1; i < points.length; i++) {
const from = points[i - 1];
const to = points[i];
if (t > to.x) continue;
const c1 = { x: from.x + (from.out?.x ?? 0), y: from.y + (from.out?.y ?? 0) };
const c2 = { x: to.x + (to.in?.x ?? 0), y: to.y + (to.in?.y ?? 0) };
const at = (s, a, b, c, d) =>
(1 - s) ** 3 * a + 3 * (1 - s) ** 2 * s * b + 3 * (1 - s) * s * s * c + s ** 3 * d;
let previous = { x: from.x, y: from.y };
for (let step = 1; step <= 20000; step++) {
const s = step / 20000;
const here = { x: at(s, from.x, c1.x, c2.x, to.x), y: at(s, from.y, c1.y, c2.y, to.y) };
if (here.x >= t) {
const share = here.x > previous.x ? (t - previous.x) / (here.x - previous.x) : 1;
return previous.y + (here.y - previous.y) * share;
}
previous = here;
}
return to.y;
}
return points.at(-1).y;
}
export async function curves(t) {
const { check, json, evaluate, mutate, click, doubleClick, drag, settle, sleep, shot, showTab, undo } = t;
const inOverlay = async (text) => {
const box = await json('__gm.overlayRect()');
const hits = (await json(`__gm.locateAll(${JSON.stringify(text)})`)) ?? [];
return (
hits.find((h) => h.x >= box.x && h.x <= box.x + box.w && h.y >= box.y && h.y <= box.y + box.h) ?? null
);
};
const menuRow = async (label) => {
const rows = (await json('__gm.menuRows()')) ?? [];
const row = rows.find((r) => typeof r === 'string' && (r === label || r.endsWith(` ${label}`)));
return row ? inOverlay(row) : null;
};
const pointsOf = (id) => json(`__gm.curve.points(${JSON.stringify(id)})`);
// 53a. The + menu makes one; its row opens the Curve tab on it, a straight line of two points.
await showTab('scene');
await t.clickText('+');
const offered = (await json('__gm.menuRows()')) ?? [];
const entry = await inOverlay('Curve');
check('the + menu offers Curve', offered.includes('Curve') && Boolean(entry), JSON.stringify(offered));
if (!entry) return;
await click(entry.x, entry.y);
await settle();
const curveId = (
await json("__gm.state.project.assets.filter((a) => a.type === 'curve').map((a) => a.id)")
).at(-1);
// "Curve" is the new asset's name, the Easing value and the row label at once – a name of its own
// keeps `inspectorField('Curve')` on the row.
await mutate(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(curveId)}).name = 'Swoop 53'`,
);
const row = await json(`__gm.assetRow(${JSON.stringify(curveId)})`);
await doubleClick(row.x, row.y);
await settle();
const fresh = await pointsOf(curveId);
check(
'a double-click on the new curve opens the Curve tab on a straight line from (0, 0) to (1, 1)',
(await json('__gm.curve.current()')) === curveId &&
JSON.stringify(fresh) ===
JSON.stringify([
{ x: 0, y: 0 },
{ x: 1, y: 1 },
]) &&
(await json('__gm.curve.info()')) === '2 points',
JSON.stringify({ current: await json('__gm.curve.current()'), fresh }),
);
// 53b. A double-click on free space puts a point there, and it is the picked one (#288); a click
// alone adds nothing.
const spot = await json('__gm.curve.at(0.5, 0.9)');
// Past the double-click that opened the tab, and again past the click that must not count.
await sleep(500);
await click(spot.x, spot.y);
const onlyClicked = (await pointsOf(curveId))?.length;
await sleep(500);
await doubleClick(spot.x, spot.y);
await settle();
const put = await pointsOf(curveId);
const middle = put?.[1];
check(
'a double-click on free space puts a point there, between the ends, and picks it – a click adds none',
onlyClicked === 2 &&
put?.length === 3 &&
Math.abs(middle.x - 0.5) < 0.01 &&
Math.abs(middle.y - 0.9) < 0.01 &&
!middle.in &&
!middle.out &&
(await json('__gm.curve.picked()')) === 1,
JSON.stringify({ put, picked: await json('__gm.curve.picked()') }),
);
// 53c. Point: Smooth grows its handles, and pulling the right one up bends the curve – the left
// handle turns with it, the other way round.
const pointButton = await json('__gm.curve.pointButton()');
await click(pointButton.x, pointButton.y);
await sleep(100);
const smooth = await menuRow('Smooth');
if (smooth) await click(smooth.x, smooth.y);
await settle();
const rounded = await pointsOf(curveId);
const grown = rounded?.[1];
check(
'Point: Smooth grows both handles of the picked point, in line',
grown?.mode === 'smooth' &&
Boolean(grown.in && grown.out) &&
Math.abs(grown.in.x * grown.out.y - grown.in.y * grown.out.x) < 1e-9,
JSON.stringify({ grown, button: pointButton }),
);
if (!grown?.out) return;
// The handle reaches above what the straight line was fitted to; Fit frames it again.
const fit = await json('__gm.curve.fitAt()');
await click(fit.x, fit.y);
await settle();
const tip = await json(`__gm.curve.at(${grown.x + grown.out.x}, ${grown.y + grown.out.y})`);
await drag(tip.x, tip.y, tip.x, tip.y - 60, { steps: 8 });
await settle();
const bent = await pointsOf(curveId);
const pulled = bent?.[1];
const across = pulled && pulled.in.x * pulled.out.y - pulled.in.y * pulled.out.x;
check(
'dragging the right handle up raises it, and the smooth point turns its left handle to match',
pulled?.out.y > grown.out.y + 0.1 && Math.abs(across) < 1e-6 && pulled.in.x < 0 && pulled.in.y < 0,
JSON.stringify({ before: grown, after: pulled }),
);
await shot('53-curve-tab');
// The preview stands still until its button runs it; pressed again, it holds.
// Three samples over 0.6 s: longer than the half second the dot rests at the end of each pass.
const sample = async () => {
const seen = [];
for (let i = 0; i < 3; i++) {
if (i > 0) await sleep(300);
seen.push((await json('__gm.curve.preview()'))?.t);
}
return seen;
};
const previewButton = await json('__gm.curve.previewButton()');
const idle = await sample();
await click(previewButton.x, previewButton.y);
await settle();
const runningButton = await json('__gm.curve.previewButton()');
const running = await sample();
await click(previewButton.x, previewButton.y);
await settle();
const held = [(await json('__gm.curve.preview()'))?.t];
await sleep(250);
held.push((await json('__gm.curve.preview()'))?.t);
check(
'the preview stands still at first, the Preview button runs it, and pressed again holds it',
!previewButton.toggled &&
new Set(idle).size === 1 &&
runningButton.toggled &&
new Set(running).size > 1 &&
!(await json('__gm.curve.previewButton()')).toggled &&
held[0] === held[1],
JSON.stringify({ previewButton, idle, running, held }),
);
// The preview's dot sits on the line at its t, and its twin beside the graph at the same height.
await evaluate('__gm.curve.setPreviewTime(0.35)');
const preview = await json('__gm.curve.preview()');
const onLine = await json(`__gm.curve.at(${preview?.t}, ${expectedAt(bent, preview?.t ?? 0)})`);
check(
'the preview dot runs along the drawn curve, and its twin beside the graph at the same height',
Math.abs(preview?.t - 0.35) < 1e-9 &&
Math.abs(preview.v - expectedAt(bent, 0.35)) < 0.002 &&
Math.abs(preview.dot.x - onLine.x) < 1 &&
Math.abs(preview.dot.y - onLine.y) < 1 &&
Math.abs(preview.bar.y - preview.dot.y) < 1e-6 &&
preview.bar.x > preview.dot.x,
JSON.stringify({ preview, onLine }),
);
// 53d. An animation key picks the curve in the inspector, and the preview draws the value it gives.
await mutate(`
const p = __gm.state.project;
const sprite = p.assets.find((a) => a.type === 'sprite');
p.assets.push({ id: 'cv-anim', type: 'animation', name: 'Curve slide', props: { fps: 10, playback: 'once' },
root: sprite.id,
tracks: [{ id: 'cv-row', target: '', property: 'x', keys: [{ frame: 0, value: 0 }, { frame: 10, value: 100 }] }] });`);
await evaluate("__gm.openDocument('asset:cv-anim')");
await settle();
const cell = await json("__gm.animation.cellAt('cv-row', 0)");
await click(cell.x, cell.y);
await settle();
const easing = await json("__gm.inspectorField('Easing')");
if (easing) {
await click(easing.x, easing.y);
await sleep(100);
const curveEntry = await menuRow('Curve');
if (curveEntry) await click(curveEntry.x, curveEntry.y);
await settle();
}
const key = async () =>
(await json("__gm.state.project.assets.find((a) => a.id === 'cv-anim').tracks[0].keys[0]"))
?.interpolation;
const curveRow = await json("__gm.inspectorField('Curve', true)");
check(
'Easing: Curve on the key takes the one curve there is and shows it in a Curve row',
(await key()) === `curve:${curveId}` && Boolean(curveRow),
JSON.stringify({ interpolation: await key(), curveRow, easing }),
);
const rootId = await json("__gm.animation.preview.objectId('')");
const drawnAt = async (frame) => {
await evaluate(`__gm.animation.scrub(${frame})`);
await sleep(100);
return (await json(`__gm.animation.preview.item(${JSON.stringify(rootId)})`))?.x;
};
const drawn = [await drawnAt(3), await drawnAt(5), await drawnAt(8)];
const wanted = [0.3, 0.5, 0.8].map((share) => 100 * expectedAt(bent, share));
check(
'the preview puts the sprite where the curve says, not where a straight line would',
drawn.every((x, i) => Math.abs(x - wanted[i]) < 0.05) && Math.abs(drawn[1] - 50) > 5,
JSON.stringify({ drawn, wanted }),
);
await shot('53-curve-key');
// 53e. A Lerp picks it too – Ease: Curve, then the curve in its row – and the game logs its value.
await mutate(`
const p = __gm.state.project;
p.assets.push({ id: 'cv-lerpy', type: 'behavior', name: 'Curve lerp', props: { enabled: true, runInEditor: false },
variables: [], graph: ${JSON.stringify({
nodes: [
node('cv-start', 'OnStart', 0, 0),
node('cv-lerp', 'Lerp', 0, 160, { a: 0, b: 100, t: 0.3 }),
node('cv-log', 'Log', 300, 0),
],
edges: [
edge('cv-e1', 'cv-start', 'exec', 'cv-log', 'exec'),
edge('cv-e2', 'cv-lerp', 'result', 'cv-log', 'message'),
],
})} });
const scene = __gm.state.activeScene;
const holder = structuredClone(scene.objects[0]);
Object.assign(holder, { id: 'cv-holder', assetId: 'cv-lerpy', parentId: '', name: 'Curve holder',
position: { x: 0, y: 0, z: 0 }, overrides: {}, instanceEdits: {} });
delete holder.groupId;
scene.objects.push(holder);
__gm.state.select({ kind: 'node', assetId: 'cv-lerpy', nodeId: 'cv-lerp', nodeIds: ['cv-lerp'], annotationIds: [] });`);
await showTab('graph');
await settle();
const pick = async (field, label) => {
const at = await json(`__gm.inspectorField(${JSON.stringify(field)}, true)`);
if (!at) return null;
await click(at.x, at.y);
await sleep(100);
const option = await menuRow(label);
if (option) await click(option.x, option.y);
else await t.press('Escape');
await settle();
return option;
};
await pick('Ease', 'Curve');
const unpicked = await json("__gm.graph.nodeSettings('cv-lerp')");
const name = await json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(curveId)}).name`);
const picked = await pick('Curve', `${name} (curve)`);
const params = await json(
"__gm.state.project.assets.find((a) => a.id === 'cv-lerpy').graph.nodes.find((n) => n.id === 'cv-lerp').params",
);
const settings = await json("__gm.graph.nodeSettings('cv-lerp')");
check(
'Ease: Curve brings the Curve row onto the node, and picking the curve there sets it',
params?.ease === 'curve' &&
params.curve === curveId &&
Boolean(picked) &&
unpicked?.length === 2 &&
settings?.some((s) => s.includes(name)),
JSON.stringify({ params, unpicked, settings, picked }),
);
await shot('53-curve-lerp');
const logFrom = t.consoleLog.length;
await t.play();
await sleep(600);
await t.stop();
const logged = t.consoleLog
.slice(logFrom)
.filter((line) => line.startsWith('[behavior Curve holder]'))
.map((line) => Number(line.split(']').pop()));
const lerpWanted = 100 * expectedAt(bent, 0.3);
check(
'the running Lerp gives what the curve says at t 0.3',
logged.length === 1 && Math.abs(logged[0] - lerpWanted) < 0.05 && Math.abs(logged[0] - 30) > 1,
JSON.stringify({ logged, lerpWanted }),
);
// 53f. Undo takes the pick back to the plain Lerp; then everything made here goes.
await showTab('graph');
await undo();
await settle();
const undone = await json(
"__gm.state.project.assets.find((a) => a.id === 'cv-lerpy').graph.nodes.find((n) => n.id === 'cv-lerp').params",
);
check('undo takes the picked curve back off the Lerp', !undone?.curve, JSON.stringify(undone));
// 53g. A preset starts the curve over from a built-in ease, and the key follows it at once.
await evaluate(`__gm.openDocument('asset:${curveId}')`);
await settle();
const presetButton = await json('__gm.curve.presetButton()');
await click(presetButton.x, presetButton.y);
await sleep(100);
const presets = (await json('__gm.menuRows()')) ?? [];
// Thirty-one entries run past the bottom of the popup; its search box is the way to the last ones.
await t.typeText('bounce out');
await t.press('Enter');
await settle();
const bounced = await pointsOf(curveId);
await evaluate("__gm.openDocument('asset:cv-anim')");
await settle();
const bouncedAt = await drawnAt(5);
check(
'Preset → Bounce out replaces the curve with the traced ease, and the key follows it (76.6 at the middle)',
presets.filter((r) => typeof r === 'string').length === 31 &&
bounced?.length > 8 &&
Math.abs(bouncedAt - 76.5625) < 0.4,
JSON.stringify({ presets: presets.length, points: bounced?.length, bouncedAt }),
);
await shot('53-curve-preset');
await headerAndScrub(t, curveId, bounced);
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => !o.id.startsWith('cv-'));
p.assets = p.assets.filter((a) => !a.id.startsWith('cv-') && a.id !== ${JSON.stringify(curveId)});
__gm.state.select(null)`);
await showTab('scene');
}
/**
* 53h–53k on the Bounce out curve the preset left: the picked point's `t` and `v` fields, the plain
* arrow over points, the magnet, and the preview dragged by its knob and by its dot beside the graph.
*/
async function headerAndScrub(t, curveId, bounced) {
const { check, json, evaluate, click, doubleClick, drag, press, settle, sleep, shot, undo } = t;
const pointsOf = () => json(`__gm.curve.points(${JSON.stringify(curveId)})`);
const at = (tt, v) => json(`__gm.curve.at(${tt}, ${v})`);
const onGrid = (n) => Math.abs(n / 0.05 - Math.round(n / 0.05)) < 1e-6;
await evaluate(`__gm.openDocument('asset:${curveId}')`);
await settle();
const fit = await json('__gm.curve.fitAt()');
await click(fit.x, fit.y);
await settle();
// Somewhere left of t 0: no point goes there, so a click only drops the pick.
const aside = await at(-0.08, 0.5);
// 53h. A picked point shows its t and v in the header; typed in, it goes there, one undo step each.
const target = bounced[2];
const spot = await at(target.x, target.y);
await click(spot.x, spot.y);
await settle();
const shownT = await json("__gm.curve.placeField('t')");
const shownV = await json("__gm.curve.placeField('v')");
const typeInto = async (axis, text) => {
const field = await json(`__gm.curve.placeField(${JSON.stringify(axis)})`);
await click(field.x, field.y);
await t.typeText(text);
await t.press('Enter');
await settle();
};
const middle = ((bounced[1].x + bounced[3].x) / 2).toFixed(3);
await typeInto('t', middle);
await typeInto('v', '0.42');
const typed = await pointsOf();
await click(aside.x, aside.y);
await undo();
const oneBack = await pointsOf();
await undo();
const twoBack = await pointsOf();
check(
'the t and v fields show the picked point, move it when typed into, and each is one undo step',
(await json('__gm.curve.picked()')) === -1 &&
shownT.enabled &&
Math.abs(Number(shownT.text) - target.x) < 0.001 &&
Math.abs(Number(shownV.text) - target.y) < 0.001 &&
typed?.length === bounced.length &&
typed[2].x === Number(middle) &&
typed[2].y === 0.42 &&
oneBack[2].x === Number(middle) &&
oneBack[2].y === target.y &&
JSON.stringify(twoBack) === JSON.stringify(bounced),
JSON.stringify({
shownT,
shownV,
middle,
typed: typed?.[2],
oneBack: oneBack?.[2],
twoBack: twoBack?.[2],
}),
);
// 53i. An end keeps its t: the field is off, its v is not. Over a point the arrow stays plain.
const start = await at(0, bounced[0].y);
await click(start.x, start.y);
await settle();
const endT = await json("__gm.curve.placeField('t')");
const endV = await json("__gm.curve.placeField('v')");
await t.mouse('mouseMoved', start.x, start.y);
await settle();
const overPoint = await json('__gm.curve.cursor()');
const free = await at(0.5, 0.2);
await t.mouse('mouseMoved', free.x, free.y);
await settle();
const overSpace = await json('__gm.curve.cursor()');
check(
"an end's t field is off and its v field on; over a point the cursor stays the plain arrow",
!endT.enabled && endV.enabled && overPoint === 'default' && overSpace === 'crosshair',
JSON.stringify({ endT, endV, overPoint, overSpace }),
);
// 53j. With the magnet on, a point put down and then dragged lands on the 0.05 grid in t and v.
const magnet = await json('__gm.curve.snapButton()');
await click(magnet.x, magnet.y);
await settle();
const put = await at(0.337, 0.412);
await sleep(500);
await doubleClick(put.x, put.y);
await settle();
const index = await json('__gm.curve.picked()');
const dropped = (await pointsOf())?.[index];
await drag(put.x, put.y, put.x + 3, put.y - 37, { steps: 8 });
const dragged = (await pointsOf())?.[index];
const magnetOn = await json('__gm.curve.snapButton()');
await click(magnet.x, magnet.y);
await click(aside.x, aside.y);
await undo();
await undo();
const restored = await pointsOf();
check(
'with the magnet on a point is put and dragged onto the 0.05 grid of t and v',
!magnet.toggled &&
magnetOn.toggled &&
!(await json('__gm.curve.snapButton()')).toggled &&
Math.abs(dropped?.x - 0.35) < 1e-6 &&
Math.abs(dropped?.y - 0.4) < 1e-6 &&
onGrid(dragged?.x) &&
onGrid(dragged?.y) &&
dragged.y > dropped.y + 0.04 &&
JSON.stringify(restored) === JSON.stringify(bounced),
JSON.stringify({ magnet, dropped, dragged, restored: restored?.length }),
);
// 53j-ii. Taking a point away (#288): Delete takes the picked one, and a right-click on a point
// opens its menu – Delete point and its type; an end's Delete point is greyed out.
const between = (bounced[0].x + bounced[1].x) / 2;
const onLine = await at(between, expectedAt(bounced, between));
await sleep(500);
await doubleClick(onLine.x, onLine.y);
const added = await pointsOf();
const pickedNew = await json('__gm.curve.picked()');
await press('Delete');
const removed = await pointsOf();
const inner = await at(bounced[2].x, bounced[2].y);
await click(inner.x, inner.y, 'right');
const pointMenu = await json('__gm.menuRows()');
const innerOn = await json('__gm.menuEnabled()');
const pickedByMenu = await json('__gm.curve.picked()');
await press('Escape');
const endAt = await at(0, bounced[0].y);
await click(endAt.x, endAt.y, 'right');
const endOn = await json('__gm.menuEnabled()');
await press('Escape');
await click(inner.x, inner.y, 'right');
await t.clickText('Delete point');
const byMenu = await pointsOf();
await undo();
await undo();
await undo();
await click(aside.x, aside.y);
check(
'a double-click on the line adds a picked point, Delete and the menu of a point take one away, never an end',
added?.length === bounced.length + 1 &&
pickedNew === 1 &&
removed?.length === bounced.length &&
JSON.stringify(pointMenu) ===
JSON.stringify(['Delete point', null, 'Straight', 'Corner', 'Smooth', 'Symmetric']) &&
innerOn?.[0] === true &&
pickedByMenu === 2 &&
endOn?.[0] === false &&
byMenu?.length === bounced.length - 1 &&
JSON.stringify(await pointsOf()) === JSON.stringify(bounced),
JSON.stringify({
added: added?.length,
pickedNew,
removed: removed?.length,
pointMenu,
innerOn,
pickedByMenu,
endOn,
byMenu: byMenu?.length,
}),
);
// 53k. Held, the preview moves only when dragged: its knob sideways sets t, its dot beside the
// graph up or down jumps to the first t the curve reaches that height. The curve stays.
const before = await json('__gm.curve.preview()');
const seventy = await at(0.7, 0);
await drag(before.knob.x, before.knob.y, seventy.x, before.knob.y, { steps: 10 });
const scrubbed = await json('__gm.curve.preview()');
const half = await at(0, 0.5);
await drag(scrubbed.bar.x, scrubbed.bar.y, scrubbed.bar.x, half.y, { steps: 10 });
const jumped = await json('__gm.curve.preview()');
await shot('53-curve-scrub');
const earlier = expectedAt(bounced, Math.max(jumped.t - 0.02, 0));
check(
'dragging the knob puts the preview at that t, dragging its twin jumps to where the curve first reaches that value',
!(await json('__gm.curve.previewButton()')).toggled &&
Math.abs(scrubbed.t - 0.7) < 0.01 &&
Math.abs(scrubbed.v - expectedAt(bounced, scrubbed.t)) < 0.002 &&
Math.abs(jumped.v - 0.5) < 0.01 &&
earlier < 0.5 &&
jumped.t < scrubbed.t &&
JSON.stringify(await pointsOf()) === JSON.stringify(bounced),
JSON.stringify({ before, scrubbed, jumped, earlier }),
);
}