tdd-enforcer 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/adapters/pi/helpers.test.ts +218 -160
- package/adapters/pi/helpers.ts +50 -17
- package/adapters/pi/hooks.test.ts +472 -0
- package/adapters/pi/hooks.ts +215 -157
- package/adapters/pi/index.test.ts +302 -0
- package/adapters/pi/index.ts +192 -135
- package/adapters/pi/log.test.ts +63 -41
- package/adapters/pi/log.ts +13 -4
- package/adapters/pi/prompts.test.ts +43 -0
- package/adapters/pi/tools.test.ts +351 -0
- package/adapters/pi/tools.ts +321 -218
- package/behaviour.md +26 -11
- package/engine/git.test.ts +432 -173
- package/engine/git.ts +80 -49
- package/engine/index.ts +1 -1
- package/engine/transition.test.ts +62 -57
- package/engine/transition.ts +9 -2
- package/package.json +4 -1
- package/tsconfig.json +12 -0
package/adapters/pi/log.test.ts
CHANGED
|
@@ -1,73 +1,95 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { tmpdir } from "node:os";
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
5
2
|
import { tddLog } from "./log.js";
|
|
6
3
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
4
|
+
describe("tddLog", () => {
|
|
5
|
+
let mockExistsSync: ReturnType<typeof vi.fn>;
|
|
6
|
+
let mockMkdirSync: ReturnType<typeof vi.fn>;
|
|
7
|
+
let mockAppendFileSync: ReturnType<typeof vi.fn>;
|
|
8
|
+
let mockWriteFileSync: ReturnType<typeof vi.fn>;
|
|
9
|
+
let mockReadFileSync: ReturnType<typeof vi.fn>;
|
|
10
|
+
let writtenContent: string;
|
|
12
11
|
|
|
13
|
-
function
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
function makeDeps() {
|
|
13
|
+
return {
|
|
14
|
+
existsSync: mockExistsSync,
|
|
15
|
+
mkdirSync: mockMkdirSync,
|
|
16
|
+
appendFileSync: mockAppendFileSync,
|
|
17
|
+
writeFileSync: mockWriteFileSync,
|
|
18
|
+
readFileSync: mockReadFileSync,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
writtenContent = "";
|
|
24
|
+
mockExistsSync = vi.fn().mockReturnValue(true);
|
|
25
|
+
mockMkdirSync = vi.fn();
|
|
26
|
+
mockAppendFileSync = vi.fn((_path: string, content: string) => {
|
|
27
|
+
writtenContent += content;
|
|
28
|
+
});
|
|
29
|
+
mockWriteFileSync = vi.fn((_path: string, content: string) => {
|
|
30
|
+
writtenContent = content;
|
|
31
|
+
});
|
|
32
|
+
mockReadFileSync = vi.fn(() => writtenContent);
|
|
33
|
+
});
|
|
16
34
|
|
|
17
|
-
describe("tddLog", () => {
|
|
18
35
|
it("creates the log file if it doesn't exist", () => {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
36
|
+
// readFileSync throws → outer catch silently swallows it
|
|
37
|
+
mockReadFileSync = vi.fn(() => {
|
|
38
|
+
throw new Error("ENOENT: no such file or directory");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
tddLog("/tdd", "INFO", "hello", undefined, makeDeps());
|
|
42
|
+
|
|
43
|
+
expect(mockAppendFileSync).toHaveBeenCalled();
|
|
44
|
+
expect(mockAppendFileSync.mock.calls[0][1]).toContain("hello");
|
|
23
45
|
});
|
|
24
46
|
|
|
25
47
|
it("appends multiple lines", () => {
|
|
26
|
-
|
|
27
|
-
tddLog(
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
const lines = content.trim().split("\n");
|
|
48
|
+
tddLog("/tdd", "INFO", "line one", undefined, makeDeps());
|
|
49
|
+
tddLog("/tdd", "DEBUG", "line two", undefined, makeDeps());
|
|
50
|
+
|
|
51
|
+
const lines = writtenContent.trim().split("\n");
|
|
31
52
|
expect(lines).toHaveLength(2);
|
|
32
53
|
expect(lines[0]).toContain("[INFO]");
|
|
33
54
|
expect(lines[0]).toContain("line one");
|
|
34
55
|
expect(lines[1]).toContain("[DEBUG]");
|
|
35
56
|
expect(lines[1]).toContain("line two");
|
|
36
|
-
|
|
57
|
+
expect(mockAppendFileSync).toHaveBeenCalledTimes(2);
|
|
37
58
|
});
|
|
38
59
|
|
|
39
60
|
it("includes data as JSON when provided", () => {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
expect(content).toContain('{"key":"val","num":42}');
|
|
44
|
-
teardown(dir);
|
|
61
|
+
tddLog("/tdd", "INFO", "with data", { key: "val", num: 42 }, makeDeps());
|
|
62
|
+
|
|
63
|
+
expect(writtenContent).toContain('{"key":"val","num":42}');
|
|
45
64
|
});
|
|
46
65
|
|
|
47
66
|
it("trims to last 1000 lines when exceeded", () => {
|
|
48
|
-
const dir = setup();
|
|
49
67
|
for (let i = 0; i < 1005; i++) {
|
|
50
|
-
tddLog(
|
|
68
|
+
tddLog("/tdd", "DEBUG", `line ${i}`, undefined, makeDeps());
|
|
51
69
|
}
|
|
52
|
-
|
|
53
|
-
const lines =
|
|
70
|
+
|
|
71
|
+
const lines = writtenContent.trim().split("\n");
|
|
54
72
|
expect(lines).toHaveLength(1000);
|
|
55
73
|
expect(lines[0]).toContain("line 5");
|
|
56
74
|
expect(lines[999]).toContain("line 1004");
|
|
57
|
-
teardown(dir);
|
|
58
75
|
});
|
|
59
76
|
|
|
60
77
|
it("does not throw on invalid tddDir", () => {
|
|
61
|
-
|
|
78
|
+
// Simulate appendFileSync failure
|
|
79
|
+
mockAppendFileSync = vi.fn(() => {
|
|
80
|
+
throw new Error("ENOENT: no such file or directory");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(() =>
|
|
84
|
+
tddLog("/nonexistent/path/tdd", "INFO", "fail", undefined, makeDeps()),
|
|
85
|
+
).not.toThrow();
|
|
62
86
|
});
|
|
63
87
|
|
|
64
88
|
it("handles missing data field gracefully", () => {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
expect(
|
|
69
|
-
expect(
|
|
70
|
-
expect(content).not.toContain("{}");
|
|
71
|
-
teardown(dir);
|
|
89
|
+
tddLog("/tdd", "WARN", "no data", undefined, makeDeps());
|
|
90
|
+
|
|
91
|
+
expect(writtenContent).toContain("[WARN]");
|
|
92
|
+
expect(writtenContent).toContain("no data");
|
|
93
|
+
expect(writtenContent).not.toContain("{}");
|
|
72
94
|
});
|
|
73
95
|
});
|
package/adapters/pi/log.ts
CHANGED
|
@@ -1,13 +1,22 @@
|
|
|
1
|
-
import { appendFileSync,
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
|
|
4
4
|
const MAX_LINES = 1000;
|
|
5
5
|
|
|
6
|
+
type FsDeps = {
|
|
7
|
+
existsSync: typeof existsSync;
|
|
8
|
+
mkdirSync: typeof mkdirSync;
|
|
9
|
+
appendFileSync: typeof appendFileSync;
|
|
10
|
+
writeFileSync: typeof writeFileSync;
|
|
11
|
+
readFileSync: typeof readFileSync;
|
|
12
|
+
};
|
|
13
|
+
|
|
6
14
|
export function tddLog(
|
|
7
15
|
tddDir: string,
|
|
8
16
|
level: "INFO" | "WARN" | "ERROR" | "DEBUG",
|
|
9
17
|
msg: string,
|
|
10
18
|
data?: Record<string, unknown>,
|
|
19
|
+
deps: FsDeps = { existsSync, mkdirSync, appendFileSync, writeFileSync, readFileSync },
|
|
11
20
|
): void {
|
|
12
21
|
try {
|
|
13
22
|
const logPath = join(tddDir, "tdd.log");
|
|
@@ -15,14 +24,14 @@ export function tddLog(
|
|
|
15
24
|
const dataStr = data !== undefined ? ` ${JSON.stringify(data)}` : "";
|
|
16
25
|
const line = `[${timestamp}] [${level}] ${msg}${dataStr}\n`;
|
|
17
26
|
|
|
18
|
-
appendFileSync(logPath, line, "utf-8");
|
|
27
|
+
deps.appendFileSync(logPath, line, "utf-8");
|
|
19
28
|
|
|
20
29
|
// Trim to last MAX_LINES
|
|
21
|
-
const content = readFileSync(logPath, "utf-8");
|
|
30
|
+
const content = deps.readFileSync(logPath, "utf-8");
|
|
22
31
|
const lines = content.trimEnd().split("\n");
|
|
23
32
|
if (lines.length > MAX_LINES) {
|
|
24
33
|
const trimmed = lines.slice(-MAX_LINES).join("\n") + "\n";
|
|
25
|
-
writeFileSync(logPath, trimmed, "utf-8");
|
|
34
|
+
deps.writeFileSync(logPath, trimmed, "utf-8");
|
|
26
35
|
}
|
|
27
36
|
} catch {
|
|
28
37
|
// Logging never throws — fail silently
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { getNudgePrompt } from "./prompts.js";
|
|
3
|
+
import type { Config } from "../../engine/types.js";
|
|
4
|
+
|
|
5
|
+
const config: Config = {
|
|
6
|
+
allowedRedPhaseFiles: ["tests/**/*.test.ts"],
|
|
7
|
+
allowedGreenPhaseFiles: ["src/**/*.ts"],
|
|
8
|
+
testCommands: ["npm test"],
|
|
9
|
+
timeoutSeconds: 30,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
describe("getNudgePrompt", () => {
|
|
13
|
+
it("returns RED prompt with config patterns by default", () => {
|
|
14
|
+
const result = getNudgePrompt("red", config);
|
|
15
|
+
expect(result).toContain("RED");
|
|
16
|
+
expect(result).toContain("tests/**/*.test.ts");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("returns RED prompt with custom matchedFiles override", () => {
|
|
20
|
+
const result = getNudgePrompt("red", config, ["custom/**/*.test.ts"]);
|
|
21
|
+
expect(result).toContain("RED");
|
|
22
|
+
expect(result).toContain("custom/**/*.test.ts");
|
|
23
|
+
expect(result).not.toContain("tests/**/*.test.ts");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("returns GREEN prompt with both red and green patterns", () => {
|
|
27
|
+
const result = getNudgePrompt("green", config);
|
|
28
|
+
expect(result).toContain("GREEN");
|
|
29
|
+
expect(result).toContain("tests/**/*.test.ts");
|
|
30
|
+
expect(result).toContain("src/**/*.ts");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("returns REFACTOR prompt", () => {
|
|
34
|
+
const result = getNudgePrompt("refactor", config);
|
|
35
|
+
expect(result).toContain("REFACTOR");
|
|
36
|
+
expect(result).toContain("free to modify");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("returns empty string for unknown phase", () => {
|
|
40
|
+
const result = getNudgePrompt("blurple" as any, config);
|
|
41
|
+
expect(result).toBe("");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
2
|
+
import { executeNextPhase, executePreviousPhase, executeTddStatus } from "./tools.js";
|
|
3
|
+
import type { NextPhaseDeps, PreviousPhaseDeps, TddStatusDeps } from "./tools.js";
|
|
4
|
+
|
|
5
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
const CONFIG = {
|
|
8
|
+
allowedRedPhaseFiles: ["tests/**/*.test.ts"],
|
|
9
|
+
allowedGreenPhaseFiles: ["src/**/*.ts"],
|
|
10
|
+
testCommands: ["npm test"],
|
|
11
|
+
timeoutSeconds: 30,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// ── executeNextPhase ────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
describe("executeNextPhase", () => {
|
|
17
|
+
let mockLoadTddState: ReturnType<typeof vi.fn>;
|
|
18
|
+
let mockNextPhase: ReturnType<typeof vi.fn>;
|
|
19
|
+
let mockGetDisallowedChanges: ReturnType<typeof vi.fn>;
|
|
20
|
+
let mockCheckGate: ReturnType<typeof vi.fn>;
|
|
21
|
+
let mockSnapshot: ReturnType<typeof vi.fn>;
|
|
22
|
+
let mockSavePhaseState: ReturnType<typeof vi.fn>;
|
|
23
|
+
let mockGetNudgePrompt: ReturnType<typeof vi.fn>;
|
|
24
|
+
let mockAsyncExec: ReturnType<typeof vi.fn>;
|
|
25
|
+
let mockTddLog: ReturnType<typeof vi.fn>;
|
|
26
|
+
|
|
27
|
+
function makeDeps(overrides: Partial<NextPhaseDeps> = {}): NextPhaseDeps {
|
|
28
|
+
return {
|
|
29
|
+
loadTddState: mockLoadTddState,
|
|
30
|
+
nextPhase: mockNextPhase,
|
|
31
|
+
getDisallowedChanges: mockGetDisallowedChanges,
|
|
32
|
+
checkGate: mockCheckGate,
|
|
33
|
+
snapshot: mockSnapshot,
|
|
34
|
+
savePhaseState: mockSavePhaseState,
|
|
35
|
+
getNudgePrompt: mockGetNudgePrompt,
|
|
36
|
+
asyncExec: mockAsyncExec,
|
|
37
|
+
tddLog: mockTddLog,
|
|
38
|
+
...overrides,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
vi.clearAllMocks();
|
|
44
|
+
mockLoadTddState = vi.fn();
|
|
45
|
+
mockNextPhase = vi.fn();
|
|
46
|
+
mockGetDisallowedChanges = vi.fn().mockReturnValue([]);
|
|
47
|
+
mockCheckGate = vi.fn();
|
|
48
|
+
mockSnapshot = vi.fn().mockReturnValue("hash123");
|
|
49
|
+
mockSavePhaseState = vi.fn();
|
|
50
|
+
mockGetNudgePrompt = vi.fn().mockReturnValue("");
|
|
51
|
+
mockAsyncExec = vi.fn();
|
|
52
|
+
mockTddLog = vi.fn();
|
|
53
|
+
|
|
54
|
+
mockNextPhase.mockImplementation((p: string) =>
|
|
55
|
+
p === "red" ? "green" : p === "green" ? "refactor" : p === "refactor" ? "red" : null,
|
|
56
|
+
);
|
|
57
|
+
mockCheckGate.mockResolvedValue({ passed: true, message: "ok" });
|
|
58
|
+
mockAsyncExec.mockResolvedValue({ stdout: "", stderr: "" });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("returns config error when TDD not setup", async () => {
|
|
62
|
+
mockLoadTddState.mockReturnValue({
|
|
63
|
+
ok: false,
|
|
64
|
+
reason: "Missing .pi/tdd/ directory. See the tdd-enforcer skill to learn how to set up TDD configs.",
|
|
65
|
+
});
|
|
66
|
+
const result = await executeNextPhase({ cwd: "/test" } as any, makeDeps());
|
|
67
|
+
expect(result.content[0].text).toContain("Missing .pi/tdd/");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("returns disabled message when TDD disabled", async () => {
|
|
71
|
+
mockLoadTddState.mockReturnValue({
|
|
72
|
+
ok: true,
|
|
73
|
+
state: { enabled: false, current: "red" },
|
|
74
|
+
config: CONFIG,
|
|
75
|
+
});
|
|
76
|
+
const result = await executeNextPhase({ cwd: "/test" } as any, makeDeps());
|
|
77
|
+
expect(result.content[0].text).toContain("not enabled");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("blocks when allowlist violations exist", async () => {
|
|
81
|
+
mockLoadTddState.mockReturnValue({
|
|
82
|
+
ok: true,
|
|
83
|
+
state: { enabled: true, current: "red" },
|
|
84
|
+
config: CONFIG,
|
|
85
|
+
});
|
|
86
|
+
mockGetDisallowedChanges.mockReturnValue(["src/violation.ts"]);
|
|
87
|
+
const result = await executeNextPhase({ cwd: "/test" } as any, makeDeps());
|
|
88
|
+
expect(result.content[0].text).toContain("BLOCKED");
|
|
89
|
+
expect(result.content[0].text).toContain("src/violation.ts");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("blocks red→green when tests pass (need failing test)", async () => {
|
|
93
|
+
mockLoadTddState.mockReturnValue({
|
|
94
|
+
ok: true,
|
|
95
|
+
state: { enabled: true, current: "red" },
|
|
96
|
+
config: CONFIG,
|
|
97
|
+
});
|
|
98
|
+
mockCheckGate.mockResolvedValue({
|
|
99
|
+
passed: false,
|
|
100
|
+
message: "Tests passed. Add a failing test before transitioning to GREEN.",
|
|
101
|
+
});
|
|
102
|
+
const result = await executeNextPhase({ cwd: "/test" } as any, makeDeps());
|
|
103
|
+
expect(result.content[0].text).toContain("Add a failing test");
|
|
104
|
+
expect(result.content[0].text).toContain("GREEN");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("blocks green→refactor when tests fail", async () => {
|
|
108
|
+
mockLoadTddState.mockReturnValue({
|
|
109
|
+
ok: true,
|
|
110
|
+
state: { enabled: true, current: "green" },
|
|
111
|
+
config: CONFIG,
|
|
112
|
+
});
|
|
113
|
+
mockCheckGate.mockResolvedValue({
|
|
114
|
+
passed: false,
|
|
115
|
+
message: "Tests failed. Fix them before transitioning to REFACTOR.",
|
|
116
|
+
});
|
|
117
|
+
const result = await executeNextPhase({ cwd: "/test" } as any, makeDeps());
|
|
118
|
+
expect(result.content[0].text).toContain("failed");
|
|
119
|
+
expect(result.content[0].text).toContain("REFACTOR");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("advances red→green when tests fail", async () => {
|
|
123
|
+
mockLoadTddState.mockReturnValue({
|
|
124
|
+
ok: true,
|
|
125
|
+
state: { enabled: true, current: "red" },
|
|
126
|
+
config: CONFIG,
|
|
127
|
+
});
|
|
128
|
+
mockCheckGate.mockResolvedValue({ passed: true, message: "Tests fail — proceed to GREEN." });
|
|
129
|
+
mockGetNudgePrompt.mockReturnValue("You are now in **GREEN** phase.");
|
|
130
|
+
const result = await executeNextPhase({ cwd: "/test" } as any, makeDeps());
|
|
131
|
+
expect(result.content[0].text).toContain("GREEN");
|
|
132
|
+
expect(mockSnapshot).toHaveBeenCalledWith("/test", "red");
|
|
133
|
+
expect(mockSavePhaseState).toHaveBeenCalledWith("/test", { enabled: true, current: "green" });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("advances green→refactor when tests pass", async () => {
|
|
137
|
+
mockLoadTddState.mockReturnValue({
|
|
138
|
+
ok: true,
|
|
139
|
+
state: { enabled: true, current: "green" },
|
|
140
|
+
config: CONFIG,
|
|
141
|
+
});
|
|
142
|
+
mockCheckGate.mockResolvedValue({ passed: true, message: "All tests pass — proceeding." });
|
|
143
|
+
mockGetNudgePrompt.mockReturnValue("You are now in **REFACTOR** phase.");
|
|
144
|
+
const result = await executeNextPhase({ cwd: "/test" } as any, makeDeps());
|
|
145
|
+
expect(result.content[0].text).toContain("REFACTOR");
|
|
146
|
+
expect(mockSnapshot).toHaveBeenCalledWith("/test", "green");
|
|
147
|
+
expect(mockSavePhaseState).toHaveBeenCalledWith("/test", { enabled: true, current: "refactor" });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("blocks refactor→red when tests fail", async () => {
|
|
151
|
+
mockLoadTddState.mockReturnValue({
|
|
152
|
+
ok: true,
|
|
153
|
+
state: { enabled: true, current: "refactor" },
|
|
154
|
+
config: CONFIG,
|
|
155
|
+
});
|
|
156
|
+
mockCheckGate.mockResolvedValue({
|
|
157
|
+
passed: false,
|
|
158
|
+
message: "Tests failed. Fix them before transitioning to RED.",
|
|
159
|
+
});
|
|
160
|
+
const result = await executeNextPhase({ cwd: "/test" } as any, makeDeps());
|
|
161
|
+
expect(result.content[0].text).toContain("failed");
|
|
162
|
+
expect(result.content[0].text).toContain("RED");
|
|
163
|
+
expect(mockSavePhaseState).not.toHaveBeenCalled();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("advances refactor→red when tests pass", async () => {
|
|
167
|
+
mockLoadTddState.mockReturnValue({
|
|
168
|
+
ok: true,
|
|
169
|
+
state: { enabled: true, current: "refactor" },
|
|
170
|
+
config: CONFIG,
|
|
171
|
+
});
|
|
172
|
+
mockCheckGate.mockResolvedValue({ passed: true, message: "All tests pass — proceeding." });
|
|
173
|
+
mockGetNudgePrompt.mockReturnValue("You are now in **RED** phase.");
|
|
174
|
+
const result = await executeNextPhase({ cwd: "/test" } as any, makeDeps());
|
|
175
|
+
expect(result.content[0].text).toContain("RED");
|
|
176
|
+
expect(mockSnapshot).toHaveBeenCalledWith("/test", "refactor");
|
|
177
|
+
expect(mockSavePhaseState).toHaveBeenCalledWith("/test", { enabled: true, current: "red" });
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// ── executePreviousPhase ────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
describe("executePreviousPhase", () => {
|
|
184
|
+
let mockLoadTddState: ReturnType<typeof vi.fn>;
|
|
185
|
+
let mockHasParent: ReturnType<typeof vi.fn>;
|
|
186
|
+
let mockHeadMessage: ReturnType<typeof vi.fn>;
|
|
187
|
+
let mockResetHard: ReturnType<typeof vi.fn>;
|
|
188
|
+
let mockUndoLastCommit: ReturnType<typeof vi.fn>;
|
|
189
|
+
let mockSavePhaseState: ReturnType<typeof vi.fn>;
|
|
190
|
+
let mockTddLog: ReturnType<typeof vi.fn>;
|
|
191
|
+
|
|
192
|
+
function makeDeps(overrides: Partial<PreviousPhaseDeps> = {}): PreviousPhaseDeps {
|
|
193
|
+
return {
|
|
194
|
+
loadTddState: mockLoadTddState,
|
|
195
|
+
hasParent: mockHasParent,
|
|
196
|
+
headMessage: mockHeadMessage,
|
|
197
|
+
resetHard: mockResetHard,
|
|
198
|
+
undoLastCommit: mockUndoLastCommit,
|
|
199
|
+
savePhaseState: mockSavePhaseState,
|
|
200
|
+
tddLog: mockTddLog,
|
|
201
|
+
...overrides,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
beforeEach(() => {
|
|
206
|
+
vi.clearAllMocks();
|
|
207
|
+
mockLoadTddState = vi.fn();
|
|
208
|
+
mockHasParent = vi.fn();
|
|
209
|
+
mockHeadMessage = vi.fn();
|
|
210
|
+
mockResetHard = vi.fn();
|
|
211
|
+
mockUndoLastCommit = vi.fn();
|
|
212
|
+
mockSavePhaseState = vi.fn();
|
|
213
|
+
mockTddLog = vi.fn();
|
|
214
|
+
|
|
215
|
+
mockHasParent.mockReturnValue(true);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("returns config error when TDD not setup", async () => {
|
|
219
|
+
mockLoadTddState.mockReturnValue({
|
|
220
|
+
ok: false,
|
|
221
|
+
reason: "Missing .pi/tdd/ directory.",
|
|
222
|
+
});
|
|
223
|
+
const result = await executePreviousPhase({ cwd: "/test" } as any, makeDeps());
|
|
224
|
+
expect(result.content[0].text).toContain("Missing .pi/tdd/");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("returns disabled message when TDD disabled", async () => {
|
|
228
|
+
mockLoadTddState.mockReturnValue({
|
|
229
|
+
ok: true,
|
|
230
|
+
state: { enabled: false, current: "red" },
|
|
231
|
+
config: CONFIG,
|
|
232
|
+
});
|
|
233
|
+
const result = await executePreviousPhase({ cwd: "/test" } as any, makeDeps());
|
|
234
|
+
expect(result.content[0].text).toContain("not enabled");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("returns no-parent message when only init commit exists", async () => {
|
|
238
|
+
mockLoadTddState.mockReturnValue({
|
|
239
|
+
ok: true,
|
|
240
|
+
state: { enabled: true, current: "red" },
|
|
241
|
+
config: CONFIG,
|
|
242
|
+
});
|
|
243
|
+
mockHasParent.mockReturnValue(false);
|
|
244
|
+
const result = await executePreviousPhase({ cwd: "/test" } as any, makeDeps());
|
|
245
|
+
expect(result.content[0].text).toContain("No previous phase");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("returns error when HEAD message is not a TDD snapshot", async () => {
|
|
249
|
+
mockLoadTddState.mockReturnValue({
|
|
250
|
+
ok: true,
|
|
251
|
+
state: { enabled: true, current: "red" },
|
|
252
|
+
config: CONFIG,
|
|
253
|
+
});
|
|
254
|
+
mockHeadMessage.mockReturnValue("garbage");
|
|
255
|
+
const result = await executePreviousPhase({ cwd: "/test" } as any, makeDeps());
|
|
256
|
+
expect(result.content[0].text).toContain("not a TDD snapshot");
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("reverts to previous phase on success", async () => {
|
|
260
|
+
mockLoadTddState.mockReturnValue({
|
|
261
|
+
ok: true,
|
|
262
|
+
state: { enabled: true, current: "green" },
|
|
263
|
+
config: CONFIG,
|
|
264
|
+
});
|
|
265
|
+
mockHeadMessage.mockReturnValue("tdd: red");
|
|
266
|
+
const result = await executePreviousPhase({ cwd: "/test" } as any, makeDeps());
|
|
267
|
+
expect(result.content[0].text).toContain("RED");
|
|
268
|
+
expect(mockResetHard).toHaveBeenCalledWith("/test");
|
|
269
|
+
expect(mockUndoLastCommit).toHaveBeenCalledWith("/test");
|
|
270
|
+
expect(mockSavePhaseState).toHaveBeenCalledWith("/test", { enabled: true, current: "red" });
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("reverts to correct phase from green head label", async () => {
|
|
274
|
+
mockLoadTddState.mockReturnValue({
|
|
275
|
+
ok: true,
|
|
276
|
+
state: { enabled: true, current: "refactor" },
|
|
277
|
+
config: CONFIG,
|
|
278
|
+
});
|
|
279
|
+
mockHeadMessage.mockReturnValue("tdd: green");
|
|
280
|
+
const result = await executePreviousPhase({ cwd: "/test" } as any, makeDeps());
|
|
281
|
+
expect(result.content[0].text).toContain("GREEN");
|
|
282
|
+
expect(mockSavePhaseState).toHaveBeenCalledWith("/test", { enabled: true, current: "green" });
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// ── executeTddStatus ────────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
describe("executeTddStatus", () => {
|
|
289
|
+
let mockLoadTddState: ReturnType<typeof vi.fn>;
|
|
290
|
+
let mockTddLog: ReturnType<typeof vi.fn>;
|
|
291
|
+
|
|
292
|
+
function makeDeps(overrides: Partial<TddStatusDeps> = {}): TddStatusDeps {
|
|
293
|
+
return {
|
|
294
|
+
loadTddState: mockLoadTddState,
|
|
295
|
+
tddLog: mockTddLog,
|
|
296
|
+
...overrides,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
beforeEach(() => {
|
|
301
|
+
vi.clearAllMocks();
|
|
302
|
+
mockLoadTddState = vi.fn();
|
|
303
|
+
mockTddLog = vi.fn();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("returns config error when TDD not setup", async () => {
|
|
307
|
+
mockLoadTddState.mockReturnValue({
|
|
308
|
+
ok: false,
|
|
309
|
+
reason: "Missing .pi/tdd/ directory.",
|
|
310
|
+
});
|
|
311
|
+
const result = await executeTddStatus({ cwd: "/test" } as any, makeDeps());
|
|
312
|
+
expect(result.content[0].text).toContain("Missing .pi/tdd/");
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("returns disabled message when TDD disabled", async () => {
|
|
316
|
+
mockLoadTddState.mockReturnValue({
|
|
317
|
+
ok: true,
|
|
318
|
+
state: { enabled: false, current: "red" },
|
|
319
|
+
config: CONFIG,
|
|
320
|
+
});
|
|
321
|
+
const result = await executeTddStatus({ cwd: "/test" } as any, makeDeps());
|
|
322
|
+
expect(result.content[0].text).toContain("not enabled");
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("returns status details when TDD enabled", async () => {
|
|
326
|
+
mockLoadTddState.mockReturnValue({
|
|
327
|
+
ok: true,
|
|
328
|
+
state: { enabled: true, current: "red" },
|
|
329
|
+
config: CONFIG,
|
|
330
|
+
});
|
|
331
|
+
const result = await executeTddStatus({ cwd: "/test" } as any, makeDeps());
|
|
332
|
+
expect(result.content[0].text).toContain("enabled");
|
|
333
|
+
expect(result.content[0].text).toContain("RED");
|
|
334
|
+
expect(result.content[0].text).toContain("tests/**/*.test.ts");
|
|
335
|
+
expect(result.content[0].text).toContain("src/**/*.ts");
|
|
336
|
+
expect(result.details).toBeDefined();
|
|
337
|
+
expect(result.details!.enabled).toBe(true);
|
|
338
|
+
expect(result.details!.phase).toBe("red");
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("returns status with current phase", async () => {
|
|
342
|
+
mockLoadTddState.mockReturnValue({
|
|
343
|
+
ok: true,
|
|
344
|
+
state: { enabled: true, current: "green" },
|
|
345
|
+
config: CONFIG,
|
|
346
|
+
});
|
|
347
|
+
const result = await executeTddStatus({ cwd: "/test" } as any, makeDeps());
|
|
348
|
+
expect(result.content[0].text).toContain("GREEN");
|
|
349
|
+
expect(result.details!.phase).toBe("green");
|
|
350
|
+
});
|
|
351
|
+
});
|