Full graph-based workflow execution engine with: - WorkflowGraphRuntime with two-phase dispatch (collect → fire Celery tasks) - Node registry with contract validation, socket types, execution kinds - WorkflowRuntimeServices: preflight, context resolution, shadow-mode A/B - WorkflowRouter: CRUD + dispatch/preflight/run-history API endpoints - OutputTypeContracts: workflow binding, rollout-mode resolution - Admin router: output-type workflow binding endpoints - Frontend: complete workflow editor with drag-drop canvas, node inspector, module bundles, reference bundles, preflight panel, validation banner, authoring guidance, blueprint templates, shadow/rollout gate UI - Tests: comprehensive coverage for all workflow modules - Docs: NODE_CONTRACT_AUDIT and VALIDATION_ERROR_INVENTORY All 20 blocks from NEXT_20_BLOCK_BATCH_PLAN completed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
958 lines
29 KiB
TypeScript
958 lines
29 KiB
TypeScript
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<RenderTemplate[]>>()
|
|
|
|
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> = {}): 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 (
|
|
<WorkflowNodeInspector
|
|
params={currentParams}
|
|
onChange={nextParams => {
|
|
setCurrentParams(nextParams)
|
|
onChange(nextParams)
|
|
}}
|
|
nodeDefinition={resolveTemplateDefinition}
|
|
step="resolve_template"
|
|
nodeDefinitions={[resolveTemplateDefinition]}
|
|
graphFamily="order_line"
|
|
onStepChange={vi.fn()}
|
|
/>
|
|
)
|
|
}
|
|
|
|
render(
|
|
<QueryClientProvider client={queryClient}>
|
|
{options.stateful ? (
|
|
<StatefulInspector />
|
|
) : (
|
|
<WorkflowNodeInspector
|
|
params={params}
|
|
onChange={onChange}
|
|
nodeDefinition={resolveTemplateDefinition}
|
|
step="resolve_template"
|
|
nodeDefinitions={[resolveTemplateDefinition]}
|
|
graphFamily="order_line"
|
|
onStepChange={vi.fn()}
|
|
/>
|
|
)}
|
|
</QueryClientProvider>,
|
|
)
|
|
|
|
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(
|
|
<QueryClientProvider client={queryClient}>
|
|
<WorkflowNodeInspector
|
|
params={{}}
|
|
onChange={vi.fn()}
|
|
nodeDefinition={notifyDefinition}
|
|
step="notify"
|
|
nodeDefinitions={[notifyDefinition]}
|
|
graphFamily="order_line"
|
|
onStepChange={vi.fn()}
|
|
/>
|
|
</QueryClientProvider>,
|
|
)
|
|
|
|
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(
|
|
<QueryClientProvider client={queryClient}>
|
|
<WorkflowNodeInspector
|
|
params={{}}
|
|
onChange={vi.fn()}
|
|
nodeDefinition={orderLineSetupDefinition}
|
|
step="order_line_setup"
|
|
nodeDefinitions={[orderLineSetupDefinition]}
|
|
graphFamily="order_line"
|
|
onStepChange={vi.fn()}
|
|
/>
|
|
</QueryClientProvider>,
|
|
)
|
|
|
|
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(
|
|
<QueryClientProvider client={queryClient}>
|
|
<WorkflowNodeInspector
|
|
params={{ use_custom_render_settings: false }}
|
|
onChange={vi.fn()}
|
|
nodeDefinition={blenderStillDefinition}
|
|
step="blender_still"
|
|
nodeDefinitions={[blenderStillDefinition]}
|
|
graphFamily="order_line"
|
|
onStepChange={vi.fn()}
|
|
/>
|
|
</QueryClientProvider>,
|
|
)
|
|
|
|
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(
|
|
<QueryClientProvider client={queryClient}>
|
|
<WorkflowNodeInspector
|
|
params={{ use_custom_render_settings: false }}
|
|
onChange={vi.fn()}
|
|
nodeDefinition={blenderTurntableDefinition}
|
|
step="blender_turntable"
|
|
nodeDefinitions={[blenderTurntableDefinition]}
|
|
graphFamily="order_line"
|
|
onStepChange={vi.fn()}
|
|
/>
|
|
</QueryClientProvider>,
|
|
)
|
|
|
|
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(
|
|
<QueryClientProvider client={queryClient}>
|
|
<WorkflowNodeInspector
|
|
params={{ expected_artifact_role: 'render_output', require_upstream_artifact: true }}
|
|
onChange={vi.fn()}
|
|
nodeDefinition={outputSaveDefinition}
|
|
step="output_save"
|
|
nodeDefinitions={[outputSaveDefinition]}
|
|
graphFamily="order_line"
|
|
onStepChange={vi.fn()}
|
|
/>
|
|
</QueryClientProvider>,
|
|
)
|
|
|
|
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(
|
|
<QueryClientProvider client={queryClient}>
|
|
<WorkflowNodeInspector
|
|
params={{ output_name_suffix: 'studio' }}
|
|
onChange={vi.fn()}
|
|
nodeDefinition={exportBlendDefinition}
|
|
step="export_blend"
|
|
nodeDefinitions={[exportBlendDefinition]}
|
|
graphFamily="order_line"
|
|
onStepChange={vi.fn()}
|
|
/>
|
|
</QueryClientProvider>,
|
|
)
|
|
|
|
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(
|
|
<QueryClientProvider client={queryClient}>
|
|
<WorkflowNodeInspector
|
|
params={{ channel: 'audit_log', require_armed_render: true }}
|
|
onChange={vi.fn()}
|
|
nodeDefinition={{
|
|
...notifyDefinition,
|
|
fields: [
|
|
{
|
|
key: 'channel',
|
|
label: 'Channel',
|
|
type: 'select',
|
|
description: 'Notification channel.',
|
|
section: 'Notification',
|
|
default: 'audit_log',
|
|
min: null,
|
|
max: null,
|
|
step: null,
|
|
unit: null,
|
|
options: [{ value: 'audit_log', label: 'Audit Log' }],
|
|
allow_blank: false,
|
|
max_length: null,
|
|
text_format: null,
|
|
},
|
|
{
|
|
key: 'require_armed_render',
|
|
label: 'Require Armed Render',
|
|
type: 'boolean',
|
|
description: 'Fail when no render task hands off notifications.',
|
|
section: 'Notification',
|
|
default: false,
|
|
min: null,
|
|
max: null,
|
|
step: null,
|
|
unit: null,
|
|
options: [],
|
|
allow_blank: true,
|
|
max_length: null,
|
|
text_format: null,
|
|
},
|
|
],
|
|
}}
|
|
step="notify"
|
|
nodeDefinitions={[notifyDefinition]}
|
|
graphFamily="order_line"
|
|
onStepChange={vi.fn()}
|
|
/>
|
|
</QueryClientProvider>,
|
|
)
|
|
|
|
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()
|
|
})
|
|
})
|