mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 05:50:35 +00:00
* ✨ Add stroke side-width helpers to common types Add set-width-to-all-sides, set-width-to-single-side and side-width to app.common.types.stroke, mirroring the border-radius helpers in app.common.types.shape.radius. They back the per-side stroke width API and are covered by unit tests. AI-assisted-by: deepseek-v4.1-flash * ✨ Round-trip per-side stroke widths through the plugin API Add strokeWidthTop/Right/Bottom/Left to format-stroke and parse-stroke so reading shape.strokes exposes the per-side widths and writing a stroke back preserves them instead of collapsing to the uniform width. parse-stroke prefers a live stroke proxy's hidden raw state when present, so a commit only rewrites the attributes the plugin touched. AI-assisted-by: deepseek-v4.1-flash * ✨ Add per-side stroke widths to the plugin API types Add the four per-side members to the Stroke interface and to the borderWidth and dimension token property lists, and refresh the generated API surface used by the plugin test suite. AI-assisted-by: deepseek-v4.1-flash * ✨ Expose per-side stroke width on the stroke proxy Add strokeWidthTop/Right/Bottom/Left to StrokeProxy. Reads fall back to the uniform strokeWidth; strokeWidth now writes all four sides, like borderRadius writes all four corners; each side setter writes only its own key and the top side also updates strokeWidth. Reject per-side writes on shapes other than boards and rectangles with a validation error naming the property, and expose the raw stroke map through a hidden proxy member so a commit preserves untouched attributes. AI-assisted-by: deepseek-v4.1-flash * ✨ Support stroke width tokens per side in the plugin API Token attribute expansion now accepts the uniform strokeWidth property and expands it to the four per-side attributes, mirroring the design tab. shape.tokens exposes the per-side keys and reports strokeWidth when all four sides share one token, and applyToken routes through the shared expander. AI-assisted-by: deepseek-v4.1-flash * 📚 Document per-side stroke width in the plugins changelog Note the new Stroke members, the restored strokeWidth token property and the shape-type validation in plugins/CHANGELOG.md. AI-assisted-by: deepseek-v4.1-flash * 🐛 Fix some minor issues --------- Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
265 lines
12 KiB
Clojure
265 lines
12 KiB
Clojure
;; 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.plugins.parser-test
|
|
(:require
|
|
[app.common.geom.point :as gpt]
|
|
[app.common.schema :as sm]
|
|
[app.common.types.grid :as ctg]
|
|
[app.common.types.shape.interactions :as ctsi]
|
|
[app.common.uuid :as uuid]
|
|
[app.plugins.format :as format]
|
|
[app.plugins.parser :as parser]
|
|
[app.plugins.strokes :as strokes]
|
|
[cljs.test :as t :include-macros true]))
|
|
|
|
(defn- overlay-action
|
|
[{:keys [type destination position manual-position-location]}]
|
|
(let [action (js-obj "type" type
|
|
"destination" (js-obj "$id" destination))]
|
|
(when (some? position)
|
|
(unchecked-set action "position" position))
|
|
(when (some? manual-position-location)
|
|
(unchecked-set action "manualPositionLocation" manual-position-location))
|
|
action))
|
|
|
|
(defn- parse-overlay-interaction
|
|
[action]
|
|
(parser/parse-interaction "click" (overlay-action action) nil))
|
|
|
|
(defn- valid-interaction?
|
|
[interaction]
|
|
(sm/validate ctsi/schema:interaction interaction))
|
|
|
|
(t/deftest test-parse-point-returns-gpt-point-record
|
|
;; Regression test for issue #8409.
|
|
;;
|
|
;; The plugin parser used to return a plain map `{:x … :y …}`, but the
|
|
;; shape-interaction schema expects `::gpt/point` (a Point record).
|
|
;; Plugin `addInteraction` calls with an `open-overlay` action and
|
|
;; `manualPositionLocation` were silently rejected by validation.
|
|
(t/testing "parse-point returns nil for nil input"
|
|
(t/is (nil? (parser/parse-point nil))))
|
|
|
|
(t/testing "parse-point returns a gpt/point record for valid input"
|
|
(let [result (parser/parse-point #js {:x 10 :y 20})]
|
|
(t/is (gpt/point? result))
|
|
(t/is (= 10 (:x result)))
|
|
(t/is (= 20 (:y result)))))
|
|
|
|
(t/testing "parse-point passes gpt/point? for a zero point"
|
|
(let [result (parser/parse-point #js {:x 0 :y 0})]
|
|
(t/is (gpt/point? result))
|
|
(t/is (= 0 (:x result)))
|
|
(t/is (= 0 (:y result))))))
|
|
|
|
(t/deftest test-parse-frame-guide-calls-guide-parser
|
|
(let [column (parser/parse-frame-guide
|
|
#js {:type "column"
|
|
:display true
|
|
:params #js {:type "stretch"
|
|
:size 12}})
|
|
row (parser/parse-frame-guide
|
|
#js {:type "row"
|
|
:display false
|
|
:params #js {:type "center"
|
|
:margin 4}})]
|
|
(t/is (= :column (:type column)))
|
|
(t/is (= true (:display column)))
|
|
(t/is (= :stretch (get-in column [:params :type])))
|
|
(t/is (= :row (:type row)))
|
|
(t/is (= false (:display row)))
|
|
(t/is (= :center (get-in row [:params :type])))))
|
|
|
|
(t/deftest test-parse-frame-guides
|
|
;; Regression test for issue #9773.
|
|
;;
|
|
;; `parse-frame-guide` returned the parser fns for column/row instead of
|
|
;; calling them with the guide, and the `board.guides` setter validated
|
|
;; against an unregistered `::ctg/grid` reference (now `ctg/schema:grid`).
|
|
;; Parsed guides must be plain maps that validate against the same direct
|
|
;; schema the setter uses, and clearing (empty input) must validate too.
|
|
(let [column #js {:type "column" :display true
|
|
:params #js {:color #js {:color "#DE4762" :opacity 0.2}
|
|
:type "stretch" :size 12 :gutter 16 :margin 16}}
|
|
square #js {:type "square" :display true
|
|
:params #js {:color #js {:color "#DE4762" :opacity 0.2} :size 8}}
|
|
parsed (parser/parse-frame-guides #js [column square])]
|
|
(t/is (= :column (-> parsed first :type)))
|
|
(t/is (= :square (-> parsed second :type)))
|
|
(t/is (map? (-> parsed first :params)))
|
|
(t/is (sm/validate [:vector ctg/schema:grid] parsed)))
|
|
|
|
(t/testing "clearing guides with an empty vector validates"
|
|
(t/is (sm/validate [:vector ctg/schema:grid] (parser/parse-frame-guides #js [])))))
|
|
|
|
(t/deftest test-parse-overlay-action-position-is-optional
|
|
(t/testing "open-overlay defaults omitted position to center"
|
|
(let [destination (uuid/next)
|
|
result (parse-overlay-interaction {:type "open-overlay"
|
|
:destination destination})]
|
|
(t/is (= :open-overlay (:action-type result)))
|
|
(t/is (= :click (:event-type result)))
|
|
(t/is (= destination (:destination result)))
|
|
(t/is (= :center (:overlay-pos-type result)))
|
|
(t/is (not (contains? result :overlay-position)))
|
|
(t/is (valid-interaction? result))))
|
|
|
|
(t/testing "toggle-overlay preserves manualPositionLocation"
|
|
(let [destination (uuid/next)
|
|
result (parse-overlay-interaction
|
|
{:type "toggle-overlay"
|
|
:destination destination
|
|
:position "manual"
|
|
:manual-position-location #js {:x 10 :y 20}})
|
|
position (:overlay-position result)]
|
|
(t/is (= :toggle-overlay (:action-type result)))
|
|
(t/is (= :manual (:overlay-pos-type result)))
|
|
(t/is (gpt/point? position))
|
|
(t/is (= 10 (:x position)))
|
|
(t/is (= 20 (:y position)))
|
|
(t/is (valid-interaction? result))))
|
|
|
|
(t/testing "explicit center position does not require manualPositionLocation"
|
|
(let [destination (uuid/next)
|
|
result (parse-overlay-interaction {:type "open-overlay"
|
|
:destination destination
|
|
:position "center"})]
|
|
(t/is (= :center (:overlay-pos-type result)))
|
|
(t/is (not (contains? result :overlay-position)))
|
|
(t/is (valid-interaction? result))))
|
|
|
|
(t/testing "manual position without manualPositionLocation still parses"
|
|
(let [destination (uuid/next)
|
|
result (parse-overlay-interaction {:type "open-overlay"
|
|
:destination destination
|
|
:position "manual"})]
|
|
(t/is (= :manual (:overlay-pos-type result)))
|
|
(t/is (not (contains? result :overlay-position)))
|
|
(t/is (valid-interaction? result)))))
|
|
|
|
(t/deftest test-parse-close-overlay-without-animation-validates
|
|
(t/testing "close-overlay without animation parses and validates"
|
|
(let [result (parser/parse-interaction "click" #js {:type "close-overlay"} nil)]
|
|
(t/is (= {:event-type :click
|
|
:action-type :close-overlay}
|
|
result))
|
|
(t/is (false? (contains? result :animation)))
|
|
(t/is (true? (sm/validate ctsi/schema:interaction result)))))
|
|
|
|
(t/testing "close-overlay preserves destination without animation"
|
|
(let [destination-id (uuid/next)
|
|
result (parser/parse-interaction
|
|
"click"
|
|
#js {:type "close-overlay"
|
|
:destination #js {"$id" destination-id}}
|
|
nil)]
|
|
(t/is (= destination-id (:destination result)))
|
|
(t/is (false? (contains? result :animation)))
|
|
(t/is (true? (sm/validate ctsi/schema:interaction result)))))
|
|
|
|
(t/testing "close-overlay preserves an explicit dissolve animation"
|
|
(let [result (parser/parse-interaction
|
|
"click"
|
|
#js {:type "close-overlay"
|
|
:animation #js {:type "dissolve"
|
|
:duration 300
|
|
:easing "linear"}}
|
|
nil)]
|
|
(t/is (= {:animation-type :dissolve
|
|
:duration 300
|
|
:easing :linear}
|
|
(:animation result)))
|
|
(t/is (true? (sm/validate ctsi/schema:interaction result))))))
|
|
|
|
|
|
(t/deftest test-parse-id-treats-blank-as-absent
|
|
;; `""` is truthy in ClojureScript, so an unguarded blank id reaches
|
|
;; `uuid/parse`. Plugins pass ids straight from JS, where an absent
|
|
;; value is routinely an empty string.
|
|
(t/testing "nil is absent"
|
|
(t/is (nil? (parser/parse-id nil))))
|
|
|
|
(t/testing "an empty string is absent"
|
|
(t/is (nil? (parser/parse-id ""))))
|
|
|
|
(t/testing "a whitespace-only string is absent"
|
|
(t/is (nil? (parser/parse-id " "))))
|
|
|
|
(t/testing "a valid uuid string is parsed"
|
|
(let [id (uuid/next)]
|
|
(t/is (= id (parser/parse-id (str id))))))
|
|
|
|
(t/testing "a malformed id raises, so plugin bugs stay visible"
|
|
(t/is (thrown? js/Error (parser/parse-id "not-a-uuid")))))
|
|
|
|
(t/deftest test-parse-stroke-maps-per-side-widths
|
|
(let [stroke (parser/parse-stroke
|
|
#js {:strokeColor "#000000"
|
|
:strokeWidth 1
|
|
:strokeWidthTop 2
|
|
:strokeWidthRight 3
|
|
:strokeWidthBottom 4
|
|
:strokeWidthLeft 5})]
|
|
(t/is (= 2 (:stroke-width-top stroke)))
|
|
(t/is (= 3 (:stroke-width-right stroke)))
|
|
(t/is (= 4 (:stroke-width-bottom stroke)))
|
|
(t/is (= 5 (:stroke-width-left stroke))))
|
|
|
|
(t/testing "a uniform literal initializes all sides in simple mode"
|
|
(let [stroke (parser/parse-stroke #js {:strokeColor "#000000" :strokeWidth 1})]
|
|
(t/is (= "simple" (aget (format/format-stroke stroke) "strokeWidthType")))
|
|
(t/is (= 1 (:stroke-width-top stroke)))
|
|
(t/is (= 1 (:stroke-width-left stroke))))))
|
|
|
|
(t/deftest test-parse-stroke-applies-uniform-width-before-sides
|
|
(let [stroke (parser/parse-stroke
|
|
#js {:strokeColor "#000000"
|
|
:strokeWidthLeft 8
|
|
:strokeWidthBottom 8
|
|
:strokeWidthRight 8
|
|
:strokeWidthTop 8
|
|
:strokeWidth 1})]
|
|
(t/is (= "simple" (aget (format/format-stroke stroke) "strokeWidthType")))
|
|
(t/is (= 8 (:stroke-width stroke)))
|
|
(t/is (= [8 8 8 8]
|
|
(mapv stroke [:stroke-width-top :stroke-width-right :stroke-width-bottom :stroke-width-left])))))
|
|
|
|
(t/deftest test-parse-stroke-top-preserves-other-widths
|
|
(let [stroke (parser/parse-stroke #js {:strokeColor "#000000" :strokeWidth 5 :strokeWidthTop 9})]
|
|
(t/is (= [9 5 5 5]
|
|
(mapv stroke [:stroke-width-top :stroke-width-right :stroke-width-bottom :stroke-width-left])))))
|
|
|
|
(t/deftest test-stroke-proxy-mode-transitions
|
|
(let [^js stroke (strokes/stroke-proxy {:stroke-color "#000000" :stroke-width 1} (fn [_] nil))]
|
|
(t/is (= "simple" (.-strokeWidthType stroke)))
|
|
(set! (.-strokeWidthTop stroke) 8)
|
|
(t/is (= "multiple" (.-strokeWidthType stroke)))
|
|
(t/is (= 1 (.-strokeWidthRight stroke)))
|
|
(set! (.-strokeWidthRight stroke) 8)
|
|
(set! (.-strokeWidthBottom stroke) 8)
|
|
(set! (.-strokeWidthLeft stroke) 8)
|
|
(t/is (= "simple" (.-strokeWidthType stroke)))
|
|
(t/is (= 8 (.-strokeWidth stroke)))
|
|
(set! (.-strokeColor stroke) "#ff0000")
|
|
(t/is (not (contains? (parser/parse-stroke stroke) :stroke-width-type)))
|
|
(set! (.-strokeWidth stroke) 3)
|
|
(t/is (= "simple" (.-strokeWidthType stroke)))
|
|
(t/is (= [3 3 3 3]
|
|
(mapv #(aget stroke %) ["strokeWidthTop" "strokeWidthRight" "strokeWidthBottom" "strokeWidthLeft"])))
|
|
(set! (.-strokeWidthRight stroke) 9)
|
|
(t/is (= "multiple" (.-strokeWidthType stroke)))
|
|
(set! (.-strokeWidth stroke) 3)
|
|
(t/is (= [3 3 3 3]
|
|
(mapv #(aget stroke %) ["strokeWidthTop" "strokeWidthRight" "strokeWidthBottom" "strokeWidthLeft"])))
|
|
(t/is (= "simple" (.-strokeWidthType stroke)))))
|
|
|
|
(t/deftest test-stroke-mode-round-trips-through-format
|
|
(doseq [right [8 12]]
|
|
(let [stroke (parser/parse-stroke
|
|
#js {:strokeColor "#000000" :strokeWidth 8 :strokeWidthRight right})]
|
|
(t/is (= stroke (parser/parse-stroke (format/format-stroke stroke)))))))
|