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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 11:22:28 +00:00
co-authored by Claude Opus 5.5
parent 806df6baca
commit 9b7ec2fc72
25 changed files with 2667 additions and 73 deletions
+7
View File
@@ -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) | | `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/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) | | `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/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 | | `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) | | `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/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/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/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/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/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 | | `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/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 | | `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) | | `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 ## tools/swift-oracle
+2 -2
View File
@@ -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/core` | Simulationskern: Modell, Ereignis-Warteschlange, Protokolle, Anwendungen — ohne DOM, ohne Node |
| `packages/fls` | `.fls` lesen, schreiben, Unbekanntes bewahren | | `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/protocol` | die eine Nachrichtenliste zwischen Editor und Simulationsraum (`/sim`) |
| `packages/sim` | Simulationsraum (für Server F und den Web Worker) und `LocalSimHost` | | `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)* | | `server` | Filius-Dienst auf Server F *(folgt)* |
Plan und Entscheidungen: `knowledge/plans/FILIUS_WEB_PLAN.md` im Dashboard. Plan und Entscheidungen: `knowledge/plans/FILIUS_WEB_PLAN.md` im Dashboard.
+3 -2
View File
@@ -18,6 +18,7 @@
import * as Y from 'yjs'; import * as Y from 'yjs';
import { import {
DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS,
defaultSwitchConfiguration, defaultSwitchConfiguration,
firewallConfiguration, firewallConfiguration,
interfaceKey, interfaceKey,
@@ -26,7 +27,7 @@ import {
type NodeKind, type NodeKind,
type TopologyNode, type TopologyNode,
} from '@mdg-filius/core'; } 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'; 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). */ /** 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()); project.firewallConfigurationsByNodeId.set(device.id, firewallConfiguration());
break; break;
case 'remoteLink': 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; break;
} }
return project; return project;
+69
View File
@@ -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;
}
}
+3
View File
@@ -1,7 +1,10 @@
// Public surface of the shared document. // Public surface of the shared document.
export * from './commands.js'; export * from './commands.js';
export * from './fields.js';
export * from './janitor.js'; export * from './janitor.js';
export * from './projectDoc.js'; export * from './projectDoc.js';
export * from './settings.js';
export * from './sheet.js';
export * from './snapshot.js'; export * from './snapshot.js';
export * from './values.js'; export * from './values.js';
export * from './view.js'; export * from './view.js';
+198
View File
@@ -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<Y.Map<unknown>>(ROOTS.nodes);
const links = (doc: Y.Doc) => doc.getMap<Y.Map<unknown>>(ROOTS.links);
const settingsOf = (node: Y.Map<unknown>) => node.get('settings') as Y.Map<unknown>;
const kindOf = (node: Y.Map<unknown>) => 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<unknown>, key: string, fields: Readonly<Record<string, string | number | boolean>>): Y.Map<unknown> {
const settings = settingsOf(node);
let map = settings.get(key) as Y.Map<unknown> | undefined;
if (map === undefined) {
map = toY(fields) as Y.Map<unknown>;
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<unknown>, 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<unknown> | undefined)?.set('isActive', false);
}, origin);
}
const wirelessOf = (node: Y.Map<unknown>) => (settingsOf(node).get('wireless') as Y.Map<unknown> | 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<unknown> | 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<unknown> | 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,
);
}
+112
View File
@@ -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<NodeKind, 'pc' | 'notebook' | 'networkSwitch' | 'remoteLink'>;
readonly name: string;
}
export type DeviceSheet = HostSheet | SwitchSheet | ModemSheet | NameSheet;
const settingsOf = (node: Y.Map<unknown>) => node.get('settings') as Y.Map<unknown>;
const setting = (node: Y.Map<unknown>, key: string) => settingsOf(node).get(key) as Y.Map<unknown> | 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<Y.Map<unknown>>(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<Y.Map<unknown>>).get(0);
const portId = port?.get('id') as string | undefined;
const plugged = [...doc.getMap<Y.Map<unknown>>(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<string>();
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 };
}
}
+80
View File
@@ -0,0 +1,80 @@
# Text typed into FILIUS' settings panels (tools/java-oracle SheetRun):
# <device> <field> <text>, 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
+703
View File
@@ -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
}
}
+415
View File
@@ -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<Record<string, FieldRule>> = {
'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<Y.Map<unknown>>(ROOTS.nodes).get(pc)!.get('settings') as Y.Map<unknown>;
const server = new Y.Map<unknown>();
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<Y.Map<unknown>>(ROOTS.nodes).get(first)!;
(full.get('ports') as Y.Array<Y.Map<unknown>>).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<Y.Map<unknown>>(ROOTS.nodes).get(hub)!.get('settings') as Y.Map<Y.Map<unknown>>).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');
});
});
+6 -1
View File
@@ -6,7 +6,7 @@ import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import type { Page } from '@playwright/test'; import type { Page } from '@playwright/test';
import { build, preview, type PreviewServer } from 'vite'; 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'); const config = join(dirname(fileURLToPath(import.meta.url)), '../vite.config.ts');
@@ -30,6 +30,11 @@ export function drawing(page: Page): Promise<TopologyView> {
return page.evaluate(() => (window as unknown as { filiusDev: { view: () => TopologyView } }).filiusDev.view()); return page.evaluate(() => (window as unknown as { filiusDev: { view: () => TopologyView } }).filiusDev.view());
} }
/** What the device sheet reads of a device. */
export function sheetOf(page: Page, id: string): Promise<DeviceSheet | undefined> {
return page.evaluate((each) => (window as unknown as { filiusDev: { sheet: (id: string) => DeviceSheet | undefined } }).filiusDev.sheet(each), id);
}
/** A device's button as data-box says it (surface pixels). */ /** 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 }> { 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]; const [x, y, width, height] = (await page.locator(`g.device[data-node="${id}"]`).getAttribute('data-box'))!.split(' ').map(Number) as [number, number, number, number];
+185
View File
@@ -0,0 +1,185 @@
// The device sheet in Chromium (Playwright): it opens with the device that
// is chosen, shows Java's fields, takes a field when it is left, marks and
// keeps a text its rule refuses, moves a notebook into a wireless network —
// beside the surface on a wide screen, below it on a phone.
import { expect, test, type Page } from '@playwright/test';
import type { HostSheet } from '@mdg-filius/doc';
import { drawing, onDevice, openEditor, serveEditor, sheetOf } from './editorPage.js';
let server: { readonly base: string; readonly close: () => Promise<void> };
test.beforeAll(async () => {
server = await serveEditor();
});
test.afterAll(async () => {
await server.close();
});
async function deviceNamed(page: Page, name: string): Promise<string> {
return (await drawing(page)).nodes.find((node) => node.name === name)!.id;
}
async function choose(page: Page, id: string): Promise<void> {
const at = await onDevice(page, id);
await page.mouse.click(at.x, at.y);
}
const sheet = (page: Page) => page.getByRole('complementary', { name: 'Einstellungen' });
const field = (page: Page, name: string) => sheet(page).locator(`input[name="${name}"]`);
test("opens with the chosen device, shows Java's fields beside the surface, and closes letting go of it", async ({ page }) => {
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
await expect(sheet(page)).toHaveCount(0);
const id = await deviceNamed(page, 'Notebook 1.10');
await choose(page, id);
await expect(sheet(page)).toBeVisible();
await expect(sheet(page).getByRole('heading')).toHaveText('Notebook');
const values = (await sheetOf(page, id)) as HostSheet;
await expect(field(page, 'name')).toHaveValue('Notebook 1.10');
await expect(field(page, 'macAddress')).toHaveValue(values.macAddress);
await expect(field(page, 'ipAddress')).toHaveValue(values.ipAddress);
await expect(field(page, 'subnetMask')).toHaveValue(values.subnetMask);
await expect(field(page, 'defaultGateway')).toHaveValue(values.defaultGateway);
await expect(sheet(page).getByLabel('Kabelgebunden (LAN)')).toBeChecked();
await expect(sheet(page).getByLabel('DHCP zur Konfiguration verwenden')).not.toBeChecked();
const surface = (await page.locator('svg.filius-canvas').boundingBox())!;
const aside = (await sheet(page).boundingBox())!;
expect(aside.x).toBeGreaterThanOrEqual(surface.x + surface.width - 1);
expect(aside.width).toBe(340);
await sheet(page).getByRole('button', { name: 'Schließen' }).click();
await expect(sheet(page)).toHaveCount(0);
await expect(page.locator(`g.device[data-node="${id}"]`)).not.toHaveAttribute('data-selected', 'true');
});
test('marks a refused address while it is typed, keeps it with what still holds, and takes a mended one', async ({ page }) => {
await openEditor(page, server.base, 'new');
await page.locator('[data-palette="pc"]').click();
await expect(sheet(page)).toBeVisible();
const id = (await drawing(page)).nodes[0]!.id;
const ip = field(page, 'ipAddress');
await ip.fill('10.0.0.256');
await expect(ip).toHaveAttribute('aria-invalid', 'true');
await field(page, 'subnetMask').focus();
await expect(sheet(page).getByRole('status')).toHaveText('Nicht übernommen – es gilt weiterhin 192.168.0.10.');
await expect(ip).toHaveValue('10.0.0.256');
expect(((await sheetOf(page, id)) as HostSheet).ipAddress).toBe('192.168.0.10');
await ip.fill('010.0.0.5');
await expect(ip).toHaveAttribute('aria-invalid', 'false');
await expect(sheet(page).getByRole('status')).toHaveCount(0);
await ip.press('Enter');
await expect(ip).toHaveValue('10.0.0.5');
expect(((await sheetOf(page, id)) as HostSheet).ipAddress).toBe('10.0.0.5');
// Escape gives a text up; the empty gateway is taken, the empty address is not.
await ip.fill('1.2.3');
await ip.press('Escape');
await expect(ip).toHaveValue('10.0.0.5');
await expect(sheet(page)).toBeVisible();
await field(page, 'defaultGateway').fill('');
await field(page, 'defaultGateway').press('Enter');
await ip.fill('');
await ip.press('Enter');
await expect(sheet(page).getByRole('status')).toHaveText('Nicht übernommen – es gilt weiterhin 10.0.0.5.');
});
test('takes a change back as one step, and shows the address as the name', async ({ page }) => {
await openEditor(page, server.base, 'new');
await page.locator('[data-palette="pc"]').click();
const id = (await drawing(page)).nodes[0]!.id;
await field(page, 'ipAddress').fill('10.0.0.9');
await field(page, 'ipAddress').press('Enter');
await sheet(page).getByLabel('IP-Adresse als Name verwenden').check();
await expect.poll(async () => (await drawing(page)).nodes[0]!.label).toBe('10.0.0.9');
await expect(field(page, 'name')).toBeDisabled();
await expect(field(page, 'name')).toHaveValue('10.0.0.9');
await page.getByRole('button', { name: 'Rückgängig' }).click();
await expect.poll(async () => (await drawing(page)).nodes[0]!.label).toBe('Rechner');
await expect(field(page, 'name')).toBeEnabled();
await page.getByRole('button', { name: 'Rückgängig' }).click();
await expect(field(page, 'ipAddress')).toHaveValue('192.168.0.10');
expect(((await sheetOf(page, id)) as HostSheet).ipAddress).toBe('192.168.0.10');
});
test('takes the text of one device when another is chosen, and shows the other', async ({ page }) => {
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
const first = await deviceNamed(page, 'Notebook 1.10');
const second = await deviceNamed(page, 'Notebook 1.11');
await choose(page, first);
await field(page, 'dnsServer').fill('192.168.1.53');
await choose(page, second);
await expect(sheet(page)).toHaveAttribute('data-device', second);
expect(((await sheetOf(page, first)) as HostSheet).dnsServer).toBe('192.168.1.53');
await expect(field(page, 'name')).toHaveValue('Notebook 1.11');
await expect(field(page, 'dnsServer')).toHaveValue(((await sheetOf(page, second)) as HostSheet).dnsServer);
});
test('offers no text of one device to the next, a refused one neither', async ({ page }) => {
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
const first = await deviceNamed(page, 'Notebook 1.10');
const second = await deviceNamed(page, 'Notebook 1.11');
await choose(page, first);
await field(page, 'ipAddress').fill('300.0.0.1');
await choose(page, second);
await expect(sheet(page)).toHaveAttribute('data-device', second);
await expect(field(page, 'ipAddress')).toHaveValue(((await sheetOf(page, second)) as HostSheet).ipAddress);
await expect(field(page, 'ipAddress')).toHaveAttribute('aria-invalid', 'false');
await expect(sheet(page).getByRole('status')).toHaveCount(0);
});
test('closes the address fields while DHCP configures the card, as Java does', async ({ page }) => {
await openEditor(page, server.base, 'new');
await page.locator('[data-palette="pc"]').click();
const fields = ['ipAddress', 'subnetMask', 'defaultGateway', 'dnsServer'];
await sheet(page).getByLabel('DHCP zur Konfiguration verwenden').check();
for (const name of fields) await expect(field(page, name)).toBeDisabled();
await expect(field(page, 'name')).toBeEnabled();
await sheet(page).getByLabel('DHCP zur Konfiguration verwenden').uncheck();
for (const name of fields) await expect(field(page, name)).toBeEnabled();
});
test("joins a notebook to a switch's wireless network, drawn as wireless", async ({ page }) => {
await openEditor(page, server.base, 'new');
await page.locator('[data-palette="networkSwitch"]').click();
const hub = (await drawing(page)).nodes[0]!.id;
await field(page, 'ssid').fill('Schule');
await field(page, 'ssid').press('Enter');
await sheet(page).getByLabel('als Wolke darstellen').check();
await expect.poll(async () => (await drawing(page)).nodes[0]!.cloud).toBe(true);
await page.locator('[data-palette="notebook"]').click();
await expect(sheet(page).getByRole('heading')).toHaveText('Notebook');
await expect(sheet(page).locator('select[name="ssid"]')).toBeDisabled();
await sheet(page).getByLabel('Drahtlos (WLAN)').check();
await sheet(page).locator('select[name="ssid"]').selectOption('Schule');
await expect.poll(async () => (await drawing(page)).links.map((link) => [link.targetNode, link.wireless])).toEqual([[hub, true]]);
await sheet(page).getByLabel('Kabelgebunden (LAN)').check();
await expect.poll(async () => (await drawing(page)).links).toEqual([]);
});
test.describe('on a phone', () => {
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });
test('opens below the surface with a tap, the device still in sight', async ({ page }) => {
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
const id = await deviceNamed(page, 'Notebook 1.10');
const at = await onDevice(page, id);
await page.touchscreen.tap(at.x, at.y);
await expect(sheet(page)).toBeVisible();
const surface = (await page.locator('svg.filius-canvas').boundingBox())!;
const aside = (await sheet(page).boundingBox())!;
expect(aside.y).toBeGreaterThanOrEqual(surface.y + surface.height - 1);
expect(aside.height).toBeLessThanOrEqual(844 / 2);
expect(surface.height).toBeGreaterThan(200);
const fieldBox = (await field(page, 'ipAddress').boundingBox())!;
expect(fieldBox.height).toBeGreaterThanOrEqual(44);
});
});
test('speaks English when asked', async ({ page }) => {
await openEditor(page, server.base, 'new', '&lang=en');
await page.locator('[data-palette="networkSwitch"]').click();
const aside = page.getByRole('complementary', { name: 'Settings' });
await expect(aside.getByText('Wi-Fi Name (SSID)')).toBeVisible();
await expect(aside.getByText('Retention Time SAT Entries (Seconds)')).toBeVisible();
await expect(aside.getByLabel('Display as Cloud')).not.toBeChecked();
});
+60 -50
View File
@@ -1,5 +1,6 @@
// The editor on a document: the palette, the surface, the tools of the design // 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 // 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 // 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 { ContextMenu, type MenuItem } from './design/ContextMenu.js';
import { Palette, type PaletteKind } from './design/Palette.js'; import { Palette, type PaletteKind } from './design/Palette.js';
import { RouterDialog } from './design/RouterDialog.js'; import { RouterDialog } from './design/RouterDialog.js';
import { DeviceSheet } from './sheet/DeviceSheet.js';
import { useTranslate } from './i18n/i18n.js'; import { useTranslate } from './i18n/i18n.js';
/** The document's drawing, read anew after every change. */ /** The document's drawing, read anew after every change. */
@@ -152,6 +154,8 @@ export function Editor({ doc }: { readonly doc: Y.Doc }) {
return items; 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. // The keyboard: undo and redo, delete what is selected, Escape lets go.
useEffect(() => { useEffect(() => {
const key = (event: KeyboardEvent) => { const key = (event: KeyboardEvent) => {
@@ -186,58 +190,64 @@ export function Editor({ doc }: { readonly doc: Y.Doc }) {
}} }}
onDevice={onDevice} onDevice={onDevice}
/> />
<div style={{ position: 'relative', flex: 1, minWidth: 0 }}> <div className="editor-work">
<Canvas <div className="editor-area">
ref={canvas} <Canvas
view={view} ref={canvas}
tool={cableTool ? 'cable' : 'select'} view={view}
selection={selection} tool={cableTool ? 'cable' : 'select'}
cableFrom={cableFrom} selection={selection}
onSelect={setSelection} cableFrom={cableFrom}
onMoveDevice={(id, x, y) => moveDevice(doc, id, x, y, origin)} onSelect={setSelection}
onMoveEnd={() => manager?.stopCapturing()} onMoveDevice={(id, x, y) => moveDevice(doc, id, x, y, origin)}
onCableStart={setCableFrom} onMoveEnd={() => manager?.stopCapturing()}
onCable={onCable} onCableStart={setCableFrom}
onMenu={(target, at) => { onCable={onCable}
setSelection(target); onMenu={(target, at) => {
setMenu({ target, at }); setSelection(target);
}} setMenu({ target, at });
/>
<div role="toolbar" style={{ position: 'absolute', left: 12, top: 12, display: 'flex', gap: 6 }}>
<button type="button" style={toolButton} disabled={!canUndo} onClick={() => manager?.undo()} aria-label={t('design.undo')} title={t('design.undo')}>
↶
</button>
<button type="button" style={toolButton} disabled={!canRedo} onClick={() => manager?.redo()} aria-label={t('design.redo')} title={t('design.redo')}>
↷
</button>
</div>
{menu === null ? null : <ContextMenu at={menu.at} items={menuItems(menu.target)} onClose={() => setMenu(null)} />}
{routerAt === null ? null : (
<RouterDialog
onChoose={(interfaces) => {
place('router', routerAt, interfaces);
setRouterAt(null);
}} }}
/> />
)} <div role="toolbar" style={{ position: 'absolute', left: 12, top: 12, display: 'flex', gap: 6 }}>
{message === null ? null : ( <button type="button" style={toolButton} disabled={!canUndo} onClick={() => manager?.undo()} aria-label={t('design.undo')} title={t('design.undo')}>
<div ↶
role="alert" </button>
style={{ <button type="button" style={toolButton} disabled={!canRedo} onClick={() => manager?.redo()} aria-label={t('design.redo')} title={t('design.redo')}>
position: 'absolute', ↷
left: '50%', </button>
bottom: 72,
transform: 'translateX(-50%)',
padding: '10px 16px',
borderRadius: 8,
background: 'var(--danger-soft)',
color: 'var(--danger-on-soft)',
border: '1px solid var(--danger)',
maxWidth: '80%',
}}
>
{message}
</div> </div>
{menu === null ? null : <ContextMenu at={menu.at} items={menuItems(menu.target)} onClose={() => setMenu(null)} />}
{routerAt === null ? null : (
<RouterDialog
onChoose={(interfaces) => {
place('router', routerAt, interfaces);
setRouterAt(null);
}}
/>
)}
{message === null ? null : (
<div
role="alert"
style={{
position: 'absolute',
left: '50%',
bottom: 72,
transform: 'translateX(-50%)',
padding: '10px 16px',
borderRadius: 8,
background: 'var(--danger-soft)',
color: 'var(--danger-on-soft)',
border: '1px solid var(--danger)',
maxWidth: '80%',
}}
>
{message}
</div>
)}
</div>
{/* 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 : (
<DeviceSheet doc={doc} id={selected.id} label={selected.label} origin={origin} onChanged={() => manager?.stopCapturing()} onMessage={setMessage} onClose={() => setSelection(null)} />
)} )}
</div> </div>
</div> </div>
+25
View File
@@ -33,6 +33,31 @@
"router.cancel": "Abbrechen", "router.cancel": "Abbrechen",
"router.prompt": "Wählen Sie die Anzahl der Schnittstellen des Vermittlungsrechners aus!", "router.prompt": "Wählen Sie die Anzahl der Schnittstellen des Vermittlungsrechners aus!",
"router.title": "Anzahl Schnittstellen", "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.fit": "Alles zeigen",
"zoom.in": "Vergrößern", "zoom.in": "Vergrößern",
"zoom.level": "{percent} %", "zoom.level": "{percent} %",
+25
View File
@@ -33,6 +33,31 @@
"router.cancel": "Cancel", "router.cancel": "Cancel",
"router.prompt": "Choose number of NIC of the router!", "router.prompt": "Choose number of NIC of the router!",
"router.title": "Number of NIC", "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.fit": "Show all",
"zoom.in": "Zoom in", "zoom.in": "Zoom in",
"zoom.level": "{percent} %", "zoom.level": "{percent} %",
+2 -2
View File
@@ -7,7 +7,7 @@
import { StrictMode, useEffect, useState } from 'react'; import { StrictMode, useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import * as Y from 'yjs'; 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 { openProjectArchive } from '@mdg-filius/fls';
import { startBridge } from './bridge/dashboardBridge.js'; import { startBridge } from './bridge/dashboardBridge.js';
import { Editor } from './Editor.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). // The development page lets its browser tests read the document's drawing (never the editor embedded in the dashboard).
useEffect(() => { 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]); }, [doc]);
const t = (key: string) => translate(language, key); const t = (key: string) => translate(language, key);
+229
View File
@@ -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: <T>(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 (
<aside className="device-sheet" aria-label={t('sheet.title')} data-device={id}>
<header>
<img src={ICON_URLS[iconOf(sheet.kind, sheet.kind === 'networkSwitch' && sheet.cloud)]} alt="" />
<h2>{title}</h2>
<button type="button" className="sheet-close" onClick={onClose} aria-label={t('sheet.close')} title={t('sheet.close')}>
×
</button>
</header>
{/* A field per device: a text typed for one device is not offered to the next. */}
<div key={id}>
{sheet.kind === 'pc' || sheet.kind === 'notebook' ? (
<HostFields sheet={sheet} label={label} change={change} onMessage={onMessage} />
) : sheet.kind === 'networkSwitch' ? (
<SwitchFields sheet={sheet} change={change} />
) : sheet.kind === 'remoteLink' ? (
<ModemFields sheet={sheet} change={change} />
) : (
<NameField name={sheet.name} change={change} />
)}
</div>
</aside>
);
}
function NameField({ name, change, shown, disabled = false }: { readonly name: string; readonly change: Change; readonly shown?: string; readonly disabled?: boolean }) {
const t = useTranslate();
return (
<SheetField
name="name"
label={t('sheet.name')}
value={shown ?? name}
rule="name"
disabled={disabled}
onCommit={(text) => 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. */}
<NameField name={sheet.name} shown={labelled ? label : sheet.name} disabled={labelled} change={change} />
<SheetField name="macAddress" label={t('sheet.macAddress')} value={sheet.macAddress} rule="name" readOnly onCommit={() => ({ invalid: true })} />
<SheetField name="ipAddress" label={t('sheet.ipAddress')} value={sheet.ipAddress} rule="ipAddress" inputMode="decimal" disabled={sheet.dhcpClient} onCommit={address('ipAddress')} />
<SheetField name="subnetMask" label={t('sheet.subnetMask')} value={sheet.subnetMask} rule="subnetMask" inputMode="decimal" disabled={sheet.dhcpClient} onCommit={address('subnetMask')} />
<SheetField
name="defaultGateway"
label={t('sheet.defaultGateway')}
value={sheet.defaultGateway}
rule="ipAddressOrEmpty"
inputMode="decimal"
disabled={sheet.dhcpClient}
onCommit={address('defaultGateway')}
/>
<SheetField name="dnsServer" label={t('sheet.dnsServer')} value={sheet.dnsServer} rule="ipAddressOrEmpty" inputMode="decimal" disabled={sheet.dhcpClient} onCommit={address('dnsServer')} />
<fieldset className="sheet-group" role="radiogroup" aria-label={t('sheet.connection')}>
<label className="sheet-check">
<input type="radio" name="connection" value="cable" checked={!sheet.wireless} onChange={() => wireless(false, chosen)} />
{t('sheet.cable')}
</label>
<label className={sheet.wirelessPossible ? 'sheet-check' : 'sheet-check disabled'}>
<input type="radio" name="connection" value="wireless" checked={sheet.wireless} disabled={!sheet.wirelessPossible} onChange={() => wireless(true, chosen)} />
{t('sheet.wireless')}
</label>
</fieldset>
<label className="sheet-field">
<span>{t('sheet.network')}</span>
<select name="ssid" value={chosen} disabled={!sheet.wireless} onChange={(event) => wireless(sheet.wireless, event.target.value)}>
<option value="">{t('sheet.chooseNetwork')}</option>
{sheet.networks.map((network) => (
<option key={network} value={network}>
{network}
</option>
))}
</select>
</label>
<SheetCheck
name="ipAsName"
label={t('sheet.ipAsName')}
checked={sheet.labels.ipAddress}
onChange={(on) => change.run(() => setHostLabels(change.doc, change.id, { ...sheet.labels, ipAddress: on }, change.origin))}
/>
<SheetCheck
name="macAsName"
label={t('sheet.macAsName')}
checked={sheet.labels.macAddress}
onChange={(on) => change.run(() => setHostLabels(change.doc, change.id, { ...sheet.labels, macAddress: on }, change.origin))}
/>
<SheetCheck name="ipForwarding" label={t('sheet.ipForwarding')} checked={sheet.ipForwarding} onChange={(on) => change.run(() => setIpForwarding(change.doc, change.id, on, change.origin))} />
<SheetCheck name="dhcpClient" label={t('sheet.dhcpClient')} checked={sheet.dhcpClient} onChange={(on) => change.run(() => setDhcpClient(change.doc, change.id, on, change.origin))} />
</>
);
}
function wirelessMessage(t: ReturnType<typeof useTranslate>, 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 (
<>
<NameField name={sheet.name} change={change} />
<SheetField name="ssid" label={t('sheet.switchSsid')} value={sheet.ssid} rule="ssid" onCommit={(text) => change.run(() => setSwitchSsid(change.doc, change.id, text, change.origin))} />
<SheetCheck name="cloud" label={t('sheet.cloud')} checked={sheet.cloud} onChange={(on) => change.run(() => setSwitchCloud(change.doc, change.id, on, change.origin))} />
<SheetField
name="retention"
label={t('sheet.retention')}
value={String(sheet.retentionSeconds)}
rule="seconds"
inputMode="numeric"
onCommit={(text) => change.run(() => setSwitchRetention(change.doc, change.id, text, change.origin))}
/>
</>
);
}
function ModemFields({ sheet, change }: { readonly sheet: ModemSheet; readonly change: Change }) {
const t = useTranslate();
return (
<>
<NameField name={sheet.name} change={change} />
<SheetField
name="port"
label={t('sheet.port')}
value={String(sheet.port)}
rule="port"
inputMode="numeric"
onCommit={(text) => change.run(() => setModemPort(change.doc, change.id, text, change.origin))}
/>
</>
);
}
+110
View File
@@ -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<string | null>(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<HTMLInputElement>) => {
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 (
<label className="sheet-field" htmlFor={id}>
<span>{label}</span>
<input
id={id}
name={name}
value={shown}
disabled={disabled}
readOnly={readOnly}
inputMode={inputMode}
autoComplete="off"
spellCheck={false}
aria-invalid={marked}
aria-describedby={hint === null ? undefined : `${id}-hint`}
onChange={(event) => {
setDraft(event.target.value);
setRefused(false);
}}
onBlur={commit}
onKeyDown={key}
/>
{hint === null ? null : (
<span id={`${id}-hint`} className="sheet-hint" role="status">
{hint}
</span>
)}
</label>
);
}
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 (
<label className={disabled ? 'sheet-check disabled' : 'sheet-check'}>
<input type="checkbox" name={name} checked={checked} disabled={disabled} onChange={(event) => onChange(event.target.checked)} />
{label}
</label>
);
}
+150
View File
@@ -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;
}
+20 -1
View File
@@ -39,11 +39,30 @@ const FROM_JAVA: Readonly<Record<string, string>> = {
'cable.lan': 'guievents_msg25', 'cable.lan': 'guievents_msg25',
'cable.wirelessNotPossible': 'guievents_msg26', 'cable.wirelessNotPossible': 'guievents_msg26',
'router.title': 'guicontainer_msg2', '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<Record<string, readonly [string, (java: string) => string]>> = { const FROM_JAVA_MENDED: Readonly<Record<string, readonly [string, (java: string) => string]>> = {
'router.prompt': ['guicontainer_msg1', (java) => java.replace(' \\n ', ' ').replace('Vermittlungsrechnerns', 'Vermittlungsrechners')], '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', () => { describe('the catalogs', () => {
+1 -10
View File
@@ -118,7 +118,7 @@ import {
} from '@mdg-filius/core'; } from '@mdg-filius/core';
import { writeFlsArchive } from './archive.js'; import { writeFlsArchive } from './archive.js';
import { EMAIL_CLIENT_FILE, EMAIL_SERVER_FILE, type JavaAddressEntry, type JavaContact, type JavaEmail } from './javaEmail.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 { FlsApplicationClass, FlsDhcpServer, FlsFileTree, FlsFirewall, FlsOwnApplication } from './systemSoftware.js';
import type { FlsDocumentationItem, FlsInterface, FlsLink, FlsNodeClass } from './topology.js'; import type { FlsDocumentationItem, FlsInterface, FlsLink, FlsNodeClass } from './topology.js';
import { writeTopologyXml, type ApplicationToWrite, type NodeToWrite, type SystemToWrite, type TopologyToWrite } from './topologyWriter.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; 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; type Warn = (message: string) => void;
function exportSystem(node: TopologyNode, project: ProjectToExport, servingModems: ReadonlySet<Id>, warn: Warn): SystemToWrite { function exportSystem(node: TopologyNode, project: ProjectToExport, servingModems: ReadonlySet<Id>, warn: Warn): SystemToWrite {
+19 -4
View File
@@ -274,6 +274,21 @@ export function openProjectArchive(archive: Uint8Array, newId: () => Id): Import
return importProject(readTopology(decodeJavaBeans(contents.configurationXml)), newId, contents.supplementalEntries); 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 { function hostLabelPolicy(node: FlsNode): HostLabelPolicy {
if (node.useIpAsName && node.useMacAsName) return 'ipAndMAC'; if (node.useIpAsName && node.useMacAsName) return 'ipAndMAC';
if (node.useIpAsName) return 'ipAddress'; if (node.useIpAsName) return 'ipAddress';
@@ -568,17 +583,17 @@ function registerServiceConfigurations(
} }
if (node.kind === 'remoteLink') { if (node.kind === 'remoteLink') {
const modem = source.system.modem; const modem = source.system.modem;
let port = modem?.port ?? 12_345; let port = modem?.port ?? DEFAULT_MODEM_PORT;
if (port < 1 || port > 65_535) { if (port < 1 || port > 65_535) {
warnings.push(`${where}: modem port ${port} is invalid, used 12345`); warnings.push(`${where}: modem port ${port} is invalid, used ${DEFAULT_MODEM_PORT}`);
port = 12_345; port = DEFAULT_MODEM_PORT;
} }
if (modem !== undefined && modem.mode !== 1 && modem.mode !== 2) { if (modem !== undefined && modem.mode !== 1 && modem.mode !== 2) {
warnings.push(`${where}: modem mode ${modem.mode} unknown, treated as client`); warnings.push(`${where}: modem mode ${modem.mode} unknown, treated as client`);
} }
// Java pairs a server and a client modem over TCP on this port. // Java pairs a server and a client modem over TCP on this port.
system.remoteLinkConfigurations.set(node.id, { system.remoteLinkConfigurations.set(node.id, {
pairIdentifier: `java-modem-port-${port}`, pairIdentifier: modemPair(port),
latencyMilliseconds: DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS, latencyMilliseconds: DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS,
isEnabled: true, isEnabled: true,
}); });
+34
View File
@@ -1095,3 +1095,37 @@ Gemessen (03.10.2026):
`msg26` (drahtlos nicht möglich), `msg12` (bereits verbunden). `msg26` (drahtlos nicht möglich), `msg12` (bereits verbunden).
* Ein Wettlauf im Swing-Thread wirft beim Speichern gelegentlich eine * Ein Wettlauf im Swing-Thread wirft beim Speichern gelegentlich eine
Ausnahme; die Datei wird trotzdem vollständig geschrieben. 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.
+10 -1
View File
@@ -21,7 +21,7 @@ if [ ! -f "$upstream/target/filius.jar" ]; then
fi fi
classpath="$upstream/target/filius.jar:$upstream/target/lib/*" classpath="$upstream/target/filius.jar:$upstream/target/lib/*"
mkdir -p "$here/.build" 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. # FILIUS writes its settings below $HOME; keep them out of the real one.
mkdir -p "$here/.build/home" mkdir -p "$here/.build/home"
# Removed first: a crashed run must not leave the previous result standing. # 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 HOME="$here/.build/home" xvfb-run -a java -cp "$classpath:$here/.build" NewDeviceRun "$output" 2>&1 | grep -v 'logback\|^$' || true
test -s "$output" 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 # 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) # 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). # and the classics of F3 (web, email, gnutella; packages/fls/test/classicsJava.test.ts).
+199
View File
@@ -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:
//
// <host|switch|modem> <field> <text> 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 <cases txt> <output json>
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<String, String[]> 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<Object> 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<String, Object> 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<String, Object> 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<String, Object> 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());
}
}