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