Files
alpenwerk-hr/tests/unit/build-org.test.ts
Maximilian Stubhan 35f17858d8 Build the org tree in the OM model as a tested pure function
Constructing the tree is where parent links, chief positions and number
ranges get wired up wrongly without anyone noticing — a team under the wrong
Bereich looks perfectly plausible in the org chart. So the construction is a
pure function taking an id generator, and the checks that matter are asserted
rather than eyeballed: exactly one root, every unit's parent of the expected
type, every unit reaching the root, one chief position per unit, unique
numbers in the right ranges, and every position pointing at a real unit and
a real job.

Also introduces the job catalogue this model needs. job_title was free text
per person, so "Schlosser:in" and "Schlosser" could coexist and no breakdown
by occupation was possible; jobs are now deduplicated by title and shared
across positions.

Every Abteilung gets a chief position, which is the level the old three-table
model had no room for.

Correction to the previous commit message: it claimed the cut-over could
follow later while the old tables kept working. It cannot. The legacy
resolve_manager_for() finds a Bereichsleitung by "division_id = X and
team_id is null and org_level = 1", and an Abteilungsleitung satisfies the
same predicate — its LIMIT 1 would then pick one of the two arbitrarily. The
two models cannot both be correct once the new level is populated, so the
remaining work is a single cut across the 11 RPCs that read the legacy
columns, not a gradual migration.
2026-07-27 12:52:02 +02:00

163 lines
6.2 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { buildOrg, type DivisionDef } from "@/supabase/build-org";
// Die Konstruktion des Org-Baums ist die Stelle, an der sich Elternbezüge
// und Leitungsplanstellen falsch verdrahten lassen, ohne dass es auffällt:
// ein Team unter dem falschen Bereich sieht im Organigramm plausibel aus.
const DIVISIONS: DivisionDef[] = [
{
name: "Produktion",
headTitle: "Bereichsleitung Produktion",
departments: [
{
name: "Fertigung",
leadTitle: "Abteilungsleitung Fertigung",
teams: [
{ name: "Montage", leadTitle: "Teamleitung Montage", icTitles: ["Monteur:in", "Anlagenführer:in"], baseSize: 3 },
{ name: "CNC", leadTitle: "Teamleitung CNC", icTitles: ["CNC-Fräser:in"], baseSize: 2 },
],
},
{
name: "Instandhaltung",
leadTitle: "Abteilungsleitung Instandhaltung",
teams: [{ name: "Mechanik", leadTitle: "Teamleitung Mechanik", icTitles: ["Schlosser:in"], baseSize: 2 }],
},
],
},
{
name: "IT",
headTitle: "Bereichsleitung IT",
departments: [
{
name: "Infrastruktur",
leadTitle: "Abteilungsleitung Infrastruktur",
teams: [{ name: "IT-Support", leadTitle: "Teamleitung IT-Support", icTitles: ["Systemadministrator:in"], baseSize: 2 }],
},
],
},
];
function build() {
let n = 0;
return buildOrg("Alpenwerk Industrie GmbH", DIVISIONS, () => `id-${++n}`);
}
describe("Struktur", () => {
it("hat genau eine Wurzel, und das ist die Gesellschaft", () => {
const { units } = build();
const roots = units.filter((u) => u.parent_id === null);
expect(roots).toHaveLength(1);
expect(roots[0].unit_type).toBe("Gesellschaft");
});
it("hängt jede Einheit unter den richtigen Typ", () => {
const { units } = build();
const byId = new Map(units.map((u) => [u.id, u]));
const erwartetesElternteil = { Bereich: "Gesellschaft", Abteilung: "Bereich", Team: "Abteilung" } as const;
for (const u of units) {
if (u.unit_type === "Gesellschaft") continue;
const parent = byId.get(u.parent_id!);
expect(parent?.unit_type, `${u.name} hängt falsch`).toBe(erwartetesElternteil[u.unit_type]);
}
});
it("baut die Ebenen vollständig auf", () => {
const { units } = build();
const zahl = (t: string) => units.filter((u) => u.unit_type === t).length;
expect(zahl("Gesellschaft")).toBe(1);
expect(zahl("Bereich")).toBe(2);
expect(zahl("Abteilung")).toBe(3);
expect(zahl("Team")).toBe(4);
});
it("führt jede Einheit über parent_id auf die Wurzel zurück", () => {
// Ein abgehängter Teilbaum würde im Organigramm nie gerendert.
const { units } = build();
const byId = new Map(units.map((u) => [u.id, u]));
for (const start of units) {
const gesehen = new Set<string>();
let cur = start;
while (cur.parent_id && !gesehen.has(cur.id)) {
gesehen.add(cur.id);
cur = byId.get(cur.parent_id)!;
}
expect(cur.parent_id, `${start.name} erreicht die Wurzel nicht`).toBeNull();
}
});
it("vergibt eindeutige Org-Nummern im richtigen Nummernkreis", () => {
const { units } = build();
expect(new Set(units.map((u) => u.org_number)).size).toBe(units.length);
const prefix = { Gesellschaft: "10", Bereich: "20", Abteilung: "21", Team: "22" } as const;
for (const u of units) expect(u.org_number.startsWith(prefix[u.unit_type]), u.name).toBe(true);
});
});
describe("Planstellen", () => {
it("gibt jeder Einheit genau eine Leitungsplanstelle", () => {
// Der Unique-Index in der Datenbank erzwingt das ebenfalls; hier soll
// der Seed gar nicht erst dagegenlaufen.
const { units, positions } = build();
for (const u of units) {
const chiefs = positions.filter((p) => p.org_unit_id === u.id && p.is_chief);
expect(chiefs, `${u.name}`).toHaveLength(1);
}
});
it("legt auch für die Abteilung eine Leitung an", () => {
// Die Ebene, die im Altmodell gefehlt hat.
const { units, positions } = build();
const abteilungen = units.filter((u) => u.unit_type === "Abteilung");
expect(abteilungen.length).toBeGreaterThan(0);
for (const a of abteilungen) {
expect(positions.some((p) => p.org_unit_id === a.id && p.is_chief)).toBe(true);
}
});
it("erzeugt für jedes Team so viele Mitarbeiter-Planstellen wie vorgesehen", () => {
const { units, icPositionsByTeam } = build();
const montage = units.find((u) => u.name === "Montage")!;
expect(icPositionsByTeam.get(montage.id)).toHaveLength(3);
expect(icPositionsByTeam.get(montage.id)!.every((p) => !p.is_chief)).toBe(true);
});
it("vergibt eindeutige Planstellennummern nach dem bestehenden Muster", () => {
const { positions } = build();
expect(new Set(positions.map((p) => p.position_number)).size).toBe(positions.length);
for (const p of positions) expect(p.position_number).toMatch(/^6\d{7}$/);
});
it("hängt jede Planstelle an eine existierende Einheit", () => {
const { units, positions } = build();
const ids = new Set(units.map((u) => u.id));
for (const p of positions) expect(ids.has(p.org_unit_id), p.position_number).toBe(true);
});
});
describe("Job-Katalog", () => {
it("führt jede Tätigkeit genau einmal", () => {
// "Schlosser:in" darf nicht als zwei Einträge existieren, sonst ist eine
// Auswertung nach Tätigkeit wertlos.
const { jobs } = build();
expect(new Set(jobs.map((j) => j.title)).size).toBe(jobs.length);
expect(new Set(jobs.map((j) => j.code)).size).toBe(jobs.length);
});
it("teilt denselben Job über mehrere Planstellen", () => {
const { positions, jobs } = build();
const jobById = new Map(jobs.map((j) => [j.id, j]));
const monteur = jobs.find((j) => j.title === "Monteur:in")!;
const stellen = positions.filter((p) => p.job_id === monteur.id);
expect(stellen.length).toBeGreaterThan(1);
expect(jobById.get(stellen[0].job_id)!.title).toBe("Monteur:in");
});
it("verweist jede Planstelle auf einen existierenden Job", () => {
const { positions, jobs } = build();
const ids = new Set(jobs.map((j) => j.id));
for (const p of positions) expect(ids.has(p.job_id), p.position_number).toBe(true);
});
});