@svgrid/grid 1.2.21 → 1.2.23
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.
- package/dist/GridFooter.svelte +34 -20
- package/dist/GridFooter.svelte.d.ts +8 -0
- package/dist/SvAutoComplete.svelte +100 -0
- package/dist/SvAutoComplete.svelte.d.ts +16 -0
- package/dist/SvButton.svelte +97 -0
- package/dist/SvButton.svelte.d.ts +23 -0
- package/dist/SvCalendar.svelte +669 -0
- package/dist/SvCalendar.svelte.d.ts +70 -0
- package/dist/SvCheckBox.svelte +81 -0
- package/dist/SvCheckBox.svelte.d.ts +19 -0
- package/dist/SvColorInput.svelte +187 -0
- package/dist/SvColorInput.svelte.d.ts +15 -0
- package/dist/SvComboBox.svelte +130 -0
- package/dist/SvComboBox.svelte.d.ts +15 -0
- package/dist/SvCountryInput.svelte +123 -0
- package/dist/SvCountryInput.svelte.d.ts +14 -0
- package/dist/SvDateTimePicker.svelte +322 -0
- package/dist/SvDateTimePicker.svelte.d.ts +37 -0
- package/dist/SvDropDownList.svelte +143 -0
- package/dist/SvDropDownList.svelte.d.ts +15 -0
- package/dist/SvForm.svelte +147 -0
- package/dist/SvForm.svelte.d.ts +29 -0
- package/dist/SvGauge.svelte +99 -0
- package/dist/SvGauge.svelte.d.ts +31 -0
- package/dist/SvGrid.controller.svelte.d.ts +16 -2
- package/dist/SvGrid.controller.svelte.js +63 -8
- package/dist/SvGrid.css +84 -31
- package/dist/SvGrid.helpers.d.ts +4 -0
- package/dist/SvGrid.helpers.js +40 -12
- package/dist/SvGrid.svelte +84 -5
- package/dist/SvGrid.types.d.ts +36 -0
- package/dist/SvGridDropdown.svelte +16 -30
- package/dist/SvGridDropdown.svelte.d.ts +4 -0
- package/dist/SvListBox.svelte +122 -0
- package/dist/SvListBox.svelte.d.ts +19 -0
- package/dist/SvMaskedInput.svelte +0 -0
- package/dist/SvMaskedInput.svelte.d.ts +15 -0
- package/dist/SvNumberInput.svelte +0 -0
- package/dist/SvNumberInput.svelte.d.ts +22 -0
- package/dist/SvPasswordInput.svelte +110 -0
- package/dist/SvPasswordInput.svelte.d.ts +22 -0
- package/dist/SvPhoneInput.svelte +0 -0
- package/dist/SvPhoneInput.svelte.d.ts +19 -0
- package/dist/SvRadioGroup.svelte +108 -0
- package/dist/SvRadioGroup.svelte.d.ts +23 -0
- package/dist/SvRating.svelte +111 -0
- package/dist/SvRating.svelte.d.ts +20 -0
- package/dist/SvRepeatButton.svelte +89 -0
- package/dist/SvRepeatButton.svelte.d.ts +21 -0
- package/dist/SvSlider.svelte +182 -0
- package/dist/SvSlider.svelte.d.ts +25 -0
- package/dist/SvSwitchButton.svelte +94 -0
- package/dist/SvSwitchButton.svelte.d.ts +19 -0
- package/dist/SvTabs.svelte +122 -0
- package/dist/SvTabs.svelte.d.ts +26 -0
- package/dist/SvTagsInput.svelte +81 -0
- package/dist/SvTagsInput.svelte.d.ts +19 -0
- package/dist/SvTimePicker.svelte +359 -0
- package/dist/SvTimePicker.svelte.d.ts +27 -0
- package/dist/SvToggleButton.svelte +57 -0
- package/dist/SvToggleButton.svelte.d.ts +16 -0
- package/dist/SvTree.svelte +210 -0
- package/dist/SvTree.svelte.d.ts +29 -0
- package/dist/build-api.js +78 -1
- package/dist/cdn/svgrid.js +10022 -5904
- package/dist/cdn/svgrid.svelte-external.js +8531 -4429
- package/dist/cell-render.d.ts +4 -0
- package/dist/cell-render.js +35 -1
- package/dist/clipboard.js +19 -1
- package/dist/collaboration.d.ts +2 -0
- package/dist/collaboration.js +4 -0
- package/dist/core.d.ts +22 -1
- package/dist/countries.d.ts +15 -0
- package/dist/countries.js +61 -0
- package/dist/datetime/date-core.d.ts +88 -0
- package/dist/datetime/date-core.js +165 -0
- package/dist/datetime/date-format.d.ts +39 -0
- package/dist/datetime/date-format.js +371 -0
- package/dist/datetime/date-restrict.d.ts +23 -0
- package/dist/datetime/date-restrict.js +50 -0
- package/dist/datetime/date-selection.d.ts +25 -0
- package/dist/datetime/date-selection.js +94 -0
- package/dist/datetime/mask.d.ts +21 -0
- package/dist/datetime/mask.js +90 -0
- package/dist/editing.d.ts +1 -1
- package/dist/editing.js +7 -0
- package/dist/editors/cell-editors.d.ts +2 -2
- package/dist/editors/cell-editors.js +3 -1
- package/dist/export-format.d.ts +178 -0
- package/dist/export-format.js +419 -0
- package/dist/hyperformula-adapter.d.ts +4 -0
- package/dist/hyperformula-adapter.js +15 -6
- package/dist/index.d.ts +36 -0
- package/dist/index.js +41 -0
- package/dist/list-option.d.ts +10 -0
- package/dist/list-option.js +7 -0
- package/dist/menus.js +18 -2
- package/dist/popover.d.ts +48 -0
- package/dist/popover.js +65 -0
- package/dist/row-resize.d.ts +4 -4
- package/dist/row-resize.js +8 -5
- package/dist/selection.d.ts +6 -0
- package/dist/selection.js +31 -5
- package/dist/server-data-source.d.ts +43 -0
- package/dist/server-data-source.js +100 -0
- package/dist/sparkline.js +8 -4
- package/dist/svgrid-wrapper.types.d.ts +26 -1
- package/package.json +6 -1
- package/src/GridFooter.svelte +34 -20
- package/src/SvAutoComplete.svelte +100 -0
- package/src/SvButton.svelte +97 -0
- package/src/SvCalendar.svelte +669 -0
- package/src/SvCalendar.test.ts +211 -0
- package/src/SvCheckBox.svelte +81 -0
- package/src/SvColorInput.svelte +187 -0
- package/src/SvComboBox.svelte +130 -0
- package/src/SvCountryInput.svelte +123 -0
- package/src/SvDateTimePicker.svelte +322 -0
- package/src/SvDateTimePicker.test.ts +136 -0
- package/src/SvDropDownList.svelte +143 -0
- package/src/SvForm.svelte +147 -0
- package/src/SvGauge.svelte +99 -0
- package/src/SvGrid.controller.svelte.ts +74 -8
- package/src/SvGrid.css +84 -31
- package/src/SvGrid.helpers.ts +41 -12
- package/src/SvGrid.svelte +84 -5
- package/src/SvGrid.types.ts +33 -0
- package/src/SvGridDropdown.svelte +16 -30
- package/src/SvListBox.svelte +122 -0
- package/src/SvMaskedInput.svelte +0 -0
- package/src/SvNumberInput.svelte +0 -0
- package/src/SvPasswordInput.svelte +110 -0
- package/src/SvPhoneInput.svelte +0 -0
- package/src/SvRadioGroup.svelte +108 -0
- package/src/SvRating.svelte +111 -0
- package/src/SvRepeatButton.svelte +89 -0
- package/src/SvSlider.svelte +182 -0
- package/src/SvSwitchButton.svelte +94 -0
- package/src/SvTabs.svelte +122 -0
- package/src/SvTagsInput.svelte +81 -0
- package/src/SvTimePicker.svelte +359 -0
- package/src/SvTimePicker.test.ts +98 -0
- package/src/SvToggleButton.svelte +57 -0
- package/src/SvTree.svelte +210 -0
- package/src/build-api.ts +115 -0
- package/src/cell-render.test.ts +38 -0
- package/src/cell-render.ts +43 -0
- package/src/clipboard.test.ts +18 -0
- package/src/clipboard.ts +24 -1
- package/src/collaboration.test.ts +30 -0
- package/src/collaboration.ts +6 -0
- package/src/core.ts +27 -3
- package/src/countries.ts +71 -0
- package/src/datetime/date-core.test.ts +217 -0
- package/src/datetime/date-core.ts +204 -0
- package/src/datetime/date-format.test.ts +121 -0
- package/src/datetime/date-format.ts +317 -0
- package/src/datetime/date-restrict.ts +60 -0
- package/src/datetime/date-selection.test.ts +129 -0
- package/src/datetime/date-selection.ts +137 -0
- package/src/datetime/mask.test.ts +36 -0
- package/src/datetime/mask.ts +84 -0
- package/src/editing.test.ts +22 -0
- package/src/editing.ts +7 -1
- package/src/editors/cell-editors.ts +7 -1
- package/src/export-data-api.test.ts +126 -0
- package/src/export-format.test.ts +107 -0
- package/src/export-format.ts +598 -0
- package/src/hyperformula-adapter.test.ts +35 -1
- package/src/hyperformula-adapter.ts +18 -6
- package/src/index.ts +71 -0
- package/src/list-option.ts +15 -0
- package/src/menus.ts +21 -2
- package/src/popover.ts +79 -0
- package/src/row-resize.test.ts +25 -0
- package/src/row-resize.ts +8 -5
- package/src/selection.test.ts +36 -4
- package/src/selection.ts +30 -9
- package/src/server-data-source.test.ts +201 -0
- package/src/server-data-source.ts +148 -0
- package/src/sparkline.test.ts +9 -0
- package/src/sparkline.ts +9 -4
- package/src/svgrid-wrapper.types.ts +28 -1
- package/src/svgrid.behavior.test.ts +27 -0
- package/src/svgrid.new-features.wrapper.test.ts +27 -1
- package/src/ui-buttons.test.ts +105 -0
- package/src/ui-composite.test.ts +89 -0
- package/src/ui-inputs.test.ts +92 -0
- package/src/ui-range.test.ts +61 -0
- package/src/ui-selection.test.ts +106 -0
package/src/countries.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* countries - a compact country reference for SvPhoneInput / SvCountryInput.
|
|
3
|
+
* ISO 3166-1 alpha-2 code, display name, E.164 dial code, and a flag emoji
|
|
4
|
+
* (derived from the code so no image assets are needed). This is a curated
|
|
5
|
+
* common-set; extend `COUNTRIES` for full coverage.
|
|
6
|
+
*/
|
|
7
|
+
export type Country = { code: string; name: string; dial: string }
|
|
8
|
+
|
|
9
|
+
/** Regional-indicator flag emoji from an ISO alpha-2 code. */
|
|
10
|
+
export function flagEmoji(code: string): string {
|
|
11
|
+
return code
|
|
12
|
+
.toUpperCase()
|
|
13
|
+
.replace(/./g, (c) => String.fromCodePoint(127397 + c.charCodeAt(0)))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const COUNTRIES: Country[] = [
|
|
17
|
+
{ code: 'US', name: 'United States', dial: '+1' },
|
|
18
|
+
{ code: 'CA', name: 'Canada', dial: '+1' },
|
|
19
|
+
{ code: 'GB', name: 'United Kingdom', dial: '+44' },
|
|
20
|
+
{ code: 'IE', name: 'Ireland', dial: '+353' },
|
|
21
|
+
{ code: 'FR', name: 'France', dial: '+33' },
|
|
22
|
+
{ code: 'DE', name: 'Germany', dial: '+49' },
|
|
23
|
+
{ code: 'ES', name: 'Spain', dial: '+34' },
|
|
24
|
+
{ code: 'PT', name: 'Portugal', dial: '+351' },
|
|
25
|
+
{ code: 'IT', name: 'Italy', dial: '+39' },
|
|
26
|
+
{ code: 'NL', name: 'Netherlands', dial: '+31' },
|
|
27
|
+
{ code: 'BE', name: 'Belgium', dial: '+32' },
|
|
28
|
+
{ code: 'CH', name: 'Switzerland', dial: '+41' },
|
|
29
|
+
{ code: 'AT', name: 'Austria', dial: '+43' },
|
|
30
|
+
{ code: 'SE', name: 'Sweden', dial: '+46' },
|
|
31
|
+
{ code: 'NO', name: 'Norway', dial: '+47' },
|
|
32
|
+
{ code: 'DK', name: 'Denmark', dial: '+45' },
|
|
33
|
+
{ code: 'FI', name: 'Finland', dial: '+358' },
|
|
34
|
+
{ code: 'PL', name: 'Poland', dial: '+48' },
|
|
35
|
+
{ code: 'CZ', name: 'Czechia', dial: '+420' },
|
|
36
|
+
{ code: 'GR', name: 'Greece', dial: '+30' },
|
|
37
|
+
{ code: 'BG', name: 'Bulgaria', dial: '+359' },
|
|
38
|
+
{ code: 'RO', name: 'Romania', dial: '+40' },
|
|
39
|
+
{ code: 'UA', name: 'Ukraine', dial: '+380' },
|
|
40
|
+
{ code: 'RU', name: 'Russia', dial: '+7' },
|
|
41
|
+
{ code: 'TR', name: 'Turkey', dial: '+90' },
|
|
42
|
+
{ code: 'IL', name: 'Israel', dial: '+972' },
|
|
43
|
+
{ code: 'AE', name: 'United Arab Emirates', dial: '+971' },
|
|
44
|
+
{ code: 'SA', name: 'Saudi Arabia', dial: '+966' },
|
|
45
|
+
{ code: 'IN', name: 'India', dial: '+91' },
|
|
46
|
+
{ code: 'PK', name: 'Pakistan', dial: '+92' },
|
|
47
|
+
{ code: 'CN', name: 'China', dial: '+86' },
|
|
48
|
+
{ code: 'HK', name: 'Hong Kong', dial: '+852' },
|
|
49
|
+
{ code: 'JP', name: 'Japan', dial: '+81' },
|
|
50
|
+
{ code: 'KR', name: 'South Korea', dial: '+82' },
|
|
51
|
+
{ code: 'SG', name: 'Singapore', dial: '+65' },
|
|
52
|
+
{ code: 'MY', name: 'Malaysia', dial: '+60' },
|
|
53
|
+
{ code: 'TH', name: 'Thailand', dial: '+66' },
|
|
54
|
+
{ code: 'ID', name: 'Indonesia', dial: '+62' },
|
|
55
|
+
{ code: 'PH', name: 'Philippines', dial: '+63' },
|
|
56
|
+
{ code: 'VN', name: 'Vietnam', dial: '+84' },
|
|
57
|
+
{ code: 'AU', name: 'Australia', dial: '+61' },
|
|
58
|
+
{ code: 'NZ', name: 'New Zealand', dial: '+64' },
|
|
59
|
+
{ code: 'ZA', name: 'South Africa', dial: '+27' },
|
|
60
|
+
{ code: 'NG', name: 'Nigeria', dial: '+234' },
|
|
61
|
+
{ code: 'EG', name: 'Egypt', dial: '+20' },
|
|
62
|
+
{ code: 'KE', name: 'Kenya', dial: '+254' },
|
|
63
|
+
{ code: 'MA', name: 'Morocco', dial: '+212' },
|
|
64
|
+
{ code: 'BR', name: 'Brazil', dial: '+55' },
|
|
65
|
+
{ code: 'AR', name: 'Argentina', dial: '+54' },
|
|
66
|
+
{ code: 'CL', name: 'Chile', dial: '+56' },
|
|
67
|
+
{ code: 'CO', name: 'Colombia', dial: '+57' },
|
|
68
|
+
{ code: 'MX', name: 'Mexico', dial: '+52' },
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
export const COUNTRY_BY_CODE = new Map(COUNTRIES.map((c) => [c.code, c]))
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for date-core math. These are written to be *discriminating*: each
|
|
3
|
+
* asserts a value that a plausible mutation (off-by-one, wrong operator, dropped
|
|
4
|
+
* clamp) would break - not just "does not throw".
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it } from 'vitest'
|
|
7
|
+
import {
|
|
8
|
+
toDate,
|
|
9
|
+
startOfDay,
|
|
10
|
+
startOfMonth,
|
|
11
|
+
endOfMonth,
|
|
12
|
+
daysInMonth,
|
|
13
|
+
startOfWeek,
|
|
14
|
+
isSameDay,
|
|
15
|
+
isSameMonth,
|
|
16
|
+
compareDay,
|
|
17
|
+
addDays,
|
|
18
|
+
addMonths,
|
|
19
|
+
addYears,
|
|
20
|
+
clampDate,
|
|
21
|
+
isoWeek,
|
|
22
|
+
decadeRange,
|
|
23
|
+
centuryRange,
|
|
24
|
+
monthMatrix,
|
|
25
|
+
weekdayOrder,
|
|
26
|
+
withTime,
|
|
27
|
+
snapMinute,
|
|
28
|
+
} from './date-core'
|
|
29
|
+
|
|
30
|
+
describe('toDate', () => {
|
|
31
|
+
it('passes through valid Date/number/string and clones Dates', () => {
|
|
32
|
+
const d = new Date(2026, 5, 15)
|
|
33
|
+
const out = toDate(d)!
|
|
34
|
+
expect(out.getTime()).toBe(d.getTime())
|
|
35
|
+
expect(out).not.toBe(d) // clone, not same ref
|
|
36
|
+
expect(toDate(d.getTime())!.getTime()).toBe(d.getTime())
|
|
37
|
+
expect(toDate('2026-06-15T00:00:00')!.getFullYear()).toBe(2026)
|
|
38
|
+
})
|
|
39
|
+
it('returns null for null/undefined/invalid', () => {
|
|
40
|
+
expect(toDate(null)).toBeNull()
|
|
41
|
+
expect(toDate(undefined)).toBeNull()
|
|
42
|
+
expect(toDate('not-a-date')).toBeNull()
|
|
43
|
+
expect(toDate(NaN)).toBeNull()
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('startOfDay / month boundaries', () => {
|
|
48
|
+
it('strips time to local midnight', () => {
|
|
49
|
+
const d = startOfDay(new Date(2026, 2, 9, 13, 45, 30, 500))
|
|
50
|
+
expect([d.getHours(), d.getMinutes(), d.getSeconds(), d.getMilliseconds()]).toEqual([0, 0, 0, 0])
|
|
51
|
+
expect(d.getDate()).toBe(9)
|
|
52
|
+
})
|
|
53
|
+
it('startOfMonth / endOfMonth / daysInMonth', () => {
|
|
54
|
+
const mid = new Date(2026, 1, 14) // Feb 2026 (28 days)
|
|
55
|
+
expect(startOfMonth(mid).getDate()).toBe(1)
|
|
56
|
+
expect(endOfMonth(mid).getDate()).toBe(28)
|
|
57
|
+
expect(daysInMonth(mid)).toBe(28)
|
|
58
|
+
expect(daysInMonth(new Date(2024, 1, 1))).toBe(29) // leap year
|
|
59
|
+
expect(daysInMonth(new Date(2026, 0, 1))).toBe(31)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('startOfWeek', () => {
|
|
64
|
+
it('honors firstDayOfWeek=0 (Sunday)', () => {
|
|
65
|
+
// 2026-06-15 is a Monday.
|
|
66
|
+
const s = startOfWeek(new Date(2026, 5, 15), 0)
|
|
67
|
+
expect(s.getDay()).toBe(0)
|
|
68
|
+
expect(s.getDate()).toBe(14) // the Sunday before
|
|
69
|
+
})
|
|
70
|
+
it('honors firstDayOfWeek=1 (Monday)', () => {
|
|
71
|
+
const s = startOfWeek(new Date(2026, 5, 15), 1)
|
|
72
|
+
expect(s.getDay()).toBe(1)
|
|
73
|
+
expect(s.getDate()).toBe(15) // Monday itself
|
|
74
|
+
})
|
|
75
|
+
it('wraps correctly when day < firstDayOfWeek', () => {
|
|
76
|
+
// Sunday 2026-06-14 with week starting Monday -> previous Monday (Jun 8)
|
|
77
|
+
const s = startOfWeek(new Date(2026, 5, 14), 1)
|
|
78
|
+
expect(s.getDate()).toBe(8)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('sameness + comparison', () => {
|
|
83
|
+
it('isSameDay ignores time, isSameMonth ignores day', () => {
|
|
84
|
+
expect(isSameDay(new Date(2026, 5, 15, 9), new Date(2026, 5, 15, 23))).toBe(true)
|
|
85
|
+
expect(isSameDay(new Date(2026, 5, 15), new Date(2026, 5, 16))).toBe(false)
|
|
86
|
+
expect(isSameDay(null, new Date())).toBe(false)
|
|
87
|
+
expect(isSameMonth(new Date(2026, 5, 1), new Date(2026, 5, 30))).toBe(true)
|
|
88
|
+
expect(isSameMonth(new Date(2026, 5, 30), new Date(2026, 6, 1))).toBe(false)
|
|
89
|
+
})
|
|
90
|
+
it('compareDay returns -1/0/1 by day', () => {
|
|
91
|
+
expect(compareDay(new Date(2026, 5, 15, 23), new Date(2026, 5, 15, 1))).toBe(0)
|
|
92
|
+
expect(compareDay(new Date(2026, 5, 14), new Date(2026, 5, 15))).toBe(-1)
|
|
93
|
+
expect(compareDay(new Date(2026, 5, 16), new Date(2026, 5, 15))).toBe(1)
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
describe('addDays / addMonths / addYears clamping', () => {
|
|
98
|
+
it('addDays crosses month boundary', () => {
|
|
99
|
+
expect(addDays(new Date(2026, 0, 31), 1).getMonth()).toBe(1) // -> Feb 1
|
|
100
|
+
expect(addDays(new Date(2026, 5, 15), -20).getMonth()).toBe(4) // -> May
|
|
101
|
+
})
|
|
102
|
+
it('addMonths clamps day-of-month (Jan 31 + 1mo = Feb 28)', () => {
|
|
103
|
+
const r = addMonths(new Date(2026, 0, 31), 1)
|
|
104
|
+
expect(r.getMonth()).toBe(1)
|
|
105
|
+
expect(r.getDate()).toBe(28) // NOT March 3
|
|
106
|
+
})
|
|
107
|
+
it('addMonths preserves time-of-day', () => {
|
|
108
|
+
const r = addMonths(new Date(2026, 0, 15, 8, 30), 2)
|
|
109
|
+
expect([r.getHours(), r.getMinutes()]).toEqual([8, 30])
|
|
110
|
+
expect(r.getMonth()).toBe(2)
|
|
111
|
+
})
|
|
112
|
+
it('addYears clamps Feb 29 -> Feb 28 on non-leap target', () => {
|
|
113
|
+
const r = addYears(new Date(2024, 1, 29), 1)
|
|
114
|
+
expect(r.getFullYear()).toBe(2025)
|
|
115
|
+
expect(r.getMonth()).toBe(1)
|
|
116
|
+
expect(r.getDate()).toBe(28)
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('clampDate', () => {
|
|
121
|
+
const min = new Date(2026, 0, 1)
|
|
122
|
+
const max = new Date(2026, 11, 31)
|
|
123
|
+
it('clamps below min and above max, passes through inside', () => {
|
|
124
|
+
expect(clampDate(new Date(2025, 5, 1), min, max).getTime()).toBe(min.getTime())
|
|
125
|
+
expect(clampDate(new Date(2027, 5, 1), min, max).getTime()).toBe(max.getTime())
|
|
126
|
+
const inside = new Date(2026, 5, 1)
|
|
127
|
+
expect(clampDate(inside, min, max).getTime()).toBe(inside.getTime())
|
|
128
|
+
})
|
|
129
|
+
it('treats null bounds as open', () => {
|
|
130
|
+
const d = new Date(1000, 0, 1)
|
|
131
|
+
expect(clampDate(d, null, null).getTime()).toBe(d.getTime())
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
describe('isoWeek', () => {
|
|
136
|
+
it('matches known ISO week numbers', () => {
|
|
137
|
+
// 2026-01-01 is a Thursday -> ISO week 1.
|
|
138
|
+
expect(isoWeek(new Date(2026, 0, 1))).toBe(1)
|
|
139
|
+
// 2026-06-15 (Mon) -> ISO week 25.
|
|
140
|
+
expect(isoWeek(new Date(2026, 5, 15))).toBe(25)
|
|
141
|
+
// 2027-01-01 is a Friday -> belongs to ISO week 53 of 2026.
|
|
142
|
+
expect(isoWeek(new Date(2027, 0, 1))).toBe(53)
|
|
143
|
+
// 2024-12-30 (Mon) -> ISO week 1 of 2025.
|
|
144
|
+
expect(isoWeek(new Date(2024, 11, 30))).toBe(1)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe('decadeRange / centuryRange', () => {
|
|
149
|
+
it('decade block anchors to the base-10 start', () => {
|
|
150
|
+
expect(decadeRange(2026)).toEqual({ start: 2020, end: 2029 })
|
|
151
|
+
expect(decadeRange(2020)).toEqual({ start: 2020, end: 2029 })
|
|
152
|
+
expect(decadeRange(2019)).toEqual({ start: 2010, end: 2019 })
|
|
153
|
+
})
|
|
154
|
+
it('century block anchors to the base-100 start', () => {
|
|
155
|
+
expect(centuryRange(2026)).toEqual({ start: 2000, end: 2099 })
|
|
156
|
+
expect(centuryRange(1999)).toEqual({ start: 1900, end: 1999 })
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
describe('monthMatrix', () => {
|
|
161
|
+
it('produces weeks x 7 cells starting on firstDayOfWeek', () => {
|
|
162
|
+
const m = monthMatrix(new Date(2026, 5, 15), 0, 6) // June 2026, Sunday-first
|
|
163
|
+
expect(m.length).toBe(6)
|
|
164
|
+
expect(m.every((row) => row.length === 7)).toBe(true)
|
|
165
|
+
// Every first cell of a row is the configured first weekday.
|
|
166
|
+
expect(m.every((row) => row[0]!.date.getDay() === 0)).toBe(true)
|
|
167
|
+
// June 1 2026 is a Monday, so the grid starts on Sunday May 31.
|
|
168
|
+
expect(m[0]![0]!.date.getMonth()).toBe(4)
|
|
169
|
+
expect(m[0]![0]!.date.getDate()).toBe(31)
|
|
170
|
+
expect(m[0]![0]!.inMonth).toBe(false)
|
|
171
|
+
expect(m[0]![1]!.inMonth).toBe(true) // June 1
|
|
172
|
+
expect(m[0]![1]!.date.getDate()).toBe(1)
|
|
173
|
+
})
|
|
174
|
+
it('honors firstDayOfWeek=1 (Monday-first grid)', () => {
|
|
175
|
+
const m = monthMatrix(new Date(2026, 5, 15), 1, 6)
|
|
176
|
+
expect(m.every((row) => row[0]!.date.getDay() === 1)).toBe(true)
|
|
177
|
+
// June 1 2026 is Monday, so the first cell IS June 1.
|
|
178
|
+
expect(m[0]![0]!.date.getDate()).toBe(1)
|
|
179
|
+
expect(m[0]![0]!.inMonth).toBe(true)
|
|
180
|
+
})
|
|
181
|
+
it('tags cells with an ISO week number', () => {
|
|
182
|
+
const m = monthMatrix(new Date(2026, 0, 1), 1, 6)
|
|
183
|
+
expect(m[0]![0]!.week).toBe(isoWeek(m[0]![0]!.date))
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
describe('weekdayOrder', () => {
|
|
188
|
+
it('rotates the 0-6 sequence by firstDayOfWeek', () => {
|
|
189
|
+
expect(weekdayOrder(0)).toEqual([0, 1, 2, 3, 4, 5, 6])
|
|
190
|
+
expect(weekdayOrder(1)).toEqual([1, 2, 3, 4, 5, 6, 0])
|
|
191
|
+
expect(weekdayOrder(6)).toEqual([6, 0, 1, 2, 3, 4, 5])
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
describe('withTime', () => {
|
|
196
|
+
it('grafts time-of-day onto a calendar day', () => {
|
|
197
|
+
const day = new Date(2026, 5, 15)
|
|
198
|
+
const time = new Date(2000, 0, 1, 14, 25, 9, 123)
|
|
199
|
+
const r = withTime(day, time)
|
|
200
|
+
expect([r.getFullYear(), r.getMonth(), r.getDate()]).toEqual([2026, 5, 15])
|
|
201
|
+
expect([r.getHours(), r.getMinutes(), r.getSeconds(), r.getMilliseconds()]).toEqual([14, 25, 9, 123])
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
describe('snapMinute', () => {
|
|
206
|
+
it('snaps down to the nearest interval multiple', () => {
|
|
207
|
+
expect(snapMinute(0, 15)).toBe(0)
|
|
208
|
+
expect(snapMinute(14, 15)).toBe(0)
|
|
209
|
+
expect(snapMinute(15, 15)).toBe(15)
|
|
210
|
+
expect(snapMinute(59, 15)).toBe(45)
|
|
211
|
+
expect(snapMinute(37, 5)).toBe(35)
|
|
212
|
+
})
|
|
213
|
+
it('interval < 1 behaves as 1 (no snapping)', () => {
|
|
214
|
+
expect(snapMinute(37, 0)).toBe(37)
|
|
215
|
+
expect(snapMinute(37, 1)).toBe(37)
|
|
216
|
+
})
|
|
217
|
+
})
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* date-core - framework-free date math shared by SvCalendar, SvTimePicker and
|
|
3
|
+
* SvDateTimePicker. No Svelte, no DOM: pure functions over the native `Date`
|
|
4
|
+
* so it is trivially unit- and mutation-testable and reusable standalone.
|
|
5
|
+
*
|
|
6
|
+
* Conventions:
|
|
7
|
+
* - A "day" is compared by local calendar date (year/month/date), ignoring the
|
|
8
|
+
* time-of-day, unless a helper says otherwise.
|
|
9
|
+
* - `firstDayOfWeek` is 0-6 (0 = Sunday), matching `Date.getDay()` and Smart's
|
|
10
|
+
* `firstDayOfWeek`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type DateLike = Date | number | string
|
|
14
|
+
|
|
15
|
+
/** Coerce a Date | epoch-ms | parseable string to a Date, or null if invalid. */
|
|
16
|
+
export function toDate(value: DateLike | null | undefined): Date | null {
|
|
17
|
+
if (value == null) return null
|
|
18
|
+
const d = value instanceof Date ? new Date(value.getTime()) : new Date(value)
|
|
19
|
+
return isNaN(d.getTime()) ? null : d
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A new Date at local midnight of the same calendar day (time stripped). */
|
|
23
|
+
export function startOfDay(d: Date): Date {
|
|
24
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate())
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** First day (midnight) of the month `d` falls in. */
|
|
28
|
+
export function startOfMonth(d: Date): Date {
|
|
29
|
+
return new Date(d.getFullYear(), d.getMonth(), 1)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Last day (midnight) of the month `d` falls in. */
|
|
33
|
+
export function endOfMonth(d: Date): Date {
|
|
34
|
+
return new Date(d.getFullYear(), d.getMonth() + 1, 0)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Number of days in the month `d` falls in. */
|
|
38
|
+
export function daysInMonth(d: Date): number {
|
|
39
|
+
return endOfMonth(d).getDate()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Start of the week containing `d`, honoring `firstDayOfWeek` (0-6). Returns a
|
|
44
|
+
* local-midnight Date on or before `d`.
|
|
45
|
+
*/
|
|
46
|
+
export function startOfWeek(d: Date, firstDayOfWeek = 0): Date {
|
|
47
|
+
const start = startOfDay(d)
|
|
48
|
+
const diff = (start.getDay() - firstDayOfWeek + 7) % 7
|
|
49
|
+
start.setDate(start.getDate() - diff)
|
|
50
|
+
return start
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** True when both dates are the same local calendar day. */
|
|
54
|
+
export function isSameDay(a: Date | null, b: Date | null): boolean {
|
|
55
|
+
if (!a || !b) return false
|
|
56
|
+
return (
|
|
57
|
+
a.getFullYear() === b.getFullYear() &&
|
|
58
|
+
a.getMonth() === b.getMonth() &&
|
|
59
|
+
a.getDate() === b.getDate()
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** True when both dates are in the same year+month. */
|
|
64
|
+
export function isSameMonth(a: Date | null, b: Date | null): boolean {
|
|
65
|
+
if (!a || !b) return false
|
|
66
|
+
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** -1 / 0 / 1 comparison by calendar day (time-of-day ignored). */
|
|
70
|
+
export function compareDay(a: Date, b: Date): number {
|
|
71
|
+
const av = startOfDay(a).getTime()
|
|
72
|
+
const bv = startOfDay(b).getTime()
|
|
73
|
+
return av < bv ? -1 : av > bv ? 1 : 0
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Add `n` days (can be negative), returning a new Date. */
|
|
77
|
+
export function addDays(d: Date, n: number): Date {
|
|
78
|
+
const r = new Date(d.getTime())
|
|
79
|
+
r.setDate(r.getDate() + n)
|
|
80
|
+
return r
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Add `n` calendar months, clamping the day-of-month so 31 Jan + 1 month = 28/29
|
|
85
|
+
* Feb rather than spilling into March (matches how calendars paginate).
|
|
86
|
+
*/
|
|
87
|
+
export function addMonths(d: Date, n: number): Date {
|
|
88
|
+
const day = d.getDate()
|
|
89
|
+
const r = new Date(d.getFullYear(), d.getMonth() + n, 1, d.getHours(), d.getMinutes(), d.getSeconds(), d.getMilliseconds())
|
|
90
|
+
r.setDate(Math.min(day, daysInMonth(r)))
|
|
91
|
+
return r
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Add `n` years, clamping Feb-29 to Feb-28 on non-leap targets. */
|
|
95
|
+
export function addYears(d: Date, n: number): Date {
|
|
96
|
+
return addMonths(d, n * 12)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Clamp `d` to the inclusive [min, max] range (either bound may be null). */
|
|
100
|
+
export function clampDate(d: Date, min: Date | null, max: Date | null): Date {
|
|
101
|
+
if (min && d.getTime() < min.getTime()) return new Date(min.getTime())
|
|
102
|
+
if (max && d.getTime() > max.getTime()) return new Date(max.getTime())
|
|
103
|
+
return d
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* ISO 8601 week number (1-53) for `d`. Week 1 is the week containing the first
|
|
108
|
+
* Thursday of the year; weeks start Monday. Independent of `firstDayOfWeek`,
|
|
109
|
+
* matching Smart's `weekNumbers` column which is always ISO.
|
|
110
|
+
*/
|
|
111
|
+
export function isoWeek(d: Date): number {
|
|
112
|
+
// Shift to the Thursday of this week, then count weeks from Jan 1.
|
|
113
|
+
const date = startOfDay(d)
|
|
114
|
+
const day = (date.getDay() + 6) % 7 // Mon=0..Sun=6
|
|
115
|
+
date.setDate(date.getDate() - day + 3) // Thursday of this week
|
|
116
|
+
const firstThursday = new Date(date.getFullYear(), 0, 4)
|
|
117
|
+
const firstDay = (firstThursday.getDay() + 6) % 7
|
|
118
|
+
firstThursday.setDate(firstThursday.getDate() - firstDay + 3)
|
|
119
|
+
const diffMs = date.getTime() - firstThursday.getTime()
|
|
120
|
+
return 1 + Math.round(diffMs / (7 * 24 * 3600 * 1000))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The [start, end] years of the decade block that `year` belongs to, as Smart
|
|
125
|
+
* renders it: a 12-cell page running from the decade start minus 1 to plus 10
|
|
126
|
+
* (one leading + one trailing year for context). We expose the canonical
|
|
127
|
+
* decade [Y0, Y0+9] and let the view pad.
|
|
128
|
+
*/
|
|
129
|
+
export function decadeRange(year: number): { start: number; end: number } {
|
|
130
|
+
const start = Math.floor(year / 10) * 10
|
|
131
|
+
return { start, end: start + 9 }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The [start, end] years of the century block (100-year) `year` belongs to. */
|
|
135
|
+
export function centuryRange(year: number): { start: number; end: number } {
|
|
136
|
+
const start = Math.floor(year / 100) * 100
|
|
137
|
+
return { start, end: start + 99 }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export type MonthMatrixCell = {
|
|
141
|
+
date: Date
|
|
142
|
+
/** In the displayed month (vs. leading/trailing days of adjacent months). */
|
|
143
|
+
inMonth: boolean
|
|
144
|
+
/** ISO week number of the row this cell sits in. */
|
|
145
|
+
week: number
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Build a month grid of `weeks` rows x 7 columns starting at `firstDayOfWeek`,
|
|
150
|
+
* padded with the trailing days of the previous month and leading days of the
|
|
151
|
+
* next so every row is full. `weeks` defaults to 6 (the max any month needs),
|
|
152
|
+
* matching Smart's fixed-height calendar.
|
|
153
|
+
*/
|
|
154
|
+
export function monthMatrix(
|
|
155
|
+
monthDate: Date,
|
|
156
|
+
firstDayOfWeek = 0,
|
|
157
|
+
weeks = 6,
|
|
158
|
+
): MonthMatrixCell[][] {
|
|
159
|
+
const first = startOfMonth(monthDate)
|
|
160
|
+
const gridStart = startOfWeek(first, firstDayOfWeek)
|
|
161
|
+
const rows: MonthMatrixCell[][] = []
|
|
162
|
+
let cursor = gridStart
|
|
163
|
+
for (let r = 0; r < weeks; r++) {
|
|
164
|
+
const row: MonthMatrixCell[] = []
|
|
165
|
+
const weekNo = isoWeek(cursor)
|
|
166
|
+
for (let c = 0; c < 7; c++) {
|
|
167
|
+
row.push({
|
|
168
|
+
date: cursor,
|
|
169
|
+
inMonth: cursor.getMonth() === first.getMonth() && cursor.getFullYear() === first.getFullYear(),
|
|
170
|
+
week: weekNo,
|
|
171
|
+
})
|
|
172
|
+
cursor = addDays(cursor, 1)
|
|
173
|
+
}
|
|
174
|
+
rows.push(row)
|
|
175
|
+
}
|
|
176
|
+
return rows
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Ordered weekday indexes (0-6) for a header row starting at `firstDayOfWeek`.
|
|
181
|
+
* e.g. firstDayOfWeek=1 -> [1,2,3,4,5,6,0].
|
|
182
|
+
*/
|
|
183
|
+
export function weekdayOrder(firstDayOfWeek = 0): number[] {
|
|
184
|
+
return Array.from({ length: 7 }, (_, i) => (firstDayOfWeek + i) % 7)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Merge a calendar day with a time-of-day taken from `time`, returning a new Date. */
|
|
188
|
+
export function withTime(day: Date, time: Date): Date {
|
|
189
|
+
return new Date(
|
|
190
|
+
day.getFullYear(),
|
|
191
|
+
day.getMonth(),
|
|
192
|
+
day.getDate(),
|
|
193
|
+
time.getHours(),
|
|
194
|
+
time.getMinutes(),
|
|
195
|
+
time.getSeconds(),
|
|
196
|
+
time.getMilliseconds(),
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Snap a minute value to the nearest lower multiple of `interval` (>=1). */
|
|
201
|
+
export function snapMinute(minute: number, interval: number): number {
|
|
202
|
+
const step = Math.max(1, Math.floor(interval))
|
|
203
|
+
return Math.floor(minute / step) * step
|
|
204
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the date-format token engine. Emphasis on:
|
|
3
|
+
* - each token formatting exactly (a mutated case label would break these)
|
|
4
|
+
* - format -> parse -> format round-trips across masks and locales
|
|
5
|
+
* - parse rejecting malformed / impossible input (not silently rolling over)
|
|
6
|
+
* - yearCutoff pivot for 2-digit years
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from 'vitest'
|
|
9
|
+
import { formatDate, parseDate, tokenizeMask } from './date-format'
|
|
10
|
+
|
|
11
|
+
const D = new Date(2026, 5, 7, 9, 4, 3, 50) // 2026-06-07 09:04:03.050 (a Sunday)
|
|
12
|
+
|
|
13
|
+
describe('tokenizeMask', () => {
|
|
14
|
+
it('splits tokens from literals and honors quotes + escapes', () => {
|
|
15
|
+
expect(tokenizeMask('yyyy-MM-dd')).toEqual([
|
|
16
|
+
{ token: 'yyyy' }, { literal: '-' }, { token: 'MM' }, { literal: '-' }, { token: 'dd' },
|
|
17
|
+
])
|
|
18
|
+
expect(tokenizeMask("yyyy'T'HH")).toEqual([{ token: 'yyyy' }, { literal: 'T' }, { token: 'HH' }])
|
|
19
|
+
expect(tokenizeMask('HH\\h mm')).toEqual([
|
|
20
|
+
{ token: 'HH' }, { literal: 'h' }, { literal: ' ' }, { token: 'mm' },
|
|
21
|
+
])
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('formatDate - individual tokens', () => {
|
|
26
|
+
it('day / month / year widths', () => {
|
|
27
|
+
expect(formatDate(D, 'd')).toBe('7')
|
|
28
|
+
expect(formatDate(D, 'dd')).toBe('07')
|
|
29
|
+
expect(formatDate(D, 'M')).toBe('6')
|
|
30
|
+
expect(formatDate(D, 'MM')).toBe('06')
|
|
31
|
+
expect(formatDate(D, 'yy')).toBe('26')
|
|
32
|
+
expect(formatDate(D, 'yyyy')).toBe('2026')
|
|
33
|
+
})
|
|
34
|
+
it('24h vs 12h hours', () => {
|
|
35
|
+
expect(formatDate(D, 'H')).toBe('9')
|
|
36
|
+
expect(formatDate(D, 'HH')).toBe('09')
|
|
37
|
+
expect(formatDate(D, 'h')).toBe('9')
|
|
38
|
+
expect(formatDate(new Date(2026, 0, 1, 13), 'h')).toBe('1')
|
|
39
|
+
expect(formatDate(new Date(2026, 0, 1, 0), 'hh')).toBe('12') // midnight -> 12
|
|
40
|
+
})
|
|
41
|
+
it('minute / second / fractional seconds', () => {
|
|
42
|
+
expect(formatDate(D, 'mm')).toBe('04')
|
|
43
|
+
expect(formatDate(D, 'ss')).toBe('03')
|
|
44
|
+
expect(formatDate(D, 'fff')).toBe('050')
|
|
45
|
+
expect(formatDate(D, 'ff')).toBe('05')
|
|
46
|
+
expect(formatDate(D, 'f')).toBe('0')
|
|
47
|
+
})
|
|
48
|
+
it('AM/PM designator (en-US)', () => {
|
|
49
|
+
expect(formatDate(new Date(2026, 0, 1, 9), 'tt', 'en-US')).toBe('AM')
|
|
50
|
+
expect(formatDate(new Date(2026, 0, 1, 21), 'tt', 'en-US')).toBe('PM')
|
|
51
|
+
expect(formatDate(new Date(2026, 0, 1, 21), 't', 'en-US')).toBe('P')
|
|
52
|
+
})
|
|
53
|
+
it('localized month + weekday names', () => {
|
|
54
|
+
expect(formatDate(D, 'MMM', 'en-US')).toBe('Jun')
|
|
55
|
+
expect(formatDate(D, 'MMMM', 'en-US')).toBe('June')
|
|
56
|
+
expect(formatDate(D, 'dddd', 'en-US')).toBe('Sunday')
|
|
57
|
+
// German month name (partial to survive ICU differences).
|
|
58
|
+
expect(formatDate(D, 'MMMM', 'de-DE').toLowerCase()).toContain('juni')
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe('formatDate - full masks', () => {
|
|
63
|
+
it('Smart default-style masks', () => {
|
|
64
|
+
expect(formatDate(D, 'dd-MMM-yy HH:mm:ss.fff', 'en-US')).toBe('07-Jun-26 09:04:03.050')
|
|
65
|
+
expect(formatDate(D, 'yyyy-MM-dd')).toBe('2026-06-07')
|
|
66
|
+
expect(formatDate(new Date(2026, 0, 1, 13, 5), 'hh:mm tt', 'en-US')).toBe('01:05 PM')
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('parseDate', () => {
|
|
71
|
+
it('parses a full datetime mask', () => {
|
|
72
|
+
const d = parseDate('07-Jun-26 09:04:03.050', 'dd-MMM-yy HH:mm:ss.fff', 'en-US')!
|
|
73
|
+
expect(d.getFullYear()).toBe(2026)
|
|
74
|
+
expect(d.getMonth()).toBe(5)
|
|
75
|
+
expect(d.getDate()).toBe(7)
|
|
76
|
+
expect([d.getHours(), d.getMinutes(), d.getSeconds(), d.getMilliseconds()]).toEqual([9, 4, 3, 50])
|
|
77
|
+
})
|
|
78
|
+
it('resolves 12h + PM into 24h', () => {
|
|
79
|
+
const d = parseDate('01:05 PM', 'hh:mm tt', 'en-US')!
|
|
80
|
+
expect(d.getHours()).toBe(13)
|
|
81
|
+
expect(d.getMinutes()).toBe(5)
|
|
82
|
+
const am = parseDate('12:30 AM', 'hh:mm tt', 'en-US')!
|
|
83
|
+
expect(am.getHours()).toBe(0) // 12 AM -> 00
|
|
84
|
+
})
|
|
85
|
+
it('applies yearCutoff pivot for 2-digit years', () => {
|
|
86
|
+
// cutoff 2029 -> pivot 29: '29' -> 2029, '30' -> 1930
|
|
87
|
+
expect(parseDate('29', 'yy', 'en-US', 2029)!.getFullYear()).toBe(2029)
|
|
88
|
+
expect(parseDate('30', 'yy', 'en-US', 2029)!.getFullYear()).toBe(1930)
|
|
89
|
+
})
|
|
90
|
+
it('rejects malformed and impossible input', () => {
|
|
91
|
+
expect(parseDate('2026/06/07', 'yyyy-MM-dd')).toBeNull() // wrong separators
|
|
92
|
+
expect(parseDate('2026-13-01', 'yyyy-MM-dd')).toBeNull() // month 13
|
|
93
|
+
expect(parseDate('2026-02-31', 'yyyy-MM-dd')).toBeNull() // Feb 31 rollover
|
|
94
|
+
expect(parseDate('', 'yyyy-MM-dd')).toBeNull()
|
|
95
|
+
expect(parseDate('2026-06-07 extra', 'yyyy-MM-dd')).toBeNull() // trailing junk
|
|
96
|
+
})
|
|
97
|
+
it('accepts trailing whitespace and flexible spaces', () => {
|
|
98
|
+
expect(parseDate('2026-06-07 ', 'yyyy-MM-dd')).not.toBeNull()
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('round-trip: format -> parse -> format', () => {
|
|
103
|
+
const masks = ['yyyy-MM-dd', 'dd-MMM-yy HH:mm:ss.fff', 'dd/MM/yyyy HH:mm', 'hh:mm:ss tt', 'MMMM d, yyyy']
|
|
104
|
+
const samples = [
|
|
105
|
+
new Date(2026, 5, 7, 9, 4, 3, 50),
|
|
106
|
+
new Date(2000, 0, 1, 0, 0, 0, 0),
|
|
107
|
+
new Date(1999, 11, 31, 23, 59, 59, 999),
|
|
108
|
+
new Date(2024, 1, 29, 13, 30, 0, 0), // leap day, PM
|
|
109
|
+
]
|
|
110
|
+
for (const mask of masks) {
|
|
111
|
+
for (const sample of samples) {
|
|
112
|
+
it(`stable for "${mask}" @ ${sample.toISOString()}`, () => {
|
|
113
|
+
const s1 = formatDate(sample, mask, 'en-US')
|
|
114
|
+
const parsed = parseDate(s1, mask, 'en-US', 2029, sample)
|
|
115
|
+
expect(parsed).not.toBeNull()
|
|
116
|
+
const s2 = formatDate(parsed!, mask, 'en-US')
|
|
117
|
+
expect(s2).toBe(s1)
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
})
|