import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { http, HttpResponse } from 'msw' import MCostsTab from '../../../../src/mobile/screens/trip/tabs/MCostsTab' import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell' import { budgetApi } from '../../../../src/api/client' import { localToday } from '../../../../src/components/Planner/today' import { resetFreezeAttempts } from '../../../../src/components/Budget/useFreezeMissingRates' import { clearExchangeRateCache } from '../../../../src/hooks/useExchangeRates' import { useAuthStore } from '../../../../src/store/authStore' import { useSettingsStore } from '../../../../src/store/settingsStore' import type { BudgetItem, Day, Trip } from '../../../../src/types' import { buildTrip, buildUser } from '../../../helpers/factories' import { buildPlanner, buildShell } from '../../../helpers/mobileTrip' import { resetAllStores, seedStore } from '../../../helpers/store' import { server } from '../../../helpers/msw/server' import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render' // FE-MOB-COSTT-001 to FE-MOB-COSTT-049 // The add/edit expense sheet is the shared desktop-sized form; the panel only // owns when it opens and what happens on save, so it is stubbed here. vi.mock('../../../../src/mobile/screens/trip/sheets/MCostSheet', () => ({ default: ({ base, editing, onClose, onSaved }: { base: string editing: { id: number } | null onClose: () => void onSaved: () => void }) => (
{editing ? `sheet editing ${editing.id}` : `sheet new ${base}`}
), })) const TRIP = buildTrip({ id: 7, title: 'Tokyo Trip', currency: 'USD' }) as Trip const DAYS = [ { id: 1, trip_id: 7, day_number: 1, date: '2026-05-01', title: null }, { id: 2, trip_id: 7, day_number: 2, date: '2026-05-02', title: 'Kyoto' }, ] as unknown as Day[] const MEMBERS = [ { id: 1, username: 'Me', avatar_url: null }, { id: 2, username: 'Ada', avatar_url: '/uploads/avatars/ada.png' }, { id: 3, username: 'Bob', avatar_url: null }, ] const RAMEN = { id: 11, trip_id: 7, name: 'Ramen', category: 'food', total_price: 80, currency: 'USD', expense_date: '2026-05-02', note: null, members: [ { user_id: 1, paid: 1, username: 'Me', amount: null, avatar_url: null }, { user_id: 2, paid: 0, username: 'Ada', amount: null, avatar_url: '/uploads/avatars/ada.png' }, ], payers: [{ user_id: 1, amount: 80, username: 'Me' }], } as unknown as BudgetItem /** Foreign currency + explicit custom shares — exercises the conversion line. */ const MUSEUM = { id: 12, trip_id: 7, name: 'Museum', category: 'activities', total_price: 30, currency: 'GBP', expense_date: '2026-04-30', note: 'Tickets; adult', members: [ { user_id: 1, paid: 0, username: 'Me', amount: 10, avatar_url: null }, { user_id: 3, paid: 0, username: 'Bob', amount: 20, avatar_url: null }, ], payers: [{ user_id: 3, amount: 30, username: 'Bob' }], } as unknown as BudgetItem /** No currency (falls back to the trip's), no members, no payer → the "unfinished" case. */ const SOUVENIR = { id: 13, trip_id: 7, name: 'Souvenirs', category: 'shopping', total_price: 25, currency: null, expense_date: null, note: null, payers: [], } as unknown as BudgetItem /** I paid exactly my own share — counts as "mine" but never as "owed". */ const TAXI = { id: 14, trip_id: 7, name: 'Taxi', category: 'transport', total_price: 20, currency: 'USD', expense_date: '2026-05-02', note: 'TICKETJSON:{"items":[]}', members: [{ user_id: 1, paid: 1, username: 'Me', amount: 20, avatar_url: null }], payers: [{ user_id: 1, amount: 20, username: 'Me' }], } as unknown as BudgetItem const ITEMS = [RAMEN, MUSEUM, SOUVENIR, TAXI] const SETTLEMENT = { balances: [ { user_id: 1, username: 'Me', avatar_url: null, balance: 12.5 }, { user_id: 2, username: 'Ada', avatar_url: '/uploads/avatars/ada.png', balance: -7.25 }, ], flows: [ { from: { user_id: 1, username: 'Me' }, to: { user_id: 2, username: 'Ada' }, amount: 40 }, { from: { user_id: 3, username: 'Bob' }, to: { user_id: 1, username: 'Me' }, amount: 15 }, { from: { user_id: 99, username: 'Ghost' }, to: { user_id: 3, username: 'Bob' }, amount: 5 }, ], } let settlementBases: string[] = [] function serveSettlement(body: unknown = SETTLEMENT, fail = false) { server.use( http.get('/api/trips/:id/budget/settlement', ({ request }) => { settlementBases.push(new URL(request.url).searchParams.get('base') ?? '') return fail ? HttpResponse.error() : HttpResponse.json(body) }), ) } function planner(overrides: Partial = {}) { return buildPlanner({ tripId: 7, trip: TRIP, days: DAYS, budgetItems: ITEMS, tripMembers: MEMBERS as unknown as TripPlanner['tripMembers'], ...overrides, }) } /** Renders and waits until the settlement response has landed in state. */ async function renderTab(p: TripPlanner = planner(), shell: MTripShellApi = buildShell()) { const view = render() await screen.findByText('+$12.50') return { ...view, planner: p, shell } } /** Walks up `n` levels — the panel's blocks are plain divs without test ids. */ function up(el: HTMLElement, n: number): HTMLElement { let node: HTMLElement = el for (let i = 0; i < n; i++) node = node.parentElement as HTMLElement return node } /** The expense card of a row, found from its name. */ function cardOf(name: string): HTMLElement { let el = screen.getByText(name).parentElement while (el && !el.className.includes('rounded-2xl')) el = el.parentElement if (!el) throw new Error(`no card for ${name}`) return el } /** Card plus the edit/delete stack that sits next to it. */ function rowOf(name: string): HTMLElement { return up(cardOf(name), 1) } describe('MCostsTab', () => { beforeEach(() => { resetAllStores() clearExchangeRateCache() seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'Me' }) }) localStorage.setItem('trek_fx_USD', JSON.stringify({ rates: { USD: 1, GBP: 0.5 }, ts: Date.now() })) localStorage.setItem('trek_fx_GBP', JSON.stringify({ rates: { GBP: 1, USD: 2 }, ts: Date.now() })) settlementBases = [] serveSettlement() }) afterEach(() => { vi.restoreAllMocks() }) it('FE-MOB-COSTT-001: loads the items and the settlement for the display currency', async () => { const { planner: p } = await renderTab() expect(p.tripActions.loadBudgetItems).toHaveBeenCalledWith(7) expect(settlementBases).toEqual(['USD']) }) it('FE-MOB-COSTT-002: sums the hero totals across currencies', async () => { await renderTab() const hero = up(screen.getByText('costs.totalSpend'), 1) expect(hero.textContent).toContain('$185.00') expect(hero.textContent).toContain('costs.yourShare') expect(hero.textContent).toContain('$80.00') expect(hero.textContent).toContain('costs.youPaid') expect(hero.textContent).toContain('$100.00') }) it('FE-MOB-COSTT-003: derives owe / owed from the settlement flows', async () => { await renderTab() expect(up(screen.getByText('costs.youOwe'), 1).textContent).toContain('$40.00') expect(up(screen.getByText('costs.youreOwed'), 1).textContent).toContain('$15.00') }) it('FE-MOB-COSTT-004: reports the unpaid expenses as outstanding', async () => { await renderTab() const block = up(screen.getByText('costs.outstanding'), 2) expect(block.textContent).toContain('1') expect(block.textContent).toContain('$25.00') }) it('FE-MOB-COSTT-005: hides the outstanding block when everything is paid', async () => { await renderTab(planner({ budgetItems: [RAMEN, TAXI] })) expect(screen.queryByText('costs.outstanding')).not.toBeInTheDocument() }) it('FE-MOB-COSTT-006: lists the settle-up flows and names the participants', async () => { await renderTab() const settle = up(screen.getByText('costs.settleUp'), 3) expect(within(settle).getByText('$40.00')).toBeInTheDocument() expect(within(settle).getByText('$15.00')).toBeInTheDocument() expect(within(settle).getByText('$5.00')).toBeInTheDocument() expect(within(settle).getByText('?')).toBeInTheDocument() expect(within(settle).getAllByText('costs.you')).toHaveLength(3) expect(within(settle).getAllByText('Ada')).toHaveLength(2) }) it('FE-MOB-COSTT-007: shows each member balance with its sign, zero for unknown members', async () => { await renderTab() const settle = up(screen.getByText('costs.settleUp'), 3) expect(within(settle).getByText('+$12.50')).toBeInTheDocument() expect(within(settle).getByText('−$7.25')).toBeInTheDocument() expect(within(settle).getByText('$0.00')).toBeInTheDocument() expect(within(settle).getByAltText('')).toHaveAttribute('src', '/uploads/avatars/ada.png') }) it('FE-MOB-COSTT-008: collapses and reopens the settle-up card', async () => { await renderTab() // The header is a real