Compare commits

..
3 Commits
78 changed files with 508 additions and 962 deletions

No files matched your search

+1 -7
View File
@@ -47,14 +47,8 @@
:send-user-feedback/by-profile
{:permits 1 :queue 3}
:import-binfile/global
:import-binfile/global
{:permits 4}
:import-binfile/by-profile
{:permits 1 :queue 2}
:profile-plugin-ops/global
{:permits 4}
:profile-plugin-ops/by-profile
{:permits 1 :queue 2}}
+2 -4
View File
@@ -216,10 +216,8 @@
;; ═══════════════════════════════════════════════
;; Profile operations
;; ═══════════════════════════════════════════════
#{:main/update-profile
:main/update-profile-props
:main/add-profile-plugin
:main/remove-profile-plugin
#{:main/update-profile
:main/update-profile-props
:main/update-profile-photo
:main/update-profile-password
:main/update-profile-notifications
+1 -7
View File
@@ -99,10 +99,7 @@
;; Binfile import limits
:binfile-import-max-object-size (* 1024 1024 100) ;; 100 MiB
:binfile-import-max-zip-entries (* 500 1000) ;; 500,000
;; Profile props total size limit (serialized, after merge)
:profile-props-max-size (* 1024 1024 2)}) ;; 2 MiB
:binfile-import-max-zip-entries (* 500 1000)}) ;; 500,000
(def schema:config
(do #_sm/optional-keys
@@ -163,9 +160,6 @@
[:binfile-import-max-object-size {:optional true} ::sm/int]
[:binfile-import-max-zip-entries {:optional true} ::sm/int]
;; Profile props total size limit (PENPOT_PROFILE_PROPS_MAX_SIZE)
[:profile-props-max-size {:optional true} ::sm/int]
[:deletion-delay {:optional true} ::ct/duration]
[:file-clean-delay {:optional true} ::ct/duration]
[:telemetry-enabled {:optional true} ::sm/boolean]
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.arrow
"Bulk Ladybug ingest through in-memory Arrow.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.debug
"In-memory Ladybug sessions for the debug graph console."
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.ingest
"Penpot file -> Ladybug graph projection."
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.ladybug
"Ladybug access layer for graph-backed Penpot.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.meta
"`GraphMeta`: the graph's own account of who built it and from what.
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.projection.document
"Project a Penpot file-data map into Ladybug nodes and structural edges.
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.projection.transforms
"Derived graph links: edges a reader could compute from the projected
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.report
(:require
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.schema
"Ladybug DDL facade for the graph-backed Penpot vertical slice.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.schema.contract
"Deliberate choices in Penpot's graph schema, recorded as data.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.schema.nodes
"Single source of truth for graph node tables.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.schema.projection
"Derive Ladybug node column schemas from Penpot Malli sources.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.schema.types
"Map Malli schemas to Ladybug column types.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.schema.values
"Shape a Penpot value into the plain data its Ladybug column type wants.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.stats
(:require
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.sync
"Incremental Ladybug graph updates from Penpot file-change events."
+18 -42
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.rpc.commands.plugins
(:require
@@ -11,7 +11,6 @@
[app.common.types.plugins :as ctp]
[app.db :as db]
[app.rpc :as-alias rpc]
[app.rpc.climit :as-alias climit]
[app.rpc.commands.profile :as profile]
[app.rpc.doc :as-alias doc]
[app.util.services :as sv]))
@@ -34,8 +33,6 @@
(sv/defmethod ::add-profile-plugin
{::doc/added "2.18"
::climit/id [[:profile-plugin-ops/by-profile ::rpc/profile-id]
[:profile-plugin-ops/global]]
::sm/params schema:add-profile-plugin
::sm/result ctp/schema:registry-entry
::db/transaction true}
@@ -45,21 +42,14 @@
(let [profile (profile/get-profile conn profile-id ::db/for-update true)
plugins (get-in profile [:props :plugins] {:ids [] :data {}})
plugin-id (:plugin-id plugin)
exists? (contains? (set (:ids plugins)) plugin-id)]
(when (and (not exists?) (>= (count (:ids plugins)) ctp/max-plugins))
(ex/raise :type :validation
:code :too-many-plugins
:hint "plugin registry exceeds maximum size"))
(let [plugins (-> plugins
(update :ids #(vec (distinct (conj % plugin-id))))
(assoc-in [:data plugin-id] plugin))
props (assoc (:props profile) :plugins plugins)]
(profile/check-props-size! (:props profile) props)
(db/update! conn :profile
{:props (db/tjson props)}
{:id profile-id}
{::db/return-keys false})
plugin)))
plugins (-> plugins
(update :ids #(vec (distinct (conj % plugin-id))))
(assoc-in [:data plugin-id] plugin))]
(db/update! conn :profile
{:props (db/tjson (assoc (:props profile) :plugins plugins))}
{:id profile-id}
{::db/return-keys false})
plugin))
(def ^:private
schema:remove-profile-plugin
@@ -68,32 +58,18 @@
(sv/defmethod ::remove-profile-plugin
{::doc/added "2.18"
::climit/id [[:profile-plugin-ops/by-profile ::rpc/profile-id]
[:profile-plugin-ops/global]]
::sm/params schema:remove-profile-plugin
::sm/result :nil
::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id plugin-id]}]
(let [profile (profile/get-profile conn profile-id ::db/for-update true)
plugins (get-in profile [:props :plugins] {:ids [] :data {}})
plugin-id-str (str plugin-id)]
(if-not (or (some #(= % plugin-id-str) (:ids plugins))
(contains? (:data plugins) plugin-id-str))
;; Nothing to remove: skip the write (and the size check) entirely.
nil
(let [plugins (-> plugins
(update :ids #(vec (remove (partial = plugin-id-str) %)))
(update :data dissoc plugin-id-str))
props (assoc (:props profile) :plugins plugins)]
;; Removal only shrinks props, so this never raises; kept for uniformity
;; so the user-facing profile.props RPC writes (update-profile-props,
;; update-profile-notifications, add/remove-profile-plugin) all go
;; through the size check. System writers (OIDC login merge,
;; management subscription update) are exempt: they write fixed-key,
;; non-accumulating shapes.
(profile/check-props-size! (:props profile) props)
(db/update! conn :profile
{:props (db/tjson props)}
{:id profile-id}
{::db/return-keys false})
nil))))
plugin-id-str (str plugin-id)
plugins (-> plugins
(update :ids #(vec (remove (partial = plugin-id-str) %)))
(update :data dissoc plugin-id-str))]
(db/update! conn :profile
{:props (db/tjson (assoc (:props profile) :plugins plugins))}
{:id profile-id}
{::db/return-keys false})
nil))
-27
View File
@@ -36,7 +36,6 @@
[cuerdas.core :as str]))
(declare check-profile-existence!)
(declare check-props-size!)
(declare decode-row)
(declare filter-props)
(declare get-profile)
@@ -281,8 +280,6 @@
(-> (get profile :props)
(assoc :notifications notifications))]
(check-props-size! (:props profile) props)
(db/update! conn :profile
{:props (db/tjson props)}
{:id profile-id}
@@ -472,28 +469,6 @@
[:map {:title "update-profile-props"}
[:props schema:props-writeable]])
(defn- props-size
"Returns the serialized size in UTF-8 bytes of the props map."
[props]
(if-let [pg (db/tjson props)]
(alength (.getBytes ^String (.getValue ^org.postgresql.util.PGobject pg) "UTF-8"))
0))
(defn check-props-size!
"Raises :props-too-large when the new props exceed the configured total
size limit *and* grow beyond the current size. Profiles that already
exceed the limit can still shrink or hold steady (grandfathered), but
cannot grow further."
[old-props new-props]
(let [limit (cf/get :profile-props-max-size)
old-size (props-size old-props)
new-size (props-size new-props)]
(when (and (> new-size limit)
(> new-size old-size))
(ex/raise :type :validation
:code :props-too-large
:hint "profile props exceed maximum size"))))
(defn update-profile-props
[{:keys [::db/conn] :as cfg} profile-id props]
(let [profile (get-profile conn profile-id ::db/for-update true)
@@ -507,8 +482,6 @@
(:props profile)
(apply dissoc props system-managed-props))]
(check-props-size! (:props profile) props)
(db/update! conn :profile
{:props (db/tjson props)}
{:id profile-id}
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.storage.pending-gc
"A maintenance task that reclaims storage objects created in 'pending'
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.tasks.demo-purge
"Task handler for delayed demo profile deletion. Submitted at demo
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.demo-test
(:require
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.graph-binder-gate-test
"Binder gate for the incremental-sync statement templates.
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.graph-sync-parity-test
"Cold projection and incremental sync are two implementations of one mapping,
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.passwords-test
(:require
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.rpc-demo-test
(:require
+1 -123
View File
@@ -2,13 +2,11 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.rpc-plugins-test
(:require
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.rpc :as-alias rpc]
[app.rpc.commands.profile :as profile]
[backend-tests.helpers :as th]
@@ -147,126 +145,6 @@
(t/is (= "Test Plugin" (get-in plugins [:data plugin-id-1 :name])))
(t/is (= "Second Plugin" (get-in plugins [:data plugin-id-2 :name]))))))
(t/deftest add-profile-plugin-rejects-oversized-code
;; The merged props must not exceed :profile-props-max-size
(let [profile (th/create-profile* 1)
plugin (assoc valid-plugin :code (apply str (repeat 200 "x")))
data {::th/type :add-profile-plugin
::rpc/profile-id (:id profile)
:plugin plugin}]
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
(let [out (th/command! data)]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :props-too-large))))))
(t/deftest remove-profile-plugin-allowed-on-oversized-profile
;; Removal shrinks props, so it passes even under a tight limit
(let [profile (th/create-profile* 1)
plugin (assoc valid-plugin :code (apply str (repeat 200 "x")))]
;; Seed an oversized registry while the limit is high
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100000})]
(let [out (th/command! {::th/type :add-profile-plugin
::rpc/profile-id (:id profile)
:plugin plugin})]
(t/is (nil? (:error out)))))
;; Removal under a tight limit still passes
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
(let [out (th/command! {::th/type :remove-profile-plugin
::rpc/profile-id (:id profile)
:plugin-id (uuid/uuid plugin-id-1)})]
(t/is (nil? (:error out)))))))
(t/deftest add-profile-plugin-rejects-51st-plugin
;; The registry holds at most 50 plugins; the 51st (new id) must fail
(let [profile (th/create-profile* 1)]
;; Seed 50 plugins
(doseq [i (range 50)]
(let [plugin (assoc valid-plugin
:plugin-id (str (uuid/next))
:name (str "Plugin " i))
out (th/command! {::th/type :add-profile-plugin
::rpc/profile-id (:id profile)
:plugin plugin})]
(t/is (nil? (:error out)) (str "seed plugin " i " should install"))))
;; The 51st must fail with a specific error
(let [extra (assoc valid-plugin
:plugin-id (str (uuid/next))
:name "One Too Many")
out (th/command! {::th/type :add-profile-plugin
::rpc/profile-id (:id profile)
:plugin extra})]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :too-many-plugins)))
;; And nothing extra was persisted
(let [saved (th/db-get :profile {:id (:id profile)})
props (profile/decode-row saved)]
(t/is (= 50 (count (get-in props [:props :plugins :ids])))))))
(t/deftest add-profile-plugin-updates-existing-at-limit
;; Re-adding an existing id at the limit is an update, not a new entry
(let [profile (th/create-profile* 1)
ids (mapv (fn [_] (str (uuid/next))) (range 50))]
(doseq [[i pid] (map-indexed vector ids)]
(th/command! {::th/type :add-profile-plugin
::rpc/profile-id (:id profile)
:plugin (assoc valid-plugin :plugin-id pid :name (str "Plugin " i))}))
(let [out (th/command! {::th/type :add-profile-plugin
::rpc/profile-id (:id profile)
:plugin (assoc valid-plugin :plugin-id (first ids) :name "Renamed")})]
(t/is (nil? (:error out)))
(let [saved (th/db-get :profile {:id (:id profile)})
props (profile/decode-row saved)]
(t/is (= 50 (count (get-in props [:props :plugins :ids]))))
(t/is (= "Renamed" (get-in props [:props :plugins :data (first ids) :name])))))))
(t/deftest add-profile-plugin-full-registry-reports-too-many-before-size
;; A full registry plus oversized content reports the count guard,
;; which runs before the size check
(let [profile (th/create-profile* 1)]
;; Seed 50 plugins under a generous limit
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 1000000})]
(doseq [i (range 50)]
(let [plugin (assoc valid-plugin
:plugin-id (str (uuid/next))
:name (str "Plugin " i))
out (th/command! {::th/type :add-profile-plugin
::rpc/profile-id (:id profile)
:plugin plugin})]
(t/is (nil? (:error out)) (str "seed plugin " i " should install")))))
;; Tight limit + 51st small plugin: count wins over size
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
(let [extra (assoc valid-plugin
:plugin-id (str (uuid/next))
:name "One Too Many")
out (th/command! {::th/type :add-profile-plugin
::rpc/profile-id (:id profile)
:plugin extra})]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :too-many-plugins))))
;; And nothing extra was persisted
(let [saved (th/db-get :profile {:id (:id profile)})
props (profile/decode-row saved)]
(t/is (= 50 (count (get-in props [:props :plugins :ids])))))))
(t/deftest remove-profile-plugin-noop-on-oversized-profile-without-plugins
;; Removing an absent id changes nothing: no write, no size failure,
;; and no :plugins key is manufactured
(let [profile (th/create-profile* 1)
big {:onboarding-questions {:big-blob (apply str (repeat 200 "x"))}}]
(th/db-update! :profile {:props (db/tjson big)} {:id (:id profile)})
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
(let [out (th/command! {::th/type :remove-profile-plugin
::rpc/profile-id (:id profile)
:plugin-id (uuid/next)})]
(t/is (nil? (:error out)))))
(let [saved (th/db-get :profile {:id (:id profile)})
props (profile/decode-row saved)]
(t/is (nil? (get-in props [:props :plugins])))
(t/is (= big (get props :props))))))
(t/deftest update-profile-props-rejects-plugins
(let [profile (th/create-profile* 1)
data {::th/type :update-profile-props
@@ -1341,104 +1341,6 @@
(t/is (th/ex-of-code? (:error out) :params-validation))))
(t/deftest update-profile-props-rejects-oversized-props
;; The merged props must not exceed :profile-props-max-size
(let [profile (th/create-profile* 1)]
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
(let [data {::th/type :update-profile-props
::rpc/profile-id (:id profile)
:props {:onboarding-questions {:big-blob (apply str (repeat 200 "x"))}}}
out (th/command! data)]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :props-too-large))))))
(t/deftest update-profile-props-grandfathers-oversized-profile
;; Profiles that already exceed the limit can shrink or hold steady,
;; but cannot grow further
(let [profile (th/create-profile* 1)
big {:onboarding-questions {:big-blob (apply str (repeat 200 "x"))}}]
;; Seed an already-oversized profile directly in DB (bypasses RPC validation)
(th/db-update! :profile {:props (db/tjson big)} {:id (:id profile)})
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
;; Shrinking update passes
(let [data {::th/type :update-profile-props
::rpc/profile-id (:id profile)
:props {:onboarding-questions {:big-blob "small"}}}
out (th/command! data)]
(t/is (nil? (:error out))))
;; Growing update fails
(let [data {::th/type :update-profile-props
::rpc/profile-id (:id profile)
:props {:onboarding-questions {:big-blob (apply str (repeat 300 "x"))}}}
out (th/command! data)]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :props-too-large))))))
(t/deftest check-props-size-measures-bytes-not-chars
;; The limit is in UTF-8 bytes: multibyte content that fits in chars
;; but exceeds the byte limit must be rejected
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 200})]
;; 70 ASCII chars (~87 bytes serialized) passes
(t/is (nil? (profile/check-props-size! {} {:blob (apply str (repeat 70 "x"))})))
;; 70 CJK chars (~227 bytes serialized, still 70 chars) raises
(try
(profile/check-props-size! {} {:blob (apply str (repeat 70 "日"))})
(t/is false "should have thrown")
(catch clojure.lang.ExceptionInfo e
(t/is (= :validation (:type (ex-data e))))
(t/is (= :props-too-large (:code (ex-data e))))))))
(t/deftest update-profile-props-allows-steady-size-on-oversized-profile
;; Same size (not smaller) on an oversized profile passes
(let [profile (th/create-profile* 1)
big {:onboarding-questions {:big-blob (apply str (repeat 200 "x"))}}]
(th/db-update! :profile {:props (db/tjson big)} {:id (:id profile)})
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
(let [data {::th/type :update-profile-props
::rpc/profile-id (:id profile)
:props {:onboarding-questions {:big-blob (apply str (repeat 200 "y"))}}}
out (th/command! data)]
(t/is (nil? (:error out)))))))
(t/deftest update-profile-notifications-rejects-growth-on-oversized-profile
;; The notifications write path goes through the same size check:
;; growing an oversized profile fails
(let [profile (th/create-profile* 1)
big {:onboarding-questions {:big-blob (apply str (repeat 200 "x"))}}]
(th/db-update! :profile {:props (db/tjson big)} {:id (:id profile)})
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
(let [data {::th/type :update-profile-notifications
::rpc/profile-id (:id profile)
:dashboard-comments :all
:email-comments :all
:email-invites :all}
out (th/command! data)]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :props-too-large))))))
(t/deftest update-profile-notifications-allows-steady-on-oversized-profile
;; Same-size notifications write on an oversized profile passes
(let [profile (th/create-profile* 1)
notifications {:dashboard-comments :all
:email-comments :all
:email-invites :all}
big {:onboarding-questions {:big-blob (apply str (repeat 200 "x"))}
:notifications notifications}]
(th/db-update! :profile {:props (db/tjson big)} {:id (:id profile)})
(with-redefs [cf/get (th/config-get-mock {:profile-props-max-size 100})]
(let [data (merge {::th/type :update-profile-notifications
::rpc/profile-id (:id profile)}
notifications)
out (th/command! data)]
(t/is (nil? (:error out)))))))
(t/deftest prepare-register-profile-password-too-short
(let [data {::th/type :prepare-register-profile
:email "user@example.com"
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.path.fit
"Curve fitting helpers."
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.path.selection
"Transforms selected path nodes and handlers."
+7 -11
View File
@@ -41,25 +41,21 @@
"Schema for plugin permissions - a set of valid permission strings."
[:set {:gen/max 11} (into [:enum] (sort valid-permissions))])
(def max-plugins
"Maximum number of plugins a profile can hold."
50)
(def schema:registry-entry
[:map
[:plugin-id :string]
[:version {:optional true} :int]
[:name [:string {:max 500}]]
[:description {:optional true} [:string {:max 4096}]]
[:host [:string {:max 500}]]
[:code [:string {:max 1048576}]] ;; 1 MiB chars; byte budget enforced by profile-props-max-size
[:icon {:optional true} [:string {:max 262144}]] ;; 256 KiB chars; see above
[:name :string]
[:description {:optional true} :string]
[:host :string]
[:code :string]
[:icon {:optional true} :string]
[:permissions schema:permissions]])
(def schema:plugin-registry
[:map
[:ids [:vector {:max max-plugins} :string]]
[:ids [:vector :string]]
[:data
[:map-of {:gen/max 5 :max max-plugins}
[:map-of {:gen/max 5}
:string
schema:registry-entry]]])
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.tokens-status
(:require
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.files-migrations-0026-test
(:require
-2
View File
@@ -82,7 +82,6 @@
[common-tests.types.objects-map-test]
[common-tests.types.organization-test]
[common-tests.types.path-data-test]
[common-tests.types.plugins-test]
[common-tests.types.shape-decode-encode-test]
[common-tests.types.shape-interactions-test]
[common-tests.types.shape-layout-test]
@@ -163,7 +162,6 @@
'common-tests.types.objects-map-test
'common-tests.types.organization-test
'common-tests.types.path-data-test
'common-tests.types.plugins-test
'common-tests.types.shape-decode-encode-test
'common-tests.types.shape-interactions-test
'common-tests.types.shape-layout-test
@@ -1,64 +0,0 @@
;; 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 common-tests.types.plugins-test
(:require
[app.common.schema :as sm]
[app.common.types.plugins :as ctp]
[clojure.test :as t]))
(def ^:private valid-entry
{:plugin-id "plugin-1"
:name "Test Plugin"
:description "A test plugin"
:host "https://example.com"
:code "(function() {})()"
:permissions #{"content:read"}})
(t/deftest registry-entry-accepts-valid-plugin
(t/is (true? (sm/validate ctp/schema:registry-entry valid-entry))))
(t/deftest registry-entry-rejects-oversized-code
(t/is (false? (sm/validate ctp/schema:registry-entry
(assoc valid-entry
:code (apply str (repeat (inc (* 1024 1024)) "x")))))))
(t/deftest registry-entry-rejects-oversized-name
(t/is (false? (sm/validate ctp/schema:registry-entry
(assoc valid-entry :name (apply str (repeat 501 "x")))))))
(t/deftest registry-entry-rejects-oversized-host
(t/is (false? (sm/validate ctp/schema:registry-entry
(assoc valid-entry :host (apply str (repeat 501 "x")))))))
(t/deftest registry-entry-rejects-oversized-description
(t/is (false? (sm/validate ctp/schema:registry-entry
(assoc valid-entry :description (apply str (repeat 4097 "x")))))))
(t/deftest registry-entry-rejects-oversized-icon
(t/is (false? (sm/validate ctp/schema:registry-entry
(assoc valid-entry :icon (apply str (repeat 262145 "x")))))))
(t/deftest registry-entry-accepts-values-at-max
(t/is (true? (sm/validate ctp/schema:registry-entry
(assoc valid-entry
:name (apply str (repeat 500 "x"))
:host (apply str (repeat 500 "x"))
:description (apply str (repeat 4096 "x"))
:icon (apply str (repeat 262144 "x"))
:code (apply str (repeat 1048576 "x")))))))
(defn- make-registry
[n]
(let [ids (mapv #(str "plugin-" %) (range n))]
{:ids ids
:data (into {} (map (fn [id] [id (assoc valid-entry :plugin-id id)]) ids))}))
(t/deftest plugin-registry-accepts-fifty-plugins
(t/is (true? (sm/validate ctp/schema:plugin-registry (make-registry 50)))))
(t/deftest plugin-registry-rejects-more-than-fifty-plugins
(t/is (false? (sm/validate ctp/schema:plugin-registry (make-registry 51)))))
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.types.tokens-status-test
(:require
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.auth
"Resolves the caller's session cookie to a real profile id.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.handlers.export
"Handle export jobs"
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.handlers.jobs
"REST surface for export jobs, under `/api/export/jobs`.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.jobs
"Export job model and lifecycle.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.jobs.scheduler
"Admission control for export jobs.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.jobs.store
"Redis persistence for export jobs.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.jobs.utils
"Temp file ownership for export jobs.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.router
"Method + path dispatch.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.wasm.pool
"Pool of headless render workers.
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.wasm.render
"Headless render pipeline: renders exports with the render-wasm Skia pipeline,
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.wasm.worker
"Render worker entry point.
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns exporter-tests.export-shapes-test
"Chunking of the browser backend."
+1 -1
View File
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns exporter-tests.jobs-test
"Job state machine. Runs without redis: a store write with no connection is
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns exporter-tests.scheduler-test
"Admission control. A headless job leases one render worker for its whole run,
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns exporter-tests.wasm-pool-test
"Worker leasing, against a stub pool: `with-worker` must give the worker back
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.main.data.workspace.path.clipboard
(:require
@@ -56,7 +56,7 @@
icon))
(mf/defc plugin-entry*
[{:keys [index manifest user-can-edit on-open-plugin on-remove-plugin remove-disabled]}]
[{:keys [index manifest user-can-edit on-open-plugin on-remove-plugin]}]
(let [{:keys [plugin-id host icon name description permissions]} manifest
plugins-permissions-peek (deref refs/plugins-permissions-peek)
@@ -100,7 +100,6 @@
[:> icon-button* {:variant "ghost"
:aria-label (tr "workspace.plugins.remove-plugin")
:on-click handle-delete-click
:disabled remove-disabled
:icon i/delete}]]))
(mf/defc plugin-management-dialog
@@ -127,11 +126,6 @@
fetching-manifest?
(mf/use-state false)
;; Ids with a persist request in flight; their remove button
;; is disabled until the request completes.
in-flight*
(mf/use-state #{})
on-url-change
(mf/use-fn
(fn [value]
@@ -181,23 +175,12 @@
(fn [plugin-index]
(let [plugins-list (preg/plugins-list)
plugin (nth plugins-list plugin-index)]
(modal/show!
{:type :confirm
:title (tr "workspace.plugins.remove-confirmation.title")
:message (tr "workspace.plugins.remove-confirmation.message" (:name plugin))
:accept-label (tr "workspace.plugins.remove-plugin")
:on-accept (fn [_]
(st/emit! (ev/event {::ev/name "remove-plugin"
:name (:name plugin)
:host (:host plugin)}))
(dp/close-plugin! plugin)
(preg/remove-plugin! plugin)
(reset! plugins-state* (preg/plugins-list))
(modal/show! :plugin-management {}))}))))]
(mf/with-effect []
(let [listener (preg/subscribe-in-flight! #(reset! in-flight* %))]
(partial preg/unsubscribe-in-flight! listener)))
(st/emit! (ev/event {::ev/name "remove-plugin"
:name (:name plugin)
:host (:host plugin)}))
(dp/close-plugin! plugin)
(preg/remove-plugin! plugin)
(reset! plugins-state* (preg/plugins-list)))))]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-dialog :plugin-management)}
@@ -253,7 +236,6 @@
:index idx
:manifest manifest
:user-can-edit user-can-edit?
:remove-disabled (contains? @in-flight* (:plugin-id manifest))
:on-open-plugin on-open-plugin
:on-remove-plugin on-remove-plugin}])]])]]]))
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.main.ui.workspace.viewport.path-state
(:require
+25 -118
View File
@@ -122,129 +122,36 @@
(declare remove-plugin!)
;; Tracks plugin ids with a persist request in flight, so rapid repeated
;; install/remove clicks on the same plugin cannot stack RPC writes.
(defonce ^:private in-flight (atom #{}))
(defonce ^:private in-flight-listeners (atom #{}))
(defn subscribe-in-flight!
"Subscribes f, called with the in-flight id set on every change.
Calls f immediately with the current set. Returns f."
[f]
(swap! in-flight-listeners conj f)
(f @in-flight)
f)
(defn unsubscribe-in-flight!
[f]
(swap! in-flight-listeners disj f)
nil)
(defn- notify-in-flight!
[]
(let [ids @in-flight]
(doseq [f @in-flight-listeners]
(f ids))))
(defn- track!
[plugin-id]
(swap! in-flight conj plugin-id)
(notify-in-flight!))
(defn- release!
[plugin-id]
(swap! in-flight disj plugin-id)
(notify-in-flight!))
(defn- validation-error?
[err]
(= :validation (:type (ex-data err))))
(defn- drop-local!
[{:keys [plugin-id]}]
(swap! registry #(-> %
(update :ids (fn [ids] (vec (remove (partial = plugin-id) ids))))
(update :data dissoc plugin-id))))
(defn- insert-at
[ids idx id]
(let [v (vec ids)
idx (max 0 (min idx (count v)))]
(vec (concat (subvec v 0 idx) [id] (subvec v idx)))))
(defn install-plugin!
[plugin]
(let [plugin-id (:plugin-id plugin)
previous (get-plugin plugin-id)
prev-idx (.indexOf (vec (:ids @registry)) plugin-id)]
(when-not (contains? @in-flight plugin-id)
(track! plugin-id)
(letfn [(update-ids [ids]
(conj
(->> ids (remove #(= % (:plugin-id plugin))))
(:plugin-id plugin)))]
(swap! registry #(-> %
(update :ids update-ids)
(update :data assoc (:plugin-id plugin) plugin)))
(->> (rp/cmd! :add-profile-plugin {:plugin plugin})
(rx/subs! (fn [_]
(release! plugin-id))
(fn [err]
(release! plugin-id)
(if (validation-error? err)
;; The server kept the previous version (if any)
;; in its original position: drop the optimistic
;; entry and restore both position and data.
(if previous
(swap! registry #(-> %
(update :ids (fn [ids]
(insert-at (remove (partial = plugin-id) ids)
prev-idx
plugin-id)))
(assoc-in [:data plugin-id] previous)))
(drop-local! plugin))
;; One-shot compensating write with terminal
;; callbacks: never re-arms tracking or rollback.
(do
(drop-local! plugin)
(->> (rp/cmd! :remove-profile-plugin {:plugin-id plugin-id})
(rx/subs! (fn [_] nil)
(fn [err2]
(.error js/console "Rollback remove failed:" err2))))))
(.error js/console "Failed to install plugin:" err))))))))
(letfn [(update-ids [ids]
(conj
(->> ids (remove #(= % (:plugin-id plugin))))
(:plugin-id plugin)))]
(swap! registry #(-> %
(update :ids update-ids)
(update :data assoc (:plugin-id plugin) plugin)))
(->> (rp/cmd! :add-profile-plugin {:plugin plugin})
(rx/subs! identity
(fn [err]
(remove-plugin! plugin)
(.error js/console "Failed to install plugin:" err))))))
(defn remove-plugin!
[{:keys [plugin-id]}]
(let [stored (get-plugin plugin-id)
prev-idx (.indexOf (vec (:ids @registry)) plugin-id)]
(when-not (contains? @in-flight plugin-id)
(track! plugin-id)
(letfn [(update-ids [ids]
(->> ids
(remove #(= % plugin-id))))]
(swap! registry #(-> %
(update :ids update-ids)
(update :data dissoc plugin-id)))
(->> (rp/cmd! :remove-profile-plugin {:plugin-id plugin-id})
(rx/subs! (fn [_]
(release! plugin-id))
(fn [err]
(release! plugin-id)
(when stored
;; Restore at the original position; the server
;; still holds the entry on validation errors.
(swap! registry #(-> %
(update :ids insert-at prev-idx plugin-id)
(update :data assoc plugin-id stored)))
;; One-shot compensating write with terminal
;; callbacks on any other failure.
(when-not (validation-error? err)
(->> (rp/cmd! :add-profile-plugin {:plugin stored})
(rx/subs! (fn [_] nil)
(fn [err2]
(.error js/console "Rollback install failed:" err2))))))
(.error js/console "Failed to remove plugin:" err))))))))
(let [plugin (get-plugin plugin-id)]
(letfn [(update-ids [ids]
(->> ids
(remove #(= % plugin-id))))]
(swap! registry #(-> %
(update :ids update-ids)
(update :data dissoc plugin-id)))
(->> (rp/cmd! :remove-profile-plugin {:plugin-id plugin-id})
(rx/subs! identity
(fn [err]
(when plugin
(install-plugin! plugin))
(.error js/console "Failed to remove plugin:" err)))))))
(defn check-permission
[plugin-id permission]
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.logic.path-actions-test
(:require
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.logic.path-clipboard-test
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.logic.path-helpers-test
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.logic.path-lifecycle-test
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.logic.path-test-helpers
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.logic.path-tools-test
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.logic.wasm-modifiers-nil-id-test
"Reproduces the production crash \"Cannot read properties of null
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.plugins.flex-test
(:require
@@ -1,271 +0,0 @@
;; 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.plugins.register-test
(:require
[app.main.repo :as rp]
[app.plugins.register :as preg]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.mock :as mock]))
(defn- record-cmd-mock
"Mock rp/cmd! that records calls in mock/rpc-calls and answers
with (response-fn cmd params)."
[response-fn]
(mock/stub
(fn [cmd params]
(swap! mock/rpc-calls conj {:cmd cmd :params params})
(response-fn cmd params))))
(defn- cmds
[]
(mapv :cmd @mock/rpc-calls))
;; --- install-plugin! ---
(t/deftest install-success-releases-id
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock (fn [_ _] (rx/of {:ok true})))}
(fn [done']
(let [plugin {:plugin-id "reg-install-ok"}]
(preg/install-plugin! plugin)
(t/is (= [:add-profile-plugin] (cmds)))
(t/is (= plugin (preg/get-plugin "reg-install-ok")))
;; id released: a second install issues a second RPC
(preg/install-plugin! plugin)
(t/is (= 2 (count @mock/rpc-calls)))
(done')))
done)))
(t/deftest install-while-in-flight-issues-no-second-rpc
(t/async done
(let [subjects (atom [])]
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [_ _]
(let [sb (rx/subject)]
(swap! subjects conj sb)
sb)))}
(fn [done']
(let [plugin {:plugin-id "reg-install-dedupe"}]
(preg/install-plugin! plugin)
(preg/install-plugin! plugin)
(t/is (= 1 (count @mock/rpc-calls)) "second install while in flight is skipped")
;; complete the pending call; the id is released afterwards
(rx/push! (first @subjects) {:ok true})
(preg/install-plugin! plugin)
(t/is (= 2 (count @mock/rpc-calls)))
(done')))
done))))
(t/deftest install-validation-error-cleans-local-only
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [_ _] (rx/throw (ex-info "rejected" {:type :validation :code :props-too-large}))))}
(fn [done']
(let [plugin {:plugin-id "reg-install-validation"}]
(preg/install-plugin! plugin)
(t/is (= 1 (count @mock/rpc-calls)) "no rollback write after validation rejection")
(t/is (nil? (preg/get-plugin "reg-install-validation")))
;; id released: the next install retries the RPC
(preg/install-plugin! plugin)
(t/is (= 2 (count @mock/rpc-calls)))
(done')))
done)))
(t/deftest install-validation-error-restores-previous-version
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [_ params]
(if (= "v2" (get-in params [:plugin :name]))
(rx/throw (ex-info "rejected" {:type :validation}))
(rx/of {:ok true}))))}
(fn [done']
(let [v1 {:plugin-id "reg-install-prev" :name "v1"}
v2 {:plugin-id "reg-install-prev" :name "v2"}]
(preg/install-plugin! v1)
(preg/install-plugin! v2)
(t/is (= 2 (count @mock/rpc-calls)))
(t/is (= v1 (preg/get-plugin "reg-install-prev"))
"the server-kept version is restored, not dropped")
(done')))
done)))
(t/deftest install-validation-error-keeps-original-position
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [_ params]
(if (= "v2" (get-in params [:plugin :name]))
(rx/throw (ex-info "rejected" {:type :validation}))
(rx/of {:ok true}))))}
(fn [done']
(let [own #{"reg-pos-a" "reg-pos-b" "reg-pos-c"}
v1 {:plugin-id "reg-pos-b" :name "v1"}
v2 {:plugin-id "reg-pos-b" :name "v2"}]
(preg/install-plugin! {:plugin-id "reg-pos-a"})
(preg/install-plugin! v1)
(preg/install-plugin! {:plugin-id "reg-pos-c"})
(preg/install-plugin! v2)
;; installs prepend, so newest-first; the rejected update
;; must preserve order and restore v1
(t/is (= ["reg-pos-c" "reg-pos-b" "reg-pos-a"]
(filterv own (mapv :plugin-id (preg/plugins-list)))))
(t/is (= v1 (preg/get-plugin "reg-pos-b")))
(done')))
done)))
(t/deftest install-persistent-failure-terminates
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [_ _] (rx/throw (ex-info "boom" {:type :other}))))}
(fn [done']
(let [plugin {:plugin-id "reg-install-hang"}]
(preg/install-plugin! plugin)
(t/is (= [:add-profile-plugin :remove-profile-plugin] (cmds))
"one-shot rollback: no further calls")
(t/is (nil? (preg/get-plugin "reg-install-hang")))
(done')))
done)))
(t/deftest install-non-validation-error-rolls-back-via-rpc
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [cmd _]
(if (= cmd :add-profile-plugin)
(rx/throw (ex-info "boom" {:type :other}))
(rx/of nil))))}
(fn [done']
(let [plugin {:plugin-id "reg-install-rollback"}]
(preg/install-plugin! plugin)
(t/is (= [:add-profile-plugin :remove-profile-plugin] (cmds)))
(t/is (nil? (preg/get-plugin "reg-install-rollback")))
(done')))
done)))
;; --- remove-plugin! ---
(t/deftest remove-success-releases-id
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock (fn [_ _] (rx/of {:ok true})))}
(fn [done']
(let [plugin {:plugin-id "reg-remove-ok"}]
(preg/install-plugin! plugin)
(preg/remove-plugin! plugin)
(t/is (= [:add-profile-plugin :remove-profile-plugin] (cmds)))
(t/is (nil? (preg/get-plugin "reg-remove-ok")))
;; id released: a second remove issues another RPC
(preg/remove-plugin! plugin)
(t/is (= 3 (count @mock/rpc-calls)))
(done')))
done)))
(t/deftest remove-while-in-flight-issues-no-second-rpc
(t/async done
(let [subjects (atom [])]
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [cmd _]
(if (= cmd :add-profile-plugin)
(rx/of {:ok true})
(let [sb (rx/subject)]
(swap! subjects conj sb)
sb))))}
(fn [done']
(let [plugin {:plugin-id "reg-remove-dedupe"}]
(preg/install-plugin! plugin)
(preg/remove-plugin! plugin)
(preg/remove-plugin! plugin)
(t/is (= 2 (count @mock/rpc-calls)) "second remove while in flight is skipped")
;; complete the pending call; the id is released afterwards
(rx/push! (first @subjects) nil)
(preg/remove-plugin! plugin)
(t/is (= 3 (count @mock/rpc-calls)))
(done')))
done))))
(t/deftest remove-validation-error-restores-local-only
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [cmd _]
(if (= cmd :add-profile-plugin)
(rx/of {:ok true})
(rx/throw (ex-info "rejected" {:type :validation})))))}
(fn [done']
(let [plugin {:plugin-id "reg-remove-validation"}]
(preg/install-plugin! plugin)
(preg/remove-plugin! plugin)
(t/is (= [:add-profile-plugin :remove-profile-plugin] (cmds))
"no reinstall write after validation rejection")
(t/is (= plugin (preg/get-plugin "reg-remove-validation"))
"local entry is restored")
(done')))
done)))
(t/deftest remove-non-validation-error-reinstalls-via-rpc
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [cmd _]
(if (= cmd :remove-profile-plugin)
(rx/throw (ex-info "boom" {:type :other}))
(rx/of {:ok true}))))}
(fn [done']
(let [plugin {:plugin-id "reg-remove-rollback"}]
(preg/install-plugin! plugin)
(preg/remove-plugin! plugin)
(t/is (= [:add-profile-plugin :remove-profile-plugin :add-profile-plugin] (cmds)))
(t/is (= plugin (preg/get-plugin "reg-remove-rollback")))
(done')))
done)))
(t/deftest remove-persistent-failure-terminates
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [cmd _]
(if (= cmd :add-profile-plugin)
(rx/of {:ok true})
(rx/throw (ex-info "boom" {:type :other})))))}
(fn [done']
(let [plugin {:plugin-id "reg-remove-hang"}]
(preg/install-plugin! plugin)
(preg/remove-plugin! plugin)
(t/is (= [:add-profile-plugin :remove-profile-plugin :add-profile-plugin] (cmds))
"one-shot rollback: no further calls")
(t/is (= plugin (preg/get-plugin "reg-remove-hang")))
(done')))
done)))
(t/deftest remove-validation-error-keeps-original-position
(t/async done
(mock/with-mocks
{rp/cmd! (record-cmd-mock
(fn [cmd _]
(if (= cmd :add-profile-plugin)
(rx/of {:ok true})
(rx/throw (ex-info "rejected" {:type :validation})))))}
(fn [done']
(let [own #{"reg-idx-a" "reg-idx-b" "reg-idx-c"}
plugin {:plugin-id "reg-idx-b"}]
(preg/install-plugin! {:plugin-id "reg-idx-a"})
(preg/install-plugin! plugin)
(preg/install-plugin! {:plugin-id "reg-idx-c"})
(preg/remove-plugin! plugin)
;; installs prepend, so the order is newest-first;
;; the failed removal must preserve it exactly
(t/is (= ["reg-idx-c" "reg-idx-b" "reg-idx-a"]
(filterv own (mapv :plugin-id (preg/plugins-list)))))
(done')))
done)))
@@ -2,7 +2,7 @@
;; 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
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.plugins.user-test
(:require
-2
View File
@@ -59,7 +59,6 @@
[frontend-tests.plugins.page-active-validation-test]
[frontend-tests.plugins.page-test]
[frontend-tests.plugins.parser-test]
[frontend-tests.plugins.register-test]
[frontend-tests.plugins.shape-bugfixes-test]
[frontend-tests.plugins.text-test]
[frontend-tests.plugins.tokens-test]
@@ -169,7 +168,6 @@
'frontend-tests.plugins.page-active-validation-test
'frontend-tests.plugins.page-test
'frontend-tests.plugins.parser-test
'frontend-tests.plugins.register-test
'frontend-tests.plugins.shape-bugfixes-test
'frontend-tests.plugins.text-test
'frontend-tests.plugins.tokens-test
+1 -11
View File
@@ -9073,17 +9073,7 @@ msgstr "Read the profile information of the current user."
msgid "workspace.plugins.plugin-list-link"
msgstr "Plugins List"
#: src/app/main/ui/workspace/plugins.cljs:187
msgid "workspace.plugins.remove-confirmation.message"
msgstr ""
"Are you sure you want to remove the plugin %s? You can install it again at "
"any time."
#: src/app/main/ui/workspace/plugins.cljs:186
msgid "workspace.plugins.remove-confirmation.title"
msgstr "Remove plugin"
#: src/app/main/ui/workspace/plugins.cljs:101, src/app/main/ui/workspace/plugins.cljs:188
#: src/app/main/ui/workspace/plugins.cljs:101
msgid "workspace.plugins.remove-plugin"
msgstr "Remove plugin"
+1 -9
View File
@@ -8821,15 +8821,7 @@ msgstr "Leer la información del usuario actual."
msgid "workspace.plugins.plugin-list-link"
msgstr "Lista de extensiones"
#: src/app/main/ui/workspace/plugins.cljs:187
msgid "workspace.plugins.remove-confirmation.message"
msgstr "¿Seguro que quieres eliminar la extensión %s? Puedes instalarla de nuevo en cualquier momento."
#: src/app/main/ui/workspace/plugins.cljs:186
msgid "workspace.plugins.remove-confirmation.title"
msgstr "Eliminar extensión"
#: src/app/main/ui/workspace/plugins.cljs:101, src/app/main/ui/workspace/plugins.cljs:188
#: src/app/main/ui/workspace/plugins.cljs:101
msgid "workspace.plugins.remove-plugin"
msgstr "Eliminar extensión"
+316 -50
View File
@@ -268,7 +268,6 @@ fn difference(
.iter()
.filter(|s| path_a.contains(to_point(s.evaluate(TValue::Parametric(0.5)))))
.copied()
.map(|s| s.reverse())
.map(|b| (BezierSource::B, b)),
);
@@ -278,16 +277,53 @@ fn difference(
fn exclusion(segments_a: Vec<Bezier>, segments_b: Vec<Bezier>) -> Vec<(BezierSource, Bezier)> {
let mut result = Vec::new();
result.extend(segments_a.iter().copied().map(|b| (BezierSource::A, b)));
result.extend(
segments_b
.iter()
.copied()
.map(|s| s.reverse())
.map(|b| (BezierSource::B, b)),
);
result.extend(segments_b.iter().copied().map(|b| (BezierSource::B, b)));
result
}
// Mirrors `app.common.types.path.subpath/clockwise?`.
fn is_clockwise(path: &Path) -> bool {
let mut points: Vec<(f32, f32)> = Vec::new();
for segment in path.segments().iter() {
match *segment {
Segment::MoveTo(p) => {
if !points.is_empty() {
break;
}
points.push(p);
}
Segment::LineTo(p) => points.push(p),
Segment::CurveTo((_, _, p)) => points.push(p),
Segment::Close => break,
}
}
if points.len() < 3 {
return false;
}
let mut signed_area = 0.0f64;
for i in 0..points.len() {
let (x1, y1) = points[i];
let (x2, y2) = points[(i + 1) % points.len()];
signed_area += f64::from(x1) * f64::from(y2) - f64::from(x2) * f64::from(y1);
}
signed_area > 0.0
}
// The kept pieces of B must point the same way round as the kept pieces of A. Not the
// `path.bool/content-bool-pair` rule, which reverses intersection on same winding and
// relies on `subpath/merge-paths` flipping subpaths when it joins them.
fn should_reverse_b(bool_type: BoolType, a_is_clockwise: bool, path_b: &Path) -> bool {
let same_winding = a_is_clockwise == is_clockwise(path_b);
match bool_type {
BoolType::Union | BoolType::Intersection => !same_winding,
BoolType::Difference | BoolType::Exclusion => same_winding,
}
}
#[derive(Debug, Clone, PartialEq, Copy)]
enum BezierSource {
A,
@@ -305,17 +341,14 @@ fn pop_first_from_pool(pool: &mut BezierPool) -> Option<(BezierSource, Bezier)>
pool.iter_mut().find_map(|e| e.take())
}
// Find and remove the segment whose start point is closest to `end` within the
// appropriate threshold. Same-source segments use a tight threshold
// (INTERSECT_THRESHOLD_SAMEd) so we prefer staying on the same original path;
// cross-source segments use a wider threshold (INTERSECT_THRESHOLD_DIFFERENT)
// to allow switching paths at intersection points.
// Same-source candidates get a tighter threshold so we stay on the original path. A
// candidate that joins by its `end` points the wrong way, so we reverse it.
fn find_next_in_pool(
pool: &mut BezierPool,
end: DVec2,
source: BezierSource,
) -> Option<(BezierSource, Bezier)> {
let mut best_idx: Option<usize> = None;
let mut best: Option<(usize, bool)> = None;
let mut best_dist_sq = f64::MAX;
for (i, entry) in pool.iter().enumerate() {
@@ -327,16 +360,21 @@ fn find_next_in_pool(
} else {
INTERSECT_THRESHOLD_DIFFERENT as f64
};
let dx = bezier.start.x - end.x;
let dy = bezier.start.y - end.y;
let dist_sq = dx * dx + dy * dy;
if dist_sq <= threshold * threshold && dist_sq < best_dist_sq {
best_dist_sq = dist_sq;
best_idx = Some(i);
for (reversed, point) in [(false, bezier.start), (true, bezier.end)] {
let dx = point.x - end.x;
let dy = point.y - end.y;
let dist_sq = dx * dx + dy * dy;
if dist_sq <= threshold * threshold && dist_sq < best_dist_sq {
best_dist_sq = dist_sq;
best = Some((i, reversed));
}
}
}
best_idx.and_then(|i| pool[i].take())
let (idx, reversed) = best?;
pool[idx]
.take()
.map(|(src, bezier)| (src, if reversed { bezier.reverse() } else { bezier }))
}
fn push_bezier(result: &mut Vec<Segment>, bezier: &Bezier) {
@@ -410,32 +448,45 @@ fn beziers_to_segments(beziers: &[(BezierSource, Bezier)]) -> Vec<Segment> {
result
}
pub fn bool_from_shapes(bool_type: BoolType, children_ids: &[Uuid], shapes: ShapesPoolRef) -> Path {
if children_ids.is_empty() {
return Path::default();
fn bool_beziers(
bool_type: BoolType,
path_a: &Path,
a_is_clockwise: bool,
path_b: &Path,
) -> (Vec<(BezierSource, Bezier)>, bool) {
let (segs_a, mut segs_b) = split_segments(path_a, path_b);
if should_reverse_b(bool_type, a_is_clockwise, path_b) {
for segment in segs_b.iter_mut() {
*segment = segment.reverse();
}
}
let Some(child) = shapes.get(&children_ids[children_ids.len() - 1]) else {
let beziers = match bool_type {
BoolType::Union => union(path_a, segs_a, path_b, segs_b),
BoolType::Difference => difference(path_a, segs_a, path_b, segs_b),
BoolType::Intersection => intersection(path_a, segs_a, path_b, segs_b),
BoolType::Exclusion => exclusion(segs_a, segs_b),
};
(beziers, path_a.is_even_odd() || path_b.is_even_odd())
}
// Fold `paths` left to right; the first entry is the base operand.
fn bool_fold(bool_type: BoolType, paths: &[Path]) -> Path {
let Some((first, rest)) = paths.split_first() else {
return Path::default();
};
let mut current_path = child.to_path(shapes);
let mut current_path = first.clone();
// Every fold step chains A's fragments, which keep their direction, so the
// accumulated path keeps this winding. Carry it instead of re-reading it from the
// emitted segment list, whose subpath order and direction fall out of pool ordering.
let is_clockwise_a = is_clockwise(&current_path);
for idx in (0..children_ids.len() - 1).rev() {
let Some(other) = shapes.get(&children_ids[idx]) else {
continue;
};
let other_path = other.to_path(shapes);
let (segs_a, segs_b) = split_segments(&current_path, &other_path);
let is_even_odd = current_path.is_even_odd() || other_path.is_even_odd();
let beziers = match bool_type {
BoolType::Union => union(&current_path, segs_a, &other_path, segs_b),
BoolType::Difference => difference(&current_path, segs_a, &other_path, segs_b),
BoolType::Intersection => intersection(&current_path, segs_a, &other_path, segs_b),
BoolType::Exclusion => exclusion(segs_a, segs_b),
};
for other_path in rest {
let (beziers, is_even_odd) =
bool_beziers(bool_type, &current_path, is_clockwise_a, other_path);
current_path = Path::new(beziers_to_segments(&beziers)).with_even_odd(is_even_odd);
}
@@ -443,6 +494,16 @@ pub fn bool_from_shapes(bool_type: BoolType, children_ids: &[Uuid], shapes: Shap
current_path
}
pub fn bool_from_shapes(bool_type: BoolType, children_ids: &[Uuid], shapes: ShapesPoolRef) -> Path {
let paths: Vec<Path> = children_ids
.iter()
.rev()
.filter_map(|id| shapes.get(id).map(|child| child.to_path(shapes)))
.collect();
bool_fold(bool_type, &paths)
}
pub fn update_bool_to_path(shape: &mut Shape, shapes: ShapesPoolRef) {
let children_ids = shape.children_ids(true);
@@ -481,6 +542,7 @@ pub fn debug_render_bool_paths(
};
let mut current_path = child.to_path(shapes);
let is_clockwise_a = is_clockwise(&current_path);
for idx in (0..children_ids.len() - 1).rev() {
let Some(other) = shapes.get(&children_ids[idx]) else {
@@ -488,15 +550,12 @@ pub fn debug_render_bool_paths(
};
let other_path = other.to_path(shapes);
let (segs_a, segs_b) = split_segments(&current_path, &other_path);
let is_even_odd = current_path.is_even_odd() || other_path.is_even_odd();
let beziers = match bool_data.bool_type {
BoolType::Union => union(&current_path, segs_a, &other_path, segs_b),
BoolType::Difference => difference(&current_path, segs_a, &other_path, segs_b),
BoolType::Intersection => intersection(&current_path, segs_a, &other_path, segs_b),
BoolType::Exclusion => exclusion(segs_a, segs_b),
};
let (beziers, is_even_odd) = bool_beziers(
bool_data.bool_type,
&current_path,
is_clockwise_a,
&other_path,
);
current_path = Path::new(beziers_to_segments(&beziers)).with_even_odd(is_even_odd);
if idx == 0 {
@@ -572,3 +631,210 @@ pub fn debug_render_bool_paths(
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn linear(from: (f64, f64), to: (f64, f64)) -> Bezier {
Bezier::from_linear_coordinates(from.0, from.1, to.0, to.1)
}
fn polygon(points: &[(f32, f32)]) -> Path {
let mut segments = vec![Segment::MoveTo(points[0])];
segments.extend(points[1..].iter().map(|p| Segment::LineTo(*p)));
segments.push(Segment::Close);
Path::new(segments)
}
fn count(segments: &[Segment], f: fn(&Segment) -> bool) -> usize {
segments.iter().filter(|s| f(s)).count()
}
fn is_move_to(s: &Segment) -> bool {
matches!(s, Segment::MoveTo(_))
}
fn is_close(s: &Segment) -> bool {
matches!(s, Segment::Close)
}
fn ring_area(ring: &[(f32, f32)]) -> f64 {
let mut area = 0.0f64;
for i in 0..ring.len() {
let (x1, y1) = ring[i];
let (x2, y2) = ring[(i + 1) % ring.len()];
area += f64::from(x1) * f64::from(y2) - f64::from(x2) * f64::from(y1);
}
area / 2.0
}
fn signed_area(segments: &[Segment]) -> f64 {
let mut total = 0.0f64;
let mut ring: Vec<(f32, f32)> = Vec::new();
for segment in segments {
match *segment {
Segment::MoveTo(p) => {
total += ring_area(&ring);
ring.clear();
ring.push(p);
}
Segment::LineTo(p) | Segment::CurveTo((_, _, p)) => ring.push(p),
Segment::Close => {
total += ring_area(&ring);
ring.clear();
}
}
}
total + ring_area(&ring)
}
// Operands and expected results taken from the CLJS bool (`app.common.types.path.bool`)
// run on the same shapes: A clockwise, B and C counter-clockwise, all overlapping.
const A_CW: [(f32, f32); 4] = [
(100.0, 100.0),
(300.0, 100.0),
(300.0, 300.0),
(100.0, 300.0),
];
const B_CCW: [(f32, f32); 4] = [
(200.0, 200.0),
(200.0, 400.0),
(400.0, 400.0),
(400.0, 200.0),
];
const C_CCW: [(f32, f32); 4] = [(60.0, 240.0), (60.0, 360.0), (260.0, 360.0), (260.0, 240.0)];
#[test]
fn test_is_clockwise() {
let cw = Path::new(vec![
Segment::MoveTo((0.0, 0.0)),
Segment::LineTo((10.0, 0.0)),
Segment::LineTo((10.0, 10.0)),
Segment::LineTo((0.0, 10.0)),
Segment::Close,
]);
assert!(is_clockwise(&cw));
let ccw = Path::new(vec![
Segment::MoveTo((0.0, 0.0)),
Segment::LineTo((0.0, 10.0)),
Segment::LineTo((10.0, 10.0)),
Segment::LineTo((10.0, 0.0)),
Segment::Close,
]);
assert!(!is_clockwise(&ccw));
}
#[test]
fn test_should_reverse_b_only_depends_on_relative_winding() {
let cw = Path::new(vec![
Segment::MoveTo((0.0, 0.0)),
Segment::LineTo((10.0, 0.0)),
Segment::LineTo((10.0, 10.0)),
Segment::LineTo((0.0, 10.0)),
Segment::Close,
]);
let ccw = Path::new(vec![
Segment::MoveTo((0.0, 0.0)),
Segment::LineTo((0.0, 10.0)),
Segment::LineTo((10.0, 10.0)),
Segment::LineTo((10.0, 0.0)),
Segment::Close,
]);
assert!(should_reverse_b(BoolType::Difference, true, &cw));
assert!(!should_reverse_b(BoolType::Difference, true, &ccw));
assert!(!should_reverse_b(BoolType::Union, true, &cw));
assert!(should_reverse_b(BoolType::Union, true, &ccw));
}
// Fragments from #11482: two point the wrong way, so joining them start-to-start only
// left five open subpaths.
#[test]
fn test_beziers_to_segments_closes_reversed_fragments() {
let beziers = vec![
(
BezierSource::A,
linear((2764.00, -240.00), (2834.74, -110.71)),
),
(
BezierSource::A,
linear((2809.29, -85.26), (2693.26, -201.29)),
),
(
BezierSource::A,
linear((2718.71, -226.74), (2764.00, -240.00)),
),
(
BezierSource::B,
linear((2718.71, -226.74), (2834.74, -110.71)),
),
(
BezierSource::B,
linear((2809.29, -85.26), (2693.26, -201.29)),
),
];
let segments = beziers_to_segments(&beziers);
let moves = segments
.iter()
.filter(|s| matches!(s, Segment::MoveTo(_)))
.count();
let closes = segments
.iter()
.filter(|s| matches!(s, Segment::Close))
.count();
assert_eq!(moves, 2);
assert_eq!(closes, 2);
// 3 fragments in the first subpath, 2 in the second, each dropping its closing LineTo.
assert_eq!(segments.len(), 7);
}
// #11482: A clockwise, B counter-clockwise. Reference (CLJS):
// M100,100 L300,100 L300,200 L200,200 L200,300 L100,300 Z
#[test]
fn test_difference_with_opposite_winding_operand() {
let result = bool_fold(BoolType::Difference, &[polygon(&A_CW), polygon(&B_CCW)]);
let segments = result.segments();
assert_eq!(count(segments, is_move_to), 1);
assert_eq!(count(segments, is_close), 1);
assert!((signed_area(segments) - 30000.0).abs() < 1.0);
assert!(is_clockwise(&result));
}
// Reference (CLJS):
// M100,100 L300,100 L300,200 L400,200 L400,400 L200,400 L200,300 L100,300 Z
#[test]
fn test_union_with_opposite_winding_operand() {
let result = bool_fold(BoolType::Union, &[polygon(&A_CW), polygon(&B_CCW)]);
let segments = result.segments();
assert_eq!(count(segments, is_move_to), 1);
assert_eq!(count(segments, is_close), 1);
assert!((signed_area(segments) - 70000.0).abs() < 1.0);
assert!(is_clockwise(&result));
}
// The second fold step must compare against A's winding, not against the winding of
// the intermediate path, whose subpath order and direction fall out of pool ordering.
// Reference (CLJS): M100,100 L300,100 L300,200 L200,200 L200,240 L100,240 Z
#[test]
fn test_difference_folds_three_opposite_winding_operands() {
let result = bool_fold(
BoolType::Difference,
&[polygon(&A_CW), polygon(&B_CCW), polygon(&C_CCW)],
);
let segments = result.segments();
assert_eq!(count(segments, is_move_to), 1);
assert_eq!(count(segments, is_close), 1);
assert!((signed_area(segments) - 24000.0).abs() < 1.0);
assert!(is_clockwise(&result));
}
}
+3 -21
View File
@@ -3,8 +3,7 @@ use skia_safe::{self as skia, Paint, RRect};
use super::{filters, RenderState, SurfaceId};
use crate::error::Result;
use crate::get_resources;
use crate::math::Rect as MathRect;
use crate::render::get_source_rect;
use crate::render::{get_image_dest_rect, get_source_rect};
use crate::shapes::{merge_fills, Fill, Frame, ImageFill, Rect, Shape, Type};
// Set the clipping area to the shape outline within the container bounds
@@ -94,15 +93,7 @@ fn draw_image_fill(
let container = &shape.selrect;
let sampling = get_resources().sampling_options;
let dest_rect = match image_fill.transform() {
Some(tf) => MathRect::from_xywh(
container.left + tf.x * container.width(),
container.top + tf.y * container.height(),
tf.width * container.width(),
tf.height * container.height(),
),
None => *container,
};
let dest_rect = get_image_dest_rect(container, image_fill);
let src_rect = get_source_rect(size, &dest_rect, image_fill);
let needs_clip = image_fill.transform().is_some() || !is_axis_aligned_image_rect(shape);
@@ -189,16 +180,7 @@ fn draw_svg_image_fill(
let fill_layer = skia::canvas::SaveLayerRec::default().paint(paint);
canvas.save_layer(&fill_layer);
let dest_rect = match image_fill.transform() {
Some(tf) => MathRect::from_xywh(
container.left + tf.x * container.width(),
container.top + tf.y * container.height(),
tf.width * container.width(),
tf.height * container.height(),
),
None => *container,
};
let dest_rect = get_image_dest_rect(container, image_fill);
let src_rect = get_source_rect(size, &dest_rect, image_fill);
if src_rect.width() <= 0.0 || src_rect.height() <= 0.0 {
canvas.restore();
+12
View File
@@ -20,6 +20,18 @@ pub fn get_dest_rect(container: &MathRect, delta: f32) -> MathRect {
)
}
pub fn get_image_dest_rect(container: &MathRect, image_fill: &ImageFill) -> MathRect {
match image_fill.transform() {
Some(tf) => MathRect::from_xywh(
container.left + tf.x * container.width(),
container.top + tf.y * container.height(),
tf.width * container.width(),
tf.height * container.height(),
),
None => *container,
}
}
pub fn get_source_rect(size: ISize, container: &MathRect, image_fill: &ImageFill) -> MathRect {
let image_width = size.width as f32;
let image_height = size.height as f32;
+10 -8
View File
@@ -5,7 +5,8 @@ use crate::shapes::{Fill, ImageFill, Shape};
use crate::state::ShapesPoolRef;
use super::document::SvgLayerCanvas;
use crate::render::RenderResources;
use crate::math::Rect as MathRect;
use crate::render::{get_image_dest_rect, RenderResources};
/// Emits fills bottom -> top for SVG export.
///
@@ -63,19 +64,20 @@ fn emit_image_fill(
let clip_id = builder.unique("imgclip");
builder.push_clip_path(&clip_id, shape, tree);
let href = xml_escape_attr(url);
emit_linked_image_element(builder, shape, image_fill, &href, &clip_id);
let dest_rect = get_image_dest_rect(&shape.selrect(), image_fill);
emit_linked_image_element(builder, shape, image_fill, dest_rect, &href, &clip_id);
Ok(())
}
/// Emits `<g clip-path>` + `<image href>` using the shape selrect and page CTM.
/// Emits `<g clip-path>` + `<image href>` at `dest_rect`, under the page CTM.
pub(super) fn emit_linked_image_element(
builder: &mut SvgLayerCanvas,
shape: &Shape,
image_fill: &ImageFill,
dest_rect: MathRect,
href: &str,
clip_id: &str,
) {
let selrect = shape.selrect();
let opacity = image_fill.opacity() as f32 / 255.0;
let preserve = if image_fill.keep_aspect_ratio() {
"xMidYMid slice"
@@ -93,10 +95,10 @@ pub(super) fn emit_linked_image_element(
builder.open_group(&format!("clip-path=\"url(#{clip_id})\""));
builder.push_raw(&format!(
r#"<image href="{href}" x="{}" y="{}" width="{}" height="{}" preserveAspectRatio="{preserve}"{opacity_attr} transform="{transform}"/>"#,
selrect.left(),
selrect.top(),
selrect.width(),
selrect.height(),
dest_rect.left(),
dest_rect.top(),
dest_rect.width(),
dest_rect.height(),
));
builder.close_group();
}
+40 -1
View File
@@ -1,6 +1,8 @@
use super::fixtures::*;
use crate::shapes::{BlendMode, Fill, ImageFill, SolidColor, StrokeCap, StrokeKind};
use crate::shapes::{
BlendMode, Fill, ImageFill, ImageFillTransform, SolidColor, StrokeCap, StrokeKind,
};
use crate::state::ShapesPool;
use crate::uuid::Uuid;
@@ -1076,6 +1078,43 @@ fn exports_image_fill_as_linked_image() {
insta::assert_snapshot!(svg);
}
#[test]
fn exports_image_fill_bounds_transform() {
let mut pool = ShapesPool::new();
let id = uid(1);
let image_id = uid(42);
add_rect_with_fills(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 100.0, 80.0),
vec![Fill::Image(ImageFill::new_with_transform(
image_id,
255,
200,
100,
false,
Some(ImageFillTransform {
x: 0.25,
y: 0.5,
width: 0.5,
height: 0.25,
}),
))],
);
let svg = render_with(&pool, id, |resources| {
resources
.images
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
});
assert!(
svg.contains(r#"x="25" y="40" width="50" height="20""#),
"linked image must keep the independent image bounds: {svg}"
);
}
#[test]
fn exports_mixed_solid_and_image_fills_in_order() {
// Image under a translucent solid; stretch (keep-aspect off); partial image
+1 -1
View File
@@ -117,6 +117,6 @@ fn emit_text_image_fill(
builder.finish_clip_path_fragment(&clip_id, canvas);
let href = xml_escape_attr(url);
emit_linked_image_element(builder, shape, image_fill, &href, &clip_id);
emit_linked_image_element(builder, shape, image_fill, shape.selrect(), &href, &clip_id);
Ok(())
}
+3 -3
View File
@@ -12,7 +12,7 @@ use super::shape_renderer::ShapeRenderer;
use super::text;
use super::RenderResources;
use super::RenderState;
use super::{get_dest_rect, get_source_rect};
use super::{get_dest_rect, get_image_dest_rect, get_source_rect};
// ---------------------------------------------------------------------------
// VectorRenderer — implements ShapeRenderer for canvas-based vector export
@@ -1043,8 +1043,8 @@ fn draw_image_fill(
let size = image.dimensions();
let container = &shape.selrect;
let src_rect = get_source_rect(size, container, image_fill);
let dest_rect = container;
let dest_rect = get_image_dest_rect(container, image_fill);
let src_rect = get_source_rect(size, &dest_rect, image_fill);
canvas.save();
+5 -3
View File
@@ -173,9 +173,11 @@ main() {
fi
done
# Get tracked files matching our extensions
local files
files=$(git ls-files | grep -E "\.(${EXTENSIONS})$" || true)
# Get tracked files matching our extensions, excluding this script itself
# (it mentions the search string in its own usage docs).
local files script_name
script_name=$(basename "$0")
files=$(git ls-files | grep -E "\.(${EXTENSIONS})$" | grep -v -F "$script_name" || true)
if [[ -z "$files" ]]; then
log_warn "No tracked files found matching extensions: ${EXTENSIONS}"