From 26517af2fd864eab5d1d9b0e59a8a3028888c36d Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 30 Sep 2026 15:56:26 -0400 Subject: [PATCH] A position is an argument, not another command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything could only be added to the end, because `append` computed its own position — the max end of the lane — and so had no opinion to state. Insert is not a new command; it is the argument that function was missing. `:at` takes a lane frame or `:end`, `:end` is the position where nothing has to move, and appending stops being a separate operation from inserting. New, reused and duplicated drawings all take it, because there was only ever one placement rule. Placing ripples: occurrences at or after the position move later by the new exposure's duration, and `:keep` against `:grow-symbol` still decides what happens at the shot's end. OVERWRITE is deliberately not a policy argument yet. Taking frames away from the occurrence already there is TRIMMING, and an argument whose second value is unimplemented is worse than an argument that is not there. A position strictly inside an existing exposure refuses and names `split`, rather than splitting on the quiet: one command performing two is how a command stops being predictable. Then split, which turned out to cost almost nothing, and that is the interesting part. The two pieces keep ONE `:time` and differ only in `:span`. The right piece's own frames therefore carry on exactly where the left's stopped, so its source clock, its keys and its corrections go on meaning what they meant: a held drawing holds the same frame either side of the cut, and a playing insert plays through it without a seam. There is no arithmetic on in-points to get wrong, and no shot-length question, since the pieces occupy the frames the one exposure occupied. The test samples every frame before and after and asserts the picture is identical — for a hold, for an exposure with a correction of its own, and for a playing insert. That is not a clever split. It is `:span` being in the node's OWN coordinates, which was decided long before there were lanes, paying for something it was not designed for. The same property is why extending a hold leaves lane keys alone. Both new commands act at the playhead, which needed `lane-frame` — the symbol's frame as a frame of the lane's own time, nil through a stepped or looping lane where one is not the other. Nil refuses; it does not snap to a nearby frame. Two smaller things found while doing it. `placeable` promised "a whole lane frame" in its refusal and then accepted 2.5, so both it and `split` now require an integer, as `extend-hold` already did for its delta. And `lane-end` is private: `:end` is the only way to ask for it. 401 tests, 5,612 assertions. The browser flow now splits an exposure at the playhead and puts a drawing in the gap, and checks that six exposures are still one row. Co-Authored-By: Claude Opus 5 --- docs/lane-model.md | 48 ++++++--- frontend/src/arthur/domain/sequence.cljs | 119 +++++++++++++++++---- frontend/src/arthur/events/ui.cljs | 36 ++++++- frontend/src/arthur/ui/timeline.cljs | 15 ++- frontend/test/arthur/domain/lane_test.cljs | 93 ++++++++++++++++ frontend/test/browser/sequence.mjs | 27 ++++- 6 files changed, 296 insertions(+), 42 deletions(-) diff --git a/docs/lane-model.md b/docs/lane-model.md index 8fc978a..718502c 100644 --- a/docs/lane-model.md +++ b/docs/lane-model.md @@ -1,9 +1,9 @@ # The Lane Model Revised 2026-09-30. Target design. Occurrence ownership, source playback, the -content and exposure commands and a one-row cel strip are implemented; -correction layers, the range and retiming commands, and the remaining views are -not. See the status note under +content and exposure commands, placement anywhere in a lane, and a one-row cel +strip are implemented; correction layers, overwrite and the retiming commands, +and the remaining views are not. See the status note under [Proof obligations](#proof-obligations-and-implementation-order). This revises the Claude artifact [The Lane Model](https://claude.ai/code/artifact/cd42981d-ed08-493f-94df-b7dd6657f0e6). @@ -442,23 +442,43 @@ The source-channel prototype has been removed: an occurrence names one symbol and carries its own playback clock, and `node/problems` rejects the old `[:source]` channel. What a lane IS lives in `arthur.domain.symbol` beside the other rules about a node map; `arthur.domain.sequence` holds the commands over -one — add lane, append drawing, reuse drawing, duplicate drawing, make unique, -and extend hold with explicit ripple and shot-length policy. Each is one history -step, and each refuses rather than half-applying. The timeline draws a lane's -occurrences as cel blocks on the lane's own row, and offers Make unique only -where the selected exposure actually shares its drawing. +one — add lane, place a drawing (new, reused or duplicated), make unique, split, +and extend hold. Each is one history step, and each refuses rather than +half-applying. The timeline draws a lane's occurrences as cel blocks on the +lane's own row, and offers Make unique only where the selected exposure actually +shares its drawing. + +There is ONE placement function and a position argument, so appending is not a +different operation from inserting: `:end` is a position like any other, the one +where nothing has to move. Placing ripples — occurrences at or after the +position move later by the new exposure's duration — and `:keep` versus +`:grow-symbol` still decides what happens at the shot's end. OVERWRITE is not a +policy argument yet, deliberately: taking frames away from the occurrence +already there is trimming, and until `trim` exists, placement that would need it +refuses instead of approximating it. A position inside an existing exposure +refuses too, and names `split` — one command does not quietly perform two. + +Splitting turned out to cost almost nothing, which is evidence for the +representation rather than for the command. The two pieces keep ONE `:time` and +differ only in `:span`, so the right piece's own frames carry on where the +left's stopped and its source clock, keys and corrections go on meaning what +they meant — a held drawing holds the same frame either side, a playing insert +plays through the cut without a seam, and the test for it samples every frame +before and after and asserts the picture is identical. That falls out of `:span` +being in the node's own coordinates; it is not something split arranges. Content copies are shallow by default and keep their references to other symbols; `:deep? true` is the explicit copy that shares nothing, so the promise of independence is only made where it is kept. -Correction layers, the range and retiming commands (blank, split, trim, move, -slip source, retime) and the exposure-sheet view are not implemented; a refusal -is the current behavior where the model demands an explicit choice nobody has -made yet. The suite stands at 397 tests and 5,561 assertions, with +Correction layers, overwrite, and the range and retiming commands (blank, trim, +move, slip source, retime) and the exposure-sheet view are not implemented; a +refusal is the current behavior where the model demands an explicit choice +nobody has made yet. The suite stands at 401 tests and 5,612 assertions, with `frontend/test/browser/sequence.mjs` driving the editor through create, hold, -overflow, undo, reuse, make unique and duplicate. Rewrite tests that encode -superseded behavior rather than preserving behavior to keep them green. +overflow, undo, reuse, make unique, duplicate, split and insert. Rewrite tests +that encode superseded behavior rather than preserving behavior to keep them +green. Build small adversarial documents and test their domain operations before expanding the interface: diff --git a/frontend/src/arthur/domain/sequence.cljs b/frontend/src/arthur/domain/sequence.cljs index 238db40..8c3551a 100644 --- a/frontend/src/arthur/domain/sequence.cljs +++ b/frontend/src/arthur/domain/sequence.cljs @@ -88,6 +88,41 @@ nodes later)] (finish clip sid nodes id extent))))) +(defn split + "Cut occurrence `id` in two at lane frame `cut`. The left piece keeps its + identity; the right gets `new-id`. + + NOTHING BUT `:span` DIFFERS between the two pieces. They keep one `:time`, so + the right piece's own frames carry on exactly where the left's stopped, and its + source clock, its keys and its corrections therefore go on meaning what they + meant before the cut — preserved by construction rather than by arithmetic on + in-points that could be wrong. A held drawing holds the same frame on both + sides; a playing insert plays on through the cut without a seam. That is what + `:span` being in the node's OWN coordinates buys, and it is why splitting + needs no shot-length policy: the pieces occupy the frames the one exposure + occupied. + + The right piece is the selection, because it is the piece that was made." + [clip sid id cut new-id] + (let [nodes (get-in clip [:symbols sid :nodes]) + n (get nodes id) + lane (get nodes (:parent n)) + {:keys [at rate]} (node/time-of n) + [lo hi] (or (node/placed-span n) [nil nil])] + (cond + (not (node/sequence? lane)) {:refused "select an occurrence in a sequence lane"} + (not (integer? cut)) {:refused "a cut is a whole lane frame"} + (contains? nodes new-id) {:refused "the new occurrence ID is already used"} + (not (and lo (< lo cut hi))) + {:refused (str "frame " cut " is not inside this exposure")} + :else + (let [local (* rate (- cut at)) + nodes (-> nodes + (assoc-in [id :span 1] local) + (assoc new-id (assoc n :id new-id :z (str "a-" new-id) + :span [local (second (:span n))])))] + (finish clip sid nodes new-id :keep))))) + (defn add-lane [clip sid id] (if (or (nil? (clip/symbol clip sid)) (get-in clip [:symbols sid :nodes id])) {:refused "the symbol is missing or the lane ID is already used"} @@ -111,27 +146,65 @@ :span [0 1] :time {:at at :rate 1} :source {:symbol drawing-id} :playback {:in 0 :speed 0 :end :stop}}) -(defn- append - "Put a held occurrence of `drawing-id` after everything already in `lane-id`. - `:frame` in the result is where it lands, for a caller that wants to look at - what it just made." - [clip sid lane-id id drawing-id extent] +(defn lane-frame + "Symbol frame `f` as a frame of lane `lane-id`'s OWN time, or nil through a + stepped or looping lane, where one frame of the symbol is not one frame of the + lane and there is no single answer to give a command." + [clip sid lane-id f] + (when-let [{:keys [at rate]} (lane-map (get-in clip [:symbols sid :nodes]) lane-id)] + (* rate (- f at)))) + +(defn- lane-end + "Where lane `lane-id`'s occupied frames stop, in its own time." + [nodes lane-id] + (apply max 0 (map #(second (node/placed-span %)) + (symbol/sequence-members nodes lane-id)))) + +(defn- place + "Put a held occurrence of `drawing-id` into `lane-id` at lane frame `at`, and + RIPPLE: everything starting at or after it moves later by its duration. + + There is one placement function and `:end` is a position like any other, so + appending is not a different operation from inserting — the end is just where + nothing has to move. Overwriting is the other policy and is NOT this: taking + frames away from the occurrence already there is trimming, which is its own + command and not something placing a drawing should do on the quiet. + + `:frame` in the result is where it landed, in the open symbol's time, for a + caller that wants to look at what it just made." + [clip sid lane-id id drawing-id at extent] (let [nodes (get-in clip [:symbols sid :nodes]) - at (apply max 0 (map #(second (node/placed-span %)) - (symbol/sequence-members nodes lane-id))) - result (finish clip sid (assoc nodes id (held id lane-id drawing-id at)) id extent) + at (if (= :end at) (lane-end nodes lane-id) at) + n (held id lane-id drawing-id at) + [lo hi] (node/placed-span n) + later (filter #(>= (first (node/placed-span %)) lo) + (symbol/sequence-members nodes lane-id)) + nodes (reduce (fn [ns sibling] + (update-in ns [(:id sibling) :time :at] (fnil + 0) (- hi lo))) + (assoc nodes id n) later) + result (finish clip sid nodes id extent) m (lane-map nodes lane-id)] (cond-> result (:clip result) (assoc :frame (+ (:at m) (/ at (:rate m))))))) -(defn- appendable - "Why a held occurrence cannot go into `lane-id`, or nil." - [clip sid lane-id id] +(defn- placeable + "Why a held occurrence cannot go into `lane-id` at `at`, or nil." + [clip sid lane-id id at] (let [nodes (get-in clip [:symbols sid :nodes]) - lane (get nodes lane-id)] + lane (get nodes lane-id) + ;; INSIDE an exposure is not a position for another one. Splitting that + ;; exposure is what makes it two, and doing it here would be one command + ;; quietly performing two: the caller asks for `split` and then places. + inside (when (number? at) + (some (fn [n] (let [[lo hi] (node/placed-span n)] + (when (< lo at hi) n))) + (symbol/sequence-members nodes lane-id)))] (cond (not (node/sequence? lane)) "select a sequence lane" (contains? nodes id) "the new occurrence ID is already used" + (not (or (= :end at) (and (integer? at) (not (neg? at))))) + "a position is :end or a whole lane frame" + inside (str "frame " at " is inside an exposure; split it first") (nil? (lane-map nodes lane-id)) "drawing creation through a stepped or looping lane is not supported" :else (first (symbol/sequence-problems nodes))))) @@ -142,13 +215,13 @@ Fresh content, not a blank range: a lane with no occurrence over a frame shows nothing there already, and a drawing nobody has drawn in is a different thing from a gap." - [clip sid lane-id id drawing-id {:keys [extent] :or {extent :keep}}] - (if-let [why (or (appendable clip sid lane-id id) + [clip sid lane-id id drawing-id {:keys [at extent] :or {extent :keep at :end}}] + (if-let [why (or (placeable clip sid lane-id id at) (when (clip/symbol clip drawing-id) "the new drawing ID is already used"))] {:refused why} - (append (assoc-in clip [:symbols drawing-id] - {:id drawing-id :name (name drawing-id) :frames 1 :nodes {}}) - sid lane-id id drawing-id extent))) + (place (assoc-in clip [:symbols drawing-id] + {:id drawing-id :name (name drawing-id) :frames 1 :nodes {}}) + sid lane-id id drawing-id at extent))) (defn reuse-drawing "Append a held occurrence of content the document ALREADY has, so the same @@ -157,15 +230,15 @@ This is the command `make-unique` is the undo of, and the reason they are two commands: reuse is a decision to share, and sharing is not something to discover later when an edit turns up somewhere else." - [clip sid lane-id id drawing-id {:keys [extent] :or {extent :keep}}] - (if-let [why (or (appendable clip sid lane-id id) + [clip sid lane-id id drawing-id {:keys [at extent] :or {extent :keep at :end}}] + (if-let [why (or (placeable clip sid lane-id id at) (when-not (clip/symbol clip drawing-id) "there is no such drawing to reuse") ;; Placing something that contains this symbol would close a ;; loop, and a lane is no different from any other placement. (when (clip/contains-symbol? clip drawing-id sid) "a symbol cannot go inside itself"))] {:refused why} - (append clip sid lane-id id drawing-id extent))) + (place clip sid lane-id id drawing-id at extent))) (defn- copied "A copy of symbol `from`, as `{:clip :id}`. @@ -192,15 +265,15 @@ rather than a copy of `id`'s own transform or corrections: those belong to that exposure, and carrying them over would make duplicating a drawing quietly duplicate the treatment of one use of it." - [clip sid id new-id {:keys [extent deep?] :or {extent :keep}}] + [clip sid id new-id {:keys [at extent deep?] :or {extent :keep at :end}}] (let [n (get-in clip [:symbols sid :nodes id]) from (node/source n)] (if-let [why (or (when-not from "select an occurrence to duplicate") (when-not (clip/symbol clip from) "the drawing it places is missing") - (appendable clip sid (:parent n) new-id))] + (placeable clip sid (:parent n) new-id at))] {:refused why} (let [{c :clip copy :id} (copied clip from deep?)] - (append c sid (:parent n) new-id copy extent))))) + (place c sid (:parent n) new-id copy at extent))))) (defn make-unique "Point occurrence `id` at a private copy of its content, leaving every other diff --git a/frontend/src/arthur/events/ui.cljs b/frontend/src/arthur/events/ui.cljs index e2360e3..22db88d 100644 --- a/frontend/src/arthur/events/ui.cljs +++ b/frontend/src/arthur/events/ui.cljs @@ -61,9 +61,10 @@ (defn- committed "One appending command, as effects: commit it, and look at what it made. - Seeking is the whole reason these are `-fx` events. A new exposure lands after - everything already in the lane, which is usually off the playhead, and a - drawing you cannot see is not one you can draw in." + Seeking is the whole reason these are `-fx` events. An appended exposure lands + past the end of the lane, off the playhead, and a drawing you cannot see is not + one you can draw in. An inserted one is already under the playhead and the + seek is a no-op, which is the same rule and not a second one." [db sid result retry] (let [{clip :clip st :store} (store/entry (:clip/current db)) path (nth (get-in db [:ui :selection]) 3 nil) @@ -110,6 +111,35 @@ {:extent (or extent :keep) :deep? deep?})] (committed db sid result [::duplicate-drawing :grow-symbol deep?])))) +(rf/reg-event-fx + ::insert-drawing + ;; The playhead is the position: you scrub to where the drawing goes. A lane + ;; that is stepped or retimed off whole frames has no single lane frame for a + ;; symbol frame, and `lane-frame` says so rather than snapping to one. + (fn [{:keys [db]} [_ extent]] + (let [clip (:clip (store/entry (:clip/current db))) + [_ sid id] (get-in db [:ui :selection]) + lane (selected-lane clip sid id) + at (sequence/lane-frame clip sid lane (get-in db [:playback :frame])) + result (if at + (sequence/append-drawing clip sid lane (random-uuid) (clip/fresh-id clip) + {:at at :extent (or extent :keep)}) + {:refused "this lane's frames are not the open symbol's"})] + (committed db sid result [::insert-drawing :grow-symbol])))) + +(rf/reg-event-db + ::split-exposure + (fn [db _] + (let [clip (:clip (store/entry (:clip/current db))) + [_ sid id] (get-in db [:ui :selection]) + cut (sequence/lane-frame clip sid (:parent (get-in clip [:symbols sid :nodes id])) + (get-in db [:playback :frame]))] + (apply-sequence-command + db sid (if cut + (sequence/split clip sid id cut (random-uuid)) + {:refused "this lane's frames are not the open symbol's"}) + nil)))) + (rf/reg-event-db ::make-unique (fn [db [_ deep?]] diff --git a/frontend/src/arthur/ui/timeline.cljs b/frontend/src/arthur/ui/timeline.cljs index bf34ecb..e484112 100644 --- a/frontend/src/arthur/ui/timeline.cljs +++ b/frontend/src/arthur/ui/timeline.cljs @@ -23,6 +23,7 @@ (:require [clojure.string :as str] [arthur.domain.node :as node] [arthur.domain.nest :as nest] + [arthur.domain.sequence :as sequence] [arthur.domain.symbol :as symbol] [arthur.domain.trace :as trace] [arthur.events.playback :as pb] @@ -236,7 +237,13 @@ shared? (and cel? (< 1 (count (for [[_ sym] (:symbols clip) [_ other] (:nodes sym) :when (= (node/source n) (node/source other))] - other))))] + other)))) + ;; Both act at the playhead, so both are offered only where the playhead + ;; is somewhere they mean something. + at (when lane? (sequence/lane-frame clip sid (:id lane) frame)) + insertable? (and lane? (integer? at)) + splittable? (and cel? (integer? at) + (let [[lo hi] (node/placed-span n)] (< lo at hi)))] [:div.pane-head [:button {:on-click #(rf/dispatch [::pb/toggle])} (if playing? "pause" "play")] [:button {:on-click #(rf/dispatch [::pb/seek 0])} "|<"] @@ -262,6 +269,12 @@ [:button {:disabled (not lane?) :title "append a new independent drawing to the selected lane" :on-click #(rf/dispatch [::ui/append-drawing])} "new drawing"] + [:button {:disabled (not insertable?) + :title "a new drawing at the playhead; later drawings ripple later" + :on-click #(rf/dispatch [::ui/insert-drawing])} "insert"] + [:button {:disabled (not splittable?) + :title "cut this exposure in two at the playhead; the picture does not change" + :on-click #(rf/dispatch [::ui/split-exposure])} "split"] [:button {:disabled (not cel?) :title "expose this same drawing again — one drawing, two exposures" :on-click #(rf/dispatch [::ui/reuse-drawing])} "reuse"] diff --git a/frontend/test/arthur/domain/lane_test.cljs b/frontend/test/arthur/domain/lane_test.cljs index bd585b1..d089add 100644 --- a/frontend/test/arthur/domain/lane_test.cljs +++ b/frontend/test/arthur/domain/lane_test.cljs @@ -334,3 +334,96 @@ (is (= [0 10 20 30 40 50] (mapv (fn [f] (let [[[id _] cx] (showing f)] (- cx (xs id)))) (range 6))) "and the lane moves on every frame, odd ones included"))) + +(defn- drawn + "What every frame draws, as sorted values, so a picture can be compared + without naming the occurrences that produced it." + [doc fs] + (let [at (sample doc fs)] + (mapv #(sort (vals (get at %))) fs))) + +(deftest a-drawing-goes-anywhere-in-the-lane-and-ripples-what-follows + (let [doc (document) + keys-of #(get-in % [:symbols :main :nodes :girl :channels [:xform :pos] :keys]) + spans #(mapv (fn [id] (node/placed-span (get-in % [:symbols :main :nodes id]))) + [:a :n :b :insert]) + r (sequence/append-drawing doc :main :girl :n :drawing-n + {:at 4 :extent :grow-symbol})] + (is (= [[0 4] [4 5] [5 9] [9 13]] (spans (:clip r)))) + (is (= 13 (get-in r [:clip :symbols :main :frames]))) + (is (= (keys-of doc) (keys-of (:clip r))) "lane keys stay where they were authored") + (is (= :n (:selection r))) + (is (= 4 (:frame r))) + (is (empty? (clip/problems (:clip r)))) + ;; The same command with no room refuses, and says how much it needs. + (is (= 13 (:required-frames (sequence/append-drawing doc :main :girl :n :drawing-n {:at 4})))) + ;; At the very front everything moves. + (is (= [[1 5] [0 1] [5 9] [9 13]] + (spans (:clip (sequence/append-drawing doc :main :girl :n :drawing-n + {:at 0 :extent :grow-symbol}))))) + ;; Inside an exposure is not a position for another one. + (is (re-find #"split it first" + (:refused (sequence/append-drawing doc :main :girl :n :drawing-n + {:at 2 :extent :grow-symbol})))) + (is (:refused (sequence/append-drawing doc :main :girl :n :drawing-n + {:at -1 :extent :grow-symbol}))) + (is (:refused (sequence/append-drawing doc :main :girl :n :drawing-n + {:at ##Inf :extent :grow-symbol}))) + ;; Reuse and duplicate take a position too; it is one placement rule. + (is (= [4 5] (node/placed-span + (get-in (sequence/reuse-drawing doc :main :girl :n :drawing-b + {:at 4 :extent :grow-symbol}) + [:clip :symbols :main :nodes :n])))) + (is (= [4 5] (node/placed-span + (get-in (sequence/duplicate-drawing doc :main :b :n + {:at 4 :extent :grow-symbol}) + [:clip :symbols :main :nodes :n])))))) + +(deftest splitting-an-exposure-changes-nothing-that-is-drawn + (let [doc (document) + fs (range 12) + before (drawn doc fs)] + (doseq [[label id cut] [["a held drawing" :a 2] + ["an exposure with a correction of its own" :b 6] + ["a playing insert" :insert 10]]] + (testing label + (let [r (sequence/split doc :main id cut :right) + after (:clip r)] + (is (= :right (:selection r))) + (is (= before (drawn after fs)) "the same picture, frame for frame") + (is (= (node/placed-span (get-in doc [:symbols :main :nodes id])) + [(first (node/placed-span (get-in after [:symbols :main :nodes id]))) + (second (node/placed-span (get-in after [:symbols :main :nodes :right])))]) + "the pieces occupy the frames the exposure did") + (is (= cut (second (node/placed-span (get-in after [:symbols :main :nodes id]))) + (first (node/placed-span (get-in after [:symbols :main :nodes :right]))))) + (is (= (:time (get-in doc [:symbols :main :nodes id])) + (:time (get-in after [:symbols :main :nodes :right]))) + "one time map, so the right piece's own frames carry on") + (is (= (select-keys (get-in doc [:symbols :main :nodes id]) [:source :playback :channels]) + (select-keys (get-in after [:symbols :main :nodes :right]) [:source :playback :channels]))) + (is (= 12 (get-in after [:symbols :main :frames])) "and no shot-length question") + (is (empty? (clip/problems after)))))))) + +(deftest split-refuses-anything-that-is-not-one-cut-inside-one-exposure + (let [doc (document)] + (doseq [cut [0 4 8 12 -1 2.5 ##NaN nil]] + (is (:refused (sequence/split doc :main :b cut :right)) (str "cut at " (pr-str cut)))) + (is (:refused (sequence/split doc :main :girl 2 :right)) "a lane is not an exposure") + (is (:refused (sequence/split doc :main :plate 2 :right)) "nor is a shape outside one") + (is (:refused (sequence/split doc :main :a 2 :b)) "the new ID has to be free"))) + +(deftest split-then-place-puts-a-drawing-inside-a-hold + ;; The two commands the doc asks for, composed: neither one guesses. + (let [doc (document) + cut (:clip (sequence/split doc :main :a 2 :right)) + r (sequence/append-drawing cut :main :girl :n :drawing-n + {:at 2 :extent :grow-symbol}) + after (:clip r)] + (is (= [[0 2] [2 3] [3 5] [5 9] [9 13]] + (mapv #(node/placed-span (get-in after [:symbols :main :nodes %])) + [:a :n :right :b :insert]))) + (is (= (get-in doc [:symbols :main :nodes :girl :channels]) + (get-in after [:symbols :main :nodes :girl :channels])) + "the performance is still timed the way it was authored") + (is (empty? (clip/problems after))))) diff --git a/frontend/test/browser/sequence.mjs b/frontend/test/browser/sequence.mjs index 06203ed..d397031 100644 --- a/frontend/test/browser/sequence.mjs +++ b/frontend/test/browser/sequence.mjs @@ -149,8 +149,33 @@ try { 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); 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; no server writes'); + console.log('PASS: create lane/drawings, one-row cels, hold ripple, seek, explicit overflow, atomic undo, reuse/make unique/duplicate, split and insert; no server writes'); } finally { if (ws?.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ id: 999999, method: 'Browser.close' }));