// 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..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 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 = { // 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(`has Java's texts (${language})`, () => { const javaTexts = (JSON.parse(readFileSync(join(here, `terminal.${language}.java.json`), 'utf8')) as { texts: Record }).texts; for (const [key, value] of Object.entries(TERMINAL_TEXTS[language])) expect(value, key).toBe(javaTexts[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'); }); });