mirror of
https://github.com/penpot/penpot.git
synced 2026-09-09 20:29:02 -04:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61caefbcf3 | ||
|
|
6586631293 | ||
|
|
66fb4a69ba |
No files matched your search
@@ -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}}
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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))
|
||||
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
@@ -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(¤t_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(¤t_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(¤t_path, segs_a, &other_path, segs_b),
|
||||
BoolType::Difference => difference(¤t_path, segs_a, &other_path, segs_b),
|
||||
BoolType::Intersection => intersection(¤t_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, ¤t_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(¤t_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(¤t_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(¤t_path, segs_a, &other_path, segs_b),
|
||||
BoolType::Difference => difference(¤t_path, segs_a, &other_path, segs_b),
|
||||
BoolType::Intersection => intersection(¤t_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,
|
||||
¤t_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,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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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}"
|
||||
|
||||
Reference in new issue
Block a user