mirror of
https://github.com/penpot/penpot.git
synced 2026-04-25 11:18:36 +00:00
* 🎉 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>
417 lines
18 KiB
Clojure
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))))))
|