2026-03-29 15:47:02 +00:00
|
|
|
import { expect, test, type Page } from '@playwright/test'
|
|
|
|
|
|
2026-05-05 23:22:08 +00:00
|
|
|
import { CanvasHelper } from '#tests/helpers/canvas'
|
2026-07-14 14:06:28 +00:00
|
|
|
import { propertyItems, propertySection } from '#tests/helpers/properties'
|
2026-05-05 23:22:08 +00:00
|
|
|
|
2026-03-29 15:47:02 +00:00
|
|
|
async function dragSlider(page: Page, canvas: CanvasHelper, testId: string, ratio: number) {
|
2026-07-14 14:06:28 +00:00
|
|
|
const slider = page.getByTestId(testId).locator(':scope > [data-orientation="horizontal"]')
|
2026-03-29 15:47:02 +00:00
|
|
|
const box = await slider.boundingBox()
|
|
|
|
|
if (!box) throw new Error(`Missing slider: ${testId}`)
|
2026-07-14 14:06:28 +00:00
|
|
|
await slider.click({
|
|
|
|
|
position: {
|
|
|
|
|
x: Math.max(2, Math.min(box.width - 2, box.width * ratio)),
|
|
|
|
|
y: box.height / 2
|
|
|
|
|
}
|
2026-03-29 15:47:02 +00:00
|
|
|
})
|
|
|
|
|
await canvas.waitForRender()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function openStrokePicker(page: Page) {
|
2026-07-14 14:06:28 +00:00
|
|
|
await propertyItems(page, 'strokes')
|
|
|
|
|
.first()
|
|
|
|
|
.getByRole('button', { name: 'Stroke', exact: true })
|
|
|
|
|
.click()
|
|
|
|
|
await expect(page.locator('[data-picker-content]')).toBeVisible()
|
2026-03-29 15:47:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function chooseFormat(page: Page, label: 'RGB' | 'HSL' | 'HSB' | 'OkHCL') {
|
2026-05-15 09:49:49 +00:00
|
|
|
await page.getByTestId('color-format-select').click()
|
2026-03-29 15:47:02 +00:00
|
|
|
await page.getByRole('option', { name: label, exact: true }).click()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function getSelectedStroke(page: Page) {
|
|
|
|
|
return page.evaluate(() => {
|
2026-05-06 09:19:39 +00:00
|
|
|
const store = window.openPencil?.getStore?.()
|
2026-05-05 23:59:56 +00:00
|
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
2026-03-29 15:47:02 +00:00
|
|
|
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)
|
2026-07-14 14:06:28 +00:00
|
|
|
await propertySection(page, 'Stroke').getByRole('button', { name: 'Add stroke' }).click()
|
2026-03-29 15:47:02 +00:00
|
|
|
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()
|
2026-05-03 18:03:40 +00:00
|
|
|
expect(
|
|
|
|
|
before?.color.r !== after?.color.r ||
|
|
|
|
|
before?.color.g !== after?.color.g ||
|
|
|
|
|
before?.color.b !== after?.color.b
|
|
|
|
|
).toBe(true)
|
2026-03-29 15:47:02 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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)
|
2026-07-14 14:06:28 +00:00
|
|
|
await propertySection(page, 'Stroke').getByRole('button', { name: 'Add stroke' }).click()
|
2026-03-29 15:47:02 +00:00
|
|
|
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)
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-03 18:03:40 +00:00
|
|
|
test('stroke picker hsb saturation and brightness sliders update stroke color on demo card', async ({
|
|
|
|
|
page
|
|
|
|
|
}) => {
|
2026-03-29 15:47:02 +00:00
|
|
|
const canvas = new CanvasHelper(page)
|
|
|
|
|
await page.goto('/demo')
|
|
|
|
|
await canvas.waitForInit()
|
|
|
|
|
|
|
|
|
|
await page.evaluate(() => {
|
2026-05-06 09:19:39 +00:00
|
|
|
const store = window.openPencil?.getStore?.()
|
2026-05-05 23:59:56 +00:00
|
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
2026-03-29 15:47:02 +00:00
|
|
|
const nodes = Array.from(store.graph.nodes.values())
|
2026-05-03 18:03:40 +00:00
|
|
|
const card =
|
|
|
|
|
nodes.find((node) => node.name === 'Card' && node.type === 'COMPONENT') ??
|
|
|
|
|
nodes.find((node) => node.name === 'Card')
|
2026-03-29 15:47:02 +00:00
|
|
|
if (!card) throw new Error('Card not found')
|
2026-05-03 18:03:40 +00:00
|
|
|
const stroke = {
|
|
|
|
|
color: { r: 0.9, g: 0.9, b: 0.92, a: 1 },
|
|
|
|
|
weight: 1,
|
|
|
|
|
opacity: 1,
|
|
|
|
|
visible: true,
|
|
|
|
|
align: 'INSIDE'
|
|
|
|
|
}
|
2026-03-29 15:47:02 +00:00
|
|
|
store.updateNodeWithUndo(card.id, { strokes: [stroke] }, 'Add demo card stroke')
|
2026-05-14 19:05:43 +00:00
|
|
|
store.select([card.id])
|
2026-03-29 15:47:02 +00:00
|
|
|
})
|
|
|
|
|
await canvas.waitForRender()
|
|
|
|
|
|
2026-07-14 14:06:28 +00:00
|
|
|
await expect(propertyItems(page, 'strokes')).toBeVisible()
|
2026-03-29 15:47:02 +00:00
|
|
|
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()
|
2026-05-03 18:03:40 +00:00
|
|
|
expect(
|
|
|
|
|
beforeS?.color.r !== afterS?.color.r ||
|
|
|
|
|
beforeS?.color.g !== afterS?.color.g ||
|
|
|
|
|
beforeS?.color.b !== afterS?.color.b
|
|
|
|
|
).toBe(true)
|
2026-03-29 15:47:02 +00:00
|
|
|
|
|
|
|
|
const beforeB = await getSelectedStroke(page)
|
|
|
|
|
await dragSlider(page, canvas, 'color-slider-hsb-b', 0.2)
|
|
|
|
|
const afterB = await getSelectedStroke(page)
|
|
|
|
|
expect(afterB).not.toBeNull()
|
2026-05-03 18:03:40 +00:00
|
|
|
expect(
|
|
|
|
|
beforeB?.color.r !== afterB?.color.r ||
|
|
|
|
|
beforeB?.color.g !== afterB?.color.g ||
|
|
|
|
|
beforeB?.color.b !== afterB?.color.b
|
|
|
|
|
).toBe(true)
|
2026-03-29 15:47:02 +00:00
|
|
|
})
|
2026-07-14 14:06:28 +00:00
|
|
|
|
|
|
|
|
test('bound stroke picker is non-destructive, rolls back Escape, and detaches in one undo step', async ({
|
|
|
|
|
page
|
|
|
|
|
}) => {
|
|
|
|
|
const canvas = new CanvasHelper(page)
|
|
|
|
|
await page.goto('/')
|
|
|
|
|
await canvas.waitForInit()
|
|
|
|
|
await canvas.drawRect(120, 120, 180, 120)
|
|
|
|
|
await propertySection(page, 'Stroke').getByRole('button', { name: 'Add stroke' }).click()
|
|
|
|
|
await canvas.waitForRender()
|
|
|
|
|
|
|
|
|
|
const before = await page.evaluate(() => {
|
|
|
|
|
const store = window.openPencil?.getStore?.()
|
|
|
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
|
|
|
|
const collection = store.graph.createCollection('Colors')
|
|
|
|
|
const variable = store.graph.createVariable('stroke-brand', 'COLOR', collection.id, {
|
|
|
|
|
r: 0.9,
|
|
|
|
|
g: 0.1,
|
|
|
|
|
b: 0.2,
|
|
|
|
|
a: 1
|
|
|
|
|
})
|
|
|
|
|
const id = [...store.state.selectedIds][0]
|
|
|
|
|
if (!id) throw new Error('Expected selected node')
|
|
|
|
|
store.graph.bindVariable(id, 'strokes/0/color', variable.id)
|
|
|
|
|
store.state.sceneVersion++
|
|
|
|
|
const node = store.getNode(id)
|
|
|
|
|
return { color: node?.strokes[0]?.color, binding: node?.boundVariables['strokes/0/color'] }
|
|
|
|
|
})
|
|
|
|
|
await canvas.waitForRender()
|
|
|
|
|
|
|
|
|
|
await openStrokePicker(page)
|
|
|
|
|
expect(
|
|
|
|
|
await page.evaluate(() => {
|
|
|
|
|
const store = window.openPencil?.getStore?.()
|
|
|
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
|
|
|
|
const id = [...store.state.selectedIds][0]
|
|
|
|
|
return id ? store.getNode(id)?.boundVariables['strokes/0/color'] : undefined
|
|
|
|
|
})
|
|
|
|
|
).toBe(before.binding)
|
|
|
|
|
|
|
|
|
|
await dragSlider(page, canvas, 'color-slider-hue', 0.55)
|
|
|
|
|
await page.keyboard.press('Escape')
|
|
|
|
|
await canvas.waitForRender()
|
|
|
|
|
const afterEscape = await page.evaluate(() => {
|
|
|
|
|
const store = window.openPencil?.getStore?.()
|
|
|
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
|
|
|
|
const id = [...store.state.selectedIds][0]
|
|
|
|
|
const node = id ? store.getNode(id) : null
|
|
|
|
|
return { color: node?.strokes[0]?.color, binding: node?.boundVariables['strokes/0/color'] }
|
|
|
|
|
})
|
|
|
|
|
expect(afterEscape).toEqual(before)
|
|
|
|
|
|
|
|
|
|
await openStrokePicker(page)
|
|
|
|
|
await dragSlider(page, canvas, 'color-slider-hue', 0.75)
|
|
|
|
|
await propertyItems(page, 'strokes')
|
|
|
|
|
.first()
|
|
|
|
|
.getByRole('button', { name: 'Stroke', exact: true })
|
|
|
|
|
.click()
|
|
|
|
|
await canvas.waitForRender()
|
|
|
|
|
expect((await getSelectedStroke(page))?.color).not.toEqual(before.color)
|
|
|
|
|
|
|
|
|
|
await canvas.undo()
|
|
|
|
|
await canvas.waitForRender()
|
|
|
|
|
const restored = await page.evaluate(() => {
|
|
|
|
|
const store = window.openPencil?.getStore?.()
|
|
|
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
|
|
|
|
const id = [...store.state.selectedIds][0]
|
|
|
|
|
const node = id ? store.getNode(id) : null
|
|
|
|
|
return { color: node?.strokes[0]?.color, binding: node?.boundVariables['strokes/0/color'] }
|
|
|
|
|
})
|
|
|
|
|
expect(restored).toEqual(before)
|
|
|
|
|
})
|