mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
dbbbd07b1c
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
750 lines
34 KiB
TypeScript
750 lines
34 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type { ExpensePrefill } from '../../../../src/components/Budget/CostsPanel'
|
|
import MCostSheet from '../../../../src/mobile/screens/trip/sheets/MCostSheet'
|
|
import type { TripMember } from '../../../../src/components/Budget/BudgetPanelMemberChips'
|
|
import { clearExchangeRateCache } from '../../../../src/hooks/useExchangeRates'
|
|
import { useTripStore, type TripStoreState } from '../../../../src/store/tripStore'
|
|
import type { BudgetItem, BudgetItemMember } from '../../../../src/types'
|
|
import { buildBudgetItem, buildTrip } from '../../../helpers/factories'
|
|
import { localToday } from '../../../../src/components/Planner/today'
|
|
import { resetAllStores } from '../../../helpers/store'
|
|
import { act, fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
|
|
|
|
// FE-MOB-COSTSH-001 to FE-MOB-COSTSH-038
|
|
// The sheet reads its copy from useTranslation(), so assertions are English.
|
|
// EUR formats de-DE style: displayed amounts and inputs use a comma.
|
|
|
|
const PEOPLE = [
|
|
{ id: 1, username: 'alice', avatar_url: null },
|
|
{ id: 2, username: 'bob', avatar_url: null },
|
|
] as TripMember[]
|
|
|
|
// The traveller's own calendar day, not the UTC one — see components/Planner/today.ts.
|
|
const TODAY = localToday()
|
|
|
|
function member(user_id: number, amount: number | null): BudgetItemMember {
|
|
return { user_id, paid: 0, username: user_id === 1 ? 'alice' : 'bob', amount }
|
|
}
|
|
|
|
let addBudgetItem: ReturnType<typeof vi.fn>
|
|
let updateBudgetItem: ReturnType<typeof vi.fn>
|
|
let deleteBudgetItem: ReturnType<typeof vi.fn>
|
|
let addToast: ReturnType<typeof vi.fn>
|
|
|
|
interface SheetOverrides {
|
|
people?: TripMember[]
|
|
me?: number
|
|
base?: string
|
|
editing?: BudgetItem | null
|
|
prefill?: ExpensePrefill
|
|
}
|
|
|
|
function renderSheet(overrides: SheetOverrides = {}) {
|
|
const onClose = vi.fn()
|
|
const onSaved = vi.fn()
|
|
const view = render(
|
|
<MCostSheet
|
|
tripId={1}
|
|
base={overrides.base ?? 'EUR'}
|
|
people={overrides.people ?? PEOPLE}
|
|
me={overrides.me ?? 1}
|
|
editing={overrides.editing ?? null}
|
|
prefill={overrides.prefill}
|
|
onClose={onClose}
|
|
onSaved={onSaved}
|
|
/>,
|
|
)
|
|
return { ...view, onClose, onSaved }
|
|
}
|
|
|
|
const nameField = () => screen.getByPlaceholderText('e.g. Dinner, souvenirs, gas…')
|
|
const totalField = () => screen.getAllByPlaceholderText('0,00')[0]
|
|
const submit = () => screen.getByRole('button', { name: 'Add' })
|
|
const saveBtn = () => screen.getByRole('button', { name: 'Save' })
|
|
|
|
function fillBasics(name: string, total: string) {
|
|
fireEvent.change(nameField(), { target: { value: name } })
|
|
fireEvent.change(totalField(), { target: { value: total } })
|
|
}
|
|
|
|
const CAT_LABELS = ['Accommodation', 'Food & drink', 'Groceries', 'Transport', 'Flights', 'Activities', 'Sightseeing', 'Shopping', 'Fees & tickets', 'Health', 'Tips', 'Other']
|
|
|
|
describe('MCostSheet', () => {
|
|
beforeEach(() => {
|
|
resetAllStores()
|
|
clearExchangeRateCache()
|
|
// Seeded FX cache, fresh enough that the hook never reaches the network.
|
|
localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1, USD: 1.25 }, ts: Date.now() }))
|
|
addBudgetItem = vi.fn(async () => buildBudgetItem({ id: 9 }))
|
|
updateBudgetItem = vi.fn(async () => buildBudgetItem({ id: 9 }))
|
|
deleteBudgetItem = vi.fn(async () => undefined)
|
|
useTripStore.setState(
|
|
{ addBudgetItem, updateBudgetItem, deleteBudgetItem } as unknown as Partial<TripStoreState>,
|
|
)
|
|
addToast = vi.fn()
|
|
;(window as unknown as { __addToast: unknown }).__addToast = addToast
|
|
})
|
|
|
|
afterEach(() => {
|
|
delete (window as unknown as { __addToast?: unknown }).__addToast
|
|
})
|
|
|
|
// The category moved from a row of eleven pills to a dropdown: the trigger
|
|
// shows the current one, and the options only exist while it is open.
|
|
const catTrigger = () => screen.getByRole('button', { name: new RegExp(`^(${CAT_LABELS.join('|')})$`) })
|
|
const chooseCategory = (label: string) => {
|
|
fireEvent.click(catTrigger())
|
|
fireEvent.click(screen.getByRole('button', { name: label, pressed: false }))
|
|
}
|
|
|
|
it('FE-MOB-COSTSH-001: opens in add mode with everyone in an equal split and a blocked submit', () => {
|
|
renderSheet()
|
|
expect(screen.getByRole('dialog', { name: 'Add expense' })).toBeInTheDocument()
|
|
expect(nameField()).toHaveValue('')
|
|
expect(catTrigger()).toHaveTextContent('Food & drink')
|
|
expect(screen.getByRole('button', { name: 'Y You' })).toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: 'B bob' })).toBeInTheDocument()
|
|
expect(submit()).toBeDisabled()
|
|
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-002: splits the typed total equally and saves the expense', async () => {
|
|
const { onSaved } = renderSheet()
|
|
// A comma is normalised to a dot before it reaches the state.
|
|
fillBasics('Dinner', '85,50')
|
|
|
|
// Shares read the way the list prints euros (#2604).
|
|
expect(screen.getAllByText('42,75 €')).toHaveLength(2)
|
|
expect(screen.getByText('Split 2 ways · 42,75 € each')).toBeInTheDocument()
|
|
|
|
fireEvent.click(submit())
|
|
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledTimes(1))
|
|
expect(addBudgetItem).toHaveBeenCalledWith(1, {
|
|
name: 'Dinner',
|
|
category: 'food',
|
|
currency: 'EUR',
|
|
payers: [{ user_id: 1, amount: 85.5 }],
|
|
members: [{ user_id: 1, amount: null }, { user_id: 2, amount: null }],
|
|
member_ids: [1, 2],
|
|
expense_date: TODAY,
|
|
total_price: 85.5,
|
|
note: null,
|
|
ticket_json: null,
|
|
// Always sent, empty when nothing was attached: the server treats the
|
|
// array as the full set of receipts for the expense.
|
|
receipt_file_ids: [],
|
|
})
|
|
await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1))
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-020: the category dropdown opens, marks the current pick and closes on choose (#1658)', () => {
|
|
renderSheet()
|
|
expect(screen.queryByRole('button', { name: 'Groceries' })).toBeNull()
|
|
|
|
fireEvent.click(catTrigger())
|
|
expect(screen.getByRole('button', { name: 'Food & drink', pressed: true })).toBeInTheDocument()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Groceries', pressed: false }))
|
|
expect(catTrigger()).toHaveTextContent('Groceries')
|
|
expect(screen.queryByRole('button', { name: 'Sightseeing' })).toBeNull()
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-021: a note typed on the phone is saved with the expense (#1658)', async () => {
|
|
renderSheet()
|
|
fillBasics('Pharmacy', '18')
|
|
fireEvent.change(screen.getByPlaceholderText(/what it covered/i), { target: { value: 'Ben was ill, he pays this back' } })
|
|
|
|
fireEvent.click(submit())
|
|
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({
|
|
note: 'Ben was ill, he pays this back',
|
|
})))
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-022: an existing note is loaded for editing, and a receipt blob never is (#1658)', () => {
|
|
const { unmount } = renderSheet({ editing: buildBudgetItem({ id: 5, name: 'Chemist', note: 'reimburse from the kitty' }) })
|
|
expect(screen.getByPlaceholderText(/what it covered/i)).toHaveValue('reimburse from the kitty')
|
|
unmount()
|
|
|
|
renderSheet({ editing: buildBudgetItem({ id: 6, name: 'Market', note: 'TICKETJSON:{"items":[]}' }) })
|
|
expect(screen.getByPlaceholderText(/what it covered/i)).toHaveValue('')
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-003: adopts the prefill and carries the reservation link into the payload', async () => {
|
|
renderSheet({ prefill: { name: 'Ryokan', category: 'accommodation', amount: 240, reservationId: 77 } })
|
|
expect(nameField()).toHaveValue('Ryokan')
|
|
// Padded to the base currency's decimals since #2175, localized for EUR.
|
|
expect(totalField()).toHaveValue('240,00')
|
|
expect(catTrigger()).toHaveTextContent('Accommodation')
|
|
|
|
fireEvent.click(submit())
|
|
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledTimes(1))
|
|
expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({
|
|
name: 'Ryokan', category: 'accommodation', total_price: 240, reservation_id: 77,
|
|
}))
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-003c: a scanned receipt opens with its currency and its photo waiting to be attached', () => {
|
|
const photo = new File(['x'], 'bill.jpg', { type: 'image/jpeg' })
|
|
renderSheet({ prefill: { name: 'Sushi Dai', amount: 4200, currency: 'JPY', date: '2026-09-20', receiptFiles: [photo] } })
|
|
expect(nameField()).toHaveValue('Sushi Dai')
|
|
expect(screen.getByDisplayValue('4200')).toBeInTheDocument()
|
|
expect(screen.getByText('bill.jpg')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-003b: a place prefill carries the place link into the payload (#1298)', async () => {
|
|
renderSheet({ prefill: { name: 'Louvre', category: 'activities', placeId: 12 } })
|
|
expect(nameField()).toHaveValue('Louvre')
|
|
|
|
fireEvent.change(totalField(), { target: { value: '34' } })
|
|
fireEvent.click(submit())
|
|
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledTimes(1))
|
|
expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({
|
|
name: 'Louvre', category: 'activities', total_price: 34, place_id: 12,
|
|
}))
|
|
expect(addBudgetItem.mock.calls[0][1]).not.toHaveProperty('reservation_id')
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-004: dropping a participant re-splits and shrinks the member list', async () => {
|
|
renderSheet()
|
|
fillBasics('Taxi', '30')
|
|
fireEvent.click(screen.getByRole('button', { name: 'B bob' }))
|
|
|
|
expect(screen.getByText('1 person · 30,00 €')).toBeInTheDocument()
|
|
expect(screen.getAllByText('Tap to include')).toHaveLength(1)
|
|
|
|
fireEvent.click(submit())
|
|
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledTimes(1))
|
|
expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({
|
|
members: [{ user_id: 1, amount: null }],
|
|
member_ids: [1],
|
|
payers: [{ user_id: 1, amount: 30 }],
|
|
}))
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-005: an expense nobody shares is saved without members but keeps its payer', async () => {
|
|
renderSheet()
|
|
fillBasics('Souvenir', '12')
|
|
fireEvent.click(screen.getByRole('button', { name: 'Y You' }))
|
|
fireEvent.click(screen.getByRole('button', { name: 'B bob' }))
|
|
|
|
fireEvent.click(submit())
|
|
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledTimes(1))
|
|
// The server derives total_price from the payer sum, so the payer has to go
|
|
// out even with an empty split — otherwise the entry comes back at 0.
|
|
expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({
|
|
payers: [{ user_id: 1, amount: 12 }], members: [], member_ids: [],
|
|
}))
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-006: switching the category updates the pressed pill and the payload', async () => {
|
|
renderSheet()
|
|
fillBasics('Metro pass', '20')
|
|
chooseCategory('Transport')
|
|
expect(catTrigger()).toHaveTextContent('Transport')
|
|
|
|
fireEvent.click(submit())
|
|
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({ category: 'transport' })))
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-007: a foreign currency shows the live conversion into the base', () => {
|
|
renderSheet()
|
|
fillBasics('Hotel', '100')
|
|
expect(screen.queryByText(/live rate/)).not.toBeInTheDocument()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'EUR €' }))
|
|
fireEvent.change(screen.getByPlaceholderText('...'), { target: { value: 'USD' } })
|
|
fireEvent.click(screen.getByRole('button', { name: 'USD $' }))
|
|
|
|
expect(screen.getByText('$100.00')).toBeInTheDocument()
|
|
// 100 USD at 1.25 USD per EUR = 80 EUR.
|
|
expect(screen.getByRole('dialog').textContent).toContain('80,00')
|
|
expect(screen.getByText(/live rate/)).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-008: "No one paid yet" saves a planning entry without payers', async () => {
|
|
renderSheet()
|
|
fillBasics('Tickets', '60')
|
|
fireEvent.click(screen.getByRole('button', { name: 'You' }))
|
|
fireEvent.click(screen.getByRole('button', { name: 'No one paid yet' }))
|
|
|
|
fireEvent.click(submit())
|
|
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({ payers: [] })))
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-009: multiple payers start out balanced on the current payer', () => {
|
|
renderSheet()
|
|
fillBasics('Group ticket', '100')
|
|
fireEvent.click(screen.getByRole('button', { name: 'Multiple people paid' }))
|
|
|
|
const amounts = screen.getAllByPlaceholderText('0,00')
|
|
// Total field plus the single active payer row.
|
|
expect(amounts).toHaveLength(2)
|
|
expect(amounts[1]).toHaveValue('100,00')
|
|
|
|
// The hint next to an inactive payer is a second way into the same toggle.
|
|
fireEvent.click(screen.getByRole('button', { name: 'Tap to include' }))
|
|
expect(screen.getAllByPlaceholderText('0,00')[2]).toHaveValue('50,00')
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-010: adding a second payer spreads the total and saves both', async () => {
|
|
renderSheet()
|
|
fillBasics('Group ticket', '100')
|
|
fireEvent.click(screen.getByRole('button', { name: 'Multiple people paid' }))
|
|
fireEvent.click(screen.getAllByRole('button', { name: 'B bob' })[0])
|
|
|
|
const amounts = screen.getAllByPlaceholderText('0,00')
|
|
expect(amounts[1]).toHaveValue('50,00')
|
|
expect(amounts[2]).toHaveValue('50,00')
|
|
|
|
fireEvent.click(submit())
|
|
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({
|
|
payers: [{ user_id: 1, amount: 50 }, { user_id: 2, amount: 50 }],
|
|
})))
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-011: a typed payer amount is pinned and the rest absorbs the remainder', () => {
|
|
renderSheet()
|
|
fillBasics('Group ticket', '100')
|
|
fireEvent.click(screen.getByRole('button', { name: 'Multiple people paid' }))
|
|
fireEvent.click(screen.getAllByRole('button', { name: 'B bob' })[0])
|
|
|
|
fireEvent.change(screen.getAllByPlaceholderText('0,00')[1], { target: { value: '30' } })
|
|
const amounts = screen.getAllByPlaceholderText('0,00')
|
|
expect(amounts[1]).toHaveValue('30')
|
|
expect(amounts[2]).toHaveValue('70,00')
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-012: payers that do not add up block the save and explain why', () => {
|
|
renderSheet()
|
|
fillBasics('Group ticket', '100')
|
|
fireEvent.click(screen.getByRole('button', { name: 'Multiple people paid' }))
|
|
fireEvent.click(screen.getAllByRole('button', { name: 'B bob' })[0])
|
|
|
|
fireEvent.change(screen.getAllByPlaceholderText('0,00')[1], { target: { value: '30' } })
|
|
fireEvent.change(screen.getAllByPlaceholderText('0,00')[2], { target: { value: '30' } })
|
|
|
|
expect(screen.getByText(/Payer amounts must add up to/)).toBeInTheDocument()
|
|
expect(submit()).toBeDisabled()
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-013: going back to a single payer keeps the remaining one selected', () => {
|
|
renderSheet()
|
|
fillBasics('Group ticket', '100')
|
|
fireEvent.click(screen.getByRole('button', { name: 'Multiple people paid' }))
|
|
fireEvent.click(screen.getAllByRole('button', { name: 'B bob' })[0])
|
|
fireEvent.click(screen.getAllByRole('button', { name: 'Y You' })[0])
|
|
fireEvent.click(screen.getByRole('button', { name: 'One person paid' }))
|
|
|
|
expect(screen.getByRole('button', { name: 'bob' })).toBeInTheDocument()
|
|
expect(screen.queryByText(/Payer amounts must add up to/)).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-014: a custom split must match the total before it can be saved', async () => {
|
|
renderSheet()
|
|
fillBasics('Dinner', '100')
|
|
fireEvent.click(screen.getByRole('button', { name: 'Custom' }))
|
|
|
|
const shares = screen.getAllByPlaceholderText('50,00')
|
|
expect(shares).toHaveLength(2)
|
|
fireEvent.change(shares[0], { target: { value: '30' } })
|
|
expect(screen.getByText('30,00 € / 100,00 €')).toBeInTheDocument()
|
|
expect(submit()).toBeDisabled()
|
|
|
|
// The remaining share now suggests what is left of the total.
|
|
fireEvent.change(screen.getByPlaceholderText('70,00'), { target: { value: '70' } })
|
|
expect(screen.getByText('Split 2 ways · 50,00 € each')).toBeInTheDocument()
|
|
|
|
fireEvent.click(submit())
|
|
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({
|
|
members: [{ user_id: 1, amount: 30 }, { user_id: 2, amount: 70 }],
|
|
})))
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-015: a custom share drops a third decimal and is cleared when the member drops out', () => {
|
|
renderSheet()
|
|
fillBasics('Dinner', '100')
|
|
fireEvent.click(screen.getByRole('button', { name: 'Custom' }))
|
|
|
|
const share = screen.getAllByPlaceholderText('50,00')[0]
|
|
fireEvent.change(share, { target: { value: '30.123' } })
|
|
expect(share).toHaveValue('30,12')
|
|
fireEvent.change(share, { target: { value: '30,5' } })
|
|
expect(screen.getByDisplayValue('30,5')).toBe(share)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Y You' }))
|
|
fireEvent.click(screen.getByRole('button', { name: 'Tap to include' }))
|
|
expect(screen.getAllByPlaceholderText('50,00')[0]).toHaveValue('')
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-016: ticket mode derives the total and the per-person shares from the items', async () => {
|
|
renderSheet()
|
|
fireEvent.change(nameField(), { target: { value: 'Izakaya' } })
|
|
fireEvent.click(screen.getByRole('button', { name: 'Ticket' }))
|
|
expect(totalField()).toBeDisabled()
|
|
|
|
fireEvent.click(screen.getAllByRole('button', { name: 'Add' })[0])
|
|
fireEvent.change(screen.getByPlaceholderText('What was it for?'), { target: { value: 'Ramen' } })
|
|
fireEvent.change(screen.getAllByPlaceholderText('0,00')[1], { target: { value: '20' } })
|
|
|
|
expect(totalField()).toHaveValue('20,00')
|
|
expect(screen.getAllByText('10,00 €')).toHaveLength(2)
|
|
|
|
fireEvent.click(screen.getAllByRole('button', { name: 'Add' })[1])
|
|
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledTimes(1))
|
|
expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({
|
|
total_price: 20,
|
|
members: [{ user_id: 1, amount: 10 }, { user_id: 2, amount: 10 }],
|
|
ticket_json: '{"items":[{"name":"Ramen","price":"20","parts":[1,2]}]}',
|
|
}))
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-017: a ticket item without a name or a participant blocks the save', () => {
|
|
renderSheet()
|
|
fireEvent.change(nameField(), { target: { value: 'Izakaya' } })
|
|
fireEvent.click(screen.getByRole('button', { name: 'Ticket' }))
|
|
fireEvent.click(screen.getAllByRole('button', { name: 'Add' })[0])
|
|
|
|
const footerSubmit = () => screen.getAllByRole('button', { name: 'Add' })[1]
|
|
fireEvent.change(screen.getAllByPlaceholderText('0,00')[1], { target: { value: '20' } })
|
|
expect(footerSubmit()).toBeDisabled()
|
|
|
|
fireEvent.change(screen.getByPlaceholderText('What was it for?'), { target: { value: 'Ramen' } })
|
|
expect(footerSubmit()).toBeEnabled()
|
|
|
|
// Un-ticking everyone makes the item unsplittable again.
|
|
fireEvent.click(screen.getByRole('button', { name: 'Y You' }))
|
|
fireEvent.click(screen.getByRole('button', { name: 'B bob' }))
|
|
expect(footerSubmit()).toBeDisabled()
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-018: removing the last ticket item drops the share breakdown', () => {
|
|
renderSheet()
|
|
fireEvent.click(screen.getByRole('button', { name: 'Ticket' }))
|
|
fireEvent.click(screen.getAllByRole('button', { name: 'Add' })[0])
|
|
fireEvent.change(screen.getByPlaceholderText('What was it for?'), { target: { value: 'Ramen' } })
|
|
expect(screen.getAllByText('Split')).toHaveLength(2)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
|
expect(screen.queryByPlaceholderText('What was it for?')).not.toBeInTheDocument()
|
|
expect(screen.getAllByText('Split')).toHaveLength(1)
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-019: editing loads name, currency, legacy category and the custom shares', () => {
|
|
const editing = buildBudgetItem({
|
|
id: 5, name: 'Flight to Osaka', category: 'Flight', currency: 'usd', total_price: 60,
|
|
expense_date: '2026-03-04',
|
|
members: [member(1, 20), member(2, 40)],
|
|
payers: [{ user_id: 2, amount: 60 }],
|
|
})
|
|
renderSheet({ editing })
|
|
|
|
expect(screen.getByRole('dialog', { name: 'Edit expense' })).toBeInTheDocument()
|
|
expect(nameField()).toHaveValue('Flight to Osaka')
|
|
expect(catTrigger()).toHaveTextContent('Flights')
|
|
expect(screen.getByRole('button', { name: 'USD $' })).toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: 'bob' })).toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: 'Custom' })).toHaveClass('bg-m-act')
|
|
// USD renders with a dot, so the total and both shares share this placeholder.
|
|
// Seeded padded to two decimals since #2175.
|
|
const amounts = screen.getAllByPlaceholderText('0.00')
|
|
expect(amounts[0]).toHaveValue('60.00')
|
|
expect(amounts[1]).toHaveValue('20.00')
|
|
expect(amounts[2]).toHaveValue('40.00')
|
|
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-020: saving an edit goes through updateBudgetItem', async () => {
|
|
const editing = buildBudgetItem({
|
|
id: 5, name: 'Dinner', category: 'food', currency: 'EUR', total_price: 60,
|
|
members: [member(1, null), member(2, null)],
|
|
payers: [{ user_id: 1, amount: 60 }],
|
|
})
|
|
const { onSaved } = renderSheet({ editing })
|
|
fireEvent.change(nameField(), { target: { value: 'Dinner & drinks' } })
|
|
fireEvent.click(saveBtn())
|
|
|
|
await waitFor(() => expect(updateBudgetItem).toHaveBeenCalledTimes(1))
|
|
expect(updateBudgetItem).toHaveBeenCalledWith(1, 5, expect.objectContaining({
|
|
name: 'Dinner & drinks',
|
|
total_price: 60,
|
|
members: [{ user_id: 1, amount: null }, { user_id: 2, amount: null }],
|
|
}))
|
|
expect(addBudgetItem).not.toHaveBeenCalled()
|
|
await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1))
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-021: an expense with two payers reopens in multi-payer mode', () => {
|
|
const editing = buildBudgetItem({
|
|
id: 6, name: 'Rental car', category: 'transport', currency: 'EUR', total_price: 100,
|
|
members: [member(1, null), member(2, null)],
|
|
payers: [{ user_id: 1, amount: 40 }, { user_id: 2, amount: 60 }],
|
|
})
|
|
renderSheet({ editing })
|
|
|
|
expect(screen.getByRole('button', { name: 'One person paid' })).toBeInTheDocument()
|
|
const amounts = screen.getAllByPlaceholderText('0,00')
|
|
expect(amounts[1]).toHaveValue('40,00')
|
|
expect(amounts[2]).toHaveValue('60,00')
|
|
expect(screen.queryByText(/Payer amounts must add up to/)).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-022: an expense nobody paid reopens on the placeholder payer', () => {
|
|
const editing = buildBudgetItem({
|
|
id: 7, name: 'Museum', category: 'sightseeing', currency: 'EUR', total_price: 0,
|
|
members: [], payers: [],
|
|
})
|
|
renderSheet({ editing })
|
|
|
|
expect(screen.getByRole('button', { name: 'No one paid yet' })).toBeInTheDocument()
|
|
expect(totalField()).toHaveValue('')
|
|
expect(saveBtn()).toBeDisabled()
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-023: a stored ticket note reopens as editable ticket rows', () => {
|
|
const editing = buildBudgetItem({
|
|
id: 8, name: 'Izakaya', category: 'food', currency: 'EUR', total_price: 20,
|
|
members: [member(1, 10), member(2, 10)],
|
|
note: 'TICKETJSON:{"items":[{"name":"Ramen","price":"20","parts":[1,2]}]}',
|
|
})
|
|
renderSheet({ editing })
|
|
|
|
expect(screen.getByRole('button', { name: 'Ticket' })).toHaveClass('bg-m-act')
|
|
expect(screen.getByPlaceholderText('What was it for?')).toHaveValue('Ramen')
|
|
expect(screen.getAllByPlaceholderText('0,00')[1]).toHaveValue('20')
|
|
expect(totalField()).toHaveValue('20,00')
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-024: a corrupt ticket note still opens the sheet with an empty item list', () => {
|
|
const editing = buildBudgetItem({
|
|
id: 9, name: 'Izakaya', category: 'food', currency: 'EUR', total_price: 20,
|
|
members: [], note: 'TICKETJSON:{not json',
|
|
})
|
|
renderSheet({ editing })
|
|
|
|
expect(screen.getByRole('button', { name: 'Ticket' })).toHaveClass('bg-m-act')
|
|
expect(screen.queryByPlaceholderText('What was it for?')).not.toBeInTheDocument()
|
|
expect(saveBtn()).toBeDisabled()
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-025: deleting takes two taps and warns in between', async () => {
|
|
const editing = buildBudgetItem({ id: 5, name: 'Dinner', category: 'food', currency: 'EUR', total_price: 60, members: [] })
|
|
const { onSaved } = renderSheet({ editing })
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
|
expect(addToast).toHaveBeenCalledWith('Tap again to delete', 'warning', undefined)
|
|
expect(deleteBudgetItem).not.toHaveBeenCalled()
|
|
expect(screen.getByRole('button', { name: 'Delete' }).className).toContain('--m-st-danger')
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
|
await waitFor(() => expect(deleteBudgetItem).toHaveBeenCalledWith(1, 5))
|
|
await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1))
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-026: a failing delete disarms the button and reports the error', async () => {
|
|
deleteBudgetItem.mockRejectedValueOnce(new Error('locked'))
|
|
const editing = buildBudgetItem({ id: 5, name: 'Dinner', category: 'food', currency: 'EUR', total_price: 60, members: [] })
|
|
const { onSaved } = renderSheet({ editing })
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
|
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
|
|
|
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Unknown error', 'error', undefined))
|
|
expect(onSaved).not.toHaveBeenCalled()
|
|
await waitFor(() => expect(screen.getByRole('button', { name: 'Delete' }).className).not.toContain('--m-st-danger'))
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-027: a failing save reports the error and lets the user retry', async () => {
|
|
addBudgetItem.mockRejectedValueOnce(new Error('offline'))
|
|
const { onSaved } = renderSheet()
|
|
fillBasics('Dinner', '20')
|
|
fireEvent.click(submit())
|
|
|
|
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Unknown error', 'error', undefined))
|
|
expect(onSaved).not.toHaveBeenCalled()
|
|
await waitFor(() => expect(submit()).toBeEnabled())
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-028: cancel reports the close only after the exit animation', () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
const { onClose } = renderSheet()
|
|
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
|
expect(onClose).not.toHaveBeenCalled()
|
|
act(() => { vi.advanceTimersByTime(280) })
|
|
expect(onClose).toHaveBeenCalledTimes(1)
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-029: renders member avatars and flags guests', () => {
|
|
const people = [
|
|
{ id: 1, username: 'alice', avatar_url: '/uploads/avatars/alice.png' },
|
|
{ id: 2, username: 'bob', avatar_url: null, is_guest: true },
|
|
] as TripMember[]
|
|
renderSheet({ people })
|
|
|
|
// The sheet lives in a portal, so query from the document root.
|
|
expect(document.querySelector('img[src="/uploads/avatars/alice.png"]')).not.toBeNull()
|
|
expect(screen.getByText('Guest')).toBeInTheDocument()
|
|
})
|
|
|
|
// #1567 all over again: an expense entered at 23:00 in Tokyo belongs on that
|
|
// evening's day, not on the UTC date that has not started there yet.
|
|
it('FE-MOB-COSTSH-031: the default expense date comes off the local clock, not UTC', async () => {
|
|
vi.useFakeTimers({ shouldAdvanceTime: true })
|
|
try {
|
|
// East of Greenwich the two dates part just after midnight, west of it just
|
|
// before, so pick the edge that this runner's zone actually has.
|
|
const hour = new Date(2026, 7, 12).getTimezoneOffset() > 0 ? 23 : 0
|
|
const now = new Date(2026, 7, 12, hour, 30, 0)
|
|
vi.setSystemTime(now)
|
|
const { onSaved } = renderSheet()
|
|
fillBasics('Ramen', '12')
|
|
fireEvent.click(submit())
|
|
|
|
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledTimes(1))
|
|
expect(addBudgetItem.mock.calls[0][1]).toMatchObject({ expense_date: '2026-08-12' })
|
|
// Guard the guard: in a UTC runner there is no drift to catch.
|
|
if (now.getTimezoneOffset() !== 0) {
|
|
expect(now.toISOString().slice(0, 10)).not.toBe('2026-08-12')
|
|
}
|
|
await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1))
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-032: reopens 4,90 as "4,90" instead of "4,9" (#2175)', () => {
|
|
const editing = buildBudgetItem({
|
|
id: 10, name: 'Coffee', category: 'food', currency: 'EUR', total_price: 4.9,
|
|
members: [], payers: [{ user_id: 1, amount: 4.9 }],
|
|
})
|
|
renderSheet({ editing })
|
|
expect(totalField()).toHaveValue('4,90')
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-033: a negative amount can be typed and saves a refund (#2176)', async () => {
|
|
const { onSaved } = renderSheet()
|
|
// The '-' used to be stripped by the input sanitizer, making this untypeable.
|
|
fillBasics('Hotel refund', '-100')
|
|
|
|
expect(submit()).toBeEnabled()
|
|
fireEvent.click(submit())
|
|
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledTimes(1))
|
|
expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({
|
|
total_price: -100,
|
|
payers: [{ user_id: 1, amount: -100 }],
|
|
}))
|
|
await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1))
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-034: a negative payer survives the reopen and the save (#2176)', async () => {
|
|
const editing = buildBudgetItem({
|
|
id: 11, name: 'Refund', category: 'other', currency: 'EUR', total_price: -60,
|
|
members: [member(1, null), member(2, null)],
|
|
payers: [{ user_id: 1, amount: -60 }],
|
|
})
|
|
renderSheet({ editing })
|
|
|
|
expect(totalField()).toHaveValue('-60,00')
|
|
// Alice is still the recorded (negative) payer — not reset to "No one paid yet".
|
|
expect(screen.getByRole('button', { name: 'You' })).toBeInTheDocument()
|
|
|
|
fireEvent.click(saveBtn())
|
|
await waitFor(() => expect(updateBudgetItem).toHaveBeenCalledWith(1, 11, expect.objectContaining({
|
|
total_price: -60,
|
|
payers: [{ user_id: 1, amount: -60 }],
|
|
})))
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-035: an expense saved without a currency reopens in the trip currency (#2525)', async () => {
|
|
// No currency on the row means the trip's own. Seeding the display currency
|
|
// instead labelled 100 euro as 100 dollars, and a plain save stored it so.
|
|
localStorage.setItem('trek_fx_USD', JSON.stringify({ rates: { USD: 1, EUR: 0.8 }, ts: Date.now() }))
|
|
useTripStore.setState({ trip: buildTrip({ id: 1, currency: 'EUR' }) } as unknown as Partial<TripStoreState>)
|
|
const editing = buildBudgetItem({
|
|
id: 12, name: 'Tram pass', category: 'transport', currency: null, total_price: 100,
|
|
members: [member(1, null)],
|
|
payers: [{ user_id: 1, amount: 100 }],
|
|
})
|
|
renderSheet({ base: 'USD', editing })
|
|
|
|
expect(totalField()).toHaveValue('100,00')
|
|
expect(screen.getByRole('button', { name: 'EUR €' })).toBeInTheDocument()
|
|
expect(screen.queryByRole('button', { name: 'USD $' })).toBeNull()
|
|
|
|
fireEvent.click(saveBtn())
|
|
await waitFor(() => expect(updateBudgetItem).toHaveBeenCalledWith(1, 12, expect.objectContaining({
|
|
total_price: 100,
|
|
currency: 'EUR',
|
|
})))
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-036: editing a booked bill previews the rate the save keeps (#2525)', () => {
|
|
// A euro trip read in dollars, the bill entered in dollars at 1.17 to the euro.
|
|
// Same currency as the list, so the sheet said nothing, while the list showed the
|
|
// booked euros at today's rate beside the 801.76 typed here.
|
|
localStorage.setItem('trek_fx_USD', JSON.stringify({ rates: { USD: 1, EUR: 0.865706 }, ts: Date.now() }))
|
|
localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1, USD: 1.1551 }, ts: Date.now() }))
|
|
useTripStore.setState({ trip: buildTrip({ id: 1, currency: 'EUR' }) } as unknown as Partial<TripStoreState>)
|
|
const editing = buildBudgetItem({
|
|
id: 13, name: 'Aparthotel Silver', category: 'accommodation', currency: 'USD', exchange_rate: 1.17, total_price: 801.76,
|
|
members: [member(1, null), member(2, null)],
|
|
payers: [{ user_id: 1, amount: 801.76 }],
|
|
})
|
|
renderSheet({ base: 'USD', editing })
|
|
|
|
const hint = screen.getByText(/live rate/).parentElement as HTMLElement
|
|
expect(within(hint).getByText('$801.76')).toBeInTheDocument()
|
|
expect(within(hint).getByText(/^685,26\s€$/)).toBeInTheDocument()
|
|
expect(within(hint).getByText('$791.55')).toBeInTheDocument()
|
|
// Each share next to what it counts as, the figure the row's "you lent" is made of.
|
|
expect(screen.getByText(/Split 2 ways · \$400\.88 → \$395\.77 each/)).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-037: a bill booked at today\'s rate has nothing to preview (#2525)', () => {
|
|
localStorage.setItem('trek_fx_USD', JSON.stringify({ rates: { USD: 1, EUR: 0.87732 }, ts: Date.now() }))
|
|
localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1, USD: 1.1398 }, ts: Date.now() }))
|
|
useTripStore.setState({ trip: buildTrip({ id: 1, currency: 'EUR' }) } as unknown as Partial<TripStoreState>)
|
|
const editing = buildBudgetItem({
|
|
id: 14, name: 'Villa', category: 'accommodation', currency: 'USD', exchange_rate: 1.1398, total_price: 12345.67,
|
|
members: [member(1, null), member(2, null)],
|
|
payers: [{ user_id: 1, amount: 12345.67 }],
|
|
})
|
|
renderSheet({ base: 'USD', editing })
|
|
|
|
expect(screen.queryByText(/live rate/)).toBeNull()
|
|
expect(screen.queryByText(/→/)).toBeNull()
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-030: a currency without a known symbol falls back to its code', () => {
|
|
localStorage.setItem('trek_fx_XTS', JSON.stringify({ rates: { XTS: 1 }, ts: Date.now() }))
|
|
renderSheet({ base: 'XTS' })
|
|
fireEvent.change(nameField(), { target: { value: 'Test purchase' } })
|
|
fireEvent.change(screen.getByPlaceholderText('0.00'), { target: { value: '10' } })
|
|
|
|
expect(screen.getAllByText('XTS 5.00')).toHaveLength(2)
|
|
expect(screen.getByText('Split 2 ways · XTS 5.00 each')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-COSTSH-038: a won expense reads whole won with grouping and suggests its custom shares faintly (#2604)', () => {
|
|
localStorage.setItem('trek_fx_KRW', JSON.stringify({ rates: { KRW: 1 }, ts: Date.now() }))
|
|
renderSheet({ base: 'KRW' })
|
|
fireEvent.change(nameField(), { target: { value: 'BBQ' } })
|
|
const total = screen.getByPlaceholderText('0')
|
|
fireEvent.change(total, { target: { value: '60000.55' } })
|
|
expect(total).toHaveValue('60000.')
|
|
|
|
expect(screen.getAllByText('₩30,000')).toHaveLength(2)
|
|
expect(screen.getByText('Split 2 ways · ₩30,000 each')).toBeInTheDocument()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Custom' }))
|
|
const shares = screen.getAllByPlaceholderText('30000')
|
|
expect(shares[0]).toHaveValue('')
|
|
expect(shares[0].className).toContain('placeholder:text-m-faint')
|
|
fireEvent.change(shares[0], { target: { value: '10000' } })
|
|
expect(screen.getByText('₩10,000 / ₩60,000')).toBeInTheDocument()
|
|
})
|
|
})
|