Files
mdg-filius/packages/fls/test/importProject.test.ts
T
julian.kiedaischandClaude Opus 5.5 da2ec1dae2 feat(fls): import a .fls topology into the editor model
importProject builds what the simulation core runs from Java's view of a
file: nodes with kinds and ports, links between port ids, device and
interface IP configurations, documentation items with Swift's derived
ids. The core gains the model types it needs (node, link, graph,
documentation item), ported from filius-ipad.

Checked against the Swift import, which the Swift oracle now writes in
full (corpus.swift.json, "state"), on the 13 files Swift reads
completely. Two differences are intended and named in the test:
- a device the file leaves unnamed has Java's default name
  ("Switch / WLAN"), not Swift's;
- multicast_server.fls: useIPAsName refers to a shared Boolean that
  Swift cannot follow; Java shows the IP address, and so do we.
All 23 files import with as many devices and cables as Java has, and an
imported example pings across its router in the core.

Port counts of switches and routers follow Java's lists instead of
Swift's guess from references.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:47:47 +00:00

340 lines
13 KiB
TypeScript

// The editor model built from a .fls file must be what the Swift import
// builds (corpus.swift.json, `state`, tools/swift-oracle) — on every corpus
// file Swift reads completely. On the others Swift loses devices (plan §13,
// M2); there Java is the reference, checked in topology.test.ts.
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 {
defaultDisplayName,
interfaceKey,
NetworkRuntimeEngine,
portSnapshot,
topologySnapshot,
type Id,
type NodeKind,
} from '@mdg-filius/core';
import {
decodeJavaBeans,
importProject,
readFlsArchive,
readTopology,
type FlsNode,
type FlsTopology,
type ImportedProject,
} from '../src/index.js';
const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '../../../corpus');
interface SwiftSummary {
skipped?: number;
warnings?: string[];
state?: unknown;
}
const swiftView = JSON.parse(readFileSync(join(corpusRoot, 'corpus.swift.json'), 'utf8')) as Record<string, SwiftSummary>;
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();
}
/** Ids a test can read: node-1, node-2, … in the order they are made. */
function counter(): () => Id {
let next = 0;
return () => `00000000-0000-0000-0000-${String(++next).padStart(12, '0')}`;
}
function importFile(file: string): ImportedProject {
const values = decodeJavaBeans(readFlsArchive(new Uint8Array(readFileSync(file))).configurationXml);
return importProject(readTopology(values), counter());
}
/** The shape describeImportedState (tools/swift-oracle) writes: indices instead of random ids. */
function asSwiftState(project: ImportedProject) {
const { nodes, links } = project.graph;
const nodeIndex = new Map(nodes.map((node, index) => [node.id, index]));
const portIndex = (nodeId: Id, portId: Id) => nodes[nodeIndex.get(nodeId)!]!.ports.findIndex((port) => port.id === portId);
return {
nodes: nodes.map((node) => ({
kind: node.kind,
displayName: node.displayName,
hostLabelPolicy: node.hostLabelPolicy,
position: [node.position.x, node.position.y],
ports: node.ports.map((port) => ({ label: port.label, mac: port.importedMacAddress ?? null, occupied: port.isOccupied })),
device: project.deviceConfigurations.get(node.id) ?? null,
interfaces: node.ports.map((port) => project.interfaceConfigurations.get(interfaceKey(node.id, port.id)) ?? null),
})),
links: links.map((link) => [
nodeIndex.get(link.sourceNodeId),
portIndex(link.sourceNodeId, link.sourcePortId),
nodeIndex.get(link.targetNodeId),
portIndex(link.targetNodeId, link.targetPortId),
]),
documentation: project.documentationItems.map((item) => ({
id: item.id,
kind: item.kind,
frame: [item.frame.x, item.frame.y, item.frame.width, item.frame.height],
text: item.text,
color: [item.color.red, item.color.green, item.color.blue, item.color.alpha],
fontName: item.fontName,
fontSize: item.fontSize,
isBold: item.isBold,
order: item.order,
})),
};
}
/** Java's name for a device the file leaves unnamed (German FILIUS), where Swift uses its own. */
const JAVA_DEFAULT_NAMES: Partial<Record<NodeKind, string>> = {
networkSwitch: 'Switch / WLAN',
gateway: 'Heimrouter',
};
/**
* Swift's state with the one intended difference applied: a node the file
* leaves unnamed has Java's default name, not Swift's ("Switch / WLAN", not
* "Switch"). A node the file names like Swift's default would fail here, not
* pass unnoticed.
*/
function withJavaDefaultNames(state: unknown): unknown {
const { nodes, ...rest } = state as { nodes: { kind: NodeKind; displayName: string }[] };
return {
...rest,
nodes: nodes.map((node) =>
node.displayName === defaultDisplayName(node.kind) && JAVA_DEFAULT_NAMES[node.kind] !== undefined
? { ...node, displayName: JAVA_DEFAULT_NAMES[node.kind] }
: node,
),
};
}
/**
* Where the Swift import is wrong and Java (corpus.java.json) is right, per
* file and node: the corrected fields.
*/
const SWIFT_ERRORS: Record<string, Record<number, { displayName: string; hostLabelPolicy: string }>> = {
// useIPAsName is `<object idref="Boolean0"/>`, the shared result of a
// button's isEnabled() — true. Swift's reader cannot follow it: it shows
// the label text as a manual name instead of the IP address.
'java-examples/multicast_server.fls': {
0: { displayName: 'Rechner', hostLabelPolicy: 'ipAddress' },
1: { displayName: 'Notebook', hostLabelPolicy: 'ipAddress' },
2: { displayName: 'Notebook', hostLabelPolicy: 'ipAddress' },
},
};
function withSwiftErrorsCorrected(name: string, state: unknown): unknown {
const corrections = SWIFT_ERRORS[name] ?? {};
const { nodes, ...rest } = state as { nodes: object[] };
return { ...rest, nodes: nodes.map((node, index) => ({ ...node, ...corrections[index] })) };
}
const complete = (summary: SwiftSummary) =>
summary.skipped === 0 && !(summary.warnings ?? []).some((warning) => warning.startsWith('Skipped'));
describe('corpus: the imported model as Swift builds it', () => {
const files = flsFiles(corpusRoot).filter((file) => complete(swiftView[relative(corpusRoot, file)]!));
it('has files to compare', () => {
expect(files.length).toBe(13);
});
for (const file of files) {
const name = relative(corpusRoot, file);
it(name, () => {
const project = importFile(file);
expect(asSwiftState(project)).toEqual(withSwiftErrorsCorrected(name, withJavaDefaultNames(swiftView[name]!.state)));
expect(project.warnings).toEqual([]);
});
}
});
describe('corpus: every file imports completely', () => {
const javaView = JSON.parse(readFileSync(join(corpusRoot, 'corpus.java.json'), 'utf8')) as Record<
string,
{ nodes: unknown[]; cables: unknown[]; documentation: unknown[] }
>;
// Also the files where Swift loses devices: as many devices and cables as
// Java has, and nothing left out.
for (const file of flsFiles(corpusRoot)) {
const name = relative(corpusRoot, file);
it(name, () => {
const project = importFile(file);
expect(project.graph.nodes).toHaveLength(javaView[name]!.nodes.length);
expect(project.graph.links).toHaveLength(javaView[name]!.cables.length);
expect(project.warnings).toEqual([]);
});
}
});
describe('an imported file runs in the simulation core', () => {
it('pings across the router from the notebook with a gateway, not from the one without', () => {
const project = importFile(join(corpusRoot, 'java-examples/zwei_rechnernetze_komplett.fls'));
const byName = (displayName: string) => project.graph.nodes.find((node) => node.displayName === displayName)!.id;
const engine = new NetworkRuntimeEngine(14);
engine.handle({
type: 'start',
snapshot: topologySnapshot({
nodes: project.graph.nodes.map((node) => ({
id: node.id,
kind: node.kind,
ports: node.ports.map((port) => portSnapshot(port.id, port.label, port.importedMacAddress)),
})),
links: project.graph.links,
deviceConfigurations: project.deviceConfigurations,
interfaceConfigurations: project.interfaceConfigurations,
}),
seed: 14,
initialTimeMilliseconds: 0,
});
expect(engine.sendIcmpEcho(byName('Notebook 1.10'), '141.99.2.10').type).toBe('delivered');
expect(engine.sendIcmpEcho(byName('Notebook 1.11'), '141.99.2.10')).toMatchObject({ type: 'icmpError', kind: 'destinationNetworkUnreachable' });
expect(engine.sendIcmpEcho(byName('Notebook 1.11'), '141.99.1.10').type).toBe('delivered');
});
});
// ── Rules the corpus does not exercise ─────────────────────────────────────
function flsNode(javaClass: FlsNode['javaClass'], fields: Partial<FlsNode> = {}): FlsNode {
return {
javaClass,
name: javaClass,
x: 0,
y: 0,
useIpAsName: false,
useMacAsName: false,
interfaces: [],
portCount: 0,
source: { kind: 'object', className: javaClass, id: undefined, args: [], staticField: undefined,
factoryMethod: undefined, properties: new Map(), calls: [], presetElements: new Map(), fromGetter: false },
...fields,
};
}
function topology(fields: Partial<FlsTopology>): FlsTopology {
return { versionLine: null, nodes: [], links: [], documentation: [], warnings: [], ...fields };
}
const nic = (ip: string | null, mask: string | null = '255.255.255.0') => ({ ip, mask, mac: undefined, gateway: '', dns: '' });
describe('importProject rules', () => {
it('labels a gateway wan0, lan0, gw2 and gives a rejected address the default', () => {
const project = importProject(
topology({ nodes: [flsNode('Gateway', { interfaces: [nic(null, null), nic('10.0.0.1'), nic('10.1.0.1')] })] }),
counter(),
);
const gateway = project.graph.nodes[0]!;
expect(gateway.ports.map((port) => port.label)).toEqual(['wan0', 'lan0', 'gw2']);
expect(gateway.ports.map((port) => project.interfaceConfigurations.get(interfaceKey(gateway.id, port.id)))).toEqual([
{ ipAddress: '42.0.0.10', subnetMask: '255.0.0.0' },
{ ipAddress: '10.0.0.1', subnetMask: '255.255.255.0' },
{ ipAddress: '10.1.0.1', subnetMask: '255.255.255.0' },
]);
expect(project.deviceConfigurations.get(gateway.id)).toEqual({
ipAddress: '42.0.0.10',
subnetMask: '255.0.0.0',
defaultGateway: '',
dnsServer: '',
});
});
it('keeps an empty name and shows the IP address only on hosts', () => {
const project = importProject(
topology({
nodes: [
flsNode('Vermittlungsrechner', { name: '', useIpAsName: true }),
flsNode('Notebook', { useIpAsName: true, useMacAsName: true }),
],
}),
counter(),
);
expect(project.graph.nodes.map((node) => [node.displayName, node.hostLabelPolicy])).toEqual([
['', 'manual'],
['Notebook', 'ipAndMAC'],
]);
});
it("takes a switch's and a router's port count from Java, and gives a rejected router address the default", () => {
const project = importProject(
topology({
nodes: [flsNode('Switch', { portCount: 3 }), flsNode('Vermittlungsrechner', { interfaces: [nic('10.0.0.1'), nic(null)] })],
}),
counter(),
);
const [networkSwitch, router] = project.graph.nodes;
expect(networkSwitch!.ports.map((port) => port.label)).toEqual(['sw1', 'sw2', 'sw3']);
expect(router!.ports.map((port) => project.interfaceConfigurations.get(interfaceKey(router!.id, port.id)))).toEqual([
{ ipAddress: '10.0.0.1', subnetMask: '255.255.255.0' },
{ ipAddress: '192.168.0.10', subnetMask: '255.255.255.0' },
]);
});
it('reads bold from the font style and alpha from the color', () => {
const project = importProject(
topology({
documentation: [
{ type: 2, text: 'b', x: 0, y: 0, width: 50, height: 30, color: [0, 0, 0, 51], font: { name: 'Dialog', size: 18, style: 1 } },
],
}),
counter(),
);
expect(project.documentationItems[0]).toMatchObject({ isBold: true, fontSize: 18, color: { alpha: 0.2 } });
});
it('leaves out a cable to itself and one to a port that already has a cable', () => {
const project = importProject(
topology({
nodes: [flsNode('Switch', { portCount: 24 }), flsNode('Rechner', { interfaces: [nic('10.0.0.1')] })],
links: [
{ ends: [{ node: 0, port: 0 }, { node: 0, port: 1 }], wireless: false },
{ ends: [{ node: 1, port: 0 }, { node: 0, port: 2 }], wireless: false },
{ ends: [{ node: 0, port: 3 }, { node: 1, port: 0 }], wireless: false },
],
}),
counter(),
);
expect(project.graph.links).toHaveLength(1);
expect(project.warnings).toEqual([
'cable 1: connects a node to itself, left out',
'cable 3: a port already has a cable, left out',
]);
});
it('leaves out documentation items without size or of an unknown type, and colors the one without color', () => {
const item = { text: 'Hallo', x: 10, y: 20, width: 100, height: 30, font: null };
const project = importProject(
topology({
documentation: [
{ ...item, type: 2, color: null },
{ ...item, type: 1, width: 0, color: [1, 2, 3, 255] },
{ ...item, type: 3, color: [1, 2, 3, 255] },
],
}),
counter(),
);
expect(project.documentationItems.map((doc) => [doc.kind, doc.text, doc.color, doc.fontName, doc.fontSize])).toEqual([
['text', 'Hallo', { red: 0, green: 0, blue: 0, alpha: 1 }, 'Dialog', 12],
]);
expect(project.warnings).toEqual([
'documentation item 1 without a color, given the default',
'documentation item without size, left out',
'documentation item of type 3, left out (known: RECT = 1, TEXT = 2)',
]);
});
});