@lotics/xlsx 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/package.json +30 -0
  2. package/src/auto_sum.test.ts +71 -0
  3. package/src/auto_sum.ts +83 -0
  4. package/src/canvas_cf.ts +135 -0
  5. package/src/canvas_chart.ts +687 -0
  6. package/src/canvas_fill.ts +156 -0
  7. package/src/canvas_images.ts +99 -0
  8. package/src/canvas_layout.test.ts +159 -0
  9. package/src/canvas_layout.ts +239 -0
  10. package/src/canvas_renderer.ts +1010 -0
  11. package/src/canvas_shape.ts +242 -0
  12. package/src/canvas_sparkline.ts +163 -0
  13. package/src/canvas_text.ts +454 -0
  14. package/src/cell_editor.ts +558 -0
  15. package/src/clipboard.test.ts +145 -0
  16. package/src/clipboard.ts +341 -0
  17. package/src/command_history.ts +102 -0
  18. package/src/csv_parser.test.ts +130 -0
  19. package/src/csv_parser.ts +172 -0
  20. package/src/data_validation.test.ts +95 -0
  21. package/src/data_validation.ts +114 -0
  22. package/src/editing_layer.test.ts +309 -0
  23. package/src/excel_cf_evaluate.test.ts +293 -0
  24. package/src/excel_cf_evaluate.ts +719 -0
  25. package/src/excel_cf_icons.ts +108 -0
  26. package/src/excel_cf_types.ts +67 -0
  27. package/src/excel_numfmt.test.ts +607 -0
  28. package/src/excel_numfmt.ts +1033 -0
  29. package/src/excel_parser.test.ts +1061 -0
  30. package/src/excel_parser.ts +393 -0
  31. package/src/excel_pattern_fills.ts +64 -0
  32. package/src/excel_table_styles.ts +160 -0
  33. package/src/excel_utils.test.ts +108 -0
  34. package/src/excel_utils.ts +162 -0
  35. package/src/fast-formula-parser.d.ts +53 -0
  36. package/src/fill_logic.ts +257 -0
  37. package/src/fill_patterns.test.ts +90 -0
  38. package/src/fill_patterns.ts +71 -0
  39. package/src/flash_fill.test.ts +75 -0
  40. package/src/flash_fill.ts +221 -0
  41. package/src/formula_deps.ts +189 -0
  42. package/src/formula_engine.test.ts +348 -0
  43. package/src/formula_engine.ts +401 -0
  44. package/src/formula_highlight.test.ts +81 -0
  45. package/src/formula_highlight.ts +139 -0
  46. package/src/formula_suggest.ts +100 -0
  47. package/src/hidden_sheets.test.ts +49 -0
  48. package/src/index.ts +149 -0
  49. package/src/keyboard_nav.test.ts +394 -0
  50. package/src/keyboard_nav.ts +891 -0
  51. package/src/move_logic.ts +63 -0
  52. package/src/numfmt_type.test.ts +158 -0
  53. package/src/numfmt_type.ts +231 -0
  54. package/src/ooxml_cell_format.ts +70 -0
  55. package/src/ooxml_chart.test.ts +85 -0
  56. package/src/ooxml_chart.ts +347 -0
  57. package/src/ooxml_chart_types.ts +207 -0
  58. package/src/ooxml_color.ts +339 -0
  59. package/src/ooxml_drawing.test.ts +87 -0
  60. package/src/ooxml_drawing.ts +287 -0
  61. package/src/ooxml_pivot.test.ts +195 -0
  62. package/src/ooxml_pivot.ts +468 -0
  63. package/src/ooxml_pivot_types.ts +165 -0
  64. package/src/ooxml_shape.ts +355 -0
  65. package/src/ooxml_sheet.ts +1271 -0
  66. package/src/ooxml_styles.ts +556 -0
  67. package/src/ooxml_table.test.ts +70 -0
  68. package/src/ooxml_table.ts +131 -0
  69. package/src/ooxml_workbook.test.ts +40 -0
  70. package/src/ooxml_workbook.ts +259 -0
  71. package/src/ooxml_zip.ts +33 -0
  72. package/src/pivot_model.ts +237 -0
  73. package/src/pivot_recompute.test.ts +210 -0
  74. package/src/pivot_recompute.ts +413 -0
  75. package/src/selection_model.ts +364 -0
  76. package/src/spreadsheet_commands.test.ts +772 -0
  77. package/src/spreadsheet_commands.ts +1805 -0
  78. package/src/structured_refs.test.ts +128 -0
  79. package/src/structured_refs.ts +160 -0
  80. package/src/style_helpers.test.ts +33 -0
  81. package/src/style_helpers.ts +30 -0
  82. package/src/template_builder.test.ts +139 -0
  83. package/src/template_builder.ts +36 -0
  84. package/src/types.ts +239 -0
  85. package/src/workbook_model.test.ts +536 -0
  86. package/src/workbook_model.ts +911 -0
  87. package/src/xlsx_round_trip.test.ts +279 -0
  88. package/src/xlsx_writer.test.ts +488 -0
  89. package/src/xlsx_writer.ts +1549 -0
  90. package/src/xml_entities.ts +23 -0
@@ -0,0 +1,607 @@
1
+ import { formatNumFmt, serialToDateParts } from "./excel_numfmt";
2
+
3
+ // =============================================================================
4
+ // General format
5
+ // =============================================================================
6
+
7
+ describe("General format", () => {
8
+ test("returns plain number", () => {
9
+ expect(formatNumFmt(1234, "General").text).toBe("1234");
10
+ });
11
+
12
+ test("returns zero", () => {
13
+ expect(formatNumFmt(0, "General").text).toBe("0");
14
+ });
15
+
16
+ test("returns decimal", () => {
17
+ expect(formatNumFmt(3.14, "General").text).toBe("3.14");
18
+ });
19
+
20
+ test("returns empty format as General", () => {
21
+ expect(formatNumFmt(42, "").text).toBe("42");
22
+ });
23
+
24
+ test("large number uses scientific notation", () => {
25
+ const result = formatNumFmt(1.23e15, "General").text;
26
+ expect(result).toContain("E+");
27
+ });
28
+
29
+ test("small number stays decimal when it fits", () => {
30
+ expect(formatNumFmt(0.00001, "General").text).toBe("0.00001");
31
+ });
32
+
33
+ test("very small number uses scientific notation", () => {
34
+ const result = formatNumFmt(1e-10, "General").text;
35
+ expect(result).toContain("E-");
36
+ });
37
+
38
+ test("string value passes through", () => {
39
+ expect(formatNumFmt("Hello" as unknown as number, "General").text).toBe("Hello");
40
+ });
41
+ });
42
+
43
+ // =============================================================================
44
+ // Built-in format IDs (via their format codes)
45
+ // =============================================================================
46
+
47
+ describe("built-in number formats", () => {
48
+ // ID 0: General
49
+ test("ID 0 — General", () => {
50
+ expect(formatNumFmt(1234.5, "General").text).toBe("1234.5");
51
+ });
52
+
53
+ // ID 1: 0
54
+ test("ID 1 — 0", () => {
55
+ expect(formatNumFmt(3.7, "0").text).toBe("4");
56
+ expect(formatNumFmt(3.1, "0").text).toBe("3");
57
+ });
58
+
59
+ // ID 2: 0.00
60
+ test("ID 2 — 0.00", () => {
61
+ expect(formatNumFmt(3.1, "0.00").text).toBe("3.10");
62
+ expect(formatNumFmt(0, "0.00").text).toBe("0.00");
63
+ });
64
+
65
+ // ID 3: #,##0
66
+ test("ID 3 — #,##0", () => {
67
+ expect(formatNumFmt(1234567, "#,##0").text).toBe("1,234,567");
68
+ expect(formatNumFmt(0, "#,##0").text).toBe("0");
69
+ expect(formatNumFmt(42, "#,##0").text).toBe("42");
70
+ });
71
+
72
+ // ID 4: #,##0.00
73
+ test("ID 4 — #,##0.00", () => {
74
+ expect(formatNumFmt(1234.5, "#,##0.00").text).toBe("1,234.50");
75
+ expect(formatNumFmt(0, "#,##0.00").text).toBe("0.00");
76
+ });
77
+
78
+ // ID 9: 0%
79
+ test("ID 9 — 0%", () => {
80
+ expect(formatNumFmt(0.75, "0%").text).toBe("75%");
81
+ expect(formatNumFmt(1, "0%").text).toBe("100%");
82
+ });
83
+
84
+ // ID 10: 0.00%
85
+ test("ID 10 — 0.00%", () => {
86
+ expect(formatNumFmt(0.5, "0.00%").text).toBe("50.00%");
87
+ expect(formatNumFmt(0.1234, "0.00%").text).toBe("12.34%");
88
+ });
89
+
90
+ // ID 11: 0.00E+00
91
+ test("ID 11 — 0.00E+00", () => {
92
+ expect(formatNumFmt(1234, "0.00E+00").text).toBe("1.23E+03");
93
+ expect(formatNumFmt(0.0056, "0.00E+00").text).toBe("5.60E-03");
94
+ expect(formatNumFmt(0, "0.00E+00").text).toBe("0.00E+00");
95
+ });
96
+
97
+ // ID 12: # ?/?
98
+ test("ID 12 — # ?/?", () => {
99
+ const result = formatNumFmt(3.25, "# ?/?").text;
100
+ expect(result).toContain("3");
101
+ expect(result).toContain("/");
102
+ expect(result).toContain("4");
103
+ });
104
+
105
+ // ID 13: # ??/??
106
+ test("ID 13 — # ??/??", () => {
107
+ const result = formatNumFmt(3.125, "# ??/??").text;
108
+ expect(result).toContain("3");
109
+ expect(result).toContain("/");
110
+ });
111
+
112
+ // ID 14: mm-dd-yy
113
+ test("ID 14 — mm-dd-yy", () => {
114
+ // Serial 44927 = 2023-01-01
115
+ expect(formatNumFmt(44927, "mm-dd-yy").text).toBe("01-01-23");
116
+ });
117
+
118
+ // ID 15: d-mmm-yy
119
+ test("ID 15 — d-mmm-yy", () => {
120
+ expect(formatNumFmt(44927, "d-mmm-yy").text).toBe("1-Jan-23");
121
+ });
122
+
123
+ // ID 16: d-mmm
124
+ test("ID 16 — d-mmm", () => {
125
+ expect(formatNumFmt(44927, "d-mmm").text).toBe("1-Jan");
126
+ });
127
+
128
+ // ID 17: mmm-yy
129
+ test("ID 17 — mmm-yy", () => {
130
+ expect(formatNumFmt(44927, "mmm-yy").text).toBe("Jan-23");
131
+ });
132
+
133
+ // ID 18: h:mm AM/PM
134
+ test("ID 18 — h:mm AM/PM", () => {
135
+ // Serial 0.75 = 6:00 PM
136
+ expect(formatNumFmt(0.75, "h:mm AM/PM").text).toBe("6:00 PM");
137
+ // Serial 0.25 = 6:00 AM
138
+ expect(formatNumFmt(0.25, "h:mm AM/PM").text).toBe("6:00 AM");
139
+ });
140
+
141
+ // ID 19: h:mm:ss AM/PM
142
+ test("ID 19 — h:mm:ss AM/PM", () => {
143
+ expect(formatNumFmt(0.5, "h:mm:ss AM/PM").text).toBe("12:00:00 PM");
144
+ });
145
+
146
+ // ID 20: h:mm
147
+ test("ID 20 — h:mm", () => {
148
+ expect(formatNumFmt(0.75, "h:mm").text).toBe("18:00");
149
+ });
150
+
151
+ // ID 21: h:mm:ss
152
+ test("ID 21 — h:mm:ss", () => {
153
+ expect(formatNumFmt(0.75, "h:mm:ss").text).toBe("18:00:00");
154
+ });
155
+
156
+ // ID 22: m/d/yy h:mm
157
+ test("ID 22 — m/d/yy h:mm", () => {
158
+ // 44927.75 = 2023-01-01 18:00
159
+ expect(formatNumFmt(44927.75, "m/d/yy h:mm").text).toBe("1/1/23 18:00");
160
+ });
161
+
162
+ // ID 37: #,##0 ;(#,##0)
163
+ test("ID 37 — #,##0 ;(#,##0)", () => {
164
+ expect(formatNumFmt(1234, "#,##0 ;(#,##0)").text).toBe("1,234 ");
165
+ expect(formatNumFmt(-1234, "#,##0 ;(#,##0)").text).toBe("(1,234)");
166
+ });
167
+
168
+ // ID 38: #,##0 ;[Red](#,##0)
169
+ test("ID 38 — #,##0 ;[Red](#,##0)", () => {
170
+ const pos = formatNumFmt(1234, "#,##0 ;[Red](#,##0)");
171
+ expect(pos.text).toBe("1,234 ");
172
+ expect(pos.color).toBeUndefined();
173
+
174
+ const neg = formatNumFmt(-1234, "#,##0 ;[Red](#,##0)");
175
+ expect(neg.text).toBe("(1,234)");
176
+ expect(neg.color).toBe("#FF0000");
177
+ });
178
+
179
+ // ID 39: #,##0.00;(#,##0.00)
180
+ test("ID 39 — #,##0.00;(#,##0.00)", () => {
181
+ expect(formatNumFmt(1234.56, "#,##0.00;(#,##0.00)").text).toBe("1,234.56");
182
+ expect(formatNumFmt(-1234.56, "#,##0.00;(#,##0.00)").text).toBe("(1,234.56)");
183
+ });
184
+
185
+ // ID 40: #,##0.00;[Red](#,##0.00)
186
+ test("ID 40 — #,##0.00;[Red](#,##0.00)", () => {
187
+ const neg = formatNumFmt(-1234.56, "#,##0.00;[Red](#,##0.00)");
188
+ expect(neg.text).toBe("(1,234.56)");
189
+ expect(neg.color).toBe("#FF0000");
190
+ });
191
+
192
+ // ID 45: mm:ss
193
+ test("ID 45 — mm:ss", () => {
194
+ // 0.5 = 12h = 720min → 00:00 of time part? Actually mm:ss is minutes:seconds
195
+ // For serial 0.04236... = 1 hour, 1 minute, 1 second? Let me use a clearer example:
196
+ // 0.25 = 6:00:00 → mm=00, ss=00
197
+ expect(formatNumFmt(0.25, "mm:ss").text).toBe("00:00");
198
+ });
199
+
200
+ // ID 46: [h]:mm:ss
201
+ test("ID 46 — [h]:mm:ss", () => {
202
+ // 1.5 = 36 hours
203
+ expect(formatNumFmt(1.5, "[h]:mm:ss").text).toBe("36:00:00");
204
+ });
205
+
206
+ // ID 48: ##0.0E+0
207
+ test("ID 48 — ##0.0E+0", () => {
208
+ const result = formatNumFmt(1234, "##0.0E+0").text;
209
+ expect(result).toContain("E+");
210
+ });
211
+
212
+ // ID 49: @
213
+ test("ID 49 — @", () => {
214
+ expect(formatNumFmt("Hello", "@").text).toBe("Hello");
215
+ });
216
+ });
217
+
218
+ // =============================================================================
219
+ // 4-section format codes
220
+ // =============================================================================
221
+
222
+ describe("4-section format codes", () => {
223
+ const fmt = '#,##0.00;(#,##0.00);"Zero";"Text: "@';
224
+
225
+ test("positive section", () => {
226
+ expect(formatNumFmt(42.5, fmt).text).toBe("42.50");
227
+ });
228
+
229
+ test("negative section (absolute value, no sign)", () => {
230
+ expect(formatNumFmt(-42.5, fmt).text).toBe("(42.50)");
231
+ });
232
+
233
+ test("zero section", () => {
234
+ expect(formatNumFmt(0, fmt).text).toBe("Zero");
235
+ });
236
+
237
+ test("text section", () => {
238
+ expect(formatNumFmt("Hello", fmt).text).toBe("Text: Hello");
239
+ });
240
+ });
241
+
242
+ // =============================================================================
243
+ // Color codes
244
+ // =============================================================================
245
+
246
+ describe("color codes", () => {
247
+ test("[Red] returns red color", () => {
248
+ const result = formatNumFmt(42, "[Red]#,##0");
249
+ expect(result.text).toBe("42");
250
+ expect(result.color).toBe("#FF0000");
251
+ });
252
+
253
+ test("[Blue] returns blue color", () => {
254
+ const result = formatNumFmt(42, "[Blue]0.00");
255
+ expect(result.text).toBe("42.00");
256
+ expect(result.color).toBe("#0000FF");
257
+ });
258
+
259
+ test("[Green] returns green color", () => {
260
+ const result = formatNumFmt(42, "[Green]0");
261
+ expect(result.text).toBe("42");
262
+ expect(result.color).toBe("#008000");
263
+ });
264
+
265
+ test("color in negative section only", () => {
266
+ const pos = formatNumFmt(42, "#,##0;[Red]-#,##0");
267
+ expect(pos.color).toBeUndefined();
268
+
269
+ const neg = formatNumFmt(-42, "#,##0;[Red]-#,##0");
270
+ expect(neg.color).toBe("#FF0000");
271
+ });
272
+
273
+ test("case-insensitive color matching", () => {
274
+ expect(formatNumFmt(1, "[red]0").color).toBe("#FF0000");
275
+ expect(formatNumFmt(1, "[RED]0").color).toBe("#FF0000");
276
+ });
277
+ });
278
+
279
+ // =============================================================================
280
+ // Conditional sections
281
+ // =============================================================================
282
+
283
+ describe("conditional sections", () => {
284
+ test("[>100] condition selects first section", () => {
285
+ const fmt = "[>100]#,##0;0.00";
286
+ expect(formatNumFmt(200, fmt).text).toBe("200");
287
+ expect(formatNumFmt(50, fmt).text).toBe("50.00");
288
+ });
289
+
290
+ test("[<=0] condition", () => {
291
+ const fmt = "[<=0]0;#,##0";
292
+ expect(formatNumFmt(0, fmt).text).toBe("0");
293
+ expect(formatNumFmt(1234, fmt).text).toBe("1,234");
294
+ });
295
+ });
296
+
297
+ // =============================================================================
298
+ // Date/time formats
299
+ // =============================================================================
300
+
301
+ describe("date/time formatting", () => {
302
+ // 44927 = 2023-01-01 (Sunday)
303
+ const serial = 44927;
304
+
305
+ test("yyyy-mm-dd", () => {
306
+ expect(formatNumFmt(serial, "yyyy-mm-dd").text).toBe("2023-01-01");
307
+ });
308
+
309
+ test("dd/mm/yyyy", () => {
310
+ expect(formatNumFmt(serial, "dd/mm/yyyy").text).toBe("01/01/2023");
311
+ });
312
+
313
+ test("m/d/yyyy", () => {
314
+ expect(formatNumFmt(serial, "m/d/yyyy").text).toBe("1/1/2023");
315
+ });
316
+
317
+ test("mmmm d, yyyy", () => {
318
+ expect(formatNumFmt(serial, 'mmmm d, yyyy').text).toBe("January 1, 2023");
319
+ });
320
+
321
+ test("mmmmm (first letter of month)", () => {
322
+ expect(formatNumFmt(serial, "mmmmm").text).toBe("J");
323
+ });
324
+
325
+ test("dddd (full day name)", () => {
326
+ expect(formatNumFmt(serial, "dddd").text).toBe("Sunday");
327
+ });
328
+
329
+ test("ddd (abbreviated day name)", () => {
330
+ expect(formatNumFmt(serial, "ddd").text).toBe("Sun");
331
+ });
332
+
333
+ test("yy (2-digit year)", () => {
334
+ expect(formatNumFmt(serial, "yy").text).toBe("23");
335
+ });
336
+
337
+ test("h:mm:ss with time fraction", () => {
338
+ // 0.5625 = 13:30:00
339
+ expect(formatNumFmt(0.5625, "h:mm:ss").text).toBe("13:30:00");
340
+ });
341
+
342
+ test("hh:mm:ss zero-padded", () => {
343
+ expect(formatNumFmt(0.125, "hh:mm:ss").text).toBe("03:00:00");
344
+ });
345
+
346
+ test("AM/PM formatting", () => {
347
+ expect(formatNumFmt(0.25, "h:mm AM/PM").text).toBe("6:00 AM");
348
+ expect(formatNumFmt(0.75, "h:mm AM/PM").text).toBe("6:00 PM");
349
+ expect(formatNumFmt(0.5, "h:mm AM/PM").text).toBe("12:00 PM");
350
+ expect(formatNumFmt(0, "h:mm AM/PM").text).toBe("12:00 AM");
351
+ });
352
+
353
+ test("A/P short format", () => {
354
+ expect(formatNumFmt(0.25, "h:mm A/P").text).toBe("6:00 A");
355
+ expect(formatNumFmt(0.75, "h:mm A/P").text).toBe("6:00 P");
356
+ });
357
+
358
+ test("m resolves to minutes when after h", () => {
359
+ expect(formatNumFmt(0.5625, "h:mm").text).toBe("13:30");
360
+ });
361
+
362
+ test("m resolves to minutes when before s", () => {
363
+ expect(formatNumFmt(0.5625, "mm:ss").text).toBe("30:00");
364
+ });
365
+
366
+ test("date with quoted literals", () => {
367
+ expect(formatNumFmt(serial, 'yyyy"/"mm"/"dd').text).toBe("2023/01/01");
368
+ });
369
+ });
370
+
371
+ // =============================================================================
372
+ // Elapsed time
373
+ // =============================================================================
374
+
375
+ describe("elapsed time", () => {
376
+ test("[h]:mm:ss for > 24 hours", () => {
377
+ expect(formatNumFmt(1.5, "[h]:mm:ss").text).toBe("36:00:00");
378
+ });
379
+
380
+ test("[h]:mm:ss for < 24 hours", () => {
381
+ expect(formatNumFmt(0.5, "[h]:mm:ss").text).toBe("12:00:00");
382
+ });
383
+
384
+ test("[m]:ss for elapsed minutes", () => {
385
+ // 0.5 = 12 hours = 720 minutes
386
+ expect(formatNumFmt(0.5, "[m]:ss").text).toBe("720:00");
387
+ });
388
+
389
+ test("[s] for elapsed seconds", () => {
390
+ // 0.5 = 12 hours = 43200 seconds
391
+ expect(formatNumFmt(0.5, "[s]").text).toBe("43200");
392
+ });
393
+ });
394
+
395
+ // =============================================================================
396
+ // Fractions
397
+ // =============================================================================
398
+
399
+ describe("fractions", () => {
400
+ test("# ?/? for simple fraction", () => {
401
+ const result = formatNumFmt(3.25, "# ?/?").text;
402
+ expect(result.trim()).toMatch(/3\s+1\/4/);
403
+ });
404
+
405
+ test("# ?/? for zero fraction shows integer only", () => {
406
+ const result = formatNumFmt(5, "# ?/?").text;
407
+ expect(result).toContain("5");
408
+ });
409
+
410
+ test("fixed denominator # ?/4", () => {
411
+ const result = formatNumFmt(3.5, "# ?/4").text;
412
+ expect(result).toContain("2/4");
413
+ });
414
+
415
+ test("fixed denominator # ?/8", () => {
416
+ const result = formatNumFmt(0.625, "# ?/8").text;
417
+ expect(result).toContain("5/8");
418
+ });
419
+ });
420
+
421
+ // =============================================================================
422
+ // Scientific notation
423
+ // =============================================================================
424
+
425
+ describe("scientific notation", () => {
426
+ test("0.00E+00 positive", () => {
427
+ expect(formatNumFmt(1234, "0.00E+00").text).toBe("1.23E+03");
428
+ });
429
+
430
+ test("0.00E+00 negative exponent", () => {
431
+ expect(formatNumFmt(0.005, "0.00E+00").text).toBe("5.00E-03");
432
+ });
433
+
434
+ test("0.00E+00 zero", () => {
435
+ expect(formatNumFmt(0, "0.00E+00").text).toBe("0.00E+00");
436
+ });
437
+
438
+ test("0E+0 compact", () => {
439
+ const result = formatNumFmt(5000, "0E+0").text;
440
+ expect(result).toContain("E+");
441
+ });
442
+ });
443
+
444
+ // =============================================================================
445
+ // Currency and locale codes
446
+ // =============================================================================
447
+
448
+ describe("currency and locale", () => {
449
+ test("$#,##0.00", () => {
450
+ expect(formatNumFmt(1234.56, "$#,##0.00").text).toBe("$1,234.56");
451
+ });
452
+
453
+ test("[$EUR] locale code extracts currency", () => {
454
+ const result = formatNumFmt(1234.56, '[$EUR]#,##0.00');
455
+ expect(result.text).toContain("EUR");
456
+ expect(result.text).toContain("1,234.56");
457
+ });
458
+
459
+ test("[$€-407] locale code with symbol and locale", () => {
460
+ const result = formatNumFmt(1234.56, "[$€-407]#,##0.00");
461
+ expect(result.text).toContain("€");
462
+ expect(result.text).toContain("1,234.56");
463
+ });
464
+
465
+ test("quoted currency suffix", () => {
466
+ expect(formatNumFmt(50000, '#,##0 "VND"').text).toBe("50,000 VND");
467
+ });
468
+ });
469
+
470
+ // =============================================================================
471
+ // Percentage
472
+ // =============================================================================
473
+
474
+ describe("percentage", () => {
475
+ test("0% integer percent", () => {
476
+ expect(formatNumFmt(0.75, "0%").text).toBe("75%");
477
+ });
478
+
479
+ test("0.00% decimal percent", () => {
480
+ expect(formatNumFmt(0.1234, "0.00%").text).toBe("12.34%");
481
+ });
482
+
483
+ test("0% for value > 1", () => {
484
+ expect(formatNumFmt(1.5, "0%").text).toBe("150%");
485
+ });
486
+ });
487
+
488
+ // =============================================================================
489
+ // Thousands separator and scaling
490
+ // =============================================================================
491
+
492
+ describe("thousands separator and scaling", () => {
493
+ test("#,##0 groups thousands", () => {
494
+ expect(formatNumFmt(1234567, "#,##0").text).toBe("1,234,567");
495
+ });
496
+
497
+ test("trailing comma scales by 1000", () => {
498
+ expect(formatNumFmt(1234567, "#,##0,").text).toBe("1,235");
499
+ });
500
+
501
+ test("two trailing commas scale by 1000000", () => {
502
+ expect(formatNumFmt(1234567890, "#,##0,,").text).toBe("1,235");
503
+ });
504
+ });
505
+
506
+ // =============================================================================
507
+ // Digit placeholders
508
+ // =============================================================================
509
+
510
+ describe("digit placeholders", () => {
511
+ test("0 pads with zeros", () => {
512
+ expect(formatNumFmt(5, "000").text).toBe("005");
513
+ });
514
+
515
+ test("# suppresses leading zeros", () => {
516
+ expect(formatNumFmt(5, "###").text).toBe("5");
517
+ });
518
+
519
+ test("0.000 fixed decimal places", () => {
520
+ expect(formatNumFmt(3.1, "0.000").text).toBe("3.100");
521
+ });
522
+
523
+ test("0 rounds to integer", () => {
524
+ expect(formatNumFmt(3.7, "0").text).toBe("4");
525
+ });
526
+
527
+ test("#.## trims trailing zeros", () => {
528
+ expect(formatNumFmt(3.1, "#.##").text).toBe("3.1");
529
+ });
530
+
531
+ test("#.## shows .5 not 0.5", () => {
532
+ expect(formatNumFmt(0.5, "#.##").text).toBe(".5");
533
+ });
534
+ });
535
+
536
+ // =============================================================================
537
+ // Underscore spacing and fill characters
538
+ // =============================================================================
539
+
540
+ describe("underscore and fill", () => {
541
+ test("_) adds a space", () => {
542
+ expect(formatNumFmt(42, "0_)").text).toBe("42 ");
543
+ });
544
+
545
+ test("*- fill character is stripped", () => {
546
+ expect(formatNumFmt(42, "0*-").text).toBe("42");
547
+ });
548
+ });
549
+
550
+ // =============================================================================
551
+ // Negative numbers
552
+ // =============================================================================
553
+
554
+ describe("negative numbers", () => {
555
+ test("single section format shows minus sign", () => {
556
+ expect(formatNumFmt(-42, "#,##0").text).toBe("-42");
557
+ });
558
+
559
+ test("2-section format uses second section for negative", () => {
560
+ expect(formatNumFmt(-42, "#,##0;(#,##0)").text).toBe("(42)");
561
+ });
562
+
563
+ test("2-section format positive uses first section", () => {
564
+ expect(formatNumFmt(42, "#,##0;(#,##0)").text).toBe("42");
565
+ });
566
+ });
567
+
568
+ // =============================================================================
569
+ // Text format (@)
570
+ // =============================================================================
571
+
572
+ describe("text format", () => {
573
+ test("@ passes text through", () => {
574
+ expect(formatNumFmt("Hello", "@").text).toBe("Hello");
575
+ });
576
+
577
+ test("4-section text section with prefix", () => {
578
+ expect(formatNumFmt("World", '0;0;0;"Hello "@').text).toBe("Hello World");
579
+ });
580
+ });
581
+
582
+ // =============================================================================
583
+ // serialToDateParts
584
+ // =============================================================================
585
+
586
+ describe("serialToDateParts", () => {
587
+ test("serial 44927 = 2023-01-01", () => {
588
+ const p = serialToDateParts(44927, false);
589
+ expect(p.year).toBe(2023);
590
+ expect(p.month).toBe(1);
591
+ expect(p.day).toBe(1);
592
+ });
593
+
594
+ test("time fraction", () => {
595
+ const p = serialToDateParts(0.5, false);
596
+ expect(p.hours).toBe(12);
597
+ expect(p.minutes).toBe(0);
598
+ expect(p.seconds).toBe(0);
599
+ });
600
+
601
+ test("1904 date system", () => {
602
+ // In 1904 system, serial 0 = 1904-01-01
603
+ // Adding 1462 days shifts to 1900 system equivalent
604
+ const p = serialToDateParts(44927, true);
605
+ expect(p.year).toBe(2027);
606
+ });
607
+ });