Oracle TextEditorWindowRun uses Java's text editor window and its file dialog as a person does. The open file's text is its Y.Text in the document (plan 7.3): typed is in the file at once, for everyone and for the running engine. Core openText/saveTextAs without Java's mistakes (lost saves after save-as onto a file or a removed file, files named 'ordner/d.txt' or ''); doc files.ts (changeFiles, fileText, nodeFiles, readFilesInto); the room takes a person's files into the engine at once and writes the engine's files back at the tick they changed; the editor's window, Java's dialog, the text area following others' changes with the caret in place, its own undo. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
232 lines
12 KiB
TypeScript
232 lines
12 KiB
TypeScript
// The text editor window against Java's (packages/sim/test/
|
|
// textEditorWindow.java.json, TextEditorWindowRun): Java's steps played on a
|
|
// computer with a web server, the same files, the dialog's lists, the title
|
|
// and the text after each; where Java errs, what the window does instead
|
|
// (textEditor.ts names each), and where the text is the document's — typed
|
|
// is written — what follows from it.
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { describe, expect, it } from 'vitest';
|
|
import * as Y from 'yjs';
|
|
import { folderEntries } from '@mdg-filius/core';
|
|
import { addDevice, changeFiles, emptyProject, fileText, nodeFiles, replaceText, ROOTS, setSoftware, writeProject } from '@mdg-filius/doc';
|
|
import { TextEditorWindow, textEditorWindow } from '../src/desktop/textEditor.js';
|
|
import { translate } from '../src/i18n/i18n.js';
|
|
|
|
interface JavaStep {
|
|
readonly step: string;
|
|
readonly title: string;
|
|
readonly text: string;
|
|
readonly dialog?: readonly {
|
|
readonly title: string;
|
|
readonly labels: readonly string[];
|
|
readonly entries: readonly string[];
|
|
}[];
|
|
readonly files: readonly (readonly [string, string, string])[];
|
|
}
|
|
|
|
const java = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../../sim/test/textEditorWindow.java.json'), 'utf8')) as { steps: JavaStep[] };
|
|
const at = (name: string) => {
|
|
const found = java.steps.find((each) => each.step === name);
|
|
if (found === undefined) throw new Error(`no step ${name}`);
|
|
return found;
|
|
};
|
|
|
|
/** The files outside the web server's folder: a folder as null, a text as it is (Java's oracle gives a file of another type by its length). */
|
|
type Files = Record<string, string | null>;
|
|
const outsideWebServer = (path: string) => !path.startsWith('/webserver');
|
|
|
|
function javaFiles(step: JavaStep): Files {
|
|
return Object.fromEntries(
|
|
step.files
|
|
.filter(([path]) => outsideWebServer(path))
|
|
.map(([path, type, content]) => [path === '/' ? '/' : path.replace(/\/$/, ''), type === 'folder' ? null : content === '(0 characters)' ? '' : content]),
|
|
);
|
|
}
|
|
|
|
const PROGRAM = translate('de', 'program.textEditor');
|
|
|
|
function setUp() {
|
|
const doc = new Y.Doc();
|
|
writeProject(doc, emptyProject());
|
|
let counter = 0;
|
|
addDevice(doc, { kind: 'pc', id: 'pc', name: 'PC', x: 0, y: 0, newId: () => `port-${++counter}` }, 'me');
|
|
setSoftware(doc, 'pc', { install: ['webServer', 'textEditor', 'commandPrompt'], remove: [] }, 'de', 'me');
|
|
const window = new TextEditorWindow(doc, 'pc');
|
|
const files = (): Files =>
|
|
Object.fromEntries(
|
|
nodeFiles(doc, 'pc')!
|
|
.allEntries()
|
|
.filter((entry) => entry.path !== '/' && outsideWebServer(entry.path))
|
|
.map((entry) => [entry.path, entry.content.kind === 'directory' ? null : entry.content.kind === 'text' ? entry.content.text : entry.content.kind]),
|
|
);
|
|
const shown = () => ({ title: window.title(PROGRAM), text: window.text, files: files() });
|
|
/** Another program writes a file (Java's setDateiInhalt). */
|
|
const outside = (path: string, text: string) => doc.transact(() => replaceText(fileText(doc, 'pc', path)!, text), 'other');
|
|
/** The command line beside the window. */
|
|
const command = (change: Parameters<typeof changeFiles>[3]) => changeFiles(doc, 'pc', 'terminal', change);
|
|
/** The dialog's list of a folder, as Java's list writes its entries. */
|
|
const entries = (folder: string) => folderEntries(nodeFiles(doc, 'pc')!, folder).map((entry) => `${entry.kind === 'folder' ? 'Ordner' : 'Datei'};${entry.name}`);
|
|
return { doc, window, files, shown, outside, command, entries };
|
|
}
|
|
|
|
/** Java's title, text and files after a step, with what differs here. */
|
|
function expected(name: string, change: { title?: string; text?: string; files?: Files } = {}) {
|
|
const step = at(name);
|
|
return { title: change.title ?? step.title, text: change.text ?? step.text, files: { ...javaFiles(step), ...change.files } };
|
|
}
|
|
|
|
describe("the text editor window, against Java's", () => {
|
|
it("plays Java's steps: the same where Java is right, as named where it errs", () => {
|
|
const { window, files, shown, outside, command, entries } = setUp();
|
|
expect(shown()).toEqual(expected('open'));
|
|
|
|
// A new text: „Speichern“ asks for a name, and the dialog lists the folder.
|
|
window.input('Erste Zeile\nZweite Zeile');
|
|
expect(window.save()).toBe('askName');
|
|
expect(entries('/')).toEqual(at('save new as notiz.txt').dialog![0]!.entries);
|
|
expect(window.saveAs('/', 'notiz.txt')).toBe('saved');
|
|
expect(shown()).toEqual(expected('save new as notiz.txt'));
|
|
window.input('Erste Zeile\nZweite Zeile\nDritte Zeile');
|
|
expect(window.save()).toBe('saved');
|
|
expect(shown()).toEqual(expected('save'));
|
|
|
|
// Another program writes the open file: the window shows it.
|
|
outside('/notiz.txt', 'von außen');
|
|
expect(shown()).toEqual(expected('changed outside, nothing typed'));
|
|
// Typed is written: another's change meets it in the file, and the window shows the file (Java keeps its copy).
|
|
window.input('getippt');
|
|
expect(files()['/notiz.txt']).toBe('getippt');
|
|
outside('/notiz.txt', 'wieder von außen');
|
|
expect(shown()).toEqual(expected('changed outside after typing', { text: 'wieder von außen' }));
|
|
|
|
// The dialog: a folder's list, a file opened there, by name in any case, by a path.
|
|
command((fileSystem) => {
|
|
fileSystem.createDirectory('/ordner');
|
|
fileSystem.writeTextFile('/ordner/b.txt', '');
|
|
});
|
|
const dialog = at('open ordner/b.txt by double clicks').dialog!;
|
|
expect([entries('/'), entries('/ordner')]).toEqual([dialog[0]!.entries, dialog[1]!.entries]);
|
|
expect(window.open('/ordner', 'b.txt')).toBe('opened');
|
|
expect(shown()).toEqual(expected('open ordner/b.txt by double clicks'));
|
|
expect(window.open('/', 'NOTIZ.TXT')).toBe('opened');
|
|
expect(shown()).toEqual(expected('open NOTIZ.TXT'));
|
|
expect(window.open('/', 'ordner/b.txt')).toBe('opened');
|
|
expect(shown()).toEqual(expected('open ordner/b.txt by name'));
|
|
// Not there, no name, an image: said so, b.txt stays open (Java forgets it, shows the image's Base64 text).
|
|
expect(window.open('/', 'fehlt.txt')).toBe('notFound');
|
|
expect(shown()).toEqual(expected('open fehlt.txt', { title: `${PROGRAM} - b.txt` }));
|
|
expect(window.save()).toBe('saved');
|
|
expect(window.open('/', '')).toBe('notFound');
|
|
expect(entries('/webserver')).toEqual(at("open the web server's image").dialog![1]!.entries);
|
|
expect(window.open('/webserver', 'splashscreen-mini.png')).toBe('notText');
|
|
expect(shown()).toEqual(expected('open without a name', { title: `${PROGRAM} - b.txt` }));
|
|
|
|
// „Speichern unter“ onto notiz.txt in other case: that file, its name in the title, and later typing still goes there.
|
|
expect(window.open('/', 'notiz.txt')).toBe('opened');
|
|
window.input('vor dem Speichern unter');
|
|
expect(window.saveAs('/', 'NOTIZ.TXT')).toBe('saved');
|
|
expect(shown()).toEqual(expected('save as NOTIZ.TXT over notiz.txt', { title: `${PROGRAM} - notiz.txt` }));
|
|
window.input('nach dem Speichern unter');
|
|
const noteText = { '/notiz.txt': 'nach dem Speichern unter' };
|
|
expect(shown()).toEqual(expected('save after save as over a file', { title: `${PROGRAM} - notiz.txt`, files: noteText }));
|
|
|
|
// „Speichern unter“ a new file in a folder: typed goes into the open file first (here notiz.txt), then the new one has it.
|
|
window.input('neu im Ordner');
|
|
noteText['/notiz.txt'] = 'neu im Ordner';
|
|
expect(window.saveAs('/ordner', 'c.txt')).toBe('saved');
|
|
expect(shown()).toEqual(expected('save as ordner/c.txt', { files: noteText }));
|
|
window.input('neu im Ordner, geändert');
|
|
expect(shown()).toEqual(expected('save after save as a new file', { files: noteText }));
|
|
|
|
// The open file removed: its text stays as a new text, and „Speichern“ asks for a name (Java's is lost).
|
|
command((fileSystem) => fileSystem.deleteItem('/ordner/c.txt'));
|
|
expect(shown()).toEqual(expected('open file removed', { title: PROGRAM, files: noteText }));
|
|
expect(window.gone).toBe('/ordner/c.txt');
|
|
window.input('nach dem Löschen');
|
|
expect(window.save()).toBe('askName');
|
|
expect(shown()).toEqual(expected('save after the file was removed', { title: PROGRAM, files: noteText }));
|
|
|
|
// A name with a folder in it is that folder's file (Java's file is named "ordner/d.txt" in the root); no name is refused.
|
|
expect(window.saveAs('/', 'ordner/d.txt')).toBe('saved');
|
|
expect(window.gone).toBeNull();
|
|
expect(shown()).toEqual(expected('save as ordner/d.txt', { title: `${PROGRAM} - d.txt`, files: noteText }));
|
|
expect(entries('/')).not.toContain('Datei;ordner/d.txt');
|
|
expect(window.saveAs('/', '')).toBe('invalidName');
|
|
const withoutNameless = Object.fromEntries(Object.entries(expected('save as without a name').files).filter(([path]) => path !== '/'));
|
|
expect(shown()).toEqual({ ...expected('save as without a name', { title: `${PROGRAM} - d.txt` }), files: { ...withoutNameless, ...noteText } });
|
|
|
|
window.newText();
|
|
expect({ ...shown(), files: {} }).toEqual({ ...expected('new'), files: {} });
|
|
});
|
|
|
|
it("names Java's dialog, its folder's label aside", () => {
|
|
const dialog = at('save new as notiz.txt').dialog![0]!;
|
|
expect(dialog.title).toBe(translate('de', 'fileDialog.saveTitle'));
|
|
expect(dialog.labels).toEqual([`${translate('de', 'fileDialog.folder')} root`, translate('de', 'fileDialog.name')]);
|
|
expect(at('open ordner/b.txt by double clicks').dialog![0]!.title).toBe(translate('de', 'fileDialog.openTitle'));
|
|
});
|
|
});
|
|
|
|
describe('the text editor window beyond the steps', () => {
|
|
it("follows the open file when another's write replaces its entry, and takes back only its own typing", () => {
|
|
const { doc, window } = setUp();
|
|
window.input('eins');
|
|
window.saveAs('/', 'a.txt');
|
|
const files = doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get('pc')!.get('files') as Y.Map<Y.Map<unknown>>;
|
|
let seen = window.version;
|
|
const stop = window.subscribe(() => (seen = window.version));
|
|
// Another replaces the entry (a text again): the window follows to the new text.
|
|
doc.transact(() => {
|
|
const entry = new Y.Map<unknown>();
|
|
entry.set('order', files.get('/a.txt')!.get('order'));
|
|
entry.set('kind', 'text');
|
|
entry.set('text', new Y.Text('ersetzt'));
|
|
files.set('/a.txt', entry);
|
|
}, 'other');
|
|
expect([window.path, window.text, window.file]).toEqual(['/a.txt', 'ersetzt', fileText(doc, 'pc', '/a.txt')]);
|
|
expect(seen).toBe(window.version);
|
|
// Own typing is taken back, another's not.
|
|
window.input('ersetzt und getippt');
|
|
doc.transact(() => fileText(doc, 'pc', '/a.txt')!.insert(0, '>'), 'other');
|
|
window.undo();
|
|
expect(window.text).toBe('>ersetzt');
|
|
window.redo();
|
|
expect(window.text).toBe('>ersetzt und getippt');
|
|
stop();
|
|
window.input('x');
|
|
expect(seen).toBeLessThan(window.version);
|
|
});
|
|
|
|
it('keeps a new text and changes nothing for the same typing, and is the same window for a computer while the editor runs', () => {
|
|
const { doc, window } = setUp();
|
|
const before = window.version;
|
|
window.input('');
|
|
expect(window.version).toBe(before);
|
|
window.input('neu');
|
|
window.newText();
|
|
expect([window.text, window.path, window.file]).toEqual(['', null, undefined]);
|
|
expect(textEditorWindow(doc, 'pc')).toBe(textEditorWindow(doc, 'pc'));
|
|
expect(textEditorWindow(doc, 'pc')).not.toBe(textEditorWindow(doc, 'other'));
|
|
expect(textEditorWindow(new Y.Doc(), 'pc')).not.toBe(textEditorWindow(doc, 'pc'));
|
|
// A file removed while open is gone until another opens; „Neu“ ends it too.
|
|
window.input('a');
|
|
window.saveAs('/', 'a.txt');
|
|
window.saveAs('/', 'b.txt');
|
|
changeFiles(doc, 'pc', 'terminal', (files) => files.deleteItem('/b.txt'));
|
|
expect([window.gone, window.text]).toEqual(['/b.txt', 'a']);
|
|
expect(window.open('/', 'a.txt')).toBe('opened');
|
|
expect(window.gone).toBeNull();
|
|
changeFiles(doc, 'pc', 'terminal', (files) => files.deleteItem('/a.txt'));
|
|
expect(window.gone).toBe('/a.txt');
|
|
window.newText();
|
|
expect(window.gone).toBeNull();
|
|
// A computer the document does not have: nothing opens, nothing is saved.
|
|
const missing = new TextEditorWindow(doc, 'none');
|
|
expect(missing.open('/', 'a.txt')).toBe('notFound');
|
|
expect(missing.saveAs('/', 'a.txt')).toBe('folderMissing');
|
|
});
|
|
});
|