import { describe, expect, it } from "vitest"; import { aggregateEvents, aggregateReport, deriveStatusAsOf, groupKeyFor, groupKeysFor, measureValue, MEASURE_LABELS, type OrgLookups, type ReportEmployee, type ReportEvent, } from "@/lib/reports"; function emp(overrides: Partial = {}): ReportEmployee { return { id: "1", first_name: "Maria", last_name: "Gruber", job_title: "Maschinenbediener:in", org_unit_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", worker_type: "Angestellte:r", collective_agreement: "Handel", work_days: ["Mo", "Di", "Mi", "Do", "Fr"], is_betriebsrat: false, has_dienstwagen: false, is_laterale_fuehrung: false, is_c_level: false, dependents_count: 0, ...overrides, }; } // Alle drei sind über die Einheit geschlüsselt, in der die Person sitzt: // "team-1" hängt unter der Abteilung Fertigung im Bereich Produktion. // "div-1" ist der Bereich selbst — dort sitzt nur die Bereichsleitung, die // weder Abteilung noch Team hat. const lookups: OrgLookups = { divisionName: new Map([ ["team-1", "Produktion"], ["div-1", "Produktion"], ]), departmentName: 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 for somebody sitting at the division itself", () => { // Eine Bereichsleitung hängt am Bereich, nicht an einem Team — vorher // liess sich das nur über team_id = null ausdrücken. const e = emp({ org_unit_id: "div-1" }); expect(groupKeyFor(e, "division", lookups)).toBe("Produktion"); expect(groupKeyFor(e, "department", lookups)).toBe("–"); expect(groupKeyFor(e, "team", lookups)).toBe("–"); }); it("falls back to a dash when somebody held no position at all", () => { const e = emp({ org_unit_id: null }); expect(groupKeyFor(e, "division", lookups)).toBe("–"); expect(groupKeyFor(e, "team", lookups)).toBe("–"); }); it("falls back to 'Unbekannt' for an unresolvable division/location id", () => { const e = emp({ org_unit_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"); }); it("resolves the Rolle & Anstellung boolean dimensions as Ja/Nein", () => { const e = emp({ is_betriebsrat: true, has_dienstwagen: false, is_laterale_fuehrung: true, is_c_level: false }); expect(groupKeyFor(e, "betriebsrat", lookups)).toBe("Ja"); expect(groupKeyFor(e, "dienstwagen", lookups)).toBe("Nein"); expect(groupKeyFor(e, "laterale_fuehrung", lookups)).toBe("Ja"); expect(groupKeyFor(e, "c_level", lookups)).toBe("Nein"); }); it("resolves worker_type/collective_agreement directly", () => { const e = emp({ worker_type: "Arbeiter:in", collective_agreement: "Süßwaren" }); expect(groupKeyFor(e, "worker_type", lookups)).toBe("Arbeiter:in"); expect(groupKeyFor(e, "collective_agreement", lookups)).toBe("Süßwaren"); }); it("resolves has_dependents from dependents_count", () => { expect(groupKeyFor(emp({ dependents_count: 0 }), "has_dependents", lookups)).toBe("Nein"); expect(groupKeyFor(emp({ dependents_count: 2 }), "has_dependents", lookups)).toBe("Ja"); }); }); describe("groupKeysFor", () => { it("returns a single-element array for every ordinary dimension", () => { expect(groupKeysFor(emp(), "division", lookups)).toEqual(["Produktion"]); }); it("returns one key per work day for the weekday dimension", () => { expect(groupKeysFor(emp({ work_days: ["Mo", "Mi"] }), "weekday", lookups)).toEqual(["Mo", "Mi"]); }); }); describe("measureValue", () => { it("returns 0 for an empty row set regardless of measure", () => { expect(measureValue([], "headcount")).toBe(0); }); it("counts headcount as row length", () => { const rows = [emp(), emp({ id: "2" }), emp({ id: "3" })]; expect(measureValue(rows, "headcount")).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); }); it("computes avg_dependents as the mean dependents_count", () => { const rows = [emp({ dependents_count: 0 }), emp({ dependents_count: 2 }), emp({ dependents_count: 4 })]; expect(measureValue(rows, "avg_dependents")).toBeCloseTo(2, 5); }); }); describe("aggregateReport", () => { it("groups rows, sorts groups descending by value, and never includes a salary field", () => { const employees = [ emp({ id: "1", org_unit_id: "team-1" }), emp({ id: "2", org_unit_id: "team-1" }), emp({ id: "3", org_unit_id: "team-2" }), ]; // Geschlüsselt über die Einheit, in der die Person sitzt: team-1 hängt // unter Produktion, team-2 unter IT. const multiDivisionLookups: OrgLookups = { ...lookups, divisionName: new Map([ ["team-1", "Produktion"], ["team-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 }, ]) ); }); it("reconstructs headcount/avg_age/avg_tenure as of a past Stichtag, not today", () => { // Entered 2019, exited 2021 — gone by today, but was active on 2020-06-01. const employees = [emp({ id: "1", entry_date: "2019-01-01", exit_date: "2021-01-01", birth_date: "1990-01-01" })]; const asOf = "2020-06-01"; const rows = aggregateReport(employees, "headcount", "division", null, lookups, asOf); expect(rows[0]).toMatchObject({ key: "Produktion", value: 1, count: 1 }); expect(measureValue(employees, "avg_age", asOf)).toBe(30); expect(measureValue(employees, "avg_tenure", asOf)).toBeCloseTo(1.42, 1); }); it("counts an employee once per work day for the weekday dimension, and sorts Mo→So instead of by value", () => { const employees = [ emp({ id: "1", work_days: ["Mo", "Di", "Mi", "Do", "Fr"] }), emp({ id: "2", work_days: ["Mo", "Mi", "Fr"] }), ]; const rows = aggregateReport(employees, "headcount", "weekday", null, lookups); expect(rows.map((r) => r.key)).toEqual(["Mo", "Di", "Mi", "Do", "Fr"]); expect(rows.find((r) => r.key === "Mo")).toMatchObject({ value: 2, count: 2 }); expect(rows.find((r) => r.key === "Di")).toMatchObject({ value: 1, count: 1 }); }); it("sorts a weekday split chronologically within each group", () => { const employees = [emp({ id: "1", org_unit_id: "team-1", work_days: ["Fr", "Mo"] })]; const rows = aggregateReport(employees, "headcount", "division", "weekday", lookups); expect(rows[0].split?.map((s) => s.key)).toEqual(["Mo", "Fr"]); }); }); describe("deriveStatusAsOf", () => { const base = { entry_date: "2020-01-01", exit_date: null as string | null, karenz_start_date: null as string | null, karenz_return_date: null as string | null }; it("is Geplant before the entry date", () => { expect(deriveStatusAsOf({ ...base, entry_date: "2025-01-01" }, "2024-12-31")).toBe("Geplant"); }); it("is Ausgetreten on/after the exit date", () => { expect(deriveStatusAsOf({ ...base, exit_date: "2022-06-01" }, "2022-06-01")).toBe("Ausgetreten"); expect(deriveStatusAsOf({ ...base, exit_date: "2022-06-01" }, "2022-05-31")).toBe("Aktiv"); }); it("is Karenz within the Karenz window, Aktiv once returned", () => { const onKarenz = { ...base, karenz_start_date: "2023-01-01", karenz_return_date: "2023-07-01" }; expect(deriveStatusAsOf(onKarenz, "2023-03-01")).toBe("Karenz"); expect(deriveStatusAsOf(onKarenz, "2023-07-01")).toBe("Aktiv"); expect(deriveStatusAsOf(onKarenz, "2022-12-31")).toBe("Aktiv"); }); }); describe("aggregateEvents", () => { const eventLookups: OrgLookups = { ...lookups, divisionName: new Map([ ["team-1", "Produktion"], ["team-2", "IT"], ]), }; function ev(overrides: Partial = {}): ReportEvent { return { employee_id: "1", first_name: "Maria", last_name: "Gruber", job_title: "Maschinenbediener:in", org_unit_id: "team-1", location_id: "loc-1", event_date: "2026-03-01", event_type: "Eintritt", description: "Eintritt als Maschinenbediener:in", ...overrides, }; } it("counts every event, unlike a Bestand headcount which only sees the current entry_date", () => { const events = [ev({ employee_id: "1" }), ev({ employee_id: "1", event_date: "2026-05-01", event_type: "Beförderung" }), ev({ employee_id: "2", org_unit_id: "team-2" })]; const rows = aggregateEvents(events, "event_type", null, eventLookups); expect(rows.find((r) => r.key === "Eintritt")).toMatchObject({ value: 2, count: 2 }); expect(rows.find((r) => r.key === "Beförderung")).toMatchObject({ value: 1, count: 1 }); }); it("groups by the affected employee's org unit and preserves the event description on drill-down", () => { const events = [ev({ org_unit_id: "team-1" }), ev({ org_unit_id: "team-2", employee_id: "2" })]; const rows = aggregateEvents(events, "division", null, eventLookups); const produktion = rows.find((r) => r.key === "Produktion")!; expect(produktion.people[0]).toMatchObject({ id: "1", title: "Eintritt als Maschinenbediener:in", entry_date: "2026-03-01" }); }); });