// The topology of every corpus file must be what Java FILIUS loads // (corpus.java.json, tools/java-oracle): each device with class, name, // position and interfaces, each cable with both ends, each documentation // item — field by field. Then the rules the corpus does not exercise. import { readdirSync, readFileSync, statSync } from 'node:fs'; import { dirname, join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { decodeJavaBeans, readFlsArchive, readTopology, type FlsNodeClass, type FlsTopology } from '../src/index.js'; const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '../../../corpus'); const javaView = JSON.parse(readFileSync(join(corpusRoot, 'corpus.java.json'), 'utf8')) as Record; function flsFiles(directory: string): string[] { return readdirSync(directory) .flatMap((name) => { const path = join(directory, name); return statSync(path).isDirectory() ? flsFiles(path) : name.endsWith('.fls') ? [path] : []; }) .sort(); } /** * The palette label Java shows: not read from the file but set from the * class while loading (setTyp(holeHardwareTyp())), in German. */ const TYPE_LABELS: Record = { Rechner: 'Rechner', Notebook: 'Notebook', Switch: 'Switch / WLAN', Vermittlungsrechner: 'Vermittlungsrechner', Gateway: 'Heimrouter', Modem: 'Modem', }; /** The topology in the shape FlsOracle.java writes, less what Swing computes (label width and height). */ function asJavaView(topology: FlsTopology, javaBounds: (number[] | null)[]) { return { success: true, nodes: topology.nodes.map((node, index) => ({ class: node.javaClass, name: node.name, label: TYPE_LABELS[node.javaClass], bounds: [node.x, node.y, ...(javaBounds[index]?.slice(2) ?? [])], ...(node.javaClass === 'Rechner' || node.javaClass === 'Notebook' ? { useIpAsName: node.useIpAsName, useMacAsName: node.useMacAsName } : {}), ports: node.javaClass === 'Switch' || node.javaClass === 'Modem' ? Array.from({ length: node.portCount }, () => ({})) : node.interfaces.map((nic) => ({ ...nic, mac: nic.mac ?? null })), })), cables: topology.links.map((link) => ({ ends: link.ends.map((end) => [end.node, end.port]), wireless: link.wireless, })), documentation: topology.documentation.map((item) => ({ type: item.type, text: item.text, bounds: [item.x, item.y, item.width, item.height], color: item.color, font: item.font === null ? null : [item.font.name, item.font.size, item.font.style], })), }; } describe('corpus: the topology as Java loads it', () => { for (const file of flsFiles(corpusRoot)) { const name = relative(corpusRoot, file); it(name, () => { const values = decodeJavaBeans(readFlsArchive(new Uint8Array(readFileSync(file))).configurationXml); const topology = readTopology(values); const expected = javaView[name] as { nodes: { bounds: number[] | null }[] }; expect(asJavaView(topology, expected.nodes.map((node) => node.bounds))).toEqual(expected); expect(topology.warnings).toEqual([]); }); } }); // ── Rules the corpus does not exercise ───────────────────────────────────── function doc(body: string): string { return `\n\n${body}\n\n`; } function topologyOf(body: string): FlsTopology { return readTopology(decodeJavaBeans(doc(body))); } function nodeItem(device: string, extra = ''): string { return ` ${device}${extra}`; } describe('readTopology rules', () => { it('passes addresses through Java setIp: canonical, null when invalid, unchanged when null', () => { const { nodes } = topologyOf(`Filius version: 2.9.4 (1.1.2026) ${nodeItem(` 010.0..0.1 255.255.255.256 0a:0b:0c:0d:0e:0f `)}`); expect(nodes[0]!.interfaces).toEqual([ { ip: '10.0.0.1', mask: null, mac: undefined, gateway: '', dns: '' }, { ip: '192.168.0.10', mask: '255.255.255.0', mac: '0A:0B:0C:0D:0E:0F', gateway: '', dns: '' }, ]); }); it("gives only a Gateway's own first interface the WAN address, not one the file creates", () => { const own = topologyOf(`${nodeItem( '', )}`); const replaced = topologyOf(`${nodeItem(` `)}`); expect(own.nodes[0]!.interfaces.map((nic) => [nic.ip, nic.mask])).toEqual([ ['42.0.0.10', '255.0.0.0'], ['192.168.0.10', '255.255.255.0'], ]); expect(own.nodes[0]!.name).toBe('Heimrouter'); expect(replaced.nodes[0]!.interfaces.map((nic) => nic.ip)).toEqual(['192.168.0.10']); }); it('places a device without bounds at 0,0 instead of leaving it out', () => { const { nodes, warnings } = topologyOf( `${nodeItem('')}`, ); expect(nodes.map((node) => [node.javaClass, node.x, node.y, node.portCount])).toEqual([['Notebook', 0, 0, 1]]); expect(warnings).toEqual([]); }); it('leaves out an unknown device and a cable to a port no device owns, and says so', () => { const { nodes, links, warnings } = topologyOf(` ${nodeItem('')} ${nodeItem(` `)} `); expect(nodes.map((node) => node.javaClass)).toEqual(['Switch']); expect(links).toEqual([{ ends: [{ node: 0, port: 0 }, { node: 0, port: 1 }], wireless: false }]); expect(warnings).toEqual([ 'node 1: unknown device class filius.hardware.knoten.Drucker, left out', 'cable 2: an end is not a port of a device, left out', ]); }); it('reads the lists by position and only when the first element fits, as netzwerkLaden does', () => { // No version line (files before 1.3.0); an empty cable list still takes its place. const topology = topologyOf(`${nodeItem( '', )} 1`); const wrongFirst = topologyOf(` not a node ${nodeItem('')}`); expect(topology.versionLine).toBeNull(); expect(topology.nodes).toHaveLength(1); expect(topology.documentation.map((item) => item.type)).toEqual([1]); expect(wrongFirst.nodes).toEqual([]); }); it("reads colors as Java's constructors do and keeps an invalid one unset", () => { const colors = ['16711935', '123', '123004']; const { documentation, warnings } = topologyOf(` ${colors .map( (args) => ` ${args} Dialog514 `, ) .join('')}`); expect(documentation.map((item) => item.color)).toEqual([[255, 0, 255, 255], [1, 2, 3, 255], null]); expect(documentation[0]!.font).toEqual({ name: 'Dialog', size: 14, style: 0 }); expect(warnings).toEqual(['documentation item: color component outside 0…255, left unset (as in Java)']); }); });