// Every corpus file, imported into the editor model and exported again: // Java FILIUS loads the written file (tools/java-oracle, exported.java.json) // to the topology of the original (corpus.java.json) — up to what the model // decides differently, named below (DEPARTURES). // // UPDATE_EXPORTED=1 writes the files to test/exported/ (not checked in); the // Java oracle then loads them and records each file's SHA-256, which must // match what the export makes now. import { createHash } from 'node:crypto'; import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import { dirname, join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; import { DOCUMENTATION_MINIMUM_SIZE, stableGeneratedMacAddress, type Id, type TopologyNode } from '@mdg-filius/core'; import { describe, expect, it } from 'vitest'; import { decodeJavaBeans, exportProject, importProject, readFlsArchive, readTopology, writeFlsArchive, type ProjectToExport, } from '../src/index.js'; import { writeTopologyXml } from '../src/topologyWriter.js'; const here = dirname(fileURLToPath(import.meta.url)); const corpusRoot = join(here, '../../../corpus'); const exportedRoot = join(here, 'exported'); function flsFiles(directory: string): string[] { return readdirSync(directory).flatMap((name) => { const path = join(directory, name); if (statSync(path).isDirectory()) return flsFiles(path); return name.endsWith('.fls') ? [path] : []; }); } /** Ids a test can read, in the order they are made. */ function counter(): () => Id { let next = 0; return () => `00000000-0000-0000-0000-${String(++next).padStart(12, '0')}`; } type JavaNode = { class: string; name: string | null; bounds: number[]; useIpAsName?: boolean; useMacAsName?: boolean; ports: unknown }; type JavaFile = { success: boolean; nodes: JavaNode[]; cables: unknown[]; documentation: unknown[]; sha256?: string }; const original = JSON.parse(readFileSync(join(corpusRoot, 'corpus.java.json'), 'utf8')) as Record; const exportedPath = join(here, 'exported.java.json'); const exported = JSON.parse(readFileSync(exportedPath, 'utf8')) as Record; /** The topology of Java's view: the label's position, not its size. */ function topologyOf(view: JavaFile) { return { success: view.success, nodes: view.nodes.map((node) => ({ class: node.class, name: node.name, position: node.bounds.slice(0, 2), useIpAsName: node.useIpAsName, useMacAsName: node.useMacAsName, ports: node.ports, })), cables: view.cables, documentation: view.documentation, }; } /** * What the model decides differently from the Java file it was imported * from, each applied to Java's view of the original: * - generatedMac: a card whose MAC the file lacked (Java rolls a new one at * each load) has the one the simulation uses, derived from its port id; * - documentation: the items in the model's render order (rectangles below * text), a missing text empty, a missing font Dialog 12, and at least the * editor's smallest size (DOCUMENTATION_MINIMUM_SIZE). */ const DEPARTURES = { generatedMac(view: JavaFile, macs: (string | undefined)[][]): JavaFile { return { ...view, nodes: view.nodes.map((node, index) => ({ ...node, ports: (node.ports as Record[]).map((port, portIndex) => 'mac' in port && port.mac === null ? { ...port, mac: macs[index]![portIndex] } : port, ), })), }; }, documentation(view: JavaFile): JavaFile { const items = (view.documentation as Record[]).map((item) => { const [x, y, width, height] = item.bounds as number[]; return { ...item, text: item.text ?? '', bounds: [x, y, Math.max(width!, DOCUMENTATION_MINIMUM_SIZE.width), Math.max(height!, DOCUMENTATION_MINIMUM_SIZE.height)], font: item.font ?? ['Dialog', 12, 0], }; }); return { ...view, documentation: sortedDocumentation(items) }; }, }; /** Documentation items in an order that does not depend on the list's. */ function sortedDocumentation(items: unknown[]): unknown[] { return items.map((item) => JSON.stringify(item)).sort().map((text) => JSON.parse(text) as unknown); } /** Every port id each exported node's MAC could be derived from, by node and port. */ function portIds(project: ReturnType): Id[][] { return project.graph.nodes.map((node) => node.ports.map((port) => port.id)); } const departed = { generatedMac: new Set(), documentation: new Set() }; describe('the editor model exported, as Java loads it', () => { for (const file of flsFiles(corpusRoot)) { const name = relative(corpusRoot, file); it(name, () => { const topology = readTopology(decodeJavaBeans(readFlsArchive(new Uint8Array(readFileSync(file))).configurationXml)); const project = importProject(topology, counter()); const { topology: toWrite } = exportProject(project); const archive = writeFlsArchive(new TextEncoder().encode(writeTopologyXml(toWrite))); if (process.env.UPDATE_EXPORTED === '1') { mkdirSync(dirname(join(exportedRoot, name)), { recursive: true }); writeFileSync(join(exportedRoot, name), archive); } const java = exported[name]; expect(java?.sha256, 'exported.java.json is not about this output: UPDATE_EXPORTED=1, then npm run oracle:java').toBe( createHash('sha256').update(archive).digest('hex'), ); // A MAC the file lacked: the one derived from the port's id. const ids = portIds(project); const macs = toWrite.nodes.map((node, index) => node.interfaces.map((nic, portIndex) => { const derived = stableGeneratedMacAddress(ids[index]![portIndex]!); return nic.mac === derived ? derived : undefined; }), ); const source = original[name]!; const withMacs = DEPARTURES.generatedMac(source, macs); const expected = DEPARTURES.documentation(withMacs); if (JSON.stringify(withMacs) !== JSON.stringify(source)) departed.generatedMac.add(name); if (JSON.stringify(expected.documentation) !== JSON.stringify(source.documentation)) departed.documentation.add(name); expect(topologyOf({ ...java!, documentation: sortedDocumentation(java!.documentation) })).toEqual(topologyOf(expected)); }); } it('departs from Java where the model decides differently', () => { expect(departed.generatedMac.size).toBeGreaterThan(0); expect(departed.documentation.size).toBeGreaterThan(0); }); }); // ── A model beyond the corpus, loaded by Java ────────────────────────────── const port = (id: string) => ({ id, label: id, isOccupied: false }); /** * What the corpus lacks: a label with IP and MAC, a position between * pixels, a switch with five ports, a wireless notebook, a router with three * interfaces and a gateway, a half-transparent bold text and a rectangle. */ const SYNTHETIC: ProjectToExport = { graph: { nodes: [ { id: 'pc', kind: 'pc', displayName: 'PC', hostLabelPolicy: 'ipAndMAC', position: { x: 10.6, y: 20.2 }, ports: [port('p0')] }, { id: 'sw', kind: 'networkSwitch', displayName: 'SW', hostLabelPolicy: 'manual', position: { x: 100, y: 20 }, ports: ['s0', 's1', 's2', 's3', 's4'].map(port) }, { id: 'nb', kind: 'notebook', displayName: 'NB', hostLabelPolicy: 'macAddress', position: { x: 100, y: 120 }, ports: [port('n0')] }, { id: 'rt', kind: 'router', displayName: 'RT', hostLabelPolicy: 'manual', position: { x: 200, y: 20 }, ports: ['r0', 'r1', 'r2'].map(port) }, ], links: [ { id: 'l1', sourceNodeId: 'pc', sourcePortId: 'p0', targetNodeId: 'sw', targetPortId: 's4' }, { id: 'l2', sourceNodeId: 'nb', sourcePortId: 'n0', targetNodeId: 'sw', targetPortId: 's0' }, { id: 'l3', sourceNodeId: 'sw', sourcePortId: 's2', targetNodeId: 'rt', targetPortId: 'r1' }, ], }, deviceConfigurations: new Map([ ['pc', { ipAddress: '10.0.0.1', subnetMask: '255.255.255.0', defaultGateway: '10.0.0.254', dnsServer: '10.0.0.53' }], ['nb', { ipAddress: '10.0.0.2', subnetMask: '255.255.255.0', defaultGateway: '', dnsServer: '' }], ['rt', { ipAddress: '10.0.0.254', subnetMask: '255.255.255.0', defaultGateway: '10.9.9.9', dnsServer: '10.0.0.53' }], ]), interfaceConfigurations: new Map([ ['rt/r0', { ipAddress: '10.1.0.1', subnetMask: '255.255.0.0' }], ['rt/r1', { ipAddress: '10.0.0.254', subnetMask: '255.255.255.0' }], ['rt/r2', { ipAddress: '10.2.0.1', subnetMask: '255.255.0.0' }], ]), hostWirelessConfigurations: new Map([['nb', { isEnabled: true, ssid: 'net' }]]), documentationItems: [ { id: 'd1', kind: 'rectangle', frame: { x: 5, y: 6, width: 300, height: 200 }, text: '', color: { red: 1, green: 0.5, blue: 0, alpha: 0.5 }, fontName: 'Dialog', fontSize: 12, isBold: false, order: 0, }, { id: 'd2', kind: 'text', frame: { x: 40.4, y: 50.6, width: 120, height: 30 }, text: 'Netz A\nzweite Zeile', color: { red: 0, green: 0, blue: 1, alpha: 1 }, fontName: 'SansSerif', fontSize: 16, isBold: true, order: 1, }, ], }; describe('a model beyond the corpus, as Java loads it', () => { it('keeps what the corpus never has', () => { const name = 'synthetic/model.fls'; const { topology: toWrite, warnings } = exportProject(SYNTHETIC); const archive = writeFlsArchive(new TextEncoder().encode(writeTopologyXml(toWrite))); if (process.env.UPDATE_EXPORTED === '1') { mkdirSync(join(exportedRoot, 'synthetic'), { recursive: true }); writeFileSync(join(exportedRoot, name), archive); } const java = exported[name]; expect(java?.sha256, 'exported.java.json is not about this output: UPDATE_EXPORTED=1, then npm run oracle:java').toBe( createHash('sha256').update(archive).digest('hex'), ); expect(warnings).toEqual([]); const card = (ip: string, mask: string, mac: string, gateway: string, dns: string, wireless = false) => ({ ip, mask, mac, gateway, dns, wireless }); expect(topologyOf(java!)).toEqual({ success: true, nodes: [ { class: 'Rechner', name: 'PC', position: [11, 20], useIpAsName: true, useMacAsName: true, ports: [card('10.0.0.1', '255.255.255.0', stableGeneratedMacAddress('p0'), '10.0.0.254', '10.0.0.53')], }, { class: 'Switch', name: 'SW', position: [100, 20], useIpAsName: undefined, useMacAsName: undefined, ports: [{}, {}, {}, {}, {}] }, { class: 'Notebook', name: 'NB', position: [100, 120], useIpAsName: false, useMacAsName: true, ports: [card('10.0.0.2', '255.255.255.0', stableGeneratedMacAddress('n0'), '', '', true)], }, { class: 'Vermittlungsrechner', name: 'RT', position: [200, 20], useIpAsName: undefined, useMacAsName: undefined, ports: [ card('10.1.0.1', '255.255.0.0', stableGeneratedMacAddress('r0'), '10.9.9.9', '10.0.0.53'), card('10.0.0.254', '255.255.255.0', stableGeneratedMacAddress('r1'), '10.9.9.9', '10.0.0.53'), card('10.2.0.1', '255.255.0.0', stableGeneratedMacAddress('r2'), '10.9.9.9', '10.0.0.53'), ], }, ], cables: [ { ends: [[0, 0], [1, 4]], wireless: false, panel: [0, 1] }, { ends: [[2, 0], [1, 0]], wireless: true, panel: [2, 1] }, { ends: [[1, 2], [3, 1]], wireless: false, panel: [1, 3] }, ], documentation: [ { type: 1, text: '', bounds: [5, 6, 300, 200], color: [255, 128, 0, 128], font: ['Dialog', 12, 0] }, { type: 2, text: 'Netz A\nzweite Zeile', bounds: [40, 51, 120, 30], color: [0, 0, 255, 255], font: ['SansSerif', 16, 1] }, ], }); }); }); // ── Rules the corpus does not exercise ───────────────────────────────────── describe('exportProject rules', () => { const node = (id: string, kind: TopologyNode['kind'], ports: string[], extra: Partial = {}): TopologyNode => ({ id, kind, displayName: id, hostLabelPolicy: 'manual', position: { x: 0, y: 0 }, ports: ports.map(port), ...extra, }); const link = (id: string, source: [string, string], target: [string, string]) => ({ id, sourceNodeId: source[0], sourcePortId: source[1], targetNodeId: target[0], targetPortId: target[1], }); const project = (nodes: TopologyNode[], links: ReturnType[], wireless: [string, boolean][] = []): ProjectToExport => ({ graph: { nodes, links }, deviceConfigurations: new Map(), interfaceConfigurations: new Map(), hostWirelessConfigurations: new Map(wireless.map(([id, isEnabled]) => [id, { isEnabled, ssid: 'net' }])), documentationItems: [], }); it('leaves out a node of a kind Java does not have, and the links to it', () => { const { topology, warnings } = exportProject( project( [node('pc', 'pc', ['p0']), node('odd', 'unsupported', ['o0']), node('sw', 'networkSwitch', ['s0', 's1'])], [link('l1', ['pc', 'p0'], ['odd', 'o0']), link('l2', ['pc', 'p0'], ['sw', 's1']), link('l3', ['pc', 'missing'], ['sw', 's0'])], ), ); expect(topology.nodes.map((each) => each.javaClass)).toEqual(['Rechner', 'Switch']); expect(topology.links).toEqual([{ ends: [{ node: 0, port: 0 }, { node: 1, port: 1 }], wireless: false, panel: [0, 1] }]); expect(warnings).toEqual([ "node 'odd': a kind Java FILIUS does not have, left out", 'link 1: an end is not a port of an exported node, left out', 'link 3: an end is not a port of an exported node, left out', ]); }); it('joins wirelessly only a host with a wireless card and a switch', () => { const { topology } = exportProject( project( [ node('nb', 'notebook', ['n0']), node('sw', 'networkSwitch', ['s0', 's1']), node('nb2', 'notebook', ['m0']), node('rt', 'router', ['r0']), node('pc', 'pc', ['p0']), ], [link('l1', ['sw', 's0'], ['nb', 'n0']), link('l2', ['nb2', 'm0'], ['rt', 'r0']), link('l3', ['pc', 'p0'], ['sw', 's1'])], [ ['nb', true], ['nb2', true], ['pc', false], ], ), ); expect(topology.links.map((each) => each.wireless)).toEqual([true, false, false]); expect(topology.nodes.map((each) => each.interfaces.map((nic) => nic.wireless))).toEqual([[true], [], [true], [false], [false]]); }); it('rounds positions to Java ints and shows IP and MAC as the policy says', () => { const { topology } = exportProject( project([node('pc', 'pc', ['p0'], { position: { x: 10.5, y: -3.4 }, hostLabelPolicy: 'ipAndMAC' })], []), ); expect(topology.nodes[0]).toMatchObject({ x: 11, y: -3, useIpAsName: true, useMacAsName: true, portCount: 1 }); expect(topology.nodes[0]!.interfaces[0]!.mac).toBe(stableGeneratedMacAddress('p0')); }); });