feat: F4.8d the documentation mode's report as a PDF, as Java's

ReportRun reads Java's report back with iText: every piece of text with its
place, font, size and colour. The editor lays the report out by iText's
rules (A4, leading 1.5, loose chunks in the current line, tables 80 % wide
with padding 2, fixed traffic columns, page breaks) and draws it with
pdf-lib in the PDF's own Helvetica, widths without kerning as iText measures.
All device pages of Internet_Komplett, the first page and the data exchange
of einfaches_rechnernetz_komplett match Java piece by piece; Java's iText
reads our PDF with the same text in the same places. The sidebar gains
Java's "Report"; the frames come from the room by a short subscription.
Not adopted: true/false for DHCP (Java's own Ja/Nein), hash table orders,
an image running off the page, the report writing back into the model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 12:35:35 +00:00
co-authored by Claude Opus 5.5
parent 7e68414bb2
commit b8b999fd66
29 changed files with 7726 additions and 23 deletions
+106
View File
@@ -0,0 +1,106 @@
// What the report tells of each device (reportDevices) and which cards it
// lists (reportCards), on FILIUS' example Internet_Komplett — the whole of it
// is compared with Java's report in packages/editor/test/report.test.ts —
// and on changes a Java file can carry: a forwarding row whose destination
// is no address, a router's DNS server, a computer's DHCP server among its
// programs, a home router's port forwarding written with numbers, small
// letters or a protocol Java does not know.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import * as Y from 'yjs';
import { openProjectArchive } from '@mdg-filius/fls';
import { reportCards, reportDevices, writeProject } from '../src/index.js';
const corpus = join(dirname(fileURLToPath(import.meta.url)), '../../../corpus/java-examples');
const FILE = 'Internet_Komplett_mit_eMail_Webserver_Intranet_Portforwarding_Firewall_DHCP_DE.fls';
function docOf(file: string): Y.Doc {
const doc = new Y.Doc();
let next = 0;
writeProject(
doc,
openProjectArchive(new Uint8Array(readFileSync(join(corpus, file))), () => `id${next++}`),
);
return doc;
}
const nodeNamed = (doc: Y.Doc, name: string) => [...doc.getMap<Y.Map<unknown>>('nodes').entries()].find(([, node]) => node.get('name') === name)!;
const settings = (node: Y.Map<unknown>) => node.get('settings') as Y.Map<unknown>;
const row = (fields: Readonly<Record<string, string>>) => {
const map = new Y.Map<unknown>();
for (const [key, value] of Object.entries(fields)) map.set(key, value);
return map;
};
describe('the devices of the report', () => {
it('tells of the computers, routers and home routers in their order, not of the switches', () => {
const devices = reportDevices(docOf(FILE));
// 26 devices, four of them switches.
expect(devices.length).toBe(22);
expect(devices.map((device) => device.kind)).not.toContain('networkSwitch');
const server = devices.find((device) => device.label === 'DNS (8.8.8.8)')!;
expect(server).toMatchObject({ kind: 'pc', gateway: '8.0.0.1', dnsServer: '', dhcpClient: false, programs: ['dnsServer', 'textEditor', 'commandPrompt'] });
expect(server.dnsRecords![1]).toEqual({ domainName: 'gmx.de.', type: 'MX', rdata: 'mail.gmx.de.' });
expect(devices.find((device) => device.label === 'Firewall')).toMatchObject({ kind: 'gateway', portForwarding: [['TCP', '80', '10.0.0.1', '80']] });
});
it("leaves out a forwarding row whose destination is no address, as Java's report does", () => {
const doc = docOf(FILE);
const [id, provider] = nodeNamed(doc, 'Provider 1');
doc.transact(() => {
const routes = new Y.Array<Y.Map<unknown>>();
routes.push([
row({ destinationNetwork: 'kein Netz', subnetMask: '255.0.0.0', gateway: '2.0.0.2', interfaceIPAddress: '2.0.0.1' }),
row({ destinationNetwork: '9.0.0.0', subnetMask: '255.0.0.0', gateway: '2.0.0.2', interfaceIPAddress: '2.0.0.1' }),
]);
settings(provider).set('routes', routes);
});
const destinations = reportDevices(doc)
.find((device) => device.id === id)!
.routes.map((route) => route.destinationNetwork);
expect(destinations).toContain('9.0.0.0');
expect(destinations).not.toContain('kein Netz');
});
it("writes a router's DNS server, as Java's does", () => {
const doc = docOf(FILE);
const [id, provider] = nodeNamed(doc, 'Provider 1');
doc.transact(() => (settings(provider).get('device') as Y.Map<unknown> | undefined)?.set('dnsServer', '8.8.8.8') ?? settings(provider).set('device', row({ dnsServer: '8.8.8.8' })));
expect(reportDevices(doc).find((device) => device.id === id)!.dnsServer).toBe('8.8.8.8');
});
it('leaves the DHCP server out of the programs, as it has no window', () => {
const doc = docOf(FILE);
const [id, server] = nodeNamed(doc, 'DNS (8.8.8.8)');
doc.transact(() => (server.get('programs') as Y.Map<unknown>).set('dhcpServer', new Y.Map()));
expect(reportDevices(doc).find((device) => device.id === id)!.programs).toEqual(['dnsServer', 'textEditor', 'commandPrompt']);
});
it('writes the port forwarding Java reports: TCP and UDP, also written 6, 17 or in small letters; no other protocol', () => {
const doc = docOf(FILE);
const [id, firewall] = nodeNamed(doc, 'Firewall');
doc.transact(() => {
const rows = new Y.Array<Y.Map<unknown>>();
for (const protocolValue of ['6', '17', 'udp', 'Tcp', 'ICMP']) rows.push([row({ protocolValue, publicPortValue: '8080', lanIPAddress: '10.0.0.1', lanPortValue: '80' })]);
settings(firewall).set('portForwarding', rows);
});
expect(
reportDevices(doc)
.find((device) => device.id === id)!
.portForwarding!.map(([protocol]) => protocol),
).toEqual(['TCP', 'UDP', 'UDP', 'TCP']);
});
});
describe('the cards of the report', () => {
it("lists every card with an address, under its device's label, in the devices' order", () => {
const cards = reportCards(docOf(FILE));
expect(cards[0]).toMatchObject({ label: '10.10.20.11', ipAddress: '10.10.20.11' });
expect(cards.filter((card) => card.label === 'Provider 1').map((card) => card.ipAddress)).toEqual(['42.0.0.1', '6.0.0.2', '2.0.0.1']);
// No switch has one.
expect(cards.some((card) => card.label.startsWith('Switch'))).toBe(false);
expect(cards.length).toBe(reportDevices(docOf(FILE)).reduce((sum, device) => sum + device.cards.length, 0));
});
});