// The XMLDecoder format as javaBeansWriter.ts writes it, read by Java // itself: every case is written to test/beans/.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, reference, 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) => ({ 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 port = reference('Port'); const CASES: Record = { references: { values: [ object('java.util.LinkedList', [ { call: 'add', args: [object('filius.hardware.NetzwerkInterface', [{ set: 'ip', value: '10.0.0.1' }, { get: 'port', statements: [], as: port }])] }, { call: 'add', args: [port] }, ]), ], expected: [ items('java.util.LinkedList', [ bean('filius.hardware.NetzwerkInterface', { ip: text('10.0.0.1'), port: { id: 2, class: 'filius.hardware.Port' } }), { ref: 2 }, ]), ], }, 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 }]] }, ], }, // A method's result changed by the statements inside its call: a map's // value reached through get(key), as XMLEncoder reaches what a // constructor put into a map. results: { values: [ object('java.util.HashMap', [ { call: 'put', args: ['key', list('java.util.ArrayList', ['first'])] }, { call: 'get', args: ['key'], statements: [{ call: 'add', args: ['second'] }] }, ]), ], expected: [{ class: 'java.util.HashMap', entries: [[text('key'), items('java.util.ArrayList', [text('first'), text('second')])]] }], }, 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; const have = actual as Record; 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) : {}; 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] })) }), ]); }); });