// The editor's surface in Chromium (Playwright), on its built page. // // - Every FILIUS example drawn: its devices and cables, each device as large // as Java made its button when it saved the file — the bounds in the files // saved by Java 2.5 and later (before, the button's width left the label // out, and older ones had other borders); the height within the font // heights of the saving machines (15 to 17), the width where the label is // wider than the icon within 16 % (the machines' fonts differ). // - The gestures: the wheel with Ctrl zooms around the pointer, a drag moves // the surface, a press without moving does nothing, the buttons step and fit. // - English and dark. import { readdirSync, readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { expect, test, type Page } from '@playwright/test'; import { decodeJavaBeans, isJavaObject, listItems, readFlsArchive } from '@mdg-filius/fls'; import { ICONS, iconOf } from '../src/canvas/layout.js'; import { serveEditor } from './editorPage.js'; const here = dirname(fileURLToPath(import.meta.url)); const examples = join(here, '../../../corpus/java-examples'); let server: { readonly base: string; readonly close: () => Promise }; let base: string; test.beforeAll(async () => { server = await serveEditor(); base = server.base; }); test.afterAll(async () => { await server.close(); }); /** The width and height Java gave each device's button when it saved the file, in the file's order. */ function savedBounds(file: string): { width: number; height: number; javaClass: string; cloud: boolean }[] { const values = decodeJavaBeans(readFlsArchive(new Uint8Array(readFileSync(join(examples, file)))).configurationXml); const nodes = listItems(values[typeof values[0] === 'string' ? 1 : 0]); return nodes.flatMap((item) => { if (!isJavaObject(item)) return []; const label = item.properties.get('imageLabel'); const device = item.properties.get('knoten'); const bounds = isJavaObject(label) ? label.properties.get('bounds') : undefined; if (!isJavaObject(bounds) || !isJavaObject(device)) return []; const field = (name: string, index: number) => (bounds.properties.get(name) ?? bounds.args[index]) as number; return [{ width: field('width', 2), height: field('height', 3), javaClass: device.className.slice(device.className.lastIndexOf('.') + 1), cloud: device.properties.get('cloud') === true }]; }); } /** The version line Java wrote at the file's start. */ function savedVersion(file: string): string | undefined { const values = decodeJavaBeans(readFlsArchive(new Uint8Array(readFileSync(join(examples, file)))).configurationXml); return typeof values[0] === 'string' ? values[0] : undefined; } async function open(page: Page, example: string, query = '') { await page.goto(`${base}?example=${example}${query}`); await page.waitForSelector('g.device'); } const KIND_OF_CLASS: Record = { Rechner: 'pc', Notebook: 'notebook', Switch: 'networkSwitch', Vermittlungsrechner: 'router', Gateway: 'gateway', Modem: 'remoteLink', }; test('draws every example with devices as large as Java made them', async ({ page }) => { const deviations: string[] = []; let compared = 0; for (const file of readdirSync(examples).filter((name) => name.endsWith('.fls'))) { await open(page, file); const saved = savedBounds(file); const version = /Filius version: (\d+)\.(\d+)/.exec(savedVersion(file) ?? ''); const comparable = version !== null && (Number(version[1]) > 2 || (Number(version[1]) === 2 && Number(version[2]) >= 5)); const devices = await page.locator('g.device').evaluateAll((elements) => elements.map((element) => ({ box: element.getAttribute('data-box')!.split(' ').map(Number), label: element.getAttribute('aria-label') })), ); expect(devices.length, file).toBe(saved.length); expect(await page.locator('path[data-link]').count(), file).toBeGreaterThan(0); for (const [index, { box, label }] of devices.entries()) { const java = saved[index]!; if (!comparable) continue; compared += 1; const [, , width, height] = box as [number, number, number, number]; const icon = ICONS[iconOf(KIND_OF_CLASS[java.javaClass]!, java.cloud)]; // The height: icon plus 5 plus Java's font height on the machine that saved it (15 to 17). const heightOk = height - java.height >= 0 && height - java.height <= 2; // The width: exact where the icon is wider than the label; else the label's width, which depends on // the saving machine's font (Java takes the system's look and feel): within 16 %. const tolerance = java.width === icon.width + 21 && width === icon.width + 21 ? 0 : Math.max(4, java.width * 0.16); if (!heightOk || Math.abs(width - java.width) > tolerance) deviations.push(`${file} #${index + 1} ${label}: Java ${java.width}×${java.height}, here ${width}×${height}`); } } expect(compared).toBeGreaterThan(80); expect(deviations).toEqual([]); }); test('zooms around the pointer with Ctrl and the wheel, and moves with a drag', async ({ page }) => { await open(page, 'internetwork_komplett.fls'); const zoom = () => page.locator('[data-zoom]').getAttribute('data-zoom').then(Number); const device = page.locator('g.device').first(); const before = (await device.boundingBox())!; const start = await zoom(); // Zoom in at the device's middle: it stays where it is, and grows. const point = { x: before.x + before.width / 2, y: before.y + before.height / 2 }; await page.mouse.move(point.x, point.y); await page.keyboard.down('Control'); await page.mouse.wheel(0, -100); await page.keyboard.up('Control'); await expect.poll(zoom).toBeGreaterThan(start); const after = (await device.boundingBox())!; expect(Math.abs(after.x + after.width / 2 - point.x)).toBeLessThan(1.5); expect(Math.abs(after.y + after.height / 2 - point.y)).toBeLessThan(1.5); expect(after.width).toBeGreaterThan(before.width); // A press without moving leaves the view; a drag moves it as far as the pointer went. const surface = (await page.locator('svg.filius-canvas').boundingBox())!; const empty = { x: surface.x + 20, y: surface.y + surface.height - 20 }; await page.mouse.move(empty.x, empty.y); await page.mouse.down(); await page.mouse.up(); expect((await device.boundingBox())!.x).toBeCloseTo(after.x, 1); await page.mouse.move(empty.x, empty.y); await page.mouse.down(); await page.mouse.move(empty.x + 60, empty.y - 40, { steps: 6 }); await page.mouse.up(); const moved = (await device.boundingBox())!; expect(moved.x - after.x).toBeCloseTo(60, 0); expect(moved.y - after.y).toBeCloseTo(-40, 0); }); test("steps with the buttons, shows all, and keeps to Java's limits", async ({ page }) => { await open(page, 'internetwork_komplett.fls'); const zoom = () => page.locator('[data-zoom]').getAttribute('data-zoom').then(Number); await page.getByRole('button', { name: 'Vergrößern' }).click(); expect(await zoom()).toBe(1.25); for (let press = 0; press < 6; press += 1) await page.getByRole('button', { name: 'Vergrößern' }).click({ force: true }); expect(await zoom()).toBe(2); await expect(page.getByRole('button', { name: 'Vergrößern' })).toBeDisabled(); await page.getByRole('button', { name: 'Alles zeigen' }).click(); const surface = (await page.locator('svg.filius-canvas').boundingBox())!; for (const box of await page.locator('g.device').evaluateAll((elements) => elements.map((element) => element.getBoundingClientRect().toJSON() as DOMRect))) { expect(box.left).toBeGreaterThanOrEqual(surface.x); expect(box.right).toBeLessThanOrEqual(surface.x + surface.width); expect(box.top).toBeGreaterThanOrEqual(surface.y); expect(box.bottom).toBeLessThanOrEqual(surface.y + surface.height); } for (let press = 0; press < 8; press += 1) await page.getByRole('button', { name: 'Verkleinern' }).click({ force: true }); expect(await zoom()).toBe(0.25); }); test('speaks English and goes dark when asked', async ({ page }) => { await open(page, 'nat.fls', '&lang=en&theme=dark'); await expect(page.getByRole('img', { name: 'Home Router: Heimrouter' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Zoom in' })).toBeVisible(); expect(await page.evaluate(() => document.documentElement.dataset.theme)).toBe('dark'); await expect(page.getByRole('img', { name: 'Switch / Wi-Fi: Internet' })).toBeVisible(); }); test('zooms with two fingers around their middle, and moves with one', async ({ browser }) => { const context = await browser.newContext({ hasTouch: true, viewport: { width: 1024, height: 768 } }); const page = await context.newPage(); await open(page, 'internetwork_komplett.fls'); const zoom = () => page.locator('[data-zoom]').getAttribute('data-zoom').then(Number); const cdp = await context.newCDPSession(page); const touch = (type: 'touchStart' | 'touchMove' | 'touchEnd', points: readonly [number, number][]) => cdp.send('Input.dispatchTouchEvent', { type, touchPoints: points.map(([x, y], id) => ({ x, y, id })) }); const device = page.locator('g.device').nth(4); const before = (await device.boundingBox())!; const centre = { x: before.x + before.width / 2, y: before.y + before.height / 2 }; const start = await zoom(); // Two fingers 40 px either side of the device, spread to 80. await touch('touchStart', [ [centre.x - 40, centre.y], [centre.x + 40, centre.y], ]); for (const distance of [50, 60, 70, 80]) { await touch('touchMove', [ [centre.x - distance, centre.y], [centre.x + distance, centre.y], ]); } await touch('touchEnd', []); expect(await zoom()).toBeCloseTo(start * 2 > 2 ? 2 : start * 2, 1); const after = (await device.boundingBox())!; expect(Math.abs(after.x + after.width / 2 - centre.x)).toBeLessThan(2); expect(Math.abs(after.y + after.height / 2 - centre.y)).toBeLessThan(2); // One finger moves the surface. await touch('touchStart', [[500, 700]]); for (const step of [10, 20, 30, 40, 50]) await touch('touchMove', [[500 + step, 700]]); await touch('touchEnd', []); expect((await device.boundingBox())!.x - after.x).toBeCloseTo(50, 0); await context.close(); }); test('moves with the wheel alone, and takes a press that hardly moved for no drag', async ({ page }) => { await open(page, 'internetwork_komplett.fls'); const zoom = () => page.locator('[data-zoom]').getAttribute('data-zoom').then(Number); const device = page.locator('g.device').first(); const before = (await device.boundingBox())!; const start = await zoom(); await page.mouse.move(before.x, before.y); await page.mouse.wheel(30, 50); await expect.poll(async () => (await device.boundingBox())!.y).toBeCloseTo(before.y - 50, 0); expect((await device.boundingBox())!.x).toBeCloseTo(before.x - 30, 0); expect(await zoom()).toBe(start); // Two pixels are a press, not a drag (DRAG_THRESHOLD): the surface stays. const placed = (await device.boundingBox())!; const surface = (await page.locator('svg.filius-canvas').boundingBox())!; await page.mouse.move(surface.x + 20, surface.y + surface.height - 20); await page.mouse.down(); await page.mouse.move(surface.x + 22, surface.y + surface.height - 20); await page.mouse.up(); expect((await device.boundingBox())!.x).toBe(placed.x); }); test('shows all of a large example at first', async ({ page }) => { await open(page, 'Internet_Komplett_mit_eMail_Webserver_Intranet_Portforwarding_Firewall_DHCP_DE.fls'); await page.waitForTimeout(100); const zoom = Number(await page.locator('[data-zoom]').getAttribute('data-zoom')); expect(zoom).toBeLessThan(1); const surface = (await page.locator('svg.filius-canvas').boundingBox())!; for (const box of await page.locator('g.device').evaluateAll((elements) => elements.map((element) => element.getBoundingClientRect().toJSON() as DOMRect))) { expect(box.left).toBeGreaterThanOrEqual(surface.x); expect(box.right).toBeLessThanOrEqual(surface.x + surface.width); expect(box.top).toBeGreaterThanOrEqual(surface.y); expect(box.bottom).toBeLessThanOrEqual(surface.y + surface.height); } });