Files
mdg-filius/packages/editor/browser/design.spec.ts
T
julian.kiedaischandClaude Opus 5.5 806df6baca feat(editor): F4.4b the design tools: palette, moving, cables, menus, undo
The palette in Java's order with Java's icons and names: a device dragged
onto the surface lands with its icon at the drop point, a tapped one in the
middle of the view; a new router asks for 2 to 8 interfaces as Java does.
A tap selects, a drag moves (divided by the scale), a long press or the
three-dot button opens Java's menu. The cable tool plugs by a drag or by
two taps, shows WAN or LAN on a home router's halves and Java's messages
when refused. Delete removes the selection; undo and redo by button and
Ctrl+Z/Ctrl+Y, each editor with its own origin. Browser tests with mouse
and touch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:37:49 +00:00

248 lines
14 KiB
TypeScript

// 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<void> };
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<string> {
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();
});