Files
mdg-filius/packages/fls/test/importProject.test.ts
T
julian.kiedaischandClaude Opus 5.5 cf47cf8f9d feat(fls): import DHCP, port forwarding and modem settings
importProject carries the DHCP client switch and server settings (hosts
and home routers), a home router's port forwarding rows and a modem's
pairing into the model; the core gains the types, ported from Swift.
The Swift oracle writes them too, and they match on the 13 files Swift
reads completely. Configuration text is now trimmed throughout, as Swift
trims every value it reads (routes and firewall rules included).

Outside those files Swift switches every DHCP server of the Internet
examples off: their "aktiv" refers to a shared Boolean it cannot follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:09:08 +00:00

595 lines
24 KiB
TypeScript

// 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,
defaultSwitchConfiguration,
firewallConfiguration,
FIREWALL_PROTOCOL,
interfaceKey,
deepEqual,
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());
}
/**
* describeSystem's shape. A firewall as the core has it without one
* (inactive, drop, no rules) is left out: Swift keeps a configuration only
* when the file sets a field, and the core treats both alike.
*/
function systemOf(project: ImportedProject, nodeId: Id) {
const result: Record<string, unknown> = {};
const switchConfiguration = project.switchConfigurations.get(nodeId);
if (switchConfiguration !== undefined) {
result.switch = {
ssid: switchConfiguration.ssid === defaultSwitchConfiguration(nodeId).ssid ? '<from node id>' : switchConfiguration.ssid,
retentionTimeMilliseconds: switchConfiguration.retentionTimeMilliseconds,
};
}
const wireless = project.hostWirelessConfigurations.get(nodeId);
if (wireless !== undefined) result.hostWireless = wireless;
const routes = project.manualRoutesByNodeId.get(nodeId);
if (routes !== undefined) {
result.manualRoutes = routes.map((route) => [route.destinationNetwork, route.subnetMask, route.gateway, route.interfaceIPAddress]);
}
const rip = project.ripEnabledByNodeId.get(nodeId);
if (rip !== undefined) result.ripEnabled = rip;
const dhcpClient = project.dhcpClientConfigurations.get(nodeId);
if (dhcpClient !== undefined) result.dhcpClient = dhcpClient.isEnabled;
const dhcpServer = project.dhcpServerConfigurations.get(nodeId);
if (dhcpServer !== undefined) {
result.dhcpServer = {
...dhcpServer,
staticAssignments: dhcpServer.staticAssignments.map((entry) => [entry.macAddress, entry.ipAddress]),
};
}
const forwarding = project.portForwardingRowsByNodeId.get(nodeId);
if (forwarding !== undefined) {
result.portForwarding = forwarding.map((row) => [row.protocolValue, row.publicPortValue, row.lanIPAddress, row.lanPortValue]);
}
const remoteLink = project.remoteLinkConfigurations.get(nodeId);
if (remoteLink !== undefined) result.remoteLink = remoteLink;
const firewall = project.firewallConfigurationsByNodeId.get(nodeId);
if (firewall !== undefined && !deepEqual(firewall, firewallConfiguration())) {
result.firewall = {
isActive: firewall.isActive,
defaultPolicy: firewall.defaultPolicy === 'accept' ? 1 : 0,
dropICMP: firewall.dropIcmp,
filterSYNSegmentsOnly: firewall.filterSynSegmentsOnly,
filterUDP: firewall.filterUdp,
rules: firewall.rules.map((rule) => [
rule.sourceIPAddress,
rule.sourceSubnetMask,
rule.destinationIPAddress,
rule.destinationSubnetMask,
rule.port,
FIREWALL_PROTOCOL[rule.protocol],
rule.action === 'accept' ? 1 : 0,
]),
};
}
return result;
}
/** 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),
system: systemOf(project, node.id),
})),
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([]);
});
}
});
/** The core started on an imported project (the editor will derive this snapshot from its state). */
function startedEngine(project: ImportedProject): NetworkRuntimeEngine {
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,
manualRoutesByNodeId: project.manualRoutesByNodeId,
ripEnabledByNodeId: project.ripEnabledByNodeId,
firewallConfigurationsByNodeId: project.firewallConfigurationsByNodeId,
switchConfigurationsByNodeId: project.switchConfigurations,
}),
seed: 14,
initialTimeMilliseconds: 0,
});
return engine;
}
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 = startedEngine(project);
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');
});
it('pings across three routers on manual routes and the routers\' default gateways', () => {
// Router 1 (141.99.1/2) sends everything else to 2.2; router 2 has manual
// routes to 1.0 and 5.0; router 3 (141.99.4/5) sends everything else to
// 4.1. The way back needs router 3's default gateway — which Swift drops.
const project = importFile(join(corpusRoot, 'java-examples/internetwork_komplett.fls'));
const notebook = project.graph.nodes.find((node) => node.displayName === 'Notebook 1.10')!.id;
const routers = project.graph.nodes.filter((node) => node.kind === 'router').map((node) => node.id);
expect(routers.map((router) => project.deviceConfigurations.get(router)?.defaultGateway)).toEqual([
'141.99.2.2',
'141.99.4.2',
'141.99.4.1',
]);
expect(startedEngine(project).sendIcmpEcho(notebook, '141.99.5.10').type).toBe('delivered');
const withoutRouterGateways = { ...project, deviceConfigurations: new Map(project.deviceConfigurations) };
for (const router of routers) withoutRouterGateways.deviceConfigurations.delete(router);
expect(startedEngine(withoutRouterGateways).sendIcmpEcho(notebook, '141.99.5.10').type).not.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,
system: {},
source: { kind: 'object', className: javaClass, id: undefined, args: [], staticField: undefined,
factoryMethod: undefined, properties: new Map(), calls: [], presetElements: new Map(), presetEntries: 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("maps a router's firewall, RIP and routes, and says what it cannot map", () => {
const project = importProject(
topology({
nodes: [
flsNode('Vermittlungsrechner', {
interfaces: [nic('10.0.0.1')],
system: {
ripEnabled: true,
manualRoutes: [
['10.1.0.0', '255.255.0.0', '10.0.0.2', '10.0.0.1'],
['10.2.0.0', '255.255.0.0', null, '10.0.0.1'],
],
firewall: {
activated: true,
defaultPolicy: 1,
dropICMP: true,
filterSYNSegmentsOnly: false,
filterUdp: false,
rules: [
{ srcIP: '10.0.0.0', srcMask: '255.0.0.0', destIP: null, destMask: '', port: 80, protocol: -1, action: 1 },
{ srcIP: '', srcMask: '', destIP: '', destMask: '', port: -1, protocol: 99, action: 0 },
],
},
},
}),
],
}),
counter(),
);
const router = project.graph.nodes[0]!.id;
expect(project.ripEnabledByNodeId.get(router)).toBe(true);
expect(project.manualRoutesByNodeId.get(router)).toEqual([
{ destinationNetwork: '10.1.0.0', subnetMask: '255.255.0.0', gateway: '10.0.0.2', interfaceIPAddress: '10.0.0.1' },
]);
expect(project.firewallConfigurationsByNodeId.get(router)).toEqual({
isActive: true,
defaultPolicy: 'accept',
dropIcmp: true,
filterSynSegmentsOnly: false,
filterUdp: false,
rules: [
{
sourceIPAddress: '10.0.0.0',
sourceSubnetMask: '255.0.0.0',
destinationIPAddress: '',
destinationSubnetMask: '',
port: 80,
protocol: 'all',
action: 'accept',
},
{
sourceIPAddress: '',
sourceSubnetMask: '',
destinationIPAddress: '',
destinationSubnetMask: '',
port: -1,
protocol: 'tcp',
action: 'drop',
},
],
});
expect(project.warnings).toEqual([
"node 'Vermittlungsrechner': a manual route without four entries, left out",
"node 'Vermittlungsrechner': firewall protocol 99 unknown, used TCP",
]);
});
it("gives a switch without SSID one from its id, and joins a host to a wireless network only with an SSID", () => {
const project = importProject(
topology({
nodes: [
flsNode('Switch', { portCount: 24, system: { ssid: undefined, retentionTime: 2000 } }),
flsNode('Switch', { portCount: 24, system: { ssid: ' MyWiFi ', retentionTime: 300_000 } }),
flsNode('Notebook', { interfaces: [nic('10.0.0.1')], system: { ssid: 'MyWiFi', manualRoutes: [] } }),
flsNode('Rechner', { interfaces: [nic('10.0.0.2')], system: { ssid: ' ', manualRoutes: [] } }),
],
}),
counter(),
);
const [first, second, notebook, pc] = project.graph.nodes.map((node) => node.id);
expect(project.switchConfigurations.get(first!)).toEqual({ ssid: defaultSwitchConfiguration(first!).ssid, retentionTimeMilliseconds: 2000 });
expect(project.switchConfigurations.get(second!)).toEqual({ ssid: 'MyWiFi', retentionTimeMilliseconds: 300_000 });
expect(project.hostWirelessConfigurations.get(notebook!)).toEqual({ isEnabled: true, ssid: 'MyWiFi' });
expect(project.hostWirelessConfigurations.has(pc!)).toBe(false);
});
it('takes DHCP settings for hosts and home routers, trimmed, and only when they are not the defaults', () => {
const defaults = { isActive: false, lowerBound: '0.0.0.0', upperBound: '0.0.0.0', gateway: '0.0.0.0', dnsServer: '0.0.0.0', useOwnSettings: false, staticAssignedAddresses: [] };
const project = importProject(
topology({
nodes: [
flsNode('Rechner', {
interfaces: [nic('10.0.0.1')],
system: {
dhcpClient: false,
dhcpServer: {
...defaults,
isActive: true,
lowerBound: ' 10.0.0.100 ',
upperBound: '10.0.0.150',
staticAssignedAddresses: [{ mac: '0A:00:00:00:00:01', ip: '10.0.0.101' }],
},
},
}),
flsNode('Notebook', { interfaces: [nic('10.0.0.2')], system: { dhcpClient: true, dhcpServer: defaults } }),
flsNode('Vermittlungsrechner', {
interfaces: [nic('10.0.0.3')],
system: { dhcpClient: true, dhcpServer: { ...defaults, isActive: true } },
}),
],
}),
counter(),
);
const [pc, notebook, router] = project.graph.nodes.map((node) => node.id);
expect(project.dhcpServerConfigurations.get(pc!)).toMatchObject({
isActive: true,
lowerBoundIPAddress: '10.0.0.100',
upperBoundIPAddress: '10.0.0.150',
staticAssignments: [{ macAddress: '0A:00:00:00:00:01', ipAddress: '10.0.0.101' }],
});
expect([...project.dhcpServerConfigurations.keys()]).toEqual([pc]);
expect([...project.dhcpClientConfigurations]).toEqual([[notebook, { isEnabled: true }]]);
expect(project.warnings).toEqual([
"node 'Vermittlungsrechner': DHCP client on a router, ignored",
"node 'Vermittlungsrechner': DHCP server settings on a router, ignored",
]);
expect(router).toBeDefined();
});
it('takes port forwarding rows of a home router and pairs modems by port', () => {
const project = importProject(
topology({
nodes: [
flsNode('Gateway', {
interfaces: [nic('42.0.0.10'), nic('192.168.0.1')],
system: { staticNAT: [[' TCP', '80', '192.168.0.10', '8080 '], ['UDP', null, '192.168.0.11', '53']] },
}),
flsNode('Modem', { portCount: 1, system: { modem: { mode: 1, port: 4242, ipAddress: 'localhost' } } }),
flsNode('Modem', { portCount: 1, system: { modem: { mode: 7, port: 70_000, ipAddress: 'localhost' } } }),
],
}),
counter(),
);
const [gateway, server, broken] = project.graph.nodes.map((node) => node.id);
expect(project.portForwardingRowsByNodeId.get(gateway!)).toEqual([
{ protocolValue: 'TCP', publicPortValue: '80', lanIPAddress: '192.168.0.10', lanPortValue: '8080' },
]);
expect(project.remoteLinkConfigurations.get(server!)).toEqual({ pairIdentifier: 'java-modem-port-4242', latencyMilliseconds: 20, isEnabled: true });
expect(project.remoteLinkConfigurations.get(broken!)?.pairIdentifier).toBe('java-modem-port-12345');
expect(project.warnings).toEqual([
"node 'Gateway': a port forwarding row without four entries, left out",
"node 'Modem': modem port 70000 is invalid, used 12345",
"node 'Modem': modem mode 7 unknown, treated as client",
]);
});
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)',
]);
});
});