Skip to content

Commit c207ee9

Browse files
committed
Refactor and enhance translation tests
- Removed outdated translation coverage tests and replaced them with new multi-turn and tool call tests. - Introduced structured test cases for multi-turn interactions, ensuring proper handling of tool calls and deduplication. - Improved tool call processing tests, including cleanup of incomplete calls and handling of inline data. - Streamlined translation test cases to utilize shared utility functions for consistency and clarity. - Added validation for tool call ID length constraints and ensured proper mapping of tool configurations. - Enhanced error handling tests to cover various failure scenarios and ensure graceful degradation.
1 parent 603c545 commit c207ee9

10 files changed

Lines changed: 989 additions & 992 deletions

tests/generate-content/_test-utils.ts

Lines changed: 369 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import { mock } from "bun:test"
1+
import { mock, expect } from "bun:test"
22

33
import type {
44
TestServer,
55
MockChatCompletionsModule,
66
MockRateLimitModule,
77
MockTokenCountModule,
8+
CapturedPayload,
89
} from "./test-types"
910

1011
export function asyncIterableFrom(
@@ -108,3 +109,370 @@ export const sampleToolCall = {
108109
arguments: '{"absolute_path": "/path/to/file.txt"}',
109110
},
110111
}
112+
113+
// Common URL patterns
114+
export const GEMINI_PRO_URL = "/v1beta/models/gemini-pro:generateContent"
115+
116+
// Message filtering helpers
117+
export function getMessagesByRole(
118+
payload: CapturedPayload,
119+
role: string,
120+
): Array<{ role: string; content?: string; tool_call_id?: string }> {
121+
return (payload.messages ?? []).filter((m) => m.role === role)
122+
}
123+
124+
export function expectMessageCounts(
125+
payload: CapturedPayload,
126+
expectations: {
127+
total?: number
128+
tool?: number
129+
assistant?: number
130+
assistantWithTools?: number
131+
user?: number
132+
},
133+
): void {
134+
const messages = payload.messages ?? []
135+
136+
if (expectations.total !== undefined) {
137+
expect(messages.length).toBeGreaterThanOrEqual(expectations.total)
138+
}
139+
140+
if (expectations.tool !== undefined) {
141+
const toolMessages = getMessagesByRole(payload, "tool")
142+
expect(toolMessages.length).toBeGreaterThanOrEqual(expectations.tool)
143+
}
144+
145+
if (expectations.assistant !== undefined) {
146+
const assistantMessages = getMessagesByRole(payload, "assistant")
147+
expect(assistantMessages.length).toBeGreaterThanOrEqual(
148+
expectations.assistant,
149+
)
150+
}
151+
152+
if (expectations.assistantWithTools !== undefined) {
153+
const assistantWithTools = messages.filter(
154+
(m) => m.role === "assistant" && m.tool_calls,
155+
)
156+
expect(assistantWithTools.length).toBeGreaterThanOrEqual(
157+
expectations.assistantWithTools,
158+
)
159+
}
160+
161+
if (expectations.user !== undefined) {
162+
const userMessages = getMessagesByRole(payload, "user")
163+
expect(userMessages.length).toBeGreaterThanOrEqual(expectations.user)
164+
}
165+
}
166+
167+
// Tool call validation helpers
168+
export function expectUniqueToolCallIds(
169+
payload: CapturedPayload,
170+
maxExpected?: number,
171+
): void {
172+
const toolMessages = getMessagesByRole(payload, "tool")
173+
const toolCallIds = new Set(
174+
toolMessages.map((m) => m.tool_call_id).filter(Boolean),
175+
)
176+
177+
if (maxExpected !== undefined) {
178+
expect(toolCallIds.size).toBeLessThanOrEqual(maxExpected)
179+
} else {
180+
expect(toolCallIds.size).toBeGreaterThan(0)
181+
}
182+
}
183+
184+
export function expectToolCallIdFormat(payload: CapturedPayload): void {
185+
const messages = payload.messages ?? []
186+
const toolMessages = getMessagesByRole(payload, "tool")
187+
188+
// Verify all tool messages have tool_call_id
189+
for (const toolMsg of toolMessages) {
190+
expect(toolMsg.tool_call_id).toBeDefined()
191+
expect(typeof toolMsg.tool_call_id).toBe("string")
192+
}
193+
194+
// Verify all tool_call_ids in assistant messages are ≤40 chars
195+
const assistantWithTools = messages.filter(
196+
(m) => m.role === "assistant" && m.tool_calls,
197+
)
198+
for (const msg of assistantWithTools) {
199+
if (msg.tool_calls) {
200+
for (const toolCall of msg.tool_calls) {
201+
expect(toolCall.id.length).toBeLessThanOrEqual(40)
202+
}
203+
}
204+
}
205+
}
206+
207+
// SSE stream parsing and assertion helpers
208+
export interface SSEEvent {
209+
event?: string
210+
data: string
211+
}
212+
213+
export function parseSSE(body: string): Array<SSEEvent> {
214+
return body
215+
.split("\n")
216+
.filter((line) => line.startsWith("data:"))
217+
.map((line) => ({ data: line.slice(5).trim() }))
218+
}
219+
220+
export interface SSEMatcher {
221+
text?: string
222+
finishReason?: string
223+
usageMetadata?: boolean
224+
toolCall?: {
225+
name: string
226+
hasArgs?: boolean
227+
completeArgs?: boolean
228+
}
229+
textMatch?: {
230+
pattern: string | RegExp
231+
minOccurrences?: number
232+
}
233+
jsonContains?: string
234+
}
235+
236+
export function expectSSEContains(body: string, matcher: SSEMatcher): void {
237+
const lines = parseSSE(body)
238+
const allData = lines.map((l) => l.data).join(" ")
239+
240+
if (matcher.text) {
241+
expect(allData.includes(matcher.text)).toBe(true)
242+
}
243+
if (matcher.finishReason) {
244+
expect(allData.includes(`"finishReason":"${matcher.finishReason}"`)).toBe(
245+
true,
246+
)
247+
}
248+
if (matcher.usageMetadata) {
249+
expect(allData.includes('"usageMetadata"')).toBe(true)
250+
}
251+
if (matcher.toolCall) {
252+
const { name, hasArgs, completeArgs } = matcher.toolCall
253+
254+
// Verify function call with name exists
255+
expect(allData.includes(`"functionCall":{"name":"${name}"`)).toBe(true)
256+
257+
if (hasArgs) {
258+
// Verify args object started
259+
expect(
260+
allData.includes(`"functionCall":{"name":"${name}","args":{`),
261+
).toBe(true)
262+
}
263+
264+
if (completeArgs) {
265+
// Verify args object is complete (contains closing brace)
266+
const argsPattern = `"functionCall":{"name":"${name}","args":{`
267+
expect(allData.includes(argsPattern)).toBe(true)
268+
// Verify there's a complete args object (find closing brace after args opening)
269+
const argsStart = allData.indexOf(argsPattern)
270+
expect(argsStart).toBeGreaterThan(-1)
271+
const afterArgs = allData.slice(argsStart + argsPattern.length)
272+
expect(afterArgs.includes("}")).toBe(true)
273+
}
274+
}
275+
276+
if (matcher.textMatch) {
277+
const { pattern, minOccurrences = 1 } = matcher.textMatch
278+
if (typeof pattern === "string") {
279+
// Count exact string occurrences
280+
const count = (allData.match(new RegExp(pattern, "g")) || []).length
281+
expect(count).toBeGreaterThanOrEqual(minOccurrences)
282+
} else {
283+
// Use regex pattern directly
284+
const matches = allData.match(pattern) || []
285+
expect(matches.length).toBeGreaterThanOrEqual(minOccurrences)
286+
}
287+
}
288+
289+
if (matcher.jsonContains) {
290+
expect(allData.includes(matcher.jsonContains)).toBe(true)
291+
}
292+
}
293+
294+
// Payload capture and validation helpers
295+
export async function setupPayloadCapture(mockResponse?: {
296+
content?: string
297+
finish_reason?: string
298+
usage?: {
299+
prompt_tokens: number
300+
completion_tokens: number
301+
total_tokens: number
302+
}
303+
}): Promise<CapturedPayload> {
304+
const capture: CapturedPayload = {} as CapturedPayload
305+
306+
const defaultResponse = {
307+
id: "test-id",
308+
choices: [
309+
{
310+
index: 0,
311+
message: {
312+
role: "assistant",
313+
content: mockResponse?.content ?? "ok",
314+
},
315+
finish_reason: mockResponse?.finish_reason ?? "stop",
316+
},
317+
],
318+
usage: mockResponse?.usage ?? {
319+
prompt_tokens: 1,
320+
completion_tokens: 1,
321+
total_tokens: 2,
322+
},
323+
}
324+
325+
await mock.module("~/services/copilot/create-chat-completions", () => ({
326+
createChatCompletions: (payload: CapturedPayload) => {
327+
// Copy all properties from payload to capture
328+
Object.assign(capture, payload)
329+
return defaultResponse
330+
},
331+
}))
332+
333+
return capture
334+
}
335+
336+
export interface ToolCleanupExpectation {
337+
noDuplicates?: boolean
338+
noEmptyFunctions?: boolean
339+
}
340+
341+
export function expectToolCleanup(
342+
payload: CapturedPayload,
343+
expected: ToolCleanupExpectation,
344+
): void {
345+
if (expected.noDuplicates) {
346+
const tools = payload.tools || []
347+
const names = tools.map((t) => t.function.name)
348+
expect(new Set(names).size).toBe(names.length)
349+
}
350+
if (expected.noEmptyFunctions) {
351+
const tools = payload.tools || []
352+
expect(tools.every((t) => t.function.name.length > 0)).toBe(true)
353+
}
354+
}
355+
356+
type ContentPart =
357+
| { text: string }
358+
| { functionCall: object }
359+
| { functionResponse: object }
360+
export function buildRequest(opts: {
361+
text?: string
362+
contents?: Array<{ role: string; parts: Array<ContentPart> }>
363+
tools?: Array<unknown>
364+
}) {
365+
return {
366+
contents: opts.contents ?? [
367+
{ role: "user", parts: [{ text: opts.text ?? "hi" }] },
368+
],
369+
...(opts.tools && { tools: opts.tools }),
370+
}
371+
}
372+
373+
// Enhanced request builder with more options
374+
export function buildGenerateContentRequest(opts: {
375+
userText?: string
376+
model?: string
377+
tools?: Array<unknown>
378+
systemInstruction?: string
379+
multiTurn?: boolean
380+
withFunctionCall?: { name: string; args: object }
381+
withFunctionResponse?: { name: string; response: object }
382+
}) {
383+
const contents: Array<{ role: string; parts: Array<ContentPart> }> = []
384+
385+
// Add user message
386+
if (opts.userText) {
387+
contents.push({ role: "user", parts: [{ text: opts.userText }] })
388+
}
389+
390+
// Add function call if requested
391+
if (opts.withFunctionCall) {
392+
contents.push({
393+
role: "model",
394+
parts: [
395+
{
396+
functionCall: {
397+
name: opts.withFunctionCall.name,
398+
args: opts.withFunctionCall.args,
399+
},
400+
},
401+
],
402+
})
403+
}
404+
405+
// Add function response if requested
406+
if (opts.withFunctionResponse) {
407+
contents.push({
408+
role: "user",
409+
parts: [
410+
{
411+
functionResponse: {
412+
name: opts.withFunctionResponse.name,
413+
response: opts.withFunctionResponse.response,
414+
},
415+
},
416+
],
417+
})
418+
}
419+
420+
const request: Record<string, unknown> = { contents }
421+
422+
if (opts.tools) {
423+
request.tools = opts.tools
424+
}
425+
426+
if (opts.systemInstruction) {
427+
request.systemInstruction = { parts: [{ text: opts.systemInstruction }] }
428+
}
429+
430+
return request
431+
}
432+
433+
// Model mapping assertion helper
434+
export function assertModelMapping(
435+
capturedPayload: CapturedPayload,
436+
_inputModel: string,
437+
expectedModel: string,
438+
): void {
439+
expect(capturedPayload.model).toBe(expectedModel)
440+
}
441+
442+
// Enhanced message extraction with filters
443+
export function extractMessages(
444+
payload: CapturedPayload,
445+
filters: {
446+
role?: string
447+
hasToolCalls?: boolean
448+
hasContent?: boolean
449+
contentContains?: string
450+
} = {},
451+
): Array<{
452+
role: string
453+
content?: string
454+
tool_call_id?: string
455+
tool_calls?: unknown
456+
}> {
457+
const messages = payload.messages ?? []
458+
459+
return messages.filter((msg) => {
460+
if (filters.role && msg.role !== filters.role) return false
461+
if (
462+
filters.hasToolCalls !== undefined
463+
&& Boolean(msg.tool_calls) !== filters.hasToolCalls
464+
)
465+
return false
466+
if (
467+
filters.hasContent !== undefined
468+
&& Boolean(msg.content) !== filters.hasContent
469+
)
470+
return false
471+
if (
472+
filters.contentContains
473+
&& (!msg.content || !msg.content.includes(filters.contentContains))
474+
)
475+
return false
476+
return true
477+
})
478+
}

0 commit comments

Comments
 (0)