/* * Top-level helpers shared by more than one nile-date-time-picker.*.test.ts file * (moved verbatim from the original single test file). Not a test file itself. */ import { fixture, html } from '@open-wc/testing'; import { utcFromFields, localDateFromFields } from '../nile-calendar/timezones'; /* PART 1 — INDEPENDENT ORACLE (shared subset; the rest stays in nile-date-time-picker.phases.test.ts) */ export type Wall = { year: number; month: number; day: number; // month is 1-BASED hours: number; minutes: number; seconds: number; ms: number; }; /* ── B. epoch -> true UTC offset in a zone ────────────────────────────────── */ const OFFSET_FMT_CACHE = new Map(); /** * The zone's real offset at an instant, in ms, east-positive. * Independent mechanism: reads Intl's own printed offset string rather than * reconstructing it from formatted date parts. */ export function offsetAt(epochMs: number, zone: string): number { let fmt = OFFSET_FMT_CACHE.get(zone); if (!fmt) { fmt = new Intl.DateTimeFormat('en-US', { timeZone: zone, timeZoneName: 'longOffset' }); OFFSET_FMT_CACHE.set(zone, fmt); } const part = fmt.formatToParts(new Date(epochMs)).find(p => p.type === 'timeZoneName'); if (!part) throw new Error(`no timeZoneName part for ${zone}`); const t = part.value.trim(); // "GMT" (exactly) means +00:00; otherwise "GMT+05:30" / "GMT-03:30" / "GMT+14" if (t === 'GMT' || t === 'UTC') return 0; const m = /^(?:GMT|UTC)([+-])(\d{1,2})(?::(\d{2}))?(?::(\d{2}))?$/.exec(t); if (!m) throw new Error(`unparsable offset "${t}" for ${zone}`); const sign = m[1] === '-' ? -1 : 1; const h = Number(m[2]); const mi = Number(m[3] ?? 0); const s = Number(m[4] ?? 0); return sign * (h * 3600000 + mi * 60000 + s * 1000); } /* ── A. epoch -> wall clock in a zone ─────────────────────────────────────── */ /** * Derived from the offset by pure UTC arithmetic — NOT by formatting the date * into y/m/d/h/m/s parts. Independent of the implementation's approach. */ export function wallAt(epochMs: number, zone: string): Wall { const local = epochMs + offsetAt(epochMs, zone); const d = new Date(local); return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate(), hours: d.getUTCHours(), minutes: d.getUTCMinutes(), seconds: d.getUTCSeconds(), ms: d.getUTCMilliseconds(), }; } export function wallEquals(a: Wall, b: Wall): boolean { return a.year === b.year && a.month === b.month && a.day === b.day && a.hours === b.hours && a.minutes === b.minutes && a.seconds === b.seconds && a.ms === b.ms; } /** Nile bracketed form -> its parts. Independent regex. */ export function parseBracketed( raw: string ): { wall: Wall; offsetMs: number; zone: string } | null { const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?\[GMT([+-])(\d{2}):(\d{2})\s+([^\]]+)\]$/ .exec(raw.trim()); if (!m) return null; const [, y, mo, d, h, mi, s, frac, sign, oh, om, zone] = m; return { wall: { year: Number(y), month: Number(mo), day: Number(d), hours: Number(h), minutes: Number(mi), seconds: Number(s), ms: frac === undefined ? 0 : Number(frac.padEnd(3, '0')), }, offsetMs: (sign === '-' ? -1 : 1) * (Number(oh) * 3600000 + Number(om) * 60000), zone: zone.trim(), }; } export const fmtWall = (w: Wall): string => `${String(w.year).padStart(4, '0')}-${String(w.month).padStart(2, '0')}-${String(w.day).padStart(2, '0')}` + `T${String(w.hours).padStart(2, '0')}:${String(w.minutes).padStart(2, '0')}:${String(w.seconds).padStart(2, '0')}` + `.${String(w.ms).padStart(3, '0')}`; /* PART 2 — DRIVER + INVARIANT CHECKER (shared subset) */ export type Payload = { value: string | null; localTimezoneValue: Date | null; unixEpoch: number | null; timezone: string; }; export type Violation = { code: string; detail: string }; /* ── driving the real component ───────────────────────────────────────────── */ export async function makePicker(attrs: { value?: string | null; timezone?: string }): Promise { const el: any = attrs.value === undefined || attrs.value === null ? await fixture(html``) : await fixture(html``); await settle2(el); return el; } export async function settle(el: any): Promise { await el.updateComplete; const cal = el.calendarElement; if (cal) await cal.updateComplete; return cal; } /** A preset value may adopt a zone, scheduling a second update. Settle twice. */ export async function settle2(el: any): Promise { await settle(el); return settle(el); } export let LAST_EMIT_COUNT = 0; export const applyButton = (cal: any): HTMLElement => cal.shadowRoot.querySelector('.apply-button') as HTMLElement; /** Click the real Apply button and capture the single committed payload. */ export async function apply(el: any): Promise { const cal = await settle(el); let detail: Payload | null = null; let count = 0; const onChange = (e: Event) => { detail = (e as CustomEvent).detail; count += 1; }; el.addEventListener('nile-change', onChange); applyButton(cal).click(); await settle(el); el.removeEventListener('nile-change', onChange); // Apply legitimately emits nothing when there is no selection — record the // count without assuming a payload exists. LAST_EMIT_COUNT = count; if (detail) (detail as any).__emitCount = count; return detail; } /* ── PHASE 16: the invariant checker ─────────────────────────────────────── */ /** * Every assertion is made against the ORACLE, never against component helpers. * Returns [] when the payload is internally consistent. */ export function checkPayload(p: Payload | null, intendedZone?: string): Violation[] { const v: Violation[] = []; if (!p) return [{ code: 'NO_PAYLOAD', detail: 'Apply emitted nothing' }]; if (p.unixEpoch === null) { if (p.value !== null) v.push({ code: 'NULL_EPOCH_WITH_VALUE', detail: `value=${p.value}` }); return v; } // A. localTimezoneValue mirrors the epoch if (!p.localTimezoneValue || p.localTimezoneValue.getTime() !== p.unixEpoch) { v.push({ code: 'A_LTV_NE_EPOCH', detail: `localTimezoneValue=${p.localTimezoneValue && p.localTimezoneValue.getTime()} epoch=${p.unixEpoch}`, }); } // E. timezone is the intended one if (intendedZone !== undefined && p.timezone !== intendedZone) { v.push({ code: 'E_TZ_MISMATCH', detail: `payload tz=${p.timezone} intended=${intendedZone}` }); } if (p.value === null) { v.push({ code: 'EPOCH_WITHOUT_VALUE', detail: `epoch=${p.unixEpoch}` }); return v; } const parsed = parseBracketed(p.value); if (!parsed) { v.push({ code: 'VALUE_UNPARSABLE', detail: `value=${p.value}` }); return v; } // D. printed offset == zone's ACTUAL offset at the epoch let trueOff: number | null = null; try { trueOff = offsetAt(p.unixEpoch, p.timezone); } catch { /* unresolvable */ } if (trueOff !== null && parsed.offsetMs !== trueOff) { v.push({ code: 'D_OFFSET_WRONG', detail: `printed=${parsed.offsetMs / 60000}min true=${trueOff / 60000}min at epoch=${p.unixEpoch} tz=${p.timezone}`, }); } // C. printed digits == the zone's reading of the epoch if (trueOff !== null) { const expected = wallAt(p.unixEpoch, p.timezone); if (!wallEquals(parsed.wall, expected)) { v.push({ code: 'C_DIGITS_WRONG', detail: `printed=${fmtWall(parsed.wall)} expected=${fmtWall(expected)} tz=${p.timezone} epoch=${p.unixEpoch}`, }); } } // B. the value, resolved independently, is the same instant as unixEpoch // (digits - printed offset must equal the epoch) const fromValue = utcFromFields( parsed.wall.year, parsed.wall.month - 1, parsed.wall.day, parsed.wall.hours, parsed.wall.minutes, parsed.wall.seconds, parsed.wall.ms ) - parsed.offsetMs; if (fromValue !== p.unixEpoch) { v.push({ code: 'B_VALUE_NE_EPOCH', detail: `value resolves to ${fromValue} but unixEpoch=${p.unixEpoch} (delta ${fromValue - p.unixEpoch}ms) value=${p.value}`, }); } return v; } /** F. emit -> reload -> Apply preserves the epoch exactly. */ export async function roundTrip(p: Payload): Promise<{ second: Payload | null; violations: Violation[] }> { const el2 = await makePicker({ value: p.value, timezone: p.timezone }); const second = await apply(el2); const violations = checkPayload(second, undefined); if (second && second.unixEpoch !== p.unixEpoch) { violations.push({ code: 'F_EPOCH_DRIFT', detail: `first=${p.unixEpoch} second=${second.unixEpoch} delta=${(second.unixEpoch ?? 0) - (p.unixEpoch ?? 0)}ms value=${p.value}`, }); } return { second, violations }; } /* ========================================================================== Shared by more than one nile-date-time-picker.phases-*.test.ts file (moved verbatim from the Section K PART 1 / PART 2 / PART 6 helpers; only `export` was added where a helper had not been exported before) ========================================================================== */ /* ── C. wall clock + zone -> candidate instant(s) ─────────────────────────── */ /** Wall clock as a "UTC-pretending" epoch — the anchor both sides agree on. */ export function wallToUTCms(w: Wall): number { return utcFromFields(w.year, w.month - 1, w.day, w.hours, w.minutes, w.seconds, w.ms); } /** * EVERY instant the zone displays as exactly `w`. * [] -> the wall clock does not exist (spring-forward gap) * [t] -> unambiguous * [t1, t2] -> ambiguous (fall-back overlap), ascending * * Method: the true offset is within ±26h of any guess, so probing the offsets * reported at guess-26h .. guess+26h yields every candidate; each is then * CONFIRMED by reading it back. No reliance on a correction-pass converging. */ export function instantsFor(w: Wall, zone: string): number[] { const guess = wallToUTCms(w); const probes = new Set(); for (let h = -26; h <= 26; h++) probes.add(offsetAt(guess + h * 3600000, zone)); const out: number[] = []; for (const off of probes) { const cand = guess - off; if (out.indexOf(cand) !== -1) continue; if (wallEquals(wallAt(cand, zone), w)) out.push(cand); } return out.sort((a, b) => a - b); } /* ── DST transition discovery (PHASE 4) ──────────────────────────────────── */ /** Every offset change in [from, to), found by coarse scan + binary search. */ export function findTransitions(zone: string, from: number, to: number): number[] { const STEP = 6 * 3600000; const hits: number[] = []; let prev = offsetAt(from, zone); for (let t = from + STEP; t < to; t += STEP) { const cur = offsetAt(t, zone); if (cur !== prev) { let lo = t - STEP, hi = t; // 1ms precision: at 1s precision offsetAt(t-1) can already read the NEW // offset, which silently classifies a forward transition as delta=0. while (hi - lo > 1) { const mid = lo + Math.floor((hi - lo) / 2); if (offsetAt(mid, zone) === prev) lo = mid; else hi = mid; } hits.push(hi); prev = cur; } } return hits; } /** Zones the runtime itself offers — the same source the component's list uses. */ export function runtimeZones(): string[] { const anyIntl = Intl as any; return typeof anyIntl.supportedValuesOf === 'function' ? (anyIntl.supportedValuesOf('timeZone') as string[]) : []; } /** Inverse of wallToUTCms — read a "UTC-pretending" epoch back as wall digits. */ export function wallFromUTCms(ms: number): Wall { const d = new Date(ms); return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate(), hours: d.getUTCHours(), minutes: d.getUTCMinutes(), seconds: d.getUTCSeconds(), ms: d.getUTCMilliseconds(), }; } /** Classify a transition and give a wall clock strictly inside the gap/overlap. */ export function transitionShape(t: number, zone: string): { kind: 'forward' | 'backward'; deltaMs: number; insideWall: Wall; offBefore: number; offAfter: number; } { const offBefore = offsetAt(t - 1, zone); const offAfter = offsetAt(t, zone); const deltaMs = offAfter - offBefore; // wall clocks in [t+offBefore, t+offAfter) are skipped (forward) or repeated // (backward, with the interval reversed). Take the midpoint either way. const lo = Math.min(t + offBefore, t + offAfter); const hi = Math.max(t + offBefore, t + offAfter); return { kind: deltaMs > 0 ? 'forward' : 'backward', deltaMs, insideWall: wallFromUTCms(lo + Math.floor((hi - lo) / 2)), offBefore, offAfter, }; } /** Seed a wall clock directly into the panel, then Apply. */ export async function selectAndApply( el: any, w: Wall, zone: string ): Promise { const cal = await settle(el); cal.timezone = zone; await settle(el); // the day is read field-wise from `value`; noon anchor keeps the DAY stable cal.value = localDateFromFields(w.year, w.month - 1, w.day, 12, 0, 0, 0); cal.currentMonth = w.month - 1; cal.currentYear = w.year; cal.tpHours = w.hours; cal.tpMinutes = w.minutes; cal.tpSeconds = w.seconds; cal.tpMilliseconds = w.ms; await settle(el); return apply(el); } export const HOST_TZ = Intl.DateTimeFormat().resolvedOptions().timeZone; /** Collects failures so a sweep reports ALL of them, not just the first. */ export class Failures { rows: string[] = []; add(ctx: string, vs: Violation[]) { for (const x of vs) this.rows.push(`[${x.code}] ${ctx} :: ${x.detail}`); } get count() { return this.rows.length; } report(limit = 25): string { if (!this.rows.length) return ''; const head = this.rows.slice(0, limit).join('\n '); return `\n ${head}${this.rows.length > limit ? `\n ...and ${this.rows.length - limit} more` : ''}`; } } export const YEAR_FROM = Date.UTC(2026, 0, 1); export const YEAR_TO = Date.UTC(2027, 0, 1); export type Tr = { zone: string; t: number }; export function allTransitions(zones: string[]): Tr[] { const out: Tr[] = []; for (const z of zones) { for (const t of findTransitions(z, YEAR_FROM, YEAR_TO)) out.push({ zone: z, t }); } return out; }