feat(sim): F4.2 the sim protocol, the simulation room and LocalSimHost; M9 measured
packages/protocol holds the one list of /sim messages and checks every field, naming what is wrong. packages/sim has the simulation room for F and the web worker: the document's snapshot at start, the engine's clock on the wall clock with Java's speed as cable delay, subscriptions to cable activity, a card's new frames and a program's new window lines, capture and clock kept over a stop, the command line as first action, a core failure told to every client. LocalSimHost mirrors the document into the worker and back without echo. M9 in Chromium: 17 examples give the same capture as in Node, character for character; the room runs in a real worker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
// 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];
|
||||
}
|
||||
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<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' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user