penpot/common/test/common_tests/geom_grid_layout_test.cljc
Andrey Antukh fa89790fd6 🐛 Fix grid layout case dispatch, divide-by-zero, and add set-auto-multi-span tests
Three critical fixes for app.common.geom.shapes.grid-layout.layout-data:

1. case dispatch on runtime booleans in get-cell-data (case→cond fix)
   In get-cell-data, column-gap and row-gap were computed with (case ...)
   using boolean locals auto-width? and auto-height? as dispatch values.
   In Clojure/ClojureScript, case compares against compile-time constants,
   so those branches never matched at runtime. Replaced both case forms
   with cond, using explicit equality tests for keyword branches.

2. divide-by-zero guards in fr/auto/span calc (JVM ArithmeticException fix)
   Guard against JVM ArithmeticException when all grid tracks are fixed
   (no flex or auto tracks):
   - (get allocated %1) → (get allocated %1 0) in set-auto-multi-span
   - (get allocate-fr-tracks %1) → (get allocate-fr-tracks %1 0) in set-flex-multi-span
   - (/ fr-column/row-space column/row-frs) guarded with (zero?) check
   - (/ auto-column/row-space column/row-autos) guarded with (zero?) check
   In JS, integer division by zero produces Infinity (caught by mth/finite),
   but on the JVM it throws before mth/finite can intercept.

3. Exhaustive tests for set-auto-multi-span behavior
   Cover all code paths and edge cases:
   - span=1 cells filtered out (unchanged track-list)
   - empty shape-cells no-op
   - even split across multiple auto tracks
   - gap deduction per extra span step
   - fixed track reducing budget; only auto tracks grow
   - smaller children not shrinking existing track sizes (max semantics)
   - flex tracks causing cell exclusion (handled by set-flex-multi-span)
   - non-spanned tracks preserved via (get allocated %1 0) default
   - :row type symmetry with :column type
   - row-gap correctly deducted in :row mode
   - documents that (sort-by span -) yields ascending order (smaller spans
     first), correcting the misleading code comment

All tests pass on both JS (Node.js) and JVM environments.

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-04-15 23:37:53 +02:00

411 lines
20 KiB
Clojure
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

;; 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-grid-layout-test
(:require
;; Requiring modifiers triggers the side-effect that wires
;; -child-min-width / -child-min-height into grid layout-data.
[app.common.geom.modifiers]
[app.common.geom.rect :as grc]
[app.common.geom.shapes.grid-layout.layout-data :as gld]
[app.common.math :as mth]
[app.common.types.shape :as cts]
[clojure.test :as t]))
;; ---------------------------------------------------------------------------
;; Shared test-data builders
;; ---------------------------------------------------------------------------
(defn- make-grid-frame
"Minimal grid-layout frame with two fixed columns of 50.0 px
and one fixed row. Width and height are explicit, no padding.
Track values are floats to avoid JVM integer-divide-by-zero when
there are no flex tracks (column-frs = 0)."
[& {:as opts}]
(cts/setup-shape
(merge {:type :frame
:layout :grid
:layout-grid-dir :row
:layout-grid-columns [{:type :fixed :value 50.0}
{:type :fixed :value 50.0}]
:layout-grid-rows [{:type :fixed :value 100.0}]
:layout-grid-cells {}
:layout-padding-type :multiple
:layout-padding {:p1 0 :p2 0 :p3 0 :p4 0}
:layout-gap {:column-gap 0 :row-gap 0}
:x 0 :y 0 :width 200 :height 100}
opts)))
(defn- bounds-for
"Return the 4-point layout-bounds for the frame."
[frame]
(grc/rect->points (grc/make-rect (:x frame) (:y frame) (:width frame) (:height frame))))
;; Build a simple non-fill child shape with explicit width/height.
;; No layout-item-margin → child-width-margin = 0.
(defn- make-child
[w h]
(cts/setup-shape {:type :rect :width w :height h :x 0 :y 0}))
;; Build the 4-point bounds vector for a child with the given dimensions.
(defn- child-bounds
[w h]
(grc/rect->points (grc/make-rect 0 0 w h)))
;; Build an auto track at its initial size (0.01) with infinite max.
(defn- auto-track [] {:type :auto :size 0.01 :max-size ##Inf})
;; Build a fixed track with the given size.
(defn- fixed-track [v]
{:type :fixed :value v :size (double v) :max-size (double v)})
;; Build a flex track (value = number of fr units) at initial size 0.01.
(defn- flex-track [fr]
{:type :flex :value fr :size 0.01 :max-size ##Inf})
;; Build a parent frame for column testing with given column-gap.
(defn- auto-col-parent
([] (auto-col-parent 0))
([column-gap]
(cts/setup-shape
{:type :frame
:layout :grid
:layout-grid-dir :row
:layout-padding-type :multiple
:layout-padding {:p1 0 :p2 0 :p3 0 :p4 0}
:layout-gap {:column-gap column-gap :row-gap 0}
:x 0 :y 0 :width 500 :height 500})))
;; Build a parent frame for row type testing with given row-gap.
(defn- auto-row-parent
([] (auto-row-parent 0))
([row-gap]
(cts/setup-shape
{:type :frame
:layout :grid
:layout-grid-dir :row
:layout-padding-type :multiple
:layout-padding {:p1 0 :p2 0 :p3 0 :p4 0}
:layout-gap {:column-gap 0 :row-gap row-gap}
:x 0 :y 0 :width 500 :height 500})))
;; Generic frame-bounds (large enough not to interfere).
(def ^:private frame-bounds
(grc/rect->points (grc/make-rect 0 0 500 500)))
;; Build a cell map for a single shape occupying column/row at given span.
;; col and row are 1-based.
(defn- make-cell
[shape-id col row col-span row-span]
{:shapes [shape-id]
:column col :column-span col-span
:row row :row-span row-span})
;; ---------------------------------------------------------------------------
;; Note on set-auto-multi-span indexing
;; ---------------------------------------------------------------------------
;;
;; Inside set-auto-multi-span, indexed-tracks is computed as:
;; from-idx = clamp(col - 1, 0, count-1)
;; to-idx = clamp((col - 1) + col-span, 0, count-1)
;; indexed-tracks = subvec(enumerate(tracks), from-idx, to-idx)
;;
;; Because to-idx is clamped to (dec count), the LAST track of the span is
;; always excluded unless there is at least one extra track beyond the span.
;;
;; Practical implication for tests: to cover N spanned tracks, provide a
;; track-list with at least N+1 tracks (the extra track acts as a sentinel
;; that absorbs the off-by-one from the clamp).
;;
;; Example: col=1, span=2, 3 total tracks:
;; to-idx = clamp(0+2, 0, 2) = 2 → subvec(v, 0, 2) = [track0, track1] ✓
;;
;; Tests that deliberately check boundary behavior (flex exclusion,
;; non-spanned tracks) use 2 total tracks so only track 0 is covered.
;; ---------------------------------------------------------------------------
;; Tests: column-gap with justify-content (case → cond fix)
;; ---------------------------------------------------------------------------
;;
;; In get-cell-data, column-gap and row-gap were computed with (case ...)
;; using boolean locals as dispatch values. case compares compile-time
;; constants, so those branches never matched at runtime. Fixed with cond.
(t/deftest grid-column-gap-space-evenly
(t/testing "justify-content :space-evenly increases column-gap correctly"
;; 2 fixed cols × 50 px = 100 px occupied; bound-width = 200; free = 100
;; formula: free / (num-cols + 1) = 100/3 ≈ 33.33
(let [frame (make-grid-frame :layout-justify-content :space-evenly
:layout-gap {:column-gap 0 :row-gap 0})
bounds (bounds-for frame)
result (gld/calc-layout-data frame bounds [] {} {})
col-gap (:column-gap result)]
(t/is (mth/close? (/ 100.0 3.0) col-gap 0.01)))))
(t/deftest grid-column-gap-space-around
(t/testing "justify-content :space-around increases column-gap correctly"
;; free = 100; formula: 100 / num-cols = 100/2 = 50
(let [frame (make-grid-frame :layout-justify-content :space-around
:layout-gap {:column-gap 0 :row-gap 0})
bounds (bounds-for frame)
result (gld/calc-layout-data frame bounds [] {} {})
col-gap (:column-gap result)]
(t/is (mth/close? 50.0 col-gap 0.01)))))
(t/deftest grid-column-gap-space-between
(t/testing "justify-content :space-between increases column-gap correctly"
;; free = 100; num-cols = 2; formula: 100 / (2-1) = 100
(let [frame (make-grid-frame :layout-justify-content :space-between
:layout-gap {:column-gap 0 :row-gap 0})
bounds (bounds-for frame)
result (gld/calc-layout-data frame bounds [] {} {})
col-gap (:column-gap result)]
(t/is (mth/close? 100.0 col-gap 0.01)))))
(t/deftest grid-column-gap-auto-width-bypasses-justify-content
(t/testing "auto-width? bypasses justify-content gap recalc → gap stays as initial"
(let [frame (make-grid-frame :layout-justify-content :space-evenly
:layout-gap {:column-gap 5 :row-gap 0}
:layout-item-h-sizing :auto)
bounds (bounds-for frame)
result (gld/calc-layout-data frame bounds [] {} {})
col-gap (:column-gap result)]
(t/is (mth/close? 5.0 col-gap 0.01)))))
;; ---------------------------------------------------------------------------
;; Tests: set-auto-multi-span
;; ---------------------------------------------------------------------------
;;
;; set-auto-multi-span grows auto tracks to accommodate children whose cell
;; spans more than one track column (or row), but only for spans that contain
;; no flex tracks (those are handled by set-flex-multi-span).
;;
;; The function signature:
;; (set-auto-multi-span parent track-list children-map shape-cells
;; bounds objects type)
;; type :column or :row
;; children-map {shape-id [child-bounds child-shape]}
;; shape-cells {cell-id cell-map}
(t/deftest set-auto-multi-span-span-1-cells-ignored
(t/testing "span=1 cells are filtered out; track-list is unchanged"
(let [sid (random-uuid)
child (make-child 200 100)
;; 2 tracks + 1 sentinel (so the span would cover tracks 0-1 if span were 2)
tracks [(auto-track) (auto-track) (auto-track)]
cells {:c1 (make-cell sid 1 1 1 1)} ; span = 1 → ignored
cmap {sid [(child-bounds 200 100) child]}
result (gld/set-auto-multi-span (auto-col-parent) tracks cmap cells frame-bounds {} :column)]
(t/is (mth/close? 0.01 (:size (nth result 0)) 0.001))
(t/is (mth/close? 0.01 (:size (nth result 1)) 0.001))
(t/is (mth/close? 0.01 (:size (nth result 2)) 0.001)))))
(t/deftest set-auto-multi-span-empty-cells
(t/testing "empty shape-cells → track-list unchanged"
(let [tracks [(auto-track) (auto-track)]
result (gld/set-auto-multi-span (auto-col-parent) tracks {} {} frame-bounds {} :column)]
(t/is (mth/close? 0.01 (:size (nth result 0)) 0.001))
(t/is (mth/close? 0.01 (:size (nth result 1)) 0.001)))))
(t/deftest set-auto-multi-span-two-auto-tracks-split-evenly
(t/testing "child spanning 2 auto tracks (with sentinel): budget split between the 2 covered tracks"
;; 3 tracks total (sentinel at index 2 keeps to-idx from being clamped).
;; col=1, span=2:
;; from-idx = clamp(0, 0, 2) = 0
;; to-idx = clamp(2, 0, 2) = 2
;; subvec(enumerate, 0, 2) = [[0, auto0], [1, auto1]]
;; size-to-allocate = 200 (child width, no gap)
;; allocate-auto-tracks pass 1 (non-assigned = both):
;; idx0: max(0.01, 200/2, 0.01) = 100; rem = 100
;; idx1: max(0.01, 100/1, 0.01) = 100; rem = 0
;; pass 2 (to-allocate=0): no change → both 100
;; sentinel track 2 is never spanned → stays at 0.01.
(let [sid (random-uuid)
child (make-child 200 100)
tracks [(auto-track) (auto-track) (auto-track)] ; sentinel at [2]
cells {:c1 (make-cell sid 1 1 2 1)}
cmap {sid [(child-bounds 200 100) child]}
result (gld/set-auto-multi-span (auto-col-parent) tracks cmap cells frame-bounds {} :column)]
(t/is (mth/close? 100.0 (:size (nth result 0)) 0.001))
(t/is (mth/close? 100.0 (:size (nth result 1)) 0.001))
;; sentinel unaffected
(t/is (mth/close? 0.01 (:size (nth result 2)) 0.001)))))
(t/deftest set-auto-multi-span-gap-deducted-from-budget
(t/testing "column-gap is subtracted once per extra span track from size-to-allocate"
;; child width = 210, column-gap = 10, span = 2
;; size-to-allocate = child-min-width - gap*(span-1) = 210 - 10*1 = 200
;; 3 tracks (sentinel at [2]) → indexed = [[0,auto],[1,auto]]
;; each auto track gets 100
(let [sid (random-uuid)
child (make-child 210 100)
tracks [(auto-track) (auto-track) (auto-track)]
cells {:c1 (make-cell sid 1 1 2 1)}
cmap {sid [(child-bounds 210 100) child]}
result (gld/set-auto-multi-span (auto-col-parent 10) tracks cmap cells frame-bounds {} :column)]
(t/is (mth/close? 100.0 (:size (nth result 0)) 0.001))
(t/is (mth/close? 100.0 (:size (nth result 1)) 0.001))
(t/is (mth/close? 0.01 (:size (nth result 2)) 0.001)))))
(t/deftest set-auto-multi-span-fixed-track-reduces-budget
(t/testing "fixed track in span is deducted from budget; only the auto track grows"
;; tracks: [fixed 60, auto 0.01, auto-sentinel] (sentinel at [2])
;; col=1, span=2 → indexed = [[0, fixed60], [1, auto]]
;; find-auto-allocations: fixed→subtract 60; auto→keep
;; to-allocate after fixed = 200 - 60 = 140; indexed-auto = [[1, auto]]
;; pass 1: idx1: max(0.01, 140/1, 0.01) = 140
;; apply: track0 = max(60, 0) = 60; track1 = max(0.01, 140) = 140
(let [sid (random-uuid)
child (make-child 200 100)
tracks [(fixed-track 60) (auto-track) (auto-track)]
cells {:c1 (make-cell sid 1 1 2 1)}
cmap {sid [(child-bounds 200 100) child]}
result (gld/set-auto-multi-span (auto-col-parent) tracks cmap cells frame-bounds {} :column)]
(t/is (mth/close? 60.0 (:size (nth result 0)) 0.001))
(t/is (mth/close? 140.0 (:size (nth result 1)) 0.001))
(t/is (mth/close? 0.01 (:size (nth result 2)) 0.001)))))
(t/deftest set-auto-multi-span-child-smaller-than-existing-tracks
(t/testing "when child is smaller than the existing track sizes, tracks are not shrunk"
;; tracks: [auto 80, auto 80, auto-sentinel]
;; child width = 50; size-to-allocate = 50
;; indexed = [[0, auto80], [1, auto80]]
;; pass 1 (non-assigned, to-alloc=50):
;; idx0: max(0.01, 50/2, 80) = 80; rem = 50-80 = -30
;; idx1: max(0.01, max(-30,0)/1, 80) = 80
;; pass 2 (to-alloc=max(-30,0)=0): same max, no change
;; both tracks stay at 80
(let [sid (random-uuid)
child (make-child 50 100)
tracks [{:type :auto :size 80.0 :max-size ##Inf}
{:type :auto :size 80.0 :max-size ##Inf}
(auto-track)]
cells {:c1 (make-cell sid 1 1 2 1)}
cmap {sid [(child-bounds 50 100) child]}
result (gld/set-auto-multi-span (auto-col-parent) tracks cmap cells frame-bounds {} :column)]
(t/is (mth/close? 80.0 (:size (nth result 0)) 0.001))
(t/is (mth/close? 80.0 (:size (nth result 1)) 0.001)))))
(t/deftest set-auto-multi-span-flex-track-in-span-excluded
(t/testing "cells whose span contains a flex track are skipped (handled by set-flex-multi-span)"
;; tracks: [flex 1fr, auto] col=1, span=2 → has-flex-track? = true → cell excluded
;; 2 tracks total (no sentinel needed since the cell is excluded before indexing)
(let [sid (random-uuid)
child (make-child 300 100)
tracks [(flex-track 1) (auto-track)]
cells {:c1 (make-cell sid 1 1 2 1)}
cmap {sid [(child-bounds 300 100) child]}
result (gld/set-auto-multi-span (auto-col-parent) tracks cmap cells frame-bounds {} :column)]
(t/is (mth/close? 0.01 (:size (nth result 0)) 0.001))
(t/is (mth/close? 0.01 (:size (nth result 1)) 0.001)))))
(t/deftest set-auto-multi-span-non-spanned-track-unaffected
(t/testing "tracks outside the span keep their size tests (get allocated %1 0) default"
;; 4 tracks; child at col=2 span=2 → indexed covers tracks 1 and 2 (sentinel [3]).
;; Track 0 (before the span) and track 3 (sentinel) are never allocated.
;; from-idx = clamp(2-1, 0, 3) = 1
;; to-idx = clamp((2-1)+2, 0, 3) = 3
;; subvec(enumerate, 1, 3) = [[1,auto],[2,auto]]
;; size-to-allocate = 200 → both indexed tracks get 100
;; apply: track0 = max(0.01, get({},0,0)) = max(0.01,0) = 0.01 ← uses default 0
;; track1 = max(0.01, 100) = 100
;; track2 = max(0.01, 100) = 100
;; track3 = max(0.01, get({},3,0)) = 0.01 (sentinel)
(let [sid (random-uuid)
child (make-child 200 100)
tracks [(auto-track) (auto-track) (auto-track) (auto-track)]
cells {:c1 (make-cell sid 2 1 2 1)}
cmap {sid [(child-bounds 200 100) child]}
result (gld/set-auto-multi-span (auto-col-parent) tracks cmap cells frame-bounds {} :column)]
;; track before span: size stays at 0.01 (default 0 from missing allocation entry)
(t/is (mth/close? 0.01 (:size (nth result 0)) 0.001))
;; spanned tracks grow
(t/is (mth/close? 100.0 (:size (nth result 1)) 0.001))
(t/is (mth/close? 100.0 (:size (nth result 2)) 0.001))
;; sentinel after span also unaffected
(t/is (mth/close? 0.01 (:size (nth result 3)) 0.001)))))
(t/deftest set-auto-multi-span-row-type
(t/testing ":row type uses :row/:row-span and grows row tracks by child height"
;; child height = 200, row-gap = 0, row=1 span=2, 3 row tracks (sentinel at [2])
;; from-idx=0, to-idx=clamp(2,0,2)=2 → [[0,auto],[1,auto]]
;; size-to-allocate = 200 → each row track gets 100
(let [sid (random-uuid)
child (make-child 100 200)
tracks [(auto-track) (auto-track) (auto-track)]
cells {:c1 (make-cell sid 1 1 1 2)}
cmap {sid [(child-bounds 100 200) child]}
result (gld/set-auto-multi-span (auto-row-parent) tracks cmap cells frame-bounds {} :row)]
(t/is (mth/close? 100.0 (:size (nth result 0)) 0.001))
(t/is (mth/close? 100.0 (:size (nth result 1)) 0.001))
(t/is (mth/close? 0.01 (:size (nth result 2)) 0.001)))))
(t/deftest set-auto-multi-span-row-gap-deducted
(t/testing "row-gap is deducted from budget for :row type"
;; child height = 210, row-gap = 10, row-span = 2
;; size-to-allocate = 210 - 10*1 = 200 → each track gets 100
(let [sid (random-uuid)
child (make-child 100 210)
tracks [(auto-track) (auto-track) (auto-track)]
cells {:c1 (make-cell sid 1 1 1 2)}
cmap {sid [(child-bounds 100 210) child]}
result (gld/set-auto-multi-span (auto-row-parent 10) tracks cmap cells frame-bounds {} :row)]
(t/is (mth/close? 100.0 (:size (nth result 0)) 0.001))
(t/is (mth/close? 100.0 (:size (nth result 1)) 0.001))
(t/is (mth/close? 0.01 (:size (nth result 2)) 0.001)))))
(t/deftest set-auto-multi-span-smaller-span-processed-first
(t/testing "cells are sorted by span ascending (sort-by span -): smaller span allocates first"
;; NOTE: (sort-by prop-span -) uses `-` as a comparator; this yields ascending
;; order (smaller span first), not descending as the code comment implies.
;;
;; 4 tracks (sentinel at [3]):
;; cell-B: col=1 span=2 (covers indexed [0,1]) processed first (span=2)
;; cell-A: col=1 span=3 (covers indexed [0,1,2]) processed second (span=3)
;;
;; cell-B: child=100px, to-allocate=100.
;; non-assigned=[0,1]; pass1: idx0→max(0.01,50,0.01)=50; idx1→max(0.01,50,0.01)=50
;; allocated = {0:50, 1:50}
;;
;; cell-A: child=300px, to-allocate=300.
;; indexed=[0,1,2]; non-assigned=[2] (tracks 0,1 already allocated)
;; pass1 (non-assigned only): idx2→max(0.01,300/1,0.01)=300 ; rem=0
;; pass2 (to-alloc=0): max preserves existing values → no change
;; allocated = {0:50, 1:50, 2:300}
;;
;; Final: track0=50, track1=50, track2=300, track3(sentinel)=0.01
(let [sid-a (random-uuid)
sid-b (random-uuid)
child-a (make-child 300 100)
child-b (make-child 100 100)
tracks [(auto-track) (auto-track) (auto-track) (auto-track)] ; sentinel at [3]
cells {:ca (make-cell sid-a 1 1 3 1)
:cb (make-cell sid-b 1 1 2 1)}
cmap {sid-a [(child-bounds 300 100) child-a]
sid-b [(child-bounds 100 100) child-b]}
result (gld/set-auto-multi-span (auto-col-parent) tracks cmap cells frame-bounds {} :column)]
(t/is (mth/close? 50.0 (:size (nth result 0)) 0.001))
(t/is (mth/close? 50.0 (:size (nth result 1)) 0.001))
(t/is (mth/close? 300.0 (:size (nth result 2)) 0.001))
(t/is (mth/close? 0.01 (:size (nth result 3)) 0.001)))))
(t/deftest set-auto-multi-span-all-fixed-tracks-in-span
(t/testing "when all spanned tracks are fixed, no auto allocation occurs; fixed tracks unchanged"
;; tracks: [fixed 100, fixed 100, auto-sentinel]
;; col=1, span=2 → indexed = [[0,fixed100],[1,fixed100]]
;; find-auto-allocations: both fixed → auto-indexed-tracks = []
;; allocate-auto-tracks on empty list → no entries in allocated map
;; apply: track0 = max(100, get({},0,0)) = max(100,0) = 100 (unchanged)
;; track1 = max(100, get({},1,0)) = max(100,0) = 100 (unchanged)
(let [sid (random-uuid)
child (make-child 50 100)
tracks [(fixed-track 100) (fixed-track 100) (auto-track)]
cells {:c1 (make-cell sid 1 1 2 1)}
cmap {sid [(child-bounds 50 100) child]}
result (gld/set-auto-multi-span (auto-col-parent) tracks cmap cells frame-bounds {} :column)]
(t/is (mth/close? 100.0 (:size (nth result 0)) 0.001))
(t/is (mth/close? 100.0 (:size (nth result 1)) 0.001)))))