🐛 Checkpoint generated histories after foreign writes

Verify each consumer history segment before opening a source editor.
Start new segments after external writes while retaining undo entries,
no-op baselines and recorded failures.

Add real-event controls for update-main, older undo/redo and failure
preservation. The original F250 regression passes with unchanged assertions.

Validation: 1,768 frontend tests, 15,004 assertions; lint and format pass.
This changes only the frontend test harness; no sync benchmark is needed.

AI-assisted-by: gpt-6
This commit is contained in:
alonso.torres 2026-10-30 08:34:58 +01:00
parent 69c328ca5b
commit 1732b730a2
2 changed files with 130 additions and 6 deletions

View File

@ -284,6 +284,117 @@
(t/is (empty? (uc/failures (await (ftm/finish-generated live)))))
(ftm/uninstall!)))))
(t/deftest ^:async source-history-checkpoints-legitimate-consumer-writes
(await
(mock/with-mocks* {rp/cmd! mock/rpc-cmd-mock rx/timer mock/timer-mock}
(t/is (map? (cg/run {:world [0 0 {:library true :library-depth 0 :stale-libraries true}]
:ops [[:library-edit 0 0 1] [:library-sync] [:edit 0 1 1 2]
[:update-main 0] [:library-edit 0 0 2]]})))
(let [initial (cg/world [0 0 {:library true :library-depth 0 :stale-libraries true}])
edit (cg/resolve-op initial {} [:library-edit 0 0 1])
owner (:library-id edit)
child (get-in edit [:shape :id])
main (get-in edit [:shape :parent-id])
copy (get-in (cg/resolve-op initial {} [:edit 0 1 1 2]) [:shape :id])
page-id (cthf/current-page-id (get (cf/all-files initial) owner))
live (ftm/start-generated initial)
live (await (ftm/run-generated-source-step live owner (:op edit)))
entry (get-in live [::ftm/source-editors owner :undo :items 0])
live (await (ftm/run-generated-step live (n/sync-from-library owner)))
live (await (ftm/run-generated-step live (n/change-property (constantly copy) :opacity 0.4)))
live (await (ftm/run-generated-step live (n/update-main (constantly copy))))
source (get (cf/all-files live) owner)
_ (t/is (= 0.4 (:opacity (tm/shape-by-id (tm/with-file live source) main))))
live (await (ftm/run-generated-source-step live owner
(n/change-property (constantly child) :fills "#00ff00")))
editor (get-in live [::ftm/source-editors owner])
base (uc/baseline (:situation editor))
source (get (cf/all-files live) owner)]
(t/is (= "#00ff00" (get-in (tm/shape-by-id (tm/with-file live source) child) [:fills 0 :fill-color])))
(t/is (= 0.4 (:opacity (tm/shape-by-id (tm/with-file live source) main))))
(t/is (= entry (get-in editor [:undo :items 0])))
(t/is (= 2 (count (get-in editor [:undo :items]))))
(t/is (= 0 (:depth base)))
(t/is (= 0.4 (get-in base [:snapshot :files owner :data :pages-index page-id :objects main :opacity])))
(t/is (= "#ff0000" (get-in base [:snapshot :files owner :data :pages-index page-id :objects child :fills 0 :fill-color])))
(t/is (empty? (uc/failures (await (ftm/finish-generated live)))))
(ftm/uninstall!)))))
(t/deftest ^:async source-history-checkpoints-preserve-no-ops-and-older-entries
(await
(mock/with-mocks* {rp/cmd! mock/rpc-cmd-mock rx/timer mock/timer-mock}
(let [initial (cg/world [0 0 {:library true :library-depth 0 :stale-libraries true}])
edit (cg/resolve-op initial {} [:library-edit 0 0 1])
owner (:library-id edit)
child (get-in edit [:shape :id])
main (get-in edit [:shape :parent-id])
copy (get-in (cg/resolve-op initial {} [:edit 0 1 1 2]) [:shape :id])
live (ftm/start-generated initial)
live (await (ftm/run-generated-source-step live owner (:op edit)))
live (await (ftm/run-generated-source-step live owner
(n/change-property (constantly child) :fills "#00ff00")))
editor (get-in live [::ftm/source-editors owner])
_ (t/is (= -1 (:depth (uc/baseline (:situation editor)))))
entries (:items (:undo editor))
live (await (ftm/run-generated-step live (n/sync-from-library owner)))
live (await (ftm/run-generated-step live (n/change-property (constantly copy) :opacity 0.4)))
live (await (ftm/run-generated-step live (n/update-main (constantly copy))))
base (uc/baseline live)
live (await (ftm/run-generated-source-step live owner
(n/change-property (constantly child) :fills "#00ff00")))
editor (get-in live [::ftm/source-editors owner])]
(t/is (= base (uc/baseline live)))
(t/is (= 1 (:depth (uc/baseline (:situation editor)))))
(t/is (= entries (:items (:undo editor))))
(t/is (empty? (uc/failures (await (ftm/finish-generated live)))))
(let [live (loop [live live steps [[(n/undo) "#ff0000"] [(n/undo) "#abcdef"]
[(n/redo) "#ff0000"] [(n/redo) "#00ff00"]]]
(if-let [[op fill] (first steps)]
(let [live (await (ftm/run-generated-source-step live owner op))
source (tm/with-file live (get (cf/all-files live) owner))]
(t/is (= fill (get-in (tm/shape-by-id source child) [:fills 0 :fill-color])))
(t/is (= 0.4 (:opacity (tm/shape-by-id source main))))
(recur live (rest steps)))
live))
live (await (ftm/run-generated-step live (n/undo)))
_ (t/is (nil? (:opacity (tm/shape-by-id (tm/with-file live (get (cf/all-files live) owner)) main))))
live (await (ftm/run-generated-step live (n/redo)))
file (tm/with-file live (get (cf/all-files live) owner))]
(t/is (= 0.4 (:opacity (tm/shape-by-id file main))))
(t/is (= "#00ff00" (get-in (tm/shape-by-id file child) [:fills 0 :fill-color])))
(t/is (empty? (uc/failures (await (ftm/finish-generated live))))))
(ftm/uninstall!)))))
(t/deftest ^:async source-handoff-reports-and-retains-a-broken-consumer-segment
(await
(mock/with-mocks* {rp/cmd! mock/rpc-cmd-mock rx/timer mock/timer-mock}
(let [initial (cg/world [0 0 {:library true :library-depth 0 :stale-libraries true}])
edit (cg/resolve-op initial {} [:library-edit 0 0 1])
owner (:library-id edit)
copy (get-in (cg/resolve-op initial {} [:edit 0 1 1 2]) [:shape :id])
file-id (:id (tm/file initial))
page-id (cthf/current-page-id (tm/file initial))
live (ftm/start-generated initial)
live (await (ftm/run-generated-step live (n/change-property (constantly copy) :opacity 0.4)))
base (uc/baseline live)
;; Corrupt the expected pre-edit state. Handoff must report this
;; whole-segment failure before a source edit advances the baseline.
live (uc/mark-baseline live (:depth base)
(assoc-in (:snapshot base)
[:files file-id :data :pages-index page-id :objects copy :opacity] 0.9))
reports (atom [])
live (await (mock/with-mocks* {t/report #(swap! reports conj %)}
(ftm/run-generated-source-step live owner (:op edit))))
failed (filter #(= :fail (:type %)) @reports)
errors (filter #(= :error (:type %)) @reports)]
(t/is (= 1 (count failed)))
(t/is (empty? errors))
(t/is (= [:variant-undo] (mapv :phase (uc/failures live))))
(t/is (= #{[:file :file1 :object :random-library-copy-0-0 :opacity]}
(:signature (first (uc/failures live)))))
(t/is (= 0 (:depth (uc/baseline live))))
(ftm/uninstall!)))))
(t/deftest ^:async source-editors-retain-their-own-undo-and-redo-histories
(doseq [ops [[[:library-add-child 0] [:library-add-child 0] [:library-undo 0]
[:edit 0 1 1 2] [:library-redo 0] [:undo] [:redo]]

View File

@ -959,20 +959,26 @@
(let [situation (refresh-generated situation)
baseline (uc/baseline situation)
current (snapshot)]
(if baseline
;; Another editor can change loaded libraries between this editor's
;; steps. Keep its own undo baseline and refresh only foreign files.
(if (and baseline
(= (uc/snapshot (dissoc file :library-of)
{:position-data? (not (features/active-feature? @st/state "render-wasm/v1"))})
(dissoc (get-in current [:files (:id file)]) :library-of)))
;; Keep the verified segment while only foreign inputs changed.
(uc/mark-baseline situation (:depth baseline)
(assoc current :files
(assoc (:files current) (:id file)
(get-in baseline [:snapshot :files (:id file)]))))
;; A foreign editor wrote this file after its last verified segment.
;; Retain its undo entries, but start the next segment at this state.
(mark-baseline situation)))))
(defn ^:async run-generated-source-step
"Run a source edit in its owning editor and retain both undo histories.
Returning to the consumer loads source data without accepting it."
[situation owner op]
(let [consumer (get-generated-editor situation)
(let [situation (await (check-variant situation))
_ (t/is (empty? (uc/failures situation)) (pr-str (uc/failures situation)))
consumer (get-generated-editor situation)
files (cf/all-files situation)
editor (or (get-in situation [::source-editors owner])
{:situation (-> (tm/make-situation (get files owner))
@ -988,10 +994,17 @@
(throw (ex-info "Source editor step failed" {:owner owner})))
(t/is (= (dissoc (:files before) owner) (dissoc (:files (snapshot)) owner))
"A source editor must not write another file")
(let [source (await (check-variant source))
(let [changed? (not= before (snapshot))
source (await (check-variant source))
editor (get-generated-editor source)
consumer (update consumer :situation merge (select-keys source [:accepted-libraries :shape-libraries]))
live (resume-generated-editor consumer)]
baseline (uc/baseline (:situation consumer))
live (resume-generated-editor consumer)
;; The consumer's verified history can itself write the source.
;; A real source edit closes that segment; a no-op keeps it exact.
live (if changed?
(mark-baseline live)
(uc/mark-baseline live (:depth baseline) (:snapshot baseline)))]
(t/is (empty? (uc/failures source)) (pr-str (uc/failures source)))
(-> live
(assoc ::source-editors (assoc (::source-editors situation) owner editor))