feat(editor): F4.6a simulation mode and a computer's desktop

Java's mode buttons start and stop the room, now in a worker of the
editor's page (LocalSimHost), with Java's speed slider. While the
simulation runs the design is locked as in Java: no palette, no sheet,
nothing moves, no undo. Cables that carry a frame light up in Java's
green, and a tap on a computer opens its desktop: Java's title, the
software installation first, then the installed programs in the order
of Java's list for the language, a taskbar with the network icon and
the network as the simulation runs it. The room sends that over a new
topic, node:<id>. Desktops stay over a return to the design and show
again at the next start. The programs' windows follow from F4.6b.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 13:41:41 +00:00
co-authored by Claude Opus 5.5
parent 6e97e3565d
commit dfd5ec03ce
44 changed files with 1190 additions and 41 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 { DeviceSheet, TopologyView } from '@mdg-filius/doc';
import type { DesktopView, DeviceSheet, TopologyView } from '@mdg-filius/doc';
const config = join(dirname(fileURLToPath(import.meta.url)), '../vite.config.ts');
@@ -35,6 +35,11 @@ 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);
}
/** What a computer's desktop reads of the document. */
export function desktopOf(page: Page, id: string): Promise<DesktopView | undefined> {
return page.evaluate((each) => (window as unknown as { filiusDev: { desktop: (id: string) => DesktopView | undefined } }).filiusDev.desktop(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];
+250
View File
@@ -0,0 +1,250 @@
// The simulation in Chromium (F4.6a): Java's mode buttons, the design locked
// while it runs, the speed, the cables that carry frames, a computer's
// desktop opened with a tap — Java's title, its programs in Java's order, its
// network as it runs — and desktops kept over a return to the design.
import { expect, test, type Page } from '@playwright/test';
import { desktopOf, drawing, onDevice, openEditor, serveEditor } 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 tap(page: Page, id: string): Promise<void> {
const at = await onDevice(page, id);
await page.mouse.click(at.x, at.y);
}
const simulationButton = (page: Page) => page.getByRole('button', { name: 'Wechselt in den Aktionsmodus' });
const designButton = (page: Page) => page.getByRole('button', { name: 'Wechselt in den Entwurfsmodus' });
const desktop = (page: Page, id: string) => page.locator(`section[data-desktop="${id}"]`);
/** Starts the simulation and waits until the room says it runs (it answers from its worker). */
async function start(page: Page): Promise<void> {
await simulationButton(page).click();
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation');
}
test('locks the design while the simulation runs, as Java does, and gives it back', async ({ page }) => {
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
const router = (await drawing(page)).nodes.find((node) => node.kind === 'router')!;
await tap(page, router.id);
await expect(page.getByRole('complementary', { name: 'Einstellungen' })).toBeVisible();
await expect(designButton(page)).toHaveAttribute('aria-pressed', 'true');
await simulationButton(page).click();
await expect(simulationButton(page)).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation');
await expect(page.getByRole('complementary', { name: 'Einstellungen' })).toHaveCount(0);
await expect(page.locator('[data-palette]')).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Rückgängig' })).toHaveCount(0);
// Java's slider: 90 per cent at first (Verbindung's delay factor 10).
const speed = page.locator('input[name="speed"]');
await expect(speed).toHaveValue('90');
await expect(page.getByTitle('Aktuelle Simulationsgeschwindigkeit')).toHaveText('90%');
await speed.fill('40');
await expect(page.getByTitle('Aktuelle Simulationsgeschwindigkeit')).toHaveText('40%');
// Nothing moves, nothing is chosen, a key removes nothing.
const at = await onDevice(page, router.id);
await page.mouse.move(at.x, at.y);
await page.mouse.down();
await page.mouse.move(at.x + 80, at.y + 60, { steps: 5 });
await page.mouse.up();
await page.keyboard.press('Delete');
const still = (await drawing(page)).nodes.find((node) => node.id === router.id)!;
expect([still.x, still.y]).toEqual([router.x, router.y]);
await expect(page.locator('g.device[data-selected]')).toHaveCount(0);
await designButton(page).click();
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'design');
await expect(page.locator('[data-palette]').first()).toBeVisible();
await expect(page.getByRole('button', { name: 'Rückgängig' })).toBeVisible();
await expect(page.locator('input[name="speed"]')).toHaveCount(0);
// The speed stays the room's for the next start.
await simulationButton(page).click();
await expect(page.locator('input[name="speed"]')).toHaveValue('40');
});
test("lights a cable while it carries a frame, in Java's green", async ({ page }) => {
// The notebooks ask for their addresses by DHCP as the simulation starts.
await openEditor(page, server.base, 'einfaches_rechnernetz_komplett.fls');
await simulationButton(page).click();
await expect(page.locator('path[data-link][data-active="true"]').first()).toBeAttached({ timeout: 10_000 });
await expect(page.locator('path[data-link][data-active="true"]').first()).toHaveCSS('stroke', 'rgb(0, 255, 64)');
await designButton(page).click();
await expect(page.locator('path[data-link][data-active]')).toHaveCount(0);
});
test("opens a computer's desktop with a tap: Java's title, its programs in Java's order, its network as it runs", async ({ page }) => {
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
const id = await deviceNamed(page, 'DNS-Server 2.10');
expect((await desktopOf(page, id))!.programs).toHaveLength(3);
await start(page);
await tap(page, id);
const window = desktop(page, id);
await expect(window.getByRole('heading')).toHaveText('DNS-Server 2.10 - 141.99.2.10');
await expect(window.locator('.desktop-icon span')).toHaveText(['Software-Installation', 'Datei-Explorer', 'DNS-Server', 'Webserver']);
await expect(window.getByRole('button', { name: 'Startbildschirm' })).toBeVisible();
await window.getByRole('button', { name: 'Netzwerk' }).click();
const values = window.locator('.desktop-network dd');
await expect(window.locator('.desktop-network dt')).toHaveText(['IP-Adresse:', 'Netzmaske:', 'DNS-Server:', 'Gateway:', 'MAC-Adresse:']);
await expect(values.nth(0)).toHaveText('141.99.2.10');
await expect(values.nth(4)).toHaveText(/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/);
// A press beside the panel closes it.
await window.locator('.desktop-info-layer').click({ position: { x: 20, y: 20 } });
await expect(window.locator('.desktop-network')).toHaveCount(0);
// A switch has no desktop; its long-press menu has nothing either.
await tap(page, await deviceNamed(page, 'Switch 1'));
await expect(page.locator('section[data-desktop]')).toHaveCount(1);
await window.getByRole('button', { name: 'Schließen' }).click();
await expect(page.locator('section[data-desktop]')).toHaveCount(0);
// Java's menu: "show desktop".
const at = await onDevice(page, await deviceNamed(page, 'Notebook 1.10'));
await page.mouse.move(at.x, at.y);
await page.mouse.down();
await page.waitForTimeout(700);
await page.mouse.up();
await page.getByRole('menuitem', { name: 'Desktop anzeigen' }).click();
await expect(page.locator('section[data-desktop] h2')).toHaveText('Notebook 1.10 - 141.99.1.10');
await expect(page.locator('section[data-desktop] .desktop-icon span')).toHaveText(['Software-Installation', 'Webbrowser']);
});
test('moves a desktop by its title bar, brings the one touched to the front, and keeps them over a return to the design', async ({ page }) => {
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
const [first, second] = [await deviceNamed(page, 'Notebook 1.10'), await deviceNamed(page, 'Notebook 1.11')];
await start(page);
await tap(page, first);
// Moved by its title bar into the half of the page the second notebook is not in.
const at = await onDevice(page, second);
const width = page.viewportSize()!.width;
const bar = (await desktop(page, first).locator('.desktop-titlebar h2').boundingBox())!;
const before = (await desktop(page, first).boundingBox())!;
const step = { x: (at.x < width / 2 ? width - 650 : 0) - before.x, y: 30 };
await page.mouse.move(bar.x + 10, bar.y + 10);
await page.mouse.down();
await page.mouse.move(bar.x + 10 + step.x, bar.y + 10 + step.y, { steps: 8 });
await page.mouse.up();
const after = (await desktop(page, first).boundingBox())!;
expect([Math.round(after.x - before.x), Math.round(after.y - before.y)]).toEqual([Math.round(step.x), step.y]);
await tap(page, second);
await expect(page.locator('section[data-desktop]')).toHaveCount(2);
const z = async (id: string) => Number(await desktop(page, id).evaluate((element) => getComputedStyle(element).zIndex));
expect(await z(second)).toBeGreaterThan(await z(first));
await desktop(page, first).locator('.desktop-titlebar h2').click();
expect(await z(first)).toBeGreaterThan(await z(second));
await designButton(page).click();
await expect(page.locator('section[data-desktop]')).toHaveCount(0);
await simulationButton(page).click();
await expect(page.locator('section[data-desktop]')).toHaveCount(2);
expect(Math.round((await desktop(page, first).boundingBox())!.x)).toBe(Math.round(after.x));
});
test('takes nothing back and keeps no design tool while the simulation runs', 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.mouse.move(at.x, at.y);
await page.mouse.down();
await page.mouse.move(at.x + 60, at.y + 40, { steps: 5 });
await page.mouse.up();
const moved = (await drawing(page)).nodes.find((node) => node.id === id)!;
await page.locator('[data-palette="cable"]').click();
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-tool', 'cable');
await start(page);
await page.keyboard.press('Control+z');
const still = (await drawing(page)).nodes.find((node) => node.id === id)!;
expect([still.x, still.y]).toEqual([moved.x, moved.y]);
await designButton(page).click();
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-tool', 'select');
// Back in the design, the step can be taken back.
await page.getByRole('button', { name: 'Rückgängig' }).click();
await expect.poll(async () => (await drawing(page)).nodes.find((node) => node.id === id)!.x).not.toBe(moved.x);
});
test("titles a desktop with the address alone where the address is the computer's name, and lights its network icon while its cable carries a frame", async ({ page }) => {
await openEditor(page, server.base, 'einfaches_rechnernetz_komplett.fls');
const id = await deviceNamed(page, 'Rechner 1');
await tap(page, id);
await page.getByRole('complementary', { name: 'Einstellungen' }).getByLabel('IP-Adresse als Name verwenden').check();
await page.getByRole('complementary', { name: 'Einstellungen' }).getByRole('button', { name: 'Schließen' }).click();
// The notebooks ask for their addresses by DHCP as the simulation starts; the switch passes their broadcasts on to Rechner 1.
await start(page);
await tap(page, id);
const window = desktop(page, id);
await expect(window.getByRole('heading')).toHaveText('192.168.0.11');
await expect(window.locator('.desktop-network-icon[data-active="true"]')).toBeAttached({ timeout: 10_000 });
await window.getByRole('button', { name: 'Netzwerk' }).click();
await expect(window.locator('.desktop-network')).toBeVisible();
await window.getByRole('button', { name: 'Startbildschirm' }).click();
await expect(window.locator('.desktop-network')).toHaveCount(0);
});
test("closes a desktop whose computer is removed while the design is shown", async ({ page }) => {
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
const id = await deviceNamed(page, 'Notebook 1.10');
await start(page);
await tap(page, id);
await designButton(page).click();
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'design');
await tap(page, id);
await page.keyboard.press('Delete');
await simulationButton(page).click();
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation');
await expect(page.locator('section[data-desktop]')).toHaveCount(0);
// The next desktop takes the first place: the gone one is not counted.
const next = await deviceNamed(page, 'Notebook 1.11');
await tap(page, next);
const area = (await page.locator('.editor-area').boundingBox())!;
const box = (await desktop(page, next).boundingBox())!;
expect([Math.round(box.x - area.x), Math.round(box.y - area.y)]).toEqual([24, 76]);
});
test.describe('on a phone', () => {
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });
test('fills the surface with a desktop opened by a tap, below the mode buttons', async ({ page }) => {
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
await simulationButton(page).tap();
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation');
await page.getByRole('button', { name: 'Alles zeigen' }).tap();
const id = await deviceNamed(page, 'Notebook 1.10');
const at = await onDevice(page, id);
await page.touchscreen.tap(at.x, at.y);
const window = desktop(page, id);
await expect(window).toBeVisible();
const box = (await window.boundingBox())!;
const area = (await page.locator('.editor-area').boundingBox())!;
const modes = (await simulationButton(page).boundingBox())!;
expect(box.width).toBeCloseTo(area.width, 0);
expect(box.y).toBeGreaterThanOrEqual(modes.y + modes.height);
expect(box.y + box.height).toBeCloseTo(area.y + area.height, 0);
const home = (await window.getByRole('button', { name: 'Startbildschirm' }).boundingBox())!;
expect(home.height).toBeGreaterThanOrEqual(44);
});
});
test('speaks English when asked', async ({ page }) => {
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls', '&lang=en');
await page.getByRole('button', { name: 'Change to Simulation Mode' }).click();
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation');
const id = await deviceNamed(page, 'DNS-Server 2.10');
await tap(page, id);
await expect(desktop(page, id).locator('.desktop-icon span')).toHaveText(['Software Installation', 'DNS server', 'File explorer', 'Webserver']);
await expect(desktop(page, id).getByRole('button', { name: 'Home Screen' })).toBeVisible();
});