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
+4
View File
@@ -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
+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);
});
});
+2
View File
@@ -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',