Die Spalte fuehrte zwei Zeilen: den Bereich und darunter die Einheit der
Person. Beim Kunden heissen die Bereiche CEO, CFO, CSMO, CPO, COO, CHRO --
Rollenbezeichnungen, die ueber jedem Namen dasselbe wiederholten. Auf
seinen Wunsch bleibt nur die Einheit stehen; der ganze Weg von oben steht
weiterhin im title, denn eine Einheit wie "Shopleitung" sagt allein nicht,
welche gemeint ist.
Sortiert wird jetzt ebenfalls nach der Einheit. Bliebe der Bereich das
erste Kriterium, ordnete die Spalte nach einem Wert, den sie nicht mehr
anzeigt -- von aussen sieht das aus wie gar keine Sortierung. Der
rekursive Ausdruck aus 3e49be5 entfaellt damit; divisionOf bleibt in
Gebrauch, die Uebersicht gruppiert weiter nach Bereich.
263 lines
10 KiB
TypeScript
263 lines
10 KiB
TypeScript
import { DummyDriver, Kysely, PostgresAdapter, PostgresIntrospector, PostgresQueryCompiler } from "kysely";
|
||
import type { Schema } from "@/lib/db/schema";
|
||
import { describe, expect, it } from "vitest";
|
||
import {
|
||
SORTIERFELDER,
|
||
STANDARD_FELD,
|
||
STANDARD_RICHTUNG,
|
||
naechsteRichtung,
|
||
parseFeld,
|
||
parseRichtung,
|
||
sortiere,
|
||
type Sortierfeld,
|
||
} from "@/lib/employee-sort";
|
||
|
||
// Feld und Richtung stehen in der Adresse, und dort kann alles stehen: eine
|
||
// veraltete Verknüpfung, ein Tippfehler, ein abgeschnittener Wert. Sie dürfen
|
||
// die Liste weder leeren noch in einer Reihenfolge zeigen, die niemand
|
||
// gewählt hat — und schon gar nicht in die Abfrage durchschlagen.
|
||
|
||
describe("parseFeld", () => {
|
||
it("nimmt jedes angebotene Feld", () => {
|
||
// Aus der Liste abgeleitet, nicht abgeschrieben: käme eine achte Spalte
|
||
// dazu, prüft dieser Test sie mit, ohne dass jemand daran denken muss.
|
||
for (const f of SORTIERFELDER) {
|
||
expect(parseFeld(f.value)).toBe(f.value);
|
||
}
|
||
});
|
||
|
||
it("fällt ohne Angabe auf den Namen zurück", () => {
|
||
expect(parseFeld(undefined)).toBe(STANDARD_FELD);
|
||
expect(parseFeld("")).toBe(STANDARD_FELD);
|
||
expect(STANDARD_FELD).toBe("name");
|
||
});
|
||
|
||
it("lässt sich keine Spalte unterschieben", () => {
|
||
// Das Feld entscheidet, welcher SQL-Ausdruck in die Sortierung geht.
|
||
expect(parseFeld("Name")).toBe(STANDARD_FELD);
|
||
expect(parseFeld("sv_nummer")).toBe(STANDARD_FELD);
|
||
expect(parseFeld("id; drop table employees")).toBe(STANDARD_FELD);
|
||
});
|
||
});
|
||
|
||
describe("parseRichtung", () => {
|
||
it("kennt genau zwei Richtungen", () => {
|
||
expect(parseRichtung("asc")).toBe("asc");
|
||
expect(parseRichtung("desc")).toBe("desc");
|
||
});
|
||
|
||
it("fällt auf aufsteigend zurück", () => {
|
||
expect(parseRichtung(undefined)).toBe(STANDARD_RICHTUNG);
|
||
expect(parseRichtung("DESC")).toBe(STANDARD_RICHTUNG);
|
||
expect(parseRichtung("absteigend")).toBe(STANDARD_RICHTUNG);
|
||
expect(STANDARD_RICHTUNG).toBe("asc");
|
||
});
|
||
});
|
||
|
||
describe("naechsteRichtung", () => {
|
||
it("dreht die Richtung, wenn dieselbe Spalte nochmal angeklickt wird", () => {
|
||
expect(naechsteRichtung("name", "asc", "name")).toBe("desc");
|
||
expect(naechsteRichtung("name", "desc", "name")).toBe("asc");
|
||
});
|
||
|
||
it("beginnt bei einer anderen Spalte wieder aufsteigend", () => {
|
||
// Wer von „Eintritt, neueste zuerst" auf „Name" wechselt, will Namen von
|
||
// A an — nicht die Richtung der vorigen Spalte geerbt.
|
||
expect(naechsteRichtung("eintritt", "desc", "name")).toBe("asc");
|
||
expect(naechsteRichtung("status", "desc", "persnr")).toBe("asc");
|
||
});
|
||
|
||
it("liefert für jede Kombination eine der beiden Richtungen", () => {
|
||
// Die Richtung geht unmittelbar in die Abfrage; etwas anderes als asc
|
||
// oder desc wäre dort ein Fehler zur Laufzeit, nicht beim Bauen.
|
||
const felder = SORTIERFELDER.map((f) => f.value as Sortierfeld);
|
||
for (const a of felder) {
|
||
for (const b of felder) {
|
||
for (const r of ["asc", "desc"] as const) {
|
||
expect(["asc", "desc"]).toContain(naechsteRichtung(a, r, b));
|
||
}
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("SORTIERFELDER", () => {
|
||
it("deckt jede Spalte der Liste ab", () => {
|
||
// Die Kopfzeile wird aus dieser Liste erzeugt. Fehlt eine Spalte hier,
|
||
// fehlt sie in der Tabelle — und eine überzählige erzeugt einen Kopf
|
||
// ohne Inhalt darunter.
|
||
expect(SORTIERFELDER.map((f) => f.label)).toEqual([
|
||
"Mitarbeiter:in",
|
||
"Pers.-Nr.",
|
||
"Organisationseinheit",
|
||
"Standort",
|
||
"Eintritt",
|
||
"Beschäftigung",
|
||
"Status",
|
||
]);
|
||
});
|
||
|
||
it("vergibt jeden Wert nur einmal", () => {
|
||
const werte = SORTIERFELDER.map((f) => f.value);
|
||
expect(new Set(werte).size).toBe(werte.length);
|
||
});
|
||
});
|
||
|
||
// ── Das erzeugte SQL ────────────────────────────────────────────────────
|
||
//
|
||
// Die Sortierung ist der einzige Teil dieser Seite, bei dem ein Fehler
|
||
// stumm bleibt: die Liste zeigt weiter fünfzehn Zeilen, nur die falschen.
|
||
// Deshalb wird hier nicht das Verhalten beschrieben, sondern die Abfrage
|
||
// gelesen, die tatsächlich herauskommt.
|
||
|
||
const db = new Kysely<Schema>({
|
||
dialect: {
|
||
createAdapter: () => new PostgresAdapter(),
|
||
createDriver: () => new DummyDriver(),
|
||
createIntrospector: (d) => new PostgresIntrospector(d),
|
||
createQueryCompiler: () => new PostgresQueryCompiler(),
|
||
},
|
||
});
|
||
|
||
const STICHTAG = "2026-09-15";
|
||
|
||
const ordnung = (feld: Sortierfeld, richtung: "asc" | "desc") =>
|
||
sortiere(db.selectFrom("employees").select("id"), feld, richtung, STICHTAG)
|
||
.compile()
|
||
.sql.replace(/^.*?order by /s, "")
|
||
.replace(/\s+/g, " ");
|
||
|
||
/**
|
||
* Die Sortierkriterien einzeln — nur an den Kommas *ausserhalb* von Klammern
|
||
* getrennt.
|
||
*
|
||
* Ein einfaches `split(", ")` zerschneidet die Unterabfrage für den Bereich:
|
||
* sie enthält selbst Kommas, in `coalesce(a, b, c)`.
|
||
*/
|
||
function kriterien(orderBy: string): string[] {
|
||
const teile: string[] = [];
|
||
let tiefe = 0;
|
||
let laufend = "";
|
||
for (const c of orderBy) {
|
||
if (c === "(") tiefe++;
|
||
else if (c === ")") tiefe--;
|
||
if (c === "," && tiefe === 0) {
|
||
teile.push(laufend.trim());
|
||
laufend = "";
|
||
continue;
|
||
}
|
||
laufend += c;
|
||
}
|
||
if (laufend.trim()) teile.push(laufend.trim());
|
||
return teile;
|
||
}
|
||
|
||
describe("das erzeugte SQL", () => {
|
||
it("sortiert Namen nach Nachname, dann Vorname", () => {
|
||
expect(ordnung("name", "asc")).toBe(
|
||
'"last_name" asc nulls last, "first_name" asc nulls last, "id"'
|
||
);
|
||
});
|
||
|
||
it("dreht beide Namensteile mit", () => {
|
||
// Nur den Nachnamen zu drehen ergäbe Z–A über die Nachnamen, aber A–Z
|
||
// innerhalb einer Namensgruppe — sichtbar bei den fünfzehn Aigner.
|
||
expect(ordnung("name", "desc")).toBe(
|
||
'"last_name" desc nulls last, "first_name" desc nulls last, "id"'
|
||
);
|
||
});
|
||
|
||
it("hängt an jede Spalte die Kennung als letztes Kriterium", () => {
|
||
// Ohne sie ist die Reihenfolge bei gleichen Werten unbestimmt, und
|
||
// dieselbe Person kann auf zwei Seiten stehen oder auf keiner.
|
||
for (const f of SORTIERFELDER) {
|
||
for (const r of ["asc", "desc"] as const) {
|
||
expect(ordnung(f.value, r), `${f.value}/${r}`).toMatch(/, "id"$/);
|
||
}
|
||
}
|
||
});
|
||
|
||
it("lässt Unbekanntes immer am Ende stehen", () => {
|
||
// Ohne `nulls last` stünden bei absteigender Sortierung alle Personen
|
||
// ohne Planstelle oder Standort ganz oben.
|
||
for (const f of SORTIERFELDER) {
|
||
for (const r of ["asc", "desc"] as const) {
|
||
const teile = kriterien(ordnung(f.value, r)).slice(0, -1); // ohne "id"
|
||
expect(teile.length, `${f.value}/${r}`).toBeGreaterThan(0);
|
||
for (const teil of teile) {
|
||
expect(teil, `${f.value}/${r}: ${teil}`).toContain("nulls last");
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
it("holt die Organisationseinheit über die laufende Besetzung", () => {
|
||
const sql = ordnung("einheit", "asc");
|
||
expect(sql).toContain("position_assignments");
|
||
expect(sql).toContain("pa.valid_to is null");
|
||
expect(sql).toContain("org_units");
|
||
|
||
// Sortiert wird nach der Einheit selbst, nicht nach dem Bereich darüber:
|
||
// die Spalte zeigt seit 22.09.2026 nur noch die Einheit, und eine
|
||
// Sortierung nach einem Wert, der nirgends steht, sieht von aussen aus
|
||
// wie gar keine Sortierung.
|
||
expect(sql).not.toContain("unit_type");
|
||
});
|
||
|
||
it("sortiert den Standort nach seinem Namen, nicht nach seiner Kennung", () => {
|
||
// location_id ist eine UUID. Danach zu sortieren sähe aus wie Zufall.
|
||
expect(ordnung("standort", "asc")).toContain("select name from locations");
|
||
});
|
||
|
||
it("nimmt bei der Beschäftigung die Stunden als zweites Kriterium", () => {
|
||
const sql = ordnung("beschaeftigung", "asc");
|
||
expect(sql.indexOf("employment_type")).toBeLessThan(sql.indexOf("weekly_hours"));
|
||
});
|
||
|
||
it("setzt keinen Wert aus der Adresse in die Abfrage", () => {
|
||
// Die Richtung ist ausgeschrieben, nicht eingesetzt. Kämen je Werte aus
|
||
// der Adresse hierher, stünden sie im SQL statt als Parameter.
|
||
//
|
||
// Gebunden wird genau ein Wert, und er kommt nicht aus der Adresse: der
|
||
// Stichtag der Statusableitung. Deshalb wird er hier namentlich
|
||
// zugelassen und alles andere ausgeschlossen — „gar keine Parameter"
|
||
// wäre die schärfere Zusicherung, aber die falsche.
|
||
for (const f of SORTIERFELDER) {
|
||
const { parameters } = sortiere(db.selectFrom("employees").select("id"), f.value, "desc", STICHTAG).compile();
|
||
expect(new Set(parameters), f.value).toEqual(new Set(f.value === "status" ? [STICHTAG] : []));
|
||
}
|
||
});
|
||
|
||
// ── Status: abgeleitet, nicht aus der Spalte gelesen ──────────────────
|
||
//
|
||
// Die Zeile zeigt den zum Stichtag abgeleiteten Status. Nach
|
||
// `employees.status` zu sortieren hiesse, nach einem Wert zu ordnen, der
|
||
// nirgends auf der Seite steht — eine als „Ausgetreten" beschriftete Person
|
||
// stünde mitten unter den aktiven.
|
||
describe("Status", () => {
|
||
it("ordnet nach der Ableitung und nicht nach der Spalte", () => {
|
||
const sql = ordnung("status", "asc");
|
||
expect(sql).toContain("exit_date");
|
||
expect(sql).toContain("entry_date");
|
||
expect(sql).toContain("karenz_start_date");
|
||
expect(sql).not.toContain('"status"');
|
||
});
|
||
|
||
it("hält die Reihenfolge des Dienstverhältnisses ein", () => {
|
||
// Aktiv (1), Karenz (2), Geplant (3), Ausgetreten (4) — dieselbe
|
||
// Folge wie im Aufzählungstyp, und aussagekräftiger als alphabetisch.
|
||
const sql = ordnung("status", "asc");
|
||
expect(sql.indexOf("then 4")).toBeLessThan(sql.indexOf("then 3"));
|
||
expect(sql.indexOf("then 3")).toBeLessThan(sql.indexOf("then 2"));
|
||
expect(sql).toContain("else 1");
|
||
});
|
||
|
||
it("erkennt den Nichtantritt wie die Ableitung", () => {
|
||
// exit_date <= entry_date: kein einziger Tag Beschäftigung. Fehlte der
|
||
// Vergleich, sortierte ein künftiger Nichtantritt unter „Geplant",
|
||
// während sein Chip „Ausgetreten" zeigt.
|
||
expect(ordnung("status", "asc")).toContain("exit_date <= entry_date");
|
||
});
|
||
});
|
||
});
|