Files
velum-raycast/tests/velum-project.test.ts
2026-10-06 13:33:21 +02:00

206 lines
12 KiB
TypeScript

import { afterEach, describe, expect, it, mock } from "bun:test";
import { promises as fs } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { RunFile } from "../src/lib/velum-project";
mock.module("@raycast/api", () => ({ environment: { supportPath: tmpdir() } }));
const project = await import("../src/lib/velum-project");
const roots: string[] = [];
afterEach(async () => { await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); });
describe("Velum-Projektstände", () => {
it("speichert Projektregeln und Bearbeitungsstände getrennt vom Quellordner", async () => {
const root = await fs.mkdtemp(join(tmpdir(), "velum-project-test-"));
roots.push(root);
const source = join(root, "Quelle");
const support = join(root, "Raycast");
await fs.mkdir(source);
await fs.writeFile(join(source, "Notiz.txt"), "Max Mustermann");
const rules = project.parseProjectRules("PERSON: Max Mustermann\nKundenSuite");
await project.saveProjectRules(source, rules, support);
expect(await project.loadProjectRules(source, support)).toEqual(rules);
await project.saveProjectWhitelist(source, [
{ type: "PERSON", original: "Falscher Treffer" },
{ type: "PERSON", original: "falscher treffer" },
], support);
expect(await project.loadProjectWhitelist(source, support)).toEqual([
{ type: "PERSON", original: "falscher treffer" },
]);
expect(await fs.readFile(join(source, "Notiz.txt"), "utf8")).toBe("Max Mustermann");
const created = await project.createRun(source, support);
created.run.mapping = { PERSON_01: { original: "Max Mustermann", type: "PERSON" } };
created.run.files.push({ source: "Notiz.txt", output: "PERSON_01.txt", kind: "text" });
await project.saveRun(created.directory, created.run);
expect((await project.listRuns(source, support))[0].run.files[0].output).toBe("PERSON_01.txt");
expect(await fs.readFile(join(source, "Notiz.txt"), "utf8")).toBe("Max Mustermann");
});
it("ordnet erneute Läufe demselben Projektpfad zu und erkennt ältere Pfadschreibweisen", async () => {
const root = await fs.mkdtemp(join(tmpdir(), "velum-project-identity-test-"));
roots.push(root);
const source = join(root, "Quelle");
const support = join(root, "Raycast");
await fs.mkdir(source);
const first = await project.createRun(`${source}/`, support);
first.run.projectPath = `${source}/`;
await project.saveRun(first.directory, first.run);
const second = await project.createRun(join(source, "."), support);
await project.saveRun(second.directory, second.run);
expect(project.projectDataRoot(source, support)).toBe(project.projectDataRoot(`${source}/`, support));
expect(first.run.id).not.toBe(second.run.id);
expect((await project.listRuns(source, support)).map(({ run }) => run.id).sort())
.toEqual([first.run.id, second.run.id].sort());
});
it("legt pseudonymisierte Kopien neben dem Projekt ab und behält die Tabelle privat", async () => {
const root = await fs.mkdtemp(join(tmpdir(), "velum-sibling-copy-test-"));
roots.push(root);
const source = join(root, "Quelle");
const support = join(root, "Raycast");
await fs.mkdir(source);
const created = await project.createRun(source, support);
created.run.mapping = { PERSON_01: { original: "Max Mustermann", type: "PERSON" } };
created.run.files = [{ source: "Unterordner/Name.txt", output: "Unterordner/PERSON_01.txt", kind: "text" }];
await fs.mkdir(join(created.directory, "files", "Unterordner"));
await fs.writeFile(join(created.directory, "files", "Unterordner", "PERSON_01.txt"), "PERSON_01");
const copyPath = await project.publishProjectCopies(created.run, created.directory);
created.run.copyPath = copyPath;
await project.saveRun(created.directory, created.run);
expect(copyPath.startsWith(join(root, "Quelle-Velum-"))).toBe(true);
expect(project.runCopiesPath(created.run, created.directory)).toBe(copyPath);
expect(await fs.readFile(join(copyPath, "Unterordner", "PERSON_01.txt"), "utf8")).toBe("PERSON_01");
expect(await fs.readdir(copyPath)).toEqual(["Unterordner"]);
expect(await fs.readFile(join(created.directory, "run.json"), "utf8")).toContain("Max Mustermann");
await fs.writeFile(join(copyPath, "Antwort.txt"), "PERSON_01 antwortet");
const current = await project.scanRestorableCopyFiles(created.run, created.directory);
expect(current.root).toBe(copyPath);
expect(current.files.map((file) => file.relPath)).toEqual(["Antwort.txt", "Unterordner/PERSON_01.txt"]);
const history = await project.scanRestorableCopyFiles(created.run, created.directory, false);
expect(history.root).toBe(join(created.directory, "files"));
expect(history.files.map((file) => file.relPath)).toEqual(["Unterordner/PERSON_01.txt"]);
expect(project.restoreTextWithCount("PERSON_01 antwortet", created.run.mapping))
.toEqual({ text: "Max Mustermann antwortet", replacements: 1 });
await expect(project.publishProjectCopies(created.run, created.directory)).rejects.toThrow("bereits vorhanden");
expect((await project.listRuns(source, support))[0].run.copyPath).toBe(copyPath);
});
it("behält die Kategorieauswahl pro Projekt für den nächsten Lauf", async () => {
const root = await fs.mkdtemp(join(tmpdir(), "velum-entity-types-test-"));
roots.push(root);
const source = join(root, "Quelle");
const other = join(root, "Anderes Projekt");
const support = join(root, "Raycast");
await fs.mkdir(source);
await fs.mkdir(other);
expect(await project.loadProjectEntityTypes(source, support)).toBeNull();
await project.saveProjectEntityTypes(`${source}/`, ["PERSON", "ORG", "PERSON"], support);
expect(await project.loadProjectEntityTypes(source, support)).toEqual(["PERSON", "ORG"]);
expect(await project.loadProjectEntityTypes(other, support)).toBeNull();
});
it("stellt aktuelle In-Place-Dateien aus dem Projektordner und frühere Stände aus dem Snapshot bereit", async () => {
const root = await fs.mkdtemp(join(tmpdir(), "velum-current-source-test-"));
roots.push(root);
const source = join(root, "Projekt");
const support = join(root, "Raycast");
await fs.mkdir(source);
const created = await project.createRun(source, support);
created.run.inPlace = true;
created.run.files = [{ source: "Notiz.txt", output: "Notiz.txt", kind: "text" }];
await fs.writeFile(join(created.directory, "files", "Notiz.txt"), "PERSON_01");
await fs.writeFile(join(source, "Notiz.txt"), "PERSON_01");
await fs.writeFile(join(source, "Antwort.txt"), "PERSON_01 antwortet");
const current = await project.scanRestorableCopyFiles(created.run, created.directory);
expect(current.root).toBe(source);
expect(current.files.map((file) => file.relPath)).toEqual(["Antwort.txt", "Notiz.txt"]);
const historical = await project.scanRestorableCopyFiles(created.run, created.directory, false);
expect(historical.root).toBe(join(created.directory, "files"));
expect(historical.files.map((file) => file.relPath)).toEqual(["Notiz.txt"]);
});
it("ersetzt nur bestätigte Vorkommen und stellt Dateinamen wieder her", () => {
const hits = [
{ id: "a", start: 0, end: 3, placeholder: "PERSON_01" },
{ id: "b", start: 8, end: 11, placeholder: "PERSON_01" },
];
expect(project.replaceSpans("Max und Max", hits, new Set(["a"]))).toBe("PERSON_01 und Max");
expect(project.restorePath("PERSON_01_Bericht.txt", {
PERSON_01: { original: "Max Mustermann", type: "PERSON" },
})).toBe("Max Mustermann_Bericht.txt");
expect(project.restorePath("Archiv/BerichtPERSON_01.docx", {
PERSON_01: { original: "Max Mustermann", type: "PERSON" },
})).toBe("Archiv/BerichtMax Mustermann.docx");
expect(project.restoreText("BerichtPERSON_01", {
PERSON_01: { original: "Max Mustermann", type: "PERSON" },
})).toBe("BerichtPERSON_01");
expect(project.restorePath("PERSON_0101.txt", {
PERSON_01: { original: "Falsch", type: "PERSON" },
PERSON_0101: { original: "Richtig", type: "PERSON" },
})).toBe("Richtig.txt");
expect(() => project.safeRelativePath("../geheim.txt")).toThrow();
});
it("übernimmt API-Trefferpositionen auch nach einem Emoji korrekt", () => {
const text = "😀 Max";
const spans = project.toUtf16Offsets(text, [{ id: "max", start: 2, end: 5, placeholder: "PERSON_01" }]);
expect(project.replaceSpans(text, spans, new Set(["max"]))).toBe("😀 PERSON_01");
});
it("ersetzt ausgewählte Projektdateien vor Ort und sichert die Originale außerhalb", async () => {
const root = await fs.mkdtemp(join(tmpdir(), "velum-in-place-test-"));
roots.push(root);
const source = join(root, "Quelle");
const support = join(root, "Raycast");
await fs.mkdir(source);
await fs.writeFile(join(source, "Bericht.txt"), "Max Mustermann");
await fs.chmod(join(source, "Bericht.txt"), 0o640);
await fs.writeFile(join(source, "Unberührt.txt"), "bleibt");
const created = await project.createRun(source, support);
await fs.writeFile(join(created.directory, "files", "PERSON_01.txt"), "PERSON_01");
const files: RunFile[] = [{ source: "Bericht.txt", output: "PERSON_01.txt", kind: "text" }];
await project.replaceProjectFilesInPlace(source, created.directory, files);
expect(await fs.readFile(join(source, "PERSON_01.txt"), "utf8")).toBe("PERSON_01");
expect((await fs.stat(join(source, "PERSON_01.txt"))).mode & 0o777).toBe(0o640);
expect(await fs.readFile(join(created.directory, "backup", "Bericht.txt"), "utf8")).toBe("Max Mustermann");
expect((await fs.stat(join(created.directory, "backup", "Bericht.txt"))).mode & 0o777).toBe(0o600);
expect(await fs.readFile(join(source, "Unberührt.txt"), "utf8")).toBe("bleibt");
await expect(fs.access(join(source, "Bericht.txt"))).rejects.toMatchObject({ code: "ENOENT" });
});
it("verhindert Namenskollisionen vor dem Ersetzen", async () => {
const root = await fs.mkdtemp(join(tmpdir(), "velum-in-place-collision-test-"));
roots.push(root);
const source = join(root, "Quelle");
const support = join(root, "Raycast");
await fs.mkdir(source);
await fs.writeFile(join(source, "Bericht.txt"), "Original");
await fs.writeFile(join(source, "PERSON_01.txt"), "Andere Datei");
const created = await project.createRun(source, support);
await fs.writeFile(join(created.directory, "files", "PERSON_01.txt"), "Pseudonym");
await expect(project.replaceProjectFilesInPlace(source, created.directory,
[{ source: "Bericht.txt", output: "PERSON_01.txt", kind: "text" }])).rejects.toThrow("Zielname bereits vorhanden");
expect(await fs.readFile(join(source, "Bericht.txt"), "utf8")).toBe("Original");
expect(await fs.readFile(join(source, "PERSON_01.txt"), "utf8")).toBe("Andere Datei");
});
it("erkennt unveränderte Ausgaben für einen erneuten Lauf desselben Ordners", async () => {
const root = await fs.mkdtemp(join(tmpdir(), "velum-repeat-test-"));
roots.push(root);
const source = join(root, "Quelle");
const support = join(root, "Raycast");
await fs.mkdir(source);
const created = await project.createRun(source, support);
const filename = "PERSON_01.txt";
await fs.writeFile(join(created.directory, "files", filename), "PERSON_01");
const current = join(source, filename);
await fs.writeFile(current, "PERSON_01");
const hash = project.digest(await fs.readFile(current));
expect(await project.matchesPreviousOutput(created.directory, filename, hash)).toBe(true);
await fs.writeFile(current, "PERSON_01 und neuer Text");
expect(await project.matchesPreviousOutput(created.directory, filename,
project.digest(await fs.readFile(current)))).toBe(false);
});
});