From 5fb7f55c955f2195b3971855c280ad88fad8584b Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Wed, 28 Oct 2026 00:07:02 +0100 Subject: [PATCH] :sparkles: Test text updates across dependent libraries Nest the source text component in the dependent main before making consumer copies. Capture fixture identities before edits, and cover letters plus all twelve style units at both acceptance boundaries. Check early acceptance of a stale dependent, direct copies that stay stale, and each editor's own undo history. Corrupt-result controls reject premature reference advancement and missing or premature propagation. Require meaningful text changes in direct and transitive source loops. The async frontend matrix drives both acceptance events and forwards the dependent editor's actual changes to the separate consumer editor. Its 13 histories pass 429 assertions, including exact undo and redo. Validation: full common JVM/JS, frontend, backend and exporter suites; all six generated properties at 40 programs, seed 712; lint/format and a browser ShapeProxy replay. Golden data is unchanged. Wider generation and pending-work coverage, plus fresh 5,000-program gates, remain open. AI-assisted-by: GPT-6 --- .../common/test_helpers/composable/gen.cljc | 29 +++- .../common_tests/logic/source_gen_test.cljc | 144 ++++++++++++++++++ .../completion_contract_test.cljs | 93 ++++++++++- 3 files changed, 264 insertions(+), 2 deletions(-) diff --git a/common/src/app/common/test_helpers/composable/gen.cljc b/common/src/app/common/test_helpers/composable/gen.cljc index 0527796b99..3aab239e14 100644 --- a/common/src/app/common/test_helpers/composable/gen.cljc +++ b/common/src/app/common/test_helpers/composable/gen.cljc @@ -108,7 +108,10 @@ (as-> s (tm/apply-to (n/create-component "library-dependent" setup/labeled-fill :size 100) s)) tm/file (thc/instantiate-component :sync-library-random-component :library-dependency-copy - :parent-label :sync-library-dependent-head :library library))) + :parent-label :sync-library-dependent-head :library library) + (cond-> (:library-text opts) + (thc/instantiate-component :library-text-component :library-dependency-text-copy + :parent-label :sync-library-dependent-head :library library)))) libraries (cond-> {(:id library) library} dependent (assoc (:id dependent) dependent)) components (for [[_ source] (ordered-files libraries) entry (tree (tm/with-file situation source)) @@ -124,6 +127,30 @@ [index source component copy]))] (-> situation (tm/with-file file) (tm/with-aux-files libraries)))) +(defn get-library-text-chain + "Participant ids from the initial three-file text dependency fixture. + Capture these before editing, so later results cannot choose expectations." + [situation] + (let [source-id (thi/id :random-library) + dependent-id (thi/id :dependent-library) + source-text-id (thi/id :library-text-child) + dependent-head-id (thi/id :library-dependency-text-copy) + dependent (get (tm/aux-files situation) dependent-id) + dependent-text-id (first (:shapes (tm/shape-by-id (tm/with-file situation dependent) dependent-head-id))) + copies (tree situation)] + {:source-id source-id + :dependent-id dependent-id + :source-text-id source-text-id + :dependent-head-id dependent-head-id + :dependent-text-id dependent-text-id + :consumer-root-ids (into [] (keep (fn [{:keys [shape]}] + (when (= (thi/id :sync-library-dependent-head) (:shape-ref shape)) + (:id shape)))) copies) + :consumer-text-ids (into [] (keep (fn [{:keys [shape]}] + (when (= dependent-text-id (:shape-ref shape)) (:id shape)))) copies) + :direct-text-ids (into [] (keep (fn [{:keys [shape]}] + (when (= source-text-id (:shape-ref shape)) (:id shape)))) copies)})) + (defn forest "All live page trees, with stable file/page prefixes for model replay." [situation] diff --git a/common/test/common_tests/logic/source_gen_test.cljc b/common/test/common_tests/logic/source_gen_test.cljc index d3f766de68..422feac1d8 100644 --- a/common/test/common_tests/logic/source_gen_test.cljc +++ b/common/test/common_tests/logic/source_gen_test.cljc @@ -6,11 +6,15 @@ (ns common-tests.logic.source-gen-test (:require + [app.common.test-helpers.composable.comp.files :as cf] [app.common.test-helpers.composable.comp.runner :as r] [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.files :as thf] [app.common.test-helpers.ids-map :as thi] + [app.common.test-helpers.shapes :as ths] + [app.common.test-helpers.texts :as thtx] [clojure.test :as t])) (t/use-fixtures :each thi/test-fixture) @@ -18,6 +22,143 @@ (def source-world [2 1 {:library true :stale-libraries true :library-text true :text true :text-styles true}]) +(def dependency-text-world + [2 0 {:library true :stale-libraries true :library-dependency true :library-text true}]) + +(t/deftest dependency-text-fixture-links-source-through-the-dependent-main + (let [situation (cg/world dependency-text-world) + {:keys [source-id dependent-id source-text-id dependent-head-id dependent-text-id + consumer-root-ids consumer-text-ids direct-text-ids]} + (cg/get-library-text-chain situation) + dependent (get (tm/aux-files situation) dependent-id) + head (tm/shape-by-id (tm/with-file situation dependent) dependent-head-id) + text (tm/shape-by-id (tm/with-file situation dependent) dependent-text-id) + main (ths/get-shape dependent :sync-library-dependent-head) + ids (map (comp :id :shape) (cg/forest situation))] + (t/is (some? head)) + (t/is (= source-id (:component-file head))) + (t/is (some #{dependent-head-id} (:shapes main))) + (t/is (= source-text-id (:shape-ref text))) + (t/is (= 2 (count consumer-root-ids))) + (t/is (= 2 (count consumer-text-ids))) + (t/is (= 2 (count direct-text-ids))) + (t/is (not-any? (set direct-text-ids) consumer-text-ids)) + (t/is (= (count ids) (count (set ids)))))) + +(defn- get-text + [state file-id shape-id] + (let [situation (:situation state) + file (get (cf/all-files situation) file-id)] + (thtx/view (:content (tm/shape-by-id (tm/with-file situation file) shape-id))))) + +(defn- get-history-depth + [state file-id] + (count (get-in state [:situation :library-histories file-id :undo]))) + +(t/deftest transitive-text-waits-for-both-library-acceptances + (doseq [tuple (into [[:library-text 0 0 1]] + (map #(vector :library-text-style 0 % 1) (range 12)))] + (t/testing (str tuple) + (let [initial (cg/initial-state dependency-text-world) + situation (:situation initial) + {:keys [source-id dependent-id source-text-id dependent-text-id + consumer-text-ids direct-text-ids]} (cg/get-library-text-chain situation) + current-id (:id (tm/file situation)) + expected (thtx/normalize (:value (:op (cg/resolve-op situation {} tuple)))) + original (get-text initial source-id source-text-id) + edited (-> initial (cg/step tuple) cg/settle-state) + early-accept (-> edited (cg/step [:library-sync 0]) cg/settle-state) + upstream (-> early-accept (cg/step [:library-accept 0]) cg/settle-state) + accepted (-> upstream (cg/step [:library-sync 0]) cg/settle-state)] + (t/is (not= original expected) "the requested source edit changes text") + (t/is (= expected (get-text edited source-id source-text-id))) + (t/is (= (dissoc (oracle/snapshot situation) source-id) + (dissoc (oracle/snapshot (:situation edited)) source-id)) + "a source edit changes neither dependent nor consumer files") + (t/is (= (oracle/snapshot (:situation edited)) + (oracle/snapshot (:situation early-accept))) + "accepting a still-stale dependent cannot pull the upstream text") + (t/is (zero? (r/undo-depth (:situation early-accept)))) + (t/is (= original (get-text early-accept dependent-id dependent-text-id))) + (t/is (= expected (get-text upstream dependent-id dependent-text-id))) + (t/is (= (dissoc (oracle/snapshot (:situation early-accept)) dependent-id) + (dissoc (oracle/snapshot (:situation upstream)) dependent-id)) + "upstream acceptance changes only the dependent editor's file") + (t/is (= (repeat 2 original) (map #(get-text upstream current-id %) consumer-text-ids))) + (t/is (zero? (r/undo-depth (:situation upstream)))) + (t/is (= 1 (get-history-depth upstream source-id))) + (t/is (= 1 (get-history-depth upstream dependent-id))) + (t/is (= (repeat 2 expected) (map #(get-text accepted current-id %) consumer-text-ids))) + (t/is (= (repeat 2 original) (map #(get-text accepted current-id %) direct-text-ids)) + "accepting the dependent does not accept direct source copies") + (t/is (= (dissoc (oracle/snapshot (:situation upstream)) current-id) + (dissoc (oracle/snapshot (:situation accepted)) current-id))) + (t/is (= 1 (r/undo-depth (:situation accepted)))) + (t/is (map? (cg/run {:world dependency-text-world + :ops [tuple [:library-sync 0] [:library-accept 0] [:library-sync 0] + [:undo] [:redo] [:library-undo 1] [:library-redo 1] + [:library-undo 0] [:library-redo 0]]}))))))) + +(t/deftest transitive-text-editors-undo-only-their-own-files + (let [initial (cg/initial-state dependency-text-world) + {:keys [source-id dependent-id]} (cg/get-library-text-chain (:situation initial)) + accepted (-> initial (cg/step [:library-text-style 0 2 1]) cg/settle-state + (cg/step [:library-accept 0]) cg/settle-state + (cg/step [:library-sync 0]) cg/settle-state) + current-id (:id (tm/file (:situation accepted))) + before (oracle/snapshot (:situation accepted))] + (doseq [[owner undo redo] [[current-id [:undo] [:redo]] + [source-id [:library-undo 1] [:library-redo 1]] + [dependent-id [:library-undo 0] [:library-redo 0]]]] + (t/testing (str owner) + (let [undone (-> accepted (cg/step undo) cg/settle-state) + redone (-> undone (cg/step redo) cg/settle-state) + after (oracle/snapshot (:situation undone))] + (t/is (not= (get before owner) (get after owner))) + (t/is (= (dissoc before owner) (dissoc after owner))) + (t/is (= before (oracle/snapshot (:situation redone))))))))) + +(defn- get-invariant + [situation model] + (try (oracle/check situation model) nil + (catch #?(:clj Exception :cljs js/Error) error + (:invariant (ex-data error))))) + +(t/deftest transitive-text-checks-reject-premature-reference-advancement-and-corrupt-results + (let [initial (cg/initial-state dependency-text-world) + {:keys [source-id dependent-id source-text-id dependent-head-id dependent-text-id + consumer-root-ids consumer-text-ids direct-text-ids]} + (cg/get-library-text-chain (:situation initial)) + current-id (:id (tm/file (:situation initial))) + original (:content (tm/shape-by-id (tm/with-file (:situation initial) + (get (cf/all-files (:situation initial)) source-id)) + source-text-id)) + edited (-> initial (cg/step [:library-text-style 0 2 1]) cg/settle-state) + upstream (-> edited (cg/step [:library-accept 0]) cg/settle-state) + accepted (-> upstream (cg/step [:library-sync 0]) cg/settle-state) + new-content (:content (tm/shape-by-id (tm/with-file (:situation edited) + (get (cf/all-files (:situation edited)) source-id)) + source-text-id))] + (doseq [[state owner root] [[edited dependent-id dependent-head-id] + [upstream current-id (first consumer-root-ids)]]] + (let [situation (:situation state) + advanced (cf/remember-subtree situation owner root (cf/all-files situation))] + (t/is (= :I4 (get-invariant advanced (:model state))) + "a newer reference cannot explain away a stale copy"))) + (doseq [[owner id content] (concat [[dependent-id dependent-text-id original]] + (map #(vector current-id % original) consumer-text-ids) + (map #(vector current-id % new-content) direct-text-ids))] + (let [situation (:situation accepted) + files (cf/all-files situation) + file (get files owner) + path [:data :pages-index (thf/current-page-id file) :objects id :content] + corrupted (assoc-in file path content) + situation (if (= owner current-id) + (tm/with-file situation corrupted) + (tm/with-aux-files situation (assoc (tm/aux-files situation) owner corrupted)))] + (t/is (= :I4 (get-invariant situation (:model accepted))) + "dropped propagation and premature direct acceptance both fail"))))) + (t/deftest a-transform-does-not-accept-pending-library-content-or-structure (doseq [[edit index] [[[:library-text 0 1 1] 8] [[:library-text 0 0 1] 8] @@ -59,6 +200,9 @@ (let [edited (-> initial (cg/step tuple) cg/settle-state)] (t/is (not= (get (oracle/snapshot situation) owner) (get (oracle/snapshot (:situation edited)) owner))) + (t/is (not= (thtx/view (:content (:shape resolved))) + (thtx/normalize (:value (:op resolved)))) + (str "meaningful source text action " tuple)) (t/is (= (dissoc (oracle/snapshot situation) owner) (dissoc (oracle/snapshot (:situation edited)) owner))) (t/is (zero? (r/undo-depth (:situation edited)))) diff --git a/frontend/test/frontend_tests/composable_tests/completion_contract_test.cljs b/frontend/test/frontend_tests/composable_tests/completion_contract_test.cljs index fe4097fd7f..895bdc7792 100644 --- a/frontend/test/frontend_tests/composable_tests/completion_contract_test.cljs +++ b/frontend/test/frontend_tests/composable_tests/completion_contract_test.cljs @@ -15,6 +15,7 @@ [app.common.logic.shapes :as cls] [app.common.math :as mth] [app.common.test-helpers.components :as thc] + [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] @@ -401,7 +402,8 @@ [tuple transform-tuple] (let [initial (cg/initial-state [2 1 {:library true :stale-libraries true :library-text true}]) situation (:situation initial) - owner (:library-id (cg/resolve-op situation {} tuple)) + resolved (cg/resolve-op situation {} tuple) + owner (:library-id resolved) transform-tuple (when transform-tuple (assoc transform-tuple 1 (first (keep-indexed @@ -419,6 +421,10 @@ current-id (:id (tm/file situation)) before (get-in (ftm/snapshot) [:files current-id])] (t/is (seq changes) (str "source edit changes " tuple)) + (when (#{:library-text :library-text-style} (first tuple)) + (t/is (not= (thtx/view (:content (:shape resolved))) + (thtx/normalize (:value (:op resolved)))) + (str "meaningful source text action " tuple))) (t/is (= :settled (await (ftm/await-step [(dwl/ext-library-changed owner (ct/now) 1 changes)])))) (t/is (= before (get-in (ftm/snapshot) [:files current-id])) "notification keeps consumer copies stale") (t/is (empty? (get-in @st/state [:workspace-undo :items])) "notification creates no consumer history") @@ -466,6 +472,91 @@ (mock/with-mocks* {rp/cmd! mock/rpc-cmd-mock rx/timer mock/timer-mock} (await (source-acceptance-history [:library-text 0 1 1] [:transform 8 3 3 4]))))) +(defn- get-editor-situation + [situation owner] + (let [files (cf/all-files situation)] + (-> situation + (tm/with-file (get files owner)) + (tm/with-aux-files (dissoc files owner))))) + +(defn- get-live-text + [owner shape-id] + (thtx/view (:content (tm/shape-by-id (tm/make-situation (get-in @st/state [:files owner])) shape-id)))) + +(defn- ^:async transitive-text-acceptance-history + [tuple] + (let [initial (cg/initial-state [2 0 {:library true :stale-libraries true + :library-dependency true :library-text true}]) + situation (:situation initial) + {:keys [source-id dependent-id source-text-id dependent-text-id + consumer-text-ids direct-text-ids]} (cg/get-library-text-chain situation) + current-id (:id (tm/file situation)) + source (get (tm/aux-files situation) source-id) + original (thtx/view (:content (tm/shape-by-id (tm/make-situation source) source-text-id))) + expected (thtx/normalize (:value (:op (cg/resolve-op situation {} tuple)))) + edited (-> initial (cg/step tuple) cg/settle-state) + source-group (peek (get-in edited [:situation :library-histories source-id :runner ::r/undo-stack])) + source-changes (vec (mapcat :redo-changes source-group)) + upstream (-> edited (cg/step [:library-accept 0]) cg/settle-state) + accepted (-> upstream (cg/step [:library-sync 0]) cg/settle-state) + dependent-context (get-editor-situation situation dependent-id) + _ (ftm/start-generated dependent-context) + before-source-notification (ftm/snapshot)] + (t/is (not= original expected) (str "meaningful source action " tuple)) + (t/is (seq source-changes)) + (t/is (= :settled (await (ftm/await-step [(dwl/ext-library-changed source-id (ct/now) 1 source-changes)])))) + (t/is (= (dissoc (:files before-source-notification) source-id) + (dissoc (:files (ftm/snapshot)) source-id)) + "upstream notification changes only the loaded source") + (t/is (empty? (get-in @st/state [:workspace-undo :items]))) + (t/is (= expected (get-live-text source-id source-text-id))) + (t/is (= original (get-live-text dependent-id dependent-text-id))) + (let [before (ftm/snapshot)] + (t/is (= :settled (await (ftm/await-step [(dwl/sync-file dependent-id source-id)])))) + (t/is (= expected (get-live-text dependent-id dependent-text-id))) + (t/is (= (repeat 2 original) (map #(get-live-text current-id %) consumer-text-ids))) + (t/is (= (dissoc (:files before) dependent-id) (dissoc (:files (ftm/snapshot)) dependent-id))) + (let [after (ftm/snapshot) + actual (ftm/refresh-generated (get-editor-situation (:situation upstream) dependent-id)) + dependent-changes (:redo-changes (peek (get-in @st/state [:workspace-undo :items])))] + (t/is (seq dependent-changes)) + (t/is (nil? (oracle/check actual (:model upstream)))) + (t/is (= :settled (await (ftm/await-step [dwu/undo])))) + (t/is (uc/same-state? before (ftm/snapshot)) "dependent undo retains the loaded source") + (t/is (= :settled (await (ftm/await-step [dwu/redo])))) + (t/is (uc/same-state? after (ftm/snapshot)) "dependent redo restores accepted text") + ;; Install the separate consumer editor with its original stale data. + ;; Deliver the real dependent acceptance changes as its notification. + (ftm/start-generated situation) + (let [before-notifications (get-in (ftm/snapshot) [:files current-id])] + (t/is (= :settled (await (ftm/await-step [(dwl/ext-library-changed source-id (ct/now) 1 source-changes)])))) + (t/is (= :settled (await (ftm/await-step [(dwl/ext-library-changed dependent-id (ct/now) 1 dependent-changes)])))) + (t/is (= before-notifications (get-in (ftm/snapshot) [:files current-id]))) + (t/is (empty? (get-in @st/state [:workspace-undo :items]))) + (t/is (= expected (get-live-text source-id source-text-id))) + (t/is (= expected (get-live-text dependent-id dependent-text-id))) + (t/is (= (repeat 2 original) (map #(get-live-text current-id %) consumer-text-ids)))) + (let [before (ftm/snapshot)] + (t/is (= :settled (await (ftm/await-step [(dwl/sync-file current-id dependent-id)])))) + (t/is (= (repeat 2 expected) (map #(get-live-text current-id %) consumer-text-ids))) + (t/is (= (repeat 2 original) (map #(get-live-text current-id %) direct-text-ids)) + "accepting the dependent leaves direct source copies stale") + (t/is (= (dissoc (:files before) current-id) (dissoc (:files (ftm/snapshot)) current-id))) + (let [after (ftm/snapshot) + actual (ftm/refresh-generated (:situation accepted))] + (t/is (nil? (oracle/check actual (:model accepted)))) + (t/is (= :settled (await (ftm/await-step [dwu/undo])))) + (t/is (uc/same-state? before (ftm/snapshot)) "consumer undo retains both loaded libraries") + (t/is (= :settled (await (ftm/await-step [dwu/redo])))) + (t/is (uc/same-state? after (ftm/snapshot)) "consumer redo restores accepted text"))))))) + +(t/deftest ^:async transitive-text-waits-for-both-library-acceptances + (await + (mock/with-mocks* {rp/cmd! mock/rpc-cmd-mock rx/timer mock/timer-mock} + (doseq [tuple (into [[:library-text 0 0 1]] + (map #(vector :library-text-style 0 % 1) (range 12)))] + (await (transitive-text-acceptance-history tuple)))))) + (t/deftest ^:async edit-step-waits-for-slow-sync (let [sync-file dwl/sync-file] (await