From 5a72743209336fb1d1936d017332936bde650b64 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Mon, 20 Oct 2025 00:44:47 -0400 Subject: [PATCH] UI tests --- tests/README.md | 147 +++++++++++++ tests/helpers/app.js | 52 +++++ tests/helpers/assertions.js | 75 +++++++ tests/helpers/canvas.js | 224 ++++++++++++++++++++ tests/specs/group-editing.test.js | 132 ++++++++++++ tests/specs/grouping.test.js | 134 ++++++++++++ tests/specs/paint-bucket.test.js | 88 ++++++++ tests/specs/shapes.test.js | 100 +++++++++ tests/specs/timeline-animation.test.js | 273 +++++++++++++++++++++++++ 9 files changed, 1225 insertions(+) create mode 100644 tests/README.md create mode 100644 tests/helpers/app.js create mode 100644 tests/helpers/assertions.js create mode 100644 tests/helpers/canvas.js create mode 100644 tests/specs/group-editing.test.js create mode 100644 tests/specs/grouping.test.js create mode 100644 tests/specs/paint-bucket.test.js create mode 100644 tests/specs/shapes.test.js create mode 100644 tests/specs/timeline-animation.test.js diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..cc9100e --- /dev/null +++ b/tests/README.md @@ -0,0 +1,147 @@ +# Lightningbeam UI Tests + +Automated UI tests for Lightningbeam using WebdriverIO and tauri-driver. + +## Prerequisites + +1. **Install test dependencies**: + ```bash + pnpm add -D @wdio/cli @wdio/local-runner @wdio/mocha-framework @wdio/spec-reporter @wdio/globals + ``` + + **Important**: If the `@wdio/local-runner` package hangs during installation, you must install it in your native OS environment (not in a container). The pnpm store can have conflicts when switching between different OS contexts. If you originally ran `pnpm install` on your host system, install the test dependencies there as well. + +2. **Build the application** - Tests require the release build: + ```bash + pnpm tauri build + ``` + + **Note**: The debug build (`pnpm tauri build --debug`) won't work for tests because it expects a dev server to be running. Tests use the self-contained release build. + +3. **Install tauri-driver** - Download from [tauri-apps/tauri releases](https://github.com/tauri-apps/tauri/releases): + ```bash + # Linux example + cargo install tauri-driver + # Or download binary and add to PATH + ``` + +## Running Tests + +### 1. Start tauri-driver +In a separate terminal, start tauri-driver: +```bash +tauri-driver --port 4444 +``` + +### 2. Run all tests +```bash +pnpm test +``` + +### Run tests in watch mode +```bash +pnpm test:watch +``` + +### Run specific test file +```bash +pnpm wdio run wdio.conf.js --spec tests/specs/shapes.test.js +``` + +## Test Structure + +``` +tests/ +├── helpers/ +│ ├── app.js # App lifecycle helpers +│ ├── canvas.js # Canvas interaction utilities +│ └── assertions.js # Custom assertions +├── specs/ +│ ├── shapes.test.js # Shape drawing tests +│ ├── grouping.test.js # Shape grouping tests +│ └── paint-bucket.test.js # Paint bucket tool tests +└── fixtures/ # Test data files +``` + +## Writing Tests + +### Example: Drawing a Rectangle + +```javascript +import { drawRectangle } from '../helpers/canvas.js'; +import { assertShapeExists } from '../helpers/assertions.js'; + +it('should draw a rectangle', async () => { + await drawRectangle(100, 100, 200, 150); + await assertShapeExists(200, 175, 'Rectangle should exist at center'); +}); +``` + +### Available Helpers + +#### Canvas Helpers (`canvas.js`) +- `clickCanvas(x, y)` - Click at coordinates +- `dragCanvas(fromX, fromY, toX, toY)` - Drag operation +- `drawRectangle(x, y, width, height)` - Draw rectangle +- `drawEllipse(x, y, width, height)` - Draw ellipse +- `selectTool(toolName)` - Select a tool by name +- `selectMultipleShapes(points)` - Select multiple shapes with Ctrl +- `useKeyboardShortcut(key, withCtrl)` - Use keyboard shortcuts +- `getPixelColor(x, y)` - Get color at coordinates +- `hasShapeAt(x, y)` - Check if shape exists at point + +#### Assertion Helpers (`assertions.js`) +- `assertShapeExists(x, y, message)` - Assert shape at coordinates +- `assertNoShapeAt(x, y, message)` - Assert no shape at coordinates +- `assertPixelColor(x, y, color, message)` - Assert pixel color +- `assertColorApproximately(color1, color2, tolerance)` - Fuzzy color match + +## Adding Data Attributes for Testing + +To make UI elements easier to test, add `data-tool` attributes to tool buttons in the UI: + +```javascript +// Example in main.js +const rectangleTool = document.createElement('button'); +rectangleTool.setAttribute('data-tool', 'rectangle'); +``` + +Current expected data attributes: +- `data-tool="rectangle"` - Rectangle tool button +- `data-tool="ellipse"` - Ellipse tool button +- `data-tool="dropper"` - Paint bucket/dropper tool button +- Add more as needed... + +## Platform Support + +- **Linux**: Full support with webkit2gtk +- **Windows**: Full support with WebView2 +- **macOS**: Limited support (no WKWebView driver available) + +## Troubleshooting + +### Tests fail to start +- Ensure the release build exists: `./src-tauri/target/release/lightningbeam` +- Check that `tauri-driver` is in your PATH + +### Canvas interactions don't work +- Verify that tool buttons have `data-tool` attributes +- Check that canvas element is present with `document.querySelector('canvas')` + +### Screenshots directory missing +```bash +mkdir -p tests/screenshots +``` + +## CI Integration + +See `.github/workflows/` for example GitHub Actions configuration (to be added). + +## Future Enhancements + +- [ ] Add color picker test helpers +- [ ] Add timeline/keyframe test helpers +- [ ] Add layer management test helpers +- [ ] Visual regression testing with screenshot comparison +- [ ] Performance benchmarks +- [ ] Add Tauri commands for better state inspection diff --git a/tests/helpers/app.js b/tests/helpers/app.js new file mode 100644 index 0000000..f869ef7 --- /dev/null +++ b/tests/helpers/app.js @@ -0,0 +1,52 @@ +/** + * App helper utilities for Tauri application testing + */ + +/** + * Wait for the Lightningbeam app to be fully loaded and ready + * @param {number} timeout - Maximum time to wait in ms + */ +export async function waitForAppReady(timeout = 5000) { + await browser.waitForApp(); + + // Check for "Create New File" dialog and click Create if present + const createButton = await browser.$('button*=Create'); + if (await createButton.isExisting()) { + await createButton.click(); + await browser.pause(500); // Wait for dialog to close + } + + // Wait for the main canvas to be present + const canvas = await browser.$('canvas'); + await canvas.waitForExist({ timeout }); + + // Additional wait for any initialization + await browser.pause(500); +} + +/** + * Get the main canvas element + * @returns {Promise} + */ +export async function getCanvas() { + return await browser.$('canvas'); +} + +/** + * Get canvas dimensions + * @returns {Promise<{width: number, height: number}>} + */ +export async function getCanvasSize() { + const canvas = await getCanvas(); + const size = await canvas.getSize(); + return size; +} + +/** + * Take a screenshot of the canvas + * @param {string} filename - Name for the screenshot file + */ +export async function takeCanvasScreenshot(filename) { + const canvas = await getCanvas(); + return await canvas.saveScreenshot(`./tests/screenshots/${filename}`); +} diff --git a/tests/helpers/assertions.js b/tests/helpers/assertions.js new file mode 100644 index 0000000..b88809d --- /dev/null +++ b/tests/helpers/assertions.js @@ -0,0 +1,75 @@ +/** + * Custom assertion helpers for Lightningbeam UI tests + */ + +import { getPixelColor, hasShapeAt } from './canvas.js'; +import { expect } from '@wdio/globals'; + +/** + * Assert that a shape exists at the given coordinates + * @param {number} x - X coordinate + * @param {number} y - Y coordinate + * @param {string} message - Custom error message + */ +export async function assertShapeExists(x, y, message = 'Expected shape to exist at coordinates') { + const shapeExists = await hasShapeAt(x, y); + expect(shapeExists).toBe(true, `${message} (${x}, ${y})`); +} + +/** + * Assert that no shape exists at the given coordinates + * @param {number} x - X coordinate + * @param {number} y - Y coordinate + * @param {string} message - Custom error message + */ +export async function assertNoShapeAt(x, y, message = 'Expected no shape at coordinates') { + const shapeExists = await hasShapeAt(x, y); + expect(shapeExists).toBe(false, `${message} (${x}, ${y})`); +} + +/** + * Assert that a pixel has a specific color + * @param {number} x - X coordinate + * @param {number} y - Y coordinate + * @param {string} expectedColor - Expected color in hex format + * @param {string} message - Custom error message + */ +export async function assertPixelColor(x, y, expectedColor, message = 'Expected pixel color to match') { + const actualColor = await getPixelColor(x, y); + expect(actualColor.toLowerCase()).toBe(expectedColor.toLowerCase(), + `${message}. Expected ${expectedColor}, got ${actualColor} at (${x}, ${y})`); +} + +/** + * Assert that a color is approximately equal to another (with tolerance) + * Useful for anti-aliasing and rendering differences + * @param {string} color1 - First color in hex format + * @param {string} color2 - Second color in hex format + * @param {number} tolerance - Tolerance per channel (0-255) + */ +export function assertColorApproximately(color1, color2, tolerance = 10) { + const rgb1 = hexToRgb(color1); + const rgb2 = hexToRgb(color2); + + const rDiff = Math.abs(rgb1.r - rgb2.r); + const gDiff = Math.abs(rgb1.g - rgb2.g); + const bDiff = Math.abs(rgb1.b - rgb2.b); + + expect(rDiff).toBeLessThanOrEqual(tolerance, `Red channel difference ${rDiff} exceeds tolerance ${tolerance}`); + expect(gDiff).toBeLessThanOrEqual(tolerance, `Green channel difference ${gDiff} exceeds tolerance ${tolerance}`); + expect(bDiff).toBeLessThanOrEqual(tolerance, `Blue channel difference ${bDiff} exceeds tolerance ${tolerance}`); +} + +/** + * Convert hex color to RGB object + * @param {string} hex - Hex color string + * @returns {{r: number, g: number, b: number}} + */ +function hexToRgb(hex) { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16) + } : null; +} diff --git a/tests/helpers/canvas.js b/tests/helpers/canvas.js new file mode 100644 index 0000000..6304f7f --- /dev/null +++ b/tests/helpers/canvas.js @@ -0,0 +1,224 @@ +/** + * Canvas interaction utilities for UI testing + */ + +/** + * Click at specific coordinates on the canvas + * @param {number} x - X coordinate relative to canvas + * @param {number} y - Y coordinate relative to canvas + */ +export async function clickCanvas(x, y) { + await browser.clickCanvas(x, y); + await browser.pause(100); // Wait for render +} + +/** + * Drag from one point to another on the canvas + * @param {number} fromX - Starting X coordinate + * @param {number} fromY - Starting Y coordinate + * @param {number} toX - Ending X coordinate + * @param {number} toY - Ending Y coordinate + */ +export async function dragCanvas(fromX, fromY, toX, toY) { + await browser.dragCanvas(fromX, fromY, toX, toY); + await browser.pause(200); // Wait for render +} + +/** + * Draw a rectangle on the canvas + * @param {number} x - Top-left X coordinate + * @param {number} y - Top-left Y coordinate + * @param {number} width - Rectangle width + * @param {number} height - Rectangle height + * @param {boolean} filled - Whether to fill the shape (default: true) + */ +export async function drawRectangle(x, y, width, height, filled = true) { + // Select the rectangle tool + await selectTool('rectangle'); + + // Set fill option + await browser.execute((filled) => { + if (window.context) { + window.context.fillShape = filled; + } + }, filled); + + // Draw by dragging from start to end point + await dragCanvas(x, y, x + width, y + height); + + // Wait for shape to be created + await browser.pause(300); +} + +/** + * Draw an ellipse on the canvas + * @param {number} x - Top-left X coordinate + * @param {number} y - Top-left Y coordinate + * @param {number} width - Ellipse width + * @param {number} height - Ellipse height + * @param {boolean} filled - Whether to fill the shape (default: true) + */ +export async function drawEllipse(x, y, width, height, filled = true) { + // Select the ellipse tool + await selectTool('ellipse'); + + // Set fill option + await browser.execute((filled) => { + if (window.context) { + window.context.fillShape = filled; + } + }, filled); + + // Draw by dragging from start to end point + await dragCanvas(x, y, x + width, y + height); + + // Wait for shape to be created + await browser.pause(300); +} + +/** + * Select a tool from the toolbar + * @param {string} toolName - Name of the tool ('rectangle', 'ellipse', 'brush', etc.) + */ +export async function selectTool(toolName) { + const toolButton = await browser.$(`[data-tool="${toolName}"]`); + await toolButton.click(); + await browser.pause(100); +} + +/** + * Select multiple shapes by dragging a selection box over them + * @param {Array<{x: number, y: number}>} points - Array of points representing shapes to select + */ +export async function selectMultipleShapes(points) { + // First, make sure we're in select mode + await selectTool('select'); + + // Calculate bounding box that encompasses all points + const minX = Math.min(...points.map(p => p.x)) - 10; + const minY = Math.min(...points.map(p => p.y)) - 10; + const maxX = Math.max(...points.map(p => p.x)) + 10; + const maxY = Math.max(...points.map(p => p.y)) + 10; + + // Drag a selection box from top-left to bottom-right + await dragCanvas(minX, minY, maxX, maxY); + await browser.pause(200); +} + +/** + * Use keyboard shortcut / menu action + * Since Tauri menu shortcuts don't reach the browser, we invoke actions directly + * @param {string} key - Key to press (e.g., 'g' for group) + * @param {boolean} withCtrl - Whether to hold Ctrl (ignored, kept for compatibility) + */ +export async function useKeyboardShortcut(key, withCtrl = true) { + if (key === 'g') { + // Call group action directly without serializing the whole function + await browser.execute('window.actions.group.create()'); + } + + await browser.pause(300); // Give time for the action to process +} + +/** + * Get pixel color at specific coordinates (requires canvas access) + * @param {number} x - X coordinate + * @param {number} y - Y coordinate + * @returns {Promise} Color in hex format + */ +export async function getPixelColor(x, y) { + const result = await browser.execute(function(x, y) { + const canvas = document.querySelector('canvas.stage'); + if (!canvas) return null; + + const ctx = canvas.getContext('2d'); + const imageData = ctx.getImageData(x, y, 1, 1); + const data = imageData.data; + + // Convert to hex + const r = data[0].toString(16).padStart(2, '0'); + const g = data[1].toString(16).padStart(2, '0'); + const b = data[2].toString(16).padStart(2, '0'); + + return `#${r}${g}${b}`; + }, x, y); + + return result; +} + +/** + * Check if a shape exists at given coordinates by checking if pixel is not background + * @param {number} x - X coordinate + * @param {number} y - Y coordinate + * @param {string} backgroundColor - Expected background color (default white) + * @returns {Promise} + */ +export async function hasShapeAt(x, y, backgroundColor = '#ffffff') { + const color = await getPixelColor(x, y); + return color && color.toLowerCase() !== backgroundColor.toLowerCase(); +} + +/** + * Double-click at specific coordinates on the canvas (to enter group editing mode) + * @param {number} x - X coordinate relative to canvas + * @param {number} y - Y coordinate relative to canvas + */ +export async function doubleClickCanvas(x, y) { + const canvas = await browser.$('canvas.stage'); + const location = await canvas.getLocation(); + + // Perform double-click using performActions + await browser.performActions([{ + type: 'pointer', + id: 'mouse', + parameters: { pointerType: 'mouse' }, + actions: [ + { type: 'pointerMove', duration: 0, x: location.x + x, y: location.y + y }, + { type: 'pointerDown', button: 0 }, + { type: 'pointerUp', button: 0 }, + { type: 'pause', duration: 50 }, + { type: 'pointerDown', button: 0 }, + { type: 'pointerUp', button: 0 } + ] + }]); + + await browser.pause(300); // Wait for group to be entered +} + +/** + * Set the timeline playhead to a specific time + * @param {number} time - Time in seconds + */ +export async function setPlayheadTime(time) { + await browser.execute(function(timeValue) { + if (window.context && window.context.activeObject) { + window.context.activeObject.currentTime = timeValue; + // Update timeline widget if it exists + if (window.context.timelineWidget && window.context.timelineWidget.timelineState) { + window.context.timelineWidget.timelineState.currentTime = timeValue; + } + } + }, time); + await browser.pause(100); +} + +/** + * Get the current playhead time + * @returns {Promise} Current time in seconds + */ +export async function getPlayheadTime() { + return await browser.execute(function() { + if (window.context && window.context.activeObject) { + return window.context.activeObject.currentTime; + } + return 0; + }); +} + +/** + * Add a keyframe at the current playhead position for selected shapes/objects + */ +export async function addKeyframe() { + await browser.execute('window.addKeyframeAtPlayhead && window.addKeyframeAtPlayhead()'); + await browser.pause(200); +} diff --git a/tests/specs/group-editing.test.js b/tests/specs/group-editing.test.js new file mode 100644 index 0000000..fa1790e --- /dev/null +++ b/tests/specs/group-editing.test.js @@ -0,0 +1,132 @@ +/** + * Group editing tests for Lightningbeam + * Tests that shapes can be edited inside groups with correct relative positioning + */ + +import { describe, it, before } from 'mocha'; +import { expect } from '@wdio/globals'; +import { waitForAppReady } from '../helpers/app.js'; +import { + drawRectangle, + selectMultipleShapes, + useKeyboardShortcut, + doubleClickCanvas, + clickCanvas +} from '../helpers/canvas.js'; +import { assertShapeExists } from '../helpers/assertions.js'; + +describe('Group Editing', () => { + before(async () => { + await waitForAppReady(); + }); + + describe('Entering and Editing Groups', () => { + it('should maintain shape positions when editing inside a group', async () => { + // Draw a rectangle + await drawRectangle(200, 200, 100, 100); + + // Verify it exists at the expected location + await assertShapeExists(250, 250, 'Rectangle should exist at center before grouping'); + + // Select it (click on the center) + await clickCanvas(250, 250); + await browser.pause(200); + + // Group it (even though it's just one shape) + await useKeyboardShortcut('g', true); + await browser.pause(300); + + // The shape should still be visible at the same location + await assertShapeExists(250, 250, 'Rectangle should still exist at same position after grouping'); + + // Double-click on the group to enter editing mode + await doubleClickCanvas(250, 250); + + // The shape should STILL be at the same position when editing the group + await assertShapeExists(250, 250, 'Rectangle should remain at same position when editing group'); + }); + + it('should correctly position new shapes drawn inside a group', async () => { + // Draw a rectangle + await drawRectangle(100, 400, 80, 80); + + // Select and group it + await clickCanvas(140, 440); + await browser.pause(200); + await useKeyboardShortcut('g', true); + await browser.pause(300); + + // Double-click to enter group editing mode + await doubleClickCanvas(140, 440); + + // Draw another rectangle inside the group at a specific location + await drawRectangle(200, 400, 80, 80); + + // Verify the new shape is where we drew it + await assertShapeExists(240, 440, 'New shape should be at the coordinates where it was drawn'); + + // Verify the original shape still exists + await assertShapeExists(140, 440, 'Original shape should still exist'); + }); + + it('should handle nested group editing with correct positioning', async () => { + // Create first group with two shapes + await drawRectangle(400, 100, 60, 60); + await drawRectangle(480, 100, 60, 60); + await selectMultipleShapes([ + { x: 430, y: 130 }, + { x: 510, y: 130 } + ]); + await useKeyboardShortcut('g', true); + await browser.pause(300); + + // Verify both shapes exist + await assertShapeExists(430, 130, 'First shape should exist'); + await assertShapeExists(510, 130, 'Second shape should exist'); + + // Create another shape and group everything together + await drawRectangle(400, 180, 60, 60); + await selectMultipleShapes([ + { x: 470, y: 130 }, // Center of first group + { x: 430, y: 210 } // Center of new shape + ]); + await useKeyboardShortcut('g', true); + await browser.pause(300); + + // Double-click to enter outer group + await doubleClickCanvas(470, 130); + + // Double-click again to enter inner group + await doubleClickCanvas(470, 130); + + // All shapes should still be at their original positions + await assertShapeExists(430, 130, 'First shape should maintain position in nested group'); + await assertShapeExists(510, 130, 'Second shape should maintain position in nested group'); + }); + }); + + describe('Mouse Coordinate Transformation', () => { + it('should correctly translate mouse coordinates when drawing in groups', async () => { + // Draw and group a shape + await drawRectangle(300, 300, 50, 50); + await clickCanvas(325, 325); + await browser.pause(200); + await useKeyboardShortcut('g', true); + await browser.pause(300); + + // Enter group + await doubleClickCanvas(325, 325); + + // Draw shapes at precise coordinates to test mouse transformation + await drawRectangle(350, 300, 30, 30); + await drawRectangle(300, 350, 30, 30); + await drawRectangle(350, 350, 30, 30); + + // Verify all shapes are at expected positions + await assertShapeExists(365, 315, 'Shape to the right should be at correct position'); + await assertShapeExists(315, 365, 'Shape below should be at correct position'); + await assertShapeExists(365, 365, 'Shape diagonal should be at correct position'); + await assertShapeExists(325, 325, 'Original shape should still exist'); + }); + }); +}); diff --git a/tests/specs/grouping.test.js b/tests/specs/grouping.test.js new file mode 100644 index 0000000..171aec3 --- /dev/null +++ b/tests/specs/grouping.test.js @@ -0,0 +1,134 @@ +/** + * Shape grouping tests for Lightningbeam + */ + +import { describe, it, before, beforeEach } from 'mocha'; +import { expect } from '@wdio/globals'; +import { waitForAppReady } from '../helpers/app.js'; +import { drawRectangle, drawEllipse, selectMultipleShapes, useKeyboardShortcut, clickCanvas } from '../helpers/canvas.js'; +import { assertShapeExists } from '../helpers/assertions.js'; + +describe('Shape Grouping', () => { + before(async () => { + await waitForAppReady(); + }); + + describe('Grouping Multiple Shapes', () => { + it('should group two rectangles together', async () => { + // Draw two rectangles + await drawRectangle(100, 100, 100, 100); + await drawRectangle(250, 100, 100, 100); + + // Select both shapes (click centers with Ctrl held) + await selectMultipleShapes([ + { x: 150, y: 150 }, + { x: 300, y: 150 } + ]); + + // Group with Ctrl+G + await useKeyboardShortcut('g', true); + + // Verify both shapes still exist after grouping + await assertShapeExists(150, 150, 'First rectangle should still exist after grouping'); + await assertShapeExists(300, 150, 'Second rectangle should still exist after grouping'); + }); + + it('should group rectangle and ellipse together', async () => { + // Draw rectangle and ellipse + await drawRectangle(100, 250, 120, 80); + await drawEllipse(280, 250, 120, 80); + + // Select both shapes + await selectMultipleShapes([ + { x: 160, y: 290 }, + { x: 340, y: 290 } + ]); + + // Group them + await useKeyboardShortcut('g', true); + + // Verify shapes exist + await assertShapeExists(160, 290, 'Rectangle should exist in group'); + await assertShapeExists(340, 290, 'Ellipse should exist in group'); + }); + + it('should group three or more shapes', async () => { + // Draw three rectangles + await drawRectangle(50, 400, 80, 80); + await drawRectangle(150, 400, 80, 80); + await drawRectangle(250, 400, 80, 80); + + // Select all three + await selectMultipleShapes([ + { x: 90, y: 440 }, + { x: 190, y: 440 }, + { x: 290, y: 440 } + ]); + + // Group them + await useKeyboardShortcut('g', true); + + // Verify all shapes exist + await assertShapeExists(90, 440, 'First shape should exist in group'); + await assertShapeExists(190, 440, 'Second shape should exist in group'); + await assertShapeExists(290, 440, 'Third shape should exist in group'); + }); + }); + + describe('Group Manipulation', () => { + it('should be able to select and move a group', async () => { + // Draw two shapes + await drawRectangle(400, 100, 80, 80); + await drawRectangle(500, 100, 80, 80); + + // Select and group + await selectMultipleShapes([ + { x: 440, y: 140 }, + { x: 540, y: 140 } + ]); + await useKeyboardShortcut('g', true); + + // Click on the group to select it (click between the shapes) + await clickCanvas(490, 140); + + // Note: Moving would require drag testing which is already covered in canvas.js + // This test verifies the group can be selected + }); + }); + + describe('Nested Groups', () => { + it('should allow grouping of groups', async () => { + // Create first group + await drawRectangle(100, 100, 60, 60); + await drawRectangle(180, 100, 60, 60); + await selectMultipleShapes([ + { x: 130, y: 130 }, + { x: 210, y: 130 } + ]); + await useKeyboardShortcut('g', true); + + // Create second group + await drawRectangle(100, 200, 60, 60); + await drawRectangle(180, 200, 60, 60); + await selectMultipleShapes([ + { x: 130, y: 230 }, + { x: 210, y: 230 } + ]); + await useKeyboardShortcut('g', true); + + // Now group both groups together + // Select center of each group + await selectMultipleShapes([ + { x: 170, y: 130 }, + { x: 170, y: 230 } + ]); + await useKeyboardShortcut('g', true); + + // Verify all original shapes still exist + await assertShapeExists(130, 130, 'First group first shape should exist'); + await assertShapeExists(210, 130, 'First group second shape should exist'); + await assertShapeExists(130, 230, 'Second group first shape should exist'); + await assertShapeExists(210, 230, 'Second group second shape should exist'); + }); + }); +}); diff --git a/tests/specs/paint-bucket.test.js b/tests/specs/paint-bucket.test.js new file mode 100644 index 0000000..0ff2714 --- /dev/null +++ b/tests/specs/paint-bucket.test.js @@ -0,0 +1,88 @@ +/** + * Paint bucket tool tests for Lightningbeam + */ + +import { describe, it, before } from 'mocha'; +import { expect } from '@wdio/globals'; +import { waitForAppReady } from '../helpers/app.js'; +import { drawRectangle, selectTool, clickCanvas, getPixelColor } from '../helpers/canvas.js'; +import { assertPixelColor } from '../helpers/assertions.js'; + +describe('Paint Bucket Tool', () => { + before(async () => { + await waitForAppReady(); + }); + + describe('Fill Shape', () => { + it('should fill a rectangle with color', async () => { + // Draw an unfilled rectangle (outline only) + await drawRectangle(100, 100, 200, 150, false); + + // Get the color before filling (should be stroke/outline only, center is white) + const colorBefore = await getPixelColor(200, 175); + + // Select paint bucket tool + await selectTool('paint_bucket'); + + // Click inside the rectangle to fill it + await clickCanvas(200, 175); + + // Get the color after filling + const colorAfter = await getPixelColor(200, 175); + + // The color should have changed from white background to filled color + expect(colorBefore.toLowerCase()).toBe('#ffffff'); // Was white (unfilled) + expect(colorAfter.toLowerCase()).not.toBe('#ffffff'); // Now filled with a color + }); + + it('should fill only the clicked shape, not adjacent shapes', async () => { + // Draw two separate unfilled rectangles + await drawRectangle(100, 300, 100, 100, false); + await drawRectangle(250, 300, 100, 100, false); + + // Fill only the first rectangle + await selectTool('paint_bucket'); + await clickCanvas(150, 350); + + // Get colors from both shapes + const firstColor = await getPixelColor(150, 350); + const secondColor = await getPixelColor(300, 350); + + // The shapes should potentially have different colors + // (or at least we confirmed we could click them individually) + }); + }); + + describe('Fill with Different Colors', () => { + it('should respect the selected fill color when using paint bucket', async () => { + // This test would require setting a specific fill color first + // Then drawing and filling a shape + // For now, this is a placeholder structure + + await drawRectangle(400, 100, 150, 100, false); + + // TODO: Add color selection logic when color picker helpers are available + // await selectColor('#ff0000'); + + await selectTool('paint_bucket'); + await clickCanvas(475, 150); + + // Verify the fill color + const color = await getPixelColor(475, 150); + // TODO: Assert expected color when we know how to set it + }); + }); + + describe('Fill Gaps Setting', () => { + it('should handle fill gaps setting for incomplete shapes', async () => { + // This test would draw an incomplete shape and test the fillGaps parameter + // Placeholder for now - would need specific incomplete shape drawing + + // The fillGaps setting controls how the paint bucket handles gaps in shapes + // This is a more advanced test that would need: + // 1. A way to draw incomplete/open shapes + // 2. A way to set the fillGaps parameter + // 3. Verification that the fill respects the gap threshold + }); + }); +}); diff --git a/tests/specs/shapes.test.js b/tests/specs/shapes.test.js new file mode 100644 index 0000000..0504f6e --- /dev/null +++ b/tests/specs/shapes.test.js @@ -0,0 +1,100 @@ +/** + * Shape drawing tests for Lightningbeam + */ + +import { describe, it, before } from 'mocha'; +import { waitForAppReady } from '../helpers/app.js'; +import { drawRectangle, drawEllipse } from '../helpers/canvas.js'; +import { assertShapeExists } from '../helpers/assertions.js'; + +describe('Shape Drawing', () => { + before(async () => { + await waitForAppReady(); + }); + + describe('Rectangle Tool', () => { + it('should draw a rectangle on the canvas', async () => { + // Draw a rectangle at (100, 100) with size 200x150 + await drawRectangle(100, 100, 200, 150); + + // Verify the shape exists by checking pixels at various points + // Check center of the rectangle + await assertShapeExists(200, 175, 'Rectangle should be drawn at center'); + + // Check edges + await assertShapeExists(110, 110, 'Rectangle should exist at top-left'); + await assertShapeExists(290, 110, 'Rectangle should exist at top-right'); + await assertShapeExists(110, 240, 'Rectangle should exist at bottom-left'); + await assertShapeExists(290, 240, 'Rectangle should exist at bottom-right'); + }); + + it('should draw multiple rectangles without interference', async () => { + // Draw first rectangle + await drawRectangle(50, 50, 100, 100); + await assertShapeExists(100, 100, 'First rectangle should exist'); + + // Draw second rectangle in different location + await drawRectangle(300, 300, 100, 100); + await assertShapeExists(350, 350, 'Second rectangle should exist'); + + // Verify first rectangle still exists + await assertShapeExists(100, 100, 'First rectangle should still exist'); + }); + + it('should draw small rectangles', async () => { + // Draw a small rectangle + await drawRectangle(400, 100, 20, 20); + await assertShapeExists(410, 110, 'Small rectangle should exist'); + }); + + it('should draw large rectangles', async () => { + // Draw a large rectangle + await drawRectangle(50, 300, 400, 200); + await assertShapeExists(250, 400, 'Large rectangle should exist at center'); + }); + }); + + describe('Ellipse Tool', () => { + it('should draw an ellipse on the canvas', async () => { + // Draw an ellipse at (100, 100) with size 200x150 + await drawEllipse(100, 100, 200, 150); + + // Check center of the ellipse + await assertShapeExists(200, 175, 'Ellipse should be drawn at center'); + }); + + it('should draw a circle (equal width and height)', async () => { + // Draw a circle + await drawEllipse(300, 100, 150, 150); + + // Check center + await assertShapeExists(375, 175, 'Circle should exist at center'); + }); + + it('should draw wide ellipses', async () => { + // Draw a wide ellipse + await drawEllipse(50, 400, 300, 100); + await assertShapeExists(200, 450, 'Wide ellipse should exist'); + }); + + it('should draw tall ellipses', async () => { + // Draw a tall ellipse + await drawEllipse(500, 100, 100, 300); + await assertShapeExists(550, 250, 'Tall ellipse should exist'); + }); + }); + + describe('Mixed Shapes', () => { + it('should draw both rectangles and ellipses in the same scene', async () => { + // Draw a rectangle + await drawRectangle(100, 100, 150, 100); + + // Draw an ellipse + await drawEllipse(300, 100, 150, 100); + + // Verify both exist + await assertShapeExists(175, 150, 'Rectangle should exist'); + await assertShapeExists(375, 150, 'Ellipse should exist'); + }); + }); +}); diff --git a/tests/specs/timeline-animation.test.js b/tests/specs/timeline-animation.test.js new file mode 100644 index 0000000..dc8fcd5 --- /dev/null +++ b/tests/specs/timeline-animation.test.js @@ -0,0 +1,273 @@ +/** + * Timeline animation tests for Lightningbeam + * Tests shape and object animations across keyframes + */ + +import { describe, it, before } from 'mocha'; +import { expect } from '@wdio/globals'; +import { waitForAppReady } from '../helpers/app.js'; +import { + drawRectangle, + drawEllipse, + clickCanvas, + dragCanvas, + selectMultipleShapes, + useKeyboardShortcut, + setPlayheadTime, + getPlayheadTime, + addKeyframe, + getPixelColor +} from '../helpers/canvas.js'; +import { assertShapeExists } from '../helpers/assertions.js'; + +describe('Timeline Animation', () => { + before(async () => { + await waitForAppReady(); + }); + + describe('Shape Keyframe Animation', () => { + it('should animate shape position across keyframes', async () => { + // Draw a rectangle at frame 1 (time 0) + await drawRectangle(100, 100, 100, 100); + + // Select the shape by dragging a selection box over it + await selectMultipleShapes([{ x: 150, y: 150 }]); + await browser.pause(200); + + // Verify it exists at original position + await assertShapeExists(150, 150, 'Shape should exist at frame 1'); + + // Move to frame 10 (time in seconds, assuming 30fps: frame 10 = 10/30 ≈ 0.333s) + await setPlayheadTime(0.333); + + // Add a keyframe at this position + await addKeyframe(); + await browser.pause(200); + + // Shape is selected, so dragging from its center will move it + await dragCanvas(150, 150, 250, 150); + await browser.pause(300); + + // At frame 10, shape should be at the new position (moved 100px to the right) + await assertShapeExists(250, 150, 'Shape should be at new position at frame 10'); + + // Go back to frame 1 + await setPlayheadTime(0); + await browser.pause(200); + + // Shape should be at original position + await assertShapeExists(150, 150, 'Shape should be at original position at frame 1'); + + // Go to middle frame (frame 5, time ≈ 0.166s) + await setPlayheadTime(0.166); + await browser.pause(200); + + // Shape should be interpolated between the two positions + // At frame 5 (halfway), shape should be around x=200 (halfway between 150 and 250) + await assertShapeExists(200, 150, 'Shape should be interpolated at frame 5'); + }); + + it('should modify shape edges when dragging edge of unselected shape', async () => { + // Draw a rectangle + await drawRectangle(400, 100, 100, 100); + + // Get color at center + const centerColorBefore = await getPixelColor(450, 150); + + // WITHOUT selecting the shape, drag the right edge + // The right edge is at x=500, so drag from there + await dragCanvas(500, 150, 550, 150); + await browser.pause(300); + + // The shape should now be modified - it's been curved/stretched + // The original center should still have the shape + await assertShapeExists(450, 150, 'Center should still have shape'); + + // And there should be shape data extended to the right + const rightColorAfter = await getPixelColor(525, 150); + // This should have color (not white) since we dragged the edge there + expect(rightColorAfter.toLowerCase()).not.toBe('#ffffff'); + }); + + it('should handle multiple keyframes on the same shape', async () => { + // Draw a shape + await drawRectangle(100, 300, 80, 80); + + // Select it + await selectMultipleShapes([{ x: 140, y: 340 }]); + await browser.pause(200); + + // Keyframe 1: time 0 (original position at x=140, y=340) + + // Keyframe 2: time 0.333 (move right) + await setPlayheadTime(0.333); + await addKeyframe(); + await browser.pause(200); + // Shape should still be selected, drag to move + await dragCanvas(140, 340, 200, 340); + await browser.pause(300); + + // Keyframe 3: time 0.666 (move down but stay within canvas) + await setPlayheadTime(0.666); + await addKeyframe(); + await browser.pause(200); + // Drag to move down (y=380 instead of 400 to stay in canvas) + await dragCanvas(200, 340, 200, 380); + await browser.pause(300); + + // Verify positions at each keyframe + await setPlayheadTime(0); + await browser.pause(200); + await assertShapeExists(140, 340, 'Shape at keyframe 1 (x=140, y=340)'); + + await setPlayheadTime(0.333); + await browser.pause(200); + await assertShapeExists(200, 340, 'Shape at keyframe 2 (x=200, y=340)'); + + await setPlayheadTime(0.666); + await browser.pause(200); + await assertShapeExists(200, 380, 'Shape at keyframe 3 (x=200, y=380)'); + + // Check interpolation between keyframe 1 and 2 (at t=0.166, halfway) + await setPlayheadTime(0.166); + await browser.pause(200); + await assertShapeExists(170, 340, 'Shape interpolated between kf1 and kf2'); + + // Check interpolation between keyframe 2 and 3 (at t=0.5, halfway) + await setPlayheadTime(0.5); + await browser.pause(200); + await assertShapeExists(200, 360, 'Shape interpolated between kf2 and kf3'); + }); + }); + + describe('Group/Object Animation', () => { + it('should animate group position across keyframes', async () => { + // Create a group with two shapes + await drawRectangle(300, 300, 60, 60); + await drawRectangle(380, 300, 60, 60); + + await selectMultipleShapes([ + { x: 330, y: 330 }, + { x: 410, y: 330 } + ]); + await useKeyboardShortcut('g', true); + await browser.pause(300); + + // Verify both shapes exist at frame 1 + await assertShapeExists(330, 330, 'First shape at frame 1'); + await assertShapeExists(410, 330, 'Second shape at frame 1'); + + // Select the group by dragging a selection box over it + await selectMultipleShapes([{ x: 370, y: 330 }]); + await browser.pause(200); + + // Move to frame 10 and add keyframe + await setPlayheadTime(0.333); + await addKeyframe(); + await browser.pause(200); + + // Group is selected, so dragging will move it + // Drag from center of group down (but keep it within canvas bounds) + await dragCanvas(370, 330, 370, 380); + await browser.pause(300); + + // At frame 10, group should be at new position (moved 50px down) + await assertShapeExists(330, 380, 'First shape at new position at frame 10'); + await assertShapeExists(410, 380, 'Second shape at new position at frame 10'); + + // Go to frame 1 + await setPlayheadTime(0); + await browser.pause(200); + + // Group should be at original position + await assertShapeExists(330, 330, 'First shape at original position at frame 1'); + await assertShapeExists(410, 330, 'Second shape at original position at frame 1'); + + // Go to frame 5 (middle, t=0.166) + await setPlayheadTime(0.166); + await browser.pause(200); + + // Group should be interpolated (halfway between y=330 and y=380, so y=355) + await assertShapeExists(330, 355, 'First shape interpolated at frame 5'); + await assertShapeExists(410, 355, 'Second shape interpolated at frame 5'); + }); + + it('should maintain relative positions of shapes within animated group', async () => { + // Create a group (using safer y coordinates) + await drawRectangle(100, 250, 50, 50); + await drawRectangle(170, 250, 50, 50); + + await selectMultipleShapes([ + { x: 125, y: 275 }, + { x: 195, y: 275 } + ]); + await useKeyboardShortcut('g', true); + await browser.pause(300); + + // Select group + await selectMultipleShapes([{ x: 160, y: 275 }]); + await browser.pause(200); + + // Add keyframe and move + await setPlayheadTime(0.333); + await addKeyframe(); + await browser.pause(200); + + await dragCanvas(160, 275, 260, 275); + await browser.pause(300); + + // At both keyframes, shapes should maintain 70px horizontal distance + await setPlayheadTime(0); + await browser.pause(200); + await assertShapeExists(125, 275, 'First shape at frame 1'); + await assertShapeExists(195, 275, 'Second shape at frame 1 (70px apart)'); + + await setPlayheadTime(0.333); + await browser.pause(200); + // Both shapes moved 100px to the right + await assertShapeExists(225, 275, 'First shape at frame 10'); + await assertShapeExists(295, 275, 'Second shape at frame 10 (still 70px apart)'); + }); + }); + + describe('Interpolation', () => { + it('should smoothly interpolate between keyframes', async () => { + // Draw a simple shape + await drawRectangle(500, 100, 50, 50); + + // Select it + await selectMultipleShapes([{ x: 525, y: 125 }]); + await browser.pause(200); + + // Keyframe at start (x=525) + await setPlayheadTime(0); + await browser.pause(100); + + // Keyframe at end (1 second = frame 30, move to x=725) + await setPlayheadTime(1.0); + await addKeyframe(); + await browser.pause(200); + + await dragCanvas(525, 125, 725, 125); + await browser.pause(300); + + // Check multiple intermediate frames for smooth interpolation + // Total movement: 200px over 1 second + + // At 25% (0.25s), x should be 525 + 50 = 575 + await setPlayheadTime(0.25); + await browser.pause(200); + await assertShapeExists(575, 125, 'Shape at 25% interpolation'); + + // At 50% (0.5s), x should be 525 + 100 = 625 + await setPlayheadTime(0.5); + await browser.pause(200); + await assertShapeExists(625, 125, 'Shape at 50% interpolation'); + + // At 75% (0.75s), x should be 525 + 150 = 675 + await setPlayheadTime(0.75); + await browser.pause(200); + await assertShapeExists(675, 125, 'Shape at 75% interpolation'); + }); + }); +});