Who started the simulation, "Pakete verwerfen" and the dropped packets move from the toolbar into a properties sheet beside the surface. New in it: restart the simulation, after asking. The room learns a restart message: stopped as by stop, every capture, dropped frame and program window emptied, started anew and named after who restarted; refused for readers and frozen projects, logged, and waiting for asked blobs like a start. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
681 lines
40 KiB
TypeScript
681 lines
40 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, epoch: () => 1_700_000_000_000 + now });
|
||
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, run } = setUp();
|
||
const other: HostMessage[] = [];
|
||
room.connect((message) => other.push(message));
|
||
// 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],
|
||
['simulation', 90],
|
||
['simulation', 100],
|
||
['design', 100],
|
||
]);
|
||
}
|
||
});
|
||
|
||
it('names who started the simulation while it runs, and no one in a room of one page', () => {
|
||
const { room, got, send, run } = setUp();
|
||
const mia: HostMessage[] = [];
|
||
const miaId = room.connect((message) => mia.push(message), 'Mia');
|
||
room.receive(miaId, { v: 1, kind: 'start' });
|
||
run(400);
|
||
const late: HostMessage[] = [];
|
||
room.connect((message) => late.push(message), 'Ben');
|
||
expect(ofKind(late, 'state').map((state) => state.startedBy)).toEqual(['Mia']);
|
||
send({ kind: 'speed', value: 50 });
|
||
send({ kind: 'stop' });
|
||
expect(ofKind(mia, 'state').map(({ mode, startedBy }) => [mode, startedBy])).toEqual([
|
||
['design', undefined],
|
||
['simulation', 'Mia'],
|
||
['simulation', 'Mia'],
|
||
['design', undefined],
|
||
]);
|
||
// Started by the client without a name, the room names no one.
|
||
send({ kind: 'start' });
|
||
expect(ofKind(got, 'state').at(-1)).not.toHaveProperty('startedBy');
|
||
});
|
||
|
||
it('forgets who started it when the core fails', () => {
|
||
const { room, run, node } = setUp(exampleDoc('java-examples/modem_beispiel.fls'));
|
||
const mia: HostMessage[] = [];
|
||
const miaId = room.connect((message) => mia.push(message), 'Mia');
|
||
room.receive(miaId, { v: 1, kind: 'start' });
|
||
room.receive(miaId, { v: 1, kind: 'input', node: node('Notebook 0.10').id, app: 'terminal', action: 'enter', args: { line: 'ping 192.168.0.11' }, reqId: 'r' });
|
||
run(10_000);
|
||
expect(ofKind(mia, 'state').at(-1)).toMatchObject({ mode: 'design' });
|
||
expect(ofKind(mia, 'state').at(-1)).not.toHaveProperty('startedBy');
|
||
});
|
||
|
||
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' });
|
||
// A program the computer does not have.
|
||
expect(reason({ node: notebook, app: 'gnutella', action: 'search' })).toMatchObject({ code: 'unknownApp' });
|
||
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');
|
||
});
|
||
});
|
||
|
||
describe("a simulation room's browser and web server windows", () => {
|
||
const views = (got: readonly HostMessage[], node: Id, app: string) => ofKind(got, 'view').filter((each) => each.node === node && each.app === app).map((each) => each.view);
|
||
|
||
it("sends the browser's view and the web server's whole when they change, and none of the browser's lines", () => {
|
||
const { node, send, run, got } = setUp();
|
||
const notebook = node('Notebook 1.10').id;
|
||
const server = node('Webserver 5.10').id;
|
||
send({ kind: 'sub', topics: [`app:${notebook}/webBrowser`, `app:${server}/webServer`] });
|
||
// Before a start: neither has anything.
|
||
expect([views(got, notebook, 'webBrowser'), views(got, server, 'webServer')]).toEqual([[null], [null]]);
|
||
send({ kind: 'start' });
|
||
run(TICK_MILLISECONDS);
|
||
expect(views(got, notebook, 'webBrowser')).toEqual([null, { address: 'http://', loading: null, arrived: false, page: { kind: 'start' } }]);
|
||
expect(views(got, server, 'webServer')).toEqual([null, { active: true, useVirtualHosts: false, virtualHosts: [['', ''], ['', ''], ['', ''], ['', ''], ['', '']] }]);
|
||
send({ kind: 'input', node: notebook, app: 'webBrowser', action: 'open', args: { address: '141.99.5.10' }, reqId: 'open' });
|
||
expect(ofKind(got, 'ack').map((each) => each.reqId)).toEqual(['open']);
|
||
expect(views(got, notebook, 'webBrowser').at(-1)).toEqual({ address: 'http://141.99.5.10/', loading: 'http://141.99.5.10/', arrived: false, page: { kind: 'start' } });
|
||
run(20_000);
|
||
const page = views(got, notebook, 'webBrowser').at(-1) as { arrived: boolean; page: { kind: string; html: string; images: Record<string, unknown> } };
|
||
expect(page.arrived).toBe(true);
|
||
expect(page.page.kind).toBe('html');
|
||
expect(Object.keys(page.page.images).length).toBeGreaterThan(0);
|
||
// The browser's lines are its view; the server's go on as lines.
|
||
expect(ofKind(got, 'app').some((each) => each.app === 'webBrowser')).toBe(false);
|
||
expect(ofKind(got, 'app').filter((each) => each.app === 'webServer').flatMap((each) => each.lines as { kind: string }[]).some((line) => line.kind === 'received')).toBe(true);
|
||
// Nothing new: no view sent again.
|
||
const before = got.length;
|
||
run(1_000);
|
||
expect(ofKind(got.slice(before), 'view')).toEqual([]);
|
||
// The server's button, check box and table.
|
||
send({ kind: 'input', node: server, app: 'webServer', action: 'setActive', args: { active: false }, reqId: 's1' });
|
||
send({ kind: 'input', node: server, app: 'webServer', action: 'useVirtualHosts', args: { use: true }, reqId: 's2' });
|
||
send({ kind: 'input', node: server, app: 'webServer', action: 'setVirtualHost', args: { row: 4, column: 1, value: 'ordner' }, reqId: 's3' });
|
||
expect(views(got, server, 'webServer').at(-1)).toEqual({ active: false, useVirtualHosts: true, virtualHosts: [['', ''], ['', ''], ['', ''], ['', ''], ['', 'ordner']] });
|
||
expect(views(got, server, 'webServer')).toHaveLength(5);
|
||
// A stop: the browser keeps its page (Java's window does), the server has none; a client subscribing again gets both.
|
||
send({ kind: 'stop' });
|
||
expect(views(got, server, 'webServer').at(-1)).toBeNull();
|
||
const again = got.length;
|
||
send({ kind: 'sub', topics: [`app:${notebook}/webBrowser`] });
|
||
expect(views(got.slice(again), notebook, 'webBrowser')).toEqual([page]);
|
||
});
|
||
|
||
it('follows the links of a page, and writes the routers’ pages in the language of the start', () => {
|
||
const { node, send, run, got, room } = setUp();
|
||
const notebook = node('Notebook 1.10').id;
|
||
const gateway = docSnapshot(exampleDoc()).deviceConfigurations.get(notebook)!.defaultGateway;
|
||
send({ kind: 'sub', topics: [`app:${notebook}/webBrowser`] });
|
||
const page = () => (views(got, notebook, 'webBrowser').at(-1) as { address: string; page: { html?: string } });
|
||
send({ kind: 'start' });
|
||
run(TICK_MILLISECONDS);
|
||
send({ kind: 'input', node: notebook, app: 'webBrowser', action: 'open', args: { address: gateway }, reqId: 'r' });
|
||
run(20_000);
|
||
expect(page().page.html).toContain('Administration des Vermittlungsrechners');
|
||
send({ kind: 'input', node: notebook, app: 'webBrowser', action: 'follow', args: { href: 'routes' }, reqId: 'f' });
|
||
run(20_000);
|
||
expect([page().address, page().page.html]).toEqual([`http://${gateway}/routes`, expect.stringContaining('Routing-/Weiterleitungs-Tabelle')]);
|
||
room.stop();
|
||
send({ kind: 'start', language: 'en' });
|
||
run(TICK_MILLISECONDS);
|
||
send({ kind: 'input', node: notebook, app: 'webBrowser', action: 'open', args: { address: gateway }, reqId: 'r' });
|
||
run(20_000);
|
||
expect(page().page.html).toContain('Router Administration');
|
||
// Started anew: in the language of the restart, German without one.
|
||
for (const [restart, title] of [[{ kind: 'restart' }, 'Administration des Vermittlungsrechners'], [{ kind: 'restart', language: 'en' }, 'Router Administration']] as const) {
|
||
send(restart);
|
||
run(TICK_MILLISECONDS);
|
||
send({ kind: 'input', node: notebook, app: 'webBrowser', action: 'open', args: { address: gateway }, reqId: 'r' });
|
||
run(20_000);
|
||
expect(page().page.html).toContain(title);
|
||
}
|
||
});
|
||
|
||
it('names a wrong action in the windows', () => {
|
||
const { node, send, run, got } = setUp();
|
||
const notebook = node('Notebook 1.10').id;
|
||
const server = node('Webserver 5.10').id;
|
||
const dns = node('DNS-Server 2.10').id;
|
||
send({ kind: 'start' });
|
||
run(TICK_MILLISECONDS);
|
||
const cases: [string, string, string, Record<string, unknown>, string][] = [
|
||
[notebook, 'webBrowser', 'open', {}, 'badArgs'],
|
||
[notebook, 'webBrowser', 'follow', { href: 7 }, 'badArgs'],
|
||
[notebook, 'webBrowser', 'follow', { href: 'a', post: 1 }, 'badArgs'],
|
||
[notebook, 'webBrowser', 'back', {}, 'unknownAction'],
|
||
[dns, 'webBrowser', 'open', { address: 'x' }, 'unknownApp'],
|
||
[server, 'webServer', 'setActive', { active: 'yes' }, 'badArgs'],
|
||
[server, 'webServer', 'useVirtualHosts', { use: 1 }, 'badArgs'],
|
||
[server, 'webServer', 'setVirtualHost', { row: 5, column: 0, value: 'a' }, 'badArgs'],
|
||
[server, 'webServer', 'setVirtualHost', { row: -1, column: 0, value: 'a' }, 'badArgs'],
|
||
[server, 'webServer', 'setVirtualHost', { row: 1.5, column: 0, value: 'a' }, 'badArgs'],
|
||
[server, 'webServer', 'setVirtualHost', { row: 0, column: 2, value: 'a' }, 'badArgs'],
|
||
[server, 'webServer', 'setVirtualHost', { row: 0, column: 1, value: 2 }, 'badArgs'],
|
||
[server, 'webServer', 'restart', {}, 'unknownAction'],
|
||
[notebook, 'webServer', 'setActive', { active: true }, 'unknownApp'],
|
||
[server, 'gnutella', 'join', { address: '192.168.0.1' }, 'unknownApp'],
|
||
];
|
||
for (const [target, app, action, args, code] of cases) {
|
||
send({ kind: 'input', node: target, app, action, args, reqId: `${app}.${action}.${JSON.stringify(args)}` });
|
||
expect(ofKind(got, 'error').at(-1), `${app} ${action} ${JSON.stringify(args)}`).toMatchObject({ code, reqId: `${app}.${action}.${JSON.stringify(args)}` });
|
||
}
|
||
expect(ofKind(got, 'ack')).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe("a simulation room's e-mail windows", () => {
|
||
const views = (got: readonly HostMessage[], node: Id, app: string) => ofKind(got, 'view').filter((each) => each.node === node && each.app === app).map((each) => each.view);
|
||
|
||
it('sends the e-mail program and server as views, and takes their actions', () => {
|
||
const { node, send, run, got } = setUp(exampleDoc('java-examples/email_komplett.fls'));
|
||
const one = node('Rechner 1').id;
|
||
const two = node('Rechner 2').id;
|
||
const server = node('Mail-Server (filius.de)').id;
|
||
send({ kind: 'sub', topics: [`app:${one}/emailClient`, `app:${two}/emailClient`, `app:${server}/emailServer`] });
|
||
expect(views(got, server, 'emailServer')).toEqual([null]);
|
||
send({ kind: 'start' });
|
||
run(TICK_MILLISECONDS);
|
||
expect(views(got, server, 'emailServer').at(-1)).toEqual({ active: true, domain: 'filius.de', accounts: [{ username: 'rechner1', mails: expect.any(Number) }, { username: 'rechner2', mails: expect.any(Number) }] });
|
||
const mails = (views(got, server, 'emailServer').at(-1) as { accounts: { mails: number }[] }).accounts[1]!.mails;
|
||
expect(views(got, one, 'emailClient').at(-1)).toMatchObject({ account: { username: 'rechner1', email: 'rechner1@filius.de' } });
|
||
const act = (target: Id, app: string, action: string, args: object, reqId: string) => send({ kind: 'input', node: target, app, action, args, reqId });
|
||
act(one, 'emailClient', 'send', { to: 'rechner2@filius.de', cc: '', bcc: '', subject: 'Hallo', text: 'Zeile' }, 'send');
|
||
run(20_000);
|
||
expect(ofKind(got, 'ack').map((each) => each.reqId)).toEqual(['send']);
|
||
expect((views(got, one, 'emailClient').at(-1) as { sent: { subject: string }[] }).sent.map((mail) => mail.subject)).toContain('Hallo');
|
||
expect((views(got, server, 'emailServer').at(-1) as { accounts: { mails: number }[] }).accounts[1]!.mails).toBe(mails + 1);
|
||
act(two, 'emailClient', 'fetch', {}, 'fetch');
|
||
run(20_000);
|
||
const inbox = () => (views(got, two, 'emailClient').at(-1) as { inbox: { subject: string }[] }).inbox;
|
||
expect(inbox().map((mail) => mail.subject)).toContain('Hallo');
|
||
const before = inbox().length;
|
||
act(two, 'emailClient', 'removeMail', { folder: 'inbox', index: 0 }, 'remove');
|
||
expect(inbox()).toHaveLength(before - 1);
|
||
// The program's lines go on as lines.
|
||
expect(ofKind(got, 'app').some((each) => each.app === 'emailClient' && each.node === one && each.lines.length > 0)).toBe(true);
|
||
act(two, 'emailClient', 'setAccount', { name: 'Zwei Zwo', email: 'zwei@filius.de', pop3Host: '141.99.50.232', pop3Port: '110', smtpHost: '141.99.50.232', smtpPort: '25', username: 'zwei', password: 'x' }, 'account');
|
||
expect(views(got, two, 'emailClient').at(-1)).toMatchObject({ account: { firstName: 'Zwei', lastName: 'Zwo', username: 'zwei', pop3Port: 110 } });
|
||
|
||
// The server: switched off with the window's line, its domain set, an account made and removed.
|
||
act(server, 'emailServer', 'setActive', { active: false }, 'off');
|
||
act(server, 'emailServer', 'setDomain', { domain: 'schule.de' }, 'domain');
|
||
act(server, 'emailServer', 'addAccount', { username: 'neu', password: 'pw', firstName: 'Vorname', lastName: 'Nachname' }, 'add');
|
||
act(server, 'emailServer', 'removeAccount', { username: 'rechner1' }, 'removeAccount');
|
||
expect(views(got, server, 'emailServer').at(-1)).toEqual({ active: false, domain: 'schule.de', accounts: [{ username: 'rechner2', mails: expect.any(Number) }, { username: 'neu', mails: 0 }] });
|
||
const lines = ofKind(got, 'app').filter((each) => each.app === 'emailServer').flatMap((each) => each.lines as { kind: string }[]);
|
||
expect(lines.at(-1)).toMatchObject({ kind: 'switched', active: false });
|
||
});
|
||
|
||
it('names a wrong action in the e-mail windows', () => {
|
||
const { node, send, run, got } = setUp(exampleDoc('java-examples/email_komplett.fls'));
|
||
const one = node('Rechner 1').id;
|
||
const server = node('Mail-Server (filius.de)').id;
|
||
send({ kind: 'start' });
|
||
run(TICK_MILLISECONDS);
|
||
const account = { name: 'A', email: 'a@filius.de', pop3Host: 'h', pop3Port: '110', smtpHost: 'h', smtpPort: '25', username: 'a', password: 'p' };
|
||
const cases: [string, string, string, Record<string, unknown>, string][] = [
|
||
[one, 'emailClient', 'send', { to: 'kein', cc: '', bcc: '', subject: '', text: '' }, 'badArgs'],
|
||
[one, 'emailClient', 'send', { to: '', cc: '', bcc: '', subject: '', text: '' }, 'badArgs'],
|
||
[one, 'emailClient', 'send', { to: 'a@b.de', cc: '', bcc: '', subject: '' }, 'badArgs'],
|
||
[one, 'emailClient', 'setAccount', { ...account, pop3Port: '0' }, 'badArgs'],
|
||
[one, 'emailClient', 'setAccount', { ...account, password: 5 }, 'badArgs'],
|
||
[one, 'emailClient', 'removeMail', { folder: 'drafts', index: 0 }, 'badArgs'],
|
||
[one, 'emailClient', 'removeMail', { folder: 'inbox', index: 0.5 }, 'badArgs'],
|
||
[one, 'emailClient', 'reply', {}, 'unknownAction'],
|
||
[server, 'emailClient', 'fetch', {}, 'unknownApp'],
|
||
[server, 'emailServer', 'setActive', { active: 1 }, 'badArgs'],
|
||
[server, 'emailServer', 'setDomain', { domain: 'neu.de' }, 'badArgs'],
|
||
[server, 'emailServer', 'setDomain', { domain: 3 }, 'badArgs'],
|
||
[server, 'emailServer', 'addAccount', { username: 'rechner1', password: 'p', firstName: '', lastName: '' }, 'badArgs'],
|
||
[server, 'emailServer', 'addAccount', { username: '', password: 'p', firstName: '', lastName: '' }, 'badArgs'],
|
||
[server, 'emailServer', 'addAccount', { username: 'a', password: '', firstName: '', lastName: '' }, 'badArgs'],
|
||
[server, 'emailServer', 'addAccount', { username: 'a b', password: 'p', firstName: '', lastName: '' }, 'badArgs'],
|
||
[server, 'emailServer', 'addAccount', { username: 'a\tb', password: 'p', firstName: '', lastName: '' }, 'badArgs'],
|
||
[server, 'emailServer', 'addAccount', { username: 'a', password: 'p', firstName: 1, lastName: '' }, 'badArgs'],
|
||
[server, 'emailServer', 'removeAccount', { username: 'niemand' }, 'badArgs'],
|
||
[server, 'emailServer', 'removeAccount', {}, 'badArgs'],
|
||
[server, 'emailServer', 'restart', {}, 'unknownAction'],
|
||
[one, 'emailServer', 'setActive', { active: true }, 'unknownApp'],
|
||
];
|
||
for (const [target, app, action, args, code] of cases) {
|
||
const reqId = `${app}.${action}.${JSON.stringify(args)}`;
|
||
send({ kind: 'input', node: target, app, action, args, reqId });
|
||
expect(ofKind(got, 'error').at(-1), reqId).toMatchObject({ code, reqId });
|
||
}
|
||
expect(ofKind(got, 'ack')).toEqual([]);
|
||
// Off, the server takes a domain, and only one Java's pattern takes.
|
||
send({ kind: 'input', node: server, app: 'emailServer', action: 'setActive', args: { active: false }, reqId: 'off' });
|
||
send({ kind: 'input', node: server, app: 'emailServer', action: 'setDomain', args: { domain: 'kein domain!' }, reqId: 'bad' });
|
||
expect(ofKind(got, 'error').at(-1)).toMatchObject({ code: 'badArgs', reqId: 'bad' });
|
||
send({ kind: 'input', node: server, app: 'emailServer', action: 'setDomain', args: { domain: 'neu.de' }, reqId: 'good' });
|
||
expect(ofKind(got, 'ack').map((each) => each.reqId)).toEqual(['off', 'good']);
|
||
});
|
||
});
|
||
|
||
|
||
describe('a simulation room started anew', () => {
|
||
/** A window sent from its beginning, and a view. */
|
||
const anew = (got: readonly HostMessage[], node: Id, app: string) => ofKind(got, 'app').filter((each) => each.node === node && each.app === app && each.from === 0);
|
||
const viewsOf = (got: readonly HostMessage[], node: Id, app: string) => ofKind(got, 'view').filter((each) => each.node === node && each.app === app);
|
||
|
||
it('empties every window and list for every client, names who did it, keeps what the devices wrote, and numbers anew', () => {
|
||
const { room, got, send, node, run } = setUp();
|
||
const benGot: HostMessage[] = [];
|
||
const ben = room.connect((message) => benGot.push(message), 'Ben');
|
||
const notebook = node('Notebook 1.10');
|
||
const server = node('Webserver 5.10').id;
|
||
const port = notebook.ports[0]!.id;
|
||
const topics = ['activity', `trace:${port}`, 'dropped', `app:${notebook.id}/terminal`, `app:${notebook.id}/webBrowser`, `app:${server}/webServer`];
|
||
send({ kind: 'sub', topics });
|
||
room.receive(ben, { v: 1, kind: 'sub', topics });
|
||
send({ kind: 'start' });
|
||
run(TICK_MILLISECONDS);
|
||
const fresh = { terminal: anew(got, notebook.id, 'terminal').at(-1)!, browser: viewsOf(got, notebook.id, 'webBrowser').at(-1)! };
|
||
const enter = (line: string) => send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line }, reqId: line });
|
||
enter('touch bleibt.txt');
|
||
enter('ping 141.99.5.10');
|
||
send({ kind: 'input', node: notebook.id, app: 'webBrowser', action: 'open', args: { address: '141.99.5.10' }, reqId: 'open' });
|
||
run(20_000);
|
||
send({ kind: 'drop', on: true });
|
||
enter('ping 141.99.5.10');
|
||
run(5_000);
|
||
send({ kind: 'drop', on: false });
|
||
expect(ofKind(got, 'dropped').flatMap((each) => each.units).length).toBeGreaterThan(0);
|
||
expect(ofKind(got, 'app').filter((each) => each.app === 'webServer').flatMap((each) => each.lines).length).toBeGreaterThan(0);
|
||
|
||
const before = { mia: got.length, ben: benGot.length };
|
||
room.receive(ben, { v: 1, kind: 'restart' });
|
||
for (const [messages, from] of [[got, before.mia], [benGot, before.ben]] as const) {
|
||
const after = messages.slice(from);
|
||
expect(ofKind(after, 'state').map((each) => [each.mode, each.startedBy])).toEqual([
|
||
['design', undefined],
|
||
['simulation', 'Ben'],
|
||
]);
|
||
expect(ofKind(after, 'trace').at(-1)).toMatchObject({ portId: port, reset: true, frames: [] });
|
||
expect(ofKind(after, 'dropped').at(-1)).toMatchObject({ reset: true, units: [] });
|
||
// The command line as at the first start, the browser on its start page, the server's log empty.
|
||
expect(anew(after, notebook.id, 'terminal').at(-1)!.lines).toEqual(fresh.terminal.lines);
|
||
expect(viewsOf(after, notebook.id, 'webBrowser').at(-1)!.view).toEqual(fresh.browser.view);
|
||
expect(anew(after, server, 'webServer').at(-1)!.lines).toEqual([]);
|
||
}
|
||
// What the devices wrote is still there; a ping is numbered from 1, and the notebook's cable lights up with its first frame.
|
||
const restarted = got.length;
|
||
const cable = readProject(exampleDoc()).graph.links.find((link) => link.sourcePortId === port || link.targetPortId === port)!.id;
|
||
enter('ls');
|
||
enter('ping 141.99.5.10');
|
||
for (let waited = 0; !ofKind(got.slice(restarted), 'trace').some((each) => each.frames.length > 0) && waited < 5_000; waited += TICK_MILLISECONDS) run(TICK_MILLISECONDS);
|
||
expect(ofKind(got.slice(restarted), 'activity').at(-1)!.links).toContain(cable);
|
||
run(20_000);
|
||
expect(ofKind(got.slice(restarted), 'app').flatMap((each) => each.lines as { text?: string }[]).some((line) => (line.text ?? '').includes('bleibt.txt'))).toBe(true);
|
||
const frames = ofKind(got.slice(restarted), 'trace').flatMap((each) => each.frames);
|
||
expect(frames.length).toBeGreaterThan(8);
|
||
expect(frames.map((frame) => frame.number)).toEqual(frames.map((_, index) => index + 1));
|
||
});
|
||
|
||
it('starts from the design emptied, where a start keeps the last run', () => {
|
||
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' });
|
||
send({ kind: 'restart' });
|
||
expect(ofKind(got, 'state').at(-1)!.mode).toBe('simulation');
|
||
send({ kind: 'sub', topics: [`trace:${port}`, `app:${notebook.id}/terminal`] });
|
||
expect(ofKind(got, 'trace').at(-1)).toMatchObject({ reset: true, frames: [] });
|
||
expect(ofKind(got, 'app').at(-1)!.lines.some((line) => /ping/.test(JSON.stringify(line)))).toBe(false);
|
||
});
|
||
});
|