tdd-enforcer 0.2.2 → 0.2.5

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.
@@ -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
- function setup() {
8
- const dir = join(tmpdir(), `tdd-log-test-${Date.now()}`);
9
- mkdirSync(dir, { recursive: true });
10
- return dir;
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 teardown(dir: string) {
14
- rmSync(dir, { recursive: true, force: true });
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
- const dir = setup();
20
- tddLog(dir, "INFO", "hello");
21
- expect(existsSync(join(dir, "tdd.log"))).toBe(true);
22
- teardown(dir);
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
- const dir = setup();
27
- tddLog(dir, "INFO", "line one");
28
- tddLog(dir, "DEBUG", "line two");
29
- const content = readFileSync(join(dir, "tdd.log"), "utf-8");
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
- teardown(dir);
57
+ expect(mockAppendFileSync).toHaveBeenCalledTimes(2);
37
58
  });
38
59
 
39
60
  it("includes data as JSON when provided", () => {
40
- const dir = setup();
41
- tddLog(dir, "INFO", "with data", { key: "val", num: 42 });
42
- const content = readFileSync(join(dir, "tdd.log"), "utf-8");
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(dir, "DEBUG", `line ${i}`);
68
+ tddLog("/tdd", "DEBUG", `line ${i}`, undefined, makeDeps());
51
69
  }
52
- const content = readFileSync(join(dir, "tdd.log"), "utf-8");
53
- const lines = content.trim().split("\n");
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
- expect(() => tddLog("/nonexistent/path/tdd", "INFO", "fail")).not.toThrow();
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
- const dir = setup();
66
- tddLog(dir, "WARN", "no data");
67
- const content = readFileSync(join(dir, "tdd.log"), "utf-8");
68
- expect(content).toContain("[WARN]");
69
- expect(content).toContain("no data");
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
  });
@@ -1,13 +1,22 @@
1
- import { appendFileSync, readFileSync, writeFileSync } from "node:fs";
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,35 @@
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
+ blockedInRed: ["tests/**/*.test.ts"],
7
+ blockedInGreen: ["src/**/*.ts"],
8
+ testCommands: ["npm test"],
9
+ timeoutSeconds: 30,
10
+ };
11
+
12
+ describe("getNudgePrompt", () => {
13
+ it("returns RED prompt with blocked files", () => {
14
+ const result = getNudgePrompt("red", config);
15
+ expect(result).toContain("RED");
16
+ expect(result).toContain("Blocked files: tests/**/*.test.ts");
17
+ });
18
+
19
+ it("returns GREEN prompt with blocked files", () => {
20
+ const result = getNudgePrompt("green", config);
21
+ expect(result).toContain("GREEN");
22
+ expect(result).toContain("Blocked files: src/**/*.ts");
23
+ });
24
+
25
+ it("returns REFACTOR prompt", () => {
26
+ const result = getNudgePrompt("refactor", config);
27
+ expect(result).toContain("REFACTOR");
28
+ expect(result).toContain("free to modify");
29
+ });
30
+
31
+ it("returns empty string for unknown phase", () => {
32
+ const result = getNudgePrompt("blurple" as any, config);
33
+ expect(result).toBe("");
34
+ });
35
+ });
@@ -1,24 +1,25 @@
1
1
  import type { Phase, Config } from "../../engine/types.js";
2
2
 
3
- export function getNudgePrompt(phase: Phase, config: Config, matchedFiles?: string[]): string {
4
- const redPatterns = (matchedFiles ?? config.allowedRedPhaseFiles).join(", ");
5
- const greenPatterns = config.allowedGreenPhaseFiles.join(", ");
3
+ export function getNudgePrompt(phase: Phase, config: Config): string {
4
+ const redBlock = config.blockedInRed.join(", ");
5
+ const greenBlock = config.blockedInGreen.join(", ");
6
6
 
7
7
  switch (phase) {
8
8
  case "red":
9
9
  return (
10
- `You are now in **RED** phase. Write failing tests matching: ${redPatterns}\n` +
11
- "Only these files can be modified. Once tests fail, call `next_tdd_phase` to proceed to GREEN.\n" +
12
- "Keep this cycle small. Write tests for one feature at a time. " +
13
- "Cover happy path, edge cases, and unhappy paths before advancing to GREEN."
10
+ `You are now in **RED** phase. Write failing tests.\n` +
11
+ `Blocked files: ${redBlock}\n` +
12
+ "All other files are free to modify. Call `next_tdd_phase` to proceed to GREEN.\n" +
13
+ "Think about what could go wrong and test for it don't just verify the happy path, " +
14
+ "cover unhappy paths and edge cases too. Keep cycles small so reverting is cheap."
14
15
  );
15
16
  case "green":
16
17
  return (
17
- `You are now in **GREEN** phase. Test files (${redPatterns}) are locked.\n` +
18
- `Implement features matching: ${greenPatterns}\n` +
19
- "Call `next_tdd_phase` to proceed to REFACTOR.\n" +
20
- "If the RED phase tests were wrong, call `previous_tdd_phase` to go back and fix them. " +
21
- "Don't be afraid to discard clean slate beats patched code."
18
+ `You are now in **GREEN** phase. Implement features.\n` +
19
+ `Blocked files: ${greenBlock}\n` +
20
+ "All other files are free to modify. Call `next_tdd_phase` to proceed to REFACTOR.\n" +
21
+ "Write minimal code to make the failing tests pass nothing more.\n" +
22
+ "If the RED phase tests were wrong, call `previous_tdd_phase` to go back and fix them."
22
23
  );
23
24
  case "refactor":
24
25
  return (
@@ -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
+ blockedInRed: ["tests/**/*.test.ts"],
9
+ blockedInGreen: ["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
+ });