365 lines
12 KiB
TypeScript
365 lines
12 KiB
TypeScript
#!/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);
|
||
}
|