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] ?? "")), ); } }); });