// The file programs (apps/filePrograms.ts) against Java FILIUS' own: // tools/java-oracle's FileCases did what the file explorer's and the text // editor's windows do to a host's files, with the steps of // file-programs.cases.txt, from TerminalCases' files // (file-programs..java.json). After each step: what it gave back 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 { deleteEntry, FILE_EXPLORER_TEXTS, folderEntries, imageFile, importFile, javaBase64, javaBase64Decode, javaFileType, makeFolder, pasteEntry, renameEntry, saveText, saveTextAs, openText, openImage, VirtualFileSystem, type Clipboard, type PageLanguage, } from '../src/index.js'; const here = dirname(fileURLToPath(import.meta.url)); /** A file as FileCases writes it: path, and for a file its type, content and size. */ type JavaNode = [string] | [string, string | null, string, number]; interface JavaStep { step: string[]; returned: string | null; files: JavaNode[]; } interface JavaCase { name: string; start: JavaNode[]; steps: JavaStep[]; } interface JavaRun { cases: JavaCase[]; texts: Record; } /** Java's file types as the engine's kinds: an image, its bytes (unknown or sound), or text. */ const kindOf = (type: string | null) => (type === 'png' || type === 'image' ? 'image' : type === null || type === 'sound' ? 'binary' : 'text'); /** Java's files with the engine's kinds, a text file's size its characters (Datei.holeGroesse decodes Base64). */ const asKinds = (nodes: JavaNode[]): JavaNode[] => nodes.map((node): JavaNode => (node.length === 1 ? node : [node[0], kindOf(node[1]), node[2], kindOf(node[1]) === 'text' ? node[2].length : node[3]])); function startFiles(start: JavaNode[]): VirtualFileSystem { const files = new VirtualFileSystem(); for (const node of start) { if (node.length === 1) files.createDirectory(node[0]); else if (kindOf(node[1]) === 'image') files.writeImageFile(node[0], javaBase64Decode(node[2])!, 'image/png'); else files.writeTextFile(node[0], node[2]); } return files; } /** The engine's files as FileCases writes Java's: preorder, each folder's entries in their order. */ function engineFiles(files: VirtualFileSystem, folder = '/'): JavaNode[] { return files.entriesInCreationOrder(folder).flatMap((entry): JavaNode[] => { const content = entry.content; if (content.kind === 'directory') return [[entry.path], ...engineFiles(files, entry.path)]; if (content.kind === 'text') return [[entry.path, 'text', content.text, content.text.length]]; return [[entry.path, content.kind, javaBase64(content.data), content.data.length]]; }); } /** A local file's bytes as FileCases makes them. */ function localBytes(spec: string): Uint8Array { if (spec === '@image') return javaBase64Decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==')!; if (spec === '@binary') return Uint8Array.from({ length: 256 }, (_, index) => index); if (spec.startsWith('@bytes:')) return new Uint8Array(Number(spec.slice('@bytes:'.length))).fill(0x61); return new TextEncoder().encode(spec); } /** Plays a case's steps on the engine's files, returning what each gave back as FileCases writes Java's. */ function play(javaCase: JavaCase, language: PageLanguage): JavaStep[] { const files = startFiles(javaCase.start); const texts = FILE_EXPLORER_TEXTS[language]; let clipboard: Clipboard | undefined; const child = (folder: string, name: string) => `${folder === '/' ? '' : folder}/${name}`; return javaCase.steps.map(({ step }) => { const [operation, folder, name = '', third = '', fourth = ''] = step as [string, string, string?, string?, string?]; let returned: string | null = null; switch (operation) { case 'mkdir': returned = name === '' ? null : String(makeFolder(files, folder, name)); break; case 'delete': returned = deleteEntry(files, folder, name) ? null : 'false'; break; case 'copy': case 'cut': clipboard = { path: child(folder, name), cut: operation === 'cut' }; break; case 'paste': { // The explorer offers paste only with something to paste. if (clipboard === undefined) break; const result = pasteEntry(files, clipboard, folder); if (result === 'invalidTarget') returned = 'fileexplorer_msg19'; else if (result !== 'pasted') returned = result; if (result === 'pasted' && clipboard.cut) clipboard = undefined; break; } case 'rename': renameEntry(files, folder, name, third); break; case 'import': { const result = importFile(files, folder, name, third, localBytes(fourth)); returned = result === 'imported' ? texts.imported : result === 'tooLarge' ? `${texts.notImported}\n${texts.tooLarge}` : texts.notImported; break; } case 'save': saveText(files, folder, name); break; case 'saveas': { const result = saveTextAs(files, folder, name, third); returned = result === 'saved' ? 'true' : result === 'folderMissing' ? 'false' : result; break; } default: throw new Error(`unknown step ${operation}`); } return { step, returned, files: engineFiles(files) }; }); } type Fix = (steps: JavaStep[], texts: Record) => JavaStep[]; /** The steps from `index` on with their files changed. */ const filesFrom = (steps: JavaStep[], index: number, change: (files: JavaNode[]) => JavaNode[]) => steps.map((step, at) => (at >= index ? { ...step, files: change(step.files) } : step)); const returnedAt = (steps: JavaStep[], index: number, returned: string | null) => steps.map((step, at) => (at === index ? { ...step, returned } : step)); /** A folder's file list without a second entry of the same path (Java pasted one). */ const withoutRepeats = (files: JavaNode[]) => files.filter((node, index) => files.findIndex((each) => each[0] === node[0]) === index); /** Where Java errs, the case as Java means it (the engine's departures, see filePrograms.ts). */ const JAVA_BUGS: Record = { // ".." at the root comes to no name: Java makes a folder without one. folders: (steps) => filesFrom(returnedAt(steps, 7, 'false'), 7, (files) => files.filter((node) => node[0] !== '/')), // An entry that is not there: Java throws (the window offers only those there). delete: (steps) => returnedAt(steps, 2, 'false'), // A second paste of the same name: Java adds a second entry; a paste with nothing cut: the window offers none. paste: (steps) => filesFrom(returnedAt(returnedAt(steps, 2, 'nameTaken'), 5, null), 2, withoutRepeats), // A rename that changes only the case is done (Java refuses); one to a name with "/", or with blanks at its ends // (the file system's rule), is refused (Java makes it). rename: (steps) => filesFrom(steps, 4, (files) => files.map((node): JavaNode => { if (node[0] === '/notiz.txt' || node[0] === '/a/b') return [`/NOTIZ.txt`, ...node.slice(1)] as JavaNode; return node[0] === '/ rand ' ? (['/kurz.txt', ...node.slice(1)] as JavaNode) : node; }), ), // An import onto a folder's name: Java throws. import: (steps, texts) => returnedAt(steps, 10, texts.sw_fileexplorer_msg1!), // "Save as" onto a folder's name: Java throws. editor: (steps) => returnedAt(steps, 4, 'isFolder'), }; function javaRun(language: PageLanguage): JavaRun { return JSON.parse(readFileSync(join(here, `file-programs.${language}.java.json`), 'utf8')) as JavaRun; } describe('the file programs as Java FILIUS does it', () => { for (const language of ['de', 'en'] as const) { const run = javaRun(language); it(`has Java's messages (${language})`, () => { const texts = FILE_EXPLORER_TEXTS[language]; expect([texts.notImported, texts.imported, texts.tooLarge, texts.invalidTarget]).toEqual( ['sw_fileexplorer_msg1', 'sw_fileexplorer_msg2', 'sw_fileexplorer_msg3', 'fileexplorer_msg19'].map((key) => run.texts[key]), ); }); it(`corrects Java only where it errs (${language})`, () => { for (const [name, fix] of Object.entries(JAVA_BUGS)) { const steps = run.cases.find((each) => each.name === name)!.steps; expect(JSON.stringify(fix(steps, run.texts)), name).not.toBe(JSON.stringify(steps)); } }); for (const javaCase of run.cases) { it(`${javaCase.name} (${language})`, () => { const measured = javaCase.steps.map((step) => ({ ...step, files: asKinds(step.files) })); const expected = JAVA_BUGS[javaCase.name]?.(measured, run.texts) ?? measured; const actual = play({ ...javaCase, start: javaCase.start }, language); for (const [index, step] of expected.entries()) { expect(actual[index], `step ${index}: ${JSON.stringify(step.step)}`).toEqual(step); } }); } } }); describe('the file programs beyond the cases', () => { it("knows FILIUS' file types by the last extension, in any case", () => { expect(['a.TXT', 'b.tar.png', 'Song.mp3', 'ohne', '.versteckt', 'x.bin'].map(javaFileType)).toEqual(['text', 'image', 'sound', 'text', 'text', undefined]); }); it('lists a folder in the order its entries were made, and nothing for a file', () => { const files = new VirtualFileSystem(); files.writeTextFile('/b.txt', 'b'); files.createDirectory('/a'); expect(folderEntries(files, '/')).toEqual([ { kind: 'file', name: 'b.txt', path: '/b.txt' }, { kind: 'folder', name: 'a', path: '/a' }, ]); expect(folderEntries(files, '/b.txt')).toEqual([]); }); it('makes no folder of an empty name (the explorer asks for none)', () => { const files = new VirtualFileSystem(); files.createDirectory('/Dokumente'); expect(makeFolder(files, '/Dokumente', '')).toBe(false); expect(files.allEntries()).toHaveLength(2); }); it('shows an image file, and nothing for any other', () => { const files = new VirtualFileSystem(); files.writeImageFile('/bild.png', new Uint8Array([1, 2]), 'image/png'); files.writeTextFile('/text.txt', 'x'); files.writeBinaryFile('/daten.bin', new Uint8Array([3])); expect(imageFile(files, '/daten.bin')).toBeUndefined(); expect(imageFile(files, '/BILD.png')).toEqual({ data: new Uint8Array([1, 2]), mediaType: 'image/png' }); expect(imageFile(files, '/text.txt')).toBeUndefined(); expect(imageFile(files, '/fehlt.png')).toBeUndefined(); }); // Measured with TextEditorWindowRun (packages/sim/test/textEditorWindow.java.json). it("opens a text file by the dialog's folder and name, in any case, and neither an image nor what is not there", () => { const files = new VirtualFileSystem(); files.writeTextFile('/notiz.txt', 'Hallo'); files.createDirectory('/ordner'); files.writeTextFile('/ordner/b.txt', ''); files.writeImageFile('/bild.png', new Uint8Array([1]), 'image/png'); expect(openText(files, '/', 'NOTIZ.TXT')).toEqual({ path: '/notiz.txt', text: 'Hallo' }); expect(openText(files, '/', 'ordner/b.txt')).toEqual({ path: '/ordner/b.txt', text: '' }); expect(openText(files, '/ordner', '../notiz.txt')).toEqual({ path: '/notiz.txt', text: 'Hallo' }); expect(openText(files, '/ordner', '/notiz.txt')).toEqual({ path: '/notiz.txt', text: 'Hallo' }); expect(['fehlt.txt', '', 'ordner'].map((name) => openText(files, '/', name))).toEqual(['notFound', 'notFound', 'notFound']); expect(openText(files, '/', 'bild.png')).toBe('notText'); }); it('saves as a name leading into a folder, and not as an empty name, one with blanks at its ends or one whose folder is missing', () => { const files = new VirtualFileSystem(); files.createDirectory('/ordner'); expect(saveTextAs(files, '/', 'ordner/d.txt', 'd')).toBe('saved'); expect(files.textFile('/ordner/d.txt')).toBe('d'); expect(['', ' x.txt', 'x.txt ', 'ordner/ e.txt', '..'].map((name) => saveTextAs(files, '/ordner', name, 'x'))).toEqual(['invalidName', 'invalidName', 'invalidName', 'invalidName', 'invalidName']); expect(saveTextAs(files, '/', 'fehlt/e.txt', 'e')).toBe('folderMissing'); expect(saveTextAs(files, '/', 'ORDNER', 'e')).toBe('isFolder'); expect(files.allEntries().map((entry) => entry.path)).toEqual(['/', '/ordner', '/ordner/d.txt']); }); // Measured with FileExplorerWindowRun (packages/sim/test/fileExplorerWindow.java.json). it("opens an image by the dialog's folder and name, in any case, and neither another file nor what is not there", () => { const files = new VirtualFileSystem(); files.createDirectory('/webserver'); files.writeImageFile('/webserver/Bild.png', new Uint8Array([1, 2]), 'image/png'); files.writeTextFile('/text.txt', 'x'); expect(openImage(files, '/webserver', 'bild.PNG')).toEqual({ path: '/webserver/Bild.png', data: new Uint8Array([1, 2]), mediaType: 'image/png' }); expect(openImage(files, '/', 'webserver/Bild.png')).toEqual(openImage(files, '/text', '/webserver/Bild.png')); expect(['fehlt.png', '', 'webserver'].map((name) => openImage(files, '/', name))).toEqual(['notFound', 'notFound', 'notFound']); expect(openImage(files, '/', 'text.txt')).toBe('notImage'); files.writeBinaryFile('/daten.bin', new Uint8Array([3])); expect(openImage(files, '/', 'daten.bin')).toBe('notImage'); }); });