First half of the mass import: a file becomes named sheets with typed rows,
and every rule that could reject a row is stated in one place.
Nothing here touches a database. The parser turns bytes into sheets, the
schema says which columns exist, and validation reports findings — the
existing state is passed in as a parameter. That is what makes 36 tests
possible without a connection, and the rules are the part worth testing.
Three decisions where the easy choice would have been silent corruption:
- A two-digit year is refused. "15.08.68" is 1968 as a birth date and 2068
as a contract end, and any rule invented here creates people not yet
born.
- "31.02.2026" is refused. Date turns it into March 3rd without complaint.
- An unrecognised value in a yes/no column is an error, not "no". Read the
other way, a typo in "Betriebsrat" quietly removes someone's dismissal
protection.
CSV is parsed rather than split. German Excel writes semicolons because the
comma is the decimal separator, so the delimiter is sniffed from the header;
a semicolon inside a quoted address would otherwise shift every following
column and import the row plausibly wrong. Quoted newlines, doubled quotes
and the byte-order mark Excel prepends are all handled — the last one makes
the first column read as "?Personalnummer", which is invisible in an editor.
Validation collects every finding instead of stopping at the first. With 800
rows that is the difference between correcting once and uploading eight
hundred times.
One rule earns its place from experience: a history event dated before the
entry it belongs to is refused here, with a row number, because the database
refuses it too — mid-insert, without one.
My own slip, caught by the type checker: `a ?? b ? c : d` does not mean what
it looks like; ?? binds tighter than the conditional.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
138 lines
5.5 KiB
TypeScript
138 lines
5.5 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
import { alsAufzaehlung, alsDatum, alsGanzzahl, alsJaNein, alsListe, alsZahl } from "@/lib/import/werte";
|
||
import { csvZerlegen, trennzeichenErkennen } from "@/lib/import/parse";
|
||
|
||
// Eine Importdatei kommt aus fremder Hand — aus einem Altsystem, aus Excel,
|
||
// von einer Steuerberatung. Die Fälle hier sind keine Randfälle, sondern das,
|
||
// was tatsächlich in solchen Dateien steht.
|
||
|
||
describe("Datum aus einer Zelle", () => {
|
||
it("liest ISO und österreichische Schreibweise", () => {
|
||
expect(alsDatum("2026-08-03")).toBe("2026-08-03");
|
||
expect(alsDatum("3.8.2026")).toBe("2026-08-03");
|
||
expect(alsDatum("03.08.2026")).toBe("2026-08-03");
|
||
expect(alsDatum("03/08/2026")).toBe("2026-08-03");
|
||
});
|
||
|
||
it("weist zweistellige Jahre ab, statt das Jahrhundert zu raten", () => {
|
||
// „15.08.68" ist als Geburtsdatum 1968 und als Vertragsende 2068. Wer
|
||
// hier eine Regel erfindet, legt lautlos Personen an, die noch nicht
|
||
// geboren sind.
|
||
expect(alsDatum("15.08.68")).toBeNull();
|
||
});
|
||
|
||
it("weist einen Tag ab, den es nicht gibt", () => {
|
||
// Date rechnet den 31.02. stillschweigend in den 03.03. um — genau die
|
||
// Sorte Korrektur, die niemand bemerkt.
|
||
expect(alsDatum("31.02.2026")).toBeNull();
|
||
expect(alsDatum("2026-13-01")).toBeNull();
|
||
});
|
||
|
||
it("liest die Serienzahl, die Excel in unformatierten Spalten hinterlässt", () => {
|
||
// 1 = 01.01.1900. Ab 60 verschiebt Excels erfundener 29.02.1900 alles um
|
||
// einen Tag; 61 ist deshalb der 01.03.1900 und nicht der 02.03.
|
||
expect(alsDatum("1")).toBe("1900-01-01");
|
||
expect(alsDatum("61")).toBe("1900-03-01");
|
||
// Zwei allgemein bekannte Anker, damit die Umrechnung nachprüfbar ist
|
||
// und nicht nur zu sich selbst passt.
|
||
expect(alsDatum("25569")).toBe("1970-01-01");
|
||
expect(alsDatum("44927")).toBe("2023-01-01");
|
||
expect(alsDatum("46237")).toBe("2026-08-03");
|
||
});
|
||
|
||
it("liefert für Unfug null statt eines Datums", () => {
|
||
expect(alsDatum("")).toBeNull();
|
||
expect(alsDatum("sofort")).toBeNull();
|
||
expect(alsDatum("3.8.")).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("Zahl aus einer Zelle", () => {
|
||
it("versteht Komma und Punkt als Dezimaltrenner", () => {
|
||
expect(alsZahl("38,5")).toBe(38.5);
|
||
expect(alsZahl("38.5")).toBe(38.5);
|
||
});
|
||
|
||
it("versteht beide Tausendertrennungen", () => {
|
||
expect(alsZahl("1.234,50")).toBe(1234.5);
|
||
expect(alsZahl("1,234.50")).toBe(1234.5);
|
||
});
|
||
|
||
it("liest ein einzelnes Komma deutsch", () => {
|
||
// „1,234" sind hierzulande 1,234 — nicht 1234. Andersherum wäre ein
|
||
// Monatsgehalt um Faktor 1000 daneben.
|
||
expect(alsZahl("1,234")).toBe(1.234);
|
||
});
|
||
|
||
it("verträgt Währungszeichen und Leerzeichen", () => {
|
||
expect(alsZahl(" 3 450,00 € ")).toBe(3450);
|
||
});
|
||
|
||
it("weist Text ab", () => {
|
||
expect(alsZahl("Vollzeit")).toBeNull();
|
||
expect(alsZahl("38,5h")).toBeNull();
|
||
expect(alsGanzzahl("38,5")).toBeNull();
|
||
expect(alsGanzzahl("2219")).toBe(2219);
|
||
});
|
||
});
|
||
|
||
describe("Ja/Nein aus einer Zelle", () => {
|
||
it("versteht die üblichen Schreibweisen", () => {
|
||
for (const j of ["ja", "JA", "j", "x", "X", "wahr", "true", "1"]) expect(alsJaNein(j)).toBe(true);
|
||
for (const n of ["nein", "N", "falsch", "false", "0", "-"]) expect(alsJaNein(n)).toBe(false);
|
||
});
|
||
|
||
it("liefert für Unbekanntes null statt „nein“", () => {
|
||
// Stillschweigend „nein“ anzunehmen hiesse, aus einem Tippfehler in der
|
||
// Spalte „Betriebsrat“ einen fehlenden Kündigungsschutz zu machen.
|
||
expect(alsJaNein("vielleicht")).toBeNull();
|
||
expect(alsJaNein("")).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("Liste und Aufzählung", () => {
|
||
it("trennt an Semikolon und Komma und wirft Leeres weg", () => {
|
||
expect(alsListe("Mo; Di ,Mi;")).toEqual(["Mo", "Di", "Mi"]);
|
||
expect(alsListe("")).toEqual([]);
|
||
});
|
||
|
||
it("bringt Aufzählungswerte auf die Schreibweise der Datenbank", () => {
|
||
expect(alsAufzaehlung("vollzeit", ["Vollzeit", "Teilzeit"])).toBe("Vollzeit");
|
||
expect(alsAufzaehlung(" TEILZEIT ", ["Vollzeit", "Teilzeit"])).toBe("Teilzeit");
|
||
expect(alsAufzaehlung("halbtags", ["Vollzeit", "Teilzeit"])).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("CSV", () => {
|
||
it("erkennt das Semikolon, das deutsches Excel schreibt", () => {
|
||
expect(trennzeichenErkennen("Personalnummer;Vorname;Nachname")).toBe(";");
|
||
expect(trennzeichenErkennen("Personalnummer,Vorname,Nachname")).toBe(",");
|
||
});
|
||
|
||
it("lässt ein Trennzeichen innerhalb von Anführungszeichen in Ruhe", () => {
|
||
// Ohne das verschiebt eine Adresse mit Semikolon alle folgenden Spalten
|
||
// — und die Zeile wird plausibel falsch importiert statt abgewiesen.
|
||
const [kopf, zeile] = csvZerlegen('Nr;Adresse;Ort\n1;"Hauptstr. 1; Stiege 2";Wien\n');
|
||
expect(kopf).toEqual(["Nr", "Adresse", "Ort"]);
|
||
expect(zeile).toEqual(["1", "Hauptstr. 1; Stiege 2", "Wien"]);
|
||
});
|
||
|
||
it("versteht verdoppelte Anführungszeichen und Umbrüche im Feld", () => {
|
||
const zeilen = csvZerlegen('A;B\n1;"sagt ""hallo""\nin zwei Zeilen"\n');
|
||
expect(zeilen[1]).toEqual(["1", 'sagt "hallo"\nin zwei Zeilen']);
|
||
});
|
||
|
||
it("verträgt CRLF und eine fehlende letzte Zeilenschaltung", () => {
|
||
expect(csvZerlegen("A;B\r\n1;2")).toEqual([
|
||
["A", "B"],
|
||
["1", "2"],
|
||
]);
|
||
});
|
||
|
||
it("entfernt die Byte-Reihenfolge-Markierung von Excel", () => {
|
||
// Ohne das heisst die erste Spalte „Personalnummer“ und wird als
|
||
// fehlend gemeldet — ein Fehler, der beim Ansehen der Datei unsichtbar ist.
|
||
expect(csvZerlegen("Personalnummer;Vorname")[0]).toEqual(["Personalnummer", "Vorname"]);
|
||
});
|
||
});
|