mirror of
https://github.com/penpot/penpot.git
synced 2026-08-13 16:28:59 +00:00
448 lines
20 KiB
Clojure
448 lines
20 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 INC Sucursal en España SL
|
|
|
|
(ns frontend-tests.data.workspace-reflow-test
|
|
"Tests the reflow tasks the layout and text pipelines feed to
|
|
`app.main.data.workspace.reflow`, which is what plugin waits observe. The
|
|
promise view of the settle signal lives in `app.plugins.reflow`; these tests
|
|
use it because it is the wait the plugin API ships."
|
|
(:require
|
|
[app.common.uuid :as uuid]
|
|
[app.main.data.workspace.reflow :as wrf]
|
|
[app.main.data.workspace.shape-layout :as dwsl]
|
|
[app.main.data.workspace.shapes :as dwsh]
|
|
[app.main.data.workspace.texts :as dwtxt]
|
|
[app.main.data.workspace.wasm-text :as dwwt]
|
|
[app.main.fonts :as fonts]
|
|
[app.plugins.reflow :as pwrf]
|
|
[app.render-wasm.api.fonts :as wasm.fonts]
|
|
[app.util.globals :as globals]
|
|
[app.util.http :as http]
|
|
[beicon.v2.core :as rx]
|
|
[cljs.test :as t :include-macros true]
|
|
[potok.v2.core :as ptk]))
|
|
|
|
(t/use-fixtures :each {:before wrf/reset-pending!
|
|
:after wrf/reset-pending!})
|
|
|
|
;; Starts the `:layout/update` pipeline on a throwaway store; its empty state
|
|
;; resolves every buffered update to no shapes and applies no modifiers.
|
|
(defn- start-pipeline!
|
|
[]
|
|
(doto (ptk/store {:state {} :on-error #(js/console.error %)})
|
|
(ptk/emit! (dwsl/initialize-shape-layout))))
|
|
|
|
(defn- stop-pipeline!
|
|
[store]
|
|
(ptk/emit! store (dwsl/finalize-shape-layout)))
|
|
|
|
(defn- start-text-pipeline!
|
|
[]
|
|
(doto (ptk/store {:state {} :on-error #(js/console.error %)})
|
|
(ptk/emit! (dwtxt/initialize-text-reflow))))
|
|
|
|
(defn- stop-text-pipeline!
|
|
[store]
|
|
(ptk/emit! store (dwtxt/finalize-text-reflow)))
|
|
|
|
(t/deftest root-only-layout-update-is-not-pending-work
|
|
;; The root lays out nothing, so an update with only its id holds no wait.
|
|
(t/async done
|
|
(let [store (start-pipeline!)]
|
|
(ptk/emit! store (ptk/data-event :layout/update {:ids [uuid/zero]}))
|
|
(-> (pwrf/wait-for-layout-update nil 20)
|
|
(.then #(t/is true "resolved with no pending work"))
|
|
(.catch #(t/is false "a root-only update was marked as pending work"))
|
|
(.then (fn []
|
|
(stop-pipeline! store)
|
|
(done)))))))
|
|
|
|
(t/deftest stale-task-cannot-finish-current-work
|
|
;; Delayed callbacks from a finalized workspace carry the old generation and
|
|
;; must not drain a new workspace task for the same shape id.
|
|
(t/async done
|
|
(let [id (uuid/next)
|
|
stale-task (wrf/start! :text-measure [id])
|
|
_ (wrf/reset-pending!)
|
|
current-task (wrf/start! :text-measure [id])]
|
|
(wrf/finish! stale-task)
|
|
(-> (pwrf/wait-for-layout-update [id] 20)
|
|
(.then #(t/is false "a stale completion drained current work"))
|
|
(.catch #(t/is true "current work stayed pending"))
|
|
(.then (fn []
|
|
(wrf/finish! current-task)
|
|
(pwrf/wait-for-layout-update [id] 100)))
|
|
(.then #(t/is true "the exact current task drained normally"))
|
|
(.catch #(t/is false "the current task did not drain"))
|
|
(.then (fn [] (done)))))))
|
|
|
|
(t/deftest reinstalling-the-pending-scan-resets-work-and-keeps-tracking
|
|
;; Reinstall the pending scan with the latest reducer.
|
|
(t/async done
|
|
(let [id (uuid/next)
|
|
stale (wrf/start! :text-measure [id])
|
|
current* (atom nil)]
|
|
(#'wrf/install-pending-subscription!)
|
|
(-> (pwrf/wait-for-layout-update [id] 100)
|
|
(.then #(t/is true "reinstalling the scan reset its previous generation"))
|
|
(.catch #(t/is false "the replaced scan kept stale work pending"))
|
|
(.then
|
|
(fn []
|
|
(reset! current* (wrf/start! :text-measure [id]))
|
|
(pwrf/wait-for-layout-update [id] 20)))
|
|
(.then #(t/is false "the replacement scan did not track new work"))
|
|
(.catch #(t/is true "the replacement scan tracked new work"))
|
|
(.then
|
|
(fn []
|
|
(wrf/finish! stale)
|
|
(wrf/finish! @current*)
|
|
(pwrf/wait-for-layout-update [id] 100)))
|
|
(.then #(t/is true "the replacement scan drained its exact task"))
|
|
(.catch #(t/is false "the replacement scan did not drain"))
|
|
(.then (fn [] (done)))))))
|
|
|
|
(t/deftest pending-promise-finishes-at-the-operation-boundary
|
|
;; Imperative render work is pending from before its thunk starts until the
|
|
;; exact promise returned by that thunk settles; no timer is involved.
|
|
(t/async done
|
|
(let [id (uuid/next)
|
|
resolve* (atom nil)
|
|
started? (atom false)]
|
|
(wrf/run-pending!
|
|
:text-measure
|
|
[id]
|
|
(fn []
|
|
(reset! started? true)
|
|
(js/Promise. (fn [resolve _] (reset! resolve* resolve)))))
|
|
(-> (pwrf/wait-for-layout-update [id] 20)
|
|
(.then #(t/is false "resolved while the render operation was pending"))
|
|
(.catch #(t/is @started? "the task was opened before running the operation"))
|
|
(.then (fn []
|
|
(@resolve*)
|
|
(pwrf/wait-for-layout-update [id] 100)))
|
|
(.then #(t/is true "resolved as soon as the render operation settled"))
|
|
(.catch #(t/is false "the settled render operation stayed pending"))
|
|
(.then (fn [] (done)))))))
|
|
|
|
(t/deftest pending-promise-finishes-after-a-synchronous-error
|
|
(let [id (uuid/next)]
|
|
(try
|
|
(wrf/run-pending! :text-measure [id] #(throw (js/Error. "boom")))
|
|
(catch :default _))
|
|
(t/async done
|
|
(-> (pwrf/wait-for-layout-update [id] 100)
|
|
(.then #(t/is true "a synchronous failure drained its exact task"))
|
|
(.catch #(t/is false "a synchronous failure leaked pending work"))
|
|
(.then (fn [] (done)))))))
|
|
|
|
(t/deftest deleted-shapes-cancel-only-their-work
|
|
(t/async done
|
|
(let [id-a (uuid/next)
|
|
id-b (uuid/next)
|
|
task-a (wrf/start! :text-bridge [id-a])
|
|
task-b (wrf/start! :text-bridge [id-b])]
|
|
(wrf/cancel-shapes! [id-a])
|
|
(-> (pwrf/wait-for-layout-update [id-a] 100)
|
|
(.then #(t/is true "deleted shape work was cancelled"))
|
|
(.catch #(t/is false "deleted shape work stayed pending"))
|
|
(.then #(pwrf/wait-for-layout-update [id-b] 20))
|
|
(.then #(t/is false "cancelling one shape drained its sibling"))
|
|
(.catch #(t/is true "sibling work stayed pending"))
|
|
(.then (fn []
|
|
(wrf/finish! task-a)
|
|
(wrf/finish! task-b)
|
|
(done)))))))
|
|
|
|
(t/deftest text-bridge-waits-for-each-shape
|
|
;; Starting measurement for one id must not release sibling bridge tasks.
|
|
(t/async done
|
|
(let [store (start-text-pipeline!)
|
|
id-a (uuid/next)
|
|
id-b (uuid/next)]
|
|
(ptk/emit! store (ptk/data-event :text/reflow {:ids [id-a id-b]}))
|
|
(let [task-a (wrf/start! :text-measure [id-a])]
|
|
(wrf/finish! task-a))
|
|
(-> (pwrf/wait-for-layout-update [id-b] 20)
|
|
(.then #(t/is false "the first text released its sibling bridge"))
|
|
(.catch #(t/is true "the sibling bridge stayed pending"))
|
|
(.then (fn []
|
|
(let [task-b (wrf/start! :text-measure [id-b])]
|
|
(wrf/finish! task-b))
|
|
(pwrf/wait-for-layout-update [id-b] 100)))
|
|
(.then #(t/is true "the sibling drained after its own measurement"))
|
|
(.catch #(t/is false "the sibling never drained"))
|
|
(.then (fn []
|
|
(stop-text-pipeline! store)
|
|
(done)))))))
|
|
|
|
(t/deftest text-bridge-observes-out-of-order-work
|
|
;; Start all bridges before matching work can finish.
|
|
(t/async done
|
|
(let [store (start-text-pipeline!)
|
|
id-a (uuid/next)
|
|
id-b (uuid/next)]
|
|
(ptk/emit! store (ptk/data-event :text/reflow {:ids [id-a id-b]}))
|
|
(let [task-b (wrf/start! :text-measure [id-b])]
|
|
(wrf/finish! task-b))
|
|
(let [task-a (wrf/start! :text-measure [id-a])]
|
|
(wrf/finish! task-a))
|
|
(-> (pwrf/wait-for-layout-update [id-a id-b] 100)
|
|
(.then #(t/is true "both out-of-order bridges observed their work"))
|
|
(.catch #(t/is false "a bridge missed work that started out of order"))
|
|
(.then (fn []
|
|
(stop-text-pipeline! store)
|
|
(done)))))))
|
|
|
|
(t/deftest text-bridge-does-not-consume-preexisting-work
|
|
;; Ignore matching work that started before the bridge.
|
|
(t/async done
|
|
(let [store (start-text-pipeline!)
|
|
id (uuid/next)
|
|
prior-task (wrf/start! :text-measure [id])]
|
|
(ptk/emit! store (ptk/data-event :text/reflow {:ids [id]}))
|
|
(wrf/finish! prior-task)
|
|
(-> (pwrf/wait-for-layout-update [id] 20)
|
|
(.then #(t/is false "preexisting work released the new bridge"))
|
|
(.catch #(t/is true "the new bridge remained pending"))
|
|
(.then (fn []
|
|
(let [current-task (wrf/start! :text-measure [id])]
|
|
(wrf/finish! current-task))
|
|
(pwrf/wait-for-layout-update [id] 100)))
|
|
(.then #(t/is true "work started after the bridge drained it"))
|
|
(.catch #(t/is false "the causal measurement did not drain the bridge"))
|
|
(.then (fn []
|
|
(stop-text-pipeline! store)
|
|
(done)))))))
|
|
|
|
(t/deftest cancelling-a-bridge-does-not-block-later-reflow-events
|
|
(t/async done
|
|
(let [store (start-text-pipeline!)
|
|
id-a (uuid/next)
|
|
id-b (uuid/next)]
|
|
(ptk/emit! store (ptk/data-event :text/reflow {:ids [id-a]}))
|
|
(wrf/cancel-shapes! [id-a])
|
|
(ptk/emit! store (ptk/data-event :text/reflow {:ids [id-b]}))
|
|
(-> (pwrf/wait-for-layout-update [id-b] 20)
|
|
(.then #(t/is false "the later bridge was not opened"))
|
|
(.catch #(t/is true "the later bridge stayed pending"))
|
|
(.then (fn []
|
|
(let [task-b (wrf/start! :text-measure [id-b])]
|
|
(wrf/finish! task-b))
|
|
(pwrf/wait-for-layout-update [id-b] 100)))
|
|
(.then #(t/is true "the later bridge drained after its own work"))
|
|
(.catch #(t/is false "the cancelled bridge blocked the pipeline"))
|
|
(.then (fn []
|
|
(stop-text-pipeline! store)
|
|
(done)))))))
|
|
|
|
(t/deftest finalizing-a-page-cancels-its-text-bridges
|
|
(t/async done
|
|
(let [store (start-text-pipeline!)
|
|
id (uuid/next)]
|
|
(ptk/emit! store (ptk/data-event :text/reflow {:ids [id]}))
|
|
(ptk/emit! store (ptk/data-event :app.main.data.workspace.pages/finalize-page))
|
|
(-> (pwrf/wait-for-layout-update [id] 100)
|
|
(.then #(t/is true "page teardown drained the unmeasured text bridge"))
|
|
(.catch #(t/is false "page teardown left text work pending"))
|
|
(.then (fn []
|
|
(stop-text-pipeline! store)
|
|
(done)))))))
|
|
|
|
(t/deftest failed-wasm-font-storage-falls-back-and-drains
|
|
(t/async done
|
|
(let [id (uuid/next)
|
|
font-key {:font-id "gfont-does-not-load"
|
|
:weight 400
|
|
:style 0
|
|
:emoji? false}
|
|
stream (rx/subject)
|
|
events (atom [])]
|
|
(->> (#'dwtxt/await-font-faces stream #{font-key} [id])
|
|
(rx/subs! #(swap! events conj %)))
|
|
(#'wasm.fonts/report-font-storage-failed! font-key)
|
|
(-> (pwrf/wait-for-layout-update [id] 100)
|
|
(.then (fn []
|
|
(t/is (= 1 (count @events))
|
|
"font failure dispatches one fallback resize")
|
|
(t/is (wasm.fonts/font-ready? font-key)
|
|
"the resize gate accepts the failed face's fallback")
|
|
(done)))
|
|
(.catch (fn [_]
|
|
(t/is false "font failure leaked pending work")
|
|
(done)))))))
|
|
|
|
(t/deftest failed-dom-font-load-falls-back-and-drains
|
|
(t/async done
|
|
(let [id (uuid/next)
|
|
font-id "gfont-layout-failure-test"]
|
|
(swap! fonts/fontsdb assoc font-id
|
|
{:id font-id
|
|
:backend :google
|
|
:family "Layout Failure Test"
|
|
:variants [{:id "regular"}]})
|
|
(swap! fonts/loaded disj font-id)
|
|
(swap! fonts/loading dissoc font-id)
|
|
(with-redefs [globals/browser? (constantly true)
|
|
http/send! (fn [_] (rx/throw (js/Error. "font fetch failed")))]
|
|
(wrf/run-pending! :font [id] #(fonts/ensure-loaded! font-id)))
|
|
(-> (pwrf/wait-for-layout-update [id] 500)
|
|
(.then (fn []
|
|
(t/is (not (contains? @fonts/loading font-id))
|
|
"a failed load must not remain cached as loading")))
|
|
(.catch #(t/is false "failed DOM font load leaked pending work"))
|
|
(.then (fn []
|
|
(swap! fonts/fontsdb dissoc font-id)
|
|
(swap! fonts/loaded disj font-id)
|
|
(swap! fonts/loading dissoc font-id)
|
|
(done)))))))
|
|
|
|
(t/deftest failed-google-font-css-does-not-abort-shared-consumers
|
|
(t/async done
|
|
(let [font-id "gfont-optional-css-test"
|
|
values (atom [])
|
|
cleanup #(swap! fonts/fontsdb dissoc font-id)]
|
|
(swap! fonts/fontsdb assoc font-id
|
|
{:id font-id
|
|
:backend :google
|
|
:family "Optional CSS Test"
|
|
:variants [{:id "regular"}]})
|
|
(with-redefs [http/send! (fn [_] (rx/throw (js/Error. "font css fetch failed")))]
|
|
(->> (fonts/fetch-font-css {:font-id font-id})
|
|
(rx/subs!
|
|
#(swap! values conj %)
|
|
(fn [_]
|
|
(cleanup)
|
|
(t/is false "an optional font CSS failure escaped the shared helper")
|
|
(done))
|
|
(fn []
|
|
(cleanup)
|
|
(t/is (empty? @values)
|
|
"a failed optional font contributes no CSS")
|
|
(done))))))))
|
|
|
|
(t/deftest deduplicated-wasm-font-failure-settles-every-face
|
|
(t/async done
|
|
(let [font-url "https://example.test/shared-font.ttf"
|
|
regular {:font-id "gfont-shared-regular"
|
|
:weight 400
|
|
:style 0
|
|
:emoji? false}
|
|
bold {:font-id "gfont-shared-bold"
|
|
:weight 700
|
|
:style 0
|
|
:emoji? false}]
|
|
(with-redefs [http/send! (fn [_] (rx/throw (js/Error. "shared fetch failed")))]
|
|
(let [request (#'wasm.fonts/fetch-font regular font-url false false)
|
|
duplicate (#'wasm.fonts/fetch-font bold font-url false false)]
|
|
(t/is (some? request) "the first face owns the shared fetch")
|
|
(t/is (nil? duplicate) "the second face reuses the shared fetch")
|
|
(->> ((:callback request))
|
|
(rx/subs!
|
|
(fn [_])
|
|
(fn [_]
|
|
(t/is false "the shared fetch failure escaped its fallback")
|
|
(done))
|
|
(fn []
|
|
(t/is (wasm.fonts/font-ready? regular)
|
|
"the first face settled through fallback")
|
|
(t/is (wasm.fonts/font-ready? bold)
|
|
"the deduplicated face settled through fallback")
|
|
(done)))))))))
|
|
|
|
(t/deftest missing-wasm-font-url-settles-without-entering-fetch-map
|
|
(t/async done
|
|
(let [font-data {:font-id "gfont-missing-url"
|
|
:weight 400
|
|
:style 0
|
|
:emoji? false}]
|
|
(t/is (nil? (#'wasm.fonts/fetch-font font-data nil false false))
|
|
"a missing URL starts no request")
|
|
(t/is (not (contains? @wasm.fonts/fetching nil))
|
|
"missing URLs are not deduplicated under nil")
|
|
(js/setTimeout
|
|
(fn []
|
|
(t/is (wasm.fonts/font-ready? font-data)
|
|
"the missing face settled through fallback")
|
|
(done))
|
|
0))))
|
|
|
|
(t/deftest wasm-font-resize-waits-for-every-face
|
|
(t/async done
|
|
(let [id (uuid/next)
|
|
regular-key {:font-id "gfont-mixed"
|
|
:weight 400
|
|
:style 0
|
|
:emoji? false}
|
|
bold-key {:font-id "gfont-mixed"
|
|
:weight 700
|
|
:style 0
|
|
:emoji? false}
|
|
stream (rx/subject)
|
|
events (atom [])]
|
|
(->> (#'dwtxt/await-font-faces stream #{regular-key bold-key} [id])
|
|
(rx/subs! #(swap! events conj %)))
|
|
(rx/push! wasm.fonts/font-stored-stream regular-key)
|
|
(-> (pwrf/wait-for-layout-update [id] 20)
|
|
(.then #(t/is false "the first face released the font task"))
|
|
(.catch #(t/is true "the second face remained pending"))
|
|
(.then (fn []
|
|
(rx/push! wasm.fonts/font-storage-failed-stream bold-key)
|
|
(pwrf/wait-for-layout-update [id] 100)))
|
|
(.then (fn []
|
|
(t/is (= 1 (count @events))
|
|
"all faces settling dispatches exactly one resize")
|
|
(done)))
|
|
(.catch (fn [_]
|
|
(t/is false "the complete face set did not drain")
|
|
(done)))))))
|
|
|
|
(t/deftest buffered-wasm-resize-releases-on-stop-without-a-commit
|
|
(t/async done
|
|
(let [store (ptk/store {:state {} :on-error #(js/console.error %)})
|
|
id (uuid/next)]
|
|
(ptk/emit! store (dwsh/update-shapes-buffer-start))
|
|
(ptk/emit! store (dwwt/resize-wasm-text-all [id]))
|
|
(-> (pwrf/wait-for-layout-update [id] 20)
|
|
(.then #(t/is false "the buffered resize was not marked pending"))
|
|
(.catch #(t/is true "the resize stayed pending while the buffer was open"))
|
|
(.then (fn []
|
|
(ptk/emit! store (dwsh/update-shapes-buffer-stop))
|
|
(pwrf/wait-for-layout-update [id] 500)))
|
|
(.then #(t/is true "buffer stop released the fallback resize"))
|
|
(.catch #(t/is false "buffer stop without a commit leaked pending work"))
|
|
(.then (fn [] (done)))))))
|
|
|
|
(t/deftest buffered-wasm-resize-releases-on-workspace-finalize
|
|
(t/async done
|
|
(let [store (ptk/store {:state {} :on-error #(js/console.error %)})
|
|
id (uuid/next)]
|
|
(ptk/emit! store (dwsh/update-shapes-buffer-start))
|
|
(ptk/emit! store (dwwt/resize-wasm-text-all [id]))
|
|
(-> (pwrf/wait-for-layout-update [id] 20)
|
|
(.then #(t/is false "the buffered resize was not marked pending"))
|
|
(.catch #(t/is true "the resize stayed pending while the buffer was open"))
|
|
(.then (fn []
|
|
(ptk/emit! store (ptk/data-event :app.main.data.workspace/finalize-workspace))
|
|
(pwrf/wait-for-layout-update [id] 500)))
|
|
(.then #(t/is true "workspace finalization released the buffered resize"))
|
|
(.catch #(t/is false "workspace finalization leaked pending work"))
|
|
(.then (fn [] (done)))))))
|
|
|
|
(t/deftest layout-update-is-pending-until-the-buffer-flushes
|
|
;; A shape id is marked on arrival and drained when the update is processed.
|
|
(t/async done
|
|
(let [store (start-pipeline!)]
|
|
(ptk/emit! store (ptk/data-event :layout/update {:ids [(uuid/next) uuid/zero]}))
|
|
(-> (pwrf/wait-for-layout-update nil 20)
|
|
(.then #(t/is false "resolved while the update was still buffered"))
|
|
(.catch #(t/is true "stayed pending until the flush"))
|
|
(.then #(pwrf/wait-for-layout-update nil 5000))
|
|
(.then #(t/is true "resolved once the update was processed"))
|
|
(.catch #(t/is false "the pipeline never drained its mark"))
|
|
(.then (fn []
|
|
(stop-pipeline! store)
|
|
(done)))))))
|