A modem that offers a connection to another project is found by six characters derived from its project and itself (modemCode): a copy of the project has another code, so no two modems offer the same one. The room names its modems of other projects to F's relay at a start and none at a stop; the relay pairs each offer with the first modem that joins its code, one to one as Java's server modem takes one client, and passes the frames between the rooms' threads. Every client hears how each modem stands (`modems`: connected, waiting, taken, unpaired, ambiguous, detached, or unavailable without F). The document keeps the connection and the code. Measured on F with two threads: a ping from one project's notebook to the other's goes through; a third project that wants the same code reads taken, and takes it when the second stops; a deleted project's modem leaves with its room. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
787 lines
45 KiB
TypeScript
787 lines
45 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 { afterEach, describe, expect, it, vi } from 'vitest';
|
||
import * as Y from 'yjs';
|
||
import { NetworkRuntimeEngine, type EthernetFrame, type Id } from '@mdg-filius/core';
|
||
import { addDevice, connect, docSnapshot, emptyProject, readProject, setHostAddress, setModemConnection, 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, type RelayModem, type RoomRelay } 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 core that fails at the first frame `node` sends — as it once did at a
|
||
* modem, before the core had modems. Every test lets it go again.
|
||
*/
|
||
function failingAt(node: Id): void {
|
||
const send = NetworkRuntimeEngine.prototype.sendEthernetFrame;
|
||
vi.spyOn(NetworkRuntimeEngine.prototype, 'sendEthernetFrame').mockImplementation(function (this: NetworkRuntimeEngine, from, port, frame) {
|
||
if (from === node) throw new Error('core broke');
|
||
send.call(this, from, port, frame);
|
||
});
|
||
}
|
||
|
||
afterEach(() => {
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
/** A room on a clock the test moves, and a client that keeps what it gets; with `relay`, F's relay to other projects. */
|
||
function setUp(doc = exampleDoc(), relay?: RoomRelay) {
|
||
let now = 1_000_000;
|
||
const room = new SimulationRoom(doc, { now: () => now, seed: () => SEED, epoch: () => 1_700_000_000_000 + now, ...(relay === undefined ? {} : { relay }) });
|
||
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();
|
||
failingAt(node('Webserver 5.10').id);
|
||
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 1.10').id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, 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', () => {
|
||
const { room, got, send, run, node } = setUp();
|
||
failingAt(node('Webserver 5.10').id);
|
||
const other: HostMessage[] = [];
|
||
room.connect((message) => other.push(message));
|
||
const ping = () => send({ kind: 'input', node: node('Notebook 1.10').id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, 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: 'core broke' }]);
|
||
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("runs FILIUS' modem example: a ping goes through the pair of modems", () => {
|
||
const { got, send, run, node } = setUp(exampleDoc('java-examples/modem_beispiel.fls'));
|
||
const notebook = node('Notebook 0.10');
|
||
send({ kind: 'sub', topics: [`app:${notebook.id}/terminal`] });
|
||
send({ kind: 'start' });
|
||
send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 192.168.0.11' }, reqId: 'r' });
|
||
run(10_000);
|
||
expect(ofKind(got, 'error')).toEqual([]);
|
||
const text = ofKind(got, 'app').flatMap((each) => each.lines as { text?: string }[]).map((line) => line.text ?? '').join('');
|
||
expect(text).toContain('4 Paket(e) gesendet, 4 Paket(e) empfangen');
|
||
});
|
||
|
||
it("tells every client how the modems stand: FILIUS' pair connected while it runs, nothing in the design", () => {
|
||
const { room, got, send, node } = setUp(exampleDoc('java-examples/modem_beispiel.fls'));
|
||
const modems = readProject(exampleDoc('java-examples/modem_beispiel.fls')).graph.nodes.filter((each) => each.kind === 'remoteLink');
|
||
expect(modems).toHaveLength(2);
|
||
send({ kind: 'start' });
|
||
expect(ofKind(got, 'modems')).toEqual([{ v: 1, kind: 'modems', modems: Object.fromEntries(modems.map((each) => [node(each.displayName).id, 'connected'])) }]);
|
||
// One who comes later hears it at once.
|
||
const later: HostMessage[] = [];
|
||
room.connect((message) => later.push(message));
|
||
expect(ofKind(later, 'modems')).toEqual(ofKind(got, 'modems'));
|
||
send({ kind: 'stop' });
|
||
expect(ofKind(got, 'modems').at(-1)).toEqual({ v: 1, kind: 'modems', modems: {} });
|
||
});
|
||
|
||
it('names its modems of other projects to the relay at a start and none at a stop, passes their frames on and takes what comes back', () => {
|
||
const doc = new Y.Doc();
|
||
writeProject(doc, emptyProject());
|
||
let counter = 0;
|
||
const newId = () => `00000000-0000-0000-0000-${(++counter).toString(16).padStart(12, 'a')}`;
|
||
const [computer, modem] = [newId(), newId()];
|
||
addDevice(doc, { kind: 'notebook', id: computer, name: 'Notebook', x: 0, y: 0, newId }, 'setup');
|
||
addDevice(doc, { kind: 'remoteLink', id: modem, name: 'Modem', x: 200, y: 0, newId }, 'setup');
|
||
connect(doc, { node: computer }, { node: modem }, newId(), 'setup');
|
||
setHostAddress(doc, computer, 'ipAddress', '10.0.0.1', 'setup');
|
||
setModemConnection(doc, modem, 'offer', 'setup');
|
||
const named: (readonly RelayModem[])[] = [];
|
||
const passed: EthernetFrame[] = [];
|
||
const { room, got, send, run } = setUp(doc, { modems: (modems) => named.push(modems), frame: (_node, frame) => passed.push(frame) });
|
||
send({ kind: 'start' });
|
||
expect(named).toEqual([[{ node: modem, relay: { role: 'offer' } }]]);
|
||
expect(ofKind(got, 'modems').at(-1)!.modems).toEqual({ [modem]: 'waiting' });
|
||
room.relayStatesChanged({ [modem]: 'connected' });
|
||
room.relayStatesChanged({ [modem]: 'connected' });
|
||
// Said once: what did not change is not sent again.
|
||
expect(ofKind(got, 'modems').map((each) => each.modems)).toEqual([{ [modem]: 'waiting' }, { [modem]: 'connected' }]);
|
||
// An ARP request from the other project: the notebook answers through the modem.
|
||
room.relayed(modem, {
|
||
identity: 1,
|
||
sourceMacAddress: '02:00:00:00:99:01',
|
||
destinationMacAddress: 'FF:FF:FF:FF:FF:FF',
|
||
payload: { kind: 'arp', packet: { operation: 'request', senderMacAddress: '02:00:00:00:99:01', senderIPAddress: '10.0.0.2', targetMacAddress: 'FF:FF:FF:FF:FF:FF', targetIPAddress: '10.0.0.1' } },
|
||
});
|
||
run(1_000);
|
||
expect(passed.map((frame) => frame.payload.kind === 'arp' && frame.payload.packet.operation)).toEqual(['reply']);
|
||
expect(passed[0]!.destinationMacAddress).toBe('02:00:00:00:99:01');
|
||
send({ kind: 'stop' });
|
||
expect(named.at(-1)).toEqual([]);
|
||
// Stopped, it takes nothing.
|
||
room.relayed(modem, passed[0]!);
|
||
expect(ofKind(got, 'error')).toEqual([]);
|
||
});
|
||
|
||
it('names no modem that is switched off', () => {
|
||
const doc = new Y.Doc();
|
||
writeProject(doc, emptyProject());
|
||
addDevice(doc, { kind: 'remoteLink', id: 'modem', name: 'Modem', x: 0, y: 0, newId: () => 'port' }, 'setup');
|
||
setModemConnection(doc, 'modem', 'offer', 'setup');
|
||
((doc.getMap<Y.Map<unknown>>('nodes').get('modem')!.get('settings') as Y.Map<Y.Map<unknown>>).get('remoteLink')!).set('isEnabled', false);
|
||
const named: (readonly RelayModem[])[] = [];
|
||
const { send } = setUp(doc, { modems: (modems) => named.push(modems), frame: () => {} });
|
||
send({ kind: 'start' });
|
||
expect(named).toEqual([[]]);
|
||
});
|
||
|
||
it('says a modem of another project is unavailable without a relay (a room of one\'s own page)', () => {
|
||
const doc = new Y.Doc();
|
||
writeProject(doc, emptyProject());
|
||
addDevice(doc, { kind: 'remoteLink', id: 'modem', name: 'Modem', x: 0, y: 0, newId: () => 'port' }, 'setup');
|
||
addDevice(doc, { kind: 'pc', id: 'pc', name: 'Rechner', x: 0, y: 0, newId: () => 'pc-port' }, 'setup');
|
||
connect(doc, { node: 'pc' }, { node: 'modem' }, 'cable', 'setup');
|
||
setModemConnection(doc, 'modem', 'join', 'setup');
|
||
const { got, send } = setUp(doc);
|
||
send({ kind: 'start' });
|
||
expect(ofKind(got, 'modems').at(-1)!.modems).toEqual({ modem: 'unavailable' });
|
||
});
|
||
|
||
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();
|
||
const { got, send, run, node } = setUp(doc);
|
||
failingAt(node('Webserver 5.10').id);
|
||
const ping = () => send({ kind: 'input', node: node('Notebook 1.10').id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r' });
|
||
send({ kind: 'start' });
|
||
ping();
|
||
run(10_000);
|
||
expect(ofKind(got, 'error')).toHaveLength(1);
|
||
// Without the cables, nothing reaches the web server, which fails the core: 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);
|
||
});
|
||
});
|