33 */
44import { describe , expect , it } from 'vitest'
55import * as XLSX from 'xlsx'
6- import { isoDateText , normalizeSheetDisplayText } from '@/lib/file-parsers/sheet-display-text'
6+ import {
7+ generalNumberText ,
8+ isoDateText ,
9+ isTimeOnlyFormat ,
10+ normalizeSheetDisplayText ,
11+ } from '@/lib/file-parsers/sheet-display-text'
712import { XlsxParser } from '@/lib/file-parsers/xlsx-parser'
813
914/**
@@ -54,9 +59,8 @@ describe('XlsxParser display text', () => {
5459 'TRUE' ,
5560 '$2,500.00' ,
5661 '4111111111111111' ,
57- '0.30000000000000004' ,
58- 'left' ,
59- 'right' ,
62+ '0.3' ,
63+ 'left right' ,
6064 ] )
6165 } )
6266
@@ -66,6 +70,60 @@ describe('XlsxParser display text', () => {
6670 expect ( dataRow ( result . content ) . slice ( 0 , 2 ) ) . toEqual ( [ '2026-03-04' , '2026-03-04T12:00:00' ] )
6771 } )
6872
73+ /**
74+ * A time-of-day serial lands on 1899-12-31 in a 1900 workbook and on
75+ * 1904-01-01 in a 1904 one, so the decision must come from the format, not
76+ * the epoch date. Elapsed formats are durations Excel shows as `30:00`.
77+ */
78+ describe . each ( [
79+ [ 'xlsx' , false ] ,
80+ [ 'xlsx' , true ] ,
81+ [ 'xls' , false ] ,
82+ [ 'xls' , true ] ,
83+ [ 'xlsb' , false ] ,
84+ [ 'xlsb' , true ] ,
85+ ] as const ) ( 'time cells in %s (date1904: %s)' , ( bookType , date1904 ) => {
86+ function timeWorkbook ( ) : Buffer {
87+ const sheet = XLSX . utils . aoa_to_sheet ( [ [ 'Clock' , 'Meridiem' , 'Elapsed' , 'Minutes' , 'Month' ] ] )
88+ sheet . A2 = { t : 'n' , v : 0.520821759 , z : 'h:mm:ss' }
89+ sheet . B2 = { t : 'n' , v : 0.75 , z : 'hh:mm AM/PM' }
90+ sheet . C2 = { t : 'n' , v : 1.25 , z : '[h]:mm' }
91+ sheet . D2 = { t : 'n' , v : 0.5 , z : '[mm]:ss' }
92+ sheet . E2 = { t : 'n' , v : 46085 , z : 'mmm' }
93+ sheet [ '!ref' ] = 'A1:E2'
94+ const book = XLSX . utils . book_new ( )
95+ XLSX . utils . book_append_sheet ( book , sheet , 'Times' )
96+ if ( date1904 ) book . Workbook = { WBProps : { date1904 : true } }
97+ return XLSX . write ( book , { type : 'buffer' , bookType } ) as Buffer
98+ }
99+
100+ it ( 'renders time-only cells as times and elapsed cells as durations' , async ( ) => {
101+ const result = await new XlsxParser ( ) . parseBuffer ( timeWorkbook ( ) )
102+
103+ const row = dataRow ( result . content )
104+ expect ( row . slice ( 0 , 4 ) ) . toEqual ( [ '12:29:59' , '18:00:00' , '30:00' , '720:00' ] )
105+ expect ( row [ 4 ] ) . toMatch ( / ^ \d { 4 } - \d { 2 } - \d { 2 } $ / )
106+ } )
107+ } )
108+
109+ /**
110+ * The SheetJS ODS writer stores each serial as the cell text, so an elapsed
111+ * cell reads back its serial; time-only cells still come from the format.
112+ */
113+ it . each ( [ false , true ] ) ( 'renders time-only cells from ods (date1904: %s)' , async ( date1904 ) => {
114+ const sheet = XLSX . utils . aoa_to_sheet ( [ [ 'Clock' ] ] )
115+ sheet . A2 = { t : 'n' , v : 0.520821759 , z : 'h:mm:ss' }
116+ sheet [ '!ref' ] = 'A1:A2'
117+ const book = XLSX . utils . book_new ( )
118+ XLSX . utils . book_append_sheet ( book , sheet , 'Times' )
119+ if ( date1904 ) book . Workbook = { WBProps : { date1904 : true } }
120+ const buffer = XLSX . write ( book , { type : 'buffer' , bookType : 'ods' } ) as Buffer
121+
122+ const result = await new XlsxParser ( ) . parseBuffer ( buffer )
123+
124+ expect ( dataRow ( result . content ) ) . toEqual ( [ '12:29:59' ] )
125+ } )
126+
69127 it . each ( [ 'xls' , 'xlsb' ] as const ) ( 'renders the same display text from %s' , async ( bookType ) => {
70128 const result = await new XlsxParser ( ) . parseBuffer ( typedWorkbook ( bookType ) )
71129
@@ -116,10 +174,43 @@ describe('isoDateText', () => {
116174 expect ( isoDateText ( new Date ( Number . NaN ) ) ) . toBe ( '' )
117175 } )
118176
119- it ( 'renders a duration or time-of-day cell without the 1899 epoch date ' , ( ) => {
177+ it ( 'renders a formatless date before 1900 as a time of day ' , ( ) => {
120178 expect ( isoDateText ( new Date ( Date . UTC ( 1899 , 11 , 30 , 0 , 30 , 0 ) ) ) ) . toBe ( '00:30:00' )
121179 expect ( isoDateText ( new Date ( Date . UTC ( 1899 , 11 , 31 , 13 , 5 , 9 ) ) ) ) . toBe ( '13:05:09' )
122180 } )
181+
182+ it ( 'decides time of day from the format whatever the epoch date' , ( ) => {
183+ expect ( isoDateText ( new Date ( Date . UTC ( 1904 , 0 , 1 , 12 , 29 , 59 ) ) , 'h:mm:ss' ) ) . toBe ( '12:29:59' )
184+ expect ( isoDateText ( new Date ( Date . UTC ( 1899 , 11 , 31 , 12 , 0 , 0 ) ) , 'yyyy-mm-dd' ) ) . toBe (
185+ '1899-12-31T12:00:00'
186+ )
187+ } )
188+ } )
189+
190+ describe ( 'isTimeOnlyFormat' , ( ) => {
191+ it . each ( [ 'h:mm:ss' , 'hh:mm AM/PM' , 'h:mm' , 'mm:ss' , '[$-409]h:mm:ss' , 'hh"h"mm' ] ) (
192+ 'treats %s as time only' ,
193+ ( format ) => {
194+ expect ( isTimeOnlyFormat ( format ) ) . toBe ( true )
195+ }
196+ )
197+
198+ it . each ( [ 'm/d/yyyy' , 'yyyy-mm-dd hh:mm' , 'mmm' , 'd-mmm' , 'mmmm yyyy' , 'General' ] ) (
199+ 'treats %s as a date' ,
200+ ( format ) => {
201+ expect ( isTimeOnlyFormat ( format ) ) . toBe ( false )
202+ }
203+ )
204+ } )
205+
206+ describe ( 'generalNumberText' , ( ) => {
207+ it ( 'keeps integers exact and rounds fractions to 15 significant digits' , ( ) => {
208+ expect ( generalNumberText ( 4111111111111111 ) ) . toBe ( '4111111111111111' )
209+ expect ( generalNumberText ( Number . MAX_SAFE_INTEGER ) ) . toBe ( '9007199254740991' )
210+ expect ( generalNumberText ( 0.1 + 0.2 ) ) . toBe ( '0.3' )
211+ expect ( generalNumberText ( 1063.8425 ) ) . toBe ( '1063.8425' )
212+ expect ( generalNumberText ( 1.22464679914735e-16 ) ) . toBe ( '1.22464679914735e-16' )
213+ } )
123214} )
124215
125216describe ( 'normalizeSheetDisplayText' , ( ) => {
@@ -139,6 +230,18 @@ describe('normalizeSheetDisplayText', () => {
139230 expect ( sheet . D1 . w ) . toBe ( '9' )
140231 } )
141232
233+ it ( 'keeps the rendered duration of an elapsed-time cell' , ( ) => {
234+ const sheet = XLSX . utils . aoa_to_sheet ( [ [ 'a' ] ] )
235+ sheet . A1 = { t : 'd' , v : new Date ( Date . UTC ( 1900 , 0 , 1 , 6 ) ) , z : '[h]:mm' , w : '30:00' }
236+ sheet . B1 = { t : 'd' , v : new Date ( Date . UTC ( 1899 , 11 , 31 , 12 ) ) , z : '[mm]:ss' , w : '720:00' }
237+ sheet [ '!ref' ] = 'A1:B1'
238+
239+ normalizeSheetDisplayText ( sheet , XLSX . utils . decode_range ( 'A1:B1' ) , XLSX . utils )
240+
241+ expect ( sheet . A1 . w ) . toBe ( '30:00' )
242+ expect ( sheet . B1 . w ) . toBe ( '720:00' )
243+ } )
244+
142245 it ( 'touches only the window on a dense sheet' , ( ) => {
143246 const sheet = XLSX . utils . aoa_to_sheet ( [ [ 'a' ] ] , { dense : true } )
144247 const inside = { t : 'd' , v : new Date ( Date . UTC ( 2026 , 2 , 4 ) ) , w : '3/4/2026' } as XLSX . CellObject
0 commit comments