|
1 | 1 | import { describe, expect, it } from 'vitest'; |
2 | 2 |
|
3 | 3 | import { celEngine } from './cel-engine'; |
| 4 | +import { CEL_STDLIB_FUNCTIONS } from './validate'; |
4 | 5 | import type { Expression } from '@objectstack/spec'; |
5 | 6 |
|
6 | 7 | const cel = (source: string): Expression => ({ dialect: 'cel', source }); |
@@ -296,4 +297,77 @@ describe('celEngine', () => { |
296 | 297 | expect(r).toEqual({ ok: true, value: true }); |
297 | 298 | }); |
298 | 299 | }); |
| 300 | + |
| 301 | + // #1928 follow-up — the authoring catalog (`introspectScope`) advertised 25 |
| 302 | + // functions but only 8 were registered; 14 faulted at runtime. These cover the |
| 303 | + // newly-registered stdlib, plus a drift-guard that every advertised function resolves. |
| 304 | + describe('stdlib catalog (registered functions)', () => { |
| 305 | + const now = new Date('2026-06-16T00:00:00Z'); |
| 306 | + |
| 307 | + it('daysBetween counts whole days (negative when reversed)', () => { |
| 308 | + expect(celEngine.evaluate(cel('daysBetween(today(), daysFromNow(7))'), { now })) |
| 309 | + .toEqual({ ok: true, value: 7 }); |
| 310 | + expect(celEngine.evaluate(cel('daysBetween(today(), daysAgo(3))'), { now })) |
| 311 | + .toEqual({ ok: true, value: -3 }); |
| 312 | + }); |
| 313 | + |
| 314 | + it('daysBetween coerces a date-string field arg (no manual hydration)', () => { |
| 315 | + const r = celEngine.evaluate(cel('daysBetween(today(), record.due)'), { |
| 316 | + now, record: { due: '2026-06-26' }, |
| 317 | + }); |
| 318 | + expect(r).toEqual({ ok: true, value: 10 }); |
| 319 | + }); |
| 320 | + |
| 321 | + it('date / datetime parse ISO strings to a timestamp', () => { |
| 322 | + const r = celEngine.evaluate(cel('date("2026-03-15") < datetime("2026-03-16T08:00:00Z")'), {}); |
| 323 | + expect(r).toEqual({ ok: true, value: true }); |
| 324 | + }); |
| 325 | + |
| 326 | + it('abs / round / min / max', () => { |
| 327 | + expect(celEngine.evaluate(cel('abs(record.x)'), { record: { x: -3.5 } })).toEqual({ ok: true, value: 3.5 }); |
| 328 | + expect(celEngine.evaluate(cel('round(2.6)'), {})).toEqual({ ok: true, value: 3 }); |
| 329 | + expect(celEngine.evaluate(cel('min(record.a, record.b)'), { record: { a: 3, b: 7 } })).toEqual({ ok: true, value: 3 }); |
| 330 | + expect(celEngine.evaluate(cel('max(record.a, record.b)'), { record: { a: 3, b: 7 } })).toEqual({ ok: true, value: 7 }); |
| 331 | + }); |
| 332 | + |
| 333 | + it('string ops: upper / lower / contains / startsWith / endsWith / matches', () => { |
| 334 | + expect(celEngine.evaluate(cel('upper(record.s)'), { record: { s: 'hi' } })).toEqual({ ok: true, value: 'HI' }); |
| 335 | + expect(celEngine.evaluate(cel('lower("HI")'), {})).toEqual({ ok: true, value: 'hi' }); |
| 336 | + expect(celEngine.evaluate(cel('contains(record.s, "ell")'), { record: { s: 'hello' } })).toEqual({ ok: true, value: true }); |
| 337 | + expect(celEngine.evaluate(cel('startsWith("hello", "he")'), {})).toEqual({ ok: true, value: true }); |
| 338 | + expect(celEngine.evaluate(cel('endsWith("hello", "lo")'), {})).toEqual({ ok: true, value: true }); |
| 339 | + expect(celEngine.evaluate(cel('matches("a1", "a.")'), {})).toEqual({ ok: true, value: true }); |
| 340 | + }); |
| 341 | + |
| 342 | + it('len / isEmpty over strings and lists', () => { |
| 343 | + expect(celEngine.evaluate(cel('len(record.items)'), { record: { items: [1, 2, 3] } })).toEqual({ ok: true, value: 3 }); |
| 344 | + expect(celEngine.evaluate(cel('isEmpty("")'), {})).toEqual({ ok: true, value: true }); |
| 345 | + expect(celEngine.evaluate(cel('isEmpty(record.items)'), { record: { items: [] } })).toEqual({ ok: true, value: true }); |
| 346 | + expect(celEngine.evaluate(cel('isEmpty(record.items)'), { record: { items: [1] } })).toEqual({ ok: true, value: false }); |
| 347 | + }); |
| 348 | + |
| 349 | + // Drift guard: introspectScope promises these to authors; every one must resolve. |
| 350 | + it('every advertised CEL_STDLIB_FUNCTIONS entry resolves at runtime', () => { |
| 351 | + const call: Record<string, string> = { |
| 352 | + now: 'now()', today: 'today()', daysFromNow: 'daysFromNow(30)', daysAgo: 'daysAgo(7)', |
| 353 | + daysBetween: 'daysBetween(today(), daysFromNow(7))', date: 'date("2026-03-15")', |
| 354 | + datetime: 'datetime("2026-03-15T08:00:00Z")', abs: 'abs(-3.5)', round: 'round(2.6)', |
| 355 | + min: 'min(1, 2)', max: 'max(1, 2)', upper: 'upper("hi")', lower: 'lower("HI")', |
| 356 | + trim: 'trim(" x ")', contains: 'contains("hello", "ell")', startsWith: 'startsWith("hi", "h")', |
| 357 | + endsWith: 'endsWith("hi", "i")', matches: 'matches("a1", "a.")', joinNonEmpty: 'joinNonEmpty(["a", "b"], "-")', |
| 358 | + isBlank: 'isBlank("")', isEmpty: 'isEmpty([])', coalesce: 'coalesce(null, "x")', len: 'len("ab")', |
| 359 | + size: 'size([1, 2])', has: 'has(record.a)', int: 'int("3")', string: 'string(3)', |
| 360 | + bool: 'bool("true")', double: 'double("3.5")', timestamp: 'timestamp("2026-01-01T00:00:00Z")', |
| 361 | + duration: 'duration("3600s")', |
| 362 | + }; |
| 363 | + const unresolved: string[] = []; |
| 364 | + for (const fn of CEL_STDLIB_FUNCTIONS) { |
| 365 | + const src = call[fn]; |
| 366 | + expect(src, `no probe call defined for advertised function \`${fn}\``).toBeTruthy(); |
| 367 | + const r = celEngine.evaluate(cel(src), { now, record: { a: 1 } }); |
| 368 | + if (!r.ok) unresolved.push(`${fn}: ${r.error.message.split('\n')[0]}`); |
| 369 | + } |
| 370 | + expect(unresolved, `advertised functions that fault at runtime:\n${unresolved.join('\n')}`).toEqual([]); |
| 371 | + }); |
| 372 | + }); |
299 | 373 | }); |
0 commit comments