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
@@ -199,6 +199,16 @@ export class Terminal {
return this.directoryValue;
}
/**
* Goes on in the working directory of a run before, where it is still a
* folder: Java's command line keeps it over a stop and a start
* (TerminalWindowRun), and the room's window holds it meanwhile.
*/
continueIn(directory: string): void {
const found = this.content(directory);
if (found !== undefined && found.content.kind === 'directory') this.directoryValue = found.path;
}
/** The window's prompt. */
get prompt(): string {
return `${this.directoryValue}> `;
+3 -2
View File
@@ -1031,7 +1031,7 @@ export class NetworkRuntimeEngine {
this.stateValue = initialState(seed);
this.packetCapture.beginRun();
this.stateValue.phase = 'running';
this.stateValue.currentTimeMilliseconds = input.initialTimeMilliseconds;
this.stateValue.currentTimeMilliseconds = Math.floor(input.initialTimeMilliseconds);
this.stateValue.simulationSpeedPercent = this.runtimeSpeed.percent;
this.stateValue.topologySnapshot = input.snapshot;
this.random = new JavaCompatibleRandom(seed);
@@ -1097,7 +1097,8 @@ export class NetworkRuntimeEngine {
}
case 'advance':
return this.advance(input.toMilliseconds);
// Whole milliseconds, as Java's clock counts (System.currentTimeMillis): a wall clock's fraction waits for the next step.
return this.advance(Math.floor(input.toMilliseconds));
case 'schedule': {
if (this.stateValue.phase !== 'running') return [this.output({ type: 'advanceIgnoredWhileStopped' })];
+11
View File
@@ -141,6 +141,17 @@ describe('event queue and virtual clock', () => {
expect(engine.state.pendingEvents).toEqual([]);
});
it("counts whole milliseconds, as Java's clock does: a time's fraction waits for the next step", () => {
const engine = new NetworkRuntimeEngine(9);
engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 9, initialTimeMilliseconds: 10.6 });
expect(engine.state.currentTimeMilliseconds).toBe(10);
engine.handle({ type: 'schedule', deadlineMilliseconds: 100, kind: marker('at-100') });
expect(firedKinds(engine.handle({ type: 'advance', toMilliseconds: 99.9 }))).toEqual([]);
expect(engine.state.currentTimeMilliseconds).toBe(99);
expect(firedKinds(engine.handle({ type: 'advance', toMilliseconds: 100.2 }))).toEqual([marker('at-100')]);
expect(engine.state.currentTimeMilliseconds).toBe(100);
});
it('rejects a past time without changing the clock', () => {
const engine = new NetworkRuntimeEngine(7);
engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 7, initialTimeMilliseconds: 10 });
+115
View File
@@ -237,6 +237,121 @@ test.describe('on a phone', () => {
const home = (await window.getByRole('button', { name: 'Startbildschirm' }).boundingBox())!;
expect(home.height).toBeGreaterThanOrEqual(44);
});
test("offers the command line's keys as buttons for a finger, and types without zooming", async ({ page }) => {
await openEditor(page, server.base, 'internetwork_komplett.fls');
await simulationButton(page).tap();
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation');
await page.getByRole('button', { name: 'Alles zeigen' }).tap();
const id = await deviceNamed(page, 'Notebook 1.10');
const at = await onDevice(page, id);
await page.touchscreen.tap(at.x, at.y);
await desktop(page, id).locator('[data-program="commandPrompt"]').tap();
const window = desktop(page, id).locator('[data-program-window="commandPrompt"]');
for (const name of ['ipconfig', 'Vorheriger Befehl', 'Befehl abbrechen (Strg+C)']) {
const box = (await window.getByRole('button', { name, exact: true }).boundingBox())!;
expect(box.height, name).toBeGreaterThanOrEqual(44);
}
await expect(window.locator('input[name="terminalInput"]')).toHaveCSS('font-size', '16px');
await window.getByRole('button', { name: 'ipconfig', exact: true }).tap();
await window.locator('input[name="terminalInput"]').press('Enter');
await expect(window.locator('.terminal-text')).toContainText('IP Adresse . . . : 141.99.1.10');
});
});
/** Opens a computer's command line on its desktop. */
async function commandLine(page: Page, name: string) {
const id = await deviceNamed(page, name);
await tap(page, id);
await desktop(page, id).locator('[data-program="commandPrompt"]').click();
const window = desktop(page, id).locator('[data-program-window="commandPrompt"]');
await expect(window.getByRole('heading')).toHaveText('Befehlszeile');
return { id, window, input: window.locator('input[name="terminalInput"]'), text: window.locator('.terminal-text'), prompt: window.locator('.terminal-prompt') };
}
test("types into a computer's command line as into Java's: the start screen, a line after its prompt, the history, a ping across the routers", async ({ page }) => {
test.setTimeout(60_000);
await openEditor(page, server.base, 'internetwork_komplett.fls');
await start(page);
const { window, input, text, prompt } = await commandLine(page, 'Notebook 1.10');
await expect(text).toContainText("Verwende den Befehl 'help'");
await expect(prompt).toHaveText('/>');
await expect(input).toBeFocused();
await input.fill('ipconfig');
await input.press('Enter');
await expect(text).toContainText('\n/> ipconfig\nIP Adresse . . . : 141.99.1.10');
await expect(input).toHaveValue('');
await input.press('ArrowUp');
await expect(input).toHaveValue('ipconfig');
await input.press('ArrowDown');
await expect(input).toHaveValue('');
// A chip puts its command into the line.
await window.getByRole('button', { name: 'ping', exact: true }).click();
await expect(input).toHaveValue('ping ');
await input.pressSequentially('141.99.5.10');
await input.press('Enter');
// While the ping runs, Java hides the prompt.
await expect(prompt).toHaveCSS('visibility', 'hidden');
await expect(text).toContainText('4 Paket(e) gesendet, 4 Paket(e) empfangen', { timeout: 20_000 });
await expect(prompt).toHaveCSS('visibility', 'visible');
await window.getByRole('button', { name: 'Vorheriger Befehl' }).click();
await expect(input).toHaveValue('ping 141.99.5.10');
});
test('ends a command with Ctrl+C, from the keyboard and from its button', async ({ page }) => {
test.setTimeout(60_000);
await openEditor(page, server.base, 'internetwork_komplett.fls');
await start(page);
const { window, input, text, prompt } = await commandLine(page, 'Notebook 1.10');
await input.fill('ping 141.99.9.9');
await input.press('Enter');
await expect(prompt).toHaveCSS('visibility', 'hidden');
await input.press('Control+c');
await expect(prompt).toHaveCSS('visibility', 'visible', { timeout: 20_000 });
await expect(text).toContainText('Paketstatistik');
await input.fill('ping 141.99.9.9');
await input.press('Enter');
await expect(prompt).toHaveCSS('visibility', 'hidden');
await window.getByRole('button', { name: 'Befehl abbrechen (Strg+C)' }).click();
await expect(prompt).toHaveCSS('visibility', 'visible', { timeout: 20_000 });
});
test('keeps the command line over leaving it, over a return to the design, and exit closes it', async ({ page }) => {
await openEditor(page, server.base, 'internetwork_komplett.fls');
await start(page);
const { id, window, input, text } = await commandLine(page, 'Notebook 1.10');
await input.fill('pwd');
await input.press('Enter');
await expect(text).toContainText('\n/> pwd\n/');
await desktop(page, id).getByRole('button', { name: 'Startbildschirm' }).click();
await expect(window).toHaveCount(0);
await desktop(page, id).locator('[data-program="commandPrompt"]').click();
await expect(text).toContainText('\n/> pwd\n/');
await designButton(page).click();
await start(page);
await expect(text).toContainText('\n/> pwd\n/');
await input.fill('exit');
await input.press('Enter');
await expect(window).toHaveCount(0);
await expect(desktop(page, id).locator('.desktop-icon').first()).toBeVisible();
await desktop(page, id).locator('[data-program="commandPrompt"]').click();
await expect(text).toContainText('\n/> exit\n');
// An exit that was there when the window opened does not close it again: it takes the next line.
await input.fill('ls');
await input.press('Enter');
await expect(text).toContainText('\n/> exit\n\n/> ls\n');
await expect(window).toBeVisible();
});
test('says so when the simulation fails, and goes back to the design', async ({ page }) => {
// FILIUS' modem example: the core has no modems yet (remote links); a ping to the other side fails it.
await openEditor(page, server.base, 'modem_beispiel.fls');
await start(page);
const { input } = await commandLine(page, 'Notebook 0.10');
await input.fill('ping 192.168.0.11');
await input.press('Enter');
await expect(page.getByRole('alert')).toHaveText('Die Simulation ist auf einen Fehler gestoßen und wurde beendet.', { timeout: 20_000 });
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'design');
});
test('speaks English when asked', async ({ page }) => {
+7 -10
View File
@@ -20,9 +20,10 @@ import { Palette, type PaletteKind } from './design/Palette.js';
import { RouterDialog } from './design/RouterDialog.js';
import { DeviceSheet } from './sheet/DeviceSheet.js';
import { DesktopWindow } from './desktop/DesktopWindow.js';
import type { DesktopProgram } from './desktop/programs.js';
import { useTranslate } from './i18n/i18n.js';
import { ModeBar } from './simulation/ModeBar.js';
import { useSimulation, type Simulation, type SimHostFactory } from './simulation/useSimulation.js';
import { useSimulation, type SimHostFactory } from './simulation/useSimulation.js';
/** The document's drawing, read anew after every change. */
export function useTopologyView(doc: Y.Doc): TopologyView {
@@ -79,6 +80,7 @@ interface OpenDesktop {
readonly x: number;
readonly y: number;
readonly z: number;
readonly program: DesktopProgram | null;
}
/** Brings a desktop to the top by its stacking, not its place in the page: moved in the page, it would lose the press on it. */
@@ -87,13 +89,6 @@ const toFront = (open: readonly OpenDesktop[], id: string): readonly OpenDesktop
return open.some((each) => each.id === id && each.z === top) ? open : open.map((each) => (each.id === id ? { ...each, z: top + 1 } : each));
};
/** A desktop that watches its computer's network while it is shown. */
function WatchedDesktop({ simulation, ...props }: Omit<Parameters<typeof DesktopWindow>[0], 'settings'> & { readonly simulation: Simulation }) {
const { watch } = simulation;
useEffect(() => watch([`node:${props.id}`]), [watch, props.id]);
return <DesktopWindow {...props} settings={simulation.nodes.get(props.id)} />;
}
export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly createSimHost?: SimHostFactory }) {
const t = useTranslate();
const view = useTopologyView(doc);
@@ -146,7 +141,7 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
setDesktops((open) => {
if (open.some((each) => each.id === id)) return toFront(open, id);
// Java stacks a desktop that does not fit beside the others a little further on.
return [...open, { id, x: 24 + 24 * open.length, y: 76 + 24 * open.length, z: Math.max(0, ...open.map((each) => each.z)) + 1 }];
return [...open, { id, x: 24 + 24 * open.length, y: 76 + 24 * open.length, z: Math.max(0, ...open.map((each) => each.z)) + 1, program: null }];
});
}, []);
@@ -305,7 +300,7 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
</div>
{simulating
? desktops.map((desktop) => (
<WatchedDesktop
<DesktopWindow
key={desktop.id}
simulation={simulation}
doc={doc}
@@ -314,6 +309,8 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
x={desktop.x}
y={desktop.y}
z={desktop.z}
program={desktop.program}
onProgram={(program) => setDesktops((open) => open.map((each) => (each.id === desktop.id ? { ...each, program } : each)))}
onMove={(x, y) => setDesktops((open) => open.map((each) => (each.id === desktop.id ? { ...each, x, y } : each)))}
onFront={() => setDesktops((open) => toFront(open, desktop.id))}
onClose={() => setDesktops((open) => open.filter((each) => each.id !== desktop.id))}
+47 -12
View File
@@ -9,26 +9,29 @@
// show; it is left out.
//
// On a wide screen the window lies over the surface and moves by its title
// bar, as Java's windows do; on a phone it fills the surface. The programs'
// windows follow (F4.6b on).
// bar, as Java's windows do; on a phone it fills the surface. A program's
// icon opens its window over the desktop (ProgramFrame), the home button
// leaves it; so far the command line has one (F4.6b), the others follow.
import { useEffect, useState, type PointerEvent as ReactPointerEvent } from 'react';
import { useCallback, useEffect, useState, type PointerEvent as ReactPointerEvent } from 'react';
import type * as Y from 'yjs';
import { desktopView } from '@mdg-filius/doc';
import type { NodeSettings } from '@mdg-filius/protocol';
import background from '../assets/desktop/hintergrundbild.png';
import networkOff from '../assets/desktop/netzwek_aus.png';
import networkOn from '../assets/desktop/netzwek_c.png';
import { ICON_URLS } from '../canvas/icons.js';
import { useLanguage, useTranslate } from '../i18n/i18n.js';
import { desktopPrograms, INSTALLATION_ICON, JAVA_PROGRAMS } from './programs.js';
import type { Simulation } from '../simulation/useSimulation.js';
import { ProgramFrame } from './ProgramFrame.js';
import { desktopPrograms, INSTALLATION_ICON, JAVA_PROGRAMS, type DesktopProgram } from './programs.js';
import { TerminalPanel } from './TerminalPanel.js';
import './desktop.css';
export interface DesktopWindowProps {
readonly doc: Y.Doc;
readonly id: string;
/** The computer's network as the simulation runs it; undefined until the room has sent it. */
readonly settings: NodeSettings | undefined;
/** The room: the computer's network as it runs, its programs' windows. */
readonly simulation: Simulation;
/** Whether its cable carried a frame at the last tick. */
readonly active: boolean;
/** The window's top left corner on the surface. */
@@ -36,11 +39,17 @@ export interface DesktopWindowProps {
readonly y: number;
/** Its place in the stack of desktops. */
readonly z: number;
/** The program whose window is open on it, kept by the editor over a return to the design (Java's desktop keeps it). */
readonly program: DesktopProgram | null;
readonly onProgram: (program: DesktopProgram | null) => void;
readonly onMove: (x: number, y: number) => void;
readonly onFront: () => void;
readonly onClose: () => void;
}
/** The programs whose windows are built so far. */
const WITH_WINDOW: ReadonlySet<DesktopProgram> = new Set(['commandPrompt']);
/** The document's desktop of a computer, read anew after every change (a program installed, the label changed). */
function useDesktopView(doc: Y.Doc, id: string) {
const [view, setView] = useState(() => desktopView(doc, id));
@@ -53,13 +62,20 @@ function useDesktopView(doc: Y.Doc, id: string) {
return view;
}
export function DesktopWindow({ doc, id, settings, active, x, y, z, onMove, onFront, onClose }: DesktopWindowProps) {
export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: open, onProgram: setOpen, onMove, onFront, onClose }: DesktopWindowProps) {
const t = useTranslate();
const language = useLanguage();
const view = useDesktopView(doc, id);
const { watch } = simulation;
useEffect(() => watch([`node:${id}`]), [watch, id]);
const settings = simulation.nodes.get(id);
const [info, setInfo] = useState(false);
const home = useCallback(() => setOpen(null), [setOpen]);
const [drag, setDrag] = useState<{ readonly pointer: number; readonly dx: number; readonly dy: number; moved: boolean } | null>(null);
if (view === undefined) return null;
const programs = desktopPrograms(view.programs, language);
// A program removed while its window was open: the desktop shows.
const shown = open !== null && programs.includes(open) ? open : null;
const address = settings?.ipAddress ?? '';
const title = view.addressAsName ? view.label : `${view.label} - ${address}`;
@@ -98,21 +114,33 @@ export function DesktopWindow({ doc, id, settings, active, x, y, z, onMove, onFr
<div className="desktop-screen" style={{ backgroundImage: `url(${background})` }}>
<ul className="desktop-icons">
<li>
{/* The programs' windows come with the next steps. */}
{/* The installation's window comes with F4.6c. */}
<button type="button" className="desktop-icon" aria-disabled="true" title={t('desktop.installation')}>
<img src={INSTALLATION_ICON} alt="" />
<span>{t('desktop.installation')}</span>
</button>
</li>
{desktopPrograms(view.programs, language).map((program) => (
{programs.map((program) => (
<li key={program}>
<button type="button" className="desktop-icon" data-program={program} aria-disabled="true" title={t(`program.${program}`)}>
<button
type="button"
className="desktop-icon"
data-program={program}
aria-disabled={WITH_WINDOW.has(program) ? undefined : 'true'}
title={t(`program.${program}`)}
onClick={() => WITH_WINDOW.has(program) && setOpen(program)}
>
<img src={JAVA_PROGRAMS[program].icon} alt="" />
<span>{t(`program.${program}`)}</span>
</button>
</li>
))}
</ul>
{shown === null ? null : (
<ProgramFrame icon={JAVA_PROGRAMS[shown].icon} title={t(`program.${shown}`)} program={shown}>
{shown === 'commandPrompt' ? <TerminalPanel node={id} simulation={simulation} onExit={home} /> : null}
</ProgramFrame>
)}
{info ? (
// A press beside the panel closes it, as in Java.
<div className="desktop-info-layer" onPointerDown={(event) => event.target === event.currentTarget && setInfo(false)}>
@@ -129,7 +157,14 @@ export function DesktopWindow({ doc, id, settings, active, x, y, z, onMove, onFr
</div>
<footer className="desktop-taskbar">
<span />
<button type="button" className="desktop-home" onClick={() => setInfo(false)}>
<button
type="button"
className="desktop-home"
onClick={() => {
setInfo(false);
setOpen(null);
}}
>
{t('desktop.home')}
</button>
<button type="button" className="desktop-network-icon" data-active={active ? 'true' : undefined} aria-label={t('desktop.network')} aria-expanded={info} title={address} onClick={() => setInfo(!info)}>
@@ -0,0 +1,20 @@
// A program's window on a computer's desktop, after Java's
// GUIApplicationWindow: it fills the desktop above the taskbar, with Java's
// dark grey title bar — the program's icon small on the left, its name in
// the middle. Java's window has no close button: the taskbar's home button
// leaves it, and the program keeps its window for the next time.
import type { ReactNode } from 'react';
export function ProgramFrame({ icon, title, program, children }: { readonly icon: string; readonly title: string; readonly program: string; readonly children: ReactNode }) {
return (
<section className="program-window" aria-label={title} data-program-window={program}>
<header className="program-titlebar">
<img src={icon} alt="" />
<h3>{title}</h3>
<span />
</header>
<div className="program-content">{children}</div>
</section>
);
}
@@ -0,0 +1,125 @@
// A computer's command line, after Java's GUIApplicationTerminalWindow: grey
// text on black, the prompt before the input line and hidden while a command
// runs on, Enter hands the line over, the arrow keys go through the lines
// entered, Ctrl+C ends a command. The window itself is the room's — the same
// for everyone who opens it, kept over stops (TerminalWindow) — and so is
// what it shows; this panel only types into it.
//
// For a finger, under the window: the commands as chips (a tap puts the
// command into the input line), the arrow keys and Ctrl+C as buttons.
import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent } from 'react';
import { historyLine, historyStep, terminalScreen, type TerminalLine } from '@mdg-filius/protocol';
import { useTranslate } from '../i18n/i18n.js';
import type { Simulation } from '../simulation/useSimulation.js';
/** The commands offered as chips: the network's first, as a lesson needs them. */
export const COMMAND_CHIPS = ['ipconfig', 'ping', 'route', 'nslookup', 'host', 'traceroute', 'arp', 'netstat', 'ls', 'cd', 'cat', 'help'] as const;
const NO_LINES: readonly TerminalLine[] = [];
export function TerminalPanel({ node, simulation, onExit }: { readonly node: string; readonly simulation: Simulation; readonly onExit: () => void }) {
const t = useTranslate();
const topic = `app:${node}/terminal` as const;
const { watch, act } = simulation;
useEffect(() => watch([topic]), [watch, topic]);
const app = simulation.apps.get(topic);
const lines = (app?.lines as readonly TerminalLine[] | undefined) ?? NO_LINES;
const screen = terminalScreen(lines);
const [input, setInput] = useState('');
const [pointer, setPointer] = useState(-1);
const field = useRef<HTMLInputElement>(null);
const scroller = useRef<HTMLDivElement>(null);
// exit closes the window — an exit typed now, by anyone; not one of the lines it had when it opened.
const seen = useRef(lines.length);
useEffect(() => {
const from = Math.max(seen.current, app?.live ?? 0);
seen.current = lines.length;
if (lines.slice(from).some((line) => line.kind === 'exit')) onExit();
}, [lines, app?.live, onExit]);
// The newest text in sight, as Java scrolls to the bottom.
useLayoutEffect(() => {
const element = scroller.current;
if (element !== null) element.scrollTop = element.scrollHeight;
}, [screen.text, screen.promptShown]);
useEffect(() => field.current?.focus(), []);
const enter = () => {
act(node, 'terminal', 'enter', { line: input });
setInput('');
setPointer(-1);
};
const browse = (step: 'up' | 'down') => {
const next = historyStep(pointer, step, screen.history.length);
setPointer(next);
setInput(historyLine(screen.history, next));
};
const interrupt = () => act(node, 'terminal', 'interrupt');
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault();
enter();
} else if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
event.preventDefault();
browse(event.key === 'ArrowUp' ? 'up' : 'down');
} else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'c' && event.currentTarget.selectionStart === event.currentTarget.selectionEnd) {
// Ctrl+C with nothing marked ends the command, as in Java; with text marked it copies.
event.preventDefault();
interrupt();
}
};
return (
<div className="terminal">
<div className="terminal-screen" ref={scroller} onClick={() => field.current?.focus()}>
<pre className="terminal-text">{screen.text}</pre>
<div className="terminal-input">
<span className="terminal-prompt" style={{ visibility: screen.promptShown ? 'visible' : 'hidden' }}>
{screen.prompt}
</span>
<input
ref={field}
name="terminalInput"
value={input}
aria-label={t('terminal.input')}
autoCapitalize="off"
autoCorrect="off"
autoComplete="off"
spellCheck={false}
enterKeyHint="send"
onChange={(event) => setInput(event.target.value)}
onKeyDown={onKeyDown}
/>
</div>
</div>
<div className="terminal-keys" role="toolbar" aria-label={t('terminal.commands')}>
{COMMAND_CHIPS.map((command) => (
<button
key={command}
type="button"
className="terminal-chip"
onClick={() => {
setInput(`${command} `);
setPointer(-1);
field.current?.focus();
}}
>
{command}
</button>
))}
<button type="button" className="terminal-chip" aria-label={t('terminal.previous')} title={t('terminal.previous')} onClick={() => browse('up')}>
↑
</button>
<button type="button" className="terminal-chip" aria-label={t('terminal.next')} title={t('terminal.next')} onClick={() => browse('down')}>
↓
</button>
<button type="button" className="terminal-chip" aria-label={t('terminal.interrupt')} title={t('terminal.interrupt')} onClick={interrupt}>
{t('terminal.ctrlC')}
</button>
</div>
</div>
);
}
+112
View File
@@ -183,3 +183,115 @@
width: 18px;
height: 18px;
}
/* A program's window over the desktop (GUIApplicationWindow): Java's dark
* grey title bar of 30 pixels with the icon at 16. */
.program-window {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
background: #ffffff;
}
.program-titlebar {
display: grid;
grid-template-columns: 30px 1fr 30px;
align-items: center;
height: 30px;
flex: none;
background: rgb(64 64 64);
}
.program-titlebar img {
justify-self: center;
width: 16px;
height: 16px;
}
.program-titlebar h3 {
margin: 0;
text-align: center;
font: 13px Dialog, sans-serif;
color: rgb(225 225 225);
}
.program-content {
position: relative;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
/* The command line (GUIApplicationTerminalWindow): grey on black, the input
* line right after the text in the same scrolling area. 16 pixels in the
* input: Safari on the iPad zooms into smaller fields. */
.terminal {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: #000000;
}
.terminal-screen {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 5px 5px 5px;
color: rgb(222 222 222);
font: 14px ui-monospace, Menlo, Consolas, 'Courier New', monospace;
cursor: text;
}
.terminal-text {
margin: 0;
font: inherit;
white-space: pre-wrap;
overflow-wrap: anywhere;
user-select: text;
}
.terminal-input {
display: flex;
align-items: baseline;
}
.terminal-prompt {
white-space: pre;
}
.terminal-input input {
flex: 1;
min-width: 0;
padding: 0;
border: none;
outline: none;
background: transparent;
color: rgb(222 222 222);
caret-color: rgb(222 222 222);
font: 16px ui-monospace, Menlo, Consolas, 'Courier New', monospace;
}
.terminal-keys {
display: flex;
gap: 6px;
flex: none;
overflow-x: auto;
padding: 6px;
background: rgb(32 32 32);
}
.terminal-chip {
flex: none;
min-width: 44px;
min-height: 44px;
padding: 0 12px;
border: 1px solid rgb(90 90 90);
border-radius: 8px;
background: rgb(48 48 48);
color: rgb(222 222 222);
font: 14px ui-monospace, Menlo, Consolas, monospace;
cursor: pointer;
}
+6
View File
@@ -154,6 +154,12 @@
"sheet.title": "Einstellungen",
"sheet.wireless": "Drahtlos (WLAN)",
"sim.failed": "Die Simulation ist auf einen Fehler gestoßen und wurde beendet.",
"terminal.commands": "Befehle",
"terminal.ctrlC": "Strg+C",
"terminal.input": "Befehl eingeben",
"terminal.interrupt": "Befehl abbrechen (Strg+C)",
"terminal.next": "Nächster Befehl",
"terminal.previous": "Vorheriger Befehl",
"zoom.fit": "Alles zeigen",
"zoom.in": "Vergrößern",
"zoom.level": "{percent} %",
+6
View File
@@ -154,6 +154,12 @@
"sheet.title": "Settings",
"sheet.wireless": "Wireless (Wi-Fi)",
"sim.failed": "The simulation ran into an error and was stopped.",
"terminal.commands": "Commands",
"terminal.ctrlC": "Ctrl+C",
"terminal.input": "Enter a command",
"terminal.interrupt": "Stop the command (Ctrl+C)",
"terminal.next": "Next command",
"terminal.previous": "Previous command",
"zoom.fit": "Show all",
"zoom.in": "Zoom in",
"zoom.level": "{percent} %",
@@ -1,11 +1,12 @@
// The editor's side of the simulation room (plan §6): its mode and speed,
// the cables that carry frames, a computer's network as it runs. The room
// the cables that carry frames, a computer's network as it runs, the lines
// of the programs' windows and the actions taken in them. The room
// runs behind a SimHost — in a worker of this page (LocalSimHost) until F
// holds it (RemoteSimHost, F5); the editor keeps no simulation of its own.
import { useCallback, useEffect, useRef, useState } from 'react';
import type * as Y from 'yjs';
import type { NodeSettings, SimHost, SimMode, Topic } from '@mdg-filius/protocol';
import type { AppKind, NodeSettings, SimHost, SimMode, Topic } from '@mdg-filius/protocol';
import { LocalSimHost } from '@mdg-filius/sim';
import SimWorker from '@mdg-filius/sim/worker?worker';
@@ -18,6 +19,12 @@ export const localSimHost: SimHostFactory = (doc) => {
return new LocalSimHost(doc, worker, () => worker.terminate());
};
/** A program window's lines as the room sent them; those before `live` came with the subscription, the rest as they happened. */
export interface AppLines {
readonly lines: readonly unknown[];
readonly live: number;
}
export interface Simulation {
readonly mode: SimMode;
/** Java's slider, 1 to 100 per cent. */
@@ -26,6 +33,8 @@ export interface Simulation {
readonly activeLinks: ReadonlySet<string>;
/** Each watched computer's network as the simulation runs it. */
readonly nodes: ReadonlyMap<string, NodeSettings>;
/** Each watched program window's lines, by its topic. */
readonly apps: ReadonlyMap<string, AppLines>;
/** Whether the core failed and the room went back to design mode, since the last start. */
readonly failed: boolean;
start(): void;
@@ -33,6 +42,8 @@ export interface Simulation {
setSpeed(value: number): void;
/** Watches these topics until the returned function is called; a topic two parts watch is sent for once. */
watch(topics: readonly Topic[]): () => void;
/** An action in a program's window (the room answers with its new lines). */
act(node: string, app: AppKind, action: string, args?: Readonly<Record<string, unknown>>): void;
}
const NO_LINKS: ReadonlySet<string> = new Set();
@@ -45,6 +56,7 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
const [activeLinks, setActiveLinks] = useState<ReadonlySet<string>>(NO_LINKS);
const [nodes, setNodes] = useState<ReadonlyMap<string, NodeSettings>>(new Map());
const [failed, setFailed] = useState(false);
const [apps, setApps] = useState<ReadonlyMap<string, AppLines>>(new Map());
useEffect(() => {
const made = createHost(doc);
@@ -62,6 +74,15 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
case 'node':
setNodes((before) => new Map(before).set(message.node, message.settings));
return;
case 'app':
setApps((before) => {
const topic = `app:${message.node}/${message.app}`;
const known = before.get(topic)?.lines ?? [];
// From 0 the room sends a window anew (a subscription): all of it is what was before.
const lines = message.from === 0 ? message.lines : [...known.slice(0, message.from), ...message.lines];
return new Map(before).set(topic, { lines, live: message.from === 0 ? lines.length : (before.get(topic)?.live ?? 0) });
});
return;
case 'error':
if (message.code === 'engineFailed') setFailed(true);
return;
@@ -101,5 +122,9 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
};
}, []);
return { mode, speed, activeLinks, nodes, failed, start, stop, setSpeed, watch };
const act = useCallback((node: string, app: AppKind, action: string, args: Readonly<Record<string, unknown>> = {}) => {
host.current?.send({ v: 1, kind: 'input', node, app, action, args, reqId: crypto.randomUUID() });
}, []);
return { mode, speed, activeLinks, nodes, apps, failed, start, stop, setSpeed, watch, act };
}
+1
View File
@@ -1,3 +1,4 @@
// Public surface of the wire protocol.
export * from './host.js';
export * from './sim.js';
export * from './terminal.js';
+82
View File
@@ -0,0 +1,82 @@
// A command line's window as the room keeps it and every client shows it
// (plan §7.3): its lines, in order, and how a client reads them, after
// GUIApplicationTerminalWindow (measured with tools/java-oracle's
// TerminalWindowRun). The window, not the program, puts the start screen
// up, writes each entered line after its prompt, takes a blank line, cls,
// reset and exit itself, hides the prompt while a command runs and keeps the
// lines entered for the arrow keys.
/** A line of a command line's window. */
export type TerminalLine =
/** What the program or the window wrote: the start screen, a command's output, a blank line's " \n". */
| { readonly kind: 'text'; readonly text: string }
/** A line entered after its prompt; `command` when the program runs it (not cls, reset or exit). */
| { readonly kind: 'input'; readonly prompt: string; readonly line: string; readonly command: boolean }
/** cls or reset: the window starts again (the start screen follows). */
| { readonly kind: 'clear' }
/** A command that runs on (ping …) began or ended (Java's Boolean to the window). */
| { readonly kind: 'busy'; readonly busy: boolean }
/** The prompt after a command, when it changed (the working directory). */
| { readonly kind: 'prompt'; readonly prompt: string }
/** exit: the window closes; its text stays. */
| { readonly kind: 'exit' };
export interface TerminalScreen {
/** What the window shows, from its last cls on. */
readonly text: string;
readonly prompt: string;
/** Java hides the prompt from a command's start until its end. */
readonly promptShown: boolean;
/** Every line entered, the oldest first (blank lines not), for the arrow keys. */
readonly history: readonly string[];
}
/** The window as Java shows it after these lines. */
export function terminalScreen(lines: readonly TerminalLine[]): TerminalScreen {
let text = '';
let prompt = '';
let promptShown = true;
let busy = false;
const history: string[] = [];
for (const line of lines) {
switch (line.kind) {
case 'text':
text += line.text;
if (!busy) promptShown = true;
break;
case 'input':
text += `\n${line.prompt}${line.line}\n`;
history.push(line.line);
if (line.command) promptShown = false;
break;
case 'clear':
text = '';
break;
case 'busy':
busy = line.busy;
promptShown = !busy;
break;
case 'prompt':
prompt = line.prompt;
break;
case 'exit':
break;
}
}
return { text, prompt, promptShown, history };
}
/**
* The arrow keys in the history (Java's commandHistoryPointer): -1 is the
* empty line below the newest, 0 the newest, and so on; up goes back, down
* forward, neither beyond the ends.
*/
export function historyStep(pointer: number, step: 'up' | 'down', length: number): number {
const next = pointer + (step === 'up' ? 1 : -1);
return Math.max(-1, Math.min(length - 1, next));
}
/** The line the history shows at a pointer: empty below the newest. */
export function historyLine(history: readonly string[], pointer: number): string {
return pointer < 0 ? '' : (history[history.length - 1 - pointer] ?? '');
}
+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']);
});
});