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>
194 lines
8.7 KiB
TypeScript
194 lines
8.7 KiB
TypeScript
// 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] })) }),
|
|
]);
|
|
});
|
|
});
|