🐛 Fix the variant events and the plugin component calls

Fixes in the variant events of the workspace and in the component calls of
the plugin API (Phase 3c).

- Rename a variant in one commit.

  Renaming a variant renamed its board, then each of its components and
  mains, as separate commits inside an undo transaction. The transaction
  kept the undo group of its first commit, the board's, while the sync that
  the renamed mains started took the group of their commits and made an
  entry of its own: one undo reverted only the copies' names (F6, case AV).

  clv/generate-rename-variant builds the board, component and main renames
  in common, and the event commits them once, so the rename and its sync are
  one undo entry, like transform into a variant.

  Case AV no longer marks its round trip as a known failure; the frontend
  golden master records it without failures.

- Keep overrides in a plugin swap only within a variant set.

  The plugin's swapComponent swapped with keep-touched for any component,
  while the UI keeps a copy's overrides only on a variant switch: the same
  swap kept different overrides by route (F33). It now keeps them only when
  the copy's component and the target are variants of one set (decision
  (p)), as swap-keeps-touched? says.

  shape-bugfixes-test: two variants of one set keep the overrides; of
  different sets, or plain components, do not.

- Look up a plugin variant head's component in its own file.

  The plugin's isVariantHead looked the shape's component up in the file of
  the shape, so it was false for copies of a library's variants; and it
  looked it up before checking the shape is a head, so on a shape with no
  component it failed the lookup's assert (F91). It now checks the head
  first and looks in the shape's :component-file.

  shape-bugfixes-test: a copy of a library variant is a variant head, and a
  plain rect is not.

- Add a variant with the shortcut when a variant set is selected.

  The context menu of a variant container offers "Add variant" and shows the
  create-component shortcut for it, but the shortcut's event did not know
  containers: with one selected it fell through to creating a component,
  which the container's own rules refuse (F95). With a single variant
  container selected, the shortcut now adds a variant to it, as the menu
  entry does.

  workspace-variants-test: the shortcut on a variant container gives the set
  a third member.

- Refuse plugin variant edits whose variant is on another page.

  The variant events the plugin API calls (add a variant, add, remove or
  rename a property, set a property value) build their changes on the
  objects of the page the workspace shows. For a variant whose mains sit on
  another page, they updated the components but not their mains, so the
  mains kept their old :variant-name and the file failed validation (F94).
  The plugin now refuses these edits, as it refuses edits of shapes on
  another page, unless the variant is in the current file and on the current
  page.

  library-test: each edit goes through with the variant on the current page
  and is refused, with nothing emitted, with it elsewhere.

- Check every component of an assets group before combining it.

  The assets panel offers to combine a group of components as variants when
  they can be: more than one, none a variant, all with their main on one
  page. It counted every component of the group, subgroups included, but
  checked the other two rules only on the components directly in it, while
  the combine takes them all; with none directly in it, those rules always
  held (F98). can-combine-group? now checks every component of the group.

  assets-components-test: a group whose only components are in a subgroup
  can be combined; a variant or a main on another page in a subgroup blocks
  it; one component cannot.

AI-assisted-by: claude-opus-5-5
This commit is contained in:
alonso.torres 2026-10-15 15:11:30 +02:00
parent fc3f0e4064
commit adbddca19a
12 changed files with 209 additions and 51 deletions

View File

@ -482,3 +482,21 @@
(cfh/components-nesting-loop?
(cfh/get-children-with-self (:objects page) (:id root))
(cfh/get-parents-with-self objects (:parent-id shape)))))))
(defn generate-rename-variant
"Rename the variant `variant-id` to `name`: its board, and each of its
components with its main. The changes must hold the objects of the page
of the variant and the data of its file."
[changes variant-id name]
(let [data (pcb/get-library-data changes)
objects (pcb/get-objects changes)
clean-name (cpn/clean-path name)
valid-name (cpn/clean-valid-name clean-name)
rename (fn [changes component]
(-> changes
(cll/generate-rename-component (:id component) valid-name data)
(pcb/update-shapes [(:main-instance-id component)] #(assoc % :name valid-name))))]
(cond-> (pcb/update-shapes changes [variant-id] #(assoc % :name clean-name))
(some? valid-name)
(as-> changes (reduce rename changes (cfv/find-variant-components data objects variant-id))))))

View File

@ -1421,18 +1421,6 @@
rename-copy (n/change-property nested :name "Mine")
rename (n/rename-variant #(:id (n/member-root % vset "a")) "Renamed")]
{:setup setup/empty-situation
;; the rename's transaction keeps the undo group of its first commit, the
;; container's, while the copies' sync takes the group of the main
;; renames: one undo reverts only the sync
:undo-check {:known-failures [{:bug "F6"
:fails {:undo #{[:object :sync-vset-vcontainer :name]
[:object :sync-vset-v0-root :name]
[:object :sync-vset-v1-root :name]
[:component :sync-vset-v0-component :name]
[:component :sync-vset-v1-component :name]}
:undo-index #{[:undo-index 1]}}
:op rename
:runners #{:frontend}}]}
:operation (tm/in-sequence
[(n/create-component m base-color :size 100)
(n/make-variant-container vset [["a" base-color] ["b" red]] :size 100)

View File

@ -14,7 +14,6 @@
[app.common.geom.rect :as grc]
[app.common.logic.variant-properties :as clvp]
[app.common.logic.variants :as clv]
[app.common.path-names :as cpn]
[app.common.types.component :as ctc]
[app.common.types.components-list :as ctkl]
[app.common.types.shape.layout :as ctsl]
@ -25,7 +24,6 @@
[app.main.data.helpers :as dsh]
[app.main.data.workspace.libraries :as dwl]
[app.main.data.workspace.selection :as dws]
[app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.transforms :as dwt]
[app.main.data.workspace.undo :as dwu]
[app.util.dom :as dom]
@ -290,8 +288,14 @@
(not (ctc/is-variant? first-shape))
(ctc/main-instance? first-shape))
add-new-variant? (every? ctc/is-variant? selected-shapes)
variant-container? (and single? (ctc/is-variant-container? first-shape))
undo-id (js/Symbol)]
(cond
variant-container?
(rx/of
(ev/event {::ev/name "add-new-variant" ::ev/origin "workspace:shortcut-create-component"})
(add-new-variant (:id first-shape)))
transform-in-variant?
(rx/of
(ev/event {::ev/name "transform-in-variant" ::ev/origin "workspace:shortcut"})
@ -336,27 +340,14 @@
(rx/empty))))))
(defn rename-variant
"Rename the variant container and all components belonging to this variant"
"Rename the variant container and all components belonging to this variant
(`clv/generate-rename-variant`), in one commit."
[variant-id name]
(ptk/reify ::rename-variant
ptk/WatchEvent
(watch [_ state _]
(let [page-id (:current-page-id state)
data (dsh/lookup-file-data state)
objects (-> (dsh/get-page data page-id)
(get :objects))
variant-components (cfv/find-variant-components data objects variant-id)
clean-name (cpn/clean-path name)
undo-id (js/Symbol)]
(rx/concat
(rx/of (dwu/start-undo-transaction undo-id)
(dwsh/update-shapes [variant-id] #(assoc % :name clean-name)))
(rx/from (map
#(dwl/rename-component-and-main-instance (:id %) clean-name)
variant-components))
(rx/of (dwu/commit-undo-transaction undo-id)))))))
(watch [it state _]
(rx/of (dch/commit-changes (-> (variant-changes it state)
(clv/generate-rename-variant variant-id name)))))))
(defn rename-comp-or-variant-and-main
"If the component is in a variant, rename the variant.

View File

@ -177,18 +177,29 @@
:title (tr "workspace.assets.components.num-variants" num-variants)}
[:> icon* {:icon-id i/variant :size "s"}]])])]))
(defn- count-leaves
"Counts the total number of leaf elements in a nested map structure.
A leaf element is considered any element inside a vector."
(defn- leaves
"The leaf elements of a nested map structure: the elements of each vector
in it."
[m]
(reduce-kv (fn [acc _ v]
(cond
(map? v) (+ acc (count-leaves v))
(vector? v) (+ acc (count v))
:else acc))
0
(map? v) (into acc (leaves v))
(vector? v) (into acc v)
:else acc))
[]
m))
(defn can-combine-group?
"Whether the components of a group of the assets panel, `groups` (its
subgroups by name, its own components under \"\"), nested ones included,
can be combined as variants: two or more, none a variant, all with their
main on one page."
[groups]
(let [components (leaves groups)]
(and (> (count components) 1)
(not-any? ctc/is-variant? components)
(apply = (map :main-instance-page components)))))
(mf/defc components-group*
[{:keys [file-id prefix groups open-groups is-force-open renaming is-listing-thumbs selected on-asset-click
on-drag-start do-rename cancel-rename on-rename-group on-group on-ungroup on-delete-group on-context-menu
@ -207,11 +218,8 @@
selected-full))
components (not-empty (get groups "" []))
can-combine? (mf/with-memo [is-local groups components]
(and is-local
(> (count-leaves groups) 1)
(not-any? ctc/is-variant? components)
(apply = (map :main-instance-page components))))
can-combine? (mf/with-memo [is-local groups]
(and is-local (can-combine-group? groups)))
on-drag-enter
(mf/use-fn
(mf/deps dragging* prefix selected-paths is-local drag-data*)

View File

@ -696,6 +696,16 @@
[file-id variant-id]
(cfv/find-variant-components (-> file-id u/locate-file :data) variant-id))
(defn- on-current-page?
"Whether the main of `component`, of the file `file-id`, is on the page the
workspace shows: the variant events build their changes there."
[file-id component]
(and (= file-id (:current-file-id @st/state))
(u/page-active? (:main-instance-page component))))
(def ^:private not-on-current-page
"The variant is not on the current page")
(declare lib-component-proxy)
(defn variant-proxy
@ -742,6 +752,9 @@
(not (r/check-permission plugin-id "library:write"))
(u/not-valid plugin-id :addVariant "Plugin doesn't have 'library:write' permission")
(not (on-current-page? file-id (first (get-variant-components file-id id))))
(u/not-valid plugin-id :addVariant not-on-current-page)
:else
(st/emit!
(se/event plugin-id "add-new-variant")
@ -753,6 +766,9 @@
(not (r/check-permission plugin-id "library:write"))
(u/not-valid plugin-id :addProperty "Plugin doesn't have 'library:write' permission")
(not (on-current-page? file-id (first (get-variant-components file-id id))))
(u/not-valid plugin-id :addProperty not-on-current-page)
:else
(st/emit!
(se/event plugin-id "add-new-property")
@ -772,6 +788,9 @@
(not (ctv/can-remove-property? properties))
(u/not-valid plugin-id :removeProperty "A variant must keep at least one property")
(not (on-current-page? file-id (first (get-variant-components file-id id))))
(u/not-valid plugin-id :removeProperty not-on-current-page)
:else
(st/emit!
(se/event plugin-id "remove-property")
@ -790,6 +809,9 @@
(not (r/check-permission plugin-id "library:write"))
(u/not-valid plugin-id :renameProperty "Plugin doesn't have 'library:write' permission")
(not (on-current-page? file-id (first (get-variant-components file-id id))))
(u/not-valid plugin-id :renameProperty not-on-current-page)
:else
(st/emit!
(dwv/update-property-name id pos (ctv/normalize-property-text name)
@ -1101,6 +1123,9 @@
(not (r/check-permission plugin-id "library:write"))
(u/not-valid plugin-id :setVariantProperty "Plugin doesn't have 'library:write' permission")
(not (on-current-page? file-id (u/locate-library-component file-id id)))
(u/not-valid plugin-id :setVariantProperty not-on-current-page)
:else
(st/emit!
(se/event plugin-id "variant-edit-property-value")

View File

@ -336,6 +336,13 @@
[shape]
(and (ctk/instance-head? shape) (ctk/in-component-copy? shape)))
(defn swap-keeps-touched?
"Whether a swap of a copy of `component` for `target` keeps the copy's
overrides: only a switch between variants of one set does, as in the UI."
[component target]
(and (ctk/is-variant? component)
(= (:variant-id component) (:variant-id target))))
(defn shape-proxy? [p]
(obj/type-of? p "ShapeProxy"))
@ -1569,7 +1576,11 @@
valid-component? (obj/type-of? component "LibraryComponentProxy")
target-file (when valid-component? (obj/get component "$file"))
target-id (when valid-component? (obj/get component "$id"))
target-data (some-> target-file u/locate-file :data)]
target-data (some-> target-file u/locate-file :data)
component (when (copy-head? shape)
(u/locate-library-component (:component-file shape) (:component-id shape)))
target (when target-data
(dm/get-in target-data [:components target-id]))]
(cond
(not (r/check-permission plugin-id "content:write"))
(u/not-valid plugin-id :swapComponent "Plugin doesn't have 'content:write' permission")
@ -1590,7 +1601,7 @@
(st/emit! (dwl/component-swap shape
target-file
target-id
true)))))
(swap-keeps-touched? component target))))))
:updateMain
(fn []
@ -1860,9 +1871,10 @@
:isVariantHead
(fn []
(let [shape (u/locate-shape file-id page-id id)
component (u/locate-library-component file-id (:component-id shape))]
(and (ctk/instance-head? shape) (ctk/is-variant? component))))
(let [shape (u/locate-shape file-id page-id id)]
(and (ctk/instance-head? shape)
(ctk/is-variant? (u/locate-library-component (:component-file shape)
(:component-id shape))))))
:isVariantContainer
(fn []

View File

@ -67,8 +67,8 @@
[{:transcript " {:component \"first\", :id sync-first-component}\n {:component \"second\", :id sync-second-component}\n {:component \"first\", :copy sync-first-copyhead-0}\n {:component \"second\", :copy sync-second-copyhead-0}\n {:target #fn, :property :fills, :value \"#00ff00\"}\n {:action :combine, :params {:targets [#fn #fn], :selected? nil}}\n \n {:target #fn, :value #fn, :pos 0}", :states ["61c776d9e973c9dd" "0f68af4cace0f325" "97651ebcd2334fe5" "47fc4c7f440fdc13"]}
{:transcript " {:component \"first\", :id sync-first-component}\n {:component \"second\", :id sync-second-component}\n {:component \"first\", :copy sync-first-copyhead-0}\n {:component \"second\", :copy sync-second-copyhead-0}\n {:target #fn, :property :fills, :value \"#00ff00\"}\n {:action :combine, :params {:targets [#fn #fn], :selected? true}}\n \n {:target #fn, :value #fn, :pos 0}", :states ["61c776d9e973c9dd" "0f68af4cace0f325" "97651ebcd2334fe5" "47fc4c7f440fdc13"]}]
:case-av-variant-rename-reaches-copy-names
[{:transcript " {:component \"main\", :id sync-main-component}\n {:variant-set \"vset\", :variant-id sync-vset-vcontainer, :count 2}\n {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n {:component \"main\", :copy sync-main-copyhead-0}\n {:target #fn, :property :name, :value \"Mine\"}\n {:action :rename, :params {:target #fn, :name \"Renamed\"}}", :states ["c6e889ca261596ce" "940f36855f4f991d" "d7a54338ab53a791"]}
{:transcript " {:component \"main\", :id sync-main-component}\n {:variant-set \"vset\", :variant-id sync-vset-vcontainer, :count 2}\n {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n {:component \"main\", :copy sync-main-copyhead-0}\n one-of -> \n {:action :rename, :params {:target #fn, :name \"Renamed\"}}", :states ["5eb544d7e28709d7" "5e6794a9b313112e"]}]
[{:transcript " {:component \"main\", :id sync-main-component}\n {:variant-set \"vset\", :variant-id sync-vset-vcontainer, :count 2}\n {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n {:component \"main\", :copy sync-main-copyhead-0}\n {:target #fn, :property :name, :value \"Mine\"}\n {:action :rename, :params {:target #fn, :name \"Renamed\"}}", :states ["c6e889ca261596ce" "940f36855f4f991d" "92ebbb582ece2af2"]}
{:transcript " {:component \"main\", :id sync-main-component}\n {:variant-set \"vset\", :variant-id sync-vset-vcontainer, :count 2}\n {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n {:component \"main\", :copy sync-main-copyhead-0}\n one-of -> \n {:action :rename, :params {:target #fn, :name \"Renamed\"}}", :states ["5eb544d7e28709d7" "e76ffcfb4eccd16d"]}]
:case-aw-variant-layer-rename-and-cleanup
[{:transcript " {:component \"main\", :id sync-main-component}\n {:variant-set \"vset\", :variant-id sync-vset-vcontainer, :count 2}\n {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n {:component \"main\", :copy sync-main-copyhead-0}\n {:target #fn, :property :fills, :value \"#00ff00\"}\n {:set-name \"vset\", :edit :rename-in-layers, :params {:member \"a\", :name \"Property 1=a2, Size=S\"}}\n \n {:set-name \"vset\", :edit :add, :params {:property-name \"Empty\"}}\n {:set-name \"vset\", :edit :remove-empty, :params {}}\n \n {:set-name \"vset\", :edit :rename-in-layers, :params {:member \"a\", :name \"Property 1=a2\"}}\n \n {:set-name \"vset\", :edit :error, :params {:member \"b\", :value \"Duplicated\"}}\n \n {:set-name \"vset\", :edit :error, :params {:member \"b\", :value nil}}\n \n {:target #fn, :value \"b\", :pos 0}", :states ["df9a042b9d59d699" "4b53486972986d97" "8553f89a9ebbcf54" "4b53486972986d97" "d65ad905aa97d661" "6daf5f692c6595f1" "d65ad905aa97d661" "cc7d2dfd618fc8cb" "6390b4d83995b629"]}]
:case-ax-variant-shortcuts-add-members

View File

@ -27,6 +27,7 @@
[app.main.data.workspace.selection :as dws]
[app.main.data.workspace.undo :as dwu]
[app.main.data.workspace.variants :as dwv]
[app.util.dom :as dom]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.mock :as mock]
@ -195,6 +196,22 @@
(run-selected done (file-with-a-rect) [:rect] (dwv/add-component-or-variant)
#(t/is (= 1 (count (ctkl/components-seq (:data %))))))))
(t/deftest create-component-shortcut-on-a-variant-container
;; the shortcut the container's "Add variant" menu entry shows; the new
;; variant focuses its first property 250 ms later, which finds no DOM here
(t/async
done
(mock/with-mocks
{dom/get-element (constantly nil)}
(fn [inner-done]
(let [file (-> (cthf/sample-file :file1)
(cthv/add-variant :v01 :c01 :m01 :c02 :m02))]
(run-selected #(js/setTimeout inner-done 300) file [:v01] (dwv/add-component-or-variant)
(fn [file']
(t/is (= 3 (count (filter #(= (cthi/id :v01) (:variant-id %))
(ctkl/components-seq (:data file'))))))))))
done)))
(t/deftest rename-of-a-plain-component
(t/async
done

View File

@ -388,6 +388,47 @@
(t/is (= [plugin-id :setVariantProperty "Plugin doesn't have 'library:write' permission"]
(first @errors)))))))
(defn- variant-edit-errors
"The errors and emitted events of `call` on a variant edit of the file
`file-id`, with its main on the page `page-id`, while the workspace shows
the current file `current-file` and page `current-page`."
[{:keys [file-id page-id current-file current-page]} call]
(let [errors (atom [])
emitted (atom [])]
(with-redefs [r/check-permission (constantly true)
u/not-valid (mock/stub (fn [_ prop msg] (swap! errors conj [prop msg])))
u/locate-library-component (constantly {:main-instance-page page-id
:variant-properties [{:name "color" :value "red"}
{:name "size" :value "s"}]})
library/get-variant-components (constantly [{:main-instance-page page-id
:variant-properties [{:name "color" :value "red"}
{:name "size" :value "s"}]}])
st/state (atom {:current-file-id current-file :current-page-id current-page})
st/emit! (fn ([_] (swap! emitted conj :emitted))
([_ & _] (swap! emitted conj :emitted)))]
(call (library/variant-proxy "test-plugin" file-id (uuid/next))
(library/lib-component-proxy "test-plugin" file-id (uuid/next))))
{:errors @errors :emitted @emitted}))
(t/deftest variant-edits-need-the-variant-on-the-current-page
(let [file-id (uuid/next)
page-id (uuid/next)
edits {:addVariant #(.addVariant ^js %1)
:addProperty #(.addProperty ^js %1)
:removeProperty #(.removeProperty ^js %1 0)
:renameProperty #(.renameProperty ^js %1 0 "shade")
:setVariantProperty #(.setVariantProperty ^js %2 0 "blue")}]
(doseq [[prop call] edits]
(t/testing (name prop)
(let [here (variant-edit-errors {:file-id file-id :page-id page-id
:current-file file-id :current-page page-id} call)
there (variant-edit-errors {:file-id file-id :page-id page-id
:current-file file-id :current-page (uuid/next)} call)]
(t/is (empty? (:errors here)))
(t/is (seq (:emitted here)))
(t/is (= [[prop "The variant is not on the current page"]] (:errors there)))
(t/is (empty? (:emitted there))))))))
;; ---------------------------------------------------------------------------
;; connectLibrary
;; ---------------------------------------------------------------------------

View File

@ -466,6 +466,31 @@
(t/is (= [[:detach "Only a copy that is not inside another copy can be detached"]] errors))
(t/is (empty? emitted))))
(t/deftest swap-component-keeps-overrides-only-within-a-variant-set
(let [set-a (uuid/next)
set-b (uuid/next)
variant #(hash-map :id (uuid/next) :variant-id %)]
(t/is (true? (shape/swap-keeps-touched? (variant set-a) (variant set-a))))
(t/is (false? (shape/swap-keeps-touched? (variant set-a) (variant set-b))))
(t/is (not (shape/swap-keeps-touched? {:id (uuid/next)} {:id (uuid/next)})))
(t/is (not (shape/swap-keeps-touched? (variant set-a) {:id (uuid/next)})))))
(t/deftest is-variant-head-reads-the-component-file-of-the-shape
;; a copy, in this file, of a variant of a library
(let [library-id (uuid/next)
copy-id (uuid/next)
rect-id (uuid/next)
objects {copy-id {:id copy-id :type :frame :component-id (uuid/next)
:component-file library-id :component-root true :shape-ref (uuid/next)}
rect-id {:id rect-id :type :rect}}
head? (fn [id]
(with-redefs [u/locate-shape (fn [_ _ id] (get objects id))
u/locate-library-component
(fn [file-id _] (when (= file-id library-id) {:variant-id (uuid/next)}))]
(.isVariantHead ^js (shape/shape-proxy "test-plugin" (uuid/next) (uuid/next) id))))]
(t/is (true? (head? copy-id)))
(t/is (false? (head? rect-id)))))
(t/deftest swap-component-rejects-a-value-that-is-no-component
(let [{:keys [objects head]} (nested-copy-objects)
{:keys [errors emitted]} (plugin-errors-of objects head #(.swapComponent ^js % #js {}))]

View File

@ -125,6 +125,7 @@
[frontend-tests.tokens.token-path-test]
[frontend-tests.tokens.tokenscript-test]
[frontend-tests.tokens.workspace-tokens-remap-test]
[frontend-tests.ui.assets-components-test]
[frontend-tests.ui.check-updates-test]
[frontend-tests.ui.color-token-position-test]
[frontend-tests.ui.colorpicker-token-set-order-test]
@ -302,6 +303,7 @@
'frontend-tests.tokens.logic.tokens-source-test
'frontend-tests.tokens.logic.tokens-status-test
'frontend-tests.tokens.workspace-tokens-remap-test
'frontend-tests.ui.assets-components-test
'frontend-tests.ui.check-updates-test
'frontend-tests.ui.color-token-position-test
'frontend-tests.ui.colorpicker-token-set-order-test

View File

@ -0,0 +1,31 @@
;; 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.ui.assets-components-test
"The check that offers to combine a group of the assets panel as variants."
(:require
[app.common.uuid :as uuid]
[app.main.ui.workspace.sidebar.assets.components :as assets]
[cljs.test :as t :include-macros true]))
(def ^:private page-1 (uuid/next))
(def ^:private page-2 (uuid/next))
(defn- component
[& {:as attrs}]
(merge {:id (uuid/next) :main-instance-page page-1} attrs))
(t/deftest combine-a-group-reads-its-nested-components
(t/testing "components only in subgroups"
(t/is (assets/can-combine-group? {"" [] "sub" {"" [(component) (component)]}})))
(t/testing "a variant in a subgroup"
(t/is (not (assets/can-combine-group? {"" [(component)]
"sub" {"" [(component :variant-id (uuid/next))]}}))))
(t/testing "a subgroup with its main on another page"
(t/is (not (assets/can-combine-group? {"" [(component)]
"sub" {"" [(component :main-instance-page page-2)]}}))))
(t/testing "a single component"
(t/is (not (assets/can-combine-group? {"" [(component)]})))))