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 0000000..4151f8f Binary files /dev/null and b/packages/doc/test/fixtures/new-devices.fls differ diff --git a/tools/java-oracle/README.md b/tools/java-oracle/README.md index b4723dd..617d6c0 100644 --- a/tools/java-oracle/README.md +++ b/tools/java-oracle/README.md @@ -1062,3 +1062,36 @@ Gemessen (03.10.2026): dazwischen um wenige Millisekunden je Sekunde: In der E-Mail-Geschichte folgt die Weitergabe gmx → webmail auf herberts Verbindung, ihre Frames liegen bis zu 272 ms hinter dem Kern. + +## Neue Geräte aus der Palette (F4.4a) + +`NewDeviceRun` legt jede Geräteart so an wie Filius' Palette +(`GUIContainer.neuerKnoten`: Konstruktor, beim Vermittlungsrechner die +Schnittstellenzahl seines Dialogs, Beschriftung, `setProperty`, das die +Einstellungen des Geräts öffnet) und speichert sie wie Filius +(`SzenarioVerwaltung.speichern`) nach +`packages/doc/test/fixtures/new-devices.fls`: Rechner, Notebook, Switch, +Vermittlungsrechner mit 2 bis 8 Schnittstellen, Heimrouter, Modem. + +Gemessen (03.10.2026): + +* Rechner und Notebook: eine Karte 192.168.0.10/255.255.255.0, kein + Gateway, kein DNS, leeres Dateisystem, keine Programme. +* Switch: 24 Ports, SSID gewürfelt (sechs Hexziffern). +* Vermittlungsrechner: so viele Karten wie gewählt, alle 192.168.0.10/24, + Firewall aus (Standardregeln), kein Eintrag als Gerät; schließt man den + Dialog, sind es 2. +* Heimrouter: WAN 42.0.0.10/255.0.0.0, LAN 192.168.0.10/24, Firewall aus. +* Modem: ein Port, Port 12345. +* MAC-Adressen gewürfelt. Der Name ist der Typ der Geräteart in der + Sprache der Oberfläche (`Knoten.TYPE`). +* Ablegen: Die Vorschau hängt mittig am Zeiger; nach dem Setzen der + Beschriftung rückt Java den Knopf so, dass das Symbol bleibt, wo die + Vorschau war (`initTextAndUpdateLocation`). +* Kabel (`GUIEvents`): der erste freie Port; am Heimrouter WAN, wenn man + die linke Hälfte des Knopfs trifft, sonst LAN; ein Funkgerät nur an einen + Switch; zwei Geräte nur einmal verbunden. Javas Meldungen: + `guievents_msg1` (kein freier Port), `msg22`/`msg23` (WAN/LAN belegt), + `msg26` (drahtlos nicht möglich), `msg12` (bereits verbunden). +* Ein Wettlauf im Swing-Thread wirft beim Speichern gelegentlich eine + Ausnahme; die Datei wird trotzdem vollständig geschrieben. diff --git a/tools/java-oracle/run.sh b/tools/java-oracle/run.sh index da29917..70ed260 100755 --- a/tools/java-oracle/run.sh +++ b/tools/java-oracle/run.sh @@ -21,7 +21,7 @@ if [ ! -f "$upstream/target/filius.jar" ]; then fi classpath="$upstream/target/filius.jar:$upstream/target/lib/*" mkdir -p "$here/.build" -javac -encoding UTF-8 -d "$here/.build" -cp "$classpath" "$here/src/FlsOracle.java" "$here/src/GetterDefaults.java" "$here/src/IpCheckCases.java" "$here/src/DnsHostsCases.java" "$here/src/EmailFileCases.java" "$here/src/BeansDump.java" "$here/src/Base64Cases.java" "$here/src/ScenarioNetwork.java" "$here/src/DhcpRun.java" "$here/src/TcpRun.java" "$here/src/DnsRun.java" "$here/src/ArpRun.java" "$here/src/RipRun.java" "$here/src/NatRun.java" "$here/src/WlanRun.java" "$here/src/ExampleRun.java" "$here/src/EchoRun.java" "$here/src/WebRun.java" "$here/src/RouterWebRun.java" "$here/src/KonfigCases.java" "$here/src/EmailRun.java" "$here/src/MailCases.java" "$here/src/EmailClientRun.java" "$here/src/TerminalCases.java" "$here/src/TerminalRun.java" "$here/src/FileCases.java" "$here/src/GnutellaRun.java" +javac -encoding UTF-8 -d "$here/.build" -cp "$classpath" "$here/src/FlsOracle.java" "$here/src/GetterDefaults.java" "$here/src/IpCheckCases.java" "$here/src/DnsHostsCases.java" "$here/src/EmailFileCases.java" "$here/src/BeansDump.java" "$here/src/Base64Cases.java" "$here/src/ScenarioNetwork.java" "$here/src/DhcpRun.java" "$here/src/TcpRun.java" "$here/src/DnsRun.java" "$here/src/ArpRun.java" "$here/src/RipRun.java" "$here/src/NatRun.java" "$here/src/WlanRun.java" "$here/src/ExampleRun.java" "$here/src/EchoRun.java" "$here/src/WebRun.java" "$here/src/RouterWebRun.java" "$here/src/KonfigCases.java" "$here/src/EmailRun.java" "$here/src/MailCases.java" "$here/src/EmailClientRun.java" "$here/src/TerminalCases.java" "$here/src/TerminalRun.java" "$here/src/FileCases.java" "$here/src/GnutellaRun.java" "$here/src/NewDeviceRun.java" # FILIUS writes its settings below $HOME; keep them out of the real one. mkdir -p "$here/.build/home" # Removed first: a crashed run must not leave the previous result standing. @@ -321,6 +321,13 @@ for run in pingLan:8000 pingRouted:13000 pingNobody:49000 pingUnreachable:45000 test -s "$output" done +# Every kind of device as FILIUS' palette makes it, saved as FILIUS saves +# (NewDeviceRun): what the editor's new devices must be (packages/doc/test/devices.test.ts). +output="$root/packages/doc/test/fixtures/new-devices.fls" +rm -f "$output" +HOME="$here/.build/home" xvfb-run -a java -cp "$classpath:$here/.build" NewDeviceRun "$output" 2>&1 | 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); + } +}