feat: F4.7 the data exchange window, measured against Java's

One row per frame (its highest layer's), Java's detail tree, the table as
Java's text file, "Tabellen löschen" for every card (clearCapture in the
room), a tab per card with Java's title, Java's layer colours as tokens,
the chosen frame and autoscroll per tab. New oracle ExchangeWindowRun.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 09:12:38 +00:00
co-authored by Claude Opus 5.5
parent 6809a71300
commit 9252b942c4
26 changed files with 7692 additions and 33 deletions
+6
View File
@@ -20,6 +20,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
| `src/runtime/apps/personalFirewall.ts`, `src/runtime/engine.ts` (`personalFirewall`, `addFirewallPortRule`, `removeFirewallRule`, Firewall-Protokoll mit Zeit), `src/runtime/apps/routerPages.ts` (`portRuleAdded`, `portRuleRemoved`), `test/firewallWindow.test.ts`, `test/firewallWindow.java.json`, `test/scenarios/firewall-pc.scenario.json` | Java `GUIApplicationFirewallWindow` (`hinzuRegel`, `loescheRegel`, `ruleToVector`), `Firewall` (`addRule`, `deleteRule`), `FirewallRule` (gemessen mit `FirewallWindowRun`) | neu (F4.6g3): die Firewall eines Rechners, wie ihr Fenster sie ändert — eine Portregel hinzugefügt (TCP, UDP oder beide; alle Absender oder nur die der eigenen Netze; akzeptieren, hinten angefügt), eine Regel entfernt; beides steht im Protokoll der Firewall (Java schreibt es nur ins Fenster). Das Protokoll trägt jetzt die Zeit jeder Zeile. Gegen Javas Fenster geprüft, mit Ping, TCP-Verbindung und DNS-Frage aus dem eigenen und einem fremden Netz nach jedem Schritt. Abweichend von Java: ein Port ist 1 bis 65535, Leerzeichen drumherum erlaubt (Java nimmt jede Zahl: −1 öffnet jeden Port beider Protokolle, 0 und 70000 ergeben Regeln, die nie greifen; ein Leerzeichen davor lehnt Java still ab) | | `src/runtime/apps/personalFirewall.ts`, `src/runtime/engine.ts` (`personalFirewall`, `addFirewallPortRule`, `removeFirewallRule`, Firewall-Protokoll mit Zeit), `src/runtime/apps/routerPages.ts` (`portRuleAdded`, `portRuleRemoved`), `test/firewallWindow.test.ts`, `test/firewallWindow.java.json`, `test/scenarios/firewall-pc.scenario.json` | Java `GUIApplicationFirewallWindow` (`hinzuRegel`, `loescheRegel`, `ruleToVector`), `Firewall` (`addRule`, `deleteRule`), `FirewallRule` (gemessen mit `FirewallWindowRun`) | neu (F4.6g3): die Firewall eines Rechners, wie ihr Fenster sie ändert — eine Portregel hinzugefügt (TCP, UDP oder beide; alle Absender oder nur die der eigenen Netze; akzeptieren, hinten angefügt), eine Regel entfernt; beides steht im Protokoll der Firewall (Java schreibt es nur ins Fenster). Das Protokoll trägt jetzt die Zeit jeder Zeile. Gegen Javas Fenster geprüft, mit Ping, TCP-Verbindung und DNS-Frage aus dem eigenen und einem fremden Netz nach jedem Schritt. Abweichend von Java: ein Port ist 1 bis 65535, Leerzeichen drumherum erlaubt (Java nimmt jede Zahl: −1 öffnet jeden Port beider Protokolle, 0 und 70000 ergeben Regeln, die nie greifen; ein Leerzeichen davor lehnt Java still ab) |
| `src/runtime/apps/gnutella.ts` (`cancelSearch`, `setMaximumKnownPeers`, `fileRows`, `gnutellaResultRow`, `gnutellaPeerCountOfText`), `test/gnutellaWindow.test.ts`, `test/gnutellaWindow.java.json` | Java `PeerToPeerAnwendung` (`abbrechenSuche`, `setMaxTeilnehmerZahl`), `PeerToPeerClient`/`PeerToPeerClientMitarbeiter` (`beenden`), `GUIApplicationPeerToPeerAnwendungWindow` (`updateErgebnisTabelle`, `updateDateiTabelle`, `zahlPruefen`), gemessen mit `GnutellaWindowRun` | erweitert (F4.6g4): was Gnutellas Fenster vom Programm braucht — „Stopp!“ schließt jede Verbindung der eigenen Anfragen (Suchen, Beitreten, Herunterladen), die Treffer unterwegs sind verloren, weitergereichte fremde Anfragen bleiben; die Zahl der Teilnehmer gilt für die kommenden; die Dateien von `/peer2peer` (ohne Ordner) mit Typ; ein Ergebnis wie Javas Tabelle es zerlegt. Gegen Javas Fenster geprüft. Abweichend von Java: jede ganze Zahl ist eine Teilnehmerzahl (Javas Prüfung `musterNurZahl` nimmt nur eine Ziffer) | | `src/runtime/apps/gnutella.ts` (`cancelSearch`, `setMaximumKnownPeers`, `fileRows`, `gnutellaResultRow`, `gnutellaPeerCountOfText`), `test/gnutellaWindow.test.ts`, `test/gnutellaWindow.java.json` | Java `PeerToPeerAnwendung` (`abbrechenSuche`, `setMaxTeilnehmerZahl`), `PeerToPeerClient`/`PeerToPeerClientMitarbeiter` (`beenden`), `GUIApplicationPeerToPeerAnwendungWindow` (`updateErgebnisTabelle`, `updateDateiTabelle`, `zahlPruefen`), gemessen mit `GnutellaWindowRun` | erweitert (F4.6g4): was Gnutellas Fenster vom Programm braucht — „Stopp!“ schließt jede Verbindung der eigenen Anfragen (Suchen, Beitreten, Herunterladen), die Treffer unterwegs sind verloren, weitergereichte fremde Anfragen bleiben; die Zahl der Teilnehmer gilt für die kommenden; die Dateien von `/peer2peer` (ohne Ordner) mit Typ; ein Ergebnis wie Javas Tabelle es zerlegt. Gegen Javas Fenster geprüft. Abweichend von Java: jede ganze Zahl ist eine Teilnehmerzahl (Javas Prüfung `musterNurZahl` nimmt nur eine Ziffer) |
| `src/runtime/viewers.ts`, `src/runtime/captureRows.ts` (`javaFrameText`), `src/runtime/engine.ts` (`portNumber` der SAT, `clearDynamicNatEntries`), `src/runtime/capture.ts` (`clearDropped`), `test/viewers.test.ts`, `test/viewers-switch.java.json`, `test/viewers-nat.java.json` | Java `SwitchFirmware` (`holeSAT`, `loescheSAT`), `GatewayFirmware` (`holeNAT`, `createEintrag`, `loescheNAT`), `EthernetFrame.toString`, `Lauscher.getDroppedDataUnits`, `Verbindung.setDrop` (gemessen mit `ViewersRun`) | neu (F4.6g5): was die Anzeigen der laufenden Simulation zeigen — die Nummer des Switch-Anschlusses („Port 1“), die NAT-Tabelle in Javas Worten (ICMP „0 (Identifier: 1)“, die Portweiterleitung mit „*“ und ohne Zeit), „Tabelle zurücksetzen“ lässt die Portweiterleitung stehen, ein verworfener Frame in Javas Textform; die Liste der verworfenen Frames lässt sich leeren, ihre Nummern laufen weiter. Gegen Java geprüft. Abweichend von Java: die Switch-Tabelle ist nach Adressen geordnet (Javas nach seiner Hash-Tabelle) | | `src/runtime/viewers.ts`, `src/runtime/captureRows.ts` (`javaFrameText`), `src/runtime/engine.ts` (`portNumber` der SAT, `clearDynamicNatEntries`), `src/runtime/capture.ts` (`clearDropped`), `test/viewers.test.ts`, `test/viewers-switch.java.json`, `test/viewers-nat.java.json` | Java `SwitchFirmware` (`holeSAT`, `loescheSAT`), `GatewayFirmware` (`holeNAT`, `createEintrag`, `loescheNAT`), `EthernetFrame.toString`, `Lauscher.getDroppedDataUnits`, `Verbindung.setDrop` (gemessen mit `ViewersRun`) | neu (F4.6g5): was die Anzeigen der laufenden Simulation zeigen — die Nummer des Switch-Anschlusses („Port 1“), die NAT-Tabelle in Javas Worten (ICMP „0 (Identifier: 1)“, die Portweiterleitung mit „*“ und ohne Zeit), „Tabelle zurücksetzen“ lässt die Portweiterleitung stehen, ein verworfener Frame in Javas Textform; die Liste der verworfenen Frames lässt sich leeren, ihre Nummern laufen weiter. Gegen Java geprüft. Abweichend von Java: die Switch-Tabelle ist nach Adressen geordnet (Javas nach seiner Hash-Tabelle) |
| `src/runtime/captureWindow.ts`, `test/exchangeWindow.test.ts`, `test/exchangeWindow.java.json` | Java `AggregatedExchangeDialog`, `AggregatedExchangePanel`, `AggregatedMessageTable` (`update`, `writeToStream`, `prepareDataArrays`, `splitString`), `MessageDetailsPanel.update`, `Lauscher.reset` (gemessen mit `ExchangeWindowRun`) | neu (F4.7): das Datenaustausch-Fenster — je Frame die Zeile seiner höchsten Schicht (`exchangeRow`), seine Details wie Javas Baum (`captureDetailLines`: „Nr.: n / Zeit: t“, je Schicht Quelle, Ziel, Protokoll, Bemerkung, die der Anwendung mit ihrer Länge in „Bytes“, eine lange oder mehrzeilige darunter) und die Tabelle als Textdatei (`exchangeExportText`: Rahmen, Breiten 10/20/40, Bemerkung auf höchstens fünf Zeilen, „...“, CR LF). Gegen Javas Fenster geprüft, auch „Tabellen löschen“ (alle Karten leer, neu nummeriert). Abweichend von Java: eine leere Bemerkung wird leer exportiert (Java schreibt „null“) |
| `src/runtime/engine.ts` (Installation), `src/runtime/apps/installation.ts`, `test/installation.test.ts` | Java `InternetKnotenBetriebssystem.installAndStartAppIfAvailable/entferneSoftware`, `WebServer.erzeugeStandardVerzeichnis`, `PeerToPeerAnwendung`/`EmailServer`/`DNSServer.setSystemSoftware/starten`, `tmpl/webserver_index_*`, `gfx/allgemein/splashscreen-mini.png` | neu (F4.6c): was ein Programm bei der Installation in die Dateien legt (Javas Startseite je Sprache, Bild, `/peer2peer`, `/mailserver/konten.txt`, `/dns/hosts`); `installProgram`/`removeProgram` im laufenden Betrieb — Einstellungen und fehlende Dateien aus dem Schnappschuss des Dokuments, Programm sofort gestartet; entfernt wird ein Server abgeschaltet, die Dateien bleiben. Die Programmerzeugung beim Start läuft über dieselbe `startProgram` in der bisherigen Reihenfolge | | `src/runtime/engine.ts` (Installation), `src/runtime/apps/installation.ts`, `test/installation.test.ts` | Java `InternetKnotenBetriebssystem.installAndStartAppIfAvailable/entferneSoftware`, `WebServer.erzeugeStandardVerzeichnis`, `PeerToPeerAnwendung`/`EmailServer`/`DNSServer.setSystemSoftware/starten`, `tmpl/webserver_index_*`, `gfx/allgemein/splashscreen-mini.png` | neu (F4.6c): was ein Programm bei der Installation in die Dateien legt (Javas Startseite je Sprache, Bild, `/peer2peer`, `/mailserver/konten.txt`, `/dns/hosts`); `installProgram`/`removeProgram` im laufenden Betrieb — Einstellungen und fehlende Dateien aus dem Schnappschuss des Dokuments, Programm sofort gestartet; entfernt wird ein Server abgeschaltet, die Dateien bleiben. Die Programmerzeugung beim Start läuft über dieselbe `startProgram` in der bisherigen Reihenfolge |
| `src/runtime/apps/web.ts` (F4.6d), `test/web.test.ts` | Java `WebBrowser.retrieveImages`, `GUIApplicationWebBrowserWindow.hyperLinkListener`, `WebServer.changeVHostTable/saveVHosts/setUseVHost` (gemessen mit `BrowserWindowRun`) | erweitert (F4.6d): die Zeile einer Seite nennt Host und Pfad, die eines Bildes trägt seine Daten (für das Fenster); `pageReference` und `linkTarget` — relative Bilder und Links zählen vom Ordner der Seite (Java vom Wurzelordner des Servers; für eine Seite im Wurzelordner sind die Anfragen Javas), eine Marke (`#…`) und andere Schemata führen nirgends hin; der Webserver schaltet seine virtuellen Hosts um und schreibt ihre Tabelle, nur mit vollständigen Zeilen (Java schreibt jede Zeile, und beim Lesen rückt eine Zelle einer unvollständigen in die Zeile davor) | | `src/runtime/apps/web.ts` (F4.6d), `test/web.test.ts` | Java `WebBrowser.retrieveImages`, `GUIApplicationWebBrowserWindow.hyperLinkListener`, `WebServer.changeVHostTable/saveVHosts/setUseVHost` (gemessen mit `BrowserWindowRun`) | erweitert (F4.6d): die Zeile einer Seite nennt Host und Pfad, die eines Bildes trägt seine Daten (für das Fenster); `pageReference` und `linkTarget` — relative Bilder und Links zählen vom Ordner der Seite (Java vom Wurzelordner des Servers; für eine Seite im Wurzelordner sind die Anfragen Javas), eine Marke (`#…`) und andere Schemata führen nirgends hin; der Webserver schaltet seine virtuellen Hosts um und schreibt ihre Tabelle, nur mit vollständigen Zeilen (Java schreibt jede Zeile, und beim Lesen rückt eine Zelle einer unvollständigen in die Zeile davor) |
| `src/runtime/apps/emailClient.ts`, `src/runtime/apps/emailServer.ts`, `src/runtime/emailAccountsFile.ts`, `src/runtime/engine.ts` (F4.6e) | Java `GUIApplicationEmailAnwendungWindow.kontoSpeichern/emailLoeschen`, `EmailAnwendung.setzeKonto/speichern/removeReceivedMail/removeSentMail`, `GUIApplicationEmailServerWindow`, `EmailServer.setMailDomain/benutzerHinzufuegen/kontoLoeschen` (gemessen mit `MailWindowRun`) | erweitert (F4.6e): das E-Mail-Programm setzt sein Konto (`accountOfForm` mit Javas Mustern für Ports und Benutzernamen, `splitName` wie Javas Dialog: letztes Wort Nachname) und schreibt es nach `/konten.txt` (`clientAccountFileText`, jetzt im Kern statt im Export), löscht eine Mail eines Ordners, prüft ein Formular wie Javas Fenster (`mailFormProblem`) und sendet mit dem Konto, mit dem die Mail geschrieben wurde; der Server setzt seine Domain (nicht leer, Javas Muster), legt ein Konto an (einen vorhandenen Namen in jeder Schreibweise nicht — Javas Fenster meldet es als angelegt) und entfernt eines, sein Startknopf schreibt die eigene Zeile des Fensters; die neue Domain steht sofort in Schnappschuss und Kontendatei (Java erst bei der nächsten Kontenänderung) | | `src/runtime/apps/emailClient.ts`, `src/runtime/apps/emailServer.ts`, `src/runtime/emailAccountsFile.ts`, `src/runtime/engine.ts` (F4.6e) | Java `GUIApplicationEmailAnwendungWindow.kontoSpeichern/emailLoeschen`, `EmailAnwendung.setzeKonto/speichern/removeReceivedMail/removeSentMail`, `GUIApplicationEmailServerWindow`, `EmailServer.setMailDomain/benutzerHinzufuegen/kontoLoeschen` (gemessen mit `MailWindowRun`) | erweitert (F4.6e): das E-Mail-Programm setzt sein Konto (`accountOfForm` mit Javas Mustern für Ports und Benutzernamen, `splitName` wie Javas Dialog: letztes Wort Nachname) und schreibt es nach `/konten.txt` (`clientAccountFileText`, jetzt im Kern statt im Export), löscht eine Mail eines Ordners, prüft ein Formular wie Javas Fenster (`mailFormProblem`) und sendet mit dem Konto, mit dem die Mail geschrieben wurde; der Server setzt seine Domain (nicht leer, Javas Muster), legt ein Konto an (einen vorhandenen Namen in jeder Schreibweise nicht — Javas Fenster meldet es als angelegt) und entfernt eines, sein Startknopf schreibt die eigene Zeile des Fensters; die neue Domain steht sofort in Schnappschuss und Kontendatei (Java erst bei der nächsten Kontenänderung) |
@@ -146,6 +147,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
| `src/settings.ts`, `src/sheet.ts`, `src/commands.ts` (Verbindungen) | Java `JConnectionsDialog` (`swapConnection`, `removeConnection`, Knöpfe „+“/„-“), `Vermittlungsrechner.hinzuAnschluss/removeNic`, `NetzwerkInterface()`, `InternetKnotenBetriebssystem.setStandardGateway` | erweitert (F4.5e): eine Karte nach der letzten anfügen (192.168.0.10/24, eigene MAC aus der Port-Kennung, bis acht), die letzte samt Kabel entfernen, die Kabel zweier Karten tauschen oder eines umstecken (dieselben Kabel, dieselben Geräte; zwei freie Karten: nichts), Javas Klicklogik (`connectionsClick`: markieren, abwählen, tauschen); das Blatt nennt die Adresse der Gegenstelle (Rechner, Router; Switch und Modem ohne). Abweichend von Java: Java entfernt auch die letzte Karte — ein Router ohne Karte behält kein Gateway (Java legt es in die Karten, gemessen), und keine Datei könnte es halten; hier bleibt eine Karte (`ROUTER_INTERFACES.fewest`) | | `src/settings.ts`, `src/sheet.ts`, `src/commands.ts` (Verbindungen) | Java `JConnectionsDialog` (`swapConnection`, `removeConnection`, Knöpfe „+“/„-“), `Vermittlungsrechner.hinzuAnschluss/removeNic`, `NetzwerkInterface()`, `InternetKnotenBetriebssystem.setStandardGateway` | erweitert (F4.5e): eine Karte nach der letzten anfügen (192.168.0.10/24, eigene MAC aus der Port-Kennung, bis acht), die letzte samt Kabel entfernen, die Kabel zweier Karten tauschen oder eines umstecken (dieselben Kabel, dieselben Geräte; zwei freie Karten: nichts), Javas Klicklogik (`connectionsClick`: markieren, abwählen, tauschen); das Blatt nennt die Adresse der Gegenstelle (Rechner, Router; Switch und Modem ohne). Abweichend von Java: Java entfernt auch die letzte Karte — ein Router ohne Karte behält kein Gateway (Java legt es in die Karten, gemessen), und keine Datei könnte es halten; hier bleibt eine Karte (`ROUTER_INTERFACES.fewest`) |
| `src/view.ts` (`nodeLabel`) | Java `Knoten.holeAnzeigeName` | herausgelöst (F4.5b): die Beschriftung eines Geräts, auch für „Verbunden mit …“ | | `src/view.ts` (`nodeLabel`) | Java `Knoten.holeAnzeigeName` | herausgelöst (F4.5b): die Beschriftung eines Geräts, auch für „Verbunden mit …“ |
| `src/view.ts` (`desktopView`) | Java `GUIDesktopWindow.updateTitle`, `GUIDesktopPanel.updateAppPane` | neu (F4.6a): was der Desktop eines Rechners vom Dokument braucht — Art, Beschriftung, ob sie die Adresse ist (Javas Titel lässt die Adresse dann weg), installierte Programme | | `src/view.ts` (`desktopView`) | Java `GUIDesktopWindow.updateTitle`, `GUIDesktopPanel.updateAppPane` | neu (F4.6a): was der Desktop eines Rechners vom Dokument braucht — Art, Beschriftung, ob sie die Adresse ist (Javas Titel lässt die Adresse dann weg), installierte Programme |
| `src/view.ts` (`captureCards`, `cardAddress`) | Java `GUIEvents.kontextMenueAktionsmodus` (je Karte „Datenaustausch anzeigen (Adresse)“), `AggregatedExchangePanel.updateTabTitle` | neu (F4.7): die Karten eines Geräts in der Reihenfolge seiner Ports mit Adresse und dem Titel ihres Reiters — die Adresse, wo die Beschriftung sie ist, sonst Beschriftung, „ - “ und Adresse; Switch und Modem haben keine. `cardAddress` zog aus `sheet.ts` hierher |
| `src/software.ts`, `test/install.test.ts`, `test/fixtures/installed-programs.fls` | Java `GUIInstallationsDialog.aenderungenSpeichern` (gemessen mit `InstallRun`), `config/Desktop_*.txt` | neu (F4.6c): `setSoftware` — installieren (aus, Javas Voreinstellungen, fehlende Dateien; DNS-Einträge, E-Mail-Konten und das Konto des E-Mail-Programms aus vorhandenen Dateien) und entfernen (mit Einstellungen, Dateien bleiben) in einem Schritt, in der Reihenfolge von Javas Liste der Sprache (`SOFTWARE_ORDER`). Gegen Javas gespeicherte Installation geprüft; benannt: Javas Gnutella würfelt 3–5 Teilnehmer, hier 3 wie in einer Datei ohne Angabe | | `src/software.ts`, `test/install.test.ts`, `test/fixtures/installed-programs.fls` | Java `GUIInstallationsDialog.aenderungenSpeichern` (gemessen mit `InstallRun`), `config/Desktop_*.txt` | neu (F4.6c): `setSoftware` — installieren (aus, Javas Voreinstellungen, fehlende Dateien; DNS-Einträge, E-Mail-Konten und das Konto des E-Mail-Programms aus vorhandenen Dateien) und entfernen (mit Einstellungen, Dateien bleiben) in einem Schritt, in der Reihenfolge von Javas Liste der Sprache (`SOFTWARE_ORDER`). Gegen Javas gespeicherte Installation geprüft; benannt: Javas Gnutella würfelt 3–5 Teilnehmer, hier 3 wie in einer Datei ohne Angabe |
| `src/simulation.ts` | Plan §6.1 (`origin: 'sim'`); Java `SzenarioVerwaltung.speichern` nach einem Lauf (gemessen mit `SaveAfterRunRun`) | neu (F4.6f1): `writeRunState` schreibt, was ein Lauf von jedem Knoten hinterlässt, und nur, was sich unterscheidet — Dateien (neue hinter die anderen, geänderte an ihrem Platz, ein Text nur an der geänderten Stelle, verschwundene entfernt), ob Programme laufen, Einstellungen, die der Knoten hat (Feldreihenfolge egal); zweimal geschrieben ändert es nichts; `SIM_ORIGIN` landet in keinem Rückgängig-Stapel | | `src/simulation.ts` | Plan §6.1 (`origin: 'sim'`); Java `SzenarioVerwaltung.speichern` nach einem Lauf (gemessen mit `SaveAfterRunRun`) | neu (F4.6f1): `writeRunState` schreibt, was ein Lauf von jedem Knoten hinterlässt, und nur, was sich unterscheidet — Dateien (neue hinter die anderen, geänderte an ihrem Platz, ein Text nur an der geänderten Stelle, verschwundene entfernt), ob Programme laufen, Einstellungen, die der Knoten hat (Feldreihenfolge egal); zweimal geschrieben ändert es nichts; `SIM_ORIGIN` landet in keinem Rückgängig-Stapel |
| `src/files.ts`, `src/simulation.ts`, `test/files.test.ts` | Plan §7.3 (Texteditor auf `Y.Text`) | neu (F4.6f2): die Dateien eines Geräts im Dokument — als Dateisystem des Kerns gelesen (`nodeFiles`), über die Operationen des Kerns geändert und nur mit dem Geänderten zurückgeschrieben (`changeFiles`), der `Y.Text` einer Textdatei in jeder Schreibweise (`fileText`), und die Dateien des Dokuments in das Dateisystem einer laufenden Engine, nur was sich unterscheidet, nach ihrer Schreibweise, und nach einem Wechsel zwischen Datei und Ordner alles neu in der Reihenfolge des Dokuments (`readFilesInto`, duldsam wie `fileSystemOf`); `writeFiles` und `replaceText` aus `simulation.ts` hierher gezogen | | `src/files.ts`, `src/simulation.ts`, `test/files.test.ts` | Plan §7.3 (Texteditor auf `Y.Text`) | neu (F4.6f2): die Dateien eines Geräts im Dokument — als Dateisystem des Kerns gelesen (`nodeFiles`), über die Operationen des Kerns geändert und nur mit dem Geänderten zurückgeschrieben (`changeFiles`), der `Y.Text` einer Textdatei in jeder Schreibweise (`fileText`), und die Dateien des Dokuments in das Dateisystem einer laufenden Engine, nur was sich unterscheidet, nach ihrer Schreibweise, und nach einem Wechsel zwischen Datei und Ordner alles neu in der Reihenfolge des Dokuments (`readFilesInto`, duldsam wie `fileSystemOf`); `writeFiles` und `replaceText` aus `simulation.ts` hierher gezogen |
@@ -170,6 +172,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
| `src/firewall.ts`, `src/sim.ts` (`firewall`) | Java `GUIApplicationFirewallWindow` (gemessen mit `FirewallWindowRun`) | neu (F4.6g3): der Bildschirmstand der Firewall eines Rechners — die drei Kästchen und die Regeln, wie die Tabelle sie zeigt (mit dem ganzen Text einer Regel, die das Fenster nicht machen kann); das Protokoll kommt als Zeilen des Fensters | | `src/firewall.ts`, `src/sim.ts` (`firewall`) | Java `GUIApplicationFirewallWindow` (gemessen mit `FirewallWindowRun`) | neu (F4.6g3): der Bildschirmstand der Firewall eines Rechners — die drei Kästchen und die Regeln, wie die Tabelle sie zeigt (mit dem ganzen Text einer Regel, die das Fenster nicht machen kann); das Protokoll kommt als Zeilen des Fensters |
| `src/gnutella.ts` | Java `GUIApplicationPeerToPeerAnwendungWindow` (gemessen mit `GnutellaWindowRun`) | neu (F4.6g4): der Bildschirmstand von Gnutella — die bekannten Teilnehmer, die Ergebnisse wie die Tabelle sie zeigt, die Dateien von `/peer2peer`, die Zahl der Teilnehmer | | `src/gnutella.ts` | Java `GUIApplicationPeerToPeerAnwendungWindow` (gemessen mit `GnutellaWindowRun`) | neu (F4.6g4): der Bildschirmstand von Gnutella — die bekannten Teilnehmer, die Ergebnisse wie die Tabelle sie zeigt, die Dateien von `/peer2peer`, die Zahl der Teilnehmer |
| `src/viewers.ts`, `src/sim.ts` (`sat`, `nat`, `drop`, `dropped`, `clock`) | Java `SatViewer`, `NatViewer`, `SimulationControl` (gemessen mit `ViewersRun`) | neu (F4.6g5): die Tabellen eines Switches und eines Heimrouters als Bildschirmstände; die Nachricht `drop` (Javas „Pakete verwerfen“ gedrückt oder losgelassen), das Thema `dropped` mit den verworfenen Frames; der Zustand trägt `clock`, die Uhrzeit, für die die Zeit 0 der Simulation steht | | `src/viewers.ts`, `src/sim.ts` (`sat`, `nat`, `drop`, `dropped`, `clock`) | Java `SatViewer`, `NatViewer`, `SimulationControl` (gemessen mit `ViewersRun`) | neu (F4.6g5): die Tabellen eines Switches und eines Heimrouters als Bildschirmstände; die Nachricht `drop` (Javas „Pakete verwerfen“ gedrückt oder losgelassen), das Thema `dropped` mit den verworfenen Frames; der Zustand trägt `clock`, die Uhrzeit, für die die Zeit 0 der Simulation steht |
| `src/sim.ts` (`clearCapture`) | Java `AggregatedMessageTable` („Tabellen löschen“, `Lauscher.reset`) | neu (F4.7): die Nachricht `clearCapture` |
## packages/sim ## packages/sim
@@ -191,6 +194,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
| `src/room.ts` (Firewall), `test/firewallWindow.test.ts` | Java `GUIApplicationFirewallWindow` | erweitert (F4.6g3): der Raum nimmt `setActive`, `setIcmpAllowed`, `setFilterUdp`, `addRule`, `removeRule` an, schickt den Bildschirmstand und das Protokoll der Firewall als Zeilen (über Stopps behalten, wie die Engine es hält); zurück ins Projekt geht die Firewall wie schon bei Routern | | `src/room.ts` (Firewall), `test/firewallWindow.test.ts` | Java `GUIApplicationFirewallWindow` | erweitert (F4.6g3): der Raum nimmt `setActive`, `setIcmpAllowed`, `setFilterUdp`, `addRule`, `removeRule` an, schickt den Bildschirmstand und das Protokoll der Firewall als Zeilen (über Stopps behalten, wie die Engine es hält); zurück ins Projekt geht die Firewall wie schon bei Routern |
| `src/room.ts` (Gnutella), `test/gnutellaWindow.test.ts`, `test/room.test.ts` | Java `GUIApplicationPeerToPeerAnwendungWindow` | erweitert (F4.6g4): der Raum nimmt `join`, `leave`, `search`, `cancelSearch`, `clearResults`, `download`, `setMaximumPeers` an, schickt den Bildschirmstand (ohne Zeilen; Javas Fenster hat keine) und schreibt nach dem Lauf die Zahl der Teilnehmer zurück; eine heruntergeladene Datei steht sofort im Dokument. Der alte Raumtest nahm Gnutella als Programm ohne Aktionen und prüft dort jetzt ein nicht installiertes Programm | | `src/room.ts` (Gnutella), `test/gnutellaWindow.test.ts`, `test/room.test.ts` | Java `GUIApplicationPeerToPeerAnwendungWindow` | erweitert (F4.6g4): der Raum nimmt `join`, `leave`, `search`, `cancelSearch`, `clearResults`, `download`, `setMaximumPeers` an, schickt den Bildschirmstand (ohne Zeilen; Javas Fenster hat keine) und schreibt nach dem Lauf die Zahl der Teilnehmer zurück; eine heruntergeladene Datei steht sofort im Dokument. Der alte Raumtest nahm Gnutella als Programm ohne Aktionen und prüft dort jetzt ein nicht installiertes Programm |
| `src/room.ts` (Anzeigen, „Pakete verwerfen“), `test/viewers.test.ts`, `test/room.test.ts` | Java `SatViewer`, `NatViewer`, `SimulationControl`, `Verbindung.setDrop` | erweitert (F4.6g5): der Raum schickt die Tabellen eines Switches und eines Heimrouters (ohne Zeilen) und nimmt ihr `reset`; jedes Kabel verwirft, solange irgendein Client „Pakete verwerfen“ hält (ein Client, der geht oder beim Stopp noch hält, zählt nicht mehr); die verworfenen Frames gehen an, wer sie beobachtet, mit jedem Start neu begonnen wie Javas Liste | | `src/room.ts` (Anzeigen, „Pakete verwerfen“), `test/viewers.test.ts`, `test/room.test.ts` | Java `SatViewer`, `NatViewer`, `SimulationControl`, `Verbindung.setDrop` | erweitert (F4.6g5): der Raum schickt die Tabellen eines Switches und eines Heimrouters (ohne Zeilen) und nimmt ihr `reset`; jedes Kabel verwirft, solange irgendein Client „Pakete verwerfen“ hält (ein Client, der geht oder beim Stopp noch hält, zählt nicht mehr); die verworfenen Frames gehen an, wer sie beobachtet, mit jedem Start neu begonnen wie Javas Liste |
| `src/room.ts` („Tabellen löschen“), `test/exchangeWindow.test.ts` | Java `Lauscher.reset`, `GUIContainer.closeDialogs/reopenDialogs` | erweitert (F4.7): `clearCapture` leert im Lauf die Listen aller Karten und die verworfenen Frames, jeder Beobachter bekommt sie neu (ab 1 nummeriert), das Kabel leuchtet schon mit dem ersten neuen Frame; im Entwurf `notRunning`. Der Mitschnitt bleibt über Stopp und Start, wie Javas |
| `browser/m9.spec.ts`, `browser/traceDigest.ts`, `browser/*Entry.ts`, `/playwright.config.ts` | Plan §13 M9 | neu (F4.2): M9 in Chromium — 17 Beispiele, je 30 s mit Eingaben, ergeben Zeichen für Zeichen denselben Mitschnitt wie in Node; der Raum in einem echten Worker | | `browser/m9.spec.ts`, `browser/traceDigest.ts`, `browser/*Entry.ts`, `/playwright.config.ts` | Plan §13 M9 | neu (F4.2): M9 in Chromium — 17 Beispiele, je 30 s mit Eingaben, ergeben Zeichen für Zeichen denselben Mitschnitt wie in Node; der Raum in einem echten Worker |
## packages/editor ## packages/editor
@@ -227,6 +231,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
| `src/desktop/PersonalFirewallPanel.tsx`, `src/desktop/serverLines.ts` (`firewallLine`), `src/desktop/DesktopWindow.tsx` | Java `GUIApplicationFirewallWindow` (`firewall_msg1`–`14`), `Firewall`/`FirewallThread` (`sw_firewall_msg5`, `msg7`, `msg8`, `firewallthread_msg1`), gemessen mit `FirewallWindowRun` | neu (F4.6g3): die Firewall eines Rechners — Reiter „Konfiguration“ und „Log“, die drei Kästchen (ICMP und UDP nur bei eingeschalteter Firewall), Port mit Protokoll und Absendern, „Hinzufügen“ (das Feld wird danach geleert), „Löschen“ der gewählten Zeile, die Tabelle; das Protokoll in Javas Worten. Besser als Java: ein Port, der keiner ist, wird rot markiert und nicht übergeben; Javas Erklärtext, den Java schreibt, aber nie zeigt, steht unter den Kästchen; eine Regel, die das Fenster nicht machen kann (aus einer Datei: verwerfend oder mit Adresse), zeigt in der Absenderspalte, was sie ist (Java zeigt sie als offenen Port für alle Absender); ins Protokoll kann man nicht tippen | | `src/desktop/PersonalFirewallPanel.tsx`, `src/desktop/serverLines.ts` (`firewallLine`), `src/desktop/DesktopWindow.tsx` | Java `GUIApplicationFirewallWindow` (`firewall_msg1`–`14`), `Firewall`/`FirewallThread` (`sw_firewall_msg5`, `msg7`, `msg8`, `firewallthread_msg1`), gemessen mit `FirewallWindowRun` | neu (F4.6g3): die Firewall eines Rechners — Reiter „Konfiguration“ und „Log“, die drei Kästchen (ICMP und UDP nur bei eingeschalteter Firewall), Port mit Protokoll und Absendern, „Hinzufügen“ (das Feld wird danach geleert), „Löschen“ der gewählten Zeile, die Tabelle; das Protokoll in Javas Worten. Besser als Java: ein Port, der keiner ist, wird rot markiert und nicht übergeben; Javas Erklärtext, den Java schreibt, aber nie zeigt, steht unter den Kästchen; eine Regel, die das Fenster nicht machen kann (aus einer Datei: verwerfend oder mit Adresse), zeigt in der Absenderspalte, was sie ist (Java zeigt sie als offenen Port für alle Absender); ins Protokoll kann man nicht tippen |
| `src/desktop/GnutellaPanel.tsx`, `src/desktop/DesktopWindow.tsx`, `src/assets/desktop/peertopeer_*.png`, `src/main.tsx` (`filiusDev.writeFile`) | Java `GUIApplicationPeerToPeerAnwendungWindow` (`peertopeeranwendung_msg1`–`31`), Javas Symbole der Reiter, gemessen mit `GnutellaWindowRun` | neu (F4.6g4): Gnutella — vier Reiter mit Javas Symbolen: Netzwerk (Adresse, anfangs Javas 192.168.0.1, rot, solange sie keine ist; „Netz beitreten“, Tabelle der Nachbarn, „Netz verlassen“), Suche (Suchbegriff, „Suchen“, „Stopp!“, Ergebnistabelle mit Wahl, „Herunterladen“, „Liste leeren“), Dateien, Einstellungen (Zahl der Teilnehmer, „Speichern“). Besser als Java: die Tabellen zeigen sofort, was das Programm hält (Javas Ergebnisse zeigen nach einer neuen Suche die alten, bis ein Treffer kommt; seine Dateien nur, was beim letzten Bescheid des Programms da war); jede ganze Zahl wird genommen; was nicht genommen wird, steht unter dem Feld statt in einem Dialog; „Herunterladen“ nur mit gewähltem Ergebnis. Die Dev-Seite schreibt mit `writeFile` eine Datei wie eine andere Person | | `src/desktop/GnutellaPanel.tsx`, `src/desktop/DesktopWindow.tsx`, `src/assets/desktop/peertopeer_*.png`, `src/main.tsx` (`filiusDev.writeFile`) | Java `GUIApplicationPeerToPeerAnwendungWindow` (`peertopeeranwendung_msg1`–`31`), Javas Symbole der Reiter, gemessen mit `GnutellaWindowRun` | neu (F4.6g4): Gnutella — vier Reiter mit Javas Symbolen: Netzwerk (Adresse, anfangs Javas 192.168.0.1, rot, solange sie keine ist; „Netz beitreten“, Tabelle der Nachbarn, „Netz verlassen“), Suche (Suchbegriff, „Suchen“, „Stopp!“, Ergebnistabelle mit Wahl, „Herunterladen“, „Liste leeren“), Dateien, Einstellungen (Zahl der Teilnehmer, „Speichern“). Besser als Java: die Tabellen zeigen sofort, was das Programm hält (Javas Ergebnisse zeigen nach einer neuen Suche die alten, bis ein Treffer kommt; seine Dateien nur, was beim letzten Bescheid des Programms da war); jede ganze Zahl wird genommen; was nicht genommen wird, steht unter dem Feld statt in einem Dialog; „Herunterladen“ nur mit gewähltem Ergebnis. Die Dev-Seite schreibt mit `writeFile` eine Datei wie eine andere Person |
| `src/simulation/ViewerWindow.tsx`, `src/simulation/DropControl.tsx`, `src/simulation/timeOfDay.ts`, `src/simulation/useSimulation.ts` (`clock`, `dropped`, `drop`), `src/Editor.tsx`, `src/sheet/sheet.css` (`.editor-toolbar`) | Java `SatViewer`, `NatViewer` (`guievents_msg8`–`10`, `27`–`34`, `36`, `sw_switchfirmware_msg1`), `SimulationControl` (`simulation_control_drop_packets`), `GUIKnotenItem` (Klick im Aktionsmodus), gemessen mit `ViewersRun` | neu (F4.6g5): ein Tippen auf einen Switch zeigt seine Tabelle, auf einen Heimrouter seine NAT-Tabelle, in einem verschiebbaren Fenster mit Javas Spalten und Uhrzeiten; „Pakete verwerfen“ wird gehalten (Finger, Maus, Leertaste/Enter), daneben die Zahl und aufklappbar die Liste der verworfenen Frames mit Uhrzeit und Javas Text. Besser als Java: „Tabelle löschen“/„zurücksetzen“ ist ein Knopf (Java: Menü der rechten Maustaste), die Liste klappt weg; die Werkzeugleiste bricht auf schmalen Bildschirmen um | | `src/simulation/ViewerWindow.tsx`, `src/simulation/DropControl.tsx`, `src/simulation/timeOfDay.ts`, `src/simulation/useSimulation.ts` (`clock`, `dropped`, `drop`), `src/Editor.tsx`, `src/sheet/sheet.css` (`.editor-toolbar`) | Java `SatViewer`, `NatViewer` (`guievents_msg8`–`10`, `27`–`34`, `36`, `sw_switchfirmware_msg1`), `SimulationControl` (`simulation_control_drop_packets`), `GUIKnotenItem` (Klick im Aktionsmodus), gemessen mit `ViewersRun` | neu (F4.6g5): ein Tippen auf einen Switch zeigt seine Tabelle, auf einen Heimrouter seine NAT-Tabelle, in einem verschiebbaren Fenster mit Javas Spalten und Uhrzeiten; „Pakete verwerfen“ wird gehalten (Finger, Maus, Leertaste/Enter), daneben die Zahl und aufklappbar die Liste der verworfenen Frames mit Uhrzeit und Javas Text. Besser als Java: „Tabelle löschen“/„zurücksetzen“ ist ein Knopf (Java: Menü der rechten Maustaste), die Liste klappt weg; die Werkzeugleiste bricht auf schmalen Bildschirmen um |
| `src/simulation/CaptureWindow.tsx`, `src/simulation/captureCells.ts`, `src/simulation/useSimulation.ts` (`traces`, `clearCapture`), `src/Editor.tsx`, `src/desktop/desktop.css`, `src/theme/tokens.css` (`--capture-*`), Kataloge (`capture.*`, `menu.showCapture`) | Java `AggregatedExchangeDialog` (`lauscherdialog_msg1`), `AggregatedExchangePanel` (`buttontabcomponent_msg1`), `AggregatedMessageTable` (`nachrichtentabelle_msg7`–`9`), `LauscherTableCellRenderer`, `LayerColorVar`, `Lauscher` (`rp_lauscher_msg1`–`14`), `GUIEvents` (`guievents_msg4`), gemessen mit `ExchangeWindowRun` | neu (F4.7): im Menü eines Geräts je Karte „Datenaustausch anzeigen (Adresse)“; ein Fenster mit einem Reiter je Karte, die Zeilen in Javas Schichtenfarben (dunkle Varianten im dunklen Thema), die gewählte weiß auf schwarz, darunter ihre Details, „Tabellen löschen“, „Als Textdatei exportieren“ (nach dem Reiter benannt), „Automatisches Scrollen“; Wahl und Scrollen je Reiter, behalten, solange das Fenster zu ist; das Fenster geht mit dem Entwurf und kommt mit dem nächsten Start; der Reiter einer entfernten Karte verschwindet. Besser als Java: die drei sind Knöpfe und ein Kästchen statt eines Menüs der rechten Maustaste; ein Frame wird nach seiner Nummer gewählt (Javas Details nehmen den Zeilenindex dafür) |
| `src/Editor.tsx`, `src/canvas/Canvas.tsx` (Aktionsmodus) | Java `GUIContainer.setActiveSite/showDesktop/closeDialogs/reopenDialogs`, `GUIKnotenItem` (Klick im Aktionsmodus), `GUIEvents.kontextMenueAktionsmodus` (`guievents_msg3`), `JCablePanel` (`farbeBlinken`) | erweitert (F4.6a): im Aktionsmodus ist der Entwurf gesperrt wie bei Java — keine Palette, kein Blatt, nichts bewegt sich, kein Rückgängig, kein Löschen —, Kabel mit Rahmen leuchten grün, ein Tipp auf einen Rechner öffnet seinen Desktop (Langdruck: „Desktop anzeigen“); offene Desktops überstehen den Entwurf und erscheinen beim nächsten Start wieder, die eines entfernten Rechners nicht | | `src/Editor.tsx`, `src/canvas/Canvas.tsx` (Aktionsmodus) | Java `GUIContainer.setActiveSite/showDesktop/closeDialogs/reopenDialogs`, `GUIKnotenItem` (Klick im Aktionsmodus), `GUIEvents.kontextMenueAktionsmodus` (`guievents_msg3`), `JCablePanel` (`farbeBlinken`) | erweitert (F4.6a): im Aktionsmodus ist der Entwurf gesperrt wie bei Java — keine Palette, kein Blatt, nichts bewegt sich, kein Rückgängig, kein Löschen —, Kabel mit Rahmen leuchten grün, ein Tipp auf einen Rechner öffnet seinen Desktop (Langdruck: „Desktop anzeigen“); offene Desktops überstehen den Entwurf und erscheinen beim nächsten Start wieder, die eines entfernten Rechners nicht |
| `browser/simulation.spec.ts`, `test/programs.test.ts` | — | neu (F4.6a): Moduswechsel, Sperren, Geschwindigkeit, Kabelaktivität, Desktop mit Titel, Programmen, Netzwerk-Info, Verschieben und Stapeln, Handy, Englisch; die Programmliste gegen Javas `Desktop_*.txt`; seit F4.6b die Befehlszeile (Ping über drei Router, Verlauf, Chips, Strg+C, Behalten, exit) und der Fehlerfall des Raums; seit F4.6c die Software-Installation (sofort lauffähig, Doppelklick, Abwählen) | | `browser/simulation.spec.ts`, `test/programs.test.ts` | — | neu (F4.6a): Moduswechsel, Sperren, Geschwindigkeit, Kabelaktivität, Desktop mit Titel, Programmen, Netzwerk-Info, Verschieben und Stapeln, Handy, Englisch; die Programmliste gegen Javas `Desktop_*.txt`; seit F4.6b die Befehlszeile (Ping über drei Router, Verlauf, Chips, Strg+C, Behalten, exit) und der Fehlerfall des Raums; seit F4.6c die Software-Installation (sofort lauffähig, Doppelklick, Abwählen) |
| `browser/web.spec.ts`, `browser/fixtures/web-pages.fls`, `test/webPagesFixture.test.ts`, `test/serverLines.test.ts`, `src/simulation/useSimulation.ts` (`views`), `src/Editor.tsx` (Sprache beim Start), `src/desktop/DesktopWindow.tsx` | — | neu (F4.6d): Browser und Webserver in Chromium auf einer eigenen Projektdatei (mit den Befehlen des Dokuments gebaut und bei jedem Lauf verglichen): Adresszeile, Titel, Sandbox, Bilder, Marke, Links im Ordner, beide Formulare bis in die Zeilen des Servers, Fehlerseite, nicht erreichbarer Server, Knopf, Kästchen und Tabelle, die Routerseiten auf Englisch; die Zeilen des Servers gegen Javas Fenster | | `browser/web.spec.ts`, `browser/fixtures/web-pages.fls`, `test/webPagesFixture.test.ts`, `test/serverLines.test.ts`, `src/simulation/useSimulation.ts` (`views`), `src/Editor.tsx` (Sprache beim Start), `src/desktop/DesktopWindow.tsx` | — | neu (F4.6d): Browser und Webserver in Chromium auf einer eigenen Projektdatei (mit den Befehlen des Dokuments gebaut und bei jedem Lauf verglichen): Adresszeile, Titel, Sandbox, Bilder, Marke, Links im Ordner, beide Formulare bis in die Zeilen des Servers, Fehlerseite, nicht erreichbarer Server, Knopf, Kästchen und Tabelle, die Routerseiten auf Englisch; die Zeilen des Servers gegen Javas Fenster |
@@ -239,6 +244,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
| `browser/firewall.spec.ts`, `test/serverLines.test.ts` (Firewall) | — | neu (F4.6g3): die Firewall in Chromium auf `email_komplett`, im Lauf auf dem Web-Server installiert — Javas Kästchen, ein Port abgelehnt und einer hinzugefügt, der Browser eines Notebooks durch die Regel gelassen, ICMP verboten und dessen Ping verworfen (beides im Protokoll), die Firewall aus (die Kästchen darunter gesperrt), die Regel gewählt und gelöscht; die Zeilen des Protokolls und der Tabelle Wort für Wort wie Javas | | `browser/firewall.spec.ts`, `test/serverLines.test.ts` (Firewall) | — | neu (F4.6g3): die Firewall in Chromium auf `email_komplett`, im Lauf auf dem Web-Server installiert — Javas Kästchen, ein Port abgelehnt und einer hinzugefügt, der Browser eines Notebooks durch die Regel gelassen, ICMP verboten und dessen Ping verworfen (beides im Protokoll), die Firewall aus (die Kästchen darunter gesperrt), die Regel gewählt und gelöscht; die Zeilen des Protokolls und der Tabelle Wort für Wort wie Javas |
| `browser/gnutella.spec.ts` | — | neu (F4.6g4): Gnutella in Chromium auf `email_komplett`, im Lauf auf zwei Notebooks installiert — vier Reiter, eine Adresse mit Javas Worten abgelehnt, ein Nachbar beigetreten, eine Datei des anderen gefunden und heruntergeladen (der Reiter Dateien zeigt sie), „Liste leeren“ hebt die Wahl auf, die Zahl der Teilnehmer gespeichert (12, die Java ablehnt; was keine Zahl ist, mit Javas Worten abgelehnt), das Netz verlassen | | `browser/gnutella.spec.ts` | — | neu (F4.6g4): Gnutella in Chromium auf `email_komplett`, im Lauf auf zwei Notebooks installiert — vier Reiter, eine Adresse mit Javas Worten abgelehnt, ein Nachbar beigetreten, eine Datei des anderen gefunden und heruntergeladen (der Reiter Dateien zeigt sie), „Liste leeren“ hebt die Wahl auf, die Zahl der Teilnehmer gespeichert (12, die Java ablehnt; was keine Zahl ist, mit Javas Worten abgelehnt), das Netz verlassen |
| `browser/viewers.spec.ts`, `test/timeOfDay.test.ts` | — | neu (F4.6g5): auf `nat_with_mapped_port` die NAT-Tabelle (Portweiterleitung, dann ein Ping, zurückgesetzt), die Switch-Tabelle (gelernt, gelöscht), „Pakete verwerfen“ gehalten, während ein Ping läuft (Antworten verloren, Liste in Javas Worten); auf einem Handy bleibt „Pakete verwerfen“ erreichbar; die Uhrzeit wie Javas `HH:mm:ss(.SSS)` | | `browser/viewers.spec.ts`, `test/timeOfDay.test.ts` | — | neu (F4.6g5): auf `nat_with_mapped_port` die NAT-Tabelle (Portweiterleitung, dann ein Ping, zurückgesetzt), die Switch-Tabelle (gelernt, gelöscht), „Pakete verwerfen“ gehalten, während ein Ping läuft (Antworten verloren, Liste in Javas Worten); auf einem Handy bleibt „Pakete verwerfen“ erreichbar; die Uhrzeit wie Javas `HH:mm:ss(.SSS)` |
| `browser/capture.spec.ts`, `test/captureCells.test.ts` | — | neu (F4.7): auf `web-pages.fls` Javas Menü, eine Seite geladen (Zeile je Frame, Javas Farben, Details, Textdatei mit Javas Kopf), die Karten eines Routers, „Tabellen löschen“, „Automatisches Scrollen“ aus, ein Reiter geschlossen, das Fenster über Entwurf und Start, der Reiter eines entfernten Rechners weg; auf einem Handy; die Zellen in beiden Sprachen und Zahlenformaten |
| `src/sheet/ServicePanels.tsx`, `src/sheet/RowsEditor.tsx`, `src/sheet/SheetTabs.tsx`, `src/sheet/DeviceSheet.tsx` (Seiten) | Java `JDHCPKonfiguration` (`jdhcpkonfiguration_msg1`–`15`), `JPortForwardingDialog` (`jportforwarding_msg4`–`7`, `jgatewayconfiguration_msg19`) | neu (F4.5c): Javas Dialoge als Seiten des Blatts mit „Zurück“ — DHCP-Server (Grundeinstellungen, statische Adresszuweisung; beim Rechner, gesperrt während DHCP die Karte einrichtet, und auf dem LAN-Reiter des Heimrouters), Portfreigaben (Heimrouter); jedes Feld beim Verlassen übernommen statt mit Javas OK-Knopf. Die Zeilentabellen (Weiterleitung, Zuordnungen, Portfreigaben) teilen sich `RowsEditor`: eine neue Zeile, sobald alle Felder gültig sind, Zeile wählen, bearbeiten, löschen. Javas Tooltip zu „Manuelle Einstellungen“ als Text unter dem Kästchen (ein Finger hat kein Überfahren) | | `src/sheet/ServicePanels.tsx`, `src/sheet/RowsEditor.tsx`, `src/sheet/SheetTabs.tsx`, `src/sheet/DeviceSheet.tsx` (Seiten) | Java `JDHCPKonfiguration` (`jdhcpkonfiguration_msg1`–`15`), `JPortForwardingDialog` (`jportforwarding_msg4`–`7`, `jgatewayconfiguration_msg19`) | neu (F4.5c): Javas Dialoge als Seiten des Blatts mit „Zurück“ — DHCP-Server (Grundeinstellungen, statische Adresszuweisung; beim Rechner, gesperrt während DHCP die Karte einrichtet, und auf dem LAN-Reiter des Heimrouters), Portfreigaben (Heimrouter); jedes Feld beim Verlassen übernommen statt mit Javas OK-Knopf. Die Zeilentabellen (Weiterleitung, Zuordnungen, Portfreigaben) teilen sich `RowsEditor`: eine neue Zeile, sobald alle Felder gültig sind, Zeile wählen, bearbeiten, löschen. Javas Tooltip zu „Manuelle Einstellungen“ als Text unter dem Kästchen (ein Finger hat kein Überfahren) |
| `src/sheet/RouterSheets.tsx`, `src/sheet/NameField.tsx`, `src/canvas/Canvas.tsx` (`highlightedLink`), `src/theme/tokens.css` (`--canvas-cable-active`) | Java `JVermittlungsrechnerKonfiguration`, `JGatewayConfiguration`, `JWeiterleitungsTabelle` (Wörter `jvermittlungsrechnerkonfiguration_msg6`–`26`, `jweiterleitungstabelle_msg3`–`6`), `JCablePanel.farbeBlinken`, `Kabel.setAktiv` | neu (F4.5b): Blatt des Vermittlungsrechners — Reiter Allgemein (Name, Gateway, Automatisches Routing, IP-Weiterleitung nur angezeigt), je Karte (Reiter mit ihrer Adresse, „Verbunden mit …“, Adresse, Maske, MAC) und Weiterleitungstabelle (alle Einträge oder nur die manuellen, Javas Zeilen grau, eine neue Zeile, die mit vier gültigen Feldern übernommen wird, Zelle bearbeiten, Eintrag löschen; bei RIP gesperrt); Blatt des Heimrouters mit Allgemein, LAN, WAN (DHCP sperrt die WAN-Adresse). Das Kabel der offenen Karte leuchtet grün wie bei Java. Javas „Eintrag Löschen“ klein geschrieben | | `src/sheet/RouterSheets.tsx`, `src/sheet/NameField.tsx`, `src/canvas/Canvas.tsx` (`highlightedLink`), `src/theme/tokens.css` (`--canvas-cable-active`) | Java `JVermittlungsrechnerKonfiguration`, `JGatewayConfiguration`, `JWeiterleitungsTabelle` (Wörter `jvermittlungsrechnerkonfiguration_msg6`–`26`, `jweiterleitungstabelle_msg3`–`6`), `JCablePanel.farbeBlinken`, `Kabel.setAktiv` | neu (F4.5b): Blatt des Vermittlungsrechners — Reiter Allgemein (Name, Gateway, Automatisches Routing, IP-Weiterleitung nur angezeigt), je Karte (Reiter mit ihrer Adresse, „Verbunden mit …“, Adresse, Maske, MAC) und Weiterleitungstabelle (alle Einträge oder nur die manuellen, Javas Zeilen grau, eine neue Zeile, die mit vier gültigen Feldern übernommen wird, Zelle bearbeiten, Eintrag löschen; bei RIP gesperrt); Blatt des Heimrouters mit Allgemein, LAN, WAN (DHCP sperrt die WAN-Adresse). Das Kabel der offenen Karte leuchtet grün wie bei Java. Javas „Eintrag Löschen“ klein geschrieben |
+1
View File
@@ -17,6 +17,7 @@ export * from './runtime/speed.js';
export * from './runtime/trace.js'; export * from './runtime/trace.js';
export * from './runtime/capture.js'; export * from './runtime/capture.js';
export * from './runtime/captureRows.js'; export * from './runtime/captureRows.js';
export * from './runtime/captureWindow.js';
export * from './runtime/viewers.js'; export * from './runtime/viewers.js';
export * from './runtime/threads.js'; export * from './runtime/threads.js';
export * from './runtime/apps/program.js'; export * from './runtime/apps/program.js';
+126
View File
@@ -0,0 +1,126 @@
// The data exchange window as Java FILIUS shows a card's frames
// (filius.gui.nachrichtensicht, the AggregatedExchangeDialog that filius.ini
// chooses; measured with tools/java-oracle's ExchangeWindowRun): a table with
// one row per frame — the row of its highest layer, with addresses —, below
// it the frame's details (MessageDetailsPanel, the default without the layer
// visualization), and the table as a text file (AggregatedMessageTable
// .writeToStream). The rows are captureRows'; the words are the viewer's.
//
// Departure from Java: an empty comment is exported empty, where Java writes
// "null" (prepareDataArrays leaves the cell unset, String.format writes it).
import type { CaptureRow } from './captureRows.js';
/** The table's row of a frame: the row of its highest layer (AggregatedMessageTable.update). */
export function exchangeRow(rows: readonly CaptureRow[]): CaptureRow {
const row = rows.at(-1);
if (row === undefined) throw new RangeError('A frame has at least its Ethernet row');
return row;
}
/** The words of the details (rp_lauscher_msg1–5, 7). */
export interface CaptureDetailWords {
readonly number: string;
readonly time: string;
readonly source: string;
readonly destination: string;
readonly protocol: string;
readonly comment: string;
}
/** A frame's details as Java's tree shows them, each node as its depth and text. */
export interface CaptureDetailLine {
readonly depth: number;
readonly text: string;
}
/** Java's String.format("%-15s", …). */
const label = (text: string) => text.padEnd(15);
/**
* MessageDetailsPanel.update: "<Nr.>: n / <Zeit>: t", then per layer (its
* name) the source, destination and protocol that are not empty, and the
* comment — of the application layer with its length in "Bytes" (Java's
* String length) — on its own line below its label when it has a line break
* or is longer than 60 characters. `rows` are the frame's layered rows
* (ports only), `comments` their comments in words.
*/
export function captureDetailLines(
rows: readonly CaptureRow[],
time: string,
words: CaptureDetailWords,
layerName: (row: CaptureRow) => string,
comment: (row: CaptureRow) => string,
): CaptureDetailLine[] {
const first = rows[0];
if (first === undefined) return [];
const lines: CaptureDetailLine[] = [{ depth: 0, text: `${words.number}: ${first.number} / ${words.time}: ${time}` }];
for (const row of rows) {
lines.push({ depth: 1, text: layerName(row) });
if (row.source !== null && row.source !== '') lines.push({ depth: 2, text: label(`${words.source}: `) + row.source });
if (row.destination !== null && row.destination !== '') lines.push({ depth: 2, text: label(`${words.destination}: `) + row.destination });
if (row.protocol !== '') lines.push({ depth: 2, text: label(`${words.protocol}: `) + row.protocol });
const text = comment(row);
if (text === '') continue;
const heading = `${words.comment}${row.layer === 'application' ? ` (${text.length} Bytes)` : ''}: `;
if (text.includes('\n') || text.length > 60) {
lines.push({ depth: 2, text: heading }, { depth: 3, text });
} else {
lines.push({ depth: 2, text: label(heading) + text });
}
}
return lines;
}
const COMMENT_COLUMN = 6;
const COMMENT_WIDTH = 40;
const MAXIMUM_COMMENT_LINES = 5;
/**
* The table as Java exports it: a frame of "=" around the header, then each
* row — its comment broken into lines of at most 40 characters, at most five,
* "..." for more — and a line of "-" after it; the number 10 characters
* wide, the comment 40, every other cell 20, lines ending in CR LF. `rows`:
* the seven cells of each row as the table shows them.
*/
export function exchangeExportText(header: readonly string[], rows: readonly (readonly string[])[]): string {
const width = (column: number) => (column === COMMENT_COLUMN ? COMMENT_WIDTH : column === 0 ? 10 : 20);
const line = (cells: readonly string[]) => `| ${header.map((_, column) => (cells[column] ?? '').padEnd(width(column))).join(' | ')} | \r\n`;
const separator = `+${header.map((_, column) => '-'.repeat(width(column) + 2)).join('+')}+\r\n`;
let text = separator.replaceAll('-', '=') + line(header) + separator.replaceAll('-', '=');
for (const row of rows) {
const remarks = splitRemark(row[COMMENT_COLUMN] ?? '', COMMENT_WIDTH);
text += line(row.map((cell, column) => (column === COMMENT_COLUMN ? remarks[0]! : cell)));
for (const remark of remarks.slice(1, MAXIMUM_COMMENT_LINES)) text += line(header.map((_, column) => (column === COMMENT_COLUMN ? remark : '')));
if (remarks.length > MAXIMUM_COMMENT_LINES) text += line(header.map((_, column) => (column === COMMENT_COLUMN ? '...' : '')));
text += separator;
}
return text;
}
/** StringUtils.normalizeSpace: whitespace runs as one space, none at the ends. */
function normalizeSpace(text: string): string {
return text.replace(/[\s\u00a0]+/g, ' ').trim();
}
/** AggregatedMessageTable.splitString: words onto lines of at most `maximum` characters, a longer word cut into pieces of it; no words, one empty line. */
function splitRemark(text: string, maximum: number): string[] {
const lines: string[] = [];
let current = '';
for (const token of normalizeSpace(text).split(' ')) {
if (current.length + token.length <= maximum) {
current += `${token} `;
} else if (token.length > maximum) {
if (current.length > 0) lines.push(current.trim());
const pieces: string[] = [];
for (let start = 0; start < token.length; start += maximum) pieces.push(token.slice(start, start + maximum));
lines.push(...pieces.slice(0, -1));
current = `${pieces.at(-1)!} `;
} else {
lines.push(current.trim());
current = `${token} `;
}
}
lines.push(current.trim());
return lines;
}
File diff suppressed because one or more lines are too long
+185
View File
@@ -0,0 +1,185 @@
// FILIUS' data exchange window against Java's (exchangeWindow.java.json;
// tools/java-oracle's ExchangeWindowRun on web-lan): the browser's card while
// it fetches a page by name — one row per frame, its highest layer's; each
// frame's details; the table as a text file —, "Tabellen löschen", which
// empties every card's list and numbers anew, and a second fetch. The rows
// themselves are compared layer by layer with Java's in webJava.test.ts;
// Java's time of day is the editor's to write (TIME here).
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import {
captureCommentText,
captureDetailLines,
captureRows,
exchangeExportText,
exchangeRow,
VirtualFileSystem,
withSnapshot,
type CaptureLayer,
type CaptureRow,
type NetworkRuntimeEngine,
} from '../src/index.js';
import { watchedEngine, withClientPortText } from './javaRun.js';
import { snapshotOf, type Scenario } from './scenario.js';
type Cells = (string | null)[];
interface JavaTable {
readonly rows: Cells[];
readonly layerRows: Cells[];
}
interface JavaStep {
readonly step: string;
readonly at: number;
readonly titles: string[];
readonly selected: number;
readonly tables: Record<string, JavaTable>;
readonly details?: [number, string][][];
readonly export?: string;
readonly menu?: string[];
}
const here = dirname(fileURLToPath(import.meta.url));
const java = JSON.parse(readFileSync(join(here, 'exchangeWindow.java.json'), 'utf8')) as { readonly steps: readonly JavaStep[] };
const at = (name: string) => java.steps.find((step) => step.step === name)!;
const scenario = JSON.parse(readFileSync(join(here, 'scenarios/web-lan.scenario.json'), 'utf8')) as Scenario;
const BROWSER = '00000000-0000-0000-0000-000000000201';
const SERVER = '00000000-0000-0000-0000-000000000203';
const portOf = (node: string) => scenario.nodes.find((each) => each.id === node)!.ports[0]!.id;
/** Java's German words (its message bundle) and number format (Locale.GERMANY). */
const LAYERS: Record<CaptureLayer, string> = { networkAccess: 'Netzzugang', internet: 'Vermittlung', transport: 'Transport', application: 'Anwendung' };
const COMMENT_WORDS = { protocol: 'Protokoll', searchForMac: 'Suche nach MAC f\u00fcr', macIs: 'MAC ist' };
const DETAIL_WORDS = { number: 'Nr.', time: 'Zeit', source: 'Quelle', destination: 'Ziel', protocol: 'Protokoll', comment: 'Bemerkungen / Details' };
const HEADER = ['Nr.', 'Zeit', 'Quelle', 'Ziel', 'Protokoll', 'Schicht', 'Bemerkungen / Details'];
const german = new Intl.NumberFormat('de-DE');
const comment = (row: CaptureRow) => captureCommentText(row.comment, COMMENT_WORDS, (value) => german.format(value));
/** A cell with placeholders: the browser's free ports and the DNS message's ID (drawn) and Java's time of day. */
const cell = (text: string) =>
withClientPortText(text)
.replace(/(^|\s)(\d{5})$/, (whole, before: string, port: string) => (Number(port) >= 49_152 ? `${before}client` : whole))
.replace(/ID=\d+/g, 'ID=x');
const TIME = /^\d\d:\d\d:\d\d\.\d\d\d$/;
function run() {
const files = new VirtualFileSystem();
files.createDirectory('/webserver', true);
files.writeTextFile('/webserver/text.txt', 'Nur Text\nzwei Zeilen');
const snapshot = withSnapshot(snapshotOf(scenario), {
webServersByNodeId: new Map([[SERVER, { port: 80, documentRoot: '/webserver', virtualHosts: false, isActive: true }]]),
webBrowserNodeIds: new Set([BROWSER]),
fileSystemsByNodeId: new Map([[SERVER, files]]),
});
const { engine } = watchedEngine(scenario, snapshot);
const until = (milliseconds: number) => engine.handle({ type: 'advance', toMilliseconds: milliseconds });
return { engine, until };
}
/** The window's table of a card: each frame's highest row, without its time. */
const table = (engine: NetworkRuntimeEngine, node: string) =>
[...groupByFrame(captureRows(engine.capture.frames(portOf(node)), { withAddresses: true })).values()].map((rows) => {
const row = exchangeRow(rows);
return [String(row.number), row.source, row.destination, row.protocol, LAYERS[row.layer], comment(row)].map((each) => (each === null ? null : cell(each)));
});
const javaTable = (step: JavaStep, node: string) => step.tables[node]!.rows.map(([number, , ...rest]) => [number!, ...rest].map((each) => (each === null ? null : cell(each))));
function groupByFrame(rows: readonly CaptureRow[]): Map<number, CaptureRow[]> {
const frames = new Map<number, CaptureRow[]>();
for (const row of rows) frames.set(row.number, [...(frames.get(row.number) ?? []), row]);
return frames;
}
describe("the data exchange window, against Java's", () => {
it('shows one row per frame, its details, its text file; is emptied by "Tabellen löschen" and numbers anew', () => {
const { engine, until } = run();
until(500);
engine.webBrowser(BROWSER)!.open('http://www.filius.de/text.txt');
const fetched = at('after the fetch');
until(fetched.at);
// One row per frame: the highest layer's, with addresses.
expect(table(engine, BROWSER)).toEqual(javaTable(fetched, BROWSER));
expect(fetched.tables[BROWSER]!.rows.length).toBeLessThan(fetched.tables[BROWSER]!.layerRows.length);
// Each frame's details, from its layered rows (ports only).
const frames = groupByFrame(captureRows(engine.capture.frames(portOf(BROWSER)), { withAddresses: false }));
const details = [...frames.values()].map((rows) => captureDetailLines(rows, 'TIME', DETAIL_WORDS, (row) => LAYERS[row.layer], comment).map(({ depth, text }) => [depth, cell(text)]));
const javaDetails = fetched.details!.map((lines) => lines.map(([depth, text]) => [depth, cell(text.replace(/(Zeit: )\S+$/, '$1TIME'))]));
expect(details).toEqual(javaDetails);
// The text file of Java's own rows, as Java writes it.
const javaRows = fetched.tables[BROWSER]!.rows.map((row) => row.map((each) => each ?? ''));
expect(exchangeExportText(HEADER, javaRows)).toBe(fetched.export);
expect(fetched.tables[BROWSER]!.rows.every((row) => TIME.test(row[1]!))).toBe(true);
// "Tabellen löschen" (Lauscher.reset): every card's list empties.
const cleared = at('cleared');
until(cleared.at);
engine.clearCapture();
expect([table(engine, BROWSER), table(engine, SERVER)]).toEqual([javaTable(cleared, BROWSER), javaTable(cleared, SERVER)]);
expect(cleared.menu).toEqual(['Tabellen l\u00f6schen', 'Als Textdatei exportieren', 'Automatisches Scrollen']);
// Fetched again: numbered from 1.
engine.webBrowser(BROWSER)!.open('http://www.filius.de/text.txt');
const again = at('fetched again');
until(again.at);
expect(table(engine, BROWSER)).toEqual(javaTable(again, BROWSER));
expect(table(engine, SERVER)).toEqual(javaTable(again, SERVER));
});
it('breaks a long comment as Java does: words onto lines of 40, a long word cut, five lines at most', () => {
const header = ['N', 'T', 'S', 'D', 'P', 'L', 'C'];
const row = (text: string) => ['1', 't', 's', 'd', 'p', 'l', text];
const lines = (text: string) =>
exchangeExportText(header, [row(text)])
.split('\r\n')
.slice(3, -2)
.map((line) => line.split(' | ')[6]!.trimEnd());
expect(lines('')).toEqual(['']);
expect(lines(' \n ')).toEqual(['']);
expect(lines(`${'a'.repeat(40)} b`)).toEqual(['a'.repeat(40), 'b']);
expect(lines(`x ${'a'.repeat(90)} y`)).toEqual(['x', 'a'.repeat(40), 'a'.repeat(40), `${'a'.repeat(10)} y`]);
expect(lines(`${'a'.repeat(41)}`)).toEqual(['a'.repeat(40), 'a']);
// Whitespace at the ends does not count (StringUtils.normalizeSpace).
expect(lines(` \n${'a'.repeat(40)} `)).toEqual(['a'.repeat(40)]);
expect(lines(Array.from({ length: 6 }, () => 'w'.repeat(39)).join(' '))).toEqual([...Array.from({ length: 5 }, () => 'w'.repeat(39)), '...']);
expect(lines(Array.from({ length: 5 }, () => 'w'.repeat(39)).join(' '))).toEqual(Array.from({ length: 5 }, () => 'w'.repeat(39)));
// An empty comment is empty (Java writes "null").
expect(exchangeExportText(header, [row('')]).split('\r\n')[3]).toBe(
`| 1 | t | s | d | p | l | ${' '.repeat(40)} | `,
);
});
it('writes a short comment beside its label and a long one below it, and the length of the data in Bytes', () => {
const base = { number: 4, timeMilliseconds: 0, source: null, destination: null } as const;
const rows: CaptureRow[] = [
{ ...base, source: 'a', destination: '', protocol: '', layer: 'networkAccess', comment: { kind: 'ethernetType', type: '0x800' } },
{ ...base, source: '', destination: 'b', protocol: 'IP', layer: 'internet', comment: { kind: 'ipProtocol', protocol: 6, timeToLive: 64 } },
{ ...base, protocol: 'HTTP', layer: 'application', comment: { kind: 'data', text: 'ä'.repeat(60) } },
{ ...base, protocol: 'HTTP', layer: 'application', comment: { kind: 'data', text: 'b'.repeat(61) } },
];
expect(captureDetailLines(rows, '12:00', DETAIL_WORDS, (row) => LAYERS[row.layer], comment)).toEqual([
{ depth: 0, text: 'Nr.: 4 / Zeit: 12:00' },
{ depth: 1, text: 'Netzzugang' },
{ depth: 2, text: 'Quelle: a' },
{ depth: 2, text: 'Bemerkungen / Details: 0x800' },
{ depth: 1, text: 'Vermittlung' },
{ depth: 2, text: 'Ziel: b' },
{ depth: 2, text: 'Protokoll: IP' },
{ depth: 2, text: 'Bemerkungen / Details: Protokoll: 6, TTL: 64' },
{ depth: 1, text: 'Anwendung' },
{ depth: 2, text: 'Protokoll: HTTP' },
{ depth: 2, text: `Bemerkungen / Details (60 Bytes): ${'ä'.repeat(60)}` },
{ depth: 1, text: 'Anwendung' },
{ depth: 2, text: 'Protokoll: HTTP' },
{ depth: 2, text: 'Bemerkungen / Details (61 Bytes): ' },
{ depth: 3, text: 'b'.repeat(61) },
]);
expect(captureDetailLines([], '12:00', DETAIL_WORDS, (row) => LAYERS[row.layer], comment)).toEqual([]);
expect(() => exchangeRow([])).toThrow(RangeError);
});
});
+1 -17
View File
@@ -24,7 +24,7 @@ import {
import { modemPort } from '@mdg-filius/fls'; import { modemPort } from '@mdg-filius/fls';
import { inOrder, ROOTS } from './projectDoc.js'; import { inOrder, ROOTS } from './projectDoc.js';
import { fromY } from './values.js'; import { fromY } from './values.js';
import { nodeLabel } from './view.js'; import { cardAddress, nodeLabel } from './view.js';
/** A device's DHCP server as Java's dialog shows it (JDHCPKonfiguration). */ /** A device's DHCP server as Java's dialog shows it (JDHCPKonfiguration). */
export interface DhcpServerSheet { export interface DhcpServerSheet {
@@ -147,22 +147,6 @@ const setting = (node: Y.Map<unknown>, key: string) => settingsOf(node).get(key)
const text = (map: Y.Map<unknown> | undefined, key: string) => (map?.get(key) as string | undefined) ?? ''; const text = (map: Y.Map<unknown> | undefined, key: string) => (map?.get(key) as string | undefined) ?? '';
/** The address of a device's card at a port, as Java's NetzwerkInterface holds it; null for a device without cards (InternetKnoten). */
function cardAddress(node: Y.Map<unknown>, portId: string): string | null {
switch (node.get('kind') as NodeKind) {
case 'pc':
case 'notebook':
return text(setting(node, 'device'), 'ipAddress');
case 'router':
case 'gateway': {
const port = (node.get('ports') as Y.Array<Y.Map<unknown>>).toArray().find((each) => each.get('id') === portId);
return text(port?.get('config') as Y.Map<unknown> | undefined, 'ipAddress');
}
default:
return null;
}
}
/** A router's or home router's cards, each with what its cable joins. */ /** A router's or home router's cards, each with what its cable joins. */
function interfacesOf(doc: Y.Doc, id: string, node: Y.Map<unknown>): InterfaceSheet[] { function interfacesOf(doc: Y.Doc, id: string, node: Y.Map<unknown>): InterfaceSheet[] {
const all = doc.getMap<Y.Map<unknown>>(ROOTS.nodes); const all = doc.getMap<Y.Map<unknown>>(ROOTS.nodes);
+45
View File
@@ -69,6 +69,51 @@ export function nodeLabel(node: Y.Map<unknown>): string {
return `${ip} (${mac})`; return `${ip} (${mac})`;
} }
/** The address of a device's card at a port, as Java's NetzwerkInterface holds it; null for a device without cards (InternetKnoten). */
export function cardAddress(node: Y.Map<unknown>, portId: string): string | null {
const text = (map: Y.Map<unknown> | undefined) => (map?.get('ipAddress') as string | undefined) ?? '';
switch (node.get('kind') as NodeKind) {
case 'pc':
case 'notebook':
return text(settingsOf(node)?.get('device'));
case 'router':
case 'gateway': {
const port = (node.get('ports') as Y.Array<Y.Map<unknown>>).toArray().find((each) => each.get('id') === portId);
return text(port?.get('config') as Y.Map<unknown> | undefined);
}
default:
return null;
}
}
/** A card whose frames the data exchange window shows: its port, address and the title of its tab. */
export interface CaptureCard {
readonly portId: string;
readonly ipAddress: string;
readonly title: string;
}
/**
* A device's cards in the order of its ports, as Java's menu offers their
* data exchange (GUIEvents.kontextMenueAktionsmodus); empty for a device
* without cards. A tab's title is the card's address where the label is the
* address (Host.isUseIPAsName), else the label, " - " and the address
* (AggregatedExchangePanel.updateTabTitle).
*/
export function captureCards(doc: Y.Doc, id: string): CaptureCard[] {
const node = doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get(id);
if (node === undefined) return [];
const policy = node.get('labelPolicy') as HostLabelPolicy;
const kind = node.get('kind') as NodeKind;
const addressAsName = (kind === 'pc' || kind === 'notebook') && (policy === 'ipAddress' || policy === 'ipAndMAC');
return (node.get('ports') as Y.Array<Y.Map<unknown>>).toArray().flatMap((port) => {
const portId = port.get('id') as string;
const ipAddress = cardAddress(node, portId);
if (ipAddress === null) return [];
return [{ portId, ipAddress, title: addressAsName ? ipAddress : `${nodeLabel(node)} - ${ipAddress}` }];
});
}
/** The drawing of the document, in the order of its devices and cables. */ /** The drawing of the document, in the order of its devices and cables. */
export function topologyView(doc: Y.Doc): TopologyView { export function topologyView(doc: Y.Doc): TopologyView {
const kinds = new Map<string, NodeKind>(); const kinds = new Map<string, NodeKind>();
+37 -2
View File
@@ -10,7 +10,7 @@ import { describe, expect, it } from 'vitest';
import * as Y from 'yjs'; import * as Y from 'yjs';
import { stableGeneratedMacAddress } from '@mdg-filius/core'; import { stableGeneratedMacAddress } from '@mdg-filius/core';
import { exportProject, openProjectArchive, type ImportedProject } from '@mdg-filius/fls'; import { exportProject, openProjectArchive, type ImportedProject } from '@mdg-filius/fls';
import { addDevice, desktopView, readProject, setHostLabels, topologyView, writeProject } from '../src/index.js'; import { addDevice, captureCards, desktopView, readProject, renameDevice, setHostAddress, setHostLabels, setInterfaceAddress, topologyView, writeProject } from '../src/index.js';
const corpus = join(dirname(fileURLToPath(import.meta.url)), '../../../corpus'); const corpus = join(dirname(fileURLToPath(import.meta.url)), '../../../corpus');
const files = ['java-examples', 'java-tests', 'ipad-fixtures'].flatMap((folder) => const files = ['java-examples', 'java-tests', 'ipad-fixtures'].flatMap((folder) =>
@@ -103,5 +103,40 @@ describe("a computer's desktop", () => {
setHostLabels(doc, 'pc', { ipAddress: true, macAddress: false }, 'me'); setHostLabels(doc, 'pc', { ipAddress: true, macAddress: false }, 'me');
expect(desktopView(doc, 'pc')).toMatchObject({ label: '192.168.0.10', addressAsName: true }); expect(desktopView(doc, 'pc')).toMatchObject({ label: '192.168.0.10', addressAsName: true });
}); });
});
it("offers each card's data exchange with the title of Java's tab: the address where the label is it, else the label and the address", () => {
// Java's titles (packages/core/test/exchangeWindow.java.json): "Anna - 192.168.0.20", a computer named so; "192.168.0.30", one labelled by its address.
const java = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../../core/test/exchangeWindow.java.json'), 'utf8')) as { steps: { step: string; titles: string[] }[] };
const doc = new Y.Doc();
let counter = 0;
const newId = () => `port-${++counter}`;
addDevice(doc, { kind: 'pc', id: 'anna', name: 'Anna', x: 0, y: 0, newId }, 'me');
setHostAddress(doc, 'anna', 'ipAddress', '192.168.0.20', 'me');
addDevice(doc, { kind: 'notebook', id: 'server', name: 'Server', x: 0, y: 0, newId }, 'me');
setHostAddress(doc, 'server', 'ipAddress', '192.168.0.30', 'me');
setHostLabels(doc, 'server', { ipAddress: true, macAddress: false }, 'me');
expect([...captureCards(doc, 'anna'), ...captureCards(doc, 'server')].map((card) => card.title)).toEqual(java.steps.find((step) => step.step === 'the server\'s card beside it')!.titles);
expect(captureCards(doc, 'anna')).toEqual([{ portId: 'port-1', ipAddress: '192.168.0.20', title: 'Anna - 192.168.0.20' }]);
// Address and MAC address: the address; the MAC address alone: the label, which is it.
setHostLabels(doc, 'server', { ipAddress: true, macAddress: true }, 'me');
expect(captureCards(doc, 'server')[0]!.title).toBe('192.168.0.30');
setHostLabels(doc, 'server', { ipAddress: false, macAddress: true }, 'me');
expect(captureCards(doc, 'server')[0]!.title).toBe(`${stableGeneratedMacAddress('port-2')} - 192.168.0.30`);
// A router's cards in the order of its ports, each with its own address; a home router's WAN card first.
addDevice(doc, { kind: 'router', id: 'router', name: 'Vermittlungsrechner', x: 0, y: 0, interfaces: 2, newId }, 'me');
const [first, second] = topologyView(doc).nodes.find((node) => node.id === 'router')!.ports;
setInterfaceAddress(doc, 'router', second!.id, 'ipAddress', '10.0.0.1', 'me');
renameDevice(doc, 'router', 'R1', 'me');
expect(captureCards(doc, 'router').map((card) => [card.portId, card.title])).toEqual([
[first!.id, `R1 - ${captureCards(doc, 'router')[0]!.ipAddress}`],
[second!.id, 'R1 - 10.0.0.1'],
]);
addDevice(doc, { kind: 'gateway', id: 'gateway', name: 'Heimrouter', x: 0, y: 0, newId }, 'me');
expect(captureCards(doc, 'gateway').map((card) => card.portId)).toEqual(topologyView(doc).nodes.find((node) => node.id === 'gateway')!.ports.map((port) => port.id));
// No cards: a switch, a modem; no device.
addDevice(doc, { kind: 'networkSwitch', id: 'switch', name: 'Switch', x: 0, y: 0, newId }, 'me');
addDevice(doc, { kind: 'remoteLink', id: 'modem', name: 'Modem', x: 0, y: 0, newId }, 'me');
expect([captureCards(doc, 'switch'), captureCards(doc, 'modem'), captureCards(doc, 'nobody')]).toEqual([[], [], []]);
});
});
+204
View File
@@ -0,0 +1,204 @@
// The data exchange window in Chromium (F4.7), on browser/fixtures/web-pages.fls:
// Java's menu offers a computer's desktop and each card's data exchange with
// its address; the browser's card while it fetches a page — a row per frame
// in its layer's colour, the chosen frame's details, the table as a text
// file named after the tab —, a router's two cards beside it, "Tabellen
// löschen", "Automatisches Scrollen" off, a tab closed, the window gone with
// the design and back with the next start without the tab of a computer
// removed; and on a phone.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { expect, test, type Page } from '@playwright/test';
import { drawing, onDevice, serveEditor } from './editorPage.js';
let server: { readonly base: string; readonly close: () => Promise<void> };
test.beforeAll(async () => {
server = await serveEditor();
});
test.afterAll(async () => {
await server.close();
});
const FIXTURE = join(dirname(fileURLToPath(import.meta.url)), 'fixtures/web-pages.fls');
async function openPages(page: Page): Promise<void> {
await page.goto(`${server.base}?example=new`);
await page.waitForSelector('svg.filius-canvas');
await page.locator('input[type="file"]').setInputFiles(FIXTURE);
await expect.poll(async () => (await drawing(page).catch(() => ({ nodes: [] }))).nodes.length).toBe(4);
}
async function start(page: Page): Promise<void> {
await page.getByRole('button', { name: 'Wechselt in den Aktionsmodus' }).click();
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation');
}
const idOf = async (page: Page, name: string) => (await drawing(page)).nodes.find((node) => node.name === name)!.id;
/** Java's menu of a device in the simulation, by a long press. */
async function menuOf(page: Page, name: string) {
const at = await onDevice(page, await idOf(page, name));
await page.mouse.move(at.x, at.y);
await page.mouse.down();
await page.waitForTimeout(700);
await page.mouse.up();
return page.getByRole('menuitem');
}
test("shows a card's frames as Java's data exchange window does, with their details, as a text file, and empties every card's", async ({ page }) => {
test.setTimeout(120_000);
await openPages(page);
await start(page);
// Java's menu: the desktop, then the card's data exchange with its address.
await expect(await menuOf(page, 'Browser')).toHaveText(['Desktop anzeigen', 'Datenaustausch anzeigen (192.168.0.10)']);
await page.getByRole('menuitem', { name: 'Datenaustausch anzeigen (192.168.0.10)' }).click();
const window = page.locator('section[data-capture]');
await expect(window.locator('h2')).toHaveText('Datenaustausch');
await expect(window.getByRole('tab')).toHaveText(['Browser - 192.168.0.10']);
await expect(window.locator('th')).toHaveText(['Nr.', 'Zeit', 'Quelle', 'Ziel', 'Protokoll', 'Schicht', 'Bemerkungen / Details']);
await expect(window.locator('tbody tr')).toHaveCount(0);
// Closed, it keeps its tab (Java hides its dialog); it lies over the devices.
await window.locator('.desktop-close').click();
await expect(window).toHaveCount(0);
// The browser fetches the server's page: a row per frame, its highest layer's, in its colour.
const at = await onDevice(page, await idOf(page, 'Browser'));
await page.mouse.click(at.x, at.y);
const desktop = page.locator('section[data-desktop]');
await desktop.locator('[data-program="webBrowser"]').click();
const address = desktop.getByRole('textbox', { name: 'Adresse' });
await address.fill('192.168.0.20');
await address.press('Enter');
await expect(desktop.locator('.program-titlebar h3')).toHaveText('Seiten & Formulare', { timeout: 30_000 });
await desktop.locator('.desktop-close').click();
await (await menuOf(page, 'Browser')).filter({ hasText: 'Datenaustausch' }).click();
await expect(window.getByRole('tab')).toHaveText(['Browser - 192.168.0.10']);
const rows = window.locator('tbody tr');
await expect(rows.first().locator('td')).toHaveText(['1', /^\d\d:\d\d:\d\d\.\d\d\d$/, '192.168.0.10', '192.168.0.20', 'ARP', 'Vermittlung', /^Suche nach MAC für 192\.168\.0\.20 \[op=REQUEST/]);
await expect(rows.first()).toHaveClass(/capture-internet/);
const get = rows.filter({ hasText: 'GET / HTTP/1.1' }).first();
await expect(get.locator('td').nth(4)).toHaveText('HTTP');
await expect(get.locator('td').nth(5)).toHaveText('Anwendung');
await expect(get.locator('td').nth(2)).toHaveText(/^192\.168\.0\.10:\d{5}$/);
await expect(get).toHaveClass(/capture-application/);
// Java's colours (LayerColorVar): application #FFD966, transport #F4B183.
expect(await get.evaluate((row) => getComputedStyle(row).backgroundColor)).toBe('rgb(255, 217, 102)');
const syn = rows.filter({ hasText: /^.*SYN, SEQ: [\d.]+$/ }).first();
await expect(syn).toHaveClass(/capture-transport/);
expect(await syn.evaluate((row) => getComputedStyle(row).backgroundColor)).toBe('rgb(244, 177, 131)');
const numbers = await rows.locator('td:first-child').allTextContents();
expect(numbers).toEqual(numbers.map((_, index) => String(index + 1)));
// A line break in a comment shows as a space (LauscherTableCellRenderer).
expect(
await get
.locator('td')
.nth(6)
.evaluate((cell) => cell.textContent),
).toMatch(/^GET \/ HTTP\/1\.1 Host: 192\.168\.0\.20 /);
// "Automatisches Scrollen" (on): the table at its end.
const scroller = window.locator('.capture-table');
const scrolled = () =>
scroller.evaluate((element) => ({
overflows: element.scrollHeight > element.clientHeight + 20,
atEnd: element.scrollTop + element.clientHeight >= element.scrollHeight - 2,
top: element.scrollTop,
}));
await expect.poll(scrolled).toMatchObject({ overflows: true, atEnd: true });
// The request chosen: white on black, its details below, every layer.
await get.click();
await expect(get).toHaveAttribute('aria-selected', 'true');
expect(await get.evaluate((row) => [getComputedStyle(row).backgroundColor, getComputedStyle(row).color])).toEqual(['rgb(0, 0, 0)', 'rgb(255, 255, 255)']);
const details = window.getByRole('list', { name: 'Bemerkungen / Details' }).locator('li');
await expect(details.first()).toHaveText(/^Nr\.: \d+ \/ Zeit: \d\d:\d\d:\d\d\.\d\d\d$/);
const lines = await details.allTextContents();
expect(lines.filter((line) => ['Netzzugang', 'Vermittlung', 'Transport', 'Anwendung'].includes(line))).toEqual(['Netzzugang', 'Vermittlung', 'Transport', 'Anwendung']);
expect(lines).toContain('Protokoll: HTTP');
expect(lines.some((line) => line.startsWith('Bemerkungen / Details (') && /^[^(]+\(\d+ Bytes\): $/.test(line))).toBe(true);
expect(lines.some((line) => line.startsWith('GET / HTTP/1.1\n'))).toBe(true);
// The table as Java's text file, named after the tab.
const [download] = await Promise.all([page.waitForEvent('download'), window.getByRole('button', { name: 'Als Textdatei exportieren' }).click()]);
expect(download.suggestedFilename()).toBe('Browser - 192.168.0.10.txt');
const text = readFileSync((await download.path())!, 'utf8');
expect(text.split('\r\n').slice(0, 3)).toEqual([
`+${'='.repeat(12)}${`+${'='.repeat(22)}`.repeat(5)}+${'='.repeat(42)}+`,
`| Nr. | Zeit | Quelle | Ziel | Protokoll | Schicht | ${'Bemerkungen / Details'.padEnd(40)} | `,
`+${'='.repeat(12)}${`+${'='.repeat(22)}`.repeat(5)}+${'='.repeat(42)}+`,
]);
expect(text).toContain('| 1 | ');
expect(text.match(/^\+-/gm)?.length).toBe(await rows.count());
// A router's cards: one tab each, the one chosen in front.
await window.locator('.desktop-close').click();
await expect(await menuOf(page, 'Router')).toHaveText(['Datenaustausch anzeigen (192.168.0.1)', 'Datenaustausch anzeigen (10.0.0.1)']);
await page.getByRole('menuitem', { name: 'Datenaustausch anzeigen (192.168.0.1)' }).click();
await expect(window.getByRole('tab')).toHaveText(['Browser - 192.168.0.10', 'Router - 192.168.0.1']);
await expect(window.getByRole('tab', { selected: true })).toHaveText('Router - 192.168.0.1');
await expect(window.locator('th')).toHaveCount(7);
// The router's card heard the ARP broadcast.
await expect(rows.first().locator('td').nth(4)).toHaveText('ARP');
// "Tabellen löschen": every card's list, numbered anew.
await window.getByRole('button', { name: 'Tabellen löschen' }).click();
await expect(rows).toHaveCount(0);
await window.getByRole('tab', { name: 'Browser - 192.168.0.10' }).click();
await expect(rows).toHaveCount(0);
await expect(window.getByRole('list', { name: 'Bemerkungen / Details' }).locator('li')).toHaveCount(0);
// Without "Automatisches Scrollen", fetched again (its tab kept over the window closed): the table stays at its start, nothing chosen.
await window.getByLabel('Automatisches Scrollen').uncheck();
await window.locator('.desktop-close').click();
await page.mouse.click(at.x, at.y);
await desktop.locator('[data-program="webBrowser"]').click();
await address.fill('192.168.0.20');
await address.press('Enter');
await page.waitForTimeout(6_000);
await desktop.locator('.desktop-close').click();
await (await menuOf(page, 'Browser')).filter({ hasText: 'Datenaustausch' }).click();
await expect(window.getByLabel('Automatisches Scrollen')).not.toBeChecked();
await expect.poll(() => rows.count()).toBeGreaterThan(15);
await expect(rows.first().locator('td').first()).toHaveText('1');
await expect(window.locator('tbody tr[aria-selected="true"]')).toHaveCount(0);
expect(await scrolled()).toMatchObject({ overflows: true, top: 0 });
// A third tab; a tab closed beside the chosen one leaves it chosen.
await window.locator('.desktop-close').click();
await (await menuOf(page, 'Server')).filter({ hasText: 'Datenaustausch' }).click();
await expect(window.getByRole('tab')).toHaveText(['Browser - 192.168.0.10', 'Router - 192.168.0.1', 'Server - 192.168.0.20']);
await window.getByRole('tab', { name: 'Browser - 192.168.0.10' }).click();
await window.getByRole('button', { name: 'Reiter schließen: Router - 192.168.0.1' }).click();
await expect(window.getByRole('tab')).toHaveText(['Browser - 192.168.0.10', 'Server - 192.168.0.20']);
await expect(window.getByRole('tab', { selected: true })).toHaveText('Browser - 192.168.0.10');
// The window goes with the design and comes back with the next start; the tab of a computer removed in the design is gone.
await page.getByRole('button', { name: 'Wechselt in den Entwurfsmodus' }).click();
await expect(window).toHaveCount(0);
const server = await onDevice(page, await idOf(page, 'Server'));
await page.mouse.click(server.x, server.y);
await page.keyboard.press('Delete');
await expect.poll(async () => (await drawing(page)).nodes.length).toBe(3);
await start(page);
await expect(window.getByRole('tab')).toHaveText(['Browser - 192.168.0.10']);
// Its last tab closed, the window shows nothing.
await window.getByRole('button', { name: 'Reiter schließen: Browser - 192.168.0.10' }).click();
await expect(window).toHaveCount(0);
});
test('fills a phone with the data exchange window, its bar in reach', async ({ page }) => {
await page.setViewportSize({ width: 360, height: 740 });
await openPages(page);
await start(page);
await (await menuOf(page, 'Browser')).filter({ hasText: 'Datenaustausch' }).click();
const window = page.locator('section[data-capture]');
for (const name of ['Tabellen löschen', 'Als Textdatei exportieren']) await expect(window.getByRole('button', { name })).toBeInViewport({ ratio: 1 });
await expect(window.getByLabel('Automatisches Scrollen')).toBeChecked();
const box = (await window.boundingBox())!;
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.x + box.width).toBeLessThanOrEqual(360);
});
+86 -9
View File
@@ -4,7 +4,8 @@
// in Java — no palette, no sheet, nothing moves — the cables that carry a // in Java — no palette, no sheet, nothing moves — the cables that carry a
// frame light up, and a tap on a computer opens its desktop. Desktops left // frame light up, and a tap on a computer opens its desktop. Desktops left
// open stay over a return to the design and show again at the next start, // open stay over a return to the design and show again at the next start,
// as Java's do (GUIContainer.closeDialogs, reopenDialogs). // as Java's do (GUIContainer.closeDialogs, reopenDialogs); so does the data
// exchange window with its tabs (F4.7).
// //
// 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
@@ -12,7 +13,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import type * as Y from 'yjs'; import type * as Y from 'yjs';
import { addDevice, connect, createUndoManager, moveDevice, removeDevice, removeLink, removeLinksOf, topologyView, type TopologyView } from '@mdg-filius/doc'; import { addDevice, captureCards, connect, createUndoManager, moveDevice, removeDevice, removeLink, removeLinksOf, topologyView, type TopologyView } from '@mdg-filius/doc';
import { Canvas, type CanvasHandle, type Selection, type Side } from './canvas/Canvas.js'; import { Canvas, type CanvasHandle, type Selection, type Side } from './canvas/Canvas.js';
import type { Point } from './canvas/viewport.js'; import type { Point } from './canvas/viewport.js';
import { ContextMenu, type MenuItem } from './design/ContextMenu.js'; import { ContextMenu, type MenuItem } from './design/ContextMenu.js';
@@ -21,6 +22,7 @@ import { RouterDialog } from './design/RouterDialog.js';
import { DeviceSheet } from './sheet/DeviceSheet.js'; import { DeviceSheet } from './sheet/DeviceSheet.js';
import { DesktopWindow, type DesktopWindowName } from './desktop/DesktopWindow.js'; import { DesktopWindow, type DesktopWindowName } from './desktop/DesktopWindow.js';
import { useLanguage, useTranslate } from './i18n/i18n.js'; import { useLanguage, useTranslate } from './i18n/i18n.js';
import { CaptureWindow } from './simulation/CaptureWindow.js';
import { ModeBar } from './simulation/ModeBar.js'; import { ModeBar } from './simulation/ModeBar.js';
import { DropControl } from './simulation/DropControl.js'; import { DropControl } from './simulation/DropControl.js';
import { ViewerWindow, type ViewerKind } from './simulation/ViewerWindow.js'; import { ViewerWindow, type ViewerKind } from './simulation/ViewerWindow.js';
@@ -75,16 +77,31 @@ const toolButton: CSSProperties = {
/** Whether a key press belongs to a field being typed in. */ /** Whether a key press belongs to a field being typed in. */
const typing = (target: EventTarget | null) => target instanceof HTMLElement && (target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)); const typing = (target: EventTarget | null) => target instanceof HTMLElement && (target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName));
/** A desktop open on the surface — or a switch's or home router's viewer (`viewer`) —: whose, where its window lies, and how far up (the last one touched on top). */ /** The data exchange window's place among the desktops (it is one window, with a tab per card). */
const CAPTURE_WINDOW = 'capture';
/**
* A desktop open on the surface — or a switch's or home router's viewer
* (`viewer`), or the data exchange window (`capture`) —: whose, where its
* window lies, and how far up (the last one touched on top).
*/
interface OpenDesktop { interface OpenDesktop {
readonly id: string; readonly id: string;
readonly viewer?: ViewerKind; readonly viewer?: ViewerKind | 'capture';
readonly x: number; readonly x: number;
readonly y: number; readonly y: number;
readonly z: number; readonly z: number;
readonly program: DesktopWindowName | null; readonly program: DesktopWindowName | null;
} }
/** A card's tab in the data exchange window: whose card, the frame chosen in it, whether it scrolls along (AggregatedMessageTable's own). */
interface OpenCaptureTab {
readonly node: string;
readonly port: string;
readonly chosen: number | undefined;
readonly autoscroll: boolean;
}
/** Brings a desktop to the top by its stacking, not its place in the page: moved in the page, it would lose the press on it. */ /** Brings a desktop to the top by its stacking, not its place in the page: moved in the page, it would lose the press on it. */
const toFront = (open: readonly OpenDesktop[], id: string): readonly OpenDesktop[] => { const toFront = (open: readonly OpenDesktop[], id: string): readonly OpenDesktop[] => {
const top = Math.max(0, ...open.map((each) => each.z)); const top = Math.max(0, ...open.map((each) => each.z));
@@ -138,13 +155,30 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
const kinds = new Map(view.nodes.map((node) => [node.id, node.kind])); const kinds = new Map(view.nodes.map((node) => [node.id, node.kind]));
const fits = (each: OpenDesktop) => { const fits = (each: OpenDesktop) => {
const kind = kinds.get(each.id); const kind = kinds.get(each.id);
if (each.viewer === 'capture') return true;
return each.viewer === 'sat' ? kind === 'networkSwitch' : each.viewer === 'nat' ? kind === 'gateway' : kind === 'pc' || kind === 'notebook'; return each.viewer === 'sat' ? kind === 'networkSwitch' : each.viewer === 'nat' ? kind === 'gateway' : kind === 'pc' || kind === 'notebook';
}; };
setDesktops((open) => (open.every(fits) ? open : open.filter(fits))); setDesktops((open) => (open.every(fits) ? open : open.filter(fits)));
}, [view]); }, [view]);
/** Opens a computer's desktop, or a switch's or home router's viewer, or brings it to the front. */ // The data exchange window's tabs (Java keeps them when the window is closed); a card that is gone loses its tab
const openDesktop = useCallback((id: string, viewer?: ViewerKind) => { // (Java: clearUnavailableComponents). Without a tab the window shows nothing; it keeps its place, as Java's dialog does.
const [captureTabs, setCaptureTabs] = useState<{ readonly tabs: readonly OpenCaptureTab[]; readonly selected: string | null }>({ tabs: [], selected: null });
const changeCaptureTab = useCallback(
(port: string, change: Partial<OpenCaptureTab>) => setCaptureTabs((before) => ({ ...before, tabs: before.tabs.map((tab) => (tab.port === port ? { ...tab, ...change } : tab)) })),
[],
);
useEffect(() => {
const cards = new Set(view.nodes.flatMap((node) => captureCards(doc, node.id).map((card) => `${node.id}/${card.portId}`)));
setCaptureTabs((before) => {
const tabs = before.tabs.filter((tab) => cards.has(`${tab.node}/${tab.port}`));
if (tabs.length === before.tabs.length) return before;
return { tabs, selected: tabs.some((tab) => tab.port === before.selected) ? before.selected : (tabs.at(-1)?.port ?? null) };
});
}, [view, doc]);
/** Opens a computer's desktop, or a switch's or home router's viewer, or the data exchange window, or brings it to the front. */
const openDesktop = useCallback((id: string, viewer?: ViewerKind | 'capture') => {
setDesktops((open) => { setDesktops((open) => {
if (open.some((each) => each.id === id)) return toFront(open, id); if (open.some((each) => each.id === id)) return toFront(open, id);
// Java stacks a desktop that does not fit beside the others a little further on. // Java stacks a desktop that does not fit beside the others a little further on.
@@ -200,11 +234,25 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
[doc, origin, manager], [doc, origin, manager],
); );
/** Shows a card's frames in the data exchange window: its tab chosen, added where it has none (AggregatedExchangePanel.addTable). */
const openCapture = useCallback(
(node: string, port: string) => {
setCaptureTabs((before) => ({ tabs: before.tabs.some((tab) => tab.port === port) ? before.tabs : [...before.tabs, { node, port, chosen: undefined, autoscroll: true }], selected: port }));
openDesktop(CAPTURE_WINDOW, 'capture');
},
[openDesktop],
);
const menuItems = (target: Selection): MenuItem[] => { const menuItems = (target: Selection): MenuItem[] => {
if (simulating) { if (simulating) {
// Java's menu in the simulation: a computer's desktop (its data exchange follows with the capture, F4.7). // Java's menu in the simulation: a computer's desktop, then each card's data exchange with its address.
if (target.kind !== 'device') return [];
const node = view.nodes.find((each) => each.id === target.id); const node = view.nodes.find((each) => each.id === target.id);
return target.kind === 'device' && (node?.kind === 'pc' || node?.kind === 'notebook') ? [{ key: 'showDesktop', label: t('menu.showDesktop'), onChoose: () => openDesktop(target.id) }] : []; const items: MenuItem[] = node?.kind === 'pc' || node?.kind === 'notebook' ? [{ key: 'showDesktop', label: t('menu.showDesktop'), onChoose: () => openDesktop(target.id) }] : [];
for (const card of captureCards(doc, target.id)) {
items.push({ key: `showCapture-${card.portId}`, label: `${t('menu.showCapture')} (${card.ipAddress})`, onChoose: () => openCapture(target.id, card.portId) });
}
return items;
} }
if (target.kind === 'link') return [{ key: 'removeCable', label: t('menu.removeCable'), onChoose: () => remove(target) }]; if (target.kind === 'link') return [{ key: 'removeCable', label: t('menu.removeCable'), onChoose: () => remove(target) }];
const node = view.nodes.find((each) => each.id === target.id); const node = view.nodes.find((each) => each.id === target.id);
@@ -310,7 +358,36 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
</div> </div>
{simulating {simulating
? desktops.map((desktop) => ? desktops.map((desktop) =>
desktop.viewer !== undefined ? ( desktop.viewer === 'capture' ? (
captureTabs.selected === null ? null : (
<CaptureWindow
key={desktop.id}
tabs={captureTabs.tabs.map((tab) => ({ ...tab, title: captureCards(doc, tab.node).find((card) => card.portId === tab.port)?.title ?? '' }))}
selected={captureTabs.selected}
simulation={simulation}
x={desktop.x}
y={desktop.y}
z={desktop.z}
onSelect={(port) => setCaptureTabs((before) => ({ ...before, selected: port }))}
onCloseTab={(port) =>
setCaptureTabs((before) => {
const tabs = before.tabs.filter((tab) => tab.port !== port);
return { tabs, selected: before.selected === port ? (tabs.at(-1)?.port ?? null) : before.selected };
})
}
onChoose={(port, number) => changeCaptureTab(port, { chosen: number })}
onAutoscroll={(port, autoscroll) => changeCaptureTab(port, { autoscroll })}
onClear={() => {
// Java's tables begin anew, with nothing chosen.
setCaptureTabs((before) => ({ ...before, tabs: before.tabs.map((tab) => ({ ...tab, chosen: undefined })) }));
simulation.clearCapture();
}}
onMove={(x, y) => setDesktops((open) => open.map((each) => (each.id === desktop.id ? { ...each, x, y } : each)))}
onFront={() => setDesktops((open) => toFront(open, desktop.id))}
onClose={() => setDesktops((open) => open.filter((each) => each.id !== desktop.id))}
/>
)
) : desktop.viewer !== undefined ? (
<ViewerWindow <ViewerWindow
key={desktop.id} key={desktop.id}
kind={desktop.viewer} kind={desktop.viewer}
+177
View File
@@ -1341,3 +1341,180 @@
white-space: pre-wrap; white-space: pre-wrap;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
/* The data exchange window (AggregatedExchangeDialog): Java's is three
* quarters of the screen wide and four fifths high; the tabs, a bar for what
* Java's right mouse button offers, the table (a row per frame in its layer's
* colour, Java's fonts by column) and the chosen frame's details below. */
.capture-window {
width: 960px;
height: 640px;
}
.capture-tabs {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 6px 8px 0;
background: var(--surface-raised);
border-bottom: 1px solid var(--border-default);
}
.capture-tab {
display: inline-flex;
align-items: center;
border: 1px solid var(--border-default);
border-bottom: none;
border-radius: 6px 6px 0 0;
background: var(--surface-sunken);
}
.capture-tab button {
min-height: 36px;
border: none;
background: transparent;
color: var(--text-primary);
font: inherit;
font-size: 13px;
cursor: pointer;
}
.capture-tab button[aria-selected='true'] {
font-weight: 600;
}
.capture-tab:has(button[aria-selected='true']) {
background: var(--surface-card);
}
.capture-tab-close {
min-width: 36px;
color: var(--text-secondary) !important;
font-size: 18px !important;
}
.capture-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: var(--surface-card);
color: var(--text-primary);
}
.capture-bar button {
min-height: 44px;
padding: 0 12px;
border: 1px solid var(--border-default);
border-radius: 8px;
background: var(--surface-raised);
color: var(--text-primary);
font: inherit;
cursor: pointer;
}
.capture-bar label {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 44px;
}
.capture-bar input {
width: 20px;
height: 20px;
}
.capture-table {
flex: 3;
min-height: 0;
overflow: auto;
background: var(--surface-sunken);
}
.capture-table table {
width: 100%;
min-width: 760px;
border-collapse: separate;
border-spacing: 0 2px;
font-size: 12px;
}
.capture-table th {
position: sticky;
top: 0;
padding: 6px;
background: var(--surface-raised);
color: var(--text-primary);
text-align: left;
font-weight: 600;
}
.capture-row {
color: var(--capture-on-layer);
cursor: pointer;
}
.capture-row td {
padding: 4px 6px;
white-space: nowrap;
}
/* Java's fonts: number and time plain, addresses and comment monospaced, the protocol bold, the layer bold italic. */
.capture-row td:nth-child(3),
.capture-row td:nth-child(4),
.capture-row td:nth-child(7) {
font-family: ui-monospace, Menlo, Consolas, monospace;
}
.capture-row td:nth-child(5) {
font-weight: 700;
}
.capture-row td:nth-child(6) {
font-weight: 700;
font-style: italic;
}
.capture-networkAccess {
background: var(--capture-network-access);
}
.capture-internet {
background: var(--capture-internet);
}
.capture-transport {
background: var(--capture-transport);
}
.capture-application {
background: var(--capture-application);
}
.capture-row[aria-selected='true'] {
background: var(--capture-chosen);
color: var(--capture-on-chosen);
}
.capture-row:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: -2px;
}
/* MessageDetailsPanel: a tree in bold monospace, a long comment on lines of its own. */
.capture-details {
flex: 2;
min-height: 0;
margin: 0;
padding: 6px;
overflow: auto;
list-style: none;
border-top: 1px solid var(--border-strong);
background: var(--surface-card);
color: var(--text-primary);
font: 700 12px ui-monospace, Menlo, Consolas, monospace;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
+20
View File
@@ -18,6 +18,25 @@
"cable.wanOccupied": "WAN-Anschluss (extern) bereits belegt!", "cable.wanOccupied": "WAN-Anschluss (extern) bereits belegt!",
"cable.wirelessNotPossible": "Drahtlose Verbindung nicht möglich", "cable.wirelessNotPossible": "Drahtlose Verbindung nicht möglich",
"canvas.label": "Netzwerk", "canvas.label": "Netzwerk",
"capture.application": "Anwendung",
"capture.autoscroll": "Automatisches Scrollen",
"capture.clear": "Tabellen löschen",
"capture.closeTab": "Reiter schließen",
"capture.comment": "Bemerkungen / Details",
"capture.destination": "Ziel",
"capture.export": "Als Textdatei exportieren",
"capture.internet": "Vermittlung",
"capture.layer": "Schicht",
"capture.macIs": "MAC ist",
"capture.networkAccess": "Netzzugang",
"capture.number": "Nr.",
"capture.protocol": "Protokoll",
"capture.protocolWord": "Protokoll",
"capture.searchForMac": "Suche nach MAC für",
"capture.source": "Quelle",
"capture.time": "Zeit",
"capture.title": "Datenaustausch",
"capture.transport": "Transport",
"connections.add": "Schnittstelle hinzufügen", "connections.add": "Schnittstelle hinzufügen",
"connections.fewest": "Eine Schnittstelle bleibt immer.", "connections.fewest": "Eine Schnittstelle bleibt immer.",
"connections.hint": "Hinweis: Zum Umstecken von Verbindungen:\n1. Verbindung auswählen durch Klick auf Schaltfläche links neben der Schnittstellenbezeichnung\n2. Auswählen der neuen Schnittstelle durch Klick auf die entsprechende Schaltfläche\nSollte die Zielschnittstelle bereits belegt sein, so werden die Verbindungen vertauscht.", "connections.hint": "Hinweis: Zum Umstecken von Verbindungen:\n1. Verbindung auswählen durch Klick auf Schaltfläche links neben der Schnittstellenbezeichnung\n2. Auswählen der neuen Schnittstelle durch Klick auf die entsprechende Schaltfläche\nSollte die Zielschnittstelle bereits belegt sein, so werden die Verbindungen vertauscht.",
@@ -272,6 +291,7 @@
"menu.delete": "Löschen", "menu.delete": "Löschen",
"menu.removeAllCables": "Alle Verbindungen entfernen", "menu.removeAllCables": "Alle Verbindungen entfernen",
"menu.removeCable": "Verbindung entfernen", "menu.removeCable": "Verbindung entfernen",
"menu.showCapture": "Datenaustausch anzeigen",
"menu.showDesktop": "Desktop anzeigen", "menu.showDesktop": "Desktop anzeigen",
"mode.currentSpeed": "Aktuelle Simulationsgeschwindigkeit", "mode.currentSpeed": "Aktuelle Simulationsgeschwindigkeit",
"mode.design": "Wechselt in den Entwurfsmodus", "mode.design": "Wechselt in den Entwurfsmodus",
+20
View File
@@ -18,6 +18,25 @@
"cable.wanOccupied": "WAN port (external) already occupied!", "cable.wanOccupied": "WAN port (external) already occupied!",
"cable.wirelessNotPossible": "Wireless connection not possible", "cable.wirelessNotPossible": "Wireless connection not possible",
"canvas.label": "Network", "canvas.label": "Network",
"capture.application": "Application",
"capture.autoscroll": "Autoscroll",
"capture.clear": "Clear tables",
"capture.closeTab": "Close tab",
"capture.comment": "Comment / Details",
"capture.destination": "Destination",
"capture.export": "Export as text file",
"capture.internet": "Internet",
"capture.layer": "Layer",
"capture.macIs": "MAC is",
"capture.networkAccess": "Network",
"capture.number": "No.",
"capture.protocol": "Protocol",
"capture.protocolWord": "Protocol",
"capture.searchForMac": "Search for MAC",
"capture.source": "Source",
"capture.time": "Time",
"capture.title": "Data exchange",
"capture.transport": "Transport",
"connections.add": "Add interface", "connections.add": "Add interface",
"connections.fewest": "One interface always stays.", "connections.fewest": "One interface always stays.",
"connections.hint": "Note: For reorganising cable connections:\n1. Choose cable connection by clicking the button next to the interface name\n2. Choose new interface by clicking according button\nIf the target interface is already occupied by another connection, then those cables will be swapped.", "connections.hint": "Note: For reorganising cable connections:\n1. Choose cable connection by clicking the button next to the interface name\n2. Choose new interface by clicking according button\nIf the target interface is already occupied by another connection, then those cables will be swapped.",
@@ -272,6 +291,7 @@
"menu.delete": "Delete", "menu.delete": "Delete",
"menu.removeAllCables": "Remove all cables", "menu.removeAllCables": "Remove all cables",
"menu.removeCable": "Remove cable", "menu.removeCable": "Remove cable",
"menu.showCapture": "Show data exchange",
"menu.showDesktop": "Show desktop", "menu.showDesktop": "Show desktop",
"mode.currentSpeed": "Current Simulation Velocity", "mode.currentSpeed": "Current Simulation Velocity",
"mode.design": "Change to Design Mode", "mode.design": "Change to Design Mode",
@@ -0,0 +1,182 @@
// The data exchange window while the simulation runs, after Java's
// AggregatedExchangeDialog (measured with tools/java-oracle's
// ExchangeWindowRun): a tab per card ("<label> - <address>", or the address
// where the label is it), each with a close button; the card's frames, one
// row per frame in its highest layer's colour (Java's LayerColorVar); the
// chosen frame's details below; „Tabellen löschen“ (every card's list),
// „Als Textdatei exportieren“ (named after the tab, as Java offers) and
// „Automatisches Scrollen“ (on, as in Java). The frames are the room's.
//
// Better than Java: the three are buttons and a box in a bar (Java's are in a
// menu of the right mouse button); a frame is chosen by its number (Java's
// details take the row's index for it); an empty comment is exported empty
// (Java writes "null").
import { memo, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react';
import type { TraceFrame } from '@mdg-filius/protocol';
import { useLanguage, useTranslate } from '../i18n/i18n.js';
import { captureWords, exportText, frameCells, frameDetails, type CaptureWords } from './captureCells.js';
import type { Simulation } from './useSimulation.js';
/** A card's tab: its title, the frame chosen in it and whether it scrolls along — each table's own, as in Java, kept while the window is closed. */
export interface CaptureTab {
readonly port: string;
readonly title: string;
readonly chosen: number | undefined;
readonly autoscroll: boolean;
}
export interface CaptureWindowProps {
readonly tabs: readonly CaptureTab[];
readonly selected: string;
readonly simulation: Simulation;
readonly x: number;
readonly y: number;
readonly z: number;
readonly onSelect: (port: string) => void;
readonly onCloseTab: (port: string) => void;
readonly onChoose: (port: string, number: number) => void;
readonly onAutoscroll: (port: string, on: boolean) => void;
/** "Tabellen löschen": every card's frames, and what was chosen in them. */
readonly onClear: () => void;
readonly onMove: (x: number, y: number) => void;
readonly onFront: () => void;
readonly onClose: () => void;
}
const FrameRow = memo(function FrameRow({
frame,
clock,
words,
chosen,
onChoose,
}: {
readonly frame: TraceFrame;
readonly clock: number;
readonly words: CaptureWords;
readonly chosen: boolean;
readonly onChoose: (number: number) => void;
}) {
const cells = frameCells(frame, clock, words);
const layer = frame.rows.at(-1)!.layer;
return (
<tr
className={`capture-row capture-${layer}`}
aria-selected={chosen}
tabIndex={0}
onClick={() => onChoose(frame.number)}
onKeyDown={(event) => (event.key === 'Enter' || event.key === ' ') && onChoose(frame.number)}
>
{cells.map((cell, column) => (
// Java's cell shows a line break as a space.
<td key={column}>{cell.replaceAll('\n', ' ')}</td>
))}
</tr>
);
});
export function CaptureWindow({ tabs, selected, simulation, x, y, z, onSelect, onCloseTab, onChoose, onAutoscroll, onClear, onMove, onFront, onClose }: CaptureWindowProps) {
const t = useTranslate();
const language = useLanguage();
const words = useMemo(() => captureWords(t, language), [t, language]);
const { watch, clock } = simulation;
useEffect(() => watch([`trace:${selected}`]), [watch, selected]);
const frames = simulation.traces.get(selected) ?? [];
const scroller = useRef<HTMLDivElement>(null);
const [drag, setDrag] = useState<{ readonly pointer: number; readonly dx: number; readonly dy: number; moved: boolean } | null>(null);
const title = t('capture.title');
const tab = tabs.find((each) => each.port === selected);
const chosenFrame = frames.find((frame) => frame.number === tab?.chosen);
const autoscroll = tab?.autoscroll ?? true;
// New frames scroll the table to its end while "Automatisches Scrollen" is on.
useEffect(() => {
if (autoscroll && scroller.current !== null) scroller.current.scrollTop = scroller.current.scrollHeight;
}, [autoscroll, frames.length, selected]);
// The title bar moves the window; the pointer is captured once it moves (pointer-capture trap).
const onPointerDown = (event: ReactPointerEvent<HTMLElement>) => {
if ((event.target as Element).closest('button') !== null) return;
setDrag({ pointer: event.pointerId, dx: event.clientX - x, dy: event.clientY - y, moved: false });
};
const onPointerMove = (event: ReactPointerEvent<HTMLElement>) => {
if (drag === null || drag.pointer !== event.pointerId) return;
if (!drag.moved) {
drag.moved = true;
event.currentTarget.setPointerCapture(event.pointerId);
}
onMove(Math.max(0, event.clientX - drag.dx), Math.max(0, event.clientY - drag.dy));
};
const onPointerUp = () => setDrag(null);
const choose = useMemo(() => (number: number) => onChoose(selected, number), [onChoose, selected]);
const download = () => {
const url = URL.createObjectURL(new Blob([exportText(frames, clock, words)], { type: 'text/plain;charset=utf-8' }));
const link = document.createElement('a');
link.href = url;
link.download = `${tab?.title ?? title}.txt`;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 0);
};
return (
<section className="desktop-window capture-window" role="dialog" aria-label={title} data-capture="" style={{ left: x, top: y, zIndex: 5 + z }} onPointerDownCapture={onFront}>
<header className="desktop-titlebar" onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp}>
<h2>{title}</h2>
<button type="button" className="desktop-close" aria-label={t('sheet.close')} title={t('sheet.close')} onClick={onClose}>
×
</button>
</header>
<div className="capture-tabs" role="tablist">
{tabs.map((each) => (
<span key={each.port} className="capture-tab">
<button type="button" role="tab" aria-selected={each.port === selected} onClick={() => onSelect(each.port)}>
{each.title}
</button>
<button type="button" className="capture-tab-close" aria-label={`${t('capture.closeTab')}: ${each.title}`} title={t('capture.closeTab')} onClick={() => onCloseTab(each.port)}>
×
</button>
</span>
))}
</div>
<div className="capture-bar">
<button type="button" onClick={onClear}>
{t('capture.clear')}
</button>
<button type="button" onClick={download}>
{t('capture.export')}
</button>
<label>
<input type="checkbox" checked={autoscroll} onChange={(event) => onAutoscroll(selected, event.target.checked)} />
{t('capture.autoscroll')}
</label>
</div>
<div className="capture-table" ref={scroller}>
<table aria-label={tab?.title ?? title}>
<thead>
<tr>
{words.header.map((column) => (
<th key={column}>{column}</th>
))}
</tr>
</thead>
<tbody>
{frames.map((frame) => (
<FrameRow key={frame.number} frame={frame} clock={clock} words={words} chosen={frame === chosenFrame} onChoose={choose} />
))}
</tbody>
</table>
</div>
<ul className="capture-details" aria-label={words.detail.comment}>
{chosenFrame === undefined
? null
: frameDetails(chosenFrame, clock, words).map((line, index) => (
<li key={index} style={{ paddingLeft: 4 + 16 * line.depth }}>
{line.text}
</li>
))}
</ul>
</section>
);
}
@@ -0,0 +1,71 @@
// What the data exchange window writes of a frame, in the viewer's words: the
// seven cells of its row (Java's columns: number, time, source, destination,
// protocol, layer, comment), its details, the table as a text file. The
// rules are the core's (captureWindow.ts, measured against Java); the words,
// the number format and the time of day are the viewer's, as Java's are its
// locale's and its wall clock's.
import {
captureCommentText,
captureDetailLines,
exchangeExportText,
exchangeRow,
type CaptureCommentWords,
type CaptureDetailLine,
type CaptureDetailWords,
type CaptureLayer,
type CaptureRow,
} from '@mdg-filius/core';
import type { TraceFrame } from '@mdg-filius/protocol';
import { timeOfDay } from './timeOfDay.js';
export interface CaptureWords {
readonly header: readonly string[];
readonly layers: Readonly<Record<CaptureLayer, string>>;
readonly comment: CaptureCommentWords;
readonly detail: CaptureDetailWords;
/** The locale's numbers (Java: NumberFormat of its locale, "1.000.000" in German). */
readonly formatNumber: (value: number) => string;
}
/** The words of the editor's catalog in its language. */
export function captureWords(t: (key: string) => string, language: string): CaptureWords {
const header = ['number', 'time', 'source', 'destination', 'protocol', 'layer', 'comment'].map((key) => t(`capture.${key}`));
const numbers = new Intl.NumberFormat(language === 'de' ? 'de-DE' : 'en-GB');
return {
header,
layers: { networkAccess: t('capture.networkAccess'), internet: t('capture.internet'), transport: t('capture.transport'), application: t('capture.application') },
comment: { protocol: t('capture.protocolWord'), searchForMac: t('capture.searchForMac'), macIs: t('capture.macIs') },
detail: { number: header[0]!, time: header[1]!, source: header[2]!, destination: header[3]!, protocol: header[4]!, comment: header[6]! },
formatNumber: (value) => numbers.format(value),
};
}
const commentOf = (row: CaptureRow, words: CaptureWords) => captureCommentText(row.comment, words.comment, words.formatNumber);
/** The frame's row, its highest layer's, as seven cells; the comment as it is (with its line breaks). */
export function frameCells(frame: TraceFrame, clock: number, words: CaptureWords): string[] {
const row = exchangeRow(frame.rows);
return [String(row.number), timeOfDay(clock, row.timeMilliseconds, true), row.source ?? '', row.destination ?? '', row.protocol, words.layers[row.layer], commentOf(row, words)];
}
/** The frame's details, from its layered rows. */
export function frameDetails(frame: TraceFrame, clock: number, words: CaptureWords): CaptureDetailLine[] {
const first = frame.layeredRows[0];
if (first === undefined) return [];
return captureDetailLines(
frame.layeredRows,
timeOfDay(clock, first.timeMilliseconds, true),
words.detail,
(row) => words.layers[row.layer],
(row) => commentOf(row, words),
);
}
/** The card's table as Java's text file. */
export function exportText(frames: readonly TraceFrame[], clock: number, words: CaptureWords): string {
return exchangeExportText(
words.header,
frames.map((frame) => frameCells(frame, clock, words)),
);
}
@@ -1,12 +1,13 @@
// The editor's side of the simulation room (plan §6): its mode and speed, // The editor's side of the simulation room (plan §6): its mode and speed,
// the cables that carry frames, a computer's network as it runs, the lines // the cables that carry frames, a computer's network as it runs, the lines
// and views of the programs' windows and the actions taken in them. The room // and views of the programs' windows and the actions taken in them, the
// cards' frames for the data exchange window. The room
// runs behind a SimHost — in a worker of this page (LocalSimHost) until F // runs behind a SimHost — in a worker of this page (LocalSimHost) until F
// holds it (RemoteSimHost, F5); the editor keeps no simulation of its own. // holds it (RemoteSimHost, F5); the editor keeps no simulation of its own.
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import type * as Y from 'yjs'; import type * as Y from 'yjs';
import type { AppKind, DroppedUnit, NodeSettings, SimHost, SimMode, Topic } from '@mdg-filius/protocol'; import type { AppKind, DroppedUnit, NodeSettings, SimHost, SimMode, Topic, TraceFrame } from '@mdg-filius/protocol';
import { LocalSimHost } from '@mdg-filius/sim'; import { LocalSimHost } from '@mdg-filius/sim';
import SimWorker from '@mdg-filius/sim/worker?worker'; import SimWorker from '@mdg-filius/sim/worker?worker';
import type { Language } from '../i18n/i18n.js'; import type { Language } from '../i18n/i18n.js';
@@ -44,12 +45,16 @@ export interface Simulation {
readonly clock: number; readonly clock: number;
/** The frames "Pakete verwerfen" dropped in this simulation, while `dropped` is watched. */ /** The frames "Pakete verwerfen" dropped in this simulation, while `dropped` is watched. */
readonly dropped: readonly DroppedUnit[]; readonly dropped: readonly DroppedUnit[];
/** Each watched card's frames as the data exchange window shows them, by its port. */
readonly traces: ReadonlyMap<string, readonly TraceFrame[]>;
/** Starts in the language the person runs FILIUS in: the routers' pages come in it, as Java's in its locale. */ /** Starts in the language the person runs FILIUS in: the routers' pages come in it, as Java's in its locale. */
start(language?: Language): void; start(language?: Language): void;
stop(): void; stop(): void;
setSpeed(value: number): void; setSpeed(value: number): void;
/** Java's "Pakete verwerfen": held (true) or let go (false). */ /** Java's "Pakete verwerfen": held (true) or let go (false). */
drop(on: boolean): void; drop(on: boolean): void;
/** Java's "Tabellen löschen": every card's frames and the dropped ones are forgotten. */
clearCapture(): void;
/** Watches these topics until the returned function is called; a topic two parts watch is sent for once. */ /** Watches these topics until the returned function is called; a topic two parts watch is sent for once. */
watch(topics: readonly Topic[]): () => void; watch(topics: readonly Topic[]): () => void;
/** An action in a program's window (the room answers with its new lines). */ /** An action in a program's window (the room answers with its new lines). */
@@ -70,6 +75,7 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
const [views, setViews] = useState<ReadonlyMap<string, unknown>>(new Map()); const [views, setViews] = useState<ReadonlyMap<string, unknown>>(new Map());
const [clock, setClock] = useState(0); const [clock, setClock] = useState(0);
const [dropped, setDropped] = useState<readonly DroppedUnit[]>([]); const [dropped, setDropped] = useState<readonly DroppedUnit[]>([]);
const [traces, setTraces] = useState<ReadonlyMap<string, readonly TraceFrame[]>>(new Map());
useEffect(() => { useEffect(() => {
const made = createHost(doc); const made = createHost(doc);
@@ -97,6 +103,9 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
return new Map(before).set(topic, { lines, live: message.from === 0 ? lines.length : (before.get(topic)?.live ?? 0) }); return new Map(before).set(topic, { lines, live: message.from === 0 ? lines.length : (before.get(topic)?.live ?? 0) });
}); });
return; return;
case 'trace':
setTraces((before) => new Map(before).set(message.portId, message.reset ? message.frames : (before.get(message.portId) ?? []).concat(message.frames)));
return;
case 'dropped': case 'dropped':
setDropped((before) => (message.reset ? message.units : [...before, ...message.units])); setDropped((before) => (message.reset ? message.units : [...before, ...message.units]));
return; return;
@@ -125,6 +134,7 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
const stop = useCallback(() => host.current?.send({ v: 1, kind: 'stop' }), []); const stop = useCallback(() => host.current?.send({ v: 1, kind: 'stop' }), []);
const setSpeed = useCallback((value: number) => host.current?.send({ v: 1, kind: 'speed', value }), []); const setSpeed = useCallback((value: number) => host.current?.send({ v: 1, kind: 'speed', value }), []);
const drop = useCallback((on: boolean) => host.current?.send({ v: 1, kind: 'drop', on }), []); const drop = useCallback((on: boolean) => host.current?.send({ v: 1, kind: 'drop', on }), []);
const clearCapture = useCallback(() => host.current?.send({ v: 1, kind: 'clearCapture' }), []);
const watch = useCallback((topics: readonly Topic[]) => { const watch = useCallback((topics: readonly Topic[]) => {
const fresh = topics.filter((topic) => { const fresh = topics.filter((topic) => {
const count = watched.current.get(topic) ?? 0; const count = watched.current.get(topic) ?? 0;
@@ -147,5 +157,5 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
host.current?.send({ v: 1, kind: 'input', node, app, action, args, reqId: crypto.randomUUID() }); host.current?.send({ v: 1, kind: 'input', node, app, action, args, reqId: crypto.randomUUID() });
}, []); }, []);
return { mode, speed, activeLinks, nodes, apps, views, failed, clock, dropped, start, stop, setSpeed, drop, watch, act }; return { mode, speed, activeLinks, nodes, apps, views, failed, clock, dropped, traces, start, stop, setSpeed, drop, clearCapture, watch, act };
} }
+15
View File
@@ -37,6 +37,14 @@
--connection-plugged: rgb(0 255 0); --connection-plugged: rgb(0 255 0);
--connection-free: rgb(255 0 0); --connection-free: rgb(255 0 0);
--connection-marked: rgb(255 255 0); --connection-marked: rgb(255 255 0);
/* Java's layer colours of the data exchange window (LayerColorVar), black text; a chosen row white on black. */
--capture-network-access: #c9c9c9;
--capture-internet: #8faadc;
--capture-transport: #f4b183;
--capture-application: #ffd966;
--capture-on-layer: #000000;
--capture-chosen: #000000;
--capture-on-chosen: #ffffff;
} }
:root[data-theme='dark'] { :root[data-theme='dark'] {
@@ -71,6 +79,13 @@
--connection-plugged: rgb(0 190 0); --connection-plugged: rgb(0 190 0);
--connection-free: rgb(210 40 40); --connection-free: rgb(210 40 40);
--connection-marked: rgb(235 215 0); --connection-marked: rgb(235 215 0);
--capture-network-access: #3a3f47;
--capture-internet: #2c4369;
--capture-transport: #6b4128;
--capture-application: #5f4f17;
--capture-on-layer: #e6e9ef;
--capture-chosen: #e6e9ef;
--capture-on-chosen: #14171c;
} }
html, html,
+78
View File
@@ -0,0 +1,78 @@
// What the data exchange window writes of a frame in the viewer's language:
// Java's words of both bundles, the locale's numbers ("1.000.000" in German,
// "1,000,000" in English), the time of day with milliseconds, the row of the
// highest layer, the details from the layered rows, the text file.
import { describe, expect, it } from 'vitest';
import { captureRows, TCP_FLAG, type CapturedFrame, type EthernetFrame } from '@mdg-filius/core';
import type { TraceFrame } from '@mdg-filius/protocol';
import { translate } from '../src/i18n/i18n.js';
import { captureWords, exportText, frameCells, frameDetails } from '../src/simulation/captureCells.js';
/** A TCP segment with data from 10.0.0.1:50000 to 10.0.0.2:80, as the room sends it. */
function frame(number: number, timeMilliseconds: number): TraceFrame {
const ethernet: EthernetFrame = {
identity: number,
sourceMacAddress: '02:00:00:00:00:01',
destinationMacAddress: '02:00:00:00:00:02',
payload: {
kind: 'ipv4',
packet: {
identity: 9,
senderIPAddress: '10.0.0.1',
receiverIPAddress: '10.0.0.2',
timeToLive: 64,
protocol: 'tcp',
payload: {
kind: 'tcp',
segment: {
sourcePort: 50_000,
destinationPort: 80,
sequenceNumber: 2_000_001,
acknowledgementNumber: 1_000_001,
flags: TCP_FLAG.acknowledgement,
payload: new TextEncoder().encode('GET / HTTP/1.1\nHost: x\n'),
},
},
},
},
};
const captured: CapturedFrame[] = [{ number, timeMilliseconds, frame: ethernet }];
return { number, rows: captureRows(captured, { withAddresses: true }), layeredRows: captureRows(captured, { withAddresses: false }) };
}
const words = (language: 'de' | 'en') => captureWords((key) => translate(language, key), language);
const clock = new Date(2026, 9, 4, 8, 30, 0, 0).getTime();
describe("the data exchange window's cells", () => {
it("writes a frame's row by its highest layer, in either language", () => {
expect(frameCells(frame(3, 1_234), clock, words('de'))).toEqual(['3', '08:30:01.234', '10.0.0.1:50000', '10.0.0.2:80', 'HTTP', 'Anwendung', 'GET / HTTP/1.1\nHost: x\n']);
expect(frameCells(frame(3, 1_234), clock, words('en'))).toEqual(['3', '08:30:01.234', '10.0.0.1:50000', '10.0.0.2:80', 'HTTP', 'Application', 'GET / HTTP/1.1\nHost: x\n']);
expect(words('de').header).toEqual(['Nr.', 'Zeit', 'Quelle', 'Ziel', 'Protokoll', 'Schicht', 'Bemerkungen / Details']);
expect(words('en').header).toEqual(['No.', 'Time', 'Source', 'Destination', 'Protocol', 'Layer', 'Comment / Details']);
});
it("writes the details with the locale's numbers and Java's words", () => {
const german = frameDetails(frame(3, 0), clock, words('de')).map((line) => line.text);
expect(german[0]).toBe('Nr.: 3 / Zeit: 08:30:00.000');
expect(german).toContain('Bemerkungen / Details: SEQ: 2.000.001, ACK: 1.000.001');
expect(german).toContain('Bemerkungen / Details: Protokoll: 6, TTL: 64');
expect(german).toContain('Bemerkungen / Details (23 Bytes): ');
const english = frameDetails(frame(3, 0), clock, words('en')).map((line) => line.text);
expect(english[0]).toBe('No.: 3 / Time: 08:30:00.000');
expect(english).toContain('Comment / Details: SEQ: 2,000,001, ACK: 1,000,001');
expect(english).toContain('Comment / Details: Protocol: 6, TTL: 64');
expect(frameDetails({ number: 1, rows: [], layeredRows: [] }, clock, words('de'))).toEqual([]);
});
it('exports the table with the words of its header', () => {
const text = exportText([frame(1, 0), frame(2, 5)], clock, words('en'));
expect(text.split('\r\n')[1]).toBe(
`| No. | Time | Source | Destination | Protocol | Layer | ${'Comment / Details'.padEnd(40)} | `,
);
expect(text.split('\r\n')[3]).toBe(
`| 1 | 08:30:00.000 | 10.0.0.1:50000 | 10.0.0.2:80 | HTTP | Application | ${'GET / HTTP/1.1 Host: x'.padEnd(40)} | `,
);
expect(text.match(/^\+-/gm)).toHaveLength(2);
});
});
+20
View File
@@ -244,6 +244,26 @@ const FROM_JAVA: Readonly<Record<string, string>> = {
'installation.apply': 'installationsdialog_msg2', 'installation.apply': 'installationsdialog_msg2',
'mode.currentSpeed': 'guimainmemu_msg15', 'mode.currentSpeed': 'guimainmemu_msg15',
'menu.showDesktop': 'guievents_msg3', 'menu.showDesktop': 'guievents_msg3',
'capture.title': 'lauscherdialog_msg1',
'capture.number': 'rp_lauscher_msg1',
'capture.time': 'rp_lauscher_msg2',
'capture.source': 'rp_lauscher_msg3',
'capture.destination': 'rp_lauscher_msg4',
'capture.protocol': 'rp_lauscher_msg5',
'capture.layer': 'rp_lauscher_msg6',
'capture.comment': 'rp_lauscher_msg7',
'capture.networkAccess': 'rp_lauscher_msg8',
'capture.internet': 'rp_lauscher_msg9',
'capture.transport': 'rp_lauscher_msg10',
'capture.application': 'rp_lauscher_msg11',
'capture.protocolWord': 'rp_lauscher_msg12',
'capture.searchForMac': 'rp_lauscher_msg13',
'capture.macIs': 'rp_lauscher_msg14',
'capture.clear': 'nachrichtentabelle_msg7',
'capture.autoscroll': 'nachrichtentabelle_msg8',
'capture.export': 'nachrichtentabelle_msg9',
'capture.closeTab': 'buttontabcomponent_msg1',
'menu.showCapture': 'guievents_msg4',
'desktop.home': 'desktoppanel_application', 'desktop.home': 'desktoppanel_application',
'desktop.installation': 'desktoppanel_msg1', 'desktop.installation': 'desktoppanel_msg1',
'network.ipAddress': 'network_msg1', 'network.ipAddress': 'network_msg1',
+5 -1
View File
@@ -21,7 +21,7 @@ export type AppKind = (typeof APP_KINDS)[number];
*/ */
export type Topic = 'activity' | 'dropped' | `trace:${string}` | `app:${string}/${AppKind}` | `node:${string}`; export type Topic = 'activity' | 'dropped' | `trace:${string}` | `app:${string}/${AppKind}` | `node:${string}`;
export const CLIENT_MESSAGE_KINDS = ['sub', 'unsub', 'start', 'stop', 'speed', 'drop', 'input'] as const; export const CLIENT_MESSAGE_KINDS = ['sub', 'unsub', 'start', 'stop', 'speed', 'drop', 'clearCapture', 'input'] as const;
export type ClientMessageKind = (typeof CLIENT_MESSAGE_KINDS)[number]; export type ClientMessageKind = (typeof CLIENT_MESSAGE_KINDS)[number];
export type ClientMessage = export type ClientMessage =
@@ -39,6 +39,8 @@ export type ClientMessage =
| { readonly v: 1; readonly kind: 'speed'; readonly value: number } | { readonly v: 1; readonly kind: 'speed'; readonly value: number }
/** Java's "Pakete verwerfen" pressed (on) or let go: every cable drops what it carries while any client holds it. */ /** Java's "Pakete verwerfen" pressed (on) or let go: every cable drops what it carries while any client holds it. */
| { readonly v: 1; readonly kind: 'drop'; readonly on: boolean } | { readonly v: 1; readonly kind: 'drop'; readonly on: boolean }
/** Java's "Tabellen löschen" in the data exchange window: every card's capture and the dropped frames are emptied, the numbers begin anew. */
| { readonly v: 1; readonly kind: 'clearCapture' }
/** An action in a program's window, answered by `ack` or `error` with the same reqId. */ /** An action in a program's window, answered by `ack` or `error` with the same reqId. */
| { | {
readonly v: 1; readonly v: 1;
@@ -153,6 +155,8 @@ export function parseClientMessage(value: unknown): Parsed<ClientMessage> {
return { message: { v: 1, kind: 'start', language: value.language } }; return { message: { v: 1, kind: 'start', language: value.language } };
case 'stop': case 'stop':
return { message: { v: 1, kind: 'stop' } }; return { message: { v: 1, kind: 'stop' } };
case 'clearCapture':
return { message: { v: 1, kind: 'clearCapture' } };
case 'speed': case 'speed':
if (typeof value.value !== 'number' || !Number.isFinite(value.value)) return bad('badMessage', 'speed is no number'); if (typeof value.value !== 'number' || !Number.isFinite(value.value)) return bad('badMessage', 'speed is no number');
return { message: { v: 1, kind: 'speed', value: value.value } }; return { message: { v: 1, kind: 'speed', value: value.value } };
+2
View File
@@ -13,6 +13,7 @@ describe("a client's messages", () => {
{ v: 1, kind: 'stop' }, { v: 1, kind: 'stop' },
{ v: 1, kind: 'speed', value: 50 }, { v: 1, kind: 'speed', value: 50 },
{ v: 1, kind: 'drop', on: true }, { v: 1, kind: 'drop', on: true },
{ v: 1, kind: 'clearCapture' },
{ v: 1, kind: 'input', node: 'node-1', app: 'terminal', action: 'enter', args: { line: 'ping 1.2.3.4' }, reqId: 'r1' }, { v: 1, kind: 'input', node: 'node-1', app: 'terminal', action: 'enter', args: { line: 'ping 1.2.3.4' }, reqId: 'r1' },
]; ];
expect(messages.map((each) => each.kind)).toEqual([...CLIENT_MESSAGE_KINDS]); expect(messages.map((each) => each.kind)).toEqual([...CLIENT_MESSAGE_KINDS]);
@@ -20,6 +21,7 @@ describe("a client's messages", () => {
// A start in a language, and the language dropped where it is none. // A start in a language, and the language dropped where it is none.
for (const language of ['de', 'en']) expect(parseClientMessage({ v: 1, kind: 'start', language })).toEqual({ message: { v: 1, kind: 'start', language } }); for (const language of ['de', 'en']) expect(parseClientMessage({ v: 1, kind: 'start', language })).toEqual({ message: { v: 1, kind: 'start', language } });
expect(parseClientMessage({ v: 1, kind: 'stop', language: 'en' })).toEqual({ message: { v: 1, kind: 'stop' } }); expect(parseClientMessage({ v: 1, kind: 'stop', language: 'en' })).toEqual({ message: { v: 1, kind: 'stop' } });
expect(parseClientMessage({ v: 1, kind: 'clearCapture', port: 'p' })).toEqual({ message: { v: 1, kind: 'clearCapture' } });
}); });
it('names what is wrong', () => { it('names what is wrong', () => {
+15
View File
@@ -389,6 +389,21 @@ export class SimulationRoom {
else this.dropping.delete(client); else this.dropping.delete(client);
this.engine.setGlobalPacketLossEnabled(this.dropping.size > 0); this.engine.setGlobalPacketLossEnabled(this.dropping.size > 0);
return; return;
case 'clearCapture':
// Java's data exchange window is there only while the simulation runs.
if (this.mode !== 'simulation') {
client.send(message({ kind: 'error', code: 'notRunning' }));
return;
}
// Lauscher.reset: every card's list and the dropped frames; each watcher gets its lists anew, numbered from 1.
this.engine.clearCapture();
this.framesSeen.clear();
for (const each of this.clients.values()) {
each.traceSent.clear();
each.droppedSent = undefined;
}
this.flush([...this.clients.values()], false);
return;
case 'speed': case 'speed':
this.speed = Math.min(100, Math.max(1, Math.trunc(input.value))); this.speed = Math.min(100, Math.max(1, Math.trunc(input.value)));
this.engine.setSimulationSpeed(this.speed); this.engine.setSimulationSpeed(this.speed);
+132
View File
@@ -0,0 +1,132 @@
// The data exchange window's side of the room, on FILIUS' example
// nat_with_mapped_port: a card's frames as the window shows them (one row
// per frame from its rows, the details from its layered rows), "Tabellen
// löschen" — every card's list and the dropped frames emptied for every
// client, numbered anew, the cables still lighting up —, refused while the
// design is shown, and the lists kept over a stop and a start, as Java's
// Lauscher keeps them. The window's rows against Java's are
// packages/core/test/exchangeWindow.test.ts.
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 { readProject, setSoftware, writeProject } from '@mdg-filius/doc';
import { exchangeRow } from '@mdg-filius/core';
import { openProjectArchive } from '@mdg-filius/fls';
import type { HostMessage, TraceFrame } from '@mdg-filius/protocol';
import { SimulationRoom, TICK_MILLISECONDS } from '../src/index.js';
const corpus = join(dirname(fileURLToPath(import.meta.url)), '../../../corpus/java-examples');
function setUp() {
let counter = 0;
const doc = new Y.Doc();
writeProject(
doc,
openProjectArchive(new Uint8Array(readFileSync(join(corpus, 'nat_with_mapped_port.fls'))), () => `00000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`),
);
const project = readProject(doc);
const webServer = project.graph.nodes.find((node) => node.displayName === 'Webserver - 192.168.0.10')!;
const port = webServer.ports[0]!.id;
const link = project.graph.links.find((each) => each.sourcePortId === port || each.targetPortId === port)!.id;
setSoftware(doc, webServer.id, { install: ['commandPrompt'], remove: [] }, 'de', 'me');
let now = 0;
const room = new SimulationRoom(doc, { now: () => now, seed: () => 5 });
const connect = () => {
const traces: TraceFrame[] = [];
const resets: number[] = [];
const dropped: number[] = [];
const lit: string[][] = [];
const errors: string[] = [];
const id = room.connect((message: HostMessage) => {
if (message.kind === 'trace' && message.portId === port) {
if (message.reset) {
resets.push(message.frames.length);
traces.length = 0;
}
traces.push(...message.frames);
}
if (message.kind === 'dropped' && message.reset) dropped.push(message.units.length);
if (message.kind === 'activity') lit.push([...message.links]);
if (message.kind === 'error') errors.push(message.code);
});
return { id, traces, resets, dropped, lit, errors };
};
const send = (who: number, fields: object) => room.receive(who, { v: 1, ...fields });
const run = (milliseconds: number) => {
for (let elapsed = 0; elapsed < milliseconds; elapsed += TICK_MILLISECONDS) {
now += TICK_MILLISECONDS;
room.tick();
}
};
const ping = (who: number, target: string) => send(who, { kind: 'input', node: webServer.id, app: 'terminal', action: 'enter', args: { line: `ping ${target}` }, reqId: target });
return { port, link, connect, send, run, ping };
}
describe('the data exchange window in the room', () => {
it('shows a card\'s frames, and "Tabellen löschen" empties every list for every client and numbers anew', () => {
const { port, link, connect, send, run, ping } = setUp();
const first = connect();
const second = connect();
for (const client of [first, second]) send(client.id, { kind: 'sub', topics: [`trace:${port}`, 'dropped', 'activity'] });
// Not while the design is shown: Java's window is not there.
send(first.id, { kind: 'clearCapture' });
expect(first.errors).toEqual(['notRunning']);
send(first.id, { kind: 'start' });
run(TICK_MILLISECONDS);
ping(first.id, '192.168.0.1');
run(5_000);
// An ARP exchange and four pings with their answers: one row each, the highest layer's; the details from all of them.
expect(first.traces.map((frame) => exchangeRow(frame.rows).protocol)).toEqual(['ARP', 'ARP', ...Array.from({ length: 8 }, () => 'ICMP')]);
expect(first.traces.map((frame) => frame.number)).toEqual(Array.from({ length: 10 }, (_, index) => index + 1));
expect(new Set(first.traces.map((frame) => frame.layeredRows.map((row) => row.layer).join()))).toEqual(new Set(['networkAccess,internet']));
expect(second.traces).toEqual(first.traces);
// Something dropped, to be emptied with the rest.
send(first.id, { kind: 'drop', on: true });
ping(first.id, '192.168.0.1');
run(5_000);
send(first.id, { kind: 'drop', on: false });
const droppedBefore = second.dropped.length;
// Either client empties the lists of both.
send(second.id, { kind: 'clearCapture' });
for (const client of [first, second]) {
expect(client.resets.at(-1)).toBe(0);
expect(client.traces).toEqual([]);
expect(client.dropped.at(-1)).toBe(0);
}
expect(second.dropped.length).toBe(droppedBefore + 1);
// Numbered from 1 again, and the cable lights up with the first of them.
ping(first.id, '192.168.0.1');
for (let waited = 0; first.traces.length === 0 && waited < 5_000; waited += TICK_MILLISECONDS) run(TICK_MILLISECONDS);
expect(first.traces.map((frame) => frame.number)).toEqual([1]);
expect(first.lit.at(-1)).toContain(link);
run(5_000);
expect(first.traces.map((frame) => frame.number)).toEqual(first.traces.map((_, index) => index + 1));
expect(first.traces.length).toBeGreaterThanOrEqual(8);
expect(second.errors).toEqual([]);
});
it('keeps the lists over a stop and a start, numbering on', () => {
const { port, connect, send, run, ping } = setUp();
const client = connect();
send(client.id, { kind: 'start' });
run(TICK_MILLISECONDS);
ping(client.id, '192.168.0.1');
run(5_000);
send(client.id, { kind: 'stop' });
send(client.id, { kind: 'start' });
run(TICK_MILLISECONDS);
ping(client.id, '192.168.0.1');
run(5_000);
// Watched after the second start: the first run's frames are there, the second's go on from them.
send(client.id, { kind: 'sub', topics: [`trace:${port}`] });
expect(client.resets).toEqual([client.traces.length]);
expect(client.traces.map((frame) => frame.number)).toEqual(client.traces.map((_, index) => index + 1));
// Each run: an ARP exchange (a start forgets what ARP learned) and four pings with their answers.
expect(client.traces.length).toBe(20);
});
});
+38
View File
@@ -1623,3 +1623,41 @@ Gemessen (03.10.2026):
zeigt `HH:mm:ss.SSS : ` und den Frame in Javas Textform zeigt `HH:mm:ss.SSS : ` und den Frame in Javas Textform
(`[src=…, dest=…, type=0x800 | [id=…; ttl=64; …]]`; ein IP-Paket ohne sein (`[src=…, dest=…, type=0x800 | [id=…; ttl=64; …]]`; ein IP-Paket ohne sein
Segment). Segment).
### Datenaustausch-Fenster (F4.7)
`ExchangeWindowRun` benutzt Javas Datenaustausch-Fenster wie eine Person:
`AggregatedExchangeDialog`, das `filius.ini` wählt (`old-exchange-dialog=0`),
mit den Details als `MessageDetailsPanel` (ohne die experimentelle
Schichtenansicht, Javas Voreinstellung). Auf `web-lan` öffnet die Sonde die
Karte des Browsers vor jedem Verkehr, lädt `www.filius.de/text.txt`, wählt
jede Zeile und liest die Details, exportiert die Tabelle (`writeToStream`),
öffnet die Karte des Servers daneben und die erste wieder, wählt „Tabellen
löschen“ aus dem Menü der Tabelle (per `mousePressed`), lädt noch einmal
und startet die Simulation ohne den Server neu (als wäre er im Entwurf
entfernt); dann wird das Fenster aktiviert (`clearUnavailableComponents`).
Nach jedem Schritt: Reitertitel, gewählter Reiter, die Zeilen jeder Tabelle
und zum Vergleich `Lauscher.getDaten` (`packages/core/test/exchangeWindow.java.json`).
Gemessen (04.10.2026):
* Die Tabelle zeigt je Frame eine Zeile, die seiner höchsten Schicht (mit
Adresse:Port); 17 Zeilen statt Javas 51 Schichtzeilen.
* Reitertitel „Anna - 192.168.0.20“, beim Rechner mit der Adresse als
Beschriftung nur „192.168.0.30“; ein zweites Öffnen wählt den Reiter.
* Die Details: „Nr.: 1 / Zeit: 08:32:35.815“, je Schicht ihr Name, darunter
`Quelle:`, `Ziel:`, `Protokoll:` auf 15 Zeichen aufgefüllt (leere fehlen),
„Bemerkungen / Details: …“, bei der Anwendung „(83 Bytes)“; eine
Bemerkung mit Zeilenumbruch oder über 60 Zeichen als eigener Knoten.
* Der Export: Rahmen aus `=` um den Kopf, Breiten 10/20/40, die Bemerkung
auf Wörter zu je höchstens 40 Zeichen, Zeilen mit CR LF.
* „Tabellen löschen“ leert die Tabellen aller Karten; danach beginnen die
Nummern bei 1. Das Menü: „Tabellen löschen“, „Als Textdatei exportieren“,
„Automatisches Scrollen“.
* Nach dem Neustart bleiben der Reiter des Browsers und seine 13 Zeilen;
der Reiter des nicht gestarteten Servers verschwindet.
Fallen der Sonde: Die Einträge des Tabellenmenüs hören nur auf
`mousePressed` (sie haben keinen `ActionListener`). Beim Stoppen versteckt Javas Oberfläche das Fenster
(`closeDialogs`); ein Aktivieren im Entwurf, wie es die Sonde zuerst tat,
entfernt alle Reiter, kommt aber in Javas Oberfläche nicht vor.
+12 -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" "$here/src/SheetRun.java" "$here/src/TerminalWindowRun.java" "$here/src/InstallRun.java" "$here/src/BrowserWindowRun.java" "$here/src/MailWindowRun.java" "$here/src/SaveAfterRunRun.java" "$here/src/TextEditorWindowRun.java" "$here/src/FileExplorerWindowRun.java" "$here/src/EchoWindowRun.java" "$here/src/DnsWindowRun.java" "$here/src/FirewallWindowRun.java" "$here/src/GnutellaWindowRun.java" "$here/src/ViewersRun.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" "$here/src/TerminalWindowRun.java" "$here/src/InstallRun.java" "$here/src/BrowserWindowRun.java" "$here/src/MailWindowRun.java" "$here/src/SaveAfterRunRun.java" "$here/src/TextEditorWindowRun.java" "$here/src/FileExplorerWindowRun.java" "$here/src/EchoWindowRun.java" "$here/src/DnsWindowRun.java" "$here/src/FirewallWindowRun.java" "$here/src/GnutellaWindowRun.java" "$here/src/ViewersRun.java" "$here/src/ExchangeWindowRun.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.
@@ -477,6 +477,17 @@ for run in switch:switch-lan nat:nat-home; do
test -s "$output" test -s "$output"
done done
# FILIUS' data exchange window used as a person does — a card opened, a page
# fetched by name, each row chosen and its details read, the table exported,
# a second card beside it, "Tabellen löschen", the simulation started again
# without one computer (ExchangeWindowRun): the capture window
# (packages/core/test/exchangeWindow.test.ts).
output="$root/packages/core/test/exchangeWindow.java.json"
rm -f "$output"
HOME="$here/.build/home" xvfb-run -a java -Duser.language=de -Duser.country=DE -cp "$classpath:$here/.build" ExchangeWindowRun \
"$root/packages/core/test/scenarios/web-lan.scenario.json" "$output" 2>&1 | grep -v 'logback\|^$\|^\s*at ' || 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).
@@ -0,0 +1,293 @@
// Uses FILIUS' data exchange window (AggregatedExchangeDialog, the one
// filius.ini chooses, with its details as MessageDetailsPanel, the default
// without the layer visualization) on web-lan as a person does: the
// browser's card opened before any traffic, a page fetched by name (DNS, then
// HTTP), each row chosen and its details read, the table exported as text,
// the server's card opened beside it and the first chosen again, "Tabellen
// löschen" from the table's menu, a second fetch, the simulation stopped and
// started again without the server (as if removed in the design) and the
// window activated. After each step it writes what the window shows: the
// tabs' titles, the chosen tab, each table's rows (all seven cells) and, for
// comparison, the card's rows as Lauscher.getDaten gives them. The reference
// for the editor's capture window (packages/core/test/exchangeWindow.test.ts).
//
// Usage: java -cp filius.jar:lib/*:classes ExchangeWindowRun <scenario.json> <output json>
import filius.gui.nachrichtensicht.AggregatedExchangeDialog;
import filius.gui.nachrichtensicht.AggregatedMessageTable;
import filius.hardware.knoten.Host;
import filius.hardware.knoten.InternetKnoten;
import filius.rahmenprogramm.nachrichten.Lauscher;
import filius.software.system.Dateisystem;
import filius.software.system.Datei;
import filius.software.system.InternetKnotenBetriebssystem;
import filius.software.www.WebBrowser;
import filius.software.www.WebServer;
import java.awt.Component;
import java.awt.Container;
import java.awt.event.MouseEvent;
import java.io.ByteArrayOutputStream;
import java.io.FileOutputStream;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.Hashtable;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import javax.swing.JFrame;
import javax.swing.JMenuItem;
import javax.swing.JPanel;
import javax.swing.JPopupMenu;
import javax.swing.JTabbedPane;
import javax.swing.JTree;
import javax.swing.SwingUtilities;
import javax.swing.table.TableModel;
import javax.swing.tree.DefaultMutableTreeNode;
public class ExchangeWindowRun {
static final String BROWSER = "00000000-0000-0000-0000-000000000201";
static final String SERVER = "00000000-0000-0000-0000-000000000203";
static final List<Object> steps = new ArrayList<>();
static ScenarioNetwork network;
static AggregatedExchangeDialog dialog;
static Object field(Class<?> type, Object object, String name) throws Exception {
Field field = type.getDeclaredField(name);
field.setAccessible(true);
return field.get(object);
}
static JTabbedPane panel() throws Exception {
return (JTabbedPane) field(AggregatedExchangeDialog.class, dialog, "exchangePanel");
}
@SuppressWarnings("unchecked")
static AggregatedMessageTable table(String mac) throws Exception {
return ((Hashtable<String, AggregatedMessageTable>) field(panel().getClass(), panel(), "tabellen")).get(mac);
}
@SuppressWarnings("unchecked")
static JPanel tab(String mac) throws Exception {
return ((Hashtable<String, JPanel>) field(panel().getClass(), panel(), "openedTabs")).get(mac);
}
static String mac(String node) {
return ((InternetKnoten) network.nodes.get(node)).getNetzwerkInterfaces().get(0).getMac();
}
static InternetKnotenBetriebssystem os(String node) {
return network.system(node);
}
static void onEdt(Runnable action) throws Exception {
SwingUtilities.invokeAndWait(action);
Thread.sleep(300);
}
static List<List<String>> rows(TableModel model) {
List<List<String>> rows = new ArrayList<>();
for (int row = 0; row < model.getRowCount(); row++) {
List<String> cells = new ArrayList<>();
for (int column = 0; column < model.getColumnCount(); column++) {
Object value = model.getValueAt(row, column);
cells.add(value == null ? null : String.valueOf(value));
}
rows.add(cells);
}
return rows;
}
static List<List<String>> lauscherRows(String mac, boolean withAddresses) {
List<List<String>> rows = new ArrayList<>();
for (Object[] row : Lauscher.getLauscher().getDaten(mac, withAddresses, 0)) {
List<String> cells = new ArrayList<>();
for (Object cell : row) cells.add(cell == null ? null : String.valueOf(cell));
rows.add(cells);
}
return rows;
}
/** The details below the table: each node of the tree, in order, as [depth, text]. */
static List<Object> details(String mac) throws Exception {
List<Object> nodes = new ArrayList<>();
JTree tree = find(tab(mac), JTree.class);
if (tree == null) return nodes;
DefaultMutableTreeNode root = (DefaultMutableTreeNode) tree.getModel().getRoot();
for (Enumeration<?> each = root.preorderEnumeration(); each.hasMoreElements();) {
DefaultMutableTreeNode node = (DefaultMutableTreeNode) each.nextElement();
List<Object> entry = new ArrayList<>();
entry.add(node.getLevel());
entry.add(String.valueOf(node.getUserObject()));
nodes.add(entry);
}
return nodes;
}
static <T> T find(Component component, Class<T> type) {
if (type.isInstance(component)) return type.cast(component);
if (component instanceof Container) {
for (Component child : ((Container) component).getComponents()) {
T found = find(child, type);
if (found != null) return found;
}
}
return null;
}
static Map<String, Object> window() throws Exception {
Map<String, Object> shown = new LinkedHashMap<>();
JTabbedPane tabs = panel();
List<String> titles = new ArrayList<>();
Method title = AggregatedExchangeComponentTitle.method();
for (int index = 0; index < tabs.getTabCount(); index++) {
Object component = tabs.getTabComponentAt(index);
titles.add(component == null ? null : (String) title.invoke(component));
}
shown.put("titles", titles);
shown.put("selected", tabs.getSelectedIndex());
shown.put("visible", dialog.isVisible());
Map<String, Object> tables = new LinkedHashMap<>();
for (String node : new String[] { BROWSER, SERVER }) {
AggregatedMessageTable table = table(mac(node));
if (table == null) continue;
Map<String, Object> each = new LinkedHashMap<>();
each.put("rows", rows(table.getModel()));
each.put("layerRows", lauscherRows(mac(node), true));
tables.put(node, each);
}
shown.put("tables", tables);
return shown;
}
/** TabTitle.getTitle, a private class's method. */
static final class AggregatedExchangeComponentTitle {
static Method method() throws Exception {
for (Class<?> inner : Class.forName("filius.gui.nachrichtensicht.AggregatedExchangePanel").getDeclaredClasses()) {
if (inner.getSimpleName().equals("TabTitle")) {
Method method = inner.getDeclaredMethod("getTitle");
method.setAccessible(true);
return method;
}
}
throw new IllegalStateException("no TabTitle");
}
}
static void step(String name, Map<String, Object> shown) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("step", name);
result.put("at", System.currentTimeMillis() - network.start);
result.putAll(shown);
steps.add(result);
}
static void fetch(WebBrowser browser, String url) throws Exception {
browser.holeWebseite(new URL(url));
Thread.sleep(9000);
}
public static void main(String[] args) throws Exception {
try {
run(args);
} catch (Throwable error) {
error.printStackTrace();
System.exit(1);
}
System.exit(0);
}
static void run(String[] args) throws Exception {
network = ScenarioNetwork.read(args[0]);
os(SERVER).installApp("filius.software.www.WebServer", false);
os(BROWSER).installApp("filius.software.www.WebBrowser", false);
WebServer server = (WebServer) os(SERVER).holeSoftware("filius.software.www.WebServer");
WebBrowser browser = (WebBrowser) os(BROWSER).holeSoftware("filius.software.www.WebBrowser");
Dateisystem files = os(SERVER).getDateisystem();
files.speicherDatei("/webserver", new Datei("text.txt", "txt", "Nur Text\nzwei Zeilen"));
server.setAktiv(true);
network.nodes.get(BROWSER).setName("Anna");
((Host) network.nodes.get(SERVER)).setUseIPAsName(true);
network.start();
Thread.sleep(500);
JFrame frame = new JFrame();
SwingUtilities.invokeAndWait(() -> {
frame.setSize(1200, 900);
frame.setVisible(true);
dialog = AggregatedExchangeDialog.getInstance(frame);
});
onEdt(() -> {
dialog.addTable(os(BROWSER), mac(BROWSER));
dialog.setVisible(true);
});
step("open", window());
fetch(browser, "http://www.filius.de/text.txt");
Map<String, Object> shown = window();
// Each row chosen, the details below it.
AggregatedMessageTable table = table(mac(BROWSER));
List<Object> details = new ArrayList<>();
for (int row = 0; row < table.getModel().getRowCount(); row++) {
final int chosen = row;
onEdt(() -> table.setRowSelectionInterval(chosen, chosen));
details.add(details(mac(BROWSER)));
}
shown.put("details", details);
shown.put("layered", lauscherRows(mac(BROWSER), false));
ByteArrayOutputStream export = new ByteArrayOutputStream();
table.writeToStream(export);
shown.put("export", export.toString(StandardCharsets.UTF_8.name()));
step("after the fetch", shown);
onEdt(() -> dialog.addTable(os(SERVER), mac(SERVER)));
step("the server's card beside it", window());
onEdt(() -> dialog.addTable(os(BROWSER), mac(BROWSER)));
step("the browser's card again", window());
// "Tabellen löschen", the first entry of the table's menu (without the layer visualization).
JPopupMenu menu = (JPopupMenu) field(AggregatedMessageTable.class, table, "menu");
List<String> entries = new ArrayList<>();
for (Component entry : menu.getComponents()) entries.add(((JMenuItem) entry).getText());
JMenuItem clear = (JMenuItem) menu.getComponent(0);
onEdt(() -> clear.dispatchEvent(new MouseEvent(clear, MouseEvent.MOUSE_PRESSED, System.currentTimeMillis(), 0, 1, 1, 1, false, MouseEvent.BUTTON1)));
Map<String, Object> cleared = window();
cleared.put("menu", entries);
step("cleared", cleared);
fetch(browser, "http://www.filius.de/text.txt");
step("fetched again", window());
// Stopped and started again (GUIContainer.closeDialogs hides the window, reopenDialogs shows it): the server's
// computer as if removed in the design, its system not started again; then the window activated.
network.stop();
Thread.sleep(500);
for (Map.Entry<String, filius.hardware.knoten.Knoten> node : network.nodes.entrySet()) {
if (!node.getKey().equals(SERVER)) node.getValue().getSystemSoftware().starten();
}
Thread.sleep(500);
Method clearUnavailable = panel().getClass().getDeclaredMethod("clearUnavailableComponents");
clearUnavailable.setAccessible(true);
onEdt(() -> {
try {
clearUnavailable.invoke(panel());
} catch (Exception error) {
throw new RuntimeException(error);
}
});
step("started again without the server", window());
network.stop();
Map<String, Object> output = new LinkedHashMap<>();
output.put("steps", steps);
try (FileOutputStream out = new FileOutputStream(args[1])) {
out.write((FlsOracle.Json.write(output, "") + "\n").getBytes(StandardCharsets.UTF_8));
}
System.out.println("exchange window steps: " + steps.size());
}
}