import { aTimeout, expect, fixture, html } from '@open-wc/testing'; import { utcFromFields, formatYear4 } from '../nile-calendar/timezones'; import { instantFromBracketedValue, wallClockFromOffsetLess } from '../nile-calendar/nile-calendar'; import './nile-date-time-picker'; import '../nile-calendar'; import '../nile-input'; import type { NileDateTimePicker } from './nile-date-time-picker'; import { makePicker, settle, settle2, applyButton, apply, roundTrip, } from './nile-date-time-picker.test-helpers'; /* * Regressions, type sync / paste, and the Y-series (Y, Y9-Y21). * Split out of nile-date-time-picker.test.ts so CI can run it on its own shard; * helpers shared across the split files live in nile-date-time-picker.test-helpers.ts. */ /** * ── R. REGRESSIONS (code-review findings #1, #2a, #3, #6) ─────────────────── */ describe('NileDateTimePicker regressions', () => { const VALUE = '2026-03-15T10:30:00'; const inputOf = (el: any) => el.shadowRoot.querySelector('nile-input') as any; const textOf = (el: any) => (inputOf(el).input as HTMLInputElement).value; const openPanel = async (el: any) => { el.open = true; await settle2(el); await aTimeout(50); return el.calendarElement as any; }; const days = (cal: any): HTMLElement[] => [...cal.shadowRoot.querySelectorAll('.day_date:not(.filler)')] as HTMLElement[]; const pad = (n: number) => String(n).padStart(2, '0'); it('R1. setting value to null clears the trigger text (#1)', async () => { const el = await fixture( html`` ); await settle2(el); expect(textOf(el), 'precondition').to.contain('2026-03-15'); el.value = null; await settle2(el); expect(textOf(el)).to.equal(''); // a later value still seeds normally el.value = '2026-04-01T08:00:00'; await settle2(el); expect(textOf(el)).to.contain('2026-04-01'); }); it('R2. the typing mask still works after the picker is moved in the DOM (#2a)', async () => { const el = await fixture( html`` ); await settle2(el); const host = el.parentNode!; el.remove(); host.appendChild(el); await settle2(el); await inputOf(el).updateComplete; for (const key of '23') { inputOf(el).dispatchEvent( new KeyboardEvent('keydown', { key, bubbles: true, composed: true, cancelable: true }) ); } expect(textOf(el)).to.equal('23/'); }); it('R3a. a relative range is rendered in the zone it is labelled with, not the host zone (#3)', async () => { const el = await fixture( html`` ); await settle2(el); const cal = el.calendarElement as any; cal.dispatchEvent(new CustomEvent('nile-change', { bubbles: true, composed: true, detail: { startDate: new Date(Date.UTC(2026, 5, 15, 3, 0, 0)), endDate: new Date(Date.UTC(2026, 5, 15, 5, 0, 0)), timezone: 'Asia/Kolkata', }, })); await settle2(el); // 03:00Z / 05:00Z are 08:30 / 10:30 in Kolkata, whatever zone the browser runs in expect(textOf(el)).to.contain('2026-06-15 - 08:30'); expect(textOf(el)).to.contain('2026-06-15 - 10:30'); expect(textOf(el)).to.match(/ - Asia\/Kolkata$/); }); it('R3b. a relative Apply shows the UTC wall-clock it is labelled with (#3)', async () => { const el = await fixture( html`` ); await settle2(el); const cal = await openPanel(el); let detail: any = null; el.addEventListener('nile-change', ((e: CustomEvent) => { detail = e.detail; }) as EventListener); cal.handleTimeValueClick('hours', 2, new Event('click')); await settle2(el); applyButton(cal).click(); await settle2(el); const end = new Date(detail.endDate); expect(detail.timezone).to.equal('UTC'); expect(textOf(el)).to.contain(`${pad(end.getUTCHours())}:${pad(end.getUTCMinutes())}`); expect(textOf(el)).to.match(/ - UTC$/); expect(el.value, 'a relative apply does not write value').to.be.null; }); it('R3c. with the timezone label hidden, a relative range keeps the host wall-clock (unchanged)', async () => { const el = await fixture( html`` ); await settle2(el); const start = new Date(Date.UTC(2026, 5, 15, 3, 0, 0)); const cal = el.calendarElement as any; cal.dispatchEvent(new CustomEvent('nile-change', { bubbles: true, composed: true, detail: { startDate: start, endDate: new Date(Date.UTC(2026, 5, 15, 5, 0, 0)), timezone: 'Asia/Kolkata' }, })); await settle2(el); expect(textOf(el)).to.contain(`${pad(start.getHours())}:${pad(start.getMinutes())}`); expect(textOf(el)).to.not.contain('Asia/Kolkata'); }); it('R6. Apply updates value, and a later timezone change keeps the applied pick (#6)', async () => { const el = await fixture( html`` ); await settle2(el); const cal = await openPanel(el); days(cal)[20].click(); await settle2(el); let detail: any = null; el.addEventListener('nile-change', ((e: CustomEvent) => { detail = e.detail; }) as EventListener); applyButton(cal).click(); await settle2(el); const pickedDay = detail.value.slice(0, 10); expect(pickedDay, 'precondition: a different day was applied').to.not.equal('2026-03-15'); expect(el.value).to.equal(detail.value); expect(textOf(el)).to.contain(pickedDay); el.timezone = 'Asia/Kolkata'; await settle2(el); expect(textOf(el)).to.contain(pickedDay); expect(textOf(el)).to.not.contain('2026-03-15'); }); }); // ── M. review fixes: type sync, Clear keeps the configured zone, non-keydown mask input ── describe('NileDateTimePicker — type sync, timezone on Clear, paste / beforeinput', () => { const dtpFixture = () => fixture(html``); const innerCalendar = (el: NileDateTimePicker): any => (el as any).calendarElement; const triggerInput = (el: NileDateTimePicker): any => el.shadowRoot!.querySelector('nile-input'); const settle = async (el: NileDateTimePicker) => { await el.updateComplete; const cal = innerCalendar(el); if (cal) await cal.updateComplete; return cal; }; 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 resetBtn = (cal: any) => cal.shadowRoot!.querySelector('.clear-button') as HTMLElement; const switchTab = async (el: NileDateTimePicker, value: string) => { const cal = await settle(el); cal.shadowRoot!.querySelector(`nile-tab[panel="${value}"]`)!.dispatchEvent( new CustomEvent('nile-tab-show', { detail: { value }, bubbles: true, composed: true }) ); await cal.updateComplete; await el.updateComplete; }; const maskFixture = () => fixture(html``); const nativeOf = (el: any) => (triggerInput(el) as any).input as HTMLInputElement; const ready = async (el: any) => { await el.updateComplete; await triggerInput(el).updateComplete; }; const paste = (el: any, text: string) => { const dt = new DataTransfer(); dt.setData('text', text); const ev = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, composed: true, cancelable: true }); triggerInput(el).dispatchEvent(ev); return ev; }; it('M1. switching the calendar to Relative updates the picker type (and attribute)', async () => { const el = await dtpFixture(); expect(el.type).to.equal('absolute'); await switchTab(el, 'relative'); expect(el.type).to.equal('relative'); expect(el.getAttribute('type')).to.equal('relative'); }); it('M2. switching back to Absolute restores type', async () => { const el = await dtpFixture(); await switchTab(el, 'relative'); await switchTab(el, 'absolute'); expect(el.type).to.equal('absolute'); }); it('M3. nile-type-change is still forwarded after the type is synced', async () => { const el = await dtpFixture(); let detail: any = null; el.addEventListener('nile-type-change', ((e: CustomEvent) => { detail = e.detail; }) as EventListener); await switchTab(el, 'relative'); expect(detail?.value).to.equal('relative'); }); it('M4. Reset in the calendar keeps the consumer-configured timezone', async () => { const el = await fixture( html`` ); const cal = await settle2(el); clickDay(cal, 5); await cal.updateComplete; resetBtn(cal).click(); await settle2(el); expect(el.timezone).to.equal('America/New_York'); expect(cal.timezone).to.equal('America/New_York'); }); it('M5. handleClear keeps the configured timezone and empties value', async () => { const el = await fixture( html`` ); await settle2(el); (el as any).handleClear(); await settle2(el); expect(el.timezone).to.equal('Asia/Kolkata'); expect(el.value == null).to.be.true; }); it('M6. with no configured timezone, Clear still lands on UTC', async () => { const el = await dtpFixture(); await settle2(el); (el as any).handleClear(); await settle2(el); expect(el.timezone).to.equal('UTC'); }); it('M8. a zone picked in the calendar dropdown is undone by Reset: wrapper and calendar both land on the configured zone', async () => { const el = await fixture( html`` ); const cal = await settle2(el); cal.handleTimezoneChange(new CustomEvent('nile-change', { detail: { value: 'Asia/Tokyo' } })); await cal.updateComplete; resetBtn(cal).click(); await settle2(el); expect(cal.timezone).to.equal('Europe/London'); expect(el.timezone).to.equal('Europe/London'); }); it('M9. a dynamic consumer timezone change is what Reset restores, in the wrapper and the calendar', async () => { const el = await fixture( html`` ); const cal = await settle2(el); el.timezone = 'Asia/Kolkata'; await settle2(el); cal.handleTimezoneChange(new CustomEvent('nile-change', { detail: { value: 'UTC' } })); await cal.updateComplete; resetBtn(cal).click(); await settle2(el); expect(cal.timezone).to.equal('Asia/Kolkata'); expect(el.timezone).to.equal('Asia/Kolkata'); }); it('M10. with the timezone field hidden, Reset after Set to now restores the configured zone; Set to now still adopts the host zone', async () => { const el = await fixture( html`` ); const cal = await settle2(el); const host = Intl.DateTimeFormat().resolvedOptions().timeZone; (cal.shadowRoot!.querySelector('.tp-set-to-now-button') as HTMLElement).click(); await cal.updateComplete; expect(cal.timezone).to.equal(host); resetBtn(cal).click(); await settle2(el); expect(cal.timezone).to.equal('Europe/London'); expect(el.timezone).to.equal('Europe/London'); }); it('M7. a date-only range seeded into the calendar keeps its days, and Reset still keeps the configured zone (default stays UTC)', async () => { const el = await fixture( html`` ); const cal = await settle2(el); cal.range = true; cal.valueAttribute = '{"startDate":"2025-12-31","endDate":"2026-01-02"}'; await settle2(el); const ymd = (d: Date) => [d.getFullYear(), d.getMonth() + 1, d.getDate()]; expect(ymd(cal.startDate)).to.deep.equal([2025, 12, 31]); expect(ymd(cal.endDate)).to.deep.equal([2026, 1, 2]); resetBtn(cal).click(); await settle2(el); expect(cal.startDate).to.equal(null); expect(el.timezone).to.equal('Asia/Kolkata'); expect(cal.timezone).to.equal('Asia/Kolkata'); const plain = await dtpFixture(); expect(plain.timezone, 'unconfigured default').to.equal('UTC'); }); it('M6b. a timezone set by the consumer AFTER first render is what Clear restores', async () => { const el = await dtpFixture(); await settle2(el); el.timezone = 'Asia/Tokyo'; await settle2(el); (el as any).handleClear(); await settle2(el); expect(el.timezone).to.equal('Asia/Tokyo'); expect(innerCalendar(el).timezone).to.equal('Asia/Tokyo'); }); it('M6c. calendar Reset after a later consumer zone change keeps that zone (picker + calendar agree)', async () => { const el = await dtpFixture(); await settle2(el); el.timezone = 'Europe/Paris'; await settle2(el); const cal = innerCalendar(el); clickDay(cal, 5); await cal.updateComplete; resetBtn(cal).click(); await settle2(el); expect(el.timezone).to.equal('Europe/Paris'); expect(cal.timezone).to.equal('Europe/Paris'); }); it('M6d. a zone picked in the calendar dropdown is NOT adopted as the default; Reset returns to the configured zone', async () => { const el = await fixture( html`` ); const cal = await settle2(el); cal.handleTimezoneChange(new CustomEvent('nile-change', { detail: { value: 'America/Chicago' } })); await settle2(el); expect(cal.timezone).to.equal('America/Chicago'); resetBtn(cal).click(); await settle2(el); expect(el.timezone).to.equal('Asia/Kolkata'); expect(cal.timezone).to.equal('Asia/Kolkata'); }); it('M6e. a zone adopted from a bracketed value is not mistaken for the configured default', async () => { const el = await dtpFixture(); await settle2(el); el.value = '2026-03-01T10:00:00.000[GMT+05:30 Asia/Kolkata]'; await settle2(el); expect(el.timezone).to.equal('Asia/Kolkata'); (el as any).handleClear(); await settle2(el); expect(el.timezone).to.equal('UTC'); }); it('M7. pasting a full datetime fills the mask like typing it', async () => { const el = await maskFixture(); await ready(el); const ev = paste(el, '230326172456426'); expect(ev.defaultPrevented).to.be.true; expect(nativeOf(el).value).to.equal('23/03/26 - 17:24:56.426'); }); it('M8. pasting separator-formatted text uses only its digits', async () => { const el = await maskFixture(); await ready(el); paste(el, '23/03/26 - 17:24'); expect(nativeOf(el).value.startsWith('23/03/26 - 17:24')).to.be.true; }); it('M9. pasting with no digits leaves the mask unchanged', async () => { const el = await maskFixture(); await ready(el); paste(el, 'hello'); expect(nativeOf(el).value).to.equal(''); }); it('M10. pasted out-of-range segments are clamped', async () => { const el = await maskFixture(); await ready(el); paste(el, '992026'); expect(nativeOf(el).value).to.not.contain('99'); }); it('M11. soft-keyboard style insertText (no usable keydown) is picked up by the mask', async () => { const el = await maskFixture(); await ready(el); const native = nativeOf(el); native.value = '2303'; triggerInput(el).dispatchEvent( new InputEvent('beforeinput', { inputType: 'insertText', data: '3', bubbles: true, composed: true }) ); await aTimeout(20); expect(native.value).to.equal('23/03/'); }); it('M12. a cut (deleteByCut) rebuilds the slots from the remaining text', async () => { const el = await maskFixture(); await ready(el); paste(el, '230326'); await ready(el); // let the input's own re-render land before simulating the cut const native = nativeOf(el); native.value = '23/03/'; triggerInput(el).dispatchEvent( new InputEvent('beforeinput', { inputType: 'deleteByCut', bubbles: true, composed: true }) ); await aTimeout(20); expect(native.value).to.equal('23/03/'); }); it('M13. paste is ignored in Relative mode', async () => { const el = await maskFixture(); await ready(el); await switchTab(el, 'relative'); const ev = paste(el, '230326'); expect(ev.defaultPrevented).to.be.false; }); }); /** * ── Y. YEARS 0-99 (Date.UTC remap regression) ─────────────────────────────── * `Date.UTC(y, …)` treats years 0-99 as 1900-1999, so year 0001 used to become 1901: the bracketed offset was * computed for the wrong year (`[GMT+16655040:00 UTC]`) and offset-less / bracketed values re-parsed a century off. * Epochs here are built with setUTCFullYear — never Date.UTC — so the oracle does not share the bug. */ describe('Y. years 0-99 keep their literal year', () => { const epochOf = (y: number, mo1: number, d: number, h = 0, mi = 0, s = 0, ms = 0) => { const dt = new Date(0); dt.setUTCFullYear(y, mo1 - 1, d); dt.setUTCHours(h, mi, s, ms); return dt.getTime(); }; const pad4 = (y: number) => String(y).padStart(4, '0'); const YEARS = [1, 99, 100]; it('Y1. utcFromFields equals Date.UTC for years >= 100 (incl. overflowing fields)', () => { for (const args of [ [2026, 5, 15, 12, 30, 15, 250], [100, 0, 1], [1900, 2, 1], [2100, 1, 29], [2024, 12, 32, 25, 61, 61, 1001], [2026, 2, 0], ] as number[][]) { expect(utcFromFields(args[0], args[1], args[2], args[3], args[4], args[5], args[6]), JSON.stringify(args)).to.equal(Date.UTC(args[0], args[1], args[2], args[3] ?? 0, args[4] ?? 0, args[5] ?? 0, args[6] ?? 0)); } }); it('Y2. utcFromFields keeps years 0-99 literal and returns NaN for NaN input', () => { expect(new Date(utcFromFields(1, 0, 1)).getUTCFullYear()).to.equal(1); expect(new Date(utcFromFields(99, 11, 31, 23, 59, 59, 999)).getUTCFullYear()).to.equal(99); expect(new Date(utcFromFields(0, 1, 29)).getUTCDate()).to.equal(29); // year 0 is a leap year expect(utcFromFields(1, 0, 1)).to.equal(epochOf(1, 1, 1)); expect(utcFromFields(NaN, 0, 1)).to.be.NaN; }); it('Y3. wallClockFromOffsetLess keeps the literal year, month length and digits', () => { for (const y of YEARS) { const w = wallClockFromOffsetLess(`${pad4(y)}-06-15T12:30:15.250`)!; expect(w, `year ${y}`).to.deep.equal({ year: y, month: 6, day: 15, hours: 12, minutes: 30, seconds: 15, ms: 250 }); } // year 0 is leap (400 | 0); the 1900 remap would clamp Feb 29 to 28 expect(wallClockFromOffsetLess('0000-02-29T10:00:00')!.day).to.equal(29); // year 1 is not leap: Feb 29 clamps to 28 expect(wallClockFromOffsetLess('0001-02-29T10:00:00')!.day).to.equal(28); }); it('Y4. instantFromBracketedValue returns the true epoch for years 0001, 0099 and 0100', () => { for (const y of YEARS) { const r = instantFromBracketedValue(`${pad4(y)}-06-15T12:30:15.250[GMT+00:00 UTC]`); expect(r, `year ${y}`).to.deep.equal({ epochMs: epochOf(y, 6, 15, 12, 30, 15, 250), timezone: 'UTC' }); } }); for (const y of YEARS) { it(`Y5.${y}. Z instant in year ${pad4(y)} emits the true epoch, a sane bracket, and round-trips`, async () => { const raw = `${pad4(y)}-06-15T12:30:15.250Z`; const p = await apply(await makePicker({ value: raw, timezone: 'UTC' })); expect(p, 'payload').to.exist; expect(p!.unixEpoch).to.equal(epochOf(y, 6, 15, 12, 30, 15, 250)); expect(p!.value).to.equal(`${pad4(y)}-06-15T12:30:15.250[GMT+00:00 UTC]`); const rt = await roundTrip(p!); expect(rt.second && rt.second.unixEpoch, 'epoch after reload').to.equal(p!.unixEpoch); expect(rt.second && rt.second.value).to.equal(p!.value); }).timeout(60000); it(`Y6.${y}. offset-less value in year ${pad4(y)} keeps its digits (no 19xx remap)`, async () => { const p = await apply(await makePicker({ value: `${pad4(y)}-06-15T12:30:15.250`, timezone: 'UTC' })); expect(p, 'payload').to.exist; expect(p!.unixEpoch).to.equal(epochOf(y, 6, 15, 12, 30, 15, 250)); expect(p!.value).to.equal(`${pad4(y)}-06-15T12:30:15.250[GMT+00:00 UTC]`); }).timeout(60000); it(`Y7.${y}. bracketed value in year ${pad4(y)} reloads to the same epoch`, async () => { const p = await apply(await makePicker({ value: `${pad4(y)}-06-15T12:30:15.250[GMT+00:00 UTC]`, timezone: 'UTC', })); expect(p, 'payload').to.exist; expect(p!.unixEpoch).to.equal(epochOf(y, 6, 15, 12, 30, 15, 250)); }).timeout(60000); } it('Y8. a Feb-29 in year 0 (leap) survives; year 0001 shows no absurd offset', async () => { const p = await apply(await makePicker({ value: '0001-01-01T00:00:00.000Z', timezone: 'UTC' })); expect(p!.value).to.not.match(/GMT[+-]\d{3,}:/); expect(p!.value).to.match(/\[GMT[+-]\d{2}:\d{2} UTC\]$/); }).timeout(60000); }); describe('Y9. typed date in years 0-99 (mask -> calendar)', () => { it('Y9. a fully typed day in year 0050 selects year 50 (not 1950), and Feb 29 of year 0 is kept', async () => { const el = await makePicker({}); el.dateFormat = 'DD/MM/YYYY'; // the default DD/MM/YY reads two digits as 20xx; a literal year 0050 needs YYYY await settle(el); const layout = (el as any).maskLayout(); const digitsFor = (mm: string, dd: string, yyyy: string) => { const map: Record = { month: mm, day: dd, year: yyyy }; const idx: Record = { month: 0, day: 0, year: 0 }; return layout.slots.map((s: any) => (s.kind in map ? map[s.kind][idx[s.kind]++] : '0')); }; const cal = el.calendarElement as any; const run = async (mm: string, dd: string, yyyy: string) => { (el as any).maskValues = digitsFor(mm, dd, yyyy); (el as any).syncCalendarFromMask(); await settle(el); }; await run('06', '15', '0050'); expect(cal.value.getFullYear(), 'year').to.equal(50); expect(cal.value.getMonth(), 'month').to.equal(5); expect(cal.value.getDate(), 'day').to.equal(15); await run('02', '29', '0000'); expect(cal.value.getFullYear(), 'year 0').to.equal(0); expect(cal.value.getDate(), 'Feb 29 exists in year 0 (leap)').to.equal(29); await run('02', '29', '0100'); expect(cal.value.getFullYear(), 'year 100 unchanged').to.equal(100); expect(cal.value.getDate(), 'year 100 is not leap: clamps to 28').to.equal(28); }).timeout(60000); }); describe('Y10. YYYY display in years 0-99 (picker input)', () => { const wall = (year: number, month = 6, day = 15) => ({ year, month, day, hours: 12, minutes: 30, seconds: 15, ms: 250 }); it('Y10a. formatYear4 pads only years 0-99', () => { expect([0, 1, 50, 99].map(formatYear4)).to.deep.equal(['0000', '0001', '0050', '0099']); for (const y of [100, 999, 1000, 2000, 9999, 10000]) expect(formatYear4(y)).to.equal(String(y)); }); it('Y10b. formatDatePartFields pads YYYY for 0-999, keeps >= 1000, and leaves YY alone', async () => { const el = await makePicker({}) as any; el.dateFormat = 'DD/MM/YYYY'; expect(el.formatDatePartFields(wall(50))).to.equal('15/06/0050'); expect(el.formatDatePartFields(wall(0, 2, 29))).to.equal('29/02/0000'); expect(el.formatDatePartFields(wall(1))).to.equal('15/06/0001'); expect(el.formatDatePartFields(wall(99))).to.equal('15/06/0099'); expect(el.formatDatePartFields(wall(100)), 'the mask has four year slots, so 100-999 are padded too').to.equal('15/06/0100'); expect(el.formatDatePartFields(wall(999))).to.equal('15/06/0999'); expect(el.formatDatePartFields(wall(1000))).to.equal('15/06/1000'); expect(el.formatDatePartFields(wall(2026))).to.equal('15/06/2026'); el.dateFormat = 'DD/MM/YY'; expect(el.formatDatePartFields(wall(1)), 'YY is always two characters (Y18)').to.equal('15/06/01'); expect(el.formatDatePartFields(wall(50))).to.equal('15/06/50'); expect(el.formatDatePartFields(wall(2026))).to.equal('15/06/26'); }).timeout(60000); it('Y10c. the trigger input shows a year-0050 value as 0050 with time and timezone, and year 2026 is unchanged', async () => { for (const [raw, shown] of [ ['0050-06-15T12:30:15.250Z', '0050-06-15 - 12:30:15.250 - UTC'], ['2026-06-15T12:30:15.250Z', '2026-06-15 - 12:30:15.250 - UTC'], ]) { const el = await fixture( html``); await settle(el); expect((el.shadowRoot!.querySelector('nile-input') as any).value, raw).to.equal(shown); } }).timeout(60000); it('Y10d. typed 0050 -> mask -> calendar -> display round-trips as 0050 (year 50, not 1950)', async () => { const el = await makePicker({}); el.dateFormat = 'DD/MM/YYYY'; await settle(el); const layout = (el as any).maskLayout(); const map: Record = { month: '06', day: '15', year: '0050' }; const idx: Record = { month: 0, day: 0, year: 0 }; (el as any).maskValues = layout.slots.map((s: any) => (s.kind in map ? map[s.kind][idx[s.kind]++] : '0')); (el as any).syncCalendarFromMask(); await settle(el); const cal = el.calendarElement as any; expect(cal.value.getFullYear()).to.equal(50); expect(cal.formatDate(cal.value)).to.equal('15/06/0050'); }).timeout(60000); }); describe('Y11. years 0-999 in the picker input: four-digit display and a stable focus/blur round trip', () => { const FORMATS: Array<[string, (y: string) => string]> = [ ['YYYY-MM-DD', y => `${y}-06-15`], ['MM/DD/YYYY', y => `06/15/${y}`], ['DD/MM/YYYY', y => `15/06/${y}`], ]; const YEARS = ['0001', '0050', '0099', '0100', '0999', '1000', '2026']; const DATE_ONLY = ['hours', 'minutes', 'seconds', 'milliseconds', 'timezone']; const make = async (format: string, year: string): Promise => { const el: any = await fixture(html``); await settle2(el); return el; }; const textOf = (el: any) => el.shadowRoot.querySelector('nile-input').input.value as string; const calDay = (el: any) => { const c = el.calendarElement; return [c.value.getFullYear(), c.value.getMonth(), c.value.getDate(), c.currentYear, c.currentMonth]; }; const focusBlur = async (el: any) => { const ni = el.shadowRoot.querySelector('nile-input'); ni.input.focus(); ni.dispatchEvent(new FocusEvent('focusout', { bubbles: true, composed: true })); await settle2(el); }; for (const [format, expectText] of FORMATS) { for (const year of YEARS) { it(`Y11 [${format}] year ${year}: four-digit text, and focus/blur x3 leaves text, calendar and value untouched`, async () => { const el = await make(format, year); const n = Number(year); expect(textOf(el), 'seeded text').to.equal(expectText(year)); const before = calDay(el); expect(before, 'seeded calendar').to.deep.equal([n, 5, 15, n, 5]); const valueBefore = el.value; let emitted = 0; el.addEventListener('nile-change', () => { emitted++; }); el.addEventListener('nile-changed', () => { emitted++; }); for (let i = 0; i < 3; i++) { await focusBlur(el); expect(textOf(el), `text after cycle ${i + 1}`).to.equal(expectText(year)); expect(calDay(el), `calendar after cycle ${i + 1}`).to.deep.equal(before); } expect(el.value, 'value untouched').to.equal(valueBefore); expect(emitted, 'no change event from focus/blur').to.equal(0); }).timeout(60000); } } }); describe('Y12. typed dates are clamped to the allowedDates range (user edits only)', () => { const DMY = 'DD/MM/YYYY'; const make = async (range: [string, string] | null, value: string, tz = 'UTC'): Promise => { const el: any = await fixture(html``); await settle2(el); return el; }; 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 textOf = (el: any) => nativeOf(el).value; /** select the DD/MM/YYYY text, delete it, and type a fresh date digit by digit */ const typeDate = async (el: any, ddmmyyyy: string) => { nativeOf(el).setSelectionRange(0, 10); press(el, 'Backspace'); for (const ch of ddmmyyyy) press(el, ch); await settle2(el); }; const calDate = (el: any) => { const c = el.calendarElement; return [c.value.getFullYear(), c.value.getMonth() + 1, c.value.getDate()]; }; const focusBlur = async (el: any) => { inputOf(el).input.focus(); inputOf(el).dispatchEvent(new FocusEvent('focusout', { bubbles: true, composed: true })); await settle2(el); }; const applyAndCount = async (el: any) => { const cal = el.calendarElement; const events: string[] = []; const details: any[] = []; for (const n of ['nile-change']) { el.addEventListener(n, (e: any) => { events.push(n); details.push(e.detail); }); } (cal.shadowRoot.querySelector('.apply-button') as HTMLElement).click(); await settle2(el); return { events, details }; }; const SEED = '2026-06-15T10:20:30.250[GMT+00:00 UTC]'; const JUNE: [string, string] = ['2026-06-10', '2026-06-20']; const CASES: Array<[string, [string, string], string, string, string]> = [ ['before range', JUNE, '05062026', '10/06/2026', '2026-06-10'], ['lower boundary', JUNE, '10062026', '10/06/2026', '2026-06-10'], ['inside range', JUNE, '15062026', '15/06/2026', '2026-06-15'], ['upper boundary', JUNE, '20062026', '20/06/2026', '2026-06-20'], ['after range', JUNE, '25062026', '20/06/2026', '2026-06-20'], ['month boundary, before', ['2026-12-29', '2027-01-03'], '28122026', '29/12/2026', '2026-12-29'], ['year boundary, after', ['2026-12-29', '2027-01-03'], '04012027', '03/01/2027', '2027-01-03'], ['year boundary, inside', ['2026-12-29', '2027-01-03'], '01012027', '01/01/2027', '2027-01-01'], ['leap: before 28 Feb', ['2024-02-28', '2024-03-01'], '27022024', '28/02/2024', '2024-02-28'], ['leap: 29 Feb is inside', ['2024-02-28', '2024-03-01'], '29022024', '29/02/2024', '2024-02-29'], ['leap: after 1 Mar', ['2024-02-28', '2024-03-01'], '02032024', '01/03/2024', '2024-03-01'], ['non-leap: before 27 Feb', ['2023-02-27', '2023-03-02'], '26022023', '27/02/2023', '2023-02-27'], ['non-leap: after 2 Mar', ['2023-02-27', '2023-03-02'], '03032023', '02/03/2023', '2023-03-02'], ]; for (const [name, range, typed, text, iso] of CASES) { it(`Y12 ${name}: typing ${typed} gives ${text}; trigger, calendar and Apply agree, time and zone kept`, async () => { const el = await make(range, SEED); await typeDate(el, typed); const [y, m, d] = iso.split('-').map(Number); expect(textOf(el).slice(0, 10), 'trigger').to.equal(text); expect(calDate(el), 'calendar').to.deep.equal([y, m, d]); expect(el.calendarElement.timezone).to.equal('UTC'); const { events, details } = await applyAndCount(el); expect([...events].sort(), 'exactly one change event').to.deep.equal(['nile-change']); expect(details[0].value, 'emitted value').to.equal(`${iso}T10:20:30.250[GMT+00:00 UTC]`); const typedIso = `${typed.slice(4)}-${typed.slice(2, 4)}-${typed.slice(0, 2)}`; if (typedIso !== iso) expect(details[0].value, 'disallowed date never committed').to.not.contain(typedIso); }).timeout(60000); } it('Y12 preserves a non-UTC zone and time of day while clamping', async () => { const el = await make(JUNE, '2026-06-15T23:45:10.005[GMT+05:30 Asia/Kolkata]', 'Asia/Kolkata'); await typeDate(el, '25062026'); expect(textOf(el).slice(0, 10)).to.equal('20/06/2026'); expect(el.calendarElement.timezone).to.equal('Asia/Kolkata'); const { details } = await applyAndCount(el); expect(details[0].value).to.equal('2026-06-20T23:45:10.005[GMT+05:30 Asia/Kolkata]'); }).timeout(60000); it('Y12 focus/blur x3 after a clamp does not change the date again and emits nothing', async () => { const el = await make(JUNE, SEED); await typeDate(el, '25062026'); const text = textOf(el); const cal = calDate(el); let emitted = 0; el.addEventListener('nile-change', () => { emitted++; }); el.addEventListener('nile-changed', () => { emitted++; }); for (let i = 0; i < 3; i++) { await focusBlur(el); expect(textOf(el), `text ${i + 1}`).to.equal(text); expect(calDate(el), `calendar ${i + 1}`).to.deep.equal(cal); } expect(emitted).to.equal(0); }).timeout(60000); it('Y12 a programmatic value outside the range is not clamped by focus/blur', async () => { const el = await make(JUNE, '2026-06-25T10:20:30.250[GMT+00:00 UTC]'); const text = textOf(el); const cal = calDate(el); const value = el.value; let emitted = 0; el.addEventListener('nile-change', () => { emitted++; }); el.addEventListener('nile-changed', () => { emitted++; }); for (let i = 0; i < 3; i++) { await focusBlur(el); expect(textOf(el)).to.equal(text); expect(calDate(el)).to.deep.equal(cal); } expect(text.slice(0, 10)).to.equal('25/06/2026'); expect(el.value).to.equal(value); expect(emitted).to.equal(0); }).timeout(60000); it('Y12 without allowedDates a typed date is never clamped', async () => { const el = await make(null, SEED); await typeDate(el, '25062026'); expect(textOf(el).slice(0, 10)).to.equal('25/06/2026'); expect(calDate(el)).to.deep.equal([2026, 6, 25]); }).timeout(60000); }); describe('Y13. changing dateFormat / hideTimeFields after render redraws the trigger from the committed value', () => { const ALL_TIME = ['hours', 'minutes', 'seconds', 'milliseconds', 'timezone']; const SEED = '2026-06-15T10:20:30.250[GMT+05:30 Asia/Kolkata]'; const make = async (extra: { allowed?: [string, string] } = {}): Promise => { const el: any = await fixture(html``); await settle2(el); return el; }; 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 textOf = (el: any) => nativeOf(el).value; const calDate = (el: any) => { const c = el.calendarElement; return [c.value.getFullYear(), c.value.getMonth() + 1, c.value.getDate()]; }; const focusBlur = async (el: any) => { inputOf(el).input.focus(); inputOf(el).dispatchEvent(new FocusEvent('focusout', { bubbles: true, composed: true })); await settle2(el); }; const watch = (el: any) => { const seen: string[] = []; for (const n of ['nile-change', 'nile-changed', 'nile-type-change']) el.addEventListener(n, () => seen.push(n)); return seen; }; const apply = async (el: any) => { (el.calendarElement.shadowRoot.querySelector('.apply-button') as HTMLElement).click(); await settle2(el); }; it('Y13 DD/MM/YYYY -> YYYY-MM-DD -> back keeps the date, time and zone', async () => { const el = await make(); const value = el.value; const seen = watch(el); expect(textOf(el).startsWith('15/06/2026')).to.be.true; el.dateFormat = 'YYYY-MM-DD'; await settle2(el); expect(textOf(el).startsWith('2026-06-15')).to.be.true; expect(textOf(el)).to.contain('10:20:30.250').and.to.contain('Asia/Kolkata'); el.dateFormat = 'DD/MM/YYYY'; await settle2(el); expect(textOf(el).startsWith('15/06/2026')).to.be.true; expect(el.value).to.equal(value); expect(el.timezone).to.equal('Asia/Kolkata'); expect(calDate(el)).to.deep.equal([2026, 6, 15]); expect(seen).to.deep.equal([]); }).timeout(60000); it('Y13 hideTimeFields hides then restores time and zone without losing them', async () => { const el = await make(); const value = el.value; const seen = watch(el); el.hideTimeFields = ALL_TIME; await settle2(el); expect(textOf(el)).to.equal('15/06/2026'); el.hideTimeFields = []; await settle2(el); expect(textOf(el)).to.contain('10:20:30.250').and.to.contain('Asia/Kolkata'); expect(el.value).to.equal(value); expect(seen).to.deep.equal([]); }).timeout(60000); it('Y13 both properties changed in one update give one consistent trigger', async () => { const el = await make(); const value = el.value; el.dateFormat = 'YYYY-MM-DD'; el.hideTimeFields = ALL_TIME; await settle2(el); expect(textOf(el)).to.equal('2026-06-15'); el.dateFormat = 'DD/MM/YYYY'; el.hideTimeFields = []; await settle2(el); expect(textOf(el)).to.contain('15/06/2026').and.to.contain('10:20:30.250').and.to.contain('Asia/Kolkata'); expect(el.value).to.equal(value); }).timeout(60000); it('Y13 focus/blur after each change does not corrupt the date, time or zone', async () => { const el = await make(); const value = el.value; const seen = watch(el); for (const [fmt, hide] of [['YYYY-MM-DD', []], ['YYYY-MM-DD', ALL_TIME], ['DD/MM/YYYY', ALL_TIME], ['DD/MM/YYYY', []]] as Array<[string, string[]]>) { el.dateFormat = fmt; el.hideTimeFields = hide; await settle2(el); const text = textOf(el); for (let i = 0; i < 2; i++) { await focusBlur(el); expect(textOf(el), `${fmt} ${hide.length}`).to.equal(text); } expect(el.value).to.equal(value); expect(el.timezone).to.equal('Asia/Kolkata'); expect(calDate(el)).to.deep.equal([2026, 6, 15]); } expect(seen).to.deep.equal([]); }).timeout(60000); it('Y13 an uncommitted draft is discarded and the committed value is redrawn', async () => { const el = await make(); const value = el.value; nativeOf(el).setSelectionRange(0, 10); press(el, 'Backspace'); for (const ch of '01012030') press(el, ch); await settle2(el); expect(textOf(el).startsWith('01/01/2030')).to.be.true; const seen = watch(el); el.dateFormat = 'YYYY-MM-DD'; await settle2(el); expect(textOf(el).startsWith('2026-06-15')).to.be.true; await focusBlur(el); expect(textOf(el).startsWith('2026-06-15')).to.be.true; expect(el.value).to.equal(value); expect(seen).to.deep.equal([]); }).timeout(60000); it('Y13 repeated toggles do not drift', async () => { const el = await make(); const value = el.value; const first = textOf(el); for (let i = 0; i < 4; i++) { el.dateFormat = 'YYYY-MM-DD'; el.hideTimeFields = ALL_TIME; await settle2(el); el.dateFormat = 'DD/MM/YYYY'; el.hideTimeFields = []; await settle2(el); } expect(textOf(el)).to.equal(first); expect(el.value).to.equal(value); }).timeout(60000); it('Y13 a programmatic value after a format change shows in the new format', async () => { const el = await make(); el.dateFormat = 'YYYY-MM-DD'; await settle2(el); el.value = '2027-03-04T05:06:07.008[GMT+05:30 Asia/Kolkata]'; await settle2(el); expect(textOf(el).startsWith('2027-03-04')).to.be.true; await focusBlur(el); expect(textOf(el).startsWith('2027-03-04')).to.be.true; expect(calDate(el)).to.deep.equal([2027, 3, 4]); }).timeout(60000); it('Y13 Apply after a format change emits the committed date once', async () => { const el = await make(); el.dateFormat = 'YYYY-MM-DD'; await settle2(el); const seen = watch(el); await apply(el); expect(seen).to.deep.equal(['nile-change']); expect(el.value.startsWith('2026-06-15T10:20:30.250')).to.be.true; }).timeout(60000); it('Y13 with no value a format change leaves the trigger empty and emits nothing', async () => { const el: any = await fixture(html``); await settle2(el); const seen = watch(el); el.dateFormat = 'YYYY-MM-DD'; el.hideTimeFields = ALL_TIME; await settle2(el); expect(textOf(el)).to.equal(''); expect(el.value ?? '').to.equal(''); expect(seen).to.deep.equal([]); }).timeout(60000); it('Y13 a format change that discards a typed draft (no committed value) emits no nile-input', async () => { const el: any = await fixture(html``); await settle2(el); nativeOf(el).setSelectionRange(0, 0); for (const ch of '01012030') press(el, ch); await settle2(el); expect(textOf(el).startsWith('01/01/2030')).to.be.true; const seen = watch(el); let inputs = 0; inputOf(el).addEventListener('nile-input', () => { inputs++; }); el.dateFormat = 'YYYY-MM-DD'; await settle2(el); el.hideTimeFields = ALL_TIME; await settle2(el); expect(textOf(el)).to.equal(''); expect(el.value ?? '').to.equal(''); expect(inputs).to.equal(0); expect(seen).to.deep.equal([]); }).timeout(60000); it('Y13 a user Clear still emits nile-input and nile-clear', async () => { const el = await make(); let inputs = 0; let clears = 0; inputOf(el).addEventListener('nile-input', () => { inputs++; }); el.addEventListener('nile-clear', () => { clears++; }); el.handleClear(); await settle2(el); expect(inputs).to.equal(1); expect(clears).to.equal(1); }).timeout(60000); it('Y13 relative mode ignores a format change', async () => { const el: any = await fixture(html``); await settle2(el); const before = el.type; el.dateFormat = 'YYYY-MM-DD'; el.hideTimeFields = ALL_TIME; await settle2(el); expect(el.type).to.equal(before); expect(el.dateFormat).to.equal('YYYY-MM-DD'); }).timeout(60000); it('Y13 allowedDates clamping still works after a format change', async () => { const el = await make({ allowed: ['2026-06-10', '2026-06-20'] }); el.dateFormat = 'DD/MM/YYYY'; el.hideTimeFields = []; await settle2(el); nativeOf(el).setSelectionRange(0, 10); press(el, 'Backspace'); for (const ch of '25062026') press(el, ch); await settle2(el); expect(textOf(el).startsWith('20/06/2026')).to.be.true; expect(calDate(el)).to.deep.equal([2026, 6, 20]); }).timeout(60000); }); describe('Y14. switching Relative -> Absolute redraws the trigger from the retained absolute value', () => { const SEED = '2026-06-15T10:20:30.250[GMT+05:30 Asia/Kolkata]'; const make = async (withValue = true): Promise => { const el: any = await fixture(html``); await settle2(el); return el; }; const inputOf = (el: any) => el.shadowRoot.querySelector('nile-input') as any; const nativeOf = (el: any) => inputOf(el).input as HTMLInputElement; const textOf = (el: any) => nativeOf(el).value; const calDate = (el: any) => { const c = el.calendarElement; return [c.value.getFullYear(), c.value.getMonth() + 1, c.value.getDate()]; }; const focusBlur = async (el: any) => { inputOf(el).input.focus(); inputOf(el).dispatchEvent(new FocusEvent('focusout', { bubbles: true, composed: true })); await settle2(el); }; /** user-style mode switch: the calendar's tab group fires nile-tab-show */ const switchTab = async (el: any, value: string) => { const cal = el.calendarElement; cal.shadowRoot.querySelector(`nile-tab[panel="${value}"]`).dispatchEvent( new CustomEvent('nile-tab-show', { detail: { value }, bubbles: true, composed: true }) ); await settle2(el); }; const relativeApply = async (el: any) => { const cal = el.calendarElement; (cal.shadowRoot.querySelector('.duration__value') as HTMLElement).click(); await cal.updateComplete; (cal.shadowRoot.querySelector('.apply-button') as HTMLElement).click(); await settle2(el); }; const watch = (el: any) => { const seen: string[] = []; for (const n of ['nile-change', 'nile-changed']) el.addEventListener(n, () => seen.push(n)); const types: any[] = []; el.addEventListener('nile-type-change', (e: any) => types.push(e.detail?.value)); let inputs = 0; inputOf(el).addEventListener('nile-input', () => { inputs++; }); return { seen, types, inputs: () => inputs }; }; it('Y14 Relative -> Absolute restores the retained absolute value, never the relative text', async () => { const el = await make(); const value = el.value; await switchTab(el, 'relative'); await relativeApply(el); expect(textOf(el)).to.contain(' - '); expect(el.value, 'relative apply leaves value alone').to.equal(value); const w = watch(el); await switchTab(el, 'absolute'); expect(el.type).to.equal('absolute'); expect(textOf(el).startsWith('15/06/2026')).to.be.true; expect(textOf(el)).to.contain('10:20:30.250').and.to.contain('Asia/Kolkata'); expect(el.value).to.equal(value); expect(el.timezone).to.equal('Asia/Kolkata'); expect(w.seen).to.deep.equal([]); expect(w.inputs()).to.equal(0); expect(w.types).to.deep.equal(['absolute']); }).timeout(60000); it('Y14 with no absolute value the trigger is cleared silently', async () => { const el = await make(false); await switchTab(el, 'relative'); await relativeApply(el); expect(textOf(el)).to.contain(' - '); const w = watch(el); await switchTab(el, 'absolute'); expect(textOf(el)).to.equal(''); expect(el.value ?? '').to.equal(''); expect(w.inputs()).to.equal(0); expect(w.seen).to.deep.equal([]); expect(w.types).to.deep.equal(['absolute']); }).timeout(60000); it('Y14 focus/blur after returning to Absolute does not corrupt date, time or zone', async () => { const el = await make(); const value = el.value; await switchTab(el, 'relative'); await relativeApply(el); await switchTab(el, 'absolute'); const text = textOf(el); for (let i = 0; i < 3; i++) { await focusBlur(el); expect(textOf(el), `blur ${i + 1}`).to.equal(text); } expect(el.value).to.equal(value); expect(el.timezone).to.equal('Asia/Kolkata'); expect(calDate(el)).to.deep.equal([2026, 6, 15]); expect((el as any).maskValues.length).to.be.greaterThan(0); }).timeout(60000); it('Y14 with no absolute value, focus/blur after returning leaves the trigger empty', async () => { const el = await make(false); await switchTab(el, 'relative'); await relativeApply(el); await switchTab(el, 'absolute'); await focusBlur(el); expect(textOf(el)).to.equal(''); expect(el.value ?? '').to.equal(''); }).timeout(60000); it('Y14 Absolute -> Relative -> Absolute and repeated switching show no stale text', async () => { const el = await make(); const first = textOf(el); const w = watch(el); for (let i = 0; i < 3; i++) { await switchTab(el, 'relative'); expect(el.type).to.equal('relative'); await switchTab(el, 'absolute'); expect(textOf(el), `round ${i + 1}`).to.equal(first); expect(textOf(el)).to.not.contain(' - Asia/Kolkata - '); } expect(w.types).to.deep.equal(['relative', 'absolute', 'relative', 'absolute', 'relative', 'absolute']); expect(w.seen).to.deep.equal([]); expect(w.inputs()).to.equal(0); }).timeout(60000); it('Y14 a programmatic type change back to Absolute also redraws', async () => { const el = await make(); const first = textOf(el); el.type = 'relative'; await settle2(el); await relativeApply(el); el.type = 'absolute'; await settle2(el); expect(textOf(el)).to.equal(first); }).timeout(60000); it('Y14 Absolute -> Relative does not change the absolute value', async () => { const el = await make(); const value = el.value; await switchTab(el, 'relative'); expect(el.value).to.equal(value); expect(el.timezone).to.equal('Asia/Kolkata'); }).timeout(60000); it('Y14 Absolute Apply and Relative Apply still work after switching back and forth', async () => { const el = await make(); await switchTab(el, 'relative'); let relative: any = null; el.addEventListener('nile-change', ((e: CustomEvent) => { relative = e.detail; }) as EventListener); await relativeApply(el); expect(relative?.startDate, 'relative apply emits a range').to.exist; await switchTab(el, 'absolute'); const events: string[] = []; let detail: any = null; for (const n of ['nile-change', 'nile-changed']) el.addEventListener(n, (e: any) => { events.push(n); detail = e.detail; }); (el.calendarElement.shadowRoot.querySelector('.apply-button') as HTMLElement).click(); await settle2(el); expect(events).to.deep.equal(['nile-change']); expect(detail.value.startsWith('2026-06-15T10:20:30.250')).to.be.true; expect(el.value.startsWith('2026-06-15T10:20:30.250')).to.be.true; }).timeout(60000); it('Y14 dateFormat and hideTimeFields updates still work after returning to Absolute', async () => { const el = await make(); await switchTab(el, 'relative'); await relativeApply(el); await switchTab(el, 'absolute'); el.dateFormat = 'YYYY-MM-DD'; el.hideTimeFields = ['hours', 'minutes', 'seconds', 'milliseconds', 'timezone']; await settle2(el); expect(textOf(el)).to.equal('2026-06-15'); el.hideTimeFields = []; el.dateFormat = 'DD/MM/YYYY'; await settle2(el); expect(textOf(el).startsWith('15/06/2026')).to.be.true; }).timeout(60000); }); describe('Y15. focus/blur never clamps a seeded year outside the default startYear/endYear range', () => { // no .startYear/.endYear here on purpose: the default navigation range (2000-2050) is what used to clamp the seed const DATE_ONLY = ['hours', 'minutes', 'seconds', 'milliseconds', 'timezone']; const make = async (year: string, format = 'DD/MM/YYYY'): Promise => { const el: any = await fixture(html``); await settle2(el); return el; }; const inputOf = (el: any) => el.shadowRoot.querySelector('nile-input') as any; const textOf = (el: any) => (inputOf(el).input as HTMLInputElement).value; const calState = (el: any) => { const c = el.calendarElement; return [c.value.getFullYear(), c.value.getMonth(), c.value.getDate(), c.currentYear, c.currentMonth]; }; const focusBlur = async (el: any) => { inputOf(el).input.focus(); inputOf(el).dispatchEvent(new FocusEvent('focusout', { bubbles: true, composed: true })); await settle2(el); }; for (const year of ['0050', '1985', '1999', '2051', '2100', '9999']) { for (const format of ['DD/MM/YYYY', 'YYYY-MM-DD']) { it(`Y15 [${format}] seeded year ${year}: focus/blur x3 leaves text, calendar, value and events untouched`, async () => { const el = await make(year, format); const text = textOf(el); const cal = calState(el); const value = el.value; expect(text, 'seeded text carries the literal year').to.contain(year); expect(cal.slice(0, 1), 'seeded calendar year').to.deep.equal([Number(year)]); let emitted = 0; el.addEventListener('nile-change', () => { emitted++; }); el.addEventListener('nile-changed', () => { emitted++; }); for (let i = 0; i < 3; i++) { await focusBlur(el); expect(textOf(el), `text after cycle ${i + 1}`).to.equal(text); expect(calState(el), `calendar after cycle ${i + 1}`).to.deep.equal(cal); } expect(el.value, 'value untouched').to.equal(value); expect(emitted, 'no change event from focus/blur').to.equal(0); }).timeout(60000); } } it('Y15 a year the USER types outside the range is still clamped (user edits only)', async () => { const el = await make('2026'); const native = inputOf(el).input as HTMLInputElement; native.setSelectionRange(0, 10); inputOf(el).dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true, composed: true, cancelable: true })); for (const ch of '15061850') { inputOf(el).dispatchEvent(new KeyboardEvent('keydown', { key: ch, bubbles: true, composed: true, cancelable: true })); } await settle2(el); await focusBlur(el); expect(textOf(el)).to.equal('15/06/2000'); }).timeout(60000); it('Y15 a year the USER types above the range clamps to the range end', async () => { const el = await make('2026'); const native = inputOf(el).input as HTMLInputElement; native.setSelectionRange(0, 10); inputOf(el).dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true, composed: true, cancelable: true })); for (const ch of '15062100') { inputOf(el).dispatchEvent(new KeyboardEvent('keydown', { key: ch, bubbles: true, composed: true, cancelable: true })); } await settle2(el); await focusBlur(el); expect(textOf(el)).to.equal('15/06/2050'); }).timeout(60000); }); describe('Y16. an unparseable value after a valid one does not leave the old selection on screen', () => { const SEED = '2026-06-15T10:20:30.000[GMT+05:30 Asia/Kolkata]'; const BAD = ['not-a-date', '2026-13-45T10:00:00', 'garbage[GMT+00:00 UTC]', ' ']; const make = async (value = SEED): Promise => { const el: any = await fixture(html``); await settle2(el); return el; }; const textOf = (el: any) => (el.shadowRoot.querySelector('nile-input').input as HTMLInputElement).value; for (const bad of BAD) { it(`Y16 ${JSON.stringify(bad)}: trigger and calendar empty (as for an invalid initial value), zone and events untouched`, async () => { const el = await make(); expect(textOf(el), 'precondition').to.contain('15/06/2026'); let changes = 0; el.addEventListener('nile-change', () => { changes++; }); el.addEventListener('nile-changed', () => { changes++; }); el.value = bad; await settle2(el); expect(textOf(el), 'trigger text').to.equal(''); expect(el.calendarElement.value, 'calendar selection').to.equal(null); expect(el.timezone, 'zone is not touched').to.equal('Asia/Kolkata'); expect(el.value, 'value is the consumer\'s, never rewritten').to.equal(bad); expect(changes, 'no change event').to.equal(0); // and a later valid value seeds normally el.value = '2027-01-02T03:04:05.000[GMT+05:30 Asia/Kolkata]'; await settle2(el); expect(textOf(el)).to.contain('02/01/2027'); expect(el.calendarElement.value.getFullYear()).to.equal(2027); }).timeout(60000); } it('Y16 an invalid INITIAL value shows an empty trigger (the behaviour the after-valid case must match)', async () => { const el = await make('not-a-date'); expect(textOf(el)).to.equal(''); expect(el.calendarElement.value ?? null).to.equal(null); }).timeout(60000); }); describe('Y17. focus/blur with a two-digit (YY) format never moves the calendar off a seeded year outside 2000-2099', () => { const DATE_ONLY = ['hours', 'minutes', 'seconds', 'milliseconds', 'timezone']; const make = async (year: string): Promise => { const el: any = await fixture(html``); await settle2(el); return el; }; const inputOf = (el: any) => el.shadowRoot.querySelector('nile-input') as any; const calState = (el: any) => { const c = el.calendarElement; return [c.value.getFullYear(), c.value.getMonth(), c.value.getDate(), c.currentYear, c.currentMonth]; }; for (const year of ['1985', '2026', '2099', '2100']) { it(`Y17 seeded year ${year}: focus/blur x3 leaves calendar, text, value and events untouched`, async () => { const el = await make(year); const text = (inputOf(el).input as HTMLInputElement).value; const cal = calState(el); const value = el.value; let emitted = 0; el.addEventListener('nile-change', () => { emitted++; }); for (let i = 0; i < 3; i++) { inputOf(el).input.focus(); inputOf(el).dispatchEvent(new FocusEvent('focusout', { bubbles: true, composed: true })); await settle2(el); expect(calState(el), `calendar after cycle ${i + 1}`).to.deep.equal(cal); expect((inputOf(el).input as HTMLInputElement).value, `text after cycle ${i + 1}`).to.equal(text); } expect(el.value).to.equal(value); expect(emitted).to.equal(0); }).timeout(60000); } }); describe('Y18. a YY year is always two characters', () => { const DATE_ONLY = ['hours', 'minutes', 'seconds', 'milliseconds', 'timezone']; for (const [year, yy] of [['0005', '05'], ['0009', '09'], ['0010', '10'], ['2005', '05'], ['2026', '26']]) { it(`Y18 year ${year} renders as ${yy} under DD/MM/YY`, async () => { const el: any = await fixture(html``); await settle2(el); expect((el.shadowRoot.querySelector('nile-input').input as HTMLInputElement).value).to.equal(`15/06/${yy}`); }).timeout(60000); } }); describe('Y19. blur clamps only the segments the user actually edited', () => { // default navigation range (2000-2050) on purpose: every seed below is outside it (1999 and 2051 sit just beyond each end) const DATE_ONLY = ['hours', 'minutes', 'seconds', 'milliseconds', 'timezone']; const make = async (value: string, format = 'DD/MM/YYYY'): Promise => { const el: any = await fixture(html``); await settle2(el); return el; }; 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 blur = async (el: any) => { nativeOf(el).focus(); inputOf(el).dispatchEvent(new FocusEvent('focusout', { bubbles: true, composed: true })); await settle2(el); }; const calDate = (el: any) => { const c = el.calendarElement; return [c.value.getFullYear(), c.value.getMonth() + 1, c.value.getDate()]; }; /** select [from, to) of the text, delete it, and type digits */ const retype = async (el: any, from: number, to: number, digits: string) => { nativeOf(el).setSelectionRange(from, to); press(el, 'Backspace'); for (const ch of digits) press(el, ch); await settle2(el); }; for (const year of ['1985', '1999', '2051', '2100', '0050']) { it(`Y19 seeded ${year}: editing only the day, then blur, leaves the untouched year alone`, async () => { const el = await make(`${year}-06-15T00:00:00[GMT+00:00 UTC]`); await retype(el, 0, 2, '20'); await blur(el); expect(nativeOf(el).value).to.equal(`20/06/${year}`); expect(calDate(el)).to.deep.equal([Number(year), 6, 20]); }).timeout(60000); it(`Y19 seeded ${year}: editing only the month, then blur, leaves the untouched year alone`, async () => { const el = await make(`${year}-06-15T00:00:00[GMT+00:00 UTC]`); await retype(el, 3, 5, '08'); await blur(el); expect(nativeOf(el).value).to.equal(`15/08/${year}`); expect(calDate(el)).to.deep.equal([Number(year), 8, 15]); }).timeout(60000); } // the approved default range is 2000-2050: a year the user completes is clamped into it, a year inside it is kept for (const [typed, expected, why] of [ ['1990', '2000', 'below the range → 2000'], ['1999', '2000', 'just below the range → 2000'], ['2100', '2050', 'above the range → 2050'], ['2051', '2050', 'just above the range → 2050'], ['2000', '2000', 'the range start is kept'], ['2050', '2050', 'the range end is kept'], ['2030', '2030', 'inside the range is kept'], ]) { it(`Y19 the year IS clamped when the user edits the year itself: ${typed} → ${expected} (${why})`, async () => { const el = await make('1985-06-15T00:00:00[GMT+00:00 UTC]'); await retype(el, 6, 10, typed); await blur(el); expect(nativeOf(el).value).to.equal(`15/06/${expected}`); expect(calDate(el)).to.deep.equal([Number(expected), 6, 15]); }).timeout(60000); } it('Y19 an in-range seeded year is not changed by editing another segment', async () => { const el = await make('2030-06-15T00:00:00[GMT+00:00 UTC]'); await retype(el, 0, 2, '20'); await blur(el); expect(nativeOf(el).value).to.equal('20/06/2030'); }).timeout(60000); it('Y19 a second edit after a blur still leaves the untouched year alone', async () => { const el = await make('1985-06-15T00:00:00[GMT+00:00 UTC]'); await retype(el, 0, 2, '20'); await blur(el); await retype(el, 3, 5, '08'); await blur(el); expect(nativeOf(el).value).to.equal('20/08/1985'); }).timeout(60000); it('Y19 an edited month/year still clamps the day to the month length (e.g. 31 -> 28 after choosing February)', async () => { const el = await make('1985-01-31T00:00:00[GMT+00:00 UTC]'); await retype(el, 3, 5, '02'); await blur(el); expect(nativeOf(el).value).to.equal('28/02/1985'); }).timeout(60000); }); describe('Y19b. typed-year clamping: explicit startYear / endYear win, an unset side falls back to 2000 / 2050', () => { const DATE_ONLY = ['hours', 'minutes', 'seconds', 'milliseconds', 'timezone']; const make = async (bounds: { start?: number; end?: number }, format = 'DD/MM/YYYY'): Promise => { const el: any = await fixture(html``); await settle2(el); return el; }; 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 })); /** retype the year segment (the last four characters of DD/MM/YYYY) and blur */ const typeYear = async (el: any, digits: string, from = 6, to = 10) => { nativeOf(el).setSelectionRange(from, to); press(el, 'Backspace'); for (const ch of digits) press(el, ch); await settle2(el); nativeOf(el).focus(); inputOf(el).dispatchEvent(new FocusEvent('focusout', { bubbles: true, composed: true })); await settle2(el); }; const calYear = (el: any) => el.calendarElement.value.getFullYear(); type Case = [string, { start?: number; end?: number }, string, string]; const cases: Case[] = [ // no bounds: the 2000-2050 default applies on both sides ['no bounds, before 2000', {}, '1850', '2000'], ['no bounds, 1999', {}, '1999', '2000'], ['no bounds, after 2050', {}, '2100', '2050'], ['no bounds, 2051', {}, '2051', '2050'], ['no bounds, year 0050', {}, '0050', '2000'], ['no bounds, 2000 and 2050 are kept', {}, '2000', '2000'], ['no bounds, 2050 is kept', {}, '2050', '2050'], // explicit bounds win over the default, including outside 2000-2050 ['explicit bounds before 2000 are respected', { start: 1850, end: 2100 }, '1850', '1850'], ['explicit bounds after 2050 are respected', { start: 1850, end: 2100 }, '2100', '2100'], ['explicit bounds still clamp below', { start: 1850, end: 2100 }, '1800', '1850'], ['explicit bounds still clamp above', { start: 1850, end: 2100 }, '2200', '2100'], ['explicit 0-9999 keeps year 0050', { start: 0, end: 9999 }, '0050', '0050'], ['explicit 0-9999 keeps 9999', { start: 0, end: 9999 }, '9999', '9999'], // custom bounds inside the default range ['custom bounds, below', { start: 1990, end: 2010 }, '1980', '1990'], ['custom bounds, above', { start: 1990, end: 2010 }, '2020', '2010'], ['custom bounds, inside', { start: 1990, end: 2010 }, '2000', '2000'], // one-sided: the unset side falls back to 2000 / 2050 ['startYear only, below it', { start: 1990 }, '1980', '1990'], ['startYear only, before 2000 but above it', { start: 1990 }, '1995', '1995'], ['startYear only, above the default end', { start: 1990 }, '2100', '2050'], ['endYear only, above it', { end: 2010 }, '2020', '2010'], ['endYear only, below the default start', { end: 2010 }, '1850', '2000'], ['endYear only, past 2050 but within it', { end: 2100 }, '2080', '2080'], ['endYear only, past it', { end: 2100 }, '2101', '2100'], ]; for (const [name, bounds, typed, expected] of cases) { it(`Y19b ${name}: typing ${typed} gives ${expected}`, async () => { const el = await make(bounds); await typeYear(el, typed); expect(nativeOf(el).value).to.equal(`15/06/${expected}`); expect(calYear(el)).to.equal(Number(expected)); }).timeout(60000); } it('Y19b no bounds: a typed YY year past 2050 is pulled back to 2050 (75 becomes 50)', async () => { const el = await make({}, 'DD/MM/YY'); await typeYear(el, '75', 6, 8); expect(nativeOf(el).value).to.equal('15/06/50'); expect(calYear(el)).to.equal(2050); }).timeout(60000); it('Y19b explicit endYear 2099: a typed YY year 75 is kept (2075)', async () => { const el = await make({ end: 2099 }, 'DD/MM/YY'); await typeYear(el, '75', 6, 8); expect(nativeOf(el).value).to.equal('15/06/75'); expect(calYear(el)).to.equal(2075); }).timeout(60000); it('Y19b endYear only: the calendar the picker renders gets only that bound', async () => { const el = await make({ end: 2010 }); const cal = el.calendarElement; expect(cal.startYear).to.be.undefined; expect(Number(cal.endYear)).to.equal(2010); }).timeout(60000); }); describe('Y20. Set to now uses the machine zone; value = null keeps the configured timezone', () => { const FROZEN = Date.UTC(2026, 5, 15, 18, 42, 37, 456); const textOf = (el: any) => (el.shadowRoot.querySelector('nile-input').input as HTMLInputElement).value; it('Y20 Set to now shows the machine clock and zone in the trigger, switches picker and calendar to the machine zone, and emits it', async () => { const HOST = Intl.DateTimeFormat().resolvedOptions().timeZone; const el: any = await fixture(html``); const cal = await settle2(el); const RealDate = Date; class FrozenDate extends RealDate { constructor(...args: any[]) { if (args.length === 0) super(FROZEN); else super(...(args as [any])); } static now() { return FROZEN; } } let detail: any = null; el.addEventListener('nile-change', (e: CustomEvent) => { detail = e.detail; }); (globalThis as any).Date = FrozenDate; try { (cal.shadowRoot!.querySelector('.tp-set-to-now-button') as HTMLElement).click(); } finally { (globalThis as any).Date = RealDate; } await settle2(el); (cal.shadowRoot!.querySelector('.apply-button') as HTMLElement).click(); await settle2(el); // the machine's own reading of the frozen instant (host zone differs between runs) const t = new RealDate(FROZEN); const p2 = (n: number) => String(n).padStart(2, '0'); expect(textOf(el)).to.equal( `${p2(t.getDate())}/${p2(t.getMonth() + 1)}/${t.getFullYear()} - ${p2(t.getHours())}:${p2(t.getMinutes())}:${p2(t.getSeconds())}.456 - ${HOST}`); expect(el.timezone, 'picker zone becomes the machine zone').to.equal(HOST); expect(cal.timezone).to.equal(HOST); expect(detail.timezone).to.equal(HOST); expect(detail.unixEpoch).to.equal(FROZEN); }).timeout(60000); it('Y20 value = null clears value and display and keeps the configured timezone', async () => { const el: any = await fixture(html``); await settle2(el); expect(textOf(el), 'precondition').to.contain('15/06/2026'); el.value = null; await settle2(el); expect(el.value).to.equal(null); expect(textOf(el)).to.equal(''); expect(el.calendarElement.value).to.equal(null); expect(el.timezone).to.equal('Asia/Kolkata'); expect(el.calendarElement.timezone).to.equal('Asia/Kolkata'); }).timeout(60000); }); describe('Y21. an overflow Relative duration never reaches the picker (Finding #8)', () => { const fire = (target: HTMLElement, value: string) => target.dispatchEvent(new CustomEvent('nile-change', { detail: { value }, bubbles: false, composed: true })); const make = async (): Promise => { const el: any = await fixture(html``); await settle2(el); return el; }; const tab = async (el: any, value: string) => { el.calendarElement.shadowRoot.querySelector(`nile-tab[panel="${value}"]`).dispatchEvent( new CustomEvent('nile-tab-show', { detail: { value }, bubbles: true, composed: true }) ); await settle2(el); }; const enter = async (el: any, unit: string, n: number) => { const cal = el.calendarElement; fire(cal.shadowRoot.querySelector('.time-input') as HTMLElement, unit); fire(cal.shadowRoot.querySelector('.duration-input') as HTMLElement, String(n)); await settle2(el); }; const apply = (el: any) => el.calendarElement.shadowRoot.querySelector('.apply-button') as HTMLElement; const textOf = (el: any) => (el.shadowRoot.querySelector('nile-input') as any).input.value as string; for (const [unit, n] of [['minutes', 999999999999], ['hours', 99999999999], ['days', 999999999], ['weeks', 999999999], ['months', 999999999]] as Array<[string, number]>) { it(`Y21 ${unit}: ${n} -> Apply is disabled, nothing is thrown, emitted or changed; correcting it applies normally`, async () => { const el = await make(); const value = el.value; await tab(el, 'relative'); const errors: string[] = []; const onError = (e: ErrorEvent) => { errors.push(e.message); e.preventDefault(); }; window.addEventListener('error', onError); const seen: string[] = []; for (const name of ['nile-change', 'nile-changed']) el.addEventListener(name, () => seen.push(name)); try { const before = textOf(el); await enter(el, unit, n); expect(apply(el).hasAttribute('disabled'), 'Apply disabled').to.be.true; apply(el).click(); await settle2(el); expect(errors, 'no uncaught RangeError').to.deep.equal([]); expect(seen, 'no change events').to.deep.equal([]); expect(textOf(el), 'trigger unchanged').to.equal(before); expect(el.value, 'value unchanged').to.equal(value); expect(el.timezone).to.equal('UTC'); await enter(el, unit, 2); expect(apply(el).hasAttribute('disabled'), 'Apply enabled again').to.be.false; apply(el).click(); await settle2(el); expect(seen).to.deep.equal(['nile-change']); expect(textOf(el)).to.contain(' - UTC'); expect(errors).to.deep.equal([]); } finally { window.removeEventListener('error', onError); } }).timeout(60000); } });