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:
@@ -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();
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user