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(`Filius429007199254740993true + 1.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(`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(` + + k1 + 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" + ] }