|
| 1 | +import { afterEach, describe, expect, it } from 'vitest'; |
| 2 | + |
| 3 | +import { celEngine } from './cel-engine'; |
| 4 | +import { inferExpressionType, validateExpression } from './validate'; |
| 5 | + |
| 6 | +/** |
| 7 | + * `isoDate(t)` / `isoDatetime(t)` — the string form of a CEL timestamp. |
| 8 | + * |
| 9 | + * ## What they must reproduce |
| 10 | + * |
| 11 | + * The flow template dialect's date macros (`service-automation` |
| 12 | + * `builtin/template.ts`, `resolveToken`) write `new Date().toISOString()` for |
| 13 | + * `{NOW()}`, its first ten characters for `{TODAY()}`, and shift the instant by |
| 14 | + * whole UTC days (`setUTCDate`) for `{TODAY() ± N}` / `{NOW() ± N}`. The |
| 15 | + * expected strings in {@link TEMPLATE_BYTES} are that function's own output, |
| 16 | + * measured through `interpolateString` at these instants under six host |
| 17 | + * zones. The host zone changed no byte of it. |
| 18 | + * |
| 19 | + * ## The scope these pins evaluate in |
| 20 | + * |
| 21 | + * A flow value envelope is evaluated with `AutomationEngine.celScope`, which |
| 22 | + * passes `{ extra, record }` and nothing else, so the engine runs on the UTC |
| 23 | + * calendar with the wall clock. Here `now` pins the clock to the row's instant |
| 24 | + * and nothing else differs. |
| 25 | + */ |
| 26 | + |
| 27 | +const flowScope = (at: string) => ({ now: new Date(at), extra: {}, record: {} }); |
| 28 | + |
| 29 | +function value(source: string, at: string): unknown { |
| 30 | + const r = celEngine.evaluate({ dialect: 'cel', source }, flowScope(at)); |
| 31 | + if (!r.ok) throw new Error(`${source} @ ${at}: ${r.error.kind}: ${r.error.message}`); |
| 32 | + return r.value; |
| 33 | +} |
| 34 | + |
| 35 | +/** `interpolateString` output at each instant, per token (measured; see the header). */ |
| 36 | +const TEMPLATE_BYTES = [ |
| 37 | + { at: '2026-10-08T17:55:06.123Z', today: '2026-10-08', now: '2026-10-08T17:55:06.123Z', plus3: '2026-10-11', minus1: '2026-10-07', nowPlus1: '2026-10-09T17:55:06.123Z' }, |
| 38 | + { at: '2026-10-08T00:00:00.000Z', today: '2026-10-08', now: '2026-10-08T00:00:00.000Z', plus3: '2026-10-11', minus1: '2026-10-07', nowPlus1: '2026-10-09T00:00:00.000Z' }, |
| 39 | + { at: '2026-01-31T23:59:59.999Z', today: '2026-01-31', now: '2026-01-31T23:59:59.999Z', plus3: '2026-02-03', minus1: '2026-01-30', nowPlus1: '2026-02-01T23:59:59.999Z' }, |
| 40 | + { at: '2026-02-28T12:00:00.000Z', today: '2026-02-28', now: '2026-02-28T12:00:00.000Z', plus3: '2026-03-03', minus1: '2026-02-27', nowPlus1: '2026-03-01T12:00:00.000Z' }, |
| 41 | + { at: '2028-02-28T12:00:00.000Z', today: '2028-02-28', now: '2028-02-28T12:00:00.000Z', plus3: '2028-03-02', minus1: '2028-02-27', nowPlus1: '2028-02-29T12:00:00.000Z' }, |
| 42 | + { at: '2026-12-31T23:30:00.000Z', today: '2026-12-31', now: '2026-12-31T23:30:00.000Z', plus3: '2027-01-03', minus1: '2026-12-30', nowPlus1: '2027-01-01T23:30:00.000Z' }, |
| 43 | + { at: '2026-03-08T07:30:00.000Z', today: '2026-03-08', now: '2026-03-08T07:30:00.000Z', plus3: '2026-03-11', minus1: '2026-03-07', nowPlus1: '2026-03-09T07:30:00.000Z' }, |
| 44 | + { at: '2026-03-29T00:30:00.000Z', today: '2026-03-29', now: '2026-03-29T00:30:00.000Z', plus3: '2026-04-01', minus1: '2026-03-28', nowPlus1: '2026-03-30T00:30:00.000Z' }, |
| 45 | + { at: '2026-11-01T23:30:00.000Z', today: '2026-11-01', now: '2026-11-01T23:30:00.000Z', plus3: '2026-11-04', minus1: '2026-10-31', nowPlus1: '2026-11-02T23:30:00.000Z' }, |
| 46 | +] as const; |
| 47 | + |
| 48 | +const REAL_TZ = process.env.TZ; |
| 49 | +afterEach(() => { |
| 50 | + if (REAL_TZ === undefined) delete process.env.TZ; |
| 51 | + else process.env.TZ = REAL_TZ; |
| 52 | +}); |
| 53 | + |
| 54 | +describe('the two shapes write the template dialect\'s bytes', () => { |
| 55 | + // A host zone east of every boundary instant's UTC day, one west, and UTC: |
| 56 | + // the renderer reads the UTC calendar only, so all three must agree. |
| 57 | + for (const hostZone of ['UTC', 'Pacific/Auckland', 'America/New_York']) { |
| 58 | + it(`over every instant, with the host process in ${hostZone}`, () => { |
| 59 | + process.env.TZ = hostZone; |
| 60 | + for (const row of TEMPLATE_BYTES) { |
| 61 | + expect(value('isoDate(today())', row.at), `{TODAY()} @ ${row.at}`).toBe(row.today); |
| 62 | + expect(value('isoDatetime(now())', row.at), `{NOW()} @ ${row.at}`).toBe(row.now); |
| 63 | + expect(value('isoDate(daysFromNow(3))', row.at), `{TODAY() + 3} @ ${row.at}`).toBe(row.plus3); |
| 64 | + expect(value('isoDate(addDays(today(), 3))', row.at), `{TODAY() + 3} @ ${row.at}`).toBe(row.plus3); |
| 65 | + expect(value('isoDate(daysAgo(1))', row.at), `{TODAY() - 1} @ ${row.at}`).toBe(row.minus1); |
| 66 | + expect(value('isoDatetime(addDays(now(), 1))', row.at), `{NOW() + 1} @ ${row.at}`).toBe(row.nowPlus1); |
| 67 | + } |
| 68 | + }); |
| 69 | + } |
| 70 | + |
| 71 | + it('isoDate renders the UTC calendar, so isoDate(today()) is the reference-timezone day', () => { |
| 72 | + // 23:30Z on Oct 8 is already Oct 9 in Auckland. today() under that |
| 73 | + // reference zone is Oct 9 at UTC midnight (ADR-0053 D1), and the instant |
| 74 | + // itself is still Oct 8 on the UTC calendar. A renderer that read the |
| 75 | + // reference zone would print the day before today() in every zone west of |
| 76 | + // UTC (the New York case below). |
| 77 | + const ctx = { now: new Date('2026-10-08T23:30:00.000Z'), timezone: 'Pacific/Auckland' }; |
| 78 | + expect(celEngine.evaluate({ dialect: 'cel', source: 'isoDate(today())' }, ctx)).toEqual({ ok: true, value: '2026-10-09' }); |
| 79 | + expect(celEngine.evaluate({ dialect: 'cel', source: 'isoDate(now())' }, ctx)).toEqual({ ok: true, value: '2026-10-08' }); |
| 80 | + const west = { now: new Date('2026-10-08T02:00:00.000Z'), timezone: 'America/New_York' }; |
| 81 | + expect(celEngine.evaluate({ dialect: 'cel', source: 'isoDate(today())' }, west)).toEqual({ ok: true, value: '2026-10-07' }); |
| 82 | + }); |
| 83 | +}); |
| 84 | + |
| 85 | +describe('the build agrees with the run', () => { |
| 86 | + it('accepts both spellings in a value slot and infers text', () => { |
| 87 | + for (const source of ['isoDate(today())', 'isoDatetime(now())', 'isoDate(daysFromNow(3))']) { |
| 88 | + expect(validateExpression('value', { dialect: 'cel', source }), source).toEqual({ ok: true, errors: [], warnings: [] }); |
| 89 | + expect(inferExpressionType({ dialect: 'cel', source }), source).toBe('text'); |
| 90 | + } |
| 91 | + }); |
| 92 | + |
| 93 | + it('refuses a misspelling with the unknown-function did-you-mean', () => { |
| 94 | + for (const [source, name, suggestion] of [ |
| 95 | + ['isoDte(today())', 'isoDte', 'isoDate'], |
| 96 | + ['isoDateTime(now())', 'isoDateTime', 'isoDatetime'], |
| 97 | + ] as const) { |
| 98 | + const v = validateExpression('value', { dialect: 'cel', source }); |
| 99 | + expect(v.ok, source).toBe(false); |
| 100 | + expect(v.errors.map((e) => e.code), source).toEqual(['cel-unknown-function']); |
| 101 | + expect(v.errors[0].params, source).toMatchObject({ name, suggestion }); |
| 102 | + expect(celEngine.evaluate({ dialect: 'cel', source }, flowScope(TEMPLATE_BYTES[0].at)).ok, source).toBe(false); |
| 103 | + } |
| 104 | + }); |
| 105 | + |
| 106 | + it('keeps string(timestamp) refused, so each shape has exactly one spelling', () => { |
| 107 | + // CEL defines string(timestamp) as RFC 3339 text that drops a zero |
| 108 | + // fraction (`…T00:00:00Z`), which is not the template's `.000Z`. A cel-js |
| 109 | + // upgrade that starts accepting it turns this red, and a person decides. |
| 110 | + for (const source of ['string(today())', 'string(now())']) { |
| 111 | + const v = validateExpression('value', { dialect: 'cel', source }); |
| 112 | + expect(v.errors.map((e) => e.code), source).toEqual(['invalid-cel']); |
| 113 | + expect(String(v.errors[0].params && 'detail' in v.errors[0].params ? v.errors[0].params.detail : ''), source) |
| 114 | + .toContain('string(google.protobuf.Timestamp)'); |
| 115 | + expect(celEngine.evaluate({ dialect: 'cel', source }, flowScope(TEMPLATE_BYTES[0].at)).ok, source).toBe(false); |
| 116 | + } |
| 117 | + }); |
| 118 | +}); |
| 119 | + |
| 120 | +describe('a non-timestamp argument is refused loudly, never rendered', () => { |
| 121 | + it('at build, when the argument\'s type is known', () => { |
| 122 | + for (const [source, overload] of [ |
| 123 | + ["isoDate('2026-10-08')", 'isoDate(string)'], |
| 124 | + ['isoDatetime(20261008)', 'isoDatetime(int)'], |
| 125 | + ['isoDate(null)', 'isoDate(null)'], |
| 126 | + ] as const) { |
| 127 | + const v = validateExpression('value', { dialect: 'cel', source }); |
| 128 | + expect(v.errors.map((e) => e.code), source).toEqual(['invalid-cel']); |
| 129 | + expect(String(v.errors[0].params && 'detail' in v.errors[0].params ? v.errors[0].params.detail : ''), source).toContain(overload); |
| 130 | + } |
| 131 | + }); |
| 132 | + |
| 133 | + it('at run, when the value arrives as text, a number or null', () => { |
| 134 | + const record = { d: '2026-10-08', n: 5, z: null }; |
| 135 | + for (const [source, overload] of [ |
| 136 | + ['isoDate(record.d)', 'isoDate(string)'], |
| 137 | + ['isoDatetime(record.n)', 'isoDatetime(double)'], |
| 138 | + ['isoDate(record.z)', 'isoDate(null)'], |
| 139 | + ] as const) { |
| 140 | + const r = celEngine.evaluate({ dialect: 'cel', source }, { now: new Date(TEMPLATE_BYTES[0].at), record }); |
| 141 | + expect(r.ok, source).toBe(false); |
| 142 | + if (!r.ok) { |
| 143 | + expect(r.error.kind, source).toBe('runtime'); |
| 144 | + expect(r.error.message, source).toContain(overload); |
| 145 | + } |
| 146 | + } |
| 147 | + // The prescribed repair for ISO text: parse it first. |
| 148 | + expect(celEngine.evaluate({ dialect: 'cel', source: 'isoDate(date(record.d))' }, { record })) |
| 149 | + .toEqual({ ok: true, value: '2026-10-08' }); |
| 150 | + }); |
| 151 | + |
| 152 | + it('at run, for an invalid or out-of-range timestamp', () => { |
| 153 | + for (const [source, fn] of [ |
| 154 | + ["isoDate(date('not a date'))", 'isoDate(t)'], |
| 155 | + ["isoDatetime(addDays(timestamp('9999-12-31T00:00:00Z'), 1))", 'isoDatetime(t)'], |
| 156 | + ["isoDate(addDays(timestamp('0001-01-01T00:00:00Z'), -1))", 'isoDate(t)'], |
| 157 | + ] as const) { |
| 158 | + const r = celEngine.evaluate({ dialect: 'cel', source }, flowScope(TEMPLATE_BYTES[0].at)); |
| 159 | + expect(r.ok, source).toBe(false); |
| 160 | + if (!r.ok) { |
| 161 | + expect(r.error.kind, source).toBe('runtime'); |
| 162 | + expect(r.error.message, source).toContain(`${fn}: \`t\` is not a renderable timestamp`); |
| 163 | + } |
| 164 | + } |
| 165 | + }); |
| 166 | +}); |
| 167 | + |
| 168 | +describe('no write path changes', () => { |
| 169 | + // Measured through a real AutomationEngine create_record over a real ObjectQL |
| 170 | + // engine and a recording driver: each of these reaches the store as a Date, |
| 171 | + // in a text, date or datetime column alike. Adding a spelling must not move |
| 172 | + // that, so the envelope's value is pinned as the same Date at the same instant. |
| 173 | + it('an envelope returning a timestamp still yields a Date', () => { |
| 174 | + const at = '2026-01-31T23:59:59.999Z'; |
| 175 | + for (const [source, iso] of [ |
| 176 | + ['today()', '2026-01-31T00:00:00.000Z'], |
| 177 | + ['now()', '2026-01-31T23:59:59.999Z'], |
| 178 | + ['daysFromNow(3)', '2026-02-03T00:00:00.000Z'], |
| 179 | + ['addDays(today(), 3)', '2026-02-03T00:00:00.000Z'], |
| 180 | + ] as const) { |
| 181 | + const v = value(source, at); |
| 182 | + expect(v, source).toBeInstanceOf(Date); |
| 183 | + expect((v as Date).toISOString(), source).toBe(iso); |
| 184 | + } |
| 185 | + }); |
| 186 | +}); |
0 commit comments