🚧 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:
alonso.torres 2026-10-23 09:50:53 +02:00
parent 8ae59c56d7
commit 35b0a893e6
23 changed files with 779 additions and 65 deletions

View File

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

View File

@ -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]

View File

@ -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."
[]

View File

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

View File

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

View File

@ -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]

View File

@ -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)

View File

@ -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))))))

View File

@ -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))))))

View File

@ -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)))

View File

@ -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"]}]

View File

@ -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

View File

@ -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)

View File

@ -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]]})))

View File

@ -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

View File

@ -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

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

View File

@ -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",

View File

@ -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(),
];
}

View File

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

View File

@ -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}`;
}
}

View File

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

View File

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