import { expect } from '@open-wc/testing'; import { TIMEZONES, utcFromFields } from '../nile-calendar/timezones'; import './nile-date-time-picker'; import '../nile-calendar'; import '../nile-input'; import type { Wall } from './nile-date-time-picker.test-helpers'; import { offsetAt, wallAt, parseBracketed, fmtWall, makePicker, apply, checkPayload, roundTrip, instantsFor, findTransitions, runtimeZones, selectAndApply, HOST_TZ, Failures, } from './nile-date-time-picker.test-helpers'; /* * Section K (adversarial audit), part 1 of 4: PHASE 1 (oracle + driver smoke), PHASE 2, FOCUS A/B, PHASE 3. * 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. */ /***************************************************************************** * SECTION K — ADVERSARIAL AUDIT * Merged in from src/_audit/nile-date-time-picker.adversarial.test.ts so the * component has a single test file. Original header follows verbatim. ****************************************************************************/ /** * ADVERSARIAL AUDIT — nile-date-time-picker (variant="date-time-picker") * ============================================================================ * A single self-contained adversarial suite, consolidated from the 10-file * working set. It answers one question: can the component ever silently * produce or persist the WRONG INSTANT? * * Structure: * PART 1 independent oracle — Intl `longOffset` + explicit UTC arithmetic * PART 2 driver + invariant checker (the PHASE 16 payload contract) * PART 3 PHASE 1 oracle validation + driver smoke test * PART 4 PHASE 2 year range / calendar arithmetic (+ focused follow-ups) * PART 5 PHASE 3 timezone sweep (every runtime zone + static fallback) * PART 6 PHASE 4/5 DST transitions, gaps, overlaps * PART 7 PHASE 7/8/9/11/12 ms, boundaries, formats, round-trips, fuzzing * PART 8 PHASE 6 host-timezone independence digest * PART 9 PHASE 10/14/15 UI sequences, Set to now, Apply semantics * * THE ORACLE RULE: nothing below may validate the component with the * component's own helpers. `zoneOffsetMs`, `composeValueISO`, `wallClockInZone` * and `wallClockToInstant` are never used to compute an expected value. The * oracle reads Intl's PRINTED offset and does its own UTC arithmetic, which is * a different mechanism from the implementation's part-differencing. * * RUNTIME: this is an exhaustive sweep (hundreds of zones, every DST * transition, thousands of randomised cases), not a unit suite. See the note * at the bottom of this file about keeping it out of the default `yarn test`. * * Some checks are DIAGNOSTIC: they record current behaviour via console.warn * (grep for `[audit-`) rather than asserting a policy. Those are labelled. */ /* ========================================================================== PART 1 — INDEPENDENT ORACLE (was oracle.ts) ========================================================================== */ /* Wall, offsetAt, wallAt, wallEquals, parseBracketed, fmtWall, wallToUTCms, instantsFor, findTransitions, runtimeZones, wallFromUTCms and transitionShape now live in nile-date-time-picker.test-helpers.ts (shared with the other split files). */ /** Temporal, only if the runtime genuinely has it. Never faked. */ export const TEMPORAL: any = typeof (globalThis as any).Temporal !== 'undefined' ? (globalThis as any).Temporal : null; /* ── D. independent parsers ───────────────────────────────────────────────── */ /** `...Z` / `...±HH:MM` -> exact instant. Built by hand, not via `new Date`. */ export function parseInstantString(raw: string): number | null { const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?(?:\.(\d{1,3}))?(Z|[+-]\d{2}:?\d{2})$/ .exec(raw.trim()); if (!m) return null; const [, y, mo, d, h, mi, s, frac, zulu] = m; const ms = frac === undefined ? 0 : Number(frac.padEnd(3, '0')); const base = utcFromFields(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s ?? 0), ms); if (zulu === 'Z') return base; const om = /^([+-])(\d{2}):?(\d{2})$/.exec(zulu)!; const off = (om[1] === '-' ? -1 : 1) * (Number(om[2]) * 3600000 + Number(om[3]) * 60000); return base - off; } /** Offset-less datetime -> wall clock digits only (no zone applied). */ export function parseOffsetLess(raw: string): Wall | null { const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?(?:\.(\d{1,3}))?$/.exec(raw.trim()); if (!m) return null; const [, y, mo, d, h, mi, s, frac] = m; return { year: Number(y), month: Number(mo), day: Number(d), hours: Number(h), minutes: Number(mi), seconds: Number(s ?? 0), // NOTE: read as written, NOT scaled — ".25" is 25ms. Records current intent. ms: frac === undefined ? 0 : Number(frac), }; } export function isResolvable(zone: string): boolean { try { new Intl.DateTimeFormat('en-US', { timeZone: zone }); return true; } catch { return false; } } /* ========================================================================== PART 2 — DRIVER + INVARIANT CHECKER (was harness.ts) ========================================================================== */ /* Payload, Violation, makePicker, settle/settle2, applyButton, apply, checkPayload, roundTrip, selectAndApply, HOST_TZ and Failures now live in nile-date-time-picker.test-helpers.ts. The PART 6 DST helpers (YEAR_FROM, YEAR_TO, Tr, allTransitions) moved there too, and mulberry32 is in phases-4. */ /* ========================================================================== PART 3 — PHASE 1: oracle validation + driver smoke test (was p1-oracle.test.ts) ========================================================================== */ describe('PHASE 1 — oracle validation', () => { it('P1.0 reports the environment (host zone, Temporal, zone count)', () => { const zones = runtimeZones(); // eslint-disable-next-line no-console console.warn(`[audit-env] hostTZ=${HOST_TZ} temporal=${TEMPORAL ? 'YES' : 'NO'} ` + `zones=${zones.length} ua=${navigator.userAgent.slice(0, 60)}`); expect(zones.length, 'runtime must expose a timezone list').to.be.greaterThan(100); }); it('P1.1 offsetAt matches known fixed offsets', () => { const t = Date.UTC(2026, 5, 15, 12, 0, 0); expect(offsetAt(t, 'UTC')).to.equal(0); expect(offsetAt(t, 'Asia/Kolkata')).to.equal(5.5 * 3600000); expect(offsetAt(t, 'Asia/Kathmandu')).to.equal(5.75 * 3600000); expect(offsetAt(t, 'Australia/Eucla')).to.equal(8.75 * 3600000); expect(offsetAt(t, 'Pacific/Kiritimati')).to.equal(14 * 3600000); expect(offsetAt(t, 'Pacific/Marquesas')).to.equal(-9.5 * 3600000); expect(offsetAt(t, 'America/St_Johns')).to.equal(-2.5 * 3600000); // NDT in June }); it('P1.2 wallAt round-trips against instantsFor on unambiguous times', () => { const zones = ['UTC', 'Asia/Kolkata', 'America/New_York', 'Pacific/Auckland', 'Australia/Lord_Howe']; for (const z of zones) { for (const t of [Date.UTC(2026, 0, 15, 3, 7, 11, 123), Date.UTC(2026, 6, 15, 18, 44, 2, 9)]) { const w = wallAt(t, z); const back = instantsFor(w, z); expect(back, `${z} ${fmtWall(w)}`).to.include(t); } } }); it('P1.3 instantsFor returns 0 for a gap and 2 for an overlap (New York 2026)', () => { const gap = instantsFor( { year: 2026, month: 3, day: 8, hours: 2, minutes: 30, seconds: 0, ms: 0 }, 'America/New_York'); expect(gap, 'spring-forward 02:30 must not exist').to.have.lengthOf(0); const dup = instantsFor( { year: 2026, month: 11, day: 1, hours: 1, minutes: 30, seconds: 0, ms: 0 }, 'America/New_York'); expect(dup, 'fall-back 01:30 occurs twice').to.have.lengthOf(2); expect(dup[1] - dup[0], 'occurrences are 1h apart').to.equal(3600000); }); it('P1.4 Temporal agrees with the oracle — or is explicitly absent (never silent)', () => { if (!TEMPORAL) { // eslint-disable-next-line no-console console.warn('[audit-env] TEMPORAL UNAVAILABLE — P1.4 cross-check NOT PERFORMED. ' + 'Oracle rests on Intl longOffset + UTC arithmetic alone.'); expect(TEMPORAL, 'recorded as not-tested, not as passed').to.equal(null); return; } // Current Temporal: Temporal.TimeZone was removed; zones are plain strings // reached through ZonedDateTime. Probe the shape rather than assume it. const probe = TEMPORAL.Instant && TEMPORAL.Instant.fromEpochMilliseconds; if (!probe) { // eslint-disable-next-line no-console console.warn('[audit-env] Temporal present but Instant.fromEpochMilliseconds missing — NOT TESTED'); expect.fail('Temporal present but unusable — investigate rather than silently pass'); } let checked = 0; for (const z of ['UTC', 'Asia/Kolkata', 'Asia/Kathmandu', 'America/New_York', 'Australia/Lord_Howe', 'Pacific/Chatham', 'Pacific/Kiritimati', 'America/St_Johns']) { for (const t of [Date.UTC(2026, 0, 15, 12, 0, 0), Date.UTC(2026, 6, 15, 12, 0, 0)]) { const zdt = TEMPORAL.Instant.fromEpochMilliseconds(t).toZonedDateTimeISO(z); expect(Number(zdt.offsetNanoseconds) / 1e6, `Temporal vs oracle ${z} @${t}`) .to.equal(offsetAt(t, z)); const w = wallAt(t, z); expect(zdt.year, `Temporal year ${z}`).to.equal(w.year); expect(zdt.month, `Temporal month ${z}`).to.equal(w.month); expect(zdt.day, `Temporal day ${z}`).to.equal(w.day); expect(zdt.hour, `Temporal hour ${z}`).to.equal(w.hours); expect(zdt.minute, `Temporal minute ${z}`).to.equal(w.minutes); checked += 1; } } // eslint-disable-next-line no-console console.warn(`[audit-env] Temporal cross-check PERFORMED on ${checked} zone/date pairs`); }); it('P1.5 findTransitions locates the two US 2026 transitions', () => { const ts = findTransitions('America/New_York', Date.UTC(2026, 0, 1), Date.UTC(2027, 0, 1)); expect(ts, 'two transitions in 2026').to.have.lengthOf(2); expect(wallAt(ts[0], 'America/New_York').month).to.equal(3); expect(wallAt(ts[1], 'America/New_York').month).to.equal(11); }); it('P1.6 independent parsers agree with hand-computed instants', () => { expect(parseInstantString('2026-06-15T09:00:00.000Z')).to.equal(Date.UTC(2026, 5, 15, 9, 0, 0, 0)); expect(parseInstantString('2026-06-15T14:30:00.000+05:30')).to.equal(Date.UTC(2026, 5, 15, 9, 0, 0, 0)); expect(parseInstantString('2026-06-15T14:30:00.000')).to.equal(null); const b = parseBracketed('2026-06-15T14:30:00.000[GMT+05:30 Asia/Kolkata]')!; expect(b.zone).to.equal('Asia/Kolkata'); expect(b.offsetMs).to.equal(5.5 * 3600000); const o = parseOffsetLess('2026-06-15T14:30:00.250')!; expect(o.ms, 'fractional read as written').to.equal(250); }); }); describe('PHASE 1 — driver smoke test (real component, real Apply)', () => { it('P1.7 a Z value applies and yields a self-consistent payload', async () => { const el = await makePicker({ value: '2026-06-15T09:00:00.000Z', timezone: 'Asia/Kolkata' }); const p = await apply(el); // eslint-disable-next-line no-console console.warn(`[audit-drive] payload=${JSON.stringify({ value: p!.value, unixEpoch: p!.unixEpoch, timezone: p!.timezone, })}`); expect(p, 'Apply emitted a payload').to.exist; expect(p!.unixEpoch, 'instant preserved').to.equal(Date.UTC(2026, 5, 15, 9, 0, 0, 0)); expect(checkPayload(p, 'Asia/Kolkata'), 'invariant violations').to.deep.equal([]); }); }); /* ========================================================================== PART 4 — PHASE 2: year range and calendar arithmetic (was p2-years.test.ts) ========================================================================== */ /** An offset a real tzdb zone could have: |offset| <= 26h, whole minutes. */ function offsetIsSane(offsetMs: number): boolean { return Number.isFinite(offsetMs) && Math.abs(offsetMs) <= 26 * 3600000 && offsetMs % 60000 === 0; } const RE_BRACKET = /\[GMT([+-])(\d+):(\d{2})\s+([^\]]+)\]$/; function printedOffsetMs(value: string): number | null { const m = RE_BRACKET.exec(value); if (!m) return null; return (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 3600000 + Number(m[3]) * 60000); } describe('PHASE 2 — year range and calendar arithmetic', () => { it('P2.0 finds the lowest year whose emitted offset is still sane (UTC)', async () => { const rows: string[] = []; let firstBad: number | null = null; for (const y of [1, 50, 100, 200, 500, 1000, 1500, 1600, 1700, 1800, 1850, 1875, 1880, 1890, 1900, 1901, 1910, 1950, 2000, 2026, 2100]) { const el = await makePicker({}); const w: Wall = { year: y, month: 6, day: 15, hours: 12, minutes: 0, seconds: 0, ms: 0 }; const p = await selectAndApply(el, w, 'UTC'); const off = p && p.value ? printedOffsetMs(p.value) : null; const sane = off !== null && offsetIsSane(off); if (!sane && firstBad === null) firstBad = y; rows.push(`year=${String(y).padStart(4)} sane=${sane ? 'YES' : 'NO '} ` + `printedOffset=${off === null ? 'unparsable' : off / 3600000 + 'h'} ` + `epoch=${p && p.unixEpoch} value=${p && p.value}`); } // eslint-disable-next-line no-console console.warn(`[audit-p2] host=${HOST_TZ} emitted-offset sanity by year:\n ${rows.join('\n ')}`); expect(rows.length).to.be.greaterThan(0); }).timeout(600000); it('P2.1 1900-2100 (every year, mid-month) emits sane offsets and round-trips', async () => { const f = new Failures(); let n = 0; const subMinute: Array<{ y: number; z: string }> = []; for (let y = 1900; y <= 2100; y++) { for (const z of ['UTC', 'Asia/Kolkata', 'America/New_York']) { const w: Wall = { year: y, month: 6, day: 15, hours: 12, minutes: 30, seconds: 15, ms: 250 }; const insts = instantsFor(w, z); if (insts.length !== 1) continue; // The bracket is `[GMT±hh:mm]`: it cannot carry a historical sub-minute (LMT) offset, so a value in such // an era is rounded by design (see FA.1). Skip only those, and pin below that they are all pre-1972. if (offsetAt(insts[0], z) % 60000 !== 0) { subMinute.push({ y, z }); continue; } const el = await makePicker({}); const p = await selectAndApply(el, w, z); const ctx = `year=${y} tz=${z}`; f.add(ctx, checkPayload(p, z)); if (p && p.value) { const off = printedOffsetMs(p.value); if (off === null || !offsetIsSane(off)) { f.add(ctx, [{ code: 'P2_INSANE_OFFSET', detail: `value=${p.value}` }]); } } if (p && p.unixEpoch !== null) f.add(ctx, (await roundTrip(p)).violations); n += 1; } } // eslint-disable-next-line no-console console.warn(`[audit-p2] 1900-2100 probes=${n} skipped(sub-minute offset)=${subMinute.length} violations=${f.count}`); expect(f.count, `year-range violations:${f.report()}`).to.equal(0); expect(subMinute.every((x) => x.y < 1972), 'only pre-1972 LMT-era instants are exempt').to.be.true; expect(n, 'the sweep still covers (nearly) every modern year').to.be.greaterThan(500); }).timeout(2400000); it('P2.2 month lengths, leap rules, 1900/2000/2100, Jan<->Dec edges', async () => { const f = new Failures(); const cases: Array<[number, number, number, string]> = [ [2024, 2, 29, 'leap 2024 valid'], [2000, 2, 29, 'century leap 2000 valid'], [2026, 1, 31, '31-day'], [2026, 4, 30, '30-day'], [2026, 2, 28, '28-day'], [2026, 12, 31, 'Dec 31'], [2026, 1, 1, 'Jan 1'], [1900, 3, 1, '1900 non-leap: Mar 1'], [2100, 3, 1, '2100 non-leap: Mar 1'], ]; for (const [y, mo, d, label] of cases) { for (const z of ['UTC', 'Asia/Kolkata']) { const w: Wall = { year: y, month: mo, day: d, hours: 10, minutes: 0, seconds: 0, ms: 0 }; const insts = instantsFor(w, z); if (insts.length !== 1) continue; // 1900 Asia/Kolkata is +05:21:10 (LMT): not representable in `[GMT±hh:mm]` (rounded by design, see FA.1). // UTC covers the 1900 non-leap case; the Kolkata 1900 row is exempt only for that sub-minute reason. if (offsetAt(insts[0], z) % 60000 !== 0) { expect(y, 'exempt only for LMT-era years').to.be.lessThan(1972); continue; } const el = await makePicker({}); const p = await selectAndApply(el, w, z); const ctx = `${label} tz=${z} ${fmtWall(w)}`; f.add(ctx, checkPayload(p, z)); if (p && p.unixEpoch !== null) { const back = wallAt(p.unixEpoch, z); if (back.year !== y || back.month !== mo || back.day !== d) { f.add(ctx, [{ code: 'P2_DATE_CHANGED', detail: `got ${back.year}-${back.month}-${back.day}` }]); } } } } expect(f.count, `calendar violations:${f.report()}`).to.equal(0); }).timeout(600000); it('P2.3 classifies impossible dates supplied as a value (REJECT/CLAMP/NORMALIZE)', async () => { const notes: string[] = []; for (const raw of ['2026-02-31T10:00:00.000', '2026-02-30', '2025-02-29T10:00:00.000', '2026-04-31T10:00:00.000', '2026-00-10T10:00:00.000', '2026-06-00T10:00:00.000']) { const el = await makePicker({ value: raw, timezone: 'UTC' }); const p = await apply(el); let cls = 'REJECT(no payload)'; if (p && p.unixEpoch !== null) { const w = wallAt(p.unixEpoch, 'UTC'); const askedDay = Number(raw.slice(8, 10)); cls = w.day === askedDay ? 'PRESERVE' : `CLAMP/NORMALIZE -> day ${w.day}`; } else if (p) cls = 'REJECT(null epoch)'; notes.push(`${raw.padEnd(26)} ${cls.padEnd(28)} value=${p && p.value}`); } // eslint-disable-next-line no-console console.warn(`[audit-p2] impossible-date classification:\n ${notes.join('\n ')}`); expect(notes.length).to.equal(6); }).timeout(300000); }); /* ========================================================================== PART 4b — focused: sub-minute offsets and extreme years (was p2b-suboffset.test.ts) ========================================================================== */ describe('FOCUS A — sub-minute zone offsets', () => { it('FA.0 measures how many zones have sub-minute offsets, and until when', () => { const zones = runtimeZones(); const offenders: string[] = []; let latestSubMinute = -Infinity; for (const z of zones) { // probe one instant per decade 1880..2100 for (let y = 1880; y <= 2100; y += 10) { const t = Date.UTC(y, 5, 15, 12, 0, 0); const off = offsetAt(t, z); if (off % 60000 !== 0) { if (offenders.indexOf(z) === -1) offenders.push(z); if (t > latestSubMinute) latestSubMinute = t; } } } // eslint-disable-next-line no-console console.warn(`[audit-focusA] zonesWithSubMinuteOffset=${offenders.length}/${zones.length} ` + `latestSubMinuteInstant=${latestSubMinute === -Infinity ? 'none' : new Date(latestSubMinute).toISOString()} ` + `sample=${offenders.slice(0, 15).join(',')}`); expect(zones.length).to.be.greaterThan(0); }).timeout(600000); it('FA.1 REPRODUCTION: Asia/Kolkata 1900-06-15 12:30:15.250 — value != its own epoch', async () => { const z = 'Asia/Kolkata'; const w: Wall = { year: 1900, month: 6, day: 15, hours: 12, minutes: 30, seconds: 15, ms: 250 }; const el = await makePicker({}); const p = await selectAndApply(el, w, z); expect(p, 'payload').to.exist; const epoch = p!.unixEpoch!; const trueOff = offsetAt(epoch, z); const viol = checkPayload(p, z); // parse the printed bracket independently const m = /\[GMT([+-])(\d{2}):(\d{2})\s+([^\]]+)\]$/.exec(p!.value!)!; const printed = (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 3600000 + Number(m[3]) * 60000); const digits = p!.value!.slice(0, 23); const fromValue = Date.parse(digits + 'Z') - printed; // does a RELOAD still land on the same epoch? const rt = await roundTrip(p!); // eslint-disable-next-line no-console console.warn(`[audit-focusA] REPRO host=${HOST_TZ} tz=${z}\n` + ` selected = ${fmtWall(w)}\n` + ` unixEpoch = ${epoch}\n` + ` true offset = ${trueOff}ms (${trueOff / 60000} min, i.e. ${trueOff / 1000}s)\n` + ` printed offset= ${printed}ms (${printed / 60000} min)\n` + ` offset lost = ${trueOff - printed}ms\n` + ` emitted value = ${p!.value}\n` + ` value resolves to ${fromValue} (delta vs epoch = ${fromValue - epoch}ms)\n` + ` reload epoch = ${rt.second && rt.second.unixEpoch} ` + `(${rt.second && rt.second.unixEpoch === epoch ? 'PRESERVED' : 'DRIFTED'})\n` + ` violations = ${viol.map(v => v.code).join(',') || 'none'}`); expect(trueOff % 60000, 'this zone/date genuinely has a sub-minute offset').to.not.equal(0); expect(fromValue - epoch, 'the emitted value does NOT describe its own epoch').to.not.equal(0); }).timeout(300000); // Start at 1972: Africa/Monrovia genuinely used -00:44:30 until 1972-01-07, so 1970-71 is not 'modern' (tzdata history). it('FA.2 modern era (1972-2100) has NO sub-minute offsets in any zone', () => { const zones = runtimeZones(); const bad: string[] = []; for (const z of zones) { for (let y = 1972; y <= 2100; y += 5) { for (const mo of [0, 6]) { const off = offsetAt(Date.UTC(y, mo, 15, 12), z); if (off % 60000 !== 0) bad.push(`${z}@${y}-${mo + 1}`); } } } // eslint-disable-next-line no-console console.warn(`[audit-focusA] modern-era sub-minute offsets=${bad.length} ${bad.slice(0, 10).join(',')}`); expect(bad, 'no modern zone has a sub-minute offset').to.deep.equal([]); }).timeout(600000); }); describe('FOCUS B — extreme years through the Z-instant path', () => { it('FB.1 records emitted offset sanity for extreme years (no harness Date() involved)', async () => { const rows: string[] = []; for (const raw of [ '0001-01-01T00:00:00.000Z', '0100-06-15T12:00:00.000Z', '1000-06-15T12:00:00.000Z', '1700-06-15T12:00:00.000Z', '1900-06-15T12:00:00.000Z', '2026-06-15T12:00:00.000Z', '2100-06-15T12:00:00.000Z', '9999-12-31T23:59:59.999Z', ]) { const el = await makePicker({ value: raw, timezone: 'UTC' }); const p = await apply(el); const m = p && p.value ? /\[GMT([+-])(\d+):(\d{2})/.exec(p.value) : null; const hours = m ? Number(m[2]) : NaN; rows.push(`${raw} -> epoch=${p && p.unixEpoch} offsetHours=${m ? (m[1] + String(hours)) : 'n/a'} ` + `${Number.isFinite(hours) && hours > 26 ? '<<< INSANE' : ''} value=${p && p.value}`); } // eslint-disable-next-line no-console console.warn(`[audit-focusB] extreme-year Z inputs:\n ${rows.join('\n ')}`); expect(rows.length).to.equal(8); }).timeout(300000); it('FB.2 records the component date-only path for 2-digit-looking years', async () => { const rows: string[] = []; for (const raw of ['0050-06-15', '0099-06-15', '0100-06-15', '1950-06-15']) { const el = await makePicker({ value: raw, timezone: 'UTC' }); const p = await apply(el); const w = p && p.unixEpoch !== null ? wallAt(p.unixEpoch, 'UTC') : null; rows.push(`${raw} -> epoch=${p && p.unixEpoch} readsBackAsYear=${w && w.year} value=${p && p.value}`); } // eslint-disable-next-line no-console console.warn(`[audit-focusB] date-only low years (component path):\n ${rows.join('\n ')}`); expect(rows.length).to.equal(4); }).timeout(300000); }); /* ========================================================================== PART 5 — PHASE 3: timezone sweep (was p3-zones.test.ts) ========================================================================== */ const NAMED = [ 'UTC', 'Asia/Kolkata', 'Asia/Calcutta', 'Asia/Kathmandu', 'Australia/Eucla', 'Australia/Lord_Howe', 'America/St_Johns', 'America/New_York', 'Europe/London', 'Pacific/Auckland', 'Pacific/Kiritimati', 'Pacific/Marquesas', ]; // Representative wall clocks across the year, with awkward ms/seconds. const PROBES: Wall[] = [ { year: 2026, month: 1, day: 15, hours: 9, minutes: 30, seconds: 0, ms: 0 }, { year: 2026, month: 7, day: 15, hours: 14, minutes: 45, seconds: 7, ms: 123 }, { year: 2026, month: 10, day: 31, hours: 23, minutes: 59, seconds: 59, ms: 999 }, ]; async function probeZone(zone: string, w: Wall, f: Failures): Promise { const el = await makePicker({}); const p = await selectAndApply(el, w, zone); const ctx = `tz=${zone} want=${fmtWall(w)} host=${HOST_TZ}`; f.add(ctx, checkPayload(p, zone)); if (!p || p.unixEpoch === null) { f.add(ctx, [{ code: 'NO_EPOCH', detail: 'apply produced none' }]); return; } const rt = await roundTrip(p); f.add(`${ctx} value=${p.value}`, rt.violations); } describe('PHASE 3 — timezone sweep', () => { it('P3.0 records both zone sources and their divergence (diagnostic, no spelling assumed)', () => { const runtime = runtimeZones(); const onlyRuntime = runtime.filter(z => TIMEZONES.indexOf(z) === -1); const onlyStatic = TIMEZONES.filter(z => runtime.indexOf(z) === -1); const unresolvableStatic = TIMEZONES.filter(z => !isResolvable(z)); // eslint-disable-next-line no-console console.warn(`[audit-p3] host=${HOST_TZ} runtimeZones=${runtime.length} staticList=${TIMEZONES.length} ` + `onlyRuntime=${onlyRuntime.length} onlyStatic=${onlyStatic.length} ` + `staticUnresolvableByIntl=${unresolvableStatic.length} ` + `india(runtime)=${runtime.filter(z => /Kolkata|Calcutta/.test(z)).join('|')} ` + `india(static)=${TIMEZONES.filter(z => /Kolkata|Calcutta/.test(z)).join('|')}`); expect(runtime.length, 'runtime exposes a zone list').to.be.greaterThan(100); // Any static-list entry Intl cannot resolve would be offered by the legacy // fallback dropdown yet rejected by isValidTimezone — record it explicitly. expect(unresolvableStatic, 'static fallback entries Intl rejects').to.deep.equal([]); }); it('P3.1 explicitly named zones round-trip with no invariant violation', async () => { const f = new Failures(); for (const z of NAMED) for (const w of PROBES) await probeZone(z, w, f); expect(f.count, `named-zone violations:${f.report()}`).to.equal(0); }).timeout(180000); it('P3.2 EVERY runtime zone round-trips (one probe each)', async () => { const f = new Failures(); const zones = runtimeZones(); let n = 0; for (const z of zones) { await probeZone(z, PROBES[1], f); n += 1; } // eslint-disable-next-line no-console console.warn(`[audit-p3] swept ${n} zones, violations=${f.count}`); expect(f.count, `all-zone violations:${f.report()}`).to.equal(0); }).timeout(900000); it('P3.3 a valid alias is never silently downgraded to UTC', async () => { const f = new Failures(); const aliases: Array<[string, string]> = [ ['Asia/Calcutta', 'Asia/Kolkata'], ['Asia/Saigon', 'Asia/Ho_Chi_Minh'], ['Europe/Kiev', 'Europe/Kyiv'], ['America/Godthab', 'America/Nuuk'], ['Asia/Rangoon', 'Asia/Yangon'], ['Pacific/Ponape', 'Pacific/Pohnpei'], ]; for (const [alias, canonical] of aliases) { let resolvable = true; try { new Intl.DateTimeFormat('en-US', { timeZone: alias }); } catch { resolvable = false; } if (!resolvable) continue; const w = PROBES[1]; const el = await makePicker({}); const p = await selectAndApply(el, w, alias); const ctx = `alias=${alias} canonical=${canonical}`; f.add(ctx, checkPayload(p)); if (p && p.unixEpoch !== null) { // the epoch must match the canonical zone's resolution, never UTC's const el2 = await makePicker({}); const pc = await selectAndApply(el2, w, canonical); if (pc && pc.unixEpoch !== p.unixEpoch) { f.add(ctx, [{ code: 'ALIAS_EPOCH_DIVERGES', detail: `alias=${p.unixEpoch} canonical=${pc.unixEpoch}` }]); } if (offsetAt(p.unixEpoch, alias) === 0 && offsetAt(p.unixEpoch, canonical) !== 0) { f.add(ctx, [{ code: 'ALIAS_DOWNGRADED_TO_UTC', detail: `value=${p.value}` }]); } } } expect(f.count, `alias violations:${f.report()}`).to.equal(0); }).timeout(180000); it('P3.5 EVERY static-fallback zone round-trips (legacy dropdown path)', async () => { const f = new Failures(); let n = 0; for (const z of TIMEZONES) { if (!isResolvable(z)) continue; await probeZone(z, PROBES[1], f); n += 1; } // eslint-disable-next-line no-console console.warn(`[audit-p3] swept ${n} static-fallback zones, violations=${f.count}`); expect(f.count, `static-list violations:${f.report()}`).to.equal(0); }).timeout(900000); it('P3.4 sub-hour zones (:30 / :45 / :15) keep exact offsets', async () => { const f = new Failures(); for (const z of ['Asia/Kolkata', 'Asia/Kathmandu', 'Australia/Eucla', 'America/St_Johns', 'Pacific/Chatham', 'Pacific/Marquesas', 'Australia/Lord_Howe']) { for (const w of PROBES) { const el = await makePicker({}); const p = await selectAndApply(el, w, z); const ctx = `subhour tz=${z} want=${fmtWall(w)}`; f.add(ctx, checkPayload(p, z)); if (p && p.unixEpoch !== null) { const off = offsetAt(p.unixEpoch, z); if (off % 3600000 === 0 && ['Asia/Kolkata', 'Asia/Kathmandu'].indexOf(z) !== -1) { f.add(ctx, [{ code: 'SUBHOUR_LOST', detail: `offset became whole hours: ${off}` }]); } const rt = await roundTrip(p); f.add(ctx, rt.violations); } } } expect(f.count, `sub-hour violations:${f.report()}`).to.equal(0); }).timeout(300000); });