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:
@@ -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