// The editor by finger alone (F4.9, plan §7.2), in Chromium on the desktop // and in WebKit on an iPad upright and on its side (playwright.config.ts): // the acceptance of F4 as a script — FILIUS' „zwei Rechnernetze“ built // anew, set up and pinged across, without a mouse —; two fingers zoom the // surface and never the page (M7); every action without a right click // (long press, the three-dot button, the documentation's menu); every // target at least 44 px, nothing beyond the screen's edge. import { expect, test, type Page } from '@playwright/test'; import { drawing, onDevice, openEditor, serveEditor } from './editorPage.js'; import { fingers, type Fingers, type Point } from './fingers.js'; let server: { readonly base: string; readonly close: () => Promise }; test.use({ hasTouch: true }); test.beforeAll(async () => { server = await serveEditor(); }); test.afterAll(async () => { await server.close(); }); const sheet = (page: Page) => page.getByRole('complementary', { name: 'Einstellungen' }); const field = (page: Page, name: string) => sheet(page).locator(`input[name="${name}"]`); async function centreOf(page: Page, selector: string): Promise { const box = (await page.locator(selector).first().boundingBox())!; return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; } /** A point of the surface by its share of the surface's width and height. */ async function surfaceAt(page: Page, across: number, down: number): Promise { const box = (await page.locator('svg.filius-canvas').boundingBox())!; return { x: box.x + box.width * across, y: box.y + box.height * down }; } /** A device dragged from the palette by finger; its sheet closed again (it opens with the new device chosen). */ async function place(page: Page, finger: Fingers, kind: string, at: Point): Promise { const before = new Set((await drawing(page)).nodes.map((node) => node.id)); await finger.drag(await centreOf(page, `[data-palette="${kind}"]`), at); if (kind === 'router') await page.getByRole('dialog').getByRole('button', { name: '2', exact: true }).tap(); await expect.poll(async () => (await drawing(page)).nodes.length).toBe(before.size + 1); await sheet(page).getByRole('button', { name: 'Schließen' }).tap(); await expect(sheet(page)).toHaveCount(0); return (await drawing(page)).nodes.find((node) => !before.has(node.id))!.id; } /** A field of the sheet typed into after a tap, as the iPad's keyboard types. */ async function type(page: Page, name: string, text: string): Promise { await field(page, name).tap(); await field(page, name).fill(text); await field(page, name).press('Enter'); } test('builds FILIUS\' „zwei Rechnernetze“ by finger and pings across (the acceptance of F4)', async ({ page }) => { test.setTimeout(120_000); await openEditor(page, server.base, 'new'); const finger = await fingers(page); const pcA = await place(page, finger, 'pc', await surfaceAt(page, 0.18, 0.25)); const switchA = await place(page, finger, 'networkSwitch', await surfaceAt(page, 0.2, 0.6)); const router = await place(page, finger, 'router', await surfaceAt(page, 0.5, 0.42)); const switchB = await place(page, finger, 'networkSwitch', await surfaceAt(page, 0.8, 0.6)); const pcB = await place(page, finger, 'pc', await surfaceAt(page, 0.82, 0.25)); // The cable tool: from device to device, a free port each; tapped again, it is off. await page.locator('[data-palette="cable"]').tap(); for (const [from, to] of [ [pcA, switchA], [switchA, router], [router, switchB], [switchB, pcB], ] as const) { const count = (await drawing(page)).links.length; await finger.drag(await onDevice(page, from), await onDevice(page, to)); await expect.poll(async () => (await drawing(page)).links.length).toBe(count + 1); } await page.locator('[data-palette="cable"]').tap(); await expect(page.locator('[data-palette="cable"]')).toHaveAttribute('aria-pressed', 'false'); // The addresses, in the sheet a tap on a device opens. for (const [id, address, gateway] of [ [pcA, '141.99.1.10', '141.99.1.1'], [pcB, '141.99.2.10', '141.99.2.1'], ] as const) { await finger.tap(await onDevice(page, id)); await expect(sheet(page)).toBeVisible(); await type(page, 'ipAddress', address); await type(page, 'subnetMask', '255.255.255.0'); await type(page, 'defaultGateway', gateway); await sheet(page).getByRole('button', { name: 'Schließen' }).tap(); } // The router's first card went to the first switch (the first cable it got), the second to the other. await finger.tap(await onDevice(page, router)); const tabs = sheet(page).getByRole('tab'); await tabs.nth(1).tap(); await type(page, 'ipAddress', '141.99.1.1'); await tabs.nth(2).tap(); await type(page, 'ipAddress', '141.99.2.1'); await expect(tabs).toHaveText(['Allgemein', '141.99.1.1', '141.99.2.1', 'Weiterleitungstabelle']); await sheet(page).getByRole('button', { name: 'Schließen' }).tap(); // The simulation; the command line installed on the first computer; a ping across the router. await page.getByRole('button', { name: 'Wechselt in den Aktionsmodus' }).tap(); await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation'); await finger.tap(await onDevice(page, pcA)); const desktop = page.locator(`section[data-desktop="${pcA}"]`); await desktop.locator('[data-program="installation"]').tap(); const installation = desktop.locator('[data-program-window="installation"]'); await installation.locator('[data-list="available"] [role="option"]').filter({ hasText: 'Befehlszeile' }).tap(); await installation.getByRole('button', { name: 'Installieren' }).tap(); await installation.getByRole('button', { name: 'Änderungen annehmen' }).tap(); await desktop.locator('[data-program="commandPrompt"]').tap(); const terminal = desktop.locator('[data-program-window="commandPrompt"]'); await terminal.getByRole('button', { name: 'ping', exact: true }).tap(); const input = terminal.locator('input[name="terminalInput"]'); await expect(input).toHaveValue('ping '); await input.pressSequentially('141.99.2.10'); await input.press('Enter'); await expect(terminal.locator('.terminal-text')).toContainText('4 Paket(e) gesendet, 4 Paket(e) empfangen', { timeout: 30_000 }); }); test('zooms the surface with two fingers and never the page (M7)', async ({ page }) => { await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls'); const finger = await fingers(page); const canvas = page.locator('svg.filius-canvas'); await expect(canvas).toHaveCSS('touch-action', 'none'); const zoom = () => page.locator('[data-zoom]').getAttribute('data-zoom').then(Number); const page_ = () => page.evaluate(() => ({ scale: window.visualViewport?.scale ?? 1, x: window.scrollX, y: window.scrollY })); const start = await zoom(); const middle = await surfaceAt(page, 0.4, 0.5); await finger.pinch(middle, 40, 80); await expect.poll(zoom).toBeGreaterThan(start * 1.5); expect(await page_()).toEqual({ scale: 1, x: 0, y: 0 }); await finger.pinch(middle, 80, 30); await expect.poll(zoom).toBeLessThan(start); expect(await page_()).toEqual({ scale: 1, x: 0, y: 0 }); }); test('reaches every action without a right click: a long press, the three-dot button, the documentation', async ({ page }) => { await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls'); const finger = await fingers(page); const view = await drawing(page); const notebook = view.nodes.find((node) => node.name === 'Notebook 1.11')!.id; // A long press opens a device's menu; „Löschen“ removes it with its cable. await finger.press(await onDevice(page, notebook)); const menu = page.getByRole('menu'); await expect(menu).toBeVisible(); await menu.getByRole('menuitem', { name: 'Löschen' }).tap(); await expect.poll(async () => (await drawing(page)).nodes.length).toBe(view.nodes.length - 1); // A tap chooses a device; its three-dot button opens the same menu. const router = view.nodes.find((node) => node.kind === 'router')!.id; await finger.tap(await onDevice(page, router)); await sheet(page).getByRole('button', { name: 'Schließen' }).tap(); await finger.tap(await onDevice(page, router)); await page.getByRole('button', { name: 'Aktionen' }).tap(); await expect(menu).toBeVisible(); await expect(menu.getByRole('menuitem', { name: 'Löschen' })).toBeVisible(); await finger.tap(await surfaceAt(page, 0.5, 0.95)); await expect(menu).toHaveCount(0); // The documentation mode: a rectangle dragged from its sidebar, its menu by a long press, removed. await page.getByRole('button', { name: 'Wechselt in den Dokumentationsmodus' }).tap(); const docs = view.docs.length; await finger.drag(await centreOf(page, '[data-docu="rectangle"]'), await surfaceAt(page, 0.5, 0.3)); await expect.poll(async () => (await drawing(page)).docs.length).toBe(docs + 1); const rectangle = (await drawing(page)).docs.find((item) => !view.docs.some((each) => each.id === item.id))!; await finger.press(await centreOf(page, `g[data-doc="${rectangle.id}"] .doc-rectangle`)); await menu.getByRole('menuitem', { name: 'Löschen' }).tap(); await expect.poll(async () => (await drawing(page)).docs.length).toBe(docs); }); test('gives every target 44 px at least and keeps the editor within the screen', async ({ page }) => { await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls'); const finger = await fingers(page); const editor = page.locator('main'); const fits = () => editor.evaluate((element) => element.scrollWidth <= element.clientWidth && element.getBoundingClientRect().right <= window.innerWidth); const small = (selector: string) => page.locator(selector).evaluateAll((elements) => elements .filter((element) => (element as HTMLElement).offsetParent !== null) .map((element) => ({ name: element.getAttribute('aria-label') ?? element.textContent?.trim() ?? '', box: element.getBoundingClientRect() })) .filter(({ box }) => box.width < 44 || box.height < 44) .map(({ name, box }) => `${name} ${Math.round(box.width)}×${Math.round(box.height)}`), ); const targets = '.editor-toolbar button, [data-palette], [data-docu], nav button, [data-zoom] ~ button, .zoom-bar button, .device-menu-button, aside[aria-label="Einstellungen"] button, aside[aria-label="Einstellungen"] [role="tab"]'; expect(await fits()).toBe(true); expect(await small(targets)).toEqual([]); // With a device chosen: its sheet and its three-dot button. const router = (await drawing(page)).nodes.find((node) => node.kind === 'router')!.id; await finger.tap(await onDevice(page, router)); await expect(sheet(page)).toBeVisible(); expect(await fits()).toBe(true); expect(await small(targets)).toEqual([]); // The documentation mode's sidebar. await sheet(page).getByRole('button', { name: 'Schließen' }).tap(); await page.getByRole('button', { name: 'Wechselt in den Dokumentationsmodus' }).tap(); expect(await fits()).toBe(true); expect(await small(targets)).toEqual([]); });