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
+85
View File
@@ -0,0 +1,85 @@
// Fingers on the screen, in Chromium and in WebKit alike (plan §7.2, M7): a
// tap through Playwright's touchscreen; a press, a drag and two fingers
// through each engine's own touch input — Chromium's DevTools protocol
// (Input.dispatchTouchEvent), and WebKit's page protocol, whose command has
// the same name and points but which Playwright does not hand out: it is
// reached through the in-process connection (Playwright runs its server in
// the test's process). That seam belongs to Playwright's insides; the
// version is pinned (package.json), and a newer one may move it.
import type { CDPSession, Page } from '@playwright/test';
export interface Point {
readonly x: number;
readonly y: number;
}
type TouchType = 'touchStart' | 'touchMove' | 'touchEnd';
export interface Fingers {
tap(at: Point): Promise<void>;
/** One finger held where it is, then lifted (a long press: 700 ms). */
press(at: Point, milliseconds?: number): Promise<void>;
/** One finger moved from one point to another in steps. */
drag(from: Point, to: Point, steps?: number): Promise<void>;
/** Two fingers either side of a middle, moved from one distance apart to another. */
pinch(middle: Point, from: number, to: number, steps?: number): Promise<void>;
}
interface WebKitSession {
send(method: string, params: unknown): Promise<unknown>;
}
/** WebKit's page session: Playwright's own object behind the page, its page proxy's session. */
function webKitSession(page: Page): WebKitSession {
const connection = (page as unknown as { _connection: { toImpl?: (object: unknown) => { delegate?: unknown; _delegate?: unknown } } })._connection;
const server = connection.toImpl?.(page);
const delegate = (server?.delegate ?? server?._delegate) as { _pageProxySession?: WebKitSession } | undefined;
if (delegate?._pageProxySession === undefined) throw new Error("WebKit's page session is out of reach: Playwright's insides have changed");
return delegate._pageProxySession;
}
export async function fingers(page: Page): Promise<Fingers> {
const engine = page.context().browser()?.browserType().name();
let send: (type: TouchType, points: readonly Point[]) => Promise<unknown>;
if (engine === 'webkit') {
const session = webKitSession(page);
// WebKit ends a touch with the fingers lifted, not with none.
let last: readonly Point[] = [];
send = (type, points) => {
const touched = type === 'touchEnd' ? last : points;
if (type !== 'touchEnd') last = points;
return session.send('Input.dispatchTouchEvent', { type, touchPoints: touched.map(({ x, y }, id) => ({ x: Math.round(x), y: Math.round(y), id })) });
};
} else {
const cdp: CDPSession = await page.context().newCDPSession(page);
send = (type, points) => cdp.send('Input.dispatchTouchEvent', { type, touchPoints: points.map(({ x, y }, id) => ({ x, y, id })) });
}
const steps = (count: number) => Array.from({ length: count }, (_, index) => (index + 1) / count);
return {
tap: (at) => page.touchscreen.tap(at.x, at.y),
async press(at, milliseconds = 700) {
await send('touchStart', [at]);
await page.waitForTimeout(milliseconds);
await send('touchEnd', []);
},
async drag(from, to, count = 8) {
await send('touchStart', [from]);
for (const share of steps(count)) await send('touchMove', [{ x: from.x + (to.x - from.x) * share, y: from.y + (to.y - from.y) * share }]);
// Held still before it is lifted, as a finger that puts something down: lifted on the move, Chromium
// takes it for a fling, and the next tap only stops the fling (no click).
await page.waitForTimeout(150);
await send('touchEnd', []);
},
async pinch(middle, from, to, count = 4) {
const pair = (distance: number): Point[] => [
{ x: middle.x - distance, y: middle.y },
{ x: middle.x + distance, y: middle.y },
];
await send('touchStart', pair(from));
for (const share of steps(count)) await send('touchMove', pair(from + (to - from) * share));
await page.waitForTimeout(150);
await send('touchEnd', []);
},
};
}
+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([]);
});