An occurrence is a node, with a clock of its own
A lane's drawings were going to be one instance whose source was a KEYED
channel: frame 0 says `:drawing-a`, frame 4 says `:drawing-b`, and the cels of
a row are that channel's keys. Two things followed from it, and both were
wrong.
The first is that playback meant whichever shape the channel happened to have.
A framed source played its symbol; a keyed source froze the selected frame.
So `node/placed-at` read animation out of storage, and adding an ordinary key
to a still turned it into an animation — the last-key bug, which was not a bug
in the code so much as the rule working as written. But WHICH drawing is used
and HOW time runs inside it are independent questions, and all four combinations
are ordinary: hold one drawing, play one animation, cut between held drawings,
cut between playing ones.
So an occurrence names one symbol in `:source {:symbol ...}` and says how its
source time advances in `:playback {:in :speed :end}` — `source = in + speed *
f`, a hold being speed 0, with `:stop`, `:hold` or `:loop` at the end named
rather than guessed. `node/placed-frame` samples it forwards, which works for
holds too, and `node/source-time` is the separate, invertible edit map, nil
where inversion is meaningless. The two were one function before, and a hold
had to lie about one of them.
The second is that a keyed source only looked necessary because an occurrence
was assumed to need a ROW. It does not. A lane is a group with `:layout
:sequence`, its occurrences are ordinary instances in the same flat node map,
and `timeline/rows` draws them as cel blocks on the lane's own row: twelve
exposures, one row, each cel still separately selectable and addressable. The
vertical growth that justified the keyed source is a presentation question, and
it is answered in the view.
`arthur.domain.sequence` holds the first commands over that shape — add lane,
append drawing, extend hold — each one history step, each refusing rather than
half-applying. Extending a hold leaves the lane's keys at their authored times,
because you are adjusting drawings underneath timed motion; a correction owned
by an occurrence travels with it. Ownership does that work, so no key needs a
flag saying what it follows. Ripple past the symbol's end is refused with the
frame count it would need, and `:extent :grow-symbol` is the caller saying yes.
`clip/blank` no longer carries `:subjects {} :features {} :groups {}`. Empty
maps write no leaf, so a blank document could not survive its own round trip —
`leaf/leaves` promises exactness and was the only honest side of that.
Documents are schema 3. A version 2 document is not read; nothing here converts
one. `docs/lane-model.md` is the design, and says which of its parts are built.
392 tests, 5,525 assertions, and `test/browser/sequence.mjs` drives the editor
through create, hold, explicit overflow and undo.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
624242b407
commit
3d3c1bbca0
38 changed files with 1564 additions and 213 deletions
|
|
@ -1,7 +1,8 @@
|
|||
(ns arthur.domain.bring-test
|
||||
(:require [cljs.test :refer [deftest is]]
|
||||
[arthur.domain.bring :as bring]
|
||||
[arthur.domain.clip :as clip]))
|
||||
[arthur.domain.clip :as clip]
|
||||
[arthur.domain.node :as node]))
|
||||
|
||||
(defn- nested
|
||||
"Three symbols: :outer places :inner, and :loose is placed by nothing."
|
||||
|
|
@ -21,6 +22,6 @@
|
|||
(is (= {:main :take :inner :inner-2} ids)
|
||||
"the root gets the name asked for; a taken id gets the next free one")
|
||||
(is (= 10 (clip/frames clip :inner)) "what was already here is untouched")
|
||||
(is (= :inner-2 (:of (first (vals (get-in clip [:symbols :take :nodes])))))
|
||||
(is (= #{:inner-2} (node/sources (first (vals (get-in clip [:symbols :take :nodes])))))
|
||||
"and the copy's instance follows its renamed symbol")
|
||||
(is (empty? (clip/problems clip)))))
|
||||
|
|
|
|||
|
|
@ -27,12 +27,14 @@
|
|||
(assoc-in [:symbols :main]
|
||||
{:id :main :frames 6
|
||||
:nodes {:root {:id :root :kind :group :z "a1"}
|
||||
:left {:id :left :kind :instance :of :sym/test
|
||||
:left {:id :left :kind :instance
|
||||
:parent :root :z "a1" :span [0 4]
|
||||
:source {:symbol :sym/test}
|
||||
:channels {[:xform :pos] (ch/framed [100 50])}}
|
||||
:right {:id :right :kind :instance :of :sym/test
|
||||
:right {:id :right :kind :instance
|
||||
:parent :root :z "a2" :span [0 4]
|
||||
:time {:mode :map :at 2 :rate 1}
|
||||
:source {:symbol :sym/test}
|
||||
:channels {[:xform :pos] (ch/framed [120 50])}}}})
|
||||
(assoc-in [:symbols :sym/test]
|
||||
(assoc (get-in source [:symbols :main]) :id :sym/test)))
|
||||
|
|
@ -66,13 +68,15 @@
|
|||
:symbols
|
||||
{:main {:id :main :frames 30
|
||||
:nodes {:root {:id :root :kind :group :z "a1"}
|
||||
:first {:id :first :kind :instance :of :sym/poses
|
||||
:first {:id :first :kind :instance
|
||||
:parent :root :z "a1"
|
||||
:source {:symbol :sym/poses}
|
||||
:playback {:tracks {:mouth {0 0, 8 20, 9 21}
|
||||
[:node :mouth-detail] {0 0, 8 4}
|
||||
:eye {0 0, 4 4}}}}
|
||||
:second {:id :second :kind :instance :of :sym/poses
|
||||
:second {:id :second :kind :instance
|
||||
:parent :root :z "a2"
|
||||
:source {:symbol :sym/poses}
|
||||
:playback {:tracks {:mouth {0 0, 8 8}}}}}}
|
||||
:sym/poses symbol}}
|
||||
store {"sizes" {:data values}}
|
||||
|
|
@ -109,8 +113,9 @@
|
|||
(deftest stage-pose-edits-preserve-earlier-motion-and-survive-save
|
||||
(let [document (-> source
|
||||
(assoc-in [:symbols :main :nodes :placed]
|
||||
{:id :placed :kind :instance :of :sym/test :parent :root
|
||||
:z "a2"})
|
||||
{:id :placed :kind :instance :parent :root
|
||||
:z "a2"
|
||||
:source {:symbol :sym/test}})
|
||||
(assoc-in [:symbols :sym/test]
|
||||
{:id :sym/test :frames 4
|
||||
:nodes {:root {:id :root :kind :group :z "a1"}
|
||||
|
|
@ -153,8 +158,8 @@
|
|||
(let [document (stage/compose source)]
|
||||
(is (empty? (clip/problems document)))
|
||||
(is (= #{:main :sym/face-8625} (set (keys (:symbols document)))))
|
||||
(is (= :sym/face-8625 (:of (placement document :left))))
|
||||
(is (= :sym/face-8625 (:of (placement document :right))))
|
||||
(is (= #{:sym/face-8625} (node/sources (placement document :left))))
|
||||
(is (= #{:sym/face-8625} (node/sources (placement document :right))))
|
||||
(testing "every placement is keyed by its own uuid"
|
||||
;; The identity change: seven placements of one drawing are seven things,
|
||||
;; and each is named by something that means only itself. Sharing a key, or
|
||||
|
|
@ -165,7 +170,7 @@
|
|||
(is (every? uuid? (map key symbols)))
|
||||
(is (= 7 (count (distinct (map key symbols)))))
|
||||
(testing "and each still says which drawing it plays and what to call it"
|
||||
(is (every? #(= :sym/face-8625 (:of (val %))) symbols))
|
||||
(is (every? #(= #{:sym/face-8625} (node/sources (val %))) symbols))
|
||||
(is (every? #(string? (:name (val %))) symbols))
|
||||
(is (= 7 (count (distinct (map #(:name (val %)) symbols))))))))
|
||||
(is (= 7 (count (filter #(= :instance (:kind %))
|
||||
|
|
@ -221,7 +226,7 @@
|
|||
(is (= :main (clip/opens-on (clip/blank)))))
|
||||
(testing "an instance can go into any symbol, and spans that symbol's frames"
|
||||
(let [[n] (vals (get-in c [:symbols :outer :nodes]))]
|
||||
(is (= :inner (:of n)))
|
||||
(is (= #{:inner} (node/sources n)))
|
||||
(is (= [0 10] (:span n)) "its own frames: all of what it places, from its own 0")
|
||||
(is (= [5 15] (node/placed-span n)) "and where that lands in the symbol it is in")))
|
||||
(testing "placing is refused when it would make a cycle"
|
||||
|
|
@ -246,14 +251,17 @@
|
|||
(is (= {:id :symbol-1 :name "symbol-1" :frames 180 :nodes {}}
|
||||
(clip/symbol made :symbol-1))
|
||||
"empty, and as long as the rest of what it was placed in")
|
||||
(is (= {:of :symbol-1 :span [0 180] :time {:mode :map :at 20 :rate 1}}
|
||||
(select-keys (get-in made [:symbols :outer :nodes u]) [:of :span :time])))
|
||||
(is (= {:span [0 180] :time {:mode :map :at 20 :rate 1}}
|
||||
(select-keys (get-in made [:symbols :outer :nodes u]) [:span :time])))
|
||||
(is (= #{:symbol-1} (node/sources (get-in made [:symbols :outer :nodes u])))
|
||||
"and it places the symbol it just made")
|
||||
(is (empty? (clip/problems made)))
|
||||
(is (= c (clip/new-symbol c :outer :inner 0 u)) "an id already in use is refused")
|
||||
(is (= c (clip/new-symbol c :outer id 200 u)) "past the end is refused")))
|
||||
|
||||
(deftest an-instance-span-is-in-its-own-frames
|
||||
(let [n {:id :i :kind :instance :of :x :z "a1" :span [3 13]
|
||||
(let [n {:id :i :kind :instance :z "a1" :span [3 13]
|
||||
:source {:symbol :x}
|
||||
:time {:mode :map :at 40 :rate 2}}]
|
||||
(is (= [41.5 46.5] (node/placed-span n)) "own frames 3 to 13, at double rate, from 40")
|
||||
(is (= 0 (node/local-frame n 40)) "the parent's :at is where its own frame 0 lands")
|
||||
|
|
|
|||
238
frontend/test/arthur/domain/lane_test.cljs
Normal file
238
frontend/test/arthur/domain/lane_test.cljs
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
(ns arthur.domain.lane-test
|
||||
(:require [cljs.test :refer [deftest is testing]]
|
||||
[arthur.domain.bring :as bring]
|
||||
[arthur.domain.channel :as ch]
|
||||
[arthur.domain.clip :as clip]
|
||||
[arthur.domain.history :as history]
|
||||
[arthur.domain.leaf :as leaf]
|
||||
[arthur.domain.nest :as nest]
|
||||
[arthur.domain.node :as node]
|
||||
[arthur.domain.palette :as pal]
|
||||
[arthur.domain.pick :as pick]
|
||||
[arthur.domain.sequence :as sequence]
|
||||
[arthur.domain.symbol :as symbol]))
|
||||
|
||||
(defn drawing [id x frames]
|
||||
{:id id :frames frames
|
||||
:nodes {:mark {:id :mark :kind :rect :z "a"
|
||||
:channels {[:geom :size] (ch/framed 4)
|
||||
[:xform :pos] (ch/framed [x 0])}}}})
|
||||
|
||||
(defn occurrence [id source at duration speed]
|
||||
{:id id :kind :instance :parent :girl :z (name id)
|
||||
:source {:symbol source} :playback {:in 0 :speed speed :end :stop}
|
||||
:time {:at at :rate 1} :span [0 duration]})
|
||||
|
||||
(defn document []
|
||||
(let [a (occurrence :a :drawing-a 0 4 0)
|
||||
b (assoc-in (occurrence :b :drawing-b 4 4 0)
|
||||
[:channels [:xform :pos]] (ch/keyed {0 [0 0] 1 [2 0]} :hold))
|
||||
insert (assoc-in (occurrence :insert :wave 8 4 1) [:playback :in] 3)]
|
||||
{:name "exposures" :fps 24 :width 320 :height 200
|
||||
:symbols
|
||||
{:main {:id :main :frames 12
|
||||
:nodes {:girl {:id :girl :kind :group :layout :sequence :z "b"
|
||||
:channels {[:xform :pos] (ch/keyed {0 [0 0] 6 [60 0] 12 [0 0]} :linear)}}
|
||||
:a a :b b :insert insert
|
||||
:plate {:id :plate :kind :rect :z "a"
|
||||
:channels {[:geom :size] (ch/framed 10)
|
||||
[:xform :pos] (ch/keyed {0 [-40 0] 11 [70 0]} :linear)}}}}
|
||||
:drawing-a (drawing :drawing-a 10 1)
|
||||
:drawing-b (drawing :drawing-b 20 1)
|
||||
:wave (assoc-in (drawing :wave 0 10) [:nodes :mark :channels [:xform :pos]]
|
||||
(ch/keyed {0 [0 0] 9 [900 0]} :linear))}}))
|
||||
|
||||
(defn sample [doc fs]
|
||||
(let [r (clip/resolver doc :main nil pal/index-of nil)]
|
||||
(into {} (map (fn [f] [f (into {} (map (juxt :node :cx)) (r f))])) fs)))
|
||||
|
||||
(deftest one-lane-mixes-held-drawings-and-playing-content
|
||||
(let [doc (document) at (sample doc (range 12))]
|
||||
(is (empty? (clip/problems doc)))
|
||||
(is (= 40 (get-in at [3 [:a :mark]])))
|
||||
(is (= 60 (get-in at [4 [:b :mark]])))
|
||||
(is (= 72 (get-in at [5 [:b :mark]])))
|
||||
(is (= 340 (get-in at [8 [:insert :mark]])))
|
||||
(is (= 610 (get-in at [11 [:insert :mark]])))
|
||||
(is (= (zipmap (range 12) (range -40 80 10))
|
||||
(into {} (map (fn [[f ops]] [f (js/Math.round (:plate ops))])) at)))
|
||||
(is (nil? (get-in at [4 [:a :mark]])) "half-open cuts have a single owner")))
|
||||
|
||||
(deftest exposure-ripple-keeps-lane-keys-and-moves-occurrence-corrections
|
||||
(let [doc (document)
|
||||
result (sequence/extend-hold doc :main :a 2 {:extent :grow-symbol})
|
||||
after (:clip result)
|
||||
nodes (get-in after [:symbols :main :nodes])]
|
||||
(is (= :a (:selection result)))
|
||||
(is (= 14 (get-in after [:symbols :main :frames])))
|
||||
(is (= [0 6] (node/placed-span (:a nodes))))
|
||||
(is (= [6 10] (node/placed-span (:b nodes))))
|
||||
(is (= [10 14] (node/placed-span (:insert nodes))))
|
||||
(doseq [id [:girl :a :b :insert :plate]]
|
||||
(is (= (get-in doc [:symbols :main :nodes id :channels]) (:channels (nodes id))))
|
||||
(is (= (get-in doc [:symbols :main :nodes id :playback]) (:playback (nodes id)))))
|
||||
(let [at (sample after [5 6 7 10])]
|
||||
(is (= 60 (get-in at [5 [:a :mark]])))
|
||||
(is (= 80 (get-in at [6 [:b :mark]])))
|
||||
(is (= 72 (get-in at [7 [:b :mark]])) "B's correction follows B")
|
||||
(is (= 320 (get-in at [10 [:insert :mark]])) "insert starts on source frame 3"))
|
||||
(is (empty? (clip/problems after)))
|
||||
(is (= (assoc-in doc [:symbols :main :frames] 14)
|
||||
(:clip (sequence/extend-hold after :main :a -2 {})))
|
||||
"shrinking restores content, except the explicitly grown shot")))
|
||||
|
||||
(deftest overflow-and-invalid-edits-are-atomic
|
||||
(let [doc (document)
|
||||
result (sequence/extend-hold doc :main :a 2 {})]
|
||||
(is (:refused result))
|
||||
(is (= 14 (:required-frames result)))
|
||||
(is (not (contains? result :clip)))
|
||||
(doseq [delta [0 -4 0.5 js/NaN]]
|
||||
(is (:refused (sequence/extend-hold doc :main :a delta {}))))
|
||||
(is (:refused (sequence/extend-hold doc :main :insert 1 {})))
|
||||
(is (:refused (sequence/extend-hold doc :main :missing 1 {})))))
|
||||
|
||||
(deftest a-gap-is-an-uncovered-interval
|
||||
(let [doc (update-in (document) [:symbols :main :nodes] dissoc :b)
|
||||
at (sample doc [3 4 7 8])]
|
||||
(is (= #{:plate} (set (keys (at 4)))))
|
||||
(is (= #{:plate} (set (keys (at 7)))))
|
||||
(is (get-in at [8 [:insert :mark]]))
|
||||
(is (empty? (clip/problems doc)))))
|
||||
|
||||
(deftest validation-rejects-overlap-but-allows-empty-lanes
|
||||
(is (some #(re-find #"overlap" %)
|
||||
(clip/problems (assoc-in (document) [:symbols :main :nodes :b :time :at] 3))))
|
||||
(is (empty? (clip/problems
|
||||
(update-in (document) [:symbols :main :nodes] dissoc :a :b :insert))))
|
||||
(is (seq (clip/problems
|
||||
(assoc-in (document) [:symbols :main :nodes :a :span] [0 ##Inf]))))
|
||||
(is (seq (clip/problems
|
||||
(assoc-in (document) [:symbols :main :nodes :a :playback :speed] -1))))
|
||||
(is (seq (node/problems {:id :old :kind :instance :z "a"
|
||||
:channels {[:source] (ch/framed {:of :wave :in 0})}}))
|
||||
"the obsolete format is rejected"))
|
||||
|
||||
(deftest playback-is-independent-of-property-channel-shape
|
||||
(let [doc (document)
|
||||
n (get-in doc [:symbols :main :nodes :a])
|
||||
keyed (node/toggle-key n [:xform :rot] 0 nil)
|
||||
unkeyed (node/toggle-key keyed [:xform :rot] 0 nil)]
|
||||
(doseq [n [n keyed unkeyed]]
|
||||
(is (= {:symbol :drawing-a :frame 0} (node/placed-frame n 11 1))))
|
||||
(let [n (get-in doc [:symbols :main :nodes :insert])]
|
||||
(is (= {:symbol :wave :frame 5} (node/placed-frame n 2 10)))
|
||||
(is (nil? (node/placed-frame n 7 10)))
|
||||
(is (= 9 (:frame (node/placed-frame (assoc-in n [:playback :end] :hold) 9 10))))
|
||||
(is (= 2 (:frame (node/placed-frame (assoc-in n [:playback :end] :loop) 9 10)))))))
|
||||
|
||||
(deftest navigation-and-hit-testing-use-the-same-source-time
|
||||
(let [doc (document)
|
||||
n (get-in doc [:symbols :main :nodes :insert])]
|
||||
(is (= 5 (:frame (nest/inside doc nil :main [:insert] 10))))
|
||||
(is (= {:at 5 :rate 1} (:time (nest/inside doc nil :main [:insert] 10))))
|
||||
(is (= 0 (:frame (nest/inside doc nil :main [:a] 3))))
|
||||
(is (nil? (:time (nest/inside doc nil :main [:a] 3))))
|
||||
(is (nil? (nest/inside doc nil :main [:a] 4)))
|
||||
(is (= ((pick/bounds-of doc nil n) 2)
|
||||
((pick/bounds-of doc nil (assoc-in n [:playback :in] 5)) 0)))))
|
||||
|
||||
(deftest seeking-and-source-reuse-do-not-share-cursors
|
||||
(let [doc (assoc-in (document) [:symbols :main :nodes :b :source :symbol] :drawing-a)
|
||||
fs [11 0 5 3 8 4 10 1 6 2 9 7]
|
||||
at (sample doc fs)]
|
||||
(is (= at (sample doc (reverse fs))))
|
||||
(is (= at (sample doc (range 12))))
|
||||
(let [edited (assoc-in doc [:symbols :drawing-a :nodes :mark :channels [:xform :pos]]
|
||||
(ch/framed [99 0]))]
|
||||
(is (= 99 (get-in (sample edited [0 4]) [0 [:a :mark]])))
|
||||
(is (= 139 (get-in (sample edited [0 4]) [4 [:b :mark]]))))))
|
||||
|
||||
(deftest occurrence-identities-and-playback-round-trip
|
||||
(let [doc (:clip (sequence/extend-hold (document) :main :a 2 {:extent :grow-symbol}))
|
||||
leaves (leaf/leaves :project doc)]
|
||||
(is (= doc (leaf/clip :project leaves)))
|
||||
(is (contains? leaves "clip/project/symbol/main/node/a"))
|
||||
(is (not (contains? leaves "clip/project/symbol/main/channel/girl/source")))
|
||||
(let [{copied :clip ids :ids}
|
||||
(bring/symbols (assoc-in (clip/blank) [:symbols :drawing-a] (drawing :drawing-a 99 1))
|
||||
doc [:main] {})]
|
||||
(is (= :drawing-a-2 (:drawing-a ids)))
|
||||
(is (= #{:drawing-a-2 :drawing-b :wave} (clip/places copied (:main ids))))
|
||||
(is (empty? (clip/problems copied))))))
|
||||
|
||||
(deftest one-transaction-undoes-the-ripple-and-shot-extension
|
||||
(let [doc (document)
|
||||
after (:clip (sequence/extend-hold doc :main :a 2 {:extent :grow-symbol}))
|
||||
before-leaves (leaf/leaves :p doc)
|
||||
after-leaves (leaf/leaves :p after)
|
||||
h (-> nil history/hold (history/record before-leaves after-leaves 0) history/settle)
|
||||
undo (history/undo h after-leaves)
|
||||
redo (history/redo (:history undo) (:leaves undo))]
|
||||
(is (= 1 (count (:done h))))
|
||||
(is (= before-leaves (:leaves undo)))
|
||||
(is (= after-leaves (:leaves redo)))))
|
||||
|
||||
(deftest create-lane-and-append-drawings
|
||||
(let [doc (:clip (sequence/add-lane (clip/blank) :main :girl))
|
||||
a (:clip (sequence/append-drawing doc :main :girl :a :drawing-a {}))
|
||||
b (:clip (sequence/append-drawing a :main :girl :b :drawing-b {}))]
|
||||
(is (empty? (clip/problems b)))
|
||||
(is (= [1 2] (node/placed-span (get-in b [:symbols :main :nodes :b]))))
|
||||
(is (= 0 (get-in b [:symbols :main :nodes :b :playback :speed])))
|
||||
(is (:refused (sequence/append-drawing b :main :girl :a :new {})))))
|
||||
|
||||
(deftest fractional-placement-rates-convert-the-hold-delta
|
||||
(let [doc (-> (document)
|
||||
(assoc-in [:symbols :main :nodes :a :time :rate] 2)
|
||||
(assoc-in [:symbols :main :nodes :a :span] [0 8]))
|
||||
after (:clip (sequence/extend-hold doc :main :a 2 {:extent :grow-symbol}))]
|
||||
(is (= [0 12] (get-in after [:symbols :main :nodes :a :span])))
|
||||
(is (= [6 10] (node/placed-span (get-in after [:symbols :main :nodes :b]))))))
|
||||
|
||||
(deftest bare-shapes-agree-in-reference-and-playback
|
||||
(let [sym (drawing :bare 12 1)]
|
||||
(is (= (symbol/eval-frame sym 0 nil pal/index-of nil)
|
||||
((symbol/resolver sym nil pal/index-of nil) 0))
|
||||
"omitted style colour must not crash a missing cursor")))
|
||||
|
||||
(deftest audio-follows-only-the-playing-occurrence
|
||||
(let [voice {:id :voice :kind :audio :z "a" :source {:sound "voice"}
|
||||
:span [0 10]
|
||||
:channels {[:audio :gain] (ch/keyed {0 0 5 1} :linear)}}
|
||||
doc (-> (document)
|
||||
(assoc-in [:symbols :wave :nodes :voice] voice)
|
||||
(assoc-in [:symbols :drawing-a :nodes :voice] voice))
|
||||
[track :as tracks] (nest/audio-tracks doc :main)]
|
||||
(is (= 1 (count tracks)) "the frozen drawing contributes no audio")
|
||||
(is (= [8 12] (node/placed-span track)))
|
||||
(is (= [3 7] (:span track)) "the source in-point trims the audio too")
|
||||
(is (= {5 0 10 1} (get-in track [:channels [:audio :gain] :keys])))
|
||||
(let [moved (:clip (sequence/extend-hold doc :main :a 2 {:extent :grow-symbol}))
|
||||
[track] (nest/audio-tracks moved :main)]
|
||||
(is (= [10 14] (node/placed-span track)))
|
||||
(is (= [3 7] (:span track))))
|
||||
(let [fast (-> doc
|
||||
(assoc-in [:symbols :main :nodes :girl :time] {:at 2 :rate 2})
|
||||
(assoc-in [:symbols :main :nodes :insert :playback :speed] 2))
|
||||
[track] (nest/audio-tracks fast :main)]
|
||||
(is (= [6 7.75] (node/placed-span track)))
|
||||
(is (= [3 10] (:span track)))
|
||||
(is (= 4 (get-in track [:time :rate]))))))
|
||||
|
||||
(deftest a-looped-insert-schedules-distinct-audio-intervals
|
||||
(let [doc (-> (document)
|
||||
(assoc-in [:symbols :wave :frames] 4)
|
||||
(assoc-in [:symbols :wave :nodes :voice]
|
||||
{:id :voice :kind :audio :z "a" :source {:sound "v"} :span [1 3]})
|
||||
(assoc-in [:symbols :main :nodes :insert :playback]
|
||||
{:in 3 :speed 1 :end :loop}))]
|
||||
(is (= [[10 12]] (mapv node/placed-span (nest/audio-tracks doc :main))))))
|
||||
|
||||
(deftest enclosing-retiming-is-respected-when-extending-the-shot
|
||||
(let [doc (assoc-in (document) [:symbols :main :nodes :girl :time] {:at 8 :rate 2})
|
||||
result (sequence/extend-hold doc :main :a 2 {})]
|
||||
(is (= 15 (:required-frames result)))
|
||||
(is (nil? (:clip result)))
|
||||
(is (= 15 (get-in (sequence/extend-hold doc :main :a 2 {:extent :grow-symbol})
|
||||
[:clip :symbols :main :frames])))))
|
||||
|
|
@ -34,7 +34,12 @@
|
|||
;; No node leaves, and still `:nodes {}`: nil there is what `symbol/nodes-of`
|
||||
;; refuses, so a saved blank document would not open.
|
||||
(is (= (get-in (clip/blank) [:symbols :main])
|
||||
(get-in (leaf/clip :c1 (leaf/leaves :c1 (clip/blank))) [:symbols :main]))))
|
||||
(get-in (leaf/clip :c1 (leaf/leaves :c1 (clip/blank))) [:symbols :main])))
|
||||
;; And the WHOLE blank document, not only its symbol. An empty field that the
|
||||
;; codec cannot write is an empty field it cannot restore, so a blank document
|
||||
;; carrying one comes back unequal to itself — which undo, whose steps are
|
||||
;; leaves, then reports as a document change nobody made.
|
||||
(is (= (clip/blank) (leaf/clip :c1 (leaf/leaves :c1 (clip/blank))))))
|
||||
|
||||
(deftest the-leaves-are-the-paths-the-sync-design-names
|
||||
(let [ls (leaf/leaves :c7 @take/clip)]
|
||||
|
|
@ -115,8 +120,9 @@
|
|||
;; right in a log and resolves nothing: `:linked-to` dangles and an export target
|
||||
;; matches no node, with no error anywhere.
|
||||
(let [u #uuid "8f594d72-a97f-4a32-82fd-08d1670a2218"
|
||||
c (one-symbol {u {:id u :kind :instance :of :sym/face-8625 :parent nil
|
||||
:z "a1" :name "8625 bottom left"}})
|
||||
c (one-symbol {u {:id u :kind :instance :parent nil
|
||||
:z "a1" :name "8625 bottom left"
|
||||
:source {:symbol :sym/face-8625}}})
|
||||
ls (leaf/leaves :c1 c)]
|
||||
(is (contains? ls (str "clip/c1/symbol/main/node/" u))
|
||||
"written plainly, with no sigil")
|
||||
|
|
|
|||
|
|
@ -93,8 +93,8 @@
|
|||
[t] (nest/audio-tracks c :main)]
|
||||
(is (= [10 40] (:span t)) "the same frames of the source")
|
||||
(is (= [50 80] (node/placed-span t)) "starting where the instance starts")
|
||||
(is (= #{50 55} (set (keys (get-in t [:channels [:audio :gain] :keys]))))
|
||||
"with its automation moved along")
|
||||
(is (= #{40 45} (set (keys (get-in t [:channels [:audio :gain] :keys]))))
|
||||
"automation is in the sound's own clock, including its source offset")
|
||||
(testing "and cut off where the instance's own span ends"
|
||||
(let [c (assoc-in c [:symbols :main :nodes #uuid "00000000-0000-4000-8000-0000000000bb" :span] [0 12])
|
||||
[t] (nest/audio-tracks c :main)]
|
||||
|
|
|
|||
|
|
@ -74,7 +74,8 @@
|
|||
[]
|
||||
(assoc-in @frozen [:symbols :wrap]
|
||||
{:id :wrap :frames 200
|
||||
:nodes {:m {:id :m :kind :instance :of :main :z "a0"
|
||||
:nodes {:m {:id :m :kind :instance :z "a0"
|
||||
:source {:symbol :main}
|
||||
:channels {[:xform :pos] {:animated? false :value [30 -10]}}}}}))
|
||||
|
||||
(deftest a-face-switched-on-shows-wherever-it-is-placed
|
||||
|
|
|
|||
|
|
@ -60,12 +60,14 @@
|
|||
:nodes {:root {:id :root :kind :group :z "a1"}
|
||||
#uuid "22222222-2222-4222-8222-222222222222"
|
||||
{:id #uuid "22222222-2222-4222-8222-222222222222"
|
||||
:kind :instance :of :sym/face :parent :root :z "a2"
|
||||
:name "8625 right"}
|
||||
:kind :instance :parent :root :z "a2"
|
||||
:name "8625 right"
|
||||
:source {:symbol :sym/face}}
|
||||
#uuid "11111111-1111-4111-8111-111111111111"
|
||||
{:id #uuid "11111111-1111-4111-8111-111111111111"
|
||||
:kind :instance :of :sym/face :parent :root :z "a1"
|
||||
:name "8625 left"}
|
||||
:kind :instance :parent :root :z "a1"
|
||||
:name "8625 left"
|
||||
:source {:symbol :sym/face}}
|
||||
:a-rect {:id :a-rect :kind :rect :parent :root :z "a3"}}}
|
||||
:sym/face {:frames 40 :nodes {:root {:id :root :kind :group :z "a1"}}}}})
|
||||
|
||||
|
|
|
|||
42
frontend/test/arthur/events/sequence_test.cljs
Normal file
42
frontend/test/arthur/events/sequence_test.cljs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
(ns arthur.events.sequence-test
|
||||
(:require [cljs.test :refer [deftest is]]
|
||||
[arthur.domain.lane-test :as fixture]
|
||||
[arthur.domain.sequence :as sequence]
|
||||
[arthur.events.ui :as ui]
|
||||
[arthur.domain.history :as history]
|
||||
[arthur.domain.leaf :as leaf]
|
||||
[arthur.footage.store :as store]
|
||||
[arthur.ui.timeline :as timeline]))
|
||||
|
||||
(deftest one-row-projects-all-occurrences-and-keeps-selection-addresses
|
||||
(let [doc (fixture/document)
|
||||
rows (timeline/rows doc :main #{})
|
||||
lane (first (filter :cels rows))]
|
||||
(is (= 2 (count rows)))
|
||||
(is (= [[0 4] [4 8] [8 12]] (mapv :span (:cels lane))))
|
||||
(is (= [[:node :main :a [:a]] [:node :main :b [:b]] [:node :main :insert [:insert]]]
|
||||
(mapv :select (:cels lane))))
|
||||
(is (= [0 6 12] (:keys lane)))
|
||||
(is (= 1 (count (filter :cels (timeline/rows doc :main #{[:girl]})))))))
|
||||
|
||||
(deftest sequence-commands-use-isolated-history-transactions
|
||||
(let [doc (fixture/document)
|
||||
id (store/install! {:clip doc :store {}} "sequence-test")
|
||||
db {:clip/current id :paint/revision 0
|
||||
:ui {:open :main :selection [:node :main :a [:a]]}}
|
||||
refused (ui/apply-sequence-command db :main
|
||||
(sequence/extend-hold doc :main :a 1 {}) [:retry])]
|
||||
(is (= doc (:clip (store/entry id))))
|
||||
(is (nil? (:history (store/entry id))))
|
||||
(is (= [:retry] (get-in refused [:ui :sequence-retry])))
|
||||
(let [r1 (sequence/extend-hold doc :main :a 1 {:extent :grow-symbol})
|
||||
db1 (ui/apply-sequence-command db :main r1 nil)
|
||||
r2 (sequence/extend-hold (:clip r1) :main :a 1 {:extent :grow-symbol})
|
||||
db2 (ui/apply-sequence-command db1 :main r2 nil)
|
||||
h (:history (store/entry id))
|
||||
undo (history/undo h (leaf/leaves "u" (:clip r2)))
|
||||
undo2 (history/undo (:history undo) (:leaves undo))]
|
||||
(is (= 2 (count (:done h))) "rapid button presses remain separate commands")
|
||||
(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]))))))
|
||||
|
|
@ -222,10 +222,12 @@
|
|||
{:main
|
||||
{:frames 12
|
||||
:nodes (cond-> {:root {:id :root :kind :group :z "a1"}
|
||||
p1 {:id p1 :kind :instance :of :sym/face :parent :root :z "a1"
|
||||
:name "left" :channels {[:xform :pos] (ch/framed [0 0])}}
|
||||
p2 {:id p2 :kind :instance :of :sym/face :parent :root :z "a2"
|
||||
:name "right" :channels {[:xform :pos] (ch/framed [4 0])}}
|
||||
p1 {:id p1 :kind :instance :parent :root :z "a1"
|
||||
:name "left" :source {:symbol :sym/face}
|
||||
:channels {[:xform :pos] (ch/framed [0 0])}}
|
||||
p2 {:id p2 :kind :instance :parent :root :z "a2"
|
||||
:name "right" :source {:symbol :sym/face}
|
||||
:channels {[:xform :pos] (ch/framed [4 0])}}
|
||||
:loose (assoc (poly :loose "a4" [0 0 1 0 1 1] :brow)
|
||||
:parent :root)}
|
||||
voice? (assoc v1 {:id v1 :kind :audio :parent :root :z "a3"
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@
|
|||
|
||||
(deftest the-tree-is-the-one-the-model-specifies
|
||||
(is (= [:face :root] (symbol/lineage (:nodes (clip/symbol @clip* :main)) :face)))
|
||||
(is (= :face-1 (get-in @clip* [:symbols :main :nodes :face-1 :of])))
|
||||
(is (= #{:face-1} (node/sources (get-in @clip* [:symbols :main :nodes :face-1]))))
|
||||
(is (= [:head] (symbol/lineage (:nodes @sym*) :head)))
|
||||
(is (= [:mouth :head] (symbol/lineage (:nodes @sym*) :mouth)))
|
||||
(is (= [:mouth-in :mouth :head] (symbol/lineage (:nodes @sym*) :mouth-in)))
|
||||
|
|
|
|||
131
frontend/test/browser/sequence.mjs
Normal file
131
frontend/test/browser/sequence.mjs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
// Local editor smoke test. Uses the in-memory blank document and disables the
|
||||
// project route, so it never creates an account, project, or server-side write.
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const url = process.env.ARTHUR_URL ?? 'http://localhost:8778/';
|
||||
const profile = mkdtempSync(join(tmpdir(), 'arthur-sequence-'));
|
||||
const port = 9335;
|
||||
const chrome = spawn(process.env.CHROME ?? '/usr/bin/chromium', [
|
||||
'--headless=new', '--no-sandbox', '--disable-gpu', '--no-first-run',
|
||||
'--no-default-browser-check', '--mute-audio', '--window-size=1440,1000',
|
||||
`--user-data-dir=${profile}`, `--remote-debugging-port=${port}`, url,
|
||||
], { stdio: 'ignore' });
|
||||
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
let ws;
|
||||
try {
|
||||
let target;
|
||||
for (let i = 0; i < 100 && !target; i++) {
|
||||
await sleep(100);
|
||||
try {
|
||||
target = (await fetch(`http://127.0.0.1:${port}/json/list`).then(r => r.json()))
|
||||
.find(t => t.type === 'page' && t.url.startsWith(url));
|
||||
} catch { /* browser starting */ }
|
||||
}
|
||||
assert(target, 'browser exposes the editor page');
|
||||
ws = new WebSocket(target.webSocketDebuggerUrl);
|
||||
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; });
|
||||
let serial = 0;
|
||||
const pending = new Map();
|
||||
const errors = [];
|
||||
ws.onmessage = ({ data }) => {
|
||||
const msg = JSON.parse(data);
|
||||
if (msg.method === 'Runtime.exceptionThrown') errors.push(msg.params.exceptionDetails);
|
||||
if (msg.id && pending.has(msg.id)) {
|
||||
const { resolve, reject } = pending.get(msg.id);
|
||||
pending.delete(msg.id);
|
||||
if (msg.error) reject(new Error(JSON.stringify(msg.error)));
|
||||
else resolve(msg.result);
|
||||
}
|
||||
};
|
||||
const send = (method, params = {}) => new Promise((resolve, reject) => {
|
||||
const id = ++serial;
|
||||
pending.set(id, { resolve, reject });
|
||||
ws.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
const evaluate = async expression => {
|
||||
const r = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
|
||||
if (r.exceptionDetails) throw new Error(JSON.stringify(r.exceptionDetails));
|
||||
return r.result.value;
|
||||
};
|
||||
await send('Runtime.enable');
|
||||
for (let i = 0; i < 100; i++) {
|
||||
if (await evaluate('typeof arthur !== "undefined" && !!arthur.events?.ui && !!document.querySelector("canvas.stage")')) break;
|
||||
await sleep(100);
|
||||
}
|
||||
await evaluate(`(() => {
|
||||
const k = cljs.core.keyword;
|
||||
cljs.core.swap_BANG_(re_frame.db.app_db, db => cljs.core.assoc(db, k('route'), k('local-test')));
|
||||
window.sequenceSnapshot = () => {
|
||||
const db = cljs.core.deref(re_frame.db.app_db);
|
||||
const entry = arthur.footage.store.entry(cljs.core.get(db, k('clip/current')));
|
||||
return cljs.core.clj__GT_js(entry);
|
||||
};
|
||||
return true;
|
||||
})()`);
|
||||
await sleep(250);
|
||||
const click = async label => {
|
||||
assert(await evaluate(`(() => {
|
||||
const b = [...document.querySelectorAll('button')].find(b => b.textContent.trim() === ${JSON.stringify(label)});
|
||||
if (!b || b.disabled) return false;
|
||||
b.click(); return true;
|
||||
})()`), `enabled button: ${label}`);
|
||||
await sleep(180);
|
||||
};
|
||||
const shot = async () => (await evaluate('sequenceSnapshot()'));
|
||||
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 occurrence');
|
||||
|
||||
// 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 exposure, ripple, and shot length');
|
||||
assert.equal(errors.length, 0, JSON.stringify(errors));
|
||||
console.log('PASS: create lane/drawings, one-row cels, hold ripple, seek, explicit overflow, atomic undo; no server writes');
|
||||
} finally {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ id: 999999, method: 'Browser.close' }));
|
||||
await sleep(350);
|
||||
}
|
||||
ws?.close();
|
||||
chrome.kill();
|
||||
await new Promise(resolve => { if (chrome.exitCode !== null || chrome.signalCode !== null) resolve(); else chrome.once('exit', resolve); });
|
||||
try {
|
||||
rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
} catch (error) {
|
||||
console.warn(`Temporary browser profile retained at ${profile}: ${error.code}`);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue