feat(editor): the simulation's properties in a sheet of their own, with a restart for everyone

Who started the simulation, "Pakete verwerfen" and the dropped packets move
from the toolbar into a properties sheet beside the surface. New in it: restart
the simulation, after asking. The room learns a restart message: stopped as by
stop, every capture, dropped frame and program window emptied, started anew
and named after who restarted; refused for readers and frozen projects, logged,
and waiting for asked blobs like a start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 21:01:41 +00:00
co-authored by Claude Opus 5.5
parent 317c0d9602
commit 12b26f4abe
23 changed files with 531 additions and 165 deletions
+22 -1
View File
@@ -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();
+1
View File
@@ -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']);
});
+16 -1
View File
@@ -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');
+35 -9
View File
@@ -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 });
});
+23 -7
View File
@@ -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<OpenSheet | null>(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<HTMLDivElement>(null);
const [sheetTop, setSheetTop] = useState(68);
@@ -512,18 +517,17 @@ export function Editor({ doc, createSimHost, projectName, readOnly = false, froz
/>
<div role="toolbar" className="editor-toolbar" ref={toolbar}>
<ModeBar mode={editorMode} speed={simulation.speed} onMode={changeMode} onSpeed={simulation.setSpeed} canRun={!readOnly} />
{simulating && simulation.startedBy !== undefined ? (
<span className="editor-note" data-note="startedBy">
{t('access.startedBy', { name: simulation.startedBy })}
</span>
) : null}
<PeopleRow people={people} />
{readOnly ? (
<span className="editor-note" data-note="readOnly">
{t(frozen ? 'access.frozen' : 'access.readOnly')}
</span>
) : null}
{simulating ? (readOnly ? null : <DropControl simulation={simulation} />) : readOnly ? null : (
{simulating ? (
<button type="button" style={properties ? { ...toolButton, ...pressedButton } : toolButton} aria-pressed={properties} onClick={() => setProperties(!properties)}>
{t('properties.title')}
</button>
) : readOnly ? null : (
<>
<button type="button" style={toolButton} disabled={!canUndo} onClick={() => manager?.undo()} aria-label={t('design.undo')} title={t('design.undo')}>
↶
@@ -637,6 +641,18 @@ export function Editor({ doc, createSimHost, projectName, readOnly = false, froz
</div>
)}
</div>
{simulating && properties ? (
<PropertiesSheet
simulation={simulation}
readOnly={readOnly}
onClose={() => 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 : (
<DeviceSheet
+1 -43
View File
@@ -1320,8 +1320,7 @@
font-weight: 600;
}
.viewer-bar button,
.drop-control button {
.viewer-bar button {
min-height: 44px;
padding: 0 12px;
border: 1px solid var(--border-default);
@@ -1332,47 +1331,6 @@
cursor: pointer;
}
/* "Pakete verwerfen" (SimulationControl): held, then the list of dropped frames below. */
.drop-control {
position: relative;
}
.drop-control-bar {
display: flex;
gap: 6px;
}
.drop-control-hold {
touch-action: none;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.drop-control button[aria-pressed='true'] {
background: var(--danger-soft);
border-color: var(--danger);
color: var(--danger-on-soft);
}
.drop-control-list {
position: absolute;
top: calc(100% + 6px);
left: 0;
width: min(720px, calc(100vw - 32px));
max-height: 240px;
margin: 0;
overflow: auto;
padding: 6px;
border: 1px solid var(--border-default);
border-radius: 8px;
background: var(--surface-card);
color: var(--text-primary);
font: 12px ui-monospace, Menlo, Consolas, monospace;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* The data exchange (AggregatedExchangeDialog): the tabs, a bar for what
* Java's right mouse button offers, the table (a row per frame in its layer's
* colour, Java's fonts by column) and the chosen frame's details below. */
+12 -4
View File
@@ -50,11 +50,11 @@
"connections.nic": "NIC {number}: {address}",
"connections.remove": "Schnittstelle entfernen",
"design.actions": "Aktionen",
"design.palette": "Komponenten",
"design.foldBar": "Leiste einklappen",
"design.unfoldBar": "Leiste ausklappen",
"design.palette": "Komponenten",
"design.redo": "Wiederholen",
"design.undo": "Rückgängig",
"design.unfoldBar": "Leiste ausklappen",
"desktop.home": "Startbildschirm",
"desktop.installation": "Software-Installation",
"desktop.network": "Netzwerk",
@@ -129,7 +129,6 @@
"docu.swatch": "Farbe {index}",
"docu.text": "Text",
"drop.hold": "Pakete verwerfen",
"drop.list": "Verworfen: {count}",
"drop.listLabel": "Verworfene Pakete",
"echoServer.log": "Ausgabe",
"echoServer.port": "Port:",
@@ -363,6 +362,15 @@
"program.textEditor": "Text-Editor",
"program.webBrowser": "Webbrowser",
"program.webServer": "Webserver",
"properties.dropHint": "Solange der Knopf gedrückt ist, verwirft jedes Kabel, was es gerade überträgt.",
"properties.droppedNone": "Noch keine verworfenen Pakete.",
"properties.restart": "Simulation neu starten",
"properties.restartAsk": "Die Simulation für alle neu starten? Alle Protokolle und Datenpakete gehen verloren.",
"properties.restartHint": "Beendet die Simulation und startet sie neu, für alle im Projekt: Fenster der Programme, Datenaustausch und verworfene Pakete beginnen leer. Was die Geräte gespeichert haben, bleibt.",
"properties.restartNo": "Abbrechen",
"properties.restartYes": "Neu starten",
"properties.simulation": "Simulation",
"properties.title": "Eigenschaften",
"remote.connecting": "Verbindung zum Projekt …",
"remote.deleted": "Das Projekt wurde gelöscht.",
"remote.noAccess": "Sie haben keinen Zugang zu diesem Projekt.",
@@ -411,7 +419,6 @@
"sheet.cards": "Netzwerkkarten",
"sheet.chooseNetwork": "Bitte auswählen",
"sheet.close": "Schließen",
"sheet.seeThrough": "Durchsichtig",
"sheet.cloud": "als Wolke darstellen",
"sheet.connectedTo": "Verbunden mit {device}",
"sheet.connection": "Anschluss",
@@ -442,6 +449,7 @@
"sheet.portForwarding": "Portfreigaben",
"sheet.retention": "Gültigkeitsdauer SAT-Einträge (in Sek.)",
"sheet.rip": "Automatisches Routing",
"sheet.seeThrough": "Durchsichtig",
"sheet.showAllEntries": "Alle Einträge anzeigen",
"sheet.subnetMask": "Netzmaske",
"sheet.switchSsid": "Name WLAN (SSID)",
+12 -4
View File
@@ -50,11 +50,11 @@
"connections.nic": "NIC {number}: {address}",
"connections.remove": "Remove interface",
"design.actions": "Actions",
"design.palette": "Components",
"design.foldBar": "Collapse the bar",
"design.unfoldBar": "Expand the bar",
"design.palette": "Components",
"design.redo": "Redo",
"design.undo": "Undo",
"design.unfoldBar": "Expand the bar",
"desktop.home": "Home Screen",
"desktop.installation": "Software Installation",
"desktop.network": "Network",
@@ -129,7 +129,6 @@
"docu.swatch": "Colour {index}",
"docu.text": "Text",
"drop.hold": "Drop Packets",
"drop.list": "Dropped: {count}",
"drop.listLabel": "Dropped packets",
"echoServer.log": "Output",
"echoServer.port": "Port:",
@@ -363,6 +362,15 @@
"program.textEditor": "Text editor",
"program.webBrowser": "Webbrowser",
"program.webServer": "Webserver",
"properties.dropHint": "While the button is held, every cable drops what it is carrying.",
"properties.droppedNone": "No dropped packets yet.",
"properties.restart": "Restart simulation",
"properties.restartAsk": "Restart the simulation for everyone? All logs and data packets are lost.",
"properties.restartHint": "Ends the simulation and starts it again, for everyone in the project: the programs' windows, the data exchange and the dropped packets begin empty. What the devices saved stays.",
"properties.restartNo": "Cancel",
"properties.restartYes": "Restart",
"properties.simulation": "Simulation",
"properties.title": "Properties",
"remote.connecting": "Connecting to the project …",
"remote.deleted": "The project was deleted.",
"remote.noAccess": "You have no access to this project.",
@@ -411,7 +419,6 @@
"sheet.cards": "Network cards",
"sheet.chooseNetwork": "Please select",
"sheet.close": "Close",
"sheet.seeThrough": "See-through",
"sheet.cloud": "Display as Cloud",
"sheet.connectedTo": "Connected to {device}",
"sheet.connection": "Connection",
@@ -442,6 +449,7 @@
"sheet.portForwarding": "Port Mapping",
"sheet.retention": "Retention Time SAT Entries (Seconds)",
"sheet.rip": "Automatic Routing",
"sheet.seeThrough": "See-through",
"sheet.showAllEntries": "Show all entries",
"sheet.subnetMask": "Netmask",
"sheet.switchSsid": "Wi-Fi Name (SSID)",
+48 -2
View File
@@ -59,7 +59,7 @@
}
/* The mode buttons (and the design's undo and redo, the simulation's
* "Pakete verwerfen") over the surface: they wrap on a narrow screen; the
* „Eigenschaften“) over the surface: they wrap on a narrow screen; the
* space between them lets taps through to the surface. */
.editor-toolbar {
position: absolute;
@@ -78,7 +78,7 @@
pointer-events: auto;
}
/* A word beside the mode buttons: who started the simulation, that this person only reads. */
/* A word beside the mode buttons: that this person only reads. */
.editor-note {
display: flex;
align-items: center;
@@ -534,3 +534,49 @@
background: var(--danger-soft);
color: var(--danger-on-soft);
}
/* The simulation's „Eigenschaften“ (PropertiesSheet): its two parts apart,
* "Pakete verwerfen" held red, the dropped frames in Java's monospace. */
.properties-sheet section + section {
margin-top: 20px;
padding-top: 4px;
border-top: 1px solid var(--border-subtle);
}
.properties-confirm p {
margin: 8px 0 0;
font-size: 14px;
}
.sheet-buttons .properties-restart {
background: var(--danger-soft);
border-color: var(--danger);
color: var(--danger-on-soft);
}
.drop-hold {
touch-action: none;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.sheet-buttons .drop-hold[aria-pressed='true'] {
background: var(--danger-soft);
border-color: var(--danger);
color: var(--danger-on-soft);
}
.dropped-list {
max-height: 320px;
margin: 8px 0 0;
overflow: auto;
padding: 6px;
border: 1px solid var(--border-default);
border-radius: 8px;
background: var(--surface-raised);
color: var(--text-primary);
font: 12px ui-monospace, Menlo, Consolas, monospace;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
@@ -1,78 +0,0 @@
// Java's "Pakete verwerfen" (SimulationControl): while the button is held,
// every cable drops what it carries; below it, the frames dropped in this
// simulation, each with its time of day and Java's words for it
// (EthernetFrame.toString). 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.
//
// Better than Java: the list folds away and shows how many it holds (Java's
// panel takes the bottom of the window).
import { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent as ReactPointerEvent } from 'react';
import { useTranslate } from '../i18n/i18n.js';
import { timeOfDay } from './timeOfDay.js';
import type { Simulation } from './useSimulation.js';
export function DropControl({ simulation }: { readonly simulation: Simulation }) {
const t = useTranslate();
const { watch, drop, dropped, clock } = simulation;
useEffect(() => 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<HTMLButtonElement>) => {
event.currentTarget.setPointerCapture(event.pointerId);
hold(true);
};
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if ((event.key === ' ' || event.key === 'Enter') && !event.repeat) {
event.preventDefault();
hold(true);
}
};
const onKeyUp = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === ' ' || event.key === 'Enter') hold(false);
};
return (
<div className="drop-control">
<div className="drop-control-bar">
<button
type="button"
className="drop-control-hold"
aria-pressed={held}
onPointerDown={onPointerDown}
onPointerCancel={() => hold(false)}
onLostPointerCapture={() => hold(false)}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
onBlur={() => hold(false)}
onContextMenu={(event) => event.preventDefault()}
>
{t('drop.hold')}
</button>
<button type="button" className="drop-control-toggle" aria-expanded={open} onClick={() => setOpen(!open)}>
{t('drop.list', { count: dropped.length })}
</button>
</div>
{open ? (
<pre className="drop-control-list" aria-label={t('drop.listLabel')} tabIndex={0}>
{dropped.map((unit) => `${timeOfDay(clock, unit.timeMilliseconds, true)} : ${unit.text}\n`).join('')}
</pre>
) : null}
</div>
);
}
@@ -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<HTMLButtonElement>) => {
event.currentTarget.setPointerCapture(event.pointerId);
hold(true);
};
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if ((event.key === ' ' || event.key === 'Enter') && !event.repeat) {
event.preventDefault();
hold(true);
}
};
const onKeyUp = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === ' ' || event.key === 'Enter') hold(false);
};
return (
<button
type="button"
className="drop-hold"
aria-pressed={held}
onPointerDown={onPointerDown}
onPointerCancel={() => hold(false)}
onLostPointerCapture={() => hold(false)}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
onBlur={() => hold(false)}
onContextMenu={(event) => event.preventDefault()}
>
{t('drop.hold')}
</button>
);
}
@@ -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 (
<aside
className="device-sheet properties-sheet"
aria-label={t("properties.title")}
>
<header>
<h2>{t("properties.title")}</h2>
<button
type="button"
className="sheet-close"
onClick={onClose}
aria-label={t("sheet.close")}
title={t("sheet.close")}
>
×
</button>
</header>
<section aria-labelledby="properties-simulation">
<h3 className="sheet-subtitle" id="properties-simulation">
{t("properties.simulation")}
</h3>
{startedBy === undefined ? null : (
<p className="sheet-note" data-note="startedBy">
{t("access.startedBy", { name: startedBy })}
</p>
)}
{readOnly ? null : asking ? (
<div
className="properties-confirm"
role="group"
aria-label={t("properties.restartAsk")}
>
<p>{t("properties.restartAsk")}</p>
<div className="sheet-buttons">
<button
type="button"
className="properties-restart"
onClick={() => {
setAsking(false);
onRestart();
}}
>
{t("properties.restartYes")}
</button>
<button type="button" onClick={() => setAsking(false)}>
{t("properties.restartNo")}
</button>
</div>
</div>
) : (
<>
<div className="sheet-buttons">
<button type="button" onClick={() => setAsking(true)}>
{t("properties.restart")}
</button>
</div>
<p className="sheet-note">{t("properties.restartHint")}</p>
</>
)}
</section>
<section aria-labelledby="properties-drop">
<h3 className="sheet-subtitle" id="properties-drop">
{t("drop.listLabel")} ({dropped.length})
</h3>
{readOnly ? null : (
<>
<div className="sheet-buttons">
<DropHold drop={drop} />
</div>
<p className="sheet-note">{t("properties.dropHint")}</p>
</>
)}
{dropped.length === 0 ? (
<p className="sheet-note">{t("properties.droppedNone")}</p>
) : (
<pre
className="dropped-list"
aria-label={t("drop.listLabel")}
tabIndex={0}
>
{dropped
.map(
(unit) =>
`${timeOfDay(clock, unit.timeMilliseconds, true)} : ${unit.text}\n`,
)
.join("")}
</pre>
)}
</section>
</aside>
);
}
@@ -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 };
}
+16 -4
View File
@@ -21,7 +21,7 @@ export type AppKind = (typeof APP_KINDS)[number];
*/
export type Topic = 'activity' | 'dropped' | `trace:${string}` | `app:${string}/${AppKind}` | `node:${string}`;
export 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<ClientMessage> {
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':
+3 -1
View File
@@ -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');
+5 -5
View File
@@ -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 });
}
+2 -1
View File
@@ -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') {
+6 -3
View File
@@ -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'));
+14 -1
View File
@@ -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<HostMessage, { kind: 'app' }> => 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' });
+25
View File
@@ -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;
+83
View File
@@ -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);
});
});