2026-10-28 19:17:39 +01:00

344 lines
17 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.protocol-test
"Tests drain, restore and interact with an injected clock and renderer.
Browser entry tests cover load and warm pan with a fake module."
(:require
[app.common.render-wasm.wasm :as wasm]
[benches.render-wasm.runtime.protocol :as protocol]
[cljs.test :as t :include-macros true]))
(defn- fake-clock
"Controllable ms clock. `advance!` moves it; tests drive time by hand."
[start]
(let [t (atom start)]
{:now (fn [] @t)
:advance! (fn [ms] (swap! t + ms) @t)}))
(defn- drain-hooks
"Fake hooks driving `:render` through `script` (a vector of frame types
returned in order), recording observed flags in `calls`. `:frame`
advances 16 ms per rAF; `:sleep` advances the clock without yielding."
[{:keys [clock script calls check]}]
(let [remaining (atom (vec script))]
{:now (:now clock)
:frame (fn []
((:advance! clock) 16)
(js/Promise.resolve ((:now clock))))
:sleep (fn [ms]
((:advance! clock) ms)
(js/Promise.resolve nil))
:check (or check (fn [] nil))
:render (fn [_timestamp flags]
(swap! calls conj flags)
(let [next (first @remaining)]
(swap! remaining rest)
next))
:render-from-cache (fn [] 0)
:set-view (fn [_view] nil)
:set-view-start (fn [] nil)
:set-view-end (fn [] nil)}))
(t/deftest immediate-full-reports-equal-boundaries
(t/async done
(let [clock (fake-clock 1000)
calls (atom [])
hooks (drain-hooks {:clock clock :script [2] :calls calls})]
(-> (protocol/drain hooks {:flags 0 :origin 1000 :immediate true})
(.then (fn [{:keys [slices viewport-ready-ms full-ms]}]
(t/is (= 1 (count slices)))
(t/is (= {:timestamp 1000
:flags 0
:frame-type 2
:duration-ms 0}
(first slices)))
(t/is (= full-ms viewport-ready-ms)
"immediate Full supplies both boundaries")
(done)))
(.catch (fn [cause]
(t/is false (str "must resolve, threw: " cause))
(done)))))))
(t/deftest progressive-frames-propagate-flags
(t/async done
(let [clock (fake-clock 0)
calls (atom [])
hooks (drain-hooks {:clock clock :script [1 3 2] :calls calls})]
(-> (protocol/drain hooks {:flags 0 :origin 0 :immediate false})
(.then (fn [{:keys [slices viewport-ready-ms full-ms]}]
(t/is (= [0 1 3] @calls)
"the previous frame type becomes the next flags")
(t/is (= [1 3 2] (mapv :frame-type slices)))
(t/is (< viewport-ready-ms full-ms)
"ViewportReady lands before Full")
(t/is (= (:timestamp (nth slices 1)) (+ 16 16))
"later frames wait for rAF")
(done)))
(.catch (fn [cause]
(t/is false (str "must resolve, threw: " cause))
(done)))))))
(t/deftest partial-jumps-straight-to-full
(t/async done
(let [clock (fake-clock 0)
calls (atom [])
hooks (drain-hooks {:clock clock :script [1 2] :calls calls})]
(-> (protocol/drain hooks {:flags 0 :origin 0 :immediate true})
(.then (fn [{:keys [slices viewport-ready-ms full-ms]}]
(t/is (= [0 1] @calls))
(t/is (= [1 2] (mapv :frame-type slices)))
(t/is (= viewport-ready-ms full-ms)
"Full as the first completion supplies both boundaries")
(done)))
(.catch (fn [cause]
(t/is false (str "must resolve, threw: " cause))
(done)))))))
(t/deftest unknown-frame-types-reject
(t/async done
;; One async context chaining the bad values: a `t/async` inside
;; `doseq` would never run (the doseq returns nil to the runner).
(letfn [(step [remaining]
(if (empty? remaining)
(done)
(let [bad (first remaining)
clock (fake-clock 0)
calls (atom [])
hooks (drain-hooks {:clock clock :script [bad] :calls calls})]
(-> (protocol/drain hooks {:flags 0 :origin 0 :immediate true})
(.then (fn [_]
(t/is false (str "must reject for frame type " bad))
(done)))
(.catch (fn [cause]
(t/is (= :benches.render-wasm.runtime.protocol/unexpected-frame-type
(:type (ex-data cause)))
(str "frame type " bad))
(step (rest remaining))))))))]
(step [0 7 js/NaN]))))
(t/deftest cancellation-stops-further-renders
(t/async done
(let [clock (fake-clock 0)
calls (atom [])
fired (atom 0)
hooks (drain-hooks {:clock clock
:script [1 1 1 1]
:calls calls
:check (fn []
(swap! fired inc)
(when (> @fired 2)
(throw (ex-info "canceled" {:phase "test"}))))})]
(-> (protocol/drain hooks {:flags 0 :origin 0 :immediate true})
(.then (fn [_]
(t/is false "must reject once check throws")
(done)))
(.catch (fn [cause]
(t/is (= "canceled" (ex-message cause)))
(t/is (= 1 (count @calls)) "cancellation stops before the next render")
(t/is (= 3 @fired) "guard fires once before and once after the render")
(done)))))))
(t/deftest progressive-failure-retains-completed-call-evidence
(t/async done
(let [clock (fake-clock 0)
calls (atom [])
hooks (drain-hooks {:clock clock :script [1 7] :calls calls})]
(-> (protocol/drain hooks {:flags 0 :origin 0 :immediate true})
(.then (fn [_] (t/is false "unknown frame must fail") (done)))
(.catch (fn [cause]
(t/is (= [1 7] (mapv :frame-type (get-in (ex-data cause) [:partial :slices]))))
(t/is (= [0 0] (mapv :duration-ms (get-in (ex-data cause) [:partial :slices]))))
(done)))))))
(t/deftest camera-failure-retains-completed-previews-and-authored-pauses
(t/async done
(let [clock (fake-clock 0)
calls (atom [])
previews (atom 0)
hooks (assoc (drain-hooks {:clock clock :script [2] :calls calls})
:render-from-cache (fn []
(if (= 1 (swap! previews inc))
((:advance! clock) 3)
(throw (ex-info "preview failed" {})))))
view {:scale 1 :x 0 :y 0}]
(-> (protocol/start-camera! {:hooks hooks :view view} {:settle-ms 0})
(protocol/preview-view! view)
(protocol/sleep! 7)
(protocol/preview-view! (assoc view :x 1))
(.then (fn [_] (t/is false "second preview must fail") (done)))
(.catch (fn [cause]
(t/is (= [3] (mapv :duration-ms (get-in (ex-data cause) [:partial :cached-slices]))))
(t/is (= [{:requested-ms 7 :actual-ms 7}]
(get-in (ex-data cause) [:partial :authored-sleeps])))
(done)))))))
(t/deftest rejected-pause-retains-earlier-camera-evidence
(t/async done
(let [clock (fake-clock 0)
sleeps (atom 0)
hooks (assoc (drain-hooks {:clock clock :script [2] :calls (atom [])})
:render-from-cache (fn [] ((:advance! clock) 3))
:sleep (fn [ms]
(if (= 1 (swap! sleeps inc))
(do ((:advance! clock) ms) (js/Promise.resolve nil))
(js/Promise.reject (ex-info "pause failed" {})))))
view {:scale 1 :x 0 :y 0}]
(-> (protocol/start-camera! {:hooks hooks :view view} {:settle-ms 0})
(protocol/preview-view! view)
(protocol/sleep! 7)
(protocol/sleep! 13)
(.then (fn [_] (t/is false "second pause must fail") (done)))
(.catch (fn [cause]
(t/is (= "pause failed" (ex-message cause)))
(t/is (= [3] (mapv :duration-ms (get-in (ex-data cause) [:partial :cached-slices]))))
(t/is (= [{:requested-ms 7 :actual-ms 7}]
(get-in (ex-data cause) [:partial :authored-sleeps])))
(done)))))))
(t/deftest restore-drains-with-sync-tiles-immediately
(t/async done
(let [clock (fake-clock 500)
calls (atom [])
order (atom [])
hooks (assoc (drain-hooks {:clock clock :script [1 3 2] :calls calls})
:set-view-start (fn [] (swap! order conj :start) nil)
:set-view (fn [view]
(swap! order conj [:view view])
nil)
:set-view-end (fn [] (swap! order conj :end) nil))
view {:scale 1 :x 10 :y 20}]
(-> (protocol/restore hooks view)
(.then (fn [{:keys [slices]}]
(t/is (= [:start [:view view] :end] @order)
"camera resets before the drain")
(t/is (= [4 1 3] @calls)
"restore drains with SyncTiles first")
(t/is (= [1 3 2] (mapv :frame-type slices)))
(done)))
(.catch (fn [cause]
(t/is false (str "must resolve, threw: " cause))
(done)))))))
(t/deftest camera-session-times-gesture-settle-and-final-drain
(t/async done
(let [clock (fake-clock 0)
renders (atom [])
cached (atom [])
views (atom [])
hooks {:now (:now clock)
:frame (fn []
((:advance! clock) 16)
(js/Promise.resolve ((:now clock))))
:sleep (fn [ms]
((:advance! clock) ms)
(js/Promise.resolve nil))
:check (fn [] nil)
:render (fn [_timestamp flags]
(swap! renders conj flags)
2)
:render-from-cache (fn []
(swap! cached conj true)
((:advance! clock) 3)
nil)
:set-view (fn [view]
(swap! views conj view)
nil)
:set-view-start (fn [] nil)
:set-view-end (fn []
((:advance! clock) 5)
nil)}
frames [{:scale 1 :x 0 :y 0} {:scale 1 :x 100 :y 0}]]
(-> (protocol/start-camera! {:hooks hooks :view (first frames)} {:settle-ms 100})
(protocol/preview-view! (first frames))
(protocol/preview-view! (second frames))
(protocol/finish-camera!)
(.then (fn [m]
(t/is (= frames @views) "every gesture frame sets the view")
(t/is (= 2 (count @cached)) "one cached preview per frame")
(t/is (= 2 (count (:cached-slices m))))
(t/is (every? #(contains? % :timestamp) (:cached-slices m)))
(t/is (every? #(contains? % :duration-ms) (:cached-slices m))
"cached slices keep their own shape")
(t/is (= 100 (:settling-requested-ms m)))
(t/is (= 100 (:settling-actual-ms m))
"fake sleep advances the clock exactly")
(t/is (= 5 (:set-view-end-ms m)))
(t/is (= [4] @renders) "final drain starts with SyncTiles")
(t/is (>= (:time-to-full-ms m) (:set-view-end-ms m))
"finalization includes _set_view_end")
(t/is (= (:time-to-viewport-ready-ms m) (:time-to-full-ms m))
"immediate final drain gives equal boundaries")
(t/is (= 108 (:last-input-to-full-ms m))
"last input at 35, Full at 143: settle + set_view_end + drain")
(t/is (= [wasm/FRAME_TYPE_FULL] (mapv :frame-type (:slices m)))
"frame is full")
(done)))
(.catch (fn [cause]
(t/is false (str "must resolve, threw: " cause))
(done)))))))
(t/deftest camera-session-chains-promises-and-records-authored-sleep
(t/async done
(let [clock (fake-clock 0)
views (atom [])
events (atom [])
hooks (assoc (drain-hooks {:clock clock :script [2] :calls (atom [])})
:frame (fn []
(swap! events conj :frame)
((:advance! clock) 16)
(js/Promise.resolve ((:now clock))))
:set-view (fn [view]
(swap! views conj view)
((:advance! clock) 2))
:render-from-cache (fn []
(swap! events conj :cached)
((:advance! clock) 3))
:set-view-end (fn []
(swap! events conj :end)
((:advance! clock) 5)))
base {:scale 1 :x 0 :y 0}
target {:scale 2 :x 100 :y 40}]
(-> (protocol/start-camera! {:hooks hooks :view base} {:settle-ms 100})
(protocol/animate-view! {:to target :steps 3})
(protocol/sleep! 25)
(protocol/finish-camera!)
(.then (fn [m]
(t/is (apply < (map :x @views))
"intermediate camera views advance toward the target")
(t/is (= target (last @views)))
(t/is (= [:frame :cached :frame :cached :frame :cached :end] @events))
(t/is (= 3 (count (:cached-slices m))))
(t/is (= [5 5 5] (mapv :view-and-preview-ms (:cached-slices m)))
"call span also includes the view update")
(t/is (= [3 3 3] (mapv :duration-ms (:cached-slices m))))
(t/is (= [{:requested-ms 25 :actual-ms 25}] (:authored-sleeps m)))
(t/is (= 88 (:active-ms m))
"three rAF waits, view/cache calls and authored pause")
(t/is (= 100 (:settling-actual-ms m)))
(t/is (= 193 (:interact-ms m))
"active, settle and camera end are one span")
(done)))
(.catch (fn [cause]
(t/is false (str "must resolve, threw: " cause))
(done)))))))
(t/deftest camera-session-rejects-hook-errors
(t/async done
(let [clock (fake-clock 0)
hooks (assoc (drain-hooks {:clock clock :script [2] :calls (atom [])})
:render-from-cache (fn [] (throw (ex-info "cache failed" {:phase "preview"}))))]
(-> (protocol/start-camera! {:hooks hooks :view {:scale 1 :x 0 :y 0}}
{:settle-ms 100})
(protocol/preview-view! {:scale 1 :x 10 :y 0})
(protocol/finish-camera!)
(.then (fn [_]
(t/is false "preview error must reject")
(done)))
(.catch (fn [cause]
(t/is (= "cache failed" (ex-message cause)))
(done)))))))