diff --git a/frontend/src/components/network/NetworkGraph/NetworkGraph.tsx b/frontend/src/components/network/NetworkGraph/NetworkGraph.tsx index 958af636..62cf2ba7 100644 --- a/frontend/src/components/network/NetworkGraph/NetworkGraph.tsx +++ b/frontend/src/components/network/NetworkGraph/NetworkGraph.tsx @@ -12,6 +12,7 @@ import { makeVolumeEdgeColor } from '@/utils/volumeColor'; import { deviceTypeIcon, deviceTypeLabel, DEVICE_TYPES } from '@/utils/deviceType'; import { useStore } from '@/store'; import type { NodeLabelConfig } from '@/store/slices/nodeLabelSlice'; +import { GENERIC_NODE_TYPES, getNodeIcon } from './nodeIcons'; import './NetworkGraph.css'; // --------------------------------------------------------------------------- @@ -74,47 +75,6 @@ const CONTESTED_COLOR = '#f59e0b'; const DARK_SURFACE = '#1e2130'; const LIGHT_BG = '#f6f8fa'; -// --------------------------------------------------------------------------- -// Bootstrap Icons — unicode codepoints for each node type -// Pre-rendered to data URLs so Sigma's WebGL renderer can display them. -// --------------------------------------------------------------------------- - -// Icons for specific service nodeTypes -const NODE_TYPE_ICONS: Record = { - 'dns-server': '\uf3ef', // bi-globe2 - 'web-server': '\uf52c', // bi-server - 'ssh-server': '\uf5c3', // bi-terminal - 'ftp-server': '\uf3d5', // bi-folder-symlink - 'mail-server': '\uf32f', // bi-envelope - 'dhcp-server': '\uf1d6', // bi-broadcast - 'ntp-server': '\uf293', // bi-clock - 'database-server': '\uf8c4', // bi-database - router: '\uf6ec', // bi-router - 'l2-device': '\uf6d5', // bi-ethernet - cluster: '\uf2ee', // bi-diagram-3 -}; - -// Icons for device types — used on generic (client/unknown) nodes -const DEVICE_TYPE_ICONS: Record = { - ROUTER: '\uf6ec', // bi-router - MOBILE: '\uf4b9', // bi-phone - LAPTOP_DESKTOP: '\uf456', // bi-laptop - SERVER: '\uf52c', // bi-server - IOT: '\uf46b', // bi-cpu - DNS_SERVER: '\uf40d', // bi-hdd-network - WEB_SERVER: '\uf3ee', // bi-globe - API_SERVER: '\uf411', // bi-hdd-stack -}; - -const FALLBACK_ICON = '\uf505'; // bi-question-circle - -function getNodeIcon(nodeType: string, deviceType: string): string { - if (!GENERIC_NODE_TYPES.has(nodeType)) { - return NODE_TYPE_ICONS[nodeType] ?? FALLBACK_ICON; - } - return DEVICE_TYPE_ICONS[deviceType] ?? FALLBACK_ICON; -} - /** * Sidecar maps: label → nodeType and label → deviceType. * Sigma strips custom graph attributes before calling defaultDrawNodeLabel — @@ -265,10 +225,6 @@ function drawNodeLabel( } } -// Generic nodeTypes that carry no specific service information. -// For these, deviceType provides a more meaningful colour signal. -const GENERIC_NODE_TYPES = new Set(['client', 'unknown']); - /** * The node's colour, from its adjudicated nodeType. * diff --git a/frontend/src/components/network/NetworkGraph/__tests__/iconCodepoints.test.ts b/frontend/src/components/network/NetworkGraph/__tests__/iconCodepoints.test.ts new file mode 100644 index 00000000..c1e42a15 --- /dev/null +++ b/frontend/src/components/network/NetworkGraph/__tests__/iconCodepoints.test.ts @@ -0,0 +1,67 @@ +import { deviceTypeIcon } from '@/utils/deviceType'; +import { NODE_TYPE_CONFIG } from '@/features/network/constants'; +import { NODE_TYPE_ICONS, DEVICE_TYPE_ICONS, FALLBACK_ICON } from '../nodeIcons'; +import iconCodepointsByName from 'bootstrap-icons/font/bootstrap-icons.json'; +import nodeIconsSrc from '../nodeIcons.ts?raw'; + +/** + * nodeIcons.ts hardcodes raw Bootstrap Icon codepoints (Sigma renders nodes in WebGL and + * can't use CSS classes), each with a trailing `// bi-xxx` comment naming the class it was + * copied from. That copy has drifted before (#495: IoT drew a lightbulb, Mobile a patch-minus). + * This test verifies every codepoint still matches its named class in the installed + * bootstrap-icons package, and that DEVICE_TYPE_ICONS still agrees with deviceTypeIcon(), + * the class-name source the legend renders from. + * + *

Sourced from bootstrap-icons.json (name → decimal codepoint) rather than the .css file: + * a plain JSON import works identically under Vite and vitest, whereas vitest.config disables + * CSS processing (`css: false`), which silently empties out `?raw` imports of .css files. + */ + +const classToCodepoint = new Map( + Object.entries(iconCodepointsByName).map(([name, codepoint]) => [ + `bi-${name}`, + (codepoint as number).toString(16).padStart(4, '0'), + ]) +); + +const entries = [...nodeIconsSrc.matchAll(/'\\u([0-9a-fA-F]{4})'[;,]\s*\/\/\s*(bi-[\w-]+)/g)]; + +describe('nodeIcons.ts codepoints match their named Bootstrap Icon class', () => { + it('finds codepoint/class comment pairs to check (sanity check the regex still matches the file)', () => { + expect(entries.length).toBeGreaterThan(0); + }); + + it.each(entries.map(m => [m[2], m[1]] as const))('%s resolves to \\u%s', (className, codepoint) => { + expect(classToCodepoint.get(className)).toBe(codepoint.toLowerCase()); + }); +}); + +describe('DEVICE_TYPE_ICONS matches the legend\'s deviceTypeIcon() class names', () => { + it.each(Object.keys(DEVICE_TYPE_ICONS) as (keyof typeof DEVICE_TYPE_ICONS)[])('%s', (deviceType) => { + const legendClass = deviceTypeIcon(deviceType); + const expectedCodepoint = classToCodepoint.get(legendClass); + expect(expectedCodepoint).toBeDefined(); + expect(DEVICE_TYPE_ICONS[deviceType]).toBe(String.fromCharCode(parseInt(expectedCodepoint!, 16))); + }); +}); + +describe('NODE_TYPE_ICONS matches NODE_TYPE_CONFIG icon class names', () => { + // 'cluster' is a canvas-only node type with no NODE_TYPE_CONFIG entry. + it.each(Object.keys(NODE_TYPE_ICONS).filter(k => k !== 'cluster'))('%s', (nodeType) => { + const config = NODE_TYPE_CONFIG[nodeType as keyof typeof NODE_TYPE_CONFIG]; + expect(config).toBeDefined(); + const expectedCodepoint = classToCodepoint.get(config.icon); + expect(expectedCodepoint).toBeDefined(); + expect(NODE_TYPE_ICONS[nodeType]).toBe(String.fromCharCode(parseInt(expectedCodepoint!, 16))); + }); +}); + +describe('NODE_TYPE_ICONS and DEVICE_TYPE_ICONS have no unexpected extra keys', () => { + it('every table entry is present among the parsed comment pairs', () => { + const allValues = new Set([...Object.values(NODE_TYPE_ICONS), ...Object.values(DEVICE_TYPE_ICONS), FALLBACK_ICON]); + const commentValues = new Set(entries.map(m => String.fromCharCode(parseInt(m[1], 16)))); + for (const v of allValues) { + expect(commentValues.has(v)).toBe(true); + } + }); +}); diff --git a/frontend/src/components/network/NetworkGraph/nodeIcons.ts b/frontend/src/components/network/NetworkGraph/nodeIcons.ts new file mode 100644 index 00000000..9ca7bfb9 --- /dev/null +++ b/frontend/src/components/network/NetworkGraph/nodeIcons.ts @@ -0,0 +1,50 @@ +// --------------------------------------------------------------------------- +// Bootstrap Icons — unicode codepoints for each node type +// Pre-rendered to data URLs so Sigma's WebGL renderer can display them. +// +// Kept in its own module, free of the `sigma` import, so it can be unit-tested without pulling +// in Sigma's WebGL dependency (importing it crashes under jsdom) — see +// __tests__/iconCodepoints.test.ts. These tables drifted from the class names they were copied +// from once already (#495: IoT drew a lightbulb, Mobile a patch-minus). +// --------------------------------------------------------------------------- + +// Generic nodeTypes that carry no specific service information. +// For these, deviceType provides a more meaningful colour signal. +export const GENERIC_NODE_TYPES = new Set(['client', 'unknown']); + +// Icons for specific service nodeTypes +export const NODE_TYPE_ICONS: Record = { + 'dns-server': '\uf3ef', // bi-globe2 + 'web-server': '\uf52c', // bi-server + 'ssh-server': '\uf5c3', // bi-terminal + 'ftp-server': '\uf3d5', // bi-folder-symlink + 'mail-server': '\uf32f', // bi-envelope + 'dhcp-server': '\uf2ee', // bi-diagram-3 + 'ntp-server': '\uf293', // bi-clock + 'database-server': '\uf8c4', // bi-database + router: '\uf6ec', // bi-router + 'l2-device': '\uf6d5', // bi-ethernet + cluster: '\uf2ee', // bi-diagram-3 +}; + +// Icons for device types — used on generic (client/unknown) nodes. +// Must stay in sync with deviceTypeIcon()'s class names (used by the legend). +export const DEVICE_TYPE_ICONS: Record = { + ROUTER: '\uf6ec', // bi-router + MOBILE: '\uf4e7', // bi-phone + LAPTOP_DESKTOP: '\uf456', // bi-laptop + SERVER: '\uf52c', // bi-server + IOT: '\uf2d6', // bi-cpu + DNS_SERVER: '\uf40d', // bi-hdd-network + WEB_SERVER: '\uf3ee', // bi-globe + API_SERVER: '\uf411', // bi-hdd-stack +}; + +export const FALLBACK_ICON = '\uf505'; // bi-question-circle + +export function getNodeIcon(nodeType: string, deviceType: string): string { + if (!GENERIC_NODE_TYPES.has(nodeType)) { + return NODE_TYPE_ICONS[nodeType] ?? FALLBACK_ICON; + } + return DEVICE_TYPE_ICONS[deviceType] ?? FALLBACK_ICON; +}