From 086804627064473b74848e50aede03817aff3c4f Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Fri, 9 Oct 2026 23:33:22 +0200 Subject: [PATCH] :sparkles: Test the component sync paths no test reached A coverage run found sync paths that no test ran. New cases, in both runners: - AK: update main into a grid main. With a swapped child the main's cells keep the copy child's id (F49, pinned). - AL: update main after renaming the copy root and its child. - AM: update main of a swap inside a group. The copy child keeps a swap slot that names a removed main child (F16, pinned). - AN: a copy flex child's margin override through a main edit, a reset and update main. The frontend round trip of the main edit shows F43 with real sizes (pinned). - AO: a copy dropped into a grid cell of a main. Its undo shows F41 (pinned). - AP: a library main deletes a middle child. - AQ: a main change reaches a copy nested in a sub-board. Also: the frontend interpreter sends the real reset event instead of installing a file; a test undoes and redoes an undo entry without a group, which only release builds store; a test confirms F50, a copy text touched only in :content-group losing its override (pinned). --- .../frontend/composable-component-tests.md | 16 +- .../test_helpers/composable/comp/cases.cljc | 222 ++++++++++++++++++ .../test_helpers/composable/comp/nodes.cljc | 32 ++- .../test_helpers/composable/comp/setups.cljc | 105 +++++++++ .../logic/composable_sync_test.cljc | 21 ++ .../common_tests/logic/text_sync_test.cljc | 48 ++++ .../composable_tests/comp/sync_test.cljs | 21 ++ .../composable_tests/interpreter.cljs | 32 ++- .../data/workspace_undo_test.cljs | 75 ++++++ frontend/test/frontend_tests/runner.cljs | 2 + 10 files changed, 543 insertions(+), 31 deletions(-) create mode 100644 frontend/test/frontend_tests/data/workspace_undo_test.cljs diff --git a/.serena/memories/frontend/composable-component-tests.md b/.serena/memories/frontend/composable-component-tests.md index 839eecc4da..7e59c7b838 100644 --- a/.serena/memories/frontend/composable-component-tests.md +++ b/.serena/memories/frontend/composable-component-tests.md @@ -99,8 +99,8 @@ Two pure runners (no store, JVM + CLJS): situation's files) once settled: after the sync fixpoint and after an undo group; never between cascade passes, where an outer copy still points at the component its main dropped (`:component-id-mismatch`), nor with `{:sync? false}`. Assembly ops (create/instantiate/nest/variant - container): no sync, no undo group. `n/reset` (`reset-copy-instance` resets a lineage's latest copy) is a user op here (sync + - undo), unlike the interpreter (file install). `{:sync? false}` disables sync. `Rotate`/`ChangeHeight`/`MoveShape` + container): no sync, no undo group. `n/reset` (`reset-copy-instance` resets a lineage's latest copy) is a user op (sync + + undo); the interpreter sends `dwl/reset-component`. `{:sync? false}` disables sync. `Rotate`/`ChangeHeight`/`MoveShape` are `:frontend-only` and throw `::runner/frontend-only` (the placement step is frontend code). Swap and switch nodes use the production `clv/generate-component-swap-in-place` (+ `clv/find-switch-target` for switch), the same code the frontend events call. Not modelled: the `:modified-at` bump, and the swap/switch event follow-ups @@ -126,7 +126,7 @@ Two pure runners (no store, JVM + CLJS): changes only when the failure is understood, and the mark goes only in the fix commit. The interpreter runs the same round trip on every `:user` and `:frontend-only` event step through the real `dwu/undo`/`dwu/redo`, and also checks the workspace undo index (`:undo-index`/`:redo-index` phases): a step must add exactly one undo - entry or group. File installs (assembly ops, reset) are its baselines; a skipped optional and an inline `Test` are not. The interpreter + entry or group. File installs (assembly ops) are its baselines; a skipped optional and an inline `Test` are not. The interpreter empties `:workspace-undo` per variant. Frontend snapshots drop `:position-data` under WASM. Undo groups get lost in derived work: `initialize-shape-layout` buffers `:layout/update` without its `:undo-group` (F43), so a swap's reflow lands in entries of its own. Match @@ -145,8 +145,9 @@ Two pure runners (no store, JVM + CLJS): their undo group (F43) at timing-dependent points (`initialize-shape-layout` buffers `:layout/update` in 100 ms windows): undo pins over such reflows are nondeterministic. New cases that are not about geometry pass `:size` (nesting sizes the outer frames too). - F43 lives in case AJ only (frontend only, round trip off): it pins the value one undo - leaves, which does not depend on how many entries the reflow makes. + F43 from float noise lives in case AJ only (frontend only, round trip off): it pins the + value one undo leaves, which does not depend on how many entries the reflow makes. Case AN + pins F43 from a real reflow (a flex main's margin edit, synced): stable in repeated runs. - Case assertions a known bug breaks use `tm/known-failure bug expected pinned actual`: a strict expected failure at assertion level (passes only while `actual` is the pinned wrong value). The undo-check marks cover only round-trip failures. @@ -158,8 +159,7 @@ Two pure runners (no store, JVM + CLJS): - Depth sweeps: `depth-sweep` in `cases.cljc`, a one-of over skip, `(n/make-nested-component name)` and `(n/make-nested-component name 2)`; asserters read `n/nesting-depth`. Two independent nesting optionals run depth 1 twice. -- General target-based nodes: `n/swap-shape target to` (`to` a lineage name or a component label), `n/move-to-cell board child row column`, `n/reset target` (a file install - in the interpreter). `n/swap-component name level to` is `swap-shape` on `n/nested-head-of`. `n/nested-head-in name` (and the general `n/nested-head-under parent-target`) finds a copy's nested +- General target-based nodes: `n/swap-shape target to` (`to` a lineage name or a component label), `n/move-to-cell board child row column`, `n/reset target` (`dwl/reset-component` in the interpreter). `n/swap-component name level to` is `swap-shape` on `n/nested-head-of`. `n/nested-head-in name` (and the general `n/nested-head-under parent-target`) finds a copy's nested head through its parent, so it survives swaps and switches (they keep the head id anyway). Careful: `n/lineage-component-id` is the OUTER component after nesting. - `n/undo-before-sync` (frontend-only) emits an edit and `dwu/undo` in one step, so the undo @@ -168,7 +168,7 @@ Two pure runners (no store, JVM + CLJS): before running the next one (`thi/id` then points at the new run's shapes). Case letters B..Z, then AA..; the sweeps (K: depth × edit-precedence; L: swaps; M: variant switches; N: rotated-instance geometry, on the #10109 fix branch until merged; O: switch of an overridden copy, mains -agreeing or not; Q: detach at depth 0..2, nested copies stay linked; R: update-main at depth 0..2 with two copies; S: delete/restore with a copy on another page; T: duplicate a copy at depth 0..2; U: move a copy into a main with a copy; V: new edit after undo empties redo; W: `(n/undo 3)` over nested cascades; X: undo before the edit's sync, frontend only; AJ: F43, one undo after a nested sync reverts only the reflow, frontend only; Y: text override × main text change, per subgroup; Z: main child move, frontend only; AA..AE: swap outcomes asserted on values, not slot storage, so they survive the Phase 2b swap model; AF: grid cell move vs a swapped copy child, setup `grid-component-with-copy`; AG/AH/AI: ports of the plugin's slot-alignment cases, setup `component-with-sibling-instances`, check `setup/misaligned-sub-heads`) are the flagship pattern — read them before +agreeing or not; Q: detach at depth 0..2, nested copies stay linked; R: update-main at depth 0..2 with two copies; S: delete/restore with a copy on another page; T: duplicate a copy at depth 0..2; U: move a copy into a main with a copy; V: new edit after undo empties redo; W: `(n/undo 3)` over nested cascades; X: undo before the edit's sync, frontend only; AJ: F43, one undo after a nested sync reverts only the reflow, frontend only; Y: text override × main text change, per subgroup; Z: main child move, frontend only; AA..AE: swap outcomes asserted on values, not slot storage, so they survive the Phase 2b swap model; AF: grid cell move vs a swapped copy child, setup `grid-component-with-copy`; AG/AH/AI: ports of the plugin's slot-alignment cases, setup `component-with-sibling-instances`, check `setup/misaligned-sub-heads`; AK: update main into a grid main, F49 pinned; AL: update main renames the component (`change-property` takes `:name`); AM: update main of a swap inside a group, setup `grouped-component-with-copy`, F16 pinned; AN: flex child margin override through main edit, reset and update main, setup `flex-component-with-copy`; `change-property` takes `:margin`, which the interpreter sends as `dwsl/update-layout-child`; AO: a copy dropped into a grid cell of a main (`nest-copy` with `:cell`, sent as `dwt/move-shapes-to-frame`), F41 pinned; AP: a library main's middle child removal reaches a copy in another file (in one file, deleting a main child deletes the copies' children itself, so sync never meets the copy child), setup `cross-file-component-with-many-children`; AQ: main change reaches a copy nested in a sub-board, setup `component-nested-in-a-sub-board`) are the flagship pattern — read them before writing a new sweep. **Scenario lineage model** (behind the sweeps): scenario ops track named component lineages as diff --git a/common/src/app/common/test_helpers/composable/comp/cases.cljc b/common/src/app/common/test_helpers/composable/comp/cases.cljc index a028ee5fc2..61a109afcc 100644 --- a/common/src/app/common/test_helpers/composable/comp/cases.cljc +++ b/common/src/app/common/test_helpers/composable/comp/cases.cljc @@ -48,6 +48,13 @@ AG — a main reorder keeps the copy's nested copies aligned. AH, AI — a copy deleting a nested copy keeps the others aligned (AI also reflows, frontend only). + AK — update main pushes a copy's grid cells to a grid main. + AL — update main renames the component after a copy root rename. + AM — update main pushes a swap made inside a group. + AN — a flex child's margin override through sync, reset and update main. + AO — a copy dropped into a grid cell of a main reaches the main's copies. + AP — a library main's middle child removal reaches a copy in another file. + AQ — a main change reaches a copy nested in a sub-board of the outer main. N — main edits reach rotated copies (frontend only). Rotation — a touched copy child survives a main rotation (frontend only too)." (:require @@ -62,6 +69,7 @@ [app.common.test-helpers.composable.core :as tm] [app.common.test-helpers.files :as thf] [app.common.types.component :as ctk] + [app.common.types.components-list :as ctkl] [app.common.types.shape-tree :as ctst] [app.common.types.shape.layout :as ctl] [clojure.test :as t])) @@ -876,6 +884,220 @@ :asserter (fn [s] (t/is (= [] (setup/misaligned-sub-heads s))))}) +(defn- grid-placement + "The cells of grid board `board`, as `{column [child-shape ...]}`." + [s board] + (let [objects (:objects (thf/current-page (tm/file s)))] + (into {} + (map (fn [cell] [(:column cell) (mapv #(get objects %) (:shapes cell))])) + (vals (:layout-grid-cells board))))) + +(defn update-main-pushes-grid-cells + "Case AK. A component's main is a grid board holding two nested copies; + optionally its copy swaps the first of them. The copy moves that child to + the free third column, then pushes its overrides to the main (update + main). The main's child at that position moves to the third column: an + instance of the component the copy swapped to, if it swapped, else the + original. Every cell of the main holds only children of the main, and each + child is in a cell. The copy is left untouched." + [] + (let [swap (n/swap-shape :copy-child :other)] + {:setup setup/grid-component-with-copy + :operation (tm/in-sequence [(tm/optional swap) + (n/move-to-cell :copy-root :copy-child 1 3) + (n/update-main :copy-root)]) + :asserter (fn [s] + (let [main (tm/role-shape s :main-root) + copy-child (tm/role-shape s :copy-child) + placement (grid-placement s main) + in-cells (into #{} (comp (mapcat val) (map :id)) placement) + cells {:stray (into #{} (remove (set (:shapes main))) in-cells) + :uncelled (count (remove in-cells (:shapes main)))} + ;; the main's third column takes the copy child's component + in-col-3 (mapv :component-id (get placement 3)) + expected [(:component-id copy-child)]] + (if (tm/applied? s swap) + ;; the cells keep the copy child's id, which no main child has + (tm/known-failure "F49" + [{:stray #{} :uncelled 0} expected] + [{:stray #{(:id copy-child)} :uncelled 1} expected] + [cells in-col-3]) + (do + (t/is (= {:stray #{} :uncelled 0} cells)) + (t/is (= expected in-col-3)))) + (t/is (empty? (:touched (tm/role-shape s :copy-root))))))})) + +(defn update-main-renames-the-component + "Case AL. A copy renames its root and optionally its child, then pushes its + overrides to the main (update main). The component and its main root take + the root's new name, the main child takes the child's new name when it was + renamed, and the copy is left untouched." + [] + (let [rename-root (n/change-property :copy-root :name "Renamed") + rename-child (n/change-property :copy-instance :name "Renamed child")] + {:setup setup/simple-component-with-copy + :operation (tm/in-sequence [rename-root + (tm/optional rename-child) + (n/update-main :copy-root)]) + :asserter (fn [s] + (let [main-root (tm/role-shape s :main-root) + component (ctkl/get-component (:data (tm/file s)) + (:component-id main-root))] + (t/is (= "Renamed" (:name component))) + (t/is (n/has-property-of rename-root main-root)) + (when (tm/applied? s rename-child) + (t/is (n/has-property-of rename-child (tm/role-shape s :main-instance)))) + (t/is (empty? (:touched (tm/role-shape s :copy-root)))) + (t/is (empty? (:touched (tm/role-shape s :copy-instance))))))})) + +(defn update-main-pushes-a-swap-inside-a-group + "Case AM. A component's main holds a nested copy inside a group; its copy + swaps that nested copy for another component, then pushes its overrides to + the main (update main). The main's group holds an instance of the + component swapped to in place of the old one, the copy's swapped child + references it, and the copy is left untouched." + [] + {:setup setup/grouped-component-with-copy + :operation (tm/in-sequence [(n/swap-shape :copy-child :other) + (n/update-main :copy-root)]) + :asserter (fn [s] + (let [objects (:objects (thf/current-page (tm/file s))) + copy-child (tm/role-shape s :copy-child) + group (get objects (:parent-id (get objects (:shape-ref copy-child)))) + in-group (mapv #(get objects %) (:shapes group))] + (t/is (= (:id (tm/role-shape s :main-root)) (:parent-id group))) + (t/is (= [(:component-id copy-child)] (mapv :component-id in-group))) + (t/is (= [(:shape-ref copy-child)] (mapv :id in-group))) + (t/is (empty? (:touched (tm/role-shape s :copy-root)))) + ;; update main leaves the swapped child its swap slot + (tm/known-failure "F16" #{} #{:swap-slot} + (into #{} + (map #(if (ctk/swap-slot? %) :swap-slot %)) + (:touched copy-child)))))}) + +(defn flex-child-override-follows-sync-reset-and-update-main + "Case AN. A copy of a flex main overrides its child's margin. Then the main + changes that margin, the copy resets, or the copy runs update main. After + the main's change the copy keeps its own margin, touched; after the reset + it shows the main's, untouched; after update main the main shows the + copy's and the copy is untouched." + [] + (let [override (n/change-property :copy-instance :margin 8) + main-change (n/change-property :main-instance :margin 20) + reset (n/reset :copy-root) + update-main (n/update-main :copy-root) + follow-up (tm/one-of [main-change reset update-main])] + {:setup setup/flex-component-with-copy + ;; the reflow after the sync of the main's margin lands in undo entries + ;; of its own, so one undo leaves the margin and the reflow in place + :undo-check {:known-failures [{:bug "F43" + :fails {:undo #{[:object :main-child :layout-item-margin] + [:object :main-child :layout-item-margin-type] + [:object :main-child :x] + [:object :main-child :selrect] + [:object :main-child :points]} + :undo-index #{[:undo-index 2]}} + :op main-change + :runners #{:frontend}}]} + :operation (tm/in-sequence [override follow-up]) + :asserter (fn [s] + (let [copy (tm/role-shape s :copy-instance) + main (tm/role-shape s :main-instance) + margin (fn [shape] (-> shape :layout-item-margin :m1)) + chosen (tm/get-choice s follow-up) + touched (contains? (:touched copy) :layout-item-margin)] + (condp = chosen + main-change (do (t/is (n/has-property-of override copy)) + (t/is (n/has-property-of main-change main)) + (t/is touched)) + reset (do (t/is (= (margin main) (margin copy))) + (t/is (not (n/has-property-of override copy))) + (t/is (not touched))) + update-main (do (t/is (n/has-property-of override main)) + (t/is (n/has-property-of override copy)) + (t/is (not touched))))))})) + +(defn copy-dropped-into-a-grid-cell-reaches-copies + "Case AO. A grid main holds two nested copies in its first two columns, the + third free; a loose copy of the same component is dropped into the third + cell of the main. The main holds it in that cell, and the main's copy gets + a child that references it, in its own third cell. Every child of both is + in a cell, and the copy is left untouched." + [] + (let [drop (n/nest-copy :loose :main-root :cell [1 3])] + {:setup setup/grid-component-with-copy-and-loose-item + ;; the drop reorders the main's children; the undo of the sync's reorder + ;; leaves the copy's children reordered + :undo-check {:known-failures [{:bug "F41" + :fails {:undo #{[:object :grid-copy :shapes]} + :variant-undo #{[:object :grid-copy :shapes]}} + :op drop}]} + :operation (tm/in-sequence [drop]) + :asserter (fn [s] + (let [main (tm/role-shape s :main-root) + copy (tm/role-shape s :copy-root) + loose-id (:id (tm/role-shape s :loose)) + placement (fn [board] + (let [placed (grid-placement s board)] + {:col-3 (mapv :id (get placed 3)) + :uncelled (count (remove (into #{} (comp (mapcat val) (map :id)) placed) + (:shapes board)))})) + in-copy (d/seek #(= loose-id (:shape-ref %)) + (map #(tm/shape-by-id s %) (:shapes copy)))] + (t/is (= {:col-3 [loose-id] :uncelled 0} (placement main))) + (t/is (some? in-copy)) + (t/is (= {:col-3 [(:id in-copy)] :uncelled 0} (placement copy))) + (t/is (empty? (:touched copy))) + (t/is (empty? (:touched in-copy)))))})) + +(defn library-main-child-removal-reaches-copy + "Case AP. A library main with three children deletes its middle one; a copy + in the consuming file then takes the library update. The copy loses its + middle child and keeps the others in order, linked and untouched." + [] + {:setup setup/cross-file-component-with-many-children + :operation (tm/in-sequence [(n/sync-from-library)]) + :asserter (fn [s] + (let [c1 (tm/role-shape s :copy-child-1) + c3 (tm/role-shape s :copy-child-3)] + (t/is (= [(:id c1) (:id c3)] (:shapes (tm/role-shape s :copy-root)))) + (t/is (some? (:shape-ref c1))) + (t/is (some? (:shape-ref c3))) + (t/is (empty? (:touched c1))) + (t/is (empty? (:touched c3)))))}) + +(defn main-change-reaches-a-copy-nested-in-a-sub-board + "Case AQ. An outer main holds a copy of an inner component inside a + sub-board, not directly under its root; the outer main has a copy, which + optionally overrides the inner rect's fill. The inner main changes that + fill. The outer main's nested copy shows the change, and so does the outer + copy unless it overrode it: then it keeps its value, touched." + [] + (let [override (n/change-property (fn [s] + (n/corresponding-shape-id s + (:id (tm/role-shape s :copy-root)) + (:id (tm/role-shape s :main-instance)))) + :fills green) + change (n/change-property :main-instance :fills red)] + {:setup setup/component-nested-in-a-sub-board + :operation (tm/in-sequence [(tm/optional override) change]) + :asserter (fn [s] + (let [image (fn [role] + (tm/shape-by-id s (n/corresponding-shape-id + s + (:id (tm/role-shape s role)) + (:id (tm/role-shape s :main-instance))))) + in-main (image :main-root) + in-copy (image :copy-root)] + (t/is (n/has-property-of change in-main)) + (if (tm/applied? s override) + (do + (t/is (n/has-property-of override in-copy)) + (t/is (contains? (:touched in-copy) :fill-group))) + (do + (t/is (n/has-property-of change in-copy)) + (t/is (empty? (:touched in-copy)))))))})) + (defn geometry-sync-with-rotated-instances "Case N (frontend only). Optionally the copy rotates, optionally the main rotates, then the main child changes its fill or its height. The change diff --git a/common/src/app/common/test_helpers/composable/comp/nodes.cljc b/common/src/app/common/test_helpers/composable/comp/nodes.cljc index c8b0c28794..56d4c43692 100644 --- a/common/src/app/common/test_helpers/composable/comp/nodes.cljc +++ b/common/src/app/common/test_helpers/composable/comp/nodes.cljc @@ -80,8 +80,12 @@ (case property :fills (assoc shape :fills (ths/sample-fills-color :fill-color value)) :opacity (assoc shape :opacity value) + :name (assoc shape :name value) + :margin (assoc shape + :layout-item-margin-type :simple + :layout-item-margin {:m1 value :m2 value :m3 value :m4 value}) (throw (ex-info (str "set-property: unsupported property " (pr-str property) - " (supported: :fills, :opacity)") + " (supported: :fills, :opacity, :name, :margin)") {:property property})))) (defn- read-property @@ -90,8 +94,10 @@ (case property :fills (-> shape :fills first :fill-color) :opacity (:opacity shape) + :name (:name shape) + :margin (-> shape :layout-item-margin :m1) (throw (ex-info (str "read-property: unsupported property " (pr-str property) - " (supported: :fills, :opacity)") + " (supported: :fills, :opacity, :name, :margin)") {:property property})))) (defrecord ChangeProperty [target property value] @@ -687,9 +693,8 @@ ;; reset — reset the overrides of a copy head ;; ;; The "Reset overrides" action on the copy head bound to `target`, a nested -;; head included, through the production `generate-reset-component`. The -;; frontend interpreter runs it as a file install: the real reset event reads -;; browser globals, so it cannot run headless. +;; head included, through the production `generate-reset-component` +;; (`reset-component` on the frontend). ;; --------------------------------------------------------------------------- (defrecord ResetOverrides [target] @@ -955,7 +960,8 @@ ;; ;; Moves the copy head bound to `copy` under the shape bound to `into`, as its ;; last child, through `generate-relocate`, as dragging it in the layers -;; panel does. Unlike `make-nested-component` it is a user action, so the +;; panel does; with a `cell` ([row column] of a grid `into`), into that cell, +;; as a canvas drop does. Unlike `make-nested-component` it is a user action, so the ;; receiving main's copies sync and the step goes through undo. ;; --------------------------------------------------------------------------- @@ -965,7 +971,7 @@ [situation parent-id] (count (:shapes (tm/shape-by-id situation parent-id)))) -(defrecord NestCopy [copy into] +(defrecord NestCopy [copy into cell] IComponentOperation (op-kind [_] :user) @@ -980,18 +986,20 @@ (pcb/with-objects (:objects page))) parent-id (nest-target-index situation parent-id) - #{copy-id}) + #{copy-id} + :cell cell) file' (thf/apply-changes the-file changes :validate? false)] (-> situation (tm/with-file file') (record-changes changes) - (tm/record-application this {:copy copy :into into}))))) + (tm/record-application this {:copy copy :into into :cell cell}))))) (defn nest-copy "Constructor for the nest-copy operation: move the copy head `copy` names - under the shape `into` names, as its last child." - [copy into] - (tm/assign-id (->NestCopy copy into))) + under the shape `into` names, as its last child, or into the grid cell + `cell` ([row column]) when given." + [copy into & {:keys [cell]}] + (tm/assign-id (->NestCopy copy into cell))) ;; --------------------------------------------------------------------------- ;; swap-shape — swap a copy head for another component diff --git a/common/src/app/common/test_helpers/composable/comp/setups.cljc b/common/src/app/common/test_helpers/composable/comp/setups.cljc index 94435f5295..a1abe8c0fb 100644 --- a/common/src/app/common/test_helpers/composable/comp/setups.cljc +++ b/common/src/app/common/test_helpers/composable/comp/setups.cljc @@ -39,6 +39,22 @@ :copy-root :copy-root}))) +(defn flex-component-with-copy + "Like `simple-component-with-copy`, with a 100x100 main root that has a + flex layout (row) and a 50x50 child. Same labels and roles." + [] + (let [file (-> (thf/sample-file :file1) + (tho/add-simple-component-with-copy + :component1 :main-root :main-child :copy-root + :main-root-params {:layout :flex :layout-flex-dir :row + :width 100 :height 100} + :main-child-params {:width 50 :height 50} + :copy-root-params {:children-labels [:copy-child]}))] + (tm/make-situation file {:main-instance :main-child + :copy-instance :copy-child + :main-root :main-root + :copy-root :copy-root}))) + (defn empty-situation "A situation with an empty file and no roles, for cases that build their configuration with operations (`create-component` and the like)." @@ -145,6 +161,43 @@ :main-child :child1 :copy-child :copy-child1}))) +(defn grouped-component-with-copy + "A component whose main board holds a group with a copy of an `:item` + component, and a copy of it. A second component, `:other`, has a red rect + (a swap target). Labels: :grouped-root :group :child1 (main side), + :grouped-copy :copy-group :copy-child1 (copy side), :item, :other. Roles: + :main-root, :copy-root, :main-child -> :child1, :copy-child -> :copy-child1." + [] + (let [file (-> (thf/sample-file :file1) + (tho/add-simple-component :item :item-root :item-rect + :root-params {:width 100 :height 100} + :child-params {:width 50 :height 50}) + (tho/add-simple-component :other :other-root :other-rect + :root-params {:width 100 :height 100} + :child-params {:width 50 :height 50 + :fills (ths/sample-fills-color + :fill-color "#ff0000")}) + (tho/add-frame :grouped-root :width 200 :height 200) + (tho/add-group :group :parent-label :grouped-root) + (thc/instantiate-component :item :child1 :parent-label :group) + (thc/make-component :grouped :grouped-root) + (thc/instantiate-component :grouped :grouped-copy))] + (label-copy-children! file :grouped-copy {:group :copy-group}) + (label-copy-children! file :copy-group {:child1 :copy-child1}) + (tm/make-situation file {:main-root :grouped-root + :copy-root :grouped-copy + :main-child :child1 + :copy-child :copy-child1}))) + +(defn grid-component-with-copy-and-loose-item + "`grid-component-with-copy` plus a top-level copy of `:item`, labelled + :loose. Same roles, plus :loose." + [] + (let [situation (grid-component-with-copy)] + (-> situation + (tm/with-file (thc/instantiate-component (tm/file situation) :item :loose)) + (assoc-in [:roles :loose] (thi/id :loose))))) + (defn component-with-sibling-instances "A component whose main (300x100) holds three copies of an `:icon` component side by side, and a copy of it. With `:grid` layout the main is a @@ -279,3 +332,55 @@ (-> (tm/make-situation file {:copy-instance :copy-child :copy-root :copy-root}) (tm/with-aux-files {(:id library') library'})))) + +(defn cross-file-component-with-many-children + "A component with three children in a shared library file and a copy of it + in the current file; the library main then deletes its middle child, so + the copy is behind it. The library travels as an auxiliary file. Labels: + in the library :component1 :main-root :main-child1/2/3, in the file + :copy-root :copy-child1/2/3. Roles: :copy-root and :copy-child-1/2/3." + [] + (let [library (-> (thf/sample-file :library :is-shared true) + (tho/add-component-with-many-children + :component1 :main-root [:main-child1 :main-child2 :main-child3])) + file (-> (thf/sample-file :file) + (thc/instantiate-component :component1 :copy-root + :library library + :children-labels [:copy-child1 + :copy-child2 + :copy-child3])) + page (thf/current-page library) + [_ del] (cls/generate-delete-shapes (pcb/empty-changes nil (:id page)) + (:data library) + page + (:objects page) + #{(thi/id :main-child2)} + {}) + library' (thf/apply-changes library del)] + (-> (tm/make-situation file {:copy-root :copy-root + :copy-child-1 :copy-child1 + :copy-child-2 :copy-child2 + :copy-child-3 :copy-child3}) + (tm/with-aux-files {(:id library') library'})))) + +(defn component-nested-in-a-sub-board + "An `:inner` component (root and rect) and an `:outer` one whose main board + holds a sub-board with a copy of `:inner` in it, and a copy of `:outer`. + Labels: :inner-root :inner-rect, :outer-root :sub-board :nested, + :outer-copy. Roles: :main-instance -> :inner-rect, :main-root -> + :outer-root, :copy-root -> :outer-copy." + [] + (let [file (-> (thf/sample-file :file1) + (tho/add-simple-component :inner :inner-root :inner-rect + :root-params {:width 50 :height 50} + :child-params {:width 20 :height 20 + :fills (ths/sample-fills-color + :fill-color labeled-fill)}) + (tho/add-frame :outer-root :width 200 :height 200) + (tho/add-frame :sub-board :parent-label :outer-root :width 100 :height 100) + (thc/instantiate-component :inner :nested :parent-label :sub-board) + (thc/make-component :outer :outer-root) + (thc/instantiate-component :outer :outer-copy))] + (tm/make-situation file {:main-instance :inner-rect + :main-root :outer-root + :copy-root :outer-copy}))) diff --git a/common/test/common_tests/logic/composable_sync_test.cljc b/common/test/common_tests/logic/composable_sync_test.cljc index aaa3308626..21f5072884 100644 --- a/common/test/common_tests/logic/composable_sync_test.cljc +++ b/common/test/common_tests/logic/composable_sync_test.cljc @@ -112,3 +112,24 @@ (t/deftest case-ah-copy-sub-head-delete-keeps-others-aligned (check (cases/copy-sub-head-delete-keeps-others-aligned))) + +(t/deftest case-ak-update-main-pushes-grid-cells + (check (cases/update-main-pushes-grid-cells))) + +(t/deftest case-al-update-main-renames-the-component + (check (cases/update-main-renames-the-component))) + +(t/deftest case-am-update-main-pushes-a-swap-inside-a-group + (check (cases/update-main-pushes-a-swap-inside-a-group))) + +(t/deftest case-an-flex-child-override-follows-sync-reset-and-update-main + (check (cases/flex-child-override-follows-sync-reset-and-update-main))) + +(t/deftest case-ao-copy-dropped-into-a-grid-cell-reaches-copies + (check (cases/copy-dropped-into-a-grid-cell-reaches-copies))) + +(t/deftest case-ap-library-main-child-removal-reaches-copy + (check (cases/library-main-child-removal-reaches-copy))) + +(t/deftest case-aq-main-change-reaches-a-copy-nested-in-a-sub-board + (check (cases/main-change-reaches-a-copy-nested-in-a-sub-board))) diff --git a/common/test/common_tests/logic/text_sync_test.cljc b/common/test/common_tests/logic/text_sync_test.cljc index 0863b3e75e..978b6c3f9b 100644 --- a/common/test/common_tests/logic/text_sync_test.cljc +++ b/common/test/common_tests/logic/text_sync_test.cljc @@ -10,6 +10,7 @@ [app.common.logic.libraries :as cll] [app.common.logic.shapes :as cls] [app.common.test-helpers.components :as thc] + [app.common.test-helpers.composable.core :as tm] [app.common.test-helpers.compositions :as tho] [app.common.test-helpers.files :as thf] [app.common.test-helpers.ids-map :as thi] @@ -879,3 +880,50 @@ (t/is (= "14" (:font-size line))) ;; The text doesn't change, because the structure was touched (t/is (= "hello world" (:text line))))) + +(t/deftest sync-copy-touched-only-in-content-group + ;; A copy text whose `:touched` has `:content-group` but no text subgroup, + ;; as files from before the subgroups hold it, keeps its own content + ;; when the main changes. + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + (tho/add-frame-with-text :main-root :main-child "hello world") + (thc/make-component :component1 :main-root) + (thc/instantiate-component :component1 :copy-root {:children-labels [:copy-child]})) + copy-child (ths/get-shape file :copy-child) + file (-> file + (ths/update-shape :copy-child :content + (assoc-in (:content copy-child) + [:children 0 :children 0 :children 0 :text] + "copy text")) + (ths/update-shape :copy-child :touched #{:content-group})) + page (thf/current-page file) + + ;; ==== Action + changes1 (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(thi/id :main-child)} + (fn [shape] + (assoc-in shape [:content :children 0 :children 0 :children 0 :font-size] "32")) + (:objects page) + {}) + + updated-file (thf/apply-changes file changes1) + + changes2 (cll/generate-sync-file-changes (pcb/empty-changes) + nil + :components + (:id updated-file) + (thi/id :component1) + (:id updated-file) + {(:id updated-file) updated-file} + (:id updated-file)) + + file' (thf/apply-changes updated-file changes2) + + ;; ==== Get + copy-child' (ths/get-shape file' :copy-child) + line (get-in copy-child' [:content :children 0 :children 0 :children 0])] + ;; with no subgroup, sync takes the "nothing touched" branch and writes + ;; the main's content + (tm/known-failure "F50" "copy text" "hello world" (:text line)) + (t/is (= #{:content-group} (:touched copy-child'))))) diff --git a/frontend/test/frontend_tests/composable_tests/comp/sync_test.cljs b/frontend/test/frontend_tests/composable_tests/comp/sync_test.cljs index deefb59ce6..09a27e2ed9 100644 --- a/frontend/test/frontend_tests/composable_tests/comp/sync_test.cljs +++ b/frontend/test/frontend_tests/composable_tests/comp/sync_test.cljs @@ -116,3 +116,24 @@ (t/deftest case-ai-copy-sub-head-delete-and-reflow-keeps-others-aligned (t/async done (ftm/check done (cases/copy-sub-head-delete-and-reflow-keeps-others-aligned)))) + +(t/deftest case-ak-update-main-pushes-grid-cells + (t/async done (ftm/check done (cases/update-main-pushes-grid-cells)))) + +(t/deftest case-al-update-main-renames-the-component + (t/async done (ftm/check done (cases/update-main-renames-the-component)))) + +(t/deftest case-am-update-main-pushes-a-swap-inside-a-group + (t/async done (ftm/check done (cases/update-main-pushes-a-swap-inside-a-group)))) + +(t/deftest case-an-flex-child-override-follows-sync-reset-and-update-main + (t/async done (ftm/check done (cases/flex-child-override-follows-sync-reset-and-update-main)))) + +(t/deftest case-ao-copy-dropped-into-a-grid-cell-reaches-copies + (t/async done (ftm/check done (cases/copy-dropped-into-a-grid-cell-reaches-copies)))) + +(t/deftest case-ap-library-main-child-removal-reaches-copy + (t/async done (ftm/check done (cases/library-main-child-removal-reaches-copy)))) + +(t/deftest case-aq-main-change-reaches-a-copy-nested-in-a-sub-board + (t/async done (ftm/check done (cases/main-change-reaches-a-copy-nested-in-a-sub-board)))) diff --git a/frontend/test/frontend_tests/composable_tests/interpreter.cljs b/frontend/test/frontend_tests/composable_tests/interpreter.cljs index 6526a1952d..bbaf7d8952 100644 --- a/frontend/test/frontend_tests/composable_tests/interpreter.cljs +++ b/frontend/test/frontend_tests/composable_tests/interpreter.cljs @@ -27,6 +27,7 @@ [app.main.data.workspace.libraries :as dwl] [app.main.data.workspace.reflow :as wrf] [app.main.data.workspace.selection :as dws] + [app.main.data.workspace.shape-layout :as dwsl] [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.texts :as dwtx] [app.main.data.workspace.thumbnails :as dwth] @@ -89,9 +90,14 @@ [op situation] (cond (instance? n/ChangeProperty op) - (let [{:keys [target property value]} op] - [(dwsh/update-shapes #{(tm/target-shape-id situation target)} - (fn [shape] (n/set-property shape property value)))]) + (let [{:keys [target property value]} op + id (tm/target-shape-id situation target)] + (if (= :margin property) + ;; the sidebar's layout child margin + (let [shape (n/set-property {} property value)] + [(dwsl/update-layout-child [id] (select-keys shape [:layout-item-margin + :layout-item-margin-type]))]) + [(dwsh/update-shapes #{id} (fn [shape] (n/set-property shape property value)))])) (instance? n/EditText op) ;; the text editor's commit on blur, SVG renderer path @@ -146,6 +152,9 @@ (instance? n/Detach op) [(dwl/detach-component (tm/target-shape-id situation (:target op)))] + (instance? n/ResetOverrides op) + [(dwl/reset-component (tm/target-shape-id situation (:target op)))] + (instance? n/UpdateMain op) [(dwl/update-component-sync (tm/target-shape-id situation (:target op)) (:id (tm/file situation)))] @@ -159,10 +168,13 @@ :change-selection? false)] (instance? n/NestCopy op) - (let [parent-id (tm/target-shape-id situation (:into op))] - [(dwsh/relocate-shapes #{(tm/target-shape-id situation (:copy op))} - parent-id - (n/nest-target-index situation parent-id))]) + (let [parent-id (tm/target-shape-id situation (:into op)) + copy-id (tm/target-shape-id situation (:copy op)) + index (n/nest-target-index situation parent-id)] + (if-let [cell (:cell op)] + ;; the canvas drop into a grid cell + [(dwt/move-shapes-to-frame #{copy-id} parent-id index cell)] + [(dwsh/relocate-shapes #{copy-id} parent-id index)])) (instance? n/RestoreComponent op) [(dwl/restore-component (:id (tm/file situation)) @@ -423,14 +435,12 @@ (defn- sync-op? "Whether the op unit `op` runs its `apply-to` on the live file instead of - dispatching events: assembly nodes, the reset, `skip` and inline tests." + dispatching events: assembly nodes, `skip` and inline tests." [op] (let [op (unwrap op)] (or (instance? tm/Skip op) (instance? tm/Test op) - (= :assembly (n/op-kind op)) - ;; a user operation, but the real reset event reads browser globals - (instance? n/ResetOverrides op)))) + (= :assembly (n/op-kind op))))) (defn- run-sync-op "Run the `apply-to` of the `sync-op?` op unit `op` on the live file and diff --git a/frontend/test/frontend_tests/data/workspace_undo_test.cljs b/frontend/test/frontend_tests/data/workspace_undo_test.cljs new file mode 100644 index 0000000000..dd99aa1825 --- /dev/null +++ b/frontend/test/frontend_tests/data/workspace_undo_test.cljs @@ -0,0 +1,75 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.data.workspace-undo-test + "Tests undo and redo of an undo entry with no undo group. The entry schema + requires a group, but release builds elide the check, so a commit that + passes a nil `:undo-group` stores such an entry; undo and redo take a + branch of their own for it." + (:require + [app.common.files.changes-builder :as pcb] + [app.common.logic.shapes :as cls] + [app.common.test-helpers.composable.comp.setups :as setup] + [app.common.test-helpers.files :as thf] + [app.common.test-helpers.ids-map :as thi] + [app.common.test-helpers.shapes :as ths] + [app.main.data.changes :as dch] + [app.main.data.workspace.undo :as dwu] + [app.main.store :as st] + [cljs.test :as t :include-macros true] + [frontend-tests.composable-tests.interpreter :as ftm] + [potok.v2.core :as ptk])) + +(t/use-fixtures :each ftm/fixture) + +(def ^:private red "#ff0000") + +(defn- fill-main-child-changes + "The changes that fill the main child of the live file `color`." + [color] + (let [page (thf/current-page (ftm/current-file))] + (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(thi/id :main-child)} + #(assoc % :fills (ths/sample-fills-color :fill-color color)) + (:objects page) + {}))) + +(defn- push-entry-without-group + "An event that adds `changes` to the undo stack as an entry with no undo + group, as a release build stores a commit whose `:undo-group` is nil." + [changes] + (ptk/reify ::push-entry-without-group + ptk/UpdateEvent + (update [_ state] + (update state :workspace-undo + (fn [undo] + (let [items (conj (vec (:items undo)) + {:undo-changes (:undo-changes changes) + :redo-changes (:redo-changes changes) + :tags #{}})] + (assoc undo :items items :index (dec (count items))))))))) + +(defn- undo-index + [] + (get-in @st/state [:workspace-undo :index])) + +(t/deftest ^:async undo-and-redo-an-entry-without-undo-group + (ftm/install! (setup/simple-component-with-labeled-copy)) + (let [changes (fill-main-child-changes red)] + (await (ftm/await-step [(dch/commit-changes {:redo-changes (:redo-changes changes) + :undo-changes (:undo-changes changes) + :save-undo? false}) + (push-entry-without-group changes)])) + (t/is (= red (ftm/fill-of :main-child))) + (t/is (= 0 (undo-index))) + + (t/is (= :settled (await (ftm/await-step [dwu/undo])))) + (t/is (= setup/labeled-fill (ftm/fill-of :main-child))) + (t/is (= -1 (undo-index))) + + (t/is (= :settled (await (ftm/await-step [dwu/redo])))) + (t/is (= red (ftm/fill-of :main-child))) + (t/is (= 0 (undo-index))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 80c2a6b3f2..88c91e322e 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -41,6 +41,7 @@ [frontend-tests.data.workspace-sync-barrier-test] [frontend-tests.data.workspace-texts-test] [frontend-tests.data.workspace-thumbnails-test] + [frontend-tests.data.workspace-undo-test] [frontend-tests.data.workspace-versions-test] [frontend-tests.debug-logging-test] [frontend-tests.errors-governor-test] @@ -200,6 +201,7 @@ 'frontend-tests.data.workspace-sync-barrier-test 'frontend-tests.data.workspace-texts-test 'frontend-tests.data.workspace-thumbnails-test + 'frontend-tests.data.workspace-undo-test 'frontend-tests.data.workspace-versions-test 'frontend-tests.debug-logging-test 'frontend-tests.errors-governor-test