mirror of
https://github.com/penpot/penpot.git
synced 2026-10-28 21:40:34 +00:00
✨ Exercise layout container edits in component histories
Generate flex direction, wrapping, justification, content alignment, gap mode and padding mode edits against settled multi-child layouts. Track their authored groups and replay histories through real layout events. Settle raw layout property changes after copy sync in the pure runner and keep derived writes in the user's undo group. Normalize absent and nil typography references in the independent text model. Make frontend undo checks account for capped history and grouped layout entries. Verify segments before eviction and keep missing-undo controls. AI-assisted-by: GPT-6
This commit is contained in:
parent
4a40c10962
commit
09fb1abf09
@ -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)
|
||||
|
||||
@ -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)))))))
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -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))))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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`)."
|
||||
|
||||
@ -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!)
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user