diff --git a/ui/src/panes/InspectorPane.test.tsx b/ui/src/panes/InspectorPane.test.tsx new file mode 100644 index 00000000..bfe9439d --- /dev/null +++ b/ui/src/panes/InspectorPane.test.tsx @@ -0,0 +1,222 @@ +// SPDX-FileCopyrightText: © 2025 StreamKit Contributors +// +// SPDX-License-Identifier: MPL-2.0 + +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import type { NodeDefinition } from '@/types/types'; + +vi.mock('jotai/react', () => ({ + useAtomValue: () => ({}), +})); + +vi.mock('@/stores/sessionAtoms', () => ({ + nodeParamsAtom: () => 'mock-atom', +})); + +// Import after mocks are set up +const { default: InspectorPane } = await import('./InspectorPane'); + +const baseNodeDefinition: NodeDefinition = { + kind: 'test-node', + description: 'A test node', + inputs: [], + outputs: [], + param_schema: {}, + categories: [], + bidirectional: false, +}; + +const baseNode = { + id: 'node-1', + type: 'custom', + position: { x: 0, y: 0 }, + data: { label: 'TestNode', kind: 'test-node', params: {} }, +}; + +describe('InspectorPane', () => { + let onParamChange: (nodeId: string, paramName: string, value: unknown) => void; + let onLabelChange: (nodeId: string, newLabel: string) => void; + + beforeEach(() => { + onParamChange = vi.fn<(nodeId: string, paramName: string, value: unknown) => void>(); + onLabelChange = vi.fn<(nodeId: string, newLabel: string) => void>(); + }); + + it('renders a select dropdown for enum-constrained string properties', () => { + const definition = { + ...baseNodeDefinition, + param_schema: { + properties: { + resolution: { + type: 'string', + enum: ['640x480', '1280x720', '1920x1080'], + description: 'Viewport resolution', + }, + }, + }, + }; + + render( + + ); + + const select = screen.getByRole('combobox', { name: 'Viewport resolution' }); + expect(select).toBeInTheDocument(); + expect(select).toHaveValue('1280x720'); + + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(3); + expect(options[0]).toHaveTextContent('640x480'); + expect(options[1]).toHaveTextContent('1280x720'); + expect(options[2]).toHaveTextContent('1920x1080'); + }); + + it('falls back to first enum value when current value does not match any option', () => { + const definition = { + ...baseNodeDefinition, + param_schema: { + properties: { + resolution: { + type: 'string', + enum: ['640x480', '1280x720', '1920x1080'], + description: 'Viewport resolution', + tunable: true, + }, + }, + }, + }; + + render( + + ); + + const select = screen.getByRole('combobox', { name: 'Viewport resolution' }); + expect(select).toHaveValue('640x480'); + }); + + it('falls back to first enum value when schema.default is not in enum', () => { + const definition = { + ...baseNodeDefinition, + param_schema: { + properties: { + resolution: { + type: 'string', + enum: ['640x480', '1280x720'], + default: 'not-a-valid-option', + description: 'Viewport resolution', + }, + }, + }, + }; + + render( + + ); + + const select = screen.getByRole('combobox', { name: 'Viewport resolution' }); + expect(select).toHaveValue('640x480'); + }); + + it('sends UpdateParams on selection change', () => { + const definition = { + ...baseNodeDefinition, + param_schema: { + properties: { + mode: { + type: 'string', + enum: ['fast', 'balanced', 'quality'], + description: 'Processing mode', + tunable: true, + }, + }, + }, + }; + + render( + + ); + + const select = screen.getByRole('combobox', { name: 'Processing mode' }); + fireEvent.change(select, { target: { value: 'quality' } }); + + expect(onParamChange).toHaveBeenCalledWith('node-1', 'mode', 'quality'); + }); + + it('renders a text input for non-enum string properties', () => { + const definition = { + ...baseNodeDefinition, + param_schema: { + properties: { + title: { + type: 'string', + description: 'Display title', + }, + }, + }, + }; + + render( + + ); + + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: 'Display title' })).toBeInTheDocument(); + }); + + it('disables enum select for non-tunable params in monitor view', () => { + const definition = { + ...baseNodeDefinition, + param_schema: { + properties: { + resolution: { + type: 'string', + enum: ['640x480', '1280x720'], + description: 'Viewport resolution', + tunable: false, + }, + }, + }, + }; + + render( + + ); + + const select = screen.getByRole('combobox', { name: 'Viewport resolution' }); + expect(select).toBeDisabled(); + }); +}); diff --git a/ui/src/panes/InspectorPane.tsx b/ui/src/panes/InspectorPane.tsx index 9442f81d..0bef5411 100644 --- a/ui/src/panes/InspectorPane.tsx +++ b/ui/src/panes/InspectorPane.tsx @@ -71,6 +71,28 @@ const FormInput = styled.input` } `; +const FormSelect = styled.select` + width: 100%; + max-width: 100%; + padding: 8px; + background: var(--sk-panel-bg); + border: 1px solid var(--sk-border); + border-radius: 4px; + color: var(--sk-text); + box-sizing: border-box; + font-family: inherit; + cursor: pointer; + + &:focus-visible { + outline: 1px solid var(--sk-primary); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.6; + } +`; + const FormTextarea = styled.textarea` width: 100%; max-width: 100%; @@ -183,6 +205,35 @@ const StringField: React.FC<{ ); }; +// Helper: Render select field for enum-constrained strings +const SelectField: React.FC<{ + inputId: string; + value: unknown; + schema: JsonSchemaProperty; + readOnly: boolean; + onChange: (value: string) => void; +}> = ({ inputId, value, schema, readOnly, onChange }) => { + const options = (schema.enum ?? []).map(String); + const defaultStr = schema.default != null ? String(schema.default) : undefined; + const fallback = (defaultStr && options.includes(defaultStr) ? defaultStr : options[0]) ?? ''; + const resolved = options.includes(String(value)) ? String(value) : fallback; + return ( + onChange(e.target.value)} + disabled={readOnly} + aria-label={schema.description} + > + {options.map((opt) => ( + + ))} + + ); +}; + // Helper: Render number field const NumberField: React.FC<{ inputId: string; @@ -286,6 +337,17 @@ const InspectorPane: React.FC = ({ switch (schema.type) { case 'string': + if (schema.enum && schema.enum.length > 0) { + return ( + handleInputChange(key, v, schema)} + /> + ); + } return (