mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
393 lines
14 KiB
TypeScript
393 lines
14 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
baseTotal,
|
|
buildCostsCsv,
|
|
categoryBreakdown,
|
|
categoryFilterKeys,
|
|
computeTotals,
|
|
currencyOf,
|
|
dayFilterKeys,
|
|
filterBudgetItems,
|
|
groupByDay,
|
|
isUnfinished,
|
|
memberShareOf,
|
|
myPaidOf,
|
|
myShareOf,
|
|
tint,
|
|
type CostsCtx,
|
|
type CostsFilterState,
|
|
type CostsSettlementFlow,
|
|
} from '../../../../src/mobile/screens/trip/tabs/costsModel';
|
|
import { buildBudgetItem } from '../../../helpers/factories';
|
|
import type { BudgetItem } from '../../../../src/types';
|
|
|
|
// FE-MOB-CMOD-001 to FE-MOB-CMOD-027
|
|
|
|
const RATES: Record<string, number> = { EUR: 1, USD: 0.5, JPY: 0.01 };
|
|
|
|
const ctx: CostsCtx = {
|
|
me: 1,
|
|
// lower case on purpose — currencyOf has to normalise it
|
|
tripCurrency: 'eur',
|
|
convert: (amount, currency) => amount * (RATES[(currency || 'EUR').toUpperCase()] ?? 1),
|
|
};
|
|
|
|
type Member = NonNullable<BudgetItem['members']>[number];
|
|
type Payer = NonNullable<BudgetItem['payers']>[number];
|
|
|
|
function member(user_id: number, amount?: number | null): Member {
|
|
return { user_id, paid: 0, username: `u${user_id}`, ...(amount === undefined ? {} : { amount }) };
|
|
}
|
|
|
|
function payer(user_id: number, amount: number): Payer {
|
|
return { user_id, amount, username: `u${user_id}` };
|
|
}
|
|
|
|
function expense(overrides: Partial<BudgetItem> = {}): BudgetItem {
|
|
return buildBudgetItem({ category: 'other', ...overrides });
|
|
}
|
|
|
|
function filters(overrides: Partial<CostsFilterState> = {}): CostsFilterState {
|
|
return { search: '', segment: 'all', categoryKey: '', dayKey: '', ...overrides };
|
|
}
|
|
|
|
describe('costsModel — money maths', () => {
|
|
it('FE-MOB-CMOD-001: uppercases the expense currency and falls back to the trip currency', () => {
|
|
expect(currencyOf(expense({ currency: 'usd' }), ctx)).toBe('USD');
|
|
expect(currencyOf(expense({ currency: null }), ctx)).toBe('EUR');
|
|
expect(currencyOf(expense({ currency: '' }), ctx)).toBe('EUR');
|
|
});
|
|
|
|
it('FE-MOB-CMOD-002: converts the expense total into the base currency', () => {
|
|
expect(baseTotal(expense({ total_price: 100, currency: 'USD' }), ctx)).toBe(50);
|
|
expect(baseTotal(expense({ total_price: 100, currency: null }), ctx)).toBe(100);
|
|
expect(baseTotal(expense({ total_price: 0 }), ctx)).toBe(0);
|
|
});
|
|
|
|
it('FE-MOB-CMOD-003: myPaidOf only sums my own payer rows, converted', () => {
|
|
const e = expense({ total_price: 100, currency: 'USD', payers: [payer(1, 60), payer(2, 40)] });
|
|
expect(myPaidOf(e, ctx)).toBe(30);
|
|
expect(myPaidOf(expense({ payers: [payer(2, 40)] }), ctx)).toBe(0);
|
|
expect(myPaidOf(expense({ payers: undefined }), ctx)).toBe(0);
|
|
});
|
|
|
|
it('FE-MOB-CMOD-004: memberShareOf prefers an explicit custom amount', () => {
|
|
const e = expense({
|
|
total_price: 100,
|
|
currency: 'USD',
|
|
members: [member(1, 70), member(2, 30)],
|
|
});
|
|
expect(memberShareOf(e, 1, ctx)).toBe(35);
|
|
expect(memberShareOf(e, 2, ctx)).toBe(15);
|
|
});
|
|
|
|
it('FE-MOB-CMOD-005: an explicit zero share is honoured instead of falling back to the equal split', () => {
|
|
const e = expense({ id: 3, total_price: 100, members: [member(1, 0), member(2, 100)] });
|
|
expect(memberShareOf(e, 1, ctx)).toBe(0);
|
|
});
|
|
|
|
it('FE-MOB-CMOD-006: memberShareOf falls back to the rotating equal split', () => {
|
|
// id 3 % 3 members = rotation index 0, so user 1 carries the leftover cent
|
|
const e = expense({ id: 3, total_price: 100, members: [member(1), member(2), member(3)] });
|
|
expect(memberShareOf(e, 1, ctx)).toBe(33.34);
|
|
expect(memberShareOf(e, 2, ctx)).toBe(33.33);
|
|
expect(memberShareOf(e, 3, ctx)).toBe(33.33);
|
|
});
|
|
|
|
it('FE-MOB-CMOD-025: a zero-total expense splits into zero shares', () => {
|
|
const e = expense({ id: 2, total_price: 0, members: [member(1), member(2)] });
|
|
expect(memberShareOf(e, 1, ctx)).toBe(0);
|
|
});
|
|
|
|
it('FE-MOB-CMOD-007: a non-participant has no share', () => {
|
|
const e = expense({ total_price: 100, members: [member(2), member(3)] });
|
|
expect(memberShareOf(e, 1, ctx)).toBe(0);
|
|
expect(memberShareOf(expense({ members: undefined }), 1, ctx)).toBe(0);
|
|
});
|
|
|
|
it('FE-MOB-CMOD-008: myShareOf resolves the share of ctx.me', () => {
|
|
const e = expense({ id: 4, total_price: 80, currency: 'USD', members: [member(1), member(2)] });
|
|
expect(myShareOf(e, ctx)).toBe(20);
|
|
expect(myShareOf(e, ctx)).toBe(memberShareOf(e, 1, ctx));
|
|
});
|
|
|
|
it('FE-MOB-CMOD-009: isUnfinished flags totals nobody has actually paid', () => {
|
|
expect(isUnfinished(expense({ total_price: 100, payers: [] }), ctx)).toBe(true);
|
|
expect(isUnfinished(expense({ total_price: 100, payers: [payer(1, 0)] }), ctx)).toBe(true);
|
|
expect(isUnfinished(expense({ total_price: 100, payers: undefined }), ctx)).toBe(true);
|
|
expect(isUnfinished(expense({ total_price: 100, payers: [payer(1, 100)] }), ctx)).toBe(false);
|
|
expect(isUnfinished(expense({ total_price: 0, payers: [] }), ctx)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('costsModel — computeTotals', () => {
|
|
it('FE-MOB-CMOD-010: aggregates spend, my paid/share, settlement direction and outstanding', () => {
|
|
const paid = expense({
|
|
id: 10,
|
|
total_price: 100,
|
|
payers: [payer(1, 100)],
|
|
members: [member(1), member(2)],
|
|
});
|
|
const unpaid = expense({
|
|
id: 11,
|
|
total_price: 50,
|
|
currency: 'USD',
|
|
payers: [],
|
|
members: [member(2, 50)],
|
|
});
|
|
const flows: CostsSettlementFlow[] = [
|
|
{ from: { user_id: 1, username: 'me' }, to: { user_id: 2, username: 'ada' }, amount: 20 },
|
|
{ from: { user_id: 3, username: 'bo' }, to: { user_id: 1, username: 'me' }, amount: 5 },
|
|
{ from: { user_id: 2, username: 'ada' }, to: { user_id: 3, username: 'bo' }, amount: 7 },
|
|
];
|
|
|
|
expect(computeTotals([paid, unpaid], flows, ctx)).toEqual({
|
|
totalSpend: 125,
|
|
myPaid: 100,
|
|
myShare: 50,
|
|
owe: 20,
|
|
owed: 5,
|
|
outstanding: 25,
|
|
outstandingCount: 1,
|
|
});
|
|
});
|
|
|
|
it('FE-MOB-CMOD-011: returns zeroes for an empty trip', () => {
|
|
expect(computeTotals([], [], ctx)).toEqual({
|
|
totalSpend: 0,
|
|
myPaid: 0,
|
|
myShare: 0,
|
|
owe: 0,
|
|
owed: 0,
|
|
outstanding: 0,
|
|
outstandingCount: 0,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('costsModel — list filters and grouping', () => {
|
|
// e1: I fronted it and am net owed. e2: someone else paid. e3: I paid exactly my own share.
|
|
const e1 = expense({
|
|
id: 20,
|
|
name: 'Hotel Berlin',
|
|
category: 'accommodation',
|
|
expense_date: '2026-07-01',
|
|
total_price: 100,
|
|
payers: [payer(1, 100)],
|
|
members: [member(1), member(2)],
|
|
});
|
|
const e2 = expense({
|
|
id: 21,
|
|
name: 'Sushi',
|
|
category: 'food',
|
|
expense_date: '2026-07-02',
|
|
total_price: 60,
|
|
payers: [payer(2, 60)],
|
|
members: [member(1), member(2)],
|
|
});
|
|
const e3 = expense({
|
|
id: 22,
|
|
name: 'Museum ticket',
|
|
category: 'activities',
|
|
expense_date: null,
|
|
total_price: 30,
|
|
payers: [payer(1, 30)],
|
|
members: [member(1)],
|
|
});
|
|
const all = [e1, e2, e3];
|
|
|
|
it('FE-MOB-CMOD-012: "all" keeps every expense and does not mutate the input', () => {
|
|
const result = filterBudgetItems(all, filters(), ctx);
|
|
expect(result).toEqual(all);
|
|
expect(result).not.toBe(all);
|
|
});
|
|
|
|
it('FE-MOB-CMOD-013: "mine" keeps expenses I fronted money on', () => {
|
|
expect(filterBudgetItems(all, filters({ segment: 'mine' }), ctx).map(e => e.id)).toEqual([20, 22]);
|
|
});
|
|
|
|
it('FE-MOB-CMOD-014: "owed" keeps expenses where I am net owed', () => {
|
|
expect(filterBudgetItems(all, filters({ segment: 'owed' }), ctx).map(e => e.id)).toEqual([20]);
|
|
});
|
|
|
|
it('FE-MOB-CMOD-015: category, day and search narrow the list and combine', () => {
|
|
expect(filterBudgetItems(all, filters({ categoryKey: 'food' }), ctx).map(e => e.id)).toEqual([21]);
|
|
expect(filterBudgetItems(all, filters({ dayKey: '2026-07-02' }), ctx).map(e => e.id)).toEqual([21]);
|
|
// search is trimmed and case-insensitive
|
|
expect(filterBudgetItems(all, filters({ search: ' HOTEL ' }), ctx).map(e => e.id)).toEqual([20]);
|
|
expect(filterBudgetItems(all, filters({ search: 'nothing' }), ctx)).toEqual([]);
|
|
expect(
|
|
filterBudgetItems(all, filters({ segment: 'mine', categoryKey: 'accommodation' }), ctx).map(e => e.id),
|
|
).toEqual([20]);
|
|
});
|
|
|
|
it('FE-MOB-CMOD-016: groupByDay sorts newest first and sinks the no-date bucket', () => {
|
|
const a = expense({ id: 30, expense_date: '2026-07-01' });
|
|
const b = expense({ id: 31, expense_date: '2026-07-03' });
|
|
const c = expense({ id: 32, expense_date: null });
|
|
const d = expense({ id: 33, expense_date: '2026-07-01' });
|
|
|
|
expect(groupByDay([a, b, c, d])).toEqual([
|
|
{ dateKey: '2026-07-03', items: [b] },
|
|
{ dateKey: '2026-07-01', items: [a, d] },
|
|
{ dateKey: '', items: [c] },
|
|
]);
|
|
expect(groupByDay([])).toEqual([]);
|
|
});
|
|
|
|
it('FE-MOB-CMOD-026: groupByDay sinks the no-date bucket even when it comes first', () => {
|
|
const undated = expense({ id: 34, expense_date: null });
|
|
const mid = expense({ id: 35, expense_date: '2026-07-02' });
|
|
const late = expense({ id: 36, expense_date: '2026-07-04' });
|
|
|
|
expect(groupByDay([undated, mid, late]).map(g => g.dateKey)).toEqual(['2026-07-04', '2026-07-02', '']);
|
|
});
|
|
|
|
it('FE-MOB-CMOD-017: categoryFilterKeys lists only used categories, in canonical order', () => {
|
|
const items = [
|
|
expense({ category: 'food' }),
|
|
expense({ category: 'accommodation' }),
|
|
// legacy free-text value that catMeta folds into "flights"
|
|
expense({ category: 'Flight' }),
|
|
expense({ category: 'food' }),
|
|
];
|
|
expect(categoryFilterKeys(items)).toEqual(['accommodation', 'food', 'flights']);
|
|
expect(categoryFilterKeys([])).toEqual([]);
|
|
});
|
|
|
|
it('FE-MOB-CMOD-018: dayFilterKeys returns distinct dates ascending, ignoring undated rows', () => {
|
|
const items = [
|
|
expense({ expense_date: '2026-07-03' }),
|
|
expense({ expense_date: null }),
|
|
expense({ expense_date: '2026-07-01' }),
|
|
expense({ expense_date: '2026-07-01' }),
|
|
];
|
|
expect(dayFilterKeys(items)).toEqual(['2026-07-01', '2026-07-03']);
|
|
});
|
|
});
|
|
|
|
describe('costsModel — breakdown and presentation', () => {
|
|
it('FE-MOB-CMOD-019: categoryBreakdown sorts by amount and scales the bars against the largest', () => {
|
|
const items = [
|
|
expense({ category: 'accommodation', total_price: 300 }),
|
|
expense({ category: 'food', total_price: 100 }),
|
|
expense({ category: 'food', total_price: 200, currency: 'USD' }),
|
|
expense({ category: 'transport', total_price: 0 }),
|
|
];
|
|
const bars = categoryBreakdown(items, ctx);
|
|
|
|
expect(bars.map(b => b.key)).toEqual(['accommodation', 'food']);
|
|
expect(bars[0]).toEqual({ key: 'accommodation', amount: 300, widthPct: 100 });
|
|
expect(bars[1].amount).toBe(200);
|
|
expect(bars[1].widthPct).toBeCloseTo(66.667, 3);
|
|
});
|
|
|
|
it('FE-MOB-CMOD-020: categoryBreakdown drops categories without spend', () => {
|
|
expect(categoryBreakdown([], ctx)).toEqual([]);
|
|
expect(categoryBreakdown([expense({ category: 'food', total_price: 0 })], ctx)).toEqual([]);
|
|
});
|
|
|
|
it('FE-MOB-CMOD-021: tint turns a hex colour into rgba, with or without the leading hash', () => {
|
|
expect(tint('#8B5CF6', 0.3)).toBe('rgba(139, 92, 246, 0.3)');
|
|
expect(tint('000000', 1)).toBe('rgba(0, 0, 0, 1)');
|
|
});
|
|
});
|
|
|
|
describe('costsModel — CSV export', () => {
|
|
const t = (key: string) => key;
|
|
|
|
it('FE-MOB-CMOD-022: builds a semicolon CSV sorted by date with per-currency decimals', () => {
|
|
const items = [
|
|
expense({
|
|
id: 40,
|
|
name: 'Ryokan',
|
|
category: 'accommodation',
|
|
expense_date: '2026-07-16',
|
|
total_price: 1500,
|
|
currency: 'JPY',
|
|
note: 'paid cash',
|
|
}),
|
|
expense({
|
|
id: 41,
|
|
name: 'Coffee',
|
|
category: 'food',
|
|
expense_date: '2026-07-15',
|
|
total_price: 4.5,
|
|
currency: null,
|
|
note: null,
|
|
}),
|
|
];
|
|
|
|
const { filename, content } = buildCostsCsv(items, {
|
|
base: 'EUR',
|
|
ctx,
|
|
locale: 'en-US',
|
|
tripTitle: 'Trip: Tokyo/2026!',
|
|
t,
|
|
});
|
|
|
|
const rows = content.split('\r\n');
|
|
expect(rows[0]).toBe('Date;Name;Category;Amount;Currency;Amount (EUR);Note');
|
|
expect(rows[1]).toBe('07/15/2026;Coffee;costs.cat.food;4.50;EUR;4.50;');
|
|
// JPY is a zero-decimal currency, the base column stays at 2
|
|
expect(rows[2]).toBe('07/16/2026;Ryokan;costs.cat.accommodation;1500;JPY;15.00;paid cash');
|
|
expect(filename).toBe('costs-Trip Tokyo2026.csv');
|
|
});
|
|
|
|
it('FE-MOB-CMOD-023: escapes separators, quotes and newlines, and drops ticket JSON notes', () => {
|
|
const items = [
|
|
expense({
|
|
id: 42,
|
|
name: 'Dinner; drinks',
|
|
category: 'food',
|
|
expense_date: '',
|
|
total_price: 20,
|
|
note: 'say "hi"\nlater',
|
|
}),
|
|
expense({
|
|
id: 43,
|
|
name: 'Tickets',
|
|
category: 'activities',
|
|
expense_date: null,
|
|
total_price: 10,
|
|
note: 'TICKETJSON:{"items":[]}',
|
|
}),
|
|
];
|
|
|
|
const { content, filename } = buildCostsCsv(items, {
|
|
base: 'EUR',
|
|
ctx,
|
|
locale: 'en-US',
|
|
tripTitle: null,
|
|
t,
|
|
});
|
|
|
|
const rows = content.split('\r\n');
|
|
expect(rows[1]).toContain('"Dinner; drinks"');
|
|
expect(rows[1]).toContain('"say ""hi""\nlater"');
|
|
// undated rows keep an empty date cell
|
|
expect(rows[1].startsWith(';')).toBe(true);
|
|
expect(rows[2].endsWith(';')).toBe(true);
|
|
expect(filename).toBe('costs-trip.csv');
|
|
});
|
|
|
|
it('FE-MOB-CMOD-024: falls back to the raw ISO date when the locale is unusable', () => {
|
|
const { content } = buildCostsCsv(
|
|
[expense({ id: 44, name: 'Bus', category: 'transport', expense_date: '2026-07-16', total_price: 3 })],
|
|
{ base: 'EUR', ctx, locale: '!!', t },
|
|
);
|
|
expect(content.split('\r\n')[1].startsWith('2026-07-16;Bus;')).toBe(true);
|
|
});
|
|
|
|
it('FE-MOB-CMOD-027: an expense without a recorded total exports as zero', () => {
|
|
const bare = expense({ id: 45, name: 'Placeholder', category: 'other', expense_date: null });
|
|
const { content } = buildCostsCsv([{ ...bare, total_price: undefined } as unknown as BudgetItem], {
|
|
base: 'EUR',
|
|
ctx,
|
|
locale: 'en-US',
|
|
t,
|
|
});
|
|
expect(content.split('\r\n')[1]).toBe(';Placeholder;costs.cat.other;0.00;EUR;0.00;');
|
|
});
|
|
});
|