Files
penpot/frontend/test/frontend_tests/data/workspace_texts_test.cljs
Andrey Antukh b6524881e0 🐛 Fix crash in apply-text-modifier with nil selrect or modifier (#8762)
* 🐛 Fix crash in apply-text-modifier with nil selrect or modifier

Guard apply-text-modifier against nil text-modifier and nil selrect
to prevent the 'invalid arguments (on pointer constructor)' error
thrown by gpt/point when called with an invalid map.

- In text-wrapper: only call apply-text-modifier when text-modifier is
  not nil (avoids unnecessary processing)
- In apply-text-modifier: handle nil text-modifier by returning shape
  unchanged; guard selrect access before calling gpt/point

* 📚 Add tests for apply-text-modifier in workspace texts

Add exhaustive unit tests covering all paths of apply-text-modifier:
- nil modifier returns shape unchanged (identity)
- modifier with no recognised keys leaves shape unchanged
- :width / :height modifiers resize shape correctly
- nil :width / :height keys are skipped
- both dimensions applied simultaneously
- :position-data is set and nil-guarded
- position-data coordinates translated by delta on resize
- shape with nil selrect + nil modifier does not throw
- position-data-only modifier on shape without selrect is safe
- selrect origin preserved when no dimension changes
- result always carries required shape keys

* 🐛 Fix zero-dimension selrect crash in change-dimensions-modifiers

When a text shape is decoded from the server via map->Rect (which
bypasses make-rect's 0.01 minimum enforcement), its selrect can have
width or height of exactly 0.  change-dimensions-modifiers and
change-size were dividing by these values, producing Infinity scale
factors that propagated through the transform pipeline until
calculate-selrect / center->rect returned nil, causing gpt/point to
throw 'invalid arguments (on pointer constructor)'.

Fix: before computing scale factors, guard sr-width / sr-height (and
old-width / old-height in change-size) against zero/negative and
non-finite values.  When degenerate, fall back to the shape's own
top-level :width/:height so the denominator and proportion-lock base
remain consistent.

Also simplify apply-text-modifier's delta calculation now that the
transform pipeline is guaranteed to produce a valid selrect, and
update the test suite to test the exact degenerate-selrect scenario
that triggered the original crash.

Signed-off-by: Andrey Antukh <niwi@niwi.nz>

* ♻️ Simplify change-dimensions-modifiers internal logic

- Remove the intermediate 'size' map ({:width sr-width :height sr-height})
  that was built only to be assoc'd and immediately destructured back into
  width/height; compute both values directly instead.

- Replace the double-negated condition 'if-not (and (not ignore-lock?) …)'
  with a clear positive 'locked?' binding, and flatten the three-branch
  if-not/if tree into two independent if expressions keyed on 'attr'.

- Call safe-size-rect once and reuse its result for both the fallback
  sizes and the scale computation, eliminating a redundant call.

- Access :transform and :transform-inverse via direct map lookup rather
  than destructuring in the function signature, consistent with how the
  rest of the let-block reads shape keys.

- Clean up change-size to use the same destructuring style as the updated
  function ({sr-width :width sr-height :height}).

- Fix typo in comment: 'havig' -> 'having'.

*  Add tests for change-size and change-dimensions-modifiers

Cover the main behavioural contract of both functions:

change-size:
- Scales both axes to the requested target dimensions.
- Sets the resize origin to the shape's top-left point.
- Nil width/height each fall back to the current dimension (scale 1 on
  that axis); both nil produces an identity resize that is optimised away.
- Propagates the shape's transform and transform-inverse matrices into the
  resulting GeometricOperation.

change-dimensions-modifiers:
- Changing :width without proportion-lock only scales the x-axis (y
  scale stays 1), and vice-versa for :height.
- With proportion-lock enabled, changing :width adjusts height via the
  inverse proportion, and changing :height adjusts width via the
  proportion.
- ignore-lock? true bypasses proportion-lock regardless of shape state.
- Values below 0.01 are clamped to 0.01 before computing the scale.
- End-to-end: applying the returned modifiers via gsh/transform-shape
  yields the expected selrect dimensions.

*  Harden safe-size-rect with additional fallbacks

The previous implementation could still return an invalid rect in several
edge cases.  The new version tries four sources in order, accepting each
only if it passes a dedicated safe-size-rect? predicate:

1. :selrect           – used when width and height are finite, positive
                        and within [-max-safe-int, max-safe-int].
2. points->rect       – computed from the shape corner points; subject to
                        the same predicate.
3. Top-level shape fields (:x :y :width :height) – present on all rect,
                        frame, image, and component shape types.
4. grc/empty-rect     – a 0,0 0.01×0.01 unit rect used as last resort so
                        callers always receive a usable, non-crashing value.

The out-of-range check (> max-safe-int) is new: it rejects coordinates
that pass d/num? (finite) but exceed the platform integer boundary defined
in app.common.schema, which previously slipped through undetected.

Tests cover all four fallback paths, including the NaN, zero-dimension,
and max-safe-int overflow cases.

*  Optimise safe-size-rect for ClojureScript performance

- Replace (when (some? rect) ...) with (and ^boolean (some? rect) ...)
  to keep the entire predicate as a single boolean expression without
  introducing an implicit conditional branch.

- Replace keyword access (:width rect) / (:height rect) with
  dm/get-prop calls, consistent with the hot-path style used throughout
  the rest of the namespace.

- Add ^boolean type hints to every sub-expression of the and chain in
  safe-size-rect? (d/num?, pos?, <=) so the ClojureScript compiler emits
  raw JS boolean operations instead of boxing the results through
  cljs.core/truth_.

- Replace (when (safe-size-rect? ...) value) in safe-size-rect with
  (and ^boolean (safe-size-rect? ...) value), avoiding an extra
  conditional and keeping the or fallback chain free of allocated
  intermediate objects.

*  Use safe-size-rect in apply-text-modifier delta-move computation

safe-size-rect was already used inside change-dimensions-modifiers to
guard the resize scale computation. However, apply-text-modifier in
texts.cljs was still reading (:selrect shape) and (:selrect new-shape)
directly to build the delta-move vector via gpt/point.

gpt/point raises "invalid arguments (on pointer constructor)" when
given a nil value or a map with non-finite :x/:y, which can happen when
a shape's selrect is missing or degenerate (e.g. decoded from the server
via map->Rect, bypassing make-rect's 0.01 floor).

Changes:
- Promote safe-size-rect from defn- to defn in app.common.types.modifiers
  so it can be reused by consumers outside the namespace.
- Replace the two raw (:selrect …) accesses in the delta-move computation
  with (ctm/safe-size-rect …), which always returns a valid, finite rect
  through the established four-step fallback chain.
- Add two frontend tests covering the delta-move path with a fully
  degenerate (zero-dimension) selrect, ensuring neither a bare
  position-data modifier nor a combined width+position-data modifier
  throws.

* ♻️ Ensure all test shapes are proper Shape records in modifiers-test

All shapes in safe-size-rect-fallbacks tests now start from a proper
Shape record built by cts/setup-shape (via make-shape) instead of plain
hash-maps. Each test that mutates geometry fields (selrect, points,
width, height) does so via assoc on the already-initialised record,
which preserves the correct type while isolating the field under test.

A (cts/shape? shape) assertion is added to each fallback test to make
the type guarantee explicit and guard against regressions.

The unused shape-with-selrect helper (which built a bare map) is
removed.

* 🔥 Remove dead code and tighten visibility in app.common.types.modifiers

Dead functions removed (zero callers across the entire codebase):
- modifiers->transform-old: superseded by modifiers->transform; only
  ever appeared in a commented-out dev/bench.cljs entry.
- change-recursive-property: no callers anywhere.
- move-parent-modifiers, resize-parent-modifiers: convenience wrappers
  for the parent-geometry builder functions; never called.
- remove-children-modifiers, add-children-modifiers,
  scale-content-modifiers: single-op convenience builders; never called.
- select-structure: projection helper; only referenced by
  select-child-geometry-modifiers which is itself dead.
- select-child-geometry-modifiers: no callers anywhere.

Functions narrowed from defn to defn- (used only within this namespace):
- valid-vector?: assertion helper called only by move/resize builders.
- increase-order: called only by add-modifiers.
- transform-move!, transform-resize!, transform-rotate!, transform!:
  steps of the modifiers->transform pipeline.
- modifiers->transform1: immediate helper for modifiers->transform; the
  doc-string describing it as 'multiplatform' was also removed since it
  is an implementation detail.
- transform-text-node, transform-paragraph-node: leaf helpers for
  scale-text-content.
- update-text-content, scale-text-content, apply-scale-content: internal
  scale-content pipeline; all called only by apply-modifier.
- remove-children-set: called only by apply-modifier.
- select-structure: demoted to defn- rather than deleted because it is
  still called by select-child-structre-modifiers, which has external
  callers.

*  Add more tests for modifiers

---------

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-03-30 11:04:54 +02:00

275 lines
14 KiB
Clojure

;; 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))))))