// The design mode's tools in Chromium (Playwright): devices from the palette, // moving them, undo, cables by drag and by two taps with Java's refusals, a // home router's WAN and LAN halves, the menus by long press and button, // deleting by key — with the mouse and, where a finger differs, by touch. import { expect, test, type Page } from '@playwright/test'; import { boxOf, drag, drawing, iconCentre, onDevice, openEditor, scaleOf, serveEditor } from './editorPage.js'; let server: { readonly base: string; readonly close: () => Promise }; test.beforeAll(async () => { server = await serveEditor(); }); test.afterAll(async () => { await server.close(); }); async function paletteCentre(page: Page, kind: string): Promise<{ x: number; y: number }> { const box = (await page.locator(`[data-palette="${kind}"]`).boundingBox())!; return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; } /** Drags a device from the palette to a point of the screen; its id. */ async function dropDevice(page: Page, kind: string, at: { x: number; y: number }): Promise { const before = new Set((await drawing(page)).nodes.map((node) => node.id)); await drag(page, await paletteCentre(page, kind), at); if (kind === 'router') await page.getByRole('dialog').getByRole('button', { name: '2', exact: true }).click(); await expect.poll(async () => (await drawing(page)).nodes.length).toBe(before.size + 1); return (await drawing(page)).nodes.find((node) => !before.has(node.id))!.id; } async function surfaceAt(page: Page, x: number, y: number): Promise<{ x: number; y: number }> { const box = (await page.locator('svg.filius-canvas').boundingBox())!; return { x: box.x + x, y: box.y + y }; } test('places a device from the palette where it is dropped, named as Java names it, and selects it', async ({ page }) => { await openEditor(page, server.base, 'new'); const at = await surfaceAt(page, 300, 250); const id = await dropDevice(page, 'pc', at); const node = (await drawing(page)).nodes[0]!; expect([node.kind, node.name, node.label]).toEqual(['pc', 'Rechner', 'Rechner']); const centre = await iconCentre(page, id); expect(Math.abs(centre.x - at.x)).toBeLessThan(1.5); expect(Math.abs(centre.y - at.y)).toBeLessThan(1.5); await expect(page.locator(`g.device[data-node="${id}"]`)).toHaveAttribute('data-selected', 'true'); // A tap on the palette puts one in the middle of the view; no second one from the click after a drag. await page.locator('[data-palette="notebook"]').click(); await expect.poll(async () => (await drawing(page)).nodes.length).toBe(2); const notebook = (await drawing(page)).nodes[1]!; expect(notebook.name).toBe('Notebook'); const middle = await surfaceAt(page, 0, 0); const surface = (await page.locator('svg.filius-canvas').boundingBox())!; const placed = await iconCentre(page, notebook.id); expect(Math.abs(placed.x - (middle.x + surface.width / 2))).toBeLessThan(1.5); expect(Math.abs(placed.y - (middle.y + surface.height / 2))).toBeLessThan(1.5); }); test("asks a router's interfaces and makes as many, two when the dialog is left", async ({ page }) => { await openEditor(page, server.base, 'new'); await drag(page, await paletteCentre(page, 'router'), await surfaceAt(page, 300, 200)); const dialog = page.getByRole('dialog', { name: 'Anzahl Schnittstellen' }); await expect(dialog).toBeVisible(); await dialog.getByRole('button', { name: '5', exact: true }).click(); await expect(dialog).toBeHidden(); expect((await drawing(page)).nodes[0]!.ports.map((port) => port.label)).toEqual(['rt1', 'rt2', 'rt3', 'rt4', 'rt5']); await drag(page, await paletteCentre(page, 'router'), await surfaceAt(page, 500, 200)); await expect(dialog).toBeVisible(); await page.keyboard.press('Escape'); await expect.poll(async () => (await drawing(page)).nodes.length).toBe(2); expect((await drawing(page)).nodes[1]!.ports).toHaveLength(2); expect((await drawing(page)).nodes.map((node) => node.name)).toEqual(['Vermittlungsrechner', 'Vermittlungsrechner']); }); test('moves a device with a drag, and takes it back and brings it again', async ({ page }) => { await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls'); const id = (await drawing(page)).nodes.find((node) => node.name === 'Notebook 1.10')!.id; // Zoomed in: the pointer's way is divided by the scale. await page.getByRole('button', { name: 'Vergrößern' }).click(); const before = await boxOf(page, id); const scale = await scaleOf(page); expect(scale).not.toBe(1); const from = await onDevice(page, id); await drag(page, from, { x: from.x + 80, y: from.y + 40 }); const moved = await boxOf(page, id); expect(moved.x).toBe(Math.round(before.x + 80 / scale)); expect(moved.y).toBe(Math.round(before.y + 40 / scale)); await page.getByRole('button', { name: 'Rückgängig' }).click(); expect(await boxOf(page, id)).toEqual(before); await page.getByRole('button', { name: 'Wiederholen' }).click(); expect(await boxOf(page, id)).toEqual(moved); await page.keyboard.press('Control+z'); expect(await boxOf(page, id)).toEqual(before); await expect(page.getByRole('button', { name: 'Rückgängig' })).toBeDisabled(); // Two drags right after each other are two steps back, not one. const start = await onDevice(page, id); await drag(page, start, { x: start.x + 50, y: start.y }); const first = await boxOf(page, id); await drag(page, { x: start.x + 50, y: start.y }, { x: start.x + 50, y: start.y + 50 }); await page.keyboard.press('Control+z'); expect(await boxOf(page, id)).toEqual(first); }); test('plugs cables with a drag and with two taps, and says why not as Java does', async ({ page }) => { await openEditor(page, server.base, 'new'); const pcA = await dropDevice(page, 'pc', await surfaceAt(page, 150, 150)); const pcB = await dropDevice(page, 'pc', await surfaceAt(page, 150, 400)); const hub = await dropDevice(page, 'networkSwitch', await surfaceAt(page, 450, 280)); const router = await dropDevice(page, 'router', await surfaceAt(page, 700, 280)); await page.locator('[data-palette="cable"]').click(); await expect(page.locator('[data-palette="cable"]')).toHaveAttribute('aria-pressed', 'true'); await drag(page, await onDevice(page, pcA), await onDevice(page, hub)); await expect.poll(async () => (await drawing(page)).links.length).toBe(1); // Two taps: the first marks the start, the second plugs. await page.mouse.click(...Object.values(await onDevice(page, pcB)) as [number, number]); await expect(page.locator(`g.device[data-node="${pcB}"] rect[stroke-dasharray]`)).toBeVisible(); await page.mouse.click(...Object.values(await onDevice(page, hub)) as [number, number]); await expect.poll(async () => (await drawing(page)).links.length).toBe(2); const links = (await drawing(page)).links; expect(links.map((link) => [link.sourceNode, link.targetNode])).toEqual([ [pcA, hub], [pcB, hub], ]); await drag(page, await onDevice(page, pcA), await onDevice(page, router)); await expect(page.getByRole('alert')).toHaveText('Maximale Anzahl angeschlossener Geräte überschritten'); await drag(page, await onDevice(page, router), await onDevice(page, hub)); await drag(page, await onDevice(page, hub), await onDevice(page, router)); await expect(page.getByRole('alert')).toHaveText('Komponenten bereits verbunden'); expect((await drawing(page)).links).toHaveLength(3); await expect(page.locator('path[data-link]')).toHaveCount(3); }); test("joins a home router at WAN on its button's left half and LAN on its right, and says which while dragging", async ({ page }) => { await openEditor(page, server.base, 'new'); const router = await dropDevice(page, 'router', await surfaceAt(page, 200, 150)); const hub = await dropDevice(page, 'networkSwitch', await surfaceAt(page, 200, 400)); const home = await dropDevice(page, 'gateway', await surfaceAt(page, 550, 280)); await page.locator('[data-palette="cable"]').click(); const left = await onDevice(page, home, 0.25); await page.mouse.move(...Object.values(await onDevice(page, router)) as [number, number]); await page.mouse.down(); await page.mouse.move(left.x, left.y, { steps: 8 }); await expect(page.locator('[data-hint]')).toHaveText('WAN-Anschluss (extern)'); await page.mouse.up(); await expect(page.locator('[data-hint]')).toHaveCount(0); const right = await onDevice(page, home, 0.75); await page.mouse.move(...Object.values(await onDevice(page, hub)) as [number, number]); await page.mouse.down(); await page.mouse.move(right.x, right.y, { steps: 8 }); await expect(page.locator('[data-hint]')).toHaveText('LAN-Anschluss (intern)'); await page.mouse.up(); const view = await drawing(page); const ports = view.nodes.find((node) => node.id === home)!.ports.map((port) => port.id); expect(view.links.map((link) => link.targetPort)).toEqual([ports[0], ports[1]]); }); test("opens a device's menu with a long press and with its button, and removes cables and devices", async ({ page }) => { await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls'); const view = await drawing(page); const hub = view.nodes.find((node) => node.name === 'Switch 1')!.id; const notebook = view.nodes.find((node) => node.name === 'Notebook 1.10')!.id; // A notebook with its cable: "remove cable", one (Java names the item by the kind). await page.mouse.click(...Object.values(await onDevice(page, notebook)) as [number, number]); await page.locator('.device-menu-button').click(); await expect(page.getByRole('menuitem')).toHaveText(['Verbindung entfernen', 'Löschen']); await page.keyboard.press('Escape'); await expect(page.getByRole('menu')).toBeHidden(); const press = await onDevice(page, hub); await page.mouse.move(press.x, press.y); await page.mouse.down(); await page.waitForTimeout(700); await expect(page.getByRole('menu')).toBeVisible(); await page.mouse.up(); await expect(page.getByRole('menuitem')).toHaveText(['Alle Verbindungen entfernen', 'Löschen']); await page.getByRole('menuitem', { name: 'Alle Verbindungen entfernen' }).click(); const after = await drawing(page); expect(after.links.some((link) => link.sourceNode === hub || link.targetNode === hub)).toBe(false); expect(after.nodes.some((node) => node.id === hub)).toBe(true); expect(after.links.length).toBe(view.links.length - 3); // The notebook's only cable went with the switch's: its menu has only "delete". await page.mouse.click(...Object.values(await onDevice(page, notebook)) as [number, number]); await page.locator('.device-menu-button').click(); await expect(page.getByRole('menuitem')).toHaveText(['Löschen']); await page.getByRole('menuitem', { name: 'Löschen' }).click(); expect((await drawing(page)).nodes.some((node) => node.id === notebook)).toBe(false); await page.keyboard.press('Control+z'); expect((await drawing(page)).nodes.some((node) => node.id === notebook)).toBe(true); }); test('selects a cable with a tap and removes it with the Delete key', async ({ page }) => { await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls'); const link = (await drawing(page)).links[0]!.id; const point = await page.locator(`path[data-link="${link}"]`).evaluate((path: SVGPathElement) => { const at = path.getPointAtLength(path.getTotalLength() / 2); const screen = at.matrixTransform(path.getScreenCTM()!); return { x: screen.x, y: screen.y }; }); await page.mouse.click(point.x, point.y); await expect(page.locator(`path[data-link="${link}"]`)).toHaveAttribute('data-selected', 'true'); await page.keyboard.press('Delete'); expect((await drawing(page)).links.some((each) => each.id === link)).toBe(false); }); test('selects a device with a tap right after the surface was moved', async ({ page }) => { await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls'); const surface = (await page.locator('svg.filius-canvas').boundingBox())!; await drag(page, { x: surface.x + 30, y: surface.y + surface.height - 30 }, { x: surface.x + 90, y: surface.y + surface.height - 60 }); const id = (await drawing(page)).nodes.find((node) => node.name === 'DNS-Server 2.10')!.id; await page.mouse.click(...Object.values(await onDevice(page, id)) as [number, number]); await expect(page.locator(`g.device[data-node="${id}"]`)).toHaveAttribute('data-selected', 'true'); }); test('by touch: a long press opens the menu, a finger drags a device', async ({ browser }) => { const context = await browser.newContext({ hasTouch: true, viewport: { width: 1024, height: 768 } }); const page = await context.newPage(); await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls'); const cdp = await context.newCDPSession(page); const touch = (type: 'touchStart' | 'touchMove' | 'touchEnd', points: readonly { x: number; y: number }[]) => cdp.send('Input.dispatchTouchEvent', { type, touchPoints: points.map(({ x, y }, id) => ({ x, y, id })) }); const id = (await drawing(page)).nodes.find((node) => node.name === 'Notebook 1.11')!.id; const at = await onDevice(page, id); await touch('touchStart', [at]); await page.waitForTimeout(700); await touch('touchEnd', []); await expect(page.getByRole('menu')).toBeVisible(); await page.keyboard.press('Escape'); await expect(page.getByRole('menu')).toBeHidden(); const before = await boxOf(page, id); const scale = await scaleOf(page); await touch('touchStart', [at]); for (let step = 1; step <= 6; step += 1) await touch('touchMove', [{ x: at.x + step * 10, y: at.y }]); await touch('touchEnd', []); expect((await boxOf(page, id)).x).toBe(Math.round(before.x + 60 / scale)); await expect(page.getByRole('menu')).toBeHidden(); await context.close(); });