import { describe, it, expect } from 'vitest'; import { dateToSerial, timeSerial, serialToUtcDate, todaySerial, parseDateTimeLiteral, } from '../dates'; describe('dateToSerial / serialToUtcDate', () => { it('epoch is 1899-12-30: serial 1 = 1899-12-31, serial 2 = 1900-01-01', () => { expect(dateToSerial(1899, 12, 31)).toBe(1); expect(dateToSerial(1900, 1, 1)).toBe(2); }); it('round-trips through serialToUtcDate', () => { const s = dateToSerial(2026, 9, 2); const d = serialToUtcDate(s); expect(d.getUTCFullYear()).toBe(2026); expect(d.getUTCMonth()).toBe(8); // 0-based expect(d.getUTCDate()).toBe(2); }); it('allows overflow, like Excel DATE() (month 13 -> next January)', () => { const s = dateToSerial(2026, 13, 1); const d = serialToUtcDate(s); expect(d.getUTCFullYear()).toBe(2027); expect(d.getUTCMonth()).toBe(0); expect(d.getUTCDate()).toBe(1); }); it('carries a fractional part for a time-of-day component', () => { const s = dateToSerial(2026, 9, 2, 12, 0, 0); expect(s % 1).toBeCloseTo(0.5, 10); }); }); describe('timeSerial', () => { it('noon = 0.5, midnight = 0', () => { expect(timeSerial(12, 0, 0)).toBeCloseTo(0.5, 10); expect(timeSerial(0, 0, 0)).toBe(0); }); it('wraps past 24h like Excel TIME()', () => { expect(timeSerial(25, 0, 0)).toBeCloseTo(1 / 24, 10); }); }); describe('todaySerial', () => { it('matches the local calendar date', () => { const n = new Date(); expect(todaySerial()).toBe(Math.trunc(dateToSerial(n.getFullYear(), n.getMonth() + 1, n.getDate()))); }); }); describe('parseDateTimeLiteral', () => { it('recognizes ISO dates', () => { const r = parseDateTimeLiteral('2026-09-02'); expect(r).toEqual({ kind: 'date', serial: dateToSerial(2026, 9, 2) }); }); it('recognizes ISO datetimes (space or T separator, optional seconds)', () => { expect(parseDateTimeLiteral('2026-09-02 14:30')).toEqual({ kind: 'datetime', serial: dateToSerial(2026, 9, 2, 14, 30, 0), }); expect(parseDateTimeLiteral('2026-09-02T14:30:15')).toEqual({ kind: 'datetime', serial: dateToSerial(2026, 9, 2, 14, 30, 15), }); }); it('recognizes dot dates, 4- and 2-digit years', () => { expect(parseDateTimeLiteral('02.09.2026')).toEqual({ kind: 'date', serial: dateToSerial(2026, 9, 2), }); expect(parseDateTimeLiteral('02.09.26')).toEqual({ kind: 'date', serial: dateToSerial(2026, 9, 2), }); expect(parseDateTimeLiteral('02.09.99')).toEqual({ kind: 'date', serial: dateToSerial(1999, 9, 2), }); }); it('recognizes dot datetimes', () => { expect(parseDateTimeLiteral('02.09.2026 14:30')).toEqual({ kind: 'datetime', serial: dateToSerial(2026, 9, 2, 14, 30, 0), }); }); it('recognizes plain times', () => { expect(parseDateTimeLiteral('14:30')).toEqual({ kind: 'time', serial: timeSerial(14, 30, 0) }); expect(parseDateTimeLiteral('14:30:15')).toEqual({ kind: 'time', serial: timeSerial(14, 30, 15) }); }); it('does NOT recognize slash-separated dates (day-first/month-first ambiguity)', () => { expect(parseDateTimeLiteral('02/09/2026')).toBeNull(); }); it('rejects an out-of-range or rolled-over date instead of silently guessing', () => { expect(parseDateTimeLiteral('31.04.2026')).toBeNull(); // April has 30 days expect(parseDateTimeLiteral('30.02.2026')).toBeNull(); // no Feb 30 expect(parseDateTimeLiteral('2026-13-01')).toBeNull(); // month 13 }); it('rejects an out-of-range minute/second', () => { expect(parseDateTimeLiteral('12:60')).toBeNull(); expect(parseDateTimeLiteral('37:30:60')).toBeNull(); }); it('reads 24 hours or more as a duration, not a time of day', () => { expect(parseDateTimeLiteral('25:00')).toEqual({ kind: 'duration', serial: 25 / 24 }); expect(parseDateTimeLiteral('37:30')).toEqual({ kind: 'duration', serial: 37.5 / 24 }); expect(parseDateTimeLiteral('100:00:30')!.serial).toBeCloseTo((100 * 3600 + 30) / 86400, 12); expect(parseDateTimeLiteral('23:59')!.kind).toBe('time'); }); it('does not misfire on plain numbers or arbitrary text', () => { expect(parseDateTimeLiteral('1234.5')).toBeNull(); expect(parseDateTimeLiteral('hello')).toBeNull(); expect(parseDateTimeLiteral('')).toBeNull(); }); });