feat(fls): write the topology of a .fls, loaded by Java as the original
topologyWriter.ts writes konfiguration.xml from Java's view of a project: the version line, the node items (label position, device with name, host label flags, interfaces or ports), the cable items and the documentation items. What a device's constructor makes is changed through getters, as XMLEncoder does, and more is added; fewer than it makes set the list anew. Cable ends name the port objects the getters return (the writer now names a getter's result, <void id="Port0" property="port"/>), and each cable carries the nodes the editor draws it between. Checked by Java against Java: every corpus file, read and written again, loads in Java FILIUS (the oracle's loading method, --sha256 ties its result to the written bytes) to the same devices, names, positions, interfaces, cables and documentation as the original; so does a synthetic topology with what the corpus never sets. The oracle and the reader now also report a cable's drawing ends (JCablePanel ziel1/ziel2): in the corpus the nodes it joins, for all 183 cables. Mutation probes rewrite the files and let Java load each mutant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
test/written/
|
||||
@@ -9,4 +9,5 @@ export * from './javaString.js';
|
||||
export * from './javabeans.js';
|
||||
export * from './systemSoftware.js';
|
||||
export * from './topology.js';
|
||||
export * from './topologyWriter.js';
|
||||
export * from './zip.js';
|
||||
|
||||
@@ -21,7 +21,22 @@ export type BeanValue =
|
||||
| BeanPrimitive
|
||||
| BeanObject
|
||||
| BeanArray
|
||||
| { readonly kind: 'class'; readonly name: string };
|
||||
| { readonly kind: 'class'; readonly name: string }
|
||||
| BeanReference;
|
||||
|
||||
/**
|
||||
* An object only Java makes — a getter's result, such as the port a network
|
||||
* interface's constructor creates — named where the getter is called
|
||||
* (`<void id="Port0" property="port"/>`) and referenced after.
|
||||
*/
|
||||
export interface BeanReference {
|
||||
readonly kind: 'reference';
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
export function reference(name: string): BeanReference {
|
||||
return { kind: 'reference', name };
|
||||
}
|
||||
|
||||
export interface BeanObject {
|
||||
readonly kind: 'object';
|
||||
@@ -42,14 +57,14 @@ export interface BeanArray {
|
||||
export type BeanStatement =
|
||||
/** target.setP(value) */
|
||||
| { readonly set: string; readonly value: BeanValue }
|
||||
/** target.getP(), then the statements on its result */
|
||||
| { readonly get: string; readonly statements: readonly BeanStatement[] }
|
||||
/** target.getP(), then the statements on its result; `as` names the result */
|
||||
| { readonly get: string; readonly statements: readonly BeanStatement[]; readonly as?: BeanReference }
|
||||
/** target.m(args) */
|
||||
| { readonly call: string; readonly args: readonly BeanValue[] }
|
||||
/** target.set(index, value) on a list */
|
||||
| { readonly setIndex: number; readonly value: BeanValue }
|
||||
/** target.get(index), then the statements on its result */
|
||||
| { readonly getIndex: number; readonly statements: readonly BeanStatement[] };
|
||||
/** target.get(index), then the statements on its result; `as` names the result */
|
||||
| { readonly getIndex: number; readonly statements: readonly BeanStatement[]; readonly as?: BeanReference };
|
||||
|
||||
export const int = (value: number): BeanPrimitive => ({ kind: 'int', value });
|
||||
export const long = (value: number | bigint): BeanPrimitive => ({ kind: 'long', value });
|
||||
@@ -70,7 +85,7 @@ export function encodeJavaBeans(values: readonly BeanValue[]): string {
|
||||
}
|
||||
|
||||
class Writer {
|
||||
private readonly ids = new Map<BeanObject | BeanArray, string>();
|
||||
private readonly ids = new Map<BeanObject | BeanArray | BeanReference, string>();
|
||||
private readonly counters = new Map<string, number>();
|
||||
/** Objects written more than once need an id; found before writing. */
|
||||
constructor(private readonly shared: ReadonlySet<BeanObject | BeanArray>) {}
|
||||
@@ -109,6 +124,12 @@ class Writer {
|
||||
case 'object':
|
||||
this.object(value, depth, lines);
|
||||
break;
|
||||
case 'reference': {
|
||||
const id = this.ids.get(value);
|
||||
if (id === undefined) throw new Error(`reference '${value.name}' used before the getter that names it`);
|
||||
lines.push(`${indent}<object idref="${id}"/>`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -171,10 +192,7 @@ class Writer {
|
||||
if ('set' in statement) {
|
||||
nested(`<void property="${escapeAttribute(statement.set)}"`, () => this.value(statement.value, depth + 1, lines));
|
||||
} else if ('get' in statement) {
|
||||
if (statement.statements.length === 0) return;
|
||||
nested(`<void property="${escapeAttribute(statement.get)}"`, () => {
|
||||
for (const inner of statement.statements) this.statement(inner, depth + 1, lines);
|
||||
});
|
||||
this.getter(`property="${escapeAttribute(statement.get)}"`, statement.statements, statement.as, depth, lines);
|
||||
} else if ('call' in statement) {
|
||||
if (statement.args.length === 0) {
|
||||
lines.push(`${indent}<void method="${escapeAttribute(statement.call)}"/>`);
|
||||
@@ -186,13 +204,38 @@ class Writer {
|
||||
} else if ('setIndex' in statement) {
|
||||
nested(`<void index="${statement.setIndex}"`, () => this.value(statement.value, depth + 1, lines));
|
||||
} else {
|
||||
if (statement.statements.length === 0) return;
|
||||
nested(`<void index="${statement.getIndex}"`, () => {
|
||||
for (const inner of statement.statements) this.statement(inner, depth + 1, lines);
|
||||
});
|
||||
this.getter(`index="${statement.getIndex}"`, statement.statements, statement.as, depth, lines);
|
||||
}
|
||||
}
|
||||
|
||||
/** A getter's call: left out when nothing happens to its result and nothing names it. */
|
||||
private getter(
|
||||
attribute: string,
|
||||
statements: readonly BeanStatement[],
|
||||
name: BeanReference | undefined,
|
||||
depth: number,
|
||||
lines: string[],
|
||||
): void {
|
||||
const indent = ' '.repeat(depth);
|
||||
if (statements.length === 0 && name === undefined) return;
|
||||
let idAttribute = '';
|
||||
if (name !== undefined) {
|
||||
if (this.ids.has(name)) throw new Error(`reference '${name.name}' named twice`);
|
||||
const count = this.counters.get(name.name) ?? 0;
|
||||
this.counters.set(name.name, count + 1);
|
||||
const id = `${name.name}${count}`;
|
||||
this.ids.set(name, id);
|
||||
idAttribute = ` id="${id}"`;
|
||||
}
|
||||
if (statements.length === 0) {
|
||||
lines.push(`${indent}<void${idAttribute} ${attribute}/>`);
|
||||
return;
|
||||
}
|
||||
lines.push(`${indent}<void${idAttribute} ${attribute}>`);
|
||||
for (const inner of statements) this.statement(inner, depth + 1, lines);
|
||||
lines.push(`${indent}</void>`);
|
||||
}
|
||||
|
||||
private idFor(value: BeanObject | BeanArray, name: string): string | undefined {
|
||||
if (!this.shared.has(value)) return undefined;
|
||||
const count = this.counters.get(name) ?? 0;
|
||||
|
||||
@@ -60,6 +60,11 @@ export interface FlsPortReference {
|
||||
export interface FlsLink {
|
||||
ends: [FlsPortReference, FlsPortReference];
|
||||
wireless: boolean;
|
||||
/**
|
||||
* The nodes the editor draws the cable between (JCablePanel ziel1, ziel2),
|
||||
* by index; null where the file names none.
|
||||
*/
|
||||
panel: [number | null, number | null];
|
||||
}
|
||||
|
||||
export type FlsColor = [red: number, green: number, blue: number, alpha: number];
|
||||
@@ -146,7 +151,10 @@ export function readTopology(values: readonly JavaValue[]): FlsTopology {
|
||||
const nodes: FlsNode[] = [];
|
||||
for (const [index, item] of nodeItems.entries()) {
|
||||
const node = context.node(item, index, nodes.length);
|
||||
if (node !== undefined) nodes.push(node);
|
||||
if (node !== undefined) {
|
||||
if (isJavaObject(item)) context.nodeItems.set(item, nodes.length);
|
||||
nodes.push(node);
|
||||
}
|
||||
}
|
||||
const links: FlsLink[] = [];
|
||||
for (const [index, item] of cableItems.entries()) {
|
||||
@@ -168,6 +176,8 @@ function listOf(value: JavaValue | undefined, className: string): JavaValue[] {
|
||||
class Reader {
|
||||
/** Every port object a node owns, to resolve cable ends by identity as Java does. */
|
||||
private readonly portOwners = new Map<JavaObject, FlsPortReference>();
|
||||
/** Every node item read, to resolve a cable panel's ends. */
|
||||
readonly nodeItems = new Map<JavaObject, number>();
|
||||
|
||||
constructor(private readonly warnings: string[]) {}
|
||||
|
||||
@@ -310,9 +320,15 @@ class Reader {
|
||||
this.warnings.push(`cable ${itemIndex + 1}: an end is not a port of a device, left out`);
|
||||
return undefined;
|
||||
}
|
||||
const panel = (item as JavaObject).properties.get('kabelpanel');
|
||||
const panelEnd = (property: string) => {
|
||||
const target = isJavaObject(panel) ? panel.properties.get(property) : undefined;
|
||||
return isJavaObject(target) ? (this.nodeItems.get(target) ?? null) : null;
|
||||
};
|
||||
return {
|
||||
ends: [first, second],
|
||||
wireless: this.booleanProperty(cable, 'wireless', `cable ${itemIndex + 1}`),
|
||||
panel: [panelEnd('ziel1'), panelEnd('ziel2')],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
// Writes `projekt/konfiguration.xml` from Java's view of a project — the
|
||||
// counterpart of topology.ts. The four values Java's netzwerkLaden reads: the
|
||||
// version line, the list of GUIKnotenItem, of GUIKabelItem and of
|
||||
// GUIDocuItem. What a node's constructor makes (its interfaces, a switch's
|
||||
// ports) is reached through getters and changed there, as XMLEncoder does;
|
||||
// what the constructor does not make is added. Cable ends name the port
|
||||
// objects of the nodes, so the same cable joins the same ports when Java
|
||||
// loads the file.
|
||||
//
|
||||
// Checked by Java: every corpus file, read and written again, loads in Java
|
||||
// FILIUS to the same topology (topologyWriter.test.ts, tools/java-oracle).
|
||||
|
||||
import {
|
||||
encodeJavaBeans,
|
||||
int,
|
||||
object,
|
||||
reference,
|
||||
type BeanObject,
|
||||
type BeanReference,
|
||||
type BeanStatement,
|
||||
type BeanValue,
|
||||
} from './javaBeansWriter.js';
|
||||
import type { FlsDocumentationItem, FlsInterface, FlsLink, FlsNode, FlsNodeClass } from './topology.js';
|
||||
|
||||
/** The version line of the files this writer makes: the format of FILIUS 2.14. */
|
||||
export const WRITTEN_VERSION_LINE = 'Filius version: 2.14.0 (mdg-filius)';
|
||||
|
||||
export type NodeToWrite = Pick<
|
||||
FlsNode,
|
||||
'javaClass' | 'name' | 'x' | 'y' | 'useIpAsName' | 'useMacAsName' | 'interfaces' | 'portCount'
|
||||
>;
|
||||
|
||||
export interface TopologyToWrite {
|
||||
readonly nodes: readonly NodeToWrite[];
|
||||
readonly links: readonly FlsLink[];
|
||||
readonly documentation: readonly FlsDocumentationItem[];
|
||||
}
|
||||
|
||||
/** What each device class's constructor makes, and whether it is a host. */
|
||||
const CONSTRUCTED: Record<FlsNodeClass, { count: number; local: boolean; host: boolean }> = {
|
||||
Rechner: { count: 1, local: false, host: true },
|
||||
Notebook: { count: 1, local: false, host: true },
|
||||
Vermittlungsrechner: { count: 1, local: false, host: false },
|
||||
Gateway: { count: 2, local: false, host: false },
|
||||
Switch: { count: 24, local: true, host: false },
|
||||
Modem: { count: 1, local: true, host: false },
|
||||
};
|
||||
|
||||
export function writeTopologyXml(topology: TopologyToWrite): string {
|
||||
return encodeJavaBeans(topologyBeans(topology));
|
||||
}
|
||||
|
||||
export function topologyBeans(topology: TopologyToWrite): BeanValue[] {
|
||||
const ports: BeanValue[][] = [];
|
||||
const items = topology.nodes.map((node, index) => {
|
||||
const nodePorts: BeanValue[] = [];
|
||||
ports[index] = nodePorts;
|
||||
return nodeItem(node, nodePorts);
|
||||
});
|
||||
const cables = topology.links.map((link) => cableItem(link, items, ports));
|
||||
return [
|
||||
WRITTEN_VERSION_LINE,
|
||||
listOf(items),
|
||||
listOf(cables),
|
||||
listOf(topology.documentation.map(documentationItem)),
|
||||
];
|
||||
}
|
||||
|
||||
function listOf(values: readonly BeanValue[]): BeanObject {
|
||||
return object(
|
||||
'java.util.LinkedList',
|
||||
values.map((value) => ({ call: 'add', args: [value] })),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A GUIKnotenItem: its label (the position) and its device; `ports`
|
||||
* receives the device's port objects. The label's size is Java's to work
|
||||
* out when it loads the file; the written 0 × 0 does not move it (measured
|
||||
* on the corpus).
|
||||
*/
|
||||
function nodeItem(node: NodeToWrite, ports: BeanValue[]): BeanObject {
|
||||
const label = object('filius.gui.netzwerksicht.JSidebarButton', [
|
||||
{ set: 'bounds', value: object('java.awt.Rectangle', [], [int(node.x), int(node.y), int(0), int(0)]) },
|
||||
]);
|
||||
return object('filius.gui.netzwerksicht.GUIKnotenItem', [
|
||||
{ set: 'imageLabel', value: label },
|
||||
{ set: 'knoten', value: device(node, ports) },
|
||||
]);
|
||||
}
|
||||
|
||||
function device(node: NodeToWrite, ports: BeanValue[]): BeanObject {
|
||||
const constructed = CONSTRUCTED[node.javaClass];
|
||||
const statements: BeanStatement[] = [{ set: 'name', value: node.name }];
|
||||
if (constructed.host) {
|
||||
statements.push({ set: 'useIPAsName', value: node.useIpAsName }, { set: 'useMACAsName', value: node.useMacAsName });
|
||||
}
|
||||
if (constructed.local) {
|
||||
statements.push(
|
||||
constructedList('anschluesse', node.portCount, constructed.count, ports, () => ({ statements: [] }), () => {
|
||||
const port = object('filius.hardware.Port');
|
||||
return { value: port, port };
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
statements.push(
|
||||
constructedList(
|
||||
'netzwerkInterfaces',
|
||||
node.interfaces.length,
|
||||
constructed.count,
|
||||
ports,
|
||||
(index) => {
|
||||
const port = reference('Port');
|
||||
return { statements: [...interfaceStatements(node.interfaces[index]!), { get: 'port', statements: [], as: port }], port };
|
||||
},
|
||||
(index) => {
|
||||
const port = reference('Port');
|
||||
const nic = object('filius.hardware.NetzwerkInterface', [
|
||||
...interfaceStatements(node.interfaces[index]!),
|
||||
{ get: 'port', statements: [], as: port },
|
||||
]);
|
||||
return { value: nic, port };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
return object(`filius.hardware.knoten.${node.javaClass}`, statements);
|
||||
}
|
||||
|
||||
/**
|
||||
* A list the constructor filled with `constructed` elements: those are
|
||||
* changed through `get(index)`, more are added. With fewer, the list is set
|
||||
* anew. `ports` receives each element's port, in order.
|
||||
*/
|
||||
function constructedList(
|
||||
property: string,
|
||||
count: number,
|
||||
constructed: number,
|
||||
ports: BeanValue[],
|
||||
existing: (index: number) => { statements: BeanStatement[]; port?: BeanReference },
|
||||
added: (index: number) => { value: BeanValue; port: BeanValue },
|
||||
): BeanStatement {
|
||||
if (count < constructed) {
|
||||
const values = Array.from({ length: count }, (_, index) => {
|
||||
const { value, port } = added(index);
|
||||
ports.push(port);
|
||||
return value;
|
||||
});
|
||||
return { set: property, value: listOf(values) };
|
||||
}
|
||||
const statements: BeanStatement[] = [];
|
||||
for (let index = 0; index < count; index++) {
|
||||
if (index < constructed) {
|
||||
// The element's own port, or — a switch's — the element itself.
|
||||
const { statements: inner, port } = existing(index);
|
||||
if (port === undefined) {
|
||||
const element = reference('Port');
|
||||
ports.push(element);
|
||||
statements.push({ getIndex: index, statements: inner, as: element });
|
||||
} else {
|
||||
ports.push(port);
|
||||
statements.push({ getIndex: index, statements: inner });
|
||||
}
|
||||
} else {
|
||||
const { value, port } = added(index);
|
||||
ports.push(port);
|
||||
statements.push({ call: 'add', args: [value] });
|
||||
}
|
||||
}
|
||||
return { get: property, statements };
|
||||
}
|
||||
|
||||
/**
|
||||
* A network interface's setters. An address Java's setter rejected (null)
|
||||
* is not written: setIp(null) throws, and the constructor's address stays.
|
||||
* A MAC the file lacked stays unwritten, so Java rolls one as it did.
|
||||
*/
|
||||
function interfaceStatements(nic: FlsInterface): BeanStatement[] {
|
||||
const statements: BeanStatement[] = [];
|
||||
if (nic.ip !== null) statements.push({ set: 'ip', value: nic.ip });
|
||||
if (nic.mask !== null) statements.push({ set: 'subnetzMaske', value: nic.mask });
|
||||
if (nic.mac !== undefined) statements.push({ set: 'mac', value: nic.mac });
|
||||
statements.push({ set: 'gateway', value: nic.gateway }, { set: 'dns', value: nic.dns });
|
||||
return statements;
|
||||
}
|
||||
|
||||
function cableItem(link: FlsLink, items: readonly BeanObject[], ports: readonly BeanValue[][]): BeanObject {
|
||||
const [first, second] = link.ends;
|
||||
const end = (reference: { node: number; port: number }) => ports[reference.node]![reference.port]!;
|
||||
const cable = object('filius.hardware.Kabel', [
|
||||
{ set: 'anschluesse', value: { kind: 'array', componentClass: 'filius.hardware.Port', items: [end(first), end(second)] } },
|
||||
...(link.wireless ? [{ set: 'wireless', value: true }] : []),
|
||||
]);
|
||||
return object('filius.gui.netzwerksicht.GUIKabelItem', [
|
||||
{ set: 'dasKabel', value: cable },
|
||||
{
|
||||
get: 'kabelpanel',
|
||||
statements: [
|
||||
{ set: 'ziel1', value: items[first.node]! },
|
||||
{ set: 'ziel2', value: items[second.node]! },
|
||||
],
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function documentationItem(item: FlsDocumentationItem): BeanObject {
|
||||
const statements: BeanStatement[] = [];
|
||||
if (item.color !== null) statements.push({ set: 'color', value: object('java.awt.Color', [], item.color.map(int)) });
|
||||
if (item.font !== null) {
|
||||
statements.push({ set: 'font', value: object('java.awt.Font', [], [item.font.name, int(item.font.style), int(item.font.size)]) });
|
||||
}
|
||||
statements.push(
|
||||
{ set: 'height', value: int(item.height) },
|
||||
{ set: 'text', value: item.text },
|
||||
{ set: 'type', value: int(item.type) },
|
||||
{ set: 'width', value: int(item.width) },
|
||||
{ set: 'x', value: int(item.x) },
|
||||
{ set: 'y', value: int(item.y) },
|
||||
);
|
||||
return object('filius.gui.netzwerksicht.GUIDocuItem', statements);
|
||||
}
|
||||
|
||||
@@ -296,6 +296,47 @@
|
||||
],
|
||||
"errors": []
|
||||
},
|
||||
"references.xml": {
|
||||
"values": [
|
||||
{
|
||||
"id": 0,
|
||||
"class": "java.util.LinkedList",
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"class": "filius.hardware.NetzwerkInterface",
|
||||
"properties": {
|
||||
"dns": {
|
||||
"string": ""
|
||||
},
|
||||
"gateway": {
|
||||
"string": ""
|
||||
},
|
||||
"ip": {
|
||||
"string": "10.0.0.1"
|
||||
},
|
||||
"mac": {
|
||||
"string": "8E:A8:7E:96:38:62"
|
||||
},
|
||||
"port": {
|
||||
"id": 2,
|
||||
"class": "filius.hardware.Port",
|
||||
"properties": {}
|
||||
},
|
||||
"subnetzMaske": {
|
||||
"string": "255.255.255.0"
|
||||
},
|
||||
"wireless": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"ref": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"errors": []
|
||||
},
|
||||
"shared.xml": {
|
||||
"values": [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<java version="17" class="java.beans.XMLDecoder">
|
||||
<object class="java.util.LinkedList">
|
||||
<void method="add">
|
||||
<object class="filius.hardware.NetzwerkInterface">
|
||||
<void property="ip">
|
||||
<string>10.0.0.1</string>
|
||||
</void>
|
||||
<void id="Port0" property="port"/>
|
||||
</object>
|
||||
</void>
|
||||
<void method="add">
|
||||
<object idref="Port0"/>
|
||||
</void>
|
||||
</object>
|
||||
</java>
|
||||
@@ -1044,9 +1044,9 @@ describe('importProject rules', () => {
|
||||
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 },
|
||||
{ ends: [{ node: 0, port: 0 }, { node: 0, port: 1 }], wireless: false, panel: [null, null] },
|
||||
{ ends: [{ node: 1, port: 0 }, { node: 0, port: 2 }], wireless: false, panel: [null, null] },
|
||||
{ ends: [{ node: 0, port: 3 }, { node: 1, port: 0 }], wireless: false, panel: [null, null] },
|
||||
],
|
||||
}),
|
||||
counter(),
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
int,
|
||||
long,
|
||||
object,
|
||||
reference,
|
||||
type BeanObject,
|
||||
type BeanValue,
|
||||
} from '../src/javaBeansWriter.js';
|
||||
@@ -42,7 +43,23 @@ const file = object('filius.software.system.Datei', [
|
||||
{ set: 'dateiInhalt', value: 'a.de. A 3600 1.2.3.4\n' },
|
||||
]);
|
||||
|
||||
const port = reference('Port');
|
||||
|
||||
const CASES: Record<string, { values: BeanValue[]; expected: unknown[] }> = {
|
||||
references: {
|
||||
values: [
|
||||
object('java.util.LinkedList', [
|
||||
{ call: 'add', args: [object('filius.hardware.NetzwerkInterface', [{ set: 'ip', value: '10.0.0.1' }, { get: 'port', statements: [], as: port }])] },
|
||||
{ call: 'add', args: [port] },
|
||||
]),
|
||||
],
|
||||
expected: [
|
||||
items('java.util.LinkedList', [
|
||||
bean('filius.hardware.NetzwerkInterface', { ip: text('10.0.0.1'), port: { id: 2, class: 'filius.hardware.Port' } }),
|
||||
{ ref: 2 },
|
||||
]),
|
||||
],
|
||||
},
|
||||
strings: {
|
||||
values: [list('java.util.LinkedList', STRINGS)],
|
||||
expected: [items('java.util.LinkedList', STRINGS.map(text))],
|
||||
|
||||
@@ -190,6 +190,7 @@ function asJavaView(topology: FlsTopology, javaBounds: (number[] | null)[]) {
|
||||
cables: topology.links.map((link) => ({
|
||||
ends: link.ends.map((end) => [end.node, end.port]),
|
||||
wireless: link.wireless,
|
||||
panel: link.panel,
|
||||
})),
|
||||
documentation: topology.documentation.map((item) => ({
|
||||
type: item.type,
|
||||
@@ -305,7 +306,7 @@ describe('readTopology rules', () => {
|
||||
</object>`);
|
||||
|
||||
expect(nodes.map((node) => node.javaClass)).toEqual(['Switch']);
|
||||
expect(links).toEqual([{ ends: [{ node: 0, port: 0 }, { node: 0, port: 1 }], wireless: false }]);
|
||||
expect(links).toEqual([{ ends: [{ node: 0, port: 0 }, { node: 0, port: 1 }], wireless: false, panel: [null, null] }]);
|
||||
expect(warnings).toEqual([
|
||||
'node 1: unknown device class filius.hardware.knoten.Drucker, left out',
|
||||
'cable 2: an end is not a port of a device, left out',
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// Every corpus file, read and written again: Java FILIUS loads the written
|
||||
// file (tools/java-oracle, written.java.json) to the same topology as the
|
||||
// original (corpus.java.json) — devices, names, positions, interfaces,
|
||||
// cables and documentation. Java against Java: no mapping of this package
|
||||
// stands between the two.
|
||||
//
|
||||
// UPDATE_WRITTEN=1 writes the files to test/written/ (not checked in); the
|
||||
// Java oracle then loads them and records each file's SHA-256, which must
|
||||
// match what the writer 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 { describe, expect, it } from 'vitest';
|
||||
import { decodeJavaBeans, readFlsArchive, readTopology, writeFlsArchive } from '../src/index.js';
|
||||
import { writeTopologyXml, type TopologyToWrite } from '../src/topologyWriter.js';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const corpusRoot = join(here, '../../../corpus');
|
||||
const writtenRoot = join(here, 'written');
|
||||
|
||||
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] : [];
|
||||
});
|
||||
}
|
||||
|
||||
type JavaNode = { class: string; name: string | null; bounds: number[]; useIpAsName: boolean; useMacAsName: boolean; ports: 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 written = JSON.parse(readFileSync(join(here, 'written.java.json'), 'utf8')) as Record<string, JavaFile>;
|
||||
|
||||
/** The topology of Java's view: the label's position, not its size, which Java works out itself. */
|
||||
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,
|
||||
})),
|
||||
cables: view.cables,
|
||||
documentation: view.documentation,
|
||||
};
|
||||
}
|
||||
|
||||
describe('the topology written again, as Java loads it', () => {
|
||||
for (const file of flsFiles(corpusRoot)) {
|
||||
const name = relative(corpusRoot, file);
|
||||
it(name, () => {
|
||||
const topology = readTopology(decodeJavaBeans(readFlsArchive(new Uint8Array(readFileSync(file))).configurationXml));
|
||||
const archive = writeFlsArchive(new TextEncoder().encode(writeTopologyXml(topology)));
|
||||
if (process.env.UPDATE_WRITTEN === '1') {
|
||||
mkdirSync(dirname(join(writtenRoot, name)), { recursive: true });
|
||||
writeFileSync(join(writtenRoot, name), archive);
|
||||
}
|
||||
const java = written[name];
|
||||
expect(java?.sha256, 'written.java.json is not about this output: UPDATE_WRITTEN=1, then npm run oracle:java').toBe(
|
||||
createHash('sha256').update(archive).digest('hex'),
|
||||
);
|
||||
expect(topologyOf(java!)).toEqual(topologyOf(original[name]!));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/** What the corpus does not have: a name set to null, useMACAsName, fewer ports than the constructor makes, more interfaces. */
|
||||
const SYNTHETIC: TopologyToWrite = {
|
||||
nodes: [
|
||||
{
|
||||
javaClass: 'Rechner',
|
||||
name: null,
|
||||
x: 10,
|
||||
y: 20,
|
||||
useIpAsName: false,
|
||||
useMacAsName: true,
|
||||
interfaces: [{ ip: '10.0.0.1', mask: '255.0.0.0', mac: 'AA:BB:CC:DD:EE:01', gateway: null, dns: '10.0.0.53' }],
|
||||
portCount: 1,
|
||||
},
|
||||
{ javaClass: 'Switch', name: 'S', x: 100, y: 20, useIpAsName: false, useMacAsName: false, interfaces: [], portCount: 3 },
|
||||
{
|
||||
javaClass: 'Vermittlungsrechner',
|
||||
name: 'R',
|
||||
x: 200,
|
||||
y: 20,
|
||||
useIpAsName: false,
|
||||
useMacAsName: false,
|
||||
interfaces: ['1', '2', '3'].map((octet) => ({
|
||||
ip: `10.0.${octet}.1`,
|
||||
mask: '255.255.255.0',
|
||||
mac: `AA:BB:CC:DD:EE:1${octet}`,
|
||||
gateway: '',
|
||||
dns: '',
|
||||
})),
|
||||
portCount: 3,
|
||||
},
|
||||
{ javaClass: 'Modem', name: 'M', x: 300, y: 20, useIpAsName: false, useMacAsName: false, interfaces: [], portCount: 1 },
|
||||
],
|
||||
links: [
|
||||
{ ends: [{ node: 0, port: 0 }, { node: 1, port: 2 }], wireless: false, panel: [0, 1] },
|
||||
{ ends: [{ node: 2, port: 2 }, { node: 1, port: 0 }], wireless: true, panel: [2, 1] },
|
||||
],
|
||||
documentation: [{ type: 1, text: null, x: 5, y: 6, width: 70, height: 80, color: null, font: null }],
|
||||
};
|
||||
|
||||
describe('a topology beyond the corpus, as Java loads it', () => {
|
||||
it('keeps what the corpus never sets', () => {
|
||||
const name = 'synthetic/synthetic.fls';
|
||||
const archive = writeFlsArchive(new TextEncoder().encode(writeTopologyXml(SYNTHETIC)));
|
||||
if (process.env.UPDATE_WRITTEN === '1') {
|
||||
mkdirSync(join(writtenRoot, 'synthetic'), { recursive: true });
|
||||
writeFileSync(join(writtenRoot, name), archive);
|
||||
}
|
||||
const java = written[name];
|
||||
expect(java?.sha256, 'written.java.json is not about this output: UPDATE_WRITTEN=1, then npm run oracle:java').toBe(
|
||||
createHash('sha256').update(archive).digest('hex'),
|
||||
);
|
||||
const plainInterface = (octet: string) => ({ ip: `10.0.${octet}.1`, mask: '255.255.255.0', mac: `AA:BB:CC:DD:EE:1${octet}`, gateway: '', dns: '' });
|
||||
expect(topologyOf(java!)).toEqual({
|
||||
success: true,
|
||||
nodes: [
|
||||
{
|
||||
class: 'Rechner',
|
||||
name: null,
|
||||
position: [10, 20],
|
||||
useIpAsName: false,
|
||||
useMacAsName: true,
|
||||
ports: [{ ip: '10.0.0.1', mask: '255.0.0.0', mac: 'AA:BB:CC:DD:EE:01', gateway: null, dns: '10.0.0.53' }],
|
||||
},
|
||||
{ class: 'Switch', name: 'S', position: [100, 20], useIpAsName: undefined, useMacAsName: undefined, ports: [{}, {}, {}] },
|
||||
{
|
||||
class: 'Vermittlungsrechner',
|
||||
name: 'R',
|
||||
position: [200, 20],
|
||||
useIpAsName: undefined,
|
||||
useMacAsName: undefined,
|
||||
ports: ['1', '2', '3'].map(plainInterface),
|
||||
},
|
||||
{ class: 'Modem', name: 'M', position: [300, 20], useIpAsName: undefined, useMacAsName: undefined, ports: [{}] },
|
||||
],
|
||||
cables: [
|
||||
{ ends: [[0, 0], [1, 2]], wireless: false, panel: [0, 1] },
|
||||
{ ends: [[2, 2], [1, 0]], wireless: true, panel: [2, 1] },
|
||||
],
|
||||
documentation: [{ type: 1, text: null, bounds: [5, 6, 70, 80], color: null, font: null }],
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user