feat(editor): F4.5b the device sheet for routers and home routers

Measured first: SheetRun now types into FILIUS' router and home router
panels, records five forwarding tables (holeTabelle) and steps in the
panel's table. Java turns a card address that passes the pattern but not
IP.ipCheck (".0.0.1") into null and the router panel throws; an empty
gateway is marked red yet taken; a refused table cell is stored as null;
a home router's DHCP client leaves its DHCP server running. None of the
nulls is adopted. The core's routeTableRows matches all five tables.

- doc: card addresses (the first card mirrored in the device's settings),
  router gateway, RIP, manual routes (a new row once all four cells pass,
  a cell, removal); the sheet reads cards with what their cable joins and
  the whole table
- editor: Java's tabs (general, each card titled by its address, the
  forwarding table greyed out under RIP; general, LAN, WAN on a home
  router); the open card's cable drawn active in Java's green

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 11:51:03 +00:00
co-authored by Claude Opus 5.5
parent 9b7ec2fc72
commit eff9433673
22 changed files with 2201 additions and 119 deletions
+128 -5
View File
@@ -1,7 +1,9 @@
// 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.
// JSwitchKonfiguration, JModemKonfiguration, JVermittlungsrechnerKonfiguration
// with its JWeiterleitungsTabelle, JGatewayConfiguration; 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
@@ -15,9 +17,12 @@
// 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.
// Nor: an interface address that passes the panel's pattern but not
// IP.ipCheck (".0.0.1") becomes null in Java, and a router's panel then
// throws; a table cell Java refuses is stored as null. Here neither is taken.
import * as Y from 'yjs';
import { DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS, defaultSwitchConfiguration, forwardsIPv4ByDefault, type Id, type NodeKind } from '@mdg-filius/core';
import { DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS, defaultSwitchConfiguration, forwardsIPv4ByDefault, type Id, type ManualRoute, 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';
@@ -86,7 +91,11 @@ export function setIpForwarding(doc: Y.Doc, id: Id, on: boolean, origin: unknown
}, origin);
}
/** DHCP for the configuration; switched on, it stops the device's own DHCP server (JHostKonfiguration.aenderungenAnnehmen). */
/**
* DHCP for the configuration. On a PC or notebook it stops the device's own
* DHCP server (JHostKonfiguration.aenderungenAnnehmen); a home router's
* panel (its WAN card) leaves its server running, as Java does.
*/
export function setDhcpClient(doc: Y.Doc, id: Id, on: boolean, origin: unknown): void {
const node = nodes(doc).get(id);
if (node === undefined) return;
@@ -96,7 +105,7 @@ export function setDhcpClient(doc: Y.Doc, id: Id, on: boolean, origin: unknown):
return;
}
settingsOf(node).set('dhcpClient', toY({ isEnabled: true }));
(settingsOf(node).get('dhcpServer') as Y.Map<unknown> | undefined)?.set('isActive', false);
if (kindOf(node) === 'pc' || kindOf(node) === 'notebook') (settingsOf(node).get('dhcpServer') as Y.Map<unknown> | undefined)?.set('isActive', false);
}, origin);
}
@@ -196,3 +205,117 @@ export function setModemPort(doc: Y.Doc, id: Id, text: string, origin: unknown):
origin,
);
}
const portsOf = (node: Y.Map<unknown>) => node.get('ports') as Y.Array<Y.Map<unknown>>;
/**
* A router's or home router's interface: its address or mask
* (NetzwerkInterface.setIp, setSubnetzMaske). The device's own settings
* mirror its first interface, as the project keeps them (the home router's
* WAN card).
*/
export function setInterfaceAddress(doc: Y.Doc, id: Id, portId: Id, field: 'ipAddress' | 'subnetMask', text: string, origin: unknown): CheckedField {
const node = nodes(doc).get(id);
const ports = node === undefined ? [] : portsOf(node).toArray();
const index = ports.findIndex((port) => port.get('id') === portId);
if (index < 0) return checkField(field, text);
return setChecked(
doc,
id,
checkField(field, text),
(device, value) => {
const port = ports[index]!;
let config = port.get('config') as Y.Map<unknown> | undefined;
if (config === undefined) {
config = toY({ ipAddress: '192.168.0.10', subnetMask: '255.255.255.0' }) as Y.Map<unknown>;
port.set('config', config);
}
config.set(field, value);
if (index === 0) (settingsOf(device).get('device') as Y.Map<unknown> | undefined)?.set(field, value);
},
origin,
);
}
/**
* A router's or home router's standard gateway (setStandardGateway: the
* gateway of every card), empty for none. A router keeps its own settings
* only while it has a gateway, as the project keeps them.
*/
export function setRouterGateway(doc: Y.Doc, id: Id, text: string, origin: unknown): CheckedField {
return setChecked(
doc,
id,
checkField('ipAddressOrEmpty', text),
(node, value) => {
const first = portsOf(node).get(0)?.get('config') as Y.Map<unknown> | undefined;
const device = settingMap(node, 'device', {
ipAddress: (first?.get('ipAddress') as string | undefined) ?? '',
subnetMask: (first?.get('subnetMask') as string | undefined) ?? '',
defaultGateway: '',
dnsServer: '',
});
device.set('defaultGateway', value);
if (kindOf(node) === 'router' && value === '' && device.get('dnsServer') === '') settingsOf(node).delete('device');
},
origin,
);
}
/** RIP on a router, kept only when on (as the project keeps it). */
export function setRip(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).set('rip', true);
else settingsOf(node).delete('rip');
}, origin);
}
/** A column of the forwarding table's manual rows. */
export type RouteColumn = keyof ManualRoute;
export const ROUTE_COLUMNS: readonly RouteColumn[] = ['destinationNetwork', 'subnetMask', 'gateway', 'interfaceIPAddress'];
/** Each column's rule (JWeiterleitungsTabelle: musterIpAdresse, musterSubNetz for the mask). */
const routeRule = (column: RouteColumn) => (column === 'subnetMask' ? 'subnetMask' : 'ipAddress');
function routesOf(node: Y.Map<unknown>): Y.Array<Y.Map<unknown>> {
let routes = settingsOf(node).get('routes') as Y.Array<Y.Map<unknown>> | undefined;
if (routes === undefined) {
routes = new Y.Array<Y.Map<unknown>>();
settingsOf(node).set('routes', routes);
}
return routes;
}
/**
* A new manual row, taken once all four cells pass (Java keeps a new row in
* the table until Weiterleitungstabelle.validate takes it); the columns
* that do not pass otherwise.
*/
export function addRoute(doc: Y.Doc, id: Id, cells: Readonly<Record<RouteColumn, string>>, origin: unknown): { readonly route: ManualRoute } | { readonly invalid: readonly RouteColumn[] } {
const checked = ROUTE_COLUMNS.map((column) => [column, checkField(routeRule(column), cells[column])] as const);
const invalid = checked.filter(([, each]) => 'invalid' in each).map(([column]) => column);
const node = nodes(doc).get(id);
if (invalid.length > 0 || node === undefined) return { invalid };
const route = Object.fromEntries(checked.map(([column, each]) => [column, (each as { value: string }).value])) as unknown as ManualRoute;
doc.transact(() => routesOf(node).push([toY({ ...route }) as Y.Map<unknown>]), origin);
return { route };
}
/** A cell of a manual row (by its place among the manual rows). */
export function setRouteCell(doc: Y.Doc, id: Id, index: number, column: RouteColumn, text: string, origin: unknown): CheckedField {
return setChecked(doc, id, checkField(routeRule(column), text), (node, value) => (settingsOf(node).get('routes') as Y.Array<Y.Map<unknown>> | undefined)?.get(index)?.set(column, value), origin);
}
/** Removes a manual row (by its place among the manual rows); the list goes with its last row, as the project keeps it. */
export function removeRoute(doc: Y.Doc, id: Id, index: number, origin: unknown): void {
const node = nodes(doc).get(id);
const routes = node === undefined ? undefined : (settingsOf(node).get('routes') as Y.Array<Y.Map<unknown>> | undefined);
if (node === undefined || routes === undefined || index < 0 || index >= routes.length) return;
doc.transact(() => {
routes.delete(index, 1);
if (routes.length === 0) settingsOf(node).delete('routes');
}, origin);
}
+102 -6
View File
@@ -1,12 +1,24 @@
// 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.
// JSwitchKonfiguration, JModemKonfiguration, JVermittlungsrechnerKonfiguration
// with its forwarding table, JGatewayConfiguration).
import * as Y from 'yjs';
import { defaultSwitchConfiguration, DEFAULT_SWITCH_RETENTION_MILLISECONDS, forwardsIPv4ByDefault, stableGeneratedMacAddress, type HostLabelPolicy, type NodeKind } from '@mdg-filius/core';
import {
defaultSwitchConfiguration,
DEFAULT_SWITCH_RETENTION_MILLISECONDS,
forwardsIPv4ByDefault,
routeTableRows,
stableGeneratedMacAddress,
type HostLabelPolicy,
type ManualRoute,
type NodeKind,
type RouteRow,
} from '@mdg-filius/core';
import { modemPort } from '@mdg-filius/fls';
import { inOrder, ROOTS } from './projectDoc.js';
import { fromY } from './values.js';
import { nodeLabel } from './view.js';
export interface HostSheet {
readonly kind: 'pc' | 'notebook';
@@ -44,16 +56,73 @@ export interface ModemSheet {
readonly port: number;
}
/** A router's or home router's card. */
export interface InterfaceSheet {
readonly portId: string;
readonly ipAddress: string;
readonly subnetMask: string;
readonly macAddress: string;
/** The device at the other end of its cable (its label, as Java names it) and the cable; null without one. */
readonly connected: { readonly device: string; readonly link: string } | null;
}
export interface RouterSheet {
readonly kind: 'router';
readonly name: string;
readonly gateway: string;
readonly rip: boolean;
/** Shown, not changed: Java's panel greys it out. */
readonly ipForwarding: boolean;
readonly interfaces: readonly InterfaceSheet[];
/** The manual rows, in their order. */
readonly routes: readonly ManualRoute[];
/** The whole table as Java shows it (Weiterleitungstabelle.holeTabelle): own addresses and networks, localhost, the manual rows, the gateway. */
readonly table: readonly RouteRow[];
}
export interface HomeRouterSheet {
readonly kind: 'gateway';
readonly name: string;
readonly gateway: string;
readonly ipForwarding: boolean;
/** DHCP for the WAN card. */
readonly dhcpClient: boolean;
readonly wan: InterfaceSheet;
readonly lan: InterfaceSheet;
}
export interface NameSheet {
readonly kind: Exclude<NodeKind, 'pc' | 'notebook' | 'networkSwitch' | 'remoteLink'>;
readonly kind: 'unsupported';
readonly name: string;
}
export type DeviceSheet = HostSheet | SwitchSheet | ModemSheet | NameSheet;
export type DeviceSheet = HostSheet | SwitchSheet | ModemSheet | RouterSheet | HomeRouterSheet | 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;
const text = (map: Y.Map<unknown> | undefined, key: string) => (map?.get(key) as string | undefined) ?? '';
/** A router's or home router's cards, each with what its cable joins. */
function interfacesOf(doc: Y.Doc, id: string, node: Y.Map<unknown>): InterfaceSheet[] {
const all = doc.getMap<Y.Map<unknown>>(ROOTS.nodes);
const links = inOrder(doc.getMap<Y.Map<unknown>>(ROOTS.links));
return (node.get('ports') as Y.Array<Y.Map<unknown>>).toArray().map((port) => {
const portId = port.get('id') as string;
const config = port.get('config') as Y.Map<unknown> | undefined;
const cable = links.find(([, link]) => (link.get('sourceNode') === id && link.get('sourcePort') === portId) || (link.get('targetNode') === id && link.get('targetPort') === portId));
const other =
cable === undefined ? undefined : all.get((cable[1].get('sourceNode') === id && cable[1].get('sourcePort') === portId ? cable[1].get('targetNode') : cable[1].get('sourceNode')) as string);
return {
portId,
ipAddress: text(config, 'ipAddress'),
subnetMask: text(config, 'subnetMask'),
macAddress: (port.get('mac') as string | undefined) ?? stableGeneratedMacAddress(portId),
connected: cable === undefined || other === undefined ? null : { device: nodeLabel(other), link: cable[0] },
};
});
}
/** 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);
@@ -106,7 +175,34 @@ export function deviceSheet(doc: Y.Doc, id: string): DeviceSheet | undefined {
}
case 'remoteLink':
return { kind, name, port: modemPort(setting(node, 'remoteLink')?.get('pairIdentifier') as string | undefined) };
default:
case 'router': {
const interfaces = interfacesOf(doc, id, node);
const gateway = text(setting(node, 'device'), 'defaultGateway');
const routes = (fromY(settingsOf(node).get('routes')) ?? []) as unknown as ManualRoute[];
return {
kind,
name,
gateway,
rip: settingsOf(node).get('rip') === true,
ipForwarding: (settingsOf(node).get('ipForwarding') as boolean | undefined) ?? forwardsIPv4ByDefault(kind),
interfaces,
routes,
table: routeTableRows(interfaces, routes, gateway),
};
}
case 'gateway': {
const [wan, lan] = interfacesOf(doc, id, node);
return {
kind,
name,
gateway: text(setting(node, 'device'), 'defaultGateway'),
ipForwarding: (settingsOf(node).get('ipForwarding') as boolean | undefined) ?? forwardsIPv4ByDefault(kind),
dhcpClient: setting(node, 'dhcpClient')?.get('isEnabled') === true,
wan: wan!,
lan: lan!,
};
}
case 'unsupported':
return { kind, name };
}
}
+19 -19
View File
@@ -55,8 +55,10 @@ export interface TopologyView {
const settingsOf = (node: Y.Map<unknown>) => node.get('settings') as Y.Map<Y.Map<unknown>> | undefined;
/** Host.holeAnzeigeName: a PC's or notebook's address and MAC address, as its label policy asks. */
function labelOf(node: Y.Map<unknown>, kind: NodeKind, name: string): string {
/** Host.holeAnzeigeName: a PC's or notebook's address and MAC address, as its label policy asks; any other device's name. */
export function nodeLabel(node: Y.Map<unknown>): string {
const kind = node.get('kind') as NodeKind;
const name = node.get('name') as string;
const policy = node.get('labelPolicy') as HostLabelPolicy;
if ((kind !== 'pc' && kind !== 'notebook') || policy === 'manual') return name;
const ip = (settingsOf(node)?.get('device')?.get('ipAddress') as string | undefined) ?? '';
@@ -80,7 +82,7 @@ export function topologyView(doc: Y.Doc): TopologyView {
id,
kind,
name,
label: labelOf(node, kind, name),
label: nodeLabel(node),
x: node.get('x') as number,
y: node.get('y') as number,
cloud: kind === 'networkSwitch' && settingsOf(node)?.get('switch')?.get('showsAsCloud') === true,
@@ -94,22 +96,20 @@ export function topologyView(doc: Y.Doc): TopologyView {
const targetNode = link.get('targetNode') as string;
return { id, sourceNode, sourcePort: link.get('sourcePort') as string, targetNode, targetPort: link.get('targetPort') as string, wireless: wireless(sourceNode, targetNode) };
});
const docs = [...doc.getMap<Y.Map<unknown>>(ROOTS.docs).entries()].map(
([id, item]): DocumentationView => ({
id,
kind: item.get('kind') as DocumentationItemKind,
x: item.get('x') as number,
y: item.get('y') as number,
width: item.get('width') as number,
height: item.get('height') as number,
text: (item.get('text') as Y.Text).toString(),
color: { red: item.get('red') as number, green: item.get('green') as number, blue: item.get('blue') as number, alpha: item.get('alpha') as number },
font: item.get('font') as string,
size: item.get('size') as number,
bold: item.get('bold') as boolean,
order: item.get('order') as number,
}),
);
const docs = [...doc.getMap<Y.Map<unknown>>(ROOTS.docs).entries()].map(([id, item]): DocumentationView => ({
id,
kind: item.get('kind') as DocumentationItemKind,
x: item.get('x') as number,
y: item.get('y') as number,
width: item.get('width') as number,
height: item.get('height') as number,
text: (item.get('text') as Y.Text).toString(),
color: { red: item.get('red') as number, green: item.get('green') as number, blue: item.get('blue') as number, alpha: item.get('alpha') as number },
font: item.get('font') as string,
size: item.get('size') as number,
bold: item.get('bold') as boolean,
order: item.get('order') as number,
}));
// The model's render order (inDeterministicRenderOrder): rectangles below texts, then by order, then by id.
const layer = (item: DocumentationView) => (item.kind === 'rectangle' ? 0 : 1);
docs.sort((left, right) => layer(left) - layer(right) || left.order - right.order || compareIds(left.id, right.id));
+38
View File
@@ -78,3 +78,41 @@ modem port
modem port 080
modem port \u002080
modem port 80
router name R1
router name
router ip 10.0.0.1
router ip 010.000.000.001
router ip 10..0.0.1
router ip .0.0.1
router ip +10.0.0.1
router ip 256.0.0.1
router ip 10.0.0.1
router ip 10.0.0.1
router ip
router ip 10.0.0.1.
router mask 255.255.0.0
router mask 255.0.255.0
router mask 256.0.0.0
router mask 255..255.0.0
router mask
router gateway
router gateway 10.0.0.254
router gateway
router gateway abc
router gateway 192.168..0.1
router gateway 10.0.0.256
homeRouter name Heim
homeRouter gateway
homeRouter gateway 42.0.0.1
homeRouter gateway 42.0.0
homeRouter wanIp 42.1.2.3
homeRouter wanIp .1.2.3
homeRouter wanIp 42..1.2
homeRouter wanIp 042.1.2.3
homeRouter wanMask 255.255.0.0
homeRouter wanMask 255.255
homeRouter lanIp 192.168.1.1
homeRouter lanIp 192.168.1.256
homeRouter lanIp 192.168.1.1
homeRouter lanMask 255.255.255.128
homeRouter lanMask abc
+794 -2
View File
@@ -692,12 +692,804 @@
"marked": false,
"stored": "80",
"shown": "80"
},
{
"device": "router",
"field": "name",
"input": "R1",
"start": "Vermittlungsrechner",
"marked": false,
"stored": "R1",
"shown": "R1"
},
{
"device": "router",
"field": "name",
"input": "",
"start": "Vermittlungsrechner",
"marked": false,
"stored": "",
"shown": ""
},
{
"device": "router",
"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": "router",
"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": "router",
"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": "router",
"field": "ip",
"input": ".0.0.1",
"start": "192.168.0.10",
"marked": false,
"stored": null,
"shown": "",
"error": "NullPointerException"
},
{
"device": "router",
"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": "router",
"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": "router",
"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": "router",
"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": "router",
"field": "ip",
"input": "",
"start": "192.168.0.10",
"marked": true,
"stored": "192.168.0.10",
"shown": "192.168.0.10"
},
{
"device": "router",
"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": "router",
"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": "router",
"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": "router",
"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": "router",
"field": "mask",
"input": "255..255.0.0",
"start": "255.255.255.0",
"marked": true,
"stored": "255.255.255.0",
"shown": "255.255.255.0"
},
{
"device": "router",
"field": "mask",
"input": "",
"start": "255.255.255.0",
"marked": true,
"stored": "255.255.255.0",
"shown": "255.255.255.0"
},
{
"device": "router",
"field": "gateway",
"input": "",
"start": "192.168.0.254",
"marked": true,
"stored": "",
"shown": ""
},
{
"device": "router",
"field": "gateway",
"input": "10.0.0.254",
"start": "192.168.0.254",
"marked": false,
"stored": "10.0.0.254",
"shown": "10.0.0.254"
},
{
"device": "router",
"field": "gateway",
"input": " ",
"start": "192.168.0.254",
"marked": true,
"stored": "",
"shown": ""
},
{
"device": "router",
"field": "gateway",
"input": "abc",
"start": "192.168.0.254",
"marked": true,
"stored": "192.168.0.254",
"shown": "192.168.0.254"
},
{
"device": "router",
"field": "gateway",
"input": "192.168..0.1",
"start": "192.168.0.254",
"marked": true,
"stored": "192.168.0.1",
"shown": "192.168.0.1"
},
{
"device": "router",
"field": "gateway",
"input": "10.0.0.256",
"start": "192.168.0.254",
"marked": true,
"stored": "192.168.0.254",
"shown": "192.168.0.254"
},
{
"device": "homeRouter",
"field": "name",
"input": "Heim",
"start": "Heimrouter",
"marked": false,
"stored": "Heim",
"shown": "Heim"
},
{
"device": "homeRouter",
"field": "gateway",
"input": "",
"start": "192.168.0.254",
"marked": true,
"stored": "",
"shown": ""
},
{
"device": "homeRouter",
"field": "gateway",
"input": "42.0.0.1",
"start": "192.168.0.254",
"marked": false,
"stored": "42.0.0.1",
"shown": "42.0.0.1"
},
{
"device": "homeRouter",
"field": "gateway",
"input": "42.0.0",
"start": "192.168.0.254",
"marked": true,
"stored": "192.168.0.254",
"shown": "192.168.0.254"
},
{
"device": "homeRouter",
"field": "wanIp",
"input": "42.1.2.3",
"start": "42.0.0.10",
"marked": false,
"stored": "42.1.2.3",
"shown": "42.1.2.3"
},
{
"device": "homeRouter",
"field": "wanIp",
"input": ".1.2.3",
"start": "42.0.0.10",
"marked": false,
"stored": null,
"shown": ""
},
{
"device": "homeRouter",
"field": "wanIp",
"input": "42..1.2",
"start": "42.0.0.10",
"marked": true,
"stored": "42.0.0.10",
"shown": "42.0.0.10"
},
{
"device": "homeRouter",
"field": "wanIp",
"input": "042.1.2.3",
"start": "42.0.0.10",
"marked": false,
"stored": "42.1.2.3",
"shown": "42.1.2.3"
},
{
"device": "homeRouter",
"field": "wanMask",
"input": "255.255.0.0",
"start": "255.0.0.0",
"marked": false,
"stored": "255.255.0.0",
"shown": "255.255.0.0"
},
{
"device": "homeRouter",
"field": "wanMask",
"input": "255.255",
"start": "255.0.0.0",
"marked": true,
"stored": "255.0.0.0",
"shown": "255.0.0.0"
},
{
"device": "homeRouter",
"field": "lanIp",
"input": "192.168.1.1",
"start": "192.168.0.10",
"marked": false,
"stored": "192.168.1.1",
"shown": "192.168.1.1"
},
{
"device": "homeRouter",
"field": "lanIp",
"input": "192.168.1.256",
"start": "192.168.0.10",
"marked": true,
"stored": "192.168.0.10",
"shown": "192.168.0.10"
},
{
"device": "homeRouter",
"field": "lanIp",
"input": " 192.168.1.1",
"start": "192.168.0.10",
"marked": true,
"stored": "192.168.0.10",
"shown": "192.168.0.10"
},
{
"device": "homeRouter",
"field": "lanMask",
"input": "255.255.255.128",
"start": "255.255.255.0",
"marked": false,
"stored": "255.255.255.128",
"shown": "255.255.255.128"
},
{
"device": "homeRouter",
"field": "lanMask",
"input": "abc",
"start": "255.255.255.0",
"marked": true,
"stored": "255.255.255.0",
"shown": "255.255.255.0"
}
],
"flows": {
"useMacAsNameThenOff": "0E:EE:AA:A5:58:21",
"useMacAsNameThenOff": "CE:0D:5A:24:8A:C6",
"useIpAsNameThenOff": "PC-A",
"dhcpClientStopsServer": true,
"dhcpClient": true
"dhcpClient": true,
"homeRouterDhcpClientStopsServer": false,
"homeRouterDhcpClient": true
},
"tables": {
"gatewayInSecondNetwork": [
{
"cells": [
"192.168.1.1",
"255.255.255.255",
"127.0.0.1",
"127.0.0.1"
],
"manual": false
},
{
"cells": [
"10.0.0.1",
"255.255.255.255",
"127.0.0.1",
"127.0.0.1"
],
"manual": false
},
{
"cells": [
"192.168.1.0",
"255.255.255.0",
"192.168.1.1",
"192.168.1.1"
],
"manual": false
},
{
"cells": [
"10.0.0.0",
"255.0.0.0",
"10.0.0.1",
"10.0.0.1"
],
"manual": false
},
{
"cells": [
"127.0.0.0",
"255.0.0.0",
"127.0.0.1",
"127.0.0.1"
],
"manual": false
},
{
"cells": [
"172.16.0.0",
"255.255.0.0",
"192.168.1.2",
"192.168.1.1"
],
"manual": true
},
{
"cells": [
"10.20.0.0",
"255.255.0.0",
"10.0.0.2",
"10.0.0.1"
],
"manual": true
},
{
"cells": [
"0.0.0.0",
"0.0.0.0",
"192.168.1.254",
"192.168.1.1"
],
"manual": false
}
],
"gatewayInNoNetwork": [
{
"cells": [
"192.168.1.1",
"255.255.255.255",
"127.0.0.1",
"127.0.0.1"
],
"manual": false
},
{
"cells": [
"10.0.0.1",
"255.255.255.255",
"127.0.0.1",
"127.0.0.1"
],
"manual": false
},
{
"cells": [
"192.168.1.0",
"255.255.255.0",
"192.168.1.1",
"192.168.1.1"
],
"manual": false
},
{
"cells": [
"10.0.0.0",
"255.0.0.0",
"10.0.0.1",
"10.0.0.1"
],
"manual": false
},
{
"cells": [
"127.0.0.0",
"255.0.0.0",
"127.0.0.1",
"127.0.0.1"
],
"manual": false
},
{
"cells": [
"172.16.0.0",
"255.255.0.0",
"192.168.1.2",
"192.168.1.1"
],
"manual": true
},
{
"cells": [
"10.20.0.0",
"255.255.0.0",
"10.0.0.2",
"10.0.0.1"
],
"manual": true
},
{
"cells": [
"0.0.0.0",
"0.0.0.0",
"172.30.0.1",
"10.0.0.1"
],
"manual": false
}
],
"gatewayInBothNetworks": [
{
"cells": [
"10.1.0.1",
"255.255.255.255",
"127.0.0.1",
"127.0.0.1"
],
"manual": false
},
{
"cells": [
"10.0.0.1",
"255.255.255.255",
"127.0.0.1",
"127.0.0.1"
],
"manual": false
},
{
"cells": [
"10.1.0.0",
"255.255.0.0",
"10.1.0.1",
"10.1.0.1"
],
"manual": false
},
{
"cells": [
"10.0.0.0",
"255.0.0.0",
"10.0.0.1",
"10.0.0.1"
],
"manual": false
},
{
"cells": [
"127.0.0.0",
"255.0.0.0",
"127.0.0.1",
"127.0.0.1"
],
"manual": false
},
{
"cells": [
"0.0.0.0",
"0.0.0.0",
"10.1.0.254",
"10.1.0.1"
],
"manual": false
}
],
"noGateway": [
{
"cells": [
"192.168.1.1",
"255.255.255.255",
"127.0.0.1",
"127.0.0.1"
],
"manual": false
},
{
"cells": [
"10.0.0.1",
"255.255.255.255",
"127.0.0.1",
"127.0.0.1"
],
"manual": false
},
{
"cells": [
"192.168.1.0",
"255.255.255.0",
"192.168.1.1",
"192.168.1.1"
],
"manual": false
},
{
"cells": [
"10.0.0.0",
"255.0.0.0",
"10.0.0.1",
"10.0.0.1"
],
"manual": false
},
{
"cells": [
"127.0.0.0",
"255.0.0.0",
"127.0.0.1",
"127.0.0.1"
],
"manual": false
},
{
"cells": [
"172.16.0.0",
"255.255.0.0",
"192.168.1.2",
"192.168.1.1"
],
"manual": true
},
{
"cells": [
"10.20.0.0",
"255.255.0.0",
"10.0.0.2",
"10.0.0.1"
],
"manual": true
}
],
"threeInterfaces": [
{
"cells": [
"172.16.5.1",
"255.255.255.255",
"127.0.0.1",
"127.0.0.1"
],
"manual": false
},
{
"cells": [
"192.168.1.1",
"255.255.255.255",
"127.0.0.1",
"127.0.0.1"
],
"manual": false
},
{
"cells": [
"10.0.0.1",
"255.255.255.255",
"127.0.0.1",
"127.0.0.1"
],
"manual": false
},
{
"cells": [
"172.16.0.0",
"255.255.0.0",
"172.16.5.1",
"172.16.5.1"
],
"manual": false
},
{
"cells": [
"192.168.1.0",
"255.255.255.0",
"192.168.1.1",
"192.168.1.1"
],
"manual": false
},
{
"cells": [
"10.0.0.0",
"255.0.0.0",
"10.0.0.1",
"10.0.0.1"
],
"manual": false
},
{
"cells": [
"127.0.0.0",
"255.0.0.0",
"127.0.0.1",
"127.0.0.1"
],
"manual": false
},
{
"cells": [
"0.0.0.0",
"0.0.0.0",
"10.0.0.254",
"10.0.0.1"
],
"manual": false
}
]
},
"edits": {
"invalidCell": {
"manual": [
[
null,
"255.255.0.0",
"192.168.0.2",
"192.168.0.10"
]
]
},
"emptyCell": {
"manual": [
[
null,
"255.255.0.0",
"192.168.0.2",
"192.168.0.10"
]
]
},
"leadingZeros": {
"manual": [
[
"10.0.0.0",
"255.255.0.0",
"192.168.0.2",
"192.168.0.10"
]
]
},
"dottedTwice": {
"manual": [
[
null,
"255.255.0.0",
"192.168.0.2",
"192.168.0.10"
]
]
},
"maskCell": {
"manual": [
[
"172.16.0.0",
"255.0.255.0",
"192.168.0.2",
"192.168.0.10"
]
]
},
"newRowThreeCells": {
"manual": [
[
"172.16.0.0",
"255.255.0.0",
"192.168.0.2",
"192.168.0.10"
]
]
},
"newRowFourCells": {
"manual": [
[
"172.16.0.0",
"255.255.0.0",
"192.168.0.2",
"192.168.0.10"
],
[
"10.9.0.0",
"255.255.0.0",
"10.0.0.9",
"10.0.0.1"
]
]
},
"newRowInvalidCell": {
"manual": [
[
"172.16.0.0",
"255.255.0.0",
"192.168.0.2",
"192.168.0.10"
]
]
},
"newRowTwice": {
"manual": [
[
"172.16.0.0",
"255.255.0.0",
"192.168.0.2",
"192.168.0.10"
]
],
"rowsShown": 2
},
"removeRow": {
"manual": []
}
}
}
+322 -29
View File
@@ -28,25 +28,50 @@ import {
setSwitchCloud,
setSwitchRetention,
setSwitchSsid,
addRoute,
removeRoute,
setInterfaceAddress,
setRip,
setRouteCell,
setRouterGateway,
topologyView,
type FieldRule,
type HomeRouterSheet,
type HostSheet,
type NewDevice,
type RouterSheet,
} from '../src/index.js';
interface JavaCase {
readonly device: 'host' | 'switch' | 'modem';
readonly device: 'host' | 'switch' | 'modem' | 'router' | 'homeRouter';
readonly field: string;
readonly input: string;
readonly start: string;
readonly marked: boolean;
readonly stored: string;
/** null: Java's card lost its address. */
readonly stored: string | null;
readonly shown: string;
/** What Java's panel threw, if it did. */
readonly error?: string;
}
interface JavaRow {
readonly cells: readonly (string | null)[];
readonly manual: boolean;
}
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 };
readonly flows: {
readonly useMacAsNameThenOff: string;
readonly useIpAsNameThenOff: string;
readonly dhcpClientStopsServer: boolean;
readonly dhcpClient: boolean;
readonly homeRouterDhcpClientStopsServer: boolean;
readonly homeRouterDhcpClient: boolean;
};
readonly tables: Readonly<Record<string, readonly JavaRow[]>>;
readonly edits: Readonly<Record<string, { readonly manual: readonly (readonly (string | null)[])[]; readonly rowsShown?: number }>>;
};
const RULES: Readonly<Record<string, FieldRule>> = {
@@ -60,10 +85,27 @@ const RULES: Readonly<Record<string, FieldRule>> = {
'switch retention': 'seconds',
'modem name': 'name',
'modem port': 'port',
'router name': 'name',
'router ip': 'ipAddress',
'router mask': 'subnetMask',
'router gateway': 'ipAddressOrEmpty',
'homeRouter name': 'name',
'homeRouter gateway': 'ipAddressOrEmpty',
'homeRouter wanIp': 'ipAddress',
'homeRouter wanMask': 'subnetMask',
'homeRouter lanIp': 'ipAddress',
'homeRouter lanMask': 'subnetMask',
};
/** 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']);
const TRIMMED = new Set(
Object.entries(RULES)
.filter(([key, rule]) => rule !== 'name' && rule !== 'ssid')
.map(([key]) => key),
);
/** Whether Java's device took the text: its value changed (to an address: null is a card that lost its own), or was the text already. */
const tookIt = (each: JavaCase) => each.stored !== null && (each.stored !== each.start || each.input === each.start);
/** 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);
@@ -88,9 +130,12 @@ describe("a field's rule, against FILIUS' settings panels", () => {
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;
const took = tookIt(verdict);
// An empty gateway Java's setter takes as "none", though a router's panel marks it (its key listener checks musterIpAdresse).
const emptyTaken = RULES[key] === 'ipAddressOrEmpty' && verdict.input.trim() === '' && took;
// 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 expected =
RULES[key] === 'name' ? true : key === 'modem port' ? took && /^([1-9][0-9]{0,4})$/.test(verdict.input) && Number(verdict.input) <= 65_535 : emptyTaken || (!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));
@@ -98,39 +143,46 @@ describe("a field's rule, against FILIUS' settings panels", () => {
});
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 disagreeing = java.cases.filter((each) => RULES[`${each.device} ${each.field}`] !== 'name' && each.field !== 'port' && each.marked === tookIt(each));
// Marked red, yet taken; or black, yet refused (the gateway's spaces Java trims to empty) — or lost: a card without its address.
const verdictOf = (each: JavaCase) => (each.marked ? 'marked, taken' : each.stored === null ? `lost${each.error === undefined ? '' : `, ${each.error}`}` : 'refused');
expect(disagreeing.map((each) => `${each.device} ${each.field} ${JSON.stringify(each.input)} ${verdictOf(each)}`)).toEqual([
'host ip "10..0.0.1" marked, taken',
'host ip ".0.0.1" refused',
'host ip "+10.0.0.1" marked, taken',
'host ip "10.0.0.1." marked, taken',
'host ip "١٠.0.0.1" marked, taken',
'host ip "-0.0.0.1" marked, taken',
'host mask "255..255.0.0" marked, taken',
'host gateway " " marked, taken',
'host gateway "192.168..1.1" marked, taken',
'host dns " " marked, taken',
'switch retention "" refused',
'switch retention "-5" marked, taken',
'switch retention "99999999999999999999" refused',
'switch retention "٣" marked, taken',
'router ip ".0.0.1" lost, NullPointerException',
'router gateway "" marked, taken',
'router gateway " " marked, taken',
'router gateway "192.168..0.1" marked, taken',
'homeRouter gateway "" marked, taken',
'homeRouter wanIp ".1.2.3" lost',
]);
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);
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 {
function add(doc: Y.Doc, kind: NewDevice['kind'], name: string = kind, interfaces?: number): string {
const id = newId();
addDevice(doc, { kind, id, name, x: 100, y: 100, newId }, 'me');
addDevice(doc, { kind, id, name, x: 100, y: 100, newId, ...(interfaces === undefined ? {} : { interfaces }) }, 'me');
return id;
}
@@ -345,7 +397,7 @@ describe("a switch's and a modem's sheet", () => {
expect(deviceSheet(doc, hub)).toMatchObject({ retentionSeconds: 1 });
});
it("pairs a modem by its port, and names the other kinds' sheets", () => {
it('pairs a modem by its port, and renames a router', () => {
const doc = new Y.Doc();
const modem = add(doc, 'remoteLink', 'Modem');
expect(deviceSheet(doc, modem)).toEqual({ kind: 'remoteLink', name: 'Modem', port: 12_345 });
@@ -355,8 +407,8 @@ describe("a switch's and a modem's sheet", () => {
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' });
expect(deviceSheet(doc, router)).toMatchObject({ kind: 'router', name: 'R2' });
expect(deviceSheet(doc, add(doc, 'gateway', 'Heim'))).toMatchObject({ kind: 'gateway', name: 'Heim' });
});
});
@@ -413,3 +465,244 @@ describe("the sheet's changes", () => {
expect(reopened.remoteLinkConfigurations.get(modemAgain!.id)?.pairIdentifier).toBe('java-modem-port-4242');
});
});
const router = (doc: Y.Doc, id: string) => deviceSheet(doc, id) as RouterSheet;
const home = (doc: Y.Doc, id: string) => deviceSheet(doc, id) as HomeRouterSheet;
/** Java's manual rows of the measured tables, as SheetRun added them. */
const JAVA_MANUAL = [
{ destinationNetwork: '172.16.0.0', subnetMask: '255.255.0.0', gateway: '192.168.1.2', interfaceIPAddress: '192.168.1.1' },
{ destinationNetwork: '010.020.0.0', subnetMask: '255.255.0.0', gateway: '10.0.0.2', interfaceIPAddress: '10.0.0.1' },
];
/** The measured tables' routers: their cards (address, mask), gateway and manual rows. */
const TABLES: Readonly<Record<string, { readonly cards: readonly (readonly [string, string])[]; readonly gateway: string; readonly manual: boolean }>> = {
gatewayInSecondNetwork: {
cards: [
['10.0.0.1', '255.0.0.0'],
['192.168.1.1', '255.255.255.0'],
],
gateway: '192.168.1.254',
manual: true,
},
gatewayInNoNetwork: {
cards: [
['10.0.0.1', '255.0.0.0'],
['192.168.1.1', '255.255.255.0'],
],
gateway: '172.30.0.1',
manual: true,
},
gatewayInBothNetworks: {
cards: [
['10.0.0.1', '255.0.0.0'],
['10.1.0.1', '255.255.0.0'],
],
gateway: '10.1.0.254',
manual: false,
},
noGateway: {
cards: [
['10.0.0.1', '255.0.0.0'],
['192.168.1.1', '255.255.255.0'],
],
gateway: '',
manual: true,
},
threeInterfaces: {
cards: [
['10.0.0.1', '255.0.0.0'],
['192.168.1.1', '255.255.255.0'],
['172.16.5.1', '255.255.0.0'],
],
gateway: '10.0.0.254',
manual: false,
},
};
describe("a router's sheet", () => {
it("shows the forwarding table Java shows, in Java's order, its manual rows editable", () => {
expect(Object.keys(java.tables).sort()).toEqual(Object.keys(TABLES).sort());
for (const [name, setup] of Object.entries(TABLES)) {
const doc = new Y.Doc();
const id = add(doc, 'router', 'R', setup.cards.length);
router(doc, id).interfaces.forEach((card, index) => {
expect(setInterfaceAddress(doc, id, card.portId, 'ipAddress', setup.cards[index]![0], 'me')).toEqual({ value: setup.cards[index]![0] });
expect(setInterfaceAddress(doc, id, card.portId, 'subnetMask', setup.cards[index]![1], 'me')).toEqual({ value: setup.cards[index]![1] });
});
setRouterGateway(doc, id, setup.gateway, 'me');
if (setup.manual) for (const row of JAVA_MANUAL) expect('route' in addRoute(doc, id, row, 'me')).toBe(true);
const table = router(doc, id).table.map((row) => ({ cells: [row.destinationNetwork, row.subnetMask, row.nextHop, row.interfaceIPAddress], manual: row.isEditable }));
expect(table, name).toEqual(java.tables[name]);
}
});
it('takes a card’s address as Java keeps it and keeps the old one for a refused text, the first card mirrored in the device', () => {
const doc = new Y.Doc();
const id = add(doc, 'router');
const [first, second] = router(doc, id).interfaces;
expect(setInterfaceAddress(doc, id, first!.portId, 'ipAddress', ' 010.0.0.1 ', 'me')).toEqual({ value: '10.0.0.1' });
expect(setInterfaceAddress(doc, id, first!.portId, 'ipAddress', '.0.0.1', 'me')).toEqual({ invalid: true });
expect(setInterfaceAddress(doc, id, second!.portId, 'subnetMask', '255.255', 'me')).toEqual({ invalid: true });
expect(setInterfaceAddress(doc, id, second!.portId, 'subnetMask', '255.0.255.0', 'me')).toEqual({ value: '255.0.255.0' });
expect(setInterfaceAddress(doc, id, 'no-port', 'ipAddress', '10.0.0.1', 'me')).toEqual({ value: '10.0.0.1' });
expect(router(doc, id).interfaces.map((card) => [card.ipAddress, card.subnetMask])).toEqual([
['10.0.0.1', '255.255.255.0'],
['192.168.0.10', '255.0.255.0'],
]);
// A router keeps its own settings only with a gateway; then they follow its first card.
expect(readProject(doc).deviceConfigurations.has(id)).toBe(false);
expect(setRouterGateway(doc, id, '10.0.0.254', 'me')).toEqual({ value: '10.0.0.254' });
expect(readProject(doc).deviceConfigurations.get(id)).toEqual({ ipAddress: '10.0.0.1', subnetMask: '255.255.255.0', defaultGateway: '10.0.0.254', dnsServer: '' });
setInterfaceAddress(doc, id, first!.portId, 'ipAddress', '10.0.0.2', 'me');
setInterfaceAddress(doc, id, first!.portId, 'subnetMask', '255.0.0.0', 'me');
expect(readProject(doc).deviceConfigurations.get(id)).toMatchObject({ ipAddress: '10.0.0.2', subnetMask: '255.0.0.0' });
expect(setRouterGateway(doc, id, 'abc', 'me')).toEqual({ invalid: true });
expect(router(doc, id).gateway).toBe('10.0.0.254');
expect(setRouterGateway(doc, id, ' ', 'me')).toEqual({ value: '' });
expect(readProject(doc).deviceConfigurations.has(id)).toBe(false);
});
it('switches RIP, and shows IP forwarding on', () => {
const doc = new Y.Doc();
const id = add(doc, 'router');
expect(router(doc, id)).toMatchObject({ rip: false, ipForwarding: true, gateway: '' });
setRip(doc, id, true, 'me');
expect(router(doc, id).rip).toBe(true);
expect(readProject(doc).ripEnabledByNodeId.get(id)).toBe(true);
setRip(doc, id, false, 'me');
expect(readProject(doc).ripEnabledByNodeId.has(id)).toBe(false);
});
it('names what each card is plugged into, by its label', () => {
const doc = new Y.Doc();
const id = add(doc, 'router');
const pc = add(doc, 'pc', 'PC-A');
setHostLabels(doc, pc, { ipAddress: true, macAddress: false }, 'me');
connect(doc, { node: pc }, { node: id }, 'cable', 'me');
expect(router(doc, id).interfaces.map((card) => card.connected)).toEqual([{ device: '192.168.0.10', link: 'cable' }, null]);
expect(router(doc, id).interfaces[0]!.macAddress).toMatch(/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/);
});
});
describe("a router's manual rows", () => {
/** A router with Java's one manual row of SheetRun's edits. */
function edited() {
const doc = new Y.Doc();
const id = add(doc, 'router');
addRoute(doc, id, { destinationNetwork: '172.16.0.0', subnetMask: '255.255.0.0', gateway: '192.168.0.2', interfaceIPAddress: '192.168.0.10' }, 'me');
const rows = () =>
readProject(doc)
.manualRoutesByNodeId.get(id)
?.map((row) => [row.destinationNetwork, row.subnetMask, row.gateway, row.interfaceIPAddress]) ?? [];
return { doc, id, rows };
}
it('take a cell as Java does where Java takes it, and keep it where Java stores null', () => {
for (const [name, column, text] of [
['leadingZeros', 'destinationNetwork', '010.0.0.0'],
['maskCell', 'subnetMask', '255.0.255.0'],
] as const) {
const { doc, id, rows } = edited();
expect('value' in setRouteCell(doc, id, 0, column, text, 'me'), name).toBe(true);
expect(rows(), name).toEqual(java.edits[name]!.manual);
}
for (const [name, text] of [
['invalidCell', 'abc'],
['emptyCell', ''],
['dottedTwice', '10..0.0'],
] as const) {
// Java: the cell becomes null.
expect(java.edits[name]!.manual[0]![0], name).toBeNull();
const { doc, id, rows } = edited();
expect(setRouteCell(doc, id, 0, 'destinationNetwork', text, 'me'), name).toEqual({ invalid: true });
expect(rows()[0]![0], name).toBe('172.16.0.0');
}
});
it('edit and lose the row they are asked for', () => {
const { doc, id, rows } = edited();
addRoute(doc, id, JAVA_MANUAL[0]!, 'me');
expect(setRouteCell(doc, id, 1, 'gateway', '192.168.1.3', 'me')).toEqual({ value: '192.168.1.3' });
expect(rows()).toEqual([
['172.16.0.0', '255.255.0.0', '192.168.0.2', '192.168.0.10'],
['172.16.0.0', '255.255.0.0', '192.168.1.3', '192.168.1.1'],
]);
removeRoute(doc, id, 1, 'me');
expect(rows()).toEqual([['172.16.0.0', '255.255.0.0', '192.168.0.2', '192.168.0.10']]);
});
it('take a new row once all four cells pass, as Java does, and lose a row', () => {
const cells = { destinationNetwork: '10.9.0.0', subnetMask: '255.255.0.0', gateway: '10.0.0.9', interfaceIPAddress: '' };
let { doc, id, rows } = edited();
expect(addRoute(doc, id, cells, 'me')).toEqual({ invalid: ['interfaceIPAddress'] });
expect(rows()).toEqual(java.edits.newRowThreeCells!.manual);
expect(addRoute(doc, id, { ...cells, interfaceIPAddress: '10..0.0.1' }, 'me')).toEqual({ invalid: ['interfaceIPAddress'] });
expect(rows()).toEqual(java.edits.newRowInvalidCell!.manual);
expect(addRoute(doc, id, { ...cells, interfaceIPAddress: '10.0.0.1' }, 'me')).toEqual({ route: { ...cells, interfaceIPAddress: '10.0.0.1' } });
expect(rows()).toEqual(java.edits.newRowFourCells!.manual);
expect(addRoute(doc, 'nobody', { ...cells, interfaceIPAddress: '10.0.0.1' }, 'me')).toEqual({ invalid: [] });
({ doc, id, rows } = edited());
removeRoute(doc, id, 5, 'me');
removeRoute(doc, id, 0, 'me');
expect(rows()).toEqual(java.edits.removeRow!.manual);
expect((doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get(id)!.get('settings') as Y.Map<unknown>).has('routes')).toBe(false);
// Java keeps one new row at a time; the sheet's draft is one row too.
expect(java.edits.newRowTwice!.rowsShown).toBe(2);
});
});
describe("a home router's sheet", () => {
it("shows its WAN and LAN cards, keeps the WAN card in the device's settings, and leaves its DHCP server running", () => {
expect(java.flows).toMatchObject({ homeRouterDhcpClient: true, homeRouterDhcpClientStopsServer: false });
const doc = new Y.Doc();
const id = add(doc, 'gateway', 'Heim');
const sheet = home(doc, id);
expect([sheet.wan.ipAddress, sheet.wan.subnetMask, sheet.lan.ipAddress, sheet.lan.subnetMask, sheet.gateway, sheet.ipForwarding, sheet.dhcpClient]).toEqual([
'42.0.0.10',
'255.0.0.0',
'192.168.0.10',
'255.255.255.0',
'',
true,
false,
]);
expect(setInterfaceAddress(doc, id, sheet.wan.portId, 'ipAddress', '042.1.2.3', 'me')).toEqual({ value: '42.1.2.3' });
expect(setInterfaceAddress(doc, id, sheet.lan.portId, 'subnetMask', '255.255.255.128', 'me')).toEqual({ value: '255.255.255.128' });
expect(setRouterGateway(doc, id, '42.0.0.1', 'me')).toEqual({ value: '42.0.0.1' });
expect(readProject(doc).deviceConfigurations.get(id)).toEqual({ ipAddress: '42.1.2.3', subnetMask: '255.0.0.0', defaultGateway: '42.0.0.1', dnsServer: '' });
// Unlike a router, a home router keeps its own settings without a gateway.
setRouterGateway(doc, id, '', 'me');
expect(readProject(doc).deviceConfigurations.get(id)).toMatchObject({ defaultGateway: '' });
const settings = doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get(id)!.get('settings') as Y.Map<unknown>;
const server = new Y.Map<unknown>();
server.set('isActive', true);
settings.set('dhcpServer', server);
setDhcpClient(doc, id, true, 'me');
expect(home(doc, id).dhcpClient).toBe(true);
expect(server.get('isActive')).toBe(true);
});
it('keeps everything through a saved file, with the router’s rows and RIP', () => {
const doc = new Y.Doc();
const id = add(doc, 'router', 'R', 3);
const homeId = add(doc, 'gateway', 'Heim');
const cards = router(doc, id).interfaces;
setInterfaceAddress(doc, id, cards[2]!.portId, 'ipAddress', '172.16.5.1', 'me');
setRouterGateway(doc, id, '192.168.0.254', 'me');
setRip(doc, id, true, 'me');
addRoute(doc, id, JAVA_MANUAL[1]!, 'me');
setInterfaceAddress(doc, homeId, home(doc, homeId).lan.portId, 'ipAddress', '192.168.7.1', 'me');
setRouterGateway(doc, homeId, '42.0.0.1', 'me');
let counter = 0;
const reopened = openProjectArchive(saveProjectArchive(readProject(doc)).archive, () => `40000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`);
const [routerAgain, homeAgain] = reopened.graph.nodes;
const address = (node: typeof routerAgain, index: number) => reopened.interfaceConfigurations.get(`${node!.id}/${node!.ports[index]!.id}`)?.ipAddress;
expect(address(routerAgain, 2)).toBe('172.16.5.1');
expect(reopened.deviceConfigurations.get(routerAgain!.id)?.defaultGateway).toBe('192.168.0.254');
expect(reopened.ripEnabledByNodeId.get(routerAgain!.id)).toBe(true);
expect(reopened.manualRoutesByNodeId.get(routerAgain!.id)).toEqual([{ destinationNetwork: '10.20.0.0', subnetMask: '255.255.0.0', gateway: '10.0.0.2', interfaceIPAddress: '10.0.0.1' }]);
expect(address(homeAgain, 1)).toBe('192.168.7.1');
expect(reopened.deviceConfigurations.get(homeAgain!.id)?.defaultGateway).toBe('42.0.0.1');
});
});