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>
570 lines
24 KiB
TypeScript
570 lines
24 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 {
|
|
defaultSwitchConfiguration,
|
|
dhcpServerConfiguration,
|
|
DOCUMENTATION_MINIMUM_SIZE,
|
|
firewallConfiguration,
|
|
firewallRule,
|
|
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;
|
|
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 {
|
|
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,
|
|
system: systemOf(node.system),
|
|
})),
|
|
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);
|
|
* - 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 {
|
|
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,
|
|
),
|
|
})),
|
|
};
|
|
},
|
|
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[];
|
|
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) };
|
|
},
|
|
};
|
|
|
|
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);
|
|
}
|
|
|
|
/** 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>(),
|
|
switchSsid: new Set<string>(),
|
|
modemRoles: 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 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', () => {
|
|
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 });
|
|
|
|
/**
|
|
* 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 = {
|
|
...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' },
|
|
{ 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' }],
|
|
['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: [
|
|
{
|
|
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 });
|
|
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: [
|
|
{
|
|
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')],
|
|
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: 'Notebook',
|
|
name: 'NB',
|
|
position: [100, 120],
|
|
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',
|
|
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'),
|
|
],
|
|
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] },
|
|
{ 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] },
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
/** 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', () => {
|
|
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 => ({
|
|
...emptyProject(),
|
|
graph: { nodes, links },
|
|
hostWirelessConfigurations: new Map(wireless.map(([id, isEnabled]) => [id, { isEnabled, ssid: 'net' }])),
|
|
});
|
|
|
|
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'));
|
|
});
|
|
});
|