mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 05:50:35 +00:00
🚧 Save Phase 4 random harness and grid fixes in progress
Save Stage 2 structure, text, geometry and layout generation, independent oracle checks, frontend seeded replay and plugin visible-value checks. Include pending grid sync and deletion fixes with focused regressions. WIP validation: component sync 21 tests / 106 assertions; move shapes 6 tests / 33 assertions. Earlier plugin checks passed 68 full cases, 11 unit tests and types. The final generated replay, golden check, 5000-case gate, full suites and live WASM check remain unfinished. Stages 3 (swaps/variants) and 4 (library files) have not started. F124 benchmark ratios against Phase 1e: main 10/100/1000 copies 0.48/0.52/0.54; three levels 0.53; chain 0.56; library 0.52; many shapes 0.42; variant switch 0.64. No regression flag. The F125 deletion change still needs its final benchmark. Save the detailed resume checklist and logs in the local stabilization plan. Keep that workflow document out of the commit. AI-assisted-by: GPT-6
This commit is contained in:
parent
8ae59c56d7
commit
35b0a893e6
@ -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})))
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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."
|
||||
[]
|
||||
|
||||
@ -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)}))
|
||||
|
||||
@ -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})))
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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))))))
|
||||
|
||||
@ -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))))))
|
||||
|
||||
@ -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)))
|
||||
|
||||
|
||||
@ -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"]}]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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]]})))
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
40
plugins/apps/composable-test-suite/ci/random.test.ts
Normal file
40
plugins/apps/composable-test-suite/ci/random.test.ts
Normal file
@ -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<void>((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;
|
||||
}
|
||||
});
|
||||
@ -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",
|
||||
|
||||
@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
@ -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))
|
||||
);
|
||||
}
|
||||
@ -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<string, Partial<Record<Field, unknown>>>;
|
||||
|
||||
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<void> {
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
});
|
||||
}
|
||||
@ -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<void> {
|
||||
// @ts-ignore
|
||||
if (penpot.waitForLayoutUpdate) {
|
||||
// @ts-ignore
|
||||
return penpot.waitForLayoutUpdate();
|
||||
} else {
|
||||
static async awaitPropagation(timeoutMs = 10000): Promise<void> {
|
||||
const api = penpot as typeof penpot & { waitForLayoutUpdate(): Promise<void> };
|
||||
if (!api.waitForLayoutUpdate) {
|
||||
throw new Error("PenpotSync.awaitPropagation: waitForLayoutUpdate is not available");
|
||||
return new Promise((resolve) => setTimeout(resolve, PenpotSync.PROPAGATION_MS));
|
||||
}
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
await Promise.race([
|
||||
api.waitForLayoutUpdate(),
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error(`Component propagation did not settle in ${timeoutMs}ms`)),
|
||||
timeoutMs
|
||||
);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user