mirror of
https://github.com/penpot/penpot.git
synced 2026-11-10 03:50:33 +00:00
* ⚡ Keep the translation flag on undo and redo of a move
Previously undos and redos always forced a component sync + thumbnail
rerender for every component touched even if the underlying commits were just
translations. This propagates the translation flag to undos and redos and
propagates it to commit-changes. An entry merged from several undos/redos is
a translation only if all of its undos/redos are translations.
This reduces a UI freeze when undoing a move of a board with 453 components
from 2.42s -> 0.22s.
AI-assisted-by: claude-sonnet-5-5
* :translation? -> :translation
---------
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
276 lines
13 KiB
Clojure
276 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 #{}
|
|
:translation false})
|
|
|
|
;; 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)))))))
|