1717 * Two layers:
1818 *
1919 * - **The formatter** (`formatCellValue`, the one path CSV, xlsx and JSON
20- * share): a census over the years 0001, 0050, 0500, 0999, 1000, 2026 and
21- * 9999, `date` and `datetime`, with and without a business timezone. The
22- * 1000, 2026 and 9999 cells are the pre-#20602 output, byte for byte.
20+ * share): a census over the years 0001, 0050, 0099, 0500, 0999, 1000, 2026
21+ * and 9999, `date` and `datetime`, with and without a business timezone.
22+ * The 1000, 2026 and 9999 cells are the pre-#20602 output, byte for byte.
2323 * - **The routes**: rows written through the create door, exported as CSV,
2424 * xlsx and JSON, and re-imported into a fresh stack through the import door,
2525 * store the same `date` and `datetime` values, under no business timezone,
2626 * Asia/Shanghai and America/New_York.
2727 *
28- * Not pinned here: the `datetime` round trip for years 0001..0099. The export
29- * spells those padded (the census below), and the import reads them back a
30- * century late, through `Date.UTC`'s two-digit-year remap in core's
31- * `zonedWallClockToUtcMs`. That is #20599's, a different defect.
28+ * A `datetime` names a year from 1000 (#20280): the create door refuses an
29+ * earlier one, so the route rows before 1000 carry a `date` only. The one
30+ * `datetime` cell the padding still reaches at the routes is an instant from
31+ * 1000 on whose business-timezone day is in 0999: the boundary row, at
32+ * `1000-01-01T02:00:00.000Z`, which America/New_York reads on 0999-12-31.
3233 */
3334
3435import { describe , it , expect , beforeAll , afterAll } from 'vitest' ;
@@ -43,7 +44,7 @@ import { loadXlsxWorkbook } from './xlsx-test-loader.js';
4344const DATE : ExportFieldMeta = { name : 'd' , type : 'date' } ;
4445const DATETIME : ExportFieldMeta = { name : 'dt' , type : 'datetime' } ;
4546
46- const YEARS = [ '0001' , '0050' , '0500' , '0999' , '1000' , '2026' , '9999' ] as const ;
47+ const YEARS = [ '0001' , '0050' , '0099' , ' 0500', '0999' , '1000' , '2026' , '9999' ] as const ;
4748
4849const ZONES = [ undefined , 'UTC' , 'Asia/Shanghai' , 'America/New_York' , 'Not/AZone' ] as const ;
4950
@@ -94,7 +95,7 @@ describe('[#20602] formatCellValue spells a four-digit year for every date and d
9495 // Year 1 in UTC, still year 0 in New York: year 0 has no four-digit form,
9596 // so it is spelled unpadded as the storage rule spells it and the import
9697 // refuses it. Padding `Intl`'s era year would spell the last day of year 1,
97- // a date a year later that the import would take .
98+ // a day a year later than the instant's .
9899 const cell = String ( formatCellValue ( '0001-01-01T03:00:00.000Z' , DATETIME , 'America/New_York' ) ) ;
99100 expect ( cell . startsWith ( '0-12-31 ' ) ) . toBe ( true ) ;
100101 expect ( cell . startsWith ( '0001-' ) ) . toBe ( false ) ;
@@ -118,17 +119,32 @@ const LEDGER = {
118119
119120const MAPPING = { ID : 'id' , Day : 'd' , At : 'dt' } ;
120121
122+ /** [#20280] The first year the create door takes for a `datetime`. */
123+ const DATETIME_FIRST_YEAR = 1000 ;
124+
125+ type Row = { year : string ; id : string ; d : string ; dt ?: string ; dtDay : ( zone : string | undefined ) => string } ;
126+
121127/**
122- * One row per year: the day `Y-01-01` and the instant at 10:00 UTC on it. The
123- * rows for 0001 and 0050 carry no `datetime`: its import is #20599's (the
124- * module note) .
128+ * One row per year: the day `Y-01-01` and, from 1000 on, the instant at 10:00
129+ * UTC on it (the module note). Then the boundary row: the instant 02:00 UTC on
130+ * 1000-01-01, which America/New_York reads on 0999-12-31 .
125131 */
126- const ROWS = YEARS . map ( ( year ) => ( {
127- year,
128- id : `y${ year } ` ,
129- d : `${ year } -01-01` ,
130- dt : year === '0001' || year === '0050' ? undefined : `${ year } -01-01T10:00:00.000Z` ,
131- } ) ) ;
132+ const ROWS : Row [ ] = [
133+ ...YEARS . map ( ( year ) => ( {
134+ year,
135+ id : `y${ year } ` ,
136+ d : `${ year } -01-01` ,
137+ dt : Number ( year ) >= DATETIME_FIRST_YEAR ? `${ year } -01-01T10:00:00.000Z` : undefined ,
138+ dtDay : ( ) => `${ year } -01-01` ,
139+ } ) ) ,
140+ {
141+ year : '0999' ,
142+ id : 'y0999-boundary' ,
143+ d : '0999-12-31' ,
144+ dt : '1000-01-01T02:00:00.000Z' ,
145+ dtDay : ( zone ) => ( zone === 'America/New_York' ? '0999-12-31' : '1000-01-01' ) ,
146+ } ,
147+ ] ;
132148
133149function createMockServer ( ) {
134150 const noop = ( ) => { } ;
@@ -257,11 +273,11 @@ describe.each(BUSINESS_ZONES)('[#20602] GET /export then POST /import, business
257273 }
258274 } ) ;
259275
260- it . each ( ROWS ) ( 'exports the $year row with a four-digit year and re-imports it unchanged' , async ( row ) => {
276+ it . each ( ROWS ) ( 'exports the $id row with a four-digit year and re-imports it unchanged' , async ( row ) => {
261277 const cell = cells . get ( row . id ) ! ;
262278 expect ( cell . d ) . toBe ( row . d ) ;
263279 if ( row . dt ) {
264- expect ( cell . dt . slice ( 0 , 11 ) ) . toBe ( `${ row . year } -01-01 ` ) ;
280+ expect ( cell . dt . slice ( 0 , 11 ) ) . toBe ( `${ row . dtDay ( zone ) } ` ) ;
265281 if ( row . year === '2026' ) expect ( cell . dt ) . toBe ( CONTROL_DATETIME_CELL [ zone ?? 'none' ] ) ;
266282 } else {
267283 expect ( cell . dt ) . toBe ( '' ) ;
0 commit comments