mirror of
https://github.com/penpot/penpot.git
synced 2026-07-24 15:08:24 +00:00
* ✨ 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>
204 lines
9.9 KiB
Clojure
204 lines
9.9 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.setups
|
|
"Component-specific setups for the test model: named functions that build an
|
|
in-memory file value for a particular component configuration. They are the
|
|
subject-specific counterpart to the generic engine; a 'simple component with
|
|
a copy' is an entirely component-shaped configuration, so it lives behind the
|
|
`comp` boundary. See `mem:frontend/composable-component-tests`.
|
|
|
|
Setups are kept as plain functions (not data): structure resists
|
|
declarativization and forcing it here has the worst cost/benefit. Each returns
|
|
a *situation* (file + ROLE bindings — meaningful named objects of the
|
|
configuration, e.g. :main-instance, :copy-instance), which the test retrieves
|
|
via the accessors below and asserts on directly.
|
|
|
|
For these one-child-per-instance configurations the meaningful objects are the
|
|
child shapes that participate in propagation, so :main-instance is the main's
|
|
child and :copy-instance is the copy's child (this makes a test read like
|
|
`(has-attr? change-attr (copy-instance situation))`)."
|
|
(:require
|
|
[app.common.files.changes-builder :as pcb]
|
|
[app.common.logic.shapes :as cls]
|
|
[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]
|
|
[frontend-tests.composable-tests.core :as tm]))
|
|
|
|
(defn simple-component-with-copy
|
|
"A situation with a simple component (root + one child) and one clean copy.
|
|
Shape labels: :main-root :main-child :copy-root :copy-child. Roles:
|
|
:main-instance -> the main child, :copy-instance -> the copy child."
|
|
[]
|
|
(let [file (-> (thf/sample-file :file1)
|
|
(tho/add-simple-component-with-copy
|
|
:component1 :main-root :main-child :copy-root
|
|
:copy-root-params {:children-labels [:copy-child]}))]
|
|
(tm/make-situation file {:main-instance :main-child
|
|
:copy-instance :copy-child
|
|
:main-root :main-root
|
|
:copy-root :copy-root})))
|
|
|
|
|
|
(defn empty-situation
|
|
"A situation with an empty file and NO roles — the starting point for scenarios
|
|
that build their own configuration via operations (e.g. `create-component`,
|
|
`make-nested-component`, `instantiate-copy`). Use as the `:setup` when the first
|
|
operation creates the component."
|
|
[]
|
|
(tm/make-situation (thf/sample-file :file1)))
|
|
|
|
(defn simple-component-with-labeled-copy
|
|
"Like `simple-component-with-copy`, but the main child starts with a known fill
|
|
(so 'value stayed' is distinguishable from coincidence). Same labels and roles."
|
|
[]
|
|
(let [file (-> (thf/sample-file :file1)
|
|
(tho/add-simple-component-with-copy
|
|
:component1 :main-root :main-child :copy-root
|
|
:main-child-params {:fills (ths/sample-fills-color :fill-color "#abcdef")}
|
|
:copy-root-params {:children-labels [:copy-child]}))]
|
|
(tm/make-situation file {:main-instance :main-child
|
|
:copy-instance :copy-child
|
|
:main-root :main-root
|
|
:copy-root :copy-root})))
|
|
|
|
(defn main-instance
|
|
"The main child shape of the configuration (role :main-instance), live."
|
|
[situation]
|
|
(tm/role-shape situation :main-instance))
|
|
|
|
(defn copy-instance
|
|
"The copy child shape of the configuration (role :copy-instance), live."
|
|
[situation]
|
|
(tm/role-shape situation :copy-instance))
|
|
|
|
(defn main-root
|
|
"The main component root shape (role :main-root), live."
|
|
[situation]
|
|
(tm/role-shape situation :main-root))
|
|
|
|
(defn copy-root
|
|
"The copy root shape (role :copy-root), live."
|
|
[situation]
|
|
(tm/role-shape situation :copy-root))
|
|
|
|
|
|
(defn component-with-many-children
|
|
"A situation with a component whose main has three children and one clean copy.
|
|
Shape labels: :main-root, :main-child1/2/3, :copy-root, :copy-child1/2/3.
|
|
Roles: :main-root, :copy-root, and :copy-child-1/2/3 -> the copy's children
|
|
(so a test can assert their post-operation order in the copy root)."
|
|
[]
|
|
(let [file (-> (thf/sample-file :file1)
|
|
(tho/add-component-with-many-children-and-copy
|
|
:component1
|
|
:main-root
|
|
[:main-child1 :main-child2 :main-child3]
|
|
:copy-root
|
|
:copy-root-params {:children-labels [:copy-child1 :copy-child2 :copy-child3]}))]
|
|
(tm/make-situation file {:main-root :main-root
|
|
:copy-root :copy-root
|
|
:copy-child-1 :copy-child1
|
|
:copy-child-2 :copy-child2
|
|
:copy-child-3 :copy-child3})))
|
|
|
|
(defn copy-child
|
|
"The copy child shape for 1-based index `n` (role :copy-child-n), live. Lets a
|
|
test retrieve specific copy children to assert their order/identity."
|
|
[situation n]
|
|
(tm/role-shape situation (keyword (str "copy-child-" n))))
|
|
|
|
|
|
(defn nested-component-with-copy
|
|
"A situation with a NESTED component configuration (depth axis): component2's
|
|
main contains a nested instance of component1 (`:main2-nested-head`); a clean
|
|
copy of component2 exists, so the copy contains a copy of that nested instance
|
|
whose child (`:copy2-child`) reaches the main through a :shape-ref CHAIN.
|
|
|
|
This is the depth-1 analogue of the simple-with-copy setup: editing
|
|
`:main2-nested-head` (the nested instance inside component2's main) and
|
|
propagating component2 must reach `:copy2-child` — the same attribute-
|
|
propagation property as the flat case, one level deeper. (Editing the
|
|
INNERMOST :main1-child and propagating only :component1 would instead be a
|
|
CHAINED propagation across two component levels — a different property; not
|
|
this setup.)
|
|
|
|
Shape labels from the helper: :main1-root/:main1-child, :main2-root,
|
|
:main2-nested-head, :copy2-root, and :copy2-child (the deep copy child, labeled
|
|
via the helper's children-labels). Roles: :main-instance -> :main2-nested-head,
|
|
:copy-instance -> :copy2-child, plus :main2-root / :copy-root for structure."
|
|
[]
|
|
(let [file (-> (thf/sample-file :file1)
|
|
(tho/add-nested-component-with-copy
|
|
:component1 :main1-root :main1-child
|
|
:component2 :main2-root :main2-nested-head
|
|
:copy2-root
|
|
:copy2-root-params {:children-labels [:copy2-child]}))]
|
|
(tm/make-situation file {:main-instance :main2-nested-head
|
|
:copy-instance :copy2-child
|
|
:main2-root :main2-root
|
|
:copy-root :copy2-root})))
|
|
|
|
|
|
(defn- change-main-child-fill
|
|
"Apply `fill-color` to the library's :main-child via the production change path
|
|
(mirrors how a real edit to the main would have changed the library), returning
|
|
the updated library file value. Used to make the library main DIVERGE from the
|
|
already-instantiated copy, so a subsequent cross-file sync has something to pull."
|
|
[library fill-color]
|
|
(let [page (thf/current-page library)
|
|
main-id (thi/id :main-child)
|
|
changes (cls/generate-update-shapes
|
|
(pcb/empty-changes nil (:id page))
|
|
#{main-id}
|
|
(fn [shape] (assoc shape :fills (ths/sample-fills-color :fill-color fill-color)))
|
|
(:objects page)
|
|
{})]
|
|
(thf/apply-changes library changes)))
|
|
|
|
(defn cross-file-component-with-copy
|
|
"LOCALITY axis (case H): a component whose main lives in a SEPARATE, shared
|
|
LIBRARY file, and a clean copy that lives in a CONSUMING file. The library main
|
|
has since DIVERGED from the copy (its :main-child now carries `new-fill`, while
|
|
the copy still carries the original `#abcdef`), modelling 'the library was
|
|
changed elsewhere'. Propagating that change to the copy is therefore a CROSS-
|
|
FILE sync (the library-update flow), not the in-file watcher.
|
|
|
|
The returned situation's PRIMARY file is the CONSUMING file (the current file,
|
|
where the copy lives and which the copy roles resolve against); the library
|
|
travels as an AUXILIARY file (see `tm/with-aux-files`) so an interpreter can
|
|
install it alongside (e.g. into the frontend store's `:files`) and run the
|
|
cross-file sync. Roles: :copy-instance -> the copy child, :copy-root -> the copy
|
|
root (both in the consuming file). `new-fill` should match the value the test
|
|
uses for its expected-value descriptor.
|
|
|
|
Labels: in the LIBRARY — :component1, :main-root, :main-child; in the CONSUMING
|
|
file — :copy-root, :copy-child."
|
|
[new-fill]
|
|
(let [library (-> (thf/sample-file :library :is-shared true)
|
|
(tho/add-simple-component
|
|
:component1 :main-root :main-child
|
|
:child-params {:fills (ths/sample-fills-color :fill-color "#abcdef")}))
|
|
;; instantiate the copy in the consuming file FIRST (copy gets the old fill)
|
|
file (-> (thf/sample-file :file)
|
|
(thc/instantiate-component :component1 :copy-root
|
|
:library library
|
|
:children-labels [:copy-child]))
|
|
;; THEN diverge the library main (copy is now stale)
|
|
library' (change-main-child-fill library new-fill)]
|
|
(-> (tm/make-situation file {:copy-instance :copy-child
|
|
:copy-root :copy-root})
|
|
(tm/with-aux-files {(:id library') library'}))))
|
|
|
|
(defn library-id
|
|
"The id of the library file in a cross-file situation (the single auxiliary
|
|
file). Used by the cross-file sync operation."
|
|
[situation]
|
|
(-> situation tm/aux-files keys first))
|