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:
@@ -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 });
|
||||
});
|
||||
});
|
||||
BIN
Binary file not shown.
Reference in New Issue
Block a user