✨ Integrate renderer benchmark case phases

- Run preparation, main and reset through the shared command executor.
- Refactor preparation into named operations and pure data projections.
- Fully document the browser bridge.

AI-assisted-by: gpt-6.1-sol
This commit is contained in:
Miguel de Benito Delgado 2026-11-03 18:49:29 +00:00
parent c369753949
commit 0bd3fcd679
6 changed files with 1136 additions and 382 deletions

View File

@ -61,8 +61,8 @@ The suite has three parts:
policy, completion rule, optional operation settings, and a run function. A
case is the unit that is measured.
- **Operation**: the function that performs a case's work during one attempt.
It receives a runtime context with the case data, scene snapshot, renderer
module, view, and hooks. Standard operations load, pan, and zoom.
It receives a command context with the case data, scene snapshot, current
view, and hooks. Standard operations load, pan, and zoom.
- **View**: the camera's zoom `:scale` and offsets `:x` and `:y`.
Optional `:viewport` settings give the canvas width, height, and device pixel
ratio, the number of drawing-buffer pixels per CSS pixel along each dimension.
@ -90,9 +90,9 @@ The suite has three parts:
context, snapshot, and upload serve all attempts of the case.
- **Preparation**: setup for one graphics context. It covers module
instantiation, WebGL2 context creation, scene build, snapshot validation,
fingerprinting, upload-order derivation, and upload. A warm preparation also
restores the starting view and drains to `Full`. One page owns one
preparation.
fingerprinting, upload-order derivation, and upload. It then runs the optional
preparation callback and drains to `Full` unless the case declares
`:preparation {:render :none}`. One page owns one preparation.
- **Attempt**: one execution of a case against a preparation. An attempt
records one outcome, one set of metrics, and a raw trace. A failed attempt
still consumes its allocated slot.
@ -234,7 +234,7 @@ The browser bridge owns everything inside the page:
- it validates a request before assigning the page to that work;
- it imports the renderer module and creates the canvas and WebGL2 context;
- it builds, validates, fingerprints, orders, and uploads the scene;
- it restores the starting view outside timing for warm cases;
- it carries each phase's returned context into the next phase or attempt;
- it runs the case operation and records the requested measurements;
- it returns plain data and disposes the context when asked.
@ -264,7 +264,7 @@ sequenceDiagram
B->>B: build, validate, fingerprint the snapshot
B->>W: upload shapes in tree order
B->>W: render until Full
B-->>N: metrics, slices, and preparation evidence
B-->>N: preparation evidence and raw phase blocks
N->>N: persist the run record
```
@ -378,10 +378,10 @@ derives the scene from that keyword namespace and attaches the run function.
{:params default-params
:view {:scale 1 :x 0 :y 0}
:context :reuse
:completion :render-full
:operation {:steps 20 :dx 200 :dy 0 :settle-ms 100}}
[rtx]
(camera/pan! rtx))
:reset (fn [rtx]
(camera/pan rtx {:steps 20 :dx -200 :dy 0 :settle-ms 100}))}
[rtx]
(camera/pan* rtx {:steps 20 :dx 200 :dy 0 :settle-ms 100}))
```
The case declares:
@ -393,14 +393,15 @@ The case declares:
- `:context`: `:fresh` creates a page, module, and context per attempt.
`:reuse` keeps one page, module, context, snapshot, and upload for all
attempts of the case.
- `:completion`: the declared completion rule, `:call`, `:await`, or
`:render-full`. The standard load body drains to `Full`; the standard pan and
zoom bodies finish a camera session with a final `Full` drain.
- `:operation`: settings read by the operation body.
- `:batch-size` and `:preparation`: execution settings recorded in the
descriptor and checked by comparisons. A batch size groups inner calls inside
one attempt; a repetition is a separate attempt. Standard load, pan, and zoom
cases run one call per attempt.
- `:preparation`: an optional `:callback` function runs after upload and returns
the next context or its Promise. Preparation then renders Full by default.
`:render :none` completes preparation after the callback for cold-render cases.
- `:reset`: an optional function that receives the context after successful
main execution and returns the next context or its Promise. Reset runs after
every successful main, including warmups and the final attempt.
Commands receive workload arguments from the case body. The callback, main body,
and reset share the command context and can each call measured commands.
### Case collection and seeds
@ -410,6 +411,10 @@ the case's data fields, and checks that the descriptor survives a serialization
round trip. Shape construction, browser startup, and renderer initialization
take place during execution.
The local registry keeps the main, reset, and preparation callback functions.
The descriptor carries the declared preparation data. The browser resolves the
functions from its registry using the case id.
The scene seed depends only on the master seed, the scene id, and the canonical
parameters. These inputs give load, pan, and zoom cases the same starting
content across case bodies, collection orders, attempt counts, and feature
@ -441,11 +446,9 @@ shape.
| `plus` | 1000 plus signs made from two open line subpaths | load, pan, zoom |
| `masks` | 24 masked groups, each with a circle mask and 8 striped rectangles | load, pan, zoom |
A load case renders a fresh scene and times module start, graphics start,
upload, and the first render to `Full`. A pan or zoom case prepares the scene
once, restores the starting view outside timing for each attempt, then times a
camera session. The standard camera sessions use 20 view updates and a 100 ms
settling wait. Pan and zoom parameters vary by scene.
Scene recipes supply canonical geometry for rendering and camera workloads.
Camera sessions submit view updates and wait for settling before their final
render. Each case declares its sequence, arguments, and reset behavior in code.
## Renderer commands
@ -629,6 +632,39 @@ the individual values that call produced. The command execution owns this
history; its guard authorizes work for that execution. Pass the resulting
context to `commands/execute` to start another command execution.
### Case phases
The browser executes preparation, main, and reset through the command executor.
Preparation builds and uploads the scene, runs its optional callback once, and
renders Full unless the case declares `:preparation {:render :none}`. A fresh
context prepares once for its attempt; a reused context prepares once for its
case. Each phase passes its returned context to the next phase.
A successful preparation response contains its raw block in
`[:preparation :phases]`. A failed preparation returns its available blocks in
top-level `:phases`. An attempt response contains main and any declared reset
in `:phases`:
```clojure
{:phase :main
:preparation-id "preparation-1"
:outcome :completed
:events [{:command :benches.render-wasm.runtime.camera/render-sync
:metrics {:command/elapsed [7]}}]}
```
Each block retains ordered command events, raw numeric vectors, and any
completion diagnostics. A phase can complete with an empty event vector.
Initialization and upload durations stay in preparation `:diagnostics`.
Automatic initial rendering retains its raw trace under
`[:diagnostics :setup-render]`. Measured commands in the callback supply the
preparation block's selected events.
Reset runs after successful main. Its returned context supplies the next reused
attempt. A phase failure releases its owned context and returns the failed
block with earlier events and available traces. Completed preparation retains
its own evidence when main or reset fails.
### Runtime code
[`runtime/commands.clj`](runtime/commands.clj) declares commands;
@ -643,7 +679,8 @@ declarations and functions during catalogue collection. Renderer operations run
through the supplied hooks when the functions execute in the browser.
[`browser/bridge.cljs`](browser/bridge.cljs) constructs the runtime context for
case operations and prepares graphics contexts, snapshots, and uploads. Reused
cases restore their starting view outside interaction timing before each attempt.
cases carry their returned context into the next attempt; their reset function
controls restoration.
## Measurement
@ -659,16 +696,18 @@ Work around those intervals includes:
- request and result serialization;
- validation, logging, and screenshots;
- module script import;
- camera restore and the drain that prepares a warm case.
- the automatic initial Full render during preparation.
The timed work includes:
Preparation diagnostics time:
- module instantiation;
- graphics context creation and initial size synchronization;
- shape batch upload, including the loading lifecycle and first view setup;
- the render drain to `Full`;
- for warm cases, the camera session: view updates, cached previews, settling,
ending the interaction, and the final drain.
- the automatic render drain to `Full`.
Measured commands record their own intervals during preparation, main, or reset.
For a camera command, this includes view updates, cached previews, settling,
ending the interaction, and the final drain.
### Lifecycle of a fresh load
@ -679,22 +718,25 @@ flowchart TD
C --> D[Build and validate snapshot - untimed]
D --> E[Fingerprint and derive upload order - untimed]
E --> F[Time upload and first view setup]
F --> G[Time render drain to Full]
G --> H[Record result]
H --> I[Dispose page and context]
F --> G[Run preparation callback]
G --> H[Render initial Full unless render is none]
H --> I[Run main]
I --> J[Run reset after successful main]
J --> K[Return raw phase records]
K --> L[Dispose page and context]
```
The module script import is untimed because the browser loads it once per page.
Module instantiation starts a new renderer state. Graphics start creates the
WebGL2 context and synchronizes the drawing buffer. Upload covers the loading
lifecycle, batch serialization, and tile preparation. The first render clock
starts after upload ends.
lifecycle, batch serialization, and tile preparation. Each measured command
starts its clock immediately before its work.
### Warm interactions
A warm case owns one prepared page. Before each attempt, the browser restores
the case's starting view and drains to `Full` outside timing. The operation then
runs a camera session:
A warm case owns one prepared page. Its main body starts from the context
returned by preparation or the preceding attempt's reset. A camera command runs
this session:
1. start the camera interaction;
2. for each view update, wait for the next animation frame, apply the view, and
@ -711,19 +753,18 @@ the run also records that call's duration.
### Slices and metrics
The browser returns raw evidence for each attempt. A fresh attempt also reports
`module-init-ms`, `graphics-init-ms`, and `upload-ms` from preparation.
The browser returns ordered events within each phase. Preparation diagnostics
retain `module-init-ms`, `graphics-init-ms`, and `upload-ms` separately.
A fresh attempt reports one drain:
A render drain retains these trace fields:
- `slices`: one record per renderer call with `timestamp`, `flags`,
`frame-type`, and `duration-ms`;
- `viewport-ready-ms`: the first `ViewportReady` or `Full` completion from the
drain origin;
- `full-ms`: the `Full` completion from the drain origin;
- `first-render-ms`: the single first-render metric for a fresh case.
- `full-ms`: the `Full` completion from the drain origin.
A warm attempt reports the camera session and the final drain:
A camera command's trace contains the camera session and final drain:
- `cached-slices`: one record per gesture frame with `timestamp`,
`duration-ms` for the cached preview call, and `view-and-preview-ms` for the
@ -746,13 +787,9 @@ Both drains and camera sessions return `end-ms`, the clock reading immediately
after the final renderer call returns `Full`. Completion durations end at that
reading. Promise callbacks can run later as the browser schedules queued work.
Both kinds of attempt report `renderer-call-total-ms`, the sum of the renderer
call durations, computed after timing.
A saved run metric declares its unit, semantics, basis, and zero policy. A batch
average records its call count; one attempt supplies one observation. Raw slices
keep zero durations and non-finite values. The metric entry classifies the value
for analysis and records a reason when it is invalid.
Measured command events keep each metric's raw values in a vector. Repeated
commands produce separate events, including when they name the same metric.
Raw values preserve zero, negative, and non-finite measurements for analysis.
### Renderer completion

File diff suppressed because it is too large Load Diff

View File

@ -9,7 +9,8 @@
A scene declares an id, description, parameters schema and build function.
A case declares an id namespaced by its scene, parameters, view, context
policy, completion mode and optional operation identity and run function.
policy and a run function. Optional preparation and reset functions transform
the context before and after its main execution.
Ordinary :require loads the declaration macros and runtime helpers.
Collection validates declarations and produces plain descriptors."
@ -83,11 +84,15 @@
The scene comes from the namespace part of the :scene/case id. An
optional argument vector and body supply the run function, which accepts
one execution context. Collection produces plain case descriptors.
one command context and returns its next context or a Promise of it.
`[:preparation :callback]` runs once after upload. Preparation renders
Full by default; `:render :none` selects a cold first render. `:reset`
runs after each successful main execution. Collection produces plain
case descriptors.
Example:
(defcase :rects/pan options [context] (pan! context))"
(defcase :rects/pan options [context] (pan* context pan-options))"
([id opts]
(case-registration-form id (scene-from-id id) opts nil))
([id opts argv & body]
@ -97,12 +102,11 @@
#?(:cljs
(do
;; Case execution context:
;; :case - collected descriptor, including operation and completion
;; :case - collected descriptor
;; :scene - scene snapshot {:objects ... :refs ...}
;; :module - WASM module handle
;; :view - prepared camera view
;; :view - current camera view
;; :hooks - clock, scheduling, guard and renderer operations
;; Case bodies return a measurement or a Promise of one.
;; Case bodies return the next context or a Promise of it.
;; Declarations and their transitive imports load in Node and the browser.
;; Browser capabilities enter through the execution context; resource
@ -152,7 +156,11 @@
[:completion {:optional true} [:enum :call :await :render-full]]
[:batch-size {:optional true} [:int {:min 1}]]
[:operation {:optional true} [:map]]
[:preparation {:optional true} [:map]]
[:preparation {:optional true}
[:map
[:render {:optional true} [:enum :full :none]]
[:callback {:optional true} fn?]]]
[:reset {:optional true} fn?]
[:run! {:optional true} fn?]])
(def schema:collected-case
@ -166,7 +174,7 @@
[:context [:enum :fresh :reuse]]
[:completion [:enum :call :await :render-full]]
[:batch-size [:int {:min 1}]]
[:preparation [:map]]
[:preparation [:map [:render {:optional true} [:enum :full :none]]]]
[:operation {:optional true} [:map]]])
(defonce registry
@ -348,7 +356,7 @@
(= encoded (-> encoded t/decode-str t/encode-str))))))
(defn project-case
"Returns the case's descriptor fields with defaults for completion,
"Returns the case's data descriptor with defaults for completion,
batch size and preparation."
[bench-case]
(-> bench-case
@ -356,7 +364,7 @@
:operation :preparation])
(update :completion #(or % :render-full))
(update :batch-size #(or % 1))
(update :preparation #(or % {}))))
(update :preparation #(dissoc (or % {}) :callback))))
(defn check-collected-case
"Validates a collected case descriptor and its Transit representation,

View File

@ -23,9 +23,7 @@
- `:view`: the current view.
- `:hooks`: the clock, scheduling, renderer and execution guard functions.
The runtime context for a case operation also includes `:module`, the
instantiated WebAssembly renderer. `pan!` and `zoom!` read movement settings
from `[:case :operation]`.
`pan!` and `zoom!` read movement settings from `[:case :operation]`.
## Rendering and camera sessions

View File

@ -107,15 +107,20 @@
(t/deftest declared-preparation-survives-collection-and-wire
(with-synthetic-scene :contracts-prepared {:count 3}
(fn []
(let [preparation {:settle-ms 25}
(let [preparation {:render :none :settle-ms 25}
registered (decl/registered-case :contracts-prepared/case)]
(decl/register-case! (assoc registered :preparation preparation))
(decl/register-case! (assoc registered
:preparation (assoc preparation :callback identity)
:reset identity))
(let [collected (cases/collect-cases {:master-seed 42 :filter "contracts-prepared/"})
decoded (transit/decode-str (transit/encode-str collected))]
(t/is (= [:contracts-prepared/case] (mapv :id decoded)))
(t/is (= collected decoded))
(t/is (= preparation (:preparation (first decoded))))
(t/is (= (first decoded) (decl/check-collected-case (first decoded)))))))))
(t/is (= (first decoded) (decl/check-collected-case (first decoded)))))
(decl/register-case! (assoc registered :preparation {:asset {:loader identity}}))
(t/is (= ::cases/invalid-case
(:type (failure-data #(cases/collect-cases {:master-seed 42 :filter "contracts-prepared/"})))))))))
(t/deftest rects-cases-carry-declared-view-context-and-completion
(let [collected (rect-cases)]

View File

@ -17,11 +17,7 @@
[benches.render-wasm.cases :as cases]
[benches.render-wasm.declarations :as decl]
[benches.render-wasm.errors :as errors]
[benches.render-wasm.measurement :as measurement]
[benches.render-wasm.report.compare :as compare]
[benches.render-wasm.report.format :as format]
[benches.render-wasm.report.summarize :as summary]
[benches.render-wasm.result :as result]
[benches.render-wasm.runtime.camera :as camera]
[cljs.test :as t :include-macros true]))
;; Forward declaration: entry-test helpers below build args against the
@ -32,10 +28,25 @@
;; `frontend-tests.render-wasm.webgl-test`, which also pins the success,
;; nil-context and registration-failure cases.
(defn- register-phase-case
[options run]
(let [params (:params (decl/registered-case :rects/load))]
(decl/defcase :rects/lifecycle
(-> (merge {:params params
:view {:scale 1 :x 0 :y 0}
:context :fresh}
options)
(update :params #(merge params %)))
[rtx]
(run rtx))))
(defn- load-case
"Collected `:rects/load` descriptor for entry tests."
"Returns the collected rectangle case for entry tests."
[]
(first (cases/collect-cases {:master-seed 42 :filter "rects/load"})))
(first (cases/collect-cases {:master-seed 42
:filter (if (decl/registered-case :rects/lifecycle)
"rects/lifecycle"
"rects/load")})))
(defn- scene-args
"Transit request for `browser/load-scene`. `overrides` change the
@ -424,9 +435,11 @@
(try
(browser/dispose!)
(finally
(decl/unregister-case! :rects/lifecycle)
(restore-dom!))))]
(reset-bench-globals!)
(try
(register-phase-case {} camera/render-sync*)
(f cleanup)
(catch :default cause
(cleanup)
@ -458,7 +471,9 @@
(load-result (scene-args 7 {})
(fn [m]
(t/is (= "ok" (:status m)))
(t/is (= 1 (count (get-in m [:trace :slices]))))
(t/is (= 1 (count (get-in m [:preparation :diagnostics :setup-render :slices]))))
(t/is (= [::camera/render-sync]
(mapv :command (get-in m [:phases 0 :events]))))
(t/is (= 1001 (:shapes (:scene m))) "the canonical scene uploads whole")
(let [graphics (:effective-graphics m)]
(t/is (= 1234 (:drawing-buffer-width graphics))
@ -475,35 +490,7 @@
(cleanup)
(done)))))))
(t/deftest successful-load-produces-portable-reports
(t/async done
(with-entry-env
(fn [cleanup]
(load-result
(scene-args 7 {})
(fn [evidence]
(let [case (load-case)
preparation (:preparation evidence)
run (-> (result/create-run {:run-id "load-report" :started-at "start"
:plan {:cases [case] :warmups 0 :repetitions 1}
:metadata {:git {:sha nil :dirty nil}
:build {:functional {:mode :test}}
:environment {} :scored? false}})
(result/record-preparation preparation)
(result/record-attempt (measurement/attempt (:id case) (:preparation-id preparation) false evidence))
(result/finish-run "end" {:reason :completed}))
text (result/encode run)
report (summary/summarize (result/decode text))
comparison (compare/compare-runs run run)
comparison-text (.compare browser/bridge text text (transit/encode-str {}))]
(t/is (= report (transit/decode-str (.summarize browser/bridge text))))
(t/is (= (format/format-run report) (.formatRun browser/bridge text)))
(t/is (= comparison (result/decode comparison-text)))
(t/is (= (format/format-comparison comparison) (.formatComparison browser/bridge comparison-text))))
(cleanup)
(done)))))))
(t/deftest exported-load-scene-retains-progressive-frame-evidence
(t/deftest preparation-retains-progressive-frame-evidence
(t/async done
(with-entry-env
(fn [cleanup]
@ -515,9 +502,10 @@
(-> (.loadScene browser/bridge (scene-args 7 {}))
(.then (fn [text]
(t/is (string? text) "the exported loadScene returns Transit")
(let [{:keys [status slices viewport-ready-ms full-ms metrics trace scene]}
(transit/decode-str text)
retained (:slices trace)
(let [{:keys [status preparation scene]} (transit/decode-str text)
{:keys [slices viewport-ready-ms full-ms]}
(get-in preparation [:diagnostics :setup-render])
retained slices
render-calls (filterv #(= "render" (first %)) (read-calls))]
(t/is (= "ok" status))
(t/is (= {:shapes 1001 :seed 7} scene)
@ -538,7 +526,6 @@
["render" timestamp flags])
retained))
"decoded slices retain every WASM call's timestamp and flags in order")
(t/is (= slices retained) "raw and trace slices survive Transit together")
(t/is (= 37 viewport-ready-ms
(+ (:timestamp (nth retained 1))
(:duration-ms (nth retained 1))))
@ -548,12 +535,10 @@
(:duration-ms (nth retained 2))))
"Full completion ends with the final slice")
(t/is (< viewport-ready-ms full-ms))
(t/is (= full-ms (get-in metrics [:first-render-ms :value])))
(t/is (= viewport-ready-ms (get-in metrics [:viewport-ready-ms :value])))
(t/is (= 10
(reduce + 0 (map :duration-ms retained))
(get-in metrics [:renderer-call-total-ms :value]))
"renderer-call total sums the retained calls without frame waits"))
(t/is (= [{:phase :preparation
:preparation-id (:preparation-id preparation)
:outcome :completed :events []}]
(:phases preparation))))
(cleanup)
(done)))
(.catch (fn [cause]
@ -579,108 +564,264 @@
(cleanup)
(done))))))))))
(t/deftest warm-pan-restores-and-reaches-full
(t/deftest compiled-pan-finishes-at-its-final-render
(t/async done
(with-entry-env
(fn [cleanup]
(load-result (scene-args 7 {:id :rects/pan :context :reuse})
(register-phase-case {:context :reuse}
#(camera/pan* % {:dx 10 :dy 5 :steps 2 :settle-ms 0}))
(load-result (scene-args 7 {})
(fn [m]
(t/is (= "ok" (:status m)))
(t/is (= 20 (count (:cached-slices m))))
(t/is (= 100 (:settling-requested-ms m)))
(t/is (= [4] (mapv :flags (:slices m))))
(t/is (= [2] (mapv :frame-type (:slices m))))
(t/is (= 3 (effect-count (read-calls) "render"))
"setup, restore and finalization each drain once")
(let [phase (first (:phases m))
event (first (:events phase))
trace (:trace event)]
(t/is (= "ok" (:status m)))
(t/is (= :main (:phase phase)))
(t/is (= ::camera/pan (:command event)))
(t/is (= 2 (count (:cached-slices trace))))
(t/is (= 0 (:settling-requested-ms trace)))
(t/is (= [4] (mapv :flags (:slices trace))))
(t/is (= [2] (mapv :frame-type (:slices trace))))
(t/is (= 2 (effect-count (read-calls) "render"))
"preparation and camera finalization each render once"))
(cleanup)
(done)))))))
(t/deftest warm-zoom-reaches-full
(t/async done
(with-entry-env
(fn [cleanup]
(load-result (scene-args 7 {:id :rects/zoom :context :reuse})
(fn [m]
(t/is (= "ok" (:status m)))
(t/is (= 20 (count (:cached-slices m))))
(t/is (= [2] (mapv :frame-type (:slices m))))
(cleanup)
(done)))))))
(t/deftest ^:async reused-context-prepares-once-and-resets-each-main
(await
(with-entry-env
(^:async fn [cleanup]
(try
(let [views (atom [])
options {:dy 0 :steps 1 :settle-ms 0}
pan (fn [phase dx rtx]
(swap! views conj [phase (get-in rtx [:view :x])])
(camera/pan* rtx (assoc options :dx dx)))]
(register-phase-case {:context :reuse
:preparation {:callback #(pan :preparation 5 %)}
:reset #(pan :reset -10 %)}
#(pan :main 10 %))
(let [prepared (transit/decode-str (await (browser/prepare-scene-request (scene-args 7 {}))))
prep (:preparation prepared)
request (transit/encode-str {:preparation-id (:preparation-id prep)})
first (transit/decode-str (await (browser/run-prepared request)))
second (transit/decode-str (await (browser/run-prepared request)))]
(t/is (= ["ok" "ok" "ok"] (mapv :status [prepared first second])))
(t/is (= [[:preparation 0] [:main 5] [:reset 15] [:main 5] [:reset 15]] @views))
(t/is (= [:preparation] (mapv :phase (:phases prep))))
(t/is (= [::camera/pan] (mapv :command (get-in prep [:phases 0 :events]))))
(t/is (= prep (:preparation first) (:preparation second)))
(doseq [execution [first second]]
(t/is (= [:main :reset] (mapv :phase (:phases execution))))
(t/is (= [:completed :completed] (mapv :outcome (:phases execution))))
(t/is (= [[::camera/pan] [::camera/pan]]
(mapv #(mapv :command (:events %)) (:phases execution)))))
(t/is (= 1 (effect-count (read-calls) "create")))
(t/is (= 6 (effect-count (read-calls) "render"))
"the callback, initial Full, and four main/reset gestures render")
(t/is (= 1 (count (filter #(= ["call" "_init_shapes_pool"] %) (read-calls)))))))
(finally
(cleanup)))))))
(t/deftest prepared-warm-scene-runs-twice-with-one-context-and-upload
(t/async done
(with-entry-env
(fn [cleanup]
(-> (browser/prepare-scene-request
(scene-args 7 {:id :rects/pan :context :reuse
:operation {:steps 2 :dx 10 :dy 5 :settle-ms 0}}))
(.then (fn [text]
(let [prepared (transit/decode-str text)
prep (:preparation prepared)
request (transit/encode-str {:preparation-id (:preparation-id prep)})]
(t/is (= "ok" (:status prepared)))
(t/is (seq (get-in prep [:diagnostics :setup-render :slices])))
(-> (browser/run-prepared request)
(.then (fn [first-text]
(let [first-result (transit/decode-str first-text)]
(t/is (= "ok" (:status first-result)))
(t/is (not (contains? (:metrics first-result) :upload-ms)))
(t/is (= prep (:preparation first-result)))
(browser/run-prepared request))))
(.then (fn [second-text]
(let [second-result (transit/decode-str second-text)
calls (read-calls)]
(t/is (= "ok" (:status second-result)))
(t/is (= prep (:preparation second-result)))
(t/is (= 1 (effect-count calls "create")))
(t/is (= 5 (effect-count calls "render")))
(t/is (= 1 (count (filter #(= ["call" "_init_shapes_pool"] %) calls))))
(t/is (= 2 (count (:cached-slices second-result))))
(cleanup)
(done))))))))
(.catch (fn [cause]
(t/is false (str "prepared reuse failed: " cause))
(cleanup)
(done))))))))
(t/deftest ^:async fresh-contexts-prepare-and-reset-once
(await
(with-entry-env
(^:async fn [cleanup]
(try
(let [visits (atom [])
work (fn [phase rtx]
(swap! visits conj phase)
(camera/render-sync* rtx))]
(register-phase-case {:preparation {:callback #(work :preparation %)}
:reset #(work :reset %)}
#(work :main %))
(dotimes [_ 2]
(let [prepared (transit/decode-str (await (browser/prepare-scene-request (scene-args 7 {}))))
prep (:preparation prepared)
request (transit/encode-str {:preparation-id (:preparation-id prep)})
evidence (transit/decode-str (await (browser/run-prepared request)))
repeated (transit/decode-str (await (browser/run-prepared request)))]
(t/is (= "ok" (:status evidence)))
(t/is (= [:main :reset] (mapv :phase (:phases evidence))))
(t/is (= [::camera/render-sync] (mapv :command (get-in prep [:phases 0 :events]))))
(t/is (= "invalid-args" (:phase repeated)))))
(t/is (= [:preparation :main :reset :preparation :main :reset] @visits))
(t/is (= 2 (effect-count (read-calls) "create")))
(t/is (= 2 (effect-count (read-calls) "render"))))
(finally
(cleanup)))))))
(t/deftest fresh-preparation-runs-once-and-saves-structured-metrics
(t/async done
(with-entry-env
(fn [cleanup]
(-> (browser/prepare-scene-request (scene-args 7 {}))
(.then (fn [text]
(let [request (transit/encode-str
{:preparation-id (get-in (transit/decode-str text)
[:preparation :preparation-id])})]
(t/is (zero? (effect-count (read-calls) "render")))
(-> (browser/run-prepared request)
(.then (fn [text]
(let [evidence (transit/decode-str text)]
(t/is (= "ok" (:status evidence)))
(t/is (= (:full-ms evidence) (get-in evidence [:metrics :first-render-ms :value])))
(t/is (contains? (:metrics evidence) :upload-ms))
(t/is (not (contains? (:metrics evidence) :full-ms)))
(t/is (not (contains? evidence :first-render-ms)))
(t/is (not (contains? evidence :render-frames)))
(browser/run-prepared request))))
(.then (fn [text]
(t/is (= "invalid-args" (:phase (transit/decode-str text))))
(cleanup)
(done)))))))
(.catch (fn [cause]
(t/is false (str "fresh preparation failed: " cause))
(cleanup)
(done))))))))
(t/deftest ^:async preparation-policy-keeps-selected-events-and-empty-main
(doseq [context [:fresh :reuse]
render [nil :none]]
(await
(with-entry-env
(^:async fn [cleanup]
(try
(unchecked-set js/globalThis "__benchNow" 0)
(register-phase-case
{:params {:count 2}
:context context
:preparation (cond-> {:callback #(-> % (camera/render-sync*) (camera/render-sync*))}
render (assoc :render render))}
identity)
(let [evidence (transit/decode-str (await (browser/load-scene (scene-args 7 {}))))
prep (:preparation evidence)
block (first (:phases prep))]
(t/is (= "ok" (:status evidence)))
(t/is (= :completed (:outcome block)))
(t/is (= [{:command ::camera/render-sync :metrics {:command/elapsed [0]}}
{:command ::camera/render-sync :metrics {:command/elapsed [0]}}]
(:events block)))
(t/is (= [{:phase :main :preparation-id (:preparation-id prep)
:outcome :completed :events []}]
(:phases evidence)))
(t/is (= (if (= render :none) 0 1) (effect-count (read-calls) "render"))))
(finally
(cleanup))))))))
(t/deftest ^:async reused-context-carries-view-without-a-reset
(await
(with-entry-env
(^:async fn [cleanup]
(try
(let [views (atom [])]
(register-phase-case {:params {:count 2} :context :reuse}
(fn [rtx]
(swap! views conj (:view rtx))
(camera/pan rtx {:dx 10 :dy 5 :steps 1 :settle-ms 0})))
(let [prepared (transit/decode-str (await (browser/prepare-scene-request (scene-args 7 {}))))
request (transit/encode-str {:preparation-id (get-in prepared [:preparation :preparation-id])})
first (transit/decode-str (await (browser/run-prepared request)))
second (transit/decode-str (await (browser/run-prepared request)))]
(t/is (= ["ok" "ok"] (mapv :status [first second])))
(t/is (= [{:scale 1 :x 0 :y 0} {:scale 1 :x 10 :y 5}] @views))
(t/is (= [[] []] (mapv #(get-in % [:phases 0 :events]) [first second])))))
(finally
(cleanup)))))))
(t/deftest ^:async phase-failure-keeps-raw-evidence-and-stops-the-context
(doseq [context [:fresh :reuse]
failed-phase [:preparation :main :reset]]
(await
(with-entry-env
(^:async fn [cleanup]
(try
(unchecked-set js/globalThis "__benchNow" 0)
(let [visits (atom [])
work (fn [phase rtx]
(swap! visits conj phase)
(-> (camera/render-sync* rtx)
(.then (fn [next-rtx]
(when (= phase failed-phase)
(throw (js/Error. "phase fixture failed")))
next-rtx))))]
(register-phase-case {:params {:count 2} :context context
:preparation {:callback #(work :preparation %)}
:reset #(work :reset %)}
#(work :main %))
(let [request (-> (transit/decode-str (scene-args 7 {}))
(assoc :preparation-id "phase-failure")
(transit/encode-str))
prepared (transit/decode-str (await (browser/prepare-scene-request request)))
run (transit/encode-str {:preparation-id "phase-failure"})
evidence (if (= failed-phase :preparation)
prepared
(transit/decode-str (await (browser/run-prepared run))))
stopped (transit/decode-str (await (browser/run-prepared run)))
blocks (:phases evidence)]
(t/is (= "failed" (:status evidence)))
(t/is (= (name failed-phase) (:phase evidence)))
(t/is (= "phase fixture failed" (:message evidence)))
(t/is (= (case failed-phase
:preparation [:preparation]
:main [:preparation :main]
:reset [:preparation :main :reset])
@visits))
(t/is (= (if (= failed-phase :reset) [:main :reset] [failed-phase])
(mapv :phase blocks)))
(t/is (= :failed (:outcome (last blocks))))
(t/is (= (repeat (count blocks)
[{:command ::camera/render-sync :metrics {:command/elapsed [0]}}])
(map :events blocks)))
(when (= failed-phase :preparation)
(let [setup (get-in evidence [:partial :setup])]
(t/is (number? (:module-ms setup)))
(t/is (number? (:graphics-ms setup)))
(t/is (number? (:upload-ms setup)))
(t/is (string? (get-in setup [:fingerprint :digest])))))
(when-not (= failed-phase :preparation)
(t/is (= (:preparation prepared) (:preparation evidence)))
(t/is (= :completed (get-in evidence [:preparation :phases 0 :outcome]))))
(t/is (= "invalid-args" (:phase stopped)))
(t/is (= 1 (effect-count (read-calls) "remove"))))
(register-phase-case {:params {:count 2}} camera/render-sync*)
(let [next-case (transit/decode-str (await (browser/load-scene (scene-args 8 {}))))]
(t/is (= "ok" (:status next-case)))
(t/is (= [::camera/render-sync]
(mapv :command (get-in next-case [:phases 0 :events]))))))
(finally
(cleanup))))))))
(t/deftest ^:async superseded-phase-failure-keeps-its-own-preparation
(doseq [failed-phase [:main :reset]]
(await
(with-entry-env
(^:async fn [cleanup]
(try
(let [entered (volatile! nil)
reject (volatile! nil)
started (js/Promise. (fn [resolve _reject] (vreset! entered resolve)))
work (fn [phase rtx]
(-> (camera/render-sync* rtx)
(.then (fn [next-rtx]
(if (= phase failed-phase)
(js/Promise. (fn [_resolve fail]
(vreset! reject fail)
(@entered nil)))
next-rtx)))))]
(register-phase-case {:params {:count 2} :context :reuse
:reset #(work :reset %)}
#(work :main %))
(let [request (-> (transit/decode-str (scene-args 7 {}))
(assoc :preparation-id "first")
(transit/encode-str))
prepared (transit/decode-str (await (browser/prepare-scene-request request)))
first-run (browser/run-prepared (transit/encode-str {:preparation-id "first"}))]
(await started)
(register-phase-case {:params {:count 2}} camera/render-sync*)
(let [next-request (-> (transit/decode-str (scene-args 8 {}))
(assoc :preparation-id "second")
(transit/encode-str))
next-prepared (transit/decode-str (await (browser/prepare-scene-request next-request)))]
(@reject (js/Error. "late phase failure"))
(let [failed (transit/decode-str (await first-run))
next-run (transit/decode-str
(await (browser/run-prepared (transit/encode-str {:preparation-id "second"}))))]
(t/is (= "failed" (:status failed)))
(t/is (= (name failed-phase) (:phase failed)))
(t/is (= (:preparation prepared) (:preparation failed)))
(t/is (= (if (= failed-phase :main) [:main] [:main :reset])
(mapv :phase (:phases failed))))
(t/is (= :failed (:outcome (last (:phases failed)))))
(t/is (= (repeat (count (:phases failed)) "first")
(map :preparation-id (:phases failed))))
(t/is (= ["ok" "ok"] (mapv :status [next-prepared next-run])))
(t/is (= "second" (get-in next-run [:preparation :preparation-id])))))))
(finally
(cleanup))))))))
(t/deftest rejected-camera-body-releases-owner
(t/async done
(with-entry-env
(fn [cleanup]
(unchecked-set js/globalThis "__benchThrowIn" "_render_from_cache")
(load-result (scene-args 7 {:id :rects/pan :context :reuse})
(register-phase-case {:context :reuse}
#(camera/pan* % {:dx 10 :dy 5 :steps 2 :settle-ms 0}))
(load-result (scene-args 7 {})
(fn [m]
(t/is (= "failed" (:status m)))
(t/is (= "interact" (:phase m)))
(t/is (= "main" (:phase m)))
(t/is (= 1 (effect-count (read-calls) "remove")))
(t/is (nil? @@#'browser/canvas*))
(cleanup)
@ -698,6 +839,7 @@
(fn [m]
(t/is (= "failed" (:status m)))
(t/is (= "module-init" (:phase m)))
(t/is (= {} (get-in m [:partial :setup])))
(cleanup)
(done)))))))
@ -727,6 +869,7 @@
(fn [m]
(t/is (= "failed" (:status m)))
(t/is (= "graphics-init" (:phase m)))
(t/is (= {} (get-in m [:partial :setup])))
(t/is (= "graphics init failed" (:message m))
"top message stays phase-labeled")
(assert-wasm-cause (:cause m)
@ -756,6 +899,10 @@
(assert-wasm-cause (:cause m)
"_set_view"
#"fake _set_view failure")
(let [setup (get-in m [:partial :setup])]
(t/is (number? (:module-ms setup)))
(t/is (number? (:graphics-ms setup)))
(t/is (string? (get-in setup [:fingerprint :digest]))))
(cleanup)
(done)))))))