🐛 Keep the UI in step with a rollback

A rollback can remove or change shapes the UI still refers to.

- The selection drops shapes that are gone and the color picker that
  edited them closes.
- The undo history is cut at the reverted action; it stays when no
  entry holds a reverted change. Undo and redo commits carry real undo
  changes and the history index they moved from, so a reverted Ctrl+Z
  puts the change and the index back.
- An open path edition whose shape the rollback changed closes without
  saving the editor's stale copy. The v2 text editor loads the reverted
  text again; the WASM editor already showed it.
- The Alt measurements look the hovered shape up in the current data.
- Plugin setters, resize() and rotate() on a removed shape throw
  "Shape not found", whatever the plugin's throwValidationErrors flag.
This commit is contained in:
alonso.torres 2026-10-20 16:16:28 +02:00
parent 84d4db0ec8
commit 46209cef13
22 changed files with 1246 additions and 849 deletions

View File

@ -23,6 +23,7 @@
- Public Plugin API objects are lightweight handles, not durable snapshots. Most getters locate fresh state from `app.main.store/state` using hidden `$id`, `$file`, `$page`, etc.
- `not-valid` logs by default but throws when the plugin flag `throwValidationErrors` is enabled. The MCP execute-code handler deliberately enables that flag while running code.
- A write to a removed shape always throws `Shape not found`, whatever that flag: setters through the shape proxy's `:on-set u/check-shape-exists` (runs outside the error handling), and methods that must not vanish silently (`resize`, `rotate`) call `u/check-shape-located` first. Methods run inside `:on-error (u/handle-error ...)`, which logs instead of throwing when the flag is off, so it rethrows errors whose `ex-data :type` is `::u/shape-not-found`.
- `naturalChildOrdering` and `throwValidationErrors` are stored per plugin under `[:plugins :flags plugin-id ...]`; changing default behavior affects automation and MCP diagnostics.
- Plugin data is stored under keyword namespaces: private data uses `(keyword "plugin" plugin-id)`, shared data uses `(keyword "shared" namespace)`.

View File

@ -22,7 +22,7 @@
- Applying a remote commit first rolls back the pending local commits of its own file (`pcm/pending-commits`: queued entries, then `[:persistence :buffer]`; other files' commits never touch its data), applies the remote changes, then replays pending local redo changes. Index updates are emitted for undo, remote redo, and replayed redo paths. Leaving the buffer out makes a remote change to the same attr win locally but lose on the server.
- Local commits are independently consumed by undo, persistence, WASM model updates, thumbnail/library watchers, and text position-data recalculation.
- Persistence buffers local commits in the store (`buffer-commit` → `[:persistence :buffer]`, stamped with `:remote-seq`): status becomes pending after about 200ms; the 3s debounce or `::force-persist` emits `flush-buffer`, which merges the buffer per file into `append-commit`s, and each append removes its own commits from the buffer (a commit arriving mid-flush stays). `initialize-persistence-state` flushes a leftover buffer. Queue entries (`pcm/queue-entry`) hold the entry keys of the first commit (id, created-at, origin, features, file-id/revn/vern) and `:commits` (id, origin, undo-group, redo/undo changes, remote-seq of each merged commit), no merged change lists; every reader goes through `:commits`. The save sends `:changes-with-metadata`, one group per commit with `:commit-id`, never flat `:changes`.
- A save answered with `:rejected` (backend kept the commits before the first failing one, see `mem:backend/subtleties`) is not a failure: `rollback-rejected` runs before `commit-persisted`. The unit is the undo group (one user action, which can commit to several files, e.g. `update-component` writes the copy's file and the library, and `component-changed` syncs inherit the edit's group; multi-file actions must share a non-nil group): the pure bookkeeping lives in `app.main.data.persistence.commits` (`pcm`): `pcm/rollback-plan` reverts, in the saved file, the pending commits from the first rejected one on, and in any file the pending commits from the first one of a reverted group on, iterating until no new group appears; other files' unrelated commits stay. `pcm/without-rolled-back` drops pending reverted commits from queue entries (partial entries trimmed, empty ones leave the queue) and the buffer; saved commits of a reverted group (the head's kept ones, and `:saved`, which `keep-saved-commits` fills on `discard-commit` with saved commits whose group a pending commit still shares) are undone by a new `save-undo? false` commit (`compensate-commits`, flushed like any edit); for a file whose data is not loaded the undo goes straight to the queue as an entry, nothing applied locally. Per file, `dch/rebase-commits` undoes every pending commit newest first, re-applies the remote changes kept since the oldest pending commit, and redoes the pending commits that stay. Undo changes hold absolute values from commit time, so undoing only the rejected commits would overwrite a peer's later change to the same attr. Remote changes are numbered (`record-remote-changes`, `[:persistence :remote-seq]`) and kept per file under `:remote-changes` only while the file has pending commits; `prune-remote-changes` (discard, release) forgets those at or below the oldest pending stamp, since re-applying them would undo saved edits. Then `dwu/forget-undo-groups` (cut history before the first entry of a reverted commit's undo-group; no match clears it; open transaction emptied), selection pruned and edition mode left when the edited shape is gone (`prune-removed-shapes`; `v2-update-text-shape-content` also skips a missing shape, so the editor's exit commits nothing), `errors/flash` `:unhandled` toast tagged `:persistence-rejected`, hidden after `rejected-timeout-ms` (10 s). It ends with `(ptk/data-event ::rolled-back {:file-ids})`: `dwl/sync-file` waits for it or `wait-persisted` and marks the library synced only on a landed save (on rollback it restores `:synced-at` and re-checks updates after the rollback's own save). `update-status` ignores `:pending` when buffer and queue are empty (`nothing-to-save?`): a dropped commit's 200 ms pending debounce would otherwise leave the status stuck and `wait-persisted-or-error` waiting forever. `prune-removed-shapes` also closes a `:colorpicker` modal. `rebase-commits` is not a commit (not saved/broadcast/undoable, commit watchers such as thumbnails do not see it) and falls back to `::dw/reload-current-file` if applying throws. `debug.commitInvalidChange({kind, before, after})` forces such a rejection live.
- A save answered with `:rejected` (backend kept the commits before the first failing one, see `mem:backend/subtleties`) is not a failure: `rollback-rejected` runs before `commit-persisted`. The unit is the undo group (one user action, which can commit to several files, e.g. `update-component` writes the copy's file and the library, and `component-changed` syncs inherit the edit's group; multi-file actions must share a non-nil group): the pure bookkeeping lives in `app.main.data.persistence.commits` (`pcm`): `pcm/rollback-plan` reverts, in the saved file, the pending commits from the first rejected one on, and in any file the pending commits from the first one of a reverted group on, iterating until no new group appears; other files' unrelated commits stay. `pcm/without-rolled-back` drops pending reverted commits from queue entries (partial entries trimmed, empty ones leave the queue) and the buffer; saved commits of a reverted group (the head's kept ones, and `:saved`, which `keep-saved-commits` fills on `discard-commit` with saved commits whose group a pending commit still shares) are undone by a new `save-undo? false` commit (`compensate-commits`, flushed like any edit); for a file whose data is not loaded the undo goes straight to the queue as an entry, nothing applied locally. Per file, `dch/rebase-commits` undoes every pending commit newest first, re-applies the remote changes kept since the oldest pending commit, and redoes the pending commits that stay. Undo changes hold absolute values from commit time, so undoing only the rejected commits would overwrite a peer's later change to the same attr. Remote changes are numbered (`record-remote-changes`, `[:persistence :remote-seq]`) and kept per file under `:remote-changes` only while the file has pending commits; `prune-remote-changes` (discard, release) forgets those at or below the oldest pending stamp, since re-applying them would undo saved edits. Then `dwu/forget-undo-groups groups undo-index` (cut history before the first entry whose `:undo-groups` — every group the entry holds, filled by `append-undo`, transactions and stacking — meets a reverted group; no match keeps the history; the open transaction is emptied only when cut or holding one, keeping its group for later commits; a reverted Ctrl+Z/Ctrl+Shift+Z commit, which carries real `:undo-changes` and `:undo-index` (the index it moved from, kept by `pcm` commit-keys), puts the index back to the plan's `:undo-index`, the oldest one's; background commits with `:undo-changes []` (position-data, component `modified-at`, deleted-font fixes) stay on screen after a rollback by design), selection pruned and edition mode left when the edited shape is gone, or when it is a path being edited that the rollback changed: `discard-path-edit` drops `[:workspace-local :edit-path id]` first, so `finalize-path-content` sees no `:old-content` and the editor's stale copy is never saved (`prune-removed-shapes` takes the edited object from before the rebase to compare). Text editions stay open: the v3 WASM editor already shows the reverted text, and the v2 editor resets its DOM root from the shape on `::rolled-back` when the text differs, since its unmount `on-blur` would otherwise save the stale DOM content (`prune-removed-shapes`; `v2-update-text-shape-content` also skips a missing shape, so the editor's exit commits nothing), `errors/flash` `:unhandled` toast tagged `:persistence-rejected`, hidden after `rejected-timeout-ms` (10 s). It ends with `(ptk/data-event ::rolled-back {:file-ids})`: `dwl/sync-file` waits for it or `wait-persisted` and marks the library synced only on a landed save (on rollback it restores `:synced-at` and re-checks updates after the rollback's own save). `update-status` ignores `:pending` when buffer and queue are empty (`nothing-to-save?`): a dropped commit's 200 ms pending debounce would otherwise leave the status stuck and `wait-persisted-or-error` waiting forever. `prune-removed-shapes` also closes a `:colorpicker` modal. `rebase-commits` is not a commit (not saved/broadcast/undoable, commit watchers such as thumbnails do not see it) and falls back to `::dw/reload-current-file` if applying throws. `debug.commitInvalidChange({kind, before, after})` forces such a rejection live.
- Persistence sends revn as the max of the commit revn and locally tracked latest revn; remote commits update that revn tracker.
- Persistence is skipped in version preview/read-only mode or without edit permission.
- Failing saves lock the workspace (`app.main.data.persistence.lock`, alias `plk`): `plk/failing?` (status `:error`, or `:recovering` while a slow-cycle attempt is in flight as `:saving`) drives `sync-failure-lock` (also on `change-team-role`), which locks only while `[:permissions :can-edit]`: a viewer, already read-only, gets the `:save-permission-denied` toast and never the save-lock banner; `lock-on-failure` stores the previous read-only value in `[:workspace-global :persistence-lock]` and sets `:read-only?`; `unlock-after-failure` restores it (into `[:render-state :pre-context-lost-read-only?]` instead while the WebGL context is lost, which keeps its own save/restore). `set-workspace-read-only` writes into the lock while it is held, so an Inspect/Design switch is not lost. `view-only-bar*` (`ui/workspace/viewport/top_bar.cljs`, shared by both renderers) shows the save error while the lock exists (text per `dps/save-retrying?`), hides its Done/exit button and takes priority over the context-loss message. No save-failure popup: page navigation, pan, zoom and inspection stay available. Once unlocked, the usual Inspect/context-loss message and action return. Plugins still write while read-only (never as a viewer: `commit-changes` drops commits without `:can-edit`). A peer's `:file-restored` reloads the file and opens the `:file-restored` modal named after the restoring session's profile.

View File

@ -158,7 +158,7 @@
"Create a commit event instance"
[{:keys [commit-id redo-changes undo-changes origin save-undo? features
file-id file-revn file-vern undo-group tags stack-undo? source ignore-wasm?
selected-before translation? skip-component-sync?]}]
selected-before translation? skip-component-sync? undo-index]}]
(assert (cpc/check-changes redo-changes)
"expect valid vector of changes for redo-changes")
@ -187,7 +187,9 @@
:ignore-wasm? ignore-wasm?
:selected-before selected-before
:translation? translation?
:skip-component-sync? skip-component-sync?}]
:skip-component-sync? skip-component-sync?
;; Undo history index an undo or redo commit moved away from
:undo-index undo-index}]
(ptk/reify ::commit
cljs.core/IDeref

View File

@ -16,11 +16,13 @@
[app.main.data.common :as-alias dc]
[app.main.data.event :as ev]
[app.main.data.helpers :as dsh]
[app.main.data.modal :as-alias modal]
[app.main.data.notifications :as ntf]
[app.main.data.persistence.commits :as pcm]
[app.main.data.persistence.lock :as plk]
[app.main.data.workspace :as-alias dw]
[app.main.data.workspace.edition :as dwe]
[app.main.data.workspace.path.state :as path.state]
[app.main.data.workspace.undo :as dwu]
[app.main.errors :as errors]
[app.main.refs :as refs]
@ -536,24 +538,54 @@
"How long the rejected changes notice stays; the report is sent anyway."
10000)
(defn- discard-path-edit
"Forgets the path editor's state for `id`, so leaving the edition saves
nothing from it."
[id]
(ptk/reify ::discard-path-edit
ptk/UpdateEvent
(update [_ state]
(update-in state [:workspace-local :edit-path] dissoc id))))
(defn- prune-removed-shapes
"Deselects the shapes missing from the current page and leaves edition
mode if the edited one is missing."
[]
"Deselects the shapes missing from the current page, closing the color
picker that edits them, and leaves edition mode if the edited one is
missing. A path the rollback changed (`edited` is the edited shape before
it) leaves edition too: the editor works on its own copy of the shape,
which still holds the reverted content. Text editors stay open and show
the reverted text."
[edited]
(ptk/reify ::prune-removed-shapes
ptk/UpdateEvent
(update [_ state]
(let [objects (dsh/lookup-page-objects state)]
(d/update-in-when state [:workspace-local :selected]
(fn [selected]
(into (empty selected) (filter #(contains? objects %)) selected)))))
(let [objects (dsh/lookup-page-objects state)
selected (dm/get-in state [:workspace-local :selected])
kept (some->> selected (into (empty selected) (filter #(contains? objects %))))]
(cond-> state
(some? selected)
(assoc-in [:workspace-local :selected] kept)
(and (not= (count selected) (count kept))
(= :colorpicker (dm/get-in state [::modal/modal :type])))
(dissoc ::modal/modal))))
ptk/WatchEvent
(watch [_ state _]
(let [edition (dm/get-in state [:workspace-local :edition])]
(if (and (some? edition)
(not (contains? (dsh/lookup-page-objects state) edition)))
(let [edition (dm/get-in state [:workspace-local :edition])
shape (get (dsh/lookup-page-objects state) edition)]
(cond
(nil? edition)
(rx/empty)
(nil? shape)
(rx/of (dwe/clear-edition-mode))
(and (path.state/editing? state)
(not= edited shape))
(rx/of (discard-path-edit edition)
(dwe/clear-edition-mode))
:else
(rx/empty))))))
(defn- compensate-commits
@ -586,13 +618,14 @@
"Events that revert the local data as `plan` says: pending commits are
undone, saved ones undone by a new commit, and their groups leave the
undo history."
[origin state {:keys [files compensate groups]}]
[origin state {:keys [files compensate groups undo-index]}]
(rx/concat
(rx/from (map (fn [[fid plan]] (dch/rebase-commits fid plan)) files))
(rx/from (map (fn [[fid commits]] (compensate-commits origin state fid commits))
(group-by :file-id compensate)))
(rx/of (dwu/forget-undo-groups groups)
(prune-removed-shapes)
(rx/of (dwu/forget-undo-groups groups undo-index)
(prune-removed-shapes (get (dsh/lookup-page-objects state)
(dm/get-in state [:workspace-local :edition])))
;; Tells listeners (e.g. a library sync) which files lost commits
(ptk/data-event ::rolled-back
{:file-ids (into (set (keys files)) (map :file-id) compensate)}))))

View File

@ -16,7 +16,7 @@
(def ^:private commit-keys
"Keys a queue entry keeps of each of its commits."
[:id :origin :undo-group :redo-changes :undo-changes :remote-seq])
[:id :origin :undo-group :redo-changes :undo-changes :remote-seq :undo-index])
(def ^:private saved-keys
"Keys a saved commit takes from its queue entry."
@ -96,7 +96,9 @@
Each file in `:files` undoes all its pending commits, applies again the
remote changes that came after them and redoes the commits that stay, so
a peer's later value for an attribute survives. Saved commits of a
reverted group go to `:compensate`, to be undone by a new commit."
reverted group go to `:compensate`, to be undone by a new commit.
`:undo-index` is the undo history index before the oldest undo or redo
commit it reverts, if any."
[pstate {:keys [file-id commits] :as entry} {:keys [commit-ids]}]
(let [rejected (set commit-ids)
accepted (into [] (remove #(contains? rejected (:id %))) commits)
@ -113,6 +115,9 @@
saved (into (vec (:saved pstate)) (saved-commits entry accepted))
compensate
(into [] (filter #(contains? groups (:undo-group %))) saved)
file-plan
(fn [[fid commits]]
(let [all (get pending fid)
@ -125,8 +130,10 @@
:reverted commits}]))]
{:files (into {} (map file-plan) reverted)
:compensate (into [] (filter #(contains? groups (:undo-group %))) saved)
:groups groups}))
:compensate compensate
:groups groups
;; Saved commits are older than pending ones
:undo-index (some :undo-index (concat compensate (mapcat val reverted)))}))
(defn release-plan
"How to roll back every pending commit of `file-id`, as if the server had

View File

@ -443,6 +443,19 @@
result (if (empty? result) txt/default-text-attrs result)]
result))
(defn- styled-runs
"The text runs of `content` with their styles, without node ids or the
style keys set to nil."
[content]
(mapv (fn [[style text]] [(d/without-nils style) text])
(txt/content->text+styles content)))
(defn same-styled-text?
"True when text contents `a` and `b` hold the same text with the same
styles."
[a b]
(= (styled-runs a) (styled-runs b)))
(defn v2-current-text-values
[{:keys [editor-instance attrs]}]
(let [result (-> (.-currentStyle editor-instance)

View File

@ -61,6 +61,8 @@
[:undo-changes [:vector cpc/schema:change]]
[:redo-changes [:vector cpc/schema:change]]
[:undo-group ::sm/uuid]
;; Undo groups of every commit the entry holds
[:undo-groups {:optional true} [:set ::sm/uuid]]
[:tags [:set :keyword]]
[:selected-before {:optional true} [:maybe [:set ::sm/uuid]]]
[:selected-after {:optional true} [:maybe [:set ::sm/uuid]]]
@ -137,7 +139,7 @@
exists, or creates a new entry if it doesn't. When stacking onto an
existing entry, the entry's original timestamp is preserved so the
history panel keeps showing when the action originated. Issue #7660."
[state {:keys [undo-changes redo-changes selected-after] :as entry}]
[state {:keys [undo-changes redo-changes undo-group selected-after] :as entry}]
(let [index (get-in state [:workspace-undo :index] -1)]
(if (>= index 0)
(update-in state [:workspace-undo :items index]
@ -145,6 +147,7 @@
(-> item
(update :undo-changes #(into undo-changes %))
(update :redo-changes #(into % redo-changes))
(update :undo-groups (fnil conj #{}) undo-group)
(assoc :selected-after selected-after))))
(add-undo-entry state entry))))
@ -156,6 +159,7 @@
(update-in [:workspace-undo :transaction :redo-changes] #(into % redo-changes))
(cond-> (nil? (get-in state [:workspace-undo :transaction :undo-group]))
(assoc-in [:workspace-undo :transaction :undo-group] undo-group))
(update-in [:workspace-undo :transaction :undo-groups] (fnil conj #{}) undo-group)
(cond-> (nil? (get-in state [:workspace-undo :transaction :selected-before]))
(assoc-in [:workspace-undo :transaction :selected-before] selected-before))
(assoc-in [:workspace-undo :transaction :selected-after] selected-after)
@ -176,7 +180,9 @@
ptk/UpdateEvent
(update [_ state]
(let [selected-after (dm/get-in state [:workspace-local :selected])
entry (assoc entry :selected-after selected-after)]
entry (assoc entry
:selected-after selected-after
:undo-groups #{(:undo-group entry)})]
(cond
(and (get-in state [:workspace-undo :transaction])
(or (not stack?)
@ -255,25 +261,31 @@
(add-transaction-undo-entry)))))
(defn forget-undo-groups
"Drops the undo history from the first entry of `undo-groups` on, or all
of it if none matches, and empties the open transaction. Later commits
of that transaction keep the undo group of its earlier ones."
[undo-groups]
"Drops the undo history from the first entry holding a commit of
`undo-groups` on, and empties the open transaction when the history is
cut or it holds one of them; its later commits keep the undo group of
its earlier ones. `undo-index`, when not nil, is the index the oldest
reverted undo or redo commit moved away from: the history points there
again."
[undo-groups undo-index]
(ptk/reify ::forget-undo-groups
ptk/UpdateEvent
(update [_ state]
(update state :workspace-undo
(fn [{:keys [items index transaction] :as undo}]
(let [first-index (d/index-of-pred items #(contains? undo-groups (:undo-group %)))]
(cond-> undo
(let [reverted? #(some undo-groups (:undo-groups %))
first-index (d/index-of-pred items reverted?)
last-index (dec (count items))
index (if (some? undo-index)
(min undo-index last-index)
(d/nilv index last-index))]
(cond-> (assoc undo :index index)
(some? first-index)
(assoc :items (subvec items 0 first-index)
:index (min (d/nilv index -1) (dec first-index)))
:index (min index (dec first-index)))
(nil? first-index)
(dissoc :items :index)
(some? transaction)
(and (some? transaction)
(or (some? first-index) (reverted? transaction)))
(assoc :transaction
(assoc empty-tx :commit-group (or (:undo-group transaction)
(:commit-group transaction)))))))))))
@ -326,20 +338,26 @@
index (or (:index undo) (dec (count items)))]
(when (and (some? items)
(<= -1 dest-index (dec (count items))))
(let [changes (vec (apply concat
(cond
(< dest-index index)
(->> (subvec items (inc dest-index) (inc index))
(reverse)
(map :undo-changes))
(> dest-index index)
(->> (subvec items (inc index) (inc dest-index))
(map :redo-changes))
:else [])))]
(when (seq changes)
(rx/of (materialize-undo changes dest-index)
(dch/commit-changes {:redo-changes changes
:undo-changes []
(let [[redo-changes undo-changes]
(cond
(< dest-index index)
(let [entries (subvec items (inc dest-index) (inc index))]
[(into [] (mapcat :undo-changes) (reverse entries))
(into [] (mapcat :redo-changes) entries)])
(> dest-index index)
(let [entries (subvec items (inc index) (inc dest-index))]
[(into [] (mapcat :redo-changes) entries)
(into [] (mapcat :undo-changes) (reverse entries))])
:else
[[] []])]
(when (seq redo-changes)
(rx/of (materialize-undo redo-changes dest-index)
;; Undoable by a rollback, which also puts the index back
(dch/commit-changes {:redo-changes redo-changes
:undo-changes undo-changes
:undo-index index
:origin it
:save-undo? false})))))))))))
@ -383,7 +401,8 @@
(restore-selection (:selected-before first-item))))
(rx/of (materialize-undo changes (dec index))
(dch/commit-changes {:redo-changes changes
:undo-changes []
:undo-changes (:redo-changes item)
:undo-index index
:save-undo? false
:origin it})
(restore-selection (:selected-before item))
@ -421,7 +440,8 @@
(restore-selection (:selected-after last-item))))
(rx/of (materialize-undo changes (inc index))
(dch/commit-changes {:redo-changes changes
:undo-changes []
:undo-changes (:undo-changes item)
:undo-index index
:origin it
:save-undo? false})
(restore-selection (:selected-after item))))))))))))

View File

@ -18,6 +18,7 @@
[app.common.types.text :as txt]
[app.config :as cf]
[app.main.data.helpers :as dsh]
[app.main.data.persistence :as-alias dps]
[app.main.data.workspace :as dw]
[app.main.data.workspace.texts :as dwt]
[app.main.features :as features]
@ -34,7 +35,9 @@
[app.util.text.content :as content]
[app.util.text.content.styles :as styles]
[app.util.timers :as ts]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[potok.v2.core :as ptk]
[rumext.v2 :as mf]))
(defn get-contrast-color [background-color]
@ -182,7 +185,22 @@
on-clipboard-change
(fn [event]
(let [style (.-detail event)]
(st/emit! (dw/set-clipboard-style style))))]
(st/emit! (dw/set-clipboard-style style))))
;; A rollback that reverts the text or its styles puts the reverted
;; content in the editor, which would otherwise keep showing and
;; saving the rejected one
rolled-back-sub
(->> st/stream
(rx/filter (ptk/type? ::dps/rolled-back))
(rx/subs! (fn [_]
(let [content (-> (dsh/lookup-page-objects @st/state)
(get shape-id)
:content)
current (content/dom->cljs (dwt/get-editor-root instance))]
(when (and (some? content)
(not (dwt/same-styled-text? content current)))
(dwt/set-editor-root! instance (content/cljs->dom content)))))))]
(.addEventListener ^js global/document "keyup" on-key-up)
(.addEventListener ^js instance "focus" on-focus)
@ -210,6 +228,7 @@
(.removeEventListener ^js instance "stylechange" on-style-change)
(.removeEventListener ^js instance "change" on-change)
(.removeEventListener ^js instance "clipboardchange" on-clipboard-change)
(rx/dispose! rolled-back-sub)
(dwt/dispose! instance)
(st/emit! (dwt/update-editor nil)))))

View File

@ -538,7 +538,9 @@
{:bounds vbox
:selected-shapes selected-shapes
:frame selected-frame
:hover-shape @measure-hover
;; The current shape: a rollback may have removed or changed it
;; since the pointer last moved
:hover-shape (some->> @measure-hover :id (get base-objects))
:zoom zoom}])
;; Show distances during movement with ALT

View File

@ -834,7 +834,9 @@
{:bounds vbox
:selected-shapes selected-shapes
:frame selected-frame
:hover-shape @measure-hover
;; The current shape: a rollback may have removed or changed it
;; since the pointer last moved
:hover-shape (some->> @measure-hover :id (get base-objects))
:zoom zoom}])
(when show-padding?

View File

@ -365,6 +365,7 @@
(let [data (u/locate-shape file-id page-id id)]
(-> (obj/reify {:name "ShapeProxy"
:on-set u/check-shape-exists
:on-error (u/handle-error plugin-id)}
:$plugin {:enumerable false :get (fn [] plugin-id)}
:$id {:enumerable false :get (fn [] id)}
@ -1041,6 +1042,7 @@
;; Methods
:resize
(fn [width height]
(u/check-shape-located file-id page-id id)
(cond
(or (not (sm/valid-safe-number? width)) (<= width 0))
(u/not-valid plugin-id :resize width)
@ -1081,6 +1083,7 @@
:rotate
(fn [angle center]
(u/check-shape-located file-id page-id id)
(let [center (when center {:x (obj/get center "x") :y (obj/get center "y")})]
(cond
(not (sm/valid-safe-number? angle))

View File

@ -151,6 +151,26 @@
(when (and (some? file-id) (some? page-id) (some? id))
(locate-shape file-id page-id id))))
(defn- shape-not-found
[id]
(ex-info (dm/str "[PENPOT PLUGIN] Shape not found: " id)
{:type ::shape-not-found}))
(defn check-shape-located
"Throws when shape `id` no longer exists (removed by an undo, a peer or
a rejected save), so a write to it does not vanish silently. Always
throws, whatever the plugin's validation flag: `handle-error` lets this
error through."
[file-id page-id id]
(when (nil? (locate-shape file-id page-id id))
(throw (shape-not-found id))))
(defn check-shape-exists
"`check-shape-located` for the shape behind `proxy`."
[proxy]
(when (nil? (proxy->shape proxy))
(throw (shape-not-found (obj/get proxy "$id")))))
(defn inside-component-copy?
"True when `shape` is nested inside a component copy. The copy root itself is
movable as a whole; its descendants are structural copy content and must not
@ -581,6 +601,10 @@
(let [explain (-> cause ex-data ::sm/explain)
throw? (throw-validation-errors? plugin-id)]
(cond
;; A write to a removed shape always reaches the plugin
(= ::shape-not-found (-> cause ex-data :type))
(throw cause)
;; If it's a clojure error we throw as a validation error
(and throw? explain)
(throw-not-valid :error (error-messages explain))

View File

@ -176,6 +176,7 @@
fn-expr (c/get params :fn)
schema-n (c/get params :schema)
wrap (c/get params :wrap)
on-set (c/get params :on-set)
schema-1 (c/get params :schema-1)
this? (c/get params :this false)
on-error (c/get params :on-error)
@ -216,6 +217,10 @@
(-> (gensym (str "wrap-fn-" (str/slug pname) "-"))
(with-meta {:tag 'function}))
on-set-sym
(-> (gensym (str "on-set-fn-" (str/slug pname) "-"))
(with-meta {:tag 'function}))
val-sym
(gensym (str "val-" (str/slug pname) "-"))
@ -232,6 +237,9 @@
(when wrap
[wrap-sym wrap])
(when (and set-expr on-set)
[on-set-sym on-set])
(when get-expr
[(make-sym pname "get-fn")
(if this?
@ -258,6 +266,10 @@
(make-sym pname "set-fn")
`(fn [~val-sym]
;; Runs outside the error handling, so what it throws
;; always reaches the caller
~@(when on-set
[`(~on-set-sym (~'js* "this"))])
~(wrap-error-handling
`(let [~this-sym (~'js* "this")
~fn-sym ~set-expr
@ -411,6 +423,7 @@
(if (some? definition)
(let [definition (if (map? definition)
(c/merge {:wrap (:wrap tmeta)
:on-set (:on-set tmeta)
:on-error (:on-error tmeta)}
definition)
(-> {:enumerable false}

View File

@ -106,3 +106,55 @@
c2 {:redo-changes [:r2] :undo-changes [:u2]}]
(t/is (= {:redo-changes [:u2 :u1] :undo-changes [:r1 :r2]}
(pcm/compensation-changes [c1 c2])))))
(t/deftest a-rollback-plan-names-the-index-before-the-oldest-reverted-undo
(let [file-id (uuid/next)
bad (commit file-id (uuid/next))
undo-1 (assoc (commit file-id (uuid/next)) :undo-index 4)
undo-2 (assoc (commit file-id (uuid/next)) :undo-index 3)
head (pcm/queue-entry [bad undo-1])
pstate (assoc (queued head) :buffer [undo-2])]
(t/is (= 4 (:undo-index (pcm/rollback-plan pstate head {:commit-ids [(:id bad) (:id undo-1)]}))))
(t/is (nil? (:undo-index (pcm/rollback-plan (queued (pcm/queue-entry [bad]))
(pcm/queue-entry [bad])
{:commit-ids [(:id bad)]}))))))
(t/deftest saved-commits-are-kept-while-a-pending-commit-shares-their-group
(let [file-id (uuid/next)
group (uuid/next)
shared (commit file-id group)
alone (commit file-id (uuid/next))
pending (commit file-id group)
old (commit file-id (uuid/next))
entry (pcm/queue-entry [shared alone])
pstate (pcm/keep-saved-commits {:queue #queue [] :index {} :buffer [pending] :saved [old]}
entry)]
(t/is (= [(:id shared)] (mapv :id (:saved pstate))))
(t/is (= file-id (:file-id (first (:saved pstate))))
"a saved commit carries the file it was saved to")))
(t/deftest remote-changes-older-than-the-oldest-pending-commit-are-forgotten
(let [file-id (uuid/next)
pending (assoc (commit file-id nil) :remote-seq 2)
pstate {:queue #queue [] :index {} :buffer [pending]
:remote-changes {file-id [{:seq 1 :changes [:old]} {:seq 3 :changes [:new]}]}}]
(t/is (= [{:seq 3 :changes [:new]}]
(get-in (pcm/prune-remote-changes pstate file-id) [:remote-changes file-id])))
(t/is (not (contains? (:remote-changes (pcm/prune-remote-changes (dissoc pstate :buffer) file-id))
file-id))
"a file with nothing pending keeps no remote changes")))
(t/deftest a-release-plan-reverts-every-pending-commit-of-the-file-and-its-groups
(let [file-id (uuid/next)
lib-id (uuid/next)
group (uuid/next)
edit (commit file-id (uuid/next))
linked (commit file-id group)
lib (commit lib-id group)
pstate (assoc (queued (pcm/queue-entry [edit linked]))
:buffer [lib])
plan (pcm/release-plan pstate file-id)]
(t/is (= [(:id edit) (:id linked)] (mapv :id (get-in plan [:files file-id :reverted]))))
(t/is (empty? (get-in plan [:files file-id :redo])))
(t/is (= [(:id lib)] (mapv :id (get-in plan [:files lib-id :reverted])))
"the rest of the undo group goes too")))

View File

@ -15,7 +15,6 @@
(:require
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.main.data.changes :as dch]
[app.main.data.event :as ev]
[app.main.data.persistence :as dps]
[app.main.errors :as errors]
@ -27,62 +26,9 @@
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.async :as async]
[frontend-tests.helpers.mock :as mock]
[frontend-tests.helpers.persistence :refer [local-commit with-persistence]]
[potok.v2.core :as ptk]))
(defn- local-commit
"Builds a synthetic local commit event for `file-id`: a page edit as redo,
no undo, from the `:local` source."
[file-id]
(ptk/data-event ::dch/commit
{:id (uuid/next)
:file-id file-id
:file-revn 0
:file-vern 0
:source :local
:features #{}
:redo-changes [{:type :mod-page :id (uuid/next) :name "Edited"}]
:undo-changes []}))
(defn- with-persistence
"Async fixture for the persistence tests: mocks the transport, flash and
the leave-page task set (`:tasks`), and runs `f` with persistence
initialized.
Evaluates to a promise resolving once `f` settles and teardown completes;
`await` it from an `^:async` test.
By default the transport responds through the `:response` subject. Pass a
`respond` function (`(fn [cmd params] observable)`) to drive the transport
response directly instead. Delivery through the subject is asynchronous
(`observe-on :async`): await each effect — via `wait-for` — before
asserting it."
[f & [respond]]
(let [file-id (uuid/next)
response (rx/subject)
failures (atom [])
requests (atom [])
tasks (atom #{})
store (ptk/store {:state {:current-file-id file-id
:permissions {:can-edit true}
:files {file-id {:id file-id :revn 0}}}
:on-error #(t/is false (str %))})]
(mock/with-mocks*
{rp/cmd! (mock/stub (fn [cmd params]
(swap! requests conj [cmd params])
(if respond
(respond cmd params)
(->> response (rx/take 1) (rx/observe-on :async)))))
errors/flash (fn [& {:keys [cause]}]
(swap! failures conj cause))
st/ongoing-tasks tasks}
(try
(ptk/emit! store (dps/initialize-persistence))
(await (f {:file-id file-id :response response :failures failures
:requests requests :store store :tasks tasks}))
(finally
(rx/dispose! store)
(rx/end! response))))))
(defn- instant-backoff
"Timer double: the backoff delays fire at once and the slow cycle never
does, so a failure that outlasts the backoff settles in `:error`."

View File

@ -0,0 +1,568 @@
;; 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.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.undo :as dwu]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.async :as async]
[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")))))))

View File

@ -9,7 +9,8 @@
read-only saves, the stalled-save watchdog, the core save pipeline
(queueing, failures, recovery), and the transient/terminal failure
classification. Retry-episode behavior lives in
`frontend-tests.data.persistence-retry-test`.
`frontend-tests.data.persistence-retry-test`, the rollback of a partly
rejected save in `frontend-tests.data.persistence-rollback-test`.
The watchdog and read-only tests are fully async: `mock/with-mocks*`
installs the doubles, `^:async` bodies `await` observable effects (never
@ -27,17 +28,16 @@
[app.main.data.render-wasm :as drw]
[app.main.data.workspace :as dw]
[app.main.data.workspace.common :as dwc]
[app.main.data.workspace.undo :as dwu]
[app.main.errors :as errors]
[app.main.refs :as refs]
[app.main.repo :as rp]
[app.main.store :as st]
[app.main.worker :as mw]
[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 :refer [local-commit with-persistence]]
[okulary.core :as l]
[potok.v2.core :as ptk]))
@ -62,9 +62,6 @@
:pages-index {page-id {:id page-id :name "Page"
:objects {uuid/zero
(cts/setup-shape {:id uuid/zero :type :frame})}}}}}
rename (fn [name]
{:type :mod-obj :id shape-id :page-id page-id
:operations [{:type :set :attr :name :val name}]})
pstate {:status status
:error {:cause-type cause-type :commit-id first-id}
:queue (into #queue [] [first-id next-id other-id])
@ -73,9 +70,9 @@
:page-id page-id :parent-id uuid/zero
:frame-id uuid/zero}]}]}
next-id {:file-id file-id :file-vern 0
:commits [{:redo-changes [(rename "Retained rectangle")]}]}
:commits [{:redo-changes [(thp/rename page-id shape-id "Retained rectangle")]}]}
other-id {:file-id (uuid/next) :file-vern 0
:commits [{:redo-changes [(rename "Another file")]}]}}}
:commits [{:redo-changes [(thp/rename page-id shape-id "Another file")]}]}}}
state {:persistence pstate}
loaded (ptk/update (#'dw/bundle-fetched {:file file :file-id file-id :thumbnails {}})
state)
@ -94,9 +91,6 @@
(let [file-id (uuid/next)
page-id (uuid/next)
commit-id (uuid/next)
rename (fn [name]
{:type :mod-obj :id uuid/zero :page-id page-id
:operations [{:type :set :attr :name :val name}]})
file {:id file-id
:vern 0
:data {:pages [page-id]
@ -106,9 +100,9 @@
pstate {:status :saving
:queue (into #queue [] [commit-id])
:index {commit-id {:file-id file-id :file-vern 0
:commits [{:redo-changes [(rename "Queued")]}]}}
:buffer [{:file-id file-id :file-vern 0 :redo-changes [(rename "Buffered")]}
{:file-id (uuid/next) :file-vern 0 :redo-changes [(rename "Another file")]}]}
:commits [{:redo-changes [(thp/rename page-id uuid/zero "Queued")]}]}}
:buffer [{:file-id file-id :file-vern 0 :redo-changes [(thp/rename page-id uuid/zero "Buffered")]}
{:file-id (uuid/next) :file-vern 0 :redo-changes [(thp/rename page-id uuid/zero "Another file")]}]}
loaded (ptk/update (#'dw/bundle-fetched {:file file :file-id file-id :thumbnails {}})
{:persistence pstate})]
(t/is (= "Buffered"
@ -136,20 +130,6 @@
(t/is (identical? data (get-in loaded [:files file-id :data])))
(t/is (identical? pstate (:persistence loaded)))))
(defn- local-commit
"Builds a synthetic local commit event for `file-id`: a page edit as redo,
no undo, from the `:local` source."
[file-id]
(ptk/data-event ::dch/commit
{:id (uuid/next)
:file-id file-id
:file-revn 0
:file-vern 0
:source :local
:features #{}
:redo-changes [{:type :mod-page :id (uuid/next) :name "Edited"}]
:undo-changes []}))
(defn- ^:async run-read-only-phases
"Drives the read-only save scenario presuming asynchronous APIs: every
phase emits its triggers, awaits the transport round-trip, and only
@ -539,46 +519,8 @@
;;
;; Same async pattern as the watchdog tests: triggers stay bare, every
;; assert block is preceded by `wait-for` on its leading signal (or a bare
;; `settle` tick when it asserts only absence).
(defn- with-persistence
"Async fixture for the persistence tests: mocks the transport, flash and
the leave-page task set (`:tasks`), and runs `f` with persistence
initialized.
Evaluates to a promise resolving once `f` settles and teardown completes;
`await` it from an `^:async` test.
By default the transport responds through the `:response` subject. Pass a
`respond` function (`(fn [cmd params] observable)`) to drive the transport
response directly instead. Delivery through the subject is asynchronous
(`observe-on :async`): await each effect — via `wait-for` — before
asserting it."
[f & [respond]]
(let [file-id (uuid/next)
response (rx/subject)
failures (atom [])
requests (atom [])
tasks (atom #{})
store (ptk/store {:state {:current-file-id file-id
:permissions {:can-edit true}
:files {file-id {:id file-id :revn 0}}}
:on-error #(t/is false (str %))})]
(mock/with-mocks*
{rp/cmd! (mock/stub (fn [cmd params]
(swap! requests conj [cmd params])
(if respond
(respond cmd params)
(->> response (rx/take 1) (rx/observe-on :async)))))
errors/flash (fn [& {:keys [cause]}]
(swap! failures conj cause))
st/ongoing-tasks tasks}
(try
(ptk/emit! store (dps/initialize-persistence))
(await (f {:file-id file-id :response response :failures failures
:requests requests :store store :tasks tasks}))
(finally
(rx/dispose! store)
(rx/end! response))))))
;; `settle` tick when it asserts only absence). The fixture is
;; `with-persistence` (`frontend-tests.helpers.persistence`).
;; Scenario: edit permission is revoked with an edit queued. The save fails
;; without sending anything, the queue keeps the edit and the user is
@ -639,47 +581,37 @@
(await
(with-persistence
(^:async fn [{:keys [file-id requests failures store]}]
(await
(mock/with-mocks*
{mw/ask! (mock/stub (fn [_] (rx/empty)))}
(let [page-id (uuid/next)
shape-id (uuid/next)
commit (rename-commit file-id page-id shape-id "A" "B")]
(ptk/emit! store
#(-> %
(assoc :current-page-id page-id)
(assoc-in [:files file-id :data] (file-data page-id shape-id "B"))
(assoc :workspace-undo {:items [{:undo-group (:undo-group @commit)
:undo-groups #{(:undo-group @commit)}
:undo-changes (:undo-changes @commit)
:redo-changes (:redo-changes @commit)
:tags #{}}]
:index 0}))
commit
#(assoc-in % [:permissions :can-edit] false)
::dps/force-persist)
(await (async/wait-for #(= :error (get-in @store [:persistence :status]))
"lost permission fails the save"))
(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 % :workspace-undo {:items [(thp/undo-entry commit)] :index 0})
commit
#(assoc-in % [:permissions :can-edit] false)
::dps/force-persist)
(await (async/wait-for #(= :error (get-in @store [:persistence :status]))
"lost permission fails the save"))
(ptk/emit! store (ptk/data-event :app.main.data.common/change-team-role))
(await (async/settle))
(t/is (= 1 (count (get-in @store [:persistence :queue])))
"a viewer keeps the edit")
(t/is (= "B" (shape-name store file-id page-id shape-id)))
(t/is (= 2 (count @failures))
"the count notice shows again over the role change notice")
(ptk/emit! store (ptk/data-event :app.main.data.common/change-team-role))
(await (async/settle))
(t/is (= 1 (count (get-in @store [:persistence :queue])))
"a viewer keeps the edit")
(t/is (= "B" (thp/shape-name store file-id page-id shape-id)))
(t/is (= 2 (count @failures))
"the count notice shows again over the role change notice")
(ptk/emit! store
#(assoc-in % [:permissions :can-edit] true)
(ptk/data-event :app.main.data.common/change-team-role))
(await (async/wait-for #(and (= :saved (get-in @store [:persistence :status]))
(empty? (get-in @store [:persistence :queue])))
"edit rights back release the edit"))
(t/is (= "A" (shape-name store file-id page-id shape-id))
"the file shows the server's data")
(t/is (empty? (get-in @store [:workspace-undo :items]))
"the released edit leaves the history")
(t/is (empty? @requests) "the released edit is not sent"))))))))
(ptk/emit! store
#(assoc-in % [:permissions :can-edit] true)
(ptk/data-event :app.main.data.common/change-team-role))
(await (async/wait-for #(and (= :saved (get-in @store [:persistence :status]))
(empty? (get-in @store [:persistence :queue])))
"edit rights back release the edit"))
(t/is (= "A" (thp/shape-name store file-id page-id shape-id))
"the file shows the server's data")
(t/is (empty? (get-in @store [:workspace-undo :items]))
"the released edit leaves the history")
(t/is (empty? @requests) "the released edit is not sent"))))))
;; Answers every save with an unexpected server error, a terminal failure.
(defn- server-error
@ -1330,40 +1262,6 @@
(await (async/wait-for #(= :saved (get-in @store [:persistence :status]))
"the save lands")))))))
(defn- rename
[page-id shape-id name]
{:type :mod-obj :id shape-id :page-id page-id
:operations [{:type :set :attr :name :val name}]})
(defn- rename-commit
"A local commit of `file-id` that renames the shape from `from` to `to`,
with its own undo group."
[file-id page-id shape-id from to]
(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 [(rename page-id shape-id to)]
:undo-changes [(rename page-id shape-id from)]}))
(defn- file-data
"File data with one page holding one rectangle named `name`."
[page-id shape-id name]
{:pages [page-id]
:pages-index {page-id {:id page-id
:name "Page"
:objects {uuid/zero (cts/setup-shape {:id uuid/zero :type :frame :shapes [shape-id]})
shape-id (cts/setup-shape {:id shape-id :type :rect :name name
:parent-id uuid/zero :frame-id uuid/zero})}}}})
(defn- shape-name
[store file-id page-id shape-id]
(get-in @store [:files file-id :data :pages-index page-id :objects shape-id :name]))
;; Scenario: an edit waits in the buffer when another user's change to the
;; same attribute arrives. The server will apply the remote change first and
;; the buffered edit after it, so the local file must end the same way.
@ -1372,27 +1270,38 @@
(await
(with-persistence
(^:async fn [{:keys [file-id store]}]
(await
(mock/with-mocks*
{mw/ask! (mock/stub (fn [_] (rx/empty)))}
(let [page-id (uuid/next)
shape-id (uuid/next)
local (rename-commit file-id page-id shape-id "Original" "Local")]
(ptk/emit! store #(-> %
(assoc :current-page-id page-id)
(assoc-in [:files file-id :data] (file-data page-id shape-id "Local"))))
(ptk/emit! store local)
(await (async/wait-for #(seq (get-in @store [:persistence :buffer]))
"the local edit is buffered"))
(ptk/emit! store (dch/commit {:file-id file-id
:file-revn 1
:file-vern 0
:source :remote
:save-undo? false
:redo-changes [(rename page-id shape-id "Remote")]
:undo-changes []}))
(await (async/settle))
(t/is (= "Local" (shape-name store file-id page-id shape-id))))))))))
(let [page-id (uuid/next)
shape-id (uuid/next)
local (thp/rename-commit file-id page-id shape-id "Original" "Local")]
(ptk/emit! store (thp/open-file file-id page-id (thp/file-data page-id shape-id "Local")))
(ptk/emit! store local)
(await (async/wait-for #(seq (get-in @store [:persistence :buffer]))
"the local edit is buffered"))
(ptk/emit! store (thp/remote-commit file-id 1 [(thp/rename page-id shape-id "Remote")]))
(await (async/settle))
(t/is (= "Local" (thp/shape-name store file-id page-id shape-id))))))))
;; Scenario: a library edit waits to be saved when a peer's change to this
;; file arrives, and both files have a shape with the same id. Proves: a
;; remote commit goes under the pending commits of its own file only.
(t/deftest ^:async a-remote-commit-ignores-the-pending-commits-of-other-files
(await
(with-persistence
(^:async fn [{:keys [file-id store]}]
(let [lib-id (uuid/next)
page-id (uuid/next)
shape-id (uuid/next)]
(ptk/emit! store (thp/open-file-with-library file-id lib-id page-id
(thp/file-data page-id shape-id "A")
(thp/file-data page-id shape-id "Y")))
(ptk/emit! store (thp/rename-commit lib-id page-id shape-id "X" "Y"))
(await (async/wait-for #(seq (get-in @store [:persistence :buffer]))
"the library edit is 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.3 (thp/shape-attr store file-id page-id shape-id :opacity)))
(t/is (= "A" (thp/shape-name store file-id page-id shape-id))
"the library edit is not replayed on this file"))))))
;; Commit boundaries.
@ -1442,581 +1351,3 @@
(await (async/wait-for #(= 2 (count @requests)) "the buffered edit is sent"))
(rx/push! response {:revn 2})
(await (async/wait-for released? "the waiters are released")))))))
;; Rollback of rejected commits.
;; 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
(with-persistence
(^:async fn [{:keys [file-id response requests store failures]}]
(await
(mock/with-mocks*
{mw/ask! (mock/stub (fn [_] (rx/empty)))}
(let [page-id (uuid/next)
shape-id (uuid/next)
other-id (uuid/next)
commit1 (rename-commit file-id page-id shape-id "A" "B")
commit2 (rename-commit file-id page-id shape-id "B" "C")
commit3 (rename-commit file-id page-id shape-id "C" "D")
commit4 (rename-commit file-id page-id shape-id "D" "E")
other (local-commit other-id)
entry (fn [commit] {:undo-group (:undo-group @commit)
:undo-changes (:undo-changes @commit)
:redo-changes (:redo-changes @commit)
:tags #{}})]
;; The local data already holds the four renames
(ptk/emit! store
#(-> %
(assoc :current-page-id page-id)
(assoc-in [:files file-id :data] (file-data page-id shape-id "E"))
(assoc-in [:files other-id] {:id other-id :revn 0})
(assoc :workspace-undo {:items (mapv 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 {:revn 0
:rejected {:commit-ids [(:id @commit2)]
:failed-commit-id (:id @commit2)
:error {:type :validation :code :referential-integrity}}})
(await (async/wait-for #(= 2 (count @requests)) "the other file's edit is sent next"))
(t/is (= "B" (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
(with-persistence
(^:async fn [{:keys [file-id response requests store]}]
(await
(mock/with-mocks*
{mw/ask! (mock/stub (fn [_] (rx/empty)))}
(let [page-id (uuid/next)
shape-id (uuid/next)
commit1 (rename-commit file-id page-id shape-id "A" "B")
commit2 (rename-commit file-id page-id shape-id "B" "C")]
(ptk/emit! store
#(-> %
(assoc :current-page-id page-id)
(assoc-in [:files file-id :data] (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 {:revn 0
:rejected {:commit-ids [(:id @commit1)]
:failed-commit-id (:id @commit1)
:error {:type :validation :code :referential-integrity}}})
(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]))))))))))
(defn- set-attr
[page-id shape-id attr val]
{:type :mod-obj :id shape-id :page-id page-id
:operations [{:type :set :attr attr :val val}]})
(defn- attr-commit
"A local commit of `file-id` that sets `attr` from `from` to `to`."
[file-id page-id shape-id attr from to]
(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 [(set-attr page-id shape-id attr to)]
:undo-changes [(set-attr page-id shape-id attr from)]}))
(defn- remote-commit
[file-id revn changes]
(dch/commit {:file-id file-id
:file-revn revn
:file-vern 0
:source :remote
:save-undo? false
:redo-changes changes
:undo-changes []}))
(defn- shape-attr
[store file-id page-id shape-id attr]
(get-in @store [:files file-id :data :pages-index page-id :objects shape-id attr]))
(def ^:private rejected-second
(fn [commit]
{:revn 0
:rejected {:commit-ids [(:id @commit)]
:failed-commit-id (:id @commit)
:error {:type :validation :code :referential-integrity}}}))
;; 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
(with-persistence
(^:async fn [{:keys [file-id response requests store]}]
(await
(mock/with-mocks*
{mw/ask! (mock/stub (fn [_] (rx/empty)))}
(let [page-id (uuid/next)
shape-id (uuid/next)
commit1 (rename-commit file-id page-id shape-id "A" "B")
commit2 (attr-commit file-id page-id shape-id :opacity 0.5 0.9)]
(ptk/emit! store #(-> %
(assoc :current-page-id page-id)
(assoc-in [:files file-id :data]
(-> (file-data page-id shape-id "B")
(assoc-in [:pages-index page-id :objects shape-id :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 (remote-commit file-id 1 [(set-attr page-id shape-id :opacity 0.3)]))
(await (async/settle))
(t/is (= 0.9 (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 (rejected-second commit2))
(await (async/wait-for #(= :saved (get-in @store [:persistence :status]))
"the rollback settles"))
(t/is (= 0.3 (shape-attr store file-id page-id shape-id :opacity))
"the peer's opacity, as on the server")
(t/is (= "B" (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
(with-persistence
(^:async fn [{:keys [file-id response requests store]}]
(await
(mock/with-mocks*
{mw/ask! (mock/stub (fn [_] (rx/empty)))}
(let [page-id (uuid/next)
shape-id (uuid/next)
commit0 (attr-commit file-id page-id shape-id :opacity 0.5 0.7)
commit1 (rename-commit file-id page-id shape-id "A" "B")
commit2 (rename-commit file-id page-id shape-id "B" "C")]
(ptk/emit! store #(-> %
(assoc :current-page-id page-id)
(assoc-in [:files file-id :data]
(-> (file-data page-id shape-id "A")
(assoc-in [:pages-index page-id :objects shape-id :opacity] 0.7)))))
(ptk/emit! store commit0)
(await (async/wait-for #(seq (get-in @store [:persistence :buffer])) "the edit is buffered"))
(ptk/emit! store (remote-commit file-id 1 [(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 (rejected-second commit2))
(await (async/wait-for #(= "B" (shape-name store file-id page-id shape-id))
"the rejected rename is undone"))
(t/is (= 0.7 (shape-attr store file-id page-id shape-id :opacity))
"the saved opacity edit stays"))))))))
(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- with-library
"Store state holding this file's data and a library file `lib-id` with
one rectangle each, named `name` and `lib-name`."
[file-id lib-id page-id shape-id name lshape-id lib-name]
#(-> %
(assoc :current-page-id page-id)
(assoc-in [:files file-id :data] (file-data page-id shape-id name))
(assoc-in [:files lib-id] {:id lib-id :revn 0 :data (file-data page-id lshape-id lib-name)})))
(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: 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
(with-persistence
(^:async fn [{:keys [file-id response requests store]}]
(await
(mock/with-mocks*
{mw/ask! (mock/stub (fn [_] (rx/empty)))}
(let [lib-id (uuid/next)
page-id (uuid/next)
shape-id (uuid/next)
lshape-id (uuid/next)
group (uuid/next)
commit1 (rename-commit file-id page-id shape-id "A" "B")
commit2 (grouped (rename-commit file-id page-id shape-id "B" "C") group)
before (rename-commit lib-id page-id lshape-id "X" "Y")
linked (grouped (rename-commit lib-id page-id lshape-id "Y" "Z") group)
after (rename-commit lib-id page-id lshape-id "Z" "W")]
(ptk/emit! store (with-library file-id lib-id page-id shape-id "C" 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 (rejected-second commit2))
(await (async/wait-for #(= 2 (count @requests)) "the library's save is sent next"))
(t/is (= "B" (shape-name store file-id page-id shape-id)))
(t/is (= "Y" (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 (= [[(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
(with-persistence
(^:async fn [{:keys [file-id response requests store]}]
(await
(mock/with-mocks*
{mw/ask! (mock/stub (fn [_] (rx/empty)))}
(let [lib-id (uuid/next)
page-id (uuid/next)
shape-id (uuid/next)
lshape-id (uuid/next)
group (uuid/next)
linked (grouped (rename-commit lib-id page-id lshape-id "X" "Y") group)
commit (grouped (rename-commit file-id page-id shape-id "A" "B") group)]
(ptk/emit! store (with-library file-id lib-id page-id shape-id "B" 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 (rejected-second 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 (= [[(rename page-id lshape-id "X")]] (sent-changes requests 2)))
(t/is (= "A" (shape-name store file-id page-id shape-id)))
(t/is (= "X" (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
(with-persistence
(^:async fn [{:keys [file-id response requests store]}]
(await
(mock/with-mocks*
{mw/ask! (mock/stub (fn [_] (rx/empty)))}
(let [lib-id (uuid/next)
page-id (uuid/next)
shape-id (uuid/next)
lshape-id (uuid/next)
group (uuid/next)
linked (grouped (rename-commit lib-id page-id lshape-id "X" "Y") group)
commit (grouped (rename-commit file-id page-id shape-id "A" "B") group)]
(ptk/emit! store (with-library file-id lib-id page-id shape-id "B" 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 (rejected-second 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
(with-persistence
(^:async fn [{:keys [file-id response requests store]}]
(await
(mock/with-mocks*
{mw/ask! (mock/stub (fn [_] (rx/empty)))}
(let [lib-id (uuid/next)
page-id (uuid/next)
shape-id (uuid/next)
lshape-id (uuid/next)
group (uuid/next)
linked (grouped (rename-commit lib-id page-id lshape-id "X" "Y") group)
commit (grouped (rename-commit file-id page-id shape-id "A" "B") group)]
(ptk/emit! store (with-library file-id lib-id page-id shape-id "B" 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 (rejected-second 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 (= [[(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" (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: a library edit waits to be saved when a peer's change to this
;; file arrives, and both files have a shape with the same id. Proves: a
;; remote commit goes under the pending commits of its own file only.
(t/deftest ^:async a-remote-commit-ignores-the-pending-commits-of-other-files
(await
(with-persistence
(^:async fn [{:keys [file-id store]}]
(await
(mock/with-mocks*
{mw/ask! (mock/stub (fn [_] (rx/empty)))}
(let [lib-id (uuid/next)
page-id (uuid/next)
shape-id (uuid/next)]
(ptk/emit! store (with-library file-id lib-id page-id shape-id "A" shape-id "Y"))
(ptk/emit! store (rename-commit lib-id page-id shape-id "X" "Y"))
(await (async/wait-for #(seq (get-in @store [:persistence :buffer]))
"the library edit is buffered"))
(ptk/emit! store (remote-commit file-id 1 [(set-attr page-id shape-id :opacity 0.3)]))
(await (async/settle))
(t/is (= 0.3 (shape-attr store file-id page-id shape-id :opacity)))
(t/is (= "A" (shape-name store file-id page-id shape-id))
"the library edit is not replayed on this file"))))))))
;; 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
(with-persistence
(^:async fn [{:keys [file-id response requests store]}]
(await
(mock/with-mocks*
{mw/ask! (mock/stub (fn [_] (rx/empty)))}
(let [page-id (uuid/next)
shape-id (uuid/next)
group (uuid/next)
commit1 (grouped (rename-commit file-id page-id shape-id "A" "B") group)
commit2 (grouped (attr-commit file-id page-id shape-id :opacity 1 0.5) group)]
(ptk/emit! store #(-> %
(assoc :current-page-id page-id)
(assoc-in [:files file-id :data]
(-> (file-data page-id shape-id "B")
(assoc-in [:pages-index page-id :objects shape-id :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 (rejected-second 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 (= [[(rename page-id shape-id "A")]] (sent-changes requests 1)))
(t/is (= "A" (shape-name store file-id page-id shape-id)))
(t/is (= 1 (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
(with-persistence
(^:async fn [{:keys [file-id response requests store]}]
(await
(mock/with-mocks*
{mw/ask! (mock/stub (fn [_] (rx/empty)))}
(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]
[(set-attr page-id id :opacity 0.5)
(set-attr page-id id :applied-tokens {:opacity "half"})]))
ids)
:undo-changes (into []
(mapcat (fn [id]
[(set-attr page-id id :opacity 1)
(set-attr page-id id :applied-tokens nil)]))
ids)}))
tx-id (js/Symbol)]
(ptk/emit! store #(-> %
(assoc :current-page-id page-id)
(assoc-in [:files file-id :data]
{: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])
third (:commit-id (nth groups 2))]
(t/is (= 3 (count groups)) "the save carries the three commits")
(rx/push! response {:revn 0
:rejected {:commit-ids [third]
:failed-commit-id third
:error {:type :validation :code :referential-integrity}}}))
(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 (shape-attr store file-id page-id id :opacity)))
(t/is (nil? (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")))))))))
;; 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
(with-persistence
(^:async fn [{:keys [file-id response requests store]}]
(await
(mock/with-mocks*
{mw/ask! (mock/stub (fn [_] (rx/empty)))}
(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 (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 text-id :page-id page-id
:parent-id uuid/zero :frame-id uuid/zero :obj text}]
:undo-changes [{:type :del-obj :id text-id :page-id page-id}]})]
(ptk/emit! store #(-> %
(assoc :current-page-id page-id)
(assoc-in [:files file-id :data]
(-> (file-data page-id shape-id "A")
(assoc-in [:pages-index page-id :objects text-id] text)
(update-in [:pages-index page-id :objects uuid/zero :shapes] conj text-id)))
(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 (rejected-second commit))
(await (async/wait-for #(nil? (get-in @store [:workspace-local :edition]))
"the rollback leaves edition mode"))
(t/is (nil? (get-in @store [:files file-id :data :pages-index page-id :objects text-id]))))))))))
(t/deftest forgetting-undo-groups-cuts-the-history-at-the-first-match
(let [[g1 g2 g3] (repeatedly 3 uuid/next)
state {:workspace-undo {:items [{:undo-group g1} {:undo-group g2} {:undo-group g3}]
:index 2
:transaction {:undo-group g3 :undo-changes [{:type :mod-page}]}}}
undo (:workspace-undo (ptk/update (dwu/forget-undo-groups #{g2 g3}) state))]
(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-clears-the-history-without-a-match
(let [state {:workspace-undo {:items [{:undo-group (uuid/next)}] :index 0}}
undo (:workspace-undo (ptk/update (dwu/forget-undo-groups #{(uuid/next)}) state))]
(t/is (empty? (:items undo)))
(t/is (nil? (:index undo)))))

View File

@ -642,3 +642,29 @@
result (dwt/merge-live-text-dimensions :auto-width selrect {:width 0 :height 0})]
(t/is (= 50 (:width result)))
(t/is (= 25 (:height result))))))
;; ---------------------------------------------------------------------------
;; same-styled-text?
;; ---------------------------------------------------------------------------
(defn- text-content
"Text content of one paragraph holding `runs`, each a map with `:text`
and its styles."
[& runs]
{:type "root"
:children [{:type "paragraph-set"
:children [{:type "paragraph"
:key (str (uuid/next))
:children (mapv #(assoc % :key (str (uuid/next))) runs)}]}]})
(t/deftest same-styled-text-ignores-node-keys-and-nil-styles
(t/is (dwt/same-styled-text? (text-content {:text "Hello" :font-weight "400"})
(text-content {:text "Hello" :font-weight "400" :font-style nil}))))
(t/deftest same-styled-text-sees-a-style-change-on-the-same-text
(t/is (not (dwt/same-styled-text? (text-content {:text "Hello" :font-weight "400"})
(text-content {:text "Hello" :font-weight "700"})))))
(t/deftest same-styled-text-sees-a-text-change
(t/is (not (dwt/same-styled-text? (text-content {:text "Hello" :font-weight "400"})
(text-content {:text "Hello!" :font-weight "400"})))))

View File

@ -5,7 +5,8 @@
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.data.workspace-undo-test
"Tests for the undo groups of commits made inside an undo transaction."
"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]
@ -104,10 +105,134 @@
(fn [page-id]
[(dwu/start-undo-transaction tx)
(rename-page page-id "A")
(dwu/forget-undo-groups #{(uuid/next)})
(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")))

View File

@ -0,0 +1,181 @@
;; 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.helpers.persistence
"Fixture and builders shared by the persistence tests: a store with
persistence initialized over a mocked transport, synthetic commits, and
one-page file data to roll back."
(:require
[app.common.types.shape :as cts]
[app.common.uuid :as uuid]
[app.main.data.changes :as dch]
[app.main.data.persistence :as dps]
[app.main.errors :as errors]
[app.main.repo :as rp]
[app.main.store :as st]
[app.main.worker :as mw]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.mock :as mock]
[potok.v2.core :as ptk]))
(defn local-commit
"Builds a synthetic local commit event for `file-id`: a page edit as redo,
no undo, from the `:local` source."
[file-id]
(ptk/data-event ::dch/commit
{:id (uuid/next)
:file-id file-id
:file-revn 0
:file-vern 0
:source :local
:features #{}
:redo-changes [{:type :mod-page :id (uuid/next) :name "Edited"}]
:undo-changes []}))
(defn with-persistence
"Async fixture for the persistence tests: mocks the transport, flash, the
worker and the leave-page task set (`:tasks`), and runs `f` with
persistence initialized.
Evaluates to a promise resolving once `f` settles and teardown completes;
`await` it from an `^:async` test.
By default the transport responds through the `:response` subject. Pass a
`respond` function (`(fn [cmd params] observable)`) to drive the transport
response directly instead. Delivery through the subject is asynchronous
(`observe-on :async`): await each effect — via `wait-for` — before
asserting it."
[f & [respond]]
(let [file-id (uuid/next)
response (rx/subject)
failures (atom [])
requests (atom [])
tasks (atom #{})
store (ptk/store {:state {:current-file-id file-id
:permissions {:can-edit true}
:files {file-id {:id file-id :revn 0}}}
:on-error #(t/is false (str %))})]
(mock/with-mocks*
{rp/cmd! (mock/stub (fn [cmd params]
(swap! requests conj [cmd params])
(if respond
(respond cmd params)
(->> response (rx/take 1) (rx/observe-on :async)))))
errors/flash (fn [& {:keys [cause]}]
(swap! failures conj cause))
;; Index updates of applied changes go to a worker the tests lack
mw/ask! (mock/stub (fn [_] (rx/empty)))
st/ongoing-tasks tasks}
(try
(ptk/emit! store (dps/initialize-persistence))
(await (f {:file-id file-id :response response :failures failures
:requests requests :store store :tasks tasks}))
(finally
(rx/dispose! store)
(rx/end! response))))))
(defn set-attr
"A change that sets `attr` of shape `shape-id` to `val`."
[page-id shape-id attr val]
{:type :mod-obj :id shape-id :page-id page-id
:operations [{:type :set :attr attr :val val}]})
(defn rename
"A change that renames shape `shape-id` to `name`."
[page-id shape-id name]
(set-attr page-id shape-id :name name))
(defn attr-commit
"A local commit of `file-id` that sets `attr` of the shape from `from` to
`to`, with its own undo group."
[file-id page-id shape-id attr from to]
(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 [(set-attr page-id shape-id attr to)]
:undo-changes [(set-attr page-id shape-id attr from)]}))
(defn rename-commit
"A local commit of `file-id` that renames the shape from `from` to `to`,
with its own undo group."
[file-id page-id shape-id from to]
(attr-commit file-id page-id shape-id :name from to))
(defn remote-commit
"A peer's commit of `changes` to `file-id` at revision `revn`."
[file-id revn changes]
(dch/commit {:file-id file-id
:file-revn revn
:file-vern 0
:source :remote
:save-undo? false
:redo-changes changes
:undo-changes []}))
(defn file-data
"File data with one page holding one rectangle named `name`, with `attrs`
on top of its defaults."
[page-id shape-id name & {:as attrs}]
{:pages [page-id]
:pages-index {page-id {:id page-id
:name "Page"
:objects {uuid/zero (cts/setup-shape {:id uuid/zero :type :frame :shapes [shape-id]})
shape-id (merge (cts/setup-shape {:id shape-id :type :rect :name name
:parent-id uuid/zero :frame-id uuid/zero})
attrs)}}}})
(defn add-shape
"`data` with `shape` added as the last child of the root of `page-id`."
[data page-id shape]
(-> data
(assoc-in [:pages-index page-id :objects (:id shape)] shape)
(update-in [:pages-index page-id :objects uuid/zero :shapes] conj (:id shape))))
(defn open-file
"A state update that opens `page-id` of `file-id` with `data` loaded."
[file-id page-id data]
#(-> %
(assoc :current-page-id page-id)
(assoc-in [:files file-id :data] data)))
(defn open-file-with-library
"`open-file` that also loads library `lib-id` with `lib-data`."
[file-id lib-id page-id data lib-data]
(comp #(assoc-in % [:files lib-id] {:id lib-id :revn 0 :data lib-data})
(open-file file-id page-id data)))
(defn shape-attr
[store file-id page-id shape-id attr]
(get-in @store [:files file-id :data :pages-index page-id :objects shape-id attr]))
(defn shape-name
[store file-id page-id shape-id]
(shape-attr store file-id page-id shape-id :name))
(defn undo-entry
"The undo history entry `commit` would add."
[commit]
(let [{:keys [undo-group undo-changes redo-changes]} @commit]
{:undo-group undo-group
:undo-groups #{undo-group}
:undo-changes undo-changes
:redo-changes redo-changes
:tags #{}}))
(defn rejection
"A save answer that rejects `commit-ids`, the first one failing
validation."
[& commit-ids]
{:revn 0
:rejected {:commit-ids (vec commit-ids)
:failed-commit-id (first commit-ids)
:error {:type :validation :code :referential-integrity}}})

View File

@ -507,6 +507,33 @@
(t/is (= 1 @calls) "the update function ran once for the committed shape")
(t/is (= 0.5 (.-opacity rect)) "the single computed result was committed")))
(t/deftest test-writing-to-a-removed-shape-throws
(let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1)
{:renderer :svg})
_ (set! st/state store)
_ (set! st/stream (ptk/input-stream store))
^js ctx (api/create-context zero-id)
^js rect (.createRectangle ctx)]
(set! (.-name rect) "Before")
(t/is (= "Before" (.-name rect)) "a write to an existing shape works")
(.remove rect)
(t/is (thrown-with-msg? js/Error #"Shape not found"
(set! (.-name rect) "After"))
"a write to a removed shape throws, even without throwValidationErrors")))
(t/deftest test-resizing-or-rotating-a-removed-shape-throws
(let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1)
{:renderer :svg})
_ (set! st/state store)
_ (set! st/stream (ptk/input-stream store))
^js ctx (api/create-context zero-id)
^js rect (.createRectangle ctx)]
(.remove rect)
(t/is (thrown-with-msg? js/Error #"Shape not found" (.resize rect 10 10))
"resizing a removed shape throws instead of doing nothing")
(t/is (thrown-with-msg? js/Error #"Shape not found" (.rotate rect 45))
"rotating a removed shape throws instead of doing nothing")))
(t/deftest test-wait-for-layout-update-no-pending
;; When nothing is pending the promise resolves immediately via the fast path
;; (the behavior-subject replays the empty map on subscribe).

View File

@ -19,6 +19,7 @@
[frontend-tests.data.nitrate-test]
[frontend-tests.data.persistence-commits-test]
[frontend-tests.data.persistence-retry-test]
[frontend-tests.data.persistence-rollback-test]
[frontend-tests.data.persistence-test]
[frontend-tests.data.profile-test]
[frontend-tests.data.repo-test]
@ -172,6 +173,7 @@
'frontend-tests.data.nitrate-test
'frontend-tests.data.persistence-commits-test
'frontend-tests.data.persistence-retry-test
'frontend-tests.data.persistence-rollback-test
'frontend-tests.data.persistence-test
'frontend-tests.data.profile-test
'frontend-tests.data.repo-test