Files
mdg-filius/packages/sim/test/terminalWindow.test.ts
julian.kiedaischandClaude Opus 5.5 3d6655e736 feat(sim): F4.6f1 what a run leaves goes back into the document
The oracle SaveAfterRunRun saves Java's project after a run. The room writes
what a run leaves that Java keeps - files, running servers, virtual hosts,
mail servers' domains and accounts, mail programs' accounts and folders,
firewalls, DHCP clients' addresses - into the document as origin 'sim', at
most once a second, after each action and at the stop; writeRunState writes
only what differs. The command line now goes on in a folder made before a
restart, as Java's does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 17:05:11 +00:00

251 lines
12 KiB
TypeScript

// The room's command line window against Java's (tools/java-oracle
// TerminalWindowRun): the same keys typed into a computer's window, the same
// text, prompt and history after each, over a stop and a start — the
// folder made before the stop is in the project afterwards (written back,
// F4.6f), and the window goes on in it, as Java's does.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import * as Y from 'yjs';
import { addDevice, emptyProject, ROOTS, setSoftware, writeProject } from '@mdg-filius/doc';
import { historyLine, historyStep, terminalScreen, type HostMessage, type TerminalLine } from '@mdg-filius/protocol';
import { SimulationRoom, TICK_MILLISECONDS } from '../src/index.js';
interface JavaStep {
readonly step: string;
readonly text: string;
readonly prompt: string;
readonly promptShown: boolean;
readonly input: string;
}
const java = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'terminalWindow.java.json'), 'utf8')) as { readonly steps: readonly JavaStep[] };
/** A computer with a command line, in a room on a clock the test moves, and a client that keeps the window's lines. */
function setUp(programs: readonly string[] = ['commandPrompt']) {
const doc = new Y.Doc();
writeProject(doc, emptyProject());
let counter = 0;
addDevice(doc, { kind: 'pc', id: 'pc', name: 'PC', x: 0, y: 0, newId: () => `port-${++counter}` }, 'me');
const installed = doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get('pc')!.get('programs') as Y.Map<Y.Map<unknown>>;
for (const program of programs) {
const entry = new Y.Map<unknown>();
entry.set('active', true);
installed.set(program, entry);
}
let now = 0;
const room = new SimulationRoom(doc, { now: () => now, seed: () => 7 });
const got: HostMessage[] = [];
const client = room.connect((message) => got.push(message));
let lines: TerminalLine[] = [];
const read = () => {
for (const message of got.splice(0)) {
if (message.kind !== 'app' || message.app !== 'terminal') continue;
expect(message.from).toBe(message.from === 0 ? 0 : lines.length);
lines = [...lines.slice(0, message.from), ...(message.lines as TerminalLine[])];
}
return lines;
};
const send = (fields: object) => room.receive(client, { v: 1, ...fields });
const run = (milliseconds: number) => {
for (let elapsed = 0; elapsed < milliseconds; elapsed += TICK_MILLISECONDS) {
now += TICK_MILLISECONDS;
room.tick();
}
};
return { doc, room, send, run, read, got, connect: (listener: (message: HostMessage) => void) => room.connect(listener) };
}
describe("a command line's window", () => {
it("shows what Java's window shows, key by key, over a stop and a start", () => {
const { send, run, read } = setUp();
send({ kind: 'sub', topics: ['app:pc/terminal'] });
send({ kind: 'start' });
run(TICK_MILLISECONDS);
let pointer = -1;
let input = '';
for (const step of java.steps) {
if (step.step.startsWith('enter:')) {
send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: step.step.slice('enter:'.length) }, reqId: step.step });
run(400);
pointer = -1;
input = '';
} else if (step.step === 'up' || step.step === 'down') {
const history = terminalScreen(read()).history;
pointer = historyStep(pointer, step.step, history.length);
input = historyLine(history, pointer);
} else if (step.step === 'restart') {
send({ kind: 'stop' });
send({ kind: 'start' });
run(400);
}
const screen = terminalScreen(read());
const expected = step;
expect({ text: screen.text, prompt: screen.prompt, promptShown: screen.promptShown, input }, step.step).toEqual({
text: expected.text,
prompt: expected.prompt,
promptShown: expected.promptShown,
input: expected.input,
});
}
expect(read().at(-1)).toEqual({ kind: 'exit' });
});
it('keeps the working directory over a stop and a start, where it is still a folder', () => {
const { send, run, read, doc } = setUp();
const files = doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get('pc')!.get('files') as Y.Map<Y.Map<unknown>>;
const folder = new Y.Map<unknown>();
folder.set('order', files.size);
folder.set('kind', 'directory');
files.set('/docs', folder);
send({ kind: 'sub', topics: ['app:pc/terminal'] });
send({ kind: 'start' });
run(TICK_MILLISECONDS);
// A folder the project has, so that the next start's file system has it too.
send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'cd /docs' }, reqId: 'a' });
run(400);
expect(terminalScreen(read()).prompt).toBe('/docs> ');
send({ kind: 'stop' });
send({ kind: 'start' });
run(400);
expect(terminalScreen(read()).prompt).toBe('/docs> ');
// The folder gone from the project (in the design): the next start goes on at the root.
send({ kind: 'stop' });
files.delete('/docs');
send({ kind: 'start' });
run(400);
expect(terminalScreen(read()).prompt).toBe('/> ');
});
it('hides the prompt while a command runs on, and Ctrl+C ends it', () => {
const { send, run, read } = setUp();
send({ kind: 'sub', topics: ['app:pc/terminal'] });
send({ kind: 'start' });
run(TICK_MILLISECONDS);
send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'ping 192.168.0.99' }, reqId: 'a' });
run(1_000);
expect(terminalScreen(read()).promptShown).toBe(false);
expect(read().some((line) => line.kind === 'busy' && line.busy)).toBe(true);
send({ kind: 'input', node: 'pc', app: 'terminal', action: 'interrupt', args: {}, reqId: 'b' });
run(15_000);
expect(terminalScreen(read()).promptShown).toBe(true);
expect(read().filter((line) => line.kind === 'busy').at(-1)).toEqual({ kind: 'busy', busy: false });
});
it('shows the prompt again after a stop that cut a command off', () => {
const { send, run, read } = setUp();
send({ kind: 'sub', topics: ['app:pc/terminal'] });
send({ kind: 'start' });
run(TICK_MILLISECONDS);
send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'ping 192.168.0.99' }, reqId: 'a' });
run(1_000);
expect(terminalScreen(read()).promptShown).toBe(false);
send({ kind: 'stop' });
send({ kind: 'start' });
run(TICK_MILLISECONDS);
expect(terminalScreen(read()).promptShown).toBe(true);
});
it('takes no line once its computer has no command line any more, and keeps what it showed', () => {
const { doc, send, run, read, got } = setUp();
send({ kind: 'sub', topics: ['app:pc/terminal'] });
send({ kind: 'start' });
run(TICK_MILLISECONDS);
send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'pwd' }, reqId: 'a' });
run(400);
const shown = terminalScreen(read()).text;
send({ kind: 'stop' });
(doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get('pc')!.get('programs') as Y.Map<unknown>).delete('commandPrompt');
send({ kind: 'start' });
run(TICK_MILLISECONDS);
send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'ls' }, reqId: 'b' });
expect(got.filter((message) => message.kind === 'error')).toEqual([{ v: 1, kind: 'error', code: 'unknownApp', reqId: 'b' }]);
expect(terminalScreen(read()).text).toBe(shown);
});
it('is the same window for everyone who opens it, and a client that comes later gets all of it', () => {
const { room, send, run, read, connect } = setUp();
send({ kind: 'sub', topics: ['app:pc/terminal'] });
send({ kind: 'start' });
run(TICK_MILLISECONDS);
send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'pwd' }, reqId: 'a' });
run(400);
const later: HostMessage[] = [];
const other = connect((message) => later.push(message));
room.receive(other, { v: 1, kind: 'sub', topics: ['app:pc/terminal'] });
const first = read();
expect(first.some((line) => line.kind === 'input' && line.line === 'pwd')).toBe(true);
expect(later.filter((message) => message.kind === 'app')).toEqual([{ v: 1, kind: 'app', node: 'pc', app: 'terminal', from: 0, lines: first }]);
// What the later client types, the first one sees.
room.receive(other, { v: 1, kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'ls' }, reqId: 'b' });
run(400);
expect(read().some((line) => line.kind === 'input' && line.line === 'ls')).toBe(true);
});
it('has no window on a computer without a command line, and takes no line while the simulation stands', () => {
const { send, run, read, got } = setUp([]);
send({ kind: 'sub', topics: ['app:pc/terminal'] });
send({ kind: 'start' });
run(TICK_MILLISECONDS);
expect(read()).toEqual([]);
send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'pwd' }, reqId: 'a' });
expect(got.filter((message) => message.kind === 'error')).toEqual([{ v: 1, kind: 'error', code: 'unknownApp', reqId: 'a' }]);
});
});
describe("the window's history", () => {
it("steps as Java's arrow keys do, never beyond its ends", () => {
expect(historyStep(-1, 'up', 0)).toBe(-1);
expect(historyStep(-1, 'up', 2)).toBe(0);
expect(historyStep(1, 'up', 2)).toBe(1);
expect(historyStep(0, 'down', 2)).toBe(-1);
expect(historyStep(-1, 'down', 2)).toBe(-1);
expect([historyLine(['a', 'b'], -1), historyLine(['a', 'b'], 0), historyLine(['a', 'b'], 1)]).toEqual(['', 'b', 'a']);
});
});
describe('the software installation while the simulation runs', () => {
it("installs a program at once, as Java starts it: a command line to type into, a web server's files", () => {
const { doc, send, run, read, got } = setUp([]);
send({ kind: 'sub', topics: ['app:pc/terminal'] });
send({ kind: 'start' });
run(TICK_MILLISECONDS);
expect(read()).toEqual([]);
setSoftware(doc, 'pc', { install: ['commandPrompt', 'webServer'], remove: [] }, 'de', 'me');
run(TICK_MILLISECONDS);
expect(terminalScreen(read()).text).toContain("Verwende den Befehl 'help'");
send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'ls /webserver' }, reqId: 'a' });
run(400);
expect(terminalScreen(read()).text).toMatch(/index\.html[\s\S]*splashscreen-mini\.png/);
expect(got.filter((message) => message.kind === 'error')).toEqual([]);
// Removed: the window stays with its text, and takes no line.
setSoftware(doc, 'pc', { install: [], remove: ['commandPrompt'] }, 'de', 'me');
const shown = terminalScreen(read()).text;
send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'pwd' }, reqId: 'b' });
expect(got.filter((message) => message.kind === 'error')).toEqual([{ v: 1, kind: 'error', code: 'unknownApp', reqId: 'b' }]);
expect(terminalScreen(read()).text).toBe(shown);
// Installed again, it goes on in the same window.
setSoftware(doc, 'pc', { install: ['commandPrompt'], remove: [] }, 'de', 'me');
send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'pwd' }, reqId: 'c' });
run(400);
expect(terminalScreen(read()).text.startsWith(shown)).toBe(true);
expect(terminalScreen(read()).text.slice(shown.length)).toBe('\n/> pwd\n/');
});
it('leaves the running engine alone in the design, and starts with the programs then', () => {
const { doc, send, run, read } = setUp([]);
send({ kind: 'sub', topics: ['app:pc/terminal'] });
send({ kind: 'start' });
send({ kind: 'stop' });
setSoftware(doc, 'pc', { install: ['commandPrompt'], remove: [] }, 'de', 'me');
expect(read()).toEqual([]);
send({ kind: 'start' });
run(TICK_MILLISECONDS);
expect(terminalScreen(read()).text).toContain("Verwende den Befehl 'help'");
});
});