// 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; 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[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>(ROOTS.nodes).get('pc')!.get('files') as Y.Map>; 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(); 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'); }); });