Files
mdg-filius/packages/editor/browser/canvas.spec.ts
T
julian.kiedaischandClaude Opus 5.5 806df6baca feat(editor): F4.4b the design tools: palette, moving, cables, menus, undo
The palette in Java's order with Java's icons and names: a device dragged
onto the surface lands with its icon at the drop point, a tapped one in the
middle of the view; a new router asks for 2 to 8 interfaces as Java does.
A tap selects, a drag moves (divided by the scale), a long press or the
three-dot button opens Java's menu. The cable tool plugs by a drag or by
two taps, shows WAN or LAN on a home router's halves and Java's messages
when refused. Delete removes the selection; undo and redo by button and
Ctrl+Z/Ctrl+Y, each editor with its own origin. Browser tests with mouse
and touch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:37:49 +00:00

236 lines
12 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'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<void> };
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<string, 'pc' | 'notebook' | 'networkSwitch' | 'router' | 'gateway' | 'remoteLink'> = {
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);
}
});