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,
});
});
});