Files
mdg-filius/packages/core/test/gnutellaWindow.test.ts
julian.kiedaischandClaude Opus 5.5 5224e1a28b feat: F4.6g4 the Gnutella window, measured against Java's
GnutellaWindowRun uses Java's Gnutella window on terminal-net beside the
line of GnutellaRun's story "search". The core cancels a search as Java
does (every connection of the program's own requests closed, passed-on
queries kept), lets the number of peers change and lists /peer2peer's
files; the room takes the window's actions and sends its view; the editor
shows Java's four tabs with Java's symbols. Not adopted from Java: a number
of peers of one digit only, a results table that keeps the last search's
rows, a files table that lags behind the folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 21:39:29 +00:00

252 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The Gnutella program as Java's window uses it, against Java's
// (gnutellaWindow.java.json, GnutellaWindowRun) on terminal-net beside the
// line of GnutellaRun's story "search": Java's steps played on the engine's
// program — joining (not with what is no address, nor itself), searching,
// "Stopp!", downloading, emptying the list, the number of peers, leaving —
// and after each what the program keeps (its peers, results, files of
// /peer2peer, number of peers) and what the window's tables show. Where Java
// errs, what the program and window do instead (named at each step): the
// tables show what the program keeps at once, a number of peers is not one
// digit only, a hit names a text's size in characters (gnutellaJava.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 { GNUTELLA_FOLDER, gnutellaPeerCountOfText, gnutellaResultRow, IP_ADDRESS_PATTERN, javaFileTypeName, NetworkRuntimeEngine, VirtualFileSystem, withSnapshot } from '../src/index.js';
import { snapshotOf, type Scenario } from './scenario.js';
interface JavaStep {
readonly step: string;
readonly address: { readonly text: string; readonly red: boolean };
readonly peers: readonly (readonly string[])[];
readonly results: readonly (readonly string[])[];
readonly files: readonly (readonly string[])[];
readonly maximum: { readonly text: string; readonly red: boolean };
readonly programMaximum: number;
readonly programPeers: readonly string[];
readonly programResults: readonly string[];
readonly programFiles: readonly (readonly string[])[];
readonly dialogs: readonly string[];
}
const here = dirname(fileURLToPath(import.meta.url));
const java = JSON.parse(readFileSync(join(here, 'gnutellaWindow.java.json'), 'utf8')) as { readonly steps: readonly JavaStep[] };
const scenario = JSON.parse(readFileSync(join(here, 'scenarios/terminal-net.scenario.json'), 'utf8')) as Scenario;
const CLIENT = '00000000-0000-0000-0000-000000000401';
const WEB = '00000000-0000-0000-0000-000000000403';
const ECHO = '00000000-0000-0000-0000-000000000404';
const FAR = '00000000-0000-0000-0000-000000000406';
const PEERS = [CLIENT, WEB, ECHO, FAR];
/** Each shared text's size in characters, as the program names it in a hit (Java decodes the text as Base64: "La la la" and "Notiz" are 3 B). */
const CHARACTERS: Readonly<Record<string, number>> = { 'Lied der Woche.txt': 8, 'notizen.txt': 5 };
const inCharacters = (text: string) => text.replace(/([^/]+): 3 B$/, (_, name: string) => `${name}: ${CHARACTERS[name]} B`);
function setUp() {
const files = new Map(PEERS.map((host) => [host, new VirtualFileSystem()]));
for (const host of [ECHO, FAR]) files.get(host)!.createDirectory(GNUTELLA_FOLDER);
files.get(ECHO)!.writeTextFile(`${GNUTELLA_FOLDER}/notizen.txt`, 'Notiz');
files.get(FAR)!.writeTextFile(`${GNUTELLA_FOLDER}/Lied der Woche.txt`, 'La la la');
const engine = new NetworkRuntimeEngine(scenario.seed);
engine.handle({
type: 'start',
snapshot: withSnapshot(snapshotOf(scenario), {
gnutellaByNodeId: new Map(PEERS.map((host) => [host, { maximumKnownPeers: host === CLIENT || host === FAR ? 1 : 2 }])),
fileSystemsByNodeId: files,
}),
seed: scenario.seed,
initialTimeMilliseconds: 0,
});
const run = (milliseconds: number) => engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + milliseconds });
return { engine, files, run };
}
describe("the Gnutella window, against Java's", () => {
it("plays Java's steps: the same where Java is right, as named where it errs", () => {
const { engine, files, run } = setUp();
const program = engine.gnutella(CLIENT)!;
const folder = files.get(CLIENT)!;
const kept = () => ({
peers: program.peers,
results: program.results,
maximum: program.maximumKnownPeers,
files: folder.contains(GNUTELLA_FOLDER)
? folder.entriesInCreationOrder(GNUTELLA_FOLDER).map((entry) => {
const content = entry.content;
if (content.kind !== 'text') throw new Error(`${entry.path} is no text`);
const name = entry.path.slice(GNUTELLA_FOLDER.length + 1);
return [name, javaFileTypeName(name, content), content.text];
})
: [],
});
const at = (name: string) => java.steps.find((step) => step.step === name)!;
const javaKept = (step: JavaStep) => ({ peers: step.programPeers, results: step.programResults.map(inCharacters), maximum: step.programMaximum, files: step.programFiles });
const check = (name: string) => expect(kept(), name).toEqual(javaKept(at(name)));
/** The window's tables as they show what the program keeps (Java's lag behind it, named where they do). */
const tables = () => ({
peers: program.peers.map((peer, index) => [String(index + 1), peer]),
results: program.results.map((result) => [...gnutellaResultRow(result)]),
files: kept().files.map(([name, type]) => [name, type]),
});
const javaTables = (step: JavaStep) => ({ peers: step.peers, results: step.results.map(([address, name]) => [address, name, ` ${CHARACTERS[name!]} B`]), files: step.files });
const join = (address: string) => {
if (IP_ADDRESS_PATTERN.test(address)) program.join(address);
};
// The others form their line first, as Java's did (the window opens 5.5 s in).
run(500);
engine.gnutella(WEB)!.join('192.168.0.31');
run(2500);
engine.gnutella(FAR)!.join('192.168.0.31');
run(2500);
check('open');
expect(at('open').address).toEqual({ text: '192.168.0.1', red: false });
expect(at('open').maximum).toEqual({ text: '1', red: false });
// What is no address: red, and not taken (Java's message).
expect([IP_ADDRESS_PATTERN.test('192.168.0.300'), at('type an address that is none').address.red]).toEqual([false, true]);
join('192.168.0.300');
run(1000);
check('join with an address that is none');
expect(at('join with an address that is none').dialogs).toEqual(['Dies ist keine gültige IP-Adresse']);
join('192.168.0.20');
run(2500);
check('join itself');
join('192.168.0.30');
run(3000);
check('join 192.168.0.30');
expect(tables(), 'join 192.168.0.30').toEqual(javaTables(at('join 192.168.0.30')));
program.search('woche');
run(3000);
check('search woche');
expect(tables()).toEqual(javaTables(at('search woche')));
program.download(0);
run(3000);
check('download row 0');
// The file's table shows it at once (Java's only when the program next tells the window of a change).
expect(at('download row 0').files).toEqual([]);
expect(tables().files).toEqual([['Lied der Woche.txt', 'txt']]);
program.download(0);
run(3000);
check('download row 0 again');
program.search('xyz');
run(3000);
check('search xyz');
// The table shows no result, as the program keeps none (Java's still shows the last search's).
expect([at('search xyz').results.length, tables().results]).toEqual([1, []]);
program.clearResults();
run(500);
check('empty the list');
// "Stopp!" half a second after "Suchen" (as Java's press came): the hits on their way are lost; a new search finds them.
program.search('notiz');
run(500);
program.cancelSearch();
run(3000);
check('search notiz and abort at once');
program.search('notiz');
run(3000);
check('search notiz again');
expect(tables()).toEqual(javaTables(at('search notiz again')));
run(2000);
check('download with no row chosen');
check('files tab');
// The number of peers: what is no number refused (Java's message), 0 taken; 12 taken too (Java takes one digit only).
expect(['abc', '0', '99999999999', '12', '4', '-1', '', '2147483647', '2147483648'].map(gnutellaPeerCountOfText)).toEqual([
undefined,
0,
undefined,
12,
4,
undefined,
undefined,
2_147_483_647,
undefined,
]);
expect([at('take a number of peers that is none').dialogs, at('take 12 peers').dialogs]).toEqual([['Es sind nur Zahlen erlaubt!'], ['Es sind nur Zahlen erlaubt!']]);
program.setMaximumKnownPeers(0);
check('take 0 peers');
check("take a number beyond Java's int");
program.setMaximumKnownPeers(12);
expect(program.maximumKnownPeers).toBe(12);
program.setMaximumKnownPeers(4);
check('take 4 peers');
program.reset();
run(1000);
check('leave the network');
expect(tables().peers).toEqual([]);
// A file added beside the window: its table shows it at once (Java's not).
folder.writeTextFile(`${GNUTELLA_FOLDER}/neu.txt`, 'neu');
check('a file added beside the window');
expect([at('a file added beside the window').files.length, tables().files.length]).toEqual([1, 2]);
});
});
describe('the Gnutella program beyond the steps', () => {
it('splits a result as Java’s table does', () => {
expect(['10.0.0.2/Lied der Woche.txt: 8 B', '10.0.0.2/a:b.txt: 3 B', '//x/y', 'kein'].map(gnutellaResultRow)).toEqual([
['10.0.0.2', 'Lied der Woche.txt', ' 8 B'],
['10.0.0.2', 'a:b.txt', ' 3 B'],
['x', 'y', ''],
['kein', '', ''],
]);
});
it('keeps the peers it knows when the number shrinks, and takes no more', () => {
const { engine, run } = setUp();
const program = engine.gnutella(WEB)!;
program.setMaximumKnownPeers(1);
program.join('192.168.0.31');
run(3000);
engine.gnutella(CLIENT)!.join('192.168.0.30');
run(3000);
expect(program.peers).toEqual(['192.168.0.31']);
program.setMaximumKnownPeers(0);
expect(program.peers).toEqual(['192.168.0.31']);
});
it("cancels only the program's own requests: a query it passes on still brings its hits back", () => {
const { engine, run } = setUp();
engine.gnutella(WEB)!.join('192.168.0.31');
run(3000);
engine.gnutella(CLIENT)!.join('192.168.0.30');
run(3000);
engine.gnutella(CLIENT)!.search('notiz');
// .30 has the query (after about 330 ms) and has sent it on to .31 (about 390 ms) when it cancels its own requests
// (its join's connection): the hits on their way back through it still come.
run(450);
engine.gnutella(WEB)!.cancelSearch();
run(3000);
expect(engine.gnutella(CLIENT)!.results).toEqual(['192.168.0.31/notizen.txt: 5 B']);
});
it('shows the files of /peer2peer, not its folders, as Java lists them', () => {
const { engine, files } = setUp();
files.get(ECHO)!.createDirectory(`${GNUTELLA_FOLDER}/ordner`);
expect(engine.gnutella(ECHO)!.fileRows()).toEqual([['notizen.txt', 'txt']]);
expect(engine.gnutella(CLIENT)!.fileRows()).toEqual([]);
});
it('cancels the downloads too, and a cancel without requests changes nothing', () => {
const { engine, files, run } = setUp();
const program = engine.gnutella(CLIENT)!;
program.cancelSearch();
program.join('192.168.0.31');
run(3000);
program.search('notiz');
run(3000);
expect(program.results).toEqual(['192.168.0.31/notizen.txt: 5 B']);
program.download(0);
program.cancelSearch();
run(3000);
expect(files.get(CLIENT)!.contains(`${GNUTELLA_FOLDER}/notizen.txt`)).toBe(false);
program.download(0);
run(3000);
expect(files.get(CLIENT)!.textFile(`${GNUTELLA_FOLDER}/notizen.txt`)).toBe('Notiz');
});
});