penpot/common/test/common_tests/geom_bounds_map_test.cljc
Andrey Antukh 2ca7acfca6
Add tests for app.common.geom and descendant namespaces (#8768)
* 🎉 Add tests for app.common.geom.bounds-map

* 🎉 Add tests for app.common.geom and descendant namespaces

* 📎 Fix linting issues

---------

Co-authored-by: Luis de Dios <luis.dedios@kaleidos.net>
2026-04-02 09:50:34 +02:00

417 lines
18 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 common-tests.geom-bounds-map-test
(:require
[app.common.geom.bounds-map :as gbm]
[app.common.geom.point :as gpt]
[app.common.geom.shapes.points :as gpo]
[app.common.math :as mth]
[app.common.types.modifiers :as ctm]
[app.common.types.shape :as cts]
[app.common.uuid :as uuid]
[clojure.test :as t]))
;; ---- Helpers ----
(defn- make-rect
"Create a minimal rect shape with given id and position/size."
[id x y w h]
(-> (cts/setup-shape {:id id
:type :rect
:name (str "rect-" id)
:x x
:y y
:width w
:height h})
(assoc :parent-id uuid/zero
:frame-id uuid/zero)))
(defn- make-group
"Create a minimal group shape with given id and children ids."
[id child-ids]
(let [x 0 y 0 w 100 h 100]
(-> (cts/setup-shape {:id id
:type :group
:name (str "group-" id)
:x x
:y y
:width w
:height h})
(assoc :parent-id uuid/zero
:frame-id uuid/zero
:shapes (vec child-ids)))))
(defn- make-masked-group
"Create a masked group shape with given id and children ids."
[id child-ids]
(let [x 0 y 0 w 100 h 100]
(-> (cts/setup-shape {:id id
:type :group
:name (str "masked-group-" id)
:x x
:y y
:width w
:height h})
(assoc :parent-id uuid/zero
:frame-id uuid/zero
:masked-group true
:shapes (vec child-ids)))))
(defn- make-objects
"Build an objects map from shapes. Sets parent-id on children."
[shapes]
(let [shape-map (into {} (map (fn [s] [(:id s) s]) shapes))]
;; Set parent-id on children based on their container's :shapes list
(reduce-kv (fn [m _id shape]
(if (contains? shape :shapes)
(reduce (fn [m' child-id]
(assoc-in m' [child-id :parent-id] (:id shape)))
m
(:shapes shape))
m))
shape-map
shape-map)))
;; ---- Tests for objects->bounds-map ----
(t/deftest objects->bounds-map-empty-test
(t/testing "Empty objects returns empty map"
(let [result (gbm/objects->bounds-map {})]
(t/is (map? result))
(t/is (empty? result)))))
(t/deftest objects->bounds-map-single-rect-test
(t/testing "Single rect produces bounds entry"
(let [id (uuid/next)
shape (make-rect id 10 20 30 40)
objects {id shape}
bm (gbm/objects->bounds-map objects)]
(t/is (contains? bm id))
(t/is (delay? (get bm id)))
(let [bounds @(get bm id)]
(t/is (vector? bounds))
(t/is (= 4 (count bounds)))
;; Verify bounds match the rect's geometry
(t/is (mth/close? 10.0 (:x (gpo/origin bounds))))
(t/is (mth/close? 20.0 (:y (gpo/origin bounds))))
(t/is (mth/close? 30.0 (gpo/width-points bounds)))
(t/is (mth/close? 40.0 (gpo/height-points bounds)))))))
(t/deftest objects->bounds-map-multiple-rects-test
(t/testing "Multiple rects each produce correct bounds"
(let [id1 (uuid/next)
id2 (uuid/next)
id3 (uuid/next)
objects {id1 (make-rect id1 0 0 100 50)
id2 (make-rect id2 50 25 200 75)
id3 (make-rect id3 10 10 1 1)}
bm (gbm/objects->bounds-map objects)]
(t/is (= 3 (count bm)))
(doseq [id [id1 id2 id3]]
(t/is (contains? bm id))
(t/is (delay? (get bm id))))
;; Check each shape's bounds
(let [b1 @(get bm id1)]
(t/is (mth/close? 0.0 (:x (gpo/origin b1))))
(t/is (mth/close? 0.0 (:y (gpo/origin b1))))
(t/is (mth/close? 100.0 (gpo/width-points b1)))
(t/is (mth/close? 50.0 (gpo/height-points b1))))
(let [b2 @(get bm id2)]
(t/is (mth/close? 50.0 (:x (gpo/origin b2))))
(t/is (mth/close? 25.0 (:y (gpo/origin b2))))
(t/is (mth/close? 200.0 (gpo/width-points b2)))
(t/is (mth/close? 75.0 (gpo/height-points b2))))
(let [b3 @(get bm id3)]
(t/is (mth/close? 10.0 (:x (gpo/origin b3))))
(t/is (mth/close? 10.0 (:y (gpo/origin b3))))))))
(t/deftest objects->bounds-map-laziness-test
(t/testing "Bounds are computed lazily (delay semantics)"
(let [id1 (uuid/next)
id2 (uuid/next)
objects {id1 (make-rect id1 0 0 10 10)
id2 (make-rect id2 5 5 20 20)}
bm (gbm/objects->bounds-map objects)]
;; Delays should not be realized until deref'd
(t/is (not (realized? (get bm id1))))
(t/is (not (realized? (get bm id2))))
;; After deref, they should be realized
@(get bm id1)
(t/is (realized? (get bm id1)))
(t/is (not (realized? (get bm id2))))
@(get bm id2)
(t/is (realized? (get bm id2))))))
;; ---- Tests for transform-bounds-map ----
(t/deftest transform-bounds-map-empty-modif-tree-test
(t/testing "Empty modif-tree returns equivalent bounds-map"
(let [id1 (uuid/next)
objects {id1 (make-rect id1 10 20 30 40)}
bm (gbm/objects->bounds-map objects)
result (gbm/transform-bounds-map bm objects {})]
;; No modifiers means no IDs to resolve, so bounds-map should be returned as-is
(t/is (= bm result)))))
(t/deftest transform-bounds-map-move-rect-test
(t/testing "Moving a rect updates its bounds"
(let [id1 (uuid/next)
objects {id1 (make-rect id1 10 20 30 40)}
bm (gbm/objects->bounds-map objects)
modif-tree {id1 {:modifiers (ctm/move-modifiers (gpt/point 100 200))}}
result (gbm/transform-bounds-map bm objects modif-tree)]
(t/is (contains? result id1))
(let [old-bounds @(get bm id1)
new-bounds @(get result id1)]
;; Original bounds should be unchanged
(t/is (mth/close? 10.0 (:x (gpo/origin old-bounds))))
(t/is (mth/close? 20.0 (:y (gpo/origin old-bounds))))
;; New bounds should be translated
(t/is (mth/close? 110.0 (:x (gpo/origin new-bounds))))
(t/is (mth/close? 220.0 (:y (gpo/origin new-bounds))))))))
(t/deftest transform-bounds-map-move-in-group-test
(t/testing "Moving a child rect also updates its parent group bounds"
(let [child-id (uuid/next)
group-id (uuid/next)
child (make-rect child-id 10 10 20 20)
group (make-group group-id [child-id])
objects (make-objects [child group])
bm (gbm/objects->bounds-map objects)
;; Move child by (50, 50)
modif-tree {child-id {:modifiers (ctm/move-modifiers (gpt/point 50 50))}}
result (gbm/transform-bounds-map bm objects modif-tree)]
;; Both child and group should have new bounds
(t/is (contains? result child-id))
(t/is (contains? result group-id))
(let [new-child-bounds @(get result child-id)]
(t/is (mth/close? 60.0 (:x (gpo/origin new-child-bounds))))
(t/is (mth/close? 60.0 (:y (gpo/origin new-child-bounds)))))
(let [new-group-bounds @(get result group-id)]
;; Group bounds should encompass the moved child
(t/is (some? new-group-bounds))))))
(t/deftest transform-bounds-map-masked-group-test
(t/testing "Masked group only uses first child for bounds"
(let [child1-id (uuid/next)
child2-id (uuid/next)
group-id (uuid/next)
child1 (make-rect child1-id 0 0 10 10)
child2 (make-rect child2-id 100 100 50 50)
group (make-masked-group group-id [child1-id child2-id])
objects (make-objects [child1 child2 group])
bm (gbm/objects->bounds-map objects)
result (gbm/transform-bounds-map bm objects {})]
;; Even with empty modif-tree, the group should be resolved
;; Masked group behavior: only first child contributes
(t/is (some? result)))))
(t/deftest transform-bounds-map-multiple-modifiers-test
(t/testing "Multiple shapes modified at once"
(let [id1 (uuid/next)
id2 (uuid/next)
objects {id1 (make-rect id1 0 0 100 100)
id2 (make-rect id2 200 200 50 50)}
bm (gbm/objects->bounds-map objects)
modif-tree {id1 {:modifiers (ctm/move-modifiers (gpt/point 10 10))}
id2 {:modifiers (ctm/move-modifiers (gpt/point -5 -5))}}
result (gbm/transform-bounds-map bm objects modif-tree)]
(let [b1 @(get result id1)]
(t/is (mth/close? 10.0 (:x (gpo/origin b1))))
(t/is (mth/close? 10.0 (:y (gpo/origin b1)))))
(let [b2 @(get result id2)]
(t/is (mth/close? 195.0 (:x (gpo/origin b2))))
(t/is (mth/close? 195.0 (:y (gpo/origin b2))))))))
(t/deftest transform-bounds-map-uuid-zero-ignored-test
(t/testing "uuid/zero in modif-tree is skipped when creating new bounds entries"
(let [id1 (uuid/next)
objects {id1 (make-rect id1 10 20 30 40)
uuid/zero {:id uuid/zero :type :frame :parent-id uuid/zero}}
bm (gbm/objects->bounds-map objects)
;; uuid/zero in modif-tree triggers resolve but its entry is preserved from original
modif-tree {id1 {:modifiers (ctm/move-modifiers (gpt/point 0 0))}
uuid/zero {:modifiers (ctm/move-modifiers (gpt/point 0 0))}}
result (gbm/transform-bounds-map bm objects modif-tree)]
;; uuid/zero may still be in result if it was in the original bounds-map
;; The function does not add NEW uuid/zero entries, but preserves existing ones
(when (contains? bm uuid/zero)
(t/is (contains? result uuid/zero))))))
(t/deftest transform-bounds-map-explicit-ids-test
(t/testing "Passing explicit ids limits which shapes are recomputed"
(let [id1 (uuid/next)
id2 (uuid/next)
objects {id1 (make-rect id1 0 0 100 100)
id2 (make-rect id2 200 200 50 50)}
bm (gbm/objects->bounds-map objects)
modif-tree {id1 {:modifiers (ctm/move-modifiers (gpt/point 10 10))}
id2 {:modifiers (ctm/move-modifiers (gpt/point 20 20))}}
;; Only recompute id1
result (gbm/transform-bounds-map bm objects modif-tree #{id1})]
;; id1 should be updated
(let [b1 @(get result id1)]
(t/is (mth/close? 10.0 (:x (gpo/origin b1)))))
;; id2 should be preserved from original bounds-map
(let [b2-original @(get bm id2)
b2-result @(get result id2)]
(t/is (= b2-original b2-result))))))
(t/deftest transform-bounds-map-nested-groups-test
(t/testing "Nested groups propagate bounds updates upward"
(let [child-id (uuid/next)
inner-grp (uuid/next)
outer-grp (uuid/next)
child (make-rect child-id 0 0 20 20)
inner (make-group inner-grp [child-id])
outer (make-group outer-grp [inner-grp])
objects (make-objects [child inner outer])
bm (gbm/objects->bounds-map objects)
modif-tree {child-id {:modifiers (ctm/move-modifiers (gpt/point 100 100))}}
result (gbm/transform-bounds-map bm objects modif-tree)]
;; All three should be in the result
(t/is (contains? result child-id))
(t/is (contains? result inner-grp))
(t/is (contains? result outer-grp))
(let [child-bounds @(get result child-id)]
(t/is (mth/close? 100.0 (:x (gpo/origin child-bounds))))
(t/is (mth/close? 100.0 (:y (gpo/origin child-bounds))))))))
;; ---- Tests for bounds-map (debug function) ----
(t/deftest bounds-map-debug-empty-test
(t/testing "Debug bounds-map with empty inputs returns empty map"
(let [result (gbm/bounds-map {} {})]
(t/is (map? result))
(t/is (empty? result)))))
(t/deftest bounds-map-debug-single-shape-test
(t/testing "Debug bounds-map returns readable entries for shapes"
(let [id (uuid/next)
shape (make-rect id 10 20 30 40)
objects {id shape}
bm (gbm/objects->bounds-map objects)
result (gbm/bounds-map objects bm)
expected-name (str "rect-" id)]
(t/is (contains? result expected-name))
(let [entry (get result expected-name)]
(t/is (map? entry))
(t/is (contains? entry :x))
(t/is (contains? entry :y))
(t/is (contains? entry :width))
(t/is (contains? entry :height))
(t/is (mth/close? 10.0 (:x entry)))
(t/is (mth/close? 20.0 (:y entry)))
(t/is (mth/close? 30.0 (:width entry)))
(t/is (mth/close? 40.0 (:height entry)))))))
(t/deftest bounds-map-debug-missing-shape-test
(t/testing "Debug bounds-map skips entries where shape is nil"
(let [fake-id (uuid/next)
real-id (uuid/next)
objects {real-id (make-rect real-id 10 20 30 40)}
real-bm (gbm/objects->bounds-map objects)
;; Bounds map has an entry for fake-id (delay with valid points)
;; but no shape in objects for fake-id
bm {fake-id (delay [(gpt/point 0 0)
(gpt/point 10 0)
(gpt/point 10 10)
(gpt/point 0 10)])
real-id (get real-bm real-id)}
result (gbm/bounds-map objects bm)]
;; fake-id has no shape in objects, so it should be excluded
(t/is (not (contains? result (str fake-id))))
;; real-id has a shape, so it should be present
(t/is (contains? result (str "rect-" real-id))))))
(t/deftest bounds-map-debug-multiple-shapes-test
(t/testing "Debug bounds-map with multiple shapes"
(let [id1 (uuid/next)
id2 (uuid/next)
objects {id1 (make-rect id1 0 0 50 50)
id2 (make-rect id2 100 100 25 25)}
bm (gbm/objects->bounds-map objects)
result (gbm/bounds-map objects bm)]
(t/is (>= (count result) 2)))))
(t/deftest bounds-map-debug-rounds-values-test
(t/testing "Debug bounds-map rounds x/y/width/height to 2 decimal places"
(let [id (uuid/next)
objects {id (make-rect id 10.123456 20.987654 30.5555 40.4444)}
bm (gbm/objects->bounds-map objects)
result (gbm/bounds-map objects bm)
entry (get result (str "rect-" id))]
(when (some? entry)
(t/is (number? (:x entry)))
(t/is (number? (:y entry)))
(t/is (number? (:width entry)))
(t/is (number? (:height entry)))))))
;; ---- Edge cases ----
(t/deftest objects->bounds-map-shape-with-identity-transform-test
(t/testing "Shape with identity transform uses selrect-based points"
(let [id (uuid/next)
shape (make-rect id 5 15 25 35)
objects {id shape}
bm (gbm/objects->bounds-map objects)]
(t/is (contains? bm id))
(let [bounds @(get bm id)]
(t/is (= 4 (count bounds)))
;; All points should have valid coordinates
(doseq [p bounds]
(t/is (number? (:x p)))
(t/is (number? (:y p))))))))
(t/deftest transform-bounds-map-unchanged-unmodified-shapes-test
(t/testing "Unmodified shapes keep their original bounds reference"
(let [id1 (uuid/next)
id2 (uuid/next)
objects {id1 (make-rect id1 0 0 100 100)
id2 (make-rect id2 200 200 50 50)}
bm (gbm/objects->bounds-map objects)
;; Only modify id1
modif-tree {id1 {:modifiers (ctm/move-modifiers (gpt/point 10 10))}}
result (gbm/transform-bounds-map bm objects modif-tree)
old-b1 @(get bm id1)
new-b1 @(get result id1)]
;; id1 should have different bounds
(t/is (not (mth/close? (:x (gpo/origin old-b1))
(:x (gpo/origin new-b1))))))))
(t/deftest transform-bounds-map-deep-nesting-test
(t/testing "3-level nesting of groups with a leaf modification"
(let [leaf-id (uuid/next)
grp1-id (uuid/next)
grp2-id (uuid/next)
grp3-id (uuid/next)
leaf (make-rect leaf-id 0 0 10 10)
grp1 (make-group grp1-id [leaf-id])
grp2 (make-group grp2-id [grp1-id])
grp3 (make-group grp3-id [grp2-id])
objects (make-objects [leaf grp1 grp2 grp3])
bm (gbm/objects->bounds-map objects)
modif-tree {leaf-id {:modifiers (ctm/move-modifiers (gpt/point 5 5))}}
result (gbm/transform-bounds-map bm objects modif-tree)]
;; All group levels should be recomputed
(t/is (contains? result leaf-id))
(t/is (contains? result grp1-id))
(t/is (contains? result grp2-id))
(t/is (contains? result grp3-id)))))
(t/deftest objects->bounds-map-zero-sized-rect-test
(t/testing "Zero-sized rect produces valid bounds (clamped to 0.01)"
(let [id (uuid/next)
shape (make-rect id 10 20 0 0)
objects {id shape}
bm (gbm/objects->bounds-map objects)]
(t/is (contains? bm id))
(let [bounds @(get bm id)]
;; Width and height should be clamped to at least 0.01
(t/is (>= (gpo/width-points bounds) 0.01))
(t/is (>= (gpo/height-points bounds) 0.01))))))