import { expect, fixture, html } from '@open-wc/testing';
import './nile-date-time-picker';
import '../nile-calendar';
import '../nile-input';
import type { NileDateTimePicker } from './nile-date-time-picker';
import type { NileCalendar } from '../nile-calendar';
/**
* nile-date-time-picker — single consolidated suite.
*
* Merged from what used to be three files:
* nile-date-time-picker.test.ts → sections A–H
* nile-date-time-picker.tz-audit.test.ts → section I
* nile-date-time-picker.dst-gap.regression.ts → section J
* nile-date-time-picker.adversarial.test.ts → section K (was src/_audit/)
*
* ── ARCHITECTURE (matters for how these tests reach the panel) ───────────────
* The component is a nile-calendar (variant="date-time-picker") plus a
* structured-input mask on the trigger input, hosted in a *nile-floating-panel*
* (Tippy-backed) — NOT a nile-dropdown.
*
* The floating panel hands its content to Tippy as the popper `content`, so the
* calendar is REPARENTED OUT of the host's shadow root at panel-construction
* time (into a detached popper div, moved to document.body on show). Therefore:
*
* el.shadowRoot.querySelector('nile-calendar') → ALWAYS null
* el.calendarElement → the calendar ✅
*
* `calendarElement` is the component's public accessor and the only reliable
* handle. The trigger input is the panel's anchor and DOES stay in the shadow
* root, so it is still queried normally.
*
* The calendar renders (and is fully interactive) even while the panel is
* closed/detached, so tests only open the panel when the assertion is about
* panel lifecycle itself (D2).
*
* ── GRID NOTE ────────────────────────────────────────────────────────────────
* In the date-time-picker variant the day grid is a COLLAPSED 3-week window
* (exactly 21 cells) centred on today — not a full month. So the count of
* `.day_date:not(.filler)` is date-dependent (≈8–21). Tests index into it and
* assert with patterns rather than fixed days.
*
* The panel's own behavior (day selection, time fields, timezone, set-to-now,
* apply/reset, expand/collapse) is exercised in nile-calendar.test.ts. This
* suite covers the wrapper's contract:
* A. structure / trigger input
* B. grouped-config forwarding (fields / value / limits / input / behavior)
* C. event payloads + parity with a standalone nile-calendar
* D. trigger formatting + panel lifecycle
* E. structuredInput mask
* F. disabled gating
* G. hideTimeFields honored by the trigger
* H. timezone carried in the value only
* I. timezone correctness audit (wall-clock ⇄ instant math)
* J. DST spring-forward gap regression
*
* All grouped config goes through the object props (fields/value/limits/input/
* behavior/popup) — the only public API the component reads.
*/
describe('NileDateTimePicker (wrapper)', () => {
const dtpFixture = () =>
fixture(html` `);
// The calendar lives in a detached Tippy popper — use the public accessor.
const innerCalendar = (el: NileDateTimePicker): any => (el as any).calendarElement;
const triggerInput = (el: NileDateTimePicker): any =>
el.shadowRoot!.querySelector('nile-input');
const panelOf = (el: NileDateTimePicker): any =>
el.shadowRoot!.querySelector('nile-floating-panel');
// settle the wrapper, then its inner calendar
const settle = async (el: NileDateTimePicker) => {
await el.updateComplete;
const cal = innerCalendar(el);
if (cal) await cal.updateComplete;
return cal;
};
// adopting a zone from a preset value sets `timezone`, which schedules a
// second update; settle twice so the re-seed lands before we assert.
const settle2 = async (el: NileDateTimePicker) => {
await settle(el);
return settle(el);
};
const dayCells = (cal: any): NodeListOf =>
cal.shadowRoot!.querySelectorAll('.day_date:not(.filler)');
const clickDay = (cal: any, i = 5) => (dayCells(cal)[i] as HTMLElement).click();
const applyBtn = (cal: any) => cal.shadowRoot!.querySelector('.apply-button') as HTMLElement;
const resetBtn = (cal: any) => cal.shadowRoot!.querySelector('.clear-button') as HTMLElement;
// ── A. structure / trigger input ──────────────────────────────────────────
describe('A. structure', () => {
it('A1. is defined', () => {
expect(customElements.get('nile-date-time-picker')).to.exist;
});
it('A2. renders an inner nile-calendar with the date-time-picker variant + footer', async () => {
const el = await dtpFixture();
const cal = await settle(el);
expect(cal).to.exist;
expect(cal.variant).to.equal('date-time-picker');
expect(cal.shadowRoot!.querySelector('.base--date-time-picker')).to.exist;
expect(cal.shadowRoot!.querySelector('.tp-footer')).to.exist;
});
it('A2b. the calendar is hosted by a nile-floating-panel, outside the shadow root', async () => {
const el = await dtpFixture();
const cal = await settle(el);
expect(panelOf(el), 'panel host').to.exist;
// documents the reparenting contract this suite depends on
expect(el.shadowRoot!.querySelector('nile-calendar'), 'moved into the popper').to.be.null;
expect(cal.tagName).to.equal('NILE-CALENDAR');
});
// ---- accessible naming of the trigger ----
// The placeholder is a FORMAT MASK ("DD/MM/YY - hh:mm:ss.SSS - timezone"), not a
// name, and it is only a last-resort fallback in the accessible-name computation.
// So the component offers two explicit naming routes:
// `label` → forwarded to nile-input, which names the native input
// via its own (visible)
// `accessibleLabel` → mirrored onto the NATIVE input as aria-label (invisible);
// needed because a host attribute does not cross
// nile-input's shadow boundary
// Contract (DEFAULT_TRIGGER_LABEL): with neither `label` nor `aria-label`, there is no VISIBLE label,
// but the native input still gets the built-in accessible name "Date and time".
it('A3. by default the trigger has no visible label but carries the built-in accessible name', async () => {
const el = await dtpFixture();
await settle(el);
const inp = triggerInput(el);
expect(inp).to.exist;
await inp.updateComplete;
expect(inp.hasAttribute('label')).to.be.false;
expect(inp.label ?? '').to.equal('');
expect((inp.input as HTMLInputElement).getAttribute('aria-label')).to.equal('Date and time');
});
it('A3b. label forwards to the trigger input as a visible label', async () => {
const el = await dtpFixture();
el.label = 'Start date';
await settle(el);
const inp = triggerInput(el);
await inp.updateComplete;
expect(inp.getAttribute('label')).to.equal('Start date');
});
it('A3c. accessibleLabel is applied as aria-label on the NATIVE input', async () => {
const el = await fixture(
html` `
);
const inp = await settle(el).then(() => triggerInput(el));
await inp.updateComplete;
// it must land on the native input, not merely on the host
expect((inp.input as HTMLInputElement).getAttribute('aria-label')).to.equal('Start date');
});
it('A3d. a visible label wins: aria-label is not also applied (no double naming)', async () => {
const el = await fixture(
html` `
);
await settle(el);
el.label = 'Visible name';
await settle(el);
const inp = triggerInput(el);
await inp.updateComplete;
expect(inp.getAttribute('label')).to.equal('Visible name');
expect((inp.input as HTMLInputElement).hasAttribute('aria-label')).to.be.false;
});
it('A4. trigger placeholder defaults to " - hh:mm:ss.SSS - timezone"', async () => {
const el = await dtpFixture();
await settle(el);
expect(triggerInput(el).getAttribute('placeholder'))
.to.equal('DD/MM/YY - hh:mm:ss.SSS - timezone');
});
it('A5. input.placeholder overrides the default placeholder', async () => {
const el = await dtpFixture();
el.placeholder = 'Custom placeholder';
await settle(el);
expect(triggerInput(el).getAttribute('placeholder')).to.equal('Custom placeholder');
});
it('A6. input.dateFormat is reflected in the default placeholder', async () => {
const el = await dtpFixture();
el.dateFormat = 'YYYY-MM-DD';
await settle(el);
expect(triggerInput(el).getAttribute('placeholder'))
.to.equal('YYYY-MM-DD - hh:mm:ss.SSS - timezone');
});
});
// ── B. grouped-config forwarding ────────────────────────────────────────────
describe('B. config forwarding', () => {
it('B1. the Absolute/Relative tabs are hidden unless showTypes is set', async () => {
const el = await dtpFixture();
expect(el.showTypes).to.be.false;
expect((await settle(el)).showTypes).to.be.false;
el.showTypes = true;
expect((await settle(el)).showTypes).to.be.true;
});
it('B2. fields.monthDropdown / yearDropdown forward to the calendar', async () => {
const el = await dtpFixture();
el.showMonthDropdown = true;
el.showYearDropdown = true;
const cal = await settle(el);
expect(cal.showMonthDropdown).to.be.true;
expect(cal.showYearDropdown).to.be.true;
});
it('B3. hideTimeFields ["seconds","milliseconds"] forwards to the calendar as hideTimeFields', async () => {
const el = await dtpFixture();
el.hideTimeFields = ['seconds', 'milliseconds'];
const cal = await settle(el);
expect(cal.hideTimeFields).to.deep.equal(['seconds', 'milliseconds']);
expect(cal.timeFields).to.be.undefined; // the calendar no longer has a timeFields property
});
it('B3b. every one of the 32 subsets of the five time fields renders the matching calendar fields, timezone block and layout', async () => {
const keys = ['hours', 'minutes', 'seconds', 'milliseconds', 'timezone'];
const labels: Record = { hours: 'Hours (24H)', minutes: 'Minutes', seconds: 'Seconds', milliseconds: 'Milliseconds' };
const el = await dtpFixture();
for (let mask = 0; mask < 32; mask++) {
const hidden = keys.filter((_, i) => mask & (1 << i));
el.hideTimeFields = hidden; // dynamic update on one live element
const cal = await settle(el);
await settle(el);
const shown = ['hours', 'minutes', 'seconds', 'milliseconds'].filter(k => !hidden.includes(k));
const got = Array.from(cal.shadowRoot!.querySelectorAll('.tp-time-field')).map((n: any) => n.getAttribute('label'));
const msg = `hidden=${JSON.stringify(hidden)}`;
expect(got, msg).to.deep.equal(shown.map(k => labels[k]));
expect(!!cal.shadowRoot!.querySelector('.tp-timezone'), msg).to.equal(!hidden.includes('timezone'));
const fewNoTz = hidden.includes('timezone') && shown.length <= 2;
expect(!!cal.shadowRoot!.querySelector('.tp-time-fields--stacked'), msg).to.equal(fewNoTz);
expect(!!cal.shadowRoot!.querySelector('.base--tp-fewfields'), msg).to.equal(fewNoTz);
}
}).timeout(60000);
it('B3d. open/close cycles and the panel reparenting keep exactly one calendar observer and never record a zero height', async () => {
const Orig = window.ResizeObserver;
const infos: { cal: boolean; live: boolean }[] = [];
let loopErrors = 0;
const onError = (e: ErrorEvent) => { if (/ResizeObserver loop/i.test(e.message || '')) loopErrors++; };
window.addEventListener('error', onError);
(window as any).ResizeObserver = class extends Orig {
info = { cal: false, live: false };
constructor(cb: ResizeObserverCallback) { super(cb); infos.push(this.info); }
observe(t: Element, o?: ResizeObserverOptions) {
if (t.classList.contains('base--date-time-picker')) { this.info.cal = true; this.info.live = true; }
return super.observe(t, o);
}
disconnect() { this.info.live = false; return super.disconnect(); }
};
try {
const el = await dtpFixture();
const cal = await settle(el);
const seen = new Set();
const raf = () => new Promise(r => requestAnimationFrame(() => r(null)));
for (let i = 0; i < 20; i++) {
el.open = true; await el.updateComplete; await raf(); await raf();
seen.add(String(cal.tpLockedHeight));
el.open = false; await el.updateComplete; await raf(); await raf();
seen.add(String(cal.tpLockedHeight));
}
expect(infos.filter(o => o.cal && o.live).length).to.be.at.most(1);
expect(seen.has('0px'), JSON.stringify([...seen])).to.be.false;
expect(loopErrors).to.equal(0);
el.remove();
await raf();
expect(infos.filter(o => o.cal && o.live).length).to.equal(0);
} finally {
window.ResizeObserver = Orig;
window.removeEventListener('error', onError);
}
}).timeout(30000);
it('B3c. a non-array hideTimeFields on the wrapper counts as an empty list and still renders every field', async () => {
for (const bad of [5, { a: 1 }, 'seconds', null]) {
const el = await dtpFixture();
(el as any).hideTimeFields = bad;
const cal = await settle(el);
await settle(el);
expect(cal.shadowRoot!.querySelectorAll('.tp-time-field').length, String(bad)).to.equal(4);
expect(cal.shadowRoot!.querySelector('.tp-timezone'), String(bad)).to.exist;
}
});
it('B4. hideDurationFields=["months"] forwards to the calendar', async () => {
const el = await dtpFixture();
el.hideDurationFields = ['months'];
el.type = 'relative';
const cal = await settle(el);
expect(cal.hideDurationFields).to.deep.equal(['months']);
expect(cal.shadowRoot!.textContent).to.not.contain('Months');
});
it('B5. value.date forwards to the calendar value attribute', async () => {
const el = await dtpFixture();
el.value = '2026-06-15';
const cal = await settle(el);
expect(cal.getAttribute('value')).to.equal('2026-06-15');
});
it('B6. type="relative" forwards as the calendar type', async () => {
const el = await dtpFixture();
el.type = 'relative';
const cal = await settle(el);
expect(cal.type).to.equal('relative');
});
it('B7. value.timezone forwards to the calendar', async () => {
const el = await dtpFixture();
el.timezone = 'Asia/Kolkata';
const cal = await settle(el);
expect(cal.timezone).to.equal('Asia/Kolkata');
});
it('B8. value.timezone defaults to UTC when unset', async () => {
const el = await dtpFixture();
const cal = await settle(el);
expect(cal.timezone).to.equal('UTC');
});
it('B9. value.unit / value.amount forward as selectedUnit / selectedValue', async () => {
const el = await dtpFixture();
el.type = 'relative';
el.selectedUnit = 'days';
el.selectedValue = 3;
const cal = await settle(el);
expect(cal.selectedUnit).to.equal('days');
expect(Number(cal.selectedValue)).to.equal(3);
});
it('B10. limits.startYear / endYear forward to the calendar', async () => {
const el = await dtpFixture();
el.startYear = 2000;
el.endYear = 2050;
const cal = await settle(el);
expect(Number(cal.startYear)).to.equal(2000);
expect(Number(cal.endYear)).to.equal(2050);
});
it('B11. allowedDates (JSON string) forwards to the calendar', async () => {
const el = await dtpFixture();
const window = { startDate: '2026-06-01', endDate: '2026-06-30' };
el.allowedDates = JSON.stringify(window);
const cal = await settle(el);
expect(cal.getAttribute('allowedDates')).to.equal(JSON.stringify(window));
});
it('B12. behavior.disabled disables the calendar and the floating panel', async () => {
const el = await dtpFixture();
el.disabled = true;
const cal = await settle(el);
expect(cal.disabled).to.be.true;
expect(panelOf(el).hasAttribute('disabled')).to.be.true;
});
it('B13. behavior.doubleClickUnselect forwards to the calendar', async () => {
const el = await dtpFixture();
el.doubleClickUnselect = true;
const cal = await settle(el);
expect(cal.doubleClickUnselect).to.be.true;
});
it('B14. input.dateFormat forwards to the calendar', async () => {
const el = await dtpFixture();
el.dateFormat = 'YYYY-MM-DD';
const cal = await settle(el);
expect(cal.dateFormat).to.equal('YYYY-MM-DD');
});
});
// ── C. event payloads + parity ──────────────────────────────────────────────
describe('C. event payloads', () => {
it('C1. apply (absolute) emits nile-change with { value: zoned string, timezone }', async () => {
const el = await dtpFixture();
const cal = await settle(el);
clickDay(cal, 5);
await cal.updateComplete;
let detail: any = null;
el.addEventListener('nile-change', ((e: CustomEvent) => { detail = e.detail; }) as EventListener);
applyBtn(cal).click();
expect(detail).to.not.be.null;
// value is a zone-encoded string: "YYYY-MM-DDTHH:mm:ss.SSS[GMT±HH:MM Zone]"
expect(detail.value).to.be.a('string');
expect(detail.value).to.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}\[GMT[+-]\d{2}:\d{2} .+\]$/);
expect(detail.value).to.include('[GMT+00:00 UTC]');
expect(detail.timezone).to.equal('UTC');
});
it('C1b. a payload with no timezone falls back to the component zone, never " - undefined"', async () => {
const el = await dtpFixture();
const cal = await settle(el);
// a calendar payload that omits `timezone` entirely. Optional chaining alone
// would still stringify undefined into the trigger text.
cal.dispatchEvent(new CustomEvent('nile-change', {
detail: { value: '2026-06-15T14:30:00.000' },
bubbles: true, composed: true,
}));
await settle(el);
const text = String(triggerInput(el).value ?? '');
expect(text, 'must not leak undefined into the trigger').to.not.contain('undefined');
expect(text, 'falls back to the component zone').to.contain('UTC');
});
it('C2. apply no longer emits the removed nile-changed event', async () => {
const el = await dtpFixture();
const cal = await settle(el);
clickDay(cal, 5);
await cal.updateComplete;
let changeDetail: any = null;
let changedFired = false;
el.addEventListener('nile-change', ((e: CustomEvent) => { changeDetail = e.detail; }) as EventListener);
el.addEventListener('nile-changed', () => { changedFired = true; });
cal.addEventListener('nile-changed', () => { changedFired = true; });
applyBtn(cal).click();
expect(changeDetail, 'nile-change still fires').to.not.be.null;
expect(changedFired, 'nile-changed is gone from both calendar and picker').to.be.false;
});
it('C3. apply emits nile-change exactly once ', async () => {
const el = await dtpFixture();
const cal = await settle(el);
clickDay(cal, 5);
await cal.updateComplete;
let count = 0;
el.addEventListener('nile-change', () => { count += 1; });
applyBtn(cal).click();
expect(count).to.equal(1);
});
it('C4. time fields are merged into the applied value', async () => {
const el = await dtpFixture();
const cal = await settle(el);
clickDay(cal, 5);
cal.tpHours = 14; cal.tpMinutes = 30; cal.tpSeconds = 5; cal.tpMilliseconds = 250;
await cal.updateComplete;
let detail: any = null;
el.addEventListener('nile-change', ((e: CustomEvent) => { detail = e.detail; }) as EventListener);
applyBtn(cal).click();
expect(detail.value).to.include('T14:30:05.250');
expect(detail.value).to.include('[GMT+00:00 UTC]');
});
it('C5. value.timezone is carried into the applied payload', async () => {
const el = await dtpFixture();
el.timezone = 'Asia/Kolkata';
const cal = await settle(el);
clickDay(cal, 5);
await cal.updateComplete;
let detail: any = null;
el.addEventListener('nile-change', ((e: CustomEvent) => { detail = e.detail; }) as EventListener);
applyBtn(cal).click();
expect(detail.timezone).to.equal('Asia/Kolkata');
});
it('C6. relative apply emits { startDate: Date, endDate: Date, timezone }', async () => {
const el = await dtpFixture();
el.type = 'relative';
const cal = await settle(el);
(cal.shadowRoot!.querySelector('.duration__value') as HTMLElement).click();
await cal.updateComplete;
let detail: any = null;
el.addEventListener('nile-change', ((e: CustomEvent) => { detail = e.detail; }) as EventListener);
applyBtn(cal).click();
expect(detail).to.not.be.null;
expect(detail.startDate).to.be.instanceOf(Date);
expect(detail.endDate).to.be.instanceOf(Date);
expect(detail.timezone).to.equal('UTC');
});
it('C7. reset emits nile-clear (no detail)', async () => {
const el = await dtpFixture();
const cal = await settle(el);
clickDay(cal, 5);
await cal.updateComplete;
let fired = false;
let detail: any = 'unset';
el.addEventListener('nile-clear', ((e: CustomEvent) => { fired = true; detail = e.detail; }) as EventListener);
resetBtn(cal).click();
await cal.updateComplete;
expect(fired).to.be.true;
expect(detail == null).to.be.true; // undefined or null, no payload
});
it('C8. nile-type-change is forwarded when the tab switches (detail.value)', async () => {
const el = await dtpFixture();
const cal = await settle(el);
let detail: any = null;
el.addEventListener('nile-type-change', ((e: CustomEvent) => { detail = e.detail; }) as EventListener);
cal.shadowRoot!.querySelector('nile-tab[panel="relative"]')!.dispatchEvent(
new CustomEvent('nile-tab-show', { detail: { value: 'relative' }, bubbles: true, composed: true })
);
await cal.updateComplete;
expect(detail).to.not.be.null;
expect(detail.value).to.equal('relative');
});
it('C9. nile-init is emitted when the element connects', async () => {
const el = document.createElement('nile-date-time-picker');
let fired = false;
el.addEventListener('nile-init', () => { fired = true; });
document.body.appendChild(el);
await (el as NileDateTimePicker).updateComplete;
expect(fired).to.be.true;
el.remove();
});
it('C10. nile-destroy is emitted when the element disconnects', async () => {
const el = await dtpFixture();
await settle(el);
let fired = false;
el.addEventListener('nile-destroy', () => { fired = true; });
el.remove();
expect(fired).to.be.true;
});
// ---- parity: the wrapper must emit exactly what a standalone calendar emits ----
it('C11. PARITY: absolute payload equals a standalone nile-calendar variant', async () => {
// standalone date-time-picker-variant calendar
const cal = await fixture(
html` `
) as any;
(cal.shadowRoot!.querySelectorAll('.day_date:not(.filler)')[5] as HTMLElement).click();
cal.tpHours = 9; cal.tpMinutes = 15; cal.tpSeconds = 0; cal.tpMilliseconds = 0;
await cal.updateComplete;
let calDetail: any = null;
cal.addEventListener('nile-change', ((e: CustomEvent) => { calDetail = e.detail; }) as EventListener);
(cal.shadowRoot!.querySelector('.apply-button') as HTMLElement).click();
// same selection through the wrapper
const el = await dtpFixture();
const inner = await settle(el);
clickDay(inner, 5);
inner.tpHours = 9; inner.tpMinutes = 15; inner.tpSeconds = 0; inner.tpMilliseconds = 0;
await inner.updateComplete;
let wrapDetail: any = null;
el.addEventListener('nile-change', ((e: CustomEvent) => { wrapDetail = e.detail; }) as EventListener);
applyBtn(inner).click();
expect(wrapDetail.value).to.be.a('string');
expect(wrapDetail.value).to.equal(calDetail.value);
expect(wrapDetail.timezone).to.equal(calDetail.timezone);
// same payload key shape
expect(Object.keys(wrapDetail).sort()).to.deep.equal(Object.keys(calDetail).sort());
});
it('C12. PARITY: relative payload shape equals a standalone nile-calendar variant', async () => {
const cal = await fixture(
html` `
) as any;
await cal.updateComplete;
(cal.shadowRoot!.querySelector('.duration__value') as HTMLElement).click();
await cal.updateComplete;
let calDetail: any = null;
cal.addEventListener('nile-change', ((e: CustomEvent) => { calDetail = e.detail; }) as EventListener);
(cal.shadowRoot!.querySelector('.apply-button') as HTMLElement).click();
const el = await dtpFixture();
el.type = 'relative';
const inner = await settle(el);
(inner.shadowRoot!.querySelector('.duration__value') as HTMLElement).click();
await inner.updateComplete;
let wrapDetail: any = null;
el.addEventListener('nile-change', ((e: CustomEvent) => { wrapDetail = e.detail; }) as EventListener);
applyBtn(inner).click();
expect(Object.keys(wrapDetail).sort()).to.deep.equal(Object.keys(calDetail).sort());
expect(wrapDetail.startDate).to.be.instanceOf(Date);
expect(wrapDetail.endDate).to.be.instanceOf(Date);
expect(wrapDetail.timezone).to.equal(calDetail.timezone);
});
});
// ── D. trigger formatting + panel lifecycle ─────────────────────────────────
describe('D. trigger formatting / panel', () => {
it('D1. an applied selection is formatted into the trigger input', async () => {
const el = await dtpFixture();
el.dateFormat = 'DD/MM/YYYY';
const cal = await settle(el);
clickDay(cal, 5);
cal.tpHours = 8; cal.tpMinutes = 5; cal.tpSeconds = 0; cal.tpMilliseconds = 0;
await cal.updateComplete;
applyBtn(cal).click();
await el.updateComplete;
await triggerInput(el).updateComplete;
const text = triggerInput(el).value as string;
// " - 08:05:00.000 - UTC"
expect(text).to.match(/^\d{2}\/\d{2}\/\d{4} - 08:05:00\.000 - UTC$/);
});
it('D2. apply closes the panel', async () => {
const el = await dtpFixture();
el.open = true;
const cal = await settle(el);
// let Tippy mount (show is queued on a microtask + transition)
await new Promise(r => setTimeout(r, 50));
expect(panelOf(el).open, 'panel opened').to.be.true;
clickDay(cal, 5);
await cal.updateComplete;
applyBtn(cal).click();
await el.updateComplete;
expect(panelOf(el).open).to.not.be.true;
expect(el.open).to.not.be.true;
});
it('D3. reset empties the trigger input', async () => {
const el = await dtpFixture();
const cal = await settle(el);
clickDay(cal, 5);
await cal.updateComplete;
applyBtn(cal).click();
await el.updateComplete;
await triggerInput(el).updateComplete;
expect(triggerInput(el).value).to.not.equal('');
resetBtn(cal).click();
await el.updateComplete;
await triggerInput(el).updateComplete;
expect(triggerInput(el).value).to.equal('');
});
});
// ── E. structuredInput mask (drives the shadow trigger input) ───────────────
describe('E. structuredInput mask', () => {
const structuredFixture = (fmt?: string, endYear?: number) =>
fixture(
fmt
? html` `
: html` `
);
const inputOf = (el: any) => el.shadowRoot.querySelector('nile-input') as any;
const nativeOf = (el: any) => inputOf(el).input as HTMLInputElement;
const press = (el: any, key: string) =>
inputOf(el).dispatchEvent(
new KeyboardEvent('keydown', { key, bubbles: true, composed: true, cancelable: true })
);
const typeKeys = (el: any, s: string) => { for (const ch of s) press(el, ch); };
const valOf = (el: any) => nativeOf(el).value;
const setCaret = (el: any, p: number) => nativeOf(el).setSelectionRange(p, p);
const blur = (el: any) =>
inputOf(el).dispatchEvent(new Event('focusout', { bubbles: true, composed: true }));
const ready = async (el: any) => { await el.updateComplete; await inputOf(el).updateComplete; };
it('E1. separators appear as each segment fills', async () => {
const el = await structuredFixture();
await ready(el);
typeKeys(el, '23'); expect(valOf(el)).to.equal('23/');
typeKeys(el, '03'); expect(valOf(el)).to.equal('23/03/');
typeKeys(el, '26'); expect(valOf(el)).to.equal('23/03/26 - ');
typeKeys(el, '17'); expect(valOf(el)).to.equal('23/03/26 - 17:');
});
it('E1b. the wrapper masks its own trigger input and no longer forwards structuredInput to its calendar (which has no manual inputs)', async () => {
const el = await structuredFixture();
await ready(el);
const cal = (el as any).calendarElement as any;
await cal.updateComplete;
expect(cal.structuredInput).to.be.false;
expect(cal.shadowRoot.querySelector('.calender-input')).to.be.null;
expect(cal.boundMaskInputs.length).to.equal(0);
typeKeys(el, '2303'); expect(valOf(el)).to.equal('23/03/'); // the wrapper's own mask is unaffected
});
it('E2. a full datetime fills every slot; letters blocked; extra digits ignored', async () => {
const el = await structuredFixture();
await ready(el);
typeKeys(el, '230326172456426');
expect(valOf(el)).to.equal('23/03/26 - 17:24:56.426');
typeKeys(el, 'iuhwe');
expect(valOf(el)).to.equal('23/03/26 - 17:24:56.426');
typeKeys(el, '9999');
expect(valOf(el)).to.equal('23/03/26 - 17:24:56.426');
});
it('E3. each segment is clamped to its max as it fills', async () => {
const el = await structuredFixture();
await ready(el);
typeKeys(el, '457830009988333');
expect(valOf(el)).to.equal('04/07/30 - 00:09:08.333');
});
it('E4. month/day have a min of 1 (00 -> 01); time fields allow 00', async () => {
const el = await structuredFixture();
await ready(el);
typeKeys(el, '000026000000000');
expect(valOf(el)).to.equal('01/01/26 - 00:00:00.000');
});
it('E5. day clamps to the month length once month + year are known (Feb 2026 -> 28)', async () => {
const el = await structuredFixture();
await ready(el);
typeKeys(el, '310226');
expect(valOf(el)).to.equal('28/02/26 - ');
});
it('E6. leap year allows Feb 29 (2024)', async () => {
const el = await structuredFixture();
await ready(el);
typeKeys(el, '290224');
expect(valOf(el)).to.equal('29/02/24 - ');
});
it('E7. blur zero-pads a single-digit segment (5 -> 05)', async () => {
const el = await structuredFixture();
await ready(el);
typeKeys(el, '5');
expect(valOf(el)).to.equal('5');
blur(el);
expect(valOf(el)).to.equal('05/');
});
it('E8. Backspace blanks the slot in place, no reflow', async () => {
const el = await structuredFixture();
await ready(el);
typeKeys(el, '230326');
expect(valOf(el)).to.equal('23/03/26 - ');
setCaret(el, 2);
press(el, 'Backspace');
expect(valOf(el)).to.equal('2/03/26 - ');
});
it('E9. dateFormat="YYYY-MM-DD" puts the 4-digit year first', async () => {
const el = await structuredFixture('YYYY-MM-DD');
await ready(el);
typeKeys(el, '20260610000000000');
expect(valOf(el)).to.equal('2026-06-10 - 00:00:00.000');
});
it('E10. a completed manual edit reflects into the inner calendar panel', async () => {
const el = await structuredFixture();
await ready(el);
typeKeys(el, '230326172456426');
const cal = innerCalendar(el);
await cal.updateComplete;
expect(cal.value).to.be.instanceOf(Date);
expect(cal.value.getDate()).to.equal(23);
expect(cal.value.getMonth()).to.equal(2); // March, 0-indexed
expect(cal.value.getFullYear()).to.equal(2026);
expect(cal.tpHours).to.equal(17);
expect(cal.tpMinutes).to.equal(24);
expect(cal.tpSeconds).to.equal(56);
expect(cal.tpMilliseconds).to.equal(426);
});
// ---- Feb day-cap with a 4-digit year, incl. the 100/400 century rules ----
// (E5/E6 cover 2-digit years; these lock in full-year leap handling.)
it('E12. Feb 31 caps to 28 in a common 4-digit year (2025)', async () => {
const el = await structuredFixture('DD/MM/YYYY');
await ready(el);
typeKeys(el, '31022025');
expect(valOf(el)).to.equal('28/02/2025 - ');
});
it('E13. Feb 31 caps to 29 in a leap 4-digit year (2024)', async () => {
const el = await structuredFixture('DD/MM/YYYY');
await ready(el);
typeKeys(el, '31022024');
expect(valOf(el)).to.equal('29/02/2024 - ');
});
it('E14. century rule: 2000 is leap (29) but 2100 is not (28)', async () => {
const leap = await structuredFixture('DD/MM/YYYY');
await ready(leap);
typeKeys(leap, '31022000');
expect(valOf(leap)).to.equal('29/02/2000 - ');
// endYear=2100 so the typed year isn't clamped by the default nav range
// (2050) — this test is specifically about the 2100 century rule.
const common = await structuredFixture('DD/MM/YYYY', 2100);
await ready(common);
typeKeys(common, '31022100');
expect(valOf(common)).to.equal('28/02/2100 - ');
});
it('E14a. default range: year clamp to 2050 revalidates the day (29/02/2100 -> 28/02/2050)', async () => {
// With the default endYear (2050), typing 2100 clamps the year to 2050.
// 2050 is NOT a leap year, so Feb 29 must cap to Feb 28 AFTER the year
// clamp — i.e. the result stays a valid date, never 29/02/2050.
const el = await structuredFixture('DD/MM/YYYY');
await ready(el);
typeKeys(el, '29022100');
expect(valOf(el)).to.equal('28/02/2050 - ');
});
it('E15. a valid day is never clamped (30 April stays 30; 31 Dec stays 31)', async () => {
const apr = await structuredFixture('DD/MM/YYYY');
await ready(apr);
typeKeys(apr, '30042025');
expect(valOf(apr)).to.equal('30/04/2025 - ');
const dec = await structuredFixture('DD/MM/YYYY');
await ready(dec);
typeKeys(dec, '31122025');
expect(valOf(dec)).to.equal('31/12/2025 - ');
});
// Invalid-day clamping for the date-time-picker variant: the trigger text AND the date the inner
// calendar stores are both capped to the month length, so Date never rolls into the next month.
const typedDate = async (keys: string) => {
const el = await structuredFixture('DD/MM/YYYY');
await ready(el);
typeKeys(el, keys);
const cal = innerCalendar(el);
await cal.updateComplete;
return { text: valOf(el), stored: cal.value as Date };
};
const ymd = (d: Date) => [d.getFullYear(), d.getMonth() + 1, d.getDate()];
it('E15a. Feb 31 in a common year clamps to Feb 28 (text and stored date)', async () => {
const { text, stored } = await typedDate('31022026');
expect(text).to.equal('28/02/2026 - ');
expect(ymd(stored)).to.deep.equal([2026, 2, 28]);
});
it('E15b. Feb 29 in a common year clamps to Feb 28, not Mar 1', async () => {
const { text, stored } = await typedDate('29022026');
expect(text).to.equal('28/02/2026 - ');
expect(ymd(stored)).to.deep.equal([2026, 2, 28]);
});
it('E15c. Feb 29 in a leap year stays Feb 29', async () => {
const { text, stored } = await typedDate('29022024');
expect(text).to.equal('29/02/2024 - ');
expect(ymd(stored)).to.deep.equal([2024, 2, 29]);
});
it('E15d. Apr 31 clamps to Apr 30, not May 1', async () => {
const { text, stored } = await typedDate('31042026');
expect(text).to.equal('30/04/2026 - ');
expect(ymd(stored)).to.deep.equal([2026, 4, 30]);
});
// structuredInput is always on for the wrapper's trigger, so these also
// cover the "structuredInput enabled + preset value" case.
it('E16. a preset value seeds the masked trigger with the day it names (no off-by-one)', async () => {
const el = await fixture(
html` `
);
el.value = '2026-06-15';
await ready(el);
// trigger text starts with the named day/month/year — not the 14th/16th
expect(valOf(el)).to.match(/^15\/06\/2026\b/);
});
it('E17. preset value → Apply with no edits emits that exact day (trigger and payload agree)', async () => {
const el = await dtpFixture();
el.value = '2026-06-15';
const cal = await settle(el);
let detail: any = null;
el.addEventListener('nile-change', ((e: CustomEvent) => { detail = e.detail; }) as EventListener);
applyBtn(cal).click();
expect(detail).to.not.be.null;
expect(detail.value).to.be.a('string');
expect(detail.value).to.match(/^2026-06-15T/);
});
/**
* clampSegment does `layout.slots[slotIndex].segStart` with no bounds check, so it
* relies on callers never passing a stale index. The only way maskValues could
* outlive its layout is a layout that SHRINKS after the mask was seeded; the
* re-sync in onMaskKeydown is what repairs it.
*
* This is an end-to-end check of that transition, not an isolation test of
* clampSegment: it asserts typing after a shrink neither throws nor blanks the
* mask. Removing the re-sync breaks basic typing long before any index goes out
* of bounds, so no test can reach clampSegment with a bad index through the UI.
*/
it('E18. typing still works after the layout shrinks post-seeding', async () => {
const el = await structuredFixture();
await ready(el);
typeKeys(el, '150626' + '143025999');
const before = valOf(el);
expect(before, 'mask is fully seeded').to.contain('15/06/26');
// drop two time segments -> layout.slots shrinks while maskValues is still long
el.hideTimeFields = ['seconds', 'milliseconds'];
await ready(el);
let threw: unknown = null;
try {
setCaret(el, 0);
typeKeys(el, '1606261430');
blur(el);
await ready(el);
} catch (err) { threw = err; }
expect(threw, `typing after the layout shrank threw: ${threw}`).to.be.null;
expect(valOf(el), 'mask still renders').to.be.a('string');
});
it('E19. relative apply then blur leaves the range text, calendar timezone and mask untouched', async () => {
const el = await structuredFixture();
await ready(el);
// seed absolute mask state that must not come back later
typeKeys(el, '150626' + '143025999');
expect(valOf(el), 'absolute mask is seeded').to.contain('15/06/26');
el.type = 'relative';
const cal = await settle(el);
(cal.shadowRoot!.querySelector('.duration__value') as HTMLElement).click();
await cal.updateComplete;
applyBtn(cal).click();
await ready(el);
const rangeText = valOf(el);
const tzBefore = cal.timezone;
expect(rangeText, 'trigger shows the relative range').to.contain(' - ');
blur(el);
await ready(el);
await cal.updateComplete;
expect(valOf(el), 'trigger text unchanged by blur').to.equal(rangeText);
expect(cal.timezone, 'calendar timezone unchanged by blur').to.equal(tzBefore);
expect(valOf(el), 'stale absolute value not restored').to.not.contain('15/06/26');
expect((el as any).maskValues, 'no stale absolute mask state').to.deep.equal([]);
});
});
// ── F. disabled gating ──────────────────────────────────────────────────────
describe('F. disabled', () => {
it('F1. when disabled, a calendar nile-change is NOT re-emitted by the wrapper', async () => {
const el = await dtpFixture();
el.disabled = true;
const cal = await settle(el);
let fired = false;
el.addEventListener('nile-change', () => { fired = true; });
cal.dispatchEvent(new CustomEvent('nile-change', {
detail: { value: new Date(2026, 5, 12, 8, 0, 0, 0), timezone: 'UTC' },
bubbles: true, composed: true,
}));
await el.updateComplete;
expect(fired).to.be.false;
});
describe('F-icons. disabled icon colors', () => {
const DISABLED = 'var(--nile-colors-dark-500, var(--ng-colors-fg-disabled))';
const iconColors = (el: any, cal: any) => {
const root = cal.shadowRoot as ShadowRoot;
return {
trigger: el.shadowRoot.querySelector('nile-glyph[slot="suffix"]').getAttribute('color'),
nav: [...root.querySelectorAll('.calendar-header__month-navigation')].map(i => i.getAttribute('color')),
chevron: root.querySelector('.tp-chevron nile-icon')!.getAttribute('color'),
steppers: [...root.querySelectorAll('.tp-stepper-btn nile-icon')].map(i => i.getAttribute('color')),
setToNow: root.querySelector('.tp-set-to-now-button nile-glyph')!.getAttribute('color'),
};
};
it('F3. every icon uses the existing disabled-state token when disabled', async () => {
const el = await dtpFixture();
el.disabled = true;
const cal = await settle(el);
await cal.updateComplete;
const c = iconColors(el, cal);
expect(c.trigger, 'trigger glyph').to.equal(DISABLED);
expect(c.nav.length, 'nav arrows').to.be.greaterThan(0);
for (const n of c.nav) expect(n, 'nav arrow').to.equal(DISABLED);
expect(c.chevron, 'expand chevron').to.equal(DISABLED);
expect(c.steppers.length, 'stepper icons').to.be.greaterThan(0);
for (const n of c.steppers) expect(n, 'stepper icon').to.equal(DISABLED);
expect(c.setToNow, 'set-to-now glyph').to.equal(DISABLED);
});
it('F4. enabled icons keep their default colors (no disabled token leaks)', async () => {
const el = await dtpFixture();
const cal = await settle(el);
const c = iconColors(el, cal);
expect(c.trigger).to.equal(null);
expect(c.steppers.every(x => x === null), 'steppers default').to.be.true;
expect(c.setToNow).to.equal(null);
for (const n of [...c.nav, c.chevron]) expect(n).to.not.equal(DISABLED);
});
it('F5. re-enabling restores the default icon colors', async () => {
const el = await dtpFixture();
el.disabled = true;
const cal = await settle(el);
el.disabled = false;
await el.updateComplete;
await cal.updateComplete;
const c = iconColors(el, cal);
expect(c.trigger).to.equal(null);
expect(c.steppers.every(x => x === null)).to.be.true;
});
});
it('F2. when disabled, mask keystrokes are ignored', async () => {
const el = await fixture(
html` `
);
await el.updateComplete;
const inp = el.shadowRoot!.querySelector('nile-input') as any;
await inp.updateComplete;
inp.dispatchEvent(new KeyboardEvent('keydown', { key: '2', bubbles: true, composed: true, cancelable: true }));
expect((inp.input as HTMLInputElement).value).to.equal('');
});
});
// ── G. hideTimeFields removal is honored by the trigger (mask / placeholder /
// applied-value), not just by the panel ──────────────────────────────
describe('G. hideTimeFields honored by the trigger input', () => {
const cfgFixture = (hideTimeFields: string[] = [], dateFormat = 'DD/MM/YYYY') =>
fixture(
html` `
);
const ph = (el: any) => triggerInput(el).getAttribute('placeholder') as string;
const applyDay = async (el: any) => {
const cal = await settle(el);
clickDay(cal, 5);
await cal.updateComplete;
applyBtn(cal).click();
await el.updateComplete;
await triggerInput(el).updateComplete;
return triggerInput(el).value as string;
};
it('G1. default (all fields) is unchanged: full placeholder + full applied value', async () => {
const el = await cfgFixture();
await el.updateComplete;
expect(ph(el)).to.equal('DD/MM/YYYY - hh:mm:ss.SSS - timezone');
expect(await applyDay(el)).to.match(/^\d{2}\/\d{2}\/\d{4} - 00:00:00\.000 - UTC$/);
});
it('G2. seconds + milliseconds removed: placeholder + applied value stop at minutes', async () => {
const el = await cfgFixture(['seconds', 'milliseconds']);
await el.updateComplete;
expect(ph(el)).to.equal('DD/MM/YYYY - hh:mm - timezone');
expect(await applyDay(el)).to.match(/^\d{2}\/\d{2}\/\d{4} - 00:00 - UTC$/);
});
it('G3. timezone removed: no " - timezone" in placeholder, no " - UTC" appended', async () => {
const el = await cfgFixture(['timezone']);
await el.updateComplete;
expect(ph(el)).to.equal('DD/MM/YYYY - hh:mm:ss.SSS');
const applied = await applyDay(el);
expect(applied).to.match(/^\d{2}\/\d{2}\/\d{4} - 00:00:00\.000$/);
expect(applied).to.not.contain('UTC');
});
it('G4. hours + seconds only (minutes, ms, tz removed): "." precedes ms only, ":" otherwise', async () => {
const el = await cfgFixture(['minutes', 'milliseconds', 'timezone']);
await el.updateComplete;
// hours then seconds, separated by ':' (seconds is not ms), no tz suffix
expect(ph(el)).to.equal('DD/MM/YYYY - hh:ss');
expect(await applyDay(el)).to.match(/^\d{2}\/\d{2}\/\d{4} - 00:00$/);
});
it('G5. mask only creates slots for shown time fields (seconds/ms removed → no ss/SSS slots)', async () => {
const el = await cfgFixture(['seconds', 'milliseconds']);
await el.updateComplete;
const inp = triggerInput(el);
await inp.updateComplete;
const press = (key: string) =>
inp.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, composed: true, cancelable: true }));
// DD MM YYYY HH mm = 10 digits; further digits have no slot to land in
for (const ch of '0102202608309999') press(ch);
expect((inp.input as HTMLInputElement).value).to.equal('01/02/2026 - 08:30');
});
it('G6. with no timezone field, the panel renders no timezone control', async () => {
const el = await cfgFixture(['timezone']);
const cal = await settle(el);
expect(cal.shadowRoot.querySelector('.tp-timezone')).to.be.null;
});
});
// ── H. timezone carried in the value only ───────────────────────────────────
describe('H. timezone in value only', () => {
const ZONED = '2026-06-15T14:30:05.250[GMT+05:30 Asia/Kolkata]';
it('H1. a preset value with the zone in the value adopts that zone (no timezone attr)', async () => {
const el = await dtpFixture();
el.value = ZONED;
const cal = await settle2(el);
expect(el.timezone).to.equal('Asia/Kolkata');
expect(cal.timezone).to.equal('Asia/Kolkata');
expect(cal.tpHours).to.equal(14);
expect(cal.tpMinutes).to.equal(30);
});
/**
* An impossible day must not silently become a different one.
*
* `new Date(2026, 1, 31)` rolls forward to March 3 rather than rejecting, so a
* preset of "2026-02-31" used to select, display AND emit March 3 as if the
* caller had asked for it. The variant now clamps to the month length, matching
* the typing mask (E5: "310226" → 28/02/26) and parseZonedValue, so all three
* entry points agree.
*/
it('H1b. an out-of-range preset day clamps to the month length (2026-02-31 → Feb 28)', async () => {
const el = await fixture(
html` `
);
el.value = '2026-02-31';
const cal = await settle2(el);
await triggerInput(el).updateComplete;
expect(cal.value.getMonth(), 'stays in February').to.equal(1);
expect(cal.value.getDate(), 'clamped to 28').to.equal(28);
expect(triggerInput(el).value).to.contain('2026-02-28');
let detail: any = null;
el.addEventListener('nile-change', ((e: CustomEvent) => { detail = e.detail; }) as EventListener);
applyBtn(cal).click();
expect(detail.value).to.match(/^2026-02-28T/);
});
it('H1c. the same clamp applies to a zone-encoded preset', async () => {
const el = await dtpFixture();
el.value = '2026-02-31T10:00:00.000[GMT+00:00 UTC]';
const cal = await settle2(el);
expect(cal.value.getMonth()).to.equal(1);
expect(cal.value.getDate()).to.equal(28);
expect(cal.tpHours, 'time survives the clamp').to.equal(10);
});
it('H1d. the default nile-calendar keeps its published parsing (no clamp): the same value seeds as `new Date(raw)` shifted by the host offset', async () => {
// the clamp is a date-time-picker behavior only; the default calendar must match the published one
const raw = '2026-02-31';
const plain = await fixture(
html` `
) as any;
await plain.updateComplete;
const parsed = new Date(raw);
const expected = new Date(parsed.getTime() - parsed.getTimezoneOffset() * 60000);
if (isNaN(expected.getTime())) {
expect(plain.value == null || isNaN(plain.value.getTime())).to.be.true;
} else {
expect(plain.value.getTime()).to.equal(expected.getTime());
}
});
/**
* Backspacing over the " - " suffix means "switch to UTC".
*
* The zone is authoritative INSIDE `value`, so it is not enough to set the child
* calendar's timezone: `this.timezone` is a public, reflected property, and
* seedTriggerFromValue re-derives the zone from `value`. Previously the wrapper
* updated neither, so `el.timezone` kept reporting the deleted zone and the next
* `value` change resurrected it in the trigger text while the panel still said
* UTC — the two surfaces disagreeing.
*/
describe('H4. deleting the timezone suffix', () => {
const seeded = async () => {
const el = await fixture(
html` `
);
el.value = ZONED;
await settle2(el);
const inp = triggerInput(el);
await inp.updateComplete;
const native = inp.input as HTMLInputElement;
expect(native.value, 'precondition: suffix present').to.contain(' - Asia/Kolkata');
native.setSelectionRange(native.value.length, native.value.length);
inp.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Backspace', bubbles: true, composed: true, cancelable: true,
}));
await settle2(el);
await inp.updateComplete;
return { el, inp, native };
};
it('H4a. the suffix is removed and stays removed', async () => {
const { native } = await seeded();
expect(native.value).to.not.contain('Asia/Kolkata');
expect(native.value).to.contain('2026-06-15 - 14:30:05.250');
});
it('H4b. the public timezone follows the panel to UTC (no stale zone)', async () => {
const { el } = await seeded();
expect(el.timezone, 'property').to.equal('UTC');
expect(el.getAttribute('timezone'), 'reflected attribute').to.equal('UTC');
expect(innerCalendar(el).timezone, 'panel').to.equal('UTC');
});
it('H4c. a later value change does NOT resurrect the deleted zone', async () => {
const { el, inp } = await seeded();
el.value = '2026-07-20';
await settle2(el);
await inp.updateComplete;
expect((inp.input as HTMLInputElement).value).to.not.contain('Asia/Kolkata');
expect(el.timezone).to.equal('UTC');
});
it('H4d. Apply emits UTC, matching what the trigger and panel show', async () => {
const { el } = await seeded();
const cal = innerCalendar(el);
let detail: any = null;
el.addEventListener('nile-change', ((e: CustomEvent) => { detail = e.detail; }) as EventListener);
applyBtn(cal).click();
expect(detail.timezone).to.equal('UTC');
expect(detail.value).to.contain('[GMT+00:00 UTC]');
});
});
it('H2. the preset zone-encoded value round-trips through Apply', async () => {
const el = await dtpFixture();
el.value = ZONED;
const cal = await settle2(el);
let detail: any = null;
el.addEventListener('nile-change', ((e: CustomEvent) => { detail = e.detail; }) as EventListener);
applyBtn(cal).click();
expect(detail).to.not.be.null;
expect(detail.value).to.equal(ZONED);
expect(detail.timezone).to.equal('Asia/Kolkata');
});
it('H3. an unrecognized zone in the preset value falls back to UTC (wall-clock kept)', async () => {
const el = await dtpFixture();
el.value = '2026-06-15T14:30:05.250[GMT+05:30 Mars/Phobos]';
const cal = await settle2(el);
expect(el.timezone).to.equal('UTC');
expect(cal.tpHours).to.equal(14);
expect(cal.tpMinutes).to.equal(30);
});
});
/**
* ── I. TIMEZONE CORRECTNESS AUDIT ──────────────────────────────────────────
*
* DISCOVERED CONTRACT (from reading nile-calendar.ts / nile-date-time-picker.ts):
*
* The picker is a WALL-CLOCK + ZONE picker, NOT an instant viewer.
*
* - The date grid selection (`value`) + the time spinners
* (`tpHours/tpMinutes/tpSeconds/tpMilliseconds`) are the wall-clock time
* AS READ IN THE SELECTED TIMEZONE. They are never re-computed from an
* instant.
* - On Apply the calendar emits:
* detail.value — "YYYY-MM-DDTHH:mm:ss.SSS[GMT±HH:MM Zone]"
* wall-clock digits verbatim + the zone's
* offset AT that instant (composeValueISO).
* detail.timezone — the selected IANA zone.
* detail.localTimezoneValue — a JS Date = the ABSOLUTE INSTANT that the
* wall-clock resolves to in that zone.
* - Switching the timezone only sets `this.timezone`: it PRESERVES the
* wall-clock fields and RE-COMPUTES the instant. So the displayed digits
* stay; the emitted offset + localTimezoneValue change.
* - A zone-encoded preset is parsed by parseZonedValue: the bracket OFFSET is
* ignored, the IANA name is authoritative, wall-clock digits are kept, an
* unrecognized zone falls back to UTC (wall-clock kept).
*
* The GENUINE timezone conversion is therefore:
* wall-clock (in zone) → absolute instant (localTimezoneValue)
* wall-clock (in zone) → zone offset (bracket in value)
*
* INDEPENDENT REFERENCE
* Expected UTC instants and offsets below are hand-computed from published,
* fixed facts (IST is always +05:30; US/Eastern is −05:00 in winter, −04:00
* in summer, DST 2026 = Mar 8 / Nov 1; UK is +00:00 winter / +01:00 summer).
* `zoneOffsetMinutesRef()` is a SECOND, independent implementation using
* Intl.DateTimeFormat 'longOffset' — a different code path from the
* component's formatToParts-subtraction — used only as a cross-check.
*/
describe('I. TZ audit — instant/wall-clock correctness', () => {
// preset a zone-encoded value on the wrapper and return the settled calendar
const presetWrapper = async (raw: string, dateFormat = 'YYYY-MM-DD') => {
const el = await fixture(
html` `
);
el.value = raw;
const cal = await settle2(el);
return { el, cal };
};
// apply and capture the wrapper-level nile-change detail
const applyAndCapture = async (el: NileDateTimePicker, cal: any) => {
let detail: any = null;
el.addEventListener('nile-change', ((e: CustomEvent) => (detail = e.detail)) as EventListener);
applyBtn(cal).click();
await el.updateComplete;
return detail;
};
// a standalone date-time-picker calendar for direct control (case 3, DST edges)
const calFixture = async () => {
const cal = await fixture(
html` `
);
await cal.updateComplete;
return cal;
};
// set a wall-clock + zone directly on a calendar, apply, capture its detail
const pickAndApply = async (
cal: NileCalendar,
y: number, mo1: number, d: number,
hh: number, mm: number, ss: number, ms: number,
zone: string
) => {
(cal as any).value = new Date(y, mo1 - 1, d); // local Y/M/D; only Y/M/D are read back
(cal as any).tpHours = hh;
(cal as any).tpMinutes = mm;
(cal as any).tpSeconds = ss;
(cal as any).tpMilliseconds = ms;
cal.timezone = zone;
await cal.updateComplete;
let detail: any = null;
cal.addEventListener('nile-change', ((e: CustomEvent) => (detail = e.detail)) as EventListener, { once: true });
(cal.shadowRoot!.querySelector('.apply-button') as HTMLElement).click();
return detail;
};
// INDEPENDENT offset oracle (different impl than the component): minutes that
// must be ADDED to UTC to get local, e.g. IST → +330, EST → −300.
const zoneOffsetMinutesRef = (utcMs: number, timeZone: string): number => {
const s = new Intl.DateTimeFormat('en-US', {
timeZone, timeZoneName: 'longOffset', hour12: false,
})
.formatToParts(new Date(utcMs))
.find(p => p.type === 'timeZoneName')!.value; // e.g. "GMT+05:30", "GMT-4"
const m = /GMT([+-])(\d{1,2})(?::(\d{2}))?/.exec(s);
if (!m) return 0; // "GMT"
const sign = m[1] === '-' ? -1 : 1;
return sign * (Number(m[2]) * 60 + Number(m[3] ?? 0));
};
const gmtLabel = (offsetMin: number): string => {
const sign = offsetMin >= 0 ? '+' : '-';
const a = Math.abs(offsetMin);
const p = (n: number) => String(n).padStart(2, '0');
return `${sign}${p(Math.floor(a / 60))}:${p(a % 60)}`;
};
// ── I1: UTC → positive offset (Asia/Kolkata, +05:30, no DST) ──────────────
describe('I1. positive-offset zone (Asia/Kolkata +05:30)', () => {
it('I1a. wall-clock 14:30:05.250 → instant is exactly UTC−05:30 = 09:00:05.250Z', async () => {
const { el, cal } = await presetWrapper('2026-06-15T14:30:05.250[GMT+05:30 Asia/Kolkata]');
// display (panel) shows the picked wall-clock in the adopted zone
expect(cal.tpHours).to.equal(14);
expect(cal.tpMinutes).to.equal(30);
expect(cal.tpSeconds).to.equal(5);
expect(cal.tpMilliseconds).to.equal(250);
expect(cal.timezone).to.equal('Asia/Kolkata');
expect(triggerInput(el).value).to.equal('2026-06-15 - 14:30:05.250 - Asia/Kolkata');
const detail = await applyAndCapture(el, cal);
// INDEPENDENT: 14:30:05.250 IST − 05:30 = 09:00:05.250 UTC
expect(detail.localTimezoneValue.getTime()).to.equal(Date.UTC(2026, 5, 15, 9, 0, 5, 250));
expect(detail.value).to.equal('2026-06-15T14:30:05.250[GMT+05:30 Asia/Kolkata]');
expect(detail.timezone).to.equal('Asia/Kolkata');
});
it('I1b. crosses into the PREVIOUS UTC day: 01:30 IST on Jun-15 → 20:00Z Jun-14', async () => {
const { el, cal } = await presetWrapper('2026-06-15T01:30:00.000[GMT+05:30 Asia/Kolkata]');
const detail = await applyAndCapture(el, cal);
// INDEPENDENT: 01:30 IST − 05:30 = 20:00 previous day
const inst = detail.localTimezoneValue as Date;
expect(inst.getTime()).to.equal(Date.UTC(2026, 5, 14, 20, 0, 0, 0));
expect(inst.getUTCDate()).to.equal(14); // instant is Jun-14 in UTC …
expect(detail.value).to.contain('2026-06-15T01:30'); // … while the displayed date stays Jun-15
expect(detail.value).to.equal('2026-06-15T01:30:00.000[GMT+05:30 Asia/Kolkata]');
});
});
// ── I2: UTC → negative offset (America/New_York) ──────────────────────────
describe('I2. negative-offset zone (America/New_York)', () => {
it('I2a. winter 14:30 EST → instant is UTC+05:00 = 19:30:00Z', async () => {
const { el, cal } = await presetWrapper('2026-01-15T14:30:00.000[GMT-05:00 America/New_York]');
expect(cal.tpHours).to.equal(14);
expect(cal.timezone).to.equal('America/New_York');
const detail = await applyAndCapture(el, cal);
// INDEPENDENT: 14:30 EST + 05:00 = 19:30 UTC (EST = −05:00 in January)
expect(detail.localTimezoneValue.getTime()).to.equal(Date.UTC(2026, 0, 15, 19, 30, 0, 0));
expect(detail.value).to.equal('2026-01-15T14:30:00.000[GMT-05:00 America/New_York]');
});
it('I2b. crosses into the NEXT UTC day: 22:00 EST on Jan-15 → 03:00Z Jan-16', async () => {
const { el, cal } = await presetWrapper('2026-01-15T22:00:00.000[GMT-05:00 America/New_York]');
const detail = await applyAndCapture(el, cal);
// INDEPENDENT: 22:00 EST + 05:00 = 03:00 next day UTC
const inst = detail.localTimezoneValue as Date;
expect(inst.getTime()).to.equal(Date.UTC(2026, 0, 16, 3, 0, 0, 0));
expect(inst.getUTCDate()).to.equal(16); // instant Jan-16 …
expect(detail.value).to.contain('2026-01-15T22:00'); // … displayed date stays Jan-15
});
});
// ── I3: timezone switching contract ──────────────────────────────────────
describe('I3. switching timezone preserves wall-clock, recomputes instant', () => {
// Fixed wall-clock: 2026-06-15 12:00:00.000 (summer → NY=EDT −04, London=BST +01)
const CASES: Array<[string, number, string]> = [
// zone, expected UTC instant ms, expected bracket offset
['UTC', Date.UTC(2026, 5, 15, 12, 0, 0, 0), '+00:00'],
['Asia/Kolkata', Date.UTC(2026, 5, 15, 6, 30, 0, 0), '+05:30'],
['America/New_York', Date.UTC(2026, 5, 15, 16, 0, 0, 0), '-04:00'],
['Europe/London', Date.UTC(2026, 5, 15, 11, 0, 0, 0), '+01:00'],
];
it('I3a. same digits, different instant+offset per zone (contract = wall-clock preserved)', async () => {
const cal = await calFixture();
for (const [zone, expectedMs, expectedOff] of CASES) {
const detail = await pickAndApply(cal, 2026, 6, 15, 12, 0, 0, 0, zone);
// wall-clock digits are IDENTICAL across zones
expect(detail.value, zone).to.contain('2026-06-15T12:00:00.000');
// instant + offset are recomputed per zone
expect(detail.value, zone).to.equal(`2026-06-15T12:00:00.000[GMT${expectedOff} ${zone}]`);
expect(detail.localTimezoneValue.getTime(), zone).to.equal(expectedMs);
// cross-check the offset with the independent Intl oracle
expect(gmtLabel(zoneOffsetMinutesRef(expectedMs, zone)), `ref ${zone}`).to.equal(expectedOff);
}
});
});
// ── I4: daylight-saving time ─────────────────────────────────────────────
describe('I4. DST (America/New_York)', () => {
it('I4a. winter uses −05:00, summer uses −04:00 for the SAME 12:00 wall-clock', async () => {
const cal = await calFixture();
const winter = await pickAndApply(cal, 2026, 1, 15, 12, 0, 0, 0, 'America/New_York');
expect(winter.value).to.equal('2026-01-15T12:00:00.000[GMT-05:00 America/New_York]');
expect(winter.localTimezoneValue.getTime()).to.equal(Date.UTC(2026, 0, 15, 17, 0, 0, 0));
const summer = await pickAndApply(cal, 2026, 7, 15, 12, 0, 0, 0, 'America/New_York');
expect(summer.value).to.equal('2026-07-15T12:00:00.000[GMT-04:00 America/New_York]');
expect(summer.localTimezoneValue.getTime()).to.equal(Date.UTC(2026, 6, 15, 16, 0, 0, 0));
});
it('I4b. spring-forward GAP (2026-03-08 02:30 NY does not exist) — instant + consistent offset', async () => {
const cal = await calFixture();
const detail = await pickAndApply(cal, 2026, 3, 8, 2, 30, 0, 0, 'America/New_York');
// NY DST 2026 begins 02:00 EST → 03:00 EDT at 07:00Z. wallClockToInstant's
// 2-step refinement resolves the nonexistent 02:30 to instant 06:30Z —
// equal to Temporal's 'earlier' disambiguation (an authoritative choice).
const inst = detail.localTimezoneValue as Date;
expect(inst.getTime()).to.equal(Date.UTC(2026, 2, 8, 6, 30, 0, 0));
// `value` is rendered back OUT of that instant, so it shows the wall clock
// the zone actually reads at 06:30Z — 01:30 EST — not the impossible 02:30
// the picker held. Same moment either way; J1 confirms this matches what
// Temporal's 'earlier' disambiguation reports (offset −05:00, hour 01:30).
expect(detail.value).to.equal('2026-03-08T01:30:00.000[GMT-05:00 America/New_York]');
});
it('I4c. fall-back OVERLAP (2026-11-01 01:30 NY is ambiguous) — resolves to earlier (EDT) instant', async () => {
const cal = await calFixture();
const detail = await pickAndApply(cal, 2026, 11, 1, 1, 30, 0, 0, 'America/New_York');
// NY DST 2026 ends 02:00 EDT → 01:00 EST at 06:00Z. 01:30 occurs twice.
// The algorithm keeps the first (EDT, −04:00) occurrence = 05:30Z.
const inst = detail.localTimezoneValue as Date;
expect(inst.getTime()).to.equal(Date.UTC(2026, 10, 1, 5, 30, 0, 0));
expect(zoneOffsetMinutesRef(inst.getTime(), 'America/New_York')).to.equal(-240); // EDT
// consistent here: 01:30 @ −04:00 = 05:30Z
expect(detail.value).to.equal('2026-11-01T01:30:00.000[GMT-04:00 America/New_York]');
});
});
// ── I5: zone-encoded preset round-trip ───────────────────────────────────
describe('I5. zone-encoded preset value', () => {
const RAW = '2026-06-15T14:30:05.250[GMT+05:30 Asia/Kolkata]';
it('I5a. displays date/time/ms/zone and round-trips exactly through Apply', async () => {
const { el, cal } = await presetWrapper(RAW);
expect(cal.tpHours).to.equal(14);
expect(cal.tpMinutes).to.equal(30);
expect(cal.tpSeconds).to.equal(5);
expect(cal.tpMilliseconds).to.equal(250);
expect(cal.timezone).to.equal('Asia/Kolkata');
expect(cal.value.getFullYear()).to.equal(2026);
expect(cal.value.getMonth()).to.equal(5);
expect(cal.value.getDate()).to.equal(15);
const detail = await applyAndCapture(el, cal);
expect(detail.value).to.equal(RAW); // value out === value in
expect(detail.timezone).to.equal('Asia/Kolkata');
expect(detail.localTimezoneValue.getTime()).to.equal(Date.UTC(2026, 5, 15, 9, 0, 5, 250));
});
});
// ── I6: bracket offset disagrees with IANA zone ──────────────────────────
describe('I6. offset validation — IANA name is authoritative', () => {
it('I6a. preset with WRONG bracket offset (+09:00 for Kolkata) is recomputed to +05:30', async () => {
const { el, cal } = await presetWrapper('2026-06-15T14:30:05.250[GMT+09:00 Asia/Kolkata]');
expect(cal.timezone).to.equal('Asia/Kolkata');
const detail = await applyAndCapture(el, cal);
// offset recomputed from the IANA zone, NOT taken from the (wrong) bracket
expect(detail.value).to.equal('2026-06-15T14:30:05.250[GMT+05:30 Asia/Kolkata]');
// instant uses the true +05:30 offset → 09:00:05.250Z (would be 05:30:05.250Z if +09:00 were used)
expect(detail.localTimezoneValue.getTime()).to.equal(Date.UTC(2026, 5, 15, 9, 0, 5, 250));
});
});
// ── I7: invalid / unsupported timezone ───────────────────────────────────
describe('I7. invalid timezone falls back to UTC (wall-clock preserved)', () => {
it('I7a. Mars/Phobos → UTC, digits kept, instant treated as UTC', async () => {
const { el, cal } = await presetWrapper('2026-06-15T14:30:05.250[GMT+05:30 Mars/Phobos]');
expect(el.timezone).to.equal('UTC');
expect(cal.timezone).to.equal('UTC');
expect(cal.tpHours).to.equal(14);
expect(cal.tpMinutes).to.equal(30);
const detail = await applyAndCapture(el, cal);
// UTC offset, and the instant equals the wall-clock read as UTC
expect(detail.value).to.equal('2026-06-15T14:30:05.250[GMT+00:00 UTC]');
expect(detail.localTimezoneValue.getTime()).to.equal(Date.UTC(2026, 5, 15, 14, 30, 5, 250));
});
});
// ── I8: input methods ────────────────────────────────────────────────────
describe('I8. input methods all seed identically', () => {
const RAW = '2026-06-15T14:30:05.250[GMT+05:30 Asia/Kolkata]';
const expectSeeded = (cal: any, el: NileDateTimePicker) => {
expect(cal.timezone).to.equal('Asia/Kolkata');
expect(cal.tpHours).to.equal(14);
expect(cal.tpMinutes).to.equal(30);
expect(cal.tpSeconds).to.equal(5);
expect(cal.tpMilliseconds).to.equal(250);
expect(triggerInput(el).value).to.equal('2026-06-15 - 14:30:05.250 - Asia/Kolkata');
};
it('I8a. via the HTML value attribute', async () => {
const el = await fixture(
html` `
);
const cal = await settle2(el);
expectSeeded(cal, el);
});
it('I8b. via the JS value property', async () => {
const el = await fixture(
html` `
);
el.value = RAW;
const cal = await settle2(el);
expectSeeded(cal, el);
});
it('I8c. a value captured from nile-change re-seeds an identical picker', async () => {
const src = await presetWrapper(RAW);
const emitted = await applyAndCapture(src.el, src.cal);
// feed the emitted value back into a fresh picker
const el2 = await fixture(
html` `
);
el2.value = emitted.value;
const cal2 = await settle2(el2);
expectSeeded(cal2, el2);
const again = await applyAndCapture(el2, cal2);
expect(again.value).to.equal(emitted.value);
expect(again.localTimezoneValue.getTime()).to.equal(emitted.localTimezoneValue.getTime());
});
});
// ── I9: full emission correctness ────────────────────────────────────────
describe('I9. emission correctness (all representations agree on one instant)', () => {
it('I9a. detail.value / timezone / localTimezoneValue and public props are consistent', async () => {
const { el, cal } = await presetWrapper('2026-06-15T14:30:05.250[GMT+05:30 Asia/Kolkata]');
const detail = await applyAndCapture(el, cal);
// 1) emitted payload
expect(detail.value).to.equal('2026-06-15T14:30:05.250[GMT+05:30 Asia/Kolkata]');
expect(detail.timezone).to.equal('Asia/Kolkata');
const instMs = Date.UTC(2026, 5, 15, 9, 0, 5, 250);
expect(detail.localTimezoneValue.getTime()).to.equal(instMs);
// 2) the three representations describe the SAME instant:
// localTimezoneValue rendered back in the zone === the wall-clock digits
const back = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Kolkata', hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
}).formatToParts(detail.localTimezoneValue);
const g = (t: string) => back.find(p => p.type === t)!.value;
expect(`${g('year')}-${g('month')}-${g('day')} ${g('hour')}:${g('minute')}:${g('second')}`)
.to.equal('2026-06-15 14:30:05');
// 3) public component state reflects the selection
expect(el.timezone).to.equal('Asia/Kolkata');
expect(cal.timezone).to.equal('Asia/Kolkata');
expect(cal.value.getDate()).to.equal(15);
expect(cal.tpHours).to.equal(14);
});
});
});
/**
* ── J. DST spring-forward gap regression ───────────────────────────────────
*
* Does `detail.value` (the zone-encoded string) agree with
* `detail.localTimezoneValue` (the emitted instant)?
*
* Input under test: wall-clock 2026-03-08 02:30:00.000, America/New_York.
* US DST 2026 spring-forward: 02:00 EST(−05:00) → 03:00 EDT(−04:00) at 07:00Z.
* Local times in [02:00, 03:00) DO NOT EXIST. 02:30 is in that gap.
*
* Two candidate instants for "02:30":
* interpret with EST(−05:00): 02:30 + 05:00 = 07:30Z (renders 03:30 EDT — "compatible/later")
* interpret with EDT(−04:00): 02:30 + 04:00 = 06:30Z (renders 01:30 EST — "earlier")
*
* A CORRECT representation must be internally consistent:
* parse(value.wallDigits, value.bracketOffset) === localTimezoneValue
*
* This consistency was once broken (the value carried −05:00 while the instant
* was 06:30Z). J3 is the guard that keeps it fixed.
*/
describe('J. DST gap regression (2026-03-08 02:30 America/New_York)', () => {
const NY = 'America/New_York';
const calFixture = async () => {
const cal = await fixture(
html` `
);
await cal.updateComplete;
return cal;
};
const pickAndApply = async (
cal: NileCalendar, y: number, mo1: number, d: number,
hh: number, mm: number, ss: number, ms: number, zone: string
) => {
(cal as any).value = new Date(y, mo1 - 1, d);
(cal as any).tpHours = hh; (cal as any).tpMinutes = mm;
(cal as any).tpSeconds = ss; (cal as any).tpMilliseconds = ms;
cal.timezone = zone;
await cal.updateComplete;
let detail: any = null;
cal.addEventListener('nile-change', ((e: CustomEvent) => (detail = e.detail)) as EventListener, { once: true });
(cal.shadowRoot!.querySelector('.apply-button') as HTMLElement).click();
return detail;
};
// Parse "YYYY-MM-DDTHH:mm:ss.SSS[GMT±HH:MM Zone]" as an offset-datetime →
// absolute epoch ms. Independent of the component's own parser.
const instantFromValue = (value: string): number => {
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})\.(\d{3})\[GMT([+-])(\d{2}):(\d{2}) /.exec(value);
if (!m) throw new Error('unparseable value: ' + value);
const [, Y, Mo, D, H, Mi, S, MS, sign, oh, om] = m;
const wallAsUTC = Date.UTC(+Y, +Mo - 1, +D, +H, +Mi, +S, +MS);
const offMs = (sign === '-' ? -1 : 1) * ((+oh) * 3600000 + (+om) * 60000);
return wallAsUTC - offMs; // instant = wallClock(as UTC) − offset
};
// AUTHORITATIVE candidate instants (hand-derived; cross-checked below).
const INSTANT_EARLIER = Date.UTC(2026, 2, 8, 6, 30, 0, 0); // 06:30Z (EDT-interpretation)
const INSTANT_COMPATIBLE = Date.UTC(2026, 2, 8, 7, 30, 0, 0); // 07:30Z (EST-interpretation)
it('J1. cross-check the two candidate instants against Temporal (authoritative), if present', () => {
// @ts-ignore — Temporal may not be on the lib typings
const T = (globalThis as any).Temporal;
if (!T) { console.warn('[dst-gap] Temporal unavailable; using hand-derived instants'); return; }
const pdt = T.PlainDateTime.from('2026-03-08T02:30:00');
const earlier = pdt.toZonedDateTime(NY, { disambiguation: 'earlier' });
const compatible = pdt.toZonedDateTime(NY, { disambiguation: 'compatible' });
expect(Number(earlier.epochMilliseconds)).to.equal(INSTANT_EARLIER);
expect(Number(compatible.epochMilliseconds)).to.equal(INSTANT_COMPATIBLE);
// Temporal 'earlier' reports offset −05:00 and resolves the wall time to 01:30
expect(earlier.offset).to.equal('-05:00');
expect(earlier.hour).to.equal(1);
expect(earlier.minute).to.equal(30);
});
it('J2. INSTANT is a valid disambiguation: localTimezoneValue == Temporal "earlier" (06:30Z)', async () => {
const cal = await calFixture();
const detail = await pickAndApply(cal, 2026, 3, 8, 2, 30, 0, 0, NY);
// The chosen instant itself is not in question — it equals a legitimate
// Temporal disambiguation ('earlier').
expect(detail.localTimezoneValue.getTime()).to.equal(INSTANT_EARLIER);
});
it('J3. GUARD: value bracket offset is CONSISTENT with localTimezoneValue', async () => {
const cal = await calFixture();
const detail = await pickAndApply(cal, 2026, 3, 8, 2, 30, 0, 0, NY);
// The value string and the emitted instant must denote the SAME moment.
expect(
instantFromValue(detail.value),
`value="${detail.value}" must denote the same instant as localTimezoneValue`
).to.equal(detail.localTimezoneValue.getTime());
});
// Guard: normal + fall-back times are ALREADY consistent and must stay so.
/**
* HOST-timezone regression (distinct from J1–J3, which vary the VALUE's zone).
*
* `new Date(y, m, d, h, …)` interprets its arguments in the HOST's zone. If that
* wall clock does not exist locally — a spring-forward gap — the constructor
* silently slides it forward. Parsing a zoned value through such a Date therefore
* corrupts it based on where the user's machine is, which has nothing to do with
* the value's own zone:
*
* host Asia/Calcutta : new Date(2026,2,8,2,30) → 02:30 ✅
* host America/New_York: new Date(2026,2,8,2,30) → 03:30 ❌ an hour appears
*
* Before the fix the trigger showed 02:30 while the panel held 03:30 and Apply
* emitted 03:30 — a silent shift that the correct-looking input concealed.
*
* NOTE: this test can only FAIL on a host whose zone has a DST gap. On this repo's
* usual host (IST, which has never observed DST) it passes either way, so run the
* suite under `TZ=America/New_York` to give it teeth.
*/
it('J3b. HOST DST gap does not shift a zoned preset (panel + payload keep 02:30)', async () => {
const el = await fixture(
html` `
);
el.value = '2026-03-08T02:30:00.000[GMT-05:00 America/New_York]';
const cal = await settle2(el);
await triggerInput(el).updateComplete;
// 1) the panel holds the wall clock the value named, not a host-normalized one
expect(cal.tpHours, 'panel hours').to.equal(2);
expect(cal.tpMinutes, 'panel minutes').to.equal(30);
expect(cal.value.getDate(), 'panel day').to.equal(8);
expect(cal.value.getMonth(), 'panel month').to.equal(2);
// 2) the trigger agrees
expect(triggerInput(el).value).to.contain('2026-03-08 - 02:30:00.000');
// 3) and Apply emits that same wall clock — the shift used to surface here
let detail: any = null;
el.addEventListener('nile-change', ((e: CustomEvent) => { detail = e.detail; }) as EventListener);
applyBtn(cal).click();
expect(detail).to.not.be.null;
// Assert on the INSTANT, not the digits: the emitted wall clock is now read
// back out of the resolved instant, so a gap time renders as 01:30 −05:00.
// A host-zone shift (the regression this guards) would move the instant to
// 07:30Z and still fail here.
expect(detail.localTimezoneValue.getTime(), 'resolved instant').to.equal(
Date.UTC(2026, 2, 8, 6, 30, 0, 0)
);
expect(instantFromValue(detail.value), 'value must denote that instant').to.equal(
detail.localTimezoneValue.getTime()
);
expect(detail.value).to.match(/\[GMT[+-]\d{2}:\d{2} America\/New_York\]$/);
});
it('J4. GUARD: a normal time stays consistent (IST 14:30 → 09:00Z)', async () => {
const cal = await calFixture();
const detail = await pickAndApply(cal, 2026, 6, 15, 14, 30, 5, 250, 'Asia/Kolkata');
expect(instantFromValue(detail.value)).to.equal(detail.localTimezoneValue.getTime());
expect(detail.localTimezoneValue.getTime()).to.equal(Date.UTC(2026, 5, 15, 9, 0, 5, 250));
});
it('J5. GUARD: a fall-back ambiguous time stays consistent (01:30 Nov-1 → 05:30Z)', async () => {
const cal = await calFixture();
const detail = await pickAndApply(cal, 2026, 11, 1, 1, 30, 0, 0, NY);
expect(instantFromValue(detail.value)).to.equal(detail.localTimezoneValue.getTime());
expect(detail.localTimezoneValue.getTime()).to.equal(Date.UTC(2026, 10, 1, 5, 30, 0, 0));
});
/**
* J2–J5 cover the OUTBOUND path (pick → emit). J6–J8 cover the INBOUND path
* (`value` attribute → trigger text), which used to build the parsed wall clock
* with the host-local `Date` constructor. On a host whose own zone has a gap at
* that wall time, 02:30 normalized forward to 03:30 and the trigger displayed a
* time the caller never supplied.
*
* The asserted invariant — displayed wall clock == supplied wall clock — holds in
* EVERY host zone, so these pass anywhere; they only *catch* the regression when
* the suite runs under a host zone with a matching gap (e.g. TZ=America/New_York).
* J8 reports the host zone so the run makes clear which case was exercised.
*/
it('J6. GUARD: a zone-encoded preset in a DST gap seeds the SUPPLIED wall clock', async () => {
const el = await fixture(html` `);
await settle2(el);
const text = String(triggerInput(el).value ?? '');
expect(text, 'date part').to.contain('2026-03-08');
expect(text, 'supplied wall clock must survive verbatim').to.contain('02:30');
expect(text, 'must not normalize the gap time forward to 03:30').to.not.contain('03:30');
});
it('J7. GUARD: a bare offset-less preset in a DST gap seeds the SUPPLIED wall clock', async () => {
const el = await fixture(html` `);
await settle2(el);
const text = String(triggerInput(el).value ?? '');
expect(text, 'date part').to.contain('2026-03-08');
expect(text, 'supplied wall clock must survive verbatim').to.contain('02:30');
expect(text, 'must not normalize the gap time forward to 03:30').to.not.contain('03:30');
});
it('J8. reports the host zone this run exercised (informational)', () => {
const hostZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const gapShifts = new Date(2026, 2, 8, 2, 30, 0, 0).getHours() !== 2;
console.warn(
`[dst-gap] host zone=${hostZone}; local Date shifts 2026-03-08 02:30 => ${gapShifts}` +
(gapShifts ? ' (J6/J7 actively guard the regression)' : ' (J6/J7 pass trivially here)')
);
expect(hostZone).to.be.a('string');
});
});
/**
* K. `unixEpoch` is the single source of truth for the emitted instant.
*
* `handleApply` resolves the picked wall-clock to ONE instant and renders
* `value` back out of it. These tests pin the resulting invariant — every
* field of the payload (`value` digits, `value` bracket offset, `unixEpoch`,
* `localTimezoneValue`) denotes the same moment — and the one place where
* that changed observable output: spring-forward gaps.
*
* The oracles here are independent of the component: `instantFromValue`
* re-derives an epoch from the string by plain offset arithmetic, and
* `offsetMinutesRef` reads the zone offset via Intl 'longOffset' — a
* different code path from the component's formatToParts subtraction.
*/
describe('K. unixEpoch as the single source of truth', () => {
const calFixture = async () => {
const cal = await fixture(
html` `
);
await cal.updateComplete;
return cal;
};
const pickAndApply = async (
cal: NileCalendar, y: number, mo1: number, d: number,
hh: number, mm: number, ss: number, ms: number, zone: string
) => {
(cal as any).value = new Date(y, mo1 - 1, d);
(cal as any).tpHours = hh; (cal as any).tpMinutes = mm;
(cal as any).tpSeconds = ss; (cal as any).tpMilliseconds = ms;
cal.timezone = zone;
await cal.updateComplete;
let detail: any = null;
cal.addEventListener('nile-change', ((e: CustomEvent) => (detail = e.detail)) as EventListener, { once: true });
(cal.shadowRoot!.querySelector('.apply-button') as HTMLElement).click();
return detail;
};
// "YYYY-MM-DDTHH:mm:ss.SSS[GMT±HH:MM Zone]" → epoch ms, by offset arithmetic only.
const instantFromValue = (value: string): number => {
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})\.(\d{3})\[GMT([+-])(\d{2}):(\d{2}) /.exec(value);
if (!m) throw new Error('unparseable value: ' + value);
const [, Y, Mo, D, H, Mi, S, MS, sign, oh, om] = m;
const offMs = (sign === '-' ? -1 : 1) * ((+oh) * 3600000 + (+om) * 60000);
return Date.UTC(+Y, +Mo - 1, +D, +H, +Mi, +S, +MS) - offMs;
};
const bracketOffsetMinutes = (value: string): number => {
const m = /\[GMT([+-])(\d{2}):(\d{2}) /.exec(value)!;
return (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3]));
};
const zoneOf = (value: string): string => /\[GMT[+-]\d{2}:\d{2} ([^\]]+)\]$/.exec(value)![1];
// independent offset oracle — Intl 'longOffset', not formatToParts subtraction
const offsetMinutesRef = (utcMs: number, timeZone: string): number => {
const s = new Intl.DateTimeFormat('en-US', { timeZone, timeZoneName: 'longOffset', hour12: false })
.formatToParts(new Date(utcMs))
.find(p => p.type === 'timeZoneName')!.value;
const m = /GMT([+-])(\d{1,2})(?::(\d{2}))?/.exec(s);
if (!m) return 0; // bare "GMT" == +00:00
return (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3] ?? 0));
};
/**
* A spread of zones the format has to survive: whole-hour, half-hour,
* quarter-hour, negative, DST-observing (both sides of the transition),
* and both DST edge cases. `ms` is non-zero throughout — the offset lookup
* used to leak the sub-second remainder and report +05:29 for Kolkata.
*/
const CASES: Array<[string, number, number, number, number, number, number, number, string]> = [
['UTC', 2026, 6, 15, 12, 0, 0, 0, 'UTC'],
['half-hour +05:30', 2026, 6, 15, 14, 30, 5, 250, 'Asia/Kolkata'],
['quarter-hour +05:45', 2026, 6, 15, 12, 0, 0, 999, 'Asia/Kathmandu'],
['quarter-hour +08:45', 2026, 6, 15, 23, 59, 59, 123, 'Australia/Eucla'],
['half-hour -03:30', 2026, 1, 15, 8, 15, 0, 1, 'America/St_Johns'],
['negative whole hour', 2026, 6, 15, 0, 0, 0, 500, 'Pacific/Honolulu'],
['DST zone, winter side', 2026, 1, 15, 12, 0, 0, 250, 'America/New_York'],
['DST zone, summer side', 2026, 7, 15, 12, 0, 0, 250, 'America/New_York'],
['spring-forward GAP', 2026, 3, 8, 2, 30, 0, 250, 'America/New_York'],
['fall-back AMBIGUOUS', 2026, 11, 1, 1, 30, 0, 250, 'America/New_York'],
['London spring gap', 2026, 3, 29, 1, 30, 0, 0, 'Europe/London'],
['Lord Howe 30-min DST', 2026, 10, 4, 2, 30, 0, 0, 'Australia/Lord_Howe'],
['southern-hemisphere DST', 2026, 12, 20, 10, 0, 0, 0, 'Pacific/Auckland'],
];
it('K1. value, unixEpoch and localTimezoneValue all denote the SAME instant', async () => {
const cal = await calFixture();
for (const [label, y, mo, d, h, mi, s, ms, zone] of CASES) {
const detail = await pickAndApply(cal, y, mo, d, h, mi, s, ms, zone);
expect(detail.unixEpoch, `${label}: unixEpoch is a number`).to.be.a('number');
expect(detail.localTimezoneValue.getTime(), `${label}: localTimezoneValue === unixEpoch`)
.to.equal(detail.unixEpoch);
expect(instantFromValue(detail.value), `${label}: value="${detail.value}" must reconstruct to unixEpoch`)
.to.equal(detail.unixEpoch);
}
});
it('K2. the bracket offset is the zone\'s TRUE offset at that epoch', async () => {
const cal = await calFixture();
for (const [label, y, mo, d, h, mi, s, ms, zone] of CASES) {
const detail = await pickAndApply(cal, y, mo, d, h, mi, s, ms, zone);
expect(zoneOf(detail.value), `${label}: zone name`).to.equal(zone);
// cross-checked against a second, independent Intl implementation
expect(bracketOffsetMinutes(detail.value), `${label}: offset in "${detail.value}"`)
.to.equal(offsetMinutesRef(detail.unixEpoch, zone));
}
});
it('K3. the wall-clock digits are what the zone actually reads at that epoch', async () => {
const cal = await calFixture();
for (const [label, y, mo, d, h, mi, s, ms, zone] of CASES) {
const detail = await pickAndApply(cal, y, mo, d, h, mi, s, ms, zone);
const shown = new Intl.DateTimeFormat('en-CA', {
timeZone: zone, hourCycle: 'h23',
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
}).formatToParts(new Date(detail.unixEpoch))
.reduce((acc: Record, p) => (p.type === 'literal' ? acc : ((acc[p.type] = p.value), acc)), {});
expect(detail.value, `${label}: digits must match the zone's own reading`).to.equal(
`${shown.year}-${shown.month}-${shown.day}T${shown.hour}:${shown.minute}:${shown.second}` +
`.${String(ms).padStart(3, '0')}[GMT${(() => {
const om = offsetMinutesRef(detail.unixEpoch, zone);
const sg = om >= 0 ? '+' : '-', a = Math.abs(om);
return `${sg}${String(Math.floor(a / 60)).padStart(2, '0')}:${String(a % 60).padStart(2, '0')}`;
})()} ${zone}]`
);
}
});
it('K4. milliseconds survive the epoch round-trip (the +05:29 regression)', async () => {
const cal = await calFixture();
for (const ms of [0, 1, 250, 500, 999]) {
const detail = await pickAndApply(cal, 2026, 6, 15, 14, 30, 5, ms, 'Asia/Kolkata');
expect(detail.value, `ms=${ms}`).to.equal(
`2026-06-15T14:30:05.${String(ms).padStart(3, '0')}[GMT+05:30 Asia/Kolkata]`
);
expect(detail.unixEpoch, `ms=${ms}`).to.equal(Date.UTC(2026, 5, 15, 9, 0, 5, ms));
}
});
it('K5. spring-forward gap emits the RESOLVED wall clock, not the impossible picked one', async () => {
const cal = await calFixture();
// 2026-03-08 02:30 does not exist in New York (02:00 EST jumps to 03:00 EDT).
const detail = await pickAndApply(cal, 2026, 3, 8, 2, 30, 0, 0, 'America/New_York');
expect(detail.unixEpoch, 'instant is unchanged by this refactor')
.to.equal(Date.UTC(2026, 2, 8, 6, 30, 0, 0));
// 01:30 −05:00 is what the zone reads at 06:30Z, and matches Temporal's
// 'earlier' disambiguation (see J1). The old output was 02:30 −04:00: the
// same instant, but a wall clock New York never displays.
expect(detail.value).to.equal('2026-03-08T01:30:00.000[GMT-05:00 America/New_York]');
expect(detail.value, 'must not emit an hour the zone skipped').to.not.contain('T02:30');
});
it('K6. fall-back ambiguity is unchanged (first/EDT occurrence, 01:30 −04:00)', async () => {
const cal = await calFixture();
const detail = await pickAndApply(cal, 2026, 11, 1, 1, 30, 0, 0, 'America/New_York');
expect(detail.unixEpoch).to.equal(Date.UTC(2026, 10, 1, 5, 30, 0, 0));
expect(detail.value).to.equal('2026-11-01T01:30:00.000[GMT-04:00 America/New_York]');
});
it('K7. normal (non-DST-edge) output is byte-for-byte unchanged', async () => {
const cal = await calFixture();
const expected: Array<[string, number[], string, string]> = [
['UTC', [2026, 6, 15, 12, 0, 0, 0], 'UTC', '2026-06-15T12:00:00.000[GMT+00:00 UTC]'],
['Kolkata', [2026, 6, 15, 14, 30, 5, 250], 'Asia/Kolkata', '2026-06-15T14:30:05.250[GMT+05:30 Asia/Kolkata]'],
['Kathmandu', [2026, 6, 15, 12, 0, 0, 0], 'Asia/Kathmandu', '2026-06-15T12:00:00.000[GMT+05:45 Asia/Kathmandu]'],
['NY winter', [2026, 1, 15, 12, 0, 0, 0], 'America/New_York', '2026-01-15T12:00:00.000[GMT-05:00 America/New_York]'],
['NY summer', [2026, 7, 15, 12, 0, 0, 0], 'America/New_York', '2026-07-15T12:00:00.000[GMT-04:00 America/New_York]'],
['Eucla', [2026, 6, 15, 23, 59, 59, 123], 'Australia/Eucla', '2026-06-15T23:59:59.123[GMT+08:45 Australia/Eucla]'],
['midnight', [2026, 6, 15, 0, 0, 0, 0], 'UTC', '2026-06-15T00:00:00.000[GMT+00:00 UTC]'],
];
for (const [label, [y, mo, d, h, mi, s, ms], zone, want] of expected) {
const detail = await pickAndApply(cal, y, mo, d, h, mi, s, ms, zone);
expect(detail.value, label).to.equal(want);
}
});
it('K8. an emitted gap value fed back in is idempotent (instant + string stable)', async () => {
const cal = await calFixture();
const first = await pickAndApply(cal, 2026, 3, 8, 2, 30, 0, 0, 'America/New_York');
const back = await fixture(
html` `
);
await back.updateComplete;
let again: any = null;
back.addEventListener('nile-change', ((e: CustomEvent) => (again = e.detail)) as EventListener, { once: true });
(back.shadowRoot!.querySelector('.apply-button') as HTMLElement).click();
expect(again.unixEpoch, 'instant must survive the round-trip').to.equal(first.unixEpoch);
expect(again.value, 'string must be a fixed point').to.equal(first.value);
});
});
/**
* ── L. C-1 — INSTANT-BEARING `value` INPUTS ─────────────────────────────────
*
* APPROVED CONTRACT (deliberate, and distinct from the wall-clock contract in
* section I):
*
* An ISO-8601 value carrying an explicit UTC designator (`Z`) or a numeric
* offset (`+05:30`) names ONE ABSOLUTE INSTANT. Its epoch must survive:
* - the browser/host timezone,
* - the selected picker timezone,
* - the order `value` and `timezone` are assigned in.
* The selected timezone changes how that instant is DISPLAYED, not which
* instant it is.
*
* Once the user edits the date or a time field, the loaded instant stops
* being authoritative and the existing wall-clock → instant pipeline takes
* over — because a user-entered wall clock means "when the clock in this
* zone reads this", which is a different thing from "this instant".
*
* An offset-LESS datetime (`2026-06-15T14:30:05.250`) is still a wall clock
* and is deliberately unchanged here (that is C-2, tracked separately).
*
* INDEPENDENT REFERENCE
* Every expectation below is a hard `Date.UTC(...)` constant or a digit
* string derived from published, fixed facts — IST is always +05:30,
* Kathmandu +05:45, Kiritimati +14:00, and US/Eastern is −05:00 (EST) in
* winter / −04:00 (EDT) in summer with the 2026 transitions on Mar 8 and
* Nov 1. Nothing is compared against the component's own arithmetic.
*
* HOST-INDEPENDENCE
* These assertions hold under every host zone, which is the point. Run the
* suite under TZ=UTC, TZ=America/New_York, TZ=Asia/Kolkata and
* TZ=Pacific/Auckland; a host-zone leak fails the epoch assertions.
*/
describe('L. C-1 — instant-bearing value inputs', () => {
// 2026-06-15T14:30:05.250Z
const Z_ISO = '2026-06-15T14:30:05.250Z';
const Z_EPOCH = Date.UTC(2026, 5, 15, 14, 30, 5, 250);
// America/New_York 2026-11-01: EDT→EST at 06:00Z, so local 01:30 happens twice.
const NY = 'America/New_York';
const NY_FIRST_0130 = Date.UTC(2026, 10, 1, 5, 30); // 01:30 −04:00 (EDT)
const NY_SECOND_0130 = Date.UTC(2026, 10, 1, 6, 30); // 01:30 −05:00 (EST)
const isoFixture = (value?: string, timezone?: string) =>
fixture(html`
`);
/** the panel's wall clock, as digits, independent of any component helper */
const panelWall = (cal: any) => {
const p = (n: number, l = 2) => String(n).padStart(l, '0');
return `${cal.value.getFullYear()}-${p(cal.value.getMonth() + 1)}-${p(cal.value.getDate())}` +
` ${p(cal.tpHours)}:${p(cal.tpMinutes)}:${p(cal.tpSeconds)}.${p(cal.tpMilliseconds, 3)}`;
};
const applyAndCapture = async (el: NileDateTimePicker, cal: any) => {
let detail: any = null;
const on = ((e: CustomEvent) => { detail = e.detail; }) as EventListener;
el.addEventListener('nile-change', on);
applyBtn(cal).click();
await el.updateComplete;
el.removeEventListener('nile-change', on);
return detail;
};
/**
* Edit a time field (index: 0=h 1=m 2=s 3=ms). Same mechanism the calendar's
* own suite uses for V25–V30 — driving the native input's focus here makes
* the field and the panel re-render each other until `updateComplete` stops
* settling, so go through the component's `nile-focus`/`nile-input` contract.
*/
const typeTimeField = async (cal: any, index: number, raw: string) => {
const field = cal.shadowRoot!.querySelectorAll('.tp-time-field')[index] as any;
field.dispatchEvent(new CustomEvent('nile-focus', { detail: {} }));
field.value = raw;
field.dispatchEvent(new CustomEvent('nile-input', { detail: { value: raw } }));
await cal.updateComplete;
return field;
};
// ── R1 ──────────────────────────────────────────────────────────────────
it('L1. a `Z` value resolves to its exact instant, whatever the host zone', async () => {
const el = await isoFixture(Z_ISO, 'UTC');
const cal = await settle2(el);
const detail = await applyAndCapture(el, cal);
expect(detail.unixEpoch, 'exact instant').to.equal(Z_EPOCH);
expect(detail.localTimezoneValue.getTime()).to.equal(Z_EPOCH);
expect(detail.value).to.equal('2026-06-15T14:30:05.250[GMT+00:00 UTC]');
});
// ── R2 ──────────────────────────────────────────────────────────────────
it('L2. the selected timezone changes the DISPLAY, not the instant (IST +05:30)', async () => {
const el = await isoFixture(Z_ISO, 'Asia/Kolkata');
const cal = await settle2(el);
await triggerInput(el).updateComplete;
// 14:30:05.250Z read in IST (+05:30) is 20:00:05.250 the same day
expect(panelWall(cal), 'panel').to.equal('2026-06-15 20:00:05.250');
expect(triggerInput(el).value, 'trigger').to.equal('2026-06-15 - 20:00:05.250 - Asia/Kolkata');
const detail = await applyAndCapture(el, cal);
expect(detail.unixEpoch, 'instant unchanged by the display zone').to.equal(Z_EPOCH);
expect(detail.value).to.equal('2026-06-15T20:00:05.250[GMT+05:30 Asia/Kolkata]');
});
it('L2b. a +14:00 zone rolls the displayed DATE forward, still the same instant', async () => {
const el = await isoFixture(Z_ISO, 'Pacific/Kiritimati');
const cal = await settle2(el);
// 14:30:05.250Z read at +14:00 is 04:30:05.250 on the NEXT day
expect(panelWall(cal)).to.equal('2026-06-16 04:30:05.250');
expect(cal.currentMonth, 'displayed month follows the rolled date').to.equal(5);
expect(cal.currentYear).to.equal(2026);
const detail = await applyAndCapture(el, cal);
expect(detail.unixEpoch).to.equal(Z_EPOCH);
});
// ── R3 ──────────────────────────────────────────────────────────────────
it('L3. a numeric offset names an instant and is never applied twice', async () => {
// 14:30:05.250+05:30 is 09:00:05.250Z
const EPOCH = Date.UTC(2026, 5, 15, 9, 0, 5, 250);
for (const zone of ['UTC', 'Asia/Kolkata', NY]) {
const el = await isoFixture('2026-06-15T14:30:05.250+05:30', zone);
const cal = await settle2(el);
const detail = await applyAndCapture(el, cal);
expect(detail.unixEpoch, `+05:30 under ${zone}`).to.equal(EPOCH);
}
});
it('L3b. a negative numeric offset behaves the same', async () => {
// 14:30:05.250-08:00 is 22:30:05.250Z
const EPOCH = Date.UTC(2026, 5, 15, 22, 30, 5, 250);
for (const zone of ['UTC', 'Asia/Tokyo']) {
const el = await isoFixture('2026-06-15T14:30:05.250-08:00', zone);
const cal = await settle2(el);
const detail = await applyAndCapture(el, cal);
expect(detail.unixEpoch, `-08:00 under ${zone}`).to.equal(EPOCH);
}
});
// ── R4 ──────────────────────────────────────────────────────────────────
it('L4. all four value/timezone assignment orders converge on one state', async () => {
// 14:30:05.250Z read in JST (+09:00) is 23:30:05.250 the same day
const WANT_WALL = '2026-06-15 23:30:05.250';
const seen: Array<{ how: string; wall: string; epoch: number }> = [];
// (a) both as attributes/properties at construction
{
const el = await isoFixture(Z_ISO, 'Asia/Tokyo');
const cal = await settle2(el);
seen.push({ how: 'declarative', wall: panelWall(cal), epoch: (await applyAndCapture(el, cal)).unixEpoch });
}
// (b) timezone first, then value
{
const el = await isoFixture(undefined, 'Asia/Tokyo');
await settle(el);
el.value = Z_ISO;
const cal = await settle2(el);
seen.push({ how: 'tz-then-value', wall: panelWall(cal), epoch: (await applyAndCapture(el, cal)).unixEpoch });
}
// (c) value and timezone in the same update
{
const el = await isoFixture();
await settle(el);
el.value = Z_ISO;
el.timezone = 'Asia/Tokyo';
const cal = await settle2(el);
seen.push({ how: 'same-update', wall: panelWall(cal), epoch: (await applyAndCapture(el, cal)).unixEpoch });
}
// (d) value, settled, THEN timezone — the ordering hazard
{
const el = await isoFixture();
await settle(el);
el.value = Z_ISO;
await settle2(el);
el.timezone = 'Asia/Tokyo';
const cal = await settle2(el);
seen.push({ how: 'tz-after-settle', wall: panelWall(cal), epoch: (await applyAndCapture(el, cal)).unixEpoch });
}
for (const s of seen) {
expect(s.wall, `${s.how} panel`).to.equal(WANT_WALL);
expect(s.epoch, `${s.how} epoch`).to.equal(Z_EPOCH);
}
});
// ── R5 ──────────────────────────────────────────────────────────────────
it('L5. the ambiguous fall-back hour keeps the EXACT occurrence it was given', async () => {
const el = await isoFixture('2026-11-01T06:30:00.000Z', NY);
const cal = await settle2(el);
// both occurrences read 01:30 locally; only the instant distinguishes them
expect(panelWall(cal)).to.equal('2026-11-01 01:30:00.000');
const detail = await applyAndCapture(el, cal);
expect(detail.unixEpoch, 'the SECOND 01:30 (EST) must survive').to.equal(NY_SECOND_0130);
expect(detail.value).to.equal('2026-11-01T01:30:00.000[GMT-05:00 America/New_York]');
});
it('L5b. and it survives closing and reopening the panel', async () => {
const el = await isoFixture('2026-11-01T06:30:00.000Z', NY);
let cal = await settle2(el);
el.open = true; await settle(el);
el.open = false; await settle(el);
cal = await settle2(el);
const detail = await applyAndCapture(el, cal);
expect(detail.unixEpoch).to.equal(NY_SECOND_0130);
});
it('L5c. the FIRST occurrence is equally preserved', async () => {
const el = await isoFixture('2026-11-01T05:30:00.000Z', NY);
const cal = await settle2(el);
expect(panelWall(cal)).to.equal('2026-11-01 01:30:00.000');
const detail = await applyAndCapture(el, cal);
expect(detail.unixEpoch, 'the FIRST 01:30 (EDT)').to.equal(NY_FIRST_0130);
expect(detail.value).to.equal('2026-11-01T01:30:00.000[GMT-04:00 America/New_York]');
});
// ── R6 ──────────────────────────────────────────────────────────────────
it('L6. editing a time field hands control back to the wall-clock pipeline', async () => {
const el = await isoFixture('2026-11-01T06:30:00.000Z', NY);
const cal = await settle2(el);
await typeTimeField(cal, 1, '31'); // minutes 30 → 31
expect(cal.tpMinutes, 'the edit landed').to.equal(31);
const detail = await applyAndCapture(el, cal);
// Deliberately policy-agnostic: C-1 must not pin which occurrence
// wallClockToInstant picks for an ambiguous 01:31 (that is C-5). What C-1
// guarantees is that the retained instant no longer decides, and that the
// emitted instant is the edited wall clock in the selected zone.
expect(detail.unixEpoch, 'the loaded instant must NOT be reused').to.not.equal(NY_SECOND_0130);
expect(detail.value).to.contain('2026-11-01T01:31:00.000');
expect(detail.value).to.match(/\[GMT-0[45]:00 America\/New_York\]$/);
});
it('L6b. editing the DATE also hands control back', async () => {
const el = await isoFixture('2026-11-01T06:30:00.000Z', NY);
const cal = await settle2(el);
cal.currentYear = 2026; cal.currentMonth = 10;
await cal.updateComplete;
const cells = cal.shadowRoot!.querySelectorAll('.day_date:not(.filler)');
(cells[cells.length - 1] as HTMLElement).click();
await cal.updateComplete;
const detail = await applyAndCapture(el, cal);
expect(detail.unixEpoch).to.not.equal(NY_SECOND_0130);
});
// ── R7 ──────────────────────────────────────────────────────────────────
it('L7. changing the timezone alone re-displays the instant, it does not reschedule', async () => {
const el = await isoFixture('2026-11-01T06:30:00.000Z', NY);
let cal = await settle2(el);
expect(panelWall(cal)).to.equal('2026-11-01 01:30:00.000');
cal.timezone = 'Asia/Tokyo';
await cal.updateComplete;
await settle2(el);
// 06:30Z read in JST (+09:00) is 15:30 the same day
expect(panelWall(cal), 'display follows the new zone').to.equal('2026-11-01 15:30:00.000');
const detail = await applyAndCapture(el, cal);
expect(detail.unixEpoch, 'the instant is unchanged').to.equal(NY_SECOND_0130);
expect(detail.value).to.equal('2026-11-01T15:30:00.000[GMT+09:00 Asia/Tokyo]');
});
// ── R8 ──────────────────────────────────────────────────────────────────
it('L8. milliseconds survive exactly, including in a +05:45 zone', async () => {
for (const ms of [0, 1, 250, 999]) {
const iso = `2026-06-15T00:00:00.${String(ms).padStart(3, '0')}Z`;
const el = await isoFixture(iso, 'Asia/Kathmandu');
const cal = await settle2(el);
// 00:00:00.xxxZ read at +05:45 is 05:45:00.xxx the same day
expect(panelWall(cal), `ms=${ms} panel`).to.equal(
`2026-06-15 05:45:00.${String(ms).padStart(3, '0')}`
);
const detail = await applyAndCapture(el, cal);
expect(detail.unixEpoch, `ms=${ms} instant`).to.equal(Date.UTC(2026, 5, 15, 0, 0, 0, ms));
expect(detail.value, `ms=${ms} bracket`).to.contain('[GMT+05:45 Asia/Kathmandu]');
}
});
// ── R9 ──────────────────────────────────────────────────────────────────
it('L9. the bracketed form keeps its documented behaviour in every rejection case', async () => {
const cases: Array<[string, string, number]> = [
// [value, expected emitted value, expected instant]
// ordinary + self-consistent: unchanged
['2026-06-15T14:30:05.250[GMT+05:30 Asia/Kolkata]',
'2026-06-15T14:30:05.250[GMT+05:30 Asia/Kolkata]', Date.UTC(2026, 5, 15, 9, 0, 5, 250)],
// wrong offset for the zone: still recomputed from the IANA name (I6a)
['2026-06-15T14:30:05.250[GMT+09:00 Asia/Kolkata]',
'2026-06-15T14:30:05.250[GMT+05:30 Asia/Kolkata]', Date.UTC(2026, 5, 15, 9, 0, 5, 250)],
// unresolvable zone: still falls back to UTC, wall clock kept (I7a)
['2026-06-15T14:30:05.250[GMT+05:30 Mars/Phobos]',
'2026-06-15T14:30:05.250[GMT+00:00 UTC]', Date.UTC(2026, 5, 15, 14, 30, 5, 250)],
// impossible day: still clamped to the month length (H1c)
['2026-02-31T10:00:00.000[GMT+00:00 UTC]',
'2026-02-28T10:00:00.000[GMT+00:00 UTC]', Date.UTC(2026, 1, 28, 10, 0, 0, 0)],
];
for (const [value, wantValue, wantEpoch] of cases) {
const el = await isoFixture(value);
const cal = await settle2(el);
const detail = await applyAndCapture(el, cal);
expect(detail.value, value).to.equal(wantValue);
expect(detail.unixEpoch, value).to.equal(wantEpoch);
}
});
it('L9b. a DST-gap bracketed value still seeds the supplied wall clock (J3b/J6)', async () => {
const el = await isoFixture('2026-03-08T02:30:00.000[GMT-05:00 America/New_York]');
const cal = await settle2(el);
expect(cal.tpHours, 'the supplied 02:30 is kept, not rejected').to.equal(2);
expect(cal.tpMinutes).to.equal(30);
const detail = await applyAndCapture(el, cal);
expect(detail.localTimezoneValue.getTime()).to.equal(Date.UTC(2026, 2, 8, 6, 30, 0, 0));
});
// ── R10 ─────────────────────────────────────────────────────────────────
it('L10. controlled round-trip keeps the two 01:30 occurrences DISTINCT', async () => {
// This is the test the companion bracket-offset handling exists for: the
// emitted value is the bracketed form, so without honouring a
// self-consistent bracket offset the second occurrence collapses onto the
// first on the next page load.
const runs: Array<{ seedIso: string; want: number }> = [
{ seedIso: '2026-11-01T05:30:00.000Z', want: NY_FIRST_0130 },
{ seedIso: '2026-11-01T06:30:00.000Z', want: NY_SECOND_0130 },
];
const finals: number[] = [];
for (const { seedIso, want } of runs) {
const el0 = await isoFixture(seedIso, NY);
const cal0 = await settle2(el0);
let emitted = await applyAndCapture(el0, cal0);
expect(emitted.unixEpoch, `gen0 for ${seedIso}`).to.equal(want);
// three generations of "store the emitted value, reload, Apply again"
for (let gen = 1; gen <= 3; gen++) {
const el = await isoFixture(emitted.value);
const cal = await settle2(el);
emitted = await applyAndCapture(el, cal);
expect(emitted.unixEpoch, `gen${gen} for ${seedIso}`).to.equal(want);
}
finals.push(emitted.unixEpoch);
}
expect(finals[0], 'EDT and EST occurrences must not collapse').to.not.equal(finals[1]);
expect(finals[0]).to.equal(NY_FIRST_0130);
expect(finals[1]).to.equal(NY_SECOND_0130);
});
// ── R11 ─────────────────────────────────────────────────────────────────
it('L11. the DEFAULT calendar variant parses an instant string exactly as published (the nile-date-picker path)', async () => {
// The default variant (no `variant`) is outside the date-time-picker work and never seeds the time spinners.
// Its full-ISO strings use standard `new Date(raw)` parsing with no extra host-offset shift (previously
// `new Date(t - t.getTimezoneOffset() * 60000)`, which moved the day for hosts away from UTC), so the value is
// the real instant and the day is the host-local day of that instant, on any host.
const instant = Date.UTC(2027, 0, 1, 2, 0, 0, 0);
const cal = await fixture(
html` `
) as any;
await cal.updateComplete;
expect(cal.variant).to.equal('default');
expect(cal.tpHours, 'the default variant never seeds the time spinners').to.equal(null);
// published default: `new Date(raw)` shifted by the host offset (identical to the 2.0.16 calendar)
const parsed = new Date(instant);
const published = new Date(parsed.getTime() - parsed.getTimezoneOffset() * 60000);
expect(cal.value.getTime(), 'published host-offset shift').to.equal(published.getTime());
});
it('L11b. a date-only range value is a pair of local calendar days (start 00:00:00, end 23:59:59.999), not UTC instants', async () => {
const cal = await fixture(html`
`) as any;
await cal.updateComplete;
expect(cal.startDate).to.be.instanceOf(Date);
expect(cal.endDate).to.be.instanceOf(Date);
expect(cal.startDate.getTime()).to.equal(new Date(2026, 5, 10, 0, 0, 0, 0).getTime());
expect(cal.endDate.getTime()).to.equal(new Date(2026, 5, 20, 23, 59, 59, 999).getTime());
});
it('L11b2. non-date-only range strings keep their historical parse (new Date(raw))', async () => {
const cal = await fixture(html`
`) as any;
await cal.updateComplete;
expect(cal.startDate.getTime()).to.equal(new Date('2026-06-10T08:30:00Z').getTime());
expect(cal.endDate.getTime()).to.equal(new Date('2026-06-20T17:45:10+05:30').getTime());
});
it('L11c. an offset-LESS datetime is read as a wall clock, and pins an epoch', async () => {
const el = await isoFixture('2026-06-15T14:30:05.250', 'UTC');
const cal = await settle2(el);
expect(cal.tpHours, 'digits taken as the wall clock, not converted').to.equal(14);
expect(cal.tpMinutes).to.equal(30);
const detail = await applyAndCapture(el, cal);
expect(detail.value).to.contain('2026-06-15T14:30:05.250');
// C-2: the first explicit epoch pin for an offset-LESS value. Derived from
// the COMPONENT's zone (`timezone="UTC"`), never the host's — so the same
// number is correct under TZ=UTC, TZ=Asia/Kolkata and TZ=America/New_York
// alike. Before C-2 the panel resolved these digits through the host zone,
// so this assertion could only have held on a UTC host.
expect(detail.unixEpoch, 'the wall clock read in the COMPONENT zone')
.to.equal(Date.UTC(2026, 5, 15, 14, 30, 5, 250));
});
it('L11d. the DEFAULT variant seeds an offset-LESS value exactly as published', async () => {
// The shape `nile-visualization` produces: host-local digits, noon-anchored. The default variant parses it
// host-locally with no extra host-offset shift, so the written digits are the Date's LOCAL fields on any host
// (the old shift moved them by the host offset, which changed the day at UTC+13/+14).
const cal = await fixture(
html` `
) as any;
await cal.updateComplete;
expect(cal.variant).to.equal('default');
expect(cal.tpHours, 'the default variant never seeds the time spinners').to.equal(null);
// published default: host-offset shift applied (identical to the 2.0.16 calendar)
const parsed = new Date('2026-06-15T12:00:00');
const published = new Date(parsed.getTime() - parsed.getTimezoneOffset() * 60000);
expect(cal.value.getTime()).to.equal(published.getTime());
});
// ── R12 ─────────────────────────────────────────────────────────────────
it('L12. seeding an instant-bearing value emits nothing', async () => {
const el = await isoFixture(undefined, 'UTC');
await settle(el);
const seen: string[] = [];
for (const name of ['nile-change', 'nile-changed', 'nile-clear']) {
el.addEventListener(name, () => seen.push(name));
}
el.value = Z_ISO;
await settle2(el);
expect(seen, 'seeding must stay silent').to.deep.equal([]);
});
// ── R13 ─────────────────────────────────────────────────────────────────
it('L13. the trigger text and the panel always agree', async () => {
const zones = ['UTC', 'Asia/Kolkata', 'Asia/Kathmandu', NY, 'Pacific/Kiritimati'];
for (const zone of zones) {
const el = await isoFixture(Z_ISO, zone);
const cal = await settle2(el);
await triggerInput(el).updateComplete;
const p = (n: number, l = 2) => String(n).padStart(l, '0');
const want =
`${cal.value.getFullYear()}-${p(cal.value.getMonth() + 1)}-${p(cal.value.getDate())}` +
` - ${p(cal.tpHours)}:${p(cal.tpMinutes)}:${p(cal.tpSeconds)}.${p(cal.tpMilliseconds, 3)}` +
` - ${zone}`;
expect(triggerInput(el).value, `trigger vs panel in ${zone}`).to.equal(want);
}
});
// ── R14 ─────────────────────────────────────────────────────────────────
it('L14. STICKY BY DESIGN: returning the fields to their seeded values restores the instant', async () => {
// Invalidation is a comparison of the derived date/time fields against the
// ones the seed produced — there is no separate dirty flag. So editing a
// field and editing it back lands on exactly the seeded fields, and the
// retained instant is honoured again. That is intentional: the state the
// consumer loaded has been restored. Pinned here so it cannot drift into
// being accidental.
const el = await isoFixture('2026-11-01T06:30:00.000Z', NY);
const cal = await settle2(el);
await typeTimeField(cal, 1, '31');
expect(cal.tpMinutes).to.equal(31);
await typeTimeField(cal, 1, '30');
expect(cal.tpMinutes).to.equal(30);
const detail = await applyAndCapture(el, cal);
expect(detail.unixEpoch, 'the seeded occurrence is restored').to.equal(NY_SECOND_0130);
});
});
/**
* ── L15–L22. C-1 CORRECTION: instant vs bracketed-occurrence semantics ──────
*
* The first cut of C-1 promoted a self-consistent bracketed value into the same
* retained-instant semantics as a `Z` value, which changed its timezone-change
* behaviour. These pin the corrected split:
*
* category A `…Z` / `…±HH:MM` a true instant. Zone-independent.
* A timezone change RE-DISPLAYS it.
* category C `…[GMT±HH:MM Zone]` a wall clock + zone whose offset also
* names WHICH occurrence of an ambiguous
* local time was meant. That memory is
* scoped to that zone: a timezone change
* discards it and the old wall-clock
* semantics resume.
*
* They also pin the invalid-timezone rule: the C-1 path DECLINES rather than
* substituting UTC, so C-3 stays exactly as it was.
*/
describe('L15-L22. C-1 correction — categories A vs C, and invalid zones', () => {
const NY = 'America/New_York';
const NY_FIRST_0130 = Date.UTC(2026, 10, 1, 5, 30); // 01:30 −04:00 (EDT)
const NY_SECOND_0130 = Date.UTC(2026, 10, 1, 6, 30); // 01:30 −05:00 (EST)
const isoFixture = (value?: string, timezone?: string) =>
fixture(html`
`);
const panelWall = (cal: any) => {
const p = (n: number, l = 2) => String(n).padStart(l, '0');
return `${cal.value.getFullYear()}-${p(cal.value.getMonth() + 1)}-${p(cal.value.getDate())}` +
` ${p(cal.tpHours)}:${p(cal.tpMinutes)}:${p(cal.tpSeconds)}.${p(cal.tpMilliseconds, 3)}`;
};
const applyAndCapture = async (el: NileDateTimePicker, cal: any) => {
let detail: any = null;
const on = ((e: CustomEvent) => { detail = e.detail; }) as EventListener;
el.addEventListener('nile-change', on);
applyBtn(cal).click();
await el.updateComplete;
el.removeEventListener('nile-change', on);
return detail;
};
const typeTimeField = async (cal: any, index: number, raw: string) => {
const field = cal.shadowRoot!.querySelectorAll('.tp-time-field')[index] as any;
field.dispatchEvent(new CustomEvent('nile-focus', { detail: {} }));
field.value = raw;
field.dispatchEvent(new CustomEvent('nile-input', { detail: { value: raw } }));
await cal.updateComplete;
return field;
};
/** change the panel's zone and let both elements settle */
const setZone = async (el: NileDateTimePicker, cal: any, zone: string) => {
cal.timezone = zone;
await cal.updateComplete;
await settle2(el);
};
// ── L15 ─────────────────────────────────────────────────────────────────
it('L15. a bracketed value keeps WALL-CLOCK timezone semantics (not instant)', async () => {
const el = await isoFixture('2026-06-15T14:30:00.000[GMT+05:30 Asia/Kolkata]');
const cal = await settle2(el);
expect(panelWall(cal), 'seeded digits').to.equal('2026-06-15 14:30:00.000');
expect(cal.timezone, 'zone adopted from the bracket').to.equal('Asia/Kolkata');
await setZone(el, cal, 'Asia/Tokyo');
expect(panelWall(cal), 'digits are PRESERVED, not re-displayed').to.equal('2026-06-15 14:30:00.000');
const detail = await applyAndCapture(el, cal);
// 14:30 JST (+09:00 year-round) is 05:30 UTC
expect(detail.unixEpoch, 'epoch means 14:30 Tokyo').to.equal(Date.UTC(2026, 5, 15, 5, 30, 0, 0));
expect(detail.value).to.equal('2026-06-15T14:30:00.000[GMT+09:00 Asia/Tokyo]');
});
// ── L16 ─────────────────────────────────────────────────────────────────
it('L16. the bracket occurrence memory is discarded on a timezone change', async () => {
const el = await isoFixture('2026-11-01T01:30:00.000[GMT-05:00 America/New_York]', NY);
const cal = await settle2(el);
expect(cal.seedInstant, 'the second occurrence is retained on load').to.equal(NY_SECOND_0130);
expect(cal.seedZone, 'and it is scoped to New York').to.equal(NY);
await setZone(el, cal, 'Asia/Tokyo');
expect(cal.seedInstant, 'occurrence memory discarded').to.equal(null);
expect(cal.seedZone).to.equal(null);
expect(panelWall(cal), '01:30 digits preserved').to.equal('2026-11-01 01:30:00.000');
const detail = await applyAndCapture(el, cal);
// 01:30 JST is the previous day 16:30 UTC
expect(detail.unixEpoch, 'recomputed as 01:30 Tokyo').to.equal(Date.UTC(2026, 9, 31, 16, 30, 0, 0));
});
// ── L17 ─────────────────────────────────────────────────────────────────
it('L17. the bracket occurrence memory is discarded on a time edit', async () => {
const el = await isoFixture('2026-11-01T01:30:00.000[GMT-05:00 America/New_York]', NY);
const cal = await settle2(el);
expect(cal.seedInstant).to.equal(NY_SECOND_0130);
await typeTimeField(cal, 1, '31');
expect(cal.tpMinutes).to.equal(31);
const detail = await applyAndCapture(el, cal);
// policy-agnostic: C-1 must not pin which occurrence of the ambiguous 01:31
// wallClockToInstant picks (that is C-5). It must only stop using the seed.
expect(detail.unixEpoch, 'the seeded occurrence is not reused').to.not.equal(NY_SECOND_0130);
expect(detail.value).to.contain('2026-11-01T01:31:00.000');
expect(detail.value).to.match(/\[GMT-0[45]:00 America\/New_York\]$/);
});
// ── L18 ─────────────────────────────────────────────────────────────────
it('L18. leaving and returning to the zone does NOT resurrect the occurrence', async () => {
const el = await isoFixture('2026-11-01T01:30:00.000[GMT-05:00 America/New_York]', NY);
const cal = await settle2(el);
expect(cal.seedInstant).to.equal(NY_SECOND_0130);
await setZone(el, cal, 'Asia/Tokyo');
expect(cal.seedInstant, 'cleared on the way out').to.equal(null);
await setZone(el, cal, NY);
expect(cal.seedInstant, 'and NOT restored on the way back').to.equal(null);
expect(panelWall(cal)).to.equal('2026-11-01 01:30:00.000');
const detail = await applyAndCapture(el, cal);
expect(detail.unixEpoch, 'the retained second occurrence must not come back')
.to.not.equal(NY_SECOND_0130);
expect([NY_FIRST_0130, NY_SECOND_0130], 'resolved by wallClockToInstant')
.to.include(detail.unixEpoch);
});
// ── L19 ─────────────────────────────────────────────────────────────────
it('L19. an invalid timezone declines the C-1 path entirely (no throw)', async () => {
let threw: string | null = null;
let el!: NileDateTimePicker;
let cal: any;
try {
el = await isoFixture('2026-06-15T14:30:05.250Z', 'Mars/Phobos');
cal = await settle2(el);
} catch (e) { threw = (e as Error).message; }
expect(threw, 'Intl must never receive an invalid zone').to.equal(null);
expect(cal.seedInstant, 'the new path declined').to.equal(null);
expect(cal.seedZone).to.equal(null);
});
// ── L20 ─────────────────────────────────────────────────────────────────
it('L20. invalid-zone behaviour is byte-identical to pre-C-1 (initial load)', async () => {
const INSTANT = Date.UTC(2026, 5, 15, 14, 30, 5, 250);
// pre-C-1: parsePresetValue read the instant through HOST-local getters,
// then wallClockToInstant's invalid-zone catch recomposed those same
// host-local digits. Both halves are derived here, so this holds on any host.
const host = new Date(INSTANT);
const p = (n: number, l = 2) => String(n).padStart(l, '0');
const preC1Panel =
`${host.getFullYear()}-${p(host.getMonth() + 1)}-${p(host.getDate())}` +
` ${p(host.getHours())}:${p(host.getMinutes())}:${p(host.getSeconds())}.${p(host.getMilliseconds(), 3)}`;
const preC1Epoch = new Date(
host.getFullYear(), host.getMonth(), host.getDate(),
host.getHours(), host.getMinutes(), host.getSeconds(), host.getMilliseconds()
).getTime();
const el = await isoFixture('2026-06-15T14:30:05.250Z', 'Mars/Phobos');
const cal = await settle2(el);
expect(panelWall(cal), 'host-local digits, as before C-1').to.equal(preC1Panel);
const detail = await applyAndCapture(el, cal);
expect(detail.unixEpoch, 'host-local instant, as before C-1').to.equal(preC1Epoch);
expect(detail.timezone, 'the invalid zone is still echoed (C-3, untouched)').to.equal('Mars/Phobos');
});
it('L20b. and when a VALID instant seed later gets an invalid timezone', async () => {
const INSTANT = Date.UTC(2026, 5, 15, 14, 30, 5, 250);
const host = new Date(INSTANT);
const p = (n: number, l = 2) => String(n).padStart(l, '0');
const preC1Panel =
`${host.getFullYear()}-${p(host.getMonth() + 1)}-${p(host.getDate())}` +
` ${p(host.getHours())}:${p(host.getMinutes())}:${p(host.getSeconds())}.${p(host.getMilliseconds(), 3)}`;
const el = await isoFixture('2026-06-15T14:30:05.250Z', 'Asia/Kolkata');
const cal = await settle2(el);
expect(cal.seedInstant, 'valid zone seeds normally').to.equal(INSTANT);
expect(panelWall(cal)).to.equal('2026-06-15 20:00:05.250');
await setZone(el, cal, 'Mars/Phobos');
expect(cal.seedInstant, 'seed dropped when the zone becomes invalid').to.equal(null);
expect(panelWall(cal), 're-parsed to the pre-C-1 host-local reading').to.equal(preC1Panel);
});
// ── L21 ─────────────────────────────────────────────────────────────────
it('L21. trigger and panel BOTH decline for an invalid zone, so they still agree', async () => {
const el = await isoFixture('2026-06-15T14:30:05.250Z', 'Mars/Phobos');
const cal = await settle2(el);
await triggerInput(el).updateComplete;
const p = (n: number, l = 2) => String(n).padStart(l, '0');
const want =
`${cal.value.getFullYear()}-${p(cal.value.getMonth() + 1)}-${p(cal.value.getDate())}` +
` - ${p(cal.tpHours)}:${p(cal.tpMinutes)}:${p(cal.tpSeconds)}.${p(cal.tpMilliseconds, 3)}` +
` - Mars/Phobos`;
expect(triggerInput(el).value, 'trigger matches the panel').to.equal(want);
});
// ── L22 ─────────────────────────────────────────────────────────────────
it('L22. CROSS-ZONE LEAK: Apply can never emit an epoch retained from another zone', async () => {
// The hole found in review: the timezone-change hook only clears while the
// fingerprint matches, so `edit -> change zone -> edit back` used to restore
// a New York occurrence while the panel read Tokyo. The Apply guard's
// `seedZone === this.timezone` clause is what closes it.
const el = await isoFixture('2026-11-01T01:30:00.000[GMT-05:00 America/New_York]', NY);
const cal = await settle2(el);
expect(cal.seedInstant).to.equal(NY_SECOND_0130);
await typeTimeField(cal, 1, '31'); // fingerprint now differs
await setZone(el, cal, 'Asia/Tokyo'); // zone moves while "dirty"
await typeTimeField(cal, 1, '30'); // fingerprint matches the seed again
expect(panelWall(cal), 'the panel reads 01:30').to.equal('2026-11-01 01:30:00.000');
expect(cal.timezone, 'in Tokyo').to.equal('Asia/Tokyo');
const detail = await applyAndCapture(el, cal);
expect(detail.unixEpoch, 'a New York occurrence must NOT leak into Tokyo')
.to.not.equal(NY_SECOND_0130);
expect(detail.unixEpoch, 'must be 01:30 Tokyo').to.equal(Date.UTC(2026, 9, 31, 16, 30, 0, 0));
expect(detail.value).to.equal('2026-11-01T01:30:00.000[GMT+09:00 Asia/Tokyo]');
});
// ── L23 ─────────────────────────────────────────────────────────────────
it('L23. a seed cannot survive Reset and be reused in a DIFFERENT zone', async () => {
// Found in pre-commit review. `handleReset`/`clearDate` null the selection
// but are not seed-aware, so a stale `seedInstant` used to outlive them. A
// later zone change plus a re-pick that happened to reproduce the seeded
// fields then emitted the OLD zone's epoch under the new zone.
const cal = await fixture(html`
`) as any;
await cal.updateComplete; await cal.updateComplete;
expect(cal.seedInstant, 'seeded').to.equal(Date.UTC(2026, 5, 15, 14, 30, 5, 250));
(cal.shadowRoot!.querySelector('nile-button.clear-button') as HTMLElement).click();
await cal.updateComplete;
expect(cal.seedInstant, 'Reset must forget the retained instant').to.equal(null);
expect(cal.seedFieldsKey).to.equal(null);
expect(cal.seedZone).to.equal(null);
});
it('L23b. …and the full exploit sequence resolves in the VISIBLE zone', async () => {
const cal = await fixture(html`
`) as any;
await cal.updateComplete; await cal.updateComplete;
(cal.shadowRoot!.querySelector('nile-button.clear-button') as HTMLElement).click();
await cal.updateComplete;
cal.timezone = 'Asia/Kolkata';
await cal.updateComplete;
// rebuild exactly the fields the seed had produced
cal.currentYear = 2026; cal.currentMonth = 5;
await cal.updateComplete;
const cell = [...cal.shadowRoot!.querySelectorAll('.day_date')]
.find(d => !d.classList.contains('filler') &&
(d as HTMLElement).textContent!.trim().replace(/\D/g, '') === '15') as HTMLElement;
cell.click();
await cal.updateComplete;
for (const [i, v] of [[0, '14'], [1, '30'], [2, '5'], [3, '250']] as Array<[number, string]>) {
const f = cal.shadowRoot!.querySelectorAll('.tp-time-field')[i] as any;
f.dispatchEvent(new CustomEvent('nile-focus', { detail: {} }));
f.value = v;
f.dispatchEvent(new CustomEvent('nile-input', { detail: { value: v } }));
await cal.updateComplete;
}
let detail: any = null;
cal.addEventListener('nile-change', ((e: CustomEvent) => { detail = e.detail; }) as EventListener);
(cal.shadowRoot!.querySelector('nile-button.apply-button') as HTMLElement).click();
await cal.updateComplete;
// 14:30:05.250 IST (+05:30, year-round) is 09:00:05.250 UTC
expect(detail.unixEpoch, 'the visible zone decides the instant')
.to.equal(Date.UTC(2026, 5, 15, 9, 0, 5, 250));
expect(detail.unixEpoch, 'the UTC-seeded epoch must not leak into IST')
.to.not.equal(Date.UTC(2026, 5, 15, 14, 30, 5, 250));
});
// ── L24 ─────────────────────────────────────────────────────────────────
it('L24. a STALE seed is dropped by any timezone change, not just a matching one', async () => {
// The general rule behind L23: once the fields have diverged from the seed,
// that seed can never legitimately describe a different zone, so a zone
// change discards it rather than leaving it dormant.
const el = await isoFixture('2026-06-15T14:30:05.250Z', 'UTC');
const cal = await settle2(el);
expect(cal.seedInstant).to.equal(Date.UTC(2026, 5, 15, 14, 30, 5, 250));
await typeTimeField(cal, 1, '31'); // fields diverge
expect(cal.seedInstant, 'still held while the zone is unchanged').to.not.equal(null);
await setZone(el, cal, 'Asia/Kolkata'); // zone moves while stale
expect(cal.seedInstant, 'a stale seed is dropped on a zone change').to.equal(null);
expect(cal.seedFieldsKey).to.equal(null);
});
it('L24b. Set to now followed by a timezone change also drops the seed', async () => {
const el = await isoFixture('2026-06-15T14:30:05.250Z', 'UTC');
const cal = await settle2(el);
expect(cal.seedInstant).to.not.equal(null);
(cal.shadowRoot!.querySelector('#tp-set-to-now') as HTMLElement).click();
await settle2(el);
await setZone(el, cal, 'Pacific/Auckland');
expect(cal.seedInstant, 'no dormant seed after Set to now + zone change').to.equal(null);
});
});
/**
* M. C-2 — an offset-LESS `value` is a WALL CLOCK, and the host must not touch it.
*
* `2026-03-08T02:30:00.000` carries no zone, so it names digits, not an instant.
* The panel used to resolve it with `new Date(raw)` + host-local getters, which
* round-trips losslessly ONLY while that wall clock exists on the host. It does
* not exist in America/New_York (02:00 → 03:00 that day), so the constructor
* slid it to 03:30 and the panel showed, and Apply emitted, a time nobody
* supplied — while the trigger text, parsed UTC-anchored, still showed 02:30.
*
* J7 is the trigger-side guard for this; these are the panel-side twins, plus
* the epoch pins that no offset-less test carried before.
*
* Every assertion below is derived from the COMPONENT's `timezone`, never the
* host's, so the expected numbers are identical under TZ=UTC, TZ=Asia/Kolkata
* and TZ=America/New_York. Under the old behavior the gap cases FAIL on a host
* whose zone has a matching gap and pass elsewhere — which is exactly the
* host-dependence being removed, so run the suite under the TZ matrix.
*/
describe('M. C-2 — offset-less datetime is a host-free wall clock', () => {
const NY = 'America/New_York';
const isoFixture = (value?: string, timezone?: string) =>
fixture(html`
`);
const applyAndCapture = async (el: NileDateTimePicker, cal: any) => {
let detail: any = null;
const on = ((e: CustomEvent) => { detail = e.detail; }) as EventListener;
el.addEventListener('nile-change', on);
applyBtn(cal).click();
await el.updateComplete;
el.removeEventListener('nile-change', on);
return detail;
};
const panelWall = (cal: any) => {
const p = (n: number, l = 2) => String(n).padStart(l, '0');
return `${cal.value.getFullYear()}-${p(cal.value.getMonth() + 1)}-${p(cal.value.getDate())}` +
` ${p(cal.tpHours)}:${p(cal.tpMinutes)}:${p(cal.tpSeconds)}.${p(cal.tpMilliseconds, 3)}`;
};
it('M1. PANEL twin of J7: a value in the HOST New York gap keeps its digits', async () => {
const el = await isoFixture('2026-03-08T02:30:00.000', 'UTC');
const cal = await settle2(el);
expect(panelWall(cal), 'the supplied wall clock, verbatim')
.to.equal('2026-03-08 02:30:00.000');
const detail = await applyAndCapture(el, cal);
expect(detail.unixEpoch, '02:30 read in UTC, the component zone')
.to.equal(Date.UTC(2026, 2, 8, 2, 30, 0, 0));
});
it('M2. a value in the HOST Auckland gap keeps its digits', async () => {
const el = await isoFixture('2026-09-27T02:30:00.000', 'UTC');
const cal = await settle2(el);
expect(panelWall(cal)).to.equal('2026-09-27 02:30:00.000');
const detail = await applyAndCapture(el, cal);
expect(detail.unixEpoch).to.equal(Date.UTC(2026, 8, 27, 2, 30, 0, 0));
});
it('M3. the trigger text and the panel derive the SAME wall clock', async () => {
// The two halves parse `value` through different code paths. Before C-2
// they disagreed for exactly the gap values above.
for (const raw of ['2026-03-08T02:30:00.000', '2026-09-27T02:30:00.000',
'2026-06-15T14:30:05.250']) {
const el = await isoFixture(raw, 'UTC');
const cal = await settle2(el);
const text = String(triggerInput(el).value ?? '');
const [date, time] = panelWall(cal).split(' ');
expect(text, `${raw}: trigger date`).to.contain(date);
expect(text, `${raw}: trigger time`).to.contain(time.slice(0, 5));
}
});
it('M4. an ordinary value resolves by the component zone, not the host', async () => {
const el = await isoFixture('2026-06-15T14:30:00.000', 'Asia/Kolkata');
const cal = await settle2(el);
expect(panelWall(cal)).to.equal('2026-06-15 14:30:00.000');
const detail = await applyAndCapture(el, cal);
// 14:30 in Kolkata is 09:00 UTC — IST is +05:30 year round.
expect(detail.unixEpoch).to.equal(Date.UTC(2026, 5, 15, 9, 0, 0, 0));
});
it('M5. a value in the SELECTED zone gap still reaches the gap policy unchanged', async () => {
// The digits land in New York's own gap. C-2 must not pre-empt that: it
// preserves the digits and leaves `wallClockToInstant` to resolve them,
// which is the C-5 policy and is deliberately NOT changed here.
const el = await isoFixture('2026-03-08T02:30:00.000', NY);
const cal = await settle2(el);
expect(panelWall(cal), 'digits preserved for the policy to act on')
.to.equal('2026-03-08 02:30:00.000');
const detail = await applyAndCapture(el, cal);
// 06:30Z — the existing gap policy's answer, reached identically from a
// seeded value and from a panel pick.
expect(detail.unixEpoch).to.equal(Date.UTC(2026, 2, 8, 6, 30, 0, 0));
});
it('M6. the seconds-omitted form works', async () => {
const el = await isoFixture('2026-06-15T08:00', 'UTC');
const cal = await settle2(el);
expect(panelWall(cal)).to.equal('2026-06-15 08:00:00.000');
const detail = await applyAndCapture(el, cal);
expect(detail.unixEpoch).to.equal(Date.UTC(2026, 5, 15, 8, 0, 0, 0));
});
it('M7. milliseconds survive', async () => {
const el = await isoFixture('2026-06-15T14:30:05.999', 'UTC');
const cal = await settle2(el);
expect(panelWall(cal)).to.equal('2026-06-15 14:30:05.999');
const detail = await applyAndCapture(el, cal);
expect(detail.unixEpoch).to.equal(Date.UTC(2026, 5, 15, 14, 30, 5, 999));
});
it('M8. an impossible day clamps to the month end, as the trigger already did', async () => {
const el = await isoFixture('2026-02-31T10:00:00.000', 'UTC');
const cal = await settle2(el);
expect(panelWall(cal), 'clamped, not rolled forward into March')
.to.equal('2026-02-28 10:00:00.000');
});
it('M9. offset-less retains NO instant, so a zone change keeps the digits', async () => {
const el = await isoFixture('2026-06-15T14:30:00.000', 'UTC');
const cal = await settle2(el);
expect(cal.seedInstant, 'names no instant, so nothing is retained').to.equal(null);
el.timezone = NY;
cal.timezone = NY;
await settle2(el);
expect(panelWall(cal), 'a wall clock follows the zone, it does not move')
.to.equal('2026-06-15 14:30:00.000');
const detail = await applyAndCapture(el, cal);
// 14:30 in New York on 2026-06-15 is EDT (−04:00) → 18:30Z.
expect(detail.unixEpoch, 'same digits, re-derived epoch')
.to.equal(Date.UTC(2026, 5, 15, 18, 30, 0, 0));
});
it('M10. instant-bearing and bracketed forms are untouched by the C-2 branch', async () => {
const z = await isoFixture('2026-06-15T14:30:05.250Z', 'UTC');
const zcal = await settle2(z);
expect(zcal.seedInstant, 'Z still seeds an instant (C-1)')
.to.equal(Date.UTC(2026, 5, 15, 14, 30, 5, 250));
expect(zcal.seedZone, 'category A is zone-independent').to.equal(null);
const off = await isoFixture('2026-06-15T14:30:05.250+05:30', 'UTC');
const ocal = await settle2(off);
expect(ocal.seedInstant, 'a numeric offset is an instant too')
.to.equal(Date.UTC(2026, 5, 15, 9, 0, 5, 250));
const br = await isoFixture('2026-11-01T01:30:00.000[GMT-04:00 America/New_York]', 'UTC');
const bcal = await settle2(br);
expect(bcal.seedZone, 'the bracketed form still records its zone').to.equal(NY);
expect(bcal.seedInstant, 'and still picks the EDT occurrence')
.to.equal(Date.UTC(2026, 10, 1, 5, 30));
const dateOnly = await isoFixture('2026-06-15', 'UTC');
const dcal = await settle2(dateOnly);
expect(dcal.seedInstant, 'a date-only value seeds no instant').to.equal(null);
expect(dcal.value.getFullYear()).to.equal(2026);
expect(dcal.value.getMonth()).to.equal(5);
expect(dcal.value.getDate()).to.equal(15);
});
});
// ── N. millisecond precision vs the timezone offset ───────────────────────
//
// REGRESSION PIN. The historical bug: `composeValueISO` looked the offset up
// at an instant that still carried its sub-second remainder. `zoneOffsetMs`
// formats to SECOND granularity and subtracts, so it returned
// `trueOffset - ms`, and Asia/Kolkata printed `+05:29` instead of `+05:30`.
//
// Two independent guards now prevent that, and this section pins the
// OBSERVABLE contract rather than either mechanism:
// 1. `zoneOffsetMs(epochMs - ms)` — strip the remainder before the lookup;
// 2. `Math.round(offMs / 60000) * 60000` — snap to whole minutes.
// Because `ms < 1000`, guard 2 alone masks the loss of guard 1, so removing
// guard 1 by itself is NOT observable. These tests fail when BOTH are gone,
// which is the bug as it actually shipped.
describe('N. milliseconds must not contaminate the emitted timezone offset', () => {
const MS_VALUES = [0, 1, 9, 10, 99, 100, 250, 500, 999];
// +05:30, +05:45 and +08:45 — a half-hour and two quarter-hour zones, where
// an off-by-one-minute offset is unmistakable.
const ZONES = ['Asia/Kolkata', 'Asia/Kathmandu', 'Australia/Eucla'];
/**
* INDEPENDENT ORACLE — deliberately NOT `zoneOffsetMs` / `composeValueISO`.
* Reads Intl's own PRINTED offset ("GMT+05:30") instead of reconstructing it
* by differencing formatted date parts, so a fault in the implementation's
* arithmetic cannot validate itself. Throws rather than returning a guess,
* so an unsupported runtime fails loudly instead of passing silently.
*/
const trueOffsetMs = (epochMs: number, zone: string): number => {
for (const style of ['longOffset', 'shortOffset'] as const) {
let text = '';
try {
const parts = new Intl.DateTimeFormat('en-US',
{ timeZone: zone, timeZoneName: style } as Intl.DateTimeFormatOptions)
.formatToParts(new Date(epochMs));
const p = parts.find(x => x.type === 'timeZoneName');
text = p ? p.value.trim() : '';
} catch {
continue;
}
if (text === 'GMT' || text === 'UTC') return 0;
const m = /^(?:GMT|UTC)([+-])(\d{1,2})(?::(\d{2}))?$/.exec(text);
if (m) {
const sign = m[1] === '-' ? -1 : 1;
return sign * (Number(m[2]) * 3600000 + Number(m[3] ?? 0) * 60000);
}
}
throw new Error(`oracle: cannot read a numeric offset for ${zone} from Intl`);
};
const BRACKET_RE =
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})\.(\d{3})\[GMT([+-])(\d{2}):(\d{2})\s+([^\]]+)\]$/;
const parseEmitted = (value: string) => {
const m = BRACKET_RE.exec(value);
if (!m) throw new Error(`emitted value is not the Nile bracketed form: ${value}`);
return {
ms: Number(m[7]),
offsetMs: (m[8] === '-' ? -1 : 1) * (Number(m[9]) * 3600000 + Number(m[10]) * 60000),
offsetText: `${m[8]}${m[9]}:${m[10]}`,
zone: m[11],
};
};
const msFixture = (value: string, timezone: string) =>
fixture(html`
`);
const applyAndCapture = async (el: NileDateTimePicker, cal: any) => {
let detail: any = null;
const on = ((e: CustomEvent) => { detail = e.detail; }) as EventListener;
el.addEventListener('nile-change', on);
applyBtn(cal).click();
await el.updateComplete;
el.removeEventListener('nile-change', on);
return detail;
};
for (const zone of ZONES) {
for (const ms of MS_VALUES) {
const frac = String(ms).padStart(3, '0');
it(`N1. ${zone} keeps the exact offset and .${frac} through emit -> reload`, async () => {
const input = `2026-06-15T14:30:45.${frac}`;
const el = await msFixture(input, zone);
const cal = await settle2(el);
const detail = await applyAndCapture(el, cal);
expect(detail, 'Apply emitted a payload').to.exist;
expect(detail.unixEpoch, 'unixEpoch is a number').to.be.a('number');
const expectedOffset = trueOffsetMs(detail.unixEpoch, zone);
const emitted = parseEmitted(detail.value);
// the offset must be the zone's TRUE offset, from the oracle
expect(emitted.offsetMs,
`${zone} printed ${emitted.offsetText} but the true offset is ` +
`${expectedOffset / 60000} min (ms=${ms})`).to.equal(expectedOffset);
// and specifically never short by exactly one minute — the +05:29 shape
expect(emitted.offsetMs,
`offset is one minute short: the millisecond remainder leaked into ` +
`the offset lookup (ms=${ms}, ${zone})`).to.not.equal(expectedOffset - 60000);
// milliseconds survive into the epoch and the printed digits agree
expect(detail.unixEpoch % 1000, 'epoch carries the milliseconds').to.equal(ms);
expect(emitted.ms, 'printed milliseconds match the epoch').to.equal(ms);
// localTimezoneValue mirrors the epoch exactly
expect(detail.localTimezoneValue.getTime(),
'localTimezoneValue.getTime() === unixEpoch').to.equal(detail.unixEpoch);
// the emitted value, resolved independently, IS the epoch
const resolved = Date.UTC(
2026, 5, 15,
Number(detail.value.slice(11, 13)), Number(detail.value.slice(14, 16)),
Number(detail.value.slice(17, 19)), emitted.ms
) - emitted.offsetMs;
expect(resolved,
`the emitted value must describe its own unixEpoch (${detail.value})`)
.to.equal(detail.unixEpoch);
// reload into a FRESH component and Apply again — epoch must not move
const reloaded = await msFixture(detail.value, detail.timezone);
const rcal = await settle2(reloaded);
const second = await applyAndCapture(reloaded, rcal);
expect(second, 'reload emitted a payload').to.exist;
expect(second.unixEpoch,
`epoch drifted across reload (${detail.value})`).to.equal(detail.unixEpoch);
expect(second.unixEpoch % 1000, 'milliseconds survive the reload').to.equal(ms);
});
}
}
it('N2. canary: Asia/Kolkata at .999 prints +05:30, never +05:29', async () => {
const el = await msFixture('2026-06-15T14:30:45.999', 'Asia/Kolkata');
const cal = await settle2(el);
const detail = await applyAndCapture(el, cal);
const emitted = parseEmitted(detail.value);
expect(emitted.offsetText, 'the exact historical regression shape').to.equal('+05:30');
expect(emitted.offsetMs).to.equal(trueOffsetMs(detail.unixEpoch, 'Asia/Kolkata'));
expect(detail.unixEpoch % 1000).to.equal(999);
});
it('N3. canary: Asia/Kathmandu at .999 prints +05:45, never +05:44', async () => {
const el = await msFixture('2026-06-15T14:30:45.999', 'Asia/Kathmandu');
const cal = await settle2(el);
const detail = await applyAndCapture(el, cal);
const emitted = parseEmitted(detail.value);
expect(emitted.offsetText, 'quarter-hour zone, worst-case milliseconds').to.equal('+05:45');
expect(emitted.offsetMs).to.equal(trueOffsetMs(detail.unixEpoch, 'Asia/Kathmandu'));
});
it('N4. the oracle disagrees with a deliberately wrong offset (oracle is live)', () => {
// guards against the oracle silently returning 0 / NaN and passing everything
const t = Date.UTC(2026, 5, 15, 9, 0, 0, 0);
expect(trueOffsetMs(t, 'Asia/Kolkata'), 'oracle reads a real +05:30').to.equal(19800000);
expect(trueOffsetMs(t, 'Asia/Kathmandu'), 'oracle reads a real +05:45').to.equal(20700000);
expect(trueOffsetMs(t, 'Australia/Eucla'), 'oracle reads a real +08:45').to.equal(31500000);
expect(trueOffsetMs(t, 'UTC'), 'oracle reads UTC as zero').to.equal(0);
});
});
});