mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 05:56:53 +00:00
8864edda18
Discussion #2084: a cost bought later (the seat upgrade for a flight) could not be tied to the booking it belongs to, and a booking held one expense. - The Costs block of the booking, transport and place forms lists every linked expense with edit, unlink (the expense stays in Costs) and delete, and offers the expenses that belong nowhere yet under Link existing expense. The phone sheets get the same block in their own design, and their Files row lists the attached files and links an existing one. - PUT /budget/:id, update_budget_item (MCP) and costs.update (plugin RPC) take reservation_id / place_id; create and update refuse an id from another trip on all three paths. - The booking's mirrored price is the sum of its expenses when they share one currency (none counts as the trip's), else the first one, with that currency, and follows every change to a linked expense. The booking form's price field leaves a booking with several expenses alone. - Deleting a booking deletes all of its linked expenses and broadcasts each one, also from the MCP delete tools. - Cards show the price with its currency. - Booking and transport dialogs: Booking Type and Travelers as dropdowns at the top, the URL beside Files (transports gain the URL field). The row is one shared component; linking and unlinking files go through the store. Help center pictures and texts for the booking, transport and expense guides are redone in all 27 languages, five Costs labels that were still English in 16 languages are translated, and the wiki follows.
291 lines
11 KiB
TypeScript
291 lines
11 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import { splitReservationDateTime, resolveDayId, formatMoney, formatMoneySum, formatPriceText, currencyDecimals, localizeAmountInput, amountToInputString } 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('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' })
|
|
})
|
|
})
|