test: F3 acceptance — the classroom classics on FILIUS' examples against Java
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>
This commit is contained in:
@@ -2189,6 +2189,20 @@ describe('RIP', () => {
|
||||
expect(engine.sendIPv4Packet(routerAId, packet)).toEqual({ type: 'icmpError', packetIdentity: packet.identity, nodeId: routerAId, kind: 'destinationNetworkUnreachable' });
|
||||
});
|
||||
|
||||
it('keeps the route it learned first when another router tells one as long', () => {
|
||||
// Java keeps whichever its threads handle first (classicsJava.test.ts, equalRipTies); the engine the first it hears.
|
||||
const engine = ripTopology();
|
||||
fromB(engine, '10.0.0.3\n10.0.0.3\n16\n75000\n203.0.113.0 255.255.255.0 1');
|
||||
const expires = route(engine, routerAId, '203.0.113.0')!.expiresAtMilliseconds;
|
||||
engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + 5_000 });
|
||||
fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n203.0.113.0 255.255.255.0 1');
|
||||
// Nor does that one keep it alive.
|
||||
expect(route(engine, routerAId, '203.0.113.0')).toMatchObject({ metric: 2, nextHop: '10.0.0.3', expiresAtMilliseconds: expires });
|
||||
// A shorter one replaces it.
|
||||
fromB(engine, '10.0.0.2\n10.0.0.2\n16\n75000\n203.0.113.0 255.255.255.0 0');
|
||||
expect(route(engine, routerAId, '203.0.113.0')).toMatchObject({ metric: 1, nextHop: '10.0.0.2' });
|
||||
});
|
||||
|
||||
it('forwards along the fewest hops, then the longest mask, and not along 16 hops', () => {
|
||||
const engine = ripTopology();
|
||||
// The /24 through a router at 10.0.0.3, which is not there.
|
||||
|
||||
@@ -131,7 +131,7 @@ describe('the Gnutella program in the engine', () => {
|
||||
ping(engine, '5//0x00//0//8//0//192.168.0.20');
|
||||
engine.gnutella(ECHO)!.reset();
|
||||
expect(engine.gnutella(ECHO)!.peers).toEqual([]);
|
||||
expect(engine.gnutella(ECHO)!.log.all.map((line) => line.peers)).toEqual([['192.168.0.20'], []]);
|
||||
expect(engine.gnutella(ECHO)!.log.all.flatMap((line) => (line.kind === 'peers' ? [line.peers] : []))).toEqual([['192.168.0.20'], []]);
|
||||
});
|
||||
|
||||
it('closes a connection whose text has no packet head, and leaves a packet of another kind (Java: its worker throws on the one)', () => {
|
||||
|
||||
@@ -319,7 +319,7 @@ describe('the Gnutella program as Java FILIUS runs it', () => {
|
||||
const expected = connections(
|
||||
(SEARCHING.has(name) ? beforeServerFins(list) : list)
|
||||
.filter((frame) => frame.time < javaUntil)
|
||||
.map((frame) => ({ time: frame.time, raw: javaText(frame), text: masked(`${withClientPortText(javaText(frame))} ${data(frame.payload)}`) }))
|
||||
.map((frame) => ({ time: frame.time, raw: javaText(frame), text: masked(`${withClientPortText(javaText(frame))} ${data(frame.payload ?? undefined)}`) }))
|
||||
.filter(compared),
|
||||
);
|
||||
const engineUntil = UP_TO_FIRST_HIT.has(name) ? firstEngineHit(engine, scenario) : until;
|
||||
|
||||
@@ -207,6 +207,16 @@ export function expectSameCaptures(
|
||||
tables?: JavaTables;
|
||||
/** Placeholders in a table cell, beyond those of tableCell. */
|
||||
cell?: (text: string) => string;
|
||||
/**
|
||||
* A frame's flow, by its text (after `text`). Where frames of different
|
||||
* flows may come in either order (Java's threads, a few ten
|
||||
* milliseconds apart), a card's frames are compared flow by flow, the
|
||||
* flows by their names: each flow's frames in order, at about the same
|
||||
* time, with their rows.
|
||||
*/
|
||||
flow?: ((text: string) => string) | undefined;
|
||||
/** Which frames are compared, by their text (after `text`); all where not given. */
|
||||
compared?: ((text: string) => boolean) | undefined;
|
||||
} = {},
|
||||
): void {
|
||||
const text = options.text ?? ((each: string) => each);
|
||||
@@ -225,14 +235,31 @@ export function expectSameCaptures(
|
||||
const from = (options.from ?? Number.NEGATIVE_INFINITY) - javaStart;
|
||||
const until = (options.until ?? Number.POSITIVE_INFINITY) - javaStart;
|
||||
const normalized = options.normalized ?? ((frames: SeenFrame[], _card: string) => frames);
|
||||
const compared = options.compared ?? (() => true);
|
||||
const window = (frames: SeenFrame[], start: number) =>
|
||||
frames.filter((frame) => frame.time - start >= from && frame.time - start < until).map((frame) => ({ time: frame.time - start, text: frame.text }));
|
||||
frames
|
||||
.filter((frame) => frame.time - start >= from && frame.time - start < until && compared(frame.text))
|
||||
.map((frame) => ({ time: frame.time - start, text: frame.text }));
|
||||
const inWindow = (time: number, start: number) => time - start >= from && time - start < until;
|
||||
// Stable: a flow's frames keep their order.
|
||||
const inFlows = <T>(items: T[], textOf: (item: T) => string): T[] =>
|
||||
options.flow === undefined
|
||||
? items
|
||||
: items
|
||||
.map((item, index) => ({ item, index, flow: options.flow!(textOf(item)) }))
|
||||
.sort((lhs, rhs) => (lhs.flow < rhs.flow ? -1 : lhs.flow > rhs.flow ? 1 : lhs.index - rhs.index))
|
||||
.map(({ item }) => item);
|
||||
for (const [card, javaList] of javaByCard) {
|
||||
if (options.tables !== undefined) {
|
||||
// The rows of the frames in the window, frame by frame.
|
||||
const javaNumbers = javaList.flatMap((frame, index) => (inWindow(frame.time, javaStart) ? [index + 1] : []));
|
||||
const engineFrames = capturedByCard.get(card)!.filter((each) => inWindow(each.timeMilliseconds, engineStart));
|
||||
const javaNumbers = inFlows(
|
||||
javaList.flatMap((frame, index) => (inWindow(frame.time, javaStart) && compared(frame.text) ? [index + 1] : [])),
|
||||
(number) => javaList[number - 1]!.text,
|
||||
);
|
||||
const engineFrames = inFlows(
|
||||
capturedByCard.get(card)!.filter((each) => inWindow(each.timeMilliseconds, engineStart) && compared(text(engineText(each.frame)))),
|
||||
(each) => text(engineText(each.frame)),
|
||||
);
|
||||
for (const [view, withAddresses] of [['rows', true], ['layeredRows', false]] as const) {
|
||||
const javaRows = options.tables[view]?.[card];
|
||||
if (javaRows === undefined) continue;
|
||||
@@ -250,8 +277,8 @@ export function expectSameCaptures(
|
||||
}
|
||||
}
|
||||
}
|
||||
const expected = normalized(window(javaList, javaStart), card);
|
||||
const actual = normalized(window(engineByCard.get(card)!, engineStart), card);
|
||||
const expected = inFlows(normalized(window(javaList, javaStart), card), (frame) => frame.text);
|
||||
const actual = inFlows(normalized(window(engineByCard.get(card)!, engineStart), card), (frame) => frame.text);
|
||||
for (let index = 0; index < Math.max(expected.length, actual.length); index += 1) {
|
||||
expect(actual[index]?.text, `${card} frame #${index + 1}`).toBe(expected[index]?.text);
|
||||
expect(Math.abs(actual[index]!.time - expected[index]!.time), `${card} time of frame #${index + 1}`).toBeLessThanOrEqual(
|
||||
@@ -268,8 +295,8 @@ export function withClientPortText(text: string): string {
|
||||
|
||||
/** Java's capture tables per card: number, source, destination, protocol, layer, comment (ScenarioNetwork.writeRows). */
|
||||
export interface JavaTables {
|
||||
rows?: Record<string, (string | null)[][]>;
|
||||
layeredRows?: Record<string, (string | null)[][]>;
|
||||
rows?: Record<string, (string | null)[][]> | undefined;
|
||||
layeredRows?: Record<string, (string | null)[][]> | undefined;
|
||||
}
|
||||
|
||||
/** Java's layer names and comment words: its German message bundle, the locale it runs in (Locale.GERMANY). */
|
||||
|
||||
@@ -542,6 +542,7 @@ describe('the command line on the network beyond the stories', () => {
|
||||
return [router(number), [...back, ...on]];
|
||||
}),
|
||||
),
|
||||
steps: [],
|
||||
};
|
||||
const engine = watchedEngine(chain, withSnapshot(snapshotOf(chain), { language: 'de', terminalNodeIds: new Set([client]) })).engine;
|
||||
engine.terminal(client)!.enter(`traceroute 10.${ROUTERS + 1}.0.10`);
|
||||
@@ -563,7 +564,7 @@ describe('the command line on the network beyond the stories', () => {
|
||||
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'terminal-net.scenario.json'), 'utf8')) as Scenario;
|
||||
// The first frame comes about 110 ms after 3 s: at a time of day with two digits of milliseconds (Java pads to three).
|
||||
const base = new Date(2026, 9, 2, 10, 37, 6, 900).getTime();
|
||||
const engine = new NetworkRuntimeEngine(scenario.seed, undefined, { wallClock: () => base + engine.state.currentTimeMilliseconds });
|
||||
const engine: NetworkRuntimeEngine = new NetworkRuntimeEngine(scenario.seed, undefined, { wallClock: () => base + engine.state.currentTimeMilliseconds });
|
||||
engine.handle({
|
||||
type: 'start',
|
||||
snapshot: withSnapshot(snapshotOf(scenario), { language: 'de', terminalNodeIds: new Set(HOSTS) }),
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
// 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));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
// What the tests of FILIUS' own examples share (examplesJava.test.ts, the
|
||||
// acceptance of F2; classicsJava.test.ts, the acceptance of F3): an example
|
||||
// read with this package's reader, its nodes by the names Java gives them,
|
||||
// and each card Java recorded by its MAC address.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { stableMacAddress, type Id } from '@mdg-filius/core';
|
||||
import { openProjectArchive, type ImportedProject } from '../src/index.js';
|
||||
import type { CaptureCard } from '../../core/test/javaRun.js';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const examples = join(here, '../../../corpus/java-examples');
|
||||
|
||||
/** The recording of a story (examples/<story>.java.json). */
|
||||
export function exampleRecording<T>(story: string): T {
|
||||
return JSON.parse(readFileSync(join(here, 'examples', `${story}.java.json`), 'utf8')) as T;
|
||||
}
|
||||
|
||||
/** An example as this package reads it, its ids numbered as the file's entries come. */
|
||||
export function openExample(file: string): ImportedProject {
|
||||
let counter = 0;
|
||||
return openProjectArchive(new Uint8Array(readFileSync(join(examples, file))), () => `00000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`);
|
||||
}
|
||||
|
||||
/** Java's names as ScenarioNetwork.fromFile gives them: a repeated name gets #2, #3, … in the file's order. */
|
||||
export function namesInJavaOrder(project: ImportedProject): Map<string, Id> {
|
||||
const names = new Map<string, Id>();
|
||||
for (const node of project.graph.nodes) {
|
||||
let name = node.displayName;
|
||||
for (let count = 2; names.has(name); count += 1) name = `${node.displayName}#${count}`;
|
||||
names.set(name, node.id);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Each card Java recorded, by its node and index (`name/index`). A card the
|
||||
* file has no MAC address for gets one Java rolls at each load, and the
|
||||
* model one derived from its port: Java's stands for the engine's in the
|
||||
* texts (withEngineMacs).
|
||||
*/
|
||||
export function exampleCards(
|
||||
project: ImportedProject,
|
||||
names: ReadonlyMap<string, Id>,
|
||||
javaCards: Record<string, string>,
|
||||
): { readonly cards: CaptureCard[]; readonly withEngineMacs: (text: string) => string } {
|
||||
const engineMacByJavaMac = new Map<string, string>();
|
||||
const cards: CaptureCard[] = Object.entries(javaCards).map(([mac, card]) => {
|
||||
const [nodeName, index] = card.split('/') as [string, string];
|
||||
const node = project.graph.nodes.find((each) => each.id === names.get(nodeName))!;
|
||||
const port = node.ports[Number(index)]!;
|
||||
engineMacByJavaMac.set(mac.toUpperCase(), (port.importedMacAddress ?? stableMacAddress(port.id)).toUpperCase());
|
||||
return { key: mac, portId: port.id };
|
||||
});
|
||||
const withEngineMacs = (text: string) => text.replace(/[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}/g, (mac) => engineMacByJavaMac.get(mac.toUpperCase()) ?? mac);
|
||||
return { cards, withEngineMacs };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -8,12 +8,10 @@
|
||||
// table rows (javaRun.ts, cards by their MAC address from the file), the
|
||||
// calls must return the same, the hosts must end with the same addresses.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { interfaceKey, NetworkRuntimeEngine, stableMacAddress, type DnsAddressResolution, type Id, type IPv4DeliveryResult } from '@mdg-filius/core';
|
||||
import { openProjectArchive, projectSnapshot, type ImportedProject } from '../src/index.js';
|
||||
import { projectSnapshot } from '../src/index.js';
|
||||
import { exampleCards, exampleRecording, namesInJavaOrder, openExample } from './exampleRun.js';
|
||||
import {
|
||||
engineText,
|
||||
expectSameCaptures,
|
||||
@@ -21,14 +19,11 @@ import {
|
||||
gaps,
|
||||
javaFrames,
|
||||
withClientPortText,
|
||||
type CaptureCard,
|
||||
type JavaFrame,
|
||||
type JavaTables,
|
||||
type SeenFrame,
|
||||
} from '../../core/test/javaRun.js';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const examples = join(here, '../../../corpus/java-examples');
|
||||
const SEED = 14;
|
||||
|
||||
interface JavaRun extends JavaTables {
|
||||
@@ -160,31 +155,19 @@ const JAVA_BUGS: Record<string, (java: JavaRun) => JavaRun> = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Java's names as ScenarioNetwork.fromFile gives them: a repeated name gets #2, #3, … in the file's order. */
|
||||
function namesInJavaOrder(project: ImportedProject): Map<string, Id> {
|
||||
const names = new Map<string, Id>();
|
||||
for (const node of project.graph.nodes) {
|
||||
let name = node.displayName;
|
||||
for (let count = 2; names.has(name); count += 1) name = `${node.displayName}#${count}`;
|
||||
names.set(name, node.id);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
describe("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 = JSON.parse(readFileSync(join(here, 'examples', `${name}.java.json`), 'utf8')) as JavaRun;
|
||||
const java = exampleRecording<JavaRun>(name);
|
||||
expect(JSON.stringify(correct(java)), name).not.toBe(JSON.stringify(java));
|
||||
}
|
||||
});
|
||||
|
||||
for (const [name, story] of Object.entries(STORIES)) {
|
||||
it(`${name}: ${story.file}`, () => {
|
||||
const measured = JSON.parse(readFileSync(join(here, 'examples', `${name}.java.json`), 'utf8')) as JavaRun;
|
||||
const measured = exampleRecording<JavaRun>(name);
|
||||
const java = JAVA_BUGS[name]?.(measured) ?? measured;
|
||||
let counter = 0;
|
||||
const project = openProjectArchive(new Uint8Array(readFileSync(join(examples, story.file))), () => `00000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`);
|
||||
const project = openExample(story.file);
|
||||
const names = namesInJavaOrder(project);
|
||||
const engine = new NetworkRuntimeEngine(SEED);
|
||||
engine.handle({ type: 'start', snapshot: projectSnapshot(project), seed: SEED, initialTimeMilliseconds: 0 });
|
||||
@@ -199,17 +182,7 @@ describe("FILIUS' examples as Java FILIUS runs them", () => {
|
||||
engine.handle({ type: 'advance', toMilliseconds: Math.max(engine.state.currentTimeMilliseconds, last) + 1_000 });
|
||||
|
||||
expect(events.map(([, event]) => event)).toEqual(java.events.map(([, event]) => event));
|
||||
// Each card by its node and index. A card the file has no MAC address for gets one Java rolls at
|
||||
// each load, and the model one derived from its port: Java's stands for the engine's in the texts.
|
||||
const engineMacByJavaMac = new Map<string, string>();
|
||||
const cards: CaptureCard[] = Object.entries(java.cards).map(([mac, card]) => {
|
||||
const [nodeName, index] = card.split('/') as [string, string];
|
||||
const node = project.graph.nodes.find((each) => each.id === names.get(nodeName))!;
|
||||
const port = node.ports[Number(index)]!;
|
||||
engineMacByJavaMac.set(mac.toUpperCase(), (port.importedMacAddress ?? stableMacAddress(port.id)).toUpperCase());
|
||||
return { key: mac, portId: port.id };
|
||||
});
|
||||
const withEngineMacs = (text: string) => text.replace(/[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}/g, (mac) => engineMacByJavaMac.get(mac.toUpperCase()) ?? mac);
|
||||
const { cards, withEngineMacs } = exampleCards(project, names, java.cards);
|
||||
const javaStoryStart = java.events[0]?.[0];
|
||||
if (story.storyStart !== undefined) {
|
||||
expectSameCaptures(engine, cards, java.frames, {
|
||||
|
||||
Reference in New Issue
Block a user