// 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 `\n\n${body}\n\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 codes inside a string', () => {
const values = decodeJavaBeans(
doc(`Filius429007199254740993true1.5ab`),
);
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(``),
);
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(``),
);
const [first, second] = listItems(list);
expect(first).toBe(second);
});
it('sets fields through Rectangle.class.getField(name).set(rect, value)', () => {
const [rectangle] = decodeJavaBeans(
doc(`x5y165`),
);
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(`xy`),
);
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(``),
);
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(`10.0.0.10A:00:00:00:00:01`),
);
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(`k1b`),
);
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(`128255`),
);
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(`10.0.0.1`),
);
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(``),
);
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(`ac1bC`),
);
expect(listItems(list)).toEqual(['a', 'b', 'C']);
});
it('does not guess what an index means once the list was changed', () => {
const [, holder] = decodeJavaBeans(
doc(``),
);
expect(isUnresolved(listItems(holder)[0])).toBe(true);
});
it('reports an idref to an id that was never defined', () => {
const [value] = decodeJavaBeans(doc(''));
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)));
});
}
});