import { useState } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, test, vi } from 'vitest' import type { RenderTemplate } from '../../api/renderTemplates' import type { WorkflowNodeDefinition, WorkflowParams } from '../../api/workflows' import { WorkflowNodeInspector } from '../../components/workflows/WorkflowNodeInspector' const listRenderTemplates = vi.fn<() => Promise>() vi.mock('../../api/renderTemplates', () => ({ listRenderTemplates: () => listRenderTemplates(), })) const resolveTemplateDefinition: WorkflowNodeDefinition = { step: 'resolve_template', label: 'Resolve Template', family: 'order_line', module_key: 'rendering.resolve_template', category: 'processing', description: 'Resolve a template for order-line rendering.', node_type: 'processNode', icon: 'layers', defaults: {}, fields: [ { key: 'template_id_override', label: 'Template Override', type: 'text', description: 'Manual template override.', section: 'General', default: '', min: null, max: null, step: null, unit: null, options: [], allow_blank: true, max_length: null, text_format: 'uuid', }, ], execution_kind: 'bridge', legacy_compatible: true, input_contract: { context: 'order_line', requires: ['order_line_context'] }, output_contract: { context: 'order_line', provides: ['render_template'] }, artifact_roles_consumed: ['order_line_context'], artifact_roles_produced: ['render_template'], legacy_source: 'legacy.resolve_template', } const notifyDefinition: WorkflowNodeDefinition = { step: 'notify', label: 'Notify', family: 'order_line', module_key: 'notifications.emit', category: 'output', description: 'Emit the workflow result.', node_type: 'outputNode', icon: 'bell', defaults: {}, fields: [], execution_kind: 'bridge', legacy_compatible: true, input_contract: { context: 'order_line', requires: ['workflow_result'] }, output_contract: { context: 'order_line', provides: ['notification_event'] }, artifact_roles_consumed: ['workflow_result'], artifact_roles_produced: ['notification_event'], legacy_source: 'legacy.notify', } const orderLineSetupDefinition: WorkflowNodeDefinition = { step: 'order_line_setup', label: 'Order Line Setup', family: 'order_line', module_key: 'orders.setup', category: 'input', description: 'Resolve order-line context and setup metadata.', node_type: 'inputNode', icon: 'package-search', defaults: {}, fields: [], execution_kind: 'native', legacy_compatible: true, input_contract: { context: 'order_line', requires: ['order_line_record'] }, output_contract: { context: 'order_line', provides: ['order_line_context'] }, artifact_roles_consumed: [], artifact_roles_produced: ['order_line_context'], legacy_source: 'legacy.order_line_setup', } const outputSaveDefinition: WorkflowNodeDefinition = { step: 'output_save', label: 'Save Output', family: 'order_line', module_key: 'media.save_output', category: 'output', description: 'Persist the selected render artifact.', node_type: 'outputNode', icon: 'download', defaults: { expected_artifact_role: '', require_upstream_artifact: false, }, fields: [ { key: 'expected_artifact_role', label: 'Expected Artifact Role', type: 'select', description: 'Restrict the accepted upstream artifact.', section: 'Output', default: '', min: null, max: null, step: null, unit: null, options: [ { value: '', label: 'Any Connected Artifact' }, { value: 'render_output', label: 'Still Output' }, { value: 'turntable_output', label: 'Turntable Output' }, ], allow_blank: true, max_length: null, text_format: null, }, { key: 'require_upstream_artifact', label: 'Require Upstream Artifact', type: 'boolean', description: 'Fail when no matching artifact is wired.', section: 'Output', default: false, min: null, max: null, step: null, unit: null, options: [], allow_blank: true, max_length: null, text_format: null, }, ], execution_kind: 'bridge', legacy_compatible: true, input_contract: { context: 'order_line', requires: ['order_line_context'], requires_any: ['rendered_image'] }, output_contract: { context: 'order_line', provides: ['workflow_result'] }, artifact_roles_consumed: ['rendered_image'], artifact_roles_produced: ['workflow_result'], legacy_source: 'legacy.output_save', } const exportBlendDefinition: WorkflowNodeDefinition = { step: 'export_blend', label: 'Export Blend', family: 'order_line', module_key: 'media.export_blend', category: 'output', description: 'Persist the generated .blend file.', node_type: 'outputNode', icon: 'download', defaults: { output_name_suffix: '', }, fields: [ { key: 'output_name_suffix', label: 'Output Name Suffix', type: 'text', description: 'Optional suffix appended to the emitted filename.', section: 'Output', default: '', min: null, max: null, step: null, unit: null, options: [], allow_blank: true, max_length: 64, text_format: 'safe_filename_suffix', }, ], execution_kind: 'bridge', legacy_compatible: true, input_contract: { context: 'order_line', requires: ['order_line_context', 'render_template'] }, output_contract: { context: 'order_line', provides: ['blend_asset'] }, artifact_roles_consumed: ['order_line_context', 'render_template'], artifact_roles_produced: ['blend_asset'], legacy_source: 'legacy.export_blend', } const blenderStillDefinition: WorkflowNodeDefinition = { step: 'blender_still', label: 'Render Still', family: 'order_line', module_key: 'render.production.still', category: 'rendering', description: 'Render a still image.', node_type: 'renderNode', icon: 'camera', defaults: { use_custom_render_settings: false }, fields: [ { key: 'use_custom_render_settings', label: 'Custom Render Settings', type: 'boolean', description: 'Enable explicit render overrides.', section: 'Render', default: false, min: null, max: null, step: null, unit: null, options: [], allow_blank: true, max_length: null, text_format: null, }, { key: 'samples', label: 'Samples', type: 'number', description: 'Render samples.', section: 'Render', default: 256, min: 1, max: 4096, step: 1, unit: null, options: [], allow_blank: true, max_length: null, text_format: null, }, { key: 'noise_threshold', label: 'Noise Threshold', type: 'text', description: 'Adaptive sampling threshold.', section: 'Denoising', default: '', min: null, max: null, step: null, unit: null, options: [], allow_blank: true, max_length: null, text_format: null, }, { key: 'focal_length_mm', label: 'Focal Length', type: 'number', description: 'Lens override.', section: 'Camera', default: null, min: 1, max: 500, step: 0.1, unit: 'mm', options: [], allow_blank: true, max_length: null, text_format: null, }, { key: 'target_collection', label: 'Target Collection', type: 'text', description: 'Collection name.', section: 'Scene', default: 'Product', min: null, max: null, step: null, unit: null, options: [], allow_blank: true, max_length: null, text_format: null, }, ], execution_kind: 'native', legacy_compatible: true, input_contract: { context: 'order_line', requires: ['order_line_context', 'render_template', 'bbox'] }, output_contract: { context: 'order_line', provides: ['rendered_image'] }, artifact_roles_consumed: ['order_line_context', 'render_template', 'bbox'], artifact_roles_produced: ['rendered_image'], legacy_source: 'legacy.blender_still', } const blenderTurntableDefinition: WorkflowNodeDefinition = { step: 'blender_turntable', label: 'Render Turntable', family: 'order_line', module_key: 'render.production.turntable', category: 'rendering', description: 'Render a turntable animation.', node_type: 'renderNode', icon: 'video', defaults: { use_custom_render_settings: false }, fields: [ { key: 'use_custom_render_settings', label: 'Custom Render Settings', type: 'boolean', description: 'Enable explicit render overrides.', section: 'Render', default: false, min: null, max: null, step: null, unit: null, options: [], allow_blank: true, max_length: null, text_format: null, }, { key: 'fps', label: 'Frames Per Second', type: 'number', description: 'Playback speed.', section: 'Animation', default: 24, min: 1, max: 120, step: 1, unit: null, options: [], allow_blank: true, max_length: null, text_format: null, }, { key: 'turntable_axis', label: 'Turntable Axis', type: 'select', description: 'Rotation axis.', section: 'Animation', default: 'z', min: null, max: null, step: null, unit: null, options: [ { value: 'x', label: 'X' }, { value: 'y', label: 'Y' }, { value: 'z', label: 'Z' }, ], allow_blank: false, max_length: null, text_format: null, }, { key: 'bg_color', label: 'Background Color', type: 'text', description: 'Background override.', section: 'Render', default: '#ffffff', min: null, max: null, step: null, unit: null, options: [], allow_blank: true, max_length: null, text_format: null, }, { key: 'camera_orbit', label: 'Camera Orbit', type: 'boolean', description: 'Orbit camera around the product.', section: 'Scene', default: true, min: null, max: null, step: null, unit: null, options: [], allow_blank: true, max_length: null, text_format: null, }, ], execution_kind: 'native', legacy_compatible: true, input_contract: { context: 'order_line', requires: ['order_line_context', 'render_template', 'bbox'] }, output_contract: { context: 'order_line', provides: ['rendered_video'] }, artifact_roles_consumed: ['order_line_context', 'render_template', 'bbox'], artifact_roles_produced: ['rendered_video'], legacy_source: 'legacy.blender_turntable', } function createRenderTemplate(overrides: Partial = {}): RenderTemplate { return { id: '0d87b85f-c454-4d61-a124-d5b59e6a43a2', name: 'Bearing Studio', category_key: 'bearings', output_type_id: null, output_type_name: null, output_type_ids: [], output_type_names: ['Still'], blend_file_path: '/templates/bearing.blend', original_filename: 'bearing.blend', target_collection: 'Product', material_replace_enabled: true, lighting_only: false, shadow_catcher_enabled: false, camera_orbit: true, workflow_input_schema: [], is_active: true, created_at: '2026-04-11T00:00:00Z', updated_at: '2026-04-11T00:00:00Z', ...overrides, } } function renderInspector( params: WorkflowParams, onChange = vi.fn(), options: { stateful?: boolean } = {}, ) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, }, }, }) function StatefulInspector() { const [currentParams, setCurrentParams] = useState(params) return ( { setCurrentParams(nextParams) onChange(nextParams) }} nodeDefinition={resolveTemplateDefinition} step="resolve_template" nodeDefinitions={[resolveTemplateDefinition]} graphFamily="order_line" onStepChange={vi.fn()} /> ) } render( {options.stateful ? ( ) : ( )} , ) return { onChange, queryClient } } describe('WorkflowNodeInspector', () => { beforeEach(() => { listRenderTemplates.mockReset() }) test('renders template-defined workflow input fields for resolve_template nodes', async () => { listRenderTemplates.mockResolvedValue([ createRenderTemplate({ workflow_input_schema: [ { key: 'studio_variant', label: 'Studio Variant', type: 'select', section: 'Template Inputs', description: 'Choose the blend lighting preset.', default: 'default', min: null, max: null, step: null, unit: null, options: [ { value: 'default', label: 'Default' }, { value: 'warm', label: 'Warm' }, ], allow_blank: false, }, ], }), ]) renderInspector({ template_id_override: '0d87b85f-c454-4d61-a124-d5b59e6a43a2', template_input__studio_variant: 'warm', }) expect(await screen.findByLabelText('Template Override')).toHaveValue( '0d87b85f-c454-4d61-a124-d5b59e6a43a2', ) expect(await screen.findByLabelText('Studio Variant')).toHaveValue('warm') expect(screen.getByText('Bearing Studio')).toBeInTheDocument() }) test('clears dynamic template inputs when template override is removed', async () => { listRenderTemplates.mockResolvedValue([ createRenderTemplate({ workflow_input_schema: [ { key: 'studio_variant', label: 'Studio Variant', type: 'select', section: 'Template Inputs', description: 'Choose the blend lighting preset.', default: 'default', min: null, max: null, step: null, unit: null, options: [ { value: 'default', label: 'Default' }, { value: 'warm', label: 'Warm' }, ], allow_blank: false, }, ], }), ]) const user = userEvent.setup() const { onChange } = renderInspector({ template_id_override: '0d87b85f-c454-4d61-a124-d5b59e6a43a2', template_input__studio_variant: 'warm', template_input__camera_profile: 'macro', }) const templateOverride = await screen.findByLabelText('Template Override') await user.selectOptions(templateOverride, '') await waitFor(() => { expect(onChange).toHaveBeenCalledWith({}) }) }) test('explains connection-driven nodes when no editor fields are available', async () => { listRenderTemplates.mockResolvedValue([]) const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, }, }, }) render( , ) expect(screen.getByText('This node has no editor settings.')).toBeInTheDocument() expect( screen.getAllByText( 'This node accepts one upstream artifact from any of: rendered image / rendered frames / rendered video / workflow result / blend asset.', ).length, ).toBeGreaterThan(0) expect(screen.getAllByText(/0 local variables by design/i).length).toBeGreaterThan(0) expect(screen.getByText('Alternative Groups')).toBeInTheDocument() expect(screen.getByText('Group 1')).toBeInTheDocument() expect(screen.getByText('Validation Watchpoints')).toBeInTheDocument() expect(screen.getByText('Watch Artifact Flow')).toBeInTheDocument() expect(screen.getByText('Watch Runtime Gap')).toBeInTheDocument() expect( screen.getAllByText('Any of: Rendered Image / Rendered Frames / Rendered Video / Workflow Result / Blend Asset').length, ).toBeGreaterThan(0) }) test('explains context-entry nodes as context-supplied instead of misconfigured', async () => { listRenderTemplates.mockResolvedValue([]) const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, }, }, }) render( , ) expect(screen.getByText('Context Entry')).toBeInTheDocument() expect( screen.getAllByText( 'Workflow context supplies Order Line Record. No additional upstream sockets are required.', ).length, ).toBeGreaterThan(0) expect(screen.getAllByText('Workflow context already provides Order Line Record.').length).toBeGreaterThan(0) expect(screen.getAllByText(/0 local variables by design/i).length).toBeGreaterThan(0) expect(screen.getByText('Validation Watchpoints')).toBeInTheDocument() expect(screen.getByText('Watch Context')).toBeInTheDocument() expect(screen.queryByText('Socket 1')).not.toBeInTheDocument() }) test('summarizes wired inputs and inspector variables separately', async () => { listRenderTemplates.mockResolvedValue([ createRenderTemplate({ workflow_input_schema: [ { key: 'studio_variant', label: 'Studio Variant', type: 'select', section: 'Template Inputs', description: 'Choose the blend lighting preset.', default: 'default', min: null, max: null, step: null, unit: null, options: [ { value: 'default', label: 'Default' }, { value: 'warm', label: 'Warm' }, ], allow_blank: false, }, ], }), ]) const user = userEvent.setup() renderInspector({}, vi.fn(), { stateful: true }) const templateOverride = await screen.findByLabelText('Template Override') await waitFor(() => { expect(within(templateOverride).getByRole('option', { name: /bearing studio/i })).toBeInTheDocument() }) await user.selectOptions(templateOverride, '0d87b85f-c454-4d61-a124-d5b59e6a43a2') expect(await screen.findByText(/1 required socket is exposed on the canvas/i)).toBeInTheDocument() expect(screen.getAllByText('This node waits for Order Line Context from upstream.').length).toBeGreaterThan(0) expect(screen.getByText('Required Sockets')).toBeInTheDocument() expect(screen.getByText('Socket 1')).toBeInTheDocument() expect(await screen.findByText(/2 local variables are edited in the inspector/i)).toBeInTheDocument() expect(screen.getByText(/Static: Template Override/i)).toBeInTheDocument() expect(screen.getByText(/Template-driven: Studio Variant/i)).toBeInTheDocument() expect(screen.getByText('Watch Legacy Drift')).toBeInTheDocument() }) test('shows automatic template variable coverage before a template override is selected', async () => { listRenderTemplates.mockResolvedValue([ createRenderTemplate({ id: 'template-a', name: 'Bearing Studio', output_type_names: ['Still'], workflow_input_schema: [ { key: 'studio_variant', label: 'Studio Variant', type: 'select', section: 'Template Inputs', description: 'Choose the blend lighting preset.', default: 'default', min: null, max: null, step: null, unit: null, options: [ { value: 'default', label: 'Default' }, { value: 'warm', label: 'Warm' }, ], allow_blank: false, }, ], }), createRenderTemplate({ id: 'template-b', name: 'Shadow Studio', output_type_names: ['Shadow Still'], workflow_input_schema: [ { key: 'shadow_density', label: 'Shadow Density', type: 'number', section: 'Template Inputs', description: 'Shadow catcher strength.', default: 0.65, min: 0, max: 1, step: 0.05, unit: null, options: [], allow_blank: false, }, { key: 'studio_variant', label: 'Studio Variant', type: 'select', section: 'Template Inputs', description: 'Choose the blend lighting preset.', default: 'default', min: null, max: null, step: null, unit: null, options: [ { value: 'default', label: 'Default' }, { value: 'dramatic', label: 'Dramatic' }, ], allow_blank: false, }, ], }), ]) renderInspector({}) expect(await screen.findByText('Automatic Resolution Coverage')).toBeInTheDocument() expect(screen.getByText(/2 active templates expose 2 unique workflow variables/i)).toBeInTheDocument() expect(screen.getByText('Potential Template Variables')).toBeInTheDocument() expect(screen.getAllByText('Studio Variant').length).toBeGreaterThan(0) expect(screen.getByText(/Available via Bearing Studio, Shadow Studio \(Still, Shadow Still\)\./i)).toBeInTheDocument() expect(screen.getByText(/Available via Shadow Studio \(Shadow Still\)\./i)).toBeInTheDocument() }) test('disables contract-owned still render fields until custom render settings are enabled', async () => { listRenderTemplates.mockResolvedValue([]) const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, }, }, }) render( , ) expect(screen.getByText('Render Override Scope')).toBeInTheDocument() expect(screen.getByLabelText('Samples')).toBeDisabled() expect(screen.getByLabelText('Noise Threshold')).toBeDisabled() expect(screen.getByLabelText('Focal Length (mm)')).toBeDisabled() expect(screen.getByLabelText('Target Collection')).toBeDisabled() }) test('disables contract-owned turntable fields until custom render settings are enabled', async () => { listRenderTemplates.mockResolvedValue([]) const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, }, }, }) render( , ) expect(screen.getByLabelText('Frames Per Second')).toBeDisabled() expect(screen.getByLabelText('Turntable Axis')).toBeDisabled() expect(screen.getByLabelText('Background Color')).toBeDisabled() expect(screen.getByLabelText('Camera Orbit')).toBeDisabled() }) test('explains output handoff semantics for output_save nodes', async () => { listRenderTemplates.mockResolvedValue([]) const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, }, }, }) render( , ) expect(screen.getByText('Output Handoff')).toBeInTheDocument() expect(screen.getByText(/does not render files itself/i)).toBeInTheDocument() expect(screen.getByText('render_output')).toBeInTheDocument() expect(screen.getByText(/shadow runs stay observer-only/i)).toBeInTheDocument() expect( screen.getAllByText( 'This node requires Order Line Context and one additional upstream artifact from any of: rendered image.', ).length, ).toBeGreaterThan(0) expect(screen.getByText(/1 required socket and 1 alternative group are exposed on the canvas/i)).toBeInTheDocument() expect(screen.getByText('Required Sockets')).toBeInTheDocument() expect(screen.getByText('Alternative Groups')).toBeInTheDocument() }) test('explains blend delivery semantics for export_blend nodes', async () => { listRenderTemplates.mockResolvedValue([]) const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, }, }, }) render( , ) expect(screen.getAllByText('Blend Delivery').length).toBeGreaterThan(0) expect(screen.getByText(/does not render pixels itself/i)).toBeInTheDocument() expect(screen.getByText('studio')).toBeInTheDocument() expect(screen.getByText(/save output/i)).toBeInTheDocument() }) test('explains notification handoff semantics for notify nodes', async () => { listRenderTemplates.mockResolvedValue([]) const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, }, }, }) render( , ) expect(screen.getAllByText('Notification Handoff').length).toBeGreaterThan(0) expect(screen.getByText(/does not emit independently/i)).toBeInTheDocument() expect(screen.getByText('audit_log')).toBeInTheDocument() expect(screen.getByText(/shadow runs suppress user notifications entirely/i)).toBeInTheDocument() }) })