calc: NOW and TODAY, the volatile functions the corpus exclusion exists for
Registers NOW/TODAY as clockFuncs (env.Now(), not time.Now()) returning a spreadsheet date serial (days since the Excel/LibreOffice epoch, 1899-12-30) -- a plain number, per the "currency is a format, not a type" rule extended to dates. TODAY truncates to midnight. These are deliberately the first functions implemented: the next commit stands up the golden-corpus harness, and its clock-pinning test (TestClockIsPinnedWithinAPass) is only meaningful once a real NOW exists to evaluate -- otherwise both calls would resolve to #NAME? and the test would pass vacuously.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APtNStXDZ5H2fAc1ZuGqtS
2 files changed,
+105
−0
internal/sheets/calc/fn_date.go+51 −0internal/sheets/calc/fn_date_test.go+54 −0
Intent
diff --git a/internal/sheets/calc/fn_date.go b/internal/sheets/calc/fn_date.go| new file mode 100644 |
| index 0000000..964e693 |
| --- /dev/null |
| +++ b/internal/sheets/calc/fn_date.go |
| @@ -0,0 +1,51 @@ |
| +package calc |
| + |
| +import "time" |
| + |
| +func init() { |
| + registerClock("NOW", fnNow) |
| + registerClock("TODAY", fnToday) |
| +} |
| + |
| +// excelEpoch is day zero in spreadsheet date-serial numbering — Dec 30, |
| +// 1899, the convention both Excel and LibreOffice Calc use by default. |
| +// A date is not a distinct Value kind in this engine: it is a plain |
| +// number that a date number format later displays as a calendar date — |
| +// the same "currency is a format, not a type" rule (global constraint) |
| +// extended to dates. Cell values are IEEE doubles end to end, so this |
| +// applies here exactly as everywhere else: no rounding at this boundary. |
| +var excelEpoch = time.Date(1899, time.December, 30, 0, 0, 0, 0, time.UTC) |
| + |
| +// dateSerial converts t to spreadsheet date-serial form: whole days since |
| +// excelEpoch, with the time of day as a fraction of one day. |
| +func dateSerial(t time.Time) float64 { |
| + return t.UTC().Sub(excelEpoch).Hours() / 24 |
| +} |
| + |
| +// fnNow returns the pass's pinned instant (env.Now()) as a date serial |
| +// with a fractional day for the time of day. It must call env.Now() and |
| +// nothing else — see corpus_test.go's TestClockIsPinnedWithinAPass, |
| +// written specifically to catch a version of this function that reached |
| +// past env for a live clock instead of the one the pass pinned. |
| +// |
| +// NOW is volatile — its answer differs on every recalculation pass by |
| +// design — so it is never checked against testdata/corpus/formulas.json |
| +// (see testdata/corpus/volatile-excluded.txt); nothing about that |
| +// exclusion weakens the guarantee this function itself must hold, that |
| +// two calls within ONE pass agree. |
| +func fnNow(env Env, args []Value) Value { |
| + if len(args) != 0 { |
| + return errValue(ErrValue) |
| + } |
| + return Value{Kind: KindNum, Num: dateSerial(env.Now())} |
| +} |
| + |
| +// fnToday is fnNow with the time of day truncated to midnight. |
| +func fnToday(env Env, args []Value) Value { |
| + if len(args) != 0 { |
| + return errValue(ErrValue) |
| + } |
| + t := env.Now().UTC() |
| + midnight := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) |
| + return Value{Kind: KindNum, Num: dateSerial(midnight)} |
| +} |
diff --git a/internal/sheets/calc/fn_date_test.go b/internal/sheets/calc/fn_date_test.go| new file mode 100644 |
| index 0000000..7cc7161 |
| --- /dev/null |
| +++ b/internal/sheets/calc/fn_date_test.go |
| @@ -0,0 +1,54 @@ |
| +package calc |
| + |
| +import ( |
| + "testing" |
| + "time" |
| +) |
| + |
| +// pinnedEnv answers every cell as blank and Now() as a fixed instant — |
| +// the minimal Env a NOW()/TODAY() unit test needs, independent of |
| +// corpus_test.go's clockEnv (which additionally models advancing a wall |
| +// clock that Now() ignores). |
| +type pinnedEnv time.Time |
| + |
| +func (p pinnedEnv) CellValue(CellKey) Value { return Value{} } |
| +func (p pinnedEnv) Now() time.Time { return time.Time(p) } |
| + |
| +func TestNowUsesEnvClock(t *testing.T) { |
| + env := pinnedEnv(time.Date(2026, 8, 30, 12, 0, 0, 0, time.UTC)) |
| + v := Eval(mustParse(t, "NOW()"), env) |
| + if v.Kind != KindNum { |
| + t.Fatalf("NOW() Kind = %v, want KindNum", v.Kind) |
| + } |
| + want := dateSerial(time.Time(env)) |
| + if v.Num != want { |
| + t.Fatalf("NOW() = %v, want %v", v.Num, want) |
| + } |
| +} |
| + |
| +// TODAY truncates the time of day to midnight, so it differs from NOW by |
| +// less than one full day and is itself a whole number of days. |
| +func TestTodayTruncatesToMidnight(t *testing.T) { |
| + env := pinnedEnv(time.Date(2026, 8, 30, 15, 30, 0, 0, time.UTC)) |
| + v := Eval(mustParse(t, "TODAY()"), env) |
| + if v.Kind != KindNum { |
| + t.Fatalf("TODAY() Kind = %v, want KindNum", v.Kind) |
| + } |
| + if v.Num != float64(int64(v.Num)) { |
| + t.Fatalf("TODAY() = %v, want a whole number of days", v.Num) |
| + } |
| + now := Eval(mustParse(t, "NOW()"), env) |
| + if now.Num-v.Num >= 1 || now.Num-v.Num < 0 { |
| + t.Fatalf("NOW()-TODAY() = %v, want in [0,1)", now.Num-v.Num) |
| + } |
| +} |
| + |
| +func TestNowTodayRejectArguments(t *testing.T) { |
| + env := pinnedEnv(time.Now()) |
| + for _, formula := range []string{"NOW(1)", "TODAY(1)"} { |
| + v := Eval(mustParse(t, formula), env) |
| + if v.Kind != KindErr || v.Err != ErrValue { |
| + t.Errorf("with an argument = %v, want #VALUE!", v) |
| + } |
| + } |
| +} |