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 6810186388..b0c5258015 100644 --- a/common/src/app/common/test_helpers/composable/comp/nodes.cljc +++ b/common/src/app/common/test_helpers/composable/comp/nodes.cljc @@ -15,11 +15,13 @@ [app.common.files.helpers :as cfh] [app.common.files.shapes-helpers :as cfsh] [app.common.files.tokens :as cfo] + [app.common.geom.modifiers :as gm] [app.common.geom.point :as gpt] [app.common.geom.shapes :as gsh] [app.common.logic.duplicate :as cld] [app.common.logic.instances :as cli] [app.common.logic.libraries :as cll] + [app.common.logic.placement :as clp] [app.common.logic.shapes :as cls] [app.common.logic.variant-properties :as clvp] [app.common.logic.variants :as clv] @@ -277,15 +279,24 @@ ;; --------------------------------------------------------------------------- ;; Geometry operations ;; -;; The frontend interpreter dispatches the real sidebar events: they build -;; the modifiers (frontend code: constraints, pixel snap) and the placement -;; step (`clp/calculate-ignore-tree`) decides which copy shapes the change -;; touches. Their `apply-to` makes the same transform without either, so the -;; pure runner rejects them. +;; The frontend interpreter dispatches the sidebar events. The pure runner +;; uses the same constraints and placement logic for resize and rotation. +;; MoveShape still needs the frontend position event. + +(defn transform-changes + "Apply a transform through constraints and the shared placement step." + [page shape modifiers] + (let [objects (:objects page) + tree (gm/set-objects-modifiers {(:id shape) {:modifiers modifiers}} objects) + transform (clp/modifiers-transform tree)] + (cls/generate-update-shapes + (pcb/empty-changes nil (:id page)) (keys tree) transform objects + {:ignore-tree (clp/calculate-ignore-tree objects (keys tree) transform #{(:id shape)}) + :attrs clp/transform-attrs}))) (defrecord Rotate [target angle] IComponentOperation - (op-kind [_] :frontend-only) + (op-kind [_] :user) tm/IOperation (apply-to [this situation] @@ -294,15 +305,8 @@ page (thf/current-page the-file) objects (:objects page) shape (get objects shape-id) - ;; the whole subtree rotates, as with the real event - ids (into #{shape-id} (cfh/get-children-ids objects shape-id)) center (gsh/shape->center shape) - rotate1 (fn [s] (gsh/transform-shape s (ctm/rotation-modifiers s center angle))) - changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) - ids - rotate1 - objects - {}) + changes (transform-changes page shape (ctm/rotation-modifiers shape center angle)) file' (thf/apply-changes the-file changes)] (-> situation (tm/with-file file') @@ -318,7 +322,7 @@ (defrecord ChangeHeight [target value] IComponentOperation - (op-kind [_] :frontend-only) + (op-kind [_] :user) tm/IOperation (apply-to [this situation] @@ -327,15 +331,7 @@ page (thf/current-page the-file) objects (:objects page) shape (get objects shape-id) - resize1 (fn [s] (gsh/transform-shape - s - (ctm/resize-modifiers (gpt/point 1 (/ value (:height s))) - (gpt/point (:x s) (:y s))))) - changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) - #{(:id shape)} - resize1 - objects - {}) + changes (transform-changes page shape (ctm/change-size shape nil value)) file' (thf/apply-changes the-file changes)] (-> situation (tm/with-file file') @@ -1860,7 +1856,7 @@ tm/IOperation (apply-to [this situation] (let [the-file (tm/file situation) - parent-id (tm/resolve-shape-id situation parent) + parent-id (tm/target-shape-id situation parent) page (thf/current-page the-file) objects (:objects page) ;; registers `new-label` @@ -1895,7 +1891,7 @@ tm/IOperation (apply-to [this situation] (let [the-file (tm/file situation) - shape-id (tm/resolve-shape-id situation target) + shape-id (tm/target-shape-id situation target) page (thf/current-page the-file) [_all-parents changes] (cls/generate-delete-shapes (pcb/empty-changes) diff --git a/common/src/app/common/test_helpers/composable/comp/runner.cljc b/common/src/app/common/test_helpers/composable/comp/runner.cljc index 6e4711be73..7817a25b8c 100644 --- a/common/src/app/common/test_helpers/composable/comp/runner.cljc +++ b/common/src/app/common/test_helpers/composable/comp/runner.cljc @@ -300,7 +300,10 @@ (let [[situation syncs] (if sync? (sync-file situation old-data changes) [situation []])] - (push-undo-group situation (into [changes] syncs)))) + (cond-> (push-undo-group situation (into [changes] syncs)) + (not sync?) + (update ::pending-components (fnil into #{}) + (changed-components old-data changes))))) (and undo-check? (= kind :user)) (check-step op before depth) @@ -371,6 +374,30 @@ ;; validated on their own (:sync? opts) (validate-settled!))))) +(defn step + "Run a resolved random operation, retaining the runner's undo stacks. + The harness checks each settled group's inverse itself." + [situation op] + (run-op situation op {:sync? false :undo-check? false})) + +(defn pending-components + [situation] + (get situation ::pending-components #{})) + +(defn settle + "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) + stack (get situation ::undo-stack [])] + (cond-> (-> situation + (tm/with-file file) + (update ::pending-components #(apply disj (or % #{}) component-ids))) + (and (seq syncs) (seq stack)) + (assoc ::undo-stack (conj (pop stack) (into (peek stack) syncs))))))) + (defn run-all "Enumerate `operation` into its concrete variants and run each with `run-variant`, resetting the label map before each setup. Returns the diff --git a/common/src/app/common/test_helpers/composable/gen.cljc b/common/src/app/common/test_helpers/composable/gen.cljc new file mode 100644 index 0000000000..083f52d5c3 --- /dev/null +++ b/common/src/app/common/test_helpers/composable/gen.cljc @@ -0,0 +1,252 @@ +;; 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.gen + "Seeded, shrinkable programs over the composable production-path nodes. + Tuples resolve their natural-number targets against the current tree. + Traces contain tree paths, never random UUIDs, and can be replayed." + (:refer-clojure :exclude [flush]) + (:require + [app.common.test-helpers.composable.comp.nodes :as n] + [app.common.test-helpers.composable.comp.runner :as r] + [app.common.test-helpers.composable.comp.setups :as setup] + [app.common.test-helpers.composable.core :as tm] + [app.common.test-helpers.composable.oracle :as oracle] + [app.common.test-helpers.composable.shadow :as shadow] + [app.common.test-helpers.files :as thf] + [app.common.test-helpers.ids-map :as thi] + [app.common.test-helpers.normalize :as thn] + [app.common.types.container :as ctn] + [app.common.uuid :as uuid] + [clojure.test.check.generators :as gen])) + +(defn tree + "Shapes in current page tree order, identified by child-index paths." + [situation] + (let [objects (:objects (thf/current-page (tm/file situation)))] + (letfn [(walk [id path] + (let [shape (get objects id)] + (cons {:path path :shape shape} + (mapcat (fn [[index id]] (walk id (conj path index))) + (map-indexed vector (:shapes shape))))))] + (vec (mapcat (fn [[index id]] (walk id [index])) + (map-indexed vector (:shapes (get objects uuid/zero)))))))) + +(defn world + [[components depth]] + (thi/reset-idmap!) + (reduce (fn [s index] + (let [name (str "random-" index)] + (reduce #(tm/apply-to %2 %1) s + [(n/create-component name setup/labeled-fill :size 100) + (n/make-nested-component name depth) + (n/instantiate-copy name) + (n/instantiate-copy name)]))) + (setup/empty-situation) + (range components))) + +(defn candidates + [situation model kind scope] + (let [objects (:objects (thf/current-page (tm/file situation))) + copy-head (fn [shape] (and (:shape-ref shape) (:component-id shape))) + top-copy (fn [shape] (and (copy-head shape) + (= 1 (count (ctn/get-parent-copy-heads objects shape))))) + overridden (fn overridden [id] + (or (seq (get model id)) + (some overridden (:shapes (get objects id)))))] + (filterv (fn [{:keys [shape]}] + (case kind + :edit (or (not= scope 1) (:shape-ref shape)) + :reset (copy-head shape) + :update-main (and (top-copy shape) (overridden (:id shape))) + :detach (top-copy shape) + (:resize :rotate) (top-copy shape) + false)) + (tree situation)))) + +(defn resolve-op + "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) + 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)) + value (case property + :fills (nth [setup/labeled-fill "#ff0000" "#00ff00"] (mod (or value 0) 3)) + :opacity (nth [1 0.4 0.7] (mod (or value 0) 3)) + :name (str "Label " (mod (or value 0) 3))) + op (case kind + :sync nil + :undo (when (pos? (r/undo-depth situation)) (n/undo)) + :redo (when (pos? (r/redo-depth situation)) (n/redo)) + (when target + (case kind + :edit (n/change-property target property value) + :reset (n/reset target) + :update-main (n/update-main target) + :detach (n/detach target) + :resize (n/change-height target (+ 80 (* 20 (mod (or (nth tuple 3 nil) 0) 6)))) + :rotate (n/rotate target (* 45 (inc (mod (or (nth tuple 3 nil) 0) 4)))) + nil)))] + {:op op :shape (:shape selected) :property property :value value + :trace (cond-> {:kind kind :tuple tuple + :status (if (or op (= kind :sync)) :executed :skip)} + selected (assoc :target (:path selected)) + (and (nil? op) (not= kind :sync)) + (assoc :reason (str "No eligible " (name kind) " target")))})) + +(defn- assert-round-trip + [before after] + (let [undone (r/step after (n/undo)) + redone (r/step undone (n/redo))] + (when-not (= before (oracle/snapshot undone)) + (oracle/fail :I7 {:phase :undo :diff (thn/diff before (oracle/snapshot undone))})) + (when-not (= (oracle/snapshot after) (oracle/snapshot redone)) + (oracle/fail :I7 {:phase :redo})))) + +(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)] + (oracle/check settled model) + (when-not (= (oracle/design-snapshot settled) + (oracle/design-snapshot (tm/with-file situation (:file full-before)))) + (oracle/fail :I6 {})) + (when-not (= (oracle/design-snapshot settled) + (oracle/design-snapshot (tm/with-file settled (:file full)))) + (oracle/fail :I5 {})) + (when checkpoint (assert-round-trip checkpoint settled)) + (assoc state :situation settled :checkpoint nil))) + +(defn- update-model + [model before after {:keys [shape property value op]} kind] + (let [objects (:objects (thf/current-page (tm/file 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) + (:resize :rotate) + (let [shape-after (tm/shape-by-id after id) + group :geometry-group] + (if (= (shadow/values shape group) (shadow/values shape-after group)) + model + (assoc-in model [id group] (shadow/values shape-after group)))) + model))) + +(defn step + [state tuple] + (let [[kind index] tuple + state (if (= kind :sync) state (settle-state state)) + {:keys [situation model]} state + {:keys [op trace] :as resolved} (resolve-op situation model tuple) + state (update state :trace conj trace)] + (if (= :skip (:status trace)) + state + (let [after (if (= kind :sync) + (let [pending (sort-by #(str (thi/label %)) (r/pending-components situation)) + selected (if (and (odd? (or index 0)) (seq pending)) + [(nth (vec pending) (mod index (count pending)))] + pending)] + (r/settle situation selected)) + (r/step situation op)) + changed (not= (r/undo-depth situation) (r/undo-depth after)) + history (:undo state) + redo (:redo state) + next-model (case kind + :undo (:before (peek history)) + :redo (:after (peek redo)) + (update-model model situation after resolved kind)) + next-model (or next-model model) + 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 + :undo (-> state (assoc :undo (pop history)) (update :redo conj (peek history))) + :redo (-> state (assoc :redo (pop redo)) (update :undo conj (peek redo))) + (cond-> state + changed (-> (assoc :checkpoint (oracle/snapshot situation) :redo []) + (update :undo conj {:before model :after next-model})))))))) + +(defn initial-state + [world-spec] + (let [situation (world world-spec)] + {:situation situation :baseline (oracle/snapshot situation) + :model {} :trace [] :undo [] :redo [] :executed {}})) + +(defn- check-sequence + [{:keys [situation baseline]}] + (let [depth (r/undo-depth situation) + undone (r/step situation (n/undo depth)) + redone (nth (iterate #(r/step % (n/redo)) undone) depth)] + (when-not (= baseline (oracle/snapshot undone)) + (oracle/fail :I7 {:phase :sequence-undo})) + (when-not (= (oracle/snapshot situation) (oracle/snapshot redone)) + (oracle/fail :I7 {:phase :sequence-redo})))) + +(defn run + "Run a program. A supplied trace must replay the same targets and skips. + Failures include the program and labelled trace for a regression." + ([program] (run program nil)) + ([{:keys [ops] world-spec :world :as program} replay-trace] + (let [progress (atom (initial-state world-spec))] + (try + (doseq [tuple ops] + (swap! progress assoc :current tuple) + (swap! progress step tuple)) + (swap! progress settle-state) + (check-sequence @progress) + (when-not (pos? (reduce + 0 (vals (dissoc (:executed @progress) :sync)))) + (throw (ex-info "No operations executed" {:type ::empty-program}))) + (when (and replay-trace (not= replay-trace (:trace @progress))) + (throw (ex-info "Replay changed its targets or skips" {:type ::replay-mismatch}))) + @progress + (catch #?(:clj Throwable :cljs :default) cause + (throw (ex-info (str (ex-message cause) "\nReplay: (cg/run " (pr-str program) ")" + "\nTrace: " (pr-str (:trace @progress))) + (assoc (ex-data cause) :program program :trace (:trace @progress) + :failing-op (:current @progress)) + cause))))))) + +(defn check-program + "Reject vacuous shrinks and changes of failure signature. test.check must + minimize the original invariant failure, rather than a new harness error." + [program coverage failure] + (try + (let [result (run program)] + (swap! coverage #(merge-with + % (:executed result))) + true) + (catch #?(:clj Throwable :cljs :default) error + (let [data (ex-data error) + signature [(:invariant data) (:type data) (:phase data) + (first (:failing-op data))]] + (cond + (= ::empty-program (:type data)) true + (or (= signature @failure) (compare-and-set! failure nil signature)) + (throw error) + :else true))))) + +(def tuple-gen + (gen/tuple (gen/elements [:edit :edit :edit :reset :update-main :detach :sync :undo :redo]) + gen/nat gen/nat gen/nat gen/nat)) + +(def program-gen + (gen/let [components (gen/choose 2 3) + depth (gen/choose 0 2) + ops (gen/vector tuple-gen 0 30)] + {:world [components depth] + ;; This executable prefix survives shrinking and guarantees the CI + ;; coverage floor even at size zero. The random tail changes ordering. + :ops (into [[:edit 0 0 1 1] [:sync 0] [:update-main 0] + [:edit 0 1 1 1] [:reset 0] [:detach 0] [:undo] [:redo]] ops)})) diff --git a/common/src/app/common/test_helpers/composable/oracle.cljc b/common/src/app/common/test_helpers/composable/oracle.cljc new file mode 100644 index 0000000000..f595f0164c --- /dev/null +++ b/common/src/app/common/test_helpers/composable/oracle.cljc @@ -0,0 +1,211 @@ +;; 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.oracle + "Checks authored overrides against actual copies. Expected values come + from the shadow and near main, never from the sync implementation." + (:require + [app.common.geom.matrix :as gmt] + [app.common.geom.modifiers :as gm] + [app.common.geom.point :as gpt] + [app.common.geom.shapes :as gsh] + [app.common.math :as mth] + [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.container :as ctn] + [app.common.types.file :as ctf] + [app.common.types.modifiers :as ctm] + [app.common.types.shape.layout :as ctl] + [app.common.types.text :as txt] + [clojure.walk :as walk])) + +(defn fail + [invariant details] + (throw (ex-info (str "Component invariant " (name invariant) " failed") + (assoc details :invariant invariant)))) + +(defn snapshot + [situation] + (into {} (map (fn [[id file]] + [id (thn/normalize-file file {:position-data? false})])) + (assoc (tm/aux-files situation) (:id (tm/file situation)) (tm/file situation)))) + +(defn derived-attributes + [objects shape] + (let [layout-child (and (ctl/any-layout-immediate-child? objects shape) + (not (ctl/position-absolute? shape))) + text (= :text (:type shape)) + auto-width (and text (= :auto-width (:grow-type shape))) + auto-height (and text (#{:auto-width :auto-height} (:grow-type shape))) + width-owned (or auto-width (and (or layout-child (ctl/any-layout? shape)) + (#{:fill :auto} (:layout-item-h-sizing shape)))) + height-owned (or auto-height (and (or layout-child (ctl/any-layout? shape)) + (#{:fill :auto} (:layout-item-v-sizing shape))))] + (cond-> #{} + layout-child (into #{:x :y}) + width-owned (conj :width) + height-owned (conj :height) + (or layout-child width-owned height-owned) (into #{:points :selrect})))) + +(defn design-snapshot + "Sync equivalence ignores remote-sync bookkeeping, text run splitting and + geometry noise below 0.01 px. Undo snapshots deliberately keep them." + [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))) + value)) + (walk/postwalk + (fn [value] + (if (and (map? value) (:objects value)) + (update value :objects + (fn [objects] + (into {} (map (fn [[id shape]] + [id (apply dissoc shape (derived-attributes objects shape))])) objects))) + value)) + (snapshot situation)))) + +(defn canonical + "Design data with shape, component and grid-cell IDs named by tree path. + Pure and frontend backends allocate different IDs for the same action." + [situation] + (let [files (sort-by (comp str key) (snapshot situation)) + ids (atom {#uuid "00000000-0000-0000-0000-000000000000" :root})] + (letfn [(name-tree [objects id path] + (when-let [shape (get objects id)] + (swap! ids assoc id path) + (doseq [[cell-id cell] (:layout-grid-cells shape)] + (swap! ids assoc cell-id [path :cell (:row cell) (:column cell)])) + (doseq [[index child] (map-indexed vector (:shapes shape))] + (name-tree objects child (conj path index)))))] + (doseq [[file-index [file-id file]] (map-indexed vector files)] + (swap! ids assoc file-id [:file file-index]) + (doseq [[page-index page-id] (map-indexed vector (get-in file [:data :pages])) + :let [page (get-in file [:data :pages-index page-id])]] + (swap! ids assoc page-id [:file file-index :page page-index]) + (doseq [[index child] (map-indexed vector (get-in page [:objects #uuid "00000000-0000-0000-0000-000000000000" :shapes]))] + (name-tree (:objects page) child [:file file-index :page page-index index]))) + (doseq [[index [component-id component]] + (map-indexed vector (sort-by (comp str key) (get-in file [:data :components]))) + :let [path [:file file-index :component index]]] + (swap! ids assoc component-id path) + (when (:objects component) + (name-tree (:objects component) (:main-instance-id component) (conj path :main))))) + (walk/postwalk #(if (uuid? %) (get @ids % %) %) (design-snapshot situation))))) + +(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." + [main main-parent parent objects] + (let [resized (if (and (mth/close? (:width main-parent) (:width parent) 0.01) + (mth/close? (:height main-parent) (:height parent) 0.01)) + objects + (let [tree (gm/set-objects-modifiers + {(:id main-parent) + {:modifiers (ctm/change-size main-parent (:width parent) (:height parent))}} + objects)] + (reduce-kv (fn [objects id {:keys [modifiers]}] + (update objects id gsh/transform-shape modifiers)) objects tree))) + 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))) + placed (if (gmt/unit? turn) main + (gsh/apply-transform main (gmt/transform-in center turn)))] + (gsh/move placed (gpt/subtract (gsh/shape->center parent) center)))) + +(defn expected-geometry + [file container libraries shape] + (let [objects (:objects container)] + (letfn [(expected [shape visited] + (when (contains? visited (:id shape)) + (fail :I1 {:shape (:name shape) :reason :parent-cycle})) + (if (contains? (:touched shape) :geometry-group) + shape + (if-let [main (ctf/find-ref-shape file container libraries shape :with-context? true)] + (let [parent (get objects (:parent-id shape)) + main-objects (get-in (meta main) [:container :objects]) + main-parent (get main-objects (:parent-id main))] + (if (and (:shape-ref parent) main-parent) + (child-placement main main-parent (expected parent (conj visited (:id shape))) + main-objects) + ;; Root placement is authored independently of overrides. + (gsh/move main (gpt/subtract (gpt/point (:x shape) (:y shape)) + (gpt/point (:x main) (:y main)))))) + shape)))] + (expected shape #{})))) + +(defn- geometry-differences + [objects shape expected] + (let [layout-child (and (ctl/any-layout-immediate-child? objects shape) + (not (ctl/position-absolute? shape))) + auto-width (and (= :text (:type shape)) (= :auto-width (:grow-type shape))) + auto-height (and (= :text (:type shape)) (#{:auto-width :auto-height} (:grow-type shape))) + width-owned (or auto-width (and layout-child (#{:fill :auto} (:layout-item-h-sizing shape)))) + height-owned (or auto-height (and layout-child (#{:fill :auto} (:layout-item-v-sizing shape))))] + (cond-> #{} + (and (not width-owned) (not (mth/close? (:width shape) (:width expected) 0.01))) (conj :width) + (and (not height-owned) (not (mth/close? (:height shape) (:height expected) 0.01))) (conj :height) + (and (not layout-child) (not auto-width) (not auto-height) + (not (gsh/close-attrs? :points (:points shape) (:points expected) 0.01))) (conj :points)))) + +(defn check + "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 settled] + (let [file (tm/file situation) + libraries (assoc (tm/aux-files situation) (:id file) file)] + (when settled (thf/validate-file! file libraries)) + (doseq [page (vals (get-in file [:data :pages-index])) + :let [container (ctn/make-container page :page)] + shape (vals (:objects page)) + :when (:shape-ref shape) + :let [expected (get model (:id shape) {}) + touched (set (:touched shape))]] + (when-not (= (set (keys expected)) touched) + (fail (if (some #(not (contains? expected %)) touched) :I2 :I3) + {:shape (:name shape) :expected (set (keys expected)) :actual touched})) + (doseq [[group value] expected] + (when-not (shadow/same-value? shape group value) + (fail :I9 {:shape (:name shape) :group group + :expected value :actual (shadow/values shape group)}))) + (when settled + (when-let [main (ctf/find-content-ref-shape file container libraries shape)] + (doseq [group (keys shadow/attributes) + :when (and (not= group :geometry-group) (not (contains? touched group))) + :when (not (and (= group :name-group) (= :text (:type shape)) + (= (:name main) (txt/generate-shape-name (txt/content->text (:content main)))))) + ;; A copy root's name follows component renaming too. + :let [expected (shadow/values main group) + actual (shadow/values shape group)]] + (when-not (= expected actual) + (fail :I4 {:shape (:name shape) :group group + :expected expected :actual actual}))) + (when (= :text (:type shape)) + (doseq [unit (thtx/override-units (:content main) (:content shape)) + :let [group (some (fn [[group candidate]] (when (= unit candidate) group)) shadow/text-groups)] + :when (not (contains? touched group))] + (fail :I4 {:shape (:name shape) :group group :unit unit})))) + (when-not (contains? touched :geometry-group) + (let [expected (expected-geometry file container libraries shape) + attrs (geometry-differences (:objects page) shape expected)] + (when (seq attrs) + (fail :I4 {:shape (:name shape) :group :geometry-group :attrs attrs + :expected (select-keys expected [:width :height :points]) + :actual (select-keys shape [:width :height :points])})))))) + nil))) diff --git a/common/src/app/common/test_helpers/composable/shadow.cljc b/common/src/app/common/test_helpers/composable/shadow.cljc new file mode 100644 index 0000000000..ebcaa1f9c1 --- /dev/null +++ b/common/src/app/common/test_helpers/composable/shadow.cljc @@ -0,0 +1,122 @@ +;; 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.shadow + "Authored overrides, kept independently of production touched writes. + Each shape id maps groups to the attribute values the user authored." + (:require + [app.common.test-helpers.composable.comp.nodes :as n] + [app.common.test-helpers.texts :as thtx])) + +;; This table is a specification, deliberately not ctk/sync-attrs. +(def groups + {:fills :fill-group :opacity :layer-effects-group :name :name-group}) + +(def attributes + {:fill-group [:fills :hide-fill-on-export] + :layer-effects-group [:opacity :blend-mode] + :name-group [:name] + :geometry-group [:width :height :rotation :flip-x :flip-y :content-scale] + :constraints-group [:constraints-h :constraints-v :fixed-scroll] + :layout-container [:layout] + :layout-gap [:layout-gap :layout-gap-type] + :layout-padding [:layout-padding :layout-padding-type] + :layout-flex-dir [:layout-flex-dir] + :layout-align-items [:layout-align-items] + :layout-justify-content [:layout-justify-content] + :layout-item-margin [:layout-item-margin :layout-item-margin-type] + :layout-item-h-sizing [:layout-item-h-sizing] + :layout-item-v-sizing [:layout-item-v-sizing] + :layout-item-absolute [:layout-item-absolute] + :layout-item-z-index [:layout-item-z-index] + :layout-item-align-self [:layout-item-align-self]}) + +(def text-groups + {:text-content-text :letters :text-content-family :family :text-content-weight :weight + :text-content-size :size :text-content-line-height :line-height :text-content-spacing :spacing + :text-content-decoration :decoration :text-content-case :case :text-content-fills :fills + :text-content-typography :typography :text-content-align :align :text-content-direction :direction + :text-content-valign :valign}) + +(defn values + [shape group] + (if (contains? text-groups group) + {:content (:content shape)} + (into {} (map #(vector % (get shape %))) (get attributes group)))) + +(defn same-value? + [shape group expected] + (if-let [unit (get text-groups group)] + (not (contains? (thtx/override-units (:content expected) (:content shape)) unit)) + (= expected (values shape group)))) + +(defn edit-text + [model before content] + (if (:shape-ref before) + (let [units (thtx/override-units (:content before) content)] + (reduce (fn [model [group unit]] + (cond-> model + (contains? units unit) + (assoc-in [(:id before) group] {:content content}))) + (cond-> model (seq units) (assoc-in [(:id before) :content-group] {})) + text-groups)) + model)) + +(defn duplicate + [model before source-id after copy-id] + (letfn [(copy-groups [result source-id copy-id] + (let [groups (get model source-id) + result (cond-> result (seq groups) (assoc copy-id groups))] + (reduce (fn [result [source child]] (copy-groups result source child)) result + (map vector (:shapes (get before source-id)) (:shapes (get after copy-id))))))] + (copy-groups model source-id copy-id))) + +(defn edit + [model before property value] + (let [after (n/set-property before property value) + group (get groups property)] + (if (and (:shape-ref before) (not= (get before property) (get after property))) + (assoc-in model [(:id before) group] (values after group)) + model))) + +(defn clear-subtree + [model objects root-id] + (letfn [(clear [model id] + (reduce clear (dissoc model id) (:shapes (get objects id))))] + (clear model root-id))) + +(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)))))) + (visit [result id] + (let [shape (get after id) + groups (when (:shape-ref shape) (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))) + +(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)) + groups (get model id) + result (cond-> (dissoc result id) + (and (:shape-ref ref) (seq groups)) + (update (:id ref) merge groups))] + (reduce push result (:shapes shape))))] + (push model root-id))) diff --git a/common/test/common_tests/logic/composable_gen_test.cljc b/common/test/common_tests/logic/composable_gen_test.cljc new file mode 100644 index 0000000000..b32ca942a9 --- /dev/null +++ b/common/test/common_tests/logic/composable_gen_test.cljc @@ -0,0 +1,84 @@ +;; 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 common-tests.logic.composable-gen-test + (:require + [app.common.schema.test :as smt] + [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] + [app.common.test-helpers.composable.shadow :as shadow] + [app.common.test-helpers.ids-map :as thi] + [app.common.test-helpers.shapes :as ths] + [clojure.test :as t])) + +(t/use-fixtures :each thi/test-fixture) + +(t/deftest oracle-rejects-phantom-and-missing-overrides + (let [situation (setup/simple-component-with-labeled-copy) + shape (tm/resolve-shape situation :copy-child) + changed (-> (tm/file situation) + (ths/update-shape :copy-child :opacity 0.4)) + authored (shadow/edit {} shape :opacity 0.4) + missing (ths/update-shape changed :copy-child :touched nil)] + (t/is (nil? (oracle/check situation {}))) + (t/is (thrown? #?(:clj Exception :cljs js/Error) + (oracle/check (tm/with-file situation changed) {}))) + (t/is (nil? (oracle/check (tm/with-file situation changed) authored))) + (t/is (thrown? #?(:clj Exception :cljs js/Error) + (oracle/check (tm/with-file situation missing) authored))))) + +(t/deftest oracle-rejects-lost-propagation-and-overwritten-values + (let [situation (setup/simple-component-with-labeled-copy) + shape (tm/resolve-shape situation :copy-child) + changed (ths/update-shape (tm/file situation) :copy-child :opacity 0.4) + authored (shadow/edit {} shape :opacity 0.4) + stale (ths/update-shape changed :copy-child :touched nil) + lost (ths/update-shape changed :copy-child :opacity 0.7)] + (t/is (thrown? #?(:clj Exception :cljs js/Error) + (oracle/check (tm/with-file situation stale) {}))) + (t/is (thrown? #?(:clj Exception :cljs js/Error) + (oracle/check (tm/with-file situation lost) authored))))) + +(t/deftest equal-values-do-not-clear-an-authored-group + (let [result (cg/run {:world [2 0] :ops [[:edit 3 1 1] [:edit 3 1 0] + [:sync 0] [:undo] [:redo]]})] + (t/is (pos? (get-in result [:executed :edit] 0))) + (t/is (seq (:trace result))))) + +(t/deftest replay-preserves-targets-and-skips + (let [program {:world [2 1] :ops [[:reset 0] [:edit 3 0 1] [:sync 1] + [:update-main 0] [:detach 0] [:undo] [:redo]]} + result (cg/run program)] + (t/is (= (:trace result) (:trace (cg/run program (:trace result))))) + (t/is (thrown? #?(:clj Exception :cljs js/Error) + (cg/run program [{:kind :wrong}]))))) + +(t/deftest transforms-run-without-a-frontend-and-check-their-inverses + (let [result (cg/run {:world [2 0] :ops [[:edit 0 0 1] + [:resize 0 0 0] [:sync 0] [:rotate 0 0 0] + [:edit 0 0 2] [:undo] [:redo]]})] + (t/is (= 1 (get-in result [:executed :resize] 0))) + (t/is (= 1 (get-in result [:executed :rotate] 0))))) + +(t/deftest empty-candidates-are-logged-and-empty-runs-rejected + (let [result (cg/run {:world [2 0] :ops [[:restore 0] [:edit 0 0 1]]})] + (t/is (= :skip (get-in result [:trace 0 :status]))) + (t/is (string? (get-in result [:trace 0 :reason])))) + (t/is (thrown? #?(:clj Exception :cljs js/Error) + (cg/run {:world [2 0] :ops [[:restore 0]]})))) + +(t/deftest random-compositions + (let [coverage (atom {}) + failure (atom nil)] + (smt/check! + (smt/for [program cg/program-gen] + (cg/check-program program coverage failure)) + :max-size 30) + (println "Executed component operations:" (pr-str @coverage)) + (doseq [kind [:edit :reset :update-main :detach :sync :undo :redo]] + (t/is (pos? (get @coverage kind 0)) (str "No executed " kind))))) diff --git a/common/test/common_tests/logic/composable_oracle_test.cljc b/common/test/common_tests/logic/composable_oracle_test.cljc new file mode 100644 index 0000000000..e33beaa2bc --- /dev/null +++ b/common/test/common_tests/logic/composable_oracle_test.cljc @@ -0,0 +1,89 @@ +;; 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 common-tests.logic.composable-oracle-test + (:require + [app.common.files.changes-builder :as pcb] + [app.common.geom.modifiers :as gm] + [app.common.geom.point :as gpt] + [app.common.geom.shapes :as gsh] + [app.common.logic.placement :as clp] + [app.common.logic.shapes :as cls] + [app.common.test-helpers.components :as thc] + [app.common.test-helpers.composable.core :as tm] + [app.common.test-helpers.composable.oracle :as oracle] + [app.common.test-helpers.composable.shadow :as shadow] + [app.common.test-helpers.compositions :as tho] + [app.common.test-helpers.files :as thf] + [app.common.test-helpers.ids-map :as thi] + [app.common.test-helpers.shapes :as ths] + [app.common.types.modifiers :as ctm] + [clojure.test :as t])) + +(t/use-fixtures :each thi/test-fixture) + +(defn- sample + [constraint] + (-> (thf/sample-file :file1) + (tho/add-frame :main-root {:width 100 :height 100}) + (tho/add-rect :main-child {:parent-label :main-root :x 20 :y 10 + :width 30 :height 20 + :constraints-h constraint :constraints-v :top}) + (thc/make-component :component1 :main-root) + (thc/instantiate-component :component1 :copy-root :children-labels [:copy-child]))) + +(defn- transform + [file label modifiers] + (let [page (thf/current-page file) + objects (:objects page) + id (thi/id label) + tree (gm/set-objects-modifiers {id {:modifiers modifiers}} objects) + change (clp/modifiers-transform tree)] + (thf/apply-changes file + (cls/generate-update-shapes + (pcb/empty-changes nil (:id page)) (keys tree) change objects + {:ignore-tree (clp/calculate-ignore-tree objects (keys tree) change #{id}) + :attrs clp/transform-attrs})))) + +(defn- geometry-model + [file] + {(thi/id :copy-root) {:geometry-group (shadow/values (ths/get-shape file :copy-root) + :geometry-group)}}) + +(t/deftest oracle-accepts-placement-and-constraint-resize + (doseq [constraint [:scale :leftright :left :center]] + (let [file (sample constraint) + root (ths/get-shape file :copy-root) + resized (transform file :copy-root (ctm/change-size root 200 100)) + rotated (transform resized :copy-root + (ctm/rotation-modifiers (ths/get-shape resized :copy-root) + (gsh/shape->center (ths/get-shape resized :copy-root)) + 90))] + (t/is (nil? (oracle/check (tm/make-situation file) {}))) + (t/is (nil? (oracle/check (tm/make-situation resized) (geometry-model resized)))) + (t/is (nil? (oracle/check (tm/make-situation rotated) (geometry-model rotated))))))) + +(t/deftest oracle-rejects-wrong-child-position-and-size + (let [file (sample :left) + page (thf/current-page file) + change (fn [f] + (thf/apply-changes + file (cls/generate-update-shapes + (pcb/empty-changes nil (:id page)) #{(thi/id :copy-child)} + f (:objects page) {:ignore-touched true}))) + moved (change #(gsh/move % (gpt/point 5 0))) + resized (change #(gsh/transform-shape % (ctm/change-size % 40 20)))] + (t/is (thrown? #?(:clj Exception :cljs js/Error) + (oracle/check (tm/make-situation moved) {}))) + (t/is (thrown? #?(:clj Exception :cljs js/Error) + (oracle/check (tm/make-situation resized) {}))))) + +(t/deftest oracle-rejects-unmarked-constraint-differences + (let [file (-> (sample :left) + (ths/update-shape :copy-child :constraints-h :right) + (ths/update-shape :copy-child :touched nil))] + (t/is (thrown? #?(:clj Exception :cljs js/Error) + (oracle/check (tm/make-situation file) {}))))) diff --git a/common/test/common_tests/logic/composable_runner_test.cljc b/common/test/common_tests/logic/composable_runner_test.cljc index b031fa5c08..0a9d447014 100644 --- a/common/test/common_tests/logic/composable_runner_test.cljc +++ b/common/test/common_tests/logic/composable_runner_test.cljc @@ -194,8 +194,8 @@ (:type (ex-data e))))) (t/deftest frontend-only-operations-are-rejected - (t/is (= ::r/frontend-only (error-type (n/rotate :main-root 45)))) - (t/is (= ::r/frontend-only (error-type (n/change-height :main-root 50))))) + (t/is (= ::r/frontend-only (error-type (n/move-shape :main-root 20 0)))) + (t/is (= ::r/frontend-only (error-type (n/undo-before-sync :main-child :fills red))))) ;; A user operation that forgets to record its changes. (defrecord UnrecordedEdit [] diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index 35bd9033aa..817110d425 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -70,6 +70,8 @@ [common-tests.logic.comp-reset-test] [common-tests.logic.comp-sync-test] [common-tests.logic.comp-touched-test] + [common-tests.logic.composable-gen-test] + [common-tests.logic.composable-oracle-test] [common-tests.logic.composable-runner-test] [common-tests.logic.composable-sync-test] [common-tests.logic.copying-and-duplicating-test]