Files
mdg-filius/packages/doc/test/sheet.test.ts
T
julian.kiedaischandClaude Opus 5.5 7b943d3d96 feat(server): the relay joins modems of different projects by a code (K3)
A modem that offers a connection to another project is found by six
characters derived from its project and itself (modemCode): a copy of the
project has another code, so no two modems offer the same one. The room
names its modems of other projects to F's relay at a start and none at a
stop; the relay pairs each offer with the first modem that joins its code,
one to one as Java's server modem takes one client, and passes the frames
between the rooms' threads. Every client hears how each modem stands
(`modems`: connected, waiting, taken, unpaired, ambiguous, detached, or
unavailable without F). The document keeps the connection and the code.

Measured on F with two threads: a ping from one project's notebook to the
other's goes through; a third project that wants the same code reads
taken, and takes it when the second stops; a deleted project's modem
leaves with its room.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:24:19 +00:00

1350 lines
68 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 { javaIpCheck } from '@mdg-filius/core';
import { openProjectArchive, saveProjectArchive } from '@mdg-filius/fls';
import {
toY,
addDevice,
checkField,
connect,
createUndoManager,
deviceSheet,
docSnapshot,
readProject,
renameDevice,
ROOTS,
setDhcpClient,
setHostAddress,
setHostLabels,
setModemCode,
setModemConnection,
setHostWireless,
setIpForwarding,
setModemPort,
setSwitchCloud,
setSwitchRetention,
setSwitchSsid,
addRoute,
addRouterInterface,
connectionsClick,
removeRouterInterface,
swapRouterCables,
addFirewallRule,
moveFirewallRule,
removeFirewallRule,
setFirewallDefaultPolicy,
setFirewallFlag,
setFirewallRuleCell,
setFirewallRuleChoice,
type FirewallRuleColumn,
addDhcpStaticAssignment,
addPortForwarding,
removeDhcpStaticAssignment,
removePortForwarding,
removeRoute,
setDhcpServerAddress,
setDhcpServerFlag,
setDhcpStaticCell,
setPortForwardingCell,
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' | 'router' | 'homeRouter' | 'dhcpHost' | 'dhcpHome';
readonly field: string;
readonly input: string;
readonly start: string;
readonly marked: boolean;
/** 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 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 }>>;
readonly dhcp: {
readonly dhcpHostShown: Readonly<Record<string, readonly [string, boolean]>>;
readonly dhcpHomeShown: Readonly<Record<string, readonly [string, boolean]>>;
readonly hostWithoutGatewayShown: readonly [string, string];
readonly staticAssignments: readonly (readonly [string, string])[];
readonly addable: readonly boolean[];
};
readonly forwarding: { readonly rows: readonly (readonly [string, string, string, string])[]; readonly valid: readonly boolean[] };
readonly firewall: {
readonly newRule: JavaFirewallRule;
readonly newFirewall: readonly [boolean, number, boolean, boolean];
readonly cells: readonly { readonly column: number; readonly input: string; readonly rule: JavaFirewallRule; readonly valid: boolean }[];
readonly moves: readonly (readonly [string, number, boolean | null, readonly string[]])[];
};
readonly connections: {
readonly steps: readonly JavaConnectionsStep[];
readonly hostPortFree: boolean;
readonly switchPortsFree: boolean;
};
};
/** A click in Java's connections dialog ("c<i>" a card's button, "+", "-") and what the dialog and the router show then. */
interface JavaConnectionsStep {
readonly click: string;
readonly local: readonly string[];
readonly remote: readonly string[];
readonly buttons: readonly ('off' | 'marked' | 'plugged' | 'free')[];
readonly add: boolean;
readonly remove: boolean;
/** Address, mask and the device its cable joins ("" for none), per card. */
readonly cards: readonly (readonly [string, string, string])[];
readonly cables: number;
readonly panelError?: string | null;
readonly gateway?: string | null;
}
interface JavaFirewallRule {
readonly srcIP: string;
readonly srcMask: string;
readonly destIP: string;
readonly destMask: string;
readonly port: number;
readonly protocol: number;
readonly action: number;
}
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',
'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',
// The DHCP dialog checks every address with musterIpAdresse: none may be empty.
'dhcpHost lower': 'ipAddress',
'dhcpHost upper': 'ipAddress',
'dhcpHost gateway': 'ipAddress',
'dhcpHost dns': 'ipAddress',
'dhcpHome lower': 'ipAddress',
'dhcpHome dns': 'ipAddress',
};
/** Fields Java's dialog shows but never takes: a home router hands out its LAN address as the gateway. */
const NOT_OFFERED = new Set(['dhcpHome gateway']);
/** Fields whose text is judged without the spaces around it. */
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) && (!keptAsTyped(each) || javaIpCheck(each.stored) !== null);
/** A DHCP server keeps its addresses as typed ("010.0.0.1"); the model keeps IP.ipCheck's form. */
const keptAsTyped = (each: JavaCase) => each.device.startsWith('dhcp');
/** What Java's model holds in the units of the sheet's value: seconds for the retention time, an address in IP.ipCheck's form. */
const javaValue = (each: JavaCase) => (each.field === 'retention' ? String(Number(each.stored) / 1000) : keptAsTyped(each) ? javaIpCheck(each.stored!) : 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), ...NOT_OFFERED]));
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}`;
if (NOT_OFFERED.has(key)) continue;
const verdict = judged(each);
// Taken: the value changed, or the text was the value already.
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 : 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));
}
});
it("names where Java's two judgements disagree, which here are one", () => {
const disagreeing = java.cases.filter(
(each) => !NOT_OFFERED.has(`${each.device} ${each.field}`) && RULES[`${each.device} ${each.field}`] !== 'name' && each.field !== 'port' && each.marked === tookIt(each),
);
const kept = (each: JavaCase) => each.stored !== null && each.stored !== each.start && !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}`}` : kept(each) ? 'kept, no address' : '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',
'dhcpHost lower ".0.0.1" kept, no address',
]);
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, interfaces?: number): string {
const id = newId();
addDevice(doc, { kind, id, name, x: 100, y: 100, newId, ...(interfaces === undefined ? {} : { interfaces }) }, '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: [],
dhcpServer: {
isActive: false,
lowerBoundIPAddress: '0.0.0.0',
upperBoundIPAddress: '0.0.0.0',
subnetMask: '255.255.255.0',
gateway: '0.0.0.0',
gatewayEditable: false,
dnsServer: '0.0.0.0',
dnsServerEditable: false,
useOwnSettings: false,
ownSettingsOffered: true,
staticAssignments: [],
},
});
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 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, connection: 'inProject', code: '' });
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)).toMatchObject({ kind: 'router', name: 'R2' });
expect(deviceSheet(doc, add(doc, 'gateway', 'Heim'))).toMatchObject({ kind: 'gateway', name: 'Heim' });
});
});
describe("a modem's connection to another project", () => {
it('offers a code, joins one as typed, and goes back to the pair on its port', () => {
const doc = new Y.Doc();
const modem = add(doc, 'remoteLink', 'Modem');
setModemConnection(doc, modem, 'offer', 'me');
expect(deviceSheet(doc, modem)).toMatchObject({ connection: 'offer', code: '' });
expect(readProject(doc).remoteLinkConfigurations.get(modem)).toMatchObject({ relay: { role: 'offer' } });
expect(docSnapshot(doc).remoteLinkConfigurationsByNodeId.get(modem)).toMatchObject({ relay: { role: 'offer' } });
setModemConnection(doc, modem, 'join', 'me');
expect(deviceSheet(doc, modem)).toMatchObject({ connection: 'join', code: '' });
expect(setModemCode(doc, modem, 'k7f-3qx', 'me')).toEqual({ value: 'K7F3QX' });
expect(deviceSheet(doc, modem)).toMatchObject({ connection: 'join', code: 'K7F3QX' });
expect(docSnapshot(doc).remoteLinkConfigurationsByNodeId.get(modem)).toMatchObject({ relay: { role: 'join', code: 'K7F3QX' } });
// Misread characters (0, O, 1, I) and a wrong length are no code; the code stays.
for (const text of ['K7F3Q0', 'K7F3QI', 'K7F3Q', 'K7F3QXY']) expect(setModemCode(doc, modem, text, 'me')).toEqual({ invalid: true });
expect(deviceSheet(doc, modem)).toMatchObject({ code: 'K7F3QX' });
setModemConnection(doc, modem, 'inProject', 'me');
expect(deviceSheet(doc, modem)).toMatchObject({ connection: 'inProject', code: '', port: 12_345 });
expect(readProject(doc).remoteLinkConfigurations.get(modem)).not.toHaveProperty('relay');
});
it('is each one step of the undo, and touches nothing but a modem', () => {
const doc = new Y.Doc();
const modem = add(doc, 'remoteLink', 'Modem');
const notebook = add(doc, 'notebook');
const undo = createUndoManager(doc, 'me');
setModemConnection(doc, modem, 'join', 'me');
undo.stopCapturing();
setModemCode(doc, modem, 'ABCDEF', 'me');
undo.undo();
expect(deviceSheet(doc, modem)).toMatchObject({ connection: 'join', code: '' });
undo.undo();
expect(deviceSheet(doc, modem)).toMatchObject({ connection: 'inProject' });
setModemConnection(doc, notebook, 'offer', 'me');
expect((doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get(notebook)!.get('settings') as Y.Map<unknown>).has('remoteLink')).toBe(false);
});
});
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');
});
});
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', address: '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');
});
});
describe("a DHCP server's sheet", () => {
/** The devices of SheetRun's DHCP flows: a host 192.168.0.10/24 with gateway and DNS, a home router; the server with known settings. */
function servers() {
const doc = new Y.Doc();
const pc = add(doc, 'pc');
setHostAddress(doc, pc, 'defaultGateway', '192.168.0.254', 'me');
setHostAddress(doc, pc, 'dnsServer', '192.168.0.253', 'me');
const homeId = add(doc, 'gateway');
for (const id of [pc, homeId]) {
setDhcpServerAddress(doc, id, 'lowerBoundIPAddress', '192.168.0.100', 'me');
setDhcpServerAddress(doc, id, 'upperBoundIPAddress', '192.168.0.200', 'me');
setDhcpServerAddress(doc, id, 'gatewayIPAddress', '10.0.0.1', 'me');
setDhcpServerAddress(doc, id, 'dnsServerIPAddress', '10.0.0.2', 'me');
}
return { doc, pc, homeId };
}
it("shows what Java's dialog shows: the device's gateway and DNS server unless its own, a home router's LAN address", () => {
const { doc, pc, homeId } = servers();
const shown = (sheet: ReturnType<typeof host>['dhcpServer']) => ({
tfUntergrenze: [sheet.lowerBoundIPAddress, true],
tfObergrenze: [sheet.upperBoundIPAddress, true],
tfNetzmaske: [sheet.subnetMask, false],
tfGateway: [sheet.gateway, sheet.gatewayEditable],
tfDNSServer: [sheet.dnsServer, sheet.dnsServerEditable],
});
expect(shown(host(doc, pc).dhcpServer)).toEqual(java.dhcp.dhcpHostShown);
expect(shown(home(doc, homeId).dhcpServer)).toEqual(java.dhcp.dhcpHomeShown);
expect(home(doc, homeId).dhcpServer.ownSettingsOffered).toBe(false);
// A home router's choice has no effect (Java's dialog keeps the flag, the gateway stays its LAN address).
setDhcpServerFlag(doc, homeId, 'useOwnSettings', true, 'me');
expect(home(doc, homeId).dhcpServer).toMatchObject({ useOwnSettings: true, gateway: '192.168.0.10', gatewayEditable: false });
const bare = add(doc, 'pc');
expect([host(doc, bare).dhcpServer.gateway, host(doc, bare).dhcpServer.dnsServer]).toEqual(java.dhcp.hostWithoutGatewayShown);
setDhcpServerFlag(doc, pc, 'useOwnSettings', true, 'me');
expect(host(doc, pc).dhcpServer).toMatchObject({ gateway: '10.0.0.1', gatewayEditable: true, dnsServer: '10.0.0.2', dnsServerEditable: true, useOwnSettings: true });
setDhcpServerFlag(doc, pc, 'isActive', true, 'me');
expect(readProject(doc).dhcpServerConfigurations.get(pc)).toMatchObject({ isActive: true, useOwnSettings: true, lowerBoundIPAddress: '192.168.0.100' });
});
it("keeps a static assignment once both cells pass and its MAC address is free, unlike Java's silent drops", () => {
const { doc, pc } = servers();
// Java's add button takes the duplicate and the "_F" too (musterMacAddress); its server drops the duplicate on saving.
expect(java.dhcp.addable).toEqual([true, true, true, false, true, false]);
const rows = [
['aa:bb:cc:dd:ee:01', '192.168.0.50'],
['AA:BB:CC:DD:EE:01', '192.168.0.51'],
['AA:BB:CC:DD:EE:_F', '192.168.0.52'],
['AA:BB:CC:DD:EE:02', '192.168.0.256'],
['AA:BB:CC:DD:EE:03', '010.0.0.1'],
['AABBCCDDEE04', '192.168.0.54'],
] as const;
const outcomes = rows.map(([macAddress, ipAddress], index) => {
const result = addDhcpStaticAssignment(doc, pc, { macAddress, ipAddress }, `assignment-${index}`, 'me');
return 'assignment' in result ? 'taken' : 'error' in result ? result.error : `invalid ${result.invalid.join(' ')}`;
});
expect(outcomes).toEqual(['taken', 'macTaken', 'invalid macAddress', 'invalid ipAddress', 'taken', 'invalid macAddress']);
// Java kept the first, the "_F" its pattern lets through, and "010.0.0.1" as typed; here upper case and IP.ipCheck's form.
expect(java.dhcp.staticAssignments).toEqual([
['aa:bb:cc:dd:ee:01', '192.168.0.50'],
['AA:BB:CC:DD:EE:_F', '192.168.0.52'],
['AA:BB:CC:DD:EE:03', '010.0.0.1'],
]);
expect(host(doc, pc).dhcpServer.staticAssignments).toEqual([
{ macAddress: 'AA:BB:CC:DD:EE:01', ipAddress: '192.168.0.50' },
{ macAddress: 'AA:BB:CC:DD:EE:03', ipAddress: '10.0.0.1' },
]);
expect(setDhcpStaticCell(doc, pc, 1, 'macAddress', 'aa:bb:cc:dd:ee:01', 'me')).toEqual({ invalid: true });
expect(setDhcpStaticCell(doc, pc, 1, 'macAddress', 'aa:bb:cc:dd:ee:09', 'me')).toEqual({ value: 'AA:BB:CC:DD:EE:09' });
expect(setDhcpStaticCell(doc, pc, 0, 'ipAddress', '192.168.0.60', 'me')).toEqual({ value: '192.168.0.60' });
expect(setDhcpStaticCell(doc, pc, 5, 'ipAddress', '192.168.0.61', 'me')).toEqual({ value: '192.168.0.61' });
removeDhcpStaticAssignment(doc, pc, 7, 'me');
removeDhcpStaticAssignment(doc, pc, 0, 'me');
expect(readProject(doc).dhcpServerConfigurations.get(pc)?.staticAssignments).toEqual([{ id: 'assignment-4', macAddress: 'AA:BB:CC:DD:EE:09', ipAddress: '10.0.0.1' }]);
expect(addDhcpStaticAssignment(doc, 'nobody', { macAddress: 'AA:BB:CC:DD:EE:0A', ipAddress: '10.0.0.3' }, 'x', 'me')).toEqual({ invalid: [] });
});
it("compares MAC addresses ignoring case, as Java does, a file's lower case too, and lets a row keep its own", () => {
const doc = new Y.Doc();
const pc = add(doc, 'pc');
addDhcpStaticAssignment(doc, pc, { macAddress: 'AA:BB:CC:DD:EE:01', ipAddress: '10.0.0.5' }, 'a', 'me');
// A file keeps a MAC address as Java's dialog took it, in lower case.
const entry = ((doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get(pc)!.get('settings') as Y.Map<Y.Map<unknown>>).get('dhcpServer')!.get('staticAssignments') as Y.Array<Y.Map<unknown>>).get(0);
entry.set('macAddress', 'aa:bb:cc:dd:ee:01');
expect(addDhcpStaticAssignment(doc, pc, { macAddress: 'AA:BB:CC:DD:EE:01', ipAddress: '10.0.0.6' }, 'b', 'me')).toEqual({ error: 'macTaken' });
expect(setDhcpStaticCell(doc, pc, 0, 'macAddress', 'AA:BB:CC:DD:EE:01', 'me')).toEqual({ value: 'AA:BB:CC:DD:EE:01' });
});
it('takes a static assignment on a device without a server in the document, made as Java makes it', () => {
const doc = new Y.Doc();
const pc = add(doc, 'pc');
expect('assignment' in addDhcpStaticAssignment(doc, pc, { macAddress: 'AA:BB:CC:DD:EE:01', ipAddress: '10.0.0.5' }, 'a', 'me')).toBe(true);
expect(readProject(doc).dhcpServerConfigurations.get(pc)).toMatchObject({
isActive: false,
lowerBoundIPAddress: '0.0.0.0',
staticAssignments: [{ id: 'a', macAddress: 'AA:BB:CC:DD:EE:01', ipAddress: '10.0.0.5' }],
});
});
});
describe("a home router's port forwarding", () => {
const JAVA_ROWS = () => java.forwarding.rows.map(([protocolValue, publicPortValue, lanIPAddress, lanPortValue]) => ({ protocolValue, publicPortValue, lanIPAddress, lanPortValue }));
it('takes a row once all four cells pass, where Java keeps any row and its firmware forwards only the valid ones', () => {
const doc = new Y.Doc();
const id = add(doc, 'gateway');
const outcomes = JAVA_ROWS().map((row) => {
const result = addPortForwarding(doc, id, row, 'me');
return 'row' in result ? 'taken' : result.invalid.join(' ');
});
// Java's firmware forwards the first only ("17" it would take, but not the leading zero of "05353").
expect(java.forwarding.valid).toEqual([true, false, false, false, false]);
expect(outcomes).toEqual(['taken', 'publicPortValue', 'publicPortValue', 'protocolValue lanIPAddress', 'protocolValue lanPortValue']);
expect(home(doc, id).portForwarding).toEqual([{ ...JAVA_ROWS()[0]!, forwarded: true }]);
expect(addPortForwarding(doc, 'nobody', JAVA_ROWS()[0]!, 'me')).toEqual({ invalid: [] });
// Java's dialog offers TCP and UDP; Java's firmware would take "tcp", "6" or "17" from a file.
for (const protocolValue of ['tcp', '6', 'ICMP']) expect(addPortForwarding(doc, id, { ...JAVA_ROWS()[0]!, protocolValue }, 'me')).toEqual({ invalid: ['protocolValue'] });
});
it("marks the rows a file brought that Java's firmware would not forward, and edits and removes rows", () => {
const doc = new Y.Doc();
const id = add(doc, 'gateway');
const settings = doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get(id)!.get('settings') as Y.Map<unknown>;
const list = new Y.Array<Y.Map<unknown>>();
settings.set('portForwarding', list);
list.push(JAVA_ROWS().map((row) => toY({ ...row }) as Y.Map<unknown>));
expect(home(doc, id).portForwarding.map((row) => row.forwarded)).toEqual(java.forwarding.valid);
expect(setPortForwardingCell(doc, id, 1, 'publicPortValue', '0053', 'me')).toEqual({ invalid: true });
expect(setPortForwardingCell(doc, id, 1, 'publicPortValue', ' 53', 'me')).toEqual({ value: '53' });
expect(setPortForwardingCell(doc, id, 1, 'protocolValue', 'ICMP', 'me')).toEqual({ invalid: true });
expect(setPortForwardingCell(doc, id, 0, 'protocolValue', 'UDP', 'me')).toEqual({ value: 'UDP' });
expect(home(doc, id).portForwarding.map((row) => [row.protocolValue, row.publicPortValue, row.forwarded])).toEqual([
['UDP', '80', true],
['UDP', '53', true],
['TCP', 'abc', false],
['tcp', '443', false],
['17', '5353', false],
]);
for (const index of [9, 4, 3, 2, 1]) removePortForwarding(doc, id, index, 'me');
expect(readProject(doc).portForwardingRowsByNodeId.get(id)).toHaveLength(1);
removePortForwarding(doc, id, 0, 'me');
expect(settings.has('portForwarding')).toBe(false);
});
it('keeps the DHCP server and the port forwarding through a saved file', () => {
const doc = new Y.Doc();
const id = add(doc, 'gateway');
setDhcpServerAddress(doc, id, 'lowerBoundIPAddress', '192.168.0.100', 'me');
setDhcpServerAddress(doc, id, 'dnsServerIPAddress', '8.8.8.8', 'me');
setDhcpServerFlag(doc, id, 'isActive', true, 'me');
addDhcpStaticAssignment(doc, id, { macAddress: 'AA:BB:CC:DD:EE:01', ipAddress: '192.168.0.50' }, 'a', 'me');
addPortForwarding(doc, id, JAVA_ROWS()[0]!, 'me');
let counter = 0;
const reopened = openProjectArchive(saveProjectArchive(readProject(doc)).archive, () => `50000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`);
const again = reopened.graph.nodes[0]!.id;
expect(reopened.dhcpServerConfigurations.get(again)).toMatchObject({
isActive: true,
lowerBoundIPAddress: '192.168.0.100',
dnsServerIPAddress: '8.8.8.8',
staticAssignments: [{ macAddress: 'AA:BB:CC:DD:EE:01', ipAddress: '192.168.0.50' }],
});
expect(reopened.portForwardingRowsByNodeId.get(again)).toEqual([JAVA_ROWS()[0]]);
});
});
describe("a router's firewall", () => {
const JAVA_PROTOCOLS: Readonly<Record<number, string>> = { [-1]: 'all', 1: 'icmp', 6: 'tcp', 17: 'udp' };
const COLUMNS: Readonly<Record<number, FirewallRuleColumn>> = { 1: 'sourceIPAddress', 2: 'sourceSubnetMask', 3: 'destinationIPAddress', 4: 'destinationSubnetMask', 6: 'port' };
const ruleOfJava = (rule: JavaFirewallRule) => ({
sourceIPAddress: rule.srcIP,
sourceSubnetMask: rule.srcMask,
destinationIPAddress: rule.destIP,
destinationSubnetMask: rule.destMask,
port: rule.port,
protocol: JAVA_PROTOCOLS[rule.protocol],
action: rule.action === 1 ? 'accept' : 'drop',
});
function firewalled() {
const doc = new Y.Doc();
const id = add(doc, 'router');
const rules = () => router(doc, id).firewall.rules;
return { doc, id, rules };
}
it("starts as Java's, and makes a new rule as Java's does", () => {
const { doc, id, rules } = firewalled();
const [isActive, policy, dropIcmp, filterSyn] = java.firewall.newFirewall;
expect(router(doc, id).firewall).toEqual({ isActive, defaultPolicy: policy === 1 ? 'accept' : 'drop', dropIcmp, filterSynSegmentsOnly: filterSyn, rules: [] });
expect(addFirewallRule(doc, id, 'me')).toBe(0);
expect(rules()).toEqual([ruleOfJava(java.firewall.newRule)]);
expect(addFirewallRule(doc, 'nobody', 'me')).toBeUndefined();
});
it("takes a cell by Java's checks, never a value they refuse (Java's table keeps it in the rule)", () => {
// Java: what its rule holds after the cell is taken, and whether the rule then passes its own check.
expect(java.firewall.cells.filter((each) => each.column in COLUMNS).map((each) => [each.column, each.input, ruleOfJava(each.rule)[COLUMNS[each.column]!], each.valid])).toEqual([
[1, '10.0.0.0', '10.0.0.0', true],
[1, 'abc', 'abc', false],
[1, '.0.0.1', '.0.0.1', true],
[1, '999.999.999.999', '999.999.999.999', true],
[1, '010.0.0.0', '010.0.0.0', true],
[1, ' ', ' ', true],
[2, '255.255.0.0', '255.255.0.0', true],
[2, '255.0.255.0', '255.0.255.0', false],
[2, '0.0.0.0', '0.0.0.0', false],
[2, '255.255.255.255', '255.255.255.255', true],
[2, '0255.255.0.0', '0255.255.0.0', true],
[3, '192.168.0.1', '192.168.0.1', true],
[3, '999.999.999.999', '999.999.999.999', false],
[4, '255.255.255.0', '255.255.255.0', true],
[6, '80', 80, true],
[6, 'abc', -1, true],
[6, '0', 0, false],
[6, '080', 80, true],
[6, '', -1, true],
[6, '65536', 65_536, false],
]);
// Here: what the rule takes, or that it takes nothing. Not adopted: ".0.0.1" (no address), "abc" as all ports, "080" (musterPort).
const ours = java.firewall.cells
.filter((each) => each.column in COLUMNS)
.map((each) => {
const { doc, id, rules } = firewalled();
addFirewallRule(doc, id, 'me');
const result = setFirewallRuleCell(doc, id, 0, COLUMNS[each.column]!, each.input, 'me');
return 'invalid' in result ? 'refused' : rules()[0]![COLUMNS[each.column]!];
});
expect(ours).toEqual([
'10.0.0.0',
'refused',
'refused',
'999.999.999.999',
'10.0.0.0',
'',
'255.255.0.0',
'refused',
'refused',
'255.255.255.255',
'255.255.0.0',
'192.168.0.1',
'refused',
'255.255.255.0',
80,
'refused',
'refused',
'refused',
-1,
'refused',
]);
});
it("chooses a rule's protocol and action as Java's lists do", () => {
const choices = java.firewall.cells.filter((each) => !(each.column in COLUMNS)).map((each) => ruleOfJava(each.rule));
expect(choices.map((each) => [each.protocol, each.action])).toEqual([
['udp', 'drop'],
['all', 'drop'],
['tcp', 'accept'],
]);
const { doc, id, rules } = firewalled();
addFirewallRule(doc, id, 'me');
setFirewallRuleChoice(doc, id, 0, { protocol: 'udp' }, 'me');
setFirewallRuleChoice(doc, id, 0, { action: 'accept' }, 'me');
expect(rules()[0]).toMatchObject({ protocol: 'udp', action: 'accept' });
setFirewallRuleChoice(doc, id, 5, { protocol: 'all' }, 'me');
expect(rules()).toHaveLength(1);
});
it("moves and removes rules as Java's buttons do, the first going no higher and the last no lower", () => {
const { doc, id, rules } = firewalled();
for (const source of ['1.1.1.1', '2.2.2.2', '3.3.3.3']) setFirewallRuleCell(doc, id, addFirewallRule(doc, id, 'me')!, 'sourceIPAddress', source, 'me');
// Java's buttons pass the row's number (1 for the first); here its place.
const ours = java.firewall.moves.map(([step, number]) => {
const answer = step === 'delete' ? (removeFirewallRule(doc, id, number - 1, 'me'), null) : moveFirewallRule(doc, id, number - 1, step === 'up' ? -1 : 1, 'me');
return [step, number, answer, rules().map((rule) => rule.sourceIPAddress)];
});
expect(ours).toEqual(java.firewall.moves);
expect(moveFirewallRule(doc, id, 7, 1, 'me')).toBe(false);
removeFirewallRule(doc, id, 7, 'me');
expect(rules()).toHaveLength(2);
});
it('switches its flags and its default, and keeps all through a saved file', () => {
const { doc, id } = firewalled();
const homeId = add(doc, 'gateway');
for (const device of [id, homeId]) {
setFirewallFlag(doc, device, 'isActive', true, 'me');
setFirewallFlag(doc, device, 'dropIcmp', true, 'me');
setFirewallFlag(doc, device, 'filterSynSegmentsOnly', false, 'me');
setFirewallDefaultPolicy(doc, device, 'accept', 'me');
addFirewallRule(doc, device, 'me');
setFirewallRuleCell(doc, device, 0, 'destinationIPAddress', '10.0.0.0', 'me');
setFirewallRuleCell(doc, device, 0, 'destinationSubnetMask', '255.0.0.0', 'me');
setFirewallRuleCell(doc, device, 0, 'port', '80', 'me');
}
expect(home(doc, homeId).firewall).toMatchObject({ isActive: true, dropIcmp: true, filterSynSegmentsOnly: false, defaultPolicy: 'accept' });
let counter = 0;
const reopened = openProjectArchive(saveProjectArchive(readProject(doc)).archive, () => `60000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`);
for (const node of reopened.graph.nodes) {
expect(reopened.firewallConfigurationsByNodeId.get(node.id)).toMatchObject({
isActive: true,
dropIcmp: true,
filterSynSegmentsOnly: false,
defaultPolicy: 'accept',
rules: [{ sourceIPAddress: '', destinationIPAddress: '10.0.0.0', destinationSubnetMask: '255.0.0.0', port: 80, protocol: 'tcp', action: 'drop' }],
});
}
});
});
describe("a router's connections", () => {
/** SheetRun's network: router R with three cards, PC A plugged into the first, switch S into the second. */
function network() {
const doc = new Y.Doc();
const id = add(doc, 'router', 'R', 3);
router(doc, id).interfaces.forEach((card, index) => setInterfaceAddress(doc, id, card.portId, 'ipAddress', `10.${index}.0.1`, 'me'));
const pc = add(doc, 'pc', 'A');
setHostAddress(doc, pc, 'ipAddress', '10.0.0.2', 'me');
const sw = add(doc, 'networkSwitch', 'S');
expect(connect(doc, { node: pc }, { node: id }, 'cable-a', 'me')).toEqual({ link: 'cable-a' });
expect(connect(doc, { node: id, port: router(doc, id).interfaces[1]!.portId }, { node: sw }, 'cable-s', 'me')).toEqual({ link: 'cable-s' });
return { doc, id, pc, sw };
}
/** What Java's dialog would show of the router, with the card marked. */
function shown(doc: Y.Doc, id: string, marked: number | null) {
const cards = router(doc, id).interfaces;
const pad = <T,>(each: T[], blank: T) => [...each, ...Array<T>(8 - each.length).fill(blank)];
return {
local: pad(cards.map((card, index) => `NIC ${index + 1}: ${card.ipAddress}`), ''),
remote: pad(cards.map((card) => (card.connected === null ? '' : card.connected.address === null ? card.connected.device : `${card.connected.device} (${card.connected.address})`)), ''),
buttons: pad(cards.map((card, index) => (index === marked ? 'marked' : card.connected === null ? 'free' : 'plugged')), 'off'),
add: cards.length < 8,
remove: cards.length > 1,
cards: cards.map((card) => [card.ipAddress, card.subnetMask, card.connected?.device ?? '']),
cables: doc.getMap(ROOTS.links).size,
};
}
it('swap, add and remove cards as Java’s dialog does, click by click, down to the last card', () => {
const { doc, id } = network();
let marked: number | null = null;
const steps = java.connections.steps;
const last = steps.findIndex((step) => step.cards.length === 1);
for (const [index, step] of steps.slice(0, last + 1).entries()) {
if (step.click.startsWith('c')) {
const next = connectionsClick(marked, Number(step.click.slice(1)));
if (next.swap !== undefined) {
const ports = router(doc, id).interfaces.map((card) => card.portId);
swapRouterCables(doc, id, ports[next.swap[0]]!, ports[next.swap[1]]!, 'me');
}
marked = next.marked;
} else if (step.click === '+') {
// Java's "+" is greyed out at eight cards; its click does nothing then.
expect(addRouterInterface(doc, id, newId(), 'me'), `step ${index}`).toBe(steps[index - 1]!.add);
marked = null;
} else if (step.click === '-') {
expect(removeRouterInterface(doc, id, 'me'), `step ${index}`).toBe(true);
marked = null;
}
const { local, remote, buttons, add: addable, remove, cards, cables } = step;
// Java's "-" stays open at one card; here the last card stays (below).
expect(shown(doc, id, marked), `step ${index} ${step.click}`).toEqual({ local, remote, buttons, add: addable, remove: index === last ? false : remove, cards, cables });
}
expect(steps[last]!.remove).toBe(true);
expect(steps.at(-1)!.cards).toEqual([]);
// Java's router without a card keeps no gateway; the one with a card does.
expect([steps[last]!.gateway, steps.at(-1)!.gateway]).toEqual(['10.0.0.254', null]);
expect(steps[last]!.panelError).toBeNull();
});
it('keep the last card, with its cable, and its gateway', () => {
const doc = new Y.Doc();
const id = add(doc, 'router', 'R', 2);
const pc = add(doc, 'pc', 'A');
connect(doc, { node: pc }, { node: id }, 'cable', 'me');
setRouterGateway(doc, id, '192.168.0.254', 'me');
expect(removeRouterInterface(doc, id, 'me')).toBe(true);
expect(removeRouterInterface(doc, id, 'me')).toBe(false);
expect(router(doc, id).interfaces).toHaveLength(1);
expect(router(doc, id).interfaces[0]!.connected).toMatchObject({ device: 'A', link: 'cable' });
expect(router(doc, id).gateway).toBe('192.168.0.254');
});
it('remove the last card’s cable with it, and free the other device’s port', () => {
const { doc, id, sw } = network();
const ports = router(doc, id).interfaces.map((card) => card.portId);
expect(swapRouterCables(doc, id, ports[1]!, ports[2]!, 'me')).toBe(true);
expect(removeRouterInterface(doc, id, 'me')).toBe(true);
expect([...doc.getMap<Y.Map<unknown>>(ROOTS.links).keys()]).toEqual(['cable-a']);
expect(java.connections.switchPortsFree).toBe(true);
// The switch's port takes another cable.
const pc = add(doc, 'pc', 'B');
expect(connect(doc, { node: pc }, { node: sw }, 'cable-b', 'me')).toEqual({ link: 'cable-b' });
expect(java.connections.hostPortFree).toBe(true);
});
it('give a new card Java’s address and mask, its own MAC address, the next label and no cable', () => {
const { doc, id } = network();
expect(addRouterInterface(doc, id, 'port-4', 'me')).toBe(true);
const cards = router(doc, id).interfaces;
expect(cards[3]).toMatchObject({ portId: 'port-4', ipAddress: '192.168.0.10', subnetMask: '255.255.255.0', connected: null });
expect(new Set(cards.map((card) => card.macAddress)).size).toBe(4);
const ports = (doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get(id)!.get('ports') as Y.Array<Y.Map<unknown>>).toArray();
expect(ports.map((port) => port.get('label'))).toEqual(['rt1', 'rt2', 'rt3', 'rt4']);
expect(ports[3]!.get('occupied')).toBe(false);
});
it('keep each cable’s identity and devices when cards swap, and move nothing between free cards', () => {
const { doc, id, pc, sw } = network();
const ports = router(doc, id).interfaces.map((card) => card.portId);
const ends = () =>
[...doc.getMap<Y.Map<unknown>>(ROOTS.links).entries()].map(([linkId, link]) => [linkId, link.get('sourceNode'), link.get('sourcePort'), link.get('targetNode'), link.get('targetPort')]);
const before = ends();
expect(swapRouterCables(doc, id, ports[0]!, ports[1]!, 'me')).toBe(true);
// The PC plugged first (the router is the cable's target), the router into the switch (its source).
expect(ends()).toEqual([
['cable-a', pc, before[0]![2], id, ports[1]],
['cable-s', id, ports[0], sw, before[1]![4]],
]);
addRouterInterface(doc, id, 'port-4', 'me');
const now = ends();
expect(swapRouterCables(doc, id, ports[2]!, 'port-4', 'me')).toBe(false);
expect(ends()).toEqual(now);
});
it('refuse a card twice, a card not the router’s, and devices that are no router', () => {
const { doc, id, pc } = network();
const ports = router(doc, id).interfaces.map((card) => card.portId);
const updates: unknown[] = [];
doc.on('update', (update) => updates.push(update));
expect(swapRouterCables(doc, id, ports[0]!, ports[0]!, 'me')).toBe(false);
expect(swapRouterCables(doc, id, ports[0]!, 'no-port', 'me')).toBe(false);
expect(swapRouterCables(doc, id, 'no-port', ports[0]!, 'me')).toBe(false);
const pcPort = (doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get(pc)!.get('ports') as Y.Array<Y.Map<unknown>>).get(0).get('id') as string;
expect(swapRouterCables(doc, id, ports[2]!, pcPort, 'me')).toBe(false);
expect(swapRouterCables(doc, 'nobody', ports[0]!, ports[1]!, 'me')).toBe(false);
const homeId = add(doc, 'gateway');
const [wan, lan] = [home(doc, homeId).wan.portId, home(doc, homeId).lan.portId];
expect(swapRouterCables(doc, homeId, wan, lan, 'me')).toBe(false);
expect(addRouterInterface(doc, homeId, 'port-x', 'me')).toBe(false);
expect(removeRouterInterface(doc, homeId, 'me')).toBe(false);
expect(addRouterInterface(doc, pc, 'port-x', 'me')).toBe(false);
expect(removeRouterInterface(doc, 'nobody', 'me')).toBe(false);
const before = updates.length;
expect(before).toBeGreaterThan(0);
for (let more = 3; more < 8; more++) expect(addRouterInterface(doc, id, `port-${more}`, 'me')).toBe(true);
expect(addRouterInterface(doc, id, 'port-9', 'me')).toBe(false);
expect(router(doc, id).interfaces).toHaveLength(8);
expect(updates).toHaveLength(before + 5);
});
it('click as Java’s dialog does: mark, unmark, swap', () => {
expect(connectionsClick(null, 2)).toEqual({ marked: 2 });
expect(connectionsClick(2, 2)).toEqual({ marked: null });
expect(connectionsClick(2, 0)).toEqual({ marked: null, swap: [2, 0] });
});
it('are each one step of the person’s undo, and go into the file', () => {
const { doc, id } = network();
const undo = createUndoManager(doc, 'me');
const ports = router(doc, id).interfaces.map((card) => card.portId);
undo.stopCapturing();
addRouterInterface(doc, id, 'port-4', 'me');
undo.stopCapturing();
swapRouterCables(doc, id, ports[0]!, 'port-4', 'me');
undo.stopCapturing();
removeRouterInterface(doc, id, 'me');
expect(router(doc, id).interfaces.map((card) => card.connected?.device ?? '')).toEqual(['', 'S', '']);
undo.undo();
expect(router(doc, id).interfaces.map((card) => card.connected?.device ?? '')).toEqual(['', 'S', '', 'A']);
undo.undo();
expect(router(doc, id).interfaces.map((card) => card.connected?.device ?? '')).toEqual(['A', 'S', '', '']);
swapRouterCables(doc, id, ports[0]!, 'port-4', 'me');
let counter = 0;
const reopened = openProjectArchive(saveProjectArchive(readProject(doc)).archive, () => `70000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`);
const again = reopened.graph.nodes.find((node) => node.displayName === 'R')!;
expect(again.ports.map((port) => port.label)).toEqual(['rt1', 'rt2', 'rt3', 'rt4']);
const plugged = reopened.graph.links.flatMap((link) => [link.sourcePortId, link.targetPortId]);
expect(again.ports.map((port) => plugged.includes(port.id))).toEqual([false, true, false, true]);
});
it('name the address of the card a cable plugs into: a computer’s, a router’s, none for a switch', () => {
const { doc, id } = network();
const other = add(doc, 'router', 'R2');
setInterfaceAddress(doc, other, router(doc, other).interfaces[1]!.portId, 'ipAddress', '10.2.0.2', 'me');
connect(doc, { node: id, port: router(doc, id).interfaces[2]!.portId }, { node: other, port: router(doc, other).interfaces[1]!.portId }, 'cable-r', 'me');
const homeId = add(doc, 'gateway', 'H');
const modem = add(doc, 'remoteLink', 'M');
addRouterInterface(doc, id, 'port-4', 'me');
addRouterInterface(doc, id, 'port-5', 'me');
connect(doc, { node: homeId, port: home(doc, homeId).lan.portId }, { node: id, port: 'port-4' }, 'cable-h', 'me');
connect(doc, { node: modem }, { node: id, port: 'port-5' }, 'cable-m', 'me');
expect(router(doc, id).interfaces.map((card) => card.connected && [card.connected.device, card.connected.address])).toEqual([
['A', '10.0.0.2'],
['S', null],
['R2', '10.2.0.2'],
['H', '192.168.0.10'],
['M', null],
]);
});
});