diff --git a/frontend/playwright-tests/tool-exhaustive.test.ts b/frontend/playwright-tests/tool-exhaustive.test.ts new file mode 100644 index 0000000..b982587 --- /dev/null +++ b/frontend/playwright-tests/tool-exhaustive.test.ts @@ -0,0 +1,271 @@ +import { test, expect, chromium, Page } from '@playwright/test'; + +const API_URL = process.env.API_URL || 'http://localhost:3001'; +const BASE_URL = process.env.FRONTEND_URL || 'http://localhost:5173'; + +async function api(method: string, endpoint: string, body?: unknown, token?: string) { + const headers: Record = { 'Content-Type': 'application/json' }; + if (token) headers.Authorization = `Bearer ${token}`; + const url = `${API_URL}${endpoint}`; + for (let attempt = 0; attempt < 3; attempt++) { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10000); + const res = await fetch(url, { method, headers, body: body ? JSON.stringify(body) : undefined, signal: controller.signal }); + clearTimeout(timeout); + const text = await res.text(); + let json: any = null; + try { json = text ? JSON.parse(text) : null; } catch {} + return { status: res.status, body: json }; + } catch (e) { + if (attempt === 2) throw e; + await new Promise(r => setTimeout(r, 500)); + } + } + throw new Error('unreachable'); +} + +class ToolTester { + page: Page; + token: string; + projectId: string; + drawingId: string; + cx: number; + cy: number; + report: Array<{ tool: string; category: string; pass: boolean; note: string; before: number; after: number }> = []; + + constructor(page: Page, token: string, projectId: string, drawingId: string, cx: number, cy: number) { + this.page = page; this.token = token; this.projectId = projectId; this.drawingId = drawingId; this.cx = cx; this.cy = cy; + } + + async getCount(): Promise { + const r = await api('GET', `/api/drawings/${this.drawingId}/elements`, undefined, this.token); + return r.body?.length ?? 0; + } + + async clickTool(tool: string) { + const btn = this.page.locator(`[data-tool="${tool}"]`).first(); + await btn.scrollIntoViewIfNeeded(); + await btn.click(); + await this.page.waitForTimeout(250); + } + + async esc() { await this.page.keyboard.press('Escape'); await this.page.waitForTimeout(200); } + + async click(x: number, y: number) { await this.page.mouse.click(x, y); await this.page.waitForTimeout(300); } + + async drag(x1: number, y1: number, x2: number, y2: number) { + await this.page.mouse.move(x1, y1); + await this.page.mouse.down(); + await this.page.mouse.move(x2, y2, { steps: 5 }); + await this.page.mouse.up(); + await this.page.waitForTimeout(500); + } + + async reset() { + try { await this.clickTool('select'); await this.esc(); } catch {} + await this.page.waitForTimeout(200); + } + + async run(tool: string, category: string, action: () => Promise, expectChange?: 'increase' | 'decrease' | 'same' | 'any') { + let before = 0, after = 0, pass = false, note = ''; + try { + await this.reset(); + before = await this.getCount(); + await this.clickTool(tool); + await action(); + await this.page.waitForTimeout(700); + after = await this.getCount(); + if (expectChange === 'increase') pass = after > before; + else if (expectChange === 'decrease') pass = after < before; + else if (expectChange === 'same') pass = after === before; + else pass = true; + note = `before=${before} after=${after}`; + } catch (e: any) { + pass = false; + note = `ERROR: ${e.message}`; + try { after = await this.getCount(); } catch {} + } + this.report.push({ tool, category, pass, note, before, after }); + console.log(`${tool}: ${pass ? 'PASS' : 'FAIL'} - ${note}`); + await this.reset(); + } +} + +test('exhaustive tool test v2', async () => { + test.setTimeout(600000); + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } }); + const logs: string[] = []; + page.on('console', msg => logs.push(`${msg.type()}: ${msg.text()}`)); + page.on('pageerror', err => logs.push(`PAGEERROR: ${err.message}`)); + + const email = `exhaustive-v2-${Date.now()}@example.com`; + const reg = await api('POST', '/api/auth/register', { email, password: 'Test1234!', name: 'Exhaustive V2 Bot' }); + const token = reg.body.session.token; + const proj = await api('POST', '/api/projects', { name: 'Exhaustive V2', description: 'x' }, token); + const projectId = proj.body.id; + + await page.goto(`${BASE_URL}/`); + await page.evaluate((t: string) => localStorage.setItem('auth_token', t), token); + await page.reload(); + await page.waitForSelector('.dashboard-project-card', { timeout: 10000 }); + await page.locator('.dashboard-project-card').first().click(); + await page.locator('button:has-text("Projekt öffnen")').first().click(); + await page.waitForSelector('canvas', { timeout: 10000 }); + await page.waitForTimeout(2500); + + let drawingId: string | null = null; + for (let i = 0; i < 30; i++) { + const ds = await api('GET', `/api/projects/${projectId}/drawings`, undefined, token); + if (ds.body?.length) { drawingId = ds.body[0].id; break; } + await new Promise(r => setTimeout(r, 300)); + } + if (!drawingId) throw new Error('No drawing created'); + + const canvas = page.locator('canvas').first(); + const box = await canvas.boundingBox(); + if (!box) throw new Error('Canvas not found'); + const cx = box.x + box.width / 2; + const cy = box.y + box.height / 2; + + const T = new ToolTester(page, token, projectId, drawingId, cx, cy); + + // --- Zeichnen --- + await T.run('line', 'Zeichnen', async () => { await T.drag(cx - 100, cy, cx + 100, cy); }, 'increase'); + await T.run('rect', 'Zeichnen', async () => { await T.drag(cx - 120, cy - 80, cx + 120, cy + 80); }, 'increase'); + await T.run('circle', 'Zeichnen', async () => { await T.drag(cx, cy - 50, cx, cy + 50); }, 'increase'); + await T.run('arc', 'Zeichnen', async () => { await T.click(cx, cy); await T.click(cx + 60, cy); await T.click(cx, cy + 60); }, 'increase'); + await T.run('polyline', 'Zeichnen', async () => { await T.click(cx - 80, cy - 80); await T.click(cx, cy - 80); await T.click(cx + 80, cy - 80); await page.keyboard.press('Enter'); }, 'increase'); + await T.run('polygon', 'Zeichnen', async () => { await T.click(cx - 70, cy + 70); await T.click(cx, cy + 140); await T.click(cx + 70, cy + 70); await page.keyboard.press('Enter'); }, 'increase'); + await T.run('dimension', 'Zeichnen', async () => { await T.drag(cx - 100, cy - 200, cx + 100, cy - 200); }, 'increase'); + await T.run('leader', 'Zeichnen', async () => { await T.drag(cx - 150, cy - 150, cx - 80, cy - 220); }, 'increase'); + await T.run('revcloud', 'Zeichnen', async () => { await T.click(cx - 50, cy + 150); await T.click(cx + 50, cy + 150); await T.click(cx + 50, cy + 250); await T.click(cx - 50, cy + 250); await page.keyboard.press('Enter'); }, 'increase'); + + // --- Bestuhlung --- + await T.run('chair', 'Bestuhlung', async () => { await T.click(cx - 200, cy - 200); }, 'increase'); + await T.run('table', 'Bestuhlung', async () => { await T.click(cx + 200, cy - 200); }, 'increase'); + await T.run('stage', 'Bestuhlung', async () => { await T.click(cx + 200, cy + 200); }, 'increase'); + await T.run('seating-row', 'Bestuhlung', async () => { await T.click(cx - 200, cy + 200); }, 'increase'); + await T.run('seating-block', 'Bestuhlung', async () => { await T.click(cx - 250, cy + 100); }, 'increase'); + await T.run('seating-template', 'Bestuhlung', async () => { + const sel = page.locator('select.template-dropdown').first(); + await sel.selectOption({ index: 1 }); + await page.waitForTimeout(400); + await T.click(cx + 250, cy + 100); + }, 'increase'); + + // --- Text --- + await T.run('text', 'Zeichnen', async () => { + await T.click(cx, cy + 300); + await page.waitForTimeout(300); + const editor = page.locator('[contenteditable="true"]').first(); + if (await editor.isVisible().catch(() => false)) { + await editor.fill('Testtext'); + await page.keyboard.press('Enter'); + } + await T.esc(); + }, 'increase'); + + // --- Hatch (needs closed shape) --- + await T.run('hatch', 'Zeichnen', async () => { + // ensure a rect in center + const cnt = await T.getCount(); + if (cnt === 0) { await T.clickTool('rect'); await T.drag(cx - 50, cy - 50, cx + 50, cy + 50); await T.clickTool('hatch'); } + await T.click(cx, cy); + }, 'any'); + + // --- Measure --- + await T.run('measure', 'Anzeige', async () => { await T.click(cx - 50, cy - 50); await T.click(cx + 50, cy + 50); }, 'same'); + + // --- Bearbeiten: create base line --- + await T.clickTool('line'); + await T.drag(cx - 100, cy + 150, cx + 100, cy + 150); + await T.reset(); + + await T.run('move', 'Bearbeiten', async () => { + await T.clickTool('select'); await T.click(cx, cy + 150); + await T.clickTool('move'); await T.drag(cx, cy + 150, cx + 50, cy + 200); + }, 'same'); + + await T.run('copy', 'Bearbeiten', async () => { + await T.clickTool('select'); await T.click(cx + 50, cy + 200); + await T.clickTool('copy'); await T.drag(cx + 50, cy + 200, cx + 50, cy + 250); + }, 'increase'); + + await T.run('rotate', 'Bearbeiten', async () => { + await T.clickTool('select'); await T.click(cx + 50, cy + 250); + await T.clickTool('rotate'); await T.drag(cx + 50, cy + 250, cx + 100, cy + 250); + }, 'same'); + + await T.run('scale', 'Bearbeiten', async () => { + await T.clickTool('select'); await T.click(cx + 50, cy + 250); + await T.clickTool('scale'); await T.drag(cx + 50, cy + 250, cx + 120, cy + 250); + }, 'same'); + + await T.run('mirror', 'Bearbeiten', async () => { + await T.clickTool('select'); await T.click(cx + 50, cy + 250); + await T.clickTool('mirror'); await T.click(cx - 80, cy + 120); await T.click(cx - 80, cy + 280); + }, 'same'); + + await T.run('offset', 'Bearbeiten', async () => { + await T.clickTool('select'); await T.click(cx + 50, cy + 250); + await T.clickTool('offset'); await T.click(cx + 50, cy + 250); await T.click(cx + 120, cy + 250); + }, 'increase'); + + // Draw a dedicated line to delete, then verify deletion + await T.clickTool('line'); + await T.drag(cx - 80, cy + 140, cx + 80, cy + 140); + await page.waitForTimeout(1500); + + await T.run('delete', 'Bearbeiten', async () => { + await T.clickTool('delete'); + await T.click(cx, cy + 140); + }, 'decrease'); + + // --- Trim/Extend/Fillet --- + await T.clickTool('line'); await T.drag(cx - 100, cy + 220, cx + 100, cy + 220); + await T.clickTool('line'); await T.drag(cx, cy + 170, cx, cy + 270); + await T.reset(); + + await T.run('trim', 'Bearbeiten', async () => { + await T.clickTool('trim'); await T.click(cx, cy + 220); await T.click(cx, cy + 190); + }, 'any'); + + // recreate crossing lines if needed + if (await T.getCount() < 2) { + await T.clickTool('line'); await T.drag(cx - 100, cy + 220, cx + 100, cy + 220); + await T.clickTool('line'); await T.drag(cx, cy + 170, cx, cy + 270); + await T.reset(); + } + + await T.run('extend', 'Bearbeiten', async () => { + await T.clickTool('extend'); await T.click(cx, cy + 220); await T.click(cx + 120, cy + 220); + }, 'any'); + + // Fillet corner + await T.clickTool('line'); await T.drag(cx - 80, cy + 120, cx - 80, cy + 180); + await T.clickTool('line'); await T.drag(cx - 80, cy + 180, cx + 20, cy + 180); + await T.reset(); + + await T.run('fillet', 'Bearbeiten', async () => { + await T.clickTool('fillet'); await T.click(cx - 80, cy + 150); await T.click(cx - 30, cy + 180); + }, 'any'); + + // --- Pan / Select smoke --- + await T.run('pan', 'Anzeige', async () => { + await page.mouse.move(cx, cy); await page.mouse.down({ button: 'middle' }); await page.mouse.move(cx + 50, cy + 50); await page.mouse.up({ button: 'middle' }); + }, 'same'); + await T.run('select', 'Anzeige', async () => { await T.click(cx, cy); }, 'same'); + + await page.evaluate((r: any) => { (window as any).__toolReport = r; }, T.report); + + console.log('\n=== SUMMARY ==='); + const fails = T.report.filter(r => !r.pass); + console.log(`Total: ${T.report.length}, Passed: ${T.report.length - fails.length}, Failed: ${fails.length}`); + fails.forEach(f => console.log(`FAIL: ${f.tool} (${f.category}) - ${f.note}`)); + + expect(fails.length).toBe(0); + + await browser.close(); +}); diff --git a/frontend/src/interaction/index.ts b/frontend/src/interaction/index.ts index d470fe7..0330d66 100644 --- a/frontend/src/interaction/index.ts +++ b/frontend/src/interaction/index.ts @@ -593,6 +593,7 @@ export class InteractionEngine { // Text: single click placement, then prompt for text content if (this.state.activeTool === 'text') { + this.updatePreview(pt); this.confirmDraw(); return; } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 911c41e..6b38c8c 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -104,7 +104,7 @@ export async function createProject(token: string, name: string, description?: s export async function deleteProject(token: string, id: string): Promise { await fetch(`${API_BASE}/api/projects/${id}`, { method: 'DELETE', - headers: authHeaders(token), + headers: { Authorization: `Bearer ${token}` }, }); } @@ -156,7 +156,7 @@ export async function renameProjectFolder(token: string, id: string, name: strin export async function deleteProjectFolder(token: string, id: string): Promise { const res = await fetch(`${API_BASE}/api/project-folders/${id}`, { method: 'DELETE', - headers: authHeaders(token), + headers: { Authorization: `Bearer ${token}` }, }); if (!res.ok) throw new Error('Failed to delete project folder'); } @@ -227,7 +227,7 @@ export async function updateElement(token: string, elementId: string, patch: Par export async function deleteElement(token: string, elementId: string): Promise { await fetch(`${API_BASE}/api/elements/${elementId}`, { method: 'DELETE', - headers: authHeaders(token), + headers: { Authorization: `Bearer ${token}` }, }); } @@ -261,7 +261,7 @@ export async function updateLayer(token: string, layerId: string, patch: Partial export async function deleteLayer(token: string, layerId: string): Promise { await fetch(`${API_BASE}/api/layers/${layerId}`, { method: 'DELETE', - headers: authHeaders(token), + headers: { Authorization: `Bearer ${token}` }, }); } @@ -295,7 +295,7 @@ export async function updateBlock(token: string, blockId: string, patch: Partial export async function deleteBlock(token: string, blockId: string): Promise { await fetch(`${API_BASE}/api/blocks/${blockId}`, { method: 'DELETE', - headers: authHeaders(token), + headers: { Authorization: `Bearer ${token}` }, }); } @@ -537,7 +537,7 @@ export async function markNotificationRead(token: string, id: string): Promise { const res = await fetch(`${API_BASE}/api/notifications/${id}`, { method: 'DELETE', - headers: authHeaders(token), + headers: { Authorization: `Bearer ${token}` }, }); if (!res.ok) throw new Error('Failed to delete notification'); } @@ -572,7 +572,7 @@ export async function createProjectShare(token: string, projectId: string, data: export async function deleteProjectShare(token: string, shareId: string): Promise { const res = await fetch(`${API_BASE}/api/shares/${shareId}`, { method: 'DELETE', - headers: authHeaders(token), + headers: { Authorization: `Bearer ${token}` }, }); if (!res.ok) throw new Error('Failed to delete share'); } @@ -644,7 +644,7 @@ export async function renameGlobalFolder(token: string, id: string, name: string export async function deleteGlobalFolder(token: string, id: string): Promise { await fetch(`${API_BASE}/api/global-folders/${id}`, { method: 'DELETE', - headers: authHeaders(token), + headers: { Authorization: `Bearer ${token}` }, }); } @@ -696,7 +696,7 @@ export async function renameGlobalBlock(token: string, id: string, name: string) export async function deleteGlobalBlock(token: string, id: string): Promise { await fetch(`${API_BASE}/api/global-blocks/${id}`, { method: 'DELETE', - headers: authHeaders(token), + headers: { Authorization: `Bearer ${token}` }, }); }