// The device sheet: what a field takes, measured against FILIUS' own settings // panels (tools/java-oracle's SheetRun typed the texts of sheet.cases.txt // into them, sheet.java.json), and what its changes do to the document — the // wireless connections moved as Java moves them, each change one step of // the person's undo, and everything kept through a saved file. 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 { openProjectArchive, saveProjectArchive } from '@mdg-filius/fls'; import { addDevice, checkField, connect, createUndoManager, deviceSheet, readProject, renameDevice, ROOTS, setDhcpClient, setHostAddress, setHostLabels, setHostWireless, setIpForwarding, setModemPort, setSwitchCloud, setSwitchRetention, setSwitchSsid, addRoute, removeRoute, setInterfaceAddress, setRip, setRouteCell, setRouterGateway, topologyView, type FieldRule, type HomeRouterSheet, type HostSheet, type NewDevice, type RouterSheet, } from '../src/index.js'; interface JavaCase { readonly device: 'host' | 'switch' | 'modem' | 'router' | 'homeRouter'; readonly field: string; readonly input: string; readonly start: string; readonly marked: boolean; /** null: Java's card lost its address. */ readonly stored: string | null; readonly shown: string; /** What Java's panel threw, if it did. */ readonly error?: string; } interface JavaRow { readonly cells: readonly (string | null)[]; readonly manual: boolean; } const java = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'sheet.java.json'), 'utf8')) as { readonly cases: readonly JavaCase[]; readonly flows: { readonly useMacAsNameThenOff: string; readonly useIpAsNameThenOff: string; readonly dhcpClientStopsServer: boolean; readonly dhcpClient: boolean; readonly homeRouterDhcpClientStopsServer: boolean; readonly homeRouterDhcpClient: boolean; }; readonly tables: Readonly>; readonly edits: Readonly>; }; const RULES: Readonly> = { 'host name': 'name', 'host ip': 'ipAddress', 'host mask': 'subnetMask', 'host gateway': 'ipAddressOrEmpty', 'host dns': 'ipAddressOrEmpty', 'switch name': 'name', 'switch ssid': 'ssid', 'switch retention': 'seconds', 'modem name': 'name', 'modem port': 'port', 'router name': 'name', 'router ip': 'ipAddress', 'router mask': 'subnetMask', 'router gateway': 'ipAddressOrEmpty', 'homeRouter name': 'name', 'homeRouter gateway': 'ipAddressOrEmpty', 'homeRouter wanIp': 'ipAddress', 'homeRouter wanMask': 'subnetMask', 'homeRouter lanIp': 'ipAddress', 'homeRouter lanMask': 'subnetMask', }; /** Fields whose text is judged without the spaces around it. */ const TRIMMED = new Set( Object.entries(RULES) .filter(([key, rule]) => rule !== 'name' && rule !== 'ssid') .map(([key]) => key), ); /** Whether Java's device took the text: its value changed (to an address: null is a card that lost its own), or was the text already. */ const tookIt = (each: JavaCase) => each.stored !== null && (each.stored !== each.start || each.input === each.start); /** What Java's model holds in the units of the sheet's value: seconds for the retention time. */ const javaValue = (each: JavaCase) => (each.field === 'retention' ? String(Number(each.stored) / 1000) : each.stored); /** The case Java judged for a text: the trimmed text's own case where spaces are left out. */ function judged(each: JavaCase): JavaCase { const key = `${each.device} ${each.field}`; if (!TRIMMED.has(key) || each.input.trim() === each.input) return each; const twin = java.cases.find((other) => other.device === each.device && other.field === each.field && other.input === each.input.trim()); if (twin === undefined) throw new Error(`no case for ${JSON.stringify(each.input.trim())} (${key})`); return twin; } describe("a field's rule, against FILIUS' settings panels", () => { it('has a case for every field the sheet checks', () => { expect(new Set(java.cases.map((each) => `${each.device} ${each.field}`))).toEqual(new Set(Object.keys(RULES))); expect(java.cases.length).toBeGreaterThan(70); }); it('takes a text exactly when Java neither marks nor refuses it, as the value Java keeps', () => { for (const each of java.cases) { const key = `${each.device} ${each.field}`; const verdict = judged(each); // Taken: the value changed, or the text was the value already. const took = tookIt(verdict); // An empty gateway Java's setter takes as "none", though a router's panel marks it (its key listener checks musterIpAdresse). const emptyTaken = RULES[key] === 'ipAddressOrEmpty' && verdict.input.trim() === '' && took; // A name is taken as typed. A modem's port Java never marks: it takes any int; here Java's own rule for ports. const expected = RULES[key] === 'name' ? true : key === 'modem port' ? took && /^([1-9][0-9]{0,4})$/.test(verdict.input) && Number(verdict.input) <= 65_535 : emptyTaken || (!verdict.marked && took); const checked = checkField(RULES[key]!, each.input); expect('value' in checked, `${key} ${JSON.stringify(each.input)}`).toBe(expected); if ('value' in checked) expect(checked.value, `${key} ${JSON.stringify(each.input)}`).toBe(RULES[key] === 'name' ? each.input : javaValue(verdict)); } }); it("names where Java's two judgements disagree, which here are one", () => { const disagreeing = java.cases.filter((each) => RULES[`${each.device} ${each.field}`] !== 'name' && each.field !== 'port' && each.marked === tookIt(each)); // Marked red, yet taken; or black, yet refused (the gateway's spaces Java trims to empty) — or lost: a card without its address. const verdictOf = (each: JavaCase) => (each.marked ? 'marked, taken' : each.stored === null ? `lost${each.error === undefined ? '' : `, ${each.error}`}` : 'refused'); expect(disagreeing.map((each) => `${each.device} ${each.field} ${JSON.stringify(each.input)} ${verdictOf(each)}`)).toEqual([ 'host ip "10..0.0.1" marked, taken', 'host ip ".0.0.1" refused', 'host ip "+10.0.0.1" marked, taken', 'host ip "10.0.0.1." marked, taken', 'host ip "١٠.0.0.1" marked, taken', 'host ip "-0.0.0.1" marked, taken', 'host mask "255..255.0.0" marked, taken', 'host gateway " " marked, taken', 'host gateway "192.168..1.1" marked, taken', 'host dns " " marked, taken', 'switch retention "" refused', 'switch retention "-5" marked, taken', 'switch retention "99999999999999999999" refused', 'switch retention "٣" marked, taken', 'router ip ".0.0.1" lost, NullPointerException', 'router gateway "" marked, taken', 'router gateway " " marked, taken', 'router gateway "192.168..0.1" marked, taken', 'homeRouter gateway "" marked, taken', 'homeRouter wanIp ".1.2.3" lost', ]); const ports = java.cases.filter((each) => each.field === 'port' && each.stored !== each.start); expect(ports.map((each) => each.input)).toEqual(['0', '65535', '65536', '-1', '080', '80']); }); it("shows what the device holds once a field is left, as Java's field does", () => { for (const each of java.cases) if (each.field !== 'retention') expect(each.shown, each.input).toBe(each.stored ?? ''); }); }); let ids = 0; const newId = () => `20000000-0000-0000-0000-${(++ids).toString(16).padStart(12, '0')}`; function add(doc: Y.Doc, kind: NewDevice['kind'], name: string = kind, interfaces?: number): string { const id = newId(); addDevice(doc, { kind, id, name, x: 100, y: 100, newId, ...(interfaces === undefined ? {} : { interfaces }) }, 'me'); return id; } const host = (doc: Y.Doc, id: string) => deviceSheet(doc, id) as HostSheet; describe("a PC's sheet", () => { it("shows a new PC as Java's panel does", () => { const doc = new Y.Doc(); const pc = add(doc, 'pc', 'Rechner'); const sheet = host(doc, pc); expect({ ...sheet, macAddress: undefined }).toEqual({ kind: 'pc', name: 'Rechner', labels: { ipAddress: false, macAddress: false }, macAddress: undefined, ipAddress: '192.168.0.10', subnetMask: '255.255.255.0', defaultGateway: '', dnsServer: '', dhcpClient: false, ipForwarding: false, wireless: false, ssid: '', wirelessPossible: true, networks: [], }); expect(sheet.macAddress).toMatch(/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/); expect(deviceSheet(doc, 'nobody')).toBeUndefined(); }); it('takes an address as Java keeps it, keeps the old one for a marked text, and says which', () => { const doc = new Y.Doc(); const pc = add(doc, 'pc'); expect(setHostAddress(doc, pc, 'ipAddress', ' 010.000.000.001 ', 'me')).toEqual({ value: '10.0.0.1' }); expect(setHostAddress(doc, pc, 'ipAddress', '10.0.0.256', 'me')).toEqual({ invalid: true }); expect(setHostAddress(doc, pc, 'subnetMask', '255.0.255.0', 'me')).toEqual({ value: '255.0.255.0' }); expect(setHostAddress(doc, pc, 'defaultGateway', '10.0.0.254', 'me')).toEqual({ value: '10.0.0.254' }); expect(setHostAddress(doc, pc, 'dnsServer', '8.8.8', 'me')).toEqual({ invalid: true }); expect(setHostAddress(doc, pc, 'dnsServer', '8.8.8.8', 'me')).toEqual({ value: '8.8.8.8' }); expect(readProject(doc).deviceConfigurations.get(pc)).toEqual({ ipAddress: '10.0.0.1', subnetMask: '255.0.255.0', defaultGateway: '10.0.0.254', dnsServer: '8.8.8.8' }); expect(setHostAddress(doc, pc, 'defaultGateway', '', 'me')).toEqual({ value: '' }); expect(host(doc, pc).defaultGateway).toBe(''); expect(setHostAddress(doc, 'nobody', 'ipAddress', '10.0.0.1', 'me')).toEqual({ value: '10.0.0.1' }); }); it('is renamed, and shows its address, its MAC address or both as its name', () => { const doc = new Y.Doc(); const pc = add(doc, 'pc'); renameDevice(doc, pc, 'PC-A', 'me'); setHostLabels(doc, pc, { ipAddress: true, macAddress: false }, 'me'); expect(topologyView(doc).nodes[0]!.label).toBe('192.168.0.10'); setHostLabels(doc, pc, { ipAddress: false, macAddress: true }, 'me'); expect(topologyView(doc).nodes[0]!.label).toBe(host(doc, pc).macAddress); setHostLabels(doc, pc, { ipAddress: true, macAddress: true }, 'me'); expect(host(doc, pc).labels).toEqual({ ipAddress: true, macAddress: true }); expect(topologyView(doc).nodes[0]!.label).toBe(`192.168.0.10 (${host(doc, pc).macAddress})`); setHostLabels(doc, pc, { ipAddress: false, macAddress: false }, 'me'); expect(topologyView(doc).nodes[0]!.label).toBe('PC-A'); }); it("keeps its name while it shows its MAC address and another field is taken (Java's panel overwrites it)", () => { // Java: the name became the MAC address; with the address shown instead, it stayed. expect(java.flows.useMacAsNameThenOff).toMatch(/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/); expect(java.flows.useIpAsNameThenOff).toBe('PC-A'); const doc = new Y.Doc(); const pc = add(doc, 'pc', 'PC-A'); setHostLabels(doc, pc, { ipAddress: false, macAddress: true }, 'me'); setHostAddress(doc, pc, 'defaultGateway', '192.168.0.1', 'me'); setHostLabels(doc, pc, { ipAddress: false, macAddress: false }, 'me'); expect(host(doc, pc).name).toBe('PC-A'); }); it("keeps IP forwarding only where it differs from the kind's", () => { const doc = new Y.Doc(); const pc = add(doc, 'pc'); const router = add(doc, 'router'); setIpForwarding(doc, pc, true, 'me'); setIpForwarding(doc, router, false, 'me'); expect(host(doc, pc).ipForwarding).toBe(true); expect(readProject(doc).ipForwardingByNodeId).toEqual( new Map([ [pc, true], [router, false], ]), ); setIpForwarding(doc, pc, false, 'me'); setIpForwarding(doc, router, true, 'me'); expect(readProject(doc).ipForwardingByNodeId).toEqual(new Map()); }); it("uses DHCP, which stops its own DHCP server, as Java's panel does", () => { expect(java.flows).toMatchObject({ dhcpClient: true, dhcpClientStopsServer: true }); const doc = new Y.Doc(); const pc = add(doc, 'pc'); const settings = doc.getMap>(ROOTS.nodes).get(pc)!.get('settings') as Y.Map; const server = new Y.Map(); server.set('isActive', true); settings.set('dhcpServer', server); setDhcpClient(doc, pc, true, 'me'); expect(host(doc, pc).dhcpClient).toBe(true); expect(readProject(doc).dhcpClientConfigurations.get(pc)).toEqual({ isEnabled: true }); expect(server.get('isActive')).toBe(false); setDhcpClient(doc, pc, false, 'me'); expect(host(doc, pc).dhcpClient).toBe(false); expect(readProject(doc).dhcpClientConfigurations.has(pc)).toBe(false); }); }); describe('a wireless card', () => { /** Two switches with their SSIDs and a notebook. */ function wlan() { const doc = new Y.Doc(); const first = add(doc, 'networkSwitch'); const second = add(doc, 'networkSwitch'); const notebook = add(doc, 'notebook'); setSwitchSsid(doc, first, 'Schule', 'me'); setSwitchSsid(doc, second, 'Gast', 'me'); const ends = () => readProject(doc).graph.links.map((link) => [link.sourceNodeId, link.targetNodeId]); return { doc, first, second, notebook, ends }; } it('offers the switches’ networks, each once', () => { const { doc, notebook } = wlan(); const third = add(doc, 'networkSwitch'); setSwitchSsid(doc, third, 'Schule', 'me'); expect(host(doc, notebook).networks).toEqual(['Schule', 'Gast']); }); it('joins the first switch with its SSID, moves with the SSID and leaves on the cable', () => { const { doc, first, second, notebook, ends } = wlan(); expect(setHostWireless(doc, notebook, true, 'Schule', 'link-1', 'me')).toEqual({ link: 'link-1' }); expect(ends()).toEqual([[notebook, first]]); expect(topologyView(doc).links[0]!.wireless).toBe(true); expect(host(doc, notebook)).toMatchObject({ wireless: true, ssid: 'Schule', wirelessPossible: true }); expect(setHostWireless(doc, notebook, true, 'Gast', 'link-2', 'me')).toEqual({ link: 'link-2' }); expect(ends()).toEqual([[notebook, second]]); // The same again changes nothing. expect(setHostWireless(doc, notebook, true, 'Gast', 'link-3', 'me')).toEqual({}); expect(ends()).toEqual([[notebook, second]]); expect(setHostWireless(doc, notebook, false, 'Gast', 'link-4', 'me')).toEqual({}); expect(ends()).toEqual([]); // Java keeps the SSID with the cable. expect(readProject(doc).hostWirelessConfigurations.get(notebook)).toEqual({ isEnabled: false, ssid: 'Gast' }); expect(setHostWireless(doc, notebook, false, '', 'link-5', 'me')).toEqual({}); expect(readProject(doc).hostWirelessConfigurations.has(notebook)).toBe(false); }); it('joins the first of two switches with its SSID', () => { const { doc, second, notebook, ends } = wlan(); const third = add(doc, 'networkSwitch'); setSwitchSsid(doc, third, 'Gast', 'me'); expect(setHostWireless(doc, notebook, true, 'Gast', 'link-1', 'me')).toEqual({ link: 'link-1' }); expect(ends()).toEqual([[notebook, second]]); }); it('joins no network without an SSID or without a switch that has it', () => { const { doc, notebook, ends } = wlan(); expect(setHostWireless(doc, notebook, true, '', 'link-1', 'me')).toEqual({}); expect(setHostWireless(doc, notebook, true, 'Nirgends', 'link-2', 'me')).toEqual({}); expect(ends()).toEqual([]); expect(host(doc, notebook)).toMatchObject({ wireless: true, ssid: 'Nirgends' }); }); it('cannot be chosen while its cable is plugged, and says why a full switch refuses it', () => { const { doc, first, notebook } = wlan(); const pc = add(doc, 'pc'); connect(doc, { node: notebook }, { node: pc }, 'cable', 'me'); expect(host(doc, notebook).wirelessPossible).toBe(false); expect(setHostWireless(doc, notebook, true, 'Schule', 'link-1', 'me')).toEqual({ error: 'cablePlugged' }); expect(host(doc, notebook).wireless).toBe(false); const other = add(doc, 'notebook'); const full = doc.getMap>(ROOTS.nodes).get(first)!; (full.get('ports') as Y.Array>).delete(1, 23); expect(connect(doc, { node: add(doc, 'pc') }, { node: first }, 'filled', 'me')).toEqual({ link: 'filled' }); expect(setHostWireless(doc, other, true, 'Schule', 'link-2', 'me')).toEqual({ error: 'noFreePort' }); expect(setHostWireless(doc, 'nobody', true, 'Schule', 'link-3', 'me')).toEqual({ error: 'unknownDevice' }); }); it("is dropped by a switch that changes its SSID, a cable to it staying (Java's panel)", () => { const { doc, first, notebook, ends } = wlan(); const pc = add(doc, 'pc'); setHostWireless(doc, notebook, true, 'Schule', 'link-1', 'me'); connect(doc, { node: pc }, { node: first }, 'cable', 'me'); expect(setSwitchSsid(doc, first, 'Schule', 'me')).toEqual({ value: 'Schule' }); expect(ends()).toHaveLength(2); expect(setSwitchSsid(doc, first, 'Ä', 'me')).toEqual({ invalid: true }); expect(ends()).toHaveLength(2); expect(setSwitchSsid(doc, first, 'Neu', 'me')).toEqual({ value: 'Neu' }); expect(ends()).toEqual([[pc, first]]); }); }); describe("a switch's and a modem's sheet", () => { it('shows and keeps the SSID, the retention time in seconds and the cloud', () => { const doc = new Y.Doc(); const hub = add(doc, 'networkSwitch', 'Switch'); expect(deviceSheet(doc, hub)).toEqual({ kind: 'networkSwitch', name: 'Switch', ssid: hub.replaceAll('-', '').slice(0, 6), retentionSeconds: 300, cloud: false }); expect(setSwitchRetention(doc, hub, ' 007 ', 'me')).toEqual({ value: '7' }); expect(setSwitchRetention(doc, hub, '-5', 'me')).toEqual({ invalid: true }); setSwitchCloud(doc, hub, true, 'me'); expect(deviceSheet(doc, hub)).toMatchObject({ retentionSeconds: 7, cloud: true }); expect(topologyView(doc).nodes[0]!.cloud).toBe(true); expect(readProject(doc).switchConfigurations.get(hub)).toEqual({ ssid: hub.replaceAll('-', '').slice(0, 6), retentionTimeMilliseconds: 7000, showsAsCloud: true }); setSwitchCloud(doc, hub, false, 'me'); expect(readProject(doc).switchConfigurations.get(hub)).toEqual({ ssid: hub.replaceAll('-', '').slice(0, 6), retentionTimeMilliseconds: 7000 }); }); it('shows whole seconds of a retention time a file brings in milliseconds', () => { const doc = new Y.Doc(); const hub = add(doc, 'networkSwitch'); (doc.getMap>(ROOTS.nodes).get(hub)!.get('settings') as Y.Map>).get('switch')!.set('retentionTimeMilliseconds', 1999); expect(deviceSheet(doc, hub)).toMatchObject({ retentionSeconds: 1 }); }); it('pairs a modem by its port, and renames a router', () => { const doc = new Y.Doc(); const modem = add(doc, 'remoteLink', 'Modem'); expect(deviceSheet(doc, modem)).toEqual({ kind: 'remoteLink', name: 'Modem', port: 12_345 }); expect(setModemPort(doc, modem, '65536', 'me')).toEqual({ invalid: true }); expect(setModemPort(doc, modem, ' 80', 'me')).toEqual({ value: '80' }); expect(readProject(doc).remoteLinkConfigurations.get(modem)).toEqual({ pairIdentifier: 'java-modem-port-80', latencyMilliseconds: 20, isEnabled: true }); expect(deviceSheet(doc, modem)).toMatchObject({ port: 80 }); const router = add(doc, 'router', 'R1'); renameDevice(doc, router, 'R2', 'me'); expect(deviceSheet(doc, router)).toMatchObject({ kind: 'router', name: 'R2' }); expect(deviceSheet(doc, add(doc, 'gateway', 'Heim'))).toMatchObject({ kind: 'gateway', name: 'Heim' }); }); }); describe("the sheet's changes", () => { it('are each one step of the person’s undo, the wireless join with its connection', () => { const doc = new Y.Doc(); const hub = add(doc, 'networkSwitch'); const notebook = add(doc, 'notebook'); setSwitchSsid(doc, hub, 'Schule', 'setup'); const undo = createUndoManager(doc, 'me'); setHostAddress(doc, notebook, 'ipAddress', '10.0.0.1', 'me'); undo.stopCapturing(); setHostWireless(doc, notebook, true, 'Schule', 'link-1', 'me'); undo.stopCapturing(); renameDevice(doc, notebook, 'NB', 'me'); undo.stopCapturing(); undo.undo(); expect(host(doc, notebook).name).toBe('notebook'); undo.undo(); expect(host(doc, notebook).wireless).toBe(false); expect(readProject(doc).graph.links).toEqual([]); undo.undo(); expect(host(doc, notebook).ipAddress).toBe('192.168.0.10'); undo.redo(); undo.redo(); expect(host(doc, notebook)).toMatchObject({ ipAddress: '10.0.0.1', wireless: true }); expect(readProject(doc).graph.links).toHaveLength(1); }); it('are kept through a saved file', () => { const doc = new Y.Doc(); const pc = add(doc, 'pc', 'PC-A'); const hub = add(doc, 'networkSwitch', 'S'); const modem = add(doc, 'remoteLink', 'M'); setHostAddress(doc, pc, 'ipAddress', '10.1.2.3', 'me'); setHostAddress(doc, pc, 'subnetMask', '255.255.0.0', 'me'); setHostAddress(doc, pc, 'defaultGateway', '10.1.0.1', 'me'); setHostAddress(doc, pc, 'dnsServer', '10.1.0.2', 'me'); setHostLabels(doc, pc, { ipAddress: true, macAddress: false }, 'me'); setIpForwarding(doc, pc, true, 'me'); setDhcpClient(doc, pc, true, 'me'); setSwitchSsid(doc, hub, 'Schule', 'me'); setSwitchRetention(doc, hub, '42', 'me'); setSwitchCloud(doc, hub, true, 'me'); setModemPort(doc, modem, '4242', 'me'); let counter = 0; const reopened = openProjectArchive(saveProjectArchive(readProject(doc)).archive, () => `30000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`); const [pcAgain, hubAgain, modemAgain] = reopened.graph.nodes; expect(pcAgain!.hostLabelPolicy).toBe('ipAddress'); expect(reopened.deviceConfigurations.get(pcAgain!.id)).toEqual({ ipAddress: '10.1.2.3', subnetMask: '255.255.0.0', defaultGateway: '10.1.0.1', dnsServer: '10.1.0.2' }); expect(reopened.ipForwardingByNodeId.get(pcAgain!.id)).toBe(true); expect(reopened.dhcpClientConfigurations.get(pcAgain!.id)).toEqual({ isEnabled: true }); expect(reopened.switchConfigurations.get(hubAgain!.id)).toEqual({ ssid: 'Schule', retentionTimeMilliseconds: 42_000, showsAsCloud: true }); expect(reopened.remoteLinkConfigurations.get(modemAgain!.id)?.pairIdentifier).toBe('java-modem-port-4242'); }); }); const router = (doc: Y.Doc, id: string) => deviceSheet(doc, id) as RouterSheet; const home = (doc: Y.Doc, id: string) => deviceSheet(doc, id) as HomeRouterSheet; /** Java's manual rows of the measured tables, as SheetRun added them. */ const JAVA_MANUAL = [ { destinationNetwork: '172.16.0.0', subnetMask: '255.255.0.0', gateway: '192.168.1.2', interfaceIPAddress: '192.168.1.1' }, { destinationNetwork: '010.020.0.0', subnetMask: '255.255.0.0', gateway: '10.0.0.2', interfaceIPAddress: '10.0.0.1' }, ]; /** The measured tables' routers: their cards (address, mask), gateway and manual rows. */ const TABLES: Readonly> = { gatewayInSecondNetwork: { cards: [ ['10.0.0.1', '255.0.0.0'], ['192.168.1.1', '255.255.255.0'], ], gateway: '192.168.1.254', manual: true, }, gatewayInNoNetwork: { cards: [ ['10.0.0.1', '255.0.0.0'], ['192.168.1.1', '255.255.255.0'], ], gateway: '172.30.0.1', manual: true, }, gatewayInBothNetworks: { cards: [ ['10.0.0.1', '255.0.0.0'], ['10.1.0.1', '255.255.0.0'], ], gateway: '10.1.0.254', manual: false, }, noGateway: { cards: [ ['10.0.0.1', '255.0.0.0'], ['192.168.1.1', '255.255.255.0'], ], gateway: '', manual: true, }, threeInterfaces: { cards: [ ['10.0.0.1', '255.0.0.0'], ['192.168.1.1', '255.255.255.0'], ['172.16.5.1', '255.255.0.0'], ], gateway: '10.0.0.254', manual: false, }, }; describe("a router's sheet", () => { it("shows the forwarding table Java shows, in Java's order, its manual rows editable", () => { expect(Object.keys(java.tables).sort()).toEqual(Object.keys(TABLES).sort()); for (const [name, setup] of Object.entries(TABLES)) { const doc = new Y.Doc(); const id = add(doc, 'router', 'R', setup.cards.length); router(doc, id).interfaces.forEach((card, index) => { expect(setInterfaceAddress(doc, id, card.portId, 'ipAddress', setup.cards[index]![0], 'me')).toEqual({ value: setup.cards[index]![0] }); expect(setInterfaceAddress(doc, id, card.portId, 'subnetMask', setup.cards[index]![1], 'me')).toEqual({ value: setup.cards[index]![1] }); }); setRouterGateway(doc, id, setup.gateway, 'me'); if (setup.manual) for (const row of JAVA_MANUAL) expect('route' in addRoute(doc, id, row, 'me')).toBe(true); const table = router(doc, id).table.map((row) => ({ cells: [row.destinationNetwork, row.subnetMask, row.nextHop, row.interfaceIPAddress], manual: row.isEditable })); expect(table, name).toEqual(java.tables[name]); } }); it('takes a card’s address as Java keeps it and keeps the old one for a refused text, the first card mirrored in the device', () => { const doc = new Y.Doc(); const id = add(doc, 'router'); const [first, second] = router(doc, id).interfaces; expect(setInterfaceAddress(doc, id, first!.portId, 'ipAddress', ' 010.0.0.1 ', 'me')).toEqual({ value: '10.0.0.1' }); expect(setInterfaceAddress(doc, id, first!.portId, 'ipAddress', '.0.0.1', 'me')).toEqual({ invalid: true }); expect(setInterfaceAddress(doc, id, second!.portId, 'subnetMask', '255.255', 'me')).toEqual({ invalid: true }); expect(setInterfaceAddress(doc, id, second!.portId, 'subnetMask', '255.0.255.0', 'me')).toEqual({ value: '255.0.255.0' }); expect(setInterfaceAddress(doc, id, 'no-port', 'ipAddress', '10.0.0.1', 'me')).toEqual({ value: '10.0.0.1' }); expect(router(doc, id).interfaces.map((card) => [card.ipAddress, card.subnetMask])).toEqual([ ['10.0.0.1', '255.255.255.0'], ['192.168.0.10', '255.0.255.0'], ]); // A router keeps its own settings only with a gateway; then they follow its first card. expect(readProject(doc).deviceConfigurations.has(id)).toBe(false); expect(setRouterGateway(doc, id, '10.0.0.254', 'me')).toEqual({ value: '10.0.0.254' }); expect(readProject(doc).deviceConfigurations.get(id)).toEqual({ ipAddress: '10.0.0.1', subnetMask: '255.255.255.0', defaultGateway: '10.0.0.254', dnsServer: '' }); setInterfaceAddress(doc, id, first!.portId, 'ipAddress', '10.0.0.2', 'me'); setInterfaceAddress(doc, id, first!.portId, 'subnetMask', '255.0.0.0', 'me'); expect(readProject(doc).deviceConfigurations.get(id)).toMatchObject({ ipAddress: '10.0.0.2', subnetMask: '255.0.0.0' }); expect(setRouterGateway(doc, id, 'abc', 'me')).toEqual({ invalid: true }); expect(router(doc, id).gateway).toBe('10.0.0.254'); expect(setRouterGateway(doc, id, ' ', 'me')).toEqual({ value: '' }); expect(readProject(doc).deviceConfigurations.has(id)).toBe(false); }); it('switches RIP, and shows IP forwarding on', () => { const doc = new Y.Doc(); const id = add(doc, 'router'); expect(router(doc, id)).toMatchObject({ rip: false, ipForwarding: true, gateway: '' }); setRip(doc, id, true, 'me'); expect(router(doc, id).rip).toBe(true); expect(readProject(doc).ripEnabledByNodeId.get(id)).toBe(true); setRip(doc, id, false, 'me'); expect(readProject(doc).ripEnabledByNodeId.has(id)).toBe(false); }); it('names what each card is plugged into, by its label', () => { const doc = new Y.Doc(); const id = add(doc, 'router'); const pc = add(doc, 'pc', 'PC-A'); setHostLabels(doc, pc, { ipAddress: true, macAddress: false }, 'me'); connect(doc, { node: pc }, { node: id }, 'cable', 'me'); expect(router(doc, id).interfaces.map((card) => card.connected)).toEqual([{ device: '192.168.0.10', link: 'cable' }, null]); expect(router(doc, id).interfaces[0]!.macAddress).toMatch(/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/); }); }); describe("a router's manual rows", () => { /** A router with Java's one manual row of SheetRun's edits. */ function edited() { const doc = new Y.Doc(); const id = add(doc, 'router'); addRoute(doc, id, { destinationNetwork: '172.16.0.0', subnetMask: '255.255.0.0', gateway: '192.168.0.2', interfaceIPAddress: '192.168.0.10' }, 'me'); const rows = () => readProject(doc) .manualRoutesByNodeId.get(id) ?.map((row) => [row.destinationNetwork, row.subnetMask, row.gateway, row.interfaceIPAddress]) ?? []; return { doc, id, rows }; } it('take a cell as Java does where Java takes it, and keep it where Java stores null', () => { for (const [name, column, text] of [ ['leadingZeros', 'destinationNetwork', '010.0.0.0'], ['maskCell', 'subnetMask', '255.0.255.0'], ] as const) { const { doc, id, rows } = edited(); expect('value' in setRouteCell(doc, id, 0, column, text, 'me'), name).toBe(true); expect(rows(), name).toEqual(java.edits[name]!.manual); } for (const [name, text] of [ ['invalidCell', 'abc'], ['emptyCell', ''], ['dottedTwice', '10..0.0'], ] as const) { // Java: the cell becomes null. expect(java.edits[name]!.manual[0]![0], name).toBeNull(); const { doc, id, rows } = edited(); expect(setRouteCell(doc, id, 0, 'destinationNetwork', text, 'me'), name).toEqual({ invalid: true }); expect(rows()[0]![0], name).toBe('172.16.0.0'); } }); it('edit and lose the row they are asked for', () => { const { doc, id, rows } = edited(); addRoute(doc, id, JAVA_MANUAL[0]!, 'me'); expect(setRouteCell(doc, id, 1, 'gateway', '192.168.1.3', 'me')).toEqual({ value: '192.168.1.3' }); expect(rows()).toEqual([ ['172.16.0.0', '255.255.0.0', '192.168.0.2', '192.168.0.10'], ['172.16.0.0', '255.255.0.0', '192.168.1.3', '192.168.1.1'], ]); removeRoute(doc, id, 1, 'me'); expect(rows()).toEqual([['172.16.0.0', '255.255.0.0', '192.168.0.2', '192.168.0.10']]); }); it('take a new row once all four cells pass, as Java does, and lose a row', () => { const cells = { destinationNetwork: '10.9.0.0', subnetMask: '255.255.0.0', gateway: '10.0.0.9', interfaceIPAddress: '' }; let { doc, id, rows } = edited(); expect(addRoute(doc, id, cells, 'me')).toEqual({ invalid: ['interfaceIPAddress'] }); expect(rows()).toEqual(java.edits.newRowThreeCells!.manual); expect(addRoute(doc, id, { ...cells, interfaceIPAddress: '10..0.0.1' }, 'me')).toEqual({ invalid: ['interfaceIPAddress'] }); expect(rows()).toEqual(java.edits.newRowInvalidCell!.manual); expect(addRoute(doc, id, { ...cells, interfaceIPAddress: '10.0.0.1' }, 'me')).toEqual({ route: { ...cells, interfaceIPAddress: '10.0.0.1' } }); expect(rows()).toEqual(java.edits.newRowFourCells!.manual); expect(addRoute(doc, 'nobody', { ...cells, interfaceIPAddress: '10.0.0.1' }, 'me')).toEqual({ invalid: [] }); ({ doc, id, rows } = edited()); removeRoute(doc, id, 5, 'me'); removeRoute(doc, id, 0, 'me'); expect(rows()).toEqual(java.edits.removeRow!.manual); expect((doc.getMap>(ROOTS.nodes).get(id)!.get('settings') as Y.Map).has('routes')).toBe(false); // Java keeps one new row at a time; the sheet's draft is one row too. expect(java.edits.newRowTwice!.rowsShown).toBe(2); }); }); describe("a home router's sheet", () => { it("shows its WAN and LAN cards, keeps the WAN card in the device's settings, and leaves its DHCP server running", () => { expect(java.flows).toMatchObject({ homeRouterDhcpClient: true, homeRouterDhcpClientStopsServer: false }); const doc = new Y.Doc(); const id = add(doc, 'gateway', 'Heim'); const sheet = home(doc, id); expect([sheet.wan.ipAddress, sheet.wan.subnetMask, sheet.lan.ipAddress, sheet.lan.subnetMask, sheet.gateway, sheet.ipForwarding, sheet.dhcpClient]).toEqual([ '42.0.0.10', '255.0.0.0', '192.168.0.10', '255.255.255.0', '', true, false, ]); expect(setInterfaceAddress(doc, id, sheet.wan.portId, 'ipAddress', '042.1.2.3', 'me')).toEqual({ value: '42.1.2.3' }); expect(setInterfaceAddress(doc, id, sheet.lan.portId, 'subnetMask', '255.255.255.128', 'me')).toEqual({ value: '255.255.255.128' }); expect(setRouterGateway(doc, id, '42.0.0.1', 'me')).toEqual({ value: '42.0.0.1' }); expect(readProject(doc).deviceConfigurations.get(id)).toEqual({ ipAddress: '42.1.2.3', subnetMask: '255.0.0.0', defaultGateway: '42.0.0.1', dnsServer: '' }); // Unlike a router, a home router keeps its own settings without a gateway. setRouterGateway(doc, id, '', 'me'); expect(readProject(doc).deviceConfigurations.get(id)).toMatchObject({ defaultGateway: '' }); const settings = doc.getMap>(ROOTS.nodes).get(id)!.get('settings') as Y.Map; const server = new Y.Map(); server.set('isActive', true); settings.set('dhcpServer', server); setDhcpClient(doc, id, true, 'me'); expect(home(doc, id).dhcpClient).toBe(true); expect(server.get('isActive')).toBe(true); }); it('keeps everything through a saved file, with the router’s rows and RIP', () => { const doc = new Y.Doc(); const id = add(doc, 'router', 'R', 3); const homeId = add(doc, 'gateway', 'Heim'); const cards = router(doc, id).interfaces; setInterfaceAddress(doc, id, cards[2]!.portId, 'ipAddress', '172.16.5.1', 'me'); setRouterGateway(doc, id, '192.168.0.254', 'me'); setRip(doc, id, true, 'me'); addRoute(doc, id, JAVA_MANUAL[1]!, 'me'); setInterfaceAddress(doc, homeId, home(doc, homeId).lan.portId, 'ipAddress', '192.168.7.1', 'me'); setRouterGateway(doc, homeId, '42.0.0.1', 'me'); let counter = 0; const reopened = openProjectArchive(saveProjectArchive(readProject(doc)).archive, () => `40000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`); const [routerAgain, homeAgain] = reopened.graph.nodes; const address = (node: typeof routerAgain, index: number) => reopened.interfaceConfigurations.get(`${node!.id}/${node!.ports[index]!.id}`)?.ipAddress; expect(address(routerAgain, 2)).toBe('172.16.5.1'); expect(reopened.deviceConfigurations.get(routerAgain!.id)?.defaultGateway).toBe('192.168.0.254'); expect(reopened.ripEnabledByNodeId.get(routerAgain!.id)).toBe(true); expect(reopened.manualRoutesByNodeId.get(routerAgain!.id)).toEqual([{ destinationNetwork: '10.20.0.0', subnetMask: '255.255.0.0', gateway: '10.0.0.2', interfaceIPAddress: '10.0.0.1' }]); expect(address(homeAgain, 1)).toBe('192.168.7.1'); expect(reopened.deviceConfigurations.get(homeAgain!.id)?.defaultGateway).toBe('42.0.0.1'); }); });