arthur/frontend/test/arthur/domain/symbol_test.cljs
Olive Vaughn 38850a29ba Slide rows along time and restack them, at any depth
A node's bar drags along the timeline: one write to its :at, for every node
alike, carried down through the instances above it. The stage and rows show
the slide live through ::render/clip, and it lands as one edit on release.

A row dropped on another's top or bottom edge goes in front of it or behind:
one write to :z, between its new neighbours (symbol/z-between). Onto the edge
of a row in another symbol, it moves there first. Neither needs anything on
screen — nest/down walks a row path by structure, without a frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 00:51:34 -04:00

412 lines
22 KiB
Clojure

(ns arthur.domain.symbol-test
"Frame evaluation, and the hand-written scene.
port-plan step 2 exists to find out whether the data model works BEFORE nine
hundred lines of measurement are ported into it, so these assertions are about
the model's claims rather than about a look: that structure is flat and
addressable, that draw order is authored, that time maps compose, that
presence and visibility are different questions, and that the fast path and the
specification give the same frame."
(:require [cljs.test :refer [deftest is testing]]
[arthur.demo :as demo]
[arthur.domain.channel :as ch]
[arthur.domain.node :as node]
[arthur.domain.palette :as pal]
[arthur.domain.raster :as raster]
[arthur.domain.clip :as clip]
[arthur.domain.symbol :as symbol]
[arthur.support.ops :as ops]))
(defn- poly [id parent z pts color & [extra]]
(merge {:id id :kind :poly :parent parent :z z
:channels {[:geom :pts] (ch/framed pts)
[:style :color] (ch/framed color)}}
extra))
(defn- sc [& nodes]
{:nodes (into {} (map (juxt :id identity)) nodes)})
(defn- ids-at [scene f]
(mapv :node (symbol/eval-frame scene f)))
(def ^:private pts-of ops/points)
;; ---- structure ----
(deftest depth-order-puts-every-node-after-its-parent
(let [s (sc {:id :a :kind :group :z "a1"}
{:id :b :kind :group :parent :a :z "a1"}
{:id :c :kind :group :parent :b :z "a1"}
{:id :d :kind :group :parent :a :z "a2"})
ord (symbol/order (:nodes s))]
(is (= 0 (symbol/depth (:nodes s) :a)))
(is (= 2 (symbol/depth (:nodes s) :c)))
(let [pos (into {} (map-indexed (fn [i id] [id i])) ord)]
(doseq [[id p] [[:b :a] [:c :b] [:d :a]]]
(is (< (get pos p) (get pos id)) (str p " must come before " id))))))
(deftest a-parent-cycle-throws-instead-of-hanging
;; Reachable from one bad :node/set-parent, and a hung tab is a far worse
;; diagnostic than a stack trace naming the nodes.
(let [s (sc {:id :a :kind :group :parent :b :z "a1"}
{:id :b :kind :group :parent :a :z "a1"})]
(is (thrown-with-msg? ExceptionInfo #"cycle" (symbol/order (:nodes s))))
(is (seq (symbol/problems s)))))
(deftest a-missing-parent-is-named-rather-than-silently-orphaning
(let [s (sc {:id :a :kind :group :parent :nope :z "a1"})]
(is (seq (symbol/problems s)))))
(deftest reparenting-is-one-field-and-does-not-move-a-subtree
;; The flat-with-pointers claim, asserted as the thing it buys: a reparent is an
;; assoc-in at one node, and nothing else in the map changes identity — which is
;; what keeps re-frame's ancestor subs from invalidating.
(let [s (sc {:id :a :kind :group :z "a1"}
{:id :b :kind :group :z "a2" :channels {[:xform :pos] (ch/framed [100 0])}}
(poly :c :a "a1" [0 0 10 0 10 10] :brow))
s' (assoc-in s [:nodes :c :parent] :b)]
(is (identical? (get-in s [:nodes :a]) (get-in s' [:nodes :a]))
"the old parent is the same object")
(is (identical? (get-in s [:nodes :b]) (get-in s' [:nodes :b]))
"and so is the new one")
(is (= [[0 0] [10 0] [10 10]]
(pts-of (first (filter #(= :c (:node %)) (symbol/eval-frame s 0))))))
(is (= [[100 0] [110 0] [110 10]]
(pts-of (first (filter #(= :c (:node %)) (symbol/eval-frame s' 0))))))))
;; ---- draw order ----
(deftest draw-order-is-depth-first-by-sibling-z
;; z is a fractional index among siblings, so the sort key is the chain of z
;; values from the root. A parent's chain is a PREFIX of its child's, which is
;; why a parent draws before its children without that being a special case.
(let [s (sc {:id :root :kind :group :z "a1"}
(poly :under :root "a0" [0 0 1 0 1 1] :bg)
{:id :mid :kind :group :parent :root :z "a1"}
(poly :deep :mid "a5" [0 0 1 0 1 1] :brow)
(poly :over :root "a2" [0 0 1 0 1 1] :teeth))]
(is (= [:under :deep :over] (ids-at s 0)))))
(deftest a-deep-child-of-an-early-sibling-still-draws-before-a-later-sibling
;; The failure this guards: comparing z paths with `compare` would compare
;; COUNT first, so a painted cel three levels under "a1" would jump in front of
;; a bare "a2". It reads as a layer order that mostly works.
(let [s (sc {:id :root :kind :group :z "a1"}
{:id :g1 :kind :group :parent :root :z "a1"}
{:id :g2 :kind :group :parent :g1 :z "a1"}
(poly :deep :g2 "a1" [0 0 1 0 1 1] :brow)
(poly :shallow :root "a2" [0 0 1 0 1 1] :teeth))]
(is (= [:deep :shallow] (ids-at s 0)))))
(deftest a-fractional-index-inserts-between-two-siblings-without-renumbering
(let [base (sc {:id :root :kind :group :z "a1"}
(poly :a :root "a1" [0 0 1 0 1 1] :bg)
(poly :c :root "a3" [0 0 1 0 1 1] :teeth))
with (assoc-in base [:nodes :b] (poly :b :root "a2" [0 0 1 0 1 1] :brow))]
(is (= [:a :c] (ids-at base 0)))
(is (= [:a :b :c] (ids-at with 0)))
(is (= (get-in base [:nodes :a]) (get-in with [:nodes :a])) "and :a is untouched")))
(deftest z-between-always-finds-room
(let [between? (fn [a b z] (and (or (nil? a) (neg? (compare a z)))
(or (nil? b) (neg? (compare z b)))))]
(testing "on the keys scenes already have"
(doseq [[a b] [[nil "a1"] ["a1" nil] ["a1" "a2"] ["a1" "a3"] ["a" "a1"]
["a1" "z1727000000000-shape"] [nil "0"] [nil "-"] ["c0000" "c0001"]]]
(is (between? a b (symbol/z-between a b)) (pr-str [a b (symbol/z-between a b)]))))
(testing "and again and again: to the back, to the front, and into one gap from either side"
(doseq [[label start step past?] [["back" "a1" #(symbol/z-between nil %) #(between? nil %1 %2)]
["front" "a1" #(symbol/z-between % nil) #(between? %1 nil %2)]
["under a2" "a1" #(symbol/z-between % "a2") #(between? %1 "a2" %2)]
["over a1" "a2" #(symbol/z-between "a1" %) #(between? "a1" %1 %2)]]]
(is (every? (fn [[x y]] (past? x y)) (partition 2 1 (take 300 (iterate step start))))
label)))))
;; ---- transform composition through the tree ----
(deftest geometry-lands-in-the-parents-space
(let [s (sc {:id :g :kind :group :z "a1"
:channels {[:xform :pos] (ch/framed [100 50])
[:xform :scale] (ch/framed [2 2])}}
(poly :p :g "a1" [0 0 10 0 10 10 0 10] :skin-base))
op (first (symbol/eval-frame s 0))]
(is (= [[100 50] [120 50] [120 70] [100 70]] (pts-of op)))))
(deftest a-keyed-group-position-moves-its-children-and-holds-between-keys
;; This is the scene the plan asks for, minimally: a rectangle parented to a
;; group whose [:xform :pos] is keyed on four frames.
(let [s (sc {:id :g :kind :group :z "a1"
:channels {[:xform :pos]
(ch/keyed {0 [0 0], 4 [10 0], 8 [10 10], 12 [0 10]})}}
(poly :p :g "a1" [0 0 2 0 2 2] :skin-base))
at #(first (pts-of (first (symbol/eval-frame s %))))]
(is (= [0 0] (at 0)))
(is (= [0 0] (at 3)) "held")
(is (= [10 0] (at 4)))
(is (= [10 10] (at 8)))
(is (= [0 10] (at 12)))
(is (= [0 10] (at 99)) "and holds the last key")))
;; ---- time maps compose along the chain ----
(deftest exposure-on-the-root-is-inherited-by-everything-under-it
;; docs/design.md is emphatic that everything rides ONE grid: a head cutting on
;; odd frames against a mouth cutting on even ones reads as two performances.
(let [s (sc {:id :root :kind :group :z "a1" :time {:mode :map :expose 3}}
{:id :g :kind :group :parent :root :z "a1"
:channels {[:xform :pos] (ch/keyed (into {} (map (juxt identity #(vector % 0))) (range 12)))}}
(poly :p :g "a1" [0 0 1 0 1 1] :skin-base))
x-at #(first (first (pts-of (first (symbol/eval-frame s %)))))]
(is (= [0 0 0 3 3 3 6 6 6 9 9 9] (mapv x-at (range 12)))))
(testing "and a node may set its own grid, which the model permits deliberately"
(let [s (sc {:id :root :kind :group :z "a1" :time {:mode :map :expose 2}}
{:id :g :kind :group :parent :root :z "a1" :time {:mode :map :expose 4}
:channels {[:xform :pos] (ch/keyed (into {} (map (juxt identity #(vector % 0))) (range 12)))}}
(poly :p :g "a1" [0 0 1 0 1 1] :skin-base))
x-at #(first (first (pts-of (first (symbol/eval-frame s %)))))]
(is (= [0 0 0 0 4 4 4 4 8 8 8 8] (mapv x-at (range 12)))))))
(deftest offset-is-per-node-which-is-the-entire-point-of-mouth-lead
;; Lead applies to performance nodes and NOT to the plate. If it were a clip
;; property the mouth would drag the whole head forward with it.
(let [keys (into {} (map (juxt identity #(vector % 0))) (range 12))
s (sc {:id :root :kind :group :z "a1"}
{:id :plate :kind :group :parent :root :z "a1"
:channels {[:xform :pos] (ch/keyed keys)}}
(poly :plate-p :plate "a1" [0 0 1 0 1 1] :skin-base)
{:id :mouth :kind :group :parent :root :z "a2" :time {:mode :map :offset 2}
:channels {[:xform :pos] (ch/keyed keys)}}
(poly :mouth-p :mouth "a1" [0 0 1 0 1 1] :mouth-dark))
x-of (fn [f id] (->> (symbol/eval-frame s f)
(filter #(= id (:node %))) first pts-of first first))]
(is (= [0 1 2 3] (mapv #(x-of % :plate-p) (range 4))))
(is (= [2 3 4 5] (mapv #(x-of % :mouth-p) (range 4))) "the mouth reads ahead")))
;; ---- span and visibility are different questions ----
(deftest span-removes-a-node-and-vis-switches-it-off
;; :span is Lottie's ip/op and Flash's PlaceObject/RemoveObject: the range over
;; which the node EXISTS. [:vis] blinks an existing node on and off. Conflating
;; them is how you end up with a part that holds a stale pose outside its range.
(let [s (sc {:id :root :kind :group :z "a1"}
(poly :p :root "a1" [0 0 1 0 1 1] :brow
{:span [2 5]
:channels {[:geom :pts] (ch/framed [0 0 1 0 1 1])
[:style :color] (ch/framed :brow)
[:vis] (ch/keyed {0 true, 3 false, 4 true})}}))]
(is (= [[] [] [:p] [] [:p] [] []] (mapv #(ids-at s %) (range 7))))))
(deftest a-hidden-group-takes-its-children-with-it
(let [s (sc {:id :g :kind :group :z "a1"
:channels {[:vis] (ch/keyed {0 true, 2 false})}}
(poly :p :g "a1" [0 0 1 0 1 1] :brow))]
(is (= [:p] (ids-at s 0)))
(is (= [] (ids-at s 2)))))
(deftest an-absent-transform-drops-the-subtree-and-an-absent-geometry-does-not
;; The asymmetry is the whole reason presence is tracked per CHANNEL rather than
;; per node. An absent mouth outline has nothing to draw, but the head it hangs
;; off is still exactly where it was.
(let [state (js/Uint8Array. #js [ch/present ch/absent-bit])
store {"pos" {:data (js/Float32Array. #js [0 0, 0 0]) :state state}
"pts" {:data (js/Int16Array. #js [0 0 1 0 1 1, 0 0 1 0 1 1]) :state state}}
absent-pos (sc {:id :g :kind :group :z "a1"
:channels {[:xform :pos] {:animated? true
:dense {:store "pos" :offset 0 :stride 2 :frames 2}}}}
(poly :child :g "a1" [0 0 1 0 1 1] :brow))
absent-pts (sc {:id :g :kind :group :z "a1"}
{:id :m :kind :poly :parent :g :z "a1"
:channels {[:geom :pts] {:animated? true
:dense {:store "pts" :offset 0 :stride 6 :frames 2}}
[:style :color] (ch/framed :mouth-dark)}}
(poly :teeth :m "a2" [0 0 1 0 1 1] :teeth))]
(is (= [:child] (mapv :node (symbol/eval-frame absent-pos 0 store))))
(is (= [] (mapv :node (symbol/eval-frame absent-pos 1 store)))
"an absent transform gives the children nowhere to be")
(is (= [:m :teeth] (mapv :node (symbol/eval-frame absent-pts 0 store))))
(is (= [:teeth] (mapv :node (symbol/eval-frame absent-pts 1 store)))
"an absent outline removes only itself")))
;; ---- stencils ----
(deftest a-stencil-resolves-to-the-stencil-nodes-palette-index
;; A stencil is a COLOUR KEY, not a node reference — the take format's clip= —
;; and the indexed buffer being its own clip mask is what keeps the iris inside
;; the eye at any gaze and any radius with no clamp anywhere.
(let [s (sc {:id :root :kind :group :z "a1"}
(poly :sclera :root "a1" [0 0 10 0 10 10] :eye-white)
{:id :iris :kind :disc :parent :root :stencil :sclera :z "a2"
:channels {[:geom :radius] (ch/framed 4)
[:style :color] (ch/framed :iris)}})
ops (symbol/eval-frame s 0)]
(is (= [:sclera :iris] (mapv :node ops)))
(is (= (:eye-white pal/index-of) (:stencil (second ops))))))
(deftest a-node-stencilled-by-something-that-drew-nothing-is-dropped
;; Unclipped would be an iris floating over the cheek on exactly the frames
;; where the eye is missing, which is worse than a missing iris.
(let [s (sc {:id :root :kind :group :z "a1"}
(poly :sclera :root "a1" [0 0 10 0 10 10] :eye-white
{:channels {[:geom :pts] (ch/framed [0 0 10 0 10 10])
[:style :color] (ch/framed :eye-white)
[:vis] (ch/keyed {0 true, 1 false})}})
{:id :iris :kind :disc :parent :root :stencil :sclera :z "a2"
:channels {[:geom :radius] (ch/framed 4)
[:style :color] (ch/framed :iris)}})]
(is (= [:sclera :iris] (ids-at s 0)))
(is (= [] (ids-at s 1)))))
;; ---- discs and rects ----
(deftest disc-and-rect-extents-retain-precision-for-enclosing-instances
(let [s (sc {:id :g :kind :group :z "a1"
:channels {[:xform :pos] (ch/framed [50 60]) [:xform :scale] (ch/framed [2 2])}}
{:id :d :kind :disc :parent :g :z "a1"
:channels {[:geom :radius] (ch/framed 3) [:style :color] (ch/framed :iris)}}
{:id :r :kind :rect :parent :g :z "a2"
:channels {[:geom :size] (ch/framed 1.7) [:style :color] (ch/framed :pupil)}})
[d r] (symbol/eval-frame s 0)]
(is (= [50 60 6] [(:cx d) (:cy d) (:r d)]))
(is (= 3.4 (:size r)))))
;; ---- the fast path and the specification agree ----
(deftest the-resolver-agrees-with-eval-frame-in-any-frame-order
;; THE assertion of this step. The resolver caches the topological order and the
;; z paths, holds a cursor per channel and reuses one point buffer per node, and
;; every one of those is a way to be subtly wrong on some frames and not others
;; — which presents as a bad take rather than as an error.
;; The frame orders and the snapshot live in `arthur.support.ops`, because the
;; same comparison is what proves a scene survived the server — see
;; flow/project-test.
(let [s demo/main
spec (ops/specified s nil)
fast (ops/resolved s nil)]
(doseq [[label fs] (ops/orders (:frames s))]
(testing label
(doseq [f fs]
(is (= (spec f) (fast f)) (str label " at frame " f)))))))
(deftest the-resolver-reuses-one-buffer-per-node
;; At 30fps per-frame allocation is the only thing that will make this stutter,
;; and fixed topology is what makes the buffer size knowable at all.
(let [res (symbol/resolver demo/main)
buf-of (fn [f id] (->> (res f) (filter #(= id (:node %))) first :pts))]
(is (identical? (buf-of 0 :card) (buf-of 30 :card)))))
;; ---- the hand-written scene, end to end ----
(deftest the-hand-written-clip-is-valid
;; `clip/problems` rather than `symbol/problems`: it checks the clip's fields,
;; the timeline map and the tracking identities as well as the nodes, so it is
;; the check a save would make.
(let [ps (clip/problems demo/clip)]
(is (empty? ps) (pr-str ps)))
(is (pos? demo/frames))
(testing "a clip is not a symbol, and handing one over fails loudly"
;; The mistake this split makes easy: both are maps with an :id, and the wrong
;; one resolves to no ops rather than to an error.
(is (thrown-with-msg? ExceptionInfo #"not a symbol"
(symbol/resolver demo/clip)))
(is (thrown-with-msg? ExceptionInfo #"not a symbol"
(symbol/eval-frame demo/clip 0)))))
(deftest the-hand-written-clip-renders-and-moves
;; port-plan step 2's done condition, as an assertion rather than a look: the
;; scene rasterises, it writes only palette indices, and the pixels are not the
;; same on every frame.
(let [res (symbol/resolver demo/main)
render (fn [f]
(let [r (raster/make (:width demo/clip) (:height demo/clip))]
(raster/clear! r (:bg pal/index-of))
(raster/draw-ops! r (res f))
r))
frames (mapv render (range 0 demo/frames 6))
sig (fn [r] (vec (array-seq (:buf r))))]
(is (every? (fn [r] (every? #(< % (count pal/rgb)) (array-seq (:buf r)))) frames)
"every byte written is a real palette index")
(is (> (count (distinct (map sig frames))) 1) "something moves")
(testing "the mark actually covers pixels"
(is (pos? (count (remove zero? (sig (first frames)))))))))
(deftest the-hand-written-clip-steps-on-the-exposure-grid
;; Exposure 2 on the clip root, inherited, so odd frames are identical to the
;; even frame before them. If this fails, exposure is being applied somewhere
;; other than the frame the channels are sampled at.
(let [res (symbol/resolver demo/main)
render (fn [f]
(let [r (raster/make (:width demo/clip) (:height demo/clip))]
(raster/clear! r (:bg pal/index-of))
(raster/draw-ops! r (res f))
(vec (array-seq (:buf r)))))]
(doseq [f (range 0 demo/frames 2)]
(is (= (render f) (render (inc f))) (str "frame " (inc f) " must hold frame " f)))
;; Two grid slots that straddle a key, not two adjacent ones: between keys
;; nothing changes, because that is what hold MEANS. The scene's second key
;; is at 57, and exposure 2 floors that onto 58 — which is itself the
;; expose-before-anything-else rule showing up in pixels.
(is (not= (render 56) (render 58)) "and a key on the grid is seen")))
(deftest the-hand-written-clip-keeps-the-iris-and-pupil-inside-the-card
;; The stencil chain, on real pixels: the iris is clipped by the card and the
;; pupil by the iris, and neither is expressed anywhere as a chain.
(let [res (symbol/resolver demo/main)]
(doseq [f (range 0 demo/frames 4)]
(let [before (raster/make (:width demo/clip) (:height demo/clip))
after (raster/make (:width demo/clip) (:height demo/clip))
ops (res f)
card? (fn [op] (= :card (:node op)))]
(raster/clear! before (:bg pal/index-of))
(raster/draw-ops! before (filter card? ops))
(raster/clear! after (:bg pal/index-of))
(raster/draw-ops! after ops)
(let [ci (:skin-base pal/index-of)
card (set (for [i (range (alength (:buf before)))
:when (= ci (aget (:buf before) i))]
i))
eye (set (for [i (range (alength (:buf after)))
:when (#{(:iris pal/index-of) (:pupil pal/index-of)}
(aget (:buf after) i))]
i))]
(is (pos? (count eye)) (str "frame " f ": the iris drew something"))
(is (empty? (remove card eye))
(str "frame " f ": " (count (remove card eye)) " pixels outside the card")))))))
;; ---- the palette is a parameter, not a global ----
(deftest the-same-scene-resolves-differently-under-a-different-ramp
;; A node names a TONE; which ramp that tone is read in belongs to the timeline
;; it sits in. So resolution must not reach for one ambient answer — the same
;; drawing has to read day or night without a stored value changing, which is
;; the entire payoff of indexed colour.
(let [s (sc {:id :root :kind :group :z "a1"}
(poly :p :root "a1" [0 0 10 0 10 10] :skin-base))
day {:skin-base 1}
night {:skin-base 17}]
(is (= 1 (:color (first (symbol/eval-frame s 0 nil day)))))
(is (= 17 (:color (first (symbol/eval-frame s 0 nil night)))))
(is (= 17 (:color (first ((symbol/resolver s nil night) 0))))
"and the playback path agrees")))
(deftest a-tone-the-ramp-does-not-define-is-loudly-wrong
;; 255 renders magenta. Naming a colour the ramp has no entry for is a bug in
;; authored data and should be impossible to miss.
(let [s (sc {:id :root :kind :group :z "a1"}
(poly :p :root "a1" [0 0 10 0 10 10] :skin-base))]
(is (= 255 (:color (first (symbol/eval-frame s 0 nil {})))))))
(deftest partitioning-the-index-space-stops-two-palettes-colliding-on-a-stencil
;; A stencil is a colour key, so two nodes sharing a tone share a stencil —
;; a real weakness of the technique. Concatenating the named palettes into one
;; index space means two nodes in DIFFERENT palettes cannot collide at all.
(let [s (sc {:id :root :kind :group :z "a1"}
(poly :sclera :root "a1" [0 0 20 0 20 20] :eye-white)
{:id :iris :kind :disc :parent :root :stencil :sclera :z "a2"
:channels {[:geom :radius] (ch/framed 4)
[:style :color] (ch/framed :iris)}})
;; :night's tones sit above :day's in one concatenated space
night {:eye-white 14 :iris 15}
ops (symbol/eval-frame s 0 nil night)]
(is (= 14 (:stencil (second ops)))
"the stencil resolves to the index the stencil node actually drew in")))