Files
julian.kiedaischandClaude Opus 5.5 6809a71300 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>
2026-10-03 22:13:48 +00:00

167 lines
8.3 KiB
TypeScript

// 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([]);
});
});