feat(editor): F4.6b the command line on a computer's desktop

A new oracle, TerminalWindowRun, types into Java's own command line
window: the window writes each line after its prompt, takes a blank
line, cls, reset and exit itself, keeps the lines for the arrow keys,
and keeps its text, history and working directory over a stop and a
start.

The room now holds a command line window per computer for everyone
(TerminalWindow), kept over stops; the protocol carries its lines and
the one way to read them (terminalScreen); the core goes on in the
working directory of the run before (continueIn). The desktop opens
programs in Java's frame, and the command line works with keyboard and
finger: grey on black, the prompt hidden while a command runs, history,
chips for the commands, Ctrl+C. The room's failure is now tested in the
browser.

Fixed: the room handed the engine the wall clock with fractions of a
millisecond, and a ping hung after its first timeout. The engine and
the room count whole milliseconds, as Java's clock does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 14:22:49 +00:00
co-authored by Claude Opus 5.5
parent dfd5ec03ce
commit ac48e969f4
23 changed files with 1255 additions and 47 deletions
+34 -8
View File
@@ -7,13 +7,16 @@
// Time: the engine's clock follows the wall clock, a millisecond for a
// millisecond, as Java's threads do; the speed is Java's slider, the delay of
// a cable (RuntimeSpeed), not a time lapse — unlike Swift's tick (plan §6.3).
// The engine counts whole milliseconds, as Java's clock does; the wall
// clock's fractions are carried to the next tick, so that no time is lost.
// A tick catches up at most MAXIMUM_CATCH_UP_MILLISECONDS: after a tab slept,
// the simulation goes on from where it was rather than running the missed
// minutes in one go.
//
// The engine outlives a stop: the capture stays, as Java's Lauscher does, and
// the next start goes on at the time it stopped, so that the capture's times
// keep rising (plan §6.2).
// keep rising (plan §6.2). So do the command lines' windows, as Java's do
// (TerminalWindow).
import * as Y from 'yjs';
import { DEFAULT_SPEED_PERCENT, NetworkRuntimeEngine, captureRows, interfaceKey, stableGeneratedMacAddress, type CapturedFrame, type CaptureRow, type Id, type LogLine } from '@mdg-filius/core';
@@ -30,6 +33,7 @@ import {
type Topic,
type TraceFrame,
} from '@mdg-filius/protocol';
import { TerminalWindow } from './terminalWindow.js';
export const TICK_MILLISECONDS = 200;
export const MAXIMUM_CATCH_UP_MILLISECONDS = 2_000;
@@ -71,6 +75,8 @@ export class SimulationRoom {
/** Per card, how many frames it held at the last tick. */
private framesSeen = new Map<Id, number>();
private lastActivity: string[] = [];
/** Each computer's command line window, kept over stops. */
private readonly terminalWindows = new Map<Id, TerminalWindow>();
constructor(
private readonly doc: Y.Doc,
@@ -162,6 +168,13 @@ export class SimulationRoom {
this.engine.setSimulationSpeed(this.speed);
if (!this.guarded(() => this.engine.handle({ type: 'start', snapshot, seed: this.options.seed(), initialTimeMilliseconds: this.engine.state.currentTimeMilliseconds }))) return;
this.linkOfPort = new Map(snapshot.links.flatMap((link) => [[link.sourcePortId, link.id] as const, [link.targetPortId, link.id] as const]));
for (const node of snapshot.nodes) {
const terminal = this.engine.terminal(node.id);
if (terminal === undefined) continue;
const window = this.terminalWindows.get(node.id);
if (window === undefined) this.terminalWindows.set(node.id, new TerminalWindow(terminal));
else window.attach(terminal);
}
this.mode = 'simulation';
this.lastWall = this.options.now();
this.broadcast(this.state());
@@ -181,8 +194,10 @@ export class SimulationRoom {
tick(): void {
if (this.mode !== 'simulation') return;
const now = this.options.now();
const elapsed = Math.min(MAXIMUM_CATCH_UP_MILLISECONDS, Math.max(0, now - (this.lastWall ?? now)));
this.lastWall = now;
const behind = Math.max(0, now - (this.lastWall ?? now));
// Whole milliseconds, as the engine counts them; the wall clock's fraction is kept for the next tick.
const elapsed = Math.min(MAXIMUM_CATCH_UP_MILLISECONDS, Math.floor(behind));
this.lastWall = behind > MAXIMUM_CATCH_UP_MILLISECONDS ? now : (this.lastWall ?? now) + elapsed;
if (!this.guarded(() => this.engine.handle({ type: 'advance', toMilliseconds: this.engine.state.currentTimeMilliseconds + elapsed }))) return;
this.flush([...this.clients.values()], false);
}
@@ -212,15 +227,16 @@ export class SimulationRoom {
if (this.mode !== 'simulation') return 'notRunning';
if (!this.engine.state.topologySnapshot.nodes.some((node) => node.id === input.node)) return 'unknownNode';
if (input.app !== 'terminal') return 'unknownAction';
const terminal = this.engine.terminal(input.node);
if (terminal === undefined) return 'unknownApp';
// A window has a program while the simulation runs (checked above), from its first start on.
const window = this.terminalWindows.get(input.node);
if (window === undefined || this.engine.terminal(input.node) === undefined) return 'unknownApp';
switch (input.action) {
case 'enter':
if (typeof input.args.line !== 'string') return 'badArgs';
terminal.enter(input.args.line);
window.enter(input.args.line);
return undefined;
case 'interrupt':
terminal.interrupt();
window.interrupt();
return undefined;
default:
return 'unknownAction';
@@ -251,6 +267,7 @@ export class SimulationRoom {
/** What the clients are owed: the cables' activity, new frames of the cards and new lines of the windows they watch. */
private flush(clients: readonly Client[], subscribing: boolean): void {
if (!subscribing) this.updateActivity();
for (const window of this.terminalWindows.values()) window.pull();
const rowsCache = new Map<Id, TraceFrame[]>();
const traceFrames = (portId: Id, after: number): TraceFrame[] => {
const frames = this.engine.capture.frames(portId).filter((frame) => frame.number > after);
@@ -281,8 +298,17 @@ export class SimulationRoom {
client.send(message({ kind: 'trace', portId, frames, reset }));
} else {
const [, node, app] = /^app:([^/]+)\/(.+)$/.exec(topic)!;
const program = this.program(node!, app as AppKind);
const sent = client.appSent.get(topic);
if (app === 'terminal') {
// The window's lines, kept over stops: a client goes on from those it has.
const lines = this.terminalWindows.get(node!)?.lines ?? [];
const from = sent ?? 0;
if (sent !== undefined && lines.length === from) continue;
client.appSent.set(topic, lines.length);
client.send(message({ kind: 'app', node: node!, app: 'terminal', from, lines: lines.slice(from) }));
continue;
}
const program = this.program(node!, app as AppKind);
if (program === undefined) {
// A window of a program that is not running: it starts empty with the next start.
if (sent !== undefined || subscribing) client.send(message({ kind: 'app', node: node!, app: app as AppKind, from: 0, lines: [] }));
+89
View File
@@ -0,0 +1,89 @@
// A computer's command line window in the room (plan §7.3): one for everyone
// who opens it, as Java has one per computer. It writes the program's lines
// into the window's lines after the window's own (the start screen, each
// entered line after its prompt, a blank line, cls, exit), in the order they
// happened. Like Java's window it outlives a stop and a start: its text, its
// history and the working directory stay (TerminalWindowRun); each start
// brings a new program, which goes on where the last one was.
import type { Terminal } from '@mdg-filius/core';
import type { TerminalLine } from '@mdg-filius/protocol';
/** StringUtils.isBlank: nothing but whitespace. */
const blank = (line: string) => line.trim() === '';
export class TerminalWindow {
private readonly linesValue: TerminalLine[] = [];
private terminal!: Terminal;
/** How many of the program's lines are in the window. */
private seen = 0;
private busy = false;
private prompt: string | undefined;
private directory = '/';
/** A window on the program of the first start. */
constructor(terminal: Terminal) {
this.attach(terminal);
}
get lines(): readonly TerminalLine[] {
return this.linesValue;
}
/** The program of a start: the first time the window shows its start screen, later it goes on in the working directory it had. */
attach(terminal: Terminal): void {
this.terminal = terminal;
this.seen = 0;
terminal.continueIn(this.directory);
if (this.linesValue.length === 0) this.linesValue.push({ kind: 'text', text: terminal.startScreen });
if (this.busy) {
// A command cut off by the stop has ended.
this.busy = false;
this.linesValue.push({ kind: 'busy', busy: false });
}
this.syncPrompt();
}
/** Takes the program's new lines into the window. */
pull(): void {
const terminal = this.terminal;
for (const entry of terminal.log.all.slice(this.seen)) {
if (entry.kind === 'text') this.linesValue.push({ kind: 'text', text: entry.text });
else {
this.busy = entry.busy;
this.linesValue.push({ kind: 'busy', busy: entry.busy });
}
}
this.seen = terminal.log.all.length;
this.directory = terminal.directory;
if (!this.busy) this.syncPrompt();
}
/** A line entered in the window. */
enter(line: string): void {
const terminal = this.terminal;
this.pull();
if (blank(line)) {
this.linesValue.push({ kind: 'text', text: ' \n' });
return;
}
const prompt = this.prompt ?? terminal.prompt;
const action = terminal.enter(line);
this.linesValue.push({ kind: 'input', prompt, line, command: action === undefined });
if (action === 'exit') this.linesValue.push({ kind: 'exit' });
if (action === 'clear') this.linesValue.push({ kind: 'clear' }, { kind: 'text', text: terminal.startScreen });
this.pull();
}
/** Ctrl+C in the window. */
interrupt(): void {
this.terminal.interrupt();
}
private syncPrompt(): void {
const prompt = this.terminal.prompt;
if (prompt === this.prompt) return;
this.prompt = prompt;
this.linesValue.push({ kind: 'prompt', prompt });
}
}
+49 -9
View File
@@ -107,8 +107,11 @@ describe('a simulation room', () => {
expect(each.from).toBe(each.from === 0 ? 0 : lines.length);
lines = [...lines.slice(0, each.from), ...each.lines];
}
expect(lines).toEqual(engine.terminal(notebook.id)!.log.all);
expect(lines.length).toBeGreaterThan(4);
// The window: its start screen, the line entered after its prompt, then what the program wrote, as the engine has it.
const program = engine.terminal(notebook.id)!.log.all.map(({ timeMilliseconds: _time, ...entry }) => entry);
expect(lines.slice(0, 3)).toEqual([{ kind: 'text', text: engine.terminal(notebook.id)!.startScreen }, { kind: 'prompt', prompt: '/> ' }, { kind: 'input', prompt: '/> ', line: 'ping 141.99.5.10', command: true }]);
expect(lines.slice(3).filter((line) => (line as { kind: string }).kind !== 'prompt')).toEqual(program);
expect(program.length).toBeGreaterThan(4);
});
it('sends the cables that carry frames when they change, and only to who asked', () => {
@@ -160,7 +163,7 @@ describe('a simulation room', () => {
expect(reset.frames.length).toBeGreaterThan(8);
});
it('empties a window when its program starts anew', () => {
it("keeps a command line's window over a stop and a start, as Java does", () => {
const { got, send, node, run } = setUp();
const notebook = node('Notebook 1.10');
send({ kind: 'sub', topics: [`app:${notebook.id}/terminal`] });
@@ -168,11 +171,17 @@ describe('a simulation room', () => {
send({ kind: 'start' });
send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ipconfig' }, reqId: 'r1' });
run(400);
expect(ofKind(got, 'app').at(-1)!.lines.length).toBeGreaterThan(0);
const before = ofKind(got, 'app').reduce((count, each) => each.from + each.lines.length, 0);
expect(before).toBeGreaterThan(3);
const sentBefore = ofKind(got, 'app').length;
send({ kind: 'stop' });
expect(ofKind(got, 'app').at(-1)).toMatchObject({ from: 0, lines: [] });
send({ kind: 'start' });
expect(ofKind(got, 'app').at(-1)).toMatchObject({ from: 0 });
run(400);
// Nothing taken away: the window goes on after its lines.
expect(ofKind(got, 'app').slice(sentBefore).every((each) => each.from >= before)).toBe(true);
send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'pwd' }, reqId: 'r2' });
run(400);
expect(ofKind(got, 'app').at(-1)!.from).toBeGreaterThanOrEqual(before);
});
it('answers an action that cannot be done with its reason', () => {
@@ -212,12 +221,14 @@ describe('a simulation room', () => {
expect(roundTrip(90)).toBeGreaterThan(roundTrip(100));
});
it('catches up at most two seconds after the clock jumped', () => {
const { got, send, jump } = setUp();
it('catches up at most two seconds after the clock jumped, and goes on from there', () => {
const { got, send, jump, run } = setUp();
send({ kind: 'start' });
jump(60_000);
// The missed minute is not made up over the next ticks either.
run(TICK_MILLISECONDS);
send({ kind: 'stop' });
expect(ofKind(got, 'state').at(-1)!.t).toBe(MAXIMUM_CATCH_UP_MILLISECONDS);
expect(ofKind(got, 'state').at(-1)!.t).toBe(MAXIMUM_CATCH_UP_MILLISECONDS + TICK_MILLISECONDS);
});
it('starts with the document as it is then', () => {
@@ -329,5 +340,34 @@ describe('a simulation room', () => {
send({ kind: 'sub', topics: [`node:${server.id}`] });
expect(ofKind(got.slice(before), 'node')).toHaveLength(1);
});
it("hands the engine whole milliseconds of a wall clock with fractions, losing none, and a ping goes through", () => {
// performance.now() has fractions; the core counts whole milliseconds as Java does (a fraction once made the ping hang).
const doc = exampleDoc();
let now = 0.718;
const room = new SimulationRoom(doc, { now: () => now, seed: () => SEED });
const got: HostMessage[] = [];
const client = room.connect((message) => got.push(message));
const notebook = readProject(doc).graph.nodes.find((each) => each.displayName === 'Notebook 1.10')!;
room.receive(client, { v: 1, kind: 'sub', topics: [`app:${notebook.id}/terminal`] });
room.receive(client, { v: 1, kind: 'start' });
for (let tick = 0; tick < 15; tick++) {
now += 200.4;
room.tick();
}
room.receive(client, { v: 1, kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r' });
for (let tick = 0; tick < 60; tick++) {
now += 200.4;
room.tick();
}
room.receive(client, { v: 1, kind: 'speed', value: 90 });
const t = ofKind(got, 'state').at(-1)!.t;
expect(Number.isInteger(t)).toBe(true);
// Less than a millisecond behind the wall clock since the start, never ahead of it.
expect(t).toBeLessThanOrEqual(now - 0.718);
expect(t).toBeGreaterThan(now - 0.718 - 1);
const text = ofKind(got, 'app').flatMap((each) => each.lines as { kind: string; text?: string }[]).map((line) => line.text ?? '').join('');
expect(text).toContain('4 Paket(e) gesendet, 4 Paket(e) empfangen');
});
});
+144
View File
@@ -0,0 +1,144 @@
{
"steps": [
{
"step": "open",
"text": "\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n\n==========================================================================\nVerwende den Befehl 'help' um Liste der verfügbaren Befehle anzuzeigen\n oder nutze 'help <Befehl>' für weitere Infos zum Befehl.\n==========================================================================\n",
"prompt": "/> ",
"promptShown": true,
"input": ""
},
{
"step": "enter:",
"text": "\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n\n==========================================================================\nVerwende den Befehl 'help' um Liste der verfügbaren Befehle anzuzeigen\n oder nutze 'help <Befehl>' für weitere Infos zum Befehl.\n==========================================================================\n \n",
"prompt": "/> ",
"promptShown": true,
"input": ""
},
{
"step": "enter: ",
"text": "\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n\n==========================================================================\nVerwende den Befehl 'help' um Liste der verfügbaren Befehle anzuzeigen\n oder nutze 'help <Befehl>' für weitere Infos zum Befehl.\n==========================================================================\n \n \n",
"prompt": "/> ",
"promptShown": true,
"input": ""
},
{
"step": "enter:pwd",
"text": "\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n\n==========================================================================\nVerwende den Befehl 'help' um Liste der verfügbaren Befehle anzuzeigen\n oder nutze 'help <Befehl>' für weitere Infos zum Befehl.\n==========================================================================\n \n \n\n/> pwd\n/",
"prompt": "/> ",
"promptShown": true,
"input": ""
},
{
"step": "enter:mkdir dir",
"text": "\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n\n==========================================================================\nVerwende den Befehl 'help' um Liste der verfügbaren Befehle anzuzeigen\n oder nutze 'help <Befehl>' für weitere Infos zum Befehl.\n==========================================================================\n \n \n\n/> pwd\n/\n/> mkdir dir\nOrdner erstellt\n",
"prompt": "/> ",
"promptShown": true,
"input": ""
},
{
"step": "enter:cd dir",
"text": "\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n\n==========================================================================\nVerwende den Befehl 'help' um Liste der verfügbaren Befehle anzuzeigen\n oder nutze 'help <Befehl>' für weitere Infos zum Befehl.\n==========================================================================\n \n \n\n/> pwd\n/\n/> mkdir dir\nOrdner erstellt\n\n/> cd dir\n",
"prompt": "/dir> ",
"promptShown": true,
"input": ""
},
{
"step": "enter:pwd",
"text": "\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n\n==========================================================================\nVerwende den Befehl 'help' um Liste der verfügbaren Befehle anzuzeigen\n oder nutze 'help <Befehl>' für weitere Infos zum Befehl.\n==========================================================================\n \n \n\n/> pwd\n/\n/> mkdir dir\nOrdner erstellt\n\n/> cd dir\n\n/dir> pwd\n/dir",
"prompt": "/dir> ",
"promptShown": true,
"input": ""
},
{
"step": "enter:nosuchcommand",
"text": "\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n\n==========================================================================\nVerwende den Befehl 'help' um Liste der verfügbaren Befehle anzuzeigen\n oder nutze 'help <Befehl>' für weitere Infos zum Befehl.\n==========================================================================\n \n \n\n/> pwd\n/\n/> mkdir dir\nOrdner erstellt\n\n/> cd dir\n\n/dir> pwd\n/dir\n/dir> nosuchcommand\nUnbekannter Befehl!\nMit 'help' erhalten Sie eine Liste bekannter Befehle.",
"prompt": "/dir> ",
"promptShown": true,
"input": ""
},
{
"step": "up",
"text": "\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n\n==========================================================================\nVerwende den Befehl 'help' um Liste der verfügbaren Befehle anzuzeigen\n oder nutze 'help <Befehl>' für weitere Infos zum Befehl.\n==========================================================================\n \n \n\n/> pwd\n/\n/> mkdir dir\nOrdner erstellt\n\n/> cd dir\n\n/dir> pwd\n/dir\n/dir> nosuchcommand\nUnbekannter Befehl!\nMit 'help' erhalten Sie eine Liste bekannter Befehle.",
"prompt": "/dir> ",
"promptShown": true,
"input": "nosuchcommand"
},
{
"step": "up",
"text": "\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n\n==========================================================================\nVerwende den Befehl 'help' um Liste der verfügbaren Befehle anzuzeigen\n oder nutze 'help <Befehl>' für weitere Infos zum Befehl.\n==========================================================================\n \n \n\n/> pwd\n/\n/> mkdir dir\nOrdner erstellt\n\n/> cd dir\n\n/dir> pwd\n/dir\n/dir> nosuchcommand\nUnbekannter Befehl!\nMit 'help' erhalten Sie eine Liste bekannter Befehle.",
"prompt": "/dir> ",
"promptShown": true,
"input": "pwd"
},
{
"step": "up",
"text": "\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n\n==========================================================================\nVerwende den Befehl 'help' um Liste der verfügbaren Befehle anzuzeigen\n oder nutze 'help <Befehl>' für weitere Infos zum Befehl.\n==========================================================================\n \n \n\n/> pwd\n/\n/> mkdir dir\nOrdner erstellt\n\n/> cd dir\n\n/dir> pwd\n/dir\n/dir> nosuchcommand\nUnbekannter Befehl!\nMit 'help' erhalten Sie eine Liste bekannter Befehle.",
"prompt": "/dir> ",
"promptShown": true,
"input": "cd dir"
},
{
"step": "down",
"text": "\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n\n==========================================================================\nVerwende den Befehl 'help' um Liste der verfügbaren Befehle anzuzeigen\n oder nutze 'help <Befehl>' für weitere Infos zum Befehl.\n==========================================================================\n \n \n\n/> pwd\n/\n/> mkdir dir\nOrdner erstellt\n\n/> cd dir\n\n/dir> pwd\n/dir\n/dir> nosuchcommand\nUnbekannter Befehl!\nMit 'help' erhalten Sie eine Liste bekannter Befehle.",
"prompt": "/dir> ",
"promptShown": true,
"input": "pwd"
},
{
"step": "down",
"text": "\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n\n==========================================================================\nVerwende den Befehl 'help' um Liste der verfügbaren Befehle anzuzeigen\n oder nutze 'help <Befehl>' für weitere Infos zum Befehl.\n==========================================================================\n \n \n\n/> pwd\n/\n/> mkdir dir\nOrdner erstellt\n\n/> cd dir\n\n/dir> pwd\n/dir\n/dir> nosuchcommand\nUnbekannter Befehl!\nMit 'help' erhalten Sie eine Liste bekannter Befehle.",
"prompt": "/dir> ",
"promptShown": true,
"input": "nosuchcommand"
},
{
"step": "down",
"text": "\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n\n==========================================================================\nVerwende den Befehl 'help' um Liste der verfügbaren Befehle anzuzeigen\n oder nutze 'help <Befehl>' für weitere Infos zum Befehl.\n==========================================================================\n \n \n\n/> pwd\n/\n/> mkdir dir\nOrdner erstellt\n\n/> cd dir\n\n/dir> pwd\n/dir\n/dir> nosuchcommand\nUnbekannter Befehl!\nMit 'help' erhalten Sie eine Liste bekannter Befehle.",
"prompt": "/dir> ",
"promptShown": true,
"input": ""
},
{
"step": "enter:cls",
"text": "\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n\n==========================================================================\nVerwende den Befehl 'help' um Liste der verfügbaren Befehle anzuzeigen\n oder nutze 'help <Befehl>' für weitere Infos zum Befehl.\n==========================================================================\n",
"prompt": "/dir> ",
"promptShown": true,
"input": ""
},
{
"step": "enter:ls",
"text": "\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n\n==========================================================================\nVerwende den Befehl 'help' um Liste der verfügbaren Befehle anzuzeigen\n oder nutze 'help <Befehl>' für weitere Infos zum Befehl.\n==========================================================================\n\n/dir> ls\nVerzeichnis ist leer\n",
"prompt": "/dir> ",
"promptShown": true,
"input": ""
},
{
"step": "restart",
"text": "\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n\n==========================================================================\nVerwende den Befehl 'help' um Liste der verfügbaren Befehle anzuzeigen\n oder nutze 'help <Befehl>' für weitere Infos zum Befehl.\n==========================================================================\n\n/dir> ls\nVerzeichnis ist leer\n",
"prompt": "/dir> ",
"promptShown": true,
"input": ""
},
{
"step": "enter:pwd",
"text": "\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n\n==========================================================================\nVerwende den Befehl 'help' um Liste der verfügbaren Befehle anzuzeigen\n oder nutze 'help <Befehl>' für weitere Infos zum Befehl.\n==========================================================================\n\n/dir> ls\nVerzeichnis ist leer\n\n/dir> pwd\n/dir",
"prompt": "/dir> ",
"promptShown": true,
"input": ""
},
{
"step": "up",
"text": "\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n\n==========================================================================\nVerwende den Befehl 'help' um Liste der verfügbaren Befehle anzuzeigen\n oder nutze 'help <Befehl>' für weitere Infos zum Befehl.\n==========================================================================\n\n/dir> ls\nVerzeichnis ist leer\n\n/dir> pwd\n/dir",
"prompt": "/dir> ",
"promptShown": true,
"input": "pwd"
},
{
"step": "enter:exit",
"text": "\n\n sSSs .S S. .S .S S. sSSs \n d%%SP .SS SS. .SS .SS SS. d%%SP \n d%S' S%S S%S S%S S%S S%S d%S' \n S%S S%S S%S S%S S%S S%S S%| \n S&S S&S S&S S&S S&S S&S S&S \n S&S_Ss S&S S&S S&S S&S S&S Y&Ss \n S&S~SP S&S S&S S&S S&S S&S `S&&S \n S&S S&S S&S S&S S&S S&S `S*S \n S*b S*S S*b S*S S*b d*S l*S \n S*S S*S S*S. S*S S*S. .S*S .S*P \n S*S S*S SSSbs S*S SSSbs_sdSSS sSS*S \n S*S S*S YSSP S*S YSSP~YSSY YSS' \n SP SP SP \n Y Y Y \n\n\n==========================================================================\nVerwende den Befehl 'help' um Liste der verfügbaren Befehle anzuzeigen\n oder nutze 'help <Befehl>' für weitere Infos zum Befehl.\n==========================================================================\n\n/dir> ls\nVerzeichnis ist leer\n\n/dir> pwd\n/dir\n/dir> exit\n",
"prompt": "/dir> ",
"promptShown": true,
"input": ""
}
]
}
+216
View File
@@ -0,0 +1,216 @@
// 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, 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']);
});
});