arthur/frontend/test/browser/sequence.mjs
Your Name 76106d36ee The shot is as long as somebody said it was
Trim, move and blank, and the decision they all three walked into: is the shot's
length authored, or derived from what is in it?

AUTHORED. `:frames` is the symbol's window — how long the shot IS — and the
occupied extent of its lanes is a different fact, read off the occurrences. A
command grows the window when the caller says `:grow-symbol` and NEVER shrinks
it, so blanking the end of a shot leaves a shot with empty frames at the end.
That is a true statement about what somebody authored, and the alternative is
deleting the last drawing and quietly shortening the film. `finish` had the
right behaviour by accident — `(apply max (:frames sym) ...)` — and now says
which number is which: `needed` is where the occurrences reach, `:frames` is
what was authored, and the only thing that makes the second follow the first is
a caller asking.

The three commands turned out to be one piece of geometry, which is `split`'s.
A `:span` is in the occurrence's OWN frames and `:time` says where those land in
the lane, so moving an edge of an exposure is ONE WRITE to `:span` and `:time`
and `:playback` are never touched. `local` and `edged` are the whole of it, and
split now goes through them too.

  trim   narrows one edge and moves nothing else. Lengthening is `extend-hold`,
         which carries a ripple policy and a shot-length policy because it needs
         them; letting trim grow as well would give one gesture two sets of
         rules and a way to overlap its neighbour.
  move   one write to `:time :at`, and a destination that would overlap is
         REFUSED rather than rippled. Moving a drawing and re-timing the ones
         around it are different intentions, and a move that pushed the rest
         would be the second wearing the first one's name. Clear the room first.
  blank  leaves a gap and does not close it. Wholly inside the range goes,
         overlapping an end is trimmed to it, spanning the range is split — the
         one case that needs an ID, and it asks for one instead of inventing it.

Because the source clock is untouched, trimming the front of a playing insert
starts it LATER INTO its animation rather than restarting it, which is the
difference between trimming and slipping and the reason they stay two commands.
The test samples the frames it kept and asserts they show what they showed.

Blanking leaves the drawings in the library. A lane does not own its content,
and a drawing whose last exposure is gone is still a drawing somebody made.

Overwrite is now `blank` then `place` and needs no policy argument of its own,
which is why it still is not one.

424 tests, 5,749 assertions. The browser flow trims an exposure at the playhead,
moves it into the gap that made, blanks it, and checks the shot is still as long
as it was authored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 16:32:59 -04:00

215 lines
10 KiB
JavaScript

// Local editor smoke test. Uses the in-memory blank document and disables the
// project route, so it never creates an account, project, or server-side write.
import { spawn } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import assert from 'node:assert/strict';
const url = process.env.ARTHUR_URL ?? 'http://localhost:8778/';
const profile = mkdtempSync(join(tmpdir(), 'arthur-sequence-'));
const port = 9335;
const chrome = spawn(process.env.CHROME ?? '/usr/bin/chromium', [
'--headless=new', '--no-sandbox', '--disable-gpu', '--no-first-run',
'--no-default-browser-check', '--mute-audio', '--window-size=1440,1000',
`--user-data-dir=${profile}`, `--remote-debugging-port=${port}`, url,
], { stdio: 'ignore' });
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
let ws;
try {
let target;
for (let i = 0; i < 100 && !target; i++) {
await sleep(100);
try {
target = (await fetch(`http://127.0.0.1:${port}/json/list`).then(r => r.json()))
.find(t => t.type === 'page' && t.url.startsWith(url));
} catch { /* browser starting */ }
}
assert(target, 'browser exposes the editor page');
ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; });
let serial = 0;
const pending = new Map();
const errors = [];
ws.onmessage = ({ data }) => {
const msg = JSON.parse(data);
if (msg.method === 'Runtime.exceptionThrown') errors.push(msg.params.exceptionDetails);
if (msg.id && pending.has(msg.id)) {
const { resolve, reject } = pending.get(msg.id);
pending.delete(msg.id);
if (msg.error) reject(new Error(JSON.stringify(msg.error)));
else resolve(msg.result);
}
};
const send = (method, params = {}) => new Promise((resolve, reject) => {
const id = ++serial;
pending.set(id, { resolve, reject });
ws.send(JSON.stringify({ id, method, params }));
});
const evaluate = async expression => {
const r = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
if (r.exceptionDetails) throw new Error(JSON.stringify(r.exceptionDetails));
return r.result.value;
};
await send('Runtime.enable');
for (let i = 0; i < 100; i++) {
if (await evaluate('typeof arthur !== "undefined" && !!arthur.events?.ui && !!document.querySelector("canvas.stage")')) break;
await sleep(100);
}
await evaluate(`(() => {
const k = cljs.core.keyword;
cljs.core.swap_BANG_(re_frame.db.app_db, db => cljs.core.assoc(db, k('route'), k('local-test')));
window.sequenceSnapshot = () => {
const db = cljs.core.deref(re_frame.db.app_db);
const entry = arthur.footage.store.entry(cljs.core.get(db, k('clip/current')));
return cljs.core.clj__GT_js(entry);
};
return true;
})()`);
await sleep(250);
const click = async label => {
assert(await evaluate(`(() => {
const b = [...document.querySelectorAll('button')].find(b => b.textContent.trim() === ${JSON.stringify(label)});
if (!b || b.disabled) return false;
b.click(); return true;
})()`), `enabled button: ${label}`);
await sleep(180);
};
const shot = async () => (await evaluate('sequenceSnapshot()'));
const instances = s => Object.values(s.clip.symbols.main.nodes).filter(n => n.kind === 'instance')
.sort((a, b) => a.time.at - b.time.at);
await click('+ lane');
await click('new drawing');
await click('hold +');
await click('hold +');
await click('hold +');
await click('new drawing');
let s = await shot();
assert.deepEqual(instances(s).map(n => [n.time.at, n.span[1]]), [[0, 4], [4, 1]]);
assert.equal(await evaluate('document.querySelectorAll(".tl-cel").length'), 2);
assert.equal(await evaluate('document.querySelectorAll(".tl-label:not(.tl-corner)").length'), 1);
assert.equal(await evaluate(`cljs.core.get_in(cljs.core.deref(re_frame.db.app_db),
cljs.core.vector(cljs.core.keyword('playback'), cljs.core.keyword('frame')))`), 4,
'new drawing seeks to its occurrence');
// Shorten this test shot to the occupied extent, purely in memory.
await evaluate(`(() => {
const k = cljs.core.keyword, db = cljs.core.deref(re_frame.db.app_db);
arthur.footage.store.edit_clip_BANG_(cljs.core.get(db, k('clip/current')),
clip => cljs.core.assoc_in(clip, cljs.core.vector(k('symbols'), k('main'), k('frames')), 5));
document.querySelector('.tl-cel').click();
})()`);
await sleep(200);
const before = await shot();
await click('hold +');
s = await shot();
assert.deepEqual(s.clip, before.clip, 'refused overflow makes no document change');
assert.equal(s.history.done.length, before.history.done.length);
await click('extend shot and apply');
s = await shot();
assert.equal(s.clip.symbols.main.frames, 6);
assert.deepEqual(instances(s).map(n => [n.time.at, n.span[1]]), [[0, 5], [5, 1]]);
assert.equal(s.history.done.length, before.history.done.length + 1);
await evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', {key:'z', ctrlKey:true, bubbles:true}))`);
await sleep(250);
assert.deepEqual((await shot()).clip, before.clip, 'one undo restores exposure, ripple, and shot length');
// Sharing: one drawing exposed twice, then one exposure decoupled. Room is
// made first so these assertions are about content and not about overflow.
await evaluate(`(() => {
const k = cljs.core.keyword, db = cljs.core.deref(re_frame.db.app_db);
arthur.footage.store.edit_clip_BANG_(cljs.core.get(db, k('clip/current')),
clip => cljs.core.assoc_in(clip, cljs.core.vector(k('symbols'), k('main'), k('frames')), 20));
document.querySelector('.tl-cel').click();
})()`);
await sleep(200);
const enabled = async label => await evaluate(`(() => {
const b = [...document.querySelectorAll('button')].find(b => b.textContent.trim() === ${JSON.stringify(label)});
return !!b && !b.disabled;
})()`);
assert.equal(await enabled('make unique'), false, 'nothing to decouple from yet');
await click('reuse');
s = await shot();
let cels = instances(s);
assert.equal(cels.length, 3);
assert.equal(cels[2].source.symbol, cels[0].source.symbol, 'reuse exposes the same drawing');
assert.equal(await evaluate('document.querySelectorAll(".tl-cel").length'), 3);
assert.equal(await evaluate('document.querySelectorAll(".tl-label:not(.tl-corner)").length'), 1,
'three exposures, still one row');
assert.equal(await enabled('make unique'), true);
await click('make unique');
s = await shot();
cels = instances(s);
assert.notEqual(cels[2].source.symbol, cels[0].source.symbol, 'that exposure has its own drawing');
assert.equal(await enabled('make unique'), false, 'and is not shared any more');
await click('duplicate');
s = await shot();
cels = instances(s);
assert.equal(cels.length, 4);
assert.equal(new Set(cels.map(n => n.source.symbol)).size, 4,
'four exposures of four drawings: nothing is shared once every copy is made');
assert.equal(s.history.done.length, before.history.done.length + 3, 'three more commands, three more steps');
// A drawing into the middle of a hold: split, then insert. Both act at the
// playhead, and neither guesses what the other one is for.
const placed = s => instances(s)
.map(n => [n.time.at + n.span[0] / (n.time.rate ?? 1), n.time.at + n.span[1] / (n.time.rate ?? 1)])
.sort((a, b) => a[0] - b[0]);
assert.deepEqual(placed(s), [[0, 4], [4, 5], [5, 6], [6, 7]]);
await evaluate(`document.querySelector('.tl-cel').click()`);
await sleep(200);
assert.equal(await enabled('split'), false, 'the start of an exposure is not inside it');
await click('+1');
await click('+1');
assert.equal(await enabled('split'), true);
await click('split');
s = await shot();
assert.deepEqual(placed(s), [[0, 2], [2, 4], [4, 5], [5, 6], [6, 7]],
'one exposure became two, over the frames it had');
await click('insert');
s = await shot();
assert.deepEqual(placed(s), [[0, 2], [2, 3], [3, 5], [5, 6], [6, 7], [7, 8]],
'the new drawing took frame 2 and everything from there rippled later');
assert.equal(await evaluate('document.querySelectorAll(".tl-cel").length'), 6);
assert.equal(await evaluate('document.querySelectorAll(".tl-label:not(.tl-corner)").length'), 1,
'six exposures, still one row');
assert.equal(s.history.done.length, before.history.done.length + 5);
// Trim, move and blank: three gestures that move nothing but their own
// exposure, and a shot whose length does not follow what is in it.
await evaluate(`[...document.querySelectorAll('.tl-cel')][2].click()`);
await sleep(200);
assert.deepEqual(placed(await shot()).slice(2, 4), [[3, 5], [5, 6]]);
await click('+1');
assert.equal(await enabled('trim out'), true, 'the playhead is inside it');
await click('trim out');
s = await shot();
assert.deepEqual(placed(s), [[0, 2], [2, 3], [3, 4], [5, 6], [6, 7], [7, 8]],
'it ends at the playhead and every other exposure stayed');
assert.equal(await enabled('move here'), true);
await click('move here');
s = await shot();
assert.deepEqual(placed(s), [[0, 2], [2, 3], [4, 5], [5, 6], [6, 7], [7, 8]],
'and moves to the playhead, into the gap it just made');
await click('blank');
s = await shot();
assert.deepEqual(placed(s), [[0, 2], [2, 3], [5, 6], [6, 7], [7, 8]],
'blanked: a gap where it was, and nothing closed it');
assert.equal(s.clip.symbols.main.frames, 20, 'the shot is as long as it was authored');
assert.equal(s.history.done.length, before.history.done.length + 8);
assert.equal(errors.length, 0, JSON.stringify(errors));
console.log('PASS: create lane/drawings, one-row cels, hold ripple, seek, explicit overflow, atomic undo, reuse/make unique/duplicate, split, insert, trim, move and blank; no server writes');
} finally {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ id: 999999, method: 'Browser.close' }));
await sleep(350);
}
ws?.close();
chrome.kill();
await new Promise(resolve => { if (chrome.exitCode !== null || chrome.signalCode !== null) resolve(); else chrome.once('exit', resolve); });
try {
rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch (error) {
console.warn(`Temporary browser profile retained at ${profile}: ${error.code}`);
}
}