diff --git a/CHANGES.md b/CHANGES.md index 446c1c9..3fe2735 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -179,6 +179,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/sim.ts` (`clearCapture`) | Java `AggregatedMessageTable` („Tabellen löschen“, `Lauscher.reset`) | neu (F4.7): die Nachricht `clearCapture` | | `src/sim.ts` (`startedBy`, `readOnly`, `frozen`, `WRITING_KINDS`), `src/sync.ts` (Schließcodes von `/sim`) | Plan §6.2, §6.4 | erweitert (F5.3a): der Zustand nennt, wer startete; was einen Raum ändert (Start, Stopp, Geschwindigkeit, Verwerfen, Tabellen löschen, Eingaben), nur mit Schreibstufe; ein eingefrorenes Projekt startet nicht; `/sim` sagt Absage und Trennung als Schließen des WebSockets (4403 mit dem Absagegrund, 4410 mit dem Trennungsgrund, 4500 wenn der Faden des Raums scheiterte) | | `src/sync.ts` | Plan §8.5 | neu (F5.2): was F einem Editor über seinen Platz im gemeinsamen Dokument sagt — warum eine Verbindung abgewiesen wird (`notAProject`, `notSignedIn`, `noAccess`, `unknownProject`), warum F sie schließt (`revoked`, `deleted`), und die Nachricht `access`, wenn sich der Zugang einer bleibenden Verbindung ändert (eingefroren, aufgetaut) | +| `src/sim.ts` (`restart`, `WRITING_KINDS`), `test/sim.test.ts` | — | neu (10.10.2026, auf Wunsch des Nutzers): die Nachricht `restart` (mit Sprache wie `start`, schreibend) — die Simulation neu starten, alles geleert. Abweichend von Java: dort nur Stoppen und Starten, Datenaustausch und Fenster überdauern das | ## packages/sim @@ -204,6 +205,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `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 | | `src/room.ts` („gestartet von“), `test/room.test.ts` | Plan §6.2 | erweitert (F5.3a): ein Client trägt auf F einen Namen; wer die laufende Simulation gestartet hat, steht im Zustand (`startedBy`), bis sie hält oder der Kern scheitert; ein Raum der eigenen Seite nennt niemanden | | `src/room.ts` (`blobsArrived`) | — | erweitert (F5.5): kamen Bytes nach, liest die laufende Simulation die Dateien neu | +| `src/room.ts` (`restart`), `test/room.test.ts` | — | neu (10.10.2026, auf Wunsch des Nutzers): der Raum startet neu — gestoppt wie bei `stop` (was die Geräte schrieben, bleibt im Dokument), dann Mitschnitt aller Karten, verworfene Rahmen, Befehlszeilen- und Browserfenster geleert, jedem Beobachter seine Fenster und Listen von vorn geschickt, gestartet wie bei `start`, benannt nach der Person, die neu startete. Aus dem Entwurf heraus: nur gestartet, ebenso geleert (anders als ein Start, der den letzten Lauf behält) | ## packages/editor @@ -274,6 +276,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/sheet/SheetTabs.tsx` (`variant="segmented"`), `src/sheet/sheet.css` (`.sheet-segments`), `src/sheet/ServicePanels.tsx` (DHCP-Server), `browser/sheet.spec.ts` | Java `JDHCPKonfiguration` (Reiter `jdhcpkonfiguration_msg10/11`) | geändert (10.10.2026, auf Wunsch des Nutzers): die beiden Seiten des DHCP-Servers („Grundeinstellungen“, „Statische Adresszuweisung“) als Segment-Control nach dem Muster des Dashboards — ein Rahmen, gleiche Anteile, das gewählte in der Akzentfarbe. Javas Wörter bleiben; gemessen: „Grundeinstellungen“ ist bei 14 px 138 px breit, eine Hälfte des 340-px-Blatts hält 139 (halbfett 157 px). Darum: Wörter brechen in ihrem Segment an Leerzeichen um, das gewählte ist nicht fett (die Breite spränge), und wo das längste Wort eines Segments samt Innenabstand nicht mehr in seinen Anteil passt (Handy, andere Systemschrift), stehen die Segmente untereinander, als ein Control — entschieden durch Messen der Wörter in der Schrift der Segmente (Canvas), nicht durch eine feste Breite. Abweichend von Java: dort Reiter (JTabbedPane) | | `src/design/SideBar.tsx` (neu), `src/design/Palette.tsx`, `src/design/DocumentationSidebar.tsx`, `src/Editor.tsx`, `src/sheet/sheet.css` (`.side-bar`), `src/i18n/*.json` (`design.foldBar`, `design.unfoldBar`), `browser/design.spec.ts`, `browser/documentation.spec.ts` | Java `GUIDesignSidebar`, `GUIDocumentationSidebar`; Dashboard `AppShell` („Leiste einklappen“) | erweitert (10.10.2026, auf Wunsch des Nutzers): die Leiste links (Palette, Dokumentationsleiste) klappt mit einem Knopf oben ein und aus — eingeklappt eine schmale Spalte (57 px statt 137) mit nur dem Knopf, nach dem Muster der Dashboard-Leiste; der Knopf steht in beiden Zuständen an derselben Stelle. Ein Zustand für beide Leisten, in diesem Browser gemerkt (`filius.barFolded`). Gemessen: die Fläche gewinnt 80 px (iPad hochkant mit Blatt 291 → 371 px, Handy 253 → 333 px). Abweichend von Java: dort ist die Leiste fest | | `src/sheet/sheet.css` (`.editor-root`, Handy), `src/Editor.tsx` (Wurzel), `browser/sheet.spec.ts` | — | geändert (10.10.2026, auf Wunsch des Nutzers): auf dem Handy (bis 639 px) nimmt das Geräte-Blatt den ganzen Editor ein, über der Leiste und den Modusknöpfen; eine Meldung bleibt darüber, Schließen gibt die Fläche zurück. Vorher stand es unter der Fläche (höchstens halb so hoch), neben einer Leiste von 137 px blieben ihm 253 von 390 px. Folge: die DHCP-Segmente stehen auf einem 390-px-Handy jetzt nebeneinander, untereinander erst unter etwa 335 px | +| `src/simulation/PropertiesSheet.tsx` (neu), `src/simulation/DropHold.tsx` (vorher `DropControl.tsx`), `src/simulation/useSimulation.ts` (`restart`), `src/Editor.tsx`, `src/sheet/sheet.css`, `src/desktop/desktop.css`, `src/i18n/*.json` (`properties.*`; `drop.list` entfällt), `browser/viewers.spec.ts`, `browser/shared.spec.ts`, `browser/fromF.spec.ts`, `tools/live/acceptance.ts` | Java `SimulationControl` | geändert (10.10.2026, auf Wunsch des Nutzers): im Aktionsmodus ein eigener Inspector „Eigenschaften“ (Knopf über der Fläche, Blatt neben ihr wie die Geräte-Einstellungen, auf dem Handy über dem ganzen Editor) statt Hinweis und Knöpfen in der Werkzeugleiste: wer die Simulation gestartet hat, „Simulation neu starten“ (für alle, nach einer Rückfrage; die Tabellen des Datenaustauschs verlieren ihre Auswahl), „Pakete verwerfen“ und darunter die verworfenen Pakete samt Anzahl. Wer nur zusieht, sieht Starter und Liste, ohne Neustart und Verwerfen | ## packages/server @@ -296,6 +299,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `test/ticket.test.ts`, `test/tickets.ts`, `test/tickets.python.json` | `tools/ticket-oracle` | neu (F5.2): jede Karte des Python-Orakels liest Node wie Python (Nutzlast oder derselbe Grund der Ablehnung); in Node signierte Karten tragen dasselbe JSON zum selben Zeitpunkt (Nodes zlib packt anders, ungepackt byte-gleich); die Ansprüche | | `test/access.test.ts`, `test/harness.ts` | — | neu (F5.2): `/__auth` (Keks, `next`, schlechte Karten, unbekanntes Projekt, zweites Projekt, nächste Person, Verlängerung), Absagen an `/sync` mit Grund, Herkunft, Dienstweg (Schlüssel, Anlegen, Fehlerantworten, Löschen, Einfrieren, Widerruf einzeln und ganz, Sitzungsliste, 500) | | `test/sync.test.ts`, `test/database.ts` | — | neu (F5.1), seit F5.2 mit echter Anmeldung (Karte, Keks): echte Clients (Hocuspocus' Provider über WebSocket) gegen eine Wegwerf-Postgres in Docker: Wiederkehr nach Neustart, nie verdichtete Aktualisierungen nach hartem Halt, zwei Personen live, Verdichten (500, Ruhe, nichts ohne Änderung), Schließen mit Personen, die sofort wiederkommen, gelöschtes Projekt, doppeltes Anlegen, Hausmeister beim Laden und nach einer Zusammenführung, unbekanntes Projekt, Lesestufe, `/healthz`, Protokoll | +| `src/simRooms.ts`, `src/simWorker.ts`, `test/sim.test.ts`, `test/blobs.test.ts` | Plan §6.4 | erweitert (10.10.2026): `restart` nur mit Schreibstufe, in einem eingefrorenen Projekt abgewiesen (`frozen`) wie `start`, im Log als `sim.restart`; ein Neustart wartet wie ein Start auf die Dateien, nach denen der Raum gefragt hat | ## tools/live diff --git a/packages/editor/browser/capture.spec.ts b/packages/editor/browser/capture.spec.ts index 8177859..6c540ba 100644 --- a/packages/editor/browser/capture.spec.ts +++ b/packages/editor/browser/capture.spec.ts @@ -3,7 +3,7 @@ // its address; the browser's card while it fetches a page — a row per frame // in its layer's colour, the chosen frame's details, the table as a text // file named after the tab —, a router's two cards beside it, "Tabellen -// löschen", "Automatisches Scrollen" off, a tab closed, the window gone with +// löschen", "Automatisches Scrollen" off, a restart emptying it, a tab closed, the window gone with // the design and back with the next start without the tab of a computer // removed; and on a phone. @@ -164,6 +164,27 @@ test("shows a card's frames as Java's data exchange window does, with their deta await expect(window.locator('tbody tr[aria-selected="true"]')).toHaveCount(0); expect(await scrolled()).toMatchObject({ overflows: true, top: 0 }); + // Started anew from „Eigenschaften“: the table begins empty, and the frame chosen before is chosen in none of the new ones. + await rows.nth(2).click(); + await expect(rows.nth(2)).toHaveAttribute('aria-selected', 'true'); + await page.getByRole('button', { name: 'Eigenschaften' }).click(); + const properties = page.getByRole('complementary', { name: 'Eigenschaften' }); + await properties.getByRole('button', { name: 'Simulation neu starten' }).click(); + await properties.getByRole('button', { name: 'Neu starten' }).click(); + await expect(rows).toHaveCount(0); + await properties.getByRole('button', { name: 'Schließen' }).click(); + await window.locator('.simulation-sheet__close').click(); + await showDesktop(page, await idOf(page, 'Browser')); + await desktop.locator('[data-program="webBrowser"]').click(); + await address.fill('192.168.0.20'); + await address.press('Enter'); + await page.waitForTimeout(6_000); + await desktop.locator('.simulation-sheet__close').click(); + await (await menuOf(page, 'Browser')).filter({ hasText: 'Datenaustausch' }).click(); + await expect.poll(() => rows.count()).toBeGreaterThan(3); + await expect(rows.first().locator('td').first()).toHaveText('1'); + await expect(window.locator('tbody tr[aria-selected="true"]')).toHaveCount(0); + // A third tab; a tab closed beside the chosen one leaves it chosen. await window.locator('.simulation-sheet__close').click(); await (await menuOf(page, 'Server')).filter({ hasText: 'Datenaustausch' }).click(); diff --git a/packages/editor/browser/fromF.spec.ts b/packages/editor/browser/fromF.spec.ts index f314775..5f34f69 100644 --- a/packages/editor/browser/fromF.spec.ts +++ b/packages/editor/browser/fromF.spec.ts @@ -49,6 +49,7 @@ test('the editor from F itself: two people, one simulation, the bundle packed', await expect(ben.page.locator('g.device')).toHaveCount(await mia.page.locator('g.device').count()); await mia.page.locator('button[name="simulationMode"]').click(); await expect(ben.page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation'); + await ben.page.getByRole('button', { name: 'Eigenschaften' }).click(); await expect(ben.page.locator('[data-note="startedBy"]')).toHaveText('Simulation gestartet von Mia'); expect([...mia.encodings.values()]).toEqual(['br']); }); diff --git a/packages/editor/browser/shared.spec.ts b/packages/editor/browser/shared.spec.ts index 667e895..a1bc116 100644 --- a/packages/editor/browser/shared.spec.ts +++ b/packages/editor/browser/shared.spec.ts @@ -46,6 +46,7 @@ const box = (page: Page, id: string) => page.locator(`g.device[data-node="${id}" const canvas = (page: Page) => page.locator('svg.filius-canvas'); const desktop = (page: Page, id: string) => page.locator(`section[data-desktop="${id}"]`); const note = (page: Page, kind: string) => page.locator(`[data-note="${kind}"]`); +const properties = (page: Page) => page.getByRole('complementary', { name: 'Eigenschaften' }); async function commandLine(page: Page, id: string) { await showDesktop(page, id); @@ -117,8 +118,12 @@ test('three people in one project of F: moving, reading only, one simulation for await mia.locator('button[name="simulationMode"]').click(); for (const page of [mia, ben, rita]) { await expect(canvas(page)).toHaveAttribute('data-mode', 'simulation'); - await expect(note(page, 'startedBy')).toHaveText('Simulation gestartet von Mia'); + await page.getByRole('button', { name: 'Eigenschaften' }).click(); + await expect(properties(page).locator('[data-note="startedBy"]')).toHaveText('Simulation gestartet von Mia'); } + // Rita may look: neither start anew nor drop. + await expect(properties(rita).getByRole('button')).toHaveText(['×']); + for (const page of [mia, ben, rita]) await properties(page).getByRole('button', { name: 'Schließen' }).click(); // Ben pings from the notebook's command line; Mia's command line of it shows the same. const bens = await commandLine(ben, notebook); @@ -141,6 +146,16 @@ test('three people in one project of F: moving, reading only, one simulation for await ritas.input.press('Enter'); await expect(rita.getByText('Hier können Sie nur zuschauen.')).toBeVisible(); + // Ben starts it anew, for everyone: the command line begins anew in each, named after him. + await ben.getByRole('button', { name: 'Eigenschaften' }).click(); + await properties(ben).getByRole('button', { name: 'Simulation neu starten' }).click(); + await properties(ben).getByRole('button', { name: 'Neu starten' }).click(); + for (const each of [bens, mias, ritas]) await expect(each.text).not.toContainText('ping 141.99.5.10'); + await expect(properties(ben).locator('[data-note="startedBy"]')).toHaveText('Simulation gestartet von Ben'); + await mia.getByRole('button', { name: 'Eigenschaften' }).click(); + await expect(properties(mia).locator('[data-note="startedBy"]')).toHaveText('Simulation gestartet von Ben'); + await expect(properties(mia).getByText('Noch keine verworfenen Pakete.')).toBeVisible(); + // Ben stops it, for everyone. await ben.locator('button[name="designMode"]').click(); for (const page of [mia, ben, rita]) await expect(canvas(page)).toHaveAttribute('data-mode', 'design'); diff --git a/packages/editor/browser/viewers.spec.ts b/packages/editor/browser/viewers.spec.ts index 1707f54..54fa9c3 100644 --- a/packages/editor/browser/viewers.spec.ts +++ b/packages/editor/browser/viewers.spec.ts @@ -2,8 +2,10 @@ // (F4.6g5), on FILIUS' example nat_with_mapped_port: a tap on a home router // shows its NAT table (the port forwarding's entry, then a ping's), a tap on // a switch its table (the addresses a ping taught it), each reset; and -// "Pakete verwerfen" held while a ping runs: the ping loses answers, the -// list shows the dropped frames in Java's words. +// "Pakete verwerfen" held while a ping runs, in the simulation's +// „Eigenschaften“: the ping loses answers, the list shows the dropped frames +// in Java's words; „Simulation neu starten“ empties the command line and the +// list and keeps the server's file. import { expect, test, type Locator, type Page } from '@playwright/test'; import { drawing, openEditor, serveEditor, showDesktop, showFromMenu } from './editorPage.js'; @@ -80,7 +82,10 @@ test('shows the tables of a home router and a switch, and drops while “Pakete await desktop.locator('[data-program="commandPrompt"]').click(); await input.fill('ping 141.99.4.10'); await input.press('Enter'); - const hold = page.getByRole('button', { name: 'Pakete verwerfen' }); + await page.getByRole('button', { name: 'Eigenschaften' }).click(); + const properties = page.getByRole('complementary', { name: 'Eigenschaften' }); + await expect(properties.getByText('Noch keine verworfenen Pakete.')).toBeVisible(); + const hold = properties.getByRole('button', { name: 'Pakete verwerfen' }); const box = (await hold.boundingBox())!; await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await page.mouse.down(); @@ -91,19 +96,40 @@ test('shows the tables of a home router and a switch, and drops while “Pakete // The second ping's statistics: an answer lost. await expect(desktop.locator('.terminal-text')).toContainText(/Paketstatistik ---[^]*Paketstatistik ---\n4 Paket\(e\) gesendet, [0-3] Paket\(e\) empfangen/, { timeout: 30_000 }); await expect(desktop.locator('.terminal-text')).toContainText('-- Timeout!'); - const toggle = page.getByRole('button', { name: /^Verworfen: [1-9]/ }); - await toggle.click(); - await expect(page.getByLabel('Verworfene Pakete')).toContainText(/\d\d:\d\d:\d\d\.\d\d\d : \[src=[0-9A-F:]+, dest=[0-9A-F:]+, type=0x8/); + await expect(properties.getByRole('heading', { name: /^Verworfene Pakete \([1-9]\d*\)$/ })).toBeVisible(); + await expect(properties.getByLabel('Verworfene Pakete', { exact: true })).toContainText(/\d\d:\d\d:\d\d\.\d\d\d : \[src=[0-9A-F:]+, dest=[0-9A-F:]+, type=0x8/); + + // „Simulation neu starten“: asked first — cancelled, nothing happens; confirmed, the command line and the + // dropped frames begin anew, the simulation runs, and what the server saved is still there. + await input.fill('touch bleibt.txt'); + await input.press('Enter'); + await properties.getByRole('button', { name: 'Simulation neu starten' }).click(); + await expect(properties.getByText('Die Simulation für alle neu starten? Alle Protokolle und Datenpakete gehen verloren.')).toBeVisible(); + await properties.getByRole('button', { name: 'Abbrechen' }).click(); + await expect(properties.getByLabel('Verworfene Pakete', { exact: true })).toBeVisible(); + await expect(desktop.locator('.terminal-text')).toContainText('ping 141.99.4.10'); + await properties.getByRole('button', { name: 'Simulation neu starten' }).click(); + await properties.getByRole('button', { name: 'Neu starten' }).click(); + await expect(desktop.locator('.terminal-text')).not.toContainText('ping 141.99.4.10'); + await expect(properties.getByText('Noch keine verworfenen Pakete.')).toBeVisible(); + await expect(properties.getByRole('heading', { name: 'Verworfene Pakete (0)' })).toBeVisible(); + await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation'); + await input.fill('ls'); + await input.press('Enter'); + await expect(desktop.locator('.terminal-text')).toContainText('bleibt.txt'); }); -test('keeps “Pakete verwerfen” in reach on a phone', async ({ page }) => { +test('keeps “Pakete verwerfen” in reach on a phone, in „Eigenschaften“ over the whole editor', async ({ page }) => { await page.setViewportSize({ width: 360, height: 740 }); await openEditor(page, server.base, 'nat_with_mapped_port.fls'); await page.getByRole('button', { name: 'Wechselt in den Aktionsmodus' }).click(); await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation'); - await expect(page.getByRole('button', { name: 'Pakete verwerfen' })).toBeInViewport({ ratio: 1 }); - await expect(page.getByRole('button', { name: /^Verworfen: / })).toBeInViewport({ ratio: 1 }); // Every part of the toolbar within the screen (the dev page's own fields above the editor are wider; they are not the editor's). const rights = await page.locator('.editor-toolbar > *').evaluateAll((parts) => parts.map((part) => part.getBoundingClientRect().right)); expect(Math.max(...rights)).toBeLessThanOrEqual(360); + await page.getByRole('button', { name: 'Eigenschaften' }).click(); + const properties = page.getByRole('complementary', { name: 'Eigenschaften' }); + expect(await properties.boundingBox()).toEqual(await page.locator('.editor-root').boundingBox()); + await expect(properties.getByRole('button', { name: 'Pakete verwerfen' })).toBeInViewport({ ratio: 1 }); + await expect(properties.getByRole('button', { name: 'Simulation neu starten' })).toBeInViewport({ ratio: 1 }); }); diff --git a/packages/editor/src/Editor.tsx b/packages/editor/src/Editor.tsx index 4785897..753bf90 100644 --- a/packages/editor/src/Editor.tsx +++ b/packages/editor/src/Editor.tsx @@ -69,7 +69,7 @@ import { translate, useLanguage, useTranslate } from './i18n/i18n.js'; import { CaptureSheet } from './simulation/CaptureSheet.js'; import { captureWords, reportTraffic } from './simulation/captureCells.js'; import { ModeBar, type EditorMode } from './simulation/ModeBar.js'; -import { DropControl } from './simulation/DropControl.js'; +import { PropertiesSheet } from './simulation/PropertiesSheet.js'; import { ViewerSheet, type ViewerKind } from './simulation/ViewerSheet.js'; import { useSeeThrough, type SheetFrame } from './simulation/SimulationSheet.js'; import { useSimulation, type SimHostFactory } from './simulation/useSimulation.js'; @@ -124,6 +124,9 @@ const toolButton: CSSProperties = { cursor: 'pointer', }; +/** A tool that is on: the sheet it opens is open. */ +const pressedButton: CSSProperties = { borderColor: 'var(--accent)', background: 'var(--accent-soft)' }; + /** Whether a key press belongs to a field being typed in. */ const typing = (target: EventTarget | null) => target instanceof HTMLElement && (target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)); @@ -175,6 +178,8 @@ export function Editor({ doc, createSimHost, projectName, readOnly = false, froz const [sheet, setSheet] = useState(null); const [seeThrough, setSeeThrough] = useSeeThrough(); const [barFolded, foldBar] = useBarFolded(); + // The simulation's „Eigenschaften“ beside the surface (PropertiesSheet). + const [properties, setProperties] = useState(false); // The sheet begins below the toolbar, which wraps on a narrow screen. const toolbar = useRef(null); const [sheetTop, setSheetTop] = useState(68); @@ -512,18 +517,17 @@ export function Editor({ doc, createSimHost, projectName, readOnly = false, froz />
- {simulating && simulation.startedBy !== undefined ? ( - - {t('access.startedBy', { name: simulation.startedBy })} - - ) : null} {readOnly ? ( {t(frozen ? 'access.frozen' : 'access.readOnly')} ) : null} - {simulating ? (readOnly ? null : ) : readOnly ? null : ( + {simulating ? ( + + ) : readOnly ? null : ( <>
)} + {simulating && properties ? ( + setProperties(false)} + onRestart={() => { + // The data exchange's tables begin anew, with nothing chosen, as after „Tabellen löschen“. + setCaptureTabs((before) => ({ ...before, tabs: before.tabs.map((tab) => ({ ...tab, chosen: undefined })) })); + simulation.restart(language); + }} + /> + ) : null} {/* Java shows the settings of the device that is chosen (not in the simulation: the design is locked); closing them lets go of it, as a click beside it does. */} {selected === undefined || simulating ? null : ( watch(['dropped']), [watch]); - const [held, setHeld] = useState(false); - const [open, setOpen] = useState(false); - const holding = useRef(false); - const hold = (on: boolean) => { - if (holding.current === on) return; - holding.current = on; - setHeld(on); - drop(on); - }; - // Let go when the control goes (the simulation stopped while it was held). - useEffect( - () => () => { - if (holding.current) drop(false); - }, - [drop], - ); - const onPointerDown = (event: ReactPointerEvent) => { - event.currentTarget.setPointerCapture(event.pointerId); - hold(true); - }; - const onKeyDown = (event: KeyboardEvent) => { - if ((event.key === ' ' || event.key === 'Enter') && !event.repeat) { - event.preventDefault(); - hold(true); - } - }; - const onKeyUp = (event: KeyboardEvent) => { - if (event.key === ' ' || event.key === 'Enter') hold(false); - }; - - return ( -
-
- - -
- {open ? ( -
-          {dropped.map((unit) => `${timeOfDay(clock, unit.timeMilliseconds, true)} : ${unit.text}\n`).join('')}
-        
- ) : null} -
- ); -} diff --git a/packages/editor/src/simulation/DropHold.tsx b/packages/editor/src/simulation/DropHold.tsx new file mode 100644 index 0000000..300001a --- /dev/null +++ b/packages/editor/src/simulation/DropHold.tsx @@ -0,0 +1,59 @@ +// Java's "Pakete verwerfen" (SimulationControl): while the button is held, +// every cable drops what it carries. The button is held with a finger, the +// mouse, or Space or Enter; letting go (it lets the captured pointer go), a +// cancel or leaving the button by keyboard ends it, and so does the button +// going (the sheet closed, the simulation stopped). It stands in the +// simulation's „Eigenschaften“ (PropertiesSheet), the frames it dropped below it. + +import { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent as ReactPointerEvent } from 'react'; +import { useTranslate } from '../i18n/i18n.js'; +import type { Simulation } from './useSimulation.js'; + +export function DropHold({ drop }: { readonly drop: Simulation['drop'] }) { + const t = useTranslate(); + const [held, setHeld] = useState(false); + const holding = useRef(false); + const hold = (on: boolean) => { + if (holding.current === on) return; + holding.current = on; + setHeld(on); + drop(on); + }; + // Let go when the button goes (the sheet closed, the simulation stopped) while it was held. + useEffect( + () => () => { + if (holding.current) drop(false); + }, + [drop], + ); + const onPointerDown = (event: ReactPointerEvent) => { + event.currentTarget.setPointerCapture(event.pointerId); + hold(true); + }; + const onKeyDown = (event: KeyboardEvent) => { + if ((event.key === ' ' || event.key === 'Enter') && !event.repeat) { + event.preventDefault(); + hold(true); + } + }; + const onKeyUp = (event: KeyboardEvent) => { + if (event.key === ' ' || event.key === 'Enter') hold(false); + }; + + return ( + + ); +} diff --git a/packages/editor/src/simulation/PropertiesSheet.tsx b/packages/editor/src/simulation/PropertiesSheet.tsx new file mode 100644 index 0000000..61a9008 --- /dev/null +++ b/packages/editor/src/simulation/PropertiesSheet.tsx @@ -0,0 +1,135 @@ +// The simulation's „Eigenschaften“: a sheet beside the surface, as the +// device's settings are in the design (on a phone over the whole editor), +// opened from the button of that name over the surface. What Java puts +// around the surface while it simulates, and one thing Java has not: +// +// - who started the simulation that runs (on F; everyone in the project +// watches the same one), +// - „Simulation neu starten“: started anew for everyone, every program +// window, the data exchange and the dropped frames emptied — what the +// devices wrote stays. Java only stops and starts, and its windows and +// tables outlive that. Asked once before, since it empties the others' too. +// - Java's "Pakete verwerfen" (DropHold) and below it the frames it dropped in +// this simulation, each with its time of day and Java's words for it +// (EthernetFrame.toString); Java's panel takes the bottom of the window. +// +// Who only watches sees who started it and what was dropped, and neither +// starts anew nor drops. + +import { useEffect, useState } from "react"; +import { useTranslate } from "../i18n/i18n.js"; +import { DropHold } from "./DropHold.js"; +import { timeOfDay } from "./timeOfDay.js"; +import type { Simulation } from "./useSimulation.js"; + +export function PropertiesSheet({ + simulation, + onRestart, + onClose, + readOnly = false, +}: { + readonly simulation: Simulation; + /** „Simulation neu starten“ confirmed. */ + readonly onRestart: () => void; + readonly onClose: () => void; + readonly readOnly?: boolean; +}) { + const t = useTranslate(); + const { watch, drop, dropped, clock, startedBy } = simulation; + useEffect(() => watch(["dropped"]), [watch]); + const [asking, setAsking] = useState(false); + + return ( + + ); +} diff --git a/packages/editor/src/simulation/useSimulation.ts b/packages/editor/src/simulation/useSimulation.ts index 381c868..48a3a7e 100644 --- a/packages/editor/src/simulation/useSimulation.ts +++ b/packages/editor/src/simulation/useSimulation.ts @@ -55,6 +55,8 @@ export interface Simulation { /** Starts in the language the person runs FILIUS in: the routers' pages come in it, as Java's in its locale. */ start(language?: Language): void; stop(): void; + /** Starts anew for everyone: every window, list and capture emptied, what the devices wrote kept (not Java's). */ + restart(language?: Language): void; setSpeed(value: number): void; /** Java's "Pakete verwerfen": held (true) or let go (false). */ drop(on: boolean): void; @@ -155,6 +157,10 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH host.current?.send(language === undefined ? { v: 1, kind: 'start' } : { v: 1, kind: 'start', language }); }, []); const stop = useCallback(() => host.current?.send({ v: 1, kind: 'stop' }), []); + const restart = useCallback((language?: Language) => { + setFailed(false); + host.current?.send(language === undefined ? { v: 1, kind: 'restart' } : { v: 1, kind: 'restart', language }); + }, []); const setSpeed = useCallback((value: number) => host.current?.send({ v: 1, kind: 'speed', value }), []); const drop = useCallback((on: boolean) => host.current?.send({ v: 1, kind: 'drop', on }), []); const clearCapture = useCallback(() => host.current?.send({ v: 1, kind: 'clearCapture' }), []); @@ -211,5 +217,5 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH host.current?.send({ v: 1, kind: 'input', node, app, action, args, reqId: crypto.randomUUID() }); }, []); - return { mode, speed, activeLinks, nodes, apps, views, failed, startedBy, refusal, clock, dropped, traces, start, stop, setSpeed, drop, clearCapture, watch, captures, act }; + return { mode, speed, activeLinks, nodes, apps, views, failed, startedBy, refusal, clock, dropped, traces, start, stop, restart, setSpeed, drop, clearCapture, watch, captures, act }; } diff --git a/packages/protocol/src/sim.ts b/packages/protocol/src/sim.ts index 99fe845..be285ac 100644 --- a/packages/protocol/src/sim.ts +++ b/packages/protocol/src/sim.ts @@ -21,7 +21,7 @@ export type AppKind = (typeof APP_KINDS)[number]; */ export type Topic = 'activity' | 'dropped' | `trace:${string}` | `app:${string}/${AppKind}` | `node:${string}`; -export const CLIENT_MESSAGE_KINDS = ['sub', 'unsub', 'start', 'stop', 'speed', 'drop', 'clearCapture', 'input'] as const; +export const CLIENT_MESSAGE_KINDS = ['sub', 'unsub', 'start', 'stop', 'restart', 'speed', 'drop', 'clearCapture', 'input'] as const; export type ClientMessageKind = (typeof CLIENT_MESSAGE_KINDS)[number]; export type ClientMessage = @@ -35,6 +35,15 @@ export type ClientMessage = */ | { readonly v: 1; readonly kind: 'start'; readonly language?: PageLanguage } | { readonly v: 1; readonly kind: 'stop' } + /** + * Starts the simulation anew (not Java's: there one stops and starts, and + * the data exchange tables and the desktops' windows outlive it): a running + * one is stopped as by `stop` — what the devices wrote stays in the + * document — then every card's capture, the dropped frames and every + * program window are emptied, and it starts as by `start`. From the design + * it only starts, emptied as well. + */ + | { readonly v: 1; readonly kind: 'restart'; readonly language?: PageLanguage } /** Java's speed slider, 1 to 100 per cent: how long a frame takes over a cable. */ | { readonly v: 1; readonly kind: 'speed'; readonly value: number } /** Java's "Pakete verwerfen" pressed (on) or let go: every cable drops what it carries while any client holds it. */ @@ -80,7 +89,7 @@ export interface DroppedUnit { export const ERROR_CODES = ['badMessage', 'badVersion', 'unknownKind', 'badTopic', 'notRunning', 'unknownNode', 'unknownApp', 'unknownAction', 'badArgs', 'engineFailed', 'readOnly', 'frozen'] as const; /** What a client sends that changes the room: only with a write grant (plan §6.4); a reader subscribes. */ -export const WRITING_KINDS: readonly ClientMessageKind[] = ['start', 'stop', 'speed', 'drop', 'clearCapture', 'input']; +export const WRITING_KINDS: readonly ClientMessageKind[] = ['start', 'stop', 'restart', 'speed', 'drop', 'clearCapture', 'input']; export type ErrorCode = (typeof ERROR_CODES)[number]; export type HostMessage = @@ -160,9 +169,12 @@ export function parseClientMessage(value: unknown): Parsed { return { message: { v: 1, kind: kind as 'sub' | 'unsub', topics: topics as Topic[] } }; } case 'start': - if (value.language === undefined) return { message: { v: 1, kind: 'start' } }; + case 'restart': { + const starting = kind as 'start' | 'restart'; + if (value.language === undefined) return { message: { v: 1, kind: starting } }; if (value.language !== 'de' && value.language !== 'en') return bad('badMessage', `language ${String(value.language)}`); - return { message: { v: 1, kind: 'start', language: value.language } }; + return { message: { v: 1, kind: starting, language: value.language } }; + } case 'stop': return { message: { v: 1, kind: 'stop' } }; case 'clearCapture': diff --git a/packages/protocol/test/sim.test.ts b/packages/protocol/test/sim.test.ts index 6f34382..249642a 100644 --- a/packages/protocol/test/sim.test.ts +++ b/packages/protocol/test/sim.test.ts @@ -11,6 +11,7 @@ describe("a client's messages", () => { { v: 1, kind: 'unsub', topics: ['activity'] }, { v: 1, kind: 'start' }, { v: 1, kind: 'stop' }, + { v: 1, kind: 'restart' }, { v: 1, kind: 'speed', value: 50 }, { v: 1, kind: 'drop', on: true }, { v: 1, kind: 'clearCapture' }, @@ -19,7 +20,7 @@ describe("a client's messages", () => { expect(messages.map((each) => each.kind)).toEqual([...CLIENT_MESSAGE_KINDS]); for (const each of messages) expect(parseClientMessage(each)).toEqual({ message: each }); // A start in a language, and the language dropped where it is none. - for (const language of ['de', 'en']) expect(parseClientMessage({ v: 1, kind: 'start', language })).toEqual({ message: { v: 1, kind: 'start', language } }); + for (const kind of ['start', 'restart']) for (const language of ['de', 'en']) expect(parseClientMessage({ v: 1, kind, language })).toEqual({ message: { v: 1, kind, language } }); expect(parseClientMessage({ v: 1, kind: 'stop', language: 'en' })).toEqual({ message: { v: 1, kind: 'stop' } }); expect(parseClientMessage({ v: 1, kind: 'clearCapture', port: 'p' })).toEqual({ message: { v: 1, kind: 'clearCapture' } }); }); @@ -34,6 +35,7 @@ describe("a client's messages", () => { expect(code({ v: 1, kind: 'sub', topics: ['activity', 'trace:'] })).toBe('badTopic'); expect(code({ v: 1, kind: 'unsub', topics: ['app:node/paint'] })).toBe('badTopic'); expect(code({ v: 1, kind: 'start', language: 'fr' })).toBe('badMessage'); + expect(code({ v: 1, kind: 'restart', language: 'fr' })).toBe('badMessage'); expect(code({ v: 1, kind: 'drop', on: 'yes' })).toBe('badMessage'); expect(code({ v: 1, kind: 'sub', topics: ['dropped', 'app:node/sat', 'app:node/nat'] })).toBe('ok'); expect(code({ v: 1, kind: 'start', language: null })).toBe('badMessage'); diff --git a/packages/server/src/simRooms.ts b/packages/server/src/simRooms.ts index a364739..45b6994 100644 --- a/packages/server/src/simRooms.ts +++ b/packages/server/src/simRooms.ts @@ -10,9 +10,9 @@ // whose bytes are not there. // // Who may do what: everyone in the project watches; only with a write grant -// does one start, stop, set the speed, hold "Pakete verwerfen", clear the -// capture or act in a window (WRITING_KINDS; else `readOnly`). A frozen -// project's simulation does not start (`frozen`); freezing stops a running +// does one start, stop, restart, set the speed, hold "Pakete verwerfen", clear +// the capture or act in a window (WRITING_KINDS; else `readOnly`). A frozen +// project's simulation does not start, nor start anew (`frozen`); freezing stops a running // one, what it left written back first. // // When the last person leaves, the room stays LINGER_MILLISECONDS (a tab @@ -250,11 +250,11 @@ export class SimRooms { answer({ kind: 'error', code: 'readOnly', ...request }); return; } - if (kind === 'start' && room.frozen) { + if ((kind === 'start' || kind === 'restart') && room.frozen) { answer({ kind: 'error', code: 'frozen', ...request }); return; } - if (kind === 'start' || kind === 'stop') this.options.log.info(`sim.${kind}`, { project: room.project, user: person.login }); + if (kind === 'start' || kind === 'stop' || kind === 'restart') this.options.log.info(`sim.${kind}`, { project: room.project, user: person.login }); } this.post(room, { type: 'client', room: room.project, client, message }); } diff --git a/packages/server/src/simWorker.ts b/packages/server/src/simWorker.ts index 1cb9950..75f0c82 100644 --- a/packages/server/src/simWorker.ts +++ b/packages/server/src/simWorker.ts @@ -44,7 +44,8 @@ function askForBlobs(name: string, held: HeldRoom): void { post({ type: 'needBlobs', room: name, shas }); } -const isStart = (message: unknown) => typeof message === 'object' && message !== null && (message as { kind?: unknown }).kind === 'start'; +/** A start, or a start anew: it waits for the files' bytes the room still asked for. */ +const isStart = (message: unknown) => typeof message === 'object' && message !== null && ['start', 'restart'].includes((message as { kind?: unknown }).kind as string); port.on('message', (message: ToRoomThread) => { if (message.type === 'open') { diff --git a/packages/server/test/blobs.test.ts b/packages/server/test/blobs.test.ts index a46ee04..cd1c95b 100644 --- a/packages/server/test/blobs.test.ts +++ b/packages/server/test/blobs.test.ts @@ -222,12 +222,15 @@ describe('the blobs on F', () => { expect(BLOB_ASKS).toBe(5); }, 30_000); - it("starts a simulation only once the blobs asked for have come, however slow F's store is", async () => { - const { project, notebook, bytes } = await projectWithPicture(51); + it.each([ + ['start', 51], + ['restart', 52], + ] as const)("starts a simulation (%s) only once the blobs asked for have come, however slow F's store is", async (kind, seed) => { + const { project, notebook, bytes } = await projectWithPicture(seed); await blobs.put(bytes); const server = await startTestServer(store, sessions, { blobs: slowly(blobs, { get: 800 }) }); const room = await enterRoom(server, project); - room.send({ kind: 'start' }); + room.send({ kind }); await until(() => room.got.some((message) => message.kind === 'state' && message.mode === 'simulation'), 5_000); room.send({ kind: 'input', node: notebook, app: 'terminal', action: 'enter', args: { line: 'cp spaet.png kopie.png' }, reqId: 'r' }); await until(() => room.got.some((message) => message.kind === 'ack')); diff --git a/packages/server/test/sim.test.ts b/packages/server/test/sim.test.ts index cc4a194..f7f414f 100644 --- a/packages/server/test/sim.test.ts +++ b/packages/server/test/sim.test.ts @@ -116,11 +116,19 @@ describe('a simulation room on F', () => { mia.send({ kind: 'input', node: notebook, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r1' }); await until(() => mia.got.some((message) => message.kind === 'ack' && message.reqId === 'r1')); await until(() => appLines(ben).some((line) => /4 Paket\(e\) gesendet, 4 Paket\(e\) empfangen/.test(line.text ?? '')), 20_000); + // Started anew by Ben: named him, the command line as at the start. + const sent = ben.got.length; + ben.send({ kind: 'restart' }); + await until(() => mia.state()?.startedBy === 'Ben'); + await until(() => ben.got.slice(sent).some((message) => message.kind === 'app' && message.from === 0)); + const anew = ben.got.slice(sent).filter((message): message is Extract => message.kind === 'app' && message.from === 0).at(-1)!; + expect((anew.lines as { text?: string }[]).some((line) => /Paket/.test(line.text ?? ''))).toBe(false); ben.send({ kind: 'stop' }); await until(() => mia.state()?.mode === 'design'); expect(mia.state()).not.toHaveProperty('startedBy'); - expect(server.log.lines.filter((line) => ['sim.start', 'sim.stop'].includes(line.event as string)).map(({ event, user }) => [event, user])).toEqual([ + expect(server.log.lines.filter((line) => ['sim.start', 'sim.stop', 'sim.restart'].includes(line.event as string)).map(({ event, user }) => [event, user])).toEqual([ ['sim.start', 'mia'], + ['sim.restart', 'ben'], ['sim.stop', 'ben'], ]); expect(server.sims.stats()).toEqual({ rooms: 1, people: 2, threads: [1] }); @@ -255,6 +263,11 @@ describe('a simulation room on F', () => { mia.send({ kind: 'start', reqId: 'x' }); await until(() => mia.got.some((message) => message.kind === 'error')); expect(mia.got.filter((message) => message.kind === 'error')).toEqual([{ v: 1, kind: 'error', code: 'frozen', reqId: 'x' }]); + // Nor anew. + mia.send({ kind: 'restart', reqId: 'y' }); + await until(() => mia.got.filter((message) => message.kind === 'error').length === 2); + expect(mia.got.filter((message) => message.kind === 'error').at(-1)).toEqual({ v: 1, kind: 'error', code: 'frozen', reqId: 'y' }); + expect(mia.state()).toMatchObject({ mode: 'design' }); // A reader is told it reads, not that it is frozen. const late = await person(server, project, 'Ben'); late.send({ kind: 'start' }); diff --git a/packages/sim/src/room.ts b/packages/sim/src/room.ts index 632da88..5da0bfc 100644 --- a/packages/sim/src/room.ts +++ b/packages/sim/src/room.ts @@ -393,6 +393,9 @@ export class SimulationRoom { case 'stop': this.stop(); return; + case 'restart': + this.restart(input.language, client.name); + return; case 'drop': if (this.mode !== 'simulation') { client.send(message({ kind: 'error', code: 'notRunning' })); @@ -475,6 +478,28 @@ export class SimulationRoom { this.flush([...this.clients.values()], false); } + /** + * Starts the simulation anew, as though nothing had run before: stopped as + * by stop (what the devices wrote stays in the document), every card's + * capture, the dropped frames and every program window emptied — Java's + * outlive a stop — and started. Each watcher gets its lists and windows + * anew, from their beginning (the dropped frames start does anew anyway; a + * view is compared, so a changed one goes out by itself). + */ + restart(language?: PageLanguage, startedBy?: string): void { + this.stop(); + this.engine.clearCapture(); + // The cables light up again with the first frame, not once a card has more than before. + this.framesSeen.clear(); + this.terminalWindows.clear(); + this.browserWindows.clear(); + for (const client of this.clients.values()) { + client.traceSent.clear(); + client.appSent.clear(); + } + this.start(language, startedBy); + } + /** Lets the simulation run to the wall clock's time, then sends what came of it. */ tick(): void { if (this.mode !== 'simulation') return; diff --git a/packages/sim/test/room.test.ts b/packages/sim/test/room.test.ts index ae59d2b..ea1af76 100644 --- a/packages/sim/test/room.test.ts +++ b/packages/sim/test/room.test.ts @@ -472,6 +472,14 @@ describe("a simulation room's browser and web server windows", () => { send({ kind: 'input', node: notebook, app: 'webBrowser', action: 'open', args: { address: gateway }, reqId: 'r' }); run(20_000); expect(page().page.html).toContain('Router Administration'); + // Started anew: in the language of the restart, German without one. + for (const [restart, title] of [[{ kind: 'restart' }, 'Administration des Vermittlungsrechners'], [{ kind: 'restart', language: 'en' }, 'Router Administration']] as const) { + send(restart); + run(TICK_MILLISECONDS); + send({ kind: 'input', node: notebook, app: 'webBrowser', action: 'open', args: { address: gateway }, reqId: 'r' }); + run(20_000); + expect(page().page.html).toContain(title); + } }); it('names a wrong action in the windows', () => { @@ -595,3 +603,78 @@ describe("a simulation room's e-mail windows", () => { }); }); + +describe('a simulation room started anew', () => { + /** A window sent from its beginning, and a view. */ + const anew = (got: readonly HostMessage[], node: Id, app: string) => ofKind(got, 'app').filter((each) => each.node === node && each.app === app && each.from === 0); + const viewsOf = (got: readonly HostMessage[], node: Id, app: string) => ofKind(got, 'view').filter((each) => each.node === node && each.app === app); + + it('empties every window and list for every client, names who did it, keeps what the devices wrote, and numbers anew', () => { + const { room, got, send, node, run } = setUp(); + const benGot: HostMessage[] = []; + const ben = room.connect((message) => benGot.push(message), 'Ben'); + const notebook = node('Notebook 1.10'); + const server = node('Webserver 5.10').id; + const port = notebook.ports[0]!.id; + const topics = ['activity', `trace:${port}`, 'dropped', `app:${notebook.id}/terminal`, `app:${notebook.id}/webBrowser`, `app:${server}/webServer`]; + send({ kind: 'sub', topics }); + room.receive(ben, { v: 1, kind: 'sub', topics }); + send({ kind: 'start' }); + run(TICK_MILLISECONDS); + const fresh = { terminal: anew(got, notebook.id, 'terminal').at(-1)!, browser: viewsOf(got, notebook.id, 'webBrowser').at(-1)! }; + const enter = (line: string) => send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line }, reqId: line }); + enter('touch bleibt.txt'); + enter('ping 141.99.5.10'); + send({ kind: 'input', node: notebook.id, app: 'webBrowser', action: 'open', args: { address: '141.99.5.10' }, reqId: 'open' }); + run(20_000); + send({ kind: 'drop', on: true }); + enter('ping 141.99.5.10'); + run(5_000); + send({ kind: 'drop', on: false }); + expect(ofKind(got, 'dropped').flatMap((each) => each.units).length).toBeGreaterThan(0); + expect(ofKind(got, 'app').filter((each) => each.app === 'webServer').flatMap((each) => each.lines).length).toBeGreaterThan(0); + + const before = { mia: got.length, ben: benGot.length }; + room.receive(ben, { v: 1, kind: 'restart' }); + for (const [messages, from] of [[got, before.mia], [benGot, before.ben]] as const) { + const after = messages.slice(from); + expect(ofKind(after, 'state').map((each) => [each.mode, each.startedBy])).toEqual([ + ['design', undefined], + ['simulation', 'Ben'], + ]); + expect(ofKind(after, 'trace').at(-1)).toMatchObject({ portId: port, reset: true, frames: [] }); + expect(ofKind(after, 'dropped').at(-1)).toMatchObject({ reset: true, units: [] }); + // The command line as at the first start, the browser on its start page, the server's log empty. + expect(anew(after, notebook.id, 'terminal').at(-1)!.lines).toEqual(fresh.terminal.lines); + expect(viewsOf(after, notebook.id, 'webBrowser').at(-1)!.view).toEqual(fresh.browser.view); + expect(anew(after, server, 'webServer').at(-1)!.lines).toEqual([]); + } + // What the devices wrote is still there; a ping is numbered from 1, and the notebook's cable lights up with its first frame. + const restarted = got.length; + const cable = readProject(exampleDoc()).graph.links.find((link) => link.sourcePortId === port || link.targetPortId === port)!.id; + enter('ls'); + enter('ping 141.99.5.10'); + for (let waited = 0; !ofKind(got.slice(restarted), 'trace').some((each) => each.frames.length > 0) && waited < 5_000; waited += TICK_MILLISECONDS) run(TICK_MILLISECONDS); + expect(ofKind(got.slice(restarted), 'activity').at(-1)!.links).toContain(cable); + run(20_000); + expect(ofKind(got.slice(restarted), 'app').flatMap((each) => each.lines as { text?: string }[]).some((line) => (line.text ?? '').includes('bleibt.txt'))).toBe(true); + const frames = ofKind(got.slice(restarted), 'trace').flatMap((each) => each.frames); + expect(frames.length).toBeGreaterThan(8); + expect(frames.map((frame) => frame.number)).toEqual(frames.map((_, index) => index + 1)); + }); + + it('starts from the design emptied, where a start keeps the last run', () => { + const { got, send, node, run } = setUp(); + const notebook = node('Notebook 1.10'); + const port = notebook.ports[0]!.id; + send({ kind: 'start' }); + send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r1' }); + run(5_000); + send({ kind: 'stop' }); + send({ kind: 'restart' }); + expect(ofKind(got, 'state').at(-1)!.mode).toBe('simulation'); + send({ kind: 'sub', topics: [`trace:${port}`, `app:${notebook.id}/terminal`] }); + expect(ofKind(got, 'trace').at(-1)).toMatchObject({ reset: true, frames: [] }); + expect(ofKind(got, 'app').at(-1)!.lines.some((line) => /ping/.test(JSON.stringify(line)))).toBe(false); + }); +}); diff --git a/tools/live/acceptance.ts b/tools/live/acceptance.ts index 35104f9..d690e40 100644 --- a/tools/live/acceptance.ts +++ b/tools/live/acceptance.ts @@ -97,6 +97,8 @@ try { // Mia starts; the simulation runs for both, named after her. await mia.locator('button[name="simulationMode"]').click(); + await eventually('the simulation runs for Ben', async () => (await canvas(ben).getAttribute('data-mode')) === 'simulation'); + await ben.getByRole('button', { name: 'Eigenschaften' }).click(); await eventually( 'the simulation runs for both, started by Mia', async () => (await canvas(ben).getAttribute('data-mode')) === 'simulation' && (await ben.locator('[data-note="startedBy"]').textContent()) === 'Simulation gestartet von Mia',