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 9d47e02b80..93322d78a2 100644 --- a/common/src/app/common/test_helpers/composable/comp/nodes.cljc +++ b/common/src/app/common/test_helpers/composable/comp/nodes.cljc @@ -94,7 +94,9 @@ #{:strokes :shadow :blur :background-blur :hidden :blocked :r1 :r2 :r3 :r4 :blend-mode :hide-fill-on-export :show-content :fixed-scroll :constraints-h :constraints-v :layout-item-margin - :layout-gap :layout-padding :layout-align-items}) + :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}) (defn set-property "`shape` with `property` set to `value`. Public so the frontend interpreter @@ -135,27 +137,71 @@ [property value] (if (= :name property) (cpn/clean-path value) value)) +(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}) + +(defn- with-layout-reflow + [changes id] + (let [objects (pcb/get-objects changes) + tree (gm/set-objects-modifiers {id {:modifiers (ctm/reflow-modifiers)}} objects) + transform (clp/modifiers-transform tree)] + (cls/generate-update-shapes changes (keys tree) transform objects + {:ignore-touched true :attrs clp/transform-attrs}))) + +(defn layout-reflow? + "Whether a property operation requires layout after its component sync." + [changes] + (::layout-reflow? changes)) + +(defn get-layout-reflow-changes + "Derived layout changes on the current file's pages, after copy inputs sync." + [situation] + (reduce (fn [changes [_ page]] + (let [ids (into [] (keep #(when (:layout %) (:id %))) (vals (:objects page)))] + (if (empty? ids) + changes + (let [tree (gm/set-objects-modifiers + (into {} (map #(vector % {:modifiers (ctm/reflow-modifiers)})) ids) + (:objects page)) + next (cls/generate-update-shapes + (pcb/empty-changes nil (:id page)) (keys tree) + (clp/modifiers-transform tree) (:objects page) + {:ignore-touched true :attrs clp/transform-attrs})] + (pcb/concat-changes changes next))))) + (pcb/empty-changes) + (get-in (tm/file situation) [:data :pages-index]))) + (defrecord ChangeProperty [target property value] IComponentOperation (op-kind [_] :user) tm/IOperation (apply-to [this situation] - (let [the-file (tm/file situation) - shape-id (tm/target-shape-id situation target) - page (thf/current-page the-file) - shape (get-in page [:objects shape-id]) - value' (applied-value* property value) - changes (cond-> (cls/generate-update-shapes - (pcb/empty-changes nil (:id page)) - #{shape-id} - (fn [shape] (set-property shape property value')) - (:objects page) - {}) - ;; renaming a main root renames its component too - (and (= :name property) (ctk/main-instance? shape)) - (cll/generate-rename-component (:component-id shape) value' (:data the-file))) - file' (thf/apply-changes the-file changes)] + (let [the-file (tm/file situation) + shape-id (tm/target-shape-id situation target) + page (thf/current-page the-file) + shape (get-in page [:objects shape-id]) + value' (applied-value* property value) + layout-id (cond + (and (:layout shape) (contains? layout-container-properties property)) shape-id + (and (= :layout-item-margin property) + (:layout (get-in page [:objects (:parent-id shape)]))) (:parent-id shape)) + changes (cond-> (cls/generate-update-shapes + (pcb/empty-changes nil (:id page)) + #{shape-id} + (fn [shape] (set-property shape property value')) + (:objects page) + {}) + layout-id + (-> (with-layout-reflow layout-id) + (assoc ::layout-reflow? true)) + + ;; renaming a main root renames its component too + (and (= :name property) (ctk/main-instance? shape)) + (cll/generate-rename-component (:component-id shape) value' (:data the-file))) + file' (thf/apply-changes the-file changes)] (-> situation (tm/with-file file') (record-changes changes) diff --git a/common/src/app/common/test_helpers/composable/comp/runner.cljc b/common/src/app/common/test_helpers/composable/comp/runner.cljc index aeaea06022..07edf27c51 100644 --- a/common/src/app/common/test_helpers/composable/comp/runner.cljc +++ b/common/src/app/common/test_helpers/composable/comp/runner.cljc @@ -78,10 +78,33 @@ :syncs (if (seq (:redo-changes changes)) [changes] []) :passes passes})) +(defn- sync-with-layout + "Layout property edits also reflow their copies after sync. Keep each + derived pass so the primary action owns its full inverse." + [situation component-ids reflow?] + (loop [situation situation component-ids component-ids syncs [] passes 0] + (let [synced (cf/sync-world situation component-ids) + before (:situation synced) + syncs (into syncs (:syncs synced)) + changes (when reflow? (n/get-layout-reflow-changes before))] + (if (empty? (:redo-changes changes)) + {:situation before :syncs syncs} + (let [after (cf/apply-changes before changes)] + ;; Grid order writers may record an already-correct order. + (if (= (:data (tm/file before)) (:data (tm/file after))) + {:situation before :syncs syncs} + (do + (when (>= passes 20) + (throw (ex-info "Layout did not settle after component sync" + {:type ::layout-not-settled}))) + (recur after (cf/changed-components before after changes) + (conj syncs changes) (inc passes))))))))) + (defn- sync-file [situation before changes] (let [{:keys [situation syncs]} - (cf/sync-world situation (cf/changed-components before situation changes))] + (sync-with-layout situation (cf/changed-components before situation changes) + (n/layout-reflow? changes))] [(validate-settled! situation) syncs])) (defn- push-undo-group @@ -295,6 +318,9 @@ (sync-file situation before-situation changes) [situation []])] (cond-> (push-undo-group situation (into [changes] syncs)) + (and (not sync?) (n/layout-reflow? changes)) + (assoc ::pending-layout? true) + (not sync?) (update ::pending-components (fnil into #{}) (cf/changed-components before-situation situation changes))))) @@ -382,9 +408,11 @@ "Sync selected pending components in the latest user's undo group." ([situation] (settle situation (pending-components situation))) ([situation component-ids] - (let [{:keys [situation syncs]} (cf/sync-world situation component-ids) + (let [{:keys [situation syncs]} (sync-with-layout situation component-ids (::pending-layout? situation)) stack (get situation ::undo-stack [])] - (cond-> (update situation ::pending-components #(apply disj (or % #{}) component-ids)) + (cond-> (-> situation + (dissoc ::pending-layout?) + (update ::pending-components #(apply disj (or % #{}) component-ids))) (and (seq syncs) (seq stack)) (assoc ::undo-stack (conj (pop stack) (into (peek stack) syncs))))))) diff --git a/common/src/app/common/test_helpers/composable/gen.cljc b/common/src/app/common/test_helpers/composable/gen.cljc index 95486a9f27..912a0b6d2a 100644 --- a/common/src/app/common/test_helpers/composable/gen.cljc +++ b/common/src/app/common/test_helpers/composable/gen.cljc @@ -10,10 +10,12 @@ Traces contain tree paths, never random UUIDs, and can be replayed." (:refer-clojure :exclude [flush]) (:require + [app.common.files.changes-builder :as pcb] [app.common.files.helpers :as cfh] [app.common.geom.modifiers :as gm] [app.common.geom.shapes :as gsh] [app.common.logic.duplicate :as cld] + [app.common.logic.placement :as clp] [app.common.logic.shapes :as cls] [app.common.logic.variants :as clv] [app.common.test-helpers.components :as thc] @@ -60,6 +62,31 @@ file (cond-> file (= layout :grid) (tho/place-in-grid-columns root 2 {1 child}))] (tm/with-file situation file)))) +(defn- with-settled-layout + [situation name] + (let [file (tm/file situation) + page (thf/current-page file) + objects (:objects page) + id ((n/main-head-of name) situation) + tree (gm/set-objects-modifiers {id {:modifiers (ctm/reflow-modifiers)}} objects) + transform (clp/modifiers-transform tree) + changes (cls/generate-update-shapes + (pcb/empty-changes nil (:id page)) (keys tree) transform objects + {:ignore-touched true :attrs clp/transform-attrs})] + (tm/with-file situation (thf/apply-changes file changes)))) + +(defn- with-layout-children + [situation name] + (let [parent (n/main-head-of name)] + (->> situation + (tm/apply-to (n/add-child parent (keyword (str name "-layout-item-1")) + :width 60 :height 30 :x 60 :y 0)) + (tm/apply-to (n/add-child parent (keyword (str name "-layout-item-2")) + :width 60 :height 30 :x 120 :y 0)) + (tm/apply-to (n/change-property parent :layout-gap {:row-gap 5 :column-gap 9})) + (tm/apply-to (n/change-property parent :layout-padding {:p1 3 :p2 6 :p3 9 :p4 12})) + (#(with-settled-layout % name))))) + (defn- with-outer-layout [situation name layout] (if-not layout @@ -172,9 +199,14 @@ (let [name (str "random-" index)] (-> (tm/apply-to (n/create-component name setup/labeled-fill :size 100) s) (with-layout name (when (zero? index) (:layout opts))) + (cond-> (and (zero? index) (:layout opts) (:layout-children opts)) + (with-layout-children name)) (as-> s (reduce #(tm/apply-to %2 %1) s [(n/make-nested-component name depth)])) - (with-outer-layout name (when (and (zero? index) (:layout-copies opts)) (:layout opts))) + (with-outer-layout name (when (and (zero? index) (pos? depth) (:layout-copies opts)) (:layout opts))) + (cond-> (and (zero? index) (pos? depth) (:layout opts) + (:layout-copies opts) (:layout-children opts)) + (with-settled-layout name)) (as-> s (reduce #(tm/apply-to %2 %1) s [(n/instantiate-copy name) (n/instantiate-copy name)]))))) (let [s (setup/empty-situation) s (if (:variants opts) @@ -394,7 +426,13 @@ :type :background-blur :value 16 :hidden false}]] [:r2 [0 8 16]] [:r3 [0 8 16]] - [:r4 [0 8 16]]]) + [:r4 [0 8 16]] + [:layout-flex-dir [:row :column :row-reverse :column-reverse]] + [:layout-wrap-type [:nowrap :wrap]] + [: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]]]) (def text-style-edits "All twelve style units, independent of the production text group table." @@ -438,7 +476,10 @@ (filter (fn [{:keys [shape]}] (and (shadow/attribute-applicable? shape style-property) (case style-property - (:layout-gap :layout-padding :layout-align-items) (some? (:layout shape)) + (:layout-gap :layout-padding :layout-align-items + :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)))) true)))) (and (= kind :edit) (= 2 (mod (or property 0) 3))) @@ -981,8 +1022,9 @@ (gen/let [components (gen/choose 2 3) depth (gen/choose 0 2) layout (gen/elements [nil :flex :grid]) + layout-children gen/boolean ops (operation-gen structure-kinds)] - {:world [components depth {:text true :text-styles true :layout layout}] + {:world [components depth {:text true :text-styles true :layout layout :layout-children layout-children}] :ops ops})) (def swap-kinds (into structure-kinds [:swap :switch])) @@ -991,9 +1033,11 @@ (gen/let [components (gen/choose 2 3) differ? gen/boolean layout (gen/elements [nil :flex :grid]) + layout-children 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}] + :text true :text-styles true :layout layout :layout-copies true + :layout-children layout-children}] :ops ops})) (def library-kinds @@ -1002,9 +1046,10 @@ (def library-program-gen (gen/let [depth (gen/choose 0 2) layout (gen/elements [nil :flex :grid]) + layout-children 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 layout :layout-copies true :layout-children layout-children}] :ops ops})) (def stale-library-kinds @@ -1014,10 +1059,11 @@ (gen/let [depth (gen/choose 0 2) library-depth (gen/choose 1 2) layout (gen/elements [nil :flex :grid]) + layout-children 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 layout :layout-copies true :layout-children layout-children}] :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 fc0ec16218..53385effff 100644 --- a/common/src/app/common/test_helpers/composable/shadow.cljc +++ b/common/src/app/common/test_helpers/composable/shadow.cljc @@ -25,7 +25,10 @@ :show-content :show-content :fixed-scroll :constraints-group :constraints-h :constraints-group :constraints-v :constraints-group :layout-item-margin :layout-item-margin :layout-gap :layout-gap - :layout-padding :layout-padding :layout-align-items :layout-align-items}) + :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}) (defn attribute-applicable? "The clipping and background-export controls belong to frames." diff --git a/common/src/app/common/test_helpers/texts.cljc b/common/src/app/common/test_helpers/texts.cljc index 06fcfeeb71..bcf54c4fe7 100644 --- a/common/src/app/common/test_helpers/texts.cljc +++ b/common/src/app/common/test_helpers/texts.cljc @@ -474,7 +474,12 @@ (defn- unit-value [node attrs] - (let [v (select-keys node attrs)] + (let [v (reduce (fn [value attr] + (if (and (#{:typography-ref-id :typography-ref-file} attr) + (nil? (get value attr))) + (dissoc value attr) + value)) + (select-keys node attrs) attrs)] ;; a default the editors leave out is the same as the default written (cond-> v (and (contains? (set attrs) :fills) (nil? (:fills v))) (assoc :fills nil)))) diff --git a/common/test/common_tests/logic/composable_gen_test.cljc b/common/test/common_tests/logic/composable_gen_test.cljc index 5018c66e93..8cd59a3fc3 100644 --- a/common/test/common_tests/logic/composable_gen_test.cljc +++ b/common/test/common_tests/logic/composable_gen_test.cljc @@ -153,6 +153,162 @@ [:style 0 property 1 1] [:detach 0] [:undo] [:redo]]}))))) +(t/deftest generated-layout-container-edits-have-distinct-inputs + (let [situation (cg/world [1 0 {:layout :flex}])] + (doseq [[index attr] [[20 :layout-flex-dir] [21 :layout-wrap-type] + [22 :layout-justify-content] [23 :layout-align-content] + [24 :layout-gap-type] [25 :layout-padding-type]]] + (let [resolved (cg/resolve-op situation {} [:style 0 index 1 1])] + (t/is (= attr (:property resolved))) + (t/is (some? (:op resolved))) + (t/is (not= (get (:shape resolved) attr) (:value resolved))))))) + +(t/deftest layout-container-fixture-has-several-children-before-copying + (doseq [layout [:flex :grid]] + (let [situation (cg/world [1 0 {:layout layout :layout-children true}]) + main-id ((n/main-head-of "random-0") situation) + entries (cg/tree situation) + heads (filter #(or (= main-id (get-in % [:shape :id])) + (= main-id (get-in % [:shape :shape-ref]))) entries) + ids (map (comp :id :shape) entries)] + (t/is (= 3 (count heads))) + (t/is (= (count ids) (count (set ids)))) + (doseq [{:keys [shape]} heads] + (t/is (= 3 (count (:shapes shape)))) + (t/is (= {:row-gap 5 :column-gap 9} (:layout-gap shape))) + (t/is (= {:p1 3 :p2 6 :p3 9 :p4 12} (:layout-padding shape))))))) + +(def layout-container-cases + [[20 :layout-flex-dir :layout-flex-dir #{:flex}] + [21 :layout-wrap-type :layout-wrap-type #{:flex}] + [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}]]) + +(t/deftest multi-child-layout-fixtures-start-settled-before-user-actions + (doseq [layout [:flex :grid] depth [0 1]] + (let [situation (cg/world [2 depth {:layout layout :layout-copies true :layout-children true}]) + main (ths/get-shape (tm/file situation) :sync-random-0-head)] + (t/is (= {:row-gap 5 :column-gap 9} (:layout-gap main))) + (t/is (= {:p1 3 :p2 6 :p3 9 :p4 12} (:layout-padding main))) + (when (= layout :grid) + (let [assigned (mapcat :shapes (vals (:layout-grid-cells main)))] + (t/is (= (set (:shapes main)) (set assigned))) + (t/is (= (count (:shapes main)) (count assigned))))) + (t/is (= :valid (try (oracle/check situation {}) :valid + (catch #?(:clj Exception :cljs js/Error) error + (select-keys (ex-data error) [:invariant :group])))))))) + +(t/deftest moving-a-flow-layout-child-does-not-author-initial-reflow + (let [result (cg/run {:world [2 0 {:text true :text-styles true :layout :flex :layout-children true}] + :ops [[:transform 0 0 0 2]]})] + (t/is (empty? (:model result))) + (t/is (every? #(empty? (get-in % [:shape :touched])) (cg/tree (:situation result)))))) + +(t/deftest changing-source-margin-settles-copies-before-moving-a-flow-child + (let [result (cg/run {:world [2 0 {:text true :text-styles true :layout :flex :layout-children true}] + :ops [[:style 0 10 1] [:transform 0 0 0 2]]})] + (t/is (every? #(not (contains? % :geometry-group)) (vals (:model result)))))) + +(t/deftest justifying-a-single-flex-child-keeps-layout-finite + (t/is (map? (cg/run {:world [2 0 {:library true :variants true :rich-variants true + :text true :text-styles true :layout :flex :layout-copies true}] + :ops [[:style 0 22 1]]})))) + +(t/deftest editing-library-copy-text-does-not-author-an-empty-typography + (let [result (cg/run {:world [2 0 {:variants true :text-styles true :library-variants true + :stale-libraries true :library-depth 1 :library true + :rich-variants true :library-text true :layout-copies true :text true}] + :ops [[:text-style 14 0 0 0]]})] + (t/is (every? #(not (contains? % :text-content-typography)) (vals (:model result)))))) + +(t/deftest layout-property-reflow-joins-the-primary-undo-group + (let [initial (cg/world [1 0 {:layout :flex :layout-children true}]) + id (thi/id :sync-random-0-copyhead-0) + ids (:shapes (tm/shape-by-id initial id)) + points (fn [situation] (mapv #(:points (tm/shape-by-id situation %)) ids)) + changed (r/step initial (n/change-property :sync-random-0-copyhead-0 :layout-gap + {:row-gap 20 :column-gap 25})) + undone (r/step changed (n/undo)) + redone (r/step undone (n/redo))] + (t/is (not= (points initial) (points changed))) + (t/is (= #{:layout-gap} (:touched (tm/shape-by-id changed id)))) + (t/is (every? #(empty? (:touched (tm/shape-by-id changed %))) ids)) + (t/is (= (oracle/snapshot initial) (oracle/snapshot undone))) + (t/is (= (oracle/snapshot changed) (oracle/snapshot redone))))) + +(t/deftest layout-container-edits-refuse-inapplicable-targets + (doseq [layout [nil :flex :grid] scope [0 1 2]] + (let [situation (cg/world [2 1 {:layout layout :layout-copies true :library true :text true}])] + (doseq [[property attr _ layouts] layout-container-cases] + (let [resolved (cg/resolve-op situation {} [:style 0 property 1 scope])] + (t/is (= attr (:property resolved))) + (if (and (contains? layouts layout) (not= scope 2)) + (do + (t/is (some? (:op resolved))) + (t/is (= :frame (get-in resolved [:shape :type]))) + (t/is (= layout (get-in resolved [:shape :layout])))) + (do + (t/is (nil? (:op resolved))) + (t/is (= :skip (get-in resolved [:trace :status])))))))))) + +(t/deftest layout-container-overrides-retain-values-and-companions + (doseq [[property attr group layouts] layout-container-cases + layout (sort layouts)] + (t/testing (str attr " in " layout) + (let [initial (cg/initial-state [1 0 {:layout layout :layout-children true}]) + situation (:situation initial) + resolved (cg/resolve-op situation {} [:style 0 property 1 1]) + shape (:shape resolved) + id (:id shape) + expected (case attr + :layout-gap-type {:layout-gap-type (:value resolved) :layout-gap (:layout-gap shape)} + :layout-padding-type {:layout-padding-type (:value resolved) :layout-padding (:layout-padding shape)} + {attr (:value resolved)}) + edited (-> initial (cg/step [:style 0 property 1 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))))) + main-index (first (keep (fn [index] + (when (= (:shape-ref shape) + (get-in (cg/resolve-op situation {} [:style index property 0 0]) + [:shape :id])) index)) + (range (count (cg/tree situation))))) + updated (-> edited (cg/step [:style main-index property 1 0]) cg/settle-state + (cg/step [:style main-index property 0 0]) cg/settle-state) + value (:value (cg/resolve-op situation {} [:style main-index property 0 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 (= (select-keys shape [:layout-gap :layout-padding]) + (select-keys (tm/shape-by-id after id) [:layout-gap :layout-padding]))) + (t/is (= :I9 (invariant (assoc-in file (conj path attr) (get shape attr))))) + (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-container-histories-survive-component-operations + (doseq [[property _ _ layouts] layout-container-cases + layout (sort layouts)] + (let [result (cg/run {:world [1 0 {:layout layout :layout-children true}] + :ops [[:style 0 property 1 1] [:style 0 property 1 0] + [:style 0 property 0 0] [:undo] [:redo] [:update-main 0] + [:style 0 property 0 1] [:reset 0] + [:style 0 property 0 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 @@ -468,6 +624,8 @@ (t/is (false? (shadow/same-value? (:result-text after) :text-content-size expected))))))) (t/deftest switch-shadow-keeps-a-typography-only-with-its-style-ownership + (thi/new-id! :file1) + (thi/new-id! :heading) (doseq [[new-size keep-reference?] [[14 true] [22 false]]] (let [content #(thtx/content [[["Same text" {:size %}]]]) applied (thtx/content diff --git a/common/test/common_tests/logic/text_override_rule_test.cljc b/common/test/common_tests/logic/text_override_rule_test.cljc index 4ad27f74b8..ecfd799dca 100644 --- a/common/test/common_tests/logic/text_override_rule_test.cljc +++ b/common/test/common_tests/logic/text_override_rule_test.cljc @@ -15,6 +15,25 @@ (t/use-fixtures :each thi/test-fixture) +(t/deftest absent-and-nil-typography-fields-do-not-author-a-reference + (let [base (thtx/content [[["Hello world"]]]) + path [:children 0 :children 0 :children 0] + id (thi/new-id! :typography) + file (thi/new-id! :file) + refs {:typography-ref-id id :typography-ref-file file}] + (doseq [[left right expected] + [[{} {:typography-ref-id nil :typography-ref-file nil} #{}] + [{:typography-ref-id nil :typography-ref-file nil} {} #{}] + [refs {} #{:typography}] + [refs (assoc refs :typography-ref-id (thi/new-id! :other-typography)) #{:typography}] + [refs (assoc refs :typography-ref-file (thi/new-id! :other-file)) #{:typography}]]] + (let [clear #(dissoc % :typography-ref-id :typography-ref-file) + main (update-in base path #(merge (clear %) left)) + copy (update-in base path #(merge (clear %) right))] + (t/is (= expected (thtx/override-units main copy))) + (t/is (= (if (seq expected) #{:text-content-typography} #{}) + (txt/override-groups main copy))))))) + (defn- overrides "The overrides of `copy` against `main` by the reference rule, checked against the production one (`txt/override-groups`)." diff --git a/frontend/test/frontend_tests/composable_tests/completion_contract_test.cljs b/frontend/test/frontend_tests/composable_tests/completion_contract_test.cljs index 4af9294de6..4ce7aeede9 100644 --- a/frontend/test/frontend_tests/composable_tests/completion_contract_test.cljs +++ b/frontend/test/frontend_tests/composable_tests/completion_contract_test.cljs @@ -94,6 +94,57 @@ (t/is (= :settled (await (ftm/await-step [dwu/redo])))) (t/is (uc/same-state? after (ftm/snapshot)) (str "one redo restores " mode))))))))) +(t/deftest ^:async capped-history-keeps-a-primary-and-layout-entry-together + (await + (mock/with-mocks* {rp/cmd! mock/rpc-cmd-mock rx/timer mock/timer-mock} + (let [initial (ftm/start-generated (cg/world [1 0 {:layout :flex :layout-children true}])) + filled (loop [situation initial index 0] + (if (< index (dec dwu/MAX-UNDO-SIZE)) + (recur (await (ftm/run-generated-step + situation + (n/change-property :sync-random-0-copyhead-0 :opacity + (if (even? index) 0.4 0.7)))) + (inc index)) + situation)) + capped (await (ftm/run-generated-step filled (n/remove-child :random-0-layout-item-1))) + before (ftm/snapshot) + checked (await (ftm/finish-generated capped))] + (t/is (empty? (uc/failures filled))) + (t/is (= dwu/MAX-UNDO-SIZE (count (get-in @st/state [:workspace-undo :items])))) + (t/is (empty? (uc/failures capped))) + (t/is (empty? (uc/failures checked))) + (t/is (uc/same-state? before (ftm/snapshot))))))) + +(t/deftest ^:async capped-history-keeps-checking-steps-and-rejects-missing-undo + (await + (mock/with-mocks* {rp/cmd! mock/rpc-cmd-mock rx/timer mock/timer-mock} + (let [initial (ftm/start-generated (setup/simple-component-with-labeled-copy)) + filled (loop [situation initial index 0] + (if (< index dwu/MAX-UNDO-SIZE) + (let [next (await (ftm/run-generated-step situation + (n/change-property :copy-root :opacity + (if (even? index) 0.4 0.7))))] + (t/is (empty? (uc/failures next))) + (recur next (inc index))) + situation)) + capped (await (ftm/run-generated-step filled (n/detach :copy-root))) + before (ftm/snapshot) + checked (await (ftm/finish-generated capped))] + (t/is (= dwu/MAX-UNDO-SIZE (count (get-in @st/state [:workspace-undo :items])))) + (t/is (empty? (uc/failures capped))) + (t/is (empty? (uc/failures checked))) + (t/is (uc/same-state? before (ftm/snapshot))) + (let [update-shapes dwsh/update-shapes + missing (await + (mock/with-mocks* + {dwsh/update-shapes (mock/stub + (fn [ids update-fn & [options]] + (update-shapes ids update-fn (assoc options :save-undo? false))))} + (await (ftm/run-generated-step checked (n/change-property :copy-root :opacity 0.2)))))] + (t/is (= 0.2 (:opacity (ths/get-shape (ftm/current-file) :copy-root)))) + (t/is (= :no-undo-entry (:phase (peek (uc/failures missing))))) + (t/is (= (inc (count (uc/failures checked))) (count (uc/failures missing))))))))) + (t/deftest ^:async swapping-growing-text-undoes-with-the-component-choice (let [features (:features @st/state)] (thw/setup-wasm-mocks!) diff --git a/frontend/test/frontend_tests/composable_tests/generated_test.cljs b/frontend/test/frontend_tests/composable_tests/generated_test.cljs index 9652362516..8a889422c6 100644 --- a/frontend/test/frontend_tests/composable_tests/generated_test.cljs +++ b/frontend/test/frontend_tests/composable_tests/generated_test.cljs @@ -88,6 +88,16 @@ rx/timer mock/timer-mock} (await (replay-program program))))) +(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]]] + layout layouts] + (await (replay {:world [1 0 {:layout layout :layout-children true}] + :ops [[:style 0 property 1 1] [:style 0 property 1 0] + [:style 0 property 0 0] [:undo] [:redo] [:update-main 0] + [:style 0 property 0 1] [:reset 0] + [:style 0 property 0 1] [:detach 0] [: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 a07ec18f1c..46b91614fc 100644 --- a/frontend/test/frontend_tests/composable_tests/interpreter.cljs +++ b/frontend/test/frontend_tests/composable_tests/interpreter.cljs @@ -154,6 +154,11 @@ :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) + [(dwsl/update-layout [id] {property value})] + [(dwsh/update-shapes #{id} (fn [shape] (n/set-property shape property value)))])) (instance? n/EditText op) @@ -550,14 +555,28 @@ (defn- check-step "The round trip of user step `op`, given the snapshot `before` and the undo - index `index-before` taken before it. Resolves with the situation." - [situation op before index-before] - (let [step (uc/step situation op) + history taken before it. Account for entries dropped by the history cap." + [situation op before index-before items-before] + (let [items-after (get-in @st/state [:workspace-undo :items]) + evicted (or (first (keep-indexed (fn [index item] + (when (= item (first items-after)) index)) + (take (inc index-before) items-before))) + 0) + step (uc/step situation op) + baseline (uc/baseline situation) + situation (cond-> situation + (pos? evicted) + (uc/mark-baseline (- (:depth baseline) evicted) (:snapshot baseline)) + (> evicted (inc (:depth baseline))) + (uc/add-error step :expired-baseline "Undo entries expired before the segment was checked")) after (snapshot) index-after (undo-index)] - (if (= index-before index-after) + (if (and (= index-before index-after) + (zero? evicted) + (= (:undo-group (get items-before index-before)) + (:undo-group (get items-after index-after)))) (js/Promise.resolve (uc/compare-states situation step :no-undo-entry before after)) - (.then (round-trip-move situation dwu/undo step :undo before index-before) + (.then (round-trip-move situation dwu/undo step :undo before (- index-before evicted)) (fn [[situation settled?]] (if settled? (.then (round-trip-move situation dwu/redo step :redo after index-after) @@ -580,8 +599,8 @@ (step 0))) (defn- check-variant - "The end-of-variant round trip: undo back to the baseline, then redo as many - times. Resolves with the situation." + "Undo the retained history segment back to its baseline, then redo it. + Also runs before the cap drops entries, so every segment gets checked." [situation] (let [{:keys [depth] :as baseline} (uc/baseline situation) final (snapshot) @@ -707,12 +726,22 @@ check-step? (and (::undo-check? situation) (contains? #{:user :frontend-only} kind)) before (when check-step? (snapshot)) - index-before (undo-index)] - (if (sync-op? op) + index-before (undo-index) + items-before (when check-step? (get-in @st/state [:workspace-undo :items]))] + (cond + ;; Check the complete segment while its baseline is still reachable. + ;; Leave room for a primary commit and its delayed layout entry. + (and check-step? + (>= index-before (- dwu/MAX-UNDO-SIZE 2)) + (< (:depth (uc/baseline situation)) index-before)) + (.then (check-variant situation) #(run-ops (mark-baseline %) ops)) + + (sync-op? op) (let [situation (run-sync-op situation op)] (when (and (not (::generated situation)) (contains? golden-kinds kind)) (observe-golden-step!)) (run-ops situation (rest ops))) + :else (do (reset! step-errors []) (.then (await-step (op-events op situation) step-timeout-ms) @@ -739,7 +768,7 @@ (cond check-step? ;; go on from the redone file, as the pure runner does - (.then (check-step situation (unwrap op) before index-before) + (.then (check-step situation (unwrap op) before index-before items-before) #(run-ops (refresh-generated %) (rest ops))) ;; never undo past the state a case's own undo went back to