arthur/frontend/test/arthur/domain/project_test.cljs
Olive Vaughn 9cd5243983 Serve the document from a Django backend, split into three tiers
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>
2026-09-28 01:11:41 -04:00

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))))