Unify cel editing in the timeline
This commit is contained in:
parent
4ddd6a8d1d
commit
26ada03591
18 changed files with 534 additions and 922 deletions
|
|
@ -43,36 +43,6 @@
|
|||
:wave (assoc-in (drawing :wave 0 10) [:nodes :mark :channels [:xform :pos]]
|
||||
(ch/keyed {0 [0 0] 9 [900 0]} :linear))}}))
|
||||
|
||||
(deftest sheet-copy-clips-without-resetting-source-or-local-keys
|
||||
(let [doc (document)
|
||||
payload (lane/sheet-range doc :main [:girl] [9 11])
|
||||
copied (first (first (:columns payload)))
|
||||
result (lane/paste-range doc :main [:girl] 1 payload random-uuid)
|
||||
nodes (get-in (:clip result) [:symbols :main :nodes])
|
||||
pasted (first (filter #(and (= :wave (node/source %))
|
||||
(= [1 3] (node/placed-span %))) (vals nodes)))]
|
||||
(is (= 2 (:duration payload)))
|
||||
(is (= [1 3] (:span copied)))
|
||||
(is (= (:playback copied) (:playback pasted)))
|
||||
(is (= [1 3] (:span pasted)))
|
||||
(is (= :wave (node/source pasted)))
|
||||
(is (empty? (clip/problems (:clip result))))
|
||||
(is (= [0 1] (node/placed-span (:a nodes))))
|
||||
(is (some #(= [3 4] (node/placed-span %)) (vals nodes)))))
|
||||
|
||||
(deftest sheet-paste-preserves-gaps-and-refuses-overflow-and-incompatible-clocks
|
||||
(let [doc (document)
|
||||
gap (:clip (lane/blank doc :main :girl [1 3] {:id :remainder}))
|
||||
payload (lane/sheet-range gap :main [:girl] [0 4])
|
||||
result (lane/paste-range gap :main [:girl] 4 payload random-uuid)
|
||||
cels (symbol/lane-cels (get-in (:clip result) [:symbols :main :nodes]) :girl)]
|
||||
(is (empty? (clip/problems (:clip result))))
|
||||
(is (not-any? #(let [[a b] (node/placed-span %)] (and (< a 7) (> b 5))) cels))
|
||||
(is (:refused (lane/paste-range doc :main [:girl] 10 payload random-uuid)))
|
||||
(is (:refused (lane/paste-range doc :main [] 0 payload random-uuid)))
|
||||
(is (:refused (lane/sheet-range
|
||||
(assoc-in doc [:symbols :main :nodes :girl :time] {:rate 2})
|
||||
:main [:girl] [0 4])))))
|
||||
|
||||
(defn sample [doc fs]
|
||||
(let [r (clip/resolver doc :main nil pal/index-of nil)]
|
||||
|
|
@ -122,7 +92,46 @@
|
|||
(doseq [delta [0 -4 0.5 js/NaN]]
|
||||
(is (:refused (lane/extend-hold doc :main :a delta {}))))
|
||||
(is (:refused (lane/extend-hold doc :main :insert 1 {})))
|
||||
(is (:refused (lane/extend-hold doc :main :missing 1 {})))))
|
||||
(is (:refused (lane/extend-hold doc :main :missing 1 {})))))
|
||||
|
||||
(deftest dragging-a-cel-edge-trims-neighbours-or-ripples-them
|
||||
(let [doc (document)
|
||||
plain (:clip (lane/resize-out doc :main :a 6 {}))
|
||||
across (:clip (lane/resize-out doc :main :a 9 {}))
|
||||
ripple (:clip (lane/resize-out doc :main :a 6 {:ripple? true
|
||||
:extent :grow-symbol}))
|
||||
shrink (:clip (lane/resize-out doc :main :a 2 {:ripple? true}))]
|
||||
(is (= [[0 6] [6 8] [8 12]]
|
||||
(mapv node/placed-span (symbol/lane-cels (get-in plain [:symbols :main :nodes]) :girl)))
|
||||
"a normal grow eats the beginning of the adjacent cel")
|
||||
(is (= [[0 9] [9 12]]
|
||||
(mapv node/placed-span (symbol/lane-cels (get-in across [:symbols :main :nodes]) :girl)))
|
||||
"a long grow removes wholly consumed cels and trims the survivor")
|
||||
(is (= [[0 6] [6 10] [10 14]]
|
||||
(mapv node/placed-span (symbol/lane-cels (get-in ripple [:symbols :main :nodes]) :girl)))
|
||||
"shift-grow moves every later cel")
|
||||
(is (= [[0 2] [2 6] [6 10]]
|
||||
(mapv node/placed-span (symbol/lane-cels (get-in shrink [:symbols :main :nodes]) :girl)))
|
||||
"shift-shrink pulls every later cel left")
|
||||
(is (:refused (lane/resize-out doc :main :a 0 {})))
|
||||
(is (:refused (lane/resize-out doc :main :a 2.5 {})))))
|
||||
|
||||
(deftest the-middle-of-a-cut-rolls-both-edges
|
||||
(let [doc (document)
|
||||
rolled (:clip (lane/roll doc :main :a :b 6))
|
||||
right-only (:clip (lane/resize-in doc :main :b 6))
|
||||
grown-left (:clip (lane/resize-in doc :main :b 2))]
|
||||
(is (= [[0 6] [6 8] [8 12]]
|
||||
(mapv node/placed-span (symbol/lane-cels (get-in rolled [:symbols :main :nodes]) :girl)))
|
||||
"the shared cut moves without moving either clip")
|
||||
(is (= [[0 4] [6 8] [8 12]]
|
||||
(mapv node/placed-span (symbol/lane-cels (get-in right-only [:symbols :main :nodes]) :girl)))
|
||||
"the right side of the junction trims only the right clip")
|
||||
(is (= [[0 2] [2 8] [8 12]]
|
||||
(mapv node/placed-span (symbol/lane-cels (get-in grown-left [:symbols :main :nodes]) :girl)))
|
||||
"growing the right clip left trims the neighbour instead of overlapping")
|
||||
(is (:refused (lane/roll doc :main :a :b 0)))
|
||||
(is (:refused (lane/roll doc :main :a :insert 6)))))
|
||||
|
||||
(deftest a-gap-is-an-uncovered-interval
|
||||
(let [doc (update-in (document) [:symbols :main :nodes] dissoc :b)
|
||||
|
|
|
|||
|
|
@ -222,6 +222,25 @@
|
|||
:main [a-uuid :inner] 3))
|
||||
"but through a loop one frame outside is many inside")))
|
||||
|
||||
(deftest linked-audio-follows-picture-moves-but-keeps-independent-edges
|
||||
(let [c (-> (clip/blank)
|
||||
(assoc-in [:symbols :drawing] {:id :drawing :frames 8 :nodes {}})
|
||||
(clip/place-symbol nil :main :drawing 2 :picture nil)
|
||||
(clip/place-sound :main {:sound "voice"} "voice" 8 1 2 :voice)
|
||||
(assoc-in [:symbols :main :nodes :voice :linked-to] :picture))
|
||||
moved (:clip (nest/slide c :main [:picture] 3))
|
||||
audio-only (:clip (nest/slide c :main [:voice] 1))
|
||||
trimmed (:clip (nest/resize-out c :main [:voice] -2 false))]
|
||||
(is (= [5 13] (node/placed-span (get-in moved [:symbols :main :nodes :picture]))))
|
||||
(is (= [5 13] (node/placed-span (get-in moved [:symbols :main :nodes :voice])))
|
||||
"moving picture carries its linked audio")
|
||||
(is (= [2 10] (node/placed-span (get-in audio-only [:symbols :main :nodes :picture]))))
|
||||
(is (= [3 11] (node/placed-span (get-in audio-only [:symbols :main :nodes :voice])))
|
||||
"moving audio itself does not carry picture")
|
||||
(is (= [2 8] (node/placed-span (get-in trimmed [:symbols :main :nodes :voice])))
|
||||
"an audio endpoint trims independently")
|
||||
(is (= [2 10] (node/placed-span (get-in trimmed [:symbols :main :nodes :picture]))))))
|
||||
|
||||
(deftest restacking-is-one-write-to-z
|
||||
(let [shape (fn [z] {:kind :poly :z z :channels {}})
|
||||
c (-> (clip/blank)
|
||||
|
|
|
|||
|
|
@ -161,6 +161,11 @@
|
|||
(is (:refused (span/trim doc :main :b :middle 6)))
|
||||
(is (re-find #"group" (:refused (span/trim doc :main :girl :out 6))))))
|
||||
|
||||
(deftest timeline-edge-resize-allows-an-ordinary-clip-to-grow
|
||||
(let [after (:clip (span/resize-out (document) :main :badge 11))]
|
||||
(is (= [2 11] (node/placed-span (get-in after [:symbols :main :nodes :badge]))))
|
||||
(is (:refused (span/resize-out (document) :main :badge 2)))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; move
|
||||
|
||||
|
|
|
|||
|
|
@ -22,21 +22,6 @@
|
|||
(is (= [0 6 12] (:keys lane)))
|
||||
(is (= 1 (count (filter :cels (timeline/rows doc :main #{[:girl]})))))))
|
||||
|
||||
(deftest the-cel-sheet-is-the-same-cels-with-the-axes-turned
|
||||
(let [doc (fixture/document)
|
||||
column (first (timeline/cel-sheet doc :main 12))
|
||||
cells (:cells column)]
|
||||
(is (= :girl (:id column)))
|
||||
(is (= [:node :main :girl [:girl]] (:select column)))
|
||||
(is (every? #(= (:select column) (:lane-select %)) cells))
|
||||
(is (= [:a :b :insert] (mapv #(get-in cells [% :cel :id]) [0 4 8])))
|
||||
(is (= [[:node :main :a [:a]]
|
||||
[:node :main :b [:b]]
|
||||
[:node :main :insert [:insert]]]
|
||||
(mapv #(get-in cells [% :cel :select]) [0 4 8])))
|
||||
(is (= (mapv :select (:cels (first (filter :cels (timeline/rows doc :main #{})))))
|
||||
(mapv #(get-in cells [% :cel :select]) [0 4 8])))))
|
||||
|
||||
(deftest a-nested-selection-converts-the-open-playhead-to-its-owning-symbol
|
||||
(let [doc (assoc-in (fixture/document) [:symbols :outer]
|
||||
{:id :outer :frames 30
|
||||
|
|
@ -52,7 +37,6 @@
|
|||
(deftest polygon-landing-follows-the-target-not-the-selection
|
||||
(let [doc (fixture/document)
|
||||
db {:ui {:open :main
|
||||
:time-view :timeline
|
||||
:selection [:node :main :plate [:plate]]
|
||||
:target {:sid :main :id :insert :path [:insert]}}
|
||||
:playback {:frame 5}}
|
||||
|
|
@ -62,10 +46,9 @@
|
|||
"looking at another shape does not silently move the creation target")
|
||||
(is (false? (:lane? landing)))))
|
||||
|
||||
(deftest cel-sheet-polygon-landing-is-decided-by-the-aimed-lane
|
||||
(deftest timeline-polygon-landing-is-decided-by-the-aimed-lane
|
||||
(let [doc (fixture/document)
|
||||
base {:ui {:open :main
|
||||
:time-view :cel-sheet
|
||||
:selection [:node :main :plate [:plate]]
|
||||
:target {:sid :main :id :girl :path [:girl]}}
|
||||
:playback {:frame 5}}
|
||||
|
|
@ -84,22 +67,22 @@
|
|||
(first (:path gap))))
|
||||
(is (empty? (clip/problems (:clip gap))))))
|
||||
|
||||
(deftest cel-sheet-polygon-landing-refuses-without-an-aimed-lane
|
||||
(deftest polygon-landing-without-an-aimed-lane-uses-the-ordinary-target
|
||||
(let [doc (fixture/document)
|
||||
db {:ui {:open :main :time-view :cel-sheet
|
||||
db {:ui {:open :main
|
||||
:target {:sid :main :id :plate :path [:plate]}}
|
||||
:playback {:frame 5}}
|
||||
landing (ui/polygon-landing doc {} db)]
|
||||
(is (re-find #"drawing lane" (:refused landing)))
|
||||
(is (true? (:lane? landing)))
|
||||
(is (nil? (:clip landing)))))
|
||||
(is (= doc (:clip landing)))
|
||||
(is (= [] (:path landing)))
|
||||
(is (false? (:lane? landing)))))
|
||||
|
||||
(deftest beginning-a-polygon-materializes-a-missing-cel-sheet-drawing
|
||||
(deftest beginning-a-polygon-materializes-a-missing-timeline-drawing
|
||||
(let [doc (fixture/document)
|
||||
with-gap (:clip (lane/blank doc :main :girl [5 7] {:id :rest}))
|
||||
id (store/install! {:clip with-gap :store {}} "polygon-start-test")
|
||||
db {:clip/current id :paint/revision 0
|
||||
:ui {:open :main :time-view :cel-sheet
|
||||
:ui {:open :main
|
||||
:target {:sid :main :id :girl :path [:girl]}}
|
||||
:playback {:frame 6}}
|
||||
after (ui/beginning-polygon db)
|
||||
|
|
@ -114,28 +97,19 @@
|
|||
(is (= 6 (get-in saved [:symbols :main :nodes cel-id :time :at])))
|
||||
(is (empty? (clip/problems saved)))))
|
||||
|
||||
(deftest beginning-a-polygon-materializes-a-lane-before-its-drawing
|
||||
(deftest beginning-a-polygon-does-not-invent-an-unselected-lane
|
||||
(let [doc (clip/blank)
|
||||
id (store/install! {:clip doc :store {}} "polygon-lane-start-test")
|
||||
db {:clip/current id :paint/revision 0
|
||||
:ui {:open :main :time-view :cel-sheet}
|
||||
:ui {:open :main}
|
||||
:playback {:frame 6}}
|
||||
after (ui/beginning-polygon db)
|
||||
saved (:clip (store/entry (:clip/current after)))
|
||||
lanes (symbol/lanes (get-in saved [:symbols :main :nodes]))
|
||||
lane-id (:id (first lanes))
|
||||
[_ sid cel-id path] (get-in after [:ui :selection])]
|
||||
lanes (symbol/lanes (get-in saved [:symbols :main :nodes]))]
|
||||
(is (= :polygon (get-in after [:ui :tool])))
|
||||
(is (= 1 (count lanes)))
|
||||
(is (= {:sid :main :id lane-id :path [lane-id]}
|
||||
(get-in after [:ui :target])))
|
||||
(is (= :main sid))
|
||||
(is (= [cel-id] path))
|
||||
(is (= lane-id (get-in saved [:symbols :main :nodes cel-id :parent])))
|
||||
(is (= 6 (get-in saved [:symbols :main :nodes cel-id :time :at])))
|
||||
(is (= 1 (count (get-in (store/entry (:clip/current after))
|
||||
[:history :done])))
|
||||
"the lane and drawing are one start-polygon undo step")
|
||||
(is (empty? lanes))
|
||||
(is (nil? (get-in after [:ui :target])))
|
||||
(is (nil? (get-in (store/entry (:clip/current after)) [:history :done])))
|
||||
(is (empty? (clip/problems saved)))))
|
||||
|
||||
(deftest sequence-commands-use-isolated-history-transactions
|
||||
|
|
|
|||
|
|
@ -117,265 +117,98 @@ try {
|
|||
// 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')
|
||||
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');
|
||||
// Real pointer capture and keyboard events exercise the spreadsheet surface.
|
||||
const cellAt = async (c, f) => evaluate(`(() => {
|
||||
const el = document.querySelector('[data-cs-col="${c}"][data-cs-frame="${f}"]');
|
||||
el.scrollIntoView({block: 'center'});
|
||||
const r = el.getBoundingClientRect();
|
||||
return {x: r.x + r.width / 2, y: r.y + r.height / 2};
|
||||
})()`);
|
||||
const mouse = (type, p) => send('Input.dispatchMouseEvent', {
|
||||
type, ...p, button: 'left', buttons: type === 'mouseReleased' ? 0 : 1, clickCount: 1,
|
||||
});
|
||||
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(120);
|
||||
await sleep(180);
|
||||
};
|
||||
const start = await cellAt(0, 0);
|
||||
await mouse('mousePressed', start);
|
||||
await mouse('mouseMoved', await cellAt(1, 2));
|
||||
await mouse('mouseReleased', await cellAt(1, 2));
|
||||
await sleep(150);
|
||||
assert.equal(await evaluate('document.querySelectorAll(".cs-cell.selected").length'), 6);
|
||||
await key('c', {modifiers: 2});
|
||||
const dest = await cellAt(0, 10);
|
||||
await mouse('mousePressed', dest);
|
||||
await mouse('mouseReleased', dest);
|
||||
await sleep(120);
|
||||
const beforePaste = await shot();
|
||||
await key('v', {modifiers: 2});
|
||||
const pasted = await shot();
|
||||
assert.equal(pasted.history.done.length, beforePaste.history.done.length + 1,
|
||||
'multi-lane paste is one undo step');
|
||||
assert.deepEqual(Object.keys(pasted.clip.symbols), Object.keys(beforePaste.clip.symbols),
|
||||
'copy reuses the drawings');
|
||||
await key('z', {modifiers: 2});
|
||||
assert.deepEqual((await shot()).clip, beforePaste.clip, 'undo restores the entire rectangle');
|
||||
await key('ArrowDown', {modifiers: 8});
|
||||
assert.equal(await evaluate('document.querySelectorAll(".cs-cell.selected").length'), 2);
|
||||
const first = await cellAt(0, 0);
|
||||
await mouse('mousePressed', first);
|
||||
await mouse('mouseReleased', first);
|
||||
await sleep(150);
|
||||
const handle = await evaluate(`(() => {
|
||||
const r = document.querySelector('.cs-fill-handle').getBoundingClientRect();
|
||||
return {x: r.x + r.width / 2, y: r.y + r.height / 2};
|
||||
})()`);
|
||||
const beforeHold = await shot();
|
||||
await mouse('mousePressed', handle);
|
||||
await mouse('mouseMoved', await cellAt(0, 4));
|
||||
await mouse('mouseReleased', await cellAt(0, 4));
|
||||
await sleep(150);
|
||||
const afterHold = await shot();
|
||||
assert.equal(afterHold.history.done.length, beforeHold.history.done.length + 1,
|
||||
'a hold drag commits once');
|
||||
assert.equal(instances(afterHold).filter(n => {
|
||||
const old = instances(beforeHold).find(o => o.id === n.id);
|
||||
return old && n.span[1] !== old.span[1] && n.time.at + n.span[1] === 5;
|
||||
}).length, 1, 'the dragged hold ends at the previewed boundary');
|
||||
await key('z', {modifiers: 2});
|
||||
assert.deepEqual((await shot()).clip, beforeHold.clip);
|
||||
await key('Delete');
|
||||
const cleared = await shot();
|
||||
assert.equal(cleared.history.done.length, beforeHold.history.done.length + 1,
|
||||
'Delete clears selected frames in one step, without the global node-delete handler');
|
||||
assert.equal(cleared.clip.symbols.main.frames, beforeHold.clip.symbols.main.frames);
|
||||
await key('z', {modifiers: 2});
|
||||
assert.deepEqual((await shot()).clip, beforeHold.clip);
|
||||
await key('x', {modifiers: 2});
|
||||
assert.equal((await shot()).history.done.length, beforeHold.history.done.length + 1);
|
||||
await key('z', {modifiers: 2});
|
||||
assert.deepEqual((await shot()).clip, beforeHold.clip, 'cut is independently undoable');
|
||||
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);
|
||||
};
|
||||
|
||||
await click('lane');
|
||||
assert.equal(await evaluate('[...document.querySelectorAll(".timing-controls > button")].every(b => b.disabled)'), true,
|
||||
'timing buttons are disabled when the selected row is a lane, not a symbol clip');
|
||||
await click('inside');
|
||||
let s = await shot();
|
||||
assert.deepEqual(placed(s), [[0, 1]], 'new inside an aimed lane is a one-frame 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');
|
||||
|
||||
await drag('.tl-cel .tl-edge.out', 3);
|
||||
s = await shot();
|
||||
assert.deepEqual(placed(s), [[0, 4]], 'a right edge directly changes the endpoint');
|
||||
|
||||
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');
|
||||
assert.equal(errors.length, 0, JSON.stringify(errors));
|
||||
console.log('PASS: lane commands and corrections agree from timeline and cel sheet; no server writes');
|
||||
console.log('PASS: one timeline creates, trims, rolls, and ripples drawing-lane symbols');
|
||||
} finally {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ id: 999999, method: 'Browser.close' }));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue