mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 14:00:37 +00:00
🚧 WIP Step 16 Introduce hooks for the browser and load-scene
This commit is contained in:
parent
b01cdbe880
commit
6cec9d7be4
@ -10,11 +10,9 @@
|
||||
Lifecycle here mirrors the editor cold-load path
|
||||
(`app.render-wasm.api/initialize-viewport` + `set-objects` sync).
|
||||
|
||||
This ns exports `bridge`, which is what the Node 'pilot' uses inside the page.
|
||||
We only ever pass data through `page.evaluate`.
|
||||
The browser exports `bridge`; Node passes data through `page.evaluate`.
|
||||
|
||||
All `bridge` entries resolve, that is they never return an unhandled rejected
|
||||
promise.
|
||||
`loadScene` resolves to Transit. `ping` and `dispose` return plain data.
|
||||
|
||||
Failures are returned as `{\"status\" \"failed\" ...}` maps."
|
||||
(:require
|
||||
@ -23,41 +21,98 @@
|
||||
[app.common.render-wasm.serializers :as sr]
|
||||
[app.common.render-wasm.wasm :as wasm]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.transit :as t]
|
||||
;; bind the enum table from the prepared renderer build's `shared.js` so
|
||||
;; a missing `shared.js` fails the compile. A stale or mismatched one throws
|
||||
;; at load
|
||||
[app.render-wasm.api.enums]
|
||||
[app.render-wasm.api.webgl :as webgl]
|
||||
[benches.render-wasm.cases]
|
||||
[benches.render-wasm.protocol :as protocol]
|
||||
[benches.render-wasm.scenes.common :as scenes]
|
||||
[benches.render-wasm.scenes.core :as core]
|
||||
[benches.render-wasm.scenes.rects :as rects]
|
||||
[benches.render-wasm.upload :as upload]))
|
||||
|
||||
;; TODO(mem:render-wasm/performance/cljs-rewrite/06-render-and-interaction-protocol):
|
||||
;; move these to the shared WASM layer and delete them here. They mirror
|
||||
;; `app.render-wasm.api/FRAME_TYPE_*` until ticket 06 owns the protocol.
|
||||
(def ^:private frame-full 2)
|
||||
;; Forward decls
|
||||
(declare now guard-current! read-graphics)
|
||||
|
||||
(def ^:private default-view
|
||||
{:scale 1 :x 0 :y 0})
|
||||
|
||||
(def ^:private poll-frame-budget
|
||||
"Own deadline for the interim Full poll: at most this many rAF frames."
|
||||
600)
|
||||
|
||||
(def ^:private poll-ms-budget
|
||||
"Own deadline for the interim Full poll: at most this many wall ms."
|
||||
15000)
|
||||
|
||||
(def schema:load-args
|
||||
"Bridge-boundary schema for `load-rects`. Invalid args produce a failure
|
||||
map before any timer starts, never inside timed bodies."
|
||||
(def schema:load-scene-args
|
||||
"Decoded Transit request for `load-scene`."
|
||||
[:map {:closed true}
|
||||
[:seed [:int {:min 0 :max 4294967295}]]
|
||||
[:view {:optional true} core/schema:view]
|
||||
[:seed [:int {:min 0 :max 4294967295}]] ;; derived in `collect-cases`
|
||||
[:case map?]
|
||||
[:module-url {:optional true} string?]
|
||||
[:wasm-url {:optional true} string?]])
|
||||
|
||||
(def ^:const ^:private attempt-ms-budget
|
||||
"Own wall deadline for one bridge attempt, checked by `browser-hooks`.
|
||||
Ticket 11 accepts a per-attempt timeout; until then this fixed value
|
||||
bounds runaway drains. The Node outer timeout stays the backstop for
|
||||
synchronously blocked pages."
|
||||
30000)
|
||||
|
||||
(def ^:const ^:private gesture-settle-ms
|
||||
"Declared settle wait for warm gestures. The actual wait is recorded
|
||||
separately by `protocol/interact`."
|
||||
100)
|
||||
|
||||
(defn- browser-hooks
|
||||
"Browser hooks for one owner epoch and deadline."
|
||||
[epoch deadline-ms]
|
||||
{:now now
|
||||
;; rAF scheduling
|
||||
:frame #(js/Promise.
|
||||
(fn [resolve] (.requestAnimationFrame js/window resolve)))
|
||||
:sleep #(js/Promise. (fn [resolve] (js/setTimeout resolve %)))
|
||||
;; checks that this attempt still owns the page, WebGL context hasn't been
|
||||
;; lost, and the deadline hasn't passed
|
||||
:check (fn []
|
||||
(when @wasm/context-lost?
|
||||
(throw (ex-info "WebGL context lost"
|
||||
{:phase "context-lost"})))
|
||||
(guard-current! epoch "protocol")
|
||||
(when (> (now) deadline-ms)
|
||||
(throw (ex-info "attempt deadline exceeded"
|
||||
{:phase "deadline"}))))
|
||||
;; WASM calls
|
||||
:render (fn [timestamp flags]
|
||||
(h/call wasm/internal-module "_render" timestamp flags))
|
||||
:render-from-cache #(h/call wasm/internal-module "_render_from_cache" 0)
|
||||
:set-view #(h/call wasm/internal-module "_set_view"
|
||||
(:scale %) (- (:x %)) (- (:y %)))
|
||||
:set-view-start #(h/call wasm/internal-module "_set_view_start")
|
||||
:set-view-end #(h/call wasm/internal-module "_set_view_end")})
|
||||
|
||||
|
||||
;; TODO(tickets 07/08): Move gestures into scene cases. These rectangle ID
|
||||
;; checks reject every new warm case; the bridge should run its gesture.
|
||||
(defn- linear-frames
|
||||
"Linear interpolation of `{:scale :x :y}` views from `from` to `to` over
|
||||
`n` frames."
|
||||
[from to n]
|
||||
(mapv (fn [i]
|
||||
(let [t (/ (inc i) n)]
|
||||
{:scale (+ (:scale from) (* t (- (:scale to) (:scale from))))
|
||||
:x (+ (:x from) (* t (- (:x to) (:x from))))
|
||||
:y (+ (:y from) (* t (- (:y to) (:y from))))}))
|
||||
(range n)))
|
||||
|
||||
(defn- gesture-frames
|
||||
"Builds rectangle pan or zoom frames from the case view. Other case ids
|
||||
throw because the bridge has no gesture for them."
|
||||
[case view]
|
||||
(cond
|
||||
(= (:id case) :rects/pan)
|
||||
(linear-frames view (update view :x + 200) 20)
|
||||
|
||||
(= (:id case) :rects/zoom)
|
||||
(linear-frames view (update view :scale * 1.5) 20)
|
||||
|
||||
:else
|
||||
(throw (ex-info (str "no gesture declared for case " (:id case))
|
||||
{:phase "gesture"
|
||||
:case (:id case)}))))
|
||||
|
||||
;; Monotonic owner generation. Every async continuation compares its
|
||||
;; captured epoch and drops stale results silently; dispose/load bump it.
|
||||
(defonce ^:private owner-epoch*
|
||||
@ -74,7 +129,7 @@
|
||||
|
||||
(defn- fail-data
|
||||
[m]
|
||||
(clj->js (assoc m "status" "failed")))
|
||||
(assoc m :status "failed"))
|
||||
|
||||
(defn- mark-live!
|
||||
[]
|
||||
@ -162,43 +217,27 @@
|
||||
(dispose!)
|
||||
true))
|
||||
|
||||
(defn- poll-full
|
||||
"TODO(mem:render-wasm/performance/cljs-rewrite/06-render-and-interaction-protocol):
|
||||
interim inline drain. Ticket 06 replaces this with `protocol.cljs`; do not
|
||||
extend it. Resolves `{:ms [...] :frames n}` at Full, `{:full false ...}` on
|
||||
budget exhaustion, and checks the owner epoch every frame. A synchronous
|
||||
throw inside a frame rejects: rAF callbacks run outside the executor, so
|
||||
without this the bridge promise would hang."
|
||||
[epoch started-frames started-ms]
|
||||
(js/Promise.
|
||||
(fn [resolve reject]
|
||||
(letfn [(step [frames]
|
||||
(if (not= epoch @owner-epoch*)
|
||||
(resolve #js {"stale" true})
|
||||
(let [frame-type (try
|
||||
(h/call wasm/internal-module "_render" (now) 0)
|
||||
(catch :default cause
|
||||
(reject cause)
|
||||
::thrown))]
|
||||
(when-not (= frame-type ::thrown)
|
||||
(cond
|
||||
(= frame-type frame-full)
|
||||
(resolve #js {"full" true
|
||||
"frames" (- frames started-frames)
|
||||
"ms" (- (now) started-ms)})
|
||||
|
||||
(or (>= frames (+ started-frames poll-frame-budget))
|
||||
(>= (- (now) started-ms) poll-ms-budget))
|
||||
(resolve #js {"full" false
|
||||
"frames" (- frames started-frames)
|
||||
"ms" (- (now) started-ms)
|
||||
"frameType" frame-type})
|
||||
|
||||
:else
|
||||
(.requestAnimationFrame
|
||||
js/window
|
||||
(fn [] (step (inc frames)))))))))]
|
||||
(.requestAnimationFrame js/window (fn [] (step (inc started-frames))))))))
|
||||
(defn- render-ok
|
||||
"Maps one drain result to the bridge value. Success keeps the owner live
|
||||
for explicit disposal. Failures release the owner only if it still is current
|
||||
(i.e. it owns the page resources) so a stale failure cannot dispose of
|
||||
resources belonging to a newer owner."
|
||||
[epoch module-ms graphics-ms upload-ms alive? drain-result snapshot seed ctx width height dpr renderer]
|
||||
(if-not alive?
|
||||
(do
|
||||
(dispose-if-current! epoch)
|
||||
(fail-data {:phase "first-render"
|
||||
:message "disposed or context lost during render"}))
|
||||
(assoc drain-result
|
||||
:status "ok"
|
||||
:module-init-ms module-ms
|
||||
:graphics-init-ms graphics-ms
|
||||
:upload-ms upload-ms
|
||||
:first-render-ms (:full-ms drain-result)
|
||||
:render-frames (count (:slices drain-result))
|
||||
:scene {:shapes (count (:objects snapshot))
|
||||
:seed seed}
|
||||
:effective-graphics (read-graphics ctx width height dpr renderer))))
|
||||
|
||||
(defn- read-graphics
|
||||
"Records effective graphics settings: requested CSS size and DPR
|
||||
@ -214,73 +253,26 @@
|
||||
attr (fn [k]
|
||||
(when (some? attrs)
|
||||
(unchecked-get attrs k)))]
|
||||
{"cssWidth" width
|
||||
"cssHeight" height
|
||||
"drawingBufferWidth" (.-drawingBufferWidth ^js ctx)
|
||||
"drawingBufferHeight" (.-drawingBufferHeight ^js ctx)
|
||||
"dpr" dpr
|
||||
"webgl2" true
|
||||
"antialias" (attr "antialias")
|
||||
"depth" (attr "depth")
|
||||
"stencil" (attr "stencil")
|
||||
"alpha" (attr "alpha")
|
||||
"preserveDrawingBuffer" (attr "preserveDrawingBuffer")
|
||||
"renderer" renderer
|
||||
"software" (software-renderer? renderer)}))
|
||||
|
||||
(defn- render-ok
|
||||
"Maps one poll result to the bridge value. Success keeps the owner live
|
||||
for explicit disposal. Every failure path releases the owner when it still
|
||||
owns the realm, so budget exhaustion and context loss never leave a live
|
||||
canvas behind. Stale results never dispose: a newer owner is live."
|
||||
[epoch module-ms graphics-ms upload-ms alive? poll snapshot seed ctx width height dpr renderer]
|
||||
(let [pm (js->clj poll :keywordize-keys true)]
|
||||
(cond
|
||||
(:stale pm)
|
||||
;; dispose! resets loss state, so stale-plus-lost means genuine
|
||||
;; context loss, not disposal. The loss listener already released the
|
||||
;; canvas; the dispose call below only matters when loss was recorded
|
||||
;; without an epoch bump.
|
||||
(if @wasm/context-lost?
|
||||
(do
|
||||
(dispose-if-current! epoch)
|
||||
(fail-data {:phase "context-lost"
|
||||
:message "WebGL context lost during render"}))
|
||||
#js {"status" "stale"})
|
||||
|
||||
;; Safety net: every loss writer bumps the epoch, so staleness
|
||||
;; normally fires first.
|
||||
(not alive?)
|
||||
(do
|
||||
(dispose-if-current! epoch)
|
||||
(fail-data {:phase "first-render"
|
||||
:message "disposed or context lost during render"}))
|
||||
|
||||
(:full pm)
|
||||
(clj->js
|
||||
{"status" "ok"
|
||||
"moduleInitMs" module-ms
|
||||
"graphicsInitMs" graphics-ms
|
||||
"uploadMs" upload-ms
|
||||
"firstRenderMs" (:ms pm)
|
||||
"renderFrames" (:frames pm)
|
||||
"scene" {"shapes" (count (:objects snapshot))
|
||||
"seed" seed}
|
||||
"effectiveGraphics" (read-graphics ctx width height dpr renderer)})
|
||||
|
||||
:else
|
||||
(do
|
||||
(dispose-if-current! epoch)
|
||||
(fail-data {:phase "first-render"
|
||||
:message "Full not reached in budget"
|
||||
:detail pm})))))
|
||||
{:css-width width
|
||||
:css-height height
|
||||
:drawing-buffer-width (.-drawingBufferWidth ^js ctx)
|
||||
:drawing-buffer-height (.-drawingBufferHeight ^js ctx)
|
||||
:dpr dpr
|
||||
:webgl2 true
|
||||
:antialias (attr "antialias")
|
||||
:depth (attr "depth")
|
||||
:stencil (attr "stencil")
|
||||
:alpha (attr "alpha")
|
||||
:preserve-drawing-buffer (attr "preserveDrawingBuffer")
|
||||
:renderer renderer
|
||||
:software (software-renderer? renderer)}))
|
||||
|
||||
(defn- install-listeners!
|
||||
"Wires context lost/restored handling for `canvas`, epoch-guarded: a stale
|
||||
canvas (removed but not yet GC'd) must not kill the live owner. On loss the
|
||||
current owner releases its canvas and GL resources itself: the poll loop
|
||||
only observes staleness afterwards, so a `render-ok` cleanup there would
|
||||
always miss."
|
||||
current owner releases its canvas and GL resources itself: the protocol
|
||||
loop only observes staleness afterwards, so a cleanup by `render-ok` /
|
||||
`warm-ok` there would always miss."
|
||||
[canvas epoch]
|
||||
(.addEventListener
|
||||
canvas "webglcontextlost"
|
||||
@ -432,7 +424,7 @@
|
||||
the top-level cause decides staleness."
|
||||
[epoch cause fallback-phase]
|
||||
(if (stale? cause)
|
||||
#js {"status" "stale"}
|
||||
{:status "stale"}
|
||||
(let [phase (try (or (:phase (ex-data cause))
|
||||
fallback-phase
|
||||
"aborted")
|
||||
@ -460,144 +452,254 @@
|
||||
(aset (.-style ^js canvas) "height" (str height "px"))
|
||||
canvas))
|
||||
|
||||
;; TODO: remove in Ticket 06
|
||||
(defn load-rects
|
||||
"Loads the `:rects/load` scene and renders it to Full. Takes plain data:
|
||||
`{\"seed\" int, \"view\" {scale x y viewport?}?, \"module-url\"?, \"wasm-url\"?}`.
|
||||
Viewport defaults to 1920x1080@2. Always resolves (failure maps included).
|
||||
(defn- warm-ok
|
||||
"Maps an interaction to the bridge result. Success keeps the owner live;
|
||||
failure releases it if this attempt still owns the page."
|
||||
[epoch module-ms graphics-ms upload-ms interact-ms alive? interaction snapshot seed ctx width height dpr renderer]
|
||||
(if-not alive?
|
||||
(do
|
||||
(dispose-if-current! epoch)
|
||||
(fail-data {:phase "interact"
|
||||
:message "disposed or context lost during interaction"}))
|
||||
(merge interaction
|
||||
{:status "ok"
|
||||
:module-init-ms module-ms
|
||||
:graphics-init-ms graphics-ms
|
||||
:upload-ms upload-ms
|
||||
:interact-ms interact-ms
|
||||
:scene {:shapes (count (:objects snapshot))
|
||||
:seed seed}
|
||||
:effective-graphics (read-graphics ctx width height dpr renderer)})))
|
||||
|
||||
The chain is flat: one `.then` per phase (module, graphics, scene, upload,
|
||||
drain) plus a terminal `.catch`, so every rejection carries its phase to a
|
||||
single ownership-aware handler."
|
||||
[args]
|
||||
(defn- run-fresh
|
||||
"Times one drain for a fresh-context case. Resolves the bridge value
|
||||
through `render-ok`; drain failures resolve through `terminal-failure`."
|
||||
[epoch hooks seed dims g live?]
|
||||
(let [{:keys [module-ms graphics-ms renderer ctx snapshot upload-ms]} g
|
||||
{:keys [width height dpr]} dims]
|
||||
(-> (protocol/drain hooks {:flags 0 :origin ((:now hooks)) :immediate false})
|
||||
(.then (fn [drain-result]
|
||||
(render-ok epoch module-ms graphics-ms upload-ms
|
||||
(live?) drain-result snapshot seed
|
||||
(:context ctx) width height dpr renderer)))
|
||||
(.catch (fn [cause]
|
||||
(terminal-failure epoch cause "first-render"))))))
|
||||
|
||||
(defn- run-warm
|
||||
"Restores the initial view and drains outside timing, then times one
|
||||
gesture for a reuse-context case. Resolves the bridge value through
|
||||
`warm-ok`; gesture failures resolve through `terminal-failure`."
|
||||
[epoch hooks case-desc seed dims g live?]
|
||||
(let [{:keys [module-ms graphics-ms renderer ctx snapshot upload-ms view]} g
|
||||
{:keys [width height dpr]} dims]
|
||||
(-> (protocol/restore hooks view)
|
||||
(.then (fn [_]
|
||||
(guard-current! epoch "interact")
|
||||
(let [t0 ((:now hooks))]
|
||||
(-> (protocol/interact
|
||||
hooks
|
||||
{:frames (gesture-frames case-desc view)
|
||||
:settle-ms gesture-settle-ms})
|
||||
(.then (fn [interaction]
|
||||
(warm-ok epoch module-ms graphics-ms upload-ms
|
||||
(- ((:now hooks)) t0) (live?) interaction
|
||||
snapshot seed (:context ctx)
|
||||
width height dpr renderer)))
|
||||
(.catch (fn [cause]
|
||||
(terminal-failure epoch cause "interact")))))))
|
||||
(.catch (fn [cause]
|
||||
(terminal-failure epoch cause "restore"))))))
|
||||
|
||||
(defn- parse-load-request
|
||||
"Decodes and validates the request before claiming the page. Failure
|
||||
leaves the current owner untouched."
|
||||
[request]
|
||||
(try
|
||||
(let [params (js->clj (or args #js {}) :keywordize-keys true)
|
||||
view (merge default-view (:view params))
|
||||
seed (:seed params)]
|
||||
(if-not (sm/validate schema:load-args
|
||||
(cond-> {:seed seed}
|
||||
(:view params) (assoc :view (:view params))
|
||||
(:module-url params) (assoc :module-url (:module-url params))
|
||||
(:wasm-url params) (assoc :wasm-url (:wasm-url params))))
|
||||
(js/Promise.resolve
|
||||
(fail-data {:phase "invalid-args"
|
||||
:message "load-rects needs {seed int, view?}"}))
|
||||
(let [epoch (swap! owner-epoch* inc)]
|
||||
;; Supersede: the epoch bump above already retired the previous
|
||||
;; owner's continuations. Release its canvas and GL resources now
|
||||
;; with the production teardown path, and drop the cached module
|
||||
;; promise so this owner measures a fresh instantiation.
|
||||
(release-owner-resources!)
|
||||
(reset! module-promise* nil)
|
||||
(let [viewport (core/resolve-viewport view)
|
||||
width (:width viewport)
|
||||
height (:height viewport)
|
||||
dpr (:dpr viewport)
|
||||
live? (fn [] (and (= epoch @owner-epoch*) (wasm/live?)))]
|
||||
(-> (ensure-module! (or (:module-url params) "./render-wasm.js")
|
||||
(or (:wasm-url params) "./render-wasm.wasm"))
|
||||
(.catch (fn [cause]
|
||||
(throw (ex-info (str "module init failed: "
|
||||
(or (ex-message cause) cause))
|
||||
{:phase "module-init"}
|
||||
cause))))
|
||||
(.then (fn [installed]
|
||||
(guard-current! epoch "module-init")
|
||||
installed))
|
||||
(.then (fn [{:keys [module factory-ms]}]
|
||||
(guard-current! epoch "graphics-init")
|
||||
(set! wasm/internal-module module)
|
||||
(let [canvas (build-canvas! width height dpr)]
|
||||
(.appendChild (.-body js/document) canvas)
|
||||
(reset! canvas* canvas)
|
||||
(let [g0 (now)
|
||||
ctx (try
|
||||
(webgl/init-context!
|
||||
canvas
|
||||
{:module module
|
||||
:context-id "webgl2"
|
||||
:css-width width
|
||||
:css-height height
|
||||
:dpr dpr
|
||||
:flags 0
|
||||
:browser (sr/translate-browser :chrome)
|
||||
:params {}})
|
||||
(catch :default cause
|
||||
(throw (ex-info "graphics init failed"
|
||||
{:phase "graphics-init"}
|
||||
cause))))]
|
||||
(if (nil? ctx)
|
||||
(throw (ex-info "WebGL2 context unavailable"
|
||||
{:phase "graphics-init"}))
|
||||
(do
|
||||
(set! wasm/gl-context-handle (:handle ctx))
|
||||
(set! wasm/gl-context (:context ctx))
|
||||
(set! wasm/canvas canvas)
|
||||
(mark-live!)
|
||||
(try
|
||||
(h/call wasm/internal-module "_resize_viewbox" width height)
|
||||
(catch :default cause
|
||||
(throw (ex-info "resize viewbox failed"
|
||||
{:phase "graphics-init"}
|
||||
cause))))
|
||||
(install-listeners! canvas epoch)
|
||||
{:module-ms factory-ms
|
||||
:graphics-ms (- (now) g0)
|
||||
:renderer (renderer-string (:context ctx))
|
||||
:ctx ctx}))))))
|
||||
(.then (fn [g]
|
||||
(guard-current! epoch "scene-build")
|
||||
(try
|
||||
(let [snapshot (-> (rects/build {:seed seed})
|
||||
(scenes/validate!))]
|
||||
(assoc g
|
||||
:snapshot snapshot
|
||||
:ordered (upload/prepare-scene snapshot)))
|
||||
(catch :default cause
|
||||
(throw (ex-info "scene build failed"
|
||||
{:phase "scene-build"}
|
||||
cause))))))
|
||||
(.then (fn [g]
|
||||
(guard-current! epoch "upload")
|
||||
(try
|
||||
(let [u0 (now)]
|
||||
(h/call wasm/internal-module "_set_view"
|
||||
(:scale view) (- (:x view)) (- (:y view)))
|
||||
(h/call wasm/internal-module "_init_shapes_pool"
|
||||
(count (:objects (:snapshot g))))
|
||||
(h/call wasm/internal-module "_begin_loading")
|
||||
(try
|
||||
(serialize-shape/serialize-shapes-batch!
|
||||
(:ordered g)
|
||||
{:include-layout? false
|
||||
:include-fills-strokes? true})
|
||||
(finally
|
||||
(h/call wasm/internal-module "_end_loading")))
|
||||
(h/call wasm/internal-module "_set_view_end")
|
||||
(assoc g :upload-ms (- (now) u0)))
|
||||
(catch :default cause
|
||||
(if (stale? cause)
|
||||
(throw cause)
|
||||
(throw (ex-info "upload failed"
|
||||
{:phase "upload"}
|
||||
cause)))))))
|
||||
(.then (fn [g]
|
||||
(guard-current! epoch "first-render")
|
||||
(let [{:keys [module-ms graphics-ms renderer ctx snapshot upload-ms]} g
|
||||
r0 (now)]
|
||||
(-> (poll-full epoch 0 r0)
|
||||
(.then (fn [poll]
|
||||
(render-ok epoch module-ms graphics-ms upload-ms
|
||||
(live?) poll snapshot seed
|
||||
(:context ctx)
|
||||
width height dpr renderer)))
|
||||
(.catch (fn [cause]
|
||||
(terminal-failure epoch cause "first-render")))))))
|
||||
(.catch (fn [cause]
|
||||
(terminal-failure epoch cause "aborted"))))))))
|
||||
(let [params (t/decode-str request)]
|
||||
(if-not (sm/validate schema:load-scene-args params)
|
||||
{:error (fail-data {:phase "invalid-args"
|
||||
:message "load-scene needs a Transit request with seed and case"})}
|
||||
(let [case-desc (:case params)
|
||||
scene (core/registered-scene (:scene case-desc))]
|
||||
(when (nil? scene)
|
||||
(throw (ex-info (str "unknown scene: " (:scene case-desc))
|
||||
{:phase "invalid-args"})))
|
||||
{:params params
|
||||
:case-desc (core/check-collected-case case-desc)
|
||||
:scene scene})))
|
||||
(catch :default cause
|
||||
(js/Promise.resolve
|
||||
(fail-data {:phase "setup" :message (ex-message cause)})))))
|
||||
{:error (fail-data {:phase "invalid-args"
|
||||
:message (or (ex-message cause)
|
||||
"invalid Transit request")})})))
|
||||
|
||||
(defn- begin-load!
|
||||
"Claims the page, releases the prior owner, then fixes this attempt's
|
||||
viewport, hooks and deadline. Module instantiation gets a fresh cache."
|
||||
[case-desc]
|
||||
(let [epoch (swap! owner-epoch* inc)]
|
||||
(release-owner-resources!)
|
||||
(reset! module-promise* nil)
|
||||
(let [viewport (core/resolve-viewport (:view case-desc))]
|
||||
{:epoch epoch
|
||||
:dims viewport
|
||||
:hooks (browser-hooks epoch (+ (now) attempt-ms-budget))
|
||||
:live? (fn [] (and (= epoch @owner-epoch*) (wasm/live?)))})))
|
||||
|
||||
(defn- load-module
|
||||
"Imports and instantiates the renderer. A failed import or factory call
|
||||
keeps the module-init phase and its cause for bridge accounting."
|
||||
[epoch params]
|
||||
(-> (ensure-module! (or (:module-url params) "./render-wasm.js")
|
||||
(or (:wasm-url params) "./render-wasm.wasm"))
|
||||
(.catch (fn [cause]
|
||||
(throw (ex-info (str "module init failed: "
|
||||
(or (ex-message cause) cause))
|
||||
{:phase "module-init"}
|
||||
cause))))
|
||||
(.then (fn [installed]
|
||||
(guard-current! epoch "module-init")
|
||||
installed))))
|
||||
|
||||
(defn- initialize-graphics!
|
||||
"Creates the canvas and WebGL context, binds them to the renderer and
|
||||
returns the module and graphics timings for later phases."
|
||||
[epoch {:keys [width height dpr]} {:keys [module factory-ms]}]
|
||||
(guard-current! epoch "graphics-init")
|
||||
(set! wasm/internal-module module)
|
||||
(let [canvas (build-canvas! width height dpr)]
|
||||
(.appendChild (.-body js/document) canvas)
|
||||
(reset! canvas* canvas)
|
||||
(let [g0 (now)
|
||||
ctx (try
|
||||
(webgl/init-context
|
||||
canvas
|
||||
{:module module
|
||||
:context-id "webgl2"
|
||||
:css-width width
|
||||
:css-height height
|
||||
:dpr dpr
|
||||
:flags 0
|
||||
:browser (sr/translate-browser :chrome)
|
||||
:params {}})
|
||||
(catch :default cause
|
||||
(throw (ex-info "graphics init failed"
|
||||
{:phase "graphics-init"}
|
||||
cause))))]
|
||||
(when (nil? ctx)
|
||||
(throw (ex-info "WebGL2 context unavailable"
|
||||
{:phase "graphics-init"})))
|
||||
(set! wasm/gl-context-handle (:handle ctx))
|
||||
(set! wasm/gl-context (:context ctx))
|
||||
(set! wasm/canvas canvas)
|
||||
(mark-live!)
|
||||
(try
|
||||
(h/call wasm/internal-module "_resize_viewbox" width height)
|
||||
(catch :default cause
|
||||
(throw (ex-info "resize viewbox failed"
|
||||
{:phase "graphics-init"}
|
||||
cause))))
|
||||
(install-listeners! canvas epoch)
|
||||
{:module-ms factory-ms
|
||||
:graphics-ms (- (now) g0)
|
||||
:renderer (renderer-string (:context ctx))
|
||||
:ctx ctx})))
|
||||
|
||||
(defn- prepare-scene
|
||||
"Builds and validates the registered scene, then orders its shapes before
|
||||
the upload timer begins."
|
||||
[epoch scene case-desc seed graphics]
|
||||
(guard-current! epoch "scene-build")
|
||||
(try
|
||||
(let [snapshot (-> ((:build scene) (assoc (:params case-desc) :seed seed))
|
||||
(scenes/validate!))]
|
||||
(assoc graphics
|
||||
:snapshot snapshot
|
||||
:ordered (upload/prepare-scene snapshot)))
|
||||
(catch :default cause
|
||||
(if (stale? cause)
|
||||
(throw cause)
|
||||
(throw (ex-info "scene build failed"
|
||||
{:phase "scene-build"}
|
||||
cause))))))
|
||||
|
||||
(defn- upload-scene!
|
||||
"Sets the initial view and uploads prepared shapes inside the upload
|
||||
timer. Ends the loading lifecycle even when serialization fails."
|
||||
[epoch case-desc graphics]
|
||||
(guard-current! epoch "upload")
|
||||
(try
|
||||
(let [u0 (now)
|
||||
view (:view case-desc)]
|
||||
(h/call wasm/internal-module "_set_view"
|
||||
(:scale view) (- (:x view)) (- (:y view)))
|
||||
(h/call wasm/internal-module "_init_shapes_pool"
|
||||
(count (:objects (:snapshot graphics))))
|
||||
(h/call wasm/internal-module "_begin_loading")
|
||||
(try
|
||||
;; Rects carry no layout; per-scene upload options arrive with 07/08.
|
||||
(serialize-shape/serialize-shapes-batch
|
||||
(:ordered graphics)
|
||||
{:include-layout? false
|
||||
:include-fills-strokes? true})
|
||||
(finally
|
||||
(h/call wasm/internal-module "_end_loading")))
|
||||
(h/call wasm/internal-module "_set_view_end")
|
||||
(assoc graphics :upload-ms (- (now) u0) :view view))
|
||||
(catch :default cause
|
||||
(if (stale? cause)
|
||||
(throw cause)
|
||||
(throw (ex-info "upload failed"
|
||||
{:phase "upload"}
|
||||
cause))))))
|
||||
|
||||
;; TODO(ticket 14): Run the declared case body here. Context dispatch skips
|
||||
;; `:run!`, so custom cases cannot execute through this bridge.
|
||||
(defn- render-scene
|
||||
"Runs the fresh or reuse completion path after upload."
|
||||
[{:keys [epoch dims hooks live?]} case-desc seed graphics]
|
||||
(guard-current! epoch "render")
|
||||
(if (= (:context case-desc) :fresh)
|
||||
(run-fresh epoch hooks seed dims graphics live?)
|
||||
(run-warm epoch hooks case-desc seed dims graphics live?)))
|
||||
|
||||
(defn load-scene
|
||||
"Loads one collected case to Full. Takes a Transit request with `:seed`,
|
||||
`:case` and optional module/WASM URLs; resolves to a Transit result.
|
||||
Validation precedes ownership and all timers. Fresh cases time upload
|
||||
and one drain; reuse cases restore and drain outside interaction timing."
|
||||
[request]
|
||||
(let [epoch* (volatile! nil)
|
||||
result (try
|
||||
(let [{:keys [error params case-desc scene]}
|
||||
(parse-load-request request)
|
||||
seed (:seed params)]
|
||||
(if error
|
||||
(js/Promise.resolve error)
|
||||
(let [{:keys [epoch dims] :as attempt} (begin-load! case-desc)]
|
||||
(vreset! epoch* epoch)
|
||||
(-> (load-module epoch params)
|
||||
(.then (fn [module]
|
||||
(initialize-graphics! epoch dims module)))
|
||||
(.then (fn [graphics]
|
||||
(prepare-scene epoch scene case-desc seed graphics)))
|
||||
(.then (fn [graphics]
|
||||
(upload-scene! epoch case-desc graphics)))
|
||||
(.then (fn [graphics]
|
||||
(render-scene attempt case-desc seed graphics)))
|
||||
(.catch (fn [cause]
|
||||
(terminal-failure epoch cause "aborted")))))))
|
||||
(catch :default cause
|
||||
(js/Promise.resolve
|
||||
(fail-data {:phase "setup" :message (ex-message cause)}))))]
|
||||
(-> result
|
||||
(.then t/encode-str)
|
||||
(.catch (fn [cause]
|
||||
(when-some [epoch @epoch*]
|
||||
(dispose-if-current! epoch))
|
||||
(t/encode-str
|
||||
(fail-data {:phase "bridge"
|
||||
:message (or (ex-message cause)
|
||||
"result encoding failed")})))))))
|
||||
|
||||
(defn ping
|
||||
"Bridge placeholder that checks the Node/browser boundary."
|
||||
@ -607,5 +709,5 @@
|
||||
(def bridge
|
||||
"Browser-side bridge exported from the compiled module."
|
||||
#js {"ping" ping
|
||||
"loadRects" load-rects
|
||||
"loadScene" load-scene
|
||||
"dispose" dispose!})
|
||||
|
||||
344
frontend/test/benches/render_wasm/protocol.cljs
Normal file
344
frontend/test/benches/render_wasm/protocol.cljs
Normal file
@ -0,0 +1,344 @@
|
||||
;; 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 benches.render-wasm.protocol
|
||||
"Shared render-completion protocol for the renderer benchmarks.
|
||||
|
||||
The WASM renderer finishes progressively in tile budgets: one `_render`
|
||||
call returns a frame type and the caller keeps calling until `FRAME_TYPE_FULL`.
|
||||
This namespace implements a driver loop as three functions over an injected
|
||||
`hooks` map. Callers can use the same timing and continuation rules for
|
||||
rendering and interaction:
|
||||
|
||||
- `drain` drives `_render` to `FRAME_TYPE_FULL`, collecting one slice per
|
||||
call.
|
||||
- `restore` puts the camera back to the case's initial view, then drains.
|
||||
Callers run it outside timing.
|
||||
- `interact` replays a deterministic gesture of small camera moves with
|
||||
cheap cached previews, waits out the declared settle, then drains to
|
||||
`FRAME_TYPE_FULL`. The caller's interaction timer includes the camera
|
||||
start, gesture, settle wait, camera end and final drain.
|
||||
|
||||
`hooks` supplies clock, scheduling and renderer operations. The browser
|
||||
supplies real capabilities; tests supply fakes.
|
||||
|
||||
- `:now` zero-arg clock, ms.
|
||||
- `:frame` zero-arg promise of the next rAF timestamp.
|
||||
- `:sleep` `(fn [ms] ...)` promise.
|
||||
- `:check` zero-arg guard. Throws on cancellation, wall deadline,
|
||||
context loss or superseded ownership. The protocol checks before work,
|
||||
after awaited frames and sleeps, and around render calls. It never
|
||||
retries; synchronous stalls cannot be
|
||||
interrupted in-page, so Node stays the outer backstop.
|
||||
- `:render` `(fn [timestamp flags] ...)` returning a frame type.
|
||||
- `:render-from-cache` zero-arg cached-preview call. The protocol times
|
||||
the call itself and discards its return value.
|
||||
- `:set-view` `(fn [view] ...)` with `view` a `{:scale :x :y}` map.
|
||||
- `:set-view-start` / `:set-view-end` zero-arg camera calls.
|
||||
|
||||
Guard and renderer errors reject the returned promises. The browser's
|
||||
`:check` guard throws when work is cancelled, exceeds its deadline,
|
||||
loses its rendering context or is no longer the active task.
|
||||
|
||||
Frame types come from `app.common.render-wasm.wasm`: FRAME_TYPE_PARTIAL and
|
||||
FRAME_TYPE_VIEWPORT_READY continue, FRAME_TYPE_FULL completes, FRAME_TYPE_NONE
|
||||
or anything else fails the attempt. The previous returned frame type becomes the
|
||||
next call's flags. `restore` and the final drain of `interact` start with
|
||||
the SyncTiles flag immediately (no rAF); a fresh drain can start with
|
||||
different flags and wait for a frame.
|
||||
|
||||
## Metrics reported
|
||||
|
||||
- `:slices` one map per `_render` call:
|
||||
- `:timestamp`: the rAF stamp the call ran under, or the clock value for
|
||||
an immediate call without a frame
|
||||
- `:flags`: flags passed in
|
||||
- `:frame-type`: frame type returned
|
||||
- `:duration-ms`: wall time of the call itself
|
||||
Raw slices are retained as-is, including zero durations.
|
||||
- `:cached-slices` one map per gesture frame:
|
||||
- `:timestamp` rAF stamp of the input
|
||||
- `:duration-ms`: the timed `_render-from-cache` call.
|
||||
Cached previews have their own shape: no flags or frame type.
|
||||
- `:viewport-ready-ms` first Full-or-ViewportReady completion measured
|
||||
from the drain origin. An immediate Full supplies both boundaries, so
|
||||
they are equal there.
|
||||
- `:full-ms` Full completion measured from the drain origin.
|
||||
- `:active-ms` from before `_set_view_start` to after the last cached
|
||||
preview and its guard check.
|
||||
- `:settling-requested-ms` the declared settle wait passed in.
|
||||
- `:settling-actual-ms` from the end of the last cached preview to
|
||||
finalization start, recorded separately from the declared value.
|
||||
- `:set-view-end-ms` wall time of the `_set_view_end` call itself.
|
||||
Finalization starts before `_set_view_end`, so the final drain times
|
||||
include it; the call time is still reported on its own.
|
||||
- `:time-to-viewport-ready-ms` / `:time-to-full-ms` the drain's
|
||||
boundaries, renamed at the interaction level.
|
||||
- `:last-input-to-full-ms` from the clock read before the last view update
|
||||
to the clock read after the final drain promise resolves."
|
||||
(:require
|
||||
[app.common.render-wasm.wasm :as wasm]))
|
||||
|
||||
|
||||
(defn- next-render-timestamp
|
||||
[{:keys [now frame]} immediate?]
|
||||
(if immediate?
|
||||
(js/Promise.resolve (now))
|
||||
(frame)))
|
||||
|
||||
|
||||
(defn- render-slice
|
||||
"Times one render call and adds its slice to the drain state."
|
||||
[{:keys [now check render]}
|
||||
{:keys [flags] :as state}
|
||||
timestamp]
|
||||
(check)
|
||||
(let [started-ms (now)
|
||||
frame-type (render timestamp flags)
|
||||
ended-ms (now)
|
||||
slice {:timestamp timestamp
|
||||
:flags flags
|
||||
:frame-type frame-type
|
||||
:duration-ms (- ended-ms started-ms)}]
|
||||
(check)
|
||||
{:state (update state :slices conj slice)
|
||||
:frame-type frame-type
|
||||
:ended-ms ended-ms}))
|
||||
|
||||
|
||||
(defn- valid-frame-type?
|
||||
[frame-type]
|
||||
(or (= frame-type wasm/FRAME_TYPE_PARTIAL)
|
||||
(= frame-type wasm/FRAME_TYPE_VIEWPORT_READY)
|
||||
(= frame-type wasm/FRAME_TYPE_FULL)))
|
||||
|
||||
|
||||
(defn- viewport-ready-time
|
||||
[current-ready-ms frame-type ended-ms origin-ms]
|
||||
(or current-ready-ms
|
||||
(when (or (= frame-type wasm/FRAME_TYPE_VIEWPORT_READY)
|
||||
(= frame-type wasm/FRAME_TYPE_FULL))
|
||||
(- ended-ms origin-ms))))
|
||||
|
||||
|
||||
(defn- advance-drain-state
|
||||
[{:keys [viewport-ready-ms] :as state}
|
||||
frame-type
|
||||
ended-ms
|
||||
origin-ms]
|
||||
(when-not (valid-frame-type? frame-type)
|
||||
(throw (ex-info (str "unexpected frame type: " (pr-str frame-type))
|
||||
{:type ::unexpected-frame-type
|
||||
:frame-type frame-type})))
|
||||
(assoc state
|
||||
:flags frame-type
|
||||
:viewport-ready-ms
|
||||
(viewport-ready-time viewport-ready-ms
|
||||
frame-type
|
||||
ended-ms
|
||||
origin-ms)))
|
||||
|
||||
(defn- drain-complete?
|
||||
[frame-type]
|
||||
(= frame-type wasm/FRAME_TYPE_FULL))
|
||||
|
||||
(defn- drain-result
|
||||
[{:keys [slices viewport-ready-ms]}
|
||||
ended-ms
|
||||
origin-ms]
|
||||
{:slices slices
|
||||
:viewport-ready-ms viewport-ready-ms
|
||||
:full-ms (- ended-ms origin-ms)})
|
||||
|
||||
|
||||
(defn drain
|
||||
"Drives `_render` to `FRAME_TYPE_FULL` and resolves to
|
||||
`{:slices [...] :viewport-ready-ms ... :full-ms ...}`.
|
||||
|
||||
The flow is:
|
||||
|
||||
drain
|
||||
└─ step
|
||||
├─ next-render-timestamp
|
||||
├─ render-slice
|
||||
├─ advance-drain-state
|
||||
└─ either
|
||||
├─ drain-result
|
||||
└─ step
|
||||
|
||||
`opts` contains:
|
||||
- `:flags` (first call's flags)
|
||||
- `:origin` (required starting moment in ms)
|
||||
- `:immediate` (run the first `_render` in the same task instead of waiting
|
||||
for a frame; later frames always wait).
|
||||
|
||||
Partial and ViewportReady continue with the returned frame type as the next
|
||||
flags. FRAME_TYPE_FULL resolves. Anything else rejects with
|
||||
`::unexpected-frame-type`. A failure of the hook `:check` rejects without
|
||||
retrying."
|
||||
[{:keys [check] :as hooks}
|
||||
{:keys [flags origin immediate]}]
|
||||
(try
|
||||
(check)
|
||||
(letfn [(step [state immediate?]
|
||||
(-> (next-render-timestamp hooks immediate?)
|
||||
(.then ;; recurse or drain
|
||||
(fn [timestamp]
|
||||
(let [{:keys [frame-type ended-ms] :as rendered}
|
||||
(render-slice hooks state timestamp)
|
||||
next-state
|
||||
(advance-drain-state (:state rendered)
|
||||
frame-type
|
||||
ended-ms
|
||||
origin)]
|
||||
|
||||
(if (drain-complete? frame-type)
|
||||
(drain-result next-state ended-ms origin)
|
||||
(step next-state false)))))))]
|
||||
|
||||
(step {:flags flags
|
||||
:slices []
|
||||
:viewport-ready-ms nil}
|
||||
immediate))
|
||||
|
||||
(catch :default cause
|
||||
(js/Promise.reject cause))))
|
||||
|
||||
(defn restore
|
||||
"Puts the camera back to `view` (`{:scale :x :y}`) and drains with the
|
||||
SyncTiles flag immediately. Resolves what `drain` resolves. Callers run
|
||||
this outside timing: the reset itself is never scored."
|
||||
[{:keys [now check set-view set-view-start set-view-end] :as hooks} view]
|
||||
(try
|
||||
(check)
|
||||
(set-view-start)
|
||||
(set-view view)
|
||||
(set-view-end)
|
||||
(drain hooks {:flags wasm/RENDER_FLAG_SYNC_TILES
|
||||
:origin (now)
|
||||
:immediate true})
|
||||
(catch :default cause
|
||||
(js/Promise.reject cause))))
|
||||
|
||||
(defn- replay-frame
|
||||
[{:keys [now frame check render-from-cache set-view]}
|
||||
state
|
||||
view
|
||||
last-frame?]
|
||||
(-> (frame)
|
||||
(.then
|
||||
(fn [timestamp]
|
||||
(check)
|
||||
(let [input-ms (now)]
|
||||
(set-view view)
|
||||
(let [t0 (now)
|
||||
_ (render-from-cache)
|
||||
duration-ms (- (now) t0)]
|
||||
(check)
|
||||
(cond-> (-> state
|
||||
(assoc :last-input-ms input-ms)
|
||||
(update :cached-slices conj
|
||||
{:timestamp timestamp
|
||||
:duration-ms duration-ms}))
|
||||
last-frame?
|
||||
(assoc :active-end-ms (now)))))))))
|
||||
|
||||
|
||||
(defn- replay-gesture
|
||||
[{:keys [now] :as hooks} frames started-ms]
|
||||
(if (empty? frames)
|
||||
(js/Promise.resolve
|
||||
{:cached-slices []
|
||||
:last-input-ms started-ms
|
||||
:active-end-ms (now)})
|
||||
|
||||
(let [last-index (dec (count frames))]
|
||||
(reduce-kv
|
||||
(fn [state-p index view]
|
||||
(.then state-p
|
||||
#(replay-frame hooks
|
||||
%
|
||||
view
|
||||
(= index last-index))))
|
||||
(js/Promise.resolve
|
||||
{:cached-slices []
|
||||
:last-input-ms started-ms})
|
||||
frames))))
|
||||
|
||||
|
||||
(defn- finish-interaction
|
||||
[{:keys [now check set-view-end] :as hooks}
|
||||
settle-ms
|
||||
started-ms
|
||||
{:keys [cached-slices last-input-ms active-end-ms]}]
|
||||
(check)
|
||||
|
||||
(let [finalization-start (now)
|
||||
t0 (now)]
|
||||
(set-view-end)
|
||||
|
||||
(let [set-view-end-ms (- (now) t0)]
|
||||
(-> (drain hooks
|
||||
{:flags wasm/RENDER_FLAG_SYNC_TILES
|
||||
:origin finalization-start
|
||||
:immediate true})
|
||||
(.then
|
||||
(fn [{:keys [slices viewport-ready-ms full-ms]}]
|
||||
{:cached-slices cached-slices
|
||||
:slices slices
|
||||
:active-ms (- active-end-ms started-ms)
|
||||
:settling-requested-ms settle-ms
|
||||
:settling-actual-ms (- finalization-start active-end-ms)
|
||||
:set-view-end-ms set-view-end-ms
|
||||
:time-to-viewport-ready-ms viewport-ready-ms
|
||||
:time-to-full-ms full-ms
|
||||
:last-input-to-full-ms (- (now) last-input-ms)}))))))
|
||||
|
||||
|
||||
(defn- settle-and-finish
|
||||
[{:keys [sleep] :as hooks}
|
||||
settle-ms
|
||||
started-ms
|
||||
gesture-state]
|
||||
(-> (sleep settle-ms)
|
||||
(.then #(finish-interaction hooks
|
||||
settle-ms
|
||||
started-ms
|
||||
gesture-state))))
|
||||
|
||||
|
||||
(defn interact
|
||||
"Replays `frames` (a vector of `{:scale :x :y}` views) as one gesture
|
||||
and resolves the interaction metrics (see the namespace docstring).
|
||||
|
||||
The flow is:
|
||||
|
||||
interact
|
||||
├─ set-view-start
|
||||
├─ replay-gesture
|
||||
│ └─ replay-frame × N
|
||||
├─ settle
|
||||
└─ finish-interaction
|
||||
├─ set-view-end
|
||||
└─ drain
|
||||
|
||||
Each frame waits for rAF, records the input time, sets the view and
|
||||
times one cached preview into `:cached-slices`. Afterwards the declared
|
||||
`settle-ms` elapses, `_set_view_end` is timed on its own, and a SyncTiles
|
||||
immediate drain runs with its origin at finalization start, so the final
|
||||
times include `_set_view_end` while reporting it separately."
|
||||
[{:keys [now check set-view-start] :as hooks}
|
||||
{:keys [frames settle-ms]}]
|
||||
(try
|
||||
(let [started-ms (do (check) (now))]
|
||||
(set-view-start)
|
||||
(-> (replay-gesture hooks (vec frames) started-ms)
|
||||
(.then #(settle-and-finish hooks
|
||||
settle-ms
|
||||
started-ms
|
||||
%))))
|
||||
(catch :default cause
|
||||
(js/Promise.reject cause))))
|
||||
@ -28,12 +28,11 @@
|
||||
:params-schema [:map {:closed true}]}
|
||||
(fn [params] params))
|
||||
|
||||
(core/defcase :contracts-scene/case
|
||||
:contracts-scene
|
||||
(core/defcase :contracts-scene/case :contracts-scene
|
||||
{:params {}
|
||||
:view {:scale 1 :x 0 :y 0}
|
||||
:context :fresh}
|
||||
(-> rtx (touch!)))
|
||||
(fn [ctx] (touch! ctx)))
|
||||
|
||||
(defn- failure-data
|
||||
[f]
|
||||
@ -191,7 +190,7 @@
|
||||
(assoc good :operation {:point (->Point 1 2)})
|
||||
(assoc-in good [:params :count] js/NaN)]]
|
||||
(t/is (= ::core/non-serializable-case
|
||||
(:type (failure-data #(core/check-collected-case! bad))))
|
||||
(:type (failure-data #(core/check-collected-case bad))))
|
||||
(pr-str bad)))))
|
||||
|
||||
(t/deftest duplicate-scene-id-from-another-namespace-is-rejected
|
||||
@ -400,12 +399,11 @@
|
||||
(:type (failure-data #(cases/collect-cases {:master-seed 42
|
||||
:filter "missing"}))))))
|
||||
|
||||
(t/deftest defcase-body-is-stored-and-stripped
|
||||
(t/deftest defcase-registers-function-without-sending-it
|
||||
(reset! body-calls [])
|
||||
(let [collected (cases/collect-cases {:master-seed 42 :filter "contracts-scene/"})]
|
||||
(t/is (= [:contracts-scene/case] (mapv :id collected)))
|
||||
(t/is (not (contains? (first collected) :run!)))
|
||||
(t/is (not (contains? (first collected) :body-source)))
|
||||
(t/is (core/transit-round-trips? (first collected))))
|
||||
(t/is (empty? @body-calls))
|
||||
(let [entry (core/registered-case :contracts-scene/case)]
|
||||
@ -414,38 +412,9 @@
|
||||
(t/is (= :runtime ((:run! entry) :runtime)))
|
||||
(t/is (= [:touched] @body-calls))))
|
||||
|
||||
(t/deftest check-body-source-accepts-pipelines
|
||||
(doseq [source ['((-> rtx (pan! x)))
|
||||
'((let [a 1] (-> rtx (pan! a))))
|
||||
'((do (log! 1) (-> rtx (pan!))))
|
||||
'((comment "setup") (-> rtx (pan!)))
|
||||
'((clojure.core/-> rtx (pan!)))]]
|
||||
(t/is (= source (core/check-body-source! :case source)) (pr-str source))))
|
||||
|
||||
(t/deftest check-body-source-rejects-other-shapes
|
||||
(doseq [source ['((pan! x))
|
||||
'((-> other (pan!)))
|
||||
'((->> rtx (pan!)))
|
||||
'((if c (-> rtx a) (-> rtx b)))
|
||||
'()
|
||||
'((do))
|
||||
'((let [x 1]))]]
|
||||
(t/is (= ::core/invalid-case
|
||||
(:type (failure-data #(core/check-body-source! :case source))))
|
||||
(pr-str source))))
|
||||
|
||||
(t/deftest register-case-rejects-invalid-body-source
|
||||
(try
|
||||
(t/is (= ::core/invalid-case
|
||||
(:type (failure-data
|
||||
#(core/register-case! {:id :contracts-badbody/case
|
||||
:scene :contracts-scene
|
||||
:ns "contracts-test"
|
||||
:params {}
|
||||
:view {:scale 1 :x 0 :y 0}
|
||||
:context :fresh
|
||||
:run! (fn [rtx] rtx)
|
||||
:body-source '((pan! x))})))))
|
||||
(t/is (nil? (core/registered-case :contracts-badbody/case)))
|
||||
(finally
|
||||
(core/unregister-case! :contracts-badbody/case))))
|
||||
(t/deftest defcase-rejects-run-function-in-options
|
||||
(t/is (= :benches.render-wasm.scenes.core/invalid-run-function
|
||||
(:type (failure-data
|
||||
#(core/defcase :contracts-scene/unchecked :contracts-scene
|
||||
{:run! (fn [x y] [x y])})))))
|
||||
(t/is (nil? (core/registered-case :contracts-scene/unchecked))))
|
||||
|
||||
@ -5,36 +5,49 @@
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns frontend-tests.benches.lifecycle-test
|
||||
"Boundary-fake coverage for the ticket-05 browser lifecycle.
|
||||
"Tests the browser bridge with fake DOM and renderer boundaries.
|
||||
|
||||
Full load/render runs headless in the pilot (real WASM + DOM). Here the
|
||||
DOM/FFI boundary is faked: bridge arg validation (seeds, views, viewports,
|
||||
non-finite numbers), viewport resolution, disposal state reset, epoch
|
||||
bumps, owner-guard and terminal-failure mapping, context-loss flags, bridge
|
||||
shape, and through-entry `load-rects` scenarios (happy path, rejected
|
||||
import, post-registration failure, overlapping loads) against fake canvases
|
||||
and a fake Emscripten factory. GL init/release unit behavior lives in
|
||||
`frontend-tests.render-wasm.webgl-test`; live context loss and
|
||||
blocked-page cancellation belong to ticket 15's failure verification;
|
||||
exhaustive stale-callback coverage belongs to ticket 06's protocol-test
|
||||
with its injected clock."
|
||||
It covers bridge input, ownership, disposal and `load-scene` with fake
|
||||
canvases and a fake Emscripten factory. Protocol timing tests use an
|
||||
injected clock in `frontend-tests.benches.protocol-test`."
|
||||
(:require
|
||||
[app.common.render-wasm.wasm :as wasm]
|
||||
[app.common.transit :as transit]
|
||||
[benches.render-wasm.browser :as browser]
|
||||
[benches.render-wasm.cases :as cases]
|
||||
[benches.render-wasm.scenes.core :as core]
|
||||
[cljs.test :as t :include-macros true]))
|
||||
|
||||
;; Partial-init unwinding of `init-context!` is covered in
|
||||
;; Forward declaration: entry-test helpers below build args against the
|
||||
;; fake Emscripten factory URL defined alongside the fake-DOM harness.
|
||||
(declare factory-url)
|
||||
|
||||
;; Partial-init unwinding of `init-context` is covered in
|
||||
;; `frontend-tests.render-wasm.webgl-test`, which also pins the success,
|
||||
;; nil-context and registration-failure cases.
|
||||
|
||||
(defn- load-case
|
||||
"Collected `:rects/load` descriptor for entry tests."
|
||||
[]
|
||||
(first (cases/collect-cases {:master-seed 42 :filter "rects/load"})))
|
||||
|
||||
(defn- scene-args
|
||||
"Transit request for `browser/load-scene`. `overrides` change the
|
||||
collected case descriptor."
|
||||
[seed overrides]
|
||||
(transit/encode-str
|
||||
{:seed seed
|
||||
:case (merge (load-case) overrides)
|
||||
:module-url (factory-url)
|
||||
:wasm-url "./fake.wasm"}))
|
||||
|
||||
(defn- expect-failed-args
|
||||
"Calls `load-rects` with `args` (never touching DOM/FFI: validation runs
|
||||
"Calls `load-scene` with `args` (never touching DOM/FFI: validation runs
|
||||
first) and asserts a failure map. Calls `done` when finished."
|
||||
[args check-phase? done]
|
||||
(-> (.then (browser/load-rects args)
|
||||
(-> (.then (browser/load-scene args)
|
||||
(fn [result]
|
||||
(let [m (js->clj result :keywordize-keys true)]
|
||||
(let [m (transit/decode-str result)]
|
||||
(t/is (= "failed" (:status m)))
|
||||
(when check-phase?
|
||||
(t/is (= "invalid-args" (:phase m))))
|
||||
@ -45,41 +58,44 @@
|
||||
|
||||
(t/deftest invalid-seed-fails-before-timers
|
||||
(t/async done
|
||||
(expect-failed-args #js {"seed" -1} true done)))
|
||||
(expect-failed-args (scene-args -1 {}) true done)))
|
||||
|
||||
(t/deftest missing-seed-fails-before-timers
|
||||
(t/async done
|
||||
(expect-failed-args #js {} true done)))
|
||||
(expect-failed-args (transit/encode-str {:case (load-case)}) true done)))
|
||||
|
||||
(t/deftest missing-case-fails-before-timers
|
||||
(t/async done
|
||||
(expect-failed-args (transit/encode-str {:seed 42}) true done)))
|
||||
|
||||
(t/deftest malformed-transit-fails-before-timers
|
||||
(t/async done
|
||||
(expect-failed-args "not transit" true done)))
|
||||
|
||||
(t/deftest invalid-view-fails-before-timers
|
||||
(t/async done
|
||||
(expect-failed-args
|
||||
#js {"seed" 42
|
||||
"view" #js {"scale" -1 "x" 0 "y" 0}}
|
||||
(scene-args 42 {:view {:scale -1 :x 0 :y 0}})
|
||||
true
|
||||
done)))
|
||||
|
||||
(t/deftest invalid-viewport-fails-before-timers
|
||||
(t/async done
|
||||
(expect-failed-args
|
||||
#js {"seed" 42
|
||||
"view" #js {"scale" 1 "x" 0 "y" 0
|
||||
"viewport" #js {"width" -5}}}
|
||||
(scene-args 42 {:view {:scale 1 :x 0 :y 0
|
||||
:viewport {:width -5}}})
|
||||
true
|
||||
done)))
|
||||
|
||||
(t/deftest non-finite-view-values-fail-before-timers
|
||||
(t/async done
|
||||
(expect-failed-args #js {"seed" 42
|
||||
"view" #js {"scale" js/Infinity "x" 0 "y" 0}}
|
||||
(expect-failed-args (scene-args 42 {:view {:scale js/Infinity :x 0 :y 0}})
|
||||
true
|
||||
(fn []
|
||||
(expect-failed-args #js {"seed" 42
|
||||
"view" #js {"scale" 1 "x" js/NaN "y" 0}}
|
||||
(expect-failed-args (scene-args 42 {:view {:scale 1 :x js/NaN :y 0}})
|
||||
true
|
||||
(fn []
|
||||
(expect-failed-args #js {"seed" 42
|
||||
"view" #js {"scale" 1 "x" 0 "y" js/Infinity}}
|
||||
(expect-failed-args (scene-args 42 {:view {:scale 1 :x 0 :y js/Infinity}})
|
||||
true
|
||||
done)))))))
|
||||
|
||||
@ -142,37 +158,33 @@
|
||||
:phase "upload"})
|
||||
"upload")
|
||||
after @@#'browser/owner-epoch*]
|
||||
(t/is (= "stale" (unchecked-get result "status")))
|
||||
(t/is (= "stale" (:status result)))
|
||||
(t/is (= before after) "no epoch bump: the newer owner is untouched")))
|
||||
(t/testing "terminal failure keeps its phase and disposes the current owner"
|
||||
(let [current @@#'browser/owner-epoch*
|
||||
result (terminate current (ex-info "upload failed" {:phase "upload"}) "aborted")]
|
||||
(t/is (= "failed" (unchecked-get result "status")))
|
||||
(t/is (= "upload" (unchecked-get result "phase")))
|
||||
(t/is (= "failed" (:status result)))
|
||||
(t/is (= "upload" (:phase result)))
|
||||
(t/is (< current @@#'browser/owner-epoch*) "current owner disposed")))
|
||||
(t/testing "phaseless failures use the fallback phase"
|
||||
(set! wasm/internal-module nil)
|
||||
(let [current @@#'browser/owner-epoch*
|
||||
result (terminate current (ex-info "boom" {}) "aborted")]
|
||||
(t/is (= "failed" (unchecked-get result "status")))
|
||||
(t/is (= "aborted" (unchecked-get result "phase")))
|
||||
(t/is (= "failed" (:status result)))
|
||||
(t/is (= "aborted" (:phase result)))
|
||||
(t/is (< current @@#'browser/owner-epoch*) "current owner disposed")))))
|
||||
|
||||
(t/deftest bridge-exposes-pilot-entries
|
||||
(let [keys (js/Object.keys browser/bridge)]
|
||||
(t/is (some #{"ping"} keys))
|
||||
(t/is (some #{"loadRects"} keys))
|
||||
(t/is (some #{"loadScene"} keys))
|
||||
(t/is (some #{"dispose"} keys))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; Through-entry fake harness.
|
||||
;;
|
||||
;; TODO(mem:render-wasm/performance/cljs-rewrite/06-render-and-interaction-protocol):
|
||||
;; shared entry-test harness. Ticket 06 replaces `load-rects` with the generic
|
||||
;; case-descriptor entry: keep this section (fake DOM, fake Emscripten factory,
|
||||
;; call recording) and repoint the bridge calls below; only call shapes and
|
||||
;; result assertions change. The DOM/module boundary the fakes cover does not
|
||||
;; change with the entry.
|
||||
;; The DOM/module boundary the fakes cover does not change with the entry:
|
||||
;; fake DOM, fake Emscripten factory, call recording.
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn- record!
|
||||
@ -229,7 +241,7 @@
|
||||
(fn [cb]
|
||||
;; Park the first frame while `__benchGateRaf`
|
||||
;; is set so a second load can supersede a live,
|
||||
;; polling owner; later frames run synchronously.
|
||||
;; active owner; later frames run synchronously.
|
||||
(if (and (unchecked-get js/globalThis "__benchGateRaf")
|
||||
(nil? (unchecked-get js/globalThis "__benchRafCb")))
|
||||
(unchecked-set js/globalThis "__benchRafCb" cb)
|
||||
@ -315,12 +327,13 @@
|
||||
(count (filter #(= effect (first %)) calls)))
|
||||
|
||||
(defn- load-result
|
||||
"Calls `browser/load-rects` with `args` and delivers the plain-data result
|
||||
to `k`. A rejected bridge (a broken always-resolve contract) arrives as a
|
||||
`threw` map so the test fails with the cause attached."
|
||||
"Decodes the bridge's Transit result for `k`. A rejected bridge arrives
|
||||
as a `threw` map so the test fails with the cause attached."
|
||||
[args k]
|
||||
(-> (browser/load-rects args)
|
||||
(.then (fn [result] (k (js->clj result :keywordize-keys true))))
|
||||
(-> (browser/load-scene args)
|
||||
(.then (fn [result]
|
||||
(t/is (string? result) "loadScene returns Transit")
|
||||
(k (transit/decode-str result))))
|
||||
(.catch (fn [cause] (k {:status "threw" :cause (str cause)})))))
|
||||
|
||||
(defn- with-entry-env
|
||||
@ -339,17 +352,15 @@
|
||||
(t/async done
|
||||
(with-entry-env
|
||||
(fn [cleanup]
|
||||
(load-result #js {"seed" 7
|
||||
"module-url" (factory-url)
|
||||
"wasm-url" "./fake.wasm"}
|
||||
(load-result (scene-args 7 {})
|
||||
(fn [m]
|
||||
(t/is (= "ok" (:status m)))
|
||||
(t/is (= 1 (:renderFrames m)))
|
||||
(t/is (= 1 (:render-frames m)))
|
||||
(t/is (= 1001 (:shapes (:scene m))) "the canonical scene uploads whole")
|
||||
(let [graphics (:effectiveGraphics m)]
|
||||
(t/is (= 1234 (:drawingBufferWidth graphics))
|
||||
(let [graphics (:effective-graphics m)]
|
||||
(t/is (= 1234 (:drawing-buffer-width graphics))
|
||||
"drawing buffer width comes from the context")
|
||||
(t/is (= 5678 (:drawingBufferHeight graphics))
|
||||
(t/is (= 5678 (:drawing-buffer-height graphics))
|
||||
"drawing buffer height comes from the context")
|
||||
(t/is (false? (:antialias graphics))
|
||||
"context attributes pass through")
|
||||
@ -361,14 +372,62 @@
|
||||
(cleanup)
|
||||
(done)))))))
|
||||
|
||||
(t/deftest invalid-args-keep-the-current-owner
|
||||
(t/async done
|
||||
(with-entry-env
|
||||
(fn [cleanup]
|
||||
(load-result (scene-args 7 {})
|
||||
(fn [loaded]
|
||||
(t/is (= "ok" (:status loaded)))
|
||||
(let [owner @@#'browser/canvas*
|
||||
epoch @@#'browser/owner-epoch*]
|
||||
(load-result (transit/encode-str {:seed -1})
|
||||
(fn [invalid]
|
||||
(t/is (= "invalid-args" (:phase invalid)))
|
||||
(t/is (identical? owner @@#'browser/canvas*))
|
||||
(t/is (= epoch @@#'browser/owner-epoch*))
|
||||
(t/is (zero? (effect-count (read-calls) "remove")))
|
||||
(cleanup)
|
||||
(done))))))))))
|
||||
|
||||
(t/deftest warm-pan-restores-and-reaches-full
|
||||
(t/async done
|
||||
(with-entry-env
|
||||
(fn [cleanup]
|
||||
(load-result (scene-args 7 {:id :rects/pan :context :reuse})
|
||||
(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 (= 2 (effect-count (read-calls) "render"))
|
||||
"restore and finalization each drain 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 rejected-import-resolves-module-init-failure
|
||||
(t/async done
|
||||
;; Needs the entry env for the import polyfill, even though the import
|
||||
;; itself fails before touching the DOM.
|
||||
(with-entry-env
|
||||
(fn [cleanup]
|
||||
(load-result #js {"seed" 7
|
||||
"module-url" "data:text/javascript,this is not valid javascript((("}
|
||||
(load-result (transit/encode-str
|
||||
{:seed 7
|
||||
:case (load-case)
|
||||
:module-url "data:text/javascript,this is not valid javascript((("})
|
||||
(fn [m]
|
||||
(t/is (= "failed" (:status m)))
|
||||
(t/is (= "module-init" (:phase m)))
|
||||
@ -376,7 +435,7 @@
|
||||
(done)))))))
|
||||
|
||||
(defn- assert-wasm-cause
|
||||
"Shared cause assertions for WASM failures through `load-rects`: plain-data
|
||||
"Shared cause assertions for WASM failures through `load-scene`: plain-data
|
||||
vector, wire contract, failing fn name, fake message and WASM code."
|
||||
[cause fn-name message-pattern]
|
||||
(t/is (vector? cause) "cause is plain-data vector")
|
||||
@ -397,9 +456,7 @@
|
||||
(with-entry-env
|
||||
(fn [cleanup]
|
||||
(unchecked-set js/globalThis "__benchThrowIn" "_set_browser")
|
||||
(load-result #js {"seed" 7
|
||||
"module-url" (factory-url)
|
||||
"wasm-url" "./fake.wasm"}
|
||||
(load-result (scene-args 7 {})
|
||||
(fn [m]
|
||||
(t/is (= "failed" (:status m)))
|
||||
(t/is (= "graphics-init" (:phase m)))
|
||||
@ -423,9 +480,7 @@
|
||||
(with-entry-env
|
||||
(fn [cleanup]
|
||||
(unchecked-set js/globalThis "__benchThrowIn" "_set_view")
|
||||
(load-result #js {"seed" 7
|
||||
"module-url" (factory-url)
|
||||
"wasm-url" "./fake.wasm"}
|
||||
(load-result (scene-args 7 {})
|
||||
(fn [m]
|
||||
(t/is (= "failed" (:status m)))
|
||||
(t/is (= "upload" (:phase m)))
|
||||
@ -447,7 +502,7 @@
|
||||
:hint (apply str (repeat 1000 "x"))}
|
||||
(js/Error. "inner boom"))
|
||||
result (terminate current hostile "aborted")
|
||||
m (js->clj result :keywordize-keys true)
|
||||
m result
|
||||
cause (:cause m)]
|
||||
(t/is (= "failed" (:status m)))
|
||||
(t/is (= "aborted" (:phase m)))
|
||||
@ -478,7 +533,7 @@
|
||||
current @@#'browser/owner-epoch*
|
||||
hostile (ex-info "evil boom" {:fn evil} (js/Error. "inner"))
|
||||
result (terminate current hostile "aborted")
|
||||
m (js->clj result :keywordize-keys true)
|
||||
m result
|
||||
cause (:cause m)
|
||||
outer (first cause)]
|
||||
(t/is (= "failed" (:status m)))
|
||||
@ -494,7 +549,7 @@
|
||||
terminate @#'browser/terminal-failure
|
||||
current @@#'browser/owner-epoch*
|
||||
result (terminate current evil "aborted")
|
||||
m (js->clj result :keywordize-keys true)
|
||||
m result
|
||||
cause (:cause m)]
|
||||
(t/is (= "failed" (:status m)))
|
||||
(t/is (core/transit-round-trips? cause) "throwing cause still crosses the wire")
|
||||
@ -510,7 +565,7 @@
|
||||
{:benches.render-wasm.browser/stale true
|
||||
:phase "upload"}))
|
||||
result (terminate current nested "aborted")
|
||||
m (js->clj result :keywordize-keys true)]
|
||||
m result]
|
||||
(t/is (= "failed" (:status m)) "only the top level decides staleness")
|
||||
(t/is (= "upload" (:phase m)))
|
||||
(t/is (vector? (:cause m)) "cause still rendered")))
|
||||
@ -520,28 +575,26 @@
|
||||
(with-entry-env
|
||||
(fn [cleanup]
|
||||
(unchecked-set js/globalThis "__benchGateRaf" true)
|
||||
(let [args (fn [seed] #js {"seed" seed
|
||||
"module-url" (factory-url)
|
||||
"wasm-url" "./fake.wasm"})
|
||||
first-load (browser/load-rects (args 7))]
|
||||
(let [args (fn [seed] (scene-args seed {}))
|
||||
first-load (browser/load-scene (args 7))]
|
||||
;; Microtasks drain before this macrotask: the first load parked on
|
||||
;; its first poll frame with a live canvas. Macrotask ordering
|
||||
;; its first frame with a live canvas. Macrotask ordering
|
||||
;; (never wall timing) sequences the assertions.
|
||||
(js/setTimeout
|
||||
(fn []
|
||||
(t/is (some? (unchecked-get js/globalThis "__benchRafCb"))
|
||||
"first load is polling")
|
||||
(let [second-load (browser/load-rects (args 8))]
|
||||
"first load is waiting for a frame")
|
||||
(let [second-load (browser/load-scene (args 8))]
|
||||
(js/setTimeout
|
||||
(fn []
|
||||
;; The second load completed; release the parked frame so
|
||||
;; the first load observes its superseded epoch.
|
||||
(if-some [parked (unchecked-get js/globalThis "__benchRafCb")]
|
||||
(parked 0)
|
||||
(t/is false "first load never parked on a poll frame"))
|
||||
(t/is false "first load never parked on a frame"))
|
||||
(-> (js/Promise.all #js [first-load second-load])
|
||||
(.then (fn [results]
|
||||
(let [[stale ok] (js->clj results :keywordize-keys true)]
|
||||
(let [[stale ok] (mapv transit/decode-str (array-seq results))]
|
||||
(t/is (= "stale" (:status stale)) "superseded load resolves stale")
|
||||
(t/is (= "ok" (:status ok)) "newer owner completes")
|
||||
(t/is (= 8 (:seed (:scene ok))) "the completer is the second load")
|
||||
|
||||
222
frontend/test/frontend_tests/benches/protocol_test.cljs
Normal file
222
frontend/test/frontend_tests/benches/protocol_test.cljs
Normal file
@ -0,0 +1,222 @@
|
||||
;; 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 frontend-tests.benches.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.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.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 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 interact-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/interact hooks {:frames frames :settle-ms 100})
|
||||
(.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)))))))
|
||||
Loading…
x
Reference in New Issue
Block a user