The palette in Java's order with Java's icons and names: a device dragged onto the surface lands with its icon at the drop point, a tapped one in the middle of the view; a new router asks for 2 to 8 interfaces as Java does. A tap selects, a drag moves (divided by the scale), a long press or the three-dot button opens Java's menu. The cable tool plugs by a drag or by two taps, shows WAN or LAN on a home router's halves and Java's messages when refused. Delete removes the selection; undo and redo by button and Ctrl+Z/Ctrl+Y, each editor with its own origin. Browser tests with mouse and touch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
258 lines
14 KiB
TypeScript
258 lines
14 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, PALETTE, readProject, removeDevice, removeLink, removeLinksOf, 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("comes from a palette in Java's order", () => {
|
|
expect(PALETTE).toEqual(['pc', 'notebook', 'networkSwitch', 'gateway', 'router', 'remoteLink']);
|
|
});
|
|
|
|
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 with all of a device\'s cables, the device staying', () => {
|
|
const { doc, pcA, pcB, hub, router, home } = network();
|
|
connect(doc, { node: pcA.id }, { node: hub.id }, 'link-1', 'me');
|
|
connect(doc, { node: pcB.id }, { node: hub.id }, 'link-2', 'me');
|
|
expect(connect(doc, { node: router.id }, { node: home.id }, 'link-3', 'me')).toEqual({ link: 'link-3' });
|
|
removeLinksOf(doc, hub.id, 'me');
|
|
expect(readProject(doc).graph.links.map((link) => link.id)).toEqual(['link-3']);
|
|
expect(readProject(doc).graph.nodes.some((node) => node.id === hub.id)).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 });
|
|
});
|
|
});
|