Files
TREK/client/src/utils/formatters.test.ts
T
Maurice dbbbd07b1c fix(costs): amounts follow their currency's decimals and format, and payments count on the local day
Several small things in the costs views, on desktop and phone alike:

- The outstanding card no longer says "You're all settled up" while a
  payment is still open in Settle up.
- The custom split shows its suggested shares as a faint placeholder in
  the currency's own form (30000 for won, not 30000.00), so it no longer
  reads like an amount already typed.
- Amount fields take only the decimals the currency has: won and yen take
  none, KWD and the other three-decimal currencies three. A switch of
  currency cuts what was already typed. 1000.555 won was saved as typed,
  listed as 1,001 and reopened as 1001.
- The expense sheet on the phone prints shares and sums with the same
  formatter as the list, with grouping and the currency's decimals.
- A payment recorded with Settle or Settle all is dated on the viewer's
  own day, and one without a date of its own is grouped by its UTC record
  time read on the local calendar. In Seoul before 09:00 it landed on the
  day before.
- "1 expenses need a payer" now uses the plural forms of each language.

Refs #2604
2026-10-12 15:04:32 +02:00

300 lines
12 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { splitReservationDateTime, resolveDayId, formatMoney, formatMoneySum, formatPriceText, currencyDecimals, localizeAmountInput, amountToInputString, zeroAmountInput } from './formatters'
import { CURRENCIES, SYMBOLS, currenciesWith } from '../components/Budget/BudgetPanel.constants'
import type { Day } from '../types'
const days = [
{ id: 10, date: '2026-05-03' },
{ id: 11, date: '2026-05-04' },
{ id: 12, date: '2026-05-22' },
] as Day[]
describe('resolveDayId', () => {
it('returns the exact-match day id', () => {
expect(resolveDayId(days, '2026-05-04')).toBe(11)
})
it('accepts a full ISO timestamp', () => {
expect(resolveDayId(days, '2026-05-22T13:30:00')).toBe(12)
})
it('falls back to the nearest day when there is no exact match', () => {
expect(resolveDayId(days, '2026-05-05')).toBe(11)
})
it('returns "" for a missing/invalid date or no days', () => {
expect(resolveDayId(days, null)).toBe('')
expect(resolveDayId(days, 'not a date')).toBe('')
expect(resolveDayId([], '2026-05-04')).toBe('')
})
})
describe('localizeAmountInput (#1624)', () => {
it('shows the amount with the currency comma separator so the field matches the list', () => {
expect(localizeAmountInput('12.5', 'EUR')).toBe('12,5')
expect(localizeAmountInput('0.00', 'EUR')).toBe('0,00')
})
it('keeps the dot for dot-separator currencies', () => {
expect(localizeAmountInput('12.5', 'USD')).toBe('12.5')
})
it('passes an empty/nullish value through unchanged', () => {
expect(localizeAmountInput('', 'EUR')).toBe('')
expect(localizeAmountInput(null, 'EUR')).toBe('')
})
})
describe('zeroAmountInput (#2604)', () => {
it('shows an empty amount the way the currency writes it', () => {
expect(zeroAmountInput('EUR')).toBe('0,00')
expect(zeroAmountInput('USD')).toBe('0.00')
expect(zeroAmountInput('KRW')).toBe('0')
expect(zeroAmountInput('KWD')).toBe('0.000')
})
})
describe('amountToInputString (#2175)', () => {
it('pads a stored number to the currency decimals so the edit form matches the list', () => {
expect(amountToInputString(4.9, 'EUR')).toBe('4.90')
expect(amountToInputString(5, 'EUR')).toBe('5.00')
expect(amountToInputString(163.2, 'USD')).toBe('163.20')
})
it('gives zero-decimal currencies no fake decimals', () => {
expect(amountToInputString(500, 'JPY')).toBe('500')
expect(amountToInputString(500, 'HUF')).toBe('500')
})
it('pads three-decimal currencies to three', () => {
expect(amountToInputString(1.5, 'KWD')).toBe('1.500')
})
it('cent-rounds float noise before padding (#1964)', () => {
expect(amountToInputString(163.20999999999998, 'EUR')).toBe('163.21')
})
it('keeps the sign of a refund (#2176)', () => {
expect(amountToInputString(-4.9, 'EUR')).toBe('-4.90')
})
it('turns nullish and non-finite values into an empty field', () => {
expect(amountToInputString(null, 'EUR')).toBe('')
expect(amountToInputString(undefined, 'EUR')).toBe('')
expect(amountToInputString(Number.NaN, 'EUR')).toBe('')
})
})
describe('KGS currency (#1400)', () => {
it('is selectable everywhere the shared currency list feeds', () => {
expect(CURRENCIES).toContain('KGS')
expect(SYMBOLS.KGS).toBeTruthy()
})
it('formats without throwing', () => {
// Lenient: older ICU builds may lack ru-KG/KGS display data and fall back.
const out = formatMoney(1234.56, 'KGS', 'en')
expect(out).toMatch(/сом|KGS/)
expect(out).toContain('234')
})
})
describe('Frankfurter-supported currency list (#1470)', () => {
it('offers the currencies that were previously missing', () => {
for (const c of ['OMR', 'CRC', 'UGX', 'MKD', 'ALL']) {
expect(CURRENCIES).toContain(c)
}
})
it('drops currencies Frankfurter has archived', () => {
expect(CURRENCIES).not.toContain('BGN')
expect(CURRENCIES).not.toContain('HRK')
})
it('has a symbol for every selectable currency', () => {
for (const c of CURRENCIES) expect(SYMBOLS[c]).toBeTruthy()
})
})
describe('currencyDecimals', () => {
it('uses 2 decimals for standard currencies', () => {
expect(currencyDecimals('USD')).toBe(2)
expect(currencyDecimals('EUR')).toBe(2)
})
it('uses 0 decimals for zero-decimal currencies', () => {
for (const c of ['JPY', 'HUF', 'UGX', 'XOF', 'RWF', 'VUV']) {
expect(currencyDecimals(c)).toBe(0)
}
})
it('uses 3 decimals for the Gulf/dinar currencies', () => {
for (const c of ['OMR', 'KWD', 'BHD', 'TND']) {
expect(currencyDecimals(c)).toBe(3)
}
})
})
describe('currenciesWith (legacy safeguard)', () => {
it('returns the base list unchanged for a supported currency', () => {
expect(currenciesWith('EUR')).toBe(CURRENCIES)
expect(currenciesWith(null)).toBe(CURRENCIES)
})
it('keeps an archived/legacy selection selectable', () => {
const opts = currenciesWith('HRK')
expect(opts).toContain('HRK')
expect(opts.length).toBe(CURRENCIES.length + 1)
})
})
describe('formatMoneySum (#1561)', () => {
// Intl money strings use non-breaking / narrow no-break spaces; normalize for assertions.
const norm = (s: string | null) => s?.replace(/[\u00A0\u202F]/g, ' ') ?? null
it('returns null for empty input and for zero/negative/non-finite amounts', () => {
expect(formatMoneySum([], 'EUR', 'en')).toBeNull()
expect(formatMoneySum([{ amount: 0, currency: 'EUR' }], 'EUR', 'en')).toBeNull()
expect(formatMoneySum([{ amount: -5, currency: 'EUR' }], 'EUR', 'en')).toBeNull()
expect(formatMoneySum([{ amount: NaN, currency: 'EUR' }], 'EUR', 'en')).toBeNull()
})
it('sums a single-currency set without rates and without an ≈ marker', () => {
const out = formatMoneySum(
[{ amount: 20, currency: 'USD' }, { amount: 30, currency: 'usd' }],
'USD', 'en',
)
expect(out).toBe('$50.00')
})
it('converts foreign amounts into the base when rates cover them', () => {
// rates[X] = units of X per 1 base (frankfurter): 10 NOK = 1 USD here.
const out = formatMoneySum(
[{ amount: 25, currency: 'USD' }, { amount: 250, currency: 'NOK' }],
'USD', 'en', { NOK: 10 },
)
expect(out).toBe('≈ $50.00')
})
it('falls back to a per-currency breakdown when rates are missing', () => {
const entries = [
{ amount: 2500, currency: 'NOK' },
{ amount: 2730.27, currency: 'USD' },
]
for (const rates of [undefined, null, {}, { USD: 0 }]) {
const out = norm(formatMoneySum(entries, 'NOK', 'en', rates))
expect(out).toContain(' + ')
expect(out).toContain('$2,730.27')
expect(out).not.toContain('≈')
// the foreign amount must never be folded into a base-labeled number
expect(out).not.toMatch(/5 ?230/)
}
})
it('orders the breakdown base-first, then foreign currencies by code', () => {
const out = norm(formatMoneySum(
[
{ amount: 1, currency: 'USD' },
{ amount: 2, currency: 'EUR' },
{ amount: 3, currency: 'NOK' },
],
'NOK', 'en',
))!
expect(out.indexOf('kr')).toBeLessThan(out.indexOf('€'))
expect(out.indexOf('€')).toBeLessThan(out.indexOf('$'))
})
it('renders a lone foreign amount honestly in its own currency', () => {
expect(formatMoneySum([{ amount: 2730.27, currency: 'USD' }], 'NOK', 'en')).toBe('$2,730.27')
})
it('applies opts.decimals to every part', () => {
expect(formatMoneySum([{ amount: 50.4, currency: 'USD' }], 'USD', 'en', null, { decimals: 0 })).toBe('$50')
const breakdown = norm(formatMoneySum(
[{ amount: 50.4, currency: 'USD' }, { amount: 10.2, currency: 'NOK' }],
'USD', 'en', null, { decimals: 0 },
))
expect(breakdown).toContain('$50')
expect(breakdown).toMatch(/10\s?kr|kr\s?10/)
})
it('tolerates currency codes unknown to Intl', () => {
const out = formatMoneySum([{ amount: 12, currency: 'XYZ' }], 'XYZ', 'en')
expect(out).toContain('XYZ')
expect(out).toContain('12')
})
})
describe('formatPriceText (#2084)', () => {
it('formats a numeric price as money in its own currency', () => {
expect(formatPriceText(320, 'EUR', 'USD', 'en')).toBe(formatMoney(320, 'EUR', 'en'))
expect(formatPriceText('12.5', 'chf', 'EUR', 'en')).toBe(formatMoney(12.5, 'CHF', 'en'))
})
it("takes the trip's currency when the price names none", () => {
expect(formatPriceText('45', null, 'jpy', 'en')).toBe(formatMoney(45, 'JPY', 'en'))
expect(formatPriceText(45, '', 'CHF', 'en')).toBe(formatMoney(45, 'CHF', 'en'))
})
it('shows nothing for a missing or empty price', () => {
expect(formatPriceText(null, 'EUR', 'EUR', 'en')).toBe('')
expect(formatPriceText(undefined, 'EUR', 'EUR', 'en')).toBe('')
expect(formatPriceText('', 'EUR', 'EUR', 'en')).toBe('')
})
it('passes a price that is not a number through as written, with its own currency', () => {
expect(formatPriceText('on request', 'EUR', 'USD', 'en')).toBe('on request EUR')
expect(formatPriceText('on request', 'eur', 'USD', 'en')).toBe('on request EUR')
expect(formatPriceText('included', null, 'USD', 'en')).toBe('included')
})
it('keeps a bare number bare when neither the price nor the trip names a currency', () => {
expect(formatPriceText('99', null, null, 'en')).toBe('99')
expect(formatPriceText(12.5, undefined, undefined, 'en')).toBe('12.5')
})
})
describe('splitReservationDateTime', () => {
it('parses full ISO datetime', () => {
expect(splitReservationDateTime('2026-06-25T10:00')).toEqual({ date: '2026-06-25', time: '10:00' })
})
it('parses full datetime with seconds', () => {
expect(splitReservationDateTime('2026-06-25T10:00:30')).toEqual({ date: '2026-06-25', time: '10:00' })
})
it('parses date-only string', () => {
expect(splitReservationDateTime('2026-06-25')).toEqual({ date: '2026-06-25', time: null })
})
it('parses bare HH:MM (new dateless format)', () => {
expect(splitReservationDateTime('10:00')).toEqual({ date: null, time: '10:00' })
})
it('parses bare single-digit hour time', () => {
expect(splitReservationDateTime('9:30')).toEqual({ date: null, time: '9:30' })
})
it('handles legacy malformed T-prefixed time ("T10:00")', () => {
expect(splitReservationDateTime('T10:00')).toEqual({ date: null, time: '10:00' })
})
it('returns null date for T-prefixed without valid date', () => {
const result = splitReservationDateTime('T23:59')
expect(result.date).toBeNull()
expect(result.time).toBe('23:59')
})
it('returns nulls for null input', () => {
expect(splitReservationDateTime(null)).toEqual({ date: null, time: null })
})
it('returns nulls for undefined input', () => {
expect(splitReservationDateTime(undefined)).toEqual({ date: null, time: null })
})
it('returns nulls for empty string', () => {
expect(splitReservationDateTime('')).toEqual({ date: null, time: null })
})
it('returns nulls for unrecognized string', () => {
expect(splitReservationDateTime('garbage')).toEqual({ date: null, time: null })
})
// #1725 — slicing the time part to five characters used to swallow the meridiem,
// so an afternoon booking came back as a morning one.
it('converts a meridiem time part instead of cutting it off', () => {
expect(splitReservationDateTime('2026-08-01T3:00 PM')).toEqual({ date: '2026-08-01', time: '15:00' })
expect(splitReservationDateTime('2026-08-01T12:30 am')).toEqual({ date: '2026-08-01', time: '00:30' })
})
it('converts a bare meridiem time', () => {
expect(splitReservationDateTime('3:00 PM')).toEqual({ date: null, time: '15:00' })
expect(splitReservationDateTime('3 PM')).toEqual({ date: null, time: '15:00' })
})
})