import { describe, expect, it } from "vitest"; import { aggregateReport, groupKeyFor, measureValue, MEASURE_LABELS, type Measure, type OrgLookups, type ReportEmployee, } from "@/lib/reports"; function emp(overrides: Partial = {}): ReportEmployee { return { id: "1", first_name: "Maria", last_name: "Gruber", job_title: "Maschinenbediener:in", division_id: "div-1", team_id: "team-1", location_id: "loc-1", status: "Aktiv", employment_type: "Vollzeit", contract_type: "unbefristet", entry_date: "2020-01-01", exit_date: null, weekly_hours: 38.5, source: "Extern", paygrade: "B", birth_date: "1990-01-01", gender: "w", ...overrides, }; } const lookups: OrgLookups = { divisionName: new Map([["div-1", "Produktion"]]), departmentNameByTeam: new Map([["team-1", "Fertigung"]]), teamName: new Map([["team-1", "Montage"]]), locationName: new Map([["loc-1", "Wien-Hernals"]]), }; describe("no salary measure in scope (consolidation §4/§8)", () => { it("does not expose an avg_salary measure", () => { expect(Object.keys(MEASURE_LABELS)).not.toContain("avg_salary"); }); }); describe("groupKeyFor", () => { it("resolves division/department/team/location names via lookups", () => { const e = emp(); expect(groupKeyFor(e, "division", lookups)).toBe("Produktion"); expect(groupKeyFor(e, "department", lookups)).toBe("Fertigung"); expect(groupKeyFor(e, "team", lookups)).toBe("Montage"); expect(groupKeyFor(e, "location", lookups)).toBe("Wien-Hernals"); }); it("falls back to a dash for department/team when the employee has no team", () => { const e = emp({ team_id: null }); expect(groupKeyFor(e, "department", lookups)).toBe("–"); expect(groupKeyFor(e, "team", lookups)).toBe("–"); }); it("falls back to 'Unbekannt' for an unresolvable division/location id", () => { const e = emp({ division_id: "ghost", location_id: "ghost" }); expect(groupKeyFor(e, "division", lookups)).toBe("Unbekannt"); expect(groupKeyFor(e, "location", lookups)).toBe("Unbekannt"); }); it("derives entry_year from entry_date", () => { expect(groupKeyFor(emp({ entry_date: "2019-06-01" }), "entry_year", lookups)).toBe("2019"); }); }); describe("measureValue", () => { it("returns 0 for an empty row set regardless of measure", () => { expect(measureValue([], "headcount")).toBe(0); }); it("counts headcount/hires/exits as row length", () => { const rows = [emp(), emp({ id: "2" }), emp({ id: "3" })]; (["headcount", "hires", "exits"] as Measure[]).forEach((m) => expect(measureValue(rows, m)).toBe(3)); }); it("sums FTE as weekly_hours / 38.5", () => { const rows = [emp({ weekly_hours: 38.5 }), emp({ weekly_hours: 19.25 })]; expect(measureValue(rows, "fte")).toBeCloseTo(1.5, 5); }); it("computes parttime_rate as a percentage of Teilzeit employees", () => { const rows = [ emp({ employment_type: "Vollzeit" }), emp({ employment_type: "Teilzeit" }), emp({ employment_type: "Teilzeit" }), emp({ employment_type: "Teilzeit" }), ]; expect(measureValue(rows, "parttime_rate")).toBe(75); }); it("computes female_share as a percentage", () => { const rows = [emp({ gender: "w" }), emp({ gender: "w" }), emp({ gender: "m" })]; expect(measureValue(rows, "female_share")).toBeCloseTo(66.67, 1); }); it("computes avg_age from birth_date", () => { const today = new Date(); const thirtyYearsAgo = `${today.getFullYear() - 30}-01-01`; const fortyYearsAgo = `${today.getFullYear() - 40}-01-01`; const rows = [emp({ birth_date: thirtyYearsAgo }), emp({ birth_date: fortyYearsAgo })]; expect(measureValue(rows, "avg_age")).toBeCloseTo(35, 0); }); it("computes avg_tenure in years using exit_date when present (deterministic)", () => { const rows = [ emp({ entry_date: "2020-01-01", exit_date: "2022-01-01" }), // ~2 years emp({ entry_date: "2020-01-01", exit_date: "2024-01-01" }), // ~4 years ]; expect(measureValue(rows, "avg_tenure")).toBeCloseTo(3, 0); }); }); describe("aggregateReport", () => { it("groups rows, sorts groups descending by value, and never includes a salary field", () => { const employees = [ emp({ id: "1", division_id: "div-1" }), emp({ id: "2", division_id: "div-1" }), emp({ id: "3", division_id: "div-2" }), ]; const multiDivisionLookups: OrgLookups = { ...lookups, divisionName: new Map([ ["div-1", "Produktion"], ["div-2", "IT"], ]), }; const rows = aggregateReport(employees, "headcount", "division", null, multiDivisionLookups); expect(rows[0]).toMatchObject({ key: "Produktion", value: 2, count: 2 }); expect(rows[1]).toMatchObject({ key: "IT", value: 1, count: 1 }); for (const row of rows) { for (const person of row.people) { expect(person).not.toHaveProperty("monthly_salary_gross"); } } }); it("caps drilldown people list source data at the full group (UI caps display, not aggregation)", () => { const employees = Array.from({ length: 15 }, (_, i) => emp({ id: String(i) })); const rows = aggregateReport(employees, "headcount", "division", null, lookups); expect(rows[0].people).toHaveLength(15); }); it("adds a split breakdown per group when a split dimension is given", () => { const employees = [ emp({ id: "1", employment_type: "Vollzeit" }), emp({ id: "2", employment_type: "Teilzeit" }), ]; const rows = aggregateReport(employees, "headcount", "division", "employment_type", lookups); expect(rows[0].split).toEqual( expect.arrayContaining([ { key: "Vollzeit", value: 1, count: 1 }, { key: "Teilzeit", value: 1, count: 1 }, ]) ); }); });