penpot/frontend/test/frontend_tests/tokens/tokenscript_test.cljs
Florian Schrödl d1c8b0f959
🐛 Report tokens named below another token with tokenscript (#12529)
A token cannot contain tokens: next to a token `a`, the tokens `a.b`
and `a.b.c` are now invalid with tokenscript, whatever the order of
their sets. As with StyleDictionary, their pills and the token form
show "Group name of a.b conflicts with a token of the same name in
another active set." and `{a.b}` is a missing reference.

The interpreter reports them as :processor/token-name-collision, which
the adapter maps to :error.token/name-collision. frontend/deps.edn
updates penpot/tokenscript-interpreter to v0.2.0, which adds the check.
Since v0.1.0 its errors carry codes and data only, which the adapter
already relies on.

Signed-off-by: Florian Schroedl <flo.schroedl@gmail.com>
2026-11-03 16:06:16 +01:00

194 lines
13 KiB
Clojure
Raw 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 frontend-tests.tokens.tokenscript-test
(:require
[app.common.test-helpers.ids-map :as cthi]
[app.common.types.tokens-lib :as ctob]
[app.main.data.tokenscript :as ts]
[cljs.test :as t :include-macros true]))
(defn- tokens-map
"Tokens map of a library with one set holding `tokens`."
[& tokens]
(let [lib (-> (ctob/make-tokens-lib)
(ctob/add-set (ctob/make-token-set :id (cthi/new-id! :core-set) :name "core")))]
(-> (reduce (fn [lib token] (ctob/add-token lib (cthi/id :core-set) (ctob/make-token token))) lib tokens)
(ctob/get-all-tokens-map))))
(t/deftest resolve-tokens-test
(t/testing "resolves references and converts units to pixels"
(let [resolved (ts/resolve-tokens
(tokens-map {:name "borderRadius.sm" :value "12px" :type :border-radius}
{:name "borderRadius.md-with-dashes" :value "{borderRadius.sm} * 2" :type :border-radius}
{:name "borderRadius.rem" :value "2rem" :type :border-radius}
{:name "opacity.half" :value "50%" :type :opacity}
{:name "borderRadius.large" :value "123456789012345" :type :border-radius}
{:name "borderRadius.largePx" :value "123456789012345px" :type :border-radius}
{:name "borderRadius.largeFn" :value "{borderRadius.sm} * 200000000" :type :border-radius}))]
(t/is (= 12 (get-in resolved ["borderRadius.sm" :resolved-value])))
(t/is (= 24 (get-in resolved ["borderRadius.md-with-dashes" :resolved-value])))
(t/is (= 32 (get-in resolved ["borderRadius.rem" :resolved-value])))
(t/is (= 0.5 (get-in resolved ["opacity.half" :resolved-value])))
(doseq [name ["borderRadius.large" "borderRadius.largePx" "borderRadius.largeFn"]]
(t/is (nil? (get-in resolved [name :resolved-value])))
(t/is (= :error.token/number-too-large (get-in resolved [name :errors 0 :error/code])))))))
(t/deftest resolve-tokens-colors-test
(let [resolved (ts/resolve-tokens
(tokens-map {:name "color.red" :value "#ff0000" :type :color}
{:name "color.alias" :value "{color.red}" :type :color}
{:name "color.alpha" :value "#ff000080" :type :color}
{:name "color.named" :value "red" :type :color}
{:name "color.light" :value "lighten({color.red}, 0.2)" :type :color}))]
(t/is (= "#ff0000" (get-in resolved ["color.alias" :resolved-value])))
(t/is (= "#ff000080" (get-in resolved ["color.alpha" :resolved-value])) "alpha is kept as hex8")
(t/is (= "#FF0000" (get-in resolved ["color.named" :resolved-value])) "css names resolve")
(t/is (string? (get-in resolved ["color.light" :resolved-value])) "color functions resolve to hex")))
(t/deftest resolve-tokens-errors-test
(let [resolved (ts/resolve-tokens
(tokens-map {:name "a" :value "{missing}" :type :border-radius}
{:name "b" :value "{a} + 1" :type :border-radius}
{:name "x" :value "{y}" :type :border-radius}
{:name "y" :value "{x}" :type :border-radius}
{:name "z" :value "{x} + 1" :type :border-radius}
{:name "bad" :value "1 ´ 2" :type :border-radius}))]
(t/is (= :error.style-dictionary/missing-reference (get-in resolved ["a" :errors 0 :error/code])))
(t/is (= ["missing"] (get-in resolved ["a" :errors 0 :error/value])))
(t/is (= :error.style-dictionary/missing-reference (get-in resolved ["b" :errors 0 :error/code]))
"dependents report the missing token")
(t/is (= ["missing"] (get-in resolved ["b" :errors 0 :error/value])))
(t/is (= :error.token/circular-reference (get-in resolved ["x" :errors 0 :error/code])))
(t/is (= :error.token/circular-reference (get-in resolved ["y" :errors 0 :error/code])))
(t/is (= :error.token/circular-reference (get-in resolved ["z" :errors 0 :error/code]))
"dependents of a cycle report the cycle")
(t/is (= :error.style-dictionary/invalid-token-value (get-in resolved ["bad" :errors 0 :error/code])))))
(t/deftest resolve-tokens-error-codes-test
(let [resolved (ts/resolve-tokens
(tokens-map {:name "zero" :value "1 / 0" :type :number}
{:name "mod-zero" :value "mod(4, 0)" :type :number}
{:name "units" :value "1px * 2em" :type :sizing}
{:name "fn" :value "foo(1)" :type :number}
{:name "ln" :value "ln(0)" :type :number}
{:name "ref" :value "{zero} + 1" :type :number}))
error (fn [name] (select-keys (get-in resolved [name :errors 0]) [:error/code :error/value]))]
(t/is (= {:error/code :error.token/division-by-zero :error/value nil} (error "zero")))
(t/is (= {:error/code :error.token/division-by-zero :error/value nil} (error "mod-zero")))
(t/is (= {:error/code :error.token/incompatible-units :error/value ["px" "em"]} (error "units")))
(t/is (= {:error/code :error.token/unknown-function :error/value "foo"} (error "fn")))
(t/is (= {:error/code :error.token/function-argument-out-of-range :error/value "ln"} (error "ln")))
(t/is (= {:error/code :error.style-dictionary/invalid-token-value :error/value "{zero} + 1"} (error "ref"))
"a token failing through its reference is an invalid value")))
;; A token cannot contain tokens: next to a token "a", the tokens "a.b"
;; and "a.b.c" get a name collision error and `{a.b}` is missing (#9584).
(t/deftest resolve-tokens-name-collision-test
(let [resolved (ts/resolve-tokens
(tokens-map {:name "a" :value "8px" :type :border-radius}
{:name "a.b" :value "12px" :type :border-radius}
{:name "a.b.c" :value "1px" :type :border-radius}
{:name "c" :value "{a} + {a.b}" :type :border-radius}))
error (fn [name] (select-keys (get-in resolved [name :errors 0]) [:error/code :error/value]))]
(t/is (= 8 (get-in resolved ["a" :resolved-value])))
(t/is (nil? (get-in resolved ["a" :errors])))
(t/is (nil? (get-in resolved ["a.b" :resolved-value])))
(t/is (= {:error/code :error.token/name-collision :error/value "a.b"} (error "a.b")))
(t/is (= {:error/code :error.token/name-collision :error/value "a.b.c"} (error "a.b.c")))
(t/is (= {:error/code :error.style-dictionary/missing-reference :error/value ["a.b"]} (error "c"))))
(t/testing "a new token below an existing one collides"
(let [tokens (tokens-map {:name "rad1" :value "8px" :type :border-radius})
resolved (ts/resolve-token tokens {:name "rad1.bad" :value "10" :type :border-radius})]
(t/is (= :error.token/name-collision (get-in resolved [:errors 0 :error/code]))))))
(t/deftest resolve-tokens-composites-test
(t/testing "typography and shadow tokens resolve field by field"
(let [resolved (ts/resolve-tokens
(tokens-map {:name "size" :value "16" :type :font-size}
{:name "typo" :type :typography
:value {:font-family ["Roboto" "Arial"]
:font-size "{size} * 2"
:font-weight "700 Italic"
:line-height "48px"}}
{:name "shadow" :type :shadow
:value [{:offset-x "2" :offset-y "{size}" :blur "4" :spread "0"
:color "#000" :inset false}]}))]
(t/is (= {:font-family ["Roboto" "Arial"] :font-size 32 :font-weight "700 Italic" :line-height 1.5}
(get-in resolved ["typo" :resolved-value]))
"line heights in pixels become a ratio of the font size")
(t/is (= [{:offset-x 2 :offset-y 16 :blur 4 :spread 0 :color "#000" :inset false}]
(get-in resolved ["shadow" :resolved-value])))))
(t/testing "font families referencing a font family token are flattened"
(let [resolved (ts/resolve-tokens
(tokens-map {:name "families" :value ["Arial" "sans-serif"] :type :font-family}
{:name "single" :value "Roboto" :type :font-family}
{:name "typo" :value {:font-family ["{families}"]} :type :typography}))]
(t/is (= ["Arial" "sans-serif"] (get-in resolved ["families" :resolved-value])))
(t/is (= ["Roboto"] (get-in resolved ["single" :resolved-value])))
(t/is (= ["Arial" "sans-serif"] (get-in resolved ["typo" :resolved-value :font-family])))))
(t/testing "a typography token with an array value is invalid"
(let [resolved (ts/resolve-tokens (tokens-map {:name "typography.bad" :value ["Roboto"] :type :typography}))]
(t/is (nil? (get-in resolved ["typography.bad" :resolved-value])))
(t/is (= :error.style-dictionary/invalid-token-value-typography
(get-in resolved ["typography.bad" :errors 0 :error/code]))))))
(t/deftest resolve-tokens-value-validation-test
(let [resolved (ts/resolve-tokens
(tokens-map {:name "opacity.bad" :value "2" :type :opacity}
{:name "stroke.bad" :value "-1" :type :stroke-width}
{:name "case.ok" :value "uppercase" :type :text-case}
{:name "case.bad" :value "wat" :type :text-case}
{:name "weight.ok" :value "bold" :type :font-weight}
{:name "weight.bad" :value "heavyish" :type :font-weight}))]
(t/is (= :error.style-dictionary/invalid-token-value-opacity (get-in resolved ["opacity.bad" :errors 0 :error/code])))
(t/is (= :error.style-dictionary/invalid-token-value-stroke-width (get-in resolved ["stroke.bad" :errors 0 :error/code])))
(t/is (= "uppercase" (get-in resolved ["case.ok" :resolved-value])))
(t/is (= :error.style-dictionary/invalid-token-value-text-case (get-in resolved ["case.bad" :errors 0 :error/code])))
(t/is (= "bold" (get-in resolved ["weight.ok" :resolved-value])))
(t/is (= :error.style-dictionary/invalid-token-value-font-weight (get-in resolved ["weight.bad" :errors 0 :error/code])))))
;; The interpreter validates the kind of value of each token type: `1foo`
;; and `1 2` are lists, not numbers.
(t/deftest resolve-tokens-type-validation-test
(let [resolved (ts/resolve-tokens
(tokens-map {:name "size.glued" :value "1foo" :type :dimensions}
{:name "size.list" :value "1 2" :type :sizing}
{:name "size.ok" :value "1rem" :type :sizing}
{:name "number.glued" :value "1foo" :type :number}
{:name "number.unit" :value "10px" :type :number}
{:name "number.ok" :value "1.5" :type :number}
{:name "opacity.percent" :value "150%" :type :opacity}
{:name "typography.bad" :value {:font-size "1foo" :line-height "1.5"} :type :typography}
{:name "shadow.bad" :value "8" :type :shadow}))
code (fn [name] (get-in resolved [name :errors 0 :error/code]))]
(doseq [name ["size.glued" "size.list" "number.glued"]]
(t/is (nil? (get-in resolved [name :resolved-value])))
(t/is (= :error.style-dictionary/invalid-token-value (code name))))
(t/is (= "1foo" (get-in resolved ["size.glued" :errors 0 :error/value])))
(t/is (= :error.style-dictionary/value-with-units (code "number.unit")))
(t/is (= :error.style-dictionary/invalid-token-value-opacity (code "opacity.percent")))
(t/is (= :error.style-dictionary/invalid-token-value-typography (code "typography.bad")))
(t/is (= :error.style-dictionary/invalid-token-value-shadow (code "shadow.bad")))
(t/is (= 16 (get-in resolved ["size.ok" :resolved-value])))
(t/is (= 1.5 (get-in resolved ["number.ok" :resolved-value])))))
;; A token with a `nil` value (e.g. a composite typography token saved via
;; the workspace form with no fields filled in) is tagged with an empty
;; input error and does not affect the other tokens.
(t/deftest resolve-tokens-nil-value-test
(let [resolved (ts/resolve-tokens
(tokens-map {:name "typography.empty" :value nil :type :typography}
{:name "borderRadius.sm" :value "12px" :type :border-radius}))]
(t/is (nil? (get-in resolved ["typography.empty" :resolved-value])))
(t/is (= :error.token/empty-input (get-in resolved ["typography.empty" :errors 0 :error/code])))
(t/is (= 12 (get-in resolved ["borderRadius.sm" :resolved-value])))))
(t/deftest resolve-token-test
(let [tokens (tokens-map {:name "a" :value "8px" :type :border-radius})
resolved (ts/resolve-token tokens {:name "b" :value "{a} * 2" :type :border-radius})]
(t/is (= 16 (:resolved-value resolved)))))