Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
222 changes: 222 additions & 0 deletions ui/src/panes/InspectorPane.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<InspectorPane
node={{ ...baseNode, data: { ...baseNode.data, params: { resolution: '1280x720' } } }}
nodeDefinition={definition as NodeDefinition}
onParamChange={onParamChange}
onLabelChange={onLabelChange}
/>
);

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(
<InspectorPane
node={baseNode}
nodeDefinition={definition as NodeDefinition}
onParamChange={onParamChange}
onLabelChange={onLabelChange}
/>
);

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(
<InspectorPane
node={baseNode}
nodeDefinition={definition as NodeDefinition}
onParamChange={onParamChange}
onLabelChange={onLabelChange}
/>
);

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(
<InspectorPane
node={{ ...baseNode, data: { ...baseNode.data, params: { mode: 'fast' } } }}
nodeDefinition={definition as NodeDefinition}
onParamChange={onParamChange}
onLabelChange={onLabelChange}
isMonitorView
/>
);

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(
<InspectorPane
node={baseNode}
nodeDefinition={definition as NodeDefinition}
onParamChange={onParamChange}
onLabelChange={onLabelChange}
/>
);

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(
<InspectorPane
node={{ ...baseNode, data: { ...baseNode.data, params: { resolution: '640x480' } } }}
nodeDefinition={definition as NodeDefinition}
onParamChange={onParamChange}
onLabelChange={onLabelChange}
isMonitorView
/>
);

const select = screen.getByRole('combobox', { name: 'Viewport resolution' });
expect(select).toBeDisabled();
});
});
62 changes: 62 additions & 0 deletions ui/src/panes/InspectorPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
Expand Down Expand Up @@ -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 (
<FormSelect
id={inputId}
value={resolved}
onChange={(e) => onChange(e.target.value)}
disabled={readOnly}
aria-label={schema.description}
>
{options.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</FormSelect>
);
};

// Helper: Render number field
const NumberField: React.FC<{
inputId: string;
Expand Down Expand Up @@ -286,6 +337,17 @@ const InspectorPane: React.FC<InspectorPaneProps> = ({

switch (schema.type) {
case 'string':
if (schema.enum && schema.enum.length > 0) {
return (
<SelectField
inputId={inputId}
value={currentValue}
schema={schema}
readOnly={isDisabled}
onChange={(v) => handleInputChange(key, v, schema)}
/>
);
Comment thread
staging-devin-ai-integration[bot] marked this conversation as resolved.
}
return (
<StringField
inputId={inputId}
Expand Down
Loading