Files
mdg-filius/packages/fls/test/javabeans.test.ts
T
julian.kiedaischandClaude Opus 5.5 a02bb62413 feat(fls): topology of a .fls file as Java loads it
readTopology maps the decoded JavaBeans graph to devices, cables and
documentation items the way SzenarioVerwaltung.netzwerkLaden does: the
kind from the Java class (the palette label is not file data), devices
without bounds at 0,0, interface values through Java's setters. All 23
corpus files match Java field by field.

Found on the way:
- listItems mixed a constructor's elements with added ones; a router's
  second interface was lost. Lists now replay add/set in order after the
  elements the constructor made.
- The first 0 of a file may be SwingConstants.CENTER; measured like the
  getter defaults.
- IP.ipCheck (javaIp.ts) measured on 46 cases: empty tokens skipped,
  signs, leading zeros and BMP digits of any script accepted.
- The Java oracle took lower-case MACs in a file for random ones (8
  interfaces); it now compares case-insensitively and reports the host
  name flags.

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

274 lines
12 KiB
TypeScript

// The XMLEncoder reader: one test per XMLDecoder rule, then the corpus —
// every node's class and position must be what Java FILIUS reads
// (corpus.java.json), including the files where the Swift reader loses nodes.
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 {
decodeJavaBeans,
isJavaObject,
isUnresolved,
listItems,
mapEntries,
readFlsArchive,
type JavaObject,
type JavaValue,
} from '../src/index.js';
function doc(body: string): string {
return `<?xml version="1.0" encoding="UTF-8"?>\n<java version="17" class="java.beans.XMLDecoder">\n${body}\n</java>\n`;
}
function object(value: JavaValue | undefined): JavaObject {
if (!isJavaObject(value)) throw new Error(`not an object: ${JSON.stringify(value)}`);
return value;
}
describe('XMLDecoder rules', () => {
it('reads primitives and strings, including <char> codes inside a string', () => {
const values = decodeJavaBeans(
doc(`<string>Filius</string><int>42</int><long>9007199254740993</long><boolean>true</boolean>
<double>1.5</double><null/><string>a<char code="#9"/>b</string><string></string>`),
);
expect(values).toEqual(['Filius', 42, 9007199254740993n, true, 1.5, null, 'a\tb', '']);
});
it('creates an object with constructor arguments and properties', () => {
const [icon] = decodeJavaBeans(
doc(`<object class="javax.swing.ImageIcon"><string>gfx/pc.png</string>
<void property="description"><string>PC</string></void></object>`),
);
expect(object(icon).className).toBe('javax.swing.ImageIcon');
expect(object(icon).args).toEqual(['gfx/pc.png']);
expect(object(icon).properties.get('description')).toBe('PC');
});
it('shares a value by id and idref', () => {
const [list] = decodeJavaBeans(
doc(`<object class="java.util.LinkedList">
<void method="add"><object class="filius.hardware.Port" id="Port0"/></void>
<void method="add"><object idref="Port0"/></void>
</object>`),
);
const [first, second] = listItems(list);
expect(first).toBe(second);
});
it('sets fields through Rectangle.class.getField(name).set(rect, value)', () => {
const [rectangle] = decodeJavaBeans(
doc(`<object class="java.awt.Rectangle" id="Rectangle0">
<void class="java.awt.Rectangle" method="getField"><string>x</string>
<void method="set"><object idref="Rectangle0"/><int>5</int></void></void>
<void class="java.awt.Rectangle" method="getField"><string>y</string>
<void method="set"><object idref="Rectangle0"/><int>165</int></void></void>
</object>`),
);
expect(object(rectangle).properties.get('x')).toBe(5);
expect(object(rectangle).properties.get('y')).toBe(165);
});
it('answers a getter nobody set with the measured Java default, and shares it by id', () => {
const [button, rectangle] = decodeJavaBeans(
doc(`<object class="filius.gui.netzwerksicht.JSidebarButton">
<void id="Integer0" property="iconTextGap"/>
<void id="Boolean0" method="isEnabled"/>
</object>
<object class="java.awt.Rectangle" id="Rectangle0">
<void class="java.awt.Rectangle" method="getField"><string>x</string>
<void method="set"><object idref="Rectangle0"/><object idref="Integer0"/></void></void>
<void class="java.awt.Rectangle" method="getField"><string>y</string>
<void method="set"><object idref="Rectangle0"/><object idref="Boolean0"/></void></void>
</object>`),
);
expect(isJavaObject(button)).toBe(true);
expect(object(rectangle).properties.get('x')).toBe(4);
expect(object(rectangle).properties.get('y')).toBe(true);
});
it('says "unresolved" for a shared getter value it has no measurement for', () => {
const [, holder] = decodeJavaBeans(
doc(`<object class="com.example.Unknown"><void id="Integer0" property="size"/></object>
<object class="java.util.ArrayList"><void method="add"><object idref="Integer0"/></void></object>`),
);
const [item] = listItems(holder);
expect(isUnresolved(item)).toBe(true);
});
it('applies statements nested in a getter to the object the getter returns', () => {
const [node] = decodeJavaBeans(
doc(`<object class="filius.hardware.knoten.Rechner">
<void id="LinkedList0" property="netzwerkInterfaces">
<void id="NetzwerkInterface0" index="0">
<void property="ip"><string>10.0.0.1</string></void>
</void>
</void>
<void property="netzwerkInterfaces">
<void index="0"><void property="mac"><string>0A:00:00:00:00:01</string></void></void>
</void>
</object>`),
);
const interfaces = object(object(node).properties.get('netzwerkInterfaces'));
expect(interfaces.className).toBe('LinkedList');
expect(interfaces.fromGetter).toBe(true);
const nic = object(listItems(interfaces)[0]);
expect(nic.properties.get('ip')).toBe('10.0.0.1');
expect(nic.properties.get('mac')).toBe('0A:00:00:00:00:01');
});
it('reads static fields, static factories, maps and arrays', () => {
const [constant, manager, map, array] = decodeJavaBeans(
doc(`<object class="javax.swing.SwingConstants" field="LEFT"/>
<object class="javax.swing.ToolTipManager" method="sharedInstance"/>
<object class="java.util.HashMap"><void method="put"><string>k</string><int>1</int></void></object>
<array class="filius.hardware.Port" length="2"><void index="1"><string>b</string></void></array>`),
);
expect(object(constant)).toMatchObject({ className: 'javax.swing.SwingConstants', staticField: 'LEFT' });
expect(object(manager).factoryMethod).toBe('sharedInstance');
expect(mapEntries(map)).toEqual([['k', 1]]);
expect(listItems(array)).toEqual([null, 'b']);
});
it('answers a measured static constant with its value, which later references share', () => {
const [color, holder] = decodeJavaBeans(
doc(`<object class="java.awt.Color" id="Color0">
<object class="javax.swing.SwingConstants" field="CENTER" id="Integer0"/>
<int>128</int><int>255</int>
</object>
<object class="java.util.ArrayList"><void method="add"><object idref="Integer0"/></void></object>`),
);
expect(object(color).args).toEqual([0, 128, 255]);
expect(listItems(holder)).toEqual([0]);
});
it('keeps the elements a constructor made ahead of the ones the file adds', () => {
// A Java router starts with one interface; the file changes it by index
// and adds two more — three interfaces, in this order.
const [node] = decodeJavaBeans(
doc(`<object class="filius.hardware.knoten.Vermittlungsrechner">
<void id="LinkedList0" property="netzwerkInterfaces">
<void id="NetzwerkInterface0" index="0"><void property="ip"><string>10.0.0.1</string></void></void>
<void method="add"><object class="filius.hardware.NetzwerkInterface" id="NetzwerkInterface1"/></void>
<void method="add"><object class="filius.hardware.NetzwerkInterface" id="NetzwerkInterface2"/></void>
</void>
</object>`),
);
const interfaces = listItems(object(node).properties.get('netzwerkInterfaces'));
expect(interfaces.map((nic) => object(nic).id)).toEqual(['NetzwerkInterface0', 'NetzwerkInterface1', 'NetzwerkInterface2']);
expect(object(interfaces[0]).properties.get('ip')).toBe('10.0.0.1');
});
it('fills the constructed elements the file never mentions, in index order', () => {
// A switch has 24 ports; the file names the three that carry a cable,
// in any order.
const [ports] = decodeJavaBeans(
doc(`<object class="filius.hardware.knoten.Switch"><void id="LinkedList0" property="anschluesse">
<void id="Port9" index="1"/><void id="Port10" index="0"/><void id="Port11" index="2"/>
</void></object>`),
);
const list = object(ports).properties.get('anschluesse');
const items = listItems(list, 24);
expect(items).toHaveLength(24);
expect(items.slice(0, 3).map((port) => object(port).id)).toEqual(['Port10', 'Port9', 'Port11']);
expect(object(items[3])).toMatchObject({ className: 'unknown', fromGetter: true });
expect(listItems(list)).toHaveLength(3);
});
it('replays add at an index and set by index in order', () => {
const [list] = decodeJavaBeans(
doc(`<object class="java.util.ArrayList">
<void method="add"><string>a</string></void>
<void method="add"><string>c</string></void>
<void method="add"><int>1</int><string>b</string></void>
<void index="2"><string>C</string></void>
</object>`),
);
expect(listItems(list)).toEqual(['a', 'b', 'C']);
});
it('does not guess what an index means once the list was changed', () => {
const [, holder] = decodeJavaBeans(
doc(`<object class="filius.hardware.knoten.Rechner">
<void id="LinkedList0" property="netzwerkInterfaces">
<void method="add"><object class="filius.hardware.NetzwerkInterface"/></void>
<void id="NetzwerkInterface5" index="3"/>
</void>
</object>
<object class="java.util.ArrayList"><void method="add"><object idref="NetzwerkInterface5"/></void></object>`),
);
expect(isUnresolved(listItems(holder)[0])).toBe(true);
});
it('reports an idref to an id that was never defined', () => {
const [value] = decodeJavaBeans(doc('<object idref="Missing0"/>'));
expect(isUnresolved(value)).toBe(true);
});
});
// ── Corpus ────────────────────────────────────────────────────────────────
const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '../../../corpus');
const javaView = JSON.parse(readFileSync(join(corpusRoot, 'corpus.java.json'), 'utf8')) as Record<
string,
{ nodes: { class: string; bounds: number[] | null }[] }
>;
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();
}
/** The GUIKnotenItem list: the first top-level list whose elements are node items. */
function nodeItems(values: JavaValue[]): JavaObject[] {
for (const value of values) {
const items = listItems(value);
if (items.length > 0 && isJavaObject(items[0]) && items[0].className.endsWith('GUIKnotenItem')) {
return items.map(object);
}
}
return [];
}
describe('corpus: node positions as Java reads them', () => {
for (const file of flsFiles(corpusRoot)) {
const name = relative(corpusRoot, file);
it(name, () => {
const values = decodeJavaBeans(readFlsArchive(new Uint8Array(readFileSync(file))).configurationXml);
const nodes = nodeItems(values);
const expected = javaView[name]!.nodes;
expect(nodes.map((item) => object(item.properties.get('knoten')).className.split('.').at(-1))).toEqual(
expected.map((node) => node.class),
);
// new Rectangle(x, y, width, height) in older files, fields set one by
// one in newer ones — the fields win, as they are set after construction.
// Only the position is compared: after loading, Swing sizes the label
// to its text, so Java's width and height are not the file's.
const positions = nodes.map((item) => {
const rectangle = object(object(item.properties.get('imageLabel')).properties.get('bounds'));
return ['x', 'y'].map((field, index) => rectangle.properties.get(field) ?? rectangle.args[index] ?? 0);
});
expect(positions).toEqual(expected.map((node) => node.bounds!.slice(0, 2)));
});
}
});