|
| 1 | +/** |
| 2 | + * ObjectUI |
| 3 | + * Copyright (c) 2024-present ObjectStack Inc. |
| 4 | + * |
| 5 | + * This source code is licensed under the MIT license found in the |
| 6 | + * LICENSE file in the root directory of this source tree. |
| 7 | + */ |
| 8 | + |
| 9 | +/** |
| 10 | + * objectui#4332 — `formatCurrency` dropped a real cents digit. |
| 11 | + * |
| 12 | + * The symbol branch passed `minimumFractionDigits: 0` against a |
| 13 | + * wholeness-switched `maximumFractionDigits`, handing Intl the RANGE `[0, 2]`. |
| 14 | + * Intl then emits the shortest representation in range, so a genuine cents |
| 15 | + * value of `.50` printed as `.5`: `$1,234.5`, `$19.9`, `$0.5` — money on a |
| 16 | + * record page reading as a data error rather than a formatting one. |
| 17 | + * |
| 18 | + * The fix is one width for both bounds (`isWhole ? 0 : 2`), which also makes |
| 19 | + * the function's three branches agree: the no-currency branch already routed |
| 20 | + * through `formatNumber` (both bounds = the width), and the bad-currency |
| 21 | + * fallback already used `toFixed`. Only the symbol branch disagreed. |
| 22 | + * |
| 23 | + * What is pinned here, and why each half matters: |
| 24 | + * |
| 25 | + * 1. **Fractional amounts keep both cents digits** — the defect. |
| 26 | + * 2. **Whole amounts still drop `.00`** — the OTHER half of the wholeness |
| 27 | + * switch, which the doc comment promises just as explicitly ("Salesforce |
| 28 | + * convention: `$1,234.50` keeps cents; `$1,234` does not") and which |
| 29 | + * objectui#4033 independently pinned through the renderer. Widening the |
| 30 | + * minimum to a constant 2 would have "fixed" this card by breaking that |
| 31 | + * one, so it is a control, not a decoration. |
| 32 | + * 3. **The branches that were already correct are unchanged** — no-currency, |
| 33 | + * bad-currency fallback, and `formatCompactCurrency` (a different function |
| 34 | + * with its own `maximumFractionDigits: 1` compact policy). |
| 35 | + */ |
| 36 | + |
| 37 | +import { describe, it, expect } from 'vitest'; |
| 38 | +import React from 'react'; |
| 39 | +import { render, screen } from '@testing-library/react'; |
| 40 | +import '@testing-library/jest-dom'; |
| 41 | +import { LocalizationProvider } from '@object-ui/i18n'; |
| 42 | +import { |
| 43 | + formatCurrency, |
| 44 | + formatCompactCurrency, |
| 45 | + formatNumber, |
| 46 | + CurrencyCellRenderer, |
| 47 | +} from '../index'; |
| 48 | + |
| 49 | +describe('formatCurrency — a fractional amount keeps both cents digits (objectui#4332)', () => { |
| 50 | + it("renders the card's fixture as the doc comment promises", () => { |
| 51 | + expect(formatCurrency(1234.5, 'USD', 'en-US')).toBe('$1,234.50'); |
| 52 | + }); |
| 53 | + |
| 54 | + it.each([ |
| 55 | + [19.9, '$19.90'], |
| 56 | + [0.5, '$0.50'], |
| 57 | + [1234.5, '$1,234.50'], |
| 58 | + ])('an amount ending in a zero cent digit keeps it: %s', (value, expected) => { |
| 59 | + // The issue's impact list — every price whose cents end in 0 was truncated |
| 60 | + // by one digit (`$19.9`, `$0.5`). |
| 61 | + expect(formatCurrency(value as number, 'USD', 'en-US')).toBe(expected as string); |
| 62 | + }); |
| 63 | + |
| 64 | + it('keeps the sign in front of a truncated-then-restored amount', () => { |
| 65 | + expect(formatCurrency(-1234.5, 'USD', 'en-US')).toBe('-$1,234.50'); |
| 66 | + }); |
| 67 | + |
| 68 | + it('a fractional amount that ROUNDS to zero cents still shows them', () => { |
| 69 | + // Sub-cent precision: `1234.001` is not whole, so it keeps cents and |
| 70 | + // rounds into them. Before the fix the `[0, 2]` range rounded to `.00` and |
| 71 | + // then trimmed it away, printing a bare `$1,234` for a value that is NOT |
| 72 | + // the whole number that spelling claims. Deliberate: wholeness — not the |
| 73 | + // rounded output — decides whether cents are shown. |
| 74 | + expect(formatCurrency(1234.001, 'USD', 'en-US')).toBe('$1,234.00'); |
| 75 | + }); |
| 76 | + |
| 77 | + it('still caps at two digits (the maximum is unchanged)', () => { |
| 78 | + expect(formatCurrency(1234.567, 'USD', 'en-US')).toBe('$1,234.57'); |
| 79 | + expect(formatCurrency(1234.56, 'USD', 'en-US')).toBe('$1,234.56'); |
| 80 | + }); |
| 81 | + |
| 82 | + it('follows the active locale while doing it (objectui#4033 is not disturbed)', () => { |
| 83 | + // de-DE: dot grouping, comma decimal mark, TRAILING symbol separated by |
| 84 | + // U+00A0. Matched loosely on the separator (written as an escape, never a |
| 85 | + // raw byte) so an ICU spacing change cannot fail the digits this pins. |
| 86 | + expect(formatCurrency(1234.5, 'EUR', 'de-DE')).toMatch(/^1\.234,50\s*€$/); |
| 87 | + }); |
| 88 | +}); |
| 89 | + |
| 90 | +describe('formatCurrency — CONTROL: the whole-number half of the switch is untouched', () => { |
| 91 | + it.each([ |
| 92 | + [1234, '$1,234'], |
| 93 | + [5000000, '$5,000,000'], |
| 94 | + [0, '$0'], |
| 95 | + ])('a whole amount still drops `.00`: %s', (value, expected) => { |
| 96 | + // The doc comment promises this as explicitly as it promises the cents, |
| 97 | + // and objectui#4033 pinned it through `CurrencyCellRenderer`. A constant |
| 98 | + // `minimumFractionDigits: 2` would turn all three of these into `.00`. |
| 99 | + expect(formatCurrency(value as number, 'USD', 'en-US')).toBe(expected as string); |
| 100 | + }); |
| 101 | + |
| 102 | + it('a non-finite amount is unaffected (it is not "whole")', () => { |
| 103 | + expect(formatCurrency(Number.NaN, 'USD', 'en-US')).toBe('$NaN'); |
| 104 | + }); |
| 105 | +}); |
| 106 | + |
| 107 | +describe('formatCurrency — CONTROL: the branches that were already correct', () => { |
| 108 | + it('the no-currency branch is unchanged — it never had the defect', () => { |
| 109 | + // It routes through `formatNumber`, which sets BOTH bounds to the width it |
| 110 | + // is given, so it already rendered `1,234.50` while the symbol branch |
| 111 | + // rendered `$1,234.5`. That disagreement is what the fix removes. |
| 112 | + expect(formatCurrency(1234.5, undefined, 'en-US')).toBe('1,234.50'); |
| 113 | + expect(formatCurrency(1234.5, undefined, 'en-US')).toBe(formatNumber(1234.5, 2, 'en-US')); |
| 114 | + // A whole amount with no currency is still money and keeps its separators |
| 115 | + // (objectui#4033: `formatNumber` passes no `scale`, so the ordinal |
| 116 | + // no-grouping policy cannot fire here). |
| 117 | + expect(formatCurrency(5000000, undefined, 'en-US')).toBe('5,000,000'); |
| 118 | + }); |
| 119 | + |
| 120 | + it('the bad-currency fallback is unchanged — it always used toFixed', () => { |
| 121 | + // `Intl` throws `RangeError: Invalid currency code` for a non-ISO-4217 |
| 122 | + // code, and `formatDisplayNumber`'s locale retry re-throws it rather than |
| 123 | + // swallowing it, so this lands in `formatCurrency`'s own catch. |
| 124 | + expect(formatCurrency(1234.5, 'NOT_A_CODE', 'en-US')).toBe('NOT_A_CODE 1234.50'); |
| 125 | + }); |
| 126 | + |
| 127 | + it('formatCompactCurrency is a different function and keeps its own policy', () => { |
| 128 | + // Compact notation caps at ONE fraction digit and strips a trailing `.0`; |
| 129 | + // it never had the `[0, max]` range, and this card does not touch it. |
| 130 | + expect(formatCompactCurrency(1234.5, 'USD', 'en-US')).toBe('$1.2K'); |
| 131 | + expect(formatCompactCurrency(150000, 'USD', 'en-US')).toBe('$150K'); |
| 132 | + }); |
| 133 | +}); |
| 134 | + |
| 135 | +describe('CurrencyCellRenderer — the user-visible surface (objectui#4332)', () => { |
| 136 | + const renderCurrency = (value: unknown, field: Record<string, unknown>, locale?: string) => |
| 137 | + render( |
| 138 | + <LocalizationProvider value={{ locale }}> |
| 139 | + <CurrencyCellRenderer value={value as any} field={{ type: 'currency', ...field } as any} /> |
| 140 | + </LocalizationProvider>, |
| 141 | + ); |
| 142 | + |
| 143 | + it('a grid cell shows the cents digit the amount actually has', () => { |
| 144 | + renderCurrency(1234.5, { currency: 'USD' }, 'en-US'); |
| 145 | + expect(screen.getByText('$1,234.50')).toBeInTheDocument(); |
| 146 | + }); |
| 147 | + |
| 148 | + it('CONTROL: a declared `scale` is inert on the currency cell path', () => { |
| 149 | + // Measured, not assumed: `CurrencyCellRenderer` reads `currency` and never |
| 150 | + // `scale`, and `formatCurrency` takes no scale argument — on this path |
| 151 | + // `scale` is neither a display width nor (money always groups) a grouping |
| 152 | + // input. So a currency field declaring `scale: 0` renders exactly like one |
| 153 | + // declaring nothing, before and after this card. Pinned so that a future |
| 154 | + // change making `scale` load-bearing here has to say so out loud. |
| 155 | + renderCurrency(1234.5, { currency: 'USD', scale: 0 }, 'en-US'); |
| 156 | + expect(screen.getByText('$1,234.50')).toBeInTheDocument(); |
| 157 | + }); |
| 158 | +}); |
0 commit comments