✨ 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).
This commit is contained in:
alonso.torres 2026-10-09 23:33:22 +02:00
parent d97cbac3a3
commit 0868046270
10 changed files with 543 additions and 31 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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})))

View File

@ -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)))

View File

@ -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')))))

View File

@ -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))))

View File

@ -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

View File

@ -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)))))

View File

@ -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