feat(core): the command line with its local commands after Java, measured against Java

The terminal splits a line as Java's window does and runs ls, cd, pwd,
mkdir, touch, cp, mv, rm, cat, test, help, ipconfig and route on the
node's files, which are the project's own. Its texts are generated from
the bundle Java loads. TerminalCases feeds Java's Terminal without a
network (16 cases, de and en); eight cases name Java bugs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 20:32:35 +00:00
co-authored by Claude Opus 5.5
parent 058675a148
commit 016f9461df
17 changed files with 14404 additions and 7 deletions
+140
View File
@@ -0,0 +1,140 @@
# Cases for tools/java-oracle's TerminalCases: FILIUS' command line fed with
# lines as typed, one per line. Read by terminal.test.ts.
case listing
ls
dir
ls Dokumente
ls /Dokumente/Alt
ls gibtsnicht
ls ..
dir /
ls a b
case navigate
pwd
cd Dokumente
pwd
cd Alt
cd ..
cd ../..
cd /Dokumente/Alt
cd /
cd gibtsnicht
cd DOKUMENTE
pwd
cd
cd /
cd notizen.txt
pwd
ls
case files
touch neu.txt
touch neu.txt
touch NEU.TXT
touch Dokumente/neu2.txt
touch /gibtsnicht/x.txt
touch ..
mkdir Neu
mkdir Neu
mkdir neu
mkdir Dokumente/Alt/Tief
mkdir /gibtsnicht/x
mkdir ..
ls
case copy
cp notizen.txt kopie.txt
cp gibtsnicht.txt x.txt
cp notizen.txt NOTIZEN.TXT
copy bild.png Dokumente/bild2.png
cp kurz.txt /leer/kurz.txt
cp notizen.txt
cp notizen.txt Dokumente
ls
case move
mv notizen.txt umbenannt.txt
mv kurz.txt Dokumente/kurz.txt
mv gibtsnicht.txt y.txt
move bild.png /leer/bild.png
mv Dokumente Ordner
ls
case rename
mv notizen.txt NOTIZEN.TXT
ls
mv Dokumente/brief.txt /brief.txt
ls
case delete
rm notizen.txt
rm notizen.txt
del Dokumente
rm /leer
cd leer
rm ../bild.png
ls
case show
cat notizen.txt
type Dokumente/brief.txt
cat gibtsnicht
cat
cat Dokumente
cat bild.png
case info
ipconfig
ipconfig x
route
route x
test
ls
case help
help
help ls
help gibtsnicht
help exit
case window
cls
reset
exit
unbekannt
LS
move
setInterrupt x
initUsageInfo
case spaces
ls Dokumente
cd Dokumente/
pwd
cd ./Alt/../Alt
pwd
case deleteInside
cd Dokumente/Alt
rm /Dokumente
pwd
ls
cd ..
pwd
rm /
ls
case sameName
mv notizen.txt notizen.txt
cp kurz.txt kurz.txt
ls
case usage
pwd x
rm
rm a b
cd a b
touch a b
mkdir a b
cp a b c
mv a b c
help type
help rm
help cp
help mv
\u0009
case paths
ls ./Dokumente
ls notizen.txt
cd Dokumente
test
ls
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+259
View File
@@ -0,0 +1,259 @@
// The command line (apps/terminal.ts) against Java FILIUS' own:
// tools/java-oracle's TerminalCases fed Java's Terminal, as its window does,
// with the lines of terminal.cases.txt on host 10.0.0.2 of router-web
// (terminal.<de|en>.java.json). After each line: what the window does
// itself, what the program told it, the working directory, and every file
// must be Java's, in both languages, except where Java errs.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import {
entryName,
javaBase64,
javaBase64Decode,
TERMINAL_TEXTS,
VirtualFileSystem,
withSnapshot,
type NetworkRuntimeEngine,
type PageLanguage,
type TerminalTextKey,
type VirtualFileContent,
} from '../src/index.js';
import { watchedEngine } from './javaRun.js';
import { snapshotOf, type Scenario } from './scenario.js';
const here = dirname(fileURLToPath(import.meta.url));
const HOST = '00000000-0000-0000-0000-000000000301';
/** A file as TerminalCases writes it: path, and for a file its type, content and size. */
type JavaNode = [string] | [string, string, string, number];
type Told = ['text', string] | ['busy', boolean];
interface JavaStep {
typed: string;
window: 'blank' | 'exit' | 'clear' | null;
told: Told[];
directory: string;
files: JavaNode[];
}
interface JavaCase {
name: string;
start: JavaNode[];
steps: JavaStep[];
}
/** TerminalCases.files: the host's files at the start, in their order. */
function startFiles(start: JavaNode[]): VirtualFileSystem {
const files = new VirtualFileSystem();
for (const node of start) {
if (node.length === 1) files.createDirectory(node[0]);
else if (node[1] === 'png') files.writeImageFile(node[0], javaBase64Decode(node[2])!, 'image/png');
else files.writeTextFile(node[0], node[2]);
}
return files;
}
/** The engine's files as TerminalCases writes Java's: preorder, each directory's entries in their order. */
function engineFiles(files: VirtualFileSystem, directory = '/'): JavaNode[] {
return files.entriesInCreationOrder(directory).flatMap((entry): JavaNode[] => {
const content: VirtualFileContent = entry.content;
if (content.kind === 'directory') return [[entry.path], ...engineFiles(files, entry.path)];
const text = content.kind === 'text' ? content.text : javaBase64(content.data);
return [[entry.path, content.kind === 'text' ? 'text' : 'binary', text, content.kind === 'text' ? content.text.length : content.data.length]];
});
}
/** Java's file types as the engine's kinds: an image FILIUS keeps in Base64, every other file as text. */
const kindOf = (type: string) => (type === 'png' ? 'binary' : 'text');
/**
* Java's sizes of text files: Datei.holeGroesse decodes the text as Base64
* and counts the bytes ("Hallo Welt" is 6). As Java means it: the
* characters. Applied to every step, in the file list and in each listing.
*/
function withTextSizes(step: JavaStep): JavaStep {
const files = step.files.map((node): JavaNode => (node.length === 1 ? node : [node[0], kindOf(node[1]), node[2], kindOf(node[1]) === 'text' ? node[2].length : node[3]]));
const sizes = new Map(files.flatMap((node) => (node.length === 4 && node[1] === 'text' ? [[entryName(node[0]), node[3]] as const] : [])));
const told = step.told.map(([kind, value]): Told => {
if (kind !== 'text') return [kind, value];
return ['text', value.replace(/^(?!\[)(.+?)(\.+)(\d+)$/gm, (line, name: string, dots: string, size: string) => (sizes.has(name) ? `${name}${dots}${sizes.get(name)}` : line))];
});
return { ...step, files, told };
}
type Fix = (steps: JavaStep[], text: (key: TerminalTextKey) => string) => JavaStep[];
const said = (step: JavaStep, ...texts: string[]): JavaStep => ({ ...step, told: texts.map((each): Told => ['text', each]) });
const at = (steps: JavaStep[], changes: Record<number, (step: JavaStep) => JavaStep>) => steps.map((step, index) => changes[index]?.(step) ?? step);
/**
* Where Java errs, the case as Java means it (the engine's departures, see
* terminal.ts).
*/
const JAVA_BUGS: Record<string, Fix> = {
// A folder that is not there: Java says "empty"; ls .. at the root lists
// nothing (".." goes above the root); ls / names no path.
listing: (steps, text) =>
at(steps, {
4: (step) => said(step, text('sw_terminal_msg20')),
5: (step) => ({ ...step, told: steps[0]!.told }),
6: (step) => said(step, (step.told[0]![1] as string).replace(/^(\S+) :/, '$1 /:')),
}),
// cd to a file makes it the working directory.
navigate: (steps, text) =>
at(steps, {
13: (step) => ({ ...said(step, text('sw_terminal_msg20')), directory: '/' }),
14: (step) => ({ ...said(step, '/'), directory: '/' }),
15: (step) => ({ ...step, directory: '/', told: listingOfStart(text) }),
}),
// touch and mkdir in a folder that is not there: "made" and "there already".
files: (steps, text) =>
at(steps, {
4: (step) => said(step, text('sw_terminal_msg12')),
10: (step) => said(step, text('sw_terminal_msg16')),
}),
// cp onto a folder's name throws (ClassCastException).
copy: (steps, text) => at(steps, { 6: (step) => said(step, text('sw_terminal_msg34')) }),
// mv onto the file in another case copies it onto itself, then removes it.
rename: (steps) => {
const renamed: JavaNode = ['/NOTIZEN.TXT', 'text', 'Hallo Welt', 10];
return at(steps, {
0: (step) => ({ ...step, files: [...step.files, renamed] }),
1: (step) => ({ ...step, files: [...step.files, renamed], told: [['text', addedFile(step.told, 'NOTIZEN.TXT', 10)]] }),
2: (step) => ({ ...step, files: [...step.files.filter((node) => node[0] !== '/brief.txt'), renamed, step.files.find((node) => node[0] === '/brief.txt')!] }),
3: (step) => ({
...step,
files: [...step.files.filter((node) => node[0] !== '/brief.txt'), renamed, step.files.find((node) => node[0] === '/brief.txt')!],
told: [['text', addedFile(step.told, 'NOTIZEN.TXT', 10, 'brief.txt')]],
}),
});
},
// ".." above the root: Java's path evaluation throws (rm ../bild.png from the root).
delete: (steps, text) =>
at(steps, {
5: (step) => ({ ...said(step, text('sw_terminal_msg37')), files: step.files.filter((node) => node[0] !== '/bild.png') }),
6: (step) => ({
...step,
files: step.files.filter((node) => node[0] !== '/bild.png'),
told: [['text', (step.told[0]![1] as string).replace(/\nbild\.png\.+\d+/, '').replace(/(:)2$/, '$11')]],
}),
}),
// rm /Dokumente from inside: Java puts the working directory in front of
// the path and finds nothing; rm / then removes the working directory.
deleteInside: (steps, text) => {
const withoutDocuments = (step: JavaStep) => step.files.filter((node) => !node[0].startsWith('/Dokumente'));
const start = steps[0]!.files;
const remaining = start.filter((node) => !node[0].startsWith('/Dokumente'));
const rootListing = listing(text, '/', remaining);
return at(steps, {
1: (step) => ({ ...said(step, text('sw_terminal_msg37')), directory: '/', files: withoutDocuments(step) }),
2: (step) => ({ ...said(step, '/'), directory: '/', files: remaining }),
3: (step) => ({ ...said(step, rootListing), directory: '/', files: remaining }),
4: (step) => ({ ...said(step, ''), directory: '/', files: remaining }),
5: (step) => ({ ...said(step, '/'), directory: '/', files: remaining }),
6: (step) => ({ ...said(step, text('sw_terminal_msg38')), directory: '/', files: remaining }),
7: (step) => ({ ...said(step, rootListing), directory: '/', files: remaining }),
});
},
// ls of a file: Java lists its children (none: "empty").
paths: (steps, text) => at(steps, { 1: (step) => said(step, text('sw_terminal_msg20')) }),
// mv onto the file itself: Java copies it onto itself, then removes it.
sameName: (steps) => {
const notes: JavaNode = ['/notizen.txt', 'text', 'Hallo Welt', 10];
return at(steps, {
0: (step) => ({ ...step, files: [...step.files, notes] }),
1: (step) => ({ ...step, files: [...step.files, notes] }),
2: (step) => ({ ...step, files: [...step.files, notes], told: [['text', addedFile(step.told, 'notizen.txt', 10)]] }),
});
},
};
/** A listing's text with a file added at its end (after `before`'s line when given), one file more counted. */
function addedFile(told: Told[], name: string, size: number, before?: string): string {
const text = told[0]![1] as string;
const line = `\n${name}${'.'.repeat(40 - name.length)}${size}`;
const withLine = before === undefined ? text.replace(/\n\n/, `${line}\n\n`) : text.replace(new RegExp(`\\n${before.replace('.', '\\.')}`), `${line}\n${before}`);
return withLine.replace(/(\d+)$/, (count) => String(Number(count) + 1));
}
/** Terminal.dir's listing of a directory as Java writes it, from TerminalCases' file list. */
function listing(text: (key: TerminalTextKey) => string, directory: string, files: JavaNode[]): string {
const children = files.filter((node) => node[0] !== directory && node[0].slice(0, node[0].lastIndexOf('/') || 1) === directory);
const lines = children.map((node) => (node.length === 1 ? `[${entryName(node[0])}]` : `${entryName(node[0])}${'.'.repeat(Math.max(0, 40 - entryName(node[0]).length))}${node[3]}`));
const directories = children.filter((node) => node.length === 1).length;
return `${text('sw_terminal_msg9')} ${directory}:\n${lines.join('\n')}\n${text('sw_terminal_msg10')}${directories}${text('sw_terminal_msg11')}${children.length - directories}`;
}
let startNodes: JavaNode[] = [];
/** The root's listing of the start files (with the sizes as Java means them). */
const listingOfStart = (text: (key: TerminalTextKey) => string): Told[] => [['text', listing(text, '/', startNodes)]];
function javaCases(language: PageLanguage): JavaCase[] {
return (JSON.parse(readFileSync(join(here, `terminal.${language}.java.json`), 'utf8')) as { cases: JavaCase[] }).cases;
}
function play(javaCase: JavaCase, language: PageLanguage): JavaStep[] {
const scenario = JSON.parse(readFileSync(join(here, 'scenarios', 'router-web.scenario.json'), 'utf8')) as Scenario;
const files = startFiles(javaCase.start);
const snapshot = withSnapshot(snapshotOf(scenario), { language, terminalNodeIds: new Set([HOST]), fileSystemsByNodeId: new Map([[HOST, files]]) });
const { engine } = watchedEngine(scenario, snapshot);
const terminal = (each: NetworkRuntimeEngine) => each.terminal(HOST)!;
let seen = 0;
return javaCase.steps.map((step) => {
const window = terminal(engine).enter(step.typed) ?? null;
const told = terminal(engine)
.log.all.slice(seen)
.map((line): Told => (line.kind === 'text' ? ['text', line.text] : ['busy', line.busy]));
seen = terminal(engine).log.all.length;
return { typed: step.typed, window, told, directory: terminal(engine).directory, files: engineFiles(files) };
});
}
describe('the command line as Java FILIUS answers it', () => {
for (const language of ['de', 'en'] as const) {
const cases = javaCases(language);
const text = (key: TerminalTextKey) => TERMINAL_TEXTS[language][key];
it(`corrects Java only where it errs (${language})`, () => {
for (const [name, fix] of Object.entries(JAVA_BUGS)) {
const javaCase = cases.find((each) => each.name === name)!;
startNodes = withTextSizes({ ...javaCase.steps[0]!, files: javaCase.start }).files;
const steps = javaCase.steps.map(withTextSizes);
expect(JSON.stringify(fix(steps, text)), name).not.toBe(JSON.stringify(steps));
}
});
for (const javaCase of cases) {
it(`${javaCase.name} (${language})`, () => {
startNodes = withTextSizes({ ...javaCase.steps[0]!, files: javaCase.start }).files;
const steps = javaCase.steps.map(withTextSizes);
const expected = JAVA_BUGS[javaCase.name]?.(steps, text) ?? steps;
const actual = play(javaCase, language);
for (const [index, step] of expected.entries()) {
const { typed, window, told, directory, files } = step;
expect(actual[index], `step ${index}: ${JSON.stringify(typed)}`).toEqual({ typed, window, told, directory, files });
}
});
}
}
});
describe('the command line beyond the cases', () => {
it('copies an image as an image', () => {
const scenario = JSON.parse(readFileSync(join(here, 'scenarios', 'router-web.scenario.json'), 'utf8')) as Scenario;
const files = new VirtualFileSystem();
files.writeImageFile('/bild.png', new Uint8Array([1, 2, 3]), 'image/png');
const { engine } = watchedEngine(scenario, withSnapshot(snapshotOf(scenario), { terminalNodeIds: new Set([HOST]), fileSystemsByNodeId: new Map([[HOST, files]]) }));
engine.terminal(HOST)!.enter('cp bild.png kopie.png');
expect(files.entry('/kopie.png').content).toEqual({ kind: 'image', data: new Uint8Array([1, 2, 3]), mediaType: 'image/png' });
});
it('lists in the order of making only a folder', () => {
const files = new VirtualFileSystem();
files.writeTextFile('/a.txt', 'a');
expect(() => files.entriesInCreationOrder('/a.txt')).toThrow('expectedDirectory');
});
});