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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 11:22:28 +00:00
co-authored by Claude Opus 5.5
parent 806df6baca
commit 9b7ec2fc72
25 changed files with 2667 additions and 73 deletions
+415
View File
@@ -0,0 +1,415 @@
// The device sheet: what a field takes, measured against FILIUS' own settings
// panels (tools/java-oracle's SheetRun typed the texts of sheet.cases.txt
// into them, sheet.java.json), and what its changes do to the document — the
// wireless connections moved as Java moves them, each change one step of
// the person's undo, and everything kept through a saved file.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import * as Y from 'yjs';
import { openProjectArchive, saveProjectArchive } from '@mdg-filius/fls';
import {
addDevice,
checkField,
connect,
createUndoManager,
deviceSheet,
readProject,
renameDevice,
ROOTS,
setDhcpClient,
setHostAddress,
setHostLabels,
setHostWireless,
setIpForwarding,
setModemPort,
setSwitchCloud,
setSwitchRetention,
setSwitchSsid,
topologyView,
type FieldRule,
type HostSheet,
type NewDevice,
} from '../src/index.js';
interface JavaCase {
readonly device: 'host' | 'switch' | 'modem';
readonly field: string;
readonly input: string;
readonly start: string;
readonly marked: boolean;
readonly stored: string;
readonly shown: string;
}
const java = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'sheet.java.json'), 'utf8')) as {
readonly cases: readonly JavaCase[];
readonly flows: { readonly useMacAsNameThenOff: string; readonly useIpAsNameThenOff: string; readonly dhcpClientStopsServer: boolean; readonly dhcpClient: boolean };
};
const RULES: Readonly<Record<string, FieldRule>> = {
'host name': 'name',
'host ip': 'ipAddress',
'host mask': 'subnetMask',
'host gateway': 'ipAddressOrEmpty',
'host dns': 'ipAddressOrEmpty',
'switch name': 'name',
'switch ssid': 'ssid',
'switch retention': 'seconds',
'modem name': 'name',
'modem port': 'port',
};
/** Fields whose text is judged without the spaces around it. */
const TRIMMED = new Set(['host ip', 'host mask', 'host gateway', 'host dns', 'switch retention', 'modem port']);
/** What Java's model holds in the units of the sheet's value: seconds for the retention time. */
const javaValue = (each: JavaCase) => (each.field === 'retention' ? String(Number(each.stored) / 1000) : each.stored);
/** The case Java judged for a text: the trimmed text's own case where spaces are left out. */
function judged(each: JavaCase): JavaCase {
const key = `${each.device} ${each.field}`;
if (!TRIMMED.has(key) || each.input.trim() === each.input) return each;
const twin = java.cases.find((other) => other.device === each.device && other.field === each.field && other.input === each.input.trim());
if (twin === undefined) throw new Error(`no case for ${JSON.stringify(each.input.trim())} (${key})`);
return twin;
}
describe("a field's rule, against FILIUS' settings panels", () => {
it('has a case for every field the sheet checks', () => {
expect(new Set(java.cases.map((each) => `${each.device} ${each.field}`))).toEqual(new Set(Object.keys(RULES)));
expect(java.cases.length).toBeGreaterThan(70);
});
it('takes a text exactly when Java neither marks nor refuses it, as the value Java keeps', () => {
for (const each of java.cases) {
const key = `${each.device} ${each.field}`;
const verdict = judged(each);
// Taken: the value changed, or the text was the value already.
const took = verdict.stored !== verdict.start || verdict.input === verdict.start;
// A name is taken as typed. A modem's port Java never marks: it takes any int; here Java's own rule for ports.
const expected = RULES[key] === 'name' ? true : key === 'modem port' ? took && /^([1-9][0-9]{0,4})$/.test(verdict.input) && Number(verdict.input) <= 65_535 : !verdict.marked && took;
const checked = checkField(RULES[key]!, each.input);
expect('value' in checked, `${key} ${JSON.stringify(each.input)}`).toBe(expected);
if ('value' in checked) expect(checked.value, `${key} ${JSON.stringify(each.input)}`).toBe(RULES[key] === 'name' ? each.input : javaValue(verdict));
}
});
it("names where Java's two judgements disagree, which here are one", () => {
const disagreeing = java.cases.filter((each) => RULES[`${each.device} ${each.field}`] !== 'name' && each.field !== 'port' && each.marked === (each.stored !== each.start));
// Marked red, yet taken; or black, yet refused (the gateway's spaces Java trims to empty).
expect(disagreeing.map((each) => `${each.field} ${JSON.stringify(each.input)} ${each.marked ? 'marked, taken' : 'refused'}`)).toEqual([
'ip "10..0.0.1" marked, taken',
'ip ".0.0.1" refused',
'ip "+10.0.0.1" marked, taken',
'ip "10.0.0.1." marked, taken',
'ip "١٠.0.0.1" marked, taken',
'ip "-0.0.0.1" marked, taken',
'mask "255..255.0.0" marked, taken',
'gateway " " marked, taken',
'gateway "192.168..1.1" marked, taken',
'dns " " marked, taken',
'retention "" refused',
'retention "-5" marked, taken',
'retention "99999999999999999999" refused',
'retention "٣" marked, taken',
]);
const ports = java.cases.filter((each) => each.field === 'port' && each.stored !== each.start);
expect(ports.map((each) => each.input)).toEqual(['0', '65535', '65536', '-1', '080', '80']);
});
it("shows what the device holds once a field is left, as Java's field does", () => {
for (const each of java.cases) if (each.field !== 'retention') expect(each.shown, each.input).toBe(each.stored);
});
});
let ids = 0;
const newId = () => `20000000-0000-0000-0000-${(++ids).toString(16).padStart(12, '0')}`;
function add(doc: Y.Doc, kind: NewDevice['kind'], name: string = kind): string {
const id = newId();
addDevice(doc, { kind, id, name, x: 100, y: 100, newId }, 'me');
return id;
}
const host = (doc: Y.Doc, id: string) => deviceSheet(doc, id) as HostSheet;
describe("a PC's sheet", () => {
it("shows a new PC as Java's panel does", () => {
const doc = new Y.Doc();
const pc = add(doc, 'pc', 'Rechner');
const sheet = host(doc, pc);
expect({ ...sheet, macAddress: undefined }).toEqual({
kind: 'pc',
name: 'Rechner',
labels: { ipAddress: false, macAddress: false },
macAddress: undefined,
ipAddress: '192.168.0.10',
subnetMask: '255.255.255.0',
defaultGateway: '',
dnsServer: '',
dhcpClient: false,
ipForwarding: false,
wireless: false,
ssid: '',
wirelessPossible: true,
networks: [],
});
expect(sheet.macAddress).toMatch(/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/);
expect(deviceSheet(doc, 'nobody')).toBeUndefined();
});
it('takes an address as Java keeps it, keeps the old one for a marked text, and says which', () => {
const doc = new Y.Doc();
const pc = add(doc, 'pc');
expect(setHostAddress(doc, pc, 'ipAddress', ' 010.000.000.001 ', 'me')).toEqual({ value: '10.0.0.1' });
expect(setHostAddress(doc, pc, 'ipAddress', '10.0.0.256', 'me')).toEqual({ invalid: true });
expect(setHostAddress(doc, pc, 'subnetMask', '255.0.255.0', 'me')).toEqual({ value: '255.0.255.0' });
expect(setHostAddress(doc, pc, 'defaultGateway', '10.0.0.254', 'me')).toEqual({ value: '10.0.0.254' });
expect(setHostAddress(doc, pc, 'dnsServer', '8.8.8', 'me')).toEqual({ invalid: true });
expect(setHostAddress(doc, pc, 'dnsServer', '8.8.8.8', 'me')).toEqual({ value: '8.8.8.8' });
expect(readProject(doc).deviceConfigurations.get(pc)).toEqual({ ipAddress: '10.0.0.1', subnetMask: '255.0.255.0', defaultGateway: '10.0.0.254', dnsServer: '8.8.8.8' });
expect(setHostAddress(doc, pc, 'defaultGateway', '', 'me')).toEqual({ value: '' });
expect(host(doc, pc).defaultGateway).toBe('');
expect(setHostAddress(doc, 'nobody', 'ipAddress', '10.0.0.1', 'me')).toEqual({ value: '10.0.0.1' });
});
it('is renamed, and shows its address, its MAC address or both as its name', () => {
const doc = new Y.Doc();
const pc = add(doc, 'pc');
renameDevice(doc, pc, 'PC-A', 'me');
setHostLabels(doc, pc, { ipAddress: true, macAddress: false }, 'me');
expect(topologyView(doc).nodes[0]!.label).toBe('192.168.0.10');
setHostLabels(doc, pc, { ipAddress: false, macAddress: true }, 'me');
expect(topologyView(doc).nodes[0]!.label).toBe(host(doc, pc).macAddress);
setHostLabels(doc, pc, { ipAddress: true, macAddress: true }, 'me');
expect(host(doc, pc).labels).toEqual({ ipAddress: true, macAddress: true });
expect(topologyView(doc).nodes[0]!.label).toBe(`192.168.0.10 (${host(doc, pc).macAddress})`);
setHostLabels(doc, pc, { ipAddress: false, macAddress: false }, 'me');
expect(topologyView(doc).nodes[0]!.label).toBe('PC-A');
});
it("keeps its name while it shows its MAC address and another field is taken (Java's panel overwrites it)", () => {
// Java: the name became the MAC address; with the address shown instead, it stayed.
expect(java.flows.useMacAsNameThenOff).toMatch(/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/);
expect(java.flows.useIpAsNameThenOff).toBe('PC-A');
const doc = new Y.Doc();
const pc = add(doc, 'pc', 'PC-A');
setHostLabels(doc, pc, { ipAddress: false, macAddress: true }, 'me');
setHostAddress(doc, pc, 'defaultGateway', '192.168.0.1', 'me');
setHostLabels(doc, pc, { ipAddress: false, macAddress: false }, 'me');
expect(host(doc, pc).name).toBe('PC-A');
});
it("keeps IP forwarding only where it differs from the kind's", () => {
const doc = new Y.Doc();
const pc = add(doc, 'pc');
const router = add(doc, 'router');
setIpForwarding(doc, pc, true, 'me');
setIpForwarding(doc, router, false, 'me');
expect(host(doc, pc).ipForwarding).toBe(true);
expect(readProject(doc).ipForwardingByNodeId).toEqual(
new Map([
[pc, true],
[router, false],
]),
);
setIpForwarding(doc, pc, false, 'me');
setIpForwarding(doc, router, true, 'me');
expect(readProject(doc).ipForwardingByNodeId).toEqual(new Map());
});
it("uses DHCP, which stops its own DHCP server, as Java's panel does", () => {
expect(java.flows).toMatchObject({ dhcpClient: true, dhcpClientStopsServer: true });
const doc = new Y.Doc();
const pc = add(doc, 'pc');
const settings = doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get(pc)!.get('settings') as Y.Map<unknown>;
const server = new Y.Map<unknown>();
server.set('isActive', true);
settings.set('dhcpServer', server);
setDhcpClient(doc, pc, true, 'me');
expect(host(doc, pc).dhcpClient).toBe(true);
expect(readProject(doc).dhcpClientConfigurations.get(pc)).toEqual({ isEnabled: true });
expect(server.get('isActive')).toBe(false);
setDhcpClient(doc, pc, false, 'me');
expect(host(doc, pc).dhcpClient).toBe(false);
expect(readProject(doc).dhcpClientConfigurations.has(pc)).toBe(false);
});
});
describe('a wireless card', () => {
/** Two switches with their SSIDs and a notebook. */
function wlan() {
const doc = new Y.Doc();
const first = add(doc, 'networkSwitch');
const second = add(doc, 'networkSwitch');
const notebook = add(doc, 'notebook');
setSwitchSsid(doc, first, 'Schule', 'me');
setSwitchSsid(doc, second, 'Gast', 'me');
const ends = () => readProject(doc).graph.links.map((link) => [link.sourceNodeId, link.targetNodeId]);
return { doc, first, second, notebook, ends };
}
it('offers the switches’ networks, each once', () => {
const { doc, notebook } = wlan();
const third = add(doc, 'networkSwitch');
setSwitchSsid(doc, third, 'Schule', 'me');
expect(host(doc, notebook).networks).toEqual(['Schule', 'Gast']);
});
it('joins the first switch with its SSID, moves with the SSID and leaves on the cable', () => {
const { doc, first, second, notebook, ends } = wlan();
expect(setHostWireless(doc, notebook, true, 'Schule', 'link-1', 'me')).toEqual({ link: 'link-1' });
expect(ends()).toEqual([[notebook, first]]);
expect(topologyView(doc).links[0]!.wireless).toBe(true);
expect(host(doc, notebook)).toMatchObject({ wireless: true, ssid: 'Schule', wirelessPossible: true });
expect(setHostWireless(doc, notebook, true, 'Gast', 'link-2', 'me')).toEqual({ link: 'link-2' });
expect(ends()).toEqual([[notebook, second]]);
// The same again changes nothing.
expect(setHostWireless(doc, notebook, true, 'Gast', 'link-3', 'me')).toEqual({});
expect(ends()).toEqual([[notebook, second]]);
expect(setHostWireless(doc, notebook, false, 'Gast', 'link-4', 'me')).toEqual({});
expect(ends()).toEqual([]);
// Java keeps the SSID with the cable.
expect(readProject(doc).hostWirelessConfigurations.get(notebook)).toEqual({ isEnabled: false, ssid: 'Gast' });
expect(setHostWireless(doc, notebook, false, '', 'link-5', 'me')).toEqual({});
expect(readProject(doc).hostWirelessConfigurations.has(notebook)).toBe(false);
});
it('joins the first of two switches with its SSID', () => {
const { doc, second, notebook, ends } = wlan();
const third = add(doc, 'networkSwitch');
setSwitchSsid(doc, third, 'Gast', 'me');
expect(setHostWireless(doc, notebook, true, 'Gast', 'link-1', 'me')).toEqual({ link: 'link-1' });
expect(ends()).toEqual([[notebook, second]]);
});
it('joins no network without an SSID or without a switch that has it', () => {
const { doc, notebook, ends } = wlan();
expect(setHostWireless(doc, notebook, true, '', 'link-1', 'me')).toEqual({});
expect(setHostWireless(doc, notebook, true, 'Nirgends', 'link-2', 'me')).toEqual({});
expect(ends()).toEqual([]);
expect(host(doc, notebook)).toMatchObject({ wireless: true, ssid: 'Nirgends' });
});
it('cannot be chosen while its cable is plugged, and says why a full switch refuses it', () => {
const { doc, first, notebook } = wlan();
const pc = add(doc, 'pc');
connect(doc, { node: notebook }, { node: pc }, 'cable', 'me');
expect(host(doc, notebook).wirelessPossible).toBe(false);
expect(setHostWireless(doc, notebook, true, 'Schule', 'link-1', 'me')).toEqual({ error: 'cablePlugged' });
expect(host(doc, notebook).wireless).toBe(false);
const other = add(doc, 'notebook');
const full = doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get(first)!;
(full.get('ports') as Y.Array<Y.Map<unknown>>).delete(1, 23);
expect(connect(doc, { node: add(doc, 'pc') }, { node: first }, 'filled', 'me')).toEqual({ link: 'filled' });
expect(setHostWireless(doc, other, true, 'Schule', 'link-2', 'me')).toEqual({ error: 'noFreePort' });
expect(setHostWireless(doc, 'nobody', true, 'Schule', 'link-3', 'me')).toEqual({ error: 'unknownDevice' });
});
it("is dropped by a switch that changes its SSID, a cable to it staying (Java's panel)", () => {
const { doc, first, notebook, ends } = wlan();
const pc = add(doc, 'pc');
setHostWireless(doc, notebook, true, 'Schule', 'link-1', 'me');
connect(doc, { node: pc }, { node: first }, 'cable', 'me');
expect(setSwitchSsid(doc, first, 'Schule', 'me')).toEqual({ value: 'Schule' });
expect(ends()).toHaveLength(2);
expect(setSwitchSsid(doc, first, 'Ä', 'me')).toEqual({ invalid: true });
expect(ends()).toHaveLength(2);
expect(setSwitchSsid(doc, first, 'Neu', 'me')).toEqual({ value: 'Neu' });
expect(ends()).toEqual([[pc, first]]);
});
});
describe("a switch's and a modem's sheet", () => {
it('shows and keeps the SSID, the retention time in seconds and the cloud', () => {
const doc = new Y.Doc();
const hub = add(doc, 'networkSwitch', 'Switch');
expect(deviceSheet(doc, hub)).toEqual({ kind: 'networkSwitch', name: 'Switch', ssid: hub.replaceAll('-', '').slice(0, 6), retentionSeconds: 300, cloud: false });
expect(setSwitchRetention(doc, hub, ' 007 ', 'me')).toEqual({ value: '7' });
expect(setSwitchRetention(doc, hub, '-5', 'me')).toEqual({ invalid: true });
setSwitchCloud(doc, hub, true, 'me');
expect(deviceSheet(doc, hub)).toMatchObject({ retentionSeconds: 7, cloud: true });
expect(topologyView(doc).nodes[0]!.cloud).toBe(true);
expect(readProject(doc).switchConfigurations.get(hub)).toEqual({ ssid: hub.replaceAll('-', '').slice(0, 6), retentionTimeMilliseconds: 7000, showsAsCloud: true });
setSwitchCloud(doc, hub, false, 'me');
expect(readProject(doc).switchConfigurations.get(hub)).toEqual({ ssid: hub.replaceAll('-', '').slice(0, 6), retentionTimeMilliseconds: 7000 });
});
it('shows whole seconds of a retention time a file brings in milliseconds', () => {
const doc = new Y.Doc();
const hub = add(doc, 'networkSwitch');
(doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get(hub)!.get('settings') as Y.Map<Y.Map<unknown>>).get('switch')!.set('retentionTimeMilliseconds', 1999);
expect(deviceSheet(doc, hub)).toMatchObject({ retentionSeconds: 1 });
});
it("pairs a modem by its port, and names the other kinds' sheets", () => {
const doc = new Y.Doc();
const modem = add(doc, 'remoteLink', 'Modem');
expect(deviceSheet(doc, modem)).toEqual({ kind: 'remoteLink', name: 'Modem', port: 12_345 });
expect(setModemPort(doc, modem, '65536', 'me')).toEqual({ invalid: true });
expect(setModemPort(doc, modem, ' 80', 'me')).toEqual({ value: '80' });
expect(readProject(doc).remoteLinkConfigurations.get(modem)).toEqual({ pairIdentifier: 'java-modem-port-80', latencyMilliseconds: 20, isEnabled: true });
expect(deviceSheet(doc, modem)).toMatchObject({ port: 80 });
const router = add(doc, 'router', 'R1');
renameDevice(doc, router, 'R2', 'me');
expect(deviceSheet(doc, router)).toEqual({ kind: 'router', name: 'R2' });
expect(deviceSheet(doc, add(doc, 'gateway', 'Heim'))).toEqual({ kind: 'gateway', name: 'Heim' });
});
});
describe("the sheet's changes", () => {
it('are each one step of the person’s undo, the wireless join with its connection', () => {
const doc = new Y.Doc();
const hub = add(doc, 'networkSwitch');
const notebook = add(doc, 'notebook');
setSwitchSsid(doc, hub, 'Schule', 'setup');
const undo = createUndoManager(doc, 'me');
setHostAddress(doc, notebook, 'ipAddress', '10.0.0.1', 'me');
undo.stopCapturing();
setHostWireless(doc, notebook, true, 'Schule', 'link-1', 'me');
undo.stopCapturing();
renameDevice(doc, notebook, 'NB', 'me');
undo.stopCapturing();
undo.undo();
expect(host(doc, notebook).name).toBe('notebook');
undo.undo();
expect(host(doc, notebook).wireless).toBe(false);
expect(readProject(doc).graph.links).toEqual([]);
undo.undo();
expect(host(doc, notebook).ipAddress).toBe('192.168.0.10');
undo.redo();
undo.redo();
expect(host(doc, notebook)).toMatchObject({ ipAddress: '10.0.0.1', wireless: true });
expect(readProject(doc).graph.links).toHaveLength(1);
});
it('are kept through a saved file', () => {
const doc = new Y.Doc();
const pc = add(doc, 'pc', 'PC-A');
const hub = add(doc, 'networkSwitch', 'S');
const modem = add(doc, 'remoteLink', 'M');
setHostAddress(doc, pc, 'ipAddress', '10.1.2.3', 'me');
setHostAddress(doc, pc, 'subnetMask', '255.255.0.0', 'me');
setHostAddress(doc, pc, 'defaultGateway', '10.1.0.1', 'me');
setHostAddress(doc, pc, 'dnsServer', '10.1.0.2', 'me');
setHostLabels(doc, pc, { ipAddress: true, macAddress: false }, 'me');
setIpForwarding(doc, pc, true, 'me');
setDhcpClient(doc, pc, true, 'me');
setSwitchSsid(doc, hub, 'Schule', 'me');
setSwitchRetention(doc, hub, '42', 'me');
setSwitchCloud(doc, hub, true, 'me');
setModemPort(doc, modem, '4242', 'me');
let counter = 0;
const reopened = openProjectArchive(saveProjectArchive(readProject(doc)).archive, () => `30000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`);
const [pcAgain, hubAgain, modemAgain] = reopened.graph.nodes;
expect(pcAgain!.hostLabelPolicy).toBe('ipAddress');
expect(reopened.deviceConfigurations.get(pcAgain!.id)).toEqual({ ipAddress: '10.1.2.3', subnetMask: '255.255.0.0', defaultGateway: '10.1.0.1', dnsServer: '10.1.0.2' });
expect(reopened.ipForwardingByNodeId.get(pcAgain!.id)).toBe(true);
expect(reopened.dhcpClientConfigurations.get(pcAgain!.id)).toEqual({ isEnabled: true });
expect(reopened.switchConfigurations.get(hubAgain!.id)).toEqual({ ssid: 'Schule', retentionTimeMilliseconds: 42_000, showsAsCloud: true });
expect(reopened.remoteLinkConfigurations.get(modemAgain!.id)?.pairIdentifier).toBe('java-modem-port-4242');
});
});