diff --git a/diagram-editor/dist.tar.gz b/diagram-editor/dist.tar.gz index 645cad48..bbabce90 100644 Binary files a/diagram-editor/dist.tar.gz and b/diagram-editor/dist.tar.gz differ diff --git a/diagram-editor/frontend/add-operation.test.tsx b/diagram-editor/frontend/add-operation.test.tsx new file mode 100644 index 00000000..cf3f31e8 --- /dev/null +++ b/diagram-editor/frontend/add-operation.test.tsx @@ -0,0 +1,142 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import AddOperation from './add-operation'; + +const mockCreateTransformChanges = jest.fn(() => [ + { + type: 'add' as const, + item: { id: 'new-transform-node' }, + }, +]); + +const mockOperations = [ + { + key: 'transform', + label: 'Transform', + createChanges: mockCreateTransformChanges, + }, + { + key: 'fork_clone', + label: 'Fork Clone', + createChanges: () => [ + { + type: 'add' as const, + item: { id: 'new-fork-clone-node' }, + }, + ], + }, +]; + +let visibleOperations = mockOperations; +const mockEditorMode = [{ mode: 0 }]; +const mockNodeManager = { + tryGetNode: () => null, +}; + +jest.mock('./editor-mode', () => ({ + EditorMode: { Normal: 0, Template: 1 }, + useEditorMode: () => mockEditorMode, +})); + +jest.mock('./node-manager', () => ({ + useNodeManager: () => mockNodeManager, +})); + +jest.mock('./utils/add-operation-catalog', () => ({ + getVisibleAddOperations: () => visibleOperations, +})); + +describe('AddOperation', () => { + beforeEach(() => { + visibleOperations = mockOperations; + jest.clearAllMocks(); + }); + + test('renders operation suggestions with corresponding icons', () => { + render(); + + const transformButton = screen.getByRole('button', { name: /Transform/ }); + const forkCloneButton = screen.getByRole('button', { name: /Fork Clone/ }); + + expect(transformButton).toBeInTheDocument(); + expect(forkCloneButton).toBeInTheDocument(); + + const transformIcon = transformButton.querySelector( + '.material-symbols-outlined', + ); + const forkCloneIcon = forkCloneButton.querySelector( + '.material-symbols-outlined', + ); + + expect(transformIcon).toBeInTheDocument(); + expect(forkCloneIcon).toBeInTheDocument(); + expect(transformIcon?.textContent).toBe('change_circle'); + expect(forkCloneIcon?.textContent).toBe('content_copy'); + }); + + test('calls onAdd with changes and primaryNodeId when an operation is clicked', () => { + const onAdd = jest.fn(); + + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: /Transform/ })); + + expect(mockCreateTransformChanges).toHaveBeenCalledWith({ + namespace: '', + parentId: 'parent-1', + newNodePosition: { x: 10, y: 20 }, + nodeManager: mockNodeManager, + }); + expect(onAdd).toHaveBeenCalledWith({ + primaryNodeId: 'new-transform-node', + changes: [ + { + type: 'add', + item: { id: 'new-transform-node' }, + }, + ], + }); + }); + + test('does not throw when clicked and onAdd is omitted', () => { + render(); + expect(() => { + fireEvent.click(screen.getByRole('button', { name: /Transform/ })); + }).not.toThrow(); + }); + + test('filters operations by search text and shows no match message', () => { + render(); + + const searchInput = screen.getByPlaceholderText('Filter operations'); + fireEvent.change(searchInput, { target: { value: 'fork' } }); + + expect( + screen.queryByRole('button', { name: /Transform/ }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /Fork Clone/ }), + ).toBeInTheDocument(); + + fireEvent.change(searchInput, { target: { value: 'non-existent' } }); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect( + screen.getByText('No operations match this filter.'), + ).toBeInTheDocument(); + }); + + test('shows empty state message when no operations are available', () => { + visibleOperations = []; + render(); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect( + screen.getByText('No operations are available here yet.'), + ).toBeInTheDocument(); + }); +}); diff --git a/diagram-editor/frontend/add-operation.tsx b/diagram-editor/frontend/add-operation.tsx index 2b310ae7..3f1aa545 100644 --- a/diagram-editor/frontend/add-operation.tsx +++ b/diagram-editor/frontend/add-operation.tsx @@ -10,57 +10,18 @@ import type { NodeAddChange, XYPosition } from '@xyflow/react'; import React from 'react'; import { EditorMode, useEditorMode } from './editor-mode'; import { useNodeManager } from './node-manager'; -import type { DiagramEditorNode } from './nodes'; import { - BufferAccessIcon, - BufferIcon, - ForkCloneIcon, - ForkResultIcon, + type DiagramEditorNode, + getAddOperationIcon, isOperationNode, - JoinIcon, - ListenIcon, - NodeIcon, - ScopeIcon, - ScriptIcon, - SectionBufferIcon, - SectionIcon, - SectionInputIcon, - SectionOutputIcon, - SplitIcon, - StreamOutIcon, - TransformIcon, - UnzipIcon, } from './nodes'; -import { - type AddOperationKey, - getVisibleAddOperations, -} from './utils/add-operation-catalog'; +import { getVisibleAddOperations } from './utils/add-operation-catalog'; import { joinNamespaces, ROOT_NAMESPACE } from './utils/namespace'; const StyledOperationButton = styled(Button)({ justifyContent: 'flex-start', }); -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: , -}; - export interface AddOperationSelection { primaryNodeId: string; changes: NodeAddChange[]; @@ -131,7 +92,7 @@ function AddOperation({ parentId, newNodePosition, onAdd }: AddOperationProps) { {operations.map((operation) => ( { const changes = operation.createChanges({ namespace, diff --git a/diagram-editor/frontend/compatible-add-operation.test.tsx b/diagram-editor/frontend/compatible-add-operation.test.tsx index acbaabb7..059f2ff0 100644 --- a/diagram-editor/frontend/compatible-add-operation.test.tsx +++ b/diagram-editor/frontend/compatible-add-operation.test.tsx @@ -1,22 +1,53 @@ import { render, screen, waitFor } from '@testing-library/react'; import { CompatibleAddOperation } from './compatible-add-operation'; -const mockCandidate = { - key: 'candidate', - label: 'Candidate operation', - createChanges: () => [ - { - type: 'add', - item: { id: 'candidate-node' }, - }, - ], -}; +const mockCandidates = [ + { + key: 'transform', + label: 'Transform', + createChanges: () => [ + { + type: 'add', + item: { id: 'transform-node' }, + }, + ], + }, + { + key: 'fork_clone', + label: 'Fork Clone', + createChanges: () => [ + { + type: 'add', + item: { id: 'fork-clone-node' }, + }, + ], + }, + { + key: 'node:calculator', + label: 'Calculator', + createChanges: () => [ + { + type: 'add', + item: { id: 'calc-node' }, + }, + ], + }, +]; + const mockCheckConnections = jest.fn( async () => new Map([ [ - 'candidate', - { id: 'candidate', status: 'compatible' as const, reason: '' }, + 'transform', + { id: 'transform', status: 'compatible' as const, reason: '' }, + ], + [ + 'fork_clone', + { id: 'fork_clone', status: 'compatible' as const, reason: '' }, + ], + [ + 'node:calculator', + { id: 'node:calculator', status: 'compatible' as const, reason: '' }, ], ]), ); @@ -46,14 +77,14 @@ jest.mock('./registry-provider', () => ({ jest.mock('./utils/add-operation-catalog', () => ({ filterCompatibleAddOperations: (candidates: unknown[]) => candidates, - getAddOperationCandidates: () => [mockCandidate], + getAddOperationCandidates: () => mockCandidates, getVisibleAddOperations: () => [], })); jest.mock('./utils/connection', () => ({ - createConnectionFromHandles: () => ({ + createConnectionFromHandles: (_source: unknown, targetId: string) => ({ source: 'source-node', - target: 'candidate-node', + target: targetId, }), })); @@ -77,10 +108,51 @@ describe('CompatibleAddOperation', () => { screen.getByText('Checking compatible operations...'), ).toBeInTheDocument(); expect( - await screen.findByRole('button', { name: /Candidate operation/ }), + await screen.findByRole('button', { name: /Transform/ }), ).toBeInTheDocument(); await waitFor(() => { expect(onContentChange).toHaveBeenCalled(); }); }); + + test('renders specific icons for compatible operations and registry builders', async () => { + render( + , + ); + + const transformButton = await screen.findByRole('button', { + name: /Transform/, + }); + const forkCloneButton = await screen.findByRole('button', { + name: /Fork Clone/, + }); + const calculatorButton = await screen.findByRole('button', { + name: /Calculator/, + }); + + const transformIcon = transformButton.querySelector( + '.material-symbols-outlined', + ); + const forkCloneIcon = forkCloneButton.querySelector( + '.material-symbols-outlined', + ); + const calculatorIcon = calculatorButton.querySelector( + '.material-symbols-outlined', + ); + + expect(transformIcon).toBeInTheDocument(); + expect(forkCloneIcon).toBeInTheDocument(); + expect(calculatorIcon).toBeInTheDocument(); + + expect(transformIcon?.textContent).toBe('change_circle'); + expect(forkCloneIcon?.textContent).toBe('content_copy'); + expect(calculatorIcon?.textContent).toBe('line_start_circle'); + }); }); diff --git a/diagram-editor/frontend/compatible-add-operation.tsx b/diagram-editor/frontend/compatible-add-operation.tsx index af3a7945..7c6c56b3 100644 --- a/diagram-editor/frontend/compatible-add-operation.tsx +++ b/diagram-editor/frontend/compatible-add-operation.tsx @@ -14,7 +14,7 @@ import type { AddOperationSelection } from './add-operation'; import { useCompatibilityChecker } from './connection-compatibility-provider'; import { EditorMode, useEditorMode } from './editor-mode'; import { useNodeManager } from './node-manager'; -import { isOperationNode, NodeIcon } from './nodes'; +import { getAddOperationIcon, isOperationNode } from './nodes'; import { useRegistry } from './registry-provider'; import { type AddOperationCandidate, @@ -221,7 +221,7 @@ export function CompatibleAddOperation({ {operations.map((operation) => ( } + startIcon={getAddOperationIcon(operation.key)} onClick={() => { const changes = operation.createChanges({ namespace, diff --git a/diagram-editor/frontend/nodes/icons.test.tsx b/diagram-editor/frontend/nodes/icons.test.tsx new file mode 100644 index 00000000..e8d759fc --- /dev/null +++ b/diagram-editor/frontend/nodes/icons.test.tsx @@ -0,0 +1,116 @@ +import { render } from '@testing-library/react'; +import type { AddOperationKey } from '../utils/add-operation-catalog'; +import { getAddOperationIcon, OPERATION_ICONS } from './icons'; + +describe('getAddOperationIcon', () => { + const allOperationKeys: AddOperationKey[] = [ + 'sectionInput', + 'sectionOutput', + 'sectionBuffer', + 'node', + 'fork_clone', + 'unzip', + 'fork_result', + 'split', + 'join', + 'transform', + 'buffer', + 'buffer_access', + 'listen', + 'stream_out', + 'scope', + 'section', + 'script', + ]; + + test('contains icon definitions for all AddOperationKey values', () => { + for (const key of allOperationKeys) { + expect(OPERATION_ICONS[key]).toBeDefined(); + } + }); + + test('allOperationKeys matches OPERATION_ICONS keys exactly', () => { + expect(Object.keys(OPERATION_ICONS).sort()).toEqual( + [...allOperationKeys].sort(), + ); + }); + + test.each(allOperationKeys)( + 'returns defined icon element for built-in operation %s', + (key) => { + const iconElement = getAddOperationIcon(key); + expect(iconElement).toBeDefined(); + + const { container } = render(
{iconElement}
); + const symbolSpan = container.querySelector('.material-symbols-outlined'); + expect(symbolSpan).toBeInTheDocument(); + expect(symbolSpan?.textContent).toBeTruthy(); + }, + ); + + test('maps specific operations to their expected material symbol names', () => { + const expectations: Record = { + 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; }