Files
alpenwerk-hr/tests/unit/reports.test.ts
Andrei Laas d2d5e1dabb
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m27s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m2s
Drei Befunde aus dem Test: Logo, Geplant-Filter, Anstehend-Farben
**Das Logo auf der Anmeldeseite.** Statt des Schriftzugs stand dort der
Ersatztext "Manner". Die SVG-Fassung war gueltiges XML, lag im Repository und
war committet — warum sie im Betrieb nicht geladen wurde, laesst sich von hier
aus nicht feststellen; dafuer braucht es die Antwort des Servers auf die URL.

Statt das weiter zu raten, faellt die Angriffsflaeche weg: es gibt jetzt eine
Datei, manner-logo.png, der Schriftzug mit durchsichtigem Grund. Kein XML, kein
Beschnitt, kein eingebackenes Feld. Das Blau darin ist #164194, also der Wert
aus §1.1.

Damit verschwinden auch die zwei Fassungen. §1.2 laesst den Schriftzug nur zur
Gaenze auf Rosa zu, und das Manual kennt dafuer zwei Lagen: auf Weiss gehoert
er nach §4.1 in ein rechteckiges rosa Feld, in einem grossflaechigen rosa
Umfeld nach §3.1 nur mit Freiraum. Beides entsteht jetzt aus derselben Datei —
das Feld zeichnet das Bauteil, aus derselben Polsterung wie den Freiraum.

**Der Filter "Geplant" fand Ausgetretene.** Die Ableitung pruefte den Eintritt
vor dem Austritt, und wer einen Eintritt in der Zukunft hatte, galt als
geplant — auch wenn der Austritt laengst verbucht war. Das trifft genau den
No-Show (Migration 20260814100000): eingestellt, nie erschienen, Austritt vor
dem Eintrittstag. Im Bestand sind das Zeilen mit Eintritt 01.10.2026, die der
Filter mitzaehlte, waehrend die Liste daneben "Ausgetreten" anzeigte.
employees.status, das die SQL-Funktion beim Austritt setzt, sagte von Anfang
an das Richtige; falsch war die Ableitung in der Anwendung.

Ein abgeschlossener Austritt wird jetzt zuerst geprueft: er beendet das
Verhaeltnis, gleichgueltig ob der Eintritt schon war oder noch kommt. Ein
Austritt, der selbst noch bevorsteht, nimmt den Eintritt nicht zurueck — wer
am 01.10. anfaengt und am 31.12. aufhoert, ist heute geplant. Die SQL-Fassung
in lib/employee-status-filter.ts bildet dieselbe Reihenfolge ab.

Keine Migration: beide Fassungen der Regel liegen in TypeScript. In SQL wird
nur der Karenz-Teil wiederholt, fuer die Fuehrungslinie, und der ist nicht
betroffen.

**Die vier Anstehend-Chips.** Zwei davon standen in der Markenfarbe, weil die
Farbe ueber den Beschriftungstext aus der Tabelle der Protokoll-Aktionen
geholt wurde — und die kennt eine andere Sprache: "Neueinstellung", nicht
"Eintritt". Wer dort nicht steht, bekam den neutralen Chip. Ein Nachschlagen,
das bei einem Fehlschlag still etwas Plausibles liefert, faellt eben nicht auf.

Die vier haben jetzt eine eigene Zuordnung, nach dem Wert verschluesselt und
nicht nach der Beschriftung: Eintritt gruen, Austritt rot, Wiedervorlage gelb,
Rueckkehr violett. Tuerkis waere fuer die Rueckkehr die naheliegendere Lesart
gewesen, kam gegen das Gruen des Eintritts aber nur auf dE 13.0; Violett steht
mit 30.8 eindeutig daneben. Schwaechstes Paar der vier: 14.2, schwaechster
Kontrast 5.49:1.

Zehn Tests dazu, darunter die drei Faelle, an denen der Filter gescheitert war.

Lint, Typen, Schemaabgleich, 562 Tests und der Build sind sauber. Im Browser
nicht gesehen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 20:42:42 +02:00

350 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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> = {}): 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,
teilzeit_art: null,
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");
});
// Der No-Show (Migration 20260814100000): eingestellt, nie erschienen, der
// Austritt wird noch vor dem Eintrittstag verbucht. Vorher gewann hier
// „Geplant", weil der Eintritt zuerst geprüft wurde — die Liste zeigte
// „Ausgetreten" und der Filter „Geplant" fand dieselbe Person trotzdem.
it("is Ausgetreten when the exit is done, even with the entry still ahead", () => {
const noShow = { ...base, entry_date: "2026-10-01", exit_date: "2026-09-10" };
expect(deriveStatusAsOf(noShow, "2026-09-15")).toBe("Ausgetreten");
});
it("stays Geplant while the entry is ahead and nothing has been taken back", () => {
expect(deriveStatusAsOf({ ...base, entry_date: "2026-10-01" }, "2026-09-15")).toBe("Geplant");
});
// Ein Austritt, der selbst noch bevorsteht, nimmt den Eintritt nicht
// zurück: am Stichtag ist beides Zukunft, und geplant ist die Person
// weiterhin.
it("stays Geplant when both entry and exit are still ahead", () => {
const geplantMitEnde = { ...base, entry_date: "2026-10-01", exit_date: "2026-12-31" };
expect(deriveStatusAsOf(geplantMitEnde, "2026-09-15")).toBe("Geplant");
});
});
describe("aggregateEvents", () => {
const eventLookups: OrgLookups = {
...lookups,
divisionName: new Map([
["team-1", "Produktion"],
["team-2", "IT"],
]),
};
function ev(overrides: Partial<ReportEvent> = {}): 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" });
});
});
// Auswertbar sein war der Zweck: „wer ist gerade in Bildungsteilzeit" liess
// sich vorher nicht beantworten, weil die Variante nur im Text der Historie
// stand.
describe("Gruppierung nach Teilzeitvariante", () => {
it("gruppiert nach der Variante", () => {
expect(groupKeyFor(emp({ teilzeit_art: "Bildungsteilzeit" }), "teilzeit_art", lookups)).toBe("Bildungsteilzeit");
expect(groupKeyFor(emp({ teilzeit_art: "Elternteilzeit" }), "teilzeit_art", lookups)).toBe("Elternteilzeit");
});
it("nennt das Fehlen „Keine“ statt eines Gedankenstrichs", () => {
// In einer Auswertung ist „keine Teilzeit" eine Aussage, kein Fehlen.
expect(groupKeyFor(emp({ teilzeit_art: null }), "teilzeit_art", lookups)).toBe("Keine");
});
});