mirror of
https://github.com/penpot/penpot.git
synced 2026-11-08 11:04:44 +00:00
A view URL without a page-id crashed builds with assertions enabled: the viewer passes a nil page-id to `initialize`, whose schema accepted a missing key but not a nil value. Release builds skip that check and draw the first page, but every viewer event reads the page from the route, so zoom to fit and fill, the next-board arrow, Navigate To interactions, and comment pins did not work. `initialize` now accepts a nil page-id, `fetch-bundle` makes it optional, and `bundle-fetched` replaces a route without page-id by one naming the file's first page. The viewer then initializes again on the completed route, as it does when the user changes page. A file with no pages keeps its route. AI-assisted-by: claude-opus-5-5 Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
204 lines
9.0 KiB
Clojure
204 lines
9.0 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 frontend-tests.data.viewer-test
|
|
(:require
|
|
[app.common.uuid :as uuid]
|
|
[app.main.data.viewer :as dv]
|
|
[app.main.router :as rt]
|
|
[beicon.v2.core :as rx]
|
|
[cljs.test :as t]
|
|
[potok.v2.core :as ptk]))
|
|
|
|
(def ^:private page-id
|
|
(uuid/custom 1 1))
|
|
|
|
(defn- base-state
|
|
"Build a minimal viewer state with the given frames and query-params."
|
|
[{:keys [frames index]}]
|
|
{:route {:params {:query {:page-id (str page-id)
|
|
:index (str index)}}}
|
|
:viewer {:pages {page-id {:frames frames}}}
|
|
:viewer-local {:viewport-size {:width 1000 :height 800}}})
|
|
|
|
(t/deftest zoom-to-fit-clamps-out-of-bounds-index
|
|
(t/testing "index exceeds frame count"
|
|
(let [state (base-state {:frames [{:selrect {:width 100 :height 100}}]
|
|
:index 1})
|
|
result (ptk/update dv/zoom-to-fit state)]
|
|
(t/is (= (get-in result [:viewer-local :zoom-type]) :fit))
|
|
(t/is (number? (get-in result [:viewer-local :zoom])))))
|
|
|
|
(t/testing "index is zero with single frame (normal case)"
|
|
(let [state (base-state {:frames [{:selrect {:width 100 :height 100}}]
|
|
:index 0})
|
|
result (ptk/update dv/zoom-to-fit state)]
|
|
(t/is (= (get-in result [:viewer-local :zoom-type]) :fit))
|
|
(t/is (number? (get-in result [:viewer-local :zoom])))))
|
|
|
|
(t/testing "index within valid range with multiple frames"
|
|
(let [state (base-state {:frames [{:selrect {:width 100 :height 100}}
|
|
{:selrect {:width 200 :height 200}}]
|
|
:index 1})
|
|
result (ptk/update dv/zoom-to-fit state)]
|
|
(t/is (= (get-in result [:viewer-local :zoom-type]) :fit))
|
|
(t/is (number? (get-in result [:viewer-local :zoom]))))))
|
|
|
|
(t/deftest zoom-to-fill-clamps-out-of-bounds-index
|
|
(t/testing "index exceeds frame count"
|
|
(let [state (base-state {:frames [{:selrect {:width 100 :height 100}}]
|
|
:index 1})
|
|
result (ptk/update dv/zoom-to-fill state)]
|
|
(t/is (= (get-in result [:viewer-local :zoom-type]) :fill))
|
|
(t/is (number? (get-in result [:viewer-local :zoom])))))
|
|
|
|
(t/testing "index is zero with single frame (normal case)"
|
|
(let [state (base-state {:frames [{:selrect {:width 100 :height 100}}]
|
|
:index 0})
|
|
result (ptk/update dv/zoom-to-fill state)]
|
|
(t/is (= (get-in result [:viewer-local :zoom-type]) :fill))
|
|
(t/is (number? (get-in result [:viewer-local :zoom])))))
|
|
|
|
(t/testing "index within valid range with multiple frames"
|
|
(let [state (base-state {:frames [{:selrect {:width 100 :height 100}}
|
|
{:selrect {:width 200 :height 200}}]
|
|
:index 1})
|
|
result (ptk/update dv/zoom-to-fill state)]
|
|
(t/is (= (get-in result [:viewer-local :zoom-type]) :fill))
|
|
(t/is (number? (get-in result [:viewer-local :zoom]))))))
|
|
|
|
(defn- watch-events
|
|
"Collect the events an event's watch emits synchronously."
|
|
[event state]
|
|
(let [out (atom [])]
|
|
(some-> (ptk/watch event state nil)
|
|
(rx/subscribe #(swap! out conj %)))
|
|
@out))
|
|
|
|
(defn- zoom-state
|
|
"Build a viewer state with the given `:zoom` query param and zoom type."
|
|
[zoom-param zoom-type]
|
|
{:route {:params {:query (cond-> {:page-id (str page-id) :index "0"}
|
|
(some? zoom-param)
|
|
(assoc :zoom zoom-param))}}
|
|
:viewer-local (cond-> {}
|
|
(some? zoom-type)
|
|
(assoc :zoom-type zoom-type))})
|
|
|
|
(t/deftest update-zoom-querystring-does-not-navigate-when-url-already-matches
|
|
(t/testing "zoom type already described by the query string"
|
|
(t/is (empty? (watch-events dv/update-zoom-querystring
|
|
(zoom-state "fit" :fit)))))
|
|
|
|
(t/testing "no zoom type and no zoom query param"
|
|
(t/is (empty? (watch-events dv/update-zoom-querystring
|
|
(zoom-state nil nil))))))
|
|
|
|
(t/deftest update-zoom-querystring-navigates-when-zoom-changes
|
|
(t/testing "zoom type differs from the query string"
|
|
(let [events (watch-events dv/update-zoom-querystring
|
|
(zoom-state "fit" :fill))
|
|
{:keys [id params options]} (some-> (first events) deref)]
|
|
(t/is (= 1 (count events)))
|
|
(t/is (= :viewer id))
|
|
(t/is (= :fill (:zoom params)))
|
|
(t/is (true? (::rt/replace options)))))
|
|
|
|
(t/testing "zoom query param absent, other params preserved"
|
|
(let [events (watch-events dv/update-zoom-querystring
|
|
(zoom-state nil :fit))
|
|
{:keys [params]} (some-> (first events) deref)]
|
|
(t/is (= 1 (count events)))
|
|
(t/is (= :fit (:zoom params)))
|
|
(t/is (= (str page-id) (:page-id params)))
|
|
(t/is (= "0" (:index params)))))
|
|
|
|
(t/testing "zoom type cleared drops the query param"
|
|
(let [events (watch-events dv/update-zoom-querystring
|
|
(zoom-state "fit" nil))
|
|
{:keys [params]} (some-> (first events) deref)]
|
|
(t/is (= 1 (count events)))
|
|
(t/is (not (contains? params :zoom))))))
|
|
|
|
(t/deftest bundle-fetched-with-zoom-fill-url-does-not-navigate
|
|
;; Regression test for the React "maximum update depth exceeded"
|
|
;; error (2.18.0-RC5): loading the viewer with a URL that already
|
|
;; contains `zoom=fill` re-entered the cycle zoom-to-fill →
|
|
;; update-zoom-querystring → nav → navigated → zoom-to-fill…,
|
|
;; because update-zoom-querystring navigated unconditionally.
|
|
;; At HEAD the guard breaks the cycle, so a bundle fetch must not
|
|
;; emit any navigation.
|
|
(let [state (as-> (base-state {:frames [{:selrect {:width 100 :height 100}}]
|
|
:index 0}) state
|
|
;; The router stores the same query map under both keys.
|
|
(let [query (assoc (rt/get-params state) :zoom "fill")]
|
|
(-> state
|
|
(assoc-in [:route :params :query] query)
|
|
(assoc-in [:route :query-params] query))))]
|
|
(let [events (watch-events (dv/bundle-fetched
|
|
{:file {:id page-id
|
|
:data {:pages [page-id]
|
|
:pages-index {page-id {:objects {}}}}}
|
|
:project {}
|
|
:team {:features []}
|
|
:share-links []
|
|
:libraries []
|
|
:users []
|
|
:permissions {}
|
|
:thumbnails {}})
|
|
state)
|
|
;; `update-page-position-data` and `go-to-frame-auto` are part
|
|
;; of the normal init sequence; the navigation events are the
|
|
;; ones that can restart the zoom cycle.
|
|
navigations (filter #(= :app.main.router/navigate (ptk/type %)) events)]
|
|
(t/is (empty? navigations)))))
|
|
|
|
(defn- minimal-bundle
|
|
[page-ids]
|
|
{:file {:id (uuid/custom 2 2)
|
|
:data {:pages (vec page-ids)
|
|
:pages-index (into {} (map (fn [id] [id {:objects {}}])) page-ids)}}
|
|
:project {}
|
|
:team {:features []}
|
|
:share-links []
|
|
:libraries []
|
|
:users []
|
|
:permissions {}
|
|
:thumbnails {}})
|
|
|
|
(defn- route-without-page-id
|
|
[]
|
|
(let [query {:file-id (str (uuid/custom 2 2)) :section "interactions" :index "0"}]
|
|
{:route {:params {:query query} :query-params query}}))
|
|
|
|
(t/deftest initialize-without-page-id-fetches-the-bundle
|
|
;; The viewer route passes `:page-id nil` when the URL has none; the
|
|
;; params check used to reject it before any fetch.
|
|
(let [events (watch-events (dv/initialize {:file-id (uuid/custom 2 2)
|
|
:page-id nil
|
|
:share-id nil})
|
|
{})]
|
|
(t/is (= [::dv/fetch-bundle] (map ptk/type (remove nil? events))))))
|
|
|
|
(t/deftest bundle-fetched-without-page-id-names-the-first-page
|
|
(t/testing "the route is replaced by one naming the first page"
|
|
(let [first-page (uuid/custom 3 1)
|
|
events (watch-events (dv/bundle-fetched
|
|
(minimal-bundle [first-page (uuid/custom 3 2)]))
|
|
(route-without-page-id))
|
|
{:keys [id params options]} (some-> (first events) deref)]
|
|
(t/is (= 1 (count events)))
|
|
(t/is (= :viewer id))
|
|
(t/is (= first-page (:page-id params)))
|
|
(t/is (= "interactions" (:section params)))
|
|
(t/is (true? (::rt/replace options)))))
|
|
|
|
(t/testing "a file without pages keeps the route"
|
|
(let [events (watch-events (dv/bundle-fetched (minimal-bundle []))
|
|
(route-without-page-id))
|
|
navigations (filter #(= :app.main.router/navigate (ptk/type %)) events)]
|
|
(t/is (empty? navigations)))))
|