diff --git a/CHANGES.md b/CHANGES.md index 0409627..7eed526 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -96,6 +96,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/projectSnapshot.ts` | Swift `TopologyNetworkRuntimeTopologySnapshot(editorState:)` | neu (Abnahme F2): das importierte Projekt als Schnappschuss des Kerns; nur die Kabel verbinden (Swifts Verbindung über die SSID nicht übernommen), nur eingeschaltete DNS-Server laufen; seit F3.1 jeder installierte Echo-Server (ein oder aus, er lässt sich zur Laufzeit schalten) und einfache Client; seit F3.2a jeder Webserver und Browser und die Dateisysteme; seit F3.2c der Rückweg `projectWithSimulationChanges`: die Firewalls aus dem Schnappschuss des Laufs ins Projekt, wie Java sie beim Speichern hat; seit F3.3a die installierten E-Mail-Server (ein oder aus) und auf dem Rückweg ihre Postfächer; seit F3.3b die installierten E-Mail-Programme und auf dem Rückweg ihre Ordner; seit F3.4a die Knoten mit der Befehlszeile (ihre Dateien sind die des Projekts) | | `test/examplesJava.test.ts`, `test/examples/*.java.json` | — | neu (Abnahme F2): fünf Beispiele von Filius (`internetwork_komplett`, `einfaches_rechnernetz_komplett`, `dns_server`, `nat_with_mapped_port`, `Internet_Komplett_…_DE`) mit dem eigenen Leser eingelesen und im Kern gespielt, gegen Java-Filius (`ExampleRun`): je Karte Frames, Zeiten und Tabellenzeilen, die Aufrufe, die Adressen am Ende; gleichzeitig startende DHCP-Clients je Client; zwei benannte Java-Fehler (`JAVA_BUGS`: Ping ohne Gateway wirft eine `NullPointerException`; NAT-Eintrag nur für die erste Gegenstelle) | | `test/classicsJava.test.ts`, `test/exampleRun.ts`, `test/examples/{web,email,gnutella}.java.json` | — | neu (Abnahme F3): die Unterrichtsklassiker auf Beispielen von Filius gegen Java-Filius (`ExampleRun`): Webseite über ihren DNS-Namen (`internetwork_komplett`), E-Mail zwischen zwei Netzen durch NAT und Weitergabe von Server zu Server (`Internet_Komplett_…_DE`), Datei per Gnutella über einen Router finden und holen, Ping, Pong und Query weitergereicht (`zwei_rechnernetze_komplett`, Programme vom Test eingesetzt); je Karte Frames, Zeiten und Tabellenzeilen (ohne RIP), die Zeilen jedes Programms, Mails, Teilnehmer, Ergebnisse und Dateien am Ende; Javas Fehler aus F3.6b und `closedPort` benannt (`JAVA_BUGS`); gleich lange RIP-Wege nach Javas Wahl (`equalRipTies`). `exampleRun.ts`: was beide Beispiel-Tests teilen (Datei öffnen, Javas Namen, Karten nach MAC) | +| `src/importProject.ts` (`modemPair`, `modemPort`) | — | zusammengelegt (F4.5a): das Modem-Paar eines Ports an einer Stelle, für Import, Export und das Geräte-Blatt | | `src/topology.ts`, `src/importProject.ts`, `src/exportProject.ts`, `src/topologyWriter.ts` (Wolke) | Java `Switch.cloud`, `JSwitchKonfiguration` | nachgetragen (F4.3): ein Switch, als Wolke gezeichnet, wird gelesen, im Modell gehalten (`SwitchConfiguration.showsAsCloud`) und wieder geschrieben — vorher ging die Eigenschaft beim Lesen verloren (`nat.fls`, `man-in-the-middle.fls`); `FlsOracle` schreibt sie, Korpus und Schreib-/Exportproben neu geprüft | | `src/topologyWriter.ts` | Java `SzenarioVerwaltung.netzwerkSpeichern`, Konstruktoren von `NetzwerkInterface`, `Port`, `GUIKabelItem` und der Systemsoftware | neu nach Java: `konfiguration.xml` aus Javas Sicht — Geräte, Schnittstellen (vom Konstruktor angelegte über Getter, weitere per `add`), Ports über Getter-Verweise, Kabel samt Zeichen-Enden (`kabelpanel`), Doku-Elemente; Systemsoftware über ihre Setter: SSID, Lernzeit, RIP, manuelle Routen, DHCP-Client und -Server, statische NAT, Modem, Router-Firewall (über das `konfig`-Plug-in des Webservers, wie XMLEncoder sie erreicht); eigene Anwendungen eines Projekts, wie die Datei sie hatte; Dateisysteme (an die Wurzel des Konstruktors angehängt wie bei XMLEncoder, beim Router hinter dessen Vorlagen); installierte Programme nach den Dateien (Webserver, Tauschbörse und E-Mail-Server legen ihre Ordner nur an, wo sie fehlen; der DNS-Server liest `/dns/hosts` beim Installieren), je unter ihrem Schlüssel mit der Systemsoftware wie `installApp`, samt Einstellungen und persönlicher Firewall; E-Mail: Domain und Bean-Konten des Servers samt Postfächern, Ordner und Adressbuch des Clients (sein Konto steht in `/konten.txt`, einer Datei); Javas Fehler nicht übernommen: ein Empfänger, den eine ältere Datei als Zeichenkette hält, wird als der `AddressEntry` geschrieben, den Javas `AddressEntry(String)` daraus macht (Java legt die Zeichenkette ungeprüft in die typisierte Liste, sein E-Mail-Fenster scheitert daran), ein `null`-Empfänger fällt weg; Kopf „Filius version: 2.14.0 (mdg-filius)“; seit der Abnahme F2 `ipForwardingEnabled`, wo es vom Standard der Klasse abweicht | | `test/topologyWriter.test.ts`, `test/written.java.json` | — | neu: alle 23 Korpus-Dateien gelesen und neu geschrieben, von Java geladen — Topologie, Systemsoftware, Dateisysteme und Programme samt E-Mail wie beim Original (bis auf die benannte Abweichung bei Empfängern als Zeichenkette, nur in `man-in-the-middle.fls`) (Java gegen Java); dazu eine künstliche Topologie mit dem, was der Korpus nicht hat (u. a. eingeschaltete Router-Firewall mit Regeln jedes Protokolls, alle Firewall-Schalter abseits der Vorgabe, Modem mit eigenem Port und Ziel, verschachtelter Dateibaum mit umbenannter Wurzel, namenlosem Ordner, Datei ohne Typ und Inhalt mit CR/CRLF und Zeichen jenseits der BMP, Heimrouter mit eigener Wurzel, jedes Filius-Programm mit Einstellungen abseits der Vorgabe samt persönlicher Firewall, von Java angelegte Ordner `/peer2peer` und `/mailserver`, E-Mail-Server und -Client mit Konto, Postfach, drei Ordnern, Adressbuch und Empfängern aller Formen) | @@ -120,6 +121,10 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/snapshot.ts` | — | neu (F4.1): Schnappschuss des Kerns und `.fls`-Export eines Dokuments, beide über das Projekt (`projectSnapshot`, `saveProjectArchive`) | | `src/view.ts` | Java `Host.holeAnzeigeName`; `exportProject.joinsWirelessly` | neu (F4.3): die Zeichensicht ohne Dateisysteme — Geräte mit Javas Beschriftung und Wolke, Kabel mit Funk-Kennung, Doku-Elemente in der Zeichenfolge des Modells | | `src/commands.ts` | Java `GUIContainer.neuerKnoten`, Konstruktoren der Geräte, `GUIEvents` (Kabel), `InternetKnoten/LokalerKnoten.holeFreienPort`, `Gateway` | neu (F4.4a): neues Gerät mit Javas Voreinstellungen (gegen `NewDeviceRun` gemessen), Verschieben auf ganze Pixel, Löschen samt Kabeln, Kabel nach Javas Regeln (erster freier Port, Heimrouter WAN/LAN, Funk nur an Switch, nur einmal verbunden) mit Javas Meldungen als Gründe, Rückgängig je Person (`Y.UndoManager`, nur der eigene Ursprung) | +| `src/fields.ts` | Java `EingabenUeberpruefung` (`musterIpAdresse`, `musterSubNetz`, `musterServiceSetIdentifier`, `musterNurZahlen`, `musterPort`), `IP.ipCheck`, wie `JHostKonfiguration`, `JSwitchKonfiguration`, `JModemKonfiguration` sie nutzen | neu (F4.5a): die Regeln der Felder des Geräte-Blatts, gegen Javas Felder gemessen (`SheetRun`). Abweichend von Java: Markieren und Übernehmen sind eine Regel — Java übernimmt rot markierten Text („10..0.0.1“, „+10.0.0.1“, „-0.0.0.1“, arabisch-indische Ziffern, Haltezeit „-5“), verwirft schwarzen („.0.0.1“, eine Haltezeit jenseits von `long`) und nimmt als Modem-Port jede Zahl (0, 65536, -1); hier gilt Javas `musterPort`. Leerzeichen um Adressen und Zahlen fallen weg (Java verwirft „ 10.0.0.1“) | +| `src/settings.ts` | Java `JHostKonfiguration.aenderungenAnnehmen/aendereAnzeigeName/updateWifiConnection`, `JSwitchKonfiguration.aenderungenAnnehmen/changeAppearance`, `JModemKonfiguration` | neu (F4.5a): Name, Anzeige als Adresse/MAC, Adresse, Maske, Gateway, DNS, IP-Weiterleitung (nur abweichend von der Art gespeichert), DHCP (hält den eigenen DHCP-Server an), LAN/WLAN mit SSID samt Funkverbindung, SSID (wirft die Funkgeräte ab), Haltezeit und Wolke eines Switches, Port eines Modems (als Paar); je Änderung eine Transaktion mit Ursprung. Abweichend von Java: Der Name bleibt, wenn die MAC-Adresse angezeigt und ein anderes Feld übernommen wird (Java überschreibt ihn mit der MAC-Adresse); ein Rechner tritt dem ersten Switch mit der SSID bei (Javas Suche hängt von unbeteiligten Geräten ab); ein voller Zugangspunkt meldet sich mit Javas Meldung (Java baut ein Kabel ohne Port) | +| `src/sheet.ts` | Java `JHostKonfiguration.updateAttribute/updateSsid`, `JSwitchKonfiguration.updateAttribute`, `JModemKonfiguration.updateAttribute` | neu (F4.5a): was das Blatt zeigt — je Art die Felder des Java-Panels, die Netze der Switches je einmal, WLAN nur bei freiem Port oder schon drahtlos, Haltezeit in ganzen Sekunden | +| `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 | | `test/collaboration.test.ts`, `test/values.test.ts` | — | neu (F4.1): zwei Replikate — zwei Felder eines Geräts, zwei neue Zeilen, gemeinsames Tippen; der Hausmeister nach einem Zusammenführen (I1, I2 auf beiden Replikaten gleich, I4 auch in anderer Schreibweise), idempotent ohne Aktualisierung; `long` einer eigenen Anwendung, Binärdateien | @@ -156,6 +161,8 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/design/ContextMenu.tsx`, `src/design/RouterDialog.tsx` | Java `GUIEvents.kontextMenueEntwurfsmodus` (`guievents_msg5`–`7`), `GUIContainer.neuerKnoten` (`guicontainer_msg1`/`2`) | neu (F4.4b): Menü eines Geräts oder Kabels (Langdruck oder Dreipunkt-Knopf), Wahl der Schnittstellenzahl eines neuen Vermittlungsrechners (2 bis 8, ohne Wahl 2); Javas Tippfehler „Vermittlungsrechnerns“ ausgebessert | | `src/Editor.tsx`, `src/canvas/Canvas.tsx` (Werkzeuge) | Java `GUIEvents` (Kabel ziehen, Heimrouter-Hälften, Meldungen `guievents_msg1/12/22–26`) | neu (F4.4b): Auswählen, Gerät ziehen, Langdruck, Kabel ziehen oder zweimal antippen mit Gummiband und WAN/LAN-Hinweis, Javas Meldungen, Entf-Taste, Rückgängig/Wiederholen per Knopf und Strg+Z/Strg+Y; jede Änderung mit dem eigenen Ursprung des Editors | | `browser/design.spec.ts`, `browser/editorPage.ts` | — | neu (F4.4b): die Werkzeuge in Chromium mit Maus und Berührung (CDP) | +| `src/sheet/DeviceSheet.tsx`, `src/sheet/SheetField.tsx`, `src/sheet/sheet.css` | Java `JKonfiguration` und die Panels je Art (Reihenfolge und Wörter `jhostkonfiguration_msg1`–`16`, `jswitchkonfiguration_msg1`–`5`, `jmodemkonfiguration_msg4`) | neu (F4.5a): das Geräte-Blatt — öffnet mit dem gewählten Gerät wie Javas Panel, neben der Fläche (340 px) oder auf dem Handy darunter; ein Feld wird beim Verlassen oder mit Enter übernommen, abgelehnter Text bleibt rot stehen mit dem, was weiter gilt (Java setzt still den alten Wert zurück), Escape verwirft ihn; Adressfelder bei DHCP gesperrt; jede Änderung ein Schritt im Rückgängig. Vermittlungsrechner und Heimrouter vorerst nur mit Namen | +| `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 | ## tools/swift-oracle diff --git a/README.md b/README.md index 11fc473..8219fe6 100644 --- a/README.md +++ b/README.md @@ -27,10 +27,10 @@ die Herkunft je Paket. Lizenz dieses Repos: **GPL-3.0** (`LICENSE`). | --- | --- | | `packages/core` | Simulationskern: Modell, Ereignis-Warteschlange, Protokolle, Anwendungen — ohne DOM, ohne Node | | `packages/fls` | `.fls` lesen, schreiben, Unbekanntes bewahren | -| `packages/doc` | gemeinsames Dokument (Yjs): Projekt ⇄ Dokument, Schnappschuss des Kerns, Hausmeister | +| `packages/doc` | gemeinsames Dokument (Yjs): Projekt ⇄ Dokument, Schnappschuss des Kerns, Hausmeister, die Änderungen der Werkzeuge und des Geräte-Blatts (mit Javas Eingabeprüfung) | | `packages/protocol` | die eine Nachrichtenliste zwischen Editor und Simulationsraum (`/sim`) | | `packages/sim` | Simulationsraum (für Server F und den Web Worker) und `LocalSimHost` | -| `packages/editor` | Oberfläche im Iframe des Dashboards (React, Vite): bisher die Fläche; `npm run dev -w @mdg-filius/editor` öffnet die Entwicklungsseite | +| `packages/editor` | Oberfläche im Iframe des Dashboards (React, Vite): bisher Fläche, Palette, Werkzeuge des Entwurfs und Geräte-Blatt; `npm run dev -w @mdg-filius/editor` öffnet die Entwicklungsseite | | `server` | Filius-Dienst auf Server F *(folgt)* | Plan und Entscheidungen: `knowledge/plans/FILIUS_WEB_PLAN.md` im Dashboard. diff --git a/packages/doc/src/commands.ts b/packages/doc/src/commands.ts index 2152bde..e73142b 100644 --- a/packages/doc/src/commands.ts +++ b/packages/doc/src/commands.ts @@ -18,6 +18,7 @@ import * as Y from 'yjs'; import { + DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS, defaultSwitchConfiguration, firewallConfiguration, interfaceKey, @@ -26,7 +27,7 @@ import { type NodeKind, type TopologyNode, } from '@mdg-filius/core'; -import type { ImportedProject } from '@mdg-filius/fls'; +import { DEFAULT_MODEM_PORT, modemPair, type ImportedProject } from '@mdg-filius/fls'; import { emptyProject, inOrder, nodeEntry, ROOTS } from './projectDoc.js'; /** The kinds of the palette, in Java's order (GUIDesignSidebar.addComponentItemsToSidebar; the cable comes first, a tool). */ @@ -101,7 +102,7 @@ export function newDeviceProject(device: NewDevice): ImportedProject { project.firewallConfigurationsByNodeId.set(device.id, firewallConfiguration()); break; case 'remoteLink': - project.remoteLinkConfigurations.set(device.id, { pairIdentifier: 'java-modem-port-12345', latencyMilliseconds: 20, isEnabled: true }); + project.remoteLinkConfigurations.set(device.id, { pairIdentifier: modemPair(DEFAULT_MODEM_PORT), latencyMilliseconds: DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS, isEnabled: true }); break; } return project; diff --git a/packages/doc/src/fields.ts b/packages/doc/src/fields.ts new file mode 100644 index 0000000..67d1266 --- /dev/null +++ b/packages/doc/src/fields.ts @@ -0,0 +1,69 @@ +// The device sheet's rules for what may be typed into a field: Java's +// EingabenUeberpruefung as FILIUS' settings panels use it +// (JHostKonfiguration, JSwitchKonfiguration, JModemKonfiguration), measured +// with tools/java-oracle's SheetRun (test/sheet.java.json). +// +// Java judges a text twice: while it is typed, the panel colours it red when +// the pattern does not match; when the field loses the focus, the model's +// setter takes it or keeps the old value — and the two disagree. Java takes +// red text ("10..0.0.1", "+10.0.0.1", "-0.0.0.1", Arabic-Indic digits, a +// retention time of "-5"), refuses black text (".0.0.1", a retention time +// past Long's range), and a modem takes any int as its port (0, 65536, -1), +// unchecked. Here one rule decides both: a text is taken exactly when it is +// not marked. What is not taken is not thrown away either (Java puts the old +// value back into the field without a word): the sheet keeps it, marked, +// until it is mended (plan I6). +// +// Spaces around an address or a number are left out (Java refuses +// " 10.0.0.1"; a phone's keyboard puts a space after a word). A name and an +// SSID are taken as typed, spaces included, as Java takes them. + +import { IP_ADDRESS_PATTERN, javaIpCheck } from '@mdg-filius/core'; + +export type FieldRule = 'name' | 'ipAddress' | 'ipAddressOrEmpty' | 'subnetMask' | 'ssid' | 'seconds' | 'port'; + +/** What a field's text comes to: the value the device takes, or that it takes none. */ +export type CheckedField = { readonly value: string } | { readonly invalid: true }; + +/** EingabenUeberpruefung.musterSubNetz. */ +const SUBNET_MASK_PATTERN = /^(0*([1-9]|1?[0-9]{1,2}|2[0-4]?[0-9]|25[0-5]))\.((0*([0-9]|1?[0-9]{1,2}|2[0-4]?[0-9]|25[0-5]))\.){2}(0*([0-9]|1?[0-9]{1,2}|2[0-4]?[0-9]|25[0-5]))$/; +/** EingabenUeberpruefung.musterServiceSetIdentifier: printable ASCII, at least one character. */ +const SSID_PATTERN = /^[ -~]+$/; +/** EingabenUeberpruefung.musterPort: 1 to 65535, no leading zero. */ +const PORT_PATTERN = /^([1-9]|[1-9][0-9]{1,3}|[1-5][0-9]{1,4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$/; +/** EingabenUeberpruefung.musterNurZahlen, at least one digit (Java's Long.parseLong refuses none). */ +const DIGITS_PATTERN = /^[0-9]+$/; + +const INVALID: CheckedField = { invalid: true }; + +/** + * An address as the panel's pattern and the model's IP.ipCheck both accept + * it: neither alone (the pattern lets ".0.0.1" through, ipCheck "10..0.0.1" + * and "+10.0.0.1"); the device keeps ipCheck's form ("010.0.0.1" is 10.0.0.1). + */ +function address(text: string, pattern: RegExp): CheckedField { + const canonical = javaIpCheck(text); + return canonical !== null && pattern.test(text) ? { value: canonical } : INVALID; +} + +export function checkField(rule: FieldRule, text: string): CheckedField { + switch (rule) { + case 'name': + return { value: text }; + case 'ssid': + return SSID_PATTERN.test(text) ? { value: text } : INVALID; + case 'ipAddress': + return address(text.trim(), IP_ADDRESS_PATTERN); + case 'ipAddressOrEmpty': + return text.trim() === '' ? { value: '' } : address(text.trim(), IP_ADDRESS_PATTERN); + case 'subnetMask': + return address(text.trim(), SUBNET_MASK_PATTERN); + case 'seconds': { + const digits = text.trim(); + // In milliseconds the device's number must stay exact. + return DIGITS_PATTERN.test(digits) && Number(digits) * 1000 <= Number.MAX_SAFE_INTEGER ? { value: String(Number(digits)) } : INVALID; + } + case 'port': + return PORT_PATTERN.test(text.trim()) ? { value: text.trim() } : INVALID; + } +} diff --git a/packages/doc/src/index.ts b/packages/doc/src/index.ts index 9e7f619..bbf9d39 100644 --- a/packages/doc/src/index.ts +++ b/packages/doc/src/index.ts @@ -1,7 +1,10 @@ // Public surface of the shared document. export * from './commands.js'; +export * from './fields.js'; export * from './janitor.js'; export * from './projectDoc.js'; +export * from './settings.js'; +export * from './sheet.js'; export * from './snapshot.js'; export * from './values.js'; export * from './view.js'; diff --git a/packages/doc/src/settings.ts b/packages/doc/src/settings.ts new file mode 100644 index 0000000..7601ec9 --- /dev/null +++ b/packages/doc/src/settings.ts @@ -0,0 +1,198 @@ +// What the device sheet does to the document: a device's settings changed as +// FILIUS' settings panels change them (JHostKonfiguration, +// JSwitchKonfiguration, JModemKonfiguration; measured with SheetRun), each a +// transaction with the person's origin, like the design tools' commands. +// +// A text field's setter takes the text only when the field's rule does +// (fields.ts) and says so; the device keeps its value otherwise. A host's +// wireless card and a switch's SSID move the wireless connections as Java +// moves them: a host joins the first switch whose SSID it names, a switch +// that changes its SSID loses its wireless hosts. +// +// Not adopted from Java: a host that shows its MAC address as its name takes +// that address as its name as soon as another field is taken (SheetRun's +// flow useMacAsNameThenOff); here the name stays what it was. And Java's +// search for the access point (updateWifiConnection) keeps the last switch +// with the SSID it passes until a device that is neither comes after the +// host — which switch depends on unrelated devices; here it is the first. + +import * as Y from 'yjs'; +import { DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS, defaultSwitchConfiguration, forwardsIPv4ByDefault, type Id, type NodeKind } from '@mdg-filius/core'; +import { modemPair } from '@mdg-filius/fls'; +import { connect, type ConnectError } from './commands.js'; +import { checkField, type CheckedField } from './fields.js'; +import { inOrder, ROOTS } from './projectDoc.js'; +import { toY } from './values.js'; + +const nodes = (doc: Y.Doc) => doc.getMap>(ROOTS.nodes); +const links = (doc: Y.Doc) => doc.getMap>(ROOTS.links); +const settingsOf = (node: Y.Map) => node.get('settings') as Y.Map; +const kindOf = (node: Y.Map) => node.get('kind') as NodeKind; + +/** A setting of a device as a map of its fields, made with these fields when the device has none. */ +function settingMap(node: Y.Map, key: string, fields: Readonly>): Y.Map { + const settings = settingsOf(node); + let map = settings.get(key) as Y.Map | undefined; + if (map === undefined) { + map = toY(fields) as Y.Map; + settings.set(key, map); + } + return map; +} + +/** Changes a device in one transaction when the text passes the rule; says what it came to. */ +function setChecked(doc: Y.Doc, id: Id, checked: CheckedField, change: (node: Y.Map, value: string) => void, origin: unknown): CheckedField { + const node = nodes(doc).get(id); + if (node === undefined || 'invalid' in checked) return checked; + doc.transact(() => change(node, checked.value), origin); + return checked; +} + +export function renameDevice(doc: Y.Doc, id: Id, name: string, origin: unknown): void { + const node = nodes(doc).get(id); + if (node !== undefined) doc.transact(() => node.set('name', name), origin); +} + +/** What a PC or notebook shows as its name (Host.useIPAsName, useMACAsName): its address, its MAC address, both or its name. */ +export function setHostLabels(doc: Y.Doc, id: Id, labels: { readonly ipAddress: boolean; readonly macAddress: boolean }, origin: unknown): void { + const node = nodes(doc).get(id); + if (node === undefined) return; + const policy = labels.ipAddress && labels.macAddress ? 'ipAndMAC' : labels.ipAddress ? 'ipAddress' : labels.macAddress ? 'macAddress' : 'manual'; + doc.transact(() => node.set('labelPolicy', policy), origin); +} + +export type HostAddressField = 'ipAddress' | 'subnetMask' | 'defaultGateway' | 'dnsServer'; + +const ADDRESS_RULES = { ipAddress: 'ipAddress', subnetMask: 'subnetMask', defaultGateway: 'ipAddressOrEmpty', dnsServer: 'ipAddressOrEmpty' } as const; + +/** A PC's or notebook's address, mask, gateway or DNS server (Betriebssystem.setzeIPAdresse …). */ +export function setHostAddress(doc: Y.Doc, id: Id, field: HostAddressField, text: string, origin: unknown): CheckedField { + return setChecked( + doc, + id, + checkField(ADDRESS_RULES[field], text), + (node, value) => settingMap(node, 'device', { ipAddress: '192.168.0.10', subnetMask: '255.255.255.0', defaultGateway: '', dnsServer: '' }).set(field, value), + origin, + ); +} + +/** IP forwarding, kept only where it differs from the kind's (as the project keeps it). */ +export function setIpForwarding(doc: Y.Doc, id: Id, on: boolean, origin: unknown): void { + const node = nodes(doc).get(id); + if (node === undefined) return; + doc.transact(() => { + if (on === forwardsIPv4ByDefault(kindOf(node))) settingsOf(node).delete('ipForwarding'); + else settingsOf(node).set('ipForwarding', on); + }, origin); +} + +/** DHCP for the configuration; switched on, it stops the device's own DHCP server (JHostKonfiguration.aenderungenAnnehmen). */ +export function setDhcpClient(doc: Y.Doc, id: Id, on: boolean, origin: unknown): void { + const node = nodes(doc).get(id); + if (node === undefined) return; + doc.transact(() => { + if (!on) { + settingsOf(node).delete('dhcpClient'); + return; + } + settingsOf(node).set('dhcpClient', toY({ isEnabled: true })); + (settingsOf(node).get('dhcpServer') as Y.Map | undefined)?.set('isActive', false); + }, origin); +} + +const wirelessOf = (node: Y.Map) => (settingsOf(node).get('wireless') as Y.Map | undefined)?.get('isEnabled') === true; + +/** The cables of a device to a switch: a wireless host's wireless connections. */ +function linksToSwitches(doc: Y.Doc, id: Id): string[] { + return [...links(doc).entries()] + .filter(([, link]) => { + const ends = [link.get('sourceNode') as Id, link.get('targetNode') as Id]; + const other = ends[0] === id ? ends[1]! : ends[1] === id ? ends[0]! : undefined; + const node = other === undefined ? undefined : nodes(doc).get(other); + return node !== undefined && kindOf(node) === 'networkSwitch'; + }) + .map(([linkId]) => linkId); +} + +/** Why a host's card cannot become wireless (Java disables the choice): its cable is plugged. */ +export type WirelessError = 'cablePlugged' | ConnectError; + +/** + * A PC's or notebook's card by cable or wireless, and the SSID of the network + * it joins ('' for none). Java (JHostKonfiguration.updateWifiConnection): + * when either changes, the host leaves its wireless network, then joins the + * first switch with that SSID, when it is wireless and names one. A card + * whose cable is plugged cannot become wireless. The SSID stays when the + * card goes back to the cable, as Java keeps it. + */ +export function setHostWireless(doc: Y.Doc, id: Id, wireless: boolean, ssid: string, linkId: Id, origin: unknown): { readonly link?: Id } | { readonly error: WirelessError } { + const node = nodes(doc).get(id); + if (node === undefined) return { error: 'unknownDevice' }; + const before = settingsOf(node).get('wireless') as Y.Map | undefined; + const wasWireless = wirelessOf(node); + const plugged = [...links(doc).values()].some((link) => link.get('sourceNode') === id || link.get('targetNode') === id); + if (wireless && !wasWireless && plugged) return { error: 'cablePlugged' }; + if (wasWireless === wireless && ((before?.get('ssid') as string | undefined) ?? '') === ssid) return {}; + let result: { readonly link?: Id } | { readonly error: WirelessError } = {}; + doc.transact(() => { + if (wasWireless) for (const each of linksToSwitches(doc, id)) links(doc).delete(each); + if (!wireless && ssid === '') settingsOf(node).delete('wireless'); + else settingsOf(node).set('wireless', toY({ isEnabled: wireless, ssid })); + if (!wireless || ssid === '') return; + const accessPoint = inOrder(nodes(doc)).find(([, candidate]) => kindOf(candidate) === 'networkSwitch' && (settingsOf(candidate).get('switch') as Y.Map | undefined)?.get('ssid') === ssid); + if (accessPoint !== undefined) result = connect(doc, { node: id }, { node: accessPoint[0] }, linkId, origin); + }, origin); + return result; +} + +const SWITCH_DEFAULTS = (id: Id) => ({ ...defaultSwitchConfiguration(id) }); + +/** A switch's SSID; a new one drops its wireless hosts (JSwitchKonfiguration.aenderungenAnnehmen). */ +export function setSwitchSsid(doc: Y.Doc, id: Id, text: string, origin: unknown): CheckedField { + return setChecked( + doc, + id, + checkField('ssid', text), + (node, value) => { + const setting = settingMap(node, 'switch', SWITCH_DEFAULTS(id)); + if (setting.get('ssid') === value) return; + setting.set('ssid', value); + for (const linkId of [...links(doc).keys()]) { + const link = links(doc).get(linkId)!; + const ends = [link.get('sourceNode') as Id, link.get('targetNode') as Id]; + const host = ends[0] === id ? ends[1]! : ends[1] === id ? ends[0]! : undefined; + const other = host === undefined ? undefined : nodes(doc).get(host); + if (other !== undefined && (kindOf(other) === 'pc' || kindOf(other) === 'notebook') && wirelessOf(other)) links(doc).delete(linkId); + } + }, + origin, + ); +} + +/** How long a switch keeps a learned MAC address, in seconds as the panel asks for it. */ +export function setSwitchRetention(doc: Y.Doc, id: Id, text: string, origin: unknown): CheckedField { + return setChecked(doc, id, checkField('seconds', text), (node, value) => settingMap(node, 'switch', SWITCH_DEFAULTS(id)).set('retentionTimeMilliseconds', Number(value) * 1000), origin); +} + +/** A switch drawn as a cloud (Switch.cloud), kept only when true. */ +export function setSwitchCloud(doc: Y.Doc, id: Id, cloud: boolean, origin: unknown): void { + const node = nodes(doc).get(id); + if (node === undefined) return; + doc.transact(() => { + const setting = settingMap(node, 'switch', SWITCH_DEFAULTS(id)); + if (cloud) setting.set('showsAsCloud', true); + else setting.delete('showsAsCloud'); + }, origin); +} + +/** A modem's port: the pair it belongs to (two modems on one port are a pair, as the export writes them). */ +export function setModemPort(doc: Y.Doc, id: Id, text: string, origin: unknown): CheckedField { + return setChecked( + doc, + id, + checkField('port', text), + (node, value) => + settingMap(node, 'remoteLink', { pairIdentifier: '', latencyMilliseconds: DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS, isEnabled: true }).set('pairIdentifier', modemPair(Number(value))), + origin, + ); +} diff --git a/packages/doc/src/sheet.ts b/packages/doc/src/sheet.ts new file mode 100644 index 0000000..c6ca1eb --- /dev/null +++ b/packages/doc/src/sheet.ts @@ -0,0 +1,112 @@ +// What the device sheet shows of a device, read straight from the document: +// the fields of FILIUS' settings panel of its kind (JHostKonfiguration, +// JSwitchKonfiguration, JModemKonfiguration). A router's and a home router's +// sheet show their name only, until their own panels follow. + +import * as Y from 'yjs'; +import { defaultSwitchConfiguration, DEFAULT_SWITCH_RETENTION_MILLISECONDS, forwardsIPv4ByDefault, stableGeneratedMacAddress, type HostLabelPolicy, type NodeKind } from '@mdg-filius/core'; +import { modemPort } from '@mdg-filius/fls'; +import { inOrder, ROOTS } from './projectDoc.js'; + +export interface HostSheet { + readonly kind: 'pc' | 'notebook'; + readonly name: string; + /** Host.useIPAsName and useMACAsName. */ + readonly labels: { readonly ipAddress: boolean; readonly macAddress: boolean }; + /** The card's MAC address (the model keeps them in upper case, as Java shows them). */ + readonly macAddress: string; + readonly ipAddress: string; + readonly subnetMask: string; + readonly defaultGateway: string; + readonly dnsServer: string; + readonly dhcpClient: boolean; + readonly ipForwarding: boolean; + readonly wireless: boolean; + /** The SSID of the network the card joins, '' for none. */ + readonly ssid: string; + /** Java offers the wireless card only while the cable's port is free, or the card is wireless already. */ + readonly wirelessPossible: boolean; + /** The switches' SSIDs, in the order of the devices, each once: the networks to choose from. */ + readonly networks: readonly string[]; +} + +export interface SwitchSheet { + readonly kind: 'networkSwitch'; + readonly name: string; + readonly ssid: string; + readonly retentionSeconds: number; + readonly cloud: boolean; +} + +export interface ModemSheet { + readonly kind: 'remoteLink'; + readonly name: string; + readonly port: number; +} + +export interface NameSheet { + readonly kind: Exclude; + readonly name: string; +} + +export type DeviceSheet = HostSheet | SwitchSheet | ModemSheet | NameSheet; + +const settingsOf = (node: Y.Map) => node.get('settings') as Y.Map; +const setting = (node: Y.Map, key: string) => settingsOf(node).get(key) as Y.Map | undefined; + +/** The sheet of a device, or undefined when the document has no such device. */ +export function deviceSheet(doc: Y.Doc, id: string): DeviceSheet | undefined { + const all = doc.getMap>(ROOTS.nodes); + const node = all.get(id); + if (node === undefined) return undefined; + const kind = node.get('kind') as NodeKind; + const name = node.get('name') as string; + switch (kind) { + case 'pc': + case 'notebook': { + const device = setting(node, 'device'); + const wireless = setting(node, 'wireless'); + const port = (node.get('ports') as Y.Array>).get(0); + const portId = port?.get('id') as string | undefined; + const plugged = [...doc.getMap>(ROOTS.links).values()].some((link) => link.get('sourceNode') === id || link.get('targetNode') === id); + const policy = node.get('labelPolicy') as HostLabelPolicy; + const isWireless = wireless?.get('isEnabled') === true; + const networks = new Set(); + for (const [other, candidate] of inOrder(all)) { + if (candidate.get('kind') !== 'networkSwitch') continue; + networks.add((setting(candidate, 'switch')?.get('ssid') as string | undefined) ?? defaultSwitchConfiguration(other).ssid); + } + return { + kind, + name, + labels: { ipAddress: policy === 'ipAddress' || policy === 'ipAndMAC', macAddress: policy === 'macAddress' || policy === 'ipAndMAC' }, + macAddress: (port?.get('mac') as string | undefined) ?? (portId === undefined ? '' : stableGeneratedMacAddress(portId)), + ipAddress: (device?.get('ipAddress') as string | undefined) ?? '', + subnetMask: (device?.get('subnetMask') as string | undefined) ?? '', + defaultGateway: (device?.get('defaultGateway') as string | undefined) ?? '', + dnsServer: (device?.get('dnsServer') as string | undefined) ?? '', + dhcpClient: setting(node, 'dhcpClient')?.get('isEnabled') === true, + ipForwarding: (settingsOf(node).get('ipForwarding') as boolean | undefined) ?? forwardsIPv4ByDefault(kind), + wireless: isWireless, + ssid: (wireless?.get('ssid') as string | undefined) ?? '', + wirelessPossible: isWireless || !plugged, + networks: [...networks], + }; + } + case 'networkSwitch': { + const config = setting(node, 'switch'); + return { + kind, + name, + ssid: (config?.get('ssid') as string | undefined) ?? defaultSwitchConfiguration(id).ssid, + // Whole seconds, as Java's panel shows them (retentionTime / 1000 in long arithmetic). + retentionSeconds: Math.trunc(((config?.get('retentionTimeMilliseconds') as number | undefined) ?? DEFAULT_SWITCH_RETENTION_MILLISECONDS) / 1000), + cloud: config?.get('showsAsCloud') === true, + }; + } + case 'remoteLink': + return { kind, name, port: modemPort(setting(node, 'remoteLink')?.get('pairIdentifier') as string | undefined) }; + default: + return { kind, name }; + } +} diff --git a/packages/doc/test/sheet.cases.txt b/packages/doc/test/sheet.cases.txt new file mode 100644 index 0000000..ac47847 --- /dev/null +++ b/packages/doc/test/sheet.cases.txt @@ -0,0 +1,80 @@ +# Text typed into FILIUS' settings panels (tools/java-oracle SheetRun): +# , the text may be empty; \uXXXX for anything but +# printable ASCII and for spaces at either end. +host name Neuer Name +host name +host name \u0020\u0020PC 1\u0020\u0020 +host name \u00c4rger-\u00df +host ip 10.0.0.1 +host ip 010.000.000.001 +host ip 10..0.0.1 +host ip .0.0.1 +host ip +10.0.0.1 +host ip 256.0.0.1 +host ip 10.0.0 +host ip 10.0.0.1. +host ip \u002010.0.0.1 +host ip 10.0.0.1\u0020 +host ip abc +host ip +host ip 0.0.0.0 +host ip 1.2.3.4.5 +host ip \u0661\u0660.0.0.1 +host ip 10.0.0.-1 +host ip -0.0.0.1 +host ip 255.255.255.255 +host mask 255.255.0.0 +host mask 255.0.255.0 +host mask 0.0.0.0 +host mask 255.255.255.255 +host mask 256.0.0.0 +host mask +host mask 255.255.255 +host mask 0255.255.0.0 +host mask 1.2.3.4 +host mask 255..255.0.0 +host gateway +host gateway \u0020\u0020\u0020 +host gateway 192.168.0.1 +host gateway 192.168.0.256 +host gateway abc +host gateway \u0020192.168.0.1 +host gateway 192.168..1.1 +host gateway 0.0.0.0 +host dns +host dns 8.8.8.8 +host dns 8.8.8 +host dns \u0020 +host dns 08.8.8.8 +switch name S1 +switch name +switch ssid Schule +switch ssid +switch ssid \u0020 +switch ssid a b +switch ssid \u00c4 +switch ssid ~ +switch ssid \u007f +switch ssid \u0020lead +switch retention 60 +switch retention 0 +switch retention +switch retention -5 +switch retention abc +switch retention 1.5 +switch retention 007 +switch retention 99999999999999999999 +switch retention \u0663 +switch retention \u002060 +modem name M +modem name +modem port 12345 +modem port 0 +modem port 65535 +modem port 65536 +modem port -1 +modem port abc +modem port +modem port 080 +modem port \u002080 +modem port 80 diff --git a/packages/doc/test/sheet.java.json b/packages/doc/test/sheet.java.json new file mode 100644 index 0000000..524d0c6 --- /dev/null +++ b/packages/doc/test/sheet.java.json @@ -0,0 +1,703 @@ +{ + "cases": [ + { + "device": "host", + "field": "name", + "input": "Neuer Name", + "start": "Rechner", + "marked": false, + "stored": "Neuer Name", + "shown": "Neuer Name" + }, + { + "device": "host", + "field": "name", + "input": "", + "start": "Rechner", + "marked": false, + "stored": "", + "shown": "" + }, + { + "device": "host", + "field": "name", + "input": " PC 1 ", + "start": "Rechner", + "marked": false, + "stored": " PC 1 ", + "shown": " PC 1 " + }, + { + "device": "host", + "field": "name", + "input": "Ärger-ß", + "start": "Rechner", + "marked": false, + "stored": "Ärger-ß", + "shown": "Ärger-ß" + }, + { + "device": "host", + "field": "ip", + "input": "10.0.0.1", + "start": "192.168.0.10", + "marked": false, + "stored": "10.0.0.1", + "shown": "10.0.0.1" + }, + { + "device": "host", + "field": "ip", + "input": "010.000.000.001", + "start": "192.168.0.10", + "marked": false, + "stored": "10.0.0.1", + "shown": "10.0.0.1" + }, + { + "device": "host", + "field": "ip", + "input": "10..0.0.1", + "start": "192.168.0.10", + "marked": true, + "stored": "10.0.0.1", + "shown": "10.0.0.1" + }, + { + "device": "host", + "field": "ip", + "input": ".0.0.1", + "start": "192.168.0.10", + "marked": false, + "stored": "192.168.0.10", + "shown": "192.168.0.10" + }, + { + "device": "host", + "field": "ip", + "input": "+10.0.0.1", + "start": "192.168.0.10", + "marked": true, + "stored": "10.0.0.1", + "shown": "10.0.0.1" + }, + { + "device": "host", + "field": "ip", + "input": "256.0.0.1", + "start": "192.168.0.10", + "marked": true, + "stored": "192.168.0.10", + "shown": "192.168.0.10" + }, + { + "device": "host", + "field": "ip", + "input": "10.0.0", + "start": "192.168.0.10", + "marked": true, + "stored": "192.168.0.10", + "shown": "192.168.0.10" + }, + { + "device": "host", + "field": "ip", + "input": "10.0.0.1.", + "start": "192.168.0.10", + "marked": true, + "stored": "10.0.0.1", + "shown": "10.0.0.1" + }, + { + "device": "host", + "field": "ip", + "input": " 10.0.0.1", + "start": "192.168.0.10", + "marked": true, + "stored": "192.168.0.10", + "shown": "192.168.0.10" + }, + { + "device": "host", + "field": "ip", + "input": "10.0.0.1 ", + "start": "192.168.0.10", + "marked": true, + "stored": "192.168.0.10", + "shown": "192.168.0.10" + }, + { + "device": "host", + "field": "ip", + "input": "abc", + "start": "192.168.0.10", + "marked": true, + "stored": "192.168.0.10", + "shown": "192.168.0.10" + }, + { + "device": "host", + "field": "ip", + "input": "", + "start": "192.168.0.10", + "marked": true, + "stored": "192.168.0.10", + "shown": "192.168.0.10" + }, + { + "device": "host", + "field": "ip", + "input": "0.0.0.0", + "start": "192.168.0.10", + "marked": false, + "stored": "0.0.0.0", + "shown": "0.0.0.0" + }, + { + "device": "host", + "field": "ip", + "input": "1.2.3.4.5", + "start": "192.168.0.10", + "marked": true, + "stored": "192.168.0.10", + "shown": "192.168.0.10" + }, + { + "device": "host", + "field": "ip", + "input": "١٠.0.0.1", + "start": "192.168.0.10", + "marked": true, + "stored": "10.0.0.1", + "shown": "10.0.0.1" + }, + { + "device": "host", + "field": "ip", + "input": "10.0.0.-1", + "start": "192.168.0.10", + "marked": true, + "stored": "192.168.0.10", + "shown": "192.168.0.10" + }, + { + "device": "host", + "field": "ip", + "input": "-0.0.0.1", + "start": "192.168.0.10", + "marked": true, + "stored": "0.0.0.1", + "shown": "0.0.0.1" + }, + { + "device": "host", + "field": "ip", + "input": "255.255.255.255", + "start": "192.168.0.10", + "marked": false, + "stored": "255.255.255.255", + "shown": "255.255.255.255" + }, + { + "device": "host", + "field": "mask", + "input": "255.255.0.0", + "start": "255.255.255.0", + "marked": false, + "stored": "255.255.0.0", + "shown": "255.255.0.0" + }, + { + "device": "host", + "field": "mask", + "input": "255.0.255.0", + "start": "255.255.255.0", + "marked": false, + "stored": "255.0.255.0", + "shown": "255.0.255.0" + }, + { + "device": "host", + "field": "mask", + "input": "0.0.0.0", + "start": "255.255.255.0", + "marked": false, + "stored": "0.0.0.0", + "shown": "0.0.0.0" + }, + { + "device": "host", + "field": "mask", + "input": "255.255.255.255", + "start": "255.255.255.0", + "marked": false, + "stored": "255.255.255.255", + "shown": "255.255.255.255" + }, + { + "device": "host", + "field": "mask", + "input": "256.0.0.0", + "start": "255.255.255.0", + "marked": true, + "stored": "255.255.255.0", + "shown": "255.255.255.0" + }, + { + "device": "host", + "field": "mask", + "input": "", + "start": "255.255.255.0", + "marked": true, + "stored": "255.255.255.0", + "shown": "255.255.255.0" + }, + { + "device": "host", + "field": "mask", + "input": "255.255.255", + "start": "255.255.255.0", + "marked": true, + "stored": "255.255.255.0", + "shown": "255.255.255.0" + }, + { + "device": "host", + "field": "mask", + "input": "0255.255.0.0", + "start": "255.255.255.0", + "marked": false, + "stored": "255.255.0.0", + "shown": "255.255.0.0" + }, + { + "device": "host", + "field": "mask", + "input": "1.2.3.4", + "start": "255.255.255.0", + "marked": false, + "stored": "1.2.3.4", + "shown": "1.2.3.4" + }, + { + "device": "host", + "field": "mask", + "input": "255..255.0.0", + "start": "255.255.255.0", + "marked": true, + "stored": "255.255.0.0", + "shown": "255.255.0.0" + }, + { + "device": "host", + "field": "gateway", + "input": "", + "start": "192.168.0.254", + "marked": false, + "stored": "", + "shown": "" + }, + { + "device": "host", + "field": "gateway", + "input": " ", + "start": "192.168.0.254", + "marked": true, + "stored": "", + "shown": "" + }, + { + "device": "host", + "field": "gateway", + "input": "192.168.0.1", + "start": "192.168.0.254", + "marked": false, + "stored": "192.168.0.1", + "shown": "192.168.0.1" + }, + { + "device": "host", + "field": "gateway", + "input": "192.168.0.256", + "start": "192.168.0.254", + "marked": true, + "stored": "192.168.0.254", + "shown": "192.168.0.254" + }, + { + "device": "host", + "field": "gateway", + "input": "abc", + "start": "192.168.0.254", + "marked": true, + "stored": "192.168.0.254", + "shown": "192.168.0.254" + }, + { + "device": "host", + "field": "gateway", + "input": " 192.168.0.1", + "start": "192.168.0.254", + "marked": true, + "stored": "192.168.0.254", + "shown": "192.168.0.254" + }, + { + "device": "host", + "field": "gateway", + "input": "192.168..1.1", + "start": "192.168.0.254", + "marked": true, + "stored": "192.168.1.1", + "shown": "192.168.1.1" + }, + { + "device": "host", + "field": "gateway", + "input": "0.0.0.0", + "start": "192.168.0.254", + "marked": false, + "stored": "0.0.0.0", + "shown": "0.0.0.0" + }, + { + "device": "host", + "field": "dns", + "input": "", + "start": "192.168.0.253", + "marked": false, + "stored": "", + "shown": "" + }, + { + "device": "host", + "field": "dns", + "input": "8.8.8.8", + "start": "192.168.0.253", + "marked": false, + "stored": "8.8.8.8", + "shown": "8.8.8.8" + }, + { + "device": "host", + "field": "dns", + "input": "8.8.8", + "start": "192.168.0.253", + "marked": true, + "stored": "192.168.0.253", + "shown": "192.168.0.253" + }, + { + "device": "host", + "field": "dns", + "input": " ", + "start": "192.168.0.253", + "marked": true, + "stored": "", + "shown": "" + }, + { + "device": "host", + "field": "dns", + "input": "08.8.8.8", + "start": "192.168.0.253", + "marked": false, + "stored": "8.8.8.8", + "shown": "8.8.8.8" + }, + { + "device": "switch", + "field": "name", + "input": "S1", + "start": "Switch / WLAN", + "marked": false, + "stored": "S1", + "shown": "S1" + }, + { + "device": "switch", + "field": "name", + "input": "", + "start": "Switch / WLAN", + "marked": false, + "stored": "", + "shown": "" + }, + { + "device": "switch", + "field": "ssid", + "input": "Schule", + "start": "Start", + "marked": false, + "stored": "Schule", + "shown": "Schule" + }, + { + "device": "switch", + "field": "ssid", + "input": "", + "start": "Start", + "marked": true, + "stored": "Start", + "shown": "Start" + }, + { + "device": "switch", + "field": "ssid", + "input": " ", + "start": "Start", + "marked": false, + "stored": " ", + "shown": " " + }, + { + "device": "switch", + "field": "ssid", + "input": "a b", + "start": "Start", + "marked": false, + "stored": "a b", + "shown": "a b" + }, + { + "device": "switch", + "field": "ssid", + "input": "Ä", + "start": "Start", + "marked": true, + "stored": "Start", + "shown": "Start" + }, + { + "device": "switch", + "field": "ssid", + "input": "~", + "start": "Start", + "marked": false, + "stored": "~", + "shown": "~" + }, + { + "device": "switch", + "field": "ssid", + "input": "", + "start": "Start", + "marked": true, + "stored": "Start", + "shown": "Start" + }, + { + "device": "switch", + "field": "ssid", + "input": " lead", + "start": "Start", + "marked": false, + "stored": " lead", + "shown": " lead" + }, + { + "device": "switch", + "field": "retention", + "input": "60", + "start": "300000", + "marked": false, + "stored": "60000", + "shown": "60" + }, + { + "device": "switch", + "field": "retention", + "input": "0", + "start": "300000", + "marked": false, + "stored": "0", + "shown": "0" + }, + { + "device": "switch", + "field": "retention", + "input": "", + "start": "300000", + "marked": false, + "stored": "300000", + "shown": "300" + }, + { + "device": "switch", + "field": "retention", + "input": "-5", + "start": "300000", + "marked": true, + "stored": "-5000", + "shown": "-5" + }, + { + "device": "switch", + "field": "retention", + "input": "abc", + "start": "300000", + "marked": true, + "stored": "300000", + "shown": "300" + }, + { + "device": "switch", + "field": "retention", + "input": "1.5", + "start": "300000", + "marked": true, + "stored": "300000", + "shown": "300" + }, + { + "device": "switch", + "field": "retention", + "input": "007", + "start": "300000", + "marked": false, + "stored": "7000", + "shown": "7" + }, + { + "device": "switch", + "field": "retention", + "input": "99999999999999999999", + "start": "300000", + "marked": false, + "stored": "300000", + "shown": "300" + }, + { + "device": "switch", + "field": "retention", + "input": "٣", + "start": "300000", + "marked": true, + "stored": "3000", + "shown": "3" + }, + { + "device": "switch", + "field": "retention", + "input": " 60", + "start": "300000", + "marked": true, + "stored": "300000", + "shown": "300" + }, + { + "device": "modem", + "field": "name", + "input": "M", + "start": "Modem", + "marked": false, + "stored": "M", + "shown": "M" + }, + { + "device": "modem", + "field": "name", + "input": "", + "start": "Modem", + "marked": false, + "stored": "", + "shown": "" + }, + { + "device": "modem", + "field": "port", + "input": "12345", + "start": "12345", + "marked": false, + "stored": "12345", + "shown": "12345" + }, + { + "device": "modem", + "field": "port", + "input": "0", + "start": "12345", + "marked": false, + "stored": "0", + "shown": "0" + }, + { + "device": "modem", + "field": "port", + "input": "65535", + "start": "12345", + "marked": false, + "stored": "65535", + "shown": "65535" + }, + { + "device": "modem", + "field": "port", + "input": "65536", + "start": "12345", + "marked": false, + "stored": "65536", + "shown": "65536" + }, + { + "device": "modem", + "field": "port", + "input": "-1", + "start": "12345", + "marked": false, + "stored": "-1", + "shown": "-1" + }, + { + "device": "modem", + "field": "port", + "input": "abc", + "start": "12345", + "marked": false, + "stored": "12345", + "shown": "12345" + }, + { + "device": "modem", + "field": "port", + "input": "", + "start": "12345", + "marked": false, + "stored": "12345", + "shown": "12345" + }, + { + "device": "modem", + "field": "port", + "input": "080", + "start": "12345", + "marked": false, + "stored": "80", + "shown": "80" + }, + { + "device": "modem", + "field": "port", + "input": " 80", + "start": "12345", + "marked": false, + "stored": "12345", + "shown": "12345" + }, + { + "device": "modem", + "field": "port", + "input": "80", + "start": "12345", + "marked": false, + "stored": "80", + "shown": "80" + } + ], + "flows": { + "useMacAsNameThenOff": "0E:EE:AA:A5:58:21", + "useIpAsNameThenOff": "PC-A", + "dhcpClientStopsServer": true, + "dhcpClient": true + } +} diff --git a/packages/doc/test/sheet.test.ts b/packages/doc/test/sheet.test.ts new file mode 100644 index 0000000..ca81ef2 --- /dev/null +++ b/packages/doc/test/sheet.test.ts @@ -0,0 +1,415 @@ +// The device sheet: what a field takes, measured against FILIUS' own settings +// panels (tools/java-oracle's SheetRun typed the texts of sheet.cases.txt +// into them, sheet.java.json), and what its changes do to the document — the +// wireless connections moved as Java moves them, each change one step of +// the person's undo, and everything kept through a saved file. + +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import * as Y from 'yjs'; +import { openProjectArchive, saveProjectArchive } from '@mdg-filius/fls'; +import { + addDevice, + checkField, + connect, + createUndoManager, + deviceSheet, + readProject, + renameDevice, + ROOTS, + setDhcpClient, + setHostAddress, + setHostLabels, + setHostWireless, + setIpForwarding, + setModemPort, + setSwitchCloud, + setSwitchRetention, + setSwitchSsid, + topologyView, + type FieldRule, + type HostSheet, + type NewDevice, +} from '../src/index.js'; + +interface JavaCase { + readonly device: 'host' | 'switch' | 'modem'; + readonly field: string; + readonly input: string; + readonly start: string; + readonly marked: boolean; + readonly stored: string; + readonly shown: string; +} + +const java = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'sheet.java.json'), 'utf8')) as { + readonly cases: readonly JavaCase[]; + readonly flows: { readonly useMacAsNameThenOff: string; readonly useIpAsNameThenOff: string; readonly dhcpClientStopsServer: boolean; readonly dhcpClient: boolean }; +}; + +const RULES: Readonly> = { + 'host name': 'name', + 'host ip': 'ipAddress', + 'host mask': 'subnetMask', + 'host gateway': 'ipAddressOrEmpty', + 'host dns': 'ipAddressOrEmpty', + 'switch name': 'name', + 'switch ssid': 'ssid', + 'switch retention': 'seconds', + 'modem name': 'name', + 'modem port': 'port', +}; + +/** Fields whose text is judged without the spaces around it. */ +const TRIMMED = new Set(['host ip', 'host mask', 'host gateway', 'host dns', 'switch retention', 'modem port']); + +/** What Java's model holds in the units of the sheet's value: seconds for the retention time. */ +const javaValue = (each: JavaCase) => (each.field === 'retention' ? String(Number(each.stored) / 1000) : each.stored); + +/** The case Java judged for a text: the trimmed text's own case where spaces are left out. */ +function judged(each: JavaCase): JavaCase { + const key = `${each.device} ${each.field}`; + if (!TRIMMED.has(key) || each.input.trim() === each.input) return each; + const twin = java.cases.find((other) => other.device === each.device && other.field === each.field && other.input === each.input.trim()); + if (twin === undefined) throw new Error(`no case for ${JSON.stringify(each.input.trim())} (${key})`); + return twin; +} + +describe("a field's rule, against FILIUS' settings panels", () => { + it('has a case for every field the sheet checks', () => { + expect(new Set(java.cases.map((each) => `${each.device} ${each.field}`))).toEqual(new Set(Object.keys(RULES))); + expect(java.cases.length).toBeGreaterThan(70); + }); + + it('takes a text exactly when Java neither marks nor refuses it, as the value Java keeps', () => { + for (const each of java.cases) { + const key = `${each.device} ${each.field}`; + const verdict = judged(each); + // Taken: the value changed, or the text was the value already. + const took = verdict.stored !== verdict.start || verdict.input === verdict.start; + // A name is taken as typed. A modem's port Java never marks: it takes any int; here Java's own rule for ports. + const expected = RULES[key] === 'name' ? true : key === 'modem port' ? took && /^([1-9][0-9]{0,4})$/.test(verdict.input) && Number(verdict.input) <= 65_535 : !verdict.marked && took; + const checked = checkField(RULES[key]!, each.input); + expect('value' in checked, `${key} ${JSON.stringify(each.input)}`).toBe(expected); + if ('value' in checked) expect(checked.value, `${key} ${JSON.stringify(each.input)}`).toBe(RULES[key] === 'name' ? each.input : javaValue(verdict)); + } + }); + + it("names where Java's two judgements disagree, which here are one", () => { + const disagreeing = java.cases.filter((each) => RULES[`${each.device} ${each.field}`] !== 'name' && each.field !== 'port' && each.marked === (each.stored !== each.start)); + // Marked red, yet taken; or black, yet refused (the gateway's spaces Java trims to empty). + expect(disagreeing.map((each) => `${each.field} ${JSON.stringify(each.input)} ${each.marked ? 'marked, taken' : 'refused'}`)).toEqual([ + 'ip "10..0.0.1" marked, taken', + 'ip ".0.0.1" refused', + 'ip "+10.0.0.1" marked, taken', + 'ip "10.0.0.1." marked, taken', + 'ip "١٠.0.0.1" marked, taken', + 'ip "-0.0.0.1" marked, taken', + 'mask "255..255.0.0" marked, taken', + 'gateway " " marked, taken', + 'gateway "192.168..1.1" marked, taken', + 'dns " " marked, taken', + 'retention "" refused', + 'retention "-5" marked, taken', + 'retention "99999999999999999999" refused', + 'retention "٣" marked, taken', + ]); + const ports = java.cases.filter((each) => each.field === 'port' && each.stored !== each.start); + expect(ports.map((each) => each.input)).toEqual(['0', '65535', '65536', '-1', '080', '80']); + }); + + it("shows what the device holds once a field is left, as Java's field does", () => { + for (const each of java.cases) if (each.field !== 'retention') expect(each.shown, each.input).toBe(each.stored); + }); +}); + +let ids = 0; +const newId = () => `20000000-0000-0000-0000-${(++ids).toString(16).padStart(12, '0')}`; + +function add(doc: Y.Doc, kind: NewDevice['kind'], name: string = kind): string { + const id = newId(); + addDevice(doc, { kind, id, name, x: 100, y: 100, newId }, 'me'); + return id; +} + +const host = (doc: Y.Doc, id: string) => deviceSheet(doc, id) as HostSheet; + +describe("a PC's sheet", () => { + it("shows a new PC as Java's panel does", () => { + const doc = new Y.Doc(); + const pc = add(doc, 'pc', 'Rechner'); + const sheet = host(doc, pc); + expect({ ...sheet, macAddress: undefined }).toEqual({ + kind: 'pc', + name: 'Rechner', + labels: { ipAddress: false, macAddress: false }, + macAddress: undefined, + ipAddress: '192.168.0.10', + subnetMask: '255.255.255.0', + defaultGateway: '', + dnsServer: '', + dhcpClient: false, + ipForwarding: false, + wireless: false, + ssid: '', + wirelessPossible: true, + networks: [], + }); + expect(sheet.macAddress).toMatch(/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/); + expect(deviceSheet(doc, 'nobody')).toBeUndefined(); + }); + + it('takes an address as Java keeps it, keeps the old one for a marked text, and says which', () => { + const doc = new Y.Doc(); + const pc = add(doc, 'pc'); + expect(setHostAddress(doc, pc, 'ipAddress', ' 010.000.000.001 ', 'me')).toEqual({ value: '10.0.0.1' }); + expect(setHostAddress(doc, pc, 'ipAddress', '10.0.0.256', 'me')).toEqual({ invalid: true }); + expect(setHostAddress(doc, pc, 'subnetMask', '255.0.255.0', 'me')).toEqual({ value: '255.0.255.0' }); + expect(setHostAddress(doc, pc, 'defaultGateway', '10.0.0.254', 'me')).toEqual({ value: '10.0.0.254' }); + expect(setHostAddress(doc, pc, 'dnsServer', '8.8.8', 'me')).toEqual({ invalid: true }); + expect(setHostAddress(doc, pc, 'dnsServer', '8.8.8.8', 'me')).toEqual({ value: '8.8.8.8' }); + expect(readProject(doc).deviceConfigurations.get(pc)).toEqual({ ipAddress: '10.0.0.1', subnetMask: '255.0.255.0', defaultGateway: '10.0.0.254', dnsServer: '8.8.8.8' }); + expect(setHostAddress(doc, pc, 'defaultGateway', '', 'me')).toEqual({ value: '' }); + expect(host(doc, pc).defaultGateway).toBe(''); + expect(setHostAddress(doc, 'nobody', 'ipAddress', '10.0.0.1', 'me')).toEqual({ value: '10.0.0.1' }); + }); + + it('is renamed, and shows its address, its MAC address or both as its name', () => { + const doc = new Y.Doc(); + const pc = add(doc, 'pc'); + renameDevice(doc, pc, 'PC-A', 'me'); + setHostLabels(doc, pc, { ipAddress: true, macAddress: false }, 'me'); + expect(topologyView(doc).nodes[0]!.label).toBe('192.168.0.10'); + setHostLabels(doc, pc, { ipAddress: false, macAddress: true }, 'me'); + expect(topologyView(doc).nodes[0]!.label).toBe(host(doc, pc).macAddress); + setHostLabels(doc, pc, { ipAddress: true, macAddress: true }, 'me'); + expect(host(doc, pc).labels).toEqual({ ipAddress: true, macAddress: true }); + expect(topologyView(doc).nodes[0]!.label).toBe(`192.168.0.10 (${host(doc, pc).macAddress})`); + setHostLabels(doc, pc, { ipAddress: false, macAddress: false }, 'me'); + expect(topologyView(doc).nodes[0]!.label).toBe('PC-A'); + }); + + it("keeps its name while it shows its MAC address and another field is taken (Java's panel overwrites it)", () => { + // Java: the name became the MAC address; with the address shown instead, it stayed. + expect(java.flows.useMacAsNameThenOff).toMatch(/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/); + expect(java.flows.useIpAsNameThenOff).toBe('PC-A'); + const doc = new Y.Doc(); + const pc = add(doc, 'pc', 'PC-A'); + setHostLabels(doc, pc, { ipAddress: false, macAddress: true }, 'me'); + setHostAddress(doc, pc, 'defaultGateway', '192.168.0.1', 'me'); + setHostLabels(doc, pc, { ipAddress: false, macAddress: false }, 'me'); + expect(host(doc, pc).name).toBe('PC-A'); + }); + + it("keeps IP forwarding only where it differs from the kind's", () => { + const doc = new Y.Doc(); + const pc = add(doc, 'pc'); + const router = add(doc, 'router'); + setIpForwarding(doc, pc, true, 'me'); + setIpForwarding(doc, router, false, 'me'); + expect(host(doc, pc).ipForwarding).toBe(true); + expect(readProject(doc).ipForwardingByNodeId).toEqual( + new Map([ + [pc, true], + [router, false], + ]), + ); + setIpForwarding(doc, pc, false, 'me'); + setIpForwarding(doc, router, true, 'me'); + expect(readProject(doc).ipForwardingByNodeId).toEqual(new Map()); + }); + + it("uses DHCP, which stops its own DHCP server, as Java's panel does", () => { + expect(java.flows).toMatchObject({ dhcpClient: true, dhcpClientStopsServer: true }); + const doc = new Y.Doc(); + const pc = add(doc, 'pc'); + const settings = doc.getMap>(ROOTS.nodes).get(pc)!.get('settings') as Y.Map; + const server = new Y.Map(); + server.set('isActive', true); + settings.set('dhcpServer', server); + setDhcpClient(doc, pc, true, 'me'); + expect(host(doc, pc).dhcpClient).toBe(true); + expect(readProject(doc).dhcpClientConfigurations.get(pc)).toEqual({ isEnabled: true }); + expect(server.get('isActive')).toBe(false); + setDhcpClient(doc, pc, false, 'me'); + expect(host(doc, pc).dhcpClient).toBe(false); + expect(readProject(doc).dhcpClientConfigurations.has(pc)).toBe(false); + }); +}); + +describe('a wireless card', () => { + /** Two switches with their SSIDs and a notebook. */ + function wlan() { + const doc = new Y.Doc(); + const first = add(doc, 'networkSwitch'); + const second = add(doc, 'networkSwitch'); + const notebook = add(doc, 'notebook'); + setSwitchSsid(doc, first, 'Schule', 'me'); + setSwitchSsid(doc, second, 'Gast', 'me'); + const ends = () => readProject(doc).graph.links.map((link) => [link.sourceNodeId, link.targetNodeId]); + return { doc, first, second, notebook, ends }; + } + + it('offers the switches’ networks, each once', () => { + const { doc, notebook } = wlan(); + const third = add(doc, 'networkSwitch'); + setSwitchSsid(doc, third, 'Schule', 'me'); + expect(host(doc, notebook).networks).toEqual(['Schule', 'Gast']); + }); + + it('joins the first switch with its SSID, moves with the SSID and leaves on the cable', () => { + const { doc, first, second, notebook, ends } = wlan(); + expect(setHostWireless(doc, notebook, true, 'Schule', 'link-1', 'me')).toEqual({ link: 'link-1' }); + expect(ends()).toEqual([[notebook, first]]); + expect(topologyView(doc).links[0]!.wireless).toBe(true); + expect(host(doc, notebook)).toMatchObject({ wireless: true, ssid: 'Schule', wirelessPossible: true }); + expect(setHostWireless(doc, notebook, true, 'Gast', 'link-2', 'me')).toEqual({ link: 'link-2' }); + expect(ends()).toEqual([[notebook, second]]); + // The same again changes nothing. + expect(setHostWireless(doc, notebook, true, 'Gast', 'link-3', 'me')).toEqual({}); + expect(ends()).toEqual([[notebook, second]]); + expect(setHostWireless(doc, notebook, false, 'Gast', 'link-4', 'me')).toEqual({}); + expect(ends()).toEqual([]); + // Java keeps the SSID with the cable. + expect(readProject(doc).hostWirelessConfigurations.get(notebook)).toEqual({ isEnabled: false, ssid: 'Gast' }); + expect(setHostWireless(doc, notebook, false, '', 'link-5', 'me')).toEqual({}); + expect(readProject(doc).hostWirelessConfigurations.has(notebook)).toBe(false); + }); + + it('joins the first of two switches with its SSID', () => { + const { doc, second, notebook, ends } = wlan(); + const third = add(doc, 'networkSwitch'); + setSwitchSsid(doc, third, 'Gast', 'me'); + expect(setHostWireless(doc, notebook, true, 'Gast', 'link-1', 'me')).toEqual({ link: 'link-1' }); + expect(ends()).toEqual([[notebook, second]]); + }); + + it('joins no network without an SSID or without a switch that has it', () => { + const { doc, notebook, ends } = wlan(); + expect(setHostWireless(doc, notebook, true, '', 'link-1', 'me')).toEqual({}); + expect(setHostWireless(doc, notebook, true, 'Nirgends', 'link-2', 'me')).toEqual({}); + expect(ends()).toEqual([]); + expect(host(doc, notebook)).toMatchObject({ wireless: true, ssid: 'Nirgends' }); + }); + + it('cannot be chosen while its cable is plugged, and says why a full switch refuses it', () => { + const { doc, first, notebook } = wlan(); + const pc = add(doc, 'pc'); + connect(doc, { node: notebook }, { node: pc }, 'cable', 'me'); + expect(host(doc, notebook).wirelessPossible).toBe(false); + expect(setHostWireless(doc, notebook, true, 'Schule', 'link-1', 'me')).toEqual({ error: 'cablePlugged' }); + expect(host(doc, notebook).wireless).toBe(false); + const other = add(doc, 'notebook'); + const full = doc.getMap>(ROOTS.nodes).get(first)!; + (full.get('ports') as Y.Array>).delete(1, 23); + expect(connect(doc, { node: add(doc, 'pc') }, { node: first }, 'filled', 'me')).toEqual({ link: 'filled' }); + expect(setHostWireless(doc, other, true, 'Schule', 'link-2', 'me')).toEqual({ error: 'noFreePort' }); + expect(setHostWireless(doc, 'nobody', true, 'Schule', 'link-3', 'me')).toEqual({ error: 'unknownDevice' }); + }); + + it("is dropped by a switch that changes its SSID, a cable to it staying (Java's panel)", () => { + const { doc, first, notebook, ends } = wlan(); + const pc = add(doc, 'pc'); + setHostWireless(doc, notebook, true, 'Schule', 'link-1', 'me'); + connect(doc, { node: pc }, { node: first }, 'cable', 'me'); + expect(setSwitchSsid(doc, first, 'Schule', 'me')).toEqual({ value: 'Schule' }); + expect(ends()).toHaveLength(2); + expect(setSwitchSsid(doc, first, 'Ä', 'me')).toEqual({ invalid: true }); + expect(ends()).toHaveLength(2); + expect(setSwitchSsid(doc, first, 'Neu', 'me')).toEqual({ value: 'Neu' }); + expect(ends()).toEqual([[pc, first]]); + }); +}); + +describe("a switch's and a modem's sheet", () => { + it('shows and keeps the SSID, the retention time in seconds and the cloud', () => { + const doc = new Y.Doc(); + const hub = add(doc, 'networkSwitch', 'Switch'); + expect(deviceSheet(doc, hub)).toEqual({ kind: 'networkSwitch', name: 'Switch', ssid: hub.replaceAll('-', '').slice(0, 6), retentionSeconds: 300, cloud: false }); + expect(setSwitchRetention(doc, hub, ' 007 ', 'me')).toEqual({ value: '7' }); + expect(setSwitchRetention(doc, hub, '-5', 'me')).toEqual({ invalid: true }); + setSwitchCloud(doc, hub, true, 'me'); + expect(deviceSheet(doc, hub)).toMatchObject({ retentionSeconds: 7, cloud: true }); + expect(topologyView(doc).nodes[0]!.cloud).toBe(true); + expect(readProject(doc).switchConfigurations.get(hub)).toEqual({ ssid: hub.replaceAll('-', '').slice(0, 6), retentionTimeMilliseconds: 7000, showsAsCloud: true }); + setSwitchCloud(doc, hub, false, 'me'); + expect(readProject(doc).switchConfigurations.get(hub)).toEqual({ ssid: hub.replaceAll('-', '').slice(0, 6), retentionTimeMilliseconds: 7000 }); + }); + + it('shows whole seconds of a retention time a file brings in milliseconds', () => { + const doc = new Y.Doc(); + const hub = add(doc, 'networkSwitch'); + (doc.getMap>(ROOTS.nodes).get(hub)!.get('settings') as Y.Map>).get('switch')!.set('retentionTimeMilliseconds', 1999); + expect(deviceSheet(doc, hub)).toMatchObject({ retentionSeconds: 1 }); + }); + + it("pairs a modem by its port, and names the other kinds' sheets", () => { + const doc = new Y.Doc(); + const modem = add(doc, 'remoteLink', 'Modem'); + expect(deviceSheet(doc, modem)).toEqual({ kind: 'remoteLink', name: 'Modem', port: 12_345 }); + expect(setModemPort(doc, modem, '65536', 'me')).toEqual({ invalid: true }); + expect(setModemPort(doc, modem, ' 80', 'me')).toEqual({ value: '80' }); + expect(readProject(doc).remoteLinkConfigurations.get(modem)).toEqual({ pairIdentifier: 'java-modem-port-80', latencyMilliseconds: 20, isEnabled: true }); + expect(deviceSheet(doc, modem)).toMatchObject({ port: 80 }); + const router = add(doc, 'router', 'R1'); + renameDevice(doc, router, 'R2', 'me'); + expect(deviceSheet(doc, router)).toEqual({ kind: 'router', name: 'R2' }); + expect(deviceSheet(doc, add(doc, 'gateway', 'Heim'))).toEqual({ kind: 'gateway', name: 'Heim' }); + }); +}); + +describe("the sheet's changes", () => { + it('are each one step of the person’s undo, the wireless join with its connection', () => { + const doc = new Y.Doc(); + const hub = add(doc, 'networkSwitch'); + const notebook = add(doc, 'notebook'); + setSwitchSsid(doc, hub, 'Schule', 'setup'); + const undo = createUndoManager(doc, 'me'); + setHostAddress(doc, notebook, 'ipAddress', '10.0.0.1', 'me'); + undo.stopCapturing(); + setHostWireless(doc, notebook, true, 'Schule', 'link-1', 'me'); + undo.stopCapturing(); + renameDevice(doc, notebook, 'NB', 'me'); + undo.stopCapturing(); + undo.undo(); + expect(host(doc, notebook).name).toBe('notebook'); + undo.undo(); + expect(host(doc, notebook).wireless).toBe(false); + expect(readProject(doc).graph.links).toEqual([]); + undo.undo(); + expect(host(doc, notebook).ipAddress).toBe('192.168.0.10'); + undo.redo(); + undo.redo(); + expect(host(doc, notebook)).toMatchObject({ ipAddress: '10.0.0.1', wireless: true }); + expect(readProject(doc).graph.links).toHaveLength(1); + }); + + it('are kept through a saved file', () => { + const doc = new Y.Doc(); + const pc = add(doc, 'pc', 'PC-A'); + const hub = add(doc, 'networkSwitch', 'S'); + const modem = add(doc, 'remoteLink', 'M'); + setHostAddress(doc, pc, 'ipAddress', '10.1.2.3', 'me'); + setHostAddress(doc, pc, 'subnetMask', '255.255.0.0', 'me'); + setHostAddress(doc, pc, 'defaultGateway', '10.1.0.1', 'me'); + setHostAddress(doc, pc, 'dnsServer', '10.1.0.2', 'me'); + setHostLabels(doc, pc, { ipAddress: true, macAddress: false }, 'me'); + setIpForwarding(doc, pc, true, 'me'); + setDhcpClient(doc, pc, true, 'me'); + setSwitchSsid(doc, hub, 'Schule', 'me'); + setSwitchRetention(doc, hub, '42', 'me'); + setSwitchCloud(doc, hub, true, 'me'); + setModemPort(doc, modem, '4242', 'me'); + let counter = 0; + const reopened = openProjectArchive(saveProjectArchive(readProject(doc)).archive, () => `30000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`); + const [pcAgain, hubAgain, modemAgain] = reopened.graph.nodes; + expect(pcAgain!.hostLabelPolicy).toBe('ipAddress'); + expect(reopened.deviceConfigurations.get(pcAgain!.id)).toEqual({ ipAddress: '10.1.2.3', subnetMask: '255.255.0.0', defaultGateway: '10.1.0.1', dnsServer: '10.1.0.2' }); + expect(reopened.ipForwardingByNodeId.get(pcAgain!.id)).toBe(true); + expect(reopened.dhcpClientConfigurations.get(pcAgain!.id)).toEqual({ isEnabled: true }); + expect(reopened.switchConfigurations.get(hubAgain!.id)).toEqual({ ssid: 'Schule', retentionTimeMilliseconds: 42_000, showsAsCloud: true }); + expect(reopened.remoteLinkConfigurations.get(modemAgain!.id)?.pairIdentifier).toBe('java-modem-port-4242'); + }); +}); diff --git a/packages/editor/browser/editorPage.ts b/packages/editor/browser/editorPage.ts index 893f28e..b883480 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 { TopologyView } from '@mdg-filius/doc'; +import type { DeviceSheet, TopologyView } from '@mdg-filius/doc'; const config = join(dirname(fileURLToPath(import.meta.url)), '../vite.config.ts'); @@ -30,6 +30,11 @@ export function drawing(page: Page): Promise { return page.evaluate(() => (window as unknown as { filiusDev: { view: () => TopologyView } }).filiusDev.view()); } +/** What the device sheet reads of a device. */ +export function sheetOf(page: Page, id: string): Promise { + return page.evaluate((each) => (window as unknown as { filiusDev: { sheet: (id: string) => DeviceSheet | undefined } }).filiusDev.sheet(each), id); +} + /** A device's button as data-box says it (surface pixels). */ export async function boxOf(page: Page, id: string): Promise<{ x: number; y: number; width: number; height: number }> { const [x, y, width, height] = (await page.locator(`g.device[data-node="${id}"]`).getAttribute('data-box'))!.split(' ').map(Number) as [number, number, number, number]; diff --git a/packages/editor/browser/sheet.spec.ts b/packages/editor/browser/sheet.spec.ts new file mode 100644 index 0000000..c0519a3 --- /dev/null +++ b/packages/editor/browser/sheet.spec.ts @@ -0,0 +1,185 @@ +// The device sheet in Chromium (Playwright): it opens with the device that +// is chosen, shows Java's fields, takes a field when it is left, marks and +// keeps a text its rule refuses, moves a notebook into a wireless network — +// beside the surface on a wide screen, below it on a phone. + +import { expect, test, type Page } from '@playwright/test'; +import type { HostSheet } from '@mdg-filius/doc'; +import { drawing, onDevice, openEditor, serveEditor, sheetOf } from './editorPage.js'; + +let server: { readonly base: string; readonly close: () => Promise }; + +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 choose(page: Page, id: string): Promise { + const at = await onDevice(page, id); + await page.mouse.click(at.x, at.y); +} + +const sheet = (page: Page) => page.getByRole('complementary', { name: 'Einstellungen' }); +const field = (page: Page, name: string) => sheet(page).locator(`input[name="${name}"]`); + +test("opens with the chosen device, shows Java's fields beside the surface, and closes letting go of it", async ({ page }) => { + await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls'); + await expect(sheet(page)).toHaveCount(0); + const id = await deviceNamed(page, 'Notebook 1.10'); + await choose(page, id); + await expect(sheet(page)).toBeVisible(); + await expect(sheet(page).getByRole('heading')).toHaveText('Notebook'); + const values = (await sheetOf(page, id)) as HostSheet; + await expect(field(page, 'name')).toHaveValue('Notebook 1.10'); + await expect(field(page, 'macAddress')).toHaveValue(values.macAddress); + await expect(field(page, 'ipAddress')).toHaveValue(values.ipAddress); + await expect(field(page, 'subnetMask')).toHaveValue(values.subnetMask); + await expect(field(page, 'defaultGateway')).toHaveValue(values.defaultGateway); + await expect(sheet(page).getByLabel('Kabelgebunden (LAN)')).toBeChecked(); + await expect(sheet(page).getByLabel('DHCP zur Konfiguration verwenden')).not.toBeChecked(); + const surface = (await page.locator('svg.filius-canvas').boundingBox())!; + const aside = (await sheet(page).boundingBox())!; + expect(aside.x).toBeGreaterThanOrEqual(surface.x + surface.width - 1); + expect(aside.width).toBe(340); + await sheet(page).getByRole('button', { name: 'Schließen' }).click(); + await expect(sheet(page)).toHaveCount(0); + await expect(page.locator(`g.device[data-node="${id}"]`)).not.toHaveAttribute('data-selected', 'true'); +}); + +test('marks a refused address while it is typed, keeps it with what still holds, and takes a mended one', async ({ page }) => { + await openEditor(page, server.base, 'new'); + await page.locator('[data-palette="pc"]').click(); + await expect(sheet(page)).toBeVisible(); + const id = (await drawing(page)).nodes[0]!.id; + const ip = field(page, 'ipAddress'); + await ip.fill('10.0.0.256'); + await expect(ip).toHaveAttribute('aria-invalid', 'true'); + await field(page, 'subnetMask').focus(); + await expect(sheet(page).getByRole('status')).toHaveText('Nicht übernommen – es gilt weiterhin 192.168.0.10.'); + await expect(ip).toHaveValue('10.0.0.256'); + expect(((await sheetOf(page, id)) as HostSheet).ipAddress).toBe('192.168.0.10'); + await ip.fill('010.0.0.5'); + await expect(ip).toHaveAttribute('aria-invalid', 'false'); + await expect(sheet(page).getByRole('status')).toHaveCount(0); + await ip.press('Enter'); + await expect(ip).toHaveValue('10.0.0.5'); + expect(((await sheetOf(page, id)) as HostSheet).ipAddress).toBe('10.0.0.5'); + // Escape gives a text up; the empty gateway is taken, the empty address is not. + await ip.fill('1.2.3'); + await ip.press('Escape'); + await expect(ip).toHaveValue('10.0.0.5'); + await expect(sheet(page)).toBeVisible(); + await field(page, 'defaultGateway').fill(''); + await field(page, 'defaultGateway').press('Enter'); + await ip.fill(''); + await ip.press('Enter'); + await expect(sheet(page).getByRole('status')).toHaveText('Nicht übernommen – es gilt weiterhin 10.0.0.5.'); +}); + +test('takes a change back as one step, and shows the address as the name', async ({ page }) => { + await openEditor(page, server.base, 'new'); + await page.locator('[data-palette="pc"]').click(); + const id = (await drawing(page)).nodes[0]!.id; + await field(page, 'ipAddress').fill('10.0.0.9'); + await field(page, 'ipAddress').press('Enter'); + await sheet(page).getByLabel('IP-Adresse als Name verwenden').check(); + await expect.poll(async () => (await drawing(page)).nodes[0]!.label).toBe('10.0.0.9'); + await expect(field(page, 'name')).toBeDisabled(); + await expect(field(page, 'name')).toHaveValue('10.0.0.9'); + await page.getByRole('button', { name: 'Rückgängig' }).click(); + await expect.poll(async () => (await drawing(page)).nodes[0]!.label).toBe('Rechner'); + await expect(field(page, 'name')).toBeEnabled(); + await page.getByRole('button', { name: 'Rückgängig' }).click(); + await expect(field(page, 'ipAddress')).toHaveValue('192.168.0.10'); + expect(((await sheetOf(page, id)) as HostSheet).ipAddress).toBe('192.168.0.10'); +}); + +test('takes the text of one device when another is chosen, and shows the other', async ({ page }) => { + await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls'); + const first = await deviceNamed(page, 'Notebook 1.10'); + const second = await deviceNamed(page, 'Notebook 1.11'); + await choose(page, first); + await field(page, 'dnsServer').fill('192.168.1.53'); + await choose(page, second); + await expect(sheet(page)).toHaveAttribute('data-device', second); + expect(((await sheetOf(page, first)) as HostSheet).dnsServer).toBe('192.168.1.53'); + await expect(field(page, 'name')).toHaveValue('Notebook 1.11'); + await expect(field(page, 'dnsServer')).toHaveValue(((await sheetOf(page, second)) as HostSheet).dnsServer); +}); + +test('offers no text of one device to the next, a refused one neither', async ({ page }) => { + await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls'); + const first = await deviceNamed(page, 'Notebook 1.10'); + const second = await deviceNamed(page, 'Notebook 1.11'); + await choose(page, first); + await field(page, 'ipAddress').fill('300.0.0.1'); + await choose(page, second); + await expect(sheet(page)).toHaveAttribute('data-device', second); + await expect(field(page, 'ipAddress')).toHaveValue(((await sheetOf(page, second)) as HostSheet).ipAddress); + await expect(field(page, 'ipAddress')).toHaveAttribute('aria-invalid', 'false'); + await expect(sheet(page).getByRole('status')).toHaveCount(0); +}); + +test('closes the address fields while DHCP configures the card, as Java does', async ({ page }) => { + await openEditor(page, server.base, 'new'); + await page.locator('[data-palette="pc"]').click(); + const fields = ['ipAddress', 'subnetMask', 'defaultGateway', 'dnsServer']; + await sheet(page).getByLabel('DHCP zur Konfiguration verwenden').check(); + for (const name of fields) await expect(field(page, name)).toBeDisabled(); + await expect(field(page, 'name')).toBeEnabled(); + await sheet(page).getByLabel('DHCP zur Konfiguration verwenden').uncheck(); + for (const name of fields) await expect(field(page, name)).toBeEnabled(); +}); + +test("joins a notebook to a switch's wireless network, drawn as wireless", async ({ page }) => { + await openEditor(page, server.base, 'new'); + await page.locator('[data-palette="networkSwitch"]').click(); + const hub = (await drawing(page)).nodes[0]!.id; + await field(page, 'ssid').fill('Schule'); + await field(page, 'ssid').press('Enter'); + await sheet(page).getByLabel('als Wolke darstellen').check(); + await expect.poll(async () => (await drawing(page)).nodes[0]!.cloud).toBe(true); + await page.locator('[data-palette="notebook"]').click(); + await expect(sheet(page).getByRole('heading')).toHaveText('Notebook'); + await expect(sheet(page).locator('select[name="ssid"]')).toBeDisabled(); + await sheet(page).getByLabel('Drahtlos (WLAN)').check(); + await sheet(page).locator('select[name="ssid"]').selectOption('Schule'); + await expect.poll(async () => (await drawing(page)).links.map((link) => [link.targetNode, link.wireless])).toEqual([[hub, true]]); + await sheet(page).getByLabel('Kabelgebunden (LAN)').check(); + await expect.poll(async () => (await drawing(page)).links).toEqual([]); +}); + +test.describe('on a phone', () => { + test.use({ viewport: { width: 390, height: 844 }, hasTouch: true }); + + test('opens below the surface with a tap, the device still in sight', async ({ page }) => { + await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls'); + const id = await deviceNamed(page, 'Notebook 1.10'); + const at = await onDevice(page, id); + await page.touchscreen.tap(at.x, at.y); + await expect(sheet(page)).toBeVisible(); + const surface = (await page.locator('svg.filius-canvas').boundingBox())!; + const aside = (await sheet(page).boundingBox())!; + expect(aside.y).toBeGreaterThanOrEqual(surface.y + surface.height - 1); + expect(aside.height).toBeLessThanOrEqual(844 / 2); + expect(surface.height).toBeGreaterThan(200); + const fieldBox = (await field(page, 'ipAddress').boundingBox())!; + expect(fieldBox.height).toBeGreaterThanOrEqual(44); + }); +}); + +test('speaks English when asked', async ({ page }) => { + await openEditor(page, server.base, 'new', '&lang=en'); + await page.locator('[data-palette="networkSwitch"]').click(); + const aside = page.getByRole('complementary', { name: 'Settings' }); + await expect(aside.getByText('Wi-Fi Name (SSID)')).toBeVisible(); + await expect(aside.getByText('Retention Time SAT Entries (Seconds)')).toBeVisible(); + await expect(aside.getByLabel('Display as Cloud')).not.toBeChecked(); +}); diff --git a/packages/editor/src/Editor.tsx b/packages/editor/src/Editor.tsx index 3780845..c2c2313 100644 --- a/packages/editor/src/Editor.tsx +++ b/packages/editor/src/Editor.tsx @@ -1,5 +1,6 @@ // The editor on a document: the palette, the surface, the tools of the design -// mode (F4.4b). The device sheet and the simulation follow (F4.5, F4.6). +// mode (F4.4b), the device sheet of the selected device (F4.5). The +// simulation follows (F4.6). // // 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 @@ -13,6 +14,7 @@ import type { Point } from './canvas/viewport.js'; 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 { useTranslate } from './i18n/i18n.js'; /** The document's drawing, read anew after every change. */ @@ -152,6 +154,8 @@ export function Editor({ doc }: { readonly doc: Y.Doc }) { return items; }; + const selected = selection?.kind === 'device' ? view.nodes.find((node) => node.id === selection.id) : undefined; + // The keyboard: undo and redo, delete what is selected, Escape lets go. useEffect(() => { const key = (event: KeyboardEvent) => { @@ -186,58 +190,64 @@ export function Editor({ doc }: { readonly doc: Y.Doc }) { }} onDevice={onDevice} /> -
- moveDevice(doc, id, x, y, origin)} - onMoveEnd={() => manager?.stopCapturing()} - onCableStart={setCableFrom} - onCable={onCable} - onMenu={(target, at) => { - setSelection(target); - setMenu({ target, at }); - }} - /> -
- - -
- {menu === null ? null : setMenu(null)} />} - {routerAt === null ? null : ( - { - place('router', routerAt, interfaces); - setRouterAt(null); +
+
+ moveDevice(doc, id, x, y, origin)} + onMoveEnd={() => manager?.stopCapturing()} + onCableStart={setCableFrom} + onCable={onCable} + onMenu={(target, at) => { + setSelection(target); + setMenu({ target, at }); }} /> - )} - {message === null ? null : ( -
- {message} +
+ +
+ {menu === null ? null : setMenu(null)} />} + {routerAt === null ? null : ( + { + place('router', routerAt, interfaces); + setRouterAt(null); + }} + /> + )} + {message === null ? null : ( +
+ {message} +
+ )} +
+ {/* Java shows the settings of the device that is chosen; closing them lets go of it, as a click beside it does. */} + {selected === undefined ? null : ( + manager?.stopCapturing()} onMessage={setMessage} onClose={() => setSelection(null)} /> )}
diff --git a/packages/editor/src/i18n/de.json b/packages/editor/src/i18n/de.json index 578f1bf..d486891 100644 --- a/packages/editor/src/i18n/de.json +++ b/packages/editor/src/i18n/de.json @@ -33,6 +33,31 @@ "router.cancel": "Abbrechen", "router.prompt": "Wählen Sie die Anzahl der Schnittstellen des Vermittlungsrechners aus!", "router.title": "Anzahl Schnittstellen", + "sheet.cable": "Kabelgebunden (LAN)", + "sheet.cablePlugged": "Das Kabel ist noch eingesteckt.", + "sheet.chooseNetwork": "Bitte auswählen", + "sheet.close": "Schließen", + "sheet.cloud": "als Wolke darstellen", + "sheet.connection": "Anschluss", + "sheet.defaultGateway": "Gateway", + "sheet.dhcpClient": "DHCP zur Konfiguration verwenden", + "sheet.dnsServer": "Domain Name Server", + "sheet.empty": "(leer)", + "sheet.invalidValue": "Dieser Wert ist ungültig.", + "sheet.ipAddress": "IP-Adresse", + "sheet.ipAsName": "IP-Adresse als Name verwenden", + "sheet.ipForwarding": "IP-Weiterleitung aktivieren", + "sheet.macAddress": "MAC-Adresse", + "sheet.macAsName": "MAC-Adresse als Name verwenden", + "sheet.name": "Name", + "sheet.network": "Drahtloses Netzwerk (SSID)", + "sheet.notTaken": "Nicht übernommen – es gilt weiterhin {value}.", + "sheet.port": "Port", + "sheet.retention": "Gültigkeitsdauer SAT-Einträge (in Sek.)", + "sheet.subnetMask": "Netzmaske", + "sheet.switchSsid": "Name WLAN (SSID)", + "sheet.title": "Einstellungen", + "sheet.wireless": "Drahtlos (WLAN)", "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 ab8a598..ccbd45e 100644 --- a/packages/editor/src/i18n/en.json +++ b/packages/editor/src/i18n/en.json @@ -33,6 +33,31 @@ "router.cancel": "Cancel", "router.prompt": "Choose number of NIC of the router!", "router.title": "Number of NIC", + "sheet.cable": "Cable (LAN)", + "sheet.cablePlugged": "The cable is still plugged in.", + "sheet.chooseNetwork": "Please select", + "sheet.close": "Close", + "sheet.cloud": "Display as Cloud", + "sheet.connection": "Connection", + "sheet.defaultGateway": "Gateway", + "sheet.dhcpClient": "Use DHCP for configuration", + "sheet.dnsServer": "Domain Name Server", + "sheet.empty": "(empty)", + "sheet.invalidValue": "This value is invalid.", + "sheet.ipAddress": "IP Address", + "sheet.ipAsName": "Use IP address as name", + "sheet.ipForwarding": "Activate IP forwarding", + "sheet.macAddress": "MAC Address", + "sheet.macAsName": "Use MAC address as name", + "sheet.name": "Name", + "sheet.network": "Wireless Network (SSID)", + "sheet.notTaken": "Not taken – still {value}.", + "sheet.port": "Port", + "sheet.retention": "Retention Time SAT Entries (Seconds)", + "sheet.subnetMask": "Netmask", + "sheet.switchSsid": "Wi-Fi Name (SSID)", + "sheet.title": "Settings", + "sheet.wireless": "Wireless (Wi-Fi)", "zoom.fit": "Show all", "zoom.in": "Zoom in", "zoom.level": "{percent} %", diff --git a/packages/editor/src/main.tsx b/packages/editor/src/main.tsx index f360b2a..02ecf65 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 { emptyProject, topologyView, writeProject } from '@mdg-filius/doc'; +import { 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) }; + (window as unknown as { filiusDev?: unknown }).filiusDev = doc === null ? undefined : { view: () => topologyView(doc), sheet: (id: string) => deviceSheet(doc, id) }; }, [doc]); const t = (key: string) => translate(language, key); diff --git a/packages/editor/src/sheet/DeviceSheet.tsx b/packages/editor/src/sheet/DeviceSheet.tsx new file mode 100644 index 0000000..3d3af1d --- /dev/null +++ b/packages/editor/src/sheet/DeviceSheet.tsx @@ -0,0 +1,229 @@ +// The device sheet (plan §7.1): the settings of the selected device, the +// fields of FILIUS' settings panel of its kind (JHostKonfiguration, +// JSwitchKonfiguration, JModemKonfiguration) in the panels' order. Java +// shows the panel when a device is chosen; so does the sheet, beside the +// surface or below it on a phone. Each change is one step of the person's +// undo. A router's and a home router's sheet name the device only, until +// their own panels follow (F4.5b). + +import { useEffect, useState } from 'react'; +import type * as Y from 'yjs'; +import { + deviceSheet, + renameDevice, + setDhcpClient, + setHostAddress, + setHostLabels, + setHostWireless, + setIpForwarding, + setModemPort, + setSwitchCloud, + setSwitchRetention, + setSwitchSsid, + type CheckedField, + type DeviceSheet as Sheet, + type HostSheet, + type ModemSheet, + type SwitchSheet, + type WirelessError, +} from '@mdg-filius/doc'; +import { iconOf } from '../canvas/layout.js'; +import { ICON_URLS } from '../canvas/icons.js'; +import { useTranslate } from '../i18n/i18n.js'; +import { SheetCheck, SheetField } from './SheetField.js'; +import './sheet.css'; + +/** The device's sheet, read anew after every change of the document. */ +function useDeviceSheet(doc: Y.Doc, id: string): Sheet | undefined { + const [sheet, setSheet] = useState(() => deviceSheet(doc, id)); + useEffect(() => { + setSheet(deviceSheet(doc, id)); + const update = () => setSheet(deviceSheet(doc, id)); + doc.on('update', update); + return () => doc.off('update', update); + }, [doc, id]); + return sheet; +} + +export interface DeviceSheetProps { + readonly doc: Y.Doc; + readonly id: string; + /** What the surface writes under the icon (a host's address or MAC address, when it shows them). */ + readonly label: string; + readonly origin: object; + /** After each change: ends the undo step. */ + readonly onChanged: () => void; + readonly onMessage: (message: string) => void; + readonly onClose: () => void; +} + +interface Change { + readonly doc: Y.Doc; + readonly id: string; + readonly origin: object; + /** Runs a change and ends the undo step. */ + readonly run: (change: () => T) => T; +} + +export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onClose }: DeviceSheetProps) { + const t = useTranslate(); + const sheet = useDeviceSheet(doc, id); + if (sheet === undefined) return null; + const change: Change = { + doc, + id, + origin, + run: (each) => { + const result = each(); + onChanged(); + return result; + }, + }; + const title = t(`device.${sheet.kind}`); + return ( + + ); +} + +function NameField({ name, change, shown, disabled = false }: { readonly name: string; readonly change: Change; readonly shown?: string; readonly disabled?: boolean }) { + const t = useTranslate(); + return ( + change.run(() => (renameDevice(change.doc, change.id, text, change.origin), { value: text }))} + /> + ); +} + +function HostFields({ sheet, label, change, onMessage }: { readonly sheet: HostSheet; readonly label: string; readonly change: Change; readonly onMessage: (message: string) => void }) { + const t = useTranslate(); + const address = + (field: 'ipAddress' | 'subnetMask' | 'defaultGateway' | 'dnsServer') => + (text: string): CheckedField => + change.run(() => setHostAddress(change.doc, change.id, field, text, change.origin)); + // Java's list offers the switches' networks; an SSID none of them has shows as "please choose" (JHostKonfiguration.updateSsid). + const chosen = sheet.networks.includes(sheet.ssid) ? sheet.ssid : ''; + const wireless = (on: boolean, ssid: string) => { + const result = change.run(() => setHostWireless(change.doc, change.id, on, ssid, crypto.randomUUID(), change.origin)); + if ('error' in result) onMessage(wirelessMessage(t, result.error)); + }; + const labelled = sheet.labels.ipAddress || sheet.labels.macAddress; + return ( + <> + {/* Java's name field shows what the surface writes, and is closed while that is the address. */} + + ({ invalid: true })} /> + + + + +
+ + +
+ + change.run(() => setHostLabels(change.doc, change.id, { ...sheet.labels, ipAddress: on }, change.origin))} + /> + change.run(() => setHostLabels(change.doc, change.id, { ...sheet.labels, macAddress: on }, change.origin))} + /> + change.run(() => setIpForwarding(change.doc, change.id, on, change.origin))} /> + change.run(() => setDhcpClient(change.doc, change.id, on, change.origin))} /> + + ); +} + +function wirelessMessage(t: ReturnType, error: WirelessError): string { + return error === 'cablePlugged' ? t('sheet.cablePlugged') : t(`cable.${error}`); +} + +function SwitchFields({ sheet, change }: { readonly sheet: SwitchSheet; readonly change: Change }) { + const t = useTranslate(); + return ( + <> + + change.run(() => setSwitchSsid(change.doc, change.id, text, change.origin))} /> + change.run(() => setSwitchCloud(change.doc, change.id, on, change.origin))} /> + change.run(() => setSwitchRetention(change.doc, change.id, text, change.origin))} + /> + + ); +} + +function ModemFields({ sheet, change }: { readonly sheet: ModemSheet; readonly change: Change }) { + const t = useTranslate(); + return ( + <> + + change.run(() => setModemPort(change.doc, change.id, text, change.origin))} + /> + + ); +} diff --git a/packages/editor/src/sheet/SheetField.tsx b/packages/editor/src/sheet/SheetField.tsx new file mode 100644 index 0000000..573b1c4 --- /dev/null +++ b/packages/editor/src/sheet/SheetField.tsx @@ -0,0 +1,110 @@ +// A text field of the device sheet. Java takes a field's text when it loses +// the focus or Enter is pressed, and colours a text its rule refuses red +// while it is typed. A refused text stays here, marked, with what the device +// keeps meanwhile, instead of being replaced without a word as Java does; +// Escape gives it up. A value the device already holds that the rule +// refuses (from a file, or written by someone else) is marked, never mended +// (plan I6). + +import { useId, useState, type KeyboardEvent } from 'react'; +import { checkField, type CheckedField, type FieldRule } from '@mdg-filius/doc'; +import { useTranslate } from '../i18n/i18n.js'; + +export interface SheetFieldProps { + readonly label: string; + /** What the device holds. */ + readonly value: string; + readonly rule: FieldRule; + /** Gives the text to the device; says whether it was taken. */ + readonly onCommit: (text: string) => CheckedField; + readonly disabled?: boolean; + readonly readOnly?: boolean; + /** A name for the browser tests and the label's input mode. */ + readonly name: string; + readonly inputMode?: 'text' | 'decimal' | 'numeric'; +} + +export function SheetField({ label, value, rule, onCommit, disabled = false, readOnly = false, name, inputMode = 'text' }: SheetFieldProps) { + const t = useTranslate(); + const id = useId(); + // What is typed and not yet taken; null shows the device's value. + const [draft, setDraft] = useState(null); + const [refused, setRefused] = useState(false); + const shown = draft ?? value; + const marked = !readOnly && 'invalid' in checkField(rule, shown); + + const commit = () => { + if (draft === null || readOnly) return; + if (draft === value) { + setDraft(null); + setRefused(false); + return; + } + const result = onCommit(draft); + if ('value' in result) { + setDraft(null); + setRefused(false); + } else { + setRefused(true); + } + }; + + const key = (event: KeyboardEvent) => { + if (event.key === 'Enter') commit(); + if (event.key === 'Escape') { + setDraft(null); + setRefused(false); + } + }; + + const hint = refused && draft !== null ? t('sheet.notTaken', { value: value === '' ? t('sheet.empty') : value }) : draft === null && marked ? t('sheet.invalidValue') : null; + return ( + + ); +} + +export function SheetCheck({ + label, + checked, + onChange, + disabled = false, + name, +}: { + readonly label: string; + readonly checked: boolean; + readonly onChange: (checked: boolean) => void; + readonly disabled?: boolean; + readonly name: string; +}) { + return ( + + ); +} diff --git a/packages/editor/src/sheet/sheet.css b/packages/editor/src/sheet/sheet.css new file mode 100644 index 0000000..9895f5a --- /dev/null +++ b/packages/editor/src/sheet/sheet.css @@ -0,0 +1,150 @@ +/* The device sheet beside the surface on a wide screen, below it on a phone + * (the dashboard's phone width, 639 px): the surface shrinks instead of + * being covered, so the device stays in sight. */ +.editor-work { + flex: 1; + min-width: 0; + display: flex; +} + +.editor-area { + position: relative; + flex: 1; + min-width: 0; + min-height: 0; +} + +.device-sheet { + width: 340px; + flex: none; + overflow-y: auto; + box-sizing: border-box; + padding: 12px 16px 24px; + border-left: 1px solid var(--border-default); + background: var(--surface-card); + color: var(--text-primary); +} + +@media (max-width: 639px) { + .editor-work { + flex-direction: column; + } + + .device-sheet { + width: auto; + max-height: 50%; + border-left: none; + border-top: 1px solid var(--border-default); + border-radius: 16px 16px 0 0; + box-shadow: 0 -4px 16px color-mix(in srgb, var(--shadow-color) 15%, transparent); + } +} + +.device-sheet header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 8px; +} + +.device-sheet header img { + width: 32px; + height: 32px; + object-fit: contain; +} + +.device-sheet h2 { + flex: 1; + margin: 0; + font-size: 17px; +} + +.device-sheet .sheet-close { + min-width: 44px; + min-height: 44px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--text-secondary); + font: inherit; + font-size: 22px; + cursor: pointer; +} + +.sheet-field { + display: grid; + gap: 4px; + margin: 10px 0; +} + +.sheet-field > span { + font-size: 13px; + color: var(--text-secondary); +} + +.sheet-field input, +.sheet-field select { + min-height: 44px; + box-sizing: border-box; + padding: 0 10px; + border: 1px solid var(--border-strong); + border-radius: 8px; + background: var(--surface-raised); + color: var(--text-primary); + /* 16 px: Safari on the iPad zooms into smaller fields. */ + font: inherit; + font-size: 16px; +} + +.sheet-field input:focus-visible, +.sheet-field select:focus-visible { + outline: 2px solid var(--border-focus); + outline-offset: 1px; +} + +.sheet-field input:disabled, +.sheet-field select:disabled { + color: var(--text-muted); + background: var(--surface-sunken); +} + +.sheet-field input[readonly] { + background: var(--surface-sunken); +} + +/* Java colours a text its rule refuses red (EingabenUeberpruefung.farbeFalsch). */ +.sheet-field input[aria-invalid='true'] { + color: var(--danger); + border-color: var(--danger); +} + +.sheet-field .sheet-hint { + font-size: 13px; + color: var(--danger); +} + +.sheet-check { + display: flex; + align-items: center; + gap: 10px; + min-height: 44px; + cursor: pointer; +} + +.sheet-check input { + width: 20px; + height: 20px; + margin: 0; + accent-color: var(--accent); +} + +.sheet-check.disabled { + color: var(--text-muted); + cursor: default; +} + +.sheet-group { + margin: 8px 0; + padding: 0; + border: none; +} diff --git a/packages/editor/test/i18n.test.ts b/packages/editor/test/i18n.test.ts index b265cb8..3d6a947 100644 --- a/packages/editor/test/i18n.test.ts +++ b/packages/editor/test/i18n.test.ts @@ -39,11 +39,30 @@ const FROM_JAVA: Readonly> = { 'cable.lan': 'guievents_msg25', 'cable.wirelessNotPossible': 'guievents_msg26', 'router.title': 'guicontainer_msg2', + 'sheet.name': 'jhostkonfiguration_msg1', + 'sheet.ipAddress': 'jhostkonfiguration_msg3', + 'sheet.subnetMask': 'jhostkonfiguration_msg4', + 'sheet.defaultGateway': 'jhostkonfiguration_msg5', + 'sheet.dnsServer': 'jhostkonfiguration_msg6', + 'sheet.dhcpClient': 'jhostkonfiguration_msg7', + 'sheet.macAddress': 'jhostkonfiguration_msg9', + 'sheet.ipAsName': 'jhostkonfiguration_msg10', + 'sheet.ipForwarding': 'jhostkonfiguration_msg11', + 'sheet.macAsName': 'jhostkonfiguration_msg12', + 'sheet.wireless': 'jhostkonfiguration_msg13', + 'sheet.cable': 'jhostkonfiguration_msg14', + 'sheet.chooseNetwork': 'jhostkonfiguration_msg16', + 'sheet.cloud': 'jswitchkonfiguration_msg3', + 'sheet.switchSsid': 'jswitchkonfiguration_msg4', + 'sheet.retention': 'jswitchkonfiguration_msg5', + 'sheet.port': 'jmodemkonfiguration_msg4', }; -/** Java's words with an error of Java's mended: the prompt says "Vermittlungsrechnerns" (guicontainer_msg1). */ +/** Java's words, mended: the prompt says "Vermittlungsrechnerns" (guicontainer_msg1). */ const FROM_JAVA_MENDED: Readonly string]>> = { 'router.prompt': ['guicontainer_msg1', (java) => java.replace(' \\n ', ' ').replace('Vermittlungsrechnerns', 'Vermittlungsrechners')], + // A label above its list, without the colon of Java's label beside it. + 'sheet.network': ['jhostkonfiguration_msg15', (java) => java.replace(/:$/, '')], }; describe('the catalogs', () => { diff --git a/packages/fls/src/exportProject.ts b/packages/fls/src/exportProject.ts index 2b27e7e..cd00487 100644 --- a/packages/fls/src/exportProject.ts +++ b/packages/fls/src/exportProject.ts @@ -118,7 +118,7 @@ import { } from '@mdg-filius/core'; import { writeFlsArchive } from './archive.js'; import { EMAIL_CLIENT_FILE, EMAIL_SERVER_FILE, type JavaAddressEntry, type JavaContact, type JavaEmail } from './javaEmail.js'; -import { PROGRAM_OF_CLASS } from './importProject.js'; +import { modemPort, PROGRAM_OF_CLASS } from './importProject.js'; import type { FlsApplicationClass, FlsDhcpServer, FlsFileTree, FlsFirewall, FlsOwnApplication } from './systemSoftware.js'; import type { FlsDocumentationItem, FlsInterface, FlsLink, FlsNodeClass } from './topology.js'; import { writeTopologyXml, type ApplicationToWrite, type NodeToWrite, type SystemToWrite, type TopologyToWrite } from './topologyWriter.js'; @@ -245,15 +245,6 @@ function firstOfEachPair(nodes: readonly TopologyNode[], project: ProjectToExpor return serving; } -const JAVA_MODEM_PAIR = /^java-modem-port-([0-9]{1,5})$/; -const DEFAULT_MODEM_PORT = 12_345; - -function modemPort(pairIdentifier: string | undefined): number { - const match = JAVA_MODEM_PAIR.exec(pairIdentifier ?? ''); - const port = match === null ? DEFAULT_MODEM_PORT : Number(match[1]); - return port >= 1 && port <= 65_535 ? port : DEFAULT_MODEM_PORT; -} - type Warn = (message: string) => void; function exportSystem(node: TopologyNode, project: ProjectToExport, servingModems: ReadonlySet, warn: Warn): SystemToWrite { diff --git a/packages/fls/src/importProject.ts b/packages/fls/src/importProject.ts index 5881f87..dc9bf23 100644 --- a/packages/fls/src/importProject.ts +++ b/packages/fls/src/importProject.ts @@ -274,6 +274,21 @@ export function openProjectArchive(archive: Uint8Array, newId: () => Id): Import return importProject(readTopology(decodeJavaBeans(contents.configurationXml)), newId, contents.supplementalEntries); } +const JAVA_MODEM_PAIR = /^java-modem-port-([0-9]{1,5})$/; +export const DEFAULT_MODEM_PORT = 12_345; + +/** The pair of Java's modems on a port: two modems on one port belong together. */ +export function modemPair(port: number): string { + return `java-modem-port-${port}`; +} + +/** The port a modem pair's identifier names, 12345 when it names none. */ +export function modemPort(pairIdentifier: string | undefined): number { + const match = JAVA_MODEM_PAIR.exec(pairIdentifier ?? ''); + const port = match === null ? DEFAULT_MODEM_PORT : Number(match[1]); + return port >= 1 && port <= 65_535 ? port : DEFAULT_MODEM_PORT; +} + function hostLabelPolicy(node: FlsNode): HostLabelPolicy { if (node.useIpAsName && node.useMacAsName) return 'ipAndMAC'; if (node.useIpAsName) return 'ipAddress'; @@ -568,17 +583,17 @@ function registerServiceConfigurations( } if (node.kind === 'remoteLink') { const modem = source.system.modem; - let port = modem?.port ?? 12_345; + let port = modem?.port ?? DEFAULT_MODEM_PORT; if (port < 1 || port > 65_535) { - warnings.push(`${where}: modem port ${port} is invalid, used 12345`); - port = 12_345; + warnings.push(`${where}: modem port ${port} is invalid, used ${DEFAULT_MODEM_PORT}`); + port = DEFAULT_MODEM_PORT; } if (modem !== undefined && modem.mode !== 1 && modem.mode !== 2) { warnings.push(`${where}: modem mode ${modem.mode} unknown, treated as client`); } // Java pairs a server and a client modem over TCP on this port. system.remoteLinkConfigurations.set(node.id, { - pairIdentifier: `java-modem-port-${port}`, + pairIdentifier: modemPair(port), latencyMilliseconds: DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS, isEnabled: true, }); diff --git a/tools/java-oracle/README.md b/tools/java-oracle/README.md index 617d6c0..7c6329e 100644 --- a/tools/java-oracle/README.md +++ b/tools/java-oracle/README.md @@ -1095,3 +1095,37 @@ Gemessen (03.10.2026): `msg26` (drahtlos nicht möglich), `msg12` (bereits verbunden). * Ein Wettlauf im Swing-Thread wirft beim Speichern gelegentlich eine Ausnahme; die Datei wird trotzdem vollständig geschrieben. + +## Das Geräte-Blatt (F4.5a) + +`SheetRun` tippt die Texte aus `packages/doc/test/sheet.cases.txt` in +Filius' eigene Einstellungsfelder (`JHostKonfiguration`, +`JSwitchKonfiguration`, `JModemKonfiguration`, per Reflection), ruft wie +beim Verlassen eines Felds `aenderungenAnnehmen` und hält je Fall fest, ob +das Feld beim Tippen rot war, was das Gerät danach hält und was das Feld +dann zeigt (`packages/doc/test/sheet.java.json`). Jeder Fall beginnt mit +einem neuen Gerät; ein Rechner hat Gateway 192.168.0.254 und DNS +192.168.0.253, ein Switch die SSID „Start“, damit sichtbar wird, was ein +Fall stehen lässt. Dazu drei Abläufe der Kästchen. + +Gemessen (03.10.2026): + +* Markieren (beim Tippen: das Muster auf den Text) und Übernehmen (beim + Verlassen: `IP.ipCheck`, dann das Muster auf dessen Ergebnis) sind zwei + Urteile, die sich widersprechen. Rot, aber übernommen: „10..0.0.1“, + „+10.0.0.1“, „10.0.0.1.“, „١٠.0.0.1“, „-0.0.0.1“ (als 0.0.0.1), + „255..255.0.0“, „192.168..1.1“, Haltezeit „-5“ (−5000 ms) und „٣“. + Schwarz, aber verworfen: „.0.0.1“, Haltezeit „“ und + „99999999999999999999“. +* Gateway und DNS: Nur Leerzeichen gelten als leer (rot, aber übernommen); + ein ungültiger Text lässt den alten Wert stehen. +* Ein verworfener Text verschwindet ohne Meldung: Das Feld zeigt danach, + was das Gerät hält. +* Name: jeder Text, auch leer und mit Leerzeichen. SSID: druckbares ASCII, + mindestens ein Zeichen, Leerzeichen eingeschlossen. +* Modem: Der Port wird nie markiert und jede `int` übernommen (0, 65536, + −1, „080“ als 80); nur Text, der keine Zahl ist, bleibt ohne Wirkung. +* „MAC-Adresse als Name verwenden“, dann ein anderes Feld übernommen: Der + Name wird zur MAC-Adresse und bleibt es nach dem Abschalten (bei der + IP-Adresse nicht). +* „DHCP zur Konfiguration verwenden“ schaltet den eigenen DHCP-Server ab. diff --git a/tools/java-oracle/run.sh b/tools/java-oracle/run.sh index 70ed260..279eaa1 100755 --- a/tools/java-oracle/run.sh +++ b/tools/java-oracle/run.sh @@ -21,7 +21,7 @@ if [ ! -f "$upstream/target/filius.jar" ]; then fi classpath="$upstream/target/filius.jar:$upstream/target/lib/*" mkdir -p "$here/.build" -javac -encoding UTF-8 -d "$here/.build" -cp "$classpath" "$here/src/FlsOracle.java" "$here/src/GetterDefaults.java" "$here/src/IpCheckCases.java" "$here/src/DnsHostsCases.java" "$here/src/EmailFileCases.java" "$here/src/BeansDump.java" "$here/src/Base64Cases.java" "$here/src/ScenarioNetwork.java" "$here/src/DhcpRun.java" "$here/src/TcpRun.java" "$here/src/DnsRun.java" "$here/src/ArpRun.java" "$here/src/RipRun.java" "$here/src/NatRun.java" "$here/src/WlanRun.java" "$here/src/ExampleRun.java" "$here/src/EchoRun.java" "$here/src/WebRun.java" "$here/src/RouterWebRun.java" "$here/src/KonfigCases.java" "$here/src/EmailRun.java" "$here/src/MailCases.java" "$here/src/EmailClientRun.java" "$here/src/TerminalCases.java" "$here/src/TerminalRun.java" "$here/src/FileCases.java" "$here/src/GnutellaRun.java" "$here/src/NewDeviceRun.java" +javac -encoding UTF-8 -d "$here/.build" -cp "$classpath" "$here/src/FlsOracle.java" "$here/src/GetterDefaults.java" "$here/src/IpCheckCases.java" "$here/src/DnsHostsCases.java" "$here/src/EmailFileCases.java" "$here/src/BeansDump.java" "$here/src/Base64Cases.java" "$here/src/ScenarioNetwork.java" "$here/src/DhcpRun.java" "$here/src/TcpRun.java" "$here/src/DnsRun.java" "$here/src/ArpRun.java" "$here/src/RipRun.java" "$here/src/NatRun.java" "$here/src/WlanRun.java" "$here/src/ExampleRun.java" "$here/src/EchoRun.java" "$here/src/WebRun.java" "$here/src/RouterWebRun.java" "$here/src/KonfigCases.java" "$here/src/EmailRun.java" "$here/src/MailCases.java" "$here/src/EmailClientRun.java" "$here/src/TerminalCases.java" "$here/src/TerminalRun.java" "$here/src/FileCases.java" "$here/src/GnutellaRun.java" "$here/src/NewDeviceRun.java" "$here/src/SheetRun.java" # FILIUS writes its settings below $HOME; keep them out of the real one. mkdir -p "$here/.build/home" # Removed first: a crashed run must not leave the previous result standing. @@ -328,6 +328,15 @@ rm -f "$output" HOME="$here/.build/home" xvfb-run -a java -cp "$classpath:$here/.build" NewDeviceRun "$output" 2>&1 | grep -v 'logback\|^$' || true test -s "$output" +# Text typed into FILIUS' settings panels of a host, a switch and a modem and +# taken as they take it (SheetRun): what the editor's device sheet marks and +# keeps (packages/doc/test/sheet.test.ts). +output="$root/packages/doc/test/sheet.java.json" +rm -f "$output" +HOME="$here/.build/home" xvfb-run -a java -cp "$classpath:$here/.build" SheetRun \ + "$root/packages/doc/test/sheet.cases.txt" "$output" 2>&1 | grep -v 'logback\|^$' || true +test -s "$output" + # FILIUS' own examples as Java FILIUS runs them, opened and started as FILIUS # does (ExampleRun): the acceptance of F2 (packages/fls/test/examplesJava.test.ts) # and the classics of F3 (web, email, gnutella; packages/fls/test/classicsJava.test.ts). diff --git a/tools/java-oracle/src/SheetRun.java b/tools/java-oracle/src/SheetRun.java new file mode 100644 index 0000000..ba1538e --- /dev/null +++ b/tools/java-oracle/src/SheetRun.java @@ -0,0 +1,199 @@ +// Types into FILIUS' own settings panels (JHostKonfiguration, +// JSwitchKonfiguration, JModemKonfiguration) and lets them take the text as +// they do when a field loses the focus (aenderungenAnnehmen), then writes, +// for each case, whether the panel marked the text red while typing, what the +// device holds afterwards and what the field shows then (updateAttribute): +// the reference for the editor's device sheet (packages/doc/test/sheet.test.ts). +// Each case starts from a new device ("start": the field's value then). The cases file is ASCII, with +// Java-style unicode escapes (backslash, u, four hex digits) for anything +// else, one case a line: +// +// the text may be empty +// +// Fields: host name, ip, mask, gateway, dns; switch name, ssid, retention; +// modem name, port. After the cases, a few flows of the check boxes +// ("flows"): a host's name while it shows its address or MAC address, and +// DHCP switched on while its own DHCP server runs. +// +// Usage: java -cp filius.jar:lib/*:classes SheetRun + +import filius.gui.GUIContainer; +import filius.gui.netzwerksicht.JKonfiguration; +import filius.hardware.knoten.Host; +import filius.hardware.knoten.Modem; +import filius.hardware.knoten.Rechner; +import filius.hardware.knoten.Switch; +import filius.rahmenprogramm.EingabenUeberpruefung; +import filius.software.system.Betriebssystem; +import filius.software.system.ModemFirmware; +import filius.software.system.SwitchFirmware; + +import java.io.FileOutputStream; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import javax.swing.JCheckBox; +import javax.swing.JTextField; + +public class SheetRun { + static Object get(Object panel, String name) throws Exception { + Field field = panel.getClass().getDeclaredField(name); + field.setAccessible(true); + return field.get(panel); + } + + static void call(Object panel, String name) throws Exception { + Method method = panel.getClass().getDeclaredMethod(name); + method.setAccessible(true); + method.invoke(panel); + } + + /** The panel's field and its check (the method its key listener calls), per device and field. */ + static final Map FIELDS = new LinkedHashMap<>(); + static { + FIELDS.put("host name", new String[] { "name", null }); + FIELDS.put("host ip", new String[] { "ipAdresse", "checkIpAddress" }); + FIELDS.put("host mask", new String[] { "netzmaske", "checkNetmask" }); + FIELDS.put("host gateway", new String[] { "gateway", "checkGatewayAddress" }); + FIELDS.put("host dns", new String[] { "dns", "checkDnsAddress" }); + FIELDS.put("switch name", new String[] { "name", null }); + FIELDS.put("switch ssid", new String[] { "ssid", "checkSSID" }); + FIELDS.put("switch retention", new String[] { "retentionTime", "checkRetentionTime" }); + FIELDS.put("modem name", new String[] { "name", null }); + FIELDS.put("modem port", new String[] { "tfPort", null }); + } + + static String stored(Object device, String key) { + if (device instanceof Host) { + Host host = (Host) device; + Betriebssystem os = (Betriebssystem) host.getSystemSoftware(); + switch (key) { + case "host name": return host.getName(); + case "host ip": return os.primaryIPAdresse(); + case "host mask": return os.primarySubnetMask(); + case "host gateway": return os.getStandardGateway(); + case "host dns": return os.getDNSServer(); + } + } else if (device instanceof Switch) { + Switch sw = (Switch) device; + SwitchFirmware firmware = (SwitchFirmware) sw.getSystemSoftware(); + switch (key) { + case "switch name": return sw.getName(); + case "switch ssid": return firmware.getSSID(); + case "switch retention": return Long.toString(firmware.getRetentionTime()); + } + } else if (device instanceof Modem) { + Modem modem = (Modem) device; + switch (key) { + case "modem name": return modem.getName(); + case "modem port": return Integer.toString(((ModemFirmware) modem.getSystemSoftware()).getPort()); + } + } + throw new IllegalArgumentException(key); + } + + /** A new device; a host with a gateway and a DNS server, a switch with a known SSID, so that what a case leaves alone shows. */ + static Object device(String kind) { + switch (kind) { + case "host": { + Rechner host = new Rechner(); + Betriebssystem os = (Betriebssystem) host.getSystemSoftware(); + os.setStandardGateway("192.168.0.254"); + os.setDNSServer("192.168.0.253"); + return host; + } + case "switch": { + Switch sw = new Switch(); + ((SwitchFirmware) sw.getSystemSoftware()).setSSID("Start"); + return sw; + } + case "modem": return new Modem(); + } + throw new IllegalArgumentException(kind); + } + + public static void main(String[] args) throws Exception { + // FILIUS' window keeps the JVM alive: end it on any failure too. + try { + run(args); + } catch (Throwable error) { + error.printStackTrace(); + System.exit(1); + } + System.exit(0); + } + + static void run(String[] args) throws Exception { + GUIContainer.getGUIContainer(); + List cases = new ArrayList<>(); + for (String line : Files.readAllLines(Path.of(args[0]), StandardCharsets.US_ASCII)) { + if (line.isEmpty() || line.startsWith("#")) continue; + String[] words = line.split(" ", 3); + String key = words[0] + " " + words[1]; + String text = words.length < 3 ? "" : IpCheckCases.unescape(words[2]); + String[] names = FIELDS.get(key); + if (names == null) throw new IllegalArgumentException(line); + Object device = device(words[0]); + String start = stored(device, key); + JKonfiguration panel = JKonfiguration.getInstance((filius.hardware.Hardware) device); + JTextField field = (JTextField) get(panel, names[0]); + field.setText(text); + boolean marked = false; + if (names[1] != null) { + call(panel, names[1]); + marked = field.getForeground().equals(EingabenUeberpruefung.farbeFalsch); + } + call(panel, "aenderungenAnnehmen"); + panel.updateAttribute(); + Map result = new LinkedHashMap<>(); + result.put("device", words[0]); + result.put("field", words[1]); + result.put("input", text); + result.put("start", start); + result.put("marked", marked); + result.put("stored", stored(device, key)); + result.put("shown", field.getText()); + cases.add(result); + } + + Map flows = new LinkedHashMap<>(); + // The MAC address as name, then another field taken: what becomes of the name? + for (String box : new String[] { "useMacAsName", "useIpAsName" }) { + Rechner host = new Rechner(); + host.setName("PC-A"); + JKonfiguration panel = JKonfiguration.getInstance(host); + ((JCheckBox) get(panel, box)).setSelected(true); + call(panel, "aendereAnzeigeName"); + ((JTextField) get(panel, "gateway")).setText("192.168.0.1"); + call(panel, "aenderungenAnnehmen"); + ((JCheckBox) get(panel, box)).setSelected(false); + call(panel, "aendereAnzeigeName"); + flows.put(box + "ThenOff", host.getName()); + } + // DHCP for the configuration while the host's own DHCP server is on. + { + Rechner host = new Rechner(); + Betriebssystem os = (Betriebssystem) host.getSystemSoftware(); + os.getDHCPServer().setAktiv(true); + JKonfiguration panel = JKonfiguration.getInstance(host); + ((JCheckBox) get(panel, "dhcp")).setSelected(true); + call(panel, "aenderungenAnnehmen"); + flows.put("dhcpClientStopsServer", !os.getDHCPServer().isAktiv()); + flows.put("dhcpClient", os.isDHCPKonfiguration()); + } + + Map output = new LinkedHashMap<>(); + output.put("cases", cases); + output.put("flows", flows); + try (FileOutputStream out = new FileOutputStream(args[1])) { + out.write((FlsOracle.Json.write(output, "") + "\n").getBytes(StandardCharsets.UTF_8)); + } + System.out.println("sheet cases: " + cases.size()); + } +}