pi_coder/test-utils.ts
2026-05-29 19:06:29 +02:00

516 lines
20 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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.

// Unit-Tests für reine Hilfsfunktionen aus pi-coder-judge-extension.ts
//
// Ausführung (TypeScript):
// npx ts-node test-utils.ts
//
// Ausführung ohne ts-node (schneller):
// node --input-type=module < <(sed 's/: string//g; s/: unknown//g; s/: void//g; s/: boolean//g' test-utils.ts)
//
// Oder: Funktionen aus dieser Datei kopieren und als .js ausführen.
// ── Funktionen (aus Extension kopiert, kein pi-API-Import nötig) ─────────────
function normalizeForComparison(s: string): string {
return s.trim().replace(/\s+/g, " ").replace(/[.,;:!?]+$/g, "").toLowerCase();
}
function parseVerdict(text: string): string {
const m = text.match(/Urteil:\s*(PASS WITH CONCERNS|PASS|FAIL)/i);
return m ? m[1].toUpperCase() : "UNREADABLE";
}
function parseBlockers(text: string): string {
const m = text.match(
/(?:\*\*Blocker\*\*|##\s*Blocker|[-*]\s*Blocker)[:\n]([\s\S]*?)(?:\n(?:\*\*Major\*\*|##\s*Major|[-*]\s*Major)|\n(?:\*\*Minor\*\*|##\s*Minor|[-*]\s*Minor)|$)/i
);
return m ? m[1].trim() : "";
}
// ── Test-Harness ──────────────────────────────────────────────────────────────
let passed = 0;
let failed = 0;
function expect(actual: unknown, expected: unknown, label: string): void {
if (actual === expected) {
console.log(`${label}`);
passed++;
} else {
console.error(`${label}`);
console.error(` erwartet: ${JSON.stringify(expected)}`);
console.error(` erhalten: ${JSON.stringify(actual)}`);
failed++;
}
}
// ── normalizeForComparison ────────────────────────────────────────────────────
console.log("\nnormalizeForComparison()");
expect(normalizeForComparison(" Foo Bar "), "foo bar",
"trimmt führende/nachfolgende Leerzeichen");
expect(normalizeForComparison("Foo.\n"), "foo",
"entfernt trailing Punkt + Newline");
expect(normalizeForComparison("A B"), "a b",
"kollabiert mehrfache Leerzeichen");
expect(normalizeForComparison("Foo:"), "foo",
"entfernt trailing Doppelpunkt");
expect(normalizeForComparison("Foo;"), "foo",
"entfernt trailing Semikolon");
expect(normalizeForComparison("Foo!"), "foo",
"entfernt trailing Ausrufezeichen");
expect(normalizeForComparison("Foo?"), "foo",
"entfernt trailing Fragezeichen");
expect(normalizeForComparison("UPPER CASE"), "upper case",
"konvertiert zu Kleinbuchstaben");
// Loop-Detection: gleiche Blocker nach Normalisierung erkannt
expect(
normalizeForComparison("missing error handling.") ===
normalizeForComparison("missing error handling"),
true,
"Loop-Detection: trailing Punkt macht keinen Unterschied"
);
expect(
normalizeForComparison("null check missing\n") ===
normalizeForComparison("null check missing"),
true,
"Loop-Detection: Newline am Ende macht keinen Unterschied"
);
expect(
normalizeForComparison("Fehler bei Import.") ===
normalizeForComparison("Fehler bei Import"),
true,
"Loop-Detection: mehrfache Leerzeichen + Punkt machen keinen Unterschied"
);
expect(
normalizeForComparison("Blocker A") === normalizeForComparison("Blocker B"),
false,
"Loop-Detection: verschiedene Blocker werden NICHT als gleich erkannt"
);
// ── parseVerdict ──────────────────────────────────────────────────────────────
console.log("\nparseVerdict()");
expect(parseVerdict("Urteil: PASS"), "PASS",
"erkennt PASS");
expect(parseVerdict("Urteil: PASS WITH CONCERNS"), "PASS WITH CONCERNS",
"erkennt PASS WITH CONCERNS (vor PASS gematcht)");
expect(parseVerdict("Urteil: FAIL"), "FAIL",
"erkennt FAIL");
expect(parseVerdict("kein Urteil hier"), "UNREADABLE",
"gibt UNREADABLE zurück wenn kein Urteil");
expect(parseVerdict("urteil: pass"), "PASS",
"case-insensitiv: 'urteil: pass'");
expect(parseVerdict("urteil: Pass With Concerns"), "PASS WITH CONCERNS",
"case-insensitiv: gemischte Groß-/Kleinschreibung");
expect(parseVerdict("Das ist mein Urteil: PASS — und mehr Text dahinter"), "PASS",
"ignoriert Text nach dem Urteil");
expect(parseVerdict("Urteil:PASS"), "PASS",
"toleriert fehlenden Leerzeichen nach Doppelpunkt");
expect(parseVerdict(""), "UNREADABLE",
"leerer String → UNREADABLE");
// ── parseBlockers ─────────────────────────────────────────────────────────────
console.log("\nparseBlockers()");
expect(
parseBlockers("**Blocker**:\n- fehlende Validierung\n**Major**:\n- anderes Problem"),
"- fehlende Validierung",
"erkennt **Blocker** mit Bold-Syntax"
);
expect(
parseBlockers("## Blocker\nNull-Check fehlt\n## Major\nanderes"),
"Null-Check fehlt",
"erkennt ## Blocker mit Heading-Syntax"
);
expect(
parseBlockers("- Blocker:\n- fehlender Import\n- Minor:\n- Stil"),
"- fehlender Import",
"erkennt - Blocker mit Bullet-Syntax"
);
expect(
parseBlockers(" Blocker\nKein Logging\n- Minor\nKleinigkeit"),
"Kein Logging",
"erkennt Blocker (Gedankenstrich)"
);
expect(
parseBlockers("Urteil: PASS\n\nAlles ok."),
"",
"gibt leeren String zurück wenn kein Blocker-Abschnitt"
);
expect(
parseBlockers("**Blocker**:\nkeine\n**Minor**:\n- Stil"),
"keine",
"extrahiert 'keine' als Blocker-Text"
);
// Mehrzeiliger Blocker
const multilineInput = `**Blocker**:
- Import fehlt
- Funktion nicht definiert
**Major**:
- weitere Sache`;
const multilineResult = parseBlockers(multilineInput);
expect(
multilineResult.includes("Import fehlt") && multilineResult.includes("Funktion nicht definiert"),
true,
"extrahiert mehrzeiligen Blocker vollständig"
);
// ── toolExecutionLabel ────────────────────────────────────────────────────────
function toolExecutionLabel(toolName, args) {
switch (toolName) {
case "edit": return `Editiere ${args.path ?? "Datei"}`;
case "write": return `Schreibe ${args.path ?? "Datei"} neu…`;
case "read": return `Lese ${args.path ?? "Datei"}`;
case "grep": return `Suche in ${args.path ?? args.pattern ?? "Dateien"}`;
case "find": return `Suche Dateien: ${args.pattern ?? ""}`;
case "ls": return `Verzeichnis: ${args.path ?? "."}`;
case "bash": {
const cmd = String(args.command ?? "").trim().replace(/\n[\s\S]*/s, "");
if (/git\s+commit/.test(cmd)) return "Git-Commit…";
if (/git\s+add/.test(cmd)) return "Stage Änderungen…";
if (/git\s+tag/.test(cmd)) return "Git-Tag setzen…";
if (/pytest|npm test|cargo test|go test|make test/.test(cmd)) return "Tests laufen…";
if (/git\s+(diff|log|show|tag -l)/.test(cmd)) return "Git-History lesen…";
if (/patch\s+-p1/.test(cmd)) return "Wende Patch an…";
if (/curl/.test(cmd)) return "HTTP-Request…";
return `Shell: ${cmd.slice(0, 55)}${cmd.length > 55 ? "…" : ""}`;
}
case "apply_patch": return "Wende Patch an…";
default: return "";
}
}
console.log("\ntoolExecutionLabel()");
expect(toolExecutionLabel("edit", { path: "src/main.ts" }), "Editiere src/main.ts…",
"edit: gibt Pfad zurück");
expect(toolExecutionLabel("edit", {}), "Editiere Datei…",
"edit: Fallback 'Datei' wenn kein Pfad");
expect(toolExecutionLabel("write", { path: "README.md" }), "Schreibe README.md neu…",
"write: gibt Pfad zurück");
expect(toolExecutionLabel("read", { path: "foo.py" }), "Lese foo.py…",
"read: gibt Pfad zurück");
expect(toolExecutionLabel("bash", { command: "git commit -m 'fix'" }), "Git-Commit…",
"bash: git commit → Git-Commit");
expect(toolExecutionLabel("bash", { command: "git add -A" }), "Stage Änderungen…",
"bash: git add → Stage Änderungen");
expect(toolExecutionLabel("bash", { command: "git tag v1.0.0" }), "Git-Tag setzen…",
"bash: git tag → Git-Tag setzen");
expect(toolExecutionLabel("bash", { command: "pytest tests/" }), "Tests laufen…",
"bash: pytest → Tests laufen");
expect(toolExecutionLabel("bash", { command: "cargo test" }), "Tests laufen…",
"bash: cargo test → Tests laufen");
expect(toolExecutionLabel("bash", { command: "git log --oneline" }), "Git-History lesen…",
"bash: git log → Git-History lesen");
expect(toolExecutionLabel("bash", { command: "patch -p1 < foo.patch" }), "Wende Patch an…",
"bash: patch -p1 → Wende Patch an");
expect(toolExecutionLabel("bash", { command: "curl https://api.example.com" }), "HTTP-Request…",
"bash: curl → HTTP-Request");
expect(toolExecutionLabel("bash", { command: "ls ." }), "Shell: ls .",
"bash: unbekannter Befehl kurz → kein abschließendes …");
expect(toolExecutionLabel("bash", { command: "a".repeat(60) }), `Shell: ${"a".repeat(55)}`,
"bash: Befehl > 55 Zeichen → abgeschnitten mit …");
expect(toolExecutionLabel("apply_patch", {}), "Wende Patch an…",
"apply_patch → Wende Patch an");
expect(toolExecutionLabel("unknown_tool", {}), "",
"unbekanntes Tool → leerer String");
// ── getLastAssistantText ──────────────────────────────────────────────────────
function getLastAssistantText(ctx) {
const entries = ctx.sessionManager.getBranch();
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
if (entry.type === "message") {
const msg = entry.message;
if (msg?.role === "assistant" && Array.isArray(msg.content)) {
return msg.content
.filter((c) => c.type === "text")
.map((c) => c.text)
.join("\n");
}
}
}
return "";
}
function makeCtx(entries) {
return { sessionManager: { getBranch: () => entries } };
}
function makeMsg(role, content) {
return { type: "message", message: { role, content } };
}
console.log("\ngetLastAssistantText()");
expect(
getLastAssistantText(makeCtx([])),
"",
"leere Session → leerer String"
);
expect(
getLastAssistantText(makeCtx([
makeMsg("assistant", [{ type: "text", text: "Hallo" }]),
])),
"Hallo",
"eine assistant-Nachricht → deren Text"
);
expect(
getLastAssistantText(makeCtx([
makeMsg("user", [{ type: "text", text: "Frage" }]),
makeMsg("assistant", [{ type: "text", text: "Antwort" }]),
])),
"Antwort",
"user + assistant → gibt assistant-Text zurück"
);
expect(
getLastAssistantText(makeCtx([
makeMsg("assistant", [{ type: "text", text: "Erste" }]),
makeMsg("assistant", [{ type: "text", text: "Letzte" }]),
])),
"Letzte",
"mehrere assistant-Nachrichten → gibt die letzte zurück"
);
expect(
getLastAssistantText(makeCtx([
makeMsg("assistant", [{ type: "tool_use", id: "x", name: "bash", input: {} }]),
])),
"",
"assistant-Nachricht ohne text-Content → leerer String"
);
expect(
getLastAssistantText(makeCtx([
makeMsg("assistant", [
{ type: "text", text: "Teil 1" },
{ type: "tool_use", id: "x", name: "edit", input: {} },
{ type: "text", text: "Teil 2" },
]),
])),
"Teil 1\nTeil 2",
"gemischte text/tool_use-Inhalte → nur text-Teile mit \\n verbunden"
);
// ── detectBumpType ────────────────────────────────────────────────────────────
function detectBumpType(lines) {
if (lines.some(l => /^feat!:|BREAKING CHANGE/.test(l))) return "major";
if (lines.some(l => /^feat(\(.+\))?:/.test(l))) return "minor";
return "patch";
}
console.log("\ndetectBumpType()");
expect(detectBumpType(["feat!: neue API"]), "major",
"feat!: → major");
expect(detectBumpType(["BREAKING CHANGE: auth umgebaut"]), "major",
"BREAKING CHANGE → major");
expect(detectBumpType(["feat: CSV-Export"]), "minor",
"feat: → minor");
expect(detectBumpType(["feat(parser): neues Feature"]), "minor",
"feat(scope): → minor");
expect(detectBumpType(["fix: Crash behoben"]), "patch",
"fix: → patch");
expect(detectBumpType(["chore: cleanup"]), "patch",
"chore: → patch");
expect(detectBumpType(["feat: kleine Änderung", "feat!: breaking"]), "major",
"major hat Vorrang vor minor in gemischter Liste");
expect(detectBumpType([]), "patch",
"leere Commit-Liste → patch");
// ── parseSemVer ───────────────────────────────────────────────────────────────
function parseSemVer(tag) {
const m = tag.match(/^v?(\d+)\.(\d+)\.(\d+)$/);
return m ? [+m[1], +m[2], +m[3]] : null;
}
function expectDeep(actual, expected, label) {
expect(JSON.stringify(actual), JSON.stringify(expected), label);
}
console.log("\nparseSemVer()");
expectDeep(parseSemVer("v1.2.3"), [1, 2, 3], "v1.2.3 → [1, 2, 3]");
expectDeep(parseSemVer("1.2.3"), [1, 2, 3], "1.2.3 ohne v → [1, 2, 3]");
expectDeep(parseSemVer("v0.0.1"), [0, 0, 1], "v0.0.1 → [0, 0, 1]");
expectDeep(parseSemVer("v10.20.300"), [10, 20, 300], "v10.20.300 → dreistellige Zahlen");
expectDeep(parseSemVer("nicht-semver"), null, "ungültiger Tag → null");
expectDeep(parseSemVer("v1.2"), null, "unvollständig v1.2 → null");
expectDeep(parseSemVer(""), null, "leerer String → null");
// ── stripOptimizeFlags ────────────────────────────────────────────────────────
function stripOptimizeFlags(args) {
return (args || "")
.replace(/--rounds\s+\d+/, "")
.replace(/--test-timeout\s+\d+/, "")
.replace(/--with-doku/, "")
.replace(/--continue/, "")
.replace(/--interactive/, "")
.replace(/--no-tests/, "")
.replace(/--approve-concerns/, "")
.replace(/--test-cmd\s+"[^"]*"/, "")
.replace(/--test-cmd\s+\S+/, "")
.trim();
}
console.log("\nstripOptimizeFlags()");
expect(stripOptimizeFlags("mein Auftrag --rounds 3"), "mein Auftrag",
"--rounds N wird entfernt");
expect(stripOptimizeFlags("--with-doku Auftrag"), "Auftrag",
"--with-doku wird entfernt");
expect(stripOptimizeFlags("Auftrag --no-tests --approve-concerns"), "Auftrag",
"--no-tests und --approve-concerns werden entfernt");
expect(stripOptimizeFlags('Auftrag --test-cmd "pytest tests/"'), "Auftrag",
'--test-cmd "..." wird entfernt');
expect(stripOptimizeFlags("Auftrag --test-timeout 60"), "Auftrag",
"--test-timeout N wird entfernt");
expect(stripOptimizeFlags("--continue --interactive Auftrag"), "Auftrag",
"--continue und --interactive werden entfernt");
expect(stripOptimizeFlags("Nur ein Auftrag"), "Nur ein Auftrag",
"keine Flags → Auftrag unverändert");
// ── parseOptimizeOptions ─────────────────────────────────────────────────────
function parseOptimizeOptions(args) {
const roundsMatch = (args || "").match(/--rounds\s+(\d+)/);
const maxRounds = roundsMatch ? Math.max(1, parseInt(roundsMatch[1], 10)) : 2;
const testCmdMatch = (args || "").match(/--test-cmd\s+"([^"]+)"|--test-cmd\s+'([^']+)'|--test-cmd\s+(\S+)/);
const testCmd = testCmdMatch ? (testCmdMatch[1] ?? testCmdMatch[2] ?? testCmdMatch[3]) : null;
const testTimeoutMatch = (args || "").match(/--test-timeout\s+(\d+)/);
const testTimeout = testTimeoutMatch ? Math.max(1, parseInt(testTimeoutMatch[1], 10)) : 120;
return { maxRounds, testCmd, testTimeout };
}
console.log("\nparseOptimizeOptions()");
expect(parseOptimizeOptions("--rounds 5").maxRounds, 5,
"--rounds 5 → maxRounds = 5");
expect(parseOptimizeOptions("--rounds 0").maxRounds, 1,
"--rounds 0 → maxRounds = 1 (Math.max-Clamp)");
expect(parseOptimizeOptions("--rounds abc").maxRounds, 2,
"--rounds abc → Regex matcht nicht → Default 2");
expect(parseOptimizeOptions("").maxRounds, 2,
"kein --rounds → Default 2");
expect(parseOptimizeOptions("--test-timeout 30").testTimeout, 30,
"--test-timeout 30 → testTimeout = 30");
expect(parseOptimizeOptions("--test-timeout 0").testTimeout, 1,
"--test-timeout 0 → testTimeout = 1 (Math.max-Clamp)");
expect(parseOptimizeOptions("").testTimeout, 120,
"kein --test-timeout → Default 120");
expect(parseOptimizeOptions('--test-cmd "pytest -v"').testCmd, "pytest -v",
'--test-cmd "..." → testCmd ohne Anführungszeichen');
expect(parseOptimizeOptions("--test-cmd pytest").testCmd, "pytest",
"--test-cmd ohne Anführungszeichen → testCmd = 'pytest'");
expect(parseOptimizeOptions("auftrag --rounds 3 --test-timeout 60").maxRounds, 3,
"mehrere Flags kombiniert: maxRounds korrekt");
// ── calcVersionStrings ────────────────────────────────────────────────────────
function calcVersionStrings(current, bump) {
const [maj, min, pat] = current ?? [0, 0, 0];
const initial = !current;
const versions = initial
? { patch: "v0.0.1", minor: "v0.1.0", major: "v1.0.0" }
: { patch: `v${maj}.${min}.${pat + 1}`, minor: `v${maj}.${min + 1}.0`, major: `v${maj + 1}.0.0` };
const recommended = initial ? "minor" : bump;
const labels = ["patch", "minor", "major"].map(
t => `${t}${versions[t]}${t === recommended ? " (empfohlen)" : ""}`
);
return { versions, recommended, labels };
}
console.log("\ncalcVersionStrings()");
expectDeep(
calcVersionStrings(null, "patch").versions,
{ patch: "v0.0.1", minor: "v0.1.0", major: "v1.0.0" },
"initial (null): alle drei Standard-Startwerte"
);
expect(calcVersionStrings(null, "patch").recommended, "minor",
"initial: recommended ist immer minor (unabhängig vom Bump)");
expectDeep(
calcVersionStrings([1, 2, 3], "patch").versions,
{ patch: "v1.2.4", minor: "v1.3.0", major: "v2.0.0" },
"[1,2,3]: patch/minor/major korrekt hochgezählt"
);
expect(calcVersionStrings([1, 2, 3], "patch").recommended, "patch",
"[1,2,3] patch-Bump → recommended = patch");
expect(calcVersionStrings([1, 2, 3], "minor").recommended, "minor",
"[1,2,3] minor-Bump → recommended = minor");
expect(calcVersionStrings([1, 2, 3], "major").recommended, "major",
"[1,2,3] major-Bump → recommended = major");
{
const labels = calcVersionStrings([1, 2, 3], "minor").labels;
expect(labels[1], "minor → v1.3.0 (empfohlen)",
"empfohlenes Label trägt Suffix '(empfohlen)'");
expect(labels[0], "patch → v1.2.4",
"nicht-empfohlenes Label hat keinen Suffix");
}
expectDeep(
calcVersionStrings([0, 9, 9], "major").versions,
{ patch: "v0.9.10", minor: "v0.10.0", major: "v1.0.0" },
"[0,9,9]: zweistellige Zahlen korrekt hochgezählt"
);
// ── parseShipVerdict ──────────────────────────────────────────────────────────
function parseShipVerdict(text) {
return text.match(/Urteil:\s*(SHIP|NO-SHIP)/i)?.[1]?.toUpperCase() ?? "";
}
console.log("\nparseShipVerdict()");
expect(parseShipVerdict("Urteil: SHIP"), "SHIP",
"erkennt SHIP");
expect(parseShipVerdict("Urteil: NO-SHIP"), "NO-SHIP",
"erkennt NO-SHIP");
expect(parseShipVerdict("urteil: ship"), "SHIP",
"case-insensitiv: 'urteil: ship'");
expect(parseShipVerdict("kein Urteil hier"), "",
"kein Urteil → leerer String");
expect(parseShipVerdict("Urteil: PASS"), "",
"PASS ist kein gültiges SHIP-Token → leerer String");
expect(parseShipVerdict(""), "",
"leerer String → leerer String");
// ── Ergebnis ──────────────────────────────────────────────────────────────────
console.log(`\n${"─".repeat(50)}`);
console.log(`Gesamt: ${passed + failed} Tests — ${passed} bestanden, ${failed} fehlgeschlagen`);
if (failed > 0) {
process.exit(1);
}