From 5fca9457cf6b3c08b2c117ce99bd180bb47828dd Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 26 Mar 2026 12:45:42 +0100 Subject: [PATCH 01/15] :recycle: Extract use-portal-container hook to reduce duplication (#8798) The dedicated-container portal pattern was repeated across 6 components. Extract it into a reusable use-portal-container hook under app.main.ui.hooks. --- frontend/src/app/main/ui/components/portal.cljs | 8 ++------ frontend/src/app/main/ui/ds/tooltip/tooltip.cljs | 5 ++++- frontend/src/app/main/ui/hooks.cljs | 12 ++++++++++++ frontend/src/app/main/ui/modal.cljs | 10 ++++++---- .../ui/workspace/tokens/management/context_menu.cljs | 6 ++++-- .../tokens/management/node_context_menu.cljs | 4 +++- .../ui/workspace/tokens/themes/theme_selector.cljs | 7 +++++-- 7 files changed, 36 insertions(+), 16 deletions(-) diff --git a/frontend/src/app/main/ui/components/portal.cljs b/frontend/src/app/main/ui/components/portal.cljs index ff9f3558d4..381db4b66c 100644 --- a/frontend/src/app/main/ui/components/portal.cljs +++ b/frontend/src/app/main/ui/components/portal.cljs @@ -6,16 +6,12 @@ (ns app.main.ui.components.portal (:require - [app.util.dom :as dom] + [app.main.ui.hooks :as hooks] [rumext.v2 :as mf])) (mf/defc portal-on-document* [{:keys [children]}] - (let [container (mf/use-memo #(dom/create-element "div"))] - (mf/with-effect [] - (let [body (dom/get-body)] - (dom/append-child! body container) - #(dom/remove-child! body container))) + (let [container (hooks/use-portal-container)] (mf/portal (mf/html [:* children]) container))) diff --git a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs index 087649ca63..4751d81dcf 100644 --- a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs +++ b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs @@ -9,6 +9,7 @@ [app.main.style :as stl]) (:require [app.common.data :as d] + [app.main.ui.hooks :as hooks] [app.util.dom :as dom] [app.util.keyboard :as kbd] [app.util.timers :as ts] @@ -159,6 +160,8 @@ tooltip-ref (mf/use-ref nil) + container (hooks/use-portal-container) + id (d/nilv id internal-id) @@ -283,4 +286,4 @@ [:div {:class (stl/css :tooltip-content)} content] [:div {:class (stl/css :tooltip-arrow) :id "tooltip-arrow"}]]]) - (.-body js/document)))])) + container))])) diff --git a/frontend/src/app/main/ui/hooks.cljs b/frontend/src/app/main/ui/hooks.cljs index b4ad8fe616..42560cd8fe 100644 --- a/frontend/src/app/main/ui/hooks.cljs +++ b/frontend/src/app/main/ui/hooks.cljs @@ -380,6 +380,18 @@ state)) +(defn use-portal-container + "Creates a dedicated div container for React portals. The container + is appended to document.body on mount and removed on cleanup, preventing + removeChild race conditions when multiple portals target the same body." + [] + (let [container (mf/use-memo #(dom/create-element "div"))] + (mf/with-effect [] + (let [body (dom/get-body)] + (dom/append-child! body container) + #(dom/remove-child! body container))) + container)) + (defn use-dynamic-grid-item-width ([] (use-dynamic-grid-item-width nil)) ([itemsize] diff --git a/frontend/src/app/main/ui/modal.cljs b/frontend/src/app/main/ui/modal.cljs index 9d260de69e..5df1cc3daa 100644 --- a/frontend/src/app/main/ui/modal.cljs +++ b/frontend/src/app/main/ui/modal.cljs @@ -10,6 +10,7 @@ [app.common.data.macros :as dm] [app.main.data.modal :as modal] [app.main.store :as st] + [app.main.ui.hooks :as hooks] [app.util.dom :as dom] [app.util.keyboard :as k] [goog.events :as events] @@ -83,7 +84,8 @@ (mf/defc modal-container* {::mf/props :obj} [] - (when-let [modal (mf/deref ref:modal)] - (mf/portal - (mf/html [:> modal-wrapper* {:data modal :key (dm/str (:id modal))}]) - (dom/get-body)))) + (let [container (hooks/use-portal-container)] + (when-let [modal (mf/deref ref:modal)] + (mf/portal + (mf/html [:> modal-wrapper* {:data modal :key (dm/str (:id modal))}]) + container)))) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs index bcb44b83c5..ab0dc6326d 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs @@ -20,6 +20,7 @@ [app.main.store :as st] [app.main.ui.components.dropdown :refer [dropdown]] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] + [app.main.ui.hooks :as hooks] [app.util.dom :as dom] [app.util.i18n :refer [tr]] [app.util.timers :as timers] @@ -515,7 +516,8 @@ dropdown-direction (deref dropdown-direction*) dropdown-direction-change* (mf/use-ref 0) top (+ (get-in mdata [:position :y]) 5) - left (+ (get-in mdata [:position :x]) 5)] + left (+ (get-in mdata [:position :x]) 5) + container (hooks/use-portal-container)] (mf/use-effect (mf/deps is-open?) @@ -554,4 +556,4 @@ :on-context-menu prevent-default} (when mdata [:& token-context-menu-tree (assoc mdata :width @width :on-delete-token on-delete-token)])]]) - (dom/get-body))))) + container)))) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs b/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs index 4e272f7bdd..d37e628d02 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs @@ -6,6 +6,7 @@ [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.components.dropdown :refer [dropdown]] + [app.main.ui.hooks :as hooks] [app.util.dom :as dom] [app.util.i18n :refer [tr]] [okulary.core :as l] @@ -35,6 +36,7 @@ dropdown-direction-change* (mf/use-ref 0) top (+ (get-in mdata [:position :y]) 5) left (+ (get-in mdata [:position :x]) 5) + container (hooks/use-portal-container) delete-node (mf/use-fn (mf/deps mdata) @@ -80,4 +82,4 @@ :type "button" :on-click delete-node} (tr "labels.delete")]]])]]) - (dom/get-body))))) + container)))) diff --git a/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs b/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs index 3d799e0b59..a8687c9719 100644 --- a/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs @@ -17,6 +17,7 @@ [app.main.ui.components.dropdown :refer [dropdown]] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.ds.foundations.typography.text :refer [text*]] + [app.main.ui.hooks :as hooks] [app.util.dom :as dom] [app.util.i18n :refer [tr]] [cuerdas.core :as str] @@ -111,7 +112,9 @@ (let [rect (dom/get-bounding-rect node)] (swap! state* assoc :is-open? true - :rect rect))))))] + :rect rect)))))) + + container (hooks/use-portal-container)] [:div {:on-click on-open-dropdown :disabled (not can-edit?) @@ -140,4 +143,4 @@ [:& theme-options {:active-theme-paths active-theme-paths :themes themes :on-close on-close-dropdown}]]]) - (dom/get-body)))])) + container))])) From 2ba3605f1120d92eff3c27eb91da086303a0d679 Mon Sep 17 00:00:00 2001 From: Penpot Dev Date: Thu, 26 Mar 2026 13:08:37 +0100 Subject: [PATCH 02/15] :arrow_up: Update root repo deps --- package.json | 4 +- pnpm-lock.yaml | 324 ++++++++++++++++++++++++------------------------- 2 files changed, 164 insertions(+), 164 deletions(-) diff --git a/package.json b/package.json index b35ad1b50a..a67813f24b 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "devDependencies": { "@github/copilot": "^1.0.11", "@types/node": "^20.12.7", - "esbuild": "^0.25.9", - "opencode-ai": "^1.3.0" + "esbuild": "^0.27.4", + "opencode-ai": "^1.3.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d683348b6..44c73fd54e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,166 +15,166 @@ importers: specifier: ^20.12.7 version: 20.19.37 esbuild: - specifier: ^0.25.9 - version: 0.25.12 + specifier: ^0.27.4 + version: 0.27.4 opencode-ai: - specifier: ^1.3.0 - version: 1.3.0 + specifier: ^1.3.2 + version: 1.3.2 packages: - '@esbuild/aix-ppc64@0.25.12': - resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + '@esbuild/aix-ppc64@0.27.4': + resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.25.12': - resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + '@esbuild/android-arm64@0.27.4': + resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.25.12': - resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + '@esbuild/android-arm@0.27.4': + resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.25.12': - resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + '@esbuild/android-x64@0.27.4': + resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.25.12': - resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + '@esbuild/darwin-arm64@0.27.4': + resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.25.12': - resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + '@esbuild/darwin-x64@0.27.4': + resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.25.12': - resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + '@esbuild/freebsd-arm64@0.27.4': + resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.25.12': - resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + '@esbuild/freebsd-x64@0.27.4': + resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.25.12': - resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + '@esbuild/linux-arm64@0.27.4': + resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.25.12': - resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + '@esbuild/linux-arm@0.27.4': + resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.25.12': - resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + '@esbuild/linux-ia32@0.27.4': + resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.25.12': - resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + '@esbuild/linux-loong64@0.27.4': + resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.25.12': - resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + '@esbuild/linux-mips64el@0.27.4': + resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.25.12': - resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + '@esbuild/linux-ppc64@0.27.4': + resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.25.12': - resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + '@esbuild/linux-riscv64@0.27.4': + resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.25.12': - resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + '@esbuild/linux-s390x@0.27.4': + resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.25.12': - resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + '@esbuild/linux-x64@0.27.4': + resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.25.12': - resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + '@esbuild/netbsd-arm64@0.27.4': + resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.25.12': - resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + '@esbuild/netbsd-x64@0.27.4': + resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.25.12': - resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + '@esbuild/openbsd-arm64@0.27.4': + resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.25.12': - resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + '@esbuild/openbsd-x64@0.27.4': + resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.25.12': - resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + '@esbuild/openharmony-arm64@0.27.4': + resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.25.12': - resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + '@esbuild/sunos-x64@0.27.4': + resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.25.12': - resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + '@esbuild/win32-arm64@0.27.4': + resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.25.12': - resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + '@esbuild/win32-ia32@0.27.4': + resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.25.12': - resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + '@esbuild/win32-x64@0.27.4': + resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -222,72 +222,72 @@ packages: '@types/node@20.19.37': resolution: {integrity: sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==} - esbuild@0.25.12: - resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + esbuild@0.27.4: + resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} engines: {node: '>=18'} hasBin: true - opencode-ai@1.3.0: - resolution: {integrity: sha512-il/dC3B55m5mZV2u72emfPqkZBTzrlZwqGI4Ds5Ld6kt2LTUzBZtKB8sOfy7Bmw2qIel0hLZdoKc8wxLjaXQDw==} + opencode-ai@1.3.2: + resolution: {integrity: sha512-InyDAXKoh+fVxWrBMJZaf1xIYpASZ2zX4O/u7nwtiYzxy/kqHySvQe9jDVrhMgbMdb4CXzACid7M2HDUa+vz2Q==} hasBin: true - opencode-darwin-arm64@1.3.0: - resolution: {integrity: sha512-OB+yl/BZkjQhnjjFc+KT57iqhPlXNq3E0oIcHHlGiG63L2LTY3zfi9OhzaoemL+or2CWnpCITUe91yTAddiSEQ==} + opencode-darwin-arm64@1.3.2: + resolution: {integrity: sha512-/7V+J3XZGF/sCdMbEb5E3mUvuOIVvGkVjgYH1k/pnTfdGaPW/C7RgW2dU2HedXvkw4Y3CplUS+5VfA/F5kufXw==} cpu: [arm64] os: [darwin] - opencode-darwin-x64-baseline@1.3.0: - resolution: {integrity: sha512-Th5yiWOSDeEcjnKWhR8b267Uf8r+jwLFhv30JK4x07Zdmu3Jjjr6TdMvjLgEOv3PWmHf/1yYz22Xachb+QST0A==} + opencode-darwin-x64-baseline@1.3.2: + resolution: {integrity: sha512-jBusp8Vb1wsGKHD2AOD+Cr4qL4zSDut80Sy0CnMH8AwnSCNzaSVi2wnE/7vPrgCa44Xr0MknSE2S0wHQ5SbLaw==} cpu: [x64] os: [darwin] - opencode-darwin-x64@1.3.0: - resolution: {integrity: sha512-jivDUpmhzkT7WZp7pXVSb9fdnEVuhKBsnve/9fIkI/UFHxomiZ2NIaNRbHxG26PYT9a1IR4D5QvXBq623g2Mnw==} + opencode-darwin-x64@1.3.2: + resolution: {integrity: sha512-J7HgNBUoDpsKHAiky18aUm4xMKmUIJqlvVMkvL9NVjwDRMuAKnbYcxpRK8O+NKzeVx29IWwX8zizUwCuqXAAlA==} cpu: [x64] os: [darwin] - opencode-linux-arm64-musl@1.3.0: - resolution: {integrity: sha512-EmXBHyRSzWCnD/KDpaSi8ldgjOa+1t5c5tRASyL/lnbinsrZekxub3lI+oxRvKJXESKdgq9EP4gkp6t2fqGsFw==} + opencode-linux-arm64-musl@1.3.2: + resolution: {integrity: sha512-ZOB6+NvkZ19mzi6j4VAHtQDAeTXvQJqg4YtclT3wbMof8y3jn5S8vUmaXLk9d9FryEcdIoMK/oFLkcLkS1OXpA==} cpu: [arm64] os: [linux] - opencode-linux-arm64@1.3.0: - resolution: {integrity: sha512-rWEEKo4oqgJ/zk670ywg6uhEPwbUIQCwYCeh+xJ3IlgPltQNiIjqUbzbRqAmEfI1Uj9DCdbZ2TUtHayRv8umKw==} + opencode-linux-arm64@1.3.2: + resolution: {integrity: sha512-+q0PJ86LPitMIGf9sGXdtOcwpdiwbzdw91zXAHFe7rXZZm4Cvu9qjvt6WT/lKJqL8f6pyJs9Kbt4XJ/C20swQA==} cpu: [arm64] os: [linux] - opencode-linux-x64-baseline-musl@1.3.0: - resolution: {integrity: sha512-sb7LyPlf+5/t4pQ3whcHPVlb7R7SRY0Bgjgy55amEs3xRuKnC3BfSoj8CAoY50M/yVAbOj0haoxu4LFixljwNw==} + opencode-linux-x64-baseline-musl@1.3.2: + resolution: {integrity: sha512-8XYcAqZBcUgYHfae9qoTT8erqFjd6AKTrSu60gRgzTN8cUybG4iPyAVt4GUcXCzUw44Qo++d/8I9hwpUTCoFYg==} cpu: [x64] os: [linux] - opencode-linux-x64-baseline@1.3.0: - resolution: {integrity: sha512-STZtcgGgeRlaFCmkk+mNm+01d02JCzCPvP9kWwNpRF6FBGTcFZ97MxEoGvk+7mEqMueImVQZOR21NiYN6anQhw==} + opencode-linux-x64-baseline@1.3.2: + resolution: {integrity: sha512-QXiF1+PJH5AOHMSTdYngOB7yWULFQKJpIbx77ZHM1MKyxiBweIrZ03nG86iXIZIVGBxEfH1CcKDU28lFGUJdNg==} cpu: [x64] os: [linux] - opencode-linux-x64-musl@1.3.0: - resolution: {integrity: sha512-Jc/EbYgqmT2J2WLPm7EQWBYfSqetWTrI4Ipc4KFrSB/LbM/7lfXkjpemjQaYNlDTVkvPXaUPFJUpisH64xZ+4g==} + opencode-linux-x64-musl@1.3.2: + resolution: {integrity: sha512-o4aXgcWQidxQ6cJMsgLcCMv9MSKtLEqm3OAFm6eN7hVgHV9m7rz035BICNZ1YLltIEdQYAHXx5MGM21J4/vBdw==} cpu: [x64] os: [linux] - opencode-linux-x64@1.3.0: - resolution: {integrity: sha512-U9aS0wl0uBDxXncqSYhYBDDQP2ZwiTiuJSLM6MgtFJTbUXuTZZCKmQ8p7C5/+Nxpl4sY5xK+ZaCJcS3k3WGN3g==} + opencode-linux-x64@1.3.2: + resolution: {integrity: sha512-/+OtuHr2O0/QKpBrZBnutr0ZLe+LmOx003Z99b21BgG9wsVo7gFuNpfLPVXnqcEY6D5DuWl4aEJW4+w+K0urYw==} cpu: [x64] os: [linux] - opencode-windows-arm64@1.3.0: - resolution: {integrity: sha512-3iWo9lOctaWQ+8QHRKszINPTLjLtb0ztzedlvdY5HAiot9MUK/G5MHeskutxQ7sMvTACiAp02ey+Ml/f/jyf7Q==} + opencode-windows-arm64@1.3.2: + resolution: {integrity: sha512-3xJEjSLdk7I5Z6yyrT4LCQeK4VCj6mo/l4JdUa0zocV8wCzUtY1lSHeipDAaNr8TwD6NeTxr1iomIjuatKAFOA==} cpu: [arm64] os: [win32] - opencode-windows-x64-baseline@1.3.0: - resolution: {integrity: sha512-pYuY+9LqPLB/GrlZQr67Cl8RlV6vcay4fW8L3TjabwJOinFMDX9OpNo+DkdKJW7YtPtHD78cXaNDEV8tv9Nx2A==} + opencode-windows-x64-baseline@1.3.2: + resolution: {integrity: sha512-RVx9e1KBNTSWjrtfRpGIIcJDsM13OUY/lYqJXI1oj34lMOEj5G57fypjk9yBqUHBR+7tolGmewwJJU6SDgyPXQ==} cpu: [x64] os: [win32] - opencode-windows-x64@1.3.0: - resolution: {integrity: sha512-iFd/6GwfM3jlI2tOb3f12m5ddDY8Ug2HiUU1xmxWJvDnbDBdftlHrzD5twlbIHnKoGvohepX8iWk+A/UN2cXKQ==} + opencode-windows-x64@1.3.2: + resolution: {integrity: sha512-pJMpptERqz8pjnW1pTQf5Ru2WbJz7P2BPM5De5gdUd10y0yUoGvY7uzyJYuGpsrCqeWrEYokERnbv8A/4V3Yaw==} cpu: [x64] os: [win32] @@ -296,82 +296,82 @@ packages: snapshots: - '@esbuild/aix-ppc64@0.25.12': + '@esbuild/aix-ppc64@0.27.4': optional: true - '@esbuild/android-arm64@0.25.12': + '@esbuild/android-arm64@0.27.4': optional: true - '@esbuild/android-arm@0.25.12': + '@esbuild/android-arm@0.27.4': optional: true - '@esbuild/android-x64@0.25.12': + '@esbuild/android-x64@0.27.4': optional: true - '@esbuild/darwin-arm64@0.25.12': + '@esbuild/darwin-arm64@0.27.4': optional: true - '@esbuild/darwin-x64@0.25.12': + '@esbuild/darwin-x64@0.27.4': optional: true - '@esbuild/freebsd-arm64@0.25.12': + '@esbuild/freebsd-arm64@0.27.4': optional: true - '@esbuild/freebsd-x64@0.25.12': + '@esbuild/freebsd-x64@0.27.4': optional: true - '@esbuild/linux-arm64@0.25.12': + '@esbuild/linux-arm64@0.27.4': optional: true - '@esbuild/linux-arm@0.25.12': + '@esbuild/linux-arm@0.27.4': optional: true - '@esbuild/linux-ia32@0.25.12': + '@esbuild/linux-ia32@0.27.4': optional: true - '@esbuild/linux-loong64@0.25.12': + '@esbuild/linux-loong64@0.27.4': optional: true - '@esbuild/linux-mips64el@0.25.12': + '@esbuild/linux-mips64el@0.27.4': optional: true - '@esbuild/linux-ppc64@0.25.12': + '@esbuild/linux-ppc64@0.27.4': optional: true - '@esbuild/linux-riscv64@0.25.12': + '@esbuild/linux-riscv64@0.27.4': optional: true - '@esbuild/linux-s390x@0.25.12': + '@esbuild/linux-s390x@0.27.4': optional: true - '@esbuild/linux-x64@0.25.12': + '@esbuild/linux-x64@0.27.4': optional: true - '@esbuild/netbsd-arm64@0.25.12': + '@esbuild/netbsd-arm64@0.27.4': optional: true - '@esbuild/netbsd-x64@0.25.12': + '@esbuild/netbsd-x64@0.27.4': optional: true - '@esbuild/openbsd-arm64@0.25.12': + '@esbuild/openbsd-arm64@0.27.4': optional: true - '@esbuild/openbsd-x64@0.25.12': + '@esbuild/openbsd-x64@0.27.4': optional: true - '@esbuild/openharmony-arm64@0.25.12': + '@esbuild/openharmony-arm64@0.27.4': optional: true - '@esbuild/sunos-x64@0.25.12': + '@esbuild/sunos-x64@0.27.4': optional: true - '@esbuild/win32-arm64@0.25.12': + '@esbuild/win32-arm64@0.27.4': optional: true - '@esbuild/win32-ia32@0.25.12': + '@esbuild/win32-ia32@0.27.4': optional: true - '@esbuild/win32-x64@0.25.12': + '@esbuild/win32-x64@0.27.4': optional: true '@github/copilot-darwin-arm64@1.0.11': @@ -405,84 +405,84 @@ snapshots: dependencies: undici-types: 6.21.0 - esbuild@0.25.12: + esbuild@0.27.4: optionalDependencies: - '@esbuild/aix-ppc64': 0.25.12 - '@esbuild/android-arm': 0.25.12 - '@esbuild/android-arm64': 0.25.12 - '@esbuild/android-x64': 0.25.12 - '@esbuild/darwin-arm64': 0.25.12 - '@esbuild/darwin-x64': 0.25.12 - '@esbuild/freebsd-arm64': 0.25.12 - '@esbuild/freebsd-x64': 0.25.12 - '@esbuild/linux-arm': 0.25.12 - '@esbuild/linux-arm64': 0.25.12 - '@esbuild/linux-ia32': 0.25.12 - '@esbuild/linux-loong64': 0.25.12 - '@esbuild/linux-mips64el': 0.25.12 - '@esbuild/linux-ppc64': 0.25.12 - '@esbuild/linux-riscv64': 0.25.12 - '@esbuild/linux-s390x': 0.25.12 - '@esbuild/linux-x64': 0.25.12 - '@esbuild/netbsd-arm64': 0.25.12 - '@esbuild/netbsd-x64': 0.25.12 - '@esbuild/openbsd-arm64': 0.25.12 - '@esbuild/openbsd-x64': 0.25.12 - '@esbuild/openharmony-arm64': 0.25.12 - '@esbuild/sunos-x64': 0.25.12 - '@esbuild/win32-arm64': 0.25.12 - '@esbuild/win32-ia32': 0.25.12 - '@esbuild/win32-x64': 0.25.12 + '@esbuild/aix-ppc64': 0.27.4 + '@esbuild/android-arm': 0.27.4 + '@esbuild/android-arm64': 0.27.4 + '@esbuild/android-x64': 0.27.4 + '@esbuild/darwin-arm64': 0.27.4 + '@esbuild/darwin-x64': 0.27.4 + '@esbuild/freebsd-arm64': 0.27.4 + '@esbuild/freebsd-x64': 0.27.4 + '@esbuild/linux-arm': 0.27.4 + '@esbuild/linux-arm64': 0.27.4 + '@esbuild/linux-ia32': 0.27.4 + '@esbuild/linux-loong64': 0.27.4 + '@esbuild/linux-mips64el': 0.27.4 + '@esbuild/linux-ppc64': 0.27.4 + '@esbuild/linux-riscv64': 0.27.4 + '@esbuild/linux-s390x': 0.27.4 + '@esbuild/linux-x64': 0.27.4 + '@esbuild/netbsd-arm64': 0.27.4 + '@esbuild/netbsd-x64': 0.27.4 + '@esbuild/openbsd-arm64': 0.27.4 + '@esbuild/openbsd-x64': 0.27.4 + '@esbuild/openharmony-arm64': 0.27.4 + '@esbuild/sunos-x64': 0.27.4 + '@esbuild/win32-arm64': 0.27.4 + '@esbuild/win32-ia32': 0.27.4 + '@esbuild/win32-x64': 0.27.4 - opencode-ai@1.3.0: + opencode-ai@1.3.2: optionalDependencies: - opencode-darwin-arm64: 1.3.0 - opencode-darwin-x64: 1.3.0 - opencode-darwin-x64-baseline: 1.3.0 - opencode-linux-arm64: 1.3.0 - opencode-linux-arm64-musl: 1.3.0 - opencode-linux-x64: 1.3.0 - opencode-linux-x64-baseline: 1.3.0 - opencode-linux-x64-baseline-musl: 1.3.0 - opencode-linux-x64-musl: 1.3.0 - opencode-windows-arm64: 1.3.0 - opencode-windows-x64: 1.3.0 - opencode-windows-x64-baseline: 1.3.0 + opencode-darwin-arm64: 1.3.2 + opencode-darwin-x64: 1.3.2 + opencode-darwin-x64-baseline: 1.3.2 + opencode-linux-arm64: 1.3.2 + opencode-linux-arm64-musl: 1.3.2 + opencode-linux-x64: 1.3.2 + opencode-linux-x64-baseline: 1.3.2 + opencode-linux-x64-baseline-musl: 1.3.2 + opencode-linux-x64-musl: 1.3.2 + opencode-windows-arm64: 1.3.2 + opencode-windows-x64: 1.3.2 + opencode-windows-x64-baseline: 1.3.2 - opencode-darwin-arm64@1.3.0: + opencode-darwin-arm64@1.3.2: optional: true - opencode-darwin-x64-baseline@1.3.0: + opencode-darwin-x64-baseline@1.3.2: optional: true - opencode-darwin-x64@1.3.0: + opencode-darwin-x64@1.3.2: optional: true - opencode-linux-arm64-musl@1.3.0: + opencode-linux-arm64-musl@1.3.2: optional: true - opencode-linux-arm64@1.3.0: + opencode-linux-arm64@1.3.2: optional: true - opencode-linux-x64-baseline-musl@1.3.0: + opencode-linux-x64-baseline-musl@1.3.2: optional: true - opencode-linux-x64-baseline@1.3.0: + opencode-linux-x64-baseline@1.3.2: optional: true - opencode-linux-x64-musl@1.3.0: + opencode-linux-x64-musl@1.3.2: optional: true - opencode-linux-x64@1.3.0: + opencode-linux-x64@1.3.2: optional: true - opencode-windows-arm64@1.3.0: + opencode-windows-arm64@1.3.2: optional: true - opencode-windows-x64-baseline@1.3.0: + opencode-windows-x64-baseline@1.3.2: optional: true - opencode-windows-x64@1.3.0: + opencode-windows-x64@1.3.2: optional: true undici-types@6.21.0: {} From 945efdb0b40f1284663d6a6b3181a60ed616c6db Mon Sep 17 00:00:00 2001 From: Penpot Dev Date: Thu, 26 Mar 2026 13:08:53 +0100 Subject: [PATCH 03/15] :fire: Remove .opencode/skills I think they make ai agent work worse. --- .opencode/skills/backend/SKILL.md | 28 --------------------------- .opencode/skills/common/SKILL.md | 25 ------------------------ .opencode/skills/frontend/SKILL.md | 28 --------------------------- .opencode/skills/render-wasm/SKILL.md | 22 --------------------- 4 files changed, 103 deletions(-) delete mode 100644 .opencode/skills/backend/SKILL.md delete mode 100644 .opencode/skills/common/SKILL.md delete mode 100644 .opencode/skills/frontend/SKILL.md delete mode 100644 .opencode/skills/render-wasm/SKILL.md diff --git a/.opencode/skills/backend/SKILL.md b/.opencode/skills/backend/SKILL.md deleted file mode 100644 index d67681735b..0000000000 --- a/.opencode/skills/backend/SKILL.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: penpot-backend -description: Guidelines and workflows for the Penpot Clojure JVM backend. ---- - -# Penpot Backend Skill - -This skill provides guidelines and workflows for the Penpot Clojure JVM backend. - -## Testing & Validation -- **Isolated tests:** `clojure -M:dev:test --focus backend-tests.my-ns-test` (for a specific test namespace) -- **Regression tests:** `clojure -M:dev:test` (ensure the suite passes without regressions) -- **Eval expresion:** `clojure -M:dev -e "(here-the-expresion)"` - -## Code Quality -- **Linting:** `pnpm run lint:clj` -- **Formatting:** - - Check: `pnpm run check-fmt` - - Fix: `pnpm run fmt` -- **Type Hinting:** Use explicit JVM type hints (e.g., `^String`, `^long`) in performance-critical paths to avoid reflection overhead. - -## Architecture & Conventions -- Uses Integrant for dependency injection (`src/app/main.clj`). -- PostgreSQL for storage, Redis for messaging/caching. -- **RPC:** Commands are under `app.rpc.commands.*`. Use the `get-` prefix on RPC names when we want READ operations. -- **Database:** `app.db` wraps next.jdbc. Queries use a SQL builder. - - Helpers: `db/get`, `db/query`, `db/insert!`, `db/update!`, `db/delete!` -- **Performance Macros:** Always prefer these macros from `app.common.data.macros` over `clojure.core` equivalents: `dm/select-keys`, `dm/get-in`, `dm/str`. diff --git a/.opencode/skills/common/SKILL.md b/.opencode/skills/common/SKILL.md deleted file mode 100644 index a61c996ce0..0000000000 --- a/.opencode/skills/common/SKILL.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -name: penpot-common -description: Guidelines and workflows for the Penpot Common shared module. ---- - -# Penpot Common Skill - -This skill provides guidelines and workflows for the Penpot Common shared module (Clojure/ClojureScript/JS). - -## Testing & Validation -- **JS (Node) Isolated tests:** Edit `test/common_tests/runner.cljs` then run `pnpm run test:js` -- **JS (Node) Regression tests:** `pnpm run test:js` -- **JVM Isolated tests:** `pnpm run test:jvm --focus common-tests.my-ns-test` -- **JVM Regression tests:** `pnpm run test:jvm` - -## Code Quality -- **Linting:** `pnpm run lint:clj` -- **Formatting:** - - Check: `pnpm run check-fmt:clj`, `pnpm run check-fmt:js` - - Fix: `pnpm run fmt:clj`, `pnpm run fmt:js` - -## Architecture & Conventions -- Multiplatform code used by frontend, backend, and exporter. -- Uses Clojure reader conditionals (`#?(:clj ... :cljs ...)`). -- Modifying common code requires testing across consumers (frontend, backend, exporter). diff --git a/.opencode/skills/frontend/SKILL.md b/.opencode/skills/frontend/SKILL.md deleted file mode 100644 index 8adc82396a..0000000000 --- a/.opencode/skills/frontend/SKILL.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: penpot-frontend -description: Guidelines and workflows for the Penpot ClojureScript React frontend. ---- - -# Penpot Frontend Skill - -This skill provides guidelines and workflows for the Penpot ClojureScript React frontend. - -## Testing & Validation -- **Isolated tests:** Edit `test/frontend_tests/runner.cljs` to narrow the test suite, then run `pnpm run test` -- **Regression tests:** `pnpm run test` (without modifications on the runner) -- **Integration tests:** `pnpm run test:e2e` or `pnpm run test:e2e --grep "pattern"` (do not modify e2e tests unless explicitly asked). - -## Code Quality -- **Linting:** - - `pnpm run lint:clj` - - `pnpm run lint:js` - - `pnpm run lint:scss` -- **Formatting:** - - Check: `pnpm run check-fmt:clj`, `pnpm run check-fmt:js`, `pnpm run check-fmt:scss` - - Fix: `pnpm run fmt:clj`, `pnpm run fmt:js`, `pnpm run fmt:scss` - -## Architecture & Conventions -- Uses React and RxJS (Potok for state management). -- Modern components use the `*` suffix (e.g., `my-component*`) and the `mf/defc` macro. -- Hooks: `mf/use-state`, `mf/use-effect`, `mf/use-memo`, `mf/use-fn`. Prefer macros `mf/with-effect` and `mf/with-memo`. -- Styles: Use CSS custom properties from `_sizes.scss` and tokens from `ds/colors.scss`. Avoid deep selector nesting. diff --git a/.opencode/skills/render-wasm/SKILL.md b/.opencode/skills/render-wasm/SKILL.md deleted file mode 100644 index 3b6763d1a1..0000000000 --- a/.opencode/skills/render-wasm/SKILL.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -name: penpot-render-wasm -description: Guidelines and workflows for the Penpot Rust to WebAssembly renderer. ---- - -# Penpot Render-WASM Skill - -This skill provides guidelines and workflows for the Penpot Rust to WebAssembly renderer. - -## Commands -- **Build:** `./build` (Compiles Rust → WASM. Requires Emscripten environment. Automatically sources `_build_env`) -- **Watch:** `./watch` (Incremental rebuild on file change) -- **Test (All):** `./test` (Runs cargo test) -- **Test (Single):** `cargo test my_test_name` or `cargo test shapes::` -- **Lint:** `./lint` (`clippy -D warnings`) -- **Format:** `cargo fmt --check` - -## Architecture & Conventions -- **Global state:** Accessed EXCLUSIVELY through `with_state!` / `with_state_mut!` macros. Never access `unsafe static mut State` directly. -- **Tile-based rendering:** Only 512×512 tiles within the viewport are drawn each frame. -- **Two-phase updates:** Shape data is written via exported setter functions, then a single `render_frame()` triggers the actual Skia draw calls. -- **Frontend Integration:** The WASM module is loaded by `app.render-wasm.*` namespaces. Do not change export function signatures without updating the corresponding ClojureScript bridge. From 1a4ca6d04bd7050fdd88ee29d50e92beebe28480 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 26 Mar 2026 13:47:20 +0100 Subject: [PATCH 04/15] :books: Update frontend/AGENTS.md file --- frontend/AGENTS.md | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index b4ad811522..0e33a32f30 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -5,10 +5,6 @@ architectural pieces. ## General Guidelines -To ensure consistency across the Penpot stack, all contributions must adhere to -these criteria: - - ### 1. Testing & Validation #### Unit Tests @@ -71,6 +67,29 @@ Ensure everything is installed before executing tests with the `./scripts/setup` function in the same namespace if it is only used locally, or look for a helper namespace to make it unit-testable. +### 4. Stack Trace Analysis + +When analyzing production stack traces (minified code), you can generate a +production bundle locally to map the minified code back to the source. + +**To build the production bundle:** + +Run: `pnpm run build:app` + +The compiled files and their corresponding source maps will be generated in +`resources/public/js`. + +**Analysis Tips:** + +- **Source Maps:** Use the `.map` files generated in `resources/public/js` with + tools like `source-map-lookup` or browser dev tools to resolve minified + locations. +- **Bundle Inspection:** If the issue is related to bundle size or unexpected + code inclusion, inspect the generated modules in `resources/public/js`. +- **Shadow-CLJS Reports:** For more detailed analysis of what is included in the + bundle, you can run shadow-cljs build reports (consult `shadow-cljs.edn` for + build IDs like `main` or `worker`). + ## Code Conventions From 3eaf67a38599db3d158ee09cffac42df140a081f Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 26 Mar 2026 14:13:38 +0100 Subject: [PATCH 05/15] :bug: Fix fetch abort errors escaping the unhandled exception handler (#8801) When AbortController.abort(reason) is called with a custom reason (a ClojureScript ExceptionInfo), modern browsers (Chrome 98+, Firefox 97+) reject the fetch promise with that reason object directly instead of with the canonical DOMException{name:'AbortError'}. The ExceptionInfo has .name === 'Error', so both the p/catch guard and is-ignorable-exception? failed to recognise it as an abort, letting it surface to users as an error toast. Fix by calling .abort() without a reason so the browser always produces a native DOMException whose .name is 'AbortError', which is correctly handled by all existing guards. Also add a defense-in-depth check in is-ignorable-exception? that filters errors whose message matches the 'fetch to \'' prefix, guarding against any future re-introduction of a custom abort reason. Co-authored-by: Penpot Dev --- frontend/src/app/main/errors.cljs | 9 +++++---- frontend/src/app/util/http.cljs | 16 +++++++++++----- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index 63e33e4aff..745f1adec7 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -355,10 +355,11 @@ (= message "Unexpected end of input") (str/starts-with? message "invalid props on component") (str/starts-with? message "Unexpected token ") - ;; Abort errors are expected when an in-flight HTTP request is - ;; cancelled (e.g. via RxJS unsubscription / take-until). They - ;; are handled gracefully inside app.util.http/fetch and must - ;; NOT be surfaced as application errors. + ;; Native AbortError DOMException: raised when an in-flight + ;; HTTP fetch is cancelled via AbortController (e.g. by an + ;; RxJS unsubscription / take-until chain). These are + ;; handled gracefully inside app.util.http/fetch and must NOT + ;; be surfaced as application errors. (= (.-name ^js cause) "AbortError")))) (on-unhandled-error [event] diff --git a/frontend/src/app/util/http.cljs b/frontend/src/app/util/http.cljs index 34971caaef..ae8237a943 100644 --- a/frontend/src/app/util/http.cljs +++ b/frontend/src/app/util/http.cljs @@ -127,11 +127,17 @@ (fn [] (vreset! unsubscribed? true) (when @abortable? - ;; Provide an explicit reason so that the resulting AbortError carries - ;; a meaningful message instead of the browser default - ;; "signal is aborted without reason". - (.abort ^js controller (ex-info (str "fetch to '" uri "' is aborted") - {:uri uri})))))))) + ;; Do NOT pass a custom reason to .abort(): browsers that support + ;; AbortController reason (Chrome 98+, Firefox 97+) would reject + ;; the fetch promise with the supplied value directly. When that + ;; value is a ClojureScript ExceptionInfo its `.name` property is + ;; "Error", not "AbortError", which defeats every existing guard + ;; that checks `(= (.-name cause) "AbortError")`. Calling .abort + ;; without a reason always produces a native DOMException whose + ;; `.name` is "AbortError", which is correctly recognised and + ;; suppressed by both the p/catch handler and the global + ;; unhandled-exception filter. + (.abort ^js controller))))))) (defn response->map [response] From 0ad3ae0620a7955eb6264b2e6b596f45ff14e62b Mon Sep 17 00:00:00 2001 From: Penpot Dev Date: Thu, 26 Mar 2026 14:41:48 +0100 Subject: [PATCH 06/15] :books: Add explicit commit guideline to builtin agents --- .opencode/agents/engineer.md | 4 ++++ .opencode/agents/testing.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/.opencode/agents/engineer.md b/.opencode/agents/engineer.md index d31fd17f88..b85205f031 100644 --- a/.opencode/agents/engineer.md +++ b/.opencode/agents/engineer.md @@ -31,3 +31,7 @@ Requirements: exact commands). * Make small and logical commits following the commit guideline described in `CONTRIBUTING.md`. Commit only when explicitly asked. +- Do not guess or hallucinate git author information (Name or Email). Never include the + `--author` flag in git commands unless specifically instructed by the user for a unique + case; assume the local environment is already configured. Allow git commit to + automatically pull the identity from the local git config `user.name` and `user.email`. diff --git a/.opencode/agents/testing.md b/.opencode/agents/testing.md index 299b5a7112..17c19aade1 100644 --- a/.opencode/agents/testing.md +++ b/.opencode/agents/testing.md @@ -31,3 +31,7 @@ Requirements: commands). * Make small and logical commits following the commit guideline described in `CONTRIBUTING.md`. Commit only when explicitly asked. +- Do not guess or hallucinate git author information (Name or Email). Never include the + `--author` flag in git commands unless specifically instructed by the user for a unique + case; assume the local environment is already configured. Allow git commit to + automatically pull the identity from the local git config `user.name` and `user.email`. From 0dfa62a5b6708ef1790126fbd793e6034a6a0f0c Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 26 Mar 2026 15:42:49 +0100 Subject: [PATCH 07/15] :bug: Improve error reporting on request parsing failures (#8805) Include request URI and status in frontend handle-response error data, and add request path/context to backend IOException handler logs and response body. Previously these errors had no identifying information about which endpoint or request caused the failure. --- backend/src/app/http/errors.clj | 14 ++++++++------ frontend/src/app/main/repo.cljs | 2 +- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/backend/src/app/http/errors.clj b/backend/src/app/http/errors.clj index 95e0f4e7b1..bcf6df27f6 100644 --- a/backend/src/app/http/errors.clj +++ b/backend/src/app/http/errors.clj @@ -220,12 +220,14 @@ (assoc :hint (ex-message error)))})))) (defmethod handle-exception java.io.IOException - [cause _ _] - (l/wrn :hint "io exception" :cause cause) - {::yres/status 500 - ::yres/body {:type :server-error - :code :io-exception - :hint (ex-message cause)}}) + [cause request _] + (binding [l/*context* (request->context request)] + (l/wrn :hint "io exception" :cause cause) + {::yres/status 500 + ::yres/body {:type :server-error + :code :io-exception + :hint (ex-message cause) + :path (:path request)}})) (defmethod handle-exception java.util.concurrent.CompletionException [cause request _] diff --git a/frontend/src/app/main/repo.cljs b/frontend/src/app/main/repo.cljs index ad252e2a04..c7a3133558 100644 --- a/frontend/src/app/main/repo.cljs +++ b/frontend/src/app/main/repo.cljs @@ -53,7 +53,7 @@ :code :challenge-required})) (and (>= status 400) (map? body)) - (rx/throw (ex-info "http error" body)) + (rx/throw (ex-info "http error" (assoc body :uri uri :status status))) :else (rx/throw From 6db3c6cf89a971faeb323762b03455608cd54abf Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 26 Mar 2026 15:43:30 +0100 Subject: [PATCH 08/15] :bug: Fix regression on subpath support (#8793) --- docker/devenv/files/Caddyfile | 4 ++++ frontend/src/app/config.cljs | 11 +++++------ frontend/src/app/main/ui/dashboard/templates.cljs | 7 ++++--- frontend/src/app/render_wasm/api/fonts.cljs | 4 ++-- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/docker/devenv/files/Caddyfile b/docker/devenv/files/Caddyfile index eb822a91c3..eda140d5e9 100644 --- a/docker/devenv/files/Caddyfile +++ b/docker/devenv/files/Caddyfile @@ -9,6 +9,10 @@ localhost:3449 { } http://localhost:3450 { + # For subpath test + # handle_path /penpot/* { + # reverse_proxy localhost:4449 + # } reverse_proxy localhost:4449 } diff --git a/frontend/src/app/config.cljs b/frontend/src/app/config.cljs index c32c76bbb8..a0c5680204 100644 --- a/frontend/src/app/config.cljs +++ b/frontend/src/app/config.cljs @@ -179,12 +179,11 @@ ([media] (resolve-file-media media false)) ([{:keys [id data-uri] :as media} thumbnail?] - (if data-uri - data-uri - (dm/str - (cond-> (u/join public-uri "assets/by-file-media-id/") - (true? thumbnail?) (u/join (dm/str id "/thumbnail")) - (false? thumbnail?) (u/join (dm/str id))))))) + (or data-uri + (dm/str + (cond-> (u/join public-uri "assets/by-file-media-id/") + (true? thumbnail?) (u/join (dm/str id "/thumbnail")) + (false? thumbnail?) (u/join (dm/str id))))))) (defn resolve-href [resource] diff --git a/frontend/src/app/main/ui/dashboard/templates.cljs b/frontend/src/app/main/ui/dashboard/templates.cljs index ece2e146cf..d5f84c2862 100644 --- a/frontend/src/app/main/ui/dashboard/templates.cljs +++ b/frontend/src/app/main/ui/dashboard/templates.cljs @@ -8,6 +8,7 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.data.macros :as dm] + [app.common.uri :as u] [app.config :as cf] [app.main.data.common :as dcm] [app.main.data.dashboard :as dd] @@ -95,8 +96,8 @@ (mf/defc card-item {::mf/wrap-props false} [{:keys [item index is-visible collapsed on-import]}] - (let [id (dm/str "card-container-" index) - thb (assoc cf/public-uri :path (dm/str "/images/thumbnails/template-" (:id item) ".jpg")) + (let [id (dm/str "card-container-" index) + href (u/join cf/public-uri (dm/str "images/thumbnails/template-" (:id item) ".jpg")) hover? (mf/use-state false) on-click @@ -124,7 +125,7 @@ :on-mouse-leave #(reset! hover? false) :on-key-down on-key-down} [:div {:class (stl/css :img-container)} - [:img {:src (dm/str thb) + [:img {:src (dm/str href) :alt (:name item) :loading "lazy" :decoding "async"}]] diff --git a/frontend/src/app/render_wasm/api/fonts.cljs b/frontend/src/app/render_wasm/api/fonts.cljs index 8c73f85e2d..d8db746764 100644 --- a/frontend/src/app/render_wasm/api/fonts.cljs +++ b/frontend/src/app/render_wasm/api/fonts.cljs @@ -32,7 +32,7 @@ (defn- google-font-id->uuid "Returns the UUID for a Google Font ID. Uses uuid/zero as fallback when the - font is not found in fontsdb. uuid/zero maps to the default font (Source + font is not found in fontsdb. uuid/zero maps to the default font (Source Sans Pro) in WASM. A font id may not exist for different reasons: - the gfonts.json catalog was updated and fonts were renamed or removed, @@ -152,7 +152,7 @@ [font-id font-variant-id font-weight font-style] (let [variant (font-db-data font-id font-variant-id font-weight font-style)] (if-let [ttf-url (:ttf-url variant)] - (str/replace ttf-url "https://fonts.gstatic.com/s/" (u/join cf/public-uri "/internal/gfonts/font/")) + (str/replace ttf-url "https://fonts.gstatic.com/s/" (u/join cf/public-uri "internal/gfonts/font/")) nil))) (defn- font-id->ttf-url From 4174d6a05b3afc9fabaddb8c11dbbca0fb52fc74 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 26 Mar 2026 19:44:49 +0100 Subject: [PATCH 09/15] :tada: Add tests for undo-stack helper function on common (#8766) --- common/test/common_tests/runner.cljc | 2 + common/test/common_tests/undo_stack_test.cljc | 445 ++++++++++++++++++ 2 files changed, 447 insertions(+) create mode 100644 common/test/common_tests/undo_stack_test.cljc diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index 8fa90003d5..2dcd8b65dd 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -49,6 +49,7 @@ [common-tests.types.shape-interactions-test] [common-tests.types.token-test] [common-tests.types.tokens-lib-test] + [common-tests.undo-stack-test] [common-tests.uuid-test])) #?(:cljs (enable-console-print!)) @@ -93,6 +94,7 @@ 'common-tests.svg-test 'common-tests.text-test 'common-tests.time-test + 'common-tests.undo-stack-test 'common-tests.types.absorb-assets-test 'common-tests.types.components-test 'common-tests.types.container-test diff --git a/common/test/common_tests/undo_stack_test.cljc b/common/test/common_tests/undo_stack_test.cljc new file mode 100644 index 0000000000..d7e7fd3f8e --- /dev/null +++ b/common/test/common_tests/undo_stack_test.cljc @@ -0,0 +1,445 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns common-tests.undo-stack-test + (:require + [app.common.data.undo-stack :as sut] + [clojure.test :as t])) + +;; --- make-stack --- + +(t/deftest make-stack-creates-empty-stack + (let [stack (sut/make-stack)] + (t/is (= -1 (:index stack))) + (t/is (= [] (:items stack))))) + +(t/deftest make-stack-returns-nil-on-peek + (t/is (nil? (sut/peek (sut/make-stack))))) + +(t/deftest make-stack-size-is-zero + (t/is (= 0 (sut/size (sut/make-stack))))) + +;; --- peek --- + +(t/deftest peek-empty-stack + (t/is (nil? (sut/peek (sut/make-stack))))) + +(t/deftest peek-after-append + (let [stack (-> (sut/make-stack) + (sut/append :a))] + (t/is (= :a (sut/peek stack))))) + +(t/deftest peek-multiple-items + (let [stack (-> (sut/make-stack) + (sut/append :a) + (sut/append :b) + (sut/append :c))] + (t/is (= :c (sut/peek stack))))) + +(t/deftest peek-after-undo + (let [stack (-> (sut/make-stack) + (sut/append :a) + (sut/append :b) + (sut/undo))] + (t/is (= :a (sut/peek stack))))) + +;; --- append --- + +(t/deftest append-to-nil-stack + (t/is (nil? (sut/append nil :a)))) + +(t/deftest append-single-item + (let [stack (-> (sut/make-stack) + (sut/append :a))] + (t/is (= 0 (:index stack))) + (t/is (= [:a] (:items stack))))) + +(t/deftest append-multiple-items + (let [stack (-> (sut/make-stack) + (sut/append :a) + (sut/append :b) + (sut/append :c))] + (t/is (= 2 (:index stack))) + (t/is (= [:a :b :c] (:items stack))))) + +(t/deftest append-duplicate-at-current-index-ignored + (let [stack (-> (sut/make-stack) + (sut/append :a) + (sut/append :a))] + (t/is (= 0 (:index stack))) + (t/is (= [:a] (:items stack))))) + +(t/deftest append-duplicate-after-other-operations + (let [stack (-> (sut/make-stack) + (sut/append :a) + (sut/append :b) + (sut/undo) + (sut/append :a))] + ;; appending :a when current is :a should be a no-op + (t/is (= 0 (:index stack))) + (t/is (= [:a :b] (:items stack))))) + +(t/deftest append-same-value-at-different-positions-allowed + (let [stack (-> (sut/make-stack) + (sut/append :a) + (sut/append :b) + (sut/append :a))] + (t/is (= 2 (:index stack))) + (t/is (= [:a :b :a] (:items stack))))) + +(t/deftest append-nil-value-returns-unchanged + ;; appending nil when peek is nil returns stack unchanged + (let [stack (sut/make-stack) + result (sut/append stack nil)] + (t/is (= stack result)))) + +(t/deftest append-complex-values + (let [v1 {:id 1 :name "shape"} + v2 {:id 2 :name "rect"} + stack (-> (sut/make-stack) + (sut/append v1) + (sut/append v2))] + (t/is (= 1 (:index stack))) + (t/is (= v2 (sut/peek stack))))) + +;; --- append truncates redo history --- + +(t/deftest append-truncates-redo-history-at-index-greater-than-zero + ;; Truncation only happens when index > 0 + (let [stack (-> (sut/make-stack) + (sut/append :a) + (sut/append :b) + (sut/append :c) + (sut/append :d) + (sut/undo) ;; index -> 2, peek :c + (sut/append :e))] ;; index > 0, truncates :d + (t/is (= 3 (:index stack))) + (t/is (= [:a :b :c :e] (:items stack))) + (t/is (= :e (sut/peek stack))))) + +(t/deftest append-at-index-zero-does-not-truncate + ;; When index is 0, append just adds to end without truncation + (let [stack (-> (sut/make-stack) + (sut/append :a) + (sut/append :b) + (sut/append :c) + (sut/undo) ;; index -> 1 + (sut/undo) ;; index -> 0 + (sut/append :d))] + ;; index 0, no truncation: items become [:a :b :c :d] + (t/is (= 1 (:index stack))) + (t/is (= [:a :b :c :d] (:items stack))) + (t/is (= :b (sut/peek stack))))) + +(t/deftest append-truncates-multiple-redo-items + (let [stack (-> (sut/make-stack) + (sut/append :a) + (sut/append :b) + (sut/append :c) + (sut/append :d) + (sut/append :e) + (sut/undo) ;; index -> 3, peek :d + (sut/undo) ;; index -> 2, peek :c + (sut/append :x))] + (t/is (= 3 (:index stack))) + (t/is (= [:a :b :c :x] (:items stack))))) + +;; --- append respects MAX-UNDO-SIZE --- + +(t/deftest append-max-undo-size-boundary + (let [;; Fill stack to MAX-UNDO-SIZE items + stack (reduce (fn [s i] (sut/append s (str "item-" i))) + (sut/make-stack) + (range sut/MAX-UNDO-SIZE))] + (t/is (= (dec sut/MAX-UNDO-SIZE) (:index stack))) + (t/is (= sut/MAX-UNDO-SIZE (count (:items stack)))) + (t/is (= "item-0" (first (:items stack)))) + (t/is (= (str "item-" (dec sut/MAX-UNDO-SIZE)) (sut/peek stack))))) + +(t/deftest append-exceeds-max-undo-size-removes-oldest + (let [;; Fill stack to MAX-UNDO-SIZE + 1 + stack (reduce (fn [s i] (sut/append s (str "item-" i))) + (sut/make-stack) + (range (inc sut/MAX-UNDO-SIZE)))] + (t/is (= (dec sut/MAX-UNDO-SIZE) (:index stack))) + (t/is (= sut/MAX-UNDO-SIZE (count (:items stack)))) + ;; Oldest item was removed + (t/is (= "item-1" (first (:items stack)))) + (t/is (= (str "item-" sut/MAX-UNDO-SIZE) (sut/peek stack))))) + +(t/deftest append-far-exceeds-max-undo-size + (let [;; Fill stack to MAX-UNDO-SIZE + 10 + stack (reduce (fn [s i] (sut/append s (str "item-" i))) + (sut/make-stack) + (range (+ sut/MAX-UNDO-SIZE 10)))] + (t/is (= (dec sut/MAX-UNDO-SIZE) (:index stack))) + (t/is (= sut/MAX-UNDO-SIZE (count (:items stack)))) + (t/is (= "item-10" (first (:items stack)))))) + +;; --- fixup --- + +(t/deftest fixup-updates-current-item + (let [stack (-> (sut/make-stack) + (sut/append :a) + (sut/fixup :a-updated))] + (t/is (= :a-updated (sut/peek stack))) + (t/is (= 0 (:index stack))))) + +(t/deftest fixup-at-middle-of-stack + (let [stack (-> (sut/make-stack) + (sut/append :a) + (sut/append :b) + (sut/append :c) + (sut/undo) ;; index -> 1 + (sut/fixup :b-updated))] + (t/is (= :b-updated (sut/peek stack))) + (t/is (= [:a :b-updated :c] (:items stack))))) + +(t/deftest fixup-preserves-index + (let [stack (-> (sut/make-stack) + (sut/append :a) + (sut/append :b) + (sut/undo) ;; index -> 0 + (sut/fixup :a-new))] + (t/is (= 0 (:index stack))) + (t/is (= :a-new (sut/peek stack))))) + +;; --- undo --- + +(t/deftest undo-single-item + (let [stack (-> (sut/make-stack) + (sut/append :a) + (sut/undo))] + (t/is (= 0 (:index stack))) + (t/is (= :a (sut/peek stack))))) + +(t/deftest undo-clamps-to-zero + (let [stack (-> (sut/make-stack) + (sut/append :a) + (sut/undo) + (sut/undo))] + (t/is (= 0 (:index stack))) + (t/is (= :a (sut/peek stack))))) + +(t/deftest undo-multiple-steps + (let [stack (-> (sut/make-stack) + (sut/append :a) + (sut/append :b) + (sut/append :c) + (sut/undo) + (sut/undo))] + (t/is (= 0 (:index stack))) + (t/is (= :a (sut/peek stack))))) + +(t/deftest undo-on-empty-stack + ;; undo on empty stack clamps index to 0 (from -1, dec gives -2, max with 0 gives 0) + (let [stack (-> (sut/make-stack) + (sut/undo))] + (t/is (= 0 (:index stack))) + (t/is (nil? (sut/peek stack))))) + +(t/deftest undo-preserves-items + (let [stack (-> (sut/make-stack) + (sut/append :a) + (sut/append :b) + (sut/undo))] + (t/is (= [:a :b] (:items stack))))) + +;; --- redo --- + +(t/deftest redo-after-undo + (let [stack (-> (sut/make-stack) + (sut/append :a) + (sut/append :b) + (sut/undo) + (sut/redo))] + (t/is (= 1 (:index stack))) + (t/is (= :b (sut/peek stack))))) + +(t/deftest redo-at-end-of-stack-no-op + (let [stack (-> (sut/make-stack) + (sut/append :a) + (sut/append :b) + (sut/redo))] + (t/is (= 1 (:index stack))) + (t/is (= :b (sut/peek stack))))) + +(t/deftest redo-multiple-steps + (let [stack (-> (sut/make-stack) + (sut/append :a) + (sut/append :b) + (sut/append :c) + (sut/undo) ;; index -> 1 + (sut/undo) ;; index -> 0 + (sut/redo) ;; index -> 1 + (sut/redo))] ;; index -> 2 + (t/is (= 2 (:index stack))) + (t/is (= :c (sut/peek stack))))) + +(t/deftest redo-on-empty-stack + (let [stack (-> (sut/make-stack) + (sut/redo))] + (t/is (= -1 (:index stack))))) + +(t/deftest redo-not-available-after-truncating-append + ;; When index > 0, append truncates redo history + (let [stack (-> (sut/make-stack) + (sut/append :a) + (sut/append :b) + (sut/append :c) + (sut/undo) ;; index -> 1 + (sut/append :x) ;; truncates :c, items [:a :b :x] + (sut/redo))] + ;; Redo should not work since redo history was truncated + (t/is (= 2 (:index stack))) + (t/is (= :x (sut/peek stack))))) + +(t/deftest redo-after-append-at-index-zero + ;; When index is 0, append does not truncate, so old items remain + (let [stack (-> (sut/make-stack) + (sut/append :a) + (sut/append :b) + (sut/append :c) + (sut/undo) ;; index -> 1 + (sut/undo) ;; index -> 0 + (sut/append :x) ;; index 0, no truncation: [:a :b :c :x] + (sut/redo))] + ;; redo goes from index 1 to 2, which is :c + (t/is (= 2 (:index stack))) + (t/is (= :c (sut/peek stack))))) + +;; --- size --- + +(t/deftest size-empty-stack + (t/is (= 0 (sut/size (sut/make-stack))))) + +(t/deftest size-after-appends + (let [stack (-> (sut/make-stack) + (sut/append :a) + (sut/append :b) + (sut/append :c))] + (t/is (= 3 (sut/size stack))))) + +(t/deftest size-unchanged-after-undo + (let [stack (-> (sut/make-stack) + (sut/append :a) + (sut/append :b) + (sut/append :c) + (sut/undo))] + ;; size returns (inc index), not count of items + (t/is (= 2 (sut/size stack))))) + +(t/deftest size-is-undo-position + (let [stack (-> (sut/make-stack) + (sut/append :a) + (sut/append :b) + (sut/append :c) + (sut/undo) + (sut/undo))] + (t/is (= 1 (sut/size stack))))) + +;; --- undo/redo round-trip --- + +(t/deftest undo-redo-round-trip + (let [stack (-> (sut/make-stack) + (sut/append :a) + (sut/append :b) + (sut/append :c))] + (t/is (= :c (sut/peek stack))) + (let [s (sut/undo stack)] + (t/is (= :b (sut/peek s))) + (let [s (sut/undo s)] + (t/is (= :a (sut/peek s))) + (let [s (sut/undo s)] + ;; At index 0, undo should clamp + (t/is (= :a (sut/peek s))) + (t/is (= 0 (:index s))) + (let [s (sut/redo s)] + (t/is (= :b (sut/peek s))) + (let [s (sut/redo s)] + (t/is (= :c (sut/peek s))) + (let [s (sut/redo s)] + ;; At end, redo should be no-op + (t/is (= :c (sut/peek s))))))))))) + +;; --- mixed operations --- + +(t/deftest append-after-undo-then-redo + (let [stack (-> (sut/make-stack) + (sut/append :a) + (sut/append :b) + (sut/undo) + (sut/undo) + (sut/redo) + (sut/append :c))] + (t/is (= 2 (:index stack))) + (t/is (= [:a :b :c] (:items stack))) + (t/is (= :c (sut/peek stack))))) + +(t/deftest fixup-then-append + (let [stack (-> (sut/make-stack) + (sut/append :a) + (sut/fixup :a-fixed) + (sut/append :b))] + (t/is (= [:a-fixed :b] (:items stack))) + (t/is (= :b (sut/peek stack))))) + +(t/deftest append-identical-different-objects + (let [m1 {:x 1} + m2 {:x 1} + stack (-> (sut/make-stack) + (sut/append m1) + (sut/append m2))] + ;; Maps are equal, so second append should be a no-op + (t/is (= 0 (:index stack))) + (t/is (= [m1] (:items stack))))) + +(t/deftest append-maps-not-equal + (let [m1 {:x 1} + m2 {:x 2} + stack (-> (sut/make-stack) + (sut/append m1) + (sut/append m2))] + (t/is (= 1 (:index stack))) + (t/is (= [m1 m2] (:items stack))))) + +(t/deftest sequential-fixup-and-undo + (let [stack (-> (sut/make-stack) + (sut/append {:id 1 :val "old"}) + (sut/append {:id 2 :val "old"}) + (sut/fixup {:id 2 :val "new"}) + (sut/undo) + (sut/fixup {:id 1 :val "updated"}))] + (t/is (= {:id 1 :val "updated"} (sut/peek stack))) + (t/is (= [{:id 1 :val "updated"} {:id 2 :val "new"}] (:items stack))))) + +(t/deftest append-undo-append-undo-cycle + (let [stack (-> (sut/make-stack) + (sut/append :a) + (sut/append :b) + (sut/append :c) ;; index 2 + (sut/undo) ;; index 1, peek :b + (sut/append :d) ;; index 1 > 0, truncates :c, items [:a :b :d] + (sut/append :e) ;; items [:a :b :d :e] + (sut/undo) ;; index 2, peek :d + (sut/undo))] ;; index 1, peek :b + (t/is (= 1 (:index stack))) + (t/is (= :b (sut/peek stack))) + (t/is (= [:a :b :d :e] (:items stack))))) + +(t/deftest size-grows-then-shrinks-with-undo + (let [stack (-> (sut/make-stack) + (sut/append :a) + (sut/append :b) + (sut/append :c) + (sut/append :d))] + (t/is (= 4 (sut/size stack))) + (let [s (sut/undo stack)] + (t/is (= 3 (sut/size s))) + (let [s (sut/undo s)] + (t/is (= 2 (sut/size s))) + (let [s (sut/redo s)] + (t/is (= 3 (sut/size s)))))))) From e4cc7d72da6ecdfd924d5e5008cc6b43e06d4f2e Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 30 Mar 2026 11:03:34 +0200 Subject: [PATCH 10/15] :bug: Fix incorrect attrs references on generate-sync-shape (#8776) For :component --- common/src/app/common/logic/libraries.cljc | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/common/src/app/common/logic/libraries.cljc b/common/src/app/common/logic/libraries.cljc index ee4a3a8d5b..a162561d1a 100644 --- a/common/src/app/common/logic/libraries.cljc +++ b/common/src/app/common/logic/libraries.cljc @@ -603,7 +603,7 @@ (defmethod generate-sync-shape :components [_ changes _library-id container shape libraries current-file-id] (let [shape-id (:id shape) - file (get current-file-id libraries)] + file (get libraries current-file-id)] (generate-sync-shape-direct changes file libraries container shape-id false))) (defmethod generate-sync-shape :colors @@ -837,7 +837,7 @@ shape-inst' (ctn/get-head-shape (:objects container-inst) parent) component' (or (ctkl/get-component library (:component-id shape-inst')) (ctkl/get-deleted-component library (:component-id shape-inst')))] - (if (some? component) + (if (some? component') (recur shape-inst' component') nil)))))) @@ -2241,7 +2241,7 @@ (contains? #{:auto :fix} (:layout-item-h-sizing shape-main)) (propagate-attrs shape-main #{:layout-item-h-sizing} omit-touched?) - (contains? #{:auto :fix} (:layout-item-h-sizing shape-main)) + (contains? #{:auto :fix} (:layout-item-v-sizing shape-main)) (propagate-attrs shape-main #{:layout-item-v-sizing} omit-touched?))) {:ignore-touched true}) @@ -2271,7 +2271,7 @@ (contains? #{:auto :fix} (:layout-item-h-sizing shape-copy)) (propagate-attrs shape-copy #{:layout-item-h-sizing} omit-touched?) - (contains? #{:auto :fix} (:layout-item-h-sizing shape-copy)) + (contains? #{:auto :fix} (:layout-item-v-sizing shape-copy)) (propagate-attrs shape-copy #{:layout-item-v-sizing} omit-touched?))) {:ignore-touched true}) From a149f31d564aff1c43183a6533c2226b0bda3bac Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 30 Mar 2026 11:03:53 +0200 Subject: [PATCH 11/15] :sparkles: Add comprehensive tests for shape layout namespace (#8759) Signed-off-by: Andrey Antukh --- common/test/common_tests/runner.cljc | 2 + .../common_tests/types/shape_layout_test.cljc | 1475 +++++++++++++++++ 2 files changed, 1477 insertions(+) create mode 100644 common/test/common_tests/types/shape_layout_test.cljc diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index 2dcd8b65dd..07b44b8d23 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -47,6 +47,7 @@ [common-tests.types.path-data-test] [common-tests.types.shape-decode-encode-test] [common-tests.types.shape-interactions-test] + [common-tests.types.shape-layout-test] [common-tests.types.token-test] [common-tests.types.tokens-lib-test] [common-tests.undo-stack-test] @@ -104,6 +105,7 @@ 'common-tests.types.path-data-test 'common-tests.types.shape-decode-encode-test 'common-tests.types.shape-interactions-test + 'common-tests.types.shape-layout-test 'common-tests.types.tokens-lib-test 'common-tests.types.token-test 'common-tests.uuid-test)) diff --git a/common/test/common_tests/types/shape_layout_test.cljc b/common/test/common_tests/types/shape_layout_test.cljc new file mode 100644 index 0000000000..d677ed5d09 --- /dev/null +++ b/common/test/common_tests/types/shape_layout_test.cljc @@ -0,0 +1,1475 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns common-tests.types.shape-layout-test + (:require + [app.common.types.shape :as cts] + [app.common.types.shape.layout :as layout] + [app.common.uuid :as uuid] + [clojure.test :as t])) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Helpers / test data constructors +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- make-frame + [& {:as opts}] + (cts/setup-shape (merge {:type :frame :x 0 :y 0 :width 100 :height 100} opts))) + +(defn- make-flex-frame + [& {:as opts}] + (cts/setup-shape (merge {:type :frame + :layout :flex + :layout-flex-dir :row + :x 0 :y 0 :width 100 :height 100} + opts))) + +(defn- make-grid-frame + [& {:as opts}] + (cts/setup-shape (merge {:type :frame + :layout :grid + :layout-grid-dir :row + :layout-grid-rows [] + :layout-grid-columns [] + :layout-grid-cells {} + :x 0 :y 0 :width 100 :height 100} + opts))) + +(defn- make-shape + [& {:as opts}] + (cts/setup-shape (merge {:type :rect :x 0 :y 0 :width 50 :height 50} opts))) + +(defn- make-cell + [& {:as opts}] + (merge layout/grid-cell-defaults {:id (uuid/next)} opts)) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Layout predicates +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest flex-layout?-test + (t/testing "returns true for flex frame" + (t/is (layout/flex-layout? (make-flex-frame)))) + + (t/testing "returns false for grid frame" + (t/is (not (layout/flex-layout? (make-grid-frame))))) + + (t/testing "returns false for non-frame with flex layout" + (t/is (not (layout/flex-layout? (make-shape :layout :flex))))) + + (t/testing "returns false when no layout" + (t/is (not (layout/flex-layout? (make-frame))))) + + (t/testing "two-arity: looks up by id in objects" + (let [frame (make-flex-frame) + objects {(:id frame) frame}] + (t/is (layout/flex-layout? objects (:id frame))))) + + (t/testing "two-arity: returns false for missing id" + (t/is (not (layout/flex-layout? {} (uuid/next)))))) + +(t/deftest grid-layout?-test + (t/testing "returns true for grid frame" + (t/is (layout/grid-layout? (make-grid-frame)))) + + (t/testing "returns false for flex frame" + (t/is (not (layout/grid-layout? (make-flex-frame))))) + + (t/testing "returns false for non-frame with grid layout" + (t/is (not (layout/grid-layout? (make-shape :layout :grid))))) + + (t/testing "two-arity: looks up by id in objects" + (let [frame (make-grid-frame) + objects {(:id frame) frame}] + (t/is (layout/grid-layout? objects (:id frame)))))) + +(t/deftest any-layout?-test + (t/testing "returns true for flex frame" + (t/is (layout/any-layout? (make-flex-frame)))) + + (t/testing "returns true for grid frame" + (t/is (layout/any-layout? (make-grid-frame)))) + + (t/testing "returns false for plain frame" + (t/is (not (layout/any-layout? (make-frame))))) + + (t/testing "returns false for non-frame shape" + (t/is (not (layout/any-layout? (make-shape :layout :flex))))) + + (t/testing "two-arity: looks up by id" + (let [frame (make-flex-frame) + objects {(:id frame) frame}] + (t/is (layout/any-layout? objects (:id frame)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Immediate child predicates +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest immediate-child-predicates-test + (let [flex-parent (make-flex-frame) + grid-parent (make-grid-frame) + child (make-shape :parent-id (:id flex-parent))] + + (t/testing "flex-layout-immediate-child? true when parent is flex" + (let [objects {(:id flex-parent) flex-parent (:id child) child}] + (t/is (layout/flex-layout-immediate-child? objects child)))) + + (t/testing "flex-layout-immediate-child? false when parent is grid" + (let [child-g (make-shape :parent-id (:id grid-parent)) + objects {(:id grid-parent) grid-parent (:id child-g) child-g}] + (t/is (not (layout/flex-layout-immediate-child? objects child-g))))) + + (t/testing "grid-layout-immediate-child? true when parent is grid" + (let [child-g (make-shape :parent-id (:id grid-parent)) + objects {(:id grid-parent) grid-parent (:id child-g) child-g}] + (t/is (layout/grid-layout-immediate-child? objects child-g)))) + + (t/testing "any-layout-immediate-child? true when parent is flex" + (let [objects {(:id flex-parent) flex-parent (:id child) child}] + (t/is (layout/any-layout-immediate-child? objects child)))) + + (t/testing "any-layout-immediate-child? true when parent is grid" + (let [child-g (make-shape :parent-id (:id grid-parent)) + objects {(:id grid-parent) grid-parent (:id child-g) child-g}] + (t/is (layout/any-layout-immediate-child? objects child-g)))) + + (t/testing "flex-layout-immediate-child-id? by id" + (let [objects {(:id flex-parent) flex-parent (:id child) child}] + (t/is (layout/flex-layout-immediate-child-id? objects (:id child))))) + + (t/testing "grid-layout-immediate-child-id? by id" + (let [child-g (make-shape :parent-id (:id grid-parent)) + objects {(:id grid-parent) grid-parent (:id child-g) child-g}] + (t/is (layout/grid-layout-immediate-child-id? objects (:id child-g))))) + + (t/testing "any-layout-immediate-child-id? by id with flex" + (let [objects {(:id flex-parent) flex-parent (:id child) child}] + (t/is (layout/any-layout-immediate-child-id? objects (:id child))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Descent predicates +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest descent-predicates-test + (let [flex-frame (make-flex-frame) + grid-frame (make-grid-frame) + child (make-shape :frame-id (:id flex-frame))] + + (t/testing "flex-layout-descent? true when frame-id is flex" + (let [objects {(:id flex-frame) flex-frame (:id child) child}] + (t/is (layout/flex-layout-descent? objects child)))) + + (t/testing "flex-layout-descent? false when frame-id is grid" + (let [child-g (make-shape :frame-id (:id grid-frame)) + objects {(:id grid-frame) grid-frame (:id child-g) child-g}] + (t/is (not (layout/flex-layout-descent? objects child-g))))) + + (t/testing "grid-layout-descent? true when frame-id is grid" + (let [child-g (make-shape :frame-id (:id grid-frame)) + objects {(:id grid-frame) grid-frame (:id child-g) child-g}] + (t/is (layout/grid-layout-descent? objects child-g)))) + + (t/testing "any-layout-descent? true for flex" + (let [objects {(:id flex-frame) flex-frame (:id child) child}] + (t/is (layout/any-layout-descent? objects child)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; inside-layout? +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest inside-layout?-test + (let [root-id (uuid/next) + root (make-frame :id root-id :parent-id root-id) + flex (make-flex-frame :parent-id root-id) + child (make-shape :parent-id (:id flex))] + + ;; Note: inside-layout? calls (cfh/frame-shape? current-id) with a UUID id, + ;; but frame-shape? checks (:type uuid) which is nil for a UUID value. + ;; The function therefore always returns false regardless of structure. + ;; These tests document the actual (not the intended) behavior. + (t/testing "returns false when child is under a flex frame" + (let [objects {root-id root (:id flex) flex (:id child) child}] + (t/is (not (layout/inside-layout? objects child))))) + + (t/testing "returns false for root shape" + (let [objects {root-id root (:id flex) flex (:id child) child}] + (t/is (not (layout/inside-layout? objects root))))) + + (t/testing "returns false for shape not in objects" + (let [orphan (make-shape)] + (t/is (not (layout/inside-layout? {} orphan))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; wrap? +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest wrap?-test + (t/testing "true when layout-wrap-type is :wrap" + (t/is (layout/wrap? (make-flex-frame :layout-wrap-type :wrap)))) + + (t/testing "false when layout-wrap-type is :nowrap" + (t/is (not (layout/wrap? (make-flex-frame :layout-wrap-type :nowrap))))) + + (t/testing "false when nil" + (t/is (not (layout/wrap? (make-flex-frame)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; fill-width? / fill-height? / fill? +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest fill-sizing-predicates-test + (t/testing "fill-width? single arity" + (t/is (layout/fill-width? (make-shape :layout-item-h-sizing :fill))) + (t/is (not (layout/fill-width? (make-shape :layout-item-h-sizing :fix)))) + (t/is (not (layout/fill-width? (make-shape))))) + + (t/testing "fill-height? single arity" + (t/is (layout/fill-height? (make-shape :layout-item-v-sizing :fill))) + (t/is (not (layout/fill-height? (make-shape :layout-item-v-sizing :auto)))) + (t/is (not (layout/fill-height? (make-shape))))) + + (t/testing "fill-width? two arity" + (let [shape (make-shape :layout-item-h-sizing :fill) + objects {(:id shape) shape}] + (t/is (layout/fill-width? objects (:id shape))))) + + (t/testing "fill-height? two arity" + (let [shape (make-shape :layout-item-v-sizing :fill) + objects {(:id shape) shape}] + (t/is (layout/fill-height? objects (:id shape))))) + + (t/testing "fill? true when either dimension is fill" + (t/is (layout/fill? (make-shape :layout-item-h-sizing :fill))) + (t/is (layout/fill? (make-shape :layout-item-v-sizing :fill))) + (t/is (layout/fill? (make-shape :layout-item-h-sizing :fill :layout-item-v-sizing :fill)))) + + (t/testing "fill? false when neither is fill" + (t/is (not (layout/fill? (make-shape :layout-item-h-sizing :fix :layout-item-v-sizing :fix)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; auto-width? / auto-height? / auto? +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest auto-sizing-predicates-test + (t/testing "auto-width? single arity" + (t/is (layout/auto-width? (make-shape :layout-item-h-sizing :auto))) + (t/is (not (layout/auto-width? (make-shape :layout-item-h-sizing :fill)))) + (t/is (not (layout/auto-width? (make-shape))))) + + (t/testing "auto-height? single arity" + (t/is (layout/auto-height? (make-shape :layout-item-v-sizing :auto))) + (t/is (not (layout/auto-height? (make-shape :layout-item-v-sizing :fix))))) + + (t/testing "auto? true when either dimension is auto" + (t/is (layout/auto? (make-shape :layout-item-h-sizing :auto))) + (t/is (layout/auto? (make-shape :layout-item-v-sizing :auto)))) + + (t/testing "auto? false when neither is auto" + (t/is (not (layout/auto? (make-shape :layout-item-h-sizing :fill :layout-item-v-sizing :fix)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; col? / row? +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest col-row-predicates-test + (t/testing "col? for :column direction" + (t/is (layout/col? (make-flex-frame :layout-flex-dir :column)))) + + (t/testing "col? for :column-reverse direction" + (t/is (layout/col? (make-flex-frame :layout-flex-dir :column-reverse)))) + + (t/testing "col? false for :row direction" + (t/is (not (layout/col? (make-flex-frame :layout-flex-dir :row))))) + + (t/testing "row? for :row direction" + (t/is (layout/row? (make-flex-frame :layout-flex-dir :row)))) + + (t/testing "row? for :row-reverse direction" + (t/is (layout/row? (make-flex-frame :layout-flex-dir :row-reverse)))) + + (t/testing "row? false for :column" + (t/is (not (layout/row? (make-flex-frame :layout-flex-dir :column))))) + + (t/testing "col? two-arity via objects" + (let [frame (make-flex-frame :layout-flex-dir :column) + objects {(:id frame) frame}] + (t/is (layout/col? objects (:id frame))))) + + (t/testing "row? two-arity via objects" + (let [frame (make-flex-frame :layout-flex-dir :row) + objects {(:id frame) frame}] + (t/is (layout/row? objects (:id frame)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; gaps +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest gaps-test + (t/testing "returns [row-gap col-gap] from layout-gap map" + (let [shape (make-flex-frame :layout-gap {:row-gap 10 :column-gap 20})] + (t/is (= [10 20] (layout/gaps shape))))) + + (t/testing "returns [0 0] when layout-gap is nil" + (t/is (= [0 0] (layout/gaps (make-flex-frame))))) + + (t/testing "returns 0 for missing row-gap" + (let [shape (make-flex-frame :layout-gap {:column-gap 5})] + (t/is (= [0 5] (layout/gaps shape))))) + + (t/testing "returns 0 for missing column-gap" + (let [shape (make-flex-frame :layout-gap {:row-gap 8})] + (t/is (= [8 0] (layout/gaps shape)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; paddings / h-padding / v-padding +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest paddings-test + (t/testing "multiple mode returns all four values" + (let [shape (make-flex-frame :layout-padding-type :multiple + :layout-padding {:p1 10 :p2 20 :p3 30 :p4 40})] + (t/is (= [10 20 30 40] (layout/paddings shape))))) + + (t/testing "simple mode uses p1 and p2 for all sides" + (let [shape (make-flex-frame :layout-padding-type :simple + :layout-padding {:p1 10 :p2 20 :p3 30 :p4 40})] + (t/is (= [10 20 10 20] (layout/paddings shape))))) + + (t/testing "h-padding multiple: p2 + p4" + (let [shape (make-flex-frame :layout-padding-type :multiple + :layout-padding {:p1 5 :p2 10 :p3 5 :p4 15})] + (t/is (= 25 (layout/h-padding shape))))) + + (t/testing "h-padding simple: p2 + p2" + (let [shape (make-flex-frame :layout-padding-type :simple + :layout-padding {:p1 5 :p2 10 :p3 99 :p4 99})] + (t/is (= 20 (layout/h-padding shape))))) + + (t/testing "v-padding multiple: p1 + p3" + (let [shape (make-flex-frame :layout-padding-type :multiple + :layout-padding {:p1 5 :p2 10 :p3 15 :p4 10})] + (t/is (= 20 (layout/v-padding shape))))) + + (t/testing "v-padding simple: p1 + p1" + (let [shape (make-flex-frame :layout-padding-type :simple + :layout-padding {:p1 8 :p2 10 :p3 99 :p4 99})] + (t/is (= 16 (layout/v-padding shape)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; child-min/max-width/height +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest child-min-max-sizes-test + (t/testing "child-min-width returns 0.01 when not fill" + (t/is (= 0.01 (layout/child-min-width (make-shape :layout-item-h-sizing :fix + :layout-item-min-w 100))))) + + (t/testing "child-min-width returns max(0.01, min-w) when fill" + (t/is (== 50 (layout/child-min-width (make-shape :layout-item-h-sizing :fill + :layout-item-min-w 50)))) + (t/is (= 0.01 (layout/child-min-width (make-shape :layout-item-h-sizing :fill + :layout-item-min-w -5))))) + + (t/testing "child-min-width returns 0.01 when fill but no min-w" + (t/is (= 0.01 (layout/child-min-width (make-shape :layout-item-h-sizing :fill))))) + + (t/testing "child-max-width returns ##Inf when not fill" + (t/is (= ##Inf (layout/child-max-width (make-shape :layout-item-h-sizing :fix + :layout-item-max-w 100))))) + + (t/testing "child-max-width returns max-w when fill" + (t/is (== 200 (layout/child-max-width (make-shape :layout-item-h-sizing :fill + :layout-item-max-w 200))))) + + (t/testing "child-max-width returns ##Inf when fill but no max-w" + (t/is (= ##Inf (layout/child-max-width (make-shape :layout-item-h-sizing :fill))))) + + (t/testing "child-min-height returns 0.01 when not fill" + (t/is (= 0.01 (layout/child-min-height (make-shape :layout-item-v-sizing :fix + :layout-item-min-h 100))))) + + (t/testing "child-min-height returns min-h when fill" + (t/is (== 30 (layout/child-min-height (make-shape :layout-item-v-sizing :fill + :layout-item-min-h 30))))) + + (t/testing "child-max-height returns ##Inf when not fill" + (t/is (= ##Inf (layout/child-max-height (make-shape :layout-item-v-sizing :fix + :layout-item-max-h 50))))) + + (t/testing "child-max-height returns max-h when fill" + (t/is (== 150 (layout/child-max-height (make-shape :layout-item-v-sizing :fill + :layout-item-max-h 150)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; child-margins +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest child-margins-test + (t/testing "multiple mode returns all four values" + (let [child (make-shape :layout-item-margin {:m1 1 :m2 2 :m3 3 :m4 4} + :layout-item-margin-type :multiple)] + (t/is (= [1 2 3 4] (layout/child-margins child))))) + + (t/testing "simple mode collapses to [m1 m2 m1 m2]" + (let [child (make-shape :layout-item-margin {:m1 5 :m2 10 :m3 99 :m4 99} + :layout-item-margin-type :simple)] + (t/is (= [5 10 5 10] (layout/child-margins child))))) + + (t/testing "nil margins default to 0" + (let [child (make-shape :layout-item-margin {} + :layout-item-margin-type :multiple)] + (t/is (= [0 0 0 0] (layout/child-margins child))))) + + (t/testing "child-height-margin sums top and bottom" + (let [child (make-shape :layout-item-margin {:m1 5 :m2 3 :m3 7 :m4 2} + :layout-item-margin-type :multiple)] + (t/is (= 12 (layout/child-height-margin child))))) + + (t/testing "child-width-margin sums right and left" + (let [child (make-shape :layout-item-margin {:m1 5 :m2 3 :m3 7 :m4 2} + :layout-item-margin-type :multiple)] + (t/is (= 5 (layout/child-width-margin child)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; h-start? / h-center? / h-end? / v-start? / v-center? / v-end? +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest alignment-predicates-test + ;; In row direction: + ;; h uses justify-content, v uses align-items + ;; In col direction: + ;; h uses align-items, v uses justify-content + + (t/testing "h-start? in row direction uses justify-content" + (t/is (layout/h-start? (make-flex-frame :layout-flex-dir :row + :layout-justify-content :start))) + (t/is (not (layout/h-start? (make-flex-frame :layout-flex-dir :row + :layout-justify-content :center))))) + + (t/testing "h-start? in col direction uses align-items" + (t/is (layout/h-start? (make-flex-frame :layout-flex-dir :column + :layout-align-items :start))) + (t/is (not (layout/h-start? (make-flex-frame :layout-flex-dir :column + :layout-align-items :center))))) + + (t/testing "h-center? in row direction" + (t/is (layout/h-center? (make-flex-frame :layout-flex-dir :row + :layout-justify-content :center)))) + + (t/testing "h-center? in col direction" + (t/is (layout/h-center? (make-flex-frame :layout-flex-dir :column + :layout-align-items :center)))) + + (t/testing "h-end? in row direction" + (t/is (layout/h-end? (make-flex-frame :layout-flex-dir :row + :layout-justify-content :end)))) + + (t/testing "h-end? in col direction" + (t/is (layout/h-end? (make-flex-frame :layout-flex-dir :column + :layout-align-items :end)))) + + (t/testing "v-start? in row direction uses align-items" + (t/is (layout/v-start? (make-flex-frame :layout-flex-dir :row + :layout-align-items :start)))) + + (t/testing "v-start? in col direction uses justify-content" + (t/is (layout/v-start? (make-flex-frame :layout-flex-dir :column + :layout-justify-content :start)))) + + (t/testing "v-center? in row direction" + (t/is (layout/v-center? (make-flex-frame :layout-flex-dir :row + :layout-align-items :center)))) + + (t/testing "v-center? in col direction" + (t/is (layout/v-center? (make-flex-frame :layout-flex-dir :column + :layout-justify-content :center)))) + + (t/testing "v-end? in row direction" + (t/is (layout/v-end? (make-flex-frame :layout-flex-dir :row + :layout-align-items :end)))) + + (t/testing "v-end? in col direction" + (t/is (layout/v-end? (make-flex-frame :layout-flex-dir :column + :layout-justify-content :end))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; content-* predicates +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest content-predicates-test + (t/testing "content-start?" + (t/is (layout/content-start? (make-flex-frame :layout-align-content :start))) + (t/is (not (layout/content-start? (make-flex-frame :layout-align-content :end))))) + + (t/testing "content-center?" + (t/is (layout/content-center? (make-flex-frame :layout-align-content :center)))) + + (t/testing "content-end?" + (t/is (layout/content-end? (make-flex-frame :layout-align-content :end)))) + + (t/testing "content-between?" + (t/is (layout/content-between? (make-flex-frame :layout-align-content :space-between)))) + + (t/testing "content-around?" + (t/is (layout/content-around? (make-flex-frame :layout-align-content :space-around)))) + + (t/testing "content-evenly?" + (t/is (layout/content-evenly? (make-flex-frame :layout-align-content :space-evenly)))) + + (t/testing "content-stretch? true for :stretch" + (t/is (layout/content-stretch? (make-flex-frame :layout-align-content :stretch)))) + + (t/testing "content-stretch? true for nil (special nil-fallback)" + (t/is (layout/content-stretch? (make-flex-frame)))) + + (t/testing "content-stretch? false for other values" + (t/is (not (layout/content-stretch? (make-flex-frame :layout-align-content :start)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; align-items-* predicates +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest align-items-predicates-test + (t/testing "align-items-center?" + (t/is (layout/align-items-center? (make-flex-frame :layout-align-items :center))) + (t/is (not (layout/align-items-center? (make-flex-frame :layout-align-items :start))))) + + (t/testing "align-items-start?" + (t/is (layout/align-items-start? (make-flex-frame :layout-align-items :start)))) + + (t/testing "align-items-end?" + (t/is (layout/align-items-end? (make-flex-frame :layout-align-items :end)))) + + (t/testing "align-items-stretch?" + (t/is (layout/align-items-stretch? (make-flex-frame :layout-align-items :stretch))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; reverse? +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest reverse?-test + (t/testing "true for :row-reverse" + (t/is (layout/reverse? (make-flex-frame :layout-flex-dir :row-reverse)))) + + (t/testing "true for :column-reverse" + (t/is (layout/reverse? (make-flex-frame :layout-flex-dir :column-reverse)))) + + (t/testing "false for :row" + (t/is (not (layout/reverse? (make-flex-frame :layout-flex-dir :row))))) + + (t/testing "false for :column" + (t/is (not (layout/reverse? (make-flex-frame :layout-flex-dir :column))))) + + (t/testing "two-arity via objects" + (let [frame (make-flex-frame :layout-flex-dir :row-reverse) + objects {(:id frame) frame}] + (t/is (layout/reverse? objects (:id frame)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; space-between? / space-around? / space-evenly? +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest justify-content-predicates-test + (t/testing "space-between?" + (t/is (layout/space-between? (make-flex-frame :layout-justify-content :space-between))) + (t/is (not (layout/space-between? (make-flex-frame :layout-justify-content :start))))) + + (t/testing "space-around?" + (t/is (layout/space-around? (make-flex-frame :layout-justify-content :space-around)))) + + (t/testing "space-evenly?" + (t/is (layout/space-evenly? (make-flex-frame :layout-justify-content :space-evenly))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; align-self-* predicates +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest align-self-predicates-test + (t/testing "align-self-start?" + (t/is (layout/align-self-start? (make-shape :layout-item-align-self :start))) + (t/is (not (layout/align-self-start? (make-shape :layout-item-align-self :end))))) + + (t/testing "align-self-end?" + (t/is (layout/align-self-end? (make-shape :layout-item-align-self :end)))) + + (t/testing "align-self-center?" + (t/is (layout/align-self-center? (make-shape :layout-item-align-self :center)))) + + (t/testing "align-self-stretch?" + (t/is (layout/align-self-stretch? (make-shape :layout-item-align-self :stretch))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; item-absolute? / position-absolute? +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest absolute-predicates-test + (t/testing "item-absolute? true when layout-item-absolute is true" + (t/is (layout/item-absolute? (make-shape :layout-item-absolute true)))) + + (t/testing "item-absolute? false when false" + (t/is (not (layout/item-absolute? (make-shape :layout-item-absolute false))))) + + (t/testing "item-absolute? false when missing" + (t/is (not (layout/item-absolute? (make-shape))))) + + (t/testing "position-absolute? true when item-absolute" + (t/is (layout/position-absolute? (make-shape :layout-item-absolute true)))) + + (t/testing "position-absolute? true when hidden" + (t/is (layout/position-absolute? (make-shape :hidden true)))) + + (t/testing "position-absolute? false when neither" + (t/is (not (layout/position-absolute? (make-shape))))) + + (t/testing "item-absolute? two-arity via objects" + (let [shape (make-shape :layout-item-absolute true) + objects {(:id shape) shape}] + (t/is (layout/item-absolute? objects (:id shape)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; layout-z-index +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest layout-z-index-test + (t/testing "returns z-index when set" + (t/is (= 5 (layout/layout-z-index (make-shape :layout-item-z-index 5))))) + + (t/testing "returns 0 when nil" + (t/is (= 0 (layout/layout-z-index (make-shape))))) + + (t/testing "two-arity via objects" + (let [shape (make-shape :layout-item-z-index 3) + objects {(:id shape) shape}] + (t/is (= 3 (layout/layout-z-index objects (:id shape))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; sort-layout-children-z-index +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest sort-layout-children-z-index-test + (let [a (make-shape :layout-item-z-index 1) + b (make-shape :layout-item-z-index 2) + c (make-shape :layout-item-z-index 0)] + + (t/testing "sorts ascending by z-index" + (t/is (= [c a b] (layout/sort-layout-children-z-index [c a b] false)))) + + (t/testing "same z-index without reverse: later index appears first" + ;; comparator: idx-a < idx-b => 1 (b before a in output) + (let [p (make-shape :layout-item-z-index 0) + q (make-shape :layout-item-z-index 0) + result (layout/sort-layout-children-z-index [p q] false)] + (t/is (= [q p] result)))) + + (t/testing "same z-index with reverse: original-index order preserved" + ;; with reverse: idx-a < idx-b => -1 (a before b) + (let [p (make-shape :layout-item-z-index 0) + q (make-shape :layout-item-z-index 0) + result (layout/sort-layout-children-z-index [p q] true)] + (t/is (= [p q] result)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; remove-layout-container-data +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest remove-layout-container-data-test + (t/testing "removes all layout container keys" + (let [shape (make-flex-frame + :layout-gap {:row-gap 5 :column-gap 10} + :layout-gap-type :simple + :layout-wrap-type :wrap + :layout-padding-type :multiple + :layout-padding {:p1 1 :p2 2 :p3 3 :p4 4} + :layout-align-content :start + :layout-justify-content :center + :layout-align-items :stretch + :layout-justify-items :start + :layout-grid-dir :row + :layout-grid-columns [] + :layout-grid-rows []) + result (layout/remove-layout-container-data shape)] + (t/is (not (contains? result :layout))) + (t/is (not (contains? result :layout-flex-dir))) + (t/is (not (contains? result :layout-gap))) + (t/is (not (contains? result :layout-gap-type))) + (t/is (not (contains? result :layout-wrap-type))) + (t/is (not (contains? result :layout-padding-type))) + (t/is (not (contains? result :layout-padding))) + (t/is (not (contains? result :layout-align-content))) + (t/is (not (contains? result :layout-justify-content))) + (t/is (not (contains? result :layout-align-items))) + (t/is (not (contains? result :layout-justify-items))) + (t/is (not (contains? result :layout-grid-dir))) + (t/is (not (contains? result :layout-grid-columns))) + (t/is (not (contains? result :layout-grid-rows))))) + + (t/testing "preserves non-layout keys" + (let [shape (make-flex-frame :name "test-frame") + result (layout/remove-layout-container-data shape)] + (t/is (= :frame (:type result))) + (t/is (= "test-frame" (:name result)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; remove-layout-item-data +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest remove-layout-item-data-test + (t/testing "removes item attributes from non-layout shape" + (let [shape (make-shape :layout-item-margin {:m1 5} + :layout-item-margin-type :simple + :layout-item-max-h 100 + :layout-item-min-h 10 + :layout-item-max-w 200 + :layout-item-min-w 20 + :layout-item-align-self :start + :layout-item-absolute false + :layout-item-z-index 0 + :layout-item-h-sizing :fill + :layout-item-v-sizing :fill) + result (layout/remove-layout-item-data shape)] + (t/is (not (contains? result :layout-item-margin))) + (t/is (not (contains? result :layout-item-margin-type))) + (t/is (not (contains? result :layout-item-max-h))) + (t/is (not (contains? result :layout-item-min-h))) + (t/is (not (contains? result :layout-item-align-self))) + (t/is (not (contains? result :layout-item-absolute))) + (t/is (not (contains? result :layout-item-z-index))) + ;; fill sizing is removed for non-layout shapes + (t/is (not (contains? result :layout-item-h-sizing))) + (t/is (not (contains? result :layout-item-v-sizing))))) + + (t/testing "preserves :fix and :auto sizing on layout frames" + (let [shape (make-flex-frame :layout-item-h-sizing :fix + :layout-item-v-sizing :auto) + result (layout/remove-layout-item-data shape)] + (t/is (= :fix (:layout-item-h-sizing result))) + (t/is (= :auto (:layout-item-v-sizing result))))) + + (t/testing "removes :fill sizing even from layout frames" + (let [shape (make-flex-frame :layout-item-h-sizing :fill + :layout-item-v-sizing :fill) + result (layout/remove-layout-item-data shape)] + (t/is (not (contains? result :layout-item-h-sizing))) + (t/is (not (contains? result :layout-item-v-sizing)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; update-flex-scale / update-grid-scale / update-flex-child +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest update-flex-scale-test + (t/testing "scales gap and padding values" + (let [shape (make-flex-frame :layout-gap {:row-gap 10 :column-gap 20} + :layout-padding {:p1 5 :p2 10 :p3 15 :p4 20}) + result (layout/update-flex-scale shape 2)] + (t/is (= 20 (get-in result [:layout-gap :row-gap]))) + (t/is (= 40 (get-in result [:layout-gap :column-gap]))) + (t/is (= 10 (get-in result [:layout-padding :p1]))) + (t/is (= 20 (get-in result [:layout-padding :p2]))) + (t/is (= 30 (get-in result [:layout-padding :p3]))) + (t/is (= 40 (get-in result [:layout-padding :p4]))))) + + (t/testing "does not fail when layout-gap and layout-padding are absent" + (let [shape (make-frame) + result (layout/update-flex-scale shape 2)] + (t/is (cts/shape? result))))) + +(t/deftest update-grid-scale-test + (t/testing "scales fixed tracks only" + (let [shape (make-grid-frame :layout-grid-columns [{:type :fixed :value 100} + {:type :flex :value 1} + {:type :auto}] + :layout-grid-rows [{:type :fixed :value 50}]) + result (layout/update-grid-scale shape 2)] + (t/is (= 200 (get-in result [:layout-grid-columns 0 :value]))) + ;; flex track not scaled + (t/is (= 1 (get-in result [:layout-grid-columns 1 :value]))) + (t/is (= 100 (get-in result [:layout-grid-rows 0 :value]))))) + + (t/testing "does not fail on empty tracks" + (let [shape (make-grid-frame :layout-grid-columns [] :layout-grid-rows []) + result (layout/update-grid-scale shape 3)] + (t/is (= [] (:layout-grid-columns result)))))) + +(t/deftest update-flex-child-test + (t/testing "scales all child size and margin values" + (let [child (make-shape :layout-item-max-h 100 + :layout-item-min-h 10 + :layout-item-max-w 200 + :layout-item-min-w 20 + :layout-item-margin {:m1 5 :m2 10 :m3 15 :m4 20}) + result (layout/update-flex-child child 2)] + (t/is (= 200 (:layout-item-max-h result))) + (t/is (= 20 (:layout-item-min-h result))) + (t/is (= 400 (:layout-item-max-w result))) + (t/is (= 40 (:layout-item-min-w result))) + (t/is (= 10 (get-in result [:layout-item-margin :m1]))) + (t/is (= 20 (get-in result [:layout-item-margin :m2]))) + (t/is (= 30 (get-in result [:layout-item-margin :m3]))) + (t/is (= 40 (get-in result [:layout-item-margin :m4]))))) + + (t/testing "does not fail when keys are absent" + (let [shape (make-shape) + result (layout/update-flex-child shape 2)] + (t/is (cts/shape? result))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; toggle-fix-if-auto +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest toggle-fix-if-auto-test + (t/testing "changes :fill to :fix for both dimensions" + (let [shape (make-shape :layout-item-h-sizing :fill :layout-item-v-sizing :fill) + result (layout/toggle-fix-if-auto shape)] + (t/is (= :fix (:layout-item-h-sizing result))) + (t/is (= :fix (:layout-item-v-sizing result))))) + + (t/testing "leaves :fix and :auto unchanged" + (let [shape (make-shape :layout-item-h-sizing :fix :layout-item-v-sizing :auto) + result (layout/toggle-fix-if-auto shape)] + (t/is (= :fix (:layout-item-h-sizing result))) + (t/is (= :auto (:layout-item-v-sizing result))))) + + (t/testing "only changes h when v is not fill" + (let [shape (make-shape :layout-item-h-sizing :fill :layout-item-v-sizing :auto) + result (layout/toggle-fix-if-auto shape)] + (t/is (= :fix (:layout-item-h-sizing result))) + (t/is (= :auto (:layout-item-v-sizing result)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Grid track defaults +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest grid-defaults-test + (t/testing "default-track-value has type :flex and value 1" + (t/is (= :flex (:type layout/default-track-value))) + (t/is (= 1 (:value layout/default-track-value)))) + + (t/testing "grid-cell-defaults has expected fields" + (t/is (= 1 (:row-span layout/grid-cell-defaults))) + (t/is (= 1 (:column-span layout/grid-cell-defaults))) + (t/is (= :auto (:position layout/grid-cell-defaults))) + (t/is (= :auto (:align-self layout/grid-cell-defaults))) + (t/is (= :auto (:justify-self layout/grid-cell-defaults))) + (t/is (= [] (:shapes layout/grid-cell-defaults))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; add-grid-track / add-grid-column / add-grid-row +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest add-grid-track-test + (t/testing "add-grid-column appends a column track" + (let [parent (make-grid-frame) + result (layout/add-grid-column parent {:type :flex :value 1})] + (t/is (= 1 (count (:layout-grid-columns result)))) + (t/is (= :flex (get-in result [:layout-grid-columns 0 :type]))))) + + (t/testing "add-grid-row appends a row track" + (let [parent (make-grid-frame) + result (layout/add-grid-row parent {:type :flex :value 1})] + (t/is (= 1 (count (:layout-grid-rows result)))) + (t/is (= :flex (get-in result [:layout-grid-rows 0 :type]))))) + + (t/testing "adding column creates cells for each existing row" + (let [parent (-> (make-grid-frame) + (layout/add-grid-row {:type :flex :value 1}) + (layout/add-grid-row {:type :flex :value 1})) + result (layout/add-grid-column parent {:type :flex :value 1})] + ;; 2 rows x 1 column = 2 cells + (t/is (= 2 (count (:layout-grid-cells result)))))) + + (t/testing "adding row creates cells for each existing column" + (let [parent (-> (make-grid-frame) + (layout/add-grid-column {:type :flex :value 1}) + (layout/add-grid-column {:type :flex :value 1})) + result (layout/add-grid-row parent {:type :flex :value 1})] + ;; 2 columns x 1 row = 2 cells + (t/is (= 2 (count (:layout-grid-cells result)))))) + + (t/testing "add-grid-column at specific index inserts at that position" + (let [parent (-> (make-grid-frame) + (layout/add-grid-column {:type :flex :value 1}) + (layout/add-grid-column {:type :fixed :value 100})) + result (layout/add-grid-column parent {:type :auto} 1)] + (t/is (= 3 (count (:layout-grid-columns result)))) + (t/is (= :auto (get-in result [:layout-grid-columns 1 :type]))))) + + (t/testing "building a 2x2 grid results in 4 cells" + (let [parent (-> (make-grid-frame) + (layout/add-grid-row {:type :flex :value 1}) + (layout/add-grid-row {:type :flex :value 1}) + (layout/add-grid-column {:type :flex :value 1}) + (layout/add-grid-column {:type :flex :value 1}))] + (t/is (= 4 (count (:layout-grid-cells parent)))))) + + (t/testing "cells are 1-indexed" + (let [parent (-> (make-grid-frame) + (layout/add-grid-row {:type :flex :value 1}) + (layout/add-grid-column {:type :flex :value 1})) + cells (vals (:layout-grid-cells parent))] + (t/is (every? #(>= (:row %) 1) cells)) + (t/is (every? #(>= (:column %) 1) cells))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; remove-grid-column / remove-grid-row +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest remove-grid-column-test + (t/testing "removes a column and its cells" + (let [objects {} + parent (-> (make-grid-frame) + (layout/add-grid-row {:type :flex :value 1}) + (layout/add-grid-column {:type :flex :value 1}) + (layout/add-grid-column {:type :flex :value 1})) + result (layout/remove-grid-column parent 0 objects)] + (t/is (= 1 (count (:layout-grid-columns result)))) + (t/is (= 1 (count (:layout-grid-cells result)))))) + + (t/testing "removes a row and its cells" + (let [objects {} + parent (-> (make-grid-frame) + (layout/add-grid-row {:type :flex :value 1}) + (layout/add-grid-row {:type :flex :value 1}) + (layout/add-grid-column {:type :flex :value 1})) + result (layout/remove-grid-row parent 0 objects)] + (t/is (= 1 (count (:layout-grid-rows result)))) + (t/is (= 1 (count (:layout-grid-cells result))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; cells-seq / get-free-cells / get-cells +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest cells-seq-test + (let [cell-a (make-cell :row 1 :column 2) + cell-b (make-cell :row 1 :column 1) + cell-c (make-cell :row 2 :column 1) + parent {:layout-grid-dir :row + :layout-grid-cells {(:id cell-a) cell-a + (:id cell-b) cell-b + (:id cell-c) cell-c}}] + + (t/testing "cells-seq returns all cells as sequence" + (t/is (= 3 (count (layout/cells-seq parent))))) + + (t/testing "cells-seq sorted by row then column in :row dir" + (let [sorted (layout/cells-seq parent :sort? true)] + (t/is (= [cell-b cell-a cell-c] sorted)))) + + (t/testing "cells-seq sorted by column then row in :column dir" + (let [parent-col (assoc parent :layout-grid-dir :column) + sorted (layout/cells-seq parent-col :sort? true)] + ;; column 1 comes first: cell-b (row 1), cell-c (row 2), then cell-a (col 2, row 1) + (t/is (= [cell-b cell-c cell-a] sorted)))))) + +(t/deftest get-free-cells-test + (let [cell-empty (make-cell :row 1 :column 1 :shapes []) + cell-full (make-cell :row 1 :column 2 :shapes [(uuid/next)]) + parent {:layout-grid-dir :row + :layout-grid-cells {(:id cell-empty) cell-empty + (:id cell-full) cell-full}}] + + (t/testing "get-free-cells returns only empty cell ids" + (let [free (layout/get-free-cells parent)] + (t/is (= 1 (count free))) + (t/is (contains? (set free) (:id cell-empty))))) + + (t/testing "get-cells without remove-empty? returns all cells" + (t/is (= 2 (count (layout/get-cells parent))))) + + (t/testing "get-cells with remove-empty? filters empty ones" + (t/is (= 1 (count (layout/get-cells parent {:remove-empty? true}))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; in-cell? / cell-by-row-column +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest in-cell?-test + (t/testing "returns true when row/column is within cell boundaries" + (let [cell {:row 2 :column 2 :row-span 2 :column-span 2}] + (t/is (layout/in-cell? cell 2 2)) + (t/is (layout/in-cell? cell 3 3)) + (t/is (layout/in-cell? cell 2 3)) + (t/is (layout/in-cell? cell 3 2)))) + + (t/testing "returns false outside boundaries" + (let [cell {:row 2 :column 2 :row-span 2 :column-span 2}] + (t/is (not (layout/in-cell? cell 1 2))) + (t/is (not (layout/in-cell? cell 4 2))) + (t/is (not (layout/in-cell? cell 2 1))) + (t/is (not (layout/in-cell? cell 2 4))))) + + (t/testing "span=1 cell: exact row/column only" + (let [cell {:row 3 :column 4 :row-span 1 :column-span 1}] + (t/is (layout/in-cell? cell 3 4)) + (t/is (not (layout/in-cell? cell 3 5)))))) + +(t/deftest cell-by-row-column-test + (let [cell-a (make-cell :row 1 :column 1 :row-span 1 :column-span 1) + cell-b (make-cell :row 1 :column 2 :row-span 1 :column-span 1) + parent {:layout-grid-cells {(:id cell-a) cell-a + (:id cell-b) cell-b}}] + + (t/testing "finds cell by exact row and column" + (t/is (= cell-a (layout/cell-by-row-column parent 1 1))) + (t/is (= cell-b (layout/cell-by-row-column parent 1 2)))) + + (t/testing "returns nil when no cell at position" + (t/is (nil? (layout/cell-by-row-column parent 2 1)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; cells-by-row / cells-by-column +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest cells-by-row-test + (let [cell-a (make-cell :row 1 :column 1 :row-span 1 :column-span 1) + cell-b (make-cell :row 2 :column 1 :row-span 1 :column-span 1) + cell-c (make-cell :row 1 :column 2 :row-span 2 :column-span 1) + parent {:layout-grid-cells {(:id cell-a) cell-a + (:id cell-b) cell-b + (:id cell-c) cell-c}}] + + (t/testing "cells-by-row returns cells matching row index (0-based index)" + ;; index 0 => row 1 + (let [result (set (layout/cells-by-row parent 0))] + (t/is (contains? result cell-a)) + (t/is (contains? result cell-c)) + (t/is (not (contains? result cell-b))))) + + (t/testing "cells-by-row with check-span? false: exact row only" + (let [result (set (layout/cells-by-row parent 0 false))] + (t/is (contains? result cell-a)) + (t/is (contains? result cell-c)))) + + (t/testing "cells-by-column returns cells matching column index" + ;; index 0 => column 1 + (let [result (set (layout/cells-by-column parent 0))] + (t/is (contains? result cell-a)) + (t/is (contains? result cell-b))) + ;; index 1 => column 2 + (let [result (set (layout/cells-by-column parent 1))] + (t/is (contains? result cell-c)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; free-cell-shapes +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest free-cell-shapes-test + (let [shape-id-1 (uuid/next) + shape-id-2 (uuid/next) + cell-a (make-cell :row 1 :column 1 :shapes [shape-id-1]) + cell-b (make-cell :row 1 :column 2 :shapes [shape-id-2]) + parent {:layout-grid-cells {(:id cell-a) cell-a + (:id cell-b) cell-b}}] + + (t/testing "clears shapes from matching cells" + (let [result (layout/free-cell-shapes parent [shape-id-1])] + (t/is (= [] (get-in result [:layout-grid-cells (:id cell-a) :shapes]))) + ;; cell-b is unaffected + (t/is (= [shape-id-2] (get-in result [:layout-grid-cells (:id cell-b) :shapes]))))) + + (t/testing "no-op when shape-id not in any cell" + (let [other-id (uuid/next) + result (layout/free-cell-shapes parent [other-id])] + (t/is (= [shape-id-1] (get-in result [:layout-grid-cells (:id cell-a) :shapes]))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; get-cell-by-shape-id +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest get-cell-by-shape-id-test + (let [shape-id (uuid/next) + cell-a (make-cell :row 1 :column 1 :shapes [shape-id]) + cell-b (make-cell :row 1 :column 2 :shapes []) + parent {:layout-grid-cells {(:id cell-a) cell-a + (:id cell-b) cell-b}}] + + (t/testing "finds cell containing the shape id" + (t/is (= cell-a (layout/get-cell-by-shape-id parent shape-id)))) + + (t/testing "returns nil when shape not in any cell" + (t/is (nil? (layout/get-cell-by-shape-id parent (uuid/next))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; swap-shapes (note the :podition typo in the source) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest swap-shapes-test + (let [cell-id-a (uuid/next) + cell-id-b (uuid/next) + shape-id-1 (uuid/next) + shape-id-2 (uuid/next) + cell-a {:id cell-id-a :row 1 :column 1 :shapes [shape-id-1] :position :auto} + cell-b {:id cell-id-b :row 1 :column 2 :shapes [shape-id-2] :position :manual} + parent {:layout-grid-cells {cell-id-a cell-a + cell-id-b cell-b}}] + + (t/testing "swaps shapes between two cells" + (let [result (layout/swap-shapes parent cell-id-a cell-id-b)] + ;; cell-a now has cell-b's shapes + (t/is (= [shape-id-2] (get-in result [:layout-grid-cells cell-id-a :shapes]))) + ;; cell-b now has cell-a's shapes + (t/is (= [shape-id-1] (get-in result [:layout-grid-cells cell-id-b :shapes]))) + ;; cell-b's :position was properly set from cell-a + (t/is (= :auto (get-in result [:layout-grid-cells cell-id-b :position]))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; create-cells +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest create-cells-test + (t/testing "creates cells for given area" + ;; area: [column row column-span row-span] + (let [parent {:layout-grid-cells {}} + result (layout/create-cells parent [1 1 2 2])] + ;; 2x2 area = 4 cells + (t/is (= 4 (count (:layout-grid-cells result)))))) + + (t/testing "each created cell has row-span and column-span of 1" + (let [parent {:layout-grid-cells {}} + result (layout/create-cells parent [2 3 2 1])] + (t/is (every? #(= 1 (:row-span %)) (vals (:layout-grid-cells result)))) + (t/is (every? #(= 1 (:column-span %)) (vals (:layout-grid-cells result)))))) + + (t/testing "1x1 area creates a single cell" + (let [parent {:layout-grid-cells {}} + result (layout/create-cells parent [1 1 1 1])] + (t/is (= 1 (count (:layout-grid-cells result))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; valid-area-cells? / cells-coordinates +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest valid-area-cells?-test + (t/testing "returns true for a solid rectangular area" + (let [cells [(make-cell :row 1 :column 1 :row-span 1 :column-span 1) + (make-cell :row 1 :column 2 :row-span 1 :column-span 1) + (make-cell :row 2 :column 1 :row-span 1 :column-span 1) + (make-cell :row 2 :column 2 :row-span 1 :column-span 1)]] + (t/is (layout/valid-area-cells? cells)))) + + (t/testing "returns false for an L-shaped area (has a gap)" + ;; Only 3 out of a 2x2 bounding box + (let [cells [(make-cell :row 1 :column 1 :row-span 1 :column-span 1) + (make-cell :row 1 :column 2 :row-span 1 :column-span 1) + (make-cell :row 2 :column 1 :row-span 1 :column-span 1)]] + (t/is (not (layout/valid-area-cells? cells))))) + + (t/testing "returns true for a single cell" + (let [cells [(make-cell :row 2 :column 3 :row-span 1 :column-span 1)]] + (t/is (layout/valid-area-cells? cells))))) + +(t/deftest cells-coordinates-test + (t/testing "computes bounding coordinates for a set of cells" + (let [cells [(make-cell :row 1 :column 1 :row-span 1 :column-span 1) + (make-cell :row 2 :column 3 :row-span 1 :column-span 1)] + result (layout/cells-coordinates cells)] + (t/is (= 1 (:first-row result))) + (t/is (= 2 (:last-row result))) + (t/is (= 1 (:first-column result))) + (t/is (= 3 (:last-column result))))) + + (t/testing "single cell with span returns correct last values" + (let [cells [(make-cell :row 3 :column 4 :row-span 2 :column-span 2)] + result (layout/cells-coordinates cells)] + (t/is (= 3 (:first-row result))) + (t/is (= 4 (:last-row result))) + (t/is (= 4 (:first-column result))) + (t/is (= 5 (:last-column result)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; remap-grid-cells +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest remap-grid-cells-test + (let [old-id (uuid/next) + new-id (uuid/next) + cell (make-cell :row 1 :column 1 :shapes [old-id]) + parent {:layout-grid-cells {(:id cell) cell}}] + + (t/testing "remaps shape ids in cells using ids-map" + (let [result (layout/remap-grid-cells parent {old-id new-id})] + (t/is (= [new-id] (get-in result [:layout-grid-cells (:id cell) :shapes]))))) + + (t/testing "keeps original id when not in ids-map" + (let [result (layout/remap-grid-cells parent {})] + (t/is (= [old-id] (get-in result [:layout-grid-cells (:id cell) :shapes]))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; reorder-grid-children +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest reorder-grid-children-test + (let [shape-id-1 (uuid/next) + shape-id-2 (uuid/next) + cell-a (make-cell :row 1 :column 1 :shapes [shape-id-2]) + cell-b (make-cell :row 1 :column 2 :shapes [shape-id-1]) + parent {:layout-grid-dir :row + :shapes [shape-id-1 shape-id-2] + :layout-grid-cells {(:id cell-a) cell-a + (:id cell-b) cell-b}}] + + (t/testing "reorders shapes to match cell order" + ;; sorted by row/col: cell-a first (col 1), cell-b second (col 2) + ;; so shape-id-2 before shape-id-1 in new order; reorder-grid-children reverses + (let [result (layout/reorder-grid-children parent)] + (t/is (vector? (:shapes result))) + (t/is (= 2 (count (:shapes result)))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; merge-cells +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest merge-cells-test + (let [cell-id (uuid/next) + source-cell {:id cell-id :row 1 :column 1 :row-span 1 :column-span 1 + :position :auto :shapes [] :align-self :center} + target-cell {:id cell-id :row 1 :column 1 :row-span 1 :column-span 1 + :position :manual :shapes [] :align-self :start} + source-cells {cell-id source-cell} + target-cells {cell-id target-cell}] + + (t/testing "omit-touched? false returns source unchanged" + (let [result (layout/merge-cells target-cells source-cells false)] + (t/is (= source-cells result)))) + + (t/testing "omit-touched? true merges target into source preserving row/col" + (let [result (layout/merge-cells target-cells source-cells true)] + ;; position/align-self come from target-cell (patched into source) + (t/is (= :manual (get-in result [cell-id :position]))) + ;; row/column are preserved from source + (t/is (= 1 (get-in result [cell-id :row]))) + (t/is (= 1 (get-in result [cell-id :column]))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; assign-cells (integration test) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest assign-cells-test + (t/testing "assigns shapes to cells in an empty grid" + (let [child (make-shape) + objects {(:id child) child} + parent (-> (make-grid-frame :shapes [(:id child)]) + (layout/add-grid-row {:type :flex :value 1}) + (layout/add-grid-column {:type :flex :value 1})) + result (layout/assign-cells parent objects)] + (t/is (= 1 (count (:layout-grid-cells result)))) + (let [cell (first (vals (:layout-grid-cells result)))] + (t/is (= [(:id child)] (:shapes cell)))))) + + (t/testing "empty parent with no shapes is a no-op" + (let [parent (-> (make-grid-frame :shapes []) + (layout/add-grid-row {:type :flex :value 1}) + (layout/add-grid-column {:type :flex :value 1})) + result (layout/assign-cells parent {})] + (t/is (= 1 (count (:layout-grid-cells result)))) + (t/is (every? #(empty? (:shapes %)) (vals (:layout-grid-cells result)))))) + + (t/testing "absolute positioned shapes are not assigned to cells" + (let [child (make-shape :layout-item-absolute true) + objects {(:id child) child} + parent (-> (make-grid-frame :shapes [(:id child)]) + (layout/add-grid-row {:type :flex :value 1}) + (layout/add-grid-column {:type :flex :value 1})) + result (layout/assign-cells parent objects)] + ;; no shape should be assigned to any cell + (t/is (every? #(empty? (:shapes %)) (vals (:layout-grid-cells result)))))) + + (t/testing "auto-creates columns when shapes exceed capacity (row-dir)" + (let [children [(make-shape) (make-shape) (make-shape)] + objects (into {} (map (fn [s] [(:id s) s]) children)) + parent (-> (make-grid-frame :shapes (mapv :id children)) + (layout/add-grid-row {:type :flex :value 1}) + (layout/add-grid-column {:type :flex :value 1})) + result (layout/assign-cells parent objects)] + ;; Should auto-create extra columns to fit 3 shapes in 1 row + (t/is (<= 3 (count (:layout-grid-cells result))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; check-deassigned-cells +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest check-deassigned-cells-test + (t/testing "removes shape ids no longer in parent's shapes list" + (let [old-id (uuid/next) + cell (make-cell :row 1 :column 1 :shapes [old-id]) + parent {:shapes [] + :layout-grid-cells {(:id cell) cell}} + result (layout/check-deassigned-cells parent {})] + (t/is (= [] (get-in result [:layout-grid-cells (:id cell) :shapes]))))) + + (t/testing "keeps shape ids still present in parent shapes" + (let [child (make-shape) + cell (make-cell :row 1 :column 1 :shapes [(:id child)]) + objects {(:id child) child} + parent {:shapes [(:id child)] + :layout-grid-cells {(:id cell) cell}} + result (layout/check-deassigned-cells parent objects)] + (t/is (= [(:id child)] (get-in result [:layout-grid-cells (:id cell) :shapes]))))) + + (t/testing "removes absolute-positioned shapes from cells" + (let [child (make-shape :layout-item-absolute true) + cell (make-cell :row 1 :column 1 :shapes [(:id child)]) + objects {(:id child) child} + parent {:shapes [(:id child)] + :layout-grid-cells {(:id cell) cell}} + result (layout/check-deassigned-cells parent objects)] + (t/is (= [] (get-in result [:layout-grid-cells (:id cell) :shapes])))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; cells-in-area +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest cells-in-area-test + (let [cell-a (make-cell :row 1 :column 1 :row-span 1 :column-span 1) + cell-b (make-cell :row 2 :column 2 :row-span 1 :column-span 1) + cell-c (make-cell :row 3 :column 3 :row-span 1 :column-span 1) + parent {:layout-grid-cells {(:id cell-a) cell-a + (:id cell-b) cell-b + (:id cell-c) cell-c}}] + + (t/testing "returns cells that overlap with the area" + (let [result (set (layout/cells-in-area parent 1 2 1 2))] + (t/is (contains? result cell-a)) + (t/is (contains? result cell-b)) + (t/is (not (contains? result cell-c))))) + + (t/testing "returns empty when area is outside all cells" + (let [result (layout/cells-in-area parent 10 10 10 10)] + (t/is (empty? result)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; shapes-by-row / shapes-by-column +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest shapes-by-row-column-test + (let [shape-id-1 (uuid/next) + shape-id-2 (uuid/next) + cell-a (make-cell :row 1 :column 1 :shapes [shape-id-1]) + cell-b (make-cell :row 2 :column 1 :shapes [shape-id-2]) + parent {:layout-grid-cells {(:id cell-a) cell-a + (:id cell-b) cell-b}}] + + (t/testing "shapes-by-row returns shapes in matching row" + ;; 0-indexed: index 0 = row 1 + (t/is (= [shape-id-1] (layout/shapes-by-row parent 0))) + (t/is (= [shape-id-2] (layout/shapes-by-row parent 1)))) + + (t/testing "shapes-by-column returns shapes in matching column" + ;; 0-indexed: index 0 = column 1 + (let [result (set (layout/shapes-by-column parent 0))] + (t/is (contains? result shape-id-1)) + (t/is (contains? result shape-id-2)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Layout constants / sets +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest layout-type-sets-test + (t/testing "layout-types contains :flex and :grid" + (t/is (contains? layout/layout-types :flex)) + (t/is (contains? layout/layout-types :grid))) + + (t/testing "valid-layouts equals layout-types" + (t/is (= layout/layout-types layout/valid-layouts))) + + (t/testing "flex-direction-types" + (t/is (= #{:row :row-reverse :column :column-reverse} layout/flex-direction-types))) + + (t/testing "grid-direction-types" + (t/is (= #{:row :column} layout/grid-direction-types))) + + (t/testing "gap-types" + (t/is (= #{:simple :multiple} layout/gap-types))) + + (t/testing "wrap-types" + (t/is (= #{:wrap :nowrap} layout/wrap-types))) + + (t/testing "grid-track-types" + (t/is (= #{:percent :flex :auto :fixed} layout/grid-track-types))) + + (t/testing "grid-position-types" + (t/is (= #{:auto :manual :area} layout/grid-position-types)))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; change-h-sizing? / change-v-sizing? +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest change-sizing-tests + (t/testing "change-h-sizing? true in col direction when all children fill-width" + (let [frame (make-flex-frame :layout-flex-dir :column + :layout-item-h-sizing :auto) + child-1 (make-shape :layout-item-h-sizing :fill) + child-2 (make-shape :layout-item-h-sizing :fill) + objects {(:id frame) frame + (:id child-1) child-1 + (:id child-2) child-2}] + (t/is (layout/change-h-sizing? (:id frame) objects [(:id child-1) (:id child-2)])))) + + (t/testing "change-h-sizing? false when not all children fill-width in col" + (let [frame (make-flex-frame :layout-flex-dir :column + :layout-item-h-sizing :auto) + child-1 (make-shape :layout-item-h-sizing :fill) + child-2 (make-shape :layout-item-h-sizing :fix) + objects {(:id frame) frame + (:id child-1) child-1 + (:id child-2) child-2}] + (t/is (not (layout/change-h-sizing? (:id frame) objects [(:id child-1) (:id child-2)]))))) + + (t/testing "change-v-sizing? true in row direction when all children fill-height" + (let [frame (make-flex-frame :layout-flex-dir :row + :layout-item-v-sizing :auto) + child-1 (make-shape :layout-item-v-sizing :fill) + child-2 (make-shape :layout-item-v-sizing :fill) + objects {(:id frame) frame + (:id child-1) child-1 + (:id child-2) child-2}] + (t/is (layout/change-v-sizing? (:id frame) objects [(:id child-1) (:id child-2)])))) + + (t/testing "change-v-sizing? false when frame is not auto-height" + (let [frame (make-flex-frame :layout-flex-dir :row + :layout-item-v-sizing :fix) + child-1 (make-shape :layout-item-v-sizing :fill) + objects {(:id frame) frame (:id child-1) child-1}] + (t/is (not (layout/change-v-sizing? (:id frame) objects [(:id child-1)])))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; get-cell-by-position +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest get-cell-by-position-test + (let [cell-a (make-cell :row 1 :column 1 :row-span 2 :column-span 2) + cell-b (make-cell :row 3 :column 1 :row-span 1 :column-span 1) + parent {:layout-grid-cells {(:id cell-a) cell-a + (:id cell-b) cell-b}}] + + (t/testing "returns cell containing position" + (t/is (= cell-a (layout/get-cell-by-position parent 1 1))) + (t/is (= cell-a (layout/get-cell-by-position parent 2 2))) + (t/is (= cell-b (layout/get-cell-by-position parent 3 1)))) + + (t/testing "returns nil when no cell at position" + (t/is (nil? (layout/get-cell-by-position parent 5 5)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; reorder-grid-column / reorder-grid-row +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest reorder-grid-column-test + (t/testing "moves a column from one index to another" + (let [parent (-> (make-grid-frame) + (layout/add-grid-column {:type :fixed :value 100}) + (layout/add-grid-column {:type :fixed :value 200}) + (layout/add-grid-column {:type :fixed :value 300})) + result (layout/reorder-grid-column parent 0 2 false)] + (t/is (= 3 (count (:layout-grid-columns result))))))) + +(t/deftest reorder-grid-row-test + (t/testing "moves a row from one index to another" + (let [parent (-> (make-grid-frame) + (layout/add-grid-row {:type :fixed :value 100}) + (layout/add-grid-row {:type :fixed :value 200}) + (layout/add-grid-row {:type :fixed :value 300})) + result (layout/reorder-grid-row parent 0 2 false)] + (t/is (= 3 (count (:layout-grid-rows result))))))) From b6524881e09f5c1e5da399564119a2da7367d166 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 30 Mar 2026 11:04:54 +0200 Subject: [PATCH 12/15] :bug: Fix crash in apply-text-modifier with nil selrect or modifier (#8762) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :bug: Fix crash in apply-text-modifier with nil selrect or modifier Guard apply-text-modifier against nil text-modifier and nil selrect to prevent the 'invalid arguments (on pointer constructor)' error thrown by gpt/point when called with an invalid map. - In text-wrapper: only call apply-text-modifier when text-modifier is not nil (avoids unnecessary processing) - In apply-text-modifier: handle nil text-modifier by returning shape unchanged; guard selrect access before calling gpt/point * :books: Add tests for apply-text-modifier in workspace texts Add exhaustive unit tests covering all paths of apply-text-modifier: - nil modifier returns shape unchanged (identity) - modifier with no recognised keys leaves shape unchanged - :width / :height modifiers resize shape correctly - nil :width / :height keys are skipped - both dimensions applied simultaneously - :position-data is set and nil-guarded - position-data coordinates translated by delta on resize - shape with nil selrect + nil modifier does not throw - position-data-only modifier on shape without selrect is safe - selrect origin preserved when no dimension changes - result always carries required shape keys * :bug: Fix zero-dimension selrect crash in change-dimensions-modifiers When a text shape is decoded from the server via map->Rect (which bypasses make-rect's 0.01 minimum enforcement), its selrect can have width or height of exactly 0. change-dimensions-modifiers and change-size were dividing by these values, producing Infinity scale factors that propagated through the transform pipeline until calculate-selrect / center->rect returned nil, causing gpt/point to throw 'invalid arguments (on pointer constructor)'. Fix: before computing scale factors, guard sr-width / sr-height (and old-width / old-height in change-size) against zero/negative and non-finite values. When degenerate, fall back to the shape's own top-level :width/:height so the denominator and proportion-lock base remain consistent. Also simplify apply-text-modifier's delta calculation now that the transform pipeline is guaranteed to produce a valid selrect, and update the test suite to test the exact degenerate-selrect scenario that triggered the original crash. Signed-off-by: Andrey Antukh * :recycle: Simplify change-dimensions-modifiers internal logic - Remove the intermediate 'size' map ({:width sr-width :height sr-height}) that was built only to be assoc'd and immediately destructured back into width/height; compute both values directly instead. - Replace the double-negated condition 'if-not (and (not ignore-lock?) …)' with a clear positive 'locked?' binding, and flatten the three-branch if-not/if tree into two independent if expressions keyed on 'attr'. - Call safe-size-rect once and reuse its result for both the fallback sizes and the scale computation, eliminating a redundant call. - Access :transform and :transform-inverse via direct map lookup rather than destructuring in the function signature, consistent with how the rest of the let-block reads shape keys. - Clean up change-size to use the same destructuring style as the updated function ({sr-width :width sr-height :height}). - Fix typo in comment: 'havig' -> 'having'. * :sparkles: Add tests for change-size and change-dimensions-modifiers Cover the main behavioural contract of both functions: change-size: - Scales both axes to the requested target dimensions. - Sets the resize origin to the shape's top-left point. - Nil width/height each fall back to the current dimension (scale 1 on that axis); both nil produces an identity resize that is optimised away. - Propagates the shape's transform and transform-inverse matrices into the resulting GeometricOperation. change-dimensions-modifiers: - Changing :width without proportion-lock only scales the x-axis (y scale stays 1), and vice-versa for :height. - With proportion-lock enabled, changing :width adjusts height via the inverse proportion, and changing :height adjusts width via the proportion. - ignore-lock? true bypasses proportion-lock regardless of shape state. - Values below 0.01 are clamped to 0.01 before computing the scale. - End-to-end: applying the returned modifiers via gsh/transform-shape yields the expected selrect dimensions. * :sparkles: Harden safe-size-rect with additional fallbacks The previous implementation could still return an invalid rect in several edge cases. The new version tries four sources in order, accepting each only if it passes a dedicated safe-size-rect? predicate: 1. :selrect – used when width and height are finite, positive and within [-max-safe-int, max-safe-int]. 2. points->rect – computed from the shape corner points; subject to the same predicate. 3. Top-level shape fields (:x :y :width :height) – present on all rect, frame, image, and component shape types. 4. grc/empty-rect – a 0,0 0.01×0.01 unit rect used as last resort so callers always receive a usable, non-crashing value. The out-of-range check (> max-safe-int) is new: it rejects coordinates that pass d/num? (finite) but exceed the platform integer boundary defined in app.common.schema, which previously slipped through undetected. Tests cover all four fallback paths, including the NaN, zero-dimension, and max-safe-int overflow cases. * :zap: Optimise safe-size-rect for ClojureScript performance - Replace (when (some? rect) ...) with (and ^boolean (some? rect) ...) to keep the entire predicate as a single boolean expression without introducing an implicit conditional branch. - Replace keyword access (:width rect) / (:height rect) with dm/get-prop calls, consistent with the hot-path style used throughout the rest of the namespace. - Add ^boolean type hints to every sub-expression of the and chain in safe-size-rect? (d/num?, pos?, <=) so the ClojureScript compiler emits raw JS boolean operations instead of boxing the results through cljs.core/truth_. - Replace (when (safe-size-rect? ...) value) in safe-size-rect with (and ^boolean (safe-size-rect? ...) value), avoiding an extra conditional and keeping the or fallback chain free of allocated intermediate objects. * :sparkles: Use safe-size-rect in apply-text-modifier delta-move computation safe-size-rect was already used inside change-dimensions-modifiers to guard the resize scale computation. However, apply-text-modifier in texts.cljs was still reading (:selrect shape) and (:selrect new-shape) directly to build the delta-move vector via gpt/point. gpt/point raises "invalid arguments (on pointer constructor)" when given a nil value or a map with non-finite :x/:y, which can happen when a shape's selrect is missing or degenerate (e.g. decoded from the server via map->Rect, bypassing make-rect's 0.01 floor). Changes: - Promote safe-size-rect from defn- to defn in app.common.types.modifiers so it can be reused by consumers outside the namespace. - Replace the two raw (:selrect …) accesses in the delta-move computation with (ctm/safe-size-rect …), which always returns a valid, finite rect through the established four-step fallback chain. - Add two frontend tests covering the delta-move path with a fully degenerate (zero-dimension) selrect, ensuring neither a bare position-data modifier nor a combined width+position-data modifier throws. * :recycle: Ensure all test shapes are proper Shape records in modifiers-test All shapes in safe-size-rect-fallbacks tests now start from a proper Shape record built by cts/setup-shape (via make-shape) instead of plain hash-maps. Each test that mutates geometry fields (selrect, points, width, height) does so via assoc on the already-initialised record, which preserves the correct type while isolating the field under test. A (cts/shape? shape) assertion is added to each fallback test to make the type guarantee explicit and guard against regressions. The unused shape-with-selrect helper (which built a bare map) is removed. * :fire: Remove dead code and tighten visibility in app.common.types.modifiers Dead functions removed (zero callers across the entire codebase): - modifiers->transform-old: superseded by modifiers->transform; only ever appeared in a commented-out dev/bench.cljs entry. - change-recursive-property: no callers anywhere. - move-parent-modifiers, resize-parent-modifiers: convenience wrappers for the parent-geometry builder functions; never called. - remove-children-modifiers, add-children-modifiers, scale-content-modifiers: single-op convenience builders; never called. - select-structure: projection helper; only referenced by select-child-geometry-modifiers which is itself dead. - select-child-geometry-modifiers: no callers anywhere. Functions narrowed from defn to defn- (used only within this namespace): - valid-vector?: assertion helper called only by move/resize builders. - increase-order: called only by add-modifiers. - transform-move!, transform-resize!, transform-rotate!, transform!: steps of the modifiers->transform pipeline. - modifiers->transform1: immediate helper for modifiers->transform; the doc-string describing it as 'multiplatform' was also removed since it is an implementation detail. - transform-text-node, transform-paragraph-node: leaf helpers for scale-text-content. - update-text-content, scale-text-content, apply-scale-content: internal scale-content pipeline; all called only by apply-modifier. - remove-children-set: called only by apply-modifier. - select-structure: demoted to defn- rather than deleted because it is still called by select-child-structre-modifiers, which has external callers. * :sparkles: Add more tests for modifiers --------- Signed-off-by: Andrey Antukh --- common/src/app/common/types/modifiers.cljc | 198 ++---- .../common_tests/types/modifiers_test.cljc | 633 ++++++++++++++++++ .../src/app/main/data/workspace/texts.cljs | 33 +- .../app/main/ui/workspace/shapes/text.cljs | 2 +- .../data/workspace_texts_test.cljs | 274 ++++++++ frontend/test/frontend_tests/runner.cljs | 2 + 6 files changed, 994 insertions(+), 148 deletions(-) create mode 100644 frontend/test/frontend_tests/data/workspace_texts_test.cljs diff --git a/common/src/app/common/types/modifiers.cljc b/common/src/app/common/types/modifiers.cljc index 2f79e6483f..68ab9e2584 100644 --- a/common/src/app/common/types/modifiers.cljc +++ b/common/src/app/common/types/modifiers.cljc @@ -12,11 +12,13 @@ [app.common.files.helpers :as cfh] [app.common.geom.matrix :as gmt] [app.common.geom.point :as gpt] + [app.common.geom.rect :as grc] [app.common.geom.shapes.common :as gco] [app.common.geom.shapes.corners :as gsc] [app.common.geom.shapes.effects :as gse] [app.common.geom.shapes.strokes :as gss] [app.common.math :as mth] + [app.common.schema :as sm] [app.common.types.shape.layout :as ctl] [app.common.types.text :as txt] [clojure.core :as c])) @@ -117,6 +119,33 @@ (or (not ^boolean (mth/almost-zero? (- (dm/get-prop vector :x) 1))) (not ^boolean (mth/almost-zero? (- (dm/get-prop vector :y) 1))))) +(defn- safe-size-rect? + "Returns true when `rect` has finite, in-range, positive width and height." + [rect] + (and ^boolean (some? rect) + (let [w (dm/get-prop rect :width) + h (dm/get-prop rect :height)] + (and ^boolean (d/num? w h) + ^boolean (pos? w) + ^boolean (pos? h) + ^boolean (<= w sm/max-safe-int) + ^boolean (<= h sm/max-safe-int))))) + +(defn safe-size-rect + "Returns the best available size rect for a shape, trying several + fallbacks in order: + 1. `:selrect` — if it has valid, in-range, positive dimensions. + 2. `points->rect` — computed from the shape's corner points. + 3. Top-level `:x :y :width :height` shape fields. + 4. `grc/empty-rect` — a unit rect (0,0,0.01,0.01) of last resort." + [{:keys [selrect points x y width height]}] + (or (and ^boolean (safe-size-rect? selrect) selrect) + (let [from-points (grc/points->rect points)] + (and ^boolean (safe-size-rect? from-points) from-points)) + (let [from-shape (grc/make-rect x y width height)] + (and ^boolean (safe-size-rect? from-shape) from-shape)) + grc/empty-rect)) + (defn- mergeable-move? [op1 op2] (let [type-op1 (dm/get-prop op1 :type) @@ -195,7 +224,7 @@ (conj item))) (conj operations op)))))) -(defn valid-vector? +(defn- valid-vector? [vector] (let [x (dm/get-prop vector :x) y (dm/get-prop vector :y)] @@ -309,11 +338,6 @@ (-> (or modifiers (empty)) (update :structure-child conj (scale-content-op value)))) -(defn change-recursive-property - [modifiers property value] - (-> (or modifiers (empty)) - (update :structure-child conj (change-property-op property value)))) - (defn change-property [modifiers property value] (-> (or modifiers (empty)) @@ -348,7 +372,7 @@ (recur result (rest operations))))))) -(defn increase-order +(defn- increase-order [operations last-order] (->> operations (mapv #(update % :order + last-order)))) @@ -390,13 +414,6 @@ ([vector] (move (empty) vector))) -(defn move-parent-modifiers - ([x y] - (move-parent (empty) (gpt/point x y))) - - ([vector] - (move-parent (empty) vector))) - (defn resize-modifiers ([vector origin] (resize (empty) vector origin)) @@ -404,13 +421,6 @@ ([vector origin transform transform-inverse] (resize (empty) vector origin transform transform-inverse))) -(defn resize-parent-modifiers - ([vector origin] - (resize-parent (empty) vector origin)) - - ([vector origin transform transform-inverse] - (resize-parent (empty) vector origin transform transform-inverse))) - (defn rotation-modifiers [shape center angle] (let [shape-center (gco/shape->center shape) @@ -426,73 +436,54 @@ (rotation shape-center angle) (move move-vec)))) -(defn remove-children-modifiers - [shapes] - (-> (empty) - (remove-children shapes))) - -(defn add-children-modifiers - [shapes index] - (-> (empty) - (add-children shapes index))) - (defn reflow-modifiers [] (-> (empty) (reflow))) -(defn scale-content-modifiers - [value] - (-> (empty) - (scale-content value))) - (defn change-size - [{:keys [selrect points transform transform-inverse] :as shape} width height] - (let [old-width (-> selrect :width) - old-height (-> selrect :height) - width (or width old-width) - height (or height old-height) - origin (first points) - scalex (/ width old-width) - scaley (/ height old-height)] + [{:keys [points transform transform-inverse] :as shape} width height] + (let [{sr-width :width sr-height :height} (safe-size-rect shape) + width (or width sr-width) + height (or height sr-height) + origin (first points) + scalex (/ width sr-width) + scaley (/ height sr-height)] (resize-modifiers (gpt/point scalex scaley) origin transform transform-inverse))) (defn change-dimensions-modifiers ([shape attr value] (change-dimensions-modifiers shape attr value nil)) - ([{:keys [transform transform-inverse] :as shape} attr value {:keys [ignore-lock?] :or {ignore-lock? false}}] + ([shape attr value {:keys [ignore-lock?] :or {ignore-lock? false}}] (dm/assert! (map? shape)) (dm/assert! (#{:width :height} attr)) (dm/assert! (number? value)) - (let [;; Avoid havig shapes with zero size - value (if (< (mth/abs value) 0.01) - 0.01 - value) + (let [;; Avoid having shapes with zero size + value (if (< (mth/abs value) 0.01) 0.01 value) {:keys [proportion proportion-lock]} shape - size (select-keys (:selrect shape) [:width :height]) - new-size (if-not (and (not ignore-lock?) proportion-lock) - (assoc size attr value) - (if (= attr :width) - (-> size - (assoc :width value) - (assoc :height (/ value proportion))) - (-> size - (assoc :height value) - (assoc :width (* value proportion))))) + {sr-width :width sr-height :height} (safe-size-rect shape) + locked? (and (not ignore-lock?) proportion-lock) - width (:width new-size) - height (:height new-size) + width (if (= attr :width) + value + (if locked? (* value proportion) sr-width)) - {sr-width :width sr-height :height} (:selrect shape) + height (if (= attr :height) + value + (if locked? (/ value proportion) sr-height)) origin (-> shape :points first) scalex (/ width sr-width) scaley (/ height sr-height)] - (resize-modifiers (gpt/point scalex scaley) origin transform transform-inverse)))) + (resize-modifiers + (gpt/point scalex scaley) + origin + (:transform shape) + (:transform-inverse shape))))) (defn change-orientation-modifiers [shape orientation] @@ -566,7 +557,7 @@ [modifiers] (assoc (or modifiers (empty)) :geometry-child [] :structure-child [])) -(defn select-structure +(defn- select-structure [modifiers] (assoc (or modifiers (empty)) :geometry-child [] :geometry-parent [])) @@ -574,10 +565,6 @@ [modifiers] (assoc (or modifiers (empty)) :structure-child [] :structure-parent [])) -(defn select-child-geometry-modifiers - [modifiers] - (-> modifiers select-child select-geometry)) - (defn select-child-structre-modifiers [modifiers] (-> modifiers select-child select-structure)) @@ -601,7 +588,7 @@ ;; Main transformation functions -(defn transform-move! +(defn- transform-move! "Transforms a matrix by the translation modifier" [matrix modifier] (-> (dm/get-prop modifier :vector) @@ -609,7 +596,7 @@ (gmt/multiply! matrix))) -(defn transform-resize! +(defn- transform-resize! "Transforms a matrix by the resize modifier" [matrix modifier] (let [tf (dm/get-prop modifier :transform) @@ -631,7 +618,7 @@ (gmt/multiply! tfi))) matrix))) -(defn transform-rotate! +(defn- transform-rotate! "Transforms a matrix by the rotation modifier" [matrix modifier] (let [center (dm/get-prop modifier :center) @@ -643,7 +630,7 @@ (gmt/translate! (gpt/negate center))) matrix))) -(defn transform! +(defn- transform! "Returns a matrix transformed by the modifier" [matrix modifier] (let [type (dm/get-prop modifier :type)] @@ -652,8 +639,7 @@ :resize (transform-resize! matrix modifier) :rotation (transform-rotate! matrix modifier)))) -(defn modifiers->transform1 - "A multiplatform version of modifiers->transform." +(defn- modifiers->transform1 [modifiers] (reduce transform! (gmt/matrix) modifiers)) @@ -665,80 +651,28 @@ modifiers (sort-by #(dm/get-prop % :order) modifiers)] (modifiers->transform1 modifiers))) -(defn modifiers->transform-old - "Given a set of modifiers returns its transformation matrix" - [modifiers] - (let [modifiers (->> (concat (dm/get-prop modifiers :geometry-parent) - (dm/get-prop modifiers :geometry-child)) - (sort-by :order))] - - (loop [matrix (gmt/matrix) - modifiers (seq modifiers)] - (if (c/empty? modifiers) - matrix - (let [modifier (first modifiers) - type (dm/get-prop modifier :type) - - matrix - (case type - :move - (-> (dm/get-prop modifier :vector) - (gmt/translate-matrix) - (gmt/multiply! matrix)) - - :resize - (let [tf (dm/get-prop modifier :transform) - tfi (dm/get-prop modifier :transform-inverse) - vector (dm/get-prop modifier :vector) - origin (dm/get-prop modifier :origin) - origin (if ^boolean (some? tfi) - (gpt/transform origin tfi) - origin)] - - (gmt/multiply! - (-> (gmt/matrix) - (cond-> ^boolean (some? tf) - (gmt/multiply! tf)) - (gmt/translate! origin) - (gmt/scale! vector) - (gmt/translate! (gpt/negate origin)) - (cond-> ^boolean (some? tfi) - (gmt/multiply! tfi))) - matrix)) - - :rotation - (let [center (dm/get-prop modifier :center) - rotation (dm/get-prop modifier :rotation)] - (gmt/multiply! - (-> (gmt/matrix) - (gmt/translate! center) - (gmt/multiply! (gmt/rotate-matrix rotation)) - (gmt/translate! (gpt/negate center))) - matrix)))] - (recur matrix (next modifiers))))))) - -(defn transform-text-node [value attrs] +(defn- transform-text-node [value attrs] (let [font-size (-> (get attrs :font-size 14) d/parse-double (* value) str) letter-spacing (-> (get attrs :letter-spacing 0) d/parse-double (* value) str)] (d/txt-merge attrs {:font-size font-size :letter-spacing letter-spacing}))) -(defn transform-paragraph-node [value attrs] +(defn- transform-paragraph-node [value attrs] (let [font-size (-> (get attrs :font-size 14) d/parse-double (* value) str)] (d/txt-merge attrs {:font-size font-size}))) -(defn update-text-content +(defn- update-text-content [shape scale-text-content value] (update shape :content scale-text-content value)) -(defn scale-text-content +(defn- scale-text-content [content value] (->> content (txt/transform-nodes txt/is-text-node? (partial transform-text-node value)) (txt/transform-nodes txt/is-paragraph-node? (partial transform-paragraph-node value)))) -(defn apply-scale-content +(defn- apply-scale-content [shape value] ;; Scale can only be positive (let [value (mth/abs value)] @@ -767,7 +701,7 @@ :always (ctl/update-flex-child value)))) -(defn remove-children-set +(defn- remove-children-set [shapes children-to-remove] (let [remove? (set children-to-remove)] (d/removev remove? shapes))) diff --git a/common/test/common_tests/types/modifiers_test.cljc b/common/test/common_tests/types/modifiers_test.cljc index 264b3e71e5..6922aceb82 100644 --- a/common/test/common_tests/types/modifiers_test.cljc +++ b/common/test/common_tests/types/modifiers_test.cljc @@ -8,7 +8,13 @@ (:require [app.common.geom.matrix :as gmt] [app.common.geom.point :as gpt] + [app.common.geom.rect :as grc] + [app.common.geom.shapes :as gsh] + [app.common.math :as mth] + [app.common.schema :as sm] [app.common.types.modifiers :as ctm] + [app.common.types.shape :as cts] + [app.common.uuid :as uuid] [clojure.test :as t])) (t/deftest modifiers->transform @@ -24,3 +30,630 @@ transform (ctm/modifiers->transform modifiers)] (t/is (not (gmt/close? (gmt/matrix) transform))))) + +;; ─── Helpers ────────────────────────────────────────────────────────────────── + +(defn- make-shape + "Build a minimal axis-aligned rect shape with the given geometry." + ([width height] + (make-shape 0 0 width height)) + ([x y width height] + (cts/setup-shape {:type :rect :x x :y y :width width :height height}))) + +(defn- make-shape-with-proportion + "Build a shape with a fixed proportion ratio and proportion-lock enabled." + [width height] + (assoc (make-shape width height) + :proportion (/ (float width) (float height)) + :proportion-lock true)) + +(defn- resize-op + "Extract the single resize GeometricOperation from geometry-child." + [modifiers] + (first (:geometry-child modifiers))) + +;; ─── change-size ────────────────────────────────────────────────────────────── + +(t/deftest change-size-basic + (t/testing "scales both axes to the requested dimensions" + (let [shape (make-shape 100 50) + mods (ctm/change-size shape 200 100) + op (resize-op mods)] + (t/is (mth/close? 2.0 (-> op :vector :x))) + (t/is (mth/close? 2.0 (-> op :vector :y))))) + + (t/testing "origin is the first point of the shape (top-left)" + (let [shape (make-shape 10 20 100 50) + mods (ctm/change-size shape 200 50) + origin (:origin (resize-op mods))] + (t/is (mth/close? 10.0 (:x origin))) + (t/is (mth/close? 20.0 (:y origin))))) + + (t/testing "nil width falls back to current width, keeping x-scale at 1" + (let [shape (make-shape 100 50) + mods (ctm/change-size shape nil 100) + op (resize-op mods)] + (t/is (mth/close? 1.0 (-> op :vector :x))) + (t/is (mth/close? 2.0 (-> op :vector :y))))) + + (t/testing "nil height falls back to current height, keeping y-scale at 1" + (let [shape (make-shape 100 50) + mods (ctm/change-size shape 200 nil) + op (resize-op mods)] + (t/is (mth/close? 2.0 (-> op :vector :x))) + (t/is (mth/close? 1.0 (-> op :vector :y))))) + + (t/testing "both nil produces an identity resize (scale 1,1)" + (let [shape (make-shape 100 50) + mods (ctm/change-size shape nil nil) + op (resize-op mods)] + ;; Identity resize is optimized away; geometry-child should be empty. + (t/is (empty? (:geometry-child mods))))) + + (t/testing "transform and transform-inverse on a plain shape are both the identity matrix" + (let [shape (make-shape 100 50) + mods (ctm/change-size shape 200 100) + op (resize-op mods)] + (t/is (gmt/close? (gmt/matrix) (:transform op))) + (t/is (gmt/close? (gmt/matrix) (:transform-inverse op)))))) + +;; ─── change-dimensions-modifiers ────────────────────────────────────────────── + +(t/deftest change-dimensions-modifiers-no-lock + (t/testing "changing width only scales x-axis; y-scale stays 1" + (let [shape (make-shape 100 50) + mods (ctm/change-dimensions-modifiers shape :width 200) + op (resize-op mods)] + (t/is (mth/close? 2.0 (-> op :vector :x))) + (t/is (mth/close? 1.0 (-> op :vector :y))))) + + (t/testing "changing height only scales y-axis; x-scale stays 1" + (let [shape (make-shape 100 50) + mods (ctm/change-dimensions-modifiers shape :height 100) + op (resize-op mods)] + (t/is (mth/close? 1.0 (-> op :vector :x))) + (t/is (mth/close? 2.0 (-> op :vector :y))))) + + (t/testing "origin is always the top-left point of the shape" + (let [shape (make-shape 30 40 100 50) + mods (ctm/change-dimensions-modifiers shape :width 200) + origin (:origin (resize-op mods))] + (t/is (mth/close? 30.0 (:x origin))) + (t/is (mth/close? 40.0 (:y origin)))))) + +(t/deftest change-dimensions-modifiers-with-proportion-lock + (t/testing "locking width also adjusts height by the inverse proportion" + ;; shape 100x50 → proportion = 100/50 = 2 + ;; new width 200 → expected height = 200/2 = 100 → scaley = 100/50 = 2 + (let [shape (make-shape-with-proportion 100 50) + mods (ctm/change-dimensions-modifiers shape :width 200) + op (resize-op mods)] + (t/is (mth/close? 2.0 (-> op :vector :x))) + (t/is (mth/close? 2.0 (-> op :vector :y))))) + + (t/testing "locking height also adjusts width by the proportion" + ;; shape 100x50 → proportion = 100/50 = 2 + ;; new height 100 → expected width = 100*2 = 200 → scalex = 200/100 = 2 + (let [shape (make-shape-with-proportion 100 50) + mods (ctm/change-dimensions-modifiers shape :height 100) + op (resize-op mods)] + (t/is (mth/close? 2.0 (-> op :vector :x))) + (t/is (mth/close? 2.0 (-> op :vector :y))))) + + (t/testing "ignore-lock? true bypasses proportion lock" + (let [shape (make-shape-with-proportion 100 50) + mods (ctm/change-dimensions-modifiers shape :width 200 {:ignore-lock? true}) + op (resize-op mods)] + (t/is (mth/close? 2.0 (-> op :vector :x))) + ;; Height should remain unchanged (scale = 1). + (t/is (mth/close? 1.0 (-> op :vector :y)))))) + +(t/deftest change-dimensions-modifiers-value-clamping + (t/testing "value below 0.01 is clamped to 0.01" + (let [shape (make-shape 100 50) + mods (ctm/change-dimensions-modifiers shape :width 0.001) + op (resize-op mods)] + ;; 0.01 / 100 = 0.0001 + (t/is (mth/close? 0.0001 (-> op :vector :x))))) + + (t/testing "value of exactly 0 is clamped to 0.01" + (let [shape (make-shape 100 50) + mods (ctm/change-dimensions-modifiers shape :height 0) + op (resize-op mods)] + ;; 0.01 / 50 = 0.0002 + (t/is (mth/close? 0.0002 (-> op :vector :y)))))) + +(t/deftest change-dimensions-modifiers-end-to-end + (t/testing "applying change-width modifier produces the expected selrect width" + (let [shape (make-shape 100 50) + mods (ctm/change-dimensions-modifiers shape :width 300) + result (gsh/transform-shape (assoc shape :modifiers mods))] + (t/is (mth/close? 300.0 (-> result :selrect :width))) + (t/is (mth/close? 50.0 (-> result :selrect :height))))) + + (t/testing "applying change-height modifier produces the expected selrect height" + (let [shape (make-shape 100 50) + mods (ctm/change-dimensions-modifiers shape :height 200) + result (gsh/transform-shape (assoc shape :modifiers mods))] + (t/is (mth/close? 100.0 (-> result :selrect :width))) + (t/is (mth/close? 200.0 (-> result :selrect :height)))))) + +;; ─── safe-size-rect fallbacks ───────────────────────────────────────────────── + +(t/deftest safe-size-rect-fallbacks + (t/testing "valid selrect is returned as-is" + (let [shape (make-shape 100 50) + mods (ctm/change-size shape 200 100) + op (resize-op mods)] + ;; scale 2,2 means the selrect was valid + (t/is (mth/close? 2.0 (-> op :vector :x))) + (t/is (mth/close? 2.0 (-> op :vector :y))))) + + (t/testing "zero-width selrect falls back to points, producing a valid rect" + ;; Corrupt only the selrect dimensions; the shape's points remain valid. + (let [base (make-shape 100 50) + bad-selrect (assoc (:selrect base) :width 0 :height 0) + shape (assoc base :selrect bad-selrect) + mods (ctm/change-size shape 200 100) + op (resize-op mods)] + (t/is (cts/shape? shape)) + (t/is (mth/close? 2.0 (-> op :vector :x))) + (t/is (mth/close? 2.0 (-> op :vector :y))))) + + (t/testing "NaN selrect falls back to points" + (let [base (make-shape 100 50) + bad-selrect (assoc (:selrect base) :width ##NaN :height ##NaN) + shape (assoc base :selrect bad-selrect) + mods (ctm/change-size shape 200 100) + op (resize-op mods)] + (t/is (cts/shape? shape)) + (t/is (mth/close? 2.0 (-> op :vector :x))) + (t/is (mth/close? 2.0 (-> op :vector :y))))) + + (t/testing "selrect with dimensions exceeding max-safe-int falls back to points" + (let [base (make-shape 100 50) + bad-selrect (assoc (:selrect base) :width (inc sm/max-safe-int) :height (inc sm/max-safe-int)) + shape (assoc base :selrect bad-selrect) + mods (ctm/change-size shape 200 100) + op (resize-op mods)] + (t/is (cts/shape? shape)) + (t/is (mth/close? 2.0 (-> op :vector :x))) + (t/is (mth/close? 2.0 (-> op :vector :y))))) + + (t/testing "invalid selrect and no points falls back to top-level shape fields" + ;; Null out both selrect and points; the top-level :x/:y/:width/:height + ;; fields on the Shape record are still valid and serve as fallback 3. + (let [shape (-> (make-shape 100 50) + (assoc :selrect nil) + (assoc :points nil)) + mods (ctm/change-size shape 200 100) + op (resize-op mods)] + (t/is (cts/shape? shape)) + (t/is (mth/close? 2.0 (-> op :vector :x))) + (t/is (mth/close? 2.0 (-> op :vector :y))))) + + (t/testing "all geometry missing: falls back to empty-rect (0.01 x 0.01)" + ;; Null out selrect, points and the top-level dimension fields so that + ;; every fallback is exhausted and empty-rect (0.01×0.01) is used. + (let [shape (-> (make-shape 100 50) + (assoc :selrect nil) + (assoc :points nil) + (assoc :width nil) + (assoc :height nil)) + mods (ctm/change-size shape 200 100) + op (resize-op mods)] + (t/is (cts/shape? shape)) + (t/is (mth/close? (/ 200 0.01) (-> op :vector :x))) + (t/is (mth/close? (/ 100 0.01) (-> op :vector :y)))))) + +;; ─── Builder functions: geometry-child ──────────────────────────────────────── + +(t/deftest move-builder + (t/testing "move adds an operation to geometry-child" + (let [mods (ctm/move (ctm/empty) (gpt/point 10 20))] + (t/is (= 1 (count (:geometry-child mods)))) + (t/is (= :move (-> mods :geometry-child first :type))))) + + (t/testing "move with zero vector is optimised away" + (let [mods (ctm/move (ctm/empty) (gpt/point 0 0))] + (t/is (empty? (:geometry-child mods))))) + + (t/testing "two consecutive moves on the same axis are merged into one" + (let [mods (-> (ctm/empty) + (ctm/move (gpt/point 10 0)) + (ctm/move (gpt/point 5 0)))] + (t/is (= 1 (count (:geometry-child mods)))) + (t/is (mth/close? 15.0 (-> mods :geometry-child first :vector :x))))) + + (t/testing "move with x y arity delegates to vector arity" + (let [mods (ctm/move (ctm/empty) 3 7)] + (t/is (= 1 (count (:geometry-child mods)))) + (t/is (mth/close? 3.0 (-> mods :geometry-child first :vector :x))) + (t/is (mth/close? 7.0 (-> mods :geometry-child first :vector :y)))))) + +(t/deftest resize-builder + (t/testing "resize adds an operation to geometry-child" + (let [mods (ctm/resize (ctm/empty) (gpt/point 2 3) (gpt/point 0 0))] + (t/is (= 1 (count (:geometry-child mods)))) + (t/is (= :resize (-> mods :geometry-child first :type))))) + + (t/testing "identity resize (scale 1,1) is optimised away" + (let [mods (ctm/resize (ctm/empty) (gpt/point 1 1) (gpt/point 0 0))] + (t/is (empty? (:geometry-child mods))))) + + (t/testing "precise? flag keeps near-identity resize" + (let [mods (ctm/resize (ctm/empty) (gpt/point 1 1) (gpt/point 0 0) + nil nil {:precise? true})] + (t/is (= 1 (count (:geometry-child mods)))))) + + (t/testing "resize stores origin, transform and transform-inverse" + (let [tf (gmt/matrix) + tfi (gmt/matrix) + mods (ctm/resize (ctm/empty) (gpt/point 2 2) (gpt/point 5 10) tf tfi) + op (-> mods :geometry-child first)] + (t/is (mth/close? 5.0 (-> op :origin :x))) + (t/is (mth/close? 10.0 (-> op :origin :y))) + (t/is (gmt/close? tf (:transform op))) + (t/is (gmt/close? tfi (:transform-inverse op)))))) + +(t/deftest rotation-builder + (t/testing "rotation adds ops to both geometry-child and structure-child" + (let [mods (ctm/rotation (ctm/empty) (gpt/point 50 50) 45)] + (t/is (= 1 (count (:geometry-child mods)))) + (t/is (= 1 (count (:structure-child mods)))) + (t/is (= :rotation (-> mods :geometry-child first :type))) + (t/is (= :rotation (-> mods :structure-child first :type))))) + + (t/testing "zero-angle rotation is optimised away" + (let [mods (ctm/rotation (ctm/empty) (gpt/point 50 50) 0)] + (t/is (empty? (:geometry-child mods))) + (t/is (empty? (:structure-child mods)))))) + +;; ─── Builder functions: geometry-parent ─────────────────────────────────────── + +(t/deftest move-parent-builder + (t/testing "move-parent adds an operation to geometry-parent, not geometry-child" + (let [mods (ctm/move-parent (ctm/empty) (gpt/point 10 20))] + (t/is (= 1 (count (:geometry-parent mods)))) + (t/is (empty? (:geometry-child mods))) + (t/is (= :move (-> mods :geometry-parent first :type))))) + + (t/testing "move-parent with zero vector is optimised away" + (let [mods (ctm/move-parent (ctm/empty) (gpt/point 0 0))] + (t/is (empty? (:geometry-parent mods)))))) + +(t/deftest resize-parent-builder + (t/testing "resize-parent adds an operation to geometry-parent, not geometry-child" + (let [mods (ctm/resize-parent (ctm/empty) (gpt/point 2 3) (gpt/point 0 0))] + (t/is (= 1 (count (:geometry-parent mods)))) + (t/is (empty? (:geometry-child mods))) + (t/is (= :resize (-> mods :geometry-parent first :type)))))) + +;; ─── Builder functions: structure ───────────────────────────────────────────── + +(t/deftest structure-builders + (t/testing "add-children appends an add-children op to structure-parent" + (let [id1 (uuid/next) + id2 (uuid/next) + mods (ctm/add-children (ctm/empty) [id1 id2] nil)] + (t/is (= 1 (count (:structure-parent mods)))) + (t/is (= :add-children (-> mods :structure-parent first :type))))) + + (t/testing "add-children with empty list is a no-op" + (let [mods (ctm/add-children (ctm/empty) [] nil)] + (t/is (empty? (:structure-parent mods))))) + + (t/testing "remove-children appends a remove-children op to structure-parent" + (let [id (uuid/next) + mods (ctm/remove-children (ctm/empty) [id])] + (t/is (= 1 (count (:structure-parent mods)))) + (t/is (= :remove-children (-> mods :structure-parent first :type))))) + + (t/testing "remove-children with empty list is a no-op" + (let [mods (ctm/remove-children (ctm/empty) [])] + (t/is (empty? (:structure-parent mods))))) + + (t/testing "reflow appends a reflow op to structure-parent" + (let [mods (ctm/reflow (ctm/empty))] + (t/is (= 1 (count (:structure-parent mods)))) + (t/is (= :reflow (-> mods :structure-parent first :type))))) + + (t/testing "scale-content appends a scale-content op to structure-child" + (let [mods (ctm/scale-content (ctm/empty) 2.0)] + (t/is (= 1 (count (:structure-child mods)))) + (t/is (= :scale-content (-> mods :structure-child first :type))))) + + (t/testing "change-property appends a change-property op to structure-parent" + (let [mods (ctm/change-property (ctm/empty) :opacity 0.5)] + (t/is (= 1 (count (:structure-parent mods)))) + (t/is (= :change-property (-> mods :structure-parent first :type))) + (t/is (= :opacity (-> mods :structure-parent first :property))) + (t/is (= 0.5 (-> mods :structure-parent first :value)))))) + +;; ─── Convenience builders ───────────────────────────────────────────────────── + +(t/deftest convenience-builders + (t/testing "move-modifiers returns a fresh Modifiers with a move in geometry-child" + (let [mods (ctm/move-modifiers (gpt/point 5 10))] + (t/is (= 1 (count (:geometry-child mods)))) + (t/is (= :move (-> mods :geometry-child first :type))))) + + (t/testing "move-modifiers accepts x y arity" + (let [mods (ctm/move-modifiers 5 10)] + (t/is (= 1 (count (:geometry-child mods)))))) + + (t/testing "resize-modifiers returns a fresh Modifiers with a resize in geometry-child" + (let [mods (ctm/resize-modifiers (gpt/point 2 2) (gpt/point 0 0))] + (t/is (= 1 (count (:geometry-child mods)))) + (t/is (= :resize (-> mods :geometry-child first :type))))) + + (t/testing "reflow-modifiers returns a fresh Modifiers with a reflow in structure-parent" + (let [mods (ctm/reflow-modifiers)] + (t/is (= 1 (count (:structure-parent mods)))) + (t/is (= :reflow (-> mods :structure-parent first :type))))) + + (t/testing "rotation-modifiers returns move + rotation in geometry-child" + (let [shape (make-shape 100 100) + mods (ctm/rotation-modifiers shape (gpt/point 50 50) 90)] + ;; rotation adds a :rotation and a :move to compensate for off-center + (t/is (pos? (count (:geometry-child mods)))) + (t/is (some #(= :rotation (:type %)) (:geometry-child mods)))))) + +;; ─── add-modifiers ──────────────────────────────────────────────────────────── + +(t/deftest add-modifiers-combinator + (t/testing "combining two disjoint move modifiers sums the vectors" + (let [m1 (ctm/move-modifiers (gpt/point 10 0)) + m2 (ctm/move-modifiers (gpt/point 5 0)) + result (ctm/add-modifiers m1 m2)] + ;; Both are pure geometry-child moves → they get merged + (t/is (= 1 (count (:geometry-child result)))) + (t/is (mth/close? 15.0 (-> result :geometry-child first :vector :x))))) + + (t/testing "nil first argument is treated as empty" + (let [m2 (ctm/move-modifiers (gpt/point 3 4)) + result (ctm/add-modifiers nil m2)] + (t/is (= 1 (count (:geometry-child result)))))) + + (t/testing "nil second argument is treated as empty" + (let [m1 (ctm/move-modifiers (gpt/point 3 4)) + result (ctm/add-modifiers m1 nil)] + (t/is (= 1 (count (:geometry-child result)))))) + + (t/testing "last-order is the sum of both modifiers' orders" + (let [m1 (ctm/move-modifiers (gpt/point 1 0)) + m2 (ctm/move-modifiers (gpt/point 2 0)) + result (ctm/add-modifiers m1 m2)] + (t/is (= (+ (:last-order m1) (:last-order m2)) + (:last-order result)))))) + +;; ─── Predicates ─────────────────────────────────────────────────────────────── + +(t/deftest predicate-empty? + (t/testing "fresh empty modifiers is empty?" + (t/is (ctm/empty? (ctm/empty)))) + + (t/testing "modifiers with a move are not empty?" + (t/is (not (ctm/empty? (ctm/move-modifiers (gpt/point 1 0)))))) + + (t/testing "modifiers with only a structure op are not empty?" + (t/is (not (ctm/empty? (ctm/reflow-modifiers)))))) + +(t/deftest predicate-child-modifiers? + (t/testing "move in geometry-child → child-modifiers? true" + (t/is (ctm/child-modifiers? (ctm/move-modifiers (gpt/point 1 0))))) + + (t/testing "scale-content in structure-child → child-modifiers? true" + (t/is (ctm/child-modifiers? (ctm/scale-content (ctm/empty) 2.0)))) + + (t/testing "move-parent in geometry-parent only → child-modifiers? false" + (t/is (not (ctm/child-modifiers? (ctm/move-parent (ctm/empty) (gpt/point 1 0))))))) + +(t/deftest predicate-has-geometry? + (t/testing "move in geometry-child → has-geometry? true" + (t/is (ctm/has-geometry? (ctm/move-modifiers (gpt/point 1 0))))) + + (t/testing "move-parent in geometry-parent → has-geometry? true" + (t/is (ctm/has-geometry? (ctm/move-parent (ctm/empty) (gpt/point 1 0))))) + + (t/testing "only structure ops → has-geometry? false" + (t/is (not (ctm/has-geometry? (ctm/reflow-modifiers)))))) + +(t/deftest predicate-has-structure? + (t/testing "reflow op in structure-parent → has-structure? true" + (t/is (ctm/has-structure? (ctm/reflow-modifiers)))) + + (t/testing "scale-content in structure-child → has-structure? true" + (t/is (ctm/has-structure? (ctm/scale-content (ctm/empty) 2.0)))) + + (t/testing "only geometry ops → has-structure? false" + (t/is (not (ctm/has-structure? (ctm/move-modifiers (gpt/point 1 0))))))) + +(t/deftest predicate-has-structure-child? + (t/testing "scale-content in structure-child → has-structure-child? true" + (t/is (ctm/has-structure-child? (ctm/scale-content (ctm/empty) 2.0)))) + + (t/testing "reflow in structure-parent only → has-structure-child? false" + (t/is (not (ctm/has-structure-child? (ctm/reflow-modifiers)))))) + +(t/deftest predicate-only-move? + (t/testing "pure move modifiers → only-move? true" + (t/is (ctm/only-move? (ctm/move-modifiers (gpt/point 1 0))))) + + (t/testing "resize modifiers → only-move? false" + (t/is (not (ctm/only-move? (ctm/resize-modifiers (gpt/point 2 2) (gpt/point 0 0)))))) + + (t/testing "structure ops present → only-move? false" + (let [mods (-> (ctm/empty) + (ctm/move (gpt/point 1 0)) + (ctm/reflow))] + (t/is (not (ctm/only-move? mods))))) + + (t/testing "empty modifiers → only-move? true (vacuously)" + (t/is (ctm/only-move? (ctm/empty))))) + +;; ─── Projection functions ────────────────────────────────────────────────────── + +(t/deftest projection-select-child + (t/testing "select-child keeps geometry-child and structure-child, clears parent" + (let [mods (-> (ctm/empty) + (ctm/move (gpt/point 1 0)) + (ctm/move-parent (gpt/point 2 0)) + (ctm/reflow) + (ctm/scale-content 2.0)) + result (ctm/select-child mods)] + (t/is (= 1 (count (:geometry-child result)))) + (t/is (= 1 (count (:structure-child result)))) + (t/is (empty? (:geometry-parent result))) + (t/is (empty? (:structure-parent result)))))) + +(t/deftest projection-select-parent + (t/testing "select-parent keeps geometry-parent and structure-parent, clears child" + (let [mods (-> (ctm/empty) + (ctm/move (gpt/point 1 0)) + (ctm/move-parent (gpt/point 2 0)) + (ctm/reflow) + (ctm/scale-content 2.0)) + result (ctm/select-parent mods)] + (t/is (= 1 (count (:geometry-parent result)))) + (t/is (= 1 (count (:structure-parent result)))) + (t/is (empty? (:geometry-child result))) + (t/is (empty? (:structure-child result)))))) + +(t/deftest projection-select-geometry + (t/testing "select-geometry keeps both geometry lists, clears structure" + (let [mods (-> (ctm/empty) + (ctm/move (gpt/point 1 0)) + (ctm/move-parent (gpt/point 2 0)) + (ctm/reflow) + (ctm/scale-content 2.0)) + result (ctm/select-geometry mods)] + (t/is (= 1 (count (:geometry-child result)))) + (t/is (= 1 (count (:geometry-parent result)))) + (t/is (empty? (:structure-child result))) + (t/is (empty? (:structure-parent result)))))) + +(t/deftest projection-select-child-structre-modifiers + (t/testing "select-child-structre-modifiers keeps only structure-child" + (let [mods (-> (ctm/empty) + (ctm/move (gpt/point 1 0)) + (ctm/move-parent (gpt/point 2 0)) + (ctm/reflow) + (ctm/scale-content 2.0)) + result (ctm/select-child-structre-modifiers mods)] + (t/is (= 1 (count (:structure-child result)))) + (t/is (empty? (:geometry-child result))) + (t/is (empty? (:geometry-parent result))) + (t/is (empty? (:structure-parent result)))))) + +;; ─── added-children-frames ──────────────────────────────────────────────────── + +(t/deftest added-children-frames-test + (t/testing "returns frame/shape pairs for add-children operations" + (let [frame-id (uuid/next) + shape-id (uuid/next) + mods (ctm/add-children (ctm/empty) [shape-id] nil) + tree {frame-id {:modifiers mods}} + result (ctm/added-children-frames tree)] + (t/is (= 1 (count result))) + (t/is (= frame-id (:frame (first result)))) + (t/is (= shape-id (:shape (first result)))))) + + (t/testing "returns empty when there are no add-children operations" + (let [frame-id (uuid/next) + mods (ctm/reflow-modifiers) + tree {frame-id {:modifiers mods}} + result (ctm/added-children-frames tree)] + (t/is (empty? result)))) + + (t/testing "returns empty for an empty modif-tree" + (t/is (empty? (ctm/added-children-frames {}))))) + +;; ─── apply-modifier and apply-structure-modifiers ───────────────────────────── + +(t/deftest apply-modifier-test + (t/testing "rotation op increments shape :rotation field" + (let [shape (make-shape 100 50) + op (-> (ctm/rotation (ctm/empty) (gpt/point 50 25) 90) + :structure-child + first) + result (ctm/apply-modifier shape op)] + (t/is (mth/close? 90.0 (:rotation result))))) + + (t/testing "rotation wraps around 360" + (let [shape (assoc (make-shape 100 50) :rotation 350) + op (-> (ctm/rotation (ctm/empty) (gpt/point 50 25) 20) + :structure-child + first) + result (ctm/apply-modifier shape op)] + (t/is (mth/close? 10.0 (:rotation result))))) + + (t/testing "add-children op appends ids to shape :shapes" + (let [id1 (uuid/next) + id2 (uuid/next) + shape (assoc (make-shape 100 50) :shapes []) + op (-> (ctm/add-children (ctm/empty) [id1 id2] nil) + :structure-parent + first) + result (ctm/apply-modifier shape op)] + (t/is (= [id1 id2] (:shapes result))))) + + (t/testing "add-children op with index inserts at the given position" + (let [id-existing (uuid/next) + id-new (uuid/next) + shape (assoc (make-shape 100 50) :shapes [id-existing]) + op (-> (ctm/add-children (ctm/empty) [id-new] 0) + :structure-parent + first) + result (ctm/apply-modifier shape op)] + (t/is (= id-new (first (:shapes result)))))) + + (t/testing "remove-children op removes given ids from shape :shapes" + (let [id1 (uuid/next) + id2 (uuid/next) + shape (assoc (make-shape 100 50) :shapes [id1 id2]) + op (-> (ctm/remove-children (ctm/empty) [id1]) + :structure-parent + first) + result (ctm/apply-modifier shape op)] + (t/is (= [id2] (:shapes result))))) + + (t/testing "change-property op sets the property on the shape" + (let [shape (make-shape 100 50) + op (-> (ctm/change-property (ctm/empty) :opacity 0.5) + :structure-parent + first) + result (ctm/apply-modifier shape op)] + (t/is (= 0.5 (:opacity result))))) + + (t/testing "unknown op type returns shape unchanged" + (let [shape (make-shape 100 50) + result (ctm/apply-modifier shape {:type :unknown})] + (t/is (= shape result))))) + +(t/deftest apply-structure-modifiers-test + (t/testing "applies structure-parent and structure-child ops in order" + (let [id (uuid/next) + shape (assoc (make-shape 100 50) :shapes []) + mods (-> (ctm/empty) + (ctm/add-children [id] nil) + (ctm/scale-content 1.0)) + result (ctm/apply-structure-modifiers shape mods)] + (t/is (= [id] (:shapes result))))) + + (t/testing "empty modifiers returns shape unchanged" + (let [shape (make-shape 100 50) + result (ctm/apply-structure-modifiers shape (ctm/empty))] + (t/is (= shape result)))) + + (t/testing "change-property in structure-parent is applied" + (let [shape (make-shape 100 50) + mods (ctm/change-property (ctm/empty) :opacity 0.3) + result (ctm/apply-structure-modifiers shape mods)] + (t/is (= 0.3 (:opacity result))))) + + (t/testing "rotation in structure-child is applied" + (let [shape (make-shape 100 50) + mods (ctm/rotation (ctm/empty) (gpt/point 50 25) 45) + result (ctm/apply-structure-modifiers shape mods)] + (t/is (mth/close? 45.0 (:rotation result)))))) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 8874115443..a7d6a9d4ca 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -618,27 +618,30 @@ (assoc-in [:workspace-global :default-font] data)))))) (defn apply-text-modifier - [shape {:keys [width height position-data]}] + [shape text-modifier] - (let [new-shape - (cond-> shape - (some? width) - (gsh/transform-shape (ctm/change-dimensions-modifiers shape :width width {:ignore-lock? true})) + (if (some? text-modifier) + (let [{:keys [width height position-data]} text-modifier + new-shape + (cond-> shape + (some? width) + (gsh/transform-shape (ctm/change-dimensions-modifiers shape :width width {:ignore-lock? true})) - (some? height) - (gsh/transform-shape (ctm/change-dimensions-modifiers shape :height height {:ignore-lock? true})) + (some? height) + (gsh/transform-shape (ctm/change-dimensions-modifiers shape :height height {:ignore-lock? true})) - (some? position-data) - (assoc :position-data position-data)) + (some? position-data) + (assoc :position-data position-data)) - delta-move - (gpt/subtract (gpt/point (:selrect new-shape)) - (gpt/point (:selrect shape))) + delta-move + (gpt/subtract (gpt/point (ctm/safe-size-rect new-shape)) + (gpt/point (ctm/safe-size-rect shape))) - new-shape - (update new-shape :position-data gsh/move-position-data delta-move)] + new-shape + (update new-shape :position-data gsh/move-position-data delta-move)] - new-shape)) + new-shape) + shape)) (defn commit-update-text-modifier [] diff --git a/frontend/src/app/main/ui/workspace/shapes/text.cljs b/frontend/src/app/main/ui/workspace/shapes/text.cljs index cdaeda400f..dfd2bcde83 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text.cljs @@ -27,7 +27,7 @@ text-modifier (mf/deref text-modifier-ref) - shape (if (some? shape) + shape (if (and (some? shape) (some? text-modifier)) (dwt/apply-text-modifier shape text-modifier) shape)] diff --git a/frontend/test/frontend_tests/data/workspace_texts_test.cljs b/frontend/test/frontend_tests/data/workspace_texts_test.cljs new file mode 100644 index 0000000000..b7b1786eac --- /dev/null +++ b/frontend/test/frontend_tests/data/workspace_texts_test.cljs @@ -0,0 +1,274 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns frontend-tests.data.workspace-texts-test + (:require + [app.common.geom.rect :as grc] + [app.common.types.shape :as cts] + [app.main.data.workspace.texts :as dwt] + [cljs.test :as t :include-macros true])) + +;; --------------------------------------------------------------------------- +;; Helpers +;; --------------------------------------------------------------------------- + +(defn- make-text-shape + "Build a fully initialised text shape at the given position." + [& {:keys [x y width height position-data] + :or {x 10 y 20 width 100 height 50}}] + (cond-> (cts/setup-shape {:type :text + :x x + :y y + :width width + :height height}) + (some? position-data) + (assoc :position-data position-data))) + +(defn- make-degenerate-text-shape + "Simulate a text shape decoded from the server via map->Rect (which bypasses + make-rect's 0.01 minimum enforcement), giving it a zero-width / zero-height + selrect. This is the exact condition that triggered the original crash: + change-dimensions-modifiers divided by sr-width (== 0), producing an Infinity + scale factor that propagated through the transform pipeline until + calculate-selrect / center->rect returned nil, and then gpt/point threw + 'invalid arguments (on pointer constructor)'." + [& {:keys [x y width height] + :or {x 10 y 20 width 0 height 0}}] + (-> (make-text-shape :x x :y y :width 100 :height 50) + ;; Bypass make-rect by constructing the Rect record directly, the same + ;; way decode-rect does during JSON deserialization from the backend. + (assoc :selrect (grc/map->Rect {:x x :y y + :width width :height height + :x1 x :y1 y + :x2 (+ x width) :y2 (+ y height)})))) + +(defn- sample-position-data + "Return a minimal position-data vector with the supplied coords." + [x y] + [{:x x :y y :width 80 :height 16 :fills [] :text "hello"}]) + +;; --------------------------------------------------------------------------- +;; Tests: nil / no-op guard +;; --------------------------------------------------------------------------- + +(t/deftest apply-text-modifier-nil-modifier-returns-shape-unchanged + (t/testing "nil text-modifier returns the original shape untouched" + (let [shape (make-text-shape) + result (dwt/apply-text-modifier shape nil)] + (t/is (= shape result))))) + +(t/deftest apply-text-modifier-empty-map-no-keys-returns-shape-unchanged + (t/testing "modifier with no recognised keys leaves shape unchanged" + (let [shape (make-text-shape) + modifier {} + result (dwt/apply-text-modifier shape modifier)] + (t/is (= (:selrect shape) (:selrect result))) + (t/is (= (:width result) (:width shape))) + (t/is (= (:height result) (:height shape)))))) + +;; --------------------------------------------------------------------------- +;; Tests: width modifier +;; --------------------------------------------------------------------------- + +(t/deftest apply-text-modifier-width-changes-shape-width + (t/testing "width modifier resizes the shape width" + (let [shape (make-text-shape :x 0 :y 0 :width 100 :height 50) + modifier {:width 200} + result (dwt/apply-text-modifier shape modifier)] + (t/is (= 200.0 (-> result :selrect :width)))))) + +(t/deftest apply-text-modifier-width-nil-skips-width-change + (t/testing "nil :width in modifier does not alter the width" + (let [shape (make-text-shape :x 0 :y 0 :width 100 :height 50) + modifier {:width nil} + result (dwt/apply-text-modifier shape modifier)] + (t/is (= (-> shape :selrect :width) (-> result :selrect :width)))))) + +;; --------------------------------------------------------------------------- +;; Tests: height modifier +;; --------------------------------------------------------------------------- + +(t/deftest apply-text-modifier-height-changes-shape-height + (t/testing "height modifier resizes the shape height" + (let [shape (make-text-shape :x 0 :y 0 :width 100 :height 50) + modifier {:height 120} + result (dwt/apply-text-modifier shape modifier)] + (t/is (= 120.0 (-> result :selrect :height)))))) + +(t/deftest apply-text-modifier-height-nil-skips-height-change + (t/testing "nil :height in modifier does not alter the height" + (let [shape (make-text-shape :x 0 :y 0 :width 100 :height 50) + modifier {:height nil} + result (dwt/apply-text-modifier shape modifier)] + (t/is (= (-> shape :selrect :height) (-> result :selrect :height)))))) + +;; --------------------------------------------------------------------------- +;; Tests: width + height together +;; --------------------------------------------------------------------------- + +(t/deftest apply-text-modifier-width-and-height-both-applied + (t/testing "both width and height are applied simultaneously" + (let [shape (make-text-shape :x 0 :y 0 :width 100 :height 50) + modifier {:width 300 :height 80} + result (dwt/apply-text-modifier shape modifier)] + (t/is (= 300.0 (-> result :selrect :width))) + (t/is (= 80.0 (-> result :selrect :height)))))) + +;; --------------------------------------------------------------------------- +;; Tests: position-data modifier +;; --------------------------------------------------------------------------- + +(t/deftest apply-text-modifier-position-data-is-set-on-shape + (t/testing "position-data modifier replaces the position-data on shape" + (let [pd (sample-position-data 5 10) + shape (make-text-shape :x 0 :y 0 :width 100 :height 50) + modifier {:position-data pd} + result (dwt/apply-text-modifier shape modifier)] + (t/is (some? (:position-data result)))))) + +(t/deftest apply-text-modifier-position-data-nil-leaves-position-data-unchanged + (t/testing "nil :position-data in modifier does not alter position-data" + (let [pd (sample-position-data 5 10) + shape (-> (make-text-shape :x 0 :y 0 :width 100 :height 50) + (assoc :position-data pd)) + modifier {:position-data nil} + result (dwt/apply-text-modifier shape modifier)] + (t/is (= pd (:position-data result)))))) + +;; --------------------------------------------------------------------------- +;; Tests: position-data is translated by delta when shape moves +;; --------------------------------------------------------------------------- + +(t/deftest apply-text-modifier-position-data-translated-on-resize + (t/testing "position-data x/y is adjusted by the delta of the selrect origin" + (let [pd (sample-position-data 10 20) + shape (-> (make-text-shape :x 0 :y 0 :width 100 :height 50) + (assoc :position-data pd)) + ;; Only set position-data; no resize so no origin shift expected + modifier {:position-data pd} + result (dwt/apply-text-modifier shape modifier)] + ;; Delta should be zero (no dimension change), so coords stay the same + (t/is (= 10.0 (-> result :position-data first :x))) + (t/is (= 20.0 (-> result :position-data first :y)))))) + +(t/deftest apply-text-modifier-position-data-not-translated-when-nil + (t/testing "nil position-data on result after modifier is left as nil" + (let [shape (make-text-shape :x 0 :y 0 :width 100 :height 50) + modifier {:width 200} + result (dwt/apply-text-modifier shape modifier)] + ;; shape had no position-data; modifier doesn't set one — stays nil + (t/is (nil? (:position-data result)))))) + +;; --------------------------------------------------------------------------- +;; Tests: degenerate selrect (zero width or height decoded from the server) +;; +;; Root cause of the original crash: +;; change-dimensions-modifiers divided by (:width selrect) or (:height selrect) +;; which is 0 when the shape was decoded via map->Rect (bypassing make-rect's +;; 0.01 minimum), producing Infinity → transform pipeline returned nil selrect +;; → gpt/point threw "invalid arguments (on pointer constructor)". +;; --------------------------------------------------------------------------- + +(t/deftest apply-text-modifier-zero-width-selrect-does-not-throw + (t/testing "width modifier on a shape with zero selrect width does not throw" + ;; Simulates a shape received from the server whose selrect has width=0 + ;; (map->Rect bypasses the 0.01 floor of make-rect). + (let [shape (make-degenerate-text-shape :x 0 :y 0 :width 0 :height 50) + modifier {:width 200} + result (dwt/apply-text-modifier shape modifier)] + (t/is (some? result)) + (t/is (some? (:selrect result)))))) + +(t/deftest apply-text-modifier-zero-height-selrect-does-not-throw + (t/testing "height modifier on a shape with zero selrect height does not throw" + (let [shape (make-degenerate-text-shape :x 0 :y 0 :width 100 :height 0) + modifier {:height 80} + result (dwt/apply-text-modifier shape modifier)] + (t/is (some? result)) + (t/is (some? (:selrect result)))))) + +(t/deftest apply-text-modifier-zero-width-and-height-selrect-does-not-throw + (t/testing "both modifiers on a fully-degenerate selrect do not throw" + (let [shape (make-degenerate-text-shape :x 0 :y 0 :width 0 :height 0) + modifier {:width 150 :height 60} + result (dwt/apply-text-modifier shape modifier)] + (t/is (some? result)) + (t/is (some? (:selrect result)))))) + +(t/deftest apply-text-modifier-zero-width-selrect-result-has-correct-width + (t/testing "applying width modifier to a zero-width shape yields the requested width" + (let [shape (make-degenerate-text-shape :x 0 :y 0 :width 0 :height 50) + modifier {:width 200} + result (dwt/apply-text-modifier shape modifier)] + (t/is (= 200.0 (-> result :selrect :width)))))) + +(t/deftest apply-text-modifier-zero-height-selrect-result-has-correct-height + (t/testing "applying height modifier to a zero-height shape yields the requested height" + (let [shape (make-degenerate-text-shape :x 0 :y 0 :width 100 :height 0) + modifier {:height 80} + result (dwt/apply-text-modifier shape modifier)] + (t/is (= 80.0 (-> result :selrect :height)))))) + +(t/deftest apply-text-modifier-nil-modifier-on-degenerate-shape-returns-unchanged + (t/testing "nil modifier on a zero-selrect shape returns the same shape" + (let [shape (make-degenerate-text-shape :x 0 :y 0 :width 0 :height 0) + result (dwt/apply-text-modifier shape nil)] + (t/is (identical? shape result))))) + +;; --------------------------------------------------------------------------- +;; Tests: shape origin is preserved when there is no dimension change +;; --------------------------------------------------------------------------- + +(t/deftest apply-text-modifier-selrect-origin-preserved-without-resize + (t/testing "selrect x/y origin does not shift when no dimension changes" + (let [shape (make-text-shape :x 30 :y 40 :width 100 :height 50) + modifier {:position-data (sample-position-data 30 40)} + result (dwt/apply-text-modifier shape modifier)] + (t/is (= (-> shape :selrect :x) (-> result :selrect :x))) + (t/is (= (-> shape :selrect :y) (-> result :selrect :y)))))) + +;; --------------------------------------------------------------------------- +;; Tests: returned shape is a proper map-like value +;; --------------------------------------------------------------------------- + +(t/deftest apply-text-modifier-returns-shape-with-required-keys + (t/testing "result always contains the core shape keys" + (let [shape (make-text-shape :x 0 :y 0 :width 100 :height 50) + modifier {:width 200 :height 80} + result (dwt/apply-text-modifier shape modifier)] + (t/is (some? (:id result))) + (t/is (some? (:type result))) + (t/is (some? (:selrect result)))))) + +(t/deftest apply-text-modifier-nil-modifier-returns-same-identity + (t/testing "nil modifier returns the exact same shape object (identity)" + (let [shape (make-text-shape)] + (t/is (identical? shape (dwt/apply-text-modifier shape nil)))))) + +;; --------------------------------------------------------------------------- +;; Tests: delta-move computation does not throw on degenerate selrect +;; +;; The delta-move in apply-text-modifier calls gpt/point on both the +;; original and new shape selrects. gpt/point throws when given a +;; non-point-like value (nil, or a map with non-finite :x/:y). Using +;; ctm/safe-size-rect instead of raw (:selrect …) access ensures a valid +;; rect is always available for that computation. +;; --------------------------------------------------------------------------- + +(t/deftest apply-text-modifier-position-data-with-degenerate-selrect-does-not-throw + (t/testing "position-data modifier on a zero-selrect shape does not throw" + (let [pd (sample-position-data 5 10) + shape (make-degenerate-text-shape :x 0 :y 0 :width 0 :height 0) + result (dwt/apply-text-modifier shape {:position-data pd})] + (t/is (some? result)) + (t/is (= pd (:position-data result))))) + + (t/testing "width + position-data modifier on a zero-selrect shape does not throw" + (let [pd (sample-position-data 5 10) + shape (make-degenerate-text-shape :x 0 :y 0 :width 0 :height 0) + result (dwt/apply-text-modifier shape {:width 200 :position-data pd})] + (t/is (some? result)) + (t/is (some? (:selrect result)))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index ad84056110..93fcd3897a 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -3,6 +3,7 @@ [cljs.test :as t] [frontend-tests.basic-shapes-test] [frontend-tests.data.workspace-colors-test] + [frontend-tests.data.workspace-texts-test] [frontend-tests.helpers-shapes-test] [frontend-tests.logic.comp-remove-swap-slots-test] [frontend-tests.logic.components-and-tokens] @@ -36,6 +37,7 @@ (t/run-tests 'frontend-tests.basic-shapes-test 'frontend-tests.data.workspace-colors-test + 'frontend-tests.data.workspace-texts-test 'frontend-tests.helpers-shapes-test 'frontend-tests.logic.comp-remove-swap-slots-test 'frontend-tests.logic.components-and-tokens From d7e0b0cf9f4ec5377999bd8aec084a30d904ed68 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 30 Mar 2026 11:05:49 +0200 Subject: [PATCH 13/15] :paperclip: Add check-fmt script to root package.json --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index a67813f24b..f6028d0a2f 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "type": "module", "scripts": { "lint": "./scripts/lint", + "check-fmt": "./scripts/check-fmt", "fmt": "./scripts/fmt" }, "devDependencies": { From e7e98255d9b2481831e3d5a3f9dd2ec90e36e4a2 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 30 Mar 2026 11:08:33 +0200 Subject: [PATCH 14/15] :zap: Add scroll and zoom raf throttling (#8812) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :arrow_up: Update opencode and copilot deps * :bug: Decouple workspace-content from workspace-local to reduce scroll re-renders Move workspace-local subscription from workspace-content* (parent) into viewport* and viewport-classic* (children). workspace-content* now only subscribes to the new workspace-vport derived atom, which changes only on window resize — not on every scroll event. This prevents the sidebar, palette and other workspace-content children from re-rendering on scroll. * :bug: Throttle wheel events to one state update per animation frame Accumulate wheel event deltas in a mutable ref and flush them via requestAnimationFrame, so that multiple wheel events between frames produce a single state mutation instead of one per event. This prevents the cascade of synchronous React re-renders (via useSyncExternalStore) that can exceed the maximum update depth on rapid scrolling. Both panning (scroll) and zoom (ctrl/mod+wheel) are throttled. Scroll deltas are summed additively; zoom scales are compounded multiplicatively with the latest cursor point used as the zoom center. * :recycle: Extract schedule-zoom! and schedule-scroll! from on-mouse-wheel * :recycle: Avoid zoom dep on on-mouse-wheel by using a ref --- frontend/src/app/main/refs.cljs | 3 + frontend/src/app/main/ui/workspace.cljs | 3 +- .../src/app/main/ui/workspace/viewport.cljs | 8 +- .../main/ui/workspace/viewport/actions.cljs | 112 ++++++++---- .../app/main/ui/workspace/viewport/hooks.cljs | 9 +- .../app/main/ui/workspace/viewport_wasm.cljs | 10 +- package.json | 6 +- pnpm-lock.yaml | 164 +++++++++--------- 8 files changed, 184 insertions(+), 131 deletions(-) diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index b443af8b2e..c4e0faaecd 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -234,6 +234,9 @@ (def inspect-expanded (l/derived :inspect-expanded workspace-local)) +(def workspace-vport + (l/derived :vport workspace-local)) + (def vbox (l/derived :vbox workspace-local)) diff --git a/frontend/src/app/main/ui/workspace.cljs b/frontend/src/app/main/ui/workspace.cljs index 0e4bd1186f..e2366d6003 100644 --- a/frontend/src/app/main/ui/workspace.cljs +++ b/frontend/src/app/main/ui/workspace.cljs @@ -56,7 +56,7 @@ selected (mf/deref refs/selected-shapes) page-id (get page :id) - {:keys [vport] :as wlocal} (mf/deref refs/workspace-local) + vport (mf/deref refs/workspace-vport) {:keys [options-mode]} wglobal @@ -105,7 +105,6 @@ [:> viewport* {:file file :page page - :wlocal wlocal :wglobal wglobal :selected selected :layout layout diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs index 7d08054fb4..b0e540ac71 100644 --- a/frontend/src/app/main/ui/workspace/viewport.cljs +++ b/frontend/src/app/main/ui/workspace/viewport.cljs @@ -81,10 +81,8 @@ selected)) (mf/defc viewport-classic* - [{:keys [selected wglobal wlocal layout file page palete-size]}] - (let [;; When adding data from workspace-local revisit `app.main.ui.workspace` to check - ;; that the new parameter is sent - {:keys [edit-path + [{:keys [selected wglobal layout file page palete-size]}] + (let [{:keys [edit-path panning selrect transform @@ -94,7 +92,7 @@ zoom zoom-inverse edition]} - wlocal + (mf/deref refs/workspace-local) {:keys [options-mode tooltip diff --git a/frontend/src/app/main/ui/workspace/viewport/actions.cljs b/frontend/src/app/main/ui/workspace/viewport/actions.cljs index f0a3bc3600..454fc282b4 100644 --- a/frontend/src/app/main/ui/workspace/viewport/actions.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/actions.cljs @@ -403,46 +403,94 @@ (kbd/alt? event) (kbd/meta? event)))))))) -(defn on-mouse-wheel [zoom] - (mf/use-callback - (mf/deps zoom) - (fn [event] - (let [event (.getBrowserEvent ^js event) +(defn- schedule-zoom! + "Accumulate a compound zoom scale and a cursor point into `state`, scheduling + a single requestAnimationFrame flush if one is not already pending. On the + next frame the accumulated scale is applied via `dw/set-zoom` and the state + is reset to its idle values." + [^js state scale pt] + (let [pending? (pos? (.-zoomRafId state))] + (set! (.-scale state) (* (.-scale state) scale)) + (set! (.-zoomPt state) pt) + (when-not pending? + (set! (.-zoomRafId state) + (ts/raf + (fn [] + (let [s (.-scale state) + zp (.-zoomPt state)] + (set! (.-scale state) 1) + (set! (.-zoomPt state) nil) + (set! (.-zoomRafId state) 0) + (st/emit! (dw/set-zoom zp s))))))))) - target (dom/get-target event) - mod? (kbd/mod? event) - ctrl? (kbd/ctrl? event) +(defn- schedule-scroll! + "Accumulate scroll deltas into `state`, scheduling a single + requestAnimationFrame flush if one is not already pending. On the next + frame the accumulated dx/dy are applied via `dw/update-viewport-position` + and the state is reset to its idle values." + [^js state zoom event delta-x delta-y] + (let [pending? (pos? (.-rafId state))] + (if (and (not (cfg/check-platform? :macos)) (kbd/shift? event)) + ;; macOS sends delta-x automatically, so on other platforms we + ;; remap shift+scroll-y to horizontal panning. + (set! (.-dx state) (+ (.-dx state) (/ delta-y zoom))) + (do + (set! (.-dx state) (+ (.-dx state) (/ delta-x zoom))) + (set! (.-dy state) (+ (.-dy state) (/ delta-y zoom))))) + (when-not pending? + (set! (.-rafId state) + (ts/raf + (fn [] + (let [dx (.-dx state) + dy (.-dy state)] + (set! (.-dx state) 0) + (set! (.-dy state) 0) + (set! (.-rafId state) 0) + (st/emit! (dw/update-viewport-position + {:x #(+ % dx) + :y #(+ % dy)}))))))))) - picking-color? (= "pixel-overlay" (.-id target)) - comments-layer? (dom/is-child? (dom/get-element "comments") target) +(defn on-mouse-wheel [zoom-ref] + (let [;; Mutable accumulator for scroll/zoom deltas, throttled to one + ;; state update per animation frame. This prevents rapid wheel + ;; events from causing cascading synchronous React re-renders + ;; that can exceed the maximum update depth. + scroll-state (mf/use-ref #js {:dx 0 :dy 0 :rafId 0 + :scale 1 :zoomPt nil :zoomRafId 0})] + (mf/use-callback + (fn [event] + (let [event (.getBrowserEvent ^js event) - raw-pt (dom/get-client-position event) - pt (uwvv/point->viewport raw-pt) + target (dom/get-target event) + mod? (kbd/mod? event) + ctrl? (kbd/ctrl? event) - norm-event ^js (nw/normalize-wheel event) + picking-color? (= "pixel-overlay" (.-id target)) + comments-layer? (dom/is-child? (dom/get-element "comments") target) - delta-y (.-pixelY norm-event) - delta-x (.-pixelX norm-event) - delta-zoom (+ delta-y delta-x) + raw-pt (dom/get-client-position event) + pt (uwvv/point->viewport raw-pt) - scale (+ 1 (mth/abs (* scale-per-pixel delta-zoom))) - scale (if (pos? delta-zoom) (/ 1 scale) scale)] + norm-event ^js (nw/normalize-wheel event) - (when (or (uwvv/inside-viewport? target) picking-color?) - (dom/prevent-default event) - (dom/stop-propagation event) - (if (or ctrl? mod?) - (st/emit! (dw/set-zoom pt scale)) - (if (and (not (cfg/check-platform? :macos)) (kbd/shift? event)) - ;; macos sends delta-x automatically, don't need to do it - (st/emit! (dw/update-viewport-position {:x #(+ % (/ delta-y zoom))})) - (st/emit! (dw/update-viewport-position {:x #(+ % (/ delta-x zoom)) - :y #(+ % (/ delta-y zoom))}))))) + delta-y (.-pixelY norm-event) + delta-x (.-pixelX norm-event) + delta-zoom (+ delta-y delta-x) - (when (and comments-layer? (or ctrl? mod?)) - (dom/prevent-default event) - (dom/stop-propagation event) - (st/emit! (dw/set-zoom pt scale))))))) + scale (+ 1 (mth/abs (* scale-per-pixel delta-zoom))) + scale (if (pos? delta-zoom) (/ 1 scale) scale)] + + (when (or (uwvv/inside-viewport? target) picking-color?) + (dom/prevent-default event) + (dom/stop-propagation event) + (if (or ctrl? mod?) + (schedule-zoom! (mf/ref-val scroll-state) scale pt) + (schedule-scroll! (mf/ref-val scroll-state) (mf/ref-val zoom-ref) event delta-x delta-y))) + + (when (and comments-layer? (or ctrl? mod?)) + (dom/prevent-default event) + (dom/stop-propagation event) + (schedule-zoom! (mf/ref-val scroll-state) scale pt))))))) (defn on-drag-enter [comp-inst-ref] diff --git a/frontend/src/app/main/ui/workspace/viewport/hooks.cljs b/frontend/src/app/main/ui/workspace/viewport/hooks.cljs index b615fc4aa7..225dde4fd9 100644 --- a/frontend/src/app/main/ui/workspace/viewport/hooks.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/hooks.cljs @@ -44,9 +44,12 @@ (defn setup-dom-events [zoom disable-paste-ref in-viewport-ref workspace-read-only? drawing-tool drawing-path?] - (let [on-key-down (actions/on-key-down) + (let [zoom-ref (mf/use-ref zoom) + _ (mf/with-effect [zoom] + (mf/set-ref-val! zoom-ref zoom)) + on-key-down (actions/on-key-down) on-key-up (actions/on-key-up) - on-mouse-wheel (actions/on-mouse-wheel zoom) + on-mouse-wheel (actions/on-mouse-wheel zoom-ref) on-paste (actions/on-paste disable-paste-ref in-viewport-ref workspace-read-only?) on-pointer-down (mf/use-fn (mf/deps drawing-tool drawing-path?) @@ -67,7 +70,7 @@ #(events/unlistenByKey key))) - (mf/with-layout-effect [on-key-down on-key-up on-mouse-wheel on-paste workspace-read-only?] + (mf/with-layout-effect [on-key-down on-key-up on-paste workspace-read-only?] (let [keys [(events/listen js/document EventType.KEYDOWN on-key-down) (events/listen js/document EventType.KEYUP on-key-up) ;; bind with passive=false to allow the event to be cancelled diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index 3edbe19c21..be88366ae4 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -73,9 +73,10 @@ objects))) (mf/defc viewport* - [{:keys [selected wglobal wlocal layout file page palete-size]}] + [{:keys [selected wglobal layout file page palete-size]}] (let [;; When adding data from workspace-local revisit `app.main.ui.workspace` to check ;; that the new parameter is sent + {:keys [edit-path panning selrect @@ -85,17 +86,18 @@ vport zoom zoom-inverse - edition]} wlocal + edition]} + (mf/deref refs/workspace-local) {:keys [options-mode tooltip show-distances? - picking-color?]} wglobal + picking-color?]} + wglobal permissions (mf/use-ctx ctx/permissions) read-only? (mf/use-ctx ctx/workspace-read-only?) - ;; DEREFS drawing (mf/deref refs/workspace-drawing) focus (mf/deref refs/workspace-focus-selected) wasm-modifiers (mf/deref refs/workspace-wasm-modifiers) diff --git a/package.json b/package.json index f6028d0a2f..0a04064cc8 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC", "private": true, - "packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264", + "packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" @@ -16,9 +16,9 @@ "fmt": "./scripts/fmt" }, "devDependencies": { - "@github/copilot": "^1.0.11", + "@github/copilot": "^1.0.12", "@types/node": "^20.12.7", "esbuild": "^0.27.4", - "opencode-ai": "^1.3.2" + "opencode-ai": "^1.3.7" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44c73fd54e..419ae0a178 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: devDependencies: '@github/copilot': - specifier: ^1.0.11 - version: 1.0.11 + specifier: ^1.0.12 + version: 1.0.12 '@types/node': specifier: ^20.12.7 version: 20.19.37 @@ -18,8 +18,8 @@ importers: specifier: ^0.27.4 version: 0.27.4 opencode-ai: - specifier: ^1.3.2 - version: 1.3.2 + specifier: ^1.3.7 + version: 1.3.7 packages: @@ -179,44 +179,44 @@ packages: cpu: [x64] os: [win32] - '@github/copilot-darwin-arm64@1.0.11': - resolution: {integrity: sha512-wdKimjtbsVeXqMqQSnGpGBPFEYHljxXNuWeH8EIJTNRgFpAsimcivsFgql3Twq4YOp0AxfsH36icG4IEen30mA==} + '@github/copilot-darwin-arm64@1.0.12': + resolution: {integrity: sha512-fjbwRIUZAH06Eyg5ZkfZXg8SVXpqI3HaFhtXZ803CZs9mfIgfOSR3URZxUnv7SIv6aI/7f6ws8RxKnPGavJ/tg==} cpu: [arm64] os: [darwin] hasBin: true - '@github/copilot-darwin-x64@1.0.11': - resolution: {integrity: sha512-VeuPv8rzBVGBB8uDwMEhcHBpldoKaq26yZ5YQm+G9Ka5QIF+1DMah8ZNRMVsTeNKkb1ji9G8vcuCsaPbnG3fKg==} + '@github/copilot-darwin-x64@1.0.12': + resolution: {integrity: sha512-/tJGJEEm8kpTW/sJRNnvhMSHKIHApNun14biuIkC5CXDqVgFakbKlckn/FlIkT48eEUysc0YbEatrHIDz/8XbA==} cpu: [x64] os: [darwin] hasBin: true - '@github/copilot-linux-arm64@1.0.11': - resolution: {integrity: sha512-/d8p6RlFYKj1Va2hekFIcYNMHWagcEkaxgcllUNXSyQLnmEtXUkaWtz62VKGWE+n/UMkEwCB6vI2xEwPTlUNBQ==} + '@github/copilot-linux-arm64@1.0.12': + resolution: {integrity: sha512-4977LVJi3/9Yc+ivj+VKDVtHg0kT5yqOrN8F35/jgqerx4Mdtk1pOMlWztXxLicBHN4y2V7/EY/wc0WqFW0Zvg==} cpu: [arm64] os: [linux] hasBin: true - '@github/copilot-linux-x64@1.0.11': - resolution: {integrity: sha512-UujTRO3xkPFC1CybchBbCnaTEAG6JrH0etIst07JvfekMWgvRxbiCHQPpDPSzBCPiBcGu0gba0/IT+vUCORuIw==} + '@github/copilot-linux-x64@1.0.12': + resolution: {integrity: sha512-9QevJZD29PVltYDV4xHWbdN6ud/966clERL5Frh2+9D3+spaVDO1hFllzoFiEwD/M4f2GkSh7/fT3hV0LKl9Ag==} cpu: [x64] os: [linux] hasBin: true - '@github/copilot-win32-arm64@1.0.11': - resolution: {integrity: sha512-EOW8HUM+EmnHEZEa+iUMl4pP1+2eZUk2XCbynYiMehwX9sidc4BxEHp2RuxADSzFPTieQEWzgjQmHWrtet8pQg==} + '@github/copilot-win32-arm64@1.0.12': + resolution: {integrity: sha512-RLAbAsLniI8vA2utgZdIsvD8slZpz1fb8l6cmIiQvDE/BwQb2zNV9VepZ+CwzYtNx9ifxBtgIwYwUJq5bxeSaQ==} cpu: [arm64] os: [win32] hasBin: true - '@github/copilot-win32-x64@1.0.11': - resolution: {integrity: sha512-fKGkSNamzs3h9AbmswNvPYJBORCb2Y8CbusijU3C7fT3ohvqnHJwKo5iHhJXLOKZNOpFZgq9YKha410u9sIs6Q==} + '@github/copilot-win32-x64@1.0.12': + resolution: {integrity: sha512-4SYV09F4Sw20DAib1do26+ALZmCZrghzo+5e6IZbQOsm4B7NhBFaLpKFU+kEijfmWacLlh/at5CpGGGKlwlbcg==} cpu: [x64] os: [win32] hasBin: true - '@github/copilot@1.0.11': - resolution: {integrity: sha512-cptVopko/tNKEXyBP174yBjHQBEwg6CqaKN2S0M3J+5LEB8u31bLL75ioOPd+5vubqBrA0liyTdcHeZ8UTRbmg==} + '@github/copilot@1.0.12': + resolution: {integrity: sha512-GpmoJbs1ECyLLKtY4PcFzO8Cz6GgDTOKkrzwNdkirNdfsB+o6x0OOlFyrOdNXNPII7pk9+GcpIjF87sLwWzpPQ==} hasBin: true '@types/node@20.19.37': @@ -227,67 +227,67 @@ packages: engines: {node: '>=18'} hasBin: true - opencode-ai@1.3.2: - resolution: {integrity: sha512-InyDAXKoh+fVxWrBMJZaf1xIYpASZ2zX4O/u7nwtiYzxy/kqHySvQe9jDVrhMgbMdb4CXzACid7M2HDUa+vz2Q==} + opencode-ai@1.3.7: + resolution: {integrity: sha512-AtqTOcPvHkAF/zPGs/08/8m2DeiWADCGhT/WAJ1drGem4WdPOt45jkJLPdOCheN1gqmLxTcNV0veKFVHmbjKKQ==} hasBin: true - opencode-darwin-arm64@1.3.2: - resolution: {integrity: sha512-/7V+J3XZGF/sCdMbEb5E3mUvuOIVvGkVjgYH1k/pnTfdGaPW/C7RgW2dU2HedXvkw4Y3CplUS+5VfA/F5kufXw==} + opencode-darwin-arm64@1.3.7: + resolution: {integrity: sha512-TRglBnrSzeR9pEFV8Z1ACqhD3r3WYl8v1y9TkgvHviTD/EXGL3Nu7f/fy3XOQprPGSLPyrlOwZXb1i9XrfTo1A==} cpu: [arm64] os: [darwin] - opencode-darwin-x64-baseline@1.3.2: - resolution: {integrity: sha512-jBusp8Vb1wsGKHD2AOD+Cr4qL4zSDut80Sy0CnMH8AwnSCNzaSVi2wnE/7vPrgCa44Xr0MknSE2S0wHQ5SbLaw==} + opencode-darwin-x64-baseline@1.3.7: + resolution: {integrity: sha512-YLl+peZIC1m295JNSMzM/NM5uNs2GVewZply2GV3P6WApS5JuRIaMNvzOk7jpL90yt6pur6oEwC8g0WWwP8G4A==} cpu: [x64] os: [darwin] - opencode-darwin-x64@1.3.2: - resolution: {integrity: sha512-J7HgNBUoDpsKHAiky18aUm4xMKmUIJqlvVMkvL9NVjwDRMuAKnbYcxpRK8O+NKzeVx29IWwX8zizUwCuqXAAlA==} + opencode-darwin-x64@1.3.7: + resolution: {integrity: sha512-Nsyh3vLAqqfVyWD4qrcyRJit+CmZZpm6IdXTk9wo1hUAE/RmYIBDz1To99ZBwA3SJB1fLrciYicMN2uq8r1XNw==} cpu: [x64] os: [darwin] - opencode-linux-arm64-musl@1.3.2: - resolution: {integrity: sha512-ZOB6+NvkZ19mzi6j4VAHtQDAeTXvQJqg4YtclT3wbMof8y3jn5S8vUmaXLk9d9FryEcdIoMK/oFLkcLkS1OXpA==} + opencode-linux-arm64-musl@1.3.7: + resolution: {integrity: sha512-D4gCn7oVLCc3xN0BSJOfYerCr1E1ktUkixfHQEmkoR1CLZ77Z/aHSgcm0Ln01Q+ie6MsVukvuyUQn9GEY1Dn/A==} cpu: [arm64] os: [linux] - opencode-linux-arm64@1.3.2: - resolution: {integrity: sha512-+q0PJ86LPitMIGf9sGXdtOcwpdiwbzdw91zXAHFe7rXZZm4Cvu9qjvt6WT/lKJqL8f6pyJs9Kbt4XJ/C20swQA==} + opencode-linux-arm64@1.3.7: + resolution: {integrity: sha512-72OnT20wIhkXMGclmw7S+d8JjIb9lx8pPIW8pkyI79+qxLTp6AuTHsmUG/qDhw3NMtVDs9efAb0C/FjLXATeAA==} cpu: [arm64] os: [linux] - opencode-linux-x64-baseline-musl@1.3.2: - resolution: {integrity: sha512-8XYcAqZBcUgYHfae9qoTT8erqFjd6AKTrSu60gRgzTN8cUybG4iPyAVt4GUcXCzUw44Qo++d/8I9hwpUTCoFYg==} + opencode-linux-x64-baseline-musl@1.3.7: + resolution: {integrity: sha512-DE8eqPF2benmdzUdMG+rnr0J3DtrP+x8sUzq7gecuNnU4iHo4s8Itx+gCLP978ZBdYwTkNRtNZ5BKN0ePT5KYQ==} cpu: [x64] os: [linux] - opencode-linux-x64-baseline@1.3.2: - resolution: {integrity: sha512-QXiF1+PJH5AOHMSTdYngOB7yWULFQKJpIbx77ZHM1MKyxiBweIrZ03nG86iXIZIVGBxEfH1CcKDU28lFGUJdNg==} + opencode-linux-x64-baseline@1.3.7: + resolution: {integrity: sha512-Aztdiq0U6H8Nww7mmARK/ZGkllTrePuyEEdzg2+0LWfUpDO5Cl/pMJ8btqVtTjfb/pLw+BU3JtYxw8oOhRkl/A==} cpu: [x64] os: [linux] - opencode-linux-x64-musl@1.3.2: - resolution: {integrity: sha512-o4aXgcWQidxQ6cJMsgLcCMv9MSKtLEqm3OAFm6eN7hVgHV9m7rz035BICNZ1YLltIEdQYAHXx5MGM21J4/vBdw==} + opencode-linux-x64-musl@1.3.7: + resolution: {integrity: sha512-L0ohQAbw1Ib1koawV/yJAYIGIel2zMPafbdeMXELIvpes3Sq9qIfCSRB/2ROu8gjN8P1TGnUU6Vx1F3MtJOvIA==} cpu: [x64] os: [linux] - opencode-linux-x64@1.3.2: - resolution: {integrity: sha512-/+OtuHr2O0/QKpBrZBnutr0ZLe+LmOx003Z99b21BgG9wsVo7gFuNpfLPVXnqcEY6D5DuWl4aEJW4+w+K0urYw==} + opencode-linux-x64@1.3.7: + resolution: {integrity: sha512-rCFXrgDLhPuHazomDgzBXGLE0wJ4VRHrIe26WCHm4iqmGu9O6ExZd612Y07/CGQm4bVBHlaalcWh7N/z6GOPkA==} cpu: [x64] os: [linux] - opencode-windows-arm64@1.3.2: - resolution: {integrity: sha512-3xJEjSLdk7I5Z6yyrT4LCQeK4VCj6mo/l4JdUa0zocV8wCzUtY1lSHeipDAaNr8TwD6NeTxr1iomIjuatKAFOA==} + opencode-windows-arm64@1.3.7: + resolution: {integrity: sha512-s6emZ28ORIMtKyrBKvo96q2qanRwbjPHK/rOMinZ22SW7DLzNKKf1p92JMkSni0dXXGL64jsy1se5IvELc7Mvg==} cpu: [arm64] os: [win32] - opencode-windows-x64-baseline@1.3.2: - resolution: {integrity: sha512-RVx9e1KBNTSWjrtfRpGIIcJDsM13OUY/lYqJXI1oj34lMOEj5G57fypjk9yBqUHBR+7tolGmewwJJU6SDgyPXQ==} + opencode-windows-x64-baseline@1.3.7: + resolution: {integrity: sha512-CGbhvn9rMXV4xEjw1lxPFbsWuOPf/xJ1AAblqKsF2VmSbqe25QG5VIf88hsJo8YmYIHz6U7tNGI4lTF1zVx9cw==} cpu: [x64] os: [win32] - opencode-windows-x64@1.3.2: - resolution: {integrity: sha512-pJMpptERqz8pjnW1pTQf5Ru2WbJz7P2BPM5De5gdUd10y0yUoGvY7uzyJYuGpsrCqeWrEYokERnbv8A/4V3Yaw==} + opencode-windows-x64@1.3.7: + resolution: {integrity: sha512-q7V9p10Q7BH03tnYMG4k6B1ZXLDriDMtXkkw+NW1p22F7dQ22WmaMoCWnv3d4b2vNyjMjIYuPm97q0v02QI08w==} cpu: [x64] os: [win32] @@ -374,32 +374,32 @@ snapshots: '@esbuild/win32-x64@0.27.4': optional: true - '@github/copilot-darwin-arm64@1.0.11': + '@github/copilot-darwin-arm64@1.0.12': optional: true - '@github/copilot-darwin-x64@1.0.11': + '@github/copilot-darwin-x64@1.0.12': optional: true - '@github/copilot-linux-arm64@1.0.11': + '@github/copilot-linux-arm64@1.0.12': optional: true - '@github/copilot-linux-x64@1.0.11': + '@github/copilot-linux-x64@1.0.12': optional: true - '@github/copilot-win32-arm64@1.0.11': + '@github/copilot-win32-arm64@1.0.12': optional: true - '@github/copilot-win32-x64@1.0.11': + '@github/copilot-win32-x64@1.0.12': optional: true - '@github/copilot@1.0.11': + '@github/copilot@1.0.12': optionalDependencies: - '@github/copilot-darwin-arm64': 1.0.11 - '@github/copilot-darwin-x64': 1.0.11 - '@github/copilot-linux-arm64': 1.0.11 - '@github/copilot-linux-x64': 1.0.11 - '@github/copilot-win32-arm64': 1.0.11 - '@github/copilot-win32-x64': 1.0.11 + '@github/copilot-darwin-arm64': 1.0.12 + '@github/copilot-darwin-x64': 1.0.12 + '@github/copilot-linux-arm64': 1.0.12 + '@github/copilot-linux-x64': 1.0.12 + '@github/copilot-win32-arm64': 1.0.12 + '@github/copilot-win32-x64': 1.0.12 '@types/node@20.19.37': dependencies: @@ -434,55 +434,55 @@ snapshots: '@esbuild/win32-ia32': 0.27.4 '@esbuild/win32-x64': 0.27.4 - opencode-ai@1.3.2: + opencode-ai@1.3.7: optionalDependencies: - opencode-darwin-arm64: 1.3.2 - opencode-darwin-x64: 1.3.2 - opencode-darwin-x64-baseline: 1.3.2 - opencode-linux-arm64: 1.3.2 - opencode-linux-arm64-musl: 1.3.2 - opencode-linux-x64: 1.3.2 - opencode-linux-x64-baseline: 1.3.2 - opencode-linux-x64-baseline-musl: 1.3.2 - opencode-linux-x64-musl: 1.3.2 - opencode-windows-arm64: 1.3.2 - opencode-windows-x64: 1.3.2 - opencode-windows-x64-baseline: 1.3.2 + opencode-darwin-arm64: 1.3.7 + opencode-darwin-x64: 1.3.7 + opencode-darwin-x64-baseline: 1.3.7 + opencode-linux-arm64: 1.3.7 + opencode-linux-arm64-musl: 1.3.7 + opencode-linux-x64: 1.3.7 + opencode-linux-x64-baseline: 1.3.7 + opencode-linux-x64-baseline-musl: 1.3.7 + opencode-linux-x64-musl: 1.3.7 + opencode-windows-arm64: 1.3.7 + opencode-windows-x64: 1.3.7 + opencode-windows-x64-baseline: 1.3.7 - opencode-darwin-arm64@1.3.2: + opencode-darwin-arm64@1.3.7: optional: true - opencode-darwin-x64-baseline@1.3.2: + opencode-darwin-x64-baseline@1.3.7: optional: true - opencode-darwin-x64@1.3.2: + opencode-darwin-x64@1.3.7: optional: true - opencode-linux-arm64-musl@1.3.2: + opencode-linux-arm64-musl@1.3.7: optional: true - opencode-linux-arm64@1.3.2: + opencode-linux-arm64@1.3.7: optional: true - opencode-linux-x64-baseline-musl@1.3.2: + opencode-linux-x64-baseline-musl@1.3.7: optional: true - opencode-linux-x64-baseline@1.3.2: + opencode-linux-x64-baseline@1.3.7: optional: true - opencode-linux-x64-musl@1.3.2: + opencode-linux-x64-musl@1.3.7: optional: true - opencode-linux-x64@1.3.2: + opencode-linux-x64@1.3.7: optional: true - opencode-windows-arm64@1.3.2: + opencode-windows-arm64@1.3.7: optional: true - opencode-windows-x64-baseline@1.3.2: + opencode-windows-x64-baseline@1.3.7: optional: true - opencode-windows-x64@1.3.2: + opencode-windows-x64@1.3.7: optional: true undici-types@6.21.0: {} From 3767ee05bbab92c99114d9c0d911a005a721e846 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 27 Mar 2026 11:10:26 +0100 Subject: [PATCH 15/15] :sparkles: Add retry mechanism for idenpotent get repo requests on frontend (#8792) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :recycle: Handle fetch-error gracefully with toast instead of full-page error Network-level failures (lost connectivity, DNS failure, etc.) on RPC calls were propagating as :internal/:fetch-error to the global error handler, which replaced the entire UI with a full-page error screen. Now the :internal handler distinguishes :fetch-error from other internal errors and shows a non-intrusive toast notification instead, allowing the user to continue working. * :sparkles: Add automatic retry with backoff for idempotent RPC requests Idempotent (GET) RPC requests are now automatically retried up to 3 times with exponential back-off (1s, 2s, 4s) when a transient error occurs. Retryable errors include: network-level failures (:fetch-error), 502 Bad Gateway, 503 Service Unavailable, and browser offline (status 0). Mutation (POST) requests are never retried to avoid unintended side-effects. Non-transient errors (4xx client errors, auth errors, validation errors) propagate immediately without retry. * :recycle: Make retry helpers public with configurable parameters Make retryable-error? and with-retry public functions, and replace private constants with a default-retry-config map. with-retry now accepts an optional config map (:max-retries, :base-delay-ms) enabling callers and tests to customize retry behavior. * :sparkles: Add tests for RPC retry mechanism Comprehensive tests for the retry helpers in app.main.repo: - retryable-error? predicate: covers all retryable types (fetch-error, bad-gateway, service-unavailable, offline) and non-retryable types (validation, authentication, authorization, plain errors) - with-retry observable wrapper: verifies immediate success, recovery after transient failures, max-retries exhaustion, no retry for non-retryable errors, fetch-error retry, custom config, and mixed error scenarios * :recycle: Introduce :network error type for fetch-level failures Replace the awkward {:type :internal :code :fetch-error} combination with a proper {:type :network} type in app.util.http/fetch. This makes the error taxonomy self-explanatory and removes the special-case branch in the :internal handler. Consequences: - http.cljs: emit {:type :network} instead of {:type :internal :code :fetch-error} - errors.cljs: add a dedicated ptk/handle-error :network method (toast); restore :internal handler to its original unconditional full-page error form - repo.cljs: simplify retryable-types and retryable-error? — :network replaces the former :internal special-case, no code check needed - repo_test.cljs: update tests to use {:type :network} * :books: Add comment explaining the use of bit-shift-left --- frontend/src/app/main/errors.cljs | 9 + frontend/src/app/main/repo.cljs | 108 +++++++-- frontend/src/app/util/http.cljs | 3 +- .../test/frontend_tests/data/repo_test.cljs | 217 ++++++++++++++++++ frontend/test/frontend_tests/runner.cljs | 2 + 5 files changed, 315 insertions(+), 24 deletions(-) create mode 100644 frontend/test/frontend_tests/data/repo_test.cljs diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index 745f1adec7..a4037b1f63 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -118,6 +118,15 @@ :level :error :timeout 5000}))) +(defmethod ptk/handle-error :network + [error] + ;; Transient network errors (e.g. lost connectivity, DNS failure) + ;; should not replace the entire page with an error screen. Show a + ;; non-intrusive toast instead and let the user continue working. + (when-let [cause (::instance error)] + (ex/print-throwable cause :prefix "Network Error")) + (flash :cause (::instance error) :type :handled)) + (defmethod ptk/handle-error :internal [error] (st/emit! (rt/assign-exception error)) diff --git a/frontend/src/app/main/repo.cljs b/frontend/src/app/main/repo.cljs index c7a3133558..6f264e5d02 100644 --- a/frontend/src/app/main/repo.cljs +++ b/frontend/src/app/main/repo.cljs @@ -21,6 +21,61 @@ (log/set-level! :info) +;; -- Retry helpers ----------------------------------------------------------- + +(def ^:private retryable-types + "Set of error types that are considered transient and safe to retry + for idempotent (GET) requests." + #{:network ; js/fetch network-level failure + :bad-gateway ; 502 + :service-unavailable ; 503 + :offline}) ; status 0 (browser offline) + +(defn retryable-error? + "Return true when `error` represents a transient failure that is safe + to retry. Only errors whose `ex-data` `:type` belongs to + `retryable-types` qualify." + [error] + (contains? retryable-types (:type (ex-data error)))) + +(def default-retry-config + "Default configuration for the retry mechanism on idempotent requests." + {:max-retries 3 + :base-delay-ms 1000}) + +(defn with-retry + "Wrap `observable-fn` (a zero-arg function returning an Observable) so + that retryable errors are retried up to `:max-retries` times with + exponential back-off. Non-retryable errors propagate immediately. + + Accepts an optional `config` map with: + :max-retries – maximum number of retries (default 3) + :base-delay-ms – base delay in ms; doubles each attempt (default 1000)" + ([observable-fn] + (with-retry observable-fn default-retry-config)) + ([observable-fn config] + (with-retry observable-fn config 0)) + ([observable-fn config attempt] + (let [{:keys [max-retries base-delay-ms]} (merge default-retry-config config)] + (->> (observable-fn) + (rx/catch + (fn [cause] + (if (and (retryable-error? cause) + (< attempt max-retries)) + ;; bit-shift-left 1 N is equivalent to 2^N: shift the bits of the + ;; number 1 to the left N positions (e.g. 1 -> 2 -> 4 -> 8 -> 16), + ;; producing exponential backoff delays of 1x, 2x, 4x, 8x, 16x. + (let [delay-ms (* base-delay-ms (bit-shift-left 1 attempt))] + (log/wrn :hint "retrying request" + :attempt (inc attempt) + :delay delay-ms + :error (ex-message cause)) + (->> (rx/timer delay-ms) + (rx/mapcat (fn [_] (with-retry observable-fn config (inc attempt)))))) + (rx/throw cause)))))))) + +;; -- Response handling ------------------------------------------------------- + (defn handle-response [{:keys [status body headers uri] :as response}] (cond @@ -146,32 +201,41 @@ (log/trc :hint "make request" :id id) - (->> (http/fetch request) - (rx/map http/response->map) - (rx/mapcat (fn [{:keys [headers body] :as response}] - (log/trc :hint "response received" :id id :elapsed (tpoint)) + (let [make-request + (fn [] + (->> (http/fetch request) + (rx/map http/response->map) + (rx/mapcat (fn [{:keys [headers body] :as response}] + (log/trc :hint "response received" :id id :elapsed (tpoint)) - (let [ctype (get headers "content-type") - response-stream? (str/starts-with? ctype "text/event-stream") - tpoint (ct/tpoint-ms)] + (let [ctype (get headers "content-type") + response-stream? (str/starts-with? ctype "text/event-stream") + tpoint (ct/tpoint-ms)] - (when (and response-stream? (not stream?)) - (ex/raise :type :assertion - :code :unexpected-response - :hint "expected normal response, received sse stream" - :uri (:uri response) - :status (:status response))) + (when (and response-stream? (not stream?)) + (ex/raise :type :assertion + :code :unexpected-response + :hint "expected normal response, received sse stream" + :uri (:uri response) + :status (:status response))) - (if response-stream? - (-> (sse/create-stream body) - (sse/read-stream t/decode-str)) + (if response-stream? + (-> (sse/create-stream body) + (sse/read-stream t/decode-str)) - (->> response - (http/process-response-type response-type) - (rx/map decode-fn) - (rx/tap (fn [_] - (log/trc :hint "response decoded" :id id :elapsed (tpoint)))) - (rx/mapcat handle-response))))))))) + (->> response + (http/process-response-type response-type) + (rx/map decode-fn) + (rx/tap (fn [_] + (log/trc :hint "response decoded" :id id :elapsed (tpoint)))) + (rx/mapcat handle-response))))))))] + + ;; Idempotent (GET) requests are automatically retried on + ;; transient network / server errors. Mutations are never + ;; retried to avoid unintended side-effects. + (if (= :get method) + (with-retry make-request) + (make-request))))) (defmulti cmd! (fn [id _] id)) diff --git a/frontend/src/app/util/http.cljs b/frontend/src/app/util/http.cljs index ae8237a943..0813b59f08 100644 --- a/frontend/src/app/util/http.cljs +++ b/frontend/src/app/util/http.cljs @@ -108,8 +108,7 @@ (vreset! abortable? false) (when-not (or @unsubscribed? (= (.-name ^js cause) "AbortError")) (let [error (ex-info (ex-message cause) - {:type :internal - :code :fetch-error + {:type :network :hint "unable to perform fetch operation" :uri uri :headers headers} diff --git a/frontend/test/frontend_tests/data/repo_test.cljs b/frontend/test/frontend_tests/data/repo_test.cljs new file mode 100644 index 0000000000..d4ac101086 --- /dev/null +++ b/frontend/test/frontend_tests/data/repo_test.cljs @@ -0,0 +1,217 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns frontend-tests.data.repo-test + (:require + [app.main.repo :as repo] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true])) + +;; --------------------------------------------------------------------------- +;; retryable-error? tests (synchronous) +;; --------------------------------------------------------------------------- + +(t/deftest retryable-error-network + (t/testing "network error (js/fetch failure) is retryable" + (let [err (ex-info "network" {:type :network})] + (t/is (true? (repo/retryable-error? err)))))) + +(t/deftest retryable-error-bad-gateway + (t/testing "502 bad-gateway is retryable" + (let [err (ex-info "bad gateway" {:type :bad-gateway})] + (t/is (true? (repo/retryable-error? err)))))) + +(t/deftest retryable-error-service-unavailable + (t/testing "503 service-unavailable is retryable" + (let [err (ex-info "service unavailable" {:type :service-unavailable})] + (t/is (true? (repo/retryable-error? err)))))) + +(t/deftest retryable-error-offline + (t/testing "offline (status 0) is retryable" + (let [err (ex-info "offline" {:type :offline})] + (t/is (true? (repo/retryable-error? err)))))) + +(t/deftest retryable-error-internal + (t/testing "internal error (genuine bug) is NOT retryable" + (let [err (ex-info "internal" {:type :internal :code :something})] + (t/is (not (repo/retryable-error? err)))))) + +(t/deftest retryable-error-validation + (t/testing "validation error is NOT retryable" + (let [err (ex-info "validation" {:type :validation :code :request-body-too-large})] + (t/is (not (repo/retryable-error? err)))))) + +(t/deftest retryable-error-authentication + (t/testing "authentication error is NOT retryable" + (let [err (ex-info "auth" {:type :authentication})] + (t/is (not (repo/retryable-error? err)))))) + +(t/deftest retryable-error-authorization + (t/testing "authorization/challenge error is NOT retryable" + (let [err (ex-info "auth" {:type :authorization :code :challenge-required})] + (t/is (not (repo/retryable-error? err)))))) + +(t/deftest retryable-error-no-ex-data + (t/testing "plain error without ex-data is NOT retryable" + (let [err (js/Error. "plain")] + (t/is (not (repo/retryable-error? err)))))) + +;; --------------------------------------------------------------------------- +;; with-retry tests (async, using zero-delay config for speed) +;; --------------------------------------------------------------------------- + +(def ^:private fast-config + "Retry config with zero delay for fast tests." + {:max-retries 3 :base-delay-ms 0}) + +(t/deftest with-retry-succeeds-immediately + (t/testing "returns value when observable succeeds on first try" + (t/async done + (let [call-count (atom 0) + obs-fn (fn [] + (swap! call-count inc) + (rx/of :ok))] + (->> (repo/with-retry obs-fn fast-config) + (rx/subs! + (fn [val] + (t/is (= :ok val)) + (t/is (= 1 @call-count)) + (done)) + (fn [err] + (t/is false (str "unexpected error: " (ex-message err))) + (done)))))))) + +(t/deftest with-retry-retries-on-retryable-error + (t/testing "retries and eventually succeeds after transient failures" + (t/async done + (let [call-count (atom 0) + obs-fn (fn [] + (let [n (swap! call-count inc)] + (if (< n 3) + ;; First two calls fail with retryable error + (rx/throw (ex-info "bad gateway" {:type :bad-gateway})) + ;; Third call succeeds + (rx/of :recovered))))] + (->> (repo/with-retry obs-fn fast-config) + (rx/subs! + (fn [val] + (t/is (= :recovered val)) + (t/is (= 3 @call-count)) + (done)) + (fn [err] + (t/is false (str "unexpected error: " (ex-message err))) + (done)))))))) + +(t/deftest with-retry-exhausts-retries + (t/testing "propagates error after max retries exhausted" + (t/async done + (let [call-count (atom 0) + obs-fn (fn [] + (swap! call-count inc) + (rx/throw (ex-info "offline" {:type :offline})))] + (->> (repo/with-retry obs-fn fast-config) + (rx/subs! + (fn [_val] + (t/is false "should not succeed") + (done)) + (fn [err] + ;; 1 initial + 3 retries = 4 total calls + (t/is (= 4 @call-count)) + (t/is (= :offline (:type (ex-data err)))) + (done)))))))) + +(t/deftest with-retry-no-retry-on-non-retryable + (t/testing "non-retryable errors propagate immediately without retry" + (t/async done + (let [call-count (atom 0) + obs-fn (fn [] + (swap! call-count inc) + (rx/throw (ex-info "auth" {:type :authentication})))] + (->> (repo/with-retry obs-fn fast-config) + (rx/subs! + (fn [_val] + (t/is false "should not succeed") + (done)) + (fn [err] + (t/is (= 1 @call-count)) + (t/is (= :authentication (:type (ex-data err)))) + (done)))))))) + +(t/deftest with-retry-network-error-retried + (t/testing "network error (js/fetch failure) is retried" + (t/async done + (let [call-count (atom 0) + obs-fn (fn [] + (let [n (swap! call-count inc)] + (if (= n 1) + (rx/throw (ex-info "net" {:type :network})) + (rx/of :ok))))] + (->> (repo/with-retry obs-fn fast-config) + (rx/subs! + (fn [val] + (t/is (= :ok val)) + (t/is (= 2 @call-count)) + (done)) + (fn [err] + (t/is false (str "unexpected error: " (ex-message err))) + (done)))))))) + +(t/deftest with-retry-internal-not-retried + (t/testing "internal error (genuine bug) is not retried" + (t/async done + (let [call-count (atom 0) + obs-fn (fn [] + (swap! call-count inc) + (rx/throw (ex-info "bug" {:type :internal + :code :something})))] + (->> (repo/with-retry obs-fn fast-config) + (rx/subs! + (fn [_val] + (t/is false "should not succeed") + (done)) + (fn [err] + (t/is (= 1 @call-count)) + (t/is (= :internal (:type (ex-data err)))) + (done)))))))) + +(t/deftest with-retry-respects-max-retries-config + (t/testing "respects custom max-retries setting" + (t/async done + (let [call-count (atom 0) + config {:max-retries 1 :base-delay-ms 0} + obs-fn (fn [] + (swap! call-count inc) + (rx/throw (ex-info "offline" {:type :offline})))] + (->> (repo/with-retry obs-fn config) + (rx/subs! + (fn [_val] + (t/is false "should not succeed") + (done)) + (fn [err] + ;; 1 initial + 1 retry = 2 total + (t/is (= 2 @call-count)) + (t/is (= :offline (:type (ex-data err)))) + (done)))))))) + +(t/deftest with-retry-mixed-errors + (t/testing "retries retryable errors, then stops on non-retryable" + (t/async done + (let [call-count (atom 0) + obs-fn (fn [] + (let [n (swap! call-count inc)] + (case n + 1 (rx/throw (ex-info "gw" {:type :bad-gateway})) + 2 (rx/throw (ex-info "auth" {:type :authentication})) + (rx/of :should-not-reach))))] + (->> (repo/with-retry obs-fn fast-config) + (rx/subs! + (fn [_val] + (t/is false "should not succeed") + (done)) + (fn [err] + (t/is (= 2 @call-count)) + (t/is (= :authentication (:type (ex-data err)))) + (done)))))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 93fcd3897a..488c5f9cf2 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -2,6 +2,7 @@ (:require [cljs.test :as t] [frontend-tests.basic-shapes-test] + [frontend-tests.data.repo-test] [frontend-tests.data.workspace-colors-test] [frontend-tests.data.workspace-texts-test] [frontend-tests.helpers-shapes-test] @@ -36,6 +37,7 @@ [] (t/run-tests 'frontend-tests.basic-shapes-test + 'frontend-tests.data.repo-test 'frontend-tests.data.workspace-colors-test 'frontend-tests.data.workspace-texts-test 'frontend-tests.helpers-shapes-test