// 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 = (got: readonly HostMessage[], kind: K) => got.filter((message): message is Extract => 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' }); expect(reason({ node: notebook, app: 'gnutella', action: 'search' })).toMatchObject({ code: 'unknownAction' }); expect(reason({ node: notebook, app: 'terminal', action: 'enter', args: { line: 7 } })).toMatchObject({ code: 'badArgs' }); expect(reason({ node: notebook, app: 'terminal', action: 'interrupt' })).toEqual({ v: 1, kind: 'ack', reqId: 'r' }); send({ kind: 'nonsense', reqId: 'q' }); expect(got.at(-1)).toEqual({ v: 1, kind: 'error', code: 'unknownKind', detail: 'kind nonsense', reqId: 'q' }); send({ kind: 'speed' }); expect(got.at(-1)).toEqual({ v: 1, kind: 'error', code: 'badMessage', detail: 'speed is no number' }); }); it('makes a cable slower with a lower speed, as Java does', () => { const roundTrip = (speed: number) => { const { got, send, node, run } = setUp(); const notebook = node('Notebook 1.10'); send({ kind: 'sub', topics: [`trace:${notebook.ports[0]!.id}`] }); send({ kind: 'speed', value: speed }); send({ kind: 'start' }); send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r1' }); run(10_000); const rows = ofKind(got, 'trace').flatMap((each) => each.frames).flatMap((frame) => frame.rows).filter((row) => row.protocol === 'ICMP'); return rows[1]!.timeMilliseconds - rows[0]!.timeMilliseconds; }; expect(roundTrip(80)).toBeGreaterThan(roundTrip(90)); expect(roundTrip(90)).toBeGreaterThan(roundTrip(100)); }); it('catches up at most two seconds after the clock jumped, 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>('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 } }; 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][] = [ [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, 'echoServer', 'setActive', { active: true }, 'unknownAction'], ]; 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][] = [ [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']); }); });