WIP kakota

This commit is contained in:
Eva Marco 2026-04-17 14:06:54 +02:00
parent 4ca4b43973
commit 3724f5b9f3
12 changed files with 418 additions and 18 deletions

View File

@ -85,11 +85,10 @@
;; :r4 nil}
;;
(defn get-attrs-multi
([objs attrs]
(get-attrs-multi objs attrs default-equal identity))
([objs attrs eqfn sel]
([objs attrs origin]
(get-attrs-multi objs attrs origin default-equal identity))
([objs attrs origin eqfn sel]
(loop [attr (first attrs)
attrs (rest attrs)
result (transient {})]
@ -107,7 +106,11 @@
:multiple
value)
(= new-val :multiple) :multiple
(= value ::unset) (sel new-val)
(= value ::unset)
(if (and (= origin :text-multi)(= attr :typography-ref-id))
:multiple
(sel new-val))
(eqfn new-val value) value
:else :multiple)]
@ -134,6 +137,6 @@
text-node-attrs (->> attrs (filter (set txt/text-node-attrs)))]
(merge
defaults
(get-attrs-multi (->> (txt/node-seq txt/is-root-node? content)) root-attrs)
(get-attrs-multi (->> (txt/node-seq txt/is-paragraph-node? content)) paragraph-attrs)
(get-attrs-multi (->> (txt/node-seq txt/is-text-node? content)) text-node-attrs))))
(get-attrs-multi (->> (txt/node-seq txt/is-root-node? content)) root-attrs :text-multi)
(get-attrs-multi (->> (txt/node-seq txt/is-paragraph-node? content)) paragraph-attrs :text-multi)
(get-attrs-multi (->> (txt/node-seq txt/is-text-node? content)) text-node-attrs :text-multi))))

View File

@ -0,0 +1,159 @@
;; 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
(ns common-tests.attrs-test
(:require
[app.common.attrs :as attrs]
[clojure.test :as t]))
(t/deftest get-attrs-multi-same-value
(t/testing "returns value when all objects have the same attribute value"
(let [objs [{:attr "red"}
{:attr "red"}
{:attr "red"}]
result (attrs/get-attrs-multi objs [:attr] :multi)]
(t/is (= {:attr "red"} result))))
(t/testing "returns nil when all objects have nil value"
(let [objs [{:attr nil}
{:attr nil}]
result (attrs/get-attrs-multi objs [:attr] :multi)]
(t/is (= {:attr nil} result)))))
(t/deftest get-attrs-multi-different-values
(t/testing "returns :multiple when objects have different concrete values"
(let [objs [{:attr "red"}
{:attr "blue"}]
result (attrs/get-attrs-multi objs [:attr] :multi)]
(t/is (= {:attr :multiple} result)))))
(t/deftest get-attrs-multi-missing-key
(t/testing "returns :multiple when one object has the attribute and another doesn't"
(let [objs [{:attr "red"}
{:other "value"}]
result (attrs/get-attrs-multi objs [:attr] :multi)]
(t/is (= {:attr :multiple} result))))
(t/testing "returns :multiple when one object has UUID and another is missing"
(let [uuid #uuid "550e8400-e29b-41d4-a716-446655440000"
objs [{:attr uuid}
{:other "value"}]
result (attrs/get-attrs-multi objs [:attr] :multi)]
(t/is (= {:attr :multiple} result))))
(t/testing "returns value when missing key comes first and is not text"
(let [objs [{:other "value"}
{:attr "red"}]
result (attrs/get-attrs-multi objs [:attr] :multi)]
(t/is (= {:attr "red"} result))))
(t/testing "returns :multiple when some objects have the key and some don't"
(let [objs [{:attr "red"}
{:other "value"}
{:attr "blue"}]
result (attrs/get-attrs-multi objs [:attr] :multi)]
(t/is (= {:attr :multiple} result))))
(t/testing "returns :multiple when one object has nil and another is missing"
(let [objs [{:attr nil}
{:other "value"}]
result (attrs/get-attrs-multi objs [:attr] :multi)]
(t/is (= {:attr :multiple} result)))))
(t/deftest get-attrs-multi-all-missing
(t/testing "all missing → attribute NOT included in result"
(let [objs [{:other "value"}
{:different "data"}]
result (attrs/get-attrs-multi objs [:attr] :multi)]
(t/is (= {} result)
"Attribute should not be in result when all objects are missing")))
(t/testing "all missing with empty maps → attribute NOT included"
(let [objs [{} {}]
result (attrs/get-attrs-multi objs [:attr] :multi)]
(t/is (= {} result)
"Attribute should not be in result"))))
(t/deftest get-attrs-multi-multiple-attributes
(t/testing "handles multiple attributes with different merge results"
(let [objs [{:attr1 "red" :attr2 "blue"}
{:attr1 "red" :attr2 "green"}
{:attr1 "red"}] ; :attr2 missing
result (attrs/get-attrs-multi objs [:attr1 :attr2] :multi)]
(t/is (= {:attr1 "red" :attr2 :multiple} result))))
(t/testing "handles mixed scenarios: same, different, and missing"
(let [uuid #uuid "550e8400-e29b-41d4-a716-446655440000"
uuid2 #uuid "550e8400-e29b-41d4-a716-446655440001"
objs [{:id :a :ref uuid}
{:id :b :ref uuid2}
{:id :c}] ; :ref missing
result (attrs/get-attrs-multi objs [:id :ref] :multi)]
(t/is (= {:id :multiple :ref :multiple} result)))))
(t/deftest get-attrs-multi-typography-ref-id-scenario
(t/testing "the specific bug scenario: typography-ref-id with UUID vs missing"
(let [uuid #uuid "550e8400-e29b-41d4-a716-446655440000"
;; Shape 1 has typography-ref-id with a UUID
shape1 {:id :shape1 :typography-ref-id uuid}
;; Shape 2 does NOT have typography-ref-id at all
shape2 {:id :shape2}
result (attrs/get-attrs-multi [shape1 shape2] [:typography-ref-id] :multi)]
(t/is (= {:typography-ref-id :multiple} result)
"Expected :multiple when one shape has the UUID and another is missing the key")))
(t/testing "both shapes missing → attribute NOT included in result"
(let [shape1 {:id :shape1}
shape2 {:id :shape2}
result (attrs/get-attrs-multi [shape1 shape2] [:typography-ref-id] :multi)]
(t/is (= {} result)
"Expected empty map when all shapes are missing the attribute"))))
(t/deftest get-attrs-multi-bug-missing-vs-present
(t/testing "BUG FIXED: one shape has :typography-ref-id, other does NOT → returns :multiple"
(let [uuid #uuid "550e8400-e29b-41d4-a716-446655440000"
shape1 {:id :shape1 :typography-ref-id uuid}
shape2 {:id :shape2}
result (attrs/get-attrs-multi [shape1 shape2] [:typography-ref-id] :multi)]
(t/is (= {:typography-ref-id :multiple} result)
"Expected :multiple when one shape has ref and another doesn't")))
(t/testing "both missing → empty map (attribute not in result)"
(let [shape1 {:id :shape1}
shape2 {:id :shape2}
result (attrs/get-attrs-multi [shape1 shape2] [:typography-ref-id] :multi)]
(t/is (= {} result)
"Expected empty map when all shapes are missing the attribute")))
(t/testing "both equal values → return the value"
(let [uuid #uuid "550e8400-e29b-41d4-a716-446655440000"
shape1 {:id :shape1 :typography-ref-id uuid}
shape2 {:id :shape2 :typography-ref-id uuid}
result (attrs/get-attrs-multi [shape1 shape2] [:typography-ref-id] :multi-text)]
(t/is (= {:typography-ref-id uuid} result))))
(t/testing "different values → return :multiple"
(let [uuid1 #uuid "550e8400-e29b-41d4-a716-446655440000"
uuid2 #uuid "550e8400-e29b-41d4-a716-446655440001"
shape1 {:id :shape1 :typography-ref-id uuid1}
shape2 {:id :shape2 :typography-ref-id uuid2}
result (attrs/get-attrs-multi [shape1 shape2] [:typography-ref-id] :multi)]
(t/is (= {:typography-ref-id :multiple} result)))))
(t/deftest get-attrs-multi-default-equal
(t/testing "numbers use close? for equality"
(let [objs [{:value 1.0}
{:value 1.0000001}]
result (attrs/get-attrs-multi objs [:value] :multi)]
(t/is (= {:value 1.0} result)
"Numbers within tolerance should be considered equal")))
(t/testing "different floating point positions beyond tolerance are :multiple"
(let [objs [{:x -26}
{:x -153}]
result (attrs/get-attrs-multi objs [:x] :multi)]
(t/is (= {:x :multiple} result)
"Different positions should be :multiple"))))

View File

@ -8,6 +8,7 @@
(:require
#?(:clj [common-tests.fressian-test])
[clojure.test :as t]
[common-tests.attrs-test]
[common-tests.buffer-test]
[common-tests.colors-test]
[common-tests.data-test]
@ -85,6 +86,7 @@
(defn -main
[& args]
(t/run-tests
'common-tests.attrs-test
'common-tests.buffer-test
'common-tests.colors-test
'common-tests.data-test

View File

@ -0,0 +1,234 @@
import { test, expect } from "@playwright/test";
import { WasmWorkspacePage } from "../pages/WasmWorkspacePage";
test.beforeEach(async ({ page }) => {
await WasmWorkspacePage.init(page);
});
test("Multiselection - check multiple values in measures", async ({ page }) => {
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.mockRPC(
/get\-file\?/,
"workspace/get-file-copy-paste.json",
);
await workspacePage.mockRPC(
"get-file-fragment?file-id=*&fragment-id=*",
"workspace/get-file-copy-paste-fragment.json",
);
await workspacePage.goToWorkspace({
fileId: "870f9f10-87b5-8137-8005-934804124660",
pageId: "870f9f10-87b5-8137-8005-934804124661",
});
// Select first shape (single selection first)
await page.getByTestId("layer-item").getByRole("button").first().click();
await workspacePage.layers.getByTestId("layer-row").nth(0).click();
// === CHECK SINGLE SELECTION - ALL MEASURE FIELDS ===
const measuresSection = workspacePage.rightSidebar.getByRole('region', { name: 'shape-measures-section' });
await expect(measuresSection).toBeVisible();
// Width
const widthInput = measuresSection.getByTitle('Width', { exact: true }).getByRole('textbox');
await expect(widthInput).toHaveValue("360");
// Height
const heightInput = measuresSection.getByTitle('Height', { exact: true }).getByRole('textbox');
await expect(heightInput).toHaveValue("53");
// X Position (using "X axis" title)
const xPosInput = measuresSection.getByTitle('X axis', { exact: true }).getByRole('textbox');
await expect(xPosInput).toHaveValue("1094");
// Y Position (using "Y axis" title)
const yPosInput = measuresSection.getByTitle('Y axis', { exact: true }).getByRole('textbox');
await expect(yPosInput).toHaveValue("856");
// === CHECK MULTI-SELECTION - MIXED VALUES ===
// Shift+click to add second layer to selection
await workspacePage.layers.getByTestId("layer-row").nth(1).click({ modifiers: ['Shift'] });
// All measure fields should show "Mixed" placeholder when values differ
await expect(widthInput).toHaveAttribute('placeholder', 'Mixed');
await expect(heightInput).toHaveAttribute('placeholder', 'Mixed');
await expect(xPosInput).toHaveAttribute('placeholder', 'Mixed');
await expect(yPosInput).toHaveAttribute('placeholder', 'Mixed');
});
test("Multiselection - check typography multiple values", async ({ page }) => {
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.mockRPC(
/get\-file\?/,
"workspace/get-file-copy-paste.json",
);
await workspacePage.mockRPC(
"get-file-fragment?file-id=*&fragment-id=*",
"workspace/get-file-copy-paste-fragment.json",
);
await workspacePage.goToWorkspace({
fileId: "870f9f10-87b5-8137-8005-934804124660",
pageId: "870f9f10-87b5-8137-8005-934804124661",
});
await page.getByTestId("layer-item").getByRole("button").first().click();
await workspacePage.layers.getByTestId("layer-row").nth(0).click();
// Text section
const textSection = workspacePage.rightSidebar.getByRole('region', { name: "Text section" });
await expect(textSection).toBeVisible();
// Single selection - show typography name (not multiple)
await expect(textSection.getByText("Multiple typographies")).not.toBeVisible();
// Multi-selection
await workspacePage.layers.getByTestId("layer-row").nth(1).click({ modifiers: ['Shift'] });
// Should show "Multiple typographies"
await expect(textSection.getByText("Multiple typographies")).toBeVisible();
});
test("Multiselection - check fill multiple values", async ({ page }) => {
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.mockRPC(
/get\-file\?/,
"workspace/get-file-copy-paste.json",
);
await workspacePage.mockRPC(
"get-file-fragment?file-id=*&fragment-id=*",
"workspace/get-file-copy-paste-fragment.json",
);
await workspacePage.goToWorkspace({
fileId: "870f9f10-87b5-8137-8005-934804124660",
pageId: "870f9f10-87b5-8137-8005-934804124661",
});
await page.getByTestId("layer-item").getByRole("button").first().click();
await workspacePage.layers.getByTestId("layer-row").nth(0).click();
// Fill section
const fillSection = workspacePage.rightSidebar.getByRole('region', { name: "Fill section" });
await expect(fillSection).toBeVisible();
// Single selection - fill color should be visible (not "Mixed")
await expect(fillSection.getByText(/Mixed/i)).not.toBeVisible();
// Multi-selection with Shift+click
await workspacePage.layers.getByTestId("layer-row").nth(1).click({ modifiers: ['Shift'] });
// Should show "Mixed" for fills when shapes have different fill colors
await expect(fillSection.getByText('Mixed')).toBeVisible();
});
test("Multiselection - check stroke multiple values", async ({ page }) => {
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.mockRPC(
/get\-file\?/,
"workspace/get-file-copy-paste.json",
);
await workspacePage.mockRPC(
"get-file-fragment?file-id=*&fragment-id=*",
"workspace/get-file-copy-paste-fragment.json",
);
await workspacePage.goToWorkspace({
fileId: "870f9f10-87b5-8137-8005-934804124660",
pageId: "870f9f10-87b5-8137-8005-934804124661",
});
await page.getByTestId("layer-item").getByRole("button").first().click();
await workspacePage.layers.getByTestId("layer-row").nth(0).click();
// Stroke section
const strokeSection = workspacePage.rightSidebar.getByRole('region', { name: "Stroke section" });
await expect(strokeSection).toBeVisible();
// Single selection - stroke should be visible (not "Mixed")
await expect(strokeSection.getByText(/Mixed/i)).not.toBeVisible();
// Multi-selection
await workspacePage.layers.getByTestId("layer-row").nth(1).click({ modifiers: ['Shift'] });
// Should show "Mixed" for strokes when shapes have different stroke colors
await expect(strokeSection.getByText('Mixed')).toBeVisible();
});
test("Multiselection - check rotation multiple values", async ({ page }) => {
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.mockRPC(
/get\-file\?/,
"workspace/get-file-copy-paste.json",
);
await workspacePage.mockRPC(
"get-file-fragment?file-id=*&fragment-id=*",
"workspace/get-file-copy-paste-fragment.json",
);
await workspacePage.goToWorkspace({
fileId: "870f9f10-87b5-8137-8005-934804124660",
pageId: "870f9f10-87b5-8137-8005-934804124661",
});
await page.getByTestId("layer-item").getByRole("button").first().click();
await workspacePage.layers.getByTestId("layer-row").nth(1).click();
// Measures section contains rotation
const measuresSection = workspacePage.rightSidebar.getByRole('region', { name: 'shape-measures-section' });
await expect(measuresSection).toBeVisible();
// Rotation field exists
const rotationInput = measuresSection.getByTitle('Rotation', { exact: true }).getByRole('textbox');
await expect(rotationInput).toBeVisible();
// Rotate that shape
await rotationInput.fill("45");
await page.keyboard.press('Enter');
await expect(rotationInput).toHaveValue("45"); // Rotation should be 45
// Multi-selection
await workspacePage.layers.getByTestId("layer-row").nth(0).click({ modifiers: ['Shift'] });
// Rotation should show "Mixed" placeholder
await expect(rotationInput).toHaveAttribute('placeholder', 'Mixed');
});
test("Multiselection typographies in reverse order", async ({ page }) => {
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.mockRPC(
/get\-file\?/,
"workspace/get-file-copy-paste.json",
);
await workspacePage.mockRPC(
"get-file-fragment?file-id=*&fragment-id=*",
"workspace/get-file-copy-paste-fragment.json",
);
await workspacePage.goToWorkspace({
fileId: "870f9f10-87b5-8137-8005-934804124660",
pageId: "870f9f10-87b5-8137-8005-934804124661",
});
await page.getByTestId("layer-item").getByRole("button").first().click();
await workspacePage.layers.getByTestId("layer-row").nth(1).click();
// Text section
const textSection = workspacePage.rightSidebar.getByRole('region', { name: "Text section" });
await expect(textSection).not.toBeVisible();
// Single selection - show typography name (not multiple)
await expect(textSection.getByText("Multiple typographies")).not.toBeVisible();
// Multi-selection
await workspacePage.layers.getByTestId("layer-row").nth(0).click({ modifiers: ['Shift'] });
await expect(textSection).toBeVisible();
// Should show "Multiple typographies"
await expect(textSection.getByText("Multiple typographies")).toBeVisible();
});

View File

@ -738,7 +738,7 @@ test.describe("Tokens: Apply token", () => {
// Check if token pill is visible on right sidebar
const strokeSectionSidebar = rightSidebar.getByRole("region", {
name: "stroke-section",
name: "Stroke section",
});
await expect(strokeSectionSidebar).toBeVisible();
const firstStrokeRow = strokeSectionSidebar.getByLabel("stroke-row-0");

View File

@ -1016,7 +1016,7 @@
objects (dsh/lookup-page-objects state page-id)
selected (dsh/lookup-selected state)
selected-obj (-> (map #(get objects %) selected))
multi (attrs/get-attrs-multi selected-obj [:proportion-lock])
multi (attrs/get-attrs-multi selected-obj [:proportion-lock] :lock)
multi? (= :multiple (:proportion-lock multi))]
(if multi?
(rx/of (dwsh/update-shapes selected #(assoc % :proportion-lock true)))

View File

@ -251,7 +251,7 @@
(-> (merge default-text-attrs node)
(assoc :fills fills)))
node))))]
(attrs/get-attrs-multi nodes attrs)))
(attrs/get-attrs-multi nodes attrs :text)))
(defn current-root-values
[{:keys [attrs shape]}]

View File

@ -195,7 +195,8 @@
(dom/set-attribute! checkbox "indeterminate" true)
(dom/remove-attribute! checkbox "indeterminate"))))
[:div {:class (stl/css :fill-section)}
[:section {:class (stl/css :fill-section)
:aria-label "Fill section"}
[:div {:class (stl/css :fill-title)}
[:> title-bar* {:collapsable has-fills?
:collapsed (not open?)

View File

@ -89,7 +89,7 @@
open? (:open @state*)
cells (hooks/use-equal-memo cells)
cell (or cell (attrs/get-attrs-multi cells cell-props))
cell (or cell (attrs/get-attrs-multi cells cell-props :grid))
multiple? (= :multiple (:id cell))
cell-ids (if multiple? (->> cells (map :id)) [(:id cell)])

View File

@ -177,7 +177,7 @@
:shape-ids ids}))))]
[:section {:class (stl/css :stroke-section)
:aria-label "stroke-section"}
:aria-label "Stroke section"}
[:div {:class (stl/css :stroke-title)}
[:> title-bar* {:collapsable has-strokes?
:collapsed (not open?)

View File

@ -314,7 +314,8 @@
expand-stream
#(swap! state* assoc-in [:more-options] true))
[:div {:class (stl/css :element-set)}
[:section {:class (stl/css :element-set)
:aria-label "Text section"}
[:div {:class (stl/css :element-title)}
[:> title-bar* {:collapsable true
:collapsed (not main-menu-open?)

View File

@ -208,9 +208,9 @@
merge-attrs
(fn [v1 v2]
(cond
(= attr-group :shadow) (attrs/get-attrs-multi [v1 v2] attrs shadow-eq shadow-sel)
(= attr-group :blur) (attrs/get-attrs-multi [v1 v2] attrs blur-eq blur-sel)
:else (attrs/get-attrs-multi [v1 v2] attrs)))
(= attr-group :shadow) (attrs/get-attrs-multi [v1 v2] attrs :shadow shadow-eq shadow-sel )
(= attr-group :blur) (attrs/get-attrs-multi [v1 v2] attrs :blur blur-eq blur-sel)
:else (attrs/get-attrs-multi [v1 v2] attrs :multi)))
merge-attr
(fn [acc applied-tokens t-attr]