From e65b56820ba3e22cd6c83a770e288e56798dc1bd Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 29 Mar 2026 18:47:02 +0300 Subject: [PATCH] Add color picker regression tests --- tests/e2e/color-picker-demo-card.spec.ts | 94 +++++++++++++++++ tests/e2e/color-picker.spec.ts | 126 +++++++++++++++++++++++ tests/e2e/stroke-picker.spec.ts | 108 +++++++++++++++++++ tests/engine/color-picker-model.test.ts | 67 ++++++++++++ tests/engine/solid-color-commit.test.ts | 34 ++++++ 5 files changed, 429 insertions(+) create mode 100644 tests/e2e/color-picker-demo-card.spec.ts create mode 100644 tests/e2e/color-picker.spec.ts create mode 100644 tests/e2e/stroke-picker.spec.ts create mode 100644 tests/engine/color-picker-model.test.ts create mode 100644 tests/engine/solid-color-commit.test.ts diff --git a/tests/e2e/color-picker-demo-card.spec.ts b/tests/e2e/color-picker-demo-card.spec.ts new file mode 100644 index 000000000..1c41b31d3 --- /dev/null +++ b/tests/e2e/color-picker-demo-card.spec.ts @@ -0,0 +1,94 @@ +import { expect, test } from '@playwright/test' + +import { CanvasHelper } from '../helpers/canvas' + +async function dragSlider(page: Parameters[0]['page'], canvas: CanvasHelper, testId: string, ratio: number) { + const slider = page.locator(`[data-test-id="${testId}"] input[type="range"]`) + const box = await slider.boundingBox() + if (!box) throw new Error(`Missing slider: ${testId}`) + const y = box.y + box.height / 2 + await page.mouse.move(box.x + 2, y) + await page.mouse.down() + await page.mouse.move(box.x + Math.max(2, Math.min(box.width - 2, box.width * ratio)), y, { + steps: 8 + }) + await page.mouse.up() + await canvas.waitForRender() +} + +async function selectDemoCard(page: Parameters[0]['page'], canvas: CanvasHelper) { + await page.goto('/demo') + await canvas.waitForInit() + + await page.evaluate(() => { + const store = window.__OPEN_PENCIL_STORE__! + const nodes = Array.from(store.graph.nodes.values()) + const card = nodes.find((node) => node.name === 'Card' && node.type === 'COMPONENT') + ?? nodes.find((node) => node.name === 'Card') + if (!card) throw new Error(`Card not found. Available: ${nodes.slice(0, 20).map((n) => `${n.name}:${n.type}`).join(', ')}`) + store.state.selectedIds = new Set([card.id]) + store.requestRender() + }) + await canvas.waitForRender() + + await expect(page.locator('[data-test-id="design-panel-single"]')).toBeVisible() + await expect(page.locator('[data-test-id="design-node-header"]')).toContainText('Card') +} + +async function getSelectedFill(page: Parameters[0]['page']) { + return page.evaluate(() => { + const store = window.__OPEN_PENCIL_STORE__! + const id = [...store.state.selectedIds][0] + const node = store.graph.getNode(id) + return node + ? { + id: node.id, + name: node.name, + type: node.type, + fill: node.fills?.[0] ?? null + } + : null + }) +} + +test('demo card fill changes through color picker', async ({ page }) => { + const canvas = new CanvasHelper(page) + await selectDemoCard(page, canvas) + + const before = await getSelectedFill(page) + + await page.locator('[data-test-id="fill-picker-swatch"]').first().click() + await expect(page.locator('[data-test-id="fill-picker-tab-solid"]')).toBeVisible() + + await dragSlider(page, canvas, 'color-slider-hue', 0.7) + + const after = await getSelectedFill(page) + + expect(after).not.toBeNull() + expect(before?.fill?.color.r !== after?.fill?.color.r || before?.fill?.color.g !== after?.fill?.color.g || before?.fill?.color.b !== after?.fill?.color.b).toBe(true) +}) + + +test('demo card fill changes from hsb saturation and brightness sliders', async ({ page }) => { + const canvas = new CanvasHelper(page) + await selectDemoCard(page, canvas) + + await page.locator('[data-test-id="fill-picker-swatch"]').first().click() + await expect(page.locator('[data-test-id="fill-picker-tab-solid"]')).toBeVisible() + await page.locator('[data-test-id="color-format-select"]').click() + await page.getByRole('option', { name: 'HSB', exact: true }).click() + + const beforeS = await getSelectedFill(page) + await dragSlider(page, canvas, 'color-slider-hsb-s', 0.5) + const afterS = await getSelectedFill(page) + + expect(afterS).not.toBeNull() + expect(beforeS?.fill?.color.r !== afterS?.fill?.color.r || beforeS?.fill?.color.g !== afterS?.fill?.color.g || beforeS?.fill?.color.b !== afterS?.fill?.color.b).toBe(true) + + const beforeB = await getSelectedFill(page) + await dragSlider(page, canvas, 'color-slider-hsb-b', 0.25) + const afterB = await getSelectedFill(page) + + expect(afterB).not.toBeNull() + expect(beforeB?.fill?.color.r !== afterB?.fill?.color.r || beforeB?.fill?.color.g !== afterB?.fill?.color.g || beforeB?.fill?.color.b !== afterB?.fill?.color.b).toBe(true) +}) diff --git a/tests/e2e/color-picker.spec.ts b/tests/e2e/color-picker.spec.ts new file mode 100644 index 000000000..22a3a2d44 --- /dev/null +++ b/tests/e2e/color-picker.spec.ts @@ -0,0 +1,126 @@ +import { expect, test, type Page } from '@playwright/test' + +import { CanvasHelper } from '../helpers/canvas' + +let page: Page +let canvas: CanvasHelper + +test.describe.configure({ mode: 'serial' }) + +test.beforeAll(async ({ browser }) => { + page = await browser.newPage() + await page.goto('/') + canvas = new CanvasHelper(page) + await canvas.waitForInit() +}) + +test.afterAll(async () => { + await page.close() +}) + +async function getSelectedId() { + return page.evaluate(() => { + const store = window.__OPEN_PENCIL_STORE__! + return [...store.state.selectedIds][0] ?? null + }) +} + +async function getSelectedFill() { + return page.evaluate(() => { + const store = window.__OPEN_PENCIL_STORE__! + const id = [...store.state.selectedIds][0] + if (!id) return null + const node = store.graph.getNode(id) + return node?.fills?.[0] ?? null + }) +} + +async function openFillPicker() { + const solidTab = page.locator('[data-test-id="fill-picker-tab-solid"]') + if (await solidTab.isVisible().catch(() => false)) return + const swatch = page.locator('[data-test-id="fill-picker-swatch"]').first() + await swatch.click() + await expect(solidTab).toBeVisible() +} + +async function chooseFormat(label: 'RGB' | 'HSL' | 'HSB' | 'OkHCL') { + await page.locator('[data-test-id="color-format-select"]').click() + await page.getByRole('option', { name: label, exact: true }).click() +} + +async function dragSlider(testId: string, ratio: number) { + const slider = page.locator(`[data-test-id="${testId}"] input[type="range"]`) + const box = await slider.boundingBox() + if (!box) throw new Error(`Missing slider: ${testId}`) + const y = box.y + box.height / 2 + await page.mouse.move(box.x + 2, y) + await page.mouse.down() + await page.mouse.move(box.x + Math.max(2, Math.min(box.width - 2, box.width * ratio)), y, { + steps: 20 + }) + await page.mouse.up() + await canvas.waitForRender() +} + +test('rgb hue slider updates selected fill color', async () => { + await canvas.clearCanvas() + await canvas.drawRect(100, 100, 160, 120) + await canvas.waitForRender() + + await openFillPicker() + const before = await getSelectedFill() + await dragSlider('color-slider-hue', 0.65) + const after = await getSelectedFill() + + expect(after).not.toBeNull() + expect(before?.color.r !== after?.color.r || before?.color.g !== after?.color.g || before?.color.b !== after?.color.b).toBe(true) +}) + +test('rgb alpha slider updates fill opacity and alpha', async () => { + await openFillPicker() + await dragSlider('color-slider-alpha', 0.3) + const after = await getSelectedFill() + + expect(after).not.toBeNull() + expect(after?.opacity).toBeLessThan(1) + expect(after?.color.a).toBeCloseTo(after?.opacity ?? 0, 3) +}) + +test('hsl saturation slider changes saturation', async () => { + await openFillPicker() + await chooseFormat('HSL') + const before = await getSelectedFill() + await dragSlider('color-slider-hsl-s', 0.2) + const after = await getSelectedFill() + + expect(after).not.toBeNull() + expect(before?.color.r !== after?.color.r || before?.color.g !== after?.color.g || before?.color.b !== after?.color.b).toBe(true) +}) + +test('hsl lightness slider changes color independently', async () => { + await openFillPicker() + await chooseFormat('HSL') + const before = await getSelectedFill() + await dragSlider('color-slider-hsl-l', 0.8) + const after = await getSelectedFill() + + expect(after).not.toBeNull() + expect(before?.color.r !== after?.color.r || before?.color.g !== after?.color.g || before?.color.b !== after?.color.b).toBe(true) +}) + +test('hsb saturation and brightness sliders both affect fill color', async () => { + await openFillPicker() + await chooseFormat('HSB') + + const beforeS = await getSelectedFill() + await dragSlider('color-slider-hsb-s', 0.15) + const afterS = await getSelectedFill() + expect(afterS).not.toBeNull() + expect(beforeS?.color.r !== afterS?.color.r || beforeS?.color.g !== afterS?.color.g || beforeS?.color.b !== afterS?.color.b).toBe(true) + + const beforeB = await getSelectedFill() + await dragSlider('color-slider-hsb-b', 0.9) + const afterB = await getSelectedFill() + expect(afterB).not.toBeNull() + expect(beforeB?.color.r !== afterB?.color.r || beforeB?.color.g !== afterB?.color.g || beforeB?.color.b !== afterB?.color.b).toBe(true) +}) diff --git a/tests/e2e/stroke-picker.spec.ts b/tests/e2e/stroke-picker.spec.ts new file mode 100644 index 000000000..b3388f769 --- /dev/null +++ b/tests/e2e/stroke-picker.spec.ts @@ -0,0 +1,108 @@ +import { expect, test, type Page } from '@playwright/test' + +import { CanvasHelper } from '../helpers/canvas' + +async function dragSlider(page: Page, canvas: CanvasHelper, testId: string, ratio: number) { + const slider = page.locator(`[data-test-id="${testId}"] input[type="range"]`) + const box = await slider.boundingBox() + if (!box) throw new Error(`Missing slider: ${testId}`) + const y = box.y + box.height / 2 + await page.mouse.move(box.x + 2, y) + await page.mouse.down() + await page.mouse.move(box.x + Math.max(2, Math.min(box.width - 2, box.width * ratio)), y, { + steps: 20 + }) + await page.mouse.up() + await canvas.waitForRender() +} + +async function openStrokePicker(page: Page) { + await page.locator('[data-test-id="stroke-item"] [data-test-id="color-picker-popover"]').waitFor({ state: 'detached' }).catch(() => {}) + await page.locator('[data-test-id="stroke-item"] button').first().click() + await expect(page.locator('[data-test-id="color-picker-popover"]')).toBeVisible() +} + +async function chooseFormat(page: Page, label: 'RGB' | 'HSL' | 'HSB' | 'OkHCL') { + await page.locator('[data-test-id="color-format-select"]').click() + await page.getByRole('option', { name: label, exact: true }).click() +} + +async function getSelectedStroke(page: Page) { + return page.evaluate(() => { + const store = window.__OPEN_PENCIL_STORE__! + const id = [...store.state.selectedIds][0] + const node = store.graph.getNode(id) + return node?.strokes?.[0] ?? null + }) +} + +test('stroke picker updates stroke color on a rectangle', async ({ page }) => { + const canvas = new CanvasHelper(page) + await page.goto('/') + await canvas.waitForInit() + + await canvas.drawRect(120, 120, 180, 120) + await page.locator('[data-test-id="stroke-section-add"]').click() + await canvas.waitForRender() + + const before = await getSelectedStroke(page) + await openStrokePicker(page) + await dragSlider(page, canvas, 'color-slider-hue', 0.7) + const after = await getSelectedStroke(page) + + expect(after).not.toBeNull() + expect(before?.color.r !== after?.color.r || before?.color.g !== after?.color.g || before?.color.b !== after?.color.b).toBe(true) +}) + +test('stroke picker alpha slider updates stroke opacity and alpha', async ({ page }) => { + const canvas = new CanvasHelper(page) + await page.goto('/') + await canvas.waitForInit() + + await canvas.drawRect(120, 120, 180, 120) + await page.locator('[data-test-id="stroke-section-add"]').click() + await canvas.waitForRender() + + await openStrokePicker(page) + await dragSlider(page, canvas, 'color-slider-alpha', 0.25) + const after = await getSelectedStroke(page) + + expect(after).not.toBeNull() + expect(after?.opacity).toBeLessThan(1) + expect(after?.color.a).toBeCloseTo(after?.opacity ?? 0, 3) +}) + +test('stroke picker hsb saturation and brightness sliders update stroke color on demo card', async ({ page }) => { + const canvas = new CanvasHelper(page) + await page.goto('/demo') + await canvas.waitForInit() + + await page.evaluate(() => { + const store = window.__OPEN_PENCIL_STORE__! + const nodes = Array.from(store.graph.nodes.values()) + const card = nodes.find((node) => node.name === 'Card' && node.type === 'COMPONENT') + ?? nodes.find((node) => node.name === 'Card') + if (!card) throw new Error('Card not found') + const stroke = { color: { r: 0.9, g: 0.9, b: 0.92, a: 1 }, weight: 1, opacity: 1, visible: true, align: 'INSIDE' } + store.updateNodeWithUndo(card.id, { strokes: [stroke] }, 'Add demo card stroke') + store.state.selectedIds = new Set([card.id]) + store.requestRender() + }) + await canvas.waitForRender() + + await expect(page.locator('[data-test-id="stroke-item"]')).toBeVisible() + await openStrokePicker(page) + await chooseFormat(page, 'HSB') + + const beforeS = await getSelectedStroke(page) + await dragSlider(page, canvas, 'color-slider-hsb-s', 0.6) + const afterS = await getSelectedStroke(page) + expect(afterS).not.toBeNull() + expect(beforeS?.color.r !== afterS?.color.r || beforeS?.color.g !== afterS?.color.g || beforeS?.color.b !== afterS?.color.b).toBe(true) + + const beforeB = await getSelectedStroke(page) + await dragSlider(page, canvas, 'color-slider-hsb-b', 0.2) + const afterB = await getSelectedStroke(page) + expect(afterB).not.toBeNull() + expect(beforeB?.color.r !== afterB?.color.r || beforeB?.color.g !== afterB?.color.g || beforeB?.color.b !== afterB?.color.b).toBe(true) +}) diff --git a/tests/engine/color-picker-model.test.ts b/tests/engine/color-picker-model.test.ts new file mode 100644 index 000000000..2e41c365a --- /dev/null +++ b/tests/engine/color-picker-model.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from 'bun:test' + +import { + createColorPickerModel, + fromPercent, + toPercent, + updateAlpha, + updateHSBChannel, + updateHSLChannel, + updateHue, + updateRGBChannel +} from '@/components/color-picker/model' + +import type { Color } from '@open-pencil/core' + +describe('color picker model', () => { + const base: Color = { r: 0.4, g: 0.2, b: 0.6, a: 0.75 } + + test('updates hue from the shared slider', () => { + const model = createColorPickerModel(base) + const updated = updateHue(model, 180) + expect(updated).not.toEqual(base) + expect(updated.a).toBeCloseTo(base.a, 5) + }) + + test('updates hue from grayscale colors without staying neutral', () => { + const grayscale: Color = { r: 1, g: 1, b: 1, a: 1 } + const updated = updateHue(createColorPickerModel(grayscale), 220) + expect(updated.r === updated.g && updated.g === updated.b).toBe(false) + }) + + test('updates alpha independently', () => { + const updated = updateAlpha(base, 0.25) + expect(updated.a).toBeCloseTo(0.25, 5) + expect(updated.r).toBeCloseTo(base.r, 5) + }) + + test('updates rgb channels in 0-255 space', () => { + const updated = updateRGBChannel(base, 'r', 255) + expect(updated.r).toBeCloseTo(1, 5) + expect(updated.g).toBeCloseTo(base.g, 5) + }) + + test('updates hsl normalized channels smoothly', () => { + const model = createColorPickerModel(base) + const updated = updateHSLChannel(model, 's', 12.3) + expect(updated.a).toBeCloseTo(base.a, 5) + expect(updated).not.toEqual(base) + }) + + test('updates hsl saturation from grayscale colors without staying neutral', () => { + const grayscale: Color = { r: 1, g: 1, b: 1, a: 1 } + const updated = updateHSLChannel(createColorPickerModel(grayscale), 's', 40) + expect(updated.r === updated.g && updated.g === updated.b).toBe(false) + }) + + test('updates hsb normalized channels smoothly', () => { + const model = createColorPickerModel(base) + const updated = updateHSBChannel(model, 'b', 45.6) + expect(updated.a).toBeCloseTo(base.a, 5) + expect(updated).not.toEqual(base) + }) + + test('percent helpers roundtrip safely', () => { + expect(fromPercent(toPercent(0.347))).toBeCloseTo(0.35, 2) + }) +}) diff --git a/tests/engine/solid-color-commit.test.ts b/tests/engine/solid-color-commit.test.ts new file mode 100644 index 000000000..ab760d543 --- /dev/null +++ b/tests/engine/solid-color-commit.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from 'bun:test' + +import { applySolidFillColor, applySolidStrokeColor } from '@/components/color-picker/solid-color-commit' + +import type { Fill, Stroke } from '@open-pencil/core' + +describe('solid color commit helpers', () => { + test('syncs fill opacity with color alpha', () => { + const fill: Fill = { + type: 'SOLID', + visible: true, + opacity: 1, + color: { r: 1, g: 0, b: 0, a: 1 } + } + + const updated = applySolidFillColor(fill, { r: 0, g: 1, b: 0, a: 0.4 }) + expect(updated.color.a).toBeCloseTo(0.4, 5) + expect(updated.opacity).toBeCloseTo(0.4, 5) + }) + + test('syncs stroke opacity with color alpha', () => { + const stroke: Stroke = { + color: { r: 0, g: 0, b: 1, a: 1 }, + weight: 1, + opacity: 1, + visible: true, + align: 'CENTER' + } + + const updated = applySolidStrokeColor({ r: 1, g: 1, b: 0, a: 0.2 }) + expect(updated.color?.a).toBeCloseTo(0.2, 5) + expect(updated.opacity).toBeCloseTo(0.2, 5) + }) +})