diff --git a/CHANGES.md b/CHANGES.md index ec9ee14..126450d 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -254,6 +254,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `browser/documentation.spec.ts`, `test/documentationPaint.test.ts` | — | neu (F4.8b): auf `nat_with_mapped_port` Javas Knopfreihenfolge, eine Fläche gezogen (mittig, grau, Deckkraft 200), verschoben, auf 20 × 20 verkleinert, Farbe aus Javas zwölf; ein Text getippt, groß und kursiv; Escape, Javas Textrand; im Entwurf deckend aufgehellt, nicht wählbar; Entf; Start der Simulation beendet den Modus; Handy; die Zeichenregeln gegen Javas gemessene Pixel | | `test/networkImage.test.ts`, `test/networkImage.java.json`, `browser/documentation.spec.ts` (Export) | — | neu (F4.8c): Ausschnitt und Fußzeile gegen Javas Druckpanel auf vier Projekten (aus Javas eigenen Grenzen, die von Javas Schrift abhängen), jedes Kabel Javas gegen `cableFrame`, Javas Farben (durchsichtig, Fußzeile 192, Fläche wie im Entwurf); Zeilenumbruch; Dateinamen; das Malen auf einem aufzeichnenden Kontext (Reihenfolge, Farben, gestrichelte Funkstrecke); im Browser der Download (Name, Größe aus den Rahmen der Fläche, Eckpixel, Flächenfarbe 215, Fußzeile hellgrau) und Javas Meldung, wenn `toBlob` nichts liefert | | `test/report.test.ts`, `test/report.java.json`, `test/report.ours.java.json`, `browser/report.spec.ts` | — | neu (F4.8d): jedes Textstück von Javas Bericht (Wort, Ort, Schrift, Größe, Farbe) gegen den eigenen Satz — alle Geräteseiten von `Internet_Komplett`, die erste Seite, ein langer Titel, ein Projekt ohne Geräte, ein leeres, der Datenaustausch von `einfaches_rechnernetz_komplett` über alle Seiten (Javas Zeilen aus Javas Seiten); Javas iText liest das eigene PDF und findet dieselben Stücke an denselben Orten (SHA-256 festgehalten); im Browser der Bericht nach einem kurzen Lauf mit DHCP: Dateiname, Titel, Bild, Geräte, Datenaustausch | +| `browser/touch.spec.ts`, `browser/fingers.ts`, `playwright.config.ts` (Projekte `ipad-portrait`, `ipad-landscape`), `package.json` (Playwright fest auf 1.62.1) | — | neu (F4.9): der Editor nur mit dem Finger, in Chromium am Desktop und in WebKit als iPad hoch und quer — die Abnahme von F4 als Ablauf ("zwei Rechnernetze" neu gebaut, Adressen im Blatt, Kabel per Werkzeug, Befehlszeile installiert, Ping quer über den Router), Zwei-Finger-Zoom vergrößert die Fläche und nie die Seite (M7, ohne Rahmen), jede Aktion ohne Rechtsklick (langes Drücken, Dreipunkt-Knopf, Menü der Dokumentation), alle Ziele mindestens 44 px, nichts über den Rand. Echte Gesten: Chromium über CDP, WebKit über `Input.dispatchTouchEvent` seiner Seitensitzung, die Playwright nur über die Verbindung im selben Prozess hergibt (Naht im Inneren von Playwright, Version deshalb fest). Ein gezogener Finger hält vor dem Loslassen 150 ms still: sonst nimmt Chromium die Geste als Schwung, und das nächste Antippen stoppt nur den Schwung | | `src/sheet/ServicePanels.tsx`, `src/sheet/RowsEditor.tsx`, `src/sheet/SheetTabs.tsx`, `src/sheet/DeviceSheet.tsx` (Seiten) | Java `JDHCPKonfiguration` (`jdhcpkonfiguration_msg1`–`15`), `JPortForwardingDialog` (`jportforwarding_msg4`–`7`, `jgatewayconfiguration_msg19`) | neu (F4.5c): Javas Dialoge als Seiten des Blatts mit „Zurück“ — DHCP-Server (Grundeinstellungen, statische Adresszuweisung; beim Rechner, gesperrt während DHCP die Karte einrichtet, und auf dem LAN-Reiter des Heimrouters), Portfreigaben (Heimrouter); jedes Feld beim Verlassen übernommen statt mit Javas OK-Knopf. Die Zeilentabellen (Weiterleitung, Zuordnungen, Portfreigaben) teilen sich `RowsEditor`: eine neue Zeile, sobald alle Felder gültig sind, Zeile wählen, bearbeiten, löschen. Javas Tooltip zu „Manuelle Einstellungen“ als Text unter dem Kästchen (ein Finger hat kein Überfahren) | | `src/sheet/RouterSheets.tsx`, `src/sheet/NameField.tsx`, `src/canvas/Canvas.tsx` (`highlightedLink`), `src/theme/tokens.css` (`--canvas-cable-active`) | Java `JVermittlungsrechnerKonfiguration`, `JGatewayConfiguration`, `JWeiterleitungsTabelle` (Wörter `jvermittlungsrechnerkonfiguration_msg6`–`26`, `jweiterleitungstabelle_msg3`–`6`), `JCablePanel.farbeBlinken`, `Kabel.setAktiv` | neu (F4.5b): Blatt des Vermittlungsrechners — Reiter Allgemein (Name, Gateway, Automatisches Routing, IP-Weiterleitung nur angezeigt), je Karte (Reiter mit ihrer Adresse, „Verbunden mit …“, Adresse, Maske, MAC) und Weiterleitungstabelle (alle Einträge oder nur die manuellen, Javas Zeilen grau, eine neue Zeile, die mit vier gültigen Feldern übernommen wird, Zelle bearbeiten, Eintrag löschen; bei RIP gesperrt); Blatt des Heimrouters mit Allgemein, LAN, WAN (DHCP sperrt die WAN-Adresse). Das Kabel der offenen Karte leuchtet grün wie bei Java. Javas „Eintrag Löschen“ klein geschrieben | diff --git a/README.md b/README.md index ac19b4e..11612d4 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Plan und Entscheidungen: `knowledge/plans/FILIUS_WEB_PLAN.md` im Dashboard. npm install npm run hooks # Sprachprüfung vor jedem Commit (braucht das Dashboard drumherum) npm run check # Typprüfung + Tests -npm run test:browser # Tests im echten Browser (Playwright, Chromium aus dem Cache) +npm run test:browser # Tests im echten Browser (Playwright: Chromium; der Touch-Test auch in WebKit als iPad hoch und quer) ``` Code, Kommentare und Testnamen sind englisch; sichtbare Texte stehen hinter diff --git a/package-lock.json b/package-lock.json index 7e71d7a..d150667 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "packages/*" ], "devDependencies": { - "@playwright/test": "^1.62.1", + "@playwright/test": "1.62.1", "@types/node": "^20.19.0", "esbuild": "^0.28.2", "typescript": "^5.9.0", diff --git a/package.json b/package.json index f82510a..e390df5 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "oracle:java": "tools/java-oracle/run.sh" }, "devDependencies": { - "@playwright/test": "^1.62.1", + "@playwright/test": "1.62.1", "@types/node": "^20.19.0", "esbuild": "^0.28.2", "typescript": "^5.9.0", diff --git a/packages/editor/browser/fingers.ts b/packages/editor/browser/fingers.ts new file mode 100644 index 0000000..4223dde --- /dev/null +++ b/packages/editor/browser/fingers.ts @@ -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; + /** One finger held where it is, then lifted (a long press: 700 ms). */ + press(at: Point, milliseconds?: number): Promise; + /** One finger moved from one point to another in steps. */ + drag(from: Point, to: Point, steps?: number): Promise; + /** Two fingers either side of a middle, moved from one distance apart to another. */ + pinch(middle: Point, from: number, to: number, steps?: number): Promise; +} + +interface WebKitSession { + send(method: string, params: unknown): Promise; +} + +/** 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 { + const engine = page.context().browser()?.browserType().name(); + let send: (type: TouchType, points: readonly Point[]) => Promise; + 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', []); + }, + }; +} diff --git a/packages/editor/browser/touch.spec.ts b/packages/editor/browser/touch.spec.ts new file mode 100644 index 0000000..1fb2e83 --- /dev/null +++ b/packages/editor/browser/touch.spec.ts @@ -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 }; + +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([]); +}); diff --git a/playwright.config.ts b/playwright.config.ts index d9b6c84..8891b5c 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,5 +1,7 @@ // The browser tests: what needs a real browser — the core in Chromium (M9), -// the room in a real web worker, later the editor's gestures (plan §7.2). +// the room in a real web worker, the editor's gestures (plan §7.2). The +// touch test (touch.spec.ts) also runs on an iPad, upright and on its side, +// in WebKit (F4.9, M7). import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ @@ -7,5 +9,9 @@ export default defineConfig({ testMatch: '**/browser/*.spec.ts', workers: 1, reporter: 'list', - projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + { name: 'ipad-portrait', use: { ...devices['iPad (gen 7)'] }, testMatch: '**/browser/touch.spec.ts' }, + { name: 'ipad-landscape', use: { ...devices['iPad (gen 7) landscape'] }, testMatch: '**/browser/touch.spec.ts' }, + ], });