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>
This commit is contained in:
2026-09-29 20:47:47 +00:00
co-authored by Claude Opus 5.5
parent a02bb62413
commit da2ec1dae2
16 changed files with 14023 additions and 1 deletions
+7
View File
@@ -0,0 +1,7 @@
// TextEncoder exists in every target (browsers, Node, workers) but belongs to
// the DOM and Node type libraries, which this package does not load on
// purpose. Declared here to the extent the core uses it.
declare class TextEncoder {
encode(input?: string): Uint8Array;
}
+3
View File
@@ -1,5 +1,8 @@
// Public surface of the simulation core.
export * from './ids.js';
export * from './model/documentation.js';
export * from './model/graph.js';
export * from './model/link.js';
export * from './model/node.js';
export * from './runtime/config.js';
export * from './runtime/engine.js';
+150
View File
@@ -0,0 +1,150 @@
// Ported from filius-ipad (GPLv2/GPLv3):
// ios/FiliusPad/TopologyEditor/Model/TopologyDocumentation.swift
// Java counterpart: filius.gui.netzwerksicht.GUIDocuItem (RECT = 1, TEXT = 2).
// Rectangles and text boxes drawn over the network.
import { compareIds, type Id } from '../ids.js';
export type DocumentationItemKind = 'text' | 'rectangle';
/** Components 0…1, as Swift keeps them. */
export interface DocumentationColor {
readonly red: number;
readonly green: number;
readonly blue: number;
readonly alpha: number;
}
export interface Rect {
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
}
export interface DocumentationItem {
readonly id: Id;
kind: DocumentationItemKind;
frame: Rect;
text: string;
color: DocumentationColor;
fontName: string;
fontSize: number;
isBold: boolean;
order: number;
}
export const DOCUMENTATION_DEFAULT_TEXT_SIZE = { width: 220, height: 96 } as const;
export const DOCUMENTATION_DEFAULT_RECTANGLE_SIZE = { width: 240, height: 140 } as const;
export const DOCUMENTATION_MINIMUM_SIZE = { width: 32, height: 24 } as const;
/** The editor's coordinate range; keeps Java `int` export and rendering safe. */
export const DOCUMENTATION_WORLD_BOUNDS: Rect = { x: -10_000, y: -10_000, width: 20_000, height: 20_000 };
function clampUnit(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.min(Math.max(value, 0), 1);
}
export function documentationColor(red: number, green: number, blue: number, alpha = 1): DocumentationColor {
return { red: clampUnit(red), green: clampUnit(green), blue: clampUnit(blue), alpha: clampUnit(alpha) };
}
export const DOCUMENTATION_BLACK = documentationColor(0, 0, 0);
export const DOCUMENTATION_PALE_YELLOW = documentationColor(255 / 255, 255 / 255, 227 / 255, 0.82);
/** CGRect.standardized: a negative width or height moves the origin instead. */
function standardized(frame: Rect): Rect {
const x = frame.width < 0 ? frame.x + frame.width : frame.x;
const y = frame.height < 0 ? frame.y + frame.height : frame.y;
return { x, y, width: Math.abs(frame.width), height: Math.abs(frame.height) };
}
/** At least the minimum size, at most the world, moved inside the world. */
export function normalizedFrame(frame: Rect): Rect {
const world = DOCUMENTATION_WORLD_BOUNDS;
const fallback = { x: world.x, y: world.y, ...DOCUMENTATION_MINIMUM_SIZE };
if (![frame.x, frame.y, frame.width, frame.height].every(Number.isFinite)) return fallback;
const rect = standardized(frame);
const width = Math.min(Math.max(rect.width, DOCUMENTATION_MINIMUM_SIZE.width), world.width);
const height = Math.min(Math.max(rect.height, DOCUMENTATION_MINIMUM_SIZE.height), world.height);
const x = Math.min(Math.max(rect.x, world.x), world.x + world.width - width);
const y = Math.min(Math.max(rect.y, world.y), world.y + world.height - height);
return { x, y, width, height };
}
/** Positive size, entirely inside the world. */
export function isSafeFrame(frame: Rect): boolean {
const world = DOCUMENTATION_WORLD_BOUNDS;
if (![frame.x, frame.y, frame.width, frame.height].every(Number.isFinite)) return false;
if (frame.width <= 0 || frame.height <= 0 || frame.width > world.width || frame.height > world.height) return false;
return (
frame.x >= world.x &&
frame.y >= world.y &&
frame.x + frame.width <= world.x + world.width &&
frame.y + frame.height <= world.y + world.height
);
}
export function normalizedFontSize(value: number): number {
if (!Number.isFinite(value)) return 14;
return Math.min(Math.max(value, 8), 72);
}
/** A documentation item as Swift's initializer normalizes it. */
export function documentationItem(fields: {
id: Id;
kind: DocumentationItemKind;
frame: Rect;
text?: string;
color: DocumentationColor;
fontName?: string;
fontSize?: number;
isBold?: boolean;
order: number;
}): DocumentationItem {
const fontName = (fields.fontName ?? 'System').trim();
return {
id: fields.id,
kind: fields.kind,
frame: normalizedFrame(fields.frame),
text: fields.kind === 'text' ? (fields.text ?? '') : '',
color: fields.color,
fontName: fontName === '' ? 'System' : fontName,
fontSize: normalizedFontSize(fields.fontSize ?? 14),
isBold: fields.isBold ?? false,
order: Math.max(0, fields.order),
};
}
/** Rectangles below text, then by order, then by id. */
export function inDeterministicRenderOrder(items: readonly DocumentationItem[]): DocumentationItem[] {
const layer = (item: DocumentationItem): number => (item.kind === 'rectangle' ? 0 : 1);
return [...items].sort(
(lhs, rhs) => layer(lhs) - layer(rhs) || lhs.order - rhs.order || compareIds(lhs.id, rhs.id),
);
}
export function nextDocumentationOrder(items: readonly DocumentationItem[]): number {
return Math.max(-1, ...items.map((item) => item.order)) + 1;
}
const UINT64 = (1n << 64n) - 1n;
const FNV_PRIME = 0x100000001b3n;
/**
* Swift's `deterministicDocumentationUUID(seed:)`: two 64-bit FNV-1a hashes
* of the seed's UTF-8 bytes (the second with the length mixed in) as the
* UUID's sixteen bytes, upper case like `uuidString`.
*/
export function deterministicDocumentationUuid(seed: string): Id {
const bytes = new TextEncoder().encode(seed);
const hash = (offset: bigint): bigint => {
let value = offset;
for (const byte of bytes) value = ((value ^ BigInt(byte)) * FNV_PRIME) & UINT64;
return value;
};
const high = hash(0xcbf29ce484222325n);
const low = hash(0x84222325cbf29ce4n) ^ BigInt(bytes.length);
const hex = high.toString(16).padStart(16, '0') + low.toString(16).padStart(16, '0');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`.toUpperCase();
}
+11
View File
@@ -0,0 +1,11 @@
// Ported from filius-ipad (GPLv2/GPLv3):
// ios/FiliusPad/TopologyEditor/Model/TopologyGraph.swift
// The nodes and the cables between them. Queries follow with the editor.
import type { TopologyLink } from './link.js';
import type { TopologyNode } from './node.js';
export interface TopologyGraph {
nodes: TopologyNode[];
links: TopologyLink[];
}
+13
View File
@@ -0,0 +1,13 @@
// Ported from filius-ipad (GPLv2/GPLv3):
// ios/FiliusPad/TopologyEditor/Model/TopologyLink.swift
// A cable: from a port of one node to a port of another.
import type { Id } from '../ids.js';
export interface TopologyLink {
readonly id: Id;
readonly sourceNodeId: Id;
readonly sourcePortId: Id;
readonly targetNodeId: Id;
readonly targetPortId: Id;
}
+45
View File
@@ -100,3 +100,48 @@ export function defaultPortLabels(kind: NodeKind): string[] {
return [];
}
}
export interface Position {
readonly x: number;
readonly y: number;
}
export interface TopologyNode {
readonly id: Id;
kind: NodeKind;
displayName: string;
hostLabelPolicy: HostLabelPolicy;
position: Position;
ports: PortMetadata[];
}
/**
* A node as Swift's `TopologyNode.init` builds it: only a PC or notebook
* shows its IP or MAC address as its label, and a new node gets the ports of
* its kind.
*
* Unlike Swift, a given name is kept as it is — also empty: Java FILIUS
* files leave routers deliberately unnamed (the Internet examples), and Java
* shows them without a label. Only a node without a name gets the default.
*/
export function topologyNode(fields: {
id: Id;
kind: NodeKind;
displayName?: string;
hostLabelPolicy?: HostLabelPolicy;
position: Position;
ports?: PortMetadata[];
/** Makes the ids of the default ports; the core has no randomness of its own. */
newId: () => Id;
}): TopologyNode {
return {
id: fields.id,
kind: fields.kind,
displayName: fields.displayName ?? defaultDisplayName(fields.kind),
hostLabelPolicy: isPcClassEndpoint(fields.kind) ? (fields.hostLabelPolicy ?? 'manual') : 'manual',
position: fields.position,
ports:
fields.ports ??
defaultPortLabels(fields.kind).map((label) => ({ id: fields.newId(), label, isOccupied: false })),
};
}
+1
View File
@@ -8,6 +8,7 @@
".": "./src/index.ts"
},
"dependencies": {
"@mdg-filius/core": "0.0.0",
"fflate": "^0.8.2",
"saxes": "^6.0.0"
}
+277
View File
@@ -0,0 +1,277 @@
// From Java's view of a .fls file (topology.ts) to the editor model the
// simulation core runs: nodes with kinds and ports, links between port ids,
// the IP configurations, documentation items.
//
// Ported from filius-ipad (GPLv2/GPLv3):
// Persistence/TopologyProjectStore.swift — importFiliusConfigurationXML,
// finalizeCurrentNodeCandidate, importedPorts, registerRuntimeConfigurations,
// resolveCableCandidates, finalizeCurrentDocumentationCandidate.
// Checked against the Swift import (corpus.swift.json, `state`) on every
// corpus file the Swift reader reads completely.
//
// Departures from Swift, where the input is Java's rather than Swift's own:
// - port counts of switches and routers are Java's (see importedPorts);
// - a node's name is Java's, kept as it is — also empty (Swift would trim it
// and put a default in place of an empty one);
// - an address Java's setter rejected (null) becomes Swift's default, as
// Swift does for an empty one; Swift would keep invalid text verbatim.
// Not here yet: switch and wireless settings, routes, firewall, DHCP, remote
// links and everything else under a node's system software.
import {
canonicalMacAddress,
deterministicDocumentationUuid,
documentationColor,
documentationItem,
inDeterministicRenderOrder,
interfaceKey,
isSafeFrame,
topologyNode,
DOCUMENTATION_BLACK,
DOCUMENTATION_PALE_YELLOW,
type DeviceConfiguration,
type DocumentationItem,
type HostLabelPolicy,
type Id,
type InterfaceConfiguration,
type InterfaceKey,
type NodeKind,
type PortMetadata,
type TopologyGraph,
type TopologyLink,
type TopologyNode,
} from '@mdg-filius/core';
import type { FlsDocumentationItem, FlsInterface, FlsNode, FlsNodeClass, FlsTopology } from './topology.js';
export interface ImportedProject {
versionLine: string | null;
graph: TopologyGraph;
deviceConfigurations: Map<Id, DeviceConfiguration>;
interfaceConfigurations: Map<InterfaceKey, InterfaceConfiguration>;
/** In render order: rectangles below text, then by order. */
documentationItems: DocumentationItem[];
/** The topology's warnings, then this step's. */
warnings: string[];
}
export const NODE_KIND_OF_CLASS: Readonly<Record<FlsNodeClass, NodeKind>> = {
Rechner: 'pc',
Notebook: 'notebook',
Switch: 'networkSwitch',
Vermittlungsrechner: 'router',
Gateway: 'gateway',
Modem: 'remoteLink',
};
/**
* The editor model of a topology. `newId` makes node, port and link ids
* (Swift uses random UUIDs; tests pass a counter). Documentation ids are
* derived from the item, as in Swift.
*/
export function importProject(topology: FlsTopology, newId: () => Id): ImportedProject {
const warnings = [...topology.warnings];
const deviceConfigurations = new Map<Id, DeviceConfiguration>();
const interfaceConfigurations = new Map<InterfaceKey, InterfaceConfiguration>();
const nodes = topology.nodes.map((source) => {
const node = importNode(source, newId, warnings);
registerConfigurations(source, node, deviceConfigurations, interfaceConfigurations);
return node;
});
const links: TopologyLink[] = [];
const occupied = new Set<Id>();
topology.links.forEach((link, index) => {
const [source, target] = link.ends.map((end) => {
const node = nodes[end.node];
const port = node?.ports[end.port];
return node === undefined || port === undefined ? undefined : { node, port };
});
const where = `cable ${index + 1}`;
if (source === undefined || target === undefined) {
warnings.push(`${where}: an end is not a port of the imported node, left out`);
return;
}
if (source.node.id === target.node.id) {
warnings.push(`${where}: connects a node to itself, left out`);
return;
}
if (occupied.has(source.port.id) || occupied.has(target.port.id)) {
warnings.push(`${where}: a port already has a cable, left out`);
return;
}
occupied.add(source.port.id);
occupied.add(target.port.id);
links.push({
id: newId(),
sourceNodeId: source.node.id,
sourcePortId: source.port.id,
targetNodeId: target.node.id,
targetPortId: target.port.id,
});
});
const documentationItems: DocumentationItem[] = [];
for (const item of topology.documentation) {
const imported = importDocumentationItem(item, documentationItems.length, warnings);
if (imported !== undefined) documentationItems.push(imported);
}
return {
versionLine: topology.versionLine,
graph: { nodes, links },
deviceConfigurations,
interfaceConfigurations,
documentationItems: inDeterministicRenderOrder(documentationItems),
warnings,
};
}
function hostLabelPolicy(node: FlsNode): HostLabelPolicy {
if (node.useIpAsName && node.useMacAsName) return 'ipAndMAC';
if (node.useIpAsName) return 'ipAddress';
if (node.useMacAsName) return 'macAddress';
return 'manual';
}
/**
* Swift's importedPorts: the labels of a kind, the MAC addresses of the
* interfaces. Swift guesses a switch's and a router's port count from the
* references it saw (at least 24, at least one); Java's list is known here
* and counts — a file that sets a shorter list has fewer ports in Java too.
* A Gateway keeps two, since Java's own code needs WAN and LAN; a host and a
* modem have exactly one, as in Swift.
*/
function importedPorts(node: FlsNode, kind: NodeKind, newId: () => Id, warnings: string[]): PortMetadata[] {
const port = (label: string, nic?: FlsInterface): PortMetadata => {
const mac = canonicalMacAddress(nic?.mac);
const id = newId();
return mac === undefined ? { id, label, isOccupied: false } : { id, label, isOccupied: false, importedMacAddress: mac };
};
switch (kind) {
case 'pc':
case 'notebook':
if (node.interfaces.length > 1) {
warnings.push(`node '${node.name ?? ''}': only the first of ${node.interfaces.length} interfaces is kept`);
}
return [port('eth0', node.interfaces[0])];
case 'networkSwitch':
return Array.from({ length: node.portCount }, (_, index) => port(`sw${index + 1}`));
case 'router':
return Array.from({ length: node.interfaces.length }, (_, index) =>
port(`rt${index + 1}`, node.interfaces[index]),
);
case 'gateway':
return Array.from({ length: Math.max(2, node.interfaces.length) }, (_, index) =>
port(index === 0 ? 'wan0' : index === 1 ? 'lan0' : `gw${index}`, node.interfaces[index]),
);
case 'remoteLink':
return [port('remote0')];
case 'unsupported':
return [];
}
}
function importNode(source: FlsNode, newId: () => Id, warnings: string[]): TopologyNode {
const kind = NODE_KIND_OF_CLASS[source.javaClass];
const ports = importedPorts(source, kind, newId, warnings);
return topologyNode({
id: newId(),
kind,
...(source.name === null ? {} : { displayName: source.name }),
hostLabelPolicy: hostLabelPolicy(source),
position: { x: source.x, y: source.y },
ports,
newId,
});
}
/** Swift's normalizedOrDefault; Java's null (a rejected address) counts as empty. */
function orDefault(value: string | null, fallback: string): string {
const trimmed = value?.trim() ?? '';
return trimmed === '' ? fallback : trimmed;
}
/** Swift's registerRuntimeConfigurations. */
function registerConfigurations(
source: FlsNode,
node: TopologyNode,
deviceConfigurations: Map<Id, DeviceConfiguration>,
interfaceConfigurations: Map<InterfaceKey, InterfaceConfiguration>,
): void {
if (node.kind === 'pc' || node.kind === 'notebook') {
const nic = source.interfaces[0];
deviceConfigurations.set(node.id, {
ipAddress: orDefault(nic?.ip ?? null, '192.168.0.10'),
subnetMask: orDefault(nic?.mask ?? null, '255.255.255.0'),
defaultGateway: nic?.gateway?.trim() ?? '',
dnsServer: nic?.dns?.trim() ?? '',
});
return;
}
if (node.kind !== 'router' && node.kind !== 'gateway') return;
node.ports.forEach((port, index) => {
const nic = source.interfaces[index];
const wan = node.kind === 'gateway' && index === 0;
interfaceConfigurations.set(interfaceKey(node.id, port.id), {
ipAddress: orDefault(nic?.ip ?? null, wan ? '42.0.0.10' : '192.168.0.10'),
subnetMask: orDefault(nic?.mask ?? null, wan ? '255.0.0.0' : '255.255.255.0'),
});
});
const wan = source.interfaces[0];
if (node.kind === 'gateway' && wan !== undefined) {
const wanConfiguration = interfaceConfigurations.get(interfaceKey(node.id, node.ports[0]!.id));
deviceConfigurations.set(node.id, {
ipAddress: wanConfiguration?.ipAddress ?? '',
subnetMask: wanConfiguration?.subnetMask ?? '',
defaultGateway: wan.gateway?.trim() ?? '',
dnsServer: wan.dns?.trim() ?? '',
});
}
}
/**
* Swift's finalizeCurrentDocumentationCandidate: RECT = 1, TEXT = 2; a frame
* inside the editor's world; the id from the item's values and its order.
*/
function importDocumentationItem(
item: FlsDocumentationItem,
order: number,
warnings: string[],
): DocumentationItem | undefined {
if (item.width <= 0 || item.height <= 0) {
warnings.push('documentation item without size, left out');
return undefined;
}
const kind = item.type === 1 ? 'rectangle' : item.type === 2 ? 'text' : undefined;
if (kind === undefined) {
warnings.push(`documentation item of type ${item.type}, left out (known: RECT = 1, TEXT = 2)`);
return undefined;
}
const frame = { x: item.x, y: item.y, width: item.width, height: item.height };
if (!isSafeFrame(frame)) {
warnings.push('documentation item outside the editor bounds, left out');
return undefined;
}
const text = item.text ?? '';
const seed = `java-doc|${order}|${item.type}|${item.x}|${item.y}|${item.width}|${item.height}|${text}`;
let color;
if (item.color === null) {
color = kind === 'text' ? DOCUMENTATION_BLACK : DOCUMENTATION_PALE_YELLOW;
warnings.push(`documentation item ${order + 1} without a color, given the default`);
} else {
const [red, green, blue, alpha] = item.color;
color = documentationColor(red / 255, green / 255, blue / 255, alpha / 255);
}
return documentationItem({
id: deterministicDocumentationUuid(seed),
kind,
frame,
text,
color,
fontName: item.font?.name ?? 'Dialog',
fontSize: item.font?.size ?? 12,
isBold: ((item.font?.style ?? 0) & 1) === 1,
order,
});
}
+1
View File
@@ -1,5 +1,6 @@
// Public surface of the .fls codec.
export * from './archive.js';
export * from './importProject.js';
export * from './javaIp.js';
export * from './javabeans.js';
export * from './topology.js';
+339
View File
@@ -0,0 +1,339 @@
// 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)',
]);
});
});
+5
View File
@@ -9,5 +9,10 @@
"include": [
"src",
"src/*.json"
],
"references": [
{
"path": "../core"
}
]
}