import { beforeEach, describe, expect, it, vi } from "vitest"; import { advancePhase, getStatusInfo, revertPhase } from "./orchestrate.js"; import type { Config, Phase, PhaseState } from "./types.js"; const CONFIG: Config = { implFiles: ["tests/**/*.test.ts"], testFiles: ["src/**/*.ts"], testCommands: ["npm test"], timeoutSeconds: 30, }; function enabledState(current: Phase): PhaseState { return { enabled: true, current }; } describe("advancePhase", () => { let mockGetDisallowedChanges: ReturnType; let mockCheckGate: ReturnType; let mockNextPhase: ReturnType; let mockSnapshot: ReturnType; let mockSavePhaseState: ReturnType; let mockTestRunner: ReturnType; function makeDeps(overrides = {}) { return { getDisallowedChanges: mockGetDisallowedChanges, checkGate: mockCheckGate, nextPhase: mockNextPhase, snapshot: mockSnapshot, savePhaseState: mockSavePhaseState, testRunner: mockTestRunner, ...overrides, }; } beforeEach(() => { vi.clearAllMocks(); mockGetDisallowedChanges = vi.fn().mockReturnValue([]); mockCheckGate = vi.fn().mockResolvedValue({ passed: true, message: "ok" }); mockNextPhase = vi .fn() .mockImplementation((p: string) => p === "red" ? "green" : p === "green" ? "refactor" : p === "refactor" ? "red" : null, ); mockSnapshot = vi.fn().mockReturnValue("hash123"); mockSavePhaseState = vi.fn(); mockTestRunner = vi.fn(); }); it("returns allowlist violation error with revert hint when files blocked", async () => { mockGetDisallowedChanges.mockReturnValue(["src/violation.ts"]); const result = await advancePhase( "/test", enabledState("red"), CONFIG, makeDeps(), ); expect(result.ok).toBe(false); expect(result.message).toBe( "BLOCKED: files not allowed in RED phase:\n" + " - src/violation.ts" + "\nRevert or remove them before proceeding.\n\nInspect with: cd .pi/tdd && git diff HEAD -- src/violation.ts", ); }); it("returns allowlist violation for multiple blocked files", async () => { mockGetDisallowedChanges.mockReturnValue(["src/a.ts", "src/b.ts"]); const result = await advancePhase( "/test", enabledState("red"), CONFIG, makeDeps(), ); expect(result.ok).toBe(false); expect(result.message).toContain("src/a.ts"); expect(result.message).toContain("src/b.ts"); expect(result.message).toContain("cd .pi/tdd && git diff HEAD -- src/a.ts"); }); it("returns gate failure error when checkGate fails", async () => { mockCheckGate.mockResolvedValue({ passed: false, message: "Tests failed:\n - npm test", }); const result = await advancePhase( "/test", enabledState("green"), CONFIG, makeDeps(), ); expect(result.ok).toBe(false); expect(result.message).toBe("Tests failed:\n - npm test"); }); it("passes testRunner to checkGate", () => { advancePhase("/test", enabledState("red"), CONFIG, makeDeps()); expect(mockCheckGate).toHaveBeenCalledWith( "red", "green", mockTestRunner, CONFIG, ); }); it("passes correct phase transition red→green", () => { advancePhase("/test", enabledState("red"), CONFIG, makeDeps()); expect(mockCheckGate).toHaveBeenCalledWith( "red", "green", expect.anything(), expect.anything(), ); }); it("passes correct phase transition green→refactor", () => { advancePhase("/test", enabledState("green"), CONFIG, makeDeps()); expect(mockCheckGate).toHaveBeenCalledWith( "green", "refactor", expect.anything(), expect.anything(), ); }); it("passes correct phase transition refactor→red", () => { advancePhase("/test", enabledState("refactor"), CONFIG, makeDeps()); expect(mockCheckGate).toHaveBeenCalledWith( "refactor", "red", expect.anything(), expect.anything(), ); }); it("calls snapshot with root and from phase on success", async () => { await advancePhase("/test", enabledState("red"), CONFIG, makeDeps()); expect(mockSnapshot).toHaveBeenCalledWith("/test", "red"); }); it("calls savePhaseState with root and new state on success", async () => { await advancePhase("/test", enabledState("red"), CONFIG, makeDeps()); expect(mockSavePhaseState).toHaveBeenCalledWith("/test", { enabled: true, current: "green", }); }); it("returns ok true with newState on success", async () => { const result = await advancePhase( "/test", enabledState("red"), CONFIG, makeDeps(), ); expect(result.ok).toBe(true); expect(result.message).toBe(""); expect(result.newState).toEqual({ enabled: true, current: "green", }); }); it("does not snapshot or save when allowlist check fails", async () => { mockGetDisallowedChanges.mockReturnValue(["src/violation.ts"]); await advancePhase("/test", enabledState("red"), CONFIG, makeDeps()); expect(mockSnapshot).not.toHaveBeenCalled(); expect(mockSavePhaseState).not.toHaveBeenCalled(); }); it("does not snapshot or save when gate check fails", async () => { mockCheckGate.mockResolvedValue({ passed: false, message: "fail", }); await advancePhase("/test", enabledState("green"), CONFIG, makeDeps()); expect(mockSnapshot).not.toHaveBeenCalled(); expect(mockSavePhaseState).not.toHaveBeenCalled(); }); it("calls getDisallowedChanges with root, current phase, and config", () => { advancePhase("/test", enabledState("red"), CONFIG, makeDeps()); expect(mockGetDisallowedChanges).toHaveBeenCalledWith( "/test", "red", CONFIG, ); }); it("uses nextPhase from deps if provided", () => { const customNext = vi.fn().mockReturnValue("refactor"); advancePhase( "/test", enabledState("red"), CONFIG, makeDeps({ nextPhase: customNext }), ); expect(customNext).toHaveBeenCalledWith("red"); }); it("falls back to real nextPhase when not provided in deps", () => { advancePhase( "/test", enabledState("red"), CONFIG, makeDeps({ nextPhase: undefined }), ); // Should use the real nextPhase function (red→green) expect(mockCheckGate).toHaveBeenCalledWith( "red", "green", expect.anything(), expect.anything(), ); }); }); describe("cached check for green→refactor", () => { let mockGetDisallowedChanges: ReturnType; let mockCheckGate: ReturnType; let mockNextPhase: ReturnType; let mockSnapshot: ReturnType; let mockSavePhaseState: ReturnType; let mockTestRunner: ReturnType; let mockFindRedHash: ReturnType; let mockRunCachedCheck: ReturnType; function makeDeps(overrides = {}) { return { getDisallowedChanges: mockGetDisallowedChanges, checkGate: mockCheckGate, nextPhase: mockNextPhase, snapshot: mockSnapshot, savePhaseState: mockSavePhaseState, testRunner: mockTestRunner, findRedHash: mockFindRedHash, runCachedCheck: mockRunCachedCheck, ...overrides, }; } beforeEach(() => { vi.clearAllMocks(); mockGetDisallowedChanges = vi.fn().mockReturnValue([]); mockCheckGate = vi.fn().mockResolvedValue({ passed: true, message: "ok" }); mockNextPhase = vi .fn() .mockImplementation((p: string) => p === "red" ? "green" : p === "green" ? "refactor" : p === "refactor" ? "red" : null, ); mockSnapshot = vi.fn(); mockSavePhaseState = vi.fn(); mockTestRunner = vi.fn(); mockFindRedHash = vi.fn().mockReturnValue("abc123"); mockRunCachedCheck = vi.fn().mockResolvedValue({ passed: true }); }); it("skips allowlist check when red snapshot exists", async () => { const result = await advancePhase( "/test", enabledState("green"), CONFIG, makeDeps(), ); expect(mockGetDisallowedChanges).not.toHaveBeenCalled(); expect(result.ok).toBe(true); }); it("still runs allowlist check when no red snapshot exists", async () => { mockFindRedHash.mockReturnValue(null); const result = await advancePhase( "/test", enabledState("green"), CONFIG, makeDeps(), ); expect(mockGetDisallowedChanges).toHaveBeenCalled(); expect(result.ok).toBe(true); }); it("runs cached check when red snapshot exists", async () => { await advancePhase("/test", enabledState("green"), CONFIG, makeDeps()); expect(mockRunCachedCheck).toHaveBeenCalledWith( "/test", CONFIG, expect.anything(), ); }); it("does not run cached check for red→green", async () => { await advancePhase("/test", enabledState("red"), CONFIG, makeDeps()); expect(mockRunCachedCheck).not.toHaveBeenCalled(); }); it("does not run cached check for refactor→red", async () => { await advancePhase("/test", enabledState("refactor"), CONFIG, makeDeps()); expect(mockRunCachedCheck).not.toHaveBeenCalled(); }); it("skips cached check when no red snapshot exists", async () => { mockFindRedHash.mockReturnValue(null); await advancePhase("/test", enabledState("green"), CONFIG, makeDeps()); expect(mockRunCachedCheck).not.toHaveBeenCalled(); }); it("blocks transition when cached check fails (tests pass against RED)", async () => { mockRunCachedCheck.mockResolvedValue({ passed: false, message: "Tests pass against RED code — test changes may be fraudulent.", }); const result = await advancePhase( "/test", enabledState("green"), CONFIG, makeDeps(), ); expect(result.ok).toBe(false); expect(result.message).toContain("RED code"); }); it("blocks transition with cancellation message", async () => { mockRunCachedCheck.mockResolvedValue({ passed: false, cancelled: true, message: "cancelled by user", }); const result = await advancePhase( "/test", enabledState("green"), CONFIG, makeDeps(), ); expect(result.ok).toBe(false); expect(result.message).toContain("Cancelled"); }); it("blocks transition with timeout message", async () => { mockRunCachedCheck.mockResolvedValue({ passed: false, timeout: true, message: "timed out after 30s", }); const result = await advancePhase( "/test", enabledState("green"), CONFIG, makeDeps(), ); expect(result.ok).toBe(false); expect(result.message).toContain("timed out"); }); it("proceeds to gate check when cached check passes", async () => { mockRunCachedCheck.mockResolvedValue({ passed: true }); const result = await advancePhase( "/test", enabledState("green"), CONFIG, makeDeps(), ); expect(result.ok).toBe(true); expect(mockCheckGate).toHaveBeenCalled(); }); it("proceeds normally when cached check is skipped (no red hash)", async () => { mockFindRedHash.mockReturnValue(null); const result = await advancePhase( "/test", enabledState("green"), CONFIG, makeDeps(), ); expect(result.ok).toBe(true); expect(mockRunCachedCheck).not.toHaveBeenCalled(); expect(mockCheckGate).toHaveBeenCalled(); }); }); describe("revertPhase", () => { let mockHasParent: ReturnType; let mockHeadMessage: ReturnType; let mockResetHard: ReturnType; let mockUndoLastCommit: ReturnType; let mockSavePhaseState: ReturnType; function makeDeps(overrides = {}) { return { hasParent: mockHasParent, headMessage: mockHeadMessage, resetHard: mockResetHard, undoLastCommit: mockUndoLastCommit, savePhaseState: mockSavePhaseState, ...overrides, }; } beforeEach(() => { vi.clearAllMocks(); mockHasParent = vi.fn().mockReturnValue(true); mockHeadMessage = vi.fn().mockReturnValue("tdd: red"); mockResetHard = vi.fn(); mockUndoLastCommit = vi.fn(); mockSavePhaseState = vi.fn(); }); it("returns error when no parent commit", async () => { mockHasParent.mockReturnValue(false); const result = await revertPhase( "/test", enabledState("green"), makeDeps(), ); expect(result.ok).toBe(false); expect(result.message).toBe("No previous phase to revert to."); }); it("returns error with tampering warning when HEAD is not a TDD snapshot", async () => { mockHeadMessage.mockReturnValue("some random commit"); const result = await revertPhase( "/test", enabledState("green"), makeDeps(), ); expect(result.ok).toBe(false); expect(result.message).toBe( 'HEAD commit "some random commit" is not a TDD snapshot. Cannot determine previous phase.\n' + "The private git repo at .pi/tdd must not be manually modified. " + "Tampering with it will cause TDD state corruption.", ); }); it("resets hard and pops last commit on success", async () => { const result = await revertPhase( "/test", enabledState("green"), makeDeps(), ); expect(result.ok).toBe(true); expect(result.message).toBe("Reverted to RED."); expect(result.newState?.current).toBe("red"); expect(mockResetHard).toHaveBeenCalledWith("/test"); expect(mockUndoLastCommit).toHaveBeenCalledWith("/test"); }); it("saves the reverted phase state", async () => { await revertPhase("/test", enabledState("green"), makeDeps()); expect(mockSavePhaseState).toHaveBeenCalledWith("/test", { enabled: true, current: "red", }); }); it("determines previous phase from HEAD commit message", async () => { mockHeadMessage.mockReturnValue("tdd: green"); const result = await revertPhase( "/test", enabledState("refactor"), makeDeps(), ); expect(result.ok).toBe(true); expect(result.newState?.current).toBe("green"); }); it("determines refactor phase from HEAD commit message", async () => { mockHeadMessage.mockReturnValue("tdd: refactor"); const result = await revertPhase("/test", enabledState("red"), makeDeps()); expect(result.ok).toBe(true); expect(result.newState?.current).toBe("refactor"); }); it("preserves enabled state in new state", async () => { const result = await revertPhase( "/test", { enabled: false, current: "green" }, makeDeps(), ); expect(result.ok).toBe(true); expect(result.newState?.enabled).toBe(false); }); it("calls deps from provided deps object", async () => { const customHasParent = vi.fn().mockReturnValue(true); const customHeadMessage = vi.fn().mockReturnValue("tdd: green"); const customResetHard = vi.fn(); const customUndoLastCommit = vi.fn(); const customSavePhaseState = vi.fn(); await revertPhase("/test", enabledState("refactor"), { hasParent: customHasParent, headMessage: customHeadMessage, resetHard: customResetHard, undoLastCommit: customUndoLastCommit, savePhaseState: customSavePhaseState, }); expect(customHasParent).toHaveBeenCalled(); expect(customHeadMessage).toHaveBeenCalled(); expect(customResetHard).toHaveBeenCalled(); expect(customUndoLastCommit).toHaveBeenCalled(); expect(customSavePhaseState).toHaveBeenCalled(); }); it("falls back to real functions when deps not provided", async () => { // Should use real hasParent, headMessage, etc. from engine const result = await revertPhase("/test", enabledState("green")); // Real hasParent will fail because /test isn't a git repo expect(result.ok).toBe(false); }); }); describe("getStatusInfo", () => { it("returns enabled status with RED phase", () => { const info = getStatusInfo( { enabled: true, current: "red" }, { implFiles: ["tests/**/*.test.ts"], testFiles: ["src/**/*.ts"], testCommands: ["npm test"], timeoutSeconds: 30, }, ); expect(info).toBe( "TDD enforcer enabled\n" + "Current phase: RED\n" + "Blocked in RED: tests/**/*.test.ts\n" + "Blocked in GREEN: src/**/*.ts\n" + "Cached check: src/**/*.ts\n" + "Test commands: npm test", ); }); it("returns disabled status", () => { const info = getStatusInfo( { enabled: false, current: "red" }, { implFiles: [], testFiles: [], testCommands: [], timeoutSeconds: 30, }, ); expect(info).toBe( "TDD enforcer disabled\n" + "Current phase: RED\n" + "Blocked in RED: (none)\n" + "Blocked in GREEN: (none)\n" + "Cached check: (none)\n" + "Test commands: (none)", ); }); it("shows GREEN phase", () => { const info = getStatusInfo( { enabled: true, current: "green" }, { implFiles: [], testFiles: [], testCommands: [], timeoutSeconds: 30, }, ); expect(info).toContain("GREEN"); }); it("shows REFACTOR phase", () => { const info = getStatusInfo( { enabled: true, current: "refactor" }, { implFiles: [], testFiles: [], testCommands: [], timeoutSeconds: 30, }, ); expect(info).toContain("REFACTOR"); }); it("shows multiple blocked files for RED", () => { const info = getStatusInfo( { enabled: true, current: "red" }, { implFiles: ["*.ts", "*.json", "src/**"], testFiles: [], testCommands: [], timeoutSeconds: 30, }, ); expect(info).toContain("Blocked in RED: *.ts, *.json, src/**"); }); it("shows multiple test commands", () => { const info = getStatusInfo( { enabled: true, current: "red" }, { implFiles: [], testFiles: [], testCommands: ["npx vitest run", "npm run lint"], timeoutSeconds: 30, }, ); expect(info).toContain("Test commands: npx vitest run, npm run lint"); }); it("shows cached check line when testFiles has patterns", () => { const info = getStatusInfo( { enabled: true, current: "green" }, { implFiles: ["src/**/*.ts"], testFiles: ["tests/**/*.test.ts"], testCommands: ["npm test"], timeoutSeconds: 30, }, ); expect(info).toContain("Cached check: tests/**/*.test.ts"); }); it("shows (none) for cached check when testFiles is empty", () => { const info = getStatusInfo( { enabled: true, current: "green" }, { implFiles: [], testFiles: [], testCommands: [], timeoutSeconds: 30, }, ); expect(info).toContain("Cached check: (none)"); }); });