Skip to content

Commit 5c0de05

Browse files
os-zhuangclaude
andauthored
test(formula): lock ADR-0053 Phase 2 Slice 3 acceptance criteria (#3181)
Add acceptance tests for the timezone-aware today()/daysFromNow()/daysAgo() functions (compute-tz core of ADR-0053 Phase 2, decision D1). The implementation already shipped (#1998/#2001/#2006); these lock the issue's criteria and pin the DST-boundary + equality behavior: - AC1: today() at 2026-06-16T02:00Z in America/Los_Angeles == UTC-midnight of 2026-06-15. - AC3: reference tz unset vs 'UTC' is byte-for-byte the pre-Phase-2 behavior for all three functions. - AC2: calendar days are correct across both 2026 US DST transitions (spring-forward Mar 8, fall-back Nov 1); a Field.datetime instant compares equal to daysFromNow(n) across DST; a Field.date string matches via the hydration-safe idioms (ordering operators, date(), daysBetween()). - A characterization guard documents the known cel-js equality limitation: a bare `date-string == today()` silently returns false because cel-js's isEqual hard-codes `string == X` to false. This is timezone-independent and cross-cutting; the fix belongs in the data layer (hydrate date fields to Date where field types are known) and is tracked as a separate follow-up. Test-only; no changeset (no functional change). Claude-Session: https://claude.ai/code/session_01SuiM565BZ3TR1VD3prMguB Co-authored-by: Claude <noreply@anthropic.com>
1 parent 41e703b commit 5c0de05

1 file changed

Lines changed: 98 additions & 0 deletions

File tree

packages/formula/src/cel-engine.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,104 @@ describe('celEngine', () => {
8888
expect(utc.ok && (utc.value as Date).toISOString()).toBe('2026-01-15T00:00:00.000Z');
8989
});
9090

91+
// ADR-0053 Phase 2 · Slice 3 acceptance criteria (#1980). The three
92+
// calendar-day functions resolve the reference-tz calendar day expressed as a
93+
// UTC-midnight Date (decision D1), DST-safe via `Intl.formatToParts` — never
94+
// hand-rolled offset math. These lock the issue's criteria verbatim plus the
95+
// DST-boundary and equality behavior the ADR promises.
96+
describe('ADR-0053 Phase 2 · Slice 3 acceptance criteria (#1980)', () => {
97+
const isoOf = (r: ReturnType<typeof celEngine.evaluate>): string | false =>
98+
r.ok ? (r.value as Date).toISOString() : false;
99+
100+
it('AC1: today() at 2026-06-16T02:00Z in America/Los_Angeles is the UTC-midnight of 2026-06-15', () => {
101+
// 02:00Z is 2026-06-15 19:00 PDT (UTC-7) — still June 15 in LA.
102+
const now = new Date('2026-06-16T02:00:00Z');
103+
const tz = 'America/Los_Angeles';
104+
expect(isoOf(celEngine.evaluate(cel('today()'), { now, timezone: tz })))
105+
.toBe('2026-06-15T00:00:00.000Z');
106+
expect(isoOf(celEngine.evaluate(cel('daysFromNow(1)'), { now, timezone: tz })))
107+
.toBe('2026-06-16T00:00:00.000Z');
108+
expect(isoOf(celEngine.evaluate(cel('daysAgo(1)'), { now, timezone: tz })))
109+
.toBe('2026-06-14T00:00:00.000Z');
110+
});
111+
112+
it('AC3: reference tz unset vs "UTC" is byte-for-byte the pre-Phase-2 behavior', () => {
113+
const now = new Date('2026-06-16T02:00:00Z');
114+
const unsetToday = celEngine.evaluate(cel('today()'), { now });
115+
expect(isoOf(unsetToday)).toBe('2026-06-16T00:00:00.000Z');
116+
// Explicit 'UTC' is identical to unset for all three functions.
117+
for (const src of ['today()', 'daysFromNow(5)', 'daysAgo(5)']) {
118+
expect(celEngine.evaluate(cel(src), { now, timezone: 'UTC' }))
119+
.toEqual(celEngine.evaluate(cel(src), { now }));
120+
}
121+
});
122+
123+
it('AC2: calendar days are correct across the spring-forward boundary (US DST 2026-03-08)', () => {
124+
// now = 2026-03-07T04:30Z = 2026-03-06 23:30 EST. daysFromNow(3) crosses
125+
// the Mar 8 spring-forward — a naive offset add would land on the wrong
126+
// instant; the Intl-based calendar math does not.
127+
const now = new Date('2026-03-07T04:30:00Z');
128+
const tz = 'America/New_York';
129+
expect(isoOf(celEngine.evaluate(cel('today()'), { now, timezone: tz })))
130+
.toBe('2026-03-06T00:00:00.000Z');
131+
expect(isoOf(celEngine.evaluate(cel('daysFromNow(3)'), { now, timezone: tz })))
132+
.toBe('2026-03-09T00:00:00.000Z');
133+
});
134+
135+
it('AC2: calendar days are correct across the fall-back boundary (US DST 2026-11-01)', () => {
136+
// now = 2026-11-02T04:30Z = 2026-11-01 23:30 EST, just after the fall-back.
137+
const now = new Date('2026-11-02T04:30:00Z');
138+
const tz = 'America/New_York';
139+
expect(isoOf(celEngine.evaluate(cel('today()'), { now, timezone: tz })))
140+
.toBe('2026-11-01T00:00:00.000Z');
141+
expect(isoOf(celEngine.evaluate(cel('daysAgo(2)'), { now, timezone: tz })))
142+
.toBe('2026-10-30T00:00:00.000Z');
143+
});
144+
145+
it('AC2: a datetime record field == daysFromNow(n) matches the right day across DST', () => {
146+
// A `Field.datetime` arrives as a Date instant, so equality is
147+
// Timestamp==Timestamp — the representation D1 makes today()/daysFromNow()
148+
// produce (UTC-midnight). This is the case D1's rationale targets.
149+
const now = new Date('2026-03-07T04:30:00Z'); // Mar 6 in NY
150+
const tz = 'America/New_York';
151+
const r = celEngine.evaluate(cel('record.due_at == daysFromNow(3)'), {
152+
now, timezone: tz, record: { due_at: new Date('2026-03-09T00:00:00Z') },
153+
});
154+
expect(r).toEqual({ ok: true, value: true });
155+
});
156+
157+
it('AC2: a date-string field matches today()/daysAgo() via the hydration-safe idioms', () => {
158+
// ADR-0053 Phase 1 (#1968) reads a `Field.date` back as a "YYYY-MM-DD"
159+
// string. The ordering operators fault → hydrate (the string becomes a
160+
// Date) and compare cleanly; `date(...)` and `daysBetween(...)` coerce
161+
// explicitly. All resolve on the reference-tz calendar day.
162+
const now = new Date('2026-11-02T04:30:00Z'); // Nov 1 in NY
163+
const ctx = { now, timezone: 'America/New_York', record: { due_date: '2026-11-01' } };
164+
expect(celEngine.evaluate(cel('record.due_date >= today() && record.due_date <= today()'), ctx))
165+
.toEqual({ ok: true, value: true });
166+
expect(celEngine.evaluate(cel('date(record.due_date) == today()'), ctx))
167+
.toEqual({ ok: true, value: true });
168+
expect(celEngine.evaluate(cel('daysBetween(today(), record.due_date) == 0'), ctx))
169+
.toEqual({ ok: true, value: true });
170+
});
171+
172+
it('KNOWN GAP: bare `date-string == today()` silently returns false (cel-js equality)', () => {
173+
// Characterization guard, NOT an endorsement. cel-js's `isEqual`
174+
// (overloads.js) hard-codes `string == X` to false and never consults a
175+
// registered overload, so a bare `Field.date` string compared with `==`
176+
// silently misses — independent of timezone (fails identically at UTC).
177+
// The fix must hydrate date fields to Date in the data layer (where field
178+
// types are known); tracked as a separate follow-up. Authors should use
179+
// the idioms in the test above until then. If this starts returning true,
180+
// the follow-up landed — update/remove this guard.
181+
const now = new Date('2026-11-02T04:30:00Z');
182+
const r = celEngine.evaluate(cel('record.due_date == today()'), {
183+
now, timezone: 'America/New_York', record: { due_date: '2026-11-01' },
184+
});
185+
expect(r).toEqual({ ok: true, value: false });
186+
});
187+
});
188+
91189
it('classifies parse errors with kind=parse', () => {
92190
const r = celEngine.evaluate(cel('1 +'), {});
93191
expect(r.ok).toBe(false);

0 commit comments

Comments
 (0)