// 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> = { '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'); }); });