🚧 WIP Step 2, common scene code, validation

This commit is contained in:
Miguel de Benito Delgado 2026-09-20 16:47:47 +02:00
parent 50c72a1ad9
commit 01f6eb2a7a
5 changed files with 519 additions and 3 deletions

View File

@ -36,7 +36,7 @@
"^(?:backend|frontend|exporter|common)/deps.edn"
"^(?:backend|frontend|exporter|common)/scripts/"
"^(?:backend|frontend|exporter|common)/dev/"
"^(?:backend|frontend|exporter|common)/test/(?!benches/)"]
"^(?:backend|frontend|exporter|common)/test/(?!benches/|frontend_tests/benches/)"]
:linter-name true}

View File

@ -37,9 +37,9 @@
"fmt:clj": "cljfmt fix --parallel=true src/ test/",
"fmt:js": "prettier -c src/**/*.stories.jsx -c playwright/**/*.js -c scripts/**/*.js -c text-editor/**/*.js -w",
"fmt:scss": "prettier -c resources/styles -c src/**/*.scss -w",
"lint:clj": "clj-kondo --fail-level error --config-dir ../.clj-kondo --lint ../common/src src/ test/benches/",
"lint:clj": "clj-kondo --fail-level error --config-dir ../.clj-kondo --lint ../common/src src/ test/benches/ test/frontend_tests/benches/",
"lint:js": "exit 0",
"lint:renderer-benchmarks": "clj-kondo --config-dir ../.clj-kondo --lint test/benches/",
"lint:renderer-benchmarks": "clj-kondo --config-dir ../.clj-kondo --lint test/benches/ test/frontend_tests/benches/",
"lint:scss": "pnpm exec stylelint '{src,resources}/**/*.scss'",
"build:test": "pnpm run build:wasm && clojure -M:dev:shadow-cljs compile test",
"test": "pnpm run build:test && node target/tests/test.js",

View File

@ -0,0 +1,198 @@
;; 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 benches.render-wasm.scenes.common
"Utilities shared by generated and saved-page fixtures.
A fixture instance is
`{:objects {uuid shape ...} :refs {label uuid ...}}`.
- `:objects` is a Penpot page object map.
- `:refs` is an instance-local lookup from custom labels to object uuids.
A fixture instance is just static data (a 'snapshot'). It is independent of
camera, operations, WASM buffers and renderer calls, and it never mutates after
upload.
Validation uses penpot's facilities, so that benchmark fixtures follow editor
semantics. We do check reachability from the root here because neither the
editor validator nor file loading guarantees it.
Authoring and scoped construction are ticket17; containers ticket18/19;
case declarations ticket20; saved-page loading ticket21.
Which shape types may own children is container semantics owned by
ticket18/19; this contract only checks hierarchy consistency."
(:require
[app.common.files.helpers :as cfh]
[app.common.files.validate :as cfv]
[app.common.geom.shapes.tree-seq :as gts]
[app.common.schema :as sm]
[app.common.types.page :as ctp]
[app.common.uuid :as uuid]))
(def schema:instance
"Envelope schema for a fixture snapshot."
[:map {:title "RendererBenchmarkFixture"}
[:objects ctp/schema:objects]
[:refs [:map-of :any ::sm/uuid]]])
(defn- fail!
[hint data]
(throw (ex-info (str "Invalid fixture instance: " hint)
(merge {:type ::invalid-fixture
:hint hint}
data))))
(defn- check-schema!
[instance]
(when-not (sm/validate schema:instance instance)
(fail! "instance does not match the fixture schema"
{::sm/explain (sm/explain schema:instance instance)})))
(defn- check-root!
[instance]
(let [root (get-in instance [:objects uuid/zero])]
(when-not (and (some? root) (cfh/root? root))
(fail! "root object must be a frame at uuid/zero"
{:id uuid/zero}))
(when-not (= uuid/zero (:parent-id root))
(fail! "root object :parent-id must be uuid/zero"
{:id uuid/zero
:parent-id (:parent-id root)}))
(when-not (= uuid/zero (:frame-id root))
(fail! "root object :frame-id must be uuid/zero"
{:id uuid/zero
:frame-id (:frame-id root)}))))
(defn- check-object-ids!
[objects]
(doseq [[id shape] objects]
(when-not (= id (:id shape))
(fail! "object key does not match the shape :id"
{:key id
:shape-id (:id shape)}))))
(defn- check-child-links!
"Guard the reachability walk against malformed child links.
`:shapes` must be a vector when present, and every listed child that
exists must point back at the listing shape. This keeps each child with a
single parent, so the descendant walk visits a forest instead of fanning
out over shared children or looping on a cycle. Production validation
repeats these checks for reachable shapes; this one only protects our own
traversal order."
[objects]
(doseq [[id shape] objects
:let [children (:shapes shape)]]
(when (some? children)
(when-not (vector? children)
(fail! "shape :shapes must be a vector"
{:id id}))
(doseq [child-id children
:let [child (get objects child-id)]
:when (some? child)]
(when-not (= id (:parent-id child))
(fail! "shape :shapes lists a child with a different :parent-id"
{:id id
:child child-id}))))))
(defn- check-reachability!
"Check the root graph through the cycle-safe descendant walk.
Runs `check-child-links!` first, which keeps the walk linear: one parent
per child and vector `:shapes` mean the walk cannot fan out over shared
children or loop on a cycle.
Detects missing child ids, objects reachable more than once (shared
children or cycles), detached subtrees and a root listed as its own
descendant. Runs before the production validator, whose walk follows
`:shapes` links and would loop on a reachable cycle."
[objects]
(check-child-links! objects)
(let [ids (cfh/get-children-ids objects uuid/zero)
seen (set ids)]
(when (contains? seen uuid/zero)
(fail! "root must not be a descendant of itself"
{:id uuid/zero}))
(doseq [id ids]
(when-not (contains? objects id)
(fail! "shape :shapes references a missing object"
{:child id})))
(when-let [dup (first (for [[id n] (frequencies ids) :when (> n 1)] id))]
(fail! "an object is reachable more than once"
{:id dup}))
(doseq [id (keys objects)
:when (not= id uuid/zero)]
(when-not (contains? seen id)
(fail! "object is not reachable from the root"
{:id id})))))
(defn- check-referential-integrity!
"Run the production referential validation over the reachable graph.
`cfv/validate-shape` checks parent/child links, frame ids and geometry
with editor semantics. The file and page only carry the uuids the
validation error schema requires; fixtures contain no component shapes.
Unreachable objects never reach this walk; `check-reachability!` owns
them."
[instance]
(let [file {:id uuid/zero :data {}}
page {:id uuid/zero :objects (:objects instance)}
errors (cfv/validate-shape uuid/zero file page {})]
(when-let [{:keys [hint] :as error} (first errors)]
(fail! hint (select-keys error [:code :shape-id :args])))))
(defn- check-refs!
[instance]
(let [objects (:objects instance)]
(doseq [[label target] (:refs instance)]
(when-not (contains? objects target)
(fail! "ref target is not present in :objects"
{:label label
:target target})))))
(defn validate!
"Validates a fixture instance and returns it unchanged.
Throws `ex-info` with `{:type ::invalid-fixture :hint ...}` when the
snapshot is invalid. Schema failures also carry `::sm/explain`.
Reachability runs before the production validator, because that walk
follows `:shapes` links and would not terminate on a reachable cycle.
Explicit `throw`s are used instead of assertions so the checks survive
elided assertions."
[instance]
(check-schema! instance)
(let [objects (:objects instance)]
(check-root! instance)
(check-object-ids! objects)
(check-reachability! objects)
(check-referential-integrity! instance)
(check-refs! instance))
instance)
(defn upload-order
"Returns the instance objects as a vector in parent-before-child order.
The root comes first and each shape's `:shapes` order is followed, using
the shared tree traversal. That traversal reverses children of flex
containers marked reverse; upload order carries no renderer semantics
because a shape's child list is uploaded from its own `:shapes` vector.
Assumes a validated instance: every child id exists."
[instance]
(vec (gts/get-children-seq uuid/zero (:objects instance))))
(defn ref-id
"Resolves an author label to the referenced object uuid, or nil."
[instance label]
(get-in instance [:refs label]))

View File

@ -0,0 +1,317 @@
;; 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.benches.fixture-test
(:require
[app.common.schema :as sm]
[app.common.types.shape :as cts]
[app.common.uuid :as uuid]
[benches.render-wasm.scenes.common :as fixture]
[cljs.test :as t :include-macros true]))
(defn- sample-instance
"Simple tiny fixture: root frame with one rectangle."
[]
(let [rect-id (uuid/custom 1 1)
rect (cts/setup-shape {:id rect-id :type :rect :name "Rect"
:x 32 :y 48 :width 200 :height 120})
root (cts/setup-shape {:id uuid/zero :type :frame :name "Root Frame"
:x 0 :y 0 :width 1920 :height 1080
:shapes [rect-id]})]
{:objects {uuid/zero root, rect-id rect}
:refs {:rect rect-id}}))
(defn- nested-instance
"Root with two frames; frame A with two rectangles, frame B with one."
[]
(let [a-id (uuid/custom 2 1)
b-id (uuid/custom 2 2)
a1-id (uuid/custom 2 3)
a2-id (uuid/custom 2 4)
b1-id (uuid/custom 2 5)
frame-a (cts/setup-shape {:id a-id :type :frame :name "A"
:x 0 :y 0 :width 100 :height 100
:parent-id uuid/zero :frame-id uuid/zero
:shapes [a1-id a2-id]})
frame-b (cts/setup-shape {:id b-id :type :frame :name "B"
:x 200 :y 0 :width 100 :height 100
:parent-id uuid/zero :frame-id uuid/zero
:shapes [b1-id]})
rect-a1 (cts/setup-shape {:id a1-id :type :rect :name "A1"
:x 10 :y 10 :width 10 :height 10
:parent-id a-id :frame-id a-id})
rect-a2 (cts/setup-shape {:id a2-id :type :rect :name "A2"
:x 30 :y 10 :width 10 :height 10
:parent-id a-id :frame-id a-id})
rect-b1 (cts/setup-shape {:id b1-id :type :rect :name "B1"
:x 210 :y 10 :width 10 :height 10
:parent-id b-id :frame-id b-id})
root (cts/setup-shape {:id uuid/zero :type :frame :name "Root Frame"
:x 0 :y 0 :width 1920 :height 1080
:parent-id uuid/zero :frame-id uuid/zero
:shapes [a-id b-id]})]
{:objects {uuid/zero root
a-id frame-a
b-id frame-b
a1-id rect-a1
a2-id rect-a2
b1-id rect-b1}
:refs {:a a-id
:a1 a1-id
:b b-id}}))
(defn- validation-error
"Returns the ex-data of the validation failure, or nil when it passes."
[instance]
(try
(fixture/validate! instance)
nil
(catch :default cause
(ex-data cause))))
(t/deftest canonical-example-is-valid
(t/testing "validate! returns the same instance"
(let [instance (sample-instance)]
(t/is (identical? instance (fixture/validate! instance)))))
(t/testing "the envelope schema accepts the example"
(t/is (true? (sm/validate fixture/schema:instance (sample-instance))))))
(t/deftest nested-hierarchy-is-valid
(let [instance (nested-instance)]
(t/is (identical? instance (fixture/validate! instance)))))
(t/deftest root-only-instance-is-valid
(let [root (cts/setup-shape {:id uuid/zero :type :frame :name "Root Frame"
:x 0 :y 0 :width 100 :height 100
:parent-id uuid/zero :frame-id uuid/zero
:shapes []})
instance {:objects {uuid/zero root} :refs {}}]
(t/is (identical? instance (fixture/validate! instance)))
(t/is (= [uuid/zero] (mapv :id (fixture/upload-order instance))))))
(t/deftest upload-order-starts-at-root
(let [instance (sample-instance)
ids (mapv :id (fixture/upload-order instance))]
(t/is (= uuid/zero (first ids)))
(t/is (= [uuid/zero (fixture/ref-id instance :rect)] ids))))
(t/deftest upload-order-is-parent-before-child
(let [instance (nested-instance)
order (fixture/upload-order instance)
names (mapv :name order)
position (into {} (map-indexed (fn [index shape] [(:id shape) index]) order))]
(t/is (= ["Root Frame" "A" "A1" "A2" "B" "B1"] names))
(t/is (every? (fn [shape]
(or (= uuid/zero (:id shape))
(< (position (:parent-id shape))
(position (:id shape)))))
order))))
(t/deftest upload-order-follows-flex-reverse
(let [frame-id (uuid/custom 8 1)
a-id (uuid/custom 8 2)
b-id (uuid/custom 8 3)
frame (cts/setup-shape {:id frame-id :type :frame :name "Flex"
:x 0 :y 0 :width 200 :height 100
:parent-id uuid/zero :frame-id uuid/zero
:layout :flex :layout-flex-dir :row-reverse
:shapes [a-id b-id]})
shape-a (cts/setup-shape {:id a-id :type :rect :name "A"
:x 0 :y 0 :width 10 :height 10
:parent-id frame-id :frame-id frame-id})
shape-b (cts/setup-shape {:id b-id :type :rect :name "B"
:x 20 :y 0 :width 10 :height 10
:parent-id frame-id :frame-id frame-id})
root (cts/setup-shape {:id uuid/zero :type :frame :name "Root Frame"
:x 0 :y 0 :width 1920 :height 1080
:parent-id uuid/zero :frame-id uuid/zero
:shapes [frame-id]})
instance {:objects {uuid/zero root
frame-id frame
a-id shape-a
b-id shape-b}
:refs {}}]
(t/is (identical? instance (fixture/validate! instance)))
(t/is (= ["Root Frame" "Flex" "B" "A"]
(mapv :name (fixture/upload-order instance))))))
(t/deftest ref-id-resolves-labels
(let [instance (sample-instance)]
(t/is (= (uuid/custom 1 1) (fixture/ref-id instance :rect)))
(t/is (nil? (fixture/ref-id instance :missing)))))
(t/deftest missing-root-is-rejected
(let [instance (sample-instance)
data (validation-error (update instance :objects dissoc uuid/zero))]
(t/is (= ::fixture/invalid-fixture (:type data)))
(t/is (= uuid/zero (:id data)))))
(t/deftest non-frame-root-is-rejected
(let [instance (sample-instance)
data (validation-error (assoc-in instance [:objects uuid/zero :type] :rect))]
(t/is (= ::fixture/invalid-fixture (:type data)))
(t/is (= uuid/zero (:id data)))))
(t/deftest root-with-non-zero-parent-id-is-rejected
(let [instance (sample-instance)
data (validation-error
(assoc-in instance [:objects uuid/zero :parent-id] (uuid/custom 9 9)))]
(t/is (= ::fixture/invalid-fixture (:type data)))
(t/is (= uuid/zero (:id data)))))
(t/deftest object-key-must-match-shape-id
(let [instance (sample-instance)
rect (get-in instance [:objects (fixture/ref-id instance :rect)])
data (validation-error (assoc-in instance [:objects (uuid/custom 7 7)] rect))]
(t/is (= ::fixture/invalid-fixture (:type data)))
(t/is (= (:id rect) (:shape-id data)))))
(t/deftest missing-child-is-rejected
(let [instance (sample-instance)
bogus (uuid/custom 3 3)
data (validation-error
(assoc-in instance [:objects uuid/zero :shapes] [bogus]))]
(t/is (= ::fixture/invalid-fixture (:type data)))
(t/is (= bogus (:child data)))))
(t/deftest child-not-listed-by-parent-is-rejected
(let [instance (sample-instance)
rect-id (fixture/ref-id instance :rect)
data (validation-error (assoc-in instance [:objects uuid/zero :shapes] []))]
(t/is (= ::fixture/invalid-fixture (:type data)))
(t/is (= "object is not reachable from the root" (:hint data)))
(t/is (= rect-id (:id data)))))
(t/deftest mismatched-parent-id-is-rejected
(let [instance (sample-instance)
rect-id (fixture/ref-id instance :rect)
data (validation-error
(assoc-in instance [:objects rect-id :parent-id] (uuid/custom 9 7)))]
(t/is (= ::fixture/invalid-fixture (:type data)))
(t/is (= "shape :shapes lists a child with a different :parent-id" (:hint data)))
(t/is (= rect-id (:child data)))))
(t/deftest duplicate-child-ids-are-rejected
(let [instance (sample-instance)
rect-id (fixture/ref-id instance :rect)
data (validation-error
(assoc-in instance [:objects uuid/zero :shapes] [rect-id rect-id]))]
(t/is (= ::fixture/invalid-fixture (:type data)))
(t/is (= "an object is reachable more than once" (:hint data)))
(t/is (= rect-id (:id data)))))
(t/deftest shape-listing-itself-is-rejected
(let [instance (sample-instance)
data (validation-error
(assoc-in instance [:objects uuid/zero :shapes] [uuid/zero]))]
(t/is (= ::fixture/invalid-fixture (:type data)))
(t/is (= "root must not be a descendant of itself" (:hint data)))
(t/is (= uuid/zero (:id data)))))
(t/deftest detached-cycle-is-rejected
(let [c1-id (uuid/custom 5 1)
c2-id (uuid/custom 5 2)
shape1 (cts/setup-shape {:id c1-id :type :rect :name "C1"
:x 0 :y 0 :width 10 :height 10
:parent-id c2-id :frame-id c1-id
:shapes [c2-id]})
shape2 (cts/setup-shape {:id c2-id :type :rect :name "C2"
:x 0 :y 0 :width 10 :height 10
:parent-id c1-id :frame-id c2-id
:shapes [c1-id]})
data (validation-error
(assoc (sample-instance) :objects
(assoc (get (sample-instance) :objects)
c1-id shape1
c2-id shape2)))]
(t/is (= ::fixture/invalid-fixture (:type data)))
(t/is (= "object is not reachable from the root" (:hint data)))
(t/is (contains? #{c1-id c2-id} (:id data)))))
(t/deftest reachable-cycle-is-rejected
(let [a-id (uuid/custom 4 1)
b-id (uuid/custom 4 2)
shape-a (cts/setup-shape {:id a-id :type :rect :name "A"
:x 0 :y 0 :width 10 :height 10
:parent-id uuid/zero :frame-id uuid/zero
:shapes [b-id]})
shape-b (cts/setup-shape {:id b-id :type :rect :name "B"
:x 0 :y 0 :width 10 :height 10
:parent-id a-id :frame-id uuid/zero
:shapes [a-id]})
instance (-> (sample-instance)
(assoc-in [:objects uuid/zero :shapes] [a-id])
(assoc-in [:objects a-id] shape-a)
(assoc-in [:objects b-id] shape-b))
data (validation-error instance)]
(t/is (= ::fixture/invalid-fixture (:type data)))
(t/is (= "shape :shapes lists a child with a different :parent-id" (:hint data)))
(t/is (= a-id (:child data)))))
(t/deftest non-vector-children-are-rejected
(let [instance (sample-instance)
rect-id (fixture/ref-id instance :rect)
data (validation-error (assoc-in instance [:objects rect-id :shapes] 5))]
(t/is (= ::fixture/invalid-fixture (:type data)))
(t/is (= "shape :shapes must be a vector" (:hint data)))
(t/is (= rect-id (:id data)))))
(t/deftest frame-id-mismatch-is-rejected
(let [instance (nested-instance)
a1-id (fixture/ref-id instance :a1)
data (validation-error
(assoc-in instance [:objects a1-id :frame-id] uuid/zero))]
(t/is (= ::fixture/invalid-fixture (:type data)))
(t/is (= :invalid-frame (:code data)))
(t/is (= a1-id (:shape-id data)))))
(t/deftest missing-ref-target-is-rejected
(let [instance (sample-instance)
data (validation-error
(assoc-in instance [:refs :rect] (uuid/custom 6 6)))]
(t/is (= ::fixture/invalid-fixture (:type data)))
(t/is (= :rect (:label data)))))
(t/deftest non-uuid-ref-value-is-rejected
(let [data (validation-error (assoc-in (sample-instance) [:refs :rect] "not-a-uuid"))]
(t/is (= ::fixture/invalid-fixture (:type data)))
(t/is (some? (::sm/explain data)))))
(t/deftest missing-refs-key-is-rejected
(let [data (validation-error (dissoc (sample-instance) :refs))]
(t/is (= ::fixture/invalid-fixture (:type data)))
(t/is (some? (::sm/explain data)))))
(t/deftest non-canonical-shape-is-rejected
(let [instance (sample-instance)
rect-id (fixture/ref-id instance :rect)
data (validation-error (assoc-in instance [:objects rect-id] {:id rect-id}))]
(t/is (= ::fixture/invalid-fixture (:type data)))
(t/is (some? (::sm/explain data)))))
(t/deftest malformed-envelopes-are-rejected
(t/testing "empty objects map"
(let [data (validation-error {:objects {} :refs {}})]
(t/is (= ::fixture/invalid-fixture (:type data)))
(t/is (= uuid/zero (:id data)))))
(t/testing "nil instance"
(let [data (validation-error nil)]
(t/is (= ::fixture/invalid-fixture (:type data)))
(t/is (some? (::sm/explain data)))))
(t/testing "nil shape value"
(let [instance (sample-instance)
rect-id (fixture/ref-id instance :rect)
data (validation-error (assoc-in instance [:objects rect-id] nil))]
(t/is (= ::fixture/invalid-fixture (:type data)))
(t/is (some? (::sm/explain data)))))
(t/testing "nil ref value"
(let [data (validation-error (assoc-in (sample-instance) [:refs :rect] nil))]
(t/is (= ::fixture/invalid-fixture (:type data)))
(t/is (some? (::sm/explain data))))))

View File

@ -175,6 +175,7 @@
(def test-namespaces
['frontend-tests.basic-shapes-test
'frontend-tests.benches.fixture-test
'frontend-tests.code-gen-style-test
'frontend-tests.composable-tests.comp.sync-test
'frontend-tests.copy-as-svg-test