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
+1
View File
@@ -17,6 +17,7 @@ export * from './runtime/speed.js';
export * from './runtime/trace.js';
export * from './runtime/capture.js';
export * from './runtime/captureRows.js';
export * from './runtime/captureWindow.js';
export * from './runtime/viewers.js';
export * from './runtime/threads.js';
export * from './runtime/apps/program.js';
+126
View File
@@ -0,0 +1,126 @@
// The data exchange window as Java FILIUS shows a card's frames
// (filius.gui.nachrichtensicht, the AggregatedExchangeDialog that filius.ini
// chooses; measured with tools/java-oracle's ExchangeWindowRun): a table with
// one row per frame — the row of its highest layer, with addresses —, below
// it the frame's details (MessageDetailsPanel, the default without the layer
// visualization), and the table as a text file (AggregatedMessageTable
// .writeToStream). The rows are captureRows'; the words are the viewer's.
//
// Departure from Java: an empty comment is exported empty, where Java writes
// "null" (prepareDataArrays leaves the cell unset, String.format writes it).
import type { CaptureRow } from './captureRows.js';
/** The table's row of a frame: the row of its highest layer (AggregatedMessageTable.update). */
export function exchangeRow(rows: readonly CaptureRow[]): CaptureRow {
const row = rows.at(-1);
if (row === undefined) throw new RangeError('A frame has at least its Ethernet row');
return row;
}
/** The words of the details (rp_lauscher_msg1–5, 7). */
export interface CaptureDetailWords {
readonly number: string;
readonly time: string;
readonly source: string;
readonly destination: string;
readonly protocol: string;
readonly comment: string;
}
/** A frame's details as Java's tree shows them, each node as its depth and text. */
export interface CaptureDetailLine {
readonly depth: number;
readonly text: string;
}
/** Java's String.format("%-15s", …). */
const label = (text: string) => text.padEnd(15);
/**
* MessageDetailsPanel.update: "<Nr.>: n / <Zeit>: t", then per layer (its
* name) the source, destination and protocol that are not empty, and the
* comment — of the application layer with its length in "Bytes" (Java's
* String length) — on its own line below its label when it has a line break
* or is longer than 60 characters. `rows` are the frame's layered rows
* (ports only), `comments` their comments in words.
*/
export function captureDetailLines(
rows: readonly CaptureRow[],
time: string,
words: CaptureDetailWords,
layerName: (row: CaptureRow) => string,
comment: (row: CaptureRow) => string,
): CaptureDetailLine[] {
const first = rows[0];
if (first === undefined) return [];
const lines: CaptureDetailLine[] = [{ depth: 0, text: `${words.number}: ${first.number} / ${words.time}: ${time}` }];
for (const row of rows) {
lines.push({ depth: 1, text: layerName(row) });
if (row.source !== null && row.source !== '') lines.push({ depth: 2, text: label(`${words.source}: `) + row.source });
if (row.destination !== null && row.destination !== '') lines.push({ depth: 2, text: label(`${words.destination}: `) + row.destination });
if (row.protocol !== '') lines.push({ depth: 2, text: label(`${words.protocol}: `) + row.protocol });
const text = comment(row);
if (text === '') continue;
const heading = `${words.comment}${row.layer === 'application' ? ` (${text.length} Bytes)` : ''}: `;
if (text.includes('\n') || text.length > 60) {
lines.push({ depth: 2, text: heading }, { depth: 3, text });
} else {
lines.push({ depth: 2, text: label(heading) + text });
}
}
return lines;
}
const COMMENT_COLUMN = 6;
const COMMENT_WIDTH = 40;
const MAXIMUM_COMMENT_LINES = 5;
/**
* The table as Java exports it: a frame of "=" around the header, then each
* row — its comment broken into lines of at most 40 characters, at most five,
* "..." for more — and a line of "-" after it; the number 10 characters
* wide, the comment 40, every other cell 20, lines ending in CR LF. `rows`:
* the seven cells of each row as the table shows them.
*/
export function exchangeExportText(header: readonly string[], rows: readonly (readonly string[])[]): string {
const width = (column: number) => (column === COMMENT_COLUMN ? COMMENT_WIDTH : column === 0 ? 10 : 20);
const line = (cells: readonly string[]) => `| ${header.map((_, column) => (cells[column] ?? '').padEnd(width(column))).join(' | ')} | \r\n`;
const separator = `+${header.map((_, column) => '-'.repeat(width(column) + 2)).join('+')}+\r\n`;
let text = separator.replaceAll('-', '=') + line(header) + separator.replaceAll('-', '=');
for (const row of rows) {
const remarks = splitRemark(row[COMMENT_COLUMN] ?? '', COMMENT_WIDTH);
text += line(row.map((cell, column) => (column === COMMENT_COLUMN ? remarks[0]! : cell)));
for (const remark of remarks.slice(1, MAXIMUM_COMMENT_LINES)) text += line(header.map((_, column) => (column === COMMENT_COLUMN ? remark : '')));
if (remarks.length > MAXIMUM_COMMENT_LINES) text += line(header.map((_, column) => (column === COMMENT_COLUMN ? '...' : '')));
text += separator;
}
return text;
}
/** StringUtils.normalizeSpace: whitespace runs as one space, none at the ends. */
function normalizeSpace(text: string): string {
return text.replace(/[\s\u00a0]+/g, ' ').trim();
}
/** AggregatedMessageTable.splitString: words onto lines of at most `maximum` characters, a longer word cut into pieces of it; no words, one empty line. */
function splitRemark(text: string, maximum: number): string[] {
const lines: string[] = [];
let current = '';
for (const token of normalizeSpace(text).split(' ')) {
if (current.length + token.length <= maximum) {
current += `${token} `;
} else if (token.length > maximum) {
if (current.length > 0) lines.push(current.trim());
const pieces: string[] = [];
for (let start = 0; start < token.length; start += maximum) pieces.push(token.slice(start, start + maximum));
lines.push(...pieces.slice(0, -1));
current = `${pieces.at(-1)!} `;
} else {
lines.push(current.trim());
current = `${token} `;
}
}
lines.push(current.trim());
return lines;
}
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);
});
});
+1 -17
View File
@@ -24,7 +24,7 @@ import {
import { modemPort } from '@mdg-filius/fls';
import { inOrder, ROOTS } from './projectDoc.js';
import { fromY } from './values.js';
import { nodeLabel } from './view.js';
import { cardAddress, nodeLabel } from './view.js';
/** A device's DHCP server as Java's dialog shows it (JDHCPKonfiguration). */
export interface DhcpServerSheet {
@@ -147,22 +147,6 @@ const setting = (node: Y.Map<unknown>, key: string) => settingsOf(node).get(key)
const text = (map: Y.Map<unknown> | undefined, key: string) => (map?.get(key) as string | undefined) ?? '';
/** The address of a device's card at a port, as Java's NetzwerkInterface holds it; null for a device without cards (InternetKnoten). */
function cardAddress(node: Y.Map<unknown>, portId: string): string | null {
switch (node.get('kind') as NodeKind) {
case 'pc':
case 'notebook':
return text(setting(node, 'device'), 'ipAddress');
case 'router':
case 'gateway': {
const port = (node.get('ports') as Y.Array<Y.Map<unknown>>).toArray().find((each) => each.get('id') === portId);
return text(port?.get('config') as Y.Map<unknown> | undefined, 'ipAddress');
}
default:
return null;
}
}
/** A router's or home router's cards, each with what its cable joins. */
function interfacesOf(doc: Y.Doc, id: string, node: Y.Map<unknown>): InterfaceSheet[] {
const all = doc.getMap<Y.Map<unknown>>(ROOTS.nodes);
+45
View File
@@ -69,6 +69,51 @@ export function nodeLabel(node: Y.Map<unknown>): string {
return `${ip} (${mac})`;
}
/** The address of a device's card at a port, as Java's NetzwerkInterface holds it; null for a device without cards (InternetKnoten). */
export function cardAddress(node: Y.Map<unknown>, portId: string): string | null {
const text = (map: Y.Map<unknown> | undefined) => (map?.get('ipAddress') as string | undefined) ?? '';
switch (node.get('kind') as NodeKind) {
case 'pc':
case 'notebook':
return text(settingsOf(node)?.get('device'));
case 'router':
case 'gateway': {
const port = (node.get('ports') as Y.Array<Y.Map<unknown>>).toArray().find((each) => each.get('id') === portId);
return text(port?.get('config') as Y.Map<unknown> | undefined);
}
default:
return null;
}
}
/** A card whose frames the data exchange window shows: its port, address and the title of its tab. */
export interface CaptureCard {
readonly portId: string;
readonly ipAddress: string;
readonly title: string;
}
/**
* A device's cards in the order of its ports, as Java's menu offers their
* data exchange (GUIEvents.kontextMenueAktionsmodus); empty for a device
* without cards. A tab's title is the card's address where the label is the
* address (Host.isUseIPAsName), else the label, " - " and the address
* (AggregatedExchangePanel.updateTabTitle).
*/
export function captureCards(doc: Y.Doc, id: string): CaptureCard[] {
const node = doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get(id);
if (node === undefined) return [];
const policy = node.get('labelPolicy') as HostLabelPolicy;
const kind = node.get('kind') as NodeKind;
const addressAsName = (kind === 'pc' || kind === 'notebook') && (policy === 'ipAddress' || policy === 'ipAndMAC');
return (node.get('ports') as Y.Array<Y.Map<unknown>>).toArray().flatMap((port) => {
const portId = port.get('id') as string;
const ipAddress = cardAddress(node, portId);
if (ipAddress === null) return [];
return [{ portId, ipAddress, title: addressAsName ? ipAddress : `${nodeLabel(node)} - ${ipAddress}` }];
});
}
/** The drawing of the document, in the order of its devices and cables. */
export function topologyView(doc: Y.Doc): TopologyView {
const kinds = new Map<string, NodeKind>();
+37 -2
View File
@@ -10,7 +10,7 @@ import { describe, expect, it } from 'vitest';
import * as Y from 'yjs';
import { stableGeneratedMacAddress } from '@mdg-filius/core';
import { exportProject, openProjectArchive, type ImportedProject } from '@mdg-filius/fls';
import { addDevice, desktopView, readProject, setHostLabels, topologyView, writeProject } from '../src/index.js';
import { addDevice, captureCards, desktopView, readProject, renameDevice, setHostAddress, setHostLabels, setInterfaceAddress, topologyView, writeProject } from '../src/index.js';
const corpus = join(dirname(fileURLToPath(import.meta.url)), '../../../corpus');
const files = ['java-examples', 'java-tests', 'ipad-fixtures'].flatMap((folder) =>
@@ -103,5 +103,40 @@ describe("a computer's desktop", () => {
setHostLabels(doc, 'pc', { ipAddress: true, macAddress: false }, 'me');
expect(desktopView(doc, 'pc')).toMatchObject({ label: '192.168.0.10', addressAsName: true });
});
});
it("offers each card's data exchange with the title of Java's tab: the address where the label is it, else the label and the address", () => {
// Java's titles (packages/core/test/exchangeWindow.java.json): "Anna - 192.168.0.20", a computer named so; "192.168.0.30", one labelled by its address.
const java = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../../core/test/exchangeWindow.java.json'), 'utf8')) as { steps: { step: string; titles: string[] }[] };
const doc = new Y.Doc();
let counter = 0;
const newId = () => `port-${++counter}`;
addDevice(doc, { kind: 'pc', id: 'anna', name: 'Anna', x: 0, y: 0, newId }, 'me');
setHostAddress(doc, 'anna', 'ipAddress', '192.168.0.20', 'me');
addDevice(doc, { kind: 'notebook', id: 'server', name: 'Server', x: 0, y: 0, newId }, 'me');
setHostAddress(doc, 'server', 'ipAddress', '192.168.0.30', 'me');
setHostLabels(doc, 'server', { ipAddress: true, macAddress: false }, 'me');
expect([...captureCards(doc, 'anna'), ...captureCards(doc, 'server')].map((card) => card.title)).toEqual(java.steps.find((step) => step.step === 'the server\'s card beside it')!.titles);
expect(captureCards(doc, 'anna')).toEqual([{ portId: 'port-1', ipAddress: '192.168.0.20', title: 'Anna - 192.168.0.20' }]);
// Address and MAC address: the address; the MAC address alone: the label, which is it.
setHostLabels(doc, 'server', { ipAddress: true, macAddress: true }, 'me');
expect(captureCards(doc, 'server')[0]!.title).toBe('192.168.0.30');
setHostLabels(doc, 'server', { ipAddress: false, macAddress: true }, 'me');
expect(captureCards(doc, 'server')[0]!.title).toBe(`${stableGeneratedMacAddress('port-2')} - 192.168.0.30`);
// A router's cards in the order of its ports, each with its own address; a home router's WAN card first.
addDevice(doc, { kind: 'router', id: 'router', name: 'Vermittlungsrechner', x: 0, y: 0, interfaces: 2, newId }, 'me');
const [first, second] = topologyView(doc).nodes.find((node) => node.id === 'router')!.ports;
setInterfaceAddress(doc, 'router', second!.id, 'ipAddress', '10.0.0.1', 'me');
renameDevice(doc, 'router', 'R1', 'me');
expect(captureCards(doc, 'router').map((card) => [card.portId, card.title])).toEqual([
[first!.id, `R1 - ${captureCards(doc, 'router')[0]!.ipAddress}`],
[second!.id, 'R1 - 10.0.0.1'],
]);
addDevice(doc, { kind: 'gateway', id: 'gateway', name: 'Heimrouter', x: 0, y: 0, newId }, 'me');
expect(captureCards(doc, 'gateway').map((card) => card.portId)).toEqual(topologyView(doc).nodes.find((node) => node.id === 'gateway')!.ports.map((port) => port.id));
// No cards: a switch, a modem; no device.
addDevice(doc, { kind: 'networkSwitch', id: 'switch', name: 'Switch', x: 0, y: 0, newId }, 'me');
addDevice(doc, { kind: 'remoteLink', id: 'modem', name: 'Modem', x: 0, y: 0, newId }, 'me');
expect([captureCards(doc, 'switch'), captureCards(doc, 'modem'), captureCards(doc, 'nobody')]).toEqual([[], [], []]);
});
});
+204
View File
@@ -0,0 +1,204 @@
// The data exchange window in Chromium (F4.7), on browser/fixtures/web-pages.fls:
// Java's menu offers a computer's desktop and each card's data exchange with
// its address; the browser's card while it fetches a page — a row per frame
// in its layer's colour, the chosen frame's details, the table as a text
// file named after the tab —, a router's two cards beside it, "Tabellen
// löschen", "Automatisches Scrollen" off, a tab closed, the window gone with
// the design and back with the next start without the tab of a computer
// removed; and on a phone.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { expect, test, type Page } from '@playwright/test';
import { drawing, onDevice, serveEditor } from './editorPage.js';
let server: { readonly base: string; readonly close: () => Promise<void> };
test.beforeAll(async () => {
server = await serveEditor();
});
test.afterAll(async () => {
await server.close();
});
const FIXTURE = join(dirname(fileURLToPath(import.meta.url)), 'fixtures/web-pages.fls');
async function openPages(page: Page): Promise<void> {
await page.goto(`${server.base}?example=new`);
await page.waitForSelector('svg.filius-canvas');
await page.locator('input[type="file"]').setInputFiles(FIXTURE);
await expect.poll(async () => (await drawing(page).catch(() => ({ nodes: [] }))).nodes.length).toBe(4);
}
async function start(page: Page): Promise<void> {
await page.getByRole('button', { name: 'Wechselt in den Aktionsmodus' }).click();
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation');
}
const idOf = async (page: Page, name: string) => (await drawing(page)).nodes.find((node) => node.name === name)!.id;
/** Java's menu of a device in the simulation, by a long press. */
async function menuOf(page: Page, name: string) {
const at = await onDevice(page, await idOf(page, name));
await page.mouse.move(at.x, at.y);
await page.mouse.down();
await page.waitForTimeout(700);
await page.mouse.up();
return page.getByRole('menuitem');
}
test("shows a card's frames as Java's data exchange window does, with their details, as a text file, and empties every card's", async ({ page }) => {
test.setTimeout(120_000);
await openPages(page);
await start(page);
// Java's menu: the desktop, then the card's data exchange with its address.
await expect(await menuOf(page, 'Browser')).toHaveText(['Desktop anzeigen', 'Datenaustausch anzeigen (192.168.0.10)']);
await page.getByRole('menuitem', { name: 'Datenaustausch anzeigen (192.168.0.10)' }).click();
const window = page.locator('section[data-capture]');
await expect(window.locator('h2')).toHaveText('Datenaustausch');
await expect(window.getByRole('tab')).toHaveText(['Browser - 192.168.0.10']);
await expect(window.locator('th')).toHaveText(['Nr.', 'Zeit', 'Quelle', 'Ziel', 'Protokoll', 'Schicht', 'Bemerkungen / Details']);
await expect(window.locator('tbody tr')).toHaveCount(0);
// Closed, it keeps its tab (Java hides its dialog); it lies over the devices.
await window.locator('.desktop-close').click();
await expect(window).toHaveCount(0);
// The browser fetches the server's page: a row per frame, its highest layer's, in its colour.
const at = await onDevice(page, await idOf(page, 'Browser'));
await page.mouse.click(at.x, at.y);
const desktop = page.locator('section[data-desktop]');
await desktop.locator('[data-program="webBrowser"]').click();
const address = desktop.getByRole('textbox', { name: 'Adresse' });
await address.fill('192.168.0.20');
await address.press('Enter');
await expect(desktop.locator('.program-titlebar h3')).toHaveText('Seiten & Formulare', { timeout: 30_000 });
await desktop.locator('.desktop-close').click();
await (await menuOf(page, 'Browser')).filter({ hasText: 'Datenaustausch' }).click();
await expect(window.getByRole('tab')).toHaveText(['Browser - 192.168.0.10']);
const rows = window.locator('tbody tr');
await expect(rows.first().locator('td')).toHaveText(['1', /^\d\d:\d\d:\d\d\.\d\d\d$/, '192.168.0.10', '192.168.0.20', 'ARP', 'Vermittlung', /^Suche nach MAC für 192\.168\.0\.20 \[op=REQUEST/]);
await expect(rows.first()).toHaveClass(/capture-internet/);
const get = rows.filter({ hasText: 'GET / HTTP/1.1' }).first();
await expect(get.locator('td').nth(4)).toHaveText('HTTP');
await expect(get.locator('td').nth(5)).toHaveText('Anwendung');
await expect(get.locator('td').nth(2)).toHaveText(/^192\.168\.0\.10:\d{5}$/);
await expect(get).toHaveClass(/capture-application/);
// Java's colours (LayerColorVar): application #FFD966, transport #F4B183.
expect(await get.evaluate((row) => getComputedStyle(row).backgroundColor)).toBe('rgb(255, 217, 102)');
const syn = rows.filter({ hasText: /^.*SYN, SEQ: [\d.]+$/ }).first();
await expect(syn).toHaveClass(/capture-transport/);
expect(await syn.evaluate((row) => getComputedStyle(row).backgroundColor)).toBe('rgb(244, 177, 131)');
const numbers = await rows.locator('td:first-child').allTextContents();
expect(numbers).toEqual(numbers.map((_, index) => String(index + 1)));
// A line break in a comment shows as a space (LauscherTableCellRenderer).
expect(
await get
.locator('td')
.nth(6)
.evaluate((cell) => cell.textContent),
).toMatch(/^GET \/ HTTP\/1\.1 Host: 192\.168\.0\.20 /);
// "Automatisches Scrollen" (on): the table at its end.
const scroller = window.locator('.capture-table');
const scrolled = () =>
scroller.evaluate((element) => ({
overflows: element.scrollHeight > element.clientHeight + 20,
atEnd: element.scrollTop + element.clientHeight >= element.scrollHeight - 2,
top: element.scrollTop,
}));
await expect.poll(scrolled).toMatchObject({ overflows: true, atEnd: true });
// The request chosen: white on black, its details below, every layer.
await get.click();
await expect(get).toHaveAttribute('aria-selected', 'true');
expect(await get.evaluate((row) => [getComputedStyle(row).backgroundColor, getComputedStyle(row).color])).toEqual(['rgb(0, 0, 0)', 'rgb(255, 255, 255)']);
const details = window.getByRole('list', { name: 'Bemerkungen / Details' }).locator('li');
await expect(details.first()).toHaveText(/^Nr\.: \d+ \/ Zeit: \d\d:\d\d:\d\d\.\d\d\d$/);
const lines = await details.allTextContents();
expect(lines.filter((line) => ['Netzzugang', 'Vermittlung', 'Transport', 'Anwendung'].includes(line))).toEqual(['Netzzugang', 'Vermittlung', 'Transport', 'Anwendung']);
expect(lines).toContain('Protokoll: HTTP');
expect(lines.some((line) => line.startsWith('Bemerkungen / Details (') && /^[^(]+\(\d+ Bytes\): $/.test(line))).toBe(true);
expect(lines.some((line) => line.startsWith('GET / HTTP/1.1\n'))).toBe(true);
// The table as Java's text file, named after the tab.
const [download] = await Promise.all([page.waitForEvent('download'), window.getByRole('button', { name: 'Als Textdatei exportieren' }).click()]);
expect(download.suggestedFilename()).toBe('Browser - 192.168.0.10.txt');
const text = readFileSync((await download.path())!, 'utf8');
expect(text.split('\r\n').slice(0, 3)).toEqual([
`+${'='.repeat(12)}${`+${'='.repeat(22)}`.repeat(5)}+${'='.repeat(42)}+`,
`| Nr. | Zeit | Quelle | Ziel | Protokoll | Schicht | ${'Bemerkungen / Details'.padEnd(40)} | `,
`+${'='.repeat(12)}${`+${'='.repeat(22)}`.repeat(5)}+${'='.repeat(42)}+`,
]);
expect(text).toContain('| 1 | ');
expect(text.match(/^\+-/gm)?.length).toBe(await rows.count());
// A router's cards: one tab each, the one chosen in front.
await window.locator('.desktop-close').click();
await expect(await menuOf(page, 'Router')).toHaveText(['Datenaustausch anzeigen (192.168.0.1)', 'Datenaustausch anzeigen (10.0.0.1)']);
await page.getByRole('menuitem', { name: 'Datenaustausch anzeigen (192.168.0.1)' }).click();
await expect(window.getByRole('tab')).toHaveText(['Browser - 192.168.0.10', 'Router - 192.168.0.1']);
await expect(window.getByRole('tab', { selected: true })).toHaveText('Router - 192.168.0.1');
await expect(window.locator('th')).toHaveCount(7);
// The router's card heard the ARP broadcast.
await expect(rows.first().locator('td').nth(4)).toHaveText('ARP');
// "Tabellen löschen": every card's list, numbered anew.
await window.getByRole('button', { name: 'Tabellen löschen' }).click();
await expect(rows).toHaveCount(0);
await window.getByRole('tab', { name: 'Browser - 192.168.0.10' }).click();
await expect(rows).toHaveCount(0);
await expect(window.getByRole('list', { name: 'Bemerkungen / Details' }).locator('li')).toHaveCount(0);
// Without "Automatisches Scrollen", fetched again (its tab kept over the window closed): the table stays at its start, nothing chosen.
await window.getByLabel('Automatisches Scrollen').uncheck();
await window.locator('.desktop-close').click();
await page.mouse.click(at.x, at.y);
await desktop.locator('[data-program="webBrowser"]').click();
await address.fill('192.168.0.20');
await address.press('Enter');
await page.waitForTimeout(6_000);
await desktop.locator('.desktop-close').click();
await (await menuOf(page, 'Browser')).filter({ hasText: 'Datenaustausch' }).click();
await expect(window.getByLabel('Automatisches Scrollen')).not.toBeChecked();
await expect.poll(() => rows.count()).toBeGreaterThan(15);
await expect(rows.first().locator('td').first()).toHaveText('1');
await expect(window.locator('tbody tr[aria-selected="true"]')).toHaveCount(0);
expect(await scrolled()).toMatchObject({ overflows: true, top: 0 });
// A third tab; a tab closed beside the chosen one leaves it chosen.
await window.locator('.desktop-close').click();
await (await menuOf(page, 'Server')).filter({ hasText: 'Datenaustausch' }).click();
await expect(window.getByRole('tab')).toHaveText(['Browser - 192.168.0.10', 'Router - 192.168.0.1', 'Server - 192.168.0.20']);
await window.getByRole('tab', { name: 'Browser - 192.168.0.10' }).click();
await window.getByRole('button', { name: 'Reiter schließen: Router - 192.168.0.1' }).click();
await expect(window.getByRole('tab')).toHaveText(['Browser - 192.168.0.10', 'Server - 192.168.0.20']);
await expect(window.getByRole('tab', { selected: true })).toHaveText('Browser - 192.168.0.10');
// The window goes with the design and comes back with the next start; the tab of a computer removed in the design is gone.
await page.getByRole('button', { name: 'Wechselt in den Entwurfsmodus' }).click();
await expect(window).toHaveCount(0);
const server = await onDevice(page, await idOf(page, 'Server'));
await page.mouse.click(server.x, server.y);
await page.keyboard.press('Delete');
await expect.poll(async () => (await drawing(page)).nodes.length).toBe(3);
await start(page);
await expect(window.getByRole('tab')).toHaveText(['Browser - 192.168.0.10']);
// Its last tab closed, the window shows nothing.
await window.getByRole('button', { name: 'Reiter schließen: Browser - 192.168.0.10' }).click();
await expect(window).toHaveCount(0);
});
test('fills a phone with the data exchange window, its bar in reach', async ({ page }) => {
await page.setViewportSize({ width: 360, height: 740 });
await openPages(page);
await start(page);
await (await menuOf(page, 'Browser')).filter({ hasText: 'Datenaustausch' }).click();
const window = page.locator('section[data-capture]');
for (const name of ['Tabellen löschen', 'Als Textdatei exportieren']) await expect(window.getByRole('button', { name })).toBeInViewport({ ratio: 1 });
await expect(window.getByLabel('Automatisches Scrollen')).toBeChecked();
const box = (await window.boundingBox())!;
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.x + box.width).toBeLessThanOrEqual(360);
});
+86 -9
View File
@@ -4,7 +4,8 @@
// in Java — no palette, no sheet, nothing moves — the cables that carry a
// frame light up, and a tap on a computer opens its desktop. Desktops left
// open stay over a return to the design and show again at the next start,
// as Java's do (GUIContainer.closeDialogs, reopenDialogs).
// as Java's do (GUIContainer.closeDialogs, reopenDialogs); so does the data
// exchange window with its tabs (F4.7).
//
// Every change goes through packages/doc's commands with this editor's own
// origin, so that its undo takes back what this person did and nothing of
@@ -12,7 +13,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import type * as Y from 'yjs';
import { addDevice, connect, createUndoManager, moveDevice, removeDevice, removeLink, removeLinksOf, topologyView, type TopologyView } from '@mdg-filius/doc';
import { addDevice, captureCards, connect, createUndoManager, moveDevice, removeDevice, removeLink, removeLinksOf, topologyView, type TopologyView } from '@mdg-filius/doc';
import { Canvas, type CanvasHandle, type Selection, type Side } from './canvas/Canvas.js';
import type { Point } from './canvas/viewport.js';
import { ContextMenu, type MenuItem } from './design/ContextMenu.js';
@@ -21,6 +22,7 @@ import { RouterDialog } from './design/RouterDialog.js';
import { DeviceSheet } from './sheet/DeviceSheet.js';
import { DesktopWindow, type DesktopWindowName } from './desktop/DesktopWindow.js';
import { useLanguage, useTranslate } from './i18n/i18n.js';
import { CaptureWindow } from './simulation/CaptureWindow.js';
import { ModeBar } from './simulation/ModeBar.js';
import { DropControl } from './simulation/DropControl.js';
import { ViewerWindow, type ViewerKind } from './simulation/ViewerWindow.js';
@@ -75,16 +77,31 @@ const toolButton: CSSProperties = {
/** Whether a key press belongs to a field being typed in. */
const typing = (target: EventTarget | null) => target instanceof HTMLElement && (target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName));
/** A desktop open on the surface — or a switch's or home router's viewer (`viewer`) —: whose, where its window lies, and how far up (the last one touched on top). */
/** The data exchange window's place among the desktops (it is one window, with a tab per card). */
const CAPTURE_WINDOW = 'capture';
/**
* A desktop open on the surface — or a switch's or home router's viewer
* (`viewer`), or the data exchange window (`capture`) —: whose, where its
* window lies, and how far up (the last one touched on top).
*/
interface OpenDesktop {
readonly id: string;
readonly viewer?: ViewerKind;
readonly viewer?: ViewerKind | 'capture';
readonly x: number;
readonly y: number;
readonly z: number;
readonly program: DesktopWindowName | null;
}
/** A card's tab in the data exchange window: whose card, the frame chosen in it, whether it scrolls along (AggregatedMessageTable's own). */
interface OpenCaptureTab {
readonly node: string;
readonly port: string;
readonly chosen: number | undefined;
readonly autoscroll: boolean;
}
/** Brings a desktop to the top by its stacking, not its place in the page: moved in the page, it would lose the press on it. */
const toFront = (open: readonly OpenDesktop[], id: string): readonly OpenDesktop[] => {
const top = Math.max(0, ...open.map((each) => each.z));
@@ -138,13 +155,30 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
const kinds = new Map(view.nodes.map((node) => [node.id, node.kind]));
const fits = (each: OpenDesktop) => {
const kind = kinds.get(each.id);
if (each.viewer === 'capture') return true;
return each.viewer === 'sat' ? kind === 'networkSwitch' : each.viewer === 'nat' ? kind === 'gateway' : kind === 'pc' || kind === 'notebook';
};
setDesktops((open) => (open.every(fits) ? open : open.filter(fits)));
}, [view]);
/** Opens a computer's desktop, or a switch's or home router's viewer, or brings it to the front. */
const openDesktop = useCallback((id: string, viewer?: ViewerKind) => {
// The data exchange window's tabs (Java keeps them when the window is closed); a card that is gone loses its tab
// (Java: clearUnavailableComponents). Without a tab the window shows nothing; it keeps its place, as Java's dialog does.
const [captureTabs, setCaptureTabs] = useState<{ readonly tabs: readonly OpenCaptureTab[]; readonly selected: string | null }>({ tabs: [], selected: null });
const changeCaptureTab = useCallback(
(port: string, change: Partial<OpenCaptureTab>) => setCaptureTabs((before) => ({ ...before, tabs: before.tabs.map((tab) => (tab.port === port ? { ...tab, ...change } : tab)) })),
[],
);
useEffect(() => {
const cards = new Set(view.nodes.flatMap((node) => captureCards(doc, node.id).map((card) => `${node.id}/${card.portId}`)));
setCaptureTabs((before) => {
const tabs = before.tabs.filter((tab) => cards.has(`${tab.node}/${tab.port}`));
if (tabs.length === before.tabs.length) return before;
return { tabs, selected: tabs.some((tab) => tab.port === before.selected) ? before.selected : (tabs.at(-1)?.port ?? null) };
});
}, [view, doc]);
/** Opens a computer's desktop, or a switch's or home router's viewer, or the data exchange window, or brings it to the front. */
const openDesktop = useCallback((id: string, viewer?: ViewerKind | 'capture') => {
setDesktops((open) => {
if (open.some((each) => each.id === id)) return toFront(open, id);
// Java stacks a desktop that does not fit beside the others a little further on.
@@ -200,11 +234,25 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
[doc, origin, manager],
);
/** Shows a card's frames in the data exchange window: its tab chosen, added where it has none (AggregatedExchangePanel.addTable). */
const openCapture = useCallback(
(node: string, port: string) => {
setCaptureTabs((before) => ({ tabs: before.tabs.some((tab) => tab.port === port) ? before.tabs : [...before.tabs, { node, port, chosen: undefined, autoscroll: true }], selected: port }));
openDesktop(CAPTURE_WINDOW, 'capture');
},
[openDesktop],
);
const menuItems = (target: Selection): MenuItem[] => {
if (simulating) {
// Java's menu in the simulation: a computer's desktop (its data exchange follows with the capture, F4.7).
// Java's menu in the simulation: a computer's desktop, then each card's data exchange with its address.
if (target.kind !== 'device') return [];
const node = view.nodes.find((each) => each.id === target.id);
return target.kind === 'device' && (node?.kind === 'pc' || node?.kind === 'notebook') ? [{ key: 'showDesktop', label: t('menu.showDesktop'), onChoose: () => openDesktop(target.id) }] : [];
const items: MenuItem[] = node?.kind === 'pc' || node?.kind === 'notebook' ? [{ key: 'showDesktop', label: t('menu.showDesktop'), onChoose: () => openDesktop(target.id) }] : [];
for (const card of captureCards(doc, target.id)) {
items.push({ key: `showCapture-${card.portId}`, label: `${t('menu.showCapture')} (${card.ipAddress})`, onChoose: () => openCapture(target.id, card.portId) });
}
return items;
}
if (target.kind === 'link') return [{ key: 'removeCable', label: t('menu.removeCable'), onChoose: () => remove(target) }];
const node = view.nodes.find((each) => each.id === target.id);
@@ -310,7 +358,36 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
</div>
{simulating
? desktops.map((desktop) =>
desktop.viewer !== undefined ? (
desktop.viewer === 'capture' ? (
captureTabs.selected === null ? null : (
<CaptureWindow
key={desktop.id}
tabs={captureTabs.tabs.map((tab) => ({ ...tab, title: captureCards(doc, tab.node).find((card) => card.portId === tab.port)?.title ?? '' }))}
selected={captureTabs.selected}
simulation={simulation}
x={desktop.x}
y={desktop.y}
z={desktop.z}
onSelect={(port) => setCaptureTabs((before) => ({ ...before, selected: port }))}
onCloseTab={(port) =>
setCaptureTabs((before) => {
const tabs = before.tabs.filter((tab) => tab.port !== port);
return { tabs, selected: before.selected === port ? (tabs.at(-1)?.port ?? null) : before.selected };
})
}
onChoose={(port, number) => changeCaptureTab(port, { chosen: number })}
onAutoscroll={(port, autoscroll) => changeCaptureTab(port, { autoscroll })}
onClear={() => {
// Java's tables begin anew, with nothing chosen.
setCaptureTabs((before) => ({ ...before, tabs: before.tabs.map((tab) => ({ ...tab, chosen: undefined })) }));
simulation.clearCapture();
}}
onMove={(x, y) => setDesktops((open) => open.map((each) => (each.id === desktop.id ? { ...each, x, y } : each)))}
onFront={() => setDesktops((open) => toFront(open, desktop.id))}
onClose={() => setDesktops((open) => open.filter((each) => each.id !== desktop.id))}
/>
)
) : desktop.viewer !== undefined ? (
<ViewerWindow
key={desktop.id}
kind={desktop.viewer}
+177
View File
@@ -1341,3 +1341,180 @@
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* The data exchange window (AggregatedExchangeDialog): Java's is three
* quarters of the screen wide and four fifths high; the tabs, a bar for what
* Java's right mouse button offers, the table (a row per frame in its layer's
* colour, Java's fonts by column) and the chosen frame's details below. */
.capture-window {
width: 960px;
height: 640px;
}
.capture-tabs {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 6px 8px 0;
background: var(--surface-raised);
border-bottom: 1px solid var(--border-default);
}
.capture-tab {
display: inline-flex;
align-items: center;
border: 1px solid var(--border-default);
border-bottom: none;
border-radius: 6px 6px 0 0;
background: var(--surface-sunken);
}
.capture-tab button {
min-height: 36px;
border: none;
background: transparent;
color: var(--text-primary);
font: inherit;
font-size: 13px;
cursor: pointer;
}
.capture-tab button[aria-selected='true'] {
font-weight: 600;
}
.capture-tab:has(button[aria-selected='true']) {
background: var(--surface-card);
}
.capture-tab-close {
min-width: 36px;
color: var(--text-secondary) !important;
font-size: 18px !important;
}
.capture-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: var(--surface-card);
color: var(--text-primary);
}
.capture-bar button {
min-height: 44px;
padding: 0 12px;
border: 1px solid var(--border-default);
border-radius: 8px;
background: var(--surface-raised);
color: var(--text-primary);
font: inherit;
cursor: pointer;
}
.capture-bar label {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 44px;
}
.capture-bar input {
width: 20px;
height: 20px;
}
.capture-table {
flex: 3;
min-height: 0;
overflow: auto;
background: var(--surface-sunken);
}
.capture-table table {
width: 100%;
min-width: 760px;
border-collapse: separate;
border-spacing: 0 2px;
font-size: 12px;
}
.capture-table th {
position: sticky;
top: 0;
padding: 6px;
background: var(--surface-raised);
color: var(--text-primary);
text-align: left;
font-weight: 600;
}
.capture-row {
color: var(--capture-on-layer);
cursor: pointer;
}
.capture-row td {
padding: 4px 6px;
white-space: nowrap;
}
/* Java's fonts: number and time plain, addresses and comment monospaced, the protocol bold, the layer bold italic. */
.capture-row td:nth-child(3),
.capture-row td:nth-child(4),
.capture-row td:nth-child(7) {
font-family: ui-monospace, Menlo, Consolas, monospace;
}
.capture-row td:nth-child(5) {
font-weight: 700;
}
.capture-row td:nth-child(6) {
font-weight: 700;
font-style: italic;
}
.capture-networkAccess {
background: var(--capture-network-access);
}
.capture-internet {
background: var(--capture-internet);
}
.capture-transport {
background: var(--capture-transport);
}
.capture-application {
background: var(--capture-application);
}
.capture-row[aria-selected='true'] {
background: var(--capture-chosen);
color: var(--capture-on-chosen);
}
.capture-row:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: -2px;
}
/* MessageDetailsPanel: a tree in bold monospace, a long comment on lines of its own. */
.capture-details {
flex: 2;
min-height: 0;
margin: 0;
padding: 6px;
overflow: auto;
list-style: none;
border-top: 1px solid var(--border-strong);
background: var(--surface-card);
color: var(--text-primary);
font: 700 12px ui-monospace, Menlo, Consolas, monospace;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
+20
View File
@@ -18,6 +18,25 @@
"cable.wanOccupied": "WAN-Anschluss (extern) bereits belegt!",
"cable.wirelessNotPossible": "Drahtlose Verbindung nicht möglich",
"canvas.label": "Netzwerk",
"capture.application": "Anwendung",
"capture.autoscroll": "Automatisches Scrollen",
"capture.clear": "Tabellen löschen",
"capture.closeTab": "Reiter schließen",
"capture.comment": "Bemerkungen / Details",
"capture.destination": "Ziel",
"capture.export": "Als Textdatei exportieren",
"capture.internet": "Vermittlung",
"capture.layer": "Schicht",
"capture.macIs": "MAC ist",
"capture.networkAccess": "Netzzugang",
"capture.number": "Nr.",
"capture.protocol": "Protokoll",
"capture.protocolWord": "Protokoll",
"capture.searchForMac": "Suche nach MAC für",
"capture.source": "Quelle",
"capture.time": "Zeit",
"capture.title": "Datenaustausch",
"capture.transport": "Transport",
"connections.add": "Schnittstelle hinzufügen",
"connections.fewest": "Eine Schnittstelle bleibt immer.",
"connections.hint": "Hinweis: Zum Umstecken von Verbindungen:\n1. Verbindung auswählen durch Klick auf Schaltfläche links neben der Schnittstellenbezeichnung\n2. Auswählen der neuen Schnittstelle durch Klick auf die entsprechende Schaltfläche\nSollte die Zielschnittstelle bereits belegt sein, so werden die Verbindungen vertauscht.",
@@ -272,6 +291,7 @@
"menu.delete": "Löschen",
"menu.removeAllCables": "Alle Verbindungen entfernen",
"menu.removeCable": "Verbindung entfernen",
"menu.showCapture": "Datenaustausch anzeigen",
"menu.showDesktop": "Desktop anzeigen",
"mode.currentSpeed": "Aktuelle Simulationsgeschwindigkeit",
"mode.design": "Wechselt in den Entwurfsmodus",
+20
View File
@@ -18,6 +18,25 @@
"cable.wanOccupied": "WAN port (external) already occupied!",
"cable.wirelessNotPossible": "Wireless connection not possible",
"canvas.label": "Network",
"capture.application": "Application",
"capture.autoscroll": "Autoscroll",
"capture.clear": "Clear tables",
"capture.closeTab": "Close tab",
"capture.comment": "Comment / Details",
"capture.destination": "Destination",
"capture.export": "Export as text file",
"capture.internet": "Internet",
"capture.layer": "Layer",
"capture.macIs": "MAC is",
"capture.networkAccess": "Network",
"capture.number": "No.",
"capture.protocol": "Protocol",
"capture.protocolWord": "Protocol",
"capture.searchForMac": "Search for MAC",
"capture.source": "Source",
"capture.time": "Time",
"capture.title": "Data exchange",
"capture.transport": "Transport",
"connections.add": "Add interface",
"connections.fewest": "One interface always stays.",
"connections.hint": "Note: For reorganising cable connections:\n1. Choose cable connection by clicking the button next to the interface name\n2. Choose new interface by clicking according button\nIf the target interface is already occupied by another connection, then those cables will be swapped.",
@@ -272,6 +291,7 @@
"menu.delete": "Delete",
"menu.removeAllCables": "Remove all cables",
"menu.removeCable": "Remove cable",
"menu.showCapture": "Show data exchange",
"menu.showDesktop": "Show desktop",
"mode.currentSpeed": "Current Simulation Velocity",
"mode.design": "Change to Design Mode",
@@ -0,0 +1,182 @@
// The data exchange window while the simulation runs, after Java's
// AggregatedExchangeDialog (measured with tools/java-oracle's
// ExchangeWindowRun): a tab per card ("<label> - <address>", or the address
// where the label is it), each with a close button; the card's frames, one
// row per frame in its highest layer's colour (Java's LayerColorVar); the
// chosen frame's details below; „Tabellen löschen“ (every card's list),
// „Als Textdatei exportieren“ (named after the tab, as Java offers) and
// „Automatisches Scrollen“ (on, as in Java). The frames are the room's.
//
// Better than Java: the three are buttons and a box in a bar (Java's are in a
// menu of the right mouse button); a frame is chosen by its number (Java's
// details take the row's index for it); an empty comment is exported empty
// (Java writes "null").
import { memo, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react';
import type { TraceFrame } from '@mdg-filius/protocol';
import { useLanguage, useTranslate } from '../i18n/i18n.js';
import { captureWords, exportText, frameCells, frameDetails, type CaptureWords } from './captureCells.js';
import type { Simulation } from './useSimulation.js';
/** A card's tab: its title, the frame chosen in it and whether it scrolls along — each table's own, as in Java, kept while the window is closed. */
export interface CaptureTab {
readonly port: string;
readonly title: string;
readonly chosen: number | undefined;
readonly autoscroll: boolean;
}
export interface CaptureWindowProps {
readonly tabs: readonly CaptureTab[];
readonly selected: string;
readonly simulation: Simulation;
readonly x: number;
readonly y: number;
readonly z: number;
readonly onSelect: (port: string) => void;
readonly onCloseTab: (port: string) => void;
readonly onChoose: (port: string, number: number) => void;
readonly onAutoscroll: (port: string, on: boolean) => void;
/** "Tabellen löschen": every card's frames, and what was chosen in them. */
readonly onClear: () => void;
readonly onMove: (x: number, y: number) => void;
readonly onFront: () => void;
readonly onClose: () => void;
}
const FrameRow = memo(function FrameRow({
frame,
clock,
words,
chosen,
onChoose,
}: {
readonly frame: TraceFrame;
readonly clock: number;
readonly words: CaptureWords;
readonly chosen: boolean;
readonly onChoose: (number: number) => void;
}) {
const cells = frameCells(frame, clock, words);
const layer = frame.rows.at(-1)!.layer;
return (
<tr
className={`capture-row capture-${layer}`}
aria-selected={chosen}
tabIndex={0}
onClick={() => onChoose(frame.number)}
onKeyDown={(event) => (event.key === 'Enter' || event.key === ' ') && onChoose(frame.number)}
>
{cells.map((cell, column) => (
// Java's cell shows a line break as a space.
<td key={column}>{cell.replaceAll('\n', ' ')}</td>
))}
</tr>
);
});
export function CaptureWindow({ tabs, selected, simulation, x, y, z, onSelect, onCloseTab, onChoose, onAutoscroll, onClear, onMove, onFront, onClose }: CaptureWindowProps) {
const t = useTranslate();
const language = useLanguage();
const words = useMemo(() => captureWords(t, language), [t, language]);
const { watch, clock } = simulation;
useEffect(() => watch([`trace:${selected}`]), [watch, selected]);
const frames = simulation.traces.get(selected) ?? [];
const scroller = useRef<HTMLDivElement>(null);
const [drag, setDrag] = useState<{ readonly pointer: number; readonly dx: number; readonly dy: number; moved: boolean } | null>(null);
const title = t('capture.title');
const tab = tabs.find((each) => each.port === selected);
const chosenFrame = frames.find((frame) => frame.number === tab?.chosen);
const autoscroll = tab?.autoscroll ?? true;
// New frames scroll the table to its end while "Automatisches Scrollen" is on.
useEffect(() => {
if (autoscroll && scroller.current !== null) scroller.current.scrollTop = scroller.current.scrollHeight;
}, [autoscroll, frames.length, selected]);
// The title bar moves the window; the pointer is captured once it moves (pointer-capture trap).
const onPointerDown = (event: ReactPointerEvent<HTMLElement>) => {
if ((event.target as Element).closest('button') !== null) return;
setDrag({ pointer: event.pointerId, dx: event.clientX - x, dy: event.clientY - y, moved: false });
};
const onPointerMove = (event: ReactPointerEvent<HTMLElement>) => {
if (drag === null || drag.pointer !== event.pointerId) return;
if (!drag.moved) {
drag.moved = true;
event.currentTarget.setPointerCapture(event.pointerId);
}
onMove(Math.max(0, event.clientX - drag.dx), Math.max(0, event.clientY - drag.dy));
};
const onPointerUp = () => setDrag(null);
const choose = useMemo(() => (number: number) => onChoose(selected, number), [onChoose, selected]);
const download = () => {
const url = URL.createObjectURL(new Blob([exportText(frames, clock, words)], { type: 'text/plain;charset=utf-8' }));
const link = document.createElement('a');
link.href = url;
link.download = `${tab?.title ?? title}.txt`;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 0);
};
return (
<section className="desktop-window capture-window" role="dialog" aria-label={title} data-capture="" style={{ left: x, top: y, zIndex: 5 + z }} onPointerDownCapture={onFront}>
<header className="desktop-titlebar" onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp}>
<h2>{title}</h2>
<button type="button" className="desktop-close" aria-label={t('sheet.close')} title={t('sheet.close')} onClick={onClose}>
×
</button>
</header>
<div className="capture-tabs" role="tablist">
{tabs.map((each) => (
<span key={each.port} className="capture-tab">
<button type="button" role="tab" aria-selected={each.port === selected} onClick={() => onSelect(each.port)}>
{each.title}
</button>
<button type="button" className="capture-tab-close" aria-label={`${t('capture.closeTab')}: ${each.title}`} title={t('capture.closeTab')} onClick={() => onCloseTab(each.port)}>
×
</button>
</span>
))}
</div>
<div className="capture-bar">
<button type="button" onClick={onClear}>
{t('capture.clear')}
</button>
<button type="button" onClick={download}>
{t('capture.export')}
</button>
<label>
<input type="checkbox" checked={autoscroll} onChange={(event) => onAutoscroll(selected, event.target.checked)} />
{t('capture.autoscroll')}
</label>
</div>
<div className="capture-table" ref={scroller}>
<table aria-label={tab?.title ?? title}>
<thead>
<tr>
{words.header.map((column) => (
<th key={column}>{column}</th>
))}
</tr>
</thead>
<tbody>
{frames.map((frame) => (
<FrameRow key={frame.number} frame={frame} clock={clock} words={words} chosen={frame === chosenFrame} onChoose={choose} />
))}
</tbody>
</table>
</div>
<ul className="capture-details" aria-label={words.detail.comment}>
{chosenFrame === undefined
? null
: frameDetails(chosenFrame, clock, words).map((line, index) => (
<li key={index} style={{ paddingLeft: 4 + 16 * line.depth }}>
{line.text}
</li>
))}
</ul>
</section>
);
}
@@ -0,0 +1,71 @@
// What the data exchange window writes of a frame, in the viewer's words: the
// seven cells of its row (Java's columns: number, time, source, destination,
// protocol, layer, comment), its details, the table as a text file. The
// rules are the core's (captureWindow.ts, measured against Java); the words,
// the number format and the time of day are the viewer's, as Java's are its
// locale's and its wall clock's.
import {
captureCommentText,
captureDetailLines,
exchangeExportText,
exchangeRow,
type CaptureCommentWords,
type CaptureDetailLine,
type CaptureDetailWords,
type CaptureLayer,
type CaptureRow,
} from '@mdg-filius/core';
import type { TraceFrame } from '@mdg-filius/protocol';
import { timeOfDay } from './timeOfDay.js';
export interface CaptureWords {
readonly header: readonly string[];
readonly layers: Readonly<Record<CaptureLayer, string>>;
readonly comment: CaptureCommentWords;
readonly detail: CaptureDetailWords;
/** The locale's numbers (Java: NumberFormat of its locale, "1.000.000" in German). */
readonly formatNumber: (value: number) => string;
}
/** The words of the editor's catalog in its language. */
export function captureWords(t: (key: string) => string, language: string): CaptureWords {
const header = ['number', 'time', 'source', 'destination', 'protocol', 'layer', 'comment'].map((key) => t(`capture.${key}`));
const numbers = new Intl.NumberFormat(language === 'de' ? 'de-DE' : 'en-GB');
return {
header,
layers: { networkAccess: t('capture.networkAccess'), internet: t('capture.internet'), transport: t('capture.transport'), application: t('capture.application') },
comment: { protocol: t('capture.protocolWord'), searchForMac: t('capture.searchForMac'), macIs: t('capture.macIs') },
detail: { number: header[0]!, time: header[1]!, source: header[2]!, destination: header[3]!, protocol: header[4]!, comment: header[6]! },
formatNumber: (value) => numbers.format(value),
};
}
const commentOf = (row: CaptureRow, words: CaptureWords) => captureCommentText(row.comment, words.comment, words.formatNumber);
/** The frame's row, its highest layer's, as seven cells; the comment as it is (with its line breaks). */
export function frameCells(frame: TraceFrame, clock: number, words: CaptureWords): string[] {
const row = exchangeRow(frame.rows);
return [String(row.number), timeOfDay(clock, row.timeMilliseconds, true), row.source ?? '', row.destination ?? '', row.protocol, words.layers[row.layer], commentOf(row, words)];
}
/** The frame's details, from its layered rows. */
export function frameDetails(frame: TraceFrame, clock: number, words: CaptureWords): CaptureDetailLine[] {
const first = frame.layeredRows[0];
if (first === undefined) return [];
return captureDetailLines(
frame.layeredRows,
timeOfDay(clock, first.timeMilliseconds, true),
words.detail,
(row) => words.layers[row.layer],
(row) => commentOf(row, words),
);
}
/** The card's table as Java's text file. */
export function exportText(frames: readonly TraceFrame[], clock: number, words: CaptureWords): string {
return exchangeExportText(
words.header,
frames.map((frame) => frameCells(frame, clock, words)),
);
}
@@ -1,12 +1,13 @@
// The editor's side of the simulation room (plan §6): its mode and speed,
// the cables that carry frames, a computer's network as it runs, the lines
// and views of the programs' windows and the actions taken in them. The room
// and views of the programs' windows and the actions taken in them, the
// cards' frames for the data exchange window. The room
// runs behind a SimHost — in a worker of this page (LocalSimHost) until F
// holds it (RemoteSimHost, F5); the editor keeps no simulation of its own.
import { useCallback, useEffect, useRef, useState } from 'react';
import type * as Y from 'yjs';
import type { AppKind, DroppedUnit, NodeSettings, SimHost, SimMode, Topic } from '@mdg-filius/protocol';
import type { AppKind, DroppedUnit, NodeSettings, SimHost, SimMode, Topic, TraceFrame } from '@mdg-filius/protocol';
import { LocalSimHost } from '@mdg-filius/sim';
import SimWorker from '@mdg-filius/sim/worker?worker';
import type { Language } from '../i18n/i18n.js';
@@ -44,12 +45,16 @@ export interface Simulation {
readonly clock: number;
/** The frames "Pakete verwerfen" dropped in this simulation, while `dropped` is watched. */
readonly dropped: readonly DroppedUnit[];
/** Each watched card's frames as the data exchange window shows them, by its port. */
readonly traces: ReadonlyMap<string, readonly TraceFrame[]>;
/** Starts in the language the person runs FILIUS in: the routers' pages come in it, as Java's in its locale. */
start(language?: Language): void;
stop(): void;
setSpeed(value: number): void;
/** Java's "Pakete verwerfen": held (true) or let go (false). */
drop(on: boolean): void;
/** Java's "Tabellen löschen": every card's frames and the dropped ones are forgotten. */
clearCapture(): void;
/** Watches these topics until the returned function is called; a topic two parts watch is sent for once. */
watch(topics: readonly Topic[]): () => void;
/** An action in a program's window (the room answers with its new lines). */
@@ -70,6 +75,7 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
const [views, setViews] = useState<ReadonlyMap<string, unknown>>(new Map());
const [clock, setClock] = useState(0);
const [dropped, setDropped] = useState<readonly DroppedUnit[]>([]);
const [traces, setTraces] = useState<ReadonlyMap<string, readonly TraceFrame[]>>(new Map());
useEffect(() => {
const made = createHost(doc);
@@ -97,6 +103,9 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
return new Map(before).set(topic, { lines, live: message.from === 0 ? lines.length : (before.get(topic)?.live ?? 0) });
});
return;
case 'trace':
setTraces((before) => new Map(before).set(message.portId, message.reset ? message.frames : (before.get(message.portId) ?? []).concat(message.frames)));
return;
case 'dropped':
setDropped((before) => (message.reset ? message.units : [...before, ...message.units]));
return;
@@ -125,6 +134,7 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
const stop = useCallback(() => host.current?.send({ v: 1, kind: 'stop' }), []);
const setSpeed = useCallback((value: number) => host.current?.send({ v: 1, kind: 'speed', value }), []);
const drop = useCallback((on: boolean) => host.current?.send({ v: 1, kind: 'drop', on }), []);
const clearCapture = useCallback(() => host.current?.send({ v: 1, kind: 'clearCapture' }), []);
const watch = useCallback((topics: readonly Topic[]) => {
const fresh = topics.filter((topic) => {
const count = watched.current.get(topic) ?? 0;
@@ -147,5 +157,5 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
host.current?.send({ v: 1, kind: 'input', node, app, action, args, reqId: crypto.randomUUID() });
}, []);
return { mode, speed, activeLinks, nodes, apps, views, failed, clock, dropped, start, stop, setSpeed, drop, watch, act };
return { mode, speed, activeLinks, nodes, apps, views, failed, clock, dropped, traces, start, stop, setSpeed, drop, clearCapture, watch, act };
}
+15
View File
@@ -37,6 +37,14 @@
--connection-plugged: rgb(0 255 0);
--connection-free: rgb(255 0 0);
--connection-marked: rgb(255 255 0);
/* Java's layer colours of the data exchange window (LayerColorVar), black text; a chosen row white on black. */
--capture-network-access: #c9c9c9;
--capture-internet: #8faadc;
--capture-transport: #f4b183;
--capture-application: #ffd966;
--capture-on-layer: #000000;
--capture-chosen: #000000;
--capture-on-chosen: #ffffff;
}
:root[data-theme='dark'] {
@@ -71,6 +79,13 @@
--connection-plugged: rgb(0 190 0);
--connection-free: rgb(210 40 40);
--connection-marked: rgb(235 215 0);
--capture-network-access: #3a3f47;
--capture-internet: #2c4369;
--capture-transport: #6b4128;
--capture-application: #5f4f17;
--capture-on-layer: #e6e9ef;
--capture-chosen: #e6e9ef;
--capture-on-chosen: #14171c;
}
html,
+78
View File
@@ -0,0 +1,78 @@
// What the data exchange window writes of a frame in the viewer's language:
// Java's words of both bundles, the locale's numbers ("1.000.000" in German,
// "1,000,000" in English), the time of day with milliseconds, the row of the
// highest layer, the details from the layered rows, the text file.
import { describe, expect, it } from 'vitest';
import { captureRows, TCP_FLAG, type CapturedFrame, type EthernetFrame } from '@mdg-filius/core';
import type { TraceFrame } from '@mdg-filius/protocol';
import { translate } from '../src/i18n/i18n.js';
import { captureWords, exportText, frameCells, frameDetails } from '../src/simulation/captureCells.js';
/** A TCP segment with data from 10.0.0.1:50000 to 10.0.0.2:80, as the room sends it. */
function frame(number: number, timeMilliseconds: number): TraceFrame {
const ethernet: EthernetFrame = {
identity: number,
sourceMacAddress: '02:00:00:00:00:01',
destinationMacAddress: '02:00:00:00:00:02',
payload: {
kind: 'ipv4',
packet: {
identity: 9,
senderIPAddress: '10.0.0.1',
receiverIPAddress: '10.0.0.2',
timeToLive: 64,
protocol: 'tcp',
payload: {
kind: 'tcp',
segment: {
sourcePort: 50_000,
destinationPort: 80,
sequenceNumber: 2_000_001,
acknowledgementNumber: 1_000_001,
flags: TCP_FLAG.acknowledgement,
payload: new TextEncoder().encode('GET / HTTP/1.1\nHost: x\n'),
},
},
},
},
};
const captured: CapturedFrame[] = [{ number, timeMilliseconds, frame: ethernet }];
return { number, rows: captureRows(captured, { withAddresses: true }), layeredRows: captureRows(captured, { withAddresses: false }) };
}
const words = (language: 'de' | 'en') => captureWords((key) => translate(language, key), language);
const clock = new Date(2026, 9, 4, 8, 30, 0, 0).getTime();
describe("the data exchange window's cells", () => {
it("writes a frame's row by its highest layer, in either language", () => {
expect(frameCells(frame(3, 1_234), clock, words('de'))).toEqual(['3', '08:30:01.234', '10.0.0.1:50000', '10.0.0.2:80', 'HTTP', 'Anwendung', 'GET / HTTP/1.1\nHost: x\n']);
expect(frameCells(frame(3, 1_234), clock, words('en'))).toEqual(['3', '08:30:01.234', '10.0.0.1:50000', '10.0.0.2:80', 'HTTP', 'Application', 'GET / HTTP/1.1\nHost: x\n']);
expect(words('de').header).toEqual(['Nr.', 'Zeit', 'Quelle', 'Ziel', 'Protokoll', 'Schicht', 'Bemerkungen / Details']);
expect(words('en').header).toEqual(['No.', 'Time', 'Source', 'Destination', 'Protocol', 'Layer', 'Comment / Details']);
});
it("writes the details with the locale's numbers and Java's words", () => {
const german = frameDetails(frame(3, 0), clock, words('de')).map((line) => line.text);
expect(german[0]).toBe('Nr.: 3 / Zeit: 08:30:00.000');
expect(german).toContain('Bemerkungen / Details: SEQ: 2.000.001, ACK: 1.000.001');
expect(german).toContain('Bemerkungen / Details: Protokoll: 6, TTL: 64');
expect(german).toContain('Bemerkungen / Details (23 Bytes): ');
const english = frameDetails(frame(3, 0), clock, words('en')).map((line) => line.text);
expect(english[0]).toBe('No.: 3 / Time: 08:30:00.000');
expect(english).toContain('Comment / Details: SEQ: 2,000,001, ACK: 1,000,001');
expect(english).toContain('Comment / Details: Protocol: 6, TTL: 64');
expect(frameDetails({ number: 1, rows: [], layeredRows: [] }, clock, words('de'))).toEqual([]);
});
it('exports the table with the words of its header', () => {
const text = exportText([frame(1, 0), frame(2, 5)], clock, words('en'));
expect(text.split('\r\n')[1]).toBe(
`| No. | Time | Source | Destination | Protocol | Layer | ${'Comment / Details'.padEnd(40)} | `,
);
expect(text.split('\r\n')[3]).toBe(
`| 1 | 08:30:00.000 | 10.0.0.1:50000 | 10.0.0.2:80 | HTTP | Application | ${'GET / HTTP/1.1 Host: x'.padEnd(40)} | `,
);
expect(text.match(/^\+-/gm)).toHaveLength(2);
});
});
+20
View File
@@ -244,6 +244,26 @@ const FROM_JAVA: Readonly<Record<string, string>> = {
'installation.apply': 'installationsdialog_msg2',
'mode.currentSpeed': 'guimainmemu_msg15',
'menu.showDesktop': 'guievents_msg3',
'capture.title': 'lauscherdialog_msg1',
'capture.number': 'rp_lauscher_msg1',
'capture.time': 'rp_lauscher_msg2',
'capture.source': 'rp_lauscher_msg3',
'capture.destination': 'rp_lauscher_msg4',
'capture.protocol': 'rp_lauscher_msg5',
'capture.layer': 'rp_lauscher_msg6',
'capture.comment': 'rp_lauscher_msg7',
'capture.networkAccess': 'rp_lauscher_msg8',
'capture.internet': 'rp_lauscher_msg9',
'capture.transport': 'rp_lauscher_msg10',
'capture.application': 'rp_lauscher_msg11',
'capture.protocolWord': 'rp_lauscher_msg12',
'capture.searchForMac': 'rp_lauscher_msg13',
'capture.macIs': 'rp_lauscher_msg14',
'capture.clear': 'nachrichtentabelle_msg7',
'capture.autoscroll': 'nachrichtentabelle_msg8',
'capture.export': 'nachrichtentabelle_msg9',
'capture.closeTab': 'buttontabcomponent_msg1',
'menu.showCapture': 'guievents_msg4',
'desktop.home': 'desktoppanel_application',
'desktop.installation': 'desktoppanel_msg1',
'network.ipAddress': 'network_msg1',
+5 -1
View File
@@ -21,7 +21,7 @@ export type AppKind = (typeof APP_KINDS)[number];
*/
export type Topic = 'activity' | 'dropped' | `trace:${string}` | `app:${string}/${AppKind}` | `node:${string}`;
export const CLIENT_MESSAGE_KINDS = ['sub', 'unsub', 'start', 'stop', 'speed', 'drop', 'input'] as const;
export const CLIENT_MESSAGE_KINDS = ['sub', 'unsub', 'start', 'stop', 'speed', 'drop', 'clearCapture', 'input'] as const;
export type ClientMessageKind = (typeof CLIENT_MESSAGE_KINDS)[number];
export type ClientMessage =
@@ -39,6 +39,8 @@ export type ClientMessage =
| { readonly v: 1; readonly kind: 'speed'; readonly value: number }
/** Java's "Pakete verwerfen" pressed (on) or let go: every cable drops what it carries while any client holds it. */
| { readonly v: 1; readonly kind: 'drop'; readonly on: boolean }
/** Java's "Tabellen löschen" in the data exchange window: every card's capture and the dropped frames are emptied, the numbers begin anew. */
| { readonly v: 1; readonly kind: 'clearCapture' }
/** An action in a program's window, answered by `ack` or `error` with the same reqId. */
| {
readonly v: 1;
@@ -153,6 +155,8 @@ export function parseClientMessage(value: unknown): Parsed<ClientMessage> {
return { message: { v: 1, kind: 'start', language: value.language } };
case 'stop':
return { message: { v: 1, kind: 'stop' } };
case 'clearCapture':
return { message: { v: 1, kind: 'clearCapture' } };
case 'speed':
if (typeof value.value !== 'number' || !Number.isFinite(value.value)) return bad('badMessage', 'speed is no number');
return { message: { v: 1, kind: 'speed', value: value.value } };
+2
View File
@@ -13,6 +13,7 @@ describe("a client's messages", () => {
{ v: 1, kind: 'stop' },
{ v: 1, kind: 'speed', value: 50 },
{ v: 1, kind: 'drop', on: true },
{ v: 1, kind: 'clearCapture' },
{ v: 1, kind: 'input', node: 'node-1', app: 'terminal', action: 'enter', args: { line: 'ping 1.2.3.4' }, reqId: 'r1' },
];
expect(messages.map((each) => each.kind)).toEqual([...CLIENT_MESSAGE_KINDS]);
@@ -20,6 +21,7 @@ describe("a client's messages", () => {
// A start in a language, and the language dropped where it is none.
for (const language of ['de', 'en']) expect(parseClientMessage({ v: 1, kind: 'start', language })).toEqual({ message: { v: 1, kind: 'start', language } });
expect(parseClientMessage({ v: 1, kind: 'stop', language: 'en' })).toEqual({ message: { v: 1, kind: 'stop' } });
expect(parseClientMessage({ v: 1, kind: 'clearCapture', port: 'p' })).toEqual({ message: { v: 1, kind: 'clearCapture' } });
});
it('names what is wrong', () => {
+15
View File
@@ -389,6 +389,21 @@ export class SimulationRoom {
else this.dropping.delete(client);
this.engine.setGlobalPacketLossEnabled(this.dropping.size > 0);
return;
case 'clearCapture':
// Java's data exchange window is there only while the simulation runs.
if (this.mode !== 'simulation') {
client.send(message({ kind: 'error', code: 'notRunning' }));
return;
}
// Lauscher.reset: every card's list and the dropped frames; each watcher gets its lists anew, numbered from 1.
this.engine.clearCapture();
this.framesSeen.clear();
for (const each of this.clients.values()) {
each.traceSent.clear();
each.droppedSent = undefined;
}
this.flush([...this.clients.values()], false);
return;
case 'speed':
this.speed = Math.min(100, Math.max(1, Math.trunc(input.value)));
this.engine.setSimulationSpeed(this.speed);
+132
View File
@@ -0,0 +1,132 @@
// The data exchange window's side of the room, on FILIUS' example
// nat_with_mapped_port: a card's frames as the window shows them (one row
// per frame from its rows, the details from its layered rows), "Tabellen
// löschen" — every card's list and the dropped frames emptied for every
// client, numbered anew, the cables still lighting up —, refused while the
// design is shown, and the lists kept over a stop and a start, as Java's
// Lauscher keeps them. The window's rows against Java's are
// packages/core/test/exchangeWindow.test.ts.
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 { readProject, setSoftware, writeProject } from '@mdg-filius/doc';
import { exchangeRow } from '@mdg-filius/core';
import { openProjectArchive } from '@mdg-filius/fls';
import type { HostMessage, TraceFrame } from '@mdg-filius/protocol';
import { SimulationRoom, TICK_MILLISECONDS } from '../src/index.js';
const corpus = join(dirname(fileURLToPath(import.meta.url)), '../../../corpus/java-examples');
function setUp() {
let counter = 0;
const doc = new Y.Doc();
writeProject(
doc,
openProjectArchive(new Uint8Array(readFileSync(join(corpus, 'nat_with_mapped_port.fls'))), () => `00000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`),
);
const project = readProject(doc);
const webServer = project.graph.nodes.find((node) => node.displayName === 'Webserver - 192.168.0.10')!;
const port = webServer.ports[0]!.id;
const link = project.graph.links.find((each) => each.sourcePortId === port || each.targetPortId === port)!.id;
setSoftware(doc, webServer.id, { install: ['commandPrompt'], remove: [] }, 'de', 'me');
let now = 0;
const room = new SimulationRoom(doc, { now: () => now, seed: () => 5 });
const connect = () => {
const traces: TraceFrame[] = [];
const resets: number[] = [];
const dropped: number[] = [];
const lit: string[][] = [];
const errors: string[] = [];
const id = room.connect((message: HostMessage) => {
if (message.kind === 'trace' && message.portId === port) {
if (message.reset) {
resets.push(message.frames.length);
traces.length = 0;
}
traces.push(...message.frames);
}
if (message.kind === 'dropped' && message.reset) dropped.push(message.units.length);
if (message.kind === 'activity') lit.push([...message.links]);
if (message.kind === 'error') errors.push(message.code);
});
return { id, traces, resets, dropped, lit, errors };
};
const send = (who: number, fields: object) => room.receive(who, { v: 1, ...fields });
const run = (milliseconds: number) => {
for (let elapsed = 0; elapsed < milliseconds; elapsed += TICK_MILLISECONDS) {
now += TICK_MILLISECONDS;
room.tick();
}
};
const ping = (who: number, target: string) => send(who, { kind: 'input', node: webServer.id, app: 'terminal', action: 'enter', args: { line: `ping ${target}` }, reqId: target });
return { port, link, connect, send, run, ping };
}
describe('the data exchange window in the room', () => {
it('shows a card\'s frames, and "Tabellen löschen" empties every list for every client and numbers anew', () => {
const { port, link, connect, send, run, ping } = setUp();
const first = connect();
const second = connect();
for (const client of [first, second]) send(client.id, { kind: 'sub', topics: [`trace:${port}`, 'dropped', 'activity'] });
// Not while the design is shown: Java's window is not there.
send(first.id, { kind: 'clearCapture' });
expect(first.errors).toEqual(['notRunning']);
send(first.id, { kind: 'start' });
run(TICK_MILLISECONDS);
ping(first.id, '192.168.0.1');
run(5_000);
// An ARP exchange and four pings with their answers: one row each, the highest layer's; the details from all of them.
expect(first.traces.map((frame) => exchangeRow(frame.rows).protocol)).toEqual(['ARP', 'ARP', ...Array.from({ length: 8 }, () => 'ICMP')]);
expect(first.traces.map((frame) => frame.number)).toEqual(Array.from({ length: 10 }, (_, index) => index + 1));
expect(new Set(first.traces.map((frame) => frame.layeredRows.map((row) => row.layer).join()))).toEqual(new Set(['networkAccess,internet']));
expect(second.traces).toEqual(first.traces);
// Something dropped, to be emptied with the rest.
send(first.id, { kind: 'drop', on: true });
ping(first.id, '192.168.0.1');
run(5_000);
send(first.id, { kind: 'drop', on: false });
const droppedBefore = second.dropped.length;
// Either client empties the lists of both.
send(second.id, { kind: 'clearCapture' });
for (const client of [first, second]) {
expect(client.resets.at(-1)).toBe(0);
expect(client.traces).toEqual([]);
expect(client.dropped.at(-1)).toBe(0);
}
expect(second.dropped.length).toBe(droppedBefore + 1);
// Numbered from 1 again, and the cable lights up with the first of them.
ping(first.id, '192.168.0.1');
for (let waited = 0; first.traces.length === 0 && waited < 5_000; waited += TICK_MILLISECONDS) run(TICK_MILLISECONDS);
expect(first.traces.map((frame) => frame.number)).toEqual([1]);
expect(first.lit.at(-1)).toContain(link);
run(5_000);
expect(first.traces.map((frame) => frame.number)).toEqual(first.traces.map((_, index) => index + 1));
expect(first.traces.length).toBeGreaterThanOrEqual(8);
expect(second.errors).toEqual([]);
});
it('keeps the lists over a stop and a start, numbering on', () => {
const { port, connect, send, run, ping } = setUp();
const client = connect();
send(client.id, { kind: 'start' });
run(TICK_MILLISECONDS);
ping(client.id, '192.168.0.1');
run(5_000);
send(client.id, { kind: 'stop' });
send(client.id, { kind: 'start' });
run(TICK_MILLISECONDS);
ping(client.id, '192.168.0.1');
run(5_000);
// Watched after the second start: the first run's frames are there, the second's go on from them.
send(client.id, { kind: 'sub', topics: [`trace:${port}`] });
expect(client.resets).toEqual([client.traces.length]);
expect(client.traces.map((frame) => frame.number)).toEqual(client.traces.map((_, index) => index + 1));
// Each run: an ARP exchange (a start forgets what ARP learned) and four pings with their answers.
expect(client.traces.length).toBe(20);
});
});