Dr. Dominik Jain 2c15dcdb84
Add systematic component tests via a composable test model (#10529)
*  Add systematic component tests via a composable test model

Introduce a framework for systematically testing Penpot component behaviour
(synchronisation/propagation, swaps, variant switches, nesting), plus a first
suite of cases built on it.

A test is expressed as a COMPOSITION OF OPERATIONS over a "situation" (an
in-memory file value plus named role bindings). Operations are reified as data
and composed by two combinators — `in-sequence` (threads the situation) and
`one-of`/`optional` (alternatives, enumerated into concrete variants). So one
written case stands for a whole matrix of variants, and coverage grows by
composition rather than by copying tests. Operations drive the REAL production
change pipeline, and event-operations dispatch the REAL workspace events and
await settlement, so the production watcher's automatic propagation is what is
exercised — the tests reflect genuine app behaviour, not a reimplementation.

Structure (frontend/test/frontend_tests/composable_tests/):
  - core            — the domain-agnostic engine: situation, the operation and
                      enumeration protocols, the combinators, and the runners.
  - comp/nodes      — the component operations (create/instantiate/reset, nesting,
                      swap, the variant ops, child add/remove/move, change, undo,
                      library sync).
  - comp/setups     — component-shaped starting configurations.
  - interpreter     — runs a case against the real frontend store: sync-ops apply
                      directly, event-ops dispatch real events and await
                      settlement (absorbing sync-file's delayed status RPC, which
                      would otherwise leak an error into subsequent tests).
  - comp/sync-test  — the cases (B-F, H, I, K, L, M).

This is test-only code with a single consumer — the frontend test suite (the
layer that runs the real app) — so it lives entirely under the frontend test
tree as .cljs, not under app/common.

The framework and its cases are documented in the project memory
frontend/composable-component-tests, added alongside.

Co-authored-by: Claude <noreply@anthropic.com>

* 🐛 Guard WASM mock teardown against an empty snapshot

`teardown-wasm-mocks!` unconditionally restored from the `originals` atom.
When run without a matching setup (double teardown, or `with-wasm-mocks*`
misused around an async test body), the snapshot is empty and every WASM API
function was `set!` to nil — permanently, for the remainder of the test run.
Any later code calling one of them (e.g. a leaked debounced resize-wasm-text
event firing during a subsequent test namespace) then crashed with
"initialized? is not a function".

Make the restore a no-op when there is nothing to restore.

Co-authored-by: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-10 12:17:43 +02:00

1021 lines
52 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 INC Sucursal en España SL
(ns frontend-tests.composable-tests.comp.nodes
"Component-specific operation nodes for the test model.
These are the `IOperation` implementations whose subject is Penpot component
behaviour: they wrap component/shape operations and drive the real production
change pipeline. This namespace sits behind the `comp` boundary precisely
because it is about components; the generic engine in
`frontend-tests.composable-tests.core` has no domain terms. See
`mem:frontend/composable-component-tests`.
Nodes deliberately call the production change pipeline directly
(`cls/generate-update-shapes` + `thf/apply-changes`,
`cll/generate-sync-file-changes` + `thf/apply-changes`) in the same way the
existing `app.common.test-helpers.compositions` helpers do.
Each node, on apply, records a self-description into the situation's applied-log
so conditions (and later undo) can read back what it did, keeping the
operation the single source of truth for what should hold."
(:require
[app.common.data :as d]
[app.common.files.changes-builder :as pcb]
[app.common.files.helpers :as cfh]
[app.common.logic.libraries :as cll]
[app.common.logic.shapes :as cls]
[app.common.logic.variants :as clv]
[app.common.test-helpers.components :as thc]
[app.common.test-helpers.compositions :as tho]
[app.common.test-helpers.files :as thf]
[app.common.test-helpers.ids-map :as thi]
[app.common.test-helpers.shapes :as ths]
[app.common.types.container :as ctn]
[frontend-tests.composable-tests.core :as tm]))
;; ---------------------------------------------------------------------------
;; Properties — an OPEN, extensible vocabulary of "what about a shape can change"
;;
;; A property is named by a keyword (`:fills`, `:opacity`, … extensible to
;; `:text`). Two pure functions know how to WRITE a property onto a shape (the
;; edit) and how to READ its comparable value back (the check). `change-property`
;; (the edit operation) uses `set-property`; `has-property-of` (the inspection)
;; uses `read-property` — so the change and its check stay duals, and adding a new
;; property kind is one clause in each `case`. (`change-attr`/`has-attr?` remain as
;; aliases for the common `:fills`/`:opacity` use.)
;; ---------------------------------------------------------------------------
(defn set-property
"Write `property` = `value` onto `shape`, returning the updated shape. The edit
half of a property; extend the `case` to support more properties. Public so the
frontend interpreter applies the IDENTICAL edit the common node does."
[shape property value]
(case property
:fills (assoc shape :fills (ths/sample-fills-color :fill-color value))
:opacity (assoc shape :opacity value)
(throw (ex-info (str "set-property: unsupported property " (pr-str property)
" (supported: :fills, :opacity)")
{:property property}))))
(defn- read-property
"Read the comparable value of `property` from `shape`. The check half of a
property; extend the `case` to support more properties."
[shape property]
(case property
:fills (-> shape :fills first :fill-color)
:opacity (:opacity shape)
(throw (ex-info (str "read-property: unsupported property " (pr-str property)
" (supported: :fills, :opacity)")
{:property property}))))
(defrecord ChangeProperty [target property value]
tm/IOperation
(apply-to [this situation]
;; Goes through the production change path, exactly like tho/update-color,
;; then records under its own identity.
(let [the-file (tm/file situation)
;; `target` may be a ROLE (resolved via the situation, so the edit
;; FOLLOWS the role as state-building ops like make-nested-component re-point it)
;; or a label; strict-presence throws if neither resolves.
shape-id (tm/target-shape-id situation target)
page (thf/current-page the-file)
changes (cls/generate-update-shapes
(pcb/empty-changes nil (:id page))
#{shape-id}
(fn [shape] (set-property shape property value))
(:objects page)
{})
file' (thf/apply-changes the-file changes)]
(-> situation
(tm/with-file file')
(tm/record-application this {:target target :property property :value value})))))
(defn change-property
"Constructor for the property-change operation: change `property` of the shape
named by `target` (a role or a label) to `value`. Stamps a node identity so the
test can interrogate it (via `has-property-of`). The general form; `change-attr`
is the same thing for the common `:fills`/`:opacity` properties."
[target property value]
(tm/assign-id (->ChangeProperty target property value)))
(def ^{:doc "Alias of `change-property` (the historical name for :fills/:opacity
changes). `[target attr value]`."}
change-attr change-property)
(defprotocol IPropertyCheck
"Inspection capability of a property-changing operation: report on its OWN
effect, applied to a shape the caller supplies. Makes no judgment about which
shape — the test supplies that (e.g. `(role-shape s :copy-child-rect)`)."
(applied-property [node] "The property this node changed.")
(applied-value [node] "The value this node applied.")
(has-property-of [node shape]
"True if `shape` carries this node's changed property at this node's applied
value. The node reports against its own change; the test chooses the shape."))
(extend-type ChangeProperty
IPropertyCheck
(applied-property [node] (:property node))
(applied-value [node] (:value node))
(has-property-of [node shape]
(= (:value node) (read-property shape (:property node)))))
;; Aliases for the historical `has-attr?` name (used by the earlier cases).
(def ^{:doc "Alias of `has-property-of`."} has-attr? has-property-of)
(def ^{:doc "Alias of `applied-property`."} applied-attr applied-property)
;; ===========================================================================
;; Synchronisation-scenario building blocks
;;
;; A family of operations that build up a component/copy configuration STEP BY
;; STEP, tracking a small, explicit set of named things in the situation so that
;; later edits and assertions can refer to them regardless of how much nesting was
;; applied. The tracked things per lineage (see the "Component objects" block
;; below for the exact fields):
;;
;; - the REMOTE head/rect — the ORIGINAL instance and its rect (the fixed
;; deepest origin; never re-pointed by plain nesting),
;; - the MAIN head/rect — the CURRENT outer main and its rect (the shape an
;; edit-to-main targets; advances inward with each `make-nested-component`),
;; - the COPY head/rect — the copy produced by `instantiate-copy` and its rect
;; (the shape an edit-to-copy targets / assertions observe),
;; - the component id the next instantiate/nest uses, and the nesting count.
;;
;; "corresponds to" between layers is resolved by following the :shape-ref chain
;; (a copy shape refs the main shape it mirrors); within these single-file setups
;; the chain resolves in the local page objects, so a local walk suffices.
;; ===========================================================================
(defn- ref-chain-ids
"The set of ids on `shape`'s :shape-ref chain within `objects`, INCLUDING the
shape's own id and every shape its :shape-ref transitively points at. So a copy
shape's chain contains the near-main it mirrors, that near-main's near-main, and
so on — letting us recognise the descendant that is the image of a given origin
shape regardless of how many layers sit between."
[objects shape]
(loop [s shape, acc #{}]
(let [acc (conj acc (:id s))]
(if-let [ref (:shape-ref s)]
(recur (get objects ref) acc)
acc))))
(defn- descendant-corresponding-to
"The id of the shape in `head-id`'s subtree that is the propagated image of
`target-id` — i.e. whose :shape-ref chain PASSES THROUGH `target-id`. Throws if
not found or ambiguous — a structural invariant of these setups."
[objects head-id target-id]
(let [descendants (cfh/get-children-ids objects head-id)
matches (filter (fn [id]
(contains? (ref-chain-ids objects (get objects id)) target-id))
descendants)]
(case (count matches)
1 (first matches)
0 (throw (ex-info "descendant-corresponding-to: no descendant corresponds to target"
{:head head-id :target target-id}))
(throw (ex-info "descendant-corresponding-to: ambiguous correspondence"
{:head head-id :target target-id :matches matches})))))
(defn- self-or-descendant-corresponding-to
"Like `descendant-corresponding-to`, but considers `head-id` ITSELF as well as
its subtree. Needed for the nested HEAD: when the instance nested at a level is
directly the image of the origin instance (e.g. level 0, where the inner copy
head IS that image), the match is the head itself, not a descendant."
[objects head-id target-id]
(if (contains? (ref-chain-ids objects (get objects head-id)) target-id)
head-id
(descendant-corresponding-to objects head-id target-id)))
;; ===========================================================================
;; Component objects — the situation tracks NAMED component lineages
;;
;; The sync/swap scenario operations track one or more COMPONENT LINEAGES, each an
;; addressable OBJECT under `:vars :components`, keyed by a name (e.g. "main"). This
;; replaces the earlier flat, single-lineage roles (:main-child-rect, …): grouping
;; one lineage's fields into one object (a) lets several lineages coexist — required
;; for swap, whose target is a *different* component — and (b) makes each operation
;; "read object `name`, update its fields, write it back".
;;
;; A component object has:
;; :main-component-id - the component to instantiate next for this lineage
;; (advances to the new OUTER component on make-nested-component)
;; :remote-head/:remote-rect - the fixed deepest origin (NEVER re-pointed)
;; :main-head/:main-rect - the current main (advances inward on make-nested-component)
;; :nesting-count
;; :nesting-data - vector, one entry per nesting level i, each:
;; {:main-head <id of that level's outer main>
;; :nested-head <id of the subinstance head introduced at level i>
;; :nested-rect <id of that nested head's rect, AT CREATION>
;; :nested-head-parent <id of the shape containing the nested head>}
;; The PARENT is the swap-stable anchor: a swap replaces the head in place but
;; keeps its parent, so an assertion re-resolves parent -> current head -> rect.
;;
;; (Child shapes are addressed via the `*-rect` fields / `lineage-rect`; if other
;; child kinds are added later, they get parallel fields, not a restructure.)
;; ===========================================================================
(defn- get-component-obj
"The component object named `name` from the situation (nil if absent)."
[situation name]
(get (tm/get-var situation :components {}) name))
(defn- put-component-obj
"Store/replace the component object named `name`."
[situation name obj]
(tm/set-var situation :components
(assoc (tm/get-var situation :components {}) name obj)))
(defn- update-component-obj
"Apply `f` (obj -> obj) to the component object named `name`."
[situation name f]
(put-component-obj situation name (f (get-component-obj situation name))))
(defn lineage-component-id
"The component id the lineage `name` will instantiate next."
[situation name]
(:main-component-id (get-component-obj situation name)))
(defn lineage-rect
"The id of lineage `name`'s current main rect (the edit-to-main target)."
[situation name]
(:main-rect (get-component-obj situation name)))
(defn lineage-nesting
"The `nesting-data` entry for level `i` of lineage `name`."
[situation name i]
(get-in (get-component-obj situation name) [:nesting-data i]))
;; Target helpers — return a (situation -> shape-id) fn for `change-property`'s
;; target (resolved at apply-time against the lineage object, so it follows the
;; current ids). `applied`-querying `Test`s read the same fields via the accessors
;; below.
(defn remote-rect-of
"Target: lineage `name`'s fixed remote (deepest-origin) rect."
[name]
(fn [s] (:remote-rect (get-component-obj s name))))
(defn main-rect-of
"Target: lineage `name`'s current main rect."
[name]
(fn [s] (:main-rect (get-component-obj s name))))
(defn copy-rect-of
"Target: lineage `name`'s current copy rect (from the latest instantiate-copy)."
[name]
(fn [s] (:copy-rect (get-component-obj s name))))
(defn lineage-copy-rect
"The id of lineage `name`'s current copy rect (for assertions)."
[situation name]
(:copy-rect (get-component-obj situation name)))
(defn level-rect
"The id of the rect currently at lineage `name`'s nesting level `level`,
re-resolved from the swap-stable :nested-head-parent: parent -> current
subinstance head -> its rect descendant. Robust to a swap having replaced the
head (and the rect) in place."
[situation name level]
(let [objects (:objects (thf/current-page (tm/file situation)))
nd (lineage-nesting situation name level)
parent (:nested-head-parent nd)
head (->> (cfh/get-immediate-children objects parent)
(filter :component-id)
first
:id)
;; the rect is the (single) rect descendant under the head
rect (->> (cfh/get-children-ids objects head)
(map #(get objects %))
(filter #(= :rect (:type %)))
first
:id)]
rect))
(defn level-rect-of
"Target/accessor fn: the rect currently at lineage `name`'s nesting level
`level` (see `level-rect`)."
[name level]
(fn [s] (level-rect s name level)))
;; ---------------------------------------------------------------------------
;; create-component — the starting operation: a component with a rect child
;; ---------------------------------------------------------------------------
(defrecord CreateComponent [name color]
tm/IOperation
(apply-to [this situation]
;; fresh, name-scoped labels so several lineages don't clash
(let [head-label (keyword (str "sync-" name "-head"))
rect-label (keyword (str "sync-" name "-rect"))
comp-label (keyword (str "sync-" name "-component"))
file' (-> (tm/file situation)
(tho/add-simple-component comp-label head-label rect-label
:child-params
{:fills (ths/sample-fills-color
:fill-color color)}))
head-id (thi/id head-label)
rect-id (thi/id rect-label)
comp-id (thi/id comp-label)]
(-> situation
(tm/with-file file')
;; create the lineage object: remote == main at creation (no nesting yet)
(put-component-obj name
{:main-component-id comp-id
:remote-head head-id :remote-rect rect-id
:main-head head-id :main-rect rect-id
:nesting-count 0
:nesting-data []})
(tm/record-application this {:component name :id comp-id})))))
(defn create-component
"Starting operation: create a component lineage named `name` (root frame + one
rect child of fill `color`) and track it as a component OBJECT. Sets
remote==main head+rect, :main-component-id, nesting-count 0. Begin a scenario
with this; create more lineages (e.g. swap targets) with further calls."
[name color]
(tm/assign-id (->CreateComponent name color)))
;; ---------------------------------------------------------------------------
;; make-nested-component — wrap the tracked component in a NEW OUTER component
;;
;; ONE specific notion of nesting (there are several): a new ENCLOSING component
;; whose main CONTAINS a COPY of the lineage's current component (contain-outward),
;; and the OUTER component becomes the lineage's new :main-component-id. The inner
;; copy's rect that corresponds (via :shape-ref) to the lineage's :remote-rect
;; becomes the new :main-rect, so an edit-to-main now targets one level deeper
;; while :remote-rect stays the fixed origin. Iterable: apply twice for 2 layers.
;; This is the structural family of penpot#9304 (a subinstance head inside a copy;
;; that issue adds a variant inner component + an extra frame — separate axes).
;; ---------------------------------------------------------------------------
(defn- nest-in-new-outer-component
"Shared nesting mechanism (contain-outward): add a fresh outer board, run
`instantiate-inner-fn` to place THE INNER INSTANCE inside it, turn the board
into a NEW OUTER component, then advance lineage `name`'s object (main ->
deeper rect, append a `nesting-data` entry with the swap-stable
:nested-head-parent, bump nesting-count) and record `op`.
`instantiate-inner-fn` is `(file outer-frame-label inner-copy-label) -> file`:
it instantiates whatever is being nested (the lineage's own component, or a
chosen variant) into `outer-frame-label`, registering `inner-copy-label` as
the inner copy's root.
`seek-rect-id` / `seek-head-id` are the ORIGIN rect and the ORIGIN instance head
whose IMAGES inside the new inner copy become this level's nested rect and nested
head — found by following the shape-ref chain. For nesting the lineage's own
component these are the lineage's :remote-rect / :remote-head; for nesting a
variant member they are THAT MEMBER's rect / root (the inner copy refs the member,
not the lineage). `nested-head` is thus the DEEPEST instance (the image of the
original component's instance), per spec — at level 0 it is the inner copy itself;
at deeper levels it is the corresponding shape nested within. Everything else is
identical across nesting flavours, so a new flavour supplies only these three."
[situation name op seek-rect-id seek-head-id instantiate-inner-fn]
(let [the-file (tm/file situation)
obj (get-component-obj situation name)
level (:nesting-count obj)
;; level-scoped labels so repeated nestings of one lineage don't clash
outer-frame (keyword (str "sync-" name "-outer-" level))
inner-copy (keyword (str "sync-" name "-innercopy-" level))
outer-comp (keyword (str "sync-" name "-outercomp-" level))
file' (-> the-file
(tho/add-frame outer-frame {:name "OuterFrame"})
(instantiate-inner-fn outer-frame inner-copy)
(thc/make-component outer-comp outer-frame))
objects (:objects (thf/current-page file'))
inner-copy-id (thi/id inner-copy)
;; images, inside the new inner copy, of the origin rect and origin instance
new-main-rect (descendant-corresponding-to objects inner-copy-id seek-rect-id)
;; nested-head = the DEEPEST instance (image of the original component's
;; instance), per spec — the switchable subinstance. Its parent is the
;; swap-stable anchor for re-resolving it after a swap/switch replaces it.
nested-head (self-or-descendant-corresponding-to objects inner-copy-id seek-head-id)
nested-parent (:parent-id (get objects nested-head))
outer-id (thi/id outer-comp)
outer-head-id (thi/id outer-frame)]
(-> situation
(tm/with-file file')
(update-component-obj
name
(fn [o]
(-> o
(assoc :main-component-id outer-id
:main-head outer-head-id
:main-rect new-main-rect
:nesting-count (inc level))
(update :nesting-data conj
{:main-head outer-head-id
:nested-head nested-head
:nested-rect new-main-rect
:nested-head-parent nested-parent}))))
(tm/record-application op {:component name :level level :outer outer-id}))))
(defrecord MakeNestedComponent [name]
tm/IOperation
(apply-to [this situation]
;; nest a COPY of the lineage's own current component. Seek the FIXED deepest
;; origin (:remote-rect / :remote-head): every level's copy refs back through it,
;; so its image identifies this level's nested rect and deepest instance. (Variant
;; nesting re-points :remote-* to the variant member, keeping this uniform.)
(let [obj (get-component-obj situation name)]
(nest-in-new-outer-component
situation name this
(:remote-rect obj)
(:remote-head obj)
(fn [file outer-frame inner-copy]
(let [inner-label (thi/label (:main-component-id obj))]
(thc/instantiate-component file inner-label inner-copy
{:parent-label outer-frame})))))))
(defn make-nested-component
"Wrap lineage `name`'s component in a NEW OUTER component (containing a copy of
it) and make the OUTER component its next-to-instantiate; advance :main to the
deeper rect while :remote stays fixed; append a `nesting-data` entry (with the
swap-stable :nested-head-parent) and bump nesting-count. ONE notion of nesting
(contain-outward). Apply repeatedly to deepen."
[name]
(tm/assign-id (->MakeNestedComponent name)))
;; ---------------------------------------------------------------------------
;; instantiate-copy — instantiate the lineage's current component, track the copy
;; ---------------------------------------------------------------------------
(defrecord InstantiateCopy [name]
tm/IOperation
(apply-to [this situation]
(let [the-file (tm/file situation)
obj (get-component-obj situation name)
comp-id (:main-component-id obj)
comp-label (thi/label comp-id)
main-rect (:main-rect obj)
copy-head (keyword (str "sync-" name "-copyhead-"
(count (:copies obj))))
file' (-> the-file
(thc/instantiate-component comp-label copy-head))
objects (:objects (thf/current-page file'))
copy-id (thi/id copy-head)
;; the rect inside the copy corresponding to the current main rect
copy-rect (descendant-corresponding-to objects copy-id main-rect)]
(-> situation
(tm/with-file file')
(update-component-obj
name
(fn [o]
(-> o
(assoc :copy-head copy-id :copy-rect copy-rect)
(update :copies (fnil conj []) {:copy-head copy-id :copy-rect copy-rect}))))
(tm/record-application this {:component name :copy copy-id})))))
(defn instantiate-copy
"Instantiate lineage `name`'s current component; track the copy's head and the
rect corresponding to its current main rect on the lineage object (as
:copy-head/:copy-rect, and appended to :copies). The copy rect is the shape
edits-to-copy target and assertions observe."
[name]
(tm/assign-id (->InstantiateCopy name)))
;; ---------------------------------------------------------------------------
;; reset-copy-instance — reset overrides on the tracked copy instance
;; ---------------------------------------------------------------------------
(defrecord ResetCopyInstance [name]
tm/IOperation
(apply-to [this situation]
;; Production reset path (generate-reset-component), validation OFF — mirrors
;; SyncFromLibrary. We call the generator directly rather than via
;; tho/reset-overrides because that helper validates the file, which the
;; assembled-context frontend store does not always satisfy; and it stays a
;; SYNC-op (`apply-to` on the live store file) rather than an event-op because
;; the real reset event transitively reads browser globals, so it cannot run
;; headless.
(let [the-file (tm/file situation)
copy-id (:copy-head (get-component-obj situation name))
page (thf/current-page the-file)
container (ctn/make-container page :page)
file-id (:id the-file)
changes (-> (pcb/empty-changes)
(cll/generate-reset-component
the-file {file-id the-file} container copy-id))
file' (thf/apply-changes the-file changes :validate? false)]
(-> situation
(tm/with-file file')
(tm/record-application this {:component name :reset copy-id})))))
(defn reset-copy-instance
"Reset overrides on lineage `name`'s tracked copy instance (its :copy-head) —
discards the copy's own divergences so it reflects its main again."
[name]
(tm/assign-id (->ResetCopyInstance name)))
;; ---------------------------------------------------------------------------
;; swap-component — replace a nested subinstance head with an instance of a
;; different component (the general "Swap component" action), in place.
;;
;; Targets lineage `name`'s nesting level `level` (its `nesting-data[level]
;; .nested-head`) and swaps it for lineage `target`'s component
;; (`:main-component-id`). Drives the production `generate-component-swap`
;; (validation off). The `apply-to` below is the pure realisation; the frontend
;; interpreter instead dispatches the real `dwl/component-swap` event, so the
;; watcher auto-propagates the swap. keep-touched? defaults
;; false (the general swap discards overrides; variant-switch would pass true).
;;
;; A swap REPLACES the head in place (Penpot keeps the head's id but rewrites it to
;; reference the new component and stamps a :swap-slot touched group). The level's
;; :nested-head-parent is unchanged, so assertions re-resolve parent -> current
;; head -> rect.
;; ---------------------------------------------------------------------------
(defrecord SwapComponent [name level target keep-touched?]
tm/IOperation
(apply-to [this situation]
(let [the-file (tm/file situation)
page (thf/current-page the-file)
objects (:objects page)
nested-head (:nested-head (lineage-nesting situation name level))
shape (get objects nested-head)
target-id (lineage-component-id situation target)
libraries {(:id the-file) the-file}
orig-shapes (when keep-touched?
(cfh/get-children-with-self objects nested-head))
[new-shape _parents changes]
(cll/generate-component-swap (pcb/empty-changes)
objects shape (:data the-file) page libraries
target-id 0 nil {} (boolean keep-touched?))
[changes _] (if keep-touched?
(clv/generate-keep-touched changes new-shape shape orig-shapes
page libraries (:data the-file))
[changes nil])
file' (thf/apply-changes the-file changes :validate? false)]
(-> situation
;; record the swapped-in head id on the level (its parent is unchanged)
(tm/with-file file')
(update-component-obj
name
(fn [o] (assoc-in o [:nesting-data level :swapped-head] (:id new-shape))))
(tm/record-application this {:component name :level level
:target target :new-head (:id new-shape)})))))
(defn swap-component
"Swap lineage `name`'s nesting level `level` for lineage `target`'s component
(the general Swap-component action). `keep-touched?` (default false) preserves
the swapped instance's overrides (true = the variant-switch flavour)."
[name level target & {:keys [keep-touched?] :or {keep-touched? false}}]
(tm/assign-id (->SwapComponent name level target keep-touched?)))
;; ===========================================================================
;; VARIANT operations (case M) — the variant-switch flavour of the swap sweep.
;;
;; A VARIANT SET is N peer components grouped in a container, distinguished by a
;; selector PROPERTY (here a single property at pos 0). Instantiation selects a
;; member by its property VALUE; a later `switch-variant` re-selects within the
;; set. Under the hood a switch is a keep-touched swap whose target is resolved
;; by property value (see `app.main.data.workspace.variants/variant-switch`), so
;; it routes through the SAME `generate-component-swap` as `swap-component` and
;; the watcher auto-propagates it across nesting levels exactly like a swap.
;;
;; OP SPLIT: only the SWITCH is a real workspace event (`variant-switch` has no
;; pure generator to call directly), so `SwitchVariant` is an EVENT-op: its
;; `apply-to` throws, and the frontend interpreter realises it by dispatching the
;; event. The container build and the variant nesting are SYNC-ops (test-helper
;; assembly + the shared nesting helper), applied via `apply-to` against the live
;; store file; they record the variant-set in vars. Event-ops do NOT run
;; `apply-to` (only sync-ops do), but the switch needs no bookkeeping: the
;; asserter re-resolves heads via the swap-stable :nested-head-parent
;; (`nested-head-of`/`level-rect`), exactly as case L does for swaps.
;;
;; Member SELECTOR: `make-variant-container` assigns each member an EXPLICIT property
;; value we choose (the `value` in its [value color] specs). A member is addressed by
;; that value in `make-nested-component-with-variant` / `switch-variant`, resolved via
;; the recorded variant-set in vars.
;; ===========================================================================
(def ^:private variant-property-name
"The single selector property's name (these tests use one property)."
"Property 1")
(defn variant-set
"Read the variant-set record `set-name` from the situation's vars (written by
`make-variant-container`): {:variant-id, :container-label, :members [{:value
:component-label :component-id} ...]}."
[situation set-name]
(or (tm/get-var situation [::variant-set set-name])
(throw (ex-info "variant-set: no such set (was make-variant-container applied?)"
{:set set-name}))))
(defn variant-member
"The member record of set `set-name` whose selector value is `value` ({:value
:component-label :component-id :root-label :rect-label :rect-id}). Throws if no
unique member matches — a structural invariant."
[situation set-name value]
(let [members (:members (variant-set situation set-name))
matches (filter #(= value (:value %)) members)]
(case (count matches)
1 (first matches)
0 (throw (ex-info "variant-member: no member has that value"
{:set set-name :value value
:available (mapv :value members)}))
(throw (ex-info "variant-member: ambiguous value"
{:set set-name :value value :count (count matches)})))))
(defn variant-member-component-label
"The component label of set `set-name`'s member whose selector value is `value`."
[situation set-name value]
(:component-label (variant-member situation set-name value)))
(defn variant-member-component-id
"The component id of set `set-name`'s member whose selector value is `value`."
[situation set-name value]
(:component-id (variant-member situation set-name value)))
;; ---------------------------------------------------------------------------
;; make-variant-container — build a variant SET synchronously
;;
;; On our branch the only user-facing combine is the async `combine-as-variants`
;; (behind `penpot.createVariantFromComponents`), whose settlement + positional,
;; non-chosen property values make it a poor fit — and case M tests the SWITCH, not
;; the combine. So we assemble the container the way the variant test-helpers do
;; (`add-variant`): a container frame (:is-variant-container) whose children are the
;; member component roots, each carrying the shared :variant-id and an EXPLICIT
;; selector value we choose. This is synchronous (a sync-op, like create-component),
;; produces exactly the structure `variant-switch`/`find-variant-components`
;; consume, and lets us address members by a value of our choosing.
;;
;; `members` is a vector of [value color] — value = the selector value used by
;; `make-nested-component-with-variant` / `switch-variant`; color = the member's
;; rect fill (how the asserter tells members apart). The set is recorded in vars
;; under `name` (variant-id + per-member component labels/ids).
;; ---------------------------------------------------------------------------
(defrecord MakeVariantContainer [name members]
tm/IOperation
(apply-to [this situation]
;; Build the set exactly as the variant test-helpers' `add-variant` do (the
;; proven idiom): a container frame (:is-variant-container), then each member's
;; ROOT as a child carrying :variant-id + :variant-name, made into a component,
;; then `update-component` stamps :variant-id + :variant-properties ON THE
;; COMPONENT. (Routing this through add-simple-component's positional component
;; params silently dropped :variant-id — hence the explicit two-step here.)
(let [container-label (keyword (str "sync-" name "-vcontainer"))
;; create the container frame FIRST, THEN read its id (thi/id is a lookup
;; that only resolves once the shape exists — reading it earlier yields nil).
base-file (-> (tm/file situation)
(ths/add-sample-shape container-label
:type :frame
:is-variant-container true))
variant-id (thi/id container-label)
[file' member-recs]
(reduce
(fn [[file recs] [idx [value color]]]
(let [comp-label (keyword (str "sync-" name "-v" idx "-component"))
root-label (keyword (str "sync-" name "-v" idx "-root"))
rect-label (keyword (str "sync-" name "-v" idx "-rect"))
file2 (-> file
;; member root as a child of the container, with variant id/name
;; (mirrors add-variant's `add-sample-shape :type :frame ...`)
(ths/add-sample-shape root-label
:type :frame
:parent-label container-label
:variant-id variant-id
:variant-name value)
(ths/add-sample-shape rect-label
:parent-label root-label
:fills (ths/sample-fills-color :fill-color color))
;; make it a component, then stamp variant metadata ON THE COMPONENT
(thc/make-component comp-label root-label)
(thc/update-component
comp-label
{:variant-id variant-id
:variant-properties [{:name variant-property-name :value value}]}))]
[file2 (conj recs {:value value
:component-label comp-label
:component-id (thi/id comp-label)
:root-label root-label
:rect-label rect-label
:rect-id (thi/id rect-label)})]))
[base-file []]
(map-indexed vector members))]
(-> situation
(tm/with-file file')
(tm/set-var [::variant-set name]
{:variant-id variant-id
:container-label container-label
:members member-recs})
(tm/record-application this {:variant-set name :variant-id variant-id
:count (count members)})))))
(defn make-variant-container
"Build a variant SET named `name` synchronously (test-helper assembly, like the
variant test-helpers' `add-variant`): a container holding N member components,
each with the shared variant-id and an EXPLICIT selector value. `members` is a
vector of [value color]. Members are addressed by `value` in
`make-nested-component-with-variant` / `switch-variant`; `color` distinguishes
them for the asserter."
[name members]
(tm/assign-id (->MakeVariantContainer name members)))
;; ---------------------------------------------------------------------------
;; make-nested-component-with-variant — nest a chosen VARIANT, deeply
;;
;; Same contain-outward nesting as `make-nested-component` (shares
;; `nest-in-new-outer-component`), except the inner instance is a SPECIFIC variant
;; member (selected by property `value`) rather than the lineage's own component.
;; So every nesting level introduces a variant subinstance head — the switch
;; target. Sync-op (test-helper instantiation), like `make-nested-component`.
;; ---------------------------------------------------------------------------
(defrecord MakeNestedComponentWithVariant [name set-name value]
tm/IOperation
(apply-to [this situation]
(let [member (variant-member situation set-name value)
root-id (thi/id (:root-label member))
rect-id (:rect-id member)]
(-> situation
(nest-in-new-outer-component
name this
rect-id ; origin rect = the variant member's own rect
root-id ; origin head = the variant member's own root
(fn [file outer-frame inner-copy]
(thc/instantiate-component file (:component-label member) inner-copy
{:parent-label outer-frame})))
;; nesting a variant makes the MEMBER the new deepest origin: re-point the
;; lineage's fixed origin so subsequent plain make-nested-component descends to the
;; variant's image (its nested-head) at every level.
(update-component-obj name #(assoc % :remote-head root-id :remote-rect rect-id))))))
(defn make-nested-component-with-variant
"Nest (contain-outward, like `make-nested-component`) a SPECIFIC variant member of set
`set-name` — the member whose selector value is `value` — under lineage `name`,
advancing `name`'s nesting. Every level so nested gets a variant subinstance
head that `switch-variant` can later re-select. Apply repeatedly to deepen."
[name set-name value]
(tm/assign-id (->MakeNestedComponentWithVariant name set-name value)))
;; ---------------------------------------------------------------------------
;; switch-variant — switch a variant copy head to a sibling member
;;
;; The variant-switch action: switch the variant copy head bound to `target` to the
;; sibling member whose selector property (pos 0) has `value`. FRONTEND-only:
;; dispatches the production `variants-switch`, which DISCOVERS the sibling by value
;; within the variant container and routes through `component-swap` with
;; keep-touched? true — so the watcher auto-propagates it across nesting levels
;; exactly like case L's swap. A switch REPLACES the head in place; the head's
;; :parent is unchanged, so assertions re-resolve parent -> current head -> rect.
;;
;; `target` is the head to switch, resolved by the standard `target-shape-id` (role
;; | label | (situation -> id) fn, e.g. `nested-head-of` for the stored nested head
;; at a level — exactly the head `swap-component` targets). So the op knows NOTHING
;; about nesting.
;; ---------------------------------------------------------------------------
(defrecord SwitchVariant [target value]
tm/IOperation
(apply-to [_ _]
(throw (ex-info (str "switch-variant is an EVENT-op with no pure `apply-to` realisation: "
"it is the real `variants-switch` workspace event (which discovers the "
"sibling via the variant container), dispatched by the frontend "
"interpreter. See the VARIANT operations note above.")
{:type ::switch-variant-is-event-op}))))
(defn switch-variant
"Switch the variant copy head bound to `target` to the sibling member whose
selector property (pos 0) has value `value`, via the real `variants-switch`
machinery (which discovers the sibling within the container). Propagates across
nesting exactly like case L's swap. `target` is resolved like any operation target
(role | label | (situation -> id) fn, e.g. `nested-head-of`), so the op is
structure-agnostic. FRONTEND-only."
[target value]
(tm/assign-id (->SwitchVariant target value)))
;; nested-head-of — a (situation -> id) target fn for the nested head at a level,
;; supplied to `switch-variant` so the operation needs no nesting knowledge.
(defn nested-head-of
"Target fn: the subinstance head introduced at lineage `name`'s nesting level `i`
— the `:nested-head` stored in nesting-data for that level (exactly the head
`swap-component` targets). This is the variant instance to switch. For
`switch-variant`'s `target`."
[name i]
(fn [s] (:nested-head (lineage-nesting s name i))))
;; ---------------------------------------------------------------------------
;; sync-from-library — pull a linked library's changes into the consuming file
;;
;; LOCALITY axis (case H): the main lives in a SEPARATE library file and the copy
;; in the consuming (current) file. Propagation here crosses a FILE boundary and
;; is NOT the in-file component watcher — on the real frontend it is the library
;; UPDATE action (the user accepts "library updated"), realised as
;; `sync-file file-id library-id`. This node models that action.
;;
;; The situation carries the consuming file as its primary `:file` and the library
;; as an AUXILIARY file (see `tm/with-aux-files`). The pure `apply-to` realisation
;; below builds the libraries map from BOTH and runs `generate-sync-file-changes`
;; for the whole library (asset-id nil), mirroring `test-sync-when-changing-
;; lower-remote`. The frontend interpreter instead dispatches the real
;; `sync-file` event (see its `op->events`).
;; ---------------------------------------------------------------------------
(defrecord SyncFromLibrary []
tm/IOperation
(apply-to [this situation]
(let [the-file (tm/file situation)
file-id (:id the-file)
aux (tm/aux-files situation)
library-id (first (keys aux))
libraries (assoc aux file-id the-file)
changes (-> (pcb/empty-changes)
(cll/generate-sync-file-changes
nil
:components
file-id
nil ; whole library (no single asset-id)
library-id
libraries
file-id))
file' (thf/apply-changes the-file changes :validate? false)]
(-> situation
(tm/with-file file')
(tm/record-application this {:library-id library-id})))))
(defn sync-from-library
"Constructor for the cross-file library-sync node (case H). Takes no parameters;
the situation supplies the consuming file (primary) and the library (auxiliary)."
[]
(tm/assign-id (->SyncFromLibrary)))
;; ---------------------------------------------------------------------------
;; undo — reverse the immediately preceding operation(s)
;;
;; Undo is "just another operation node": the APP owns reversal; this node does
;; not snapshot-and-compare. It is an EVENT-op: the frontend interpreter
;; dispatches the real `dwu/undo` event, which pops the workspace undo stack
;; (maintained automatically by the prior operations' commits) and applies the
;; inverse changes. "Test undo everywhere" = append this node after any case; the
;; post-undo assertion is an ordinary condition about the resulting state.
;;
;; A pure `apply-to` realisation is NOT built: it would require every node to
;; stash its produced change value (the engine `:undo-changes`) into its record
;; so this node could apply the inverse — a retrofit across all nodes that no
;; case needs. It therefore throws loudly rather than pretending.
;; ---------------------------------------------------------------------------
(defrecord Undo []
tm/IOperation
(apply-to [_ _]
(throw (ex-info (str "Undo is an EVENT-op with no pure `apply-to` realisation (change "
"values are not stashed per-node). It is realised by the frontend "
"interpreter via the real undo event.")
{:type ::undo-is-event-op}))))
(defn undo
"Constructor for the undo node (case I). Takes no parameters; on the frontend it
dispatches the real undo, reversing the immediately preceding operation(s)."
[]
(tm/assign-id (->Undo)))
;; ---------------------------------------------------------------------------
;; add-child — add a new shape into a (main) component, structurally
;;
;; This is STRUCTURAL modification (changes the tree shape), as opposed to
;; change-attr (attribute modification). It mirrors the established
;; comp-sync-test "add shape" pattern: create a free shape on the page, then
;; relocate it INTO the target parent via the production `generate-relocate`
;; path. Propagation then materializes a corresponding child in copies.
;;
;; `parent` is the binding name of the shape to add the new child under (e.g.
;; :main-root). `new-label` is the label assigned to the created shape, so a
;; condition can find the corresponding copy child by what was recorded.
;; ---------------------------------------------------------------------------
(defrecord AddChild [parent new-label shape-params]
tm/IOperation
(apply-to [this situation]
(let [the-file (tm/file situation)
;; strict-presence on the parent (it must exist before we add under it)
parent-id (tm/resolve-shape-id situation parent)
;; 1) create the free shape on the page (registers `new-label`)
file-1 (ths/add-sample-shape the-file new-label (or shape-params {}))
page (thf/current-page file-1)
new-id (thi/id new-label)
;; 2) relocate it into the parent through the production change path
changes (cls/generate-relocate
(-> (pcb/empty-changes nil)
(pcb/with-page-id (:id page))
(pcb/with-objects (:objects page)))
parent-id ; parent-id
0 ; to-index
#{new-id}) ; ids to move
file' (thf/apply-changes file-1 changes)]
(-> situation
(tm/with-file file')
(tm/record-application this {:parent parent :new-label new-label})))))
(defn add-child
"Constructor for the structural add-child node: create a new shape (labeled
`new-label`) and relocate it under the shape bound to `parent`. Stamps a node
identity so the test can interrogate it."
[parent new-label & {:as shape-params}]
(tm/assign-id (->AddChild parent new-label shape-params)))
;; ---------------------------------------------------------------------------
;; remove-child — delete a shape from a (main) component, structurally
;;
;; Subtractive structural modification (twin of add-child). Mirrors the
;; established comp-sync-test "delete shape" pattern: `generate-delete-shapes`
;; on the named shape in the main. Propagation then removes the corresponding
;; child from clean copies. (Domain note from comp-sync-test: deleting from a
;; COPY would only hide; here we delete from the MAIN and propagate the removal.)
;;
;; `target` is the binding name (label) of the shape to remove from the main.
;; ---------------------------------------------------------------------------
(defrecord RemoveChild [target]
tm/IOperation
(apply-to [this situation]
(let [the-file (tm/file situation)
;; strict-presence: the target must exist before removal
shape-id (tm/resolve-shape-id situation target)
page (thf/current-page the-file)
[_all-parents changes]
(cls/generate-delete-shapes (pcb/empty-changes)
the-file
page
(:objects page)
#{shape-id}
{:components-v2 true})
file' (thf/apply-changes the-file changes)]
(-> situation
(tm/with-file file')
(tm/record-application this {:target target})))))
(defn remove-child
"Constructor for the structural remove-child node: delete the shape bound to
`target` from the main. Stamps a node identity."
[target]
(tm/assign-id (->RemoveChild target)))
;; ---------------------------------------------------------------------------
;; move-child — reorder an existing shape within a (main) component
;;
;; Structural modification where nothing is created or destroyed; only ORDER
;; changes, and that order should propagate to clean copies while :shape-ref
;; identity stays decoupled from position. Mirrors the established comp-sync-test
;; "move shape" pattern: `generate-relocate` of an EXISTING child to a new index
;; under the same parent.
;;
;; `target` is the binding name (label) of the existing main child to move;
;; `to-index` is its destination index under `parent`.
;; ---------------------------------------------------------------------------
(defrecord MoveChild [target parent to-index]
tm/IOperation
(apply-to [this situation]
(let [the-file (tm/file situation)
shape-id (tm/resolve-shape-id situation target)
parent-id (tm/resolve-shape-id situation parent)
page (thf/current-page the-file)
changes (cls/generate-relocate
(-> (pcb/empty-changes nil)
(pcb/with-page-id (:id page))
(pcb/with-objects (:objects page)))
parent-id
to-index
#{shape-id})
file' (thf/apply-changes the-file changes)]
(-> situation
(tm/with-file file')
(tm/record-application this {:target target :parent parent :to-index to-index})))))
(defn move-child
"Constructor for the structural move-child node: relocate the existing shape
bound to `target` to `to-index` under the shape bound to `parent`. Stamps a
node identity."
[target parent to-index]
(tm/assign-id (->MoveChild target parent to-index)))
(defprotocol IStructuralCheck
"Inspection capability of a structural node: retrieve the shapes involved in its
own effect, so the test can state ref-integrity / placement assertions itself."
(added-shape [node situation]
"The shape this node added to the main (live, from the current file).")
(materialized-instance-child [node situation container-shape]
"The child of `container-shape` that is the materialized instance of this
node's added shape (i.e. whose :shape-ref points at it), or nil. Retrieval
only — the test asserts the relationship."))
(extend-type AddChild
IStructuralCheck
(added-shape [node situation]
(ths/get-shape (tm/file situation) (:new-label node)))
(materialized-instance-child [node situation container-shape]
(let [the-file (tm/file situation)
added-id (thi/id (:new-label node))]
(->> (:shapes container-shape)
(map #(ths/get-shape-by-id the-file %))
(d/seek #(= added-id (:shape-ref %)))))))