feat: F4.6g5 the switch and NAT tables and "Pakete verwerfen", measured against Java's

ViewersRun takes the rows of Java's viewers from the firmware: a switch's
table, a home router's NAT table, each after its reset, and the frames
"Pakete verwerfen" drops while it is held. The core writes a frame as Java
does and resets the NAT's dynamic entries; the room sends both tables as
views, drops while any client holds the button, and sends the dropped
frames, begun anew with each start, with the clock their times of day are
written from; the editor opens a table with a tap on a switch or home
router and holds "Pakete verwerfen" in its toolbar, which now wraps on a
phone. This completes F4.6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 22:13:48 +00:00
co-authored by Claude Opus 5.5
parent 5224e1a28b
commit 6809a71300
31 changed files with 1370 additions and 31 deletions
+1
View File
@@ -17,6 +17,7 @@ export * from './runtime/speed.js';
export * from './runtime/trace.js';
export * from './runtime/capture.js';
export * from './runtime/captureRows.js';
export * from './runtime/viewers.js';
export * from './runtime/threads.js';
export * from './runtime/apps/program.js';
export * from './runtime/apps/installation.js';
+10
View File
@@ -68,6 +68,11 @@ class CaptureList {
forgetIdentities(): void {
this.identities.clear();
}
/** Every frame forgotten, the numbers going on (the dropped frames' list, which keeps no identities). */
clear(): void {
this.frames.length = 0;
}
}
export class PacketCapture {
@@ -107,6 +112,11 @@ export class PacketCapture {
return this.dropped.frames;
}
/** The dropped frames forgotten (SimulationControl begins its list anew), their numbers going on. */
clearDropped(): void {
this.dropped.clear();
}
/** The simulation starts again: the lists stay, frames of the new run are new. */
beginRun(): void {
for (const list of this.listsByPortId.values()) list.forgetIdentities();
+12 -1
View File
@@ -15,7 +15,7 @@
// for an unknown transport protocol (all empty) does not occur.
import type { CapturedFrame } from './capture.js';
import { IPV4_PROTOCOL, TCP_FLAG, type ArpPacket, type IcmpMessage, type IPv4Packet } from './packets.js';
import { IPV4_PROTOCOL, TCP_FLAG, type ArpPacket, type EthernetFrame, type IcmpMessage, type IPv4Packet } from './packets.js';
/** Java's four layers (Lauscher.PROTOKOLL_SCHICHTEN): network access, internet, transport, application. */
export type CaptureLayer = 'networkAccess' | 'internet' | 'transport' | 'application';
@@ -182,6 +182,17 @@ function packetText(packet: IPv4Packet): string {
);
}
/**
* EthernetFrame.toString: addresses, Java's type (0x800 for IP, 0x806 for
* ARP) and what the frame carries — an IP packet without its segment, an
* ICMP packet whole, an ARP packet. SimulationControl lists dropped frames so.
*/
export function javaFrameText(frame: EthernetFrame): string {
const payload = frame.payload;
const data = payload.kind === 'arp' ? arpText(payload.packet) : packetText(payload.packet);
return `[src=${frame.sourceMacAddress}, dest=${frame.destinationMacAddress}, type=${payload.kind === 'arp' ? '0x806' : '0x800'} | ${data}]`;
}
/** Java's String.trim: cuts characters up to U+0020 at both ends. */
function javaTrim(text: string): string {
let start = 0;
+10
View File
@@ -276,6 +276,8 @@ export interface SwitchSatEntry {
readonly macAddress: string;
readonly portId: Id;
readonly portLabel: string;
/** The port's place among the switch's, from 1 (SwitchFirmware.holeSAT: "Port <n>"). */
readonly portNumber: number;
readonly updatedAtMilliseconds: number;
}
@@ -4060,6 +4062,12 @@ export class NetworkRuntimeEngine {
}
}
/** GatewayFirmware.loescheNAT (the NAT viewer's "reset"): the dynamic entries go, the port forwarding's stay. */
clearDynamicNatEntries(nodeId: Id): void {
const table = this.stateValue.natTablesByNodeId.get(nodeId);
if (table !== undefined) this.stateValue.natTablesByNodeId.set(nodeId, table.filter((entry) => entry.type === 'static'));
}
/** The home router's NAT table as it stands: static entries, then the dynamic ones that have not expired. */
natEntries(nodeId: Id): readonly NatEntry[] {
this.expireNatEntries(nodeId);
@@ -4729,6 +4737,7 @@ export class NetworkRuntimeEngine {
const node = this.nodeSnapshot(nodeId);
if (node === undefined || node.kind !== 'networkSwitch') return [];
const labelsByPortId = new Map(node.ports.map((port) => [port.id, port.label]));
const numbersByPortId = new Map(node.ports.map((port, index) => [port.id, index + 1]));
const updatedAtByMac = this.stateValue.switchForwardingUpdatedAtMillisecondsByNodeId.get(nodeId) ?? new Map();
const table = this.stateValue.switchForwardingTablesByNodeId.get(nodeId) ?? new Map<string, Id>();
return [...table]
@@ -4736,6 +4745,7 @@ export class NetworkRuntimeEngine {
macAddress,
portId,
portLabel: labelsByPortId.get(portId) ?? '?',
portNumber: numbersByPortId.get(portId) ?? 0,
updatedAtMilliseconds: updatedAtByMac.get(macAddress) ?? 0,
}))
.sort((lhs, rhs) =>
+22
View File
@@ -0,0 +1,22 @@
// The rows of FILIUS' viewers of a running simulation, as Java writes them:
// a home router's NAT table (GatewayFirmware.holeNAT, createEintrag, shown by
// NatViewer). A switch's table (SwitchFirmware.holeSAT) is the engine's
// switchSatEntries with the port's number; the time of an entry is the
// viewer's to write, in its clock.
import type { NatEntry } from './engine.js';
/** A NAT entry's cells: protocol, WAN port, remote address, LAN address, LAN port; and its last update, none for the port forwarding's. */
export interface NatViewRow {
readonly cells: readonly [string, string, string, string, string];
readonly updatedAtMilliseconds?: number;
}
const PROTOCOL_NAMES: Readonly<Record<NatEntry['protocol'], string>> = { tcp: 'TCP', udp: 'UDP', icmp: 'ICMP' };
/** createEintrag: an ICMP entry's "port" is its identifier ("0 (Identifier: 1)"); the port forwarding's remote end is "*". */
export function natViewRow(entry: NatEntry): NatViewRow {
const port = (value: number) => (entry.protocol === 'icmp' ? `0 (Identifier: ${value})` : String(value));
const cells = [PROTOCOL_NAMES[entry.protocol], port(entry.port), entry.type === 'static' ? '*' : entry.remoteIPAddress, entry.lan.ipAddress, port(entry.lan.port)] as const;
return entry.type === 'static' ? { cells } : { cells, updatedAtMilliseconds: entry.updatedAtMilliseconds };
}
+67
View File
@@ -0,0 +1,67 @@
{
"startEpoch": 1791063964929,
"steps": [
{
"step": "start",
"at": 720,
"rows": [
[
"TCP",
"8080",
"*",
"192.168.0.10",
"80",
"-"
]
]
},
{
"step": "after ping and datagram",
"at": 2926,
"rows": [
[
"TCP",
"8080",
"*",
"192.168.0.10",
"80",
"-"
],
[
"ICMP",
"0 (Identifier: 1)",
"42.0.0.20",
"192.168.0.10",
"0 (Identifier: 1)",
"21:46:06"
],
[
"UDP",
"61570",
"42.0.0.20",
"192.168.0.10",
"51179",
"21:46:06"
]
],
"answers": {
"ping 42.0.0.20": "ICMP 0/0",
"udp port": 51179
}
},
{
"step": "reset",
"at": 2926,
"rows": [
[
"TCP",
"8080",
"*",
"192.168.0.10",
"80",
"-"
]
]
}
]
}
@@ -0,0 +1,90 @@
{
"startEpoch": 1791063953143,
"steps": [
{
"step": "after pings",
"at": 1632,
"rows": [
[
"02:00:00:00:01:11",
"Port 1",
"21:45:54"
],
[
"02:00:00:00:01:13",
"Port 3",
"21:45:54"
],
[
"02:00:00:00:01:12",
"Port 2",
"21:45:54"
]
],
"updated": {
"02:00:00:00:01:11": 1464,
"02:00:00:00:01:13": 1575,
"02:00:00:00:01:12": 1129
},
"ports": 24,
"answers": {
"ping .20": "ICMP 0/0",
"ping .30": "ICMP 0/0"
}
},
{
"step": "reset",
"at": 1632,
"rows": [],
"updated": {},
"ports": 24
},
{
"step": "ping .20 again",
"at": 1855,
"rows": [
[
"02:00:00:00:01:11",
"Port 1",
"21:45:54"
],
[
"02:00:00:00:01:12",
"Port 2",
"21:45:54"
]
],
"updated": {
"02:00:00:00:01:11": 1688,
"02:00:00:00:01:12": 1799
},
"ports": 24,
"answers": {
"ping .20": "ICMP 0/0"
}
},
{
"step": "after the retention",
"at": 7855,
"rows": [],
"updated": {},
"ports": 24
},
{
"step": "dropped while held",
"at": 10606,
"answer": "null",
"dropped": [
"21:46:01.054 : [src=02:00:00:00:01:11, dest=02:00:00:00:01:13, type=0x800 | [id=7; ttl=64; protocol=1; sender=192.168.0.10; target=192.168.0.30; icmpType=8; icmpCode=0; identifier=1; seqNr=4]]"
]
},
{
"step": "released",
"at": 10829,
"answer": "ICMP 0/0",
"dropped": [
"21:46:01.054 : [src=02:00:00:00:01:11, dest=02:00:00:00:01:13, type=0x800 | [id=7; ttl=64; protocol=1; sender=192.168.0.10; target=192.168.0.30; icmpType=8; icmpCode=0; identifier=1; seqNr=4]]"
]
}
]
}
+128
View File
@@ -0,0 +1,128 @@
// What FILIUS' viewers of a running simulation show, against Java's
// (viewers-switch.java.json, viewers-nat.java.json; ViewersRun): a switch's
// source address table after pings, after its reset and after the retention;
// a home router's NAT table after a ping and a datagram, and after its reset
// (the port forwarding's entry stays); the frames "Pakete verwerfen" drops
// while it is held, in Java's words. Java's time of day is the viewer's to
// write (the editor's), and its table of a switch comes in the order of
// Java's hash map, here by address.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { javaFrameText, natViewRow, NetworkRuntimeEngine, type Steps } from '../src/index.js';
import { snapshotOf, type Scenario } from './scenario.js';
interface JavaStep {
readonly step: string;
readonly at: number;
readonly rows?: readonly (readonly string[])[];
readonly updated?: Readonly<Record<string, number>>;
readonly answer?: string;
readonly answers?: Readonly<Record<string, string | number>>;
readonly dropped?: readonly string[];
}
const here = dirname(fileURLToPath(import.meta.url));
const javaRun = (name: string) => JSON.parse(readFileSync(join(here, `viewers-${name}.java.json`), 'utf8')) as { readonly steps: readonly JavaStep[] };
const scenarioOf = (name: string) => JSON.parse(readFileSync(join(here, `scenarios/${name}.scenario.json`), 'utf8')) as Scenario;
function started(scenario: Scenario) {
const engine = new NetworkRuntimeEngine(scenario.seed);
engine.handle({ type: 'start', snapshot: snapshotOf(scenario), seed: scenario.seed, initialTimeMilliseconds: 0 });
const run = (milliseconds: number) => engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + milliseconds });
/** A ping, returning when its answer came or its wait ended, as Java's sendProbe blocks. */
const ping = (from: string, target: string, sequenceNumber: number): string => {
let answer: string | undefined;
engine.startThread(
(function* (): Steps {
const reply = yield* engine.icmpEchoSteps(from, target, { identifier: 1, sequenceNumber });
answer = reply === undefined ? 'null' : `ICMP ${reply.message.kind === 'echoReply' ? '0/0' : reply.message.kind}`;
})(),
);
for (let waited = 0; answer === undefined && waited < 30_000; waited += 10) run(10);
return answer ?? 'null';
};
return { engine, run, ping };
}
describe("a switch's table and the dropped frames, against Java's", () => {
const java = javaRun('switch');
const at = (name: string) => java.steps.find((step) => step.step === name)!;
const SWITCH = '00000000-0000-0000-0000-000000000104';
const PC10 = '00000000-0000-0000-0000-000000000101';
it('learns, is reset, ages, and lists what is dropped while it is held', () => {
const { engine, run, ping } = started(scenarioOf('switch-lan'));
run(500);
/** The table as Java's viewer shows it, without the time: address in capitals, "Port <n>". */
const table = () => engine.switchSatEntries(SWITCH).map((entry) => [entry.macAddress.toUpperCase(), `Port ${entry.portNumber}`]);
const javaTable = (name: string) => at(name).rows!.map(([mac = '', port = '']) => [mac, port]);
const sorted = (rows: string[][]) => [...rows].sort(([lhs], [rhs]) => (lhs! < rhs! ? -1 : 1));
expect([ping(PC10, '192.168.0.20', 1), ping(PC10, '192.168.0.30', 2)]).toEqual(Object.values(at('after pings').answers!));
// By address (Java's in its hash map's order).
expect(table()).toEqual(sorted(javaTable('after pings')));
expect(table()).not.toEqual(javaTable('after pings'));
engine.clearSwitchSat(SWITCH);
expect(table()).toEqual(javaTable('reset'));
expect(ping(PC10, '192.168.0.20', 3)).toBe(at('ping .20 again').answers!['ping .20']);
expect(table()).toEqual(sorted(javaTable('ping .20 again')));
run(6000);
expect(table()).toEqual(javaTable('after the retention'));
// "Pakete verwerfen" held while .10 pings .30: the request is dropped on its first cable; released, the ping is answered.
engine.setGlobalPacketLossEnabled(true);
const dropped = ping(PC10, '192.168.0.30', 4);
engine.setGlobalPacketLossEnabled(false);
const text = (step: JavaStep) => step.dropped!.map((line) => line.slice(line.indexOf(' : ') + 3));
const ours = () => engine.capture.droppedFrames().map((each) => javaFrameText(each.frame));
const sameButIdentity = (lines: readonly string[]) => lines.map((line) => line.replace(/\[id=\d+;/, '[id=ID;'));
expect(dropped).toBe(at('dropped while held').answer);
expect(sameButIdentity(ours())).toEqual(sameButIdentity(text(at('dropped while held'))));
expect(ping(PC10, '192.168.0.30', 5)).toBe(at('released').answer);
expect(sameButIdentity(ours())).toEqual(sameButIdentity(text(at('released'))));
// Java's time of the drop is the time of day with milliseconds.
expect(at('released').dropped![0]).toMatch(/^\d\d:\d\d:\d\d\.\d\d\d : \[src=/);
});
});
describe("a home router's NAT table, against Java's", () => {
const java = javaRun('nat');
const at = (name: string) => java.steps.find((step) => step.step === name)!;
const GATEWAY = '00000000-0000-0000-0000-000000000502';
const LAN_PC = '00000000-0000-0000-0000-000000000501';
it('shows the port forwarding, a ping and a datagram, and keeps the port forwarding when reset', () => {
const { engine, run, ping } = started(scenarioOf('nat-home'));
run(500);
const rows = () =>
engine.natEntries(GATEWAY).map((entry) => {
const row = natViewRow(entry);
return [...row.cells, row.updatedAtMilliseconds === undefined ? '-' : 'TIME'];
});
/** Java's rows with a time of day as TIME, and the datagram's ports (drawn) as PORT. */
const javaRows = (name: string) =>
at(name).rows!.map((row) => (row[0] === 'UDP' ? [row[0], 'PORT', row[2], row[3], 'PORT', 'TIME'] : row.map((cell, index) => (index === 5 && cell !== '-' ? 'TIME' : cell))));
expect(rows()).toEqual(javaRows('start'));
expect(ping(LAN_PC, '42.0.0.20', 1)).toBe(at('after ping and datagram').answers!['ping 42.0.0.20']);
const socket = engine.bindUdpSocket(LAN_PC, { remoteIPAddress: '42.0.0.20', remotePort: 7 })!;
engine.sendUdp(socket, new TextEncoder().encode('one'));
run(1500);
expect(rows().map((row) => (row[0] === 'UDP' ? [row[0], 'PORT', row[2], row[3], 'PORT', row[5]] : row))).toEqual(javaRows('after ping and datagram'));
engine.clearDynamicNatEntries(GATEWAY);
expect(rows()).toEqual(javaRows('reset'));
});
it('writes an entry as Java’s createEintrag does', () => {
expect([
natViewRow({ protocol: 'tcp', remoteIPAddress: '', port: 8080, lan: { ipAddress: '192.168.0.10', port: 80 }, type: 'static', updatedAtMilliseconds: 5 }),
natViewRow({ protocol: 'icmp', remoteIPAddress: '42.0.0.20', port: 3, lan: { ipAddress: '192.168.0.10', port: 4 }, type: 'dynamic', updatedAtMilliseconds: 7 }),
natViewRow({ protocol: 'udp', remoteIPAddress: '42.0.0.30', port: 8080, lan: { ipAddress: '192.168.0.10', port: 80 }, type: 'dynamicFromStatic', updatedAtMilliseconds: 9 }),
]).toEqual([
{ cells: ['TCP', '8080', '*', '192.168.0.10', '80'] },
{ cells: ['ICMP', '0 (Identifier: 3)', '42.0.0.20', '192.168.0.10', '0 (Identifier: 4)'], updatedAtMilliseconds: 7 },
{ cells: ['UDP', '8080', '42.0.0.30', '192.168.0.10', '80'], updatedAtMilliseconds: 9 },
]);
});
});
+114
View File
@@ -0,0 +1,114 @@
// The viewers of a running simulation and "Pakete verwerfen" in Chromium
// (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.
import { expect, test, type Locator, type Page } from '@playwright/test';
import { drawing, onDevice, openEditor, serveEditor } from './editorPage.js';
let server: { readonly base: string; readonly close: () => Promise<void> };
test.beforeAll(async () => {
server = await serveEditor();
});
test.afterAll(async () => {
await server.close();
});
async function tap(page: Page, id: string) {
const at = await onDevice(page, id);
await page.mouse.click(at.x, at.y);
}
async function install(desktop: Locator, program: string) {
await desktop.locator('[data-program="installation"]').click();
const installation = desktop.locator('[data-program-window="installation"]');
await installation.locator('[data-list="available"] [role="option"]').filter({ hasText: program }).click();
await installation.getByRole('button', { name: 'Installieren' }).click();
await installation.getByRole('button', { name: 'Änderungen annehmen' }).click();
}
test('shows the tables of a home router and a switch, and drops while “Pakete verwerfen” is held', async ({ page }) => {
test.setTimeout(120_000);
await openEditor(page, server.base, 'nat_with_mapped_port.fls');
const nodes = (await drawing(page)).nodes;
const idOf = (name: string) => nodes.find((node) => node.name === name)!.id;
await page.getByRole('button', { name: 'Wechselt in den Aktionsmodus' }).click();
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation');
// The home router with the port forwarding: its entry, with Java's columns.
const gateway = idOf('Heimrouter Vincent');
await tap(page, gateway);
const nat = page.locator(`section[data-viewer="${gateway}"]`);
await expect(nat.locator('h2')).toHaveText('NAT-Tabelle Heimrouter Vincent');
await expect(nat.locator('th')).toHaveText(['Protokoll', 'Portfreigabe', 'WAN-Adresse', 'LAN-Adresse', 'LAN-Port', 'Letzte Aktualisierung']);
await expect(nat.locator('tbody tr')).toHaveCount(1);
await expect(nat.locator('tbody tr').first().locator('td')).toHaveText(['TCP', '80', '*', '192.168.0.10', '80', '-']);
await nat.locator('.desktop-close').click();
// The web server behind it pings out.
const web = idOf('Webserver - 192.168.0.10');
await tap(page, web);
const desktop = page.locator(`section[data-desktop="${web}"]`);
await install(desktop, 'Befehlszeile');
await desktop.locator('[data-program="commandPrompt"]').click();
const input = desktop.locator('input[name="terminalInput"]');
await input.fill('ping 141.99.4.10');
await input.press('Enter');
await expect(desktop.locator('.terminal-text')).toContainText('4 Paket(e) empfangen', { timeout: 30_000 });
await desktop.locator('.desktop-close').click();
await tap(page, gateway);
await expect(nat.locator('tbody tr')).toHaveCount(2);
await expect(nat.locator('tbody tr').nth(1).locator('td').nth(0)).toHaveText('ICMP');
await expect(nat.locator('tbody tr').nth(1).locator('td').nth(5)).toHaveText(/^\d\d:\d\d:\d\d$/);
await nat.getByRole('button', { name: 'Tabelle zurücksetzen' }).click();
await expect(nat.locator('tbody tr')).toHaveCount(1);
await nat.locator('.desktop-close').click();
// The switch the web server hangs on: the addresses the ping taught it.
const link = (await drawing(page)).links.find((each) => each.sourceNode === web || each.targetNode === web)!;
const lanSwitch = link.sourceNode === web ? link.targetNode : link.sourceNode;
await tap(page, lanSwitch);
const sat = page.locator(`section[data-viewer="${lanSwitch}"]`);
await expect(sat.locator('th')).toHaveText(['MAC', 'Port', 'Letzte Aktualisierung']);
await expect(sat.locator('tbody tr').first().locator('td')).toHaveText([/^[0-9A-F:]{17}$/, /^Port \d+$/, /^\d\d:\d\d:\d\d$/]);
await sat.getByRole('button', { name: 'Tabelle löschen' }).click();
await expect(sat.locator('tbody tr')).toHaveCount(0);
await sat.locator('.desktop-close').click();
// "Pakete verwerfen" held while a ping runs: answers lost, the frames listed.
await tap(page, web);
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' });
const box = (await hold.boundingBox())!;
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await expect(hold).toHaveAttribute('aria-pressed', 'true');
await page.waitForTimeout(2_500);
await page.mouse.up();
await expect(hold).toHaveAttribute('aria-pressed', 'false');
// 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/);
});
test('keeps “Pakete verwerfen” in reach on a phone', 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);
});
+37 -11
View File
@@ -22,6 +22,8 @@ import { DeviceSheet } from './sheet/DeviceSheet.js';
import { DesktopWindow, type DesktopWindowName } from './desktop/DesktopWindow.js';
import { useLanguage, useTranslate } from './i18n/i18n.js';
import { ModeBar } from './simulation/ModeBar.js';
import { DropControl } from './simulation/DropControl.js';
import { ViewerWindow, type ViewerKind } from './simulation/ViewerWindow.js';
import { useSimulation, type SimHostFactory } from './simulation/useSimulation.js';
/** The document's drawing, read anew after every change. */
@@ -73,9 +75,10 @@ const toolButton: CSSProperties = {
/** 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));
/** A desktop open on the surface: whose, where its window lies, and how far up (the last one touched on top). */
/** A desktop open on the surface — or a switch's or home router's viewer (`viewer`) —: whose, where its window lies, and how far up (the last one touched on top). */
interface OpenDesktop {
readonly id: string;
readonly viewer?: ViewerKind;
readonly x: number;
readonly y: number;
readonly z: number;
@@ -130,18 +133,22 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [simulation.failed]);
// A desktop whose computer is gone, or is no computer any more, closes (Java: reopenDialogs).
// A desktop whose computer is gone, or is no computer any more, closes (Java: reopenDialogs); so does a viewer whose switch or home router is.
useEffect(() => {
const computers = new Set(view.nodes.filter((node) => node.kind === 'pc' || node.kind === 'notebook').map((node) => node.id));
setDesktops((open) => (open.every((each) => computers.has(each.id)) ? open : open.filter((each) => computers.has(each.id))));
const kinds = new Map(view.nodes.map((node) => [node.id, node.kind]));
const fits = (each: OpenDesktop) => {
const kind = kinds.get(each.id);
return each.viewer === 'sat' ? kind === 'networkSwitch' : each.viewer === 'nat' ? kind === 'gateway' : kind === 'pc' || kind === 'notebook';
};
setDesktops((open) => (open.every(fits) ? open : open.filter(fits)));
}, [view]);
/** Opens a computer's desktop, or brings it to the front. */
const openDesktop = useCallback((id: string) => {
/** Opens a computer's desktop, or a switch's or home router's viewer, or brings it to the front. */
const openDesktop = useCallback((id: string, viewer?: ViewerKind) => {
setDesktops((open) => {
if (open.some((each) => each.id === id)) return toFront(open, id);
// Java stacks a desktop that does not fit beside the others a little further on.
return [...open, { id, x: 24 + 24 * open.length, y: 76 + 24 * open.length, z: Math.max(0, ...open.map((each) => each.z)) + 1, program: null }];
return [...open, { id, ...(viewer === undefined ? {} : { viewer }), x: 24 + 24 * open.length, y: 76 + 24 * open.length, z: Math.max(0, ...open.map((each) => each.z)) + 1, program: null }];
});
}, []);
@@ -275,6 +282,9 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
onActivate={(id) => {
const kind = view.nodes.find((node) => node.id === id)?.kind;
if (kind === 'pc' || kind === 'notebook') openDesktop(id);
// Java's click on a switch shows its table, on a home router its NAT table.
else if (kind === 'networkSwitch') openDesktop(id, 'sat');
else if (kind === 'gateway') openDesktop(id, 'nat');
}}
onMenu={(target, at) => {
if (simulating) {
@@ -285,9 +295,9 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
setMenu({ target, at });
}}
/>
<div role="toolbar" style={{ position: 'absolute', left: 12, top: 12, zIndex: 50, display: 'flex', gap: 6, alignItems: 'center' }}>
<div role="toolbar" className="editor-toolbar">
<ModeBar mode={simulation.mode} speed={simulation.speed} onMode={(mode) => (mode === 'simulation' ? simulation.start(language) : simulation.stop())} onSpeed={simulation.setSpeed} />
{simulating ? null : (
{simulating ? <DropControl simulation={simulation} /> : (
<>
<button type="button" style={toolButton} disabled={!canUndo} onClick={() => manager?.undo()} aria-label={t('design.undo')} title={t('design.undo')}>
↶
@@ -299,7 +309,22 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
)}
</div>
{simulating
? desktops.map((desktop) => (
? desktops.map((desktop) =>
desktop.viewer !== undefined ? (
<ViewerWindow
key={desktop.id}
kind={desktop.viewer}
id={desktop.id}
label={view.nodes.find((node) => node.id === desktop.id)?.label ?? ''}
simulation={simulation}
x={desktop.x}
y={desktop.y}
z={desktop.z}
onMove={(x, y) => setDesktops((open) => open.map((each) => (each.id === desktop.id ? { ...each, x, y } : each)))}
onFront={() => setDesktops((open) => toFront(open, desktop.id))}
onClose={() => setDesktops((open) => open.filter((each) => each.id !== desktop.id))}
/>
) : (
<DesktopWindow
key={desktop.id}
simulation={simulation}
@@ -316,7 +341,8 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
onFront={() => setDesktops((open) => toFront(open, desktop.id))}
onClose={() => setDesktops((open) => open.filter((each) => each.id !== desktop.id))}
/>
))
),
)
: null}
{menu === null ? null : <ContextMenu at={menu.at} items={menuItems(menu.target)} onClose={() => setMenu(null)} />}
{routerAt === null ? null : (
+90
View File
@@ -1251,3 +1251,93 @@
.gnutella-table tbody tr[tabindex] {
cursor: pointer;
}
/* A switch's or home router's viewer (SatViewer, NatViewer): a table and its
* reset, in a window as small as Java's dialog allows the NAT table's six columns. */
.viewer-window {
width: 600px;
height: 320px;
}
.viewer-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
overflow: auto;
background: var(--surface-card);
color: var(--text-primary);
}
.viewer-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.viewer-table th,
.viewer-table td {
padding: 6px;
border-bottom: 1px solid var(--border-default);
text-align: left;
overflow-wrap: anywhere;
}
.viewer-table th {
font-weight: 600;
}
.viewer-bar button,
.drop-control button {
min-height: 44px;
padding: 0 12px;
border: 1px solid var(--border-default);
border-radius: 8px;
background: var(--surface-raised);
color: var(--text-primary);
font: inherit;
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;
}
+16
View File
@@ -84,6 +84,9 @@
"dnsServer.tabMX": "Mailaustausch (MX)",
"dnsServer.tabNS": "Nameserver (NS)",
"dnsServer.tables": "Einträge",
"drop.hold": "Pakete verwerfen",
"drop.list": "Verworfen: {count}",
"drop.listLabel": "Verworfene Pakete",
"echoServer.log": "Ausgabe",
"echoServer.port": "Port:",
"echoServer.start": "Starten",
@@ -386,6 +389,19 @@
"simpleClient.port": "Server-Port:",
"simpleClient.receiveFailed": "Keine Verbindung hergestellt!",
"simpleClient.send": "Senden",
"tables.lanAddress": "LAN-Adresse",
"tables.lanPort": "LAN-Port",
"tables.lastUpdate": "Letzte Aktualisierung",
"tables.mac": "MAC",
"tables.natReset": "Tabelle zurücksetzen",
"tables.natTitle": "NAT-Tabelle",
"tables.port": "Port",
"tables.portMapping": "Portfreigabe",
"tables.portNumber": "Port",
"tables.protocol": "Protokoll",
"tables.satReset": "Tabelle löschen",
"tables.satTitle": "SAT Tabelle",
"tables.wanAddress": "WAN-Adresse",
"terminal.commands": "Befehle",
"terminal.ctrlC": "Strg+C",
"terminal.input": "Befehl eingeben",
+16
View File
@@ -84,6 +84,9 @@
"dnsServer.tabMX": "Mail exchange (MX)",
"dnsServer.tabNS": "Name server (NS)",
"dnsServer.tables": "Records",
"drop.hold": "Drop Packets",
"drop.list": "Dropped: {count}",
"drop.listLabel": "Dropped packets",
"echoServer.log": "Output",
"echoServer.port": "Port:",
"echoServer.start": "Start",
@@ -386,6 +389,19 @@
"simpleClient.port": "Server port:",
"simpleClient.receiveFailed": "No connection established!",
"simpleClient.send": "Send",
"tables.lanAddress": "LAN Address",
"tables.lanPort": "LAN Port",
"tables.lastUpdate": "Last Update",
"tables.mac": "MAC",
"tables.natReset": "Reset table",
"tables.natTitle": "NAT Table",
"tables.port": "Port",
"tables.portMapping": "Port Mapping",
"tables.portNumber": "Port",
"tables.protocol": "Protocol",
"tables.satReset": "Clear Table",
"tables.satTitle": "SAT table",
"tables.wanAddress": "WAN Address",
"terminal.commands": "Commands",
"terminal.ctrlC": "Ctrl+C",
"terminal.input": "Enter a command",
+20
View File
@@ -7,6 +7,26 @@
display: flex;
}
/* 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
* space between them lets taps through to the surface. */
.editor-toolbar {
position: absolute;
left: 12px;
top: 12px;
right: 12px;
z-index: 50;
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
pointer-events: none;
}
.editor-toolbar > * {
pointer-events: auto;
}
.editor-area {
position: relative;
flex: 1;
@@ -0,0 +1,78 @@
// 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,103 @@
// A switch's or a home router's viewer while the simulation runs, after
// Java's SatViewer and NatViewer (measured with tools/java-oracle's
// ViewersRun): a window titled "SAT Tabelle <name>" or "NAT-Tabelle <name>"
// with the table — MAC, port, last update; protocol, port mapping, WAN
// address, LAN address, LAN port, last update — and the table reset. A tap on
// the device opens it, as Java's click does. The tables are the room's.
//
// Better than Java: the reset is a button (Java's is in a menu of the right
// mouse button), and a switch's addresses come in their order (Java's in its
// hash map's).
import { useEffect, useState, type PointerEvent as ReactPointerEvent } from 'react';
import type { NatView, SatView } from '@mdg-filius/protocol';
import { ICON_URLS } from '../canvas/icons.js';
import { useTranslate } from '../i18n/i18n.js';
import { timeOfDay } from './timeOfDay.js';
import type { Simulation } from './useSimulation.js';
export type ViewerKind = 'sat' | 'nat';
export interface ViewerWindowProps {
readonly kind: ViewerKind;
readonly id: string;
readonly label: string;
readonly simulation: Simulation;
readonly x: number;
readonly y: number;
readonly z: number;
readonly onMove: (x: number, y: number) => void;
readonly onFront: () => void;
readonly onClose: () => void;
}
export function ViewerWindow({ kind, id, label, simulation, x, y, z, onMove, onFront, onClose }: ViewerWindowProps) {
const t = useTranslate();
const topic = `app:${id}/${kind}` as const;
const { watch, act, clock } = simulation;
useEffect(() => watch([topic]), [watch, topic]);
const view = simulation.views.get(topic) as SatView | NatView | null | undefined;
const [drag, setDrag] = useState<{ readonly pointer: number; readonly dx: number; readonly dy: number; moved: boolean } | null>(null);
const title = `${t(kind === 'sat' ? 'tables.satTitle' : 'tables.natTitle')} ${label}`;
// The title bar moves the window; the pointer is captured once it moves (pointer-capture trap).
const onPointerDown = (event: ReactPointerEvent<HTMLElement>) => {
if ((event.target as Element).closest('button') !== null) return;
setDrag({ pointer: event.pointerId, dx: event.clientX - x, dy: event.clientY - y, moved: false });
};
const onPointerMove = (event: ReactPointerEvent<HTMLElement>) => {
if (drag === null || drag.pointer !== event.pointerId) return;
if (!drag.moved) {
drag.moved = true;
event.currentTarget.setPointerCapture(event.pointerId);
}
onMove(Math.max(0, event.clientX - drag.dx), Math.max(0, event.clientY - drag.dy));
};
const onPointerUp = () => setDrag(null);
const columns =
kind === 'sat' ? ['tables.mac', 'tables.port', 'tables.lastUpdate'] : ['tables.protocol', 'tables.portMapping', 'tables.wanAddress', 'tables.lanAddress', 'tables.lanPort', 'tables.lastUpdate'];
const rows: readonly (readonly string[])[] =
view === null || view === undefined
? []
: kind === 'sat'
? (view as SatView).rows.map((row) => [row.macAddress, `${t('tables.portNumber')} ${row.port}`, timeOfDay(clock, row.updatedAtMilliseconds)])
: (view as NatView).rows.map((row) => [...row.cells, row.updatedAtMilliseconds === undefined ? '-' : timeOfDay(clock, row.updatedAtMilliseconds)]);
return (
<section className="desktop-window viewer-window" role="dialog" aria-label={title} data-viewer={id} style={{ left: x, top: y, zIndex: 5 + z }} onPointerDownCapture={onFront}>
<header className="desktop-titlebar" onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp}>
<img src={ICON_URLS[kind === 'sat' ? 'switch' : 'router']} alt="" />
<h2>{title}</h2>
<button type="button" className="desktop-close" aria-label={t('sheet.close')} title={t('sheet.close')} onClick={onClose}>
×
</button>
</header>
<div className="viewer-body">
<table className="viewer-table" aria-label={title}>
<thead>
<tr>
{columns.map((column) => (
<th key={column}>{t(column)}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((cells, row) => (
<tr key={row}>
{cells.map((cell, column) => (
<td key={column}>{cell}</td>
))}
</tr>
))}
</tbody>
</table>
<div className="viewer-bar">
<button type="button" onClick={() => act(id, kind, 'reset')}>
{t(kind === 'sat' ? 'tables.satReset' : 'tables.natReset')}
</button>
</div>
</div>
</section>
);
}
@@ -0,0 +1,13 @@
// A time of the simulation as Java's viewers write the wall clock's: its
// hours, minutes and seconds (SimpleDateFormat "HH:mm:ss"), with the
// milliseconds where the list of dropped frames has them
// (Lauscher.formatTimestamp), in the viewer's time zone.
const two = (value: number) => String(value).padStart(2, '0');
/** The time of day of the simulation's time `milliseconds`, its time 0 standing for `clock` (milliseconds since 1970). */
export function timeOfDay(clock: number, milliseconds: number, withMilliseconds = false): string {
const at = new Date(clock + milliseconds);
const text = `${two(at.getHours())}:${two(at.getMinutes())}:${two(at.getSeconds())}`;
return withMilliseconds ? `${text}.${String(at.getMilliseconds()).padStart(3, '0')}` : text;
}
@@ -6,7 +6,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type * as Y from 'yjs';
import type { AppKind, NodeSettings, SimHost, SimMode, Topic } from '@mdg-filius/protocol';
import type { AppKind, DroppedUnit, NodeSettings, SimHost, SimMode, Topic } from '@mdg-filius/protocol';
import { LocalSimHost } from '@mdg-filius/sim';
import SimWorker from '@mdg-filius/sim/worker?worker';
import type { Language } from '../i18n/i18n.js';
@@ -40,10 +40,16 @@ export interface Simulation {
readonly views: ReadonlyMap<string, unknown>;
/** Whether the core failed and the room went back to design mode, since the last start. */
readonly failed: boolean;
/** The time of day (milliseconds since 1970) the simulation's time 0 stands for: a viewer's times of day are written from it. */
readonly clock: number;
/** The frames "Pakete verwerfen" dropped in this simulation, while `dropped` is watched. */
readonly dropped: readonly DroppedUnit[];
/** 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;
setSpeed(value: number): void;
/** Java's "Pakete verwerfen": held (true) or let go (false). */
drop(on: boolean): void;
/** Watches these topics until the returned function is called; a topic two parts watch is sent for once. */
watch(topics: readonly Topic[]): () => void;
/** An action in a program's window (the room answers with its new lines). */
@@ -62,6 +68,8 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
const [failed, setFailed] = useState(false);
const [apps, setApps] = useState<ReadonlyMap<string, AppLines>>(new Map());
const [views, setViews] = useState<ReadonlyMap<string, unknown>>(new Map());
const [clock, setClock] = useState(0);
const [dropped, setDropped] = useState<readonly DroppedUnit[]>([]);
useEffect(() => {
const made = createHost(doc);
@@ -71,6 +79,7 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
case 'state':
setMode(message.mode);
setSpeedState(message.speed);
setClock(message.clock);
if (message.mode === 'design') setActiveLinks(NO_LINKS);
return;
case 'activity':
@@ -88,6 +97,9 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
return new Map(before).set(topic, { lines, live: message.from === 0 ? lines.length : (before.get(topic)?.live ?? 0) });
});
return;
case 'dropped':
setDropped((before) => (message.reset ? message.units : [...before, ...message.units]));
return;
case 'view':
setViews((before) => new Map(before).set(`app:${message.node}/${message.app}`, message.view));
return;
@@ -112,6 +124,7 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
}, []);
const stop = useCallback(() => host.current?.send({ v: 1, kind: 'stop' }), []);
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 watch = useCallback((topics: readonly Topic[]) => {
const fresh = topics.filter((topic) => {
const count = watched.current.get(topic) ?? 0;
@@ -134,5 +147,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, start, stop, setSpeed, watch, act };
return { mode, speed, activeLinks, nodes, apps, views, failed, clock, dropped, start, stop, setSpeed, drop, watch, act };
}
+14
View File
@@ -206,6 +206,20 @@ const FROM_JAVA: Readonly<Record<string, string>> = {
'gnutella.networkTab': 'peertopeeranwendung_msg29',
'gnutella.leave': 'peertopeeranwendung_msg30',
'gnutella.leaveHint': 'peertopeeranwendung_msg31',
'tables.satTitle': 'guievents_msg8',
'tables.mac': 'guievents_msg9',
'tables.port': 'guievents_msg10',
'tables.lastUpdate': 'guievents_msg27',
'tables.satReset': 'guievents_msg28',
'tables.natTitle': 'guievents_msg29',
'tables.protocol': 'guievents_msg30',
'tables.wanAddress': 'guievents_msg31',
'tables.portMapping': 'guievents_msg32',
'tables.lanAddress': 'guievents_msg33',
'tables.lanPort': 'guievents_msg34',
'tables.natReset': 'guievents_msg36',
'tables.portNumber': 'sw_switchfirmware_msg1',
'drop.hold': 'simulation_control_drop_packets',
'personalFirewall.hint': 'firewall_msg2',
'personalFirewall.port': 'firewall_msg3',
'personalFirewall.allSenders': 'firewall_msg4',
+16
View File
@@ -0,0 +1,16 @@
// A simulation time as Java's viewers write the wall clock: HH:mm:ss, and
// with milliseconds for the dropped frames (Lauscher.formatTimestamp), in the
// viewer's time zone.
import { describe, expect, it } from 'vitest';
import { timeOfDay } from '../src/simulation/timeOfDay.js';
describe('a time of day', () => {
it('pads hours, minutes, seconds and milliseconds as Java does', () => {
const clock = new Date(2026, 9, 3, 7, 5, 3, 4).getTime();
expect(timeOfDay(clock, 0)).toBe('07:05:03');
expect(timeOfDay(clock, 0, true)).toBe('07:05:03.004');
expect(timeOfDay(clock, 56_996 + 3_600_000 * 15, true)).toBe('22:06:00.000');
expect(timeOfDay(clock, 96, true)).toBe('07:05:03.100');
});
});
+1
View File
@@ -6,5 +6,6 @@ export * from './dns.js';
export * from './echo.js';
export * from './firewall.js';
export * from './gnutella.js';
export * from './viewers.js';
export * from './mail.js';
export * from './web.js';
+31 -10
View File
@@ -8,19 +8,20 @@ import type { CaptureRow, PageLanguage } from '@mdg-filius/core';
export const SIM_PROTOCOL_VERSION = 1;
/** The programs whose windows a client can watch and act in. */
export const APP_KINDS = ['terminal', 'webBrowser', 'webServer', 'emailClient', 'emailServer', 'echoServer', 'simpleClient', 'gnutella', 'dnsServer', 'firewall'] as const;
/** The programs whose windows a client can watch and act in; and a switch's and a home router's viewers (sat, nat). */
export const APP_KINDS = ['terminal', 'webBrowser', 'webServer', 'emailClient', 'emailServer', 'echoServer', 'simpleClient', 'gnutella', 'dnsServer', 'firewall', 'sat', 'nat'] as const;
export type AppKind = (typeof APP_KINDS)[number];
/**
* What a client subscribes to: which cables carry frames (`activity`), a
* card's capture (`trace:<portId>`), a program's window
* (`app:<nodeId>/<app>`), a computer's network settings as it runs with them
* (`node:<nodeId>`: its desktop's title and network info).
* (`node:<nodeId>`: its desktop's title and network info), the frames
* "Pakete verwerfen" dropped (`dropped`).
*/
export type Topic = 'activity' | `trace:${string}` | `app:${string}/${AppKind}` | `node:${string}`;
export type Topic = 'activity' | 'dropped' | `trace:${string}` | `app:${string}/${AppKind}` | `node:${string}`;
export const CLIENT_MESSAGE_KINDS = ['sub', 'unsub', 'start', 'stop', 'speed', 'input'] as const;
export const CLIENT_MESSAGE_KINDS = ['sub', 'unsub', 'start', 'stop', 'speed', 'drop', 'input'] as const;
export type ClientMessageKind = (typeof CLIENT_MESSAGE_KINDS)[number];
export type ClientMessage =
@@ -36,6 +37,8 @@ export type ClientMessage =
| { readonly v: 1; readonly kind: 'stop' }
/** 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. */
| { readonly v: 1; readonly kind: 'drop'; readonly on: boolean }
/** An action in a program's window, answered by `ack` or `error` with the same reqId. */
| {
readonly v: 1;
@@ -47,7 +50,7 @@ export type ClientMessage =
readonly reqId: string;
};
export const HOST_MESSAGE_KINDS = ['state', 'activity', 'trace', 'app', 'view', 'node', 'ack', 'error'] as const;
export const HOST_MESSAGE_KINDS = ['state', 'activity', 'trace', 'dropped', 'app', 'view', 'node', 'ack', 'error'] as const;
export type HostMessageKind = (typeof HOST_MESSAGE_KINDS)[number];
export type SimMode = 'design' | 'simulation';
@@ -59,17 +62,31 @@ export interface TraceFrame {
readonly layeredRows: readonly CaptureRow[];
}
/** A frame "Pakete verwerfen" dropped: its number among them, when, and what it was (EthernetFrame.toString). */
export interface DroppedUnit {
readonly number: number;
readonly timeMilliseconds: number;
readonly text: string;
}
/** engineFailed: the core threw (a part it has not got yet, such as remote links); the room is back in design mode. */
export const ERROR_CODES = ['badMessage', 'badVersion', 'unknownKind', 'badTopic', 'notRunning', 'unknownNode', 'unknownApp', 'unknownAction', 'badArgs', 'engineFailed'] as const;
export type ErrorCode = (typeof ERROR_CODES)[number];
export type HostMessage =
/** The room's mode, speed and time; sent to every client on a change and when it connects. */
| { readonly v: 1; readonly kind: 'state'; readonly mode: SimMode; readonly speed: number; readonly t: number }
/**
* The room's mode, speed and time; sent to every client on a change and
* when it connects. `clock`: the time of day, in milliseconds since 1970,
* that the simulation's time 0 stands for (a viewer writes its times of day
* from it, as Java's write the wall clock's).
*/
| { readonly v: 1; readonly kind: 'state'; readonly mode: SimMode; readonly speed: number; readonly t: number; readonly clock: number }
/** The cables that carried a frame since the last tick. */
| { readonly v: 1; readonly kind: 'activity'; readonly links: readonly string[] }
/** A card's new frames; on subscribing, all it holds (`reset`). */
| { readonly v: 1; readonly kind: 'trace'; readonly portId: string; readonly frames: readonly TraceFrame[]; readonly reset: boolean }
/** The frames "Pakete verwerfen" dropped since the start, new ones; on subscribing, all (`reset`): each its number, time and Java's text. */
| { readonly v: 1; readonly kind: 'dropped'; readonly units: readonly DroppedUnit[]; readonly reset: boolean }
/** A program's new window lines from line `from` on; on subscribing, all (from 0). */
| { readonly v: 1; readonly kind: 'app'; readonly node: string; readonly app: AppKind; readonly from: number; readonly lines: readonly unknown[] }
/**
@@ -79,7 +96,8 @@ export type HostMessage =
* web server's (WebServerView), the e-mail program's and server's
* (EmailClientView, EmailServerView), the echo server's and the generic
* client's (EchoServerView, SimpleClientView), the DNS server's
* (DnsServerView).
* (DnsServerView), the firewall's (FirewallView), Gnutella's
* (GnutellaView), a switch's and a home router's tables (SatView, NatView).
*/
| { readonly v: 1; readonly kind: 'view'; readonly node: string; readonly app: AppKind; readonly view: unknown }
/**
@@ -106,7 +124,7 @@ const bad = (code: ErrorCode, detail: string) => ({ error: { code, detail } });
/** Whether a topic is one the room knows. */
export function isTopic(value: unknown): value is Topic {
if (value === 'activity') return true;
if (value === 'activity' || value === 'dropped') return true;
if (typeof value !== 'string') return false;
if (value.startsWith('trace:')) return value.length > 'trace:'.length;
if (value.startsWith('node:')) return /^node:[^/]+$/.test(value);
@@ -138,6 +156,9 @@ export function parseClientMessage(value: unknown): Parsed<ClientMessage> {
case 'speed':
if (typeof value.value !== 'number' || !Number.isFinite(value.value)) return bad('badMessage', 'speed is no number');
return { message: { v: 1, kind: 'speed', value: value.value } };
case 'drop':
if (typeof value.on !== 'boolean') return bad('badMessage', 'on is no boolean');
return { message: { v: 1, kind: 'drop', on: value.on } };
case 'input': {
const { node, app, action, args, reqId } = value;
if (typeof reqId !== 'string') return bad('badMessage', 'reqId is no string');
+15
View File
@@ -0,0 +1,15 @@
// What the viewers of a running simulation show, as the room keeps them and
// every client shows them (plan §7.3), after Java's SatViewer and NatViewer
// (measured with tools/java-oracle's ViewersRun): a switch's source address
// table and a home router's NAT table. A time is the simulation's; the
// viewer writes it as a time of day (the `state` message's `clock`).
/** A switch's learned addresses: the address (in capitals: the switch learns it so, as holeSAT writes it), the port's number (from 1), its last update. */
export interface SatView {
readonly rows: readonly { readonly macAddress: string; readonly port: number; readonly updatedAtMilliseconds: number }[];
}
/** A home router's NAT entries: protocol, WAN port, remote address, LAN address, LAN port in Java's words; the last update, none for the port forwarding's. */
export interface NatView {
readonly rows: readonly { readonly cells: readonly [string, string, string, string, string]; readonly updatedAtMilliseconds?: number }[];
}
+3
View File
@@ -12,6 +12,7 @@ describe("a client's messages", () => {
{ v: 1, kind: 'start' },
{ v: 1, kind: 'stop' },
{ v: 1, kind: 'speed', value: 50 },
{ v: 1, kind: 'drop', on: true },
{ v: 1, kind: 'input', node: 'node-1', app: 'terminal', action: 'enter', args: { line: 'ping 1.2.3.4' }, reqId: 'r1' },
];
expect(messages.map((each) => each.kind)).toEqual([...CLIENT_MESSAGE_KINDS]);
@@ -31,6 +32,8 @@ 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: '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');
expect(code({ v: 1, kind: 'speed', value: '50' })).toBe('badMessage');
expect(code({ v: 1, kind: 'speed', value: Number.NaN })).toBe('badMessage');
+62 -3
View File
@@ -52,6 +52,8 @@ import {
gnutellaPeerCountOfText,
gnutellaResultRow,
interfaceKey,
javaFrameText,
natViewRow,
IP_ADDRESS_PATTERN,
portRuleSender,
stableGeneratedMacAddress,
@@ -80,6 +82,8 @@ import {
type EchoServerView,
type FirewallView,
type GnutellaView,
type NatView,
type SatView,
type SimpleClientView,
type TraceFrame,
type WebServerView,
@@ -99,6 +103,8 @@ export interface RoomOptions {
readonly seed: () => number;
/** How often a running simulation writes what it left into the document; WRITE_BACK_MILLISECONDS by default. */
readonly writeBackMilliseconds?: number;
/** The time of day, in milliseconds since 1970, for the viewers' times (Date.now by default). */
readonly epoch?: () => number;
}
interface Client {
@@ -106,6 +112,8 @@ interface Client {
readonly topics: Set<Topic>;
/** Per card, the number of the last frame sent. */
readonly traceSent: Map<Id, number>;
/** The number of the last dropped frame sent; none before the first message (or after a start). */
droppedSent: number | undefined;
/** Per program window, how many of its program's lines it has; none while the program does not run (a stop empties it). */
readonly appSent: Map<string, number>;
/** Per computer, the settings last sent (as text). */
@@ -133,6 +141,8 @@ export class SimulationRoom {
/** Per card, how many frames it held at the last tick. */
private framesSeen = new Map<Id, number>();
private lastActivity: string[] = [];
/** The clients holding "Pakete verwerfen": every cable drops while there is one. */
private readonly dropping = new Set<Client>();
/** Each computer's command line window, kept over stops. */
private readonly terminalWindows = new Map<Id, TerminalWindow>();
/** Each computer's browser window, kept over stops. */
@@ -313,13 +323,15 @@ export class SimulationRoom {
/** A new client; it gets the room's state at once. */
connect(send: (message: HostMessage) => void): number {
const id = this.nextClient++;
this.clients.set(id, { send, topics: new Set(), traceSent: new Map(), appSent: new Map(), nodeSent: new Map(), viewSent: new Map() });
this.clients.set(id, { send, topics: new Set(), traceSent: new Map(), droppedSent: undefined, appSent: new Map(), nodeSent: new Map(), viewSent: new Map() });
send(this.state());
return id;
}
disconnect(client: number): void {
const gone = this.clients.get(client);
this.clients.delete(client);
if (gone !== undefined && this.dropping.delete(gone)) this.engine.setGlobalPacketLossEnabled(this.dropping.size > 0);
}
/** How many clients the room has (F stops a room nobody watches). */
@@ -350,6 +362,7 @@ export class SimulationRoom {
for (const topic of input.topics) {
client.topics.add(topic);
if (topic.startsWith('trace:')) client.traceSent.delete(topic.slice('trace:'.length));
if (topic === 'dropped') client.droppedSent = undefined;
if (topic.startsWith('app:')) {
client.appSent.delete(topic);
client.viewSent.delete(topic);
@@ -367,6 +380,15 @@ export class SimulationRoom {
case 'stop':
this.stop();
return;
case 'drop':
if (this.mode !== 'simulation') {
client.send(message({ kind: 'error', code: 'notRunning' }));
return;
}
if (input.on) this.dropping.add(client);
else this.dropping.delete(client);
this.engine.setGlobalPacketLossEnabled(this.dropping.size > 0);
return;
case 'speed':
this.speed = Math.min(100, Math.max(1, Math.trunc(input.value)));
this.engine.setSimulationSpeed(this.speed);
@@ -384,7 +406,8 @@ export class SimulationRoom {
}
private state(): HostMessage {
return message({ kind: 'state', mode: this.mode, speed: this.speed, t: this.engine.state.currentTimeMilliseconds });
const t = this.engine.state.currentTimeMilliseconds;
return message({ kind: 'state', mode: this.mode, speed: this.speed, t, clock: (this.options.epoch ?? Date.now)() - t });
}
private broadcast(output: HostMessage): void {
@@ -398,6 +421,9 @@ export class SimulationRoom {
this.engine.setSimulationSpeed(this.speed);
if (!this.guarded(() => this.engine.handle({ type: 'start', snapshot, seed: this.options.seed(), initialTimeMilliseconds: this.engine.state.currentTimeMilliseconds }))) return;
this.linkOfPort = new Map(snapshot.links.flatMap((link) => [[link.sourcePortId, link.id] as const, [link.targetPortId, link.id] as const]));
// Java's SimulationControl begins its list of dropped frames anew with each simulation.
this.engine.capture.clearDropped();
for (const client of this.clients.values()) client.droppedSent = undefined;
this.software = this.documentSoftware();
this.syncWindows();
this.mode = 'simulation';
@@ -412,6 +438,7 @@ export class SimulationRoom {
// A failure already stopped the engine and went back to the design.
if (!this.guarded(() => this.writeBack())) return;
this.engine.handle({ type: 'stop' });
this.dropping.clear();
this.mode = 'design';
this.lastWall = undefined;
this.broadcast(this.state());
@@ -477,6 +504,9 @@ export class SimulationRoom {
return this.actInFirewall(input);
case 'gnutella':
return this.actInGnutella(input);
case 'sat':
case 'nat':
return this.actInViewer(input);
default:
return 'unknownAction';
}
@@ -512,6 +542,19 @@ export class SimulationRoom {
}
}
/** A switch's or home router's viewer: its table reset (SatViewer, NatViewer: "Tabelle löschen", "Tabelle zurücksetzen"). */
private actInViewer(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
if (this.nodeKind(input.node) !== (input.app === 'sat' ? 'networkSwitch' : 'gateway')) return 'unknownApp';
if (input.action !== 'reset') return 'unknownAction';
if (input.app === 'sat') this.engine.clearSwitchSat(input.node);
else this.engine.clearDynamicNatEntries(input.node);
return undefined;
}
private nodeKind(node: Id): string | undefined {
return this.engine.state.topologySnapshot.nodes.find((each) => each.id === node)?.kind;
}
/** The Gnutella window: "Netz beitreten" with the address, "Netz verlassen", "Suchen" with the words, "Stopp!", "Liste leeren", "Herunterladen" of the row chosen, the number of peers saved. */
private actInGnutella(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
const peer = this.engine.gnutella(input.node);
@@ -762,6 +805,16 @@ export class SimulationRoom {
const view: DnsServerView = { active: server.isActive, recursive: server.recursiveResolutionEnabled, rows: { A: rows('A'), MX: rows('MX'), NS: rows('NS') } };
return view;
}
case 'sat': {
if (this.nodeKind(node) !== 'networkSwitch') return null;
const view: SatView = { rows: this.engine.switchSatEntries(node).map((entry) => ({ macAddress: entry.macAddress, port: entry.portNumber, updatedAtMilliseconds: entry.updatedAtMilliseconds })) };
return view;
}
case 'nat': {
if (this.nodeKind(node) !== 'gateway') return null;
const view: NatView = { rows: this.engine.natEntries(node).map(natViewRow) };
return view;
}
case 'gnutella': {
const peer = this.engine.gnutella(node);
if (peer === undefined) return null;
@@ -845,6 +898,12 @@ export class SimulationRoom {
for (const topic of client.topics) {
if (topic === 'activity') {
if (subscribing) client.send(message({ kind: 'activity', links: this.lastActivity }));
} else if (topic === 'dropped') {
const reset = client.droppedSent === undefined;
const frames = this.engine.capture.droppedFrames().filter((each) => each.number > (client.droppedSent ?? 0));
if (frames.length === 0 && !reset) continue;
client.droppedSent = frames.at(-1)?.number ?? client.droppedSent ?? 0;
client.send(message({ kind: 'dropped', units: frames.map((each) => ({ number: each.number, timeMilliseconds: each.timeMilliseconds, text: javaFrameText(each.frame) })), reset }));
} else if (topic.startsWith('node:')) {
const node = topic.slice('node:'.length);
const settings = this.nodeSettings(node);
@@ -871,7 +930,7 @@ export class SimulationRoom {
client.send(message({ kind: 'view', node: node!, app: app as AppKind, view }));
}
// The browser's lines are its view; the DNS server's and Gnutella's windows have none.
if (app === 'webBrowser' || app === 'dnsServer' || app === 'gnutella') continue;
if (app === 'webBrowser' || app === 'dnsServer' || app === 'gnutella' || app === 'sat' || app === 'nat') continue;
}
const sent = client.appSent.get(topic);
if (app === 'terminal') {
+7 -3
View File
@@ -29,7 +29,7 @@ function exampleDoc(file = 'java-examples/internetwork_komplett.fls'): Y.Doc {
/** A room on a clock the test moves, and a client that keeps what it gets. */
function setUp(doc = exampleDoc()) {
let now = 1_000_000;
const room = new SimulationRoom(doc, { now: () => now, seed: () => SEED });
const room = new SimulationRoom(doc, { now: () => now, seed: () => SEED, epoch: () => 1_700_000_000_000 + now });
const got: HostMessage[] = [];
const client = room.connect((message) => got.push(message));
const project = readProject(doc);
@@ -58,13 +58,17 @@ const ofKind = <K extends HostMessage['kind']>(got: readonly HostMessage[], kind
describe('a simulation room', () => {
it('tells a new client its state, and every client a change', () => {
const { room, got, send } = setUp();
const { room, got, send, run } = setUp();
const other: HostMessage[] = [];
room.connect((message) => other.push(message));
expect(got).toEqual([{ v: 1, kind: 'state', mode: 'design', speed: 90, t: 0 }]);
// The simulation's time 0 stands for the time of day then (the epoch here follows the room's clock).
expect(got).toEqual([{ v: 1, kind: 'state', mode: 'design', speed: 90, t: 0, clock: 1_700_001_000_000 }]);
send({ kind: 'start' });
run(3_000);
send({ kind: 'speed', value: 250 });
send({ kind: 'stop' });
// The time of day of the simulation's time 0 stays while it runs.
expect(new Set(ofKind(got, 'state').map((state) => state.clock))).toEqual(new Set([1_700_001_000_000]));
for (const each of [got, other]) {
expect(ofKind(each, 'state').map(({ mode, speed }) => [mode, speed])).toEqual([
['design', 90],
+166
View File
@@ -0,0 +1,166 @@
// The viewers of a running simulation in the room, on FILIUS' example
// nat_with_mapped_port: a switch's table and a home router's NAT table as
// views (their reset an action), and "Pakete verwerfen": every cable drops
// while any client holds it, and the dropped frames go to who watches them,
// begun anew with each simulation. The tables' rows against Java's are
// packages/core/test/viewers.test.ts.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import * as Y from 'yjs';
import { readProject, setSoftware, writeProject } from '@mdg-filius/doc';
import { openProjectArchive } from '@mdg-filius/fls';
import type { DroppedUnit, HostMessage, NatView, SatView } from '@mdg-filius/protocol';
import { SimulationRoom, TICK_MILLISECONDS } from '../src/index.js';
const corpus = join(dirname(fileURLToPath(import.meta.url)), '../../../corpus/java-examples');
function setUp() {
let counter = 0;
const doc = new Y.Doc();
writeProject(
doc,
openProjectArchive(new Uint8Array(readFileSync(join(corpus, 'nat_with_mapped_port.fls'))), () => `00000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`),
);
const project = readProject(doc);
const idOf = (name: string) => project.graph.nodes.find((node) => node.displayName === name)!.id;
const gateway = [...project.portForwardingRowsByNodeId.keys()][0]!;
// The switch the web server behind the home router hangs on.
const webServer = idOf('Webserver - 192.168.0.10');
const link = project.graph.links.find((each) => each.sourceNodeId === webServer || each.targetNodeId === webServer)!;
const lanSwitch = link.sourceNodeId === webServer ? link.targetNodeId : link.sourceNodeId;
setSoftware(doc, webServer, { install: ['commandPrompt'], remove: [] }, 'de', 'me');
// The web server's card with an address in small letters (a file may hold one so): the switch's table writes it in capitals.
const webPort = (doc.getMap<Y.Map<unknown>>('nodes').get(webServer)!.get('ports') as Y.Array<Y.Map<unknown>>).get(0);
webPort.set('mac', 'aa:bb:cc:00:11:22');
let now = 0;
const room = new SimulationRoom(doc, { now: () => now, seed: () => 5 });
const views = new Map<string, unknown>();
const dropped: DroppedUnit[] = [];
const resets: number[] = [];
const messages: HostMessage[] = [];
const errors: string[] = [];
const connect = () =>
room.connect((message: HostMessage) => {
messages.push(message);
if (message.kind === 'view') views.set(`${message.node}/${message.app}`, message.view);
if (message.kind === 'dropped') {
if (message.reset) {
resets.push(message.units.length);
dropped.length = 0;
}
dropped.push(...message.units);
}
if (message.kind === 'error') errors.push(message.code);
});
const client = connect();
const send = (fields: object, who = client) => room.receive(who, { v: 1, ...fields });
const run = (milliseconds: number) => {
for (let elapsed = 0; elapsed < milliseconds; elapsed += TICK_MILLISECONDS) {
now += TICK_MILLISECONDS;
room.tick();
}
};
const terminal = (node: string, line: string) => send({ kind: 'input', node, app: 'terminal', action: 'enter', args: { line }, reqId: line });
return { room, idOf, gateway, lanSwitch, webServer, views, dropped, resets, messages, errors, connect, send, run, terminal };
}
describe('the viewers in the room', () => {
it("shows a switch's table and a home router's NAT table, and resets them", () => {
const { gateway, lanSwitch, webServer, views, messages, errors, send, run, terminal } = setUp();
send({ kind: 'sub', topics: [`app:${lanSwitch}/sat`, `app:${gateway}/nat`, `app:${webServer}/sat`, `app:${lanSwitch}/nat`] });
send({ kind: 'start' });
run(TICK_MILLISECONDS);
const sat = () => views.get(`${lanSwitch}/sat`) as SatView;
const nat = () => views.get(`${gateway}/nat`) as NatView;
// The port forwarding's entry, before anything went through.
expect(nat()).toEqual({ rows: [{ cells: ['TCP', '80', '*', '192.168.0.10', '80'] }] });
expect(sat()).toEqual({ rows: [] });
// No switch, no home router: no table.
expect([views.get(`${webServer}/sat`), views.get(`${lanSwitch}/nat`)]).toEqual([null, null]);
terminal(webServer, 'ping 141.99.4.10');
run(10_000);
expect(sat().rows.length).toBeGreaterThanOrEqual(2);
expect(sat().rows.every((row) => Number.isInteger(row.port) && row.port >= 1 && row.updatedAtMilliseconds > 0)).toBe(true);
expect(sat().rows.map((row) => row.macAddress)).toContain('AA:BB:CC:00:11:22');
expect(nat().rows.map((row) => row.cells.slice(0, 1).concat(row.cells.slice(2, 4)))).toEqual([
['TCP', '*', '192.168.0.10'],
['ICMP', '141.99.4.10', '192.168.0.10'],
]);
expect(nat().rows[1]!.updatedAtMilliseconds).toBeGreaterThan(0);
send({ kind: 'input', node: lanSwitch, app: 'sat', action: 'reset', args: {}, reqId: 'sat' });
send({ kind: 'input', node: gateway, app: 'nat', action: 'reset', args: {}, reqId: 'nat' });
expect(sat()).toEqual({ rows: [] });
expect(nat()).toEqual({ rows: [{ cells: ['TCP', '80', '*', '192.168.0.10', '80'] }] });
// What the viewers never hand over.
send({ kind: 'input', node: webServer, app: 'sat', action: 'reset', args: {}, reqId: 'a' });
send({ kind: 'input', node: lanSwitch, app: 'nat', action: 'reset', args: {}, reqId: 'b' });
send({ kind: 'input', node: lanSwitch, app: 'sat', action: 'sort', args: {}, reqId: 'c' });
expect(errors).toEqual(['unknownApp', 'unknownApp', 'unknownAction']);
// The viewers have no lines: none are sent.
expect(messages.filter((message) => message.kind === 'app')).toEqual([]);
});
it('drops while any client holds "Pakete verwerfen", and lists what it dropped anew with each simulation', () => {
const { webServer, dropped, resets, messages, errors, connect, send, run, terminal, room } = setUp();
send({ kind: 'sub', topics: ['dropped'] });
// Not while the design is shown.
send({ kind: 'drop', on: true });
expect(errors).toEqual(['notRunning']);
send({ kind: 'start' });
run(TICK_MILLISECONDS);
terminal(webServer, 'ping 192.168.0.1');
run(12_000);
expect(dropped).toEqual([]);
// Nothing dropped, nothing sent after the subscription's (empty) list.
expect(messages.filter((message) => message.kind === 'dropped')).toHaveLength(2);
const other = connect();
send({ kind: 'drop', on: true });
send({ kind: 'drop', on: true }, other);
send({ kind: 'drop', on: false });
// The other still holds it: the ping is dropped.
terminal(webServer, 'ping 192.168.0.1');
run(12_000);
expect(dropped.length).toBeGreaterThan(0);
expect(dropped[0]!.text).toMatch(/^\[src=[0-9A-F:]+, dest=[0-9A-F:]+, type=0x8(00|06) \| \[/i);
expect(dropped.map((unit) => unit.number)).toEqual(dropped.map((_, index) => index + 1));
const count = dropped.length;
// Subscribed anew, the list comes whole.
send({ kind: 'unsub', topics: ['dropped'] });
send({ kind: 'sub', topics: ['dropped'] });
expect([resets.at(-1), dropped.length]).toEqual([count, count]);
// The other leaves: nothing is dropped any more.
room.disconnect(other);
terminal(webServer, 'ping 192.168.0.1');
run(12_000);
expect(dropped.length).toBe(count);
// A new simulation begins the list anew.
send({ kind: 'stop' });
send({ kind: 'start' });
run(TICK_MILLISECONDS);
expect(resets.at(-1)).toBe(0);
expect(dropped).toEqual([]);
expect(errors).toEqual(['notRunning']);
});
it('forgets who held "Pakete verwerfen" when the simulation stops', () => {
const { webServer, dropped, connect, send, run, terminal } = setUp();
const other = connect();
send({ kind: 'sub', topics: ['dropped'] });
send({ kind: 'start' });
run(TICK_MILLISECONDS);
// Held through the stop: the letting go comes in the design, and is refused there.
send({ kind: 'drop', on: true }, other);
send({ kind: 'stop' });
send({ kind: 'drop', on: false }, other);
send({ kind: 'start' });
run(TICK_MILLISECONDS);
send({ kind: 'drop', on: true });
send({ kind: 'drop', on: false });
terminal(webServer, 'ping 192.168.0.1');
run(12_000);
expect(dropped).toEqual([]);
});
});