feat(fls): export the editor model's topology to Java's view

exportProject.ts is the counterpart of importProject.ts: from the editor
model to the Java view topologyWriter.ts writes. Step X1, the topology:
each node becomes the Java device of its kind with name, position and label
flags; its ports become interfaces or ports; each link a cable drawn
between its nodes; each documentation item a GUIDocuItem. A port's MAC is
the one the simulation uses (the file's, or one derived from the port id);
a router's gateway and DNS server go to every interface, as Java's own
setters do (Java reads the first interface's only). A host with a wireless
card joins a switch by a wireless cable, the only wireless connection Java
makes. The import now enables a host's wireless by Java's card flag, also
without an SSID, where Swift enables it for every SSID.

Checked by Java: every corpus file, imported, exported and written, loads
in Java FILIUS to the topology of the original up to named departures of
the model (a derived MAC where Java rolls one at each load; documentation in
render order, with the model's empty text, Dialog 12 and minimum size where
Java has none or less). A synthetic model adds a label with IP and MAC,
positions between pixels, a five-port switch, a wireless notebook, a
three-interface router and a half-transparent bold text. 17 mutation
probes, each loaded by Java, were all caught.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 08:58:29 +00:00
co-authored by Claude Opus 5.5
parent 968dbbc34a
commit 6dc1286866
10 changed files with 15728 additions and 18 deletions
+3 -1
View File
@@ -37,7 +37,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
| `src/zip.ts` | `Persistence/TopologyFLSArchive.swift` (Fehler, Grenzen, Pfadregeln, ZIP-Leser/-Schreiber, CRC-32) | übertragen; DEFLATE über `fflate`, mit Apples Abschneide-Verhalten nachgebildet; Pfadfaltung (NFC, Breite, volles Case-Folding) an 41 Paaren gegen das Swift-Orakel gemessen | | `src/zip.ts` | `Persistence/TopologyFLSArchive.swift` (Fehler, Grenzen, Pfadregeln, ZIP-Leser/-Schreiber, CRC-32) | übertragen; DEFLATE über `fflate`, mit Apples Abschneide-Verhalten nachgebildet; Pfadfaltung (NFC, Breite, volles Case-Folding) an 41 Paaren gegen das Swift-Orakel gemessen |
| `src/archive.ts` | ebenda, Archiv-Teil von Import und Export | übertragen, ohne die XML-Ebene | | `src/archive.ts` | ebenda, Archiv-Teil von Import und Export | übertragen, ohne die XML-Ebene |
| `src/javabeans.ts`, `src/javaDefaults.json` | — | neu: XMLEncoder-Leser nach den Regeln von `java.beans.XMLDecoder` (Swift liest elementweise und verliert geteilte Werte); Getter-Standardwerte und statische Konstanten mit `tools/java-oracle` gemessen | | `src/javabeans.ts`, `src/javaDefaults.json` | — | neu: XMLEncoder-Leser nach den Regeln von `java.beans.XMLDecoder` (Swift liest elementweise und verliert geteilte Werte); Getter-Standardwerte und statische Konstanten mit `tools/java-oracle` gemessen |
| `src/importProject.ts` | `Persistence/TopologyProjectStore.swift` (Knoten, Ports, Kabel, IP-Konfiguration, Switch/WLAN, Routen, RIP, Firewall, DHCP, Portweiterleitung, Modem, Dateisysteme, Programme, Doku-Elemente des Imports) | übertragen, Eingabe ist Javas Sicht: Name und Port-Anzahl wie Java, abgelehnte Adresse → Swift-Standard, Router-Standardgateway wie Java (Swift verliert es), jede Router-Firewall eingetragen, Konfigurationstexte wie in Swift getrimmt (`swiftTrim`), Rechner ohne Dateisystem leer wie in Java (Swift: Demo-Dateien), alle Programme bleiben (Swift verwirft sechs), Webserver-Dateien bleiben in `/webserver` (Swift: `/www`), keine versteckten Zustandsdateien im Dateisystem, DNS-Einträge so, wie Javas Server sie aus `/dns/hosts` beantwortet; gegen Swifts importiertes Modell auf 13 Dateien geprüft | | `src/importProject.ts` | `Persistence/TopologyProjectStore.swift` (Knoten, Ports, Kabel, IP-Konfiguration, Switch/WLAN, Routen, RIP, Firewall, DHCP, Portweiterleitung, Modem, Dateisysteme, Programme, Doku-Elemente des Imports) | übertragen, Eingabe ist Javas Sicht: Name und Port-Anzahl wie Java, abgelehnte Adresse → Swift-Standard, Router-Standardgateway wie Java (Swift verliert es), jede Router-Firewall eingetragen, Konfigurationstexte wie in Swift getrimmt (`swiftTrim`), Rechner ohne Dateisystem leer wie in Java (Swift: Demo-Dateien), alle Programme bleiben (Swift verwirft sechs), Webserver-Dateien bleiben in `/webserver` (Swift: `/www`), keine versteckten Zustandsdateien im Dateisystem, DNS-Einträge so, wie Javas Server sie aus `/dns/hosts` beantwortet, WLAN eines Rechners eingeschaltet nach Javas Funk-Merkmal der Karte (auch ohne SSID; Swift: jede SSID); gegen Swifts importiertes Modell auf 13 Dateien geprüft |
| `test/importProject.test.ts` | — | neu: Swifts importiertes Modell (13 Dateien, begründete Abweichungen; im Dateisystem ausgenommen nur Swifts versteckte Zustandsdateien), Vollständigkeit aller 23, Pings im Kern (über einen Router; über drei Router mit manuellen Routen und Router-Gateways) | | `test/importProject.test.ts` | — | neu: Swifts importiertes Modell (13 Dateien, begründete Abweichungen; im Dateisystem ausgenommen nur Swifts versteckte Zustandsdateien), Vollständigkeit aller 23, Pings im Kern (über einen Router; über drei Router mit manuellen Routen und Router-Gateways) |
| `src/systemSoftware.ts` | Java `SwitchFirmware`, `Betriebssystem`, `VermittlungsrechnerBetriebssystem`, `GatewayFirmware`, `Weiterleitungstabelle`, `Firewall`, `FirewallRule`, `DHCPServer`, `ModemFirmware`, `Dateisystem`, `Datei` | neu nach Java: SSID, Lernzeit, RIP, manuelle Routen, Router-Firewall (an allen Stellen, an denen die Datei sie erreicht), DHCP (ein kaputter statischer Eintrag fällt allein weg, Java verliert alle folgenden), statische NAT, Modem, Dateisystem (bei Router und Heimrouter das, was die Datei zu den Vorlagen des Konstruktors hinzufügt, oder eine eigene Wurzel, die sie ersetzt), installierte Programme von Rechner und Notebook; `Character.isWhitespace` am JDK gemessen; gegen Java geprüft | | `src/systemSoftware.ts` | Java `SwitchFirmware`, `Betriebssystem`, `VermittlungsrechnerBetriebssystem`, `GatewayFirmware`, `Weiterleitungstabelle`, `Firewall`, `FirewallRule`, `DHCPServer`, `ModemFirmware`, `Dateisystem`, `Datei` | neu nach Java: SSID, Lernzeit, RIP, manuelle Routen, Router-Firewall (an allen Stellen, an denen die Datei sie erreicht), DHCP (ein kaputter statischer Eintrag fällt allein weg, Java verliert alle folgenden), statische NAT, Modem, Dateisystem (bei Router und Heimrouter das, was die Datei zu den Vorlagen des Konstruktors hinzufügt, oder eine eigene Wurzel, die sie ersetzt), installierte Programme von Rechner und Notebook; `Character.isWhitespace` am JDK gemessen; gegen Java geprüft |
| `src/javaIp.ts` | Java `IP.inetAton`/`ipCheck` | neu nach Java; an 46 Fällen mit Java gemessen | | `src/javaIp.ts` | Java `IP.inetAton`/`ipCheck` | neu nach Java; an 46 Fällen mit Java gemessen |
@@ -51,6 +51,8 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
| `test/javaBeansWriter.test.ts`, `test/beans/` | — | neu: 11 Dateien des Schreibers, von Javas `XMLDecoder` gelesen und beschrieben; Erwartungen von Hand | | `test/javaBeansWriter.test.ts`, `test/beans/` | — | neu: 11 Dateien des Schreibers, von Javas `XMLDecoder` gelesen und beschrieben; Erwartungen von Hand |
| `src/topologyWriter.ts` | Java `SzenarioVerwaltung.netzwerkSpeichern`, Konstruktoren von `NetzwerkInterface`, `Port`, `GUIKabelItem` und der Systemsoftware | neu nach Java: `konfiguration.xml` aus Javas Sicht — Geräte, Schnittstellen (vom Konstruktor angelegte über Getter, weitere per `add`), Ports über Getter-Verweise, Kabel samt Zeichen-Enden (`kabelpanel`), Doku-Elemente; Systemsoftware über ihre Setter: SSID, Lernzeit, RIP, manuelle Routen, DHCP-Client und -Server, statische NAT, Modem, Router-Firewall (über das `konfig`-Plug-in des Webservers, wie XMLEncoder sie erreicht); Dateisysteme (an die Wurzel des Konstruktors angehängt wie bei XMLEncoder, beim Router hinter dessen Vorlagen); installierte Programme nach den Dateien (Webserver, Tauschbörse und E-Mail-Server legen ihre Ordner nur an, wo sie fehlen; der DNS-Server liest `/dns/hosts` beim Installieren), je unter ihrem Schlüssel mit der Systemsoftware wie `installApp`, samt Einstellungen und persönlicher Firewall; E-Mail: Domain und Bean-Konten des Servers samt Postfächern, Ordner und Adressbuch des Clients (sein Konto steht in `/konten.txt`, einer Datei); Javas Fehler nicht übernommen: ein Empfänger, den eine ältere Datei als Zeichenkette hält, wird als der `AddressEntry` geschrieben, den Javas `AddressEntry(String)` daraus macht (Java legt die Zeichenkette ungeprüft in die typisierte Liste, sein E-Mail-Fenster scheitert daran), ein `null`-Empfänger fällt weg; Kopf „Filius version: 2.14.0 (mdg-filius)“ | | `src/topologyWriter.ts` | Java `SzenarioVerwaltung.netzwerkSpeichern`, Konstruktoren von `NetzwerkInterface`, `Port`, `GUIKabelItem` und der Systemsoftware | neu nach Java: `konfiguration.xml` aus Javas Sicht — Geräte, Schnittstellen (vom Konstruktor angelegte über Getter, weitere per `add`), Ports über Getter-Verweise, Kabel samt Zeichen-Enden (`kabelpanel`), Doku-Elemente; Systemsoftware über ihre Setter: SSID, Lernzeit, RIP, manuelle Routen, DHCP-Client und -Server, statische NAT, Modem, Router-Firewall (über das `konfig`-Plug-in des Webservers, wie XMLEncoder sie erreicht); Dateisysteme (an die Wurzel des Konstruktors angehängt wie bei XMLEncoder, beim Router hinter dessen Vorlagen); installierte Programme nach den Dateien (Webserver, Tauschbörse und E-Mail-Server legen ihre Ordner nur an, wo sie fehlen; der DNS-Server liest `/dns/hosts` beim Installieren), je unter ihrem Schlüssel mit der Systemsoftware wie `installApp`, samt Einstellungen und persönlicher Firewall; E-Mail: Domain und Bean-Konten des Servers samt Postfächern, Ordner und Adressbuch des Clients (sein Konto steht in `/konten.txt`, einer Datei); Javas Fehler nicht übernommen: ein Empfänger, den eine ältere Datei als Zeichenkette hält, wird als der `AddressEntry` geschrieben, den Javas `AddressEntry(String)` daraus macht (Java legt die Zeichenkette ungeprüft in die typisierte Liste, sein E-Mail-Fenster scheitert daran), ein `null`-Empfänger fällt weg; Kopf „Filius version: 2.14.0 (mdg-filius)“ |
| `test/topologyWriter.test.ts`, `test/written.java.json` | — | neu: alle 23 Korpus-Dateien gelesen und neu geschrieben, von Java geladen — Topologie, Systemsoftware, Dateisysteme und Programme samt E-Mail wie beim Original (bis auf die benannte Abweichung bei Empfängern als Zeichenkette, nur in `man-in-the-middle.fls`) (Java gegen Java); dazu eine künstliche Topologie mit dem, was der Korpus nicht hat (u. a. eingeschaltete Router-Firewall mit Regeln jedes Protokolls, alle Firewall-Schalter abseits der Vorgabe, Modem mit eigenem Port und Ziel, verschachtelter Dateibaum mit umbenannter Wurzel, namenlosem Ordner, Datei ohne Typ und Inhalt mit CR/CRLF und Zeichen jenseits der BMP, Heimrouter mit eigener Wurzel, jedes Filius-Programm mit Einstellungen abseits der Vorgabe samt persönlicher Firewall, von Java angelegte Ordner `/peer2peer` und `/mailserver`, E-Mail-Server und -Client mit Konto, Postfach, drei Ordnern, Adressbuch und Empfängern aller Formen) | | `test/topologyWriter.test.ts`, `test/written.java.json` | — | neu: alle 23 Korpus-Dateien gelesen und neu geschrieben, von Java geladen — Topologie, Systemsoftware, Dateisysteme und Programme samt E-Mail wie beim Original (bis auf die benannte Abweichung bei Empfängern als Zeichenkette, nur in `man-in-the-middle.fls`) (Java gegen Java); dazu eine künstliche Topologie mit dem, was der Korpus nicht hat (u. a. eingeschaltete Router-Firewall mit Regeln jedes Protokolls, alle Firewall-Schalter abseits der Vorgabe, Modem mit eigenem Port und Ziel, verschachtelter Dateibaum mit umbenannter Wurzel, namenlosem Ordner, Datei ohne Typ und Inhalt mit CR/CRLF und Zeichen jenseits der BMP, Heimrouter mit eigener Wurzel, jedes Filius-Programm mit Einstellungen abseits der Vorgabe samt persönlicher Firewall, von Java angelegte Ordner `/peer2peer` und `/mailserver`, E-Mail-Server und -Client mit Konto, Postfach, drei Ordnern, Adressbuch und Empfängern aller Formen) |
| `src/exportProject.ts` | Gegenstück zu `importProject.ts`; Java `JHostKonfiguration.updateWifiConnection`, `InternetKnotenBetriebssystem.getStandardGateway`/`setStandardGateway` | neu: Editor-Modell → Javas Sicht für den Schreiber, Schritt X1 Topologie — Geräte, Schnittstellen (MAC wie in der Simulation: aus der Datei oder aus der Port-ID abgeleitet), Kabel samt Funk (Karte drahtlos und Switch, wie Java verbindet), Gateway und DNS an allen Schnittstellen wie Javas eigene Setter, Doku-Elemente |
| `test/exportProject.test.ts`, `test/exported.java.json` | — | neu: alle 23 Korpus-Dateien importiert, exportiert, geschrieben und von Java geladen — Topologie wie beim Original bis auf benannte Abweichungen (abgeleiteter MAC statt gewürfeltem, Doku in Zeichenreihenfolge mit Text, Schrift und Mindestgröße des Modells); dazu ein künstliches Modell, das Java ebenfalls lädt |
| `src/importEmail.ts` | `Persistence/TopologyProjectStore.swift` (`registerJavaEmailConfigurations`, `assignedJavaEmailMessages`) | übertragen, Eingabe ist Javas Sicht: Server-Konten aus `/mailserver/konten.txt`, ohne Datei aus der Bean-Liste (Java verlöre sie); Client-Konto aus `/konten.txt`, ohne Datei aus der älteren `kontoListe` (Filius 2.14 liest sie nicht mehr); Konten und Nachrichten bleiben, wo Swift verwirft, was seine Prüfung nicht besteht; Vor- und Nachname getrennt; Empfangszeit nur aus der Datei (Swift nummeriert); gegen Swifts Modell geprüft | | `src/importEmail.ts` | `Persistence/TopologyProjectStore.swift` (`registerJavaEmailConfigurations`, `assignedJavaEmailMessages`) | übertragen, Eingabe ist Javas Sicht: Server-Konten aus `/mailserver/konten.txt`, ohne Datei aus der Bean-Liste (Java verlöre sie); Client-Konto aus `/konten.txt`, ohne Datei aus der älteren `kontoListe` (Filius 2.14 liest sie nicht mehr); Konten und Nachrichten bleiben, wo Swift verwirft, was seine Prüfung nicht besteht; Vor- und Nachname getrennt; Empfangszeit nur aus der Datei (Swift nummeriert); gegen Swifts Modell geprüft |
| `test/importEmail.test.ts`, `test/email-*.swift.json` | `State/TopologyEmail.swift` (`decodeJavaServer`, `decodeJavaClient`) als Vergleich | neu: dieselben 43 Kontodateien mit Swifts Leser; wie Swift bis auf benannte Fälle, in denen Swift prüft und die ganze Datei verwirft, wo Java liest | | `test/importEmail.test.ts`, `test/email-*.swift.json` | `State/TopologyEmail.swift` (`decodeJavaServer`, `decodeJavaClient`) als Vergleich | neu: dieselben 43 Kontodateien mit Swifts Leser; wie Swift bis auf benannte Fälle, in denen Swift prüft und die ganze Datei verwirft, wo Java liest |
| `src/javaFileSystem.ts`, `src/javaString.ts` | Java `Dateisystem.holeDatei`, `String.trim`/`split`/`equalsIgnoreCase` | neu nach Java; die Groß-/Kleinschreibung gegenüber ASCII am JDK über die ganze BMP gemessen | | `src/javaFileSystem.ts`, `src/javaString.ts` | Java `Dateisystem.holeDatei`, `String.trim`/`split`/`equalsIgnoreCase` | neu nach Java; die Groß-/Kleinschreibung gegenüber ASCII am JDK über die ganze BMP gemessen |
+1
View File
@@ -1 +1,2 @@
test/written/ test/written/
test/exported/
+192
View File
@@ -0,0 +1,192 @@
// From the editor model to Java's view of a .fls file, which
// topologyWriter.ts writes — the counterpart of importProject.ts.
//
// The topology: each node becomes the Java device of its kind, with its name,
// position and label policy; its ports become interfaces (PC, notebook,
// router, home router) or ports (switch, modem) of Java's device; each link
// becomes a cable between those, drawn between its two nodes; each
// documentation item a GUIDocuItem. A host whose wireless is enabled has a
// wireless card, and its link to a switch is a wireless cable — the only
// wireless connection Java makes (JHostKonfiguration.updateWifiConnection). System software, files and programs
// follow in later steps.
//
// What the model keeps is written as it keeps it, and so differs from the
// Java file it may have been imported from where the import decides (named
// in exportProject.test.ts):
// - a port's MAC address is the one the simulation uses: the file's, or
// one derived from the port's id (effectiveMacAddress) — stable across
// saves, where Java rolls a new one at each load;
// - a router's or home router's gateway and DNS server are written to every
// interface, as Java's own setters (setStandardGateway, setDNSServer) do:
// Java reads them from the first interface only (getStandardGateway,
// getDNSServer), and the model keeps no others;
// - a documentation item always has a text and a font, bold or plain: where
// Java has none, the empty text and Dialog 12 the model gives it.
//
// Checked by Java: every corpus file, imported, exported and written, loads in
// Java FILIUS to the topology of the original up to those departures.
import {
effectiveMacAddress,
interfaceKey,
type DeviceConfiguration,
type DocumentationItem,
type HostLabelPolicy,
type HostWirelessConfiguration,
type Id,
type InterfaceConfiguration,
type InterfaceKey,
type NodeKind,
type TopologyGraph,
type TopologyNode,
} from '@mdg-filius/core';
import type { FlsDocumentationItem, FlsInterface, FlsLink, FlsNodeClass } from './topology.js';
import type { NodeToWrite, TopologyToWrite } from './topologyWriter.js';
/** The parts of the editor model the topology is made of. */
export interface ProjectToExport {
readonly graph: TopologyGraph;
readonly deviceConfigurations: ReadonlyMap<Id, DeviceConfiguration>;
readonly interfaceConfigurations: ReadonlyMap<InterfaceKey, InterfaceConfiguration>;
readonly hostWirelessConfigurations: ReadonlyMap<Id, HostWirelessConfiguration>;
readonly documentationItems: readonly DocumentationItem[];
}
export interface ExportedTopology {
readonly topology: TopologyToWrite;
readonly warnings: string[];
}
const CLASS_OF_KIND: Readonly<Record<Exclude<NodeKind, 'unsupported'>, FlsNodeClass>> = {
pc: 'Rechner',
notebook: 'Notebook',
networkSwitch: 'Switch',
router: 'Vermittlungsrechner',
gateway: 'Gateway',
remoteLink: 'Modem',
};
export function exportProject(project: ProjectToExport): ExportedTopology {
const warnings: string[] = [];
const exported = project.graph.nodes.filter((node) => {
if (node.kind !== 'unsupported') return true;
warnings.push(`node '${node.displayName}': a kind Java FILIUS does not have, left out`);
return false;
});
const indexOfNode = new Map(exported.map((node, index) => [node.id, index]));
const nodes = exported.map((node) => exportNode(node, project));
const links: FlsLink[] = [];
project.graph.links.forEach((link, index) => {
const source = indexOfNode.get(link.sourceNodeId);
const target = indexOfNode.get(link.targetNodeId);
const sourcePort = source === undefined ? -1 : exported[source]!.ports.findIndex((port) => port.id === link.sourcePortId);
const targetPort = target === undefined ? -1 : exported[target]!.ports.findIndex((port) => port.id === link.targetPortId);
if (source === undefined || target === undefined || sourcePort < 0 || targetPort < 0) {
warnings.push(`link ${index + 1}: an end is not a port of an exported node, left out`);
return;
}
links.push({
ends: [
{ node: source, port: sourcePort },
{ node: target, port: targetPort },
],
wireless: joinsWirelessly(exported[source]!, exported[target]!, project),
panel: [source, target],
});
});
return {
topology: { nodes, links, documentation: project.documentationItems.map(exportDocumentationItem) },
warnings,
};
}
function exportNode(node: TopologyNode, project: ProjectToExport): NodeToWrite {
const javaClass = CLASS_OF_KIND[node.kind as Exclude<NodeKind, 'unsupported'>];
const interfaces = exportInterfaces(node, project);
return {
javaClass,
name: node.displayName,
x: Math.round(node.position.x),
y: Math.round(node.position.y),
useIpAsName: showsIp(node.hostLabelPolicy),
useMacAsName: showsMac(node.hostLabelPolicy),
interfaces,
portCount: javaClass === 'Switch' || javaClass === 'Modem' ? node.ports.length : interfaces.length,
system: {},
};
}
function wirelessCard(node: TopologyNode, project: ProjectToExport): boolean {
return (node.kind === 'pc' || node.kind === 'notebook') && project.hostWirelessConfigurations.get(node.id)?.isEnabled === true;
}
/** A host with a wireless card and a switch. */
function joinsWirelessly(first: TopologyNode, second: TopologyNode, project: ProjectToExport): boolean {
return (
(wirelessCard(first, project) && second.kind === 'networkSwitch') ||
(wirelessCard(second, project) && first.kind === 'networkSwitch')
);
}
function showsIp(policy: HostLabelPolicy): boolean {
return policy === 'ipAddress' || policy === 'ipAndMAC';
}
function showsMac(policy: HostLabelPolicy): boolean {
return policy === 'macAddress' || policy === 'ipAndMAC';
}
/**
* The interfaces of a PC, notebook, router or home router. An address the
* model has none for is left to Java's constructor (null).
*/
function exportInterfaces(node: TopologyNode, project: ProjectToExport): FlsInterface[] {
const device = project.deviceConfigurations.get(node.id);
switch (node.kind) {
case 'pc':
case 'notebook':
return node.ports.slice(0, 1).map((port) => ({
ip: device?.ipAddress ?? null,
mask: device?.subnetMask ?? null,
mac: effectiveMacAddress(port),
gateway: device?.defaultGateway ?? '',
dns: device?.dnsServer ?? '',
wireless: wirelessCard(node, project),
}));
case 'router':
case 'gateway':
return node.ports.map((port) => {
const configuration = project.interfaceConfigurations.get(interfaceKey(node.id, port.id));
return {
ip: configuration?.ipAddress ?? null,
mask: configuration?.subnetMask ?? null,
mac: effectiveMacAddress(port),
gateway: device?.defaultGateway ?? '',
dns: device?.dnsServer ?? '',
wireless: false,
};
});
default:
return [];
}
}
/** Components 0…1 as Java's 0…255. */
function channel(value: number): number {
return Math.round(value * 255);
}
function exportDocumentationItem(item: DocumentationItem): FlsDocumentationItem {
return {
type: item.kind === 'rectangle' ? 1 : 2,
text: item.text,
x: Math.round(item.frame.x),
y: Math.round(item.frame.y),
width: Math.round(item.frame.width),
height: Math.round(item.frame.height),
color: [channel(item.color.red), channel(item.color.green), channel(item.color.blue), channel(item.color.alpha)],
font: { name: item.fontName, style: item.isBold ? 1 : 0, size: item.fontSize },
};
}
+8 -2
View File
@@ -22,6 +22,9 @@
// without one the core behaves as with an inactive one). // without one the core behaves as with an inactive one).
// - configuration text is trimmed, as Swift trims every value it reads; // - configuration text is trimmed, as Swift trims every value it reads;
// names stay as Java has them; // names stay as Java has them;
// - a host's wireless: enabled when its card is wireless, as in Java, which
// joins a network only then — also without an SSID; Swift enables it for
// every SSID;
// - a Java modem becomes an in-project remote link pair keyed by its port, // - a Java modem becomes an in-project remote link pair keyed by its port,
// as in Swift — two FILIUS instances on different hosts are not joined. // as in Swift — two FILIUS instances on different hosts are not joined.
// - a PC or notebook whose file has no file system gets an empty one, as in // - a PC or notebook whose file has no file system gets an empty one, as in
@@ -564,8 +567,11 @@ function registerSystemConfigurations(
retentionTimeMilliseconds: source.system.retentionTime ?? 300_000, retentionTimeMilliseconds: source.system.retentionTime ?? 300_000,
}); });
} }
if ((node.kind === 'pc' || node.kind === 'notebook') && ssid !== '') { // Java joins a wireless network when the card is wireless and an SSID is set
system.hostWirelessConfigurations.set(node.id, { isEnabled: true, ssid }); // (JHostKonfiguration.updateWifiConnection); the model keeps both.
const wireless = source.interfaces[0]?.wireless === true;
if ((node.kind === 'pc' || node.kind === 'notebook') && (ssid !== '' || wireless)) {
system.hostWirelessConfigurations.set(node.id, { isEnabled: wireless, ssid });
} }
const routes: ManualRoute[] = []; const routes: ManualRoute[] = [];
for (const row of source.system.manualRoutes ?? []) { for (const row of source.system.manualRoutes ?? []) {
+1
View File
@@ -11,3 +11,4 @@ export * from './systemSoftware.js';
export * from './topology.js'; export * from './topology.js';
export * from './topologyWriter.js'; export * from './topologyWriter.js';
export * from './zip.js'; export * from './zip.js';
export * from './exportProject.js';
+348
View File
@@ -0,0 +1,348 @@
// Every corpus file, imported into the editor model and exported again:
// Java FILIUS loads the written file (tools/java-oracle, exported.java.json)
// to the topology of the original (corpus.java.json) — up to what the model
// decides differently, named below (DEPARTURES).
//
// UPDATE_EXPORTED=1 writes the files to test/exported/ (not checked in); the
// Java oracle then loads them and records each file's SHA-256, which must
// match what the export makes now.
import { createHash } from 'node:crypto';
import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { DOCUMENTATION_MINIMUM_SIZE, stableGeneratedMacAddress, type Id, type TopologyNode } from '@mdg-filius/core';
import { describe, expect, it } from 'vitest';
import {
decodeJavaBeans,
exportProject,
importProject,
readFlsArchive,
readTopology,
writeFlsArchive,
type ProjectToExport,
} from '../src/index.js';
import { writeTopologyXml } from '../src/topologyWriter.js';
const here = dirname(fileURLToPath(import.meta.url));
const corpusRoot = join(here, '../../../corpus');
const exportedRoot = join(here, 'exported');
function flsFiles(directory: string): string[] {
return readdirSync(directory).flatMap((name) => {
const path = join(directory, name);
if (statSync(path).isDirectory()) return flsFiles(path);
return name.endsWith('.fls') ? [path] : [];
});
}
/** Ids a test can read, in the order they are made. */
function counter(): () => Id {
let next = 0;
return () => `00000000-0000-0000-0000-${String(++next).padStart(12, '0')}`;
}
type JavaNode = { class: string; name: string | null; bounds: number[]; useIpAsName?: boolean; useMacAsName?: boolean; ports: unknown };
type JavaFile = { success: boolean; nodes: JavaNode[]; cables: unknown[]; documentation: unknown[]; sha256?: string };
const original = JSON.parse(readFileSync(join(corpusRoot, 'corpus.java.json'), 'utf8')) as Record<string, JavaFile>;
const exportedPath = join(here, 'exported.java.json');
const exported = JSON.parse(readFileSync(exportedPath, 'utf8')) as Record<string, JavaFile>;
/** The topology of Java's view: the label's position, not its size. */
function topologyOf(view: JavaFile) {
return {
success: view.success,
nodes: view.nodes.map((node) => ({
class: node.class,
name: node.name,
position: node.bounds.slice(0, 2),
useIpAsName: node.useIpAsName,
useMacAsName: node.useMacAsName,
ports: node.ports,
})),
cables: view.cables,
documentation: view.documentation,
};
}
/**
* What the model decides differently from the Java file it was imported
* from, each applied to Java's view of the original:
* - generatedMac: a card whose MAC the file lacked (Java rolls a new one at
* each load) has the one the simulation uses, derived from its port id;
* - documentation: the items in the model's render order (rectangles below
* text), a missing text empty, a missing font Dialog 12, and at least the
* editor's smallest size (DOCUMENTATION_MINIMUM_SIZE).
*/
const DEPARTURES = {
generatedMac(view: JavaFile, macs: (string | undefined)[][]): JavaFile {
return {
...view,
nodes: view.nodes.map((node, index) => ({
...node,
ports: (node.ports as Record<string, unknown>[]).map((port, portIndex) =>
'mac' in port && port.mac === null ? { ...port, mac: macs[index]![portIndex] } : port,
),
})),
};
},
documentation(view: JavaFile): JavaFile {
const items = (view.documentation as Record<string, unknown>[]).map((item) => {
const [x, y, width, height] = item.bounds as number[];
return {
...item,
text: item.text ?? '',
bounds: [x, y, Math.max(width!, DOCUMENTATION_MINIMUM_SIZE.width), Math.max(height!, DOCUMENTATION_MINIMUM_SIZE.height)],
font: item.font ?? ['Dialog', 12, 0],
};
});
return { ...view, documentation: sortedDocumentation(items) };
},
};
/** Documentation items in an order that does not depend on the list's. */
function sortedDocumentation(items: unknown[]): unknown[] {
return items.map((item) => JSON.stringify(item)).sort().map((text) => JSON.parse(text) as unknown);
}
/** Every port id each exported node's MAC could be derived from, by node and port. */
function portIds(project: ReturnType<typeof importProject>): Id[][] {
return project.graph.nodes.map((node) => node.ports.map((port) => port.id));
}
const departed = { generatedMac: new Set<string>(), documentation: new Set<string>() };
describe('the editor model exported, 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 project = importProject(topology, counter());
const { topology: toWrite } = exportProject(project);
const archive = writeFlsArchive(new TextEncoder().encode(writeTopologyXml(toWrite)));
if (process.env.UPDATE_EXPORTED === '1') {
mkdirSync(dirname(join(exportedRoot, name)), { recursive: true });
writeFileSync(join(exportedRoot, name), archive);
}
const java = exported[name];
expect(java?.sha256, 'exported.java.json is not about this output: UPDATE_EXPORTED=1, then npm run oracle:java').toBe(
createHash('sha256').update(archive).digest('hex'),
);
// A MAC the file lacked: the one derived from the port's id.
const ids = portIds(project);
const macs = toWrite.nodes.map((node, index) =>
node.interfaces.map((nic, portIndex) => {
const derived = stableGeneratedMacAddress(ids[index]![portIndex]!);
return nic.mac === derived ? derived : undefined;
}),
);
const source = original[name]!;
const withMacs = DEPARTURES.generatedMac(source, macs);
const expected = DEPARTURES.documentation(withMacs);
if (JSON.stringify(withMacs) !== JSON.stringify(source)) departed.generatedMac.add(name);
if (JSON.stringify(expected.documentation) !== JSON.stringify(source.documentation)) departed.documentation.add(name);
expect(topologyOf({ ...java!, documentation: sortedDocumentation(java!.documentation) })).toEqual(topologyOf(expected));
});
}
it('departs from Java where the model decides differently', () => {
expect(departed.generatedMac.size).toBeGreaterThan(0);
expect(departed.documentation.size).toBeGreaterThan(0);
});
});
// ── A model beyond the corpus, loaded by Java ──────────────────────────────
const port = (id: string) => ({ id, label: id, isOccupied: false });
/**
* What the corpus lacks: a label with IP and MAC, a position between
* pixels, a switch with five ports, a wireless notebook, a router with three
* interfaces and a gateway, a half-transparent bold text and a rectangle.
*/
const SYNTHETIC: ProjectToExport = {
graph: {
nodes: [
{ id: 'pc', kind: 'pc', displayName: 'PC', hostLabelPolicy: 'ipAndMAC', position: { x: 10.6, y: 20.2 }, ports: [port('p0')] },
{ id: 'sw', kind: 'networkSwitch', displayName: 'SW', hostLabelPolicy: 'manual', position: { x: 100, y: 20 }, ports: ['s0', 's1', 's2', 's3', 's4'].map(port) },
{ id: 'nb', kind: 'notebook', displayName: 'NB', hostLabelPolicy: 'macAddress', position: { x: 100, y: 120 }, ports: [port('n0')] },
{ id: 'rt', kind: 'router', displayName: 'RT', hostLabelPolicy: 'manual', position: { x: 200, y: 20 }, ports: ['r0', 'r1', 'r2'].map(port) },
],
links: [
{ id: 'l1', sourceNodeId: 'pc', sourcePortId: 'p0', targetNodeId: 'sw', targetPortId: 's4' },
{ id: 'l2', sourceNodeId: 'nb', sourcePortId: 'n0', targetNodeId: 'sw', targetPortId: 's0' },
{ id: 'l3', sourceNodeId: 'sw', sourcePortId: 's2', targetNodeId: 'rt', targetPortId: 'r1' },
],
},
deviceConfigurations: new Map([
['pc', { ipAddress: '10.0.0.1', subnetMask: '255.255.255.0', defaultGateway: '10.0.0.254', dnsServer: '10.0.0.53' }],
['nb', { ipAddress: '10.0.0.2', subnetMask: '255.255.255.0', defaultGateway: '', dnsServer: '' }],
['rt', { ipAddress: '10.0.0.254', subnetMask: '255.255.255.0', defaultGateway: '10.9.9.9', dnsServer: '10.0.0.53' }],
]),
interfaceConfigurations: new Map([
['rt/r0', { ipAddress: '10.1.0.1', subnetMask: '255.255.0.0' }],
['rt/r1', { ipAddress: '10.0.0.254', subnetMask: '255.255.255.0' }],
['rt/r2', { ipAddress: '10.2.0.1', subnetMask: '255.255.0.0' }],
]),
hostWirelessConfigurations: new Map([['nb', { isEnabled: true, ssid: 'net' }]]),
documentationItems: [
{
id: 'd1',
kind: 'rectangle',
frame: { x: 5, y: 6, width: 300, height: 200 },
text: '',
color: { red: 1, green: 0.5, blue: 0, alpha: 0.5 },
fontName: 'Dialog',
fontSize: 12,
isBold: false,
order: 0,
},
{
id: 'd2',
kind: 'text',
frame: { x: 40.4, y: 50.6, width: 120, height: 30 },
text: 'Netz A\nzweite Zeile',
color: { red: 0, green: 0, blue: 1, alpha: 1 },
fontName: 'SansSerif',
fontSize: 16,
isBold: true,
order: 1,
},
],
};
describe('a model beyond the corpus, as Java loads it', () => {
it('keeps what the corpus never has', () => {
const name = 'synthetic/model.fls';
const { topology: toWrite, warnings } = exportProject(SYNTHETIC);
const archive = writeFlsArchive(new TextEncoder().encode(writeTopologyXml(toWrite)));
if (process.env.UPDATE_EXPORTED === '1') {
mkdirSync(join(exportedRoot, 'synthetic'), { recursive: true });
writeFileSync(join(exportedRoot, name), archive);
}
const java = exported[name];
expect(java?.sha256, 'exported.java.json is not about this output: UPDATE_EXPORTED=1, then npm run oracle:java').toBe(
createHash('sha256').update(archive).digest('hex'),
);
expect(warnings).toEqual([]);
const card = (ip: string, mask: string, mac: string, gateway: string, dns: string, wireless = false) => ({ ip, mask, mac, gateway, dns, wireless });
expect(topologyOf(java!)).toEqual({
success: true,
nodes: [
{
class: 'Rechner',
name: 'PC',
position: [11, 20],
useIpAsName: true,
useMacAsName: true,
ports: [card('10.0.0.1', '255.255.255.0', stableGeneratedMacAddress('p0'), '10.0.0.254', '10.0.0.53')],
},
{ class: 'Switch', name: 'SW', position: [100, 20], useIpAsName: undefined, useMacAsName: undefined, ports: [{}, {}, {}, {}, {}] },
{
class: 'Notebook',
name: 'NB',
position: [100, 120],
useIpAsName: false,
useMacAsName: true,
ports: [card('10.0.0.2', '255.255.255.0', stableGeneratedMacAddress('n0'), '', '', true)],
},
{
class: 'Vermittlungsrechner',
name: 'RT',
position: [200, 20],
useIpAsName: undefined,
useMacAsName: undefined,
ports: [
card('10.1.0.1', '255.255.0.0', stableGeneratedMacAddress('r0'), '10.9.9.9', '10.0.0.53'),
card('10.0.0.254', '255.255.255.0', stableGeneratedMacAddress('r1'), '10.9.9.9', '10.0.0.53'),
card('10.2.0.1', '255.255.0.0', stableGeneratedMacAddress('r2'), '10.9.9.9', '10.0.0.53'),
],
},
],
cables: [
{ ends: [[0, 0], [1, 4]], wireless: false, panel: [0, 1] },
{ ends: [[2, 0], [1, 0]], wireless: true, panel: [2, 1] },
{ ends: [[1, 2], [3, 1]], wireless: false, panel: [1, 3] },
],
documentation: [
{ type: 1, text: '', bounds: [5, 6, 300, 200], color: [255, 128, 0, 128], font: ['Dialog', 12, 0] },
{ type: 2, text: 'Netz A\nzweite Zeile', bounds: [40, 51, 120, 30], color: [0, 0, 255, 255], font: ['SansSerif', 16, 1] },
],
});
});
});
// ── Rules the corpus does not exercise ─────────────────────────────────────
describe('exportProject rules', () => {
const node = (id: string, kind: TopologyNode['kind'], ports: string[], extra: Partial<TopologyNode> = {}): TopologyNode => ({
id,
kind,
displayName: id,
hostLabelPolicy: 'manual',
position: { x: 0, y: 0 },
ports: ports.map(port),
...extra,
});
const link = (id: string, source: [string, string], target: [string, string]) => ({
id,
sourceNodeId: source[0],
sourcePortId: source[1],
targetNodeId: target[0],
targetPortId: target[1],
});
const project = (nodes: TopologyNode[], links: ReturnType<typeof link>[], wireless: [string, boolean][] = []): ProjectToExport => ({
graph: { nodes, links },
deviceConfigurations: new Map(),
interfaceConfigurations: new Map(),
hostWirelessConfigurations: new Map(wireless.map(([id, isEnabled]) => [id, { isEnabled, ssid: 'net' }])),
documentationItems: [],
});
it('leaves out a node of a kind Java does not have, and the links to it', () => {
const { topology, warnings } = exportProject(
project(
[node('pc', 'pc', ['p0']), node('odd', 'unsupported', ['o0']), node('sw', 'networkSwitch', ['s0', 's1'])],
[link('l1', ['pc', 'p0'], ['odd', 'o0']), link('l2', ['pc', 'p0'], ['sw', 's1']), link('l3', ['pc', 'missing'], ['sw', 's0'])],
),
);
expect(topology.nodes.map((each) => each.javaClass)).toEqual(['Rechner', 'Switch']);
expect(topology.links).toEqual([{ ends: [{ node: 0, port: 0 }, { node: 1, port: 1 }], wireless: false, panel: [0, 1] }]);
expect(warnings).toEqual([
"node 'odd': a kind Java FILIUS does not have, left out",
'link 1: an end is not a port of an exported node, left out',
'link 3: an end is not a port of an exported node, left out',
]);
});
it('joins wirelessly only a host with a wireless card and a switch', () => {
const { topology } = exportProject(
project(
[
node('nb', 'notebook', ['n0']),
node('sw', 'networkSwitch', ['s0', 's1']),
node('nb2', 'notebook', ['m0']),
node('rt', 'router', ['r0']),
node('pc', 'pc', ['p0']),
],
[link('l1', ['sw', 's0'], ['nb', 'n0']), link('l2', ['nb2', 'm0'], ['rt', 'r0']), link('l3', ['pc', 'p0'], ['sw', 's1'])],
[
['nb', true],
['nb2', true],
['pc', false],
],
),
);
expect(topology.links.map((each) => each.wireless)).toEqual([true, false, false]);
expect(topology.nodes.map((each) => each.interfaces.map((nic) => nic.wireless))).toEqual([[true], [], [true], [false], [false]]);
});
it('rounds positions to Java ints and shows IP and MAC as the policy says', () => {
const { topology } = exportProject(
project([node('pc', 'pc', ['p0'], { position: { x: 10.5, y: -3.4 }, hostLabelPolicy: 'ipAndMAC' })], []),
);
expect(topology.nodes[0]).toMatchObject({ x: 11, y: -3, useIpAsName: true, useMacAsName: true, portCount: 1 });
expect(topology.nodes[0]!.interfaces[0]!.mac).toBe(stableGeneratedMacAddress('p0'));
});
});
File diff suppressed because it is too large Load Diff
+9 -3
View File
@@ -664,24 +664,30 @@ describe('importProject rules', () => {
]); ]);
}); });
it("gives a switch without SSID one from its id, and joins a host to a wireless network only with an SSID", () => { it("gives a switch without SSID one from its id, and keeps a host's wireless card and SSID", () => {
const wirelessNic = (ip: string) => ({ ...nic(ip), wireless: true });
const project = importProject( const project = importProject(
topology({ topology({
nodes: [ nodes: [
flsNode('Switch', { portCount: 24, system: { ssid: undefined, retentionTime: 2000 } }), flsNode('Switch', { portCount: 24, system: { ssid: undefined, retentionTime: 2000 } }),
flsNode('Switch', { portCount: 24, system: { ssid: ' MyWiFi ', retentionTime: 300_000 } }), flsNode('Switch', { portCount: 24, system: { ssid: ' MyWiFi ', retentionTime: 300_000 } }),
flsNode('Notebook', { interfaces: [nic('10.0.0.1')], system: { ssid: 'MyWiFi', manualRoutes: [] } }), flsNode('Notebook', { interfaces: [wirelessNic('10.0.0.1')], system: { ssid: 'MyWiFi', manualRoutes: [] } }),
flsNode('Rechner', { interfaces: [nic('10.0.0.2')], system: { ssid: ' ', manualRoutes: [] } }), flsNode('Rechner', { interfaces: [nic('10.0.0.2')], system: { ssid: ' ', manualRoutes: [] } }),
flsNode('Notebook', { interfaces: [nic('10.0.0.3')], system: { ssid: 'MyWiFi', manualRoutes: [] } }),
flsNode('Notebook', { interfaces: [wirelessNic('10.0.0.4')], system: { ssid: null, manualRoutes: [] } }),
], ],
}), }),
counter(), counter(),
); );
const [first, second, notebook, pc] = project.graph.nodes.map((node) => node.id); const [first, second, notebook, pc, wired, withoutNetwork] = project.graph.nodes.map((node) => node.id);
expect(project.switchConfigurations.get(first!)).toEqual({ ssid: defaultSwitchConfiguration(first!).ssid, retentionTimeMilliseconds: 2000 }); expect(project.switchConfigurations.get(first!)).toEqual({ ssid: defaultSwitchConfiguration(first!).ssid, retentionTimeMilliseconds: 2000 });
expect(project.switchConfigurations.get(second!)).toEqual({ ssid: 'MyWiFi', retentionTimeMilliseconds: 300_000 }); expect(project.switchConfigurations.get(second!)).toEqual({ ssid: 'MyWiFi', retentionTimeMilliseconds: 300_000 });
expect(project.hostWirelessConfigurations.get(notebook!)).toEqual({ isEnabled: true, ssid: 'MyWiFi' }); expect(project.hostWirelessConfigurations.get(notebook!)).toEqual({ isEnabled: true, ssid: 'MyWiFi' });
expect(project.hostWirelessConfigurations.has(pc!)).toBe(false); expect(project.hostWirelessConfigurations.has(pc!)).toBe(false);
// Java joins only with a wireless card: an SSID alone keeps the cable.
expect(project.hostWirelessConfigurations.get(wired!)).toEqual({ isEnabled: false, ssid: 'MyWiFi' });
expect(project.hostWirelessConfigurations.get(withoutNetwork!)).toEqual({ isEnabled: true, ssid: '' });
}); });
it('takes DHCP settings for hosts and home routers, trimmed, and only when they are not the defaults', () => { it('takes DHCP settings for hosts and home routers, trimmed, and only when they are not the defaults', () => {
+5 -3
View File
@@ -88,9 +88,11 @@ ist, wird still 3600.
## Was Java aus unseren Dateien lädt ## Was Java aus unseren Dateien lädt
`run.sh` lädt auch die Dateien unter `packages/fls/test/written/`, die `run.sh` lädt auch die Dateien unter `packages/fls/test/written/`, die
`topologyWriter.test.ts` mit `UPDATE_WRITTEN=1` schreibt (nicht `topologyWriter.test.ts` mit `UPDATE_WRITTEN=1` schreibt, und unter
eingecheckt), mit derselben Lademethode wie den Korpus, und schreibt `packages/fls/test/exported/`, die `exportProject.test.ts` mit
`packages/fls/test/written.java.json` samt SHA-256 jeder Datei `UPDATE_EXPORTED=1` schreibt (beide nicht eingecheckt), mit derselben
Lademethode wie den Korpus, und schreibt `written.java.json` bzw.
`exported.java.json` daneben, samt SHA-256 jeder Datei
(`--sha256`), damit der Test merkt, wenn der Befund nicht mehr zur Ausgabe (`--sha256`), damit der Test merkt, wenn der Befund nicht mehr zur Ausgabe
passt. Je Kabel steht auch `panel`: die Geräte, zwischen denen die passt. Je Kabel steht auch `panel`: die Geräte, zwischen denen die
Oberfläche es zeichnet (`JCablePanel.ziel1/ziel2`) — im Korpus bei allen 183 Oberfläche es zeichnet (`JCablePanel.ziel1/ziel2`) — im Korpus bei allen 183
+8 -6
View File
@@ -89,12 +89,14 @@ rm -f "$beans/beans.java.json"
HOME="$here/.build/home" xvfb-run -a java -cp "$classpath:$here/.build" BeansDump "$beans" "$beans/beans.java.json" 2>&1 | grep -v 'logback\|^$' || true HOME="$here/.build/home" xvfb-run -a java -cp "$classpath:$here/.build" BeansDump "$beans" "$beans/beans.java.json" 2>&1 | grep -v 'logback\|^$' || true
test -s "$beans/beans.java.json" test -s "$beans/beans.java.json"
# What Java loads from the files packages/fls writes (topologyWriter.test.ts # What Java loads from the files packages/fls writes: Java's view written
# writes them with UPDATE_WRITTEN=1; the files are not checked in, their # again (topologyWriter.test.ts, UPDATE_WRITTEN=1) and the editor model
# SHA-256 is). # exported (exportProject.test.ts, UPDATE_EXPORTED=1). The files are not
written="$root/packages/fls/test/written" # checked in, their SHA-256 is.
if [ -d "$written" ]; then for written in "$root/packages/fls/test/written" "$root/packages/fls/test/exported"; do
if [ -d "$written" ]; then
rm -f "$written.java.json" rm -f "$written.java.json"
HOME="$here/.build/home" xvfb-run -a java -cp "$classpath:$here/.build" FlsOracle "$written" "$written.java.json" --sha256 2>&1 | grep -v 'logback\|^$' || true HOME="$here/.build/home" xvfb-run -a java -cp "$classpath:$here/.build" FlsOracle "$written" "$written.java.json" --sha256 2>&1 | grep -v 'logback\|^$' || true
test -s "$written.java.json" test -s "$written.java.json"
fi fi
done