From 1732b730a2fbd1534723ae1f743d75aa468bbf39 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Fri, 30 Oct 2026 08:34:58 +0100 Subject: [PATCH] :bug: 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 --- .../composable_tests/generated_test.cljs | 111 ++++++++++++++++++ .../composable_tests/interpreter.cljs | 25 +++- 2 files changed, 130 insertions(+), 6 deletions(-) diff --git a/frontend/test/frontend_tests/composable_tests/generated_test.cljs b/frontend/test/frontend_tests/composable_tests/generated_test.cljs index 8e712a6afe..0fdd79a4dc 100644 --- a/frontend/test/frontend_tests/composable_tests/generated_test.cljs +++ b/frontend/test/frontend_tests/composable_tests/generated_test.cljs @@ -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]] diff --git a/frontend/test/frontend_tests/composable_tests/interpreter.cljs b/frontend/test/frontend_tests/composable_tests/interpreter.cljs index dd848d15c1..b9f9b4a317 100644 --- a/frontend/test/frontend_tests/composable_tests/interpreter.cljs +++ b/frontend/test/frontend_tests/composable_tests/interpreter.cljs @@ -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))