From a086fd9657eed8aed3bb3da8fbbc4e06a7b5a09b Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Wed, 28 Oct 2026 19:57:53 +0100 Subject: [PATCH] :sparkles: Generate layout item edits in component histories Add grid item justification and child margin mode, alignment, fixed/fill sizing, size limits, absolute position and stacking order. Sample optional asymmetric margins before copying. Keep existing input indexes and fixture defaults, with explicit target eligibility and independent authored groups. Check companion values, propagation, preserved overrides, reset, update main, detach and exact undo/redo. Replay the histories through the real frontend layout events and retain corrupt-result controls. Correct F239's full-sync comparison to wait for layout on both routes. The reduced add-child/absolute-child history stays settled, while the negative control still rejects a real 0.25 px absolute-child displacement. No geometry tolerance or golden data changes. Validate the final continuation on common JVM (2004 tests / 32919 assertions), common JS (1940 / 31522), frontend (1745 / 13569), backend (1793 / 9007) and exporter (16 / 40). All four modules pass lint and format. All six generated properties pass 40 programs at seed 712 in both runtimes. Golden data is unchanged. The final continuation benchmark passes with no regression flags. Lower quantile ratios against Phase 1e: main 10/100/1000 0.51/0.58/0.57, three levels 0.57, chain 0.53, library 0.57, many shapes 0.45, variant switch 0.66. Machine/JVM differences make these rough comparisons; baseline unchanged. AI-assisted-by: gpt-6 --- .../test_helpers/composable/comp/nodes.cljc | 14 +- .../common/test_helpers/composable/gen.cljc | 49 ++++++- .../test_helpers/composable/shadow.cljc | 9 +- .../logic/composable_gen_test.cljc | 134 +++++++++++++++++- .../composable_tests/generated_test.cljs | 17 ++- .../composable_tests/interpreter.cljs | 9 +- 6 files changed, 218 insertions(+), 14 deletions(-) diff --git a/common/src/app/common/test_helpers/composable/comp/nodes.cljc b/common/src/app/common/test_helpers/composable/comp/nodes.cljc index 93322d78a2..e1b12558c9 100644 --- a/common/src/app/common/test_helpers/composable/comp/nodes.cljc +++ b/common/src/app/common/test_helpers/composable/comp/nodes.cljc @@ -96,7 +96,10 @@ :fixed-scroll :constraints-h :constraints-v :layout-item-margin :layout-gap :layout-padding :layout-align-items :layout-flex-dir :layout-wrap-type :layout-justify-content :layout-align-content - :layout-gap-type :layout-padding-type}) + :layout-gap-type :layout-padding-type :layout-justify-items + :layout-item-margin-type :layout-item-align-self :layout-item-h-sizing + :layout-item-v-sizing :layout-item-min-w :layout-item-max-w + :layout-item-min-h :layout-item-max-h :layout-item-absolute :layout-item-z-index}) (defn set-property "`shape` with `property` set to `value`. Public so the frontend interpreter @@ -140,7 +143,7 @@ (def ^:private layout-container-properties #{:layout-gap :layout-padding :layout-align-items :layout-flex-dir :layout-wrap-type :layout-justify-content :layout-align-content - :layout-gap-type :layout-padding-type}) + :layout-gap-type :layout-padding-type :layout-justify-items}) (defn- with-layout-reflow [changes id] @@ -186,7 +189,7 @@ value' (applied-value* property value) layout-id (cond (and (:layout shape) (contains? layout-container-properties property)) shape-id - (and (= :layout-item-margin property) + (and (contains? ctl/layout-child-attrs property) (:layout (get-in page [:objects (:parent-id shape)]))) (:parent-id shape)) changes (cond-> (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) @@ -194,6 +197,11 @@ (fn [shape] (set-property shape property value')) (:objects page) {}) + (and (= :layout-item-absolute property) + (ctl/grid-layout? (get-in page [:objects layout-id]))) + (pcb/update-shapes [layout-id] ctl/assign-cells + {:with-objects? true :ignore-touched true}) + layout-id (-> (with-layout-reflow layout-id) (assoc ::layout-reflow? true)) diff --git a/common/src/app/common/test_helpers/composable/gen.cljc b/common/src/app/common/test_helpers/composable/gen.cljc index 912a0b6d2a..4ec308b1c7 100644 --- a/common/src/app/common/test_helpers/composable/gen.cljc +++ b/common/src/app/common/test_helpers/composable/gen.cljc @@ -87,6 +87,17 @@ (tm/apply-to (n/change-property parent :layout-padding {:p1 3 :p2 6 :p3 9 :p4 12})) (#(with-settled-layout % name))))) +(defn- with-layout-item-margins + [situation name] + (let [ids (:shapes (tm/shape-by-id situation ((n/main-head-of name) situation)))] + (-> (reduce (fn [s id] + (->> s + (tm/apply-to (n/change-property (constantly id) :layout-item-margin + {:m1 2 :m2 4 :m3 6 :m4 8})) + (tm/apply-to (n/change-property (constantly id) :layout-item-margin-type :multiple)))) + situation ids) + (with-settled-layout name)))) + (defn- with-outer-layout [situation name layout] (if-not layout @@ -201,6 +212,8 @@ (with-layout name (when (zero? index) (:layout opts))) (cond-> (and (zero? index) (:layout opts) (:layout-children opts)) (with-layout-children name)) + (cond-> (and (zero? index) (:layout opts) (:layout-item-margins opts)) + (with-layout-item-margins name)) (as-> s (reduce #(tm/apply-to %2 %1) s [(n/make-nested-component name depth)])) (with-outer-layout name (when (and (zero? index) (pos? depth) (:layout-copies opts)) (:layout opts))) @@ -432,7 +445,18 @@ [:layout-justify-content [:start :space-between :center :end :space-around :space-evenly]] [:layout-align-content [:stretch :center :end :start :space-between :space-around :space-evenly]] [:layout-gap-type [:multiple :simple]] - [:layout-padding-type [:simple :multiple]]]) + [:layout-padding-type [:simple :multiple]] + [:layout-justify-items [:start :center :end :stretch]] + [:layout-item-margin-type [:simple :multiple]] + [:layout-item-align-self [:start :center :end :stretch]] + [:layout-item-h-sizing [:fix :fill]] + [:layout-item-v-sizing [:fix :fill]] + [:layout-item-min-w [0 24 64]] + [:layout-item-max-w [256 96 64]] + [:layout-item-min-h [0 24 64]] + [:layout-item-max-h [256 96 64]] + [:layout-item-absolute [false true]] + [:layout-item-z-index [0 2 -1]]]) (def text-style-edits "All twelve style units, independent of the production text group table." @@ -480,7 +504,14 @@ :layout-gap-type :layout-padding-type :layout-justify-content :layout-align-content) (some? (:layout shape)) (:layout-flex-dir :layout-wrap-type) (= :flex (:layout shape)) - :layout-item-margin (some? (:layout (tm/shape-by-id situation (:parent-id shape)))) + :layout-justify-items (= :grid (:layout shape)) + :layout-item-align-self (= :flex (:layout (tm/shape-by-id situation (:parent-id shape)))) + (:layout-item-margin :layout-item-margin-type + :layout-item-h-sizing :layout-item-v-sizing + :layout-item-min-w :layout-item-max-w + :layout-item-min-h :layout-item-max-h + :layout-item-absolute :layout-item-z-index) + (some? (:layout (tm/shape-by-id situation (:parent-id shape)))) true)))) (and (= kind :edit) (= 2 (mod (or property 0) 3))) (remove (fn [{:keys [shape]}] @@ -637,7 +668,7 @@ (shadow/parent-orientation (all-objects situation) (all-objects settled)) (shadow/settle (all-objects settled))) all (into (set (synchronizable-components situation)) (r/pending-components situation)) - full-before (:situation (cf/sync-world situation all)) + full-before (r/settle situation all) full (:situation (cf/sync-world settled all)) opts {:free-head-placement false} settled-design (oracle/canonical settled opts) @@ -1023,8 +1054,9 @@ depth (gen/choose 0 2) layout (gen/elements [nil :flex :grid]) layout-children gen/boolean + layout-item-margins gen/boolean ops (operation-gen structure-kinds)] - {:world [components depth {:text true :text-styles true :layout layout :layout-children layout-children}] + {:world [components depth {:text true :text-styles true :layout layout :layout-children layout-children :layout-item-margins layout-item-margins}] :ops ops})) (def swap-kinds (into structure-kinds [:swap :switch])) @@ -1034,10 +1066,11 @@ differ? gen/boolean layout (gen/elements [nil :flex :grid]) layout-children gen/boolean + layout-item-margins gen/boolean ops (operation-gen swap-kinds)] {:world [components 2 {:variants true :variant-diff differ? :rich-variants true :text true :text-styles true :layout layout :layout-copies true - :layout-children layout-children}] + :layout-children layout-children :layout-item-margins layout-item-margins}] :ops ops})) (def library-kinds @@ -1047,9 +1080,10 @@ (gen/let [depth (gen/choose 0 2) layout (gen/elements [nil :flex :grid]) layout-children gen/boolean + layout-item-margins gen/boolean ops (operation-gen library-kinds)] {:world [2 depth {:library true :variants true :rich-variants true :text true :text-styles true - :layout layout :layout-copies true :layout-children layout-children}] + :layout layout :layout-copies true :layout-children layout-children :layout-item-margins layout-item-margins}] :ops ops})) (def stale-library-kinds @@ -1060,10 +1094,11 @@ library-depth (gen/choose 1 2) layout (gen/elements [nil :flex :grid]) layout-children gen/boolean + layout-item-margins gen/boolean ops (operation-gen stale-library-kinds)] {:world [2 depth {:library true :stale-libraries true :library-variants true :library-text true :library-depth library-depth :variants true :rich-variants true :text true :text-styles true - :layout layout :layout-copies true :layout-children layout-children}] + :layout layout :layout-copies true :layout-children layout-children :layout-item-margins layout-item-margins}] :ops ops})) (def dependency-kinds diff --git a/common/src/app/common/test_helpers/composable/shadow.cljc b/common/src/app/common/test_helpers/composable/shadow.cljc index 53385effff..cd9ea53862 100644 --- a/common/src/app/common/test_helpers/composable/shadow.cljc +++ b/common/src/app/common/test_helpers/composable/shadow.cljc @@ -28,7 +28,14 @@ :layout-padding :layout-padding :layout-align-items :layout-align-items :layout-flex-dir :layout-flex-dir :layout-wrap-type :layout-wrap-type :layout-justify-content :layout-justify-content :layout-align-content :layout-align-content - :layout-gap-type :layout-gap :layout-padding-type :layout-padding}) + :layout-gap-type :layout-gap :layout-padding-type :layout-padding + :layout-justify-items :layout-justify-items + :layout-item-margin-type :layout-item-margin + :layout-item-align-self :layout-item-align-self + :layout-item-h-sizing :layout-item-h-sizing :layout-item-v-sizing :layout-item-v-sizing + :layout-item-min-w :layout-item-min-w :layout-item-max-w :layout-item-max-w + :layout-item-min-h :layout-item-min-h :layout-item-max-h :layout-item-max-h + :layout-item-absolute :layout-item-absolute :layout-item-z-index :layout-item-z-index}) (defn attribute-applicable? "The clipping and background-export controls belong to frames." diff --git a/common/test/common_tests/logic/composable_gen_test.cljc b/common/test/common_tests/logic/composable_gen_test.cljc index 8cd59a3fc3..1df835ffaf 100644 --- a/common/test/common_tests/logic/composable_gen_test.cljc +++ b/common/test/common_tests/logic/composable_gen_test.cljc @@ -184,7 +184,51 @@ [22 :layout-justify-content :layout-justify-content #{:flex :grid}] [23 :layout-align-content :layout-align-content #{:flex :grid}] [24 :layout-gap-type :layout-gap #{:flex :grid}] - [25 :layout-padding-type :layout-padding #{:flex :grid}]]) + [25 :layout-padding-type :layout-padding #{:flex :grid}] + [26 :layout-justify-items :layout-justify-items #{:grid}]]) + +(def layout-item-cases + [[27 :layout-item-margin-type :layout-item-margin #{:flex :grid}] + [28 :layout-item-align-self :layout-item-align-self #{:flex}] + [29 :layout-item-h-sizing :layout-item-h-sizing #{:flex :grid}] + [30 :layout-item-v-sizing :layout-item-v-sizing #{:flex :grid}] + [31 :layout-item-min-w :layout-item-min-w #{:flex :grid}] + [32 :layout-item-max-w :layout-item-max-w #{:flex :grid}] + [33 :layout-item-min-h :layout-item-min-h #{:flex :grid}] + [34 :layout-item-max-h :layout-item-max-h #{:flex :grid}] + [35 :layout-item-absolute :layout-item-absolute #{:flex :grid}] + [36 :layout-item-z-index :layout-item-z-index #{:flex :grid}]]) + +(t/deftest remaining-layout-inputs-resolve-only-in-their-own-context + (doseq [layout [nil :flex :grid] + [property attr _ layouts] (into [[26 :layout-justify-items :layout-justify-items #{:grid}]] + layout-item-cases)] + (let [situation (cg/world [1 0 {:layout layout :layout-children true}]) + resolved (cg/resolve-op situation {} [:style 0 property 1 1]) + parent (tm/shape-by-id situation (get-in resolved [:shape :parent-id]))] + (t/is (= attr (:property resolved))) + (if (contains? layouts layout) + (do + (t/is (some? (:op resolved))) + (t/is (not= (get (:shape resolved) attr) (:value resolved))) + (t/is (= layout (if (= property 26) (get-in resolved [:shape :layout]) (:layout parent))))) + (do + (t/is (nil? (:op resolved))) + (t/is (= :skip (get-in resolved [:trace :status])))))))) + +(t/deftest asymmetric-item-margins-are-settled-before-copying + (doseq [layout [:flex :grid]] + (let [situation (cg/world [1 0 {:layout layout :layout-children true :layout-item-margins true}]) + main-id ((n/main-rect-of "random-0") situation) + images (filter #(or (= main-id (get-in % [:shape :id])) + (= main-id (get-in % [:shape :shape-ref]))) (cg/tree situation))] + (t/is (= 3 (count images))) + (doseq [{:keys [shape]} images] + (t/is (= {:m1 2 :m2 4 :m3 6 :m4 8} (:layout-item-margin shape))) + (t/is (= :multiple (:layout-item-margin-type shape)))) + (t/is (= :valid (try (oracle/check situation {}) :valid + (catch #?(:clj Exception :cljs js/Error) error + (select-keys (ex-data error) [:invariant :group])))))))) (t/deftest multi-child-layout-fixtures-start-settled-before-user-actions (doseq [layout [:flex :grid] depth [0 1]] @@ -309,6 +353,94 @@ (t/is (pos? (get-in result [:coverage :random kind :changed] 0)) (str kind " changes " property " in " layout)))))) +(t/deftest adding-a-flex-child-before-making-a-copy-child-absolute-stays-settled + (t/is (map? (cg/run {:world [1 0 {:layout :flex :layout-children true}] + :ops [[:add-child 0] [:style 2 35 1 1]]})))) + +(t/deftest full-settlement-comparison-still-rejects-absolute-child-drift + (let [initial (-> (cg/initial-state [1 0 {:layout :flex :layout-children true}]) + (cg/step [:add-child 0]) cg/settle-state) + id (get-in (cg/resolve-op (:situation initial) {} [:style 2 35 1 1]) [:shape :id]) + edited (cg/step initial [:style 2 35 1 1]) + settled (assoc edited :situation (r/settle (:situation edited)) :checkpoint nil) + settle r/settle + corrupt-full (fn [situation component-ids] + (let [result (settle situation component-ids) + file (tm/file result) + path [:data :pages-index (:id (thf/current-page file)) :objects id]] + (tm/with-file result (update-in file path gsh/move (gpt/point 0.25 0))))) + invariant (with-redefs [r/settle (fn + ([situation] (settle situation (r/pending-components situation))) + ([situation ids] (corrupt-full situation ids)))] + (try (cg/settle-state settled) nil + (catch #?(:clj Exception :cljs js/Error) error + (:invariant (ex-data error)))))] + (t/is (= :I6 invariant)))) + +(t/deftest layout-item-overrides-retain-values-and-companions + (doseq [[property attr group layouts] layout-item-cases + layout (sort layouts)] + (t/testing (str attr " in " layout) + (let [initial (cg/initial-state [1 0 {:layout layout :layout-children true :layout-item-margins true}]) + situation (:situation initial) + authored (if (= property 27) 0 1) + other (if (= property 27) 1 0) + resolved (cg/resolve-op situation {} [:style 0 property authored 1]) + shape (:shape resolved) + id (:id shape) + expected (if (= property 27) + {:layout-item-margin-type :simple :layout-item-margin {:m1 2 :m2 4 :m3 6 :m4 8}} + {attr (:value resolved)}) + edited (-> initial (cg/step [:style 0 property authored 1]) cg/settle-state) + after (:situation edited) + file (tm/file after) + path [:data :pages-index (:id (thf/current-page file)) :objects id] + invariant (fn [file] + (try (oracle/check (tm/with-file after file) (:model edited)) nil + (catch #?(:clj Exception :cljs js/Error) error + (:invariant (ex-data error))))) + reverted (cond-> (if (contains? shape attr) + (assoc-in file (conj path attr) (get shape attr)) + (update-in file path dissoc attr)) + (and (= property 35) (= layout :grid)) + (assoc-in (conj (pop path) (:parent-id shape) :layout-grid-cells) + (:layout-grid-cells (tm/shape-by-id situation (:parent-id shape))))) + main-index (first (keep (fn [index] + (when (= (:shape-ref shape) + (get-in (cg/resolve-op situation {} [:style index property other 0]) + [:shape :id])) index)) + (range (count (cg/tree situation))))) + updated (-> edited (cg/step [:style main-index property authored 0]) cg/settle-state + (cg/step [:style main-index property other 0]) cg/settle-state) + value (:value (cg/resolve-op situation {} [:style main-index property other 0])) + copies (filter #(= (:shape-ref shape) (get-in % [:shape :shape-ref])) (cg/tree situation))] + (t/is (= expected (get-in edited [:model id group]))) + (t/is (= #{group} (:touched (tm/shape-by-id after id)))) + (t/is (= (:layout-item-margin shape) (:layout-item-margin (tm/shape-by-id after id)))) + (t/is (= :I9 (invariant reverted))) + (t/is (= :I3 (invariant (update-in file (conj path :touched) disj group)))) + (t/is (some? main-index)) + (t/is (= 2 (count copies))) + (doseq [{copy :shape} copies] + (let [actual (tm/shape-by-id (:situation updated) (:id copy))] + (t/is (= (if (= id (:id copy)) (:value resolved) value) (get actual attr))) + (t/is (= (if (= id (:id copy)) #{group} #{}) (set (:touched actual)))))) + (t/is (= expected (get-in updated [:model id group]))))))) + +(t/deftest layout-item-histories-survive-component-operations + (doseq [[property _ _ layouts] layout-item-cases + layout (sort layouts)] + (let [authored (if (= property 27) 0 1) + other (if (= property 27) 1 0) + result (cg/run {:world [1 0 {:layout layout :layout-children true :layout-item-margins true}] + :ops [[:style 0 property authored 1] [:style 0 property authored 0] + [:style 0 property other 0] [:undo] [:redo] [:update-main 0] + [:style 0 property other 1] [:reset 0] + [:style 0 property other 1] [:detach 0] [:undo] [:redo]]})] + (doseq [kind [:style :update-main :reset :detach]] + (t/is (pos? (get-in result [:coverage :random kind :changed] 0)) + (str kind " changes " property " in " layout)))))) + (t/deftest text-style-edits-cover-every-style-unit (doseq [[unit group] (map-indexed vector [:text-content-family :text-content-weight :text-content-size diff --git a/frontend/test/frontend_tests/composable_tests/generated_test.cljs b/frontend/test/frontend_tests/composable_tests/generated_test.cljs index 8a889422c6..f60cbbc709 100644 --- a/frontend/test/frontend_tests/composable_tests/generated_test.cljs +++ b/frontend/test/frontend_tests/composable_tests/generated_test.cljs @@ -90,7 +90,7 @@ (t/deftest ^:async layout-container-histories-survive-component-operations (doseq [[property layouts] [[20 [:flex]] [21 [:flex]] [22 [:flex :grid]] [23 [:flex :grid]] - [24 [:flex :grid]] [25 [:flex :grid]]] + [24 [:flex :grid]] [25 [:flex :grid]] [26 [:grid]]] layout layouts] (await (replay {:world [1 0 {:layout layout :layout-children true}] :ops [[:style 0 property 1 1] [:style 0 property 1 0] @@ -98,6 +98,21 @@ [:style 0 property 0 1] [:reset 0] [:style 0 property 0 1] [:detach 0] [:undo] [:redo]]})))) +(t/deftest ^:async layout-item-histories-survive-component-operations + (doseq [property (range 27 37) + layout (if (= property 28) [:flex] [:flex :grid])] + (let [authored (if (= property 27) 0 1) + other (if (= property 27) 1 0)] + (await (replay {:world [1 0 {:layout layout :layout-children true :layout-item-margins true}] + :ops [[:style 0 property authored 1] [:style 0 property authored 0] + [:style 0 property other 0] [:undo] [:redo] [:update-main 0] + [:style 0 property other 1] [:reset 0] + [:style 0 property other 1] [:detach 0] [:undo] [:redo]]}))))) + +(t/deftest ^:async adding-a-flex-child-before-making-a-copy-child-absolute-stays-settled + (await (replay {:world [1 0 {:layout :flex :layout-children true}] + :ops [[:add-child 0] [:style 2 35 1 1] [:undo] [:redo]]}))) + (t/deftest ^:async nesting-a-copy-alongside-a-hidden-layout-child-stays-settled (await (replay {:world [3 0 {:text true :text-styles true :layout :flex}] :ops [[:delete 9 9 1 2] [:text 5 9 1 3] [:style 3 1 1] diff --git a/frontend/test/frontend_tests/composable_tests/interpreter.cljs b/frontend/test/frontend_tests/composable_tests/interpreter.cljs index 46b91614fc..01707c10de 100644 --- a/frontend/test/frontend_tests/composable_tests/interpreter.cljs +++ b/frontend/test/frontend_tests/composable_tests/interpreter.cljs @@ -150,13 +150,20 @@ [(dwsl/update-layout-child [id] (select-keys shape [:layout-item-margin :layout-item-margin-type]))]) + (:layout-item-margin-type :layout-item-align-self + :layout-item-h-sizing :layout-item-v-sizing + :layout-item-min-w :layout-item-max-w + :layout-item-min-h :layout-item-max-h + :layout-item-absolute :layout-item-z-index) + [(dwsl/update-layout-child [id] {property value})] + ;; the layers panel's rename, which renames a main's component too :name [(dw/end-rename-shape id value)] ;; Container controls use the sidebar transaction and reflow event. (:layout-flex-dir :layout-wrap-type :layout-justify-content - :layout-align-content :layout-gap-type :layout-padding-type) + :layout-align-content :layout-gap-type :layout-padding-type :layout-justify-items) [(dwsl/update-layout [id] {property value})] [(dwsh/update-shapes #{id} (fn [shape] (n/set-property shape property value)))]))