Files
mdg-filius/packages/sim/test/room.test.ts
T
julian.kiedaischandClaude Opus 5.5 5224e1a28b feat: F4.6g4 the Gnutella window, measured against Java's
GnutellaWindowRun uses Java's Gnutella window on terminal-net beside the
line of GnutellaRun's story "search". The core cancels a search as Java
does (every connection of the program's own requests closed, passed-on
queries kept), lets the number of peers change and lists /peer2peer's
files; the room takes the window's actions and sends its view; the editor
shows Java's four tabs with Java's symbols. Not adopted from Java: a number
of peers of one digit only, a results table that keeps the last search's
rows, a files table that lags behind the folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 21:39:29 +00:00

561 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The simulation room against the engine run by hand: what a client gets —
// a card's frames, a program's window, the cables' activity, the room's
// state — is what the engine holds, sent once each and only to the clients
// that subscribed. On FILIUS' example internetwork_komplett: Notebook 1.10
// pings Webserver 5.10 across three routers from its command line.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import * as Y from 'yjs';
import { NetworkRuntimeEngine, type Id } from '@mdg-filius/core';
import { docSnapshot, readProject, writeProject } from '@mdg-filius/doc';
import { openProjectArchive } from '@mdg-filius/fls';
import type { HostMessage, TraceFrame } from '@mdg-filius/protocol';
import { MAXIMUM_CATCH_UP_MILLISECONDS, SimulationRoom, TICK_MILLISECONDS, traceFramesOf } from '../src/index.js';
const corpus = join(dirname(fileURLToPath(import.meta.url)), '../../../corpus');
const SEED = 14;
function exampleDoc(file = 'java-examples/internetwork_komplett.fls'): Y.Doc {
let counter = 0;
const project = openProjectArchive(new Uint8Array(readFileSync(join(corpus, file))), () => `00000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`);
const doc = new Y.Doc();
writeProject(doc, project);
return doc;
}
/** A room on a clock the test moves, and a client that keeps what it gets. */
function setUp(doc = exampleDoc()) {
let now = 1_000_000;
const room = new SimulationRoom(doc, { now: () => now, seed: () => SEED });
const got: HostMessage[] = [];
const client = room.connect((message) => got.push(message));
const project = readProject(doc);
const node = (name: string) => project.graph.nodes.find((each) => each.displayName === name)!;
return {
room,
got,
client,
node,
send: (message: object) => room.receive(client, { v: 1, ...message }),
/** Ticks for so many milliseconds of wall time. */
run: (milliseconds: number) => {
for (let elapsed = 0; elapsed < milliseconds; elapsed += TICK_MILLISECONDS) {
now += TICK_MILLISECONDS;
room.tick();
}
},
jump: (milliseconds: number) => {
now += milliseconds;
room.tick();
},
};
}
const ofKind = <K extends HostMessage['kind']>(got: readonly HostMessage[], kind: K) => got.filter((message): message is Extract<HostMessage, { kind: K }> => message.kind === kind);
describe('a simulation room', () => {
it('tells a new client its state, and every client a change', () => {
const { room, got, send } = setUp();
const other: HostMessage[] = [];
room.connect((message) => other.push(message));
expect(got).toEqual([{ v: 1, kind: 'state', mode: 'design', speed: 90, t: 0 }]);
send({ kind: 'start' });
send({ kind: 'speed', value: 250 });
send({ kind: 'stop' });
for (const each of [got, other]) {
expect(ofKind(each, 'state').map(({ mode, speed }) => [mode, speed])).toEqual([
['design', 90],
['simulation', 90],
['simulation', 100],
['design', 100],
]);
}
});
it("sends a card's frames and a terminal's lines as the engine has them", () => {
const { got, send, node, run } = setUp();
const notebook = node('Notebook 1.10');
const port = notebook.ports[0]!.id;
send({ kind: 'sub', topics: [`trace:${port}`, `app:${notebook.id}/terminal`] });
send({ kind: 'start' });
run(1_000);
send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r1' });
run(8_000);
// The same story on an engine run by hand: started alike, advanced tick by tick, the line typed at the same time.
const engine = new NetworkRuntimeEngine(SEED);
engine.handle({ type: 'start', snapshot: docSnapshot(exampleDoc()), seed: SEED, initialTimeMilliseconds: 0 });
engine.setSimulationSpeed(90);
for (let t = TICK_MILLISECONDS; t <= 1_000; t += TICK_MILLISECONDS) engine.handle({ type: 'advance', toMilliseconds: t });
engine.terminal(notebook.id)!.enter('ping 141.99.5.10');
for (let t = 1_000 + TICK_MILLISECONDS; t <= 9_000; t += TICK_MILLISECONDS) engine.handle({ type: 'advance', toMilliseconds: t });
expect(ofKind(got, 'ack')).toEqual([{ v: 1, kind: 'ack', reqId: 'r1' }]);
const traces = ofKind(got, 'trace');
expect(traces[0]).toMatchObject({ portId: port, reset: true });
expect(traces.slice(1).every((each) => !each.reset && each.frames.length > 0)).toBe(true);
const frames: TraceFrame[] = traces.flatMap((each) => each.frames);
expect(frames).toEqual(traceFramesOf(engine.capture.frames(port)));
expect(frames.length).toBeGreaterThan(8);
const windows = ofKind(got, 'app');
let lines: unknown[] = [];
for (const each of windows) {
expect(each.from).toBe(each.from === 0 ? 0 : lines.length);
lines = [...lines.slice(0, each.from), ...each.lines];
}
// The window: its start screen, the line entered after its prompt, then what the program wrote, as the engine has it.
const program = engine.terminal(notebook.id)!.log.all.map(({ timeMilliseconds: _time, ...entry }) => entry);
expect(lines.slice(0, 3)).toEqual([{ kind: 'text', text: engine.terminal(notebook.id)!.startScreen }, { kind: 'prompt', prompt: '/> ' }, { kind: 'input', prompt: '/> ', line: 'ping 141.99.5.10', command: true }]);
expect(lines.slice(3).filter((line) => (line as { kind: string }).kind !== 'prompt')).toEqual(program);
expect(program.length).toBeGreaterThan(4);
});
it('sends the cables that carry frames when they change, and only to who asked', () => {
const { room, got, send, node, run } = setUp();
const quiet: HostMessage[] = [];
room.connect((message) => quiet.push(message));
send({ kind: 'sub', topics: ['activity'] });
send({ kind: 'start' });
run(1_000);
const notebook = node('Notebook 1.10');
send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r1' });
run(10_000);
const activity = ofKind(got, 'activity');
expect(activity[0]).toEqual({ v: 1, kind: 'activity', links: [] });
expect(activity.some((each) => each.links.length >= 2)).toBe(true);
// Each message differs from the one before; the last, after the ping, says all is quiet.
for (let index = 1; index < activity.length; index += 1) expect(activity[index]!.links).not.toEqual(activity[index - 1]!.links);
expect(activity.at(-1)!.links).toEqual([]);
expect(ofKind(quiet, 'activity')).toEqual([]);
});
it('stops sending a card it no longer watches', () => {
const { got, send, node, run } = setUp();
const notebook = node('Notebook 1.10');
send({ kind: 'sub', topics: [`trace:${notebook.ports[0]!.id}`] });
send({ kind: 'start' });
send({ kind: 'unsub', topics: [`trace:${notebook.ports[0]!.id}`] });
const before = got.length;
send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r1' });
run(5_000);
expect(ofKind(got.slice(before), 'trace')).toEqual([]);
});
it('keeps the capture over a stop and goes on at the time it stopped', () => {
const { got, send, node, run } = setUp();
const notebook = node('Notebook 1.10');
const port = notebook.ports[0]!.id;
send({ kind: 'start' });
send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r1' });
run(5_000);
send({ kind: 'stop' });
const stoppedAt = ofKind(got, 'state').at(-1)!.t;
expect(stoppedAt).toBe(5_000);
send({ kind: 'start' });
expect(ofKind(got, 'state').at(-1)!.t).toBe(stoppedAt);
send({ kind: 'sub', topics: [`trace:${port}`] });
const reset = ofKind(got, 'trace').at(-1)!;
expect(reset.reset).toBe(true);
expect(reset.frames.length).toBeGreaterThan(8);
});
it("keeps a command line's window over a stop and a start, as Java does", () => {
const { got, send, node, run } = setUp();
const notebook = node('Notebook 1.10');
send({ kind: 'sub', topics: [`app:${notebook.id}/terminal`] });
expect(ofKind(got, 'app')).toEqual([{ v: 1, kind: 'app', node: notebook.id, app: 'terminal', from: 0, lines: [] }]);
send({ kind: 'start' });
send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ipconfig' }, reqId: 'r1' });
run(400);
const before = ofKind(got, 'app').reduce((count, each) => each.from + each.lines.length, 0);
expect(before).toBeGreaterThan(3);
const sentBefore = ofKind(got, 'app').length;
send({ kind: 'stop' });
send({ kind: 'start' });
run(400);
// Nothing taken away: the window goes on after its lines.
expect(ofKind(got, 'app').slice(sentBefore).every((each) => each.from >= before)).toBe(true);
send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'pwd' }, reqId: 'r2' });
run(400);
expect(ofKind(got, 'app').at(-1)!.from).toBeGreaterThanOrEqual(before);
});
it('answers an action that cannot be done with its reason', () => {
const { got, send, node } = setUp();
const notebook = node('Notebook 1.10').id;
const reason = (action: object) => {
send({ kind: 'input', reqId: 'r', args: {}, ...action });
return got.at(-1);
};
expect(reason({ node: notebook, app: 'terminal', action: 'enter', args: { line: 'ipconfig' } })).toMatchObject({ kind: 'error', code: 'notRunning', reqId: 'r' });
send({ kind: 'start' });
expect(reason({ node: 'nobody', app: 'terminal', action: 'enter', args: { line: 'x' } })).toMatchObject({ code: 'unknownNode' });
expect(reason({ node: node('Switch 1').id, app: 'terminal', action: 'enter', args: { line: 'x' } })).toMatchObject({ code: 'unknownApp' });
expect(reason({ node: notebook, app: 'terminal', action: 'paint' })).toMatchObject({ code: 'unknownAction' });
// 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');
});
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']);
});
});