feat(fls): read and write a project's own applications as their beans

A project's own applications (classes under projekt/anwendungen, installed
on a host) are no longer left out. The decoder now keeps the Java type of
every primitive — at setters, constructor and call arguments, also through
shared ids like Integer0 — and which properties an object's own getter
made. beanCapture.ts turns a decoded bean back into what the writer writes,
with the node's system software as a mark the writer replaces by the
system software it writes; what the format cannot say is refused, not
guessed (an unresolved value, a Field, a factory-made object, an object a
getter reaches elsewhere in the file). The reader keeps own applications as
captured beans; the corpus no longer warns.

The capture first took a setter with another object's getter result for an
own getter; the round-trip test found it. The Java oracle did not show
whether an application runs on its node's system software, so a mutation
writing the mark instead survived; it now reports onItsSystem (true for all
192 applications of the corpus).

Checked by Java: from the files written again, FILIUS loads Accumulator and
Broadcast installed, switched on and on their node's system software, as
in the original. The round-trip test covers every primitive, null, a
class, an array, shared objects, getter chains, constructed elements, map
reads and static constants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 09:43:16 +00:00
co-authored by Claude Opus 5.5
parent 95c5b7d97b
commit 3548652687
18 changed files with 1444 additions and 404 deletions
+142
View File
@@ -0,0 +1,142 @@
// A decoded JavaBean (javabeans.ts) turned back into what javaBeansWriter.ts
// writes, so that a bean this package does not understand — a project's own
// application — is written again as the file had it. Java's primitive types
// come from the decoder (propertyKinds, argKinds, a call's kinds); a number
// without one was an Integer or a Double. The node's system software, which
// the bean refers to, is marked (SYSTEM_SOFTWARE_CLASS) and put back by the
// writer as the system software of the node it writes.
//
// What the writer's format cannot say is refused, never guessed: a value the
// decoder left unresolved, a Field, an object a static factory made, and an
// object the bean reached by a getter elsewhere in the file (another part of
// the node, whose identity a copy would lose). Objects the bean shares with
// itself stay shared.
import { isJavaObject, isUnresolved, type JavaArray, type JavaObject, type JavaValue, type PrimitiveKind } from './javabeans.js';
import type { BeanObject, BeanStatement, BeanValue } from './javaBeansWriter.js';
/** The class name of the mark where a captured bean refers to its node's system software. */
export const SYSTEM_SOFTWARE_CLASS = '#systemSoftware';
export type CaptureResult = { readonly bean: BeanObject } | { readonly failure: string };
class CaptureFailure extends Error {}
/**
* The bean `object` as the writer writes it; `systemSoftware` is the node's
* system software as the decoder has it.
*/
export function captureBean(object: JavaObject, systemSoftware: JavaValue | undefined): CaptureResult {
const captured = new Map<JavaObject, BeanObject>();
const value = (item: JavaValue, kind: PrimitiveKind | undefined): BeanValue => {
if (item === null || typeof item === 'boolean') return item;
if (typeof item === 'string') return kind === 'char' ? { kind: 'char', value: item } : item;
if (typeof item === 'number' || typeof item === 'bigint') return number(item, kind);
if (isJavaObject(item)) {
if (item === systemSoftware) return { kind: 'object', className: SYSTEM_SOFTWARE_CLASS };
if (item.fromGetter) throw new CaptureFailure(`it refers to ${item.className}, which a getter reaches elsewhere in the file`);
return bean(item);
}
if (isUnresolved(item)) throw new CaptureFailure(`a value is unresolved (${item.reason})`);
switch (item.kind) {
case 'array':
return { kind: 'array', componentClass: item.componentClass, items: (item as JavaArray).items.map((each) => value(each, undefined)) };
case 'class':
return { kind: 'class', name: item.name };
default:
throw new CaptureFailure(`it holds a Field (${item.className}.${item.name})`);
}
};
const bean = (item: JavaObject): BeanObject => {
const known = captured.get(item);
if (known !== undefined) return known;
if (item.factoryMethod !== undefined) throw new CaptureFailure(`${item.className}.${item.factoryMethod} makes an object this writer cannot`);
const statements: BeanStatement[] = [];
const result: BeanObject =
item.staticField === undefined
? { kind: 'object', className: item.className, args: item.args.map((arg, index) => value(arg, item.argKinds[index])), statements }
: { kind: 'object', className: item.className, staticField: item.staticField, statements };
captured.set(item, result);
statements.push(...statementsOf(item));
return result;
};
/** Setters and getters with what follows on their results; the constructor's elements; the calls but reads. */
const statementsOf = (item: JavaObject): BeanStatement[] => {
const statements: BeanStatement[] = [];
for (const [property, each] of item.properties) {
if (item.propertiesFromGetters.has(property) && isJavaObject(each)) {
statements.push({ get: property, statements: statementsOf(each) });
} else {
statements.push({ set: property, value: value(each, item.propertyKinds.get(property)) });
}
}
for (const [index, element] of item.presetElements) {
statements.push({ getIndex: index, statements: isJavaObject(element) ? statementsOf(element) : [] });
}
for (const [key, entry] of item.presetEntries) {
statements.push({ call: 'get', args: [key], statements: isJavaObject(entry) ? statementsOf(entry) : [] });
}
for (const call of item.calls) {
// A read (get(i), get(key)) is the preset element or entry above, or a value set before.
if (call.method === 'get' && call.args.length === 1) continue;
statements.push({ call: call.method, args: call.args.map((arg, index) => value(arg, call.kinds?.[index])) });
}
return statements;
};
try {
return { bean: bean(object) };
} catch (error) {
if (error instanceof CaptureFailure) return { failure: error.message };
throw error;
}
}
function number(item: number | bigint, kind: PrimitiveKind | undefined): BeanValue {
switch (kind) {
case 'byte':
case 'short':
case 'int':
return { kind, value: Number(item) };
case 'long':
return { kind: 'long', value: item };
case 'float':
case 'double':
return { kind, value: Number(item) };
case 'char':
return { kind: 'char', value: String.fromCharCode(Number(item)) };
default:
// An Integer, or a Double where the number has a fraction.
return typeof item === 'number' && !Number.isInteger(item) ? { kind: 'double', value: item } : { kind: 'int', value: Number(item) };
}
}
/** The bean with each mark of the system software replaced by `systemSoftware`. */
export function withSystemSoftware(value: BeanValue, systemSoftware: BeanValue): BeanValue {
const seen = new Map<BeanObject, BeanObject>();
const replace = (item: BeanValue): BeanValue => {
if (item === null || typeof item !== 'object') return item;
if (item.kind === 'array') return { ...item, items: item.items.map(replace) };
if (item.kind !== 'object') return item;
if (item.className === SYSTEM_SOFTWARE_CLASS) return systemSoftware;
return object(item);
};
const statement = (each: BeanStatement): BeanStatement => {
if ('set' in each || 'setIndex' in each) return { ...each, value: replace(each.value) };
if ('call' in each) return { ...each, args: each.args.map(replace), ...(each.statements === undefined ? {} : { statements: each.statements.map(statement) }) };
return { ...each, statements: each.statements.map(statement) };
};
const object = (item: BeanObject): BeanObject => {
const known = seen.get(item);
if (known !== undefined) return known;
const statements: BeanStatement[] = [];
const result: BeanObject = { ...item, ...(item.args === undefined ? {} : { args: item.args.map(replace) }), statements };
seen.set(item, result);
statements.push(...(item.statements ?? []).map(statement));
return result;
};
return replace(value);
}
+76 -10
View File
@@ -45,8 +45,18 @@ export interface JavaObject {
readonly factoryMethod: string | undefined;
/** Set through setters, `Field.set` or created lazily by a getter. */
readonly properties: Map<string, JavaValue>;
/** Method calls in order (`add`, `put`, …), with their arguments. */
readonly calls: { method: string; args: JavaValue[] }[];
/** The Java type of each property set to a primitive (`int`, `long`, …): numbers alone do not say. */
readonly propertyKinds: Map<string, PrimitiveKind>;
/**
* The properties whose value this object's own getter made (a placeholder
* the file's statements then changed) — not set to an object from
* elsewhere.
*/
readonly propertiesFromGetters: Set<string>;
/** The Java types of the constructor's arguments, where they are primitives. */
readonly argKinds: (PrimitiveKind | undefined)[];
/** Method calls in order (`add`, `put`, …), with their arguments and those arguments' primitive types. */
readonly calls: { method: string; args: JavaValue[]; kinds?: (PrimitiveKind | undefined)[] }[];
/**
* Elements the object already held before the file's statements — a list
* its constructor filled, reached by `<void index="i">` on a getter's
@@ -62,6 +72,23 @@ export interface JavaObject {
readonly fromGetter: boolean;
}
/** The primitive types XMLEncoder writes as elements of their own. */
export type PrimitiveKind = 'boolean' | 'byte' | 'char' | 'short' | 'int' | 'long' | 'float' | 'double';
const PRIMITIVE_TAGS = new Set<string>(['boolean', 'byte', 'char', 'short', 'int', 'long', 'float', 'double']);
/** A wrapper's id prefix, as XMLEncoder names a shared value (Integer0), and its primitive. */
const KIND_OF_ID_PREFIX: Readonly<Record<string, PrimitiveKind>> = {
Boolean: 'boolean',
Byte: 'byte',
Character: 'char',
Short: 'short',
Integer: 'int',
Long: 'long',
Float: 'float',
Double: 'double',
};
export interface JavaArray {
readonly kind: 'array';
readonly componentClass: string;
@@ -262,6 +289,9 @@ function newObject(fields: Partial<JavaObject> & { className: string }): JavaObj
staticField: fields.staticField,
factoryMethod: fields.factoryMethod,
properties: new Map(),
propertyKinds: new Map(),
propertiesFromGetters: new Set(),
argKinds: [],
calls: [],
presetElements: new Map(),
presetEntries: new Map(),
@@ -271,6 +301,15 @@ function newObject(fields: Partial<JavaObject> & { className: string }): JavaObj
class Decoder {
private readonly ids = new Map<string, JavaValue>();
/** The primitive type of each id that names a primitive value. */
private readonly idKinds = new Map<string, PrimitiveKind>();
/** The primitive type an argument element stands for, if any. */
private kindOf(element: Element): PrimitiveKind | undefined {
if (PRIMITIVE_TAGS.has(element.name)) return element.name as PrimitiveKind;
const idref = element.attributes.idref;
return idref === undefined ? undefined : this.idKinds.get(idref);
}
topLevel(java: Element): JavaValue[] {
return java.children.map((child) => this.expression(child));
@@ -332,6 +371,10 @@ class Decoder {
private primitive(element: Element, value: JavaValue): JavaValue {
this.store(element.attributes.id, value);
const id = element.attributes.id;
// An element of its own (<int>), or a constant named by its wrapper (Integer0).
const kind = PRIMITIVE_TAGS.has(element.name) ? (element.name as PrimitiveKind) : KIND_OF_ID_PREFIX[id?.replace(/\d+$/, '') ?? ''];
if (id !== undefined && kind !== undefined) this.idKinds.set(id, kind);
return value;
}
@@ -382,7 +425,10 @@ class Decoder {
factoryMethod: attributes.method,
});
this.store(attributes.id, object);
for (const arg of args) object.args.push(this.expression(arg));
for (const arg of args) {
object.args.push(this.expression(arg));
object.argKinds.push(this.kindOf(arg));
}
this.applyStatements(
element.children.filter((child) => child.name === 'void'),
object,
@@ -400,12 +446,14 @@ class Decoder {
private statement(element: Element, target: JavaValue | undefined): JavaValue {
const attributes = element.attributes;
const args: JavaValue[] = [];
const kinds: (PrimitiveKind | undefined)[] = [];
const nested: Element[] = [];
for (const child of element.children) {
if (child.name === 'void') {
nested.push(child);
} else {
args.push(this.expression(child));
kinds.push(this.kindOf(child));
}
}
@@ -416,17 +464,23 @@ class Decoder {
value =
args.length === 0
? this.getProperty(target, attributes.property, attributes.id)
: this.setProperty(target, attributes.property, args[0]!);
: this.setProperty(target, attributes.property, args[0]!, kinds[0]);
} else if (attributes.index !== undefined) {
const index = Number.parseInt(attributes.index, 10);
value = args.length === 0 ? this.getIndex(target, index, attributes.id) : this.setIndex(target, index, args[0]!);
} else if (attributes.method !== undefined) {
value = this.call(target, attributes.method, args, attributes.id);
value = this.call(target, attributes.method, args, attributes.id, kinds);
} else if (attributes.idref !== undefined) {
value = this.ids.get(attributes.idref) ?? null;
}
this.store(attributes.id, value);
// A getter's primitive result, shared by id: its wrapper names the type.
const prefix = attributes.id?.replace(/\d+$/, '');
const kind = prefix === undefined ? undefined : KIND_OF_ID_PREFIX[prefix];
if (kind !== undefined && (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'boolean')) {
this.idKinds.set(attributes.id!, kind);
}
this.applyStatements(nested, value);
return value;
}
@@ -439,10 +493,16 @@ class Decoder {
return newObject({ className, id, args, factoryMethod: method });
}
private call(target: JavaValue | undefined, method: string, args: JavaValue[], id: string | undefined): JavaValue {
private call(
target: JavaValue | undefined,
method: string,
args: JavaValue[],
id: string | undefined,
kinds: (PrimitiveKind | 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 === 'set' && args.length === 2) return this.setProperty(args[0]!, field.name, args[1]!, kinds[1]);
if (method === 'get' && args.length === 1) return this.getProperty(args[0]!, field.name, id);
}
if (args.length === 0) {
@@ -450,15 +510,20 @@ class Decoder {
if (property !== undefined) return this.getProperty(target, property, id);
}
if (isJavaObject(target)) {
target.calls.push({ method, args });
target.calls.push(kinds.some((kind) => kind !== undefined) ? { method, args, kinds } : { method, args });
if (method === 'get' && args.length === 1 && typeof args[0] === 'number') return this.getIndex(target, args[0], id);
if (method === 'get' && args.length === 1 && typeof args[0] === 'string') return this.getEntry(target, args[0], id);
}
return null;
}
private setProperty(target: JavaValue | undefined, property: string, value: JavaValue): JavaValue {
if (isJavaObject(target)) target.properties.set(property, value);
private setProperty(target: JavaValue | undefined, property: string, value: JavaValue, kind?: PrimitiveKind): JavaValue {
if (isJavaObject(target)) {
target.properties.set(property, value);
target.propertiesFromGetters.delete(property);
if (kind === undefined) target.propertyKinds.delete(property);
else target.propertyKinds.set(property, kind);
}
return null;
}
@@ -482,6 +547,7 @@ class Decoder {
}
const placeholder = newObject({ className: prefix ?? 'unknown', id, fromGetter: true });
target.properties.set(property, placeholder);
target.propertiesFromGetters.add(property);
return placeholder;
}
+30 -6
View File
@@ -28,7 +28,9 @@
// not know Java's constructors and sees a placeholder per path; they are
// merged here.
import { captureBean } from './beanCapture.js';
import { isDomainName } from './dnsHosts.js';
import type { BeanObject } from './javaBeansWriter.js';
import {
EMAIL_CLIENT_FILE,
EMAIL_SERVER_FILE,
@@ -130,6 +132,13 @@ const SERVER_APPLICATIONS = new Set<string>([
'filius.software.www.WebServer',
]);
/** An application of the project's own: its key, class and bean, which refers to its node's system software by a mark. */
export interface FlsOwnApplication {
key: string | null;
javaClass: string;
bean: BeanObject;
}
/** An installed application with the settings the model reads. */
export interface FlsApplication {
/** The key in installierteAnwendungen — the class name in every corpus file. */
@@ -199,6 +208,12 @@ export interface FlsSystemSoftware {
modem?: FlsModem;
/** Rechner, Notebook: the installed applications, in the file's order. */
applications?: FlsApplication[];
/**
* Rechner, Notebook: the installed applications FILIUS does not ship — a
* project's own, compiled into projekt/anwendungen — kept as their beans,
* to be written again as the file had them.
*/
ownApplications?: FlsOwnApplication[];
/**
* Every node with an IP stack: the root directory (named "root" by Java's
* constructor) as the file has it. A router's or home router's holds only
@@ -232,7 +247,7 @@ export function readSystemSoftware(device: JavaObject, javaClass: FlsNodeClass,
case 'Notebook':
result.ssid = optionalString(value('ssid'), 'ssid', warn) ?? null;
result.fileSystem = fileSystem(value('dateisystem'), warn);
result.applications = applications(value('installierteAnwendungen'), result.fileSystem, warn);
[result.applications, result.ownApplications] = applications(value('installierteAnwendungen'), result.fileSystem, software, warn);
break;
case 'Vermittlungsrechner':
result.ripEnabled = boolean(value('ripEnabled'), false, 'ripEnabled', warn);
@@ -279,11 +294,18 @@ function modemAddress(value: string | null | undefined): string | null {
/**
* installierteAnwendungen: key → application. A class FILIUS does not ship —
* a project's own application, compiled into projekt/anwendungen — is left
* out: this reader cannot run Java classes.
* a project's own application, compiled into projekt/anwendungen — cannot
* run here; it is kept as its bean (beanCapture.ts), or left out with a
* warning where the bean cannot be written again.
*/
function applications(map: JavaValue | undefined, root: FlsFileTree, warn: Warn): FlsApplication[] {
function applications(
map: JavaValue | undefined,
root: FlsFileTree,
software: JavaObject | undefined,
warn: Warn,
): [FlsApplication[], FlsOwnApplication[]] {
const result: FlsApplication[] = [];
const own: FlsOwnApplication[] = [];
for (const [key, application] of mapEntries(map)) {
if (!isJavaObject(application)) {
warn(`application ${describe(key)} is ${describe(application)}, left out`);
@@ -291,7 +313,9 @@ function applications(map: JavaValue | undefined, root: FlsFileTree, warn: Warn)
}
const javaClass = application.className;
if (!(FILIUS_APPLICATION_CLASSES as readonly string[]).includes(javaClass)) {
warn(`application ${javaClass} is not part of FILIUS (a project's own application), left out`);
const captured = captureBean(application, software);
if ('bean' in captured) own.push({ key: typeof key === 'string' ? key : null, javaClass, bean: captured.bean });
else warn(`application ${javaClass}, a project's own, cannot be written again: ${captured.failure}; left out`);
continue;
}
const simple = simpleClassName(javaClass);
@@ -348,7 +372,7 @@ function applications(map: JavaValue | undefined, root: FlsFileTree, warn: Warn)
}
result.push(entry);
}
return result;
return [result, own];
}
/**
+17 -7
View File
@@ -19,7 +19,8 @@
// the web server, the peer-to-peer application and the e-mail server make
// their directories only where the files lack them, and the DNS server reads
// /dns/hosts when it is installed. Each is put under its key and given the
// system software, as installApp does.
// system software, as installApp does. A project's own application is
// written as the file had it (beanCapture.ts).
//
// E-mail: the server's domain and bean accounts with their mailboxes, the
// client's three folders and address book. The client's account is no bean
@@ -33,6 +34,7 @@
// FILIUS to the same topology and system software (topologyWriter.test.ts,
// tools/java-oracle).
import { withSystemSoftware } from './beanCapture.js';
import {
encodeJavaBeans,
int,
@@ -165,7 +167,8 @@ function device(node: NodeToWrite, ports: BeanValue[]): BeanObject {
);
}
// Named for the applications, which are given it.
const system = (node.system.applications?.length ?? 0) > 0 ? reference('SystemSoftware') : undefined;
const applicationCount = (node.system.applications?.length ?? 0) + (node.system.ownApplications?.length ?? 0);
const system = applicationCount > 0 ? reference('SystemSoftware') : undefined;
const systemStatementList = systemStatements(node.system, node.javaClass, system);
statements.push(
system === undefined
@@ -198,13 +201,20 @@ function systemStatements(system: SystemToWrite, javaClass: FlsNodeClass, self:
});
}
if (system.fileSystem !== undefined) statements.push(fileSystemStatement(system.fileSystem, system.templateFiles !== false));
if (self !== undefined && system.applications !== undefined) {
if (self !== undefined) {
statements.push({
get: 'installierteAnwendungen',
statements: system.applications.map((application) => ({
call: 'put',
args: [application.key, object(application.javaClass, applicationStatements(application, self))],
})),
statements: [
...(system.applications ?? []).map((application): BeanStatement => ({
call: 'put',
args: [application.key, object(application.javaClass, applicationStatements(application, self))],
})),
// A project's own: its bean as the file had it, on this node's system software.
...(system.ownApplications ?? []).map((application): BeanStatement => ({
call: 'put',
args: [application.key, withSystemSoftware(application.bean, self)],
})),
],
});
}
if (system.dhcpServer !== undefined) {
+118
View File
@@ -0,0 +1,118 @@
// A decoded bean written again (beanCapture.ts): decoded, captured, written
// and decoded once more, it holds the same values with the same Java types —
// so XMLDecoder calls the same setters with the same arguments. Java's own
// check is in topologyWriter.test.ts and exportProject.test.ts: the corpus's
// own applications, loaded by FILIUS from the files written again.
import { describe, expect, it } from 'vitest';
import { captureBean, SYSTEM_SOFTWARE_CLASS, withSystemSoftware } from '../src/beanCapture.js';
import { decodeJavaBeans, isJavaObject, type JavaObject, type JavaValue } from '../src/javabeans.js';
import { encodeJavaBeans, object, reference, type BeanObject } from '../src/javaBeansWriter.js';
function doc(body: string): string {
return `<?xml version="1.0" encoding="UTF-8"?>\n<java version="17" class="java.beans.XMLDecoder">\n${body}\n</java>\n`;
}
function bean(value: JavaValue | undefined): JavaObject {
if (!isJavaObject(value)) throw new Error(`not an object: ${JSON.stringify(value)}`);
return value;
}
/** What the decoder keeps of an object, by value: class, arguments, properties and calls with their types. */
function shape(value: JavaValue, seen = new Set<JavaObject>()): unknown {
if (!isJavaObject(value)) return typeof value === 'bigint' ? `${value}n` : value;
if (seen.has(value)) return { shared: value.className };
seen.add(value);
return {
className: value.className,
staticField: value.staticField,
fromGetter: value.fromGetter,
args: value.args.map((arg) => shape(arg, seen)),
argKinds: value.argKinds,
properties: [...value.properties].map(([name, each]) => [name, shape(each, seen), value.propertyKinds.get(name)]),
presetElements: [...value.presetElements].map(([index, each]) => [index, shape(each, seen)]),
presetEntries: [...value.presetEntries].map(([key, each]) => [key, shape(each, seen)]),
calls: value.calls.filter((call) => call.method !== 'get').map((call) => [call.method, call.args.map((arg) => shape(arg, seen)), call.kinds]),
};
}
/** The application of a file: the second value, the first being the system software it refers to. */
const APPLICATION = doc(`<object class="filius.software.system.Betriebssystem" id="Betriebssystem0"/>
<object class="example.OwnApplication">
<short>3</short><char>x</char>
<void property="systemSoftware"><object idref="Betriebssystem0"/></void>
<void property="aktiv"><boolean>true</boolean></void>
<void property="count"><long>9007199254740993</long></void>
<void property="small"><byte>-2</byte></void>
<void property="ratio"><float>0.5</float></void>
<void property="exact"><double>0.1</double></void>
<void property="letter"><char>y</char></void>
<void property="port"><int>55555</int></void>
<void property="name"><string>Thread-7</string></void>
<void property="nothing"><null/></void>
<void property="type"><class>java.lang.String</class></void>
<void property="names"><array class="java.lang.String" length="2"><void index="0"><string>a</string></void><void index="1"><string>b</string></void></array></void>
<void property="first"><object class="java.util.ArrayList" id="ArrayList0"><void method="add"><int>1</int></void></object></void>
<void property="second"><object idref="ArrayList0"/></void>
<void property="alignment"><object class="javax.swing.SwingConstants" field="CENTER"/></void>
<void property="settings"><void property="depth"><int>4</int></void></void>
<void property="list"><void index="0"><void property="label"><string>constructed</string></void></void></void>
<void property="table"><void method="put"><string>k</string><long>5</long></void></void>
</object>`);
describe('captureBean', () => {
it('writes a bean again with the same values and Java types', () => {
const [system, application] = decodeJavaBeans(APPLICATION);
const captured = captureBean(bean(application), system);
if (!('bean' in captured)) throw new Error(captured.failure);
const written = withSystemSoftware(captured.bean, object('filius.software.system.Betriebssystem', [], []));
const [again] = decodeJavaBeans(encodeJavaBeans([written]));
const { properties: _original, ...rest } = shape(application!) as { properties: [string, unknown, unknown][] };
const { properties: _again, ...restAgain } = shape(again!) as { properties: [string, unknown, unknown][] };
expect(restAgain).toEqual(rest);
// The system software is another object now, the one it was given. A
// static constant the decoder answers with its measured value comes back
// as that value, SwingConstants.CENTER as the int it is.
const withoutSystem = (properties: [string, unknown, unknown][]) => properties.filter(([name]) => name !== 'systemSoftware');
const expected = withoutSystem((shape(application!) as { properties: [string, unknown, unknown][] }).properties).map(
([name, value, kind]) => (name === 'alignment' ? [name, value, 'int'] : [name, value, kind]),
);
expect(withoutSystem((shape(again!) as { properties: [string, unknown, unknown][] }).properties)).toEqual(expected);
expect(bean(bean(again).properties.get('systemSoftware')).className).toBe('filius.software.system.Betriebssystem');
// Shared within the bean stays shared.
expect(bean(again).properties.get('first')).toBe(bean(again).properties.get('second'));
});
it("marks the node's system software and puts the given one in its place", () => {
const [system, application] = decodeJavaBeans(APPLICATION);
const captured = captureBean(bean(application), system) as { bean: BeanObject };
const mark = captured.bean.statements!.find((statement) => 'set' in statement && statement.set === 'systemSoftware');
expect(mark).toEqual({ set: 'systemSoftware', value: { kind: 'object', className: SYSTEM_SOFTWARE_CLASS } });
const self = reference('SystemSoftware');
const replaced = withSystemSoftware(captured.bean, self) as BeanObject;
expect(replaced.statements!.find((statement) => 'set' in statement && statement.set === 'systemSoftware')).toEqual({ set: 'systemSoftware', value: self });
// The captured bean itself is left as it was.
expect(captured.bean.statements!).toContainEqual(mark);
});
it('refuses what it cannot write again, and says why', () => {
/** The last value of the file captured, the first being the system software. */
const failure = (body: string) => {
const values = decodeJavaBeans(doc(`<object class="filius.software.system.Betriebssystem" id="Betriebssystem0"/>${body}`));
return captureBean(bean(values.at(-1)), values[0]);
};
expect(failure(`<object class="example.A"><void property="x"><object idref="Missing0"/></void></object>`)).toEqual({
failure: 'a value is unresolved (idref Missing0 is not defined)',
});
expect(
failure(`<object class="example.A"><void property="x"><object class="example.Factory" method="create"/></void></object>`),
).toEqual({ failure: 'example.Factory.create makes an object this writer cannot' });
// A folder of the file system, reached elsewhere by a getter: a copy would not be that folder.
expect(
failure(`<object class="example.B"><void id="DefaultMutableTreeNode0" property="root"><void property="x"><int>1</int></void></void></object>
<object class="example.A"><void property="folder"><object idref="DefaultMutableTreeNode0"/></void></object>`),
).toEqual({ failure: 'it refers to DefaultMutableTreeNode, which a getter reaches elsewhere in the file' });
});
});
+4 -12
View File
@@ -1,16 +1,8 @@
// Warnings the corpus is expected to produce, shared by the corpus tests.
/**
* Two examples install an application of their own, compiled into the
* archive (projekt/anwendungen). Java loads it from there; this reader
* cannot run it — and the Java oracle, reading only konfiguration.xml, does
* not see it either.
* None since the reader keeps a project's own applications as their beans
* (beanCapture.ts); until then the two examples that install one warned
* that it was left out.
*/
export const EXPECTED_WARNINGS: Record<string, string[]> = {
'java-examples/anwendung_komplett.fls': [
"node 1: application filius.software.clientserver.Accumulator is not part of FILIUS (a project's own application), left out",
],
'java-examples/multicast_server.fls': [
"node 1: application filius.software.clientserver.Broadcast is not part of FILIUS (a project's own application), left out",
],
};
export const EXPECTED_WARNINGS: Record<string, string[]> = {};
+16 -16
View File
@@ -765,13 +765,13 @@ describe('a model beyond the corpus, as Java loads it', () => {
}),
// In the order of Java's key.
applications: [
{ key: 'filius.software.clientserver.ClientBaustein', class: 'filius.software.clientserver.ClientBaustein', zielIPAdresse: '10.0.0.9', zielPort: 7777 },
{ key: 'filius.software.clientserver.ServerBaustein', class: 'filius.software.clientserver.ServerBaustein', aktiv: true, port: 7777 },
{ key: 'filius.software.dateiaustausch.PeerToPeerAnwendung', class: 'filius.software.dateiaustausch.PeerToPeerAnwendung', maxTeilnehmerZahl: 7 },
{ key: 'filius.software.dns.DNSServer', class: 'filius.software.dns.DNSServer', aktiv: true, port: 53, recursiveResolutionEnabled: true },
{ key: 'filius.software.clientserver.ClientBaustein', class: 'filius.software.clientserver.ClientBaustein', onItsSystem: true, zielIPAdresse: '10.0.0.9', zielPort: 7777 },
{ key: 'filius.software.clientserver.ServerBaustein', class: 'filius.software.clientserver.ServerBaustein', onItsSystem: true, aktiv: true, port: 7777 },
{ key: 'filius.software.dateiaustausch.PeerToPeerAnwendung', class: 'filius.software.dateiaustausch.PeerToPeerAnwendung', onItsSystem: true, maxTeilnehmerZahl: 7 },
{ key: 'filius.software.dns.DNSServer', class: 'filius.software.dns.DNSServer', onItsSystem: true, aktiv: true, port: 53, recursiveResolutionEnabled: true },
{
key: 'filius.software.email.EmailAnwendung',
class: 'filius.software.email.EmailAnwendung',
class: 'filius.software.email.EmailAnwendung', onItsSystem: true,
received: [javaMail()],
sent: [javaMail({ versendet: true })],
drafts: [javaMail({ betreff: '', text: '', empfaenger: [] })],
@@ -792,7 +792,7 @@ describe('a model beyond the corpus, as Java loads it', () => {
},
{
key: 'filius.software.email.EmailServer',
class: 'filius.software.email.EmailServer',
class: 'filius.software.email.EmailServer', onItsSystem: true,
aktiv: true,
mailDomain: 'schule.de',
beanAccounts: [serverAccount([javaMail()])],
@@ -801,7 +801,7 @@ describe('a model beyond the corpus, as Java loads it', () => {
},
{
key: 'filius.software.firewall.Firewall',
class: 'filius.software.firewall.Firewall',
class: 'filius.software.firewall.Firewall', onItsSystem: true,
activated: false,
defaultPolicy: 1,
dropICMP: false,
@@ -809,12 +809,12 @@ describe('a model beyond the corpus, as Java loads it', () => {
filterUdp: true,
rules: [['10.0.0.0', '255.0.0.0', '', '', 22, 6, 1]],
},
{ key: 'filius.software.lokal.FileExplorer', class: 'filius.software.lokal.FileExplorer' },
{ key: 'filius.software.lokal.ImageViewer', class: 'filius.software.lokal.ImageViewer' },
{ key: 'filius.software.lokal.Terminal', class: 'filius.software.lokal.Terminal' },
{ key: 'filius.software.lokal.TextEditor', class: 'filius.software.lokal.TextEditor' },
{ key: 'filius.software.www.WebBrowser', class: 'filius.software.www.WebBrowser' },
{ key: 'filius.software.www.WebServer', class: 'filius.software.www.WebServer', aktiv: true, port: 8080, useVHost: true },
{ key: 'filius.software.lokal.FileExplorer', class: 'filius.software.lokal.FileExplorer', onItsSystem: true },
{ key: 'filius.software.lokal.ImageViewer', class: 'filius.software.lokal.ImageViewer', onItsSystem: true },
{ key: 'filius.software.lokal.Terminal', class: 'filius.software.lokal.Terminal', onItsSystem: true },
{ key: 'filius.software.lokal.TextEditor', class: 'filius.software.lokal.TextEditor', onItsSystem: true },
{ key: 'filius.software.www.WebBrowser', class: 'filius.software.www.WebBrowser', onItsSystem: true },
{ key: 'filius.software.www.WebServer', class: 'filius.software.www.WebServer', onItsSystem: true, aktiv: true, port: 8080, useVHost: true },
],
},
},
@@ -843,11 +843,11 @@ describe('a model beyond the corpus, as Java loads it', () => {
fileSystem: { directory: 'root', children: [{ directory: 'peer2peer', children: [] }] },
applications: [
// Without a setting: no target (null), the model's 3 peers, Java's personal firewall.
{ key: 'filius.software.clientserver.ClientBaustein', class: 'filius.software.clientserver.ClientBaustein', zielIPAdresse: null, zielPort: 55_555 },
{ key: 'filius.software.dateiaustausch.PeerToPeerAnwendung', class: 'filius.software.dateiaustausch.PeerToPeerAnwendung', maxTeilnehmerZahl: 3 },
{ key: 'filius.software.clientserver.ClientBaustein', class: 'filius.software.clientserver.ClientBaustein', onItsSystem: true, zielIPAdresse: null, zielPort: 55_555 },
{ key: 'filius.software.dateiaustausch.PeerToPeerAnwendung', class: 'filius.software.dateiaustausch.PeerToPeerAnwendung', onItsSystem: true, maxTeilnehmerZahl: 3 },
{
key: 'filius.software.firewall.Firewall',
class: 'filius.software.firewall.Firewall',
class: 'filius.software.firewall.Firewall', onItsSystem: true,
activated: true,
defaultPolicy: 0,
dropICMP: false,
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -525,7 +525,7 @@ function flsNode(javaClass: FlsNode['javaClass'], fields: Partial<FlsNode> = {})
portCount: 0,
system: {},
source: { kind: 'object', className: javaClass, id: undefined, args: [], staticField: undefined,
factoryMethod: undefined, properties: new Map(), calls: [], presetElements: new Map(), presetEntries: new Map(), fromGetter: false },
factoryMethod: undefined, properties: new Map(), propertyKinds: new Map(), propertiesFromGetters: new Set(), argKinds: [], calls: [], presetElements: new Map(), presetEntries: new Map(), fromGetter: false },
...fields,
};
}
+22
View File
@@ -92,6 +92,28 @@ describe('XMLDecoder rules', () => {
expect(object(rectangle).properties.get('y')).toBe(true);
});
it('keeps the Java type of each primitive a setter, a constructor or a call gets', () => {
// A shared getter result, as in the corpus: a button's measured icon text gap.
const [, bean] = decodeJavaBeans(
doc(`<object class="filius.gui.netzwerksicht.JSidebarButton"><void id="Integer0" property="iconTextGap"/></object>
<object class="example.Bean"><short>3</short><char>x</char>
<void property="count"><long>7</long></void>
<void property="ratio"><float>0.5</float></void>
<void property="gap"><object idref="Integer0"/></void>
<void property="shared"><byte id="Byte0">2</byte></void>
<void property="again"><object idref="Byte0"/></void>
<void property="text"><string>7</string></void>
<void property="count2"><int>1</int></void>
<void property="count2"><string>no longer a number</string></void>
<void method="put"><string>key</string><double>1.0</double></void>
</object>`),
);
expect(object(bean).argKinds).toEqual(['short', 'char']);
expect(Object.fromEntries(object(bean).propertyKinds)).toEqual({ count: 'long', ratio: 'float', gap: 'int', shared: 'byte', again: 'byte' });
expect(object(bean).calls).toEqual([{ method: 'put', args: ['key', 1], kinds: [undefined, 'double'] }]);
});
it('says "unresolved" for a shared getter value it has no measurement for', () => {
const [, holder] = decodeJavaBeans(
doc(`<object class="com.example.Unknown"><void id="Integer0" property="size"/></object>
+27 -3
View File
@@ -1,7 +1,7 @@
// A project's own applications (classes under projekt/anwendungen): Java
// installs them when it loads the whole archive (tools/java-oracle, laden);
// this package reads, writes and exports them in a later step (B2). Until
// then the tests compare Java's views without them.
// installs them when it loads the whole archive (tools/java-oracle, laden).
// The reader keeps them as beans, without knowing what Java makes of them;
// its test compares them by key and class.
import { FILIUS_APPLICATION_CLASSES } from '../src/index.js';
@@ -32,6 +32,30 @@ export function withoutOwnApplications<T>(view: T): T {
};
}
/** Java's view with each own application as its key, class and whether it runs on its node's system software. */
export function withOwnApplicationsByClass<T>(view: T): T {
const { nodes } = view as View;
if (nodes === undefined) return view;
return {
...view,
nodes: nodes.map((node) =>
node.system.applications === undefined
? node
: {
...node,
system: {
...node.system,
applications: (node.system.applications as { key: string; class: string }[]).map((application) =>
(FILIUS_APPLICATION_CLASSES as readonly string[]).includes(application.class)
? application
: { key: application.key, class: application.class, onItsSystem: (application as { onItsSystem?: boolean }).onItsSystem },
),
},
},
),
};
}
/** Whether Java's view has an own application installed. */
export function hasOwnApplications(view: unknown): boolean {
return JSON.stringify(withoutOwnApplications(view)) !== JSON.stringify(view);
+25 -3
View File
@@ -21,7 +21,9 @@ import {
type JavaEmailAccount,
} from '../src/index.js';
import { EXPECTED_WARNINGS } from './expectedWarnings.js';
import { hasOwnApplications, OWN_APPLICATION_FILES, withoutOwnApplications } from './ownApplications.js';
import { SYSTEM_SOFTWARE_CLASS } from '../src/beanCapture.js';
import type { BeanValue } from '../src/javaBeansWriter.js';
import { hasOwnApplications, OWN_APPLICATION_FILES, withOwnApplicationsByClass } from './ownApplications.js';
import { javaEmail, javaEmailAccount } from './javaEmailNames.js';
const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '../../../corpus');
@@ -69,12 +71,18 @@ function javaFileTree(tree: FlsFileTree): unknown {
}
/** FlsOracle.applications' shape: Java's property names, sorted by key. */
function isSystemSoftwareMark(value: BeanValue): boolean {
return value !== null && typeof value === 'object' && value.kind === 'object' && value.className === SYSTEM_SOFTWARE_CLASS;
}
function javaApplications(applications: FlsApplication[]): unknown[] {
return [...applications]
.sort((lhs, rhs) => ((lhs.key ?? '') < (rhs.key ?? '') ? -1 : 1))
.map((application) => ({
key: application.key,
class: application.javaClass,
// The reader's applications are its node's: each file gives them its system software.
onItsSystem: true,
...(application.isActive === undefined ? {} : { aktiv: application.isActive, port: application.port }),
...(application.targetPort === undefined
? {}
@@ -178,7 +186,21 @@ function asJavaView(topology: FlsTopology, javaBounds: (number[] | null)[]) {
...(node.system.modem === undefined ? {} : { modem: javaModem(node.system.modem) }),
...(node.system.fileSystem === undefined ? {} : { fileSystem: javaFileTree(node.system.fileSystem) }),
...('templateFiles' in node.system ? { templateFiles: undefined } : {}),
...(node.system.applications === undefined ? {} : { applications: javaApplications(node.system.applications) }),
...(node.system.applications === undefined
? {}
: {
applications: [
...(javaApplications(node.system.applications) as { key: string | null }[]),
...(node.system.ownApplications ?? []).map((application) => ({
key: application.key,
class: application.javaClass,
onItsSystem: application.bean.statements?.some(
(statement) => 'set' in statement && statement.set === 'systemSoftware' && isSystemSoftwareMark(statement.value),
),
})),
].sort((lhs, rhs) => ((lhs.key ?? '') < (rhs.key ?? '') ? -1 : 1)),
}),
...('ownApplications' in node.system ? { ownApplications: undefined } : {}),
...(node.system.firewall === undefined
? {}
: {
@@ -252,7 +274,7 @@ describe('corpus: the topology as Java loads it', () => {
const expected = javaView[name] as { nodes: { bounds: number[] | null }[] };
expect(asJavaView(topology, expected.nodes.map((node) => node.bounds))).toEqual(
withoutTemplateFiles(withoutOwnApplications(expected), topology),
withoutTemplateFiles(withOwnApplicationsByClass(expected), topology),
);
expect(topology.warnings).toEqual(EXPECTED_WARNINGS[name] ?? []);
});
+18 -18
View File
@@ -18,7 +18,6 @@ import { describe, expect, it } from 'vitest';
import { decodeJavaBeans, readFlsArchive, readTopology, writeFlsArchive } from '../src/index.js';
import type { JavaEmail } from '../src/javaEmail.js';
import { writeTopologyXml, type TopologyToWrite } from '../src/topologyWriter.js';
import { withoutOwnApplications } from './ownApplications.js';
const here = dirname(fileURLToPath(import.meta.url));
const corpusRoot = join(here, '../../../corpus');
@@ -94,8 +93,10 @@ describe('the topology written again, as Java loads it', () => {
for (const file of flsFiles(corpusRoot)) {
const name = relative(corpusRoot, file);
it(name, () => {
const topology = readTopology(decodeJavaBeans(readFlsArchive(new Uint8Array(readFileSync(file))).configurationXml));
const archive = writeFlsArchive(new TextEncoder().encode(writeTopologyXml(topology)));
const contents = readFlsArchive(new Uint8Array(readFileSync(file)));
const topology = readTopology(decodeJavaBeans(contents.configurationXml));
// With the archive's other entries: the classes of the project's own applications.
const archive = writeFlsArchive(new TextEncoder().encode(writeTopologyXml(topology)), contents.supplementalEntries);
if (process.env.UPDATE_WRITTEN === '1') {
mkdirSync(dirname(join(writtenRoot, name)), { recursive: true });
writeFileSync(join(writtenRoot, name), archive);
@@ -104,8 +105,7 @@ describe('the topology written again, as Java loads it', () => {
expect(java?.sha256, 'written.java.json is not about this output: UPDATE_WRITTEN=1, then npm run oracle:java').toBe(
createHash('sha256').update(archive).digest('hex'),
);
// The written file has no archive entries: Java installs no own application (B2).
expect(topologyOf(java!)).toEqual(topologyOf(withRecipientsRead(withoutOwnApplications(original[name]!)) as JavaFile));
expect(topologyOf(java!)).toEqual(topologyOf(withRecipientsRead(original[name]!) as JavaFile));
});
}
@@ -448,13 +448,13 @@ describe('a topology beyond the corpus, as Java loads it', () => {
},
// In the order of Java's key.
applications: [
{ key: 'filius.software.clientserver.ClientBaustein', class: 'filius.software.clientserver.ClientBaustein', zielIPAdresse: '10.0.0.9', zielPort: 7777 },
{ key: 'filius.software.clientserver.ServerBaustein', class: 'filius.software.clientserver.ServerBaustein', aktiv: true, port: 7777 },
{ key: 'filius.software.dateiaustausch.PeerToPeerAnwendung', class: 'filius.software.dateiaustausch.PeerToPeerAnwendung', maxTeilnehmerZahl: 7 },
{ key: 'filius.software.dns.DNSServer', class: 'filius.software.dns.DNSServer', aktiv: true, port: 5353, recursiveResolutionEnabled: true },
{ key: 'filius.software.clientserver.ClientBaustein', class: 'filius.software.clientserver.ClientBaustein', onItsSystem: true, zielIPAdresse: '10.0.0.9', zielPort: 7777 },
{ key: 'filius.software.clientserver.ServerBaustein', class: 'filius.software.clientserver.ServerBaustein', onItsSystem: true, aktiv: true, port: 7777 },
{ key: 'filius.software.dateiaustausch.PeerToPeerAnwendung', class: 'filius.software.dateiaustausch.PeerToPeerAnwendung', onItsSystem: true, maxTeilnehmerZahl: 7 },
{ key: 'filius.software.dns.DNSServer', class: 'filius.software.dns.DNSServer', onItsSystem: true, aktiv: true, port: 5353, recursiveResolutionEnabled: true },
{
key: 'filius.software.email.EmailAnwendung',
class: 'filius.software.email.EmailAnwendung',
class: 'filius.software.email.EmailAnwendung', onItsSystem: true,
received: [javaMail()],
sent: [javaMail({ versendet: true, neu: false })],
drafts: [javaMail({ betreff: null, text: null, dateReceived: null, empfaenger: [] })],
@@ -464,7 +464,7 @@ describe('a topology beyond the corpus, as Java loads it', () => {
},
{
key: 'filius.software.email.EmailServer',
class: 'filius.software.email.EmailServer',
class: 'filius.software.email.EmailServer', onItsSystem: true,
aktiv: true,
mailDomain: 'schule.de',
beanAccounts: [
@@ -486,7 +486,7 @@ describe('a topology beyond the corpus, as Java loads it', () => {
},
{
key: 'filius.software.firewall.Firewall',
class: 'filius.software.firewall.Firewall',
class: 'filius.software.firewall.Firewall', onItsSystem: true,
activated: false,
defaultPolicy: 1,
dropICMP: true,
@@ -494,12 +494,12 @@ describe('a topology beyond the corpus, as Java loads it', () => {
filterUdp: false,
rules: [['10.0.0.0', '255.0.0.0', '', '', 22, 6, 1]],
},
{ key: 'filius.software.lokal.FileExplorer', class: 'filius.software.lokal.FileExplorer' },
{ key: 'filius.software.lokal.ImageViewer', class: 'filius.software.lokal.ImageViewer' },
{ key: 'filius.software.lokal.Terminal', class: 'filius.software.lokal.Terminal' },
{ key: 'filius.software.lokal.TextEditor', class: 'filius.software.lokal.TextEditor' },
{ key: 'filius.software.www.WebBrowser', class: 'filius.software.www.WebBrowser' },
{ key: 'filius.software.www.WebServer', class: 'filius.software.www.WebServer', aktiv: true, port: 8080, useVHost: true },
{ key: 'filius.software.lokal.FileExplorer', class: 'filius.software.lokal.FileExplorer', onItsSystem: true },
{ key: 'filius.software.lokal.ImageViewer', class: 'filius.software.lokal.ImageViewer', onItsSystem: true },
{ key: 'filius.software.lokal.Terminal', class: 'filius.software.lokal.Terminal', onItsSystem: true },
{ key: 'filius.software.lokal.TextEditor', class: 'filius.software.lokal.TextEditor', onItsSystem: true },
{ key: 'filius.software.www.WebBrowser', class: 'filius.software.www.WebBrowser', onItsSystem: true },
{ key: 'filius.software.www.WebServer', class: 'filius.software.www.WebServer', onItsSystem: true, aktiv: true, port: 8080, useVHost: true },
],
},
},
File diff suppressed because it is too large Load Diff