Simpl-Resultat/src/services/reportService.cartes.test.ts
le king fu 3be05db41a
All checks were successful
PR Check / rust (push) Successful in 21m48s
PR Check / frontend (push) Successful in 2m15s
PR Check / rust (pull_request) Successful in 21m44s
PR Check / frontend (pull_request) Successful in 2m16s
feat(reports/cartes): Mensuel/YTD toggle on KPI cards + user guide section (#102)
Adds a segmented Monthly/YTD toggle next to the reference-month picker that
flips the four KPI cards (income, expenses, net, savings rate) between the
reference-month value (unchanged default) and a Year-to-Date cumulative view.

In YTD mode, the "current" value sums January to the reference month of the
reference year; MoM delta compares it to Jan to (refMonth - 1) of the same
year (null in January, since no prior YTD window exists); YoY delta compares
it to Jan to refMonth of the previous year; savings rate is recomputed from
YTD income and expenses, and stays null when YTD income is zero.

The 13-month sparkline, top movers, seasonality and budget adherence cards
remain monthly regardless of the toggle (by design). The savings-rate tooltip
is now dynamic and mirrors the active mode. The mode is persisted in
localStorage under `reports-cartes-period-mode`.

Also adds a dedicated Cartes section to `docs/guide-utilisateur.md` covering
the four KPI formulas, the Monthly/YTD toggle and its effect on deltas, the
sparkline, top movers, seasonality, budget adherence and the savings-rate
edge case. Mirrored in the in-app `docs.reports` i18n tree (features/steps/
tips extended) for both FR and EN.

No SQL migration: YTD sums are computed from the already-fetched
`flowByMonth` map, so no extra round trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 09:49:21 -04:00

441 lines
17 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { shiftMonth, getCartesSnapshot } from "./reportService";
vi.mock("./db", () => ({
getDb: vi.fn(),
}));
import { getDb } from "./db";
const mockSelect = vi.fn();
const mockDb = { select: mockSelect };
beforeEach(() => {
vi.mocked(getDb).mockResolvedValue(mockDb as never);
mockSelect.mockReset();
});
describe("shiftMonth", () => {
it("shifts forward within a year", () => {
expect(shiftMonth(2026, 1, 2)).toEqual({ year: 2026, month: 3 });
});
it("shifts backward within a year", () => {
expect(shiftMonth(2026, 6, -3)).toEqual({ year: 2026, month: 3 });
});
it("wraps around January to the previous year", () => {
expect(shiftMonth(2026, 1, -1)).toEqual({ year: 2025, month: 12 });
});
it("wraps past multiple years back", () => {
expect(shiftMonth(2026, 4, -24)).toEqual({ year: 2024, month: 4 });
});
it("wraps past year forward", () => {
expect(shiftMonth(2025, 11, 3)).toEqual({ year: 2026, month: 2 });
});
});
/**
* Dispatch mock SELECT responses based on the SQL fragment being queried.
* Each entry returns the canned rows for queries whose text contains `match`.
*/
function routeSelect(routes: { match: string; rows: unknown[] }[]): void {
mockSelect.mockImplementation((sql: string) => {
for (const r of routes) {
if (sql.includes(r.match)) return Promise.resolve(r.rows);
}
return Promise.resolve([]);
});
}
describe("getCartesSnapshot", () => {
it("returns zero-filled KPIs when there is no data", async () => {
routeSelect([]);
const snapshot = await getCartesSnapshot(2026, 3);
expect(snapshot.referenceYear).toBe(2026);
expect(snapshot.referenceMonth).toBe(3);
expect(snapshot.kpis.income.current).toBe(0);
expect(snapshot.kpis.expenses.current).toBe(0);
expect(snapshot.kpis.net.current).toBe(0);
// Savings rate is null (renders as "—") when income is zero.
expect(snapshot.kpis.savingsRate.current).toBeNull();
expect(snapshot.kpis.income.sparkline).toHaveLength(13);
expect(snapshot.flow12Months).toHaveLength(12);
expect(snapshot.topMoversUp).toHaveLength(0);
expect(snapshot.topMoversDown).toHaveLength(0);
expect(snapshot.budgetAdherence.categoriesTotal).toBe(0);
expect(snapshot.seasonality.historicalYears).toHaveLength(0);
expect(snapshot.seasonality.historicalAverage).toBeNull();
expect(snapshot.seasonality.deviationPct).toBeNull();
});
it("computes MoM and YoY deltas from a monthly flow stream", async () => {
// Reference = 2026-03
routeSelect([
{
match: "strftime('%Y-%m', date)",
rows: [
{ month: "2025-03", income: 3000, expenses: 1800 }, // YoY comparison
{ month: "2026-02", income: 4000, expenses: 2000 }, // MoM comparison
{ month: "2026-03", income: 5000, expenses: 2500 }, // Reference
],
},
]);
const snapshot = await getCartesSnapshot(2026, 3);
expect(snapshot.kpis.income.current).toBe(5000);
expect(snapshot.kpis.income.previousMonth).toBe(4000);
expect(snapshot.kpis.income.previousYear).toBe(3000);
expect(snapshot.kpis.income.deltaMoMAbs).toBe(1000);
expect(snapshot.kpis.income.deltaMoMPct).toBe(25);
expect(snapshot.kpis.income.deltaYoYAbs).toBe(2000);
expect(Math.round(snapshot.kpis.income.deltaYoYPct ?? 0)).toBe(67);
expect(snapshot.kpis.expenses.current).toBe(2500);
expect(snapshot.kpis.net.current).toBe(2500);
expect(snapshot.kpis.savingsRate.current).toBe(50);
});
it("January reference month shifts MoM to December of previous year", async () => {
routeSelect([
{
match: "strftime('%Y-%m', date)",
rows: [
{ month: "2025-12", income: 2000, expenses: 1000 },
{ month: "2026-01", income: 3000, expenses: 1500 },
],
},
]);
const snapshot = await getCartesSnapshot(2026, 1);
expect(snapshot.kpis.income.current).toBe(3000);
expect(snapshot.kpis.income.previousMonth).toBe(2000);
// YoY for January 2026 = January 2025 = no data
expect(snapshot.kpis.income.previousYear).toBeNull();
expect(snapshot.kpis.income.deltaYoYAbs).toBeNull();
});
it("savings rate is null when income is zero (no division by zero, renders as — in UI)", async () => {
routeSelect([
{
match: "strftime('%Y-%m', date)",
rows: [
{ month: "2026-03", income: 0, expenses: 500 },
],
},
]);
const snapshot = await getCartesSnapshot(2026, 3);
expect(snapshot.kpis.savingsRate.current).toBeNull();
expect(snapshot.kpis.income.current).toBe(0);
expect(snapshot.kpis.expenses.current).toBe(500);
expect(snapshot.kpis.net.current).toBe(-500);
});
it("handles less than 13 months of history by filling gaps with zero", async () => {
routeSelect([
{
match: "strftime('%Y-%m', date)",
rows: [
{ month: "2026-03", income: 1000, expenses: 400 },
],
},
]);
const snapshot = await getCartesSnapshot(2026, 3);
expect(snapshot.kpis.income.sparkline).toHaveLength(13);
// First 12 points are zero, last one is 1000
expect(snapshot.kpis.income.sparkline[12].value).toBe(1000);
expect(snapshot.kpis.income.sparkline[0].value).toBe(0);
// MoM comparison with a missing month returns null (no data for 2026-02)
expect(snapshot.kpis.income.previousMonth).toBeNull();
expect(snapshot.kpis.income.deltaMoMAbs).toBeNull();
});
it("computes seasonality only when historical data exists", async () => {
routeSelect([
{
match: "strftime('%Y-%m', date)",
rows: [{ month: "2026-03", income: 3000, expenses: 1500 }],
},
{
match: "CAST(strftime('%Y', date) AS INTEGER) AS year",
rows: [
{ year: 2025, amount: 1200 },
{ year: 2024, amount: 1000 },
],
},
]);
const snapshot = await getCartesSnapshot(2026, 3);
expect(snapshot.seasonality.historicalYears).toHaveLength(2);
expect(snapshot.seasonality.historicalAverage).toBe(1100);
expect(snapshot.seasonality.referenceAmount).toBe(1500);
// (1500 - 1100) / 1100 * 100 ≈ 36.36
expect(Math.round(snapshot.seasonality.deviationPct ?? 0)).toBe(36);
});
it("seasonality deviation stays null when there is no historical average", async () => {
routeSelect([
{
match: "strftime('%Y-%m', date)",
rows: [{ month: "2026-03", income: 2000, expenses: 800 }],
},
]);
const snapshot = await getCartesSnapshot(2026, 3);
expect(snapshot.seasonality.historicalYears).toHaveLength(0);
expect(snapshot.seasonality.historicalAverage).toBeNull();
expect(snapshot.seasonality.deviationPct).toBeNull();
});
it("splits top movers by sign and caps each list at 5", async () => {
// Seven up-movers, three down-movers — verify we get 5 up and 3 down.
// Cumulative totals mirror the monthly ones in this fixture — the Cartes
// service only reads the monthly block for top movers, so cumulative
// values are inert here.
const momRows = [
{ category_id: 1, category_name: "C1", category_color: "#000", month_current_total: 200, month_previous_total: 100, cumulative_current_total: 200, cumulative_previous_total: 100 },
{ category_id: 2, category_name: "C2", category_color: "#000", month_current_total: 400, month_previous_total: 100, cumulative_current_total: 400, cumulative_previous_total: 100 },
{ category_id: 3, category_name: "C3", category_color: "#000", month_current_total: 500, month_previous_total: 100, cumulative_current_total: 500, cumulative_previous_total: 100 },
{ category_id: 4, category_name: "C4", category_color: "#000", month_current_total: 700, month_previous_total: 100, cumulative_current_total: 700, cumulative_previous_total: 100 },
{ category_id: 5, category_name: "C5", category_color: "#000", month_current_total: 900, month_previous_total: 100, cumulative_current_total: 900, cumulative_previous_total: 100 },
{ category_id: 6, category_name: "C6", category_color: "#000", month_current_total: 1100, month_previous_total: 100, cumulative_current_total: 1100, cumulative_previous_total: 100 },
{ category_id: 7, category_name: "C7", category_color: "#000", month_current_total: 1300, month_previous_total: 100, cumulative_current_total: 1300, cumulative_previous_total: 100 },
{ category_id: 8, category_name: "D1", category_color: "#000", month_current_total: 100, month_previous_total: 500, cumulative_current_total: 100, cumulative_previous_total: 500 },
{ category_id: 9, category_name: "D2", category_color: "#000", month_current_total: 100, month_previous_total: 700, cumulative_current_total: 100, cumulative_previous_total: 700 },
{ category_id: 10, category_name: "D3", category_color: "#000", month_current_total: 100, month_previous_total: 900, cumulative_current_total: 100, cumulative_previous_total: 900 },
];
routeSelect([
{
match: "strftime('%Y-%m', date)",
rows: [{ month: "2026-03", income: 1000, expenses: 500 }],
},
{
// Matches the getCompareMonthOverMonth SQL pattern.
match: "ORDER BY ABS(month_current_total - month_previous_total) DESC",
rows: momRows,
},
]);
const snapshot = await getCartesSnapshot(2026, 3);
expect(snapshot.topMoversUp).toHaveLength(5);
expect(snapshot.topMoversDown).toHaveLength(3);
// Top up is the biggest delta (C7: +1200)
expect(snapshot.topMoversUp[0].categoryName).toBe("C7");
// Top down is the biggest negative delta (D3: -800)
expect(snapshot.topMoversDown[0].categoryName).toBe("D3");
});
it("computes YTD KPIs correctly when mode=ytd (sums Jan→refMonth of refYear)", async () => {
// Reference = 2026-03, YTD = Jan + Feb + Mar of 2026.
routeSelect([
{
match: "strftime('%Y-%m', date)",
rows: [
// Previous year YTD: Jan + Feb + Mar 2025 = income 3000, expenses 1500.
{ month: "2025-01", income: 1000, expenses: 500 },
{ month: "2025-02", income: 1000, expenses: 500 },
{ month: "2025-03", income: 1000, expenses: 500 },
// Current YTD: Jan + Feb + Mar 2026.
{ month: "2026-01", income: 2000, expenses: 800 },
{ month: "2026-02", income: 2500, expenses: 1000 },
{ month: "2026-03", income: 3000, expenses: 1200 },
],
},
]);
const snapshot = await getCartesSnapshot(2026, 3, "ytd");
// Current = sum Jan+Feb+Mar 2026
expect(snapshot.kpis.income.current).toBe(7500);
expect(snapshot.kpis.expenses.current).toBe(3000);
expect(snapshot.kpis.net.current).toBe(4500);
// Savings rate YTD = 4500 / 7500 = 60%
expect(snapshot.kpis.savingsRate.current).toBe(60);
// MoM in YTD = current YTD vs Jan→Feb 2026 = income 4500, expenses 1800.
expect(snapshot.kpis.income.previousMonth).toBe(4500);
expect(snapshot.kpis.income.deltaMoMAbs).toBe(3000);
expect(snapshot.kpis.expenses.previousMonth).toBe(1800);
// YoY in YTD = Jan→Mar 2026 vs Jan→Mar 2025.
expect(snapshot.kpis.income.previousYear).toBe(3000);
expect(snapshot.kpis.income.deltaYoYAbs).toBe(4500);
expect(snapshot.kpis.expenses.previousYear).toBe(1500);
});
it("YTD savings rate is null when YTD income is zero", async () => {
routeSelect([
{
match: "strftime('%Y-%m', date)",
rows: [
{ month: "2026-01", income: 0, expenses: 200 },
{ month: "2026-02", income: 0, expenses: 150 },
{ month: "2026-03", income: 0, expenses: 300 },
],
},
]);
const snapshot = await getCartesSnapshot(2026, 3, "ytd");
expect(snapshot.kpis.income.current).toBe(0);
expect(snapshot.kpis.expenses.current).toBe(650);
expect(snapshot.kpis.savingsRate.current).toBeNull();
});
it("YTD MoM delta is null when reference month is January (no prior YTD window in same year)", async () => {
routeSelect([
{
match: "strftime('%Y-%m', date)",
rows: [
{ month: "2025-01", income: 500, expenses: 200 },
{ month: "2026-01", income: 1000, expenses: 400 },
],
},
]);
const snapshot = await getCartesSnapshot(2026, 1, "ytd");
expect(snapshot.kpis.income.current).toBe(1000);
// MoM previous YTD for January is empty (no prior month in same year).
expect(snapshot.kpis.income.previousMonth).toBeNull();
expect(snapshot.kpis.income.deltaMoMAbs).toBeNull();
expect(snapshot.kpis.income.deltaMoMPct).toBeNull();
// YoY still works — Jan 2025.
expect(snapshot.kpis.income.previousYear).toBe(500);
expect(snapshot.kpis.income.deltaYoYAbs).toBe(500);
});
it("YTD YoY delta uses Jan→refMonth of the previous year", async () => {
routeSelect([
{
match: "strftime('%Y-%m', date)",
rows: [
// Prev year YTD Jan→Apr 2025
{ month: "2025-01", income: 100, expenses: 50 },
{ month: "2025-02", income: 100, expenses: 50 },
{ month: "2025-03", income: 100, expenses: 50 },
{ month: "2025-04", income: 100, expenses: 50 },
// Prev year outside window — must be ignored.
{ month: "2025-05", income: 999, expenses: 999 },
// Current YTD Jan→Apr 2026
{ month: "2026-01", income: 300, expenses: 100 },
{ month: "2026-02", income: 300, expenses: 100 },
{ month: "2026-03", income: 300, expenses: 100 },
{ month: "2026-04", income: 300, expenses: 100 },
],
},
]);
const snapshot = await getCartesSnapshot(2026, 4, "ytd");
expect(snapshot.kpis.income.current).toBe(1200);
expect(snapshot.kpis.income.previousYear).toBe(400); // 4 * 100
expect(snapshot.kpis.expenses.previousYear).toBe(200); // 4 * 50
expect(snapshot.kpis.income.deltaYoYAbs).toBe(800);
});
it("defaults to mode=month and produces monthly KPIs (back-compat)", async () => {
routeSelect([
{
match: "strftime('%Y-%m', date)",
rows: [
{ month: "2026-01", income: 1000, expenses: 400 },
{ month: "2026-02", income: 1000, expenses: 400 },
{ month: "2026-03", income: 5000, expenses: 2500 },
],
},
]);
// No mode argument — default "month".
const snapshot = await getCartesSnapshot(2026, 3);
// Monthly ref only, not YTD sum.
expect(snapshot.kpis.income.current).toBe(5000);
expect(snapshot.kpis.expenses.current).toBe(2500);
});
it("counts budgeted expense categories even though monthBudget is stored signed-negative (#112)", async () => {
// Regression: before the fix, `r.monthBudget > 0` rejected every expense
// row because budgetService signs expense budgets as negative. The card
// then claimed "no budgeted categories" even when the user had set
// budgets on expense categories.
routeSelect([
{
match: "FROM categories WHERE is_active = 1 ORDER BY sort_order",
rows: [
{
id: 10,
name: "Alimentation",
parent_id: null,
color: "#f59e0b",
icon: null,
type: "expense",
is_active: 1,
is_inputable: 1,
sort_order: 1,
created_at: "",
},
{
id: 11,
name: "Restaurants",
parent_id: null,
color: "#ef4444",
icon: null,
type: "expense",
is_active: 1,
is_inputable: 1,
sort_order: 2,
created_at: "",
},
{
id: 12,
name: "Loyer",
parent_id: null,
color: "#6366f1",
icon: null,
type: "expense",
is_active: 1,
is_inputable: 1,
sort_order: 3,
created_at: "",
},
],
},
{
match: "FROM budget_entries WHERE year",
rows: [
{ id: 1, category_id: 10, year: 2026, month: 3, amount: 500 },
{ id: 2, category_id: 11, year: 2026, month: 3, amount: 200 },
// Category 12 has no budget entry.
],
},
{
// Matches both the monthly and YTD actuals queries; for this test
// the YTD slice can mirror the monthly one.
match: "FROM transactions\n WHERE date BETWEEN",
rows: [
{ category_id: 10, actual: -400 }, // 400 spent against 500 budget -> in target
{ category_id: 11, actual: -350 }, // 350 spent against 200 budget -> overrun
{ category_id: 12, actual: -150 }, // no budget, should not appear
],
},
]);
const snapshot = await getCartesSnapshot(2026, 3);
expect(snapshot.budgetAdherence.categoriesTotal).toBe(2);
expect(snapshot.budgetAdherence.categoriesInTarget).toBe(1);
expect(snapshot.budgetAdherence.worstOverruns).toHaveLength(1);
const [worst] = snapshot.budgetAdherence.worstOverruns;
expect(worst.categoryName).toBe("Restaurants");
expect(worst.actual).toBe(350);
expect(worst.budget).toBe(200);
expect(worst.overrunAbs).toBe(150);
expect(worst.overrunPct).toBeCloseTo(75, 5);
});
});