diff --git a/docs/lane-model.md b/docs/lane-model.md index 00da7b8..4756a09 100644 --- a/docs/lane-model.md +++ b/docs/lane-model.md @@ -444,7 +444,7 @@ 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, place a drawing (new, reused or duplicated), make unique, split, -and extend hold. Each is one history step, and each refuses rather than +trim, move, blank 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. @@ -472,6 +472,32 @@ 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. +THE SHOT LENGTH IS AUTHORED, which is the decision the range commands forced. +`:frames` is the symbol's window — how long the shot IS — and the occupied +extent of its lanes is a different fact derived from the occurrences. A command +grows the window only when the caller says `:grow-symbol`, and never shrinks it: +blanking the end of a shot leaves a shot with empty frames at the end, because +that is a true statement about what somebody authored, and deriving the window +from the extent would make deleting the last drawing quietly shorten the film. +`finish` keeps the two numbers apart by name now rather than by a `max` that +read like an accident. + +Trim NARROWS one edge and moves nothing else; lengthening is `extend-hold`, +which carries the ripple and shot-length policies because it needs them. +Move is one write to `:time :at` and REFUSES a destination that would overlap, +because moving a drawing and re-timing the ones around it are different +intentions — clear the room with `blank` or `trim` first, which is the +composition. Blank leaves a gap and does not close it; an exposure wholly inside +the range goes, one overlapping an end is trimmed to it, and the one spanning +the range is split. Their drawings stay in the library, since a lane does not +own its content. + +All three are the same geometry as `split`: a `:span` is in the occurrence's own +frames, so moving an edge is one write and `:time` and `:playback` are never +touched. That is why trimming the front of a playing insert starts it later into +its animation instead of restarting it — the difference between trimming and +slipping, and the reason they stay separate commands. + Correction layers EVALUATE. `channel/problems` used to refuse an `:over` stack and `value-at`/`cursor` used to throw on one; both now read it, and the agreement test that holds the optimized cursor to the specification covers @@ -501,17 +527,23 @@ re-measurement for good; and an incompatible offset threw in the read path, which would have taken the stage down on exactly the case the model says to report. +Still unbuilt: overwrite as a placement policy (which is now `blank` then +`place`, composed inside one transaction), slip source, retime, and deleting +reused content. A lane cannot hold AUDIO occurrences — `sequence-problems` +requires visual ones, though this document says a lane may hold either and +should reject only a mixture. + What is NOT implemented is a command that produces a layer — the doc's Constant adjustment, Ramp and Return motion — and with it the question of how a view offers those three over a selected range, and how it offers a conflict for resolution. Overwrite, the range and retiming commands (blank, trim, move, slip source, retime) and the exposure-sheet view are also not implemented; a refusal is the current behavior where the model -demands an explicit choice nobody has made yet. The suite stands at 414 tests -and 5,696 assertions, with `frontend/test/browser/sequence.mjs` driving the +demands an explicit choice nobody has made yet. The suite stands at 424 tests +and 5,749 assertions, with `frontend/test/browser/sequence.mjs` driving the editor through create, hold, overflow, undo, reuse, make unique, duplicate, -split and insert. Rewrite tests that encode superseded behavior rather than -preserving behavior to keep them green. +split, insert, trim, move and blank. 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 8c3551a..3a14e6a 100644 --- a/frontend/src/arthur/domain/sequence.cljs +++ b/frontend/src/arthur/domain/sequence.cljs @@ -36,26 +36,60 @@ (recur (:parent n) (conj seen id) (conj chain n))))))) (defn- finish + "Commit `nodes` as symbol `sid`'s, or refuse. + + THE SHOT LENGTH IS AUTHORED. `:frames` is the symbol's window — how long the + shot IS — and the occupied extent of its lanes is a different fact derived + from the occurrences. A command may GROW the window when the caller says + `:grow-symbol`, and never shrinks it: emptying the end of a shot leaves a shot + with empty frames at the end, which is a true statement about what somebody + authored. Deriving the window from the extent instead would make deleting the + last drawing silently shorten the film. + + So there are two numbers and this function keeps them apart: `needed` is where + the occurrences reach, `:frames` is what was authored, and the only way the + second follows the first is a caller asking." [clip sid nodes selection extent] (let [sym (clip/symbol clip sid) - changed (for [[id n] nodes :when (node/sequence? n) - child (symbol/sequence-members nodes id) - :let [m (lane-map nodes id) - end (second (node/placed-span child))]] - (when m (+ (:at m) (/ end (:rate m))))) - end (apply max (:frames sym) (keep identity changed)) + reach (for [[id n] nodes :when (node/sequence? n) + child (symbol/sequence-members nodes id) + :let [m (lane-map nodes id) + end (second (node/placed-span child))]] + (when m (+ (:at m) (/ end (:rate m))))) + needed (js/Math.ceil (apply max 0 (keep identity reach))) ps (symbol/problems (assoc sym :nodes nodes))] (cond (seq ps) {:refused (first ps)} (not (#{:keep :grow-symbol} extent)) {:refused "choose an explicit shot-length policy"} - (and (> end (:frames sym)) (= :keep extent)) - {:refused (str "the edit needs " (js/Math.ceil end) " frames; extend the shot to continue") - :required-frames (js/Math.ceil end)} - :else {:clip (-> clip - (assoc-in [:symbols sid :nodes] nodes) - (assoc-in [:symbols sid :frames] (js/Math.ceil end))) + (and (> needed (:frames sym)) (= :keep extent)) + {:refused (str "the edit needs " needed " frames; extend the shot to continue") + :required-frames needed} + :else {:clip (cond-> (assoc-in clip [:symbols sid :nodes] nodes) + (> needed (:frames sym)) + (assoc-in [:symbols sid :frames] needed)) :selection selection}))) +;; --------------------------------------------------------------------------- +;; the geometry every exposure edit is made of +;; +;; A `:span` is in the occurrence's OWN frames and its `:time` says where those +;; land in the lane. So moving an edge of an exposure is one write to `:span`, +;; and `:time` and `:playback` are untouched — which is why trimming the front +;; of a playing insert starts it later in its source instead of resetting it, +;; and why the two halves of a split go on meaning what the one exposure meant. +;; Trim, split and blank are all this one operation, applied differently. + +(defn- local + "Lane frame `f` as one of `n`'s own frames." + [n f] + (let [{:keys [at rate]} (node/time-of n)] + (* rate (- f at)))) + +(defn- edged + "`n` with its `:in` or `:out` edge at lane frame `f`." + [n which f] + (assoc-in n [:span (case which :in 0 :out 1)] (local n f))) + (defn extend-hold "Change one held occurrence's duration by `delta` lane frames and ripple its later siblings. Lane channels, occurrence channels and source clocks stay put. @@ -116,13 +150,102 @@ (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))])))] + (let [nodes (-> nodes + (assoc id (edged n :out cut)) + (assoc new-id (assoc (edged n :in cut) + :id new-id :z (str "a-" new-id))))] (finish clip sid nodes new-id :keep))))) +(defn trim + "Move one edge of occurrence `id` to lane frame `to`, without disturbing a + single other occurrence. + + TRIM NARROWS. Lengthening an exposure is `extend-hold`, which carries a ripple + policy and a shot-length policy because it needs them; letting trim grow as + well would give one gesture two sets of rules and a way to overlap its + neighbour. `edge` is `:in` or `:out`. + + The source clock is untouched, so trimming the front of a playing insert + starts it later INTO its animation rather than restarting it — which is the + difference between trimming and slipping, and why they are separate commands." + [clip sid id edge to] + (let [nodes (get-in clip [:symbols sid :nodes]) + n (get nodes id) + lane (get nodes (:parent n)) + [lo hi] (or (node/placed-span n) [nil nil])] + (cond + (not (node/sequence? lane)) {:refused "select an occurrence in a sequence lane"} + (not (#{:in :out} edge)) {:refused "an edge is :in or :out"} + (not (integer? to)) {:refused "an edge goes to a whole lane frame"} + (not (and lo (< lo to hi))) + {:refused (str "frame " to " is not inside this exposure; trim narrows it")} + :else (finish clip sid (assoc nodes id (edged n edge to)) id :keep)))) + +(defn move + "Put occurrence `id` at lane frame `to`, leaving every other occurrence and + its own length, source and corrections alone. + + One write to `:time :at`. A destination that would overlap a neighbour is + REFUSED rather than rippled or overwritten: moving a drawing and re-timing the + ones around it are different intentions, and a move that silently pushed the + rest would be the second one wearing the first one's name. Clear the room + first — `blank` makes a gap, `trim` shortens a neighbour." + [clip sid id to] + (let [nodes (get-in clip [:symbols sid :nodes]) + n (get nodes id) + lane (get nodes (:parent n)) + {:keys [at]} (node/time-of n)] + (cond + (not (node/sequence? lane)) {:refused "select an occurrence in a sequence lane"} + (not (integer? to)) {:refused "an exposure moves to a whole lane frame"} + (nil? (node/placed-span n)) {:refused "an occurrence needs a span to move"} + :else + (let [moved (update-in n [:time :at] (fnil + 0) (- to (first (node/placed-span n))))] + (if (not= to (first (node/placed-span moved))) + {:refused "exposure timing through a stepped or looping lane is not supported"} + (finish clip sid (assoc nodes id moved) id :keep)))))) + +(defn blank + "Clear lane frames `[a b)` of lane `lane-id`, leaving a GAP. + + A gap is not a drawing. Nothing is invented to cover those frames and nothing + closes the hole — the occurrences after it stay where they are, because + emptying frames and re-timing a performance are different intentions. + + What it does to each exposure it meets is the edge geometry above: one wholly + inside is removed, one overlapping an end is trimmed to it, and the one that + spans the whole range is split, which is the only case that needs `id`. Their + drawings stay in the library — a lane does not own its content, and a drawing + whose last exposure is gone is still a drawing somebody made." + [clip sid lane-id [a b] {:keys [id]}] + (let [nodes (get-in clip [:symbols sid :nodes]) + lane (get nodes lane-id) + members (when (node/sequence? lane) (symbol/sequence-members nodes lane-id)) + spanning (when members + (first (filter #(let [[lo hi] (node/placed-span %)] (and (< lo a) (> hi b))) + members)))] + (cond + (not (node/sequence? lane)) {:refused "select a sequence lane"} + (not (and (integer? a) (integer? b) (< a b))) + {:refused "a range to blank is whole lane frames, and not empty"} + (and spanning (or (nil? id) (contains? nodes id))) + {:refused "blanking inside one exposure splits it, which needs a free ID for the remainder"} + :else + (let [nodes (reduce + (fn [ns n] + (let [[lo hi] (node/placed-span n)] + (cond + (or (<= hi a) (>= lo b)) ns + (and (< lo a) (> hi b)) + (-> ns + (assoc (:id n) (edged n :out a)) + (assoc id (assoc (edged n :in b) :id id :z (str "a-" id)))) + (and (>= lo a) (<= hi b)) (dissoc ns (:id n)) + (< lo a) (assoc ns (:id n) (edged n :out a)) + :else (assoc ns (:id n) (edged n :in b))))) + nodes members)] + (finish clip sid nodes (or (when spanning id) lane-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"} diff --git a/frontend/src/arthur/events/ui.cljs b/frontend/src/arthur/events/ui.cljs index 22db88d..68438c7 100644 --- a/frontend/src/arthur/events/ui.cljs +++ b/frontend/src/arthur/events/ui.cljs @@ -140,6 +140,49 @@ {:refused "this lane's frames are not the open symbol's"}) nil)))) +(defn- at-playhead + "The selected occurrence, its lane, and the playhead as a frame of that lane's + own time — or a refusal in place of the frame where there is no single one." + [db] + (let [clip (:clip (store/entry (:clip/current db))) + [_ sid id] (get-in db [:ui :selection]) + n (get-in clip [:symbols sid :nodes id])] + {:clip clip :sid sid :id id :node n + :at (sequence/lane-frame clip sid (:parent n) (get-in db [:playback :frame]))})) + +(rf/reg-event-db + ::trim-exposure + (fn [db [_ edge]] + (let [{:keys [clip sid id at]} (at-playhead db)] + (apply-sequence-command + db sid (if at + (sequence/trim clip sid id edge at) + {:refused "this lane's frames are not the open symbol's"}) + nil)))) + +(rf/reg-event-db + ::move-exposure + (fn [db _] + (let [{:keys [clip sid id at]} (at-playhead db)] + (apply-sequence-command + db sid (if at + (sequence/move clip sid id at) + {:refused "this lane's frames are not the open symbol's"}) + nil)))) + +(rf/reg-event-db + ::blank-exposure + ;; The selected exposure's own frames, so the range needs no second gesture and + ;; the case that would split an exposure cannot arise. + (fn [db _] + (let [{:keys [clip sid node]} (at-playhead db) + span (node/placed-span node)] + (apply-sequence-command + db sid (if (and span (every? integer? span)) + (sequence/blank clip sid (:parent node) span {}) + {:refused "select an exposure that starts and ends on whole lane frames"}) + 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 e484112..8906631 100644 --- a/frontend/src/arthur/ui/timeline.cljs +++ b/frontend/src/arthur/ui/timeline.cljs @@ -284,6 +284,18 @@ [:button {:disabled (not shared?) :title "give this exposure its own copy; other exposures keep sharing" :on-click #(rf/dispatch [::ui/make-unique])} "make unique"] + [:button {:disabled (not splittable?) + :title "start this exposure at the playhead; nothing else moves" + :on-click #(rf/dispatch [::ui/trim-exposure :in])} "trim in"] + [:button {:disabled (not splittable?) + :title "end this exposure at the playhead; nothing else moves" + :on-click #(rf/dispatch [::ui/trim-exposure :out])} "trim out"] + [:button {:disabled (not (and cel? (integer? at))) + :title "put this exposure at the playhead; refused if something is there" + :on-click #(rf/dispatch [::ui/move-exposure])} "move here"] + [:button {:disabled (not cel?) + :title "clear this exposure's frames, leaving a gap; later drawings stay put" + :on-click #(rf/dispatch [::ui/blank-exposure])} "blank"] [:button {:disabled (not held?) :title "shorten this exposure; ripple later drawings, keeping lane keys fixed" :on-click #(rf/dispatch [::ui/extend-hold -1])} "hold −"] diff --git a/frontend/test/arthur/domain/lane_test.cljs b/frontend/test/arthur/domain/lane_test.cljs index 8138088..5e526a8 100644 --- a/frontend/test/arthur/domain/lane_test.cljs +++ b/frontend/test/arthur/domain/lane_test.cljs @@ -484,3 +484,127 @@ (get-in moved [:symbols :main :nodes :b :channels])) "the layer itself was not touched by the retiming") (is (empty? (clip/problems moved))))) + +(defn- spans [clip ids] + (mapv #(node/placed-span (get-in clip [:symbols :main :nodes %])) ids)) + +(deftest trimming-narrows-one-exposure-and-moves-nothing-else + (let [doc (document) + r (sequence/trim doc :main :b :out 6) + after (:clip r)] + (is (= [[0 4] [4 6] [8 12]] (spans after [:a :b :insert]))) + (is (= :b (:selection r))) + (is (= (select-keys (get-in doc [:symbols :main :nodes :b]) [:time :playback :channels :source]) + (select-keys (get-in after [:symbols :main :nodes :b]) [:time :playback :channels :source])) + "only :span changed") + (is (= 12 (get-in after [:symbols :main :frames]))) + (is (empty? (clip/problems after))))) + +(deftest trimming-the-front-of-a-playing-insert-does-not-restart-it + ;; The difference between trimming and slipping. Its own frames are where they + ;; were, so the frames that survive show exactly what they showed. + (let [doc (document) + before (sample doc [10 11]) + after (:clip (sequence/trim doc :main :insert :in 10))] + (is (= [10 12] (node/placed-span (get-in after [:symbols :main :nodes :insert])))) + (is (= (:playback (get-in doc [:symbols :main :nodes :insert])) + (:playback (get-in after [:symbols :main :nodes :insert])))) + (is (= before (sample after [10 11])) "the same animation on the frames it kept") + ;; And the frames it gave up show nothing of it. + (is (= #{:plate} (set (keys (get (sample after [9]) 9))))))) + +(deftest trim-refuses-to-lengthen-or-to-land-on-an-edge + (let [doc (document)] + (doseq [[label edge to] [["at its own start" :in 4] + ["at its own end" :out 8] + ["past its end" :out 9] + ["before its start" :in 2] + ["off a whole frame" :out 5.5]]] + (is (:refused (sequence/trim doc :main :b edge to)) label)) + (is (:refused (sequence/trim doc :main :b :middle 6))) + (is (:refused (sequence/trim doc :main :girl :out 6)) "a lane is not an exposure"))) + +(deftest moving-an-exposure-keeps-its-length-and-its-source-origin + (let [doc (update-in (document) [:symbols :main :nodes] dissoc :b) + r (sequence/move doc :main :insert 4) + after (:clip r)] + (is (= [[0 4] [4 8]] (spans after [:a :insert]))) + (is (= :insert (:selection r))) + (is (= (:playback (get-in doc [:symbols :main :nodes :insert])) + (:playback (get-in after [:symbols :main :nodes :insert])))) + ;; It began on source frame 3 at lane 8; it begins on source frame 3 at lane 4. + (is (= (get-in (sample doc [8]) [8 [:insert :mark]]) + (get-in (sample after [4]) [4 [:insert :mark]]))) + (is (empty? (clip/problems after))))) + +(deftest a-move-onto-an-occupied-frame-is-refused-rather-than-rippled + (let [doc (document)] + (is (:refused (sequence/move doc :main :insert 6)) "it would overlap B") + (is (:refused (sequence/move doc :main :insert 4.5))) + (is (:refused (sequence/move doc :main :girl 2))) + ;; Clearing the room first is the composition, and then it goes. + (let [cleared (:clip (sequence/blank doc :main :girl [4 8] {}))] + (is (= [[0 4] [4 8]] (spans (:clip (sequence/move cleared :main :insert 4)) + [:a :insert])))))) + +(deftest blanking-leaves-a-gap-and-does-not-close-it + (let [doc (document) + r (sequence/blank doc :main :girl [5 7] {:id :rest}) + after (:clip r)] + ;; B spanned the range, so it became two exposures with a hole between them. + (is (= [[0 4] [4 5] [7 8] [8 12]] (spans after [:a :b :rest :insert]))) + (is (= :rest (:selection r))) + (let [at (sample after [4 5 6 7])] + (is (= #{:plate} (set (keys (at 5)))) "nothing is drawn on a blanked frame") + (is (= #{:plate} (set (keys (at 6))))) + (is (get-in at [4 [:b :mark]])) + (is (get-in at [7 [:rest :mark]]))) + (is (= 12 (get-in after [:symbols :main :frames]))) + (is (empty? (clip/problems after))))) + +(deftest blanking-a-whole-exposure-removes-it-and-keeps-its-drawing + (let [doc (document) + after (:clip (sequence/blank doc :main :girl [4 8] {}))] + (is (nil? (get-in after [:symbols :main :nodes :b]))) + (is (= [[0 4] [8 12]] (spans after [:a :insert])) "and moves nothing") + (is (= (get-in doc [:symbols :drawing-b]) (get-in after [:symbols :drawing-b])) + "a lane does not own its content") + (is (empty? (clip/problems after))))) + +(deftest blanking-a-range-trims-what-it-only-partly-covers + (let [doc (document) + after (:clip (sequence/blank doc :main :girl [3 9] {}))] + (is (= [[0 3] [9 12]] (spans after [:a :insert]))) + (is (nil? (get-in after [:symbols :main :nodes :b]))) + (is (= (get-in (sample doc [9]) [9 [:insert :mark]]) + (get-in (sample after [9]) [9 [:insert :mark]])) + "the insert kept its own frames, so frame 9 shows what it showed") + (is (empty? (clip/problems after))))) + +(deftest blank-refuses-what-it-cannot-do-in-one-piece + (let [doc (document)] + (is (re-find #"free ID" (:refused (sequence/blank doc :main :girl [5 7] {}))) + "splitting an exposure needs an ID for the remainder") + (is (:refused (sequence/blank doc :main :girl [5 7] {:id :a})) "and a free one") + (is (:refused (sequence/blank doc :main :girl [7 5] {}))) + (is (:refused (sequence/blank doc :main :girl [5 5] {}))) + (is (:refused (sequence/blank doc :main :girl [5 6.5] {}))) + (is (:refused (sequence/blank doc :main :plate [0 2] {}))))) + +(deftest the-shot-length-is-authored-and-emptying-a-lane-does-not-shorten-it + ;; The window and the occupied extent are two facts. A shot with nothing in + ;; the last half is a shot somebody authored that long, and deleting the last + ;; drawing must not quietly shorten the film. + (let [doc (document) + empty-lane (:clip (sequence/blank doc :main :girl [0 12] {}))] + (is (empty? (symbol/sequence-members (get-in empty-lane [:symbols :main :nodes]) :girl))) + (is (= 12 (get-in empty-lane [:symbols :main :frames]))) + (is (empty? (clip/problems empty-lane))) + ;; Growing is still the caller's word, and only ever grows. + (is (:refused (sequence/append-drawing empty-lane :main :girl :n :drawing-n {:at 20}))) + (is (= 21 (get-in (sequence/append-drawing empty-lane :main :girl :n :drawing-n + {:at 20 :extent :grow-symbol}) + [:clip :symbols :main :frames]))) + (is (= 12 (get-in (:clip (sequence/trim doc :main :insert :out 9)) + [:symbols :main :frames])) + "and trimming the last exposure leaves the window where it was"))) diff --git a/frontend/test/browser/sequence.mjs b/frontend/test/browser/sequence.mjs index d397031..48b1644 100644 --- a/frontend/test/browser/sequence.mjs +++ b/frontend/test/browser/sequence.mjs @@ -174,8 +174,31 @@ try { 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); + + // Trim, move and blank: three gestures that move nothing but their own + // exposure, 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 exposure 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); 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, split and insert; no server writes'); + console.log('PASS: create lane/drawings, one-row cels, hold ripple, seek, explicit overflow, atomic undo, reuse/make unique/duplicate, split, insert, trim, move and blank; no server writes'); } finally { if (ws?.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ id: 999999, method: 'Browser.close' }));