Files
mdg-filius/packages/fls/test/exportProject.test.ts
T
julian.kiedaischandClaude Opus 5.5 56cdb4926e feat: F4.8a the documentation items in model and file, measured against Java's
New oracle DocuRun on Java's JDocuElement. Italic is kept (the model knew
only bold), an item keeps its file's size and font size (Swift's 32 x 24
and 8-72 changed Java's files), an item without a colour is Java's light
grey. Java's new items, colour lists, font sizes and styles, the drag's
smallest sizes and Java's stacking; document commands for the editor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:42:21 +00:00

1062 lines
46 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,
firewallConfiguration,
firewallRule,
stableGeneratedMacAddress,
type EmailMessage,
type Id,
type InstallableProgram,
type TopologyNode,
VirtualFileSystem,
} from '@mdg-filius/core';
import { describe, expect, it } from 'vitest';
import {
decodeJavaBeans,
exportProject,
importProject,
javaBase64,
openProjectArchive,
readFlsArchive,
saveProjectArchive,
readTopology,
writeFlsArchive,
type FlsFileTree,
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 with its files in any order. */
function systemOf(settings: Record<string, unknown>) {
return settings.fileSystem === undefined || settings.fileSystem === null
? settings
: { ...settings, fileSystem: sortedTree(settings.fileSystem as JavaTree) };
}
/** 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, a missing colour
* the light grey Java shows for it;
* - 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;
* - routerFiles: the model holds no files for a router or home router; what
* the file adds to the ones its constructor makes (a web server's
* www.conf) is not written;
* - fileTypes: a file's type follows from its kind in the model (fileType
* below), not the one the file named — Java names them unevenly itself
* (txt, text/txt, html, none);
* - fileOrder: directories first, then by name (compared regardless of
* order here);
* - recipientsAsStrings: a recipient an older file keeps as a string is the
* address Java's AddressEntry(String) reads from it (the oracle reports
* it next to the string), as the import reads it — Java puts the string
* into its typed list unchecked and its e-mail window fails on it;
* - sentFolder: the messages of the client's sent folder are sent ones;
* - accountFiles: /konten.txt and /mailserver/konten.txt are written anew
* from the model's accounts as FILIUS 2.14 writes them — with its types
* (text/txt, txt), the client's line without the line break older
* versions ended it with.
*/
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 } };
});
},
routerFiles(view: JavaFile, added: number[]): JavaFile {
return mapSystems(view, (node, index) => {
if ((node.class !== 'Vermittlungsrechner' && node.class !== 'Gateway') || added[index] === 0) return node.system;
const tree = node.system.fileSystem as JavaTree;
return { ...node.system, fileSystem: { ...tree, children: tree.children.slice(0, tree.children.length - added[index]!) } };
});
},
fileTypes(view: JavaFile): JavaFile {
const retype = (tree: JavaTree): JavaTree => ({
...tree,
...('file' in tree ? { type: fileType(tree.file as string, tree.type as string | null) } : {}),
children: tree.children.map(retype),
});
return mapSystems(view, (node) =>
node.system.fileSystem === undefined || node.system.fileSystem === null
? node.system
: { ...node.system, fileSystem: retype(node.system.fileSystem as JavaTree) },
);
},
recipientsAsStrings(view: JavaFile): JavaFile {
return withRecipientsRead(view) as JavaFile;
},
sentFolder(view: JavaFile): JavaFile {
return mapSystems(view, (node) =>
node.system.applications === undefined
? node.system
: {
...node.system,
applications: (node.system.applications as Record<string, unknown>[]).map((application) =>
application.sent === undefined
? application
: { ...application, sent: (application.sent as Record<string, unknown>[]).map((message) => ({ ...message, versendet: true })) },
),
},
);
},
accountFiles(view: JavaFile, contents: (Map<string, string> | undefined)[]): JavaFile {
/** The tree at a path ("" for the root), with each account file as the export writes it. */
const rewrite = (tree: JavaTree, path: string, files: Map<string, string>): JavaTree => {
const children = tree.children.map((child) => rewrite(child, `${path}/${String(child.file ?? child.directory)}`, files));
const content = 'file' in tree ? files.get(path) : undefined;
if (content === undefined) return { ...tree, children };
const client = path.toLowerCase() === '/konten.txt';
return { ...tree, ...javaFile(tree.file as string, client ? 'text/txt' : 'txt', client ? content.replace(/\n$/, '') : content), children };
};
return mapSystems(view, (node, index) => {
const tree = node.system.fileSystem as JavaTree | null | undefined;
const files = contents[index];
return tree === undefined || tree === null || files === undefined ? node.system : { ...node.system, fileSystem: rewrite(tree, '', files) };
});
},
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, width, height],
font: item.font ?? ['Dialog', 12, 0],
color: item.color ?? [192, 192, 192, 255],
};
});
return { ...view, documentation: sortedDocumentation(items) };
},
};
type JavaTree = { file?: string; directory?: string | null; type?: string | null; children: JavaTree[] } & Record<string, unknown>;
const IMAGE_TYPES = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'image']);
const TEXT_TYPES = new Set(['', 'text', 'txt', 'text/txt', 'html', 'htm', 'css', 'xml', 'csv', 'conf', 'log']);
const TEXT_EXTENSIONS = new Set(['txt', 'html', 'htm', 'css', 'xml', 'csv', 'conf', 'log']);
/**
* The type of a file in the model's kind: an image (by its type or
* extension) by its media type, "jpg" for JPEG; text by a known extension,
* else "text"; other data by its type.
*/
function fileType(name: string, type: string | null): string {
const declared = (type ?? '').trim().toLowerCase();
const dot = name.lastIndexOf('.');
const extension = dot < 0 ? '' : name.slice(dot + 1).toLowerCase();
if (IMAGE_TYPES.has(declared) || IMAGE_TYPES.has(extension)) {
const subtype = declared === 'image' || declared === '' ? (extension === '' ? 'png' : extension) : declared;
return subtype === 'jpeg' ? 'jpg' : subtype;
}
if (TEXT_TYPES.has(declared)) return TEXT_EXTENSIONS.has(extension) ? extension : 'text';
return declared;
}
/** A file tree with every directory's children in an order that does not depend on the list's. */
function sortedTree(tree: JavaTree): JavaTree {
const children = tree.children.map(sortedTree);
return { ...tree, children: children.map((child) => JSON.stringify(child)).sort().map((text) => JSON.parse(text) as JavaTree) };
}
/** Every node's file tree as text, sorted or as it is. */
function fileTrees(view: JavaFile, sorted: boolean): string {
return JSON.stringify(
view.nodes.map((node) => {
const tree = node.system.fileSystem as JavaTree | null | undefined;
return tree === undefined || tree === null ? null : sorted ? sortedTree(tree) : tree;
}),
);
}
/**
* The contents of /konten.txt (with the client's account) and
* /mailserver/konten.txt (with the server) in a host's files, as the file
* has them: the ones the export writes anew.
*/
function accountFileContents(root: FlsFileTree | undefined, project: ReturnType<typeof importProject>, id: Id): Map<string, string> | undefined {
if (root === undefined) return undefined;
const result = new Map<string, string>();
const programs = project.installedPrograms.get(id);
const child = (tree: FlsFileTree, name: string) => tree.children.find((each) => each.kind !== 'other' && each.name?.toLowerCase() === name);
const client = child(root, 'konten.txt');
if (programs?.has('emailClient') && project.emailClientConfigurations.get(id)?.account != null && client?.kind === 'file') {
result.set(`/${client.name}`, client.content ?? '');
}
const directory = child(root, 'mailserver');
const server = directory?.kind === 'directory' ? child(directory, 'konten.txt') : undefined;
if (programs?.has('emailServer') && directory?.kind === 'directory' && server?.kind === 'file') {
result.set(`/${directory.name}/${server.name}`, server.content ?? '');
}
return result;
}
/** Each recipient as Java reads its string, none null. */
function withRecipientsRead(value: unknown, key = ''): unknown {
if (Array.isArray(value)) {
if (key === 'empfaenger' || key === 'cc' || key === 'bcc') {
return value.filter((entry) => entry !== null).map((entry) => (entry[0] === 'string' ? entry[2] : entry));
}
return value.map((item) => withRecipientsRead(item));
}
if (value !== null && typeof value === 'object') {
return Object.fromEntries(Object.entries(value).map(([name, item]) => [name, withRecipientsRead(item, name)]));
}
return value;
}
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>(),
routerFiles: new Set<string>(),
fileTypes: new Set<string>(),
fileOrder: new Set<string>(),
recipientsAsStrings: new Set<string>(),
sentFolder: new Set<string>(),
accountFiles: 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 bytes = new Uint8Array(readFileSync(file));
const topology = readTopology(decodeJavaBeans(readFlsArchive(bytes).configurationXml));
const project = openProjectArchive(bytes, counter());
const { topology: toWrite } = exportProject(project);
const { archive } = saveProjectArchive(project);
// The project's own applications, and every other entry, byte for byte.
expect(readFlsArchive(archive).supplementalEntries).toEqual(readFlsArchive(bytes).supplementalEntries);
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);
// The account files of each host the export writes anew, by path, as the file has them.
const accountFiles = topology.nodes.map((node, index) => accountFileContents(node.system.fileSystem, project, nodeIds[index]!));
// What each router's file adds to its constructor's files.
const added = topology.nodes.map((node) => (node.system.templateFiles === true ? (node.system.fileSystem?.children.length ?? 0) : 0));
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)],
['routerFiles', (view) => DEPARTURES.routerFiles(view, added)],
['fileTypes', (view) => DEPARTURES.fileTypes(view)],
['recipientsAsStrings', (view) => DEPARTURES.recipientsAsStrings(view)],
['sentFolder', (view) => DEPARTURES.sentFolder(view)],
['accountFiles', (view) => DEPARTURES.accountFiles(view, accountFiles)],
['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;
}
if (fileTrees(expected, true) === fileTrees(java!, true) && fileTrees(expected, false) !== fileTrees(java!, false)) {
departed.fileOrder.add(name);
}
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(),
virtualFileSystems: new Map(),
installedPrograms: new Map(),
activePrograms: new Map(),
webServerConfigurations: new Map(),
gnutellaConfigurations: new Map(),
dnsServerConfigurations: new Map(),
echoServerConfigurations: new Map(),
simpleClientConfigurations: new Map(),
emailClientConfigurations: new Map(),
emailServerConfigurations: new Map(),
documentationItems: [],
};
}
const port = (id: string) => ({ id, label: id, isOccupied: false });
/**
* A message with every character Java escapes in a text, a separator in its
* subject (a space in the account file), a sender with name and address,
* recipients without a name.
*/
const SYNTHETIC_MAIL: EmailMessage = {
id: 1,
from: { name: 'Anna', mailAddress: 'anna@schule.de' },
to: [
{ name: null, mailAddress: 'ben@schule.de' },
{ name: 'Carla', mailAddress: 'carla@schule.de' },
],
cc: [],
bcc: [{ name: null, mailAddress: 'eva@schule.de' }],
subject: 'Hallo; Welt',
body: 'a$b;c|d#e\nzweite Zeile',
receivedAtMilliseconds: 1_727_690_000_000,
isNew: false,
isMarkedForDeletion: true,
isSent: false,
};
/** SYNTHETIC_MAIL as the oracle describes it after Java loaded it. */
function javaMail(changes: Record<string, unknown> = {}) {
return {
absender: ['Anna', 'anna@schule.de'],
empfaenger: [[null, 'ben@schule.de'], ['Carla', 'carla@schule.de']],
cc: [],
bcc: [[null, 'eva@schule.de']],
betreff: 'Hallo; Welt',
text: 'a$b;c|d#e\nzweite Zeile',
dateReceived: '1727690000000',
neu: false,
delete: true,
versendet: false,
...changes,
};
}
const SERVER_ACCOUNTS_FILE =
'anna;schule.de;geheim;Muster;Anna;#Anna <anna@schule.de>$<ben@schule.de>,Carla <carla@schule.de>$$<eva@schule.de>$1727690000000$Hallo Welt$' +
'a&&00036&&b&&00059&&c&&00124&&d&&00035&&e|zweite Zeile\n';
const CLIENT_ACCOUNT_FILE = '10.0.0.1;10.0.0.1;110;25;anna;geheim;Muster;Anna;anna@schule.de';
/** Bytes of a length: (index * 37 + 11) mod 256. */
function bytesOf(length: number): Uint8Array {
return Uint8Array.from({ length }, (_, index) => (index * 37 + 11) % 256);
}
/**
* Nested directories, text with a known extension and without one, a PNG and
* a JPEG, binary data with and without a media type, 57 bytes — one line of
* Base64 exactly.
*/
function syntheticFiles(): VirtualFileSystem {
const files = new VirtualFileSystem();
files.createDirectory('/docs/deep', true);
files.writeTextFile('/docs/readme.txt', 'Zeile 1\r\nZeile 2');
files.writeTextFile('/docs/deep/notes', 'no extension');
files.writeTextFile('/index.HTML', '<p>upper case extension</p>');
files.writeImageFile('/bild.png', bytesOf(57), 'image/png');
files.writeImageFile('/foto.jpg', bytesOf(100), 'image/jpeg');
files.writeBinaryFile('/daten.bin', bytesOf(58), 'application/zip');
files.writeBinaryFile('/roh', bytesOf(3));
// The web server's and the peer-to-peer application's own: Java makes none.
files.createDirectory('/webserver');
files.writeTextFile('/webserver/index.html', '<p>own</p>');
files.createDirectory('/peer2peer');
return files;
}
/** The files a router's or home router's constructor makes, as Java loaded them from the corpus. */
function templateOf(javaClass: string): unknown[] {
const node = Object.values(original)
.flatMap((file) => file.nodes)
.find((each) => each.class === javaClass);
return (node!.system.fileSystem as JavaTree).children.slice(0, 1);
}
/** A file as the oracle describes it: its content's UTF-16 length and the SHA-256 of its UTF-8 bytes. */
function javaFile(name: string, type: string | null, content: string) {
return { file: name, type, length: content.length, sha256: createHash('sha256').update(content, 'utf8').digest('hex'), children: [] };
}
/**
* 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]]),
ipForwardingByNodeId: new Map([['pc', true]]),
firewallConfigurationsByNodeId: new Map([
[
'pc',
firewallConfiguration({
isActive: false,
defaultPolicy: 'accept',
rules: [firewallRule({ sourceIPAddress: '10.0.0.0', sourceSubnetMask: '255.0.0.0', port: 22, action: 'accept' })],
}),
],
[
'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' }]]]),
virtualFileSystems: new Map([['pc', syntheticFiles()]]),
installedPrograms: new Map<Id, Set<InstallableProgram>>([
[
'pc',
new Set<InstallableProgram>([
'webServer',
'dnsServer',
'echoServer',
'simpleClient',
'gnutella',
'firewall',
'commandPrompt',
'textEditor',
'fileExplorer',
'imageViewer',
'webBrowser',
'emailServer',
'emailClient',
]),
],
['nb', new Set<InstallableProgram>(['gnutella', 'simpleClient', 'firewall'])],
]),
emailServerConfigurations: new Map([
[
'pc',
{
domain: 'schule.de',
pop3Port: 110,
accounts: [{ username: 'anna', password: 'geheim', firstName: 'Anna', lastName: 'Muster', mailbox: [SYNTHETIC_MAIL] }],
nextMessageID: 2,
},
],
]),
emailClientConfigurations: new Map([
[
'pc',
{
account: {
pop3Host: '10.0.0.1',
pop3Port: 110,
smtpHost: '10.0.0.1',
smtpPort: 25,
username: 'anna',
password: 'geheim',
firstName: 'Anna',
lastName: 'Muster',
email: 'anna@schule.de',
},
inbox: [SYNTHETIC_MAIL],
sent: [{ ...SYNTHETIC_MAIL, id: 2, isSent: true }],
drafts: [{ ...SYNTHETIC_MAIL, id: 3, subject: '', body: '', to: [] }],
addressBook: [
{ name: 'Muster', firstName: 'Anna', street: 'Weg 1', houseNumber: 12, postalCode: 20095, city: 'Hamburg', email: 'anna@schule.de', phone: '040' },
],
nextMessageID: 4,
},
],
]),
activePrograms: new Map([['pc', new Set<InstallableProgram>(['webServer', 'dnsServer', 'echoServer', 'emailServer'])]]),
webServerConfigurations: new Map([['pc', { port: 8080, documentRoot: '/webserver', virtualHosts: true }]]),
dnsServerConfigurations: new Map([['pc', { recursiveResolutionEnabled: true, records: [] }]]),
echoServerConfigurations: new Map([['pc', { port: 7777 }]]),
simpleClientConfigurations: new Map([
['pc', { targetAddress: '10.0.0.9', targetPort: 7777 }],
['nb', { targetAddress: '', targetPort: 55_555 }],
]),
gnutellaConfigurations: new Map([['pc', { maximumKnownPeers: 7 }]]),
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,
isItalic: 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,
isItalic: false,
order: 1,
},
// Italic, bold and italic, smaller than Swift's smallest size: kept as they are.
{
id: 'd3',
kind: 'text',
frame: { x: 400, y: 10, width: 15, height: 10 },
text: 'kursiv',
color: { red: 0.31, green: 0.51, blue: 0.74, alpha: 1 },
fontName: 'Dialog',
fontSize: 6,
isBold: false,
isItalic: true,
order: 2,
},
{
id: 'd4',
kind: 'text',
frame: { x: 400, y: 40, width: 88, height: 20 },
text: 'beides',
color: { red: 0, green: 0, blue: 0, alpha: 1 },
fontName: 'Serif',
fontSize: 24,
isBold: true,
isItalic: true,
order: 3,
},
],
};
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(["node 'PC': /mailserver/konten.txt: a subject holds a character Java's account file cannot carry, written there with a space"]);
const serverAccount = (nachrichten: unknown[]) => ({
benutzername: 'anna',
passwort: 'geheim',
nachname: 'Muster',
vorname: 'Anna',
emailAdresse: null,
pop3server: null,
pop3port: null,
smtpserver: null,
smtpport: null,
nachrichten,
});
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,
ipForwardingEnabled: true,
dhcpServer: JAVA_DHCP_DEFAULTS,
fileSystem: sortedTree({
directory: 'root',
children: [
{
directory: 'docs',
children: [{ directory: 'deep', children: [javaFile('notes', 'text', 'no extension')] }, javaFile('readme.txt', 'txt', 'Zeile 1\r\nZeile 2')],
},
javaFile('bild.png', 'png', javaBase64(bytesOf(57))),
javaFile('daten.bin', 'zip', javaBase64(bytesOf(58))),
javaFile('foto.jpg', 'jpg', javaBase64(bytesOf(100))),
javaFile('index.HTML', 'html', '<p>upper case extension</p>'),
javaFile('roh', '', javaBase64(bytesOf(3))),
{ directory: 'webserver', children: [javaFile('index.html', 'html', '<p>own</p>')] },
{ directory: 'peer2peer', children: [] },
javaFile('konten.txt', 'text/txt', CLIENT_ACCOUNT_FILE),
{ directory: 'mailserver', children: [javaFile('konten.txt', 'txt', SERVER_ACCOUNTS_FILE)] },
],
}),
// In the order of Java's key.
applications: [
{ key: 'filius.software.clientserver.ClientBaustein', class: 'filius.software.clientserver.ClientBaustein', onItsSystem: true, zielIPAdresse: '10.0.0.9', zielPort: 7777 },
{ key: 'filius.software.clientserver.ServerBaustein', class: 'filius.software.clientserver.ServerBaustein', onItsSystem: true, aktiv: true, port: 7777 },
{ key: 'filius.software.dateiaustausch.PeerToPeerAnwendung', class: 'filius.software.dateiaustausch.PeerToPeerAnwendung', onItsSystem: true, maxTeilnehmerZahl: 7 },
{ key: 'filius.software.dns.DNSServer', class: 'filius.software.dns.DNSServer', onItsSystem: true, aktiv: true, port: 53, recursiveResolutionEnabled: true },
{
key: 'filius.software.email.EmailAnwendung',
class: 'filius.software.email.EmailAnwendung', onItsSystem: true,
received: [javaMail()],
sent: [javaMail({ versendet: true })],
drafts: [javaMail({ betreff: '', text: '', empfaenger: [] })],
addressBook: [['Muster', 'Anna', 'Weg 1', 12, 20095, 'Hamburg', 'anna@schule.de', '040']],
// /konten.txt as Java's laden() reads it.
fileAccount: {
benutzername: 'anna',
passwort: 'geheim',
nachname: 'Muster',
vorname: 'Anna',
emailAdresse: 'anna@schule.de',
pop3server: '10.0.0.1',
pop3port: '110',
smtpserver: '10.0.0.1',
smtpport: '25',
nachrichten: [],
},
},
{
key: 'filius.software.email.EmailServer',
class: 'filius.software.email.EmailServer', onItsSystem: true,
aktiv: true,
mailDomain: 'schule.de',
beanAccounts: [serverAccount([javaMail()])],
// /mailserver/konten.txt as Java's kontenLaden() reads it: the subject with a space, a message's flags as new.
fileAccounts: [serverAccount([javaMail({ betreff: 'Hallo Welt', neu: true, delete: false })])],
},
{
key: 'filius.software.firewall.Firewall',
class: 'filius.software.firewall.Firewall', onItsSystem: true,
activated: false,
defaultPolicy: 1,
dropICMP: false,
filterSYNSegmentsOnly: true,
filterUdp: true,
rules: [['10.0.0.0', '255.0.0.0', '', '', 22, 6, 1]],
},
{ key: 'filius.software.lokal.FileExplorer', class: 'filius.software.lokal.FileExplorer', onItsSystem: true },
{ key: 'filius.software.lokal.ImageViewer', class: 'filius.software.lokal.ImageViewer', onItsSystem: true },
{ key: 'filius.software.lokal.Terminal', class: 'filius.software.lokal.Terminal', onItsSystem: true },
{ key: 'filius.software.lokal.TextEditor', class: 'filius.software.lokal.TextEditor', onItsSystem: true },
{ key: 'filius.software.www.WebBrowser', class: 'filius.software.www.WebBrowser', onItsSystem: true },
{ key: 'filius.software.www.WebServer', class: 'filius.software.www.WebServer', onItsSystem: true, aktiv: true, port: 8080, useVHost: true },
],
},
},
{
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,
ipForwardingEnabled: false,
dhcpServer: JAVA_DHCP_DEFAULTS,
// Made by the peer-to-peer application.
fileSystem: { directory: 'root', children: [{ directory: 'peer2peer', children: [] }] },
applications: [
// Without a setting: no target (null), the model's 3 peers, Java's personal firewall.
{ key: 'filius.software.clientserver.ClientBaustein', class: 'filius.software.clientserver.ClientBaustein', onItsSystem: true, zielIPAdresse: null, zielPort: 55_555 },
{ key: 'filius.software.dateiaustausch.PeerToPeerAnwendung', class: 'filius.software.dateiaustausch.PeerToPeerAnwendung', onItsSystem: true, maxTeilnehmerZahl: 3 },
{
key: 'filius.software.firewall.Firewall',
class: 'filius.software.firewall.Firewall', onItsSystem: true,
activated: true,
defaultPolicy: 0,
dropICMP: false,
filterSYNSegmentsOnly: true,
filterUdp: true,
rules: [],
},
],
},
},
{
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,
fileSystem: sortedTree({ directory: 'root', children: templateOf('Vermittlungsrechner') as JavaTree[] }),
manualRoutes: [['10.6.0.0', '255.255.0.0', '10.1.0.2', '10.1.0.1']],
dhcpClient: false,
ipForwardingEnabled: true,
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: {
fileSystem: sortedTree({ directory: 'root', children: templateOf('Gateway') as JavaTree[] }),
manualRoutes: [],
dhcpClient: true,
ipForwardingEnabled: 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] },
// Java's italic (2) and bold italic (3), and sizes below Swift's smallest, as the model keeps them.
{ type: 2, text: 'kursiv', bounds: [400, 10, 15, 10], color: [79, 130, 189, 255], font: ['Dialog', 6, 2] },
{ type: 2, text: 'beides', bounds: [400, 40, 88, 20], color: [0, 0, 0, 255], font: ['Serif', 24, 3] },
],
});
});
});
/** 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('javaBase64', () => {
// FILIUS's Base64.encodeBytes around its line length (tools/java-oracle Base64Cases).
const cases = JSON.parse(readFileSync(join(here, 'base64.java.json'), 'utf8')) as [number, string][];
it.each(cases)('encodes %i bytes as Java does', (length, java) => {
const bytes = Uint8Array.from({ length }, (_, index) => (index * 37 + 11) % 256);
expect(javaBase64(bytes)).toBe(java);
});
});
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'));
});
});