diff --git a/common/src/app/common/types/modifiers.cljc b/common/src/app/common/types/modifiers.cljc index 2f79e6483f..68ab9e2584 100644 --- a/common/src/app/common/types/modifiers.cljc +++ b/common/src/app/common/types/modifiers.cljc @@ -12,11 +12,13 @@ [app.common.files.helpers :as cfh] [app.common.geom.matrix :as gmt] [app.common.geom.point :as gpt] + [app.common.geom.rect :as grc] [app.common.geom.shapes.common :as gco] [app.common.geom.shapes.corners :as gsc] [app.common.geom.shapes.effects :as gse] [app.common.geom.shapes.strokes :as gss] [app.common.math :as mth] + [app.common.schema :as sm] [app.common.types.shape.layout :as ctl] [app.common.types.text :as txt] [clojure.core :as c])) @@ -117,6 +119,33 @@ (or (not ^boolean (mth/almost-zero? (- (dm/get-prop vector :x) 1))) (not ^boolean (mth/almost-zero? (- (dm/get-prop vector :y) 1))))) +(defn- safe-size-rect? + "Returns true when `rect` has finite, in-range, positive width and height." + [rect] + (and ^boolean (some? rect) + (let [w (dm/get-prop rect :width) + h (dm/get-prop rect :height)] + (and ^boolean (d/num? w h) + ^boolean (pos? w) + ^boolean (pos? h) + ^boolean (<= w sm/max-safe-int) + ^boolean (<= h sm/max-safe-int))))) + +(defn safe-size-rect + "Returns the best available size rect for a shape, trying several + fallbacks in order: + 1. `:selrect` — if it has valid, in-range, positive dimensions. + 2. `points->rect` — computed from the shape's corner points. + 3. Top-level `:x :y :width :height` shape fields. + 4. `grc/empty-rect` — a unit rect (0,0,0.01,0.01) of last resort." + [{:keys [selrect points x y width height]}] + (or (and ^boolean (safe-size-rect? selrect) selrect) + (let [from-points (grc/points->rect points)] + (and ^boolean (safe-size-rect? from-points) from-points)) + (let [from-shape (grc/make-rect x y width height)] + (and ^boolean (safe-size-rect? from-shape) from-shape)) + grc/empty-rect)) + (defn- mergeable-move? [op1 op2] (let [type-op1 (dm/get-prop op1 :type) @@ -195,7 +224,7 @@ (conj item))) (conj operations op)))))) -(defn valid-vector? +(defn- valid-vector? [vector] (let [x (dm/get-prop vector :x) y (dm/get-prop vector :y)] @@ -309,11 +338,6 @@ (-> (or modifiers (empty)) (update :structure-child conj (scale-content-op value)))) -(defn change-recursive-property - [modifiers property value] - (-> (or modifiers (empty)) - (update :structure-child conj (change-property-op property value)))) - (defn change-property [modifiers property value] (-> (or modifiers (empty)) @@ -348,7 +372,7 @@ (recur result (rest operations))))))) -(defn increase-order +(defn- increase-order [operations last-order] (->> operations (mapv #(update % :order + last-order)))) @@ -390,13 +414,6 @@ ([vector] (move (empty) vector))) -(defn move-parent-modifiers - ([x y] - (move-parent (empty) (gpt/point x y))) - - ([vector] - (move-parent (empty) vector))) - (defn resize-modifiers ([vector origin] (resize (empty) vector origin)) @@ -404,13 +421,6 @@ ([vector origin transform transform-inverse] (resize (empty) vector origin transform transform-inverse))) -(defn resize-parent-modifiers - ([vector origin] - (resize-parent (empty) vector origin)) - - ([vector origin transform transform-inverse] - (resize-parent (empty) vector origin transform transform-inverse))) - (defn rotation-modifiers [shape center angle] (let [shape-center (gco/shape->center shape) @@ -426,73 +436,54 @@ (rotation shape-center angle) (move move-vec)))) -(defn remove-children-modifiers - [shapes] - (-> (empty) - (remove-children shapes))) - -(defn add-children-modifiers - [shapes index] - (-> (empty) - (add-children shapes index))) - (defn reflow-modifiers [] (-> (empty) (reflow))) -(defn scale-content-modifiers - [value] - (-> (empty) - (scale-content value))) - (defn change-size - [{:keys [selrect points transform transform-inverse] :as shape} width height] - (let [old-width (-> selrect :width) - old-height (-> selrect :height) - width (or width old-width) - height (or height old-height) - origin (first points) - scalex (/ width old-width) - scaley (/ height old-height)] + [{:keys [points transform transform-inverse] :as shape} width height] + (let [{sr-width :width sr-height :height} (safe-size-rect shape) + width (or width sr-width) + height (or height sr-height) + origin (first points) + scalex (/ width sr-width) + scaley (/ height sr-height)] (resize-modifiers (gpt/point scalex scaley) origin transform transform-inverse))) (defn change-dimensions-modifiers ([shape attr value] (change-dimensions-modifiers shape attr value nil)) - ([{:keys [transform transform-inverse] :as shape} attr value {:keys [ignore-lock?] :or {ignore-lock? false}}] + ([shape attr value {:keys [ignore-lock?] :or {ignore-lock? false}}] (dm/assert! (map? shape)) (dm/assert! (#{:width :height} attr)) (dm/assert! (number? value)) - (let [;; Avoid havig shapes with zero size - value (if (< (mth/abs value) 0.01) - 0.01 - value) + (let [;; Avoid having shapes with zero size + value (if (< (mth/abs value) 0.01) 0.01 value) {:keys [proportion proportion-lock]} shape - size (select-keys (:selrect shape) [:width :height]) - new-size (if-not (and (not ignore-lock?) proportion-lock) - (assoc size attr value) - (if (= attr :width) - (-> size - (assoc :width value) - (assoc :height (/ value proportion))) - (-> size - (assoc :height value) - (assoc :width (* value proportion))))) + {sr-width :width sr-height :height} (safe-size-rect shape) + locked? (and (not ignore-lock?) proportion-lock) - width (:width new-size) - height (:height new-size) + width (if (= attr :width) + value + (if locked? (* value proportion) sr-width)) - {sr-width :width sr-height :height} (:selrect shape) + height (if (= attr :height) + value + (if locked? (/ value proportion) sr-height)) origin (-> shape :points first) scalex (/ width sr-width) scaley (/ height sr-height)] - (resize-modifiers (gpt/point scalex scaley) origin transform transform-inverse)))) + (resize-modifiers + (gpt/point scalex scaley) + origin + (:transform shape) + (:transform-inverse shape))))) (defn change-orientation-modifiers [shape orientation] @@ -566,7 +557,7 @@ [modifiers] (assoc (or modifiers (empty)) :geometry-child [] :structure-child [])) -(defn select-structure +(defn- select-structure [modifiers] (assoc (or modifiers (empty)) :geometry-child [] :geometry-parent [])) @@ -574,10 +565,6 @@ [modifiers] (assoc (or modifiers (empty)) :structure-child [] :structure-parent [])) -(defn select-child-geometry-modifiers - [modifiers] - (-> modifiers select-child select-geometry)) - (defn select-child-structre-modifiers [modifiers] (-> modifiers select-child select-structure)) @@ -601,7 +588,7 @@ ;; Main transformation functions -(defn transform-move! +(defn- transform-move! "Transforms a matrix by the translation modifier" [matrix modifier] (-> (dm/get-prop modifier :vector) @@ -609,7 +596,7 @@ (gmt/multiply! matrix))) -(defn transform-resize! +(defn- transform-resize! "Transforms a matrix by the resize modifier" [matrix modifier] (let [tf (dm/get-prop modifier :transform) @@ -631,7 +618,7 @@ (gmt/multiply! tfi))) matrix))) -(defn transform-rotate! +(defn- transform-rotate! "Transforms a matrix by the rotation modifier" [matrix modifier] (let [center (dm/get-prop modifier :center) @@ -643,7 +630,7 @@ (gmt/translate! (gpt/negate center))) matrix))) -(defn transform! +(defn- transform! "Returns a matrix transformed by the modifier" [matrix modifier] (let [type (dm/get-prop modifier :type)] @@ -652,8 +639,7 @@ :resize (transform-resize! matrix modifier) :rotation (transform-rotate! matrix modifier)))) -(defn modifiers->transform1 - "A multiplatform version of modifiers->transform." +(defn- modifiers->transform1 [modifiers] (reduce transform! (gmt/matrix) modifiers)) @@ -665,80 +651,28 @@ modifiers (sort-by #(dm/get-prop % :order) modifiers)] (modifiers->transform1 modifiers))) -(defn modifiers->transform-old - "Given a set of modifiers returns its transformation matrix" - [modifiers] - (let [modifiers (->> (concat (dm/get-prop modifiers :geometry-parent) - (dm/get-prop modifiers :geometry-child)) - (sort-by :order))] - - (loop [matrix (gmt/matrix) - modifiers (seq modifiers)] - (if (c/empty? modifiers) - matrix - (let [modifier (first modifiers) - type (dm/get-prop modifier :type) - - matrix - (case type - :move - (-> (dm/get-prop modifier :vector) - (gmt/translate-matrix) - (gmt/multiply! matrix)) - - :resize - (let [tf (dm/get-prop modifier :transform) - tfi (dm/get-prop modifier :transform-inverse) - vector (dm/get-prop modifier :vector) - origin (dm/get-prop modifier :origin) - origin (if ^boolean (some? tfi) - (gpt/transform origin tfi) - origin)] - - (gmt/multiply! - (-> (gmt/matrix) - (cond-> ^boolean (some? tf) - (gmt/multiply! tf)) - (gmt/translate! origin) - (gmt/scale! vector) - (gmt/translate! (gpt/negate origin)) - (cond-> ^boolean (some? tfi) - (gmt/multiply! tfi))) - matrix)) - - :rotation - (let [center (dm/get-prop modifier :center) - rotation (dm/get-prop modifier :rotation)] - (gmt/multiply! - (-> (gmt/matrix) - (gmt/translate! center) - (gmt/multiply! (gmt/rotate-matrix rotation)) - (gmt/translate! (gpt/negate center))) - matrix)))] - (recur matrix (next modifiers))))))) - -(defn transform-text-node [value attrs] +(defn- transform-text-node [value attrs] (let [font-size (-> (get attrs :font-size 14) d/parse-double (* value) str) letter-spacing (-> (get attrs :letter-spacing 0) d/parse-double (* value) str)] (d/txt-merge attrs {:font-size font-size :letter-spacing letter-spacing}))) -(defn transform-paragraph-node [value attrs] +(defn- transform-paragraph-node [value attrs] (let [font-size (-> (get attrs :font-size 14) d/parse-double (* value) str)] (d/txt-merge attrs {:font-size font-size}))) -(defn update-text-content +(defn- update-text-content [shape scale-text-content value] (update shape :content scale-text-content value)) -(defn scale-text-content +(defn- scale-text-content [content value] (->> content (txt/transform-nodes txt/is-text-node? (partial transform-text-node value)) (txt/transform-nodes txt/is-paragraph-node? (partial transform-paragraph-node value)))) -(defn apply-scale-content +(defn- apply-scale-content [shape value] ;; Scale can only be positive (let [value (mth/abs value)] @@ -767,7 +701,7 @@ :always (ctl/update-flex-child value)))) -(defn remove-children-set +(defn- remove-children-set [shapes children-to-remove] (let [remove? (set children-to-remove)] (d/removev remove? shapes))) diff --git a/common/test/common_tests/types/modifiers_test.cljc b/common/test/common_tests/types/modifiers_test.cljc index 264b3e71e5..6922aceb82 100644 --- a/common/test/common_tests/types/modifiers_test.cljc +++ b/common/test/common_tests/types/modifiers_test.cljc @@ -8,7 +8,13 @@ (:require [app.common.geom.matrix :as gmt] [app.common.geom.point :as gpt] + [app.common.geom.rect :as grc] + [app.common.geom.shapes :as gsh] + [app.common.math :as mth] + [app.common.schema :as sm] [app.common.types.modifiers :as ctm] + [app.common.types.shape :as cts] + [app.common.uuid :as uuid] [clojure.test :as t])) (t/deftest modifiers->transform @@ -24,3 +30,630 @@ transform (ctm/modifiers->transform modifiers)] (t/is (not (gmt/close? (gmt/matrix) transform))))) + +;; ─── Helpers ────────────────────────────────────────────────────────────────── + +(defn- make-shape + "Build a minimal axis-aligned rect shape with the given geometry." + ([width height] + (make-shape 0 0 width height)) + ([x y width height] + (cts/setup-shape {:type :rect :x x :y y :width width :height height}))) + +(defn- make-shape-with-proportion + "Build a shape with a fixed proportion ratio and proportion-lock enabled." + [width height] + (assoc (make-shape width height) + :proportion (/ (float width) (float height)) + :proportion-lock true)) + +(defn- resize-op + "Extract the single resize GeometricOperation from geometry-child." + [modifiers] + (first (:geometry-child modifiers))) + +;; ─── change-size ────────────────────────────────────────────────────────────── + +(t/deftest change-size-basic + (t/testing "scales both axes to the requested dimensions" + (let [shape (make-shape 100 50) + mods (ctm/change-size shape 200 100) + op (resize-op mods)] + (t/is (mth/close? 2.0 (-> op :vector :x))) + (t/is (mth/close? 2.0 (-> op :vector :y))))) + + (t/testing "origin is the first point of the shape (top-left)" + (let [shape (make-shape 10 20 100 50) + mods (ctm/change-size shape 200 50) + origin (:origin (resize-op mods))] + (t/is (mth/close? 10.0 (:x origin))) + (t/is (mth/close? 20.0 (:y origin))))) + + (t/testing "nil width falls back to current width, keeping x-scale at 1" + (let [shape (make-shape 100 50) + mods (ctm/change-size shape nil 100) + op (resize-op mods)] + (t/is (mth/close? 1.0 (-> op :vector :x))) + (t/is (mth/close? 2.0 (-> op :vector :y))))) + + (t/testing "nil height falls back to current height, keeping y-scale at 1" + (let [shape (make-shape 100 50) + mods (ctm/change-size shape 200 nil) + op (resize-op mods)] + (t/is (mth/close? 2.0 (-> op :vector :x))) + (t/is (mth/close? 1.0 (-> op :vector :y))))) + + (t/testing "both nil produces an identity resize (scale 1,1)" + (let [shape (make-shape 100 50) + mods (ctm/change-size shape nil nil) + op (resize-op mods)] + ;; Identity resize is optimized away; geometry-child should be empty. + (t/is (empty? (:geometry-child mods))))) + + (t/testing "transform and transform-inverse on a plain shape are both the identity matrix" + (let [shape (make-shape 100 50) + mods (ctm/change-size shape 200 100) + op (resize-op mods)] + (t/is (gmt/close? (gmt/matrix) (:transform op))) + (t/is (gmt/close? (gmt/matrix) (:transform-inverse op)))))) + +;; ─── change-dimensions-modifiers ────────────────────────────────────────────── + +(t/deftest change-dimensions-modifiers-no-lock + (t/testing "changing width only scales x-axis; y-scale stays 1" + (let [shape (make-shape 100 50) + mods (ctm/change-dimensions-modifiers shape :width 200) + op (resize-op mods)] + (t/is (mth/close? 2.0 (-> op :vector :x))) + (t/is (mth/close? 1.0 (-> op :vector :y))))) + + (t/testing "changing height only scales y-axis; x-scale stays 1" + (let [shape (make-shape 100 50) + mods (ctm/change-dimensions-modifiers shape :height 100) + op (resize-op mods)] + (t/is (mth/close? 1.0 (-> op :vector :x))) + (t/is (mth/close? 2.0 (-> op :vector :y))))) + + (t/testing "origin is always the top-left point of the shape" + (let [shape (make-shape 30 40 100 50) + mods (ctm/change-dimensions-modifiers shape :width 200) + origin (:origin (resize-op mods))] + (t/is (mth/close? 30.0 (:x origin))) + (t/is (mth/close? 40.0 (:y origin)))))) + +(t/deftest change-dimensions-modifiers-with-proportion-lock + (t/testing "locking width also adjusts height by the inverse proportion" + ;; shape 100x50 → proportion = 100/50 = 2 + ;; new width 200 → expected height = 200/2 = 100 → scaley = 100/50 = 2 + (let [shape (make-shape-with-proportion 100 50) + mods (ctm/change-dimensions-modifiers shape :width 200) + op (resize-op mods)] + (t/is (mth/close? 2.0 (-> op :vector :x))) + (t/is (mth/close? 2.0 (-> op :vector :y))))) + + (t/testing "locking height also adjusts width by the proportion" + ;; shape 100x50 → proportion = 100/50 = 2 + ;; new height 100 → expected width = 100*2 = 200 → scalex = 200/100 = 2 + (let [shape (make-shape-with-proportion 100 50) + mods (ctm/change-dimensions-modifiers shape :height 100) + op (resize-op mods)] + (t/is (mth/close? 2.0 (-> op :vector :x))) + (t/is (mth/close? 2.0 (-> op :vector :y))))) + + (t/testing "ignore-lock? true bypasses proportion lock" + (let [shape (make-shape-with-proportion 100 50) + mods (ctm/change-dimensions-modifiers shape :width 200 {:ignore-lock? true}) + op (resize-op mods)] + (t/is (mth/close? 2.0 (-> op :vector :x))) + ;; Height should remain unchanged (scale = 1). + (t/is (mth/close? 1.0 (-> op :vector :y)))))) + +(t/deftest change-dimensions-modifiers-value-clamping + (t/testing "value below 0.01 is clamped to 0.01" + (let [shape (make-shape 100 50) + mods (ctm/change-dimensions-modifiers shape :width 0.001) + op (resize-op mods)] + ;; 0.01 / 100 = 0.0001 + (t/is (mth/close? 0.0001 (-> op :vector :x))))) + + (t/testing "value of exactly 0 is clamped to 0.01" + (let [shape (make-shape 100 50) + mods (ctm/change-dimensions-modifiers shape :height 0) + op (resize-op mods)] + ;; 0.01 / 50 = 0.0002 + (t/is (mth/close? 0.0002 (-> op :vector :y)))))) + +(t/deftest change-dimensions-modifiers-end-to-end + (t/testing "applying change-width modifier produces the expected selrect width" + (let [shape (make-shape 100 50) + mods (ctm/change-dimensions-modifiers shape :width 300) + result (gsh/transform-shape (assoc shape :modifiers mods))] + (t/is (mth/close? 300.0 (-> result :selrect :width))) + (t/is (mth/close? 50.0 (-> result :selrect :height))))) + + (t/testing "applying change-height modifier produces the expected selrect height" + (let [shape (make-shape 100 50) + mods (ctm/change-dimensions-modifiers shape :height 200) + result (gsh/transform-shape (assoc shape :modifiers mods))] + (t/is (mth/close? 100.0 (-> result :selrect :width))) + (t/is (mth/close? 200.0 (-> result :selrect :height)))))) + +;; ─── safe-size-rect fallbacks ───────────────────────────────────────────────── + +(t/deftest safe-size-rect-fallbacks + (t/testing "valid selrect is returned as-is" + (let [shape (make-shape 100 50) + mods (ctm/change-size shape 200 100) + op (resize-op mods)] + ;; scale 2,2 means the selrect was valid + (t/is (mth/close? 2.0 (-> op :vector :x))) + (t/is (mth/close? 2.0 (-> op :vector :y))))) + + (t/testing "zero-width selrect falls back to points, producing a valid rect" + ;; Corrupt only the selrect dimensions; the shape's points remain valid. + (let [base (make-shape 100 50) + bad-selrect (assoc (:selrect base) :width 0 :height 0) + shape (assoc base :selrect bad-selrect) + mods (ctm/change-size shape 200 100) + op (resize-op mods)] + (t/is (cts/shape? shape)) + (t/is (mth/close? 2.0 (-> op :vector :x))) + (t/is (mth/close? 2.0 (-> op :vector :y))))) + + (t/testing "NaN selrect falls back to points" + (let [base (make-shape 100 50) + bad-selrect (assoc (:selrect base) :width ##NaN :height ##NaN) + shape (assoc base :selrect bad-selrect) + mods (ctm/change-size shape 200 100) + op (resize-op mods)] + (t/is (cts/shape? shape)) + (t/is (mth/close? 2.0 (-> op :vector :x))) + (t/is (mth/close? 2.0 (-> op :vector :y))))) + + (t/testing "selrect with dimensions exceeding max-safe-int falls back to points" + (let [base (make-shape 100 50) + bad-selrect (assoc (:selrect base) :width (inc sm/max-safe-int) :height (inc sm/max-safe-int)) + shape (assoc base :selrect bad-selrect) + mods (ctm/change-size shape 200 100) + op (resize-op mods)] + (t/is (cts/shape? shape)) + (t/is (mth/close? 2.0 (-> op :vector :x))) + (t/is (mth/close? 2.0 (-> op :vector :y))))) + + (t/testing "invalid selrect and no points falls back to top-level shape fields" + ;; Null out both selrect and points; the top-level :x/:y/:width/:height + ;; fields on the Shape record are still valid and serve as fallback 3. + (let [shape (-> (make-shape 100 50) + (assoc :selrect nil) + (assoc :points nil)) + mods (ctm/change-size shape 200 100) + op (resize-op mods)] + (t/is (cts/shape? shape)) + (t/is (mth/close? 2.0 (-> op :vector :x))) + (t/is (mth/close? 2.0 (-> op :vector :y))))) + + (t/testing "all geometry missing: falls back to empty-rect (0.01 x 0.01)" + ;; Null out selrect, points and the top-level dimension fields so that + ;; every fallback is exhausted and empty-rect (0.01×0.01) is used. + (let [shape (-> (make-shape 100 50) + (assoc :selrect nil) + (assoc :points nil) + (assoc :width nil) + (assoc :height nil)) + mods (ctm/change-size shape 200 100) + op (resize-op mods)] + (t/is (cts/shape? shape)) + (t/is (mth/close? (/ 200 0.01) (-> op :vector :x))) + (t/is (mth/close? (/ 100 0.01) (-> op :vector :y)))))) + +;; ─── Builder functions: geometry-child ──────────────────────────────────────── + +(t/deftest move-builder + (t/testing "move adds an operation to geometry-child" + (let [mods (ctm/move (ctm/empty) (gpt/point 10 20))] + (t/is (= 1 (count (:geometry-child mods)))) + (t/is (= :move (-> mods :geometry-child first :type))))) + + (t/testing "move with zero vector is optimised away" + (let [mods (ctm/move (ctm/empty) (gpt/point 0 0))] + (t/is (empty? (:geometry-child mods))))) + + (t/testing "two consecutive moves on the same axis are merged into one" + (let [mods (-> (ctm/empty) + (ctm/move (gpt/point 10 0)) + (ctm/move (gpt/point 5 0)))] + (t/is (= 1 (count (:geometry-child mods)))) + (t/is (mth/close? 15.0 (-> mods :geometry-child first :vector :x))))) + + (t/testing "move with x y arity delegates to vector arity" + (let [mods (ctm/move (ctm/empty) 3 7)] + (t/is (= 1 (count (:geometry-child mods)))) + (t/is (mth/close? 3.0 (-> mods :geometry-child first :vector :x))) + (t/is (mth/close? 7.0 (-> mods :geometry-child first :vector :y)))))) + +(t/deftest resize-builder + (t/testing "resize adds an operation to geometry-child" + (let [mods (ctm/resize (ctm/empty) (gpt/point 2 3) (gpt/point 0 0))] + (t/is (= 1 (count (:geometry-child mods)))) + (t/is (= :resize (-> mods :geometry-child first :type))))) + + (t/testing "identity resize (scale 1,1) is optimised away" + (let [mods (ctm/resize (ctm/empty) (gpt/point 1 1) (gpt/point 0 0))] + (t/is (empty? (:geometry-child mods))))) + + (t/testing "precise? flag keeps near-identity resize" + (let [mods (ctm/resize (ctm/empty) (gpt/point 1 1) (gpt/point 0 0) + nil nil {:precise? true})] + (t/is (= 1 (count (:geometry-child mods)))))) + + (t/testing "resize stores origin, transform and transform-inverse" + (let [tf (gmt/matrix) + tfi (gmt/matrix) + mods (ctm/resize (ctm/empty) (gpt/point 2 2) (gpt/point 5 10) tf tfi) + op (-> mods :geometry-child first)] + (t/is (mth/close? 5.0 (-> op :origin :x))) + (t/is (mth/close? 10.0 (-> op :origin :y))) + (t/is (gmt/close? tf (:transform op))) + (t/is (gmt/close? tfi (:transform-inverse op)))))) + +(t/deftest rotation-builder + (t/testing "rotation adds ops to both geometry-child and structure-child" + (let [mods (ctm/rotation (ctm/empty) (gpt/point 50 50) 45)] + (t/is (= 1 (count (:geometry-child mods)))) + (t/is (= 1 (count (:structure-child mods)))) + (t/is (= :rotation (-> mods :geometry-child first :type))) + (t/is (= :rotation (-> mods :structure-child first :type))))) + + (t/testing "zero-angle rotation is optimised away" + (let [mods (ctm/rotation (ctm/empty) (gpt/point 50 50) 0)] + (t/is (empty? (:geometry-child mods))) + (t/is (empty? (:structure-child mods)))))) + +;; ─── Builder functions: geometry-parent ─────────────────────────────────────── + +(t/deftest move-parent-builder + (t/testing "move-parent adds an operation to geometry-parent, not geometry-child" + (let [mods (ctm/move-parent (ctm/empty) (gpt/point 10 20))] + (t/is (= 1 (count (:geometry-parent mods)))) + (t/is (empty? (:geometry-child mods))) + (t/is (= :move (-> mods :geometry-parent first :type))))) + + (t/testing "move-parent with zero vector is optimised away" + (let [mods (ctm/move-parent (ctm/empty) (gpt/point 0 0))] + (t/is (empty? (:geometry-parent mods)))))) + +(t/deftest resize-parent-builder + (t/testing "resize-parent adds an operation to geometry-parent, not geometry-child" + (let [mods (ctm/resize-parent (ctm/empty) (gpt/point 2 3) (gpt/point 0 0))] + (t/is (= 1 (count (:geometry-parent mods)))) + (t/is (empty? (:geometry-child mods))) + (t/is (= :resize (-> mods :geometry-parent first :type)))))) + +;; ─── Builder functions: structure ───────────────────────────────────────────── + +(t/deftest structure-builders + (t/testing "add-children appends an add-children op to structure-parent" + (let [id1 (uuid/next) + id2 (uuid/next) + mods (ctm/add-children (ctm/empty) [id1 id2] nil)] + (t/is (= 1 (count (:structure-parent mods)))) + (t/is (= :add-children (-> mods :structure-parent first :type))))) + + (t/testing "add-children with empty list is a no-op" + (let [mods (ctm/add-children (ctm/empty) [] nil)] + (t/is (empty? (:structure-parent mods))))) + + (t/testing "remove-children appends a remove-children op to structure-parent" + (let [id (uuid/next) + mods (ctm/remove-children (ctm/empty) [id])] + (t/is (= 1 (count (:structure-parent mods)))) + (t/is (= :remove-children (-> mods :structure-parent first :type))))) + + (t/testing "remove-children with empty list is a no-op" + (let [mods (ctm/remove-children (ctm/empty) [])] + (t/is (empty? (:structure-parent mods))))) + + (t/testing "reflow appends a reflow op to structure-parent" + (let [mods (ctm/reflow (ctm/empty))] + (t/is (= 1 (count (:structure-parent mods)))) + (t/is (= :reflow (-> mods :structure-parent first :type))))) + + (t/testing "scale-content appends a scale-content op to structure-child" + (let [mods (ctm/scale-content (ctm/empty) 2.0)] + (t/is (= 1 (count (:structure-child mods)))) + (t/is (= :scale-content (-> mods :structure-child first :type))))) + + (t/testing "change-property appends a change-property op to structure-parent" + (let [mods (ctm/change-property (ctm/empty) :opacity 0.5)] + (t/is (= 1 (count (:structure-parent mods)))) + (t/is (= :change-property (-> mods :structure-parent first :type))) + (t/is (= :opacity (-> mods :structure-parent first :property))) + (t/is (= 0.5 (-> mods :structure-parent first :value)))))) + +;; ─── Convenience builders ───────────────────────────────────────────────────── + +(t/deftest convenience-builders + (t/testing "move-modifiers returns a fresh Modifiers with a move in geometry-child" + (let [mods (ctm/move-modifiers (gpt/point 5 10))] + (t/is (= 1 (count (:geometry-child mods)))) + (t/is (= :move (-> mods :geometry-child first :type))))) + + (t/testing "move-modifiers accepts x y arity" + (let [mods (ctm/move-modifiers 5 10)] + (t/is (= 1 (count (:geometry-child mods)))))) + + (t/testing "resize-modifiers returns a fresh Modifiers with a resize in geometry-child" + (let [mods (ctm/resize-modifiers (gpt/point 2 2) (gpt/point 0 0))] + (t/is (= 1 (count (:geometry-child mods)))) + (t/is (= :resize (-> mods :geometry-child first :type))))) + + (t/testing "reflow-modifiers returns a fresh Modifiers with a reflow in structure-parent" + (let [mods (ctm/reflow-modifiers)] + (t/is (= 1 (count (:structure-parent mods)))) + (t/is (= :reflow (-> mods :structure-parent first :type))))) + + (t/testing "rotation-modifiers returns move + rotation in geometry-child" + (let [shape (make-shape 100 100) + mods (ctm/rotation-modifiers shape (gpt/point 50 50) 90)] + ;; rotation adds a :rotation and a :move to compensate for off-center + (t/is (pos? (count (:geometry-child mods)))) + (t/is (some #(= :rotation (:type %)) (:geometry-child mods)))))) + +;; ─── add-modifiers ──────────────────────────────────────────────────────────── + +(t/deftest add-modifiers-combinator + (t/testing "combining two disjoint move modifiers sums the vectors" + (let [m1 (ctm/move-modifiers (gpt/point 10 0)) + m2 (ctm/move-modifiers (gpt/point 5 0)) + result (ctm/add-modifiers m1 m2)] + ;; Both are pure geometry-child moves → they get merged + (t/is (= 1 (count (:geometry-child result)))) + (t/is (mth/close? 15.0 (-> result :geometry-child first :vector :x))))) + + (t/testing "nil first argument is treated as empty" + (let [m2 (ctm/move-modifiers (gpt/point 3 4)) + result (ctm/add-modifiers nil m2)] + (t/is (= 1 (count (:geometry-child result)))))) + + (t/testing "nil second argument is treated as empty" + (let [m1 (ctm/move-modifiers (gpt/point 3 4)) + result (ctm/add-modifiers m1 nil)] + (t/is (= 1 (count (:geometry-child result)))))) + + (t/testing "last-order is the sum of both modifiers' orders" + (let [m1 (ctm/move-modifiers (gpt/point 1 0)) + m2 (ctm/move-modifiers (gpt/point 2 0)) + result (ctm/add-modifiers m1 m2)] + (t/is (= (+ (:last-order m1) (:last-order m2)) + (:last-order result)))))) + +;; ─── Predicates ─────────────────────────────────────────────────────────────── + +(t/deftest predicate-empty? + (t/testing "fresh empty modifiers is empty?" + (t/is (ctm/empty? (ctm/empty)))) + + (t/testing "modifiers with a move are not empty?" + (t/is (not (ctm/empty? (ctm/move-modifiers (gpt/point 1 0)))))) + + (t/testing "modifiers with only a structure op are not empty?" + (t/is (not (ctm/empty? (ctm/reflow-modifiers)))))) + +(t/deftest predicate-child-modifiers? + (t/testing "move in geometry-child → child-modifiers? true" + (t/is (ctm/child-modifiers? (ctm/move-modifiers (gpt/point 1 0))))) + + (t/testing "scale-content in structure-child → child-modifiers? true" + (t/is (ctm/child-modifiers? (ctm/scale-content (ctm/empty) 2.0)))) + + (t/testing "move-parent in geometry-parent only → child-modifiers? false" + (t/is (not (ctm/child-modifiers? (ctm/move-parent (ctm/empty) (gpt/point 1 0))))))) + +(t/deftest predicate-has-geometry? + (t/testing "move in geometry-child → has-geometry? true" + (t/is (ctm/has-geometry? (ctm/move-modifiers (gpt/point 1 0))))) + + (t/testing "move-parent in geometry-parent → has-geometry? true" + (t/is (ctm/has-geometry? (ctm/move-parent (ctm/empty) (gpt/point 1 0))))) + + (t/testing "only structure ops → has-geometry? false" + (t/is (not (ctm/has-geometry? (ctm/reflow-modifiers)))))) + +(t/deftest predicate-has-structure? + (t/testing "reflow op in structure-parent → has-structure? true" + (t/is (ctm/has-structure? (ctm/reflow-modifiers)))) + + (t/testing "scale-content in structure-child → has-structure? true" + (t/is (ctm/has-structure? (ctm/scale-content (ctm/empty) 2.0)))) + + (t/testing "only geometry ops → has-structure? false" + (t/is (not (ctm/has-structure? (ctm/move-modifiers (gpt/point 1 0))))))) + +(t/deftest predicate-has-structure-child? + (t/testing "scale-content in structure-child → has-structure-child? true" + (t/is (ctm/has-structure-child? (ctm/scale-content (ctm/empty) 2.0)))) + + (t/testing "reflow in structure-parent only → has-structure-child? false" + (t/is (not (ctm/has-structure-child? (ctm/reflow-modifiers)))))) + +(t/deftest predicate-only-move? + (t/testing "pure move modifiers → only-move? true" + (t/is (ctm/only-move? (ctm/move-modifiers (gpt/point 1 0))))) + + (t/testing "resize modifiers → only-move? false" + (t/is (not (ctm/only-move? (ctm/resize-modifiers (gpt/point 2 2) (gpt/point 0 0)))))) + + (t/testing "structure ops present → only-move? false" + (let [mods (-> (ctm/empty) + (ctm/move (gpt/point 1 0)) + (ctm/reflow))] + (t/is (not (ctm/only-move? mods))))) + + (t/testing "empty modifiers → only-move? true (vacuously)" + (t/is (ctm/only-move? (ctm/empty))))) + +;; ─── Projection functions ────────────────────────────────────────────────────── + +(t/deftest projection-select-child + (t/testing "select-child keeps geometry-child and structure-child, clears parent" + (let [mods (-> (ctm/empty) + (ctm/move (gpt/point 1 0)) + (ctm/move-parent (gpt/point 2 0)) + (ctm/reflow) + (ctm/scale-content 2.0)) + result (ctm/select-child mods)] + (t/is (= 1 (count (:geometry-child result)))) + (t/is (= 1 (count (:structure-child result)))) + (t/is (empty? (:geometry-parent result))) + (t/is (empty? (:structure-parent result)))))) + +(t/deftest projection-select-parent + (t/testing "select-parent keeps geometry-parent and structure-parent, clears child" + (let [mods (-> (ctm/empty) + (ctm/move (gpt/point 1 0)) + (ctm/move-parent (gpt/point 2 0)) + (ctm/reflow) + (ctm/scale-content 2.0)) + result (ctm/select-parent mods)] + (t/is (= 1 (count (:geometry-parent result)))) + (t/is (= 1 (count (:structure-parent result)))) + (t/is (empty? (:geometry-child result))) + (t/is (empty? (:structure-child result)))))) + +(t/deftest projection-select-geometry + (t/testing "select-geometry keeps both geometry lists, clears structure" + (let [mods (-> (ctm/empty) + (ctm/move (gpt/point 1 0)) + (ctm/move-parent (gpt/point 2 0)) + (ctm/reflow) + (ctm/scale-content 2.0)) + result (ctm/select-geometry mods)] + (t/is (= 1 (count (:geometry-child result)))) + (t/is (= 1 (count (:geometry-parent result)))) + (t/is (empty? (:structure-child result))) + (t/is (empty? (:structure-parent result)))))) + +(t/deftest projection-select-child-structre-modifiers + (t/testing "select-child-structre-modifiers keeps only structure-child" + (let [mods (-> (ctm/empty) + (ctm/move (gpt/point 1 0)) + (ctm/move-parent (gpt/point 2 0)) + (ctm/reflow) + (ctm/scale-content 2.0)) + result (ctm/select-child-structre-modifiers mods)] + (t/is (= 1 (count (:structure-child result)))) + (t/is (empty? (:geometry-child result))) + (t/is (empty? (:geometry-parent result))) + (t/is (empty? (:structure-parent result)))))) + +;; ─── added-children-frames ──────────────────────────────────────────────────── + +(t/deftest added-children-frames-test + (t/testing "returns frame/shape pairs for add-children operations" + (let [frame-id (uuid/next) + shape-id (uuid/next) + mods (ctm/add-children (ctm/empty) [shape-id] nil) + tree {frame-id {:modifiers mods}} + result (ctm/added-children-frames tree)] + (t/is (= 1 (count result))) + (t/is (= frame-id (:frame (first result)))) + (t/is (= shape-id (:shape (first result)))))) + + (t/testing "returns empty when there are no add-children operations" + (let [frame-id (uuid/next) + mods (ctm/reflow-modifiers) + tree {frame-id {:modifiers mods}} + result (ctm/added-children-frames tree)] + (t/is (empty? result)))) + + (t/testing "returns empty for an empty modif-tree" + (t/is (empty? (ctm/added-children-frames {}))))) + +;; ─── apply-modifier and apply-structure-modifiers ───────────────────────────── + +(t/deftest apply-modifier-test + (t/testing "rotation op increments shape :rotation field" + (let [shape (make-shape 100 50) + op (-> (ctm/rotation (ctm/empty) (gpt/point 50 25) 90) + :structure-child + first) + result (ctm/apply-modifier shape op)] + (t/is (mth/close? 90.0 (:rotation result))))) + + (t/testing "rotation wraps around 360" + (let [shape (assoc (make-shape 100 50) :rotation 350) + op (-> (ctm/rotation (ctm/empty) (gpt/point 50 25) 20) + :structure-child + first) + result (ctm/apply-modifier shape op)] + (t/is (mth/close? 10.0 (:rotation result))))) + + (t/testing "add-children op appends ids to shape :shapes" + (let [id1 (uuid/next) + id2 (uuid/next) + shape (assoc (make-shape 100 50) :shapes []) + op (-> (ctm/add-children (ctm/empty) [id1 id2] nil) + :structure-parent + first) + result (ctm/apply-modifier shape op)] + (t/is (= [id1 id2] (:shapes result))))) + + (t/testing "add-children op with index inserts at the given position" + (let [id-existing (uuid/next) + id-new (uuid/next) + shape (assoc (make-shape 100 50) :shapes [id-existing]) + op (-> (ctm/add-children (ctm/empty) [id-new] 0) + :structure-parent + first) + result (ctm/apply-modifier shape op)] + (t/is (= id-new (first (:shapes result)))))) + + (t/testing "remove-children op removes given ids from shape :shapes" + (let [id1 (uuid/next) + id2 (uuid/next) + shape (assoc (make-shape 100 50) :shapes [id1 id2]) + op (-> (ctm/remove-children (ctm/empty) [id1]) + :structure-parent + first) + result (ctm/apply-modifier shape op)] + (t/is (= [id2] (:shapes result))))) + + (t/testing "change-property op sets the property on the shape" + (let [shape (make-shape 100 50) + op (-> (ctm/change-property (ctm/empty) :opacity 0.5) + :structure-parent + first) + result (ctm/apply-modifier shape op)] + (t/is (= 0.5 (:opacity result))))) + + (t/testing "unknown op type returns shape unchanged" + (let [shape (make-shape 100 50) + result (ctm/apply-modifier shape {:type :unknown})] + (t/is (= shape result))))) + +(t/deftest apply-structure-modifiers-test + (t/testing "applies structure-parent and structure-child ops in order" + (let [id (uuid/next) + shape (assoc (make-shape 100 50) :shapes []) + mods (-> (ctm/empty) + (ctm/add-children [id] nil) + (ctm/scale-content 1.0)) + result (ctm/apply-structure-modifiers shape mods)] + (t/is (= [id] (:shapes result))))) + + (t/testing "empty modifiers returns shape unchanged" + (let [shape (make-shape 100 50) + result (ctm/apply-structure-modifiers shape (ctm/empty))] + (t/is (= shape result)))) + + (t/testing "change-property in structure-parent is applied" + (let [shape (make-shape 100 50) + mods (ctm/change-property (ctm/empty) :opacity 0.3) + result (ctm/apply-structure-modifiers shape mods)] + (t/is (= 0.3 (:opacity result))))) + + (t/testing "rotation in structure-child is applied" + (let [shape (make-shape 100 50) + mods (ctm/rotation (ctm/empty) (gpt/point 50 25) 45) + result (ctm/apply-structure-modifiers shape mods)] + (t/is (mth/close? 45.0 (:rotation result)))))) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 8874115443..a7d6a9d4ca 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -618,27 +618,30 @@ (assoc-in [:workspace-global :default-font] data)))))) (defn apply-text-modifier - [shape {:keys [width height position-data]}] + [shape text-modifier] - (let [new-shape - (cond-> shape - (some? width) - (gsh/transform-shape (ctm/change-dimensions-modifiers shape :width width {:ignore-lock? true})) + (if (some? text-modifier) + (let [{:keys [width height position-data]} text-modifier + new-shape + (cond-> shape + (some? width) + (gsh/transform-shape (ctm/change-dimensions-modifiers shape :width width {:ignore-lock? true})) - (some? height) - (gsh/transform-shape (ctm/change-dimensions-modifiers shape :height height {:ignore-lock? true})) + (some? height) + (gsh/transform-shape (ctm/change-dimensions-modifiers shape :height height {:ignore-lock? true})) - (some? position-data) - (assoc :position-data position-data)) + (some? position-data) + (assoc :position-data position-data)) - delta-move - (gpt/subtract (gpt/point (:selrect new-shape)) - (gpt/point (:selrect shape))) + delta-move + (gpt/subtract (gpt/point (ctm/safe-size-rect new-shape)) + (gpt/point (ctm/safe-size-rect shape))) - new-shape - (update new-shape :position-data gsh/move-position-data delta-move)] + new-shape + (update new-shape :position-data gsh/move-position-data delta-move)] - new-shape)) + new-shape) + shape)) (defn commit-update-text-modifier [] diff --git a/frontend/src/app/main/ui/workspace/shapes/text.cljs b/frontend/src/app/main/ui/workspace/shapes/text.cljs index cdaeda400f..dfd2bcde83 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text.cljs @@ -27,7 +27,7 @@ text-modifier (mf/deref text-modifier-ref) - shape (if (some? shape) + shape (if (and (some? shape) (some? text-modifier)) (dwt/apply-text-modifier shape text-modifier) shape)] diff --git a/frontend/test/frontend_tests/data/workspace_texts_test.cljs b/frontend/test/frontend_tests/data/workspace_texts_test.cljs new file mode 100644 index 0000000000..b7b1786eac --- /dev/null +++ b/frontend/test/frontend_tests/data/workspace_texts_test.cljs @@ -0,0 +1,274 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns frontend-tests.data.workspace-texts-test + (:require + [app.common.geom.rect :as grc] + [app.common.types.shape :as cts] + [app.main.data.workspace.texts :as dwt] + [cljs.test :as t :include-macros true])) + +;; --------------------------------------------------------------------------- +;; Helpers +;; --------------------------------------------------------------------------- + +(defn- make-text-shape + "Build a fully initialised text shape at the given position." + [& {:keys [x y width height position-data] + :or {x 10 y 20 width 100 height 50}}] + (cond-> (cts/setup-shape {:type :text + :x x + :y y + :width width + :height height}) + (some? position-data) + (assoc :position-data position-data))) + +(defn- make-degenerate-text-shape + "Simulate a text shape decoded from the server via map->Rect (which bypasses + make-rect's 0.01 minimum enforcement), giving it a zero-width / zero-height + selrect. This is the exact condition that triggered the original crash: + change-dimensions-modifiers divided by sr-width (== 0), producing an Infinity + scale factor that propagated through the transform pipeline until + calculate-selrect / center->rect returned nil, and then gpt/point threw + 'invalid arguments (on pointer constructor)'." + [& {:keys [x y width height] + :or {x 10 y 20 width 0 height 0}}] + (-> (make-text-shape :x x :y y :width 100 :height 50) + ;; Bypass make-rect by constructing the Rect record directly, the same + ;; way decode-rect does during JSON deserialization from the backend. + (assoc :selrect (grc/map->Rect {:x x :y y + :width width :height height + :x1 x :y1 y + :x2 (+ x width) :y2 (+ y height)})))) + +(defn- sample-position-data + "Return a minimal position-data vector with the supplied coords." + [x y] + [{:x x :y y :width 80 :height 16 :fills [] :text "hello"}]) + +;; --------------------------------------------------------------------------- +;; Tests: nil / no-op guard +;; --------------------------------------------------------------------------- + +(t/deftest apply-text-modifier-nil-modifier-returns-shape-unchanged + (t/testing "nil text-modifier returns the original shape untouched" + (let [shape (make-text-shape) + result (dwt/apply-text-modifier shape nil)] + (t/is (= shape result))))) + +(t/deftest apply-text-modifier-empty-map-no-keys-returns-shape-unchanged + (t/testing "modifier with no recognised keys leaves shape unchanged" + (let [shape (make-text-shape) + modifier {} + result (dwt/apply-text-modifier shape modifier)] + (t/is (= (:selrect shape) (:selrect result))) + (t/is (= (:width result) (:width shape))) + (t/is (= (:height result) (:height shape)))))) + +;; --------------------------------------------------------------------------- +;; Tests: width modifier +;; --------------------------------------------------------------------------- + +(t/deftest apply-text-modifier-width-changes-shape-width + (t/testing "width modifier resizes the shape width" + (let [shape (make-text-shape :x 0 :y 0 :width 100 :height 50) + modifier {:width 200} + result (dwt/apply-text-modifier shape modifier)] + (t/is (= 200.0 (-> result :selrect :width)))))) + +(t/deftest apply-text-modifier-width-nil-skips-width-change + (t/testing "nil :width in modifier does not alter the width" + (let [shape (make-text-shape :x 0 :y 0 :width 100 :height 50) + modifier {:width nil} + result (dwt/apply-text-modifier shape modifier)] + (t/is (= (-> shape :selrect :width) (-> result :selrect :width)))))) + +;; --------------------------------------------------------------------------- +;; Tests: height modifier +;; --------------------------------------------------------------------------- + +(t/deftest apply-text-modifier-height-changes-shape-height + (t/testing "height modifier resizes the shape height" + (let [shape (make-text-shape :x 0 :y 0 :width 100 :height 50) + modifier {:height 120} + result (dwt/apply-text-modifier shape modifier)] + (t/is (= 120.0 (-> result :selrect :height)))))) + +(t/deftest apply-text-modifier-height-nil-skips-height-change + (t/testing "nil :height in modifier does not alter the height" + (let [shape (make-text-shape :x 0 :y 0 :width 100 :height 50) + modifier {:height nil} + result (dwt/apply-text-modifier shape modifier)] + (t/is (= (-> shape :selrect :height) (-> result :selrect :height)))))) + +;; --------------------------------------------------------------------------- +;; Tests: width + height together +;; --------------------------------------------------------------------------- + +(t/deftest apply-text-modifier-width-and-height-both-applied + (t/testing "both width and height are applied simultaneously" + (let [shape (make-text-shape :x 0 :y 0 :width 100 :height 50) + modifier {:width 300 :height 80} + result (dwt/apply-text-modifier shape modifier)] + (t/is (= 300.0 (-> result :selrect :width))) + (t/is (= 80.0 (-> result :selrect :height)))))) + +;; --------------------------------------------------------------------------- +;; Tests: position-data modifier +;; --------------------------------------------------------------------------- + +(t/deftest apply-text-modifier-position-data-is-set-on-shape + (t/testing "position-data modifier replaces the position-data on shape" + (let [pd (sample-position-data 5 10) + shape (make-text-shape :x 0 :y 0 :width 100 :height 50) + modifier {:position-data pd} + result (dwt/apply-text-modifier shape modifier)] + (t/is (some? (:position-data result)))))) + +(t/deftest apply-text-modifier-position-data-nil-leaves-position-data-unchanged + (t/testing "nil :position-data in modifier does not alter position-data" + (let [pd (sample-position-data 5 10) + shape (-> (make-text-shape :x 0 :y 0 :width 100 :height 50) + (assoc :position-data pd)) + modifier {:position-data nil} + result (dwt/apply-text-modifier shape modifier)] + (t/is (= pd (:position-data result)))))) + +;; --------------------------------------------------------------------------- +;; Tests: position-data is translated by delta when shape moves +;; --------------------------------------------------------------------------- + +(t/deftest apply-text-modifier-position-data-translated-on-resize + (t/testing "position-data x/y is adjusted by the delta of the selrect origin" + (let [pd (sample-position-data 10 20) + shape (-> (make-text-shape :x 0 :y 0 :width 100 :height 50) + (assoc :position-data pd)) + ;; Only set position-data; no resize so no origin shift expected + modifier {:position-data pd} + result (dwt/apply-text-modifier shape modifier)] + ;; Delta should be zero (no dimension change), so coords stay the same + (t/is (= 10.0 (-> result :position-data first :x))) + (t/is (= 20.0 (-> result :position-data first :y)))))) + +(t/deftest apply-text-modifier-position-data-not-translated-when-nil + (t/testing "nil position-data on result after modifier is left as nil" + (let [shape (make-text-shape :x 0 :y 0 :width 100 :height 50) + modifier {:width 200} + result (dwt/apply-text-modifier shape modifier)] + ;; shape had no position-data; modifier doesn't set one — stays nil + (t/is (nil? (:position-data result)))))) + +;; --------------------------------------------------------------------------- +;; Tests: degenerate selrect (zero width or height decoded from the server) +;; +;; Root cause of the original crash: +;; change-dimensions-modifiers divided by (:width selrect) or (:height selrect) +;; which is 0 when the shape was decoded via map->Rect (bypassing make-rect's +;; 0.01 minimum), producing Infinity → transform pipeline returned nil selrect +;; → gpt/point threw "invalid arguments (on pointer constructor)". +;; --------------------------------------------------------------------------- + +(t/deftest apply-text-modifier-zero-width-selrect-does-not-throw + (t/testing "width modifier on a shape with zero selrect width does not throw" + ;; Simulates a shape received from the server whose selrect has width=0 + ;; (map->Rect bypasses the 0.01 floor of make-rect). + (let [shape (make-degenerate-text-shape :x 0 :y 0 :width 0 :height 50) + modifier {:width 200} + result (dwt/apply-text-modifier shape modifier)] + (t/is (some? result)) + (t/is (some? (:selrect result)))))) + +(t/deftest apply-text-modifier-zero-height-selrect-does-not-throw + (t/testing "height modifier on a shape with zero selrect height does not throw" + (let [shape (make-degenerate-text-shape :x 0 :y 0 :width 100 :height 0) + modifier {:height 80} + result (dwt/apply-text-modifier shape modifier)] + (t/is (some? result)) + (t/is (some? (:selrect result)))))) + +(t/deftest apply-text-modifier-zero-width-and-height-selrect-does-not-throw + (t/testing "both modifiers on a fully-degenerate selrect do not throw" + (let [shape (make-degenerate-text-shape :x 0 :y 0 :width 0 :height 0) + modifier {:width 150 :height 60} + result (dwt/apply-text-modifier shape modifier)] + (t/is (some? result)) + (t/is (some? (:selrect result)))))) + +(t/deftest apply-text-modifier-zero-width-selrect-result-has-correct-width + (t/testing "applying width modifier to a zero-width shape yields the requested width" + (let [shape (make-degenerate-text-shape :x 0 :y 0 :width 0 :height 50) + modifier {:width 200} + result (dwt/apply-text-modifier shape modifier)] + (t/is (= 200.0 (-> result :selrect :width)))))) + +(t/deftest apply-text-modifier-zero-height-selrect-result-has-correct-height + (t/testing "applying height modifier to a zero-height shape yields the requested height" + (let [shape (make-degenerate-text-shape :x 0 :y 0 :width 100 :height 0) + modifier {:height 80} + result (dwt/apply-text-modifier shape modifier)] + (t/is (= 80.0 (-> result :selrect :height)))))) + +(t/deftest apply-text-modifier-nil-modifier-on-degenerate-shape-returns-unchanged + (t/testing "nil modifier on a zero-selrect shape returns the same shape" + (let [shape (make-degenerate-text-shape :x 0 :y 0 :width 0 :height 0) + result (dwt/apply-text-modifier shape nil)] + (t/is (identical? shape result))))) + +;; --------------------------------------------------------------------------- +;; Tests: shape origin is preserved when there is no dimension change +;; --------------------------------------------------------------------------- + +(t/deftest apply-text-modifier-selrect-origin-preserved-without-resize + (t/testing "selrect x/y origin does not shift when no dimension changes" + (let [shape (make-text-shape :x 30 :y 40 :width 100 :height 50) + modifier {:position-data (sample-position-data 30 40)} + result (dwt/apply-text-modifier shape modifier)] + (t/is (= (-> shape :selrect :x) (-> result :selrect :x))) + (t/is (= (-> shape :selrect :y) (-> result :selrect :y)))))) + +;; --------------------------------------------------------------------------- +;; Tests: returned shape is a proper map-like value +;; --------------------------------------------------------------------------- + +(t/deftest apply-text-modifier-returns-shape-with-required-keys + (t/testing "result always contains the core shape keys" + (let [shape (make-text-shape :x 0 :y 0 :width 100 :height 50) + modifier {:width 200 :height 80} + result (dwt/apply-text-modifier shape modifier)] + (t/is (some? (:id result))) + (t/is (some? (:type result))) + (t/is (some? (:selrect result)))))) + +(t/deftest apply-text-modifier-nil-modifier-returns-same-identity + (t/testing "nil modifier returns the exact same shape object (identity)" + (let [shape (make-text-shape)] + (t/is (identical? shape (dwt/apply-text-modifier shape nil)))))) + +;; --------------------------------------------------------------------------- +;; Tests: delta-move computation does not throw on degenerate selrect +;; +;; The delta-move in apply-text-modifier calls gpt/point on both the +;; original and new shape selrects. gpt/point throws when given a +;; non-point-like value (nil, or a map with non-finite :x/:y). Using +;; ctm/safe-size-rect instead of raw (:selrect …) access ensures a valid +;; rect is always available for that computation. +;; --------------------------------------------------------------------------- + +(t/deftest apply-text-modifier-position-data-with-degenerate-selrect-does-not-throw + (t/testing "position-data modifier on a zero-selrect shape does not throw" + (let [pd (sample-position-data 5 10) + shape (make-degenerate-text-shape :x 0 :y 0 :width 0 :height 0) + result (dwt/apply-text-modifier shape {:position-data pd})] + (t/is (some? result)) + (t/is (= pd (:position-data result))))) + + (t/testing "width + position-data modifier on a zero-selrect shape does not throw" + (let [pd (sample-position-data 5 10) + shape (make-degenerate-text-shape :x 0 :y 0 :width 0 :height 0) + result (dwt/apply-text-modifier shape {:width 200 :position-data pd})] + (t/is (some? result)) + (t/is (some? (:selrect result)))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index ad84056110..93fcd3897a 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -3,6 +3,7 @@ [cljs.test :as t] [frontend-tests.basic-shapes-test] [frontend-tests.data.workspace-colors-test] + [frontend-tests.data.workspace-texts-test] [frontend-tests.helpers-shapes-test] [frontend-tests.logic.comp-remove-swap-slots-test] [frontend-tests.logic.components-and-tokens] @@ -36,6 +37,7 @@ (t/run-tests 'frontend-tests.basic-shapes-test 'frontend-tests.data.workspace-colors-test + 'frontend-tests.data.workspace-texts-test 'frontend-tests.helpers-shapes-test 'frontend-tests.logic.comp-remove-swap-slots-test 'frontend-tests.logic.components-and-tokens