diff --git a/.serena/memories/common/testing.md b/.serena/memories/common/testing.md index 8504fffcac..530b48ba9e 100644 --- a/.serena/memories/common/testing.md +++ b/.serena/memories/common/testing.md @@ -93,3 +93,12 @@ For geometry-sensitive tests, read `mem:common/geometry-invariants` before posit ## Debugging Use `mem:common/component-debugging-recipes` for shape-tree dumps, undo/change inspection, and temporary live instrumentation recipes. + +## Composable undo comparison + +`comp/undo-check` keeps normalized snapshots exact, including `:touched` and +`:remote-synced`. Comparison permits only absolute floating noise below 1e-10 +inside shape geometry: x/y, width/height, rotation, points, selrect and transform +matrices. Reflow can recompute 0.01 as 0.010000000000005116 after undo (F130). +This tolerance does not apply to opacity, other authored attrs or touched groups. +The regression rejects a 1e-5 height change and a 5e-15 opacity change. 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 ce9fe0bef6..dcd01cd0f0 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 @@ -34,6 +34,9 @@ a pinned phase that does not fail fails the case, so the mark goes only with the fix." (:require + [app.common.geom.matrix :as gmt] + [app.common.geom.point :as gpt] + [app.common.math :as mth] [app.common.test-helpers.composable.core :as tm] [app.common.test-helpers.ids-map :as thi] [app.common.test-helpers.normalize :as thn] @@ -166,13 +169,43 @@ (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?] + (or (= expected actual) + (cond + (and geometry? (number? expected) (number? actual)) + (mth/close? expected actual 1e-10) + + (and geometry? (gmt/matrix? expected) (gmt/matrix? actual)) + (every? #(same-state? (get expected %) (get actual %) 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]) + + (and (map? expected) (map? actual) (= (set (keys expected)) (set (keys actual)))) + (every? (fn [key] + (same-state? (get expected key) (get actual key) + (or geometry? + (and (contains? expected :parent-id) + (contains? #{:x :y :width :height :rotation :points + :selrect :transform :transform-inverse} key))))) + (keys expected)) + + (and (sequential? expected) (sequential? actual) (= (count expected) (count actual))) + (every? true? (map #(same-state? %1 %2 geometry?) expected actual)) + + :else false)))) + (defn compare-states "Record a failure of `phase` at `step` unless the normalized files `expected` and `actual` are equal. Phases: `:undo`, `:redo`, `:no-undo-entry` (a step that added no entry changed the file), `:variant-undo` and `:variant-redo`." [situation step phase expected actual] - (if (= expected actual) + (if (same-state? expected actual) situation (add-failure situation {:phase phase :step step diff --git a/common/test/common_tests/logic/composable_runner_test.cljc b/common/test/common_tests/logic/composable_runner_test.cljc index 379c8de27b..e654a9d6e1 100644 --- a/common/test/common_tests/logic/composable_runner_test.cljc +++ b/common/test/common_tests/logic/composable_runner_test.cljc @@ -21,6 +21,20 @@ (t/use-fixtures :each thi/test-fixture) +(t/deftest undo-comparison-allows-only-negligible-geometry-noise + (let [situation (setup/simple-component-with-labeled-copy) + file (tm/file situation) + id (:id (tm/role-shape situation :copy-instance)) + path [:data :pages-index (:id (thf/current-page file)) :objects id] + before (-> (uc/snapshot file) + (assoc-in (conj path :height) 0.01) + (assoc-in (conj path :opacity) 0.4)) + failures #(uc/failures (uc/compare-states {} nil :undo before %))] + (t/is (empty? (failures (assoc-in before (conj path :height) 0.010000000000005116)))) + (t/is (seq (failures (assoc-in before (conj path :height) 0.01001)))) + (t/is (seq (failures (assoc-in before (conj path :opacity) (+ 0.4 5e-15))))) + (t/is (seq (failures (assoc-in before (conj path :touched) #{:geometry-group})))))) + (t/deftest undo-main-child-structure-restores-remote-sync-state (let [initial (setup/nested-component-with-copy) updated (-> initial