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>
This commit is contained in:
@@ -26,6 +26,7 @@ export * from './runtime/apps/routerPages.js';
|
||||
export * from './runtime/apps/firewallSettings.js';
|
||||
export * from './runtime/apps/emailServer.js';
|
||||
export * from './runtime/apps/emailClient.js';
|
||||
export * from './runtime/apps/filePrograms.js';
|
||||
export * from './runtime/apps/terminal.js';
|
||||
export * from './runtime/apps/terminalTexts.js';
|
||||
export * from './runtime/email.js';
|
||||
|
||||
@@ -343,6 +343,32 @@ export class VirtualFileSystem {
|
||||
return this.existingPath(destination);
|
||||
}
|
||||
|
||||
/**
|
||||
* Java's rename (Datei.setName, DefaultMutableTreeNode.setUserObject): the
|
||||
* entry keeps its place among its folder's entries, also when only its
|
||||
* case changes; another entry of the folder may not have the name yet.
|
||||
* Returns the new path.
|
||||
*/
|
||||
renameInPlace(rawPath: string, newName: string): string {
|
||||
if (newName.includes('/') || newName.includes('\\')) fail('invalidPathComponent', newName);
|
||||
const sourcePath = this.existingPath(rawPath);
|
||||
if (sourcePath === '/') fail('cannotMutateRoot');
|
||||
const destination = normalizedAbsolutePath(join(parentPath(sourcePath), newName));
|
||||
if (entryName(destination) !== newName) fail('invalidPathComponent', newName);
|
||||
const taken = this.existingPathIfPresent(destination);
|
||||
if (taken !== undefined && !swiftEqual(taken, sourcePath)) fail('itemAlreadyExists', destination);
|
||||
this.transaction((candidate) => {
|
||||
const store: Store = new Map();
|
||||
for (const [key, entry] of candidate.store) {
|
||||
const inside = entry.path === sourcePath || entry.path.startsWith(`${sourcePath}/`);
|
||||
const path = inside ? destination + entry.path.slice(sourcePath.length) : entry.path;
|
||||
store.set(inside ? keyOf(path) : key, { path, content: entry.content });
|
||||
}
|
||||
candidate.store = store;
|
||||
});
|
||||
return destination;
|
||||
}
|
||||
|
||||
deleteItem(rawPath: string, recursive = false): void {
|
||||
const path = this.existingPath(rawPath);
|
||||
if (path === '/') fail('cannotMutateRoot');
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
// What FILIUS' file explorer, text editor and image viewer do to a host's
|
||||
// files (filius.software.lokal.FileExplorer, TextEditor, ImageViewer; most of
|
||||
// it in their windows, GUIApplicationFileExplorerWindow,
|
||||
// GUIApplicationTextEditorWindow, GUIApplicationImageViewerWindow): the
|
||||
// operations behind their buttons, on the node's file system, for the
|
||||
// editor's windows to call. The files are the project's own, so what they
|
||||
// change is in a project saved afterwards.
|
||||
//
|
||||
// Departures from Java (filePrograms.test.ts names each):
|
||||
// - a new folder's name that comes to no name (".." at the root) makes no
|
||||
// folder (Java makes one without a name);
|
||||
// - a paste where the folder has the name already is refused (Java adds a
|
||||
// second entry of the same name);
|
||||
// - a rename to a name with "/" is refused (Java makes a file whose name
|
||||
// holds the separator), as one to a name with blanks at its ends (the
|
||||
// file system's rule), and one that changes only the case is done (Java
|
||||
// finds the entry itself and refuses);
|
||||
// - "save as" onto a folder's name, and an import onto it, is refused (Java
|
||||
// throws);
|
||||
// - a text file's size is its length in characters (as the command line's).
|
||||
|
||||
import type { VirtualFileContent, VirtualFileSystem } from '../../model/virtualFileSystem.js';
|
||||
import { entryName, parentPath } from '../../model/virtualFileSystem.js';
|
||||
import type { PageLanguage } from './routerPages.js';
|
||||
import { evaluatedPath } from './terminal.js';
|
||||
|
||||
/** FILIUS' config/filetypes.txt: a file's type by its extension. */
|
||||
const FILE_TYPES: Readonly<Record<string, 'text' | 'sound' | 'image'>> = {
|
||||
txt: 'text',
|
||||
cfg: 'text',
|
||||
bat: 'text',
|
||||
sh: 'text',
|
||||
html: 'text',
|
||||
htm: 'text',
|
||||
ini: 'text',
|
||||
php: 'text',
|
||||
xml: 'text',
|
||||
mp3: 'sound',
|
||||
mp2: 'sound',
|
||||
wav: 'sound',
|
||||
jpeg: 'image',
|
||||
jpg: 'image',
|
||||
bmp: 'image',
|
||||
tiff: 'image',
|
||||
png: 'image',
|
||||
};
|
||||
|
||||
/** An image's media type by its extension. */
|
||||
const IMAGE_MEDIA_TYPES: Readonly<Record<string, string>> = { jpeg: 'image/jpeg', jpg: 'image/jpeg', bmp: 'image/bmp', tiff: 'image/tiff', png: 'image/png' };
|
||||
|
||||
/** FileExplorer.getFileType: the type of the last extension; a name without one is text, an unknown one has none. */
|
||||
export function javaFileType(name: string): 'text' | 'sound' | 'image' | undefined {
|
||||
const parts = name
|
||||
.toLowerCase()
|
||||
.split('.')
|
||||
.filter((part) => part !== '');
|
||||
if (parts.length < 2) return 'text';
|
||||
return Object.hasOwn(FILE_TYPES, parts.at(-1)!) ? FILE_TYPES[parts.at(-1)!] : undefined;
|
||||
}
|
||||
|
||||
/** FileExplorer.addFile: the largest file it imports, in bytes. */
|
||||
export const IMPORT_MAXIMUM_BYTES = 150_000;
|
||||
|
||||
/** The explorer's messages (the import's, the paste's refusal), as Java's message bundle gives them. */
|
||||
export const FILE_EXPLORER_TEXTS: Readonly<Record<PageLanguage, Readonly<Record<'imported' | 'notImported' | 'tooLarge' | 'invalidTarget', string>>>> = {
|
||||
de: {
|
||||
imported: 'Datei erfolgreich importiert!',
|
||||
notImported: 'Datei konnte nicht importiert werden',
|
||||
tooLarge: 'Datei existiert nicht oder ist zu groß (> 150KB)',
|
||||
invalidTarget: 'Unzulässiger Zielordner!',
|
||||
},
|
||||
en: {
|
||||
imported: 'File successfully imported!',
|
||||
notImported: 'File could not be imported.',
|
||||
tooLarge: 'File does not exist or is too large (> 150KB)',
|
||||
invalidTarget: 'Invalid target folder!',
|
||||
},
|
||||
};
|
||||
|
||||
/** An entry of a folder as the explorer lists it (ordnerInhaltAnzeigen), in the order it was made. */
|
||||
export interface FolderEntry {
|
||||
readonly kind: 'file' | 'folder';
|
||||
readonly name: string;
|
||||
readonly path: string;
|
||||
}
|
||||
|
||||
/** What the explorer copied or cut, to paste. */
|
||||
export interface Clipboard {
|
||||
readonly path: string;
|
||||
readonly cut: boolean;
|
||||
}
|
||||
|
||||
function found(files: VirtualFileSystem, path: string): { readonly path: string; readonly content: VirtualFileContent } | undefined {
|
||||
try {
|
||||
return files.entry(path);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const childPath = (folder: string, name: string) => `${folder === '/' ? '' : folder}/${name}`;
|
||||
|
||||
/** The folder's entries, files and folders, in the order they were made; none when it is no folder. */
|
||||
export function folderEntries(files: VirtualFileSystem, folder: string): FolderEntry[] {
|
||||
if (found(files, folder)?.content.kind !== 'directory') return [];
|
||||
return files.entriesInCreationOrder(folder).map((entry) => ({ kind: entry.content.kind === 'directory' ? 'folder' : 'file', name: entryName(entry.path), path: entry.path }));
|
||||
}
|
||||
|
||||
/**
|
||||
* "New folder" (Dateisystem.erstelleVerzeichnis): the name taken as a path
|
||||
* from the folder ("a/b" makes b in a); nothing when the name is empty or
|
||||
* there already. False when its folder is not there, or the name comes to
|
||||
* no name.
|
||||
*/
|
||||
export function makeFolder(files: VirtualFileSystem, folder: string, name: string): boolean {
|
||||
if (name === '') return false;
|
||||
const path = evaluatedPath(name.startsWith('/') ? name : `${folder}/${name}`);
|
||||
if (path === '/') return false;
|
||||
if (found(files, parentPath(path))?.content.kind !== 'directory') return false;
|
||||
if (found(files, path) === undefined) files.createDirectory(path);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** "Delete": the entry of the folder, a folder with all in it; false when it is not there. */
|
||||
export function deleteEntry(files: VirtualFileSystem, folder: string, name: string): boolean {
|
||||
const entry = found(files, childPath(folder, name));
|
||||
if (entry === undefined || entry.path === '/') return false;
|
||||
files.deleteItem(entry.path, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Paste": a copy of the copied or cut entry, with all in it, added to the
|
||||
* folder (at the end of its entries); a cut one removed from where it was.
|
||||
* Not into the entry itself or below it (invalidTarget), not where the
|
||||
* folder has an entry of that name (nameTaken; a cut entry pasted into its
|
||||
* own folder moves to the end).
|
||||
*/
|
||||
export function pasteEntry(files: VirtualFileSystem, clipboard: Clipboard, folder: string): 'pasted' | 'invalidTarget' | 'nameTaken' | 'missing' {
|
||||
const source = found(files, clipboard.path);
|
||||
const target = found(files, folder);
|
||||
if (source === undefined || target?.content.kind !== 'directory') return 'missing';
|
||||
const lower = (path: string) => path.toLowerCase();
|
||||
if (lower(target.path) === lower(source.path) || lower(target.path).startsWith(`${lower(source.path)}/`)) return 'invalidTarget';
|
||||
const destination = childPath(target.path, entryName(source.path));
|
||||
const taken = found(files, destination);
|
||||
if (taken !== undefined && !(clipboard.cut && taken.path === source.path)) return 'nameTaken';
|
||||
// The entry and all below it in preorder, each folder's entries in their order (Java's deep copy keeps it).
|
||||
const below = (path: string): { path: string; content: VirtualFileContent }[] =>
|
||||
files.entriesInCreationOrder(path).flatMap((entry) => [entry, ...(entry.content.kind === 'directory' ? below(entry.path) : [])]);
|
||||
const subtree = [source, ...(source.content.kind === 'directory' ? below(source.path) : [])].map((entry) => ({
|
||||
path: destination + entry.path.slice(source.path.length),
|
||||
content: entry.content,
|
||||
}));
|
||||
if (clipboard.cut) files.deleteItem(source.path, true);
|
||||
for (const entry of subtree) write(files, entry.path, entry.content);
|
||||
return 'pasted';
|
||||
}
|
||||
|
||||
/**
|
||||
* "Rename": a name that is not blank and that no other entry of the folder
|
||||
* has yet; the entry keeps its place. False otherwise, and for a name with
|
||||
* "/".
|
||||
*/
|
||||
export function renameEntry(files: VirtualFileSystem, folder: string, name: string, newName: string): boolean {
|
||||
const entry = found(files, childPath(folder, name));
|
||||
if (entry === undefined || entry.path === '/') return false;
|
||||
// The file system refuses an empty name, one with "/", one around blanks and one another entry has.
|
||||
try {
|
||||
files.renameInPlace(entry.path, newName);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Writes the content at the path, a file over a file of that name. */
|
||||
function write(files: VirtualFileSystem, path: string, content: VirtualFileContent): void {
|
||||
if (content.kind === 'directory') files.createDirectory(path);
|
||||
else if (content.kind === 'text') files.writeTextFile(path, content.text);
|
||||
else if (content.kind === 'image') files.writeImageFile(path, content.data, content.mediaType);
|
||||
else if (content.mediaType === undefined) files.writeBinaryFile(path, content.data);
|
||||
else files.writeBinaryFile(path, content.data, content.mediaType);
|
||||
}
|
||||
|
||||
/** A file's path in the folder for a name: the entry of that name in any case, kept as it is, else a new one. */
|
||||
function fileTarget(files: VirtualFileSystem, folder: string, name: string): { readonly path: string; readonly isFolder: boolean } | undefined {
|
||||
if (found(files, folder)?.content.kind !== 'directory') return undefined;
|
||||
const existing = found(files, childPath(folder, name));
|
||||
return existing === undefined ? { path: childPath(found(files, folder)!.path, name), isFolder: false } : { path: existing.path, isFolder: existing.content.kind === 'directory' };
|
||||
}
|
||||
|
||||
/**
|
||||
* "Import" (FileExplorer.addFile): a local file into the folder, under the
|
||||
* new name ("noName" when empty), its type by the local file's name. A text
|
||||
* file line by line, each line ended with CR LF; an image as an image; any
|
||||
* other as its bytes. Over one of that name its content (and kind), the
|
||||
* name kept as it is.
|
||||
*/
|
||||
export function importFile(files: VirtualFileSystem, folder: string, newName: string, localName: string, bytes: Uint8Array): 'imported' | 'tooLarge' | 'notImported' {
|
||||
if (bytes.length > IMPORT_MAXIMUM_BYTES) return 'tooLarge';
|
||||
const target = fileTarget(files, folder, newName === '' ? 'noName' : newName);
|
||||
if (target === undefined || target.isFolder) return 'notImported';
|
||||
const type = javaFileType(localName);
|
||||
if (type === 'text') {
|
||||
// BufferedReader.readLine: a line ends at LF, CR or CR LF; the last one may end without.
|
||||
const lines = new TextDecoder().decode(bytes).split(/\r\n|\r|\n/);
|
||||
if (lines.at(-1) === '') lines.pop();
|
||||
files.writeTextFile(target.path, lines.map((line) => `${line}\r\n`).join(''));
|
||||
} else if (type === 'image') {
|
||||
files.writeImageFile(target.path, bytes, IMAGE_MEDIA_TYPES[localName.toLowerCase().split('.').at(-1)!]!);
|
||||
} else {
|
||||
files.writeBinaryFile(target.path, bytes);
|
||||
}
|
||||
return 'imported';
|
||||
}
|
||||
|
||||
/** The editor's "save": the open file's text. */
|
||||
export function saveText(files: VirtualFileSystem, path: string, text: string): void {
|
||||
files.writeTextFile(path, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor's "save as" (Dateisystem.speicherDatei): the text as a file of
|
||||
* the folder, over one of that name in any case (its name kept as it is).
|
||||
*/
|
||||
export function saveTextAs(files: VirtualFileSystem, folder: string, name: string, text: string): 'saved' | 'folderMissing' | 'isFolder' {
|
||||
const target = fileTarget(files, folder, name);
|
||||
if (target === undefined) return 'folderMissing';
|
||||
if (target.isFolder) return 'isFolder';
|
||||
files.writeTextFile(target.path, text);
|
||||
return 'saved';
|
||||
}
|
||||
|
||||
/** The image viewer's "open": an image file's bytes and media type; none for any other file. */
|
||||
export function imageFile(files: VirtualFileSystem, path: string): { readonly data: Uint8Array; readonly mediaType: string } | undefined {
|
||||
const entry = found(files, path);
|
||||
return entry?.content.kind === 'image' ? { data: entry.content.data, mediaType: entry.content.mediaType } : undefined;
|
||||
}
|
||||
@@ -94,7 +94,7 @@ const USAGE: Record<string, string> = {
|
||||
* Dateisystem.evaluatePathString for an absolute path: "." and empty parts
|
||||
* left out, ".." one up (at the root it stays there).
|
||||
*/
|
||||
function evaluatedPath(path: string): string {
|
||||
export function evaluatedPath(path: string): string {
|
||||
const parts: string[] = [];
|
||||
for (const part of path.split('/')) {
|
||||
if (part === '..') parts.pop();
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# FileCases: what FILIUS' file explorer, text editor and image viewer do to a
|
||||
# host's files, as their windows call FILIUS (tools/java-oracle/src/FileCases.java).
|
||||
# Each case starts from TerminalCases' files. A step: an operation and its
|
||||
# fields, separated by tabs; text in Java-style unicode escapes.
|
||||
case folders
|
||||
mkdir / neu
|
||||
mkdir / neu
|
||||
mkdir / NEU
|
||||
mkdir /
|
||||
mkdir /Dokumente Unter
|
||||
mkdir / leer/innen
|
||||
mkdir / fehlt/innen
|
||||
mkdir / ..
|
||||
mkdir /Dokumente ../oben
|
||||
mkdir /Dokumente /absolut
|
||||
case delete
|
||||
delete / notizen.txt
|
||||
delete / DOKUMENTE
|
||||
delete / gibtsnicht
|
||||
case paste
|
||||
copy / notizen.txt
|
||||
paste /Dokumente
|
||||
paste /Dokumente
|
||||
cut / kurz.txt
|
||||
paste /leer
|
||||
paste /leer
|
||||
copy / Dokumente
|
||||
paste /Dokumente/Alt
|
||||
paste /Dokumente
|
||||
paste /leer
|
||||
import /Dokumente/Alt tief.txt quelle.txt tief
|
||||
copy / Dokumente
|
||||
paste /leer/Dokumente
|
||||
cut / bild.png
|
||||
paste /
|
||||
case rename
|
||||
rename / notizen.txt notiz.txt
|
||||
rename / notiz.txt KURZ.TXT
|
||||
rename / notiz.txt
|
||||
rename / Dokumente Papiere
|
||||
rename / notiz.txt NOTIZ.txt
|
||||
rename / notiz.txt a/b
|
||||
rename / kurz.txt rand
|
||||
case import
|
||||
import / neu.txt quelle.txt Zeile 1\u000aZeile 2
|
||||
import / quelle.txt ohne Namen
|
||||
import / zeilen.txt quelle.txt A\u000d\u000aB\u000dC\u000a\u000aD
|
||||
import / NOTIZEN.TXT quelle.txt neu
|
||||
import / daten.bin quelle.bin @binary
|
||||
import / foto.png bild.png @image
|
||||
import /Dokumente foto bild.png @image
|
||||
import / gross.txt gross.txt @bytes:150001
|
||||
import / genau.txt genau.txt @bytes:150000
|
||||
import / leer.txt leer.txt
|
||||
import / Dokumente quelle.txt in den Ordner
|
||||
import / Umlaut.txt quelle.txt Gr\u00fc\u00dfe
|
||||
case editor
|
||||
save /notizen.txt Neuer Text
|
||||
saveas / neu.txt Hallo\u000aWelt
|
||||
saveas / NOTIZEN.TXT ersetzt
|
||||
saveas / bild.png Text ins Bild
|
||||
saveas / Dokumente in den Ordner
|
||||
saveas /fehlt x.txt y
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,241 @@
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user