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>
153 lines
7.4 KiB
Clojure
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))))
|