From 7461c5304ca0fa5fdb97e02ec85639b567f4423b Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 24 Mar 2026 19:10:44 +0100 Subject: [PATCH] :sparkles: Add comprehensive tests for app.common.colors ns (#8758) Cover all public functions: valid-hex-color?, parse-rgb, valid-rgb-color?, rgb->str, hex->rgb, rgb->hex, rgb->hsv, hsv->rgb, rgb->hsl, hsl->rgb, hex->hsl, hex->hsv, hex->rgba, hex->hsla, hex->lum, hsl->hex, hsl->hsv, hsv->hex, hsv->hsl, format-hsla, format-rgba, expand-hex, prepend-hash, remove-hash, color-string?, parse, next-rgb, reduce-range, interpolate-color, uniform-spread, uniform-spread? and interpolate-gradient. Tests pass on both JVM and JS (ClojureScript) platforms. Platform differences (NaN saturation for achromatic colors, integer vs float return types) are handled with mth/close?. --- common/test/common_tests/colors_test.cljc | 347 ++++++++++++++++++++++ 1 file changed, 347 insertions(+) diff --git a/common/test/common_tests/colors_test.cljc b/common/test/common_tests/colors_test.cljc index 9e296bef03..de505fd540 100644 --- a/common/test/common_tests/colors_test.cljc +++ b/common/test/common_tests/colors_test.cljc @@ -7,6 +7,8 @@ (ns common-tests.colors-test (:require #?(:cljs [goog.color :as gcolors]) + [app.common.colors :as c] + [app.common.math :as mth] [app.common.types.color :as colors] [clojure.test :as t])) @@ -92,3 +94,348 @@ (t/is (false? (colors/color-string? ""))) (t/is (false? (colors/color-string? "kkkkkk")))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; app.common.colors tests +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +;; --- Predicates and parsing + +(t/deftest ac-valid-hex-color + (t/is (true? (c/valid-hex-color? "#000000"))) + (t/is (true? (c/valid-hex-color? "#FFFFFF"))) + (t/is (true? (c/valid-hex-color? "#fabada"))) + (t/is (true? (c/valid-hex-color? "#aaa"))) + (t/is (false? (c/valid-hex-color? nil))) + (t/is (false? (c/valid-hex-color? ""))) + (t/is (false? (c/valid-hex-color? "#"))) + (t/is (false? (c/valid-hex-color? "#qqqqqq"))) + (t/is (false? (c/valid-hex-color? "#aaaa"))) + (t/is (false? (c/valid-hex-color? "fabada")))) + +(t/deftest ac-parse-rgb + (t/is (= [255 30 30] (c/parse-rgb "rgb(255, 30, 30)"))) + (t/is (= [255 30 30] (c/parse-rgb "(255, 30, 30)"))) + (t/is (= [0 0 0] (c/parse-rgb "(0,0,0)"))) + (t/is (= [255 255 255] (c/parse-rgb "rgb(255,255,255)"))) + ;; Values out of 0-255 range return nil + (t/is (nil? (c/parse-rgb "rgb(256, 0, 0)"))) + (t/is (nil? (c/parse-rgb "rgb(0, -1, 0)"))) + (t/is (nil? (c/parse-rgb "not-a-color"))) + (t/is (nil? (c/parse-rgb "#fabada")))) + +(t/deftest ac-valid-rgb-color + (t/is (true? (c/valid-rgb-color? "rgb(255, 30, 30)"))) + (t/is (true? (c/valid-rgb-color? "(255,30,30)"))) + (t/is (false? (c/valid-rgb-color? nil))) + (t/is (false? (c/valid-rgb-color? ""))) + (t/is (false? (c/valid-rgb-color? "#fabada"))) + (t/is (false? (c/valid-rgb-color? "rgb(300,0,0)")))) + +;; --- Core conversions + +(t/deftest ac-rgb-to-str + (t/is (= "rgb(1,2,3)" (c/rgb->str [1 2 3]))) + (t/is (= "rgba(1,2,3,0.5)" (c/rgb->str [1 2 3 0.5]))) + (t/is (= "rgb(0,0,0)" (c/rgb->str [0 0 0]))) + (t/is (= "rgb(255,255,255)" (c/rgb->str [255 255 255])))) + +(t/deftest ac-hex-to-rgb + (t/is (= [0 0 0] (c/hex->rgb "#000000"))) + (t/is (= [255 255 255] (c/hex->rgb "#ffffff"))) + (t/is (= [1 2 3] (c/hex->rgb "#010203"))) + (t/is (= [250 186 218] (c/hex->rgb "#fabada"))) + ;; Invalid hex falls back to [0 0 0] + (t/is (= [0 0 0] (c/hex->rgb "#kkk")))) + +(t/deftest ac-rgb-to-hex + (t/is (= "#000000" (c/rgb->hex [0 0 0]))) + (t/is (= "#ffffff" (c/rgb->hex [255 255 255]))) + (t/is (= "#010203" (c/rgb->hex [1 2 3]))) + (t/is (= "#fabada" (c/rgb->hex [250 186 218])))) + +(t/deftest ac-hex-to-rgb-roundtrip + (t/is (= [250 186 218] (c/hex->rgb (c/rgb->hex [250 186 218])))) + (t/is (= [10 20 30] (c/hex->rgb (c/rgb->hex [10 20 30]))))) + +(t/deftest ac-rgb-to-hsv + ;; Achromatic black + (let [[h s v] (c/rgb->hsv [0 0 0])] + (t/is (= 0 h)) + (t/is (= 0 s)) + (t/is (= 0 v))) + ;; Red: h=0, s=1, v=255 + (let [[h s v] (c/rgb->hsv [255 0 0])] + (t/is (mth/close? h 0.0)) + (t/is (mth/close? s 1.0)) + (t/is (= 255 v))) + ;; Blue: h=240, s=1, v=255 + (let [[h s v] (c/rgb->hsv [0 0 255])] + (t/is (mth/close? h 240.0)) + (t/is (mth/close? s 1.0)) + (t/is (= 255 v))) + ;; Achromatic gray: h=0, s=0, v=128 + (let [[h s v] (c/rgb->hsv [128 128 128])] + (t/is (= 0 h)) + (t/is (= 0 s)) + (t/is (= 128 v)))) + +(t/deftest ac-hsv-to-rgb + (t/is (= [0 0 0] (c/hsv->rgb [0 0 0]))) + (t/is (= [255 255 255] (c/hsv->rgb [0 0 255]))) + (t/is (= [1 2 3] (c/hsv->rgb [210 0.6666666666666666 3]))) + ;; Achromatic (s=0) + (let [[r g b] (c/hsv->rgb [0 0 128])] + (t/is (= r g b 128)))) + +(t/deftest ac-rgb-to-hsv-roundtrip + (let [orig [100 150 200] + [h s v] (c/rgb->hsv orig) + result (c/hsv->rgb [h s v])] + ;; Roundtrip may have rounding of ±1 + (t/is (every? true? (map #(< (mth/abs (- %1 %2)) 2) orig result))))) + +(t/deftest ac-rgb-to-hsl + ;; Black: h=0, s=0.0, l=0.0 (s is 0.0 not 0 on JVM, and ##NaN for white) + (let [[h s l] (c/rgb->hsl [0 0 0])] + (t/is (= 0 h)) + (t/is (mth/close? l 0.0))) + ;; White: h=0, s=##NaN (achromatic), l=1.0 + (let [[_ _ l] (c/rgb->hsl [255 255 255])] + (t/is (mth/close? l 1.0))) + ;; Red [255 0 0] → hue=0, saturation=1, lightness=0.5 + (let [[h s l] (c/rgb->hsl [255 0 0])] + (t/is (mth/close? h 0.0)) + (t/is (mth/close? s 1.0)) + (t/is (mth/close? l 0.5)))) + +(t/deftest ac-hsl-to-rgb + (t/is (= [0 0 0] (c/hsl->rgb [0 0 0]))) + (t/is (= [255 255 255] (c/hsl->rgb [0 0 1]))) + (t/is (= [1 2 3] (c/hsl->rgb [210.0 0.5 0.00784313725490196]))) + ;; Achromatic (s=0): all channels equal lightness*255 + (let [[r g b] (c/hsl->rgb [0 0 0.5])] + (t/is (= r g b)))) + +(t/deftest ac-rgb-hsl-roundtrip + (let [orig [100 150 200] + hsl (c/rgb->hsl orig) + result (c/hsl->rgb hsl)] + (t/is (every? true? (map #(< (mth/abs (- %1 %2)) 2) orig result))))) + +(t/deftest ac-hex-to-hsv + ;; Black: h=0, s=0, v=0 (integers on JVM) + (let [[h s v] (c/hex->hsv "#000000")] + (t/is (= 0 h)) + (t/is (= 0 s)) + (t/is (= 0 v))) + ;; Red: h=0, s=1, v=255 + (let [[h s v] (c/hex->hsv "#ff0000")] + (t/is (mth/close? h 0.0)) + (t/is (mth/close? s 1.0)) + (t/is (= 255 v)))) + +(t/deftest ac-hex-to-rgba + (t/is (= [0 0 0 1.0] (c/hex->rgba "#000000" 1.0))) + (t/is (= [255 255 255 0.5] (c/hex->rgba "#ffffff" 0.5))) + (t/is (= [1 2 3 0.8] (c/hex->rgba "#010203" 0.8)))) + +(t/deftest ac-hex-to-hsl + ;; Black: h=0, s=0.0, l=0.0 + (let [[h s l] (c/hex->hsl "#000000")] + (t/is (= 0 h)) + (t/is (mth/close? s 0.0)) + (t/is (mth/close? l 0.0))) + ;; Invalid hex falls back to [0 0 0] + (let [[h _ _] (c/hex->hsl "invalid")] + (t/is (= 0 h)))) + +(t/deftest ac-hex-to-hsla + ;; Black + full opacity: h=0, s=0.0, l=0.0, a=1.0 + (let [[h s l a] (c/hex->hsla "#000000" 1.0)] + (t/is (= 0 h)) + (t/is (mth/close? s 0.0)) + (t/is (mth/close? l 0.0)) + (t/is (= a 1.0))) + ;; White + half opacity: l=1.0, a=0.5 + (let [[_ _ l a] (c/hex->hsla "#ffffff" 0.5)] + (t/is (mth/close? l 1.0)) + (t/is (= a 0.5)))) + +(t/deftest ac-hsl-to-hex + (t/is (= "#000000" (c/hsl->hex [0 0 0]))) + (t/is (= "#ffffff" (c/hsl->hex [0 0 1]))) + (t/is (= "#ff0000" (c/hsl->hex [0 1 0.5])))) + +(t/deftest ac-hsl-to-hsv + ;; Black: stays [0 0 0] + (let [[h s v] (c/hsl->hsv [0 0 0])] + (t/is (= 0 h)) + (t/is (= 0 s)) + (t/is (= 0 v))) + ;; Red: hsl [0 1 0.5] → hsv h≈0, s≈1, v≈255 + (let [[h s v] (c/hsl->hsv [0 1 0.5])] + (t/is (mth/close? h 0.0)) + (t/is (mth/close? s 1.0)) + (t/is (mth/close? v 255.0)))) + +(t/deftest ac-hsv-to-hex + (t/is (= "#000000" (c/hsv->hex [0 0 0]))) + (t/is (= "#ffffff" (c/hsv->hex [0 0 255])))) + +(t/deftest ac-hsv-to-hsl + ;; Black + (let [[h s l] (c/hsv->hsl [0 0 0])] + (t/is (= 0 h)) + (t/is (mth/close? s 0.0)) + (t/is (mth/close? l 0.0))) + ;; White: h=0, s=##NaN (achromatic), l=1.0 + (let [[_ _ l] (c/hsv->hsl [0 0 255])] + (t/is (mth/close? l 1.0)))) + +(t/deftest ac-hex-to-lum + ;; Black has luminance 0 + (t/is (= 0.0 (c/hex->lum "#000000"))) + ;; White has max luminance + (let [lum (c/hex->lum "#ffffff")] + (t/is (> lum 0))) + ;; Luminance is non-negative + (t/is (>= (c/hex->lum "#fabada") 0))) + +;; --- Formatters + +(t/deftest ac-format-hsla + (t/is (= "210 50% 0.78% / 1" (c/format-hsla [210.0 0.5 0.00784313725490196 1]))) + (t/is (= "220 5% 30% / 0.8" (c/format-hsla [220.0 0.05 0.3 0.8]))) + (t/is (= "0 0% 0% / 0" (c/format-hsla [0 0 0 0])))) + +(t/deftest ac-format-rgba + (t/is (= "210, 199, 12, 0.08" (c/format-rgba [210 199 12 0.08]))) + (t/is (= "0, 0, 0, 1" (c/format-rgba [0 0 0 1]))) + (t/is (= "255, 255, 255, 0.5" (c/format-rgba [255 255 255 0.5])))) + +;; --- String utilities + +(t/deftest ac-expand-hex + ;; Single char: repeated 6 times + (t/is (= "aaaaaa" (c/expand-hex "a"))) + ;; Two chars: repeated as 3 pairs + (t/is (= "aaaaaa" (c/expand-hex "aa"))) + ;; Three chars: each char doubled + (t/is (= "aabbcc" (c/expand-hex "abc"))) + ;; Other lengths: returned as-is + (t/is (= "aaaa" (c/expand-hex "aaaa"))) + (t/is (= "aaaaaa" (c/expand-hex "aaaaaa")))) + +(t/deftest ac-prepend-hash + (t/is (= "#fabada" (c/prepend-hash "fabada"))) + ;; Already has hash: unchanged + (t/is (= "#fabada" (c/prepend-hash "#fabada")))) + +(t/deftest ac-remove-hash + (t/is (= "fabada" (c/remove-hash "#fabada"))) + ;; No hash: unchanged + (t/is (= "fabada" (c/remove-hash "fabada")))) + +;; --- High-level predicates / parsing + +(t/deftest ac-color-string + (t/is (true? (c/color-string? "#aaa"))) + (t/is (true? (c/color-string? "#fabada"))) + (t/is (true? (c/color-string? "rgb(10,10,10)"))) + (t/is (true? (c/color-string? "(10,10,10)"))) + (t/is (true? (c/color-string? "magenta"))) + (t/is (false? (c/color-string? nil))) + (t/is (false? (c/color-string? ""))) + (t/is (false? (c/color-string? "notacolor")))) + +(t/deftest ac-parse + ;; Valid hex → normalized lowercase + (t/is (= "#fabada" (c/parse "#fabada"))) + (t/is (= "#fabada" (c/parse "#FABADA"))) + ;; Short hex → expanded+normalized + (t/is (= "#aaaaaa" (c/parse "#aaa"))) + ;; Hex without hash: normalize-hex is called, returns lowercase without adding # + (t/is (= "fabada" (c/parse "fabada"))) + ;; Named color + (t/is (= "#ff0000" (c/parse "red"))) + (t/is (= "#ff00ff" (c/parse "magenta"))) + ;; rgb() notation + (t/is (= "#ff1e1e" (c/parse "rgb(255, 30, 30)"))) + ;; Invalid → nil + (t/is (nil? (c/parse "notacolor"))) + (t/is (nil? (c/parse nil)))) + +;; --- next-rgb + +(t/deftest ac-next-rgb + ;; Increment blue channel + (t/is (= [0 0 1] (c/next-rgb [0 0 0]))) + (t/is (= [0 0 255] (c/next-rgb [0 0 254]))) + ;; Blue overflow: increment green, reset blue + (t/is (= [0 1 0] (c/next-rgb [0 0 255]))) + ;; Green overflow: increment red, reset green + (t/is (= [1 0 0] (c/next-rgb [0 255 255]))) + ;; White overflows: throws + (t/is (thrown? #?(:clj Exception :cljs :default) + (c/next-rgb [255 255 255])))) + +;; --- reduce-range + +(t/deftest ac-reduce-range + (t/is (= 0.5 (c/reduce-range 0.5 2))) + (t/is (= 0.0 (c/reduce-range 0.1 2))) + (t/is (= 0.25 (c/reduce-range 0.3 4))) + (t/is (= 0.0 (c/reduce-range 0.0 10)))) + +;; --- Gradient helpers + +(t/deftest ac-interpolate-color + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0}] + ;; At c1's offset → c1 with updated offset + (let [result (c/interpolate-color c1 c2 0.0)] + (t/is (= "#000000" (:color result))) + (t/is (= 0.0 (:opacity result)))) + ;; At c2's offset → c2 with updated offset + (let [result (c/interpolate-color c1 c2 1.0)] + (t/is (= "#ffffff" (:color result))) + (t/is (= 1.0 (:opacity result)))) + ;; At midpoint → gray + (let [result (c/interpolate-color c1 c2 0.5)] + (t/is (= "#7f7f7f" (:color result))) + (t/is (mth/close? (:opacity result) 0.5))))) + +(t/deftest ac-uniform-spread + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + stops (c/uniform-spread c1 c2 3)] + (t/is (= 3 (count stops))) + (t/is (= 0.0 (:offset (first stops)))) + (t/is (mth/close? 0.5 (:offset (second stops)))) + (t/is (= 1.0 (:offset (last stops)))))) + +(t/deftest ac-uniform-spread? + (let [c1 {:color "#000000" :opacity 0.0 :offset 0.0} + c2 {:color "#ffffff" :opacity 1.0 :offset 1.0} + stops (c/uniform-spread c1 c2 3)] + ;; A uniformly spread result should pass the predicate + (t/is (true? (c/uniform-spread? stops)))) + ;; Manual non-uniform stops should not pass + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#888888" :opacity 0.5 :offset 0.3} + {:color "#ffffff" :opacity 1.0 :offset 1.0}]] + (t/is (false? (c/uniform-spread? stops))))) + +(t/deftest ac-interpolate-gradient + (let [stops [{:color "#000000" :opacity 0.0 :offset 0.0} + {:color "#ffffff" :opacity 1.0 :offset 1.0}]] + ;; At start + (let [result (c/interpolate-gradient stops 0.0)] + (t/is (= "#000000" (:color result)))) + ;; At end + (let [result (c/interpolate-gradient stops 1.0)] + (t/is (= "#ffffff" (:color result)))) + ;; In the middle + (let [result (c/interpolate-gradient stops 0.5)] + (t/is (= "#7f7f7f" (:color result)))))) +