Files
julian.kiedaischandClaude Opus 5.5 2c7733b575 feat: F4.6f2 the text editor on the document's Y.Text, files both ways while the simulation runs
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>
2026-10-03 17:50:27 +00:00

328 lines
19 KiB
TypeScript

// What a run leaves in the document, against what Java's project holds when
// it is saved after the same run (tools/java-oracle SaveAfterRunRun,
// fixtures/after-run-*.fls; both read with the same reader): the files a
// command line made, the accounts and mails, which servers run, the virtual
// hosts, a new domain; the addresses DHCP clients got. Named: the virtual
// hosts' file holds the complete rows only (F4.6d).
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 { VirtualFileSystem as VirtualFileSystemOf, type Id, type VirtualFileSystem } from '@mdg-filius/core';
import { addDevice, changeFiles, createUndoManager, emptyProject, fileText, readProject, ROOTS, setSoftware, SIM_ORIGIN, writeProject, writeRunState } from '@mdg-filius/doc';
import { openProjectArchive, type ImportedProject } from '@mdg-filius/fls';
import type { HostMessage } from '@mdg-filius/protocol';
import { SimulationRoom, TICK_MILLISECONDS, WRITE_BACK_MILLISECONDS } from '../src/index.js';
const here = dirname(fileURLToPath(import.meta.url));
const corpus = join(here, '../../../corpus/java-examples');
function open(path: string): ImportedProject {
let ids = 0;
return openProjectArchive(new Uint8Array(readFileSync(path)), () => `00000000-0000-0000-0000-${(++ids).toString(16).padStart(12, '0')}`);
}
function setUp(file: string, writeBackMilliseconds?: number) {
const doc = new Y.Doc();
writeProject(doc, open(join(corpus, file)));
let now = 1_000_000;
const room = new SimulationRoom(doc, { now: () => now, seed: () => 5, ...(writeBackMilliseconds === undefined ? {} : { writeBackMilliseconds }) });
const got: HostMessage[] = [];
const client = room.connect((message) => got.push(message));
const id = (name: string) => readProject(doc).graph.nodes.find((node) => node.displayName === name)!.id;
const act = (node: string, app: string, action: string, args: object = {}) => room.receive(client, { v: 1, kind: 'input', node: id(node), app, action, args, reqId: `${app}.${action}` });
const run = (milliseconds: number) => {
for (let elapsed = 0; elapsed < milliseconds; elapsed += TICK_MILLISECONDS) {
now += TICK_MILLISECONDS;
room.tick();
}
};
/** What the room sent of a window since the last call, as text. */
const sent = (app: string) => JSON.stringify(got.splice(0).filter((message) => message.kind === 'app' && message.app === app));
return { doc, room, got, id, act, run, sent, send: (message: object) => room.receive(client, { v: 1, ...message }) };
}
/** A computer's files: text as it is, the rest by kind. */
function filesOf(files: VirtualFileSystem | undefined): Record<string, string> {
return Object.fromEntries((files?.allEntries() ?? []).map((entry) => [entry.path, entry.content.kind === 'text' ? entry.content.text : entry.content.kind]));
}
/** What a run leaves of a node, as the model holds it. */
function slice(project: ImportedProject, name: string) {
const id = project.graph.nodes.find((node) => node.displayName === name)!.id;
return {
files: filesOf(project.virtualFileSystems.get(id)),
programs: [...(project.installedPrograms.get(id) ?? [])].sort(),
active: [...(project.activePrograms.get(id) ?? [])].sort(),
webServer: project.webServerConfigurations.get(id),
emailServer: project.emailServerConfigurations.get(id),
emailClient: project.emailClientConfigurations.get(id),
firewall: project.firewallConfigurationsByNodeId.get(id),
};
}
const ACCOUNT = (user: string, last: string) => ({ name: `Rechner ${last}`, email: `${user}@filius.de`, pop3Host: '141.99.50.232', pop3Port: '110', smtpHost: '141.99.50.232', smtpPort: '25', username: user, password: user });
describe("what a run leaves in the document, against Java's project saved after it", () => {
it('keeps the files, accounts, mails, servers, virtual hosts and domain of a run', () => {
const { doc, id, act, run, send } = setUp('email_komplett.fls');
setSoftware(doc, id('Rechner 1'), { install: ['commandPrompt'], remove: [] }, 'de', 'me');
send({ kind: 'start' });
run(2_000);
act('Rechner 1', 'terminal', 'enter', { line: 'mkdir ordner' });
run(500);
act('Rechner 1', 'terminal', 'enter', { line: 'touch ordner/notiz.txt' });
run(500);
act('Rechner 1', 'emailClient', 'setAccount', ACCOUNT('rechner1', '1'));
act('Rechner 2', 'emailClient', 'setAccount', ACCOUNT('rechner2', '2'));
act('Rechner 1', 'emailClient', 'send', { to: 'rechner2@filius.de', cc: '', bcc: '', subject: 'Nach dem Lauf', text: 'Erste Zeile\nZweite Zeile' });
run(8_000);
act('Rechner 2', 'emailClient', 'fetch');
run(8_000);
act('Mail-Server (filius.de)', 'emailServer', 'addAccount', { username: 'neu', password: 'pw', firstName: 'Vorname', lastName: 'Nachname' });
act('Web-Server', 'webServer', 'setActive', { active: false });
act('Web-Server', 'webServer', 'useVirtualHosts', { use: true });
act('Web-Server', 'webServer', 'setVirtualHost', { row: 0, column: 0, value: 'a.de' });
act('Web-Server', 'webServer', 'setVirtualHost', { row: 0, column: 1, value: 'ordner' });
act('Mail-Server (senior.de)', 'emailServer', 'setActive', { active: false });
act('Mail-Server (senior.de)', 'emailServer', 'setDomain', { domain: 'senior.org' });
run(1_000);
send({ kind: 'stop' });
const ours = readProject(doc);
const java = open(join(here, 'fixtures/after-run-email.fls'));
for (const name of ['Rechner 1', 'Rechner 2', 'Rechner 3', 'Mail-Server (filius.de)', 'Mail-Server (senior.de)', 'DNS-Server']) {
expect(slice(ours, name), name).toEqual(slice(java, name));
}
// The virtual hosts' file holds the complete rows (Java's every row, the empty ones as empty lines).
const web = slice(java, 'Web-Server');
expect(web.files['/www.conf/vhosts']).toBe('a.de\nordner\n\n\n\n\n\n\n\n\n');
expect(slice(ours, 'Web-Server')).toEqual({ ...web, files: { ...web.files, '/www.conf/vhosts': 'a.de\nordner\n' } });
});
it('keeps the addresses DHCP clients got', () => {
const { doc, run, send } = setUp('einfaches_rechnernetz_komplett.fls');
send({ kind: 'start' });
run(20_000);
// Written while it runs, not only at the stop.
const running = readProject(doc);
send({ kind: 'stop' });
expect(readProject(doc).deviceConfigurations).toEqual(running.deviceConfigurations);
const devices = (project: ImportedProject) =>
project.graph.nodes
.filter((node) => project.dhcpClientConfigurations.get(node.id)?.isEnabled === true)
.map((node) => project.deviceConfigurations.get(node.id)!)
// Which client gets which address is the threads' race in Java.
.map(({ ipAddress, ...rest }) => ({ ...rest, ipAddress: ipAddress.startsWith('192.168.0.') ? 'leased' : ipAddress }));
const java = open(join(here, 'fixtures/after-run-dhcp.fls'));
const before = open(join(corpus, 'einfaches_rechnernetz_komplett.fls'));
expect(devices(readProject(doc))).toEqual(devices(java));
expect(devices(before)).not.toEqual(devices(java));
const addresses = (project: ImportedProject) =>
project.graph.nodes.filter((node) => project.dhcpClientConfigurations.get(node.id)?.isEnabled === true).map((node) => project.deviceConfigurations.get(node.id)!.ipAddress).sort();
expect(addresses(readProject(doc))).toEqual(addresses(java));
});
});
describe("the room's write-back", () => {
it('writes while the simulation runs, at most once a second, after an action and at the stop, as the room', () => {
const { doc, id, act, run, send } = setUp('email_komplett.fls');
const origins: unknown[] = [];
doc.on('update', (_update: Uint8Array, origin: unknown) => origins.push(origin));
const node = id('Mail-Server (filius.de)');
const accounts = () => (readProject(doc).emailServerConfigurations.get(node)?.accounts ?? []).map((account) => account.username);
send({ kind: 'start' });
run(TICK_MILLISECONDS);
act('Mail-Server (filius.de)', 'emailServer', 'addAccount', { username: 'neu', password: 'pw', firstName: 'A', lastName: 'B' });
expect(accounts()).toContain('neu');
expect(origins.length).toBeGreaterThan(0);
expect(new Set(origins)).toEqual(new Set([SIM_ORIGIN]));
// Nothing new: nothing is written.
const written = origins.length;
run(3 * WRITE_BACK_MILLISECONDS);
expect(origins.length).toBe(written);
send({ kind: 'stop' });
expect(origins.length).toBe(written);
});
it('writes at the stop what it left since the last write', () => {
const { doc, id, run, send } = setUp('einfaches_rechnernetz_komplett.fls', 60_000);
const notebook = id('Notebook 1');
const before = readProject(doc).deviceConfigurations.get(notebook);
send({ kind: 'start' });
run(20_000);
expect(readProject(doc).deviceConfigurations.get(notebook)).toEqual(before);
send({ kind: 'stop' });
expect(readProject(doc).deviceConfigurations.get(notebook)!.ipAddress).toMatch(/^192\.168\.0\./);
});
it('removes from the document what a command line removed', () => {
const { doc, id, act, run, send } = setUp('email_komplett.fls');
const node = id('Rechner 1');
setSoftware(doc, node, { install: ['commandPrompt'], remove: [] }, 'de', 'me');
const files = () => Object.keys(Object.fromEntries((readProject(doc).virtualFileSystems.get(node)?.allEntries() ?? []).map((entry) => [entry.path, true])));
expect(files()).toContain('/konten.txt');
send({ kind: 'start' });
run(TICK_MILLISECONDS);
act('Rechner 1', 'terminal', 'enter', { line: 'rm konten.txt' });
expect(files()).not.toContain('/konten.txt');
});
it("is never a person's step to take back", () => {
const { doc, id, act, run, send } = setUp('email_komplett.fls');
const undo = createUndoManager(doc, 'me');
send({ kind: 'start' });
run(TICK_MILLISECONDS);
act('Web-Server', 'webServer', 'setActive', { active: false });
send({ kind: 'stop' });
expect(readProject(doc).activePrograms.get(id('Web-Server'))).toBeUndefined();
expect(undo.undoStack).toHaveLength(0);
});
it('writes a text where it changed, keeps a file another wrote meanwhile, and removes a file the run removed', () => {
const doc = new Y.Doc();
const { doc: source } = setUp('email_komplett.fls');
writeProject(doc, readProject(source));
const nodeId = readProject(doc).graph.nodes.find((node) => node.displayName === 'Rechner 1')!.id as Id;
const files = () => doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get(nodeId)!.get('files') as Y.Map<Y.Map<unknown>>;
const fileSystem = readProject(doc).virtualFileSystems.get(nodeId)!;
fileSystem.createDirectory('/neu');
fileSystem.writeTextFile('/neu/a.txt', 'Anfang Mitte Ende');
writeRunState(doc, new Map([[nodeId, { files: fileSystem }]]));
const text = files().get('/neu/a.txt')!.get('text') as Y.Text;
expect(text.toString()).toBe('Anfang Mitte Ende');
const order = files().get('/neu/a.txt')!.get('order');
// The run changes the middle; the entry and its text stay the same objects.
fileSystem.writeTextFile('/neu/a.txt', 'Anfang neue Mitte Ende');
const changes: string[] = [];
text.observe((event) => changes.push(JSON.stringify(event.delta)));
writeRunState(doc, new Map([[nodeId, { files: fileSystem }]]));
expect([files().get('/neu/a.txt')!.get('text'), text.toString(), files().get('/neu/a.txt')!.get('order')]).toEqual([text, 'Anfang neue Mitte Ende', order]);
expect(changes).toEqual([JSON.stringify([{ retain: 7 }, { insert: 'neue ' }])]);
// A file turned into a folder, and one removed.
fileSystem.deleteItem('/neu/a.txt');
fileSystem.createDirectory('/neu/a.txt');
fileSystem.deleteItem('/konten.txt');
writeRunState(doc, new Map([[nodeId, { files: fileSystem }]]));
expect(files().get('/neu/a.txt')!.get('kind')).toBe('directory');
expect(files().get('/neu/a.txt')!.get('order')).toBe(order);
expect(files().has('/konten.txt')).toBe(false);
// An image's bytes and type.
fileSystem.writeImageFile('/bild.png', new Uint8Array([1, 2]), 'image/png');
writeRunState(doc, new Map([[nodeId, { files: fileSystem }]]));
fileSystem.writeImageFile('/bild.png', new Uint8Array([1, 3]), 'image/png');
writeRunState(doc, new Map([[nodeId, { files: fileSystem }]]));
expect(files().get('/bild.png')!.get('data')).toEqual(new Uint8Array([1, 3]));
fileSystem.writeImageFile('/bild.png', new Uint8Array([1, 3]), 'image/gif');
writeRunState(doc, new Map([[nodeId, { files: fileSystem }]]));
expect(files().get('/bild.png')!.get('mediaType')).toBe('image/gif');
// A setting written again alike, its fields in another order or one undefined: nothing; with a field less: written.
const settingsDoc = new Y.Doc();
writeProject(settingsDoc, readProject(source));
const server = readProject(settingsDoc).graph.nodes.find((node) => node.displayName === 'Web-Server')!.id;
const web = readProject(settingsDoc).webServerConfigurations.get(server)!;
const updates: unknown[] = [];
settingsDoc.on('update', (update: Uint8Array) => updates.push(update));
writeRunState(settingsDoc, new Map([[server, { settings: { webServer: { virtualHosts: web.virtualHosts, documentRoot: web.documentRoot, port: web.port, extra: undefined } } }]]));
expect(updates).toHaveLength(0);
writeRunState(settingsDoc, new Map([[server, { settings: { webServer: { port: web.port, documentRoot: web.documentRoot } } }]]));
expect(updates).toHaveLength(1);
expect(readProject(settingsDoc).webServerConfigurations.get(server)).toEqual({ port: web.port, documentRoot: web.documentRoot });
writeRunState(settingsDoc, new Map([[server, { settings: { webServer: { port: web.port, documentRoot: web.documentRoot, virtualHosts: true } } }]]));
expect(updates).toHaveLength(2);
// A node without files whose run has none: no files.
const switchId = readProject(settingsDoc).graph.nodes.find((node) => node.kind === 'networkSwitch')!.id;
writeRunState(settingsDoc, new Map([[switchId, { files: new VirtualFileSystemOf() }]]));
expect(settingsDoc.getMap<Y.Map<unknown>>(ROOTS.nodes).get(switchId)!.has('files')).toBe(false);
// A node the document no longer has, and settings it has not: nothing.
writeRunState(doc, new Map([['niemand', { files: fileSystem }]]));
writeRunState(doc, new Map([[nodeId, { settings: { webServer: { port: 80 } } }]]));
expect(readProject(doc).webServerConfigurations.has(nodeId)).toBe(false);
});
});
describe('files both ways while the simulation runs', () => {
it("takes what a person writes into the document at once, and writes the engine's files at the tick they changed", () => {
const { doc, id, act, run, send, sent } = setUp('email_komplett.fls', 60_000);
const one = id('Rechner 1');
setSoftware(doc, one, { install: ['commandPrompt'], remove: [] }, 'de', 'me');
send({ kind: 'sub', topics: [`app:${one}/terminal`] });
send({ kind: 'start' });
run(TICK_MILLISECONDS);
// A file saved by a person (the text editor), then typed into: the command line reads it.
changeFiles(doc, one, 'me', (files) => files.writeTextFile('/notiz.txt', 'Hallo'));
act('Rechner 1', 'terminal', 'enter', { line: 'cat notiz.txt' });
expect(sent('terminal')).toContain('Hallo');
doc.transact(() => fileText(doc, one, '/notiz.txt')!.insert(5, ' Welt'), 'me');
act('Rechner 1', 'terminal', 'enter', { line: 'cat notiz.txt' });
expect(sent('terminal')).toContain('Hallo Welt');
// Removed by a person: gone for the command line.
changeFiles(doc, one, 'me', (files) => files.deleteItem('/notiz.txt'));
act('Rechner 1', 'terminal', 'enter', { line: 'cat notiz.txt' });
expect(sent('terminal')).not.toContain('Hallo');
// A mail taken by the server changes its accounts file in a tick: in the document then, the settings a minute later.
const server = id('Mail-Server (filius.de)');
const accountsFile = () => fileText(doc, server, '/mailserver/konten.txt')!.toString();
const before = accountsFile();
const mailboxes = () => readProject(doc).emailServerConfigurations.get(server)!.accounts.map((account) => account.mailbox.length);
const settings = mailboxes();
act('Rechner 1', 'emailClient', 'setAccount', ACCOUNT('rechner1', '1'));
act('Rechner 1', 'emailClient', 'send', { to: 'rechner2@filius.de', cc: '', bcc: '', subject: 'Im Lauf', text: 'Text' });
const afterAction = accountsFile();
let tick = 0;
while (accountsFile() === afterAction && tick++ < 50) run(TICK_MILLISECONDS);
expect(accountsFile()).not.toBe(before);
expect(accountsFile()).toContain('Im Lauf');
expect(mailboxes()).toEqual(settings);
// A person types into the file the engine just wrote: both stay.
doc.transact(() => fileText(doc, server, '/mailserver/konten.txt')!.insert(0, '# '), 'me');
expect(accountsFile().startsWith('# ')).toBe(true);
expect(accountsFile()).toContain('Im Lauf');
});
it('takes the first file of a computer that had none, leaves out one whose engine has no files, and writes nothing back of what it took', () => {
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');
addDevice(doc, { kind: 'pc', id: 'bare', name: 'Bare', x: 100, y: 0, newId: () => `port-${++counter}` }, 'me');
const programs = doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get('pc')!.get('programs') as Y.Map<Y.Map<unknown>>;
const entry = new Y.Map<unknown>();
entry.set('active', true);
programs.set('commandPrompt', entry);
// Two computers without files (a new one has a few).
for (const id of ['pc', 'bare']) doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get(id)!.delete('files');
let now = 0;
const room = new SimulationRoom(doc, { now: () => now, seed: () => 7 });
const got: HostMessage[] = [];
const client = room.connect((message) => got.push(message));
const send = (fields: object) => room.receive(client, { v: 1, ...fields });
const run = (milliseconds: number) => {
for (let elapsed = 0; elapsed < milliseconds; elapsed += TICK_MILLISECONDS) {
now += TICK_MILLISECONDS;
room.tick();
}
};
send({ kind: 'sub', topics: ['app:pc/terminal'] });
send({ kind: 'start' });
run(TICK_MILLISECONDS);
expect(doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get('pc')!.has('files')).toBe(false);
const writes: unknown[] = [];
doc.on('afterTransaction', (transaction: Y.Transaction) => transaction.origin === SIM_ORIGIN && transaction.changed.size > 0 && writes.push(transaction));
changeFiles(doc, 'pc', 'me', (files) => files.writeTextFile('/erste.txt', 'Erste Datei'));
// A computer whose programs use no files: its file waits for the next start, the simulation runs on.
changeFiles(doc, 'bare', 'me', (files) => files.writeTextFile('/x.txt', 'x'));
run(3 * WRITE_BACK_MILLISECONDS);
expect(room.currentMode).toBe('simulation');
expect(got.filter((message) => message.kind === 'error')).toEqual([]);
// What it took it does not write back: the document has it.
expect(writes).toHaveLength(0);
send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'cat erste.txt' }, reqId: 'cat' });
expect(JSON.stringify(got)).toContain('Erste Datei');
});
});