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>
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user