Files
mdg-filius/packages/sim/test/terminalWindow.test.ts
T
julian.kiedaischandClaude Opus 5.5 f0e7c73ec7 feat(editor): F4.6c the software installation on a computer's desktop
A new oracle, InstallRun, installs each of Java's programs on new
computers, all of them on one, each installed and removed again, and a
DNS and an e-mail server installed again after their removal; Java saves
the result as a project. A new program is switched off (a computer's
firewall is on), makes the folders and files it lacks (the web server's
start page in FILIUS' language and its picture, /peer2peer,
/mailserver/konten.txt, /dns/hosts), and removing it takes its settings
and leaves its files; a server installed again reads its records or
accounts from them. Java installs in the order of its list and starts
the program at once.

The document's setSoftware does the same, checked against Java's saved
installation. The core knows what a program makes in the files and
installs or removes a program while the simulation runs; the room
watches the document's programs and hands changes to the engine, so a
new command line works at once. The desktop offers Java's dialog with
two lists, arrows, double click and apply changes.

Also: Java's file may name a number it rolled by the result of any
getter (an idref, here a thread's priority); GetterDefaults now
measures the priority of the saved thread classes, which the import
read as 0 before.

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

260 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. One
// difference is named: what a run made in the files is not yet written back
// into the document (F4.6f), so after the restart the folder made before is
// gone and the window goes on at the root, where Java's goes on in it.
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) };
}
/** Until the files of a run are written back (F4.6f): the folder made before the restart is gone, the window goes on at the root. */
const FILES_NOT_KEPT_YET = (text: string) => text.replaceAll('/dir', '/');
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 = '';
let restartedAt: number | undefined;
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);
restartedAt = terminalScreen(read()).text.length;
}
const screen = terminalScreen(read());
const expected =
restartedAt === undefined
? step
: { ...step, text: step.text.slice(0, restartedAt) + FILES_NOT_KEPT_YET(step.text.slice(restartedAt)), prompt: FILES_NOT_KEPT_YET(step.prompt) };
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: the next start goes on at the root.
files.delete('/docs');
send({ kind: 'stop' });
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'");
});
});