exportProject.ts is the counterpart of importProject.ts: from the editor model to the Java view topologyWriter.ts writes. Step X1, the topology: each node becomes the Java device of its kind with name, position and label flags; its ports become interfaces or ports; each link a cable drawn between its nodes; each documentation item a GUIDocuItem. A port's MAC is the one the simulation uses (the file's, or one derived from the port id); a router's gateway and DNS server go to every interface, as Java's own setters do (Java reads the first interface's only). A host with a wireless card joins a switch by a wireless cable, the only wireless connection Java makes. The import now enables a host's wireless by Java's card flag, also without an SSID, where Swift enables it for every SSID. Checked by Java: every corpus file, imported, exported and written, loads in Java FILIUS to the topology of the original up to named departures of the model (a derived MAC where Java rolls one at each load; documentation in render order, with the model's empty text, Dialog 12 and minimum size where Java has none or less). A synthetic model adds a label with IP and MAC, positions between pixels, a five-port switch, a wireless notebook, a three-interface router and a half-transparent bold text. 17 mutation probes, each loaded by Java, were all caught. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
349 lines
15 KiB
TypeScript
349 lines
15 KiB
TypeScript
// 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<string, JavaFile>;
|
|
const exportedPath = join(here, 'exported.java.json');
|
|
const exported = JSON.parse(readFileSync(exportedPath, 'utf8')) as Record<string, JavaFile>;
|
|
|
|
/** 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<string, unknown>[]).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<string, unknown>[]).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<typeof importProject>): Id[][] {
|
|
return project.graph.nodes.map((node) => node.ports.map((port) => port.id));
|
|
}
|
|
|
|
const departed = { generatedMac: new Set<string>(), documentation: new Set<string>() };
|
|
|
|
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> = {}): 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<typeof link>[], 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'));
|
|
});
|
|
});
|