arthur/frontend/test/arthur/domain/nest_test.cljs
2026-10-01 23:35:22 -04:00

358 lines
20 KiB
Clojure
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(ns arthur.domain.nest-test
(:require [cljs.test :refer [deftest is testing]]
[arthur.domain.channel :as ch]
[arthur.domain.clip :as clip]
[arthur.domain.nest :as nest]
[arthur.domain.node :as node]
[arthur.domain.paint :as paint]
[arthur.domain.palette :as pal]))
(defn- nested
"Three symbols: :outer places :inner, and :loose is placed by nothing."
[]
(-> (clip/blank)
(assoc-in [:symbols :outer] {:id :outer :frames 200 :nodes {}})
(assoc-in [:symbols :inner] {:id :inner :frames 10 :nodes {}})
(assoc-in [:symbols :loose] {:id :loose :frames 30 :nodes {}})
(clip/place-symbol nil :outer :inner 5 (random-uuid) nil)))
(deftest a-frame-is-carried-down-through-the-instances-a-row-path-names
(let [c (nested)
[id] (keys (get-in c [:symbols :outer :nodes]))
at #(select-keys (nest/inside c nil :outer %1 %2) [:sid :frame])]
(is (= {:sid :outer :frame 12} (at [] 12)))
(is (= {:sid :inner :frame 7} (at [id] 12))
"the instance starts at 5, so frame 12 outside is frame 7 inside")
(is (nil? (nest/inside c nil :outer [id] 2))
"and before it starts there is no inside to be in")))
(deftest a-shape-drawn-into-an-instance-lands-where-it-was-drawn
(let [u #uuid "00000000-0000-4000-8000-0000000000dd"
c (-> (clip/blank)
(assoc-in [:symbols :box] {:id :box :frames 30 :nodes {}})
(clip/place-symbol nil :main :box 10 u nil)
;; moved, turned and doubled, so nothing lines up by accident
(update-in [:symbols :main :nodes u :channels] merge
{[:xform :pos] (ch/framed [40 20])
[:xform :rot] (ch/framed (/ js/Math.PI 2))
[:xform :scale] (ch/framed [2 2])}))
drawn [100 50 140 50 120 90]
{:keys [sid frame pts]} (nest/drawn-inside c nil :main [u] 16 drawn)
c (paint/new-shape c sid :shape frame pts :brow)
[op] (filter #(= [u :shape] (:node %))
((clip/resolver c :main nil pal/index-of nil) 16))]
(is (= :box sid))
(is (= 6 frame) "frame 16 of main is frame 6 of an instance placed at 10")
(is (every? #(< (js/Math.abs %) 1e-9)
(map - drawn (take 6 (array-seq (:pts op)))))
"resolved back out through the instance, it is exactly what was drawn")))
(deftest a-shape-two-instances-down-is-edited-where-it-is-seen
(let [u #uuid "00000000-0000-4000-8000-0000000000d1"
v #uuid "00000000-0000-4000-8000-0000000000d2"
turn (fn [c host id pos rot k]
(update-in c [:symbols host :nodes id :channels] merge
{[:xform :pos] (ch/framed pos)
[:xform :rot] (ch/framed rot)
[:xform :scale] (ch/framed [k k])}))
c (-> (clip/blank)
(assoc-in [:symbols :mid] {:id :mid :frames 40 :nodes {}})
(assoc-in [:symbols :box] {:id :box :frames 30 :nodes {}})
(clip/place-symbol nil :main :mid 10 u nil)
(clip/place-symbol nil :mid :box 2 v nil)
(turn :main u [40 20] (/ js/Math.PI 2) 2)
(turn :mid v [5 -3] 0.3 1.5)
(paint/new-shape :box :shape 4 [0 0 10 0 5 10] :brow))
draw #(take 6 (array-seq (:pts (first (filter (fn [op] (= [u v :shape] (:node op)))
((clip/resolver % :main nil pal/index-of nil) 16))))))
{:keys [frame matrix time]} (nest/inside c nil :main [u v :shape] 16)
out (js/Float64Array. 2)
seen (mapcat (fn [[x y]] (vec (array-seq (node/apply-pt! out 0 matrix x y))))
(partition 2 [0 0 10 0 5 10]))
[x y] (array-seq (node/apply-pt! out 0 (node/invert matrix) 7 3))
moved (paint/set-vertex c :box :shape frame 0 [x y])
keyed (paint/add-key c :box :shape (:frame (nest/inside c nil :main [u v :shape] 20)))]
(is (= 4 frame) "16 of main is 6 of mid, which is 4 of box and of the shape in it")
(is (= {:at 12 :rate 1} time))
(is (= #{4 8} (set (keys (get-in keyed [:symbols :box :nodes :shape :channels paint/geometry :keys]))))
"a key added at 20 of main lands at 8, the shape's own time")
(is (nil? (nest/inside c nil :main [u v :shape] 13))
"and where the shape is not on screen there is nothing to edit")
(is (every? #(< (js/Math.abs %) 1e-9) (map - (draw c) seen))
"the handles sit on what the stage draws")
(is (every? #(< (js/Math.abs %) 1e-9) (map - [7 3] (take 2 (draw moved))))
"and a vertex dragged to (7, 3) is drawn at (7, 3)")))
(deftest a-placed-symbols-sound-is-heard-where-it-is-placed
(let [voice {:id :v :kind :audio :source {:footage "f"} :z "a1"
:span [10 40] :time {:mode :map :at -10 :rate 1}
:channels {[:audio :gain] (ch/keyed {0 0.0 5 1.0} :hold)}}
c (-> (clip/blank)
(assoc-in [:symbols :talk] {:id :talk :frames 30 :nodes {:v voice}})
(clip/place-symbol nil :main :talk 50 #uuid "00000000-0000-4000-8000-0000000000bb" nil))
[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 (= #{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)]
(is (= [10 22] (:span t)))
(is (= [50 62] (node/placed-span t)))))))
(defn- picture
"What `sid` draws at each of `fs`, without the node paths a move changes:
per frame, the sorted marks with their points rounded to a thousandth."
[c sid fs]
(let [resolve (clip/resolver c sid nil pal/index-of nil)
round #(/ (js/Math.round (* 1000 %)) 1000)]
(mapv (fn [f]
(sort-by str (map (fn [op]
[(:color op) (mapv round (take (* 2 (:n op)) (array-seq (:pts op))))])
(resolve f))))
fs)))
(def ^:private a-uuid #uuid "00000000-0000-4000-8000-0000000000e1")
(def ^:private b-uuid #uuid "00000000-0000-4000-8000-0000000000e2")
(defn- studio
"`main` holds a keyed shape and a moved, turned, doubled instance of `box`,
placed at frame 10; `box` holds a shape of its own."
[]
(let [tri (fn [id x keyed]
{:id id :kind :poly :z "a1" :paint? true :span [4 60]
:channels {[:geom :pts] (ch/keyed (into {} (map (fn [[f dx]] [f [x 10 (+ x dx) 10 x 40]])) keyed) :hold)
[:style :color] (ch/framed :brow)}})]
(-> (clip/blank)
(assoc-in [:symbols :main :nodes :tri] (tri :tri 100 {4 20 30 40}))
(assoc-in [:symbols :box] {:id :box :frames 50 :nodes {:inner (tri :inner 5 {0 10})}})
(clip/place-symbol nil :main :box 10 a-uuid nil)
(update-in [:symbols :main :nodes a-uuid :channels] merge
{[:xform :pos] (ch/framed [30 20])
[:xform :rot] (ch/framed 0.5)
[:xform :scale] (ch/framed [2 2])}))))
(deftest moving-a-node-into-a-symbol-changes-nothing-on-screen
;; From frame 10, where the instance starts: inside a symbol a node exists only
;; while that symbol is on screen, so moving the shape in cuts off 4–9.
(let [c (studio)
fs [10 16 29 30 45 59 60 70]
{moved :clip :as r} (nest/move-node c nil :main [:tri] [a-uuid] 16)]
(is (nil? (:refused r)) (:refused r))
(is (contains? (get-in moved [:symbols :box :nodes]) :tri) "it is in the symbol now")
(is (not (contains? (get-in moved [:symbols :main :nodes]) :tri)) "and not beside it")
(is (= [4 60] (get-in moved [:symbols :box :nodes :tri :span]))
"its span and keys are its own and do not change")
(is (= -10 (get-in moved [:symbols :box :nodes :tri :time :at]))
"its time map takes up the ten frames the instance starts late")
(is (= (picture c :main fs) (picture moved :main fs)) "and the picture is the same, frame for frame")
(testing "moving it back out is also invisible"
(let [{back :clip} (nest/move-node moved nil :main [a-uuid :tri] [] 16)]
(is (= (picture c :main fs) (picture back :main fs)))))))
(deftest moving-an-instance-moves-only-its-at
(let [c (-> (studio)
(assoc-in [:symbols :holder] {:id :holder :frames 100 :nodes {}})
(clip/place-symbol nil :main :holder 3 b-uuid nil))
fs [0 10 16 40 59]
{moved :clip :as r} (nest/move-node c nil :main [a-uuid] [b-uuid] 16)
n (get-in moved [:symbols :holder :nodes a-uuid])]
(is (nil? (:refused r)) (:refused r))
(is (= 7 (get-in n [:time :at])) "placed at 10 in main is at 7 inside something placed at 3")
(is (= [0 50] (:span n)) "its own frames do not move")
(is (= (picture c :main fs) (picture moved :main fs)))))
(deftest grouping-makes-a-symbol-around-them-and-changes-nothing-on-screen
(let [c (studio)
fs [0 4 10 16 30 59 60]
{grouped :clip :as r} (nest/group c nil :main [[:tri] [a-uuid]] :group-1 b-uuid 16)
inst (get-in grouped [:symbols :main :nodes b-uuid])]
(is (nil? (:refused r)) (:refused r))
(is (= #{:tri a-uuid} (set (keys (get-in grouped [:symbols :group-1 :nodes])))))
(is (= [b-uuid] (keys (dissoc (get-in grouped [:symbols :main :nodes]) :lane)))
"one instance where they were")
(is (= 4 (get-in inst [:time :at])) "starting where the earliest of them starts")
(is (= 56 (clip/frames grouped :group-1)) "and lasting until the last one ends")
(is (= (picture c :main fs) (picture grouped :main fs)))
(is (empty? (clip/problems grouped)))))
(deftest what-cannot-move-says-why
(let [c (studio)]
(is (:refused (nest/move-node c nil :main [a-uuid] [a-uuid] 16)) "not into itself")
(is (:refused (nest/move-node c nil :main [:tri] [a-uuid] 2)) "not while the target is off screen")
(let [roto (assoc-in c [:symbols :main :nodes :tri :channels [:geom :pts] :generated] {:by :roto})]
(is (re-find #"generated" (:refused (nest/move-node roto nil :main [:tri] [a-uuid] 16)))))))
(deftest moving-into-a-retimed-instance-keeps-the-timing
(let [c (assoc-in (studio) [:symbols :main :nodes a-uuid :time :rate] 2)
fs [10 12 17 20 29 34]
{moved :clip :as r} (nest/move-node c nil :main [:tri] [a-uuid] 16)]
(is (nil? (:refused r)) (:refused r))
(is (= {:at -20 :rate 0.5}
(select-keys (get-in moved [:symbols :box :nodes :tri :time]) [:at :rate]))
"half speed inside something at double speed, so it runs as it did")
(is (= (picture c :main fs) (picture moved :main fs)))))
(deftest sliding-a-shape-inside-a-retimed-instance-moves-it-by-frames-of-the-open-symbol
;; `box` runs at double speed from 10, so six frames of main are twelve of box.
(let [c (-> (studio)
(update-in [:symbols :main :nodes] dissoc :tri)
(assoc-in [:symbols :main :nodes a-uuid :time :rate] 2)
(assoc-in [:symbols :box :nodes :inner :channels [:geom :pts]]
(ch/keyed {4 [5 10 15 10 5 40] 20 [5 10 45 10 5 40]} :hold)))
{slid :clip :as r} (nest/slide c :main [a-uuid :inner] 6)
fs [13 15 18 20]]
(is (nil? (:refused r)) (:refused r))
(is (= {:mode :map :at 12 :rate 1} (get-in slid [:symbols :box :nodes :inner :time])))
(is (= [4 60] (get-in slid [:symbols :box :nodes :inner :span])) "its span and keys are its own")
(is (= (picture c :main fs) (picture slid :main (map #(+ 6 %) fs)))
"what it did at a frame of main it now does six frames later")
(is (seq (first (picture c :main [14]))))
(is (empty? (first (picture slid :main [14]))) "and it is not there yet where it was")
(testing "sliding it again adds to where it is"
(is (= 2 (get-in (:clip (nest/slide slid :main [a-uuid :inner] -5))
[:symbols :box :nodes :inner :time :at]))))))
(deftest sliding-needs-nothing-on-screen-and-refuses-only-a-loop
(let [c (studio)]
(is (= 3 (get-in (:clip (nest/slide c :main [a-uuid :inner] 3))
[:symbols :box :nodes :inner :time :at]))
"the instance starts at 10, and where the playhead is has nothing to do with it")
(is (:refused (nest/slide (assoc-in c [:symbols :main :nodes a-uuid :time :loop?] true)
:main [a-uuid :inner] 3))
"but through a loop one frame outside is many inside")))
(deftest linked-audio-follows-picture-moves-but-keeps-independent-edges
(let [c (-> (clip/blank)
(assoc-in [:symbols :drawing] {:id :drawing :frames 8 :nodes {}})
(clip/place-symbol nil :main :drawing 2 :picture nil)
(clip/place-sound :main {:sound "voice"} "voice" 8 1 2 :voice)
(assoc-in [:symbols :main :nodes :voice :linked-to] :picture))
moved (:clip (nest/slide c :main [:picture] 3))
audio-only (:clip (nest/slide c :main [:voice] 1))
trimmed (:clip (nest/resize-out c :main [:voice] -2 false))]
(is (= [5 13] (node/placed-span (get-in moved [:symbols :main :nodes :picture]))))
(is (= [5 13] (node/placed-span (get-in moved [:symbols :main :nodes :voice])))
"moving picture carries its linked audio")
(is (= [2 10] (node/placed-span (get-in audio-only [:symbols :main :nodes :picture]))))
(is (= [3 11] (node/placed-span (get-in audio-only [:symbols :main :nodes :voice])))
"moving audio itself does not carry picture")
(is (= [2 8] (node/placed-span (get-in trimmed [:symbols :main :nodes :voice])))
"an audio endpoint trims independently")
(is (= [2 10] (node/placed-span (get-in trimmed [:symbols :main :nodes :picture]))))))
(deftest restacking-is-one-write-to-z
(let [shape (fn [z] {:kind :poly :z z :channels {}})
c (-> (clip/blank)
(assoc-in [:symbols :main :nodes] {:a (shape "a1") :b (shape "a2") :c (shape "a3")}))
order (fn [c] (map key (sort-by (comp :z val) (get-in c [:symbols :main :nodes]))))
front (:clip (nest/restack c :main [:a] [:c] true))
back (:clip (nest/restack c :main [:c] [:a] false))
mid (:clip (nest/restack c :main [:a] [:b] true))]
(is (= [:b :c :a] (order front)) "in front of the front one")
(is (= [:c :a :b] (order back)) "behind the back one")
(is (= [:b :a :c] (order mid)) "between two")
(is (= (dissoc (get-in c [:symbols :main :nodes]) :a)
(dissoc (get-in mid [:symbols :main :nodes]) :a))
"and nothing else is renumbered")
(is (:refused (nest/restack c :main [:a] [a-uuid :inner] true))
"only among the things it is beside")))
(deftest a-sound-dropped-in-a-placed-symbol-is-heard-where-the-placement-puts-it
(let [u #uuid "00000000-0000-4000-8000-0000000000a1"
c (-> (nested)
(clip/place-sound :inner {:sound "tone"} "tone.mp3" 40 1 2 u))
sound (get-in c [:symbols :inner :nodes u])
[heard] (nest/audio-tracks c :outer)]
(is (empty? (node/problems sound)) "a sound needs no transform")
(is (= {:sound "tone"} (:source sound)))
(is (= #{[:audio :gain] [:audio :pan] [:audio :rate]} (set (keys (node/channels sound)))))
(is (= [0 8] (:span heard))
"own frames 0-8: it starts on inner's 2 and inner ends on 10")
(is (= [7 15] (node/placed-span heard)) "inner starts on 5 of outer")
(is (empty? ((clip/resolver c :inner nil pal/index-of nil) 3))
"and it draws nothing")
(is (= c (clip/place-sound c :inner {:sound "tone"} "tone.mp3" 40 1 10 (random-uuid)))
"nor lands past the end of its symbol")
(testing "a video's own sound keeps its length at another rate"
(let [v (-> (nested)
(clip/place-sound :outer {:footage "f"} "take" 90 2.5 0 u)
(get-in [:symbols :outer :nodes u]))]
(is (empty? (node/problems v)))
(is (= [0 36] (node/placed-span v)) "90 frames at 30 are 36 at 12")))))
;; ---------------------------------------------------------------------------
;; the ruler IS the symbol's frames
(defn- on-twos
"A 12fps project holding a 30fps lane, which is what changing project fps to
12 leaves behind: `docs/time.md` records each symbol's own rate so its timing
stays put. Two one-frame held clips in it, at 5 and at 19."
[]
(let [held (fn [id at] {:id id :kind :instance :z (str "a-" id) :span [0 1]
;; As `span/held` writes it: no `:mode`, because
;; `node/time-of` reads an absent one as `:map`.
:time {:at at :rate 1}
:source {:symbol :draw} :playback {:in 0 :speed 0 :end :stop}})]
(-> (clip/blank)
(assoc :fps 12)
(assoc-in [:symbols :draw] {:id :draw :fps 30 :frames 1 :nodes {}})
(assoc-in [:symbols :lane] {:id :lane :fps 30 :frames 60 :display :lane
:nodes {:a (held :a 5) :b (held :b 19)}}))))
(deftest a-drag-in-the-open-symbol-is-frame-for-frame
;; `df` counts the OPEN SYMBOL's frames, because that is what the ruler is
;; drawn in -- see `docs/one-grid-plan.md`. So one frame of the gesture is one
;; frame of the document, the project's output rate is not in the arithmetic at
;; all, and nothing can land between two frames.
(doseq [project [8 12 24 30 60]]
(let [c (assoc (on-twos) :fps project)
at #(get-in % [:symbols :lane :nodes :b :time :at])
span #(node/placed-span (get-in % [:symbols :lane :nodes :b]))]
(is (= 20 (at (:clip (nest/slide c :lane [:b] 1)))) (str "at " project "fps"))
(is (= 18 (at (:clip (nest/slide c :lane [:b] -1)))))
(is (= 24 (at (:clip (nest/slide c :lane [:b] 5)))))
(is (= [20 21] (span (:clip (nest/slide c :lane [:b] 1)))))
(testing "an edge drag too"
(let [grown (nest/resize-out c :lane [:b] 1 false)]
(is (nil? (:refused grown)) (:refused grown))
(is (= [19 21] (node/placed-span (get-in (:clip grown) [:symbols :lane :nodes :b]))))
(is (= [18 20] (node/placed-span
(get-in (:clip (nest/resize-in c :lane [:b] -1))
[:symbols :lane :nodes :b])))))))))
(deftest a-drag-through-a-retimed-instance-still-lands-on-a-whole-frame
;; The one case where a ruler frame is not a frame of the space being edited:
;; a row reached THROUGH an instance somebody deliberately retimed. At double
;; speed one frame of the ruler is two inside, and at half speed it is half of
;; one -- which has to round, because a frame number cannot fall between two
;; frames. That rounding is `nest/dragged` and it is the whole of its job.
(let [inner (fn [rate]
(-> (on-twos)
(assoc-in [:symbols :outer]
{:id :outer :fps 30 :frames 60
:nodes {:ins {:id :ins :kind :instance :z "a" :span [0 60]
:time {:mode :map :at 0 :rate rate}
:source {:symbol :lane}
:playback {:in 0 :speed 1 :end :stop}}}})))
at (fn [rate df]
(get-in (:clip (nest/slide (inner rate) :outer [:ins :b] df))
[:symbols :lane :nodes :b :time :at]))]
(is (= 21 (at 2 1)) "double speed inside: one frame of the ruler is two")
(is (= 23 (at 2 2)))
(is (= 20 (at 0.5 2)) "half speed: two frames of the ruler are one inside")
(is (= 20 (at 0.5 1)) "and one lands on the nearer whole frame, half upwards")
(is (every? integer? (map #(at 0.5 %) (range -4 5))))))
(deftest sliding-a-held-clip-moves-it-rather-than-forgetting-where-it-was
;; `span/held` writes `{:at f :rate 1}` with no `:mode`, and the slide used to
;; read that as "no time map" and replace the whole thing — so the first drag
;; of a freshly drawn clip threw its `:at` away and jumped it to the head of
;; the lane. Same grid both sides here, so the only question is the mode.
(let [c (assoc (on-twos) :fps 30)
slid (:clip (nest/slide c :lane [:b] 3))]
(is (= 22 (get-in slid [:symbols :lane :nodes :b :time :at])))
(is (= [22 23] (node/placed-span (get-in slid [:symbols :lane :nodes :b]))))
(is (= 1 (:rate (get-in slid [:symbols :lane :nodes :b :time])))
"and the rest of its map is still there")))