66 * `coerceRow` driven by a fake reference resolver.
77 */
88
9- import { describe , it , expect } from 'vitest' ;
9+ import { describe , it , expect , afterEach } from 'vitest' ;
1010import {
1111 parseBooleanCell ,
1212 parseNumberCell ,
@@ -84,7 +84,6 @@ describe('parseNumberCell', () => {
8484describe ( 'parseDateCell' , ( ) => {
8585 it ( 'normalises bare calendar dates without timezone drift' , ( ) => {
8686 expect ( parseDateCell ( '2026-06-30' , 'date' ) ) . toBe ( '2026-06-30' ) ;
87- expect ( parseDateCell ( '2026/6/3' , 'date' ) ) . toBe ( '2026-06-03' ) ;
8887 } ) ;
8988 it ( 'emits full ISO for datetime' , ( ) => {
9089 expect ( parseDateCell ( '2026-06-30' , 'datetime' ) ) . toBe ( '2026-06-30T00:00:00.000Z' ) ;
@@ -99,6 +98,102 @@ describe('parseDateCell', () => {
9998 } ) ;
10099} ) ;
101100
101+ /**
102+ * [#20534] A text cell is read only in ISO 8601 or the export's own
103+ * `YYYY-MM-DD HH:mm:ss`, on a calendar day that exists; everything else is
104+ * refused (`undefined`, so the row's `invalid_date`), never rolled over, never
105+ * read in the host's zone and never read month-first. A `date`'s year keeps
106+ * four digits. Every case runs under two host zones that disagree by twelve
107+ * hours, and must answer the same under both — the host-zone reading this
108+ * removes answered differently (`07/15/2026 10:00` was `…T14:00Z` in New York
109+ * and `…T02:00Z` in Shanghai).
110+ */
111+ describe ( '[#20534] parseDateCell — ISO 8601 or the export shape, on a real day' , ( ) => {
112+ const HOST_ZONES = [ 'America/New_York' , 'Asia/Shanghai' ] ;
113+ const originalTz = process . env . TZ ;
114+ afterEach ( ( ) => {
115+ if ( originalTz === undefined ) delete process . env . TZ ;
116+ else process . env . TZ = originalTz ;
117+ } ) ;
118+
119+ /** Run `fn` under each host zone; the answers must agree. */
120+ function onBothHosts ( fn : ( ) => string | undefined ) : string | undefined {
121+ const answers = HOST_ZONES . map ( ( tz ) => {
122+ process . env . TZ = tz ;
123+ expect ( Intl . DateTimeFormat ( ) . resolvedOptions ( ) . timeZone ) . toBe ( tz ) ;
124+ return fn ( ) ;
125+ } ) ;
126+ expect ( answers [ 1 ] ) . toBe ( answers [ 0 ] ) ;
127+ return answers [ 0 ] ;
128+ }
129+
130+ type Kind = 'date' | 'datetime' | 'time' ;
131+
132+ const REFUSED : ReadonlyArray < readonly [ cell : unknown , kind : Kind ] > = [
133+ // An impossible day, in every shape and on every branch — was rolled over.
134+ [ '2026-02-30' , 'date' ] , [ '2026-02-30' , 'datetime' ] , [ '2026-02-30' , 'time' ] ,
135+ [ '2026-02-29' , 'datetime' ] , [ '2026-04-31' , 'datetime' ] ,
136+ [ '2026-02-30 10:00' , 'datetime' ] , [ '2026-02-30 10:00' , 'date' ] , [ '2026-02-30 10:00' , 'time' ] ,
137+ [ '2026-02-30T10:00:00Z' , 'datetime' ] , [ '2026-02-30T10:00:00Z' , 'date' ] , [ '2026-02-30T10:00:00Z' , 'time' ] ,
138+ // Locale and prose spellings — were read in the host zone, and month-first.
139+ [ '07/15/2026 10:00' , 'datetime' ] , [ '07/15/2026 10:00' , 'date' ] , [ '07/15/2026 10:00' , 'time' ] ,
140+ [ '07/08/2026' , 'datetime' ] , [ '07/08/2026' , 'date' ] ,
141+ [ '07/15/2026' , 'date' ] , [ '15 July 2026' , 'date' ] , [ '15 July 2026' , 'datetime' ] ,
142+ [ 'Jul 15 2026 10:00' , 'time' ] , [ 'Wed, 15 Jul 2026 10:00:00 GMT' , 'datetime' ] ,
143+ // Year-first but not ISO 8601.
144+ [ '2026/6/3' , 'date' ] , [ '2026/07/15' , 'date' ] , [ '2026-7-15' , 'date' ] ,
145+ [ '2026/08/01 06:00:00' , 'datetime' ] , [ '2026-07-15 9:00' , 'datetime' ] ,
146+ // Reduced / expanded forms, a zone after a space, lower-case `t` / `z`.
147+ [ '2026' , 'date' ] , [ '2026-07' , 'datetime' ] , [ '+002026-07-15' , 'date' ] ,
148+ [ '2026-07-15 10:00Z' , 'datetime' ] , [ '2026-07-15 10:00:00+08:00' , 'datetime' ] ,
149+ [ '2026-07-15t10:00:00z' , 'datetime' ] ,
150+ // A zone-naive 24:00 — was handed to `new Date(s)`, in the host zone.
151+ [ '2026-07-15 24:00' , 'datetime' ] , [ '2026-07-15T24:00:00' , 'date' ] ,
152+ // A number — `new Date(String(n))` read it as a year, in the host zone.
153+ [ 2026 , 'date' ] , [ 45000 , 'datetime' ] , [ 45000 , 'time' ] ,
154+ ] ;
155+
156+ it . each ( REFUSED ) ( 'refuses %j as a %s cell on every host' , ( cell , kind ) => {
157+ expect ( onBothHosts ( ( ) => parseDateCell ( cell , kind ) ) ) . toBeUndefined ( ) ;
158+ } ) ;
159+
160+ const ADMITTED : ReadonlyArray < readonly [ cell : unknown , kind : Kind , stored : string ] > = [
161+ // ISO 8601 and the export shape keep their readings.
162+ [ '2026-07-15' , 'date' , '2026-07-15' ] ,
163+ [ '2026-07-15' , 'datetime' , '2026-07-15T00:00:00.000Z' ] ,
164+ [ '2028-02-29' , 'date' , '2028-02-29' ] ,
165+ [ '2028-02-29T10:00:00Z' , 'datetime' , '2028-02-29T10:00:00.000Z' ] ,
166+ [ '2026-07-15 10:00:00' , 'datetime' , '2026-07-15T10:00:00.000Z' ] ,
167+ [ '2026-07-15 10:00:00' , 'date' , '2026-07-15' ] ,
168+ [ '2026-07-15 10:00:00' , 'time' , '10:00:00' ] ,
169+ [ '2026-07-15T10:00' , 'datetime' , '2026-07-15T10:00:00.000Z' ] ,
170+ [ '2026-07-15 10:00:00.123' , 'datetime' , '2026-07-15T10:00:00.123Z' ] ,
171+ [ '2026-07-15T10:00:00Z' , 'datetime' , '2026-07-15T10:00:00.000Z' ] ,
172+ [ '2026-07-15T10:00:00+08:00' , 'datetime' , '2026-07-15T02:00:00.000Z' ] ,
173+ [ '2026-07-15T10:00:00+0800' , 'datetime' , '2026-07-15T02:00:00.000Z' ] ,
174+ [ '2026-07-15T02:00:00+08:00' , 'date' , '2026-07-14' ] ,
175+ [ '2026-07-15T10:00:00+08:00' , 'time' , '02:00:00' ] ,
176+ [ '2026-07-15T24:00:00Z' , 'datetime' , '2026-07-16T00:00:00.000Z' ] ,
177+ [ ' 2026-07-15 ' , 'date' , '2026-07-15' ] ,
178+ [ '10:00' , 'time' , '10:00:00' ] ,
179+ // The year keeps four digits on every `date` branch.
180+ [ '0500-01-01' , 'date' , '0500-01-01' ] ,
181+ [ '0001-01-01' , 'date' , '0001-01-01' ] ,
182+ [ '0999-12-31 10:00:00' , 'date' , '0999-12-31' ] ,
183+ [ '0050-01-01T10:00:00Z' , 'date' , '0050-01-01' ] ,
184+ [ new Date ( '0500-01-01T00:00:00Z' ) , 'date' , '0500-01-01' ] ,
185+ // A bare day into a `datetime` is spelled from the day, never `Date.UTC(y, …)`,
186+ // which read years 0..99 as 1900..1999 (`0001-01-01` was stored as 1901).
187+ [ '0001-01-01' , 'datetime' , '0001-01-01T00:00:00.000Z' ] ,
188+ [ '0050-01-01' , 'datetime' , '0050-01-01T00:00:00.000Z' ] ,
189+ [ '0500-01-01' , 'datetime' , '0500-01-01T00:00:00.000Z' ] ,
190+ ] ;
191+
192+ it . each ( ADMITTED ) ( 'reads %j as a %s cell as %j on every host' , ( cell , kind , stored ) => {
193+ expect ( onBothHosts ( ( ) => parseDateCell ( cell , kind ) ) ) . toBe ( stored ) ;
194+ } ) ;
195+ } ) ;
196+
102197describe ( 'matchOption' , ( ) => {
103198 const options = [ { label : '高' , value : 'high' } , { label : '低' , value : 'low' } ] ;
104199 it ( 'matches by option value (code)' , ( ) => {
@@ -139,7 +234,7 @@ describe('coerceRow', () => {
139234 note : { type : 'text' } ,
140235 } ) ;
141236 const { data, errors } = await coerceRow (
142- { done : '是' , amount : '$1,200.50' , priority : '高' , tags : 'A, B' , due : '2026/07/ 01' , note : ' hi ' } ,
237+ { done : '是' , amount : '$1,200.50' , priority : '高' , tags : 'A, B' , due : '2026-07- 01' , note : ' hi ' } ,
143238 metaMap ,
144239 { } ,
145240 ) ;
0 commit comments