import { describe, expect, it } from 'vitest'; import { rollupStageRevenue, rollupStageCounts, computeTotalForecast, computeOccupancyRate, rollupBerthStatusCounts, } from '@/lib/services/report-math'; // Canonical 7-stage pipeline (see PIPELINE_STAGES in src/lib/constants.ts): // enquiry, qualified, nurturing, eoi, reservation, deposit_paid, contract. // Non-canonical input stage strings go through canonicalizeStage which // maps legacy 9-stage values back into the canonical bucket. describe('rollupStageRevenue', () => { it('sums revenue per canonicalized stage', () => { const out = rollupStageRevenue([ { stage: 'enquiry', revenue: '100.50' }, { stage: 'eoi', revenue: '200.00' }, { stage: 'enquiry', revenue: '50.25' }, ]); expect(out['enquiry']).toBe('150.75'); expect(out['eoi']).toBe('200'); }); it('canonicalizes legacy 9-stage keys into modern buckets without dropping rows', () => { // 'deposit_10pct' → 'deposit_paid', 'contract_sent' → 'contract'. const out = rollupStageRevenue([ { stage: 'deposit_10pct', revenue: '1000' }, { stage: 'contract_sent', revenue: '2000' }, ]); expect(out['deposit_paid']).toBe('1000'); expect(out['contract']).toBe('2000'); const total = Object.values(out).reduce((a, v) => a + parseFloat(v), 0); expect(total).toBe(3000); }); it("'open' legacy alias canonicalizes to 'enquiry'", () => { const out = rollupStageRevenue([ { stage: 'open', revenue: '500' }, { stage: 'enquiry', revenue: '100' }, ]); // Both rows land in 'enquiry' since 'open' is the legacy alias. expect(out['enquiry']).toBe('600'); expect(out['open']).toBeUndefined(); }); it('treats null revenue as 0', () => { const out = rollupStageRevenue([ { stage: 'enquiry', revenue: null }, { stage: 'enquiry', revenue: '500' }, ]); expect(out['enquiry']).toBe('500'); }); }); describe('rollupStageCounts', () => { it('sums counts per stage with canonicalization', () => { const out = rollupStageCounts([ { stage: 'enquiry', count: 5 }, { stage: 'eoi', count: 3 }, { stage: 'enquiry', count: 2 }, ]); expect(out['enquiry']).toBe(7); expect(out['eoi']).toBe(3); }); it('returns empty object for empty input', () => { expect(rollupStageCounts([])).toEqual({}); }); }); describe('computeTotalForecast', () => { it('applies stage weights and returns 2-decimal-fixed string', () => { const forecast = computeTotalForecast( [ { stage: 'enquiry', revenue: '1000' }, { stage: 'eoi', revenue: '2000' }, ], { enquiry: 0.2, eoi: 0.5 }, ); // 1000*0.2 + 2000*0.5 = 200 + 1000 = 1200.00 expect(forecast).toBe('1200.00'); }); it('treats stages missing from weight map as 0 (no silent default)', () => { const forecast = computeTotalForecast( [ { stage: 'enquiry', revenue: '1000' }, { stage: 'unknown_stage', revenue: '2000' }, ], { enquiry: 0.3 }, ); // 'unknown_stage' canonicalizes to 'enquiry' (fallback) - so it ALSO // gets the enquiry weight. Verifies canonicalization stays consistent // between rollup and forecast so the totals reconcile. // 1000*0.3 + 2000*0.3 = 300 + 600 = 900 expect(forecast).toBe('900.00'); }); it('returns "0.00" for empty input', () => { expect(computeTotalForecast([], {})).toBe('0.00'); }); it('skips rows with null revenue', () => { const forecast = computeTotalForecast( [ { stage: 'enquiry', revenue: null }, { stage: 'enquiry', revenue: '500' }, ], { enquiry: 1.0 }, ); expect(forecast).toBe('500.00'); }); it('matches expected forecast snapshot for a representative pipeline', () => { // Deterministic fixture across all 7 canonical stages. Locks the // math against weight-tuning regressions. const rows = [ { stage: 'enquiry', revenue: '50000' }, // 50000 * 0.05 = 2500 { stage: 'qualified', revenue: '40000' }, // 40000 * 0.10 = 4000 { stage: 'nurturing', revenue: '30000' }, // 30000 * 0.15 = 4500 { stage: 'eoi', revenue: '120000' }, // 120000 * 0.25 = 30000 { stage: 'reservation', revenue: '80000' }, // 80000 * 0.50 = 40000 { stage: 'deposit_paid', revenue: '60000' }, // 60000 * 0.75 = 45000 { stage: 'contract', revenue: '40000' }, // 40000 * 0.90 = 36000 ]; const weights = { enquiry: 0.05, qualified: 0.1, nurturing: 0.15, eoi: 0.25, reservation: 0.5, deposit_paid: 0.75, contract: 0.9, }; // 2500 + 4000 + 4500 + 30000 + 40000 + 45000 + 36000 = 162000 expect(computeTotalForecast(rows, weights)).toMatchInlineSnapshot('"162000.00"'); }); }); describe('computeOccupancyRate', () => { it('counts only "sold" as occupied (under_offer is a hold, not occupied)', () => { const result = computeOccupancyRate({ sold: 30, under_offer: 10, available: 60, }); // 30 sold of 100 total = 30% expect(result.occupancyRate).toBe(30); expect(result.totalBerths).toBe(100); }); it('rounds to 1 decimal place', () => { const result = computeOccupancyRate({ sold: 1, available: 2, }); // 1/3 = 33.333... → 33.3 expect(result.occupancyRate).toBe(33.3); }); it('returns 0/0 not NaN when there are no berths', () => { const result = computeOccupancyRate({}); expect(result.occupancyRate).toBe(0); expect(result.totalBerths).toBe(0); }); it('returns 100 when every berth is sold', () => { const result = computeOccupancyRate({ sold: 50 }); expect(result.occupancyRate).toBe(100); expect(result.totalBerths).toBe(50); }); }); describe('rollupBerthStatusCounts', () => { it('maps per-status counts + computes total', () => { const out = rollupBerthStatusCounts([ { status: 'sold', count: 30 }, { status: 'available', count: 60 }, { status: 'under_offer', count: 10 }, ]); expect(out.statusCounts).toEqual({ sold: 30, available: 60, under_offer: 10 }); expect(out.totalBerths).toBe(100); }); });