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>
This commit is contained in:
parent
b6517f837a
commit
9cd5243983
61 changed files with 4694 additions and 269 deletions
65
frontend/test/arthur/domain/canon_test.cljs
Normal file
65
frontend/test/arthur/domain/canon_test.cljs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
(ns arthur.domain.canon-test
|
||||
"A content address is only an address if the same inputs always write the same
|
||||
bytes, so these are the ways that could stop being true."
|
||||
(:require [cljs.test :refer [deftest is testing]]
|
||||
[arthur.domain.canon :as canon]))
|
||||
|
||||
(deftest key-order-does-not-change-the-text
|
||||
;; The reason this namespace exists. Two maps that are `=` must hash alike, and
|
||||
;; CLJS map iteration order is not part of `=`.
|
||||
(is (= (canon/write {:b 2 :a 1 :c 3})
|
||||
(canon/write {:c 3 :a 1 :b 2})
|
||||
(canon/write (into {} [[:c 3] [:b 2] [:a 1]]))))
|
||||
(is (= "{\"a\":1,\"b\":2,\"c\":3}" (canon/write {:a 1 :b 2 :c 3}))))
|
||||
|
||||
(deftest the-text-is-valid-json-because-the-server-reads-two-fields-out-of-it
|
||||
(let [text (canon/write {:detector "mediapipe" :version "1.0.1"
|
||||
:params {:anchor-avg 2 :contour-avg 1}
|
||||
:tracks ["outer" "inner"] :scale 16384})
|
||||
back (js->clj (js/JSON.parse text))]
|
||||
(is (= "mediapipe" (get back "detector")))
|
||||
(is (= "1.0.1" (get back "version")))
|
||||
(is (= 2 (get-in back ["params" "anchor-avg"])))))
|
||||
|
||||
(deftest an-integral-double-is-written-without-a-point
|
||||
;; JS and Python disagree here — `1` against `1.0` — which is exactly why the
|
||||
;; server hashes the text it was sent instead of re-rendering the values.
|
||||
(is (= "1" (canon/write 1.0)))
|
||||
(is (= "1" (canon/write 1)))
|
||||
(is (= "0.12" (canon/write 0.12)))
|
||||
(is (= "-0.5" (canon/write -0.5)))
|
||||
;; And a double that needs all its digits keeps them: shortest round-trip, not
|
||||
;; a fixed precision, or two different takes would share a key.
|
||||
(is (= "0.5625" (canon/write 0.5625)))
|
||||
(is (= (str (/ 1 3)) (canon/write (/ 1 3)))))
|
||||
|
||||
(deftest a-namespaced-key-keeps-its-namespace
|
||||
(is (= "{\"roto/lips-outer\":1}" (canon/write {:roto/lips-outer 1}))))
|
||||
|
||||
(deftest strings-are-escaped-by-a-json-writer-and-not-by-hand
|
||||
(is (= "{\"source\":\"a \\\"quoted\\\" clip.mov\"}"
|
||||
(canon/write {:source "a \"quoted\" clip.mov"})))
|
||||
(is (= "{\"source\":\"café.mov\"}" (canon/write {:source "café.mov"}))))
|
||||
|
||||
(deftest nil-and-the-booleans-are-json-and-not-omitted
|
||||
;; Omitting a nil would make {:presence nil} and {} the same address, and those
|
||||
;; are a take with no absence data and a take whose absence data was forgotten.
|
||||
(is (= "{\"a\":null,\"b\":false,\"c\":true}" (canon/write {:a nil :b false :c true})))
|
||||
(is (not= (canon/write {:presence nil}) (canon/write {}))))
|
||||
|
||||
(deftest a-keyword-value-is-refused-rather-than-named
|
||||
;; Because then :mouth and "mouth" would address the same block, and the field
|
||||
;; the server reads would have a type that depended on the caller.
|
||||
(is (thrown-with-msg? ExceptionInfo #"keyword VALUE" (canon/write {:role :eyes})))
|
||||
(is (thrown-with-msg? ExceptionInfo #"keyword VALUE" (canon/write {:roles [:eyes]}))))
|
||||
|
||||
(deftest a-set-is-refused-because-it-has-no-one-text
|
||||
(is (thrown-with-msg? ExceptionInfo #"sort it into a vector" (canon/write {:kept #{1 2}}))))
|
||||
|
||||
(deftest nan-is-refused-because-it-would-cache-a-measurement-that-went-wrong
|
||||
(is (thrown-with-msg? ExceptionInfo #"NaN or infinity" (canon/write {:scale js/NaN})))
|
||||
(is (thrown-with-msg? ExceptionInfo #"NaN or infinity" (canon/write {:scale js/Infinity}))))
|
||||
|
||||
(deftest nesting-is-ordered-all-the-way-down
|
||||
(is (= (canon/write {:a {:z 1 :y [{:q 1 :p 2}]}})
|
||||
(canon/write {:a {:y [{:p 2 :q 1}] :z 1}}))))
|
||||
110
frontend/test/arthur/domain/leaf_test.cljs
Normal file
110
frontend/test/arthur/domain/leaf_test.cljs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
(ns arthur.domain.leaf-test
|
||||
"Leaf addressing has one property that matters above every other: nothing is
|
||||
lost. A persistence layer that drops a field saves a document which comes back
|
||||
subtly smaller, and the loss is discovered later, by somebody whose work is
|
||||
already gone.
|
||||
|
||||
So the assertion is exact equality on the real scenes — the frozen take in both
|
||||
head modes, the hand-written demo, the swarm — rather than on a fixture, and
|
||||
`scene-keys` makes a field added without a leaf fail loudly instead."
|
||||
(:require [cljs.test :refer [deftest is testing]]
|
||||
[arthur.demo :as demo]
|
||||
[arthur.demo.swarm :as swarm]
|
||||
[arthur.demo.take :as take]
|
||||
[arthur.domain.channel :as ch]
|
||||
[arthur.domain.leaf :as leaf]))
|
||||
|
||||
(deftest every-real-scene-survives-the-split-exactly
|
||||
(doseq [[label scene] [["the frozen take" @take/scene]
|
||||
["the locked take" @take/locked]
|
||||
["the hand-written demo" demo/scene]
|
||||
["the swarm" @swarm/scene]]]
|
||||
(testing label
|
||||
(is (= scene (leaf/scene :c1 (leaf/leaves :c1 scene)))))))
|
||||
|
||||
(deftest the-leaves-are-the-paths-the-sync-design-names
|
||||
(let [ls (leaf/leaves :c7 @take/scene)]
|
||||
(is (contains? ls "clip/c7/timing"))
|
||||
(is (contains? ls "clip/c7/stage"))
|
||||
(is (contains? ls "clip/c7/source"))
|
||||
(is (contains? ls "clip/c7/node/mouth"))
|
||||
(is (contains? ls "clip/c7/channel/mouth/geom.pts"))
|
||||
(is (contains? ls "clip/c7/channel/mouth-in/vis"))
|
||||
(is (contains? ls "clip/c7/feature/eye-r"))
|
||||
(is (contains? ls "clip/c7/group/eyes-1"))
|
||||
(is (contains? ls "clip/c7/subject/face-1"))
|
||||
;; `:head`'s measured channels are written together by a freeze and replaced
|
||||
;; together by a re-freeze, so they are one leaf and not three.
|
||||
(is (contains? ls "clip/c7/measured/head"))
|
||||
(is (= 3 (count (get ls "clip/c7/measured/head"))))))
|
||||
|
||||
(deftest a-node-and-its-channels-are-different-leaves
|
||||
;; The boundary that lets two people key different parts without meeting. A node
|
||||
;; leaf carries structure and no geometry.
|
||||
(let [ls (leaf/leaves :c1 @take/scene)
|
||||
n (get ls "clip/c1/node/mouth")]
|
||||
(is (= {:id :mouth :name "mouth" :kind :poly :parent :head :z "a1"} n))
|
||||
(is (nil? (:channels n)))
|
||||
(is (:animated? (get ls "clip/c1/channel/mouth/geom.pts")))))
|
||||
|
||||
(deftest a-field-with-no-leaf-is-refused-rather-than-dropped
|
||||
;; The invariant that keeps the round trip exact as the model grows: a scene
|
||||
;; field nobody gave a leaf to would save silently and come back missing.
|
||||
(is (thrown-with-msg? ExceptionInfo #"no leaf to save it in"
|
||||
(leaf/leaves :c1 (assoc @take/scene :sequences []))))
|
||||
(is (= leaf/scene-keys (set (keys (assoc @take/scene :name "x"))))
|
||||
"scene-keys has drifted from what a frozen scene actually holds"))
|
||||
|
||||
(deftest an-absent-field-stays-absent
|
||||
;; A scene with no fps must not come back with `:fps nil`. `=` is the test, and
|
||||
;; the demo scene is the case: it has no analysis record and its root has no
|
||||
;; channels.
|
||||
(let [ls (leaf/leaves :c1 demo/scene)]
|
||||
(is (not (contains? ls "clip/c1/source")))
|
||||
(is (not (contains? (leaf/scene :c1 ls) :analysis)))
|
||||
(is (not (contains? (get-in (leaf/scene :c1 ls) [:nodes :root]) :channels)))))
|
||||
|
||||
(deftest a-namespaced-id-is-one-path-segment
|
||||
;; docs/architecture.md draws a node as `:eye-r/iris`, and a leaf path is
|
||||
;; "/"-delimited, so the two have to be reconciled somewhere.
|
||||
(let [scene {:nodes {:eye-r/iris {:id :eye-r/iris :kind :disc :parent nil :z "a1"
|
||||
:channels {[:geom :radius] (ch/framed 2)}}}}
|
||||
ls (leaf/leaves :c1 scene)]
|
||||
(is (contains? ls "clip/c1/node/eye-r~iris"))
|
||||
(is (= scene (leaf/scene :c1 ls))))
|
||||
;; `(keyword "a~b")` rather than a literal: ~ is unquote in CLJS source.
|
||||
(is (thrown-with-msg? ExceptionInfo #"cannot contain ~"
|
||||
(leaf/segment (keyword "a~b")))))
|
||||
|
||||
(deftest another-clips-leaves-are-ignored-rather-than-merged
|
||||
;; A project's whole leaf map can be handed in for one clip, which is what makes
|
||||
;; a two-clip project one fetch.
|
||||
(let [a (leaf/leaves :a @take/scene)
|
||||
b (leaf/leaves :b demo/scene)]
|
||||
(is (= @take/scene (leaf/scene :a (merge a b))))
|
||||
(is (= demo/scene (leaf/scene :b (merge a b))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; what a document may not contain
|
||||
|
||||
(deftest a-placeholder-tier-2-key-is-refused
|
||||
;; `demo/swarm` names its blocks "swarm/pos", which is exactly the descriptive
|
||||
;; key content addressing replaced: a handle that only means something on the
|
||||
;; machine that made it. It is a fine load test and not a document.
|
||||
(let [ps (leaf/problems (leaf/leaves :c1 @swarm/scene))]
|
||||
(is (seq ps))
|
||||
(is (some #(re-find #"names tier 2 as \"swarm/pos\"" %) ps) (pr-str (first ps)))))
|
||||
|
||||
(deftest a-frozen-clip-has-no-problems
|
||||
(is (empty? (leaf/problems (leaf/leaves :c1 @take/scene))))
|
||||
(is (empty? (leaf/problems (leaf/leaves :c1 @take/locked)))))
|
||||
|
||||
(deftest a-channel-leaf-for-a-node-that-is-not-there-is-named
|
||||
(let [ls (dissoc (leaf/leaves :c1 @take/scene) "clip/c1/node/mouth")]
|
||||
(is (some #(re-find #"node with no node leaf" %) (leaf/problems ls)))))
|
||||
|
||||
(deftest a-property-with-path-punctuation-in-it-is-refused
|
||||
(is (thrown-with-msg?
|
||||
ExceptionInfo #"cannot contain . or /"
|
||||
(leaf/leaves :c1 {:nodes {:a {:id :a :kind :poly :parent nil :z "a1"
|
||||
:channels {[:geom :pts.x] (ch/framed [0 0])}}}}))))
|
||||
146
frontend/test/arthur/domain/project_test.cljs
Normal file
146
frontend/test/arthur/domain/project_test.cljs
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
(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))))
|
||||
|
|
@ -13,7 +13,8 @@
|
|||
[arthur.domain.node :as node]
|
||||
[arthur.domain.palette :as pal]
|
||||
[arthur.domain.raster :as raster]
|
||||
[arthur.domain.scene :as scene]))
|
||||
[arthur.domain.scene :as scene]
|
||||
[arthur.support.ops :as ops]))
|
||||
|
||||
(defn- poly [id parent z pts color & [extra]]
|
||||
(merge {:id id :kind :poly :parent parent :z z
|
||||
|
|
@ -27,9 +28,7 @@
|
|||
(defn- ids-at [scene f]
|
||||
(mapv :node (scene/eval-frame scene f)))
|
||||
|
||||
(defn- pts-of [op]
|
||||
(mapv (fn [i] [(aget (:pts op) (* 2 i)) (aget (:pts op) (inc (* 2 i)))])
|
||||
(range (:n op))))
|
||||
(def ^:private pts-of ops/points)
|
||||
|
||||
;; ---- structure ----
|
||||
|
||||
|
|
@ -264,22 +263,16 @@
|
|||
;; 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.
|
||||
(let [s demo/scene
|
||||
res (scene/resolver s)
|
||||
n (:frames s)
|
||||
snapshot (fn [ops]
|
||||
(mapv (fn [op]
|
||||
(cond-> (dissoc op :pts :i)
|
||||
(:pts op) (assoc :points (pts-of op))))
|
||||
ops))]
|
||||
(doseq [[label fs] [["forward" (range n)]
|
||||
["backward" (reverse (range n))]
|
||||
["random access" [0 71 5 5 40 6 70 1 23 24 25 24 23 0 47 48]]
|
||||
["every third" (range 0 n 3)]]]
|
||||
;; 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/scene
|
||||
spec (ops/specified s nil)
|
||||
fast (ops/resolved s nil)]
|
||||
(doseq [[label fs] (ops/orders (:frames s))]
|
||||
(testing label
|
||||
(doseq [f fs]
|
||||
(is (= (snapshot (scene/eval-frame s f)) (snapshot (res f)))
|
||||
(str label " at frame " f)))))))
|
||||
(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,
|
||||
|
|
|
|||
51
frontend/test/arthur/domain/sha256_test.cljs
Normal file
51
frontend/test/arthur/domain/sha256_test.cljs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
(ns arthur.domain.sha256-test
|
||||
"The digests below came out of Python's `hashlib`, which is the implementation
|
||||
this one has to agree with: the server recomputes the key of every block and
|
||||
every analysis it is handed and refuses a mismatch, so a disagreement between
|
||||
the two languages is an upload that fails with nothing wrong.
|
||||
|
||||
The lengths are chosen, not arbitrary. 55 and 56 bytes are either side of the
|
||||
point where the length field no longer fits in the first block, and 63/64 and
|
||||
119/120 are the block boundaries themselves. A hand-written SHA-256 that is
|
||||
wrong is almost always wrong exactly there, or wrong about sign — see the
|
||||
namespace docstring — and a sign bug is invisible on short inputs."
|
||||
(:require [cljs.test :refer [deftest is testing]]
|
||||
[arthur.domain.sha256 :as sha]))
|
||||
|
||||
(defn- a [n] (apply str (repeat n "a")))
|
||||
|
||||
(deftest the-digests-are-the-ones-hashlib-gives
|
||||
(doseq [[input expect]
|
||||
[["" "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"]
|
||||
["abc" "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"]
|
||||
["abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"
|
||||
"248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"]
|
||||
[(a 55) "9f4390f8d30c2dd92ec9f095b65e2b9ae9b0a925a5258e241c9f1e910f734318"]
|
||||
[(a 56) "b35439a4ac6f0948b6d6f9e3c6af0f5f590ce20f1bde7090ef7970686ec6738a"]
|
||||
[(a 63) "7d3e74a05d7db15bce4ad9ec0658ea98e3f06eeecf16b4c6fff2da457ddc2f34"]
|
||||
[(a 64) "ffe054fe7ae0cb6dc65c3af9b61d5209f439851db43d0ba5997337df154668eb"]
|
||||
[(a 119) "31eba51c313a5c08226adf18d4a359cfdfd8d2e816b13f4af952f7ea6584dcfb"]
|
||||
[(a 120) "2f3d335432c70b580af0e8e1b3674a7c020d683aa5f73aaaedfdc55af904c21c"]
|
||||
["arthur" "befa156f0283eb0062beb9b86e16a413e1cf8c5135e5518d5c4fa321ce0c7b6b"]]]
|
||||
(is (= expect (sha/of-string input))
|
||||
(str (count input) " bytes"))))
|
||||
|
||||
(deftest a-non-ascii-descriptor-hashes-as-utf-8
|
||||
;; A descriptor holds source filenames, so this is reachable from a clip called
|
||||
;; "café.mov" and not a curiosity. UTF-16 code units would give another answer.
|
||||
(is (= "3392aa2b9d70af3b1de0c4d4f7bc6fcd02e1df8079263bb7cda7b1b71704a90a"
|
||||
(sha/of-string "café — naïve ✓"))))
|
||||
|
||||
(deftest raw-bytes-hash-too-because-a-block-is-bytes
|
||||
(is (= "40aff2e9d2d8922e47afd4648e6967497158785fbd1da870e7110266bf944880"
|
||||
(sha/of-bytes (js/Uint8Array. (into-array (range 256)))))))
|
||||
|
||||
(deftest a-key-says-which-algorithm-produced-it
|
||||
(is (= (str "sha256:" (sha/of-string "abc")) (sha/key-of "abc")))
|
||||
;; The point of the prefix: a key cannot be mistaken for the descriptive
|
||||
;; placeholder — "take/geom" — that content addressing replaced.
|
||||
(is (re-matches #"sha256:[0-9a-f]{64}" (sha/key-of "abc"))))
|
||||
|
||||
(deftest one-changed-bit-changes-the-key
|
||||
;; The whole property everything above this file relies on, asserted once.
|
||||
(is (not= (sha/of-string "anchor-avg:2") (sha/of-string "anchor-avg:3"))))
|
||||
91
frontend/test/arthur/domain/wire_test.cljs
Normal file
91
frontend/test/arthur/domain/wire_test.cljs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
(ns arthur.domain.wire-test
|
||||
"What the wire format has to carry, stated as the things JSON would have lost."
|
||||
(:require [cljs.test :refer [deftest is testing]]
|
||||
[arthur.demo.take :as take]
|
||||
[arthur.domain.channel :as ch]
|
||||
[arthur.domain.leaf :as leaf]
|
||||
[arthur.domain.wire :as wire]))
|
||||
|
||||
(defn- round [v] (wire/decode (wire/encode v)))
|
||||
(defn- round-json [v] (wire/decode-json (wire/encode-json v)))
|
||||
|
||||
(deftest a-frame-key-comes-back-a-number
|
||||
;; THE reason this is transit. Keys are a map by FRAME, and `{"0" v}` is not
|
||||
;; `{0 v}`: `value-at` would find no key at frame 0 and the part would hold its
|
||||
;; first pose forever, on a document that looked fine.
|
||||
(let [c (ch/keyed {0 true 4 false 12 true})]
|
||||
(is (= c (round c)))
|
||||
(is (every? number? (keys (:keys (round c)))))
|
||||
(is (= true (ch/value-at (round c) 13)))))
|
||||
|
||||
(deftest an-id-comes-back-a-keyword
|
||||
(is (= {:id :mouth-in :kind :poly :parent :mouth :z "a2"}
|
||||
(round {:id :mouth-in :kind :poly :parent :mouth :z "a2"})))
|
||||
(is (keyword? (:id (round {:id :mouth})))))
|
||||
|
||||
(deftest a-channels-property-vector-survives-as-a-map-key
|
||||
;; `:channels` is keyed by `[:geom :pts]`, and a format with string keys only
|
||||
;; would have to invent an encoding for that — which is what leaf paths do for
|
||||
;; addressing and what the wire format must NOT have to do for values.
|
||||
(let [m {[:geom :pts] (ch/framed [0 1]) [:vis] (ch/framed true)}]
|
||||
(is (= m (round m)))
|
||||
(is (vector? (first (keys (round m)))))))
|
||||
|
||||
(deftest a-keyed-channel-comes-back-a-plain-map-and-not-a-sorted-one
|
||||
;; Transit loses sortedness, which is why `domain/channel` says keys are a PLAIN
|
||||
;; map and builds the sorted index at read time. Asserted so that nobody
|
||||
;; "improves" the codec into a sorted map that works until the first round trip.
|
||||
(let [c (round (ch/keyed (into {} (map (juxt identity str)) (range 20))))]
|
||||
(is (map? (:keys c)))
|
||||
(is (not (sorted? (:keys c))))
|
||||
(is (= (vec (range 20)) (ch/frames c)))))
|
||||
|
||||
(deftest the-numbers-come-back-as-themselves
|
||||
(is (= {:a 0.5625 :b -1 :c 1e-9 :d 0 :e 16384}
|
||||
(round {:a 0.5625 :b -1 :c 1e-9 :d 0 :e 16384}))))
|
||||
|
||||
(deftest an-empty-vector-stays-an-empty-vector
|
||||
;; `:over` is present and empty by design — port-plan step 2 scope — and
|
||||
;; `channel/check-unimplemented!` throws on a NON-empty one, so a codec that
|
||||
;; turned `[]` into nil or into `[nil]` would either lose the field or refuse to
|
||||
;; play the document back.
|
||||
(is (= {:over []} (round {:over []})))
|
||||
(is (= [] (:over (round (ch/keyed {0 1}))))))
|
||||
|
||||
(deftest a-whole-leaf-map-round-trips-through-parsed-json
|
||||
;; What a save actually does: transit, then parsed so the column holds JSON.
|
||||
(let [ls (leaf/leaves :c1 @take/scene)]
|
||||
(is (= ls (into {} (map (fn [[p v]] [p (round-json v)])) ls)))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; the bytes
|
||||
|
||||
(deftest a-block-comes-back-byte-for-byte
|
||||
(doseq [[label array] [["int16" (js/Int16Array. #js [0 1 -1 32767 -32768 12345])]
|
||||
["float32" (js/Float32Array. #js [0 1.5 -0.25 1e-8])]
|
||||
["uint8" (js/Uint8Array. #js [0 1 255])]]]
|
||||
(testing label
|
||||
(let [back (wire/typed label (wire/base64 array))]
|
||||
(is (= (vec (array-seq array)) (vec (array-seq back))))
|
||||
(is (= (.-constructor array) (.-constructor back)))))))
|
||||
|
||||
(deftest a-real-sized-block-does-not-overflow-the-stack
|
||||
;; `String.fromCharCode.apply` with a few hundred thousand arguments throws a
|
||||
;; RangeError from inside a save, pointing nowhere near the array that caused it.
|
||||
;; A 600-frame geometry block is that size, so the chunking is load-bearing.
|
||||
(let [big (js/Int16Array. 600000)]
|
||||
(dotimes [i 600000] (aset big i (- (mod i 65536) 32768)))
|
||||
(let [back (wire/typed "int16" (wire/base64 big))]
|
||||
(is (= 600000 (.-length back)))
|
||||
(is (= (aget big 599999) (aget back 599999))))))
|
||||
|
||||
(deftest a-view-into-a-block-encodes-only-its-own-bytes
|
||||
;; `dense-at` hands out SUBARRAYS, so a caller can reach this with a view whose
|
||||
;; byteOffset is not zero. Encoding the whole underlying buffer would silently
|
||||
;; store the neighbouring tracks too.
|
||||
(let [whole (js/Int16Array. #js [1 2 3 4 5 6])
|
||||
view (.subarray whole 2 4)]
|
||||
(is (= [3 4] (vec (array-seq (wire/typed "int16" (wire/base64 view))))))))
|
||||
|
||||
(deftest an-unknown-element-type-is-refused
|
||||
(is (thrown-with-msg? ExceptionInfo #"int16" (wire/typed "float64" "AA=="))))
|
||||
217
frontend/test/arthur/flow/address_test.cljs
Normal file
217
frontend/test/arthur/flow/address_test.cljs
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
(ns arthur.flow.address-test
|
||||
"Content addressing has one failure mode in each direction, and reading the
|
||||
table in `flow/address` will catch neither.
|
||||
|
||||
A KNOB MISSING from a block's table gives two different sets of bytes the same
|
||||
name. The symptom is not an error: it is a slider that appears to do nothing
|
||||
until something else forces a reload, and then does everything at once. That is
|
||||
the bug docs/architecture.md describes as \"the tool got worse\" with no event to
|
||||
attach it to.
|
||||
|
||||
A KNOB TOO MANY throws away a good bake on an unrelated tweak. Invisible while
|
||||
everything is fast, and the reason baking exists once it is not.
|
||||
|
||||
So the table is not trusted. `every-knob-that-moves-a-block-renames-it` freezes
|
||||
the same synthetic take once per knob and asserts the biconditional per block:
|
||||
the bytes changed if and only if the key changed. It is the same shape of
|
||||
assertion as `each-dense-track-follows-its-own-features-presence` in
|
||||
freeze-test, for the same reason — a hand-maintained mapping needs a test that
|
||||
fails when the hand is wrong."
|
||||
(:require [cljs.test :refer [deftest is testing]]
|
||||
[arthur.domain.params :as params]
|
||||
[arthur.domain.sha256 :as sha]
|
||||
[arthur.flow.address :as address]
|
||||
[arthur.flow.freeze :as freeze]
|
||||
[arthur.flow.take :as take]
|
||||
[arthur.synth :as synth]))
|
||||
|
||||
;; Short on purpose: the property is about which inputs reach which block, and it
|
||||
;; holds at any length. Twenty-four freezes of the 229-frame take would be a
|
||||
;; minute of test time to assert nothing extra.
|
||||
(def ^:private frames 48)
|
||||
(def ^:private fps 30)
|
||||
|
||||
(def ^:private dense-track (delay (synth/synth-dense frames {:seed 3})))
|
||||
|
||||
(defn- params-at [overrides]
|
||||
(merge take/knobs
|
||||
{:name "addr" :fps fps :aspect 1 :stage [320 200]
|
||||
:expose 1 :head :as-filmed}
|
||||
overrides))
|
||||
|
||||
(defn- freeze-at
|
||||
"Measure AND freeze at these settings, which is what a re-freeze does: several
|
||||
of the knobs act in stage 4, so a fixture that only re-froze would hold most of
|
||||
them still and pass whatever the table said."
|
||||
[overrides]
|
||||
(let [p (params-at overrides)
|
||||
p (assoc p :analysis (address/analysis
|
||||
(merge {:detector "synth" :version "mulberry32"
|
||||
:seed 3 :frames frames :fps (:fps p)
|
||||
:aspect (:aspect p)}
|
||||
(:detector overrides))))]
|
||||
(freeze/clip p (take/measure p {:dense @dense-track}))))
|
||||
|
||||
(defn- bytes-of [{:keys [data state]}]
|
||||
(str (sha/of-bytes (js/Uint8Array. (.-buffer data)))
|
||||
"/" (if state (sha/of-bytes state) "-")))
|
||||
|
||||
(defn- by-role
|
||||
"role -> {:key :bytes}. The role comes out of the block's own descriptor, which
|
||||
is how a block is identified across two freezes that renamed it."
|
||||
[clip]
|
||||
(into {}
|
||||
(map (fn [[k entry]]
|
||||
[(get (js->clj (js/JSON.parse (:descriptor entry))) "role")
|
||||
{:key k :bytes (bytes-of entry)}]))
|
||||
(:store clip)))
|
||||
|
||||
(def ^:private base (delay (by-role (freeze-at {}))))
|
||||
|
||||
;; The knobs whose effect is upstream of `flow/take/measure`: they are read by
|
||||
;; `measure/interior`, which runs over SOURCE PIXELS in events/footage before any
|
||||
;; of this. A synthetic fixture has no pixels to move, so the biconditional cannot
|
||||
;; be asserted for them here and `the-teeth-table-is-asserted-at-the-descriptor`
|
||||
;; asserts the half that is assertable instead.
|
||||
(def ^:private pixel-knobs
|
||||
#{:cavity-erode :tongue-reject :blob-grow :top-bias :teeth-verts :min-area
|
||||
:teeth-on :teeth-smooth})
|
||||
|
||||
(defn- bump
|
||||
"A different, still valid value for a knob — and a LARGE difference, which is
|
||||
not laziness about picking one.
|
||||
|
||||
Several of these knobs feed `condition/quantize-snap`, which rounds onto a grid:
|
||||
gaze and brow cells, the blink hold. A 3% change to `gaze-gain` moves every
|
||||
sample inside the cell it was already in, so the bytes come out identical and
|
||||
the biconditional reports the knob as an input the block does not have — which
|
||||
is true of that perturbation and false of the knob. A 75% change crosses cells.
|
||||
The first version of this test asserted the fixture's resolution rather than the
|
||||
invalidation table."
|
||||
[id]
|
||||
(let [{:keys [type default] must-even? :even?} (get params/definitions id)]
|
||||
(cond
|
||||
(and (= :integer type) must-even?) (+ default 2)
|
||||
(= :integer type) (+ default 1)
|
||||
(zero? default) 0.5
|
||||
:else (* default 1.75))))
|
||||
|
||||
(deftest every-knob-that-moves-a-block-renames-it
|
||||
(doseq [id (sort (remove pixel-knobs (keys params/definitions)))]
|
||||
(let [moved (by-role (freeze-at {id (bump id)}))]
|
||||
(testing (str id " " (get params/defaults id) " -> " (bump id))
|
||||
(is (= (set (keys @base)) (set (keys moved)))
|
||||
"a knob changed which blocks exist at all")
|
||||
(doseq [role (sort (keys @base))]
|
||||
(let [a (get @base role)
|
||||
b (get moved role)]
|
||||
(is (= (not= (:bytes a) (:bytes b))
|
||||
(not= (:key a) (:key b)))
|
||||
(str role ": bytes " (if (= (:bytes a) (:bytes b)) "same" "differ")
|
||||
" but key " (if (= (:key a) (:key b)) "same" "differs")
|
||||
" — " (if (= (:key a) (:key b))
|
||||
(str "add " id " to block-knobs for " (pr-str role))
|
||||
(str "remove " id " from block-knobs for " (pr-str role)))))))))))
|
||||
|
||||
(deftest the-teeth-table-is-asserted-at-the-descriptor
|
||||
;; Every knob the teeth block declares must reach its key. The other half — that
|
||||
;; each one also moves its bytes — needs real pixels, because the crop, the otsu
|
||||
;; threshold and the radial contour all happen in `measure/interior` above the
|
||||
;; stage this fixture starts at. Stated rather than quietly skipped.
|
||||
(let [spec {:role "teeth" :analysis "sha256:0" :params params/defaults
|
||||
:tracks ["contour"] :features [:teeth]
|
||||
:observation nil
|
||||
:layout {:type "int16" :scale 16384 :stride 20 :frames 48 :tracks 1}}
|
||||
base (:key (address/block spec))]
|
||||
(doseq [id (get address/block-knobs "teeth")]
|
||||
(is (not= base (:key (address/block (assoc-in spec [:params id] (bump id)))))
|
||||
(str id " does not reach the teeth block's key")))))
|
||||
|
||||
(deftest a-block-whose-role-has-no-table-is-refused
|
||||
;; The table cannot be forgotten for a new block: without an entry there is no
|
||||
;; answer to "which knobs rename this", and a key that never changes is worse
|
||||
;; than no key at all.
|
||||
(is (thrown-with-msg?
|
||||
ExceptionInfo #"no invalidation table"
|
||||
(address/block {:role "plate" :analysis "sha256:0" :params {} :tracks []
|
||||
:features [] :observation nil :layout {}}))))
|
||||
|
||||
(deftest a-knob-the-freeze-was-not-handed-is-refused
|
||||
(is (thrown-with-msg?
|
||||
ExceptionInfo #"knob this block's bytes depend on"
|
||||
(address/block {:role "geom" :analysis "sha256:0"
|
||||
:params {:anchor-avg 2} :tracks ["outer"]
|
||||
:features [:mouth] :observation nil :layout {}}))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; the detector version
|
||||
|
||||
(deftest the-detector-version-reaches-every-block
|
||||
;; THE requirement, and the reason a key is a hash over inputs rather than over
|
||||
;; bytes: after a model upgrade the old blocks must be UNREACHABLE, not merely
|
||||
;; different. Every block, because a version in one key and not another is the
|
||||
;; same bug with a smaller blast radius.
|
||||
(let [upgraded (by-role (freeze-at {:detector {:version "1.0.2"}}))]
|
||||
(is (= (set (keys @base)) (set (keys upgraded))))
|
||||
(doseq [role (sort (keys @base))]
|
||||
(is (not= (:key (get @base role)) (:key (get upgraded role)))
|
||||
(str role " survived a detector upgrade under its old name"))
|
||||
;; And nothing was recomputed: same numbers, new name. That is what makes
|
||||
;; this a cache-key change and not a re-analysis.
|
||||
(is (= (:bytes (get @base role)) (:bytes (get upgraded role)))))))
|
||||
|
||||
(deftest an-analysis-without-a-version-is-refused
|
||||
(is (thrown-with-msg? ExceptionInfo #"detector's VERSION"
|
||||
(address/analysis {:detector "mediapipe" :frames 1})))
|
||||
(is (thrown-with-msg? ExceptionInfo #"detector's VERSION"
|
||||
(address/analysis {:version "1.0.1" :frames 1}))))
|
||||
|
||||
(deftest the-analysis-id-is-a-hash-of-its-own-descriptor
|
||||
(let [a (address/analysis {:detector "synth" :version "mulberry32" :seed 1
|
||||
:frames 229 :fps 30 :aspect 1})]
|
||||
(is (= (:id a) (sha/key-of (address/analysis-descriptor a))))
|
||||
(is (sha/key? (:id a)))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; the rest of what a key is made of
|
||||
|
||||
(deftest the-same-inputs-give-the-same-keys
|
||||
;; Twice, independently — not the same clip read twice. Map order, `delay`s and
|
||||
;; `Math.round` are all places a second run could differ, and a key that is not
|
||||
;; reproducible addresses nothing.
|
||||
(is (= (into {} (map (juxt key (comp :key val))) (by-role (freeze-at {})))
|
||||
(into {} (map (juxt key (comp :key val))) (by-role (freeze-at {}))))))
|
||||
|
||||
(deftest a-key-is-the-hash-of-the-descriptor-stored-beside-it
|
||||
;; What the server checks on upload, checked here too, because if it can only
|
||||
;; fail on the wire it fails as a 409 with nothing wrong.
|
||||
(doseq [[k entry] (:store (freeze-at {}))]
|
||||
(is (sha/key? k))
|
||||
(is (= k (sha/key-of (:descriptor entry)))
|
||||
(str "the descriptor stored under " k " is not what that key hashes"))))
|
||||
|
||||
(deftest a-presence-gap-renames-only-the-blocks-that-read-it
|
||||
;; The observation digest is per block. A brow occlusion must not rename the
|
||||
;; mouth's block: that would be correct and useless, throwing away every bake in
|
||||
;; the clip on one feature's gap.
|
||||
(let [gap (set (range 10 15))
|
||||
with (by-role (freeze/clip
|
||||
(assoc (params-at {}) :analysis
|
||||
(address/analysis {:detector "synth" :version "mulberry32"
|
||||
:seed 3 :frames frames :fps fps :aspect 1}))
|
||||
(assoc (take/measure (params-at {}) {:dense @dense-track})
|
||||
:presence {:brow-r (mapv #(not (contains? gap %))
|
||||
(range frames))})))]
|
||||
(is (not= (:key (get @base "brows")) (:key (get with "brows"))))
|
||||
(is (not= (:key (get @base "brow-pos")) (:key (get with "brow-pos"))))
|
||||
(doseq [role ["geom" "eyes" "iris-pos" "head-pos" "head-rot" "head-scale"]]
|
||||
(is (= (:key (get @base role)) (:key (get with role)))
|
||||
(str role " was renamed by a gap in a brow")))))
|
||||
|
||||
(deftest the-clips-name-and-stage-are-not-in-any-key
|
||||
;; Tier 1 facts. Two clips of one take under two names, at two stage sizes, are
|
||||
;; the same analysis over the same bytes, and sharing them is the return on
|
||||
;; addressing. A name in the key would make every rename a re-freeze.
|
||||
(let [other (by-role (freeze-at {:name "another take" :stage [640 480]}))]
|
||||
(doseq [role (sort (keys @base))]
|
||||
(is (= (:key (get @base role)) (:key (get other role))) role))))
|
||||
|
|
@ -33,6 +33,11 @@
|
|||
(defn- node [id] (get-in @scene* [:nodes id]))
|
||||
(defn- chan [id path] (get-in (node id) [:channels path]))
|
||||
|
||||
(defn- block-of
|
||||
"The typed array a dense channel reads, through its own handle."
|
||||
[ch]
|
||||
(:data (get @store (:store (:dense ch)))))
|
||||
|
||||
(defn- pts-at
|
||||
"The mouth's [:geom :pts] at frame f, as a flat CLJS vector."
|
||||
[id f]
|
||||
|
|
@ -127,8 +132,11 @@
|
|||
(doseq [path [[:xform :pos] [:xform :rot] [:xform :scale]]]
|
||||
(is (nil? (:scale (:dense (get-in (node :head) [:measured path]))))
|
||||
(str path " should be plain Float32")))
|
||||
(is (instance? js/Int16Array (:data (get @store "take/geom"))))
|
||||
(is (instance? js/Float32Array (:data (get @store "take/head-pos")))))
|
||||
;; Reached through the channel's own handle rather than by naming a key. A key
|
||||
;; is a hash now, so a test that wrote one out would be asserting a digest.
|
||||
(is (instance? js/Int16Array (block-of (chan :mouth [:geom :pts]))))
|
||||
(is (instance? js/Float32Array
|
||||
(block-of (get-in (node :head) [:measured [:xform :pos]])))))
|
||||
|
||||
(deftest a-value-past-the-block-s-range-is-refused-rather-than-saturated
|
||||
;; Saturating reads as articulation flattening off at the extremes — a bad
|
||||
|
|
@ -305,10 +313,17 @@
|
|||
;; asking for a different stage moves and rescales the same geometry rather than
|
||||
;; re-measuring anything.
|
||||
(let [big (freeze/clip (assoc take/params :stage [640 480] :name "big")
|
||||
@take/measured)]
|
||||
@take/measured)
|
||||
key-of (fn [sc] (:store (:dense (get-in sc [:nodes :mouth :channels [:geom :pts]]))))]
|
||||
(is (= [640 480] [(:width (:scene big)) (:height (:scene big))]))
|
||||
(is (= (vec (array-seq (:data (get @store "take/geom"))))
|
||||
(vec (array-seq (:data (get (:store big) "big/geom")))))
|
||||
;; Stronger than it was, and for free: the stage is not an input to tier 2, so
|
||||
;; the two clips do not merely hold equal bytes — they name the SAME BLOCK, and
|
||||
;; a stage change cannot invalidate a bake. The clip's name is not an input
|
||||
;; either, which is why "big" and "take" still agree.
|
||||
(is (= (key-of @scene*) (key-of (:scene big)))
|
||||
"a different stage is a different document over the same tier 2")
|
||||
(is (= (vec (array-seq (:data (get @store (key-of @scene*)))))
|
||||
(vec (array-seq (:data (get (:store big) (key-of (:scene big)))))))
|
||||
"the geometry is the same numbers at either stage size")
|
||||
(is (not= (:value (get-in (:scene big) [:nodes :face :channels [:xform :scale]]))
|
||||
(:value (chan :face [:xform :scale]))))))
|
||||
|
|
|
|||
59
frontend/test/arthur/support/ops.cljs
Normal file
59
frontend/test/arthur/support/ops.cljs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
(ns arthur.support.ops
|
||||
"Comparing two evaluations of a scene, frame for frame.
|
||||
|
||||
`domain/scene` has two evaluators on purpose — `eval-frame` is the
|
||||
specification and `resolver` is what playback uses — and scene-test's central
|
||||
assertion is that they agree in forward, backward and random frame order.
|
||||
Step 9 needs the same comparison for a different question: that a scene which
|
||||
has been through the server produces the same ops as the one that went in.
|
||||
|
||||
Shared rather than copied, because the interesting part is not the equality —
|
||||
it is the FRAME ORDERS. The resolver holds a cursor per channel and reuses one
|
||||
point buffer per node, so it can agree on a forward pass and disagree on a
|
||||
scrub, and a copy of this list that forgot 'backward' would test the easy half.
|
||||
|
||||
`snapshot` is what makes ops comparable at all: a resolved op carries `:pts` as
|
||||
a VIEW into a reused buffer, so two ops from different frames can be `=` while
|
||||
naming the same array, and holding one and then asking for the next frame
|
||||
changes what the first one says. Reading the points out is what pins the frame."
|
||||
(:require [arthur.domain.palette :as pal]
|
||||
[arthur.domain.scene :as scene]))
|
||||
|
||||
(defn points
|
||||
"An op's points as a vector of [x y], read out of its buffer."
|
||||
[op]
|
||||
(mapv (fn [i] [(aget (:pts op) (* 2 i)) (aget (:pts op) (inc (* 2 i)))])
|
||||
(range (:n op))))
|
||||
|
||||
(defn snapshot
|
||||
"Ops -> comparable data. `:i` goes too: it is the draw-order index, and it is a
|
||||
function of the scene rather than of the frame."
|
||||
[ops]
|
||||
(mapv (fn [op]
|
||||
(cond-> (dissoc op :pts :i)
|
||||
(:pts op) (assoc :points (points op))))
|
||||
ops))
|
||||
|
||||
(defn orders
|
||||
"The frame orders any two evaluators have to agree in, over `n` frames.
|
||||
|
||||
Random access is a fixed list rather than a shuffle: a failure that only
|
||||
reproduces one run in five is worse than no test."
|
||||
[n]
|
||||
[["forward" (range n)]
|
||||
["backward" (reverse (range n))]
|
||||
["random access" (filterv #(< % n) [0 71 5 5 40 6 70 1 23 24 25 24 23 0 47 48])]
|
||||
["every third" (range 0 n 3)]])
|
||||
|
||||
(defn specified
|
||||
"(fn [f] -> snapshot) through `eval-frame`, the specification."
|
||||
([scene store] (specified scene store pal/index-of))
|
||||
([scene store palette]
|
||||
(fn [f] (snapshot (scene/eval-frame scene f store palette)))))
|
||||
|
||||
(defn resolved
|
||||
"(fn [f] -> snapshot) through `resolver`, the playback path."
|
||||
([scene store] (resolved scene store pal/index-of))
|
||||
([scene store palette]
|
||||
(let [res (scene/resolver scene store palette)]
|
||||
(fn [f] (snapshot (res f))))))
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
// Drives a real Chrome at the running dev server and checks that the frozen
|
||||
// take is a MOVING MOUTH on a canvas.
|
||||
// Drives a real Chrome at the running server and checks two things that no
|
||||
// assertion in cljs.test can: that the frozen take is a MOVING MOUTH on a canvas,
|
||||
// and that a document which has been through the server comes back as the same
|
||||
// picture.
|
||||
//
|
||||
// This exists because port-plan step 5 is the first step whose done-criterion is
|
||||
// a picture, and a picture cannot be asserted from cljs.test. A take that
|
||||
|
|
@ -11,10 +13,13 @@
|
|||
// nothing: `node --experimental-websocket` has a global WebSocket and
|
||||
// `--headless=new --remote-debugging-port=N` is the whole of the other side.
|
||||
//
|
||||
// cd frontend
|
||||
// mise exec -- npx shadow-cljs compile app
|
||||
// mise exec -- npx shadow-cljs watch app # or `server`, for :dev-http
|
||||
// mise exec -- node --experimental-websocket test/browser/take.mjs
|
||||
// Since step 9 the page is Django's, so the suite needs the backend up rather than
|
||||
// shadow-cljs's `:dev-http`, which is gone. ARTHUR_URL is unchanged because the
|
||||
// port is unchanged — 8778 was never 8777, which is still the old JS tool's.
|
||||
//
|
||||
// mise exec -- python manage.py runserver 8778 # from the REPO ROOT
|
||||
// cd frontend && mise exec -- npx shadow-cljs watch app
|
||||
// cd frontend && mise exec -- node --experimental-websocket test/browser/take.mjs
|
||||
//
|
||||
// Writes a PNG per sampled frame into test/browser/out/ so that "it drew
|
||||
// something" can be checked by eye as well as by pixel count.
|
||||
|
|
@ -198,6 +203,11 @@ const SEEK = (f) => `(() => {
|
|||
return el.value;
|
||||
})()`;
|
||||
|
||||
// Everything the page has to say about loading, saving and opening. Read off the
|
||||
// page rather than out of app-db, for the same reason the playhead is: what the
|
||||
// page SHOWS is what a person would check.
|
||||
const STATUS = `[...document.querySelectorAll('.load-status')].map((d) => d.textContent).join(' | ')`;
|
||||
|
||||
const CLICK = (label) => `(() => {
|
||||
const b = [...document.querySelectorAll('.transport button')]
|
||||
.find((b) => b.textContent.trim() === ${JSON.stringify(label)});
|
||||
|
|
@ -312,6 +322,71 @@ async function main() {
|
|||
`${new Set(during.map((p) => p.hash)).size} distinct of ${during.length}`);
|
||||
await page.shot('take-playing');
|
||||
|
||||
// --- it round-trips through the server ---
|
||||
//
|
||||
// THE DONE CRITERION of port-plan step 9, end to end: tier 1 over HTTP, tier 2
|
||||
// as content-addressed blocks, and the same frames on the far side. The ops are
|
||||
// compared frame for frame in arthur.domain.project-test, which is the strict
|
||||
// version of this; what only a browser can check is that the whole path — the
|
||||
// CSRF header, the block upload, the leaf write, the reload, the typed arrays
|
||||
// rebuilt out of base64 — draws the same pixels at the end of it.
|
||||
async function statusMatching(pattern, tries = 120) {
|
||||
for (let i = 0; i < tries; i++) {
|
||||
const text = await page.eval(STATUS);
|
||||
if (pattern.test(text)) return text;
|
||||
await sleep(250);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
async function sample(frames) {
|
||||
const out = [];
|
||||
for (const f of frames) {
|
||||
await page.eval(SEEK(f));
|
||||
await sleep(120);
|
||||
out.push(await page.eval(PROBE));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const FRAMES = [0, 10, 28, 80, 160];
|
||||
check(await page.eval(CLICK('take')), 'back to the take, for the round trip');
|
||||
await sleep(150);
|
||||
const sent = await sample(FRAMES);
|
||||
|
||||
check(await page.eval(CLICK('save')), 'save is clickable');
|
||||
const saved = await statusMatching(/saved r\d+/);
|
||||
check(saved !== null, 'the document saves', saved ?? (await page.eval(STATUS)));
|
||||
// Eleven blocks the first time. The COUNT is not asserted — that is a fact
|
||||
// about the freeze, not about saving — but that some went up is.
|
||||
check(/· [1-9]\d* blocks?/.test(saved ?? ''), 'and its tier 2 went with it', saved ?? '');
|
||||
|
||||
// Again, unchanged. Content addressing means the second save uploads nothing
|
||||
// and rewrites nothing: this is the assertion that the keys are stable across
|
||||
// two independent freezes of the same take, and that an unchanged leaf keeps
|
||||
// its version rather than being rewritten.
|
||||
check(await page.eval(CLICK('save')), 'save is clickable again');
|
||||
const resaved = await statusMatching(/saved r\d+ · 0 leaves · 0 blocks/);
|
||||
check(resaved !== null, 'saving an unchanged document writes nothing',
|
||||
resaved ?? (await page.eval(STATUS)));
|
||||
|
||||
check(await page.eval(CLICK('open')), 'open is clickable');
|
||||
const opened = await statusMatching(/opened /);
|
||||
check(opened !== null, 'the project opens', opened ?? (await page.eval(STATUS)));
|
||||
|
||||
const back = await sample(FRAMES);
|
||||
await page.shot('take-round-trip');
|
||||
check(back.every((p) => p.drawn > 200), 'the reopened take draws',
|
||||
back.map((p) => p.drawn).join(','));
|
||||
check(FRAMES.every((f, i) => sent[i].hash === back[i].hash),
|
||||
'every sampled frame is the same picture after the round trip',
|
||||
FRAMES.filter((f, i) => sent[i].hash !== back[i].hash).join(',') || 'all identical');
|
||||
check(back[1].toneSet.includes(MOUTH_DARK),
|
||||
'and the open mouth still has an interior on the far side');
|
||||
// The reopened clip is not one of the built-ins: this is the document that came
|
||||
// back from the server, not the one that was in the page all along.
|
||||
check(!back[0].scene.includes('take'), 'the picture is the reopened document',
|
||||
JSON.stringify(back[0].scene));
|
||||
|
||||
check(page.logs.length === 0, 'no errors on the console',
|
||||
page.logs.slice(0, 3).join(' | '));
|
||||
} finally {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue