// 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, topologyView, type FieldRule, type HostSheet, type NewDevice, } from '../src/index.js'; interface JavaCase { readonly device: 'host' | 'switch' | 'modem'; readonly field: string; readonly input: string; readonly start: string; readonly marked: boolean; readonly stored: string; readonly shown: string; } 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 }; }; 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', }; /** Fields whose text is judged without the spaces around it. */ const TRIMMED = new Set(['host ip', 'host mask', 'host gateway', 'host dns', 'switch retention', 'modem port']); /** 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 = verdict.stored !== verdict.start || verdict.input === verdict.start; // 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 : !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 === (each.stored !== each.start)); // Marked red, yet taken; or black, yet refused (the gateway's spaces Java trims to empty). expect(disagreeing.map((each) => `${each.field} ${JSON.stringify(each.input)} ${each.marked ? 'marked, taken' : 'refused'}`)).toEqual([ 'ip "10..0.0.1" marked, taken', 'ip ".0.0.1" refused', 'ip "+10.0.0.1" marked, taken', 'ip "10.0.0.1." marked, taken', 'ip "١٠.0.0.1" marked, taken', 'ip "-0.0.0.1" marked, taken', 'mask "255..255.0.0" marked, taken', 'gateway " " marked, taken', 'gateway "192.168..1.1" marked, taken', 'dns " " marked, taken', 'retention "" refused', 'retention "-5" marked, taken', 'retention "99999999999999999999" refused', 'retention "٣" marked, taken', ]); 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): string { const id = newId(); addDevice(doc, { kind, id, name, x: 100, y: 100, newId }, '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 names the other kinds' sheets", () => { 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)).toEqual({ kind: 'router', name: 'R2' }); expect(deviceSheet(doc, add(doc, 'gateway', 'Heim'))).toEqual({ 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'); }); });