From bfde40d1ab4b83a660445538e8ddd3122c0b2710 Mon Sep 17 00:00:00 2001 From: Miguel de Benito Delgado Date: Mon, 21 Sep 2026 20:01:30 +0200 Subject: [PATCH] :construction: WIP Step 5, bools --- .../benches/render_wasm/scenes/builder.clj | 30 ++- .../benches/render_wasm/scenes/builder.cljs | 131 ++++++++-- .../benches/boolean_fixture_test.cljs | 242 ++++++++++++++++++ 3 files changed, 381 insertions(+), 22 deletions(-) create mode 100644 frontend/test/frontend_tests/benches/boolean_fixture_test.cljs diff --git a/frontend/test/benches/render_wasm/scenes/builder.clj b/frontend/test/benches/render_wasm/scenes/builder.clj index db35cfc01c..bf5ade452e 100644 --- a/frontend/test/benches/render_wasm/scenes/builder.clj +++ b/frontend/test/benches/render_wasm/scenes/builder.clj @@ -19,7 +19,10 @@ (frame {:x 0 :y 0 :width 100 :height 100} (rect)) (group :tiles {} - (rect [:tile 3] {}))) + (rect [:tile 3] {})) + (bool :diff {:bool-type :difference} + (rect {:x 0 :y 0 :width 100 :height 100}) + (rect {:x 50 :y 50 :width 100 :height 100}))) `params` is a map with `:seed` (required), optional `:root` attributes for the canonical root frame, and optional `:defaults` with one attribute @@ -114,3 +117,28 @@ ~label-or-attrs nil (fn [] ~@body)))) + +(defmacro bool + "Adds a boolean shape to the current scope, runs `body` with the bool as + parent, and returns the bool uuid. + + Forms: `(bool attrs & body)` and `(bool label attrs & body)`. `label` is a + literal keyword or vector stored in the instance `:refs`. `attrs` must + carry `:bool-type` (`:union`, `:difference`, `:intersection` or + `:exclude`). Content and geometry derive from the children after they + finalize, so geometry, content and transform attrs are rejected. Fills, + strokes, shadows and blurs are inherited from the head child (first for + `:difference`, last otherwise) unless attrs supply them. A bool needs at + least one child and cannot contain frames." + [label-or-attrs & body] + (if (or (keyword? label-or-attrs) (vector? label-or-attrs)) + `(benches.render-wasm.scenes.builder/with-container + :bool + ~(first body) + ~label-or-attrs + (fn [] ~@(rest body))) + `(benches.render-wasm.scenes.builder/with-container + :bool + ~label-or-attrs + nil + (fn [] ~@body)))) diff --git a/frontend/test/benches/render_wasm/scenes/builder.cljs b/frontend/test/benches/render_wasm/scenes/builder.cljs index 817ed3c4a0..9d32fbce16 100644 --- a/frontend/test/benches/render_wasm/scenes/builder.cljs +++ b/frontend/test/benches/render_wasm/scenes/builder.cljs @@ -9,7 +9,7 @@ A fixture scope owns one seeded generator, one object map, and one label map. Shape constructors add themselves to the current parent: `rect`, and - the `frame`/`group` container scopes. The scope returns a validated + the `frame`/`group`/`bool` container scopes. The scope returns a validated snapshot in the ticket02 contract `{:objects ... :refs ...}`. Construction is synchronous by design: the scope and the current parent @@ -27,13 +27,16 @@ Coordinates stay page-absolute: nesting supplies ownership and order, not translation. Frames keep authored bounds; groups derive bounds from their children inside out; a masked group takes the geometry of its first child. + Booleans derive content and geometry from their children with the canonical + path engine and inherit style from the head child unless attrs supply it. - Masked groups, frame/group scopes are this ticket; booleans ticket19; - paths ticket07/08." + Masked groups and the frame/group/bool scopes live here; paths ticket07/08." (:require [app.common.data :as d] + [app.common.files.helpers :as cfh] [app.common.geom.shapes :as gsh] [app.common.types.color :as clr] + [app.common.types.path :as path] [app.common.types.shape :as cts] [app.common.types.shape-tree :as ctst] [app.common.uuid :as uuid] @@ -55,12 +58,12 @@ [seed] (let [state (atom (bit-or seed 0))] (fn [] - (let [s (swap! state (fn [s] (bit-or (+ s 0x6d2b79f5) 0))) - v (js/Math.imul (bit-xor s (unsigned-bit-shift-right s 15)) - (bit-or s 1)) - v (bit-xor v (+ v (js/Math.imul (bit-xor v (unsigned-bit-shift-right v 7)) - (bit-or v 61)))) - v (bit-xor v (unsigned-bit-shift-right v 14))] + (let [s (swap! state (fn [s] (bit-or (+ s 0x6d2b79f5) 0))) + v (js/Math.imul (bit-xor s (unsigned-bit-shift-right s 15)) + (bit-or s 1)) + v (bit-xor v (+ v (js/Math.imul (bit-xor v (unsigned-bit-shift-right v 7)) + (bit-or v 61)))) + v (bit-xor v (unsigned-bit-shift-right v 14))] (/ (unsigned-bit-shift-right v 0) 4294967296))))) (defn rng-float @@ -256,11 +259,33 @@ id)) (def ^:private frame-bounds-keys + "Frames do not derive their bounds from children." [:x :y :width :height]) (def ^:private group-geometry-keys + "Group geometry derives from its children." [:x :y :width :height :selrect :points]) +(def ^:private bool-geometry-keys + "Bool geometry, content and placement derive from its children." + [:x :y :width :height :selrect :points :content]) + +(def ^:private bool-transform-keys + "Construction does not accept transforms: `path/update-geometry` would + pivot them on the empty pre-finalization selrect." + [:transform :transform-inverse :rotation :flip-x :flip-y]) + +(defn- reject-attrs! + "Throws when `attrs` carries any of `attr-keys`. Shared by containers + whose geometry or content derives from their children." + [attrs id error-type message attr-keys] + (doseq [key attr-keys] + (when (some? (get attrs key)) + (throw (ex-info message + {:type error-type + :id id + :key key}))))) + (defn- setup-frame [id attrs] (when-not (map? attrs) @@ -289,14 +314,70 @@ (throw (ex-info "group attrs must be a map; the label form takes the attrs map next" {:type ::invalid-attrs :id id}))) - (doseq [key group-geometry-keys] - (when (some? (get attrs key)) - (throw (ex-info "group derives its geometry from children" - {:type ::group-geometry - :id id - :key key})))) + (reject-attrs! attrs id ::group-geometry + "group derives its geometry from children" + group-geometry-keys) (cts/setup-shape (assoc attrs :id id :type :group))) +(defn- setup-bool + [id attrs] + (when-not (map? attrs) + (throw (ex-info "bool attrs must be a map; the label form takes the attrs map next" + {:type ::invalid-attrs + :id id}))) + (reject-attrs! attrs id ::bool-geometry + "bool derives its geometry and content from children" + bool-geometry-keys) + (reject-attrs! attrs id ::bool-transform + "bool construction does not accept transforms" + bool-transform-keys) + (when-not (contains? cts/bool-types (:bool-type attrs)) + (throw (ex-info "bool requires :bool-type :union :difference :intersection or :exclude" + {:type ::bool-type + :id id + :bool-type (:bool-type attrs) + :supported cts/bool-types}))) + (cts/setup-shape (assoc attrs :id id :type :bool))) + +(defn- bool-head + "Canonical style head: the first child is the base for `:difference`, the + last child otherwise." + [bool-shape children] + (if (= :difference (:bool-type bool-shape)) + (first children) + (last children))) + +(defn- finalize-bool! + [state id] + (let [scope @state + bool (get-in scope [:objects id]) + children (mapv (:objects scope) (:shapes bool))] + (when (empty? children) + (throw (ex-info "bool requires at least one child" + {:type ::empty-bool + :id id}))) + (when-let [frame (first (filter cfh/frame-shape? children))] + (throw (ex-info "bool children cannot be frames" + {:type ::frame-in-bool + :id id + :child (:id frame)}))) + (let [inherited (d/without-nils + (select-keys (bool-head bool children) + path/bool-style-properties)) + supplied (d/without-nils + (select-keys (get-in scope [:container-attrs id]) + path/bool-style-properties)) + bool (-> bool (merge inherited) (merge supplied)) + + ;; `path/update-bool-shape` dispatches to `path/wasm:calc-bool-content`, + ;; the renderer override, so fixture content would depend on renderer + ;; initialization and A/B configuration. Compose the pure helpers: + ;; fixture generation is untimed and must not vary with the renderer + ;; being compared. + content (path/calc-bool-content bool (:objects scope)) + bool (path/update-geometry bool content)] + (swap! state assoc-in [:objects id] bool)))) + (defn- finalize-group! [state id] (let [scope @state @@ -313,24 +394,30 @@ (def ^:private container-specs "Per-container behavior: how to build it, which parent scope it opens, - and how to finalize it after the body. Ticket19 extends the table with - booleans." + and how to finalize it after the body. `:keep-attrs?` records the raw + attrs in the scope so the finalizer can let supplied styles win over + inherited ones." {:frame {:setup setup-frame :parent (fn [id _parent] {:id id :frame-id id}) :finalize (fn [_state _id] nil)} :group {:setup setup-group :parent (fn [id parent] {:id id :frame-id (:frame-id parent)}) - :finalize finalize-group!}}) + :finalize finalize-group!} + :bool {:setup setup-bool + :parent (fn [id parent] {:id id :frame-id (:frame-id parent)}) + :finalize finalize-bool! + :keep-attrs? true}}) (defn with-container - "Creates a frame or group, runs `thunk` with the new parent in scope, - finalizes groups, and returns the container uuid. + "Creates a frame, group or bool, runs `thunk` with the new parent in + scope, finalizes the containers that derive state from children, and + returns the container uuid. Any exception from the body or the finalizer marks the container unfinished and propagates; `finish!` then rejects the instance even if the caller catches the exception." [type attrs label thunk] - (let [{:keys [setup parent finalize]} + (let [{:keys [setup parent finalize keep-attrs?]} (or (get container-specs type) (throw (ex-info "unsupported container type" {:type ::invalid-container @@ -338,6 +425,8 @@ state (ensure-state *state*) id (rng-uuid (:rng @state)) shape (setup id attrs)] + (when keep-attrs? + (swap! state assoc-in [:container-attrs id] attrs)) (add-object! state label shape) (binding [*parent* (parent id *parent*)] (try diff --git a/frontend/test/frontend_tests/benches/boolean_fixture_test.cljs b/frontend/test/frontend_tests/benches/boolean_fixture_test.cljs new file mode 100644 index 0000000000..6742a32e8d --- /dev/null +++ b/frontend/test/frontend_tests/benches/boolean_fixture_test.cljs @@ -0,0 +1,242 @@ +;; 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.boolean-fixture-test + (:require + [app.common.types.path :as path] + [app.common.types.shape :as cts] + [app.common.uuid :as uuid] + [benches.render-wasm.scenes.builder :as b :include-macros true] + [benches.render-wasm.scenes.common :as common] + [cljs.test :as t :include-macros true])) + +(defn- failure-data + [f] + (try + (f) + nil + (catch :default cause + (ex-data cause)))) + +(defn- selrect + [shape] + [(:x (:selrect shape)) (:y (:selrect shape)) + (:width (:selrect shape)) (:height (:selrect shape))]) + +(defn- children-of + [instance id] + (mapv (:objects instance) (get-in instance [:objects id :shapes]))) + +(defn- run-scope + "Runs `f` inside a raw scope and returns the scope state, so tests can + inspect partial builds without calling finish!." + [params f] + (let [state (b/start params)] + (binding [b/*state* state + b/*defaults* {}] + (f)) + state)) + +(t/deftest difference-of-overlapping-rects-derives-the-l-shape + (let [red {:fill-color "#ff0000" :fill-opacity 1} + blue {:fill-color "#0000ff" :fill-opacity 1} + instance (b/fixture {:seed 1} + (b/bool :l-shape {:bool-type :difference :name "L"} + (b/rect {:x 0 :y 0 :width 100 :height 100 :fills [red]}) + (b/rect {:x 50 :y 50 :width 100 :height 100 :fills [blue]}))) + bool (first (children-of instance uuid/zero)) + children (children-of instance (:id bool))] + (t/is (= :bool (:type bool))) + (t/is (= :difference (:bool-type bool))) + (t/is (= (:id bool) (common/ref-id instance :l-shape))) + (t/is (= 2 (count children))) + (t/is (= [(:id (first children)) (:id (second children))] (:shapes bool))) + (t/is (= [(:id bool) (:id bool)] + [(:parent-id (first children)) (:parent-id (second children))])) + (t/is (= [uuid/zero uuid/zero] + [(:frame-id (first children)) (:frame-id (second children))])) + (t/is (= [0 0 100 100] (selrect bool))) + (t/is (= (:selrect bool) (path/calc-selrect (:content bool)))) + (t/is (= 4 (count (:points bool)))) + (t/is (= #{[0 0] [100 0] [100 50] [50 50] [50 100] [0 100]} + (set (map (juxt :x :y) (path/get-points (:content bool)))))))) + +(t/deftest union-intersection-and-exclude-derive-canonical-bounds + (doseq [[bool-type expected] [[:union [0 0 150 150]] + [:intersection [50 50 50 50]] + [:exclude [0 0 150 150]]]] + (let [instance (b/fixture {:seed 1} + (b/bool {:bool-type bool-type} + (b/rect {:x 0 :y 0 :width 100 :height 100}) + (b/rect {:x 50 :y 50 :width 100 :height 100}))) + bool (first (children-of instance uuid/zero))] + (t/is (= bool-type (:bool-type bool))) + (t/is (= expected (selrect bool)) (str bool-type))))) + +(t/deftest operation-picks-the-canonical-style-head + (let [red {:fill-color "#ff0000" :fill-opacity 1} + blue {:fill-color "#0000ff" :fill-opacity 1} + blur {:id (uuid/custom 3 3) :type :layer-blur :value 4 :hidden false} + difference (b/fixture {:seed 1} + (b/bool {:bool-type :difference} + (b/rect {:x 0 :y 0 :width 10 :height 10 + :fills [red] :blur blur}) + (b/rect {:x 5 :y 0 :width 10 :height 10 + :fills [blue]}))) + union (b/fixture {:seed 1} + (b/bool {:bool-type :union} + (b/rect {:x 0 :y 0 :width 10 :height 10 + :fills [red] :blur blur}) + (b/rect {:x 5 :y 0 :width 10 :height 10 + :fills [blue]}))) + diff-bool (first (children-of difference uuid/zero)) + union-bool (first (children-of union uuid/zero))] + (t/is (= [red] (:fills diff-bool))) + (t/is (= blur (:blur diff-bool))) + (t/is (= [blue] (:fills union-bool))) + (t/is (nil? (:blur union-bool))))) + +(t/deftest supplied-styles-override-inherited + (let [instance (b/fixture {:seed 1} + (b/bool {:bool-type :union :fills []} + (b/rect {:x 0 :y 0 :width 10 :height 10 + :fills [{:fill-color "#ff0000" :fill-opacity 1}]}) + (b/rect {:x 5 :y 0 :width 10 :height 10 + :fills [{:fill-color "#0000ff" :fill-opacity 1}]}))) + bool (first (children-of instance uuid/zero))] + (t/is (= [] (:fills bool))))) + +(t/deftest nil-styles-do-not-count-as-supplied + (let [red {:fill-color "#ff0000" :fill-opacity 1} + blue {:fill-color "#0000ff" :fill-opacity 1} + instance (b/fixture {:seed 1} + (b/bool {:bool-type :union :fills nil} + (b/rect {:x 0 :y 0 :width 10 :height 10 :fills [red]}) + (b/rect {:x 5 :y 0 :width 10 :height 10 :fills [blue]}))) + bool (first (children-of instance uuid/zero))] + (t/is (= [blue] (:fills bool))))) + +(t/deftest single-child-bool-uses-the-child-path + (let [instance (b/fixture {:seed 1} + (b/bool {:bool-type :difference} + (b/rect {:x 10 :y 20 :width 30 :height 40}))) + bool (first (children-of instance uuid/zero))] + (t/is (= 1 (count (:shapes bool)))) + (t/is (= [10 20 30 40] (selrect bool))) + (t/is (= (:selrect bool) (path/calc-selrect (:content bool)))))) + +(t/deftest nested-bools-finalize-inside-out + (let [instance (b/fixture {:seed 1} + (b/bool {:bool-type :union} + (b/bool {:bool-type :difference} + (b/rect {:x 0 :y 0 :width 100 :height 100}) + (b/rect {:x 50 :y 50 :width 100 :height 100})) + (b/rect {:x 200 :y 0 :width 50 :height 50}))) + outer (first (children-of instance uuid/zero)) + inner (first (children-of instance (:id outer)))] + (t/is (= :bool (:type inner))) + (t/is (= [0 0 100 100] (selrect inner))) + (t/is (= [0 0 250 100] (selrect outer))) + (t/is (= (:selrect outer) (path/calc-selrect (:content outer)))))) + +(t/deftest bool-inside-frame-keeps-frame-ids + (let [instance (b/fixture {:seed 1} + (b/frame {:x 0 :y 0 :width 200 :height 200} + (b/bool {:bool-type :union} + (b/rect {:x 10 :y 10 :width 10 :height 10}) + (b/rect {:x 30 :y 10 :width 10 :height 10})))) + frame (first (children-of instance uuid/zero)) + bool (first (children-of instance (:id frame))) + rects (children-of instance (:id bool))] + (t/is (= (:id frame) (:parent-id bool))) + (t/is (= (:id frame) (:frame-id bool))) + (t/is (= [(:id frame) (:id frame)] (mapv :frame-id rects))) + (t/is (= [(:id bool) (:id bool)] (mapv :parent-id rects))))) + +(t/deftest bool-inside-group-derives-group-bounds + (let [instance (b/fixture {:seed 1} + (b/group {} + (b/bool {:bool-type :union} + (b/rect {:x 10 :y 20 :width 30 :height 40}) + (b/rect {:x 50 :y 80 :width 10 :height 10})))) + group (first (children-of instance uuid/zero)) + bool (first (children-of instance (:id group)))] + (t/is (= :bool (:type bool))) + (t/is (= [10 20 50 70] (selrect group))) + (t/is (= uuid/zero (:frame-id bool))))) + +(t/deftest empty-bool-is-rejected + (let [data (failure-data #(b/fixture {:seed 1} (b/bool {:bool-type :union})))] + (t/is (= ::b/empty-bool (:type data))))) + +(t/deftest caught-empty-bool-still-fails-finish + (let [state (run-scope {:seed 1} + (fn [] + (try + (b/bool {:bool-type :union}) + (catch :default _ nil))))] + (t/is (= ::b/unfinished-container + (:type (failure-data #(b/finish! state))))))) + +(t/deftest bool-geometry-attrs-are-rejected + (doseq [attrs [{:bool-type :union :x 1} + {:bool-type :union :selrect {:x 0 :y 0 :width 1 :height 1}} + {:bool-type :union :content []}]] + (let [data (failure-data #(b/fixture {:seed 1} (b/bool attrs (b/rect))))] + (t/is (= ::b/bool-geometry (:type data)) (str attrs)) + (t/is (contains? #{:x :selrect :content} (:key data)) (str attrs))))) + +(t/deftest bool-transform-attrs-are-rejected + (doseq [attrs [{:bool-type :union :rotation 45} + {:bool-type :union :transform "m"} + {:bool-type :union :transform-inverse "m"} + {:bool-type :union :flip-x true} + {:bool-type :union :flip-y true}]] + (let [data (failure-data #(b/fixture {:seed 1} (b/bool attrs (b/rect))))] + (t/is (= ::b/bool-transform (:type data)) (str attrs))))) + +(t/deftest bool-type-is-required-and-validated + (t/is (= ::b/bool-type + (:type (failure-data #(b/fixture {:seed 1} (b/bool {:name "B"} (b/rect))))))) + (let [data (failure-data #(b/fixture {:seed 1} (b/bool {:bool-type :merge} (b/rect))))] + (t/is (= ::b/bool-type (:type data))) + (t/is (= :merge (:bool-type data))) + (t/is (= cts/bool-types (:supported data))))) + +(t/deftest frame-child-is-rejected + (let [data (failure-data + #(b/fixture {:seed 1} + (b/bool {:bool-type :union} + (b/frame {:x 0 :y 0 :width 10 :height 10} + (b/rect)))))] + (t/is (= ::b/frame-in-bool (:type data))) + (t/is (uuid? (:child data))))) + +(t/deftest bool-generation-ignores-the-renderer-override + (let [original path/wasm:calc-bool-content] + (try + (set! path/wasm:calc-bool-content + (fn [& _] + (throw (ex-info "renderer override ran during generation" + {:type ::unexpected-renderer-call})))) + (let [instance (b/fixture {:seed 1} + (b/bool {:bool-type :difference} + (b/rect {:x 0 :y 0 :width 100 :height 100}) + (b/rect {:x 50 :y 50 :width 100 :height 100}))) + bool (first (children-of instance uuid/zero))] + (t/is (= [0 0 100 100] (selrect bool)))) + (finally + (set! path/wasm:calc-bool-content original))))) + +(t/deftest non-map-bool-attrs-are-rejected + (t/is (= ::b/invalid-attrs + (:type (failure-data #(b/fixture {:seed 1} (b/bool :hero (b/rect))))))) + (let [state (run-scope {:seed 1} + (fn [] + (try + (b/bool :hero (b/rect)) + (catch :default _ nil))))] + (t/is (= 1 (count (get-in @state [:objects uuid/zero :shapes]))))))