feat(editor): F4.5a the device sheet for PCs, notebooks, switches and modems

Measured first: the new oracle SheetRun types 77 texts into FILIUS' own
settings panels and records what was marked red, what the device took and
what the field showed afterwards. Java's two judgements disagree (red text
taken, black text refused, any int as a modem port, a refused text dropped
without a word); here one rule decides both (packages/doc fields.ts), a
refused text stays marked in the field, and only what Java's model accepts
reaches the document.

- doc: settings.ts (name, label policy, addresses, IP forwarding, DHCP
  stopping the own DHCP server, LAN/WLAN with the wireless connection,
  switch SSID dropping its wireless hosts, retention time, cloud, modem
  port as its pair), sheet.ts (what the sheet shows)
- fls: modemPair/modemPort in one place
- editor: DeviceSheet beside the surface or below it on a phone, opened
  with the selection; every change one undo step
- not adopted from Java: the name overwritten by the MAC address, the
  access point search depending on unrelated devices

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 11:22:28 +00:00
co-authored by Claude Opus 5.5
parent 806df6baca
commit 9b7ec2fc72
25 changed files with 2667 additions and 73 deletions
+6 -1
View File
@@ -6,7 +6,7 @@ import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { Page } from '@playwright/test';
import { build, preview, type PreviewServer } from 'vite';
import type { TopologyView } from '@mdg-filius/doc';
import type { DeviceSheet, TopologyView } from '@mdg-filius/doc';
const config = join(dirname(fileURLToPath(import.meta.url)), '../vite.config.ts');
@@ -30,6 +30,11 @@ export function drawing(page: Page): Promise<TopologyView> {
return page.evaluate(() => (window as unknown as { filiusDev: { view: () => TopologyView } }).filiusDev.view());
}
/** What the device sheet reads of a device. */
export function sheetOf(page: Page, id: string): Promise<DeviceSheet | undefined> {
return page.evaluate((each) => (window as unknown as { filiusDev: { sheet: (id: string) => DeviceSheet | undefined } }).filiusDev.sheet(each), id);
}
/** A device's button as data-box says it (surface pixels). */
export async function boxOf(page: Page, id: string): Promise<{ x: number; y: number; width: number; height: number }> {
const [x, y, width, height] = (await page.locator(`g.device[data-node="${id}"]`).getAttribute('data-box'))!.split(' ').map(Number) as [number, number, number, number];
+185
View File
@@ -0,0 +1,185 @@
// The device sheet in Chromium (Playwright): it opens with the device that
// is chosen, shows Java's fields, takes a field when it is left, marks and
// keeps a text its rule refuses, moves a notebook into a wireless network —
// beside the surface on a wide screen, below it on a phone.
import { expect, test, type Page } from '@playwright/test';
import type { HostSheet } from '@mdg-filius/doc';
import { drawing, onDevice, openEditor, serveEditor, sheetOf } from './editorPage.js';
let server: { readonly base: string; readonly close: () => Promise<void> };
test.beforeAll(async () => {
server = await serveEditor();
});
test.afterAll(async () => {
await server.close();
});
async function deviceNamed(page: Page, name: string): Promise<string> {
return (await drawing(page)).nodes.find((node) => node.name === name)!.id;
}
async function choose(page: Page, id: string): Promise<void> {
const at = await onDevice(page, id);
await page.mouse.click(at.x, at.y);
}
const sheet = (page: Page) => page.getByRole('complementary', { name: 'Einstellungen' });
const field = (page: Page, name: string) => sheet(page).locator(`input[name="${name}"]`);
test("opens with the chosen device, shows Java's fields beside the surface, and closes letting go of it", async ({ page }) => {
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
await expect(sheet(page)).toHaveCount(0);
const id = await deviceNamed(page, 'Notebook 1.10');
await choose(page, id);
await expect(sheet(page)).toBeVisible();
await expect(sheet(page).getByRole('heading')).toHaveText('Notebook');
const values = (await sheetOf(page, id)) as HostSheet;
await expect(field(page, 'name')).toHaveValue('Notebook 1.10');
await expect(field(page, 'macAddress')).toHaveValue(values.macAddress);
await expect(field(page, 'ipAddress')).toHaveValue(values.ipAddress);
await expect(field(page, 'subnetMask')).toHaveValue(values.subnetMask);
await expect(field(page, 'defaultGateway')).toHaveValue(values.defaultGateway);
await expect(sheet(page).getByLabel('Kabelgebunden (LAN)')).toBeChecked();
await expect(sheet(page).getByLabel('DHCP zur Konfiguration verwenden')).not.toBeChecked();
const surface = (await page.locator('svg.filius-canvas').boundingBox())!;
const aside = (await sheet(page).boundingBox())!;
expect(aside.x).toBeGreaterThanOrEqual(surface.x + surface.width - 1);
expect(aside.width).toBe(340);
await sheet(page).getByRole('button', { name: 'Schließen' }).click();
await expect(sheet(page)).toHaveCount(0);
await expect(page.locator(`g.device[data-node="${id}"]`)).not.toHaveAttribute('data-selected', 'true');
});
test('marks a refused address while it is typed, keeps it with what still holds, and takes a mended one', async ({ page }) => {
await openEditor(page, server.base, 'new');
await page.locator('[data-palette="pc"]').click();
await expect(sheet(page)).toBeVisible();
const id = (await drawing(page)).nodes[0]!.id;
const ip = field(page, 'ipAddress');
await ip.fill('10.0.0.256');
await expect(ip).toHaveAttribute('aria-invalid', 'true');
await field(page, 'subnetMask').focus();
await expect(sheet(page).getByRole('status')).toHaveText('Nicht übernommen – es gilt weiterhin 192.168.0.10.');
await expect(ip).toHaveValue('10.0.0.256');
expect(((await sheetOf(page, id)) as HostSheet).ipAddress).toBe('192.168.0.10');
await ip.fill('010.0.0.5');
await expect(ip).toHaveAttribute('aria-invalid', 'false');
await expect(sheet(page).getByRole('status')).toHaveCount(0);
await ip.press('Enter');
await expect(ip).toHaveValue('10.0.0.5');
expect(((await sheetOf(page, id)) as HostSheet).ipAddress).toBe('10.0.0.5');
// Escape gives a text up; the empty gateway is taken, the empty address is not.
await ip.fill('1.2.3');
await ip.press('Escape');
await expect(ip).toHaveValue('10.0.0.5');
await expect(sheet(page)).toBeVisible();
await field(page, 'defaultGateway').fill('');
await field(page, 'defaultGateway').press('Enter');
await ip.fill('');
await ip.press('Enter');
await expect(sheet(page).getByRole('status')).toHaveText('Nicht übernommen – es gilt weiterhin 10.0.0.5.');
});
test('takes a change back as one step, and shows the address as the name', async ({ page }) => {
await openEditor(page, server.base, 'new');
await page.locator('[data-palette="pc"]').click();
const id = (await drawing(page)).nodes[0]!.id;
await field(page, 'ipAddress').fill('10.0.0.9');
await field(page, 'ipAddress').press('Enter');
await sheet(page).getByLabel('IP-Adresse als Name verwenden').check();
await expect.poll(async () => (await drawing(page)).nodes[0]!.label).toBe('10.0.0.9');
await expect(field(page, 'name')).toBeDisabled();
await expect(field(page, 'name')).toHaveValue('10.0.0.9');
await page.getByRole('button', { name: 'Rückgängig' }).click();
await expect.poll(async () => (await drawing(page)).nodes[0]!.label).toBe('Rechner');
await expect(field(page, 'name')).toBeEnabled();
await page.getByRole('button', { name: 'Rückgängig' }).click();
await expect(field(page, 'ipAddress')).toHaveValue('192.168.0.10');
expect(((await sheetOf(page, id)) as HostSheet).ipAddress).toBe('192.168.0.10');
});
test('takes the text of one device when another is chosen, and shows the other', async ({ page }) => {
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
const first = await deviceNamed(page, 'Notebook 1.10');
const second = await deviceNamed(page, 'Notebook 1.11');
await choose(page, first);
await field(page, 'dnsServer').fill('192.168.1.53');
await choose(page, second);
await expect(sheet(page)).toHaveAttribute('data-device', second);
expect(((await sheetOf(page, first)) as HostSheet).dnsServer).toBe('192.168.1.53');
await expect(field(page, 'name')).toHaveValue('Notebook 1.11');
await expect(field(page, 'dnsServer')).toHaveValue(((await sheetOf(page, second)) as HostSheet).dnsServer);
});
test('offers no text of one device to the next, a refused one neither', async ({ page }) => {
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
const first = await deviceNamed(page, 'Notebook 1.10');
const second = await deviceNamed(page, 'Notebook 1.11');
await choose(page, first);
await field(page, 'ipAddress').fill('300.0.0.1');
await choose(page, second);
await expect(sheet(page)).toHaveAttribute('data-device', second);
await expect(field(page, 'ipAddress')).toHaveValue(((await sheetOf(page, second)) as HostSheet).ipAddress);
await expect(field(page, 'ipAddress')).toHaveAttribute('aria-invalid', 'false');
await expect(sheet(page).getByRole('status')).toHaveCount(0);
});
test('closes the address fields while DHCP configures the card, as Java does', async ({ page }) => {
await openEditor(page, server.base, 'new');
await page.locator('[data-palette="pc"]').click();
const fields = ['ipAddress', 'subnetMask', 'defaultGateway', 'dnsServer'];
await sheet(page).getByLabel('DHCP zur Konfiguration verwenden').check();
for (const name of fields) await expect(field(page, name)).toBeDisabled();
await expect(field(page, 'name')).toBeEnabled();
await sheet(page).getByLabel('DHCP zur Konfiguration verwenden').uncheck();
for (const name of fields) await expect(field(page, name)).toBeEnabled();
});
test("joins a notebook to a switch's wireless network, drawn as wireless", async ({ page }) => {
await openEditor(page, server.base, 'new');
await page.locator('[data-palette="networkSwitch"]').click();
const hub = (await drawing(page)).nodes[0]!.id;
await field(page, 'ssid').fill('Schule');
await field(page, 'ssid').press('Enter');
await sheet(page).getByLabel('als Wolke darstellen').check();
await expect.poll(async () => (await drawing(page)).nodes[0]!.cloud).toBe(true);
await page.locator('[data-palette="notebook"]').click();
await expect(sheet(page).getByRole('heading')).toHaveText('Notebook');
await expect(sheet(page).locator('select[name="ssid"]')).toBeDisabled();
await sheet(page).getByLabel('Drahtlos (WLAN)').check();
await sheet(page).locator('select[name="ssid"]').selectOption('Schule');
await expect.poll(async () => (await drawing(page)).links.map((link) => [link.targetNode, link.wireless])).toEqual([[hub, true]]);
await sheet(page).getByLabel('Kabelgebunden (LAN)').check();
await expect.poll(async () => (await drawing(page)).links).toEqual([]);
});
test.describe('on a phone', () => {
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });
test('opens below the surface with a tap, the device still in sight', async ({ page }) => {
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
const id = await deviceNamed(page, 'Notebook 1.10');
const at = await onDevice(page, id);
await page.touchscreen.tap(at.x, at.y);
await expect(sheet(page)).toBeVisible();
const surface = (await page.locator('svg.filius-canvas').boundingBox())!;
const aside = (await sheet(page).boundingBox())!;
expect(aside.y).toBeGreaterThanOrEqual(surface.y + surface.height - 1);
expect(aside.height).toBeLessThanOrEqual(844 / 2);
expect(surface.height).toBeGreaterThan(200);
const fieldBox = (await field(page, 'ipAddress').boundingBox())!;
expect(fieldBox.height).toBeGreaterThanOrEqual(44);
});
});
test('speaks English when asked', async ({ page }) => {
await openEditor(page, server.base, 'new', '&lang=en');
await page.locator('[data-palette="networkSwitch"]').click();
const aside = page.getByRole('complementary', { name: 'Settings' });
await expect(aside.getByText('Wi-Fi Name (SSID)')).toBeVisible();
await expect(aside.getByText('Retention Time SAT Entries (Seconds)')).toBeVisible();
await expect(aside.getByLabel('Display as Cloud')).not.toBeChecked();
});