import { expect, fixture, html, oneEvent } from '@open-wc/testing'; import './nile-timestamp-builder'; import type { NileTimestampBuilder } from './nile-timestamp-builder'; import { clampField, clampWallClock, daysInMonth, epochToFields, fieldsToEpoch, formatWallClock, getLocalTimeZone, getTimezoneList, hasTemporal, isValidTimeZone, normalizeValueInput, parseZonedValue, } from './nile-timestamp-builder-utils'; const make = (attrs = '') => fixture( html`` ); /** Dispatch a nile-change carrying a STRING value on the nth inner nile-input. */ function changeField(el: NileTimestampBuilder, index: number, value: number) { const inputs = el.shadowRoot!.querySelectorAll('nile-input'); const input = inputs[index]; input.dispatchEvent( new CustomEvent('nile-change', { detail: { value: String(value) }, bubbles: true, composed: true, }) ); } // Field order in the expanded view. const FIELD = { year: 0, month: 1, day: 2, hour: 3, minute: 4, second: 5, millisecond: 6, }; describe('NileTimestampBuilder', () => { describe('rendering & defaults', () => { it('renders', async () => { const el = await make(); expect(el).to.exist; expect(el.shadowRoot).to.not.be.null; }); it('is defined', () => { expect(customElements.get('nile-timestamp-builder')).to.exist; }); it('tag name', async () => { const el = await make(); expect(el.tagName.toLowerCase()).to.equal('nile-timestamp-builder'); }); it('has styles', async () => { const mod = await import('./nile-timestamp-builder'); expect(mod.NileTimestampBuilder.styles).to.exist; }); it('defaults', async () => { const el = await make(); expect(el.value).to.equal(null); expect(el.open).to.equal(false); expect(el.minYear).to.equal(1); expect(el.maxYear).to.equal(9999); expect(el.timezone).to.be.a('string'); }); it('resolves the timezone list once on connect', async () => { const el = await make(); expect(el.timezoneList.length).to.be.greaterThan(0); }); it('collapsed view shows a placeholder when empty', async () => { const el = await make(); const value = el.shadowRoot!.querySelector('[part="value"]'); expect(value?.textContent?.trim()).to.equal('No timestamp set'); }); }); describe('view toggling', () => { it('Edit expands, Done collapses', async () => { const el = await make(); (el as any).handleEdit(); await el.updateComplete; expect(el.open).to.be.true; expect(el.shadowRoot!.querySelector('[part="editor"]')).to.exist; (el as any).handleDone(); await el.updateComplete; expect(el.open).to.be.false; expect(el.shadowRoot!.querySelector('[part="display"]')).to.exist; }); it('readonly never expands', async () => { const el = await make(); el.readonly = true; await el.updateComplete; (el as any).handleEdit(); await el.updateComplete; expect(el.open).to.be.false; expect(el.shadowRoot!.querySelector('[part="editor"]')).to.not.exist; }); describe('copyable', () => { let written: string[]; let original: unknown; beforeEach(() => { written = []; original = (navigator as any).clipboard; Object.defineProperty(navigator, 'clipboard', { value: { writeText: (t: string) => { written.push(t); return Promise.resolve(); }, }, configurable: true, }); }); afterEach(() => { Object.defineProperty(navigator, 'clipboard', { value: original, configurable: true, }); }); it('stays hidden unless copyable is passed', async () => { const el = await make(); el.readonly = true; await el.updateComplete; expect(el.shadowRoot!.querySelector('[part="actions"]')).to.not.exist; }); it('shows a single Copy action when readonly and copyable', async () => { const el = await make(); el.readonly = true; el.copyable = true; await el.updateComplete; const buttons = el.shadowRoot!.querySelectorAll('nile-button'); expect(buttons.length).to.equal(1); // Icon only — the accessible name carries the meaning, not text. expect(buttons[0].textContent!.trim()).to.equal(''); expect(buttons[0].getAttribute('aria-label')).to.equal( 'Copy timestamp' ); expect( buttons[0].querySelector('nile-glyph')!.getAttribute('name') ).to.equal('ng-copy'); }); it('does not show Copy when disabled', async () => { const el = await make(); el.value = new Date(); el.disabled = true; el.copyable = true; await el.updateComplete; expect(el.shadowRoot!.querySelector('[part="actions"]')).to.not.exist; }); it('does not show Copy when disabled and readonly together', async () => { const el = await make(); el.value = new Date(); el.disabled = true; el.readonly = true; el.copyable = true; await el.updateComplete; expect(el.shadowRoot!.querySelector('[part="actions"]')).to.not.exist; }); it('does not show Copy while the value is editable', async () => { const el = await make(); el.copyable = true; await el.updateComplete; expect(el.shadowRoot!.querySelectorAll('nile-button').length).to.equal( 2 ); }); it('writes the canonical zoned value to the clipboard', async () => { const el = await make(); el.value = new Date(Date.UTC(2026, 2, 14, 4, 11, 0)); el.timezone = 'UTC'; el.readonly = true; el.copyable = true; await el.updateComplete; await (el as any).handleCopy(); expect(written.length).to.equal(1); expect(written[0]).to.contain('2026-03-14'); }); it('emits nile-copy with the copied value', async () => { const el = await make(); el.value = new Date(Date.UTC(2026, 2, 14, 4, 11, 0)); el.readonly = true; el.copyable = true; await el.updateComplete; setTimeout(() => (el as any).handleCopy()); const evt = await oneEvent(el, 'nile-copy'); expect(evt.detail.value).to.be.a('string'); }); it('confirms with a checked icon and an updated accessible name', async () => { const el = await make(); el.value = new Date(); el.readonly = true; el.copyable = true; await el.updateComplete; await (el as any).handleCopy(); await el.updateComplete; expect(el.justCopied).to.be.true; const button = el.shadowRoot!.querySelector('nile-button')!; expect(button.getAttribute('aria-label')).to.equal('Copied'); expect( button.querySelector('nile-glyph')!.getAttribute('name') ).to.equal('ng-copy-check'); }); it('disables Copy when there is no value to copy', async () => { const el = await make(); el.readonly = true; el.copyable = true; await el.updateComplete; expect(el.hasValue).to.be.false; const button = el.shadowRoot!.querySelector('nile-button')!; expect(button.hasAttribute('disabled')).to.be.true; await (el as any).handleCopy(); expect(written.length).to.equal(0); }); it('emits nile-copy-error when the clipboard rejects', async () => { Object.defineProperty(navigator, 'clipboard', { value: { writeText: () => Promise.reject(new Error('denied')) }, configurable: true, }); const el = await make(); el.value = new Date(); el.readonly = true; el.copyable = true; await el.updateComplete; setTimeout(() => (el as any).handleCopy()); const evt = await oneEvent(el, 'nile-copy-error'); expect(evt.detail.value).to.be.a('string'); expect(el.justCopied).to.be.false; }); }); it('keeps the collapsed row the same height with and without actions', async () => { // The token stylesheet is not loaded under test, so the one length this // assertion depends on is supplied explicitly. It inherits into the // shadow root like a custom property normally would. const withHeightToken = async (mutate: (el: NileTimestampBuilder) => void) => { const el = await make(); el.style.setProperty('--ng-height-28px', '28px'); el.value = new Date(); mutate(el); await el.updateComplete; return el.shadowRoot!.querySelector('.tsb__collapsed') as HTMLElement; }; const withActions = await withHeightToken(() => {}); const readonlyRow = await withHeightToken(el => { el.readonly = true; }); const disabledRow = await withHeightToken(el => { el.disabled = true; }); expect(withActions.offsetHeight).to.equal(readonlyRow.offsetHeight); expect(withActions.offsetHeight).to.equal(disabledRow.offsetHeight); }); it('renders no actions when readonly', async () => { const el = await make(); el.readonly = true; await el.updateComplete; expect(el.shadowRoot!.querySelector('[part="actions"]')).to.not.exist; expect(el.shadowRoot!.querySelectorAll('nile-button').length).to.equal(0); }); it('renders no actions when disabled', async () => { const el = await make(); el.disabled = true; await el.updateComplete; expect(el.shadowRoot!.querySelector('[part="actions"]')).to.not.exist; }); it('renders Now and Edit when editable', async () => { const el = await make(); await el.updateComplete; expect(el.shadowRoot!.querySelector('[part="actions"]')).to.exist; expect(el.shadowRoot!.querySelectorAll('nile-button').length).to.equal(2); }); it('marks the readonly display as focusable and aria-readonly', async () => { const el = await make(); el.readonly = true; await el.updateComplete; const display = el.shadowRoot!.querySelector('.tsb__display')!; expect(display.getAttribute('tabindex')).to.equal('0'); expect(display.getAttribute('aria-readonly')).to.equal('true'); }); it('drops the display from the tab order when disabled', async () => { const el = await make(); el.disabled = true; await el.updateComplete; const display = el.shadowRoot!.querySelector('.tsb__display')!; expect(display.hasAttribute('tabindex')).to.be.false; }); it('emits nile-expand / nile-collapse', async () => { const el = await make(); setTimeout(() => (el as any).handleEdit()); const expandEvt = await oneEvent(el, 'nile-expand'); expect(expandEvt.detail.open).to.be.true; setTimeout(() => (el as any).handleDone()); const collapseEvt = await oneEvent(el, 'nile-collapse'); expect(collapseEvt.detail.open).to.be.false; }); const display = (el: NileTimestampBuilder) => el.shadowRoot!.querySelector('.tsb__display') as HTMLElement; const tap = (el: NileTimestampBuilder) => display(el).dispatchEvent( new PointerEvent('pointerup', { pointerType: 'touch', bubbles: true, composed: true, }) ); it('double-click on the display expands', async () => { const el = await make(); display(el).dispatchEvent( new MouseEvent('dblclick', { bubbles: true, composed: true }) ); await el.updateComplete; expect(el.open).to.be.true; }); it('double-tap on the display expands', async () => { const el = await make(); tap(el); tap(el); await el.updateComplete; expect(el.open).to.be.true; }); it('a single tap does not expand', async () => { const el = await make(); tap(el); await el.updateComplete; expect(el.open).to.be.false; }); it('two taps further apart than the double-tap window do not expand', async () => { const el = await make(); tap(el); (el as any).lastTapAt -= 1000; tap(el); await el.updateComplete; expect(el.open).to.be.false; }); it('readonly and disabled ignore double-click and double-tap', async () => { for (const flag of ['readonly', 'disabled'] as const) { const el = await make(); el[flag] = true; await el.updateComplete; display(el).dispatchEvent( new MouseEvent('dblclick', { bubbles: true, composed: true }) ); tap(el); tap(el); await el.updateComplete; expect(el.open, flag).to.be.false; } }); }); describe('fields <-> epoch', () => { it('derives fields from an external value', async () => { const el = await make(); el.timezone = 'UTC'; el.value = Date.UTC(2022, 4, 15, 13, 45, 30, 250); await el.updateComplete; expect(el.wallClock.year).to.equal(2022); expect(el.wallClock.month).to.equal(5); expect(el.wallClock.day).to.equal(15); expect(el.wallClock.hour).to.equal(13); expect(el.wallClock.minute).to.equal(45); expect(el.wallClock.second).to.equal(30); expect(el.wallClock.millisecond).to.equal(250); expect(el.hasValue).to.be.true; }); it('field change payload is coerced to a Number', async () => { const el = await make(); el.timezone = 'UTC'; (el as any).handleEdit(); await el.updateComplete; changeField(el, FIELD.hour, 9); await el.updateComplete; expect(el.wallClock.hour).to.equal(9); expect(el.wallClock.hour).to.be.a('number'); // Field edits stage only; commit via Done before checking the value. (el as any).handleDone(); await el.updateComplete; expect(el.value).to.be.an.instanceOf(Date); }); it('round trips fields to epoch and back', async () => { const el = await make(); el.timezone = 'UTC'; const epoch = Date.UTC(2021, 5, 15, 12, 30, 45, 123); el.value = epoch; await el.updateComplete; (el as any).handleEdit(); await el.updateComplete; // Re-stage the same field, then commit; epoch must be preserved. changeField(el, FIELD.second, 45); await el.updateComplete; (el as any).handleDone(); await el.updateComplete; expect(el.value?.getTime()).to.equal(epoch); }); }); describe('clamping', () => { async function seeded() { const el = await make(); el.timezone = 'UTC'; el.value = Date.UTC(2023, 0, 15, 10, 0, 0, 0); await el.updateComplete; (el as any).handleEdit(); await el.updateComplete; return el; } it('clamps month to 1-12', async () => { const el = await seeded(); changeField(el, FIELD.month, 13); await el.updateComplete; expect(el.wallClock.month).to.equal(12); }); it('clamps day to leap-aware month length', async () => { const el = await seeded(); el.value = Date.UTC(2024, 1, 10, 0, 0, 0, 0); // Feb 2024 (leap) await el.updateComplete; changeField(el, FIELD.day, 30); await el.updateComplete; expect(el.wallClock.day).to.equal(29); }); it('clamps day for a non-leap February', async () => { const el = await seeded(); el.value = Date.UTC(2023, 1, 10, 0, 0, 0, 0); // Feb 2023 await el.updateComplete; changeField(el, FIELD.day, 31); await el.updateComplete; expect(el.wallClock.day).to.equal(28); }); it('clamps hour/minute/second/ms', async () => { const el = await seeded(); changeField(el, FIELD.hour, 30); changeField(el, FIELD.minute, 99); changeField(el, FIELD.second, 61); changeField(el, FIELD.millisecond, 1500); await el.updateComplete; expect(el.wallClock.hour).to.equal(23); expect(el.wallClock.minute).to.equal(59); expect(el.wallClock.second).to.equal(59); expect(el.wallClock.millisecond).to.equal(999); }); it('clamps year to min/max', async () => { const el = await seeded(); el.minYear = 2000; el.maxYear = 2030; await el.updateComplete; changeField(el, FIELD.year, 3000); await el.updateComplete; expect(el.wallClock.year).to.equal(2030); changeField(el, FIELD.year, 1000); await el.updateComplete; expect(el.wallClock.year).to.equal(2000); }); it('recaps day when switching to a shorter month', async () => { const el = await seeded(); changeField(el, FIELD.day, 31); // January has 31 days await el.updateComplete; expect(el.wallClock.day).to.equal(31); changeField(el, FIELD.month, 4); // April has 30 await el.updateComplete; expect(el.wallClock.day).to.equal(30); }); }); describe('timezone behaviour', () => { it('switching timezone keeps fields and changes epoch', async () => { const el = await make(); el.timezone = 'UTC'; el.value = Date.UTC(2022, 0, 1, 12, 0, 0, 0); await el.updateComplete; const before = { y: el.wallClock.year, mo: el.wallClock.month, d: el.wallClock.day, h: el.wallClock.hour, epoch: el.value?.getTime(), }; const select = el.shadowRoot!.querySelector('nile-select'); // ensure select exists (expanded not required for handler) (el as any).open = true; await el.updateComplete; const tzSelect = el.shadowRoot!.querySelector('nile-select')!; tzSelect.dispatchEvent( new CustomEvent('nile-change', { detail: { value: 'Asia/Kolkata' }, bubbles: true, composed: true, }) ); await el.updateComplete; // Timezone selection stages only — commit via Done to see the new epoch. (el as any).handleDone(); await el.updateComplete; expect(select || tzSelect).to.exist; expect(el.wallClock.year).to.equal(before.y); expect(el.wallClock.month).to.equal(before.mo); expect(el.wallClock.day).to.equal(before.d); expect(el.wallClock.hour).to.equal(before.h); expect(el.value?.getTime()).to.not.equal(before.epoch); }); it('Use UTC sets timezone to UTC and keeps fields', async () => { const el = await make(); el.timezone = 'Asia/Kolkata'; el.value = Date.now(); await el.updateComplete; const y = el.wallClock.year; (el as any).handleUseUtc(); await el.updateComplete; expect(el.timezone).to.equal('UTC'); expect(el.wallClock.year).to.equal(y); }); it('Use Local Timezone sets a valid tz', async () => { const el = await make(); el.timezone = 'UTC'; el.value = Date.now(); await el.updateComplete; (el as any).handleUseLocalTz(); await el.updateComplete; expect(isValidTimeZone(el.timezone)).to.be.true; }); }); describe('set to now', () => { it('sets value near Date.now() and emits nile-change', async () => { const el = await make(); setTimeout(() => (el as any).handleSetNow()); const evt = await oneEvent(el, 'nile-change'); // Emitted detail matches the date-time picker contract: a zone-encoded // string value plus the absolute instant as a Date. expect(evt.detail.value).to.be.a('string'); expect(evt.detail.value).to.match(/^\d{4}-\d{2}-\d{2}T.*\[GMT[+-]\d{2}:\d{2} .+\]$/); expect(evt.detail.localTimezoneValue).to.be.an.instanceOf(Date); expect(evt.detail.timezone).to.be.a('string'); expect(Math.abs(el.value!.getTime() - Date.now())).to.be.lessThan(5000); expect(el.hasValue).to.be.true; }); }); describe('emit contract (date-time picker parity)', () => { it('emits a zone-encoded value string carrying the selected offset + zone, only on Done', async () => { const el = await make(); el.timezone = 'Asia/Yerevan'; // UTC+4, no DST el.value = Date.UTC(2030, 3, 7, 17, 8, 56, 892); // 21:08:56.892 wall-clock (el as any).open = true; // fields only render in the expanded view await el.updateComplete; // Editing a field only stages it — no nile-change until Done. let changeFired = false; el.addEventListener('nile-change', () => (changeFired = true)); changeField(el, FIELD.second, 56); // same value; re-stages the field await el.updateComplete; expect(changeFired).to.be.false; const listener = oneEvent(el, 'nile-change'); (el as any).handleDone(); const evt = await listener; expect(evt.detail.value).to.equal( '2030-04-07T21:08:56.892[GMT+04:00 Asia/Yerevan]' ); expect(evt.detail.localTimezoneValue).to.be.an.instanceOf(Date); // localTimezoneValue is the absolute instant behind the wall-clock, and it // must agree exactly with the epoch — no roundtrip drift. expect((evt.detail.localTimezoneValue as Date).getTime()).to.equal( Date.UTC(2030, 3, 7, 17, 8, 56, 892) ); expect(el.value?.getTime()).to.equal(Date.UTC(2030, 3, 7, 17, 8, 56, 892)); expect(evt.detail.timezone).to.equal('Asia/Yerevan'); }); it('labels a UTC value as [GMT+00:00 UTC]', async () => { const el = await make(); el.timezone = 'UTC'; el.value = Date.UTC(2030, 3, 7, 17, 8, 56, 892); (el as any).open = true; await el.updateComplete; changeField(el, FIELD.second, 56); await el.updateComplete; const listener = oneEvent(el, 'nile-change'); (el as any).handleDone(); const evt = await listener; expect(evt.detail.value).to.equal( '2030-04-07T17:08:56.892[GMT+00:00 UTC]' ); }); it('selecting a timezone stages only — no nile-change until Done', async () => { const el = await make(); el.timezone = 'UTC'; el.value = Date.UTC(2022, 0, 1, 12, 0, 0, 0); (el as any).open = true; await el.updateComplete; let changeFired = false; el.addEventListener('nile-change', () => (changeFired = true)); const tzSelect = el.shadowRoot!.querySelector('nile-select')!; tzSelect.dispatchEvent( new CustomEvent('nile-change', { detail: { value: 'Asia/Kolkata' }, bubbles: true, composed: true, }) ); await el.updateComplete; expect(el.timezone).to.equal('Asia/Kolkata'); expect(changeFired).to.be.false; expect(el.value?.getTime()).to.equal(Date.UTC(2022, 0, 1, 12, 0, 0, 0)); // unchanged const listener = oneEvent(el, 'nile-change'); (el as any).handleDone(); const evt = await listener; expect(evt.detail.timezone).to.equal('Asia/Kolkata'); expect(el.value?.getTime()).to.not.equal(Date.UTC(2022, 0, 1, 12, 0, 0, 0)); }); it('field edits emit live nile-input without committing', async () => { const el = await make(); el.timezone = 'UTC'; el.value = Date.UTC(2022, 0, 1, 12, 0, 0, 0); (el as any).open = true; await el.updateComplete; const listener = oneEvent(el, 'nile-input'); changeField(el, FIELD.hour, 9); const evt = await listener; expect(evt.detail.value).to.be.a('string'); expect(evt.detail.localTimezoneValue).to.be.an.instanceOf(Date); // The stored value has NOT committed yet. expect(el.value?.getTime()).to.equal(Date.UTC(2022, 0, 1, 12, 0, 0, 0)); }); }); describe('value accepts Date, number, or string, stores a Date', () => { it('accepts an ISO string via property assignment', async () => { const el = await make(); el.value = '2026-07-15T00:00:00.000Z'; await el.updateComplete; expect(el.value?.getTime()).to.equal(Date.UTC(2026, 6, 15, 0, 0, 0, 0)); }); it('accepts a numeric string via property assignment', async () => { const el = await make(); const epoch = Date.UTC(2026, 6, 15, 0, 0, 0, 0); el.value = String(epoch); await el.updateComplete; expect(el.value?.getTime()).to.equal(epoch); }); it('accepts a Date via property assignment', async () => { const el = await make(); const date = new Date(Date.UTC(2026, 6, 15, 0, 0, 0, 0)); el.value = date; await el.updateComplete; expect(el.value).to.be.an.instanceOf(Date); expect(el.value?.getTime()).to.equal(date.getTime()); }); it('accepts an ISO string via the value attribute', async () => { const el = await fixture( html`` ); expect(el.value?.getTime()).to.equal(Date.UTC(2026, 6, 15, 0, 0, 0, 0)); }); it('collapses an unparseable string to null', async () => { const el = await make(); el.value = 'not-a-date'; await el.updateComplete; expect(el.value).to.equal(null); }); it('collapses an empty string to null', async () => { const el = await make(); el.value = Date.now(); await el.updateComplete; el.value = ''; await el.updateComplete; expect(el.value).to.equal(null); }); }); describe('empty-expand seeding', () => { it('seeds fields on Edit but does NOT emit until Done', async () => { const el = await make(); let emitted = false; el.addEventListener('nile-change', () => (emitted = true)); (el as any).handleEdit(); await el.updateComplete; expect(el.value).to.equal(null); expect(el.hasValue).to.be.false; expect(el.wallClock.year).to.be.greaterThan(1970); expect(emitted).to.be.false; (el as any).handleDone(); await el.updateComplete; expect(emitted).to.be.true; expect(el.value).to.be.an.instanceOf(Date); expect(el.hasValue).to.be.true; }); }); describe('external value = null clears', () => { it('clears state when value set back to null', async () => { const el = await make(); el.timezone = 'UTC'; el.value = Date.now(); await el.updateComplete; expect(el.hasValue).to.be.true; el.value = null; await el.updateComplete; expect(el.hasValue).to.be.false; const hidden = el.shadowRoot!.querySelector('.value-input')!; expect(hidden.value).to.equal(''); }); }); describe('form association', () => { it('FormData contains name -> ISO string', async () => { const epoch = Date.UTC(2020, 0, 1, 0, 0, 0, 0); const form = await fixture(html`
`); const el = form.querySelector( 'nile-timestamp-builder' )!; await el.updateComplete; const data = new FormData(form); expect(data.get('ts')).to.equal(new Date(epoch).toISOString()); }); it('required + null is invalid', async () => { const el = await make(); el.required = true; el.value = null; await el.updateComplete; expect(el.checkValidity()).to.be.false; }); it('required + value is valid', async () => { const el = await make(); el.required = true; el.value = Date.now(); await el.updateComplete; expect(el.checkValidity()).to.be.true; }); it('disabled control is excluded from FormData', async () => { const epoch = Date.UTC(2020, 0, 1, 0, 0, 0, 0); const form = await fixture(html`
`); const el = form.querySelector( 'nile-timestamp-builder' )!; await el.updateComplete; const data = new FormData(form); expect(data.get('ts')).to.equal(null); }); it('reset restores the default value', async () => { const form = await fixture(html`
`); const el = form.querySelector( 'nile-timestamp-builder' )!; await el.updateComplete; el.value = 5000; await el.updateComplete; form.reset(); await el.updateComplete; expect(el.value?.getTime()).to.equal(1000); }); }); describe('robustness', () => { it('falls back to a valid tz when given junk', async () => { const el = await fixture( html`` ); await el.updateComplete; expect(isValidTimeZone(el.timezone)).to.be.true; }); }); }); describe('nile-timestamp-builder utils', () => { it('daysInMonth is leap-aware', () => { expect(daysInMonth(2024, 2)).to.equal(29); expect(daysInMonth(2023, 2)).to.equal(28); expect(daysInMonth(2024, 4)).to.equal(30); expect(daysInMonth(2024, 1)).to.equal(31); expect(daysInMonth(2024, 12)).to.equal(31); }); it('clampField collapses NaN to min', () => { expect(clampField(NaN, 0, 10)).to.equal(0); expect(clampField(5, 0, 10)).to.equal(5); expect(clampField(-3, 0, 10)).to.equal(0); expect(clampField(99, 0, 10)).to.equal(10); }); it('clampWallClock caps day using year+month', () => { const clamped = clampWallClock( { year: 2023, month: 2, day: 31, hour: 25, minute: 61, second: 61, millisecond: 1001, }, 1, 9999 ); expect(clamped.day).to.equal(28); expect(clamped.hour).to.equal(23); expect(clamped.minute).to.equal(59); expect(clamped.second).to.equal(59); expect(clamped.millisecond).to.equal(999); }); it('getTimezoneList is non-empty', () => { expect(getTimezoneList().length).to.be.greaterThan(0); }); it('getLocalTimeZone is valid', () => { expect(isValidTimeZone(getLocalTimeZone())).to.be.true; }); it('isValidTimeZone', () => { expect(isValidTimeZone('UTC')).to.be.true; expect(isValidTimeZone('America/New_York')).to.be.true; expect(isValidTimeZone('Not/AZone')).to.be.false; expect(isValidTimeZone('')).to.be.false; }); it('hasTemporal returns a boolean', () => { expect(hasTemporal()).to.be.a('boolean'); }); describe('normalizeValueInput', () => { it('converts a number to a Date at that instant', () => { const result = normalizeValueInput(1712500000000); expect(result).to.be.an.instanceOf(Date); expect(result?.getTime()).to.equal(1712500000000); }); it('parses a numeric string as a Date at that epoch ms', () => { expect(normalizeValueInput('1712500000000')?.getTime()).to.equal( 1712500000000 ); }); it('parses an ISO string as a Date', () => { expect(normalizeValueInput('2026-07-15T00:00:00.000Z')?.getTime()).to.equal( Date.UTC(2026, 6, 15, 0, 0, 0, 0) ); }); it('passes a Date through unchanged', () => { const d = new Date(1712500000000); expect(normalizeValueInput(d)).to.equal(d); }); it('collapses null, undefined, empty string, and NaN to null', () => { expect(normalizeValueInput(null)).to.equal(null); expect(normalizeValueInput(undefined)).to.equal(null); expect(normalizeValueInput('')).to.equal(null); expect(normalizeValueInput(' ')).to.equal(null); expect(normalizeValueInput(NaN)).to.equal(null); }); it('collapses an unparseable string to null', () => { expect(normalizeValueInput('not-a-date')).to.equal(null); }); }); describe('parseZonedValue offset/timezone consistency', () => { it('accepts an offset that matches the zone at that instant', () => { const parsed = parseZonedValue('2026-08-04T12:00:00[GMT+02:00 Europe/Paris]'); expect(parsed).to.not.equal(null); expect(parsed!.timezone).to.equal('Europe/Paris'); // 12:00 at +02:00 is 10:00Z — the wall-clock the string displays. expect(parsed!.date.getTime()).to.equal(Date.UTC(2026, 7, 4, 10, 0, 0, 0)); }); it('rejects a stale offset that contradicts the zone', () => { // Paris is +02:00 in August. Accepting +01:00 would resolve to 11:00Z and // silently render as 13:00 Paris — an hour away from the 12:00 shown. expect( parseZonedValue('2026-08-04T12:00:00[GMT+01:00 Europe/Paris]') ).to.equal(null); }); it('accepts both offsets of a DST fall-back, preserving the distinct instants', () => { // 2026-11-01 01:30 occurs twice in New York; only the offset separates them. const first = parseZonedValue( '2026-11-01T01:30:00[GMT-04:00 America/New_York]' ); const second = parseZonedValue( '2026-11-01T01:30:00[GMT-05:00 America/New_York]' ); expect(first).to.not.equal(null); expect(second).to.not.equal(null); expect(first!.date.getTime()).to.equal(Date.UTC(2026, 10, 1, 5, 30, 0, 0)); expect(second!.date.getTime()).to.equal(Date.UTC(2026, 10, 1, 6, 30, 0, 0)); expect(first!.date.getTime()).to.not.equal(second!.date.getTime()); }); it('rejects a nonexistent spring-forward local time under either offset', () => { // New York jumps 02:00 -> 03:00 on 2026-03-08, so 02:30 never occurs. expect( parseZonedValue('2026-03-08T02:30:00[GMT-05:00 America/New_York]') ).to.equal(null); expect( parseZonedValue('2026-03-08T02:30:00[GMT-04:00 America/New_York]') ).to.equal(null); }); it('rejects an unknown timezone identifier without throwing', () => { expect( parseZonedValue('2026-08-04T12:00:00[GMT+02:00 Not/AZone]') ).to.equal(null); }); it('round-trips the values emitted by formatZonedValue', () => { const yerevan = parseZonedValue( '2030-04-07T21:08:56.892[GMT+04:00 Asia/Yerevan]' ); expect(yerevan?.date.getTime()).to.equal( Date.UTC(2030, 3, 7, 17, 8, 56, 892) ); const utc = parseZonedValue('2030-04-07T17:08:56.892[GMT+00:00 UTC]'); expect(utc?.date.getTime()).to.equal(Date.UTC(2030, 3, 7, 17, 8, 56, 892)); }); }); it('formatWallClock pads correctly', () => { expect( formatWallClock({ year: 5, month: 3, day: 7, hour: 8, minute: 9, second: 1, millisecond: 42, }) ).to.equal('0005-03-07 08:09:01.042'); }); it('round-trips across multiple zones', () => { const epoch = Date.UTC(2021, 5, 15, 12, 30, 45, 123); for (const tz of [ 'UTC', 'America/New_York', 'Asia/Kolkata', 'Asia/Kathmandu', ]) { const w = epochToFields(epoch, tz); expect(fieldsToEpoch(w, tz)).to.equal(epoch); } }); it('resolves DST gaps/overlaps deterministically', () => { // Spring-forward gap in US Eastern (2021-03-14 02:30 does not exist). const gap = { year: 2021, month: 3, day: 14, hour: 2, minute: 30, second: 0, millisecond: 0, }; const a = fieldsToEpoch(gap, 'America/New_York'); const b = fieldsToEpoch(gap, 'America/New_York'); expect(a).to.equal(b); expect(Number.isFinite(a)).to.be.true; // Fall-back overlap (2021-11-07 01:30 occurs twice). const overlap = { ...gap, month: 11, day: 7, hour: 1 }; const c = fieldsToEpoch(overlap, 'America/New_York'); expect(fieldsToEpoch(overlap, 'America/New_York')).to.equal(c); expect(Number.isFinite(c)).to.be.true; }); it('epochToFields agrees with a known UTC instant', () => { const w = epochToFields(Date.UTC(1999, 11, 31, 23, 59, 59, 999), 'UTC'); expect(w).to.deep.equal({ year: 1999, month: 12, day: 31, hour: 23, minute: 59, second: 59, millisecond: 999, }); }); });