Skip to content

Commit 4432ce7

Browse files
committed
feat(test-llm-autocompletion): use globally unique file numbers for approvals
- Modified getNextFileNumber to use globally unique numbers across both approved and rejected files (e.g., approved.1.txt, rejected.2.txt) - Added renumberApprovals function to fix duplicate file numbers - Updated clean command to include renumbering functionality - Added tests for the new global numbering and renumbering features - Updated README to document the changes
1 parent 07612b0 commit 4432ce7

File tree

4 files changed

+226
-10
lines changed

4 files changed

+226
-10
lines changed

src/test-llm-autocompletion/README.md

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ This test suite uses approval testing instead of regex pattern matching to valid
2121
2222
- Display the test input and output
2323
- Ask you whether the output is acceptable
24-
- Save your decision to `approvals/{category}/{test-name}/approved.N.txt` or `rejected.N.txt`
24+
- Save your decision to `approvals/{category}/{test-name}.approved.N.txt` or `{test-name}.rejected.N.txt`
25+
- File numbers are globally unique across approved and rejected files (e.g., `approved.1.txt`, `rejected.2.txt`, `approved.3.txt`)
2526
2627
2. **Subsequent Runs**:
2728
- If the output matches a previously approved file, the test passes
@@ -95,7 +96,7 @@ LLM_MODEL=anthropic/claude-3-haiku pnpm run test
9596

9697
### Clean Command
9798

98-
The `clean` command removes approval files for test cases that no longer exist:
99+
The `clean` command removes approval files for test cases that no longer exist and renumbers files to fix duplicates:
99100

100101
```bash
101102
pnpm run clean
@@ -106,7 +107,14 @@ This is useful when you've deleted or renamed test cases and want to clean up th
106107
- Scan all approval files in the `approvals/` directory
107108
- Check if each approval corresponds to an existing test case
108109
- Remove approvals for test cases that no longer exist
109-
- Report how many files were cleaned
110+
- **Renumber files** to fix duplicate numbers and gaps (e.g., if you have `approved.1.txt` and `rejected.1.txt`, they will be renumbered to `approved.1.txt` and `rejected.2.txt`)
111+
- Report how many files were cleaned and renumbered
112+
113+
The renumbering feature is particularly useful when:
114+
115+
- You've manually moved files between approved and rejected
116+
- You have legacy files with duplicate numbers from before the global numbering was implemented
117+
- You want to ensure all file numbers are sequential and unique
110118

111119
### Skip Approval Mode
112120

@@ -155,6 +163,7 @@ Is this acceptable? (y/n):
155163
## Notes
156164

157165
- The `approvals/` directory is gitignored
158-
- Each approved/rejected output gets a unique numbered file
166+
- Each approved/rejected output gets a globally unique numbered file (numbers are unique across both approved and rejected files for the same test case)
159167
- Tests only prompt for input in the terminal when output is new
160168
- The test summary at the end shows how many passed/failed
169+
- To move a file from approved to rejected (or vice versa), simply rename it and run `pnpm run clean` to fix the numbering

src/test-llm-autocompletion/approvals.spec.ts

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
22
import fs from "fs"
33
import path from "path"
44
import readline from "readline"
5-
import { checkApproval } from "./approvals.js"
5+
import { checkApproval, renumberApprovals } from "./approvals.js"
66

77
const TEST_APPROVALS_DIR = "approvals"
88
const TEST_CATEGORY = "test-category"
@@ -80,6 +80,30 @@ describe("approvals", () => {
8080

8181
expect(files).toContain(`${TEST_NAME}.rejected.1.txt`)
8282
})
83+
84+
it("should use globally unique numbers across approved and rejected", async () => {
85+
let callCount = 0
86+
vi.spyOn(readline, "createInterface").mockReturnValue({
87+
question: (_prompt: string, callback: (answer: string) => void) => {
88+
// First call: approve, second call: reject, third call: approve
89+
callCount++
90+
callback(callCount === 2 ? "n" : "y")
91+
},
92+
close: () => {},
93+
} as any)
94+
95+
await checkApproval(TEST_CATEGORY, TEST_NAME, "input1", "output1") // approved.1
96+
await checkApproval(TEST_CATEGORY, TEST_NAME, "input2", "output2") // rejected.2
97+
await checkApproval(TEST_CATEGORY, TEST_NAME, "input3", "output3") // approved.3
98+
99+
const categoryDir = path.join(TEST_APPROVALS_DIR, TEST_CATEGORY)
100+
const files = fs.readdirSync(categoryDir)
101+
102+
expect(files).toContain(`${TEST_NAME}.approved.1.txt`)
103+
expect(files).toContain(`${TEST_NAME}.rejected.2.txt`)
104+
expect(files).toContain(`${TEST_NAME}.approved.3.txt`)
105+
expect(files).toHaveLength(3)
106+
})
83107
})
84108

85109
describe("matching existing files", () => {
@@ -171,6 +195,71 @@ describe("approvals", () => {
171195
})
172196
})
173197

198+
describe("renumberApprovals", () => {
199+
it("should renumber files with duplicate numbers", () => {
200+
const categoryDir = path.join(TEST_APPROVALS_DIR, TEST_CATEGORY)
201+
fs.mkdirSync(categoryDir, { recursive: true })
202+
203+
// Create files with duplicate numbers (approved.1 and rejected.1)
204+
fs.writeFileSync(path.join(categoryDir, `${TEST_NAME}.approved.1.txt`), "output1", "utf-8")
205+
fs.writeFileSync(path.join(categoryDir, `${TEST_NAME}.rejected.1.txt`), "output2", "utf-8")
206+
207+
const result = renumberApprovals(TEST_APPROVALS_DIR)
208+
209+
expect(result.renamedCount).toBeGreaterThan(0)
210+
211+
const files = fs.readdirSync(categoryDir)
212+
const numbers = files.map((f) => {
213+
const match = f.match(/\.(\d+)\.txt$/)
214+
return match ? parseInt(match[1], 10) : 0
215+
})
216+
217+
// All numbers should be unique
218+
const uniqueNumbers = new Set(numbers)
219+
expect(uniqueNumbers.size).toBe(numbers.length)
220+
})
221+
222+
it("should renumber files with gaps", () => {
223+
const categoryDir = path.join(TEST_APPROVALS_DIR, TEST_CATEGORY)
224+
fs.mkdirSync(categoryDir, { recursive: true })
225+
226+
// Create files with gaps (1, 3, 5)
227+
fs.writeFileSync(path.join(categoryDir, `${TEST_NAME}.approved.1.txt`), "output1", "utf-8")
228+
fs.writeFileSync(path.join(categoryDir, `${TEST_NAME}.approved.3.txt`), "output2", "utf-8")
229+
fs.writeFileSync(path.join(categoryDir, `${TEST_NAME}.approved.5.txt`), "output3", "utf-8")
230+
231+
const result = renumberApprovals(TEST_APPROVALS_DIR)
232+
233+
expect(result.renamedCount).toBeGreaterThan(0)
234+
235+
const files = fs.readdirSync(categoryDir)
236+
expect(files).toContain(`${TEST_NAME}.approved.1.txt`)
237+
expect(files).toContain(`${TEST_NAME}.approved.2.txt`)
238+
expect(files).toContain(`${TEST_NAME}.approved.3.txt`)
239+
})
240+
241+
it("should not rename files that are already sequential", () => {
242+
const categoryDir = path.join(TEST_APPROVALS_DIR, TEST_CATEGORY)
243+
fs.mkdirSync(categoryDir, { recursive: true })
244+
245+
// Create files that are already sequential
246+
fs.writeFileSync(path.join(categoryDir, `${TEST_NAME}.approved.1.txt`), "output1", "utf-8")
247+
fs.writeFileSync(path.join(categoryDir, `${TEST_NAME}.rejected.2.txt`), "output2", "utf-8")
248+
fs.writeFileSync(path.join(categoryDir, `${TEST_NAME}.approved.3.txt`), "output3", "utf-8")
249+
250+
const result = renumberApprovals(TEST_APPROVALS_DIR)
251+
252+
expect(result.renamedCount).toBe(0)
253+
})
254+
255+
it("should return zero counts for non-existent directory", () => {
256+
const result = renumberApprovals("non-existent-dir")
257+
258+
expect(result.renamedCount).toBe(0)
259+
expect(result.totalFiles).toBe(0)
260+
})
261+
})
262+
174263
describe("skip-approval mode", () => {
175264
it("should mark new outputs as unknown with newOutput=true", async () => {
176265
const result = await checkApproval(TEST_CATEGORY, TEST_NAME, "input", "output", true)

src/test-llm-autocompletion/approvals.ts

Lines changed: 112 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,19 @@ function getCategoryPath(category: string): string {
1313
return path.join(APPROVALS_DIR, category)
1414
}
1515

16-
function getNextFileNumber(categoryDir: string, testName: string, type: "approved" | "rejected"): number {
16+
function getNextFileNumber(categoryDir: string, testName: string): number {
1717
if (!fs.existsSync(categoryDir)) {
1818
return 1
1919
}
2020

2121
const files = fs.readdirSync(categoryDir)
22-
const pattern = new RegExp(`^${testName}\\.${type}\\.(\\d+)\\.txt$`)
22+
// Match both approved and rejected files to get globally unique numbers
23+
const pattern = new RegExp(`^${testName}\\.(approved|rejected)\\.(\\d+)\\.txt$`)
2324
const numbers = files
2425
.filter((f) => pattern.test(f))
2526
.map((f) => {
2627
const match = f.match(pattern)
27-
return match ? parseInt(match[1], 10) : 0
28+
return match ? parseInt(match[2], 10) : 0
2829
})
2930

3031
return numbers.length > 0 ? Math.max(...numbers) + 1 : 1
@@ -112,11 +113,118 @@ export async function checkApproval(
112113

113114
fs.mkdirSync(categoryDir, { recursive: true })
114115

115-
const nextNumber = getNextFileNumber(categoryDir, testName, type)
116+
const nextNumber = getNextFileNumber(categoryDir, testName)
116117
const filename = `${testName}.${type}.${nextNumber}.txt`
117118
const filePath = path.join(categoryDir, filename)
118119

119120
fs.writeFileSync(filePath, output, "utf-8")
120121

121122
return { isApproved, newOutput: true }
122123
}
124+
125+
export interface RenumberResult {
126+
renamedCount: number
127+
totalFiles: number
128+
}
129+
130+
export function renumberApprovals(approvalsDir: string = "approvals"): RenumberResult {
131+
let renamedCount = 0
132+
let totalFiles = 0
133+
134+
if (!fs.existsSync(approvalsDir)) {
135+
return { renamedCount, totalFiles }
136+
}
137+
138+
// Get all category directories
139+
const categories = fs.readdirSync(approvalsDir, { withFileTypes: true }).filter((d) => d.isDirectory())
140+
141+
for (const category of categories) {
142+
const categoryDir = path.join(approvalsDir, category.name)
143+
const files = fs.readdirSync(categoryDir).filter((f) => f.endsWith(".txt"))
144+
145+
// Group files by test name
146+
const filesByTestName = new Map<string, string[]>()
147+
const pattern = /^(.+)\.(approved|rejected)\.(\d+)\.txt$/
148+
149+
for (const file of files) {
150+
const match = file.match(pattern)
151+
if (match) {
152+
totalFiles++
153+
const testName = match[1]
154+
if (!filesByTestName.has(testName)) {
155+
filesByTestName.set(testName, [])
156+
}
157+
filesByTestName.get(testName)!.push(file)
158+
}
159+
}
160+
161+
// Renumber files for each test name
162+
for (const [testName, testFiles] of filesByTestName) {
163+
// Sort files by their current number
164+
const sortedFiles = testFiles.sort((a, b) => {
165+
const matchA = a.match(pattern)!
166+
const matchB = b.match(pattern)!
167+
return parseInt(matchA[3], 10) - parseInt(matchB[3], 10)
168+
})
169+
170+
// Check if renumbering is needed
171+
const numbers = sortedFiles.map((f) => {
172+
const match = f.match(pattern)!
173+
return parseInt(match[3], 10)
174+
})
175+
176+
// Check for duplicates or gaps
177+
const uniqueNumbers = new Set(numbers)
178+
const needsRenumber = uniqueNumbers.size !== numbers.length || !isSequential(numbers)
179+
180+
if (needsRenumber) {
181+
// Renumber all files sequentially
182+
for (let i = 0; i < sortedFiles.length; i++) {
183+
const oldFile = sortedFiles[i]
184+
const match = oldFile.match(pattern)!
185+
const type = match[2]
186+
const newNumber = i + 1
187+
const newFile = `${testName}.${type}.${newNumber}.txt`
188+
189+
if (oldFile !== newFile) {
190+
const oldPath = path.join(categoryDir, oldFile)
191+
const newPath = path.join(categoryDir, newFile)
192+
193+
// Use a temp file to avoid conflicts
194+
const tempPath = path.join(categoryDir, `${testName}.${type}.temp_${i}.txt`)
195+
fs.renameSync(oldPath, tempPath)
196+
sortedFiles[i] = `${testName}.${type}.temp_${i}.txt`
197+
}
198+
}
199+
200+
// Now rename from temp to final
201+
for (let i = 0; i < sortedFiles.length; i++) {
202+
const tempFile = sortedFiles[i]
203+
if (tempFile.includes(".temp_")) {
204+
const match = tempFile.match(/^(.+)\.(approved|rejected)\.temp_(\d+)\.txt$/)!
205+
const type = match[2]
206+
const newNumber = i + 1
207+
const newFile = `${testName}.${type}.${newNumber}.txt`
208+
209+
const tempPath = path.join(categoryDir, tempFile)
210+
const newPath = path.join(categoryDir, newFile)
211+
fs.renameSync(tempPath, newPath)
212+
renamedCount++
213+
}
214+
}
215+
}
216+
}
217+
}
218+
219+
return { renamedCount, totalFiles }
220+
}
221+
222+
function isSequential(numbers: number[]): boolean {
223+
const sorted = [...numbers].sort((a, b) => a - b)
224+
for (let i = 0; i < sorted.length; i++) {
225+
if (sorted[i] !== i + 1) {
226+
return false
227+
}
228+
}
229+
return true
230+
}

src/test-llm-autocompletion/runner.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import fs from "fs"
44
import path from "path"
55
import { GhostProviderTester } from "./ghost-provider-tester.js"
66
import { testCases, getCategories, TestCase } from "./test-cases.js"
7-
import { checkApproval } from "./approvals.js"
7+
import { checkApproval, renumberApprovals } from "./approvals.js"
88

99
interface TestResult {
1010
testCase: TestCase
@@ -395,6 +395,16 @@ export class TestRunner {
395395
} else {
396396
console.log("No orphaned approval files found.")
397397
}
398+
399+
// Renumber files to fix duplicates and gaps
400+
console.log("\n🔢 Renumbering approval files to fix duplicates and gaps...\n")
401+
const renumberResult = renumberApprovals(approvalsDir)
402+
403+
if (renumberResult.renamedCount > 0) {
404+
console.log(`✅ Renumbered ${renumberResult.renamedCount} files to ensure unique sequential numbering.`)
405+
} else {
406+
console.log("No renumbering needed - all files already have unique sequential numbers.")
407+
}
398408
}
399409
}
400410

0 commit comments

Comments
 (0)