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:
+7
-2
File diff suppressed because one or more lines are too long
@@ -199,6 +199,16 @@ export class Terminal {
|
|||||||
return this.directoryValue;
|
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. */
|
/** The window's prompt. */
|
||||||
get prompt(): string {
|
get prompt(): string {
|
||||||
return `${this.directoryValue}> `;
|
return `${this.directoryValue}> `;
|
||||||
|
|||||||
@@ -1031,7 +1031,7 @@ export class NetworkRuntimeEngine {
|
|||||||
this.stateValue = initialState(seed);
|
this.stateValue = initialState(seed);
|
||||||
this.packetCapture.beginRun();
|
this.packetCapture.beginRun();
|
||||||
this.stateValue.phase = 'running';
|
this.stateValue.phase = 'running';
|
||||||
this.stateValue.currentTimeMilliseconds = input.initialTimeMilliseconds;
|
this.stateValue.currentTimeMilliseconds = Math.floor(input.initialTimeMilliseconds);
|
||||||
this.stateValue.simulationSpeedPercent = this.runtimeSpeed.percent;
|
this.stateValue.simulationSpeedPercent = this.runtimeSpeed.percent;
|
||||||
this.stateValue.topologySnapshot = input.snapshot;
|
this.stateValue.topologySnapshot = input.snapshot;
|
||||||
this.random = new JavaCompatibleRandom(seed);
|
this.random = new JavaCompatibleRandom(seed);
|
||||||
@@ -1097,7 +1097,8 @@ export class NetworkRuntimeEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'advance':
|
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': {
|
case 'schedule': {
|
||||||
if (this.stateValue.phase !== 'running') return [this.output({ type: 'advanceIgnoredWhileStopped' })];
|
if (this.stateValue.phase !== 'running') return [this.output({ type: 'advanceIgnoredWhileStopped' })];
|
||||||
|
|||||||
@@ -141,6 +141,17 @@ describe('event queue and virtual clock', () => {
|
|||||||
expect(engine.state.pendingEvents).toEqual([]);
|
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', () => {
|
it('rejects a past time without changing the clock', () => {
|
||||||
const engine = new NetworkRuntimeEngine(7);
|
const engine = new NetworkRuntimeEngine(7);
|
||||||
engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 7, initialTimeMilliseconds: 10 });
|
engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 7, initialTimeMilliseconds: 10 });
|
||||||
|
|||||||
@@ -237,6 +237,121 @@ test.describe('on a phone', () => {
|
|||||||
const home = (await window.getByRole('button', { name: 'Startbildschirm' }).boundingBox())!;
|
const home = (await window.getByRole('button', { name: 'Startbildschirm' }).boundingBox())!;
|
||||||
expect(home.height).toBeGreaterThanOrEqual(44);
|
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 }) => {
|
test('speaks English when asked', async ({ page }) => {
|
||||||
|
|||||||
@@ -20,9 +20,10 @@ import { Palette, type PaletteKind } from './design/Palette.js';
|
|||||||
import { RouterDialog } from './design/RouterDialog.js';
|
import { RouterDialog } from './design/RouterDialog.js';
|
||||||
import { DeviceSheet } from './sheet/DeviceSheet.js';
|
import { DeviceSheet } from './sheet/DeviceSheet.js';
|
||||||
import { DesktopWindow } from './desktop/DesktopWindow.js';
|
import { DesktopWindow } from './desktop/DesktopWindow.js';
|
||||||
|
import type { DesktopProgram } from './desktop/programs.js';
|
||||||
import { useTranslate } from './i18n/i18n.js';
|
import { useTranslate } from './i18n/i18n.js';
|
||||||
import { ModeBar } from './simulation/ModeBar.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. */
|
/** The document's drawing, read anew after every change. */
|
||||||
export function useTopologyView(doc: Y.Doc): TopologyView {
|
export function useTopologyView(doc: Y.Doc): TopologyView {
|
||||||
@@ -79,6 +80,7 @@ interface OpenDesktop {
|
|||||||
readonly x: number;
|
readonly x: number;
|
||||||
readonly y: number;
|
readonly y: number;
|
||||||
readonly z: 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. */
|
/** 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));
|
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 }) {
|
export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly createSimHost?: SimHostFactory }) {
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
const view = useTopologyView(doc);
|
const view = useTopologyView(doc);
|
||||||
@@ -146,7 +141,7 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
|
|||||||
setDesktops((open) => {
|
setDesktops((open) => {
|
||||||
if (open.some((each) => each.id === id)) return toFront(open, id);
|
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.
|
// 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>
|
</div>
|
||||||
{simulating
|
{simulating
|
||||||
? desktops.map((desktop) => (
|
? desktops.map((desktop) => (
|
||||||
<WatchedDesktop
|
<DesktopWindow
|
||||||
key={desktop.id}
|
key={desktop.id}
|
||||||
simulation={simulation}
|
simulation={simulation}
|
||||||
doc={doc}
|
doc={doc}
|
||||||
@@ -314,6 +309,8 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
|
|||||||
x={desktop.x}
|
x={desktop.x}
|
||||||
y={desktop.y}
|
y={desktop.y}
|
||||||
z={desktop.z}
|
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)))}
|
onMove={(x, y) => setDesktops((open) => open.map((each) => (each.id === desktop.id ? { ...each, x, y } : each)))}
|
||||||
onFront={() => setDesktops((open) => toFront(open, desktop.id))}
|
onFront={() => setDesktops((open) => toFront(open, desktop.id))}
|
||||||
onClose={() => setDesktops((open) => open.filter((each) => each.id !== desktop.id))}
|
onClose={() => setDesktops((open) => open.filter((each) => each.id !== desktop.id))}
|
||||||
|
|||||||
@@ -9,26 +9,29 @@
|
|||||||
// show; it is left out.
|
// show; it is left out.
|
||||||
//
|
//
|
||||||
// On a wide screen the window lies over the surface and moves by its title
|
// 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'
|
// bar, as Java's windows do; on a phone it fills the surface. A program's
|
||||||
// windows follow (F4.6b on).
|
// 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 type * as Y from 'yjs';
|
||||||
import { desktopView } from '@mdg-filius/doc';
|
import { desktopView } from '@mdg-filius/doc';
|
||||||
import type { NodeSettings } from '@mdg-filius/protocol';
|
|
||||||
import background from '../assets/desktop/hintergrundbild.png';
|
import background from '../assets/desktop/hintergrundbild.png';
|
||||||
import networkOff from '../assets/desktop/netzwek_aus.png';
|
import networkOff from '../assets/desktop/netzwek_aus.png';
|
||||||
import networkOn from '../assets/desktop/netzwek_c.png';
|
import networkOn from '../assets/desktop/netzwek_c.png';
|
||||||
import { ICON_URLS } from '../canvas/icons.js';
|
import { ICON_URLS } from '../canvas/icons.js';
|
||||||
import { useLanguage, useTranslate } from '../i18n/i18n.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';
|
import './desktop.css';
|
||||||
|
|
||||||
export interface DesktopWindowProps {
|
export interface DesktopWindowProps {
|
||||||
readonly doc: Y.Doc;
|
readonly doc: Y.Doc;
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
/** The computer's network as the simulation runs it; undefined until the room has sent it. */
|
/** The room: the computer's network as it runs, its programs' windows. */
|
||||||
readonly settings: NodeSettings | undefined;
|
readonly simulation: Simulation;
|
||||||
/** Whether its cable carried a frame at the last tick. */
|
/** Whether its cable carried a frame at the last tick. */
|
||||||
readonly active: boolean;
|
readonly active: boolean;
|
||||||
/** The window's top left corner on the surface. */
|
/** The window's top left corner on the surface. */
|
||||||
@@ -36,11 +39,17 @@ export interface DesktopWindowProps {
|
|||||||
readonly y: number;
|
readonly y: number;
|
||||||
/** Its place in the stack of desktops. */
|
/** Its place in the stack of desktops. */
|
||||||
readonly z: number;
|
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 onMove: (x: number, y: number) => void;
|
||||||
readonly onFront: () => void;
|
readonly onFront: () => void;
|
||||||
readonly onClose: () => 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). */
|
/** 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) {
|
function useDesktopView(doc: Y.Doc, id: string) {
|
||||||
const [view, setView] = useState(() => desktopView(doc, id));
|
const [view, setView] = useState(() => desktopView(doc, id));
|
||||||
@@ -53,13 +62,20 @@ function useDesktopView(doc: Y.Doc, id: string) {
|
|||||||
return view;
|
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 t = useTranslate();
|
||||||
const language = useLanguage();
|
const language = useLanguage();
|
||||||
const view = useDesktopView(doc, id);
|
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 [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);
|
const [drag, setDrag] = useState<{ readonly pointer: number; readonly dx: number; readonly dy: number; moved: boolean } | null>(null);
|
||||||
if (view === undefined) return 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 address = settings?.ipAddress ?? '';
|
||||||
const title = view.addressAsName ? view.label : `${view.label} - ${address}`;
|
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})` }}>
|
<div className="desktop-screen" style={{ backgroundImage: `url(${background})` }}>
|
||||||
<ul className="desktop-icons">
|
<ul className="desktop-icons">
|
||||||
<li>
|
<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')}>
|
<button type="button" className="desktop-icon" aria-disabled="true" title={t('desktop.installation')}>
|
||||||
<img src={INSTALLATION_ICON} alt="" />
|
<img src={INSTALLATION_ICON} alt="" />
|
||||||
<span>{t('desktop.installation')}</span>
|
<span>{t('desktop.installation')}</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{desktopPrograms(view.programs, language).map((program) => (
|
{programs.map((program) => (
|
||||||
<li key={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="" />
|
<img src={JAVA_PROGRAMS[program].icon} alt="" />
|
||||||
<span>{t(`program.${program}`)}</span>
|
<span>{t(`program.${program}`)}</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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 ? (
|
{info ? (
|
||||||
// A press beside the panel closes it, as in Java.
|
// A press beside the panel closes it, as in Java.
|
||||||
<div className="desktop-info-layer" onPointerDown={(event) => event.target === event.currentTarget && setInfo(false)}>
|
<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>
|
</div>
|
||||||
<footer className="desktop-taskbar">
|
<footer className="desktop-taskbar">
|
||||||
<span />
|
<span />
|
||||||
<button type="button" className="desktop-home" onClick={() => setInfo(false)}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="desktop-home"
|
||||||
|
onClick={() => {
|
||||||
|
setInfo(false);
|
||||||
|
setOpen(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
{t('desktop.home')}
|
{t('desktop.home')}
|
||||||
</button>
|
</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)}>
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -183,3 +183,115 @@
|
|||||||
width: 18px;
|
width: 18px;
|
||||||
height: 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -154,6 +154,12 @@
|
|||||||
"sheet.title": "Einstellungen",
|
"sheet.title": "Einstellungen",
|
||||||
"sheet.wireless": "Drahtlos (WLAN)",
|
"sheet.wireless": "Drahtlos (WLAN)",
|
||||||
"sim.failed": "Die Simulation ist auf einen Fehler gestoßen und wurde beendet.",
|
"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.fit": "Alles zeigen",
|
||||||
"zoom.in": "Vergrößern",
|
"zoom.in": "Vergrößern",
|
||||||
"zoom.level": "{percent} %",
|
"zoom.level": "{percent} %",
|
||||||
|
|||||||
@@ -154,6 +154,12 @@
|
|||||||
"sheet.title": "Settings",
|
"sheet.title": "Settings",
|
||||||
"sheet.wireless": "Wireless (Wi-Fi)",
|
"sheet.wireless": "Wireless (Wi-Fi)",
|
||||||
"sim.failed": "The simulation ran into an error and was stopped.",
|
"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.fit": "Show all",
|
||||||
"zoom.in": "Zoom in",
|
"zoom.in": "Zoom in",
|
||||||
"zoom.level": "{percent} %",
|
"zoom.level": "{percent} %",
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
// The editor's side of the simulation room (plan §6): its mode and speed,
|
// 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
|
// 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.
|
// holds it (RemoteSimHost, F5); the editor keeps no simulation of its own.
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import type * as Y from 'yjs';
|
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 { LocalSimHost } from '@mdg-filius/sim';
|
||||||
import SimWorker from '@mdg-filius/sim/worker?worker';
|
import SimWorker from '@mdg-filius/sim/worker?worker';
|
||||||
|
|
||||||
@@ -18,6 +19,12 @@ export const localSimHost: SimHostFactory = (doc) => {
|
|||||||
return new LocalSimHost(doc, worker, () => worker.terminate());
|
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 {
|
export interface Simulation {
|
||||||
readonly mode: SimMode;
|
readonly mode: SimMode;
|
||||||
/** Java's slider, 1 to 100 per cent. */
|
/** Java's slider, 1 to 100 per cent. */
|
||||||
@@ -26,6 +33,8 @@ export interface Simulation {
|
|||||||
readonly activeLinks: ReadonlySet<string>;
|
readonly activeLinks: ReadonlySet<string>;
|
||||||
/** Each watched computer's network as the simulation runs it. */
|
/** Each watched computer's network as the simulation runs it. */
|
||||||
readonly nodes: ReadonlyMap<string, NodeSettings>;
|
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. */
|
/** Whether the core failed and the room went back to design mode, since the last start. */
|
||||||
readonly failed: boolean;
|
readonly failed: boolean;
|
||||||
start(): void;
|
start(): void;
|
||||||
@@ -33,6 +42,8 @@ export interface Simulation {
|
|||||||
setSpeed(value: number): void;
|
setSpeed(value: number): void;
|
||||||
/** Watches these topics until the returned function is called; a topic two parts watch is sent for once. */
|
/** Watches these topics until the returned function is called; a topic two parts watch is sent for once. */
|
||||||
watch(topics: readonly Topic[]): () => void;
|
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();
|
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 [activeLinks, setActiveLinks] = useState<ReadonlySet<string>>(NO_LINKS);
|
||||||
const [nodes, setNodes] = useState<ReadonlyMap<string, NodeSettings>>(new Map());
|
const [nodes, setNodes] = useState<ReadonlyMap<string, NodeSettings>>(new Map());
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
|
const [apps, setApps] = useState<ReadonlyMap<string, AppLines>>(new Map());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const made = createHost(doc);
|
const made = createHost(doc);
|
||||||
@@ -62,6 +74,15 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
|
|||||||
case 'node':
|
case 'node':
|
||||||
setNodes((before) => new Map(before).set(message.node, message.settings));
|
setNodes((before) => new Map(before).set(message.node, message.settings));
|
||||||
return;
|
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':
|
case 'error':
|
||||||
if (message.code === 'engineFailed') setFailed(true);
|
if (message.code === 'engineFailed') setFailed(true);
|
||||||
return;
|
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,3 +1,4 @@
|
|||||||
// Public surface of the wire protocol.
|
// Public surface of the wire protocol.
|
||||||
export * from './host.js';
|
export * from './host.js';
|
||||||
export * from './sim.js';
|
export * from './sim.js';
|
||||||
|
export * from './terminal.js';
|
||||||
|
|||||||
@@ -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] ?? '');
|
||||||
|
}
|
||||||
@@ -7,13 +7,16 @@
|
|||||||
// Time: the engine's clock follows the wall clock, a millisecond for a
|
// 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
|
// 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).
|
// 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,
|
// 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
|
// the simulation goes on from where it was rather than running the missed
|
||||||
// minutes in one go.
|
// minutes in one go.
|
||||||
//
|
//
|
||||||
// The engine outlives a stop: the capture stays, as Java's Lauscher does, and
|
// 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
|
// 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 * as Y from 'yjs';
|
||||||
import { DEFAULT_SPEED_PERCENT, NetworkRuntimeEngine, captureRows, interfaceKey, stableGeneratedMacAddress, type CapturedFrame, type CaptureRow, type Id, type LogLine } from '@mdg-filius/core';
|
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 Topic,
|
||||||
type TraceFrame,
|
type TraceFrame,
|
||||||
} from '@mdg-filius/protocol';
|
} from '@mdg-filius/protocol';
|
||||||
|
import { TerminalWindow } from './terminalWindow.js';
|
||||||
|
|
||||||
export const TICK_MILLISECONDS = 200;
|
export const TICK_MILLISECONDS = 200;
|
||||||
export const MAXIMUM_CATCH_UP_MILLISECONDS = 2_000;
|
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. */
|
/** Per card, how many frames it held at the last tick. */
|
||||||
private framesSeen = new Map<Id, number>();
|
private framesSeen = new Map<Id, number>();
|
||||||
private lastActivity: string[] = [];
|
private lastActivity: string[] = [];
|
||||||
|
/** Each computer's command line window, kept over stops. */
|
||||||
|
private readonly terminalWindows = new Map<Id, TerminalWindow>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly doc: Y.Doc,
|
private readonly doc: Y.Doc,
|
||||||
@@ -162,6 +168,13 @@ export class SimulationRoom {
|
|||||||
this.engine.setSimulationSpeed(this.speed);
|
this.engine.setSimulationSpeed(this.speed);
|
||||||
if (!this.guarded(() => this.engine.handle({ type: 'start', snapshot, seed: this.options.seed(), initialTimeMilliseconds: this.engine.state.currentTimeMilliseconds }))) return;
|
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]));
|
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.mode = 'simulation';
|
||||||
this.lastWall = this.options.now();
|
this.lastWall = this.options.now();
|
||||||
this.broadcast(this.state());
|
this.broadcast(this.state());
|
||||||
@@ -181,8 +194,10 @@ export class SimulationRoom {
|
|||||||
tick(): void {
|
tick(): void {
|
||||||
if (this.mode !== 'simulation') return;
|
if (this.mode !== 'simulation') return;
|
||||||
const now = this.options.now();
|
const now = this.options.now();
|
||||||
const elapsed = Math.min(MAXIMUM_CATCH_UP_MILLISECONDS, Math.max(0, now - (this.lastWall ?? now)));
|
const behind = Math.max(0, now - (this.lastWall ?? 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;
|
if (!this.guarded(() => this.engine.handle({ type: 'advance', toMilliseconds: this.engine.state.currentTimeMilliseconds + elapsed }))) return;
|
||||||
this.flush([...this.clients.values()], false);
|
this.flush([...this.clients.values()], false);
|
||||||
}
|
}
|
||||||
@@ -212,15 +227,16 @@ export class SimulationRoom {
|
|||||||
if (this.mode !== 'simulation') return 'notRunning';
|
if (this.mode !== 'simulation') return 'notRunning';
|
||||||
if (!this.engine.state.topologySnapshot.nodes.some((node) => node.id === input.node)) return 'unknownNode';
|
if (!this.engine.state.topologySnapshot.nodes.some((node) => node.id === input.node)) return 'unknownNode';
|
||||||
if (input.app !== 'terminal') return 'unknownAction';
|
if (input.app !== 'terminal') return 'unknownAction';
|
||||||
const terminal = this.engine.terminal(input.node);
|
// A window has a program while the simulation runs (checked above), from its first start on.
|
||||||
if (terminal === undefined) return 'unknownApp';
|
const window = this.terminalWindows.get(input.node);
|
||||||
|
if (window === undefined || this.engine.terminal(input.node) === undefined) return 'unknownApp';
|
||||||
switch (input.action) {
|
switch (input.action) {
|
||||||
case 'enter':
|
case 'enter':
|
||||||
if (typeof input.args.line !== 'string') return 'badArgs';
|
if (typeof input.args.line !== 'string') return 'badArgs';
|
||||||
terminal.enter(input.args.line);
|
window.enter(input.args.line);
|
||||||
return undefined;
|
return undefined;
|
||||||
case 'interrupt':
|
case 'interrupt':
|
||||||
terminal.interrupt();
|
window.interrupt();
|
||||||
return undefined;
|
return undefined;
|
||||||
default:
|
default:
|
||||||
return 'unknownAction';
|
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. */
|
/** 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 {
|
private flush(clients: readonly Client[], subscribing: boolean): void {
|
||||||
if (!subscribing) this.updateActivity();
|
if (!subscribing) this.updateActivity();
|
||||||
|
for (const window of this.terminalWindows.values()) window.pull();
|
||||||
const rowsCache = new Map<Id, TraceFrame[]>();
|
const rowsCache = new Map<Id, TraceFrame[]>();
|
||||||
const traceFrames = (portId: Id, after: number): TraceFrame[] => {
|
const traceFrames = (portId: Id, after: number): TraceFrame[] => {
|
||||||
const frames = this.engine.capture.frames(portId).filter((frame) => frame.number > after);
|
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 }));
|
client.send(message({ kind: 'trace', portId, frames, reset }));
|
||||||
} else {
|
} else {
|
||||||
const [, node, app] = /^app:([^/]+)\/(.+)$/.exec(topic)!;
|
const [, node, app] = /^app:([^/]+)\/(.+)$/.exec(topic)!;
|
||||||
const program = this.program(node!, app as AppKind);
|
|
||||||
const sent = client.appSent.get(topic);
|
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) {
|
if (program === undefined) {
|
||||||
// A window of a program that is not running: it starts empty with the next start.
|
// 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: [] }));
|
if (sent !== undefined || subscribing) client.send(message({ kind: 'app', node: node!, app: app as AppKind, from: 0, lines: [] }));
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,8 +107,11 @@ describe('a simulation room', () => {
|
|||||||
expect(each.from).toBe(each.from === 0 ? 0 : lines.length);
|
expect(each.from).toBe(each.from === 0 ? 0 : lines.length);
|
||||||
lines = [...lines.slice(0, each.from), ...each.lines];
|
lines = [...lines.slice(0, each.from), ...each.lines];
|
||||||
}
|
}
|
||||||
expect(lines).toEqual(engine.terminal(notebook.id)!.log.all);
|
// The window: its start screen, the line entered after its prompt, then what the program wrote, as the engine has it.
|
||||||
expect(lines.length).toBeGreaterThan(4);
|
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', () => {
|
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);
|
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 { got, send, node, run } = setUp();
|
||||||
const notebook = node('Notebook 1.10');
|
const notebook = node('Notebook 1.10');
|
||||||
send({ kind: 'sub', topics: [`app:${notebook.id}/terminal`] });
|
send({ kind: 'sub', topics: [`app:${notebook.id}/terminal`] });
|
||||||
@@ -168,11 +171,17 @@ describe('a simulation room', () => {
|
|||||||
send({ kind: 'start' });
|
send({ kind: 'start' });
|
||||||
send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ipconfig' }, reqId: 'r1' });
|
send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ipconfig' }, reqId: 'r1' });
|
||||||
run(400);
|
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' });
|
send({ kind: 'stop' });
|
||||||
expect(ofKind(got, 'app').at(-1)).toMatchObject({ from: 0, lines: [] });
|
|
||||||
send({ kind: 'start' });
|
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', () => {
|
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));
|
expect(roundTrip(90)).toBeGreaterThan(roundTrip(100));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('catches up at most two seconds after the clock jumped', () => {
|
it('catches up at most two seconds after the clock jumped, and goes on from there', () => {
|
||||||
const { got, send, jump } = setUp();
|
const { got, send, jump, run } = setUp();
|
||||||
send({ kind: 'start' });
|
send({ kind: 'start' });
|
||||||
jump(60_000);
|
jump(60_000);
|
||||||
|
// The missed minute is not made up over the next ticks either.
|
||||||
|
run(TICK_MILLISECONDS);
|
||||||
send({ kind: 'stop' });
|
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', () => {
|
it('starts with the document as it is then', () => {
|
||||||
@@ -329,5 +340,34 @@ describe('a simulation room', () => {
|
|||||||
send({ kind: 'sub', topics: [`node:${server.id}`] });
|
send({ kind: 'sub', topics: [`node:${server.id}`] });
|
||||||
expect(ofKind(got.slice(before), 'node')).toHaveLength(1);
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1239,3 +1239,29 @@ Gemessen (03.10.2026):
|
|||||||
Karte; ohne Karte behält der Router kein Gateway (`setStandardGateway`
|
Karte; ohne Karte behält der Router kein Gateway (`setStandardGateway`
|
||||||
schreibt es in die Karten, `getStandardGateway` liest `null`).
|
schreibt es in die Karten, `getStandardGateway` liest `null`).
|
||||||
|
|
||||||
|
### Das Fenster der Befehlszeile (F4.6b)
|
||||||
|
|
||||||
|
`TerminalWindowRun` öffnet Javas `GUIApplicationTerminalWindow` auf einem
|
||||||
|
`GUIDesktopPanel` eines Rechners ohne Netz, gibt Tasten direkt an den
|
||||||
|
Tastenhörer des Eingabefelds (Enter, Pfeil hoch/runter), stoppt und startet
|
||||||
|
das System einmal (`beenden`, `starten`, wie ein Moduswechsel) und schreibt
|
||||||
|
nach jeder Taste Text, Prompt, ob er sichtbar ist, und die Eingabezeile
|
||||||
|
(`packages/sim/test/terminalWindow.java.json`).
|
||||||
|
|
||||||
|
Gemessen (03.10.2026):
|
||||||
|
|
||||||
|
* Eine leere Zeile, auch nur aus Leerzeichen (`StringUtils.isBlank`), gibt
|
||||||
|
„␣␣\n“; jede andere Zeile steht danach als „\n“ + Prompt + Zeile + „\n“
|
||||||
|
im Fenster, auch `cls`, `reset` und `exit`, bevor das Fenster sie selbst
|
||||||
|
erledigt.
|
||||||
|
* `cls`/`reset` zeigen wieder den Startbildschirm; `exit` schließt das
|
||||||
|
Fenster, sein Text bleibt.
|
||||||
|
* Pfeil hoch geht durch alle eingegebenen Zeilen zurück (leere nicht),
|
||||||
|
runter vor, unter der neuesten steht eine leere Zeile; über die Enden
|
||||||
|
hinaus bleibt es stehen.
|
||||||
|
* Der Prompt folgt dem Arbeitsverzeichnis, nachgeführt, wenn ein Befehl
|
||||||
|
fertig ist (`cd dir` → „/dir> “).
|
||||||
|
* Nach Stopp und Start bleiben Text, Verlauf **und** Arbeitsverzeichnis:
|
||||||
|
Javas Terminal setzt den Ordner nur bei der Installation
|
||||||
|
(`setSystemSoftware`), und das Fenster ist dasselbe Objekt.
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ if [ ! -f "$upstream/target/filius.jar" ]; then
|
|||||||
fi
|
fi
|
||||||
classpath="$upstream/target/filius.jar:$upstream/target/lib/*"
|
classpath="$upstream/target/filius.jar:$upstream/target/lib/*"
|
||||||
mkdir -p "$here/.build"
|
mkdir -p "$here/.build"
|
||||||
javac -encoding UTF-8 -d "$here/.build" -cp "$classpath" "$here/src/FlsOracle.java" "$here/src/GetterDefaults.java" "$here/src/IpCheckCases.java" "$here/src/DnsHostsCases.java" "$here/src/EmailFileCases.java" "$here/src/BeansDump.java" "$here/src/Base64Cases.java" "$here/src/ScenarioNetwork.java" "$here/src/DhcpRun.java" "$here/src/TcpRun.java" "$here/src/DnsRun.java" "$here/src/ArpRun.java" "$here/src/RipRun.java" "$here/src/NatRun.java" "$here/src/WlanRun.java" "$here/src/ExampleRun.java" "$here/src/EchoRun.java" "$here/src/WebRun.java" "$here/src/RouterWebRun.java" "$here/src/KonfigCases.java" "$here/src/EmailRun.java" "$here/src/MailCases.java" "$here/src/EmailClientRun.java" "$here/src/TerminalCases.java" "$here/src/TerminalRun.java" "$here/src/FileCases.java" "$here/src/GnutellaRun.java" "$here/src/NewDeviceRun.java" "$here/src/SheetRun.java"
|
javac -encoding UTF-8 -d "$here/.build" -cp "$classpath" "$here/src/FlsOracle.java" "$here/src/GetterDefaults.java" "$here/src/IpCheckCases.java" "$here/src/DnsHostsCases.java" "$here/src/EmailFileCases.java" "$here/src/BeansDump.java" "$here/src/Base64Cases.java" "$here/src/ScenarioNetwork.java" "$here/src/DhcpRun.java" "$here/src/TcpRun.java" "$here/src/DnsRun.java" "$here/src/ArpRun.java" "$here/src/RipRun.java" "$here/src/NatRun.java" "$here/src/WlanRun.java" "$here/src/ExampleRun.java" "$here/src/EchoRun.java" "$here/src/WebRun.java" "$here/src/RouterWebRun.java" "$here/src/KonfigCases.java" "$here/src/EmailRun.java" "$here/src/MailCases.java" "$here/src/EmailClientRun.java" "$here/src/TerminalCases.java" "$here/src/TerminalRun.java" "$here/src/FileCases.java" "$here/src/GnutellaRun.java" "$here/src/NewDeviceRun.java" "$here/src/SheetRun.java" "$here/src/TerminalWindowRun.java"
|
||||||
# FILIUS writes its settings below $HOME; keep them out of the real one.
|
# FILIUS writes its settings below $HOME; keep them out of the real one.
|
||||||
mkdir -p "$here/.build/home"
|
mkdir -p "$here/.build/home"
|
||||||
# Removed first: a crashed run must not leave the previous result standing.
|
# Removed first: a crashed run must not leave the previous result standing.
|
||||||
@@ -337,6 +337,14 @@ HOME="$here/.build/home" xvfb-run -a java -cp "$classpath:$here/.build" SheetRun
|
|||||||
"$root/packages/doc/test/sheet.cases.txt" "$output" 2>&1 | grep -v 'logback\|^$' || true
|
"$root/packages/doc/test/sheet.cases.txt" "$output" 2>&1 | grep -v 'logback\|^$' || true
|
||||||
test -s "$output"
|
test -s "$output"
|
||||||
|
|
||||||
|
# FILIUS' command line window as it shows what is typed into it, key by key,
|
||||||
|
# over a stop and a start (TerminalWindowRun): the room's window
|
||||||
|
# (packages/sim/test/terminalWindow.test.ts).
|
||||||
|
output="$root/packages/sim/test/terminalWindow.java.json"
|
||||||
|
rm -f "$output"
|
||||||
|
HOME="$here/.build/home" xvfb-run -a java -Duser.language=de -Duser.country=DE -cp "$classpath:$here/.build" TerminalWindowRun "$output" 2>&1 | grep -v 'logback\|^$' || true
|
||||||
|
test -s "$output"
|
||||||
|
|
||||||
# FILIUS' own examples as Java FILIUS runs them, opened and started as FILIUS
|
# FILIUS' own examples as Java FILIUS runs them, opened and started as FILIUS
|
||||||
# does (ExampleRun): the acceptance of F2 (packages/fls/test/examplesJava.test.ts)
|
# does (ExampleRun): the acceptance of F2 (packages/fls/test/examplesJava.test.ts)
|
||||||
# and the classics of F3 (web, email, gnutella; packages/fls/test/classicsJava.test.ts).
|
# and the classics of F3 (web, email, gnutella; packages/fls/test/classicsJava.test.ts).
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
// Types into FILIUS' own command line window (GUIApplicationTerminalWindow on
|
||||||
|
// a GUIDesktopPanel) of a computer without a network, key by key as its key
|
||||||
|
// listener hears them, and writes after each key what the window shows: its
|
||||||
|
// text, its prompt and whether the prompt is shown, the input line. Then the
|
||||||
|
// simulation is stopped and started again (beenden, starten), as a change of
|
||||||
|
// mode does, and the window and the working directory are read again: the
|
||||||
|
// reference for the editor's command line window (packages/sim/test/
|
||||||
|
// terminalWindow.test.ts).
|
||||||
|
//
|
||||||
|
// Usage: java -cp filius.jar:lib/*:classes TerminalWindowRun <output json>
|
||||||
|
|
||||||
|
import filius.gui.anwendungssicht.GUIApplicationTerminalWindow;
|
||||||
|
import filius.gui.anwendungssicht.GUIDesktopPanel;
|
||||||
|
import filius.hardware.knoten.Rechner;
|
||||||
|
import filius.software.system.Betriebssystem;
|
||||||
|
|
||||||
|
import java.awt.event.KeyEvent;
|
||||||
|
import java.awt.event.KeyListener;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import javax.swing.JLabel;
|
||||||
|
import javax.swing.JTextArea;
|
||||||
|
import javax.swing.JTextField;
|
||||||
|
|
||||||
|
public class TerminalWindowRun {
|
||||||
|
static final String TERMINAL = "filius.software.lokal.Terminal";
|
||||||
|
|
||||||
|
static Object field(Object owner, String name) throws Exception {
|
||||||
|
Field field = GUIApplicationTerminalWindow.class.getDeclaredField(name);
|
||||||
|
field.setAccessible(true);
|
||||||
|
return field.get(owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, Object> shown(GUIApplicationTerminalWindow window, String step) throws Exception {
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("step", step);
|
||||||
|
result.put("text", ((JTextArea) field(window, "terminalField")).getText());
|
||||||
|
JLabel label = (JLabel) field(window, "inputLabel");
|
||||||
|
result.put("prompt", label.getText());
|
||||||
|
result.put("promptShown", label.isVisible());
|
||||||
|
result.put("input", ((JTextField) field(window, "inputField")).getText());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void press(JTextField input, int key, int modifiers) {
|
||||||
|
KeyEvent event = new KeyEvent(input, KeyEvent.KEY_PRESSED, System.currentTimeMillis(), modifiers, key, KeyEvent.CHAR_UNDEFINED);
|
||||||
|
for (KeyListener listener : input.getKeyListeners()) listener.keyPressed(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Waits until the program has answered: the prompt is shown again (or a second passed). */
|
||||||
|
static void settle(GUIApplicationTerminalWindow window) throws Exception {
|
||||||
|
JLabel label = (JLabel) field(window, "inputLabel");
|
||||||
|
for (int waited = 0; waited < 2000 && !label.isVisible(); waited += 20) Thread.sleep(20);
|
||||||
|
Thread.sleep(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) throws Exception {
|
||||||
|
try {
|
||||||
|
run(args);
|
||||||
|
} catch (Throwable error) {
|
||||||
|
error.printStackTrace();
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
System.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void run(String[] args) throws Exception {
|
||||||
|
Rechner host = new Rechner();
|
||||||
|
Betriebssystem system = (Betriebssystem) host.getSystemSoftware();
|
||||||
|
system.installApp(TERMINAL, true);
|
||||||
|
system.starten();
|
||||||
|
GUIDesktopPanel desktop = new GUIDesktopPanel(system);
|
||||||
|
GUIApplicationTerminalWindow window = (GUIApplicationTerminalWindow) desktop.starteAnwendung(TERMINAL);
|
||||||
|
JTextField input = (JTextField) field(window, "inputField");
|
||||||
|
List<Object> steps = new ArrayList<>();
|
||||||
|
steps.add(shown(window, "open"));
|
||||||
|
// A line typed and entered ("type:<line>"), a key ("up", "down", "ctrl+c"), a stop and start ("restart").
|
||||||
|
String[] keys = { "enter:", "enter: ", "enter:pwd", "enter:mkdir dir", "enter:cd dir", "enter:pwd", "enter:nosuchcommand", "up", "up", "up", "down", "down", "down",
|
||||||
|
"enter:cls", "enter:ls", "restart", "enter:pwd", "up", "enter:exit" };
|
||||||
|
for (String key : keys) {
|
||||||
|
if (key.startsWith("enter:")) {
|
||||||
|
input.setText(key.substring("enter:".length()));
|
||||||
|
press(input, KeyEvent.VK_ENTER, 0);
|
||||||
|
settle(window);
|
||||||
|
} else if (key.equals("up")) {
|
||||||
|
press(input, KeyEvent.VK_UP, 0);
|
||||||
|
} else if (key.equals("down")) {
|
||||||
|
press(input, KeyEvent.VK_DOWN, 0);
|
||||||
|
} else if (key.equals("restart")) {
|
||||||
|
system.beenden();
|
||||||
|
system.starten();
|
||||||
|
Thread.sleep(300);
|
||||||
|
}
|
||||||
|
steps.add(shown(window, key));
|
||||||
|
}
|
||||||
|
Map<String, Object> output = new LinkedHashMap<>();
|
||||||
|
output.put("steps", steps);
|
||||||
|
try (FileOutputStream out = new FileOutputStream(args[0])) {
|
||||||
|
out.write((FlsOracle.Json.write(output, "") + "\n").getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
System.out.println("terminal window steps: " + steps.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user