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>
236 lines
12 KiB
TypeScript
236 lines
12 KiB
TypeScript
// 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);
|
||
}
|
||
});
|