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