|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// [#20599] Wherever core builds a UTC instant from year / month / day / time |
| 4 | +// parts, a year from 0001 to 0099 is read as itself. `Date.UTC(year, …)` and |
| 5 | +// `new Date(year, …)` read a year from 0 to 99 as 1900 + year (ECMA-262 |
| 6 | +// `MakeFullYear`); `wallClockToUtcMs` does not, and every site in this file |
| 7 | +// builds through it. |
| 8 | +// |
| 9 | +// Pins: 0001, 0050 and 0099 (the remapped years), 0100 (the first year |
| 10 | +// `Date.UTC` reads as written) and a 2026 control, in UTC and in Asia/Shanghai |
| 11 | +// wherever the site takes a zone. Every expected instant is spelled as an ISO |
| 12 | +// string, never computed by the code under test. Before 1901 the tz database |
| 13 | +// gives Asia/Shanghai its local mean time, +08:05:43, and America/New_York |
| 14 | +// −04:56:02: that is the offset a wall clock in those years is read at. |
| 15 | + |
| 16 | +import { describe, it, expect, beforeEach, afterAll } from 'vitest'; |
| 17 | +import { |
| 18 | + wallClockToUtcMs, |
| 19 | + zonedWallClockToUtcMs, |
| 20 | + zonedDateStartToUtcMs, |
| 21 | + bucketDateKey, |
| 22 | + bucketKeyToCalendarRange, |
| 23 | +} from './datetime.js'; |
| 24 | + |
| 25 | +const isoOf = (ms: number) => new Date(ms).toISOString(); |
| 26 | + |
| 27 | +const YEARS = ['0001', '0050', '0099', '0100', '2026'] as const; |
| 28 | + |
| 29 | +// Every pin runs on a UTC host and on an Asia/Shanghai host: the sites read |
| 30 | +// UTC components only, and the answer must not move with the process zone. |
| 31 | +const HOSTS = ['UTC', 'Asia/Shanghai'] as const; |
| 32 | +const originalTz = process.env.TZ; |
| 33 | +afterAll(() => { |
| 34 | + if (originalTz === undefined) delete process.env.TZ; |
| 35 | + else process.env.TZ = originalTz; |
| 36 | +}); |
| 37 | + |
| 38 | +describe.each(HOSTS)('on a %s host', (host) => { |
| 39 | + beforeEach(() => { |
| 40 | + process.env.TZ = host; |
| 41 | + expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe(host); |
| 42 | + }); |
| 43 | + |
| 44 | + describe('[#20599] wallClockToUtcMs builds a UTC instant with the year as written', () => { |
| 45 | + it.each(YEARS)('%s-01-01 is midnight UTC of that year', (y) => { |
| 46 | + expect(isoOf(wallClockToUtcMs({ year: Number(y), month: 1, day: 1 }))).toBe(`${y}-01-01T00:00:00.000Z`); |
| 47 | + }); |
| 48 | + |
| 49 | + it.each(YEARS)('%s-06-15 10:20:30.456 keeps every time component', (y) => { |
| 50 | + const ms = wallClockToUtcMs({ year: Number(y), month: 6, day: 15, hour: 10, minute: 20, second: 30, millisecond: 456 }); |
| 51 | + expect(isoOf(ms)).toBe(`${y}-06-15T10:20:30.456Z`); |
| 52 | + }); |
| 53 | + |
| 54 | + it('answers exactly what Date.UTC answers from year 100 on', () => { |
| 55 | + for (const [year, month, day, hour, minute, second, ms] of [ |
| 56 | + [100, 1, 1, 0, 0, 0, 0], |
| 57 | + [999, 12, 31, 23, 59, 59, 999], |
| 58 | + [2026, 7, 15, 10, 0, 0, 0], |
| 59 | + [9999, 12, 31, 23, 59, 59, 999], |
| 60 | + ]) { |
| 61 | + expect(wallClockToUtcMs({ year, month, day, hour, minute, second, millisecond: ms })).toBe( |
| 62 | + Date.UTC(year, month - 1, day, hour, minute, second, ms), |
| 63 | + ); |
| 64 | + } |
| 65 | + }); |
| 66 | + |
| 67 | + // H5: the rollover is load-bearing — `bucketKeyToCalendarRange` ends Q4 and |
| 68 | + // December at month 13, and `filter-tokens` counts a month's days as day 0 of |
| 69 | + // the next. The helper rolls every component over as `Date.UTC` does. |
| 70 | + describe('rolls every component over past its end, as Date.UTC does', () => { |
| 71 | + it.each([ |
| 72 | + ['month 13 is January of the next year', { year: 50, month: 13, day: 1 }, '0051-01-01T00:00:00.000Z'], |
| 73 | + ['month 0 is December of the previous year', { year: 50, month: 0, day: 1 }, '0049-12-01T00:00:00.000Z'], |
| 74 | + ['day 0 is the last day of the previous month', { year: 50, month: 3, day: 0 }, '0050-02-28T00:00:00.000Z'], |
| 75 | + ['day 0 of March is February 29 in a leap year', { year: 48, month: 3, day: 0 }, '0048-02-29T00:00:00.000Z'], |
| 76 | + ['day 32 of December runs into the next year', { year: 50, month: 12, day: 32 }, '0051-01-01T00:00:00.000Z'], |
| 77 | + ['February 29 of a common year is March 1', { year: 50, month: 2, day: 29 }, '0050-03-01T00:00:00.000Z'], |
| 78 | + ['hour 24 is the next midnight', { year: 99, month: 12, day: 31, hour: 24 }, '0100-01-01T00:00:00.000Z'], |
| 79 | + ['hour −1 is the previous evening', { year: 50, month: 1, day: 1, hour: -1 }, '0049-12-31T23:00:00.000Z'], |
| 80 | + ['day 0 of January of year 1 is the last day of year 0', { year: 1, month: 1, day: 0 }, '0000-12-31T00:00:00.000Z'], |
| 81 | + ])('%s', (_label, parts, expected) => { |
| 82 | + expect(isoOf(wallClockToUtcMs(parts))).toBe(expected); |
| 83 | + }); |
| 84 | + |
| 85 | + it('rolls the 2026 control exactly as Date.UTC does', () => { |
| 86 | + expect(wallClockToUtcMs({ year: 2026, month: 13, day: 1 })).toBe(Date.UTC(2026, 12, 1)); |
| 87 | + expect(wallClockToUtcMs({ year: 2026, month: 3, day: 0 })).toBe(Date.UTC(2026, 2, 0)); |
| 88 | + expect(wallClockToUtcMs({ year: 2026, month: 12, day: 32 })).toBe(Date.UTC(2026, 11, 32)); |
| 89 | + expect(wallClockToUtcMs({ year: 2026, month: 1, day: 1, hour: 24 })).toBe(Date.UTC(2026, 0, 1, 24)); |
| 90 | + }); |
| 91 | + }); |
| 92 | + |
| 93 | + it('answers NaN for a NaN or non-finite component, as Date.UTC does', () => { |
| 94 | + expect(wallClockToUtcMs({ year: Number.NaN, month: 1, day: 1 })).toBeNaN(); |
| 95 | + expect(wallClockToUtcMs({ year: 2026, month: Number.NaN, day: 1 })).toBeNaN(); |
| 96 | + expect(wallClockToUtcMs({ year: 2026, month: 1, day: Number.NaN })).toBeNaN(); |
| 97 | + expect(wallClockToUtcMs({ year: 2026, month: 1, day: 1, hour: Number.NaN })).toBeNaN(); |
| 98 | + expect(wallClockToUtcMs({ year: Number.POSITIVE_INFINITY, month: 1, day: 1 })).toBeNaN(); |
| 99 | + }); |
| 100 | + }); |
| 101 | + |
| 102 | + describe('[#20599] zonedWallClockToUtcMs reads a year below 100 as written', () => { |
| 103 | + it.each(YEARS)('%s-01-01 10:00 with no zone, UTC or an unknown zone is 10:00 UTC that day', (y) => { |
| 104 | + for (const tz of [undefined, 'UTC', 'Not/AZone']) { |
| 105 | + expect(isoOf(zonedWallClockToUtcMs({ year: Number(y), month: 1, day: 1, hour: 10 }, tz))).toBe( |
| 106 | + `${y}-01-01T10:00:00.000Z`, |
| 107 | + ); |
| 108 | + } |
| 109 | + }); |
| 110 | + |
| 111 | + it.each([ |
| 112 | + ['0001', '0001-01-01T01:54:17.000Z'], |
| 113 | + ['0050', '0050-01-01T01:54:17.000Z'], |
| 114 | + ['0099', '0099-01-01T01:54:17.000Z'], |
| 115 | + ['0100', '0100-01-01T01:54:17.000Z'], |
| 116 | + ['2026', '2026-01-01T02:00:00.000Z'], |
| 117 | + ])('%s-01-01 10:00 in Asia/Shanghai is read at that year\'s offset', (y, expected) => { |
| 118 | + expect(isoOf(zonedWallClockToUtcMs({ year: Number(y), month: 1, day: 1, hour: 10 }, 'Asia/Shanghai'))).toBe(expected); |
| 119 | + }); |
| 120 | + |
| 121 | + it('keeps the last second of 0099 in 0099 in Asia/Shanghai', () => { |
| 122 | + const ms = zonedWallClockToUtcMs({ year: 99, month: 12, day: 31, hour: 23, minute: 59, second: 59 }, 'Asia/Shanghai'); |
| 123 | + expect(isoOf(ms)).toBe('0099-12-31T15:54:16.000Z'); |
| 124 | + }); |
| 125 | + |
| 126 | + // The offset read probes the zone at the wall clock taken as UTC. Early on |
| 127 | + // 0001-01-01 in a zone west of UTC that probe is still in year 0, whose |
| 128 | + // `Intl` year part is the era year `1` (1 BC): read without its era, the |
| 129 | + // probe would be a year off and so would the answer. |
| 130 | + it('reads 0001-01-01 in America/New_York, where the offset probe lands in year 0', () => { |
| 131 | + expect(isoOf(zonedWallClockToUtcMs({ year: 1, month: 1, day: 1 }, 'America/New_York'))).toBe( |
| 132 | + '0001-01-01T04:56:02.000Z', |
| 133 | + ); |
| 134 | + expect(isoOf(zonedWallClockToUtcMs({ year: 1, month: 1, day: 1, hour: 3 }, 'America/New_York'))).toBe( |
| 135 | + '0001-01-01T07:56:02.000Z', |
| 136 | + ); |
| 137 | + expect(isoOf(zonedWallClockToUtcMs({ year: 2026, month: 1, day: 1 }, 'America/New_York'))).toBe( |
| 138 | + '2026-01-01T05:00:00.000Z', |
| 139 | + ); |
| 140 | + }); |
| 141 | + }); |
| 142 | + |
| 143 | + describe('[#20599] zonedDateStartToUtcMs', () => { |
| 144 | + it.each(YEARS)('%s-01-01 begins at midnight UTC with no zone and in UTC', (y) => { |
| 145 | + expect(isoOf(zonedDateStartToUtcMs(`${y}-01-01`))).toBe(`${y}-01-01T00:00:00.000Z`); |
| 146 | + expect(isoOf(zonedDateStartToUtcMs(`${y}-01-01`, 'UTC'))).toBe(`${y}-01-01T00:00:00.000Z`); |
| 147 | + }); |
| 148 | + |
| 149 | + it.each([ |
| 150 | + ['0001', '0000-12-31T15:54:17.000Z'], |
| 151 | + ['0050', '0049-12-31T15:54:17.000Z'], |
| 152 | + ['0099', '0098-12-31T15:54:17.000Z'], |
| 153 | + ['0100', '0099-12-31T15:54:17.000Z'], |
| 154 | + ['2026', '2025-12-31T16:00:00.000Z'], |
| 155 | + ])('%s-01-01 begins at Asia/Shanghai midnight', (y, expected) => { |
| 156 | + expect(isoOf(zonedDateStartToUtcMs(`${y}-01-01`, 'Asia/Shanghai'))).toBe(expected); |
| 157 | + }); |
| 158 | + }); |
| 159 | + |
| 160 | + describe('[#20599] bucketDateKey(week) puts a day in 0001..0099 in its own ISO week', () => { |
| 161 | + // A key's year below 1000 is spelled unpadded (`49-W52`): the unpadded-key |
| 162 | + // family, which this card leaves alone. So the key is read as numbers here, |
| 163 | + // whatever its padding, and asserted as the ISO week of the day. |
| 164 | + const weekOf = (key: string | null) => { |
| 165 | + const m = /^(\d+)-W(\d{2})$/.exec(String(key)); |
| 166 | + return m ? { year: Number(m[1]), week: Number(m[2]) } : key; |
| 167 | + }; |
| 168 | + |
| 169 | + it.each([ |
| 170 | + ['0001-01-01T10:00:00.000Z', { year: 1, week: 1 }], |
| 171 | + ['0050-01-01T10:00:00.000Z', { year: 49, week: 52 }], |
| 172 | + ['0050-06-15T10:00:00.000Z', { year: 50, week: 24 }], |
| 173 | + ['0099-12-31T10:00:00.000Z', { year: 99, week: 53 }], |
| 174 | + ['0100-01-04T10:00:00.000Z', { year: 100, week: 1 }], |
| 175 | + ['2026-06-15T10:00:00.000Z', { year: 2026, week: 25 }], |
| 176 | + ])('%s in UTC', (instant, expected) => { |
| 177 | + expect(weekOf(bucketDateKey(instant, 'week'))).toEqual(expected); |
| 178 | + expect(weekOf(bucketDateKey(instant, 'week', 'UTC'))).toEqual(expected); |
| 179 | + }); |
| 180 | + |
| 181 | + it.each([ |
| 182 | + // Sunday 0050-01-02 in UTC is Monday 0050-01-03 in Shanghai: week 1 of 0050. |
| 183 | + ['0050-01-02T20:00:00.000Z', { year: 49, week: 52 }, { year: 50, week: 1 }], |
| 184 | + ['0001-01-07T20:00:00.000Z', { year: 1, week: 1 }, { year: 1, week: 2 }], |
| 185 | + ['0099-12-31T20:00:00.000Z', { year: 99, week: 53 }, { year: 99, week: 53 }], |
| 186 | + ['2026-06-14T20:00:00.000Z', { year: 2026, week: 24 }, { year: 2026, week: 25 }], |
| 187 | + ])('%s in UTC and in Asia/Shanghai', (instant, utc, shanghai) => { |
| 188 | + expect(weekOf(bucketDateKey(instant, 'week'))).toEqual(utc); |
| 189 | + expect(weekOf(bucketDateKey(instant, 'week', 'Asia/Shanghai'))).toEqual(shanghai); |
| 190 | + }); |
| 191 | + }); |
| 192 | + |
| 193 | + describe('[#20599] bucketKeyToCalendarRange spans a key in 0001..0099 in its own year', () => { |
| 194 | + it.each([ |
| 195 | + ['0001', '0001-01-01', '0002-01-01'], |
| 196 | + ['0050', '0050-01-01', '0051-01-01'], |
| 197 | + ['0099', '0099-01-01', '0100-01-01'], |
| 198 | + ['0100', '0100-01-01', '0101-01-01'], |
| 199 | + ['2026', '2026-01-01', '2027-01-01'], |
| 200 | + ])('year %s', (key, start, end) => { |
| 201 | + expect(bucketKeyToCalendarRange(key, 'year')).toEqual({ start, end }); |
| 202 | + }); |
| 203 | + |
| 204 | + it.each([ |
| 205 | + ['0001-Q2', '0001-04-01', '0001-07-01'], |
| 206 | + ['0050-Q1', '0050-01-01', '0050-04-01'], |
| 207 | + ['0050-Q4', '0050-10-01', '0051-01-01'], |
| 208 | + ['0099-Q4', '0099-10-01', '0100-01-01'], |
| 209 | + ['0100-Q4', '0100-10-01', '0101-01-01'], |
| 210 | + ['2026-Q4', '2026-10-01', '2027-01-01'], |
| 211 | + ])('quarter %s', (key, start, end) => { |
| 212 | + expect(bucketKeyToCalendarRange(key, 'quarter')).toEqual({ start, end }); |
| 213 | + }); |
| 214 | + |
| 215 | + it.each([ |
| 216 | + ['0001-02', '0001-02-01', '0001-03-01'], |
| 217 | + ['0050-01', '0050-01-01', '0050-02-01'], |
| 218 | + ['0050-12', '0050-12-01', '0051-01-01'], |
| 219 | + ['0099-12', '0099-12-01', '0100-01-01'], |
| 220 | + ['0100-12', '0100-12-01', '0101-01-01'], |
| 221 | + ['2026-12', '2026-12-01', '2027-01-01'], |
| 222 | + ])('month %s', (key, start, end) => { |
| 223 | + expect(bucketKeyToCalendarRange(key, 'month')).toEqual({ start, end }); |
| 224 | + }); |
| 225 | + |
| 226 | + it.each([ |
| 227 | + ['0001-01-01', '0001-01-02'], |
| 228 | + ['0048-02-29', '0048-03-01'], |
| 229 | + ['0050-01-01', '0050-01-02'], |
| 230 | + ['0050-12-31', '0051-01-01'], |
| 231 | + ['0099-12-31', '0100-01-01'], |
| 232 | + ['0100-01-01', '0100-01-02'], |
| 233 | + ['2026-02-28', '2026-03-01'], |
| 234 | + ])('day %s', (key, end) => { |
| 235 | + expect(bucketKeyToCalendarRange(key, 'day')).toEqual({ start: key, end }); |
| 236 | + }); |
| 237 | + |
| 238 | + it('still refuses an impossible day in 0001..0099 rather than rolling it over', () => { |
| 239 | + expect(bucketKeyToCalendarRange('0050-02-29', 'day')).toBeNull(); // 0050 is not a leap year |
| 240 | + expect(bucketKeyToCalendarRange('0099-04-31', 'day')).toBeNull(); |
| 241 | + expect(bucketKeyToCalendarRange('0100-02-29', 'day')).toBeNull(); // 0100 is not a leap year |
| 242 | + expect(bucketKeyToCalendarRange('2026-02-29', 'day')).toBeNull(); |
| 243 | + }); |
| 244 | + |
| 245 | + // The week arm validates a reconstructed Monday against the week LABEL, |
| 246 | + // which is spelled unpadded below the year 1000 (the unpadded-key family, |
| 247 | + // left alone here), so it is pinned on the 2026 control only. |
| 248 | + it('week 2026-W01 (the control)', () => { |
| 249 | + expect(bucketKeyToCalendarRange('2026-W01', 'week')).toEqual({ start: '2025-12-29', end: '2026-01-05' }); |
| 250 | + }); |
| 251 | + }); |
| 252 | +}); |
0 commit comments