= {
+ transform: 'change_circle',
+ fork_clone: 'content_copy',
+ unzip: 'format_list_numbered',
+ fork_result: 'question_mark',
+ split: 'call_split',
+ join: 'arrow_and_edge',
+ buffer: 'database',
+ buffer_access: 'database_upload',
+ listen: 'hearing',
+ stream_out: 'notes',
+ scope: 'rectangle',
+ section: 'select_all',
+ script: 'code',
+ sectionInput: 'input',
+ sectionOutput: 'output',
+ sectionBuffer: 'database',
+ node: 'line_start_circle',
+ };
+
+ for (const [key, expectedSymbol] of Object.entries(expectations)) {
+ const { container } = render(
+
+ {getAddOperationIcon(key as AddOperationKey)}
+
,
+ );
+ const symbolSpan = container.querySelector('.material-symbols-outlined');
+ expect(symbolSpan?.textContent).toBe(expectedSymbol);
+ }
+ });
+
+ test('returns NodeIcon (line_start_circle) for registry node builders with node: prefix', () => {
+ const { container } = render(
+ {getAddOperationIcon('node:custom_calculator')}
,
+ );
+ const symbolSpan = container.querySelector('.material-symbols-outlined');
+ expect(symbolSpan?.textContent).toBe('line_start_circle');
+ });
+
+ test('falls back gracefully to NodeIcon for unknown keys, prototype methods, and non-string inputs', () => {
+ const dangerousKeys = [
+ 'unknown_key',
+ 'toString',
+ 'valueOf',
+ 'constructor',
+ 'hasOwnProperty',
+ '__proto__',
+ undefined,
+ null,
+ 123,
+ ];
+
+ for (const key of dangerousKeys) {
+ const { container } = render(
+
+ {getAddOperationIcon(
+ key as unknown as Parameters[0],
+ )}
+
,
+ );
+ const symbolSpan = container.querySelector('.material-symbols-outlined');
+ expect(symbolSpan?.textContent).toBe('line_start_circle');
+ }
+ });
+});
diff --git a/diagram-editor/frontend/nodes/icons.tsx b/diagram-editor/frontend/nodes/icons.tsx
index 31c96130..bf9f6e86 100644
--- a/diagram-editor/frontend/nodes/icons.tsx
+++ b/diagram-editor/frontend/nodes/icons.tsx
@@ -1,7 +1,9 @@
import { Box, type BoxProps } from '@mui/material';
import type React from 'react';
-import type { DiagramOperation } from '../types/api';
-import { exhaustiveCheck } from '../utils/exhaustive-check';
+import type {
+ AddOperationCandidateKey,
+ AddOperationKey,
+} from '../utils/add-operation-catalog';
export interface MaterialSymbolProps extends BoxProps {
symbol: string;
@@ -93,38 +95,36 @@ export function UnzipIcon(): React.JSX.Element {
return ;
}
-export function getIcon(op: DiagramOperation): React.ComponentType {
- switch (op.type) {
- case 'node':
- return NodeIcon;
- case 'section':
- return SectionIcon;
- case 'fork_clone':
- return ForkCloneIcon;
- case 'unzip':
- return UnzipIcon;
- case 'fork_result':
- return ForkResultIcon;
- case 'split':
- return SplitIcon;
- case 'join':
- return JoinIcon;
- case 'transform':
- return TransformIcon;
- case 'script':
- return ScriptIcon;
- case 'buffer':
- return BufferIcon;
- case 'buffer_access':
- return BufferAccessIcon;
- case 'listen':
- return ListenIcon;
- case 'scope':
- return ScopeIcon;
- case 'stream_out':
- return StreamOutIcon;
- default:
- exhaustiveCheck(op);
- throw new Error('unknown op');
+export const OPERATION_ICONS: Record = {
+ sectionInput: ,
+ sectionOutput: ,
+ sectionBuffer: ,
+ node: ,
+ fork_clone: ,
+ unzip: ,
+ fork_result: ,
+ split: ,
+ join: ,
+ transform: ,
+ buffer: ,
+ buffer_access: ,
+ listen: ,
+ stream_out: ,
+ scope: ,
+ section: ,
+ script: ,
+};
+
+const VALID_OPERATION_KEYS = new Set(Object.keys(OPERATION_ICONS));
+
+export function getAddOperationIcon(
+ key: AddOperationCandidateKey | string,
+): React.ReactNode {
+ if (typeof key === 'string' && key.startsWith('node:')) {
+ return OPERATION_ICONS.node;
}
+ if (typeof key === 'string' && VALID_OPERATION_KEYS.has(key)) {
+ return OPERATION_ICONS[key as AddOperationKey];
+ }
+ return OPERATION_ICONS.node;
}