import { expect } from '@open-wc/testing'; import './nile-date-time-picker'; import '../nile-calendar'; import '../nile-input'; import type { Wall, Payload } from './nile-date-time-picker.test-helpers'; import { offsetAt, wallAt, fmtWall, makePicker, settle, applyButton, apply, checkPayload, roundTrip, instantsFor, runtimeZones, selectAndApply, HOST_TZ, Failures, } from './nile-date-time-picker.test-helpers'; /* * Section K (adversarial audit), part 4 of 4: PHASE 12, 10, 14, 15. * 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. */ /* ── deterministic RNG (PHASE 12) ─────────────────────────────────────────── */ export function mulberry32(seed: number): () => number { let a = seed >>> 0; return () => { a = (a + 0x6D2B79F5) >>> 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } describe('PHASE 12 — randomized property testing (deterministic seed)', () => { it('P12.1 random wall clock x zone reproduces its own digits unless it is a gap', async () => { const SEED = 20260913; const rnd = mulberry32(SEED); const zones = runtimeZones(); const f = new Failures(); const N = 1500; let gaps = 0; let checked = 0; for (let i = 0; i < N; i++) { const z = zones[Math.floor(rnd() * zones.length)]; const w: Wall = { year: 1990 + Math.floor(rnd() * 80), month: 1 + Math.floor(rnd() * 12), day: 1 + Math.floor(rnd() * 28), hours: Math.floor(rnd() * 24), minutes: Math.floor(rnd() * 60), seconds: Math.floor(rnd() * 60), ms: Math.floor(rnd() * 1000), }; const occ = instantsFor(w, z); const el = await makePicker({}); const p = await selectAndApply(el, w, z); const ctx = `seed=${SEED} i=${i} host=${HOST_TZ} tz=${z} want=${fmtWall(w)}`; f.add(ctx, checkPayload(p, z)); if (p && p.unixEpoch !== null) { if (occ.length === 0) { gaps += 1; // gap: digits legitimately differ } else { const back = wallAt(p.unixEpoch, z); if (fmtWall(back) !== fmtWall(w)) { f.add(ctx, [{ code: 'P12_DIGITS_NOT_REPRODUCED', detail: `expectedWall=${fmtWall(w)} actualWall=${fmtWall(back)} ` + `expectedEpochOneOf=[${occ.join(',')}] actualEpoch=${p.unixEpoch} ` + `offset=${offsetAt(p.unixEpoch, z) / 60000}min value=${p.value}`, }]); } if (occ.indexOf(p.unixEpoch) === -1) { f.add(ctx, [{ code: 'P12_EPOCH_NOT_AN_OCCURRENCE', detail: `actualEpoch=${p.unixEpoch} validOccurrences=[${occ.join(',')}] value=${p.value}`, }]); } checked += 1; } } } // eslint-disable-next-line no-console console.warn(`[audit-p12] seed=${SEED} cases=${N} verified=${checked} gapsSkipped=${gaps} violations=${f.count}`); expect(f.count, `random property violations:${f.report()}`).to.equal(0); }).timeout(1800000); }); /* ========================================================================== PART 9 — PHASE 10/14/15: UI sequences, Set to now, Apply semantics (was p1045-ui.test.ts) ========================================================================== */ const days = (cal: any): HTMLElement[] => Array.from(cal.shadowRoot.querySelectorAll('.day_date:not(.filler)')); const nowBtn = (cal: any): any => cal.shadowRoot.querySelector('.tp-set-to-now-button'); const localLink = (cal: any): any => cal.shadowRoot.querySelector('.tp-timezone-local-link'); const resetBtn = (cal: any): any => cal.shadowRoot.querySelector('.clear-button'); const steppers = (cal: any): HTMLElement[] => Array.from(cal.shadowRoot.querySelectorAll('.tp-stepper-btn')); async function clickDay(el: any, i = 3) { const cal = await settle(el); const d = days(cal); if (d.length > i) { d[i].click(); await settle(el); } } async function setZone(el: any, z: string) { const cal = await settle(el); cal.timezone = z; await settle(el); } async function setTime(el: any, h: number, mi: number, s = 0, ms = 0) { const cal = await settle(el); cal.tpHours = h; cal.tpMinutes = mi; cal.tpSeconds = s; cal.tpMilliseconds = ms; await settle(el); } describe('PHASE 10 — UI sequence fuzzing', () => { type Step = 'date' | 'time' | 'zone' | 'nav-month' | 'nav-year' | 'reset'; const SEQUENCES: Step[][] = [ ['date', 'time', 'zone'], ['zone', 'date', 'time'], ['time', 'date', 'zone'], ['date', 'zone', 'time'], ['zone', 'time', 'date'], ['time', 'zone', 'date'], ['nav-month', 'date', 'time'], ['nav-year', 'date', 'time'], ['date', 'time', 'zone', 'reset', 'date', 'time'], ['date', 'zone', 'time', 'zone', 'date'], ]; it('P10.1 every ordering yields a payload consistent with its own final state', async () => { const f = new Failures(); const zones = ['Asia/Kolkata', 'America/New_York', 'Pacific/Chatham']; let n = 0; for (const seq of SEQUENCES) { for (const z of zones) { const el = await makePicker({}); for (const step of seq) { if (step === 'date') await clickDay(el, 3); else if (step === 'time') await setTime(el, 14, 45, 7, 123); else if (step === 'zone') await setZone(el, z); else if (step === 'reset') { const c = await settle(el); resetBtn(c) && resetBtn(c).click(); await settle(el); } else if (step === 'nav-month') { const c = await settle(el); c.currentMonth = (c.currentMonth + 1) % 12; await settle(el); } else if (step === 'nav-year') { const c = await settle(el); c.currentYear = c.currentYear + 1; await settle(el); } } const p = await apply(el); const ctx = `seq=[${seq.join('>')}] tz=${z} host=${HOST_TZ}`; // The payload must be self-consistent regardless of interaction order. f.add(ctx, checkPayload(p)); if (p && p.unixEpoch !== null) f.add(ctx, (await roundTrip(p)).violations); n += 1; } } // eslint-disable-next-line no-console console.warn(`[audit-p10] sequences run=${n} violations=${f.count}`); expect(f.count, `UI-sequence violations:${f.report()}`).to.equal(0); }).timeout(1800000); it('P10.2 changing zone AFTER selecting keeps wall-clock digits and moves the epoch', async () => { const f = new Failures(); const el = await makePicker({}); await clickDay(el, 3); await setTime(el, 14, 30, 0, 0); await setZone(el, 'Asia/Kolkata'); const p1 = await apply(el); await setZone(el, 'America/New_York'); const p2 = await apply(el); f.add('zone-change p1', checkPayload(p1, 'Asia/Kolkata')); f.add('zone-change p2', checkPayload(p2, 'America/New_York')); if (p1 && p2 && p1.unixEpoch !== null && p2.unixEpoch !== null) { const w1 = wallAt(p1.unixEpoch, 'Asia/Kolkata'); const w2 = wallAt(p2.unixEpoch, 'America/New_York'); // documented: a wall clock FOLLOWS the zone (digits kept, epoch re-derived) if (fmtWall(w1) !== fmtWall(w2)) { f.add('zone-change', [{ code: 'P10_DIGITS_NOT_KEPT', detail: `kolkata=${fmtWall(w1)} ny=${fmtWall(w2)} (documented behaviour is digits-kept)`, }]); } if (p1.unixEpoch === p2.unixEpoch) { f.add('zone-change', [{ code: 'P10_EPOCH_DID_NOT_MOVE', detail: `both=${p1.unixEpoch}` }]); } } expect(f.count, `zone-change violations:${f.report()}`).to.equal(0); }).timeout(300000); }); describe('PHASE 14 — Set to now (frozen clock)', () => { // Contract (nile-calendar handleSetToNow, unchanged since the variant was introduced): "Set to now" reads the // HOST-local clock fields and adopts the HOST timezone, so the committed instant is exactly "now" and the // payload is presented in the host zone — whatever zone was selected before. (P14.2 covers the explicit // "Use local timezone" link.) The clock is frozen by replacing the global Date class: `new Date()` does not go // through `Date.now`, so stubbing only `Date.now` (as this test used to) leaves the component on the real clock. it('P14.1 Set to now commits the frozen instant, presented in the HOST zone (selected zone is replaced)', async () => { const f = new Failures(); const FROZEN = Date.UTC(2026, 5, 15, 18, 42, 37, 456); 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; } } try { for (const z of ['UTC', 'Asia/Kolkata', 'America/New_York', 'Pacific/Kiritimati']) { const el = await makePicker({}); await setZone(el, z); const cal = await settle(el); const btn = nowBtn(cal); if (!btn) { f.add(z, [{ code: 'P14_NO_BUTTON', detail: 'set-to-now control not found' }]); continue; } (globalThis as any).Date = FrozenDate; try { btn.click(); } finally { (globalThis as any).Date = RealDate; } await settle(el); const p = await apply(el); const ctx = `set-to-now (selected tz=${z}) frozen=${FROZEN} host=${HOST_TZ}`; // the payload must be internally consistent in the HOST zone f.add(ctx, checkPayload(p, HOST_TZ)); if (p && p.unixEpoch !== null) { const drift = p.unixEpoch - FROZEN; // eslint-disable-next-line no-console console.warn(`[audit-p14] selected=${z} frozenEpoch=${FROZEN} appliedEpoch=${p.unixEpoch} ` + `drift=${drift}ms value=${p.value}`); if (Math.abs(drift) > 1000) { f.add(ctx, [{ code: 'P14_NOW_DRIFT', detail: `drift=${drift}ms (>1s from frozen now)` }]); } if (p.timezone !== HOST_TZ) { f.add(ctx, [{ code: 'P14_ZONE_NOT_HOST', detail: `payload tz=${p.timezone} host=${HOST_TZ}` }]); } } } } finally { (globalThis as any).Date = RealDate; } expect(f.count, `set-to-now violations:${f.report()}`).to.equal(0); }).timeout(600000); it('P14.2 "Use local timezone" adopts the HOST zone deliberately, not by accident', async () => { const el = await makePicker({}); await setZone(el, 'Pacific/Kiritimati'); const cal = await settle(el); const link = localLink(cal); const before = cal.timezone; if (link) { link.click(); await settle(el); } // eslint-disable-next-line no-console console.warn(`[audit-p14] use-local-timezone: before=${before} after=${(await settle(el)).timezone} host=${HOST_TZ}`); expect(link, 'the local-timezone control exists').to.exist; }).timeout(300000); }); describe('PHASE 15 — Apply semantics / commit boundary', () => { // ROOT CAUSE (verified with a probe): the picker's panel content is parked in a detached popper while the panel // is closed, so the calendar's time fields are NOT connected (isConnected=false, hasUpdated=false, // no shadow root, `.input` null). A stepper click called `nile-input.focus()` -> `this.input.focus` -> TypeError. // A user can only click a stepper once the panel is open (connected), so this only hit programmatic clicks. // The calendar's step() now skips focusing an input that has not rendered; see also calendar test S2. it('P15.1 editing before Apply does not publish a committed instant', async () => { const el = await makePicker({}); let emissions = 0; el.addEventListener('nile-change', () => { emissions += 1; }); await clickDay(el, 3); await setTime(el, 9, 15, 0, 0); await setZone(el, 'Asia/Kolkata'); const cal = await settle(el); const sts = steppers(cal); if (sts.length) { sts[0].click(); await settle(el); } const beforeApply = emissions; applyButton(cal).click(); await settle(el); // eslint-disable-next-line no-console console.warn(`[audit-p15] emissions before Apply=${beforeApply}, after Apply=${emissions}`); expect(beforeApply, 'no nile-change before Apply').to.equal(0); expect(emissions, 'exactly one commit on Apply').to.equal(1); }).timeout(300000); it('P15.2 Apply emits exactly one payload per click across zones', async () => { const f = new Failures(); for (const z of ['UTC', 'Asia/Kolkata', 'America/New_York']) { const el = await makePicker({}); await clickDay(el, 3); await setTime(el, 10, 0, 0, 0); await setZone(el, z); const p: Payload | null = await apply(el); const count = p ? (p as any).__emitCount : 0; if (count !== 1) { f.add(`tz=${z}`, [{ code: 'P15_EMIT_COUNT', detail: `nile-change fired ${count} times for one Apply` }]); } } expect(f.count, `apply-emission violations:${f.report()}`).to.equal(0); }).timeout(300000); it('P15.3 Reset then reselect produces a fresh, consistent payload', async () => { const f = new Failures(); const el = await makePicker({ value: '2026-06-15T09:00:00.000Z', timezone: 'Asia/Kolkata' }); const p1 = await apply(el); const cal = await settle(el); if (resetBtn(cal)) { resetBtn(cal).click(); await settle(el); } await clickDay(el, 4); await setTime(el, 23, 59, 59, 999); const p2 = await apply(el); f.add('after-reset', checkPayload(p2, 'Asia/Kolkata')); if (p1 && p2 && p1.unixEpoch === p2.unixEpoch) { f.add('after-reset', [{ code: 'P15_STALE_EPOCH', detail: `reset did not clear retained instant: ${p1.unixEpoch}` }]); } if (p2 && p2.unixEpoch !== null) f.add('after-reset', (await roundTrip(p2)).violations); expect(f.count, `reset violations:${f.report()}`).to.equal(0); }).timeout(300000); }); // ============================================================================ // NOTE ON TEST-RUNNER PICKUP // Section K is an exhaustive sweep (hundreds of zones, every DST transition, // thousands of randomised cases) and takes minutes, not seconds. It now lives // in this file, so a plain `yarn test` runs it alongside the unit suites and // the default run is correspondingly slower. // // Section K's own `it`s carry explicit `.timeout(...)` calls, so they are not // bound by the 15s default in web-test-runner.config.mjs. The runner-level // `testsFinishTimeout` (300000 = 5 min) still applies to the whole run; raise // it there if the suite outgrows it. // ============================================================================