feat(sim): F4.6f1 what a run leaves goes back into the document

The oracle SaveAfterRunRun saves Java's project after a run. The room writes
what a run leaves that Java keeps - files, running servers, virtual hosts,
mail servers' domains and accounts, mail programs' accounts and folders,
firewalls, DHCP clients' addresses - into the document as origin 'sim', at
most once a second, after each action and at the stop; writeRunState writes
only what differs. The command line now goes on in a folder made before a
restart, as Java's does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 17:05:11 +00:00
co-authored by Claude Opus 5.5
parent 11f13bca84
commit 3d6655e736
15 changed files with 705 additions and 22 deletions
+5
View File
@@ -18,6 +18,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
| `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) |
| `src/model/virtualFileSystem.ts` (`revision`) | — | erweitert (F4.6f1): ein Zähler, der sich mit jeder Änderung ändert (auch einem Löschen), damit der Raum ein Dateisystem nur zurückschreibt, wenn es sich geändert hat |
| `src/runtime/threads.ts` | Javas Threads der Programme (`Anwendung extends Thread`, `ServerMitarbeiter`) | neu (F3.1): ein Programm-Thread als Generator, der liefert, worauf er wartet (Bedingung, Zeit oder beides); die Engine setzt nach jedem Ereignis fort, wessen Warten vorbei ist, in der Reihenfolge, in der die Threads begannen; nichts blockiert die Engine. Abweichend von Java: ein Thread wacht sofort, wenn seine Bedingung gilt (Java sieht oft nur alle 100 ms nach) | | `src/runtime/threads.ts` | Javas Threads der Programme (`Anwendung extends Thread`, `ServerMitarbeiter`) | neu (F3.1): ein Programm-Thread als Generator, der liefert, worauf er wartet (Bedingung, Zeit oder beides); die Engine setzt nach jedem Ereignis fort, wessen Warten vorbei ist, in der Reihenfolge, in der die Threads begannen; nichts blockiert die Engine. Abweichend von Java: ein Thread wacht sofort, wenn seine Bedingung gilt (Java sieht oft nur alle 100 ms nach) |
| `src/runtime/apps/program.ts` | Java `filius.software.Anwendung` | neu (F3.1): die Zeilen im Fenster eines Programms (strukturiert, damit der Editor sie übersetzt) und der Befehls-Thread (`ausfuehren`) | | `src/runtime/apps/program.ts` | Java `filius.software.Anwendung` | neu (F3.1): die Zeilen im Fenster eines Programms (strukturiert, damit der Editor sie übersetzt) und der Befehls-Thread (`ausfuehren`) |
| `src/runtime/apps/server.ts` | Java `ServerAnwendung`, `ServerMitarbeiter` | neu (F3.2a, aus `echo.ts` herausgelöst): was jeder Server teilt — ein Thread nimmt Verbindungen an, solange er eingeschaltet ist, je Verbindung einer beantwortet jede Nachricht; ausgeschaltet schließt er Socket und Verbindungen; Port belegt → er schaltet sich aus; seit F3.3a kann ein Server das Gespräch einer Verbindung selbst führen (`converse`: Begrüßung, keine oder mehrere Antworten, Schließen) | | `src/runtime/apps/server.ts` | Java `ServerAnwendung`, `ServerMitarbeiter` | neu (F3.2a, aus `echo.ts` herausgelöst): was jeder Server teilt — ein Thread nimmt Verbindungen an, solange er eingeschaltet ist, je Verbindung einer beantwortet jede Nachricht; ausgeschaltet schließt er Socket und Verbindungen; Port belegt → er schaltet sich aus; seit F3.3a kann ein Server das Gespräch einer Verbindung selbst führen (`converse`: Begrüßung, keine oder mehrere Antworten, Schließen) |
@@ -99,6 +100,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
| `src/javaBeansWriter.ts` | — | neu: Schreiber für das `XMLDecoder`-Format, Gegenstück zu `javabeans.ts`; ausdrücklich typisierte Eingabe, geteilte Objekte über `id`/`idref`, was XML nicht fassen kann (und CR) als `<char code>`; von Java selbst geprüft (`BeansDump`) | | `src/javaBeansWriter.ts` | — | neu: Schreiber für das `XMLDecoder`-Format, Gegenstück zu `javabeans.ts`; ausdrücklich typisierte Eingabe, geteilte Objekte über `id`/`idref`, was XML nicht fassen kann (und CR) als `<char code>`; von Java selbst geprüft (`BeansDump`) |
| `test/javaBeansWriter.test.ts`, `test/beans/` | — | neu: 11 Dateien des Schreibers, von Javas `XMLDecoder` gelesen und beschrieben; Erwartungen von Hand | | `test/javaBeansWriter.test.ts`, `test/beans/` | — | neu: 11 Dateien des Schreibers, von Javas `XMLDecoder` gelesen und beschrieben; Erwartungen von Hand |
| `src/projectSnapshot.ts` | Swift `TopologyNetworkRuntimeTopologySnapshot(editorState:)` | neu (Abnahme F2): das importierte Projekt als Schnappschuss des Kerns; nur die Kabel verbinden (Swifts Verbindung über die SSID nicht übernommen), nur eingeschaltete DNS-Server laufen; seit F3.1 jeder installierte Echo-Server (ein oder aus, er lässt sich zur Laufzeit schalten) und einfache Client; seit F3.2a jeder Webserver und Browser und die Dateisysteme; seit F3.2c der Rückweg `projectWithSimulationChanges`: die Firewalls aus dem Schnappschuss des Laufs ins Projekt, wie Java sie beim Speichern hat; seit F3.3a die installierten E-Mail-Server (ein oder aus) und auf dem Rückweg ihre Postfächer; seit F3.3b die installierten E-Mail-Programme und auf dem Rückweg ihre Ordner; seit F3.4a die Knoten mit der Befehlszeile (ihre Dateien sind die des Projekts) | | `src/projectSnapshot.ts` | Swift `TopologyNetworkRuntimeTopologySnapshot(editorState:)` | neu (Abnahme F2): das importierte Projekt als Schnappschuss des Kerns; nur die Kabel verbinden (Swifts Verbindung über die SSID nicht übernommen), nur eingeschaltete DNS-Server laufen; seit F3.1 jeder installierte Echo-Server (ein oder aus, er lässt sich zur Laufzeit schalten) und einfache Client; seit F3.2a jeder Webserver und Browser und die Dateisysteme; seit F3.2c der Rückweg `projectWithSimulationChanges`: die Firewalls aus dem Schnappschuss des Laufs ins Projekt, wie Java sie beim Speichern hat; seit F3.3a die installierten E-Mail-Server (ein oder aus) und auf dem Rückweg ihre Postfächer; seit F3.3b die installierten E-Mail-Programme und auf dem Rückweg ihre Ordner; seit F3.4a die Knoten mit der Befehlszeile (ihre Dateien sind die des Projekts) |
| `src/projectSnapshot.ts` (`emailServerAfterRun`) | Java `EmailServer` (gespeichert nach einem Lauf, gemessen mit `SaveAfterRunRun`) | erweitert (F4.6f1): der Rückweg nimmt auch die Domain eines E-Mail-Servers mit; als eigene Funktion für den Rückweg des Raums ins Dokument |
| `test/examplesJava.test.ts`, `test/examples/*.java.json` | — | neu (Abnahme F2): fünf Beispiele von Filius (`internetwork_komplett`, `einfaches_rechnernetz_komplett`, `dns_server`, `nat_with_mapped_port`, `Internet_Komplett_…_DE`) mit dem eigenen Leser eingelesen und im Kern gespielt, gegen Java-Filius (`ExampleRun`): je Karte Frames, Zeiten und Tabellenzeilen, die Aufrufe, die Adressen am Ende; gleichzeitig startende DHCP-Clients je Client; zwei benannte Java-Fehler (`JAVA_BUGS`: Ping ohne Gateway wirft eine `NullPointerException`; NAT-Eintrag nur für die erste Gegenstelle) | | `test/examplesJava.test.ts`, `test/examples/*.java.json` | — | neu (Abnahme F2): fünf Beispiele von Filius (`internetwork_komplett`, `einfaches_rechnernetz_komplett`, `dns_server`, `nat_with_mapped_port`, `Internet_Komplett_…_DE`) mit dem eigenen Leser eingelesen und im Kern gespielt, gegen Java-Filius (`ExampleRun`): je Karte Frames, Zeiten und Tabellenzeilen, die Aufrufe, die Adressen am Ende; gleichzeitig startende DHCP-Clients je Client; zwei benannte Java-Fehler (`JAVA_BUGS`: Ping ohne Gateway wirft eine `NullPointerException`; NAT-Eintrag nur für die erste Gegenstelle) |
| `test/classicsJava.test.ts`, `test/exampleRun.ts`, `test/examples/{web,email,gnutella}.java.json` | — | neu (Abnahme F3): die Unterrichtsklassiker auf Beispielen von Filius gegen Java-Filius (`ExampleRun`): Webseite über ihren DNS-Namen (`internetwork_komplett`), E-Mail zwischen zwei Netzen durch NAT und Weitergabe von Server zu Server (`Internet_Komplett_…_DE`), Datei per Gnutella über einen Router finden und holen, Ping, Pong und Query weitergereicht (`zwei_rechnernetze_komplett`, Programme vom Test eingesetzt); je Karte Frames, Zeiten und Tabellenzeilen (ohne RIP), die Zeilen jedes Programms, Mails, Teilnehmer, Ergebnisse und Dateien am Ende; Javas Fehler aus F3.6b und `closedPort` benannt (`JAVA_BUGS`); gleich lange RIP-Wege nach Javas Wahl (`equalRipTies`). `exampleRun.ts`: was beide Beispiel-Tests teilen (Datei öffnen, Javas Namen, Karten nach MAC) | | `test/classicsJava.test.ts`, `test/exampleRun.ts`, `test/examples/{web,email,gnutella}.java.json` | — | neu (Abnahme F3): die Unterrichtsklassiker auf Beispielen von Filius gegen Java-Filius (`ExampleRun`): Webseite über ihren DNS-Namen (`internetwork_komplett`), E-Mail zwischen zwei Netzen durch NAT und Weitergabe von Server zu Server (`Internet_Komplett_…_DE`), Datei per Gnutella über einen Router finden und holen, Ping, Pong und Query weitergereicht (`zwei_rechnernetze_komplett`, Programme vom Test eingesetzt); je Karte Frames, Zeiten und Tabellenzeilen (ohne RIP), die Zeilen jedes Programms, Mails, Teilnehmer, Ergebnisse und Dateien am Ende; Javas Fehler aus F3.6b und `closedPort` benannt (`JAVA_BUGS`); gleich lange RIP-Wege nach Javas Wahl (`equalRipTies`). `exampleRun.ts`: was beide Beispiel-Tests teilen (Datei öffnen, Javas Namen, Karten nach MAC) |
| `src/importProject.ts` (`modemPair`, `modemPort`) | — | zusammengelegt (F4.5a): das Modem-Paar eines Ports an einer Stelle, für Import, Export und das Geräte-Blatt | | `src/importProject.ts` (`modemPair`, `modemPort`) | — | zusammengelegt (F4.5a): das Modem-Paar eines Ports an einer Stelle, für Import, Export und das Geräte-Blatt |
@@ -136,6 +138,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
| `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/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 |
| `test/sheet.test.ts`, `test/sheet.cases.txt`, `test/sheet.java.json` | — | neu (F4.5a): 77 in Javas Felder getippte Texte, jede Regel dagegen samt der benannten Abweichungen; die Befehle, die Funkverbindungen, Rückgängig je Änderung, alles über eine gespeicherte `.fls` | | `test/sheet.test.ts`, `test/sheet.cases.txt`, `test/sheet.java.json` | — | neu (F4.5a): 77 in Javas Felder getippte Texte, jede Regel dagegen samt der benannten Abweichungen; die Befehle, die Funkverbindungen, Rückgängig je Änderung, alles über eine gespeicherte `.fls` |
| `test/devices.test.ts`, `test/fixtures/new-devices.fls` | — | neu (F4.4a): neue Geräte jeder Art gegen Javas gespeicherte (Zufall maskiert), Kabelregeln, ein selbst gebautes Netz pingt im Kern, Rückgängig nur der eigenen Änderungen über zwei Replikate | | `test/devices.test.ts`, `test/fixtures/new-devices.fls` | — | neu (F4.4a): neue Geräte jeder Art gegen Javas gespeicherte (Zufall maskiert), Kabelregeln, ein selbst gebautes Netz pingt im Kern, Rückgängig nur der eigenen Änderungen über zwei Replikate |
| `test/projectDoc.test.ts` | — | neu (F4.1): alle 23 Korpusdateien → Dokument → Yjs-Update → Projekt: dasselbe Projekt, derselbe Schnappschuss, dieselbe `.fls`, dieselbe Reihenfolge je Ordner; Kennungen absichtlich gegen die Dateireihenfolge | | `test/projectDoc.test.ts` | — | neu (F4.1): alle 23 Korpusdateien → Dokument → Yjs-Update → Projekt: dasselbe Projekt, derselbe Schnappschuss, dieselbe `.fls`, dieselbe Reihenfolge je Ordner; Kennungen absichtlich gegen die Dateireihenfolge |
@@ -165,6 +168,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
| `src/room.ts` (Installation) | Java `GUIInstallationsDialog` (Programme starten sofort) | erweitert (F4.6c): im laufenden Betrieb beobachtet der Raum die installierten Programme des Dokuments und installiert oder entfernt sie in der Engine; eine neue Befehlszeile bekommt ihr Fenster | | `src/room.ts` (Installation) | Java `GUIInstallationsDialog` (Programme starten sofort) | erweitert (F4.6c): im laufenden Betrieb beobachtet der Raum die installierten Programme des Dokuments und installiert oder entfernt sie in der Engine; eine neue Befehlszeile bekommt ihr Fenster |
| `src/browserWindow.ts`, `src/room.ts` (Browser, Webserver), `test/browserWindow.test.ts`, `test/browserWindow.java.json` | Java `GUIApplicationWebBrowserWindow`, `GUIApplicationWebServerWindow` (gemessen mit `BrowserWindowRun`) | neu (F4.6d): das Browserfenster im Raum, eines je Rechner für alle, über Stopps behalten — Adresse öffnen (keine Adresse: die Zeile zurück auf „http://“), Links und Formulare der gezeigten Seite folgen, was das Programm bekam, in den Bildschirmstand; der Raum schickt Bildschirmstände (Browser statt Zeilen, Webserver zu seinen Zeilen), nimmt die Aktionen `open`/`follow` und `setActive`/`useVirtualHosts`/`setVirtualHost` an und startet in der Sprache der Person. Gegen Javas Fenster geprüft; benannt: Javas Fehlerseite findet ihre Vorlage nie, Bilder und Links einer Seite im Ordner, eine Marke lädt in Java die Startseite, die Datei der virtuellen Hosts | | `src/browserWindow.ts`, `src/room.ts` (Browser, Webserver), `test/browserWindow.test.ts`, `test/browserWindow.java.json` | Java `GUIApplicationWebBrowserWindow`, `GUIApplicationWebServerWindow` (gemessen mit `BrowserWindowRun`) | neu (F4.6d): das Browserfenster im Raum, eines je Rechner für alle, über Stopps behalten — Adresse öffnen (keine Adresse: die Zeile zurück auf „http://“), Links und Formulare der gezeigten Seite folgen, was das Programm bekam, in den Bildschirmstand; der Raum schickt Bildschirmstände (Browser statt Zeilen, Webserver zu seinen Zeilen), nimmt die Aktionen `open`/`follow` und `setActive`/`useVirtualHosts`/`setVirtualHost` an und startet in der Sprache der Person. Gegen Javas Fenster geprüft; benannt: Javas Fehlerseite findet ihre Vorlage nie, Bilder und Links einer Seite im Ordner, eine Marke lädt in Java die Startseite, die Datei der virtuellen Hosts |
| `src/room.ts` (E-Mail), `test/mailWindow.test.ts`, `test/mailWindow.java.json` | Java `GUIApplicationEmailAnwendungWindow`, `GUIApplicationEmailServerWindow` (gemessen mit `MailWindowRun`) | erweitert (F4.6e): der Raum schickt die Bildschirmstände von E-Mail-Programm und -Server zu ihren Zeilen und nimmt `setAccount`/`send`/`fetch`/`removeMail` und `setActive`/`setDomain` (nur bei angehaltenem Server)/`addAccount`/`removeAccount` an. Gegen Javas Fenster geprüft; benannt: ein vorhandener Benutzername, Löschen im falschen Ordner, die Domain in der Kontendatei | | `src/room.ts` (E-Mail), `test/mailWindow.test.ts`, `test/mailWindow.java.json` | Java `GUIApplicationEmailAnwendungWindow`, `GUIApplicationEmailServerWindow` (gemessen mit `MailWindowRun`) | erweitert (F4.6e): der Raum schickt die Bildschirmstände von E-Mail-Programm und -Server zu ihren Zeilen und nimmt `setAccount`/`send`/`fetch`/`removeMail` und `setActive`/`setDomain` (nur bei angehaltenem Server)/`addAccount`/`removeAccount` an. Gegen Javas Fenster geprüft; benannt: ein vorhandener Benutzername, Löschen im falschen Ordner, die Domain in der Kontendatei |
| `src/room.ts` (Rückweg), `test/writeBack.test.ts`, `test/fixtures/after-run-*.fls`, `test/terminalWindow.test.ts` | Java `GUIMainMenu.stopSimulation`, `SzenarioVerwaltung.speichern` (gemessen mit `SaveAfterRunRun`) | erweitert (F4.6f1): der Raum schreibt, was ein Lauf hinterlässt (Dateien, laufende Server, virtuelle Hosts, Domain und Konten der E-Mail-Server, Konto und Ordner der E-Mail-Programme, Firewalls, die Adresse eines DHCP-Clients), als `origin: 'sim'` ins Dokument: höchstens einmal je Sekunde (ab dem Start gezählt), nach jeder Aktion in einem Fenster und beim Stopp. Gegen Javas nach dem Lauf gespeichertes Projekt geprüft; die Befehlszeile geht nach einem Neustart im vorher angelegten Ordner weiter wie Javas (die benannte Abweichung aus F4.6b ist weg) |
| `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
@@ -198,6 +202,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
| `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 |
| `browser/mail.spec.ts`, `test/mailLines.test.ts` | — | neu (F4.6e): E-Mail in Chromium auf Javas Beispiel `email_komplett` — Prüfungen und Dialoge, Senden, Abrufen, Antworten, Löschen im gewählten Ordner, Konto-Dialog, ein Fehler als Dialog; Konten anlegen und entfernen, Domain nur angehalten; die Zeilen des Server-Fensters gegen Javas Fenster | | `browser/mail.spec.ts`, `test/mailLines.test.ts` | — | neu (F4.6e): E-Mail in Chromium auf Javas Beispiel `email_komplett` — Prüfungen und Dialoge, Senden, Abrufen, Antworten, Löschen im gewählten Ordner, Konto-Dialog, ein Fehler als Dialog; Konten anlegen und entfernen, Domain nur angehalten; die Zeilen des Server-Fensters gegen Javas Fenster |
| `browser/simulation.spec.ts` (Rückweg) | — | erweitert (F4.6f1): ein in der Befehlszeile angelegter Ordner ist nach Entwurf und Neustart da, ein entfernter weg |
| `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 |
@@ -213,11 +213,17 @@ export function fileContentOfJavaType(rawType: string, content: string, path: st
export class VirtualFileSystem { export class VirtualFileSystem {
private store: Store; private store: Store;
private revisionValue = 0;
constructor() { constructor() {
this.store = new Map([[keyOf('/'), { path: '/', content: { kind: 'directory' } }]]); this.store = new Map([[keyOf('/'), { path: '/', content: { kind: 'directory' } }]]);
} }
/** Counts the changes: different after each one (the simulation room writes a file system back when it changed). */
get revision(): number {
return this.revisionValue;
}
/** `init(entries:)`: shallow entries first; each parent must exist. */ /** `init(entries:)`: shallow entries first; each parent must exist. */
static fromEntries(entries: readonly VirtualFileEntry[]): VirtualFileSystem { static fromEntries(entries: readonly VirtualFileEntry[]): VirtualFileSystem {
const fileSystem = new VirtualFileSystem(); const fileSystem = new VirtualFileSystem();
@@ -460,10 +466,12 @@ export class VirtualFileSystem {
const key = keyOf(path); const key = keyOf(path);
const existing = this.store.get(key); const existing = this.store.get(key);
this.store.set(key, { path: existing?.path ?? path, content }); this.store.set(key, { path: existing?.path ?? path, content });
this.revisionValue += 1;
} }
private delete(path: string): void { private delete(path: string): void {
this.store.delete(keyOf(path)); this.store.delete(keyOf(path));
this.revisionValue += 1;
} }
private transaction(change: (candidate: VirtualFileSystem) => void): void { private transaction(change: (candidate: VirtualFileSystem) => void): void {
@@ -471,6 +479,7 @@ export class VirtualFileSystem {
change(candidate); change(candidate);
candidate.validateQuotas(); candidate.validateQuotas();
this.store = candidate.store; this.store = candidate.store;
this.revisionValue += 1;
} }
private persistedEntryCount(): number { private persistedEntryCount(): number {
@@ -538,6 +547,7 @@ export class VirtualFileSystem {
candidate.set(destination, content); candidate.set(destination, content);
candidate.validateQuotas(); candidate.validateQuotas();
this.store = candidate.store; this.store = candidate.store;
this.revisionValue += 1;
} }
/** The subtree at `sourcePath`, shallow first. */ /** The subtree at `sourcePath`, shallow first. */
@@ -164,3 +164,24 @@ describe('virtual file system', () => {
expect(fileSystem.upgradeLegacyDefaultImages()).toBe(0); expect(fileSystem.upgradeLegacyDefaultImages()).toBe(0);
}); });
}); });
describe("a file system's revision", () => {
it('changes with every change, a removal too, and not with a reading', () => {
const files = new VirtualFileSystem();
const seen = [files.revision];
files.createDirectory('/a');
seen.push(files.revision);
files.writeTextFile('/a/b.txt', 'x');
seen.push(files.revision);
files.renameInPlace('/a/b.txt', 'c.txt');
seen.push(files.revision);
files.deleteItem('/a/c.txt');
seen.push(files.revision);
files.allEntries();
files.contains('/a');
seen.push(files.revision);
expect(new Set(seen.slice(0, 5)).size).toBe(5);
expect(seen[5]).toBe(seen[4]);
});
});
+1
View File
@@ -4,6 +4,7 @@ export * from './fields.js';
export * from './janitor.js'; export * from './janitor.js';
export * from './projectDoc.js'; export * from './projectDoc.js';
export * from './settings.js'; export * from './settings.js';
export * from './simulation.js';
export * from './sheet.js'; export * from './sheet.js';
export * from './snapshot.js'; export * from './snapshot.js';
export * from './values.js'; export * from './values.js';
+122
View File
@@ -0,0 +1,122 @@
// What a run leaves in the project, written back into the document (plan
// §6.1: the room writes it as `origin: 'sim'`), as Java's project holds it
// when it is saved after a run (measured with tools/java-oracle's
// SaveAfterRunRun): each computer's files as its programs left them, which
// servers run, and the settings a run changes — a mail server's domain and
// accounts, a mail program's account and folders, a web server's virtual
// hosts, a firewall changed through a router's page, the address a DHCP
// client got. Only what differs is written, so that a write changes nothing
// the second time and a text edited meanwhile keeps what it did not touch.
import * as Y from 'yjs';
import type { Id, InstallableProgram, VirtualFileContent, VirtualFileSystem } from '@mdg-filius/core';
import { fileEntry, NODE_SETTINGS, ROOTS } from './projectDoc.js';
export { emailServerAfterRun } from '@mdg-filius/fls';
import { fromY, toY, type PlainValue } from './values.js';
/** The origin of the room's writes (plan §6.1); a person's undo never takes them. */
export const SIM_ORIGIN = 'sim';
/** A node's settings key in the document (NODE_SETTINGS' values). */
export type NodeSettingsKey = (typeof NODE_SETTINGS)[keyof typeof NODE_SETTINGS];
/** What a run left of a node. */
export interface NodeRunState {
readonly files?: VirtualFileSystem;
readonly active?: Partial<Record<InstallableProgram, boolean>>;
readonly settings?: Partial<Record<NodeSettingsKey, unknown>>;
}
/** Plain values alike, whatever the order of an object's fields; a field that is undefined is none. */
function same(left: unknown, right: unknown): boolean {
if (left instanceof Uint8Array || right instanceof Uint8Array) return left instanceof Uint8Array && right instanceof Uint8Array && sameBytes(left, right);
if (Array.isArray(left) || Array.isArray(right)) {
return Array.isArray(left) && Array.isArray(right) && left.length === right.length && left.every((each, index) => same(each, right[index]));
}
if (typeof left === 'object' && left !== null && typeof right === 'object' && right !== null) {
const fields = (value: object) => Object.entries(value).filter(([, field]) => field !== undefined);
const leftFields = fields(left);
const rightFields = new Map(fields(right));
return leftFields.length === rightFields.size && leftFields.every(([key, field]) => rightFields.has(key) && same(field, rightFields.get(key)));
}
return left === right;
}
function sameBytes(left: Uint8Array, right: Uint8Array): boolean {
return left.length === right.length && left.every((byte, index) => byte === right[index]);
}
/** A text changed where it changed: the common start and end stay (and with them what another person typed there). */
function replaceText(text: Y.Text, next: string): void {
const current = text.toString();
if (current === next) return;
let start = 0;
while (start < current.length && start < next.length && current[start] === next[start]) start += 1;
let end = 0;
while (end < current.length - start && end < next.length - start && current[current.length - 1 - end] === next[next.length - 1 - end]) end += 1;
if (current.length - start - end > 0) text.delete(start, current.length - start - end);
if (next.length - start - end > 0) text.insert(start, next.slice(start, next.length - end));
}
/** Whether a document entry holds the content. */
function holds(entry: Y.Map<unknown>, content: VirtualFileContent): boolean {
if (entry.get('kind') !== content.kind) return false;
if (content.kind === 'directory' || content.kind === 'text') return true;
return sameBytes(entry.get('data') as Uint8Array, content.data) && entry.get('mediaType') === content.mediaType;
}
/** A node's files as the run left them: new ones after the others, changed ones in their place, gone ones removed. */
function writeFiles(node: Y.Map<unknown>, fileSystem: VirtualFileSystem): void {
let files = node.get('files') as Y.Map<Y.Map<unknown>> | undefined;
const wanted: [string, VirtualFileContent][] = [];
const walk = (folder: string): void => {
for (const entry of fileSystem.entriesInCreationOrder(folder)) {
wanted.push([entry.path, entry.content]);
if (entry.content.kind === 'directory') walk(entry.path);
}
};
walk('/');
if (files === undefined) {
if (wanted.length === 0) return;
files = new Y.Map<Y.Map<unknown>>();
node.set('files', files);
}
const paths = new Set(wanted.map(([path]) => path));
for (const path of [...files.keys()]) if (!paths.has(path)) files.delete(path);
let order = Math.max(-1, ...[...files.values()].map((entry) => entry.get('order') as number)) + 1;
for (const [path, content] of wanted) {
const entry = files.get(path);
if (entry === undefined) {
files.set(path, fileEntry(order++, content));
} else if (!holds(entry, content)) {
files.set(path, fileEntry(entry.get('order') as number, content));
} else if (content.kind === 'text') {
replaceText(entry.get('text') as Y.Text, content.text);
}
}
}
/** Writes what the runs left of the nodes, what differs only, in one transaction; nodes the document no longer has are left out. */
export function writeRunState(doc: Y.Doc, states: ReadonlyMap<Id, NodeRunState>, origin: unknown = SIM_ORIGIN): void {
const nodes = doc.getMap<Y.Map<unknown>>(ROOTS.nodes);
doc.transact(() => {
for (const [id, state] of states) {
const node = nodes.get(id);
if (node === undefined) continue;
if (state.files !== undefined) writeFiles(node, state.files);
const programs = node.get('programs') as Y.Map<Y.Map<unknown>>;
for (const [program, active] of Object.entries(state.active ?? {})) {
const entry = programs.get(program);
if (entry !== undefined && entry.get('active') !== active) entry.set('active', active);
}
const settings = node.get('settings') as Y.Map<unknown>;
// Only a setting the node has (a router's own web server has none).
for (const [key, value] of Object.entries(state.settings ?? {})) {
const current = settings.get(key);
if (value === undefined || current === undefined) continue;
if (!same(fromY(current), value)) settings.set(key, toY(value as PlainValue));
}
}
}, origin);
}
@@ -362,6 +362,37 @@ test('keeps the command line over leaving it, over a return to the design, and e
await expect(window).toBeVisible(); await expect(window).toBeVisible();
}); });
test('keeps what a run made in the files over a return to the design, as Java\'s project keeps it', async ({ page }) => {
await openEditor(page, server.base, 'internetwork_komplett.fls');
await start(page);
const { input, text } = await commandLine(page, 'Notebook 1.10');
await input.fill('mkdir ordner');
await input.press('Enter');
await input.fill('ls');
await input.press('Enter');
await expect(text).toContainText('ordner');
await designButton(page).click();
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'design');
await start(page);
await input.fill('cls');
await input.press('Enter');
await input.fill('ls');
await input.press('Enter');
await expect(text).toContainText('\n/> ls\n');
await expect(text).toContainText('ordner');
// Removed in the run, it is gone from the project too.
await input.fill('rm ordner');
await input.press('Enter');
await designButton(page).click();
await start(page);
await input.fill('cls');
await input.press('Enter');
await input.fill('ls');
await input.press('Enter');
await expect(text).toContainText('\n/> ls\n');
await expect(text).not.toContainText('ordner');
});
test("installs and removes programs as Java's installation does, and a program installed runs at once", async ({ page }) => { test("installs and removes programs as Java's installation does, and a program installed runs at once", async ({ page }) => {
await openEditor(page, server.base, 'internetwork_komplett.fls'); await openEditor(page, server.base, 'internetwork_komplett.fls');
const id = await deviceNamed(page, 'Rechner 3.10'); const id = await deviceNamed(page, 'Rechner 3.10');
+10 -5
View File
@@ -17,7 +17,7 @@
// /mailserver/konten.txt at once), and an e-mail program's inbox and sent // /mailserver/konten.txt at once), and an e-mail program's inbox and sent
// mails. // mails.
import { portSnapshot, topologySnapshot, type DnsServerConfiguration, type EchoServerSnapshot, type EmailServerSnapshot, type Id, type WebServerSnapshot, type TopologySnapshot } from '@mdg-filius/core'; import { portSnapshot, topologySnapshot, type DnsServerConfiguration, type EchoServerSnapshot, type EmailServerConfiguration, type EmailServerSnapshot, type Id, type WebServerSnapshot, type TopologySnapshot } from '@mdg-filius/core';
import type { ImportedProject } from './importProject.js'; import type { ImportedProject } from './importProject.js';
export function projectSnapshot(project: ImportedProject): TopologySnapshot { export function projectSnapshot(project: ImportedProject): TopologySnapshot {
@@ -72,18 +72,23 @@ export function projectSnapshot(project: ImportedProject): TopologySnapshot {
}); });
} }
/** An e-mail server's settings as a run left them: its domain and accounts, the next message's number after their mails. */
export function emailServerAfterRun(configuration: EmailServerConfiguration, server: EmailServerSnapshot): EmailServerConfiguration {
const messages = server.accounts.reduce((count, account) => count + account.mailbox.length, 0);
return { ...configuration, domain: server.domain, accounts: server.accounts, nextMessageID: messages + 1 };
}
/** /**
* The project with what the simulation changed that Java keeps, as the run's * The project with what the simulation changed that Java keeps, as the run's
* snapshot holds it: the firewalls, the e-mail servers' mailboxes, and the * snapshot holds it: the firewalls, the e-mail servers' domains and
* e-mail programs' inboxes and sent mails. * mailboxes, and the e-mail programs' accounts, inboxes and sent mails.
*/ */
export function projectWithSimulationChanges(project: ImportedProject, snapshot: TopologySnapshot): ImportedProject { export function projectWithSimulationChanges(project: ImportedProject, snapshot: TopologySnapshot): ImportedProject {
const emailServers = new Map(project.emailServerConfigurations); const emailServers = new Map(project.emailServerConfigurations);
for (const [nodeId, server] of snapshot.emailServersByNodeId) { for (const [nodeId, server] of snapshot.emailServersByNodeId) {
const configuration = emailServers.get(nodeId); const configuration = emailServers.get(nodeId);
if (configuration === undefined) continue; if (configuration === undefined) continue;
const messages = server.accounts.reduce((count, account) => count + account.mailbox.length, 0); emailServers.set(nodeId, emailServerAfterRun(configuration, server));
emailServers.set(nodeId, { ...configuration, accounts: server.accounts, nextMessageID: messages + 1 });
} }
const emailClients = new Map(project.emailClientConfigurations); const emailClients = new Map(project.emailClientConfigurations);
for (const [nodeId, client] of snapshot.emailClientsByNodeId) { for (const [nodeId, client] of snapshot.emailClientsByNodeId) {
+70 -1
View File
@@ -27,6 +27,12 @@
// installed or removed on the desktop (Java's software installation) is // installed or removed on the desktop (Java's software installation) is
// installed or removed in the running engine at once, as Java starts or ends // installed or removed in the running engine at once, as Java starts or ends
// it; any other change comes in at the next start. // it; any other change comes in at the next start.
//
// What a run leaves that Java's project keeps (files, which servers run,
// accounts, mails, a DHCP client's address …) goes back into the document,
// as `origin: 'sim'`: at most once a second while it runs, after each action
// in a window and when it stops (writeRunState). So the next start, and every
// client, sees it — as Java's project saved after a run holds it.
import * as Y from 'yjs'; import * as Y from 'yjs';
import { import {
@@ -44,7 +50,7 @@ import {
type PageLanguage, type PageLanguage,
type TopologySnapshot, type TopologySnapshot,
} from '@mdg-filius/core'; } from '@mdg-filius/core';
import { docSnapshot, installedSoftware, ROOTS } from '@mdg-filius/doc'; import { docSnapshot, emailServerAfterRun, fromY, installedSoftware, ROOTS, SIM_ORIGIN, writeRunState, type NodeRunState } from '@mdg-filius/doc';
import { import {
parseClientMessage, parseClientMessage,
SIM_PROTOCOL_VERSION, SIM_PROTOCOL_VERSION,
@@ -64,6 +70,8 @@ import { BrowserWindow } from './browserWindow.js';
import { TerminalWindow } from './terminalWindow.js'; import { TerminalWindow } from './terminalWindow.js';
export const TICK_MILLISECONDS = 200; export const TICK_MILLISECONDS = 200;
/** How often a running simulation writes what it left into the document. */
export const WRITE_BACK_MILLISECONDS = 1_000;
export const MAXIMUM_CATCH_UP_MILLISECONDS = 2_000; export const MAXIMUM_CATCH_UP_MILLISECONDS = 2_000;
export interface RoomOptions { export interface RoomOptions {
@@ -71,6 +79,8 @@ export interface RoomOptions {
readonly now: () => number; readonly now: () => number;
/** A seed for each start. */ /** A seed for each start. */
readonly seed: () => number; readonly seed: () => number;
/** How often a running simulation writes what it left into the document; WRITE_BACK_MILLISECONDS by default. */
readonly writeBackMilliseconds?: number;
} }
interface Client { interface Client {
@@ -111,6 +121,10 @@ export class SimulationRoom {
private readonly browserWindows = new Map<Id, BrowserWindow>(); private readonly browserWindows = new Map<Id, BrowserWindow>();
/** The language of the last start (the pages the devices write themselves). */ /** The language of the last start (the pages the devices write themselves). */
private language: PageLanguage | undefined; private language: PageLanguage | undefined;
/** Each node's file system and its revision when it was last written back. */
private writtenFiles = new Map<Id, { readonly files: object; readonly revision: number }>();
/** The wall clock's time of the last write-back. */
private lastWriteBack = 0;
/** Each computer's programs as the running engine has them. */ /** Each computer's programs as the running engine has them. */
private software = new Map<Id, readonly InstallableProgram[]>(); private software = new Map<Id, readonly InstallableProgram[]>();
@@ -157,6 +171,56 @@ export class SimulationRoom {
this.flush([...this.clients.values()], false); this.flush([...this.clients.values()], false);
} }
/**
* What the run left of each node that Java's project keeps: its files (when
* they changed since the last write), whether its servers run, a web
* server's virtual hosts, an e-mail server's domain and accounts, an
* e-mail program's account and folders, a firewall, a DHCP client's address.
*/
private runState(): Map<Id, NodeRunState> {
const snapshot = this.engine.state.topologySnapshot;
const states = new Map<Id, NodeRunState>();
const nodes = this.doc.getMap<Y.Map<unknown>>(ROOTS.nodes);
for (const { id } of snapshot.nodes) {
const node = nodes.get(id);
if (node === undefined) continue;
const setting = (key: string) => fromY((node.get('settings') as Y.Map<unknown>).get(key));
const active: NonNullable<NodeRunState['active']> = {};
const settings: Record<string, unknown> = {};
const web = this.engine.webServer(id);
const webSettings = snapshot.webServersByNodeId.get(id);
if (web !== undefined && webSettings !== undefined) {
active.webServer = web.isActive;
settings.webServer = { port: web.port, documentRoot: webSettings.documentRoot, virtualHosts: web.useVirtualHosts };
}
const mailServer = this.engine.emailServer(id);
const mailServerSettings = snapshot.emailServersByNodeId.get(id);
const configured = setting('emailServer');
if (mailServer !== undefined && mailServerSettings !== undefined && configured !== undefined) {
active.emailServer = mailServer.isActive;
settings.emailServer = emailServerAfterRun(configured as never, mailServerSettings);
}
const echo = this.engine.echoServer(id);
if (echo !== undefined) active.echoServer = echo.isActive;
settings.emailClient = snapshot.emailClientsByNodeId.get(id);
settings.firewall = snapshot.firewallConfigurationsByNodeId.get(id);
if (snapshot.dhcpClientConfigurationsByNodeId.get(id)?.isEnabled === true) settings.device = snapshot.deviceConfigurations.get(id);
const files = snapshot.fileSystemsByNodeId.get(id);
const written = this.writtenFiles.get(id);
const changed = files !== undefined && (written?.files !== files || written.revision !== files.revision);
if (files !== undefined && changed) this.writtenFiles.set(id, { files, revision: files.revision });
states.set(id, { ...(changed ? { files } : {}), active, settings });
}
return states;
}
/** Writes what the run left into the document (origin 'sim'). */
private writeBack(): void {
if (this.engine.state.phase !== 'running') return;
this.lastWriteBack = this.options.now();
writeRunState(this.doc, this.runState(), SIM_ORIGIN);
}
/** The document as the engine takes it, in the language of the start. */ /** The document as the engine takes it, in the language of the start. */
private snapshot(): TopologySnapshot { private snapshot(): TopologySnapshot {
const snapshot = docSnapshot(this.doc); const snapshot = docSnapshot(this.doc);
@@ -246,6 +310,7 @@ export class SimulationRoom {
case 'input': { case 'input': {
let failure: ErrorCode | undefined = 'engineFailed'; let failure: ErrorCode | undefined = 'engineFailed';
if (!this.guarded(() => (failure = this.act(input)))) return; if (!this.guarded(() => (failure = this.act(input)))) return;
if (failure === undefined) this.guarded(() => this.writeBack());
client.send(failure === undefined ? message({ kind: 'ack', reqId: input.reqId }) : message({ kind: 'error', code: failure, reqId: input.reqId })); client.send(failure === undefined ? message({ kind: 'ack', reqId: input.reqId }) : message({ kind: 'error', code: failure, reqId: input.reqId }));
this.flush([...this.clients.values()], false); this.flush([...this.clients.values()], false);
return; return;
@@ -272,12 +337,15 @@ export class SimulationRoom {
this.syncWindows(); this.syncWindows();
this.mode = 'simulation'; this.mode = 'simulation';
this.lastWall = this.options.now(); this.lastWall = this.options.now();
this.lastWriteBack = this.lastWall;
this.broadcast(this.state()); this.broadcast(this.state());
this.flush([...this.clients.values()], false); this.flush([...this.clients.values()], false);
} }
stop(): void { stop(): void {
if (this.mode === 'design') return; if (this.mode === 'design') return;
// A failure already stopped the engine and went back to the design.
if (!this.guarded(() => this.writeBack())) return;
this.engine.handle({ type: 'stop' }); this.engine.handle({ type: 'stop' });
this.mode = 'design'; this.mode = 'design';
this.lastWall = undefined; this.lastWall = undefined;
@@ -295,6 +363,7 @@ export class SimulationRoom {
this.lastWall = behind > MAXIMUM_CATCH_UP_MILLISECONDS ? now : (this.lastWall ?? now) + elapsed; this.lastWall = behind > MAXIMUM_CATCH_UP_MILLISECONDS ? now : (this.lastWall ?? now) + elapsed;
if (!this.guarded(() => this.engine.handle({ type: 'advance', toMilliseconds: this.engine.state.currentTimeMilliseconds + elapsed }))) return; if (!this.guarded(() => this.engine.handle({ type: 'advance', toMilliseconds: this.engine.state.currentTimeMilliseconds + elapsed }))) return;
this.flush([...this.clients.values()], false); this.flush([...this.clients.values()], false);
if (now - this.lastWriteBack >= (this.options.writeBackMilliseconds ?? WRITE_BACK_MILLISECONDS)) this.guarded(() => this.writeBack());
} }
/** /**
Binary file not shown.
Binary file not shown.
+6 -15
View File
@@ -1,9 +1,8 @@
// The room's command line window against Java's (tools/java-oracle // The room's command line window against Java's (tools/java-oracle
// TerminalWindowRun): the same keys typed into a computer's window, the same // TerminalWindowRun): the same keys typed into a computer's window, the same
// text, prompt and history after each, over a stop and a start. One // text, prompt and history after each, over a stop and a start — the
// difference is named: what a run made in the files is not yet written back // folder made before the stop is in the project afterwards (written back,
// into the document (F4.6f), so after the restart the folder made before is // F4.6f), and the window goes on in it, as Java's does.
// gone and the window goes on at the root, where Java's goes on in it.
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
@@ -59,9 +58,6 @@ function setUp(programs: readonly string[] = ['commandPrompt']) {
return { doc, room, send, run, read, got, connect: (listener: (message: HostMessage) => void) => room.connect(listener) }; return { doc, room, send, run, read, got, connect: (listener: (message: HostMessage) => void) => room.connect(listener) };
} }
/** Until the files of a run are written back (F4.6f): the folder made before the restart is gone, the window goes on at the root. */
const FILES_NOT_KEPT_YET = (text: string) => text.replaceAll('/dir', '/');
describe("a command line's window", () => { describe("a command line's window", () => {
it("shows what Java's window shows, key by key, over a stop and a start", () => { it("shows what Java's window shows, key by key, over a stop and a start", () => {
const { send, run, read } = setUp(); const { send, run, read } = setUp();
@@ -70,7 +66,6 @@ describe("a command line's window", () => {
run(TICK_MILLISECONDS); run(TICK_MILLISECONDS);
let pointer = -1; let pointer = -1;
let input = ''; let input = '';
let restartedAt: number | undefined;
for (const step of java.steps) { for (const step of java.steps) {
if (step.step.startsWith('enter:')) { if (step.step.startsWith('enter:')) {
send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: step.step.slice('enter:'.length) }, reqId: step.step }); send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: step.step.slice('enter:'.length) }, reqId: step.step });
@@ -85,13 +80,9 @@ describe("a command line's window", () => {
send({ kind: 'stop' }); send({ kind: 'stop' });
send({ kind: 'start' }); send({ kind: 'start' });
run(400); run(400);
restartedAt = terminalScreen(read()).text.length;
} }
const screen = terminalScreen(read()); const screen = terminalScreen(read());
const expected = const expected = step;
restartedAt === undefined
? step
: { ...step, text: step.text.slice(0, restartedAt) + FILES_NOT_KEPT_YET(step.text.slice(restartedAt)), prompt: FILES_NOT_KEPT_YET(step.prompt) };
expect({ text: screen.text, prompt: screen.prompt, promptShown: screen.promptShown, input }, step.step).toEqual({ expect({ text: screen.text, prompt: screen.prompt, promptShown: screen.promptShown, input }, step.step).toEqual({
text: expected.text, text: expected.text,
prompt: expected.prompt, prompt: expected.prompt,
@@ -120,9 +111,9 @@ describe("a command line's window", () => {
send({ kind: 'start' }); send({ kind: 'start' });
run(400); run(400);
expect(terminalScreen(read()).prompt).toBe('/docs> '); expect(terminalScreen(read()).prompt).toBe('/docs> ');
// The folder gone from the project: the next start goes on at the root. // The folder gone from the project (in the design): the next start goes on at the root.
files.delete('/docs');
send({ kind: 'stop' }); send({ kind: 'stop' });
files.delete('/docs');
send({ kind: 'start' }); send({ kind: 'start' });
run(400); run(400);
expect(terminalScreen(read()).prompt).toBe('/> '); expect(terminalScreen(read()).prompt).toBe('/> ');
+242
View File
@@ -0,0 +1,242 @@
// What a run leaves in the document, against what Java's project holds when
// it is saved after the same run (tools/java-oracle SaveAfterRunRun,
// fixtures/after-run-*.fls; both read with the same reader): the files a
// command line made, the accounts and mails, which servers run, the virtual
// hosts, a new domain; the addresses DHCP clients got. Named: the virtual
// hosts' file holds the complete rows only (F4.6d).
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 { VirtualFileSystem as VirtualFileSystemOf, type Id, type VirtualFileSystem } from '@mdg-filius/core';
import { createUndoManager, readProject, ROOTS, setSoftware, SIM_ORIGIN, writeProject, writeRunState } from '@mdg-filius/doc';
import { openProjectArchive, type ImportedProject } from '@mdg-filius/fls';
import type { HostMessage } from '@mdg-filius/protocol';
import { SimulationRoom, TICK_MILLISECONDS, WRITE_BACK_MILLISECONDS } from '../src/index.js';
const here = dirname(fileURLToPath(import.meta.url));
const corpus = join(here, '../../../corpus/java-examples');
function open(path: string): ImportedProject {
let ids = 0;
return openProjectArchive(new Uint8Array(readFileSync(path)), () => `00000000-0000-0000-0000-${(++ids).toString(16).padStart(12, '0')}`);
}
function setUp(file: string, writeBackMilliseconds?: number) {
const doc = new Y.Doc();
writeProject(doc, open(join(corpus, file)));
let now = 1_000_000;
const room = new SimulationRoom(doc, { now: () => now, seed: () => 5, ...(writeBackMilliseconds === undefined ? {} : { writeBackMilliseconds }) });
const got: HostMessage[] = [];
const client = room.connect((message) => got.push(message));
const id = (name: string) => readProject(doc).graph.nodes.find((node) => node.displayName === name)!.id;
const act = (node: string, app: string, action: string, args: object = {}) => room.receive(client, { v: 1, kind: 'input', node: id(node), app, action, args, reqId: `${app}.${action}` });
const run = (milliseconds: number) => {
for (let elapsed = 0; elapsed < milliseconds; elapsed += TICK_MILLISECONDS) {
now += TICK_MILLISECONDS;
room.tick();
}
};
return { doc, room, got, id, act, run, send: (message: object) => room.receive(client, { v: 1, ...message }) };
}
/** A computer's files: text as it is, the rest by kind. */
function filesOf(files: VirtualFileSystem | undefined): Record<string, string> {
return Object.fromEntries((files?.allEntries() ?? []).map((entry) => [entry.path, entry.content.kind === 'text' ? entry.content.text : entry.content.kind]));
}
/** What a run leaves of a node, as the model holds it. */
function slice(project: ImportedProject, name: string) {
const id = project.graph.nodes.find((node) => node.displayName === name)!.id;
return {
files: filesOf(project.virtualFileSystems.get(id)),
programs: [...(project.installedPrograms.get(id) ?? [])].sort(),
active: [...(project.activePrograms.get(id) ?? [])].sort(),
webServer: project.webServerConfigurations.get(id),
emailServer: project.emailServerConfigurations.get(id),
emailClient: project.emailClientConfigurations.get(id),
firewall: project.firewallConfigurationsByNodeId.get(id),
};
}
const ACCOUNT = (user: string, last: string) => ({ name: `Rechner ${last}`, email: `${user}@filius.de`, pop3Host: '141.99.50.232', pop3Port: '110', smtpHost: '141.99.50.232', smtpPort: '25', username: user, password: user });
describe("what a run leaves in the document, against Java's project saved after it", () => {
it('keeps the files, accounts, mails, servers, virtual hosts and domain of a run', () => {
const { doc, id, act, run, send } = setUp('email_komplett.fls');
setSoftware(doc, id('Rechner 1'), { install: ['commandPrompt'], remove: [] }, 'de', 'me');
send({ kind: 'start' });
run(2_000);
act('Rechner 1', 'terminal', 'enter', { line: 'mkdir ordner' });
run(500);
act('Rechner 1', 'terminal', 'enter', { line: 'touch ordner/notiz.txt' });
run(500);
act('Rechner 1', 'emailClient', 'setAccount', ACCOUNT('rechner1', '1'));
act('Rechner 2', 'emailClient', 'setAccount', ACCOUNT('rechner2', '2'));
act('Rechner 1', 'emailClient', 'send', { to: 'rechner2@filius.de', cc: '', bcc: '', subject: 'Nach dem Lauf', text: 'Erste Zeile\nZweite Zeile' });
run(8_000);
act('Rechner 2', 'emailClient', 'fetch');
run(8_000);
act('Mail-Server (filius.de)', 'emailServer', 'addAccount', { username: 'neu', password: 'pw', firstName: 'Vorname', lastName: 'Nachname' });
act('Web-Server', 'webServer', 'setActive', { active: false });
act('Web-Server', 'webServer', 'useVirtualHosts', { use: true });
act('Web-Server', 'webServer', 'setVirtualHost', { row: 0, column: 0, value: 'a.de' });
act('Web-Server', 'webServer', 'setVirtualHost', { row: 0, column: 1, value: 'ordner' });
act('Mail-Server (senior.de)', 'emailServer', 'setActive', { active: false });
act('Mail-Server (senior.de)', 'emailServer', 'setDomain', { domain: 'senior.org' });
run(1_000);
send({ kind: 'stop' });
const ours = readProject(doc);
const java = open(join(here, 'fixtures/after-run-email.fls'));
for (const name of ['Rechner 1', 'Rechner 2', 'Rechner 3', 'Mail-Server (filius.de)', 'Mail-Server (senior.de)', 'DNS-Server']) {
expect(slice(ours, name), name).toEqual(slice(java, name));
}
// The virtual hosts' file holds the complete rows (Java's every row, the empty ones as empty lines).
const web = slice(java, 'Web-Server');
expect(web.files['/www.conf/vhosts']).toBe('a.de\nordner\n\n\n\n\n\n\n\n\n');
expect(slice(ours, 'Web-Server')).toEqual({ ...web, files: { ...web.files, '/www.conf/vhosts': 'a.de\nordner\n' } });
});
it('keeps the addresses DHCP clients got', () => {
const { doc, run, send } = setUp('einfaches_rechnernetz_komplett.fls');
send({ kind: 'start' });
run(20_000);
// Written while it runs, not only at the stop.
const running = readProject(doc);
send({ kind: 'stop' });
expect(readProject(doc).deviceConfigurations).toEqual(running.deviceConfigurations);
const devices = (project: ImportedProject) =>
project.graph.nodes
.filter((node) => project.dhcpClientConfigurations.get(node.id)?.isEnabled === true)
.map((node) => project.deviceConfigurations.get(node.id)!)
// Which client gets which address is the threads' race in Java.
.map(({ ipAddress, ...rest }) => ({ ...rest, ipAddress: ipAddress.startsWith('192.168.0.') ? 'leased' : ipAddress }));
const java = open(join(here, 'fixtures/after-run-dhcp.fls'));
const before = open(join(corpus, 'einfaches_rechnernetz_komplett.fls'));
expect(devices(readProject(doc))).toEqual(devices(java));
expect(devices(before)).not.toEqual(devices(java));
const addresses = (project: ImportedProject) =>
project.graph.nodes.filter((node) => project.dhcpClientConfigurations.get(node.id)?.isEnabled === true).map((node) => project.deviceConfigurations.get(node.id)!.ipAddress).sort();
expect(addresses(readProject(doc))).toEqual(addresses(java));
});
});
describe("the room's write-back", () => {
it('writes while the simulation runs, at most once a second, after an action and at the stop, as the room', () => {
const { doc, id, act, run, send } = setUp('email_komplett.fls');
const origins: unknown[] = [];
doc.on('update', (_update: Uint8Array, origin: unknown) => origins.push(origin));
const node = id('Mail-Server (filius.de)');
const accounts = () => (readProject(doc).emailServerConfigurations.get(node)?.accounts ?? []).map((account) => account.username);
send({ kind: 'start' });
run(TICK_MILLISECONDS);
act('Mail-Server (filius.de)', 'emailServer', 'addAccount', { username: 'neu', password: 'pw', firstName: 'A', lastName: 'B' });
expect(accounts()).toContain('neu');
expect(origins.length).toBeGreaterThan(0);
expect(new Set(origins)).toEqual(new Set([SIM_ORIGIN]));
// Nothing new: nothing is written.
const written = origins.length;
run(3 * WRITE_BACK_MILLISECONDS);
expect(origins.length).toBe(written);
send({ kind: 'stop' });
expect(origins.length).toBe(written);
});
it('writes at the stop what it left since the last write', () => {
const { doc, id, run, send } = setUp('einfaches_rechnernetz_komplett.fls', 60_000);
const notebook = id('Notebook 1');
const before = readProject(doc).deviceConfigurations.get(notebook);
send({ kind: 'start' });
run(20_000);
expect(readProject(doc).deviceConfigurations.get(notebook)).toEqual(before);
send({ kind: 'stop' });
expect(readProject(doc).deviceConfigurations.get(notebook)!.ipAddress).toMatch(/^192\.168\.0\./);
});
it('removes from the document what a command line removed', () => {
const { doc, id, act, run, send } = setUp('email_komplett.fls');
const node = id('Rechner 1');
setSoftware(doc, node, { install: ['commandPrompt'], remove: [] }, 'de', 'me');
const files = () => Object.keys(Object.fromEntries((readProject(doc).virtualFileSystems.get(node)?.allEntries() ?? []).map((entry) => [entry.path, true])));
expect(files()).toContain('/konten.txt');
send({ kind: 'start' });
run(TICK_MILLISECONDS);
act('Rechner 1', 'terminal', 'enter', { line: 'rm konten.txt' });
expect(files()).not.toContain('/konten.txt');
});
it("is never a person's step to take back", () => {
const { doc, id, act, run, send } = setUp('email_komplett.fls');
const undo = createUndoManager(doc, 'me');
send({ kind: 'start' });
run(TICK_MILLISECONDS);
act('Web-Server', 'webServer', 'setActive', { active: false });
send({ kind: 'stop' });
expect(readProject(doc).activePrograms.get(id('Web-Server'))).toBeUndefined();
expect(undo.undoStack).toHaveLength(0);
});
it('writes a text where it changed, keeps a file another wrote meanwhile, and removes a file the run removed', () => {
const doc = new Y.Doc();
const { doc: source } = setUp('email_komplett.fls');
writeProject(doc, readProject(source));
const nodeId = readProject(doc).graph.nodes.find((node) => node.displayName === 'Rechner 1')!.id as Id;
const files = () => doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get(nodeId)!.get('files') as Y.Map<Y.Map<unknown>>;
const fileSystem = readProject(doc).virtualFileSystems.get(nodeId)!;
fileSystem.createDirectory('/neu');
fileSystem.writeTextFile('/neu/a.txt', 'Anfang Mitte Ende');
writeRunState(doc, new Map([[nodeId, { files: fileSystem }]]));
const text = files().get('/neu/a.txt')!.get('text') as Y.Text;
expect(text.toString()).toBe('Anfang Mitte Ende');
const order = files().get('/neu/a.txt')!.get('order');
// The run changes the middle; the entry and its text stay the same objects.
fileSystem.writeTextFile('/neu/a.txt', 'Anfang neue Mitte Ende');
const changes: string[] = [];
text.observe((event) => changes.push(JSON.stringify(event.delta)));
writeRunState(doc, new Map([[nodeId, { files: fileSystem }]]));
expect([files().get('/neu/a.txt')!.get('text'), text.toString(), files().get('/neu/a.txt')!.get('order')]).toEqual([text, 'Anfang neue Mitte Ende', order]);
expect(changes).toEqual([JSON.stringify([{ retain: 7 }, { insert: 'neue ' }])]);
// A file turned into a folder, and one removed.
fileSystem.deleteItem('/neu/a.txt');
fileSystem.createDirectory('/neu/a.txt');
fileSystem.deleteItem('/konten.txt');
writeRunState(doc, new Map([[nodeId, { files: fileSystem }]]));
expect(files().get('/neu/a.txt')!.get('kind')).toBe('directory');
expect(files().get('/neu/a.txt')!.get('order')).toBe(order);
expect(files().has('/konten.txt')).toBe(false);
// An image's bytes and type.
fileSystem.writeImageFile('/bild.png', new Uint8Array([1, 2]), 'image/png');
writeRunState(doc, new Map([[nodeId, { files: fileSystem }]]));
fileSystem.writeImageFile('/bild.png', new Uint8Array([1, 3]), 'image/png');
writeRunState(doc, new Map([[nodeId, { files: fileSystem }]]));
expect(files().get('/bild.png')!.get('data')).toEqual(new Uint8Array([1, 3]));
fileSystem.writeImageFile('/bild.png', new Uint8Array([1, 3]), 'image/gif');
writeRunState(doc, new Map([[nodeId, { files: fileSystem }]]));
expect(files().get('/bild.png')!.get('mediaType')).toBe('image/gif');
// A setting written again alike, its fields in another order or one undefined: nothing; with a field less: written.
const settingsDoc = new Y.Doc();
writeProject(settingsDoc, readProject(source));
const server = readProject(settingsDoc).graph.nodes.find((node) => node.displayName === 'Web-Server')!.id;
const web = readProject(settingsDoc).webServerConfigurations.get(server)!;
const updates: unknown[] = [];
settingsDoc.on('update', (update: Uint8Array) => updates.push(update));
writeRunState(settingsDoc, new Map([[server, { settings: { webServer: { virtualHosts: web.virtualHosts, documentRoot: web.documentRoot, port: web.port, extra: undefined } } }]]));
expect(updates).toHaveLength(0);
writeRunState(settingsDoc, new Map([[server, { settings: { webServer: { port: web.port, documentRoot: web.documentRoot } } }]]));
expect(updates).toHaveLength(1);
expect(readProject(settingsDoc).webServerConfigurations.get(server)).toEqual({ port: web.port, documentRoot: web.documentRoot });
writeRunState(settingsDoc, new Map([[server, { settings: { webServer: { port: web.port, documentRoot: web.documentRoot, virtualHosts: true } } }]]));
expect(updates).toHaveLength(2);
// A node without files whose run has none: no files.
const switchId = readProject(settingsDoc).graph.nodes.find((node) => node.kind === 'networkSwitch')!.id;
writeRunState(settingsDoc, new Map([[switchId, { files: new VirtualFileSystemOf() }]]));
expect(settingsDoc.getMap<Y.Map<unknown>>(ROOTS.nodes).get(switchId)!.has('files')).toBe(false);
// A node the document no longer has, and settings it has not: nothing.
writeRunState(doc, new Map([['niemand', { files: fileSystem }]]));
writeRunState(doc, new Map([[nodeId, { settings: { webServer: { port: 80 } } }]]));
expect(readProject(doc).webServerConfigurations.has(nodeId)).toBe(false);
});
});
+25
View File
@@ -1368,3 +1368,28 @@ Gemessen (03.10.2026):
* Löschen löscht in Javas Fenster die Zeile der zuletzt **angeklickten** * Löschen löscht in Javas Fenster die Zeile der zuletzt **angeklickten**
Liste: War zuvor eine gesendete Mail gewählt und ist im Posteingang schon Liste: War zuvor eine gesendete Mail gewählt und ist im Posteingang schon
eine markiert, geht die gesendete. eine markiert, geht die gesendete.
### Was ein Lauf im Projekt hinterlässt (F4.6f1)
`SaveAfterRunRun` öffnet ein Beispiel, wie FILIUS es öffnet, startet es,
spielt eine Geschichte über die Programme (wie ihre Fenster sie aufrufen),
stoppt wie der Entwurfsmodus (`stopSimulation`: `beenden` jedes Knotens) und
speichert (`SzenarioVerwaltung.speichern`; Java speichert nur im Entwurf):
`packages/sim/test/fixtures/after-run-email.fls` (email_komplett: Ordner
und Datei aus der Befehlszeile, Konten gesetzt, eine Mail gesendet und
abgerufen, ein neues Konto, Webserver aus und virtuelle Hosts an, ein
E-Mail-Server aus mit neuer Domain) und `after-run-dhcp.fls`
(einfaches_rechnernetz_komplett, 10 s DHCP).
Gemessen (03.10.2026):
* Java speichert die Dateien, wie die Programme sie hinterließen (auch
`/www.conf/vhosts`, die Kontendateien), ob jeder Server läuft, das
Kästchen der virtuellen Hosts (nach jedem Start an, wenn die Datei da
ist), Domain und Konten der E-Mail-Server, Konto, Posteingang und
Gesendete der E-Mail-Programme (eine abgerufene Mail mit „\n“ am Ende).
* Die Adressen, die DHCP-Clients bekamen, stehen danach in ihren Karten,
mit Gateway „0.0.0.0“, wo der Server keines vergibt.
* Die Konten der Programme in email_komplett liegen nur in der alten
`kontoListe`, die FILIUS 2.14 nicht liest; das Orakel setzt sie, wie man
sie in den Dialog tippt (`setzeKonto`, `speichern`).
+13 -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" 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"
# 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.
@@ -389,6 +389,18 @@ HOME="$here/.build/home" xvfb-run -a java -Duser.language=de -Duser.country=DE -
"$root/packages/core/test/scenarios/mail-lan.scenario.json" "$output" 2>&1 | grep -v 'logback\|^$\|^\s*at \|AWT-EventQueue' || true "$root/packages/core/test/scenarios/mail-lan.scenario.json" "$output" 2>&1 | grep -v 'logback\|^$\|^\s*at \|AWT-EventQueue' || true
test -s "$output" test -s "$output"
# What a run leaves in FILIUS' project: an example started, a story played,
# stopped as the design mode stops it and saved (SaveAfterRunRun): the room's
# write-back into the document (packages/sim/test/writeBack.test.ts).
for run in email:email_komplett dhcp:einfaches_rechnernetz_komplett; do
story="${run%%:*}"
output="$root/packages/sim/test/fixtures/after-run-$story.fls"
rm -f "$output"
HOME="$here/.build/home" xvfb-run -a java -Duser.language=de -Duser.country=DE -cp "$classpath:$here/.build" SaveAfterRunRun \
"$root/corpus/java-examples/${run##*:}.fls" "$story" "$output" 2>&1 | grep -v 'logback\|^$\|^\s*at ' || true
test -s "$output"
done
# 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).
+149
View File
@@ -0,0 +1,149 @@
// What a run leaves in FILIUS' project: an example opened and started as
// FILIUS does (ScenarioNetwork.fromFile), a story played through the
// programs as their windows call them, the simulation stopped as the design
// mode stops it (GUIMainMenu.stopSimulation: every node's beenden) and the
// project saved (SzenarioVerwaltung.speichern; Java saves only in the design
// mode). The reference for the room's write-back into the document
// (packages/sim/test/writeBack.test.ts).
//
// SaveAfterRunRun <file.fls> <story> <out.fls>
//
// email email_komplett, with FILIUS' command line installed on Rechner 1
// before the start: Rechner 1 and 2 set their accounts (as typed
// into the program's dialog), Rechner 1 types "mkdir ordner" and
// "touch ordner/notiz.txt", writes to rechner2@filius.de; Rechner 2
// fetches; Mail-Server (filius.de) gets the account "neu"; the
// Web-Server is switched off, its virtual hosts on with the row
// a.de → ordner; Mail-Server (senior.de) is switched off and its
// domain set to senior.org
// dhcp einfaches_rechnernetz_komplett: the notebooks get their
// addresses from the server (10 s)
import filius.gui.GUIContainer;
import filius.gui.netzwerksicht.GUIKnotenItem;
import filius.hardware.knoten.Knoten;
import filius.rahmenprogramm.SzenarioVerwaltung;
import filius.software.email.Email;
import filius.software.email.EmailAnwendung;
import filius.software.email.EmailKonto;
import filius.software.email.EmailServer;
import filius.software.email.EmailUtils;
import filius.software.lokal.Terminal;
import filius.software.system.Betriebssystem;
import filius.software.system.InternetKnotenBetriebssystem;
import filius.software.www.WebServer;
import java.util.Arrays;
import java.util.StringTokenizer;
public class SaveAfterRunRun {
static ScenarioNetwork network;
static InternetKnotenBetriebssystem system(String name) {
Knoten knoten = network.nodes.get(name);
if (knoten == null) throw new IllegalStateException("no node " + name);
return (InternetKnotenBetriebssystem) knoten.getSystemSoftware();
}
/** GUIApplicationTerminalWindow on Enter: the line handed to the program. */
static void type(Terminal terminal, String line) throws Exception {
StringTokenizer tokens = new StringTokenizer(line, " ");
String command = tokens.nextToken();
String[] parameters = new String[3 + tokens.countTokens()];
Arrays.fill(parameters, "");
for (int index = 0; tokens.hasMoreTokens(); index++) parameters[index] = tokens.nextToken();
terminal.terminalEingabeAuswerten(command, parameters);
Thread.sleep(500);
}
/**
* The account as one types it into the program's dialog and saves it
* (kontoSpeichern: setzeKonto, speichern): the file keeps it only in the
* older kontoListe, which FILIUS 2.14 no longer reads.
*/
static EmailKonto account(EmailAnwendung program, String user, String server, String domain, String lastName) {
EmailKonto account = new EmailKonto();
account.setVorname("Rechner");
account.setNachname(lastName);
account.setBenutzername(user);
account.setPasswort(user);
account.setPop3port("110");
account.setPop3server(server);
account.setSmtpport("25");
account.setSmtpserver(server);
account.setEmailAdresse(user + "@" + domain);
program.setzeKonto(account);
program.speichern();
return account;
}
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.fromFile(args[0]);
String story = args[1];
if (story.equals("email")) {
((Betriebssystem) system("Rechner 1")).installAndStartAppIfAvailable("filius.software.lokal.Terminal");
}
network.start();
Thread.sleep(2000);
switch (story) {
case "email": {
Terminal terminal = (Terminal) system("Rechner 1").holeSoftware("filius.software.lokal.Terminal");
type(terminal, "mkdir ordner");
type(terminal, "touch ordner/notiz.txt");
EmailAnwendung one = (EmailAnwendung) system("Rechner 1").holeSoftware("filius.software.email.EmailAnwendung");
EmailKonto account = account(one, "rechner1", "141.99.50.232", "filius.de", "1");
Email mail = new Email();
mail.setAbsender(account.getVorname() + (!account.getNachname().isEmpty() ? (" " + account.getNachname()) : "") + " <" + account.getEmailAdresse() + ">");
mail.setEmpfaenger(EmailUtils.stringToAddressEntryList("rechner2@filius.de"));
mail.setCc(EmailUtils.stringToAddressEntryList(""));
mail.setBcc(EmailUtils.stringToAddressEntryList(""));
mail.setBetreff("Nach dem Lauf");
mail.setText("Erste Zeile\nZweite Zeile");
one.versendeEmail(account.getSmtpserver(), mail, account.getEmailAdresse());
Thread.sleep(8000);
EmailAnwendung two = (EmailAnwendung) system("Rechner 2").holeSoftware("filius.software.email.EmailAnwendung");
EmailKonto other = account(two, "rechner2", "141.99.50.232", "filius.de", "2");
two.emailsAbholenEmails(other.getBenutzername(), other.getPasswort(), other.getPop3port(), other.getPop3server());
Thread.sleep(8000);
((EmailServer) system("Mail-Server (filius.de)").holeSoftware("filius.software.email.EmailServer")).benutzerHinzufuegen("neu", "pw", "Nachname", "Vorname");
WebServer web = (WebServer) system("Web-Server").holeSoftware("filius.software.www.WebServer");
web.setAktiv(false);
web.setUseVHost(true);
web.changeVHostTable(0, 0, "a.de");
web.changeVHostTable(0, 1, "ordner");
EmailServer senior = (EmailServer) system("Mail-Server (senior.de)").holeSoftware("filius.software.email.EmailServer");
senior.setAktiv(false);
senior.setMailDomain("senior.org");
Thread.sleep(1000);
break;
}
case "dhcp":
Thread.sleep(10000);
break;
default:
throw new IllegalArgumentException("unknown story " + story);
}
// GUIMainMenu.stopSimulation, then the design mode's save.
for (GUIKnotenItem item : GUIContainer.getGUIContainer().getKnotenItems()) {
try {
item.getKnoten().getSystemSoftware().beenden();
} catch (Exception error) {}
}
Thread.sleep(1000);
GUIContainer gui = GUIContainer.getGUIContainer();
if (!SzenarioVerwaltung.getInstance().speichern(args[2], gui.getKnotenItems(), gui.getCableItems(), gui.getDocuItems())) {
throw new IllegalStateException("FILIUS could not save " + args[2]);
}
System.out.println("saved after the run: " + story);
}
}