test: F4.9 the editor by finger in Chromium and on an iPad upright and on its side

The touch test runs in Chromium on the desktop and in WebKit as an iPad
(810x1080 and 1080x810): the acceptance of F4 as a script ("zwei
Rechnernetze" built anew by finger, set up and pinged across), two fingers
zoom the surface and never the page (M7, without a frame), every action
without a right click, every target at least 44 px. Real gestures: CDP in
Chromium, WebKit's own Input.dispatchTouchEvent through Playwright's
in-process connection (Playwright pinned to 1.62.1). A dragged finger holds
still before it is lifted, or Chromium takes it for a fling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 13:02:59 +00:00
co-authored by Claude Opus 5.5
parent b8b999fd66
commit bd2e725791
7 changed files with 300 additions and 5 deletions
+203
View File
@@ -0,0 +1,203 @@
// 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<void> };
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<Point> {
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<Point> {
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<string> {
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<void> {
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([]);
});