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:
2026-10-03 08:01:32 +00:00
co-authored by Claude Opus 5.5
parent ce5b10c1f3
commit 49b8fe715e
16 changed files with 159639 additions and 46 deletions
+14
View File
@@ -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.
+1 -1
View File
@@ -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)', () => {
+1 -1
View File
@@ -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;
+34 -7
View File
@@ -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). */
+2 -1
View File
@@ -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) }),