Add comprehensive tests for path and descendant namespaces (#8755)

Add tests for app.common.types.path.subpath, helpers, segment,
bool operations (union/difference/intersection/exclude), top-level
path API, and shape-to-path conversion. Covers previously untested
functions across all path sub-namespaces. Tests pass on both JVM
and JS (ClojureScript/Node) platforms.
This commit is contained in:
Andrey Antukh
2026-03-24 19:53:22 +01:00
committed by GitHub
parent 3ef100427b
commit cc73a768d5

View File

@@ -10,6 +10,7 @@
[app.common.data :as d]
[app.common.geom.matrix :as gmt]
[app.common.geom.point :as gpt]
[app.common.geom.rect :as grc]
[app.common.math :as mth]
[app.common.pprint :as pp]
[app.common.transit :as trans]
@@ -18,6 +19,7 @@
[app.common.types.path.helpers :as path.helpers]
[app.common.types.path.impl :as path.impl]
[app.common.types.path.segment :as path.segment]
[app.common.types.path.subpath :as path.subpath]
[clojure.test :as t]))
(def sample-content
@@ -537,3 +539,693 @@
(t/deftest calculate-bool-content
(let [result (path.bool/calculate-content :union contents-for-bool)]
(t/is (= result bool-result))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SUBPATH TESTS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(t/deftest subpath-pt=
(t/testing "pt= returns true for nearby points"
(t/is (path.subpath/pt= (gpt/point 0 0) (gpt/point 0.05 0.05))))
(t/testing "pt= returns false for distant points"
(t/is (not (path.subpath/pt= (gpt/point 0 0) (gpt/point 1 0))))))
(t/deftest subpath-make-subpath
(t/testing "make-subpath from a single move-to command"
(let [cmd {:command :move-to :params {:x 5.0 :y 10.0}}
sp (path.subpath/make-subpath cmd)]
(t/is (= (gpt/point 5.0 10.0) (:from sp)))
(t/is (= (gpt/point 5.0 10.0) (:to sp)))
(t/is (= [cmd] (:data sp)))))
(t/testing "make-subpath from explicit from/to/data"
(let [from (gpt/point 0 0)
to (gpt/point 10 10)
data [{:command :move-to :params {:x 0 :y 0}}
{:command :line-to :params {:x 10 :y 10}}]
sp (path.subpath/make-subpath from to data)]
(t/is (= from (:from sp)))
(t/is (= to (:to sp)))
(t/is (= data (:data sp))))))
(t/deftest subpath-add-subpath-command
(t/testing "adding a line-to command extends the subpath"
(let [cmd0 {:command :move-to :params {:x 0.0 :y 0.0}}
cmd1 {:command :line-to :params {:x 5.0 :y 5.0}}
sp (-> (path.subpath/make-subpath cmd0)
(path.subpath/add-subpath-command cmd1))]
(t/is (= (gpt/point 5.0 5.0) (:to sp)))
(t/is (= 2 (count (:data sp))))))
(t/testing "adding a close-path is replaced by a line-to at from"
(let [cmd0 {:command :move-to :params {:x 1.0 :y 2.0}}
sp (path.subpath/make-subpath cmd0)
sp2 (path.subpath/add-subpath-command sp {:command :close-path :params {}})]
;; The close-path gets replaced by a line-to back to :from
(t/is (= (gpt/point 1.0 2.0) (:to sp2))))))
(t/deftest subpath-reverse-command
(let [prev {:command :move-to :params {:x 0.0 :y 0.0}}
cmd {:command :line-to :params {:x 5.0 :y 3.0}}
rev (path.subpath/reverse-command cmd prev)]
(t/is (= :line-to (:command rev)))
(t/is (= 0.0 (get-in rev [:params :x])))
(t/is (= 0.0 (get-in rev [:params :y])))))
(t/deftest subpath-reverse-command-curve
(let [prev {:command :move-to :params {:x 0.0 :y 0.0}}
cmd {:command :curve-to
:params {:x 10.0 :y 0.0 :c1x 3.0 :c1y 5.0 :c2x 7.0 :c2y 5.0}}
rev (path.subpath/reverse-command cmd prev)]
;; end-point should be previous point coords
(t/is (= 0.0 (get-in rev [:params :x])))
(t/is (= 0.0 (get-in rev [:params :y])))
;; handlers are swapped
(t/is (= 7.0 (get-in rev [:params :c1x])))
(t/is (= 5.0 (get-in rev [:params :c1y])))
(t/is (= 3.0 (get-in rev [:params :c2x])))
(t/is (= 5.0 (get-in rev [:params :c2y])))))
(def ^:private simple-open-content
[{:command :move-to :params {:x 0.0 :y 0.0}}
{:command :line-to :params {:x 10.0 :y 0.0}}
{:command :line-to :params {:x 10.0 :y 10.0}}])
(def ^:private simple-closed-content
[{:command :move-to :params {:x 0.0 :y 0.0}}
{:command :line-to :params {:x 10.0 :y 0.0}}
{:command :line-to :params {:x 10.0 :y 10.0}}
{:command :line-to :params {:x 0.0 :y 0.0}}])
(t/deftest subpath-get-subpaths
(t/testing "open path produces one subpath"
(let [sps (path.subpath/get-subpaths simple-open-content)]
(t/is (= 1 (count sps)))
(t/is (= (gpt/point 0.0 0.0) (get-in sps [0 :from])))))
(t/testing "content with two move-to produces two subpaths"
(let [content [{:command :move-to :params {:x 0.0 :y 0.0}}
{:command :line-to :params {:x 5.0 :y 0.0}}
{:command :move-to :params {:x 10.0 :y 0.0}}
{:command :line-to :params {:x 15.0 :y 0.0}}]
sps (path.subpath/get-subpaths content)]
(t/is (= 2 (count sps))))))
(t/deftest subpath-is-closed?
(t/testing "subpath with same from/to is closed"
(let [sp (path.subpath/make-subpath (gpt/point 0 0) (gpt/point 0 0) [])]
(t/is (path.subpath/is-closed? sp))))
(t/testing "subpath with different from/to is not closed"
(let [sp (path.subpath/make-subpath (gpt/point 0 0) (gpt/point 10 10) [])]
(t/is (not (path.subpath/is-closed? sp))))))
(t/deftest subpath-reverse-subpath
(let [content [{:command :move-to :params {:x 0.0 :y 0.0}}
{:command :line-to :params {:x 10.0 :y 0.0}}
{:command :line-to :params {:x 10.0 :y 10.0}}]
sps (path.subpath/get-subpaths content)
sp (first sps)
rev (path.subpath/reverse-subpath sp)]
(t/is (= (:to sp) (:from rev)))
(t/is (= (:from sp) (:to rev)))
;; reversed data starts with a move-to at old :to
(t/is (= :move-to (get-in rev [:data 0 :command])))))
(t/deftest subpath-subpaths-join
(let [sp1 (path.subpath/make-subpath (gpt/point 0 0) (gpt/point 5 0)
[{:command :move-to :params {:x 0 :y 0}}
{:command :line-to :params {:x 5 :y 0}}])
sp2 (path.subpath/make-subpath (gpt/point 5 0) (gpt/point 10 0)
[{:command :move-to :params {:x 5 :y 0}}
{:command :line-to :params {:x 10 :y 0}}])
joined (path.subpath/subpaths-join sp1 sp2)]
(t/is (= (gpt/point 0 0) (:from joined)))
(t/is (= (gpt/point 10 0) (:to joined)))
;; data has move-to from sp1 + line-to from sp1 + line-to from sp2 (rest of sp2)
(t/is (= 3 (count (:data joined))))))
(t/deftest subpath-close-subpaths
(t/testing "content that is already a closed triangle stays closed"
(let [result (path.subpath/close-subpaths simple-closed-content)]
(t/is (seq result))))
(t/testing "two open fragments that form a closed loop get merged"
;; fragment A: 0,0 → 5,0
;; fragment B: 10,0 → 5,0 (reversed, connects to A's end)
;; After close-subpaths the result should have segments
(let [content [{:command :move-to :params {:x 0.0 :y 0.0}}
{:command :line-to :params {:x 5.0 :y 0.0}}
{:command :move-to :params {:x 10.0 :y 0.0}}
{:command :line-to :params {:x 5.0 :y 0.0}}]
result (path.subpath/close-subpaths content)]
(t/is (seq result)))))
(t/deftest subpath-reverse-content
(let [result (path.subpath/reverse-content simple-open-content)]
(t/is (= (count simple-open-content) (count result)))
;; First command of reversed content is a move-to at old end
(t/is (= :move-to (:command (first result))))
(t/is (mth/close? 10.0 (get-in (first result) [:params :x])))
(t/is (mth/close? 10.0 (get-in (first result) [:params :y])))))
(t/deftest subpath-clockwise?
(t/testing "square drawn clockwise is detected as clockwise"
;; A square drawn clockwise: top-left → top-right → bottom-right → bottom-left
(let [cw-content [{:command :move-to :params {:x 0.0 :y 0.0}}
{:command :line-to :params {:x 10.0 :y 0.0}}
{:command :line-to :params {:x 10.0 :y 10.0}}
{:command :line-to :params {:x 0.0 :y 10.0}}
{:command :line-to :params {:x 0.0 :y 0.0}}]]
(t/is (path.subpath/clockwise? cw-content))))
(t/testing "counter-clockwise square is not clockwise"
(let [ccw-content [{:command :move-to :params {:x 0.0 :y 0.0}}
{:command :line-to :params {:x 0.0 :y 10.0}}
{:command :line-to :params {:x 10.0 :y 10.0}}
{:command :line-to :params {:x 10.0 :y 0.0}}
{:command :line-to :params {:x 0.0 :y 0.0}}]]
(t/is (not (path.subpath/clockwise? ccw-content))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS TESTS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(t/deftest helpers-s=
(t/is (path.helpers/s= 0.0 0.0))
(t/is (path.helpers/s= 1.0 1.0000000001))
(t/is (not (path.helpers/s= 0.0 1.0))))
(t/deftest helpers-make-move-to
(let [pt (gpt/point 3.0 7.0)
cmd (path.helpers/make-move-to pt)]
(t/is (= :move-to (:command cmd)))
(t/is (= 3.0 (get-in cmd [:params :x])))
(t/is (= 7.0 (get-in cmd [:params :y])))))
(t/deftest helpers-make-line-to
(let [pt (gpt/point 4.0 8.0)
cmd (path.helpers/make-line-to pt)]
(t/is (= :line-to (:command cmd)))
(t/is (= 4.0 (get-in cmd [:params :x])))
(t/is (= 8.0 (get-in cmd [:params :y])))))
(t/deftest helpers-make-curve-params
(t/testing "single point form — point and handlers coincide"
(let [p (gpt/point 5.0 5.0)
params (path.helpers/make-curve-params p)]
(t/is (mth/close? 5.0 (:x params)))
(t/is (mth/close? 5.0 (:c1x params)))
(t/is (mth/close? 5.0 (:c2x params)))))
(t/testing "two-arg form — handler specified"
(let [p (gpt/point 10.0 0.0)
h (gpt/point 5.0 5.0)
params (path.helpers/make-curve-params p h)]
(t/is (mth/close? 10.0 (:x params)))
(t/is (mth/close? 5.0 (:c1x params)))
;; c2 defaults to point
(t/is (mth/close? 10.0 (:c2x params))))))
(t/deftest helpers-make-curve-to
(let [to (gpt/point 10.0 0.0)
h1 (gpt/point 3.0 5.0)
h2 (gpt/point 7.0 5.0)
cmd (path.helpers/make-curve-to to h1 h2)]
(t/is (= :curve-to (:command cmd)))
(t/is (= 10.0 (get-in cmd [:params :x])))
(t/is (= 3.0 (get-in cmd [:params :c1x])))
(t/is (= 7.0 (get-in cmd [:params :c2x])))))
(t/deftest helpers-update-curve-to
(let [base {:command :line-to :params {:x 10.0 :y 0.0}}
h1 (gpt/point 3.0 5.0)
h2 (gpt/point 7.0 5.0)
cmd (path.helpers/update-curve-to base h1 h2)]
(t/is (= :curve-to (:command cmd)))
(t/is (= 3.0 (get-in cmd [:params :c1x])))
(t/is (= 7.0 (get-in cmd [:params :c2x])))))
(t/deftest helpers-prefix->coords
(t/is (= [:c1x :c1y] (path.helpers/prefix->coords :c1)))
(t/is (= [:c2x :c2y] (path.helpers/prefix->coords :c2)))
(t/is (nil? (path.helpers/prefix->coords nil))))
(t/deftest helpers-position-fixed-angle
(t/testing "returns point unchanged when from-point is nil"
(let [pt (gpt/point 5.0 3.0)]
(t/is (= pt (path.helpers/position-fixed-angle pt nil)))))
(t/testing "snaps to nearest 45-degree angle"
(let [from (gpt/point 0 0)
;; Angle ~30° from from, should snap to 45°
to (gpt/point 10 6)
snapped (path.helpers/position-fixed-angle to from)]
;; result should have same distance
(let [d-orig (gpt/distance to from)
d-snapped (gpt/distance snapped from)]
(t/is (mth/close? d-orig d-snapped 0.01))))))
(t/deftest helpers-command->line
(let [prev {:command :move-to :params {:x 0.0 :y 0.0}}
cmd {:command :line-to :params {:x 5.0 :y 3.0} :prev (gpt/point 0 0)}
[from to] (path.helpers/command->line cmd (path.helpers/segment->point prev))]
(t/is (= (gpt/point 0.0 0.0) from))
(t/is (= (gpt/point 5.0 3.0) to))))
(t/deftest helpers-command->bezier
(let [prev {:command :move-to :params {:x 0.0 :y 0.0}}
cmd {:command :curve-to
:params {:x 10.0 :y 0.0 :c1x 3.0 :c1y 5.0 :c2x 7.0 :c2y 5.0}}
[from to h1 h2] (path.helpers/command->bezier cmd (path.helpers/segment->point prev))]
(t/is (= (gpt/point 0.0 0.0) from))
(t/is (= (gpt/point 10.0 0.0) to))
(t/is (= (gpt/point 3.0 5.0) h1))
(t/is (= (gpt/point 7.0 5.0) h2))))
(t/deftest helpers-line-values
(let [from (gpt/point 0.0 0.0)
to (gpt/point 10.0 0.0)
mid (path.helpers/line-values [from to] 0.5)]
(t/is (mth/close? 5.0 (:x mid)))
(t/is (mth/close? 0.0 (:y mid)))))
(t/deftest helpers-curve-split
(let [start (gpt/point 0.0 0.0)
end (gpt/point 10.0 0.0)
h1 (gpt/point 3.0 5.0)
h2 (gpt/point 7.0 5.0)
[[s1 e1 _ _] [s2 e2 _ _]] (path.helpers/curve-split start end h1 h2 0.5)]
;; First sub-curve starts at start and ends near midpoint
(t/is (mth/close? 0.0 (:x s1) 0.01))
(t/is (mth/close? 10.0 (:x e2) 0.01))
;; The split point (e1 / s2) should be the same
(t/is (mth/close? (:x e1) (:x s2) 0.01))
(t/is (mth/close? (:y e1) (:y s2) 0.01))))
(t/deftest helpers-split-line-to
(let [from (gpt/point 0.0 0.0)
seg {:command :line-to :params {:x 10.0 :y 0.0}}
[s1 s2] (path.helpers/split-line-to from seg 0.5)]
(t/is (= :line-to (:command s1)))
(t/is (mth/close? 5.0 (get-in s1 [:params :x])))
(t/is (= s2 seg))))
(t/deftest helpers-split-curve-to
(let [from (gpt/point 0.0 0.0)
seg {:command :curve-to
:params {:x 10.0 :y 0.0 :c1x 3.0 :c1y 5.0 :c2x 7.0 :c2y 5.0}}
[s1 s2] (path.helpers/split-curve-to from seg 0.5)]
(t/is (= :curve-to (:command s1)))
(t/is (= :curve-to (:command s2)))
;; s2 ends at original endpoint
(t/is (mth/close? 10.0 (get-in s2 [:params :x]) 0.01))
(t/is (mth/close? 0.0 (get-in s2 [:params :y]) 0.01))))
(t/deftest helpers-split-line-to-ranges
(t/testing "no split values returns original segment"
(let [from (gpt/point 0.0 0.0)
seg {:command :line-to :params {:x 10.0 :y 0.0}}
result (path.helpers/split-line-to-ranges from seg [])]
(t/is (= [seg] result))))
(t/testing "splits at 0.25 and 0.75 produces 3 segments"
(let [from (gpt/point 0.0 0.0)
seg {:command :line-to :params {:x 10.0 :y 0.0}}
result (path.helpers/split-line-to-ranges from seg [0.25 0.75])]
(t/is (= 3 (count result))))))
(t/deftest helpers-split-curve-to-ranges
(t/testing "no split values returns original segment"
(let [from (gpt/point 0.0 0.0)
seg {:command :curve-to
:params {:x 10.0 :y 0.0 :c1x 3.0 :c1y 5.0 :c2x 7.0 :c2y 5.0}}
result (path.helpers/split-curve-to-ranges from seg [])]
(t/is (= [seg] result))))
(t/testing "split at 0.5 produces 2 segments"
(let [from (gpt/point 0.0 0.0)
seg {:command :curve-to
:params {:x 10.0 :y 0.0 :c1x 3.0 :c1y 5.0 :c2x 7.0 :c2y 5.0}}
result (path.helpers/split-curve-to-ranges from seg [0.5])]
(t/is (= 2 (count result))))))
(t/deftest helpers-line-has-point?
(let [from (gpt/point 0.0 0.0)
to (gpt/point 10.0 0.0)]
(t/is (path.helpers/line-has-point? (gpt/point 5.0 0.0) [from to]))
(t/is (not (path.helpers/line-has-point? (gpt/point 5.0 1.0) [from to])))))
(t/deftest helpers-segment-has-point?
(let [from (gpt/point 0.0 0.0)
to (gpt/point 10.0 0.0)]
(t/is (path.helpers/segment-has-point? (gpt/point 5.0 0.0) [from to]))
;; Outside segment bounds even though on same infinite line
(t/is (not (path.helpers/segment-has-point? (gpt/point 15.0 0.0) [from to])))))
(t/deftest helpers-curve-has-point?
(let [start (gpt/point 0.0 0.0)
end (gpt/point 10.0 0.0)
h1 (gpt/point 0.0 0.0)
h2 (gpt/point 10.0 0.0)
;; degenerate curve (same as line) — midpoint should be on it
curve [start end h1 h2]]
(t/is (path.helpers/curve-has-point? (gpt/point 5.0 0.0) curve))
(t/is (not (path.helpers/curve-has-point? (gpt/point 5.0 100.0) curve)))))
(t/deftest helpers-curve-tangent
(let [start (gpt/point 0.0 0.0)
end (gpt/point 10.0 0.0)
h1 (gpt/point 3.0 0.0)
h2 (gpt/point 7.0 0.0)
tangent (path.helpers/curve-tangent [start end h1 h2] 0.5)]
;; For a nearly-horizontal curve, the tangent y-component is small
(t/is (mth/close? 1.0 (:x tangent) 0.01))
(t/is (mth/close? 0.0 (:y tangent) 0.01))))
(t/deftest helpers-curve->lines
(let [start (gpt/point 0.0 0.0)
end (gpt/point 10.0 0.0)
h1 (gpt/point 3.0 5.0)
h2 (gpt/point 7.0 5.0)
lines (path.helpers/curve->lines start end h1 h2)]
;; curve->lines produces num-segments lines (10 by default, closed [0..1] => 11 pairs)
(t/is (pos? (count lines)))
(t/is (= 2 (count (first lines))))))
(t/deftest helpers-line-line-intersect
(t/testing "perpendicular lines intersect"
(let [l1 [(gpt/point 5.0 0.0) (gpt/point 5.0 10.0)]
l2 [(gpt/point 0.0 5.0) (gpt/point 10.0 5.0)]
result (path.helpers/line-line-intersect l1 l2)]
(t/is (some? result))))
(t/testing "parallel lines do not intersect"
(let [l1 [(gpt/point 0.0 0.0) (gpt/point 10.0 0.0)]
l2 [(gpt/point 0.0 5.0) (gpt/point 10.0 5.0)]
result (path.helpers/line-line-intersect l1 l2)]
(t/is (nil? result)))))
(t/deftest helpers-subcurve-range
(let [start (gpt/point 0.0 0.0)
end (gpt/point 10.0 0.0)
h1 (gpt/point 3.0 5.0)
h2 (gpt/point 7.0 5.0)
[s e _ _] (path.helpers/subcurve-range start end h1 h2 0.25 0.75)]
;; sub-curve should start near t=0.25 and end near t=0.75
(t/is (some? s))
(t/is (some? e))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SEGMENT UNTESTED FUNCTIONS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(t/deftest segment-update-handler
(let [cmd {:command :curve-to
:params {:x 10.0 :y 0.0 :c1x 0.0 :c1y 0.0 :c2x 0.0 :c2y 0.0}}
pt (gpt/point 3.0 5.0)
r (path.segment/update-handler cmd :c1 pt)]
(t/is (= 3.0 (get-in r [:params :c1x])))
(t/is (= 5.0 (get-in r [:params :c1y])))))
(t/deftest segment-get-handler
(let [cmd {:command :curve-to
:params {:x 10.0 :y 0.0 :c1x 3.0 :c1y 5.0 :c2x 7.0 :c2y 2.0}}]
(t/is (= (gpt/point 3.0 5.0) (path.segment/get-handler cmd :c1)))
(t/is (= (gpt/point 7.0 2.0) (path.segment/get-handler cmd :c2)))
(t/is (nil? (path.segment/get-handler {:command :line-to :params {:x 1 :y 2}} :c1)))))
(t/deftest segment-handler->node
(let [content (path/content sample-content-2)]
;; For :c1 prefix, the node is the previous segment
(let [node (path.segment/handler->node (vec content) 2 :c1)]
(t/is (some? node)))
;; For :c2 prefix, the node is the current segment's endpoint
(let [node (path.segment/handler->node (vec content) 2 :c2)]
(t/is (some? node)))))
(t/deftest segment-calculate-opposite-handler
(let [pt (gpt/point 5.0 5.0)
h (gpt/point 8.0 5.0)
opp (path.segment/calculate-opposite-handler pt h)]
(t/is (mth/close? 2.0 (:x opp)))
(t/is (mth/close? 5.0 (:y opp)))))
(t/deftest segment-opposite-handler
(let [pt (gpt/point 5.0 5.0)
h (gpt/point 8.0 5.0)
opp (path.segment/opposite-handler pt h)]
(t/is (mth/close? 2.0 (:x opp)))
(t/is (mth/close? 5.0 (:y opp)))))
(t/deftest segment-point-indices
(let [content (path/content sample-content-2)
pt (gpt/point 480.0 839.0)
idxs (path.segment/point-indices content pt)]
(t/is (= [0] (vec idxs)))))
(t/deftest segment-opposite-index
(let [content (path/content sample-content-2)]
;; Index 2 with :c2 prefix — the node is the current point of index 2
(let [result (path.segment/opposite-index content 2 :c2)]
;; result is either nil or [index prefix]
(t/is (or (nil? result) (vector? result))))))
(t/deftest segment-split-segments
(let [content (path/content sample-content-square)
points #{(gpt/point 10.0 0.0)
(gpt/point 0.0 0.0)}
result (path.segment/split-segments content points 0.5)]
;; result should have more segments than original (splits added)
(t/is (> (count result) (count sample-content-square)))))
(t/deftest segment-content->selrect
(let [content (path/content sample-content-square)
rect (path.segment/content->selrect content)]
(t/is (some? rect))
(t/is (mth/close? 0.0 (:x1 rect) 0.1))
(t/is (mth/close? 0.0 (:y1 rect) 0.1))
(t/is (mth/close? 10.0 (:x2 rect) 0.1))
(t/is (mth/close? 10.0 (:y2 rect) 0.1))))
(t/deftest segment-content-center
(let [content (path/content sample-content-square)
center (path.segment/content-center content)]
(t/is (some? center))
(t/is (mth/close? 5.0 (:x center) 0.1))
(t/is (mth/close? 5.0 (:y center) 0.1))))
(t/deftest segment-move-content
(let [content (path/content sample-content-square)
move-vec (gpt/point 5.0 5.0)
result (path.segment/move-content content move-vec)
first-seg (first (vec result))]
(t/is (= :move-to (:command first-seg)))
(t/is (mth/close? 5.0 (get-in first-seg [:params :x])))))
(t/deftest segment-is-curve?
(let [content (path/content sample-content-2)]
;; point at index 0 is 480,839 — no handler offset, not a curve
(let [pt (gpt/point 480.0 839.0)]
;; is-curve? can return nil (falsy) or boolean — just check it doesn't throw
(t/is (not (path.segment/is-curve? content pt))))
;; A point that is reached by a curve-to command should be detectable
(let [curve-pt (gpt/point 4.0 4.0)]
(t/is (or (nil? (path.segment/is-curve? content curve-pt))
(boolean? (path.segment/is-curve? content curve-pt)))))))
(t/deftest segment-append-segment
(let [content (path/content sample-content)
seg {:command :line-to :params {:x 100.0 :y 100.0}}
result (path.segment/append-segment content seg)]
(t/is (= (inc (count (vec content))) (count result)))))
(t/deftest segment-remove-nodes
(let [content (path/content simple-open-content)
;; remove the midpoint
pt (gpt/point 10.0 0.0)
result (path.segment/remove-nodes content #{pt})]
;; should have fewer segments
(t/is (< (count result) (count simple-open-content)))))
(t/deftest segment-join-nodes
(let [content (path/content simple-open-content)
pt1 (gpt/point 0.0 0.0)
pt2 (gpt/point 10.0 10.0)
result (path.segment/join-nodes content #{pt1 pt2})]
;; join-nodes adds new segments connecting the given points
(t/is (>= (count result) (count simple-open-content)))))
(t/deftest segment-separate-nodes
(let [content (path/content simple-open-content)
pt (gpt/point 10.0 0.0)
result (path.segment/separate-nodes content #{pt})]
;; separate-nodes should return a collection (vector or seq)
(t/is (coll? result))))
(t/deftest segment-make-corner-point
(let [content (path/content sample-content-2)
;; Take a curve point and make it a corner
pt (gpt/point 439.0 802.0)
result (path.segment/make-corner-point content pt)]
;; Result is a PathData instance
(t/is (some? result))))
(t/deftest segment-next-node
(t/testing "no prev-point returns move-to"
(let [content (path/content sample-content)
position (gpt/point 100.0 100.0)
result (path.segment/next-node content position nil nil)]
(t/is (= :move-to (:command result)))))
(t/testing "with prev-point and no handler and last command is not close-path"
;; Use a content that does NOT end with :close-path
(let [content (path/content simple-open-content)
position (gpt/point 100.0 100.0)
prev (gpt/point 50.0 50.0)
result (path.segment/next-node content position prev nil)]
(t/is (= :line-to (:command result))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PATH TOP-LEVEL UNTESTED FUNCTIONS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(t/deftest path-from-plain
(let [result (path/from-plain sample-content)]
(t/is (path/content? result))
(t/is (= (count sample-content) (count (vec result))))))
(t/deftest path-calc-selrect
(let [content (path/content sample-content-square)
rect (path/calc-selrect content)]
(t/is (some? rect))
(t/is (mth/close? 0.0 (:x1 rect) 0.1))
(t/is (mth/close? 0.0 (:y1 rect) 0.1))))
(t/deftest path-close-subpaths
(let [content [{:command :move-to :params {:x 0.0 :y 0.0}}
{:command :line-to :params {:x 10.0 :y 0.0}}
{:command :move-to :params {:x 10.0 :y 5.0}}
{:command :line-to :params {:x 0.0 :y 0.0}}]
result (path/close-subpaths content)]
(t/is (path/content? result))
(t/is (seq (vec result)))))
(t/deftest path-move-content
(let [content (path/content sample-content-square)
move-vec (gpt/point 3.0 4.0)
result (path/move-content content move-vec)
first-r (first (vec result))]
(t/is (= :move-to (:command first-r)))
(t/is (mth/close? 3.0 (get-in first-r [:params :x])))
(t/is (mth/close? 4.0 (get-in first-r [:params :y])))))
(t/deftest path-move-content-zero-vec
(t/testing "moving by zero returns same content"
(let [content (path/content sample-content-square)
result (path/move-content content (gpt/point 0 0))]
;; should return same object (identity) when zero vector
(t/is (= (vec content) (vec result))))))
(t/deftest path-shape-with-open-path?
(t/testing "path shape with open content is open"
(let [shape {:type :path
:content (path/content simple-open-content)}]
(t/is (path/shape-with-open-path? shape))))
(t/testing "path shape with closed content is not open"
(let [shape {:type :path
:content (path/content simple-closed-content)}]
(t/is (not (path/shape-with-open-path? shape))))))
(t/deftest path-get-byte-size
(let [content (path/content sample-content)
size (path/get-byte-size content)]
(t/is (pos? size))))
(t/deftest path-apply-content-modifiers
(let [content (path/content sample-content)
;; shift the first point by x=5, y=3
modifiers {0 {:x 5.0 :y 3.0}}
result (path/apply-content-modifiers content modifiers)
first-seg (first (vec result))]
(t/is (mth/close? (+ 480.0 5.0) (get-in first-seg [:params :x])))
(t/is (mth/close? (+ 839.0 3.0) (get-in first-seg [:params :y])))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; BOOL OPERATIONS — INTERSECTION / DIFFERENCE / EXCLUSION
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Two non-overlapping rectangles for bool tests
(def ^:private rect-a
[{:command :move-to :params {:x 0.0 :y 0.0}}
{:command :line-to :params {:x 10.0 :y 0.0}}
{:command :line-to :params {:x 10.0 :y 10.0}}
{:command :line-to :params {:x 0.0 :y 10.0}}
{:command :line-to :params {:x 0.0 :y 0.0}}
{:command :close-path :params {}}])
(def ^:private rect-b
[{:command :move-to :params {:x 5.0 :y 5.0}}
{:command :line-to :params {:x 15.0 :y 5.0}}
{:command :line-to :params {:x 15.0 :y 15.0}}
{:command :line-to :params {:x 5.0 :y 15.0}}
{:command :line-to :params {:x 5.0 :y 5.0}}
{:command :close-path :params {}}])
(def ^:private rect-c
[{:command :move-to :params {:x 20.0 :y 20.0}}
{:command :line-to :params {:x 30.0 :y 20.0}}
{:command :line-to :params {:x 30.0 :y 30.0}}
{:command :line-to :params {:x 20.0 :y 30.0}}
{:command :line-to :params {:x 20.0 :y 20.0}}
{:command :close-path :params {}}])
(t/deftest bool-difference
(let [result (path.bool/calculate-content :difference [rect-a rect-b])]
;; difference result must be a sequence (possibly empty for degenerate cases)
(t/is (or (nil? result) (sequential? result)))))
(t/deftest bool-intersection
(let [result (path.bool/calculate-content :intersection [rect-a rect-b])]
(t/is (or (nil? result) (sequential? result)))))
(t/deftest bool-exclusion
(let [result (path.bool/calculate-content :exclude [rect-a rect-b])]
(t/is (or (nil? result) (sequential? result)))))
(t/deftest bool-union-non-overlapping
(let [result (path.bool/calculate-content :union [rect-a rect-c])]
;; non-overlapping union should contain both shapes' segments
(t/is (seq result))
(t/is (> (count result) (count rect-a)))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SHAPE-TO-PATH TESTS (via path/convert-to-path)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- make-selrect [x y w h]
(grc/make-rect x y w h))
(t/deftest shape-to-path-rect-simple
(let [shape {:type :rect :x 0.0 :y 0.0 :width 100.0 :height 50.0
:selrect (make-selrect 0.0 0.0 100.0 50.0)}
result (path/convert-to-path shape {})]
(t/is (= :path (:type result)))
(t/is (path/content? (:content result)))
;; A simple rect (no radius) produces an empty path in the current impl
;; so we just check it doesn't throw and returns a :path type
(t/is (some? (:content result)))))
(t/deftest shape-to-path-circle
(let [shape {:type :circle :x 0.0 :y 0.0 :width 100.0 :height 100.0
:selrect (make-selrect 0.0 0.0 100.0 100.0)}
result (path/convert-to-path shape {})]
(t/is (= :path (:type result)))
(t/is (path/content? (:content result)))
;; A circle converts to bezier curves — should have multiple segments
(t/is (> (count (vec (:content result))) 1))))
(t/deftest shape-to-path-path
(let [shape {:type :path :content (path/content sample-content)}
result (path/convert-to-path shape {})]
;; A path shape stays a path shape unchanged
(t/is (= :path (:type result)))))
(t/deftest shape-to-path-rect-with-radius
(let [shape {:type :rect :x 0.0 :y 0.0 :width 100.0 :height 100.0
:r1 10.0 :r2 10.0 :r3 10.0 :r4 10.0
:selrect (make-selrect 0.0 0.0 100.0 100.0)}
result (path/convert-to-path shape {})]
(t/is (= :path (:type result)))
;; rounded rect should have curve-to segments
(let [segs (vec (:content result))
curve-segs (filter #(= :curve-to (:command %)) segs)]
(t/is (pos? (count curve-segs))))))