diff --git a/.serena/memories/common/component-data-model.md b/.serena/memories/common/component-data-model.md index 9b9c4cbbc3..5b5317e8ea 100644 --- a/.serena/memories/common/component-data-model.md +++ b/.serena/memories/common/component-data-model.md @@ -30,7 +30,7 @@ Masters are not normally touched through `set-shape-attr`, but touched flags can - A copy sub-head the user SWAPPED to another component keeps `:shape-ref` on the near-main child it replaces (its *position*); `:component-id`/`:component-file` name the component its *content* comes from; the touched group `:swap-group` marks the swap. Its children refer into its own component's main. - So for a swapped head the position shape (`find-ref-shape`, X in the near main) and the content shape (its component's main root) differ. `ctf/find-ref-shape` gives position (parent heads are tried outermost first); code that needs the content of a swapped head goes through the content helper, never through `get-ref-shape` with the head's own component. -- A copy sub-head whose component differs from its near main's component must have `:swap-group`; one that matches must not. +- A copy sub-head whose component differs from its near main's component must have `:swap-group`. An authored swap keeps this group if a later main edit chooses the same component: equal does not mean untouched. Only an explicit swap back clears it. - A head takes a position only through `ctf/take-position` (swap in a copy, keep-touched switch, update-main into a copy, the `:ref-not-in-near-main` repair): it refers to the position, is swapped iff its component differs, and otherwise its descendants are remapped to the position's descendants. Setting `:shape-ref`/`:swap-group` by hand leaves the children referring to the wrong main, which validation does not catch and the next sync turns into delete + re-add. - Copy children are paired to main children by `shape-ref` (`is-main-of?`, seek, not index), swapped or not. `find-near-match` (positional) is only a validator/repair heuristic; validity requires membership of the ref among the near-main parent's children, not index equality (`mem:common/file-change-validation-migration-subtleties`). - Copy child ORDER converges to the main's via the async sync (`moved` branch of `compare-children`); local code must never reorder copy children directly (guards in `:mov-objects`/`:reorder-children`). @@ -81,3 +81,15 @@ that update-main just pushed (F168). The source copy clears its overrides. Compare against the parent's pushed frame: the inverse sync has already written its new rotation, while the original container still has the old one. Using that old frame creates phantom geometry overrides (F172). + +Pushing a swapped subtree into a nested copy can remap it to an ordinary +position with the same component. Content differences against that new +reference become authored overrides on the promoted head and descendants; +otherwise a later full sync discards the values update-main just pushed +(F174). The source copy clears its overrides and follows the new subtree. + +When sync turns a parent, its subtree moves with that turn before the child +walk continues (F179). This includes ordinary and swapped children with +authored geometry. Normalize parent axis directions for the turn: resize +already runs through dimensions and constraints. Refresh the walk from +current builder objects so later direct syncs do not turn stale records. diff --git a/.serena/memories/common/testing.md b/.serena/memories/common/testing.md index 530b48ba9e..a0a79944e9 100644 --- a/.serena/memories/common/testing.md +++ b/.serena/memories/common/testing.md @@ -102,3 +102,106 @@ inside shape geometry: x/y, width/height, rotation, points, selrect and transfor matrices. Reflow can recompute 0.01 as 0.010000000000005116 after undo (F130). This tolerance does not apply to opacity, other authored attrs or touched groups. The regression rejects a 1e-5 height change and a 5e-15 opacity change. + + +## Generated component programs + +`composable/gen` builds seeded EDN programs and resolves targets from current +shape-tree order. Run `common-tests.logic.composable-gen-test`; set +`PENPOT_GEN_NUM` and `PENPOT_GEN_SEED` to replay a count and seed. Before merge, +Phase 4 requires 5000 scenarios per property. `cg/run` accepts the printed +program and an optional expected trace; changed targets or skips fail replay. +Use separate fresh JVMs for concurrent checks: `world` resets the global test +label map, so concurrent futures in one JVM corrupt each other's fixtures. + +The independent shadow tracks authored groups and values; the oracle checks +validation, touched membership, retained values, propagation and geometry. +Settlement also checks partial/full sync equivalence, idempotence and exact +undo/redo snapshots. Library routing in `comp/files` applies both the working +file and auxiliary file changes, then cascades both in the same undo group. + +Backend parity uses `oracle/canonical` and `oracle/equivalent?`: IDs become +file/page/tree paths, flip +nil/false values become booleans, and derived layout/text dimensions and text +run splitting are omitted. Free copy-head placement after leaving a layout is +also omitted: pure execution has no reflow history. Each backend still checks +child offsets and propagation; a changed child offset fails the comparison. +These exclusions do not apply to undo snapshots or authored scalar values. +Swapped-head constraints and layout-item inputs use the position reference; +content inputs use the component's content main. + + +For long generated JVM checks, bound heap growth with -J-Xmx1536m (adjust +only if a run reports a real heap limit failure). Several uncapped JVMs can +fill host RAM and swap and make browser checks time out before tests start. +Keep concurrency low; use fresh processes for test-label isolation. + +Canonical design data keeps unrounded geometry. Matrices are represented +by their effect on local shape bounds, so their tolerance has pixel units +rather than comparing dimensionless coefficients (F153). Undo snapshots +and golden masters keep raw matrices. Use oracle/equivalent? to compare +it: the absolute 0.01 tolerance applies only inside shape geometry. Rounding +first can split two positions 7e-7 px apart across a 0.005 boundary (F150). +Scalar values and touched stay exact. comp/undo-check/same-state? supplies +the shared comparison; its undo default remains 1e-10. + +Swapped-head dimensions use the content main; position, rotation, flips, +constraints and layout-item inputs use the position source (F151). +When sizes differ, expected geometry keeps the position source's +untransformed top-left anchor after resizing. Resizing around a rotated +corner shifts that anchor and creates a false I4 failure (F167). +A detached swapped head that becomes a plain copy loses swap-group without +inheriting its former position's groups. Descendants whose refs do not change +keep their existing shadow groups (F155). + +For free copy-head placement, expected geometry keeps the placement matrix +it applied. Re-decomposing a 0.01px frame can add scale noise that becomes +a .011px error in its larger children (F161). Child geometry, scalar values +and undo comparisons remain checked; this does not widen their tolerance. + +Detach's shadow expresses inherited geometry in the surviving copy's frame +before merging groups (F163). Compute orientation from the source and copy +before detaching, never from the resulting child, whose geometry is checked. + +The switch shadow compares numeric geometry of the two mains with float +tolerance too (F164). A 1e-12 size difference must keep a resize override; +a 1px difference must discard it. Nongeometry values remain exact. + +Expected child placement takes unit axis directions from the parent +matrices. Resize already runs through dimensions and constraints; a +minimum-size clamp can leave residual matrix scale that must not resize +fixed children again (F173). Canonical matrices and undo remain raw. The +fixture still rejects a real100→80px fixed-child height change. + +Update-main's shadow uses the source values and the new near reference to +predict content overrides when a pushed swap becomes an ordinary nested +copy (F174). Read the reference id from the new shape for correspondence, +but never read its resulting values or touched groups to decide what the +user authored. The negative control corrupts both promoted head and child +fills and still predicts the source fills. + +A switch retains orientation supplied by the old component even when the +old copy has no geometry override (F175). Predict this choice from the old +root and the new expected parent placement. A negative control removes the +new root's rotation; the shadow must still expect the old angle. Ordinary +position inheritance after swapping back must sync without creating new +groups (F176); the same shrunk program runs through the pure and async +frontend paths. + +Retained orientation must merge into a geometry group that already carries +size (F178). Its presence does not mean the angle was carried: the two +variant mains can agree on size and disagree on rotation. The control keeps +the expected80px height and90deg angle even if the result loses its angle. + +Duplicate candidates and pure Duplicate nodes use cld/allow-duplicate?, the +normal frontend eligibility guard (F181). Finite repeated component nesting +can still be unsafe to duplicate inside its main; the portable regression +checks refusal, mixed batches and allowed ALT duplication at the page root. +This is an operation precondition, not an oracle exception. + +The switch shadow follows an authored swapped near head to its content +main, using the model's swap group and component identity (F184). Following +the replaced position instead can lose an inherited fill override. Keep +these choices independent of the resulting touched set and values. The +negative control ignores a different fill on the replaced position and +still expects the authored fill if the switch result is corrupted. diff --git a/.serena/memories/frontend/composable-component-tests.md b/.serena/memories/frontend/composable-component-tests.md index 82c9ab9327..e29c9ceab1 100644 --- a/.serena/memories/frontend/composable-component-tests.md +++ b/.serena/memories/frontend/composable-component-tests.md @@ -76,8 +76,8 @@ Layout: real frontend), `comp/sync_test.cljs` (one `t/async` deftest per shared case; registered in `frontend_tests/runner.cljs`). - JVM/JS: `common-tests.logic.composable-sync-test` runs the shared cases through the pure - runner; all except those that need frontend code: N, Z, AI and the rotation case (placement - step), X (pending sync), AJ (reflow), AT, AU, AV, AX (variant action events). + runner; all except those that need frontend code: Z and AI (layout reflow), X (pending sync), AJ (reflow), AT, AU, AV, AX + (variant action events). N and the rotation case also run on the JVM. Judging (both runners): each variant is judged before the next one runs (`r/run-each`, and the interpreter's `check`), while the label map still names its shapes; a variant whose inline @@ -109,8 +109,10 @@ Two pure runners (no store, JVM + CLJS): 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 (sync + - undo); the interpreter sends `dwl/reset-component`. `{:sync? false}` disables sync. `Rotate`/`ChangeHeight`/`MoveShape` - are `:frontend-only` and throw `::runner/frontend-only` (their modifiers come from frontend events; the placement step itself is common, `app.common.logic.placement`). Swap and switch nodes use the production + undo); the interpreter sends `dwl/reset-component`. `{:sync? false}` disables sync. + `Rotate` and `ChangeHeight` are shared user operations. Their pure modifiers + use CLJC constraints and `app.common.logic.placement`; the frontend interpreter + still drives the workspace events. `MoveShape` remains frontend-only. 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 (token propagation, WASM text resize, layout update). Tests: @@ -280,8 +282,8 @@ reproduce what the real measurement commits (F37: `commit-position-data`). as an error of its case and the next variant runs. `(optional (in-sequence …))` is not flattened for the interpreter — use independent optionals (nested choices are fine). The Serena/clj-kondo cache for `nodes.cljc` goes stale (phantom symbols) — trust the build. Cross-namespace global-state leaks land in this suite first; suspect them before -the framework on inexplicable full-run-only failures. Case H's `sync-file` schedules a delayed RPC -that fails headless (benign; absorbed by per-op grace). +the framework on inexplicable full-run-only failures. Case H's `sync-file` schedules a delayed library-status RPC. Fixed and generated +cases mock RPC and the delay timer for the whole async replay. --- @@ -313,7 +315,9 @@ Distinguishing abstractions (the OOP articulation of the shared principles): each other. `OpCreateNestableComponent(strategy, name)` prefixes its roles with `name`; give every extra lineage in a case a name. - `OpDetach`/`OpSwap`/`OpReset` wait for pending propagation before acting (a script could - otherwise act inside the sync window, which no user can). `swapComponent` keeps touched (F33). + otherwise act inside the sync window, which no user can). `swapComponent` follows the UI: + ordinary swaps clear content overrides and retain orientation; switches + within one variant set carry matching overrides. The suite pins published `@penpot/plugin-types` 1.4.2, which lacks `resetOverrides` (typed cast). - Known bugs: `Assert.knownFailure(bug, expected, pinned, actual, equals)` (same order as `tm/known-failure`), the counterpart of `tm/known-failure`. @@ -335,3 +339,37 @@ Details: README, "Running in CI". ## Substrate `mem:common/testing`, `mem:common/component-data-model`, `mem:common/component-swap-pipeline`, `mem:frontend/testing`. + + +## Seeded random replay + +`common-tests.logic.composable-gen-test` checks EDN programs through common. +`frontend-tests.composable-tests.generated-test/replay` accepts the same EDN +(including a shrunk failure), checks target/skip traces, and drives real events +with per-step barriers and undo/redo. Its independent model maps shapes between +backends by file/page/tree path, including auxiliary library files. UpdateMain +passes the target shape's component-file to update-component-sync. Refresh both +the working file and libraries after each step; strip installed library-of +metadata only from the portable situation, never from undo snapshots. + +Plugin `RandomCompositions` uses the real Plugin API and its own visible fill +and opacity model. It waits for waitForLayoutUpdate with a hard deadline; +there is no fixed-delay fallback. Live WASM checks use ws1 and enable plugin +flags naturalChildOrdering and throwValidationErrors. When browser automation +runs in a background tab, foreground it (and use CDP focus emulation if needed) +before diagnosing a timeout: background timers can wait a minute per step. +Verify initialized WASM, no workspace exception and empty pending reflow. + +Rotate nodes take a delta angle. The interpreter must pass delta?true to +increase-rotation; its default sets an absolute angle. A45deg turn followed +by90deg must end135deg on both backends (F171). + +## Suite choice after Phase 4 + +Keep CLJS replay as the composable regression suite, backed by shared common +generation and test.check shrinking. Keep the plugin for public API, WASM, +font loading and measured-text smoke checks. Retain its fixed cases until +equivalent coverage replaces them; do not build a third full random model +for parity. Pure common checks cannot expose async undo/measurement order +errors such as F137/F152. Compare coverage and CI cost separately: timings +collected during stress runs do not prove either suite is faster. diff --git a/common/src/app/common/test_helpers/composable/gen.cljc b/common/src/app/common/test_helpers/composable/gen.cljc index 6f758eebf4..94e3015795 100644 --- a/common/src/app/common/test_helpers/composable/gen.cljc +++ b/common/src/app/common/test_helpers/composable/gen.cljc @@ -56,7 +56,22 @@ file (cond-> file (= layout :grid) (tho/place-in-grid-columns root 2 {1 child}))] (tm/with-file situation file)))) - +(defn- with-library + [situation] + (let [library (-> (tm/make-situation (thf/sample-file :random-library :is-shared true)) + (as-> s (tm/apply-to (n/create-component "library-random" setup/labeled-fill :size 100) s)) + (as-> s (tm/apply-to (n/make-nested-component "library-random" 1) s)) + tm/file) + components (sort-by (comp str :id) (vals (get-in library [:data :components]))) + file (reduce (fn [file [index component copy]] + (thc/instantiate-component + file (thi/label (:id component)) + (keyword (str "random-library-copy-" index "-" copy)) + :library library)) + (tm/file situation) + (for [[index component] (map-indexed vector components) copy (range 2)] + [index component copy]))] + (-> situation (tm/with-file file) (tm/with-aux-files {(:id library) library})))) (defn forest "All live page trees, with stable file/page prefixes for model replay." @@ -75,34 +90,35 @@ (defn world [[components depth opts]] (thi/reset-idmap!) - (reduce (fn [s index] - (let [name (str "random-" index)] - (-> (tm/apply-to (n/create-component name setup/labeled-fill :size 100) s) - (with-layout name (when (zero? index) (:layout opts))) - (as-> s (reduce #(tm/apply-to %2 %1) s - [(n/make-nested-component name depth) - (n/instantiate-copy name) - (n/instantiate-copy name)]))))) - (let [s (setup/empty-situation) - s (if (:variants opts) - (reduce #(tm/apply-to %2 %1) s - [(n/create-component "variant" setup/labeled-fill :size 100) - (n/make-variant-container "random-variants" - [["a" setup/labeled-fill] - ["b" (if (:variant-diff opts) "#ff0000" setup/labeled-fill)]] - :size 100) - (n/make-nested-component-with-variant "variant" "random-variants" "a") - (n/make-nested-component "variant" 1) - (n/instantiate-copy "variant") - (n/instantiate-copy "variant")]) - s)] - (cond-> s - (:text opts) - (tm/with-file (-> (thtx/add-text-component-with-copy (tm/file s) [[["Hello world"]]]) - (ths/update-shape :main-child :grow-type :auto-height) - (ths/update-shape :copy-child :grow-type :auto-height) - (ths/update-shape :copy-child :touched nil))))) - (range components))) + (cond-> (reduce (fn [s index] + (let [name (str "random-" index)] + (-> (tm/apply-to (n/create-component name setup/labeled-fill :size 100) s) + (with-layout name (when (zero? index) (:layout opts))) + (as-> s (reduce #(tm/apply-to %2 %1) s + [(n/make-nested-component name depth) + (n/instantiate-copy name) + (n/instantiate-copy name)]))))) + (let [s (setup/empty-situation) + s (if (:variants opts) + (reduce #(tm/apply-to %2 %1) s + [(n/create-component "variant" setup/labeled-fill :size 100) + (n/make-variant-container "random-variants" + [["a" setup/labeled-fill] + ["b" (if (:variant-diff opts) "#ff0000" setup/labeled-fill)]] + :size 100) + (n/make-nested-component-with-variant "variant" "random-variants" "a") + (n/make-nested-component "variant" 1) + (n/instantiate-copy "variant") + (n/instantiate-copy "variant")]) + s)] + (cond-> s + (:text opts) + (tm/with-file (-> (thtx/add-text-component-with-copy (tm/file s) [[["Hello world"]]]) + (ths/update-shape :main-child :grow-type :auto-height) + (ths/update-shape :copy-child :grow-type :auto-height) + (ths/update-shape :copy-child :touched nil))))) + (range components)) + (:library opts) (with-library))) (defn candidates [situation model kind scope] @@ -430,3 +446,10 @@ :ops (into [[:edit 0 0 1 1] [:switch 0] [:switch 0] [:swap 0] [:undo] [:redo] [:reset 0] [:edit 0 1 1 1] [:update-main 0] [:sync 1]] ops)})) + +(def library-program-gen + (gen/let [ops (gen/vector (gen/tuple (gen/elements (into structure-kinds [:swap :switch :library-sync])) + gen/nat gen/nat gen/nat gen/nat) 0 20)] + {:world [2 1 {:library true :variants true}] + :ops (into [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1]] ops)})) diff --git a/common/test/common_tests/logic/composable_gen_test.cljc b/common/test/common_tests/logic/composable_gen_test.cljc index b9a422d65b..1b060ec332 100644 --- a/common/test/common_tests/logic/composable_gen_test.cljc +++ b/common/test/common_tests/logic/composable_gen_test.cljc @@ -162,6 +162,43 @@ (t/is (nil? (:variant-id shape))) (t/is (nil? (get-in file [:data :components (:component-id shape) :variant-id])))))) +(t/deftest generated-library-world-has-remote-mains-and-local-copies + (let [situation (cg/world [2 1 {:library true}]) + file (tm/file situation) + libraries (tm/aux-files situation) + remote (filter #(contains? libraries (:component-file (:shape %))) (cg/tree situation))] + (t/is (= 1 (count libraries))) + (t/is (seq remote)) + (t/is (every? :shape-ref (map :shape remote))) + (t/is (not (contains? libraries (:id file)))))) + +(t/deftest generated-library-update-main-checks-remote-and-local-inverses + (let [program {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:sync 1]]} + result (cg/run program)] + (t/is (= 1 (get-in result [:executed :update-main]))) + (t/is (= 1 (get-in result [:executed :library-sync]))) + (t/is (= 1 (count (tm/aux-files (:situation result))))))) + +(t/deftest random-library-compositions + (let [coverage (atom {}) + failure (atom nil)] + (smt/check! + (smt/for [program cg/library-program-gen] + (cg/check-program program coverage failure)) + :max-size 30) + (println "Executed library operations:" (pr-str @coverage)) + (doseq [kind [:edit :update-main :library-sync :undo :redo]] + (t/is (pos? (get @coverage kind 0)) (str "No executed " kind))))) + +(t/deftest update-main-moves-an-authored-swap-to-the-replaced-main-shape + (let [result (cg/run {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:switch 2 0 0 0] [:update-main 0 0 0 0]]})] + (t/is (some #(contains? % :swap-group) (vals (:model result)))))) + (t/deftest swap-after-updating-a-rotated-main-is-already-fully-synced (t/is (map? (cg/run {:world [2 2 {:variants true :variant-diff false}] :ops [[:edit 0 0 1 1] [:switch 0] [:switch 0] [:swap 0] @@ -169,6 +206,12 @@ [:update-main 0] [:sync 1] [:rotate 0 0 0 0] [:update-main 0 0 0 0] [:swap 0 0 0 0] [:restore 0 0 0 0]]})))) +(t/deftest update-main-after-deleting-a-swapped-main-keeps-the-model-in-step + (t/is (map? (cg/run {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:switch 1 0 0 0] [:delete 2 0 0 0] [:update-main 0 0 0 0]]})))) + (t/deftest detach-from-a-deleted-near-main-inherits-only-the-skipped-level (t/is (map? (cg/run {:world [3 2 {:text true :layout nil}] :ops [[:add-child 0] [:remove-child 0] [:duplicate 0] [:nest 0] @@ -178,6 +221,18 @@ [:detach 0] [:undo] [:redo] [:delete 1 0 0 0] [:detach 13 0 0 0]]})))) +(t/deftest switch-keeps-root-geometry-inherited-through-a-near-main + (t/is (map? (cg/run {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:rotate 0 0 0 0] [:switch 1 0 0 0]]})))) + +(t/deftest a-main-detach-removes-an-indirect-copys-invalid-swap + (t/is (map? (cg/run {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:switch 1 0 0 0] [:detach 0 0 0 0] [:delete 0 0 0 0]]})))) + (t/deftest undo-after-a-main-child-add-and-root-rotation-is-fully-settled (t/is (map? (cg/run {:world [2 0 {:text true :layout nil}] :ops [[:add-child 0] [:remove-child 0] [:duplicate 0] [:nest 0] @@ -196,6 +251,18 @@ [:detach 0] [:undo] [:redo] [:rotate 2 0 0 0] [:resize 7 0 0 0] [:duplicate 0 0 0 0]]})))) +(t/deftest detached-swapped-head-follows-its-own-component-without-position-overrides + (t/is (map? (cg/run {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:duplicate 1 0 0 0] [:swap 13 0 0 0] [:detach 16 0 0 0]]})))) + +(t/deftest detaching-an-outer-copy-keeps-its-surviving-inner-swap + (t/is (map? (cg/run {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:switch 3 0 0 0] [:detach 3 0 0 0]]})))) + (t/deftest pushed-swap-followed-by-nesting-undoes-exactly (t/is (map? (cg/run {:world [2 2 {:variants true :variant-diff false}] :ops [[:edit 0 0 1 1] [:switch 0] [:switch 0] [:swap 0] @@ -221,6 +288,33 @@ [:add-child 1 0 0 0] [:add-child 1 0 0 0] [:swap 0 0 0 0] [:swap 0 0 0 0] [:text 0 0 0 0]]})))) +(t/deftest updating-main-then-swapping-a-child-restores-parent-state + (t/is (map? (cg/run {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:switch 5 0 0 0] [:update-main 0 0 0 0] [:swap 13 0 0 0]]})))) + +(t/deftest pushed-variant-subtree-restores-its-main-parent-order + (t/is (map? (cg/run {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:resize 0 0 0 0] [:duplicate 0 0 0 0] + [:switch 2 0 0 0] [:update-main 2 0 0 0]]})))) + +(t/deftest updating-a-duplicated-switched-copy-restores-the-main-order + (t/is (map? (cg/run {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:switch 1 0 0 0] [:duplicate 0 0 0 0] + [:update-main 0 0 0 0] [:redo 0 0 0 0]]})))) + +(t/deftest switching-a-detached-rotated-copy-keeps-its-resize + (t/is (map? (cg/run {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:rotate 1 0 0 0] [:detach 1 0 0 0] + [:detach 2 0 0 0] [:resize 2 0 0 0] [:switch 2 0 0 0]]})))) + (t/deftest minimum-size-parent-orientation-does-not-amplify-oracle-drift (t/is (map? (cg/run {:world [3 0 {:text true :layout nil}] :ops [[:add-child 0] [:remove-child 0] [:duplicate 0] [:nest 0] @@ -285,6 +379,12 @@ (t/is (= [:green] (:fills expected))) (t/is (false? (shadow/same-value? (:copy after) :fill-group expected)))))) +(t/deftest switching-a-copy-of-a-swap-keeps-position-overrides-inherited + (t/is (map? (cg/run {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:switch 13 0 0 0] [:rotate 0 0 0 0] [:switch 2 0 0 0]]})))) + (t/deftest rotated-swapped-head-keeps-its-position-anchor-with-a-different-content-size (t/is (map? (cg/run {:world [2 2 {:variants true :variant-diff false}] :ops [[:edit 0 0 1 1] [:switch 0] [:switch 0] [:swap 0] @@ -328,6 +428,34 @@ (t/is (= {:fills [:white] :hide-fill-on-export nil} (get-in model [id :fill-group]))) (t/is (false? (shadow/same-value? (get after id) :fill-group (get-in model [id :fill-group]))))))) +(t/deftest generated-library-pushed-swap-authors-content-against-its-new-position + (t/is (map? (cg/run {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:switch 2 0 0 0] [:edit 8 0 0 0] [:switch 0 0 0 0] + [:update-main 7 0 0 0]]})))) + +(t/deftest generated-library-swap-back-syncs-the-near-positions-overrides + (t/is (map? (cg/run {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:switch 15 0 0 0] [:duplicate 16 0 0 0] [:edit 8 0 0 0] + [:switch 4 0 0 0] [:update-main 0 0 0 0]]})))) + +(t/deftest generated-library-move-restores-existing-component-nesting-on-undo + (t/is (map? (cg/run {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:switch 5 0 0 0] [:nest 8 0 0 0] [:nest 5 0 0 0] + [:restore 0 0 0 0]]})))) + +(t/deftest generated-library-authored-child-follows-its-parents-synced-turn + (t/is (map? (cg/run {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:resize 0 0 0 0] [:switch 2 0 0 0] [:rotate 1 0 0 0] + [:remove-child 0 0 0 0]]})))) + (t/deftest switch-shadow-keeps-inherited-orientation-even-if-the-result-loses-it (let [before {:page {:id :page} :main-a {:id :main-a :parent-id :page :name "A" :rotation 45} @@ -369,6 +497,12 @@ [:nest 3 0 0 0] [:swap 10 0 0 0] [:resize 3 0 0 0] [:update-main 5 0 0 0] [:switch 0 0 0 0] [:detach 0 0 0 0]]})))) +(t/deftest pushing-a-swap-keeps-the-orientation-inherited-from-its-position + (t/is (map? (cg/run {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:rotate 0 0 0 0] [:swap 13 0 0 0] [:update-main 1 0 0 0]]})))) + (t/deftest resizing-a-copy-of-an-empty-flex-main-keeps-layout-valid (t/is (map? (cg/run {:world [3 0 {:text true :layout :flex}] :ops [[:add-child 0] [:remove-child 0] [:duplicate 0] [:nest 0] @@ -382,6 +516,52 @@ (t/is (map? (cg/run {:world [2 1] :ops [[:rotate 1 0 0 0] [:rotate 1 0 1 0] [:update-main 0 0 0 0]]})))) +(t/deftest pushing-a-switched-child-uses-its-parents-pushed-frame + (t/is (map? (cg/run {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:switch 5 0 0 0] [:rotate 1 0 0 0] [:update-main 0 0 0 0]]})))) + +(t/deftest generated-library-switch-keeps-overrides-from-a-deleted-near-main + (t/is (map? (cg/run {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:delete 0 0 0 0] [:delete 3 0 0 0] [:resize 0 0 0 0] + [:delete 9 0 0 0] [:switch 0 0 0 0]]})))) + +(t/deftest generated-library-duplicate-refuses-an-expanding-component-branch + (t/is (map? (cg/run {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:switch 5] [:nest 8] [:duplicate 1]]})))) + +(t/deftest generated-library-parent-turn-settles-swapped-placement + (t/is (map? (cg/run {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:switch 2 0 0 0] [:resize 0 0 0 0] [:rotate 2 0 0 0]]})))) + +(t/deftest generated-library-restored-variant-syncs-its-converted-name + (t/is (map? (cg/run {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:delete 3 0 0 0] [:delete 1 0 0 0] [:restore 7 0 0 0] + [:delete 1 0 0 0] [:restore 0 0 0 0]]})))) + +(t/deftest generated-library-switch-inherits-a-swapped-near-mains-authored-fill + (t/is (map? (cg/run {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:edit 12 0 0 0] [:nest 10 0 0 0] + [:switch 7 0 0 0] [:switch 2 0 0 0]]})))) + +(t/deftest generated-library-reset-places-new-children-after-clearing-rotation + (t/is (map? (cg/run {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:switch 1 0 0 0] [:rotate 1 0 0 0] [:duplicate 0 0 0 0] + [:reset 2 0 0 0] [:redo 0 0 0 0]]})))) + (t/deftest generated-update-main-settles-a-turned-swapped-position (let [result (cg/run {:world [2 2 {:variants true :variant-diff false}] :ops [[:edit 0 0 1 1] [:switch 0] [:switch 0] [:swap 0] [:undo] [:redo] diff --git a/common/test/common_tests/logic/composable_runner_test.cljc b/common/test/common_tests/logic/composable_runner_test.cljc index 69772f8c01..d8299bc5a7 100644 --- a/common/test/common_tests/logic/composable_runner_test.cljc +++ b/common/test/common_tests/logic/composable_runner_test.cljc @@ -8,6 +8,7 @@ (:require [app.common.files.changes-builder :as pcb] [app.common.logic.shapes :as cls] + [app.common.test-helpers.components :as thc] [app.common.test-helpers.composable.comp.nodes :as n] [app.common.test-helpers.composable.comp.runner :as r] [app.common.test-helpers.composable.comp.setups :as setup] @@ -619,6 +620,22 @@ (t/is (= ::tm/unbound-shape-name (:type (ex-data error)))) (t/is (some #{:copy-child} (:present (ex-data error)))))) +(t/deftest library-update-main-routes-both-files-and-undoes-the-cascade + (let [initial (setup/cross-file-component-with-copy setup/labeled-fill) + library (first (vals (tm/aux-files initial))) + initial (tm/with-file initial + (thc/instantiate-component (tm/file initial) :component1 :copy2-root + :library library :children-labels [:copy2-child])) + edited (-> initial (r/step (n/change-property :copy-child :fills "#ff0000")) r/settle) + updated (-> edited (r/step (n/update-main :copy-root)) r/settle) + remote (get (tm/aux-files updated) (:id library)) + snapshot (fn [s] (uc/snapshot-files (assoc (tm/aux-files s) (:id (tm/file s)) (tm/file s))))] + (t/is (= "#ff0000" (get-in (thf/current-page remote) [:objects (thi/id :main-child) :fills 0 :fill-color]))) + (t/is (= "#ff0000" (:fill-color (first (:fills (tm/resolve-shape updated :copy2-child)))))) + (t/is (nil? (:touched (tm/resolve-shape updated :copy-child)))) + (t/is (= (snapshot edited) (snapshot (r/step updated (n/undo))))) + (t/is (= (snapshot updated) (snapshot (-> updated (r/step (n/undo)) (r/step (n/redo)))))))) + (t/deftest adding-a-grid-child-records-cell-order-before-reflow (let [initial (setup/grid-component-with-copy) changed (-> initial (r/step (n/add-child :grid-root :new-grid-child)) r/settle) diff --git a/frontend/test/frontend_tests/composable_tests/generated_test.cljs b/frontend/test/frontend_tests/composable_tests/generated_test.cljs index 57e21de55f..69a7d54726 100644 --- a/frontend/test/frontend_tests/composable_tests/generated_test.cljs +++ b/frontend/test/frontend_tests/composable_tests/generated_test.cljs @@ -103,6 +103,31 @@ (doseq [seed [712 20261022]] (await (replay (gen/generate cg/swap-program-gen 5 seed))))) +(t/deftest ^:async library-programs-replay-through-the-frontend + (await (replay {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:sync 1]]})) + (await (replay (gen/generate cg/library-program-gen 5 712)))) + +(t/deftest ^:async inherited-rotation-switch-shrunk-regression + (await (replay {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:rotate 0 0 0 0] [:switch 1 0 0 0]]}))) + +(t/deftest ^:async swapped-subtree-update-main-shrunk-regression + (await (replay {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:resize 0 0 0 0] [:duplicate 0 0 0 0] + [:switch 2 0 0 0] [:update-main 2 0 0 0]]}))) + +(t/deftest ^:async main-head-detach-shrunk-regression + (await (replay {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:switch 1 0 0 0] [:detach 0 0 0 0] [:delete 0 0 0 0]]}))) + (t/deftest ^:async partial-sync-geometry-boundary-shrunk-regression (await (replay {:world [2 0 {:text true :layout nil}] :ops [[:add-child 0] [:remove-child 0] [:duplicate 0] [:nest 0] @@ -117,6 +142,12 @@ :ops [[:duplicate 0] [:nest 0] [:rotate 0 0 0] [:edit 0 0 1 1] [:update-main 0]]}))) +(t/deftest ^:async updating-main-then-swapping-a-child-restores-parent-state + (await (replay {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:switch 5 0 0 0] [:update-main 0 0 0 0] [:swap 13 0 0 0]]}))) + (t/deftest ^:async swapping-main-subtrees-with-several-children-undoes-their-order (await (replay {:world [2 2 {:variants true :variant-diff false}] :ops [[:edit 0 0 1 1] [:switch 0] [:switch 0] [:swap 0] @@ -135,6 +166,20 @@ [:resize 0 0 0 0] [:nest 0 0 0 0] [:rotate 4 0 0 0] [:detach 3 0 0 0] [:update-main 4 0 0 0]]}))) +(t/deftest ^:async updating-a-duplicated-switched-copy-restores-the-main-order + (await (replay {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:switch 1 0 0 0] [:duplicate 0 0 0 0] + [:update-main 0 0 0 0] [:redo 0 0 0 0]]}))) + +(t/deftest ^:async switching-a-detached-rotated-copy-keeps-its-resize + (await (replay {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:rotate 1 0 0 0] [:detach 1 0 0 0] + [:detach 2 0 0 0] [:resize 2 0 0 0] [:switch 2 0 0 0]]}))) + (t/deftest ^:async minimum-size-parent-orientation-does-not-amplify-oracle-drift (await (replay {:world [3 0 {:text true :layout nil}] :ops [[:add-child 0] [:remove-child 0] [:duplicate 0] [:nest 0] @@ -154,12 +199,24 @@ [:detach 0] [:undo] [:redo] [:resize 0 0 0 0] [:delete 0 0 0 0] [:rotate 0 0 0 0] [:detach 0 0 0 0]]}))) +(t/deftest ^:async switching-a-copy-of-a-swap-keeps-position-overrides-inherited + (await (replay {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:switch 13 0 0 0] [:rotate 0 0 0 0] [:switch 2 0 0 0]]}))) + (t/deftest ^:async rotated-swapped-head-keeps-its-position-anchor-with-a-different-content-size (await (replay {:world [2 2 {:variants true :variant-diff false}] :ops [[:edit 0 0 1 1] [:switch 0] [:switch 0] [:swap 0] [:undo] [:redo] [:reset 0] [:edit 0 1 1 1] [:update-main 0] [:sync 1] [:swap 17 0 0 0] [:resize 0 0 0 0] [:rotate 0 0 0 0]]}))) +(t/deftest ^:async pushing-a-swap-keeps-the-orientation-inherited-from-its-position + (await (replay {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:rotate 0 0 0 0] [:swap 13 0 0 0] [:update-main 1 0 0 0]]}))) + (t/deftest ^:async resizing-a-copy-of-an-empty-flex-main-keeps-layout-valid (await (replay {:world [3 0 {:text true :layout :flex}] :ops [[:add-child 0] [:remove-child 0] [:duplicate 0] [:nest 0] @@ -173,6 +230,12 @@ (await (replay {:world [2 1] :ops [[:rotate 1 0 0 0] [:rotate 1 0 1 0] [:update-main 0 0 0 0]]}))) +(t/deftest ^:async pushing-a-switched-child-uses-its-parents-pushed-frame + (await (replay {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:switch 5 0 0 0] [:rotate 1 0 0 0] [:update-main 0 0 0 0]]}))) + (t/deftest ^:async minimum-size-parent-placement-keeps-fixed-child-dimensions (await (replay {:world [2 0 {:text true :layout nil}] :ops [[:add-child 0] [:remove-child 0] [:duplicate 0] [:nest 0] @@ -182,6 +245,41 @@ [:detach 0] [:undo] [:redo] [:resize 1 0 0 0] [:nest 2 0 0 0] [:rotate 0 0 3 0] [:edit 0 0 0 0]]}))) +(t/deftest ^:async generated-library-pushed-swap-authors-content-against-its-new-position + (await (replay {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:switch 2 0 0 0] [:edit 8 0 0 0] [:switch 0 0 0 0] + [:update-main 7 0 0 0]]}))) + +(t/deftest ^:async generated-library-swap-back-syncs-the-near-positions-overrides + (await (replay {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:switch 15 0 0 0] [:duplicate 16 0 0 0] [:edit 8 0 0 0] + [:switch 4 0 0 0] [:update-main 0 0 0 0]]}))) + +(t/deftest ^:async generated-library-move-restores-existing-component-nesting-on-undo + (await (replay {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:switch 5 0 0 0] [:nest 8 0 0 0] [:nest 5 0 0 0] + [:restore 0 0 0 0]]}))) + +(t/deftest ^:async generated-library-authored-child-follows-its-parents-synced-turn + (await (replay {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:resize 0 0 0 0] [:switch 2 0 0 0] [:rotate 1 0 0 0] + [:remove-child 0 0 0 0]]}))) + +(t/deftest ^:async generated-library-switch-keeps-overrides-from-a-deleted-near-main + (await (replay {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:delete 0 0 0 0] [:delete 3 0 0 0] [:resize 0 0 0 0] + [:delete 9 0 0 0] [:switch 0 0 0 0]]}))) + (t/deftest ^:async switching-an-untouched-rotated-copy-survives-full-sync (await (replay {:world [3 2 {:variants true :variant-diff false}] :ops [[:edit 0 0 1 1] [:switch 0] [:switch 0] [:swap 0] [:undo] [:redo] @@ -198,6 +296,39 @@ [:nest 0 0 0 0] [:detach 0 0 0 0] [:update-main 0 0 0 0] [:switch 0 0 0 0]]}))) +(t/deftest ^:async generated-library-duplicate-refuses-an-expanding-component-branch + (await (replay {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:switch 5] [:nest 8] [:duplicate 1]]}))) + +(t/deftest ^:async generated-library-parent-turn-settles-swapped-placement + (await (replay {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:switch 2 0 0 0] [:resize 0 0 0 0] [:rotate 2 0 0 0]]}))) + +(t/deftest ^:async generated-library-restored-variant-syncs-its-converted-name + (await (replay {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:delete 3 0 0 0] [:delete 1 0 0 0] [:restore 7 0 0 0] + [:delete 1 0 0 0] [:restore 0 0 0 0]]}))) + +(t/deftest ^:async generated-library-switch-inherits-a-swapped-near-mains-authored-fill + (await (replay {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:edit 12 0 0 0] [:nest 10 0 0 0] + [:switch 7 0 0 0] [:switch 2 0 0 0]]}))) + +(t/deftest ^:async generated-library-reset-places-new-children-after-clearing-rotation + (await (replay {:world [2 1 {:library true :variants true}] + :ops [[:edit 0 0 1 2] [:update-main 0] [:library-sync] + [:undo] [:redo] [:reset 0] [:sync 1] + [:switch 1 0 0 0] [:rotate 1 0 0 0] [:duplicate 0 0 0 0] + [:reset 2 0 0 0] [:redo 0 0 0 0]]}))) + (t/deftest ^:async generated-update-main-settles-a-turned-swapped-position (await (replay {:world [2 2 {:variants true :variant-diff false}] :ops [[:edit 0 0 1 1] [:switch 0] [:switch 0] [:swap 0] [:undo] [:redo] diff --git a/plugins/apps/composable-test-suite/src/composable-tests/operations/OpSwap.ts b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpSwap.ts index e98d4cecb2..b17414d854 100644 --- a/plugins/apps/composable-test-suite/src/composable-tests/operations/OpSwap.ts +++ b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpSwap.ts @@ -9,8 +9,9 @@ import { PenpotSync } from "../util/PenpotSync"; * component, via the Plugin API's `swapComponent()`. The new head keeps the * old head's id, so a target that found the old head finds the new one. * - * Unlike the swap in the workspace UI, the API keeps the head's overrides - * (it swaps with keep-touched, F33 in the stabilization plan). + * The API follows the workspace rules: ordinary swaps clear content + * overrides and retain orientation. A switch between variants of one set + * carries matching overrides. */ export class OpSwap extends Operation { /**