penpot/frontend/test/frontend_tests/data/workspace_variants_test.cljs
alonso.torres a5ad3caffe 🐛 Check and commit plugin component calls as the UI does
- The UI offers swap and reset only on copy heads, and detach only on
  copies that are not inside another copy. The plugin's swapComponent
  and resetOverrides accepted any shape inside a copy (F92), and its
  detach any shape at all (F93), and sent the same events, whose
  changes expect a head. swapComponent and resetOverrides report "The
  shape is not a component copy head" for anything else; detach
  reports a shape that is not a copy, or a copy inside another copy,
  by ctn/detachable?, which the detach event checks too. swapComponent
  looked up the target file before checking its argument, so a value
  that is not a component failed an assert instead of reporting
  "Component not valid".
- The plugin's switchVariant did not check whether the switch would
  nest a component in itself. The switch event checks that and calls
  the :on-error of its params' metadata; the plugin passed none, so
  the default, rx/throw, built an error observable that nothing used,
  and the call did nothing and reported nothing (F53). The plugin
  passes an :on-error that reports "The switch would create a
  component nesting loop", and the event's default does nothing.
- Choosing a property value in the design tab with several members
  selected sent, for each member, an event that set the value and
  another that cleared its error, each its own commit and undo entry.
  The plugin's setVariantProperty set the value and left the error as
  it was (F94). dwv/update-property-value takes the components, and
  sets the value and clears the error of each in one commit; the
  design tab passes the selected members, the plugin its component,
  and the composable interpreter the one member of its edit.

shape-bugfixes-test checks each plugin case on the proxy, and
workspace-variants-test sets a value on two members with errors.

AI-assisted-by: claude-opus-5-5

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 00:35:55 +02:00

246 lines
10 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.data.workspace-variants-test
"The checks the workspace runs before combining components as variants
and before a variant switch, and the branches of the variant events that
the composable cases (`frontend-tests.composable-tests.comp.sync-test`)
do not reach: a combine on another page, a switch that would nest a
component in itself, the shortcuts with no variant selected and the
rename of a plain component."
(:require
[app.common.data :as d]
[app.common.logic.variants :as clv]
[app.common.test-helpers.components :as cthc]
[app.common.test-helpers.compositions :as ctho]
[app.common.test-helpers.files :as cthf]
[app.common.test-helpers.ids-map :as cthi]
[app.common.test-helpers.shapes :as cths]
[app.common.test-helpers.variants :as cthv]
[app.common.types.components-list :as ctkl]
[app.common.uuid :as uuid]
[app.main.data.common :as dcm]
[app.main.data.helpers :as dsh]
[app.main.data.workspace.selection :as dws]
[app.main.data.workspace.undo :as dwu]
[app.main.data.workspace.variants :as dwv]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.mock :as mock]
[frontend-tests.helpers.state :as ths]
[potok.v2.core :as ptk]))
(t/use-fixtures :each {:before cthi/reset-idmap!})
(defn- setup-file
"A variant set (members :m01 and :m02, each with a child), a copy of :m01,
two plain components and a copy of the first."
[]
(-> (cthf/sample-file :file1)
(cthv/add-variant-with-child :v01 :c01 :m01 :c02 :m02 :m01-child :m02-child)
(cthc/instantiate-component :c01 :copy01 :children-labels [:copy01-child])
(ctho/add-simple-component :plain1 :plain1-root :plain1-child)
(ctho/add-simple-component :plain2 :plain2-root :plain2-child)
(cthc/instantiate-component :plain1 :plain1-copy)))
(defn- state-of
[file]
@(ths/setup-store file))
(t/deftest valid-components-for-variants
(let [file (setup-file)
state (state-of file)
page-id (:current-page-id state)
valid? #(dwv/valid-components-for-variants? state page-id (mapv cthi/id %))]
(t/testing "two plain main roots"
(t/is (valid? [:plain1-root :plain2-root])))
(t/testing "fewer than two distinct components"
(t/is (not (valid? [:plain1-root])))
(t/is (not (valid? [:plain1-root :plain1-root]))))
(t/testing "a variant main"
(t/is (not (valid? [:plain1-root :m01]))))
(t/testing "a copy"
(t/is (not (valid? [:plain1-root :plain1-copy]))))
(t/testing "a page that is not the current one"
(t/is (not (dwv/valid-components-for-variants?
state (uuid/next) [(cthi/id :plain1-root) (cthi/id :plain2-root)]))))))
(t/deftest valid-variant-switch
(let [file (setup-file)
state (state-of file)
valid? (fn [label pos val]
(clv/valid-switch? (dsh/lookup-libraries state) (cths/get-shape file label) pos val))]
(t/testing "a variant copy head and a value some member holds"
(t/is (valid? :copy01 0 "Value2"))
(t/is (valid? :copy01 0 "Value1")))
(t/testing "a value no member holds"
(t/is (not (valid? :copy01 0 "Value3"))))
(t/testing "a position outside the properties"
(t/is (not (valid? :copy01 1 "Value2")))
(t/is (not (valid? :copy01 -1 "Value2"))))
(t/testing "a value that is not a string"
(t/is (not (valid? :copy01 0 2))))
(t/testing "not a variant copy head"
(t/is (not (valid? :m01 0 "Value2")))
(t/is (not (valid? :copy01-child 0 "Value2")))
(t/is (not (valid? :plain1-copy 0 "Value2"))))))
(defn- all-transactions-committed
"A `run-store` stopper: the first undo commit after which no transaction
is pending, for events that commit their transaction asynchronously."
[store]
(fn [stream]
(->> stream
(rx/filter (ptk/type? ::dwu/commit-undo-transaction))
(rx/filter #(empty? (get-in @store [:workspace-undo :transactions-pending]))))))
(defn- go-to-page
"Stands in for `dcm/go-to-workspace`, which needs the router: makes
`page-id` the current page and announces it as initialized."
[& {:keys [page-id]}]
(ptk/reify ::go-to-page
ptk/UpdateEvent
(update [_ state]
(assoc state :current-page-id page-id))
ptk/WatchEvent
(watch [_ _ _]
(rx/of (ptk/data-event :app.main.data.workspace.pages/initialize-page {})))))
(t/deftest combine-on-another-page
(t/async
done
(mock/with-mocks
{dcm/go-to-workspace go-to-page}
(fn [inner-done]
(let [file (cthf/sample-file :file1)
page-1 (cthf/current-page-id file)
file (-> file
(cthf/add-sample-page :page2)
(ctho/add-simple-component :plain1 :plain1-root :plain1-child)
(ctho/add-simple-component :plain2 :plain2-root :plain2-child)
(vary-meta assoc :current-page-id page-1))
store (ths/setup-store file)
ids [(cthi/id :plain1-root) (cthi/id :plain2-root)]]
(ths/run-store
store inner-done
[(dwv/combine-as-variants ids {:page-id (cthi/id :page2)
:trigger "plugin:combine-as-variants"})]
(fn [state]
(let [data (:data (ths/get-file-from-state state))
c1 (ctkl/get-component data (cthi/id :plain1))
c2 (ctkl/get-component data (cthi/id :plain2))]
(t/is (= (cthi/id :page2) (:current-page-id state)))
(t/is (some? (:variant-id c1)))
(t/is (= (:variant-id c1) (:variant-id c2)))))
(all-transactions-committed store))))
done)))
(t/deftest switch-into-a-nesting-loop
(t/async
done
(let [file (-> (cthf/sample-file :file1)
(cthv/add-variant :v01 :c01 :m01 :c02 :m02)
(cthc/instantiate-component :c01 :nested :parent-label :m02))
store (ths/setup-store file)
errors (atom 0)
params (with-meta {:shapes [(cths/get-shape file :nested)] :pos 0 :val "Value2"}
{:on-error #(swap! errors inc)})]
(ths/run-store
store done
[(dwv/variants-switch params)]
(fn [state]
(t/is (= 1 @errors))
(t/is (= (cthi/id :c01)
(:component-id (cths/get-shape (ths/get-file-from-state state) :nested)))))))))
(defn- object-count
[file]
(count (:objects (cthf/current-page file))))
(defn- run-selected
"Run `event` on a store for `file` with the shapes `labels` selected, then
`check` the resulting file."
[done file labels event check]
(ths/run-store
(ths/setup-store file) done
[(dws/select-shapes (into (d/ordered-set) (map cthi/id) labels)) event]
#(check (ths/get-file-from-state %))))
(defn- file-with-a-rect
[]
(-> (cthf/sample-file :file1)
(cths/add-sample-shape :rect)))
(t/deftest duplicate-shortcut-with-nothing-selected
(t/async
done
(let [file (file-with-a-rect)]
(run-selected done file [] (dwv/duplicate-or-add-variant)
#(t/is (= (object-count file) (object-count %)))))))
(t/deftest duplicate-shortcut-on-a-plain-shape
(t/async
done
(let [file (file-with-a-rect)]
(run-selected done file [:rect] (dwv/duplicate-or-add-variant)
#(t/is (= (inc (object-count file)) (object-count %)))))))
(t/deftest create-component-shortcut-on-a-plain-shape
(t/async
done
(run-selected done (file-with-a-rect) [:rect] (dwv/add-component-or-variant)
#(t/is (= 1 (count (ctkl/components-seq (:data %))))))))
(t/deftest rename-of-a-plain-component
(t/async
done
(let [file (-> (cthf/sample-file :file1)
(ctho/add-simple-component :plain1 :plain1-root :plain1-child))]
(ths/run-store
(ths/setup-store file) done
[(dwv/rename-comp-or-variant-and-main (cthi/id :plain1) "Renamed")]
(fn [state]
(let [file' (ths/get-file-from-state state)]
(t/is (= "Renamed" (:name (ctkl/get-component (:data file') (cthi/id :plain1)))))
(t/is (= "Renamed" (:name (cths/get-shape file' :plain1-root))))))))))
(t/deftest remove-empty-properties-of-a-set-with-many-properties
(t/async
done
(let [props (fn [value]
(mapv (fn [i]
{:name (str "Property " (inc i))
:value (if (= i 5) "" value)})
(range 10)))
file (-> (cthf/sample-file :file1)
(cthv/add-variant :v01 :c01 :m01 :c02 :m02)
(cthc/update-component :c01 {:variant-properties (props "a")})
(cthc/update-component :c02 {:variant-properties (props "b")}))]
(ths/run-store
(ths/setup-store file) done
[(dwv/remove-empty-properties (cthi/id :v01))]
(fn [state]
(let [data (:data (ths/get-file-from-state state))]
(t/is (= (remove #{"Property 6"} (map :name (props "a")))
(map :name (:variant-properties (ctkl/get-component data (cthi/id :c01))))))))))))
(t/deftest set-a-property-value-on-several-members
(t/async
done
(let [file (-> (setup-file)
(cths/update-shape :m01 :variant-error "Duplicated")
(cths/update-shape :m02 :variant-error "Duplicated"))]
(ths/run-store
(ths/setup-store file) done
[(dwv/update-property-value [(cthi/id :c01) (cthi/id :c02)] 0 "Same")]
(fn [state]
(let [file' (ths/get-file-from-state state)
value #(get-in (ctkl/get-component (:data file') (cthi/id %)) [:variant-properties 0 :value])]
(t/is (= ["Same" "Same"] [(value :c01) (value :c02)]))
(t/is (nil? (:variant-error (cths/get-shape file' :m01))))
(t/is (nil? (:variant-error (cths/get-shape file' :m02))))))))))