From fbc0b7fd9306cb484863b7af4c98abe0a4f9b5c1 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Sat, 24 Oct 2026 14:35:45 +0200 Subject: [PATCH] :sparkles: Extend generated component checks to structure and geometry Cover text, layouts, nesting, duplication and transforms with independent geometry and override predictions. Compare raw design geometry within a pixel tolerance and keep exact undo snapshots. Add corrupt-state controls and replay shrunk programs through the async frontend. Route pure-runner changes by file so later library scenarios can share ordered sync and inverse changes. Refresh every frontend file on replay. Final-generator MVP and structure gates each pass 5000 scenarios, seed712. Isolated stage check: 40 tests, 156 assertions. The full working-tree frontend run passes 1528 tests and 8877 assertions. Existing golden changes record reviewed fixes; raw undo stays strict. AI-assisted-by: GPT-6 --- .../test_helpers/composable/comp/files.cljc | 101 ++++++++++ .../test_helpers/composable/comp/nodes.cljc | 23 +-- .../test_helpers/composable/comp/runner.cljc | 51 ++--- .../composable/comp/undo_check.cljc | 21 +- .../common/test_helpers/composable/gen.cljc | 76 +++++-- .../test_helpers/composable/oracle.cljc | 137 ++++++++++--- .../test_helpers/composable/shadow.cljc | 153 ++++++++++++-- .../logic/composable_gen_test.cljc | 141 ++++++++++++- .../logic/composable_oracle_test.cljc | 190 +++++++++++++++++- .../test/common_tests/logic/golden_master.edn | 32 +-- .../composable_tests/generated_test.cljs | 83 +++++++- .../composable_tests/golden_master.edn | 52 ++--- .../composable_tests/interpreter.cljs | 20 +- 13 files changed, 889 insertions(+), 191 deletions(-) create mode 100644 common/src/app/common/test_helpers/composable/comp/files.cljc diff --git a/common/src/app/common/test_helpers/composable/comp/files.cljc b/common/src/app/common/test_helpers/composable/comp/files.cljc new file mode 100644 index 0000000000..6a898c2310 --- /dev/null +++ b/common/src/app/common/test_helpers/composable/comp/files.cljc @@ -0,0 +1,101 @@ +;; 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 app.common.test-helpers.composable.comp.files + "File routing for the pure composable runner, including library inverses." + (:require + [app.common.files.changes-builder :as pcb] + [app.common.logic.libraries :as cll] + [app.common.test-helpers.composable.core :as tm] + [app.common.test-helpers.files :as thf])) + +(defn all-files + [situation] + (assoc (tm/aux-files situation) (:id (tm/file situation)) (tm/file situation))) + +(defn- with-files + [situation files] + (let [id (:id (tm/file situation))] + (-> situation + (tm/with-file (get files id)) + (tm/with-aux-files (dissoc files id))))) + +(defn route + "Tag changes destined for another file, preserving their original order." + [changes file-id] + (let [tag #(mapv (fn [change] (assoc change ::destination file-id)) %)] + (-> changes (update :redo-changes tag) (update :undo-changes tag)))) + +(defn route-update-main + [changes current-id main-id] + (let [tag (fn [change] + (cond-> (dissoc change :local-change?) + (and (not (:local-change? change)) (not= current-id main-id)) + (assoc ::destination main-id))) + tag-all #(mapv tag %)] + (-> changes (update :redo-changes tag-all) (update :undo-changes tag-all)))) + +(defn apply-changes + "Apply each consecutive run to its file, stripping runner routing metadata." + ([situation changes] (apply-changes situation changes :redo-changes)) + ([situation changes side] + (let [current-id (:id (tm/file situation))] + (with-files situation + (reduce (fn [files group] + (let [id (or (::destination (first group)) current-id) + file (get files id)] + (when-not file + (throw (ex-info "Missing composable change destination" {:file-id id}))) + (assoc files id + (thf/apply-changes file + {:redo-changes (mapv #(dissoc % ::destination) group)} + :validate? false)))) + (all-files situation) + (partition-by #(or (::destination %) current-id) (get changes side))))))) + +(defn changed-components + [before changes] + (let [current-id (:id (tm/file before)) + files (all-files before)] + (into #{} + (mapcat (fn [[id group]] + (cll/changed-components (:data (get files id)) {:redo-changes group}))) + (group-by #(or (::destination %) current-id) (:redo-changes changes))))) + +(defn- sync-local + [situation file-id ids] + (let [files (all-files situation) + result (cll/generate-sync-components-cascade (pcb/empty-changes) file-id ids files + (:id (tm/file situation))) + changes (route (:changes result) file-id)] + {:situation (with-files situation (assoc-in files [file-id :data] (:data result))) + :syncs (if (seq (:redo-changes changes)) [changes] [])})) + +(defn sync-world + "Settle changed library mains first, pull their copies into the current + file, then settle local inputs. Each cascade joins the same undo group." + [situation component-ids] + (let [current-id (:id (tm/file situation)) + owners (into {} (mapcat (fn [[id file]] + (map #(vector % id) (keys (get-in file [:data :components]))))) + (all-files situation)) + by-file (group-by owners component-ids) + foreign (sort-by (comp str key) (dissoc by-file current-id)) + result + (reduce (fn [{:keys [situation syncs]} [library-id ids]] + (let [settled (sync-local situation library-id ids) + situation (:situation settled) + pulled (cll/generate-sync-file-cascade + (pcb/empty-changes) nil :components current-id nil library-id + (all-files situation) current-id) + changes (:changes pulled)] + {:situation (apply-changes situation changes) + :syncs (cond-> (into syncs (:syncs settled)) + (seq (:redo-changes changes)) (conj changes))})) + {:situation situation :syncs []} foreign) + local (sync-local (:situation result) current-id (get by-file current-id []))] + {:situation (:situation local) + :syncs (into (:syncs result) (:syncs local))})) diff --git a/common/src/app/common/test_helpers/composable/comp/nodes.cljc b/common/src/app/common/test_helpers/composable/comp/nodes.cljc index b0dd95b813..f888032fce 100644 --- a/common/src/app/common/test_helpers/composable/comp/nodes.cljc +++ b/common/src/app/common/test_helpers/composable/comp/nodes.cljc @@ -28,6 +28,7 @@ [app.common.math :as mth] [app.common.path-names :as cpn] [app.common.test-helpers.components :as thc] + [app.common.test-helpers.composable.comp.files :as cf] [app.common.test-helpers.composable.core :as tm] [app.common.test-helpers.compositions :as tho] [app.common.test-helpers.files :as thf] @@ -832,13 +833,6 @@ ;; the order the sync built them. ;; --------------------------------------------------------------------------- -(defn- strip-local-change - [changes] - (let [strip (partial mapv #(dissoc % :local-change?))] - (-> changes - (update :redo-changes strip) - (update :undo-changes strip)))) - (defrecord UpdateMain [target] IComponentOperation (op-kind [_] :user) @@ -854,10 +848,9 @@ (cll/generate-sync-shape-inverse (libraries situation) container shape-id) - (strip-local-change)) - file' (thf/apply-changes the-file changes :validate? false)] + (cf/route-update-main (:id the-file) (:component-file (get-in page [:objects shape-id])))) + situation (cf/apply-changes situation changes)] (-> situation - (tm/with-file file') (record-changes (when (seq (:redo-changes changes)) changes)) (tm/record-application this {:target target :updated shape-id}))))) @@ -1139,12 +1132,12 @@ ;; --------------------------------------------------------------------------- (defn swap-target-id - "The component id `swap-shape`'s `to` names: a lineage name (a string) or a - component label (a keyword)." + "Resolve a lineage name, component label, or situation-to-component fn." [situation to] - (if (keyword? to) - (thi/id to) - (lineage-component-id situation to))) + (cond + (fn? to) (to situation) + (keyword? to) (thi/id to) + :else (lineage-component-id situation to))) (defrecord SwapShape [target to keep-touched?] IComponentOperation diff --git a/common/src/app/common/test_helpers/composable/comp/runner.cljc b/common/src/app/common/test_helpers/composable/comp/runner.cljc index 7817a25b8c..ef5d67067c 100644 --- a/common/src/app/common/test_helpers/composable/comp/runner.cljc +++ b/common/src/app/common/test_helpers/composable/comp/runner.cljc @@ -33,6 +33,7 @@ (:require [app.common.files.changes-builder :as pcb] [app.common.logic.libraries :as cll] + [app.common.test-helpers.composable.comp.files :as cf] [app.common.test-helpers.composable.comp.nodes :as n] [app.common.test-helpers.composable.comp.undo-check :as uc] [app.common.test-helpers.composable.core :as tm] @@ -46,19 +47,15 @@ `old-data`, the file data before `changes` applied." cll/changed-components) -(defn- libraries - "The situation's files by id, with `file` as its current file." - [situation file] - (assoc (tm/aux-files situation) (:id file) file)) - (defn- validate-settled! "Validate the situation's file, resolving components against its other files. Only a settled file is valid: between two passes of a sync cascade, an outer copy can still point at the component its main no longer uses. Throws when the file is invalid." [situation] - (let [file (tm/file situation)] - (thf/validate-file! file (libraries situation file)) + (let [files (cf/all-files situation)] + (doseq [file (vals files)] + (thf/validate-file! file files)) situation)) (defn sync-cascade @@ -80,14 +77,10 @@ :passes passes})) (defn- sync-file - "Sync `situation`'s file, starting from the components that `changes` - modified (judged against `old-data`), and validate the result. Returns - `[situation syncs]`, with `syncs` the applied sync changes in order." - [situation old-data changes] - (let [{:keys [file syncs]} (sync-cascade (tm/file situation) - (tm/aux-files situation) - (changed-components old-data changes))] - [(validate-settled! (tm/with-file situation file)) syncs])) + [situation before changes] + (let [{:keys [situation syncs]} + (cf/sync-world situation (cf/changed-components before changes))] + [(validate-settled! situation) syncs])) (defn- push-undo-group "Add `group`, the changes of one user operation and its syncs in order, to @@ -101,19 +94,9 @@ (assoc ::redo-stack []))))) (defn- apply-group - "Apply to the situation's file the `side` changes (`:undo-changes` or - `:redo-changes`) of each changes in `group`, in order, and validate the - result." [situation side group] - (let [file (reduce (fn [file changes] - (thf/apply-changes file - {:redo-changes (get changes side)} - :validate? false)) - (tm/file situation) - group)] - (-> situation - (tm/with-file file) - (validate-settled!)))) + (validate-settled! + (reduce #(cf/apply-changes %1 %2 side) situation group))) (defn- move-group "Move the latest group of the `from` stack to the `to` stack, applying its @@ -290,7 +273,7 @@ (when `:sync?`), pushes its undo group, and gets its round trip (when `:undo-check?`). An assembly operation moves the round trip's baseline." [situation op kind {:keys [sync? undo-check?]}] - (let [old-data (:data (tm/file situation)) + (let [before-situation situation before (when undo-check? (snapshot situation)) depth (undo-depth situation) [recorded? changes situation] (n/take-changes (tm/apply-to op situation))] @@ -298,12 +281,12 @@ (cond-> (if (nil? changes) situation (let [[situation syncs] (if sync? - (sync-file situation old-data changes) + (sync-file situation before-situation changes) [situation []])] (cond-> (push-undo-group situation (into [changes] syncs)) (not sync?) (update ::pending-components (fnil into #{}) - (changed-components old-data changes))))) + (cf/changed-components before-situation changes))))) (and undo-check? (= kind :user)) (check-step op before depth) @@ -388,13 +371,9 @@ "Sync selected pending components in the latest user's undo group." ([situation] (settle situation (pending-components situation))) ([situation component-ids] - (let [{:keys [file syncs]} (sync-cascade (tm/file situation) - (tm/aux-files situation) - component-ids) + (let [{:keys [situation syncs]} (cf/sync-world situation component-ids) stack (get situation ::undo-stack [])] - (cond-> (-> situation - (tm/with-file file) - (update ::pending-components #(apply disj (or % #{}) component-ids))) + (cond-> (update situation ::pending-components #(apply disj (or % #{}) component-ids)) (and (seq syncs) (seq stack)) (assoc ::undo-stack (conj (pop stack) (into (peek stack) syncs))))))) diff --git a/common/src/app/common/test_helpers/composable/comp/undo_check.cljc b/common/src/app/common/test_helpers/composable/comp/undo_check.cljc index dcd01cd0f0..510c4f2075 100644 --- a/common/src/app/common/test_helpers/composable/comp/undo_check.cljc +++ b/common/src/app/common/test_helpers/composable/comp/undo_check.cljc @@ -169,25 +169,26 @@ (into (entities-signature :component (:components ed) (:components ad) #{} (constantly nil))))))) -(defn- same-state? - "Reflow can recompute geometry at machine precision after undo. Keep - snapshots exact, allowing only absolute geometry noise below 1e-10." - ([expected actual] (same-state? expected actual false)) - ([expected actual geometry?] +(defn same-state? + "Compare exact data with an absolute tolerance only in shape geometry. + Undo defaults to 1e-10; design equivalence can supply its own tolerance." + ([expected actual] (same-state? expected actual 1e-10 false)) + ([expected actual tolerance] (same-state? expected actual tolerance false)) + ([expected actual tolerance geometry?] (or (= expected actual) (cond (and geometry? (number? expected) (number? actual)) - (mth/close? expected actual 1e-10) + (mth/close? expected actual tolerance) (and geometry? (gmt/matrix? expected) (gmt/matrix? actual)) - (every? #(same-state? (get expected %) (get actual %) true) [:a :b :c :d :e :f]) + (every? #(same-state? (get expected %) (get actual %) tolerance true) [:a :b :c :d :e :f]) (and geometry? (gpt/point? expected) (gpt/point? actual)) - (every? #(same-state? (get expected %) (get actual %) true) [:x :y]) + (every? #(same-state? (get expected %) (get actual %) tolerance true) [:x :y]) (and (map? expected) (map? actual) (= (set (keys expected)) (set (keys actual)))) (every? (fn [key] - (same-state? (get expected key) (get actual key) + (same-state? (get expected key) (get actual key) tolerance (or geometry? (and (contains? expected :parent-id) (contains? #{:x :y :width :height :rotation :points @@ -195,7 +196,7 @@ (keys expected)) (and (sequential? expected) (sequential? actual) (= (count expected) (count actual))) - (every? true? (map #(same-state? %1 %2 geometry?) expected actual)) + (every? true? (map #(same-state? %1 %2 tolerance geometry?) expected actual)) :else false)))) diff --git a/common/src/app/common/test_helpers/composable/gen.cljc b/common/src/app/common/test_helpers/composable/gen.cljc index c3f3001645..ddf03b4c18 100644 --- a/common/src/app/common/test_helpers/composable/gen.cljc +++ b/common/src/app/common/test_helpers/composable/gen.cljc @@ -12,6 +12,7 @@ (:require [app.common.files.helpers :as cfh] [app.common.logic.shapes :as cls] + [app.common.test-helpers.composable.comp.files :as cf] [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] @@ -52,6 +53,22 @@ file (cond-> file (= layout :grid) (tho/place-in-grid-columns root 2 {1 child}))] (tm/with-file situation file)))) + + +(defn forest + "All live page trees, with stable file/page prefixes for model replay." + [situation] + (vec (for [[file-index [_ file]] (map-indexed vector (sort-by (comp str key) (cf/all-files situation))) + [page-index page-id] (map-indexed vector (get-in file [:data :pages])) + entry (tree (tm/with-file situation (with-meta file {:current-page-id page-id})))] + (update entry :path #(into [:file file-index :page page-index] %))))) + +(defn- all-objects + [situation] + (reduce (fn [objects component] (merge (:objects component) objects)) + (into {} (map (juxt (comp :id :shape) :shape)) (forest situation)) + (mapcat #(vals (get-in % [:data :components])) (vals (cf/all-files situation))))) + (defn world [[components depth opts]] (thi/reset-idmap!) @@ -88,7 +105,13 @@ (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))) + :edit (and (not= :text (:type shape)) + (case scope + 1 (:shape-ref shape) + 2 (and (:shape-ref shape) + (some #(contains? (tm/aux-files situation) (:component-file %)) + (cfh/get-parents-with-self objects (:id shape)))) + true)) :reset (and (copy-head shape) (live-component shape)) :update-main (and (top-copy shape) (live-component shape) (overridden (:id shape))) :detach (top-copy shape) @@ -125,7 +148,15 @@ "Resolve one tuple. Empty target sets produce an explicit skip." [situation model [kind index property value scope :as tuple]] (let [index (or index 0) - items (candidates situation model kind scope) + items (cond->> (candidates situation model kind scope) + (and (= kind :edit) (= 2 (mod (or property 0) 3))) + (remove (fn [{:keys [shape]}] + ;; Variant names encode properties or rename the set. + ;; Those actions have their own nodes, not scalar edits. + (or (ctk/is-variant-container? shape) + (:variant-id shape) + (get-in (tm/file situation) + [:data :components (:component-id shape) :variant-id]))))) selected (when (seq items) (nth items (mod index (count items)))) target (when selected (constantly (get-in selected [:shape :id]))) property (nth [:fills :opacity :name] (mod (or property 0) 3)) @@ -162,6 +193,7 @@ :status (if (or op (= kind :sync)) :executed :skip)} selected (assoc :target (:path selected)) (:into selected) (assoc :into (:into selected)) + (:destination selected) (assoc :to (get-in selected [:destination :path])) (and (nil? op) (not= kind :sync)) (assoc :reason (str "No eligible " (name kind) " target")))})) @@ -177,30 +209,34 @@ (defn settle-state [{:keys [situation model checkpoint] :as state}] (let [settled (r/settle situation) - all (keys (get-in (tm/file situation) [:data :components])) - full-before (r/sync-cascade (tm/file situation) (tm/aux-files situation) all) - full (r/sync-cascade (tm/file settled) (tm/aux-files settled) - all)] + model (-> model + (shadow/parent-orientation (all-objects situation) (all-objects settled)) + (shadow/settle (all-objects settled))) + all (mapcat #(keys (get-in % [:data :components])) (vals (cf/all-files situation))) + full-before (:situation (cf/sync-world situation all)) + full (:situation (cf/sync-world settled all)) + settled-design (oracle/canonical settled) + full-before-design (oracle/canonical full-before) + full-design (oracle/canonical full)] (oracle/check settled model) - (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)))) + (when-not (oracle/equivalent? settled-design full-before-design) + (oracle/fail :I6 {:diff (thn/diff settled-design full-before-design)})) + (when-not (oracle/equivalent? settled-design full-design) (oracle/fail :I5 {})) (when checkpoint (assert-round-trip checkpoint settled)) - (assoc state :situation settled :checkpoint nil))) + (cond-> (assoc state :situation settled :model model :checkpoint nil) + (and (not= model (:model state)) (seq (:undo state))) + (assoc-in [:undo (dec (count (:undo state))) :after] model)))) (defn- update-model [model before after {:keys [shape property value op]} kind] - (let [objects (:objects (thf/current-page (tm/file before))) + (let [objects (all-objects before) id (:id shape)] (case kind :edit (shadow/edit model shape property value) :reset (shadow/clear-subtree model objects id) - :update-main (shadow/update-main model objects id) - :detach (shadow/detach model objects - (:objects (thf/current-page (tm/file after))) id) + :update-main (shadow/update-main model objects (all-objects after) id) + :detach (shadow/detach model objects (all-objects 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))) @@ -236,13 +272,17 @@ :undo (:before (peek history)) :redo (:after (peek redo)) (update-model model situation after resolved kind)) - next-model (or next-model model) + next-model (cond-> (or next-model model) + (not (#{:undo :redo} kind)) + (shadow/parent-orientation (all-objects situation) (all-objects after))) + next-model (shadow/settle next-model (all-objects after)) state (-> state (assoc :situation after :model next-model) (update-in [:executed kind] (fnil inc 0)))] (oracle/check after next-model (empty? (r/pending-components after))) (case kind - :sync state + :sync (cond-> state + (seq history) (assoc-in [:undo (dec (count history)) :after] next-model)) :undo (-> state (assoc :undo (pop history)) (update :redo conj (peek history))) :redo (-> state (assoc :redo (pop redo)) (update :undo conj (peek redo))) (cond-> state diff --git a/common/src/app/common/test_helpers/composable/oracle.cljc b/common/src/app/common/test_helpers/composable/oracle.cljc index 9224f7a395..519c1d8915 100644 --- a/common/src/app/common/test_helpers/composable/oracle.cljc +++ b/common/src/app/common/test_helpers/composable/oracle.cljc @@ -13,11 +13,14 @@ [app.common.geom.point :as gpt] [app.common.geom.shapes :as gsh] [app.common.math :as mth] + [app.common.test-helpers.composable.comp.files :as cf] + [app.common.test-helpers.composable.comp.undo-check :as uc] [app.common.test-helpers.composable.core :as tm] [app.common.test-helpers.composable.shadow :as shadow] [app.common.test-helpers.files :as thf] [app.common.test-helpers.normalize :as thn] [app.common.test-helpers.texts :as thtx] + [app.common.types.component :as ctk] [app.common.types.container :as ctn] [app.common.types.file :as ctf] [app.common.types.modifiers :as ctm] @@ -53,20 +56,30 @@ height-owned (conj :height) (or layout-child width-owned height-owned) (into #{:points :selrect})))) +(defn- matrix-effect + "Matrix coefficients have no pixel unit. Compare their effect over the + shape's local bounds, so the geometry tolerance has the same unit." + [shape matrix] + (let [half-width (/ (:width shape) 2) + half-height (/ (:height shape) 2)] + (mapv #(gpt/transform % (or matrix (gmt/matrix))) + [(gpt/point (- half-width) (- half-height)) + (gpt/point half-width (- half-height)) + (gpt/point half-width half-height) + (gpt/point (- half-width) half-height)]))) + (defn design-snapshot - "Sync equivalence ignores remote-sync bookkeeping, text run splitting and - geometry noise below 0.01 px. Undo snapshots deliberately keep them." + "Design inputs without derived placement, dimensions and bookkeeping. + Geometry remains unrounded; equivalent? applies its tolerance directly." [situation] (walk/postwalk (fn [value] (if (and (map? value) (contains? value :parent-id)) - (let [round-geometry (fn [value] - (walk/postwalk #(if (number? %) (/ (mth/round (* % 100)) 100) %) value))] - (cond-> (reduce (fn [shape key] - (if (contains? shape key) (update shape key round-geometry) shape)) - (dissoc value :remote-synced) - [:x :y :width :height :points :selrect :transform :transform-inverse :rotation]) - (= :text (:type value)) (update :content thtx/view))) + (cond-> (-> value + (dissoc :remote-synced) + (update :flip-x boolean) + (update :flip-y boolean)) + (= :text (:type value)) (update :content thtx/view)) value)) (walk/postwalk (fn [value] @@ -77,7 +90,13 @@ [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))) + (as-> moved (assoc moved + :transform (matrix-effect shape (:transform moved)) + :transform-inverse (matrix-effect shape (:transform-inverse moved)))) + (as-> shape (apply dissoc shape + (cond-> (derived-attributes objects shape) + (and (:component-id shape) (:shape-ref shape)) + (into #{:x :y :points :selrect})))) (update :constraints-h #(or % (gsh/default-constraints-h shape))) (update :constraints-v #(or % (gsh/default-constraints-v shape))))])) objects))) value)) @@ -111,6 +130,20 @@ (name-tree (:objects component) (:main-instance-id component) (conj path :main))))) (walk/postwalk #(if (uuid? %) (get @ids % %) %) (design-snapshot situation))))) +(defn equivalent? + "Compare canonical data, allowing 0.01 only within shape geometry." + [expected actual] + (uc/same-state? expected actual 0.01)) + +(defn- orientation-matrix + "Resize uses constraints and dimensions. Parent placement supplies axis + directions, without applying residual minimum-size scale a second time." + [shape] + (let [{:keys [a b c d]} (or (:transform shape) (gmt/matrix)) + horizontal (gpt/unit (gpt/point a b)) + vertical (gpt/unit (gpt/point c d))] + (gmt/matrix (:x horizontal) (:y horizontal) (:x vertical) (:y vertical) 0 0))) + (defn- child-placement "Place a main child against its expected copy parent. Only lower-level constraint and matrix math is shared with production, not sync code." @@ -127,14 +160,21 @@ main (get resized (:id main)) main-parent (get resized (:id main-parent)) center (gsh/shape->center main-parent) - turn (gmt/multiply (or (:transform parent) (gmt/matrix)) - (or (:transform-inverse main-parent) (gmt/matrix))) + turn (gmt/multiply (orientation-matrix parent) + (gmt/inverse (orientation-matrix main-parent))) placed (if (gmt/unit? turn) main - (gsh/apply-transform main (gmt/transform-in center turn)))] + (gsh/apply-transform main (gmt/transform-in center turn))) + ;; Flip flags follow the parent's composition history. A single + ;; large turn can toggle both flags where several small turns do not. + flipped? (fn [key] + (odd? (count (filter identity [(get main key) (get main-parent key) + (get parent key)]))))] (-> 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))))) + (- (or (:rotation parent) 0) (or (:rotation main-parent) 0))) 360) + :flip-x (flipped? :flip-x) + :flip-y (flipped? :flip-y))))) (defn- with-derived-geometry [objects shape expected] @@ -157,19 +197,42 @@ (fail :I1 {:shape (:name shape) :reason :parent-cycle})) (if (contains? (:touched shape) :geometry-group) 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))))] + (let [main (ctf/find-ref-shape file container libraries shape :with-context? true) + parent (get objects (:parent-id shape)) + main-objects (get-in (meta main) [:container :objects]) + main-parent (get main-objects (:parent-id main)) + placed (cond + (nil? main) shape + (and (:shape-ref parent) main-parent) + (child-placement main main-parent (expected parent (conj visited (:id shape))) + main-objects) + :else + ;; Ordinary parents left by detach still place their nested copies. + (let [turn (gmt/multiply (or (:transform shape) (gmt/matrix)) + (or (:transform-inverse main) (gmt/matrix))) + placed (if (gmt/unit? turn) main + (gsh/apply-transform main (gmt/transform-in (gsh/shape->center main) turn)))] + (-> placed + (gsh/move (gpt/subtract (gsh/shape->center shape) (gsh/shape->center placed))) + ;; This is the placement matrix just applied. + ;; Re-decomposing a .01px frame can add scale + ;; noise that grows in its larger children. + (assoc :transform (:transform shape) + :transform-inverse (:transform-inverse shape) + :rotation (:rotation shape) + :flip-x (:flip-x shape) :flip-y (:flip-y shape))))) + content (when (ctk/swapped? shape) + (ctf/find-content-ref-shape file container libraries shape)) + placed (if content + ;; Content supplies size; the position source + ;; still owns the untransformed top-left anchor. + (-> placed + (gsh/transform-shape + (ctm/change-size placed (:width content) (:height content))) + (gsh/absolute-move (gpt/point (get-in placed [:selrect :x]) + (get-in placed [:selrect :y])))) + placed)] + (with-derived-geometry objects shape placed))))] (expected shape #{})))) (defn- geometry-differences @@ -217,10 +280,10 @@ (fail :I4 {:shape (:name shape) :group :layout-grid-cells :expected (grid-cells main) :actual (grid-cells shape)})))) -(defn check +(defn- check-file "I1–I4 and I9. Before settlement only authored groups/values are checked; pending main changes may legitimately leave copies stale." - ([situation model] (check situation model true)) + ([situation model] (check-file situation model true)) ([situation model settled] (let [file (tm/file situation) libraries (assoc (tm/aux-files situation) (:id file) file)] @@ -246,7 +309,11 @@ :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 (comparable-values main shape group) + :let [source (if (or (= group :constraints-group) + (.startsWith (name group) "layout-item-")) + (or (ctf/find-ref-shape file container libraries shape) main) + main) + expected (comparable-values source shape group) actual (comparable-values shape shape group)]] (when-not (= expected actual) (fail :I4 {:shape (:name shape) :group group @@ -264,3 +331,13 @@ :expected (select-keys expected [:width :height :points]) :actual (select-keys shape [:width :height :points])})))))) nil))) + +(defn check + "Check both the working file and every library in the generated world." + ([situation model] (check situation model true)) + ([situation model settled] + (let [files (cf/all-files situation)] + (doseq [[id file] files] + (check-file (-> situation (tm/with-file file) (tm/with-aux-files (dissoc files id))) + model settled))) + nil)) diff --git a/common/src/app/common/test_helpers/composable/shadow.cljc b/common/src/app/common/test_helpers/composable/shadow.cljc index 058c9cb6bf..98f7c29fce 100644 --- a/common/src/app/common/test_helpers/composable/shadow.cljc +++ b/common/src/app/common/test_helpers/composable/shadow.cljc @@ -8,6 +8,7 @@ "Authored overrides, kept independently of production touched writes. Each shape id maps groups to the attribute values the user authored." (:require + [app.common.math :as mth] [app.common.test-helpers.composable.comp.nodes :as n] [app.common.test-helpers.texts :as thtx])) @@ -62,7 +63,14 @@ [shape group expected] (if-let [unit (get text-groups group)] (not (contains? (thtx/override-units (:content expected) (:content shape)) unit)) - (= expected (values shape group)))) + (if (= group :geometry-group) + (every? (fn [[key value]] + (let [actual (get shape key)] + (cond + (#{:flip-x :flip-y} key) (= (boolean value) (boolean actual)) + (and (number? value) (number? actual)) (mth/close? value actual 1e-10) + :else (= value actual)))) expected) + (= expected (values shape group))))) (defn edit-text [model before content] @@ -99,21 +107,54 @@ (reduce clear (dissoc model id) (:shapes (get objects id))))] (clear model root-id))) +(defn- in-orientation + [values source target] + (reduce (fn [values key] + (cond-> values + (not= (boolean (get source key)) (boolean (get target key))) + (update key not))) + (update values :rotation + #(mod (+ (or % 0) (- (or (:rotation target) 0) (or (:rotation source) 0))) 360)) + [:flip-x :flip-y])) + (defn detach "A detached outer head loses its groups. Surviving nested copies inherit the authored groups of the near-main links the detach skips." [model before after root-id] (letfn [(inherit [shape] - (loop [shape shape groups (get model (:id shape)) seen #{}] - (let [ref (:shape-ref shape)] - (if (or (nil? ref) (contains? seen ref) - (= ref (:shape-ref (get after (:id shape))))) - groups - (recur (get before ref) (merge (get model ref) groups) - (conj seen ref)))))) + (let [original shape + new-ref (:shape-ref (get after (:id shape))) + swapped? (contains? (get model (:id shape)) :swap-group)] + (loop [shape shape groups (get model (:id shape)) seen #{}] + (let [ref (:shape-ref shape) + source (get before ref) + inherited (get model ref) + inherited (cond-> inherited + (:geometry-group inherited) + (update :geometry-group in-orientation source original))] + (if (or (nil? ref) (contains? seen ref) (= ref new-ref)) + groups + (recur source + (merge (if swapped? + (select-keys inherited + (filter #(or (#{:geometry-group :constraints-group} %) + (.startsWith (name %) "layout-item-")) + (keys inherited))) + inherited) groups) + (conj seen ref))))))) (visit [result id] (let [shape (get after id) - groups (when (:shape-ref shape) (inherit (get before id))) + groups (when (:shape-ref shape) + (cond + (= (:shape-ref shape) (:shape-ref (get before id))) + (get model id) + (and (contains? (get model id) :swap-group) + (= (:component-id shape) + (:component-id (get after (:shape-ref shape))))) + ;; Leaving the enclosing copy makes this a plain + ;; copy of its own main, without position inheritance. + (dissoc (get model id) :swap-group) + :else (inherit (get before id)))) result (cond-> (dissoc result id) (seq groups) (assoc id groups))] (reduce visit result (:shapes (get before id)))))] (visit model root-id))) @@ -121,13 +162,93 @@ (defn update-main "Pushing overrides into a nested copy in a main makes them authored at that level. Ordinary main shapes do not have touched groups." - [model objects root-id] - (letfn [(push [result id] - (let [shape (get objects id) - ref (get objects (:shape-ref shape)) + [model before after root-id] + (letfn [(content-overrides [shape reference] + (when reference + (let [content-groups (remove #(or (#{:geometry-group :constraints-group} %) + (.startsWith (name %) "layout-item-")) + (keys attributes)) + groups (into {} + (keep (fn [group] + (let [expected (values reference group)] + (when-not (same-value? shape group expected) + [group (values shape group)])))) + content-groups) + units (when (= :text (:type shape)) + (thtx/override-units (:content reference) (:content shape)))] + (reduce (fn [groups [group unit]] + (cond-> groups + (contains? units unit) + (assoc group {:content (:content shape)}))) + (cond-> groups (seq units) (assoc :content-group {})) + text-groups)))) + (push [result id content?] + (let [shape (get before id) + updated (get after id) + ref (get after (:shape-ref updated)) groups (get model id) + content-main (get before (:shape-ref ref)) + pushed-swap? (and content-main (contains? groups :swap-group) + (= (:component-id ref) (:component-id content-main))) + content? (or content? pushed-swap?) + groups (merge (when (and content? + (= (:component-id ref) (:component-id content-main))) + (content-overrides shape content-main)) + groups) + root (get before root-id) + main-root (get before (:shape-ref root)) + geometry (cond-> (values shape :geometry-group) + (get-in model [(:id main-root) :geometry-group]) + (in-orientation root main-root)) + expected (when pushed-swap? + (in-orientation (values content-main :geometry-group) + (get before (:parent-id content-main)) + (get after (:parent-id ref)))) + groups (cond-> groups + (and pushed-swap? + (not (same-value? geometry :geometry-group + (select-keys expected [:rotation :flip-x :flip-y])))) + (assoc :geometry-group geometry)) result (cond-> (dissoc result id) + (and (contains? groups :swap-group) + (not= (:component-id updated) (:component-id ref))) + (assoc id {:swap-group {}}) (and (:shape-ref ref) (seq groups)) - (update (:id ref) merge groups))] - (reduce push result (:shapes shape))))] - (push model root-id))) + (update (:id ref) merge + (cond-> groups + (= (:component-id ref) + (:component-id (get after (:shape-ref ref)))) + (dissoc :swap-group))))] + (reduce #(push %1 %2 content?) result (:shapes shape))))] + (push model root-id false))) + +(defn parent-orientation + "Authored child geometry moves with its parent's orientation. Compare the + parent frames, without reading the child's resulting geometry." + [model before after] + (reduce-kv + (fn [result id groups] + (let [old (get before id) + shape (get after id) + parent (get before (:parent-id old)) + new-parent (get after (:parent-id shape))] + (if (and (:geometry-group groups) old shape + (= (:parent-id old) (:parent-id shape)) parent new-parent) + (update-in result [id :geometry-group] in-orientation parent new-parent) + result))) + model model)) + +(defn settle + "A detached position turns its copies into ordinary shapes. Their former + component choice stops applying; other authored groups stay in place." + [model objects] + (reduce-kv + (fn [result id groups] + (let [shape (get objects id) + position (get objects (:shape-ref shape)) + ordinary? (and shape position + (nil? (:component-id shape)) + (nil? (:component-id position))) + groups (cond-> groups ordinary? (dissoc :swap-group))] + (if (seq groups) (assoc result id groups) (dissoc result id)))) + model model)) diff --git a/common/test/common_tests/logic/composable_gen_test.cljc b/common/test/common_tests/logic/composable_gen_test.cljc index ba06f0d341..6f0377c55c 100644 --- a/common/test/common_tests/logic/composable_gen_test.cljc +++ b/common/test/common_tests/logic/composable_gen_test.cljc @@ -7,6 +7,7 @@ (ns common-tests.logic.composable-gen-test (:require [app.common.schema.test :as smt] + [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] @@ -104,14 +105,132 @@ (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)))))) + (let [situation (cg/world [3 0]) + mains (take 2 (filter #(get-in % [:shape :main-instance]) (cg/tree situation))) + pending (reduce (fn [s entry] + (r/step s (n/change-property + (constantly (get-in entry [:shape :id])) :fills "#ff0000"))) + situation mains) + ids (r/pending-components pending) + partial (r/settle pending [(first (sort-by str ids))]) + settled (r/settle partial)] + (t/is (= 2 (count ids))) + (t/is (= 1 (count (r/pending-components partial)))) + (t/is (empty? (r/pending-components settled))))) + +(t/deftest detach-from-a-deleted-near-main-inherits-only-the-skipped-level + (t/is (map? (cg/run {:world [3 2 {:text true :layout nil}] + :ops [[: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] [:delete 1 0 0 0] + [:detach 13 0 0 0]]})))) + +(t/deftest undo-after-a-main-child-add-and-root-rotation-is-fully-settled + (t/is (map? (cg/run {:world [2 0 {:text true :layout nil}] + :ops [[: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] [:undo 0 0 0 0] + [:add-child 17 0 0 0] [:rotate 19 0 0 0] [:undo 0 0 0 0]]})))) + +(t/deftest minimum-size-transform-sync-shrunk-regression + (t/is (map? (cg/run {:world [2 0 {:text true :layout nil}] + :ops [[: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] [:rotate 2 0 0 0] + [:resize 7 0 0 0] [:duplicate 0 0 0 0]]})))) + +(t/deftest updating-a-rotated-outer-main-composes-authored-child-orientation + (t/is (map? (cg/run {:world [2 0 {:text true :layout nil}] + :ops [[: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] [:duplicate 0 0 0 0] + [:resize 0 0 0 0] [:nest 0 0 0 0] [:rotate 4 0 0 0] + [:detach 3 0 0 0] [:update-main 4 0 0 0]]})))) + +(t/deftest minimum-size-parent-orientation-does-not-amplify-oracle-drift + (t/is (map? (cg/run {:world [3 0 {:text true :layout nil}] + :ops [[: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] [:edit 4 0 0 0] + [:duplicate 3 0 0 0] [:nest 10 0 0 0] [:rotate 9 0 0 0] + [:update-main 7 0 0 0] [:delete 0 0 0 0]]})))) + +(t/deftest detaching-a-rotated-parent-inherits-geometry-in-the-copy-frame + (t/is (map? (cg/run {:world [2 0 {:text true :layout nil}] + :ops [[: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] [:resize 0 0 0 0] + [:delete 0 0 0 0] [:rotate 0 0 0 0] [:detach 0 0 0 0]]})))) + +(t/deftest detach-shadow-does-not-read-the-resulting-child-orientation + (let [before {:root {:id :root :shapes [:child]} + :child {:id :child :shape-ref :near :rotation 90} + :near {:id :near :shape-ref :main :rotation 45}} + after {:root {:id :root :shapes [:child]} + :child {:id :child :shape-ref :main :rotation 0}} + model (shadow/detach {:near {:geometry-group {:rotation 45}}} before after :root)] + (t/is (= 90 (get-in model [:child :geometry-group :rotation]))) + (t/is (false? (shadow/same-value? (:child after) :geometry-group + (get-in model [:child :geometry-group])))))) + +(t/deftest pushed-swap-shadow-predicts-orientation-without-reading-the-new-child + (let [before {:copy {:id :copy :shape-ref :main :shapes [:child]} + :main {:id :main :rotation 0} + :child {:id :child :parent-id :copy :component-id :target :shape-ref :position + :rotation 45 :width 100 :height 100} + :target-main {:id :target-main :component-id :target :parent-id :page + :rotation 0 :width 100 :height 100} + :page {:id :page :rotation 0}} + after (-> before + (assoc-in [:child :shape-ref] :pushed) + (assoc :pushed (assoc (:target-main before) :id :pushed + :shape-ref :target-main :parent-id :main))) + model (shadow/update-main {:child {:swap-group {}}} before after :copy) + expected (get-in model [:pushed :geometry-group])] + (t/is (= 45 (:rotation expected))) + (t/is (false? (shadow/same-value? (:pushed after) :geometry-group expected))))) + +(t/deftest pushed-swap-shadow-predicts-content-without-reading-the-new-child + (let [before {:copy {:id :copy :shape-ref :main :shapes [:child]} + :main {:id :main} + :child {:id :child :parent-id :copy :component-id :target + :fills [:white] :shapes [:leaf]} + :leaf {:id :leaf :parent-id :child :fills [:white]} + :position {:id :position :component-id :target :fills [:green]} + :position-leaf {:id :position-leaf :fills [:green]}} + after (-> before + (assoc-in [:child :shape-ref] :pushed) + (assoc-in [:leaf :shape-ref] :pushed-leaf) + (assoc :pushed {:id :pushed :component-id :target :shape-ref :position + :parent-id :main :fills [:green]} + :pushed-leaf {:id :pushed-leaf :shape-ref :position-leaf + :parent-id :pushed :fills [:green]})) + model (shadow/update-main {:child {:swap-group {}}} before after :copy)] + (doseq [id [:pushed :pushed-leaf]] + (t/is (= {:fills [:white] :hide-fill-on-export nil} (get-in model [id :fill-group]))) + (t/is (false? (shadow/same-value? (get after id) :fill-group (get-in model [id :fill-group]))))))) + +(t/deftest resizing-a-copy-of-an-empty-flex-main-keeps-layout-valid + (t/is (map? (cg/run {:world [3 0 {:text true :layout :flex}] + :ops [[: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] [:remove-child 0 0 0 0] + [:reset 0 0 0 0] [:nest 0 0 0 0] [:resize 0 0 0 0]]})))) + +(t/deftest updating-a-main-after-successive-rotations-keeps-child-flips + (t/is (map? (cg/run {:world [2 1] + :ops [[:rotate 1 0 0 0] [:rotate 1 0 1 0] [:update-main 0 0 0 0]]})))) diff --git a/common/test/common_tests/logic/composable_oracle_test.cljc b/common/test/common_tests/logic/composable_oracle_test.cljc index 38eb4f12f0..30751fd5ff 100644 --- a/common/test/common_tests/logic/composable_oracle_test.cljc +++ b/common/test/common_tests/logic/composable_oracle_test.cljc @@ -7,6 +7,7 @@ (ns common-tests.logic.composable-oracle-test (:require [app.common.files.changes-builder :as pcb] + [app.common.geom.matrix :as gmt] [app.common.geom.modifiers :as gm] [app.common.geom.point :as gpt] [app.common.geom.shapes :as gsh] @@ -14,6 +15,8 @@ [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.composable.gen :as cg] [app.common.test-helpers.composable.oracle :as oracle] @@ -27,6 +30,65 @@ (t/use-fixtures :each thi/test-fixture) +(t/deftest sync-equivalence-compares-geometry-without-rounding-boundaries + (let [initial (setup/simple-component-with-labeled-copy) + file (ths/update-shape (tm/file initial) :copy-child :opacity 0.4) + page (thf/current-page file) + id (thi/id :copy-child) + move-to (fn [x] + (tm/with-file initial + (thf/apply-changes file + (cls/generate-update-shapes + (pcb/empty-changes nil (:id page)) [id] + #(gsh/absolute-move % (gpt/point x (:y %))) + (:objects page) {:ignore-touched true})))) + before (move-to (- 103.005 1e-6)) + after (move-to (+ 103.005 1e-6)) + scalar (tm/with-file before + (assoc-in (tm/file before) [:data :pages-index (:id page) :objects id :opacity] + (+ 0.4 1e-15)))] + (t/is (oracle/equivalent? (oracle/canonical before) (oracle/canonical after))) + (t/is (not (oracle/equivalent? (oracle/canonical before) (oracle/canonical (move-to 101))))) + (t/is (not (oracle/equivalent? (oracle/canonical before) (oracle/canonical scalar)))))) + +(t/deftest swapped-head-inputs-follow-position-and-content-separately + (let [file (-> (thf/sample-file :file1) + (tho/add-simple-component :a :a-root :a-child + :root-params {:height 120}) + (tho/add-simple-component :b :b-root :b-child + :root-params {:constraints-h :right + :layout-item-margin {:m1 9 :m2 9 :m3 9 :m4 9}}) + (tho/add-frame :outer-root) + (thc/instantiate-component :a :position :parent-label :outer-root) + (thc/make-component :outer :outer-root) + (thc/instantiate-component :outer :copy-root :children-labels [:swapped]) + (tho/swap-component-in-shape :swapped :b)) + file (:file (r/sync-cascade file {} (keys (get-in file [:data :components])))) + model {(thi/id :swapped) {:swap-group {}}}] + (t/is (nil? (oracle/check (tm/make-situation file) model))) + (doseq [[attr value] [[:width 101] + [:constraints-h :right] + [:layout-item-margin {:m1 9 :m2 9 :m3 9 :m4 9}]]] + (let [broken (assoc-in file [:data :pages-index (:id (thf/current-page file)) + :objects (thi/id :swapped) attr] value)] + (t/is (thrown? #?(:clj Exception :cljs js/Error) + (oracle/check (tm/make-situation broken) model))))))) + +(t/deftest detached-rotated-parent-still-places-its-nested-copy + (let [detached (-> (setup/nested-component-with-copy) + (r/step (n/rotate :copy-root 90)) r/settle + (r/step (n/detach :copy-root)) r/settle)] + (t/is (nil? (oracle/check detached {}))) + (let [file (tm/file detached) + page (thf/current-page file) + head (tm/role-shape detached :copy-instance) + id (first (:shapes head)) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) [id] + #(gsh/move % (gpt/point 10 0)) (:objects page) + {:ignore-touched true}) + broken (tm/with-file detached (thf/apply-changes file changes))] + (t/is (thrown? #?(:clj Exception :cljs js/Error) (oracle/check broken {})))))) + (defn- sample [constraint] (-> (thf/sample-file :file1) @@ -156,5 +218,129 @@ (: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)))))) + (t/is (oracle/equivalent? (oracle/canonical situation) (oracle/canonical (tm/with-file situation moved)))) + (t/is (not (oracle/equivalent? (oracle/canonical situation) (oracle/canonical (tm/with-file situation bad))))))) + +(t/deftest sync-comparison-treats-nil-and-false-flips-as-the-same-orientation + (let [situation (setup/simple-component-with-labeled-copy) + file (tm/file situation) + unflipped (tm/with-file situation (-> file (ths/update-shape :copy-root :flip-x false) (ths/update-shape :copy-root :touched nil))) + flipped (tm/with-file situation (-> file (ths/update-shape :copy-root :flip-x true) (ths/update-shape :copy-root :touched nil)))] + (t/is (oracle/equivalent? (oracle/canonical situation) (oracle/canonical unflipped))) + (t/is (not (oracle/equivalent? (oracle/canonical situation) (oracle/canonical flipped)))))) + +(t/deftest expected-child-flips-follow-successive-parent-rotations + (let [file (sample :left) + turn (fn [file] + (let [root (ths/get-shape file :copy-root)] + (transform file :copy-root + (ctm/rotation-modifiers root (gsh/shape->center root) 45)))) + rotated (-> file turn turn)] + (t/is (nil? (oracle/check (tm/make-situation rotated) (geometry-model rotated)))))) + +(t/deftest backend-comparison-allows-instance-placement-after-leaving-a-layout + (let [situation (setup/nested-component-with-copy) + file (tm/file situation) + page (thf/current-page file) + head (tm/resolve-shape situation :main2-nested-head) + child (first (:shapes head)) + move (fn [ids] + (thf/apply-changes + file (cls/generate-update-shapes + (pcb/empty-changes nil (:id page)) ids + #(gsh/move % (gpt/point 30 0)) (:objects page) + {:ignore-touched true}))) + placed (tm/with-file situation (move [(:id head) child])) + broken (tm/with-file situation (move [child]))] + (t/is (oracle/equivalent? (oracle/canonical situation) (oracle/canonical placed))) + (t/is (not (oracle/equivalent? (oracle/canonical situation) (oracle/canonical broken)))))) + +(t/deftest nested-copy-child-flips-follow-successive-root-rotations + (let [result (cg/run + {:world [2 1 {:text true}] + :ops [[: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] + [:rotate 4 0 0 0] [:rotate 4 0 5 0]]})] + (t/is (= 3 (get-in result [:executed :rotate]))))) + +(t/deftest matrix-equivalence-measures-its-effect-in-pixels + (doseq [[size equal?] [[0.01 true] [100 false]]] + (let [file (-> (thf/sample-file :file1) + (tho/add-rect :rect {:width size :height size})) + changed (ths/update-shape file :rect :transform (gmt/matrix 0.8 0 0 1 0 0))] + (t/is (= equal? (oracle/equivalent? (oracle/canonical (tm/make-situation file)) + (oracle/canonical (tm/make-situation changed)))))))) + +(t/deftest authored-child-orientation-follows-the-parent-frame + (let [model {:child {:geometry-group {:width 20 :height 30 :rotation 90}}} + before {:child {:id :child :parent-id :parent} + :parent {:rotation 45}} + after {:child {:id :child :parent-id :parent :rotation 0 :width 20 :height 30} + :parent {:rotation 135 :flip-x true :flip-y true}} + expected (get-in (shadow/parent-orientation model before after) [:child :geometry-group])] + (t/is (= {:width 20 :height 30 :rotation 180 :flip-x true :flip-y true} expected)) + (t/is (not (shadow/same-value? (:child after) :geometry-group expected))))) + +(t/deftest swapped-head-content-size-keeps-the-position-anchor-after-rotation + (let [file (-> (thf/sample-file :file1) + (tho/add-simple-component :a :a-root :a-child + :root-params {:width 100 :height 100} + :child-params {:width 20 :height 20}) + (tho/add-simple-component :b :b-root :b-child + :root-params {:width 100 :height 100} + :child-params {:width 20 :height 20}) + (tho/add-frame :outer-root) + (thc/instantiate-component :a :position :parent-label :outer-root)) + root (ths/get-shape file :position) + file (transform file :position (ctm/change-size root 100 80)) + root (ths/get-shape file :position) + file (-> (transform file :position + (ctm/rotation-modifiers root (gsh/shape->center root) 45)) + (thc/make-component :outer :outer-root) + (thc/instantiate-component :outer :copy-root :children-labels [:swapped]) + (tho/swap-component-in-shape :swapped :b)) + file (:file (r/sync-cascade file {} (keys (get-in file [:data :components])))) + model {(thi/id :position) {:geometry-group (shadow/values (ths/get-shape file :position) + :geometry-group)} + (thi/id :swapped) {:swap-group {}}} + page (thf/current-page file) + moved (thf/apply-changes + file (cls/generate-update-shapes + (pcb/empty-changes nil (:id page)) [(thi/id :swapped)] + #(gsh/move % (gpt/point 5 0)) (:objects page) + {:ignore-touched true}))] + (t/is (nil? (oracle/check (tm/make-situation file) model))) + (t/is (thrown? #?(:clj Exception :cljs js/Error) + (oracle/check (tm/make-situation moved) model))))) + +(t/deftest minimum-size-parent-placement-keeps-fixed-child-dimensions + (let [result (cg/run {:world [2 0 {:text true :layout nil}] + :ops [[: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] [:resize 1 0 0 0] + [:nest 2 0 0 0] [:rotate 0 0 3 0] [:edit 0 0 0 0]]}) + situation (:situation result) + file (tm/file situation) + page (thf/current-page file) + objects (:objects page) + child (first (filter (fn [shape] + (let [parent (get objects (:parent-id shape))] + (and (= :frame (:type shape)) (:component-id shape) + (:shape-ref shape) (empty? (:touched shape)) + (> (:height shape) 1) + (< (:width parent 1) 0.02) + (< (:height parent 1) 0.02)))) + (vals objects))) + changed (thf/apply-changes + file (cls/generate-update-shapes + (pcb/empty-changes nil (:id page)) [(:id child)] + #(gsh/transform-shape % (ctm/change-size % nil 80)) objects + {:ignore-touched true}))] + (t/is (some? child)) + (t/is (thrown? #?(:clj Exception :cljs js/Error) + (oracle/check (tm/with-file situation changed) (:model result)))))) diff --git a/common/test/common_tests/logic/golden_master.edn b/common/test/common_tests/logic/golden_master.edn index 2e910129aa..f533ff41a3 100644 --- a/common/test/common_tests/logic/golden_master.edn +++ b/common/test/common_tests/logic/golden_master.edn @@ -9,33 +9,33 @@ :ac [{:transcript "create-component {:component \"main\", :id sync-main-component}\n make-variant-container {:variant-set \"vset\", :variant-id sync-vset-vcontainer, :count 3}\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 :opacity, :value 0.5}\n switch-variant {:target-component sync-vset-v1-component}\n test\n switch-variant {:target-component sync-vset-v2-component}", :states ["9f9ac61ce3c54295" "5bd9620c6fc0cf6e" "387d74ba1c75e52f" "62a55a068ebd6043"]}] :ad - [{:transcript "create-component {:component \"main\", :id sync-main-component}\n create-component {:component \"s1\", :id sync-s1-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 swap-shape {:target #fn, :to \"s1\", :keep-touched? false}\n update-main {:target #fn, :updated sync-main-copyhead-0}", :states ["9835a328e680007d" "8cc31cfc15b28449" "40c27ac7ca18d972"]}] + [{:transcript "create-component {:component \"main\", :id sync-main-component}\n create-component {:component \"s1\", :id sync-s1-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 swap-shape {:target #fn, :to \"s1\", :keep-touched? false}\n update-main {:target #fn, :updated sync-main-copyhead-0}", :states ["9835a328e680007d" "cb25044bb21b9f5f" "f1af8a77852eb4e6"]}] :ae [{:transcript "create-component {:component \"main\", :id sync-main-component}\n create-component {:component \"s1\", :id sync-s1-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 swap-shape {:target #fn, :to \"s1\", :keep-touched? false}\n detach {:target #fn, :detached sync-main-copyhead-0}\n change-property {:target #fn, :property :fills, :value \"#0000ff\"}", :states ["a3cb16fb335bc929" "e10b1283627fbeb6" "9be2dfab826240b0" "1fc0e554196dfd2b"]} {:transcript "create-component {:component \"main\", :id sync-main-component}\n create-component {:component \"s1\", :id sync-s1-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 swap-shape {:target #fn, :to \"s1\", :keep-touched? false}\n duplicate {:target #fn, :new-head 5}\n change-property {:target #fn, :property :fills, :value \"#0000ff\"}", :states ["a3cb16fb335bc929" "d291c9ca9a187b1b" "99bb6214c05d575b" "0694d4feab3cb633"]}] :af - [{:transcript "swap-shape {:target :copy-child, :to :other, :keep-touched? false}\n move-to-cell {:board :main-root, :child :main-child, :row 1, :column 3}", :states ["a3bca1c5fd1b9b29" "8a7fa3fde8815d35" "1c786eab89f462da"]} + [{:transcript "swap-shape {:target :copy-child, :to :other, :keep-touched? false}\n move-to-cell {:board :main-root, :child :main-child, :row 1, :column 3}", :states ["0ad5feb2c5e1a843" "18e4f6517178625b" "8247989d981f28b1"]} {:transcript "one-of -> skip\n move-to-cell {:board :main-root, :child :main-child, :row 1, :column 3}", :states ["7f1f0be7424c543f" "7c9214e430ebb4e8"]}] :ag - [{:transcript "move-child {:target :sub1, :parent :row-root, :to-index 3}", :states ["7a572aaeec3d11c7" "e6899eae694479cf"]}] + [{:transcript "move-child {:target :sub1, :parent :row-root, :to-index 3}", :states ["b8a611163a069cb1" "7b199a2abaca4eeb"]}] :ah - [{:transcript "one-of -> recorded-choice\n remove-child {:target :copy-sub1}", :states ["3043434871927bf0" "c5acd77993218759"]} - {:transcript "one-of -> recorded-choice\n remove-child {:target :copy-sub3}", :states ["fdddae12ff7dd7bc" "7efeb83144959be0"]} + [{:transcript "one-of -> recorded-choice\n remove-child {:target :copy-sub1}", :states ["905c9c7c79ff0526" "ba4df35defa20eb6"]} + {:transcript "one-of -> recorded-choice\n remove-child {:target :copy-sub3}", :states ["fd51d05632c0d55f" "53fc16ef85447d0d"]} {:transcript "one-of -> skip", :states ["4f8193bba8dbfb0d"]}] :ak - [{:transcript "swap-shape {:target :copy-child, :to :other, :keep-touched? false}\n move-to-cell {:board :copy-root, :child :copy-child, :row 1, :column 3}\n update-main {:target :copy-root, :updated grid-copy}", :states ["a3bca1c5fd1b9b29" "246adb9d062e7222" "725449d340ed4db7" "f7c72fa8b8699165"]} + [{:transcript "swap-shape {:target :copy-child, :to :other, :keep-touched? false}\n move-to-cell {:board :copy-root, :child :copy-child, :row 1, :column 3}\n update-main {:target :copy-root, :updated grid-copy}", :states ["0ad5feb2c5e1a843" "7e8833e5c26e234c" "2508613c0f17bc78" "f3d331b4689dd5b9"]} {:transcript "one-of -> skip\n move-to-cell {:board :copy-root, :child :copy-child, :row 1, :column 3}\n update-main {:target :copy-root, :updated grid-copy}", :states ["8aad596633c254fd" "7f1f0be7424c543f" "7c9214e430ebb4e8"]}] :al [{:transcript "change-property {:target :copy-root, :property :name, :value \"Renamed\"}\n change-property {:target :copy-instance, :property :name, :value \"Renamed child\"}\n update-main {:target :copy-root, :updated copy-root}", :states ["0166c98332ed23a7" "6c0c864aa286702f" "af3a00259b74b965" "6598d0729b91c5b3"]} {:transcript "change-property {:target :copy-root, :property :name, :value \"Renamed\"}\n one-of -> skip\n update-main {:target :copy-root, :updated copy-root}", :states ["0166c98332ed23a7" "e2849db0c0174bfc" "c307f8e2c66e8760"]}] :am - [{:transcript "swap-shape {:target :copy-child, :to :other, :keep-touched? false}\n update-main {:target :copy-root, :updated grouped-copy}", :states ["606d0e9ed09ebe9b" "c96014db9eb364e5" "bc48794035d17e6f"]}] + [{:transcript "swap-shape {:target :copy-child, :to :other, :keep-touched? false}\n update-main {:target :copy-root, :updated grouped-copy}", :states ["606d0e9ed09ebe9b" "33ec8f9abaa1b8fb" "e5bd7388fa65520a"]}] :an [{:transcript "change-property {:target :copy-instance, :property :margin, :value 8}\n change-property {:target :main-instance, :property :margin, :value 20}", :states ["4087468201a18ebc" "04328fe58e252ee9" "85db26a4ea6a899c"]} {:transcript "change-property {:target :copy-instance, :property :margin, :value 8}\n reset-overrides {:target :copy-root, :reset copy-root}", :states ["4087468201a18ebc" "cb5b3ab586333d10" "054cd426071de8e3"]} {:transcript "change-property {:target :copy-instance, :property :margin, :value 8}\n update-main {:target :copy-root, :updated copy-root}", :states ["4087468201a18ebc" "6b82a7e5bb516fc5" "8d389cd790e5cfb3"]}] :ao - [{:transcript "nest-copy {:copy :loose, :into :main-root, :cell [1 3]}", :states ["6e7144142468b3ea" "16834d9e0ad8afe0"]}] + [{:transcript "nest-copy {:copy :loose, :into :main-root, :cell [1 3]}", :states ["83772bd7270eb3a4" "486bc870af39864a"]}] :ap [{:transcript "sync-from-library {:library-id library}", :states ["3550b8df58ff0c95" "3872af2b94d47dea"]}] :aq @@ -145,12 +145,12 @@ :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 rotate {:target :main-root, :angle 30}\n change-property {:target :main-instance, :property :fills, :value \"#ff0000\"}", :states ["8208735a98e4c105" "f848dd262a0a07aa" "85f71340f8c9c134" "884bb300d156bcb4"]} + {:transcript "rotate {:target :copy-root, :angle 45}\n rotate {:target :main-root, :angle 30}\n change-height {:target :main-instance, :value 80}", :states ["8208735a98e4c105" "f848dd262a0a07aa" "88df05dbfc4217c8" "6f3c4b7eb0e89f8a"]} {: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 rotate {:target :main-root, :angle 30}\n change-property {:target :main-instance, :property :fills, :value \"#ff0000\"}", :states ["39e2dadef951dca2" "0cc17d2b4ff02bb9" "b675e496913b617e"]} + {:transcript "one-of -> skip\n rotate {:target :main-root, :angle 30}\n change-height {:target :main-instance, :value 80}", :states ["39e2dadef951dca2" "789103afba436783" "78667a8353113f40"]} {: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 @@ -173,7 +173,7 @@ {: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"]}] + [{: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 ["d12dd9fffaf38df9" "54f0852878f4edfa" "8759a8056147e49e" "3680cabdfe765e60"]}] :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"]}] @@ -185,12 +185,12 @@ {: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 change-property {:target #fn, :property :fills, :value \"#00ff00\"}\n duplicate {:target #fn, :new-head 8}\n change-property {:target #fn, :property :fills, :value \"#0000ff\"}", :states ["083c91e86a50fc3d" "f5f413ad5bea79a4" "0dba6a2c453b5250" "9857e951ca902581"]} {: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 one-of -> skip\n duplicate {:target #fn, :new-head 8}\n change-property {:target #fn, :property :fills, :value \"#0000ff\"}", :states ["ba24e834d0767879" "e97c69b1e46ffe5d" "ec75501d3a176eef"]}] :u - [{:transcript "create-component {:component \"outer\", :id sync-outer-component}\n create-component {:component \"inner\", :id sync-inner-component}\n instantiate-copy {:component \"outer\", :copy sync-outer-copyhead-0}\n instantiate-copy {:component \"inner\", :copy sync-inner-copyhead-0}\n nest-copy {:copy #fn, :into #fn, :cell nil}\n change-property {:target #fn, :property :fills, :value \"#00ff00\"}\n change-property {:target #fn, :property :fills, :value \"#0000ff\"}", :states ["787b17b227cfdb85" "f6490ae9fe96d3e4" "bd40aa51b1ec2fd1" "53c3ace2c3feb6d6"]} - {:transcript "create-component {:component \"outer\", :id sync-outer-component}\n create-component {:component \"inner\", :id sync-inner-component}\n instantiate-copy {:component \"outer\", :copy sync-outer-copyhead-0}\n instantiate-copy {:component \"inner\", :copy sync-inner-copyhead-0}\n nest-copy {:copy #fn, :into #fn, :cell nil}\n one-of -> skip\n change-property {:target #fn, :property :fills, :value \"#0000ff\"}", :states ["787b17b227cfdb85" "e662ae31e1675fa9" "435dd5416b94f48f"]}] + [{:transcript "create-component {:component \"outer\", :id sync-outer-component}\n create-component {:component \"inner\", :id sync-inner-component}\n instantiate-copy {:component \"outer\", :copy sync-outer-copyhead-0}\n instantiate-copy {:component \"inner\", :copy sync-inner-copyhead-0}\n nest-copy {:copy #fn, :into #fn, :cell nil}\n change-property {:target #fn, :property :fills, :value \"#00ff00\"}\n change-property {:target #fn, :property :fills, :value \"#0000ff\"}", :states ["83fec2239ffc4df8" "2bf9ec1fdeaa05ed" "bd40aa51b1ec2fd1" "53c3ace2c3feb6d6"]} + {:transcript "create-component {:component \"outer\", :id sync-outer-component}\n create-component {:component \"inner\", :id sync-inner-component}\n instantiate-copy {:component \"outer\", :copy sync-outer-copyhead-0}\n instantiate-copy {:component \"inner\", :copy sync-inner-copyhead-0}\n nest-copy {:copy #fn, :into #fn, :cell nil}\n one-of -> skip\n change-property {:target #fn, :property :fills, :value \"#0000ff\"}", :states ["83fec2239ffc4df8" "e662ae31e1675fa9" "435dd5416b94f48f"]}] :v [{:transcript "change-property {:target :main-instance, :property :fills, :value \"#ff0000\"}\n undo {:undone 2}\n change-property {:target :main-instance, :property :fills, :value \"#00ff00\"}\n undo {:undone 2}\n redo {:redone 2}\n redo {:redone 0}", :states ["17b5ce28aeffa62e" "e60c9f0fd46b2310" "a30b6f606aaa5f66" "e60c9f0fd46b2310" "a30b6f606aaa5f66" "a30b6f606aaa5f66" "0ba2151cc54b42fa"]}] :variant-switch-into-copies - [{:transcript "variant-switch-into-copies", :states ["6cdf94ad6bd691c5"]}] + [{:transcript "variant-switch-into-copies", :states ["540f8ef5f8ad96ea"]}] :w [{:transcript "create-component {:component \"main\", :id sync-main-component}\n one-of -> skip\n instantiate-copy {:component \"main\", :copy sync-main-copyhead-0}\n change-property {:target #fn, :property :fills, :value \"#0000ff\"}\n change-property {:target #fn, :property :fills, :value \"#00ff00\"}\n change-property {:target #fn, :property :fills, :value \"#aaaaaa\"}\n undo {:undone 5}", :states ["f06eb554db2a667d" "906c1b936c948e9c" "8e29f79b473c1a0b" "0072b33980dae904" "bd57e8070d829bdf"]} {: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 change-property {:target #fn, :property :fills, :value \"#0000ff\"}\n change-property {:target #fn, :property :fills, :value \"#00ff00\"}\n change-property {:target #fn, :property :fills, :value \"#aaaaaa\"}\n undo {:undone 5}", :states ["1de51816cdc0ad11" "ed2117b3a6ae1dd6" "c90876e1b44d00fb" "a5b1e4b00086c891" "f09bed0fa67d711c"]} diff --git a/frontend/test/frontend_tests/composable_tests/generated_test.cljs b/frontend/test/frontend_tests/composable_tests/generated_test.cljs index 94a23ab82b..a8e7cd9274 100644 --- a/frontend/test/frontend_tests/composable_tests/generated_test.cljs +++ b/frontend/test/frontend_tests/composable_tests/generated_test.cljs @@ -11,22 +11,25 @@ [app.common.test-helpers.composable.gen :as cg] [app.common.test-helpers.composable.oracle :as oracle] [app.common.test-helpers.normalize :as thn] + [app.main.repo :as rp] + [beicon.v2.core :as rx] [cljs.test :as t :include-macros true] [clojure.test.check.generators :as gen] - [frontend-tests.composable-tests.interpreter :as ftm])) + [frontend-tests.composable-tests.interpreter :as ftm] + [frontend-tests.helpers.mock :as mock])) (t/use-fixtures :each ftm/fixture) (defn- live-model [state live] - (let [by-path (into {} (map (juxt :path (comp :id :shape))) (cg/tree live))] + (let [by-path (into {} (map (juxt :path (comp :id :shape))) (cg/forest 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))))) + (cg/forest (:situation state))))) -(defn ^:async replay +(defn- ^:async replay-program "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}] @@ -38,7 +41,7 @@ (if-let [tuple (first tuples)] (let [state (cg/settle-state state) pure (:situation state) - live (tm/with-file live (ftm/current-file)) + live (ftm/refresh-generated live) expected (cg/resolve-op pure (:model state) tuple) actual (if (#{:undo :redo} (first tuple)) expected @@ -48,7 +51,7 @@ (await (ftm/run-generated-step live op)) (do (t/is (= :settled (await (ftm/await-step [])))) - (tm/with-file live (ftm/current-file)))) + (ftm/refresh-generated live))) next-state (-> state (cg/step tuple) (cg/settle-state))] (when-not next-live (throw (ex-info "Frontend step failed" {:program program :tuple tuple}))) @@ -59,7 +62,7 @@ (ex-data error) error)))) (let [expected (oracle/canonical (:situation next-state)) actual (oracle/canonical next-live)] - (t/is (true? (= expected actual)) + (t/is (true? (oracle/equivalent? 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))) @@ -72,6 +75,12 @@ (t/is false (str (ex-message error) "\nReplay: " (pr-str program) "\nDetails: " (pr-str (ex-data error))))))) +(defn ^:async replay + [program] + (await (mock/with-mocks* {rp/cmd! mock/rpc-cmd-mock + rx/timer mock/timer-mock} + (await (replay-program program))))) + (t/deftest ^:async seeded-programs-replay-through-the-frontend (doseq [seed [712 20261022 1792691]] (await (replay (gen/generate cg/program-gen 20 seed))))) @@ -90,7 +99,67 @@ (t/deftest ^:async nesting-restores-default-constraints-on-undo (await (replay {:world [2 0] :ops [[:nest 0]]}))) +(t/deftest ^:async partial-sync-geometry-boundary-shrunk-regression + (await (replay {:world [2 0 {:text true :layout nil}] + :ops [[: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] [:undo 0 0 0 0] + [:add-child 17 0 0 0] [:rotate 19 0 0 0] [:undo 0 0 0 0]]}))) + (t/deftest ^:async update-main-after-nesting-a-rotated-text-copy-undoes-together (await (replay {:world [2 0 {:text true}] :ops [[:duplicate 0] [:nest 0] [:rotate 0 0 0] [:edit 0 0 1 1] [:update-main 0]]}))) + +(t/deftest ^:async updating-a-rotated-outer-main-composes-authored-child-orientation + (await (replay {:world [2 0 {:text true :layout nil}] + :ops [[: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] [:duplicate 0 0 0 0] + [:resize 0 0 0 0] [:nest 0 0 0 0] [:rotate 4 0 0 0] + [:detach 3 0 0 0] [:update-main 4 0 0 0]]}))) + +(t/deftest ^:async minimum-size-parent-orientation-does-not-amplify-oracle-drift + (await (replay {:world [3 0 {:text true :layout nil}] + :ops [[: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] [:edit 4 0 0 0] + [:duplicate 3 0 0 0] [:nest 10 0 0 0] [:rotate 9 0 0 0] + [:update-main 7 0 0 0] [:delete 0 0 0 0]]}))) + +(t/deftest ^:async detaching-a-rotated-parent-inherits-geometry-in-the-copy-frame + (await (replay {:world [2 0 {:text true :layout nil}] + :ops [[: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] [:resize 0 0 0 0] + [:delete 0 0 0 0] [:rotate 0 0 0 0] [:detach 0 0 0 0]]}))) + +(t/deftest ^:async resizing-a-copy-of-an-empty-flex-main-keeps-layout-valid + (await (replay {:world [3 0 {:text true :layout :flex}] + :ops [[: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] [:remove-child 0 0 0 0] + [:reset 0 0 0 0] [:nest 0 0 0 0] [:resize 0 0 0 0]]}))) + +(t/deftest ^:async updating-a-main-after-successive-rotations-keeps-child-flips + (await (replay {:world [2 1] + :ops [[:rotate 1 0 0 0] [:rotate 1 0 1 0] [:update-main 0 0 0 0]]}))) + +(t/deftest ^:async minimum-size-parent-placement-keeps-fixed-child-dimensions + (await (replay {:world [2 0 {:text true :layout nil}] + :ops [[: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] [:resize 1 0 0 0] + [:nest 2 0 0 0] [:rotate 0 0 3 0] [:edit 0 0 0 0]]}))) diff --git a/frontend/test/frontend_tests/composable_tests/golden_master.edn b/frontend/test/frontend_tests/composable_tests/golden_master.edn index 28410fcbda..2cfc29a69c 100644 --- a/frontend/test/frontend_tests/composable_tests/golden_master.edn +++ b/frontend/test/frontend_tests/composable_tests/golden_master.edn @@ -9,42 +9,42 @@ :case-ac-second-variant-switch-keeps-root-overrides [{:transcript " {:component \"main\", :id sync-main-component}\n {:variant-set \"vset\", :variant-id sync-vset-vcontainer, :count 3}\n {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n {:component \"main\", :copy sync-main-copyhead-0}\n {:target #fn, :property :opacity, :value 0.5}\n {:target #fn, :value \"b\", :pos 0}\n \n {:target #fn, :value \"c\", :pos 0}", :states ["45b82e3d3433e503" "6aa8b050f6e60b75" "2464ce46aa6d70f8" "83ae3b7689a44277"]}] :case-ad-update-main-pushes-a-swapped-child - [{:transcript " {:component \"main\", :id sync-main-component}\n {:component \"s1\", :id sync-s1-component}\n {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n {:component \"main\", :copy sync-main-copyhead-0}\n {:component \"main\", :copy sync-main-copyhead-1}\n {:target #fn, :to \"s1\", :keep-touched? false}\n {:target #fn}", :states ["70154c2f90c4d94f" "8276a39e105869aa" "60ae4e382dab157a"]}] + [{:transcript " {:component \"main\", :id sync-main-component}\n {:component \"s1\", :id sync-s1-component}\n {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n {:component \"main\", :copy sync-main-copyhead-0}\n {:component \"main\", :copy sync-main-copyhead-1}\n {:target #fn, :to \"s1\", :keep-touched? false}\n {:target #fn}", :states ["70154c2f90c4d94f" "81f3d357f0dbb315" "c88064e53602169d"]}] :case-ae-detached-or-duplicated-swapped-copy-follows-its-component [{:transcript " {:component \"main\", :id sync-main-component}\n {:component \"s1\", :id sync-s1-component}\n {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n {:component \"main\", :copy sync-main-copyhead-0}\n {:target #fn, :to \"s1\", :keep-touched? false}\n {:target #fn}\n {:target #fn, :property :fills, :value \"#0000ff\"}", :states ["d74e87bca3fa2eb9" "ffc66442a5849acd" "ad43d75dcc79bc8a" "6fa02ad66793cf43"]} {:transcript " {:component \"main\", :id sync-main-component}\n {:component \"s1\", :id sync-s1-component}\n {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n {:component \"main\", :copy sync-main-copyhead-0}\n {:target #fn, :to \"s1\", :keep-touched? false}\n {:target #fn, :new-head 5}\n {:target #fn, :property :fills, :value \"#0000ff\"}", :states ["d74e87bca3fa2eb9" "e2162174653b4d7b" "0bd8763796243e1f" "f612d9fde86170a6"]}] :case-af-grid-cell-move-reaches-a-swapped-copy-child - [{:transcript " {:target :copy-child, :to :other, :keep-touched? false}\n {:board :main-root, :child :main-child, :row 1, :column 3}", :states ["5a6af6c0b8f86ac3" "ec2294c072f52ae5" "8cd63b096547fb1b"]} + [{:transcript " {:target :copy-child, :to :other, :keep-touched? false}\n {:board :main-root, :child :main-child, :row 1, :column 3}", :states ["66a66d6e7afac79f" "72c68fd299002108" "a8170f02220c56c3"]} {:transcript "one-of -> \n {:board :main-root, :child :main-child, :row 1, :column 3}", :states ["ab911a9c4ef389ba" "56df09285036aa60"]}] :case-ag-main-reorder-keeps-copy-sub-heads-aligned - [{:transcript " {:target :sub1, :parent :row-root, :to-index 3}", :states ["8118be0e3d318e30" "e0d17dd4c27d652c"]}] + [{:transcript " {:target :sub1, :parent :row-root, :to-index 3}", :states ["598fcda0ce7604ff" "bf7fbe3947f98e57"]}] :case-ah-copy-sub-head-delete-keeps-others-aligned - [{:transcript "one-of -> \n {:target :copy-sub1}", :states ["fff37cf722a40e61" "6fd3a8bad999968b"]} - {:transcript "one-of -> \n {:target :copy-sub3}", :states ["efada86db353920f" "3364b0fbfb76067a"]} + [{:transcript "one-of -> \n {:target :copy-sub1}", :states ["8cce96ea845f126a" "9fbdeb6913ed25c7"]} + {:transcript "one-of -> \n {:target :copy-sub3}", :states ["2fb52dd7ebffcfc2" "6daed3c2ff997a9f"]} {:transcript "one-of -> ", :states ["94318a628db305b6"]}] :case-ai-copy-sub-head-delete-and-reflow-keeps-others-aligned - [{:transcript "one-of -> \n {:target :copy-sub1}\n {:target :copy-root, :value 500}", :states ["fff37cf722a40e61" "738377a4c5ef9f3c" "f23a64d143d27dba"]} - {:transcript "one-of -> \n {:target :copy-sub1}\n one-of -> ", :states ["fff37cf722a40e61" "6fd3a8bad999968b"]} - {:transcript "one-of -> \n {:target :copy-sub3}\n {:target :copy-root, :value 500}", :states ["efada86db353920f" "701edc575ad9c0c6" "91db7f5306659911"]} - {:transcript "one-of -> \n {:target :copy-sub3}\n one-of -> ", :states ["efada86db353920f" "3364b0fbfb76067a"]} + [{:transcript "one-of -> \n {:target :copy-sub1}\n {:target :copy-root, :value 500}", :states ["8cce96ea845f126a" "259a2b096309de89" "57cee31291838076"]} + {:transcript "one-of -> \n {:target :copy-sub1}\n one-of -> ", :states ["8cce96ea845f126a" "9fbdeb6913ed25c7"]} + {:transcript "one-of -> \n {:target :copy-sub3}\n {:target :copy-root, :value 500}", :states ["2fb52dd7ebffcfc2" "4a9ca18246cd24c1" "d7816b420adf634f"]} + {:transcript "one-of -> \n {:target :copy-sub3}\n one-of -> ", :states ["2fb52dd7ebffcfc2" "6daed3c2ff997a9f"]} {:transcript "one-of -> \n {:target :copy-root, :value 500}", :states ["000943ecb6c266ee" "209fe67d76a27a9b"]} {:transcript "one-of -> \n one-of -> ", :states ["94318a628db305b6"]}] :case-aj-reflow-after-nested-sync-is-its-own-undo-step - [{:transcript " {:component \"main\", :id sync-main-component}\n {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n {:component \"main\", :level 1, :outer sync-main-outercomp-1}\n {:component \"main\", :copy sync-main-copyhead-0}\n {:target #fn, :property :fills, :value \"#0000ff\"}\n {:times 1}", :states ["43ca8b2de80a51fc" "ce4ef0e73faa0894" "ef4de3d8d773582c"]}] + [{:transcript " {:component \"main\", :id sync-main-component}\n {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n {:component \"main\", :level 1, :outer sync-main-outercomp-1}\n {:component \"main\", :copy sync-main-copyhead-0}\n {:target #fn, :property :fills, :value \"#0000ff\"}\n {:times 1}", :states ["43ca8b2de80a51fc" "32b45a5ac7536b08" "7104bfaccd42cd3b"]}] :case-ak-update-main-pushes-grid-cells - [{:transcript " {:target :copy-child, :to :other, :keep-touched? false}\n {:board :copy-root, :child :copy-child, :row 1, :column 3}\n {:target :copy-root}", :states ["5a6af6c0b8f86ac3" "278e4ff6bf0df746" "322e03c88f4a9dce" "432cfef7e9b05164"]} + [{:transcript " {:target :copy-child, :to :other, :keep-touched? false}\n {:board :copy-root, :child :copy-child, :row 1, :column 3}\n {:target :copy-root}", :states ["66a66d6e7afac79f" "9865d4f8003e91ef" "2d73539548abb75a" "5e5d65dc5416e495"]} {:transcript "one-of -> \n {:board :copy-root, :child :copy-child, :row 1, :column 3}\n {:target :copy-root}", :states ["9900a7f71dc0cb5f" "de66ab1c3d4cc157" "6b5ff9fd86e2aa87"]}] :case-al-update-main-renames-the-component [{:transcript " {:target :copy-root, :property :name, :value \"Renamed\"}\n {:target :copy-instance, :property :name, :value \"Renamed child\"}\n {:target :copy-root}", :states ["d4e11348047fc1e1" "0021aeab497d25e6" "c0aa5a2b915a7542" "ca23729038aec91f"]} {:transcript " {:target :copy-root, :property :name, :value \"Renamed\"}\n one-of -> \n {:target :copy-root}", :states ["d4e11348047fc1e1" "409795fc3e2bb3a7" "6a882f51aaf16306"]}] :case-am-update-main-pushes-a-swap-inside-a-group - [{:transcript " {:target :copy-child, :to :other, :keep-touched? false}\n {:target :copy-root}", :states ["b4f808a12f723283" "9b695fc212f26b53" "32b94deb961ac763"]}] + [{:transcript " {:target :copy-child, :to :other, :keep-touched? false}\n {:target :copy-root}", :states ["b4f808a12f723283" "c9440f1c68655424" "1359aa054bd1a482"]}] :case-an-flex-child-override-follows-sync-reset-and-update-main [{:transcript " {:target :copy-instance, :property :margin, :value 8}\n {:target :main-instance, :property :margin, :value 20}", :states ["234a21e54bf21bd5" "03664b1e6eb74856" "820db97e4e4b43dc"]} {:transcript " {:target :copy-instance, :property :margin, :value 8}\n {:target :copy-root}", :states ["234a21e54bf21bd5" "1f92f9b4d2e50598" "a09954797e57930c"]} {:transcript " {:target :copy-instance, :property :margin, :value 8}\n {:target :copy-root}", :states ["234a21e54bf21bd5" "ace1e76c1da2c443" "8f3452e82544f1ad"]}] :case-ao-copy-dropped-into-a-grid-cell-reaches-copies - [{:transcript " {:copy :loose, :into :main-root, :cell [1 3]}", :states ["abc6e41a291516a2" "571d314ba8917a08"]}] + [{:transcript " {:copy :loose, :into :main-root, :cell [1 3]}", :states ["85c7e7a4f4289716" "a00c83f43550909b"]}] :case-ap-library-main-child-removal-reaches-copy [{:transcript "", :states ["dd877acf0d7f120c" "0a472792b8a73e33"]}] :case-aq-main-change-reaches-a-copy-nested-in-a-sub-board @@ -56,8 +56,8 @@ {:transcript " {:component \"main\", :id sync-main-component}\n {:variant-set \"vset\", :variant-id sync-vset-vcontainer, :count 2}\n {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n {:component \"main\", :copy sync-main-copyhead-0}\n {:target #fn, :property :fills, :value \"#00ff00\"}\n {:set-name \"vset\", :edit :add, :params {:property-name \"Size\", :property-value \"S\"}}\n {:set-name \"vset\", :edit :reorder, :params {:from 1, :to 0}}\n \n {:target #fn, :value \"b\", :pos #fn}", :states ["df9a042b9d59d699" "c4f5353ee8eb5822" "20e7c6031f77998f" "4b387428cd241ed1" "a79933f2d6da95da"]} {:transcript " {:component \"main\", :id sync-main-component}\n {:variant-set \"vset\", :variant-id sync-vset-vcontainer, :count 2}\n {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n {:component \"main\", :copy sync-main-copyhead-0}\n {:target #fn, :property :fills, :value \"#00ff00\"}\n {:set-name \"vset\", :edit :add, :params {:property-name \"Size\", :property-value \"S\"}}\n {:set-name \"vset\", :edit :remove, :params {:pos 1}}\n \n {:target #fn, :value \"b\", :pos #fn}", :states ["df9a042b9d59d699" "c4f5353ee8eb5822" "df9a042b9d59d699" "ef6a99bd8579dc7f" "3b1bfc8427b3963d"]}] :case-as-added-variant-is-a-switch-target - [{:transcript " {:component \"main\", :id sync-main-component}\n {:variant-set \"vset\", :variant-id sync-vset-vcontainer, :count 2}\n {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n {:component \"main\", :copy sync-main-copyhead-0}\n {:target #fn, :property :fills, :value \"#00ff00\"}\n {:set-name \"vset\", :member \"a\", :component-id 7}\n \n {:target #fn, :value \"Value 3\", :pos 0}", :states ["df9a042b9d59d699" "00b4a79bae39f703" "0b85fa1611096bb7" "542884fb9e0b082e"]} - {:transcript " {:component \"main\", :id sync-main-component}\n {:variant-set \"vset\", :variant-id sync-vset-vcontainer, :count 2}\n {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n {:component \"main\", :copy sync-main-copyhead-0}\n {:target #fn, :property :fills, :value \"#00ff00\"}\n {:set-name \"vset\", :member \"b\", :component-id 7}\n \n {:target #fn, :value \"Value 3\", :pos 0}", :states ["df9a042b9d59d699" "73a3942a18403d5e" "3234ee274caa5b9e" "0fd372565af6eae2"]}] + [{:transcript " {:component \"main\", :id sync-main-component}\n {:variant-set \"vset\", :variant-id sync-vset-vcontainer, :count 2}\n {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n {:component \"main\", :copy sync-main-copyhead-0}\n {:target #fn, :property :fills, :value \"#00ff00\"}\n {:set-name \"vset\", :member \"a\", :component-id 7}\n \n {:target #fn, :value \"Value 3\", :pos 0}", :states ["df9a042b9d59d699" "43d53bfb72ea0e0c" "6d809ac935ee93e1" "144104054c2e6b3e"]} + {:transcript " {:component \"main\", :id sync-main-component}\n {:variant-set \"vset\", :variant-id sync-vset-vcontainer, :count 2}\n {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n {:component \"main\", :copy sync-main-copyhead-0}\n {:target #fn, :property :fills, :value \"#00ff00\"}\n {:set-name \"vset\", :member \"b\", :component-id 7}\n \n {:target #fn, :value \"Value 3\", :pos 0}", :states ["df9a042b9d59d699" "06e9af57624cd581" "55609c964d15c784" "c8d9eaf05aa22931"]}] :case-at-transformed-component-keeps-its-copies [{:transcript " {:component \"main\", :id sync-main-component}\n {:component \"main\", :copy sync-main-copyhead-0}\n {:target #fn, :property :fills, :value \"#00ff00\"}\n {:target #fn, :property :name, :value \"Button/Primary\"}\n {:action :transform, :params {:target #fn}}\n \n {:target #fn, :value \"Value 2\", :pos 0}", :states ["19168567c3f4c8b1" "865741f5dae8819e" "7f7bd28b4563fabd" "bca3e99455b8e931" "f8be77e905b63d2f"]} {:transcript " {:component \"main\", :id sync-main-component}\n {:component \"main\", :copy sync-main-copyhead-0}\n {:target #fn, :property :fills, :value \"#00ff00\"}\n {:target #fn, :property :name, :value \"Button/Primary\"}\n {:action :shortcut, :params {:kind :create-component, :targets [#fn]}}\n \n {:target #fn, :value \"Value 2\", :pos 0}", :states ["19168567c3f4c8b1" "865741f5dae8819e" "7f7bd28b4563fabd" "bca3e99455b8e931" "f8be77e905b63d2f"]} @@ -72,10 +72,10 @@ :case-aw-variant-layer-rename-and-cleanup [{:transcript " {:component \"main\", :id sync-main-component}\n {:variant-set \"vset\", :variant-id sync-vset-vcontainer, :count 2}\n {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n {:component \"main\", :copy sync-main-copyhead-0}\n {:target #fn, :property :fills, :value \"#00ff00\"}\n {:set-name \"vset\", :edit :rename-in-layers, :params {:member \"a\", :name \"Property 1=a2, Size=S\"}}\n \n {:set-name \"vset\", :edit :add, :params {:property-name \"Empty\"}}\n {:set-name \"vset\", :edit :remove-empty, :params {}}\n \n {:set-name \"vset\", :edit :rename-in-layers, :params {:member \"a\", :name \"Property 1=a2\"}}\n \n {:set-name \"vset\", :edit :error, :params {:member \"b\", :value \"Duplicated\"}}\n \n {:set-name \"vset\", :edit :error, :params {:member \"b\", :value nil}}\n \n {:target #fn, :value \"b\", :pos 0}", :states ["df9a042b9d59d699" "4b53486972986d97" "8553f89a9ebbcf54" "4b53486972986d97" "d65ad905aa97d661" "6daf5f692c6595f1" "d65ad905aa97d661" "cc7d2dfd618fc8cb" "6390b4d83995b629"]}] :case-ax-variant-shortcuts-add-members - [{:transcript " {:component \"main\", :id sync-main-component}\n {:variant-set \"vset\", :variant-id sync-vset-vcontainer, :count 2}\n {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n {:component \"main\", :copy sync-main-copyhead-0}\n {:target #fn, :property :fills, :value \"#00ff00\"}\n {:action :shortcut, :params {:kind :create-component, :targets [#fn]}}", :states ["df9a042b9d59d699" "00b4a79bae39f703" "3c821d5f87436799"]} - {:transcript " {:component \"main\", :id sync-main-component}\n {:variant-set \"vset\", :variant-id sync-vset-vcontainer, :count 2}\n {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n {:component \"main\", :copy sync-main-copyhead-0}\n {:target #fn, :property :fills, :value \"#00ff00\"}\n {:action :shortcut, :params {:kind :duplicate, :targets [#fn]}}", :states ["df9a042b9d59d699" "00b4a79bae39f703" "3c821d5f87436799"]} - {:transcript " {:component \"main\", :id sync-main-component}\n {:variant-set \"vset\", :variant-id sync-vset-vcontainer, :count 2}\n {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n {:component \"main\", :copy sync-main-copyhead-0}\n {:target #fn, :property :fills, :value \"#00ff00\"}\n {:action :shortcut, :params {:kind :create-component, :targets [#fn #fn]}}", :states ["df9a042b9d59d699" "07e820c7c676a7b0" "d3972300b9f78ab6"]} - {:transcript " {:component \"main\", :id sync-main-component}\n {:variant-set \"vset\", :variant-id sync-vset-vcontainer, :count 2}\n {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n {:component \"main\", :copy sync-main-copyhead-0}\n {:target #fn, :property :fills, :value \"#00ff00\"}\n {:action :shortcut, :params {:kind :duplicate, :targets [#fn #fn]}}", :states ["df9a042b9d59d699" "07e820c7c676a7b0" "d3972300b9f78ab6"]}] + [{:transcript " {:component \"main\", :id sync-main-component}\n {:variant-set \"vset\", :variant-id sync-vset-vcontainer, :count 2}\n {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n {:component \"main\", :copy sync-main-copyhead-0}\n {:target #fn, :property :fills, :value \"#00ff00\"}\n {:action :shortcut, :params {:kind :create-component, :targets [#fn]}}", :states ["df9a042b9d59d699" "43d53bfb72ea0e0c" "419f9e2fc8ad2835"]} + {:transcript " {:component \"main\", :id sync-main-component}\n {:variant-set \"vset\", :variant-id sync-vset-vcontainer, :count 2}\n {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n {:component \"main\", :copy sync-main-copyhead-0}\n {:target #fn, :property :fills, :value \"#00ff00\"}\n {:action :shortcut, :params {:kind :duplicate, :targets [#fn]}}", :states ["df9a042b9d59d699" "43d53bfb72ea0e0c" "419f9e2fc8ad2835"]} + {:transcript " {:component \"main\", :id sync-main-component}\n {:variant-set \"vset\", :variant-id sync-vset-vcontainer, :count 2}\n {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n {:component \"main\", :copy sync-main-copyhead-0}\n {:target #fn, :property :fills, :value \"#00ff00\"}\n {:action :shortcut, :params {:kind :create-component, :targets [#fn #fn]}}", :states ["df9a042b9d59d699" "cc394d17823fd4a0" "c3e08163f30218ee"]} + {:transcript " {:component \"main\", :id sync-main-component}\n {:variant-set \"vset\", :variant-id sync-vset-vcontainer, :count 2}\n {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n {:component \"main\", :copy sync-main-copyhead-0}\n {:target #fn, :property :fills, :value \"#00ff00\"}\n {:action :shortcut, :params {:kind :duplicate, :targets [#fn #fn]}}", :states ["df9a042b9d59d699" "cc394d17823fd4a0" "c3e08163f30218ee"]}] :case-ay-token-on-a-main-reaches-copies [{:transcript " {:component \"main\", :id sync-main-component}\n {:tokens [[\"radius.s\" 10] [\"radius.l\" 30]]}\n {:component \"main\", :copy sync-main-copyhead-0}\n {:target #fn, :token-name \"radius.s\", :value 10}\n {:target #fn, :token-name \"radius.l\", :value 30}\n \n {:target #fn, :token-name \"radius.l\"}", :states ["04cddffca776d7fb" "24756b13d210f3a5" "8e8c0c21d4a0638b" "f656f9c8188793cf"]} {:transcript " {:component \"main\", :id sync-main-component}\n {:tokens [[\"radius.s\" 10] [\"radius.l\" 30]]}\n {:component \"main\", :copy sync-main-copyhead-0}\n {:target #fn, :token-name \"radius.s\", :value 10}\n {:target #fn, :token-name \"radius.l\", :value 30}\n \n one-of -> ", :states ["04cddffca776d7fb" "24756b13d210f3a5" "8fb17ce46cb883c8"]} @@ -156,12 +156,12 @@ {:transcript " {:component \"main\", :id sync-main-component}\n {:variant-set \"vset\", :variant-id sync-vset-vcontainer, :count 4}\n {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n {:component \"main\", :level 1, :outer sync-main-outercomp-1}\n {:component \"main\", :level 2, :outer sync-main-outercomp-2}\n one-of -> \n one-of -> \n {:target #fn, :value \"v3\", :pos 0}\n ", :states ["5465856887185ca0" "72519cdcd7188cc4"]} {:transcript " {:component \"main\", :id sync-main-component}\n {:variant-set \"vset\", :variant-id sync-vset-vcontainer, :count 4}\n {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n {:component \"main\", :level 1, :outer sync-main-outercomp-1}\n {:component \"main\", :level 2, :outer sync-main-outercomp-2}\n one-of -> \n one-of -> \n one-of -> \n ", :states ["0873995f173d4a79"]}] :case-n-geometry-sync-with-rotated-instances - [{:transcript " {:target :copy-root, :angle 45}\n {:target :main-root, :angle 30}\n {:target :main-instance, :property :fills, :value \"#ff0000\"}", :states ["ba9a6dac13f812ea" "53df1008cfc253d9" "5209d5674c0f3d7d" "25decd78552afde5"]} - {:transcript " {:target :copy-root, :angle 45}\n {:target :main-root, :angle 30}\n {:target :main-instance, :value 80}", :states ["ba9a6dac13f812ea" "53df1008cfc253d9" "3770b3227c55f2e3" "3e932096c59afd2a"]} + [{:transcript " {:target :copy-root, :angle 45}\n {:target :main-root, :angle 30}\n {:target :main-instance, :property :fills, :value \"#ff0000\"}", :states ["ba9a6dac13f812ea" "f3351f6d926b041a" "4a494df6989d018f" "7a9f7377eea04aef"]} + {:transcript " {:target :copy-root, :angle 45}\n {:target :main-root, :angle 30}\n {:target :main-instance, :value 80}", :states ["ba9a6dac13f812ea" "f3351f6d926b041a" "b491da7458174877" "a123c1e4ec60897d"]} {:transcript " {:target :copy-root, :angle 45}\n one-of -> \n {:target :main-instance, :property :fills, :value \"#ff0000\"}", :states ["ba9a6dac13f812ea" "65ca84670680dd3f" "a920758fa5650950"]} {:transcript " {:target :copy-root, :angle 45}\n one-of -> \n {:target :main-instance, :value 80}", :states ["ba9a6dac13f812ea" "3f78d8893ed44646" "ca1b4068acd4cf89"]} - {:transcript "one-of -> \n {:target :main-root, :angle 30}\n {:target :main-instance, :property :fills, :value \"#ff0000\"}", :states ["9209eb7b4ee877fa" "594405b1ecce4064" "9db6f380604b2fea"]} - {:transcript "one-of -> \n {:target :main-root, :angle 30}\n {:target :main-instance, :value 80}", :states ["9209eb7b4ee877fa" "1834062b7878fd2f" "85d3c98c7713553c"]} + {:transcript "one-of -> \n {:target :main-root, :angle 30}\n {:target :main-instance, :property :fills, :value \"#ff0000\"}", :states ["483eb80a902eacfa" "e114d8a40d0ce273" "bb70b68365bdacc0"]} + {:transcript "one-of -> \n {:target :main-root, :angle 30}\n {:target :main-instance, :value 80}", :states ["483eb80a902eacfa" "a7012e0e406ede72" "dcc10b7c43621315"]} {:transcript "one-of -> \n one-of -> \n {:target :main-instance, :property :fills, :value \"#ff0000\"}", :states ["386e940ac63af4b1" "8f48f693ee787fec"]} {:transcript "one-of -> \n one-of -> \n {:target :main-instance, :value 80}", :states ["a41078de929e10aa" "76237d559c31a37e"]}] :case-o-variant-switch-keeps-override-only-where-mains-agree @@ -194,10 +194,10 @@ {:transcript " {:component \"main\", :id sync-main-component}\n {:component \"main\", :level 1, :outer sync-main-outercomp-1}\n {:component \"main\", :level 1, :outer sync-main-outercomp-1}\n {:component \"main\", :copy sync-main-copyhead-0}\n {:target #fn, :property :fills, :value \"#00ff00\"}\n {:target #fn, :new-head 8}\n {:target #fn, :property :fills, :value \"#0000ff\"}", :states ["170f05fe60d794fb" "6f95a0e52dbc58f3" "1682e155232c79ff" "7e41824902c17537"]} {:transcript " {:component \"main\", :id sync-main-component}\n {:component \"main\", :level 1, :outer sync-main-outercomp-1}\n {:component \"main\", :level 1, :outer sync-main-outercomp-1}\n {:component \"main\", :copy sync-main-copyhead-0}\n one-of -> \n {:target #fn, :new-head 8}\n {:target #fn, :property :fills, :value \"#0000ff\"}", :states ["e09ce2b9958878d1" "59617d71e336c70c" "4f37d9c4a6c72163"]}] :case-touched-copy-child-survives-main-rotation - [{:transcript " {:target :copy-root, :angle 30}\n {:target :copy-instance, :value 60}\n \n {:target :main-root, :angle 40}", :states ["4b1199b00726b9ad" "cfb4d7484a4abd78" "f4cf8a16c5457089" "fa0aab3b284e3448"]}] + [{:transcript " {:target :copy-root, :angle 30}\n {:target :copy-instance, :value 60}\n \n {:target :main-root, :angle 40}", :states ["e5382e7917232740" "b210314af79df750" "81f04c0e4b057fdf" "c54b585f599dc249"]}] :case-u-nested-copy-reaches-copies-of-the-receiving-main - [{:transcript " {:component \"outer\", :id sync-outer-component}\n {:component \"inner\", :id sync-inner-component}\n {:component \"outer\", :copy sync-outer-copyhead-0}\n {:component \"inner\", :copy sync-inner-copyhead-0}\n {:copy #fn, :into #fn, :cell nil}\n {:target #fn, :property :fills, :value \"#00ff00\"}\n {:target #fn, :property :fills, :value \"#0000ff\"}", :states ["3e741919b165a9b1" "3b36d2a788757ccf" "13cc7bde13f6db61" "d2df2363c6fd87ee"]} - {:transcript " {:component \"outer\", :id sync-outer-component}\n {:component \"inner\", :id sync-inner-component}\n {:component \"outer\", :copy sync-outer-copyhead-0}\n {:component \"inner\", :copy sync-inner-copyhead-0}\n {:copy #fn, :into #fn, :cell nil}\n one-of -> \n {:target #fn, :property :fills, :value \"#0000ff\"}", :states ["3e741919b165a9b1" "20ac8b615e23f0ea" "3b80647668a43256"]}] + [{:transcript " {:component \"outer\", :id sync-outer-component}\n {:component \"inner\", :id sync-inner-component}\n {:component \"outer\", :copy sync-outer-copyhead-0}\n {:component \"inner\", :copy sync-inner-copyhead-0}\n {:copy #fn, :into #fn, :cell nil}\n {:target #fn, :property :fills, :value \"#00ff00\"}\n {:target #fn, :property :fills, :value \"#0000ff\"}", :states ["9e0426e1dc456cde" "eb401f2227844ad7" "13cc7bde13f6db61" "d2df2363c6fd87ee"]} + {:transcript " {:component \"outer\", :id sync-outer-component}\n {:component \"inner\", :id sync-inner-component}\n {:component \"outer\", :copy sync-outer-copyhead-0}\n {:component \"inner\", :copy sync-inner-copyhead-0}\n {:copy #fn, :into #fn, :cell nil}\n one-of -> \n {:target #fn, :property :fills, :value \"#0000ff\"}", :states ["9e0426e1dc456cde" "20ac8b615e23f0ea" "3b80647668a43256"]}] :case-v-new-edit-after-undo-clears-redo [{:transcript " {:target :main-instance, :property :fills, :value \"#ff0000\"}\n {:times 1}\n {:target :main-instance, :property :fills, :value \"#00ff00\"}\n {:times 1}\n \n ", :states ["386e940ac63af4b1" "734ec524d88eb6bb" "2249ff7b4eb6cdaa" "734ec524d88eb6bb" "2249ff7b4eb6cdaa" "2249ff7b4eb6cdaa" "fb1a35e87f575e72"]}] :case-w-undo-several-steps-over-nested-cascades diff --git a/frontend/test/frontend_tests/composable_tests/interpreter.cljs b/frontend/test/frontend_tests/composable_tests/interpreter.cljs index 62757b048c..bf9f8b6f5f 100644 --- a/frontend/test/frontend_tests/composable_tests/interpreter.cljs +++ b/frontend/test/frontend_tests/composable_tests/interpreter.cljs @@ -256,8 +256,9 @@ [(dwl/reset-component (tm/target-shape-id situation (:target op)))] (instance? n/UpdateMain op) - [(dwl/update-component-sync (tm/target-shape-id situation (:target op)) - (:id (tm/file situation)))] + (let [id (tm/target-shape-id situation (:target op)) + shape (tm/shape-by-id situation id)] + [(dwl/update-component-sync id (:component-file shape))]) (instance? n/DeleteComponent op) (let [root (tm/shape-by-id situation (tm/target-shape-id situation (:target op)))] @@ -358,6 +359,17 @@ (let [st @st/state] (get-in st [:files (:current-file-id st)]))) +(defn refresh-generated + "Read every generated file after events, keeping library installation + metadata out of the portable situation. Undo snapshots still keep it." + [situation] + (-> situation + (tm/with-file (current-file)) + (tm/with-aux-files + (into {} (map (fn [[id _]] + [id (dissoc (get-in @st/state [:files id]) :library-of)])) + (tm/aux-files situation))))) + (defn fill-of "The fill color of the shape `label` in the live file." [label] @@ -440,7 +452,7 @@ [situation op] (let [before (tm/file situation) situation (-> situation - (tm/with-file (current-file)) + (refresh-generated) (record-choices op)) op (unwrap op) record (cond-> (dissoc (into {} op) ::tm/id) @@ -707,7 +719,7 @@ check-step? ;; go on from the redone file, as the pure runner does (.then (check-step situation (unwrap op) before index-before) - #(run-ops (tm/with-file % (current-file)) (rest ops))) + #(run-ops (refresh-generated %) (rest ops))) ;; never undo past the state a case's own undo went back to (and (::undo-check? situation)