Author corrections without baking them into motion
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.
This commit is contained in:
parent
3dbbe285fc
commit
815ce449ea
14 changed files with 806 additions and 60 deletions
|
|
@ -287,6 +287,30 @@
|
|||
"a covering replacement establishes the shape seen by later layers")
|
||||
(is (= [2 3 4] (ch/value-at (corrected base put3 add3) 0 nil)))))
|
||||
|
||||
(deftest stack-compatibility-follows-adjacent-replacements-and-skips-conflicts
|
||||
(let [base (ch/framed [0 0])
|
||||
left (ch/layer :left [0 2] :replace (ch/framed [1 2 3]))
|
||||
right (ch/layer :right [2 4] :replace (ch/framed [4 5 6]))
|
||||
add3 (ch/layer :add [0 4] :offset (ch/framed [1 1 1]))
|
||||
valid (corrected base left right add3)
|
||||
broken (corrected base (assoc left :conflict "skip it") right add3)]
|
||||
(is (empty? (ch/problems valid))
|
||||
"adjacent replacements jointly prevent the base shape reaching the offset")
|
||||
(is (seq (ch/problems broken))
|
||||
"a conflicted replacement is absent from the effective stack")
|
||||
(is (empty? (ch/conflicts valid)))
|
||||
(is (= [:left :add] (mapv :id (ch/conflicts broken))))))
|
||||
|
||||
(deftest reconciliation-recomputes-the-complete-stack-in-order
|
||||
(let [old (assoc (ch/framed [0 0]) :over
|
||||
[(assoc (ch/layer :put [0 3] :replace (ch/framed [1 2 3]))
|
||||
:conflict "old")
|
||||
(ch/layer :add [0 3] :offset (ch/framed [1 1 1]))])
|
||||
layers (ch/reconcile old)]
|
||||
(is (nil? (:conflict (first layers))) "a stale mark is cleared")
|
||||
(is (nil? (:conflict (second layers))) "the later offset sees that replacement")
|
||||
(is (= [2 3 4] (ch/value-at (assoc old :over layers) 1 nil)))))
|
||||
|
||||
(deftest generated-base-time-and-authored-correction-time-can-differ
|
||||
(let [c (corrected (ch/keyed {0 0, 2 20} :hold)
|
||||
(ch/layer :nudge [1 2] :offset (ch/framed 3)))
|
||||
|
|
|
|||
86
frontend/test/arthur/domain/correction_test.cljs
Normal file
86
frontend/test/arthur/domain/correction_test.cljs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
(ns arthur.domain.correction-test
|
||||
(:require [cljs.test :refer [deftest is testing]]
|
||||
[arthur.domain.channel :as ch]
|
||||
[arthur.domain.clip :as clip]
|
||||
[arthur.domain.correction :as correction]
|
||||
[arthur.domain.leaf :as leaf]
|
||||
[arthur.domain.lane-test :as fixture]))
|
||||
|
||||
(defn- channel [doc id path]
|
||||
(get-in doc [:symbols :main :nodes id :channels path]))
|
||||
|
||||
(deftest authors-the-three-motions-as-ordinary-layer-channels
|
||||
(let [doc (fixture/document)
|
||||
constant (correction/add doc :main :girl [:xform :rot]
|
||||
{:id :flat :support [2 5] :motion :constant :delta 1})
|
||||
ramp (correction/add (:clip constant) :main :girl [:xform :rot]
|
||||
{:id :ramp :support [6 9] :motion :ramp :start 0 :end 2})
|
||||
returned (correction/add (:clip ramp) :main :girl [:xform :rot]
|
||||
{:id :return :support [9 12] :motion :return
|
||||
:start 0 :peak 3 :peak-frame 10})
|
||||
c (channel (:clip returned) :girl [:xform :rot])]
|
||||
(is (= :girl (:selection returned)))
|
||||
(is (= [:flat :ramp :return] (mapv :id (:over c))))
|
||||
(is (= [0 0 1 1 1 0 0 1 2 0 3 0]
|
||||
(mapv #(ch/value-at c % nil) (range 12))))
|
||||
(is (empty? (clip/problems (:clip returned))))))
|
||||
|
||||
(deftest materializes-defaults-and-refuses-incomplete-intent
|
||||
(let [doc (fixture/document)
|
||||
result (correction/add doc :main :a [:xform :rot]
|
||||
{:id :nudge :support [0 3] :motion :return
|
||||
:start 0 :peak 0.5 :peak-frame 1})]
|
||||
(is (= 0.5 (ch/value-at (channel (:clip result) :a [:xform :rot]) 1 nil)))
|
||||
(is (= (dissoc (get-in doc [:symbols :main :nodes :a]) :channels)
|
||||
(dissoc (get-in (:clip result) [:symbols :main :nodes :a]) :channels)))
|
||||
(doseq [spec [{:id :x :support [0 1] :motion :ramp :start 0 :end 1}
|
||||
{:id :x :support [0 2] :motion :return :start 0 :peak 1 :peak-frame 1}
|
||||
{:id :x :support [0 3] :motion :constant :delta js/NaN}
|
||||
{:id :x :support [0 3] :motion :constant :delta [1 2]}]]
|
||||
(let [r (correction/add doc :main :a [:xform :rot] spec)]
|
||||
(is (:refused r))
|
||||
(is (not (contains? r :clip)))))))
|
||||
|
||||
(deftest removal-and-retry-never-leave-an-invalid-stack
|
||||
(let [doc (fixture/document)
|
||||
base (ch/framed [0 0])
|
||||
put3 (ch/layer :put3 [0 2] :replace (ch/framed [1 2 3]))
|
||||
add3 (ch/layer :add3 [0 2] :offset (ch/framed [1 1 1]))
|
||||
stacked (assoc base :over [put3 add3])
|
||||
doc (assoc-in doc [:symbols :main :nodes :girl :channels [:xform :pos]] stacked)]
|
||||
(is (:refused (correction/remove-layer doc :main :girl [:xform :pos] :put3))
|
||||
"removing the replacement would expose a wrong-shaped base")
|
||||
(let [conflicted (assoc-in doc [:symbols :main :nodes :girl :channels [:xform :pos] :over 1 :conflict]
|
||||
"old topology")
|
||||
retried (correction/retry-layer conflicted :main :girl [:xform :pos] :add3)]
|
||||
(is (:clip retried))
|
||||
(is (nil? (get-in (:clip retried)
|
||||
[:symbols :main :nodes :girl :channels [:xform :pos] :over 1 :conflict]))))
|
||||
(let [without-replacement (-> doc
|
||||
(assoc-in [:symbols :main :nodes :girl :channels [:xform :pos] :over]
|
||||
[(assoc add3 :conflict "old topology")]))]
|
||||
(is (:refused (correction/retry-layer without-replacement :main :girl
|
||||
[:xform :pos] :add3))
|
||||
"retry refuses when the current effective base still has the wrong shape"))))
|
||||
|
||||
(deftest a-cel-correction-travels-with-its-owner
|
||||
(let [doc (fixture/document)
|
||||
result (correction/add doc :main :b [:xform :pos]
|
||||
{:id :nudge :support [1 3] :motion :constant :delta [4 0]})
|
||||
c (channel (:clip result) :b [:xform :pos])]
|
||||
(is (= [6 0] (ch/value-at c 1 nil)))
|
||||
(is (= [0 0] (ch/value-at c 0 nil)))
|
||||
(is (= [2 0] (ch/value-at c 3 nil)))))
|
||||
|
||||
(deftest corrections-round-trip-with-identity-support-and-order
|
||||
(let [doc (fixture/document)
|
||||
one (:clip (correction/add doc :main :girl [:xform :rot]
|
||||
{:id :one :support [0 3] :motion :constant :delta 1}))
|
||||
two (:clip (correction/add one :main :girl [:xform :rot]
|
||||
{:id :two :support [3 6] :motion :ramp
|
||||
:start 0 :end 2}))
|
||||
back (leaf/clip "u" (leaf/leaves "u" two))]
|
||||
(is (= two back))
|
||||
(is (= [:one :two]
|
||||
(mapv :id (get-in back [:symbols :main :nodes :girl
|
||||
:channels [:xform :rot] :over]))))))
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
(ns arthur.events.lane-test
|
||||
(:require [cljs.test :refer [deftest is]]
|
||||
[arthur.domain.lane-test :as fixture]
|
||||
[arthur.domain.correction :as correction]
|
||||
[arthur.domain.lane :as lane]
|
||||
[arthur.events.ui :as ui]
|
||||
[arthur.domain.history :as history]
|
||||
|
|
@ -24,6 +25,8 @@
|
|||
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]]
|
||||
|
|
@ -65,3 +68,23 @@
|
|||
(is (= (:clip r1) (leaf/clip "u" (:leaves undo))))
|
||||
(is (= doc (leaf/clip "u" (:leaves undo2))))
|
||||
(is (= [:node :main :a [:a]] (get-in db2 [:ui :selection]))))))
|
||||
|
||||
(deftest a-correction-is-one-step-and-keeps-the-full-selection-address
|
||||
(let [doc (fixture/document)
|
||||
id (store/install! {:clip doc :store {}} "correction-event-test")
|
||||
selection [:node :main :a [:outer :a]]
|
||||
db {:clip/current id :paint/revision 0
|
||||
:ui {:open :outer :selection selection}}
|
||||
result (correction/add doc :main :a [:xform :rot]
|
||||
{:id :nudge :support [0 3] :motion :return
|
||||
:start 0 :peak 0.5 :peak-frame 1})
|
||||
after (ui/apply-correction-command db result)
|
||||
entry (store/entry id)]
|
||||
(is (= selection (get-in after [:ui :selection])))
|
||||
(is (= 1 (count (get-in entry [:history :done]))))
|
||||
(is (= 0.5 (get-in (:clip entry)
|
||||
[:symbols :main :nodes :a :channels [:xform :rot]
|
||||
:over 0 :values :keys 1])))
|
||||
(let [refused (ui/apply-correction-command after {:refused "nope"})]
|
||||
(is (= "nope" (get-in refused [:project :status])))
|
||||
(is (= 1 (count (get-in (store/entry id) [:history :done])))))))
|
||||
|
|
|
|||
|
|
@ -255,10 +255,54 @@ try {
|
|||
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 agree from timeline and cel sheet; no server writes');
|
||||
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' }));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue