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
762 changes: 762 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,16 @@
"remark-math": "^6.0.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"concurrently": "^9.1.0",
"electron": "^33.2.1",
"electron-builder": "^26.8.1",
"jsdom": "^29.1.1",
"typescript": "^5.6.3",
"vite": "^6.0.3",
"vitest": "^4.1.5",
Expand Down
132 changes: 132 additions & 0 deletions src/main/backend/__tests__/deepseekClient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AppError, ErrorCodes } from '../errors'
import { callDeepSeek } from '../deepseekClient'
import { getActiveSettings } from '../settingsStore'

vi.mock('../settingsStore', () => ({
getActiveSettings: vi.fn(),
}))

const mockedGetActiveSettings = vi.mocked(getActiveSettings)

function mockSettings(provider = 'deepseek') {
mockedGetActiveSettings.mockReturnValue({
apiKey: 'test-key',
provider,
model: provider === 'kimi' ? 'kimi-k2.6' : 'deepseek-v4-flash',
language: 'zh-CN',
})
}

function createStreamResponse(chunks: string[], status = 200): Response {
const encoder = new TextEncoder()
const stream = new ReadableStream({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk))
}
controller.close()
},
})

return new Response(stream, { status })
}

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

it('streams content and normalizes usage from SSE chunks', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValue(createStreamResponse([
'data: {"choices":[{"delta":{"content":"hello "}}]}\n',
'data: {"choices":[{"delta":{"content":"world"}}],"usage":{"prompt_tokens":10,"completion_tokens":3,"total_tokens":13}}\n',
'data: [DONE]\n',
]))
const updates: string[] = []

const result = await callDeepSeek([
{ role: 'user', content: 'Summarize' },
], (chunk) => updates.push(chunk))

expect(result.content).toBe('hello world')
expect(updates).toEqual(['hello ', 'world'])
expect(result.usage).toEqual({ promptTokens: 10, completionTokens: 3, totalTokens: 13 })
expect(result.rawUsage).toEqual({ prompt_tokens: 10, completion_tokens: 3, total_tokens: 13 })
})

it('adds include_usage stream option only for compatible providers', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockImplementation(async () => createStreamResponse([
'data: {"choices":[{"delta":{"content":"ok"}}]}\n',
]))

await callDeepSeek([{ role: 'user', content: 'test' }])
let body = JSON.parse(fetchMock.mock.calls[0][1]?.body as string)
expect(body.stream_options).toEqual({ include_usage: true })

fetchMock.mockClear()
mockSettings('minimax')
await callDeepSeek([{ role: 'user', content: 'test' }])
body = JSON.parse(fetchMock.mock.calls[0][1]?.body as string)
expect(body.stream_options).toBeUndefined()
})

it('skips malformed SSE JSON chunks', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValue(createStreamResponse([
'data: {bad json}\n',
'data: {"choices":[{"delta":{"content":"valid"}}]}\n',
]))

await expect(callDeepSeek([{ role: 'user', content: 'test' }])).resolves.toMatchObject({
content: 'valid',
})
})

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

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

fetchMock.mockResolvedValue(new Response('server exploded', { status: 500 }))
await expect(callDeepSeek([{ role: 'user', content: 'test' }])).rejects.toMatchObject({
code: ErrorCodes.API_SERVER_ERROR,
detail: 'server exploded',
})
})

it('throws API_RESPONSE_INVALID for empty streamed content', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValue(createStreamResponse([
'data: {"usage":{"prompt_tokens":1,"completion_tokens":0,"total_tokens":1}}\n',
]))

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

it('maps external abort to ANALYSIS_CANCELLED', async () => {
const controller = new AbortController()
controller.abort()
const fetchMock = vi.mocked(fetch)
fetchMock.mockImplementation(async (_url, init) => {
if ((init?.signal as AbortSignal).aborted) {
throw Object.assign(new Error('aborted'), { name: 'AbortError' })
}
return createStreamResponse([])
})

await expect(callDeepSeek([{ role: 'user', content: 'test' }], undefined, controller.signal))
.rejects.toBeInstanceOf(AppError)
await expect(callDeepSeek([{ role: 'user', content: 'test' }], undefined, controller.signal))
.rejects.toMatchObject({ code: ErrorCodes.ANALYSIS_CANCELLED })
})
})
137 changes: 137 additions & 0 deletions src/main/backend/__tests__/paperAnalyzerFlow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { analyzePaper } from '../paperAnalyzer'
import { ErrorCodes } from '../errors'
import { getCachedCodeBundle } from '../codeCache'
import { callDeepSeek } from '../deepseekClient'
import { parsePDF } from '../pdfParser'
import { getActiveSettings } from '../settingsStore'

vi.mock('../pdfParser', () => ({
parsePDF: vi.fn(),
}))

vi.mock('../deepseekClient', () => ({
callDeepSeek: vi.fn(),
}))

vi.mock('../settingsStore', () => ({
getActiveSettings: vi.fn(),
}))

const mockedParsePDF = vi.mocked(parsePDF)
const mockedCallDeepSeek = vi.mocked(callDeepSeek)
const mockedGetActiveSettings = vi.mocked(getActiveSettings)

const usage = { promptTokens: 100, completionTokens: 20, totalTokens: 120 }
const rawUsage = { prompt_tokens: 100, completion_tokens: 20, total_tokens: 120 }

function outputWithoutCode(summary = 'Paper summary'): string {
return [
'<P2CC_SUMMARY>',
summary,
'</P2CC_SUMMARY>',
'<P2CC_CODE_DECISION>{"needed": false}</P2CC_CODE_DECISION>',
].join('')
}

function outputWithCode(): string {
return [
'<P2CC_SUMMARY>Paper summary</P2CC_SUMMARY>',
'<P2CC_CODE_DECISION>{"needed": true}</P2CC_CODE_DECISION>',
'<P2CC_CODE_BLUEPRINT>',
JSON.stringify({
coreContribution: 'a minimal loss function',
minimalImplementationBoundary: 'only the loss function',
files: [{
path: 'core_code/loss.py',
purpose: 'implements the proposed loss function',
mainSymbols: ['loss'],
mustInclude: ['loss'],
mustNotInclude: ['training loop'],
}],
}),
'</P2CC_CODE_BLUEPRINT>',
'<P2CC_CODE_BUNDLE><P2CC_FILE path="core_code/loss.py">def loss():\n return 0</P2CC_FILE></P2CC_CODE_BUNDLE>',
].join('')
}

function mockLlmOutput(content: string) {
mockedCallDeepSeek.mockImplementation(async (_messages, onUpdate) => {
onUpdate?.(content)
return { content, usage, rawUsage }
})
}

describe('analyzePaper flow', () => {
beforeEach(() => {
vi.clearAllMocks()
mockedGetActiveSettings.mockReturnValue({
apiKey: 'key',
provider: 'deepseek',
model: 'deepseek-v4-flash',
language: 'zh-CN',
})
mockedParsePDF.mockResolvedValue({ text: 'paper text', pageCount: 3 })
})

it('returns the new success shape with usage for summary-only analysis', async () => {
mockLlmOutput(outputWithoutCode('A compact summary'))
const progress: string[] = []
const summaryChunks: string[] = []

const result = await analyzePaper(
'paper.pdf',
(item) => progress.push(item.stage),
(chunk) => summaryChunks.push(chunk)
)

expect(result).toEqual({
ok: true,
result: { summary: 'A compact summary', hasCoreCode: false },
usage,
rawUsage,
})
expect(summaryChunks.join('')).toBe('A compact summary')
expect(progress).toEqual(expect.arrayContaining(['parsing', 'summarizing', 'generating_code', 'done']))
})

it('caches generated core code and returns hasCoreCode for valid code output', async () => {
mockLlmOutput(outputWithCode())

const result = await analyzePaper('paper.pdf', () => {})

expect(result.ok).toBe(true)
if (result.ok) {
expect(result.result.hasCoreCode).toBe(true)
}
expect(getCachedCodeBundle()?.files).toEqual([
{ path: 'core_code/loss.py', content: 'def loss():\n return 0' },
])
})

it('keeps usage when parsing the model output fails after the LLM call', async () => {
mockLlmOutput('<P2CC_SUMMARY>missing end tag')

const result = await analyzePaper('paper.pdf', () => {})

expect(result).toMatchObject({
ok: false,
error: { code: ErrorCodes.API_RESPONSE_INVALID },
usage,
rawUsage,
})
})

it('returns ANALYSIS_CANCELLED when aborted before parsing starts', async () => {
const controller = new AbortController()
controller.abort()

const result = await analyzePaper('paper.pdf', () => {}, undefined, controller.signal)

expect(result).toMatchObject({
ok: false,
error: { code: ErrorCodes.ANALYSIS_CANCELLED },
})
expect(mockedParsePDF).not.toHaveBeenCalled()
})
})
20 changes: 1 addition & 19 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Language, t } from './i18n'
import SettingsPanel from './components/SettingsPanel'
import PDFPanel from './components/PDFPanel'
import OutputPanel from './components/OutputPanel'
import { clamp, readStoredNumber } from './utils/resizableLayout'

const SIDEBAR_WIDTH_KEY = 'paper2corecode.sidebarWidth'
const UPLOAD_HEIGHT_KEY = 'paper2corecode.uploadHeight'
Expand All @@ -16,25 +17,6 @@ const UPLOAD_MAX_HEIGHT = 420
const RESULT_MIN_HEIGHT = 260
const RESIZE_HANDLE_SIZE = 16

function clamp(value: number, min: number, max: number): number {
if (max < min) return min
return Math.min(Math.max(value, min), max)
}

function readStoredNumber(key: string, fallback: number): number {
let stored: string | null = null
try {
stored = window.localStorage.getItem(key)
} catch {
return fallback
}

if (!stored) return fallback

const parsed = Number(stored)
return Number.isFinite(parsed) ? parsed : fallback
}

function getContentWidth(element: HTMLElement): number {
const styles = window.getComputedStyle(element)
return element.clientWidth - parseFloat(styles.paddingLeft) - parseFloat(styles.paddingRight)
Expand Down
Loading