penpot/frontend/test/frontend_tests/data/workspace_undo_test.cljs
alonso.torres a261e00a33 🐛 Keep a file reload from doubling the workspace streams
Reloading the open file (a peer's restore, the fallback of a rollback)
runs initialize-workspace again without leaving the workspace. The
streams the first run started kept running next to the new ones, so a
commit added one undo entry per reload and each notification was
handled twice. The old subscription also sent its unsubscribe after the
new subscribe, which ended the new one on the server.

A new initialize-workspace, text reflow, shape layout or notification
subscription stops the one before it, and a subscription replaced by
one to the same file sends no unsubscribe.
2026-11-05 10:56:46 +01:00

275 lines
13 KiB
Clojure

;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.data.workspace-undo-test
"Tests for the undo groups of commits and history entries, and for
forgetting them after a rollback."
(:require
[app.common.test-helpers.files :as cthf]
[app.common.test-helpers.ids-map :as cthi]
[app.common.uuid :as uuid]
[app.main.data.changes :as dch]
[app.main.data.comments :as dcmt]
[app.main.data.fonts :as df]
[app.main.data.workspace :as dw]
[app.main.data.workspace.undo :as dwu]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.async :as async]
[frontend-tests.helpers.mock :as mock]
[frontend-tests.helpers.state :as ths]
[potok.v2.core :as ptk]))
(t/use-fixtures :each {:before cthi/reset-idmap!})
(defn- rename-page
"Commits a page rename, with `params` for `commit-changes`."
[page-id name & {:as params}]
(dch/commit-changes (merge {:redo-changes [{:type :mod-page :id page-id :name name}]
:undo-changes [{:type :mod-page :id page-id :name "Page 1"}]}
params)))
(defn- run-commits
"Runs `events` on a store of a sample file and calls `check` with the
undo groups of the commits made and the final state."
[done events check]
(let [file (cthf/sample-file :file1 :page-label :page1)
store (ths/setup-store file)
groups (atom [])
sub (->> (ptk/input-stream store)
(rx/filter dch/commit?)
(rx/subs! #(swap! groups conj (:undo-group (deref %)))))]
(ths/run-store store done (events (cthf/current-page-id file))
(fn [state]
(rx/dispose! sub)
(check @groups state)))))
;; Scenario: an action commits twice inside one undo transaction (as applying
;; a token to several shapes does). Proves: both commits carry the undo group
;; of the transaction, which its history entry takes too, so a rejected save
;; reverts the whole Ctrl+Z step, not half of it.
(t/deftest commits-inside-a-transaction-share-its-undo-group
(t/async
done
(let [tx (js/Symbol)
tx-group (atom nil)
spy (ptk/reify ::spy-transaction
ptk/EffectEvent
(effect [_ state _]
(reset! tx-group (get-in state [:workspace-undo :transaction :commit-group]))))]
(run-commits done
(fn [page-id]
[(dwu/start-undo-transaction tx)
spy
(rename-page page-id "A")
(rename-page page-id "B")
(dwu/commit-undo-transaction tx)])
(fn [groups _]
(t/is (some? @tx-group) "the transaction has its own group")
(t/is (= [@tx-group @tx-group] groups)))))))
;; Scenario: commits made with no transaction open. Proves: each one keeps
;; its own undo group.
(t/deftest commits-outside-a-transaction-get-their-own-undo-group
(t/async
done
(run-commits done
(fn [page-id]
[(rename-page page-id "A")
(rename-page page-id "B")])
(fn [groups _]
(t/is (= 2 (count (distinct groups))))))))
;; Scenario: inside a transaction, a commit names its undo group (e.g. a
;; component update that also writes the library). Proves: the explicit group
;; is kept.
(t/deftest an-explicit-undo-group-is-kept-inside-a-transaction
(t/async
done
(let [tx (js/Symbol)
group (uuid/next)]
(run-commits done
(fn [page-id]
[(dwu/start-undo-transaction tx)
(rename-page page-id "A" :undo-group group)
(dwu/commit-undo-transaction tx)])
(fn [groups _]
(t/is (= [group] groups)))))))
;; Scenario: a rollback forgets undo groups while a transaction is open, then
;; the transaction commits again. Proves: the later commit keeps the group of
;; the earlier one, so the transaction stays one rollback unit.
(t/deftest a-transaction-keeps-its-undo-group-after-forget-undo-groups
(t/async
done
(let [tx (js/Symbol)]
(run-commits done
(fn [page-id]
[(dwu/start-undo-transaction tx)
(rename-page page-id "A")
(ptk/reify ::forget-the-transaction-group
ptk/UpdateEvent
(update [_ state]
(let [group (get-in state [:workspace-undo :transaction :commit-group])]
;; The transaction holds the first commit, as `append-undo` records it
(->> (assoc-in state [:workspace-undo :transaction :undo-groups] #{group})
(ptk/update (dwu/forget-undo-groups #{group} nil))))))
(rename-page page-id "B")
(dwu/commit-undo-transaction tx)])
(fn [[first-group second-group :as groups] _]
(t/is (= 2 (count groups)))
(t/is (some? first-group))
(t/is (= first-group second-group)))))))
(defn- entry
[page-id name group]
{:undo-changes [{:type :mod-page :id page-id :name "Page 1"}]
:redo-changes [{:type :mod-page :id page-id :name name}]
:undo-group group
:tags #{}})
;; Scenario: inside one transaction, a commit names another undo group (as a
;; component update does). Proves: the history entry records both groups,
;; so a rollback of either one finds it.
(t/deftest a-transaction-entry-records-every-group-it-holds
(let [page-id (uuid/next)
[g1 g2] (repeatedly 2 uuid/next)
tx (js/Symbol)
state (reduce #(ptk/update %2 %1)
{}
[(dwu/start-undo-transaction tx)
(dwu/append-undo (entry page-id "A" g1) false)
(dwu/append-undo (entry page-id "B" g2) false)
(dwu/commit-undo-transaction tx)])]
(t/is (= #{g1 g2} (get-in state [:workspace-undo :items 0 :undo-groups])))))
;; Scenario: a commit stacks onto the last history entry. Proves: the entry
;; records the stacked commit's group too.
(t/deftest a-stacked-commit-adds-its-group-to-the-entry
(let [page-id (uuid/next)
[g1 g2] (repeatedly 2 uuid/next)
state (reduce #(ptk/update %2 %1)
{}
[(dwu/append-undo (entry page-id "A" g1) false)
(dwu/append-undo (entry page-id "B" g2) true)])]
(t/is (= 1 (count (get-in state [:workspace-undo :items]))))
(t/is (= #{g1 g2} (get-in state [:workspace-undo :items 0 :undo-groups])))))
;; Scenario: the user presses Ctrl+Z on a page rename. Proves: the commit it
;; makes can be undone (its undo changes redo the rename) and names the index
;; the history moved from, so a rollback can put both back.
(t/deftest an-undo-commit-can-be-undone
(t/async
done
(let [file (cthf/sample-file :file1 :page-label :page1)
page-id (cthf/current-page-id file)
store (ths/setup-store file)
commits (atom [])
sub (->> (ptk/input-stream store)
(rx/filter dch/commit?)
(rx/subs! #(swap! commits conj (deref %))))
item (assoc (entry page-id "A" (uuid/next)) :undo-groups #{})]
(ths/run-store store done
[#(assoc % :workspace-undo {:items [item] :index 0})
dwu/undo]
(fn [state]
(rx/dispose! sub)
(let [[commit] @commits]
(t/is (= 1 (count @commits)))
(t/is (= (:undo-changes item) (:redo-changes commit)))
(t/is (= (:redo-changes item) (:undo-changes commit)))
(t/is (= 0 (:undo-index commit)))
(t/is (= -1 (get-in state [:workspace-undo :index])))))))))
;; Forgetting the undo groups of a rollback.
(defn- entry-of-groups
"An undo entry holding commits of `groups`, the first one naming it."
[& groups]
{:undo-group (first groups) :undo-groups (set groups)})
(defn- forget
"The undo state after forgetting `groups` from `undo-state`."
[undo-state groups undo-index]
(:workspace-undo (ptk/update (dwu/forget-undo-groups groups undo-index)
{:workspace-undo undo-state})))
(t/deftest forgetting-undo-groups-cuts-the-history-at-the-first-match
(let [[g1 g2 g3] (repeatedly 3 uuid/next)
undo (forget {:items [(entry-of-groups g1) (entry-of-groups g2) (entry-of-groups g3)]
:index 2
:transaction {:undo-group g3 :undo-changes [{:type :mod-page}]}}
#{g2 g3} nil)]
(t/is (= [g1] (mapv :undo-group (:items undo))))
(t/is (= 0 (:index undo)))
(t/is (empty? (get-in undo [:transaction :undo-changes])) "the open transaction is emptied")
(t/is (= g3 (get-in undo [:transaction :commit-group]))
"its later commits keep the group of its earlier ones")))
(t/deftest forgetting-undo-groups-matches-any-group-an-entry-holds
(let [[g1 g2 g3] (repeatedly 3 uuid/next)
undo (forget {:items [(entry-of-groups g1) (entry-of-groups g2 g3)] :index 1}
#{g3} nil)]
(t/is (= [g1] (mapv :undo-group (:items undo))))
(t/is (= 0 (:index undo)))))
(t/deftest forgetting-undo-groups-keeps-the-history-without-a-match
(let [[g1 g2] (repeatedly 2 uuid/next)
tx {:undo-group g2 :undo-groups #{g2} :undo-changes [{:type :mod-page}]}
undo (forget {:items [(entry-of-groups g1)] :index 0 :transaction tx}
#{(uuid/next)} nil)]
(t/is (= [g1] (mapv :undo-group (:items undo))))
(t/is (= 0 (:index undo)))
(t/is (= tx (:transaction undo)) "an unrelated open transaction stays")))
(t/deftest forgetting-undo-groups-empties-a-transaction-that-holds-one
(let [[g1 g2] (repeatedly 2 uuid/next)
tx {:undo-group g2 :undo-groups #{g2} :undo-changes [{:type :mod-page}]}
undo (forget {:items [(entry-of-groups g1)] :index 0 :transaction tx}
#{g2} nil)]
(t/is (= [g1] (mapv :undo-group (:items undo))) "the history stays")
(t/is (empty? (get-in undo [:transaction :undo-changes])))
(t/is (= g2 (get-in undo [:transaction :commit-group])))))
(t/deftest forgetting-undo-groups-points-back-to-the-index-of-an-undone-undo
(let [[g1 g2 g3] (repeatedly 3 uuid/next)
undo-state {:items [(entry-of-groups g1) (entry-of-groups g2) (entry-of-groups g3)]
:index 0}]
(t/is (= 2 (:index (forget undo-state #{(uuid/next)} 2))))
(t/is (= 0 (:index (forget undo-state #{g2} 2)))
"a cut history cannot point past its end")))
;; Scenario: the workspace opens and then reloads twice (a peer's restore,
;; the rollback fallback), each reload running `initialize-workspace` again
;; without leaving the workspace; then one commit is made. Proves: each
;; reload replaces the workspace streams, so the commit adds one undo entry,
;; not one per reload.
(t/deftest ^:async a-workspace-reload-adds-one-undo-entry-per-commit
(let [file (cthf/sample-file :file1 :page-label :page1)
team-id (uuid/next)
store (ths/setup-store file {:renderer :svg})
loaded (fn [type] (mock/stub (fn [& _] (ptk/data-event type {}))))]
(await
(mock/with-mocks*
{dw/initialize-file (loaded ::file-requested)
dcmt/retrieve-comment-threads (loaded ::threads-requested)
dcmt/fetch-profiles (loaded ::profiles-requested)
df/fetch-fonts (loaded ::fonts-requested)}
(try
(ptk/emit! store
#(assoc % :current-team-id team-id)
(dw/initialize-workspace team-id (:id file))
(ptk/event ::dw/reload-current-file)
(ptk/event ::dw/reload-current-file))
(await (async/settle))
(ptk/emit! store (rename-page (cthf/current-page-id file) "Renamed"))
(await (async/wait-for #(seq (get-in @store [:workspace-undo :items]))
"the commit reaches the undo history"))
(await (async/settle))
(t/is (= 1 (count (get-in @store [:workspace-undo :items]))))
(finally
(rx/dispose! store)))))))