Files
mdg-filius/packages/editor/browser/touch.spec.ts
T
julian.kiedaischandClaude Opus 5.5 16d9cc2095 feat(editor): the simulation opens sheets, not windows in the window
A tap on a device in the simulation now only chooses it; its three-dot
button (or a long press) opens what Java's click opened: the desktop, and
newly a switch's SAT table and a home router's NAT table, beside each
card's data exchange.

The desktop, the tables and the data exchange are no windows in the
window any more but the simulation's sheet (SimulationSheet), after the
dashboard's AppSheet: below the toolbar to the bottom, a head with icon,
title, who else has it open, see-through and close. One at a time — it
covers the surface. It stays over a return to the design with the program
open on a desktop; a sheet whose device was removed closes. See-through
fades the body (0.55) and keeps the head; the choice is kept in the
browser. Java opens these as movable windows, several at once, with a
click; named in CHANGES.md.

Tests open through the menu (showFromMenu, showDesktop in editorPage.ts).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:18:25 +00:00

238 lines
13 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, 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('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([]);
});