From 69fa6822174ec3453b03a89744a6d69bb3e7a412 Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Fri, 30 Oct 2026 17:50:30 +0100 Subject: [PATCH] :bug: Keep variant sets intact under group, mask, board, and layout --- common/src/app/common/types/container.cljc | 46 ++ .../common_tests/types/container_test.cljc | 137 +++++ .../src/app/main/data/workspace/groups.cljs | 37 +- .../app/main/data/workspace/shape_layout.cljs | 22 +- .../src/app/main/data/workspace/shapes.cljs | 16 +- .../app/main/ui/workspace/context_menu.cljs | 12 +- .../frontend_tests/logic/groups_test.cljs | 555 +++++++++++++++++- 7 files changed, 757 insertions(+), 68 deletions(-) diff --git a/common/src/app/common/types/container.cljc b/common/src/app/common/types/container.cljc index cc7ea9bd07..d790999db0 100644 --- a/common/src/app/common/types/container.cljc +++ b/common/src/app/common/types/container.cljc @@ -426,6 +426,52 @@ true (has-any-copy-parent? objects (:parent-id shape)))))) +(defn get-variant-boundary-id + "Id of the nearest variant main or variant container for this shape, or nil + when the shape is outside any variant set. + + Nested children resolve to their enclosing main (not the container), so two + shapes under different variants are treated as different boundaries even + when they share a set." + [objects shape] + (some (fn [s] + (cond + (ctk/is-variant? s) (:id s) + (ctk/is-variant-container? s) (:id s) + :else nil)) + (cfh/get-parents-with-self objects (:id shape)))) + +(defn crosses-variant-set-boundary? + "True when the selection mixes shapes that belong to different variant + boundaries — including outside vs in-set, two different variant mains + (even in the same set), or two different variant containers. + + Structure commands (group, mask, board-from-selection, layout) must not + reparent across that boundary." + [objects shapes] + (let [ids (into #{} (map #(get-variant-boundary-id objects %)) shapes)] + (> (count ids) 1))) + +(defn ids-for-structure-op + "Ids allowed for Group, Mask, and Create board from selection. + + Drops copy-children and variant mains, refuses a cross-boundary or + container selection, then removes descendants whose ancestor is + still selected. Mains are dropped before loop-cleaning so a + selected main does not swallow nested children. Layout does not + use this helper." + [objects ids] + (let [shapes (->> ids + (remove #(has-any-copy-parent? objects (get objects %))) + (keep (d/getf objects)))] + (if (or (crosses-variant-set-boundary? objects shapes) + (some ctk/is-variant-container? shapes)) + [] + (->> shapes + (remove ctk/is-variant?) + (map :id) + (cfh/clean-loops objects))))) + (defn has-any-main? "Check if the shape is a main component or has any children or parent that is a main component." [objects shape] diff --git a/common/test/common_tests/types/container_test.cljc b/common/test/common_tests/types/container_test.cljc index e00b1fb9c6..9f7a308c8a 100644 --- a/common/test/common_tests/types/container_test.cljc +++ b/common/test/common_tests/types/container_test.cljc @@ -154,3 +154,140 @@ shape-b (make-shape id-b :parent-id id-a) objects (objects-map shape-a shape-b)] (t/is (nil? (ctc/find-component-main objects shape-a false)))))) + +(t/deftest get-variant-boundary-id-recognizes-container-main-and-nested + (let [page-id uuid/zero + container-id (uuid/next) + main-id (uuid/next) + nested-id (uuid/next) + outside-id (uuid/next) + container (make-shape container-id + :parent-id page-id + :type :frame + :is-variant-container true) + main (make-shape main-id + :parent-id container-id + :type :frame + :variant-id container-id) + nested (make-shape nested-id :parent-id main-id) + outside (make-shape outside-id :parent-id page-id) + objects (objects-map container main nested outside)] + (t/is (= main-id (ctc/get-variant-boundary-id objects nested))) + (t/is (= main-id (ctc/get-variant-boundary-id objects main))) + (t/is (= container-id (ctc/get-variant-boundary-id objects container))) + (t/is (nil? (ctc/get-variant-boundary-id objects outside))))) + +(t/deftest crosses-variant-set-boundary? + (let [page-id uuid/zero + container-id (uuid/next) + main-id (uuid/next) + main-2-id (uuid/next) + nested-id (uuid/next) + nested-2-id (uuid/next) + outside-id (uuid/next) + other-set-id (uuid/next) + other-main-id (uuid/next) + other-nest-id (uuid/next) + container (make-shape container-id + :parent-id page-id + :type :frame + :is-variant-container true) + main (make-shape main-id + :parent-id container-id + :type :frame + :variant-id container-id) + main-2 (make-shape main-2-id + :parent-id container-id + :type :frame + :variant-id container-id) + nested (make-shape nested-id :parent-id main-id) + nested-2 (make-shape nested-2-id :parent-id main-2-id) + outside (make-shape outside-id :parent-id page-id) + other-set (make-shape other-set-id + :parent-id page-id + :type :frame + :is-variant-container true) + other-main (make-shape other-main-id + :parent-id other-set-id + :type :frame + :variant-id other-set-id) + other-nested (make-shape other-nest-id :parent-id other-main-id) + objects (objects-map container main main-2 nested nested-2 + outside other-set other-main other-nested)] + (t/testing "nested child + outside crosses" + (t/is (true? (ctc/crosses-variant-set-boundary? + objects [nested outside])))) + (t/testing "main + outside crosses" + (t/is (true? (ctc/crosses-variant-set-boundary? + objects [main outside])))) + (t/testing "container + outside crosses" + (t/is (true? (ctc/crosses-variant-set-boundary? + objects [container outside])))) + (t/testing "nested children of two variants in the same set cross" + (t/is (true? (ctc/crosses-variant-set-boundary? + objects [nested nested-2])))) + (t/testing "nested children from two different sets cross" + (t/is (true? (ctc/crosses-variant-set-boundary? + objects [nested other-nested])))) + (t/testing "two outside shapes do not cross" + (let [outside-2 (make-shape (uuid/next) :parent-id page-id)] + (t/is (false? (ctc/crosses-variant-set-boundary? + (assoc objects (:id outside-2) outside-2) + [outside outside-2]))))) + (t/testing "nested only does not cross" + (t/is (false? (ctc/crosses-variant-set-boundary? objects [nested])))) + (t/testing "main + nested under that main do not cross" + (t/is (false? (ctc/crosses-variant-set-boundary? + objects [main nested])))) + (t/testing "two nested under the same main do not cross" + (let [nested-b (make-shape (uuid/next) :parent-id main-id)] + (t/is (false? (ctc/crosses-variant-set-boundary? + (assoc objects (:id nested-b) nested-b) + [nested nested-b]))))))) + +(t/deftest ids-for-structure-op + (let [page-id uuid/zero + container-id (uuid/next) + main-id (uuid/next) + main-2-id (uuid/next) + nested-id (uuid/next) + nested-2-id (uuid/next) + outside-id (uuid/next) + outside-2-id (uuid/next) + container (make-shape container-id + :parent-id page-id + :type :frame + :is-variant-container true) + main (make-shape main-id + :parent-id container-id + :type :frame + :variant-id container-id) + main-2 (make-shape main-2-id + :parent-id container-id + :type :frame + :variant-id container-id) + nested (make-shape nested-id :parent-id main-id) + nested-2 (make-shape nested-2-id :parent-id main-id) + outside (make-shape outside-id :parent-id page-id) + outside-2 (make-shape outside-2-id :parent-id page-id) + objects (objects-map container main main-2 nested nested-2 + outside outside-2)] + (t/testing "container-only is refused" + (t/is (= [] (vec (ctc/ids-for-structure-op objects [container-id]))))) + (t/testing "nested + outside is refused" + (t/is (= [] (vec (ctc/ids-for-structure-op objects [nested-id outside-id]))))) + (t/testing "main + outside is refused" + (t/is (= [] (vec (ctc/ids-for-structure-op objects [main-id outside-id]))))) + (t/testing "two mains cross and are refused" + (t/is (= [] (vec (ctc/ids-for-structure-op objects [main-id main-2-id]))))) + (t/testing "single main is dropped to empty" + (t/is (= [] (vec (ctc/ids-for-structure-op objects [main-id]))))) + (t/testing "main + nested under that main keeps only the nested" + (t/is (= [nested-id] + (vec (ctc/ids-for-structure-op objects [main-id nested-id]))))) + (t/testing "two nested under one main are both kept" + (t/is (= [nested-id nested-2-id] + (vec (ctc/ids-for-structure-op objects [nested-id nested-2-id]))))) + (t/testing "two ordinary outside shapes are both kept" + (t/is (= [outside-id outside-2-id] + (vec (ctc/ids-for-structure-op objects [outside-id outside-2-id]))))))) diff --git a/frontend/src/app/main/data/workspace/groups.cljs b/frontend/src/app/main/data/workspace/groups.cljs index 46a8737774..53175a61f2 100644 --- a/frontend/src/app/main/data/workspace/groups.cljs +++ b/frontend/src/app/main/data/workspace/groups.cljs @@ -30,6 +30,14 @@ reverse (map #(get objects %)))) +(defn- shapes-for-group-or-mask + "Shapes allowed for Group and Mask after the shared variant gates. + + `ids-for-structure-op` drops mains before `clean-loops` so a selected + variant main does not swallow nested children." + [objects ids] + (shapes-for-grouping objects (ctn/ids-for-structure-op objects ids))) + (defn- get-empty-groups-after-group-creation "An auxiliary function that finds and returns a set of ids that corresponds to groups that should be deleted after a group creation. @@ -188,18 +196,11 @@ (ptk/reify ::group-shapes ptk/WatchEvent (watch [it state _] - (let [id (d/nilv id (uuid/next)) - page-id (:current-page-id state) - objects (dsh/lookup-page-objects state page-id) - - shapes - (->> ids - (cfh/clean-loops objects) - (remove #(ctn/has-any-copy-parent? objects (get objects %))) - (shapes-for-grouping objects) - (remove ctk/is-variant?)) - - parents (into #{} (map :parent-id) shapes)] + (let [id (d/nilv id (uuid/next)) + page-id (:current-page-id state) + objects (dsh/lookup-page-objects state page-id) + shapes (shapes-for-group-or-mask objects ids) + parents (into #{} (map :parent-id) shapes)] (when-not (empty? shapes) (let [[group changes] (prepare-create-group (pcb/empty-changes it) id objects page-id shapes "Group" false)] @@ -213,7 +214,9 @@ (ptk/reify ::group-selected ptk/WatchEvent (watch [_ state _] - (let [selected (dsh/lookup-selected state)] + ;; Raw ids: lookup-selected already clean-loops, which would drop + ;; nested children of a selected variant main before we can. + (let [selected (dsh/get-selected-ids state)] (rx/of (group-shapes nil selected :change-selection? true)))))) (defn ungroup-shapes @@ -287,11 +290,9 @@ (watch [it state _] (let [page-id (:current-page-id state) objects (dsh/lookup-page-objects state page-id) - selected (->> (or ids (dsh/lookup-selected state)) - (cfh/clean-loops objects) - (remove #(ctn/has-any-copy-parent? objects (get objects %)))) - shapes (->> (shapes-for-grouping objects selected) - (remove ctk/is-variant?)) + shapes (shapes-for-group-or-mask + objects + (or ids (dsh/get-selected-ids state))) first-shape (first shapes)] (when-not (empty? shapes) (let [;; If the selected shape is a group, we can use it. If not, diff --git a/frontend/src/app/main/data/workspace/shape_layout.cljs b/frontend/src/app/main/data/workspace/shape_layout.cljs index df614dfe7a..784036857f 100644 --- a/frontend/src/app/main/data/workspace/shape_layout.cljs +++ b/frontend/src/app/main/data/workspace/shape_layout.cljs @@ -17,6 +17,7 @@ [app.common.logic.libraries :as cll] [app.common.types.color :as clr] [app.common.types.component :as ctc] + [app.common.types.container :as ctn] [app.common.types.modifiers :as ctm] [app.common.types.shape.layout :as ctl] [app.common.uuid :as uuid] @@ -216,13 +217,15 @@ has-component? (some true? (map ctc/instance-root? selected-shapes)) is-component? (and single? has-component?) has-variant? (some ctc/is-variant? selected-shapes) + has-container? (some ctc/is-variant-container? selected-shapes) + crosses-set? (ctn/crosses-variant-set-boundary? objects selected-shapes) has-layout? (and single? (ctl/any-layout? (first selected-shapes))) new-shape-id (uuid/next) undo-id (js/Symbol)] - (if has-variant? + (if (or has-variant? has-container? crosses-set?) (rx/empty) (rx/concat (rx/of (dwu/start-undo-transaction undo-id)) @@ -294,12 +297,17 @@ undo-id (js/Symbol)] - (rx/of - (dwu/start-undo-transaction undo-id) - (if (and single? is-frame? (not has-layout?)) - (create-layout-from-id (first selected) type :from-frame? true) - (create-layout-from-selection type)) - (dwu/commit-undo-transaction undo-id)))))) + ;; Refuse before undo: container or cross-boundary. Leave single + ;; mains on the from-id branch (selection refuses has-variant?). + (if (or (some ctc/is-variant-container? selected-shapes) + (ctn/crosses-variant-set-boundary? objects selected-shapes)) + (rx/empty) + (rx/of + (dwu/start-undo-transaction undo-id) + (if (and single? is-frame? (not has-layout?)) + (create-layout-from-id (first selected) type :from-frame? true) + (create-layout-from-selection type)) + (dwu/commit-undo-transaction undo-id))))))) (defn toggle-layout [type] diff --git a/frontend/src/app/main/data/workspace/shapes.cljs b/frontend/src/app/main/data/workspace/shapes.cljs index 6ddf66ddc8..491f7a2c2d 100644 --- a/frontend/src/app/main/data/workspace/shapes.cljs +++ b/frontend/src/app/main/data/workspace/shapes.cljs @@ -476,16 +476,14 @@ (ptk/reify ::create-artboard-from-selection ptk/WatchEvent (watch [_ state _] - (let [page-id (:current-page-id state) - objects (dsh/lookup-page-objects state page-id) - selected (->> (dsh/lookup-selected state) - (cfh/clean-loops objects) - (remove #(ctn/has-any-copy-parent? objects (get objects %))) - (remove #(->> % - (get objects) - (ctc/is-variant?))))] + (let [page-id (:current-page-id state) + objects (dsh/lookup-page-objects state page-id) + selected (vec (ctn/ids-for-structure-op + objects + (dsh/get-selected-ids state)))] - (rx/of (create-artboard-from-shapes selected id parent-id index name delta))))))) + (when (seq selected) + (rx/of (create-artboard-from-shapes selected id parent-id index name delta)))))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Shape Flags diff --git a/frontend/src/app/main/ui/workspace/context_menu.cljs b/frontend/src/app/main/ui/workspace/context_menu.cljs index 7cb59b75e5..6a11dfd988 100644 --- a/frontend/src/app/main/ui/workspace/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/context_menu.cljs @@ -398,6 +398,8 @@ objects (deref refs/workspace-page-objects) any-in-copy? (some #(ctn/has-any-copy-parent? objects %) shapes) any-is-variant? (some ctk/is-variant? shapes) + any-is-container? (some ctk/is-variant-container? shapes) + crosses-set? (ctn/crosses-variant-set-boundary? objects shapes) ;; components can't be ungrouped has-frame? (->> shapes (d/seek #(and (cfh/frame-shape? %) (not (ctk/instance-head? %)) (not (ctk/is-variant-container? %))))) @@ -416,7 +418,7 @@ #(st/emit! (dwsh/create-artboard-from-selection))] [:* - (when (not (or any-in-copy? any-is-variant?)) + (when (not (or any-in-copy? any-is-variant? any-is-container? crosses-set?)) [:* (when (or has-bool? has-group? has-mask? has-frame?) [:> menu-entry* {:title (tr "workspace.shape.menu.ungroup") @@ -621,6 +623,10 @@ (and single? (every? ctl/grid-layout? shapes)) any-is-variant? (some ctk/is-variant? shapes) + ;; shape-context-menu* already omits this menu for container-only; + ;; gate here too so a future caller cannot show Add flex/grid. + any-is-container? (some ctk/is-variant-container? shapes) + crosses-set? (ctn/crosses-variant-set-boundary? objects shapes) on-add-layout (mf/use-fn @@ -653,7 +659,9 @@ :shortcut-key :toggle-layout-grid :on-click on-remove-layout}])] - (when (or single? (not any-is-variant?)) + (when (and (not crosses-set?) + (not any-is-container?) + (or single? (not any-is-variant?))) [:div [:> menu-separator* {}] [:> menu-entry* {:title (tr "workspace.shape.menu.add-flex") diff --git a/frontend/test/frontend_tests/logic/groups_test.cljs b/frontend/test/frontend_tests/logic/groups_test.cljs index 709285ac74..33590b3bc0 100644 --- a/frontend/test/frontend_tests/logic/groups_test.cljs +++ b/frontend/test/frontend_tests/logic/groups_test.cljs @@ -6,18 +6,18 @@ (ns frontend-tests.logic.groups-test (: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 thv] [app.common.types.component :as ctk] - [app.common.uuid :as uuid] + [app.common.types.container :as ctn] + [app.common.types.shape.layout :as ctl] [app.main.data.changes :as dch] - [app.main.data.workspace :as dw] [app.main.data.workspace.groups :as dwgr] [app.main.data.workspace.selection :as dws] + [app.main.data.workspace.shape-layout :as dwsl] + [app.main.data.workspace.shapes :as dwsh] [beicon.v2.core :as rx] [cljs.test :as t :include-macros true] [frontend-tests.helpers.pages :as thp] @@ -41,6 +41,24 @@ [] (cths/add-sample-shape (variant-file) :rect)) +(defn- variant-file-with-nested-and-outside + [] + (-> (variant-file) + (cths/add-sample-shape :nested :parent-label :root1) + (cths/add-sample-shape :outside))) + +(defn- variant-file-with-two-nested + [] + (-> (variant-file) + (cths/add-sample-shape :nested-a :parent-label :root1) + (cths/add-sample-shape :nested-b :parent-label :root1))) + +(defn- variant-file-with-nested-in-each-main + [] + (-> (variant-file) + (cths/add-sample-shape :nested-a :parent-label :root1) + (cths/add-sample-shape :nested-b :parent-label :root2))) + (defn- masked-groups [file] (->> (cthf/current-page file) @@ -103,29 +121,46 @@ ;; Group has been created and is selected (t/is (= (get-in new-state [:workspace-local :selected]) #{group-id})))))))) -(t/deftest mask-group-leaves-variant-main-in-the-set +(t/deftest mask-group-of-main-and-outside-does-not-commit (t/async done - (let [file (variant-file-with-rect) - rect (cths/get-shape file :rect) - main (cths/get-shape file :root1) - comp (cthc/get-component file :comp1)] + (let [file (variant-file-with-rect) + rect (cths/get-shape file :rect) + main (cths/get-shape file :root1) + commits (atom [])] (run file [(dwgr/mask-group [(cthi/id :root1) (cthi/id :rect)])] done (fn [state] - (let [file' (ths/get-file-from-state state) - main' (cths/get-shape file' :root1) - rect' (cths/get-shape file' :rect) - group (first (masked-groups file')) - comp' (cthc/get-component file' :comp1)] - (t/is (= (:parent-id main) (:parent-id main'))) - (t/is (= (:variant-id main) (:variant-id main'))) - (t/is (= (:variant-name main) (:variant-name main'))) - (t/is (= (:variant-id comp) (:variant-id comp'))) - (t/is (= (:id group) (:parent-id rect'))) - (t/is (= (:parent-id rect) (:parent-id group))) - (t/is (= (:selrect rect) (:selrect group))))))))) + (let [file' (ths/get-file-from-state state)] + (t/is (= 0 (count @commits))) + (t/is (empty? (masked-groups file'))) + (t/is (= (:parent-id main) (:parent-id (cths/get-shape file' :root1)))) + (t/is (= (:parent-id rect) (:parent-id (cths/get-shape file' :rect)))))) + :commits commits)))) + +(t/deftest mask-group-of-nested-and-outside-does-not-commit + (t/async + done + (let [file (variant-file-with-nested-and-outside) + nested (cths/get-shape file :nested) + outside (cths/get-shape file :outside) + commits (atom [])] + (t/is (ctn/crosses-variant-set-boundary? + (:objects (cthf/current-page file)) + [nested outside])) + (run file + [(dwgr/mask-group [(cthi/id :nested) (cthi/id :outside)])] + done + (fn [state] + (let [file' (ths/get-file-from-state state)] + (t/is (= 0 (count @commits))) + (t/is (empty? (masked-groups file'))) + (t/is (= (:parent-id nested) + (:parent-id (cths/get-shape file' :nested)))) + (t/is (= (:parent-id outside) + (:parent-id (cths/get-shape file' :outside)))))) + :commits commits)))) (t/deftest mask-group-of-only-variant-mains-does-not-commit (t/async @@ -178,24 +213,480 @@ (t/is (= [group-id] (map :id (groups file')))) (t/is (= group-id (:parent-id (cths/get-shape file' :child)))))))))) -(t/deftest group-shapes-leaves-variant-main-in-the-set +(t/deftest group-shapes-of-main-and-outside-does-not-commit (t/async done - (let [file (variant-file-with-rect) - rect (cths/get-shape file :rect)] + (let [file (variant-file-with-rect) + rect (cths/get-shape file :rect) + main (cths/get-shape file :root1) + commits (atom [])] (run file [(dwgr/group-shapes nil [(cthi/id :root1) (cthi/id :rect)])] done (fn [state] - (let [file' (ths/get-file-from-state state) - board (cths/get-shape file' :variant) - main (cths/get-shape file' :root1) - rect' (cths/get-shape file' :rect) - group (first (groups file'))] - (t/is (= (:id board) (:parent-id main))) + (let [file' (ths/get-file-from-state state)] + (t/is (= 0 (count @commits))) + (t/is (empty? (groups file'))) + (t/is (= (:parent-id main) (:parent-id (cths/get-shape file' :root1)))) + (t/is (= (:parent-id rect) (:parent-id (cths/get-shape file' :rect)))))) + :commits commits)))) + +(t/deftest group-shapes-of-nested-and-outside-does-not-commit + (t/async + done + (let [file (variant-file-with-nested-and-outside) + nested (cths/get-shape file :nested) + outside (cths/get-shape file :outside) + commits (atom [])] + (run file + [(dwgr/group-shapes nil [(cthi/id :nested) (cthi/id :outside)])] + done + (fn [state] + (let [file' (ths/get-file-from-state state)] + (t/is (= 0 (count @commits))) + (t/is (empty? (groups file'))) + (t/is (= (:parent-id nested) + (:parent-id (cths/get-shape file' :nested)))) + (t/is (= (:parent-id outside) + (:parent-id (cths/get-shape file' :outside)))))) + :commits commits)))) + +(t/deftest group-shapes-of-two-nested-children-creates-a-group + (t/async + done + (let [file (variant-file-with-two-nested) + main (cths/get-shape file :root1)] + (run file + [(dwgr/group-shapes nil [(cthi/id :nested-a) (cthi/id :nested-b)])] + done + (fn [state] + (let [file' (ths/get-file-from-state state) + group (first (groups file'))] (t/is (some? group)) - (t/is (= (:id group) (:parent-id rect'))) - (t/is (= (:parent-id rect) (:parent-id group))))))))) + (t/is (= (:id main) (:parent-id group))) + (t/is (= (:id group) (:parent-id (cths/get-shape file' :nested-a)))) + (t/is (= (:id group) (:parent-id (cths/get-shape file' :nested-b)))))))))) + +(t/deftest group-shapes-of-main-and-nested-keeps-main-groups-nested + (t/async + done + (let [file (-> (variant-file) + (cths/add-sample-shape :nested :parent-label :root1)) + main (cths/get-shape file :root1) + nested (cths/get-shape file :nested) + commits (atom [])] + (run file + [(dwgr/group-shapes nil [(cthi/id :root1) (cthi/id :nested)])] + done + (fn [state] + (let [file' (ths/get-file-from-state state) + main' (cths/get-shape file' :root1) + nested' (cths/get-shape file' :nested) + group (first (groups file'))] + (t/is (pos? (count @commits))) + (t/is (some? group)) + (t/is (= (:parent-id main) (:parent-id main'))) + (t/is (= (:variant-id main) (:variant-id main'))) + (t/is (= (:id main') (:parent-id group))) + (t/is (= (:id group) (:parent-id nested'))) + (t/is (= (:parent-id nested) (:id main))))) + :commits commits)))) + +(t/deftest mask-group-of-main-and-nested-keeps-main-masks-nested + (t/async + done + (let [file (-> (variant-file) + (cths/add-sample-shape :nested :parent-label :root1)) + main (cths/get-shape file :root1) + nested (cths/get-shape file :nested) + commits (atom [])] + (run file + [(dwgr/mask-group [(cthi/id :root1) (cthi/id :nested)])] + done + (fn [state] + (let [file' (ths/get-file-from-state state) + main' (cths/get-shape file' :root1) + nested' (cths/get-shape file' :nested) + group (first (masked-groups file'))] + (t/is (pos? (count @commits))) + (t/is (some? group)) + (t/is (:masked-group group)) + (t/is (= (:parent-id main) (:parent-id main'))) + (t/is (= (:variant-id main) (:variant-id main'))) + (t/is (= (:id main') (:parent-id group))) + (t/is (= (:id group) (:parent-id nested'))) + (t/is (= (:parent-id nested) (:id main))))) + :commits commits)))) + +(t/deftest group-shapes-of-nested-in-different-variants-does-not-commit + (t/async + done + (let [file (variant-file-with-nested-in-each-main) + nested-a (cths/get-shape file :nested-a) + nested-b (cths/get-shape file :nested-b) + commits (atom [])] + (t/is (ctn/crosses-variant-set-boundary? + (:objects (cthf/current-page file)) + [nested-a nested-b])) + (run file + [(dwgr/group-shapes nil [(cthi/id :nested-a) (cthi/id :nested-b)])] + done + (fn [state] + (let [file' (ths/get-file-from-state state)] + (t/is (= 0 (count @commits))) + (t/is (empty? (groups file'))) + (t/is (= (:parent-id nested-a) + (:parent-id (cths/get-shape file' :nested-a)))) + (t/is (= (:parent-id nested-b) + (:parent-id (cths/get-shape file' :nested-b)))))) + :commits commits)))) + +(t/deftest mask-group-of-nested-in-different-variants-does-not-commit + (t/async + done + (let [file (variant-file-with-nested-in-each-main) + nested-a (cths/get-shape file :nested-a) + nested-b (cths/get-shape file :nested-b) + commits (atom [])] + (run file + [(dwgr/mask-group [(cthi/id :nested-a) (cthi/id :nested-b)])] + done + (fn [state] + (let [file' (ths/get-file-from-state state)] + (t/is (= 0 (count @commits))) + (t/is (empty? (masked-groups file'))) + (t/is (= (:parent-id nested-a) + (:parent-id (cths/get-shape file' :nested-a)))) + (t/is (= (:parent-id nested-b) + (:parent-id (cths/get-shape file' :nested-b)))))) + :commits commits)))) + +(t/deftest create-artboard-of-nested-and-outside-does-not-commit + (t/async + done + (let [file (variant-file-with-nested-and-outside) + nested (cths/get-shape file :nested) + outside (cths/get-shape file :outside) + commits (atom [])] + (run file + [(dws/select-shapes (d/ordered-set (cthi/id :nested) (cthi/id :outside))) + (dwsh/create-artboard-from-selection)] + done + (fn [state] + (let [file' (ths/get-file-from-state state)] + (t/is (= 0 (count @commits))) + (t/is (= (:parent-id nested) + (:parent-id (cths/get-shape file' :nested)))) + (t/is (= (:parent-id outside) + (:parent-id (cths/get-shape file' :outside)))))) + :commits commits)))) + +(t/deftest create-artboard-of-main-and-outside-does-not-commit + (t/async + done + (let [file (variant-file-with-rect) + rect (cths/get-shape file :rect) + main (cths/get-shape file :root1) + commits (atom [])] + (run file + [(dws/select-shapes (d/ordered-set (cthi/id :root1) (cthi/id :rect))) + (dwsh/create-artboard-from-selection)] + done + (fn [state] + (let [file' (ths/get-file-from-state state)] + (t/is (= 0 (count @commits))) + (t/is (= (:parent-id main) (:parent-id (cths/get-shape file' :root1)))) + (t/is (= (:parent-id rect) (:parent-id (cths/get-shape file' :rect)))))) + :commits commits)))) + +(t/deftest create-artboard-of-two-nested-children-commits + (t/async + done + (let [file (variant-file-with-two-nested) + main (cths/get-shape file :root1) + commits (atom [])] + (run file + [(dws/select-shapes (d/ordered-set (cthi/id :nested-a) (cthi/id :nested-b))) + (dwsh/create-artboard-from-selection)] + done + (fn [state] + (let [file' (ths/get-file-from-state state) + nested-a' (cths/get-shape file' :nested-a) + nested-b' (cths/get-shape file' :nested-b) + board (get (:objects (cthf/current-page file')) + (:parent-id nested-a'))] + (t/is (pos? (count @commits))) + (t/is (= :frame (:type board))) + (t/is (not (ctk/is-variant-container? board))) + (t/is (= (:id main) (:parent-id board))) + (t/is (= (:id board) (:parent-id nested-a'))) + (t/is (= (:id board) (:parent-id nested-b'))))) + :commits commits)))) + +(t/deftest create-artboard-of-main-and-nested-keeps-main-boards-nested + (t/async + done + (let [file (-> (variant-file) + (cths/add-sample-shape :nested :parent-label :root1)) + main (cths/get-shape file :root1) + nested (cths/get-shape file :nested) + commits (atom [])] + (run file + [(dws/select-shapes (d/ordered-set (cthi/id :root1) (cthi/id :nested))) + (dwsh/create-artboard-from-selection)] + done + (fn [state] + (let [file' (ths/get-file-from-state state) + main' (cths/get-shape file' :root1) + nested' (cths/get-shape file' :nested) + board (get (:objects (cthf/current-page file')) + (:parent-id nested'))] + (t/is (pos? (count @commits))) + (t/is (= :frame (:type board))) + (t/is (not (ctk/is-variant-container? board))) + (t/is (= (:parent-id main) (:parent-id main'))) + (t/is (= (:variant-id main) (:variant-id main'))) + (t/is (= (:id main') (:parent-id board))) + (t/is (= (:id board) (:parent-id nested'))) + (t/is (= (:parent-id nested) (:id main))))) + :commits commits)))) + +(t/deftest group-selected-of-main-and-nested-keeps-main-groups-nested + (t/async + done + (let [file (-> (variant-file) + (cths/add-sample-shape :nested :parent-label :root1)) + main (cths/get-shape file :root1) + nested (cths/get-shape file :nested) + commits (atom [])] + (run file + [(dws/select-shapes (d/ordered-set (cthi/id :root1) (cthi/id :nested))) + (dwgr/group-selected)] + done + (fn [state] + (let [file' (ths/get-file-from-state state) + main' (cths/get-shape file' :root1) + nested' (cths/get-shape file' :nested) + group (first (groups file'))] + (t/is (pos? (count @commits))) + (t/is (some? group)) + (t/is (= (:parent-id main) (:parent-id main'))) + (t/is (= (:variant-id main) (:variant-id main'))) + (t/is (= (:id main') (:parent-id group))) + (t/is (= (:id group) (:parent-id nested'))) + (t/is (= (:parent-id nested) (:id main))))) + :commits commits)))) + +(t/deftest mask-group-from-selection-of-main-and-nested-keeps-main-masks-nested + (t/async + done + (let [file (-> (variant-file) + (cths/add-sample-shape :nested :parent-label :root1)) + main (cths/get-shape file :root1) + nested (cths/get-shape file :nested) + commits (atom [])] + (run file + [(dws/select-shapes (d/ordered-set (cthi/id :root1) (cthi/id :nested))) + (dwgr/mask-group)] + done + (fn [state] + (let [file' (ths/get-file-from-state state) + main' (cths/get-shape file' :root1) + nested' (cths/get-shape file' :nested) + group (first (masked-groups file'))] + (t/is (pos? (count @commits))) + (t/is (some? group)) + (t/is (:masked-group group)) + (t/is (= (:parent-id main) (:parent-id main'))) + (t/is (= (:variant-id main) (:variant-id main'))) + (t/is (= (:id main') (:parent-id group))) + (t/is (= (:id group) (:parent-id nested'))) + (t/is (= (:parent-id nested) (:id main))))) + :commits commits)))) + +(t/deftest create-layout-of-nested-and-outside-does-not-commit + (t/async + done + (let [file (variant-file-with-nested-and-outside) + nested (cths/get-shape file :nested) + outside (cths/get-shape file :outside) + commits (atom [])] + (run file + [(dws/select-shapes (d/ordered-set (cthi/id :nested) (cthi/id :outside))) + (dwsl/create-layout-from-selection :flex)] + done + (fn [state] + (let [file' (ths/get-file-from-state state)] + (t/is (= 0 (count @commits))) + (t/is (= (:parent-id nested) + (:parent-id (cths/get-shape file' :nested)))) + (t/is (= (:parent-id outside) + (:parent-id (cths/get-shape file' :outside)))))) + :commits commits)))) + +(t/deftest create-layout-of-nested-in-different-variants-does-not-commit + (t/async + done + (let [file (variant-file-with-nested-in-each-main) + nested-a (cths/get-shape file :nested-a) + nested-b (cths/get-shape file :nested-b) + commits (atom [])] + (run file + [(dws/select-shapes (d/ordered-set (cthi/id :nested-a) (cthi/id :nested-b))) + (dwsl/create-layout :flex)] + done + (fn [state] + (let [file' (ths/get-file-from-state state)] + (t/is (= 0 (count @commits))) + (t/is (= (:parent-id nested-a) + (:parent-id (cths/get-shape file' :nested-a)))) + (t/is (= (:parent-id nested-b) + (:parent-id (cths/get-shape file' :nested-b)))))) + :commits commits)))) + +(t/deftest group-shapes-of-variant-container-only-does-not-commit + (t/async + done + (let [file (variant-file) + board (cths/get-shape file :variant) + commits (atom [])] + (run file + [(dwgr/group-shapes nil [(cthi/id :variant)])] + done + (fn [state] + (let [file' (ths/get-file-from-state state)] + (t/is (= 0 (count @commits))) + (t/is (empty? (groups file'))) + (t/is (= (:parent-id board) + (:parent-id (cths/get-shape file' :variant)))) + (t/is (ctk/is-variant-container? + (cths/get-shape file' :variant))))) + :commits commits)))) + +(t/deftest mask-group-of-variant-container-only-does-not-commit + (t/async + done + (let [file (variant-file) + board (cths/get-shape file :variant) + commits (atom [])] + (run file + [(dwgr/mask-group [(cthi/id :variant)])] + done + (fn [state] + (let [file' (ths/get-file-from-state state)] + (t/is (= 0 (count @commits))) + (t/is (empty? (masked-groups file'))) + (t/is (= (:parent-id board) + (:parent-id (cths/get-shape file' :variant)))) + (t/is (ctk/is-variant-container? + (cths/get-shape file' :variant))))) + :commits commits)))) + +(t/deftest create-artboard-of-variant-container-only-does-not-commit + (t/async + done + (let [file (variant-file) + board (cths/get-shape file :variant) + commits (atom [])] + (run file + [(dws/select-shapes (d/ordered-set (cthi/id :variant))) + (dwsh/create-artboard-from-selection)] + done + (fn [state] + (let [file' (ths/get-file-from-state state)] + (t/is (= 0 (count @commits))) + (t/is (= (:parent-id board) + (:parent-id (cths/get-shape file' :variant)))) + (t/is (ctk/is-variant-container? + (cths/get-shape file' :variant))))) + :commits commits)))) + +(t/deftest mask-group-of-two-nested-children-creates-a-mask + (t/async + done + (let [file (variant-file-with-two-nested) + main (cths/get-shape file :root1)] + (run file + [(dwgr/mask-group [(cthi/id :nested-a) (cthi/id :nested-b)])] + done + (fn [state] + (let [file' (ths/get-file-from-state state) + group (first (masked-groups file'))] + (t/is (= 1 (count (masked-groups file')))) + (t/is (:masked-group group)) + (t/is (= (:id main) (:parent-id group))) + (t/is (= (:id group) (:parent-id (cths/get-shape file' :nested-a)))) + (t/is (= (:id group) (:parent-id (cths/get-shape file' :nested-b)))))))))) + +(t/deftest create-layout-of-variant-container-only-does-not-commit + (t/async + done + (let [file (variant-file) + board (cths/get-shape file :variant) + commits (atom [])] + (t/is (not (ctl/any-layout? board))) + (run file + [(dws/select-shapes (d/ordered-set (cthi/id :variant))) + ;; Pin the create-layout entry (from-id hole), not only + ;; create-layout-from-selection which already no-ops. + (dwsl/create-layout :flex)] + done + (fn [state] + (let [file' (ths/get-file-from-state state) + board' (cths/get-shape file' :variant)] + (t/is (= 0 (count @commits))) + (t/is (not (ctl/any-layout? board'))) + (t/is (ctk/is-variant-container? board')) + (t/is (= (:parent-id board) (:parent-id board'))))) + :commits commits)))) + +(t/deftest create-layout-of-variant-main-commits + (t/async + done + (let [file (variant-file) + main (cths/get-shape file :root1) + board (cths/get-shape file :variant) + commits (atom [])] + (t/is (not (ctl/any-layout? main))) + (run file + [(dws/select-shapes (d/ordered-set (cthi/id :root1))) + ;; Locks create-layout → create-layout-from-id for a single main. + (dwsl/create-layout :flex)] + done + (fn [state] + (let [file' (ths/get-file-from-state state) + main' (cths/get-shape file' :root1)] + (t/is (pos? (count @commits))) + (t/is (ctl/flex-layout? main')) + (t/is (ctk/is-variant? main')) + (t/is (= (:variant-id main) (:variant-id main'))) + (t/is (= (:id board) (:parent-id main'))))) + :commits commits)))) + +(t/deftest create-layout-of-one-nested-child-commits + (t/async + done + (let [file (-> (variant-file) + (cths/add-sample-shape :nested :parent-label :root1)) + nested (cths/get-shape file :nested) + main (cths/get-shape file :root1) + commits (atom [])] + (run file + [(dws/select-shapes (d/ordered-set (cthi/id :nested))) + ;; Same Shift+A / menu entry as the container refuse and main allow. + (dwsl/create-layout :flex)] + done + (fn [state] + (let [file' (ths/get-file-from-state state) + nested' (cths/get-shape file' :nested) + ;; Layout wraps the child in a new board under the main. + parent' (get (:objects (cthf/current-page file')) + (:parent-id nested'))] + (t/is (pos? (count @commits))) + (t/is (not= (:parent-id nested) (:parent-id nested'))) + (t/is (= (:id main) (:parent-id parent'))) + (t/is (ctl/any-layout? parent')))) + :commits commits)))) (t/deftest ungroup-shapes-leaves-a-variant-container (t/async