Constant, ramp, and return offsets now append ordinary channel layers to a lane or cel in explicit owner frames. The inspector exposes the commands as one undoable transaction, shows conflicts, and offers removal or retry while preserving generated bases through regeneration. Ordered-stack compatibility is shared by validation, conflict reporting, and regeneration, including adjacent replacement coverage. Cel-sheet gaps and headers select their lane, so commands cannot fall through to another column's stale selection. 437 tests, 5,804 assertions; both browser flows; 56 Django tests; optimized frontend build.
319 lines
15 KiB
JavaScript
319 lines
15 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.laneSnapshot = () => {
|
|
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);
|
|
// A command is named the same wherever it is drawn, and since the transport
|
|
// strip was consolidated it is drawn in one of two places: as a button in the
|
|
// strip, or as a row in one of the strip's menus. So the test asks for it by
|
|
// name and this finds it — opening each menu in turn to look — rather than the
|
|
// test knowing which menu anything ended up in. An icon button is matched on
|
|
// its `aria-label`, which is also what a screen reader is told it is.
|
|
// Two bars carry commands: the location bar says where an edit lands and holds
|
|
// what creates things there, the transport strip holds what acts on a cel.
|
|
const bars = ['.loc', '.pane.time .pane-head'];
|
|
const within = (suffix) => bars.map((b) => `${b} ${suffix}`).join(', ');
|
|
const named = label =>
|
|
`(b => b.textContent.trim() === ${JSON.stringify(label)}` +
|
|
` || b.getAttribute('aria-label') === ${JSON.stringify(label)})`;
|
|
const shut = async () => {
|
|
await evaluate(`(() => { document.querySelectorAll('.menu-scrim').forEach(s => s.click()); return true })()`);
|
|
await sleep(120);
|
|
};
|
|
// Leaves the control on screen and returns what to select it with.
|
|
const reveal = async label => {
|
|
await shut();
|
|
if (await evaluate(`![...document.querySelectorAll('${within('button')}')].find(${named(label)})`)) {
|
|
const menus = await evaluate(
|
|
`[...document.querySelectorAll('${within('.menu-wrap > button')}')].map(b => b.textContent.trim())`);
|
|
let found = false;
|
|
for (const menu of menus) {
|
|
await evaluate(`(() => { [...document.querySelectorAll('${within('.menu-wrap > button')}')]
|
|
.find(b => b.textContent.trim() === ${JSON.stringify(menu)}).click(); return true })()`);
|
|
await sleep(180);
|
|
if (await evaluate(`!![...document.querySelectorAll('.menu-item')].find(${named(label)})`)) { found = true; break; }
|
|
await shut();
|
|
}
|
|
assert(found, `a control named: ${label}`);
|
|
return '.menu-item';
|
|
}
|
|
return within('button');
|
|
};
|
|
const click = async label => {
|
|
const where = await reveal(label);
|
|
assert(await evaluate(`(() => {
|
|
const b = [...document.querySelectorAll('${where}')].find(${named(label)});
|
|
if (!b || b.disabled) return false;
|
|
b.click(); return true;
|
|
})()`), `enabled control: ${label}`);
|
|
await sleep(180);
|
|
await shut();
|
|
};
|
|
const shot = async () => (await evaluate('laneSnapshot()'));
|
|
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 cel');
|
|
|
|
// 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 cel, ripple, and shot length');
|
|
|
|
// Sharing: one drawing exposed twice, then one cel 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 => {
|
|
const where = await reveal(label);
|
|
const yes = await evaluate(`(() => {
|
|
const b = [...document.querySelectorAll('${where}')].find(${named(label)});
|
|
return !!b && !b.disabled;
|
|
})()`);
|
|
await shut();
|
|
return yes;
|
|
};
|
|
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 cels, 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 cel 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 cels 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 a cel 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 cel 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 cels, 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
|
|
// cel, 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 cel 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);
|
|
|
|
// The same cels with the axes turned. Selecting a sheet cell feeds the same
|
|
// action strip and therefore the same domain command and undo transaction.
|
|
await click('cel sheet');
|
|
assert.equal(await evaluate('document.querySelectorAll(".cs-head:not(.cs-frame)").length'), 1,
|
|
'one lane is one cel-sheet column');
|
|
assert.equal(await evaluate('document.querySelectorAll(".cs-cell").length'), 20,
|
|
'one cell per authored frame');
|
|
await evaluate(`document.querySelector('.cs-cell').click()`);
|
|
await sleep(180);
|
|
await click('hold +');
|
|
s = await shot();
|
|
assert.deepEqual(placed(s), [[0, 3], [3, 4], [6, 7], [7, 8], [8, 9]],
|
|
'a command selected in the sheet has the timeline command semantics');
|
|
assert.equal(s.history.done.length, before.history.done.length + 9);
|
|
|
|
// Correction authoring is reachable from the same selection. The range is
|
|
// in this cel's own frames and Apply is one isolated history transaction.
|
|
assert(await evaluate(`(() => {
|
|
const section = [...document.querySelectorAll('.section')]
|
|
.find(s => s.querySelector('h2')?.textContent.trim() === 'corrections');
|
|
const label = [...section.querySelectorAll('label')]
|
|
.find(l => l.textContent.trim().startsWith('offset (degrees)'));
|
|
const input = label?.querySelector('input');
|
|
if (!input) return false;
|
|
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
|
|
set.call(input, '15');
|
|
input.dispatchEvent(new Event('input', {bubbles: true}));
|
|
input.dispatchEvent(new Event('change', {bubbles: true}));
|
|
return true;
|
|
})()`), 'rotation correction value is editable');
|
|
await sleep(100);
|
|
await click('apply correction');
|
|
s = await shot();
|
|
const selectedCel = instances(s).find(n => n.time.at === 0);
|
|
const rot = Object.values(selectedCel.channels).find(c => c.over?.length);
|
|
assert.equal(rot.over.length, 1);
|
|
assert(Math.abs(rot.over[0].values.value - Math.PI / 12) < 1e-9,
|
|
'the inspector converts the authored degree offset to radians');
|
|
assert.equal(s.history.done.length, before.history.done.length + 10);
|
|
|
|
// A gap targets its column's lane. This catches the stale-selection bug that
|
|
// only appears once a sheet has more than one lane.
|
|
await click('timeline');
|
|
assert.equal(await evaluate('document.querySelectorAll(".tl-cel").length'), 5);
|
|
await click('+ lane');
|
|
await click('cel sheet');
|
|
assert.equal(await evaluate('document.querySelectorAll(".cs-head:not(.cs-frame)").length'), 2);
|
|
await evaluate(`([...document.querySelectorAll('.cs-cell')].slice(0, 2)
|
|
.find(c => c.textContent.trim() !== '—')).click()`);
|
|
await sleep(100);
|
|
await evaluate(`([...document.querySelectorAll('.cs-cell')].slice(0, 2)
|
|
.find(c => c.textContent.trim() === '—')).click()`);
|
|
await sleep(100);
|
|
await click('overwrite');
|
|
s = await shot();
|
|
assert(Object.values(s.clip.symbols.main.nodes)
|
|
.some(n => n.kind === 'instance' && n.parent !== 'girl' && n.time.at === 0),
|
|
'clicking a gap selects that column before overwrite');
|
|
assert.equal(s.history.done.length, before.history.done.length + 12,
|
|
'correction, lane creation, and overwrite are separate undo steps');
|
|
assert.equal(errors.length, 0, JSON.stringify(errors));
|
|
console.log('PASS: lane commands and corrections agree from timeline and cel sheet; 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}`);
|
|
}
|
|
}
|