Files
mdg-filius/packages/core/test/filePrograms.test.ts
T
julian.kiedaischandClaude Opus 5.5 9d42b90157 feat(core): the file explorer, text editor and image viewer's file operations after Java, measured against Java
FileCases does what the windows of FILIUS' file explorer and text editor
do to a host's files: new folder, delete, copy, cut and paste, rename,
import, save and save as. The core's filePrograms.ts does the same on the
node's file system; the file system renames an entry in its place, as
Java's tree does.

Departures from Java: a folder name that comes to no name makes nothing,
a paste onto a name there already is refused, a rename to a name with a
slash or blanks at its ends is refused and one of the case only is done,
save as and import onto a folder's name are refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:59:11 +00:00

242 lines
10 KiB
TypeScript

// 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.<de|en>.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,
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<string, string>;
}
/** 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<string, string>) => 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<string, Fix> = {
// ".." 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();
});
});