Step 9. The tier split was the work; Django was the easy half.
Tier 1 — the authored scene — is the document, and it is addressed as
independently versioned leaves rather than saved whole, so one vertex drag
cannot clobber a collaborator's keying. `domain/leaf` is the document as
path -> value; `domain/wire` puts it on the wire as transit, because JSON
has neither integer map keys nor keywords and a save would quietly turn
`{0 v}` into `{"0" v}`.
Tier 2 — the dense channel blocks — is content-addressed by a hash over
every input, with the detector version inside every key through the
analysis the block descriptor names. `flow/address`'s `block-knobs` is the
invalidation table, and `address-test` does not trust it: it re-freezes the
take once per knob and asserts the biconditional, that a block's bytes
changed if and only if its key changed. That found `brow-pos` not depending
on `contour-avg` — the brow ring is smoothed, the raise is not.
Tier 3 — frames and audio — is served by the hash of its bytes out of the
same store. A manifest now names frames and carries a URL for each, so the
frame layout stopped being a shared secret between a shell script and a
ClojureScript namespace, and the `?v=` cache-buster went with it: a blob's
name is the hash of its contents, so a stale copy is not a thing that can
happen. The synthetic take's `audio.wav` moved to `static/arthur/` — an
asset the project owns, not an extraction that churns.
The server verifies rather than trusting a name it was handed: it
recomputes every key from the descriptor stored beside it, refuses an
analysis that declares no detector version, and refuses a document naming
blocks it does not hold. It hashes the descriptor TEXT, because JS prints
an integral double as `1` and Python as `1.0`, and a scheme where both ends
re-render the numbers disagrees on the first parameter that happens to be
whole.
Two loose ends from step 8 closed on the way. `pack` no longer takes a
`(track, frame)` predicate whose call sites each re-derived a feature from
an index — every track names the feature it follows, which deleted five
hand-maintained mappings. And `:dev-http` is gone: Django serves the page,
shadow-cljs only builds into the staticfiles tree.
227 CLJS tests, 31 Django tests, green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
146 lines
7 KiB
Clojure
146 lines
7 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 scene-test uses to hold `eval-frame` and
|
|
`resolver` to each other, which is the strictest statement available about two
|
|
scenes being the same scene.
|
|
|
|
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.project :as project]
|
|
[arthur.domain.scene :as scene]
|
|
[arthur.flow.freeze :as freeze]
|
|
[arthur.support.ops :as ops]))
|
|
|
|
(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-scene
|
|
(let [ps (scene/problems (:scene @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 (= (:scene @before) (:scene @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 (:frames (:scene @before))
|
|
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 (:scene @before) (:store @before))
|
|
b (g (:scene @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 {:scene @take/locked :store @take/store}
|
|
back (wired :c1 locked)
|
|
a (ops/resolved (:scene locked) (:store locked))
|
|
b (ops/resolved (:scene back) (:store back))]
|
|
(is (= (:scene locked) (:scene 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")
|
|
(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 [clip id path f]
|
|
(ch/value-at (get-in (:scene clip) [:nodes id :channels path])
|
|
f (:store clip)))
|
|
;; 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) ((scene/resolver (:scene back) (:store back)) 12))]
|
|
(is (not (contains? drawn :eye-r)))
|
|
(is (contains? drawn :eye-l))
|
|
(is (contains? drawn :mouth))))
|