// The simulation room against the engine run by hand: what a client gets — // a card's frames, a program's window, the cables' activity, the room's // state — is what the engine holds, sent once each and only to the clients // that subscribed. On FILIUS' example internetwork_komplett: Notebook 1.10 // pings Webserver 5.10 across three routers from its command line. 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 { NetworkRuntimeEngine, type Id } from '@mdg-filius/core'; import { docSnapshot, readProject, writeProject } from '@mdg-filius/doc'; import { openProjectArchive } from '@mdg-filius/fls'; import type { HostMessage, TraceFrame } from '@mdg-filius/protocol'; import { MAXIMUM_CATCH_UP_MILLISECONDS, SimulationRoom, TICK_MILLISECONDS, traceFramesOf } from '../src/index.js'; const corpus = join(dirname(fileURLToPath(import.meta.url)), '../../../corpus'); const SEED = 14; function exampleDoc(file = 'java-examples/internetwork_komplett.fls'): Y.Doc { let counter = 0; const project = openProjectArchive(new Uint8Array(readFileSync(join(corpus, file))), () => `00000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`); const doc = new Y.Doc(); writeProject(doc, project); return 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 got: HostMessage[] = []; const client = room.connect((message) => got.push(message)); const project = readProject(doc); const node = (name: string) => project.graph.nodes.find((each) => each.displayName === name)!; return { room, got, client, node, send: (message: object) => room.receive(client, { v: 1, ...message }), /** Ticks for so many milliseconds of wall time. */ run: (milliseconds: number) => { for (let elapsed = 0; elapsed < milliseconds; elapsed += TICK_MILLISECONDS) { now += TICK_MILLISECONDS; room.tick(); } }, jump: (milliseconds: number) => { now += milliseconds; room.tick(); }, }; } const ofKind = (got: readonly HostMessage[], kind: K) => got.filter((message): message is Extract => message.kind === kind); describe('a simulation room', () => { it('tells a new client its state, and every client a change', () => { const { room, got, send } = setUp(); const other: HostMessage[] = []; room.connect((message) => other.push(message)); expect(got).toEqual([{ v: 1, kind: 'state', mode: 'design', speed: 90, t: 0 }]); send({ kind: 'start' }); send({ kind: 'speed', value: 250 }); send({ kind: 'stop' }); for (const each of [got, other]) { expect(ofKind(each, 'state').map(({ mode, speed }) => [mode, speed])).toEqual([ ['design', 90], ['simulation', 90], ['simulation', 100], ['design', 100], ]); } }); it("sends a card's frames and a terminal's lines as the engine has them", () => { const { got, send, node, run } = setUp(); const notebook = node('Notebook 1.10'); const port = notebook.ports[0]!.id; send({ kind: 'sub', topics: [`trace:${port}`, `app:${notebook.id}/terminal`] }); send({ kind: 'start' }); run(1_000); send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r1' }); run(8_000); // The same story on an engine run by hand: started alike, advanced tick by tick, the line typed at the same time. const engine = new NetworkRuntimeEngine(SEED); engine.handle({ type: 'start', snapshot: docSnapshot(exampleDoc()), seed: SEED, initialTimeMilliseconds: 0 }); engine.setSimulationSpeed(90); for (let t = TICK_MILLISECONDS; t <= 1_000; t += TICK_MILLISECONDS) engine.handle({ type: 'advance', toMilliseconds: t }); engine.terminal(notebook.id)!.enter('ping 141.99.5.10'); for (let t = 1_000 + TICK_MILLISECONDS; t <= 9_000; t += TICK_MILLISECONDS) engine.handle({ type: 'advance', toMilliseconds: t }); expect(ofKind(got, 'ack')).toEqual([{ v: 1, kind: 'ack', reqId: 'r1' }]); const traces = ofKind(got, 'trace'); expect(traces[0]).toMatchObject({ portId: port, reset: true }); expect(traces.slice(1).every((each) => !each.reset && each.frames.length > 0)).toBe(true); const frames: TraceFrame[] = traces.flatMap((each) => each.frames); expect(frames).toEqual(traceFramesOf(engine.capture.frames(port))); expect(frames.length).toBeGreaterThan(8); const windows = ofKind(got, 'app'); let lines: unknown[] = []; for (const each of windows) { expect(each.from).toBe(each.from === 0 ? 0 : lines.length); lines = [...lines.slice(0, each.from), ...each.lines]; } expect(lines).toEqual(engine.terminal(notebook.id)!.log.all); expect(lines.length).toBeGreaterThan(4); }); it('sends the cables that carry frames when they change, and only to who asked', () => { const { room, got, send, node, run } = setUp(); const quiet: HostMessage[] = []; room.connect((message) => quiet.push(message)); send({ kind: 'sub', topics: ['activity'] }); send({ kind: 'start' }); run(1_000); const notebook = node('Notebook 1.10'); send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r1' }); run(10_000); const activity = ofKind(got, 'activity'); expect(activity[0]).toEqual({ v: 1, kind: 'activity', links: [] }); expect(activity.some((each) => each.links.length >= 2)).toBe(true); // Each message differs from the one before; the last, after the ping, says all is quiet. for (let index = 1; index < activity.length; index += 1) expect(activity[index]!.links).not.toEqual(activity[index - 1]!.links); expect(activity.at(-1)!.links).toEqual([]); expect(ofKind(quiet, 'activity')).toEqual([]); }); it('stops sending a card it no longer watches', () => { const { got, send, node, run } = setUp(); const notebook = node('Notebook 1.10'); send({ kind: 'sub', topics: [`trace:${notebook.ports[0]!.id}`] }); send({ kind: 'start' }); send({ kind: 'unsub', topics: [`trace:${notebook.ports[0]!.id}`] }); const before = got.length; send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r1' }); run(5_000); expect(ofKind(got.slice(before), 'trace')).toEqual([]); }); it('keeps the capture over a stop and goes on at the time it stopped', () => { 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' }); const stoppedAt = ofKind(got, 'state').at(-1)!.t; expect(stoppedAt).toBe(5_000); send({ kind: 'start' }); expect(ofKind(got, 'state').at(-1)!.t).toBe(stoppedAt); send({ kind: 'sub', topics: [`trace:${port}`] }); const reset = ofKind(got, 'trace').at(-1)!; expect(reset.reset).toBe(true); expect(reset.frames.length).toBeGreaterThan(8); }); it('empties a window when its program starts anew', () => { const { got, send, node, run } = setUp(); const notebook = node('Notebook 1.10'); send({ kind: 'sub', topics: [`app:${notebook.id}/terminal`] }); expect(ofKind(got, 'app')).toEqual([{ v: 1, kind: 'app', node: notebook.id, app: 'terminal', from: 0, lines: [] }]); send({ kind: 'start' }); send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ipconfig' }, reqId: 'r1' }); run(400); expect(ofKind(got, 'app').at(-1)!.lines.length).toBeGreaterThan(0); send({ kind: 'stop' }); expect(ofKind(got, 'app').at(-1)).toMatchObject({ from: 0, lines: [] }); send({ kind: 'start' }); expect(ofKind(got, 'app').at(-1)).toMatchObject({ from: 0 }); }); it('answers an action that cannot be done with its reason', () => { const { got, send, node } = setUp(); const notebook = node('Notebook 1.10').id; const reason = (action: object) => { send({ kind: 'input', reqId: 'r', args: {}, ...action }); return got.at(-1); }; expect(reason({ node: notebook, app: 'terminal', action: 'enter', args: { line: 'ipconfig' } })).toMatchObject({ kind: 'error', code: 'notRunning', reqId: 'r' }); send({ kind: 'start' }); expect(reason({ node: 'nobody', app: 'terminal', action: 'enter', args: { line: 'x' } })).toMatchObject({ code: 'unknownNode' }); expect(reason({ node: node('Switch 1').id, app: 'terminal', action: 'enter', args: { line: 'x' } })).toMatchObject({ code: 'unknownApp' }); expect(reason({ node: notebook, app: 'terminal', action: 'paint' })).toMatchObject({ code: 'unknownAction' }); expect(reason({ node: notebook, app: 'webBrowser', action: 'open' })).toMatchObject({ code: 'unknownAction' }); expect(reason({ node: notebook, app: 'terminal', action: 'enter', args: { line: 7 } })).toMatchObject({ code: 'badArgs' }); expect(reason({ node: notebook, app: 'terminal', action: 'interrupt' })).toEqual({ v: 1, kind: 'ack', reqId: 'r' }); send({ kind: 'nonsense', reqId: 'q' }); expect(got.at(-1)).toEqual({ v: 1, kind: 'error', code: 'unknownKind', detail: 'kind nonsense', reqId: 'q' }); send({ kind: 'speed' }); expect(got.at(-1)).toEqual({ v: 1, kind: 'error', code: 'badMessage', detail: 'speed is no number' }); }); it('makes a cable slower with a lower speed, as Java does', () => { const roundTrip = (speed: number) => { const { got, send, node, run } = setUp(); const notebook = node('Notebook 1.10'); send({ kind: 'sub', topics: [`trace:${notebook.ports[0]!.id}`] }); send({ kind: 'speed', value: speed }); send({ kind: 'start' }); send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r1' }); run(10_000); const rows = ofKind(got, 'trace').flatMap((each) => each.frames).flatMap((frame) => frame.rows).filter((row) => row.protocol === 'ICMP'); return rows[1]!.timeMilliseconds - rows[0]!.timeMilliseconds; }; expect(roundTrip(80)).toBeGreaterThan(roundTrip(90)); expect(roundTrip(90)).toBeGreaterThan(roundTrip(100)); }); it('catches up at most two seconds after the clock jumped', () => { const { got, send, jump } = setUp(); send({ kind: 'start' }); jump(60_000); send({ kind: 'stop' }); expect(ofKind(got, 'state').at(-1)!.t).toBe(MAXIMUM_CATCH_UP_MILLISECONDS); }); it('starts with the document as it is then', () => { const doc = exampleDoc(); const { send, node, room, got } = setUp(doc); const notebook: Id = node('Notebook 1.10').id; send({ kind: 'start' }); doc.getMap>('nodes').get(notebook)!.set('name', 'Neu'); send({ kind: 'input', node: notebook, app: 'terminal', action: 'enter', args: { line: 'ipconfig' }, reqId: 'r1' }); expect(got.at(-1)).toMatchObject({ kind: 'ack' }); send({ kind: 'stop' }); // The room snapshots at the start: a node removed while it runs is gone at the next start. doc.getMap('nodes').delete(notebook); send({ kind: 'start' }); send({ kind: 'input', node: notebook, app: 'terminal', action: 'enter', args: { line: 'ipconfig' }, reqId: 'r2' }); expect(got.at(-1)).toMatchObject({ kind: 'error', code: 'unknownNode', reqId: 'r2' }); expect(room.currentMode).toBe('simulation'); }); it('tells every client when the core fails, and goes back to design mode', () => { // FILIUS' modem example: remote links are not in the core yet, it throws at the first frame to a modem. const { room, got, send, run, node } = setUp(exampleDoc('java-examples/modem_beispiel.fls')); const other: HostMessage[] = []; room.connect((message) => other.push(message)); const ping = () => send({ kind: 'input', node: node('Notebook 0.10').id, app: 'terminal', action: 'enter', args: { line: 'ping 192.168.0.11' }, reqId: 'r' }); send({ kind: 'start' }); ping(); run(10_000); for (const each of [got, other]) { expect(ofKind(each, 'error')).toEqual([{ v: 1, kind: 'error', code: 'engineFailed', detail: expect.stringContaining('remote links') }]); expect(ofKind(each, 'state').at(-1)).toMatchObject({ mode: 'design' }); } expect(room.currentMode).toBe('design'); // It can start again (and fail again, telling it again). send({ kind: 'start' }); ping(); run(10_000); expect(ofKind(got, 'error')).toHaveLength(2); }); it('sends a card anew to a client that watches it again', () => { const { got, send, node, run } = setUp(); const notebook = node('Notebook 1.10'); const topic = `trace:${notebook.ports[0]!.id}`; send({ kind: 'start' }); send({ kind: 'sub', topics: [topic] }); send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r1' }); run(3_000); send({ kind: 'unsub', topics: [topic] }); run(3_000); send({ kind: 'sub', topics: [topic] }); const again = ofKind(got, 'trace').at(-1)!; expect(again.reset).toBe(true); expect(again.frames[0]!.number).toBe(1); }); it('changes the speed of a running simulation', () => { const roundTrip = (change: boolean) => { const { got, send, node, run } = setUp(); const notebook = node('Notebook 1.10'); send({ kind: 'sub', topics: [`trace:${notebook.ports[0]!.id}`] }); send({ kind: 'start' }); run(1_000); if (change) send({ kind: 'speed', value: 100 }); send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r1' }); run(10_000); const rows = ofKind(got, 'trace').flatMap((each) => each.frames).flatMap((frame) => frame.rows).filter((row) => row.protocol === 'ICMP'); return rows[1]!.timeMilliseconds - rows[0]!.timeMilliseconds; }; expect(roundTrip(true)).toBeLessThan(roundTrip(false)); }); it('reads the document anew at the start after a failure', () => { const doc = exampleDoc('java-examples/modem_beispiel.fls'); const { got, send, run, node } = setUp(doc); const ping = () => send({ kind: 'input', node: node('Notebook 0.10').id, app: 'terminal', action: 'enter', args: { line: 'ping 192.168.0.11' }, reqId: 'r' }); send({ kind: 'start' }); ping(); run(10_000); expect(ofKind(got, 'error')).toHaveLength(1); // Without the modems' cables, nothing reaches a modem: the next start runs. doc.getMap('links').clear(); send({ kind: 'start' }); ping(); run(10_000); expect(ofKind(got, 'error')).toHaveLength(1); expect(ofKind(got, 'state').at(-1)).toMatchObject({ mode: 'simulation' }); }); it("sends a computer's network as it runs, anew when DHCP changes it, and nothing before a start", () => { const { got, send, run, node } = setUp(exampleDoc('java-examples/einfaches_rechnernetz_komplett.fls')); const notebook = node('Notebook 1'); const server = node('Server'); send({ kind: 'sub', topics: [`node:${notebook.id}`, `node:${server.id}`] }); expect(ofKind(got, 'node')).toEqual([]); send({ kind: 'start' }); run(20_000); const sent = ofKind(got, 'node').filter((each) => each.node === notebook.id); expect(sent.length).toBeGreaterThanOrEqual(2); expect(sent.at(-1)!.settings).toMatchObject({ subnetMask: '255.255.255.0', dnsServer: '192.168.0.100', macAddress: expect.stringMatching(/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/) }); expect(sent.at(-1)!.settings.ipAddress).toMatch(/^192\.168\.0\.\d+$/); // Sent when it changes, not every tick. expect(new Set(sent.map((each) => JSON.stringify(each.settings))).size).toBe(sent.length); expect(ofKind(got, 'node').filter((each) => each.node === server.id).map((each) => each.settings)).toEqual([ { ipAddress: '192.168.0.100', subnetMask: '255.255.255.0', defaultGateway: '', dnsServer: '192.168.0.100', macAddress: expect.any(String) }, ]); // A client that subscribes again gets it again. const before = got.length; send({ kind: 'sub', topics: [`node:${server.id}`] }); expect(ofKind(got.slice(before), 'node')).toHaveLength(1); }); });