arthur/frontend/test/arthur/domain/project_test.cljs
Olive Vaughn 5dff490162 Symbols, not timelines; no symbol is special
Everything that holds nodes is a symbol (domain/timeline -> domain/symbol,
:timelines -> :symbols) and a node that places one is :kind :instance. The
reserved :main root is gone: which symbol is on screen is editor state
([:ui :open]), every domain function that needs a symbol is told which, and
a document opens on the longest symbol nothing else places.

Saved projects move to schema 2 through migration 0007, which rewrites leaf
paths, instance kinds and the feature :symbol key; the client refuses a
schema it does not read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 12:46:42 -04:00

153 lines
7.4 KiB
Clojure

(ns arthur.domain.project-test
"The done criterion of port-plan step 9, made mechanical.
\"Round-tripping a project through the server is the proof the model
serialises\" — and the proof has to be an assertion rather than a look, because
the ways a document survives a round trip LOOKING correct are the interesting
ones: a frame key that came back a string, an absence mask that came back all
zeroes, a dense block read as the wrong element type. Every one of those plays
back as a slightly wrong performance rather than as an error.
So what is compared is the OPS, frame for frame, through both evaluators, in
every frame order — the same machinery timeline-test uses to hold `eval-frame`
and `resolver` to each other, which is the strictest statement available about
two documents being the same document.
This runs the conversion the network runs — `JSON.parse(JSON.stringify(...))` —
and not the network. `clips/tests.py` puts the same document through Django, and
the browser suite drives the real thing end to end; what is asserted here is the
half that does not need a server to be wrong."
(:require [cljs.test :refer [deftest is testing]]
[arthur.demo.take :as take]
[arthur.domain.channel :as ch]
[arthur.domain.clip :as clip]
[arthur.domain.project :as project]
[arthur.domain.symbol :as symbol]
[arthur.flow.freeze :as freeze]
[arthur.support.ops :as ops]))
(defn- face-symbol [c] (clip/symbol c :face-1))
(defn- wired
"A clip out and back, over a wire that is really only JSON."
[cid clip]
(project/load cid (js/JSON.parse (js/JSON.stringify (project/save cid clip)))))
(def ^:private before (delay @take/frozen))
(def ^:private after (delay (wired :c1 @before)))
(deftest what-comes-back-is-a-valid-clip
;; `clip/problems` and not `symbol/problems`: the round trip has to preserve
;; the tracking identities and the timeline map as well as the nodes, and only
;; the clip-level check looks at those.
(let [ps (clip/problems (:clip @after))]
(is (empty? ps) (pr-str ps))))
(deftest the-document-comes-back-equal
;; Stronger than it needs to be and worth having: not merely equivalent, EQUAL.
;; Any drift here is a field the codec is rewriting, and a field that is
;; rewritten once is rewritten again on every save.
(is (= (:clip @before) (:clip @after))))
(deftest every-frame-resolves-to-the-same-ops-before-and-after
;; The assertion. Both evaluators, both scenes, every frame order — so a block
;; that came back with its offsets shifted, or a cursor that seeks differently
;; over a rebuilt key map, has nowhere to hide.
(let [n (clip/frames (:clip @before) :main)
paths {"specification" [ops/specified ops/specified]
"playback" [ops/resolved ops/resolved]
"spec vs playback, after" [ops/specified ops/resolved]}]
(doseq [[label [f g]] paths
[order fs] (ops/orders n)]
(let [a (f (face-symbol (:clip @before)) (:store @before))
b (g (face-symbol (:clip @after)) (:store @after))]
(testing (str label ", " order)
(doseq [frame fs]
(is (= (a frame) (b frame))
(str label " disagrees at frame " frame " going " order))))))))
(deftest the-blocks-come-back-byte-for-byte
;; A handle that names a sha256 has to name the bytes you actually hold.
(is (= (set (keys (:store @before))) (set (keys (:store @after)))))
(doseq [[k entry] (:store @before)]
(let [back (get (:store @after) k)]
(is (= (.-constructor (:data entry)) (.-constructor (:data back)))
(str k " came back as a different element type"))
(is (= (vec (array-seq (:data entry))) (vec (array-seq (:data back))))
(str k " came back with different numbers"))
(is (= (some? (:state entry)) (some? (:state back)))
(str k " gained or lost its state mask")))))
(deftest the-locked-take-round-trips-too
;; The other head mode, because it is the one whose `:head` channels are FRAMED
;; rather than dense: a codec that only handled dense channels would pass
;; everything above and lose the locked take's identity transform.
(let [locked {:clip @take/locked :store @take/store}
back (wired :c1 locked)
a (ops/resolved (face-symbol (:clip locked)) (:store locked))
b (ops/resolved (face-symbol (:clip back)) (:store back))]
(is (= (:clip locked) (:clip back)))
(doseq [frame (range 0 take/frames 7)]
(is (= (a frame) (b frame)) (str "frame " frame)))))
;; ---------------------------------------------------------------------------
;; the state masks
;;
;; freeze-test's presence assertions, re-run on the other side of the wire. This
;; is the part most likely to survive looking correct: a mask lost in transit
;; shows up as a part that is drawn on a frame it was not observed on, which is a
;; pose invented out of its neighbours rather than a blank or an error.
(def ^:private windows
{:eye-r (set (range 10 15))
:eye-l (set (range 20 25))
:brow-r (set (range 30 35))
:brow-l (set (range 40 45))})
(def ^:private gappy
(delay (freeze/clip (assoc take/params :name "gappy")
{:face-1 (assoc @take/measured
:presence
(into {} (map (fn [[id gap]]
[id (mapv #(not (contains? gap %))
(range take/frames))]))
windows))})))
(deftest an-absence-mask-survives-the-wire
(let [back (wired :c1 @gappy)
at (fn [entry id path f]
(ch/value-at (get-in (:nodes (face-symbol (:clip entry))) [id :channels path])
f (:store entry)))
;; Every dense track of the eye, iris, brow and brow-position blocks, and
;; the feature whose gap it must follow — the same table
;; `each-dense-track-follows-its-own-features-presence` pins.
tracks [[:eye-r [:geom :pts] :eye-r]
[:eye-r-in [:geom :pts] :eye-r]
[:eye-l [:geom :pts] :eye-l]
[:eye-l-in [:geom :pts] :eye-l]
[:iris-r [:xform :pos] :eye-r]
[:iris-l [:xform :pos] :eye-l]
[:brow-r [:geom :pts] :brow-r]
[:brow-l [:geom :pts] :brow-l]
[:brow-r [:xform :pos] :brow-r]
[:brow-l [:xform :pos] :brow-l]]]
(doseq [[id path owner] tracks
[feature gap] windows
f gap]
(if (= feature owner)
(is (ch/nothing? (at back id path f))
(str id " " path " is present at " f " after the round trip, with "
feature " occluded"))
(is (not (ch/nothing? (at back id path f)))
(str id " " path " follows " feature "'s gap at frame " f
" after the round trip"))))))
(deftest an-occluded-feature-is-still-not-drawn-after-the-wire
;; Presence is not visibility, on the far side too: the node is dropped from the
;; frame rather than hidden, and its partner is not.
(let [back (wired :c1 @gappy)
drawn (into #{} (map :node)
((symbol/resolver (face-symbol (:clip back)) (:store back)) 12))]
(is (not (contains? drawn :eye-r)))
(is (contains? drawn :eye-l))
(is (contains? drawn :mouth))))