A new oracle, TerminalWindowRun, types into Java's own command line window: the window writes each line after its prompt, takes a blank line, cls, reset and exit itself, keeps the lines for the arrow keys, and keeps its text, history and working directory over a stop and a start. The room now holds a command line window per computer for everyone (TerminalWindow), kept over stops; the protocol carries its lines and the one way to read them (terminalScreen); the core goes on in the working directory of the run before (continueIn). The desktop opens programs in Java's frame, and the command line works with keyboard and finger: grey on black, the prompt hidden while a command runs, history, chips for the commands, Ctrl+C. The room's failure is now tested in the browser. Fixed: the room handed the engine the wall clock with fractions of a millisecond, and a ping hung after its first timeout. The engine and the room count whole milliseconds, as Java's clock does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
374 lines
19 KiB
TypeScript
374 lines
19 KiB
TypeScript
// 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 = <K extends HostMessage['kind']>(got: readonly HostMessage[], kind: K) => got.filter((message): message is Extract<HostMessage, { kind: K }> => 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];
|
|
}
|
|
// The window: its start screen, the line entered after its prompt, then what the program wrote, as the engine has it.
|
|
const program = engine.terminal(notebook.id)!.log.all.map(({ timeMilliseconds: _time, ...entry }) => entry);
|
|
expect(lines.slice(0, 3)).toEqual([{ kind: 'text', text: engine.terminal(notebook.id)!.startScreen }, { kind: 'prompt', prompt: '/> ' }, { kind: 'input', prompt: '/> ', line: 'ping 141.99.5.10', command: true }]);
|
|
expect(lines.slice(3).filter((line) => (line as { kind: string }).kind !== 'prompt')).toEqual(program);
|
|
expect(program.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("keeps a command line's window over a stop and a start, as Java does", () => {
|
|
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);
|
|
const before = ofKind(got, 'app').reduce((count, each) => each.from + each.lines.length, 0);
|
|
expect(before).toBeGreaterThan(3);
|
|
const sentBefore = ofKind(got, 'app').length;
|
|
send({ kind: 'stop' });
|
|
send({ kind: 'start' });
|
|
run(400);
|
|
// Nothing taken away: the window goes on after its lines.
|
|
expect(ofKind(got, 'app').slice(sentBefore).every((each) => each.from >= before)).toBe(true);
|
|
send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'pwd' }, reqId: 'r2' });
|
|
run(400);
|
|
expect(ofKind(got, 'app').at(-1)!.from).toBeGreaterThanOrEqual(before);
|
|
});
|
|
|
|
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, and goes on from there', () => {
|
|
const { got, send, jump, run } = setUp();
|
|
send({ kind: 'start' });
|
|
jump(60_000);
|
|
// The missed minute is not made up over the next ticks either.
|
|
run(TICK_MILLISECONDS);
|
|
send({ kind: 'stop' });
|
|
expect(ofKind(got, 'state').at(-1)!.t).toBe(MAXIMUM_CATCH_UP_MILLISECONDS + TICK_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<Y.Map<unknown>>('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);
|
|
});
|
|
|
|
it("hands the engine whole milliseconds of a wall clock with fractions, losing none, and a ping goes through", () => {
|
|
// performance.now() has fractions; the core counts whole milliseconds as Java does (a fraction once made the ping hang).
|
|
const doc = exampleDoc();
|
|
let now = 0.718;
|
|
const room = new SimulationRoom(doc, { now: () => now, seed: () => SEED });
|
|
const got: HostMessage[] = [];
|
|
const client = room.connect((message) => got.push(message));
|
|
const notebook = readProject(doc).graph.nodes.find((each) => each.displayName === 'Notebook 1.10')!;
|
|
room.receive(client, { v: 1, kind: 'sub', topics: [`app:${notebook.id}/terminal`] });
|
|
room.receive(client, { v: 1, kind: 'start' });
|
|
for (let tick = 0; tick < 15; tick++) {
|
|
now += 200.4;
|
|
room.tick();
|
|
}
|
|
room.receive(client, { v: 1, kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r' });
|
|
for (let tick = 0; tick < 60; tick++) {
|
|
now += 200.4;
|
|
room.tick();
|
|
}
|
|
room.receive(client, { v: 1, kind: 'speed', value: 90 });
|
|
const t = ofKind(got, 'state').at(-1)!.t;
|
|
expect(Number.isInteger(t)).toBe(true);
|
|
// Less than a millisecond behind the wall clock since the start, never ahead of it.
|
|
expect(t).toBeLessThanOrEqual(now - 0.718);
|
|
expect(t).toBeGreaterThan(now - 0.718 - 1);
|
|
const text = ofKind(got, 'app').flatMap((each) => each.lines as { kind: string; text?: string }[]).map((line) => line.text ?? '').join('');
|
|
expect(text).toContain('4 Paket(e) gesendet, 4 Paket(e) empfangen');
|
|
});
|
|
});
|
|
|