feat(fls): a writer for the XMLDecoder format, checked by Java
javaBeansWriter.ts writes what java.beans.XMLDecoder reads — the counterpart of javabeans.ts and the base of the .fls writer. Its input says explicitly what Java should do: construct a class with typed arguments, call setters, getters (with statements on their result), methods and indexed access; an object reached twice is written once with an id and referenced after. Unlike XMLEncoder it writes what it is given, defaults included. What XML 1.0 cannot hold (controls, lone surrogates, U+FFFE/F) and a CR, which XML would read as a line break, are written as <char code>. Checked by Java itself: nine files of the writer (strings with every kind of odd unit, all primitive types including NaN, -0.0 and a long beyond 2^53, collections, maps, shared objects, Rectangle, Color, FILIUS beans with getter chains, arrays, indexed access, a static constant) are read by XMLDecoder in the Java oracle (BeansDump), which describes what it got and collects the errors the decoder would only log. Mutation probes regenerate the files and let Java read each mutant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
// Writes the format java.beans.XMLDecoder reads — the counterpart of
|
||||
// javabeans.ts. The input says what Java should do, explicitly typed: which
|
||||
// class to construct with which arguments, which setters, getters and methods
|
||||
// to call. An object reached twice (the same JS object) is written once with
|
||||
// an id and referenced after (idref), as XMLEncoder does.
|
||||
//
|
||||
// Unlike XMLEncoder this writer does not leave out what equals a
|
||||
// constructor's default: it writes what it is given. Checked by Java itself
|
||||
// (tools/java-oracle BeansDump reads what this writes; javaBeansWriter.test.ts).
|
||||
|
||||
export type BeanPrimitive =
|
||||
| { readonly kind: 'int' | 'short' | 'byte'; readonly value: number }
|
||||
| { readonly kind: 'long'; readonly value: number | bigint }
|
||||
| { readonly kind: 'float' | 'double'; readonly value: number }
|
||||
| { readonly kind: 'char'; readonly value: string };
|
||||
|
||||
export type BeanValue =
|
||||
| null
|
||||
| string
|
||||
| boolean
|
||||
| BeanPrimitive
|
||||
| BeanObject
|
||||
| BeanArray
|
||||
| { readonly kind: 'class'; readonly name: string };
|
||||
|
||||
export interface BeanObject {
|
||||
readonly kind: 'object';
|
||||
readonly className: string;
|
||||
/** Constructor arguments. */
|
||||
readonly args?: readonly BeanValue[];
|
||||
/** `C.F`: a static field instead of a new object. */
|
||||
readonly staticField?: string;
|
||||
readonly statements?: readonly BeanStatement[];
|
||||
}
|
||||
|
||||
export interface BeanArray {
|
||||
readonly kind: 'array';
|
||||
readonly componentClass: string;
|
||||
readonly items: readonly BeanValue[];
|
||||
}
|
||||
|
||||
export type BeanStatement =
|
||||
/** target.setP(value) */
|
||||
| { readonly set: string; readonly value: BeanValue }
|
||||
/** target.getP(), then the statements on its result */
|
||||
| { readonly get: string; readonly statements: readonly BeanStatement[] }
|
||||
/** target.m(args) */
|
||||
| { readonly call: string; readonly args: readonly BeanValue[] }
|
||||
/** target.set(index, value) on a list */
|
||||
| { readonly setIndex: number; readonly value: BeanValue }
|
||||
/** target.get(index), then the statements on its result */
|
||||
| { readonly getIndex: number; readonly statements: readonly BeanStatement[] };
|
||||
|
||||
export const int = (value: number): BeanPrimitive => ({ kind: 'int', value });
|
||||
export const long = (value: number | bigint): BeanPrimitive => ({ kind: 'long', value });
|
||||
export const double = (value: number): BeanPrimitive => ({ kind: 'double', value });
|
||||
export const float = (value: number): BeanPrimitive => ({ kind: 'float', value });
|
||||
|
||||
export function object(className: string, statements: readonly BeanStatement[] = [], args: readonly BeanValue[] = []): BeanObject {
|
||||
return { kind: 'object', className, args, statements };
|
||||
}
|
||||
|
||||
/** An XMLDecoder document: the values in order, as `new XMLDecoder(in).readObject()` returns them. */
|
||||
export function encodeJavaBeans(values: readonly BeanValue[]): string {
|
||||
const writer = new Writer(sharedObjects(values));
|
||||
const lines = ['<?xml version="1.0" encoding="UTF-8"?>', '<java version="17" class="java.beans.XMLDecoder">'];
|
||||
for (const value of values) writer.value(value, 1, lines);
|
||||
lines.push('</java>', '');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
class Writer {
|
||||
private readonly ids = new Map<BeanObject | BeanArray, string>();
|
||||
private readonly counters = new Map<string, number>();
|
||||
/** Objects written more than once need an id; found before writing. */
|
||||
constructor(private readonly shared: ReadonlySet<BeanObject | BeanArray>) {}
|
||||
|
||||
value(value: BeanValue, depth: number, lines: string[]): void {
|
||||
const indent = ' '.repeat(depth);
|
||||
if (value === null) {
|
||||
lines.push(`${indent}<null/>`);
|
||||
} else if (typeof value === 'string') {
|
||||
lines.push(`${indent}<string>${stringContent(value)}</string>`);
|
||||
} else if (typeof value === 'boolean') {
|
||||
lines.push(`${indent}<boolean>${value}</boolean>`);
|
||||
} else {
|
||||
switch (value.kind) {
|
||||
case 'int':
|
||||
case 'short':
|
||||
case 'byte':
|
||||
lines.push(`${indent}<${value.kind}>${integerText(value.value)}</${value.kind}>`);
|
||||
break;
|
||||
case 'long':
|
||||
lines.push(`${indent}<long>${typeof value.value === 'bigint' ? value.value : integerText(value.value)}</long>`);
|
||||
break;
|
||||
case 'float':
|
||||
case 'double':
|
||||
lines.push(`${indent}<${value.kind}>${floatingText(value.value)}</${value.kind}>`);
|
||||
break;
|
||||
case 'char':
|
||||
lines.push(`${indent}${charElement(value.value)}`);
|
||||
break;
|
||||
case 'class':
|
||||
lines.push(`${indent}<class>${escapeText(value.name)}</class>`);
|
||||
break;
|
||||
case 'array':
|
||||
this.array(value, depth, lines);
|
||||
break;
|
||||
case 'object':
|
||||
this.object(value, depth, lines);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private object(value: BeanObject, depth: number, lines: string[]): void {
|
||||
const indent = ' '.repeat(depth);
|
||||
const existing = this.ids.get(value);
|
||||
if (existing !== undefined) {
|
||||
lines.push(`${indent}<object idref="${existing}"/>`);
|
||||
return;
|
||||
}
|
||||
const id = this.idFor(value, simpleName(value.className));
|
||||
const idAttribute = id === undefined ? '' : ` id="${id}"`;
|
||||
if (value.staticField !== undefined) {
|
||||
lines.push(`${indent}<object class="${escapeAttribute(value.className)}"${idAttribute} field="${escapeAttribute(value.staticField)}"/>`);
|
||||
return;
|
||||
}
|
||||
const args = value.args ?? [];
|
||||
const statements = value.statements ?? [];
|
||||
if (args.length === 0 && statements.length === 0) {
|
||||
lines.push(`${indent}<object class="${escapeAttribute(value.className)}"${idAttribute}/>`);
|
||||
return;
|
||||
}
|
||||
lines.push(`${indent}<object class="${escapeAttribute(value.className)}"${idAttribute}>`);
|
||||
for (const arg of args) this.value(arg, depth + 1, lines);
|
||||
for (const statement of statements) this.statement(statement, depth + 1, lines);
|
||||
lines.push(`${indent}</object>`);
|
||||
}
|
||||
|
||||
private array(value: BeanArray, depth: number, lines: string[]): void {
|
||||
const indent = ' '.repeat(depth);
|
||||
const existing = this.ids.get(value);
|
||||
if (existing !== undefined) {
|
||||
lines.push(`${indent}<object idref="${existing}"/>`);
|
||||
return;
|
||||
}
|
||||
const id = this.idFor(value, `${simpleName(value.componentClass)}Array`);
|
||||
const idAttribute = id === undefined ? '' : ` id="${id}"`;
|
||||
const head = `${indent}<array class="${escapeAttribute(value.componentClass)}"${idAttribute} length="${value.items.length}"`;
|
||||
if (value.items.length === 0) {
|
||||
lines.push(`${head}/>`);
|
||||
return;
|
||||
}
|
||||
lines.push(`${head}>`);
|
||||
for (const [index, item] of value.items.entries()) {
|
||||
lines.push(`${indent} <void index="${index}">`);
|
||||
this.value(item, depth + 2, lines);
|
||||
lines.push(`${indent} </void>`);
|
||||
}
|
||||
lines.push(`${indent}</array>`);
|
||||
}
|
||||
|
||||
private statement(statement: BeanStatement, depth: number, lines: string[]): void {
|
||||
const indent = ' '.repeat(depth);
|
||||
const nested = (open: string, body: () => void) => {
|
||||
lines.push(`${indent}${open}>`);
|
||||
body();
|
||||
lines.push(`${indent}</void>`);
|
||||
};
|
||||
if ('set' in statement) {
|
||||
nested(`<void property="${escapeAttribute(statement.set)}"`, () => this.value(statement.value, depth + 1, lines));
|
||||
} else if ('get' in statement) {
|
||||
if (statement.statements.length === 0) return;
|
||||
nested(`<void property="${escapeAttribute(statement.get)}"`, () => {
|
||||
for (const inner of statement.statements) this.statement(inner, depth + 1, lines);
|
||||
});
|
||||
} else if ('call' in statement) {
|
||||
if (statement.args.length === 0) {
|
||||
lines.push(`${indent}<void method="${escapeAttribute(statement.call)}"/>`);
|
||||
return;
|
||||
}
|
||||
nested(`<void method="${escapeAttribute(statement.call)}"`, () => {
|
||||
for (const arg of statement.args) this.value(arg, depth + 1, lines);
|
||||
});
|
||||
} else if ('setIndex' in statement) {
|
||||
nested(`<void index="${statement.setIndex}"`, () => this.value(statement.value, depth + 1, lines));
|
||||
} else {
|
||||
if (statement.statements.length === 0) return;
|
||||
nested(`<void index="${statement.getIndex}"`, () => {
|
||||
for (const inner of statement.statements) this.statement(inner, depth + 1, lines);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private idFor(value: BeanObject | BeanArray, name: string): string | undefined {
|
||||
if (!this.shared.has(value)) return undefined;
|
||||
const count = this.counters.get(name) ?? 0;
|
||||
this.counters.set(name, count + 1);
|
||||
const id = `${name}${count}`;
|
||||
this.ids.set(value, id);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
/** The objects and arrays the values reach more than once. */
|
||||
function sharedObjects(roots: readonly BeanValue[]): Set<BeanObject | BeanArray> {
|
||||
const seen = new Set<BeanObject | BeanArray>();
|
||||
const shared = new Set<BeanObject | BeanArray>();
|
||||
const visitStatement = (statement: BeanStatement): void => {
|
||||
if ('set' in statement || 'setIndex' in statement) visit(statement.value);
|
||||
else if ('call' in statement) statement.args.forEach(visit);
|
||||
else statement.statements.forEach(visitStatement);
|
||||
};
|
||||
const visit = (value: BeanValue): void => {
|
||||
if (value === null || typeof value !== 'object' || (value.kind !== 'object' && value.kind !== 'array')) return;
|
||||
if (seen.has(value)) {
|
||||
shared.add(value);
|
||||
return;
|
||||
}
|
||||
seen.add(value);
|
||||
if (value.kind === 'array') {
|
||||
value.items.forEach(visit);
|
||||
} else {
|
||||
(value.args ?? []).forEach(visit);
|
||||
(value.statements ?? []).forEach(visitStatement);
|
||||
}
|
||||
};
|
||||
roots.forEach(visit);
|
||||
return shared;
|
||||
}
|
||||
|
||||
function simpleName(className: string): string {
|
||||
return className.slice(className.lastIndexOf('.') + 1).replace(/\$/g, '_');
|
||||
}
|
||||
|
||||
function integerText(value: number): string {
|
||||
if (!Number.isInteger(value)) throw new RangeError(`not an integer: ${value}`);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/** Text Double.valueOf reads back to the same value. */
|
||||
function floatingText(value: number): string {
|
||||
if (Object.is(value, -0)) return '-0.0';
|
||||
if (Number.isNaN(value)) return 'NaN';
|
||||
if (value === Infinity) return 'Infinity';
|
||||
if (value === -Infinity) return '-Infinity';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* A string's content: what XML 1.0 cannot hold, and a CR (which XML would
|
||||
* read as a line break), as `<char code="#…"/>`, the rest escaped.
|
||||
*/
|
||||
function stringContent(value: string): string {
|
||||
let out = '';
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const unit = value.charCodeAt(index);
|
||||
if (unit >= 0xd800 && unit <= 0xdbff && index + 1 < value.length) {
|
||||
const next = value.charCodeAt(index + 1);
|
||||
if (next >= 0xdc00 && next <= 0xdfff) {
|
||||
out += value[index]! + value[index + 1]!;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out += needsCharElement(unit) ? charCode(unit) : escapeText(value[index]!);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function charElement(value: string): string {
|
||||
const unit = value.charCodeAt(0);
|
||||
if (value.length !== 1) throw new RangeError('a char is one UTF-16 unit');
|
||||
return needsCharElement(unit) ? `<char code="#${unit.toString(16).toUpperCase()}"/>` : `<char>${escapeText(value)}</char>`;
|
||||
}
|
||||
|
||||
function needsCharElement(unit: number): boolean {
|
||||
return (unit < 0x20 && unit !== 0x09 && unit !== 0x0a) || (unit >= 0xd800 && unit <= 0xdfff) || unit === 0xfffe || unit === 0xffff;
|
||||
}
|
||||
|
||||
function charCode(unit: number): string {
|
||||
return `<char code="#${unit.toString(16).toUpperCase()}"/>`;
|
||||
}
|
||||
|
||||
function escapeText(text: string): string {
|
||||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function escapeAttribute(text: string): string {
|
||||
return escapeText(text).replace(/"/g, '"');
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<java version="17" class="java.beans.XMLDecoder">
|
||||
<array class="java.lang.String" length="3">
|
||||
<void index="0">
|
||||
<string>a</string>
|
||||
</void>
|
||||
<void index="1">
|
||||
<null/>
|
||||
</void>
|
||||
<void index="2">
|
||||
<string>c</string>
|
||||
</void>
|
||||
</array>
|
||||
<array class="int" length="2">
|
||||
<void index="0">
|
||||
<int>0</int>
|
||||
</void>
|
||||
<void index="1">
|
||||
<int>5</int>
|
||||
</void>
|
||||
</array>
|
||||
</java>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<java version="17" class="java.beans.XMLDecoder">
|
||||
<object class="java.awt.Rectangle">
|
||||
<int>1</int>
|
||||
<int>2</int>
|
||||
<int>3</int>
|
||||
<int>4</int>
|
||||
</object>
|
||||
<object class="java.awt.Color">
|
||||
<int>10</int>
|
||||
<int>20</int>
|
||||
<int>30</int>
|
||||
<int>40</int>
|
||||
</object>
|
||||
</java>
|
||||
@@ -0,0 +1,411 @@
|
||||
{
|
||||
"arrays.xml": {
|
||||
"values": [
|
||||
{
|
||||
"id": 0,
|
||||
"class": "[Ljava.lang.String;",
|
||||
"items": [
|
||||
{
|
||||
"string": "a"
|
||||
},
|
||||
null,
|
||||
{
|
||||
"string": "c"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"class": "[I",
|
||||
"items": [
|
||||
{
|
||||
"int": 0
|
||||
},
|
||||
{
|
||||
"int": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"errors": []
|
||||
},
|
||||
"awt.xml": {
|
||||
"values": [
|
||||
{
|
||||
"rectangle": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"color": [
|
||||
10,
|
||||
20,
|
||||
30,
|
||||
40
|
||||
]
|
||||
}
|
||||
],
|
||||
"errors": []
|
||||
},
|
||||
"beans.xml": {
|
||||
"values": [
|
||||
{
|
||||
"id": 0,
|
||||
"class": "filius.software.system.Datei",
|
||||
"properties": {
|
||||
"dateiInhalt": {
|
||||
"string": "a.de. A 3600 1.2.3.4\\u000A"
|
||||
},
|
||||
"dateiTyp": {
|
||||
"string": "text"
|
||||
},
|
||||
"name": {
|
||||
"string": "hosts"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"class": "filius.software.email.EmailKonto",
|
||||
"properties": {
|
||||
"benutzername": {
|
||||
"string": "bob"
|
||||
},
|
||||
"emailAdresse": null,
|
||||
"nachname": {
|
||||
"string": ""
|
||||
},
|
||||
"nachrichten": {
|
||||
"id": 2,
|
||||
"class": "java.util.LinkedList",
|
||||
"items": [
|
||||
{
|
||||
"id": 3,
|
||||
"class": "filius.software.email.Email",
|
||||
"properties": {
|
||||
"absender": {
|
||||
"id": 4,
|
||||
"class": "filius.software.email.AddressEntry",
|
||||
"properties": {
|
||||
"mailAddress": {
|
||||
"string": "a@x"
|
||||
},
|
||||
"name": {
|
||||
"string": "Alice"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bcc": {
|
||||
"id": 5,
|
||||
"class": "java.util.ArrayList",
|
||||
"items": []
|
||||
},
|
||||
"betreff": {
|
||||
"string": "Hi"
|
||||
},
|
||||
"cc": {
|
||||
"id": 6,
|
||||
"class": "java.util.ArrayList",
|
||||
"items": []
|
||||
},
|
||||
"dateReceived": {
|
||||
"string": ""
|
||||
},
|
||||
"delete": false,
|
||||
"empfaenger": {
|
||||
"id": 7,
|
||||
"class": "java.util.ArrayList",
|
||||
"items": [
|
||||
{
|
||||
"id": 8,
|
||||
"class": "filius.software.email.AddressEntry",
|
||||
"properties": {
|
||||
"mailAddress": {
|
||||
"string": "b@y"
|
||||
},
|
||||
"name": null
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"neu": false,
|
||||
"text": {
|
||||
"string": ""
|
||||
},
|
||||
"versendet": false
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"passwort": null,
|
||||
"pop3port": null,
|
||||
"pop3server": null,
|
||||
"smtpport": null,
|
||||
"smtpserver": null,
|
||||
"vorname": {
|
||||
"string": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"class": "filius.software.email.Kontakt",
|
||||
"properties": {
|
||||
"email": null,
|
||||
"hausnr": {
|
||||
"int": 12
|
||||
},
|
||||
"name": {
|
||||
"string": "Lang"
|
||||
},
|
||||
"plz": {
|
||||
"int": 0
|
||||
},
|
||||
"strasse": null,
|
||||
"telefon": null,
|
||||
"vorname": null,
|
||||
"wohnort": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"errors": []
|
||||
},
|
||||
"collections.xml": {
|
||||
"values": [
|
||||
{
|
||||
"id": 0,
|
||||
"class": "java.util.Vector",
|
||||
"items": [
|
||||
{
|
||||
"int": 1
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"class": "java.util.ArrayList",
|
||||
"items": [
|
||||
{
|
||||
"string": "nested"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"class": "java.util.HashMap",
|
||||
"entries": [
|
||||
[
|
||||
{
|
||||
"string": "key"
|
||||
},
|
||||
{
|
||||
"int": 8
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"errors": []
|
||||
},
|
||||
"constants.xml": {
|
||||
"values": [
|
||||
{
|
||||
"int": 0
|
||||
}
|
||||
],
|
||||
"errors": []
|
||||
},
|
||||
"indexed.xml": {
|
||||
"values": [
|
||||
{
|
||||
"id": 0,
|
||||
"class": "java.util.LinkedList",
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"class": "filius.software.system.Datei",
|
||||
"properties": {
|
||||
"dateiInhalt": null,
|
||||
"dateiTyp": null,
|
||||
"name": {
|
||||
"string": "renamed"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"string": "replaced"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"errors": []
|
||||
},
|
||||
"numbers.xml": {
|
||||
"values": [
|
||||
{
|
||||
"int": -1
|
||||
},
|
||||
{
|
||||
"int": 2147483647
|
||||
},
|
||||
{
|
||||
"long": "-9007199254740991"
|
||||
},
|
||||
{
|
||||
"long": "9223372036854775807"
|
||||
},
|
||||
{
|
||||
"double": "0.1"
|
||||
},
|
||||
{
|
||||
"double": "-0.0"
|
||||
},
|
||||
{
|
||||
"double": "1.0E300"
|
||||
},
|
||||
{
|
||||
"double": "NaN"
|
||||
},
|
||||
{
|
||||
"double": "-Infinity"
|
||||
},
|
||||
{
|
||||
"float": "1.5"
|
||||
},
|
||||
{
|
||||
"short": -3
|
||||
},
|
||||
{
|
||||
"byte": 127
|
||||
},
|
||||
{
|
||||
"char": 120
|
||||
},
|
||||
{
|
||||
"char": 1
|
||||
},
|
||||
true,
|
||||
false,
|
||||
null,
|
||||
{
|
||||
"class": "java.lang.String"
|
||||
}
|
||||
],
|
||||
"errors": []
|
||||
},
|
||||
"shared.xml": {
|
||||
"values": [
|
||||
{
|
||||
"id": 0,
|
||||
"class": "java.util.LinkedList",
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"class": "java.util.ArrayList",
|
||||
"items": [
|
||||
{
|
||||
"string": "shared"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"class": "java.util.ArrayList",
|
||||
"items": [
|
||||
{
|
||||
"string": "other"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"ref": 1
|
||||
},
|
||||
{
|
||||
"ref": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"class": "filius.software.system.Datei",
|
||||
"properties": {
|
||||
"dateiInhalt": {
|
||||
"string": "a.de. A 3600 1.2.3.4\\u000A"
|
||||
},
|
||||
"dateiTyp": {
|
||||
"string": "text"
|
||||
},
|
||||
"name": {
|
||||
"string": "hosts"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ref": 3
|
||||
}
|
||||
],
|
||||
"errors": []
|
||||
},
|
||||
"strings.xml": {
|
||||
"values": [
|
||||
{
|
||||
"id": 0,
|
||||
"class": "java.util.LinkedList",
|
||||
"items": [
|
||||
{
|
||||
"string": "plain"
|
||||
},
|
||||
{
|
||||
"string": ""
|
||||
},
|
||||
{
|
||||
"string": " spaced "
|
||||
},
|
||||
{
|
||||
"string": "<&>\"'"
|
||||
},
|
||||
{
|
||||
"string": "]]>"
|
||||
},
|
||||
{
|
||||
"string": "tab\\u0009here"
|
||||
},
|
||||
{
|
||||
"string": "line\\u000Abreak"
|
||||
},
|
||||
{
|
||||
"string": "cr\\u000Dhere"
|
||||
},
|
||||
{
|
||||
"string": "crlf\\u000D\\u000Ahere"
|
||||
},
|
||||
{
|
||||
"string": "\\u0000\\u0001\\u001F\\u007F"
|
||||
},
|
||||
{
|
||||
"string": "\\u0085\\u2028\\u2029"
|
||||
},
|
||||
{
|
||||
"string": "\\uFEFFbom"
|
||||
},
|
||||
{
|
||||
"string": "Gr\\u00FC\\u00DFe \\uD83D\\uDE00"
|
||||
},
|
||||
{
|
||||
"string": "lone \\uD800 high"
|
||||
},
|
||||
{
|
||||
"string": "lone \\uDC00 low"
|
||||
},
|
||||
{
|
||||
"string": "\\uFFFE\\uFFFF"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"errors": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<java version="17" class="java.beans.XMLDecoder">
|
||||
<object class="filius.software.system.Datei">
|
||||
<void property="name">
|
||||
<string>hosts</string>
|
||||
</void>
|
||||
<void property="dateiTyp">
|
||||
<string>text</string>
|
||||
</void>
|
||||
<void property="dateiInhalt">
|
||||
<string>a.de. A 3600 1.2.3.4
|
||||
</string>
|
||||
</void>
|
||||
</object>
|
||||
<object class="filius.software.email.EmailKonto">
|
||||
<void property="benutzername">
|
||||
<string>bob</string>
|
||||
</void>
|
||||
<void property="passwort">
|
||||
<null/>
|
||||
</void>
|
||||
<void property="nachname">
|
||||
<null/>
|
||||
</void>
|
||||
<void property="nachrichten">
|
||||
<void method="add">
|
||||
<object class="filius.software.email.Email">
|
||||
<void property="absender">
|
||||
<object class="filius.software.email.AddressEntry">
|
||||
<void property="name">
|
||||
<string>Alice</string>
|
||||
</void>
|
||||
<void property="mailAddress">
|
||||
<string>a@x</string>
|
||||
</void>
|
||||
</object>
|
||||
</void>
|
||||
<void property="empfaenger">
|
||||
<void method="add">
|
||||
<object class="filius.software.email.AddressEntry">
|
||||
<void property="mailAddress">
|
||||
<string>b@y</string>
|
||||
</void>
|
||||
</object>
|
||||
</void>
|
||||
</void>
|
||||
<void property="betreff">
|
||||
<string>Hi</string>
|
||||
</void>
|
||||
<void property="neu">
|
||||
<boolean>false</boolean>
|
||||
</void>
|
||||
</object>
|
||||
</void>
|
||||
</void>
|
||||
</object>
|
||||
<object class="filius.software.email.Kontakt">
|
||||
<void property="hausnr">
|
||||
<int>12</int>
|
||||
</void>
|
||||
<void property="name">
|
||||
<string>Lang</string>
|
||||
</void>
|
||||
</object>
|
||||
</java>
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<java version="17" class="java.beans.XMLDecoder">
|
||||
<object class="java.util.Vector">
|
||||
<void method="add">
|
||||
<int>1</int>
|
||||
</void>
|
||||
<void method="add">
|
||||
<object class="java.util.ArrayList">
|
||||
<void method="add">
|
||||
<string>nested</string>
|
||||
</void>
|
||||
</object>
|
||||
</void>
|
||||
</object>
|
||||
<object class="java.util.HashMap">
|
||||
<void method="put">
|
||||
<string>key</string>
|
||||
<int>7</int>
|
||||
</void>
|
||||
<void method="put">
|
||||
<string>key</string>
|
||||
<int>8</int>
|
||||
</void>
|
||||
</object>
|
||||
</java>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<java version="17" class="java.beans.XMLDecoder">
|
||||
<object class="javax.swing.SwingConstants" field="CENTER"/>
|
||||
</java>
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<java version="17" class="java.beans.XMLDecoder">
|
||||
<object class="java.util.LinkedList">
|
||||
<void method="add">
|
||||
<object class="filius.software.system.Datei">
|
||||
<void property="name">
|
||||
<string>first</string>
|
||||
</void>
|
||||
</object>
|
||||
</void>
|
||||
<void index="0">
|
||||
<void property="name">
|
||||
<string>renamed</string>
|
||||
</void>
|
||||
</void>
|
||||
<void method="add">
|
||||
<string>second</string>
|
||||
</void>
|
||||
<void index="1">
|
||||
<string>replaced</string>
|
||||
</void>
|
||||
</object>
|
||||
</java>
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<java version="17" class="java.beans.XMLDecoder">
|
||||
<int>-1</int>
|
||||
<int>2147483647</int>
|
||||
<long>-9007199254740991</long>
|
||||
<long>9223372036854775807</long>
|
||||
<double>0.1</double>
|
||||
<double>-0.0</double>
|
||||
<double>1e+300</double>
|
||||
<double>NaN</double>
|
||||
<double>-Infinity</double>
|
||||
<float>1.5</float>
|
||||
<short>-3</short>
|
||||
<byte>127</byte>
|
||||
<char>x</char>
|
||||
<char code="#1"/>
|
||||
<boolean>true</boolean>
|
||||
<boolean>false</boolean>
|
||||
<null/>
|
||||
<class>java.lang.String</class>
|
||||
</java>
|
||||
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<java version="17" class="java.beans.XMLDecoder">
|
||||
<object class="java.util.LinkedList">
|
||||
<void method="add">
|
||||
<object class="java.util.ArrayList" id="ArrayList0">
|
||||
<void method="add">
|
||||
<string>shared</string>
|
||||
</void>
|
||||
</object>
|
||||
</void>
|
||||
<void method="add">
|
||||
<object class="java.util.ArrayList" id="ArrayList1">
|
||||
<void method="add">
|
||||
<string>other</string>
|
||||
</void>
|
||||
</object>
|
||||
</void>
|
||||
<void method="add">
|
||||
<object idref="ArrayList0"/>
|
||||
</void>
|
||||
<void method="add">
|
||||
<object idref="ArrayList1"/>
|
||||
</void>
|
||||
</object>
|
||||
<object class="filius.software.system.Datei" id="Datei0">
|
||||
<void property="name">
|
||||
<string>hosts</string>
|
||||
</void>
|
||||
<void property="dateiTyp">
|
||||
<string>text</string>
|
||||
</void>
|
||||
<void property="dateiInhalt">
|
||||
<string>a.de. A 3600 1.2.3.4
|
||||
</string>
|
||||
</void>
|
||||
</object>
|
||||
<object idref="Datei0"/>
|
||||
</java>
|
||||
@@ -0,0 +1,55 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<java version="17" class="java.beans.XMLDecoder">
|
||||
<object class="java.util.LinkedList">
|
||||
<void method="add">
|
||||
<string>plain</string>
|
||||
</void>
|
||||
<void method="add">
|
||||
<string></string>
|
||||
</void>
|
||||
<void method="add">
|
||||
<string> spaced </string>
|
||||
</void>
|
||||
<void method="add">
|
||||
<string><&>"'</string>
|
||||
</void>
|
||||
<void method="add">
|
||||
<string>]]></string>
|
||||
</void>
|
||||
<void method="add">
|
||||
<string>tab here</string>
|
||||
</void>
|
||||
<void method="add">
|
||||
<string>line
|
||||
break</string>
|
||||
</void>
|
||||
<void method="add">
|
||||
<string>cr<char code="#D"/>here</string>
|
||||
</void>
|
||||
<void method="add">
|
||||
<string>crlf<char code="#D"/>
|
||||
here</string>
|
||||
</void>
|
||||
<void method="add">
|
||||
<string><char code="#0"/><char code="#1"/><char code="#1F"/></string>
|
||||
</void>
|
||||
<void method="add">
|
||||
<string>
</string>
|
||||
</void>
|
||||
<void method="add">
|
||||
<string>bom</string>
|
||||
</void>
|
||||
<void method="add">
|
||||
<string>Grüße 😀</string>
|
||||
</void>
|
||||
<void method="add">
|
||||
<string>lone <char code="#D800"/> high</string>
|
||||
</void>
|
||||
<void method="add">
|
||||
<string>lone <char code="#DC00"/> low</string>
|
||||
</void>
|
||||
<void method="add">
|
||||
<string><char code="#FFFE"/><char code="#FFFF"/></string>
|
||||
</void>
|
||||
</object>
|
||||
</java>
|
||||
@@ -0,0 +1,193 @@
|
||||
// The XMLDecoder format as javaBeansWriter.ts writes it, read by Java
|
||||
// itself: every case is written to test/beans/<name>.xml (UPDATE_BEAN_CASES=1
|
||||
// rewrites them), tools/java-oracle BeansDump reads each with XMLDecoder and
|
||||
// describes what it got (beans.java.json). The descriptions must match the
|
||||
// expectations written here by hand, and Java must report no error.
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { decodeJavaBeans } from '../src/javabeans.js';
|
||||
import {
|
||||
double,
|
||||
encodeJavaBeans,
|
||||
float,
|
||||
int,
|
||||
long,
|
||||
object,
|
||||
type BeanObject,
|
||||
type BeanValue,
|
||||
} from '../src/javaBeansWriter.js';
|
||||
|
||||
const directory = join(dirname(fileURLToPath(import.meta.url)), 'beans');
|
||||
|
||||
const list = (className: string, items: BeanValue[]): BeanObject =>
|
||||
object(className, items.map((item) => ({ call: 'add', args: [item] })));
|
||||
/** BeansDump's escaping: every unit outside printable ASCII, and the backslash, as backslash-u and four hex digits. */
|
||||
const text = (value: string) => ({
|
||||
string: value.replace(/[^\x20-\x7E]|\\/g, (unit) => `\\u${unit.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')}`),
|
||||
});
|
||||
const bean = (className: string, properties: Record<string, unknown>) => ({ class: className, properties });
|
||||
const items = (className: string, values: unknown[]) => ({ class: className, items: values });
|
||||
|
||||
const STRINGS = ['plain', '', ' spaced ', '<&>"\'', ']]>', 'tab\there', 'line\nbreak', 'cr\rhere', 'crlf\r\nhere',
|
||||
'\u0000\u0001\u001F\u007F', '\u0085\u2028\u2029', '\uFEFFbom', 'Grüße 😀', 'lone \uD800 high', 'lone \uDC00 low', '\uFFFE\uFFFF'];
|
||||
|
||||
const inner = list('java.util.ArrayList', ['shared']);
|
||||
const other = list('java.util.ArrayList', ['other']);
|
||||
const file = object('filius.software.system.Datei', [
|
||||
{ set: 'name', value: 'hosts' },
|
||||
{ set: 'dateiTyp', value: 'text' },
|
||||
{ set: 'dateiInhalt', value: 'a.de. A 3600 1.2.3.4\n' },
|
||||
]);
|
||||
|
||||
const CASES: Record<string, { values: BeanValue[]; expected: unknown[] }> = {
|
||||
strings: {
|
||||
values: [list('java.util.LinkedList', STRINGS)],
|
||||
expected: [items('java.util.LinkedList', STRINGS.map(text))],
|
||||
},
|
||||
numbers: {
|
||||
values: [int(-1), int(2_147_483_647), long(-9_007_199_254_740_991), long(9_223_372_036_854_775_807n), double(0.1), double(-0),
|
||||
double(1e300), double(Number.NaN), double(-Infinity), float(1.5), { kind: 'short', value: -3 }, { kind: 'byte', value: 127 },
|
||||
{ kind: 'char', value: 'x' }, { kind: 'char', value: '\u0001' }, true, false, null, { kind: 'class', name: 'java.lang.String' }],
|
||||
expected: [{ int: -1 }, { int: 2_147_483_647 }, { long: '-9007199254740991' }, { long: '9223372036854775807' }, { double: 0.1 },
|
||||
{ double: -0 }, { double: 1e300 }, { double: Number.NaN }, { double: -Infinity }, { float: 1.5 }, { short: -3 }, { byte: 127 },
|
||||
{ char: 120 }, { char: 1 }, true, false, null, { class: 'java.lang.String' }],
|
||||
},
|
||||
collections: {
|
||||
values: [
|
||||
list('java.util.Vector', [int(1), list('java.util.ArrayList', ['nested'])]),
|
||||
object('java.util.HashMap', [{ call: 'put', args: ['key', int(7)] }, { call: 'put', args: ['key', int(8)] }]),
|
||||
],
|
||||
expected: [
|
||||
items('java.util.Vector', [{ int: 1 }, items('java.util.ArrayList', [text('nested')])]),
|
||||
{ class: 'java.util.HashMap', entries: [[text('key'), { int: 8 }]] },
|
||||
],
|
||||
},
|
||||
shared: {
|
||||
values: [list('java.util.LinkedList', [inner, other, inner, other]), file, file],
|
||||
expected: [
|
||||
{
|
||||
id: 0,
|
||||
class: 'java.util.LinkedList',
|
||||
items: [items('java.util.ArrayList', [text('shared')]), items('java.util.ArrayList', [text('other')]), { ref: 1 }, { ref: 2 }],
|
||||
},
|
||||
{ id: 3, class: 'filius.software.system.Datei' },
|
||||
{ ref: 3 },
|
||||
],
|
||||
},
|
||||
awt: {
|
||||
values: [object('java.awt.Rectangle', [], [int(1), int(2), int(3), int(4)]), object('java.awt.Color', [], [int(10), int(20), int(30), int(40)])],
|
||||
expected: [{ rectangle: [1, 2, 3, 4] }, { color: [10, 20, 30, 40] }],
|
||||
},
|
||||
beans: {
|
||||
values: [
|
||||
file,
|
||||
object('filius.software.email.EmailKonto', [
|
||||
{ set: 'benutzername', value: 'bob' },
|
||||
{ set: 'passwort', value: null },
|
||||
{ set: 'nachname', value: null },
|
||||
{
|
||||
get: 'nachrichten',
|
||||
statements: [
|
||||
{
|
||||
call: 'add',
|
||||
args: [
|
||||
object('filius.software.email.Email', [
|
||||
{ set: 'absender', value: object('filius.software.email.AddressEntry', [{ set: 'name', value: 'Alice' }, { set: 'mailAddress', value: 'a@x' }]) },
|
||||
{ get: 'empfaenger', statements: [{ call: 'add', args: [object('filius.software.email.AddressEntry', [{ set: 'mailAddress', value: 'b@y' }])] }] },
|
||||
{ set: 'betreff', value: 'Hi' },
|
||||
{ set: 'neu', value: false },
|
||||
]),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
object('filius.software.email.Kontakt', [{ set: 'hausnr', value: int(12) }, { set: 'name', value: 'Lang' }]),
|
||||
],
|
||||
expected: [
|
||||
bean('filius.software.system.Datei', { name: text('hosts'), dateiTyp: text('text'), dateiInhalt: text('a.de. A 3600 1.2.3.4\n') }),
|
||||
bean('filius.software.email.EmailKonto', {
|
||||
benutzername: text('bob'),
|
||||
passwort: null,
|
||||
// setNachname keeps "" for null.
|
||||
nachname: text(''),
|
||||
nachrichten: items('java.util.LinkedList', [
|
||||
bean('filius.software.email.Email', {
|
||||
betreff: text('Hi'),
|
||||
neu: false,
|
||||
empfaenger: items('java.util.ArrayList', [bean('filius.software.email.AddressEntry', { name: null, mailAddress: text('b@y') })]),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
bean('filius.software.email.Kontakt', { hausnr: { int: 12 }, name: text('Lang') }),
|
||||
],
|
||||
},
|
||||
arrays: {
|
||||
values: [{ kind: 'array', componentClass: 'java.lang.String', items: ['a', null, 'c'] }, { kind: 'array', componentClass: 'int', items: [int(0), int(5)] }],
|
||||
expected: [items('[Ljava.lang.String;', [text('a'), null, text('c')]), items('[I', [{ int: 0 }, { int: 5 }])],
|
||||
},
|
||||
indexed: {
|
||||
values: [object('java.util.LinkedList', [
|
||||
{ call: 'add', args: [object('filius.software.system.Datei', [{ set: 'name', value: 'first' }])] },
|
||||
{ getIndex: 0, statements: [{ set: 'name', value: 'renamed' }] },
|
||||
{ call: 'add', args: ['second'] },
|
||||
{ setIndex: 1, value: 'replaced' },
|
||||
])],
|
||||
expected: [items('java.util.LinkedList', [bean('filius.software.system.Datei', { name: text('renamed') }), text('replaced')])],
|
||||
},
|
||||
constants: {
|
||||
values: [{ kind: 'object', className: 'javax.swing.SwingConstants', staticField: 'CENTER' }],
|
||||
expected: [{ int: 0 }],
|
||||
},
|
||||
};
|
||||
|
||||
/** Whether Java's description has everything expected: listed properties only, numbers by value, ids only where given. */
|
||||
function matches(actual: unknown, expected: unknown): boolean {
|
||||
if (expected === null || typeof expected !== 'object') return Object.is(actual, expected);
|
||||
if (actual === null || typeof actual !== 'object') return false;
|
||||
const want = expected as Record<string, unknown>;
|
||||
const have = actual as Record<string, unknown>;
|
||||
for (const [key, value] of Object.entries(want)) {
|
||||
if ((key === 'double' || key === 'float') && typeof have[key] === 'string') {
|
||||
if (!Object.is(Number(have[key]), value)) return false;
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const list = have[key];
|
||||
if (!Array.isArray(list) || list.length !== value.length || !value.every((item, index) => matches(list[index], item))) return false;
|
||||
continue;
|
||||
}
|
||||
if (!(key in have) || !matches(have[key], value)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const javaDump = existsSync(join(directory, 'beans.java.json'))
|
||||
? (JSON.parse(readFileSync(join(directory, 'beans.java.json'), 'utf8')) as Record<string, { values: unknown[]; errors: string[] }>)
|
||||
: {};
|
||||
|
||||
describe('javaBeansWriter, read by Java', () => {
|
||||
for (const [name, { values, expected }] of Object.entries(CASES)) {
|
||||
it(name, () => {
|
||||
const path = join(directory, `${name}.xml`);
|
||||
const xml = encodeJavaBeans(values);
|
||||
if (process.env.UPDATE_BEAN_CASES === '1') writeFileSync(path, xml);
|
||||
expect(readFileSync(path, 'utf8')).toBe(xml);
|
||||
const java = javaDump[`${name}.xml`];
|
||||
expect(java?.errors).toEqual([]);
|
||||
expect(java!.values).toHaveLength(expected.length);
|
||||
java!.values.forEach((value, index) => {
|
||||
expect(matches(value, expected[index]), `value ${index}: ${JSON.stringify(value)}`).toBe(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it('reads back with the reader of this package', () => {
|
||||
expect(decodeJavaBeans(encodeJavaBeans(CASES.strings!.values))).toEqual([
|
||||
expect.objectContaining({ calls: STRINGS.map((value) => ({ method: 'add', args: [value] })) }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user