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:
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user