import { describe, expect, it } from "vitest"; import { MAX_ZEICHEN, einbettungstext, fundstelle, zerlegen } from "@/lib/ki/zerlegen"; /** * Das Zerlegen einer Wiki-Seite. * * Es entscheidet darüber, was später als Fundstelle unter einer Antwort steht. * Ein Abschnitt, der mitten im Satz beginnt, ist als Zitat wertlos — und ein * Codeblock, der an seinen Rauten zerfällt, macht aus einem Shell-Beispiel * vier sinnlose Bruchstücke. */ describe("zerlegen", () => { it("trennt an den Überschriften", () => { const abschnitte = zerlegen( ["# Erstens", "Text A", "", "# Zweitens", "Text B"].join("\n"), ); expect(abschnitte).toHaveLength(2); expect(abschnitte[0]!.ueberschrift).toBe("Erstens"); expect(abschnitte[0]!.text).toBe("Text A"); expect(abschnitte[1]!.ueberschrift).toBe("Zweitens"); }); it("führt die Überschriftenkette mit", () => { const abschnitte = zerlegen( [ "# FwDV 7", "Einleitung, lang genug um für sich zu stehen und nicht angehängt zu werden.", "", "## Rückzugsdruck", "Der Rückzugsdruck ist der Druck, bei dem der Trupp den Rückweg antritt.", ].join("\n"), ); expect(abschnitte[1]!.pfad).toEqual(["FwDV 7", "Rückzugsdruck"]); }); it("räumt die Kette beim Zurückspringen auf eine höhere Ebene auf", () => { const abschnitte = zerlegen( [ "# Eins", "Ein hinreichend langer Absatz, damit er nicht mit dem nächsten verschmilzt.", "", "## Unterpunkt", "Noch ein hinreichend langer Absatz, damit er als eigener Abschnitt bleibt.", "", "# Zwei", "Und ein dritter Absatz, ebenfalls lang genug für einen eigenen Abschnitt.", ].join("\n"), ); // Nach dem Sprung zurück auf Ebene 1 darf "Unterpunkt" nicht mehr in der // Kette stehen — sonst zitiert die Antwort eine Fundstelle, die es nicht // gibt. expect(abschnitte.at(-1)!.pfad).toEqual(["Zwei"]); }); it("lässt Rauten in einem Codeblock in Ruhe", () => { const abschnitte = zerlegen( [ "# Anleitung", "Ein Absatz, der lang genug ist, um für sich zu stehen und nicht zu verschmelzen.", "", "```bash", "# Das ist ein Kommentar, keine Überschrift", "docker compose up -d", "```", ].join("\n"), ); // Genau ein Abschnitt: die Raute im Codeblock hat nichts getrennt. expect(abschnitte).toHaveLength(1); expect(abschnitte[0]!.text).toContain("# Das ist ein Kommentar"); }); it("teilt einen zu langen Abschnitt an Absatzgrenzen", () => { const absatz = "Ein Satz mit ein paar Wörtern darin. ".repeat(20).trim(); const lang = [absatz, "", absatz, "", absatz].join("\n"); const abschnitte = zerlegen(`# Lang\n${lang}`, 500); expect(abschnitte.length).toBeGreaterThan(1); for (const abschnitt of abschnitte) { expect(abschnitt.text.length).toBeLessThanOrEqual(500); // Kein Stück endet mitten im Wort. expect(abschnitt.text).not.toMatch(/\w-$/); } }); it("gibt geteilten Stücken dieselbe Überschrift", () => { const lang = "Ein hinreichend langer Satz zum Auffüllen. ".repeat(40); const abschnitte = zerlegen(`## Prüffristen\n${lang}`, 400); expect(abschnitte.length).toBeGreaterThan(1); for (const abschnitt of abschnitte) { expect(abschnitt.pfad).toEqual(["Prüffristen"]); } }); /** * Der Test, der eine falsche Fundstelle verhindert: ein kurzer Abschnitt * unter eigener Überschrift wird NICHT an den vorherigen gehängt. Sonst * stünde unter einer Antwort „Haupt" als Fundstelle, während der zitierte * Satz aus „Winzig" stammt. */ it("hängt einen kurzen Abschnitt nicht unter eine fremde Überschrift", () => { const abschnitte = zerlegen( [ "# Haupt", "Ein hinreichend langer Absatz, der als eigener Abschnitt bestehen bleibt.", "", "## Winzig", "Kurz.", ].join("\n"), ); expect(abschnitte).toHaveLength(2); expect(abschnitte[1]!.text).toBe("Kurz."); expect(abschnitte[1]!.pfad).toEqual(["Haupt", "Winzig"]); }); it("zieht Reststücke desselben Abschnitts wieder zusammen", () => { // Ein langer Abschnitt wird geteilt; bleibt am Ende ein kurzes Stück // übrig, gehört es an das vorherige — es stammt aus demselben Kapitel. const abschnitte = zerlegen(`## Eins\n${"Satz um Satz zu fuellen. ".repeat(30)}kurz.`, 400); expect(abschnitte.length).toBeGreaterThan(1); for (const abschnitt of abschnitte) { expect(abschnitt.pfad).toEqual(["Eins"]); expect(abschnitt.text.length).toBeGreaterThan(40); } }); it("kommt mit Text vor der ersten Überschrift zurecht", () => { const abschnitte = zerlegen( ["Ein Vorspann ohne Überschrift, lang genug um zu bestehen.", "", "# Danach", "Inhalt."].join( "\n", ), ); expect(abschnitte[0]!.ueberschrift).toBeNull(); expect(abschnitte[0]!.pfad).toEqual([]); }); it("liefert für leeren Text nichts", () => { expect(zerlegen("")).toEqual([]); expect(zerlegen(" \n\n ")).toEqual([]); }); it("hält die Vorgabelänge ein", () => { const lang = "Wort ".repeat(2000); for (const abschnitt of zerlegen(lang)) { expect(abschnitt.text.length).toBeLessThanOrEqual(MAX_ZEICHEN); } }); }); describe("einbettungstext", () => { /** * Der wichtigste Test der Datei: ein Abschnitt über den Rückzugsdruck ohne * das Wort „Atemschutz" darüber findet sich nicht, wenn jemand nach * Atemschutz fragt. */ it("stellt Seitentitel und Überschriftenkette voran", () => { const text = einbettungstext( { ueberschrift: "Rückzugsdruck", pfad: ["Atemschutz", "Rückzugsdruck"], text: "Zwei Drittel." }, "FwDV 7", ); expect(text).toContain("FwDV 7 › Atemschutz › Rückzugsdruck"); expect(text).toContain("Zwei Drittel."); }); it("kommt ohne Überschrift aus", () => { const text = einbettungstext({ ueberschrift: null, pfad: [], text: "Nur Text." }, "Titel"); expect(text).toBe("Titel\n\nNur Text."); }); }); describe("fundstelle", () => { it("verkettet Titel und Pfad", () => { expect(fundstelle("FwDV 7", ["Atemschutz", "Rückzugsdruck"])).toBe( "FwDV 7 › Atemschutz › Rückzugsdruck", ); }); it("lässt Leeres weg", () => { expect(fundstelle("Titel", [])).toBe("Titel"); expect(fundstelle("", ["Kapitel"])).toBe("Kapitel"); }); });