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>
412 lines
22 KiB
Clojure
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")))
|