From 35b0a893e606e6bcc90148a1322ebb5b278ccdde Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Fri, 23 Oct 2026 09:50:53 +0200 Subject: [PATCH] :construction: Save Phase 4 random harness and grid fixes in progress Save Stage 2 structure, text, geometry and layout generation, independent oracle checks, frontend seeded replay and plugin visible-value checks. Include pending grid sync and deletion fixes with focused regressions. WIP validation: component sync 21 tests / 106 assertions; move shapes 6 tests / 33 assertions. Earlier plugin checks passed 68 full cases, 11 unit tests and types. The final generated replay, golden check, 5000-case gate, full suites and live WASM check remain unfinished. Stages 3 (swaps/variants) and 4 (library files) have not started. F124 benchmark ratios against Phase 1e: main 10/100/1000 copies 0.48/0.52/0.54; three levels 0.53; chain 0.56; library 0.52; many shapes 0.42; variant switch 0.64. No regression flag. The F125 deletion change still needs its final benchmark. Save the detailed resume checklist and logs in the local stabilization plan. Keep that workflow document out of the commit. AI-assisted-by: GPT-6 --- common/src/app/common/logic/libraries.cljc | 5 +- common/src/app/common/logic/shapes.cljc | 6 + .../test_helpers/composable/comp/cases.cljc | 8 +- .../common/test_helpers/composable/gen.cljc | 133 ++++++++++--- .../test_helpers/composable/oracle.cljc | 89 +++++++-- .../test_helpers/composable/shadow.cljc | 11 ++ .../common_tests/logic/comp_sync_test.cljc | 50 +++++ .../logic/composable_gen_test.cljc | 33 ++++ .../logic/composable_oracle_test.cljc | 71 +++++++ .../logic/composable_sync_test.cljc | 9 +- .../test/common_tests/logic/golden_master.edn | 11 ++ .../common_tests/logic/golden_master_test.clj | 2 + .../common_tests/logic/move_shapes_test.cljc | 19 ++ .../composable_tests/generated_test.cljs | 88 +++++++++ .../composable_tests/interpreter.cljs | 26 ++- frontend/test/frontend_tests/runner.cljs | 2 + .../composable-test-suite/ci/random.test.ts | 40 ++++ .../apps/composable-test-suite/package.json | 2 +- .../src/composable-tests/cases.ts | 2 + .../cases/caseRandomCompositions.ts | 12 ++ .../operations/OpRandomSequence.ts | 178 ++++++++++++++++++ .../src/composable-tests/random/program.ts | 16 ++ .../src/composable-tests/util/PenpotSync.ts | 31 +-- 23 files changed, 779 insertions(+), 65 deletions(-) create mode 100644 frontend/test/frontend_tests/composable_tests/generated_test.cljs create mode 100644 plugins/apps/composable-test-suite/ci/random.test.ts create mode 100644 plugins/apps/composable-test-suite/src/composable-tests/cases/caseRandomCompositions.ts create mode 100644 plugins/apps/composable-test-suite/src/composable-tests/operations/OpRandomSequence.ts create mode 100644 plugins/apps/composable-test-suite/src/composable-tests/random/program.ts diff --git a/common/src/app/common/logic/libraries.cljc b/common/src/app/common/logic/libraries.cljc index 15577ab903..e13f030d5f 100644 --- a/common/src/app/common/logic/libraries.cljc +++ b/common/src/app/common/logic/libraries.cljc @@ -1321,7 +1321,10 @@ main-cells (-> shape-main (ctsl/remap-grid-cells ids-map) :layout-grid-cells)] (-> shape-copy (assoc :layout-grid-cells - (ctsl/merge-cells main-cells copy-cells omit-touched?)) + (if (and omit-touched? + (contains? (:touched shape-copy) :layout-grid-cells)) + copy-cells + main-cells)) (ctsl/assign-cells objects)))) {:ignore-touched true :with-objects? true}))) diff --git a/common/src/app/common/logic/shapes.cljc b/common/src/app/common/logic/shapes.cljc index a98977de5d..afb94ee564 100644 --- a/common/src/app/common/logic/shapes.cljc +++ b/common/src/app/common/logic/shapes.cljc @@ -439,6 +439,12 @@ (pcb/remove-objects ids-to-delete {:ignore-touched ignore-touched}) (pcb/remove-objects empty-parents) (generate-copy-deletions data page pages-index copy-deletions) + (pcb/update-shapes all-parents + (fn [shape objects] + (if (ctl/grid-layout? shape) + (ctl/assign-cell-positions shape objects) + shape)) + {:ignore-touched true :with-objects? true}) (pcb/resize-parents all-parents) (pcb/update-shapes groups-to-unmask (fn [shape] 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 89a47abd2c..14d27f5d82 100644 --- a/common/src/app/common/test_helpers/composable/comp/cases.cljc +++ b/common/src/app/common/test_helpers/composable/comp/cases.cljc @@ -67,8 +67,8 @@ removal too; a copy's own token survives. AZ — a nested copy pasted out of its main keeps the overrides the main gave it (F17). - N — main edits reach rotated copies (frontend only). - Rotation — a touched copy child survives a main rotation (frontend only too)." + N — main edits reach rotated copies. + Rotation — a touched copy child survives a main rotation." (:require [app.common.data :as d] [app.common.files.helpers :as cfh] @@ -1614,7 +1614,7 @@ (t/is (empty? (:touched copy))))))))})) (defn geometry-sync-with-rotated-instances - "Case N (frontend only). Optionally the copy rotates, optionally the main + "Case N. Optionally the copy rotates, optionally the main rotates, then the main child changes its fill or its height. The change reaches the copy child. A copy root's rotation is inherited content that a copy may override: the copy shows its own rotation, else the main's, else @@ -1676,7 +1676,7 @@ (t/is (empty? (:touched (setup/copy-root s))))))})) (defn touched-copy-child-survives-main-rotation - "Frontend only. A copy rotates its root and resizes its child, then the + "A copy rotates its root and resizes its child, then the main rotates. The child's touched :geometry-group shields it: its height and its position relative to the copy root do not change." [] diff --git a/common/src/app/common/test_helpers/composable/gen.cljc b/common/src/app/common/test_helpers/composable/gen.cljc index 083f52d5c3..c3f3001645 100644 --- a/common/src/app/common/test_helpers/composable/gen.cljc +++ b/common/src/app/common/test_helpers/composable/gen.cljc @@ -10,15 +10,21 @@ Traces contain tree paths, never random UUIDs, and can be replayed." (:refer-clojure :exclude [flush]) (:require + [app.common.files.helpers :as cfh] + [app.common.logic.shapes :as cls] [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] [app.common.test-helpers.composable.core :as tm] [app.common.test-helpers.composable.oracle :as oracle] [app.common.test-helpers.composable.shadow :as shadow] + [app.common.test-helpers.compositions :as tho] [app.common.test-helpers.files :as thf] [app.common.test-helpers.ids-map :as thi] [app.common.test-helpers.normalize :as thn] + [app.common.test-helpers.shapes :as ths] + [app.common.test-helpers.texts :as thtx] + [app.common.types.component :as ctk] [app.common.types.container :as ctn] [app.common.uuid :as uuid] [clojure.test.check.generators :as gen])) @@ -35,37 +41,85 @@ (vec (mapcat (fn [[index id]] (walk id [index])) (map-indexed vector (:shapes (get objects uuid/zero)))))))) +(defn- with-layout + [situation name layout] + (if-not layout + situation + (let [root (keyword (str "sync-" name "-head")) + child (keyword (str "sync-" name "-rect")) + defaults (case layout :flex cls/initial-flex-layout :grid cls/initial-grid-layout) + file (reduce-kv #(ths/update-shape %1 root %2 %3) (tm/file situation) defaults) + file (cond-> file (= layout :grid) (tho/place-in-grid-columns root 2 {1 child}))] + (tm/with-file situation file)))) + (defn world - [[components depth]] + [[components depth opts]] (thi/reset-idmap!) (reduce (fn [s index] (let [name (str "random-" index)] - (reduce #(tm/apply-to %2 %1) s - [(n/create-component name setup/labeled-fill :size 100) - (n/make-nested-component name depth) - (n/instantiate-copy name) - (n/instantiate-copy name)]))) - (setup/empty-situation) + (-> (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)] + (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))) (defn candidates [situation model kind scope] (let [objects (:objects (thf/current-page (tm/file situation))) + file (tm/file situation) + libraries (assoc (tm/aux-files situation) (:id file) file) copy-head (fn [shape] (and (:shape-ref shape) (:component-id shape))) + live-component (fn [shape] + (let [component (get-in libraries [(:component-file shape) :data :components (:component-id shape)])] + (and component (not (:deleted component))))) top-copy (fn [shape] (and (copy-head shape) (= 1 (count (ctn/get-parent-copy-heads objects shape))))) overridden (fn overridden [id] (or (seq (get model id)) - (some overridden (:shapes (get objects id)))))] - (filterv (fn [{:keys [shape]}] - (case kind - :edit (or (not= scope 1) (:shape-ref shape)) - :reset (copy-head shape) - :update-main (and (top-copy shape) (overridden (:id shape))) - :detach (top-copy shape) - (:resize :rotate) (top-copy shape) - false)) - (tree situation)))) + (some overridden (:shapes (get objects id))))) + eligible (filterv (fn [{:keys [shape]}] + (case kind + :edit (and (not= :text (:type shape)) (or (not= scope 1) (:shape-ref shape))) + :reset (and (copy-head shape) (live-component shape)) + :update-main (and (top-copy shape) (live-component shape) (overridden (:id shape))) + :detach (top-copy shape) + (:resize :rotate) (top-copy shape) + :text (= :text (:type shape)) + :add-child (ctk/main-instance? shape) + :remove-child (and (= :rect (:type shape)) (nil? (:shape-ref shape)) + (some ctk/main-instance? (cfh/get-parents-with-self objects (:id shape)))) + :duplicate (and (top-copy shape) (< (count objects) 120)) + :nest (and (top-copy shape) (not (overridden (:id shape)))) + :delete (ctk/main-instance? shape) + false)) + (tree situation))] + (case kind + :restore (->> (vals (get-in file [:data :components])) + (filter :deleted) + (sort-by (comp str :id)) + (map-indexed (fn [index component] + {:path [:deleted index] + :shape {:id (:main-instance-id component) + :component-id (:id component)}})) + vec) + :nest (vec (for [entry eligible + parent (tree situation) + :let [parent-shape (:shape parent)] + :when (and (ctk/main-instance? parent-shape) + (not= (:parent-id (:shape entry)) (:id parent-shape)) + (not (ctn/invalid-structure-for-component? + objects parent-shape [(:shape entry)] false libraries)))] + (assoc entry :into (:path parent) :parent (:shape parent)))) + eligible))) (defn resolve-op "Resolve one tuple. Empty target sets produce an explicit skip." @@ -91,11 +145,23 @@ :detach (n/detach target) :resize (n/change-height target (+ 80 (* 20 (mod (or (nth tuple 3 nil) 0) 6)))) :rotate (n/rotate target (* 45 (inc (mod (or (nth tuple 3 nil) 0) 4)))) - nil)))] + :text (let [text (thtx/view (:content (:shape selected))) + text (if (even? (or (nth tuple 2 nil) 0)) + (thtx/set-letters text (nth ["Hello world" "Hello there" "Hello\nworld"] (mod (or (nth tuple 3 nil) 0) 3))) + (thtx/restyle text :size (nth [14 18 24] (mod (or (nth tuple 3 nil) 0) 3))))] + (n/edit-text target :content text)) + :add-child (n/add-child target (keyword (str "generated-" (count (:applied situation)))) + :width 20 :height 20 :x 10 :y 10) + :remove-child (n/remove-child target) + :duplicate (n/duplicate target) + :nest (n/nest-copy target (constantly (get-in selected [:parent :id]))) + :delete (n/delete-component target) + :restore (n/restore-component (n/delete-component target)))))] {:op op :shape (:shape selected) :property property :value value :trace (cond-> {:kind kind :tuple tuple :status (if (or op (= kind :sync)) :executed :skip)} selected (assoc :target (:path selected)) + (:into selected) (assoc :into (:into selected)) (and (nil? op) (not= kind :sync)) (assoc :reason (str "No eligible " (name kind) " target")))})) @@ -116,11 +182,11 @@ full (r/sync-cascade (tm/file settled) (tm/aux-files settled) all)] (oracle/check settled model) - (when-not (= (oracle/design-snapshot settled) - (oracle/design-snapshot (tm/with-file situation (:file full-before)))) - (oracle/fail :I6 {})) - (when-not (= (oracle/design-snapshot settled) - (oracle/design-snapshot (tm/with-file settled (:file full)))) + (when-not (= (oracle/canonical settled) + (oracle/canonical (tm/with-file situation (:file full-before)))) + (oracle/fail :I6 {:diff (thn/diff (oracle/canonical settled) (oracle/canonical (tm/with-file situation (:file full-before))))})) + (when-not (= (oracle/canonical settled) + (oracle/canonical (tm/with-file settled (:file full)))) (oracle/fail :I5 {})) (when checkpoint (assert-round-trip checkpoint settled)) (assoc state :situation settled :checkpoint nil))) @@ -135,6 +201,10 @@ :update-main (shadow/update-main model objects id) :detach (shadow/detach model objects (:objects (thf/current-page (tm/file after))) id) + :text (shadow/edit-text model shape (:content (n/text-edit-commit shape (:edit op) (:value op)))) + :duplicate (shadow/duplicate model objects id + (:objects (thf/current-page (tm/file after))) + (:new-head (n/applied-result op (tm/file before) (tm/file after)))) (:resize :rotate) (let [shape-after (tm/shape-by-id after id) group :geometry-group] @@ -250,3 +320,20 @@ ;; coverage floor even at size zero. The random tail changes ordering. :ops (into [[:edit 0 0 1 1] [:sync 0] [:update-main 0] [:edit 0 1 1 1] [:reset 0] [:detach 0] [:undo] [:redo]] ops)})) + +(def structure-kinds + [:edit :reset :update-main :detach :sync :undo :redo :text :add-child + :remove-child :duplicate :nest :delete :restore :resize :rotate]) + +(def structure-program-gen + (gen/let [components (gen/choose 2 3) + depth (gen/choose 0 2) + layout (gen/elements [nil :flex :grid]) + ops (gen/vector (gen/tuple (gen/elements structure-kinds) + gen/nat gen/nat gen/nat gen/nat) 0 20)] + {:world [components depth {:text true :layout layout}] + :ops (into [[:add-child 0] [:remove-child 0] [:duplicate 0] [:nest 0] + [:delete 0] [:restore 0] [:text 0 0 1] [:text 1 1 1] + [:resize 0 0 2] [:rotate 0 0 0] [:reset 0] + [:edit 0 0 1 1] [:update-main 0] [:sync 1] + [:detach 0] [:undo] [:redo]] ops)})) diff --git a/common/src/app/common/test_helpers/composable/oracle.cljc b/common/src/app/common/test_helpers/composable/oracle.cljc index f595f0164c..9224f7a395 100644 --- a/common/src/app/common/test_helpers/composable/oracle.cljc +++ b/common/src/app/common/test_helpers/composable/oracle.cljc @@ -74,7 +74,12 @@ (update value :objects (fn [objects] (into {} (map (fn [[id shape]] - [id (apply dissoc shape (derived-attributes objects shape))])) objects))) + [id (-> (gsh/move shape + (gpt/point (- (get-in objects [(:parent-id shape) :x] 0)) + (- (get-in objects [(:parent-id shape) :y] 0)))) + (as-> shape (apply dissoc shape (derived-attributes objects shape))) + (update :constraints-h #(or % (gsh/default-constraints-h shape))) + (update :constraints-v #(or % (gsh/default-constraints-v shape))))])) objects))) value)) (snapshot situation)))) @@ -126,7 +131,23 @@ (or (:transform-inverse main-parent) (gmt/matrix))) placed (if (gmt/unit? turn) main (gsh/apply-transform main (gmt/transform-in center turn)))] - (gsh/move placed (gpt/subtract (gsh/shape->center parent) center)))) + (-> placed + (gsh/move (gpt/subtract (gsh/shape->center parent) center)) + (assoc :rotation (mod (+ (or (:rotation main) 0) + (- (or (:rotation parent) 0) (or (:rotation main-parent) 0))) 360))))) + +(defn- with-derived-geometry + [objects shape expected] + (let [derived (derived-attributes objects shape) + width (if (contains? derived :width) (:width shape) (:width expected)) + height (if (contains? derived :height) (:height shape) (:height expected)) + expected (if (and (mth/close? width (:width expected) 0.01) + (mth/close? height (:height expected) 0.01)) + expected + (gsh/transform-shape expected (ctm/change-size expected width height)))] + (gsh/move expected + (gpt/point (if (contains? derived :x) (- (:x shape) (:x expected)) 0) + (if (contains? derived :y) (- (:y shape) (:y expected)) 0))))) (defn expected-geometry [file container libraries shape] @@ -136,17 +157,19 @@ (fail :I1 {:shape (:name shape) :reason :parent-cycle})) (if (contains? (:touched shape) :geometry-group) shape - (if-let [main (ctf/find-ref-shape file container libraries shape :with-context? true)] - (let [parent (get objects (:parent-id shape)) - main-objects (get-in (meta main) [:container :objects]) - main-parent (get main-objects (:parent-id main))] - (if (and (:shape-ref parent) main-parent) - (child-placement main main-parent (expected parent (conj visited (:id shape))) - main-objects) - ;; Root placement is authored independently of overrides. - (gsh/move main (gpt/subtract (gpt/point (:x shape) (:y shape)) - (gpt/point (:x main) (:y main)))))) - shape)))] + (with-derived-geometry + objects shape + (if-let [main (ctf/find-ref-shape file container libraries shape :with-context? true)] + (let [parent (get objects (:parent-id shape)) + main-objects (get-in (meta main) [:container :objects]) + main-parent (get main-objects (:parent-id main))] + (if (and (:shape-ref parent) main-parent) + (child-placement main main-parent (expected parent (conj visited (:id shape))) + main-objects) + ;; Root placement is authored independently of overrides. + (gsh/move main (gpt/subtract (gpt/point (:x shape) (:y shape)) + (gpt/point (:x main) (:y main)))))) + shape))))] (expected shape #{})))) (defn- geometry-differences @@ -155,14 +178,45 @@ (not (ctl/position-absolute? shape))) auto-width (and (= :text (:type shape)) (= :auto-width (:grow-type shape))) auto-height (and (= :text (:type shape)) (#{:auto-width :auto-height} (:grow-type shape))) - width-owned (or auto-width (and layout-child (#{:fill :auto} (:layout-item-h-sizing shape)))) - height-owned (or auto-height (and layout-child (#{:fill :auto} (:layout-item-v-sizing shape))))] + derived (derived-attributes objects shape) + width-owned (contains? derived :width) + height-owned (contains? derived :height)] (cond-> #{} + (not (mth/close? (or (:rotation shape) 0) (or (:rotation expected) 0) 0.01)) (conj :rotation) + (not= (boolean (:flip-x shape)) (boolean (:flip-x expected))) (conj :flip-x) + (not= (boolean (:flip-y shape)) (boolean (:flip-y expected))) (conj :flip-y) (and (not width-owned) (not (mth/close? (:width shape) (:width expected) 0.01))) (conj :width) (and (not height-owned) (not (mth/close? (:height shape) (:height expected) 0.01))) (conj :height) (and (not layout-child) (not auto-width) (not auto-height) (not (gsh/close-attrs? :points (:points shape) (:points expected) 0.01))) (conj :points)))) +(defn- comparable-values + [shape context group] + (let [shape (if (= group :constraints-group) + (cond-> shape + (nil? (:constraints-h shape)) (assoc :constraints-h (gsh/default-constraints-h context)) + (nil? (:constraints-v shape)) (assoc :constraints-v (gsh/default-constraints-v context))) + shape)] + (shadow/values shape group))) + +(defn- grid-cells + [shape] + (let [indices (zipmap (:shapes shape) (range))] + (->> (vals (:layout-grid-cells shape)) + (map #(-> % (dissoc :id) (update :shapes (fn [ids] (mapv indices ids))))) + (sort-by (juxt :row :column)) + vec))) + +(defn- check-structure + [objects shape main] + (let [refs (mapv #(get-in objects [% :shape-ref]) (:shapes shape))] + (when-not (= (vec (:shapes main)) refs) + (fail :I4 {:shape (:name shape) :group :structure + :expected (:shapes main) :actual refs})) + (when (and (= :grid (:layout main)) (not= (grid-cells shape) (grid-cells main))) + (fail :I4 {:shape (:name shape) :group :layout-grid-cells + :expected (grid-cells main) :actual (grid-cells shape)})))) + (defn check "I1–I4 and I9. Before settlement only authored groups/values are checked; pending main changes may legitimately leave copies stale." @@ -186,13 +240,14 @@ :expected value :actual (shadow/values shape group)}))) (when settled (when-let [main (ctf/find-content-ref-shape file container libraries shape)] + (check-structure (:objects page) shape main) (doseq [group (keys shadow/attributes) :when (and (not= group :geometry-group) (not (contains? touched group))) :when (not (and (= group :name-group) (= :text (:type shape)) (= (:name main) (txt/generate-shape-name (txt/content->text (:content main)))))) ;; A copy root's name follows component renaming too. - :let [expected (shadow/values main group) - actual (shadow/values shape group)]] + :let [expected (comparable-values main shape group) + actual (comparable-values shape shape group)]] (when-not (= expected actual) (fail :I4 {:shape (:name shape) :group group :expected expected :actual actual}))) diff --git a/common/src/app/common/test_helpers/composable/shadow.cljc b/common/src/app/common/test_helpers/composable/shadow.cljc index ebcaa1f9c1..058c9cb6bf 100644 --- a/common/src/app/common/test_helpers/composable/shadow.cljc +++ b/common/src/app/common/test_helpers/composable/shadow.cljc @@ -20,14 +20,25 @@ :layer-effects-group [:opacity :blend-mode] :name-group [:name] :geometry-group [:width :height :rotation :flip-x :flip-y :content-scale] + :text-font-group [:grow-type] :constraints-group [:constraints-h :constraints-v :fixed-scroll] :layout-container [:layout] + :layout-grid-dir [:layout-grid-dir] + :layout-grid-rows [:layout-grid-rows] + :layout-grid-columns [:layout-grid-columns] + :layout-align-content [:layout-align-content] + :layout-justify-items [:layout-justify-items] + :layout-wrap-type [:layout-wrap-type] :layout-gap [:layout-gap :layout-gap-type] :layout-padding [:layout-padding :layout-padding-type] :layout-flex-dir [:layout-flex-dir] :layout-align-items [:layout-align-items] :layout-justify-content [:layout-justify-content] :layout-item-margin [:layout-item-margin :layout-item-margin-type] + :layout-item-max-h [:layout-item-max-h] + :layout-item-min-h [:layout-item-min-h] + :layout-item-max-w [:layout-item-max-w] + :layout-item-min-w [:layout-item-min-w] :layout-item-h-sizing [:layout-item-h-sizing] :layout-item-v-sizing [:layout-item-v-sizing] :layout-item-absolute [:layout-item-absolute] diff --git a/common/test/common_tests/logic/comp_sync_test.cljc b/common/test/common_tests/logic/comp_sync_test.cljc index 5d20980ce1..e124969318 100644 --- a/common/test/common_tests/logic/comp_sync_test.cljc +++ b/common/test/common_tests/logic/comp_sync_test.cljc @@ -15,9 +15,14 @@ [app.common.logic.libraries :as cll] [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] + [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] + [app.common.test-helpers.normalize :as thn] [app.common.test-helpers.shapes :as ths] [app.common.types.component :as ctk] [app.common.types.container :as ctn] @@ -28,6 +33,51 @@ (t/use-fixtures :each thi/test-fixture) +(t/deftest grid-sync-keeps-new-main-cells + (let [file (-> (thf/sample-file :file1) + (tho/add-grid-frame :main-root :width 100 :height 100) + (tho/add-rect :main-child :parent-label :main-root) + (tho/place-in-grid-columns :main-root 1 {1 :main-child}) + (thc/make-component :component1 :main-root) + (thc/instantiate-component :component1 :copy-root)) + initial (tm/make-situation file {}) + added (-> initial + (r/step (n/add-child :main-root :new-child :width 20 :height 20)) + r/settle) + cells (fn [s label copy?] + (let [objects (:objects (thf/current-page (tm/file s))) + root (tm/resolve-shape s label)] + (into {} (map (fn [[id cell]] + [id (cond-> cell copy? + (update :shapes #(mapv (fn [id] (:shape-ref (get objects id))) %)))])) + (:layout-grid-cells root))))] + (doseq [s [added]] + (t/is (= (cells s :main-root false) (cells s :copy-root true))) + (t/is (nil? (:touched (tm/resolve-shape s :copy-root))))) + (let [undone (r/step added (n/undo)) + redone (r/step undone (n/redo))] + (t/is (= (thn/normalize-file (tm/file initial)) (thn/normalize-file (tm/file undone))) + (pr-str (thn/diff (thn/normalize-file (tm/file initial)) (thn/normalize-file (tm/file undone))))) + (t/is (= (thn/normalize-file (tm/file added)) (thn/normalize-file (tm/file redone))) + (pr-str (thn/diff (thn/normalize-file (tm/file added)) (thn/normalize-file (tm/file redone)))))))) + +(t/deftest grid-sync-keeps-authored-placement-until-reset + (let [initial (setup/grid-component-with-copy) + moved (-> initial (r/step (n/move-to-cell :grid-copy :copy-child1 1 3)) r/settle) + edited (-> moved (r/step (n/change-property :grid-root :opacity 0.4)) r/settle) + reset (-> edited (r/step (n/reset :grid-copy)) r/settle) + cells #(get (tm/resolve-shape % :grid-copy) :layout-grid-cells) + copy-membership (fn [s] + (let [root (tm/resolve-shape s :grid-copy) + objects (:objects (thf/current-page (tm/file s)))] + (update-vals (:layout-grid-cells root) + #(update % :shapes (fn [ids] (mapv (fn [id] (:shape-ref (get objects id))) ids))))))] + (t/is (contains? (:touched (tm/resolve-shape moved :grid-copy)) :layout-grid-cells)) + (t/is (= (cells moved) (cells edited))) + (t/is (= (:layout-grid-cells (tm/resolve-shape reset :grid-root)) + (copy-membership reset))) + (t/is (nil? (:touched (tm/resolve-shape reset :grid-copy)))))) + (t/deftest test-sync-when-changing-attribute (let [;; ==== Setup file (-> (thf/sample-file :file1) diff --git a/common/test/common_tests/logic/composable_gen_test.cljc b/common/test/common_tests/logic/composable_gen_test.cljc index b32ca942a9..ba06f0d341 100644 --- a/common/test/common_tests/logic/composable_gen_test.cljc +++ b/common/test/common_tests/logic/composable_gen_test.cljc @@ -7,6 +7,7 @@ (ns common-tests.logic.composable-gen-test (:require [app.common.schema.test :as smt] + [app.common.test-helpers.composable.comp.runner :as r] [app.common.test-helpers.composable.comp.setups :as setup] [app.common.test-helpers.composable.core :as tm] [app.common.test-helpers.composable.gen :as cg] @@ -65,6 +66,14 @@ (t/is (= 1 (get-in result [:executed :resize] 0))) (t/is (= 1 (get-in result [:executed :rotate] 0))))) +(t/deftest structure-and-text-operations-run-through-production-nodes + (let [result (cg/run {:world [2 0 {:text true :layout :flex}] + :ops [[:edit 0 0 1] [:add-child 0] [:remove-child 0] + [:duplicate 0] [:nest 0] [:delete 0] [:restore 0] + [:text 0 0 1] [:undo] [:redo]]})] + (doseq [kind [:add-child :remove-child :duplicate :nest :delete :restore :text]] + (t/is (pos? (get-in result [:executed kind] 0)) (str kind " did not execute"))))) + (t/deftest empty-candidates-are-logged-and-empty-runs-rejected (let [result (cg/run {:world [2 0] :ops [[:restore 0] [:edit 0 0 1]]})] (t/is (= :skip (get-in result [:trace 0 :status]))) @@ -82,3 +91,27 @@ (println "Executed component operations:" (pr-str @coverage)) (doseq [kind [:edit :reset :update-main :detach :sync :undo :redo]] (t/is (pos? (get @coverage kind 0)) (str "No executed " kind))))) + +(t/deftest random-structure-compositions + (let [coverage (atom {}) + failure (atom nil)] + (smt/check! + (smt/for [program cg/structure-program-gen] + (cg/check-program program coverage failure)) + :max-size 30) + (println "Executed structure operations:" (pr-str @coverage)) + (doseq [kind cg/structure-kinds] + (t/is (pos? (get @coverage kind 0)) (str "No executed " kind))))) + +(t/deftest partial-sync-keeps-independent-components-pending + (let [initial (cg/initial-state [3 0]) + source (get-in (cg/resolve-op (:situation initial) {} [:nest 0]) [:shape :id]) + nested (-> initial (cg/step [:nest 0]) (cg/settle-state)) + candidates (cg/candidates (:situation nested) (:model nested) :nest nil) + index (first (keep-indexed #(when (= source (get-in %2 [:shape :id])) %1) candidates)) + state (cg/step nested [:nest index]) + partial (cg/step state [:sync 1]) + settled (cg/settle-state partial)] + (t/is (= 2 (count (r/pending-components (:situation state))))) + (t/is (= 1 (count (r/pending-components (:situation partial))))) + (t/is (empty? (r/pending-components (:situation settled)))))) diff --git a/common/test/common_tests/logic/composable_oracle_test.cljc b/common/test/common_tests/logic/composable_oracle_test.cljc index e33beaa2bc..38eb4f12f0 100644 --- a/common/test/common_tests/logic/composable_oracle_test.cljc +++ b/common/test/common_tests/logic/composable_oracle_test.cljc @@ -13,7 +13,9 @@ [app.common.logic.placement :as clp] [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.core :as tm] + [app.common.test-helpers.composable.gen :as cg] [app.common.test-helpers.composable.oracle :as oracle] [app.common.test-helpers.composable.shadow :as shadow] [app.common.test-helpers.compositions :as tho] @@ -87,3 +89,72 @@ (ths/update-shape :copy-child :touched nil))] (t/is (thrown? #?(:clj Exception :cljs js/Error) (oracle/check (tm/make-situation file) {}))))) + +(t/deftest oracle-accepts-layout-owned-fill-sizing + (doseq [layout [:flex :grid]] + (let [file (-> (thf/sample-file :file1) + (tho/add-frame :main-root {:width 100 :height 100}) + (tho/add-rect :main-child {:parent-label :main-root :x 20 :y 10 + :width 30 :height 20 :constraints-h :scale}) + (thc/make-component :component1 :main-root) + (tho/add-frame :layout-parent (merge (case layout :flex cls/initial-flex-layout + :grid cls/initial-grid-layout) + {:width 200 :height 200})) + (thc/instantiate-component :component1 :copy-root :parent-label :layout-parent + :children-labels [:copy-child]) + (ths/update-shape :copy-root :layout-item-h-sizing :fill) + (cond-> (= layout :grid) + (tho/place-in-grid-columns :layout-parent 1 {1 :copy-root}))) + root (ths/get-shape file :copy-root) + page (thf/current-page file) + tree (gm/set-objects-modifiers {(:id root) {:modifiers (ctm/change-size root 200 nil)}} + (:objects page)) + resized (thf/apply-changes + file (cls/generate-update-shapes + (pcb/empty-changes nil (:id page)) (keys tree) + (clp/modifiers-transform tree) (:objects page) {:ignore-touched true}))] + (t/is (nil? (oracle/check (tm/make-situation resized) + {(thi/id :copy-root) {:layout-item-h-sizing {:layout-item-h-sizing :fill}}})))))) + +(t/deftest oracle-accepts-measured-auto-height-text + (let [situation (cg/world [2 0 {:text true}]) + file (tm/file situation) + page (thf/current-page file) + resized (thf/apply-changes + file (cls/generate-update-shapes + (pcb/empty-changes nil (:id page)) #{(thi/id :copy-child)} + #(gsh/transform-shape % (ctm/change-size % nil 80)) + (:objects page) {:ignore-touched true}))] + (t/is (nil? (oracle/check (tm/with-file situation resized) {}))))) + +(t/deftest oracle-accepts-a-layout-copy-after-main-child-addition + (doseq [layout [:flex :grid]] + (let [result (cg/run {:world [2 0 {:layout layout}] + :ops [[:add-child 0] [:sync 1]]})] + (t/is (= 1 (get-in result [:executed :add-child])))))) + +(t/deftest oracle-rejects-unmarked-layout-input-differences + (let [situation (cg/world [2 0 {:layout :flex}]) + root ((n/copy-head-of "random-0") situation) + file (-> (tm/file situation) + (ths/update-shape-by-id root :layout-gap {:row-gap 10 :column-gap 10}) + (ths/update-shape-by-id root :touched nil))] + (t/is (thrown? #?(:clj Exception :cljs js/Error) + (oracle/check (tm/with-file situation file) {}))))) + +(t/deftest design-comparison-keeps-relative-positions-under-layout-movement + (let [state (reduce cg/step (cg/initial-state [2 0 {:text true :layout :flex}]) + [[:nest 0] [:detach 0]]) + situation (:situation (cg/settle-state state)) + file (tm/file situation) + page (thf/current-page file) + move (fn [file ids delta] + (thf/apply-changes + file (cls/generate-update-shapes + (pcb/empty-changes nil (:id page)) ids + #(gsh/move % (gpt/point delta 0)) + (:objects (thf/current-page file)) {:ignore-touched true}))) + moved (move file #{(thi/id :copy-root) (thi/id :copy-child)} 50) + bad (move moved #{(thi/id :copy-child)} 5)] + (t/is (= (oracle/canonical situation) (oracle/canonical (tm/with-file situation moved)))) + (t/is (not= (oracle/canonical situation) (oracle/canonical (tm/with-file situation bad)))))) diff --git a/common/test/common_tests/logic/composable_sync_test.cljc b/common/test/common_tests/logic/composable_sync_test.cljc index 851a348bf2..82d18c28df 100644 --- a/common/test/common_tests/logic/composable_sync_test.cljc +++ b/common/test/common_tests/logic/composable_sync_test.cljc @@ -6,8 +6,7 @@ (ns common-tests.logic.composable-sync-test "Runs the shared composable component cases through the pure runner, on the - JVM and in JS. Only the frontend interpreter runs the geometry cases (N, Z, - AI and the rotation case), whose modifiers come from frontend events, case X, + JVM and in JS. Only the frontend interpreter runs the geometry cases Z and AI, whose modifiers come from frontend events, case X, which needs a pending frontend sync, case AJ, which needs the frontend reflow, and cases AT..AX, whose variant actions are frontend events." (:require @@ -74,6 +73,12 @@ (t/deftest case-m-variant-switch-scenarios (check (cases/variant-switch-scenarios))) +(t/deftest case-n-geometry-sync-with-rotated-instances + (check (cases/geometry-sync-with-rotated-instances))) + +(t/deftest touched-copy-child-survives-main-rotation + (check (cases/touched-copy-child-survives-main-rotation))) + (t/deftest case-o-variant-switch-keeps-override-only-where-mains-agree (check (cases/variant-switch-keeps-override-only-where-mains-agree))) diff --git a/common/test/common_tests/logic/golden_master.edn b/common/test/common_tests/logic/golden_master.edn index 43ea637d28..2e910129aa 100644 --- a/common/test/common_tests/logic/golden_master.edn +++ b/common/test/common_tests/logic/golden_master.edn @@ -144,6 +144,15 @@ [{:transcript "main-edit-into-100-copies", :states ["3fd3b227924187f4"]}] :main-edit-into-1000-copies [{:transcript "main-edit-into-1000-copies", :states ["b6994497085bc07f"]}] + :n + [{:transcript "rotate {:target :copy-root, :angle 45}\n rotate {:target :main-root, :angle 30}\n change-property {:target :main-instance, :property :fills, :value \"#ff0000\"}", :states ["8208735a98e4c105" "f9a383ec6e3b0bb3" "e1975cf2f47c5763" "9a89ace89a9faec9"]} + {:transcript "rotate {:target :copy-root, :angle 45}\n rotate {:target :main-root, :angle 30}\n change-height {:target :main-instance, :value 80}", :states ["8208735a98e4c105" "f9a383ec6e3b0bb3" "7e9afa9fd32338bd" "15ad444e53bca81b"]} + {:transcript "rotate {:target :copy-root, :angle 45}\n one-of -> skip\n change-property {:target :main-instance, :property :fills, :value \"#ff0000\"}", :states ["8208735a98e4c105" "aa9f6c1b6a87ac0f" "38265afaa05640e8"]} + {:transcript "rotate {:target :copy-root, :angle 45}\n one-of -> skip\n change-height {:target :main-instance, :value 80}", :states ["8208735a98e4c105" "e92b0483129edb9c" "afd213908fee2f38"]} + {:transcript "one-of -> skip\n rotate {:target :main-root, :angle 30}\n change-property {:target :main-instance, :property :fills, :value \"#ff0000\"}", :states ["ceecd80f347920c9" "f5c326161b425dca" "049a41b4774ab16d"]} + {:transcript "one-of -> skip\n rotate {:target :main-root, :angle 30}\n change-height {:target :main-instance, :value 80}", :states ["ceecd80f347920c9" "b23260b0f15be83b" "65e4a3e632ab0e48"]} + {:transcript "one-of -> skip\n one-of -> skip\n change-property {:target :main-instance, :property :fills, :value \"#ff0000\"}", :states ["17b5ce28aeffa62e" "8f31b06c3ab1921b"]} + {:transcript "one-of -> skip\n one-of -> skip\n change-height {:target :main-instance, :value 80}", :states ["928074db7743f484" "d54aee2c77882202"]}] :o [{:transcript "create-component {:component \"main\", :id sync-main-component}\n make-variant-container {:variant-set \"vset\", :variant-id sync-vset-vcontainer, :count 2}\n make-nested-component-with-variant {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n instantiate-copy {:component \"main\", :copy sync-main-copyhead-0}\n change-property {:target #fn, :property :fills, :value \"#00ff00\"}\n switch-variant {:target-component sync-vset-v1-component}", :states ["9a1a3735f1009290" "b00ea3774a618f8e" "d15895a5c5a7e993"]} {:transcript "create-component {:component \"main\", :id sync-main-component}\n make-variant-container {:variant-set \"vset\", :variant-id sync-vset-vcontainer, :count 2}\n make-nested-component-with-variant {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n instantiate-copy {:component \"main\", :copy sync-main-copyhead-0}\n change-property {:target #fn, :property :fills, :value \"#00ff00\"}\n switch-variant {:target-component sync-vset-v1-component}", :states ["1247c1fd53a5de3f" "45d0fe0a073fbada" "9a8e3d09e48af9fe"]}] @@ -163,6 +172,8 @@ {:transcript "create-component {:component \"main\", :id sync-main-component}\n make-nested-component {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n instantiate-copy {:component \"main\", :copy sync-main-copyhead-0}\n instantiate-copy {:component \"main\", :copy sync-main-copyhead-1}\n one-of -> skip\n change-property {:target #fn, :property :fills, :value \"#00ff00\"}\n update-main {:target #fn, :updated sync-main-copyhead-0}", :states ["165424c8242fe851" "0c79c89b4319b252" "43c4776b4a90f22c"]} {:transcript "create-component {:component \"main\", :id sync-main-component}\n make-nested-component {:component \"main\", :level 1, :outer sync-main-outercomp-1}\n make-nested-component {:component \"main\", :level 1, :outer sync-main-outercomp-1}\n instantiate-copy {:component \"main\", :copy sync-main-copyhead-0}\n instantiate-copy {:component \"main\", :copy sync-main-copyhead-1}\n change-property {:target #fn, :property :fills, :value \"#0000ff\"}\n change-property {:target #fn, :property :fills, :value \"#00ff00\"}\n update-main {:target #fn, :updated sync-main-copyhead-0}", :states ["dd907ef11d9e4851" "481d62dd7e471206" "046525ebd0ffe008" "682e0ba7926cc177"]} {:transcript "create-component {:component \"main\", :id sync-main-component}\n make-nested-component {:component \"main\", :level 1, :outer sync-main-outercomp-1}\n make-nested-component {:component \"main\", :level 1, :outer sync-main-outercomp-1}\n instantiate-copy {:component \"main\", :copy sync-main-copyhead-0}\n instantiate-copy {:component \"main\", :copy sync-main-copyhead-1}\n one-of -> skip\n change-property {:target #fn, :property :fills, :value \"#00ff00\"}\n update-main {:target #fn, :updated sync-main-copyhead-0}", :states ["af30a8074415ea47" "0292c4761cadebad" "8c3b12b00c20dd24"]}] + :rotation + [{:transcript "rotate {:target :copy-root, :angle 30}\n change-height {:target :copy-instance, :value 60}\n test\n rotate {:target :main-root, :angle 40}", :states ["f54075611f2456da" "dcfc7d5c6f4bec2a" "1f948e0492586197" "46492bbe943d1366"]}] :s [{:transcript "change-property {:target :copy-instance, :property :fills, :value \"#00ff00\"}\n change-property {:target :main-instance, :property :fills, :value \"#ff0000\"}\n delete-component {:target :main-root, :component-id component1}\n test\n restore-component {:component-id component1}\n change-property {:target :main-instance, :property :fills, :value \"#0000ff\"}", :states ["93c77f92c889307b" "0c2ac66ca5452745" "11bd55106f98bebc" "6a0d75c08f6b08b8" "bccb7c6434c8de28" "9da4088c9b6c2b45"]} {:transcript "one-of -> skip\n change-property {:target :main-instance, :property :fills, :value \"#ff0000\"}\n delete-component {:target :main-root, :component-id component1}\n test\n restore-component {:component-id component1}\n change-property {:target :main-instance, :property :fills, :value \"#0000ff\"}", :states ["8c5be47002e73694" "323f1f1ed8b35e4d" "eb7c91cf5e08a6c3" "86c4d8c4352a04db" "ddbdea68968e3988"]}] diff --git a/common/test/common_tests/logic/golden_master_test.clj b/common/test/common_tests/logic/golden_master_test.clj index bb39803ca3..449cf30af6 100644 --- a/common/test/common_tests/logic/golden_master_test.clj +++ b/common/test/common_tests/logic/golden_master_test.clj @@ -53,6 +53,8 @@ :k cases/synchronisation-scenarios :l cases/swap-scenarios :m cases/variant-switch-scenarios + :n cases/geometry-sync-with-rotated-instances + :rotation cases/touched-copy-child-survives-main-rotation :o cases/variant-switch-keeps-override-only-where-mains-agree :q cases/detach-keeps-nested-copies-linked :r cases/update-main-reaches-other-copies diff --git a/common/test/common_tests/logic/move_shapes_test.cljc b/common/test/common_tests/logic/move_shapes_test.cljc index 57f176dc7e..8fdb32cb51 100644 --- a/common/test/common_tests/logic/move_shapes_test.cljc +++ b/common/test/common_tests/logic/move_shapes_test.cljc @@ -21,6 +21,25 @@ (t/use-fixtures :each thi/test-fixture) +(t/deftest deleting-a-grid-child-clears-its-cell-and-keeps-undo + (let [file (-> (thf/sample-file :file1) + (tho/add-grid-frame :grid :width 200 :height 100) + (tho/add-rect :first :parent-label :grid) + (tho/add-rect :second :parent-label :grid) + (tho/place-in-grid-columns :grid 2 {1 :first 2 :second})) + page (thf/current-page file) + [_ changes] (cls/generate-delete-shapes (pcb/empty-changes) (:data file) page + (:objects page) #{(thi/id :first)} {}) + removed (thf/apply-changes file changes) + grid (ths/get-shape removed :grid) + members (mapcat :shapes (vals (:layout-grid-cells grid))) + undone (thf/apply-changes removed {:redo-changes (:undo-changes changes)}) + redone (thf/apply-changes undone changes)] + (t/is (not-any? #{(thi/id :first)} members)) + (t/is (= #{(thi/id :second)} (set members))) + (t/is (= (thn/normalize-file file) (thn/normalize-file undone))) + (t/is (= (thn/normalize-file removed) (thn/normalize-file redone))))) + (t/deftest test-relocate-shape (let [;; ==== Setup file (-> (thf/sample-file :file1) diff --git a/frontend/test/frontend_tests/composable_tests/generated_test.cljs b/frontend/test/frontend_tests/composable_tests/generated_test.cljs new file mode 100644 index 0000000000..d21402a152 --- /dev/null +++ b/frontend/test/frontend_tests/composable_tests/generated_test.cljs @@ -0,0 +1,88 @@ +;; 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.composable-tests.generated-test + (:require + [app.common.test-helpers.composable.comp.undo-check :as uc] + [app.common.test-helpers.composable.core :as tm] + [app.common.test-helpers.composable.gen :as cg] + [app.common.test-helpers.composable.oracle :as oracle] + [app.common.test-helpers.normalize :as thn] + [cljs.test :as t :include-macros true] + [clojure.test.check.generators :as gen] + [frontend-tests.composable-tests.interpreter :as ftm])) + +(t/use-fixtures :each ftm/fixture) + +(defn- live-model + [state live] + (let [by-path (into {} (map (juxt :path (comp :id :shape))) (cg/tree live))] + (into {} + (keep (fn [{:keys [path shape]}] + (when-let [groups (get (:model state) (:id shape))] + (when-let [id (get by-path path)] [id groups])))) + (cg/tree (:situation state))))) + +(defn ^:async replay + "Replay a JVM-shrunk EDN program against the async frontend. The pure + runner chooses targets independently and both must make the same choices." + [{:keys [world ops] :as program}] + (try + (let [initial (cg/initial-state world)] + (loop [state initial + live (ftm/start-generated (:situation initial)) + tuples ops] + (if-let [tuple (first tuples)] + (let [state (cg/settle-state state) + pure (:situation state) + live (tm/with-file live (ftm/current-file)) + expected (cg/resolve-op pure (:model state) tuple) + actual (if (#{:undo :redo} (first tuple)) + expected + (cg/resolve-op live (live-model state live) tuple))] + (t/is (= (:trace expected) (:trace actual)) (str "Replay changed a target: " tuple)) + (let [next-live (if-let [op (:op actual)] + (await (ftm/run-generated-step live op)) + (do + (t/is (= :settled (await (ftm/await-step [])))) + (tm/with-file live (ftm/current-file)))) + next-state (-> state (cg/step tuple) (cg/settle-state))] + (when-not next-live + (throw (ex-info "Frontend step failed" {:program program :tuple tuple}))) + (try + (oracle/check next-live (live-model next-state next-live)) + (catch :default error + (throw (ex-info (str "Frontend oracle at " tuple ": " (ex-message error)) + (ex-data error) error)))) + (let [expected (oracle/canonical (:situation next-state)) + actual (oracle/canonical next-live)] + (t/is (true? (= expected actual)) + (str "Backend mismatch at " tuple "; replay " (pr-str program) + "; diff " (pr-str (thn/diff expected actual))))) + (t/is (empty? (uc/failures next-live)) (pr-str (uc/failures next-live))) + (recur next-state next-live (rest tuples)))) + (let [checked (await (ftm/finish-generated live))] + (t/is (empty? (uc/failures checked)) (pr-str (uc/failures checked))) + (ftm/uninstall!))))) + (catch :default error + (ftm/uninstall!) + (t/is false (str (ex-message error) "\nReplay: " (pr-str program) + "\nDetails: " (pr-str (ex-data error))))))) + +(t/deftest ^:async seeded-programs-replay-through-the-frontend + (doseq [seed [712 20261022 1792691]] + (await (replay (gen/generate cg/program-gen 20 seed))))) + +(t/deftest ^:async detach-undo-shrunk-regression + (await (replay {:world [2 0] :ops [[:edit 0 0 1 1] [:detach 0] [:undo] [:redo]]}))) + +(t/deftest ^:async structure-programs-replay-through-the-frontend + (doseq [seed [712 20261022]] + (await (replay (gen/generate cg/structure-program-gen 10 seed))))) + +(t/deftest ^:async nested-text-rotation-shrunk-regression + (await (replay {:world [2 0 {:text true}] + :ops [[:nest 0] [:detach 0] [:rotate 0]]}))) diff --git a/frontend/test/frontend_tests/composable_tests/interpreter.cljs b/frontend/test/frontend_tests/composable_tests/interpreter.cljs index 1e29b6618d..8ad626cb93 100644 --- a/frontend/test/frontend_tests/composable_tests/interpreter.cljs +++ b/frontend/test/frontend_tests/composable_tests/interpreter.cljs @@ -297,11 +297,11 @@ to-index)]) (instance? n/RemoveChild op) - [(dwsh/delete-shapes #{(tm/resolve-shape-id situation (:target op))})] + [(dwsh/delete-shapes #{(tm/target-shape-id situation (:target op))})] (instance? n/AddChild op) (let [{:keys [parent new-label shape-params]} op - parent (tm/resolve-shape situation parent)] + parent (tm/shape-by-id situation (tm/target-shape-id situation parent))] [(dwsh/add-shape (n/child-shape parent new-label shape-params) {:no-select? true})]) @@ -677,7 +677,7 @@ index-before (undo-index)] (if (sync-op? op) (let [situation (run-sync-op situation op)] - (when (contains? golden-kinds kind) + (when (and (not (::generated situation)) (contains? golden-kinds kind)) (observe-golden-step!)) (run-ops situation (rest ops))) (do @@ -701,7 +701,7 @@ :else (let [situation (record-op situation op)] - (when (contains? golden-kinds kind) + (when (and (not (::generated situation)) (contains? golden-kinds kind)) (observe-golden-step!)) (cond check-step? @@ -779,6 +779,24 @@ (ptk/data-event ::stop-headless-text-measurement)) (wrf/reset-pending!)) +(defn start-generated + "Install a generated world with the same watchers and undo checks as cases." + [situation] + (-> situation + (assoc ::undo-check? true ::generated true) + (uc/with-runner :frontend) + (install!) + (mark-baseline))) + +(defn run-generated-step + "Drive a resolved node through real events, settlement and undo/redo." + [situation op] + (run-ops situation [op])) + +(defn finish-generated + [situation] + (check-variant situation)) + (defn- run-variant "Set up one variant on the global store and run its ops. `setup` returns a situation (file + roles). With `undo-check?`, runs the undo/redo round diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index ad5b5e25d2..c6dd6f8c3e 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -9,6 +9,7 @@ [frontend-tests.code-gen-style-test] [frontend-tests.composable-tests.comp.sync-test] [frontend-tests.composable-tests.completion-contract-test] + [frontend-tests.composable-tests.generated-test] [frontend-tests.copy-as-svg-test] [frontend-tests.data.changes-test] [frontend-tests.data.comments-filters-test] @@ -194,6 +195,7 @@ 'frontend-tests.code-gen-style-test 'frontend-tests.composable-tests.comp.sync-test 'frontend-tests.composable-tests.completion-contract-test + 'frontend-tests.composable-tests.generated-test 'frontend-tests.copy-as-svg-test 'frontend-tests.data.changes-test 'frontend-tests.data.comments-filters-test diff --git a/plugins/apps/composable-test-suite/ci/random.test.ts b/plugins/apps/composable-test-suite/ci/random.test.ts new file mode 100644 index 0000000000..0c18e96c8d --- /dev/null +++ b/plugins/apps/composable-test-suite/ci/random.test.ts @@ -0,0 +1,40 @@ +import { strict as assert } from "node:assert"; +import { test } from "node:test"; +import { makeProgram } from "../src/composable-tests/random/program.ts"; +import { PenpotSync } from "../src/composable-tests/util/PenpotSync.ts"; + +test("random programs replay a seed exactly", () => { + assert.deepEqual(makeProgram(712, 40), makeProgram(712, 40)); + assert.notDeepEqual(makeProgram(712, 40), makeProgram(713, 40)); + assert.equal(makeProgram(712, 40).length, 40); + assert.equal(makeProgram(0, 0).length, 0); +}); + +test("propagation fails when pending work never settles", async () => { + const scope = globalThis as unknown as { penpot: unknown }; + const previous = scope.penpot; + scope.penpot = { waitForLayoutUpdate: () => new Promise(() => {}) }; + try { + await assert.rejects(PenpotSync.awaitPropagation(5), /did not settle/); + } finally { + scope.penpot = previous; + } +}); + +test("propagation waits for the completion promise", async () => { + const scope = globalThis as unknown as { penpot: unknown }; + const previous = scope.penpot; + let complete!: () => void; + scope.penpot = { waitForLayoutUpdate: () => new Promise((resolve) => (complete = resolve)) }; + try { + let settled = false; + const waiting = PenpotSync.awaitPropagation(1000).then(() => (settled = true)); + await Promise.resolve(); + assert.equal(settled, false); + complete(); + await waiting; + assert.equal(settled, true); + } finally { + scope.penpot = previous; + } +}); diff --git a/plugins/apps/composable-test-suite/package.json b/plugins/apps/composable-test-suite/package.json index 1f21539f19..3d16d9d5a7 100644 --- a/plugins/apps/composable-test-suite/package.json +++ b/plugins/apps/composable-test-suite/package.json @@ -9,7 +9,7 @@ "build": "tsc && vite build", "build:headless": "vite build --config vite.config.headless.ts", "test:ci": "pnpm run build:headless && tsx ci/run-ci.ts", - "test:unit": "tsx --test ci/static-server.test.ts", + "test:unit": "tsx --test ci/*.test.ts", "preview": "vite preview", "bootstrap": "pnpm install --ignore-workspace && pnpm run build && pnpm run start", "types:check": "tsc --noEmit", diff --git a/plugins/apps/composable-test-suite/src/composable-tests/cases.ts b/plugins/apps/composable-test-suite/src/composable-tests/cases.ts index bb80dc6341..b56ab49e25 100644 --- a/plugins/apps/composable-test-suite/src/composable-tests/cases.ts +++ b/plugins/apps/composable-test-suite/src/composable-tests/cases.ts @@ -9,6 +9,7 @@ import { createTestCaseMainChildMovedOutLeavesCopy } from "./cases/caseMainChild import { createTestCaseDetachKeepsNestedCopiesLinked } from "./cases/caseDetachKeepsNestedCopiesLinked.ts"; import { createTestCaseCopySwapSurvivesMainChanges } from "./cases/caseCopySwapSurvivesMainChanges.ts"; import { createTestCaseResetOfNestedSwapKeepsTopCopyOverrides } from "./cases/caseResetOfNestedSwapKeepsTopCopyOverrides.ts"; +import { createTestCaseRandomCompositions } from "./cases/caseRandomCompositions.ts"; /** * All composable test cases currently defined. A factory (not a constant): each @@ -27,5 +28,6 @@ export function allCases(): readonly TestCase[] { createTestCaseDetachKeepsNestedCopiesLinked(), createTestCaseCopySwapSurvivesMainChanges(), createTestCaseResetOfNestedSwapKeepsTopCopyOverrides(), + createTestCaseRandomCompositions(), ]; } diff --git a/plugins/apps/composable-test-suite/src/composable-tests/cases/caseRandomCompositions.ts b/plugins/apps/composable-test-suite/src/composable-tests/cases/caseRandomCompositions.ts new file mode 100644 index 0000000000..391707041a --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/cases/caseRandomCompositions.ts @@ -0,0 +1,12 @@ +import { TestCase } from "../test-suite/TestCase.ts"; +import { OpOneOf } from "../operations/OpOneOf.ts"; +import { OpRandomSequence } from "../operations/OpRandomSequence.ts"; + +export function createTestCaseRandomCompositions(): TestCase { + return new TestCase( + "RandomCompositions", + "Two nested components and their copies receive seeded edits, resets, duplication, detachment " + + "and transforms. Every settled step must retain authored values and follow untouched main values.", + new OpOneOf(new OpRandomSequence(712), new OpRandomSequence(20261022), new OpRandomSequence(1792691)) + ); +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/operations/OpRandomSequence.ts b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpRandomSequence.ts new file mode 100644 index 0000000000..1b651b4e3d --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/operations/OpRandomSequence.ts @@ -0,0 +1,178 @@ +import type { Board, Shape } from "@penpot/plugin-types"; +import { Operation } from "../core/Operation.ts"; +import { Situation } from "../core/Situation.ts"; +import { makeProgram, type RandomStep } from "../random/program.ts"; +import { PenpotSync } from "../util/PenpotSync.ts"; + +type Field = "fills" | "opacity"; +type Overrides = Map>>; + +function visible(field: Field, value: unknown): unknown { + if (field !== "fills") return value; + const fills = value as Shape["fills"]; + return Array.isArray(fills) ? fills.map((fill) => [fill.fillColor?.toLowerCase(), fill.fillOpacity ?? 1]) : fills; +} + +function children(shape: Shape): Shape[] { + return "children" in shape ? (shape as Board).children : []; +} + +function subtree(shape: Shape): Shape[] { + return [shape, ...children(shape).flatMap(subtree)]; +} + +/** Random edits through the real Plugin API, with an independent value model. */ +export class OpRandomSequence extends Operation { + constructor( + readonly seed: number, + readonly program: readonly RandomStep[] = makeProgram(seed, 32) + ) { + super(); + } + + async applyTo(situation: Situation): Promise { + const overrides: Overrides = new Map(); + const roots: Shape[] = []; + const leafBoard = penpot.createBoard(); + leafBoard.resize(100, 100); + leafBoard.name = `Random leaf ${this.seed}`; + const rect = penpot.createRectangle(); + rect.resize(40, 30); + rect.name = "Random child"; + rect.fills = [{ fillColor: "#ABCDEF", fillOpacity: 1 }]; + leafBoard.appendChild(rect); + rect.x = leafBoard.x + 10; + rect.y = leafBoard.y + 10; + const leaf = penpot.library.local.createComponent([leafBoard]); + const outerBoard = penpot.createBoard(); + outerBoard.resize(200, 200); + outerBoard.name = `Random outer ${this.seed}`; + outerBoard.appendChild(leaf.instance()); + const outer = penpot.library.local.createComponent([outerBoard]); + roots.push(leaf.mainInstance(), outer.mainInstance(), leaf.instance(), outer.instance(), outer.instance()); + roots.forEach((root) => situation.applyPosAdvanceX(root)); + await PenpotSync.awaitPropagation(); + + const forget = (root: Shape) => subtree(root).forEach((shape) => overrides.delete(shape.id)); + const check = () => { + for (const shape of roots.flatMap(subtree)) { + const main = shape.componentRefShape(); + if (!main) continue; + for (const field of ["fills", "opacity"] as const) { + const own = overrides.get(shape.id); + const expected = own && field in own ? own[field] : visible(field, main[field]); + const actual = visible(field, shape[field]); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error( + `Visible override rule failed for ${shape.name}.${field}: ` + + `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}` + ); + } + } + } + }; + const prefix: RandomStep[] = [ + ["copy", 0, 0, 1], + ["main", 0, 1, 1], + ["reset", 0, 0, 0], + ]; + for (const [index, tuple] of [...prefix, ...this.program].entries()) { + const [kind, selector, property, value] = tuple; + const all = roots.flatMap(subtree); + const copyHeads = all.filter((shape) => shape.isComponentHead() && shape.isComponentCopyInstance()); + let candidates: Shape[]; + switch (kind) { + case "main": + candidates = all.filter((shape) => shape.isComponentMainInstance() && !shape.componentRefShape()); + break; + case "copy": + candidates = all.filter((shape) => shape.componentRefShape()); + break; + case "detach": + candidates = copyHeads.filter( + (shape) => + !shape.parent?.isComponentCopyInstance() && + subtree(shape) + .slice(1) + .every((child) => !child.isComponentHead()) + ); + break; + case "duplicate": + candidates = roots.filter((shape) => shape.isComponentCopyInstance() && roots.length < 12); + break; + case "reset": + candidates = copyHeads; + break; + case "rotate": + case "resize": + candidates = roots.filter((shape) => shape.isComponentCopyInstance()); + break; + case "sync": + candidates = []; + break; + } + const shape = candidates.length ? candidates[selector % candidates.length] : undefined; + situation.recordApplication( + `seed ${this.seed}, step ${index}: ${JSON.stringify(tuple)} ` + + (shape ? `target ${all.indexOf(shape)}` : kind === "sync" ? "settle" : "skip: no eligible target") + ); + if (!shape && kind !== "sync") continue; + switch (kind) { + case "main": + case "copy": { + const field = property % 2 === 0 ? "fills" : "opacity"; + const next = + field === "fills" + ? [{ fillColor: ["#ABCDEF", "#FF0000", "#00FF00"][value % 3], fillOpacity: 1 }] + : [1, 0.4, 0.7][value % 3]; + if (JSON.stringify(visible(field, shape![field])) !== JSON.stringify(visible(field, next))) { + if (shape!.componentRefShape()) { + overrides.set(shape!.id, { ...overrides.get(shape!.id), [field]: visible(field, next) }); + } + if (field === "fills") shape!.fills = next as Shape["fills"]; + else shape!.opacity = next as number; + } + break; + } + case "reset": + forget(shape!); + (shape as Shape & { resetOverrides(): void }).resetOverrides(); + break; + case "detach": + forget(shape!); + shape!.detach(); + break; + case "duplicate": { + const copy = shape!.clone(); + const source = subtree(shape!); + subtree(copy).forEach((child, i) => { + const own = overrides.get(source[i].id); + if (own) overrides.set(child.id, { ...own }); + }); + roots.push(copy); + situation.applyPosAdvanceX(copy); + break; + } + case "resize": + shape!.resize(shape!.width, 80 + (value % 6) * 20); + break; + case "rotate": + shape!.rotation += 45; + break; + case "sync": + break; + } + await PenpotSync.awaitPropagation(); + try { + check(); + } catch (error) { + throw new Error(`${String(error)}\nReplay: seed ${this.seed}, program ${JSON.stringify(this.program)}`); + } + } + check(); + } + + toString(): string { + return `random component program, seed ${this.seed}`; + } +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/random/program.ts b/plugins/apps/composable-test-suite/src/composable-tests/random/program.ts new file mode 100644 index 0000000000..6cdd018c97 --- /dev/null +++ b/plugins/apps/composable-test-suite/src/composable-tests/random/program.ts @@ -0,0 +1,16 @@ +export type RandomKind = "main" | "copy" | "reset" | "duplicate" | "detach" | "resize" | "rotate" | "sync"; +export type RandomStep = readonly [RandomKind, number, number, number]; + +/** A portable PRNG. Programs, rather than API handles, form the replay data. */ +export function makeProgram(seed: number, steps: number): RandomStep[] { + let state = seed >>> 0; + const next = () => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + return state; + }; + const kinds: RandomKind[] = ["main", "copy", "reset", "duplicate", "detach", "resize", "rotate", "sync"]; + return Array.from({ length: steps }, () => { + const kind = kinds[(next() >>> 16) % kinds.length]; + return [kind, next() >>> 16, next() >>> 16, next() >>> 16] as const; + }); +} diff --git a/plugins/apps/composable-test-suite/src/composable-tests/util/PenpotSync.ts b/plugins/apps/composable-test-suite/src/composable-tests/util/PenpotSync.ts index ff4db17676..96cec31b9a 100644 --- a/plugins/apps/composable-test-suite/src/composable-tests/util/PenpotSync.ts +++ b/plugins/apps/composable-test-suite/src/composable-tests/util/PenpotSync.ts @@ -2,23 +2,28 @@ * Timing utilities for the composable tests. */ export class PenpotSync { - /** The fixed settling delay, in milliseconds, used by `awaitPropagation`. */ - private static readonly PROPAGATION_MS = 200; - /** * Waits for component-change propagation to settle. Reads can otherwise race - * ahead of the propagation a preceding edit triggers. This is a pragmatic - * fixed delay; it is intended to be replaced once the Plugin API offers an - * explicit "wait for propagation" primitive. + * ahead of the propagation a preceding edit triggers. A deadline fails the operation instead of checking a partly synced file. */ - static awaitPropagation(): Promise { - // @ts-ignore - if (penpot.waitForLayoutUpdate) { - // @ts-ignore - return penpot.waitForLayoutUpdate(); - } else { + static async awaitPropagation(timeoutMs = 10000): Promise { + const api = penpot as typeof penpot & { waitForLayoutUpdate(): Promise }; + if (!api.waitForLayoutUpdate) { throw new Error("PenpotSync.awaitPropagation: waitForLayoutUpdate is not available"); - return new Promise((resolve) => setTimeout(resolve, PenpotSync.PROPAGATION_MS)); + } + let timer: ReturnType | undefined; + try { + await Promise.race([ + api.waitForLayoutUpdate(), + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`Component propagation did not settle in ${timeoutMs}ms`)), + timeoutMs + ); + }), + ]); + } finally { + clearTimeout(timer); } } }