✨ 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
This commit is contained in:
alonso.torres 2026-10-28 00:07:02 +01:00
parent e4fb356cb5
commit 5fb7f55c95
3 changed files with 264 additions and 2 deletions

View File

@ -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]

View File

@ -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))))

View File

@ -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