alonso.torres 820cbfee38 ✨ Check undo and redo of every step in the composable cases
The composable cases tested undo only where a case asked for it, so a
change generator with a wrong inverse, or a step that splits into
several undo entries, went unnoticed.

Both runners now run an undo/redo round trip on every variant, unless
the case turns it off with a reason. After each user step that adds an
undo group, undo must give back the file before the step and redo the
file after it; a user step that adds no group must change nothing. At
the end of the variant, undoing back to the last assembly step and
redoing everything must give back both states. Files compare through
a shared normalizer that drops :modified-at and, on request,
:position-data, and keeps :touched and :remote-synced.

- The pure runner gets a redo stack and a Redo node; a user operation
  that pushes a group empties it. Case P undoes and redoes a main edit
  in both suites.
- The frontend interpreter runs the round trip through the real undo
  and redo events, and checks that each step moves the workspace undo
  index by one entry. Each variant starts with an empty undo stack.

A case can mark a failure that a known bug causes. The mark pins, per
phase, the signature of the failure (the shape, page and component
attributes or the undo index that differ). It covers no other failure,
and it fails the case once the bug stops happening.

The check found two bugs, both pinned:
- F41: when sync moves a copy child to an earlier position, its undo
  change puts the child back at the same index, so undoing a reorder
  in a main leaves the copies reordered (case F).
- F43: the reflow after a swap or variant switch loses the step's undo
  group, so its commits land in undo entries of their own and one undo
  leaves the step half reverted (cases L and M).
2026-10-28 15:46:54 +01:00

607 lines
29 KiB
Clojure

;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.composable-tests.interpreter
"FRONTEND interpreter + test-facing `check` for the composable test model.
The generic engine lives in `app.common.test-helpers.composable.core` and the
component instruments (operation records, setups, role accessors, inspection
methods) behind the `comp` boundary. What lives HERE is:
1. `op->events` — the event realisation of each EVENT-op: the real
workspace event(s) it dispatches (it depends on
`app.main.data.workspace.*`).
2. an ASYNC interpreter that drives a sequence of operations through the REAL
app: it installs the situation's file into the global store, starts the
real component-change watcher, then for each operation dispatches its
event(s) and AWAITS all pending work it caused (`await-step`: the
watcher's sync wait and layout/text reflow, so the app's AUTOMATIC
propagation — not a manual sync — is what runs), re-reading the file
from the store after each. A step that does not settle in time fails.
3. `check` — the test-facing entry: takes {:setup :operation} + an OPTIONAL
asserter; enumerates the operation and runs each variant; assertions may
be inline (`Test` ops) and/or in the asserter.
IN-FILE PROPAGATION IS AUTOMATIC: there is no propagate operation. The watcher
(`dwl/watch-component-changes`), running on the global store, detects
main-instance changes in the CURRENT file and syncs that file's copies on its
own. That automatic behaviour is precisely what the cases exercise. The one
deliberate exception is CROSS-FILE propagation (case H): when the main lives
in a linked LIBRARY and the copy in the consuming file, the watcher does NOT
cross the file boundary — the real app propagates via the library-UPDATE
action, so the `SyncFromLibrary` op explicitly dispatches `sync-file` (this is
faithful: it is exactly the user action, not a test shortcut). See
`mem:frontend/composable-component-tests`.
NOTE on the store: this uses the GLOBAL `st/state` store (not the isolated
`setup-store`), because the watcher reads `refs/workspace-data` which derives
from `st/state`. State is re-installed per variant for isolation."
(:require
[app.common.test-helpers.composable.comp.nodes :as n]
[app.common.test-helpers.composable.comp.undo-check :as uc]
[app.common.test-helpers.composable.core :as tm]
[app.common.test-helpers.files :as cthf]
[app.common.test-helpers.ids-map :as cthi]
[app.common.test-helpers.shapes :as cths]
[app.main.data.workspace :as dw]
[app.main.data.workspace.libraries :as dwl]
[app.main.data.workspace.reflow :as wrf]
[app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.thumbnails :as dwth]
[app.main.data.workspace.transforms :as dwt]
[app.main.data.workspace.undo :as dwu]
[app.main.data.workspace.variants :as dwv]
[app.main.features :as features]
[app.main.repo :as rp]
[app.main.store :as st]
[app.plugins.reflow :as pwrf]
[beicon.v2.core :as rx]
[cljs.test :as t]
[frontend-tests.helpers.mock :as mock]
[potok.v2.core :as ptk]))
;; --------------------------------------------------------------------------
;; (0) Thumbnail rendering — disabled in this headless suite
;;
;; The propagation watcher we start (`dwl/watch-component-changes`) ALSO schedules
;; THUMBNAIL renders on every component change (its `component-changed` event has a
;; thumbnail branch). Thumbnail rendering reaches `app.util.dom/get-css-variable`,
;; which calls `window.getComputedStyle` — and there is no `window` in the headless
;; runner, so a (queued, async) render fires LATER and crashes the run. Thumbnails
;; are pure UI side-effect, irrelevant to what we test, so we stub the public seam
;; `dwth/update-thumbnail` to a no-op event for the duration of OUR tests (installed
;; / restored by a `:each` fixture in the case namespace — the same `set!`-and-
;; restore pattern as `frontend_tests.helpers.wasm`, whose `:after` correctly runs
;; only after each async test's `done`). Scope is thus our suite only.
;; --------------------------------------------------------------------------
(defonce ^:private original-update-thumbnail (atom nil))
(defn- noop-thumbnail-event []
(ptk/reify ::noop-thumbnail
ptk/WatchEvent
(watch [_ _ _] (rx/empty))))
(defn install-thumbnail-noop!
"Replace `dwth/update-thumbnail` with a no-op event so no thumbnail rendering
runs (it would reach `window`, absent headless). Restore with
`restore-thumbnail!`. Idempotent."
[]
(when (nil? @original-update-thumbnail)
(reset! original-update-thumbnail dwth/update-thumbnail)
(set! dwth/update-thumbnail (fn [& _] (noop-thumbnail-event)))))
(defn restore-thumbnail!
"Restore the real `dwth/update-thumbnail` saved by `install-thumbnail-noop!`."
[]
(when-let [orig @original-update-thumbnail]
(set! dwth/update-thumbnail orig)
(reset! original-update-thumbnail nil)))
;; --------------------------------------------------------------------------
;; (1) Frontend realisation of operations: op record -> workspace event(s)
;;
;; The operation's "what" (its update-fn / target) is shared in common; this maps
;; it to the "how" on the frontend — the real event a user interaction dispatches.
;; Returns a vector of events to emit for that operation.
;; --------------------------------------------------------------------------
;; The edit a ChangeProperty performs is the SHARED `n/set-property`, so the
;; frontend applies the identical change the common node does (only the wrapping
;; differs: a real workspace event here vs. apply-changes there).
(defn- add-child->shape
"Build the shape value AddChild introduces, parented under the target parent in
the current store page (mirrors the common add-child's sample shape). Returns
the shape with parent/frame set to the target parent."
[parent-label new-label shape-params]
(let [parent-id (cthi/id parent-label)
shape (cths/sample-shape new-label (or shape-params {}))]
(assoc shape
:parent-id parent-id
:frame-id parent-id)))
(defn op->events
"Map a comp operation record to the real workspace event(s) it dispatches.
`op` is one of the comp node records (app.common.test-helpers.composable.comp.nodes);
`situation` provides cross-file context (e.g. the library id) for ops that need
it.
Each operation is realised with the SAME production change builder the common
node uses, but wrapped in the real workspace event (so it commits to the store
and the watcher auto-propagates):
ChangeProperty -> update-shapes (generate-update-shapes; n/set-property)
MoveChild -> relocate-shapes (generate-relocate, existing shape)
AddChild -> add-shape (create a shape under the parent)
RemoveChild -> delete-shapes (generate-delete-shapes)
SyncFromLibrary -> sync-file (the cross-file library-update action, H)
Undo -> dwu/undo (reverse the previous operation(s), I)
Redo -> dwu/redo (apply the undone operation(s) again)
(In-file propagation is automatic — the watcher — so no propagate op exists.)"
[op situation]
(cond
(instance? n/ChangeProperty op)
(let [{:keys [target property value]} op]
;; `target` may be a ROLE (resolved via the situation, so the edit follows
;; the role as make-nested-component re-points it) or a label — same dual resolution
;; as the common ChangeProperty. The edit uses the SHARED `n/set-property`.
[(dwsh/update-shapes #{(tm/target-shape-id situation target)}
(fn [shape] (n/set-property shape property value)))])
(instance? n/Rotate op)
;; The real sidebar rotation event, absolute mode: rotates the shape (and its
;; whole subtree) around its center. Its apply-modifiers step runs the
;; placement-vs-override classification for component copies (check-delta),
;; which is part of what a case using `rotate` exercises.
(let [{:keys [target angle]} op]
[(dwt/increase-rotation [(tm/target-shape-id situation target)] angle)])
(instance? n/ChangeHeight op)
;; The real sidebar dimension event.
(let [{:keys [target value]} op]
[(dwt/update-dimensions [(tm/target-shape-id situation target)] :height value)])
(instance? n/SwapComponent op)
;; Swap lineage `name`'s nesting level `level` for lineage `target`'s component
;; via the REAL swap event (dwl/component-swap), so it commits through the
;; normal path and the watcher AUTOMATICALLY propagates the swap to copies
;; (incl. the deeper nesting levels). This is the behaviour under test.
(let [{:keys [name level target keep-touched?]} op
file-id (:id (tm/file situation))
nested (n/lineage-nesting situation name level)
shape (tm/shape-by-id situation (:nested-head nested))
target-id (n/lineage-component-id situation target)]
[(dwl/component-swap shape file-id target-id (boolean keep-touched?))])
(instance? n/SwitchVariant op)
;; The variant-switch action: switch the resolved variant copy head to the
;; sibling member whose selector property (pos 0) has `value`, via the REAL
;; `variants-switch` event (which discovers the sibling in the variant container
;; and routes through component-swap keep-touched? true, so the watcher
;; auto-propagates the switch across nesting levels exactly like a swap). `target`
;; uses the standard resolution (role | label | (situation -> id) fn), so the op
;; is structure-blind.
(let [{:keys [target value]} op
head-id (tm/target-shape-id situation target)
shape (tm/shape-by-id situation head-id)]
[(dwv/variants-switch {:shapes [shape] :pos 0 :val value})])
(instance? n/MoveChild op)
(let [{:keys [target parent to-index]} op]
[(dwsh/relocate-shapes #{(cthi/id target)} (cthi/id parent) to-index)])
(instance? n/RemoveChild op)
(let [{:keys [target]} op]
[(dwsh/delete-shapes #{(cthi/id target)})])
(instance? n/AddChild op)
(let [{:keys [parent new-label shape-params]} op]
[(dwsh/add-shape (add-child->shape parent new-label shape-params)
{:no-select? true})])
(instance? n/SyncFromLibrary op)
;; The cross-file library-update action: sync the current (consuming) file
;; from its linked library — exactly what the "library updated" dialog does.
(let [file-id (:current-file-id @st/state)
library-id (first (keys (tm/aux-files situation)))]
[(dwl/sync-file file-id library-id)])
(instance? n/Undo op)
;; Reverse the previous operation(s) via the real undo event. The workspace
;; undo stack was maintained automatically by the prior ops' commits.
[dwu/undo]
(instance? n/Redo op)
;; Apply again the latest undone operation(s) via the real redo event.
[dwu/redo]
:else
(throw (ex-info (str "op->events: no frontend realisation for " (pr-str (type op)))
{:op op}))))
;; --------------------------------------------------------------------------
;; (2) Async interpreter
;; --------------------------------------------------------------------------
;; Longest a step may take to settle; a step that takes longer fails.
(def ^:private step-timeout-ms 2000)
(defn- install-situation-event
"An UpdateEvent installing the situation's files into the (global) store: the
primary file as the current/workspace file, plus any AUXILIARY files (e.g. a
linked library, for the cross-file case H) alongside in `:files`, each tagged
`:library-of` the current file so the library-sync machinery treats them as
linked libraries. The undo stack starts empty, so an undo never reaches the
previous variant's entries."
[situation]
(let [file (tm/file situation)
aux (tm/aux-files situation)]
(ptk/reify ::install-file-event
ptk/UpdateEvent
(update [_ state]
(assoc state
:current-file-id (:id file)
:current-page-id (cthf/current-page-id file)
:permissions {:can-edit true}
:workspace-undo {}
:files (into {(:id file) file}
(map (fn [[lib-id lib]] [lib-id (assoc lib :library-of (:id file))]))
aux))))))
(defn current-file
"Read the live workspace file out of the global store."
[]
(let [st @st/state]
(get-in st [:files (:current-file-id st)])))
(defn await-step
"Emit `events` into the global store and return a promise that resolves with
`:settled` once every pending work has drained: component sync (the
watcher's wait for each commit and the sync it starts) and layout and text
reflow, as tracked by `app.main.data.workspace.reflow`. It waits the way the
plugin `waitForLayoutUpdate` does. When `timeout-ms` (default
`step-timeout-ms`) passes first, it resolves with `{:timeout pending}`, the
work still pending then. Relies on every producer opening its pending work
synchronously while the events are processed."
([events] (await-step events step-timeout-ms))
([events timeout-ms]
(doseq [e events] (st/emit! e))
(-> (pwrf/wait-for-layout-update nil timeout-ms)
(.then (constantly :settled))
(.catch (fn [_] {:timeout (wrf/pending)})))))
(defn- describe-pending
"The pending work map with the ids replaced by their test labels, if any."
[pending]
(update-keys pending #(or (cthi/label %) %)))
(defn- record-op
"Record an operation's application onto the situation (after its effect settled),
re-reading the file from the store. For a RecordedChoice (one-of), record the
choice under the one-of identity (so the asserter's `get-choice` works) and
then the chosen op's application, in the order `RecordedChoice`'s own
`apply-to` uses."
[situation op]
(let [situation (tm/with-file situation (current-file))
[situation op] (if (tm/recorded-choice? op)
[(tm/record-choice situation op) (tm/choice-of op)]
[situation op])]
(tm/record-application situation op (dissoc (into {} op) ::tm/id))))
(defn- op-events
"The workspace events to dispatch for an op unit (a plain op or a one-of's
RecordedChoice — for the latter, the chosen op's events). `situation` provides
cross-file context to ops that need it."
[op situation]
(op->events (if (tm/recorded-choice? op) (tm/choice-of op) op) situation))
(defn- install-file-event
"An UpdateEvent that replaces the current file in the (global) store with `file`
(keeping aux files and everything else). Used to write back the result of a
FILE-TRANSFORMING op (see `file-op?`)."
[file]
(ptk/reify ::install-file
ptk/UpdateEvent
(update [_ state]
(-> state
(assoc :current-file-id (:id file)
:current-page-id (cthf/current-page-id file))
(assoc-in [:files (:id file)] file)))))
(defn- unwrap
"The concrete operation of an op unit (the chosen op of a RecordedChoice)."
[op]
(if (tm/recorded-choice? op) (tm/choice-of op) op))
;; --------------------------------------------------------------------------
;; Undo/redo round trip (see `app.common.test-helpers.composable.comp.undo-check`)
;;
;; Run on every user step that goes through a real event: undo must give back
;; the file before the step and redo the file after it, each moving the
;; workspace undo index back to where it was, so a step adds exactly one undo
;; entry (or group). File installs (assembly steps and the reset, see
;; `sync-op?`) add no entry, so they are the baseline the end-of-variant round
;; trip undoes back to.
;; --------------------------------------------------------------------------
(defn- undo-index
"The workspace undo index: the entry the next undo reverts, -1 when none."
[]
(let [{:keys [index items]} (:workspace-undo @st/state)]
(or index (dec (count items)))))
(defn- snapshot
"The normalized live file the round trip compares. WASM regenerates
`:position-data` by measuring, so it is left out there."
[]
(uc/snapshot (current-file)
{:position-data? (not (features/active-feature? @st/state "render-wasm/v1"))}))
(defn- mark-baseline
[situation]
(uc/mark-baseline situation (undo-index) (snapshot)))
(defn- settle!
"Emit `event` and wait for it to settle. Resolves with nil, or with the
reason it did not settle."
[event]
(.then (await-step [event])
(fn [result]
(when-not (= :settled result)
(str "did not settle; pending: " (pr-str (describe-pending (:timeout result))))))))
(defn- round-trip-move
"Emit `event` (undo or redo) and check that the file equals the snapshot
`expected` and the undo index `expected-index`, recording failures of
`phase` at `step`. Resolves with `[situation settled?]`."
[situation event step phase expected expected-index]
(.then (settle! event)
(fn [error]
(if error
[(uc/add-error situation step phase error) false]
[(-> situation
(uc/compare-states step phase expected (snapshot))
(uc/compare-index step (keyword (str (name phase) "-index"))
expected-index (undo-index)))
true]))))
(defn- check-step
"The round trip of user step `op`, given the snapshot `before` and the undo
index `index-before` taken before it. Resolves with the situation."
[situation op before index-before]
(let [step (uc/step situation op)
after (snapshot)
index-after (undo-index)]
(if (= index-before index-after)
(js/Promise.resolve (uc/compare-states situation step :no-undo-entry before after))
(.then (round-trip-move situation dwu/undo step :undo before index-before)
(fn [[situation settled?]]
(if settled?
(.then (round-trip-move situation dwu/redo step :redo after index-after)
first)
situation))))))
(defn- repeat-move
"Emit `event` (undo or redo) until `done?` holds, at most `limit` times.
Resolves with `[situation times]`, `situation` holding a failure of
`phase` when a move did not settle."
[situation event phase done? limit]
(letfn [(step [times]
(if (or (done?) (>= times limit))
(js/Promise.resolve [situation times])
(.then (settle! event)
(fn [error]
(if error
[(uc/add-error situation nil phase error) times]
(step (inc times)))))))]
(step 0)))
(defn- check-variant
"The end-of-variant round trip: undo back to the baseline, then redo as many
times. Resolves with the situation."
[situation]
(let [{:keys [depth] :as baseline} (uc/baseline situation)
final (snapshot)
final-index (undo-index)
limit (- final-index depth)]
(if (pos? limit)
(.then (repeat-move situation dwu/undo :variant-undo #(<= (undo-index) depth) limit)
(fn [[situation times]]
(let [situation (uc/compare-states situation nil :variant-undo
(:snapshot baseline) (snapshot))]
(.then (repeat-move situation dwu/redo :variant-redo
#(>= (undo-index) final-index) times)
(fn [[situation _]]
(uc/compare-states situation nil :variant-redo final (snapshot)))))))
(js/Promise.resolve situation))))
(defn- sync-op?
"Whether `op` is a SYNCHRONOUS, `apply-to`-based operation rather than one that
dispatches a workspace event and needs settling. Two kinds:
- FILE-TRANSFORMING: the `:assembly` nodes (see `n/IComponentOperation`) and
`skip`, which arrange the CONFIGURATION (create, nest, instantiate,
re-point roles) — applied by running `apply-to` against the live store
file and writing the result back. The property under test is still
exercised by the SUBSEQUENT real-event ops.
- `Test`: an inline assertion checkpoint — its `apply-to` runs the assertion
against the current situation and returns it unchanged.
Both are handled by `run-sync-op` (no async settle — any store write is a
synchronous UpdateEvent)."
[op]
(let [op (unwrap op)]
(or (instance? tm/Skip op)
(instance? tm/Test op)
(= :assembly (n/op-kind op))
;; A user operation, but the real reset event transitively reads browser
;; globals (CSS vars), so it cannot run headless; the shared apply-to runs
;; the production reset generator with validation off.
(instance? n/ResetCopyInstance op))))
(defn- run-sync-op
"Apply a synchronous (`sync-op?`) operation: run its shared `apply-to` against a
situation whose `:file` is the live store file, write the resulting file back
into the store, and return the updated situation (with any re-pointed roles).
For `Test` this runs the inline assertion (against the live store state) and the
file is unchanged; for `make-nested-component`/`skip` it writes back the transformed file.
Synchronous."
[situation op]
(let [situation (tm/with-file situation (current-file))
situation (tm/apply-to op situation)]
(st/emit! (install-file-event (tm/file situation)))
(cond-> situation
(and (::undo-check? situation)
(not (instance? tm/Test (unwrap op))))
(mark-baseline))))
(defn- run-ops
"Async fold over `ops` (concrete operation units, in order — plain ops and/or
one-of RecordedChoice wrappers). Threads the situation. A SYNCHRONOUS op
(`sync-op?` — file-transforming or an inline `Test`) is applied synchronously
via `apply-to`; every other op dispatches its real workspace event(s) and
awaits all pending work (`await-step`). The file is re-read from the store
after each. Calls `k` with the final situation, or with nil when a step did
not settle in time; that step fails the test, naming the work still
pending."
[situation ops k]
(if (empty? ops)
(k situation)
(let [op (first ops)
kind (let [op (unwrap op)]
(when (satisfies? n/IComponentOperation op)
(n/op-kind op)))
check-step? (and (::undo-check? situation)
(contains? #{:user :frontend-only} kind))
before (when check-step? (snapshot))
index-before (undo-index)]
(if (sync-op? op)
(run-ops (run-sync-op situation op) (rest ops) k)
(.then (await-step (op-events op situation))
(fn [result]
(if (= :settled result)
(let [situation (record-op situation op)]
(cond
check-step?
(.then (check-step situation (unwrap op) before index-before)
#(run-ops % (rest ops) k))
;; never undo past the state a case's own undo went back to
(and (::undo-check? situation)
(= :undo kind)
(< (undo-index) (:depth (uc/baseline situation))))
(run-ops (mark-baseline situation) (rest ops) k)
:else
(run-ops situation (rest ops) k)))
(do
(t/is false (str "Step did not settle in " step-timeout-ms "ms: "
(pr-str (type (unwrap op)))
"; pending: " (pr-str (describe-pending (:timeout result)))))
(k nil)))))))))
(defonce ^:private original-store
;; Captured at namespace-LOAD time — i.e. before any test in the run executes.
;; Several test namespaces (the plugins suite) `set!` st/state / st/stream to
;; isolated stores and never restore them. That silently kills this harness:
;; the refs in `app.main.refs` (through which `watch-component-changes`
;; observes commits) are okulary lenses bound to THIS atom instance at load
;; time, so once the var points elsewhere, our events commit to a store the
;; watcher does not see and propagation dies with no error. Re-installing the
;; original per variant makes the harness immune to run order.
{:state st/state :stream st/stream})
(defn- restore-global-store!
"Point st/state / st/stream back at the load-time originals (see
`original-store`)."
[]
(set! st/state (:state original-store))
(set! st/stream (:stream original-store)))
(defn install!
"Install `situation` in the global store and start the watchers the workspace
starts, replacing the previous variant's. Restores the global store first (a
preceding namespace may have swapped it) and forgets any pending work left
over, so a step that timed out earlier cannot hold up this variant's steps.
Stopping the previous layout watcher clears it too, but on the first install
of a run there is none. Returns the situation."
[situation]
(restore-global-store!)
(wrf/reset-pending!)
(st/emit! (install-situation-event situation)
(dw/initialize-edit-watchers)
(dwl/watch-library-changes))
situation)
(defn- run-variant
"Set up one variant on the global store and run its ops. `setup` returns a
situation (file + roles). With `undo-check?`, runs the undo/redo round
trip of each user step and of the variant. Calls `k` with the final
situation, or nil when a step did not settle."
[setup ops undo-check? k]
;; fresh label space per variant (mirrors the pure `tm/run-all`)
(cthi/reset-idmap!)
(let [situation (-> (setup)
(assoc ::undo-check? undo-check?)
(uc/with-runner :frontend)
(install!))]
(run-ops (cond-> situation undo-check? (mark-baseline))
ops
(fn [situation]
(if (and situation undo-check?)
(.then (check-variant situation) k)
(k situation))))))
;; --------------------------------------------------------------------------
;; (3) Test-facing check
;; --------------------------------------------------------------------------
(defn check
"Frontend `check`: run `case-map` ({:setup :operation}) through the REAL app and,
if `asserter` is given, apply it (a situation -> any fn performing assertions)
to the resulting situation of EACH enumerated variant. Async — `done` is the
cljs.test async callback and MUST be called when finished.
Each variant also runs the undo/redo round trip, asserted after the
asserter (see `app.common.test-helpers.composable.comp.undo-check`; the
case map's `:undo-check` turns it off or marks known failures).
Assertions may be INLINE (via `Test` operations in the `:operation` sequence,
firing as the op runs) and/or via the trailing `asserter`; `asserter` is
optional (omit when all assertions are inline). The asserter closes over node
references the test holds (e.g. `has-property-of` on a change node). In-file
propagation is AUTOMATIC (the watcher) — no propagate op is added.
Mocks are installed for the duration of the check: `rp/cmd!` returns
success (recording calls) and `rx/timer` fires instantly, so the
`SyncFromLibrary` op's delayed RPC does not produce network errors.
Arities: `(check done case-map)`, which takes the asserter from the case
map's `:asserter`, or `(check done case-map asserter)`."
([done case-map] (check done case-map (:asserter case-map)))
([done {:keys [setup operation] :as case-map} asserter]
(mock/with-mocks
{rp/cmd! mock/rpc-cmd-mock
rx/timer mock/timer-mock}
(fn [inner-done]
(let [variants (tm/enumerate operation)]
(letfn [(run-next [vs]
(if (empty? vs)
(inner-done)
(run-variant
setup
;; a variant is a composed operation; flatten to its ordered leaf
;; ops. `enumerate` already removed all one-of choices, so the
;; variant is a Sequence (or a single op).
(tm/sequence-ops (first vs))
(uc/enabled? case-map)
(fn [situation]
(when situation
(t/testing (str "operations:\n " (tm/describe-applied situation))
(when asserter
(asserter situation))
(uc/check! situation case-map)))
(run-next (rest vs))))))]
(run-next variants))))
done)))