A button at the top of the palette and the documentation's bar folds it to a slim column, as the dashboard's own bar does; one state for both, kept in the browser. Up to 639 px the device sheet covers the whole editor, over the bar and the mode buttons, instead of sharing half the height below the surface. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
288 lines
16 KiB
TypeScript
288 lines
16 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();
|
|
});
|
|
|
|
test('folds the palette to a slim column with its button in the same place, kept for the next visit and for the documentation', async ({ page }) => {
|
|
await openEditor(page, server.base, 'new');
|
|
const palette = page.getByRole('navigation', { name: 'Komponenten' });
|
|
const fold = palette.getByRole('button', { name: 'Leiste einklappen' });
|
|
const before = { fold: (await fold.boundingBox())!, bar: (await palette.boundingBox())!, surface: (await page.locator('svg.filius-canvas').boundingBox())! };
|
|
// The button stands at the top, over the cable.
|
|
expect(before.fold.y + before.fold.height).toBeLessThanOrEqual((await paletteCentre(page, 'cable')).y);
|
|
await expect(fold).toHaveAttribute('aria-expanded', 'true');
|
|
await fold.click();
|
|
|
|
const unfold = palette.getByRole('button', { name: 'Leiste ausklappen' });
|
|
await expect(unfold).toHaveAttribute('aria-expanded', 'false');
|
|
await expect(palette.locator('[data-palette]')).toHaveCount(0);
|
|
const folded = { fold: (await unfold.boundingBox())!, bar: (await palette.boundingBox())!, surface: (await page.locator('svg.filius-canvas').boundingBox())! };
|
|
expect([folded.fold.x, folded.fold.y, folded.fold.width, folded.fold.height]).toEqual([before.fold.x, before.fold.y, 44, 44]);
|
|
expect(folded.bar.width).toBe(57);
|
|
expect(folded.surface.x).toBe(before.surface.x - (before.bar.width - 57));
|
|
expect(folded.surface.width).toBe(before.surface.width + (before.bar.width - 57));
|
|
|
|
// Folded on the next visit, and the documentation's bar with it; unfolded there, the palette is back.
|
|
await openEditor(page, server.base, 'new');
|
|
await expect(palette.getByRole('button', { name: 'Leiste ausklappen' })).toBeVisible();
|
|
await expect(palette.locator('[data-palette]')).toHaveCount(0);
|
|
await page.getByRole('button', { name: 'Wechselt in den Dokumentationsmodus' }).click();
|
|
const sidebar = page.getByRole('navigation', { name: 'Dokumentation' });
|
|
await expect(sidebar.locator('[data-docu]')).toHaveCount(0);
|
|
await sidebar.getByRole('button', { name: 'Leiste ausklappen' }).click();
|
|
await expect(sidebar.locator('[data-docu]')).toHaveCount(2);
|
|
await page.getByRole('button', { name: 'Wechselt in den Entwurfsmodus' }).click();
|
|
await expect(palette.locator('[data-palette]')).toHaveCount(7);
|
|
expect((await palette.boundingBox())!.width).toBe(before.bar.width);
|
|
});
|
|
|
|
test('names the fold button in English when asked', async ({ page }) => {
|
|
await openEditor(page, server.base, 'new', '&lang=en');
|
|
const palette = page.getByRole('navigation', { name: 'Components' });
|
|
await palette.getByRole('button', { name: 'Collapse the bar' }).click();
|
|
await expect(palette.getByRole('button', { name: 'Expand the bar' })).toHaveAttribute('title', 'Expand the bar');
|
|
});
|