|
35 | 35 | * template — including a head variable named like an identifier CEL claims |
36 | 36 | * for itself (`{list.0}` → `vars["list"][0]`, #22290), or one the flow |
37 | 37 | * scope binds over it (`{vars.0}` → `vars["vars"][0]`), and inside an |
38 | | - * EXPRESSION token (`{int * 2}` → `vars["int"] * 2`). |
| 38 | + * EXPRESSION token (`{int * 2}` → `vars["int"] * 2`) — and the `has()` |
| 39 | + * guard it prints answers `null`, rather than failing the run, where the |
| 40 | + * variable itself was never bound (#19939 pass 3). |
39 | 41 | */ |
40 | 42 |
|
41 | 43 | import { describe, expect, it } from 'vitest'; |
42 | 44 | import { VALUE_SLOT_TEMPLATE_REFUSAL, valueSlotTemplateRefusals } from '@objectstack/spec/automation'; |
43 | 45 | import { AutomationEngine } from '../engine.js'; |
| 46 | +import { registerLogicNodes } from './logic-nodes.js'; |
44 | 47 | import { interpolateString } from './template.js'; |
45 | 48 |
|
46 | 49 | const VARIABLES = new Map<string, unknown>([ |
@@ -196,11 +199,62 @@ describe('the remedy a refused path prints reads, through CEL, the value the int |
196 | 199 | }); |
197 | 200 |
|
198 | 201 | it('the guard is read off the refusal too, so each row above evaluates it where one is printed', () => { |
199 | | - expect(printedSpellings('{list.tags}')).toEqual(['vars["list"].tags', 'has(vars.list.tags) ? vars.list.tags : null']); |
| 202 | + expect(printedSpellings('{list.tags}')).toEqual(['vars["list"].tags', 'has(vars.list) && has(vars.list.tags) ? vars.list.tags : null']); |
200 | 203 | expect(printedSpellings('{list}')).toEqual(['vars["list"]', 'has(vars.list) ? vars.list : null']); |
201 | 204 | expect(printedSpellings('{null.tags}')).toEqual(['vars["null"].tags']); |
202 | 205 | }); |
203 | 206 |
|
| 207 | + // The guard is for "one that may be absent" — the VARIABLE as well as a key. |
| 208 | + // A variable the run never bound (an `isInput` one the caller left out, with |
| 209 | + // no default: `seedDeclaredVariables` sets nothing for it) is absent from the |
| 210 | + // scope, and `has()` evaluates everything but its last selection, so a guard |
| 211 | + // on the last key alone, `has(source.id)`, fails the run there. The printed |
| 212 | + // guard tests each step off `vars`, which holds only the bound variables. |
| 213 | + it.each([ |
| 214 | + ['{source.id}', 'source', { id: 'r1' }, 'r1'], |
| 215 | + ['{record.owner.name}', 'record', { owner: { name: 'Ada' } }, 'Ada'], |
| 216 | + ['{list.tags}', 'list', { tags: 'T' }, 'T'], |
| 217 | + ['{x}', 'x', 7, 7], |
| 218 | + ])('the printed guard of %s answers `null` where the variable, or a key on the way, is absent', (token, root, bound, value) => { |
| 219 | + const guard = printedSpellings(token)[1]!; |
| 220 | + expect(guard, token).toMatch(/^has\(vars\./); |
| 221 | + const evaluate = (variables: Map<string, unknown>) => |
| 222 | + engine.evaluateValueEnvelope({ dialect: 'cel', source: guard }, variables, token); |
| 223 | + expect(evaluate(new Map()), `${guard} — ${root} unbound`).toBeNull(); |
| 224 | + expect(evaluate(new Map([[root, null]])), `${guard} — ${root} null`).toBeNull(); |
| 225 | + if (typeof bound === 'object') expect(evaluate(new Map([[root, {}]])), `${guard} — a key absent`).toBeNull(); |
| 226 | + const variables = new Map<string, unknown>([[root, bound]]); |
| 227 | + expect(interpolateString(token, variables, CONTEXT)).toEqual(value); |
| 228 | + expect(evaluate(variables), `${guard} — bound`).toEqual(value); |
| 229 | + }); |
| 230 | + |
| 231 | + it('through a run: an `isInput` variable the caller left out — the printed guard writes `null`, a guard on the last key fails the run', async () => { |
| 232 | + const guard = printedSpellings('{source.id}')[1]!; |
| 233 | + expect(guard).toBe('has(vars.source) && has(vars.source.id) ? vars.source.id : null'); |
| 234 | + const run = async (source: string) => { |
| 235 | + const flowEngine = new AutomationEngine(quiet); |
| 236 | + registerLogicNodes(flowEngine, { logger: quiet, getService: () => undefined } as never); |
| 237 | + flowEngine.registerFlow('guarded', { |
| 238 | + name: 'guarded', label: 'Guarded', type: 'autolaunched', |
| 239 | + variables: [{ name: 'source', type: 'object', isInput: true }, { name: 'who', type: 'text', isOutput: true }], |
| 240 | + nodes: [ |
| 241 | + { id: 'start', type: 'start', label: 'Start' }, |
| 242 | + { id: 'assign', type: 'assignment', label: 'Assign', config: { assignments: { who: { dialect: 'cel', source } } } }, |
| 243 | + { id: 'end', type: 'end', label: 'End' }, |
| 244 | + ], |
| 245 | + edges: [{ id: 'e1', source: 'start', target: 'assign' }, { id: 'e2', source: 'assign', target: 'end' }], |
| 246 | + } as never); |
| 247 | + return flowEngine.execute('guarded', { params: {} } as never); |
| 248 | + }; |
| 249 | + const guarded = await run(guard); |
| 250 | + expect(guarded.success, guarded.error).toBe(true); |
| 251 | + expect(guarded.output).toEqual({ who: null }); |
| 252 | + // Control: the guard the refusal printed before — `has()` on the last key alone. |
| 253 | + const lastKeyOnly = await run('has(source.id) ? source.id : null'); |
| 254 | + expect(lastKeyOnly.success).toBe(false); |
| 255 | + expect(lastKeyOnly.error).toContain('Unknown variable: source'); |
| 256 | + }); |
| 257 | + |
204 | 258 | it('a later keyword segment, and an index in the middle of a path', () => { |
205 | 259 | const variables = new Map<string, unknown>([['record', { in: 'x', tags: { null: 'y' } }], ['rows', [{ name: 'r0' }]]]); |
206 | 260 | expectReadingsAgree('{record.in}', variables, 'x'); |
|
0 commit comments