diff --git a/tests/e2e/code-panel.spec.ts b/tests/e2e/code-panel.spec.ts new file mode 100644 index 000000000..cc952f13c --- /dev/null +++ b/tests/e2e/code-panel.spec.ts @@ -0,0 +1,120 @@ +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() +}) + +function codeTab() { + return page.locator('[data-test-id="properties-tab-code"]') +} + +function designTab() { + return page.locator('[data-test-id="properties-tab-design"]') +} + +function codePanel() { + return page.locator('[data-test-id="code-panel"]') +} + +function codePanelEmpty() { + return page.locator('[data-test-id="code-panel-empty"]') +} + +function formatToggle() { + return page.locator('[data-test-id="code-panel-format-toggle"]') +} + +function copyButton() { + return page.locator('[data-test-id="code-panel-copy"]') +} + +test('Code tab shows empty state with no selection', async () => { + await codeTab().click() + await expect(codePanelEmpty()).toBeVisible() + await expect(codePanelEmpty()).toContainText('Select a layer') +}) + +test('selecting a rectangle shows JSX code', async () => { + await canvas.drawRect(100, 100, 200, 150) + await canvas.waitForRender() + + await expect(codePanel()).toBeVisible() + + const code = await page.evaluate(() => { + const el = document.querySelector('[data-test-id="code-panel"]') + return el?.textContent ?? '' + }) + expect(code).toContain('Rectangle') +}) + +test('format toggle switches between OpenPencil and Tailwind', async () => { + await expect(formatToggle()).toBeVisible() + + const initialFormat = await formatToggle().textContent() + expect(initialFormat).toContain('OpenPencil') + + await formatToggle().click() + await expect(formatToggle()).toContainText('Tailwind') + + const code = await page.evaluate(() => { + const el = document.querySelector('[data-test-id="code-panel"]') + return el?.textContent ?? '' + }) + expect(code).toContain('div') + + await formatToggle().click() + await expect(formatToggle()).toContainText('OpenPencil') +}) + +test('copy button works and shows confirmation', async () => { + await copyButton().click() + + await expect(copyButton()).toContainText('Copied') + + await page.waitForTimeout(2500) + await expect(copyButton()).toContainText('Copy') +}) + +test('deselecting shows empty state again', async () => { + await page.keyboard.press('Escape') + await canvas.waitForRender() + + await expect(codePanelEmpty()).toBeVisible() +}) + +test('selecting a frame shows Frame in JSX', async () => { + // Create a frame via store to avoid click-targeting issues + await page.evaluate(() => { + const store = window.__OPEN_PENCIL_STORE__! + const id = store.createShape('FRAME', 300, 100, 200, 200) + store.select([id]) + }) + await canvas.waitForRender() + + const code = await page.evaluate(() => { + const el = document.querySelector('[data-test-id="code-panel"]') + return el?.textContent ?? '' + }) + expect(code).toContain('Frame') +}) + +test('switching back to Design tab works', async () => { + await designTab().click() + + const panel = page.locator('[data-test-id="design-panel-single"], [data-test-id="design-panel-empty"]') + await expect(panel.first()).toBeVisible() +}) diff --git a/tests/e2e/components.spec.ts b/tests/e2e/components.spec.ts new file mode 100644 index 000000000..2d2b452b4 --- /dev/null +++ b/tests/e2e/components.spec.ts @@ -0,0 +1,169 @@ +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() +}) + +function getNodeById(id: string) { + return page.evaluate((nodeId) => { + const store = window.__OPEN_PENCIL_STORE__! + const n = store.graph.getNode(nodeId) + if (!n) return null + return { type: n.type, name: n.name, componentId: n.componentId, childIds: n.childIds } + }, id) +} + +function getSelectedIds() { + return page.evaluate(() => [...window.__OPEN_PENCIL_STORE__!.state.selectedIds]) +} + +function getPageChildren() { + return page.evaluate(() => { + const store = window.__OPEN_PENCIL_STORE__! + return store.graph.getChildren(store.state.currentPageId).map((n) => ({ + id: n.id, + type: n.type, + name: n.name, + componentId: n.componentId + })) + }) +} + +let componentId: string + +test('create component from selection (⌘⌥K)', async () => { + await canvas.drawRect(100, 100, 120, 80) + await canvas.waitForRender() + + await page.keyboard.press('Meta+Alt+k') + await canvas.waitForRender() + + const ids = await getSelectedIds() + expect(ids).toHaveLength(1) + + const node = await getNodeById(ids[0]) + expect(node!.type).toBe('COMPONENT') + componentId = ids[0] +}) + +test('component shows purple label in design panel', async () => { + const header = page.locator('[data-test-id="design-node-header"]') + await expect(header).toContainText('COMPONENT') +}) + +test('component visible in layers panel', async () => { + const layers = page.locator('[data-node-id]') + const count = await layers.count() + expect(count).toBeGreaterThan(0) + + const types = await page.evaluate(() => { + const store = window.__OPEN_PENCIL_STORE__! + return store.graph.getChildren(store.state.currentPageId).map((n) => n.type) + }) + expect(types).toContain('COMPONENT') +}) + +test('create instance from component (context menu)', async () => { + const children = await getPageChildren() + const comp = children.find((c) => c.type === 'COMPONENT') + expect(comp).toBeTruthy() + + // Use store directly to create instance + await page.evaluate((compId) => { + const store = window.__OPEN_PENCIL_STORE__! + store.createInstanceFromComponent(compId, 300, 100) + }, comp!.id) + await canvas.waitForRender() + + const updated = await getPageChildren() + const instance = updated.find((c) => c.type === 'INSTANCE') + expect(instance).toBeTruthy() + expect(instance!.componentId).toBe(comp!.id) +}) + +test('instance shows INSTANCE type in design panel', async () => { + const children = await getPageChildren() + const instance = children.find((c) => c.type === 'INSTANCE')! + + await page.evaluate((id) => { + window.__OPEN_PENCIL_STORE__!.select([id]) + }, instance.id) + await canvas.waitForRender() + + const header = page.locator('[data-test-id="design-node-header"]') + await expect(header).toContainText('INSTANCE') +}) + +test('instance has "Go to Main Component" button', async () => { + const goToBtn = page.locator('[data-test-id="design-go-to-component"]') + await expect(goToBtn).toBeVisible() +}) + +test('instance has "Detach" button', async () => { + const detachBtn = page.locator('[data-test-id="design-detach-instance"]') + await expect(detachBtn).toBeVisible() +}) + +test('modifying component propagates to instance', async () => { + // Select the component + await page.evaluate((id) => { + window.__OPEN_PENCIL_STORE__!.select([id]) + }, componentId) + await canvas.waitForRender() + + // Change component fill + await page.evaluate((id) => { + const store = window.__OPEN_PENCIL_STORE__! + store.updateNodeWithUndo(id, { + fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true, blendMode: 'NORMAL' }] + }, 'Change fill') + }, componentId) + await canvas.waitForRender() + + // Check instance got the same fill + const children = await getPageChildren() + const instance = children.find((c) => c.type === 'INSTANCE')! + const instanceNode = await page.evaluate((id) => { + const store = window.__OPEN_PENCIL_STORE__! + const n = store.graph.getNode(id) + const child = store.graph.getChildren(id)[0] + return child ? { fills: child.fills } : { fills: n?.fills ?? [] } + }, instance.id) + + expect(instanceNode.fills.length).toBeGreaterThan(0) +}) + +test('detach instance converts to frame', async () => { + const children = await getPageChildren() + const instance = children.find((c) => c.type === 'INSTANCE')! + + await page.evaluate((id) => { + window.__OPEN_PENCIL_STORE__!.select([id]) + }, instance.id) + await canvas.waitForRender() + + await page.evaluate(() => { + window.__OPEN_PENCIL_STORE__!.detachInstance() + }) + await canvas.waitForRender() + + const ids = await getSelectedIds() + const detached = await getNodeById(ids[0]) + expect(detached!.type).toBe('FRAME') + + canvas.assertNoErrors() +}) diff --git a/tests/e2e/context-menu.spec.ts b/tests/e2e/context-menu.spec.ts new file mode 100644 index 000000000..471ac2900 --- /dev/null +++ b/tests/e2e/context-menu.spec.ts @@ -0,0 +1,222 @@ +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() +}) + +function getPageChildren() { + return page.evaluate(() => { + const store = window.__OPEN_PENCIL_STORE__! + return store.graph.getChildren(store.state.currentPageId).map((n) => ({ + id: n.id, + type: n.type, + name: n.name, + visible: n.visible, + locked: n.locked + })) + }) +} + +function getSelectedCount() { + return page.evaluate(() => window.__OPEN_PENCIL_STORE__!.state.selectedIds.size) +} + +async function rightClickShape(x: number, y: number) { + const box = await canvas.canvas.boundingBox() + await page.mouse.click(box!.x + x, box!.y + y, { button: 'right' }) +} + +function contextMenu() { + return page.locator('[role="menu"]') +} + +function contextItem(testId: string) { + return page.locator(`[data-test-id="${testId}"]`) +} + +test('right-click on empty canvas shows context menu without selection items disabled', async () => { + await rightClickShape(500, 400) + + await expect(contextMenu()).toBeVisible() + + const copyItem = contextItem('context-copy') + await expect(copyItem).toBeVisible() + + await page.keyboard.press('Escape') +}) + +test('draw shape and right-click selects it', async () => { + await canvas.drawRect(200, 200, 120, 80) + await canvas.waitForRender() + + // Deselect first + await page.keyboard.press('Escape') + await canvas.waitForRender() + + // Right-click the shape + await rightClickShape(250, 230) + + expect(await getSelectedCount()).toBe(1) + await expect(contextMenu()).toBeVisible() +}) + +test('context menu shows expected items', async () => { + await expect(contextItem('context-copy')).toBeVisible() + await expect(contextItem('context-cut')).toBeVisible() + await expect(contextItem('context-duplicate')).toBeVisible() + await expect(contextItem('context-delete')).toBeVisible() + await expect(contextItem('context-group')).toBeVisible() + await expect(contextItem('context-bring-to-front')).toBeVisible() + await expect(contextItem('context-send-to-back')).toBeVisible() + await expect(contextItem('context-toggle-visibility')).toBeVisible() + await expect(contextItem('context-toggle-lock')).toBeVisible() + + await page.keyboard.press('Escape') +}) + +test('duplicate via context menu works', async () => { + const countBefore = (await getPageChildren()).length + + await rightClickShape(250, 230) + await contextItem('context-duplicate').click() + await canvas.waitForRender() + + const countAfter = (await getPageChildren()).length + expect(countAfter).toBe(countBefore + 1) +}) + +test('toggle visibility via context menu', async () => { + await canvas.click(250, 230) + await canvas.waitForRender() + + const nodeId = await page.evaluate(() => [...window.__OPEN_PENCIL_STORE__!.state.selectedIds][0]) + + await rightClickShape(250, 230) + await contextItem('context-toggle-visibility').click() + await canvas.waitForRender() + + const hidden = await page.evaluate((id) => { + const n = window.__OPEN_PENCIL_STORE__!.graph.getNode(id) + return n ? { visible: n.visible } : null + }, nodeId) + expect(hidden!.visible).toBe(false) + + // Toggle back: select via store since invisible nodes can't be hit-tested + await page.evaluate((id) => { + const store = window.__OPEN_PENCIL_STORE__! + store.toggleVisibility() + }, nodeId) + await canvas.waitForRender() + + const restored = await page.evaluate((id) => { + const n = window.__OPEN_PENCIL_STORE__!.graph.getNode(id) + return n ? { visible: n.visible } : null + }, nodeId) + expect(restored!.visible).toBe(true) +}) + +test('toggle lock via context menu', async () => { + await rightClickShape(250, 230) + await contextItem('context-toggle-lock').click() + await canvas.waitForRender() + + const children = await getPageChildren() + const locked = children.find((c) => c.locked) + expect(locked).toBeTruthy() + + // Unlock + await rightClickShape(250, 230) + await contextItem('context-toggle-lock').click() + await canvas.waitForRender() + + const after = await getPageChildren() + expect(after.every((c) => !c.locked)).toBe(true) +}) + +test('delete via context menu removes node', async () => { + const countBefore = (await getPageChildren()).length + + await rightClickShape(250, 230) + await contextItem('context-delete').click() + await canvas.waitForRender() + + const countAfter = (await getPageChildren()).length + expect(countAfter).toBe(countBefore - 1) +}) + +test('group via context menu', async () => { + await canvas.clearCanvas() + await canvas.drawRect(100, 100, 60, 60) + await canvas.drawRect(200, 100, 60, 60) + await canvas.selectAll() + await canvas.waitForRender() + + await rightClickShape(130, 130) + await contextItem('context-group').click() + await canvas.waitForRender() + + const children = await getPageChildren() + const group = children.find((c) => c.type === 'GROUP') + expect(group).toBeTruthy() +}) + +test('ungroup via store after context-menu group', async () => { + // Groups are click-through, so ungroup via store instead + await page.evaluate(() => { + const store = window.__OPEN_PENCIL_STORE__! + const group = store.graph.getChildren(store.state.currentPageId).find((n) => n.type === 'GROUP') + if (group) store.select([group.id]) + store.ungroupSelected() + }) + await canvas.waitForRender() + + const children = await getPageChildren() + expect(children.every((c) => c.type !== 'GROUP')).toBe(true) + expect(children.length).toBe(2) + + canvas.assertNoErrors() +}) + +test('create component via context menu', async () => { + await canvas.click(130, 130) + await canvas.waitForRender() + + await rightClickShape(130, 130) + await contextItem('context-create-component').click() + await canvas.waitForRender() + + const children = await getPageChildren() + const comp = children.find((c) => c.type === 'COMPONENT') + expect(comp).toBeTruthy() + + canvas.assertNoErrors() +}) + +test('Copy/Paste as submenu exists', async () => { + await rightClickShape(130, 130) + + const submenuTrigger = contextItem('context-copy-paste-as') + await expect(submenuTrigger).toBeVisible() + + await submenuTrigger.hover() + await page.waitForTimeout(300) + + await expect(contextItem('context-copy-as-svg')).toBeVisible() + await expect(contextItem('context-copy-as-jsx')).toBeVisible() + + await page.keyboard.press('Escape') +}) diff --git a/tests/e2e/copy-paste.spec.ts b/tests/e2e/copy-paste.spec.ts new file mode 100644 index 000000000..bb20db197 --- /dev/null +++ b/tests/e2e/copy-paste.spec.ts @@ -0,0 +1,135 @@ +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() +}) + +function getPageChildCount() { + return page.evaluate(() => { + const store = window.__OPEN_PENCIL_STORE__! + return store.graph.getChildren(store.state.currentPageId).length + }) +} + +function getSelectedCount() { + return page.evaluate(() => window.__OPEN_PENCIL_STORE__!.state.selectedIds.size) +} + +function getSelectedNodes() { + return page.evaluate(() => { + const store = window.__OPEN_PENCIL_STORE__! + return [...store.state.selectedIds].map((id) => { + const n = store.graph.getNode(id)! + return { id: n.id, name: n.name, type: n.type, x: n.x, y: n.y, width: n.width, height: n.height, fills: n.fills } + }) + }) +} + +test('copy + paste via store duplicates a shape', async () => { + await canvas.drawRect(100, 100, 120, 80) + await canvas.waitForRender() + + const countBefore = await getPageChildCount() + + await page.evaluate(() => { + const store = window.__OPEN_PENCIL_STORE__! + const data = new DataTransfer() + store.writeCopyData(data) + const html = data.getData('text/html') + if (html) store.pasteFromHTML(html) + }) + await canvas.waitForRender() + + const countAfter = await getPageChildCount() + expect(countAfter).toBe(countBefore + 1) +}) + +test('pasted node is offset from original', async () => { + const nodes = await getSelectedNodes() + expect(nodes).toHaveLength(1) + + const pasted = nodes[0] + expect(pasted.name).toBe('Rectangle') +}) + +test('⌘D duplicates in place', async () => { + // Clear canvas and start fresh + await canvas.clearCanvas() + await canvas.drawRect(200, 200, 100, 80) + await canvas.waitForRender() + + const countBefore = await getPageChildCount() + await canvas.duplicate() + + const countAfter = await getPageChildCount() + expect(countAfter).toBe(countBefore + 1) + expect(await getSelectedCount()).toBe(1) +}) + +test('duplicate preserves fills', async () => { + // Set a custom fill on the selected node + await page.evaluate(() => { + const store = window.__OPEN_PENCIL_STORE__! + const id = [...store.state.selectedIds][0] + store.updateNodeWithUndo(id, { + fills: [{ type: 'SOLID', color: { r: 0, g: 0.5, b: 1, a: 1 }, opacity: 1, visible: true, blendMode: 'NORMAL' }] + }, 'Set fill') + }) + await canvas.waitForRender() + + await canvas.duplicate() + + const nodes = await getSelectedNodes() + expect(nodes[0].fills[0].color.b).toBeCloseTo(1, 1) +}) + +test('cut removes original', async () => { + await canvas.clearCanvas() + await canvas.drawRect(100, 100, 80, 60) + await canvas.waitForRender() + + expect(await getPageChildCount()).toBe(1) + + // Cut via store + await page.evaluate(() => { + const store = window.__OPEN_PENCIL_STORE__! + const data = new DataTransfer() + store.writeCopyData(data) + store.deleteSelected() + }) + await canvas.waitForRender() + + expect(await getPageChildCount()).toBe(0) +}) + +test('multi-select duplicate creates copies of all', async () => { + await canvas.clearCanvas() + await canvas.drawRect(100, 100, 60, 60) + await canvas.drawRect(200, 100, 60, 60) + await canvas.selectAll() + await canvas.waitForRender() + + expect(await getSelectedCount()).toBe(2) + + const countBefore = await getPageChildCount() + await canvas.duplicate() + + const countAfter = await getPageChildCount() + expect(countAfter).toBe(countBefore + 2) + + canvas.assertNoErrors() +}) diff --git a/tests/e2e/design-panel.spec.ts b/tests/e2e/design-panel.spec.ts new file mode 100644 index 000000000..60c2d7861 --- /dev/null +++ b/tests/e2e/design-panel.spec.ts @@ -0,0 +1,167 @@ +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() +}) + +function designPanel() { + return page.locator('[data-test-id="design-panel-single"]') +} + +function nodeHeader() { + return page.locator('[data-test-id="design-node-header"]') +} + +function fillSection() { + return page.locator('[data-test-id="fill-section"]') +} + +function strokeSection() { + return page.locator('[data-test-id="stroke-section"]') +} + +function positionSection() { + return page.locator('[data-test-id="position-section"]') +} + +function effectsSection() { + return page.locator('[data-test-id="effects-section"]') +} + +function getNode(id: string) { + return page.evaluate((nodeId) => { + const store = window.__OPEN_PENCIL_STORE__! + const n = store.graph.getNode(nodeId) + if (!n) return null + return { fills: n.fills, strokes: n.strokes, effects: n.effects, opacity: n.opacity, visible: n.visible, x: n.x, y: n.y, width: n.width, height: n.height, rotation: n.rotation } + }, id) +} + +function getSelectedId() { + return page.evaluate(() => { + const store = window.__OPEN_PENCIL_STORE__! + return [...store.state.selectedIds][0] ?? null + }) +} + +test('selecting a rectangle shows design panel with type and name', async () => { + await canvas.drawRect(100, 100, 120, 80) + await canvas.waitForRender() + + await expect(designPanel()).toBeVisible() + await expect(nodeHeader()).toContainText('RECTANGLE') + await expect(nodeHeader()).toContainText('Rectangle') +}) + +test('position section shows X, Y, rotation inputs', async () => { + await expect(positionSection()).toBeVisible() + + const inputs = positionSection().locator('[data-test-id="scrub-input"]') + const count = await inputs.count() + expect(count).toBeGreaterThanOrEqual(3) +}) + +test('fill section appears with default fill', async () => { + await expect(fillSection()).toBeVisible() + + const fillItems = fillSection().locator('[data-test-id="fill-item"]') + await expect(fillItems.first()).toBeVisible() +}) + +test('fill item shows color swatch', async () => { + const swatch = fillSection().locator('[data-test-id="fill-picker-swatch"]').first() + await expect(swatch).toBeVisible() +}) + +test('adding a stroke creates stroke section item', async () => { + const addBtn = strokeSection().locator('[data-test-id="stroke-section-add"]') + await addBtn.click() + await canvas.waitForRender() + + const strokeItems = strokeSection().locator('[data-test-id="stroke-item"]') + await expect(strokeItems.first()).toBeVisible() + + const id = await getSelectedId() + const node = await getNode(id!) + expect(node!.strokes.length).toBe(1) +}) + +test('adding an effect creates effect item', async () => { + const addBtn = effectsSection().locator('[data-test-id="effects-section-add"]') + await addBtn.click() + await canvas.waitForRender() + + const effectItems = effectsSection().locator('[data-test-id="effects-item"]') + await expect(effectItems.first()).toBeVisible() + + const id = await getSelectedId() + const node = await getNode(id!) + expect(node!.effects.length).toBe(1) +}) + +test('adding a second fill shows two fill items', async () => { + const addBtn = fillSection().locator('[data-test-id="fill-section-add"]') + await addBtn.click() + await canvas.waitForRender() + + const fillItems = fillSection().locator('[data-test-id="fill-item"]') + expect(await fillItems.count()).toBe(2) + + const id = await getSelectedId() + const node = await getNode(id!) + expect(node!.fills.length).toBe(2) +}) + +test('visibility toggle in appearance section works', async () => { + const visBtn = page.locator('[data-test-id="appearance-visibility"]') + await expect(visBtn).toBeVisible() + + const id = await getSelectedId() + const before = await getNode(id!) + expect(before!.visible).toBe(true) + + await visBtn.click() + await canvas.waitForRender() + + const after = await getNode(id!) + expect(after!.visible).toBe(false) + + await visBtn.click() + await canvas.waitForRender() + + const restored = await getNode(id!) + expect(restored!.visible).toBe(true) +}) + +test('deselecting shows empty design panel', async () => { + await page.keyboard.press('Escape') + await canvas.waitForRender() + + await expect(page.locator('[data-test-id="design-panel-empty"]')).toBeVisible() +}) + +test('multi-select shows mixed header', async () => { + await canvas.drawRect(300, 100, 60, 60) + await canvas.drawRect(400, 100, 60, 60) + await canvas.selectAll() + await canvas.waitForRender() + + const multiHeader = page.locator('[data-test-id="design-multi-header"]') + await expect(multiHeader).toBeVisible() + await expect(multiHeader).toContainText('Mixed') + await expect(multiHeader).toContainText('layers') +}) diff --git a/tests/e2e/keyboard-shortcuts.spec.ts b/tests/e2e/keyboard-shortcuts.spec.ts new file mode 100644 index 000000000..0e7eb38d7 --- /dev/null +++ b/tests/e2e/keyboard-shortcuts.spec.ts @@ -0,0 +1,218 @@ +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() +}) + +function getActiveTool() { + return page.evaluate(() => window.__OPEN_PENCIL_STORE__!.state.activeTool) +} + +function getSelectedCount() { + return page.evaluate(() => window.__OPEN_PENCIL_STORE__!.state.selectedIds.size) +} + +function getPageChildren() { + return page.evaluate(() => { + const store = window.__OPEN_PENCIL_STORE__! + return store.graph.getChildren(store.state.currentPageId).map((n) => ({ + id: n.id, + type: n.type, + name: n.name, + childIds: n.childIds + })) + }) +} + +function getUIVisible() { + return page.evaluate(() => window.__OPEN_PENCIL_STORE__!.state.showUI) +} + +test.describe('tool switching', () => { + test('V → SELECT', async () => { + await page.keyboard.press('v') + expect(await getActiveTool()).toBe('SELECT') + }) + + test('R → RECTANGLE', async () => { + await page.keyboard.press('r') + expect(await getActiveTool()).toBe('RECTANGLE') + }) + + test('O → ELLIPSE', async () => { + await page.keyboard.press('o') + expect(await getActiveTool()).toBe('ELLIPSE') + }) + + test('F → FRAME', async () => { + await page.keyboard.press('f') + expect(await getActiveTool()).toBe('FRAME') + }) + + test('T → TEXT', async () => { + await page.keyboard.press('t') + expect(await getActiveTool()).toBe('TEXT') + }) + + test('L → LINE', async () => { + await page.keyboard.press('l') + expect(await getActiveTool()).toBe('LINE') + }) + + test('P → PEN', async () => { + await page.keyboard.press('p') + expect(await getActiveTool()).toBe('PEN') + }) + + test('H → HAND', async () => { + await page.keyboard.press('h') + expect(await getActiveTool()).toBe('HAND') + }) + + test('S → SECTION', async () => { + await page.keyboard.press('s') + expect(await getActiveTool()).toBe('SECTION') + }) +}) + +test.describe('selection shortcuts', () => { + test('⌘A selects all', async () => { + await page.keyboard.press('v') + await canvas.drawRect(100, 100, 60, 60) + await canvas.drawRect(200, 100, 60, 60) + + await page.keyboard.press('Meta+a') + expect(await getSelectedCount()).toBe(2) + }) + + test('Escape clears selection and resets to SELECT tool', async () => { + await page.keyboard.press('Escape') + expect(await getSelectedCount()).toBe(0) + expect(await getActiveTool()).toBe('SELECT') + }) + + test('Backspace deletes selected', async () => { + await canvas.selectAll() + const beforeCount = await getSelectedCount() + expect(beforeCount).toBeGreaterThan(0) + + await page.keyboard.press('Backspace') + await canvas.waitForRender() + + const children = await getPageChildren() + expect(children).toHaveLength(0) + }) +}) + +test.describe('z-order shortcuts', () => { + test('] brings to front', async () => { + await canvas.drawRect(100, 100, 60, 60) + await canvas.drawRect(100, 100, 60, 60) + + const childrenBefore = await getPageChildren() + const firstId = childrenBefore[0].id + + // Select the first (bottom) node + await page.evaluate((id) => { + window.__OPEN_PENCIL_STORE__!.select([id]) + }, firstId) + await canvas.waitForRender() + + await page.keyboard.press(']') + await canvas.waitForRender() + + const childrenAfter = await getPageChildren() + expect(childrenAfter[childrenAfter.length - 1].id).toBe(firstId) + }) + + test('[ sends to back', async () => { + const children = await getPageChildren() + const lastId = children[children.length - 1].id + + // Select the last (top) node + await page.evaluate((id) => { + window.__OPEN_PENCIL_STORE__!.select([id]) + }, lastId) + await canvas.waitForRender() + + await page.keyboard.press('[') + await canvas.waitForRender() + + const after = await getPageChildren() + expect(after[0].id).toBe(lastId) + }) +}) + +test.describe('group/ungroup shortcuts', () => { + test('⌘G groups selection', async () => { + await canvas.selectAll() + expect(await getSelectedCount()).toBeGreaterThanOrEqual(2) + + await page.keyboard.press('Meta+g') + await canvas.waitForRender() + + const children = await getPageChildren() + const group = children.find((c) => c.type === 'GROUP') + expect(group).toBeTruthy() + expect(group!.childIds.length).toBeGreaterThanOrEqual(2) + }) + + test('⌘⇧G ungroups', async () => { + await page.keyboard.press('Meta+Shift+g') + await canvas.waitForRender() + + const children = await getPageChildren() + expect(children.every((c) => c.type !== 'GROUP')).toBe(true) + }) +}) + +test.describe('UI toggles', () => { + test('⌘\\ toggles UI visibility', async () => { + const before = await getUIVisible() + + await page.keyboard.press('Meta+\\') + await canvas.waitForRender() + + const after = await getUIVisible() + expect(after).toBe(!before) + + // Restore + await page.keyboard.press('Meta+\\') + await canvas.waitForRender() + expect(await getUIVisible()).toBe(before) + }) +}) + +test.describe('undo/redo', () => { + test('⌘Z undoes last action', async () => { + await canvas.clearCanvas() + await canvas.drawRect(100, 100, 60, 60) + expect((await getPageChildren()).length).toBe(1) + + await page.keyboard.press('Meta+z') + await canvas.waitForRender() + + expect((await getPageChildren()).length).toBe(0) + }) + + test('⌘⇧Z redoes undone action', async () => { + await page.keyboard.press('Meta+Shift+z') + await canvas.waitForRender() + + expect((await getPageChildren()).length).toBe(1) + }) +}) diff --git a/tests/e2e/multi-page.spec.ts b/tests/e2e/multi-page.spec.ts new file mode 100644 index 000000000..766bb8dbf --- /dev/null +++ b/tests/e2e/multi-page.spec.ts @@ -0,0 +1,180 @@ +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() +}) + +function getPages() { + return page.evaluate(() => { + const store = window.__OPEN_PENCIL_STORE__! + return store.graph.getPages().map((p) => ({ id: p.id, name: p.name })) + }) +} + +function getCurrentPageId() { + return page.evaluate(() => window.__OPEN_PENCIL_STORE__!.state.currentPageId) +} + +function getPageChildCount() { + return page.evaluate(() => { + const store = window.__OPEN_PENCIL_STORE__! + return store.graph.getChildren(store.state.currentPageId).length + }) +} + +function pagesPanel() { + return page.locator('[data-test-id="pages-panel"]') +} + +function pageItems() { + return page.locator('[data-test-id="pages-item"]') +} + +function addPageButton() { + return page.locator('[data-test-id="pages-add"]') +} + +test('initial state has one page', async () => { + const pages = await getPages() + expect(pages).toHaveLength(1) + expect(pages[0].name).toBe('Page 1') +}) + +test('pages panel shows current page', async () => { + await expect(pagesPanel()).toBeVisible() + await expect(pageItems().first()).toContainText('Page 1') +}) + +test('add page creates a second page', async () => { + await addPageButton().click() + await canvas.waitForRender() + + const pages = await getPages() + expect(pages).toHaveLength(2) + + expect(await pageItems().count()).toBe(2) +}) + +test('new page is auto-selected', async () => { + const pages = await getPages() + const currentId = await getCurrentPageId() + expect(currentId).toBe(pages[1].id) +}) + +test('new page is empty', async () => { + expect(await getPageChildCount()).toBe(0) +}) + +test('drawing on new page adds nodes only to it', async () => { + await canvas.drawRect(100, 100, 80, 60) + await canvas.waitForRender() + + expect(await getPageChildCount()).toBe(1) +}) + +test('switching to first page shows its content', async () => { + await pageItems().first().click() + await canvas.waitForRender() + + const pages = await getPages() + const currentId = await getCurrentPageId() + expect(currentId).toBe(pages[0].id) +}) + +test('first page has no shapes (we never drew on it)', async () => { + expect(await getPageChildCount()).toBe(0) +}) + +test('switching back to second page shows its shape', async () => { + await pageItems().nth(1).click() + await canvas.waitForRender() + + expect(await getPageChildCount()).toBe(1) +}) + +test('add a third page', async () => { + await addPageButton().click() + await canvas.waitForRender() + + const pages = await getPages() + expect(pages).toHaveLength(3) + expect(await pageItems().count()).toBe(3) +}) + +test('delete current page switches to adjacent', async () => { + const pagesBefore = await getPages() + const deletingId = await getCurrentPageId() + + await page.evaluate(() => { + const store = window.__OPEN_PENCIL_STORE__! + store.deletePage(store.state.currentPageId) + }) + await canvas.waitForRender() + + const pagesAfter = await getPages() + expect(pagesAfter).toHaveLength(pagesBefore.length - 1) + expect(pagesAfter.find((p) => p.id === deletingId)).toBeUndefined() + + const currentId = await getCurrentPageId() + expect(pagesAfter.some((p) => p.id === currentId)).toBe(true) +}) + +test('rename page via store', async () => { + const pages = await getPages() + const currentId = await getCurrentPageId() + + await page.evaluate( + ([id, name]) => { + window.__OPEN_PENCIL_STORE__!.renamePage(id, name) + }, + [currentId, 'Renamed Page'] as [string, string] + ) + await canvas.waitForRender() + + const updated = await getPages() + const renamed = updated.find((p) => p.id === currentId) + expect(renamed!.name).toBe('Renamed Page') + + canvas.assertNoErrors() +}) + +test('cannot delete the last page', async () => { + // Delete until 1 remains + let pages = await getPages() + while (pages.length > 1) { + await page.evaluate(() => { + const store = window.__OPEN_PENCIL_STORE__! + store.deletePage(store.state.currentPageId) + }) + await canvas.waitForRender() + pages = await getPages() + } + + expect(pages).toHaveLength(1) + + // Try deleting the last one — should be a no-op + await page.evaluate(() => { + const store = window.__OPEN_PENCIL_STORE__! + store.deletePage(store.state.currentPageId) + }) + await canvas.waitForRender() + + const after = await getPages() + expect(after).toHaveLength(1) + + canvas.assertNoErrors() +}) diff --git a/tests/e2e/text-editing.spec.ts b/tests/e2e/text-editing.spec.ts new file mode 100644 index 000000000..c9aa08d7e --- /dev/null +++ b/tests/e2e/text-editing.spec.ts @@ -0,0 +1,136 @@ +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() +}) + +function getSelectedNode() { + return page.evaluate(() => { + const store = window.__OPEN_PENCIL_STORE__! + const id = [...store.state.selectedIds][0] + if (!id) return null + const n = store.graph.getNode(id) + if (!n) return null + return { + id: n.id, + type: n.type, + name: n.name, + text: n.text, + fontSize: n.fontSize, + fontFamily: n.fontFamily, + fontWeight: n.fontWeight, + width: n.width, + height: n.height + } + }) +} + +function getPageChildren() { + return page.evaluate(() => { + const store = window.__OPEN_PENCIL_STORE__! + return store.graph.getChildren(store.state.currentPageId).map((n) => ({ + type: n.type, + name: n.name, + text: n.text + })) + }) +} + +test('pressing T activates text tool', async () => { + await page.keyboard.press('t') + + const tool = await page.evaluate(() => window.__OPEN_PENCIL_STORE__!.state.activeTool) + expect(tool).toBe('TEXT') +}) + +test('clicking with text tool creates a text node', async () => { + await canvas.click(200, 200) + await canvas.waitForRender() + + // Wait a tick for text editing mode to activate + await page.waitForTimeout(200) + + const children = await getPageChildren() + const textNode = children.find((c) => c.type === 'TEXT') + expect(textNode).toBeTruthy() +}) + +test('text node is selected after creation', async () => { + const node = await getSelectedNode() + expect(node).toBeTruthy() + expect(node!.type).toBe('TEXT') +}) + +test('typography section appears for text node', async () => { + // Exit text editing mode + await page.keyboard.press('Escape') + await canvas.waitForRender() + + // Re-select the text node + await canvas.click(200, 200) + await canvas.waitForRender() + + const typoSection = page.locator('[data-test-id="typography-section"]') + await expect(typoSection).toBeVisible() +}) + +test('text node has default font properties', async () => { + const node = await getSelectedNode() + expect(node).toBeTruthy() + expect(node!.fontSize).toBeGreaterThan(0) + expect(node!.fontFamily).toBeTruthy() +}) + +test('creating text via store works', async () => { + await page.keyboard.press('Escape') + await canvas.waitForRender() + + await page.evaluate(() => { + const store = window.__OPEN_PENCIL_STORE__! + const id = store.createShape('TEXT', 100, 300, 200, 30) + store.graph.updateNode(id, { text: 'Hello World', fontSize: 24, fontFamily: 'Inter' }) + store.select([id]) + }) + await canvas.waitForRender() + + const node = await getSelectedNode() + expect(node!.text).toBe('Hello World') + expect(node!.fontSize).toBe(24) +}) + +test('undo removes text node', async () => { + const beforeCount = (await getPageChildren()).length + + await canvas.undo() + await canvas.waitForRender() + + const afterCount = (await getPageChildren()).length + expect(afterCount).toBe(beforeCount - 1) +}) + +test('frame tool creates FRAME node', async () => { + await page.keyboard.press('f') + await canvas.drag(400, 100, 600, 250) + await canvas.waitForRender() + + const node = await getSelectedNode() + expect(node!.type).toBe('FRAME') + expect(node!.width).toBeGreaterThan(0) + expect(node!.height).toBeGreaterThan(0) + + canvas.assertNoErrors() +})