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:
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user