Files
mdg-filius/packages/fls/test/javabeans.test.ts
T
julian.kiedaischandClaude Opus 5.5 a02b78b4cb feat(fls): XMLEncoder reader following java.beans.XMLDecoder
A generic interpreter for the .fls configuration: objects, properties,
calls, indexes, static fields and factories, Field.set, and id/idref
anywhere — including getter results, answered from the measured Java
defaults or reported as unresolved. All 23 corpus files: class and
position of every node as Java reads them, also where Swift loses nodes.

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

199 lines
8.4 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="CENTER"/>
<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: 'CENTER' });
expect(object(manager).factoryMethod).toBe('sharedInstance');
expect(mapEntries(map)).toEqual([['k', 1]]);
expect(listItems(array)).toEqual([null, 'b']);
});
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)));
});
}
});