Make lanes explicit symbol views
This commit is contained in:
parent
fb38990090
commit
e459307a4a
22 changed files with 2183 additions and 2330 deletions
|
|
@ -1,5 +1,5 @@
|
|||
// 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.
|
||||
// Browser smoke test for the explicit-lane workflow. It uses the in-memory
|
||||
// document and disables project routing, so it performs no server-side write.
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
|
@ -7,7 +7,7 @@ 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 profile = mkdtempSync(join(tmpdir(), 'arthur-explicit-lane-'));
|
||||
const port = 9335;
|
||||
const chrome = spawn(process.env.CHROME ?? '/usr/bin/chromium', [
|
||||
'--headless=new', '--no-sandbox', '--disable-gpu', '--no-first-run',
|
||||
|
|
@ -16,6 +16,7 @@ const chrome = spawn(process.env.CHROME ?? '/usr/bin/chromium', [
|
|||
], { stdio: 'ignore' });
|
||||
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
let ws;
|
||||
|
||||
try {
|
||||
let target;
|
||||
for (let i = 0; i < 100 && !target; i++) {
|
||||
|
|
@ -23,11 +24,12 @@ try {
|
|||
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 */ }
|
||||
} catch { /* Chromium is still 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 = [];
|
||||
|
|
@ -35,10 +37,10 @@ try {
|
|||
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);
|
||||
const waiting = pending.get(msg.id);
|
||||
pending.delete(msg.id);
|
||||
if (msg.error) reject(new Error(JSON.stringify(msg.error)));
|
||||
else resolve(msg.result);
|
||||
if (msg.error) waiting.reject(new Error(JSON.stringify(msg.error)));
|
||||
else waiting.resolve(msg.result);
|
||||
}
|
||||
};
|
||||
const send = (method, params = {}) => new Promise((resolve, reject) => {
|
||||
|
|
@ -47,13 +49,16 @@ try {
|
|||
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;
|
||||
const result = await send('Runtime.evaluate', {
|
||||
expression, returnByValue: true, awaitPromise: true,
|
||||
});
|
||||
if (result.exceptionDetails) throw new Error(JSON.stringify(result.exceptionDetails));
|
||||
return result.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;
|
||||
if (await evaluate('typeof arthur !== "undefined" && !!document.querySelector("canvas.stage")')) break;
|
||||
await sleep(100);
|
||||
}
|
||||
await evaluate(`(() => {
|
||||
|
|
@ -61,342 +66,145 @@ try {
|
|||
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 cljs.core.clj__GT_js(arthur.footage.store.entry(cljs.core.get(db, k('clip/current'))));
|
||||
};
|
||||
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', '.section'];
|
||||
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);
|
||||
|
||||
const named = label => `(b => b.getAttribute('aria-label') === ${JSON.stringify(label)}` +
|
||||
` || b.textContent.trim() === ${JSON.stringify(label)})`;
|
||||
const closeMenus = async () => {
|
||||
await evaluate(`(() => { document.querySelectorAll('.menu-scrim').forEach(x => x.click()); return true })()`);
|
||||
await sleep(80);
|
||||
};
|
||||
// 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);
|
||||
const clickNew = async label => {
|
||||
await closeMenus();
|
||||
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 menu = document.querySelector('.loc .menu-wrap > button');
|
||||
if (!menu) return false;
|
||||
menu.click(); return true;
|
||||
})()`), 'new menu exists');
|
||||
await sleep(100);
|
||||
assert(await evaluate(`(() => {
|
||||
const item = [...document.querySelectorAll('.menu-item')].find(${named(label)});
|
||||
if (!item || item.disabled) return false;
|
||||
item.click(); return true;
|
||||
})()`), `enabled creation command: ${label}`);
|
||||
await sleep(220);
|
||||
await closeMenus();
|
||||
};
|
||||
// UUIDs expose a mutable hash cache through clj->js; compare their identity,
|
||||
// not that implementation detail, when asserting exact undo restoration.
|
||||
const shot = async () => JSON.parse(JSON.stringify(await evaluate('laneSnapshot()'),
|
||||
(_key, value) => value?.uuid ?? value));
|
||||
const instances = s => Object.values(s.clip.symbols.main.nodes)
|
||||
.filter(n => n.kind === 'instance')
|
||||
.sort((a, b) => a.time.at - b.time.at);
|
||||
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),
|
||||
]);
|
||||
const key = async (key, extra = {}) => {
|
||||
await send('Input.dispatchKeyEvent', {type: 'keyDown', key, ...extra});
|
||||
await send('Input.dispatchKeyEvent', {type: 'keyUp', key, ...extra});
|
||||
await sleep(180);
|
||||
};
|
||||
const undo = () => key('z', {modifiers: 2});
|
||||
const drag = async (selector, df, {zone = 0.5, shift = false} = {}) => {
|
||||
const points = await evaluate(`(() => {
|
||||
const handle = document.querySelector(${JSON.stringify(selector)});
|
||||
if (!handle) return null;
|
||||
const track = handle.closest('.tl-track');
|
||||
const h = handle.getBoundingClientRect();
|
||||
const t = track.getBoundingClientRect();
|
||||
const frames = Number(document.querySelector('.at-frame').textContent.split('/')[1]);
|
||||
const x = h.left + h.width * ${zone};
|
||||
const y = h.top + h.height / 2;
|
||||
return {x, y, end: x + t.width * ${df} / frames};
|
||||
})()`);
|
||||
assert(points, `drag handle exists: ${selector}`);
|
||||
const modifiers = shift ? 8 : 0;
|
||||
await send('Input.dispatchMouseEvent', {
|
||||
type: 'mousePressed', x: points.x, y: points.y,
|
||||
button: 'left', buttons: 1, clickCount: 1, modifiers,
|
||||
});
|
||||
await sleep(100);
|
||||
await send('Input.dispatchMouseEvent', {
|
||||
type: 'mouseMoved', x: points.end, y: points.y,
|
||||
button: 'left', buttons: 1, modifiers,
|
||||
});
|
||||
await sleep(100);
|
||||
await send('Input.dispatchMouseEvent', {
|
||||
type: 'mouseReleased', x: points.end, y: points.y,
|
||||
button: 'left', buttons: 0, clickCount: 1, modifiers,
|
||||
});
|
||||
await sleep(250);
|
||||
};
|
||||
const tabs = () => evaluate(`(() => {
|
||||
const k = cljs.core.keyword, db = cljs.core.deref(re_frame.db.app_db);
|
||||
return {tabs: cljs.core.clj__GT_js(cljs.core.get_in(db, [k('ui'), k('tabs')])).map(String),
|
||||
open: String(cljs.core.clj__GT_js(cljs.core.get_in(db, [k('ui'), k('open')])))};
|
||||
})()`);
|
||||
// A real two-press double-click, not `.dispatchEvent`: what broke here was
|
||||
// where the browser decides to deliver the click, which a synthetic event
|
||||
// cannot show.
|
||||
const doubleClick = async selector => {
|
||||
const p = await evaluate(`(() => {
|
||||
const el = document.querySelector(${JSON.stringify(selector)});
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
return {x: r.left + r.width / 2, y: r.top + r.height / 2};
|
||||
})()`);
|
||||
assert(p, `something to double-click: ${selector}`);
|
||||
for (const clickCount of [1, 2]) {
|
||||
await send('Input.dispatchMouseEvent', {type: 'mousePressed', x: p.x, y: p.y,
|
||||
button: 'left', buttons: 1, clickCount});
|
||||
await send('Input.dispatchMouseEvent', {type: 'mouseReleased', x: p.x, y: p.y,
|
||||
button: 'left', buttons: 0, clickCount});
|
||||
await sleep(60);
|
||||
}
|
||||
await sleep(280);
|
||||
};
|
||||
const dropPoolSymbol = async frame => {
|
||||
const points = await evaluate(`(() => {
|
||||
const source = document.querySelector('.pool-row:not(.main) .pool-item[draggable="true"]');
|
||||
const track = document.querySelector('.tl-track');
|
||||
if (!source || !track) return null;
|
||||
source.scrollIntoView({block: 'center'});
|
||||
const a = source.getBoundingClientRect(), b = track.getBoundingClientRect();
|
||||
const frames = Number(document.querySelector('.at-frame').textContent.split('/')[1]);
|
||||
return {sx: a.left + a.width / 2, sy: a.top + a.height / 2,
|
||||
tx: b.left + b.width * (${frame} + 0.25) / frames,
|
||||
ty: b.top + b.height / 2};
|
||||
})()`);
|
||||
assert(points, 'a library symbol and lane are available to drag');
|
||||
await send('Input.dispatchMouseEvent', {type: 'mouseMoved', x: points.sx, y: points.sy});
|
||||
await send('Input.dispatchMouseEvent', {type: 'mousePressed', x: points.sx, y: points.sy,
|
||||
button: 'left', buttons: 1, clickCount: 1});
|
||||
await send('Input.dispatchMouseEvent', {type: 'mouseMoved', x: points.sx + 12, y: points.sy,
|
||||
button: 'left', buttons: 1});
|
||||
await sleep(120);
|
||||
await send('Input.dispatchMouseEvent', {type: 'mouseMoved', x: points.tx, y: points.ty,
|
||||
button: 'left', buttons: 1});
|
||||
await sleep(120);
|
||||
assert.equal(await evaluate('document.querySelectorAll(".tl-label.ghost").length'), 0,
|
||||
'targeting an existing lane does not preview a temporary new row');
|
||||
const lanePreview = await evaluate(`(() => {
|
||||
const db = cljs.core.deref(re_frame.db.app_db), k = cljs.core.keyword;
|
||||
return {ghosts: document.querySelectorAll('.tl-track .tl-cel.ghost').length,
|
||||
drop: cljs.core.clj__GT_js(cljs.core.get_in(db, [k('ui'), k('drop')]))};
|
||||
})()`);
|
||||
assert.equal(lanePreview.ghosts, 1,
|
||||
`the pool drop preview is drawn inside the targeted lane: ${JSON.stringify(lanePreview)}`);
|
||||
await send('Input.dispatchMouseEvent', {type: 'mouseReleased', x: points.tx, y: points.ty,
|
||||
button: 'left', buttons: 0, clickCount: 1});
|
||||
await sleep(300);
|
||||
};
|
||||
const dragClipBetweenLanes = async frame => {
|
||||
const points = await evaluate(`(() => {
|
||||
const tracks = [...document.querySelectorAll('.tl-track')];
|
||||
const source = tracks[1]?.querySelector('.tl-cel');
|
||||
const target = tracks[0];
|
||||
if (!source || !target) return null;
|
||||
const a = source.getBoundingClientRect(), b = target.getBoundingClientRect();
|
||||
const frames = Number(document.querySelector('.at-frame').textContent.split('/')[1]);
|
||||
return {sx: a.left + a.width / 2, sy: a.top + a.height / 2,
|
||||
tx: b.left + b.width * (${frame} + 0.25) / frames,
|
||||
ty: b.top + b.height / 2};
|
||||
})()`);
|
||||
assert(points, 'two lanes and a source clip are available');
|
||||
await send('Input.dispatchMouseEvent', {type: 'mousePressed', x: points.sx, y: points.sy,
|
||||
button: 'left', buttons: 1, clickCount: 1});
|
||||
await send('Input.dispatchMouseEvent', {type: 'mouseMoved', x: points.tx, y: points.ty,
|
||||
button: 'left', buttons: 1});
|
||||
await sleep(150);
|
||||
assert.equal(await evaluate('document.querySelectorAll(".tl-track")[0].querySelectorAll(".tl-cel.ghost").length'), 1,
|
||||
'cross-lane movement previews in the destination lane');
|
||||
await send('Input.dispatchMouseEvent', {type: 'mouseReleased', x: points.tx, y: points.ty,
|
||||
button: 'left', buttons: 0, clickCount: 1});
|
||||
await sleep(300);
|
||||
};
|
||||
const mainInstances = s => Object.values(s.clip.symbols.main.nodes)
|
||||
.filter(n => n.kind === 'instance');
|
||||
const laneSymbols = s => Object.values(s.clip.symbols).filter(sym => sym.display === 'lane');
|
||||
|
||||
assert.equal(await evaluate('[...document.querySelectorAll(".timing-controls > button")].every(b => b.disabled)'), true,
|
||||
'timing buttons are disabled without a symbol clip');
|
||||
await click('inside');
|
||||
assert.equal((await shot()).clip.symbols.main.display, undefined,
|
||||
'a blank document starts as an ordinary symbol');
|
||||
|
||||
await clickNew('inside');
|
||||
let s = await shot();
|
||||
assert.deepEqual(placed(s), [[0, 1]],
|
||||
'new at the root automatically makes a lane and a one-frame symbol clip');
|
||||
assert.equal(await evaluate('document.querySelectorAll(".tl-label .kind").length'), 1,
|
||||
'new temporal content creates a lane row rather than a row per symbol');
|
||||
assert.equal(await evaluate(`document.querySelectorAll('.cel-sheet, [aria-label="time view"]').length`), 0,
|
||||
'there is one temporal interface');
|
||||
assert.equal(await evaluate('document.querySelectorAll(".timing-controls > button").length'), 3,
|
||||
'timing operations are direct buttons');
|
||||
let placed = mainInstances(s);
|
||||
assert.equal(placed.length, 1, 'new symbol places one instance');
|
||||
assert.equal(s.clip.symbols[placed[0].source.symbol].display, undefined,
|
||||
'new symbol remains ordinary');
|
||||
assert.equal(await evaluate('document.querySelectorAll(".tl-track").length'), 1,
|
||||
'an ordinary symbol is an ordinary timeline row');
|
||||
|
||||
await drag('.tl-cel .tl-edge.out', 3);
|
||||
await clickNew('lane');
|
||||
s = await shot();
|
||||
assert.deepEqual(placed(s), [[0, 4]], 'a right edge directly changes the endpoint');
|
||||
placed = mainInstances(s);
|
||||
assert.equal(placed.length, 2, 'explicit lane places a second symbol');
|
||||
assert.equal(laneSymbols(s).length, 1, 'only the lane command marks a symbol as a lane');
|
||||
assert.equal(await evaluate('[...document.querySelectorAll(".tl-track")].filter(t => t.arthurLane).length'), 1,
|
||||
'the explicit lane is drawn as one linear track');
|
||||
|
||||
for (let i = 0; i < 4; i++) await click('+1');
|
||||
await click('inside');
|
||||
await drag('.tl-cel:nth-of-type(2) .tl-edge.out', 2);
|
||||
for (let i = 0; i < 3; i++) await click('+1');
|
||||
await click('inside');
|
||||
s = await shot();
|
||||
assert.deepEqual(placed(s), [[0, 4], [4, 7], [7, 8]]);
|
||||
|
||||
await drag('.tl-cel:nth-of-type(2) .tl-junction', 1, {zone: 0.5});
|
||||
assert.deepEqual(placed(await shot()), [[0, 5], [5, 7], [7, 8]],
|
||||
'the middle of a junction rolls both edges');
|
||||
await undo();
|
||||
|
||||
await drag('.tl-cel:nth-of-type(2) .tl-junction', 1, {zone: 0.9});
|
||||
assert.deepEqual(placed(await shot()), [[0, 4], [5, 7], [7, 8]],
|
||||
'the right side trims only the right clip');
|
||||
await undo();
|
||||
|
||||
await drag('.tl-cel:nth-of-type(2) .tl-junction', -1, {zone: 0.1});
|
||||
assert.deepEqual(placed(await shot()), [[0, 3], [4, 7], [7, 8]],
|
||||
'the left side trims only the left clip');
|
||||
await undo();
|
||||
|
||||
await drag('.tl-cel:nth-of-type(2) .tl-edge.out', 2, {shift: true});
|
||||
assert.deepEqual(placed(await shot()), [[0, 4], [4, 9], [9, 10]],
|
||||
'Shift-edge ripples every later clip on the lane');
|
||||
await undo();
|
||||
|
||||
await drag('.tl-cel:first-of-type .tl-edge.out', 2);
|
||||
assert.deepEqual(placed(await shot()), [[0, 6], [6, 7], [7, 8]],
|
||||
'ordinary growth trims adjacent spans and never overlaps');
|
||||
|
||||
await dropPoolSymbol(10);
|
||||
s = await shot();
|
||||
assert.deepEqual(placed(s), [[0, 6], [6, 7], [7, 8], [10, 11]],
|
||||
'an arbitrary library symbol drops into an existing lane');
|
||||
assert.equal(instances(s).at(-1).playback.speed, 1,
|
||||
'a dropped symbol plays naturally instead of becoming a held drawing');
|
||||
|
||||
await evaluate(`re_frame.core.dispatch(cljs.core.vector(cljs.core.keyword('arthur.events.ui/new-lane')))`);
|
||||
await sleep(180);
|
||||
s = await shot();
|
||||
const renameControls = await evaluate('document.querySelectorAll(".tl-label .tl-rename").length');
|
||||
assert.equal(renameControls, 2,
|
||||
`both lanes expose rename controls: ${JSON.stringify(s.clip.symbols.main.nodes)}`);
|
||||
await evaluate('document.querySelector(".tl-label .tl-rename").click()');
|
||||
assert.equal(await evaluate('document.querySelectorAll(".tl-rename").length'), 1,
|
||||
'the lane exposes its rename control');
|
||||
await evaluate('document.querySelector(".tl-rename").click()');
|
||||
await sleep(80);
|
||||
assert(await evaluate(`(() => {
|
||||
const input = document.querySelector('.tl-name-input');
|
||||
if (!input) return false;
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(input, 'Foreground');
|
||||
input.dispatchEvent(new InputEvent('input', {bubbles: true, inputType: 'insertText', data: 'Foreground'}));
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')
|
||||
.set.call(input, 'Foreground');
|
||||
input.dispatchEvent(new InputEvent('input', {bubbles: true, inputType: 'insertText'}));
|
||||
input.blur(); return true;
|
||||
})()`), 'lane rename editor opens');
|
||||
await sleep(180);
|
||||
s = await shot();
|
||||
assert(Object.values(s.clip.symbols.main.nodes).some(n => n.layout === 'sequence' && n.name === 'Foreground'),
|
||||
'a lane name is editable and persisted in the document');
|
||||
assert(mainInstances(s).some(n => n.name === 'Foreground'), 'lane name persists');
|
||||
|
||||
await dragClipBetweenLanes(12);
|
||||
s = await shot();
|
||||
const lanes = Object.values(s.clip.symbols.main.nodes).filter(n => n.layout === 'sequence');
|
||||
assert.deepEqual(lanes.map(l => instances(s).filter(n => n.parent === l.id).length).sort(), [1, 3],
|
||||
'a clip body can move from one lane to another');
|
||||
|
||||
// EXPANDING A LANE OPENS THE SELECTED CLIP. Its own keys, and under it the
|
||||
// lanes and nodes of the symbol it places, all on this ruler — which is what
|
||||
// makes the whole document editable from the root timeline.
|
||||
const rowLabels = () => evaluate(
|
||||
`[...document.querySelectorAll('.tl-labels > .tl-label')].map(e => e.textContent.trim())`);
|
||||
const twist = async i => {
|
||||
assert(await evaluate(`(() => {
|
||||
const t = document.querySelectorAll('.tl-labels > .tl-label .tl-twist')[${i}];
|
||||
if (!t || t.disabled) return false;
|
||||
t.click(); return true;
|
||||
})()`), `an expander at row ${i}`);
|
||||
await sleep(220);
|
||||
};
|
||||
await evaluate(`(() => { document.querySelector('.tl-track .tl-cel').click(); return true })()`);
|
||||
await sleep(200);
|
||||
const collapsed = await rowLabels();
|
||||
await twist(0);
|
||||
const opened = await rowLabels();
|
||||
assert(opened.length > collapsed.length, 'the lane opens');
|
||||
assert.equal(opened.filter(l => l.includes('instance')).length, 1,
|
||||
`one clip portal, not one branch per clip: ${JSON.stringify(opened)}`);
|
||||
const portalAt = opened.findIndex(l => l.includes('instance'));
|
||||
await twist(portalAt);
|
||||
const deep = await rowLabels();
|
||||
assert(deep.length > opened.length,
|
||||
`the portal opens the symbol the clip places: ${JSON.stringify(deep)}`);
|
||||
// Selecting something nested must not close the portal that revealed it.
|
||||
await evaluate(`(() => {
|
||||
const k = cljs.core.keyword, db = cljs.core.deref(re_frame.db.app_db);
|
||||
const sel = cljs.core.get_in(db, [k('ui'), k('selection')]);
|
||||
const path = cljs.core.nth(sel, 3);
|
||||
re_frame.core.dispatch(cljs.core.vector(
|
||||
k('arthur.events.ui/select'),
|
||||
cljs.core.vector(k('node'), cljs.core.nth(sel, 1), cljs.core.nth(sel, 2),
|
||||
cljs.core.conj(path, k('made-up-child')))));
|
||||
return true;
|
||||
// Drag a library symbol into the explicit lane. The row itself is the target;
|
||||
// no temporary lane is previewed or created.
|
||||
const drop = await evaluate(`(() => {
|
||||
const source = document.querySelector('.pool-row:not(.main) .pool-item[draggable="true"]');
|
||||
const track = [...document.querySelectorAll('.tl-track')].find(t => t.arthurLane);
|
||||
if (!source || !track) return null;
|
||||
source.scrollIntoView({block: 'center'});
|
||||
const a = source.getBoundingClientRect(), b = track.getBoundingClientRect();
|
||||
return {sx: a.left + a.width / 2, sy: a.top + a.height / 2,
|
||||
tx: b.left + b.width * .085, ty: b.top + b.height / 2};
|
||||
})()`);
|
||||
await sleep(220);
|
||||
assert.equal((await rowLabels()).filter(l => l.includes('instance')).length, 1,
|
||||
'a selection under the clip keeps its portal open');
|
||||
await twist(portalAt);
|
||||
await twist(0);
|
||||
assert(drop, 'a pool symbol and explicit lane are available');
|
||||
await send('Input.dispatchMouseEvent', {type: 'mouseMoved', x: drop.sx, y: drop.sy});
|
||||
await send('Input.dispatchMouseEvent', {type: 'mousePressed', x: drop.sx, y: drop.sy,
|
||||
button: 'left', buttons: 1, clickCount: 1});
|
||||
await send('Input.dispatchMouseEvent', {type: 'mouseMoved', x: drop.sx + 12, y: drop.sy,
|
||||
button: 'left', buttons: 1});
|
||||
await sleep(100);
|
||||
await send('Input.dispatchMouseEvent', {type: 'mouseMoved', x: drop.tx, y: drop.ty,
|
||||
button: 'left', buttons: 1});
|
||||
await sleep(120);
|
||||
assert.equal(await evaluate('document.querySelectorAll(".tl-label.ghost").length'), 0,
|
||||
'pool drop does not preview an invented lane');
|
||||
assert.equal(await evaluate('document.querySelectorAll(".tl-cel.ghost").length'), 1,
|
||||
'pool drop previews inside the existing lane');
|
||||
await send('Input.dispatchMouseEvent', {type: 'mouseReleased', x: drop.tx, y: drop.ty,
|
||||
button: 'left', buttons: 0, clickCount: 1});
|
||||
await sleep(300);
|
||||
s = await shot();
|
||||
assert.equal(Object.keys(laneSymbols(s)[0].nodes).length, 1,
|
||||
`the dropped clip remains in the explicit lane: ${JSON.stringify(s)}`);
|
||||
assert.equal(laneSymbols(s).length, 1, 'the drop creates no extra lane');
|
||||
|
||||
const before = await tabs();
|
||||
const tabChips = () => evaluate('document.querySelectorAll(".tabs .tab").length');
|
||||
const chipsBefore = await tabChips();
|
||||
await doubleClick('.tl-track .tl-cel');
|
||||
const after = await tabs();
|
||||
assert.equal(after.tabs.length, before.tabs.length + 1,
|
||||
`double-clicking a clip opens the symbol it places, as the pool row does: ${JSON.stringify(after)}`);
|
||||
assert(!before.tabs.includes(after.open) && after.tabs.includes(after.open),
|
||||
`the opened symbol is the one in front: ${JSON.stringify(after)}`);
|
||||
assert.equal(await tabChips(), chipsBefore + 1,
|
||||
'the opened symbol is drawn as one more tab');
|
||||
assert.equal(await evaluate('document.querySelectorAll("#app > *").length'), 1,
|
||||
'opening from the timeline leaves the editor standing: a stale node selection ' +
|
||||
'pointing into the symbol just left used to throw and unmount it');
|
||||
// A second explicit lane is a sibling in the open symbol even though the
|
||||
// first remains aimed. Move the clip between their linear tracks.
|
||||
await clickNew('lane');
|
||||
s = await shot();
|
||||
assert.equal(laneSymbols(s).length, 2, 'a second explicit command creates a second lane');
|
||||
const move = await evaluate(`(() => {
|
||||
const tracks = [...document.querySelectorAll('.tl-track')].filter(t => t.arthurLane);
|
||||
const from = tracks.find(t => t.querySelector('.tl-cel'));
|
||||
const to = tracks.find(t => t !== from);
|
||||
const cel = from?.querySelector('.tl-cel');
|
||||
if (!cel || !to) return null;
|
||||
const a = cel.getBoundingClientRect(), b = to.getBoundingClientRect();
|
||||
return {sx: a.left + a.width / 2, sy: a.top + a.height / 2,
|
||||
tx: b.left + b.width * .12, ty: b.top + b.height / 2};
|
||||
})()`);
|
||||
assert(move, 'two explicit lanes and a source clip are available');
|
||||
await send('Input.dispatchMouseEvent', {type: 'mousePressed', x: move.sx, y: move.sy,
|
||||
button: 'left', buttons: 1, clickCount: 1});
|
||||
await send('Input.dispatchMouseEvent', {type: 'mouseMoved', x: move.tx, y: move.ty,
|
||||
button: 'left', buttons: 1});
|
||||
await sleep(150);
|
||||
await send('Input.dispatchMouseEvent', {type: 'mouseReleased', x: move.tx, y: move.ty,
|
||||
button: 'left', buttons: 0, clickCount: 1});
|
||||
await sleep(300);
|
||||
s = await shot();
|
||||
assert.deepEqual(laneSymbols(s).map(x => Object.keys(x.nodes).length).sort(), [0, 1],
|
||||
'a clip body moves from one explicit lane to the other');
|
||||
assert.equal(errors.length, 0, JSON.stringify(errors));
|
||||
console.log('PASS: generic lanes preview, rename, move, place, open, trim, roll, and ripple clips');
|
||||
console.log('PASS: symbols are ordinary; explicit lanes rename, accept drops, and exchange clips');
|
||||
} 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); });
|
||||
if (ws?.readyState === WebSocket.OPEN) ws.close();
|
||||
chrome.kill('SIGTERM');
|
||||
await new Promise(resolve => 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}`);
|
||||
if (error.code !== 'ENOTEMPTY') throw error;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue