diff --git a/CHANGES.md b/CHANGES.md
index a58004e..622febd 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -30,6 +30,8 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
| --- | --- | --- |
| `src/zip.ts` | `Persistence/TopologyFLSArchive.swift` (Fehler, Grenzen, Pfadregeln, ZIP-Leser/-Schreiber, CRC-32) | übertragen; DEFLATE über `fflate`, mit Apples Abschneide-Verhalten nachgebildet; Pfadfaltung (NFC, Breite, volles Case-Folding) an 41 Paaren gegen das Swift-Orakel gemessen |
| `src/archive.ts` | ebenda, Archiv-Teil von Import und Export | übertragen, ohne die XML-Ebene |
+| `src/javabeans.ts`, `src/javaDefaults.json` | — | neu: XMLEncoder-Leser nach den Regeln von `java.beans.XMLDecoder` (Swift liest elementweise und verliert geteilte Werte); Getter-Standardwerte mit `tools/java-oracle` gemessen |
+| `test/javabeans.test.ts` | — | neu: je XMLDecoder-Regel ein Test; alle 23 Korpus-Dateien, Klasse und Position jedes Geräts wie Java |
| `test/zip.test.ts` | `TopologyProjectPersistenceTests.swift` (Archiv-Ablehnungstests, `makeMinimalZIP`, `makePayloadZIP`) | übertragen, ergänzt um DEFLATE-Grenzfälle |
| `test/corpus.test.ts` | — | neu: 23 Korpus-Dateien, gegengeprüft mit `fflate.unzipSync` |
| `test/pathFolding.test.ts`, `test/path-folding.*.json` | — | neu: welche Pfade dieselbe Datei meinen, Ergebnis aus dem Swift-Kern |
diff --git a/package-lock.json b/package-lock.json
index ee14279..430e4da 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1337,6 +1337,18 @@
"fsevents": "~2.3.2"
}
},
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@@ -1651,6 +1663,12 @@
"node": ">=8"
}
},
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "license": "MIT"
+ },
"packages/core": {
"name": "@mdg-filius/core",
"version": "0.0.0",
@@ -1661,7 +1679,8 @@
"version": "0.0.0",
"license": "GPL-3.0-only",
"dependencies": {
- "fflate": "^0.8.2"
+ "fflate": "^0.8.2",
+ "saxes": "^6.0.0"
}
}
}
diff --git a/packages/fls/package.json b/packages/fls/package.json
index c29989f..0df7db4 100644
--- a/packages/fls/package.json
+++ b/packages/fls/package.json
@@ -8,6 +8,7 @@
".": "./src/index.ts"
},
"dependencies": {
- "fflate": "^0.8.2"
+ "fflate": "^0.8.2",
+ "saxes": "^6.0.0"
}
}
diff --git a/packages/fls/src/index.ts b/packages/fls/src/index.ts
index 3c2466a..5adcc9b 100644
--- a/packages/fls/src/index.ts
+++ b/packages/fls/src/index.ts
@@ -1,3 +1,4 @@
// Public surface of the .fls codec.
export * from './archive.js';
+export * from './javabeans.js';
export * from './zip.js';
diff --git a/packages/fls/src/javabeans.ts b/packages/fls/src/javabeans.ts
new file mode 100644
index 0000000..4e604d9
--- /dev/null
+++ b/packages/fls/src/javabeans.ts
@@ -0,0 +1,448 @@
+// Reads the output of java.beans.XMLEncoder — the format of
+// `projekt/konfiguration.xml` — into a graph of generic values, following the
+// rules of java.beans.XMLDecoder. Nothing is instantiated and no Java class
+// is known here: an object is its class name, its constructor arguments, the
+// properties set on it and the methods called on it. The mapping to the
+// FILIUS model is a separate layer (topology.ts).
+//
+// New relative to filius-ipad, whose reader works element by element and
+// cannot follow values XMLEncoder shares by reference (plan §13, M2):
+// - `id`/`idref` resolve anywhere, including the results of getters;
+// - a getter on a property nobody set answers the value Java would, from
+// measured defaults (javaDefaults.json), or an explicit "unresolved".
+//
+// XMLDecoder's rules, as far as XMLEncoder uses them:
+// args… statements… new C(args), then the statements
+// the static field C.F
+// args the static factory C.m(args)
+// the value stored under id X
+// target.getP() (a value)
+// value target.setP(value)
+// / v target.get(i) / target.set(i, v)
+// args target.m(args)
+// args C.m(args) (static)
+// id="X" on or store the value under X
+// statements nested in a apply to that void's value
+
+import { SaxesParser } from 'saxes';
+import javaDefaults from './javaDefaults.json' with { type: 'json' };
+
+// ── Values ─────────────────────────────────────────────────────────────────
+
+export type JavaValue = null | string | number | bigint | boolean | JavaObject | JavaArray | JavaField | JavaClass | Unresolved;
+
+export interface JavaObject {
+ readonly kind: 'object';
+ /** Fully qualified where the file names it; the simple name for objects only known by id. */
+ readonly className: string;
+ readonly id: string | undefined;
+ /** Constructor arguments, or the factory method's arguments. */
+ readonly args: JavaValue[];
+ /** `C.F` for a static field constant. */
+ readonly staticField: string | undefined;
+ /** `C.m(args)` for an object made by a static factory. */
+ readonly factoryMethod: string | undefined;
+ /** Set through setters, `Field.set` or created lazily by a getter. */
+ readonly properties: Map;
+ /** Method calls in order (`add`, `put`, …), with their arguments. */
+ readonly calls: { method: string; args: JavaValue[] }[];
+ /** `set(i, v)` on the object. */
+ readonly indexed: Map;
+ /** True when the object was not in the file but returned by a getter. */
+ readonly fromGetter: boolean;
+}
+
+export interface JavaArray {
+ readonly kind: 'array';
+ readonly componentClass: string;
+ readonly id: string | undefined;
+ readonly items: JavaValue[];
+}
+
+/** A `java.lang.reflect.Field`, as `Class.getField(name)` returns it. */
+export interface JavaField {
+ readonly kind: 'field';
+ readonly className: string;
+ readonly name: string;
+}
+
+export interface JavaClass {
+ readonly kind: 'class';
+ readonly name: string;
+}
+
+/** A value Java would know and this reader cannot: never a guess, always said so. */
+export interface Unresolved {
+ readonly kind: 'unresolved';
+ readonly reason: string;
+}
+
+export class JavaBeansError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'JavaBeansError';
+ }
+}
+
+export function isJavaObject(value: JavaValue | undefined): value is JavaObject {
+ return typeof value === 'object' && value !== null && (value as { kind?: string }).kind === 'object';
+}
+
+export function isUnresolved(value: JavaValue | undefined): value is Unresolved {
+ return typeof value === 'object' && value !== null && (value as { kind?: string }).kind === 'unresolved';
+}
+
+/** `java.awt.Rectangle` → `Rectangle`. */
+export function simpleClassName(className: string): string {
+ return className.slice(className.lastIndexOf('.') + 1);
+}
+
+/** The elements of a list: `add` calls, then `set(i, v)` replacing by index. */
+export function listItems(value: JavaValue | undefined): JavaValue[] {
+ if (typeof value === 'object' && value !== null && (value as JavaArray).kind === 'array') {
+ return [...(value as JavaArray).items];
+ }
+ if (!isJavaObject(value)) return [];
+ const items = value.calls.filter((call) => call.method === 'add').map((call) => call.args.at(-1) ?? null);
+ for (const [index, item] of value.indexed) items[index] = item;
+ return items;
+}
+
+/** The entries of a map: `put` calls in order. */
+export function mapEntries(value: JavaValue | undefined): [JavaValue, JavaValue][] {
+ if (!isJavaObject(value)) return [];
+ return value.calls
+ .filter((call) => call.method === 'put')
+ .map((call) => [call.args[0] ?? null, call.args[1] ?? null] as [JavaValue, JavaValue]);
+}
+
+// ── XML tree ───────────────────────────────────────────────────────────────
+
+interface Element {
+ readonly name: string;
+ readonly attributes: Readonly>;
+ readonly children: Element[];
+ /** Text directly inside, including `` resolved — only read for text elements. */
+ text: string;
+ /** Text and `` children in document order, for ``. */
+ readonly parts: (string | Element)[];
+}
+
+function parseXml(text: string): Element {
+ const parser = new SaxesParser({ xmlns: false, position: true });
+ const root: Element = { name: '#document', attributes: {}, children: [], text: '', parts: [] };
+ const stack: Element[] = [root];
+ parser.on('opentag', (tag) => {
+ const element: Element = {
+ name: tag.name,
+ attributes: tag.attributes as Record,
+ children: [],
+ text: '',
+ parts: [],
+ };
+ const parent = stack.at(-1)!;
+ parent.children.push(element);
+ parent.parts.push(element);
+ stack.push(element);
+ });
+ parser.on('text', (value) => {
+ const current = stack.at(-1)!;
+ current.text += value;
+ current.parts.push(value);
+ });
+ parser.on('closetag', () => {
+ stack.pop();
+ });
+ parser.on('error', (error) => {
+ throw new JavaBeansError(`XML: ${error.message}`);
+ });
+ parser.write(text).close();
+ const java = root.children[0];
+ if (java === undefined || java.name !== 'java') throw new JavaBeansError('The root element is not .');
+ return java;
+}
+
+/** The bytes as text, in the encoding the XML declaration names (UTF-8 otherwise). */
+export function decodeXmlBytes(bytes: Uint8Array): string {
+ const head = new TextDecoder('latin1').decode(bytes.subarray(0, 200));
+ const declared = /<\?xml[^>]*encoding=["']([A-Za-z0-9._-]+)["']/.exec(head)?.[1] ?? 'utf-8';
+ return new TextDecoder(declared.toLowerCase(), { fatal: true }).decode(bytes);
+}
+
+// ── Decoder ────────────────────────────────────────────────────────────────
+
+const PRIMITIVE_ID_PREFIXES = new Set(['Integer', 'Boolean', 'Long', 'Short', 'Byte', 'Float', 'Double', 'Character']);
+const DEFAULTS = javaDefaults as Readonly>;
+
+/** `getFoo`/`isFoo` → `foo`, as java.beans.Introspector derives it. */
+function propertyOfGetter(method: string): string | undefined {
+ const match = /^(?:get|is)([A-Z].*)$/.exec(method);
+ if (match === null) return undefined;
+ const name = match[1]!;
+ return name.length > 1 && name[1] === name[1]!.toUpperCase() && name[1] !== name[1]!.toLowerCase()
+ ? name
+ : name[0]!.toLowerCase() + name.slice(1);
+}
+
+function newObject(fields: Partial & { className: string }): JavaObject {
+ return {
+ kind: 'object',
+ className: fields.className,
+ id: fields.id,
+ args: fields.args ?? [],
+ staticField: fields.staticField,
+ factoryMethod: fields.factoryMethod,
+ properties: new Map(),
+ calls: [],
+ indexed: new Map(),
+ fromGetter: fields.fromGetter ?? false,
+ };
+}
+
+class Decoder {
+ private readonly ids = new Map();
+
+ topLevel(java: Element): JavaValue[] {
+ return java.children.map((child) => this.expression(child));
+ }
+
+ private store(id: string | undefined, value: JavaValue): void {
+ if (id !== undefined) this.ids.set(id, value);
+ }
+
+ /** The value of an argument element. */
+ private expression(element: Element): JavaValue {
+ const attributes = element.attributes;
+ switch (element.name) {
+ case 'null':
+ return null;
+ case 'string': {
+ const value = element.parts
+ .map((part) => (typeof part === 'string' ? part : part.name === 'char' ? charValue(part) : ''))
+ .join('');
+ this.store(attributes.id, value);
+ return value;
+ }
+ case 'char': {
+ const value = charValue(element);
+ this.store(attributes.id, value);
+ return value;
+ }
+ case 'boolean':
+ return this.primitive(element, element.text.trim() === 'true');
+ case 'int':
+ case 'short':
+ case 'byte':
+ return this.primitive(element, Number.parseInt(element.text.trim(), 10));
+ case 'float':
+ case 'double':
+ return this.primitive(element, Number.parseFloat(element.text.trim()));
+ case 'long': {
+ const big = BigInt(element.text.trim());
+ return this.primitive(element, Number.isSafeInteger(Number(big)) ? Number(big) : big);
+ }
+ case 'class': {
+ const value: JavaClass = { kind: 'class', name: element.text.trim() };
+ this.store(attributes.id, value);
+ return value;
+ }
+ case 'array':
+ return this.array(element);
+ case 'object':
+ return this.object(element);
+ case 'void':
+ // A void in argument position is still a statement; XMLDecoder
+ // evaluates it for its side effects and its id.
+ this.statement(element, undefined);
+ return null;
+ default:
+ throw new JavaBeansError(`Unknown element <${element.name}>.`);
+ }
+ }
+
+ private primitive(element: Element, value: JavaValue): JavaValue {
+ this.store(element.attributes.id, value);
+ return value;
+ }
+
+ private array(element: Element): JavaArray {
+ const attributes = element.attributes;
+ const length = attributes.length === undefined ? undefined : Number.parseInt(attributes.length, 10);
+ const array: JavaArray = {
+ kind: 'array',
+ componentClass: attributes.class ?? 'java.lang.Object',
+ id: attributes.id,
+ items: length === undefined ? [] : Array.from({ length }, () => null),
+ };
+ this.store(attributes.id, array);
+ for (const child of element.children) {
+ if (child.name === 'void') {
+ this.statement(child, array);
+ } else {
+ array.items.push(this.expression(child));
+ }
+ }
+ return array;
+ }
+
+ private object(element: Element): JavaValue {
+ const attributes = element.attributes;
+ if (attributes.idref !== undefined) {
+ const value = this.ids.get(attributes.idref);
+ if (value === undefined && !this.ids.has(attributes.idref)) {
+ return { kind: 'unresolved', reason: `idref ${attributes.idref} is not defined` };
+ }
+ this.applyStatements(element.children, value ?? null);
+ return value ?? null;
+ }
+ const className = attributes.class;
+ if (className === undefined) throw new JavaBeansError(' without class or idref.');
+ const args = element.children.filter((child) => child.name !== 'void');
+ const object = newObject({
+ className,
+ id: attributes.id,
+ staticField: attributes.field,
+ factoryMethod: attributes.method,
+ });
+ this.store(attributes.id, object);
+ for (const arg of args) object.args.push(this.expression(arg));
+ this.applyStatements(
+ element.children.filter((child) => child.name === 'void'),
+ object,
+ );
+ return object;
+ }
+
+ private applyStatements(children: readonly Element[], target: JavaValue): void {
+ for (const child of children) {
+ if (child.name === 'void') this.statement(child, target);
+ }
+ }
+
+ /** Executes a `` on `target`; returns its value (a getter's result, say). */
+ private statement(element: Element, target: JavaValue | undefined): JavaValue {
+ const attributes = element.attributes;
+ const args: JavaValue[] = [];
+ const nested: Element[] = [];
+ for (const child of element.children) {
+ if (child.name === 'void') {
+ nested.push(child);
+ } else {
+ args.push(this.expression(child));
+ }
+ }
+
+ let value: JavaValue = null;
+ if (attributes.class !== undefined) {
+ value = this.staticCall(attributes.class, attributes.method ?? 'new', args, attributes.id);
+ } else if (attributes.property !== undefined) {
+ value =
+ args.length === 0
+ ? this.getProperty(target, attributes.property, attributes.id)
+ : this.setProperty(target, attributes.property, args[0]!);
+ } else if (attributes.index !== undefined) {
+ const index = Number.parseInt(attributes.index, 10);
+ value = args.length === 0 ? this.getIndex(target, index) : this.setIndex(target, index, args[0]!);
+ } else if (attributes.method !== undefined) {
+ value = this.call(target, attributes.method, args, attributes.id);
+ } else if (attributes.idref !== undefined) {
+ value = this.ids.get(attributes.idref) ?? null;
+ }
+
+ this.store(attributes.id, value);
+ this.applyStatements(nested, value);
+ return value;
+ }
+
+ private staticCall(className: string, method: string, args: JavaValue[], id: string | undefined): JavaValue {
+ if (method === 'getField' && typeof args[0] === 'string') {
+ const field: JavaField = { kind: 'field', className, name: args[0] };
+ return field;
+ }
+ return newObject({ className, id, args, factoryMethod: method });
+ }
+
+ private call(target: JavaValue | undefined, method: string, args: JavaValue[], id: string | undefined): JavaValue {
+ if (typeof target === 'object' && target !== null && (target as JavaField).kind === 'field') {
+ const field = target as JavaField;
+ if (method === 'set' && args.length === 2) return this.setProperty(args[0]!, field.name, args[1]!);
+ if (method === 'get' && args.length === 1) return this.getProperty(args[0]!, field.name, id);
+ }
+ if (args.length === 0) {
+ const property = propertyOfGetter(method);
+ if (property !== undefined) return this.getProperty(target, property, id);
+ }
+ if (isJavaObject(target)) {
+ target.calls.push({ method, args });
+ if (method === 'get' && args.length === 1 && typeof args[0] === 'number') return this.getIndex(target, args[0]);
+ }
+ return null;
+ }
+
+ private setProperty(target: JavaValue | undefined, property: string, value: JavaValue): JavaValue {
+ if (isJavaObject(target)) target.properties.set(property, value);
+ return null;
+ }
+
+ /**
+ * A getter: the value set before, else what Java returns for an untouched
+ * object (javaDefaults.json), else — for an object whose statements follow —
+ * a placeholder object that later getters return again.
+ */
+ private getProperty(target: JavaValue | undefined, property: string, id: string | undefined): JavaValue {
+ if (!isJavaObject(target)) {
+ return { kind: 'unresolved', reason: `getter ${property} on a value that is not an object` };
+ }
+ if (target.properties.has(property)) return target.properties.get(property)!;
+ const defaultKey = `${simpleClassName(target.className)}#${property}`;
+ if (Object.hasOwn(DEFAULTS, defaultKey)) return DEFAULTS[defaultKey]!;
+ const prefix = id?.replace(/\d+$/, '');
+ if (prefix !== undefined && PRIMITIVE_ID_PREFIXES.has(prefix)) {
+ return { kind: 'unresolved', reason: `no measured default for ${defaultKey}` };
+ }
+ const placeholder = newObject({ className: prefix ?? 'unknown', id, fromGetter: true });
+ target.properties.set(property, placeholder);
+ return placeholder;
+ }
+
+ private getIndex(target: JavaValue | undefined, index: number): JavaValue {
+ if (typeof target === 'object' && target !== null && (target as JavaArray).kind === 'array') {
+ return (target as JavaArray).items[index] ?? null;
+ }
+ if (!isJavaObject(target)) return { kind: 'unresolved', reason: `index ${index} on a value that is not an object` };
+ const existing = listItems(target)[index];
+ if (existing !== undefined) return existing;
+ // A list the file never filled, like a node's interface list created by
+ // its constructor: the element is a placeholder that later reads return.
+ const placeholder = newObject({ className: 'unknown', fromGetter: true });
+ target.indexed.set(index, placeholder);
+ return placeholder;
+ }
+
+ private setIndex(target: JavaValue | undefined, index: number, value: JavaValue): JavaValue {
+ if (typeof target === 'object' && target !== null && (target as JavaArray).kind === 'array') {
+ (target as JavaArray).items[index] = value;
+ } else if (isJavaObject(target)) {
+ target.indexed.set(index, value);
+ }
+ return null;
+ }
+}
+
+function charValue(element: Element): string {
+ const code = element.attributes.code;
+ if (code !== undefined) {
+ const numeric = code.startsWith('#') ? Number.parseInt(code.slice(1), 16) : Number.parseInt(code, 10);
+ return String.fromCharCode(numeric);
+ }
+ return element.text;
+}
+
+/**
+ * Decodes an XMLEncoder document into the objects `readObject()` would return,
+ * one per top-level element, in order.
+ */
+export function decodeJavaBeans(xml: string | Uint8Array): JavaValue[] {
+ const text = typeof xml === 'string' ? xml : decodeXmlBytes(xml);
+ return new Decoder().topLevel(parseXml(text));
+}
diff --git a/packages/fls/test/javabeans.test.ts b/packages/fls/test/javabeans.test.ts
new file mode 100644
index 0000000..ba58a59
--- /dev/null
+++ b/packages/fls/test/javabeans.test.ts
@@ -0,0 +1,198 @@
+// 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(`Filius 42 9007199254740993 true
+ 1.5 a b `),
+ );
+
+ 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(`gfx/pc.png
+ PC `),
+ );
+
+ 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(`
+ x
+ 5
+ y
+ 165
+ `),
+ );
+
+ 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(`
+
+ x
+
+ y
+
+ `),
+ );
+
+ 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.1
+
+
+
+ 0A: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(`
+
+ k 1
+ b `),
+ );
+
+ 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(' '));
+
+ 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)));
+ });
+ }
+});
diff --git a/packages/fls/test/tsconfig.json b/packages/fls/test/tsconfig.json
index e0afcb9..b302fb2 100644
--- a/packages/fls/test/tsconfig.json
+++ b/packages/fls/test/tsconfig.json
@@ -4,7 +4,12 @@
"noEmit": true,
"composite": false,
"declaration": false,
- "types": ["node"]
+ "types": [
+ "node"
+ ],
+ "resolveJsonModule": true
},
- "include": ["."]
+ "include": [
+ "."
+ ]
}
diff --git a/packages/fls/tsconfig.json b/packages/fls/tsconfig.json
index 8d72bcf..71c53c2 100644
--- a/packages/fls/tsconfig.json
+++ b/packages/fls/tsconfig.json
@@ -3,7 +3,11 @@
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
- "types": []
+ "types": [],
+ "resolveJsonModule": true
},
- "include": ["src"]
+ "include": [
+ "src",
+ "src/*.json"
+ ]
}