A web page by its DNS name (internetwork_komplett), an e-mail between two networks through NAT and MX relay (Internet_Komplett_..._DE), a file found and fetched with Gnutella across a router (zwei_rechnernetze_komplett): ExampleRun records frames, tables, program lines and end states; the core plays the same calls and matches card by card. Equal RIP ties take Java's choice, RIP beacons are left out, the search's two connections compared flow by flow. Also: a core test that an equal RIP route from another router neither replaces nor refreshes the route, and nine stale type errors in core tests fixed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
498 lines
26 KiB
TypeScript
498 lines
26 KiB
TypeScript
// The classics of the classroom on FILIUS' own examples, as Java FILIUS runs
|
|
// them (the acceptance of F3): a web page loaded by its DNS name, an e-mail
|
|
// sent between two networks, a file found and fetched with Gnutella.
|
|
// tools/java-oracle's ExampleRun opened each file in Java FILIUS as FILIUS
|
|
// opens it, started it, called the programs as their windows call them and
|
|
// wrote every frame each card saw, the capture's tables, what each program
|
|
// told its window and where the programs ended (examples/<story>.java.json).
|
|
// Here the file is read with this package's reader and run in the core
|
|
// (projectSnapshot), the same calls at the same times: each card's frames
|
|
// in the same order, with the same content and rows, at about the same
|
|
// time; each program's lines in the same order at about the same time; the
|
|
// same mails, peers, results and files at the end.
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
formatMail,
|
|
GNUTELLA_FOLDER,
|
|
javaBase64,
|
|
javaFileTypeName,
|
|
NetworkRuntimeEngine,
|
|
VirtualFileSystem,
|
|
type Id,
|
|
type LogLine,
|
|
type MailboxEntry,
|
|
} from '@mdg-filius/core';
|
|
import { projectSnapshot, type ImportedProject } from '../src/index.js';
|
|
import { expectSameCaptures, GAP_TOLERANCE_MILLISECONDS, withClientPortText, type JavaFrame, type JavaTables } from '../../core/test/javaRun.js';
|
|
import { browserText, emailClientText, emailServerText, isPolled, POLL_MILLISECONDS, serverText } from '../../core/test/programLines.js';
|
|
import { exampleCards, exampleRecording, namesInJavaOrder, openExample } from './exampleRun.js';
|
|
|
|
const SEED = 14;
|
|
|
|
interface JavaRun extends JavaTables {
|
|
frames: Record<string, JavaFrame[]>;
|
|
cards: Record<string, string>;
|
|
/** What each program told its window: time, program (its node's name, or "browser"), text; a call the story made as "call …". */
|
|
programs: [number, string, string][];
|
|
/** Where the programs ended (ExampleRun). */
|
|
end: Record<string, unknown>;
|
|
/** Every change of each RIP router's table: time, router, `network/mask gateway interface hops; …`. */
|
|
ripTables: [number, string, string][];
|
|
}
|
|
|
|
type Lines = Record<string, [number, string][]>;
|
|
type Node = (name: string) => Id;
|
|
type JavaMail = [string, string, string];
|
|
|
|
interface Classic {
|
|
readonly file: string;
|
|
/** Changes to the example before it starts, as ExampleRun makes them. */
|
|
readonly prepare?: (project: ImportedProject, node: Node) => void;
|
|
/**
|
|
* At the first call: RIP routes the engine's routers chose otherwise than
|
|
* Java's where both ways are as long (equalRipTies).
|
|
*/
|
|
readonly ripTies?: true;
|
|
/** Placeholders in a table cell of the story (the frames' texts carry no data). */
|
|
readonly cell?: (text: string) => string;
|
|
/**
|
|
* Where frames of different connections may come in either order: a
|
|
* frame's flow (expectSameCaptures), by its text.
|
|
*/
|
|
readonly flow?: (text: string) => string;
|
|
/** Changes to the engine before it runs, from Java's run. */
|
|
readonly beforeRun?: (engine: NetworkRuntimeEngine, java: JavaRun) => void;
|
|
/** A call the story made at a program ("call …" in Java's lines, without "call "). */
|
|
readonly act: (engine: NetworkRuntimeEngine, node: Node, program: string, call: string) => void;
|
|
/** Java's lines by program, as its windows show them (without the calls). */
|
|
readonly javaLines: (java: JavaRun) => Lines;
|
|
readonly engineLines: (engine: NetworkRuntimeEngine, node: Node) => Lines;
|
|
/** Where the engine's programs ended, as ExampleRun writes Java's. */
|
|
readonly end: (engine: NetworkRuntimeEngine, node: Node) => unknown;
|
|
/**
|
|
* The waits Java ends only at a look every 100 ms, one after the other, on
|
|
* the way to a line (a connection opened); a line is compared within
|
|
* GAP_TOLERANCE_MILLISECONDS and these looks.
|
|
*/
|
|
readonly looks: number;
|
|
/**
|
|
* When the DHCP clients are done: the cards are compared from the first
|
|
* call on (the leases before it are the acceptance of F2, examplesJava's
|
|
* firewall story).
|
|
*/
|
|
readonly afterLeases?: true;
|
|
}
|
|
|
|
function lines<E>(log: readonly LogLine<E>[] | undefined, text: (entry: E) => string | undefined): [number, string][] {
|
|
return (log ?? []).flatMap((line) => {
|
|
const each = text(line);
|
|
return each === undefined ? [] : [[line.timeMilliseconds, withClientPortText(each)] as [number, string]];
|
|
});
|
|
}
|
|
|
|
/** Java's lines of the programs: what their windows show (no empty notices, null, nor the browser's "update" at its start). */
|
|
function javaLinesOf(java: JavaRun, programs: readonly string[]): Lines {
|
|
const told: Lines = Object.fromEntries(programs.map((program) => [program, []]));
|
|
const firstCall = Math.min(...java.programs.filter(([, , text]) => text.startsWith('call ')).map(([time]) => time));
|
|
for (const [time, program, text] of java.programs) {
|
|
if (text === 'null' || text.startsWith('call ') || (time < firstCall && text === 'update')) continue;
|
|
told[program]!.push([time, withClientPortText(text)]);
|
|
}
|
|
return told;
|
|
}
|
|
|
|
/**
|
|
* Where a router has two ways to a network as long as each other, Java takes
|
|
* the one whose message its threads handle first (examplesJava's firewall
|
|
* story; tools/java-oracle/README.md, Abnahme F2), the engine the first it
|
|
* learned; neither replaces it with a way as long. As a random number Java
|
|
* drew, Java's choice is given to the engine (its table at the time),
|
|
* route by route; a route that differs otherwise (another length, or one
|
|
* of them missing) is a difference, not a tie.
|
|
*/
|
|
function equalRipTies(engine: NetworkRuntimeEngine, java: JavaRun, node: Node, time: number): void {
|
|
const latest = new Map<string, string>();
|
|
for (const [changed, router, table] of java.ripTables) if (changed <= time) latest.set(router, table);
|
|
for (const [router, table] of latest) {
|
|
const routes = [...(engine.state.ripTablesByNodeId.get(node(router)) ?? [])];
|
|
for (const entry of table.split('; ')) {
|
|
const [destination, gateway, card, hops] = entry.split(' ') as [string, string, string, string];
|
|
const index = routes.findIndex((route) => `${route.destinationNetwork}/${route.subnetMask}` === destination);
|
|
const route = routes[index];
|
|
expect(route?.metric, `${router}: ${destination}`).toBe(Number(hops));
|
|
if (route!.nextHop === gateway) continue;
|
|
// Java's hopPublicIp: the first address of the router that told it, as its other routes through that router say.
|
|
const told = routes.find((each) => each.nextHop === gateway && each.hopPublicIPAddress !== undefined)?.hopPublicIPAddress;
|
|
routes[index] = { ...route!, nextHop: gateway, interfaceIPAddress: card, ...(told === undefined ? {} : { hopPublicIPAddress: told }) };
|
|
}
|
|
engine.state.ripTablesByNodeId.set(node(router), routes);
|
|
}
|
|
}
|
|
|
|
const mails = (entries: readonly MailboxEntry[]): JavaMail[] => entries.map((entry) => [formatMail(entry.mail), String(entry.isNew), String(entry.isMarkedForDeletion)]);
|
|
|
|
const GMX = 'mail.gmx.de (130.100.200.25)';
|
|
const WEBMAIL = 'mail.webmail.de (140.100.200.25)';
|
|
const GNUTELLA_PEERS = ['Notebook 1.10', 'Notebook 1.11', 'DNS-Server 2.10'];
|
|
|
|
const CLASSICS: Record<string, Classic> = {
|
|
web: {
|
|
file: 'internetwork_komplett.fls',
|
|
act: (engine, node, _program, call) => engine.webBrowser(node('Notebook 1.10'))!.open(call.slice('holeWebseite '.length)),
|
|
javaLines: (java) => javaLinesOf(java, ['browser', 'Webserver 5.10']),
|
|
engineLines: (engine, node) => ({
|
|
browser: lines(engine.webBrowser(node('Notebook 1.10'))!.log.all, browserText),
|
|
'Webserver 5.10': lines(engine.webServer(node('Webserver 5.10'))!.log.all, serverText),
|
|
}),
|
|
end: () => ({}),
|
|
looks: 1,
|
|
},
|
|
email: {
|
|
file: 'Internet_Komplett_mit_eMail_Webserver_Intranet_Portforwarding_Firewall_DHCP_DE.fls',
|
|
act: (engine, node, program, call) => {
|
|
const client = engine.emailClient(node(program))!;
|
|
if (call === 'emailsAbholenEmails') {
|
|
expect(client.fetch()).toBe(true);
|
|
} else {
|
|
expect(client.send({ to: call.slice('versendeEmail '.length), cc: '', bcc: '', subject: 'Treffen', text: 'Hallo Frimp,\nbis morgen!\nHerbert' })).toBeUndefined();
|
|
}
|
|
},
|
|
javaLines: (java) => javaLinesOf(java, ['Notebook', 'Notebook#2', GMX, WEBMAIL]),
|
|
engineLines: (engine, node) => ({
|
|
Notebook: lines(engine.emailClient(node('Notebook'))!.log.all, emailClientText),
|
|
'Notebook#2': lines(engine.emailClient(node('Notebook#2'))!.log.all, emailClientText),
|
|
[GMX]: lines(engine.emailServer(node(GMX))!.log.all, (entry) => emailServerText(entry)),
|
|
[WEBMAIL]: lines(engine.emailServer(node(WEBMAIL))!.log.all, (entry) => emailServerText(entry)),
|
|
}),
|
|
end: (engine, node) => ({
|
|
[GMX]: engine.emailServer(node(GMX))!.state.accounts.map((each) => [each.username, mails(each.mailbox)]),
|
|
[WEBMAIL]: engine.emailServer(node(WEBMAIL))!.state.accounts.map((each) => [each.username, mails(each.mailbox)]),
|
|
...Object.fromEntries(
|
|
['Notebook', 'Notebook#2'].map((name) => {
|
|
const client = engine.emailClient(node(name))!;
|
|
return [name, { inbox: mails(client.inbox), sent: mails(client.sent) }];
|
|
}),
|
|
),
|
|
}),
|
|
// Two connections one after the other, each opened at a look: herbert's to mail.gmx.de, then the
|
|
// server's on to mail.webmail.de (measured: Java some 80 ms later at each; the fetch is a call of its own).
|
|
looks: 2,
|
|
afterLeases: true,
|
|
// #3 reaches the school's network (42.0.0.0) through "" or #2, two hops each: every answer to the school.
|
|
ripTies: true,
|
|
},
|
|
gnutella: {
|
|
file: 'zwei_rechnernetze_komplett.fls',
|
|
prepare: (project, node) => {
|
|
for (const name of GNUTELLA_PEERS) {
|
|
project.installedPrograms.get(node(name))!.add('gnutella');
|
|
project.gnutellaConfigurations.set(node(name), { maximumKnownPeers: 3 });
|
|
}
|
|
const files = project.virtualFileSystems.get(node('DNS-Server 2.10')) ?? new VirtualFileSystem();
|
|
project.virtualFileSystems.set(node('DNS-Server 2.10'), files);
|
|
files.createDirectory(GNUTELLA_FOLDER, true);
|
|
files.writeTextFile(`${GNUTELLA_FOLDER}/Lied der Woche.txt`, 'La la la');
|
|
},
|
|
// A hit's size and a pong's bytes, with the payload length that counts them (JAVA_BUGS; gnutellaJava.test.ts).
|
|
cell: (text) => text.replace(/: \d+ B\/\//g, ': n B//').replace(/(\/\/0x01\/\/\d+\/\/\d+\/\/)\d+(\/\/.*\/\/6346\/\/\d+\/\/)\d+$/, '$1n$2n'),
|
|
// A search goes to both peers of 1.10 at once, and at its card the frames of the two connections come in
|
|
// either order (gnutellaJava.test.ts, BY_CONNECTION): compared direction by direction between two hosts on
|
|
// the server's port (the query's and the download's connection to 2.10 one after the other), ARP on its own.
|
|
flow: (text) => / TCP (\S+) > (\S+) /.exec(text)?.slice(1).join(' > ') ?? 'ARP',
|
|
// The ids of Java's own pings and queries (a ping or query with no hops yet), in the order they were made:
|
|
// the engine draws its own, these take their place (gnutellaJava.test.ts).
|
|
beforeRun: (engine, java) => {
|
|
const own = Object.values(java.frames)
|
|
.flat()
|
|
.flatMap((frame) => {
|
|
const match = /^(\d+)\/\/0x(?:00|80)\/\/0\/\//.exec(frame.payload ?? '');
|
|
return match === null ? [] : [[frame.time, Number(match[1])] as const];
|
|
})
|
|
.sort((lhs, rhs) => lhs[0] - rhs[0]);
|
|
const ids = [...new Set(own.map(([, guid]) => guid))];
|
|
engine.drawGnutellaGuid = () => ids.shift() ?? 1;
|
|
},
|
|
act: (engine, node, program, call) => {
|
|
const peer = engine.gnutella(node(program))!;
|
|
if (call.startsWith('join ')) peer.join(call.slice('join '.length));
|
|
else if (call.startsWith('search ')) peer.search(call.slice('search '.length));
|
|
else peer.download(Number(call.slice('download '.length)));
|
|
},
|
|
// Each program's peers and results as it told them, each change once (Java tells its window also when
|
|
// nothing changed, and empty lists at its start); results only when there are some.
|
|
javaLines: (java) => {
|
|
const told: Lines = Object.fromEntries(GNUTELLA_PEERS.flatMap((name) => [[`${name} peers`, []], [`${name} results`, []]]));
|
|
for (const [time, program, text] of java.programs) {
|
|
const [kind, list] = [text.slice(0, text.indexOf(' ')), text.slice(text.indexOf(' ') + 1)];
|
|
if (kind === 'call' || (kind === 'results' && list === '')) continue;
|
|
const each = told[`${program} ${kind}`]!;
|
|
if ((each.at(-1)?.[1] ?? '') !== list) each.push([time, list]);
|
|
}
|
|
return told;
|
|
},
|
|
engineLines: (engine, node) =>
|
|
Object.fromEntries(
|
|
GNUTELLA_PEERS.flatMap((name) =>
|
|
(['peers', 'results'] as const).map((wanted) => {
|
|
const told: [number, string][] = [];
|
|
for (const line of engine.gnutella(node(name))!.log.all) {
|
|
if (line.kind !== wanted) continue;
|
|
const text = line.kind === 'peers' ? line.peers.join(',') : line.results.join('|');
|
|
if ((wanted === 'results' && text === '') || (told.at(-1)?.[1] ?? '') === text) continue;
|
|
told.push([line.timeMilliseconds, text]);
|
|
}
|
|
return [`${name} ${wanted}`, told];
|
|
}),
|
|
),
|
|
),
|
|
end: (engine, node) =>
|
|
Object.fromEntries(
|
|
GNUTELLA_PEERS.map((name) => {
|
|
const peer = engine.gnutella(node(name))!;
|
|
const files = engine.state.topologySnapshot.fileSystemsByNodeId.get(node(name))!;
|
|
const shared = files.contains(GNUTELLA_FOLDER)
|
|
? files.entriesInCreationOrder(GNUTELLA_FOLDER).flatMap((entry): [string, string, string][] => {
|
|
const content = entry.content;
|
|
if (content.kind === 'directory') return [];
|
|
const fileName = entry.path.slice(entry.path.lastIndexOf('/') + 1);
|
|
return [[fileName, javaFileTypeName(fileName, content), content.kind === 'text' ? content.text : javaBase64(content.data)]];
|
|
})
|
|
: [];
|
|
return [name, { peers: [...peer.peers], results: [...peer.results], files: shared }];
|
|
}),
|
|
),
|
|
looks: 2,
|
|
},
|
|
};
|
|
|
|
/** A hit's size as Java's Datei.holeGroesse says it (the text decoded as Base64), and as the engine (its characters; gnutellaJava.test.ts). */
|
|
const withHitSize = (text: string) => text.replace('/Lied der Woche.txt: 3 B', '/Lied der Woche.txt: 8 B');
|
|
|
|
/** Java's run without the frames `keep` refuses, each card's table rows numbered anew. */
|
|
function withFrames(java: JavaRun, keep: (frame: JavaFrame, card: string, frames: readonly JavaFrame[]) => boolean): JavaRun {
|
|
const numbers = new Map<string, Map<number, number>>();
|
|
const frames = Object.fromEntries(
|
|
Object.entries(java.frames).map(([card, list]) => {
|
|
const renumbered = new Map<number, number>();
|
|
const kept = list.filter((frame, index) => {
|
|
if (!keep(frame, card, list)) return false;
|
|
renumbered.set(index + 1, renumbered.size + 1);
|
|
return true;
|
|
});
|
|
numbers.set(card, renumbered);
|
|
return [card, kept];
|
|
}),
|
|
);
|
|
const tables = (rows: Record<string, (string | null)[][]> | undefined) =>
|
|
rows === undefined
|
|
? undefined
|
|
: Object.fromEntries(
|
|
Object.entries(rows).map(([card, list]) => [
|
|
card,
|
|
list.flatMap((row) => {
|
|
const number = numbers.get(card)?.get(Number(row[0]));
|
|
return number === undefined ? [] : [[String(number), ...row.slice(1)]];
|
|
}),
|
|
]),
|
|
);
|
|
return { ...java, frames, rows: tables(java.rows), layeredRows: tables(java.layeredRows) };
|
|
}
|
|
|
|
/** A Gnutella connection by its client's end. */
|
|
const clientEnd = (frame: JavaFrame) => (frame.sourcePort === 6346 ? `${frame.destinationIp}:${frame.destinationPort}` : `${frame.sourceIp}:${frame.sourcePort}`);
|
|
|
|
/**
|
|
* Where Java errs, per story: its run corrected, as Java means it.
|
|
*
|
|
* gnutella: a hit names the file's size in characters, where Java decodes
|
|
* its text as Base64 first (gnutella.ts); in the frames' rows the sizes
|
|
* are masked (Classic.cell), as a pong's bytes are. And Java's server closes
|
|
* a connection after its hits; the engine keeps it, as it keeps every other
|
|
* (gnutella.ts): each connection whose server sends the first FIN, without
|
|
* its frames from that FIN on (gnutellaJava.test.ts, beforeServerFins).
|
|
* And 2.10 passes the query on to 1.11, which has no gateway and cannot
|
|
* answer: Java's connectClientMode sends the SYN once and gives up after one
|
|
* wait (tcpJava.test.ts, closedPort); the engine sends it three times, a
|
|
* wait of three round trips each. The two later SYNs added on every card
|
|
* the first showed on (without rows of Java's).
|
|
*/
|
|
const JAVA_TIMEOUT_MILLISECONDS = 3 * 2_750;
|
|
const toUnreachable = (frame: JavaFrame) => frame.tcpFlags === 'S' && frame.destinationIp === '141.99.1.11' && frame.sourceIp === '141.99.2.10';
|
|
|
|
const JAVA_BUGS: Record<string, (java: JavaRun) => JavaRun> = {
|
|
gnutella: (java) =>
|
|
withSynRetries(
|
|
withFrames(
|
|
{
|
|
...java,
|
|
programs: java.programs.map(([time, program, text]) => [time, program, withHitSize(text)]),
|
|
end: JSON.parse(withHitSize(JSON.stringify(java.end))) as Record<string, unknown>,
|
|
},
|
|
(frame, _card, frames) => {
|
|
if (frame.tcpFlags === undefined) return true;
|
|
const firstFin = frames.find((each) => each.tcpFlags?.includes('F') === true && clientEnd(each) === clientEnd(frame));
|
|
return firstFin === undefined || firstFin.sourcePort !== 6346 || frames.indexOf(frame) < frames.indexOf(firstFin);
|
|
},
|
|
),
|
|
),
|
|
};
|
|
|
|
/** Java's run with the SYN to 1.11 sent twice more, a timeout apart, on every card it showed on. */
|
|
function withSynRetries(java: JavaRun): JavaRun {
|
|
return {
|
|
...java,
|
|
frames: Object.fromEntries(
|
|
Object.entries(java.frames).map(([card, list]) => {
|
|
const syn = list.find(toUnreachable);
|
|
return [card, syn === undefined ? list : [...list, ...[1, 2].map((attempt) => ({ ...syn, time: syn.time + attempt * JAVA_TIMEOUT_MILLISECONDS }))]];
|
|
}),
|
|
),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Initial sequence numbers below the millions. Java gives the next number to
|
|
* each TCP socket as it is made (the core does too), and a server makes its
|
|
* first listening socket in its own thread when the simulation starts
|
|
* (ServerAnwendung.annehmenVerbindungen): which server of an example draws
|
|
* which number is a race of Java's threads.
|
|
*/
|
|
const withoutInitialNumbers = (text: string) =>
|
|
text
|
|
.replace(/(seq|ack)=(\d+)/g, (_, name: string, value: string) => `${name}=${Number(value) % 1_000_000}`)
|
|
.replace(/(SEQ|ACK): \d+\.(\d{3}\.\d{3})/g, '$1: n.$2');
|
|
|
|
/** A frame's text with placeholders: free ports, DNS message IDs (drawn at random in Java, from the seed in the engine), initial sequence numbers. */
|
|
const frameText = (text: string) => withoutInitialNumbers(withClientPortText(text)).replace(/ID=\d+/g, 'ID=x');
|
|
|
|
/** The example read, changed as the story changes it, and started in the engine. */
|
|
function started(classic: Classic, java: JavaRun) {
|
|
const project = openExample(classic.file);
|
|
const names = namesInJavaOrder(project);
|
|
const node: Node = (nodeName) => names.get(nodeName)!;
|
|
classic.prepare?.(project, node);
|
|
const engine = new NetworkRuntimeEngine(SEED);
|
|
engine.handle({ type: 'start', snapshot: projectSnapshot(project), seed: SEED, initialTimeMilliseconds: 0 });
|
|
classic.beforeRun?.(engine, java);
|
|
return { project, names, node, engine };
|
|
}
|
|
|
|
/** The story played in the engine as Java played it, and compared with Java's run (corrected, JAVA_BUGS). */
|
|
function expectSameAsJava(name: string, java: JavaRun): void {
|
|
const classic = CLASSICS[name]!;
|
|
const { project, names, node, engine } = started(classic, java);
|
|
|
|
const calls = java.programs.filter(([, , text]) => text.startsWith('call '));
|
|
for (const [index, [time, program, text]] of calls.entries()) {
|
|
engine.handle({ type: 'advance', toMilliseconds: time });
|
|
if (index === 0 && classic.ripTies !== undefined) equalRipTies(engine, java, node, time);
|
|
classic.act(engine, node, program, text.slice('call '.length));
|
|
}
|
|
const last = Math.max(...Object.values(java.frames).flat().map((frame) => frame.time), ...java.programs.map(([time]) => time));
|
|
engine.handle({ type: 'advance', toMilliseconds: last + 5_000 });
|
|
|
|
const tolerance = GAP_TOLERANCE_MILLISECONDS + classic.looks * POLL_MILLISECONDS;
|
|
const { cards, withEngineMacs } = exampleCards(project, names, java.cards);
|
|
const firstCall = calls[0]![0];
|
|
expectSameCaptures(engine, cards, java.frames, {
|
|
text: (text) => frameText(withEngineMacs(text)),
|
|
cell: (text) => (classic.cell ?? ((each: string) => each))(withoutInitialNumbers(withEngineMacs(text))),
|
|
tables: java,
|
|
tolerance,
|
|
// Both run on the same clock: the calls came at the same times.
|
|
starts: { java: 0, engine: 0 },
|
|
from: classic.afterLeases === undefined ? undefined : firstCall,
|
|
flow: classic.flow,
|
|
// RIP's beacons come every 30 s after random pauses, as many as a router drew since its start, which
|
|
// depends on the order Java's threads start in (tools/java-oracle/README.md, RIP): RIP is the
|
|
// acceptance of F2 (examplesJava.test.ts, with its tables), the programs' frames are compared here.
|
|
compared: (text) => !/ UDP \S+:521 > \S+:520 /.test(text),
|
|
});
|
|
|
|
const expected = classic.javaLines(java);
|
|
const actual = classic.engineLines(engine, node);
|
|
expect(Object.keys(actual).sort()).toEqual(Object.keys(expected).sort());
|
|
for (const [program, expectedLines] of Object.entries(expected)) {
|
|
const actualLines = actual[program]!;
|
|
expect(
|
|
actualLines.map(([, text]) => text),
|
|
`${program}'s lines`,
|
|
).toEqual(expectedLines.map(([, text]) => text));
|
|
for (const [index, [javaTime, text]] of expectedLines.entries()) {
|
|
const late = javaTime - actualLines[index]![0];
|
|
const most = (isPolled(text) ? POLL_MILLISECONDS : 0) + tolerance;
|
|
expect(Math.abs(late), `${program}'s line ${JSON.stringify(text.slice(0, 60))}: Java ${late} ms later`).toBeLessThanOrEqual(most);
|
|
}
|
|
}
|
|
expect(classic.end(engine, node)).toEqual(java.end);
|
|
}
|
|
|
|
/**
|
|
* Java's run with two frames of one card in each other's place, their rows
|
|
* too: the order Java's threads give two connections a few ten
|
|
* milliseconds apart.
|
|
*/
|
|
function withSwapped(java: JavaRun, card: string, index: number): JavaRun {
|
|
const frames = [...java.frames[card]!];
|
|
[frames[index], frames[index + 1]] = [frames[index + 1]!, frames[index]!];
|
|
const [first, second] = [String(index + 1), String(index + 2)];
|
|
const rows = (tables: Record<string, (string | null)[][]> | undefined) =>
|
|
tables === undefined
|
|
? undefined
|
|
: { ...tables, [card]: tables[card]!.map((row) => (row[0] === first ? [second, ...row.slice(1)] : row[0] === second ? [first, ...row.slice(1)] : row)) };
|
|
return { ...java, frames: { ...java.frames, [card]: frames }, rows: rows(java.rows), layeredRows: rows(java.layeredRows) };
|
|
}
|
|
|
|
describe("the classics of the classroom on FILIUS' examples as Java FILIUS runs them", () => {
|
|
it('corrects Java only where it errs', () => {
|
|
for (const [name, correct] of Object.entries(JAVA_BUGS)) {
|
|
const java = exampleRecording<JavaRun>(name);
|
|
expect(JSON.stringify(correct(java)), name).not.toBe(JSON.stringify(java));
|
|
}
|
|
});
|
|
|
|
it("gives the engine Java's way only where both are as long", () => {
|
|
const java = exampleRecording<JavaRun>('email');
|
|
const firstCall = java.programs.find(([, , text]) => text.startsWith('call '))![0];
|
|
// #3's route to the school's network, two hops through "" (12.0.0.1) or #2 (3.0.0.1), as Java's table says it.
|
|
const withRoute = (route: string): JavaRun => ({
|
|
...java,
|
|
ripTables: java.ripTables.map(([time, router, table]) => [time, router, router === '#3' ? table.replace(/42\.0\.0\.0\/\S+ \S+ \S+ \d+/, route) : table]),
|
|
});
|
|
const given = (recorded: JavaRun) => {
|
|
const { engine, node } = started(CLASSICS.email!, recorded);
|
|
engine.handle({ type: 'advance', toMilliseconds: firstCall });
|
|
equalRipTies(engine, recorded, node, firstCall);
|
|
return engine.state.ripTablesByNodeId.get(node('#3'))!.find((route) => route.destinationNetwork === '42.0.0.0')!;
|
|
};
|
|
for (const [gateway, card] of [['12.0.0.1', '12.0.0.2'], ['3.0.0.1', '3.0.0.2']] as const) {
|
|
expect(given(withRoute(`42.0.0.0/255.255.255.0 ${gateway} ${card} 2`)), gateway).toMatchObject({ nextHop: gateway, interfaceIPAddress: card, metric: 2 });
|
|
}
|
|
// Another length is no tie.
|
|
expect(() => given(withRoute('42.0.0.0/255.255.255.0 3.0.0.1 3.0.0.2 3'))).toThrow();
|
|
});
|
|
|
|
for (const name of Object.keys(CLASSICS)) {
|
|
it(`${name}: ${CLASSICS[name]!.file}`, () => {
|
|
const measured = exampleRecording<JavaRun>(name);
|
|
expectSameAsJava(name, JAVA_BUGS[name]?.(measured) ?? measured);
|
|
});
|
|
}
|
|
|
|
it("gnutella: the search's two connections in either order", () => {
|
|
// A recording before had them the other way round at 1.10's card (CLASSICS.gnutella.flow).
|
|
const java = JAVA_BUGS.gnutella!(exampleRecording<JavaRun>('gnutella'));
|
|
const card = Object.entries(java.cards).find(([, each]) => each === 'Notebook 1.10/0')![0];
|
|
// The first two frames after the search between other hosts each, within 60 ms of each other.
|
|
const hosts = (frame: JavaFrame) => [frame.sourceIp, frame.destinationIp].sort().join(' ');
|
|
const frames = java.frames[card]!;
|
|
const index = frames.findIndex((frame, at) => {
|
|
const next = frames[at + 1];
|
|
return frame.time >= 5_500 && frame.tcpFlags !== undefined && next?.tcpFlags !== undefined && hosts(frame) !== hosts(next) && next.time - frame.time <= 60;
|
|
});
|
|
expect(index).toBeGreaterThan(0);
|
|
expectSameAsJava('gnutella', withSwapped(java, card, index));
|
|
});
|
|
});
|