feat(sim): F4.6f1 what a run leaves goes back into the document
The oracle SaveAfterRunRun saves Java's project after a run. The room writes what a run leaves that Java keeps - files, running servers, virtual hosts, mail servers' domains and accounts, mail programs' accounts and folders, firewalls, DHCP clients' addresses - into the document as origin 'sim', at most once a second, after each action and at the stop; writeRunState writes only what differs. The command line now goes on in a folder made before a restart, as Java's does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -213,11 +213,17 @@ export function fileContentOfJavaType(rawType: string, content: string, path: st
|
||||
|
||||
export class VirtualFileSystem {
|
||||
private store: Store;
|
||||
private revisionValue = 0;
|
||||
|
||||
constructor() {
|
||||
this.store = new Map([[keyOf('/'), { path: '/', content: { kind: 'directory' } }]]);
|
||||
}
|
||||
|
||||
/** Counts the changes: different after each one (the simulation room writes a file system back when it changed). */
|
||||
get revision(): number {
|
||||
return this.revisionValue;
|
||||
}
|
||||
|
||||
/** `init(entries:)`: shallow entries first; each parent must exist. */
|
||||
static fromEntries(entries: readonly VirtualFileEntry[]): VirtualFileSystem {
|
||||
const fileSystem = new VirtualFileSystem();
|
||||
@@ -460,10 +466,12 @@ export class VirtualFileSystem {
|
||||
const key = keyOf(path);
|
||||
const existing = this.store.get(key);
|
||||
this.store.set(key, { path: existing?.path ?? path, content });
|
||||
this.revisionValue += 1;
|
||||
}
|
||||
|
||||
private delete(path: string): void {
|
||||
this.store.delete(keyOf(path));
|
||||
this.revisionValue += 1;
|
||||
}
|
||||
|
||||
private transaction(change: (candidate: VirtualFileSystem) => void): void {
|
||||
@@ -471,6 +479,7 @@ export class VirtualFileSystem {
|
||||
change(candidate);
|
||||
candidate.validateQuotas();
|
||||
this.store = candidate.store;
|
||||
this.revisionValue += 1;
|
||||
}
|
||||
|
||||
private persistedEntryCount(): number {
|
||||
@@ -538,6 +547,7 @@ export class VirtualFileSystem {
|
||||
candidate.set(destination, content);
|
||||
candidate.validateQuotas();
|
||||
this.store = candidate.store;
|
||||
this.revisionValue += 1;
|
||||
}
|
||||
|
||||
/** The subtree at `sourcePath`, shallow first. */
|
||||
|
||||
@@ -164,3 +164,24 @@ describe('virtual file system', () => {
|
||||
expect(fileSystem.upgradeLegacyDefaultImages()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a file system's revision", () => {
|
||||
it('changes with every change, a removal too, and not with a reading', () => {
|
||||
const files = new VirtualFileSystem();
|
||||
const seen = [files.revision];
|
||||
files.createDirectory('/a');
|
||||
seen.push(files.revision);
|
||||
files.writeTextFile('/a/b.txt', 'x');
|
||||
seen.push(files.revision);
|
||||
files.renameInPlace('/a/b.txt', 'c.txt');
|
||||
seen.push(files.revision);
|
||||
files.deleteItem('/a/c.txt');
|
||||
seen.push(files.revision);
|
||||
files.allEntries();
|
||||
files.contains('/a');
|
||||
seen.push(files.revision);
|
||||
expect(new Set(seen.slice(0, 5)).size).toBe(5);
|
||||
expect(seen[5]).toBe(seen[4]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ export * from './fields.js';
|
||||
export * from './janitor.js';
|
||||
export * from './projectDoc.js';
|
||||
export * from './settings.js';
|
||||
export * from './simulation.js';
|
||||
export * from './sheet.js';
|
||||
export * from './snapshot.js';
|
||||
export * from './values.js';
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// What a run leaves in the project, written back into the document (plan
|
||||
// §6.1: the room writes it as `origin: 'sim'`), as Java's project holds it
|
||||
// when it is saved after a run (measured with tools/java-oracle's
|
||||
// SaveAfterRunRun): each computer's files as its programs left them, which
|
||||
// servers run, and the settings a run changes — a mail server's domain and
|
||||
// accounts, a mail program's account and folders, a web server's virtual
|
||||
// hosts, a firewall changed through a router's page, the address a DHCP
|
||||
// client got. Only what differs is written, so that a write changes nothing
|
||||
// the second time and a text edited meanwhile keeps what it did not touch.
|
||||
|
||||
import * as Y from 'yjs';
|
||||
import type { Id, InstallableProgram, VirtualFileContent, VirtualFileSystem } from '@mdg-filius/core';
|
||||
import { fileEntry, NODE_SETTINGS, ROOTS } from './projectDoc.js';
|
||||
|
||||
export { emailServerAfterRun } from '@mdg-filius/fls';
|
||||
import { fromY, toY, type PlainValue } from './values.js';
|
||||
|
||||
/** The origin of the room's writes (plan §6.1); a person's undo never takes them. */
|
||||
export const SIM_ORIGIN = 'sim';
|
||||
|
||||
/** A node's settings key in the document (NODE_SETTINGS' values). */
|
||||
export type NodeSettingsKey = (typeof NODE_SETTINGS)[keyof typeof NODE_SETTINGS];
|
||||
|
||||
/** What a run left of a node. */
|
||||
export interface NodeRunState {
|
||||
readonly files?: VirtualFileSystem;
|
||||
readonly active?: Partial<Record<InstallableProgram, boolean>>;
|
||||
readonly settings?: Partial<Record<NodeSettingsKey, unknown>>;
|
||||
}
|
||||
|
||||
/** Plain values alike, whatever the order of an object's fields; a field that is undefined is none. */
|
||||
function same(left: unknown, right: unknown): boolean {
|
||||
if (left instanceof Uint8Array || right instanceof Uint8Array) return left instanceof Uint8Array && right instanceof Uint8Array && sameBytes(left, right);
|
||||
if (Array.isArray(left) || Array.isArray(right)) {
|
||||
return Array.isArray(left) && Array.isArray(right) && left.length === right.length && left.every((each, index) => same(each, right[index]));
|
||||
}
|
||||
if (typeof left === 'object' && left !== null && typeof right === 'object' && right !== null) {
|
||||
const fields = (value: object) => Object.entries(value).filter(([, field]) => field !== undefined);
|
||||
const leftFields = fields(left);
|
||||
const rightFields = new Map(fields(right));
|
||||
return leftFields.length === rightFields.size && leftFields.every(([key, field]) => rightFields.has(key) && same(field, rightFields.get(key)));
|
||||
}
|
||||
return left === right;
|
||||
}
|
||||
|
||||
function sameBytes(left: Uint8Array, right: Uint8Array): boolean {
|
||||
return left.length === right.length && left.every((byte, index) => byte === right[index]);
|
||||
}
|
||||
|
||||
/** A text changed where it changed: the common start and end stay (and with them what another person typed there). */
|
||||
function replaceText(text: Y.Text, next: string): void {
|
||||
const current = text.toString();
|
||||
if (current === next) return;
|
||||
let start = 0;
|
||||
while (start < current.length && start < next.length && current[start] === next[start]) start += 1;
|
||||
let end = 0;
|
||||
while (end < current.length - start && end < next.length - start && current[current.length - 1 - end] === next[next.length - 1 - end]) end += 1;
|
||||
if (current.length - start - end > 0) text.delete(start, current.length - start - end);
|
||||
if (next.length - start - end > 0) text.insert(start, next.slice(start, next.length - end));
|
||||
}
|
||||
|
||||
/** Whether a document entry holds the content. */
|
||||
function holds(entry: Y.Map<unknown>, content: VirtualFileContent): boolean {
|
||||
if (entry.get('kind') !== content.kind) return false;
|
||||
if (content.kind === 'directory' || content.kind === 'text') return true;
|
||||
return sameBytes(entry.get('data') as Uint8Array, content.data) && entry.get('mediaType') === content.mediaType;
|
||||
}
|
||||
|
||||
/** A node's files as the run left them: new ones after the others, changed ones in their place, gone ones removed. */
|
||||
function writeFiles(node: Y.Map<unknown>, fileSystem: VirtualFileSystem): void {
|
||||
let files = node.get('files') as Y.Map<Y.Map<unknown>> | undefined;
|
||||
const wanted: [string, VirtualFileContent][] = [];
|
||||
const walk = (folder: string): void => {
|
||||
for (const entry of fileSystem.entriesInCreationOrder(folder)) {
|
||||
wanted.push([entry.path, entry.content]);
|
||||
if (entry.content.kind === 'directory') walk(entry.path);
|
||||
}
|
||||
};
|
||||
walk('/');
|
||||
if (files === undefined) {
|
||||
if (wanted.length === 0) return;
|
||||
files = new Y.Map<Y.Map<unknown>>();
|
||||
node.set('files', files);
|
||||
}
|
||||
const paths = new Set(wanted.map(([path]) => path));
|
||||
for (const path of [...files.keys()]) if (!paths.has(path)) files.delete(path);
|
||||
let order = Math.max(-1, ...[...files.values()].map((entry) => entry.get('order') as number)) + 1;
|
||||
for (const [path, content] of wanted) {
|
||||
const entry = files.get(path);
|
||||
if (entry === undefined) {
|
||||
files.set(path, fileEntry(order++, content));
|
||||
} else if (!holds(entry, content)) {
|
||||
files.set(path, fileEntry(entry.get('order') as number, content));
|
||||
} else if (content.kind === 'text') {
|
||||
replaceText(entry.get('text') as Y.Text, content.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes what the runs left of the nodes, what differs only, in one transaction; nodes the document no longer has are left out. */
|
||||
export function writeRunState(doc: Y.Doc, states: ReadonlyMap<Id, NodeRunState>, origin: unknown = SIM_ORIGIN): void {
|
||||
const nodes = doc.getMap<Y.Map<unknown>>(ROOTS.nodes);
|
||||
doc.transact(() => {
|
||||
for (const [id, state] of states) {
|
||||
const node = nodes.get(id);
|
||||
if (node === undefined) continue;
|
||||
if (state.files !== undefined) writeFiles(node, state.files);
|
||||
const programs = node.get('programs') as Y.Map<Y.Map<unknown>>;
|
||||
for (const [program, active] of Object.entries(state.active ?? {})) {
|
||||
const entry = programs.get(program);
|
||||
if (entry !== undefined && entry.get('active') !== active) entry.set('active', active);
|
||||
}
|
||||
const settings = node.get('settings') as Y.Map<unknown>;
|
||||
// Only a setting the node has (a router's own web server has none).
|
||||
for (const [key, value] of Object.entries(state.settings ?? {})) {
|
||||
const current = settings.get(key);
|
||||
if (value === undefined || current === undefined) continue;
|
||||
if (!same(fromY(current), value)) settings.set(key, toY(value as PlainValue));
|
||||
}
|
||||
}
|
||||
}, origin);
|
||||
}
|
||||
@@ -362,6 +362,37 @@ test('keeps the command line over leaving it, over a return to the design, and e
|
||||
await expect(window).toBeVisible();
|
||||
});
|
||||
|
||||
test('keeps what a run made in the files over a return to the design, as Java\'s project keeps it', async ({ page }) => {
|
||||
await openEditor(page, server.base, 'internetwork_komplett.fls');
|
||||
await start(page);
|
||||
const { input, text } = await commandLine(page, 'Notebook 1.10');
|
||||
await input.fill('mkdir ordner');
|
||||
await input.press('Enter');
|
||||
await input.fill('ls');
|
||||
await input.press('Enter');
|
||||
await expect(text).toContainText('ordner');
|
||||
await designButton(page).click();
|
||||
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'design');
|
||||
await start(page);
|
||||
await input.fill('cls');
|
||||
await input.press('Enter');
|
||||
await input.fill('ls');
|
||||
await input.press('Enter');
|
||||
await expect(text).toContainText('\n/> ls\n');
|
||||
await expect(text).toContainText('ordner');
|
||||
// Removed in the run, it is gone from the project too.
|
||||
await input.fill('rm ordner');
|
||||
await input.press('Enter');
|
||||
await designButton(page).click();
|
||||
await start(page);
|
||||
await input.fill('cls');
|
||||
await input.press('Enter');
|
||||
await input.fill('ls');
|
||||
await input.press('Enter');
|
||||
await expect(text).toContainText('\n/> ls\n');
|
||||
await expect(text).not.toContainText('ordner');
|
||||
});
|
||||
|
||||
test("installs and removes programs as Java's installation does, and a program installed runs at once", async ({ page }) => {
|
||||
await openEditor(page, server.base, 'internetwork_komplett.fls');
|
||||
const id = await deviceNamed(page, 'Rechner 3.10');
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
// /mailserver/konten.txt at once), and an e-mail program's inbox and sent
|
||||
// mails.
|
||||
|
||||
import { portSnapshot, topologySnapshot, type DnsServerConfiguration, type EchoServerSnapshot, type EmailServerSnapshot, type Id, type WebServerSnapshot, type TopologySnapshot } from '@mdg-filius/core';
|
||||
import { portSnapshot, topologySnapshot, type DnsServerConfiguration, type EchoServerSnapshot, type EmailServerConfiguration, type EmailServerSnapshot, type Id, type WebServerSnapshot, type TopologySnapshot } from '@mdg-filius/core';
|
||||
import type { ImportedProject } from './importProject.js';
|
||||
|
||||
export function projectSnapshot(project: ImportedProject): TopologySnapshot {
|
||||
@@ -72,18 +72,23 @@ export function projectSnapshot(project: ImportedProject): TopologySnapshot {
|
||||
});
|
||||
}
|
||||
|
||||
/** An e-mail server's settings as a run left them: its domain and accounts, the next message's number after their mails. */
|
||||
export function emailServerAfterRun(configuration: EmailServerConfiguration, server: EmailServerSnapshot): EmailServerConfiguration {
|
||||
const messages = server.accounts.reduce((count, account) => count + account.mailbox.length, 0);
|
||||
return { ...configuration, domain: server.domain, accounts: server.accounts, nextMessageID: messages + 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* The project with what the simulation changed that Java keeps, as the run's
|
||||
* snapshot holds it: the firewalls, the e-mail servers' mailboxes, and the
|
||||
* e-mail programs' inboxes and sent mails.
|
||||
* snapshot holds it: the firewalls, the e-mail servers' domains and
|
||||
* mailboxes, and the e-mail programs' accounts, inboxes and sent mails.
|
||||
*/
|
||||
export function projectWithSimulationChanges(project: ImportedProject, snapshot: TopologySnapshot): ImportedProject {
|
||||
const emailServers = new Map(project.emailServerConfigurations);
|
||||
for (const [nodeId, server] of snapshot.emailServersByNodeId) {
|
||||
const configuration = emailServers.get(nodeId);
|
||||
if (configuration === undefined) continue;
|
||||
const messages = server.accounts.reduce((count, account) => count + account.mailbox.length, 0);
|
||||
emailServers.set(nodeId, { ...configuration, accounts: server.accounts, nextMessageID: messages + 1 });
|
||||
emailServers.set(nodeId, emailServerAfterRun(configuration, server));
|
||||
}
|
||||
const emailClients = new Map(project.emailClientConfigurations);
|
||||
for (const [nodeId, client] of snapshot.emailClientsByNodeId) {
|
||||
|
||||
@@ -27,6 +27,12 @@
|
||||
// installed or removed on the desktop (Java's software installation) is
|
||||
// installed or removed in the running engine at once, as Java starts or ends
|
||||
// it; any other change comes in at the next start.
|
||||
//
|
||||
// What a run leaves that Java's project keeps (files, which servers run,
|
||||
// accounts, mails, a DHCP client's address …) goes back into the document,
|
||||
// as `origin: 'sim'`: at most once a second while it runs, after each action
|
||||
// in a window and when it stops (writeRunState). So the next start, and every
|
||||
// client, sees it — as Java's project saved after a run holds it.
|
||||
|
||||
import * as Y from 'yjs';
|
||||
import {
|
||||
@@ -44,7 +50,7 @@ import {
|
||||
type PageLanguage,
|
||||
type TopologySnapshot,
|
||||
} from '@mdg-filius/core';
|
||||
import { docSnapshot, installedSoftware, ROOTS } from '@mdg-filius/doc';
|
||||
import { docSnapshot, emailServerAfterRun, fromY, installedSoftware, ROOTS, SIM_ORIGIN, writeRunState, type NodeRunState } from '@mdg-filius/doc';
|
||||
import {
|
||||
parseClientMessage,
|
||||
SIM_PROTOCOL_VERSION,
|
||||
@@ -64,6 +70,8 @@ import { BrowserWindow } from './browserWindow.js';
|
||||
import { TerminalWindow } from './terminalWindow.js';
|
||||
|
||||
export const TICK_MILLISECONDS = 200;
|
||||
/** How often a running simulation writes what it left into the document. */
|
||||
export const WRITE_BACK_MILLISECONDS = 1_000;
|
||||
export const MAXIMUM_CATCH_UP_MILLISECONDS = 2_000;
|
||||
|
||||
export interface RoomOptions {
|
||||
@@ -71,6 +79,8 @@ export interface RoomOptions {
|
||||
readonly now: () => number;
|
||||
/** A seed for each start. */
|
||||
readonly seed: () => number;
|
||||
/** How often a running simulation writes what it left into the document; WRITE_BACK_MILLISECONDS by default. */
|
||||
readonly writeBackMilliseconds?: number;
|
||||
}
|
||||
|
||||
interface Client {
|
||||
@@ -111,6 +121,10 @@ export class SimulationRoom {
|
||||
private readonly browserWindows = new Map<Id, BrowserWindow>();
|
||||
/** The language of the last start (the pages the devices write themselves). */
|
||||
private language: PageLanguage | undefined;
|
||||
/** Each node's file system and its revision when it was last written back. */
|
||||
private writtenFiles = new Map<Id, { readonly files: object; readonly revision: number }>();
|
||||
/** The wall clock's time of the last write-back. */
|
||||
private lastWriteBack = 0;
|
||||
/** Each computer's programs as the running engine has them. */
|
||||
private software = new Map<Id, readonly InstallableProgram[]>();
|
||||
|
||||
@@ -157,6 +171,56 @@ export class SimulationRoom {
|
||||
this.flush([...this.clients.values()], false);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the run left of each node that Java's project keeps: its files (when
|
||||
* they changed since the last write), whether its servers run, a web
|
||||
* server's virtual hosts, an e-mail server's domain and accounts, an
|
||||
* e-mail program's account and folders, a firewall, a DHCP client's address.
|
||||
*/
|
||||
private runState(): Map<Id, NodeRunState> {
|
||||
const snapshot = this.engine.state.topologySnapshot;
|
||||
const states = new Map<Id, NodeRunState>();
|
||||
const nodes = this.doc.getMap<Y.Map<unknown>>(ROOTS.nodes);
|
||||
for (const { id } of snapshot.nodes) {
|
||||
const node = nodes.get(id);
|
||||
if (node === undefined) continue;
|
||||
const setting = (key: string) => fromY((node.get('settings') as Y.Map<unknown>).get(key));
|
||||
const active: NonNullable<NodeRunState['active']> = {};
|
||||
const settings: Record<string, unknown> = {};
|
||||
const web = this.engine.webServer(id);
|
||||
const webSettings = snapshot.webServersByNodeId.get(id);
|
||||
if (web !== undefined && webSettings !== undefined) {
|
||||
active.webServer = web.isActive;
|
||||
settings.webServer = { port: web.port, documentRoot: webSettings.documentRoot, virtualHosts: web.useVirtualHosts };
|
||||
}
|
||||
const mailServer = this.engine.emailServer(id);
|
||||
const mailServerSettings = snapshot.emailServersByNodeId.get(id);
|
||||
const configured = setting('emailServer');
|
||||
if (mailServer !== undefined && mailServerSettings !== undefined && configured !== undefined) {
|
||||
active.emailServer = mailServer.isActive;
|
||||
settings.emailServer = emailServerAfterRun(configured as never, mailServerSettings);
|
||||
}
|
||||
const echo = this.engine.echoServer(id);
|
||||
if (echo !== undefined) active.echoServer = echo.isActive;
|
||||
settings.emailClient = snapshot.emailClientsByNodeId.get(id);
|
||||
settings.firewall = snapshot.firewallConfigurationsByNodeId.get(id);
|
||||
if (snapshot.dhcpClientConfigurationsByNodeId.get(id)?.isEnabled === true) settings.device = snapshot.deviceConfigurations.get(id);
|
||||
const files = snapshot.fileSystemsByNodeId.get(id);
|
||||
const written = this.writtenFiles.get(id);
|
||||
const changed = files !== undefined && (written?.files !== files || written.revision !== files.revision);
|
||||
if (files !== undefined && changed) this.writtenFiles.set(id, { files, revision: files.revision });
|
||||
states.set(id, { ...(changed ? { files } : {}), active, settings });
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
/** Writes what the run left into the document (origin 'sim'). */
|
||||
private writeBack(): void {
|
||||
if (this.engine.state.phase !== 'running') return;
|
||||
this.lastWriteBack = this.options.now();
|
||||
writeRunState(this.doc, this.runState(), SIM_ORIGIN);
|
||||
}
|
||||
|
||||
/** The document as the engine takes it, in the language of the start. */
|
||||
private snapshot(): TopologySnapshot {
|
||||
const snapshot = docSnapshot(this.doc);
|
||||
@@ -246,6 +310,7 @@ export class SimulationRoom {
|
||||
case 'input': {
|
||||
let failure: ErrorCode | undefined = 'engineFailed';
|
||||
if (!this.guarded(() => (failure = this.act(input)))) return;
|
||||
if (failure === undefined) this.guarded(() => this.writeBack());
|
||||
client.send(failure === undefined ? message({ kind: 'ack', reqId: input.reqId }) : message({ kind: 'error', code: failure, reqId: input.reqId }));
|
||||
this.flush([...this.clients.values()], false);
|
||||
return;
|
||||
@@ -272,12 +337,15 @@ export class SimulationRoom {
|
||||
this.syncWindows();
|
||||
this.mode = 'simulation';
|
||||
this.lastWall = this.options.now();
|
||||
this.lastWriteBack = this.lastWall;
|
||||
this.broadcast(this.state());
|
||||
this.flush([...this.clients.values()], false);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.mode === 'design') return;
|
||||
// A failure already stopped the engine and went back to the design.
|
||||
if (!this.guarded(() => this.writeBack())) return;
|
||||
this.engine.handle({ type: 'stop' });
|
||||
this.mode = 'design';
|
||||
this.lastWall = undefined;
|
||||
@@ -295,6 +363,7 @@ export class SimulationRoom {
|
||||
this.lastWall = behind > MAXIMUM_CATCH_UP_MILLISECONDS ? now : (this.lastWall ?? now) + elapsed;
|
||||
if (!this.guarded(() => this.engine.handle({ type: 'advance', toMilliseconds: this.engine.state.currentTimeMilliseconds + elapsed }))) return;
|
||||
this.flush([...this.clients.values()], false);
|
||||
if (now - this.lastWriteBack >= (this.options.writeBackMilliseconds ?? WRITE_BACK_MILLISECONDS)) this.guarded(() => this.writeBack());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -1,9 +1,8 @@
|
||||
// The room's command line window against Java's (tools/java-oracle
|
||||
// TerminalWindowRun): the same keys typed into a computer's window, the same
|
||||
// text, prompt and history after each, over a stop and a start. One
|
||||
// difference is named: what a run made in the files is not yet written back
|
||||
// into the document (F4.6f), so after the restart the folder made before is
|
||||
// gone and the window goes on at the root, where Java's goes on in it.
|
||||
// text, prompt and history after each, over a stop and a start — the
|
||||
// folder made before the stop is in the project afterwards (written back,
|
||||
// F4.6f), and the window goes on in it, as Java's does.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
@@ -59,9 +58,6 @@ function setUp(programs: readonly string[] = ['commandPrompt']) {
|
||||
return { doc, room, send, run, read, got, connect: (listener: (message: HostMessage) => void) => room.connect(listener) };
|
||||
}
|
||||
|
||||
/** Until the files of a run are written back (F4.6f): the folder made before the restart is gone, the window goes on at the root. */
|
||||
const FILES_NOT_KEPT_YET = (text: string) => text.replaceAll('/dir', '/');
|
||||
|
||||
describe("a command line's window", () => {
|
||||
it("shows what Java's window shows, key by key, over a stop and a start", () => {
|
||||
const { send, run, read } = setUp();
|
||||
@@ -70,7 +66,6 @@ describe("a command line's window", () => {
|
||||
run(TICK_MILLISECONDS);
|
||||
let pointer = -1;
|
||||
let input = '';
|
||||
let restartedAt: number | undefined;
|
||||
for (const step of java.steps) {
|
||||
if (step.step.startsWith('enter:')) {
|
||||
send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: step.step.slice('enter:'.length) }, reqId: step.step });
|
||||
@@ -85,13 +80,9 @@ describe("a command line's window", () => {
|
||||
send({ kind: 'stop' });
|
||||
send({ kind: 'start' });
|
||||
run(400);
|
||||
restartedAt = terminalScreen(read()).text.length;
|
||||
}
|
||||
const screen = terminalScreen(read());
|
||||
const expected =
|
||||
restartedAt === undefined
|
||||
? step
|
||||
: { ...step, text: step.text.slice(0, restartedAt) + FILES_NOT_KEPT_YET(step.text.slice(restartedAt)), prompt: FILES_NOT_KEPT_YET(step.prompt) };
|
||||
const expected = step;
|
||||
expect({ text: screen.text, prompt: screen.prompt, promptShown: screen.promptShown, input }, step.step).toEqual({
|
||||
text: expected.text,
|
||||
prompt: expected.prompt,
|
||||
@@ -120,9 +111,9 @@ describe("a command line's window", () => {
|
||||
send({ kind: 'start' });
|
||||
run(400);
|
||||
expect(terminalScreen(read()).prompt).toBe('/docs> ');
|
||||
// The folder gone from the project: the next start goes on at the root.
|
||||
files.delete('/docs');
|
||||
// The folder gone from the project (in the design): the next start goes on at the root.
|
||||
send({ kind: 'stop' });
|
||||
files.delete('/docs');
|
||||
send({ kind: 'start' });
|
||||
run(400);
|
||||
expect(terminalScreen(read()).prompt).toBe('/> ');
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
// 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 { createUndoManager, 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();
|
||||
}
|
||||
};
|
||||
return { doc, room, got, id, act, run, 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user