Files
HartOMat/frontend/src/__tests__/components/WorkflowNodeInspector.test.tsx
T
HartmutandClaude Sonnet 4.6 d2e4934cca feat: workflow graph system — blocks 1-20 complete checkpoint
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>
2026-07-21 19:09:54 +02:00

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()
})
})