ViewersRun takes the rows of Java's viewers from the firmware: a switch's table, a home router's NAT table, each after its reset, and the frames "Pakete verwerfen" drops while it is held. The core writes a frame as Java does and resets the NAT's dynamic entries; the room sends both tables as views, drops while any client holds the button, and sends the dropped frames, begun anew with each start, with the clock their times of day are written from; the editor opens a table with a tap on a switch or home router and holds "Pakete verwerfen" in its toolbar, which now wraps on a phone. This completes F4.6. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
565 lines
33 KiB
TypeScript
565 lines
33 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("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']);
|
||
});
|
||
});
|
||
|