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`. 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. 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/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}) 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/runner.cljc b/common/test/common_tests/runner.cljc index 8fa90003d5..07b44b8d23 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -47,8 +47,10 @@ [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] [common-tests.uuid-test])) #?(:cljs (enable-console-print!)) @@ -93,6 +95,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 @@ -102,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/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/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))))))) 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)))))))) 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/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 diff --git a/frontend/src/app/config.cljs b/frontend/src/app/config.cljs index b0f67f1165..4755b9fa15 100644 --- a/frontend/src/app/config.cljs +++ b/frontend/src/app/config.cljs @@ -239,12 +239,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/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 6b54b59d0e..43ff2a71af 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -623,27 +623,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/errors.cljs b/frontend/src/app/main/errors.cljs index b1df018682..4eb52b5b35 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -395,10 +395,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/main/repo.cljs b/frontend/src/app/main/repo.cljs index d2a0ff5068..6f264e5d02 100644 --- a/frontend/src/app/main/repo.cljs +++ b/frontend/src/app/main/repo.cljs @@ -108,7 +108,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 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/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/src/app/render_wasm/api/fonts.cljs b/frontend/src/app/render_wasm/api/fonts.cljs index f50a7e9a14..9eab0582f5 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, @@ -158,7 +158,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 diff --git a/frontend/src/app/util/http.cljs b/frontend/src/app/util/http.cljs index f2810d4ac0..1568a19674 100644 --- a/frontend/src/app/util/http.cljs +++ b/frontend/src/app/util/http.cljs @@ -126,11 +126,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] 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 b39ba239d5..488c5f9cf2 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -4,6 +4,7 @@ [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] [frontend-tests.logic.comp-remove-swap-slots-test] [frontend-tests.logic.components-and-tokens] @@ -38,6 +39,7 @@ '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 'frontend-tests.logic.comp-remove-swap-slots-test 'frontend-tests.logic.components-and-tokens diff --git a/package.json b/package.json index f1e95b022c..0a04064cc8 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "license": "MPL-2.0", "author": "Kaleidos INC", "private": true, - "packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268", + "packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319", "repository": { "type": "git", "url": "https://github.com/penpot/penpot" @@ -12,12 +12,13 @@ "type": "module", "scripts": { "lint": "./scripts/lint", + "check-fmt": "./scripts/check-fmt", "fmt": "./scripts/fmt" }, "devDependencies": { "@github/copilot": "^1.0.12", "@types/node": "^20.12.7", - "esbuild": "^0.25.9", + "esbuild": "^0.27.4", "opencode-ai": "^1.3.7" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9926b4a29..419ae0a178 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.7 version: 1.3.7 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,8 +222,8 @@ 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 @@ -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.12': @@ -405,34 +405,34 @@ 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.7: optionalDependencies: