diff --git a/CHANGES.md b/CHANGES.md index c2f68d6..ffad4e0 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -14,6 +14,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/model/documentation.ts` | `Model/TopologyDocumentation.swift` | übertragen; FNV-Kennung mit BigInt, gegen die IDs aus dem Swift-Import geprüft | | `src/runtime/config.ts` | `State/TopologyEditorState.swift` (Laufzeit-Konfigurationen, WLAN eines Rechners, DHCP, Portweiterleitung, Remote Link ohne LAN-Transport, Programme und ihre Konfiguration, Firewall, DNS-Einträge aus `State/TopologyDNSResolver.swift`, E-Mail-Konten und -Nachrichten aus `State/TopologyEmail.swift`); dazu Echo-Server- und Client-Konfiguration, die Swift nicht speichert | übertragen; Java-Beschriftungen der Oberfläche weggelassen | | `src/runtime/engine.ts` | `State/TopologyNetworkRuntimeEngine.swift` Z. ~313–3585, ~4472–5187 (UDP), ~4753–4850 | übertragen: Warteschlange, Uhr, Ethernet, Switch, ARP, IPv4, ICMP, Routing, Firewall, UDP-Sockets (binden, freie Ports nach Javas Zufall, senden samt Broadcast, zustellen, empfangen mit Wartezeit, schließen), TCP (Z. ~5187–5913: Server- und Client-Sockets, Handschlag mit Ergebnis, Segmente zu 1460 Byte bis zum PSH, höchstens drei Versuche, Abbau über alle Java-Zustände samt `TIME_WAIT`; seit F2.3c nach Javas `TCPSocket` an Java-Filius gemessen: je Versuch und für `TIME_WAIT` 3 RTT nach der Geschwindigkeit (8250 ms bei 90 %, Swift fest 7500 bzw. 2500 ms), der Zeitablauf zählt ab der Übergabe des Segments, vor ARP; ein Segment beendet `TIME_WAIT`; die Gegenseite schließt beim FIN von selbst; nicht bestätigte Daten schließen mit FIN statt sofort; der angenommene Socket hat die Startnummer des Listeners, der Listener eine neue. seit F2.3e wie Java: SYN+ACK und FIN gehen nie ein zweites Mal hinaus; der Server gibt einen Handschlag nach 3 RTT auf und schließt mit FIN; nach dem FIN wartet ein Socket höchstens fünfmal 3 RTT, jedes Segment beendet eine Wartezeit, dann CLOSED (Java lässt den Zustand stehen). Abweichend von Java: drei SYN statt einem, weil Javas Wiederholungsschleife nach dem ersten Zeitablauf abbricht, und ein wiederholtes SYN in `SYN_RCVD` bekommt das SYN+ACK noch einmal, statt den Socket zu schließen), DHCP (Z. ~5913–6694: Server mit festen Zuordnungen, Adressbereich, Angeboten, Leases und Sperrliste; Client seit F2.3b nach Javas `DHCPClient` neu gebaut und an Java-Filius gemessen: je Versuch eine Nachricht, eine Antwort, die nicht passt, wird gelesen und verworfen; die ARP-Prüfung wartet eine RTT, eine Antwort für die Adresse beendet sie sofort, dann DECLINE; nach einem Zeitablauf wiederholt der Zustand seine Nachricht, nach dem fünften nimmt der Client eine Link-Local-Adresse `169.254.1.x/16` (ARP-Probe, ARP-Reply an alle) und gibt keine alte Adresse zurück; Swift sendete doppelt, wartete nicht auf ARP und kehrte nach zehn Versuchen zur alten Adresse zurück. Abweichend von Java: Nach fünf belegten Link-Local-Adressen gibt der Client auf (Java sucht endlos), und ein ARP-Paket für eine andere Adresse beendet die Prüfung nicht. Seit F2.3d befördert ein Kabel einen Frame nach dem anderen wie Javas `SimplexVerbindung` (ein Frame wartet im Ausgangspuffer, bis der vorige angekommen ist, und erscheint im Protokoll, wenn er losgeht); Swift schickt alle zugleich, die Option `swiftCables` behält das für den Swift-Vergleich. Die RTT der DHCP-Zeiten (Warten, Angebote, Sperrliste) folgt wie in Java der Geschwindigkeit (50 Kabel, 2750 ms bei 90 %); Swift rechnet fest mit 2500 ms. ARP seit F2.4b nach Javas `holeARPTabellenEintrag`, an Java-Filius gemessen: eine Auflösung fragt, wartet eine RTT, fragt ein zweites Mal und gibt nach einer weiteren RTT auf; dann „Host unreachable" beim Absender selbst oder vom Router zurück (bei ICMP nur für Echo-Anfragen); ein wartender Absender wacht nach den übrigen Ereignissen desselben Zeitpunkts auf; der Ping wartet danach eine RTT auf die Antwort. Swift schickt in der Ereignisverarbeitung beide Anfragen zugleich und lässt das Paket ohne Ende warten, wenn die Adresse irgendwo im Netz vorkommt (Wissen, das kein Knoten hat), und blockiert direkte Aufrufe ohne Wartezeit; die Option `swiftArp` behält das für den Swift-Vergleich. Seit F2.4b-2 kehrt ein Senden wie in Java sofort zurück, die Frames reisen, während die Uhr läuft (Swift: der Aufruf kehrt erst zurück, wenn alle Frames angekommen sind; die Option `swiftSends` behält das für den Swift-Vergleich), und UDP und TCP senden über Javas Transport-Thread je Knoten und Protokoll: ein Segment nach dem anderen; solange IP auf ARP wartet, blockiert das nächste `senden`. Wartende Absender kommen in der Reihenfolge an die Reihe, in der sie kamen (Javas Sperre überlässt das dem Zufall). Seit F2.4b-3 geht jedes ankommende Paket wie in Java durch den IP-Thread (UDP, TCP) oder den ICMP-Thread (ICMP) seines Knotens: zustellen, Echo-Reply, weiterleiten, eins nach dem anderen; solange ARP wartet, warten die folgenden. Abweichend von Java weckt eine ARP-Antwort alle, die auf die Adresse warten (Javas `notify` weckt einen beliebigen). DNS seit F2.4a nach Javas `DNSServer`/`Resolver`, an Java-Filius gemessen: Server auf Port 53 mit Einträgen aus dem Modell (die Antwort wiederholt die Frage nicht, RCODE immer 0, ein Verweis mit NS und A im Antwortteil), iterativer Resolver mit 5 RTT je Anfrage, der nur die Server behält, an die er verwiesen wurde; seit F2.4c auf den Ereignissen statt blockierend und mit rekursivem Server nach `DNSServer.answerWithRemoteData` (Nameserver der Zone, dann der eigene DNS-Server, sonst Verweis), abweichend von Java fragt er sich nie selbst (Java endlos). Gateway-NAT seit F2.6 (Z. ~3585–3900) nach Javas `filius.software.nat`, an Java-Filius gemessen: die Übersetzung vor dem IP-Thread (wie Javas NAT-Threads zwischen Karte und Ethernet), ausgehend WAN-Adresse und der Port des Eintrags (ICMP behält die Kennung), eingehend nach Gegenstelle, Port und Protokoll, sonst nach der Portweiterleitung (legt einen dynamischen Eintrag an), ein Ping an die WAN-Adresse beantwortet der Heimrouter, Pakete von außen an das LAN verschwinden, beide Seiten durch die Firewall des Heimrouters, ausgehend TTL 1 bekommt „Zeit überschritten“; dynamische Einträge verfallen nach 300 s mal Javas Verzögerungsfaktor (Swift fest 300 s), aufgefrischt nur eingehend, seit 01.10.2026 genau in diesem Alter (Java findet sie erst bei seiner sekündlichen Prüfung, bis zu 1 s später; so auch der Switch: Javas `checkSAT` prüft jede Sekunde, der Kern verfällt genau zur Lernzeit); der Heimrouter leitet weiter wie ein Router. Abweichend von Java: ein LAN-Endpunkt bekommt für jede neue Gegenstelle einen Eintrag auf demselben WAN-Port (Java behält nur den für die erste, die Antworten weiterer bleiben am Heimrouter hängen). RIP seit F2.5a (Z. ~4700–5090) nach Javas `filius.software.rip`, seit F2.5b an Java-Filius gemessen (die Pause in Javas `float`-Rechnung): eine Route mit 0 Hops je eigenem Netz, die nie verfällt; der erste Beacon nach einer Sekunde, dann alle 25,2–35,2 s aus einem Zufall je Router (Java: ein ungesetzter `Random` je Router; der Kern setzt ihn aus Szenario und Router-ID, `ripSeed`; Swift: ein Zufall für alle); eine Nachricht je Schnittstelle ohne die Routen über sie (Split Horizon); eine neue Route oder mehr Hops vom selben Router senden sofort; eine gelernte Route wird genau nach ihren 75 s auf 16 gesetzt und sofort gemeldet (Java erst beim nächsten eigenen Beacon, bis zu gut 30 s später; Swift bei jedem Ereignis; seit 01.10.2026, vorher wie Java); Nachrichten von jedem Quellport (Swift: nur 521); ein RIP-Router leitet nur nach seiner Tabelle weiter, wenige Hops vor langer Maske. `receiveUdp` kehrt wie Javas `empfangen` zurück, sobald ein Datagramm da ist. Seit der Abnahme F2 nach Javas `ARPThread`: ein Eintrag entsteht nur aus einer Anfrage nach der eigenen Adresse oder aus einer Antwort (Swift: aus jedem Paket; `swiftArp` behält das), ein Paket von der Netzadresse wird verworfen; ein Rechner leitet weiter, wenn seine IP-Weiterleitung an ist (`ipForwardingByNodeId`). Seit F2.8b nach Javas Tabellen gemessen: eine ARP-Anfrage trägt als Ziel-MAC die Broadcast-Adresse (Swift: Nullen; `swiftArp` behält sie), eine ICMP-Fehlermeldung Identifier und Sequenznummer 0 (RFC 792, Java; Swift kopiert die des Echos, ein Ping erkennt die Meldung am eingebetteten Echo), und „Host nicht erreichbar“ vom Router trägt das Paket, wie er es weitergeleitet hat (TTL gesenkt). Die Broadcast-Adresse schreibt sie immer groß (Java in der Antwort an 0.0.0.0 klein). Den Mitschnitt je Karte (`capture.ts`) schreibt sie seit F2.8a dort, wo Java ihn schreibt: beim Abgang auf das Kabel und bei der Ankunft an der Karte. Seit F3.4a läuft die Befehlszeile mit (`terminal`, auf den Dateien des Knotens). Seit F3.4b nach Javas `ICMP`/`ICMPThread`, an Java-Filius gemessen: ein Echo im Programm-Thread (`icmpEchoSteps`: Warteschlange leeren, senden mit ARP-Wartezeit, eine RTT auf die erste ICMP-Nachricht warten, gleich welche), ein ICMP-Paket an den Broadcast eines eigenen Netzes geht mit TTL 1 an alle und wird von dort beantwortet, einer für ein fremdes Netz verworfen; Javas Routenwahl (`routeRow`), die ARP-Tabelle mit Javas Broadcast-Zeile (`arpTable`, `removeArpEntry`, `resetArpTable`), DNS im Programm-Thread mit Antwort- und Autoritätsteil (`resolveDnsSteps`), ein UDP-Socket weiß, dass er ohne Gegenstelle lauscht (`listening`); ein Rechner nimmt Pakete an 127.0.0.1 an. Eine ARP-Wartezeit endet wie Javas `wait` nach allem, was zur selben Zeit sonst geschieht (eine Antwort zur selben Millisekunde zählt noch). Seit F3.4c die Uhrzeit eines Frames (`wallTime`) für `tcpdump`. Seit F3.6a läuft das Gnutella-Programm mit (`gnutella`, Schnappschuss `gnutellaByNodeId`), mit eigener Zufallsquelle für die GUIDs (`drawGnutellaGuid`). Seit F3.5a schreibt der Mailserver seine Kontendatei `/mailserver/konten.txt` beim Start und bei jeder Änderung seiner Konten in die Dateien des Knotens (Java schreibt die Benachrichtigung an einen lokalen Absender erst beim nächsten Speichern hinein). Abweichend von Java: eine Echo-Antwort kommt von der gefragten Adresse (RFC 792; Java antwortet von 127.0.0.1, wenn die eigene Adresse gefragt war, und von der nahen Karte, wenn die ferne eines Routers gefragt war), und „Netz nicht erreichbar“ vom Router trägt wie „Host nicht erreichbar“ das Paket, wie er es weitergeleitet hätte, mit Absender (Java bettet ein halb gebautes ohne Absender ein). Seit F3.3b läuft das E-Mail-Programm mit (`emailClient`), seine gesendeten und abgeholten Mails stehen im Schnappschuss (`emailClientsByNodeId`); ein Programm-Thread kann MX nachschlagen (`resolveDnsMailServerSteps`). Seit F3.3a läuft der E-Mail-Server mit (`emailServer`); was er annimmt oder löscht, steht im Schnappschuss (`emailServersByNodeId`), aus dem es zurück ins Projekt geht; die Uhrzeit für seine Benachrichtigungen kommt aus der Option `wallClock`. Seit F3.2c ändert die Seite `konfig` der Router und Heimrouter die Firewall zur Laufzeit; die Änderung steht im Schnappschuss (`firewallConfiguration`), aus dem sie zurück ins Projekt geht. Seit F3.2b starten Router und Heimrouter ihren Webserver auf Port 80 mit Javas Seiten (Javas Server lauscht wegen eines Wettlaufs in `setAktiv` manchmal nie, der Kern immer; `swiftRouters` lässt ihn für den Swift-Vergleich weg), die Firewall führt ein Protokoll je Knoten (Regeltreffer bei TCP und UDP, verworfenes ICMP), das wie Javas Protokollseite einen Neustart überlebt, eine RIP-Route kennt die öffentliche Adresse des meldenden Routers (`hopPublicIp`), und TCP zählt Sequenznummern und Segmentgröße wie Java in Zeichen (UTF-16-Einheiten) statt in Bytes, ohne ein Zeichenpaar zu zerreißen; Bytes, die kein Text sind, zählen als Bytes. Seit F3.2a schließt ein Programm wie Javas `schliessen()` nur auf Bitte (`requestTcpClose`): Das FIN geht zur selben Zeit, nach dem, was der Programm-Thread noch tut. Seit F3.1 laufen Verbinden, Senden, Empfangen und Annehmen auch als Schritte eines Programm-Threads (`connectTcpSteps`, `sendTcpSteps`, `receiveTcpSteps`, `acceptTcpSteps`, `resolveHostSteps`); die blockierenden Aufrufe sind nur noch eine Hülle darum. Abweichend von Java: Ein Verbindungsaufbau ohne Route scheitert sofort (Javas Socket-Thread stirbt an der `RouteNotFoundException`, `verbinden` wartet ewig), eine Nachricht, die vor dem Schließen ankam, wird noch ausgegeben (Java verwirft sie), und Senden unterscheidet wie Java „nicht verbunden", „unterbrochen" und „Zeitüberschreitung". WLAN seit F2.7 an Java-Filius gemessen: zur Laufzeit ist ein Funkkabel ein Kabel wie jedes andere, und eine SSID verbindet nichts (nach ihr legt nur Javas Editor das Funkkabel, `JHostKonfiguration.updateWifiConnection`); Swifts Verbindung über die SSID zur Laufzeit, auch ohne Kabel (`wirelessAssociations`), nicht übernommen. Klasse statt Werttyp. Noch nicht übertragen (wirft `NotPortedError`): Remote Link | +| `src/runtime/engine.ts` (ganze Millisekunden) | Java `System.currentTimeMillis` (long) | behoben (F4.6b): `start` und `advance` runden auf ganze Millisekunden ab, wie Javas Uhr zählt. Gefunden im Browser: der Raum gab `performance.now()` mit Bruchteilen weiter, und ein Ping blieb nach der ersten Zeitüberschreitung hängen; alle Tests liefen zuvor mit glatten Takten | | `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/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) | @@ -28,7 +29,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/runtime/apps/emailClient.ts` | Java `EmailAnwendung`, `SMTPClient`, `POP3Client`, das Senden aus `GUIApplicationEmailAnwendungWindow` | neu nach Java (F3.3b): das E-Mail-Programm sendet eine Mail an den SMTP-Server seines Kontos, eine Verbindung je Empfänger (An, Cc, Bcc), und holt die Mails seines Kontos vom POP3-Server (jede abgerufen, dann gelöscht); die Prüfungen des Fensters (kein Konto, Adresse, die Javas Muster nicht nimmt, kein Empfänger); Meldungen wie Javas Beobachter (Verbindung, versendet, getrennt, Ausnahmen); der SMTP-Client dient auch dem Server zum Weiterleiten. An Java-Filius gemessen (`EmailClientRun`). Abweichend von Java: der SMTP-Port des Kontos gilt (Java verbindet immer zu 25); drei SYN statt einem | | `src/runtime/apps/filePrograms.ts` | Java `FileExplorer`, `TextEditor`, `ImageViewer` und ihre Fenster, `config/filetypes.txt`, die Meldungen `sw_fileexplorer_msg1`–`3`, `fileexplorer_msg19` | neu nach Java (F3.5b): die Dateioperationen hinter den Knöpfen von Datei-Explorer (Ordnerliste, neuer Ordner, löschen, kopieren/ausschneiden/einfügen, umbenennen, importieren), Texteditor (speichern, speichern unter) und Bildbetrachter, auf den Dateien des Knotens; an Java-Filius gemessen (`FileCases`). Abweichend von Java: ein Ordnername, der zu keinem Namen wird, legt nichts an; Einfügen auf einen vorhandenen Namen wird abgelehnt (Java: zweiter Eintrag gleichen Namens); Umbenennen auf einen Namen mit `/` oder mit Leerzeichen am Rand wird abgelehnt, eine reine Änderung der Groß-/Kleinschreibung ausgeführt (Java lehnt sie ab); „Speichern unter“ und Import auf einen Ordnernamen werden abgelehnt (Java wirft); die Größe einer Textdatei sind ihre Zeichen | | `src/runtime/apps/gnutella.ts` | Java `filius.software.dateiaustausch` (`PeerToPeerAnwendung`, `PeerToPeerServer`, `PeerToPeerServerMitarbeiter`, `PeerToPeerClient`, `PeerToPeerClientMitarbeiter`, `PingPaket`, `PongPaket`) | neu nach Java (F3.6a): Beitreten mit Ping und Pong in Javas Textformat über Port 6346, Weiterleiten an die eigenen Teilnehmer, Pongs auf dem Weg des Pings zurück, Höchstzahl der Teilnehmer, Zurücksetzen, Ordner `/peer2peer`; an Java-Filius gemessen (`GnutellaRun`). Die Swift-Fassung (`TopologyRuntimeGnutella.swift`, eigenes Protokoll) nicht übernommen. Seit F3.6b Suche mit Query und Treffern (weitergeleitet wie Pings, Treffer auf dem Weg der Query zurück), Ergebnisse, Download per HTTP über 6346 (`QueryPaket`, `QueryHitPaket`, `httpAnfrageVerarbeiten`, `herunterladenDatei`). Abweichend von Java: ein zweites Mal ankommender Ping bekommt keinen Pong, und seine Verbindung bleibt (Java wirft und schließt sie); wer Treffer geschickt hat, hält die Verbindung, damit auch die Treffer der weiter entfernten Teilnehmer zurückkommen (Java schließt sie, die gehen verloren); Größen in Treffer und Pong in Zeichen (Java: Base64-dekodiert) | -| `src/runtime/apps/terminal.ts`, `src/runtime/apps/terminalTexts.ts` | Java `filius.software.lokal.Terminal`, die Eingabe aus `GUIApplicationTerminalWindow`, die Texte `sw_terminal_*` und `terminal_msg*` des Bündels | neu nach Java (F3.4a): die Befehlszeile mit Javas Zerlegung der Eingabe, Verteilung nach dem Befehlsnamen und den lokalen Befehlen `ls`/`dir`, `cd`, `pwd`, `mkdir`, `touch`, `cp`/`copy`, `mv`/`move`, `rm`/`del`, `cat`/`type`, `test`, `help`, `ipconfig`, `route`; Texte in der Sprache des Projekts, aus dem von Java geladenen Bündel erzeugt. An Java-Filius gemessen (`TerminalCases`). Abweichend von Java: die Größe einer Textdatei sind ihre Zeichen (Java dekodiert Base64); ein fehlender Ordner ist für `ls` „nicht vorhanden“, `ls /` nennt die Wurzel; `cd` auf eine Datei, `touch`/`mkdir` in fehlenden Ordnern und `cp` auf einen Ordnernamen scheitern; `..` bleibt an der Wurzel; `rm` und `mv` nehmen absolute Pfade absolut, die Wurzel ist nicht löschbar, nach dem Löschen des Arbeitsordners gilt der nächste verbliebene darüber; `mv` auf die Datei selbst benennt sie um. Seit F3.4b die Netzbefehle `ping` (Strg+C über `interrupt`), `host`, `nslookup`, `arp` und `netstat`, mit Javas Laufmarken; ein unbekannter Befehl wird wie bei Java sofort gemeldet, auch während ein anderer läuft; seit F3.4c `traceroute` und `tcpdump` (`arpsend` nicht gebaut, der Befehl ist unbekannt); an Java-Filius gemessen (`TerminalRun`). Abweichend von Java: `ping` nennt den Host, wo Java seinen ersten Parameter nennt (`-b` in der Statistik), und nimmt vor dem Host nur `-b` (Java nimmt jedes Wort und pingt einen Broadcast); `arp` und `netstat` listen in der Reihenfolge des Lernens bzw. Anlegens (Java: Reihenfolge einer `HashMap`); `netstat` zeigt von einem UDP-Server nur den lauschenden Socket (Java auch einen für jede je beantwortete Adresse, nie geschlossen); `traceroute` sagt sein Fazit vor der Endmarke (Java danach, wo sein Fenster keinen Text mehr zeigt) und nennt ein Ziel beim 20. Sprung erreicht (Java: nicht erreicht); `tcpdump` meldet jeden Frame sofort (Java schaut alle 100 ms) | +| `src/runtime/apps/terminal.ts`, `src/runtime/apps/terminalTexts.ts` | Java `filius.software.lokal.Terminal`, die Eingabe aus `GUIApplicationTerminalWindow`, die Texte `sw_terminal_*` und `terminal_msg*` des Bündels | neu nach Java (F3.4a): die Befehlszeile mit Javas Zerlegung der Eingabe, Verteilung nach dem Befehlsnamen und den lokalen Befehlen `ls`/`dir`, `cd`, `pwd`, `mkdir`, `touch`, `cp`/`copy`, `mv`/`move`, `rm`/`del`, `cat`/`type`, `test`, `help`, `ipconfig`, `route`; Texte in der Sprache des Projekts, aus dem von Java geladenen Bündel erzeugt. An Java-Filius gemessen (`TerminalCases`). Abweichend von Java: die Größe einer Textdatei sind ihre Zeichen (Java dekodiert Base64); ein fehlender Ordner ist für `ls` „nicht vorhanden“, `ls /` nennt die Wurzel; `cd` auf eine Datei, `touch`/`mkdir` in fehlenden Ordnern und `cp` auf einen Ordnernamen scheitern; `..` bleibt an der Wurzel; `rm` und `mv` nehmen absolute Pfade absolut, die Wurzel ist nicht löschbar, nach dem Löschen des Arbeitsordners gilt der nächste verbliebene darüber; `mv` auf die Datei selbst benennt sie um. Seit F3.4b die Netzbefehle `ping` (Strg+C über `interrupt`), `host`, `nslookup`, `arp` und `netstat`, mit Javas Laufmarken; ein unbekannter Befehl wird wie bei Java sofort gemeldet, auch während ein anderer läuft; seit F3.4c `traceroute` und `tcpdump` (`arpsend` nicht gebaut, der Befehl ist unbekannt); an Java-Filius gemessen (`TerminalRun`). Abweichend von Java: `ping` nennt den Host, wo Java seinen ersten Parameter nennt (`-b` in der Statistik), und nimmt vor dem Host nur `-b` (Java nimmt jedes Wort und pingt einen Broadcast); `arp` und `netstat` listen in der Reihenfolge des Lernens bzw. Anlegens (Java: Reihenfolge einer `HashMap`); `netstat` zeigt von einem UDP-Server nur den lauschenden Socket (Java auch einen für jede je beantwortete Adresse, nie geschlossen); `traceroute` sagt sein Fazit vor der Endmarke (Java danach, wo sein Fenster keinen Text mehr zeigt) und nennt ein Ziel beim 20. Sprung erreicht (Java: nicht erreicht); `tcpdump` meldet jeden Frame sofort (Java schaut alle 100 ms); seit F4.6b `continueIn` — Javas Arbeitsverzeichnis überdauert Stopp und Start (`TerminalWindowRun`), das Fenster des Raums reicht es an das Programm des nächsten Starts weiter | | `src/runtime/apps/echo.ts` | Java `filius.software.clientserver` (`ServerBaustein`, `ServerBausteinMitarbeiter`, `ClientAnwendung`, `ClientBaustein`) | neu nach Java statt nach Swift (F3.1): Echo-Server (auf `server.ts`, sendet jede Nachricht zurück) und einfacher Client (verbinden über Adresse oder Namen, senden, empfangen, trennen), mit Javas Fensterzeilen. An Java-Filius gemessen. Abweichend von Java: Der Server merkt das Ausschalten, der Client die Verbindung sofort (Java alle 100 ms); ohne Route scheitert der Client sofort (Java wartet ewig ohne Meldung); ausgeschaltet bestätigt der Server noch das FIN seiner Verbindungen (Java gibt den Port frei, das FIN bleibt unbeantwortet) | | `src/runtime/dns.ts` | Java `filius.software.dns.DNSNachricht`, `Query`, `ResourceRecord` | neu nach Java statt nach Swift (Swift ersetzt Javas Textnachrichten durch `FILIUS-DNS-TYPED/1`): Kopfzeile `ID=… QR=… RCODE=… QDCOUNT=… …`, Frage- und Eintragszeilen, Suche nach Namen ohne Rücksicht auf Groß- und Kleinschreibung, NS der längsten Zone, sonst der Wurzel | | `src/runtime/ipv4.ts` | Engine `parseIPv4`/`sameSubnet` | übertragen | @@ -142,6 +143,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/sim.ts`, `src/host.ts` | Plan §6.4 | neu (F4.2): die Nachrichten zwischen Editor und Simulationsraum in einer Liste (Client: sub, unsub, start, stop, speed, input; Raum: state, activity, trace, app, ack, error), Prüfung jedes Felds mit benanntem Fehler statt stillem Verwerfen; `SimHost` | | `test/sim.test.ts` | — | neu (F4.2): jede Art gelesen, jede Ablehnung mit ihrem Grund | | `src/sim.ts` (`node`) | Java `NetworkInfoPanel.updateInfo`, `GUIDesktopWindow.updateTitle` | erweitert (F4.6a): Thema `node:` und Nachricht `node` — die erste Karte eines Geräts, wie die Simulation sie fährt (DHCP ändert sie), für Titel und Netzwerk-Info des Desktops | +| `src/terminal.ts` | Java `GUIApplicationTerminalWindow` (gemessen mit `TerminalWindowRun`) | neu (F4.6b): die Zeilen eines Befehlszeilen-Fensters (Text, Eingabe nach ihrem Prompt, cls, Beschäftigt, Prompt, exit) und wie ein Client sie liest — Text ab dem letzten cls, Prompt, ob er sichtbar ist (verborgen, solange ein Befehl läuft), Verlauf; Javas Pfeiltasten (`commandHistoryPointer`) | ## packages/sim @@ -151,6 +153,8 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/channel.ts`, `src/serveRoom.ts`, `src/localSimHost.ts`, `src/worker.ts` | Plan §6.1 | neu (F4.2): der Raum in einem Web Worker der Seite — Dokument-Updates in beide Richtungen ohne Echo, Nachrichten geprüft | | `test/room.test.ts`, `test/localSimHost.test.ts` | — | neu (F4.2): der Raum gegen die von Hand gesteuerte Engine (dieselben Rahmen, dieselben Zeilen), Abonnieren, Geschwindigkeit, Nachholen, Fehler; Host und Worker-Seite über einen `MessageChannel` | | `src/room.ts` (`node`) | Plan §6.4 | erweitert (F4.6a): der Raum schickt einem Abonnenten die Netzwerkwerte eines Geräts beim Abonnieren und wenn sie sich ändern, nichts vor dem ersten Start | +| `src/room.ts` (Takt) | — | behoben (F4.6b): der Raum gibt der Engine ganze Millisekunden und trägt den Bruchteil der Wanduhr in den nächsten Takt; nach einem Sprung holt er höchstens zwei Sekunden nach, auch über die folgenden Takte nicht mehr | +| `src/terminalWindow.ts`, `src/room.ts` (Befehlszeile), `test/terminalWindow.test.ts`, `test/terminalWindow.java.json` | Java `GUIApplicationTerminalWindow`, `Terminal.setSystemSoftware` | neu (F4.6b): das Fenster der Befehlszeile im Raum, eines je Rechner für alle — Startbildschirm, jede Eingabe nach ihrem Prompt, leere Zeile, cls, exit, danach die Zeilen des Programms in ihrer Reihenfolge; es überdauert Stopp und Start mit Text, Verlauf und Arbeitsverzeichnis wie Javas. Gegen Javas Fenster Taste für Taste geprüft; benannt: was ein Lauf in den Dateien anlegt, steht nach dem Neustart noch nicht im Dokument (F4.6f), ein dort angelegter Ordner fehlt dann | | `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 @@ -175,8 +179,9 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/sheet/ConnectionsPanel.tsx`, `src/sheet/SheetField.tsx` (`SheetNote`), `src/theme/tokens.css` (`--connection-*`) | Java `JConnectionsDialog` (`jvermittlungsrechnerkonfiguration_msg19`, `22`–`25`) | neu (F4.5e): „Verbindungen verwalten“ als Seite des Vermittlungsrechners (unter „Allgemein“ nach „Firewall einrichten“, wie bei Java): je Karte Javas RJ45-Knopf links von „NIC n: Adresse“ in Javas Farben (grün belegt, rot frei, gelb markiert), darunter die Gegenstelle wie bei Java („A (10.0.0.2)“, ein Switch nur mit Namen); antippen markiert, dieselbe abwählen, eine andere tauscht; „+“ und „−“ mit Javas „Schnittstelle hinzufügen“ und „Maximal 8 Schnittstellen erlaubt!“; Javas Hinweis darunter wie im Dialog. Java zeichnet die Kabel zwischen zwei Spalten — im schmalen Blatt steht die Gegenstelle unter der Karte; der Reiter einer entfernten Karte fällt auf „Allgemein“ zurück | | `src/simulation/useSimulation.ts`, `src/simulation/ModeBar.tsx`, `src/assets/modes/*.png` | Java `GUIMainMenu.selectMode/updateLatency` (`guimainmemu_msg3`/`4`/`15`/`16`), Javas Modussymbole (`gfx/allgemein`) | neu (F4.6a): der Raum im Worker der Seite (`LocalSimHost` über `?worker`), Javas Moduswahl mit Javas Symbolen, im Aktionsmodus der Geschwindigkeitsregler mit Prozent (90 % zu Beginn wie Java); Javas Tasten Strg+D/Strg+R nicht, sie legen im Browser ein Lesezeichen an und laden neu | | `src/desktop/DesktopWindow.tsx`, `src/desktop/programs.ts`, `src/desktop/desktop.css`, `src/assets/desktop/*.png` | Java `GUIDesktopWindow`, `GUIDesktopPanel`, `GUIDesktopIcon`, `NetworkInfoPanel`, `config/Desktop_de_DE.txt`/`_en_GB.txt` (`desktoppanel_*`, `network_msg*`) | neu (F4.6a): der Desktop eines Rechners — Titel „Name - Adresse“ (nur die Beschriftung, wo sie die Adresse ist), Javas Hintergrund, Software-Installation zuerst, dann die Programme in der Reihenfolge der Java-Liste der Sprache, graue Taskleiste mit „Startbildschirm“ und dem Netzwerksymbol (leuchtet, solange das Kabel trägt), Netzwerk-Info mit den laufenden Werten; auf breiten Schirmen verschiebbar über der Fläche, mehrere gestapelt, auf dem Handy flächig unter den Modusknöpfen. Javas Taskleiste hat 30 px, hier 44 für Finger; Javas Knopf „Ändern“ unter schreibgeschützten Feldern fehlt. Die Programmfenster folgen ab F4.6b | +| `src/desktop/TerminalPanel.tsx`, `src/desktop/ProgramFrame.tsx` | Java `GUIApplicationTerminalWindow`, `GUIApplicationWindow` | neu (F4.6b): ein Programmfenster über dem Desktop mit Javas dunkelgrauer Titelleiste (kein Schließknopf, „Startbildschirm“ führt zurück; das offene Programm überdauert den Entwurf wie bei Java) und die Befehlszeile — grau auf schwarz, Prompt vor der Eingabe (verborgen, solange ein Befehl läuft), Enter, Pfeiltasten, Strg+C (mit markiertem Text kopiert es); für Finger Befehle als Chips, Pfeile und Strg+C als Knöpfe; `exit` schließt das Fenster für alle, die es offen haben | | `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` | +| `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 | | `src/sheet/ServicePanels.tsx`, `src/sheet/RowsEditor.tsx`, `src/sheet/SheetTabs.tsx`, `src/sheet/DeviceSheet.tsx` (Seiten) | Java `JDHCPKonfiguration` (`jdhcpkonfiguration_msg1`–`15`), `JPortForwardingDialog` (`jportforwarding_msg4`–`7`, `jgatewayconfiguration_msg19`) | neu (F4.5c): Javas Dialoge als Seiten des Blatts mit „Zurück“ — DHCP-Server (Grundeinstellungen, statische Adresszuweisung; beim Rechner, gesperrt während DHCP die Karte einrichtet, und auf dem LAN-Reiter des Heimrouters), Portfreigaben (Heimrouter); jedes Feld beim Verlassen übernommen statt mit Javas OK-Knopf. Die Zeilentabellen (Weiterleitung, Zuordnungen, Portfreigaben) teilen sich `RowsEditor`: eine neue Zeile, sobald alle Felder gültig sind, Zeile wählen, bearbeiten, löschen. Javas Tooltip zu „Manuelle Einstellungen“ als Text unter dem Kästchen (ein Finger hat kein Überfahren) | | `src/sheet/RouterSheets.tsx`, `src/sheet/NameField.tsx`, `src/canvas/Canvas.tsx` (`highlightedLink`), `src/theme/tokens.css` (`--canvas-cable-active`) | Java `JVermittlungsrechnerKonfiguration`, `JGatewayConfiguration`, `JWeiterleitungsTabelle` (Wörter `jvermittlungsrechnerkonfiguration_msg6`–`26`, `jweiterleitungstabelle_msg3`–`6`), `JCablePanel.farbeBlinken`, `Kabel.setAktiv` | neu (F4.5b): Blatt des Vermittlungsrechners — Reiter Allgemein (Name, Gateway, Automatisches Routing, IP-Weiterleitung nur angezeigt), je Karte (Reiter mit ihrer Adresse, „Verbunden mit …“, Adresse, Maske, MAC) und Weiterleitungstabelle (alle Einträge oder nur die manuellen, Javas Zeilen grau, eine neue Zeile, die mit vier gültigen Feldern übernommen wird, Zelle bearbeiten, Eintrag löschen; bei RIP gesperrt); Blatt des Heimrouters mit Allgemein, LAN, WAN (DHCP sperrt die WAN-Adresse). Das Kabel der offenen Karte leuchtet grün wie bei Java. Javas „Eintrag Löschen“ klein geschrieben | diff --git a/packages/core/src/runtime/apps/terminal.ts b/packages/core/src/runtime/apps/terminal.ts index 07dcf52..01aa295 100644 --- a/packages/core/src/runtime/apps/terminal.ts +++ b/packages/core/src/runtime/apps/terminal.ts @@ -199,6 +199,16 @@ export class Terminal { return this.directoryValue; } + /** + * Goes on in the working directory of a run before, where it is still a + * folder: Java's command line keeps it over a stop and a start + * (TerminalWindowRun), and the room's window holds it meanwhile. + */ + continueIn(directory: string): void { + const found = this.content(directory); + if (found !== undefined && found.content.kind === 'directory') this.directoryValue = found.path; + } + /** The window's prompt. */ get prompt(): string { return `${this.directoryValue}> `; diff --git a/packages/core/src/runtime/engine.ts b/packages/core/src/runtime/engine.ts index 3a6267f..fba974f 100644 --- a/packages/core/src/runtime/engine.ts +++ b/packages/core/src/runtime/engine.ts @@ -1031,7 +1031,7 @@ export class NetworkRuntimeEngine { this.stateValue = initialState(seed); this.packetCapture.beginRun(); this.stateValue.phase = 'running'; - this.stateValue.currentTimeMilliseconds = input.initialTimeMilliseconds; + this.stateValue.currentTimeMilliseconds = Math.floor(input.initialTimeMilliseconds); this.stateValue.simulationSpeedPercent = this.runtimeSpeed.percent; this.stateValue.topologySnapshot = input.snapshot; this.random = new JavaCompatibleRandom(seed); @@ -1097,7 +1097,8 @@ export class NetworkRuntimeEngine { } case 'advance': - return this.advance(input.toMilliseconds); + // Whole milliseconds, as Java's clock counts (System.currentTimeMillis): a wall clock's fraction waits for the next step. + return this.advance(Math.floor(input.toMilliseconds)); case 'schedule': { if (this.stateValue.phase !== 'running') return [this.output({ type: 'advanceIgnoredWhileStopped' })]; diff --git a/packages/core/test/engine.test.ts b/packages/core/test/engine.test.ts index 6233b9f..fb1f26a 100644 --- a/packages/core/test/engine.test.ts +++ b/packages/core/test/engine.test.ts @@ -141,6 +141,17 @@ describe('event queue and virtual clock', () => { expect(engine.state.pendingEvents).toEqual([]); }); + it("counts whole milliseconds, as Java's clock does: a time's fraction waits for the next step", () => { + const engine = new NetworkRuntimeEngine(9); + engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 9, initialTimeMilliseconds: 10.6 }); + expect(engine.state.currentTimeMilliseconds).toBe(10); + engine.handle({ type: 'schedule', deadlineMilliseconds: 100, kind: marker('at-100') }); + expect(firedKinds(engine.handle({ type: 'advance', toMilliseconds: 99.9 }))).toEqual([]); + expect(engine.state.currentTimeMilliseconds).toBe(99); + expect(firedKinds(engine.handle({ type: 'advance', toMilliseconds: 100.2 }))).toEqual([marker('at-100')]); + expect(engine.state.currentTimeMilliseconds).toBe(100); + }); + it('rejects a past time without changing the clock', () => { const engine = new NetworkRuntimeEngine(7); engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 7, initialTimeMilliseconds: 10 }); diff --git a/packages/editor/browser/simulation.spec.ts b/packages/editor/browser/simulation.spec.ts index 4e483a4..474cd5c 100644 --- a/packages/editor/browser/simulation.spec.ts +++ b/packages/editor/browser/simulation.spec.ts @@ -237,6 +237,121 @@ test.describe('on a phone', () => { const home = (await window.getByRole('button', { name: 'Startbildschirm' }).boundingBox())!; expect(home.height).toBeGreaterThanOrEqual(44); }); + + test("offers the command line's keys as buttons for a finger, and types without zooming", async ({ page }) => { + await openEditor(page, server.base, 'internetwork_komplett.fls'); + await simulationButton(page).tap(); + await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation'); + await page.getByRole('button', { name: 'Alles zeigen' }).tap(); + const id = await deviceNamed(page, 'Notebook 1.10'); + const at = await onDevice(page, id); + await page.touchscreen.tap(at.x, at.y); + await desktop(page, id).locator('[data-program="commandPrompt"]').tap(); + const window = desktop(page, id).locator('[data-program-window="commandPrompt"]'); + for (const name of ['ipconfig', 'Vorheriger Befehl', 'Befehl abbrechen (Strg+C)']) { + const box = (await window.getByRole('button', { name, exact: true }).boundingBox())!; + expect(box.height, name).toBeGreaterThanOrEqual(44); + } + await expect(window.locator('input[name="terminalInput"]')).toHaveCSS('font-size', '16px'); + await window.getByRole('button', { name: 'ipconfig', exact: true }).tap(); + await window.locator('input[name="terminalInput"]').press('Enter'); + await expect(window.locator('.terminal-text')).toContainText('IP Adresse . . . : 141.99.1.10'); + }); +}); + +/** Opens a computer's command line on its desktop. */ +async function commandLine(page: Page, name: string) { + const id = await deviceNamed(page, name); + await tap(page, id); + await desktop(page, id).locator('[data-program="commandPrompt"]').click(); + const window = desktop(page, id).locator('[data-program-window="commandPrompt"]'); + await expect(window.getByRole('heading')).toHaveText('Befehlszeile'); + return { id, window, input: window.locator('input[name="terminalInput"]'), text: window.locator('.terminal-text'), prompt: window.locator('.terminal-prompt') }; +} + +test("types into a computer's command line as into Java's: the start screen, a line after its prompt, the history, a ping across the routers", async ({ page }) => { + test.setTimeout(60_000); + await openEditor(page, server.base, 'internetwork_komplett.fls'); + await start(page); + const { window, input, text, prompt } = await commandLine(page, 'Notebook 1.10'); + await expect(text).toContainText("Verwende den Befehl 'help'"); + await expect(prompt).toHaveText('/>'); + await expect(input).toBeFocused(); + await input.fill('ipconfig'); + await input.press('Enter'); + await expect(text).toContainText('\n/> ipconfig\nIP Adresse . . . : 141.99.1.10'); + await expect(input).toHaveValue(''); + await input.press('ArrowUp'); + await expect(input).toHaveValue('ipconfig'); + await input.press('ArrowDown'); + await expect(input).toHaveValue(''); + // A chip puts its command into the line. + await window.getByRole('button', { name: 'ping', exact: true }).click(); + await expect(input).toHaveValue('ping '); + await input.pressSequentially('141.99.5.10'); + await input.press('Enter'); + // While the ping runs, Java hides the prompt. + await expect(prompt).toHaveCSS('visibility', 'hidden'); + await expect(text).toContainText('4 Paket(e) gesendet, 4 Paket(e) empfangen', { timeout: 20_000 }); + await expect(prompt).toHaveCSS('visibility', 'visible'); + await window.getByRole('button', { name: 'Vorheriger Befehl' }).click(); + await expect(input).toHaveValue('ping 141.99.5.10'); +}); + +test('ends a command with Ctrl+C, from the keyboard and from its button', async ({ page }) => { + test.setTimeout(60_000); + await openEditor(page, server.base, 'internetwork_komplett.fls'); + await start(page); + const { window, input, text, prompt } = await commandLine(page, 'Notebook 1.10'); + await input.fill('ping 141.99.9.9'); + await input.press('Enter'); + await expect(prompt).toHaveCSS('visibility', 'hidden'); + await input.press('Control+c'); + await expect(prompt).toHaveCSS('visibility', 'visible', { timeout: 20_000 }); + await expect(text).toContainText('Paketstatistik'); + await input.fill('ping 141.99.9.9'); + await input.press('Enter'); + await expect(prompt).toHaveCSS('visibility', 'hidden'); + await window.getByRole('button', { name: 'Befehl abbrechen (Strg+C)' }).click(); + await expect(prompt).toHaveCSS('visibility', 'visible', { timeout: 20_000 }); +}); + +test('keeps the command line over leaving it, over a return to the design, and exit closes it', async ({ page }) => { + await openEditor(page, server.base, 'internetwork_komplett.fls'); + await start(page); + const { id, window, input, text } = await commandLine(page, 'Notebook 1.10'); + await input.fill('pwd'); + await input.press('Enter'); + await expect(text).toContainText('\n/> pwd\n/'); + await desktop(page, id).getByRole('button', { name: 'Startbildschirm' }).click(); + await expect(window).toHaveCount(0); + await desktop(page, id).locator('[data-program="commandPrompt"]').click(); + await expect(text).toContainText('\n/> pwd\n/'); + await designButton(page).click(); + await start(page); + await expect(text).toContainText('\n/> pwd\n/'); + await input.fill('exit'); + await input.press('Enter'); + await expect(window).toHaveCount(0); + await expect(desktop(page, id).locator('.desktop-icon').first()).toBeVisible(); + await desktop(page, id).locator('[data-program="commandPrompt"]').click(); + await expect(text).toContainText('\n/> exit\n'); + // An exit that was there when the window opened does not close it again: it takes the next line. + await input.fill('ls'); + await input.press('Enter'); + await expect(text).toContainText('\n/> exit\n\n/> ls\n'); + await expect(window).toBeVisible(); +}); + +test('says so when the simulation fails, and goes back to the design', async ({ page }) => { + // FILIUS' modem example: the core has no modems yet (remote links); a ping to the other side fails it. + await openEditor(page, server.base, 'modem_beispiel.fls'); + await start(page); + const { input } = await commandLine(page, 'Notebook 0.10'); + await input.fill('ping 192.168.0.11'); + await input.press('Enter'); + await expect(page.getByRole('alert')).toHaveText('Die Simulation ist auf einen Fehler gestoßen und wurde beendet.', { timeout: 20_000 }); + await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'design'); }); test('speaks English when asked', async ({ page }) => { diff --git a/packages/editor/src/Editor.tsx b/packages/editor/src/Editor.tsx index e3cc7a1..d7ad53c 100644 --- a/packages/editor/src/Editor.tsx +++ b/packages/editor/src/Editor.tsx @@ -20,9 +20,10 @@ import { Palette, type PaletteKind } from './design/Palette.js'; import { RouterDialog } from './design/RouterDialog.js'; import { DeviceSheet } from './sheet/DeviceSheet.js'; import { DesktopWindow } from './desktop/DesktopWindow.js'; +import type { DesktopProgram } from './desktop/programs.js'; import { useTranslate } from './i18n/i18n.js'; import { ModeBar } from './simulation/ModeBar.js'; -import { useSimulation, type Simulation, type SimHostFactory } from './simulation/useSimulation.js'; +import { useSimulation, type SimHostFactory } from './simulation/useSimulation.js'; /** The document's drawing, read anew after every change. */ export function useTopologyView(doc: Y.Doc): TopologyView { @@ -79,6 +80,7 @@ interface OpenDesktop { readonly x: number; readonly y: number; readonly z: number; + readonly program: DesktopProgram | null; } /** 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. */ @@ -87,13 +89,6 @@ const toFront = (open: readonly OpenDesktop[], id: string): readonly OpenDesktop return open.some((each) => each.id === id && each.z === top) ? open : open.map((each) => (each.id === id ? { ...each, z: top + 1 } : each)); }; -/** A desktop that watches its computer's network while it is shown. */ -function WatchedDesktop({ simulation, ...props }: Omit[0], 'settings'> & { readonly simulation: Simulation }) { - const { watch } = simulation; - useEffect(() => watch([`node:${props.id}`]), [watch, props.id]); - return ; -} - export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly createSimHost?: SimHostFactory }) { const t = useTranslate(); const view = useTopologyView(doc); @@ -146,7 +141,7 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c setDesktops((open) => { if (open.some((each) => each.id === id)) return toFront(open, id); // Java stacks a desktop that does not fit beside the others a little further on. - return [...open, { id, x: 24 + 24 * open.length, y: 76 + 24 * open.length, z: Math.max(0, ...open.map((each) => each.z)) + 1 }]; + return [...open, { id, x: 24 + 24 * open.length, y: 76 + 24 * open.length, z: Math.max(0, ...open.map((each) => each.z)) + 1, program: null }]; }); }, []); @@ -305,7 +300,7 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c {simulating ? desktops.map((desktop) => ( - setDesktops((open) => open.map((each) => (each.id === desktop.id ? { ...each, program } : each)))} 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))} diff --git a/packages/editor/src/desktop/DesktopWindow.tsx b/packages/editor/src/desktop/DesktopWindow.tsx index 94f2a64..0a9ade8 100644 --- a/packages/editor/src/desktop/DesktopWindow.tsx +++ b/packages/editor/src/desktop/DesktopWindow.tsx @@ -9,26 +9,29 @@ // show; it is left out. // // On a wide screen the window lies over the surface and moves by its title -// bar, as Java's windows do; on a phone it fills the surface. The programs' -// windows follow (F4.6b on). +// bar, as Java's windows do; on a phone it fills the surface. A program's +// icon opens its window over the desktop (ProgramFrame), the home button +// leaves it; so far the command line has one (F4.6b), the others follow. -import { useEffect, useState, type PointerEvent as ReactPointerEvent } from 'react'; +import { useCallback, useEffect, useState, type PointerEvent as ReactPointerEvent } from 'react'; import type * as Y from 'yjs'; import { desktopView } from '@mdg-filius/doc'; -import type { NodeSettings } from '@mdg-filius/protocol'; import background from '../assets/desktop/hintergrundbild.png'; import networkOff from '../assets/desktop/netzwek_aus.png'; import networkOn from '../assets/desktop/netzwek_c.png'; import { ICON_URLS } from '../canvas/icons.js'; import { useLanguage, useTranslate } from '../i18n/i18n.js'; -import { desktopPrograms, INSTALLATION_ICON, JAVA_PROGRAMS } from './programs.js'; +import type { Simulation } from '../simulation/useSimulation.js'; +import { ProgramFrame } from './ProgramFrame.js'; +import { desktopPrograms, INSTALLATION_ICON, JAVA_PROGRAMS, type DesktopProgram } from './programs.js'; +import { TerminalPanel } from './TerminalPanel.js'; import './desktop.css'; export interface DesktopWindowProps { readonly doc: Y.Doc; readonly id: string; - /** The computer's network as the simulation runs it; undefined until the room has sent it. */ - readonly settings: NodeSettings | undefined; + /** The room: the computer's network as it runs, its programs' windows. */ + readonly simulation: Simulation; /** Whether its cable carried a frame at the last tick. */ readonly active: boolean; /** The window's top left corner on the surface. */ @@ -36,11 +39,17 @@ export interface DesktopWindowProps { readonly y: number; /** Its place in the stack of desktops. */ readonly z: number; + /** The program whose window is open on it, kept by the editor over a return to the design (Java's desktop keeps it). */ + readonly program: DesktopProgram | null; + readonly onProgram: (program: DesktopProgram | null) => void; readonly onMove: (x: number, y: number) => void; readonly onFront: () => void; readonly onClose: () => void; } +/** The programs whose windows are built so far. */ +const WITH_WINDOW: ReadonlySet = new Set(['commandPrompt']); + /** The document's desktop of a computer, read anew after every change (a program installed, the label changed). */ function useDesktopView(doc: Y.Doc, id: string) { const [view, setView] = useState(() => desktopView(doc, id)); @@ -53,13 +62,20 @@ function useDesktopView(doc: Y.Doc, id: string) { return view; } -export function DesktopWindow({ doc, id, settings, active, x, y, z, onMove, onFront, onClose }: DesktopWindowProps) { +export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: open, onProgram: setOpen, onMove, onFront, onClose }: DesktopWindowProps) { const t = useTranslate(); const language = useLanguage(); const view = useDesktopView(doc, id); + const { watch } = simulation; + useEffect(() => watch([`node:${id}`]), [watch, id]); + const settings = simulation.nodes.get(id); const [info, setInfo] = useState(false); + const home = useCallback(() => setOpen(null), [setOpen]); const [drag, setDrag] = useState<{ readonly pointer: number; readonly dx: number; readonly dy: number; moved: boolean } | null>(null); if (view === undefined) return null; + const programs = desktopPrograms(view.programs, language); + // A program removed while its window was open: the desktop shows. + const shown = open !== null && programs.includes(open) ? open : null; const address = settings?.ipAddress ?? ''; const title = view.addressAsName ? view.label : `${view.label} - ${address}`; @@ -98,21 +114,33 @@ export function DesktopWindow({ doc, id, settings, active, x, y, z, onMove, onFr
  • - {/* The programs' windows come with the next steps. */} + {/* The installation's window comes with F4.6c. */}
  • - {desktopPrograms(view.programs, language).map((program) => ( + {programs.map((program) => (
  • -
  • ))}
+ {shown === null ? null : ( + + {shown === 'commandPrompt' ? : null} + + )} {info ? ( // A press beside the panel closes it, as in Java.
event.target === event.currentTarget && setInfo(false)}> @@ -129,7 +157,14 @@ export function DesktopWindow({ doc, id, settings, active, x, y, z, onMove, onFr
- + ))} + + + +
+ + ); +} diff --git a/packages/editor/src/desktop/desktop.css b/packages/editor/src/desktop/desktop.css index 0f63ac6..41b834f 100644 --- a/packages/editor/src/desktop/desktop.css +++ b/packages/editor/src/desktop/desktop.css @@ -183,3 +183,115 @@ width: 18px; height: 18px; } + +/* A program's window over the desktop (GUIApplicationWindow): Java's dark + * grey title bar of 30 pixels with the icon at 16. */ +.program-window { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + background: #ffffff; +} + +.program-titlebar { + display: grid; + grid-template-columns: 30px 1fr 30px; + align-items: center; + height: 30px; + flex: none; + background: rgb(64 64 64); +} + +.program-titlebar img { + justify-self: center; + width: 16px; + height: 16px; +} + +.program-titlebar h3 { + margin: 0; + text-align: center; + font: 13px Dialog, sans-serif; + color: rgb(225 225 225); +} + +.program-content { + position: relative; + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +/* The command line (GUIApplicationTerminalWindow): grey on black, the input + * line right after the text in the same scrolling area. 16 pixels in the + * input: Safari on the iPad zooms into smaller fields. */ +.terminal { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + background: #000000; +} + +.terminal-screen { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 5px 5px 5px; + color: rgb(222 222 222); + font: 14px ui-monospace, Menlo, Consolas, 'Courier New', monospace; + cursor: text; +} + +.terminal-text { + margin: 0; + font: inherit; + white-space: pre-wrap; + overflow-wrap: anywhere; + user-select: text; +} + +.terminal-input { + display: flex; + align-items: baseline; +} + +.terminal-prompt { + white-space: pre; +} + +.terminal-input input { + flex: 1; + min-width: 0; + padding: 0; + border: none; + outline: none; + background: transparent; + color: rgb(222 222 222); + caret-color: rgb(222 222 222); + font: 16px ui-monospace, Menlo, Consolas, 'Courier New', monospace; +} + +.terminal-keys { + display: flex; + gap: 6px; + flex: none; + overflow-x: auto; + padding: 6px; + background: rgb(32 32 32); +} + +.terminal-chip { + flex: none; + min-width: 44px; + min-height: 44px; + padding: 0 12px; + border: 1px solid rgb(90 90 90); + border-radius: 8px; + background: rgb(48 48 48); + color: rgb(222 222 222); + font: 14px ui-monospace, Menlo, Consolas, monospace; + cursor: pointer; +} diff --git a/packages/editor/src/i18n/de.json b/packages/editor/src/i18n/de.json index 10086bb..30f72a3 100644 --- a/packages/editor/src/i18n/de.json +++ b/packages/editor/src/i18n/de.json @@ -154,6 +154,12 @@ "sheet.title": "Einstellungen", "sheet.wireless": "Drahtlos (WLAN)", "sim.failed": "Die Simulation ist auf einen Fehler gestoßen und wurde beendet.", + "terminal.commands": "Befehle", + "terminal.ctrlC": "Strg+C", + "terminal.input": "Befehl eingeben", + "terminal.interrupt": "Befehl abbrechen (Strg+C)", + "terminal.next": "Nächster Befehl", + "terminal.previous": "Vorheriger Befehl", "zoom.fit": "Alles zeigen", "zoom.in": "Vergrößern", "zoom.level": "{percent} %", diff --git a/packages/editor/src/i18n/en.json b/packages/editor/src/i18n/en.json index b07fb24..1a7102d 100644 --- a/packages/editor/src/i18n/en.json +++ b/packages/editor/src/i18n/en.json @@ -154,6 +154,12 @@ "sheet.title": "Settings", "sheet.wireless": "Wireless (Wi-Fi)", "sim.failed": "The simulation ran into an error and was stopped.", + "terminal.commands": "Commands", + "terminal.ctrlC": "Ctrl+C", + "terminal.input": "Enter a command", + "terminal.interrupt": "Stop the command (Ctrl+C)", + "terminal.next": "Next command", + "terminal.previous": "Previous command", "zoom.fit": "Show all", "zoom.in": "Zoom in", "zoom.level": "{percent} %", diff --git a/packages/editor/src/simulation/useSimulation.ts b/packages/editor/src/simulation/useSimulation.ts index daa2375..baec6df 100644 --- a/packages/editor/src/simulation/useSimulation.ts +++ b/packages/editor/src/simulation/useSimulation.ts @@ -1,11 +1,12 @@ // The editor's side of the simulation room (plan §6): its mode and speed, -// the cables that carry frames, a computer's network as it runs. The room +// the cables that carry frames, a computer's network as it runs, the lines +// of the programs' windows and the actions taken in them. The room // runs behind a SimHost — in a worker of this page (LocalSimHost) until F // holds it (RemoteSimHost, F5); the editor keeps no simulation of its own. import { useCallback, useEffect, useRef, useState } from 'react'; import type * as Y from 'yjs'; -import type { NodeSettings, SimHost, SimMode, Topic } from '@mdg-filius/protocol'; +import type { AppKind, NodeSettings, SimHost, SimMode, Topic } from '@mdg-filius/protocol'; import { LocalSimHost } from '@mdg-filius/sim'; import SimWorker from '@mdg-filius/sim/worker?worker'; @@ -18,6 +19,12 @@ export const localSimHost: SimHostFactory = (doc) => { return new LocalSimHost(doc, worker, () => worker.terminate()); }; +/** A program window's lines as the room sent them; those before `live` came with the subscription, the rest as they happened. */ +export interface AppLines { + readonly lines: readonly unknown[]; + readonly live: number; +} + export interface Simulation { readonly mode: SimMode; /** Java's slider, 1 to 100 per cent. */ @@ -26,6 +33,8 @@ export interface Simulation { readonly activeLinks: ReadonlySet; /** Each watched computer's network as the simulation runs it. */ readonly nodes: ReadonlyMap; + /** Each watched program window's lines, by its topic. */ + readonly apps: ReadonlyMap; /** Whether the core failed and the room went back to design mode, since the last start. */ readonly failed: boolean; start(): void; @@ -33,6 +42,8 @@ export interface Simulation { setSpeed(value: number): void; /** Watches these topics until the returned function is called; a topic two parts watch is sent for once. */ watch(topics: readonly Topic[]): () => void; + /** An action in a program's window (the room answers with its new lines). */ + act(node: string, app: AppKind, action: string, args?: Readonly>): void; } const NO_LINKS: ReadonlySet = new Set(); @@ -45,6 +56,7 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH const [activeLinks, setActiveLinks] = useState>(NO_LINKS); const [nodes, setNodes] = useState>(new Map()); const [failed, setFailed] = useState(false); + const [apps, setApps] = useState>(new Map()); useEffect(() => { const made = createHost(doc); @@ -62,6 +74,15 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH case 'node': setNodes((before) => new Map(before).set(message.node, message.settings)); return; + case 'app': + setApps((before) => { + const topic = `app:${message.node}/${message.app}`; + const known = before.get(topic)?.lines ?? []; + // From 0 the room sends a window anew (a subscription): all of it is what was before. + const lines = message.from === 0 ? message.lines : [...known.slice(0, message.from), ...message.lines]; + return new Map(before).set(topic, { lines, live: message.from === 0 ? lines.length : (before.get(topic)?.live ?? 0) }); + }); + return; case 'error': if (message.code === 'engineFailed') setFailed(true); return; @@ -101,5 +122,9 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH }; }, []); - return { mode, speed, activeLinks, nodes, failed, start, stop, setSpeed, watch }; + const act = useCallback((node: string, app: AppKind, action: string, args: Readonly> = {}) => { + host.current?.send({ v: 1, kind: 'input', node, app, action, args, reqId: crypto.randomUUID() }); + }, []); + + return { mode, speed, activeLinks, nodes, apps, failed, start, stop, setSpeed, watch, act }; } diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index d65ffc4..0309374 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -1,3 +1,4 @@ // Public surface of the wire protocol. export * from './host.js'; export * from './sim.js'; +export * from './terminal.js'; diff --git a/packages/protocol/src/terminal.ts b/packages/protocol/src/terminal.ts new file mode 100644 index 0000000..c9f6169 --- /dev/null +++ b/packages/protocol/src/terminal.ts @@ -0,0 +1,82 @@ +// A command line's window as the room keeps it and every client shows it +// (plan §7.3): its lines, in order, and how a client reads them, after +// GUIApplicationTerminalWindow (measured with tools/java-oracle's +// TerminalWindowRun). The window, not the program, puts the start screen +// up, writes each entered line after its prompt, takes a blank line, cls, +// reset and exit itself, hides the prompt while a command runs and keeps the +// lines entered for the arrow keys. + +/** A line of a command line's window. */ +export type TerminalLine = + /** What the program or the window wrote: the start screen, a command's output, a blank line's " \n". */ + | { readonly kind: 'text'; readonly text: string } + /** A line entered after its prompt; `command` when the program runs it (not cls, reset or exit). */ + | { readonly kind: 'input'; readonly prompt: string; readonly line: string; readonly command: boolean } + /** cls or reset: the window starts again (the start screen follows). */ + | { readonly kind: 'clear' } + /** A command that runs on (ping …) began or ended (Java's Boolean to the window). */ + | { readonly kind: 'busy'; readonly busy: boolean } + /** The prompt after a command, when it changed (the working directory). */ + | { readonly kind: 'prompt'; readonly prompt: string } + /** exit: the window closes; its text stays. */ + | { readonly kind: 'exit' }; + +export interface TerminalScreen { + /** What the window shows, from its last cls on. */ + readonly text: string; + readonly prompt: string; + /** Java hides the prompt from a command's start until its end. */ + readonly promptShown: boolean; + /** Every line entered, the oldest first (blank lines not), for the arrow keys. */ + readonly history: readonly string[]; +} + +/** The window as Java shows it after these lines. */ +export function terminalScreen(lines: readonly TerminalLine[]): TerminalScreen { + let text = ''; + let prompt = ''; + let promptShown = true; + let busy = false; + const history: string[] = []; + for (const line of lines) { + switch (line.kind) { + case 'text': + text += line.text; + if (!busy) promptShown = true; + break; + case 'input': + text += `\n${line.prompt}${line.line}\n`; + history.push(line.line); + if (line.command) promptShown = false; + break; + case 'clear': + text = ''; + break; + case 'busy': + busy = line.busy; + promptShown = !busy; + break; + case 'prompt': + prompt = line.prompt; + break; + case 'exit': + break; + } + } + return { text, prompt, promptShown, history }; +} + +/** + * The arrow keys in the history (Java's commandHistoryPointer): -1 is the + * empty line below the newest, 0 the newest, and so on; up goes back, down + * forward, neither beyond the ends. + */ +export function historyStep(pointer: number, step: 'up' | 'down', length: number): number { + const next = pointer + (step === 'up' ? 1 : -1); + return Math.max(-1, Math.min(length - 1, next)); +} + +/** The line the history shows at a pointer: empty below the newest. */ +export function historyLine(history: readonly string[], pointer: number): string { + return pointer < 0 ? '' : (history[history.length - 1 - pointer] ?? ''); +} diff --git a/packages/sim/src/room.ts b/packages/sim/src/room.ts index 4f67dc3..d8ec4ab 100644 --- a/packages/sim/src/room.ts +++ b/packages/sim/src/room.ts @@ -7,13 +7,16 @@ // Time: the engine's clock follows the wall clock, a millisecond for a // millisecond, as Java's threads do; the speed is Java's slider, the delay of // a cable (RuntimeSpeed), not a time lapse — unlike Swift's tick (plan §6.3). +// The engine counts whole milliseconds, as Java's clock does; the wall +// clock's fractions are carried to the next tick, so that no time is lost. // A tick catches up at most MAXIMUM_CATCH_UP_MILLISECONDS: after a tab slept, // the simulation goes on from where it was rather than running the missed // minutes in one go. // // The engine outlives a stop: the capture stays, as Java's Lauscher does, and // the next start goes on at the time it stopped, so that the capture's times -// keep rising (plan §6.2). +// keep rising (plan §6.2). So do the command lines' windows, as Java's do +// (TerminalWindow). import * as Y from 'yjs'; import { DEFAULT_SPEED_PERCENT, NetworkRuntimeEngine, captureRows, interfaceKey, stableGeneratedMacAddress, type CapturedFrame, type CaptureRow, type Id, type LogLine } from '@mdg-filius/core'; @@ -30,6 +33,7 @@ import { type Topic, type TraceFrame, } from '@mdg-filius/protocol'; +import { TerminalWindow } from './terminalWindow.js'; export const TICK_MILLISECONDS = 200; export const MAXIMUM_CATCH_UP_MILLISECONDS = 2_000; @@ -71,6 +75,8 @@ export class SimulationRoom { /** Per card, how many frames it held at the last tick. */ private framesSeen = new Map(); private lastActivity: string[] = []; + /** Each computer's command line window, kept over stops. */ + private readonly terminalWindows = new Map(); constructor( private readonly doc: Y.Doc, @@ -162,6 +168,13 @@ export class SimulationRoom { this.engine.setSimulationSpeed(this.speed); if (!this.guarded(() => this.engine.handle({ type: 'start', snapshot, seed: this.options.seed(), initialTimeMilliseconds: this.engine.state.currentTimeMilliseconds }))) return; this.linkOfPort = new Map(snapshot.links.flatMap((link) => [[link.sourcePortId, link.id] as const, [link.targetPortId, link.id] as const])); + for (const node of snapshot.nodes) { + const terminal = this.engine.terminal(node.id); + if (terminal === undefined) continue; + const window = this.terminalWindows.get(node.id); + if (window === undefined) this.terminalWindows.set(node.id, new TerminalWindow(terminal)); + else window.attach(terminal); + } this.mode = 'simulation'; this.lastWall = this.options.now(); this.broadcast(this.state()); @@ -181,8 +194,10 @@ export class SimulationRoom { tick(): void { if (this.mode !== 'simulation') return; const now = this.options.now(); - const elapsed = Math.min(MAXIMUM_CATCH_UP_MILLISECONDS, Math.max(0, now - (this.lastWall ?? now))); - this.lastWall = now; + const behind = Math.max(0, now - (this.lastWall ?? now)); + // Whole milliseconds, as the engine counts them; the wall clock's fraction is kept for the next tick. + const elapsed = Math.min(MAXIMUM_CATCH_UP_MILLISECONDS, Math.floor(behind)); + 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; this.flush([...this.clients.values()], false); } @@ -212,15 +227,16 @@ export class SimulationRoom { if (this.mode !== 'simulation') return 'notRunning'; if (!this.engine.state.topologySnapshot.nodes.some((node) => node.id === input.node)) return 'unknownNode'; if (input.app !== 'terminal') return 'unknownAction'; - const terminal = this.engine.terminal(input.node); - if (terminal === undefined) return 'unknownApp'; + // A window has a program while the simulation runs (checked above), from its first start on. + const window = this.terminalWindows.get(input.node); + if (window === undefined || this.engine.terminal(input.node) === undefined) return 'unknownApp'; switch (input.action) { case 'enter': if (typeof input.args.line !== 'string') return 'badArgs'; - terminal.enter(input.args.line); + window.enter(input.args.line); return undefined; case 'interrupt': - terminal.interrupt(); + window.interrupt(); return undefined; default: return 'unknownAction'; @@ -251,6 +267,7 @@ export class SimulationRoom { /** What the clients are owed: the cables' activity, new frames of the cards and new lines of the windows they watch. */ private flush(clients: readonly Client[], subscribing: boolean): void { if (!subscribing) this.updateActivity(); + for (const window of this.terminalWindows.values()) window.pull(); const rowsCache = new Map(); const traceFrames = (portId: Id, after: number): TraceFrame[] => { const frames = this.engine.capture.frames(portId).filter((frame) => frame.number > after); @@ -281,8 +298,17 @@ export class SimulationRoom { client.send(message({ kind: 'trace', portId, frames, reset })); } else { const [, node, app] = /^app:([^/]+)\/(.+)$/.exec(topic)!; - const program = this.program(node!, app as AppKind); const sent = client.appSent.get(topic); + if (app === 'terminal') { + // The window's lines, kept over stops: a client goes on from those it has. + const lines = this.terminalWindows.get(node!)?.lines ?? []; + const from = sent ?? 0; + if (sent !== undefined && lines.length === from) continue; + client.appSent.set(topic, lines.length); + client.send(message({ kind: 'app', node: node!, app: 'terminal', from, lines: lines.slice(from) })); + continue; + } + const program = this.program(node!, app as AppKind); if (program === undefined) { // A window of a program that is not running: it starts empty with the next start. if (sent !== undefined || subscribing) client.send(message({ kind: 'app', node: node!, app: app as AppKind, from: 0, lines: [] })); diff --git a/packages/sim/src/terminalWindow.ts b/packages/sim/src/terminalWindow.ts new file mode 100644 index 0000000..9bb303a --- /dev/null +++ b/packages/sim/src/terminalWindow.ts @@ -0,0 +1,89 @@ +// A computer's command line window in the room (plan §7.3): one for everyone +// who opens it, as Java has one per computer. It writes the program's lines +// into the window's lines after the window's own (the start screen, each +// entered line after its prompt, a blank line, cls, exit), in the order they +// happened. Like Java's window it outlives a stop and a start: its text, its +// history and the working directory stay (TerminalWindowRun); each start +// brings a new program, which goes on where the last one was. + +import type { Terminal } from '@mdg-filius/core'; +import type { TerminalLine } from '@mdg-filius/protocol'; + +/** StringUtils.isBlank: nothing but whitespace. */ +const blank = (line: string) => line.trim() === ''; + +export class TerminalWindow { + private readonly linesValue: TerminalLine[] = []; + private terminal!: Terminal; + /** How many of the program's lines are in the window. */ + private seen = 0; + private busy = false; + private prompt: string | undefined; + private directory = '/'; + + /** A window on the program of the first start. */ + constructor(terminal: Terminal) { + this.attach(terminal); + } + + get lines(): readonly TerminalLine[] { + return this.linesValue; + } + + /** The program of a start: the first time the window shows its start screen, later it goes on in the working directory it had. */ + attach(terminal: Terminal): void { + this.terminal = terminal; + this.seen = 0; + terminal.continueIn(this.directory); + if (this.linesValue.length === 0) this.linesValue.push({ kind: 'text', text: terminal.startScreen }); + if (this.busy) { + // A command cut off by the stop has ended. + this.busy = false; + this.linesValue.push({ kind: 'busy', busy: false }); + } + this.syncPrompt(); + } + + /** Takes the program's new lines into the window. */ + pull(): void { + const terminal = this.terminal; + for (const entry of terminal.log.all.slice(this.seen)) { + if (entry.kind === 'text') this.linesValue.push({ kind: 'text', text: entry.text }); + else { + this.busy = entry.busy; + this.linesValue.push({ kind: 'busy', busy: entry.busy }); + } + } + this.seen = terminal.log.all.length; + this.directory = terminal.directory; + if (!this.busy) this.syncPrompt(); + } + + /** A line entered in the window. */ + enter(line: string): void { + const terminal = this.terminal; + this.pull(); + if (blank(line)) { + this.linesValue.push({ kind: 'text', text: ' \n' }); + return; + } + const prompt = this.prompt ?? terminal.prompt; + const action = terminal.enter(line); + this.linesValue.push({ kind: 'input', prompt, line, command: action === undefined }); + if (action === 'exit') this.linesValue.push({ kind: 'exit' }); + if (action === 'clear') this.linesValue.push({ kind: 'clear' }, { kind: 'text', text: terminal.startScreen }); + this.pull(); + } + + /** Ctrl+C in the window. */ + interrupt(): void { + this.terminal.interrupt(); + } + + private syncPrompt(): void { + const prompt = this.terminal.prompt; + if (prompt === this.prompt) return; + this.prompt = prompt; + this.linesValue.push({ kind: 'prompt', prompt }); + } +} diff --git a/packages/sim/test/room.test.ts b/packages/sim/test/room.test.ts index 7ac5fe8..046f136 100644 --- a/packages/sim/test/room.test.ts +++ b/packages/sim/test/room.test.ts @@ -107,8 +107,11 @@ describe('a simulation room', () => { expect(each.from).toBe(each.from === 0 ? 0 : lines.length); lines = [...lines.slice(0, each.from), ...each.lines]; } - expect(lines).toEqual(engine.terminal(notebook.id)!.log.all); - expect(lines.length).toBeGreaterThan(4); + // The window: its start screen, the line entered after its prompt, then what the program wrote, as the engine has it. + const program = engine.terminal(notebook.id)!.log.all.map(({ timeMilliseconds: _time, ...entry }) => entry); + expect(lines.slice(0, 3)).toEqual([{ kind: 'text', text: engine.terminal(notebook.id)!.startScreen }, { kind: 'prompt', prompt: '/> ' }, { kind: 'input', prompt: '/> ', line: 'ping 141.99.5.10', command: true }]); + expect(lines.slice(3).filter((line) => (line as { kind: string }).kind !== 'prompt')).toEqual(program); + expect(program.length).toBeGreaterThan(4); }); it('sends the cables that carry frames when they change, and only to who asked', () => { @@ -160,7 +163,7 @@ describe('a simulation room', () => { expect(reset.frames.length).toBeGreaterThan(8); }); - it('empties a window when its program starts anew', () => { + it("keeps a command line's window over a stop and a start, as Java does", () => { const { got, send, node, run } = setUp(); const notebook = node('Notebook 1.10'); send({ kind: 'sub', topics: [`app:${notebook.id}/terminal`] }); @@ -168,11 +171,17 @@ describe('a simulation room', () => { send({ kind: 'start' }); send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ipconfig' }, reqId: 'r1' }); run(400); - expect(ofKind(got, 'app').at(-1)!.lines.length).toBeGreaterThan(0); + const before = ofKind(got, 'app').reduce((count, each) => each.from + each.lines.length, 0); + expect(before).toBeGreaterThan(3); + const sentBefore = ofKind(got, 'app').length; send({ kind: 'stop' }); - expect(ofKind(got, 'app').at(-1)).toMatchObject({ from: 0, lines: [] }); send({ kind: 'start' }); - expect(ofKind(got, 'app').at(-1)).toMatchObject({ from: 0 }); + run(400); + // Nothing taken away: the window goes on after its lines. + expect(ofKind(got, 'app').slice(sentBefore).every((each) => each.from >= before)).toBe(true); + send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'pwd' }, reqId: 'r2' }); + run(400); + expect(ofKind(got, 'app').at(-1)!.from).toBeGreaterThanOrEqual(before); }); it('answers an action that cannot be done with its reason', () => { @@ -212,12 +221,14 @@ describe('a simulation room', () => { expect(roundTrip(90)).toBeGreaterThan(roundTrip(100)); }); - it('catches up at most two seconds after the clock jumped', () => { - const { got, send, jump } = setUp(); + it('catches up at most two seconds after the clock jumped, and goes on from there', () => { + const { got, send, jump, run } = setUp(); send({ kind: 'start' }); jump(60_000); + // The missed minute is not made up over the next ticks either. + run(TICK_MILLISECONDS); send({ kind: 'stop' }); - expect(ofKind(got, 'state').at(-1)!.t).toBe(MAXIMUM_CATCH_UP_MILLISECONDS); + expect(ofKind(got, 'state').at(-1)!.t).toBe(MAXIMUM_CATCH_UP_MILLISECONDS + TICK_MILLISECONDS); }); it('starts with the document as it is then', () => { @@ -329,5 +340,34 @@ describe('a simulation room', () => { send({ kind: 'sub', topics: [`node:${server.id}`] }); expect(ofKind(got.slice(before), 'node')).toHaveLength(1); }); + + it("hands the engine whole milliseconds of a wall clock with fractions, losing none, and a ping goes through", () => { + // performance.now() has fractions; the core counts whole milliseconds as Java does (a fraction once made the ping hang). + const doc = exampleDoc(); + let now = 0.718; + const room = new SimulationRoom(doc, { now: () => now, seed: () => SEED }); + const got: HostMessage[] = []; + const client = room.connect((message) => got.push(message)); + const notebook = readProject(doc).graph.nodes.find((each) => each.displayName === 'Notebook 1.10')!; + room.receive(client, { v: 1, kind: 'sub', topics: [`app:${notebook.id}/terminal`] }); + room.receive(client, { v: 1, kind: 'start' }); + for (let tick = 0; tick < 15; tick++) { + now += 200.4; + room.tick(); + } + room.receive(client, { v: 1, kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r' }); + for (let tick = 0; tick < 60; tick++) { + now += 200.4; + room.tick(); + } + room.receive(client, { v: 1, kind: 'speed', value: 90 }); + const t = ofKind(got, 'state').at(-1)!.t; + expect(Number.isInteger(t)).toBe(true); + // Less than a millisecond behind the wall clock since the start, never ahead of it. + expect(t).toBeLessThanOrEqual(now - 0.718); + expect(t).toBeGreaterThan(now - 0.718 - 1); + const text = ofKind(got, 'app').flatMap((each) => each.lines as { kind: string; text?: string }[]).map((line) => line.text ?? '').join(''); + expect(text).toContain('4 Paket(e) gesendet, 4 Paket(e) empfangen'); + }); }); diff --git a/packages/sim/test/terminalWindow.java.json b/packages/sim/test/terminalWindow.java.json new file mode 100644 index 0000000..9044159 --- /dev/null +++ b/packages/sim/test/terminalWindow.java.json @@ -0,0 +1,144 @@ +{ + "steps": [ + { + "step": "open", + "text": "\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n\n==========================================================================\nVerwende den Befehl 'help' um Liste der verfügbaren Befehle anzuzeigen\n oder nutze 'help ' für weitere Infos zum Befehl.\n==========================================================================\n", + "prompt": "/> ", + "promptShown": true, + "input": "" + }, + { + "step": "enter:", + "text": "\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n\n==========================================================================\nVerwende den Befehl 'help' um Liste der verfügbaren Befehle anzuzeigen\n oder nutze 'help ' für weitere Infos zum Befehl.\n==========================================================================\n \n", + "prompt": "/> ", + "promptShown": true, + "input": "" + }, + { + "step": "enter: ", + "text": "\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n\n==========================================================================\nVerwende den Befehl 'help' um Liste der verfügbaren Befehle anzuzeigen\n oder nutze 'help ' für weitere Infos zum Befehl.\n==========================================================================\n \n \n", + "prompt": "/> ", + "promptShown": true, + "input": "" + }, + { + "step": "enter:pwd", + "text": "\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n\n==========================================================================\nVerwende den Befehl 'help' um Liste der verfügbaren Befehle anzuzeigen\n oder nutze 'help ' für weitere Infos zum Befehl.\n==========================================================================\n \n \n\n/> pwd\n/", + "prompt": "/> ", + "promptShown": true, + "input": "" + }, + { + "step": "enter:mkdir dir", + "text": "\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n\n==========================================================================\nVerwende den Befehl 'help' um Liste der verfügbaren Befehle anzuzeigen\n oder nutze 'help ' für weitere Infos zum Befehl.\n==========================================================================\n \n \n\n/> pwd\n/\n/> mkdir dir\nOrdner erstellt\n", + "prompt": "/> ", + "promptShown": true, + "input": "" + }, + { + "step": "enter:cd dir", + "text": "\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n\n==========================================================================\nVerwende den Befehl 'help' um Liste der verfügbaren Befehle anzuzeigen\n oder nutze 'help ' für weitere Infos zum Befehl.\n==========================================================================\n \n \n\n/> pwd\n/\n/> mkdir dir\nOrdner erstellt\n\n/> cd dir\n", + "prompt": "/dir> ", + "promptShown": true, + "input": "" + }, + { + "step": "enter:pwd", + "text": "\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n\n==========================================================================\nVerwende den Befehl 'help' um Liste der verfügbaren Befehle anzuzeigen\n oder nutze 'help ' für weitere Infos zum Befehl.\n==========================================================================\n \n \n\n/> pwd\n/\n/> mkdir dir\nOrdner erstellt\n\n/> cd dir\n\n/dir> pwd\n/dir", + "prompt": "/dir> ", + "promptShown": true, + "input": "" + }, + { + "step": "enter:nosuchcommand", + "text": "\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n\n==========================================================================\nVerwende den Befehl 'help' um Liste der verfügbaren Befehle anzuzeigen\n oder nutze 'help ' für weitere Infos zum Befehl.\n==========================================================================\n \n \n\n/> pwd\n/\n/> mkdir dir\nOrdner erstellt\n\n/> cd dir\n\n/dir> pwd\n/dir\n/dir> nosuchcommand\nUnbekannter Befehl!\nMit 'help' erhalten Sie eine Liste bekannter Befehle.", + "prompt": "/dir> ", + "promptShown": true, + "input": "" + }, + { + "step": "up", + "text": "\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n\n==========================================================================\nVerwende den Befehl 'help' um Liste der verfügbaren Befehle anzuzeigen\n oder nutze 'help ' für weitere Infos zum Befehl.\n==========================================================================\n \n \n\n/> pwd\n/\n/> mkdir dir\nOrdner erstellt\n\n/> cd dir\n\n/dir> pwd\n/dir\n/dir> nosuchcommand\nUnbekannter Befehl!\nMit 'help' erhalten Sie eine Liste bekannter Befehle.", + "prompt": "/dir> ", + "promptShown": true, + "input": "nosuchcommand" + }, + { + "step": "up", + "text": "\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n\n==========================================================================\nVerwende den Befehl 'help' um Liste der verfügbaren Befehle anzuzeigen\n oder nutze 'help ' für weitere Infos zum Befehl.\n==========================================================================\n \n \n\n/> pwd\n/\n/> mkdir dir\nOrdner erstellt\n\n/> cd dir\n\n/dir> pwd\n/dir\n/dir> nosuchcommand\nUnbekannter Befehl!\nMit 'help' erhalten Sie eine Liste bekannter Befehle.", + "prompt": "/dir> ", + "promptShown": true, + "input": "pwd" + }, + { + "step": "up", + "text": "\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n\n==========================================================================\nVerwende den Befehl 'help' um Liste der verfügbaren Befehle anzuzeigen\n oder nutze 'help ' für weitere Infos zum Befehl.\n==========================================================================\n \n \n\n/> pwd\n/\n/> mkdir dir\nOrdner erstellt\n\n/> cd dir\n\n/dir> pwd\n/dir\n/dir> nosuchcommand\nUnbekannter Befehl!\nMit 'help' erhalten Sie eine Liste bekannter Befehle.", + "prompt": "/dir> ", + "promptShown": true, + "input": "cd dir" + }, + { + "step": "down", + "text": "\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n\n==========================================================================\nVerwende den Befehl 'help' um Liste der verfügbaren Befehle anzuzeigen\n oder nutze 'help ' für weitere Infos zum Befehl.\n==========================================================================\n \n \n\n/> pwd\n/\n/> mkdir dir\nOrdner erstellt\n\n/> cd dir\n\n/dir> pwd\n/dir\n/dir> nosuchcommand\nUnbekannter Befehl!\nMit 'help' erhalten Sie eine Liste bekannter Befehle.", + "prompt": "/dir> ", + "promptShown": true, + "input": "pwd" + }, + { + "step": "down", + "text": "\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n\n==========================================================================\nVerwende den Befehl 'help' um Liste der verfügbaren Befehle anzuzeigen\n oder nutze 'help ' für weitere Infos zum Befehl.\n==========================================================================\n \n \n\n/> pwd\n/\n/> mkdir dir\nOrdner erstellt\n\n/> cd dir\n\n/dir> pwd\n/dir\n/dir> nosuchcommand\nUnbekannter Befehl!\nMit 'help' erhalten Sie eine Liste bekannter Befehle.", + "prompt": "/dir> ", + "promptShown": true, + "input": "nosuchcommand" + }, + { + "step": "down", + "text": "\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n\n==========================================================================\nVerwende den Befehl 'help' um Liste der verfügbaren Befehle anzuzeigen\n oder nutze 'help ' für weitere Infos zum Befehl.\n==========================================================================\n \n \n\n/> pwd\n/\n/> mkdir dir\nOrdner erstellt\n\n/> cd dir\n\n/dir> pwd\n/dir\n/dir> nosuchcommand\nUnbekannter Befehl!\nMit 'help' erhalten Sie eine Liste bekannter Befehle.", + "prompt": "/dir> ", + "promptShown": true, + "input": "" + }, + { + "step": "enter:cls", + "text": "\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n\n==========================================================================\nVerwende den Befehl 'help' um Liste der verfügbaren Befehle anzuzeigen\n oder nutze 'help ' für weitere Infos zum Befehl.\n==========================================================================\n", + "prompt": "/dir> ", + "promptShown": true, + "input": "" + }, + { + "step": "enter:ls", + "text": "\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n\n==========================================================================\nVerwende den Befehl 'help' um Liste der verfügbaren Befehle anzuzeigen\n oder nutze 'help ' für weitere Infos zum Befehl.\n==========================================================================\n\n/dir> ls\nVerzeichnis ist leer\n", + "prompt": "/dir> ", + "promptShown": true, + "input": "" + }, + { + "step": "restart", + "text": "\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n\n==========================================================================\nVerwende den Befehl 'help' um Liste der verfügbaren Befehle anzuzeigen\n oder nutze 'help ' für weitere Infos zum Befehl.\n==========================================================================\n\n/dir> ls\nVerzeichnis ist leer\n", + "prompt": "/dir> ", + "promptShown": true, + "input": "" + }, + { + "step": "enter:pwd", + "text": "\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n\n==========================================================================\nVerwende den Befehl 'help' um Liste der verfügbaren Befehle anzuzeigen\n oder nutze 'help ' für weitere Infos zum Befehl.\n==========================================================================\n\n/dir> ls\nVerzeichnis ist leer\n\n/dir> pwd\n/dir", + "prompt": "/dir> ", + "promptShown": true, + "input": "" + }, + { + "step": "up", + "text": "\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n\n==========================================================================\nVerwende den Befehl 'help' um Liste der verfügbaren Befehle anzuzeigen\n oder nutze 'help ' für weitere Infos zum Befehl.\n==========================================================================\n\n/dir> ls\nVerzeichnis ist leer\n\n/dir> pwd\n/dir", + "prompt": "/dir> ", + "promptShown": true, + "input": "pwd" + }, + { + "step": "enter:exit", + "text": "\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n\n==========================================================================\nVerwende den Befehl 'help' um Liste der verfügbaren Befehle anzuzeigen\n oder nutze 'help ' für weitere Infos zum Befehl.\n==========================================================================\n\n/dir> ls\nVerzeichnis ist leer\n\n/dir> pwd\n/dir\n/dir> exit\n", + "prompt": "/dir> ", + "promptShown": true, + "input": "" + } + ] +} diff --git a/packages/sim/test/terminalWindow.test.ts b/packages/sim/test/terminalWindow.test.ts new file mode 100644 index 0000000..1fb1bdd --- /dev/null +++ b/packages/sim/test/terminalWindow.test.ts @@ -0,0 +1,216 @@ +// The room's command line window against Java's (tools/java-oracle +// 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 +// difference is named: what a run made in the files is not yet written back +// into the document (F4.6f), so after the restart the folder made before is +// gone and the window goes on at the root, where Java's goes on in it. + +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 { addDevice, emptyProject, ROOTS, writeProject } from '@mdg-filius/doc'; +import { historyLine, historyStep, terminalScreen, type HostMessage, type TerminalLine } from '@mdg-filius/protocol'; +import { SimulationRoom, TICK_MILLISECONDS } from '../src/index.js'; + +interface JavaStep { + readonly step: string; + readonly text: string; + readonly prompt: string; + readonly promptShown: boolean; + readonly input: string; +} + +const java = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'terminalWindow.java.json'), 'utf8')) as { readonly steps: readonly JavaStep[] }; + +/** A computer with a command line, in a room on a clock the test moves, and a client that keeps the window's lines. */ +function setUp(programs: readonly string[] = ['commandPrompt']) { + const doc = new Y.Doc(); + writeProject(doc, emptyProject()); + let counter = 0; + addDevice(doc, { kind: 'pc', id: 'pc', name: 'PC', x: 0, y: 0, newId: () => `port-${++counter}` }, 'me'); + const installed = doc.getMap>(ROOTS.nodes).get('pc')!.get('programs') as Y.Map>; + for (const program of programs) { + const entry = new Y.Map(); + entry.set('active', true); + installed.set(program, entry); + } + let now = 0; + const room = new SimulationRoom(doc, { now: () => now, seed: () => 7 }); + const got: HostMessage[] = []; + const client = room.connect((message) => got.push(message)); + let lines: TerminalLine[] = []; + const read = () => { + for (const message of got.splice(0)) { + if (message.kind !== 'app' || message.app !== 'terminal') continue; + expect(message.from).toBe(message.from === 0 ? 0 : lines.length); + lines = [...lines.slice(0, message.from), ...(message.lines as TerminalLine[])]; + } + return lines; + }; + const send = (fields: object) => room.receive(client, { v: 1, ...fields }); + const run = (milliseconds: number) => { + for (let elapsed = 0; elapsed < milliseconds; elapsed += TICK_MILLISECONDS) { + now += TICK_MILLISECONDS; + room.tick(); + } + }; + 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", () => { + it("shows what Java's window shows, key by key, over a stop and a start", () => { + const { send, run, read } = setUp(); + send({ kind: 'sub', topics: ['app:pc/terminal'] }); + send({ kind: 'start' }); + run(TICK_MILLISECONDS); + let pointer = -1; + let input = ''; + let restartedAt: number | undefined; + for (const step of java.steps) { + if (step.step.startsWith('enter:')) { + send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: step.step.slice('enter:'.length) }, reqId: step.step }); + run(400); + pointer = -1; + input = ''; + } else if (step.step === 'up' || step.step === 'down') { + const history = terminalScreen(read()).history; + pointer = historyStep(pointer, step.step, history.length); + input = historyLine(history, pointer); + } else if (step.step === 'restart') { + send({ kind: 'stop' }); + send({ kind: 'start' }); + run(400); + restartedAt = terminalScreen(read()).text.length; + } + const screen = terminalScreen(read()); + const expected = + 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({ + text: expected.text, + prompt: expected.prompt, + promptShown: expected.promptShown, + input: expected.input, + }); + } + expect(read().at(-1)).toEqual({ kind: 'exit' }); + }); + + it('keeps the working directory over a stop and a start, where it is still a folder', () => { + const { send, run, read, doc } = setUp(); + const files = doc.getMap>(ROOTS.nodes).get('pc')!.get('files') as Y.Map>; + const folder = new Y.Map(); + folder.set('order', files.size); + folder.set('kind', 'directory'); + files.set('/docs', folder); + send({ kind: 'sub', topics: ['app:pc/terminal'] }); + send({ kind: 'start' }); + run(TICK_MILLISECONDS); + // A folder the project has, so that the next start's file system has it too. + send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'cd /docs' }, reqId: 'a' }); + run(400); + expect(terminalScreen(read()).prompt).toBe('/docs> '); + send({ kind: 'stop' }); + send({ kind: 'start' }); + run(400); + expect(terminalScreen(read()).prompt).toBe('/docs> '); + // The folder gone from the project: the next start goes on at the root. + files.delete('/docs'); + send({ kind: 'stop' }); + send({ kind: 'start' }); + run(400); + expect(terminalScreen(read()).prompt).toBe('/> '); + }); + + it('hides the prompt while a command runs on, and Ctrl+C ends it', () => { + const { send, run, read } = setUp(); + send({ kind: 'sub', topics: ['app:pc/terminal'] }); + send({ kind: 'start' }); + run(TICK_MILLISECONDS); + send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'ping 192.168.0.99' }, reqId: 'a' }); + run(1_000); + expect(terminalScreen(read()).promptShown).toBe(false); + expect(read().some((line) => line.kind === 'busy' && line.busy)).toBe(true); + send({ kind: 'input', node: 'pc', app: 'terminal', action: 'interrupt', args: {}, reqId: 'b' }); + run(15_000); + expect(terminalScreen(read()).promptShown).toBe(true); + expect(read().filter((line) => line.kind === 'busy').at(-1)).toEqual({ kind: 'busy', busy: false }); + }); + + it('shows the prompt again after a stop that cut a command off', () => { + const { send, run, read } = setUp(); + send({ kind: 'sub', topics: ['app:pc/terminal'] }); + send({ kind: 'start' }); + run(TICK_MILLISECONDS); + send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'ping 192.168.0.99' }, reqId: 'a' }); + run(1_000); + expect(terminalScreen(read()).promptShown).toBe(false); + send({ kind: 'stop' }); + send({ kind: 'start' }); + run(TICK_MILLISECONDS); + expect(terminalScreen(read()).promptShown).toBe(true); + }); + + it('takes no line once its computer has no command line any more, and keeps what it showed', () => { + const { doc, send, run, read, got } = setUp(); + send({ kind: 'sub', topics: ['app:pc/terminal'] }); + send({ kind: 'start' }); + run(TICK_MILLISECONDS); + send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'pwd' }, reqId: 'a' }); + run(400); + const shown = terminalScreen(read()).text; + send({ kind: 'stop' }); + (doc.getMap>(ROOTS.nodes).get('pc')!.get('programs') as Y.Map).delete('commandPrompt'); + send({ kind: 'start' }); + run(TICK_MILLISECONDS); + send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'ls' }, reqId: 'b' }); + expect(got.filter((message) => message.kind === 'error')).toEqual([{ v: 1, kind: 'error', code: 'unknownApp', reqId: 'b' }]); + expect(terminalScreen(read()).text).toBe(shown); + }); + + it('is the same window for everyone who opens it, and a client that comes later gets all of it', () => { + const { room, send, run, read, connect } = setUp(); + send({ kind: 'sub', topics: ['app:pc/terminal'] }); + send({ kind: 'start' }); + run(TICK_MILLISECONDS); + send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'pwd' }, reqId: 'a' }); + run(400); + const later: HostMessage[] = []; + const other = connect((message) => later.push(message)); + room.receive(other, { v: 1, kind: 'sub', topics: ['app:pc/terminal'] }); + const first = read(); + expect(first.some((line) => line.kind === 'input' && line.line === 'pwd')).toBe(true); + expect(later.filter((message) => message.kind === 'app')).toEqual([{ v: 1, kind: 'app', node: 'pc', app: 'terminal', from: 0, lines: first }]); + // What the later client types, the first one sees. + room.receive(other, { v: 1, kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'ls' }, reqId: 'b' }); + run(400); + expect(read().some((line) => line.kind === 'input' && line.line === 'ls')).toBe(true); + }); + + it('has no window on a computer without a command line, and takes no line while the simulation stands', () => { + const { send, run, read, got } = setUp([]); + send({ kind: 'sub', topics: ['app:pc/terminal'] }); + send({ kind: 'start' }); + run(TICK_MILLISECONDS); + expect(read()).toEqual([]); + send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'pwd' }, reqId: 'a' }); + expect(got.filter((message) => message.kind === 'error')).toEqual([{ v: 1, kind: 'error', code: 'unknownApp', reqId: 'a' }]); + }); +}); + +describe("the window's history", () => { + it("steps as Java's arrow keys do, never beyond its ends", () => { + expect(historyStep(-1, 'up', 0)).toBe(-1); + expect(historyStep(-1, 'up', 2)).toBe(0); + expect(historyStep(1, 'up', 2)).toBe(1); + expect(historyStep(0, 'down', 2)).toBe(-1); + expect(historyStep(-1, 'down', 2)).toBe(-1); + expect([historyLine(['a', 'b'], -1), historyLine(['a', 'b'], 0), historyLine(['a', 'b'], 1)]).toEqual(['', 'b', 'a']); + }); +}); diff --git a/tools/java-oracle/README.md b/tools/java-oracle/README.md index 4a5139d..6507679 100644 --- a/tools/java-oracle/README.md +++ b/tools/java-oracle/README.md @@ -1239,3 +1239,29 @@ Gemessen (03.10.2026): Karte; ohne Karte behält der Router kein Gateway (`setStandardGateway` schreibt es in die Karten, `getStandardGateway` liest `null`). +### Das Fenster der Befehlszeile (F4.6b) + +`TerminalWindowRun` öffnet Javas `GUIApplicationTerminalWindow` auf einem +`GUIDesktopPanel` eines Rechners ohne Netz, gibt Tasten direkt an den +Tastenhörer des Eingabefelds (Enter, Pfeil hoch/runter), stoppt und startet +das System einmal (`beenden`, `starten`, wie ein Moduswechsel) und schreibt +nach jeder Taste Text, Prompt, ob er sichtbar ist, und die Eingabezeile +(`packages/sim/test/terminalWindow.java.json`). + +Gemessen (03.10.2026): + +* Eine leere Zeile, auch nur aus Leerzeichen (`StringUtils.isBlank`), gibt + „␣␣\n“; jede andere Zeile steht danach als „\n“ + Prompt + Zeile + „\n“ + im Fenster, auch `cls`, `reset` und `exit`, bevor das Fenster sie selbst + erledigt. +* `cls`/`reset` zeigen wieder den Startbildschirm; `exit` schließt das + Fenster, sein Text bleibt. +* Pfeil hoch geht durch alle eingegebenen Zeilen zurück (leere nicht), + runter vor, unter der neuesten steht eine leere Zeile; über die Enden + hinaus bleibt es stehen. +* Der Prompt folgt dem Arbeitsverzeichnis, nachgeführt, wenn ein Befehl + fertig ist (`cd dir` → „/dir> “). +* Nach Stopp und Start bleiben Text, Verlauf **und** Arbeitsverzeichnis: + Javas Terminal setzt den Ordner nur bei der Installation + (`setSystemSoftware`), und das Fenster ist dasselbe Objekt. + diff --git a/tools/java-oracle/run.sh b/tools/java-oracle/run.sh index 279eaa1..6b0ce15 100755 --- a/tools/java-oracle/run.sh +++ b/tools/java-oracle/run.sh @@ -21,7 +21,7 @@ if [ ! -f "$upstream/target/filius.jar" ]; then fi classpath="$upstream/target/filius.jar:$upstream/target/lib/*" mkdir -p "$here/.build" -javac -encoding UTF-8 -d "$here/.build" -cp "$classpath" "$here/src/FlsOracle.java" "$here/src/GetterDefaults.java" "$here/src/IpCheckCases.java" "$here/src/DnsHostsCases.java" "$here/src/EmailFileCases.java" "$here/src/BeansDump.java" "$here/src/Base64Cases.java" "$here/src/ScenarioNetwork.java" "$here/src/DhcpRun.java" "$here/src/TcpRun.java" "$here/src/DnsRun.java" "$here/src/ArpRun.java" "$here/src/RipRun.java" "$here/src/NatRun.java" "$here/src/WlanRun.java" "$here/src/ExampleRun.java" "$here/src/EchoRun.java" "$here/src/WebRun.java" "$here/src/RouterWebRun.java" "$here/src/KonfigCases.java" "$here/src/EmailRun.java" "$here/src/MailCases.java" "$here/src/EmailClientRun.java" "$here/src/TerminalCases.java" "$here/src/TerminalRun.java" "$here/src/FileCases.java" "$here/src/GnutellaRun.java" "$here/src/NewDeviceRun.java" "$here/src/SheetRun.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" # FILIUS writes its settings below $HOME; keep them out of the real one. mkdir -p "$here/.build/home" # Removed first: a crashed run must not leave the previous result standing. @@ -337,6 +337,14 @@ HOME="$here/.build/home" xvfb-run -a java -cp "$classpath:$here/.build" SheetRun "$root/packages/doc/test/sheet.cases.txt" "$output" 2>&1 | grep -v 'logback\|^$' || true test -s "$output" +# FILIUS' command line window as it shows what is typed into it, key by key, +# over a stop and a start (TerminalWindowRun): the room's window +# (packages/sim/test/terminalWindow.test.ts). +output="$root/packages/sim/test/terminalWindow.java.json" +rm -f "$output" +HOME="$here/.build/home" xvfb-run -a java -Duser.language=de -Duser.country=DE -cp "$classpath:$here/.build" TerminalWindowRun "$output" 2>&1 | grep -v 'logback\|^$' || true +test -s "$output" + # FILIUS' own examples as Java FILIUS runs them, opened and started as FILIUS # does (ExampleRun): the acceptance of F2 (packages/fls/test/examplesJava.test.ts) # and the classics of F3 (web, email, gnutella; packages/fls/test/classicsJava.test.ts). diff --git a/tools/java-oracle/src/TerminalWindowRun.java b/tools/java-oracle/src/TerminalWindowRun.java new file mode 100644 index 0000000..a9fe941 --- /dev/null +++ b/tools/java-oracle/src/TerminalWindowRun.java @@ -0,0 +1,108 @@ +// Types into FILIUS' own command line window (GUIApplicationTerminalWindow on +// a GUIDesktopPanel) of a computer without a network, key by key as its key +// listener hears them, and writes after each key what the window shows: its +// text, its prompt and whether the prompt is shown, the input line. Then the +// simulation is stopped and started again (beenden, starten), as a change of +// mode does, and the window and the working directory are read again: the +// reference for the editor's command line window (packages/sim/test/ +// terminalWindow.test.ts). +// +// Usage: java -cp filius.jar:lib/*:classes TerminalWindowRun + +import filius.gui.anwendungssicht.GUIApplicationTerminalWindow; +import filius.gui.anwendungssicht.GUIDesktopPanel; +import filius.hardware.knoten.Rechner; +import filius.software.system.Betriebssystem; + +import java.awt.event.KeyEvent; +import java.awt.event.KeyListener; +import java.io.FileOutputStream; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import javax.swing.JLabel; +import javax.swing.JTextArea; +import javax.swing.JTextField; + +public class TerminalWindowRun { + static final String TERMINAL = "filius.software.lokal.Terminal"; + + static Object field(Object owner, String name) throws Exception { + Field field = GUIApplicationTerminalWindow.class.getDeclaredField(name); + field.setAccessible(true); + return field.get(owner); + } + + static Map shown(GUIApplicationTerminalWindow window, String step) throws Exception { + Map result = new LinkedHashMap<>(); + result.put("step", step); + result.put("text", ((JTextArea) field(window, "terminalField")).getText()); + JLabel label = (JLabel) field(window, "inputLabel"); + result.put("prompt", label.getText()); + result.put("promptShown", label.isVisible()); + result.put("input", ((JTextField) field(window, "inputField")).getText()); + return result; + } + + static void press(JTextField input, int key, int modifiers) { + KeyEvent event = new KeyEvent(input, KeyEvent.KEY_PRESSED, System.currentTimeMillis(), modifiers, key, KeyEvent.CHAR_UNDEFINED); + for (KeyListener listener : input.getKeyListeners()) listener.keyPressed(event); + } + + /** Waits until the program has answered: the prompt is shown again (or a second passed). */ + static void settle(GUIApplicationTerminalWindow window) throws Exception { + JLabel label = (JLabel) field(window, "inputLabel"); + for (int waited = 0; waited < 2000 && !label.isVisible(); waited += 20) Thread.sleep(20); + Thread.sleep(200); + } + + 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 { + Rechner host = new Rechner(); + Betriebssystem system = (Betriebssystem) host.getSystemSoftware(); + system.installApp(TERMINAL, true); + system.starten(); + GUIDesktopPanel desktop = new GUIDesktopPanel(system); + GUIApplicationTerminalWindow window = (GUIApplicationTerminalWindow) desktop.starteAnwendung(TERMINAL); + JTextField input = (JTextField) field(window, "inputField"); + List steps = new ArrayList<>(); + steps.add(shown(window, "open")); + // A line typed and entered ("type:"), a key ("up", "down", "ctrl+c"), a stop and start ("restart"). + String[] keys = { "enter:", "enter: ", "enter:pwd", "enter:mkdir dir", "enter:cd dir", "enter:pwd", "enter:nosuchcommand", "up", "up", "up", "down", "down", "down", + "enter:cls", "enter:ls", "restart", "enter:pwd", "up", "enter:exit" }; + for (String key : keys) { + if (key.startsWith("enter:")) { + input.setText(key.substring("enter:".length())); + press(input, KeyEvent.VK_ENTER, 0); + settle(window); + } else if (key.equals("up")) { + press(input, KeyEvent.VK_UP, 0); + } else if (key.equals("down")) { + press(input, KeyEvent.VK_DOWN, 0); + } else if (key.equals("restart")) { + system.beenden(); + system.starten(); + Thread.sleep(300); + } + steps.add(shown(window, key)); + } + Map output = new LinkedHashMap<>(); + output.put("steps", steps); + try (FileOutputStream out = new FileOutputStream(args[0])) { + out.write((FlsOracle.Json.write(output, "") + "\n").getBytes(StandardCharsets.UTF_8)); + } + System.out.println("terminal window steps: " + steps.size()); + } +}