Files
mdg-filius/packages/editor/browser/touch.spec.ts
T
julian.kiedaischandClaude Opus 5.5 8c4e5049f6 feat(editor): handles for a finger on documentation items, and a grip that moves a text
The focused rectangle or text shows 44 px handles on its focus band: corners,
and the middles of long enough sides; on an item smaller than two targets only
the bottom right one, outside its corner. A text gets a grip above its top
left corner, since its middle is the field typed into. The three-dot button
moves above the top right corner; grip and button step aside at the surface's
edge. A tap on a handle is one on what lies under it, a long press opens the
menu, a second finger zooms. WebKit hands a tap's end to the handle despite
the capture, so the handle passes its events on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:04:40 +00:00

335 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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, scaleOf, 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');
// A tap chooses the computer; its three-dot button opens its menu, „Desktop anzeigen“ its desktop as the sheet.
await finger.tap(await onDevice(page, pcA));
await page.getByRole('button', { name: 'Aktionen' }).tap();
await page.getByRole('menuitem', { name: 'Desktop anzeigen' }).tap();
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('puts a device or an item down where it is when a second finger comes or the browser takes the touch', async ({ page }) => {
for (const gesture of ['dragThenPinch', 'dragThenCancel'] as const) {
// Each on a fresh page: the pinch zooms, and the next device would be elsewhere.
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
const finger = await fingers(page);
const id = (await drawing(page)).nodes[0]!.id;
const before = (await drawing(page)).nodes.find((node) => node.id === id)!;
const from = await onDevice(page, id);
await finger[gesture](from, { x: from.x + 40, y: from.y + 60 });
// In the document, not only on the screen: the drag ended there and then.
await expect
.poll(async () => {
const now = (await drawing(page)).nodes.find((node) => node.id === id)!;
return now.x !== before.x || now.y !== before.y;
}, { message: gesture })
.toBe(true);
const shown = await page.locator(`g.device[data-node="${id}"]`).getAttribute('data-box');
await page.waitForTimeout(300);
expect(await page.locator(`g.device[data-node="${id}"]`).getAttribute('data-box')).toBe(shown);
}
// A rectangle of the documentation, its drag taken by the browser: it too stays where it was dragged.
await openEditor(page, server.base, 'nat_with_mapped_port.fls');
const finger = await fingers(page);
await page.locator('button[name="documentationMode"]').tap();
const rectangle = (await drawing(page)).docs.find((item) => item.kind === 'rectangle')!;
const area = (await page.locator(`g[data-doc="${rectangle.id}"]`).boundingBox())!;
const middle = { x: area.x + area.width / 2, y: area.y + area.height / 2 };
await finger.dragThenCancel(middle, { x: middle.x + 40, y: middle.y + 30 });
await expect.poll(async () => (await drawing(page)).docs.find((item) => item.id === rectangle.id)!.x).not.toBe(rectangle.x);
});
test("resizes a rectangle of the documentation by its handles, and moves and resizes a text by its grip and handles, with a finger", async ({ page }) => {
await openEditor(page, server.base, 'nat_with_mapped_port.fls');
const finger = await fingers(page);
await page.locator('button[name="documentationMode"]').tap();
const docOf = async (id: string) => (await drawing(page)).docs.find((item) => item.id === id)!;
const frameOf = async (id: string) => {
const item = await docOf(id);
return [item.x, item.y, item.width, item.height].map(Math.round);
};
const handle = (name: string) => page.locator(`[data-handle="${name}"]`);
const scale = await scaleOf(page);
// A rectangle tapped: its handles, each a finger's target, the three-dot button clear of them.
const rectangle = (await drawing(page)).docs.find((item) => item.kind === 'rectangle')!;
await finger.tap(await centreOf(page, `g[data-doc="${rectangle.id}"] .doc-rectangle`));
await expect(page.locator(`g[data-doc="${rectangle.id}"]`)).toHaveAttribute('data-focused', 'true');
const names = await page.locator('[data-handle]').evaluateAll((all) => all.map((each) => each.getAttribute('data-handle')));
expect(names).toEqual(expect.arrayContaining(['top-left', 'top-right', 'bottom-right', 'bottom-left']));
for (const box of await page.locator('[data-handle]').evaluateAll((all) => all.map((each) => each.getBoundingClientRect().toJSON() as DOMRect))) expect([box.width, box.height]).toEqual([44, 44]);
const menu = (await page.locator('.device-menu-button').boundingBox())!;
for (const box of await page.locator('[data-handle]').evaluateAll((all) => all.map((each) => each.getBoundingClientRect().toJSON() as DOMRect))) {
expect(box.y >= menu.y + menu.height || box.y + box.height <= menu.y || box.x >= menu.x + menu.width || box.x + box.width <= menu.x).toBe(true);
}
// The bottom right corner dragged 60 right and 40 down: wider and higher by as much in the drawing, the top left kept.
const [x, y, width, height] = await frameOf(rectangle.id);
const bottomRight = await centreOf(page, '[data-handle="bottom-right"]');
await finger.drag(bottomRight, { x: bottomRight.x + 60, y: bottomRight.y + 40 });
await expect.poll(() => frameOf(rectangle.id)).toEqual([x, y, Math.round(width! + 60 / scale), Math.round(height! + 40 / scale)]);
// The top left one 30 left: the right edge kept.
const wider = await frameOf(rectangle.id);
const topLeft = await centreOf(page, '[data-handle="top-left"]');
await finger.drag(topLeft, { x: topLeft.x - 30, y: topLeft.y });
await expect.poll(async () => (await frameOf(rectangle.id))[0]).toBe(Math.round(wider[0]! - 30 / scale));
const [left, , across] = await frameOf(rectangle.id);
expect(Math.abs(left! + across! - (wider[0]! + wider[2]!))).toBeLessThanOrEqual(1);
// A long press on a handle opens the rectangle's menu, as on the rectangle.
await finger.press(await centreOf(page, '[data-handle="top-left"]'));
await expect(page.getByRole('menu').getByRole('group', { name: 'Farbe' })).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.getByRole('menu')).toHaveCount(0);
// The text in its top edge lies partly under a handle: tapped there, the text is chosen and typed into, not the rectangle.
const below = (await drawing(page)).docs.find((item) => item.kind === 'text' && item.text.startsWith('Hosts'))!;
const words = (await page.locator(`g[data-doc="${below.id}"] .doc-text`).boundingBox())!;
const covering = (await page.locator('[data-handle]').evaluateAll((all) => all.map((each) => each.getBoundingClientRect().toJSON() as DOMRect)))
.map((box) => ({ left: Math.max(box.x, words.x), top: Math.max(box.y, words.y), right: Math.min(box.x + box.width, words.x + words.width), bottom: Math.min(box.y + box.height, words.y + words.height) }))
.find((both) => both.right - both.left > 4 && both.bottom - both.top > 4)!;
expect(covering).toBeDefined();
await finger.tap({ x: (covering.left + covering.right) / 2, y: (covering.top + covering.bottom) / 2 });
await expect(page.getByRole('textbox', { name: 'Text' })).toBeVisible();
await expect(page.locator(`g[data-doc="${below.id}"]`)).toHaveAttribute('data-focused', 'true');
// A text with room around it, tapped: a grip above its top left corner moves it — its size kept.
await page.getByRole('textbox', { name: 'Text' }).press('Escape');
const text = (await drawing(page)).docs.find((item) => item.kind === 'text' && item.text.startsWith('Traffic'))!;
await finger.tap(await centreOf(page, `g[data-doc="${text.id}"] .doc-text`));
await expect(page.getByRole('textbox', { name: 'Text' })).toBeVisible();
await expect(handle('move')).toBeVisible();
// The handles sit on the blue band around the text (where Java takes hold of it), not on its words.
const bandBox = (await page.locator(`g[data-doc="${text.id}"] .doc-focus`).boundingBox())!;
const cornerAt = await centreOf(page, '[data-handle="bottom-right"]');
expect(Math.abs(cornerAt.x - (bandBox.x + bandBox.width + scale))).toBeLessThanOrEqual(1.5);
expect(Math.abs(cornerAt.y - (bandBox.y + bandBox.height + scale))).toBeLessThanOrEqual(1.5);
// Every point of a handle's target takes the finger, also where it lies over the field typed into.
const hidden = await page.locator('[data-handle]').evaluateAll((all) =>
all.flatMap((each) => {
const box = each.getBoundingClientRect();
const points = [
[box.left + 3, box.top + 3],
[box.right - 3, box.top + 3],
[box.left + 3, box.bottom - 3],
[box.right - 3, box.bottom - 3],
];
return points.filter(([x, y]) => document.elementFromPoint(x!, y!)?.closest('[data-handle]') !== each).map(() => each.getAttribute('data-handle'));
}),
);
expect(hidden).toEqual([]);
const before = await frameOf(text.id);
const grip = await centreOf(page, '[data-handle="move"]');
await finger.drag(grip, { x: grip.x + 50, y: grip.y + 30 });
await expect.poll(() => frameOf(text.id)).toEqual([Math.round(before[0]! + 50 / scale), Math.round(before[1]! + 30 / scale), before[2], before[3]]);
// Its bottom right handle: wider and higher; what it says is unchanged.
const moved = await frameOf(text.id);
const corner = await centreOf(page, '[data-handle="bottom-right"]');
await finger.drag(corner, { x: corner.x + 40, y: corner.y + 20 });
await expect.poll(() => frameOf(text.id)).toEqual([moved[0], moved[1], Math.round(moved[2]! + 40 / scale), Math.round(moved[3]! + 20 / scale)]);
expect((await docOf(text.id)).text).toBe(text.text);
// Two fingers, the second on a handle: they zoom, and the text keeps its size.
const sized = await frameOf(text.id);
const onHandle = await centreOf(page, '[data-handle="bottom-right"]');
await finger.pinch({ x: onHandle.x - 40, y: onHandle.y }, 40, 120);
await expect.poll(() => scaleOf(page)).toBeGreaterThan(scale * 1.5);
expect(await frameOf(text.id)).toEqual(sized);
});
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, [data-handle], 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([]);
});