morphit/apps/web/scripts/i18n-formatters-smoke.ts

365 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env tsx
/**
* Smoke for the centralized i18n formatters.
*
* Validates that `formatFiat`, `formatPercent`, `formatBlurt`,
* `formatCount`, and the canonical date/time formatters
* (`formatDayMonth`, `formatDayMonthTime`, `formatDayMonthShort`,
* `formatMonthYear`) produce locale-aware output and don't
* regress between locales.
*
* The exact glyphs the runtime ICU library produces vary
* per Node version (CLDR updates, narrow-NBSP changes, etc.),
* so this smoke tests properties rather than exact strings:
*
* - formatFiat(1234.5, "USD") contains the digits and a
* USD-region indicator ("$" or "USD" or both).
* - formatFiat(1234.5, "EUR") swaps to EUR display.
* - formatFiat(1234, "JPY") uses 0 decimals.
* - formatFiat(0.0000023, "XAU") uses 8 decimals for precious metals.
* - formatFiat(N, "ZZZ") falls back to "{number} ZZZ" when the
* ticker isn't a known ISO 4217 code.
* - formatPercent(1.5) contains the digit pair and a "%".
* - formatBlurt(60) produces 3 fractional digits.
* - formatCount(1234) contains the digits but possibly with
* a separator.
* - the canonical date formatters return non-empty strings
* that include the year as 4 digits in some script, day-first
* with a full month name; formatDayMonthTime appends 24-hour
* UTC time WITH seconds and an explicit "UTC" suffix.
*
* The smoke also checks for graceful failure on NaN /
* undefined / out-of-range inputs.
*
* cp128: `formatUsd` was removed; all call sites migrated to
* `formatFiat(amount, ticker)` with ticker coming from the
* indexer's denomination_fiat config field. This smoke was
* updated accordingly.
*/
import {
formatFiat,
formatPercent,
formatBlurt,
formatCount,
formatDayMonth,
formatDayMonthTime,
formatDayMonthShort,
formatMonthYear,
formatCountCompact
} from '../src/lib/i18n/formatters';
interface Scenario {
readonly name: string;
readonly fn: () => boolean;
}
const scenarios: readonly Scenario[] = [
// ─── formatFiat (cp128 — was formatUsd before the rename) ──
{
name: 'formatFiat(1234.5, "USD") — contains 1234.50',
fn: () => {
const out = formatFiat(1234.5, 'USD');
// Some locales use NBSP or NNBSP between value and currency
const digitsOnly = out.replace(/\D/g, '');
return digitsOnly.includes('123450');
}
},
{
name: 'formatFiat(1234.5) — defaults to USD when ticker omitted',
fn: () => {
const out = formatFiat(1234.5);
const digitsOnly = out.replace(/\D/g, '');
return digitsOnly.includes('123450');
}
},
{
name: 'formatFiat(0, "USD") — returns formatted zero',
fn: () => {
const out = formatFiat(0, 'USD');
return out.length > 0 && /[0]/.test(out);
}
},
{
name: 'formatFiat(NaN, "USD") — returns "—"',
fn: () => formatFiat(Number.NaN, 'USD') === '—'
},
{
name: 'formatFiat(Infinity, "USD") — returns "—"',
fn: () => formatFiat(Number.POSITIVE_INFINITY, 'USD') === '—'
},
{
name: 'formatFiat(1234.5, "EUR") — EUR-formatted',
fn: () => {
const out = formatFiat(1234.5, 'EUR');
const digitsOnly = out.replace(/\D/g, '');
return digitsOnly.includes('123450');
}
},
{
name: 'formatFiat(1234, "JPY") — 0 decimals',
fn: () => {
// JPY has no sub-yen. The formatted output should NOT
// contain a "00" decimal suffix.
const out = formatFiat(1234, 'JPY');
const digitsOnly = out.replace(/\D/g, '');
// We want "1234" not "123400". Could be "1,234" -> "1234".
return digitsOnly === '1234';
}
},
{
name: 'formatFiat(0.0000023, "XAU") — gold ounces, 8 decimals',
fn: () => {
const out = formatFiat(0.0000023, 'XAU');
// Result should contain the digits 23 somewhere (perhaps
// "0.00000230 XAU" or with a recognized symbol). We don't
// over-prescribe the exact format since Intl behavior
// varies on XAU support.
return /23/.test(out);
}
},
{
name: 'formatFiat(1234.5, "ZZZ") — unknown ticker falls back to "{number} ZZZ"',
fn: () => {
const out = formatFiat(1234.5, 'ZZZ');
// Unknown ticker → not ISO 4217 → fallback to
// "{decimal-formatted number} ZZZ".
return out.includes('ZZZ');
}
},
{
name: 'formatFiat — lowercase ticker is normalized',
fn: () => {
const out = formatFiat(100, 'usd');
const digitsOnly = out.replace(/\D/g, '');
return digitsOnly.includes('10000');
}
},
// ─── formatPercent ─────────────────────────────────
{
name: 'formatPercent(1.5) — contains 1, 5, %',
fn: () => {
const out = formatPercent(1.5);
const hasDigits = /[0-9]/.test(out);
const hasPercent = out.includes('%');
return hasDigits && hasPercent;
}
},
{
name: 'formatPercent(7.6, 1) — APR-style',
fn: () => {
const out = formatPercent(7.6, 1);
return out.includes('%') && /[0-9]/.test(out);
}
},
{
name: 'formatPercent(NaN) — returns "—"',
fn: () => formatPercent(Number.NaN) === '—'
},
// ─── formatBlurt ───────────────────────────────────
{
name: 'formatBlurt(60) — 3 decimals',
fn: () => {
const out = formatBlurt(60);
// "60.000" or "60,000" depending on locale; either way 3
// digits after the decimal separator.
const decimalGroup = out.match(/[\\.,](\d+)$/);
return decimalGroup !== null && decimalGroup[1]!.length === 3;
}
},
{
name: 'formatBlurt(0.001) — preserves precision',
fn: () => {
const out = formatBlurt(0.001);
return out.includes('001');
}
},
{
name: 'formatBlurt(60, 0) — zero decimals',
fn: () => {
const out = formatBlurt(60, 0);
return out === '60' || out === '60' || /^[0-9۰-۹]+$/.test(out);
}
},
// ─── formatCount ───────────────────────────────────
{
name: 'formatCount(1234) — has digits, no decimal',
fn: () => {
const out = formatCount(1234);
// Check the digits are all there. A count should never
// have fractional digits. The simplest test: the digits
// in the output (after stripping all non-digit chars)
// should equal the input as a string.
const digitsOnly = out.replace(/[^\d]/g, '');
return digitsOnly === '1234';
}
},
// ─── Canonical UI date/time (Ken's sitewide standard) ──
// TIME is 24-hour UTC with an explicit "UTC" suffix and seconds,
// built from getUTC* so it is timezone-independent (deterministic
// regardless of the sandbox's TZ): "…@ 16:45:18 UTC".
{
name: 'formatDayMonthTime — 24h UTC time with explicit UTC suffix',
fn: () => {
const out = formatDayMonthTime('2026-06-30T16:45:18Z');
return out.includes('16:45:18 UTC') && out.includes('2026');
}
},
{
name: 'formatDayMonthTime — never 12-hour (no AM/PM)',
fn: () => {
const out = formatDayMonthTime('2026-06-30T16:45:18Z');
return !/\b[AP]M\b/i.test(out) && / @ \d{2}:\d{2}:\d{2} UTC$/.test(out);
}
},
{
name: 'formatDayMonthTime — date part is UTC-coherent (no midnight rollover mismatch)',
fn: () => {
// 23:30 UTC on the 30th must render the 30th (UTC), not the
// 31st/1st that a local frame could show. In 'en': "30 June".
const out = formatDayMonthTime('2026-06-30T23:30:00Z');
return out.includes('23:30:00 UTC') && /\b30\b/.test(out) && out.includes('June');
}
},
{
name: 'formatDayMonthTime(invalid) — returns "—"',
fn: () => formatDayMonthTime('not-a-date') === '—' && formatDayMonthTime(null) === '—'
},
{
name: 'formatDayMonth — date only, no time, no UTC suffix, UTC-coherent day',
fn: () => {
// UTC calendar date (no time-of-day, no "UTC" literal). 23:30 UTC
// on the 30th must render the 30th (UTC), not a local rollover.
const out = formatDayMonth('2026-06-30T23:30:00Z');
return (
out.includes('2026') &&
!out.includes('UTC') &&
!out.includes('@') &&
/\b30\b/.test(out) &&
out.includes('June')
);
}
},
{
name: 'formatMonthYear — month name + year, no day',
fn: () => {
const out = formatMonthYear('2026-07-15T00:00:00Z');
return out.includes('July') && out.includes('2026') && !/\b15\b/.test(out);
}
},
{
name: 'formatMonthYear(invalid) — returns "—"',
fn: () => formatMonthYear('') === '—'
},
{
// cp420 — the compact mobile order-card date. "26 Jun": day + a
// 3-char month, no year, no time.
name: 'formatDayMonthShort — day + 3-char month, no year',
fn: () => {
const out = formatDayMonthShort('2026-06-26T12:00:00Z');
return out.includes('26') && out.includes('Jun') && !out.includes('2026');
}
},
{
// The month must be clipped to exactly 3 chars so a 16-char username
// still fits one line ("June" → "Jun", never the full month name).
name: 'formatDayMonthShort — month clipped to 3 chars (no full "June")',
fn: () => !formatDayMonthShort('2026-06-26T12:00:00Z').includes('June')
},
{
// UTC-coherent: a 23:30-UTC instant on the last of the month must not
// roll the displayed day into the next month for eastern viewers.
name: 'formatDayMonthShort — UTC-coherent day (no midnight rollover)',
fn: () => {
const out = formatDayMonthShort('2026-06-30T23:30:00Z');
return /\b30\b/.test(out) && out.includes('Jun');
}
},
{
// Returns '' (not "—") for absent/invalid input so callers can drop
// the "before <date>" suffix entirely.
name: 'formatDayMonthShort(invalid/absent) — returns empty string',
fn: () =>
formatDayMonthShort('not-a-date') === '' &&
formatDayMonthShort(null) === '' &&
formatDayMonthShort('') === ''
},
{
name: 'formatCountCompact — compacts thousands (en "1.2K")',
fn: () => {
const out = formatCountCompact(1234);
// en compact → "1.2K"; other locales differ, so assert it's
// SHORTER than the grouped form and contains the leading digit.
return out.length <= 5 && /^[0-9۰-۹]/.test(out) && out !== '1,234';
}
},
{
name: 'formatCountCompact — small values as-is, non-finite → "0"',
fn: () => {
const small = formatCountCompact(42);
return /4.?2/.test(small) && formatCountCompact(Number.NaN) === '0';
}
},
// ─── Locale-switching ──────────────────────────────
// Don't actually switch the active locale here (would
// require async loading of the locale dictionary). The
// smoke runs in default 'en'. Cross-locale validation
// is deferred to the i18n-locale-parity smoke.
{
name: 'formatPercent — sample call before locale set',
fn: () => {
// Call before the locale-switcher runs: should fall back to
// DEFAULT_LOCALE without throwing.
const out = formatPercent(50);
return out.includes('%');
}
}
];
let passed = 0;
let failed = 0;
const failures: string[] = [];
console.log('');
console.log('── i18n formatters smoke ────────────────────────────────');
console.log('');
// Run synchronously: formatters fall back to DEFAULT_LOCALE
// via the activeLocale() try/catch when the svelte-i18n
// store isn't initialized. That fallback path is itself
// part of what we're testing — formatters must not crash
// in SSR or smoke contexts.
for (const s of scenarios) {
try {
if (s.fn()) {
passed++;
} else {
failed++;
failures.push(`${s.name}`);
}
} catch (err) {
failed++;
failures.push(`${s.name} — threw: ${err instanceof Error ? err.message : String(err)}`);
}
}
if (failed === 0) {
console.log(` ✓ all ${passed} scenarios passed`);
console.log('');
console.log('────────────────────────────────────────────────────────');
console.log(`✓ all ${passed} scenarios passed`);
process.exit(0);
} else {
console.log(` ${passed} passed, ${failed} failed`);
console.log('');
console.log(failures.join('\n'));
console.log('');
console.log('────────────────────────────────────────────────────────');
console.log(`${failed} of ${passed + failed} scenarios failed`);
process.exit(1);
}