// The design tools' changes to the document: new devices as Java's palette // makes them (tools/java-oracle's NewDeviceRun saved them in // fixtures/new-devices.fls), moving and removing, cables by Java's rules, // and each person's undo taking back their own changes only. 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 { interfaceKey, NetworkRuntimeEngine, type Id } from '@mdg-filius/core'; import { openProjectArchive, projectSnapshot, type ImportedProject } from '@mdg-filius/fls'; import { addDevice, connect, createUndoManager, docSnapshot, moveDevice, newDeviceProject, PALETTE, readProject, removeDevice, removeLink, removeLinksOf, repair, ROOTS, topologyView, type NewDevice } from '../src/index.js'; const fixture = join(dirname(fileURLToPath(import.meta.url)), 'fixtures/new-devices.fls'); /** Everything the project holds for one device, as plain values; Java's random MAC addresses and SSID left out. */ function entriesOf(project: ImportedProject, id: Id) { const node = project.graph.nodes.find((each) => each.id === id)!; const fields = [ 'deviceConfigurations', 'hostWirelessConfigurations', 'manualRoutesByNodeId', 'ripEnabledByNodeId', 'ipForwardingByNodeId', 'firewallConfigurationsByNodeId', 'dhcpClientConfigurations', 'dhcpServerConfigurations', 'portForwardingRowsByNodeId', 'remoteLinkConfigurations', 'webServerConfigurations', 'gnutellaConfigurations', 'dnsServerConfigurations', 'emailClientConfigurations', 'emailServerConfigurations', 'echoServerConfigurations', 'simpleClientConfigurations', 'installedPrograms', 'activePrograms', 'ownApplicationsByNodeId', ] as const; const switchConfiguration = project.switchConfigurations.get(id); return { kind: node.kind, name: node.displayName, labelPolicy: node.hostLabelPolicy, position: node.position, ports: node.ports.map((port) => [port.label, port.isOccupied]), interfaces: node.ports.map((port) => project.interfaceConfigurations.get(interfaceKey(id, port.id))), switch: switchConfiguration === undefined ? undefined : { ...switchConfiguration, ssid: 'rolled' }, files: project.virtualFileSystems.get(id)?.allEntries(), ...Object.fromEntries(fields.map((field) => [field, (project[field] as ReadonlyMap).get(id)])), }; } function javaDevices(): ImportedProject { let counter = 0; return openProjectArchive(new Uint8Array(readFileSync(fixture)), () => `00000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`); } let ids = 0; const newId = () => `10000000-0000-0000-0000-${(++ids).toString(16).padStart(12, '0')}`; function device(kind: NewDevice['kind'], fields: Partial = {}): NewDevice { return { kind, id: newId(), name: kind, x: 100, y: 100, newId, ...fields }; } describe('a new device', () => { it("is what Java's palette makes, of every kind and every router size", () => { const java = javaDevices(); expect(java.graph.nodes.map((node) => node.kind)).toEqual(['pc', 'notebook', 'networkSwitch', ...Array(7).fill('router'), 'gateway', 'remoteLink']); for (const node of java.graph.nodes) { const ours = newDeviceProject({ kind: node.kind as NewDevice['kind'], id: node.id, name: node.displayName, x: node.position.x, y: node.position.y, interfaces: node.ports.length, newId }); expect(entriesOf(ours, node.id), `${node.kind} ${node.ports.length}`).toEqual(entriesOf(java, node.id)); } }); it("comes from a palette in Java's order", () => { expect(PALETTE).toEqual(['pc', 'notebook', 'networkSwitch', 'gateway', 'router', 'remoteLink']); }); it('keeps a router between 2 and 8 interfaces, 2 when not asked', () => { const count = (interfaces?: number) => newDeviceProject(device('router', interfaces === undefined ? {} : { interfaces })).graph.nodes[0]!.ports.length; expect([count(), count(1), count(5), count(12)]).toEqual([2, 2, 5, 8]); }); it('comes after all others in the document, at whole pixels', () => { const doc = new Y.Doc(); const first = device('pc', { x: 10.4, y: 20.6 }); // An id below the first one's: the order is the place, not the id. const second = device('networkSwitch', { id: '00000000-0000-0000-0000-000000000001' }); addDevice(doc, first, 'me'); addDevice(doc, second, 'me'); const project = readProject(doc); expect(project.graph.nodes.map((node) => node.id)).toEqual([first.id, second.id]); expect(project.graph.nodes[0]!.position).toEqual({ x: 10, y: 21 }); expect(project.deviceConfigurations.get(first.id)).toEqual({ ipAddress: '192.168.0.10', subnetMask: '255.255.255.0', defaultGateway: '', dnsServer: '' }); moveDevice(doc, first.id, 33.5, 44.49, 'me'); expect(readProject(doc).graph.nodes[0]!.position).toEqual({ x: 34, y: 44 }); }); }); /** A small network built with the tools: two PCs, a switch, a router, a home router, a wireless notebook. */ function network() { const doc = new Y.Doc(); const [pcA, pcB, hub, router, home, notebook] = [device('pc'), device('pc'), device('networkSwitch'), device('router', { interfaces: 2 }), device('gateway'), device('notebook')]; for (const each of [pcA, pcB, hub, router, home, notebook]) addDevice(doc, each, 'me'); return { doc, pcA, pcB, hub, router, home, notebook, port: (node: NewDevice, index: number) => readProject(doc).graph.nodes.find((each) => each.id === node.id)!.ports[index]!.id }; } describe('a cable', () => { it("takes each device's first free port", () => { const { doc, pcA, pcB, hub, port } = network(); expect(connect(doc, { node: pcA.id }, { node: hub.id }, 'link-1', 'me')).toEqual({ link: 'link-1' }); expect(connect(doc, { node: pcB.id }, { node: hub.id }, 'link-2', 'me')).toEqual({ link: 'link-2' }); expect(readProject(doc).graph.links.map((link) => [link.sourcePortId, link.targetPortId])).toEqual([ [port(pcA, 0), port(hub, 0)], [port(pcB, 0), port(hub, 1)], ]); }); it("refuses as Java does: no free port, the same two devices again, a device with itself", () => { const { doc, pcA, hub, router } = network(); connect(doc, { node: pcA.id }, { node: hub.id }, 'link-1', 'me'); expect(connect(doc, { node: pcA.id }, { node: router.id }, 'link-2', 'me')).toEqual({ error: 'noFreePort' }); expect(connect(doc, { node: hub.id }, { node: pcA.id }, 'link-3', 'me')).toEqual({ error: 'noFreePort' }); expect(connect(doc, { node: router.id }, { node: hub.id }, 'link-4', 'me')).toEqual({ link: 'link-4' }); expect(connect(doc, { node: hub.id }, { node: router.id }, 'link-5', 'me')).toEqual({ error: 'alreadyConnected' }); expect(connect(doc, { node: hub.id }, { node: hub.id }, 'link-6', 'me')).toEqual({ error: 'sameDevice' }); expect(connect(doc, { node: hub.id }, { node: 'nobody' }, 'link-7', 'me')).toEqual({ error: 'unknownDevice' }); expect(readProject(doc).graph.links.map((link) => link.id)).toEqual(['link-1', 'link-4']); }); it("joins a home router at its WAN port, or at its LAN port when asked, each refused when taken", () => { const { doc, pcA, pcB, hub, router, home, port } = network(); expect(connect(doc, { node: router.id }, { node: home.id }, 'wan', 'me')).toEqual({ link: 'wan' }); expect(connect(doc, { node: pcA.id }, { node: home.id }, 'again', 'me')).toEqual({ error: 'wanOccupied' }); expect(connect(doc, { node: hub.id }, { node: home.id, port: port(home, 1) }, 'lan', 'me')).toEqual({ link: 'lan' }); expect(connect(doc, { node: pcB.id }, { node: home.id, port: port(home, 1) }, 'again', 'me')).toEqual({ error: 'lanOccupied' }); expect(readProject(doc).graph.links.map((link) => link.targetPortId)).toEqual([port(home, 0), port(home, 1)]); }); it('takes a port that was asked for, if it is free', () => { const { doc, pcA, pcB, hub, port } = network(); expect(connect(doc, { node: pcA.id }, { node: hub.id, port: port(hub, 5) }, 'link-1', 'me')).toEqual({ link: 'link-1' }); expect(connect(doc, { node: pcB.id }, { node: hub.id, port: port(hub, 5) }, 'link-2', 'me')).toEqual({ error: 'portTaken' }); expect(connect(doc, { node: pcB.id }, { node: hub.id, port: 'no-port' }, 'link-2', 'me')).toEqual({ error: 'unknownPort' }); }); it('joins a wireless host only to a switch, and draws it as wireless', () => { const { doc, notebook, hub, router } = network(); const settings = doc.getMap>(ROOTS.nodes).get(notebook.id)!.get('settings') as Y.Map; const wireless = new Y.Map(); wireless.set('isEnabled', true); wireless.set('ssid', 'schule'); settings.set('wireless', wireless); expect(connect(doc, { node: notebook.id }, { node: router.id }, 'link-1', 'me')).toEqual({ error: 'wirelessNotPossible' }); expect(connect(doc, { node: router.id }, { node: notebook.id }, 'link-1', 'me')).toEqual({ error: 'wirelessNotPossible' }); expect(connect(doc, { node: notebook.id }, { node: hub.id }, 'link-1', 'me')).toEqual({ link: 'link-1' }); expect(topologyView(doc).links[0]!.wireless).toBe(true); }); it('is pulled with all of a device\'s cables, the device staying', () => { const { doc, pcA, pcB, hub, router, home } = network(); connect(doc, { node: pcA.id }, { node: hub.id }, 'link-1', 'me'); connect(doc, { node: pcB.id }, { node: hub.id }, 'link-2', 'me'); expect(connect(doc, { node: router.id }, { node: home.id }, 'link-3', 'me')).toEqual({ link: 'link-3' }); removeLinksOf(doc, hub.id, 'me'); expect(readProject(doc).graph.links.map((link) => link.id)).toEqual(['link-3']); expect(readProject(doc).graph.nodes.some((node) => node.id === hub.id)).toBe(true); }); it('is pulled alone, and with the device it is plugged into', () => { const { doc, pcA, pcB, hub } = network(); connect(doc, { node: pcA.id }, { node: hub.id }, 'link-1', 'me'); connect(doc, { node: pcB.id }, { node: hub.id }, 'link-2', 'me'); removeLink(doc, 'link-1', 'me'); expect(readProject(doc).graph.links.map((link) => link.id)).toEqual(['link-2']); removeDevice(doc, hub.id, 'me'); const project = readProject(doc); expect(project.graph.links).toEqual([]); expect(project.graph.nodes.some((node) => node.id === hub.id)).toBe(false); }); }); describe('a network built with the tools', () => { it('runs in the core: two PCs across a switch ping each other', () => { const { doc, pcA, pcB, hub } = network(); connect(doc, { node: pcA.id }, { node: hub.id }, 'link-1', 'me'); connect(doc, { node: pcB.id }, { node: hub.id }, 'link-2', 'me'); const address = (node: NewDevice, ip: string) => (doc.getMap>(ROOTS.nodes).get(node.id)!.get('settings') as Y.Map>).get('device')!.set('ipAddress', ip); address(pcA, '192.168.0.1'); address(pcB, '192.168.0.2'); expect(docSnapshot(doc)).toEqual(projectSnapshot(readProject(doc))); const engine = new NetworkRuntimeEngine(1); engine.handle({ type: 'start', snapshot: docSnapshot(doc), seed: 1, initialTimeMilliseconds: 0 }); expect(engine.sendIcmpEcho(pcA.id, '192.168.0.2').type).toBe('delivered'); }); }); describe("a person's undo", () => { it('takes back their own changes only, and brings them back', () => { const mine = new Y.Doc(); const theirs = new Y.Doc(); const exchange = () => { Y.applyUpdate(theirs, Y.encodeStateAsUpdate(mine, Y.encodeStateVector(theirs))); Y.applyUpdate(mine, Y.encodeStateAsUpdate(theirs, Y.encodeStateVector(mine))); }; const undo = createUndoManager(mine, 'me'); const [pc, hub, other] = [device('pc'), device('networkSwitch'), device('notebook')]; addDevice(mine, pc, 'me'); undo.stopCapturing(); addDevice(mine, hub, 'me'); undo.stopCapturing(); exchange(); addDevice(theirs, other, 'them'); connect(theirs, { node: other.id }, { node: hub.id }, 'their-link', 'them'); exchange(); // My last change was the switch: undoing it takes it away. Their cable to it is left without an end, // which the janitor removes (I1) — on F, not in a client. undo.undo(); exchange(); const ids = (doc: Y.Doc) => readProject(doc).graph.nodes.map((node) => node.id); expect(ids(mine)).toEqual([pc.id, other.id]); expect(ids(theirs)).toEqual([pc.id, other.id]); expect(repair(theirs).danglingLinks).toEqual(['their-link']); exchange(); undo.undo(); expect(ids(mine)).toEqual([other.id]); // Nothing more of mine: their notebook stays. expect(undo.undo()).toBeNull(); expect(ids(mine)).toEqual([other.id]); undo.redo(); undo.redo(); expect(new Set(ids(mine))).toEqual(new Set([pc.id, hub.id, other.id])); }); it('takes back a removed device with its cables, and a drag as one step', () => { const doc = new Y.Doc(); const [pc, hub] = [device('pc'), device('networkSwitch')]; addDevice(doc, pc, 'me'); addDevice(doc, hub, 'me'); connect(doc, { node: pc.id }, { node: hub.id }, 'link-1', 'me'); const undo = createUndoManager(doc, 'me'); // A drag: many moves within a moment. for (let step = 1; step <= 10; step += 1) moveDevice(doc, pc.id, 100 + step * 10, 100, 'me'); undo.stopCapturing(); removeDevice(doc, pc.id, 'me'); undo.undo(); const project = readProject(doc); expect(project.graph.links.map((link) => link.id)).toEqual(['link-1']); expect(project.graph.nodes.find((node) => node.id === pc.id)!.position).toEqual({ x: 200, y: 100 }); undo.undo(); expect(readProject(doc).graph.nodes.find((node) => node.id === pc.id)!.position).toEqual({ x: 100, y: 100 }); }); });