From 27ab7a1f49b3506e8aa100cf0985893a63977def Mon Sep 17 00:00:00 2001 From: Julian Kiedaisch Date: Sat, 3 Oct 2026 09:18:14 +0000 Subject: [PATCH] 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 --- CHANGES.md | 2 + packages/doc/src/commands.ts | 228 +++++++++++++++++++ packages/doc/src/index.ts | 1 + packages/doc/src/projectDoc.ts | 26 ++- packages/doc/test/devices.test.ts | 243 +++++++++++++++++++++ packages/doc/test/fixtures/new-devices.fls | Bin 0 -> 2759 bytes tools/java-oracle/README.md | 33 +++ tools/java-oracle/run.sh | 9 +- tools/java-oracle/src/NewDeviceRun.java | 62 ++++++ 9 files changed, 595 insertions(+), 9 deletions(-) create mode 100644 packages/doc/src/commands.ts create mode 100644 packages/doc/test/devices.test.ts create mode 100644 packages/doc/test/fixtures/new-devices.fls create mode 100644 tools/java-oracle/src/NewDeviceRun.java diff --git a/CHANGES.md b/CHANGES.md index f503919..939a25d 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -119,6 +119,8 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/janitor.ts` | Plan §5.3 | neu (F4.1): der Hausmeister, I1 (Kabel ins Leere), I2 (zwei Kabel an einem Port: kleinere Kennung bleibt), I4 (fehlender Ordner, ohne Rücksicht auf Groß-/Kleinschreibung); deterministisch, idempotent, Ursprung `janitor` | | `src/snapshot.ts` | — | neu (F4.1): Schnappschuss des Kerns und `.fls`-Export eines Dokuments, beide über das Projekt (`projectSnapshot`, `saveProjectArchive`) | | `src/view.ts` | Java `Host.holeAnzeigeName`; `exportProject.joinsWirelessly` | neu (F4.3): die Zeichensicht ohne Dateisysteme — Geräte mit Javas Beschriftung und Wolke, Kabel mit Funk-Kennung, Doku-Elemente in der Zeichenfolge des Modells | +| `src/commands.ts` | Java `GUIContainer.neuerKnoten`, Konstruktoren der Geräte, `GUIEvents` (Kabel), `InternetKnoten/LokalerKnoten.holeFreienPort`, `Gateway` | neu (F4.4a): neues Gerät mit Javas Voreinstellungen (gegen `NewDeviceRun` gemessen), Verschieben auf ganze Pixel, Löschen samt Kabeln, Kabel nach Javas Regeln (erster freier Port, Heimrouter WAN/LAN, Funk nur an Switch, nur einmal verbunden) mit Javas Meldungen als Gründe, Rückgängig je Person (`Y.UndoManager`, nur der eigene Ursprung) | +| `test/devices.test.ts`, `test/fixtures/new-devices.fls` | — | neu (F4.4a): neue Geräte jeder Art gegen Javas gespeicherte (Zufall maskiert), Kabelregeln, ein selbst gebautes Netz pingt im Kern, Rückgängig nur der eigenen Änderungen über zwei Replikate | | `test/projectDoc.test.ts` | — | neu (F4.1): alle 23 Korpusdateien → Dokument → Yjs-Update → Projekt: dasselbe Projekt, derselbe Schnappschuss, dieselbe `.fls`, dieselbe Reihenfolge je Ordner; Kennungen absichtlich gegen die Dateireihenfolge | | `test/collaboration.test.ts`, `test/values.test.ts` | — | neu (F4.1): zwei Replikate — zwei Felder eines Geräts, zwei neue Zeilen, gemeinsames Tippen; der Hausmeister nach einem Zusammenführen (I1, I2 auf beiden Replikaten gleich, I4 auch in anderer Schreibweise), idempotent ohne Aktualisierung; `long` einer eigenen Anwendung, Binärdateien | diff --git a/packages/doc/src/commands.ts b/packages/doc/src/commands.ts new file mode 100644 index 0000000..bce6649 --- /dev/null +++ b/packages/doc/src/commands.ts @@ -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[] = ['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; + 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>(ROOTS.nodes); +const links = (doc: Y.Doc) => doc.getMap>(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 { + 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>).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>; + 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(); + 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 }); +} diff --git a/packages/doc/src/index.ts b/packages/doc/src/index.ts index 5b7101c..9e7f619 100644 --- a/packages/doc/src/index.ts +++ b/packages/doc/src/index.ts @@ -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'; diff --git a/packages/doc/src/projectDoc.ts b/packages/doc/src/projectDoc.ts index 378457c..9ffc177 100644 --- a/packages/doc/src/projectDoc.ts +++ b/packages/doc/src/projectDoc.ts @@ -122,7 +122,8 @@ function filesMap(files: VirtualFileSystem): Y.Map> { return map; } -function nodeMap(node: TopologyNode, order: number, project: ImportedProject): Y.Map { +/** 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 { const map = new Y.Map(); 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>(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>(ROOTS.links); project.graph.links.forEach((link, index) => links.set(link.id, linkMap(link, index))); const docs = doc.getMap>(ROOTS.docs); @@ -240,11 +241,10 @@ function fileSystemOf(files: Y.Map>): 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(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(ROOTS.archive).entries()), + }; for (const [id, map] of inOrder(doc.getMap>(ROOTS.nodes))) { const ports = (map.get('ports') as Y.Array>).toArray(); project.graph.nodes.push({ diff --git a/packages/doc/test/devices.test.ts b/packages/doc/test/devices.test.ts new file mode 100644 index 0000000..9b0049d --- /dev/null +++ b/packages/doc/test/devices.test.ts @@ -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).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 { + 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>(ROOTS.nodes).get(notebook.id)!.get('settings') as Y.Map; + const wireless = new Y.Map(); + 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>(ROOTS.nodes).get(node.id)!.get('settings') as Y.Map>).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 }); + }); +}); diff --git a/packages/doc/test/fixtures/new-devices.fls b/packages/doc/test/fixtures/new-devices.fls new file mode 100644 index 0000000000000000000000000000000000000000..4151f8f5932c9c1fe8010dc24bb30947a7483a68 GIT binary patch literal 2759 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 | grep -v 'logback\|^$' || true +test -s "$output" + # FILIUS' own examples as Java FILIUS runs them, opened and started as FILIUS # does (ExampleRun): the acceptance of F2 (packages/fls/test/examplesJava.test.ts) # and the classics of F3 (web, email, gnutella; packages/fls/test/classicsJava.test.ts). diff --git a/tools/java-oracle/src/NewDeviceRun.java b/tools/java-oracle/src/NewDeviceRun.java new file mode 100644 index 0000000..7b0ed59 --- /dev/null +++ b/tools/java-oracle/src/NewDeviceRun.java @@ -0,0 +1,62 @@ +// Makes every kind of device as FILIUS' palette makes it +// (GUIContainer.neuerKnoten: the constructor, a router's number of interfaces +// from its dialog, the label, setProperty, which opens the device's settings) +// and saves them in one project, as FILIUS saves (SzenarioVerwaltung.speichern). +// The editor's new devices must be what this file holds once read +// (packages/doc/test/devices.test.ts). +// +// NewDeviceRun +// +// In the file's order: Rechner, Notebook, Switch, Vermittlungsrechner with 2 +// to 8 interfaces, Gateway (Heimrouter), Modem. + +import filius.gui.GUIContainer; +import filius.gui.netzwerksicht.GUIDesignSidebar; +import filius.gui.netzwerksicht.GUIKabelItem; +import filius.gui.netzwerksicht.GUIKnotenItem; +import filius.gui.netzwerksicht.JSidebarButton; +import filius.gui.netzwerksicht.GUIDocuItem; +import filius.hardware.knoten.Gateway; +import filius.hardware.knoten.Knoten; +import filius.hardware.knoten.Modem; +import filius.hardware.knoten.Notebook; +import filius.hardware.knoten.Rechner; +import filius.hardware.knoten.Switch; +import filius.hardware.knoten.Vermittlungsrechner; +import filius.rahmenprogramm.SzenarioVerwaltung; + +import java.util.ArrayList; +import java.util.List; +import javax.swing.ImageIcon; + +public class NewDeviceRun { + static void add(List items, Knoten device, String icon, int x, int y) { + JSidebarButton label = new JSidebarButton(new ImageIcon(NewDeviceRun.class.getResource("/" + icon + ".png")), device.holeHardwareTyp()); + label.setBounds(x, y, label.getWidth(), label.getHeight()); + label.initTextAndUpdateLocation(device.holeAnzeigeName()); + GUIKnotenItem item = new GUIKnotenItem(); + item.setKnoten(device); + item.setImageLabel(label); + GUIContainer.getGUIContainer().setProperty(device); + items.add(item); + } + + public static void main(String[] args) throws Exception { + GUIContainer.getGUIContainer(); + List items = new ArrayList<>(); + add(items, new Rechner(), GUIDesignSidebar.RECHNER, 100, 100); + add(items, new Notebook(), GUIDesignSidebar.NOTEBOOK, 300, 100); + add(items, new Switch(), GUIDesignSidebar.SWITCH, 500, 100); + for (int interfaces = 2; interfaces <= 8; interfaces++) { + Vermittlungsrechner router = new Vermittlungsrechner(); + router.setzeAnzahlAnschluesse(interfaces); + add(items, router, GUIDesignSidebar.VERMITTLUNGSRECHNER, 100 * interfaces, 300); + } + add(items, new Gateway(), GUIDesignSidebar.GATEWAY, 100, 500); + add(items, new Modem(), GUIDesignSidebar.MODEM, 300, 500); + if (!SzenarioVerwaltung.getInstance().speichern(args[0], items, new ArrayList(), new ArrayList())) { + throw new IllegalStateException("FILIUS could not save " + args[0]); + } + System.exit(0); + } +}