First version mostly built

This commit is contained in:
Deven Thiel 2026-03-05 17:04:52 -05:00
parent 27bb45f7df
commit 99a3dbd73c
42 changed files with 9443 additions and 3338 deletions

View file

@ -0,0 +1,99 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useAppStore } from '@/store/appStore';
describe('appStore — CRUD', () => {
beforeEach(() => {
useAppStore.setState({
data: {
workEntries: [],
payments: [],
expenses: [],
taxInputs: {},
dashboard: { charts: [], widgets: [] },
settings: { theme: 'standard', mode: 'dark', defaultRate: 50 },
version: 1,
},
vault: null,
});
});
it('addWorkEntry assigns id and timestamps', () => {
const e = useAppStore.getState().addWorkEntry({
date: '2024-01-01', description: 'code review', amount: 150,
});
expect(e.id).toBeTruthy();
expect(e.createdAt).toBeGreaterThan(0);
expect(useAppStore.getState().data.workEntries).toHaveLength(1);
});
it('updateWorkEntry patches fields', () => {
const e = useAppStore.getState().addWorkEntry({
date: '2024-01-01', description: 'old', amount: 100,
});
useAppStore.getState().updateWorkEntry(e.id, { description: 'new' });
const updated = useAppStore.getState().data.workEntries[0];
expect(updated.description).toBe('new');
expect(updated.amount).toBe(100);
expect(updated.updatedAt).toBeGreaterThanOrEqual(e.createdAt);
});
it('deleteWorkEntry removes it', () => {
const e = useAppStore.getState().addWorkEntry({
date: '2024-01-01', description: 'x', amount: 1,
});
useAppStore.getState().deleteWorkEntry(e.id);
expect(useAppStore.getState().data.workEntries).toHaveLength(0);
});
it('addPayment / addExpense follow same pattern', () => {
const p = useAppStore.getState().addPayment({
date: '2024-01-01', amount: 5000, payer: 'Acme',
});
const ex = useAppStore.getState().addExpense({
date: '2024-01-01', amount: 200, description: 'laptop', deductible: true,
});
expect(p.id).toBeTruthy();
expect(ex.id).toBeTruthy();
expect(useAppStore.getState().data.payments).toHaveLength(1);
expect(useAppStore.getState().data.expenses).toHaveLength(1);
});
it('mutations bump version counter', () => {
const v0 = useAppStore.getState().data.version;
useAppStore.getState().addWorkEntry({ date: '2024-01-01', description: 'x' });
expect(useAppStore.getState().data.version).toBe(v0 + 1);
});
it('setTaxInputs merges per-year', () => {
useAppStore.getState().setTaxInputs(2024, { priorYearAGI: 50000 });
useAppStore.getState().setTaxInputs(2024, { priorYearTax: 6000 });
const ti = useAppStore.getState().data.taxInputs[2024];
expect(ti.priorYearAGI).toBe(50000);
expect(ti.priorYearTax).toBe(6000);
expect(ti.filingStatus).toBe('single');
});
it('chart add/update/remove', () => {
useAppStore.getState().addChart({ title: 'test chart' });
const charts = useAppStore.getState().data.dashboard.charts;
expect(charts).toHaveLength(1);
const id = charts[0].id;
useAppStore.getState().updateChart(id, { type: 'bar' });
expect(useAppStore.getState().data.dashboard.charts[0].type).toBe('bar');
useAppStore.getState().removeChart(id);
expect(useAppStore.getState().data.dashboard.charts).toHaveLength(0);
});
it('setTheme updates both theme and mode', () => {
useAppStore.getState().setTheme('cyberpunk', 'light');
expect(useAppStore.getState().data.settings.theme).toBe('cyberpunk');
expect(useAppStore.getState().data.settings.mode).toBe('light');
});
it('setDefaultRate updates settings', () => {
useAppStore.getState().setDefaultRate(85);
expect(useAppStore.getState().data.settings.defaultRate).toBe(85);
});
});

View file

@ -0,0 +1,239 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { HierSpreadsheet } from '@/components/spreadsheet/HierSpreadsheet';
import { WorkEntryForm, ExpenseForm } from '@/components/spreadsheet/EntryForm';
import { Modal, ConfirmDialog } from '@/components/common/Modal';
import type { HierNode } from '@/types';
// ─── HierSpreadsheet ─────────────────────────────────────────────────────────
describe('HierSpreadsheet', () => {
const tree: HierNode[] = [
{
key: '2024', level: 'year', label: '2024', value: 1500,
children: [
{
key: '2024-03', level: 'month', label: 'March 2024', value: 1500,
children: [
{
key: '2024-03-15', level: 'day', label: 'Mar 15', value: 1500,
children: [
{ key: 'i1', level: 'item', label: 'Task A', value: 1000, children: [] },
{ key: 'i2', level: 'item', label: 'Task B', value: 500, children: [] },
],
},
],
},
],
},
];
it('shows only top-level rows by default', () => {
render(<HierSpreadsheet nodes={tree} valueLabel="Amount" />);
expect(screen.getByText('2024')).toBeInTheDocument();
expect(screen.queryByText('March 2024')).not.toBeInTheDocument();
});
it('expands row on click', async () => {
render(<HierSpreadsheet nodes={tree} valueLabel="Amount" />);
await userEvent.click(screen.getByText('2024'));
expect(screen.getByText('March 2024')).toBeInTheDocument();
});
it('collapses expanded row on second click', async () => {
render(<HierSpreadsheet nodes={tree} valueLabel="Amount" />);
await userEvent.click(screen.getByText('2024'));
expect(screen.getByText('March 2024')).toBeInTheDocument();
await userEvent.click(screen.getByText('2024'));
expect(screen.queryByText('March 2024')).not.toBeInTheDocument();
});
it('Item-level button expands entire tree', async () => {
render(<HierSpreadsheet nodes={tree} valueLabel="Amount" />);
await userEvent.click(screen.getByRole('button', { name: 'Item' }));
expect(screen.getByText('Task A')).toBeInTheDocument();
expect(screen.getByText('Task B')).toBeInTheDocument();
});
it('Year button collapses everything', async () => {
render(<HierSpreadsheet nodes={tree} valueLabel="Amount" />);
await userEvent.click(screen.getByRole('button', { name: 'Item' }));
await userEvent.click(screen.getByRole('button', { name: 'Year' }));
expect(screen.queryByText('Task A')).not.toBeInTheDocument();
});
it('Month button expands years but not days', async () => {
render(<HierSpreadsheet nodes={tree} valueLabel="Amount" />);
await userEvent.click(screen.getByRole('button', { name: 'Month' }));
expect(screen.getByText('March 2024')).toBeInTheDocument();
expect(screen.queryByText('Mar 15')).not.toBeInTheDocument();
});
it('displays grand total', () => {
render(<HierSpreadsheet nodes={tree} valueLabel="Amount" />);
expect(screen.getByText('Grand Total')).toBeInTheDocument();
expect(screen.getAllByText('$1,500.00').length).toBeGreaterThanOrEqual(1);
});
it('calls onEdit for item rows', async () => {
const onEdit = vi.fn();
render(<HierSpreadsheet nodes={tree} valueLabel="Amount" onEdit={onEdit} />);
await userEvent.click(screen.getByRole('button', { name: 'Item' }));
const editBtns = screen.getAllByTitle('Edit');
await userEvent.click(editBtns[0]);
expect(onEdit).toHaveBeenCalledTimes(1);
});
it('shows empty state', () => {
render(<HierSpreadsheet nodes={[]} valueLabel="Amount" />);
expect(screen.getByText(/No entries yet/)).toBeInTheDocument();
});
});
// ─── WorkEntryForm ───────────────────────────────────────────────────────────
describe('WorkEntryForm', () => {
const numberInputs = (container: HTMLElement) =>
Array.from(container.querySelectorAll('input[type="number"]')) as HTMLInputElement[];
it('submits flat amount', async () => {
const onSubmit = vi.fn();
const { container } = render(<WorkEntryForm defaultRate={50} onSubmit={onSubmit} onCancel={() => {}} />);
await userEvent.type(screen.getByPlaceholderText(/What did you work/), 'Code review');
const [amountInput] = numberInputs(container);
await userEvent.type(amountInput, '150');
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
description: 'Code review',
amount: 150,
}));
expect(onSubmit.mock.calls[0][0].hours).toBeUndefined();
});
it('submits hours × rate', async () => {
const onSubmit = vi.fn();
const { container } = render(<WorkEntryForm defaultRate={75} onSubmit={onSubmit} onCancel={() => {}} />);
await userEvent.click(screen.getByRole('button', { name: /Time × rate/ }));
await userEvent.type(screen.getByPlaceholderText(/What did you work/), 'Dev work');
const [hoursInput] = numberInputs(container);
await userEvent.type(hoursInput, '4');
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
hours: 4,
rate: 75,
}));
expect(onSubmit.mock.calls[0][0].amount).toBeUndefined();
});
it('shows computed value preview for time mode', async () => {
const { container } = render(<WorkEntryForm defaultRate={50} onSubmit={() => {}} onCancel={() => {}} />);
await userEvent.click(screen.getByRole('button', { name: /Time × rate/ }));
const [hoursInput] = numberInputs(container);
await userEvent.type(hoursInput, '3');
expect(screen.getByText(/\$150\.00/)).toBeInTheDocument();
});
it('pre-fills from initial data', () => {
render(
<WorkEntryForm
initial={{ date: '2024-03-15', description: 'existing', amount: 200 }}
defaultRate={50}
onSubmit={() => {}}
onCancel={() => {}}
/>,
);
expect(screen.getByDisplayValue('existing')).toBeInTheDocument();
expect(screen.getByDisplayValue('200')).toBeInTheDocument();
});
});
// ─── ExpenseForm ─────────────────────────────────────────────────────────────
describe('ExpenseForm', () => {
it('includes deductible checkbox defaulting to true', () => {
render(<ExpenseForm onSubmit={() => {}} onCancel={() => {}} />);
const cb = screen.getByRole('checkbox');
expect(cb).toBeChecked();
});
it('submits with deductible toggled off', async () => {
const onSubmit = vi.fn();
const { container } = render(<ExpenseForm onSubmit={onSubmit} onCancel={() => {}} />);
const textInputs = container.querySelectorAll('input:not([type="date"]):not([type="number"]):not([type="checkbox"])');
const numInputs = container.querySelectorAll('input[type="number"]');
await userEvent.type(textInputs[0] as HTMLInputElement, 'coffee');
await userEvent.type(numInputs[0] as HTMLInputElement, '5');
await userEvent.click(screen.getByRole('checkbox'));
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
description: 'coffee',
amount: 5,
deductible: false,
}));
});
});
// ─── Modal ───────────────────────────────────────────────────────────────────
describe('Modal', () => {
it('renders when open', () => {
render(<Modal open title="Test" onClose={() => {}}>body</Modal>);
expect(screen.getByText('Test')).toBeInTheDocument();
expect(screen.getByText('body')).toBeInTheDocument();
});
it('does not render when closed', () => {
render(<Modal open={false} title="Hidden" onClose={() => {}}>body</Modal>);
expect(screen.queryByText('Hidden')).not.toBeInTheDocument();
});
it('closes on Escape key', () => {
const onClose = vi.fn();
render(<Modal open title="Test" onClose={onClose}>body</Modal>);
fireEvent.keyDown(window, { key: 'Escape' });
expect(onClose).toHaveBeenCalled();
});
it('closes on overlay click but not content click', () => {
const onClose = vi.fn();
render(<Modal open title="Test" onClose={onClose}>body</Modal>);
fireEvent.click(screen.getByText('body'));
expect(onClose).not.toHaveBeenCalled();
});
});
describe('ConfirmDialog', () => {
it('calls onConfirm when confirmed', async () => {
const onConfirm = vi.fn();
const onCancel = vi.fn();
render(
<ConfirmDialog
open
title="Sure?"
message="really?"
confirmLabel="Yes"
onConfirm={onConfirm}
onCancel={onCancel}
/>,
);
await userEvent.click(screen.getByRole('button', { name: 'Yes' }));
expect(onConfirm).toHaveBeenCalled();
expect(onCancel).not.toHaveBeenCalled();
});
it('calls onCancel when cancelled', async () => {
const onConfirm = vi.fn();
const onCancel = vi.fn();
render(
<ConfirmDialog open title="t" message="m" onConfirm={onConfirm} onCancel={onCancel} />,
);
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onCancel).toHaveBeenCalled();
expect(onConfirm).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest';
import { encrypt, decrypt, verifyPassword } from '@/lib/crypto/encryption';
describe('encryption', () => {
it('encrypts and decrypts round-trip', async () => {
const plaintext = JSON.stringify({ hello: 'world', n: 42 });
const ct = await encrypt(plaintext, 'my-password');
expect(ct).not.toContain('hello');
expect(ct).not.toBe(plaintext);
const back = await decrypt(ct, 'my-password');
expect(back).toBe(plaintext);
});
it('throws on wrong password', async () => {
const ct = await encrypt('secret data', 'correct-password');
await expect(decrypt(ct, 'wrong-password')).rejects.toThrow();
});
it('produces different ciphertext each time (random salt+iv)', async () => {
const ct1 = await encrypt('same plaintext', 'same-password');
const ct2 = await encrypt('same plaintext', 'same-password');
expect(ct1).not.toBe(ct2);
// But both decrypt to the same thing
expect(await decrypt(ct1, 'same-password')).toBe(await decrypt(ct2, 'same-password'));
});
it('handles unicode content', async () => {
const text = '日本語テスト 🎉 émojis';
const ct = await encrypt(text, 'pw');
expect(await decrypt(ct, 'pw')).toBe(text);
});
it('handles large payloads', async () => {
const big = 'x'.repeat(100_000);
const ct = await encrypt(big, 'pw');
expect(await decrypt(ct, 'pw')).toBe(big);
});
it('verifyPassword returns true for correct password', async () => {
const ct = await encrypt('data', 'correct');
expect(await verifyPassword(ct, 'correct')).toBe(true);
});
it('verifyPassword returns false for wrong password (no throw)', async () => {
const ct = await encrypt('data', 'correct');
expect(await verifyPassword(ct, 'wrong')).toBe(false);
});
it('output is valid base64', async () => {
const ct = await encrypt('test', 'pw');
expect(() => atob(ct)).not.toThrow();
});
});

View file

@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest';
import { fmtMoney, fmtDuration, fmtDurationVerbose, totalMinutes, msToHours } from '@/lib/format';
describe('fmtMoney', () => {
it('formats dollars with 2 decimals', () => {
expect(fmtMoney(1234.5)).toBe('$1,234.50');
expect(fmtMoney(0)).toBe('$0.00');
});
it('handles null/undefined', () => {
expect(fmtMoney(null)).toBe('—');
expect(fmtMoney(undefined)).toBe('—');
});
it('handles negative values', () => {
expect(fmtMoney(-500)).toContain('500');
});
});
describe('fmtDuration', () => {
it('formats H:MM:SS', () => {
expect(fmtDuration(0)).toBe('0:00:00');
expect(fmtDuration(65_000)).toBe('0:01:05');
expect(fmtDuration(3_661_000)).toBe('1:01:01'); // 1h 1m 1s
expect(fmtDuration(10 * 3_600_000)).toBe('10:00:00');
});
it('floors fractional seconds', () => {
expect(fmtDuration(1_999)).toBe('0:00:01');
});
});
describe('fmtDurationVerbose', () => {
it('formats Xh Xm Xs', () => {
expect(fmtDurationVerbose(3_661_000)).toBe('1h 1m 1s');
expect(fmtDurationVerbose(0)).toBe('0h 0m 0s');
});
});
describe('totalMinutes', () => {
it('computes h*60 + m ignoring seconds', () => {
expect(totalMinutes(3_661_000)).toBe(61); // 1h 1m 1s → 61 min
expect(totalMinutes(59_000)).toBe(0); // 59s → 0 min
expect(totalMinutes(90 * 60_000)).toBe(90); // 90 min
});
});
describe('msToHours', () => {
it('converts milliseconds to decimal hours', () => {
expect(msToHours(3_600_000)).toBe(1);
expect(msToHours(1_800_000)).toBe(0.5);
expect(msToHours(5_400_000)).toBe(1.5);
});
});

50
src/__tests__/setup.ts Normal file
View file

@ -0,0 +1,50 @@
import '@testing-library/jest-dom';
import { webcrypto } from 'node:crypto';
import { beforeEach } from 'vitest';
// jsdom lacks WebCrypto subtle — polyfill from Node
if (!globalThis.crypto?.subtle) {
Object.defineProperty(globalThis, 'crypto', { value: webcrypto });
}
// jsdom lacks ResizeObserver (needed by Recharts)
class ResizeObserverMock {
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver;
// structuredClone polyfill for older jsdom
if (!globalThis.structuredClone) {
globalThis.structuredClone = (o: unknown) => JSON.parse(JSON.stringify(o));
}
// Silence "not implemented: navigation" warnings from jsdom
const originalWarn = console.warn;
console.warn = (...args: unknown[]) => {
if (typeof args[0] === 'string' && args[0].includes('Not implemented')) return;
originalWarn(...args);
};
// jsdom 25.x backs localStorage with a file store that lacks clear().
// Replace it with a plain in-memory implementation for tests.
const store: Record<string, string> = {};
const localStorageMock: Storage = {
getItem: (k) => store[k] ?? null,
setItem: (k, v) => { store[k] = String(v); },
removeItem: (k) => { delete store[k]; },
clear: () => { Object.keys(store).forEach((k) => delete store[k]); },
get length() { return Object.keys(store).length; },
key: (i) => Object.keys(store)[i] ?? null,
};
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true });
// Reset cookies and localStorage between tests
beforeEach(() => {
document.cookie.split(';').forEach((c) => {
const name = c.split('=')[0].trim();
if (name) document.cookie = `${name}=; max-age=0; path=/`;
});
localStorage.clear();
});

256
src/__tests__/stats.test.ts Normal file
View file

@ -0,0 +1,256 @@
import { describe, it, expect } from 'vitest';
import { aggregate, buildHierarchy, buildChartSeries } from '@/lib/stats/aggregate';
import { workEntryValue } from '@/types';
import type { WorkEntry, Payment, Expense } from '@/types';
const mkWork = (date: string, amount?: number, hours?: number, rate?: number): WorkEntry => ({
id: `w-${date}-${amount ?? hours}`, date,
description: 'work', amount, hours, rate,
createdAt: 0, updatedAt: 0,
});
const mkPayment = (date: string, amount: number): Payment => ({
id: `p-${date}-${amount}`, date, amount, payer: 'X', createdAt: 0, updatedAt: 0,
});
const mkExpense = (date: string, amount: number, deductible = true): Expense => ({
id: `e-${date}-${amount}`, date, amount, description: 'exp', deductible,
createdAt: 0, updatedAt: 0,
});
describe('workEntryValue', () => {
it('returns amount when set', () => {
expect(workEntryValue(mkWork('2024-01-01', 150))).toBe(150);
});
it('returns hours * rate when amount not set', () => {
expect(workEntryValue(mkWork('2024-01-01', undefined, 4, 50))).toBe(200);
});
it('amount takes precedence over hours/rate', () => {
const e = mkWork('2024-01-01', 100, 10, 50);
expect(workEntryValue(e)).toBe(100);
});
it('returns 0 when neither set', () => {
expect(workEntryValue(mkWork('2024-01-01'))).toBe(0);
});
});
describe('aggregate', () => {
it('rolls up days → months → years', () => {
const agg = aggregate(
[],
[mkPayment('2024-01-15', 1000), mkPayment('2024-01-20', 500), mkPayment('2024-02-10', 300)],
[],
);
expect(agg.days.get('2024-01-15')?.payments).toBe(1000);
expect(agg.months.get('2024-01')?.payments).toBe(1500);
expect(agg.months.get('2024-02')?.payments).toBe(300);
expect(agg.years.find((y) => y.label === '2024')?.payments).toBe(1800);
});
it('computes net = payments expenses', () => {
const agg = aggregate(
[],
[mkPayment('2024-03-01', 5000)],
[mkExpense('2024-03-01', 1200)],
);
expect(agg.days.get('2024-03-01')?.net).toBe(3800);
expect(agg.months.get('2024-03')?.net).toBe(3800);
});
it('separates deductible from total expenses', () => {
const agg = aggregate(
[],
[],
[mkExpense('2024-03-01', 100, true), mkExpense('2024-03-01', 50, false)],
);
const d = agg.days.get('2024-03-01')!;
expect(d.expenses).toBe(150);
expect(d.deductibleExpenses).toBe(100);
});
it('computes monthly average per active day', () => {
const agg = aggregate(
[],
[mkPayment('2024-03-01', 100), mkPayment('2024-03-10', 200)],
[],
);
// 2 active days, 300 total → 150 avg
expect(agg.months.get('2024-03')?.avgPerChild).toBe(150);
expect(agg.months.get('2024-03')?.childCount).toBe(2);
});
it('computes yearly average per active month', () => {
const agg = aggregate(
[],
[mkPayment('2024-01-05', 1000), mkPayment('2024-03-05', 2000)],
[],
);
const y = agg.years.find((y) => y.label === '2024')!;
// 2 months active, 3000 net → 1500 avg/month
expect(y.childCount).toBe(2);
expect(y.avgPerChild).toBe(1500);
});
it('projects year-end from YTD (halfway through year)', () => {
const agg = aggregate(
[],
[mkPayment('2024-01-01', 50000)],
[],
new Date('2024-07-01'), // ~halfway
);
const y = agg.years.find((y) => y.label === '2024')!;
expect(y.projected).toBeGreaterThan(90000);
expect(y.projected).toBeLessThan(110000);
});
it('does not project completed years', () => {
const agg = aggregate([], [mkPayment('2023-06-01', 1000)], [], new Date('2024-06-01'));
const y = agg.years.find((y) => y.label === '2023')!;
expect(y.projected).toBeNull();
});
it('projects month-end from current day', () => {
const agg = aggregate(
[],
[mkPayment('2024-06-10', 1000)],
[],
new Date('2024-06-15'), // halfway through June
);
const m = agg.months.get('2024-06')!;
expect(m.projected).toBeGreaterThan(1500);
expect(m.projected).toBeLessThan(2500);
});
it('sorts years descending', () => {
const agg = aggregate(
[],
[mkPayment('2022-01-01', 1), mkPayment('2024-01-01', 1), mkPayment('2023-01-01', 1)],
[],
);
expect(agg.years.map((y) => y.label)).toEqual(['2024', '2023', '2022']);
});
});
describe('buildHierarchy', () => {
it('builds year→month→day→item tree', () => {
const work = [
mkWork('2024-03-15', 100),
mkWork('2024-03-15', 50),
mkWork('2024-04-01', 200),
];
const tree = buildHierarchy(work, (e) => workEntryValue(e as WorkEntry), (e) => (e as WorkEntry).description);
expect(tree).toHaveLength(1); // one year
expect(tree[0].level).toBe('year');
expect(tree[0].value).toBe(350);
expect(tree[0].children).toHaveLength(2); // two months
const march = tree[0].children.find((m) => m.key === '2024-03')!;
expect(march.value).toBe(150);
expect(march.children).toHaveLength(1); // one day
expect(march.children[0].children).toHaveLength(2); // two items
});
it('attaches entry to item leaves', () => {
const work = [mkWork('2024-01-01', 100)];
const tree = buildHierarchy(work, (e) => workEntryValue(e as WorkEntry), () => 'label');
const item = tree[0].children[0].children[0].children[0];
expect(item.level).toBe('item');
expect(item.entry).toBe(work[0]);
});
it('sorts years, months, days descending (newest first)', () => {
const work = [mkWork('2023-01-01', 1), mkWork('2024-01-01', 1)];
const tree = buildHierarchy(work, (e) => workEntryValue(e as WorkEntry), () => '');
expect(tree[0].key).toBe('2024');
expect(tree[1].key).toBe('2023');
});
});
describe('buildChartSeries', () => {
it('produces points at requested granularity', () => {
const series = buildChartSeries(
[],
[mkPayment('2024-01-15', 100), mkPayment('2024-02-10', 200)],
[],
['payments'],
'month',
null,
null,
);
expect(series).toHaveLength(2);
expect(series[0].payments).toBe(100);
expect(series[1].payments).toBe(200);
});
it('filters by date range', () => {
const series = buildChartSeries(
[],
[mkPayment('2024-01-01', 1), mkPayment('2024-06-01', 1), mkPayment('2024-12-01', 1)],
[],
['payments'],
'month',
'2024-03',
'2024-09',
);
expect(series).toHaveLength(1);
expect(series[0].label).toBe('2024-06');
});
it('computes cumulative metrics', () => {
const series = buildChartSeries(
[],
[mkPayment('2024-01-01', 100), mkPayment('2024-02-01', 200), mkPayment('2024-03-01', 300)],
[],
['cumulativePayments'],
'month',
null,
null,
);
expect(series[0].cumulativePayments).toBe(100);
expect(series[1].cumulativePayments).toBe(300);
expect(series[2].cumulativePayments).toBe(600);
});
it('sorts ascending for time-series display', () => {
const series = buildChartSeries(
[],
[mkPayment('2024-03-01', 1), mkPayment('2024-01-01', 1)],
[],
['payments'],
'month',
null,
null,
);
expect(series[0].label < series[1].label).toBe(true);
});
it('groups by week when requested', () => {
const series = buildChartSeries(
[],
// Monday and Tuesday of same week
[mkPayment('2024-06-03', 100), mkPayment('2024-06-04', 50)],
[],
['payments'],
'week',
null,
null,
);
expect(series).toHaveLength(1);
expect(series[0].payments).toBe(150);
});
it('computes netIncome metric', () => {
const series = buildChartSeries(
[],
[mkPayment('2024-01-01', 1000)],
[mkExpense('2024-01-01', 300)],
['netIncome'],
'month',
null,
null,
);
expect(series[0].netIncome).toBe(700);
});
});

View file

@ -0,0 +1,100 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { CookieStorage } from '@/lib/storage/adapters';
import { Vault } from '@/lib/storage/vault';
import type { AppData } from '@/types';
describe('CookieStorage', () => {
it('saves and loads a small blob', async () => {
const cs = new CookieStorage('testuser');
await cs.save('hello-world-encrypted-blob');
const loaded = await cs.load();
expect(loaded).toBe('hello-world-encrypted-blob');
});
it('chunks large blobs across multiple cookies', async () => {
const cs = new CookieStorage('big');
const big = 'A'.repeat(10000); // > chunk size
await cs.save(big);
const loaded = await cs.load();
expect(loaded).toBe(big);
});
it('isolates namespaces', async () => {
const alice = new CookieStorage('alice');
const bob = new CookieStorage('bob');
await alice.save('alice-data');
await bob.save('bob-data');
expect(await alice.load()).toBe('alice-data');
expect(await bob.load()).toBe('bob-data');
});
it('returns null when nothing stored', async () => {
const cs = new CookieStorage('empty');
expect(await cs.load()).toBeNull();
});
it('clear removes all chunks', async () => {
const cs = new CookieStorage('clearme');
await cs.save('some-data-here');
await cs.clear();
expect(await cs.load()).toBeNull();
});
it('overwrite clears old chunks (shrinking data)', async () => {
const cs = new CookieStorage('shrink');
await cs.save('X'.repeat(10000)); // many chunks
await cs.save('small'); // one chunk
expect(await cs.load()).toBe('small');
});
});
describe('Vault', () => {
const mkData = (): AppData => ({
workEntries: [{
id: '1', date: '2024-01-01', description: 'test', amount: 100,
createdAt: 0, updatedAt: 0,
}],
payments: [],
expenses: [],
taxInputs: {},
dashboard: { charts: [], widgets: [] },
settings: { theme: 'standard', mode: 'dark', storageMode: 'cookie', defaultRate: 50 },
version: 1,
});
it('encrypts on save and decrypts on load (cookie mode)', async () => {
const v = new Vault({ mode: 'cookie', username: 'u1', password: 'supersecret' });
const data = mkData();
await v.save(data);
// Verify cookie content is NOT plaintext
expect(document.cookie).not.toContain('test');
expect(document.cookie).not.toContain('workEntries');
const loaded = await v.load();
expect(loaded).toEqual(data);
});
it('wrong password fails to load', async () => {
const v1 = new Vault({ mode: 'cookie', username: 'u2', password: 'correct' });
await v1.save(mkData());
const v2 = new Vault({ mode: 'cookie', username: 'u2', password: 'wrong' });
await expect(v2.load()).rejects.toThrow();
});
it('different users see different data', async () => {
const va = new Vault({ mode: 'cookie', username: 'alice', password: 'pw' });
const vb = new Vault({ mode: 'cookie', username: 'bob', password: 'pw' });
const dataA = mkData();
dataA.workEntries[0].description = 'alice-work';
const dataB = mkData();
dataB.workEntries[0].description = 'bob-work';
await va.save(dataA);
await vb.save(dataB);
expect((await va.load())?.workEntries[0].description).toBe('alice-work');
expect((await vb.load())?.workEntries[0].description).toBe('bob-work');
});
});

282
src/__tests__/tax.test.ts Normal file
View file

@ -0,0 +1,282 @@
import { describe, it, expect } from 'vitest';
import { calculateTax } from '@/lib/tax/calculate';
import { applyBrackets, getTaxYearData } from '@/lib/tax/brackets';
import type { Payment, Expense, TaxInputs } from '@/types';
const mkPayment = (date: string, amount: number): Payment => ({
id: 'p', date, amount, payer: 'Client', createdAt: 0, updatedAt: 0,
});
const mkExpense = (date: string, amount: number, deductible = true): Expense => ({
id: 'e', date, amount, description: 'exp', deductible, createdAt: 0, updatedAt: 0,
});
describe('applyBrackets', () => {
it('returns 0 for zero/negative income', () => {
const b = getTaxYearData(2024).brackets.single;
expect(applyBrackets(b, 0)).toBe(0);
expect(applyBrackets(b, -100)).toBe(0);
});
it('applies 10% bracket correctly (2024 single)', () => {
const b = getTaxYearData(2024).brackets.single;
expect(applyBrackets(b, 10000)).toBeCloseTo(1000, 2);
});
it('applies across multiple brackets (2024 single, $50k taxable)', () => {
const b = getTaxYearData(2024).brackets.single;
// 11600 * 0.10 + (47150 - 11600) * 0.12 + (50000 - 47150) * 0.22
const expected = 11600 * 0.10 + 35550 * 0.12 + 2850 * 0.22;
expect(applyBrackets(b, 50000)).toBeCloseTo(expected, 2);
});
it('handles top bracket (infinity)', () => {
const b = getTaxYearData(2024).brackets.single;
const tax = applyBrackets(b, 1_000_000);
expect(tax).toBeGreaterThan(300_000);
expect(Number.isFinite(tax)).toBe(true);
});
});
describe('getTaxYearData', () => {
it('returns exact year data when available', () => {
expect(getTaxYearData(2024).year).toBe(2024);
expect(getTaxYearData(2025).year).toBe(2025);
});
it('falls back to closest year for unknown years', () => {
expect(getTaxYearData(2030).year).toBe(2025);
expect(getTaxYearData(2020).year).toBe(2024);
});
it('provides 4 quarterly due dates', () => {
expect(getTaxYearData(2024).quarterlyDueDates).toHaveLength(4);
});
});
describe('calculateTax — core SE tax', () => {
const baseInputs: TaxInputs = { taxYear: 2024, filingStatus: 'single' };
it('zero income → zero tax', () => {
const r = calculateTax([], [], baseInputs);
expect(r.totalFederalTax).toBe(0);
expect(r.netProfit).toBe(0);
expect(r.totalSETax).toBe(0);
});
it('$50k net profit, single, 2024 — SE tax math', () => {
const r = calculateTax([mkPayment('2024-03-01', 50000)], [], baseInputs);
// SE base: 50000 * 0.9235 = 46175
expect(r.seTaxableBase).toBeCloseTo(46175, 2);
// SS: 46175 * 0.124 = 5725.70
expect(r.socialSecurityTax).toBeCloseTo(5725.70, 2);
// Medicare: 46175 * 0.029 = 1339.08 (rounds to 1339.08)
expect(r.medicareTax).toBeCloseTo(1339.08, 1);
// Total SE: ~7064.78
expect(r.totalSETax).toBeCloseTo(7064.78, 1);
// SE deduction = half of (SS + Medicare)
expect(r.seTaxDeduction).toBeCloseTo(3532.39, 1);
});
it('deductible expenses reduce net profit', () => {
const r = calculateTax(
[mkPayment('2024-03-01', 50000)],
[mkExpense('2024-02-01', 10000)],
baseInputs,
);
expect(r.netProfit).toBe(40000);
});
it('non-deductible expenses do NOT reduce net profit', () => {
const r = calculateTax(
[mkPayment('2024-03-01', 50000)],
[mkExpense('2024-02-01', 10000, false)],
baseInputs,
);
expect(r.netProfit).toBe(50000);
});
it('filters payments by tax year', () => {
const r = calculateTax(
[mkPayment('2023-12-31', 99999), mkPayment('2024-01-01', 1000)],
[],
baseInputs,
);
expect(r.grossReceipts).toBe(1000);
});
it('Social Security tax capped at wage base', () => {
const r = calculateTax([mkPayment('2024-01-01', 500000)], [], baseInputs);
// 500k * 0.9235 = 461750, but SS only applies to first 168600
expect(r.socialSecurityTax).toBeCloseTo(168600 * 0.124, 2);
// Medicare has no cap
expect(r.medicareTax).toBeCloseTo(461750 * 0.029, 1);
});
it('W-2 wages reduce SS room (combined cap)', () => {
const r = calculateTax(
[mkPayment('2024-01-01', 100000)],
[],
{ ...baseInputs, w2Wages: 168600 }, // already hit SS cap via W-2
);
expect(r.socialSecurityTax).toBe(0);
// Medicare still applies
expect(r.medicareTax).toBeGreaterThan(0);
});
it('Additional Medicare kicks in above threshold', () => {
const low = calculateTax([mkPayment('2024-01-01', 100000)], [], baseInputs);
expect(low.additionalMedicareTax).toBe(0);
const high = calculateTax([mkPayment('2024-01-01', 300000)], [], baseInputs);
expect(high.additionalMedicareTax).toBeGreaterThan(0);
});
it('Standard deduction applied (2024 single = $14,600)', () => {
const r = calculateTax([mkPayment('2024-01-01', 30000)], [], baseInputs);
expect(r.standardDeduction).toBe(14600);
});
it('QBI deduction reduces taxable income', () => {
const r = calculateTax([mkPayment('2024-01-01', 80000)], [], baseInputs);
expect(r.qbiDeduction).toBeGreaterThan(0);
// QBI capped at 20% of QBI
const qbiBase = r.netProfit - r.seTaxDeduction;
expect(r.qbiDeduction).toBeLessThanOrEqual(qbiBase * 0.20 + 0.01);
});
it('QBI phases out for high earners', () => {
// Way above phaseout end
const r = calculateTax([mkPayment('2024-01-01', 500000)], [], baseInputs);
expect(r.qbiDeduction).toBe(0);
});
it('Married Filing Jointly uses MFJ brackets & deduction', () => {
const r = calculateTax([mkPayment('2024-01-01', 50000)], [], {
taxYear: 2024,
filingStatus: 'mfj',
});
expect(r.standardDeduction).toBe(29200);
});
});
describe('calculateTax — safe harbor & quarterly', () => {
const baseInputs: TaxInputs = { taxYear: 2024, filingStatus: 'single' };
it('prompts for prior year data when missing', () => {
const r = calculateTax([mkPayment('2024-01-01', 50000)], [], baseInputs);
const fields = r.prompts.map((p) => p.field);
expect(fields).toContain('priorYearTax');
expect(fields).toContain('priorYearAGI');
});
it('no safe harbor when prior year data missing', () => {
const r = calculateTax([mkPayment('2024-01-01', 50000)], [], baseInputs);
expect(r.safeHarborAmount).toBeNull();
expect(r.safeHarborMet).toBeNull();
});
it('computes 100% safe harbor for normal AGI', () => {
const r = calculateTax([mkPayment('2024-01-01', 100000)], [], {
...baseInputs,
priorYearTax: 8000,
priorYearAGI: 60000,
});
// Safe harbor = min(100% of prior tax, 90% of current)
// Prior: 8000 * 1.00 = 8000
// Current 90% will be higher, so safe harbor = 8000
expect(r.safeHarborAmount).toBe(8000);
});
it('computes 110% safe harbor for high prior AGI', () => {
const r = calculateTax([mkPayment('2024-01-01', 200000)], [], {
...baseInputs,
priorYearTax: 10000,
priorYearAGI: 200000, // > 150k
});
// 110% rule: 10000 * 1.10 = 11000
expect(r.safeHarborAmount).toBeLessThanOrEqual(11000);
expect(r.notes.some((n) => n.includes('110%'))).toBe(true);
});
it('safeHarborMet true when already paid enough', () => {
const r = calculateTax([mkPayment('2024-01-01', 50000)], [], {
...baseInputs,
priorYearTax: 5000,
priorYearAGI: 40000,
estimatedPaymentsMade: 5000,
});
expect(r.safeHarborMet).toBe(true);
});
it('safeHarborMet false when underpaid', () => {
const r = calculateTax([mkPayment('2024-01-01', 50000)], [], {
...baseInputs,
priorYearTax: 5000,
priorYearAGI: 40000,
estimatedPaymentsMade: 100,
});
expect(r.safeHarborMet).toBe(false);
});
it('produces 4 quarterly payments summing to annual liability', () => {
const r = calculateTax([mkPayment('2024-01-01', 100000)], [], baseInputs);
expect(r.quarterlySchedule).toHaveLength(4);
const sum = r.quarterlySchedule.reduce((s, q) => s + q.projectedAmount, 0);
expect(sum).toBeCloseTo(r.totalFederalTax, 0);
});
it('marks past-due quarters correctly', () => {
const r = calculateTax([mkPayment('2024-01-01', 50000)], [], baseInputs, {
asOf: new Date('2024-07-01'),
});
// Q1 (Apr 15) and Q2 (Jun 17) should be past due
expect(r.quarterlySchedule[0].isPastDue).toBe(true);
expect(r.quarterlySchedule[1].isPastDue).toBe(true);
expect(r.quarterlySchedule[2].isPastDue).toBe(false);
expect(r.quarterlySchedule[3].isPastDue).toBe(false);
});
it('withholding reduces quarterly need', () => {
const withW2 = calculateTax([mkPayment('2024-01-01', 50000)], [], {
...baseInputs,
federalWithholding: 5000,
});
const without = calculateTax([mkPayment('2024-01-01', 50000)], [], baseInputs);
expect(withW2.quarterlySchedule[0].projectedAmount).toBeLessThan(
without.quarterlySchedule[0].projectedAmount,
);
});
it('notes <$1000 threshold exemption', () => {
const r = calculateTax([mkPayment('2024-01-01', 3000)], [], baseInputs);
expect(r.notes.some((n) => n.includes('$1,000'))).toBe(true);
});
});
describe('calculateTax — projection mode', () => {
it('scales YTD to full year when project=true', () => {
// Halfway through 2024 with $50k → project $100k
const actual = calculateTax([mkPayment('2024-01-15', 50000)], [], {
taxYear: 2024,
filingStatus: 'single',
}, {
asOf: new Date('2024-07-01'),
project: true,
});
expect(actual.grossReceipts).toBeGreaterThan(90000);
expect(actual.grossReceipts).toBeLessThan(110000);
expect(actual.notes.some((n) => n.includes('Projected'))).toBe(true);
});
it('does not project past years', () => {
const r = calculateTax([mkPayment('2024-06-01', 50000)], [], {
taxYear: 2024,
filingStatus: 'single',
}, {
asOf: new Date('2025-06-01'),
project: true,
});
expect(r.grossReceipts).toBe(50000);
});
});

View file

@ -0,0 +1,245 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { useTimerStore } from '@/store/timerStore';
describe('timerStore', () => {
beforeEach(() => {
vi.useFakeTimers();
useTimerStore.setState({
currentRate: 50,
running: false,
runStartedAt: null,
accumulatedMs: 0,
splits: [],
lastHeartbeat: Date.now(),
elapsedMs: 0,
crashRecovery: null,
});
});
afterEach(() => {
vi.useRealTimers();
});
it('starts and tracks elapsed time', () => {
const s = useTimerStore.getState();
s.start();
expect(useTimerStore.getState().running).toBe(true);
vi.advanceTimersByTime(5000);
useTimerStore.getState()._tick();
expect(useTimerStore.getState().elapsedMs).toBeGreaterThanOrEqual(5000);
});
it('pause freezes elapsed and stops ticking', () => {
useTimerStore.getState().start();
vi.advanceTimersByTime(3000);
useTimerStore.getState().pause();
const frozen = useTimerStore.getState().elapsedMs;
expect(useTimerStore.getState().running).toBe(false);
vi.advanceTimersByTime(10000);
expect(useTimerStore.getState().elapsedMs).toBe(frozen);
});
it('resume after pause accumulates correctly', () => {
useTimerStore.getState().start();
vi.advanceTimersByTime(2000);
useTimerStore.getState().pause();
vi.advanceTimersByTime(5000); // paused time doesn't count
useTimerStore.getState().start();
vi.advanceTimersByTime(3000);
useTimerStore.getState()._tick();
const elapsed = useTimerStore.getState().elapsedMs;
expect(elapsed).toBeGreaterThanOrEqual(5000);
expect(elapsed).toBeLessThan(7000); // not 10000
});
it('split records current segment and resets clock', () => {
useTimerStore.getState().start();
vi.advanceTimersByTime(4000);
const split = useTimerStore.getState().split('task A');
expect(split.elapsedMs).toBeGreaterThanOrEqual(4000);
expect(split.rate).toBe(50);
expect(split.label).toBe('task A');
expect(split.recorded).toBe(false);
expect(useTimerStore.getState().splits).toHaveLength(1);
// Clock resets but keeps running
expect(useTimerStore.getState().accumulatedMs).toBe(0);
expect(useTimerStore.getState().running).toBe(true);
});
it('split preserves rate at time of split (rate change after doesn\'t affect)', () => {
useTimerStore.getState().start();
vi.advanceTimersByTime(1000);
const split = useTimerStore.getState().split();
expect(split.rate).toBe(50);
useTimerStore.getState().setRate(100);
expect(useTimerStore.getState().splits[0].rate).toBe(50);
expect(useTimerStore.getState().currentRate).toBe(100);
});
it('setRate only affects live clock, not existing splits', () => {
useTimerStore.getState().start();
vi.advanceTimersByTime(1000);
useTimerStore.getState().split();
useTimerStore.getState().split();
useTimerStore.getState().setRate(999);
expect(useTimerStore.getState().splits.every((s) => s.rate === 50)).toBe(true);
});
it('markRecorded sets flag and stores work entry id', () => {
useTimerStore.getState().start();
vi.advanceTimersByTime(1000);
const split = useTimerStore.getState().split();
useTimerStore.getState().markRecorded(split.id, 'we-123');
const updated = useTimerStore.getState().splits.find((s) => s.id === split.id)!;
expect(updated.recorded).toBe(true);
expect(updated.recordedWorkEntryId).toBe('we-123');
});
it('updateSplit modifies fields', () => {
useTimerStore.getState().start();
vi.advanceTimersByTime(1000);
const split = useTimerStore.getState().split();
useTimerStore.getState().updateSplit(split.id, { label: 'new label', elapsedMs: 9999 });
const updated = useTimerStore.getState().splits.find((s) => s.id === split.id)!;
expect(updated.label).toBe('new label');
expect(updated.elapsedMs).toBe(9999);
});
it('deleteSplit removes from table', () => {
useTimerStore.getState().start();
vi.advanceTimersByTime(1000);
const split = useTimerStore.getState().split();
useTimerStore.getState().deleteSplit(split.id);
expect(useTimerStore.getState().splits).toHaveLength(0);
});
it('reset clears everything', () => {
useTimerStore.getState().start();
vi.advanceTimersByTime(1000);
useTimerStore.getState().split();
useTimerStore.getState().reset();
expect(useTimerStore.getState().running).toBe(false);
expect(useTimerStore.getState().elapsedMs).toBe(0);
expect(useTimerStore.getState().splits).toHaveLength(0);
});
it('persists state to cookie on tick', () => {
useTimerStore.getState().start();
vi.advanceTimersByTime(1500);
useTimerStore.getState()._tick();
expect(document.cookie).toContain('t99_timer');
});
});
describe('timerStore crash recovery', () => {
beforeEach(() => {
vi.useRealTimers();
});
it('detects crash when heartbeat stale + running', () => {
const now = Date.now();
const staleSnapshot = {
currentRate: 75,
running: true,
runStartedAt: now - 60000, // started 1 min ago
accumulatedMs: 0,
splits: [],
lastHeartbeat: now - 30000, // last seen 30s ago (> 5s threshold)
};
document.cookie = `t99_timer=${encodeURIComponent(JSON.stringify(staleSnapshot))}; path=/`;
useTimerStore.setState({ crashRecovery: null });
useTimerStore.getState()._restoreFromCookie();
const cr = useTimerStore.getState().crashRecovery;
expect(cr).not.toBeNull();
expect(cr!.gapMs).toBeGreaterThan(25000);
expect(cr!.crashTime).toBe(now - 30000);
});
it('does NOT flag crash when paused', () => {
const now = Date.now();
const snapshot = {
currentRate: 50,
running: false,
runStartedAt: null,
accumulatedMs: 120000,
splits: [],
lastHeartbeat: now - 60000,
};
document.cookie = `t99_timer=${encodeURIComponent(JSON.stringify(snapshot))}; path=/`;
useTimerStore.setState({ crashRecovery: null });
useTimerStore.getState()._restoreFromCookie();
expect(useTimerStore.getState().crashRecovery).toBeNull();
});
it('restores elapsed time from original start (includes gap)', () => {
const now = Date.now();
const snapshot = {
currentRate: 50,
running: true,
runStartedAt: now - 120000, // 2 min ago
accumulatedMs: 0,
splits: [],
lastHeartbeat: now - 60000,
};
document.cookie = `t99_timer=${encodeURIComponent(JSON.stringify(snapshot))}; path=/`;
useTimerStore.getState()._restoreFromCookie();
// Clock should show ~2 minutes (original start was preserved)
expect(useTimerStore.getState().elapsedMs).toBeGreaterThanOrEqual(120000);
});
it('subtractCrashGap removes gap from accumulated time', () => {
useTimerStore.setState({
running: false,
runStartedAt: null,
accumulatedMs: 100000,
crashRecovery: { crashTime: 0, reloadTime: 30000, gapMs: 30000 },
elapsedMs: 100000,
});
useTimerStore.getState().subtractCrashGap();
expect(useTimerStore.getState().accumulatedMs).toBe(70000);
expect(useTimerStore.getState().crashRecovery).toBeNull();
});
it('subtractCrashGap clamps to zero', () => {
useTimerStore.setState({
accumulatedMs: 5000,
crashRecovery: { crashTime: 0, reloadTime: 0, gapMs: 99999 },
});
useTimerStore.getState().subtractCrashGap();
expect(useTimerStore.getState().accumulatedMs).toBe(0);
});
it('dismissCrashBanner keeps time but removes banner', () => {
useTimerStore.setState({
accumulatedMs: 100000,
crashRecovery: { crashTime: 0, reloadTime: 0, gapMs: 30000 },
});
useTimerStore.getState().dismissCrashBanner();
expect(useTimerStore.getState().crashRecovery).toBeNull();
expect(useTimerStore.getState().accumulatedMs).toBe(100000); // unchanged
});
it('restores splits from cookie', () => {
const snapshot = {
currentRate: 50,
running: false,
runStartedAt: null,
accumulatedMs: 0,
splits: [
{ id: 's1', startedAt: 0, elapsedMs: 60000, rate: 50, recorded: false },
{ id: 's2', startedAt: 0, elapsedMs: 30000, rate: 75, recorded: true, recordedWorkEntryId: 'we-1' },
],
lastHeartbeat: Date.now(),
};
document.cookie = `t99_timer=${encodeURIComponent(JSON.stringify(snapshot))}; path=/`;
useTimerStore.getState()._restoreFromCookie();
expect(useTimerStore.getState().splits).toHaveLength(2);
expect(useTimerStore.getState().splits[1].recorded).toBe(true);
});
});