feat(editor): F4.5a the device sheet for PCs, notebooks, switches and modems

Measured first: the new oracle SheetRun types 77 texts into FILIUS' own
settings panels and records what was marked red, what the device took and
what the field showed afterwards. Java's two judgements disagree (red text
taken, black text refused, any int as a modem port, a refused text dropped
without a word); here one rule decides both (packages/doc fields.ts), a
refused text stays marked in the field, and only what Java's model accepts
reaches the document.

- doc: settings.ts (name, label policy, addresses, IP forwarding, DHCP
  stopping the own DHCP server, LAN/WLAN with the wireless connection,
  switch SSID dropping its wireless hosts, retention time, cloud, modem
  port as its pair), sheet.ts (what the sheet shows)
- fls: modemPair/modemPort in one place
- editor: DeviceSheet beside the surface or below it on a phone, opened
  with the selection; every change one undo step
- not adopted from Java: the name overwritten by the MAC address, the
  access point search depending on unrelated devices

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 11:22:28 +00:00
co-authored by Claude Opus 5.5
parent 806df6baca
commit 9b7ec2fc72
25 changed files with 2667 additions and 73 deletions
+3 -2
View File
@@ -18,6 +18,7 @@
import * as Y from 'yjs';
import {
DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS,
defaultSwitchConfiguration,
firewallConfiguration,
interfaceKey,
@@ -26,7 +27,7 @@ import {
type NodeKind,
type TopologyNode,
} from '@mdg-filius/core';
import type { ImportedProject } from '@mdg-filius/fls';
import { DEFAULT_MODEM_PORT, modemPair, type ImportedProject } from '@mdg-filius/fls';
import { emptyProject, inOrder, nodeEntry, ROOTS } from './projectDoc.js';
/** The kinds of the palette, in Java's order (GUIDesignSidebar.addComponentItemsToSidebar; the cable comes first, a tool). */
@@ -101,7 +102,7 @@ export function newDeviceProject(device: NewDevice): ImportedProject {
project.firewallConfigurationsByNodeId.set(device.id, firewallConfiguration());
break;
case 'remoteLink':
project.remoteLinkConfigurations.set(device.id, { pairIdentifier: 'java-modem-port-12345', latencyMilliseconds: 20, isEnabled: true });
project.remoteLinkConfigurations.set(device.id, { pairIdentifier: modemPair(DEFAULT_MODEM_PORT), latencyMilliseconds: DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS, isEnabled: true });
break;
}
return project;
+69
View File
@@ -0,0 +1,69 @@
// The device sheet's rules for what may be typed into a field: Java's
// EingabenUeberpruefung as FILIUS' settings panels use it
// (JHostKonfiguration, JSwitchKonfiguration, JModemKonfiguration), measured
// with tools/java-oracle's SheetRun (test/sheet.java.json).
//
// Java judges a text twice: while it is typed, the panel colours it red when
// the pattern does not match; when the field loses the focus, the model's
// setter takes it or keeps the old value — and the two disagree. Java takes
// red text ("10..0.0.1", "+10.0.0.1", "-0.0.0.1", Arabic-Indic digits, a
// retention time of "-5"), refuses black text (".0.0.1", a retention time
// past Long's range), and a modem takes any int as its port (0, 65536, -1),
// unchecked. Here one rule decides both: a text is taken exactly when it is
// not marked. What is not taken is not thrown away either (Java puts the old
// value back into the field without a word): the sheet keeps it, marked,
// until it is mended (plan I6).
//
// Spaces around an address or a number are left out (Java refuses
// " 10.0.0.1"; a phone's keyboard puts a space after a word). A name and an
// SSID are taken as typed, spaces included, as Java takes them.
import { IP_ADDRESS_PATTERN, javaIpCheck } from '@mdg-filius/core';
export type FieldRule = 'name' | 'ipAddress' | 'ipAddressOrEmpty' | 'subnetMask' | 'ssid' | 'seconds' | 'port';
/** What a field's text comes to: the value the device takes, or that it takes none. */
export type CheckedField = { readonly value: string } | { readonly invalid: true };
/** EingabenUeberpruefung.musterSubNetz. */
const SUBNET_MASK_PATTERN = /^(0*([1-9]|1?[0-9]{1,2}|2[0-4]?[0-9]|25[0-5]))\.((0*([0-9]|1?[0-9]{1,2}|2[0-4]?[0-9]|25[0-5]))\.){2}(0*([0-9]|1?[0-9]{1,2}|2[0-4]?[0-9]|25[0-5]))$/;
/** EingabenUeberpruefung.musterServiceSetIdentifier: printable ASCII, at least one character. */
const SSID_PATTERN = /^[ -~]+$/;
/** EingabenUeberpruefung.musterPort: 1 to 65535, no leading zero. */
const PORT_PATTERN = /^([1-9]|[1-9][0-9]{1,3}|[1-5][0-9]{1,4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$/;
/** EingabenUeberpruefung.musterNurZahlen, at least one digit (Java's Long.parseLong refuses none). */
const DIGITS_PATTERN = /^[0-9]+$/;
const INVALID: CheckedField = { invalid: true };
/**
* An address as the panel's pattern and the model's IP.ipCheck both accept
* it: neither alone (the pattern lets ".0.0.1" through, ipCheck "10..0.0.1"
* and "+10.0.0.1"); the device keeps ipCheck's form ("010.0.0.1" is 10.0.0.1).
*/
function address(text: string, pattern: RegExp): CheckedField {
const canonical = javaIpCheck(text);
return canonical !== null && pattern.test(text) ? { value: canonical } : INVALID;
}
export function checkField(rule: FieldRule, text: string): CheckedField {
switch (rule) {
case 'name':
return { value: text };
case 'ssid':
return SSID_PATTERN.test(text) ? { value: text } : INVALID;
case 'ipAddress':
return address(text.trim(), IP_ADDRESS_PATTERN);
case 'ipAddressOrEmpty':
return text.trim() === '' ? { value: '' } : address(text.trim(), IP_ADDRESS_PATTERN);
case 'subnetMask':
return address(text.trim(), SUBNET_MASK_PATTERN);
case 'seconds': {
const digits = text.trim();
// In milliseconds the device's number must stay exact.
return DIGITS_PATTERN.test(digits) && Number(digits) * 1000 <= Number.MAX_SAFE_INTEGER ? { value: String(Number(digits)) } : INVALID;
}
case 'port':
return PORT_PATTERN.test(text.trim()) ? { value: text.trim() } : INVALID;
}
}
+3
View File
@@ -1,7 +1,10 @@
// Public surface of the shared document.
export * from './commands.js';
export * from './fields.js';
export * from './janitor.js';
export * from './projectDoc.js';
export * from './settings.js';
export * from './sheet.js';
export * from './snapshot.js';
export * from './values.js';
export * from './view.js';
+198
View File
@@ -0,0 +1,198 @@
// What the device sheet does to the document: a device's settings changed as
// FILIUS' settings panels change them (JHostKonfiguration,
// JSwitchKonfiguration, JModemKonfiguration; measured with SheetRun), each a
// transaction with the person's origin, like the design tools' commands.
//
// A text field's setter takes the text only when the field's rule does
// (fields.ts) and says so; the device keeps its value otherwise. A host's
// wireless card and a switch's SSID move the wireless connections as Java
// moves them: a host joins the first switch whose SSID it names, a switch
// that changes its SSID loses its wireless hosts.
//
// Not adopted from Java: a host that shows its MAC address as its name takes
// that address as its name as soon as another field is taken (SheetRun's
// flow useMacAsNameThenOff); here the name stays what it was. And Java's
// search for the access point (updateWifiConnection) keeps the last switch
// with the SSID it passes until a device that is neither comes after the
// host — which switch depends on unrelated devices; here it is the first.
import * as Y from 'yjs';
import { DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS, defaultSwitchConfiguration, forwardsIPv4ByDefault, type Id, type NodeKind } from '@mdg-filius/core';
import { modemPair } from '@mdg-filius/fls';
import { connect, type ConnectError } from './commands.js';
import { checkField, type CheckedField } from './fields.js';
import { inOrder, ROOTS } from './projectDoc.js';
import { toY } from './values.js';
const nodes = (doc: Y.Doc) => doc.getMap<Y.Map<unknown>>(ROOTS.nodes);
const links = (doc: Y.Doc) => doc.getMap<Y.Map<unknown>>(ROOTS.links);
const settingsOf = (node: Y.Map<unknown>) => node.get('settings') as Y.Map<unknown>;
const kindOf = (node: Y.Map<unknown>) => node.get('kind') as NodeKind;
/** A setting of a device as a map of its fields, made with these fields when the device has none. */
function settingMap(node: Y.Map<unknown>, key: string, fields: Readonly<Record<string, string | number | boolean>>): Y.Map<unknown> {
const settings = settingsOf(node);
let map = settings.get(key) as Y.Map<unknown> | undefined;
if (map === undefined) {
map = toY(fields) as Y.Map<unknown>;
settings.set(key, map);
}
return map;
}
/** Changes a device in one transaction when the text passes the rule; says what it came to. */
function setChecked(doc: Y.Doc, id: Id, checked: CheckedField, change: (node: Y.Map<unknown>, value: string) => void, origin: unknown): CheckedField {
const node = nodes(doc).get(id);
if (node === undefined || 'invalid' in checked) return checked;
doc.transact(() => change(node, checked.value), origin);
return checked;
}
export function renameDevice(doc: Y.Doc, id: Id, name: string, origin: unknown): void {
const node = nodes(doc).get(id);
if (node !== undefined) doc.transact(() => node.set('name', name), origin);
}
/** What a PC or notebook shows as its name (Host.useIPAsName, useMACAsName): its address, its MAC address, both or its name. */
export function setHostLabels(doc: Y.Doc, id: Id, labels: { readonly ipAddress: boolean; readonly macAddress: boolean }, origin: unknown): void {
const node = nodes(doc).get(id);
if (node === undefined) return;
const policy = labels.ipAddress && labels.macAddress ? 'ipAndMAC' : labels.ipAddress ? 'ipAddress' : labels.macAddress ? 'macAddress' : 'manual';
doc.transact(() => node.set('labelPolicy', policy), origin);
}
export type HostAddressField = 'ipAddress' | 'subnetMask' | 'defaultGateway' | 'dnsServer';
const ADDRESS_RULES = { ipAddress: 'ipAddress', subnetMask: 'subnetMask', defaultGateway: 'ipAddressOrEmpty', dnsServer: 'ipAddressOrEmpty' } as const;
/** A PC's or notebook's address, mask, gateway or DNS server (Betriebssystem.setzeIPAdresse …). */
export function setHostAddress(doc: Y.Doc, id: Id, field: HostAddressField, text: string, origin: unknown): CheckedField {
return setChecked(
doc,
id,
checkField(ADDRESS_RULES[field], text),
(node, value) => settingMap(node, 'device', { ipAddress: '192.168.0.10', subnetMask: '255.255.255.0', defaultGateway: '', dnsServer: '' }).set(field, value),
origin,
);
}
/** IP forwarding, kept only where it differs from the kind's (as the project keeps it). */
export function setIpForwarding(doc: Y.Doc, id: Id, on: boolean, origin: unknown): void {
const node = nodes(doc).get(id);
if (node === undefined) return;
doc.transact(() => {
if (on === forwardsIPv4ByDefault(kindOf(node))) settingsOf(node).delete('ipForwarding');
else settingsOf(node).set('ipForwarding', on);
}, origin);
}
/** DHCP for the configuration; switched on, it stops the device's own DHCP server (JHostKonfiguration.aenderungenAnnehmen). */
export function setDhcpClient(doc: Y.Doc, id: Id, on: boolean, origin: unknown): void {
const node = nodes(doc).get(id);
if (node === undefined) return;
doc.transact(() => {
if (!on) {
settingsOf(node).delete('dhcpClient');
return;
}
settingsOf(node).set('dhcpClient', toY({ isEnabled: true }));
(settingsOf(node).get('dhcpServer') as Y.Map<unknown> | undefined)?.set('isActive', false);
}, origin);
}
const wirelessOf = (node: Y.Map<unknown>) => (settingsOf(node).get('wireless') as Y.Map<unknown> | undefined)?.get('isEnabled') === true;
/** The cables of a device to a switch: a wireless host's wireless connections. */
function linksToSwitches(doc: Y.Doc, id: Id): string[] {
return [...links(doc).entries()]
.filter(([, link]) => {
const ends = [link.get('sourceNode') as Id, link.get('targetNode') as Id];
const other = ends[0] === id ? ends[1]! : ends[1] === id ? ends[0]! : undefined;
const node = other === undefined ? undefined : nodes(doc).get(other);
return node !== undefined && kindOf(node) === 'networkSwitch';
})
.map(([linkId]) => linkId);
}
/** Why a host's card cannot become wireless (Java disables the choice): its cable is plugged. */
export type WirelessError = 'cablePlugged' | ConnectError;
/**
* A PC's or notebook's card by cable or wireless, and the SSID of the network
* it joins ('' for none). Java (JHostKonfiguration.updateWifiConnection):
* when either changes, the host leaves its wireless network, then joins the
* first switch with that SSID, when it is wireless and names one. A card
* whose cable is plugged cannot become wireless. The SSID stays when the
* card goes back to the cable, as Java keeps it.
*/
export function setHostWireless(doc: Y.Doc, id: Id, wireless: boolean, ssid: string, linkId: Id, origin: unknown): { readonly link?: Id } | { readonly error: WirelessError } {
const node = nodes(doc).get(id);
if (node === undefined) return { error: 'unknownDevice' };
const before = settingsOf(node).get('wireless') as Y.Map<unknown> | undefined;
const wasWireless = wirelessOf(node);
const plugged = [...links(doc).values()].some((link) => link.get('sourceNode') === id || link.get('targetNode') === id);
if (wireless && !wasWireless && plugged) return { error: 'cablePlugged' };
if (wasWireless === wireless && ((before?.get('ssid') as string | undefined) ?? '') === ssid) return {};
let result: { readonly link?: Id } | { readonly error: WirelessError } = {};
doc.transact(() => {
if (wasWireless) for (const each of linksToSwitches(doc, id)) links(doc).delete(each);
if (!wireless && ssid === '') settingsOf(node).delete('wireless');
else settingsOf(node).set('wireless', toY({ isEnabled: wireless, ssid }));
if (!wireless || ssid === '') return;
const accessPoint = inOrder(nodes(doc)).find(([, candidate]) => kindOf(candidate) === 'networkSwitch' && (settingsOf(candidate).get('switch') as Y.Map<unknown> | undefined)?.get('ssid') === ssid);
if (accessPoint !== undefined) result = connect(doc, { node: id }, { node: accessPoint[0] }, linkId, origin);
}, origin);
return result;
}
const SWITCH_DEFAULTS = (id: Id) => ({ ...defaultSwitchConfiguration(id) });
/** A switch's SSID; a new one drops its wireless hosts (JSwitchKonfiguration.aenderungenAnnehmen). */
export function setSwitchSsid(doc: Y.Doc, id: Id, text: string, origin: unknown): CheckedField {
return setChecked(
doc,
id,
checkField('ssid', text),
(node, value) => {
const setting = settingMap(node, 'switch', SWITCH_DEFAULTS(id));
if (setting.get('ssid') === value) return;
setting.set('ssid', value);
for (const linkId of [...links(doc).keys()]) {
const link = links(doc).get(linkId)!;
const ends = [link.get('sourceNode') as Id, link.get('targetNode') as Id];
const host = ends[0] === id ? ends[1]! : ends[1] === id ? ends[0]! : undefined;
const other = host === undefined ? undefined : nodes(doc).get(host);
if (other !== undefined && (kindOf(other) === 'pc' || kindOf(other) === 'notebook') && wirelessOf(other)) links(doc).delete(linkId);
}
},
origin,
);
}
/** How long a switch keeps a learned MAC address, in seconds as the panel asks for it. */
export function setSwitchRetention(doc: Y.Doc, id: Id, text: string, origin: unknown): CheckedField {
return setChecked(doc, id, checkField('seconds', text), (node, value) => settingMap(node, 'switch', SWITCH_DEFAULTS(id)).set('retentionTimeMilliseconds', Number(value) * 1000), origin);
}
/** A switch drawn as a cloud (Switch.cloud), kept only when true. */
export function setSwitchCloud(doc: Y.Doc, id: Id, cloud: boolean, origin: unknown): void {
const node = nodes(doc).get(id);
if (node === undefined) return;
doc.transact(() => {
const setting = settingMap(node, 'switch', SWITCH_DEFAULTS(id));
if (cloud) setting.set('showsAsCloud', true);
else setting.delete('showsAsCloud');
}, origin);
}
/** A modem's port: the pair it belongs to (two modems on one port are a pair, as the export writes them). */
export function setModemPort(doc: Y.Doc, id: Id, text: string, origin: unknown): CheckedField {
return setChecked(
doc,
id,
checkField('port', text),
(node, value) =>
settingMap(node, 'remoteLink', { pairIdentifier: '', latencyMilliseconds: DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS, isEnabled: true }).set('pairIdentifier', modemPair(Number(value))),
origin,
);
}
+112
View File
@@ -0,0 +1,112 @@
// What the device sheet shows of a device, read straight from the document:
// the fields of FILIUS' settings panel of its kind (JHostKonfiguration,
// JSwitchKonfiguration, JModemKonfiguration). A router's and a home router's
// sheet show their name only, until their own panels follow.
import * as Y from 'yjs';
import { defaultSwitchConfiguration, DEFAULT_SWITCH_RETENTION_MILLISECONDS, forwardsIPv4ByDefault, stableGeneratedMacAddress, type HostLabelPolicy, type NodeKind } from '@mdg-filius/core';
import { modemPort } from '@mdg-filius/fls';
import { inOrder, ROOTS } from './projectDoc.js';
export interface HostSheet {
readonly kind: 'pc' | 'notebook';
readonly name: string;
/** Host.useIPAsName and useMACAsName. */
readonly labels: { readonly ipAddress: boolean; readonly macAddress: boolean };
/** The card's MAC address (the model keeps them in upper case, as Java shows them). */
readonly macAddress: string;
readonly ipAddress: string;
readonly subnetMask: string;
readonly defaultGateway: string;
readonly dnsServer: string;
readonly dhcpClient: boolean;
readonly ipForwarding: boolean;
readonly wireless: boolean;
/** The SSID of the network the card joins, '' for none. */
readonly ssid: string;
/** Java offers the wireless card only while the cable's port is free, or the card is wireless already. */
readonly wirelessPossible: boolean;
/** The switches' SSIDs, in the order of the devices, each once: the networks to choose from. */
readonly networks: readonly string[];
}
export interface SwitchSheet {
readonly kind: 'networkSwitch';
readonly name: string;
readonly ssid: string;
readonly retentionSeconds: number;
readonly cloud: boolean;
}
export interface ModemSheet {
readonly kind: 'remoteLink';
readonly name: string;
readonly port: number;
}
export interface NameSheet {
readonly kind: Exclude<NodeKind, 'pc' | 'notebook' | 'networkSwitch' | 'remoteLink'>;
readonly name: string;
}
export type DeviceSheet = HostSheet | SwitchSheet | ModemSheet | NameSheet;
const settingsOf = (node: Y.Map<unknown>) => node.get('settings') as Y.Map<unknown>;
const setting = (node: Y.Map<unknown>, key: string) => settingsOf(node).get(key) as Y.Map<unknown> | undefined;
/** The sheet of a device, or undefined when the document has no such device. */
export function deviceSheet(doc: Y.Doc, id: string): DeviceSheet | undefined {
const all = doc.getMap<Y.Map<unknown>>(ROOTS.nodes);
const node = all.get(id);
if (node === undefined) return undefined;
const kind = node.get('kind') as NodeKind;
const name = node.get('name') as string;
switch (kind) {
case 'pc':
case 'notebook': {
const device = setting(node, 'device');
const wireless = setting(node, 'wireless');
const port = (node.get('ports') as Y.Array<Y.Map<unknown>>).get(0);
const portId = port?.get('id') as string | undefined;
const plugged = [...doc.getMap<Y.Map<unknown>>(ROOTS.links).values()].some((link) => link.get('sourceNode') === id || link.get('targetNode') === id);
const policy = node.get('labelPolicy') as HostLabelPolicy;
const isWireless = wireless?.get('isEnabled') === true;
const networks = new Set<string>();
for (const [other, candidate] of inOrder(all)) {
if (candidate.get('kind') !== 'networkSwitch') continue;
networks.add((setting(candidate, 'switch')?.get('ssid') as string | undefined) ?? defaultSwitchConfiguration(other).ssid);
}
return {
kind,
name,
labels: { ipAddress: policy === 'ipAddress' || policy === 'ipAndMAC', macAddress: policy === 'macAddress' || policy === 'ipAndMAC' },
macAddress: (port?.get('mac') as string | undefined) ?? (portId === undefined ? '' : stableGeneratedMacAddress(portId)),
ipAddress: (device?.get('ipAddress') as string | undefined) ?? '',
subnetMask: (device?.get('subnetMask') as string | undefined) ?? '',
defaultGateway: (device?.get('defaultGateway') as string | undefined) ?? '',
dnsServer: (device?.get('dnsServer') as string | undefined) ?? '',
dhcpClient: setting(node, 'dhcpClient')?.get('isEnabled') === true,
ipForwarding: (settingsOf(node).get('ipForwarding') as boolean | undefined) ?? forwardsIPv4ByDefault(kind),
wireless: isWireless,
ssid: (wireless?.get('ssid') as string | undefined) ?? '',
wirelessPossible: isWireless || !plugged,
networks: [...networks],
};
}
case 'networkSwitch': {
const config = setting(node, 'switch');
return {
kind,
name,
ssid: (config?.get('ssid') as string | undefined) ?? defaultSwitchConfiguration(id).ssid,
// Whole seconds, as Java's panel shows them (retentionTime / 1000 in long arithmetic).
retentionSeconds: Math.trunc(((config?.get('retentionTimeMilliseconds') as number | undefined) ?? DEFAULT_SWITCH_RETENTION_MILLISECONDS) / 1000),
cloud: config?.get('showsAsCloud') === true,
};
}
case 'remoteLink':
return { kind, name, port: modemPort(setting(node, 'remoteLink')?.get('pairIdentifier') as string | undefined) };
default:
return { kind, name };
}
}
+80
View File
@@ -0,0 +1,80 @@
# Text typed into FILIUS' settings panels (tools/java-oracle SheetRun):
# <device> <field> <text>, the text may be empty; \uXXXX for anything but
# printable ASCII and for spaces at either end.
host name Neuer Name
host name
host name \u0020\u0020PC 1\u0020\u0020
host name \u00c4rger-\u00df
host ip 10.0.0.1
host ip 010.000.000.001
host ip 10..0.0.1
host ip .0.0.1
host ip +10.0.0.1
host ip 256.0.0.1
host ip 10.0.0
host ip 10.0.0.1.
host ip \u002010.0.0.1
host ip 10.0.0.1\u0020
host ip abc
host ip
host ip 0.0.0.0
host ip 1.2.3.4.5
host ip \u0661\u0660.0.0.1
host ip 10.0.0.-1
host ip -0.0.0.1
host ip 255.255.255.255
host mask 255.255.0.0
host mask 255.0.255.0
host mask 0.0.0.0
host mask 255.255.255.255
host mask 256.0.0.0
host mask
host mask 255.255.255
host mask 0255.255.0.0
host mask 1.2.3.4
host mask 255..255.0.0
host gateway
host gateway \u0020\u0020\u0020
host gateway 192.168.0.1
host gateway 192.168.0.256
host gateway abc
host gateway \u0020192.168.0.1
host gateway 192.168..1.1
host gateway 0.0.0.0
host dns
host dns 8.8.8.8
host dns 8.8.8
host dns \u0020
host dns 08.8.8.8
switch name S1
switch name
switch ssid Schule
switch ssid
switch ssid \u0020
switch ssid a b
switch ssid \u00c4
switch ssid ~
switch ssid \u007f
switch ssid \u0020lead
switch retention 60
switch retention 0
switch retention
switch retention -5
switch retention abc
switch retention 1.5
switch retention 007
switch retention 99999999999999999999
switch retention \u0663
switch retention \u002060
modem name M
modem name
modem port 12345
modem port 0
modem port 65535
modem port 65536
modem port -1
modem port abc
modem port
modem port 080
modem port \u002080
modem port 80
+703
View File
@@ -0,0 +1,703 @@
{
"cases": [
{
"device": "host",
"field": "name",
"input": "Neuer Name",
"start": "Rechner",
"marked": false,
"stored": "Neuer Name",
"shown": "Neuer Name"
},
{
"device": "host",
"field": "name",
"input": "",
"start": "Rechner",
"marked": false,
"stored": "",
"shown": ""
},
{
"device": "host",
"field": "name",
"input": " PC 1 ",
"start": "Rechner",
"marked": false,
"stored": " PC 1 ",
"shown": " PC 1 "
},
{
"device": "host",
"field": "name",
"input": "Ärger-ß",
"start": "Rechner",
"marked": false,
"stored": "Ärger-ß",
"shown": "Ärger-ß"
},
{
"device": "host",
"field": "ip",
"input": "10.0.0.1",
"start": "192.168.0.10",
"marked": false,
"stored": "10.0.0.1",
"shown": "10.0.0.1"
},
{
"device": "host",
"field": "ip",
"input": "010.000.000.001",
"start": "192.168.0.10",
"marked": false,
"stored": "10.0.0.1",
"shown": "10.0.0.1"
},
{
"device": "host",
"field": "ip",
"input": "10..0.0.1",
"start": "192.168.0.10",
"marked": true,
"stored": "10.0.0.1",
"shown": "10.0.0.1"
},
{
"device": "host",
"field": "ip",
"input": ".0.0.1",
"start": "192.168.0.10",
"marked": false,
"stored": "192.168.0.10",
"shown": "192.168.0.10"
},
{
"device": "host",
"field": "ip",
"input": "+10.0.0.1",
"start": "192.168.0.10",
"marked": true,
"stored": "10.0.0.1",
"shown": "10.0.0.1"
},
{
"device": "host",
"field": "ip",
"input": "256.0.0.1",
"start": "192.168.0.10",
"marked": true,
"stored": "192.168.0.10",
"shown": "192.168.0.10"
},
{
"device": "host",
"field": "ip",
"input": "10.0.0",
"start": "192.168.0.10",
"marked": true,
"stored": "192.168.0.10",
"shown": "192.168.0.10"
},
{
"device": "host",
"field": "ip",
"input": "10.0.0.1.",
"start": "192.168.0.10",
"marked": true,
"stored": "10.0.0.1",
"shown": "10.0.0.1"
},
{
"device": "host",
"field": "ip",
"input": " 10.0.0.1",
"start": "192.168.0.10",
"marked": true,
"stored": "192.168.0.10",
"shown": "192.168.0.10"
},
{
"device": "host",
"field": "ip",
"input": "10.0.0.1 ",
"start": "192.168.0.10",
"marked": true,
"stored": "192.168.0.10",
"shown": "192.168.0.10"
},
{
"device": "host",
"field": "ip",
"input": "abc",
"start": "192.168.0.10",
"marked": true,
"stored": "192.168.0.10",
"shown": "192.168.0.10"
},
{
"device": "host",
"field": "ip",
"input": "",
"start": "192.168.0.10",
"marked": true,
"stored": "192.168.0.10",
"shown": "192.168.0.10"
},
{
"device": "host",
"field": "ip",
"input": "0.0.0.0",
"start": "192.168.0.10",
"marked": false,
"stored": "0.0.0.0",
"shown": "0.0.0.0"
},
{
"device": "host",
"field": "ip",
"input": "1.2.3.4.5",
"start": "192.168.0.10",
"marked": true,
"stored": "192.168.0.10",
"shown": "192.168.0.10"
},
{
"device": "host",
"field": "ip",
"input": "١٠.0.0.1",
"start": "192.168.0.10",
"marked": true,
"stored": "10.0.0.1",
"shown": "10.0.0.1"
},
{
"device": "host",
"field": "ip",
"input": "10.0.0.-1",
"start": "192.168.0.10",
"marked": true,
"stored": "192.168.0.10",
"shown": "192.168.0.10"
},
{
"device": "host",
"field": "ip",
"input": "-0.0.0.1",
"start": "192.168.0.10",
"marked": true,
"stored": "0.0.0.1",
"shown": "0.0.0.1"
},
{
"device": "host",
"field": "ip",
"input": "255.255.255.255",
"start": "192.168.0.10",
"marked": false,
"stored": "255.255.255.255",
"shown": "255.255.255.255"
},
{
"device": "host",
"field": "mask",
"input": "255.255.0.0",
"start": "255.255.255.0",
"marked": false,
"stored": "255.255.0.0",
"shown": "255.255.0.0"
},
{
"device": "host",
"field": "mask",
"input": "255.0.255.0",
"start": "255.255.255.0",
"marked": false,
"stored": "255.0.255.0",
"shown": "255.0.255.0"
},
{
"device": "host",
"field": "mask",
"input": "0.0.0.0",
"start": "255.255.255.0",
"marked": false,
"stored": "0.0.0.0",
"shown": "0.0.0.0"
},
{
"device": "host",
"field": "mask",
"input": "255.255.255.255",
"start": "255.255.255.0",
"marked": false,
"stored": "255.255.255.255",
"shown": "255.255.255.255"
},
{
"device": "host",
"field": "mask",
"input": "256.0.0.0",
"start": "255.255.255.0",
"marked": true,
"stored": "255.255.255.0",
"shown": "255.255.255.0"
},
{
"device": "host",
"field": "mask",
"input": "",
"start": "255.255.255.0",
"marked": true,
"stored": "255.255.255.0",
"shown": "255.255.255.0"
},
{
"device": "host",
"field": "mask",
"input": "255.255.255",
"start": "255.255.255.0",
"marked": true,
"stored": "255.255.255.0",
"shown": "255.255.255.0"
},
{
"device": "host",
"field": "mask",
"input": "0255.255.0.0",
"start": "255.255.255.0",
"marked": false,
"stored": "255.255.0.0",
"shown": "255.255.0.0"
},
{
"device": "host",
"field": "mask",
"input": "1.2.3.4",
"start": "255.255.255.0",
"marked": false,
"stored": "1.2.3.4",
"shown": "1.2.3.4"
},
{
"device": "host",
"field": "mask",
"input": "255..255.0.0",
"start": "255.255.255.0",
"marked": true,
"stored": "255.255.0.0",
"shown": "255.255.0.0"
},
{
"device": "host",
"field": "gateway",
"input": "",
"start": "192.168.0.254",
"marked": false,
"stored": "",
"shown": ""
},
{
"device": "host",
"field": "gateway",
"input": " ",
"start": "192.168.0.254",
"marked": true,
"stored": "",
"shown": ""
},
{
"device": "host",
"field": "gateway",
"input": "192.168.0.1",
"start": "192.168.0.254",
"marked": false,
"stored": "192.168.0.1",
"shown": "192.168.0.1"
},
{
"device": "host",
"field": "gateway",
"input": "192.168.0.256",
"start": "192.168.0.254",
"marked": true,
"stored": "192.168.0.254",
"shown": "192.168.0.254"
},
{
"device": "host",
"field": "gateway",
"input": "abc",
"start": "192.168.0.254",
"marked": true,
"stored": "192.168.0.254",
"shown": "192.168.0.254"
},
{
"device": "host",
"field": "gateway",
"input": " 192.168.0.1",
"start": "192.168.0.254",
"marked": true,
"stored": "192.168.0.254",
"shown": "192.168.0.254"
},
{
"device": "host",
"field": "gateway",
"input": "192.168..1.1",
"start": "192.168.0.254",
"marked": true,
"stored": "192.168.1.1",
"shown": "192.168.1.1"
},
{
"device": "host",
"field": "gateway",
"input": "0.0.0.0",
"start": "192.168.0.254",
"marked": false,
"stored": "0.0.0.0",
"shown": "0.0.0.0"
},
{
"device": "host",
"field": "dns",
"input": "",
"start": "192.168.0.253",
"marked": false,
"stored": "",
"shown": ""
},
{
"device": "host",
"field": "dns",
"input": "8.8.8.8",
"start": "192.168.0.253",
"marked": false,
"stored": "8.8.8.8",
"shown": "8.8.8.8"
},
{
"device": "host",
"field": "dns",
"input": "8.8.8",
"start": "192.168.0.253",
"marked": true,
"stored": "192.168.0.253",
"shown": "192.168.0.253"
},
{
"device": "host",
"field": "dns",
"input": " ",
"start": "192.168.0.253",
"marked": true,
"stored": "",
"shown": ""
},
{
"device": "host",
"field": "dns",
"input": "08.8.8.8",
"start": "192.168.0.253",
"marked": false,
"stored": "8.8.8.8",
"shown": "8.8.8.8"
},
{
"device": "switch",
"field": "name",
"input": "S1",
"start": "Switch / WLAN",
"marked": false,
"stored": "S1",
"shown": "S1"
},
{
"device": "switch",
"field": "name",
"input": "",
"start": "Switch / WLAN",
"marked": false,
"stored": "",
"shown": ""
},
{
"device": "switch",
"field": "ssid",
"input": "Schule",
"start": "Start",
"marked": false,
"stored": "Schule",
"shown": "Schule"
},
{
"device": "switch",
"field": "ssid",
"input": "",
"start": "Start",
"marked": true,
"stored": "Start",
"shown": "Start"
},
{
"device": "switch",
"field": "ssid",
"input": " ",
"start": "Start",
"marked": false,
"stored": " ",
"shown": " "
},
{
"device": "switch",
"field": "ssid",
"input": "a b",
"start": "Start",
"marked": false,
"stored": "a b",
"shown": "a b"
},
{
"device": "switch",
"field": "ssid",
"input": "Ä",
"start": "Start",
"marked": true,
"stored": "Start",
"shown": "Start"
},
{
"device": "switch",
"field": "ssid",
"input": "~",
"start": "Start",
"marked": false,
"stored": "~",
"shown": "~"
},
{
"device": "switch",
"field": "ssid",
"input": "",
"start": "Start",
"marked": true,
"stored": "Start",
"shown": "Start"
},
{
"device": "switch",
"field": "ssid",
"input": " lead",
"start": "Start",
"marked": false,
"stored": " lead",
"shown": " lead"
},
{
"device": "switch",
"field": "retention",
"input": "60",
"start": "300000",
"marked": false,
"stored": "60000",
"shown": "60"
},
{
"device": "switch",
"field": "retention",
"input": "0",
"start": "300000",
"marked": false,
"stored": "0",
"shown": "0"
},
{
"device": "switch",
"field": "retention",
"input": "",
"start": "300000",
"marked": false,
"stored": "300000",
"shown": "300"
},
{
"device": "switch",
"field": "retention",
"input": "-5",
"start": "300000",
"marked": true,
"stored": "-5000",
"shown": "-5"
},
{
"device": "switch",
"field": "retention",
"input": "abc",
"start": "300000",
"marked": true,
"stored": "300000",
"shown": "300"
},
{
"device": "switch",
"field": "retention",
"input": "1.5",
"start": "300000",
"marked": true,
"stored": "300000",
"shown": "300"
},
{
"device": "switch",
"field": "retention",
"input": "007",
"start": "300000",
"marked": false,
"stored": "7000",
"shown": "7"
},
{
"device": "switch",
"field": "retention",
"input": "99999999999999999999",
"start": "300000",
"marked": false,
"stored": "300000",
"shown": "300"
},
{
"device": "switch",
"field": "retention",
"input": "٣",
"start": "300000",
"marked": true,
"stored": "3000",
"shown": "3"
},
{
"device": "switch",
"field": "retention",
"input": " 60",
"start": "300000",
"marked": true,
"stored": "300000",
"shown": "300"
},
{
"device": "modem",
"field": "name",
"input": "M",
"start": "Modem",
"marked": false,
"stored": "M",
"shown": "M"
},
{
"device": "modem",
"field": "name",
"input": "",
"start": "Modem",
"marked": false,
"stored": "",
"shown": ""
},
{
"device": "modem",
"field": "port",
"input": "12345",
"start": "12345",
"marked": false,
"stored": "12345",
"shown": "12345"
},
{
"device": "modem",
"field": "port",
"input": "0",
"start": "12345",
"marked": false,
"stored": "0",
"shown": "0"
},
{
"device": "modem",
"field": "port",
"input": "65535",
"start": "12345",
"marked": false,
"stored": "65535",
"shown": "65535"
},
{
"device": "modem",
"field": "port",
"input": "65536",
"start": "12345",
"marked": false,
"stored": "65536",
"shown": "65536"
},
{
"device": "modem",
"field": "port",
"input": "-1",
"start": "12345",
"marked": false,
"stored": "-1",
"shown": "-1"
},
{
"device": "modem",
"field": "port",
"input": "abc",
"start": "12345",
"marked": false,
"stored": "12345",
"shown": "12345"
},
{
"device": "modem",
"field": "port",
"input": "",
"start": "12345",
"marked": false,
"stored": "12345",
"shown": "12345"
},
{
"device": "modem",
"field": "port",
"input": "080",
"start": "12345",
"marked": false,
"stored": "80",
"shown": "80"
},
{
"device": "modem",
"field": "port",
"input": " 80",
"start": "12345",
"marked": false,
"stored": "12345",
"shown": "12345"
},
{
"device": "modem",
"field": "port",
"input": "80",
"start": "12345",
"marked": false,
"stored": "80",
"shown": "80"
}
],
"flows": {
"useMacAsNameThenOff": "0E:EE:AA:A5:58:21",
"useIpAsNameThenOff": "PC-A",
"dhcpClientStopsServer": true,
"dhcpClient": true
}
}
+415
View File
@@ -0,0 +1,415 @@
// The device sheet: what a field takes, measured against FILIUS' own settings
// panels (tools/java-oracle's SheetRun typed the texts of sheet.cases.txt
// into them, sheet.java.json), and what its changes do to the document — the
// wireless connections moved as Java moves them, each change one step of
// the person's undo, and everything kept through a saved file.
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 { openProjectArchive, saveProjectArchive } from '@mdg-filius/fls';
import {
addDevice,
checkField,
connect,
createUndoManager,
deviceSheet,
readProject,
renameDevice,
ROOTS,
setDhcpClient,
setHostAddress,
setHostLabels,
setHostWireless,
setIpForwarding,
setModemPort,
setSwitchCloud,
setSwitchRetention,
setSwitchSsid,
topologyView,
type FieldRule,
type HostSheet,
type NewDevice,
} from '../src/index.js';
interface JavaCase {
readonly device: 'host' | 'switch' | 'modem';
readonly field: string;
readonly input: string;
readonly start: string;
readonly marked: boolean;
readonly stored: string;
readonly shown: string;
}
const java = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'sheet.java.json'), 'utf8')) as {
readonly cases: readonly JavaCase[];
readonly flows: { readonly useMacAsNameThenOff: string; readonly useIpAsNameThenOff: string; readonly dhcpClientStopsServer: boolean; readonly dhcpClient: boolean };
};
const RULES: Readonly<Record<string, FieldRule>> = {
'host name': 'name',
'host ip': 'ipAddress',
'host mask': 'subnetMask',
'host gateway': 'ipAddressOrEmpty',
'host dns': 'ipAddressOrEmpty',
'switch name': 'name',
'switch ssid': 'ssid',
'switch retention': 'seconds',
'modem name': 'name',
'modem port': 'port',
};
/** Fields whose text is judged without the spaces around it. */
const TRIMMED = new Set(['host ip', 'host mask', 'host gateway', 'host dns', 'switch retention', 'modem port']);
/** What Java's model holds in the units of the sheet's value: seconds for the retention time. */
const javaValue = (each: JavaCase) => (each.field === 'retention' ? String(Number(each.stored) / 1000) : each.stored);
/** The case Java judged for a text: the trimmed text's own case where spaces are left out. */
function judged(each: JavaCase): JavaCase {
const key = `${each.device} ${each.field}`;
if (!TRIMMED.has(key) || each.input.trim() === each.input) return each;
const twin = java.cases.find((other) => other.device === each.device && other.field === each.field && other.input === each.input.trim());
if (twin === undefined) throw new Error(`no case for ${JSON.stringify(each.input.trim())} (${key})`);
return twin;
}
describe("a field's rule, against FILIUS' settings panels", () => {
it('has a case for every field the sheet checks', () => {
expect(new Set(java.cases.map((each) => `${each.device} ${each.field}`))).toEqual(new Set(Object.keys(RULES)));
expect(java.cases.length).toBeGreaterThan(70);
});
it('takes a text exactly when Java neither marks nor refuses it, as the value Java keeps', () => {
for (const each of java.cases) {
const key = `${each.device} ${each.field}`;
const verdict = judged(each);
// Taken: the value changed, or the text was the value already.
const took = verdict.stored !== verdict.start || verdict.input === verdict.start;
// A name is taken as typed. A modem's port Java never marks: it takes any int; here Java's own rule for ports.
const expected = RULES[key] === 'name' ? true : key === 'modem port' ? took && /^([1-9][0-9]{0,4})$/.test(verdict.input) && Number(verdict.input) <= 65_535 : !verdict.marked && took;
const checked = checkField(RULES[key]!, each.input);
expect('value' in checked, `${key} ${JSON.stringify(each.input)}`).toBe(expected);
if ('value' in checked) expect(checked.value, `${key} ${JSON.stringify(each.input)}`).toBe(RULES[key] === 'name' ? each.input : javaValue(verdict));
}
});
it("names where Java's two judgements disagree, which here are one", () => {
const disagreeing = java.cases.filter((each) => RULES[`${each.device} ${each.field}`] !== 'name' && each.field !== 'port' && each.marked === (each.stored !== each.start));
// Marked red, yet taken; or black, yet refused (the gateway's spaces Java trims to empty).
expect(disagreeing.map((each) => `${each.field} ${JSON.stringify(each.input)} ${each.marked ? 'marked, taken' : 'refused'}`)).toEqual([
'ip "10..0.0.1" marked, taken',
'ip ".0.0.1" refused',
'ip "+10.0.0.1" marked, taken',
'ip "10.0.0.1." marked, taken',
'ip "١٠.0.0.1" marked, taken',
'ip "-0.0.0.1" marked, taken',
'mask "255..255.0.0" marked, taken',
'gateway " " marked, taken',
'gateway "192.168..1.1" marked, taken',
'dns " " marked, taken',
'retention "" refused',
'retention "-5" marked, taken',
'retention "99999999999999999999" refused',
'retention "٣" marked, taken',
]);
const ports = java.cases.filter((each) => each.field === 'port' && each.stored !== each.start);
expect(ports.map((each) => each.input)).toEqual(['0', '65535', '65536', '-1', '080', '80']);
});
it("shows what the device holds once a field is left, as Java's field does", () => {
for (const each of java.cases) if (each.field !== 'retention') expect(each.shown, each.input).toBe(each.stored);
});
});
let ids = 0;
const newId = () => `20000000-0000-0000-0000-${(++ids).toString(16).padStart(12, '0')}`;
function add(doc: Y.Doc, kind: NewDevice['kind'], name: string = kind): string {
const id = newId();
addDevice(doc, { kind, id, name, x: 100, y: 100, newId }, 'me');
return id;
}
const host = (doc: Y.Doc, id: string) => deviceSheet(doc, id) as HostSheet;
describe("a PC's sheet", () => {
it("shows a new PC as Java's panel does", () => {
const doc = new Y.Doc();
const pc = add(doc, 'pc', 'Rechner');
const sheet = host(doc, pc);
expect({ ...sheet, macAddress: undefined }).toEqual({
kind: 'pc',
name: 'Rechner',
labels: { ipAddress: false, macAddress: false },
macAddress: undefined,
ipAddress: '192.168.0.10',
subnetMask: '255.255.255.0',
defaultGateway: '',
dnsServer: '',
dhcpClient: false,
ipForwarding: false,
wireless: false,
ssid: '',
wirelessPossible: true,
networks: [],
});
expect(sheet.macAddress).toMatch(/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/);
expect(deviceSheet(doc, 'nobody')).toBeUndefined();
});
it('takes an address as Java keeps it, keeps the old one for a marked text, and says which', () => {
const doc = new Y.Doc();
const pc = add(doc, 'pc');
expect(setHostAddress(doc, pc, 'ipAddress', ' 010.000.000.001 ', 'me')).toEqual({ value: '10.0.0.1' });
expect(setHostAddress(doc, pc, 'ipAddress', '10.0.0.256', 'me')).toEqual({ invalid: true });
expect(setHostAddress(doc, pc, 'subnetMask', '255.0.255.0', 'me')).toEqual({ value: '255.0.255.0' });
expect(setHostAddress(doc, pc, 'defaultGateway', '10.0.0.254', 'me')).toEqual({ value: '10.0.0.254' });
expect(setHostAddress(doc, pc, 'dnsServer', '8.8.8', 'me')).toEqual({ invalid: true });
expect(setHostAddress(doc, pc, 'dnsServer', '8.8.8.8', 'me')).toEqual({ value: '8.8.8.8' });
expect(readProject(doc).deviceConfigurations.get(pc)).toEqual({ ipAddress: '10.0.0.1', subnetMask: '255.0.255.0', defaultGateway: '10.0.0.254', dnsServer: '8.8.8.8' });
expect(setHostAddress(doc, pc, 'defaultGateway', '', 'me')).toEqual({ value: '' });
expect(host(doc, pc).defaultGateway).toBe('');
expect(setHostAddress(doc, 'nobody', 'ipAddress', '10.0.0.1', 'me')).toEqual({ value: '10.0.0.1' });
});
it('is renamed, and shows its address, its MAC address or both as its name', () => {
const doc = new Y.Doc();
const pc = add(doc, 'pc');
renameDevice(doc, pc, 'PC-A', 'me');
setHostLabels(doc, pc, { ipAddress: true, macAddress: false }, 'me');
expect(topologyView(doc).nodes[0]!.label).toBe('192.168.0.10');
setHostLabels(doc, pc, { ipAddress: false, macAddress: true }, 'me');
expect(topologyView(doc).nodes[0]!.label).toBe(host(doc, pc).macAddress);
setHostLabels(doc, pc, { ipAddress: true, macAddress: true }, 'me');
expect(host(doc, pc).labels).toEqual({ ipAddress: true, macAddress: true });
expect(topologyView(doc).nodes[0]!.label).toBe(`192.168.0.10 (${host(doc, pc).macAddress})`);
setHostLabels(doc, pc, { ipAddress: false, macAddress: false }, 'me');
expect(topologyView(doc).nodes[0]!.label).toBe('PC-A');
});
it("keeps its name while it shows its MAC address and another field is taken (Java's panel overwrites it)", () => {
// Java: the name became the MAC address; with the address shown instead, it stayed.
expect(java.flows.useMacAsNameThenOff).toMatch(/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/);
expect(java.flows.useIpAsNameThenOff).toBe('PC-A');
const doc = new Y.Doc();
const pc = add(doc, 'pc', 'PC-A');
setHostLabels(doc, pc, { ipAddress: false, macAddress: true }, 'me');
setHostAddress(doc, pc, 'defaultGateway', '192.168.0.1', 'me');
setHostLabels(doc, pc, { ipAddress: false, macAddress: false }, 'me');
expect(host(doc, pc).name).toBe('PC-A');
});
it("keeps IP forwarding only where it differs from the kind's", () => {
const doc = new Y.Doc();
const pc = add(doc, 'pc');
const router = add(doc, 'router');
setIpForwarding(doc, pc, true, 'me');
setIpForwarding(doc, router, false, 'me');
expect(host(doc, pc).ipForwarding).toBe(true);
expect(readProject(doc).ipForwardingByNodeId).toEqual(
new Map([
[pc, true],
[router, false],
]),
);
setIpForwarding(doc, pc, false, 'me');
setIpForwarding(doc, router, true, 'me');
expect(readProject(doc).ipForwardingByNodeId).toEqual(new Map());
});
it("uses DHCP, which stops its own DHCP server, as Java's panel does", () => {
expect(java.flows).toMatchObject({ dhcpClient: true, dhcpClientStopsServer: true });
const doc = new Y.Doc();
const pc = add(doc, 'pc');
const settings = doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get(pc)!.get('settings') as Y.Map<unknown>;
const server = new Y.Map<unknown>();
server.set('isActive', true);
settings.set('dhcpServer', server);
setDhcpClient(doc, pc, true, 'me');
expect(host(doc, pc).dhcpClient).toBe(true);
expect(readProject(doc).dhcpClientConfigurations.get(pc)).toEqual({ isEnabled: true });
expect(server.get('isActive')).toBe(false);
setDhcpClient(doc, pc, false, 'me');
expect(host(doc, pc).dhcpClient).toBe(false);
expect(readProject(doc).dhcpClientConfigurations.has(pc)).toBe(false);
});
});
describe('a wireless card', () => {
/** Two switches with their SSIDs and a notebook. */
function wlan() {
const doc = new Y.Doc();
const first = add(doc, 'networkSwitch');
const second = add(doc, 'networkSwitch');
const notebook = add(doc, 'notebook');
setSwitchSsid(doc, first, 'Schule', 'me');
setSwitchSsid(doc, second, 'Gast', 'me');
const ends = () => readProject(doc).graph.links.map((link) => [link.sourceNodeId, link.targetNodeId]);
return { doc, first, second, notebook, ends };
}
it('offers the switches’ networks, each once', () => {
const { doc, notebook } = wlan();
const third = add(doc, 'networkSwitch');
setSwitchSsid(doc, third, 'Schule', 'me');
expect(host(doc, notebook).networks).toEqual(['Schule', 'Gast']);
});
it('joins the first switch with its SSID, moves with the SSID and leaves on the cable', () => {
const { doc, first, second, notebook, ends } = wlan();
expect(setHostWireless(doc, notebook, true, 'Schule', 'link-1', 'me')).toEqual({ link: 'link-1' });
expect(ends()).toEqual([[notebook, first]]);
expect(topologyView(doc).links[0]!.wireless).toBe(true);
expect(host(doc, notebook)).toMatchObject({ wireless: true, ssid: 'Schule', wirelessPossible: true });
expect(setHostWireless(doc, notebook, true, 'Gast', 'link-2', 'me')).toEqual({ link: 'link-2' });
expect(ends()).toEqual([[notebook, second]]);
// The same again changes nothing.
expect(setHostWireless(doc, notebook, true, 'Gast', 'link-3', 'me')).toEqual({});
expect(ends()).toEqual([[notebook, second]]);
expect(setHostWireless(doc, notebook, false, 'Gast', 'link-4', 'me')).toEqual({});
expect(ends()).toEqual([]);
// Java keeps the SSID with the cable.
expect(readProject(doc).hostWirelessConfigurations.get(notebook)).toEqual({ isEnabled: false, ssid: 'Gast' });
expect(setHostWireless(doc, notebook, false, '', 'link-5', 'me')).toEqual({});
expect(readProject(doc).hostWirelessConfigurations.has(notebook)).toBe(false);
});
it('joins the first of two switches with its SSID', () => {
const { doc, second, notebook, ends } = wlan();
const third = add(doc, 'networkSwitch');
setSwitchSsid(doc, third, 'Gast', 'me');
expect(setHostWireless(doc, notebook, true, 'Gast', 'link-1', 'me')).toEqual({ link: 'link-1' });
expect(ends()).toEqual([[notebook, second]]);
});
it('joins no network without an SSID or without a switch that has it', () => {
const { doc, notebook, ends } = wlan();
expect(setHostWireless(doc, notebook, true, '', 'link-1', 'me')).toEqual({});
expect(setHostWireless(doc, notebook, true, 'Nirgends', 'link-2', 'me')).toEqual({});
expect(ends()).toEqual([]);
expect(host(doc, notebook)).toMatchObject({ wireless: true, ssid: 'Nirgends' });
});
it('cannot be chosen while its cable is plugged, and says why a full switch refuses it', () => {
const { doc, first, notebook } = wlan();
const pc = add(doc, 'pc');
connect(doc, { node: notebook }, { node: pc }, 'cable', 'me');
expect(host(doc, notebook).wirelessPossible).toBe(false);
expect(setHostWireless(doc, notebook, true, 'Schule', 'link-1', 'me')).toEqual({ error: 'cablePlugged' });
expect(host(doc, notebook).wireless).toBe(false);
const other = add(doc, 'notebook');
const full = doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get(first)!;
(full.get('ports') as Y.Array<Y.Map<unknown>>).delete(1, 23);
expect(connect(doc, { node: add(doc, 'pc') }, { node: first }, 'filled', 'me')).toEqual({ link: 'filled' });
expect(setHostWireless(doc, other, true, 'Schule', 'link-2', 'me')).toEqual({ error: 'noFreePort' });
expect(setHostWireless(doc, 'nobody', true, 'Schule', 'link-3', 'me')).toEqual({ error: 'unknownDevice' });
});
it("is dropped by a switch that changes its SSID, a cable to it staying (Java's panel)", () => {
const { doc, first, notebook, ends } = wlan();
const pc = add(doc, 'pc');
setHostWireless(doc, notebook, true, 'Schule', 'link-1', 'me');
connect(doc, { node: pc }, { node: first }, 'cable', 'me');
expect(setSwitchSsid(doc, first, 'Schule', 'me')).toEqual({ value: 'Schule' });
expect(ends()).toHaveLength(2);
expect(setSwitchSsid(doc, first, 'Ä', 'me')).toEqual({ invalid: true });
expect(ends()).toHaveLength(2);
expect(setSwitchSsid(doc, first, 'Neu', 'me')).toEqual({ value: 'Neu' });
expect(ends()).toEqual([[pc, first]]);
});
});
describe("a switch's and a modem's sheet", () => {
it('shows and keeps the SSID, the retention time in seconds and the cloud', () => {
const doc = new Y.Doc();
const hub = add(doc, 'networkSwitch', 'Switch');
expect(deviceSheet(doc, hub)).toEqual({ kind: 'networkSwitch', name: 'Switch', ssid: hub.replaceAll('-', '').slice(0, 6), retentionSeconds: 300, cloud: false });
expect(setSwitchRetention(doc, hub, ' 007 ', 'me')).toEqual({ value: '7' });
expect(setSwitchRetention(doc, hub, '-5', 'me')).toEqual({ invalid: true });
setSwitchCloud(doc, hub, true, 'me');
expect(deviceSheet(doc, hub)).toMatchObject({ retentionSeconds: 7, cloud: true });
expect(topologyView(doc).nodes[0]!.cloud).toBe(true);
expect(readProject(doc).switchConfigurations.get(hub)).toEqual({ ssid: hub.replaceAll('-', '').slice(0, 6), retentionTimeMilliseconds: 7000, showsAsCloud: true });
setSwitchCloud(doc, hub, false, 'me');
expect(readProject(doc).switchConfigurations.get(hub)).toEqual({ ssid: hub.replaceAll('-', '').slice(0, 6), retentionTimeMilliseconds: 7000 });
});
it('shows whole seconds of a retention time a file brings in milliseconds', () => {
const doc = new Y.Doc();
const hub = add(doc, 'networkSwitch');
(doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get(hub)!.get('settings') as Y.Map<Y.Map<unknown>>).get('switch')!.set('retentionTimeMilliseconds', 1999);
expect(deviceSheet(doc, hub)).toMatchObject({ retentionSeconds: 1 });
});
it("pairs a modem by its port, and names the other kinds' sheets", () => {
const doc = new Y.Doc();
const modem = add(doc, 'remoteLink', 'Modem');
expect(deviceSheet(doc, modem)).toEqual({ kind: 'remoteLink', name: 'Modem', port: 12_345 });
expect(setModemPort(doc, modem, '65536', 'me')).toEqual({ invalid: true });
expect(setModemPort(doc, modem, ' 80', 'me')).toEqual({ value: '80' });
expect(readProject(doc).remoteLinkConfigurations.get(modem)).toEqual({ pairIdentifier: 'java-modem-port-80', latencyMilliseconds: 20, isEnabled: true });
expect(deviceSheet(doc, modem)).toMatchObject({ port: 80 });
const router = add(doc, 'router', 'R1');
renameDevice(doc, router, 'R2', 'me');
expect(deviceSheet(doc, router)).toEqual({ kind: 'router', name: 'R2' });
expect(deviceSheet(doc, add(doc, 'gateway', 'Heim'))).toEqual({ kind: 'gateway', name: 'Heim' });
});
});
describe("the sheet's changes", () => {
it('are each one step of the person’s undo, the wireless join with its connection', () => {
const doc = new Y.Doc();
const hub = add(doc, 'networkSwitch');
const notebook = add(doc, 'notebook');
setSwitchSsid(doc, hub, 'Schule', 'setup');
const undo = createUndoManager(doc, 'me');
setHostAddress(doc, notebook, 'ipAddress', '10.0.0.1', 'me');
undo.stopCapturing();
setHostWireless(doc, notebook, true, 'Schule', 'link-1', 'me');
undo.stopCapturing();
renameDevice(doc, notebook, 'NB', 'me');
undo.stopCapturing();
undo.undo();
expect(host(doc, notebook).name).toBe('notebook');
undo.undo();
expect(host(doc, notebook).wireless).toBe(false);
expect(readProject(doc).graph.links).toEqual([]);
undo.undo();
expect(host(doc, notebook).ipAddress).toBe('192.168.0.10');
undo.redo();
undo.redo();
expect(host(doc, notebook)).toMatchObject({ ipAddress: '10.0.0.1', wireless: true });
expect(readProject(doc).graph.links).toHaveLength(1);
});
it('are kept through a saved file', () => {
const doc = new Y.Doc();
const pc = add(doc, 'pc', 'PC-A');
const hub = add(doc, 'networkSwitch', 'S');
const modem = add(doc, 'remoteLink', 'M');
setHostAddress(doc, pc, 'ipAddress', '10.1.2.3', 'me');
setHostAddress(doc, pc, 'subnetMask', '255.255.0.0', 'me');
setHostAddress(doc, pc, 'defaultGateway', '10.1.0.1', 'me');
setHostAddress(doc, pc, 'dnsServer', '10.1.0.2', 'me');
setHostLabels(doc, pc, { ipAddress: true, macAddress: false }, 'me');
setIpForwarding(doc, pc, true, 'me');
setDhcpClient(doc, pc, true, 'me');
setSwitchSsid(doc, hub, 'Schule', 'me');
setSwitchRetention(doc, hub, '42', 'me');
setSwitchCloud(doc, hub, true, 'me');
setModemPort(doc, modem, '4242', 'me');
let counter = 0;
const reopened = openProjectArchive(saveProjectArchive(readProject(doc)).archive, () => `30000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`);
const [pcAgain, hubAgain, modemAgain] = reopened.graph.nodes;
expect(pcAgain!.hostLabelPolicy).toBe('ipAddress');
expect(reopened.deviceConfigurations.get(pcAgain!.id)).toEqual({ ipAddress: '10.1.2.3', subnetMask: '255.255.0.0', defaultGateway: '10.1.0.1', dnsServer: '10.1.0.2' });
expect(reopened.ipForwardingByNodeId.get(pcAgain!.id)).toBe(true);
expect(reopened.dhcpClientConfigurations.get(pcAgain!.id)).toEqual({ isEnabled: true });
expect(reopened.switchConfigurations.get(hubAgain!.id)).toEqual({ ssid: 'Schule', retentionTimeMilliseconds: 42_000, showsAsCloud: true });
expect(reopened.remoteLinkConfigurations.get(modemAgain!.id)?.pairIdentifier).toBe('java-modem-port-4242');
});
});