|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * The Debug run's paused screen decides a field's `visibleWhen` live, through |
| 5 | + * the flow runner's own renderer, over the screen's declared fields and the |
| 6 | + * values the author is collecting — never over the run's variables |
| 7 | + * (objectui#10743). Every run goes through the real `FlowSimulator`, and every |
| 8 | + * screen through the real `ScreenPreview` → `ScreenView`, the way |
| 9 | + * `FlowSimulatorPanel` mounts them (`node` + `snapshot.variables`). |
| 10 | + * |
| 11 | + * The three cases are the ruling's three pins, each with a control field that |
| 12 | + * declares no predicate in the same tree: |
| 13 | + * |
| 14 | + * 1. a sibling-field predicate reveals its field live (the console sample's |
| 15 | + * `note`, `visibleWhen: 'discount > 0'`, and the docs shape |
| 16 | + * `createOpportunity == true`) — the Debug run used to judge the predicate |
| 17 | + * once against the run's variables, drop the field and strip the predicate, |
| 18 | + * so nothing the author typed or ticked ever brought it back; |
| 19 | + * 2. a predicate over a run variable is not read from the run's variables, and |
| 20 | + * the screen step names it as an error; |
| 21 | + * 3. the control: a field with no predicate is drawn throughout. |
| 22 | + * |
| 23 | + * ⛔ Whether the renderer SHOWS or HIDES a predicate it cannot evaluate is its |
| 24 | + * own fallback and objectui#8069's question. Pin 2 therefore reads the SCOPE |
| 25 | + * only: the render is the same whether the run variable is `true` or `false`. |
| 26 | + */ |
| 27 | + |
| 28 | +import { describe, it, expect, vi, afterEach } from 'vitest'; |
| 29 | +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; |
| 30 | + |
| 31 | +vi.mock('../../../providers/AdapterProvider', () => ({ |
| 32 | + useAdapter: () => ({ fake: 'adapter' }), |
| 33 | +})); |
| 34 | + |
| 35 | +vi.mock('../../../providers/MetadataProvider', () => ({ |
| 36 | + useMetadata: () => ({ objects: [] }), |
| 37 | +})); |
| 38 | + |
| 39 | +import { ScreenPreview } from './ScreenPreview'; |
| 40 | +import { FlowSimulator } from './simulator/flow-simulator'; |
| 41 | +import type { SimEdge, SimNode } from './simulator/flow-sim-types'; |
| 42 | + |
| 43 | +afterEach(() => cleanup()); |
| 44 | + |
| 45 | +/** The console preview sample's `review` screen (apps/console `preview-samples.ts`), plus a control field. */ |
| 46 | +const consoleSample: SimNode = { |
| 47 | + id: 'review', |
| 48 | + type: 'screen', |
| 49 | + label: 'CSM review', |
| 50 | + config: { |
| 51 | + fields: [ |
| 52 | + { name: 'discount', label: 'Discount %', type: 'number', required: false }, |
| 53 | + { name: 'note', label: 'Note', type: 'text', required: true, visibleWhen: 'discount > 0' }, |
| 54 | + { name: 'comment', label: 'Comment', type: 'text' }, |
| 55 | + ], |
| 56 | + }, |
| 57 | +}; |
| 58 | + |
| 59 | +/** The docs' lead-conversion screen (`content/docs/automation/flows.mdx`), plus a control field. */ |
| 60 | +const docsShape: SimNode = { |
| 61 | + id: 'screen_1', |
| 62 | + type: 'screen', |
| 63 | + label: 'Conversion Details', |
| 64 | + config: { |
| 65 | + fields: [ |
| 66 | + { name: 'createOpportunity', label: 'Create Opportunity?', type: 'boolean', required: true, defaultValue: false }, |
| 67 | + { name: 'opportunityName', label: 'Opportunity Name', type: 'text', required: true, visibleWhen: 'createOpportunity == true' }, |
| 68 | + { name: 'plain', label: 'Plain', type: 'text' }, |
| 69 | + ], |
| 70 | + }, |
| 71 | +}; |
| 72 | + |
| 73 | +/** A predicate over a run variable — nothing on this screen declares `needsApproval`. */ |
| 74 | +const runVariableShape: SimNode = { |
| 75 | + id: 'gate', |
| 76 | + type: 'screen', |
| 77 | + label: 'Gate', |
| 78 | + config: { |
| 79 | + fields: [ |
| 80 | + { name: 'reason', label: 'Reason', type: 'text', required: true, visibleWhen: 'needsApproval == true' }, |
| 81 | + { name: 'plain', label: 'Plain', type: 'text' }, |
| 82 | + ], |
| 83 | + }, |
| 84 | +}; |
| 85 | + |
| 86 | +/** start → `node` → end, run to the screen pause with `seed` as the run's variables. */ |
| 87 | +function pauseAt(node: SimNode, seed: Record<string, unknown> = {}) { |
| 88 | + const nodes: SimNode[] = [{ id: 'start', type: 'start' }, node, { id: 'end', type: 'end' }]; |
| 89 | + const edges: SimEdge[] = [ |
| 90 | + { id: 'e1', source: 'start', target: node.id }, |
| 91 | + { id: 'e2', source: node.id, target: 'end' }, |
| 92 | + ]; |
| 93 | + const sim = new FlowSimulator(nodes, edges); |
| 94 | + sim.reset(seed); |
| 95 | + sim.runToEnd(); |
| 96 | + expect(sim.state.status).toBe('paused'); |
| 97 | + expect(sim.state.pausedReason).toBe('screen'); |
| 98 | + const step = sim.state.steps.find((st) => st.nodeId === node.id); |
| 99 | + return { sim, step, node, variables: sim.state.variables }; |
| 100 | +} |
| 101 | + |
| 102 | +describe('the Debug run screen decides a sibling-field visibleWhen live (objectui#10743)', () => { |
| 103 | + it('console sample: typing a discount above zero reveals Note; zero hides it; Comment (no predicate) is drawn throughout', () => { |
| 104 | + const { node, variables, step } = pauseAt(consoleSample); |
| 105 | + const { container } = render(<ScreenPreview node={node} variables={variables} />); |
| 106 | + const discount = container.querySelector('#ff-discount') as HTMLInputElement | null; |
| 107 | + expect(discount).not.toBeNull(); |
| 108 | + expect(screen.getByText('Comment')).toBeInTheDocument(); |
| 109 | + |
| 110 | + fireEvent.change(discount!, { target: { value: '5' } }); |
| 111 | + expect(screen.getByText('Note')).toBeInTheDocument(); |
| 112 | + expect(screen.getByText('Comment')).toBeInTheDocument(); |
| 113 | + |
| 114 | + fireEvent.change(discount!, { target: { value: '0' } }); |
| 115 | + expect(screen.queryByText('Note')).not.toBeInTheDocument(); |
| 116 | + expect(screen.getByText('Comment')).toBeInTheDocument(); |
| 117 | + |
| 118 | + // The sibling predicate is the renderer's to decide, not a step error, and |
| 119 | + // the pause never names `note` as a field it hid. |
| 120 | + expect(step?.status).toBe('paused'); |
| 121 | + expect(step?.error).toBeUndefined(); |
| 122 | + expect(step?.note ?? '').not.toMatch(/"note"/); |
| 123 | + }); |
| 124 | + |
| 125 | + it('docs shape: ticking Create Opportunity reveals Opportunity Name; Plain (no predicate) is drawn throughout', () => { |
| 126 | + const { node, variables, step } = pauseAt(docsShape); |
| 127 | + const { container } = render(<ScreenPreview node={node} variables={variables} />); |
| 128 | + expect(screen.queryByText('Opportunity Name')).not.toBeInTheDocument(); |
| 129 | + expect(screen.getByText('Plain')).toBeInTheDocument(); |
| 130 | + expect(screen.getByText(/hidden by .*visible when/i)).toBeInTheDocument(); |
| 131 | + |
| 132 | + fireEvent.click(container.querySelector('#ff-createOpportunity')!); |
| 133 | + expect(screen.getByText('Opportunity Name')).toBeInTheDocument(); |
| 134 | + expect(screen.getByText('Plain')).toBeInTheDocument(); |
| 135 | + expect(screen.queryByText(/hidden by .*visible when/i)).not.toBeInTheDocument(); |
| 136 | + |
| 137 | + expect(step?.error).toBeUndefined(); |
| 138 | + expect(step?.note ?? '').not.toMatch(/"opportunityName"/); |
| 139 | + }); |
| 140 | + |
| 141 | + it('docs shape with the flow variable declared (`createOpportunity: false` in the run, as the docs recommend): the tick still reveals the field', () => { |
| 142 | + // This is the shape that froze hardest: the run held `false`, the predicate |
| 143 | + // evaluated false against it, the field was dropped, and the checkbox the |
| 144 | + // author ticked in the preview changed nothing. |
| 145 | + const { node, variables } = pauseAt(docsShape, { createOpportunity: false }); |
| 146 | + expect(variables).toEqual({ createOpportunity: false }); |
| 147 | + const { container } = render(<ScreenPreview node={node} variables={variables} />); |
| 148 | + expect(screen.queryByText('Opportunity Name')).not.toBeInTheDocument(); |
| 149 | + fireEvent.click(container.querySelector('#ff-createOpportunity')!); |
| 150 | + expect(screen.getByText('Opportunity Name')).toBeInTheDocument(); |
| 151 | + expect(screen.getByText('Plain')).toBeInTheDocument(); |
| 152 | + }); |
| 153 | +}); |
| 154 | + |
| 155 | +describe('the Debug run screen does not read a visibleWhen from the run variables, and names it (objectui#10743)', () => { |
| 156 | + it('the render is the same whether the run holds needsApproval true or false; Plain (no predicate) is drawn in both', () => { |
| 157 | + const t = pauseAt(runVariableShape, { needsApproval: true }); |
| 158 | + const withTrue = render(<ScreenPreview node={t.node} variables={t.variables} />); |
| 159 | + const reasonWithTrue = withTrue.queryByText('Reason') !== null; |
| 160 | + expect(withTrue.getByText('Plain')).toBeInTheDocument(); |
| 161 | + withTrue.unmount(); |
| 162 | + |
| 163 | + const f = pauseAt(runVariableShape, { needsApproval: false }); |
| 164 | + const withFalse = render(<ScreenPreview node={f.node} variables={f.variables} />); |
| 165 | + const reasonWithFalse = withFalse.queryByText('Reason') !== null; |
| 166 | + expect(withFalse.getByText('Plain')).toBeInTheDocument(); |
| 167 | + |
| 168 | + // Scope, not direction: the run variable is not what decides the field. |
| 169 | + expect(reasonWithFalse).toBe(reasonWithTrue); |
| 170 | + }); |
| 171 | + |
| 172 | + it('the screen step names the field whose visibleWhen references a name that is not a field on this screen, whatever the run holds', () => { |
| 173 | + for (const seed of [{}, { needsApproval: true }, { needsApproval: false }]) { |
| 174 | + const { step } = pauseAt(runVariableShape, seed); |
| 175 | + expect(step?.status).toBe('paused'); |
| 176 | + expect(step?.error).toMatch(/"reason"/); |
| 177 | + expect(step?.error).toMatch(/needsApproval/); |
| 178 | + expect(step?.error).not.toMatch(/"plain"/); |
| 179 | + } |
| 180 | + }); |
| 181 | +}); |
0 commit comments