Files
mdg-filius/packages/doc/test/devices.test.ts
T
julian.kiedaischandClaude Opus 5.5 27ab7a1f49 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>
2026-10-03 09:18:14 +00:00

244 lines
13 KiB
TypeScript

// 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 });
});
});