Files
Laufzettel/tests/csv.test.ts
T
Florian WildeandClaude Opus 5 86d55917f9
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(stammdaten): Mitarbeiter, Abteilungen und Geraete
Die Stammdaten, an denen die Historie haengt. Personen werden deaktiviert,
Geraete und Abteilungen archiviert — geloescht wird nichts, woran ein Lauf
haengen kann.

- Mitarbeiterliste mit Suche ueber Anzeigename, Mail, UPN und sAMAccountName,
  Filter nach Abteilung, standardmaessig nur aktive; die Suche ist ein eigenes
  Bauteil, weil #013 und #014 sie wieder brauchen
- Personenseite mit Stammdaten, Geraeten, Vorgesetztem; was aus dem Verzeichnis
  kommt, ist gesperrt — der naechste Abgleich wuerde es ohnehin zurueckdrehen
- Abteilungen unter Admin: Ziel-OU, Standardgruppen, Mail-Domain, dazu die
  Namen, die bei Personen vorkommen, aber nicht gepflegt sind
- Geraete mit Suche ueber Hostname, Bezeichnung, Seriennummer und Tag-Code;
  CSV-Import mit einem Ergebnis je Zeile
- Neues Recht mitarbeiterBearbeiten; die Seitenleiste fuehrt Personen und
  Geraete unter einem Eintrag, im Modus Dienstleistung anders beschriftet

Der unabhaengige Review fand zwei Blocker. Der schwerere meldete Erfolg: ein
nicht geschlossenes Anfuehrungszeichen verschluckte im CSV-Import alle
Folgezeilen bis zum naechsten `"`, und der Import sagte „3 von 3 Zeilen
angelegt". Ein Wert reicht jetzt nicht mehr ueber die Zeile hinaus — fuer
Hostnamen und Seriennummern ist ein Umbruch praktisch immer ein vergessenes
Anfuehrungszeichen, und so endet der Schaden an seiner Zeile. Der zweite:
ungepflegte Abteilungen wurden ueber alle Kunden hinweg gruppiert.

Dazu sieben weitere Befunde, alle behoben — darunter eine Besitzerauswahl, die
Personen fremder Kunden anbot, und ein Techniker, der ein Geraet endgueltig
loeschen konnte.

610 Tests gruen, 18 E2E-Ablaeufe gegen den gebauten Stand.

Schliesst #011

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 12:51:54 +02:00

255 lines
10 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
CsvFehler,
csvErzeugen,
csvZerlegen,
hatSpalte,
spaltenName,
trennzeichenErkennen,
wertAus,
} from "@/lib/csv";
/**
* CSV lesen und schreiben (#011).
*
* Reine Logik ohne Datenbank — und genau der Ort, an dem sich die Randfälle
* billig absichern lassen. Geprüft wird, was in echten Dateien steht: das
* Semikolon des deutschen Excel, ein BOM davor, CRLF, Werte in
* Anführungszeichen mit Trennzeichen und Umbruch darin, verdoppelte
* Anführungszeichen, Leerzeilen und Kopfzeilen in jeder Schreibweise.
*/
/** Kürzer als `zeilen.map(...)` in jedem Test. */
function werte(text: string, spalte: string): readonly string[] {
return csvZerlegen(text).zeilen.map((zeile) => wertAus(zeile, spalte));
}
describe("Spaltennamen normalisieren", () => {
it("macht Schreibweise, Leerraum, Unterstrich und Bindestrich unerheblich", () => {
for (const roh of ["serialNumber", "SerialNumber", " Serial Number ", "serial_number", "SERIAL-NUMBER"]) {
expect(spaltenName(roh), roh).toBe("serialnumber");
}
});
});
describe("Trennzeichen erkennen", () => {
it("erkennt das Semikolon, das deutsches Excel schreibt", () => {
expect(trennzeichenErkennen("hostname;label;type\na;b;c\n")).toBe(";");
});
it("erkennt das Komma", () => {
expect(trennzeichenErkennen("hostname,label,type\na,b,c\n")).toBe(",");
});
it("liest nur die Kopfzeile und lässt sich von Kommas in den Werten nicht täuschen", () => {
// Die Werte enthalten viermal so viele Kommas wie die Kopfzeile Semikolons.
const text = "hostname;label\nPC-1;Regal, Keller, hinten, links\nPC-2;Regal, Keller, vorn, rechts\n";
expect(trennzeichenErkennen(text)).toBe(";");
expect(werte(text, "label")).toEqual(["Regal, Keller, hinten, links", "Regal, Keller, vorn, rechts"]);
});
it("nimmt bei einer einspaltigen Datei das Komma – das Zeichen der Norm", () => {
expect(trennzeichenErkennen("hostname\nPC-1\n")).toBe(",");
});
it("lässt sich überstimmen", () => {
const tabelle = csvZerlegen("a;b\n1;2\n", { trennzeichen: "," });
expect(tabelle.spalten).toEqual(["a;b"]);
});
});
describe("Kopfzeile", () => {
it("ist unabhängig von Groß- und Kleinschreibung", () => {
const tabelle = csvZerlegen("HostName,LABEL,Serial Number\npc-1,Regal,SN1\n");
expect(tabelle.spalten).toEqual(["hostname", "label", "serialnumber"]);
expect(wertAus(tabelle.zeilen[0]!, "serialNumber")).toBe("SN1");
});
it("überspringt ein BOM am Dateianfang", () => {
const tabelle = csvZerlegen("hostname,label\npc-1,Regal\n");
expect(tabelle.spalten).toEqual(["hostname", "label"]);
});
it("nimmt bei einem doppelten Spaltennamen den ersten", () => {
const tabelle = csvZerlegen("hostname,label,hostname\npc-1,Regal,pc-2\n");
expect(wertAus(tabelle.zeilen[0]!, "hostname")).toBe("pc-1");
});
it("wirft bei einer leeren Datei", () => {
expect(() => csvZerlegen("")).toThrow(CsvFehler);
expect(() => csvZerlegen("\r\n")).toThrow(CsvFehler);
});
it("sagt, welche Spalten es gibt", () => {
const tabelle = csvZerlegen("hostname;typ\npc-1;PC\n");
expect(hatSpalte(tabelle, "hostname")).toBe(true);
expect(hatSpalte(tabelle, "label", "bezeichnung")).toBe(false);
expect(hatSpalte(tabelle, "type", "typ")).toBe(true);
});
});
describe("Werte in Anführungszeichen", () => {
it("nimmt Trennzeichen und verdoppelte Anführungszeichen auf", () => {
const text = 'hostname,label\npc-1,"Regal, Keller"\npc-2,"Der ""gute"" Platz"\n';
expect(werte(text, "label")).toEqual(["Regal, Keller", 'Der "gute" Platz']);
});
it("nimmt Umbrüche im Wert nur auf ausdrücklichen Wunsch auf", () => {
// Standard ist **nein**: ein Umbruch in einem Hostnamen kommt nicht vor, ein
// vergessenes schliessendes Anfuehrungszeichen staendig. Siehe
// `werteUeberZeilen` — ohne die Grenze verschluckt eine kaputte Zelle alle
// Folgezeilen, und der Import meldet dafuer Erfolg.
const text = 'hostname,label\npc-1,"Zeile 1\nZeile 2"\n';
const tabelle = csvZerlegen(text, { werteUeberZeilen: true });
expect(tabelle.zeilen.map((zeile) => wertAus(zeile, "label"))).toEqual(["Zeile 1\nZeile 2"]);
// Streng gelesen ist Zeile 2 kaputt, und Zeile 3 („Zeile 2\"“) ist nur noch
// der Rest davon – gemeldet wird beides, verschluckt nichts.
const streng = csvZerlegen(text);
expect(streng.zeilen[0]?.nummer).toBe(2);
expect(streng.zeilen[0]?.fehler).toMatch(/nicht geschlossen/);
});
it("normalisiert CRLF innerhalb eines Werts auf einen einzelnen Umbruch", () => {
const tabelle = csvZerlegen('hostname,label\r\npc-1,"oben\r\nunten"\r\n', {
werteUeberZeilen: true,
});
expect(tabelle.zeilen.map((zeile) => wertAus(zeile, "label"))).toEqual(["oben\nunten"]);
});
it("behält Leerraum innerhalb der Anführungszeichen, verwirft ihn außerhalb", () => {
const text = 'hostname , label\n pc-1 , " Regal " \n';
const tabelle = csvZerlegen(text);
expect(tabelle.spalten).toEqual(["hostname", "label"]);
expect(wertAus(tabelle.zeilen[0]!, "hostname")).toBe("pc-1");
// `wertAus` trimmt für die Anwendung; der rohe Wert bleibt erhalten.
expect(tabelle.zeilen[0]!.werte[1]).toBe(" Regal ");
});
it("behandelt ein Anführungszeichen mitten im Wert als gewöhnliches Zeichen", () => {
expect(werte('hostname,label\npc-1,24" Monitor\n', "label")).toEqual(['24" Monitor']);
});
it("meldet ein nicht geschlossenes Anführungszeichen als Zeilenfehler, statt zu werfen", () => {
/*
* Frueher warf das hier — und damit starb der ganze Import an einer einzigen
* kaputten Zelle. Das Akzeptanzkriterium verlangt das Gegenteil: die Zeile
* wird gemeldet, die anderen werden eingelesen.
*/
const tabelle = csvZerlegen('hostname,label\npc-1,"offen\npc-2,gut\n');
expect(tabelle.zeilen.map((zeile) => zeile.nummer)).toEqual([2, 3]);
expect(tabelle.zeilen[0]?.fehler).toMatch(/Anführungszeichen/);
expect(tabelle.zeilen[1]?.fehler).toBeUndefined();
expect(wertAus(tabelle.zeilen[1]!, "hostname")).toBe("pc-2");
});
it("meldet Text nach dem schließenden Anführungszeichen, statt ihn anzuhängen", () => {
// Vorher wanderte alles Folgende stillschweigend in diesen Wert – samt der
// naechsten Zeilen.
const tabelle = csvZerlegen('hostname,label\npc-1,"Regal" Keller,x\npc-2,gut\n');
expect(tabelle.zeilen[0]?.fehler).toMatch(/schließenden Anführungszeichen/);
expect(wertAus(tabelle.zeilen[1]!, "hostname")).toBe("pc-2");
});
it("wirft nur, wenn schon die Kopfzeile unlesbar ist", () => {
// Ohne Spaltennamen hat keine einzige Zeile eine Bedeutung – da hilft keine
// Zeilenmeldung.
expect(() => csvZerlegen('hostname,"label\npc-1,Regal\n')).toThrow(CsvFehler);
expect(() => csvZerlegen('hostname,"label\npc-1,Regal\n')).toThrow(/Kopfzeile/);
});
});
describe("Zeilen", () => {
it("überspringt Leerzeilen und Zeilen aus lauter Trennzeichen", () => {
const tabelle = csvZerlegen("hostname;label\n\npc-1;Regal\n;;\r\n\npc-2;Kiste\n");
expect(tabelle.zeilen.map((zeile) => wertAus(zeile, "hostname"))).toEqual(["pc-1", "pc-2"]);
});
it("zählt Zeilennummern wie ein Editor – über Leerzeilen und Umbrüche in Werten hinweg", () => {
const text = [
"hostname,label", // 1
"", // 2
"pc-1,Regal", // 3
'pc-2,"oben', // 4
'unten"', // 5
"pc-3,Kiste", // 6
].join("\n");
const tabelle = csvZerlegen(text, { werteUeberZeilen: true });
expect(tabelle.zeilen.map((zeile) => zeile.nummer)).toEqual([3, 4, 6]);
});
it("kommt ohne abschließenden Umbruch aus", () => {
expect(werte("hostname,label\npc-1,Regal", "hostname")).toEqual(["pc-1"]);
});
it("verträgt fehlende und zusätzliche Spalten", () => {
const tabelle = csvZerlegen("hostname,label,type\npc-1\npc-2,Regal,PC,zuviel,noch mehr\n");
const [kurz, lang] = tabelle.zeilen;
// Fehlende Spalte: kein Eintrag, also auch kein leerer String.
expect(kurz!.felder.has("label")).toBe(false);
expect(wertAus(kurz!, "label")).toBe("");
expect(wertAus(lang!, "type")).toBe("PC");
// Ueberzaehlige Werte bleiben erreichbar, ohne eine Spalte zu belegen.
expect(lang!.werte).toHaveLength(5);
});
it("bricht bei zu vielen Zeilen ab und nennt die Grenze", () => {
const viele = ["hostname", ...Array.from({ length: 5 }, (_, i) => `pc-${i}`)].join("\n");
expect(() => csvZerlegen(viele, { hoechstZeilen: 3 })).toThrow(/mehr als 3 Zeilen/);
// Genau an der Grenze geht es noch.
expect(csvZerlegen(viele, { hoechstZeilen: 5 }).zeilen).toHaveLength(5);
});
});
describe("Mehrere Namen für dieselbe Spalte", () => {
it("nimmt den ersten gesetzten Wert", () => {
const tabelle = csvZerlegen("label;bezeichnung\n;Regal\nKiste;\n");
expect(tabelle.zeilen.map((zeile) => wertAus(zeile, "label", "bezeichnung"))).toEqual(["Regal", "Kiste"]);
});
});
describe("Erzeugen", () => {
it("maskiert Trennzeichen, Anführungszeichen, Umbrüche und Randleerraum", () => {
const text = csvErzeugen(
["a", "b"],
[
{ a: 'x;y "z"', b: "oben\nunten" },
{ a: " Rand ", b: "" },
],
{
mitBom: false,
},
);
expect(text).toBe('a;b\r\n"x;y ""z""";"oben\nunten"\r\n" Rand ";\r\n');
});
it("stellt standardmäßig ein BOM voran und trennt mit Semikolon – für deutsches Excel", () => {
const text = csvErzeugen(["hostname"], [{ hostname: "pc-1" }]);
expect(text.startsWith("")).toBe(true);
expect(text.endsWith("\r\n")).toBe(true);
});
it("schreibt fehlende Werte als leere Zelle", () => {
expect(csvErzeugen(["a", "b"], [{ a: null }, { b: 7 }], { mitBom: false })).toBe("a;b\r\n;\r\n;7\r\n");
});
it("liest zurück, was es geschrieben hat", () => {
const spalten = ["hostname", "label", "type"];
const zeilen = [
{ hostname: "pc-1", label: 'Regal, "Keller"', type: "PC" },
{ hostname: "", label: "oben\nunten", type: "RAUM" },
{ hostname: " Rand ", label: "a;b,c", type: "" },
];
for (const trennzeichen of [",", ";"] as const) {
const text = csvErzeugen(spalten, zeilen, { trennzeichen });
// Der Rundlauf liest eine **selbst erzeugte** Datei – dort sind Umbrueche
// in Werten gewollt und korrekt maskiert.
const tabelle = csvZerlegen(text, { werteUeberZeilen: true });
expect(tabelle.trennzeichen, trennzeichen).toBe(trennzeichen);
expect(tabelle.zeilen.map((zeile) => zeile.werte)).toEqual(
zeilen.map((zeile) => spalten.map((spalte) => zeile[spalte as keyof typeof zeile] ?? "")),
);
}
});
});