Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions src/main/backend/__tests__/codeBlueprint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ describe('extractJsonObject', () => {
it('throws on malformed JSON', () => {
expect(() => extractJsonObject('{bad')).toThrow(AppError)
})

it('throws with parse detail when braces contain invalid JSON', () => {
expect(() => extractJsonObject('{bad}')).toThrow(AppError)
})
})

describe('validateGeneratedFilePath', () => {
Expand Down Expand Up @@ -182,6 +186,45 @@ describe('parseCodeBlueprint', () => {
it('throws when input is not valid JSON', () => {
expect(() => parseCodeBlueprint('not json')).toThrow(AppError)
})

it('throws when a file entry is not an object', () => {
const parsed = JSON.parse(makeMinimalBlueprintJson())
parsed.files = ['bad-file']

expect(() => parseCodeBlueprint(JSON.stringify(parsed))).toThrow(/文件 #1 无效/)
})

it('throws when optional file arrays are present but not arrays', () => {
const parsed = JSON.parse(makeMinimalBlueprintJson())
parsed.files[0].inputs = 'paper tensor'

expect(() => parseCodeBlueprint(JSON.stringify(parsed))).toThrow(/inputs 格式无效/)
})

it('throws when optional file arrays contain empty values', () => {
const parsed = JSON.parse(makeMinimalBlueprintJson())
parsed.files[0].outputs = ['']

expect(() => parseCodeBlueprint(JSON.stringify(parsed))).toThrow(AppError)
})

it('throws when omitted entries are not objects', () => {
const parsed = JSON.parse(makeMinimalBlueprintJson())
parsed.omitted = ['training loop']

expect(() => parseCodeBlueprint(JSON.stringify(parsed))).toThrow(/omitted #1 无效/)
})

it('ignores non-object minimality checks and non-string evidence', () => {
const parsed = JSON.parse(makeMinimalBlueprintJson())
parsed.minimalityCheck = 'minimal'
parsed.files[0].evidence = 123

const blueprint = parseCodeBlueprint(JSON.stringify(parsed))

expect(blueprint.minimalityCheck).toBeUndefined()
expect(blueprint.files[0].evidence).toBeUndefined()
})
})

describe('validateFilesAgainstBlueprint', () => {
Expand Down
121 changes: 117 additions & 4 deletions src/main/backend/__tests__/deepseekClient.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AppError, ErrorCodes } from '../errors'
import { callDeepSeek } from '../deepseekClient'
import { getActiveSettings } from '../settingsStore'
Expand All @@ -9,11 +9,11 @@ vi.mock('../settingsStore', () => ({

const mockedGetActiveSettings = vi.mocked(getActiveSettings)

function mockSettings(provider = 'deepseek') {
function mockSettings(provider = 'deepseek', model?: string, apiKey = 'test-key') {
mockedGetActiveSettings.mockReturnValue({
apiKey: 'test-key',
apiKey,
provider,
model: provider === 'kimi' ? 'kimi-k2.6' : 'deepseek-v4-flash',
model: model ?? (provider === 'kimi' ? 'kimi-k2.6' : 'deepseek-v4-flash'),
language: 'zh-CN',
})
}
Expand All @@ -32,13 +32,44 @@ function createStreamResponse(chunks: string[], status = 200): Response {
return new Response(stream, { status })
}

function createOkStreamResponse(content = 'ok') {
return createStreamResponse([
`data: {"choices":[{"delta":{"content":"${content}"}}]}\n`,
])
}

function requestBody() {
return JSON.parse(vi.mocked(fetch).mock.calls.at(-1)?.[1]?.body as string)
}

describe('callDeepSeek', () => {
beforeEach(() => {
vi.restoreAllMocks()
mockSettings()
vi.stubGlobal('fetch', vi.fn())
})

afterEach(() => {
vi.useRealTimers()
})

it('throws when the active API key or provider is invalid', async () => {
const fetchMock = vi.mocked(fetch)

mockSettings('deepseek', undefined, ' ')
await expect(callDeepSeek([{ role: 'user', content: 'test' }])).rejects.toMatchObject({
code: ErrorCodes.API_KEY_MISSING,
})
expect(fetchMock).not.toHaveBeenCalled()

mockSettings('unknown-provider')
await expect(callDeepSeek([{ role: 'user', content: 'test' }])).rejects.toMatchObject({
code: ErrorCodes.API_SERVER_ERROR,
message: 'Unsupported provider: unknown-provider',
})
expect(fetchMock).not.toHaveBeenCalled()
})

it('streams content and normalizes usage from SSE chunks', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValue(createStreamResponse([
Expand Down Expand Up @@ -89,6 +120,12 @@ describe('callDeepSeek', () => {

it('maps API response errors to app error codes', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValue(new Response('bad key', { status: 401 }))

await expect(callDeepSeek([{ role: 'user', content: 'test' }])).rejects.toMatchObject({
code: ErrorCodes.API_UNAUTHORIZED,
})

fetchMock.mockResolvedValue(new Response('nope', { status: 429 }))

await expect(callDeepSeek([{ role: 'user', content: 'test' }])).rejects.toMatchObject({
Expand All @@ -102,6 +139,15 @@ describe('callDeepSeek', () => {
})
})

it('throws API_RESPONSE_INVALID when the API does not return a stream', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValue(new Response(null, { status: 200 }))

await expect(callDeepSeek([{ role: 'user', content: 'test' }])).rejects.toMatchObject({
code: ErrorCodes.API_RESPONSE_INVALID,
})
})

it('throws API_RESPONSE_INVALID for empty streamed content', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValue(createStreamResponse([
Expand Down Expand Up @@ -129,4 +175,71 @@ describe('callDeepSeek', () => {
await expect(callDeepSeek([{ role: 'user', content: 'test' }], undefined, controller.signal))
.rejects.toMatchObject({ code: ErrorCodes.ANALYSIS_CANCELLED })
})

it('maps timeout aborts and network failures to app error codes', async () => {
vi.useFakeTimers()
const fetchMock = vi.mocked(fetch)
fetchMock.mockImplementation(async (_url, init) => new Promise<Response>((_resolve, reject) => {
;(init?.signal as AbortSignal).addEventListener('abort', () => {
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }))
})
}))

const timedOut = expect(callDeepSeek([{ role: 'user', content: 'test' }]))
.rejects.toMatchObject({ code: ErrorCodes.API_TIMEOUT })
await vi.advanceTimersByTimeAsync(120_000)
await timedOut

vi.useRealTimers()
fetchMock.mockRejectedValue(new Error('socket closed'))
await expect(callDeepSeek([{ role: 'user', content: 'test' }])).rejects.toMatchObject({
code: ErrorCodes.API_NETWORK_ERROR,
detail: 'socket closed',
})
})

it('applies Jiekou model-specific request options and rejects unsupported models', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockImplementation(async () => createOkStreamResponse())
mockSettings('jiekou', 'gemini-3.1-flash-lite-preview')

await callDeepSeek([{ role: 'user', content: 'test' }])
expect(requestBody()).toMatchObject({
model: 'gemini-3.1-flash-lite-preview',
max_tokens: 65536,
temperature: 0.7,
})

mockSettings('jiekou', 'gpt-5.5-pro')
await expect(callDeepSeek([{ role: 'user', content: 'test' }])).rejects.toMatchObject({
code: ErrorCodes.API_SERVER_ERROR,
message: 'Model gpt-5.5-pro is not supported by the current API endpoint.',
})
})

it('applies GLM and MiMo model-specific request options', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockImplementation(async () => createOkStreamResponse())

mockSettings('glm', 'glm-5.1')
await callDeepSeek([{ role: 'user', content: 'test' }])
expect(requestBody()).toMatchObject({
model: 'glm-5.1',
max_tokens: 65536,
temperature: 1,
thinking: { type: 'enabled' },
})

mockSettings('mimo', 'mimo-v2.5-pro')
await callDeepSeek([{ role: 'user', content: 'test' }])
expect(requestBody()).toMatchObject({
model: 'mimo-v2.5-pro',
max_completion_tokens: 131072,
temperature: 1,
top_p: 0.95,
frequency_penalty: 0,
presence_penalty: 0,
stop: null,
})
})
})
69 changes: 69 additions & 0 deletions src/main/backend/__tests__/exportCode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,75 @@ describe('writeCodeFolder', () => {
fs.rmSync(output, { recursive: true, force: true })
})

it('exports blueprint README defaults when optional scope fields are absent', () => {
cacheCodeBundle({
readme: '# Test Summary',
files: [{ path: 'core_code/loss.py', content: 'def loss(): pass' }],
blueprint: {
coreContribution: 'A minimal algorithm',
minimalImplementationBoundary: 'Only the recurrence',
paperDomain: 'Optimization',
files: [{
path: 'core_code/loss.py',
purpose: 'Implement recurrence | with escaped pipe',
mainSymbols: ['loss\nfunction'],
mustInclude: ['recurrence'],
mustNotInclude: ['training loop'],
}],
},
})

const output = tempDir()
const result = writeCodeFolder(output)
expect(result.ok).toBe(true)

const readmeContent = fs.readFileSync(path.join(output, 'README.md'), 'utf-8')
expect(readmeContent).toContain('### Inferred Paper Domain')
expect(readmeContent).toContain('Optimization')
expect(readmeContent).toContain('Implement recurrence \\| with escaped pipe')
expect(readmeContent).toContain('loss function')
expect(readmeContent).toContain('| None specified. | Not applicable. |')
expect(readmeContent).toContain('Not specified.')

fs.rmSync(output, { recursive: true, force: true })
})

it.each([
'/absolute.py',
'C:/absolute.py',
'core_code//loss.py',
'README.md',
'core_code/README.md',
])('rejects unsafe generated path variant %s', (unsafePath) => {
cacheCodeBundle({
readme: '# Unsafe',
files: [{ path: unsafePath, content: 'bad' }],
})

const output = tempDir()
const result = writeCodeFolder(output)

expect(result).toEqual({ ok: false, error: `Unsafe generated file path: ${unsafePath}` })
fs.rmSync(output, { recursive: true, force: true })
})

it('returns a write failure when the output path cannot be created as a directory', () => {
cacheCodeBundle({
readme: '# Test Summary',
files: [{ path: 'core_code/loss.py', content: 'def loss(): pass' }],
})
const output = tempDir()
fs.writeFileSync(output, 'not a directory')

const result = writeCodeFolder(output)

expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.error).toContain('Failed to write project folder')
}
fs.rmSync(output, { force: true })
})

it('creates nested directories for generated files', () => {
cacheCodeBundle({
readme: '# Nested',
Expand Down
Loading
Loading