feat(fls): export the editor model's system software

exportProject.ts, step X2: a switch's SSID and learning time, a host's
SSID, manual routes, RIP, DHCP client and server with static assignments, a
home router's port forwarding, the firewall of a router or home router (the
core's names as Java's numbers), and modems. What the model has no setting
for is what Java's constructors make. A remote link pair becomes modems on
the port its identifier names (12345 when it names none); the first of a
pair in node order serves, the others connect to localhost.

Checked by Java: every corpus file, imported, exported and written, loads
with the system software of the original up to two new named departures: a
switch SSID Java rolls is the one the model derives from the node id, and
the model keeps a modem pair, not which one serves (modem_beispiel.fls has
the client first; both ways work within one project). The synthetic model
now has settings away from every default, a home router with port
forwarding and two modem pairs. 21 mutation probes, each loaded by Java,
were all caught.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 09:04:30 +00:00
co-authored by Claude Opus 5.5
parent 6dc1286866
commit d3f476269a
4 changed files with 960 additions and 235 deletions
+235 -14
View File
@@ -11,7 +11,16 @@ 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 {
defaultSwitchConfiguration,
dhcpServerConfiguration,
DOCUMENTATION_MINIMUM_SIZE,
firewallConfiguration,
firewallRule,
stableGeneratedMacAddress,
type Id,
type TopologyNode,
} from '@mdg-filius/core';
import { describe, expect, it } from 'vitest';
import {
decodeJavaBeans,
@@ -42,13 +51,27 @@ function counter(): () => Id {
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 JavaNode = {
class: string;
name: string | null;
bounds: number[];
useIpAsName?: boolean;
useMacAsName?: boolean;
ports: unknown;
system: Record<string, 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 system software without its files and applications, later steps. */
function systemOf(system: Record<string, unknown>) {
const { fileSystem: _files, applications: _applications, ...settings } = system;
return settings;
}
/** The topology of Java's view: the label's position, not its size. */
function topologyOf(view: JavaFile) {
return {
@@ -60,6 +83,7 @@ function topologyOf(view: JavaFile) {
useIpAsName: node.useIpAsName,
useMacAsName: node.useMacAsName,
ports: node.ports,
system: systemOf(node.system),
})),
cables: view.cables,
documentation: view.documentation,
@@ -73,7 +97,12 @@ function topologyOf(view: JavaFile) {
* 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).
* editor's smallest size (DOCUMENTATION_MINIMUM_SIZE);
* - switchSsid: a switch's SSID the file lacked (Java rolls one) is the one
* the model derives from the node's id;
* - modemRoles: the model keeps a pair of modems, not which one serves: the
* first of a pair in node order serves, the others connect. Both ways
* work within one project, where the client connects to localhost.
*/
const DEPARTURES = {
generatedMac(view: JavaFile, macs: (string | undefined)[][]): JavaFile {
@@ -87,6 +116,20 @@ const DEPARTURES = {
})),
};
},
switchSsid(view: JavaFile, nodeIds: Id[]): JavaFile {
return mapSystems(view, (node, index) =>
node.class === 'Switch' && node.system.ssid === null ? { ...node.system, ssid: defaultSwitchConfiguration(nodeIds[index]!).ssid } : node.system,
);
},
modemRoles(view: JavaFile, pairs: (string | undefined)[]): JavaFile {
const seen = new Set<string | undefined>();
return mapSystems(view, (node, index) => {
if (node.class !== 'Modem') return node.system;
const serves = !seen.has(pairs[index]);
seen.add(pairs[index]);
return { ...node.system, modem: { ...(node.system.modem as object), mode: serves ? 1 : 2 } };
});
},
documentation(view: JavaFile): JavaFile {
const items = (view.documentation as Record<string, unknown>[]).map((item) => {
const [x, y, width, height] = item.bounds as number[];
@@ -101,6 +144,10 @@ const DEPARTURES = {
},
};
function mapSystems(view: JavaFile, system: (node: JavaNode, index: number) => Record<string, unknown>): JavaFile {
return { ...view, nodes: view.nodes.map((node, index) => ({ ...node, system: system(node, index) })) };
}
/** 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);
@@ -111,7 +158,12 @@ 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>() };
const departed = {
generatedMac: new Set<string>(),
documentation: new Set<string>(),
switchSsid: new Set<string>(),
modemRoles: new Set<string>(),
};
describe('the editor model exported, as Java loads it', () => {
for (const file of flsFiles(corpusRoot)) {
@@ -138,22 +190,54 @@ describe('the editor model exported, as Java loads it', () => {
}),
);
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);
const nodeIds = project.graph.nodes.map((node) => node.id);
const pairs = nodeIds.map((id) => project.remoteLinkConfigurations.get(id)?.pairIdentifier);
const steps: [keyof typeof departed, (view: JavaFile) => JavaFile][] = [
['generatedMac', (view) => DEPARTURES.generatedMac(view, macs)],
['switchSsid', (view) => DEPARTURES.switchSsid(view, nodeIds)],
['modemRoles', (view) => DEPARTURES.modemRoles(view, pairs)],
['documentation', (view) => DEPARTURES.documentation(view)],
];
let expected = source;
for (const [departure, apply] of steps) {
const next = apply(expected);
if (departure === 'documentation') {
if (JSON.stringify(next.documentation) !== JSON.stringify(expected.documentation)) departed[departure].add(name);
} else if (JSON.stringify(next) !== JSON.stringify(expected)) {
departed[departure].add(name);
}
expected = next;
}
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);
for (const [departure, files] of Object.entries(departed)) expect(files.size, departure).toBeGreaterThan(0);
});
});
// ── A model beyond the corpus, loaded by Java ──────────────────────────────
/** A model without nodes, settings or documentation. */
function emptyProject(): ProjectToExport {
return {
graph: { nodes: [], links: [] },
deviceConfigurations: new Map(),
interfaceConfigurations: new Map(),
hostWirelessConfigurations: new Map(),
switchConfigurations: new Map(),
manualRoutesByNodeId: new Map(),
ripEnabledByNodeId: new Map(),
firewallConfigurationsByNodeId: new Map(),
dhcpClientConfigurations: new Map(),
dhcpServerConfigurations: new Map(),
portForwardingRowsByNodeId: new Map(),
remoteLinkConfigurations: new Map(),
documentationItems: [],
};
}
const port = (id: string) => ({ id, label: id, isOccupied: false });
/**
@@ -162,12 +246,17 @@ const port = (id: string) => ({ id, label: id, isOccupied: false });
* interfaces and a gateway, a half-transparent bold text and a rectangle.
*/
const SYNTHETIC: ProjectToExport = {
...emptyProject(),
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) },
{ id: 'gw', kind: 'gateway', displayName: 'GW', hostLabelPolicy: 'manual', position: { x: 300, y: 20 }, ports: ['g0', 'g1'].map(port) },
{ id: 'm1', kind: 'remoteLink', displayName: 'M1', hostLabelPolicy: 'manual', position: { x: 400, y: 20 }, ports: [port('m1p')] },
{ id: 'm2', kind: 'remoteLink', displayName: 'M2', hostLabelPolicy: 'manual', position: { x: 500, y: 20 }, ports: [port('m2p')] },
{ id: 'm3', kind: 'remoteLink', displayName: 'M3', hostLabelPolicy: 'manual', position: { x: 600, y: 20 }, ports: [port('m3p')] },
],
links: [
{ id: 'l1', sourceNodeId: 'pc', sourcePortId: 'p0', targetNodeId: 'sw', targetPortId: 's4' },
@@ -184,6 +273,56 @@ const SYNTHETIC: ProjectToExport = {
['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' }],
['gw/g0', { ipAddress: '42.0.0.7', subnetMask: '255.0.0.0' }],
['gw/g1', { ipAddress: '192.168.1.1', subnetMask: '255.255.255.0' }],
]),
switchConfigurations: new Map([['sw', { ssid: 'S-net', retentionTimeMilliseconds: 1234 }]]),
manualRoutesByNodeId: new Map([
['pc', [{ destinationNetwork: '10.5.0.0', subnetMask: '255.255.0.0', gateway: '10.0.0.254', interfaceIPAddress: '10.0.0.1' }]],
['rt', [{ destinationNetwork: '10.6.0.0', subnetMask: '255.255.0.0', gateway: '10.1.0.2', interfaceIPAddress: '10.1.0.1' }]],
]),
ripEnabledByNodeId: new Map([['rt', true]]),
firewallConfigurationsByNodeId: new Map([
[
'rt',
firewallConfiguration({
isActive: true,
defaultPolicy: 'accept',
dropIcmp: true,
filterSynSegmentsOnly: false,
filterUdp: false,
rules: [
firewallRule({ sourceIPAddress: '10.0.0.0', sourceSubnetMask: '255.0.0.0', protocol: 'all', action: 'accept' }),
firewallRule({ destinationIPAddress: '10.2.0.5', destinationSubnetMask: '255.255.255.255', port: 80, protocol: 'tcp' }),
firewallRule({ port: 53, protocol: 'udp', action: 'accept' }),
firewallRule({ protocol: 'icmp' }),
],
}),
],
]),
dhcpClientConfigurations: new Map([
['pc', { isEnabled: true }],
['gw', { isEnabled: true }],
]),
dhcpServerConfigurations: new Map([
[
'gw',
dhcpServerConfiguration({
isActive: true,
lowerBoundIPAddress: '192.168.1.100',
upperBoundIPAddress: '192.168.1.199',
gatewayIPAddress: '192.168.1.1',
dnsServerIPAddress: '8.8.8.8',
useOwnSettings: true,
staticAssignments: [{ id: 'a1', macAddress: 'AA:BB:CC:DD:EE:77', ipAddress: '192.168.1.77' }],
}),
],
]),
portForwardingRowsByNodeId: new Map([['gw', [{ protocolValue: 'TCP', publicPortValue: '8080', lanIPAddress: '192.168.1.10', lanPortValue: '80' }]]]),
remoteLinkConfigurations: new Map([
['m1', { pairIdentifier: 'java-modem-port-4242', latencyMilliseconds: 20, isEnabled: true }],
['m2', { pairIdentifier: 'java-modem-port-4242', latencyMilliseconds: 20, isEnabled: true }],
['m3', { pairIdentifier: 'our own pair', latencyMilliseconds: 20, isEnabled: true }],
]),
hostWirelessConfigurations: new Map([['nb', { isEnabled: true, ssid: 'net' }]]),
documentationItems: [
@@ -227,6 +366,15 @@ describe('a model beyond the corpus, as Java loads it', () => {
);
expect(warnings).toEqual([]);
const card = (ip: string, mask: string, mac: string, gateway: string, dns: string, wireless = false) => ({ ip, mask, mac, gateway, dns, wireless });
const modem = (name: string, x: number, mode: number, port: number) => ({
class: 'Modem',
name,
position: [x, 20],
useIpAsName: undefined,
useMacAsName: undefined,
ports: [{}],
system: { modem: { mode, port, ipAdresse: 'localhost' } },
});
expect(topologyOf(java!)).toEqual({
success: true,
nodes: [
@@ -237,8 +385,22 @@ describe('a model beyond the corpus, as Java loads it', () => {
useIpAsName: true,
useMacAsName: true,
ports: [card('10.0.0.1', '255.255.255.0', stableGeneratedMacAddress('p0'), '10.0.0.254', '10.0.0.53')],
system: {
ssid: null,
manualRoutes: [['10.5.0.0', '255.255.0.0', '10.0.0.254', '10.0.0.1']],
dhcpClient: true,
dhcpServer: JAVA_DHCP_DEFAULTS,
},
},
{
class: 'Switch',
name: 'SW',
position: [100, 20],
useIpAsName: undefined,
useMacAsName: undefined,
ports: [{}, {}, {}, {}, {}],
system: { ssid: 'S-net', retentionTime: 1234 },
},
{ class: 'Switch', name: 'SW', position: [100, 20], useIpAsName: undefined, useMacAsName: undefined, ports: [{}, {}, {}, {}, {}] },
{
class: 'Notebook',
name: 'NB',
@@ -246,6 +408,7 @@ describe('a model beyond the corpus, as Java loads it', () => {
useIpAsName: false,
useMacAsName: true,
ports: [card('10.0.0.2', '255.255.255.0', stableGeneratedMacAddress('n0'), '', '', true)],
system: { ssid: 'net', manualRoutes: [], dhcpClient: false, dhcpServer: JAVA_DHCP_DEFAULTS },
},
{
class: 'Vermittlungsrechner',
@@ -258,7 +421,56 @@ describe('a model beyond the corpus, as Java loads it', () => {
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'),
],
system: {
ripEnabled: true,
manualRoutes: [['10.6.0.0', '255.255.0.0', '10.1.0.2', '10.1.0.1']],
dhcpClient: false,
dhcpServer: JAVA_DHCP_DEFAULTS,
firewall: {
activated: true,
defaultPolicy: 1,
dropICMP: true,
filterSYNSegmentsOnly: false,
filterUdp: false,
rules: [
['10.0.0.0', '255.0.0.0', '', '', -1, -1, 1],
['', '', '10.2.0.5', '255.255.255.255', 80, 6, 0],
['', '', '', '', 53, 17, 1],
['', '', '', '', -1, 1, 0],
],
},
},
},
{
class: 'Gateway',
name: 'GW',
position: [300, 20],
useIpAsName: undefined,
useMacAsName: undefined,
ports: [
card('42.0.0.7', '255.0.0.0', stableGeneratedMacAddress('g0'), '', ''),
card('192.168.1.1', '255.255.255.0', stableGeneratedMacAddress('g1'), '', ''),
],
system: {
manualRoutes: [],
dhcpClient: true,
dhcpServer: {
aktiv: true,
untergrenze: '192.168.1.100',
obergrenze: '192.168.1.199',
gatewayip: '192.168.1.1',
dnsserverip: '8.8.8.8',
ownSettings: true,
staticAssignedAddresses: ['AA:BB:CC:DD:EE:77 192.168.1.77'],
},
staticNAT: [['TCP', '8080', '192.168.1.10', '80']],
firewall: { activated: false, defaultPolicy: 0, dropICMP: false, filterSYNSegmentsOnly: true, filterUdp: true, rules: [] },
},
},
// A pair on Java's port: the first serves; a pair of our own: Java's default port.
modem('M1', 400, 1, 4242),
modem('M2', 500, 2, 4242),
modem('M3', 600, 1, 12345),
],
cables: [
{ ends: [[0, 0], [1, 4]], wireless: false, panel: [0, 1] },
@@ -273,6 +485,17 @@ describe('a model beyond the corpus, as Java loads it', () => {
});
});
/** The DHCP server as every IP stack's constructor leaves it. */
const JAVA_DHCP_DEFAULTS = {
aktiv: false,
untergrenze: '0.0.0.0',
obergrenze: '0.0.0.0',
gatewayip: '0.0.0.0',
dnsserverip: '0.0.0.0',
ownSettings: false,
staticAssignedAddresses: [],
};
// ── Rules the corpus does not exercise ─────────────────────────────────────
describe('exportProject rules', () => {
@@ -293,11 +516,9 @@ describe('exportProject rules', () => {
targetPortId: target[1],
});
const project = (nodes: TopologyNode[], links: ReturnType<typeof link>[], wireless: [string, boolean][] = []): ProjectToExport => ({
...emptyProject(),
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', () => {