Miguel de Benito Delgado e4709287dd ✨ Complete renderer benchmark command catalogue
- Improve readme
- Migrate scene cases to measured commands and remove obsolete descriptors.
- Preserve exact camera targets, including very small positive scales.

AI-assisted-by: gpt-6.1-sol
2026-11-05 11:18:16 +01:00

511 lines
24 KiB
Clojure

;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns performance-tests.render-wasm.commands-test
(:require
[app.common.render-wasm.wasm :as wasm]
[app.common.schema :as sm]
[benches.render-wasm.builder :as builder]
[benches.render-wasm.cases :as cases]
[benches.render-wasm.declarations :as decl]
[benches.render-wasm.errors :as errors]
[benches.render-wasm.runtime.camera :as camera]
[benches.render-wasm.runtime.commands :as cmd :refer [defcommand]]
[cljs.test :as t]))
(defcommand move
"Adds the supplied offsets to the command context's view."
[rtx {:keys [dx dy]}]
(-> rtx
(update-in [:view :x] + dx)
(update-in [:view :y] + dy)))
(defcommand selected-renders
{:measured-variant false}
[rtx]
(-> rtx
(camera/render-sync*)
(move {:dx 10 :dy 5})
(camera/render-sync)
(camera/render-sync*)))
(defcommand unexpected-promise
[rtx cause]
(if cause
(js/Promise.reject cause)
(js/Promise.resolve rtx)))
(defcommand reject-offset
[rtx offset]
{:pre [(pos? offset)]}
((get-in rtx [:hooks :render-sync]))
(update-in rtx [:view :x] + offset))
(defcommand replace-owner
[rtx]
(assoc-in rtx [:hooks :check] (fn [])))
(defcommand async-work
{:async [complete fail]}
[rtx work]
(work rtx complete fail))
(defcommand selected-async
{:async [complete fail] :measured-variant false}
[rtx]
(-> (camera/render-sync* rtx)
(move {:dx 10 :dy 5})
(camera/render-sync*)
(.then complete)
(.catch fail)))
(defn- context
[clock]
{:case {:id :commands/example}
:scene (builder/scene {:seed 1})
:view {:scale 1 :x 0 :y 0}
:hooks {:now #(deref clock)
:check (fn [])
:frame #(js/Promise.resolve (vswap! clock + 16))
:sleep #(do (vswap! clock + %) (js/Promise.resolve nil))
:render (fn [_ _] (vswap! clock + 7) wasm/FRAME_TYPE_FULL)
:render-sync #(vswap! clock + 7)
:render-from-cache #(vswap! clock + 2)
:set-view (fn [_])
:set-view-start (fn [])
:set-view-end #(vswap! clock + 3)}})
(t/deftest ^:async measurement-uses-core-time
(let [clock (volatile! 0)
rtx (assoc-in (context clock) [:hooks :render-sync]
#(do
(vswap! clock + 7)
(js/queueMicrotask (fn [] (vswap! clock + 500)))))
{:keys [outcome events context]}
(await
(cmd/execute
rtx
(fn [rtx]
(let [input (-> (js/Promise.resolve rtx)
(.then (fn [rtx]
(vswap! clock + 1000)
rtx)))]
(-> (camera/render-sync* input)
(.then (fn [rtx]
(vswap! clock + 2000)
rtx)))))))]
(t/is (= :completed outcome))
(t/is (= (:view rtx) (:view context)))
(t/is (= [{:command ::camera/render-sync
:metrics {:command/elapsed [7]}}]
events))
(t/is (= 3507 @clock))))
(t/deftest ^:async composite-preserves-view-and-selected-observations
(doseq [promised [false true]]
(let [clock (volatile! 0)
cost (volatile! 0)
rtx (assoc-in (context clock) [:hooks :render-sync]
#(vswap! clock + (vswap! cost + 7)))
{:keys [outcome context events]}
(await (cmd/execute rtx #(selected-renders
(if promised (js/Promise.resolve %) %))))]
(t/is (= :completed outcome))
(t/is (= {:scale 1 :x 10 :y 5} (:view context)))
(t/is (= [{:command ::camera/render-sync
:metrics {:command/elapsed [7]}}
{:command ::camera/render-sync
:metrics {:command/elapsed [21]}}]
events))
(t/is (= 42 @clock)))))
(t/deftest measured-command-documentation
(t/is (.startsWith (:doc (meta #'camera/render-sync*))
"Measured variant of `render-sync`."))
(t/is (= "Measured variant of `unexpected-promise`."
(:doc (meta #'unexpected-promise*)))))
(t/deftest ^:async zero-duration-is-a-raw-observation
(let [rtx (context (volatile! 0))
result (await (cmd/execute rtx #(move* % {:dx 2 :dy 3})))]
(t/is (= :completed (:outcome result)))
(t/is (= {:scale 1 :x 2 :y 3} (get-in result [:context :view])))
(t/is (= [{:command ::move :metrics {:command/elapsed [0]}}]
(:events result)))))
(t/deftest ^:async async-completion-keeps-endpoints-and-raw-evidence
(doseq [command [async-work async-work*]
supplied [false true]]
(let [clock (volatile! 0)
rtx (context clock)
metrics {:renderer/call-elapsed [1 2 4]}
trace {:steps [1 2 3]}
result (await
(cmd/execute
rtx
(fn [rtx]
(command
(-> (js/Promise.resolve rtx)
(.then #(do (vswap! clock + 1000) %)))
(fn [rtx complete _fail]
(vswap! clock + 7)
(let [end @clock
evidence {:metrics metrics :trace trace}
output (assoc-in rtx [:view :x] 10)]
(if supplied
(-> (js/Promise.resolve nil)
(.then (fn [_]
(vswap! clock + 500)
(complete output (assoc evidence :end-ms end)))))
(do
(complete output evidence)
(js/queueMicrotask #(vswap! clock + 500))))))))))]
(t/is (= :completed (:outcome result)))
(t/is (= 10 (get-in result [:context :view :x])))
(t/is (= 1507 @clock))
(t/is (= (if (= command async-work*)
[{:command ::async-work
:metrics (assoc metrics :command/elapsed [7])
:trace trace}]
[])
(:events result)))))
(let [result (await (cmd/execute (context (volatile! 0)) selected-async))]
(t/is (= :completed (:outcome result)))
(t/is (= {:scale 1 :x 10 :y 5} (get-in result [:context :view])))
(t/is (= [::camera/render-sync ::camera/render-sync]
(mapv :command (:events result))))))
(t/deftest ^:async async-completion-retains-invalid-numeric-observations
(let [result (await
(cmd/execute
(context (volatile! 0))
#(async-work* % (fn [rtx complete _fail]
(complete rtx {:end-ms js/Infinity
:metrics {:sample/raw [-1 0 js/NaN js/Infinity]}})))))
values (get-in result [:events 0 :metrics :sample/raw])]
(t/is (= :completed (:outcome result)))
(t/is (= [js/Infinity] (get-in result [:events 0 :metrics :command/elapsed])))
(t/is (= [-1 0] (subvec values 0 2)))
(t/is (js/Number.isNaN (nth values 2)))
(t/is (= js/Infinity (nth values 3)))))
(t/deftest ^:async async-commands-use-the-first-terminal-signal
(let [result (await
(cmd/execute
(context (volatile! 0))
#(async-work* % (fn [rtx complete fail]
(complete (assoc-in rtx [:view :x] 10))
(complete nil)
(fail (js/Error. "late failure"))
(throw (js/Error. "late throw"))))))]
(t/is (= :completed (:outcome result)))
(t/is (= 10 (get-in result [:context :view :x])))
(t/is (= [{:command ::async-work :metrics {:command/elapsed [0]}}] (:events result)))
(t/is (= [:complete :fail :fail] (mapv :signal (:diagnostics result)))))
(let [result (await
(cmd/execute
(context (volatile! 0))
#(async-work* % (fn [rtx complete _fail]
(js/queueMicrotask (fn [] (complete (assoc-in rtx [:view :x] 20))))
rtx))))]
(t/is (= :completed (:outcome result)))
(t/is (= 20 (get-in result [:context :view :x]))))
(let [unhandled (atom [])
observe #(swap! unhandled conj %)]
(.on js/process "unhandledRejection" observe)
(try
(let [result (await
(cmd/execute
(context (volatile! 0))
#(async-work* % (fn [rtx complete _fail]
(complete rtx)
(js/Promise.reject (js/Error. "rejected after completion"))))))]
(await (js/Promise. #(js/setImmediate %)))
(t/is (= :completed (:outcome result)))
(t/is (= [{:command ::async-work :metrics {:command/elapsed [0]}}] (:events result)))
(t/is (= [] @unhandled)))
(finally
(.removeListener js/process "unhandledRejection" observe)))))
(t/deftest ^:async async-failures-retain-causes-and-earlier-events
(doseq [mode [:throw :reject :fail]]
(let [cause (ex-info "renderer failed" {:partial {:slices [{:duration-ms 3}]}})
result (await
(cmd/execute
(context (volatile! 0))
#(-> %
(camera/render-sync*)
(async-work* (fn [rtx complete fail]
(case mode
:throw (throw cause)
:reject (js/Promise.reject cause)
:fail (do (fail cause) (complete rtx))))))))]
(t/is (= :failed (:outcome result)))
(t/is (identical? cause (:error result)))
(t/is (= [{:command ::camera/render-sync :metrics {:command/elapsed [7]}}]
(:events result))))))
(t/deftest ^:async malformed-async-completion-fails-before-recording
(doseq [[output evidence expected]
[[identity {:end-ms nil} ::cmd/invalid-completion]
[identity {:metrics {:sample/raw []}} ::cmd/invalid-completion]
[identity {:metrics {:sample/raw ["bad"]}} ::cmd/invalid-completion]
[identity {:metrics {:command/elapsed [123]}} ::cmd/invalid-completion]
[#(assoc-in % [:view :scale] 0) {} ::cmd/invalid-context]
[#(assoc-in % [:hooks :check] (fn [])) {} ::cmd/ownership-lost]]]
(let [result (await (cmd/execute (context (volatile! 0))
#(async-work* % (fn [rtx complete _fail]
(complete (output rtx) evidence)))))]
(t/is (= :failed (:outcome result)))
(t/is (= expected (:type (ex-data (:error result)))))
(t/is (= [] (:events result))))))
(t/deftest ^:async async-completion-checks-the-owner-guard
(doseq [cause [(ex-info "deadline expired" {:phase :deadline})
(ex-info "owner superseded" {::errors/stale true})]]
(let [blocked (volatile! nil)
rtx (assoc-in (context (volatile! 0)) [:hooks :check]
#(when @blocked (throw @blocked)))
result (await (cmd/execute rtx #(async-work* % (fn [rtx complete _fail]
(vreset! blocked cause)
(complete rtx)))))]
(t/is (= :failed (:outcome result)))
(t/is (identical? cause (:error result)))
(t/is (= [] (:events result))))))
(t/deftest ^:async late-completion-cannot-record-in-another-execution
(let [callbacks (volatile! nil)
pending (volatile! nil)
first-run (await
(cmd/execute
(context (volatile! 0))
(fn [rtx]
(vreset! pending
(.catch (async-work* rtx (fn [rtx complete fail]
(vreset! callbacks [rtx complete fail])))
errors/as-failure))
rtx)))
[rtx complete fail] @callbacks
next-run (await (cmd/execute (:context first-run)
(fn [next-rtx]
(complete rtx)
(fail (js/Error. "late failure"))
(camera/render-sync* next-rtx))))
cause (await @pending)]
(t/is (= :completed (:outcome first-run)))
(t/is (= [] (:events first-run)))
(t/is (= ::cmd/inactive-execution (:type (ex-data cause))))
(t/is (= :completed (:outcome next-run)))
(t/is (= [{:command ::camera/render-sync :metrics {:command/elapsed [7]}}]
(:events next-run)))))
(t/deftest ^:async camera-gestures-return-the-view-and-renderer-evidence
(doseq [[command options target command-id measured]
[[camera/pan {:dx 10 :dy 5 :steps 2 :settle-ms 5} {:scale 1 :x 10 :y 5} ::camera/pan false]
[camera/pan* {:dx 10 :dy 5 :steps 2 :settle-ms 5} {:scale 1 :x 10 :y 5} ::camera/pan true]
[camera/zoom {:factor 2 :steps 2 :settle-ms 5} {:scale 2 :x 0 :y 0} ::camera/zoom false]
[camera/zoom* {:factor 2 :steps 2 :settle-ms 5} {:scale 2 :x 0 :y 0} ::camera/zoom true]]]
(let [clock (volatile! 0)
view (volatile! nil)
remaining (volatile! [wasm/FRAME_TYPE_PARTIAL wasm/FRAME_TYPE_FULL])
rtx (update (context clock) :hooks merge
{:set-view #(vreset! view %)
:render (fn [_ _]
(vswap! clock + 7)
(let [frame-type (first @remaining)]
(vswap! remaining rest)
(when (= wasm/FRAME_TYPE_FULL frame-type)
(js/queueMicrotask #(vswap! clock + 500)))
frame-type))})
result (await (cmd/execute rtx #(command % options)))]
(t/is (= :completed (:outcome result)))
(t/is (= target @view (get-in result [:context :view])))
(t/is (= 574 @clock))
(if measured
(do
(t/is (= command-id (get-in result [:events 0 :command])))
(t/is (= {:command/elapsed [74]
:renderer/call-elapsed [7 7]
:renderer/cache-call-elapsed [2 2]
:camera/finalization-to-full [33]}
(get-in result [:events 0 :metrics])))
(t/is (= 74 (get-in result [:events 0 :trace :end-ms]))))
(t/is (= [] (:events result))))
(vreset! clock 0)
(let [invalid (await (cmd/execute rtx #(command % (assoc options :steps 0))))]
(t/is (= :failed (:outcome invalid)))
(t/is (= ::cmd/invalid-arguments (:type (ex-data (:error invalid)))))
(t/is (= [] (:events invalid)))
(t/is (zero? @clock))))))
(t/deftest ^:async load-and-restore-finish-at-the-renderer-endpoint
(doseq [[command restore measured] [[camera/load false false] [camera/load* false true]
[camera/restore true false] [camera/restore* true true]]]
(let [clock (volatile! 0)
view (volatile! nil)
target {:scale 2 :x 30 :y -10}
rtx (update (context clock) :hooks merge
{:set-view #(vreset! view %)
:render (fn [_ _]
(vswap! clock + 7)
(js/queueMicrotask #(vswap! clock + 500))
wasm/FRAME_TYPE_FULL)})
result (await (cmd/execute rtx #(if restore
(command (js/Promise.resolve %) target)
(command (js/Promise.resolve %)))))]
(t/is (= :completed (:outcome result)))
(t/is (= (if restore target (:view rtx)) (get-in result [:context :view])))
(when restore (t/is (= target @view)))
(t/is (= (if restore 510 523) @clock))
(when measured
(let [event (first (:events result))]
(t/is (= (if restore ::camera/restore ::camera/load) (:command event)))
(t/is (= [(if restore 10 23)] (get-in event [:metrics :command/elapsed])))
(t/is (= [7] (get-in event [:metrics :renderer/call-elapsed])))
(t/is (= (if restore 10 23) (get-in event [:trace :end-ms])))
(when-not restore
(t/is (= [23] (get-in event [:metrics :renderer/prepared-scene-to-full])))))))))
(t/deftest ^:async invalid-camera-targets-fail-before-changing-renderer-state
(doseq [[command options view]
[[camera/pan* {:dx sm/max-safe-int :dy 0 :steps 1 :settle-ms 0} {:scale 1 :x 1 :y 0}]
[camera/zoom* {:factor sm/max-safe-int :steps 1 :settle-ms 0} {:scale 2 :x 0 :y 0}]
[camera/restore* {:scale 1 :x 10 :y 0 :viewport {:width 800}} {:scale 1 :x 0 :y 0}]]]
(let [clock (volatile! 0)
applied (volatile! view)
rtx (-> (context clock)
(assoc :view view)
(assoc-in [:hooks :set-view] #(vreset! applied %)))
result (await (cmd/execute rtx #(command % options)))]
(t/is (= :failed (:outcome result)))
(t/is (= ::cmd/invalid-arguments (:type (ex-data (:error result)))))
(t/is (= view @applied))
(t/is (zero? @clock)))))
(t/deftest ^:async declared-reference-cases-execute-selected-work-and-reset-the-view
(let [clock (volatile! 0)
descriptor (first (cases/collect-cases {:master-seed 42 :filter "rects/blocking-full"}))
result (await (cmd/execute (assoc (context clock) :case descriptor)
(:run! (decl/registered-case (:id descriptor)))))]
(t/is (= :completed (:outcome result)))
(t/is (= [{:command ::camera/render-sync :metrics {:command/elapsed [7]}}] (:events result))))
(let [clock (volatile! 0)
descriptor (first (cases/collect-cases {:master-seed 42 :filter "rects/mixed-pans"}))
registered (decl/registered-case (:id descriptor))
applied (volatile! (:view descriptor))
initial (-> (context clock) (assoc :case descriptor)
(assoc-in [:hooks :set-view] #(vreset! applied %)))]
(loop [rtx initial remaining 2]
(when (pos? remaining)
(let [main (await (cmd/execute rtx (:run! registered)))
_ (t/is (= {:scale 1 :x 0 :y 80} @applied (get-in main [:context :view])))
reset (await (cmd/execute (:context main) (:reset registered)))]
(t/is (= :completed (:outcome main) (:outcome reset)))
(t/is (= [::camera/pan ::camera/pan] (mapv :command (:events main))))
(t/is (= [::camera/restore] (mapv :command (:events reset))))
(t/is (= (:view descriptor) @applied (get-in reset [:context :view])))
(recur (:context reset) (dec remaining)))))))
(t/deftest ^:async small-positive-zoom-scales-stay-positive-and-reach-the-target
(doseq [[scale factor] [[1 1e-20] [js/Number.MIN_VALUE 1]]]
(let [applied (volatile! {:scale scale :x 0 :y 0})
minimum (volatile! scale)
rtx (-> (context (volatile! 0))
(assoc :view @applied)
(assoc-in [:hooks :set-view]
#(do (vreset! applied %)
(vswap! minimum min (:scale %)))))
result (await (cmd/execute rtx #(camera/zoom* % {:factor factor :steps 2 :settle-ms 0})))]
(t/is (= :completed (:outcome result)))
(t/is (pos? @minimum))
(t/is (= (* scale factor) (:scale @applied) (get-in result [:context :view :scale]))))))
(t/deftest ^:async failed-work-retains-earlier-observations
(let [unhandled (atom [])
observe #(swap! unhandled conj %)]
(.on js/process "unhandledRejection" observe)
(try
(doseq [cause [nil (js/Error. "rejected core")]]
(let [rtx (context (volatile! 0))
result (await (cmd/execute rtx #(-> %
(camera/render-sync*)
(unexpected-promise* cause))))]
(t/is (= :failed (:outcome result)))
(t/is (= ::cmd/async-result (:type (ex-data (:error result)))))
(t/is (= [{:command ::camera/render-sync
:metrics {:command/elapsed [7]}}]
(:events result)))))
(await (js/Promise. #(js/setImmediate %)))
(t/is (= [] @unhandled))
(finally
(.removeListener js/process "unhandledRejection" observe)))))
(t/deftest ^:async execution-failures-preserve-causes-and-earlier-observations
(doseq [cause [(js/Error. "renderer failed")
"renderer disconnected"
{:reason :renderer-disconnected}
(errors/fatal-error "recording failed" {} (js/Error. "storage"))]]
(let [clock (volatile! 0)
rtx (assoc-in (context clock) [:hooks :render-sync]
#(if (zero? @clock)
(vswap! clock + 7)
(throw cause)))
result (await (cmd/execute rtx #(-> %
(camera/render-sync*)
(camera/render-sync*))))
error (:error result)]
(t/is (= :failed (:outcome result)))
(t/is (errors/failure? error))
(if (errors/failure? cause)
(t/is (identical? cause error))
(t/is (= cause (:thrown (ex-data error)))))
(t/is (= [{:command ::camera/render-sync
:metrics {:command/elapsed [7]}}]
(:events result))))))
(t/deftest ^:async invalid-boundaries-fail-the-execution
(doseq [[input command expected]
[[#(assoc-in % [:view :scale] 0) camera/render-sync* ::cmd/invalid-context]
[#(assoc % :module #js {}) camera/render-sync* ::cmd/invalid-context]
[#(update % :hooks dissoc :render-sync) camera/render-sync* ::cmd/invalid-context]
[#(update % :hooks dissoc :frame) camera/render-sync* ::cmd/invalid-context]
[#(assoc-in % [:hooks :sleep] 0) camera/render-sync* ::cmd/invalid-context]
[identity #(reject-offset* % -1) ::cmd/invalid-arguments]]]
(let [clock (volatile! 0)
now-reads (volatile! 0)
rtx (assoc-in (context clock) [:hooks :now]
#(do (vswap! now-reads inc) @clock))
result (await (cmd/execute rtx #(command (input %))))]
(t/is (= :failed (:outcome result)))
(t/is (= expected (:type (ex-data (:error result)))))
(t/is (= [] (:events result)))
(t/is (zero? @now-reads))
(t/is (zero? @clock)))))
(t/deftest ^:async commands-enforce-execution-ownership
(let [rtx (context (volatile! 0))
result (await (cmd/execute rtx replace-owner*))
cause (await (.then (camera/render-sync* rtx) (fn [_] nil) identity))]
(t/is (= :failed (:outcome result)))
(t/is (= ::cmd/ownership-lost (:type (ex-data (:error result)))))
(t/is (= [] (:events result)))
(t/is (= ::cmd/missing-execution (:type (ex-data cause))))))
(t/deftest ^:async completed-context-starts-a-new-execution
(let [first-result (await (cmd/execute (context (volatile! 0))
#(move* % {:dx 10 :dy 5})))
rtx (:context first-result)
late-error (await (.then (camera/render-sync* rtx)
(fn [_] nil)
identity))
next-result (await (cmd/execute rtx camera/render-sync*))]
(t/is (= ::cmd/inactive-execution (:type (ex-data late-error))))
(t/is (= :completed (:outcome next-result)))
(t/is (= {:scale 1 :x 10 :y 5} (get-in next-result [:context :view])))
(t/is (= [{:command ::camera/render-sync
:metrics {:command/elapsed [7]}}]
(:events next-result)))))