The dropdown is gone; each column header is now a link that sorts by that column, with an arrow showing the direction. Clicking the column already sorted reverses it; clicking a different one starts ascending again — going from "Eintritt, newest first" to "Name" should give you names from A, not inherit the previous direction. Names sort by surname and then forename, as asked. Both parts reverse together: turning only the surname would give Z-A across surnames but A-Z within each one, which is visible immediately among the fifteen Aigner. Three of the seven columns are not on the employee row. Bereich and Team hang off the position, Standort off a lookup table, so they are fetched as correlated subqueries rather than joins. That is not a style preference: the same filter chain produces the page *and* the count, and a join onto position_assignments would double every person who has held more than one position over time — the line above the list would read 1,203 for 867 people. Bereich is the level below the company, so it needs to walk up from the unit. No recursion: org_unit_type has exactly four levels, so two hops up cover it. Everything sorts `nulls last`, otherwise reversing the direction floats every person without a position or location to the top. The expressions live in lib/employee-sort.ts rather than in the page so the generated SQL can be read in a test — the failure mode here is silent, the list still shows fifteen rows, just the wrong ones. Eighteen tests, and the rules are mutation-checked: dropping the forename, dropping the id tiebreaker, dropping `nulls last`, sorting the location by its uuid, and shortening the Bereich walk each turn them red. Not seen in a browser: login goes through the company account and the database is unreachable. Typecheck, lint, 458 tests and the build are clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
231 lines
8.6 KiB
TypeScript
231 lines
8.6 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.",
|
||
"Bereich/Team",
|
||
"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 ordnung = (feld: Sortierfeld, richtung: "asc" | "desc") =>
|
||
sortiere(db.selectFrom("employees").select("id"), feld, richtung)
|
||
.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 Bereich und Team über die laufende Besetzung", () => {
|
||
const sql = ordnung("bereich", "asc");
|
||
expect(sql).toContain("position_assignments");
|
||
expect(sql).toContain("pa.valid_to is null");
|
||
// Der Bereich ist die Ebene unter der Gesellschaft, nicht die Einheit
|
||
// selbst — sonst stünde in der Spalte etwas anderes als sortiert wird.
|
||
expect(sql).toContain("unit_type = 'Bereich'");
|
||
|
||
// Und über *alle drei* Ebenen: wer in einem Team sitzt, hat den Bereich
|
||
// zwei Sprünge über sich. Bliebe nur die eigene Einheit übrig, stünden
|
||
// alle Teammitglieder ohne Bereich da und rutschten ans Listenende.
|
||
//
|
||
// Dass drei Ebenen reichen, ist eine Aussage über die Daten (der
|
||
// Aufzählungstyp org_unit_type kennt genau vier Stufen) und lässt sich
|
||
// hier nicht prüfen — dass sie überhaupt abgefragt werden, schon.
|
||
expect(sql).toContain("ou.unit_type = 'Bereich'");
|
||
expect(sql).toContain("e1.unit_type = 'Bereich'");
|
||
expect(sql).toContain("e2.unit_type = 'Bereich'");
|
||
});
|
||
|
||
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.
|
||
for (const f of SORTIERFELDER) {
|
||
const { parameters } = sortiere(db.selectFrom("employees").select("id"), f.value, "desc").compile();
|
||
expect(parameters, f.value).toEqual([]);
|
||
}
|
||
});
|
||
});
|