feat(editor): F4.3 the editor's frame and surface; a switch's cloud read and written
packages/editor (React 18, Vite) draws a document as Java FILIUS does: Java's default hardware icons, the device button of JSidebarButton, cables as JCablePanel's quadratic curve between the buttons' middles, wireless cables dashed, documentation items below. The surface moves with mouse and finger (the pointer captured only once it moved) and zooms with Ctrl and the wheel, a trackpad pinch, two fingers and the zoom bar; the first view shows all. German and English catalogs with Java's device names, the dashboard's colour tokens, the bridge with its origin check. packages/doc gets the drawing's light view of a document. Java's Switch.cloud (the Internet in the examples) was lost on reading; it is now read, kept in the model and written, with FlsOracle and the corpus, written and exported checks renewed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
// 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 { build, preview, type PreviewServer } from 'vite';
|
||||
import { decodeJavaBeans, isJavaObject, listItems, readFlsArchive } from '@mdg-filius/fls';
|
||||
import { ICONS, iconOf } from '../src/canvas/layout.js';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const config = join(here, '../vite.config.ts');
|
||||
const examples = join(here, '../../../corpus/java-examples');
|
||||
|
||||
let server: PreviewServer;
|
||||
let base: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await build({ configFile: config, logLevel: 'silent' });
|
||||
server = await preview({ configFile: config, preview: { port: 0 }, logLevel: 'silent' });
|
||||
base = server.resolvedUrls!.local[0]!;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.httpServer.close(() => resolve()));
|
||||
});
|
||||
|
||||
/** 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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user