;; 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.persistence-rollback-test "Tests for the rollback of a save the server partly rejected (`app.main.data.persistence`): which commits are reverted, how peers' changes survive, and how the UI state follows. The pure plan behind it is tested in `frontend-tests.data.persistence-commits-test`." (:require [app.common.geom.point :as gpt] [app.common.time :as ct] [app.common.types.shape :as cts] [app.common.uuid :as uuid] [app.main.data.changes :as dch] [app.main.data.modal :as-alias modal] [app.main.data.persistence :as dps] [app.main.data.workspace :as dw] [app.main.data.workspace.comments :as dwcm] [app.main.data.workspace.undo :as dwu] [app.main.errors :as errors] [app.util.i18n :as i18n] [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.persistence :as thp] [potok.v2.core :as ptk])) (defn- grouped "`commit` with its undo group set to `group`: one action that makes several commits gives them the same undo group." [commit group] (ptk/data-event ::dch/commit (assoc @commit :undo-group group))) (defn- add-shape-commit "A local commit of `file-id` that adds `shape` to the root of `page-id`." [file-id page-id {:keys [id] :as shape}] (ptk/data-event ::dch/commit {:id (uuid/next) :file-id file-id :file-revn 0 :file-vern 0 :source :local :features #{} :undo-group (uuid/next) :redo-changes [{:type :add-obj :id id :page-id page-id :parent-id uuid/zero :frame-id uuid/zero :obj shape}] :undo-changes [{:type :del-obj :id id :page-id page-id}]})) (defn- sent-changes "The changes of each group of the `n`th save request." [requests n] (mapv :changes (get-in (nth @requests n) [1 :changes-with-metadata]))) ;; Scenario: the first save carries two commits; while it is in flight a ;; third one is queued, an edit of another file is queued too, and a fourth ;; one waits in the buffer. The server keeps the first commit and rejects ;; the second. The file goes back to the first commit: the rejected one and ;; every later one of that file are undone and dropped from the queue, the ;; buffer and the undo history, while the other file's edit is still sent. ;; Proves: a rejection leaves the file as the server holds it and the queue ;; keeps going. (t/deftest ^:async a-rejected-save-undoes-the-rejected-commit-and-every-later-one (await (thp/with-persistence (^:async fn [{:keys [file-id response requests store failures]}] (let [page-id (uuid/next) shape-id (uuid/next) other-id (uuid/next) commit1 (thp/rename-commit file-id page-id shape-id "A" "B") commit2 (thp/rename-commit file-id page-id shape-id "B" "C") commit3 (thp/rename-commit file-id page-id shape-id "C" "D") commit4 (thp/rename-commit file-id page-id shape-id "D" "E") other (thp/local-commit other-id)] ;; The local data already holds the four renames (ptk/emit! store (thp/open-file file-id page-id (thp/file-data page-id shape-id "E")) #(-> % (assoc-in [:files other-id] {:id other-id :revn 0}) (assoc :workspace-undo {:items (mapv thp/undo-entry [commit1 commit2 commit3 commit4]) :index 3}) (assoc-in [:workspace-local :selected] #{shape-id}))) (ptk/emit! store commit1 commit2 ::dps/force-persist) (await (async/wait-for #(= 1 (count @requests)) "the first two commits are sent")) (ptk/emit! store commit3 other ::dps/force-persist) (await (async/wait-for #(= 3 (count (get-in @store [:persistence :queue]))) "the third commit and the other file's edit are queued")) (ptk/emit! store commit4) (await (async/wait-for #(seq (get-in @store [:persistence :buffer])) "the fourth commit is buffered")) (rx/push! response (thp/rejection (:id @commit2))) (await (async/wait-for #(= 2 (count @requests)) "the other file's edit is sent next")) (t/is (= "B" (thp/shape-name store file-id page-id shape-id)) "the file holds the commits the server kept") (t/is (= [other-id] (mapv #(get-in @store [:persistence :index % :file-id]) (get-in @store [:persistence :queue]))) "only the other file's edit is still queued") (t/is (empty? (get-in @store [:persistence :buffer]))) (t/is (= [(:undo-group @commit1)] (mapv :undo-group (get-in @store [:workspace-undo :items])))) (t/is (= 0 (get-in @store [:workspace-undo :index]))) (t/is (= 1 (count @failures)) "the user is told once") (t/is (= other-id (get-in (second @requests) [1 :id]))) (rx/push! response {:revn 1}) (await (async/wait-for #(= :saved (get-in @store [:persistence :status])) "the queue drains"))))))) ;; Scenario: an edit is buffered while a save is in flight and the save comes ;; back rejected, so the rollback drops the edit; the 200 ms pending notice ;; of that edit fires afterwards. Proves: with nothing left to save the ;; notice is ignored and the status stays :saved, so waiters such as "Pin ;; this version" are not left hanging. (t/deftest ^:async a-pending-notice-after-a-rollback-leaves-the-status-saved (await (thp/with-persistence (^:async fn [{:keys [file-id response requests store]}] (let [page-id (uuid/next) shape-id (uuid/next) commit1 (thp/rename-commit file-id page-id shape-id "A" "B") commit2 (thp/rename-commit file-id page-id shape-id "B" "C")] (ptk/emit! store (thp/open-file file-id page-id (thp/file-data page-id shape-id "C"))) (ptk/emit! store commit1 ::dps/force-persist) (await (async/wait-for #(= 1 (count @requests)) "the first commit is sent")) (ptk/emit! store commit2) (rx/push! response (thp/rejection (:id @commit1))) (await (async/wait-for #(and (= :saved (get-in @store [:persistence :status])) (empty? (get-in @store [:persistence :buffer]))) "the rollback drops both commits")) ;; The notice the dropped edit scheduled (ptk/emit! store (#'dps/update-status :pending)) (await (async/settle)) (t/is (= :saved (get-in @store [:persistence :status])))))))) ;; Scenario: two edits wait in the buffer, the second one changing the ;; opacity, when a peer's opacity change arrives. The server keeps the first ;; edit and rejects the second. The server holds the peer's opacity, so the ;; local file must too, not the opacity from before the rejected edit. ;; Proves: the rollback applies the peer's changes again over the undone ;; commits. (t/deftest ^:async a-rollback-keeps-a-peer-change-to-the-same-attribute (await (thp/with-persistence (^:async fn [{:keys [file-id response requests store]}] (let [page-id (uuid/next) shape-id (uuid/next) commit1 (thp/rename-commit file-id page-id shape-id "A" "B") commit2 (thp/attr-commit file-id page-id shape-id :opacity 0.5 0.9)] (ptk/emit! store (thp/open-file file-id page-id (thp/file-data page-id shape-id "B" :opacity 0.9))) (ptk/emit! store commit1 commit2) (await (async/wait-for #(= 2 (count (get-in @store [:persistence :buffer]))) "both edits are buffered")) (ptk/emit! store (thp/remote-commit file-id 1 [(thp/set-attr page-id shape-id :opacity 0.3)])) (await (async/settle)) (t/is (= 0.9 (thp/shape-attr store file-id page-id shape-id :opacity)) "the local edit stays on top of the peer's while pending") (ptk/emit! store ::dps/force-persist) (await (async/wait-for #(= 1 (count @requests)) "the edits are sent")) (rx/push! response (thp/rejection (:id @commit2))) (await (async/wait-for #(= :saved (get-in @store [:persistence :status])) "the rollback settles")) (t/is (= 0.3 (thp/shape-attr store file-id page-id shape-id :opacity)) "the peer's opacity, as on the server") (t/is (= "B" (thp/shape-name store file-id page-id shape-id)) "the kept edit stays")))))) ;; Scenario: a peer's opacity change arrives while an opacity edit is ;; pending; that edit is saved (on the server it lands after the peer's). ;; Later two new edits are sent and the second is rejected. The rollback ;; must not apply the old peer change again, which would undo the saved ;; edit. Proves: remote changes older than the oldest pending commit are ;; forgotten once the commits pending before them are saved. (t/deftest ^:async a-rollback-ignores-peer-changes-older-than-the-pending-commits (await (thp/with-persistence (^:async fn [{:keys [file-id response requests store]}] (let [page-id (uuid/next) shape-id (uuid/next) commit0 (thp/attr-commit file-id page-id shape-id :opacity 0.5 0.7) commit1 (thp/rename-commit file-id page-id shape-id "A" "B") commit2 (thp/rename-commit file-id page-id shape-id "B" "C")] (ptk/emit! store (thp/open-file file-id page-id (thp/file-data page-id shape-id "A" :opacity 0.7))) (ptk/emit! store commit0) (await (async/wait-for #(seq (get-in @store [:persistence :buffer])) "the edit is buffered")) (ptk/emit! store (thp/remote-commit file-id 1 [(thp/set-attr page-id shape-id :opacity 0.3)])) (ptk/emit! store ::dps/force-persist) (await (async/wait-for #(= 1 (count @requests)) "the opacity edit is sent")) (rx/push! response {:revn 1}) (await (async/wait-for #(= :saved (get-in @store [:persistence :status])) "the opacity edit is saved")) (t/is (empty? (get-in @store [:persistence :remote-changes file-id])) "nothing pending, nothing kept") (ptk/emit! store #(assoc-in % [:files file-id :data :pages-index page-id :objects shape-id :name] "C") commit1 commit2 ::dps/force-persist) (await (async/wait-for #(= 2 (count @requests)) "the renames are sent")) (rx/push! response (thp/rejection (:id @commit2))) (await (async/wait-for #(= "B" (thp/shape-name store file-id page-id shape-id)) "the rejected rename is undone")) (t/is (= 0.7 (thp/shape-attr store file-id page-id shape-id :opacity)) "the saved opacity edit stays")))))) ;; Undo groups across files and saves. ;; Scenario: one action makes a commit in this file and one in a library ;; (as updating a main component from a copy does). The library also has an ;; unrelated edit before the action and another after it. This file's save ;; comes back with the action's commit rejected while the library's commits ;; are still queued. Proves: the rejection reverts the whole action, so the ;; library drops its commit of the action and every later one, and keeps ;; the edit made before it. (t/deftest ^:async a-rejection-reverts-the-pending-commits-of-the-same-action-in-other-files (await (thp/with-persistence (^:async fn [{:keys [file-id response requests store]}] (let [lib-id (uuid/next) page-id (uuid/next) shape-id (uuid/next) lshape-id (uuid/next) group (uuid/next) commit1 (thp/rename-commit file-id page-id shape-id "A" "B") commit2 (grouped (thp/rename-commit file-id page-id shape-id "B" "C") group) before (thp/rename-commit lib-id page-id lshape-id "X" "Y") linked (grouped (thp/rename-commit lib-id page-id lshape-id "Y" "Z") group) after (thp/rename-commit lib-id page-id lshape-id "Z" "W")] (ptk/emit! store (thp/open-file-with-library file-id lib-id page-id (thp/file-data page-id shape-id "C") (thp/file-data page-id lshape-id "W"))) (ptk/emit! store commit1 commit2 before linked after ::dps/force-persist) (await (async/wait-for #(= 1 (count @requests)) "this file's save is sent")) (rx/push! response (thp/rejection (:id @commit2))) (await (async/wait-for #(= 2 (count @requests)) "the library's save is sent next")) (t/is (= "B" (thp/shape-name store file-id page-id shape-id))) (t/is (= "Y" (thp/shape-name store lib-id page-id lshape-id)) "the library goes back to the edit made before the action") (t/is (= lib-id (get-in (second @requests) [1 :id]))) (t/is (= [[(thp/rename page-id lshape-id "Y")]] (sent-changes requests 1)) "only the library's edit made before the action is saved") (rx/push! response {:revn 1}) (await (async/wait-for #(= :saved (get-in @store [:persistence :status])) "the queue drains"))))))) ;; Scenario: one action makes a commit in a library and one in this file. ;; The library's is saved first; then this file's save comes back with the ;; action's commit rejected. The library's commit can no longer be dropped. ;; Proves: a new commit undoes the saved part of the rejected action. (t/deftest ^:async a-rejection-undoes-the-saved-commits-of-the-same-action-with-a-new-commit (await (thp/with-persistence (^:async fn [{:keys [file-id response requests store]}] (let [lib-id (uuid/next) page-id (uuid/next) shape-id (uuid/next) lshape-id (uuid/next) group (uuid/next) linked (grouped (thp/rename-commit lib-id page-id lshape-id "X" "Y") group) commit (grouped (thp/rename-commit file-id page-id shape-id "A" "B") group)] (ptk/emit! store (thp/open-file-with-library file-id lib-id page-id (thp/file-data page-id shape-id "B") (thp/file-data page-id lshape-id "Y"))) (ptk/emit! store linked commit ::dps/force-persist) (await (async/wait-for #(= 1 (count @requests)) "the library's save is sent first")) (t/is (= lib-id (get-in (first @requests) [1 :id]))) (rx/push! response {:revn 1}) (await (async/wait-for #(= 2 (count @requests)) "this file's save is sent")) (rx/push! response (thp/rejection (:id @commit))) (await (async/wait-for #(seq (get-in @store [:persistence :buffer])) "a new commit undoes the library's")) (ptk/emit! store ::dps/force-persist) (await (async/wait-for #(= 3 (count @requests)) "the new commit is sent")) (t/is (= lib-id (get-in (nth @requests 2) [1 :id]))) (t/is (= [[(thp/rename page-id lshape-id "X")]] (sent-changes requests 2))) (t/is (= "A" (thp/shape-name store file-id page-id shape-id))) (t/is (= "X" (thp/shape-name store lib-id page-id lshape-id))) (rx/push! response {:revn 2}) (await (async/wait-for #(= :saved (get-in @store [:persistence :status])) "the queue drains"))))))) ;; Scenario: as above, while the user has an undo transaction open. Proves: ;; the undo commit gets its own group, not the transaction's, so a later ;; rejection in the transaction does not revert it. (t/deftest ^:async the-undo-of-a-saved-commit-stays-out-of-an-open-transaction (await (thp/with-persistence (^:async fn [{:keys [file-id response requests store]}] (let [lib-id (uuid/next) page-id (uuid/next) shape-id (uuid/next) lshape-id (uuid/next) group (uuid/next) linked (grouped (thp/rename-commit lib-id page-id lshape-id "X" "Y") group) commit (grouped (thp/rename-commit file-id page-id shape-id "A" "B") group)] (ptk/emit! store (thp/open-file-with-library file-id lib-id page-id (thp/file-data page-id shape-id "B") (thp/file-data page-id lshape-id "Y"))) (ptk/emit! store linked commit ::dps/force-persist) (await (async/wait-for #(= 1 (count @requests)) "the library's save is sent first")) (rx/push! response {:revn 1}) (await (async/wait-for #(= 2 (count @requests)) "this file's save is sent")) (ptk/emit! store (dwu/start-undo-transaction (js/Symbol) :timeout 0)) (let [tx-group (get-in @store [:workspace-undo :transaction :commit-group])] (rx/push! response (thp/rejection (:id @commit))) (await (async/wait-for #(seq (get-in @store [:persistence :buffer])) "a new commit undoes the library's")) (t/is (some? tx-group)) (t/is (not= tx-group (get-in @store [:persistence :buffer 0 :undo-group]))) (ptk/emit! store ::dps/force-persist) (await (async/wait-for #(= 3 (count @requests)) "the new commit is sent")) (rx/push! response {:revn 2}) (await (async/wait-for #(= :saved (get-in @store [:persistence :status])) "the queue drains")))))))) ;; Scenario: as above, but the library is no longer loaded when this file's ;; save comes back. Proves: the undo of the library's saved commit is still ;; sent, without touching local data the library no longer has. (t/deftest ^:async a-rejection-queues-the-undo-of-a-saved-commit-of-a-file-not-loaded (await (thp/with-persistence (^:async fn [{:keys [file-id response requests store]}] (let [lib-id (uuid/next) page-id (uuid/next) shape-id (uuid/next) lshape-id (uuid/next) group (uuid/next) linked (grouped (thp/rename-commit lib-id page-id lshape-id "X" "Y") group) commit (grouped (thp/rename-commit file-id page-id shape-id "A" "B") group)] (ptk/emit! store (thp/open-file-with-library file-id lib-id page-id (thp/file-data page-id shape-id "B") (thp/file-data page-id lshape-id "Y"))) (ptk/emit! store linked commit ::dps/force-persist) (await (async/wait-for #(= 1 (count @requests)) "the library's save is sent first")) (rx/push! response {:revn 1}) (await (async/wait-for #(= 2 (count @requests)) "this file's save is sent")) (ptk/emit! store #(update % :files dissoc lib-id)) (rx/push! response (thp/rejection (:id @commit))) (await (async/wait-for #(= 3 (count @requests)) "the undo is queued and sent")) (t/is (= lib-id (get-in (nth @requests 2) [1 :id]))) (t/is (= [[(thp/rename page-id lshape-id "X")]] (sent-changes requests 2))) (t/is (empty? (get-in @store [:persistence :buffer]))) (t/is (nil? (get-in @store [:files lib-id])) "no local data is made up") (t/is (= "A" (thp/shape-name store file-id page-id shape-id))) (rx/push! response {:revn 2}) (await (async/wait-for #(= :saved (get-in @store [:persistence :status])) "the queue drains"))))))) ;; Scenario: one action makes two commits in this file and the save keeps ;; the first and rejects the second. Proves: the kept commit of the action ;; is undone too, with a new commit, so no action is left half done. (t/deftest ^:async a-rejection-undoes-the-kept-commits-of-the-same-action (await (thp/with-persistence (^:async fn [{:keys [file-id response requests store]}] (let [page-id (uuid/next) shape-id (uuid/next) group (uuid/next) commit1 (grouped (thp/rename-commit file-id page-id shape-id "A" "B") group) commit2 (grouped (thp/attr-commit file-id page-id shape-id :opacity 1 0.5) group)] (ptk/emit! store (thp/open-file file-id page-id (thp/file-data page-id shape-id "B" :opacity 0.5))) (ptk/emit! store commit1 commit2 ::dps/force-persist) (await (async/wait-for #(= 1 (count @requests)) "the action is sent")) (rx/push! response (thp/rejection (:id @commit2))) (await (async/wait-for #(seq (get-in @store [:persistence :buffer])) "a new commit undoes the kept one")) (ptk/emit! store ::dps/force-persist) (await (async/wait-for #(= 2 (count @requests)) "the new commit is sent")) (t/is (= [[(thp/rename page-id shape-id "A")]] (sent-changes requests 1))) (t/is (= "A" (thp/shape-name store file-id page-id shape-id))) (t/is (= 1 (thp/shape-attr store file-id page-id shape-id :opacity))) (rx/push! response {:revn 1}) (await (async/wait-for #(= :saved (get-in @store [:persistence :status])) "the queue drains"))))))) ;; Scenario: applying a token to five shapes makes three commits inside one ;; undo transaction, all sent in one save; the server keeps the first two ;; and rejects the third. Proves: the whole action is reverted, the kept ;; commits by a new commit, so opacity and applied tokens are back to their ;; starting values on every shape. (t/deftest ^:async a-rejection-reverts-every-commit-of-an-undo-transaction (await (thp/with-persistence (^:async fn [{:keys [file-id response requests store]}] (let [page-id (uuid/next) shape-ids (into [] (repeatedly 5 uuid/next)) shapes (map #(cts/setup-shape {:id % :type :rect :name "R" :opacity 1 :parent-id uuid/zero :frame-id uuid/zero}) shape-ids) apply-to (fn [ids] (dch/commit-changes {:redo-changes (into [] (mapcat (fn [id] [(thp/set-attr page-id id :opacity 0.5) (thp/set-attr page-id id :applied-tokens {:opacity "half"})])) ids) :undo-changes (into [] (mapcat (fn [id] [(thp/set-attr page-id id :opacity 1) (thp/set-attr page-id id :applied-tokens nil)])) ids)})) tx-id (js/Symbol)] (ptk/emit! store (thp/open-file file-id page-id {:pages [page-id] :pages-index {page-id {:id page-id :name "Page" :objects (into {uuid/zero (cts/setup-shape {:id uuid/zero :type :frame :shapes shape-ids})} (map (juxt :id identity)) shapes)}}})) (ptk/emit! store (dwu/start-undo-transaction tx-id :timeout 0) (apply-to (subvec shape-ids 0 2)) (apply-to (subvec shape-ids 2 4)) (apply-to (subvec shape-ids 4 5)) (dwu/commit-undo-transaction tx-id) ::dps/force-persist) (await (async/wait-for #(= 1 (count @requests)) "the action is sent")) (let [groups (get-in (first @requests) [1 :changes-with-metadata])] (t/is (= 3 (count groups)) "the save carries the three commits") (rx/push! response (thp/rejection (:commit-id (nth groups 2))))) (await (async/wait-for #(seq (get-in @store [:persistence :buffer])) "a new commit undoes the kept ones")) (ptk/emit! store ::dps/force-persist) (await (async/wait-for #(= 2 (count @requests)) "the new commit is sent")) (doseq [id shape-ids] (t/is (= 1 (thp/shape-attr store file-id page-id id :opacity))) (t/is (nil? (thp/shape-attr store file-id page-id id :applied-tokens)))) (t/is (= #{1} (into #{} (comp (mapcat :changes) (mapcat :operations) (filter #(= :opacity (:attr %))) (map :val)) (get-in (second @requests) [1 :changes-with-metadata]))) "the new commit puts the kept opacity back on the server") (rx/push! response {:revn 1}) (await (async/wait-for #(= :saved (get-in @store [:persistence :status])) "the queue drains"))))))) ;; UI state after a rollback. ;; Scenario: the rejected commit added the text being edited. Proves: the ;; rollback leaves edition mode, so the editor does not keep pointing at a ;; shape that no longer exists. (t/deftest ^:async a-rollback-leaves-the-edition-of-a-removed-shape (await (thp/with-persistence (^:async fn [{:keys [file-id response requests store]}] (let [page-id (uuid/next) shape-id (uuid/next) text-id (uuid/next) text (cts/setup-shape {:id text-id :type :text :parent-id uuid/zero :frame-id uuid/zero}) commit (add-shape-commit file-id page-id text)] (ptk/emit! store (thp/open-file file-id page-id (-> (thp/file-data page-id shape-id "A") (thp/add-shape page-id text))) #(assoc-in % [:workspace-local :edition] text-id)) (ptk/emit! store commit ::dps/force-persist) (await (async/wait-for #(= 1 (count @requests)) "the new text is sent")) (rx/push! response (thp/rejection (:id @commit))) (await (async/wait-for #(nil? (get-in @store [:workspace-local :edition])) "the rollback leaves edition mode")) (t/is (nil? (thp/shape-attr store file-id page-id text-id :id)))))))) ;; Scenario: the user edits the nodes of a saved path and the rejected commit ;; changed that path. The path editor works on its own copy of the shape, ;; which still holds the rejected content. Proves: the rollback leaves the ;; edition and drops that copy, so the editor neither shows nor saves the ;; reverted content. (t/deftest ^:async a-rollback-leaves-the-edition-of-a-changed-path (await (thp/with-persistence (^:async fn [{:keys [file-id response requests store]}] (let [page-id (uuid/next) shape-id (uuid/next) commit (thp/rename-commit file-id page-id shape-id "A" "B")] (ptk/emit! store (thp/open-file file-id page-id (thp/file-data page-id shape-id "B")) #(-> % (assoc-in [:workspace-local :edition] shape-id) (assoc-in [:workspace-local :edit-path shape-id] {:edit-mode :move :old-content [:rejected]}) (assoc-in [:workspace-drawing :object] {:id shape-id :content [:rejected]}))) (ptk/emit! store commit ::dps/force-persist) (await (async/wait-for #(= 1 (count @requests)) "the edit is sent")) (rx/push! response (thp/rejection (:id @commit))) (await (async/wait-for #(nil? (get-in @store [:workspace-local :edition])) "the rollback leaves edition mode")) (t/is (= "A" (thp/shape-name store file-id page-id shape-id))) (t/is (nil? (get-in @store [:workspace-local :edit-path shape-id])) "the editor's state is dropped") (t/is (nil? (get-in @store [:workspace-drawing :object])) "the editor's copy of the path is dropped") (await (async/settle)) (t/is (= 1 (count @requests)) "closing the editor saves nothing")))))) ;; Scenario: a rollback changes a shape other than the one being edited. ;; Proves: the edition stays open. (t/deftest ^:async a-rollback-keeps-the-edition-of-an-unchanged-shape (await (thp/with-persistence (^:async fn [{:keys [file-id response requests store]}] (let [page-id (uuid/next) shape-id (uuid/next) other-id (uuid/next) other (cts/setup-shape {:id other-id :type :rect :name "Other" :parent-id uuid/zero :frame-id uuid/zero}) commit (thp/rename-commit file-id page-id shape-id "A" "B")] (ptk/emit! store (thp/open-file file-id page-id (-> (thp/file-data page-id shape-id "B") (thp/add-shape page-id other))) #(assoc-in % [:workspace-local :edition] other-id)) (ptk/emit! store commit ::dps/force-persist) (await (async/wait-for #(= 1 (count @requests)) "the edit is sent")) (rx/push! response (thp/rejection (:id @commit))) (await (async/wait-for #(= "A" (thp/shape-name store file-id page-id shape-id)) "the rollback lands")) (await (async/settle)) (t/is (= other-id (get-in @store [:workspace-local :edition])))))))) ;; Scenario: the color picker is open on a new rectangle when the save that ;; created it is rejected. Proves: the rollback closes the picker, which ;; would otherwise edit a shape that no longer exists. (t/deftest ^:async a-rollback-closes-the-color-picker-of-a-removed-shape (await (thp/with-persistence (^:async fn [{:keys [file-id response requests store]}] (let [page-id (uuid/next) shape-id (uuid/next) rect-id (uuid/next) rect (cts/setup-shape {:id rect-id :type :rect :parent-id uuid/zero :frame-id uuid/zero}) commit (add-shape-commit file-id page-id rect)] (ptk/emit! store (thp/open-file file-id page-id (-> (thp/file-data page-id shape-id "A") (thp/add-shape page-id rect))) #(-> % (assoc-in [:workspace-local :selected] #{rect-id}) (assoc ::modal/modal {:id (uuid/next) :type :colorpicker :props {}}))) (ptk/emit! store commit ::dps/force-persist) (await (async/wait-for #(= 1 (count @requests)) "the new rectangle is sent")) (rx/push! response (thp/rejection (:id @commit))) (await (async/wait-for #(empty? (get-in @store [:workspace-local :selected])) "the rollback deselects the removed shape")) (t/is (nil? (::modal/modal @store)) "the color picker is closed")))))) ;; Scenario: the user undoes a rename with Ctrl+Z and the server rejects the ;; commit the undo makes. Proves: the rollback puts the rename back on ;; screen and the history back where it was, instead of clearing it. (t/deftest ^:async a-rejected-undo-puts-the-change-and-the-history-back (await (thp/with-persistence (^:async fn [{:keys [file-id response requests store]}] (let [page-id (uuid/next) shape-id (uuid/next) item (thp/undo-entry (thp/rename-commit file-id page-id shape-id "A" "B"))] (ptk/emit! store (thp/open-file file-id page-id (thp/file-data page-id shape-id "B")) #(assoc % :workspace-undo {:items [item] :index 0})) (ptk/emit! store dwu/undo) (await (async/wait-for #(= "A" (thp/shape-name store file-id page-id shape-id)) "the rename is undone")) (t/is (= -1 (get-in @store [:workspace-undo :index]))) (ptk/emit! store ::dps/force-persist) (await (async/wait-for #(= 1 (count @requests)) "the undo is sent")) (rx/push! response (thp/rejection (get-in (first @requests) [1 :changes-with-metadata 0 :commit-id]))) (await (async/wait-for #(= "B" (thp/shape-name store file-id page-id shape-id)) "the rename is back")) (t/is (= [item] (get-in @store [:workspace-undo :items])) "the history stays") (t/is (= 0 (get-in @store [:workspace-undo :index])) "it points at the rename again") (await (async/wait-for #(= :saved (get-in @store [:persistence :status])) "the rollback settles"))))))) ;; Version restores. (defn- restore-version "The load of `file-id` that a peer's restore causes: another version with `data`." [file-id data] (#'dw/bundle-fetched {:file {:id file-id :revn 0 :vern 1 :data data} :file-id file-id :thumbnails {}})) ;; Scenario: the undo of a rejected rename cannot be applied, so the ;; rollback reloads the file. Proves: `::rolled-back` waits for the ;; reloaded data, so a listener that reads the listed shapes sees the ;; server data, never the rejected one. (t/deftest ^:async a-rollback-that-reloads-the-file-is-reported-after-the-reload (let [reloads (atom 0) original (get-method ptk/resolve ::dw/reload-current-file)] (-add-method ptk/resolve ::dw/reload-current-file (fn [_ _] (swap! reloads inc) (ptk/data-event ::reloaded {}))) (try (await (thp/with-persistence (^:async fn [{:keys [file-id response requests store]}] (let [page-id (uuid/next) shape-id (uuid/next) ;; A change type with no handler, so its undo throws commit (ptk/data-event ::dch/commit (update @(thp/rename-commit file-id page-id shape-id "A" "B") :undo-changes #(into [{:type :debug-fail}] %))) seen (atom []) sub (->> (ptk/input-stream store) (rx/filter (ptk/type? ::dps/rolled-back)) (rx/subs! (fn [event] (swap! seen conj [(:shape-ids @event) (thp/shape-name store file-id page-id shape-id)]))))] (ptk/emit! store (thp/open-file file-id page-id (thp/file-data page-id shape-id "B"))) (ptk/emit! store commit ::dps/force-persist) (await (async/wait-for #(= 1 (count @requests)) "the rename is sent")) (rx/push! response (thp/rejection (:id @commit))) (await (async/wait-for #(= 1 @reloads) "the rollback reloads the file")) (await (async/settle)) (t/is (empty? @seen) "nothing is reported before the reload") (ptk/emit! store (#'dw/bundle-fetched {:file {:id file-id :revn 0 :data (thp/file-data page-id shape-id "A")} :file-id file-id :thumbnails {}})) (await (async/wait-for #(seq @seen) "the rollback is reported")) (t/is (= [[#{shape-id} "A"]] @seen) "the listener reads the reloaded data") (rx/dispose! sub))))) (finally (-add-method ptk/resolve ::dw/reload-current-file original))))) ;; Scenario: a save of two renames is in flight and a third rename waits in ;; the queue when a peer restores an earlier version; the file reloads to ;; it and a peer's change to it arrives. The answer then comes: the server ;; kept the first rename and rejected the second, before the restore. ;; Proves: neither the peer's change, the answer nor its rollback replays ;; the renames on the reloaded data, the user gets no rejection warning, ;; and the queued rename, made on the replaced version, is dropped unsent. (t/deftest ^:async an-answer-from-before-a-restore-leaves-the-reloaded-file-alone (await (thp/with-persistence (^:async fn [{:keys [file-id response requests store failures]}] (let [page-id (uuid/next) shape-id (uuid/next) commit1 (thp/rename-commit file-id page-id shape-id "A" "B") commit2 (thp/rename-commit file-id page-id shape-id "B" "C") commit3 (thp/rename-commit file-id page-id shape-id "C" "D")] (ptk/emit! store (thp/open-file file-id page-id (thp/file-data page-id shape-id "D")) #(assoc-in % [:files file-id :vern] 0)) (ptk/emit! store commit1 commit2 ::dps/force-persist) (await (async/wait-for #(= 1 (count @requests)) "the first two renames are sent")) (ptk/emit! store commit3 ::dps/force-persist) (await (async/wait-for #(= 2 (count (get-in @store [:persistence :queue]))) "the third rename is queued")) (ptk/emit! store (restore-version file-id (thp/file-data page-id shape-id "Restored"))) (ptk/emit! store (thp/remote-commit file-id 1 [(thp/set-attr page-id shape-id :opacity 0.5)])) (await (async/settle)) (t/is (= "Restored" (thp/shape-name store file-id page-id shape-id)) "a peer's change does not replay the renames") (rx/push! response (thp/rejection (:id @commit2))) (await (async/wait-for #(and (= :saved (get-in @store [:persistence :status])) (empty? (get-in @store [:persistence :queue]))) "the queue drains")) (t/is (= "Restored" (thp/shape-name store file-id page-id shape-id)) "the file keeps the restored version") (t/is (= 0.5 (thp/shape-attr store file-id page-id shape-id :opacity))) (t/is (empty? @failures) "no rejection warning for a replaced version") (t/is (= 1 (count @requests)) "the rename made on the replaced version is not sent")))))) ;; Scenario: a save is in flight when a peer restores an earlier version and ;; the file reloads; the server handled the save after the restore, so it ;; refuses it for its old version. Proves: the failure of a save made on a ;; replaced version drops it instead of halting the queue and locking the ;; workspace. (t/deftest ^:async a-save-refused-after-a-restore-is-dropped (await (thp/with-persistence (^:async fn [{:keys [file-id response requests store failures]}] (let [page-id (uuid/next) shape-id (uuid/next)] (ptk/emit! store (thp/open-file file-id page-id (thp/file-data page-id shape-id "B")) #(assoc-in % [:files file-id :vern] 0)) (ptk/emit! store (thp/rename-commit file-id page-id shape-id "A" "B") ::dps/force-persist) (await (async/wait-for #(= 1 (count @requests)) "the rename is sent")) (ptk/emit! store (restore-version file-id (thp/file-data page-id shape-id "Restored"))) (rx/error! response (ex-info "A different version has been restored for the file." {:type :validation :code :vern-conflict})) (await (async/wait-for #(and (= :saved (get-in @store [:persistence :status])) (empty? (get-in @store [:persistence :queue]))) "the refused save is dropped")) (t/is (empty? @failures)) (t/is (nil? (get-in @store [:workspace-global :persistence-lock]))) (t/is (= "Restored" (thp/shape-name store file-id page-id shape-id)))))))) (defn- comment-thread "A comment thread of `file-id` on `page-id`, pinned on `frame-id`." [file-id page-id frame-id position] {:id (uuid/next) :page-id page-id :file-id file-id :project-id (uuid/next) :owner-id (uuid/next) :file-name "File" :seqn 1 :content "Note" :participants #{} :created-at (ct/now) :modified-at (ct/now) :position position :frame-id frame-id}) ;; Scenario: comment #1 sits on board Y. During a save Y is moved, which ;; moves pin #1 in the same action, and board R is drawn and gets comment ;; #2. The server rejects the move, so the move, the pin move and R are ;; rolled back. Comment positions are saved by their own requests, so the ;; server already holds the moved pin and #2 on R. Proves: pin #1 goes back ;; to its spot on screen and on the server, and #2, whose board is gone, ;; moves to the page root, as deleting the board does. (t/deftest ^:async a-rollback-puts-comment-pins-back-and-detaches-them-from-removed-boards (let [answer (rx/subject) respond (fn [cmd _] (if (= :update-file cmd) (->> answer (rx/take 1) (rx/observe-on :async)) (rx/of nil)))] (await (thp/with-persistence (^:async fn [{:keys [file-id requests store]}] (let [page-id (uuid/next) shape-id (uuid/next) board-y (cts/setup-shape {:id (uuid/next) :type :frame :name "Y" :parent-id uuid/zero :frame-id uuid/zero}) board-r (cts/setup-shape {:id (uuid/next) :type :frame :name "R" :parent-id uuid/zero :frame-id uuid/zero}) [p0 p1 p2] [(gpt/point 610 10) (gpt/point 760 10) (gpt/point 20 20)] thread-1 (comment-thread file-id page-id (:id board-y) p1) thread-2 (comment-thread file-id page-id (:id board-r) p2) group (uuid/next) move (grouped (thp/rename-commit file-id page-id (:id board-y) "Y" "Y moved") group) pin (grouped (ptk/data-event ::dch/commit {:id (uuid/next) :file-id file-id :file-revn 0 :file-vern 0 :source :local :features #{} :redo-changes [{:type :set-comment-thread-position :page-id page-id :comment-thread-id (:id thread-1) :frame-id (:id board-y) :position p1}] :undo-changes [{:type :set-comment-thread-position :page-id page-id :comment-thread-id (:id thread-1) :frame-id (:id board-y) :position p0}]}) group) draw (add-shape-commit file-id page-id board-r) data (-> (thp/file-data page-id shape-id "A") (thp/add-shape page-id (assoc board-y :name "Y moved")) (thp/add-shape page-id board-r) (assoc-in [:pages-index page-id :comment-thread-positions] {(:id thread-1) {:frame-id (:id board-y) :position p1} (:id thread-2) {:frame-id (:id board-r) :position p2}})) sent (fn [cmd] (into [] (comp (filter #(= cmd (first %))) (map second)) @requests))] ;; The local data and the rendered pins already hold the edits (ptk/emit! store (thp/open-file file-id page-id data) #(assoc % :comment-threads {(:id thread-1) thread-1 (:id thread-2) thread-2}) (dwcm/watch-comment-thread-position-changes (rx/subject))) (ptk/emit! store move pin draw ::dps/force-persist) (await (async/wait-for #(seq (sent :update-file)) "the edits are sent")) (rx/push! answer (thp/rejection (:id @move))) (await (async/wait-for #(seq (sent :update-comment-thread-frame)) "the rollback updates the comments")) (t/is (nil? (thp/shape-attr store file-id page-id (:id board-r) :id)) "R is gone") (t/is (= p0 (get-in @store [:comment-threads (:id thread-1) :position])) "pin #1 is back on screen") (t/is (= [{:id (:id thread-1) :position p0 :frame-id (:id board-y)}] (sent :update-comment-thread-position)) "and on the server") (t/is (= uuid/zero (get-in @store [:comment-threads (:id thread-2) :frame-id])) "comment #2 leaves the removed board") (t/is (= p2 (get-in @store [:comment-threads (:id thread-2) :position])) "and keeps its spot") (t/is (= [{:id (:id thread-2) :frame-id uuid/zero}] (sent :update-comment-thread-frame))) (rx/end! answer))) respond)))) (defn- rejection-notice "Saves a rename of the open file and answers it with `answer` applied to a rejection of it, after `prepare` updates the state (given the file id) while the save is in flight. Resolves with the params the notice was flashed with; `tr` answers each key with its arguments." [prepare answer] (let [flashes (atom [])] (-> (thp/with-persistence (^:async fn [{:keys [file-id response requests store]}] (await (mock/with-mocks* {errors/flash (fn [& {:as params}] (swap! flashes conj params) (js/Promise.resolve nil)) i18n/tr (fn ([key] [key]) ([key & args] (into [key] args)))} (let [page-id (uuid/next) shape-id (uuid/next) commit1 (thp/rename-commit file-id page-id shape-id "A" "B")] (ptk/emit! store (thp/open-file file-id page-id (thp/file-data page-id shape-id "B"))) (ptk/emit! store commit1 ::dps/force-persist) (await (async/wait-for #(= 1 (count @requests)) "the rename is sent")) (ptk/emit! store #(prepare % file-id)) (rx/push! response (answer (thp/rejection (:id @commit1)))) (await (async/wait-for #(seq @flashes) "the notice is flashed"))))))) (.then (fn [_] (first @flashes)))))) ;; Scenario: an edit made while the save was in flight is undone with the ;; rejected one. Proves: the notice says the latest changes were undone, ;; and goes away by itself. (t/deftest ^:async a-rejection-notice-says-the-latest-changes-were-undone (let [{:keys [hint tag timeout]} (await (rejection-notice (fn [state _] state) identity))] (t/is (= ["errors.save-rejected-changes-undone"] hint)) (t/is (= dps/rejected-tag tag)) (t/is (some? timeout)))) ;; Scenario: the user went to another file before the answer came back. ;; Proves: the notice names the file it is about. (t/deftest ^:async a-rejection-in-a-file-not-open-names-it (let [{:keys [hint]} (await (rejection-notice (fn [state file-id] (-> state (assoc-in [:files file-id :name] "Brand kit") (assoc :current-file-id (uuid/next)))) identity))] (t/is (= ["errors.save-rejected-changes-undone-in-file" "Brand kit"] hint)))) ;; Scenario: the page holds data that was invalid before the change, so the ;; backend flags the rejection. Proves: the notice says the data is damaged. (t/deftest ^:async a-rejection-on-damaged-data-says-so (let [{:keys [hint]} (await (rejection-notice (fn [state _] state) #(assoc-in % [:rejected :preexisting-damage] true)))] (t/is (= ["errors.save-rejected-damaged-data"] hint)))) ;; Scenario: the repair notice of the open file is still on screen when a ;; save of it comes back rejected. The store holds one toast. Proves: the ;; notice tells both, and stays like the repair notice did. (t/deftest ^:async a-rejection-keeps-the-repair-notice-on-screen (let [{:keys [hint tag timeout]} (await (rejection-notice (fn [state _] (assoc state :notification {:content "repaired" :tag dps/file-repaired-tag})) identity))] (t/is (= ["workspace.notifications.file-repaired-changes-undone"] hint)) (t/is (= dps/repaired-rejected-tag tag)) (t/is (nil? timeout))))