penpot/frontend/test/frontend_tests/logic/multi_variant_switch_test.cljs
Pablo Alba f4a0d42363
🐛 Keep multi-select variant switches refreshing every instance (#12857)
* 🐛 Keep multi-select variant switches refreshing every instance

Multi variants-switch ended each swap with select-shape, which replaced
the selection, interrupted layout/WASM work, and navigated once per copy
so only one instance looked updated under flex. Skip per-swap select and
layout in the batch, re-read live geometry, coalesce layout ids via a
companion DataEvent, then restore selection once. The same path covers
Assets component-multi-swap. Frontend tests lock select-shape count,
layout coalesce, and mixed no-op selections.

Closes #12829

AI-assisted-by: Composer
2026-11-05 16:52:52 +01:00

255 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.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"))))))