diff --git a/CHANGES.md b/CHANGES.md index 5333830..c2f68d6 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -129,6 +129,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/settings.ts`, `src/sheet.ts`, `src/fields.ts` (Firewall) | Java `JFirewallDialog`, `GatewayFirewallConfigTable.closeEditor`, `Firewall.addRule/moveUp/moveDown/deleteRule`, `FirewallRule.validateExistingAttributes`, `EingabenUeberpruefung.isValidSubnetmask` | erweitert (F4.5d): Firewall an/aus, ICMP filtern, nur SYN verwerfen, Standardaktion, Regeln (neu wie Javas: leer, TCP, alle Ports, verwerfen; Zelle, Protokoll, Aktion, rauf/runter, löschen); Quelle leer, Adresse oder Javas Marke „999.999.999.999“, Ziel leer oder Adresse, Masken leer oder durchgehende Einsen, Port leer (alle) oder `musterPort`. Abweichend von Java: Javas Tabelle schreibt eine Zelle vor der Prüfung in die Regel, sodass die Firewall „abc“, Maske „255.0.255.0“ oder Port 0 hält, während die Tabelle den alten Wert zeigt — hier wird nichts übernommen, was die Prüfung ablehnt; ein Port, der keine Zahl ist, wird nicht still zu „alle Ports“; „.0.0.1“ und „080“ gelten nicht | | `src/settings.ts`, `src/sheet.ts`, `src/commands.ts` (Verbindungen) | Java `JConnectionsDialog` (`swapConnection`, `removeConnection`, Knöpfe „+“/„-“), `Vermittlungsrechner.hinzuAnschluss/removeNic`, `NetzwerkInterface()`, `InternetKnotenBetriebssystem.setStandardGateway` | erweitert (F4.5e): eine Karte nach der letzten anfügen (192.168.0.10/24, eigene MAC aus der Port-Kennung, bis acht), die letzte samt Kabel entfernen, die Kabel zweier Karten tauschen oder eines umstecken (dieselben Kabel, dieselben Geräte; zwei freie Karten: nichts), Javas Klicklogik (`connectionsClick`: markieren, abwählen, tauschen); das Blatt nennt die Adresse der Gegenstelle (Rechner, Router; Switch und Modem ohne). Abweichend von Java: Java entfernt auch die letzte Karte — ein Router ohne Karte behält kein Gateway (Java legt es in die Karten, gemessen), und keine Datei könnte es halten; hier bleibt eine Karte (`ROUTER_INTERFACES.fewest`) | | `src/view.ts` (`nodeLabel`) | Java `Knoten.holeAnzeigeName` | herausgelöst (F4.5b): die Beschriftung eines Geräts, auch für „Verbunden mit …“ | +| `src/view.ts` (`desktopView`) | Java `GUIDesktopWindow.updateTitle`, `GUIDesktopPanel.updateAppPane` | neu (F4.6a): was der Desktop eines Rechners vom Dokument braucht — Art, Beschriftung, ob sie die Adresse ist (Javas Titel lässt die Adresse dann weg), installierte Programme | | `test/sheet.test.ts`, `test/sheet.cases.txt`, `test/sheet.java.json` | — | neu (F4.5a): 77 in Javas Felder getippte Texte, jede Regel dagegen samt der benannten Abweichungen; die Befehle, die Funkverbindungen, Rückgängig je Änderung, alles über eine gespeicherte `.fls` | | `test/devices.test.ts`, `test/fixtures/new-devices.fls` | — | neu (F4.4a): neue Geräte jeder Art gegen Javas gespeicherte (Zufall maskiert), Kabelregeln, ein selbst gebautes Netz pingt im Kern, Rückgängig nur der eigenen Änderungen über zwei Replikate | | `test/projectDoc.test.ts` | — | neu (F4.1): alle 23 Korpusdateien → Dokument → Yjs-Update → Projekt: dasselbe Projekt, derselbe Schnappschuss, dieselbe `.fls`, dieselbe Reihenfolge je Ordner; Kennungen absichtlich gegen die Dateireihenfolge | @@ -140,6 +141,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | --- | --- | --- | | `src/sim.ts`, `src/host.ts` | Plan §6.4 | neu (F4.2): die Nachrichten zwischen Editor und Simulationsraum in einer Liste (Client: sub, unsub, start, stop, speed, input; Raum: state, activity, trace, app, ack, error), Prüfung jedes Felds mit benanntem Fehler statt stillem Verwerfen; `SimHost` | | `test/sim.test.ts` | — | neu (F4.2): jede Art gelesen, jede Ablehnung mit ihrem Grund | +| `src/sim.ts` (`node`) | Java `NetworkInfoPanel.updateInfo`, `GUIDesktopWindow.updateTitle` | erweitert (F4.6a): Thema `node:` und Nachricht `node` — die erste Karte eines Geräts, wie die Simulation sie fährt (DHCP ändert sie), für Titel und Netzwerk-Info des Desktops | ## packages/sim @@ -148,6 +150,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/room.ts` | Plan §6.1–6.4; Javas Geschwindigkeit `Verbindung.verzoegerungsFaktor` | neu (F4.2): der Simulationsraum — eine Engine je Projekt, Schnappschuss des Dokuments beim Start, Uhr im Takt der Wanduhr (Geschwindigkeit = Kabelverzögerung wie Java, Nachholen höchstens 2 s), Mitschnitt und Uhr über Stopp und Start, Abonnements (Kabelaktivität bei Änderung, neue Rahmen je Karte in beiden Ansichten, neue Fensterzeilen je Programm), Befehlszeile als erste Aktion; ein Fehler des Kerns wird allen gemeldet (`engineFailed`) statt den Raum zu beenden | | `src/channel.ts`, `src/serveRoom.ts`, `src/localSimHost.ts`, `src/worker.ts` | Plan §6.1 | neu (F4.2): der Raum in einem Web Worker der Seite — Dokument-Updates in beide Richtungen ohne Echo, Nachrichten geprüft | | `test/room.test.ts`, `test/localSimHost.test.ts` | — | neu (F4.2): der Raum gegen die von Hand gesteuerte Engine (dieselben Rahmen, dieselben Zeilen), Abonnieren, Geschwindigkeit, Nachholen, Fehler; Host und Worker-Seite über einen `MessageChannel` | +| `src/room.ts` (`node`) | Plan §6.4 | erweitert (F4.6a): der Raum schickt einem Abonnenten die Netzwerkwerte eines Geräts beim Abonnieren und wenn sie sich ändern, nichts vor dem ersten Start | | `browser/m9.spec.ts`, `browser/traceDigest.ts`, `browser/*Entry.ts`, `/playwright.config.ts` | Plan §13 M9 | neu (F4.2): M9 in Chromium — 17 Beispiele, je 30 s mit Eingaben, ergeben Zeichen für Zeichen denselben Mitschnitt wie in Node; der Raum in einem echten Worker | ## packages/editor @@ -170,6 +173,10 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `browser/sheet.spec.ts` | — | neu (F4.5a): das Blatt in Chromium — Felder wie Java, Markieren und Behalten, Gerätewechsel, Rückgängig, WLAN, Handy mit Berührung, Englisch; seit F4.5b Router-Reiter, aktives Kabel, Weiterleitungstabelle, RIP, Heimrouter; seit F4.5c DHCP-Server und Portfreigaben; seit F4.5d Firewall; seit F4.5e Verbindungen (Tauschen, Umstecken, Karten bis acht und bis eine, Markierung nach Rückgängig) | | `src/sheet/FirewallPanel.tsx` | Java `JFirewallDialog` (`jfirewalldialog_msg21`–`43`, `jvermittlungsrechnerkonfiguration_msg4`) | neu (F4.5d): die Firewall als Seite des Blatts (bei Vermittlungsrechner und Heimrouter unter „Allgemein“): Reiter Allgemein mit den drei Schaltern und Javas Erklärungen, Reiter Firewall-Regeln mit Javas Einleitung, Standardaktion, Regeltabelle, rauf/runter (an den Enden gesperrt), Neue Regel, Regel löschen (die Nachfolgerin bleibt gewählt, wie bei Java). Javas erster Reiter heißt „Netzwerkschnittstellen“, enthält aber die allgemeinen Schalter — hier „Allgemein“; Javas „Aktion?“ ohne Fragezeichen, „über die Standard-Aktion“ groß | | `src/sheet/ConnectionsPanel.tsx`, `src/sheet/SheetField.tsx` (`SheetNote`), `src/theme/tokens.css` (`--connection-*`) | Java `JConnectionsDialog` (`jvermittlungsrechnerkonfiguration_msg19`, `22`–`25`) | neu (F4.5e): „Verbindungen verwalten“ als Seite des Vermittlungsrechners (unter „Allgemein“ nach „Firewall einrichten“, wie bei Java): je Karte Javas RJ45-Knopf links von „NIC n: Adresse“ in Javas Farben (grün belegt, rot frei, gelb markiert), darunter die Gegenstelle wie bei Java („A (10.0.0.2)“, ein Switch nur mit Namen); antippen markiert, dieselbe abwählen, eine andere tauscht; „+“ und „−“ mit Javas „Schnittstelle hinzufügen“ und „Maximal 8 Schnittstellen erlaubt!“; Javas Hinweis darunter wie im Dialog. Java zeichnet die Kabel zwischen zwei Spalten — im schmalen Blatt steht die Gegenstelle unter der Karte; der Reiter einer entfernten Karte fällt auf „Allgemein“ zurück | +| `src/simulation/useSimulation.ts`, `src/simulation/ModeBar.tsx`, `src/assets/modes/*.png` | Java `GUIMainMenu.selectMode/updateLatency` (`guimainmemu_msg3`/`4`/`15`/`16`), Javas Modussymbole (`gfx/allgemein`) | neu (F4.6a): der Raum im Worker der Seite (`LocalSimHost` über `?worker`), Javas Moduswahl mit Javas Symbolen, im Aktionsmodus der Geschwindigkeitsregler mit Prozent (90 % zu Beginn wie Java); Javas Tasten Strg+D/Strg+R nicht, sie legen im Browser ein Lesezeichen an und laden neu | +| `src/desktop/DesktopWindow.tsx`, `src/desktop/programs.ts`, `src/desktop/desktop.css`, `src/assets/desktop/*.png` | Java `GUIDesktopWindow`, `GUIDesktopPanel`, `GUIDesktopIcon`, `NetworkInfoPanel`, `config/Desktop_de_DE.txt`/`_en_GB.txt` (`desktoppanel_*`, `network_msg*`) | neu (F4.6a): der Desktop eines Rechners — Titel „Name - Adresse“ (nur die Beschriftung, wo sie die Adresse ist), Javas Hintergrund, Software-Installation zuerst, dann die Programme in der Reihenfolge der Java-Liste der Sprache, graue Taskleiste mit „Startbildschirm“ und dem Netzwerksymbol (leuchtet, solange das Kabel trägt), Netzwerk-Info mit den laufenden Werten; auf breiten Schirmen verschiebbar über der Fläche, mehrere gestapelt, auf dem Handy flächig unter den Modusknöpfen. Javas Taskleiste hat 30 px, hier 44 für Finger; Javas Knopf „Ändern“ unter schreibgeschützten Feldern fehlt. Die Programmfenster folgen ab F4.6b | +| `src/Editor.tsx`, `src/canvas/Canvas.tsx` (Aktionsmodus) | Java `GUIContainer.setActiveSite/showDesktop/closeDialogs/reopenDialogs`, `GUIKnotenItem` (Klick im Aktionsmodus), `GUIEvents.kontextMenueAktionsmodus` (`guievents_msg3`), `JCablePanel` (`farbeBlinken`) | erweitert (F4.6a): im Aktionsmodus ist der Entwurf gesperrt wie bei Java — keine Palette, kein Blatt, nichts bewegt sich, kein Rückgängig, kein Löschen —, Kabel mit Rahmen leuchten grün, ein Tipp auf einen Rechner öffnet seinen Desktop (Langdruck: „Desktop anzeigen“); offene Desktops überstehen den Entwurf und erscheinen beim nächsten Start wieder, die eines entfernten Rechners nicht | +| `browser/simulation.spec.ts`, `test/programs.test.ts` | — | neu (F4.6a): Moduswechsel, Sperren, Geschwindigkeit, Kabelaktivität, Desktop mit Titel, Programmen, Netzwerk-Info, Verschieben und Stapeln, Handy, Englisch; die Programmliste gegen Javas `Desktop_*.txt` | | `src/sheet/ServicePanels.tsx`, `src/sheet/RowsEditor.tsx`, `src/sheet/SheetTabs.tsx`, `src/sheet/DeviceSheet.tsx` (Seiten) | Java `JDHCPKonfiguration` (`jdhcpkonfiguration_msg1`–`15`), `JPortForwardingDialog` (`jportforwarding_msg4`–`7`, `jgatewayconfiguration_msg19`) | neu (F4.5c): Javas Dialoge als Seiten des Blatts mit „Zurück“ — DHCP-Server (Grundeinstellungen, statische Adresszuweisung; beim Rechner, gesperrt während DHCP die Karte einrichtet, und auf dem LAN-Reiter des Heimrouters), Portfreigaben (Heimrouter); jedes Feld beim Verlassen übernommen statt mit Javas OK-Knopf. Die Zeilentabellen (Weiterleitung, Zuordnungen, Portfreigaben) teilen sich `RowsEditor`: eine neue Zeile, sobald alle Felder gültig sind, Zeile wählen, bearbeiten, löschen. Javas Tooltip zu „Manuelle Einstellungen“ als Text unter dem Kästchen (ein Finger hat kein Überfahren) | | `src/sheet/RouterSheets.tsx`, `src/sheet/NameField.tsx`, `src/canvas/Canvas.tsx` (`highlightedLink`), `src/theme/tokens.css` (`--canvas-cable-active`) | Java `JVermittlungsrechnerKonfiguration`, `JGatewayConfiguration`, `JWeiterleitungsTabelle` (Wörter `jvermittlungsrechnerkonfiguration_msg6`–`26`, `jweiterleitungstabelle_msg3`–`6`), `JCablePanel.farbeBlinken`, `Kabel.setAktiv` | neu (F4.5b): Blatt des Vermittlungsrechners — Reiter Allgemein (Name, Gateway, Automatisches Routing, IP-Weiterleitung nur angezeigt), je Karte (Reiter mit ihrer Adresse, „Verbunden mit …“, Adresse, Maske, MAC) und Weiterleitungstabelle (alle Einträge oder nur die manuellen, Javas Zeilen grau, eine neue Zeile, die mit vier gültigen Feldern übernommen wird, Zelle bearbeiten, Eintrag löschen; bei RIP gesperrt); Blatt des Heimrouters mit Allgemein, LAN, WAN (DHCP sperrt die WAN-Adresse). Das Kabel der offenen Karte leuchtet grün wie bei Java. Javas „Eintrag Löschen“ klein geschrieben | diff --git a/packages/doc/src/view.ts b/packages/doc/src/view.ts index 6ceb037..f26ffc7 100644 --- a/packages/doc/src/view.ts +++ b/packages/doc/src/view.ts @@ -4,7 +4,7 @@ // so that it can run on every change while someone drags a device. import * as Y from 'yjs'; -import { compareIds, stableGeneratedMacAddress, type DocumentationItemKind, type HostLabelPolicy, type NodeKind } from '@mdg-filius/core'; +import { compareIds, stableGeneratedMacAddress, type DocumentationItemKind, type HostLabelPolicy, type InstallableProgram, type NodeKind } from '@mdg-filius/core'; import { inOrder, ROOTS } from './projectDoc.js'; export interface NodeView { @@ -115,3 +115,28 @@ export function topologyView(doc: Y.Doc): TopologyView { docs.sort((left, right) => layer(left) - layer(right) || left.order - right.order || compareIds(left.id, right.id)); return { nodes, links, docs }; } + +/** What a computer's desktop needs of the document (Java's GUIDesktopWindow, GUIDesktopPanel). */ +export interface DesktopView { + readonly kind: 'pc' | 'notebook'; + /** Its label (Host.holeAnzeigeName). */ + readonly label: string; + /** Whether the label is its address: Java's title then leaves the address out (isUseIPAsName). */ + readonly addressAsName: boolean; + readonly programs: readonly InstallableProgram[]; +} + +/** A PC's or notebook's desktop; undefined for any other device. */ +export function desktopView(doc: Y.Doc, id: string): DesktopView | undefined { + const node = doc.getMap>(ROOTS.nodes).get(id); + const kind = node?.get('kind') as NodeKind | undefined; + if (node === undefined || (kind !== 'pc' && kind !== 'notebook')) return undefined; + const policy = node.get('labelPolicy') as HostLabelPolicy; + return { + kind, + label: nodeLabel(node), + addressAsName: policy === 'ipAddress' || policy === 'ipAndMAC', + programs: [...(node.get('programs') as Y.Map).keys()] as InstallableProgram[], + }; +} + diff --git a/packages/doc/test/view.test.ts b/packages/doc/test/view.test.ts index 2fd2957..aa1d791 100644 --- a/packages/doc/test/view.test.ts +++ b/packages/doc/test/view.test.ts @@ -10,7 +10,7 @@ import { describe, expect, it } from 'vitest'; import * as Y from 'yjs'; import { stableGeneratedMacAddress } from '@mdg-filius/core'; import { exportProject, openProjectArchive, type ImportedProject } from '@mdg-filius/fls'; -import { readProject, topologyView, writeProject } from '../src/index.js'; +import { addDevice, desktopView, readProject, setHostLabels, topologyView, writeProject } from '../src/index.js'; const corpus = join(dirname(fileURLToPath(import.meta.url)), '../../../corpus'); const files = ['java-examples', 'java-tests', 'ipad-fixtures'].flatMap((folder) => @@ -70,3 +70,38 @@ describe('the drawing of a document', () => { expect(switchLabel).toBe('Switch 1'); }); }); + +describe("a computer's desktop", () => { + it('names its programs and its label, and whether the label is its address, for computers only', () => { + for (const file of files) { + const doc = docOf(file); + const project = readProject(doc); + for (const node of project.graph.nodes) { + const desktop = desktopView(doc, node.id); + if (node.kind !== 'pc' && node.kind !== 'notebook') { + expect(desktop, `${file} ${node.displayName}`).toBeUndefined(); + continue; + } + expect(new Set(desktop!.programs), `${file} ${node.displayName}`).toEqual(project.installedPrograms.get(node.id) ?? new Set()); + expect(desktop!.kind).toBe(node.kind); + expect(desktop!.label).toBe(topologyView(doc).nodes.find((each) => each.id === node.id)!.label); + expect(desktop!.addressAsName).toBe(node.hostLabelPolicy === 'ipAddress' || node.hostLabelPolicy === 'ipAndMAC'); + } + } + expect(desktopView(docOf(files[0]!), 'nobody')).toBeUndefined(); + }); + + it("takes a label of address and MAC address for the address, as Java's title does (isUseIPAsName), the MAC address alone not", () => { + const doc = new Y.Doc(); + let counter = 0; + addDevice(doc, { kind: 'pc', id: 'pc', name: 'PC', x: 0, y: 0, newId: () => `port-${++counter}` }, 'me'); + expect(desktopView(doc, 'pc')).toMatchObject({ kind: 'pc', label: 'PC', addressAsName: false, programs: [] }); + setHostLabels(doc, 'pc', { ipAddress: true, macAddress: true }, 'me'); + expect(desktopView(doc, 'pc')!.addressAsName).toBe(true); + setHostLabels(doc, 'pc', { ipAddress: false, macAddress: true }, 'me'); + expect(desktopView(doc, 'pc')!.addressAsName).toBe(false); + setHostLabels(doc, 'pc', { ipAddress: true, macAddress: false }, 'me'); + expect(desktopView(doc, 'pc')).toMatchObject({ label: '192.168.0.10', addressAsName: true }); + }); +}); + diff --git a/packages/editor/browser/editorPage.ts b/packages/editor/browser/editorPage.ts index b883480..5043ef0 100644 --- a/packages/editor/browser/editorPage.ts +++ b/packages/editor/browser/editorPage.ts @@ -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 (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 { + 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]; diff --git a/packages/editor/browser/simulation.spec.ts b/packages/editor/browser/simulation.spec.ts new file mode 100644 index 0000000..4e483a4 --- /dev/null +++ b/packages/editor/browser/simulation.spec.ts @@ -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 }; + +test.beforeAll(async () => { + server = await serveEditor(); +}); + +test.afterAll(async () => { + await server.close(); +}); + +async function deviceNamed(page: Page, name: string): Promise { + return (await drawing(page)).nodes.find((node) => node.name === name)!.id; +} + +async function tap(page: Page, id: string): Promise { + 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 { + 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(); +}); diff --git a/packages/editor/src/Editor.tsx b/packages/editor/src/Editor.tsx index 09d37b0..e3cc7a1 100644 --- a/packages/editor/src/Editor.tsx +++ b/packages/editor/src/Editor.tsx @@ -1,6 +1,10 @@ // The editor on a document: the palette, the surface, the tools of the design -// mode (F4.4b), the device sheet of the selected device (F4.5). The -// simulation follows (F4.6). +// mode (F4.4b), the device sheet of the selected device (F4.5), Java's mode +// buttons and the simulation (F4.6): while it runs, the design is locked as +// in Java — no palette, no sheet, nothing moves — the cables that carry a +// frame light up, and a tap on a computer opens its desktop. Desktops left +// open stay over a return to the design and show again at the next start, +// as Java's do (GUIContainer.closeDialogs, reopenDialogs). // // Every change goes through packages/doc's commands with this editor's own // origin, so that its undo takes back what this person did and nothing of @@ -15,7 +19,10 @@ import { ContextMenu, type MenuItem } from './design/ContextMenu.js'; import { Palette, type PaletteKind } from './design/Palette.js'; import { RouterDialog } from './design/RouterDialog.js'; import { DeviceSheet } from './sheet/DeviceSheet.js'; +import { DesktopWindow } from './desktop/DesktopWindow.js'; import { useTranslate } from './i18n/i18n.js'; +import { ModeBar } from './simulation/ModeBar.js'; +import { useSimulation, type Simulation, type SimHostFactory } from './simulation/useSimulation.js'; /** The document's drawing, read anew after every change. */ export function useTopologyView(doc: Y.Doc): TopologyView { @@ -66,9 +73,33 @@ const toolButton: CSSProperties = { /** Whether a key press belongs to a field being typed in. */ const typing = (target: EventTarget | null) => target instanceof HTMLElement && (target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)); -export function Editor({ doc }: { readonly doc: Y.Doc }) { +/** A desktop open on the surface: whose, where its window lies, and how far up (the last one touched on top). */ +interface OpenDesktop { + readonly id: string; + readonly x: number; + readonly y: number; + readonly z: number; +} + +/** Brings a desktop to the top by its stacking, not its place in the page: moved in the page, it would lose the press on it. */ +const toFront = (open: readonly OpenDesktop[], id: string): readonly OpenDesktop[] => { + const top = Math.max(0, ...open.map((each) => each.z)); + return open.some((each) => each.id === id && each.z === top) ? open : open.map((each) => (each.id === id ? { ...each, z: top + 1 } : each)); +}; + +/** A desktop that watches its computer's network while it is shown. */ +function WatchedDesktop({ simulation, ...props }: Omit[0], 'settings'> & { readonly simulation: Simulation }) { + const { watch } = simulation; + useEffect(() => watch([`node:${props.id}`]), [watch, props.id]); + return ; +} + +export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly createSimHost?: SimHostFactory }) { const t = useTranslate(); const view = useTopologyView(doc); + const simulation = useSimulation(doc, createSimHost); + const simulating = simulation.mode === 'simulation'; + const [desktops, setDesktops] = useState([]); const origin = useMemo(() => ({ editor: crypto.randomUUID() }), []); const { manager, canUndo, canRedo } = useUndo(doc, origin); const canvas = useRef(null); @@ -88,6 +119,37 @@ export function Editor({ doc }: { readonly doc: Y.Doc }) { return () => clearTimeout(timer); }, [message]); + // The simulation locks the design: what was chosen or begun is let go. + useEffect(() => { + if (!simulating) return; + setSelection(null); + setCableTool(false); + setCableFrom(null); + setMenu(null); + setRouterAt(null); + }, [simulating]); + + useEffect(() => { + if (simulation.failed) setMessage(t('sim.failed')); + // The message is shown once per failure. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [simulation.failed]); + + // A desktop whose computer is gone, or is no computer any more, closes (Java: reopenDialogs). + useEffect(() => { + const computers = new Set(view.nodes.filter((node) => node.kind === 'pc' || node.kind === 'notebook').map((node) => node.id)); + setDesktops((open) => (open.every((each) => computers.has(each.id)) ? open : open.filter((each) => computers.has(each.id)))); + }, [view]); + + /** Opens a computer's desktop, or brings it to the front. */ + const openDesktop = useCallback((id: string) => { + setDesktops((open) => { + if (open.some((each) => each.id === id)) return toFront(open, id); + // Java stacks a desktop that does not fit beside the others a little further on. + return [...open, { id, x: 24 + 24 * open.length, y: 76 + 24 * open.length, z: Math.max(0, ...open.map((each) => each.z)) + 1 }]; + }); + }, []); + // What is selected may be gone (removed here or by someone else). useEffect(() => { if (selection?.kind === 'device' && !view.nodes.some((node) => node.id === selection.id)) setSelection(null); @@ -137,6 +199,11 @@ export function Editor({ doc }: { readonly doc: Y.Doc }) { ); const menuItems = (target: Selection): MenuItem[] => { + if (simulating) { + // Java's menu in the simulation: a computer's desktop (its data exchange follows with the capture, F4.7). + const node = view.nodes.find((each) => each.id === target.id); + return target.kind === 'device' && (node?.kind === 'pc' || node?.kind === 'notebook') ? [{ key: 'showDesktop', label: t('menu.showDesktop'), onChoose: () => openDesktop(target.id) }] : []; + } if (target.kind === 'link') return [{ key: 'removeCable', label: t('menu.removeCable'), onChoose: () => remove(target) }]; const node = view.nodes.find((each) => each.id === target.id); const cables = view.links.some((link) => link.sourceNode === target.id || link.targetNode === target.id); @@ -161,7 +228,7 @@ export function Editor({ doc }: { readonly doc: Y.Doc }) { // The keyboard: undo and redo, delete what is selected, Escape lets go. useEffect(() => { const key = (event: KeyboardEvent) => { - if (typing(event.target)) return; + if (typing(event.target) || simulating) return; const command = event.ctrlKey || event.metaKey; if (command && event.key.toLowerCase() === 'z') { event.preventDefault(); @@ -180,18 +247,20 @@ export function Editor({ doc }: { readonly doc: Y.Doc }) { }; window.addEventListener('keydown', key); return () => window.removeEventListener('keydown', key); - }, [manager, selection, menu, routerAt, remove]); + }, [manager, selection, menu, routerAt, remove, simulating]); return (
- { - setCableTool(on); - setCableFrom(null); - }} - onDevice={onDevice} - /> + {simulating ? null : ( + { + setCableTool(on); + setCableFrom(null); + }} + onDevice={onDevice} + /> + )}
{ + const kind = view.nodes.find((node) => node.id === id)?.kind; + if (kind === 'pc' || kind === 'notebook') openDesktop(id); + }} onMenu={(target, at) => { + if (simulating) { + if (menuItems(target).length > 0) setMenu({ target, at }); + return; + } setSelection(target); setMenu({ target, at }); }} /> -
- - +
+ (mode === 'simulation' ? simulation.start() : simulation.stop())} onSpeed={simulation.setSpeed} /> + {simulating ? null : ( + <> + + + + )}
+ {simulating + ? desktops.map((desktop) => ( + (link.sourceNode === desktop.id || link.targetNode === desktop.id) && simulation.activeLinks.has(link.id))} + x={desktop.x} + y={desktop.y} + z={desktop.z} + onMove={(x, y) => setDesktops((open) => open.map((each) => (each.id === desktop.id ? { ...each, x, y } : each)))} + onFront={() => setDesktops((open) => toFront(open, desktop.id))} + onClose={() => setDesktops((open) => open.filter((each) => each.id !== desktop.id))} + /> + )) + : null} {menu === null ? null : setMenu(null)} />} {routerAt === null ? null : ( void; /** A cable drawn active, as Java draws the cable of the card whose tab is open (Kabel.setAktiv). */ readonly highlightedLink?: string | null; + /** + * The simulation: a tap on a device activates it (a computer's desktop), + * a long press opens its menu; nothing moves, nothing is selected, as in + * Java's simulation mode. + */ + readonly mode?: 'design' | 'simulation'; + /** The cables that carry a frame now, drawn as Java draws them then (JCablePanel.farbeBlinken). */ + readonly activeLinks?: ReadonlySet; + readonly onActivate?: (id: string) => void; } +const NO_LINKS: ReadonlySet = new Set(); + /** The label's width in Java's font, measured once per text. */ function useTextWidth(): (text: string) => number { const cache = useRef(new Map()); @@ -141,7 +152,7 @@ type Gesture = | { readonly kind: 'link'; readonly id: string; readonly start: Point; moved: boolean; longPressed: boolean }; export const Canvas = forwardRef(function Canvas( - { view, tool = 'select', selection = null, cableFrom = null, onSelect, onMoveDevice, onMoveEnd, onCableStart, onCable, onMenu, highlightedLink = null }, + { view, tool = 'select', selection = null, cableFrom = null, onSelect, onMoveDevice, onMoveEnd, onCableStart, onCable, onMenu, highlightedLink = null, mode = 'design', activeLinks = NO_LINKS, onActivate }, handle, ) { const t = useTranslate(); @@ -222,6 +233,7 @@ export const Canvas = forwardRef(function Canvas( [kinds, t], ); + const simulating = mode === 'simulation'; const pointers = useRef(new Map()); const gesture = useRef(null); const longPress = useRef | null>(null); @@ -258,7 +270,7 @@ export const Canvas = forwardRef(function Canvas( now.longPressed = true; onMenu?.({ kind: 'device', id: now.id }, now.start); }, LONG_PRESS_MILLISECONDS); - } else if (linkId !== undefined && linkId !== null) { + } else if (linkId !== undefined && linkId !== null && !simulating) { gesture.current = { kind: 'link', id: linkId, start: point, moved: false, longPressed: false }; longPress.current = setTimeout(() => { const now = gesture.current; @@ -302,7 +314,7 @@ export const Canvas = forwardRef(function Canvas( const step = { x: point.x - now.last.x, y: point.y - now.last.y }; now.last = point; setViewport((before) => shifted(before ?? currentRef.current, step)); - } else if (now.kind === 'device' && !now.longPressed) { + } else if (now.kind === 'device' && !now.longPressed && !simulating) { if (tool === 'cable') { const from = boxes.get(now.id)!.center; const target = deviceAt(point); @@ -328,7 +340,7 @@ export const Canvas = forwardRef(function Canvas( return; } if (now.kind === 'background') { - if (!now.moved) { + if (!now.moved && !simulating) { onSelect?.(null); if (cableFrom !== null) onCableStart?.(null); } @@ -339,6 +351,10 @@ export const Canvas = forwardRef(function Canvas( return; } if (now.longPressed) return; + if (simulating) { + if (!now.moved) onActivate?.(now.id); + return; + } if (tool === 'cable') { setBand(null); const from = { id: now.id, side: now.side }; @@ -399,6 +415,7 @@ export const Canvas = forwardRef(function Canvas( ref={surface} className="filius-canvas" data-tool={tool} + data-mode={mode} role="application" aria-label={t('canvas.label')} style={{ ...style, cursor: tool === 'cable' ? 'crosshair' : 'default' }} @@ -421,7 +438,7 @@ export const Canvas = forwardRef(function Canvas( if (source === undefined || target === undefined) return null; const d = cablePath(source.center, target.center); const selected = selection?.kind === 'link' && selection.id === link.id; - const active = highlightedLink === link.id; + const active = simulating ? activeLinks.has(link.id) : highlightedLink === link.id; return ( (function Canvas( )} - {menuButton === null || selection === null ? null : ( + {menuButton === null || selection === null || simulating ? null : ( + +
+
    +
  • + {/* The programs' windows come with the next steps. */} + +
  • + {desktopPrograms(view.programs, language).map((program) => ( +
  • + +
  • + ))} +
+ {info ? ( + // A press beside the panel closes it, as in Java. +
event.target === event.currentTarget && setInfo(false)}> +
+ {rows.map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+
+ ) : null} +
+
+ + + +
+ + ); +} diff --git a/packages/editor/src/desktop/desktop.css b/packages/editor/src/desktop/desktop.css new file mode 100644 index 0000000..0f63ac6 --- /dev/null +++ b/packages/editor/src/desktop/desktop.css @@ -0,0 +1,185 @@ +/* A computer's desktop after Java's GUIDesktopPanel: 640 × 460 pixels below + * the title bar, a grey taskbar at the bottom (180, 180, 180; 44 pixels high + * for a finger, Java's has 30), icons of 120 × 96 with white names on Java's + * background. On a phone it fills the surface. Java's colours stay in both + * themes: the screen is the simulated computer's, not the editor's. */ +.desktop-window { + position: absolute; + z-index: 5; + display: flex; + flex-direction: column; + width: 640px; + max-width: 100%; + height: calc(460px + 14px + 36px); + max-height: 100%; + box-sizing: border-box; + border: 1px solid var(--border-strong); + border-radius: 8px; + overflow: hidden; + background: var(--surface-card); + box-shadow: 0 8px 24px color-mix(in srgb, var(--shadow-color) 25%, transparent); +} + +/* Below the mode buttons, which stay in reach. */ +@media (max-width: 639px) { + .desktop-window { + inset: 72px 0 0 0 !important; + width: auto; + height: auto; + border-radius: 0; + } +} + +.desktop-titlebar { + display: flex; + align-items: center; + gap: 8px; + min-height: 36px; + padding: 0 0 0 8px; + background: var(--surface-raised); + border-bottom: 1px solid var(--border-default); + cursor: move; + touch-action: none; + user-select: none; +} + +.desktop-titlebar img { + width: 24px; + height: 24px; + object-fit: contain; +} + +.desktop-titlebar h2 { + flex: 1; + margin: 0; + font-size: 15px; + font-weight: 600; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + color: var(--text-primary); +} + +.desktop-close { + min-width: 44px; + min-height: 36px; + border: none; + background: transparent; + color: var(--text-secondary); + font: inherit; + font-size: 22px; + cursor: pointer; +} + +.desktop-screen { + position: relative; + flex: 1; + min-height: 0; + overflow: auto; + background-size: cover; + background-position: top left; +} + +.desktop-icons { + display: flex; + flex-wrap: wrap; + gap: 10px 5px; + margin: 0; + padding: 10px 5px; + list-style: none; +} + +.desktop-icon { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + width: 120px; + height: 96px; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + color: #ffffff; + font: 13px Dialog, sans-serif; + cursor: pointer; +} + +.desktop-icon[aria-disabled='true'] { + cursor: default; +} + +.desktop-icon:focus-visible { + outline: 2px solid #ffffff; +} + +.desktop-icon img { + width: 64px; + height: 64px; +} + +.desktop-info-layer { + position: absolute; + inset: 0; + display: flex; + justify-content: flex-end; + align-items: flex-end; +} + +.desktop-network { + margin: 0; + padding: 8px 12px; + border: 2px inset #c0c0c0; + background: #eeeeee; + color: #000000; + font: 13px Dialog, sans-serif; +} + +.desktop-network div { + display: grid; + grid-template-columns: 110px 140px; + align-items: center; + min-height: 26px; +} + +.desktop-network dt, +.desktop-network dd { + margin: 0; +} + +.desktop-network dd { + padding: 2px 6px; + background: #ffffff; + border: 1px solid #b0b0b0; + font-variant-numeric: tabular-nums; + user-select: text; +} + +.desktop-taskbar { + display: grid; + grid-template-columns: 52px 1fr 52px; + align-items: stretch; + height: 44px; + flex: none; + background: rgb(180 180 180); +} + +.desktop-home, +.desktop-network-icon { + border: none; + background: transparent; + color: #000000; + font: 13px Dialog, sans-serif; + cursor: pointer; +} + +.desktop-home { + justify-self: center; + padding: 0 12px; +} + +.desktop-network-icon img { + width: 18px; + height: 18px; +} diff --git a/packages/editor/src/desktop/programs.ts b/packages/editor/src/desktop/programs.ts new file mode 100644 index 0000000..a01327e --- /dev/null +++ b/packages/editor/src/desktop/programs.ts @@ -0,0 +1,56 @@ +// The programs of a computer's desktop, after Java's list of them +// (config/Desktop_.txt: name, class, window, icon), which also sets +// their order there — each language's own (the English list is alphabetical, +// the German one is not). The DHCP server has no window on the desktop; it is +// set up in the device sheet, as in Java. + +import type { InstallableProgram } from '@mdg-filius/core'; +import type { Language } from '../i18n/i18n.js'; +import browser from '../assets/desktop/icon_browser.png'; +import simpleClient from '../assets/desktop/icon_clientbaustein.png'; +import dns from '../assets/desktop/icon_dns.png'; +import emailClient from '../assets/desktop/icon_emailprogramm.png'; +import emailServer from '../assets/desktop/icon_emailserver.png'; +import fileExplorer from '../assets/desktop/icon_filebrowser.png'; +import firewall from '../assets/desktop/icon_firewall.png'; +import imageViewer from '../assets/desktop/icon_imageviewer.png'; +import gnutella from '../assets/desktop/icon_peertopeer.png'; +import echoServer from '../assets/desktop/icon_serverbaustein.png'; +import installation from '../assets/desktop/icon_softwareinstallation.png'; +import terminal from '../assets/desktop/icon_terminal.png'; +import textEditor from '../assets/desktop/icon_texteditor.png'; +import webServer from '../assets/desktop/icon_webserver.png'; + +export type DesktopProgram = Exclude; + +/** Each program's class in Java and its icon (gfx/desktop). */ +export const JAVA_PROGRAMS: Readonly> = { + commandPrompt: { javaClass: 'filius.software.lokal.Terminal', icon: terminal, iconFile: 'icon_terminal.png' }, + imageViewer: { javaClass: 'filius.software.lokal.ImageViewer', icon: imageViewer, iconFile: 'icon_imageviewer.png' }, + fileExplorer: { javaClass: 'filius.software.lokal.FileExplorer', icon: fileExplorer, iconFile: 'icon_filebrowser.png' }, + dnsServer: { javaClass: 'filius.software.dns.DNSServer', icon: dns, iconFile: 'icon_dns.png' }, + emailClient: { javaClass: 'filius.software.email.EmailAnwendung', icon: emailClient, iconFile: 'icon_emailprogramm.png' }, + emailServer: { javaClass: 'filius.software.email.EmailServer', icon: emailServer, iconFile: 'icon_emailserver.png' }, + echoServer: { javaClass: 'filius.software.clientserver.ServerBaustein', icon: echoServer, iconFile: 'icon_serverbaustein.png' }, + simpleClient: { javaClass: 'filius.software.clientserver.ClientBaustein', icon: simpleClient, iconFile: 'icon_clientbaustein.png' }, + firewall: { javaClass: 'filius.software.firewall.Firewall', icon: firewall, iconFile: 'icon_firewall.png' }, + gnutella: { javaClass: 'filius.software.dateiaustausch.PeerToPeerAnwendung', icon: gnutella, iconFile: 'icon_peertopeer.png' }, + textEditor: { javaClass: 'filius.software.lokal.TextEditor', icon: textEditor, iconFile: 'icon_texteditor.png' }, + webServer: { javaClass: 'filius.software.www.WebServer', icon: webServer, iconFile: 'icon_webserver.png' }, + webBrowser: { javaClass: 'filius.software.www.WebBrowser', icon: browser, iconFile: 'icon_browser.png' }, +}; + +/** The software installation's icon, always first on the desktop (GUIDesktopPanel.initInstaller). */ +export const INSTALLATION_ICON = installation; + +/** The order of Java's list in each language. */ +export const DESKTOP_ORDER: Readonly> = { + de: ['commandPrompt', 'imageViewer', 'fileExplorer', 'dnsServer', 'emailClient', 'emailServer', 'echoServer', 'simpleClient', 'firewall', 'gnutella', 'textEditor', 'webServer', 'webBrowser'], + en: ['commandPrompt', 'dnsServer', 'echoServer', 'emailServer', 'emailClient', 'fileExplorer', 'firewall', 'simpleClient', 'gnutella', 'imageViewer', 'textEditor', 'webBrowser', 'webServer'], +}; + +/** A computer's programs as its desktop shows them, in the language's order. */ +export function desktopPrograms(installed: readonly InstallableProgram[], language: Language): DesktopProgram[] { + const have = new Set(installed); + return DESKTOP_ORDER[language].filter((program) => have.has(program)); +} diff --git a/packages/editor/src/i18n/de.json b/packages/editor/src/i18n/de.json index 86a53af..10086bb 100644 --- a/packages/editor/src/i18n/de.json +++ b/packages/editor/src/i18n/de.json @@ -22,6 +22,9 @@ "design.palette": "Komponenten", "design.redo": "Wiederholen", "design.undo": "Rückgängig", + "desktop.home": "Startbildschirm", + "desktop.installation": "Software-Installation", + "desktop.network": "Netzwerk", "dev.example": "Beispiel", "dev.newProject": "Neues Projekt", "dev.openFile": "Datei öffnen …", @@ -79,6 +82,29 @@ "menu.delete": "Löschen", "menu.removeAllCables": "Alle Verbindungen entfernen", "menu.removeCable": "Verbindung entfernen", + "menu.showDesktop": "Desktop anzeigen", + "mode.currentSpeed": "Aktuelle Simulationsgeschwindigkeit", + "mode.design": "Wechselt in den Entwurfsmodus", + "mode.simulation": "Wechselt in den Aktionsmodus", + "mode.speed": "Simulationsgeschwindigkeit anpassen", + "network.dnsServer": "DNS-Server:", + "network.gateway": "Gateway:", + "network.ipAddress": "IP-Adresse:", + "network.macAddress": "MAC-Adresse:", + "network.subnetMask": "Netzmaske:", + "program.commandPrompt": "Befehlszeile", + "program.dnsServer": "DNS-Server", + "program.echoServer": "Echo-Server", + "program.emailClient": "E-Mail-Programm", + "program.emailServer": "E-Mail-Server", + "program.fileExplorer": "Datei-Explorer", + "program.firewall": "Firewall", + "program.gnutella": "Gnutella", + "program.imageViewer": "Bildbetrachter", + "program.simpleClient": "Einfacher Client", + "program.textEditor": "Text-Editor", + "program.webBrowser": "Webbrowser", + "program.webServer": "Webserver", "route.destinationNetwork": "Ziel", "route.gateway": "Nächstes Gateway", "route.interfaceIPAddress": "Über Schnittstelle", @@ -127,6 +153,7 @@ "sheet.switchSsid": "Name WLAN (SSID)", "sheet.title": "Einstellungen", "sheet.wireless": "Drahtlos (WLAN)", + "sim.failed": "Die Simulation ist auf einen Fehler gestoßen und wurde beendet.", "zoom.fit": "Alles zeigen", "zoom.in": "Vergrößern", "zoom.level": "{percent} %", diff --git a/packages/editor/src/i18n/en.json b/packages/editor/src/i18n/en.json index 1079ff7..b07fb24 100644 --- a/packages/editor/src/i18n/en.json +++ b/packages/editor/src/i18n/en.json @@ -22,6 +22,9 @@ "design.palette": "Components", "design.redo": "Redo", "design.undo": "Undo", + "desktop.home": "Home Screen", + "desktop.installation": "Software Installation", + "desktop.network": "Network", "dev.example": "Example", "dev.newProject": "New project", "dev.openFile": "Open file …", @@ -79,6 +82,29 @@ "menu.delete": "Delete", "menu.removeAllCables": "Remove all cables", "menu.removeCable": "Remove cable", + "menu.showDesktop": "Show desktop", + "mode.currentSpeed": "Current Simulation Velocity", + "mode.design": "Change to Design Mode", + "mode.simulation": "Change to Simulation Mode", + "mode.speed": "Adjust Simulation Velocity", + "network.dnsServer": "DNS server:", + "network.gateway": "Gateway:", + "network.ipAddress": "IP address:", + "network.macAddress": "MAC address:", + "network.subnetMask": "Netmask:", + "program.commandPrompt": "Command Line", + "program.dnsServer": "DNS server", + "program.echoServer": "Echo server", + "program.emailClient": "Email program", + "program.emailServer": "Email server", + "program.fileExplorer": "File explorer", + "program.firewall": "Firewall", + "program.gnutella": "Gnutella", + "program.imageViewer": "Image viewer", + "program.simpleClient": "Generic client", + "program.textEditor": "Text editor", + "program.webBrowser": "Webbrowser", + "program.webServer": "Webserver", "route.destinationNetwork": "Destination", "route.gateway": "Next gateway", "route.interfaceIPAddress": "NIC", @@ -127,6 +153,7 @@ "sheet.switchSsid": "Wi-Fi Name (SSID)", "sheet.title": "Settings", "sheet.wireless": "Wireless (Wi-Fi)", + "sim.failed": "The simulation ran into an error and was stopped.", "zoom.fit": "Show all", "zoom.in": "Zoom in", "zoom.level": "{percent} %", diff --git a/packages/editor/src/i18n/i18n.tsx b/packages/editor/src/i18n/i18n.tsx index 2342eca..ccdd99d 100644 --- a/packages/editor/src/i18n/i18n.tsx +++ b/packages/editor/src/i18n/i18n.tsx @@ -24,6 +24,10 @@ export function LanguageProvider({ language, children }: { readonly language: La return {children}; } +export function useLanguage(): Language { + return useContext(LanguageContext); +} + export function useTranslate(): Translate { const language = useContext(LanguageContext); return (key, values) => translate(language, key, values); diff --git a/packages/editor/src/main.tsx b/packages/editor/src/main.tsx index 02ecf65..b799a78 100644 --- a/packages/editor/src/main.tsx +++ b/packages/editor/src/main.tsx @@ -7,7 +7,7 @@ import { StrictMode, useEffect, useState } from 'react'; import { createRoot } from 'react-dom/client'; import * as Y from 'yjs'; -import { deviceSheet, emptyProject, topologyView, writeProject } from '@mdg-filius/doc'; +import { desktopView, deviceSheet, emptyProject, topologyView, writeProject } from '@mdg-filius/doc'; import { openProjectArchive } from '@mdg-filius/fls'; import { startBridge } from './bridge/dashboardBridge.js'; import { Editor } from './Editor.js'; @@ -62,7 +62,7 @@ function App() { // The development page lets its browser tests read the document's drawing (never the editor embedded in the dashboard). useEffect(() => { - (window as unknown as { filiusDev?: unknown }).filiusDev = doc === null ? undefined : { view: () => topologyView(doc), sheet: (id: string) => deviceSheet(doc, id) }; + (window as unknown as { filiusDev?: unknown }).filiusDev = doc === null ? undefined : { view: () => topologyView(doc), sheet: (id: string) => deviceSheet(doc, id), desktop: (id: string) => desktopView(doc, id) }; }, [doc]); const t = (key: string) => translate(language, key); diff --git a/packages/editor/src/simulation/ModeBar.tsx b/packages/editor/src/simulation/ModeBar.tsx new file mode 100644 index 0000000..198a829 --- /dev/null +++ b/packages/editor/src/simulation/ModeBar.tsx @@ -0,0 +1,44 @@ +// Java's mode buttons (GUIMainMenu: design and simulation mode, with Java's +// icons) and, while the simulation runs, its speed slider with the per cent +// beside it. Java's keys for the modes (Ctrl+D, Ctrl+R) are not offered: in +// a browser they bookmark and reload the page. + +import type { CSSProperties } from 'react'; +import type { SimMode } from '@mdg-filius/protocol'; +import simulation from '../assets/modes/aktionsmodus.png'; +import simulationOn from '../assets/modes/aktionsmodus_aktiv.png'; +import design from '../assets/modes/entwurfsmodus.png'; +import designOn from '../assets/modes/entwurfsmodus_aktiv.png'; +import { useTranslate } from '../i18n/i18n.js'; + +const modeButton: CSSProperties = { + width: 52, + height: 52, + padding: 0, + border: '1px solid var(--border-default)', + borderRadius: 8, + background: 'var(--surface-raised)', + cursor: 'pointer', +}; + +export function ModeBar({ mode, speed, onMode, onSpeed }: { readonly mode: SimMode; readonly speed: number; readonly onMode: (mode: SimMode) => void; readonly onSpeed: (value: number) => void }) { + const t = useTranslate(); + return ( + <> + + + {mode === 'simulation' ? ( + + ) : null} + + ); +} diff --git a/packages/editor/src/simulation/useSimulation.ts b/packages/editor/src/simulation/useSimulation.ts new file mode 100644 index 0000000..daa2375 --- /dev/null +++ b/packages/editor/src/simulation/useSimulation.ts @@ -0,0 +1,105 @@ +// The editor's side of the simulation room (plan §6): its mode and speed, +// the cables that carry frames, a computer's network as it runs. The room +// runs behind a SimHost — in a worker of this page (LocalSimHost) until F +// holds it (RemoteSimHost, F5); the editor keeps no simulation of its own. + +import { useCallback, useEffect, useRef, useState } from 'react'; +import type * as Y from 'yjs'; +import type { NodeSettings, SimHost, SimMode, Topic } from '@mdg-filius/protocol'; +import { LocalSimHost } from '@mdg-filius/sim'; +import SimWorker from '@mdg-filius/sim/worker?worker'; + +/** Makes the room of a document. */ +export type SimHostFactory = (doc: Y.Doc) => SimHost; + +/** The room in a worker of this page. */ +export const localSimHost: SimHostFactory = (doc) => { + const worker = new SimWorker(); + return new LocalSimHost(doc, worker, () => worker.terminate()); +}; + +export interface Simulation { + readonly mode: SimMode; + /** Java's slider, 1 to 100 per cent. */ + readonly speed: number; + /** The cables that carried a frame at the last tick. */ + readonly activeLinks: ReadonlySet; + /** Each watched computer's network as the simulation runs it. */ + readonly nodes: ReadonlyMap; + /** Whether the core failed and the room went back to design mode, since the last start. */ + readonly failed: boolean; + start(): void; + stop(): void; + setSpeed(value: number): void; + /** Watches these topics until the returned function is called; a topic two parts watch is sent for once. */ + watch(topics: readonly Topic[]): () => void; +} + +const NO_LINKS: ReadonlySet = new Set(); + +export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimHost): Simulation { + const host = useRef(null); + const watched = useRef(new Map()); + const [mode, setMode] = useState('design'); + const [speed, setSpeedState] = useState(100); + const [activeLinks, setActiveLinks] = useState>(NO_LINKS); + const [nodes, setNodes] = useState>(new Map()); + const [failed, setFailed] = useState(false); + + useEffect(() => { + const made = createHost(doc); + host.current = made; + const stop = made.onMessage((message) => { + switch (message.kind) { + case 'state': + setMode(message.mode); + setSpeedState(message.speed); + if (message.mode === 'design') setActiveLinks(NO_LINKS); + return; + case 'activity': + setActiveLinks(new Set(message.links)); + return; + case 'node': + setNodes((before) => new Map(before).set(message.node, message.settings)); + return; + case 'error': + if (message.code === 'engineFailed') setFailed(true); + return; + default: + return; + } + }); + made.send({ v: 1, kind: 'sub', topics: ['activity', ...watched.current.keys()] }); + return () => { + stop(); + made.close(); + host.current = null; + }; + }, [doc, createHost]); + + const start = useCallback(() => { + setFailed(false); + host.current?.send({ v: 1, kind: 'start' }); + }, []); + const stop = useCallback(() => host.current?.send({ v: 1, kind: 'stop' }), []); + const setSpeed = useCallback((value: number) => host.current?.send({ v: 1, kind: 'speed', value }), []); + const watch = useCallback((topics: readonly Topic[]) => { + const fresh = topics.filter((topic) => { + const count = watched.current.get(topic) ?? 0; + watched.current.set(topic, count + 1); + return count === 0; + }); + if (fresh.length > 0) host.current?.send({ v: 1, kind: 'sub', topics: fresh }); + return () => { + const gone = topics.filter((topic) => { + const count = (watched.current.get(topic) ?? 1) - 1; + if (count > 0) watched.current.set(topic, count); + else watched.current.delete(topic); + return count <= 0; + }); + if (gone.length > 0) host.current?.send({ v: 1, kind: 'unsub', topics: gone }); + }; + }, []); + + return { mode, speed, activeLinks, nodes, failed, start, stop, setSpeed, watch }; +} diff --git a/packages/editor/test/i18n.test.ts b/packages/editor/test/i18n.test.ts index d042fda..af976bf 100644 --- a/packages/editor/test/i18n.test.ts +++ b/packages/editor/test/i18n.test.ts @@ -109,6 +109,16 @@ const FROM_JAVA: Readonly> = { 'connections.local': 'jvermittlungsrechnerkonfiguration_msg22', 'connections.add': 'jvermittlungsrechnerkonfiguration_msg24', 'connections.most': 'jvermittlungsrechnerkonfiguration_msg25', + 'mode.speed': 'guimainmemu_msg16', + 'mode.currentSpeed': 'guimainmemu_msg15', + 'menu.showDesktop': 'guievents_msg3', + 'desktop.home': 'desktoppanel_application', + 'desktop.installation': 'desktoppanel_msg1', + 'network.ipAddress': 'network_msg1', + 'network.subnetMask': 'network_msg4', + 'network.dnsServer': 'network_msg2', + 'network.gateway': 'network_msg3', + 'network.macAddress': 'network_msg9', }; /** Java's words, mended: the prompt says "Vermittlungsrechnerns" (guicontainer_msg1). */ @@ -128,6 +138,9 @@ const FROM_JAVA_MENDED: Readonly java.replaceAll('\\n', '\n').replace('\nüber', '\nÜber')], 'firewall.filterSynHint': ['jfirewalldialog_msg43', (java) => java.replaceAll('\\n', '\n')], + // Without Java's key (Strg+D, Strg+R): in a browser they bookmark and reload the page. + 'mode.design': ['guimainmemu_msg3', (java) => java.replace(/ \((Strg|Ctrl)\+D\)$/, '')], + 'mode.simulation': ['guimainmemu_msg4', (java) => java.replace(/ \((Strg|Ctrl)\+R\)$/, '')], // Java's lines without their leading space and the empty one; a step starts with a capital letter. 'connections.hint': [ 'jvermittlungsrechnerkonfiguration_msg19', diff --git a/packages/editor/test/programs.test.ts b/packages/editor/test/programs.test.ts new file mode 100644 index 0000000..7aee843 --- /dev/null +++ b/packages/editor/test/programs.test.ts @@ -0,0 +1,34 @@ +// The desktop's programs against Java's lists (config/Desktop_.txt): +// the same programs, in each language's order, with Java's names and icons. + +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { desktopPrograms, DESKTOP_ORDER, JAVA_PROGRAMS } from '../src/desktop/programs.js'; +import { CATALOGS, type Language } from '../src/i18n/i18n.js'; + +const config = join(dirname(fileURLToPath(import.meta.url)), '../../../tools/java-oracle/.upstream/src/main/resources/config'); +const FILES: Readonly> = { de: 'Desktop_de_DE.txt', en: 'Desktop_en_GB.txt' }; + +describe("the desktop's programs", () => { + it.skipIf(!existsSync(config))("are Java's, in the order of each language's list, with Java's names and icons", () => { + for (const language of ['de', 'en'] as const) { + const lines = readFileSync(join(config, FILES[language]), 'utf8') + .split(/\r?\n/) + .filter((line) => line.trim() !== '' && !line.trim().startsWith('#')) + .map((line) => line.split(';')); + expect(DESKTOP_ORDER[language].map((program) => JAVA_PROGRAMS[program].javaClass)).toEqual(lines.map((line) => line[1])); + expect(DESKTOP_ORDER[language].map((program) => CATALOGS[language][`program.${program}`])).toEqual(lines.map((line) => line[0])); + expect(DESKTOP_ORDER[language].map((program) => `gfx/desktop/${JAVA_PROGRAMS[program].iconFile}`)).toEqual(lines.map((line) => line[3]!.trim())); + } + }); + + it("shows a computer's programs only, in the language's order, without the DHCP server", () => { + expect(desktopPrograms(['webBrowser', 'commandPrompt', 'dhcpServer', 'imageViewer'], 'de')).toEqual(['commandPrompt', 'imageViewer', 'webBrowser']); + expect(desktopPrograms(['webBrowser', 'commandPrompt', 'dhcpServer', 'imageViewer'], 'en')).toEqual(['commandPrompt', 'imageViewer', 'webBrowser']); + expect(desktopPrograms(['webServer', 'webBrowser'], 'de')).toEqual(['webServer', 'webBrowser']); + expect(desktopPrograms(['webServer', 'webBrowser'], 'en')).toEqual(['webBrowser', 'webServer']); + expect(desktopPrograms([], 'de')).toEqual([]); + }); +}); diff --git a/packages/protocol/src/sim.ts b/packages/protocol/src/sim.ts index 66094ea..e86228b 100644 --- a/packages/protocol/src/sim.ts +++ b/packages/protocol/src/sim.ts @@ -15,9 +15,10 @@ export type AppKind = (typeof APP_KINDS)[number]; /** * What a client subscribes to: which cables carry frames (`activity`), a * card's capture (`trace:`), a program's window - * (`app:/`). + * (`app:/`), a computer's network settings as it runs with them + * (`node:`: its desktop's title and network info). */ -export type Topic = 'activity' | `trace:${string}` | `app:${string}/${AppKind}`; +export type Topic = 'activity' | `trace:${string}` | `app:${string}/${AppKind}` | `node:${string}`; export const CLIENT_MESSAGE_KINDS = ['sub', 'unsub', 'start', 'stop', 'speed', 'input'] as const; export type ClientMessageKind = (typeof CLIENT_MESSAGE_KINDS)[number]; @@ -41,7 +42,7 @@ export type ClientMessage = readonly reqId: string; }; -export const HOST_MESSAGE_KINDS = ['state', 'activity', 'trace', 'app', 'ack', 'error'] as const; +export const HOST_MESSAGE_KINDS = ['state', 'activity', 'trace', 'app', 'node', 'ack', 'error'] as const; export type HostMessageKind = (typeof HOST_MESSAGE_KINDS)[number]; export type SimMode = 'design' | 'simulation'; @@ -66,9 +67,23 @@ export type HostMessage = | { readonly v: 1; readonly kind: 'trace'; readonly portId: string; readonly frames: readonly TraceFrame[]; readonly reset: boolean } /** A program's new window lines from line `from` on; on subscribing, all (from 0). */ | { readonly v: 1; readonly kind: 'app'; readonly node: string; readonly app: AppKind; readonly from: number; readonly lines: readonly unknown[] } + /** + * A computer's first card as the simulation runs it (DHCP may change it): + * on subscribing, when it changes, and none while the simulation never ran. + */ + | { readonly v: 1; readonly kind: 'node'; readonly node: string; readonly settings: NodeSettings } | { readonly v: 1; readonly kind: 'ack'; readonly reqId: string } | { readonly v: 1; readonly kind: 'error'; readonly code: ErrorCode; readonly reqId?: string; readonly detail?: string }; +/** What Java's desktop shows of a computer's network (NetworkInfoPanel, the window's title). */ +export interface NodeSettings { + readonly ipAddress: string; + readonly subnetMask: string; + readonly defaultGateway: string; + readonly dnsServer: string; + readonly macAddress: string; +} + export type Parsed = { readonly message: T } | { readonly error: { readonly code: ErrorCode; readonly detail: string } }; const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); @@ -79,6 +94,7 @@ export function isTopic(value: unknown): value is Topic { if (value === 'activity') return true; if (typeof value !== 'string') return false; if (value.startsWith('trace:')) return value.length > 'trace:'.length; + if (value.startsWith('node:')) return /^node:[^/]+$/.test(value); const app = /^app:([^/]+)\/(.+)$/.exec(value); return app !== null && (APP_KINDS as readonly string[]).includes(app[2]!); } diff --git a/packages/protocol/test/sim.test.ts b/packages/protocol/test/sim.test.ts index 8d6bb3d..ea2e2a7 100644 --- a/packages/protocol/test/sim.test.ts +++ b/packages/protocol/test/sim.test.ts @@ -35,15 +35,15 @@ describe("a client's messages", () => { }); it('knows its topics', () => { - expect(['activity', 'trace:x', 'app:n/gnutella', 'app:n/webBrowser'].every(isTopic)).toBe(true); - expect(['', 'trace:', 'app:/terminal', 'app:n/', 'app:n/terminal/x', 'node:n', 7].some(isTopic)).toBe(false); + expect(['activity', 'trace:x', 'app:n/gnutella', 'app:n/webBrowser', 'node:n'].every(isTopic)).toBe(true); + expect(['', 'trace:', 'app:/terminal', 'app:n/', 'app:n/terminal/x', 'node:', 'node:n/x', 'nodes:n', 7].some(isTopic)).toBe(false); }); }); describe("a host's messages", () => { it('reads each kind, and names an unknown one', () => { for (const kind of HOST_MESSAGE_KINDS) expect('message' in parseHostMessage({ v: 1, kind })).toBe(true); - expect(parseHostMessage({ v: 1, kind: 'node' })).toEqual({ error: { code: 'unknownKind', detail: 'kind node' } }); + expect(parseHostMessage({ v: 1, kind: 'nodes' })).toEqual({ error: { code: 'unknownKind', detail: 'kind nodes' } }); expect(parseHostMessage({ v: 0, kind: 'state' })).toEqual({ error: { code: 'badVersion', detail: 'version 0' } }); }); }); diff --git a/packages/sim/src/room.ts b/packages/sim/src/room.ts index 5d12e03..4f67dc3 100644 --- a/packages/sim/src/room.ts +++ b/packages/sim/src/room.ts @@ -16,7 +16,7 @@ // keep rising (plan §6.2). import * as Y from 'yjs'; -import { DEFAULT_SPEED_PERCENT, NetworkRuntimeEngine, captureRows, type CapturedFrame, type CaptureRow, type Id, type LogLine } from '@mdg-filius/core'; +import { DEFAULT_SPEED_PERCENT, NetworkRuntimeEngine, captureRows, interfaceKey, stableGeneratedMacAddress, type CapturedFrame, type CaptureRow, type Id, type LogLine } from '@mdg-filius/core'; import { docSnapshot } from '@mdg-filius/doc'; import { parseClientMessage, @@ -25,6 +25,7 @@ import { type ClientMessage, type ErrorCode, type HostMessage, + type NodeSettings, type SimMode, type Topic, type TraceFrame, @@ -47,6 +48,8 @@ interface Client { readonly traceSent: Map; /** Per program window, how many of its program's lines it has; none while the program does not run (a stop empties it). */ readonly appSent: Map; + /** Per computer, the settings last sent (as text). */ + readonly nodeSent: Map; } /** A program with a window: its lines. */ @@ -79,7 +82,7 @@ export class SimulationRoom { /** A new client; it gets the room's state at once. */ connect(send: (message: HostMessage) => void): number { const id = this.nextClient++; - this.clients.set(id, { send, topics: new Set(), traceSent: new Map(), appSent: new Map() }); + this.clients.set(id, { send, topics: new Set(), traceSent: new Map(), appSent: new Map(), nodeSent: new Map() }); send(this.state()); return id; } @@ -117,6 +120,7 @@ export class SimulationRoom { client.topics.add(topic); if (topic.startsWith('trace:')) client.traceSent.delete(topic.slice('trace:'.length)); if (topic.startsWith('app:')) client.appSent.delete(topic); + if (topic.startsWith('node:')) client.nodeSent.delete(topic.slice('node:'.length)); } this.flush([client], true); return; @@ -260,6 +264,14 @@ export class SimulationRoom { for (const topic of client.topics) { if (topic === 'activity') { if (subscribing) client.send(message({ kind: 'activity', links: this.lastActivity })); + } else if (topic.startsWith('node:')) { + const node = topic.slice('node:'.length); + const settings = this.nodeSettings(node); + if (settings === undefined) continue; + const text = JSON.stringify(settings); + if (client.nodeSent.get(node) === text) continue; + client.nodeSent.set(node, text); + client.send(message({ kind: 'node', node, settings })); } else if (topic.startsWith('trace:')) { const portId = topic.slice('trace:'.length); const reset = !client.traceSent.has(portId); @@ -288,6 +300,26 @@ export class SimulationRoom { } } + /** + * A device's first card as the engine runs it (a computer's settings, which + * DHCP may change); undefined for a device the simulation never had. + */ + private nodeSettings(id: Id): NodeSettings | undefined { + const snapshot = this.engine.state.topologySnapshot; + const node = snapshot.nodes.find((each) => each.id === id); + if (node === undefined) return undefined; + const port = node.ports[0]; + const device = snapshot.deviceConfigurations.get(id); + const card = port === undefined ? undefined : snapshot.interfaceConfigurations.get(interfaceKey(id, port.id)); + return { + ipAddress: device?.ipAddress ?? card?.ipAddress ?? '', + subnetMask: device?.subnetMask ?? card?.subnetMask ?? '', + defaultGateway: device?.defaultGateway ?? '', + dnsServer: device?.dnsServer ?? '', + macAddress: port === undefined ? '' : (port.macAddress ?? stableGeneratedMacAddress(port.id)), + }; + } + /** The cables whose cards got frames since the last tick; sent when that changes. */ private updateActivity(): void { const links = new Set(); diff --git a/packages/sim/test/localSimHost.test.ts b/packages/sim/test/localSimHost.test.ts index 76b4129..d0559a9 100644 --- a/packages/sim/test/localSimHost.test.ts +++ b/packages/sim/test/localSimHost.test.ts @@ -117,8 +117,8 @@ describe('the simulation in a worker of the page', () => { const host = new LocalSimHost(doc, channel.port1 as unknown as Port, () => channel.port1.close()); const got: HostMessage[] = []; host.onMessage((message) => got.push(message)); - channel.port2.postMessage({ type: 'host', message: { v: 1, kind: 'node', id: 'x' } }); - expect(await until(() => got[0])).toEqual({ v: 1, kind: 'error', code: 'unknownKind', detail: 'kind node' }); + channel.port2.postMessage({ type: 'host', message: { v: 1, kind: 'nodes', id: 'x' } }); + expect(await until(() => got[0])).toEqual({ v: 1, kind: 'error', code: 'unknownKind', detail: 'kind nodes' }); host.close(); channel.port2.close(); }); diff --git a/packages/sim/test/room.test.ts b/packages/sim/test/room.test.ts index 61cd5ff..7ac5fe8 100644 --- a/packages/sim/test/room.test.ts +++ b/packages/sim/test/room.test.ts @@ -306,4 +306,28 @@ describe('a simulation room', () => { expect(ofKind(got, 'error')).toHaveLength(1); expect(ofKind(got, 'state').at(-1)).toMatchObject({ mode: 'simulation' }); }); + + it("sends a computer's network as it runs, anew when DHCP changes it, and nothing before a start", () => { + const { got, send, run, node } = setUp(exampleDoc('java-examples/einfaches_rechnernetz_komplett.fls')); + const notebook = node('Notebook 1'); + const server = node('Server'); + send({ kind: 'sub', topics: [`node:${notebook.id}`, `node:${server.id}`] }); + expect(ofKind(got, 'node')).toEqual([]); + send({ kind: 'start' }); + run(20_000); + const sent = ofKind(got, 'node').filter((each) => each.node === notebook.id); + expect(sent.length).toBeGreaterThanOrEqual(2); + expect(sent.at(-1)!.settings).toMatchObject({ subnetMask: '255.255.255.0', dnsServer: '192.168.0.100', macAddress: expect.stringMatching(/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/) }); + expect(sent.at(-1)!.settings.ipAddress).toMatch(/^192\.168\.0\.\d+$/); + // Sent when it changes, not every tick. + expect(new Set(sent.map((each) => JSON.stringify(each.settings))).size).toBe(sent.length); + expect(ofKind(got, 'node').filter((each) => each.node === server.id).map((each) => each.settings)).toEqual([ + { ipAddress: '192.168.0.100', subnetMask: '255.255.255.0', defaultGateway: '', dnsServer: '192.168.0.100', macAddress: expect.any(String) }, + ]); + // A client that subscribes again gets it again. + const before = got.length; + send({ kind: 'sub', topics: [`node:${server.id}`] }); + expect(ofKind(got.slice(before), 'node')).toHaveLength(1); + }); }); +