Files
Laufzettel/tests/csv.test.ts
T
florian.wilde f85bc87324
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(stammdaten): Mitarbeiter, Abteilungen und Geraete (#25)
2026-09-25 10:52:44 +00:00

255 lines
10 KiB
TypeScript
Raw Permalink 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] ?? "")),
);
}
});
});