diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index bc52e63736..8d2aec1597 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -1057,67 +1057,123 @@ (cfh/get-children-with-self (:objects page) (:id root)) (cfh/get-parents-with-self objects (:parent-id shape)))))) +(def component-swap-layout-ids-type + "Companion DataEvent type emitted by `component-swap` when `:skip-layout?` + is set. Batch callers filter this exact keyword (libraries ns) — never a + bare `::component-swap-layout-ids` in another namespace." + ::component-swap-layout-ids) + +(def ^:private component-swap-batch-done-type + ::component-swap-batch-done) + +(defn coalesce-skipped-layout + "While `swap-source` runs, collect companion layout-id DataEvents from + `stream`, then emit one `:layout/update` with the union. + + `swap-source` must emit WatchEvents already configured with + `:skip-layout? true`. Uses a done marker (not `(rx/take (count …))`) so + no-op swaps cannot hang the fold. Reads the buffer inside `rx/defer` so + the id set is not frozen empty when `watch` returns." + [stream swap-source] + (let [layout-acc (volatile! [])] + (rx/concat + (rx/merge + (->> stream + (rx/take-until (rx/filter (ptk/type? component-swap-batch-done-type) stream)) + (rx/filter (ptk/type? component-swap-layout-ids-type)) + (rx/tap (fn [ev] + (vswap! layout-acc into (:ids (deref ev))))) + (rx/ignore)) + (rx/concat + swap-source + (rx/of (ptk/data-event component-swap-batch-done-type {})))) + (rx/defer + #(rx/of (ptk/data-event :layout/update {:ids @layout-acc})))))) + (defn component-swap - "Swaps a component with another one" - [shape file-id id-new-component keep-touched?] - (dm/assert! (uuid? id-new-component)) - (dm/assert! (uuid? file-id)) - (ptk/reify ::component-swap - ptk/WatchEvent - (watch [it state _] - ;; First delete shapes so we have space in the layout otherwise we can have problems - ;; in the grid creating new rows/columns to make space - (let [libraries (dsh/lookup-libraries state) - page (dsh/lookup-page state) - objects (:objects page) - parent (get objects (:parent-id shape)) + "Swaps a component with another one. - ldata (dsh/lookup-file-data state file-id) - orig-shapes (when keep-touched? (cfh/get-children-with-self objects (:id shape))) + Optional `opts`: + - `:skip-select?` — do not emit `select-shape` after the swap (batch callers + restore selection once). + - `:skip-layout?` — do not emit `:layout/update`; emit a companion + `component-swap-layout-ids-type` DataEvent instead so the batch can + coalesce one layout update." + ([shape file-id id-new-component keep-touched?] + (component-swap shape file-id id-new-component keep-touched? nil)) + ([shape file-id id-new-component keep-touched? + {:keys [skip-select? skip-layout?] :or {skip-select? false skip-layout? false}}] + (dm/assert! (uuid? id-new-component)) + (dm/assert! (uuid? file-id)) + (ptk/reify ::component-swap + ptk/WatchEvent + (watch [it state _] + ;; First delete shapes so we have space in the layout otherwise we can have problems + ;; in the grid creating new rows/columns to make space + (let [libraries (dsh/lookup-libraries state) + page (dsh/lookup-page state) + objects (:objects page)] + ;; Re-read from live state: sibling swaps / layout can move this head. + ;; Gone from objects → no-op (do not swap a closed-over stale shape). + (if-let [shape (get objects (:id shape))] + (let [parent (get objects (:parent-id shape)) - ;; If the target parent is a grid layout we need to pass the target cell - target-cell (when (ctsl/grid-layout? parent) - (ctsl/get-cell-by-shape-id parent (:id shape))) + ldata (dsh/lookup-file-data state file-id) + orig-shapes (when keep-touched? (cfh/get-children-with-self objects (:id shape))) - index (find-shape-index objects (:parent-id shape) (:id shape)) + ;; If the target parent is a grid layout we need to pass the target cell + target-cell (when (ctsl/grid-layout? parent) + (ctsl/get-cell-by-shape-id parent (:id shape))) - ;; Store the properties that need to be maintained when the component is swapped - keep-props-values (select-keys shape ctk/swap-keep-attrs) + index (find-shape-index objects (:parent-id shape) (:id shape)) - undo-id (js/Symbol) - undo-group (uuid/next) + ;; Store the properties that need to be maintained when the component is swapped + keep-props-values (select-keys shape ctk/swap-keep-attrs) - [new-shape all-parents changes] - (-> (pcb/empty-changes it (:id page)) - (pcb/set-undo-group undo-group) - (cll/generate-component-swap objects shape ldata page libraries id-new-component - index target-cell keep-props-values keep-touched?)) + undo-id (js/Symbol) + undo-group (uuid/next) - updated-objects (pcb/get-objects changes) - new-children-ids (cfh/get-children-ids-with-self updated-objects (:id new-shape)) - new-text-ids (->> new-children-ids - (keep (fn [id] - (when-let [child (get updated-objects id)] - (when (and (cfh/text-shape? child) - (not= :fixed (:grow-type child))) - id)))) - (vec)) + [new-shape all-parents changes] + (-> (pcb/empty-changes it (:id page)) + (pcb/set-undo-group undo-group) + (cll/generate-component-swap objects shape ldata page libraries id-new-component + index target-cell keep-props-values keep-touched?)) - [changes parents-of-swapped] - (if keep-touched? - (clv/generate-keep-touched changes new-shape shape orig-shapes page libraries ldata) - [changes []]) - update-layout-ids (concat all-parents parents-of-swapped new-children-ids)] - (rx/of - (dwu/start-undo-transaction undo-id) - (dch/commit-changes changes) - (dwtp/propagate-workspace-tokens) ;; Make the new instance get the token values from the current file, not from the component's library - (when (and (features/active-feature? state "render-wasm/v1") - (seq new-text-ids)) - (dwwt/resize-wasm-text-all new-text-ids {:skip-component-sync? true})) - (ptk/data-event :layout/update {:ids update-layout-ids :undo-group undo-group}) - (dwu/commit-undo-transaction undo-id) - (dws/select-shape (:id new-shape) false)))))) + updated-objects (pcb/get-objects changes) + new-children-ids (cfh/get-children-ids-with-self updated-objects (:id new-shape)) + new-text-ids (->> new-children-ids + (keep (fn [id] + (when-let [child (get updated-objects id)] + (when (and (cfh/text-shape? child) + (not= :fixed (:grow-type child))) + id)))) + (vec)) + + [changes parents-of-swapped] + (if keep-touched? + (clv/generate-keep-touched changes new-shape shape orig-shapes page libraries ldata) + [changes []]) + update-layout-ids (concat all-parents parents-of-swapped new-children-ids)] + (cond-> (rx/of + (dwu/start-undo-transaction undo-id) + (dch/commit-changes changes) + ;; Make the new instance get the token values from the current file, + ;; not from the component's library + (dwtp/propagate-workspace-tokens) + (when (and (features/active-feature? state "render-wasm/v1") + (seq new-text-ids)) + (dwwt/resize-wasm-text-all new-text-ids {:skip-component-sync? true}))) + (not skip-layout?) + (rx/concat (rx/of (ptk/data-event :layout/update + {:ids update-layout-ids :undo-group undo-group}))) + skip-layout? + (rx/concat (rx/of (ptk/data-event component-swap-layout-ids-type + {:ids update-layout-ids}))) + true + (rx/concat (rx/of (dwu/commit-undo-transaction undo-id))) + (not skip-select?) + (rx/concat (rx/of (dws/select-shape (:id new-shape) false))))) + (rx/empty))))))) (defn component-multi-swap "Swaps several components with another one" @@ -1131,17 +1187,21 @@ {::ev/name "component-swap"}) ptk/WatchEvent - (watch [_ state _] - (let [undo-id (js/Symbol)] + (watch [_ state stream] + (let [undo-id (js/Symbol) + swap-opts {:skip-select? true :skip-layout? true}] (log/info :msg "COMPONENT-SWAP" :file (debug-pretty-file file-id state) :id-new-component id-new-component :undo-id undo-id) (rx/concat (rx/of (dwu/start-undo-transaction undo-id)) - (rx/map #(component-swap % file-id id-new-component false) (rx/from shapes)) - (rx/of (dwu/commit-undo-transaction undo-id)) - (rx/of (dwsp/open-specialized-panel :component-swap))))))) + (coalesce-skipped-layout + stream + (->> (rx/from shapes) + (rx/map #(component-swap % file-id id-new-component false swap-opts)))) + (rx/of (dwu/commit-undo-transaction undo-id) + (dwsp/open-specialized-panel :component-swap))))))) (def valid-asset-types #{:colors :components :typographies}) diff --git a/frontend/src/app/main/data/workspace/variants.cljs b/frontend/src/app/main/data/workspace/variants.cljs index 11aa37c85c..46730ee112 100644 --- a/frontend/src/app/main/data/workspace/variants.cljs +++ b/frontend/src/app/main/data/workspace/variants.cljs @@ -780,60 +780,72 @@ (rx/of (combine-as-variants selected options)))))) (defn- variant-switch - "Switch the shape (that must be a variant copy head) for the closest one with the property value passed as parameter" - [shape {:keys [pos val] :as params}] + "Switch the shape (that must be a variant copy head) for the closest one with the property value passed as parameter. + + `params` may include `:swap-opts` forwarded to `component-swap` (e.g. + `{:skip-select? true :skip-layout? true}` for a multi batch)." + [shape {:keys [pos val swap-opts] :as params}] (ptk/reify ::variant-switch ptk/WatchEvent (watch [it state _] - (let [libraries (dsh/lookup-libraries state) - component-id (:component-id shape) - component (ctf/get-component libraries (:component-file shape) component-id :include-deleted? false)] - ;; If the value is already val, do nothing - (when (not= val (dm/get-in component [:variant-properties pos :value])) - (let [current-page-objects (dsh/lookup-page-objects state) - variant-id (:variant-id component) - component-file-data (dm/get-in libraries [(:component-file shape) :data]) - component-page-objects (-> (dsh/get-page component-file-data (:main-instance-page component)) - (get :objects)) - variant-comps (cfv/find-variant-components component-file-data component-page-objects variant-id) - target-props (-> (:variant-properties component) - (update pos assoc :value val)) - valid-comps (->> variant-comps - (remove #(= (:id %) component-id)) - (filter #(= (dm/get-in % [:variant-properties pos :value]) val)) - (reverse)) - nearest-comp (apply min-key #(ctv/distance target-props (:variant-properties %)) valid-comps) - shape-parents (cfh/get-parents-with-self current-page-objects (:parent-id shape)) - nearest-comp-children (cfh/get-children-with-self component-page-objects (:main-instance-id nearest-comp)) - comps-nesting-loop? (seq? (cfh/components-nesting-loop? nearest-comp-children shape-parents)) + (let [objects (dsh/lookup-page-objects state)] + ;; Sibling swaps / layout can move this head before our turn. + ;; Gone from objects → no-op (do not switch a closed-over stale shape). + (if-let [shape (get objects (:id shape))] + (let [libraries (dsh/lookup-libraries state) + component-id (:component-id shape) + component (ctf/get-component libraries (:component-file shape) component-id :include-deleted? false)] + ;; If the value is already val, do nothing + (if (not= val (dm/get-in component [:variant-properties pos :value])) + (let [variant-id (:variant-id component) + component-file-data (dm/get-in libraries [(:component-file shape) :data]) + component-page-objects (-> (dsh/get-page component-file-data (:main-instance-page component)) + (get :objects)) + variant-comps (cfv/find-variant-components component-file-data component-page-objects variant-id) + target-props (-> (:variant-properties component) + (update pos assoc :value val)) + valid-comps (->> variant-comps + (remove #(= (:id %) component-id)) + (filter #(= (dm/get-in % [:variant-properties pos :value]) val)) + (reverse)) + nearest-comp (apply min-key #(ctv/distance target-props (:variant-properties %)) valid-comps) + shape-parents (cfh/get-parents-with-self objects (:parent-id shape)) + nearest-comp-children (cfh/get-children-with-self component-page-objects (:main-instance-id nearest-comp)) + comps-nesting-loop? (seq? (cfh/components-nesting-loop? nearest-comp-children shape-parents)) - {:keys [on-error] - :or {on-error rx/throw}} (meta params)] + {:keys [on-error] + :or {on-error rx/throw}} (meta params)] - ;; If there is no nearest-comp, do nothing - (when nearest-comp - (if comps-nesting-loop? - (do - (on-error) - (rx/empty)) - (rx/of - (dwl/component-swap shape (:component-file shape) (:id nearest-comp) true) - (ev/event (-> {::ev/name "variant-switch" ::ev/origin "workspace:design-tab"} - (merge (meta it))))))))))))) + ;; If there is no nearest-comp, do nothing + (if nearest-comp + (if comps-nesting-loop? + (do + (on-error) + (rx/empty)) + (rx/of + (dwl/component-swap shape (:component-file shape) (:id nearest-comp) true swap-opts) + (ev/event (-> {::ev/name "variant-switch" ::ev/origin "workspace:design-tab"} + (merge (meta it)))))) + (rx/empty))) + (rx/empty))) + (rx/empty)))))) (defn variants-switch "Switch each shape (that must be a variant copy head) for the closest one with the property value passed as parameter" [{:keys [shapes] :as params}] (ptk/reify ::variants-switch ptk/WatchEvent - (watch [it _ _] - (let [ids (into (d/ordered-set) d/xf:map-id shapes) - undo-id (js/Symbol)] + (watch [it _ stream] + (let [ids (into (d/ordered-set) d/xf:map-id shapes) + undo-id (js/Symbol) + swap-opts {:skip-select? true :skip-layout? true}] (rx/concat (rx/of (dwu/start-undo-transaction undo-id)) - (->> (rx/from shapes) - (rx/map (fn [data] - (-> (variant-switch data params) - (with-meta (meta it)))))) + (dwl/coalesce-skipped-layout + stream + (->> (rx/from shapes) + (rx/map (fn [shape] + (-> (variant-switch shape (assoc params :swap-opts swap-opts)) + (with-meta (meta it))))))) (rx/of (dwu/commit-undo-transaction undo-id) (dws/select-shapes ids))))))) diff --git a/frontend/test/frontend_tests/logic/multi_variant_switch_test.cljs b/frontend/test/frontend_tests/logic/multi_variant_switch_test.cljs new file mode 100644 index 0000000000..3bcd56d3a5 --- /dev/null +++ b/frontend/test/frontend_tests/logic/multi_variant_switch_test.cljs @@ -0,0 +1,254 @@ +;; 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.logic.multi-variant-switch-test + "Regression tests for #12829: multi-select variants-switch / + component-multi-swap must not emit one select-shape per copy, and must + coalesce skipped per-swap layout into one :layout/update." + (:require + [app.common.data :as d] + [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.uuid :as uuid] + [app.main.data.workspace.libraries :as dwl] + [app.main.data.workspace.selection :as-alias dws] + [app.main.data.workspace.variants :as dwv] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.pages :as thp] + [frontend-tests.helpers.state :as ths] + [potok.v2.core :as ptk])) + +(t/use-fixtures :each + {:before thp/reset-idmap!}) + +(def ^:private fill-value1 "#2152e5") +(def ^:private fill-value2 "#e52152") + +(defn- count-events + "Atom counting how many events of `type` land on the store input-stream + (nested WatchEvents only appear there — not on a bare ptk/watch of + variants-switch)." + [store type] + (let [counter (atom 0)] + (->> (ptk/input-stream store) + (rx/filter (ptk/type? type)) + (rx/tap (fn [_] (swap! counter inc))) + (rx/subs! (fn [_] nil))) + counter)) + +(defn- collect-layout-updates + "Atom accumulating deref'd `:layout/update` payloads from the store + input-stream (same subscribe-before-emit pattern as `count-events`)." + [store] + (let [acc (atom [])] + (->> (ptk/input-stream store) + (rx/filter (ptk/type? :layout/update)) + (rx/tap #(swap! acc conj (deref %))) + (rx/subs! (fn [_] nil))) + acc)) + +(defn- assert-coalesced-layout + [layout-updates flex-board-id] + (t/is (= 1 (count @layout-updates)) + (str "expected one coalesced :layout/update; got " + (count @layout-updates))) + (let [ids (set (:ids (first @layout-updates)))] + (t/is (seq ids) + "coalesced :layout/update must carry non-empty :ids") + (t/is (contains? ids flex-board-id) + (str "coalesced :ids must include flex board; got " ids)))) + +(defn- fill-color + [shape] + (-> shape :fills first :fill-color)) + +(defn- setup-flex-variant-file + "Two variant members with distinct fills and sizes." + [] + (-> (cthf/sample-file :file1) + (cthv/add-variant :v01 :c01 :m01 :c02 :m02 + :variant1-params {:fills (cths/sample-fills-color + :fill-color fill-value1) + :width 40 + :height 40} + :variant2-params {:fills (cths/sample-fills-color + :fill-color fill-value2) + :width 50 + :height 50}) + (ctho/add-frame :flex-board + :name "Toggle Group" + :layout :flex + :layout-flex-dir :row + :width 400 + :height 80))) + +(defn- setup-flex-multi-copies + "Three Value1 copies as children of a flex board." + [] + (-> (setup-flex-variant-file) + (cthc/instantiate-component :c01 :copy1 :parent-label :flex-board) + (cthc/instantiate-component :c01 :copy2 :parent-label :flex-board) + (cthc/instantiate-component :c01 :copy3 :parent-label :flex-board))) + +(defn- setup-flex-mixed-copies + "Two Value1 copies and one already on Value2 (no-op on switch to Value2)." + [] + (-> (setup-flex-variant-file) + (cthc/instantiate-component :c01 :copy1 :parent-label :flex-board) + (cthc/instantiate-component :c01 :copy2 :parent-label :flex-board) + (cthc/instantiate-component :c02 :copy3 :parent-label :flex-board))) + +(t/deftest multi-variants-switch-emits-no-intermediate-select-shape + (t/async + done + (let [file (setup-flex-multi-copies) + store (ths/setup-store file) + copy1 (cths/get-shape file :copy1) + copy2 (cths/get-shape file :copy2) + copy3 (cths/get-shape file :copy3) + flex-board-id (:id (cths/get-shape file :flex-board)) + target-comp-id (cthi/id :c02) + copy-ids (d/ordered-set (:id copy1) (:id copy2) (:id copy3)) + select-shape-n (count-events store ::dws/select-shape) + layout-updates (collect-layout-updates store) + events + [(dwv/variants-switch {:shapes [copy1 copy2 copy3] + :pos 0 + :val "Value2"})]] + + (ths/run-store + store done events + (fn [new-state] + (let [file' (ths/get-file-from-state new-state) + copy1' (cths/get-shape file' :copy1) + copy2' (cths/get-shape file' :copy2) + copy3' (cths/get-shape file' :copy3) + selected (get-in new-state [:workspace-local :selected])] + + (t/is (zero? @select-shape-n) + (str "multi variants-switch must not emit intermediate " + "select-shape; got " @select-shape-n)) + (assert-coalesced-layout layout-updates flex-board-id) + + (t/is (= target-comp-id (:component-id copy1'))) + (t/is (= target-comp-id (:component-id copy2'))) + (t/is (= target-comp-id (:component-id copy3'))) + (t/is (= fill-value2 (fill-color copy1'))) + (t/is (= fill-value2 (fill-color copy2'))) + (t/is (= fill-value2 (fill-color copy3'))) + (t/is (= (set copy-ids) selected)))))))) + +;; Done-marker collection must not hang when some selected copies already +;; have the target value (no companion from those no-ops). +(t/deftest multi-variants-switch-coalesces-layout-when-some-noop + (t/async + done + (let [file (setup-flex-mixed-copies) + store (ths/setup-store file) + copy1 (cths/get-shape file :copy1) + copy2 (cths/get-shape file :copy2) + copy3 (cths/get-shape file :copy3) + flex-board-id (:id (cths/get-shape file :flex-board)) + target-comp-id (cthi/id :c02) + copy-ids (d/ordered-set (:id copy1) (:id copy2) (:id copy3)) + select-shape-n (count-events store ::dws/select-shape) + layout-updates (collect-layout-updates store) + events + [(dwv/variants-switch {:shapes [copy1 copy2 copy3] + :pos 0 + :val "Value2"})]] + + (ths/run-store + store done events + (fn [new-state] + (let [file' (ths/get-file-from-state new-state) + copy1' (cths/get-shape file' :copy1) + copy2' (cths/get-shape file' :copy2) + copy3' (cths/get-shape file' :copy3) + selected (get-in new-state [:workspace-local :selected])] + + (t/is (zero? @select-shape-n)) + (assert-coalesced-layout layout-updates flex-board-id) + + (t/is (= target-comp-id (:component-id copy1'))) + (t/is (= target-comp-id (:component-id copy2'))) + (t/is (= target-comp-id (:component-id copy3'))) + (t/is (= fill-value2 (fill-color copy1'))) + (t/is (= fill-value2 (fill-color copy2'))) + (t/is (= fill-value2 (fill-color copy3'))) + (t/is (= (set copy-ids) selected)))))))) + +(t/deftest component-multi-swap-skips-select-and-coalesces-layout + (t/async + done + (let [file (setup-flex-multi-copies) + store (ths/setup-store file) + copy1 (cths/get-shape file :copy1) + copy2 (cths/get-shape file :copy2) + copy3 (cths/get-shape file :copy3) + flex-board-id (:id (cths/get-shape file :flex-board)) + target-comp-id (cthi/id :c02) + select-shape-n (count-events store ::dws/select-shape) + layout-updates (collect-layout-updates store) + events + [(dwl/component-multi-swap [copy1 copy2 copy3] + (:id file) + target-comp-id)]] + + (ths/run-store + store done events + (fn [new-state] + (let [file' (ths/get-file-from-state new-state) + copy1' (cths/get-shape file' :copy1) + copy2' (cths/get-shape file' :copy2) + copy3' (cths/get-shape file' :copy3)] + + (t/is (zero? @select-shape-n) + (str "component-multi-swap must not emit intermediate " + "select-shape; got " @select-shape-n)) + (assert-coalesced-layout layout-updates flex-board-id) + + (t/is (= target-comp-id (:component-id copy1'))) + (t/is (= target-comp-id (:component-id copy2'))) + (t/is (= target-comp-id (:component-id copy3'))) + (t/is (= fill-value2 (fill-color copy1'))) + (t/is (= fill-value2 (fill-color copy2'))) + (t/is (= fill-value2 (fill-color copy3'))))))))) + +(t/deftest component-swap-noop-when-shape-missing + (t/async + done + (let [file (setup-flex-multi-copies) + store (ths/setup-store file) + copy1 (cths/get-shape file :copy1) + ghost (assoc copy1 :id (uuid/next)) + before (ths/get-file-from-state @store)] + (ths/run-store + store done + [(dwl/component-swap ghost (:id file) (cthi/id :c02) true)] + (fn [new-state] + (t/is (= before (ths/get-file-from-state new-state)) + "component-swap must no-op when shape id is absent from objects")))))) + +(t/deftest variant-switch-noop-when-shape-missing + (t/async + done + (let [file (setup-flex-multi-copies) + store (ths/setup-store file) + copy1 (cths/get-shape file :copy1) + ghost (assoc copy1 :id (uuid/next)) + before (ths/get-file-from-state @store)] + (ths/run-store + store done + [(#'dwv/variant-switch ghost {:pos 0 :val "Value2"})] + (fn [new-state] + (t/is (= before (ths/get-file-from-state new-state)) + "variant-switch must no-op when shape id is absent from objects")))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 94188ea2b4..df1a89ef60 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -52,6 +52,7 @@ [frontend-tests.logic.copying-and-duplicating-test] [frontend-tests.logic.frame-guides-test] [frontend-tests.logic.groups-test] + [frontend-tests.logic.multi-variant-switch-test] [frontend-tests.logic.nudge-selected-shapes-test] [frontend-tests.logic.pasting-in-containers-test] [frontend-tests.logic.path-actions-test] @@ -204,6 +205,7 @@ 'frontend-tests.logic.copying-and-duplicating-test 'frontend-tests.logic.frame-guides-test 'frontend-tests.logic.groups-test + 'frontend-tests.logic.multi-variant-switch-test 'frontend-tests.logic.nudge-selected-shapes-test 'frontend-tests.logic.path-actions-test 'frontend-tests.logic.path-clipboard-test