feat(doc): F4.4a devices and cables in the document, undo per person; NewDeviceRun

A new device is what Java's palette makes, measured with the new oracle
NewDeviceRun (every kind and router size saved as FILIUS saves). Devices
move to whole pixels and go with their cables; a cable takes the first free
port, a home router's WAN port unless its LAN port is asked for, a wireless
host only joins a switch, two devices join once, each refusal named with
Java's message. Each person's undo takes back their own changes only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 09:18:14 +00:00
co-authored by Claude Opus 5.5
parent 63273916b5
commit 27ab7a1f49
9 changed files with 595 additions and 9 deletions
+228
View File
@@ -0,0 +1,228 @@
// What the editor's design tools do to the document: a new device as FILIUS'
// palette makes it, a device moved or removed, a cable plugged or pulled —
// each a transaction with the person's origin, so that their undo takes back
// their own changes and nobody else's (plan §5.1; Scratch's lesson 2).
//
// A new device is what Java's constructor makes (GUIContainer.neuerKnoten,
// measured with tools/java-oracle's NewDeviceRun): a PC or notebook with
// 192.168.0.10/24 and an empty file system, a switch with 24 ports, a router
// with the interfaces chosen (2 to 8, all 192.168.0.10/24) and its firewall
// off, a home router with WAN 42.0.0.10/8 and LAN 192.168.0.10/24, a modem
// on port 12345. Java rolls the MAC addresses and a switch's SSID; here they
// come from the ids, as for every device without them (stable across saves).
//
// A cable follows Java's rules (GUIEvents, createConnection): a device's
// first free port, a home router's WAN port unless its LAN port is asked
// for (Java: the right half of its button), a wireless host only to a
// switch, two devices joined once.
import * as Y from 'yjs';
import {
defaultSwitchConfiguration,
firewallConfiguration,
interfaceKey,
VirtualFileSystem,
type Id,
type NodeKind,
type TopologyNode,
} from '@mdg-filius/core';
import type { ImportedProject } from '@mdg-filius/fls';
import { emptyProject, inOrder, nodeEntry, ROOTS } from './projectDoc.js';
/** The kinds of the palette, in Java's order (GUIDesignSidebar). */
export const PALETTE: readonly Exclude<NodeKind, 'unsupported'>[] = ['pc', 'notebook', 'networkSwitch', 'router', 'gateway', 'remoteLink'];
/** A router's interfaces, as Java's dialog offers them (2 to 8, 2 when it is closed). */
export const ROUTER_INTERFACES = { minimum: 2, maximum: 8, standard: 2 } as const;
export interface NewDevice {
readonly kind: Exclude<NodeKind, 'unsupported'>;
readonly id: Id;
/** Java's name of the kind in the editor's language (Knoten.TYPE: "Rechner", "Computer" …). */
readonly name: string;
/** The top left corner of its button. */
readonly x: number;
readonly y: number;
/** A router's interfaces. */
readonly interfaces?: number;
/** Makes the ports' ids. */
readonly newId: () => Id;
}
const HOST_INTERFACE = { ipAddress: '192.168.0.10', subnetMask: '255.255.255.0' } as const;
function portLabels(kind: NewDevice['kind'], interfaces: number): string[] {
switch (kind) {
case 'pc':
case 'notebook':
return ['eth0'];
case 'networkSwitch':
return Array.from({ length: 24 }, (_, index) => `sw${index + 1}`);
case 'router':
return Array.from({ length: interfaces }, (_, index) => `rt${index + 1}`);
case 'gateway':
return ['wan0', 'lan0'];
case 'remoteLink':
return ['remote0'];
}
}
/** The new device as a project of its own: Java's constructor's state in the editor's model. */
export function newDeviceProject(device: NewDevice): ImportedProject {
const interfaces = Math.min(ROUTER_INTERFACES.maximum, Math.max(ROUTER_INTERFACES.minimum, Math.trunc(device.interfaces ?? ROUTER_INTERFACES.standard)));
const node: TopologyNode = {
id: device.id,
kind: device.kind,
displayName: device.name,
hostLabelPolicy: 'manual',
position: { x: Math.round(device.x), y: Math.round(device.y) },
ports: portLabels(device.kind, interfaces).map((label) => ({ id: device.newId(), label, isOccupied: false })),
};
const project = emptyProject();
project.graph.nodes.push(node);
const card = (index: number, address: { ipAddress: string; subnetMask: string }) => project.interfaceConfigurations.set(interfaceKey(device.id, node.ports[index]!.id), address);
switch (device.kind) {
case 'pc':
case 'notebook':
project.deviceConfigurations.set(device.id, { ...HOST_INTERFACE, defaultGateway: '', dnsServer: '' });
project.virtualFileSystems.set(device.id, new VirtualFileSystem());
break;
case 'networkSwitch':
project.switchConfigurations.set(device.id, defaultSwitchConfiguration(device.id));
break;
case 'router':
node.ports.forEach((_, index) => card(index, HOST_INTERFACE));
project.firewallConfigurationsByNodeId.set(device.id, firewallConfiguration());
break;
case 'gateway':
card(0, { ipAddress: '42.0.0.10', subnetMask: '255.0.0.0' });
card(1, HOST_INTERFACE);
project.deviceConfigurations.set(device.id, { ipAddress: '42.0.0.10', subnetMask: '255.0.0.0', defaultGateway: '', dnsServer: '' });
project.firewallConfigurationsByNodeId.set(device.id, firewallConfiguration());
break;
case 'remoteLink':
project.remoteLinkConfigurations.set(device.id, { pairIdentifier: 'java-modem-port-12345', latencyMilliseconds: 20, isEnabled: true });
break;
}
return project;
}
const nodes = (doc: Y.Doc) => doc.getMap<Y.Map<unknown>>(ROOTS.nodes);
const links = (doc: Y.Doc) => doc.getMap<Y.Map<unknown>>(ROOTS.links);
/** Adds the device after all others. */
export function addDevice(doc: Y.Doc, device: NewDevice, origin: unknown): void {
const project = newDeviceProject(device);
doc.transact(() => {
const order = Math.max(-1, ...[...nodes(doc).values()].map((node) => node.get('order') as number)) + 1;
nodes(doc).set(device.id, nodeEntry(project.graph.nodes[0]!, order, project));
}, origin);
}
/** Moves a device's button to a place (whole pixels, as Java keeps them). */
export function moveDevice(doc: Y.Doc, id: Id, x: number, y: number, origin: unknown): void {
const node = nodes(doc).get(id);
if (node === undefined) return;
doc.transact(() => {
node.set('x', Math.round(x));
node.set('y', Math.round(y));
}, origin);
}
/** Removes a device with its cables, as Java does. */
export function removeDevice(doc: Y.Doc, id: Id, origin: unknown): void {
doc.transact(() => {
for (const [linkId, link] of [...links(doc).entries()]) if (link.get('sourceNode') === id || link.get('targetNode') === id) links(doc).delete(linkId);
nodes(doc).delete(id);
}, origin);
}
export function removeLink(doc: Y.Doc, id: Id, origin: unknown): void {
doc.transact(() => links(doc).delete(id), origin);
}
/** A cable's end: a device, and the port when one was asked for. */
export interface CableEnd {
readonly node: Id;
readonly port?: Id;
}
/**
* Why a cable cannot be plugged, with Java's message where it has one:
* noFreePort guievents_msg1, wanOccupied guievents_msg22, lanOccupied
* guievents_msg23, wirelessNotPossible guievents_msg26, alreadyConnected
* guievents_msg12.
*/
export type ConnectError = 'unknownDevice' | 'sameDevice' | 'unknownPort' | 'portTaken' | 'noFreePort' | 'wanOccupied' | 'lanOccupied' | 'wirelessNotPossible' | 'alreadyConnected';
/** The ports cables use, as `node/port`. */
function pluggedPorts(doc: Y.Doc): Set<string> {
return new Set([...links(doc).values()].flatMap((link) => [`${link.get('sourceNode')}/${link.get('sourcePort')}`, `${link.get('targetNode')}/${link.get('targetPort')}`]));
}
/** The port a cable takes at an end, or why there is none. */
export function cablePort(doc: Y.Doc, end: CableEnd): { readonly port: Id } | { readonly error: ConnectError } {
const node = nodes(doc).get(end.node);
if (node === undefined) return { error: 'unknownDevice' };
const ports = (node.get('ports') as Y.Array<Y.Map<unknown>>).toArray().map((port) => ({ id: port.get('id') as Id, label: port.get('label') as string }));
const plugged = pluggedPorts(doc);
const free = (port: Id) => !plugged.has(`${end.node}/${port}`);
const kind = node.get('kind') as NodeKind;
if (kind === 'gateway') {
// WAN unless LAN is asked for; each its own message when taken.
const wanted = end.port === undefined ? ports[0] : ports.find((port) => port.id === end.port);
if (wanted === undefined) return { error: 'unknownPort' };
if (free(wanted.id)) return { port: wanted.id };
return { error: wanted.label === 'wan0' ? 'wanOccupied' : wanted.label === 'lan0' ? 'lanOccupied' : 'portTaken' };
}
if (end.port !== undefined) {
if (!ports.some((port) => port.id === end.port)) return { error: 'unknownPort' };
return free(end.port) ? { port: end.port } : { error: 'portTaken' };
}
const first = ports.find((port) => free(port.id));
return first === undefined ? { error: 'noFreePort' } : { port: first.id };
}
/** A host whose card joins a switch's wireless network. */
function wireless(doc: Y.Doc, id: Id): boolean {
const node = nodes(doc).get(id)!;
const kind = node.get('kind') as NodeKind;
const settings = node.get('settings') as Y.Map<Y.Map<unknown>>;
return (kind === 'pc' || kind === 'notebook') && settings.get('wireless')?.get('isEnabled') === true;
}
/** Plugs a cable between two devices, from the first to the second, or says why it cannot. */
export function connect(doc: Y.Doc, from: CableEnd, to: CableEnd, linkId: Id, origin: unknown): { readonly link: Id } | { readonly error: ConnectError } {
if (!nodes(doc).has(from.node) || !nodes(doc).has(to.node)) return { error: 'unknownDevice' };
if (from.node === to.node) return { error: 'sameDevice' };
const source = cablePort(doc, from);
if ('error' in source) return source;
const target = cablePort(doc, to);
if ('error' in target) return target;
const kindOf = (id: Id) => nodes(doc).get(id)!.get('kind') as NodeKind;
if ((wireless(doc, from.node) && kindOf(to.node) !== 'networkSwitch') || (wireless(doc, to.node) && kindOf(from.node) !== 'networkSwitch')) return { error: 'wirelessNotPossible' };
const joined = [...links(doc).values()].some((link) => {
const ends = [link.get('sourceNode'), link.get('targetNode')];
return ends.includes(from.node) && ends.includes(to.node);
});
if (joined) return { error: 'alreadyConnected' };
doc.transact(() => {
const link = new Y.Map<unknown>();
link.set('order', Math.max(-1, ...inOrder(links(doc)).map(([, each]) => each.get('order') as number)) + 1);
link.set('sourceNode', from.node);
link.set('sourcePort', source.port);
link.set('targetNode', to.node);
link.set('targetPort', target.port);
links(doc).set(linkId, link);
}, origin);
return { link: linkId };
}
/**
* A person's undo: their own changes to the devices, cables and
* documentation items, nobody else's. Changes within a short while (a drag)
* are taken back at once.
*/
export function createUndoManager(doc: Y.Doc, origin: unknown): Y.UndoManager {
return new Y.UndoManager([nodes(doc), links(doc), doc.getMap(ROOTS.docs)], { trackedOrigins: new Set([origin]), captureTimeout: 500 });
}
+1
View File
@@ -1,4 +1,5 @@
// Public surface of the shared document.
export * from './commands.js';
export * from './janitor.js';
export * from './projectDoc.js';
export * from './snapshot.js';
+18 -8
View File
@@ -122,7 +122,8 @@ function filesMap(files: VirtualFileSystem): Y.Map<Y.Map<unknown>> {
return map;
}
function nodeMap(node: TopologyNode, order: number, project: ImportedProject): Y.Map<unknown> {
/** A device's entry in the document, with what the project holds for it. */
export function nodeEntry(node: TopologyNode, order: number, project: ImportedProject): Y.Map<unknown> {
const map = new Y.Map<unknown>();
map.set('order', order);
map.set('kind', node.kind);
@@ -200,7 +201,7 @@ export function writeProject(doc: Y.Doc, project: ImportedProject, origin: unkno
meta.set('schema', SCHEMA_VERSION);
meta.set('versionLine', project.versionLine);
const nodes = doc.getMap<Y.Map<unknown>>(ROOTS.nodes);
project.graph.nodes.forEach((node, index) => nodes.set(node.id, nodeMap(node, index, project)));
project.graph.nodes.forEach((node, index) => nodes.set(node.id, nodeEntry(node, index, project)));
const links = doc.getMap<Y.Map<unknown>>(ROOTS.links);
project.graph.links.forEach((link, index) => links.set(link.id, linkMap(link, index)));
const docs = doc.getMap<Y.Map<unknown>>(ROOTS.docs);
@@ -240,11 +241,10 @@ function fileSystemOf(files: Y.Map<Y.Map<unknown>>): VirtualFileSystem {
return fileSystem;
}
/** The project the document holds; its warnings are the reader's and stay with the import. */
export function readProject(doc: Y.Doc): ImportedProject {
const meta = doc.getMap(ROOTS.meta);
const project: ImportedProject = {
versionLine: (meta.get('versionLine') as string | null | undefined) ?? null,
/** A project with nothing in it. */
export function emptyProject(): ImportedProject {
return {
versionLine: null,
graph: { nodes: [], links: [] },
deviceConfigurations: new Map(),
interfaceConfigurations: new Map(),
@@ -269,10 +269,20 @@ export function readProject(doc: Y.Doc): ImportedProject {
echoServerConfigurations: new Map(),
simpleClientConfigurations: new Map(),
documentationItems: [],
archiveEntries: new Map(doc.getMap<Uint8Array>(ROOTS.archive).entries()),
archiveEntries: new Map(),
ownApplicationsByNodeId: new Map(),
warnings: [],
};
}
/** The project the document holds; its warnings are the reader's and stay with the import. */
export function readProject(doc: Y.Doc): ImportedProject {
const meta = doc.getMap(ROOTS.meta);
const project: ImportedProject = {
...emptyProject(),
versionLine: (meta.get('versionLine') as string | null | undefined) ?? null,
archiveEntries: new Map(doc.getMap<Uint8Array>(ROOTS.archive).entries()),
};
for (const [id, map] of inOrder(doc.getMap<Y.Map<unknown>>(ROOTS.nodes))) {
const ports = (map.get('ports') as Y.Array<Y.Map<unknown>>).toArray();
project.graph.nodes.push({
+243
View File
@@ -0,0 +1,243 @@
// The design tools' changes to the document: new devices as Java's palette
// makes them (tools/java-oracle's NewDeviceRun saved them in
// fixtures/new-devices.fls), moving and removing, cables by Java's rules,
// and each person's undo taking back their own changes only.
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 { interfaceKey, NetworkRuntimeEngine, type Id } from '@mdg-filius/core';
import { openProjectArchive, projectSnapshot, type ImportedProject } from '@mdg-filius/fls';
import { addDevice, connect, createUndoManager, docSnapshot, moveDevice, newDeviceProject, readProject, removeDevice, removeLink, repair, ROOTS, topologyView, type NewDevice } from '../src/index.js';
const fixture = join(dirname(fileURLToPath(import.meta.url)), 'fixtures/new-devices.fls');
/** Everything the project holds for one device, as plain values; Java's random MAC addresses and SSID left out. */
function entriesOf(project: ImportedProject, id: Id) {
const node = project.graph.nodes.find((each) => each.id === id)!;
const fields = [
'deviceConfigurations',
'hostWirelessConfigurations',
'manualRoutesByNodeId',
'ripEnabledByNodeId',
'ipForwardingByNodeId',
'firewallConfigurationsByNodeId',
'dhcpClientConfigurations',
'dhcpServerConfigurations',
'portForwardingRowsByNodeId',
'remoteLinkConfigurations',
'webServerConfigurations',
'gnutellaConfigurations',
'dnsServerConfigurations',
'emailClientConfigurations',
'emailServerConfigurations',
'echoServerConfigurations',
'simpleClientConfigurations',
'installedPrograms',
'activePrograms',
'ownApplicationsByNodeId',
] as const;
const switchConfiguration = project.switchConfigurations.get(id);
return {
kind: node.kind,
name: node.displayName,
labelPolicy: node.hostLabelPolicy,
position: node.position,
ports: node.ports.map((port) => [port.label, port.isOccupied]),
interfaces: node.ports.map((port) => project.interfaceConfigurations.get(interfaceKey(id, port.id))),
switch: switchConfiguration === undefined ? undefined : { ...switchConfiguration, ssid: 'rolled' },
files: project.virtualFileSystems.get(id)?.allEntries(),
...Object.fromEntries(fields.map((field) => [field, (project[field] as ReadonlyMap<Id, unknown>).get(id)])),
};
}
function javaDevices(): ImportedProject {
let counter = 0;
return openProjectArchive(new Uint8Array(readFileSync(fixture)), () => `00000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`);
}
let ids = 0;
const newId = () => `10000000-0000-0000-0000-${(++ids).toString(16).padStart(12, '0')}`;
function device(kind: NewDevice['kind'], fields: Partial<NewDevice> = {}): NewDevice {
return { kind, id: newId(), name: kind, x: 100, y: 100, newId, ...fields };
}
describe('a new device', () => {
it("is what Java's palette makes, of every kind and every router size", () => {
const java = javaDevices();
expect(java.graph.nodes.map((node) => node.kind)).toEqual(['pc', 'notebook', 'networkSwitch', ...Array(7).fill('router'), 'gateway', 'remoteLink']);
for (const node of java.graph.nodes) {
const ours = newDeviceProject({ kind: node.kind as NewDevice['kind'], id: node.id, name: node.displayName, x: node.position.x, y: node.position.y, interfaces: node.ports.length, newId });
expect(entriesOf(ours, node.id), `${node.kind} ${node.ports.length}`).toEqual(entriesOf(java, node.id));
}
});
it('keeps a router between 2 and 8 interfaces, 2 when not asked', () => {
const count = (interfaces?: number) => newDeviceProject(device('router', interfaces === undefined ? {} : { interfaces })).graph.nodes[0]!.ports.length;
expect([count(), count(1), count(5), count(12)]).toEqual([2, 2, 5, 8]);
});
it('comes after all others in the document, at whole pixels', () => {
const doc = new Y.Doc();
const first = device('pc', { x: 10.4, y: 20.6 });
// An id below the first one's: the order is the place, not the id.
const second = device('networkSwitch', { id: '00000000-0000-0000-0000-000000000001' });
addDevice(doc, first, 'me');
addDevice(doc, second, 'me');
const project = readProject(doc);
expect(project.graph.nodes.map((node) => node.id)).toEqual([first.id, second.id]);
expect(project.graph.nodes[0]!.position).toEqual({ x: 10, y: 21 });
expect(project.deviceConfigurations.get(first.id)).toEqual({ ipAddress: '192.168.0.10', subnetMask: '255.255.255.0', defaultGateway: '', dnsServer: '' });
moveDevice(doc, first.id, 33.5, 44.49, 'me');
expect(readProject(doc).graph.nodes[0]!.position).toEqual({ x: 34, y: 44 });
});
});
/** A small network built with the tools: two PCs, a switch, a router, a home router, a wireless notebook. */
function network() {
const doc = new Y.Doc();
const [pcA, pcB, hub, router, home, notebook] = [device('pc'), device('pc'), device('networkSwitch'), device('router', { interfaces: 2 }), device('gateway'), device('notebook')];
for (const each of [pcA, pcB, hub, router, home, notebook]) addDevice(doc, each, 'me');
return { doc, pcA, pcB, hub, router, home, notebook, port: (node: NewDevice, index: number) => readProject(doc).graph.nodes.find((each) => each.id === node.id)!.ports[index]!.id };
}
describe('a cable', () => {
it("takes each device's first free port", () => {
const { doc, pcA, pcB, hub, port } = network();
expect(connect(doc, { node: pcA.id }, { node: hub.id }, 'link-1', 'me')).toEqual({ link: 'link-1' });
expect(connect(doc, { node: pcB.id }, { node: hub.id }, 'link-2', 'me')).toEqual({ link: 'link-2' });
expect(readProject(doc).graph.links.map((link) => [link.sourcePortId, link.targetPortId])).toEqual([
[port(pcA, 0), port(hub, 0)],
[port(pcB, 0), port(hub, 1)],
]);
});
it("refuses as Java does: no free port, the same two devices again, a device with itself", () => {
const { doc, pcA, hub, router } = network();
connect(doc, { node: pcA.id }, { node: hub.id }, 'link-1', 'me');
expect(connect(doc, { node: pcA.id }, { node: router.id }, 'link-2', 'me')).toEqual({ error: 'noFreePort' });
expect(connect(doc, { node: hub.id }, { node: pcA.id }, 'link-3', 'me')).toEqual({ error: 'noFreePort' });
expect(connect(doc, { node: router.id }, { node: hub.id }, 'link-4', 'me')).toEqual({ link: 'link-4' });
expect(connect(doc, { node: hub.id }, { node: router.id }, 'link-5', 'me')).toEqual({ error: 'alreadyConnected' });
expect(connect(doc, { node: hub.id }, { node: hub.id }, 'link-6', 'me')).toEqual({ error: 'sameDevice' });
expect(connect(doc, { node: hub.id }, { node: 'nobody' }, 'link-7', 'me')).toEqual({ error: 'unknownDevice' });
expect(readProject(doc).graph.links.map((link) => link.id)).toEqual(['link-1', 'link-4']);
});
it("joins a home router at its WAN port, or at its LAN port when asked, each refused when taken", () => {
const { doc, pcA, pcB, hub, router, home, port } = network();
expect(connect(doc, { node: router.id }, { node: home.id }, 'wan', 'me')).toEqual({ link: 'wan' });
expect(connect(doc, { node: pcA.id }, { node: home.id }, 'again', 'me')).toEqual({ error: 'wanOccupied' });
expect(connect(doc, { node: hub.id }, { node: home.id, port: port(home, 1) }, 'lan', 'me')).toEqual({ link: 'lan' });
expect(connect(doc, { node: pcB.id }, { node: home.id, port: port(home, 1) }, 'again', 'me')).toEqual({ error: 'lanOccupied' });
expect(readProject(doc).graph.links.map((link) => link.targetPortId)).toEqual([port(home, 0), port(home, 1)]);
});
it('takes a port that was asked for, if it is free', () => {
const { doc, pcA, pcB, hub, port } = network();
expect(connect(doc, { node: pcA.id }, { node: hub.id, port: port(hub, 5) }, 'link-1', 'me')).toEqual({ link: 'link-1' });
expect(connect(doc, { node: pcB.id }, { node: hub.id, port: port(hub, 5) }, 'link-2', 'me')).toEqual({ error: 'portTaken' });
expect(connect(doc, { node: pcB.id }, { node: hub.id, port: 'no-port' }, 'link-2', 'me')).toEqual({ error: 'unknownPort' });
});
it('joins a wireless host only to a switch, and draws it as wireless', () => {
const { doc, notebook, hub, router } = network();
const settings = doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get(notebook.id)!.get('settings') as Y.Map<unknown>;
const wireless = new Y.Map<unknown>();
wireless.set('isEnabled', true);
wireless.set('ssid', 'schule');
settings.set('wireless', wireless);
expect(connect(doc, { node: notebook.id }, { node: router.id }, 'link-1', 'me')).toEqual({ error: 'wirelessNotPossible' });
expect(connect(doc, { node: router.id }, { node: notebook.id }, 'link-1', 'me')).toEqual({ error: 'wirelessNotPossible' });
expect(connect(doc, { node: notebook.id }, { node: hub.id }, 'link-1', 'me')).toEqual({ link: 'link-1' });
expect(topologyView(doc).links[0]!.wireless).toBe(true);
});
it('is pulled alone, and with the device it is plugged into', () => {
const { doc, pcA, pcB, hub } = network();
connect(doc, { node: pcA.id }, { node: hub.id }, 'link-1', 'me');
connect(doc, { node: pcB.id }, { node: hub.id }, 'link-2', 'me');
removeLink(doc, 'link-1', 'me');
expect(readProject(doc).graph.links.map((link) => link.id)).toEqual(['link-2']);
removeDevice(doc, hub.id, 'me');
const project = readProject(doc);
expect(project.graph.links).toEqual([]);
expect(project.graph.nodes.some((node) => node.id === hub.id)).toBe(false);
});
});
describe('a network built with the tools', () => {
it('runs in the core: two PCs across a switch ping each other', () => {
const { doc, pcA, pcB, hub } = network();
connect(doc, { node: pcA.id }, { node: hub.id }, 'link-1', 'me');
connect(doc, { node: pcB.id }, { node: hub.id }, 'link-2', 'me');
const address = (node: NewDevice, ip: string) => (doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get(node.id)!.get('settings') as Y.Map<Y.Map<unknown>>).get('device')!.set('ipAddress', ip);
address(pcA, '192.168.0.1');
address(pcB, '192.168.0.2');
expect(docSnapshot(doc)).toEqual(projectSnapshot(readProject(doc)));
const engine = new NetworkRuntimeEngine(1);
engine.handle({ type: 'start', snapshot: docSnapshot(doc), seed: 1, initialTimeMilliseconds: 0 });
expect(engine.sendIcmpEcho(pcA.id, '192.168.0.2').type).toBe('delivered');
});
});
describe("a person's undo", () => {
it('takes back their own changes only, and brings them back', () => {
const mine = new Y.Doc();
const theirs = new Y.Doc();
const exchange = () => {
Y.applyUpdate(theirs, Y.encodeStateAsUpdate(mine, Y.encodeStateVector(theirs)));
Y.applyUpdate(mine, Y.encodeStateAsUpdate(theirs, Y.encodeStateVector(mine)));
};
const undo = createUndoManager(mine, 'me');
const [pc, hub, other] = [device('pc'), device('networkSwitch'), device('notebook')];
addDevice(mine, pc, 'me');
undo.stopCapturing();
addDevice(mine, hub, 'me');
undo.stopCapturing();
exchange();
addDevice(theirs, other, 'them');
connect(theirs, { node: other.id }, { node: hub.id }, 'their-link', 'them');
exchange();
// My last change was the switch: undoing it takes it away. Their cable to it is left without an end,
// which the janitor removes (I1) — on F, not in a client.
undo.undo();
exchange();
const ids = (doc: Y.Doc) => readProject(doc).graph.nodes.map((node) => node.id);
expect(ids(mine)).toEqual([pc.id, other.id]);
expect(ids(theirs)).toEqual([pc.id, other.id]);
expect(repair(theirs).danglingLinks).toEqual(['their-link']);
exchange();
undo.undo();
expect(ids(mine)).toEqual([other.id]);
// Nothing more of mine: their notebook stays.
expect(undo.undo()).toBeNull();
expect(ids(mine)).toEqual([other.id]);
undo.redo();
undo.redo();
expect(new Set(ids(mine))).toEqual(new Set([pc.id, hub.id, other.id]));
});
it('takes back a removed device with its cables, and a drag as one step', () => {
const doc = new Y.Doc();
const [pc, hub] = [device('pc'), device('networkSwitch')];
addDevice(doc, pc, 'me');
addDevice(doc, hub, 'me');
connect(doc, { node: pc.id }, { node: hub.id }, 'link-1', 'me');
const undo = createUndoManager(doc, 'me');
// A drag: many moves within a moment.
for (let step = 1; step <= 10; step += 1) moveDevice(doc, pc.id, 100 + step * 10, 100, 'me');
undo.stopCapturing();
removeDevice(doc, pc.id, 'me');
undo.undo();
const project = readProject(doc);
expect(project.graph.links.map((link) => link.id)).toEqual(['link-1']);
expect(project.graph.nodes.find((node) => node.id === pc.id)!.position).toEqual({ x: 200, y: 100 });
undo.undo();
expect(readProject(doc).graph.nodes.find((node) => node.id === pc.id)!.position).toEqual({ x: 100, y: 100 });
});
});
Binary file not shown.