From 01f6eb2a7a5b2525e7e8c8a5ffa439a1234d5a4d Mon Sep 17 00:00:00 2001 From: Miguel de Benito Delgado Date: Sun, 20 Sep 2026 16:47:47 +0200 Subject: [PATCH] :construction: WIP Step 2, common scene code, validation --- .clj-kondo/config.edn | 2 +- frontend/package.json | 4 +- .../benches/render_wasm/scenes/common.cljs | 198 +++++++++++ .../frontend_tests/benches/fixture_test.cljs | 317 ++++++++++++++++++ frontend/test/frontend_tests/runner.cljs | 1 + 5 files changed, 519 insertions(+), 3 deletions(-) create mode 100644 frontend/test/benches/render_wasm/scenes/common.cljs create mode 100644 frontend/test/frontend_tests/benches/fixture_test.cljs diff --git a/.clj-kondo/config.edn b/.clj-kondo/config.edn index cb83ee6565..ed58d74207 100644 --- a/.clj-kondo/config.edn +++ b/.clj-kondo/config.edn @@ -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} diff --git a/frontend/package.json b/frontend/package.json index e76fa6e62c..9ecc807111 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/test/benches/render_wasm/scenes/common.cljs b/frontend/test/benches/render_wasm/scenes/common.cljs new file mode 100644 index 0000000000..9250f36864 --- /dev/null +++ b/frontend/test/benches/render_wasm/scenes/common.cljs @@ -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])) diff --git a/frontend/test/frontend_tests/benches/fixture_test.cljs b/frontend/test/frontend_tests/benches/fixture_test.cljs new file mode 100644 index 0000000000..6050e02831 --- /dev/null +++ b/frontend/test/frontend_tests/benches/fixture_test.cljs @@ -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)))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index bdc9193088..27b0a842f6 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -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