penpot/common/test/common_tests/geom_flex_layout_test.cljc
Eva Marco fcbb8f1fc0
🐛 Keep derived layout paddings non-negative (#12129)
Adding a flex or grid layout to a board with children derives the
paddings from the distance between the board and its children. A child
that overflows the board, or floating point noise after "Resize board
to fit content", gave negative paddings. The shape schema rejects them
since paddings must be non-negative, so update-file failed and autosave
stopped. Clamp the derived paddings to 0, as the derived gaps already
are.

Closes #12107

AI-assisted-by: claude-opus-5-5
2026-10-06 16:34:28 +02:00

138 lines
6.2 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 SUBSIDIARY SL
(ns common-tests.geom-flex-layout-test
(:require
[app.common.geom.rect :as grc]
[app.common.geom.shapes.flex-layout.params :as flparams]
[app.common.geom.shapes.flex-layout.positions :as flp]
[app.common.math :as mth]
[app.common.types.shape :as cts]
[app.common.types.shape.layout :as ctl]
[clojure.test :as t]))
;; ---- helpers ----
(defn- make-col-frame
"Minimal col? flex frame with wrap enabled.
wrap is required for the content-around? predicate to activate."
[& {:as opts}]
(cts/setup-shape (merge {:type :frame
:layout :flex
:layout-flex-dir :column
:layout-wrap-type :wrap
:x 0 :y 0 :width 200 :height 200}
opts)))
(defn- rect->bounds
"Convert a rect to the 4-point layout-bounds vector expected by gpo/*."
[rect]
(grc/rect->points rect))
;; ---- get-base-line (around? branch) ----
;;
;; Bug: in positions.cljc the col? + around? branch had a mis-parenthesised
;; expression `(/ free-width num-lines) 2`, which was parsed as three
;; arguments to `max`:
;; (max lines-gap-col (/ free-width num-lines) 2)
;; instead of the intended two-argument max with a nested division:
;; (max lines-gap-col (/ free-width num-lines 2))
;;
;; For a col? layout the cross-axis is horizontal (hv), so the around? offset
;; is applied as hv(delta) — i.e. the delta ends up in (:x base-p).
(t/deftest get-base-line-around-uses-half-per-line-free-width
(t/testing "col? + content-around? offset is free-width / num-lines / 2"
;; Layout: col? wrap, width=200, 3 lines each 20px wide → free-width=140
;; lines-gap-col = 0 (no gap defined)
;; Expected horizontal offset = max(0, 140/3/2) ≈ 23.33
;; Before the bug fix the formula was (max ... (/ 140 3) 2) ≈ 46.67.
(let [frame (make-col-frame :layout-align-content :space-around)
bounds (rect->bounds (grc/make-rect 0 0 200 200))
;; 3 lines of 20px each (widths); no row gap
num-lines 3
total-width 60
total-height 0
base-p (flp/get-base-line frame bounds total-width total-height num-lines)
free-width (- 200 total-width)
;; lines-gap-col = (dec 3) * 0 = 0; max(0, free-width/num-lines/2)
expected-x (/ free-width num-lines 2)]
;; The base point x-coordinate (hv offset) should equal half per-line free space.
(t/is (mth/close? expected-x (:x base-p) 0.01))))
(t/testing "col? + content-around? offset respects lines-gap-col minimum"
;; When the accumulated column gap exceeds the computed half-per-line value
;; max(lines-gap-col, free-width/num-lines/2) returns the gap.
(let [frame (make-col-frame :layout-align-content :space-around
:layout-gap {:column-gap 50 :row-gap 0})
bounds (rect->bounds (grc/make-rect 0 0 200 200))
;; 4 lines × 20px = 80px used; free-width=120; half-per-line = 120/4/2 = 15
;; lines-gap-col = (dec 4)*50 = 150 → max(150, 15) = 150
num-lines 4
total-width 80
total-height 0
base-p (flp/get-base-line frame bounds total-width total-height num-lines)
lines-gap-col (* (dec num-lines) 50)]
(t/is (mth/close? lines-gap-col (:x base-p) 0.01)))))
;; ---- v-end? guard (drop-line-area) ----
;;
;; Bug: `v-end?` inside `drop-line-area` was guarded by `row?` instead of
;; `col?`, so vertical-end alignment in a column layout was never triggered.
;; We verify the predicate behaviour directly via ctl/v-end?.
(t/deftest v-end-guard-uses-col-not-row
(t/testing "v-end? is true for col? frame with justify-content :end"
;; col? + justify-content=:end → ctl/v-end? must be true
(let [frame (cts/setup-shape {:type :frame
:layout :flex
:layout-flex-dir :column
:layout-justify-content :end
:x 0 :y 0 :width 100 :height 100})]
(t/is (true? (ctl/v-end? frame)))))
(t/testing "v-end? is false for row? frame with only justify-content :end"
;; row? + justify-content=:end alone does NOT set v-end?; for row layouts
;; v-end? checks align-items, not justify-content.
(let [frame (cts/setup-shape {:type :frame
:layout :flex
:layout-flex-dir :row
:layout-justify-content :end
:x 0 :y 0 :width 100 :height 100})]
(t/is (not (ctl/v-end? frame))))))
;; ---- calculate-params paddings ----
;;
;; Adding a flex layout derives the paddings from the distance between the
;; board and its children. A child that overflows the board, or floating
;; point noise after "Resize board to fit content", gave negative paddings
;; that the shape schema rejects, which broke autosave (#12107).
(defn- params-for-child
[child-attrs]
(let [parent (cts/setup-shape {:type :frame :x 0 :y 0 :width 100 :height 100})
child (cts/setup-shape (merge {:type :rect :parent-id (:id parent)} child-attrs))
parent (assoc parent :shapes [(:id child)])
objects {(:id parent) parent (:id child) child}]
(flparams/calculate-params objects [child] parent)))
(t/deftest calculate-params-keeps-positive-padding
(t/is (= {:p1 20 :p2 10 :p3 20 :p4 10}
(:layout-padding (params-for-child {:x 10 :y 20 :width 50 :height 50})))))
(t/deftest calculate-params-clamps-overflowing-child-padding
(t/is (= {:p1 0 :p2 0 :p3 0 :p4 0}
(:layout-padding (params-for-child {:x -10 :y -5 :width 150 :height 150})))))
(t/deftest calculate-params-clamps-float-noise-padding
(let [padding (:layout-padding
(params-for-child {:x -1.1728368917829357E-5
:y -2.1457672119140625E-6
:width 100 :height 100}))]
(t/is (every? #(not (neg? %)) (vals padding)))))