feat: F4.7 the data exchange window, measured against Java's

One row per frame (its highest layer's), Java's detail tree, the table as
Java's text file, "Tabellen löschen" for every card (clearCapture in the
room), a tab per card with Java's title, Java's layer colours as tokens,
the chosen frame and autoscroll per tab. New oracle ExchangeWindowRun.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 09:12:38 +00:00
co-authored by Claude Opus 5.5
parent 6809a71300
commit 9252b942c4
26 changed files with 7692 additions and 33 deletions
File diff suppressed because one or more lines are too long
+185
View File
@@ -0,0 +1,185 @@
// FILIUS' data exchange window against Java's (exchangeWindow.java.json;
// tools/java-oracle's ExchangeWindowRun on web-lan): the browser's card while
// it fetches a page by name — one row per frame, its highest layer's; each
// frame's details; the table as a text file —, "Tabellen löschen", which
// empties every card's list and numbers anew, and a second fetch. The rows
// themselves are compared layer by layer with Java's in webJava.test.ts;
// Java's time of day is the editor's to write (TIME here).
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import {
captureCommentText,
captureDetailLines,
captureRows,
exchangeExportText,
exchangeRow,
VirtualFileSystem,
withSnapshot,
type CaptureLayer,
type CaptureRow,
type NetworkRuntimeEngine,
} from '../src/index.js';
import { watchedEngine, withClientPortText } from './javaRun.js';
import { snapshotOf, type Scenario } from './scenario.js';
type Cells = (string | null)[];
interface JavaTable {
readonly rows: Cells[];
readonly layerRows: Cells[];
}
interface JavaStep {
readonly step: string;
readonly at: number;
readonly titles: string[];
readonly selected: number;
readonly tables: Record<string, JavaTable>;
readonly details?: [number, string][][];
readonly export?: string;
readonly menu?: string[];
}
const here = dirname(fileURLToPath(import.meta.url));
const java = JSON.parse(readFileSync(join(here, 'exchangeWindow.java.json'), 'utf8')) as { readonly steps: readonly JavaStep[] };
const at = (name: string) => java.steps.find((step) => step.step === name)!;
const scenario = JSON.parse(readFileSync(join(here, 'scenarios/web-lan.scenario.json'), 'utf8')) as Scenario;
const BROWSER = '00000000-0000-0000-0000-000000000201';
const SERVER = '00000000-0000-0000-0000-000000000203';
const portOf = (node: string) => scenario.nodes.find((each) => each.id === node)!.ports[0]!.id;
/** Java's German words (its message bundle) and number format (Locale.GERMANY). */
const LAYERS: Record<CaptureLayer, string> = { networkAccess: 'Netzzugang', internet: 'Vermittlung', transport: 'Transport', application: 'Anwendung' };
const COMMENT_WORDS = { protocol: 'Protokoll', searchForMac: 'Suche nach MAC f\u00fcr', macIs: 'MAC ist' };
const DETAIL_WORDS = { number: 'Nr.', time: 'Zeit', source: 'Quelle', destination: 'Ziel', protocol: 'Protokoll', comment: 'Bemerkungen / Details' };
const HEADER = ['Nr.', 'Zeit', 'Quelle', 'Ziel', 'Protokoll', 'Schicht', 'Bemerkungen / Details'];
const german = new Intl.NumberFormat('de-DE');
const comment = (row: CaptureRow) => captureCommentText(row.comment, COMMENT_WORDS, (value) => german.format(value));
/** A cell with placeholders: the browser's free ports and the DNS message's ID (drawn) and Java's time of day. */
const cell = (text: string) =>
withClientPortText(text)
.replace(/(^|\s)(\d{5})$/, (whole, before: string, port: string) => (Number(port) >= 49_152 ? `${before}client` : whole))
.replace(/ID=\d+/g, 'ID=x');
const TIME = /^\d\d:\d\d:\d\d\.\d\d\d$/;
function run() {
const files = new VirtualFileSystem();
files.createDirectory('/webserver', true);
files.writeTextFile('/webserver/text.txt', 'Nur Text\nzwei Zeilen');
const snapshot = withSnapshot(snapshotOf(scenario), {
webServersByNodeId: new Map([[SERVER, { port: 80, documentRoot: '/webserver', virtualHosts: false, isActive: true }]]),
webBrowserNodeIds: new Set([BROWSER]),
fileSystemsByNodeId: new Map([[SERVER, files]]),
});
const { engine } = watchedEngine(scenario, snapshot);
const until = (milliseconds: number) => engine.handle({ type: 'advance', toMilliseconds: milliseconds });
return { engine, until };
}
/** The window's table of a card: each frame's highest row, without its time. */
const table = (engine: NetworkRuntimeEngine, node: string) =>
[...groupByFrame(captureRows(engine.capture.frames(portOf(node)), { withAddresses: true })).values()].map((rows) => {
const row = exchangeRow(rows);
return [String(row.number), row.source, row.destination, row.protocol, LAYERS[row.layer], comment(row)].map((each) => (each === null ? null : cell(each)));
});
const javaTable = (step: JavaStep, node: string) => step.tables[node]!.rows.map(([number, , ...rest]) => [number!, ...rest].map((each) => (each === null ? null : cell(each))));
function groupByFrame(rows: readonly CaptureRow[]): Map<number, CaptureRow[]> {
const frames = new Map<number, CaptureRow[]>();
for (const row of rows) frames.set(row.number, [...(frames.get(row.number) ?? []), row]);
return frames;
}
describe("the data exchange window, against Java's", () => {
it('shows one row per frame, its details, its text file; is emptied by "Tabellen löschen" and numbers anew', () => {
const { engine, until } = run();
until(500);
engine.webBrowser(BROWSER)!.open('http://www.filius.de/text.txt');
const fetched = at('after the fetch');
until(fetched.at);
// One row per frame: the highest layer's, with addresses.
expect(table(engine, BROWSER)).toEqual(javaTable(fetched, BROWSER));
expect(fetched.tables[BROWSER]!.rows.length).toBeLessThan(fetched.tables[BROWSER]!.layerRows.length);
// Each frame's details, from its layered rows (ports only).
const frames = groupByFrame(captureRows(engine.capture.frames(portOf(BROWSER)), { withAddresses: false }));
const details = [...frames.values()].map((rows) => captureDetailLines(rows, 'TIME', DETAIL_WORDS, (row) => LAYERS[row.layer], comment).map(({ depth, text }) => [depth, cell(text)]));
const javaDetails = fetched.details!.map((lines) => lines.map(([depth, text]) => [depth, cell(text.replace(/(Zeit: )\S+$/, '$1TIME'))]));
expect(details).toEqual(javaDetails);
// The text file of Java's own rows, as Java writes it.
const javaRows = fetched.tables[BROWSER]!.rows.map((row) => row.map((each) => each ?? ''));
expect(exchangeExportText(HEADER, javaRows)).toBe(fetched.export);
expect(fetched.tables[BROWSER]!.rows.every((row) => TIME.test(row[1]!))).toBe(true);
// "Tabellen löschen" (Lauscher.reset): every card's list empties.
const cleared = at('cleared');
until(cleared.at);
engine.clearCapture();
expect([table(engine, BROWSER), table(engine, SERVER)]).toEqual([javaTable(cleared, BROWSER), javaTable(cleared, SERVER)]);
expect(cleared.menu).toEqual(['Tabellen l\u00f6schen', 'Als Textdatei exportieren', 'Automatisches Scrollen']);
// Fetched again: numbered from 1.
engine.webBrowser(BROWSER)!.open('http://www.filius.de/text.txt');
const again = at('fetched again');
until(again.at);
expect(table(engine, BROWSER)).toEqual(javaTable(again, BROWSER));
expect(table(engine, SERVER)).toEqual(javaTable(again, SERVER));
});
it('breaks a long comment as Java does: words onto lines of 40, a long word cut, five lines at most', () => {
const header = ['N', 'T', 'S', 'D', 'P', 'L', 'C'];
const row = (text: string) => ['1', 't', 's', 'd', 'p', 'l', text];
const lines = (text: string) =>
exchangeExportText(header, [row(text)])
.split('\r\n')
.slice(3, -2)
.map((line) => line.split(' | ')[6]!.trimEnd());
expect(lines('')).toEqual(['']);
expect(lines(' \n ')).toEqual(['']);
expect(lines(`${'a'.repeat(40)} b`)).toEqual(['a'.repeat(40), 'b']);
expect(lines(`x ${'a'.repeat(90)} y`)).toEqual(['x', 'a'.repeat(40), 'a'.repeat(40), `${'a'.repeat(10)} y`]);
expect(lines(`${'a'.repeat(41)}`)).toEqual(['a'.repeat(40), 'a']);
// Whitespace at the ends does not count (StringUtils.normalizeSpace).
expect(lines(` \n${'a'.repeat(40)} `)).toEqual(['a'.repeat(40)]);
expect(lines(Array.from({ length: 6 }, () => 'w'.repeat(39)).join(' '))).toEqual([...Array.from({ length: 5 }, () => 'w'.repeat(39)), '...']);
expect(lines(Array.from({ length: 5 }, () => 'w'.repeat(39)).join(' '))).toEqual(Array.from({ length: 5 }, () => 'w'.repeat(39)));
// An empty comment is empty (Java writes "null").
expect(exchangeExportText(header, [row('')]).split('\r\n')[3]).toBe(
`| 1 | t | s | d | p | l | ${' '.repeat(40)} | `,
);
});
it('writes a short comment beside its label and a long one below it, and the length of the data in Bytes', () => {
const base = { number: 4, timeMilliseconds: 0, source: null, destination: null } as const;
const rows: CaptureRow[] = [
{ ...base, source: 'a', destination: '', protocol: '', layer: 'networkAccess', comment: { kind: 'ethernetType', type: '0x800' } },
{ ...base, source: '', destination: 'b', protocol: 'IP', layer: 'internet', comment: { kind: 'ipProtocol', protocol: 6, timeToLive: 64 } },
{ ...base, protocol: 'HTTP', layer: 'application', comment: { kind: 'data', text: 'ä'.repeat(60) } },
{ ...base, protocol: 'HTTP', layer: 'application', comment: { kind: 'data', text: 'b'.repeat(61) } },
];
expect(captureDetailLines(rows, '12:00', DETAIL_WORDS, (row) => LAYERS[row.layer], comment)).toEqual([
{ depth: 0, text: 'Nr.: 4 / Zeit: 12:00' },
{ depth: 1, text: 'Netzzugang' },
{ depth: 2, text: 'Quelle: a' },
{ depth: 2, text: 'Bemerkungen / Details: 0x800' },
{ depth: 1, text: 'Vermittlung' },
{ depth: 2, text: 'Ziel: b' },
{ depth: 2, text: 'Protokoll: IP' },
{ depth: 2, text: 'Bemerkungen / Details: Protokoll: 6, TTL: 64' },
{ depth: 1, text: 'Anwendung' },
{ depth: 2, text: 'Protokoll: HTTP' },
{ depth: 2, text: `Bemerkungen / Details (60 Bytes): ${'ä'.repeat(60)}` },
{ depth: 1, text: 'Anwendung' },
{ depth: 2, text: 'Protokoll: HTTP' },
{ depth: 2, text: 'Bemerkungen / Details (61 Bytes): ' },
{ depth: 3, text: 'b'.repeat(61) },
]);
expect(captureDetailLines([], '12:00', DETAIL_WORDS, (row) => LAYERS[row.layer], comment)).toEqual([]);
expect(() => exchangeRow([])).toThrow(RangeError);
});
});