From 4a6b09f8442849cb325459a18610c0ce398c9679 Mon Sep 17 00:00:00 2001 From: 1837669410 <1837669410@qq.com> Date: Tue, 12 May 2026 20:31:26 +0800 Subject: [PATCH] Improve coverage for core analysis flows --- .../backend/__tests__/codeBlueprint.test.ts | 43 ++++ .../backend/__tests__/deepseekClient.test.ts | 121 ++++++++++- src/main/backend/__tests__/exportCode.test.ts | 69 +++++++ .../__tests__/paperAnalyzerFlow.test.ts | 113 +++++++++++ src/main/backend/__tests__/pdfParser.test.ts | 114 +++++++++++ .../backend/__tests__/promptBuilder.test.ts | 60 ++++++ .../backend/__tests__/settingsStore.test.ts | 185 +++++++++++++++++ src/renderer/__tests__/App.analysis.test.tsx | 140 ++++++++++++- src/renderer/__tests__/App.resizable.test.tsx | 44 ++++ src/renderer/__tests__/i18n.test.ts | 6 + src/renderer/components/SettingsPanel.tsx | 13 +- .../components/__tests__/OutputPanel.test.tsx | 182 ++++++++++++++++- .../components/__tests__/PDFPanel.test.tsx | 47 ++++- .../__tests__/SettingsPanel.test.tsx | 189 ++++++++++++++++++ vitest.config.ts | 9 + 15 files changed, 1317 insertions(+), 18 deletions(-) create mode 100644 src/main/backend/__tests__/pdfParser.test.ts create mode 100644 src/main/backend/__tests__/promptBuilder.test.ts create mode 100644 src/main/backend/__tests__/settingsStore.test.ts create mode 100644 src/renderer/components/__tests__/SettingsPanel.test.tsx diff --git a/src/main/backend/__tests__/codeBlueprint.test.ts b/src/main/backend/__tests__/codeBlueprint.test.ts index e719918..8b286ec 100644 --- a/src/main/backend/__tests__/codeBlueprint.test.ts +++ b/src/main/backend/__tests__/codeBlueprint.test.ts @@ -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', () => { @@ -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', () => { diff --git a/src/main/backend/__tests__/deepseekClient.test.ts b/src/main/backend/__tests__/deepseekClient.test.ts index 30bb210..694b720 100644 --- a/src/main/backend/__tests__/deepseekClient.test.ts +++ b/src/main/backend/__tests__/deepseekClient.test.ts @@ -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' @@ -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', }) } @@ -32,6 +32,16 @@ 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() @@ -39,6 +49,27 @@ describe('callDeepSeek', () => { 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([ @@ -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({ @@ -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([ @@ -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((_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, + }) + }) }) diff --git a/src/main/backend/__tests__/exportCode.test.ts b/src/main/backend/__tests__/exportCode.test.ts index 0c77edb..d6d799d 100644 --- a/src/main/backend/__tests__/exportCode.test.ts +++ b/src/main/backend/__tests__/exportCode.test.ts @@ -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', diff --git a/src/main/backend/__tests__/paperAnalyzerFlow.test.ts b/src/main/backend/__tests__/paperAnalyzerFlow.test.ts index e7f2667..fb489ff 100644 --- a/src/main/backend/__tests__/paperAnalyzerFlow.test.ts +++ b/src/main/backend/__tests__/paperAnalyzerFlow.test.ts @@ -134,4 +134,117 @@ describe('analyzePaper flow', () => { }) expect(mockedParsePDF).not.toHaveBeenCalled() }) + + it('returns ANALYSIS_CANCELLED when aborted after PDF parsing', async () => { + const controller = new AbortController() + mockedParsePDF.mockImplementationOnce(async () => { + controller.abort() + return { text: 'paper text', pageCount: 1 } + }) + + const result = await analyzePaper('paper.pdf', () => {}, undefined, controller.signal) + + expect(result).toMatchObject({ + ok: false, + error: { code: ErrorCodes.ANALYSIS_CANCELLED }, + }) + expect(mockedCallDeepSeek).not.toHaveBeenCalled() + }) + + it('falls back to summary-only when needed core code has an invalid blueprint', async () => { + mockLlmOutput([ + 'Paper summary', + '{"needed": true}', + '{"files":[]}', + 'def loss():\n return 0', + ].join('')) + const progress: string[] = [] + + const result = await analyzePaper('paper.pdf', (item) => progress.push(item.message)) + + expect(result).toEqual({ + ok: true, + result: { summary: 'Paper summary', hasCoreCode: false }, + usage, + rawUsage, + }) + expect(progress).toContain('模型未能生成有效的核心代码蓝图,本次仅保留论文总结') + expect(getCachedCodeBundle()).toBeNull() + }) + + it('falls back to summary-only when generated files do not match the blueprint', async () => { + mockLlmOutput([ + 'Paper summary', + '{"needed": true}', + '', + 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'], + }], + }), + '', + 'def train():\n pass', + ].join('')) + + const result = await analyzePaper('paper.pdf', () => {}) + + expect(result).toMatchObject({ + ok: true, + result: { summary: 'Paper summary', hasCoreCode: false }, + usage, + rawUsage, + }) + expect(getCachedCodeBundle()).toBeNull() + }) + + it('returns ANALYSIS_FAILED for unexpected PDF parsing errors', async () => { + mockedParsePDF.mockRejectedValueOnce(new Error('cannot read file')) + + const result = await analyzePaper('paper.pdf', () => {}) + + expect(result).toMatchObject({ + ok: false, + error: { + code: ErrorCodes.ANALYSIS_FAILED, + detail: 'cannot read file', + }, + }) + expect(mockedCallDeepSeek).not.toHaveBeenCalled() + }) + + it('returns ANALYSIS_FAILED for unexpected LLM errors', async () => { + mockedCallDeepSeek.mockRejectedValueOnce(new Error('stream broke')) + + const result = await analyzePaper('paper.pdf', () => {}) + + expect(result).toMatchObject({ + ok: false, + error: { + code: ErrorCodes.ANALYSIS_FAILED, + detail: 'stream broke', + }, + }) + }) + + it('uses Chinese as fallback language when settings cannot be read', async () => { + mockedGetActiveSettings.mockImplementationOnce(() => { + throw new Error('settings unavailable') + }) + mockLlmOutput(outputWithoutCode('Fallback language summary')) + const progress: string[] = [] + + const result = await analyzePaper('paper.pdf', (item) => progress.push(item.message)) + + expect(result).toMatchObject({ + ok: true, + result: { summary: 'Fallback language summary', hasCoreCode: false }, + }) + expect(progress[0]).toBe('读取 PDF 文件...') + }) }) diff --git a/src/main/backend/__tests__/pdfParser.test.ts b/src/main/backend/__tests__/pdfParser.test.ts new file mode 100644 index 0000000..127be5b --- /dev/null +++ b/src/main/backend/__tests__/pdfParser.test.ts @@ -0,0 +1,114 @@ +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AppError, ErrorCodes } from '../errors' +import { parsePDF } from '../pdfParser' + +const pdfParseMock = vi.hoisted(() => vi.fn()) + +vi.mock('pdf-parse', () => ({ + PDFParse: pdfParseMock, +})) + +let tempDir: string + +function writeFile(name: string, content: string | Buffer) { + const filePath = path.join(tempDir, name) + fs.writeFileSync(filePath, content) + return filePath +} + +function expectAppError(error: unknown, code: string) { + expect(error).toBeInstanceOf(AppError) + expect((error as AppError).code).toBe(code) +} + +describe('parsePDF', () => { + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'p2cc-pdf-')) + pdfParseMock.mockReset() + }) + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }) + }) + + it('rejects missing, nonexistent, non-PDF, and empty paths before parsing', async () => { + await expect(parsePDF('')).rejects.toMatchObject({ code: ErrorCodes.PDF_NOT_FOUND }) + await expect(parsePDF(path.join(tempDir, 'missing.pdf'))).rejects.toMatchObject({ code: ErrorCodes.PDF_NOT_FOUND }) + + const textFile = writeFile('paper.txt', 'not a pdf') + await expect(parsePDF(textFile)).rejects.toMatchObject({ code: ErrorCodes.PDF_INVALID }) + + const emptyPdf = writeFile('empty.pdf', Buffer.alloc(0)) + await expect(parsePDF(emptyPdf)).rejects.toMatchObject({ code: ErrorCodes.PDF_INVALID }) + expect(pdfParseMock).not.toHaveBeenCalled() + }) + + it('wraps parser failures as invalid PDF errors', async () => { + const filePath = writeFile('broken.pdf', 'pdf bytes') + pdfParseMock.mockImplementation(function () { + return { + getText: vi.fn().mockRejectedValue(new Error('bad xref')), + destroy: vi.fn().mockResolvedValue(undefined), + } + }) + + await expect(parsePDF(filePath)).rejects.toMatchObject({ + code: ErrorCodes.PDF_INVALID, + detail: 'bad xref', + }) + }) + + it('rejects PDFs without enough extracted text', async () => { + const filePath = writeFile('scan.pdf', 'pdf bytes') + pdfParseMock.mockImplementation(function () { + return { + getText: vi.fn().mockResolvedValue({ text: 'too short', total: 1 }), + destroy: vi.fn().mockResolvedValue(undefined), + } + }) + + try { + await parsePDF(filePath) + throw new Error('Expected parsePDF to reject') + } catch (error) { + expectAppError(error, ErrorCodes.PDF_TEXT_EMPTY) + expect((error as AppError).detail).toContain('Extracted text length') + } + }) + + it('returns cleaned text and page count for valid PDFs', async () => { + const filePath = writeFile('paper.pdf', 'pdf bytes') + const destroy = vi.fn().mockResolvedValue(undefined) + const rawText = `Title\r\n\r\n\r\n\r\n${'word '.repeat(30)}\x00\x07 end` + pdfParseMock.mockImplementation(function () { + return { + getText: vi.fn().mockResolvedValue({ text: rawText, total: 3 }), + destroy, + } + }) + + const result = await parsePDF(filePath) + + expect(result.pageCount).toBe(3) + expect(result.text).not.toContain('\r') + expect(result.text).not.toContain('\x00') + expect(result.text).not.toContain(' ') + expect(result.text).toContain('Title') + expect(destroy).toHaveBeenCalledTimes(1) + }) + + it('ignores parser cleanup failures after extracting text', async () => { + const filePath = writeFile('paper.pdf', 'pdf bytes') + pdfParseMock.mockImplementation(function () { + return { + getText: vi.fn().mockResolvedValue({ text: 'valid '.repeat(30), total: 2 }), + destroy: vi.fn().mockRejectedValue(new Error('cleanup failed')), + } + }) + + await expect(parsePDF(filePath)).resolves.toMatchObject({ pageCount: 2 }) + }) +}) diff --git a/src/main/backend/__tests__/promptBuilder.test.ts b/src/main/backend/__tests__/promptBuilder.test.ts new file mode 100644 index 0000000..5f55cc5 --- /dev/null +++ b/src/main/backend/__tests__/promptBuilder.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { buildCodePrompt, buildCombinedAnalysisPrompt, buildSummaryPrompt } from '../promptBuilder' + +describe('promptBuilder', () => { + it('builds Chinese summary prompts by default with README and math rules', () => { + const prompt = buildSummaryPrompt('paper body') + + expect(prompt.system).toContain('Use Chinese') + expect(prompt.user).toContain('paper body') + expect(prompt.user).toContain('# 论文标题') + expect(prompt.user).toContain('## 一句话总结') + expect(prompt.user).toContain('GitHub Flavored Markdown tables') + expect(prompt.user).toContain('Do not include any demo code') + expect(prompt.user).toContain('$$') + }) + + it('builds English summary prompts when requested', () => { + const prompt = buildSummaryPrompt('paper body', 'en-US') + + expect(prompt.system).toContain('Use English') + expect(prompt.user).toContain('# Paper Title') + expect(prompt.user).toContain('## One-Sentence Summary') + expect(prompt.user).toContain('## Experimental Results') + }) + + it('builds code prompts with strict JSON schema and summary context', () => { + const prompt = buildCodePrompt('paper text', 'summary text', 'en-US') + + expect(prompt.system).toContain('Use English') + expect(prompt.user).toContain('paper text') + expect(prompt.user).toContain('summary text') + expect(prompt.user).toContain('Return ONLY strict JSON') + expect(prompt.user).toContain('"files"') + expect(prompt.user).toContain('"notApplicableReason"') + expect(prompt.user).toContain('Every file path MUST be relative') + }) + + it('builds combined prompts with required tagged protocol for non-code papers', () => { + const prompt = buildCombinedAnalysisPrompt('combined paper') + + expect(prompt.system).toContain('Use Chinese') + expect(prompt.user).toContain('combined paper') + expect(prompt.user).toContain('') + expect(prompt.user).toContain('') + expect(prompt.user).toContain('{"needed": true, "reason": "brief reason"}') + expect(prompt.user).toContain('If "needed" is false, stop immediately') + }) + + it('builds combined prompts with blueprint and exact code bundle constraints', () => { + const prompt = buildCombinedAnalysisPrompt('combined paper', 'en-US') + + expect(prompt.system).toContain('Use English') + expect(prompt.user).toContain('# Paper Title') + expect(prompt.user).toContain('') + expect(prompt.user).toContain('') + expect(prompt.user).toContain('') + expect(prompt.user).toContain('no more and no fewer') + expect(prompt.user).toContain('Avoid generic files') + }) +}) diff --git a/src/main/backend/__tests__/settingsStore.test.ts b/src/main/backend/__tests__/settingsStore.test.ts new file mode 100644 index 0000000..e0cbe7f --- /dev/null +++ b/src/main/backend/__tests__/settingsStore.test.ts @@ -0,0 +1,185 @@ +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getActiveSettings, PROVIDER_SETTINGS, saveSettingsPatch } from '../settingsStore' + +const getPathMock = vi.hoisted(() => vi.fn()) + +vi.mock('electron', () => ({ + app: { + getPath: getPathMock, + }, +})) + +let tempDir: string + +function configPath() { + return path.join(tempDir, 'config.json') +} + +function writeConfig(value: unknown) { + fs.writeFileSync(configPath(), JSON.stringify(value), 'utf-8') +} + +function readStoredConfig() { + return JSON.parse(fs.readFileSync(configPath(), 'utf-8')) +} + +describe('settingsStore', () => { + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'p2cc-settings-')) + getPathMock.mockReturnValue(tempDir) + }) + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }) + vi.clearAllMocks() + }) + + it('returns default active settings when no config exists', () => { + expect(getActiveSettings()).toEqual({ + apiKey: '', + provider: 'deepseek', + model: PROVIDER_SETTINGS.deepseek.defaultModel, + language: 'zh-CN', + }) + }) + + it('returns default active settings when stored config is not an object', () => { + writeConfig(['bad-config']) + + expect(getActiveSettings()).toEqual({ + apiKey: '', + provider: 'deepseek', + model: PROVIDER_SETTINGS.deepseek.defaultModel, + language: 'zh-CN', + }) + }) + + it('throws when stored config is invalid JSON', () => { + fs.writeFileSync(configPath(), '{bad}', 'utf-8') + + expect(() => getActiveSettings()).toThrow(SyntaxError) + }) + + it('normalizes legacy config with global apiKey and model', () => { + writeConfig({ + provider: 'kimi', + apiKey: 'legacy-key', + model: 'kimi-k2.5', + language: 'en-US', + }) + + expect(getActiveSettings()).toEqual({ + apiKey: 'legacy-key', + provider: 'kimi', + model: 'kimi-k2.5', + language: 'en-US', + }) + }) + + it('falls back invalid stored provider, language, and model safely', () => { + writeConfig({ + provider: 'unknown-provider', + language: 'fr-FR', + providers: { + deepseek: { apiKey: 'deep-key', model: 'invalid-model' }, + }, + }) + + expect(getActiveSettings()).toEqual({ + apiKey: 'deep-key', + provider: 'deepseek', + model: PROVIDER_SETTINGS.deepseek.defaultModel, + language: 'zh-CN', + }) + }) + + it('ignores malformed provider records and non-string API keys', () => { + writeConfig({ + provider: 'kimi', + language: 'en-US', + providers: { + deepseek: 'bad-record', + kimi: { apiKey: 123, model: 'not-a-model' }, + }, + }) + + expect(getActiveSettings()).toEqual({ + apiKey: '', + provider: 'kimi', + model: PROVIDER_SETTINGS.kimi.defaultModel, + language: 'en-US', + }) + }) + + it('keeps legacy global values only when the saved provider and model are valid', () => { + writeConfig({ + provider: 'glm', + apiKey: 'legacy-glm-key', + model: 'glm-5-turbo', + language: 'en-US', + }) + + expect(getActiveSettings()).toEqual({ + apiKey: 'legacy-glm-key', + provider: 'glm', + model: 'glm-5-turbo', + language: 'en-US', + }) + }) + + it('saves active provider settings and preserves provider-specific keys', () => { + expect(saveSettingsPatch({ apiKey: 'deep-key', language: 'en-US' })).toEqual({ + apiKey: 'deep-key', + provider: 'deepseek', + model: PROVIDER_SETTINGS.deepseek.defaultModel, + language: 'en-US', + }) + + expect(saveSettingsPatch({ provider: 'kimi' })).toEqual({ + apiKey: '', + provider: 'kimi', + model: PROVIDER_SETTINGS.kimi.defaultModel, + language: 'en-US', + }) + + expect(saveSettingsPatch({ apiKey: 'kimi-key', model: 'kimi-k2.5' })).toEqual({ + apiKey: 'kimi-key', + provider: 'kimi', + model: 'kimi-k2.5', + language: 'en-US', + }) + + expect(saveSettingsPatch({ provider: 'deepseek' })).toEqual({ + apiKey: 'deep-key', + provider: 'deepseek', + model: PROVIDER_SETTINGS.deepseek.defaultModel, + language: 'en-US', + }) + }) + + it('resets unsupported model patches to the selected provider default', () => { + writeConfig({ provider: 'glm', language: 'zh-CN', providers: {} }) + + expect(saveSettingsPatch({ model: 'not-a-model' })).toMatchObject({ + provider: 'glm', + model: PROVIDER_SETTINGS.glm.defaultModel, + }) + }) + + it('throws when saving an unsupported provider patch', () => { + expect(() => saveSettingsPatch({ provider: 'bad-provider' })).toThrow('Unsupported provider') + expect(fs.existsSync(configPath())).toBe(false) + }) + + it('writes normalized config with all known providers', () => { + saveSettingsPatch({ apiKey: 'deep-key' }) + + const stored = readStoredConfig() + expect(stored.provider).toBe('deepseek') + expect(Object.keys(stored.providers).sort()).toEqual(Object.keys(PROVIDER_SETTINGS).sort()) + expect(stored.providers.deepseek.apiKey).toBe('deep-key') + }) +}) diff --git a/src/renderer/__tests__/App.analysis.test.tsx b/src/renderer/__tests__/App.analysis.test.tsx index 14f40b8..729f7f5 100644 --- a/src/renderer/__tests__/App.analysis.test.tsx +++ b/src/renderer/__tests__/App.analysis.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { act, cleanup, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import App from '../App' @@ -104,10 +104,17 @@ describe('App analysis flow', () => { expect(screen.getByText(/Status: Parsing/)).toBeInTheDocument() act(() => { + electronAPI.emitProgress({ stage: 'parsing', message: 'Reading PDF...' }) + electronAPI.emitProgress({ stage: 'parsing', message: 'Reading PDF...' }) electronAPI.emitProgress({ stage: 'summarizing', message: 'Calling model...' }) + electronAPI.emitProgress({ stage: 'generating_code', message: 'Checking code scope...' }) + electronAPI.emitProgress({ stage: 'done', message: 'Analysis complete' }) electronAPI.emitSummary('partial summary') }) expect(screen.getByText(/Status: Calling model/)).toBeInTheDocument() + expect(screen.getAllByText('Reading PDF...')).toHaveLength(1) + expect(screen.getByText('Checking code scope...')).toBeInTheDocument() + expect(screen.getByText('Analysis complete')).toBeInTheDocument() expect(screen.getByText('partial summary')).toBeInTheDocument() await act(async () => { @@ -124,6 +131,44 @@ describe('App analysis flow', () => { expect(screen.getByText(/Tokens:/)).toHaveTextContent('15') }) + it('accepts a dropped PDF and resets the initial summary checklist', async () => { + render() + await screen.findByText('API Key configured') + const dropZone = screen.getByText('Drop a research paper here').parentElement!.parentElement! + const pdf = new File(['pdf'], 'dropped.pdf', { type: 'application/pdf' }) + Object.defineProperty(pdf, 'path', { value: 'C:\\papers\\dropped.pdf' }) + + fireEvent.drop(dropZone, { dataTransfer: { files: [pdf] } }) + + expect(await screen.findByText('dropped.pdf')).toBeInTheDocument() + expect(screen.getByText('All set! Click "Start Analysis" above to begin.')).toBeInTheDocument() + }) + + it('keeps the initial PDF state when file selection is cancelled', async () => { + const user = userEvent.setup() + electronAPI.selectPDF.mockResolvedValue(null) + + render() + await screen.findByText('API Key configured') + await user.click(screen.getByRole('button', { name: /Select PDF/ })) + + expect(screen.queryByText('paper.pdf')).not.toBeInTheDocument() + expect(screen.getByText('Attach a research paper')).toBeInTheDocument() + }) + + it('keeps the default language when settings do not include one', async () => { + electronAPI.getSettings.mockResolvedValue({ + apiKey: 'key', + provider: 'deepseek', + model: 'deepseek-v4-flash', + }) + + render() + + expect(await screen.findByText('API Key 已配置')).toBeInTheDocument() + expect(screen.getByRole('button', { name: '选择 PDF' })).toBeInTheDocument() + }) + it('calls cancelAnalysis from the analyzing primary action', async () => { const user = userEvent.setup() const deferred = createDeferred() @@ -142,7 +187,8 @@ describe('App analysis flow', () => { const user = userEvent.setup() electronAPI.analyzePaper.mockResolvedValue({ ok: false, - error: { code: 'API_RATE_LIMITED', message: 'API rate limit exceeded' }, + error: { code: 'API_RATE_LIMITED', message: 'API rate limit exceeded', detail: 'Retry after 60 seconds' }, + usage: { promptTokens: 7, completionTokens: 3, totalTokens: 10 }, }) render() @@ -151,6 +197,94 @@ describe('App analysis flow', () => { await user.click(screen.getByRole('button', { name: /Start Analysis/ })) await waitFor(() => expect(screen.getByText(/Status: Failed/)).toBeInTheDocument()) - expect(screen.getByText('API rate limit exceeded')).toBeInTheDocument() + expect(screen.getByText(/API rate limit exceeded/)).toBeInTheDocument() + expect(screen.getByText(/Retry after 60 seconds/)).toBeInTheDocument() + expect(screen.getByText(/Tokens:/)).toHaveTextContent('10') + }) + + it('shows failed responses without optional detail or token usage', async () => { + const user = userEvent.setup() + electronAPI.analyzePaper.mockResolvedValue({ + ok: false, + error: { code: 'API_RESPONSE_INVALID', message: 'Invalid model response' }, + }) + + render() + await screen.findByText('API Key configured') + await user.click(screen.getByRole('button', { name: /Select PDF/ })) + await user.click(screen.getByRole('button', { name: /Start Analysis/ })) + + await waitFor(() => expect(screen.getByText(/Status: Failed/)).toBeInTheDocument()) + expect(screen.getByText('Invalid model response')).toBeInTheDocument() + expect(screen.getByText(/No token usage yet/)).toBeInTheDocument() + }) + + it('sets error status when analyzePaper rejects', async () => { + const user = userEvent.setup() + electronAPI.analyzePaper.mockRejectedValue(new Error('IPC failed')) + + render() + await screen.findByText('API Key configured') + await user.click(screen.getByRole('button', { name: /Select PDF/ })) + await user.click(screen.getByRole('button', { name: /Start Analysis/ })) + + await waitFor(() => expect(screen.getByText(/Status: Failed/)).toBeInTheDocument()) + expect(screen.getByText('IPC failed')).toBeInTheDocument() + }) + + it('handles generated-code completion dialog and download errors', async () => { + const user = userEvent.setup() + electronAPI.analyzePaper.mockResolvedValue({ + ok: true, + result: { summary: 'Generated code summary', hasCoreCode: true }, + }) + electronAPI.downloadCoreCode.mockResolvedValue({ ok: false, error: 'No code cache' }) + + render() + await screen.findByText('API Key configured') + await user.click(screen.getByRole('button', { name: /Select PDF/ })) + await user.click(screen.getByRole('button', { name: /Start Analysis/ })) + + expect(await screen.findByText('Execution complete')).toBeInTheDocument() + expect(screen.getByText(/componentized code project have been generated/)).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Close' })) + await waitFor(() => expect(screen.queryByText('Execution complete')).not.toBeInTheDocument()) + + await user.click(screen.getByRole('button', { name: /Download Core Code/ })) + expect(electronAPI.downloadCoreCode).toHaveBeenCalledTimes(1) + expect(await screen.findByText('No code cache')).toBeInTheDocument() + }) + + it('downloads generated core code without showing an error when export succeeds', async () => { + const user = userEvent.setup() + electronAPI.analyzePaper.mockResolvedValue({ + ok: true, + result: { summary: 'Generated code summary', hasCoreCode: true }, + }) + electronAPI.downloadCoreCode.mockResolvedValue({ ok: true, path: 'C:\\exports\\paper-core-code' }) + + render() + await screen.findByText('API Key configured') + await user.click(screen.getByRole('button', { name: /Select PDF/ })) + await user.click(screen.getByRole('button', { name: /Start Analysis/ })) + await user.click(await screen.findByRole('button', { name: 'Close' })) + + await user.click(screen.getByRole('button', { name: /Download Core Code/ })) + + expect(electronAPI.downloadCoreCode).toHaveBeenCalledTimes(1) + expect(screen.queryByText(/No code cache/)).not.toBeInTheDocument() + }) + + it('persists language changes from the header controls', async () => { + const user = userEvent.setup() + + render() + await screen.findByText('API Key configured') + + await user.click(screen.getByRole('button', { name: '中文' })) + expect(electronAPI.saveSettings).toHaveBeenCalledWith({ language: 'zh-CN' }) + + await user.click(screen.getByRole('button', { name: 'EN' })) + expect(electronAPI.saveSettings).toHaveBeenCalledWith({ language: 'en-US' }) }) }) diff --git a/src/renderer/__tests__/App.resizable.test.tsx b/src/renderer/__tests__/App.resizable.test.tsx index 5a9e787..62e916a 100644 --- a/src/renderer/__tests__/App.resizable.test.tsx +++ b/src/renderer/__tests__/App.resizable.test.tsx @@ -61,6 +61,26 @@ describe('App resizable layout', () => { }) }) + it('clamps stored panel sizes to the available viewport on mount', async () => { + localStorage.setItem('paper2corecode.sidebarWidth', '420') + localStorage.setItem('paper2corecode.uploadHeight', '420') + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(700) + vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(500) + + render() + await screen.findByText('API Key configured') + + const sidebarHandle = screen.getByLabelText('Resize sidebar') + const appBody = sidebarHandle.parentElement as HTMLElement + const uploadHandle = screen.getByLabelText('Resize upload and summary panels') + const mainContent = uploadHandle.parentElement as HTMLElement + + await waitFor(() => { + expect(appBody.style.gridTemplateColumns).toContain('220px') + expect(mainContent.style.gridTemplateRows).toContain('230px') + }) + }) + it('updates and persists sidebar width during drag', async () => { render() await screen.findByText('API Key configured') @@ -93,6 +113,30 @@ describe('App resizable layout', () => { expect(document.body).not.toHaveClass('is-resizing-layout') }) + it('handles pointer cancellation and storage failures during resizing', async () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('quota exceeded') + }) + render() + await screen.findByText('API Key configured') + + const sidebarHandle = screen.getByLabelText('Resize sidebar') + const appBody = sidebarHandle.parentElement as HTMLElement + fireEvent.pointerDown(sidebarHandle, { clientX: 280 }) + fireEvent.pointerCancel(window, { clientX: 320 }) + + expect(appBody.style.gridTemplateColumns).toContain('320px') + expect(document.body).not.toHaveClass('is-resizing-layout') + + const uploadHandle = screen.getByLabelText('Resize upload and summary panels') + const mainContent = uploadHandle.parentElement as HTMLElement + fireEvent.pointerDown(uploadHandle, { clientY: 260 }) + fireEvent.pointerCancel(window, { clientY: 300 }) + + expect(mainContent.style.gridTemplateRows).toContain('300px') + expect(document.body).not.toHaveClass('is-resizing-layout') + }) + it('clamps dragged sizes to configured bounds', async () => { render() await screen.findByText('API Key configured') diff --git a/src/renderer/__tests__/i18n.test.ts b/src/renderer/__tests__/i18n.test.ts index 03cf57f..d9e8394 100644 --- a/src/renderer/__tests__/i18n.test.ts +++ b/src/renderer/__tests__/i18n.test.ts @@ -25,4 +25,10 @@ describe('i18n coverage for analysis metrics', () => { expect(t(language, key).trim()).not.toBe('') } }) + + it.each(languages)('falls back to the requested key for missing or non-string values in %s', (language) => { + expect(t(language, 'missing.key')).toBe('missing.key') + expect(t(language, 'result.missing')).toBe('result.missing') + expect(t(language, 'result')).toBe('result') + }) }) diff --git a/src/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx index a1ebd23..fb8a8d1 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -125,7 +125,6 @@ export default function SettingsPanel({ const [model, setModel] = useState(PROVIDERS[0].models[0].value) const [configured, setConfigured] = useState(false) const [editing, setEditing] = useState(false) - const [saved, setSaved] = useState(false) const [loading, setLoading] = useState(true) const applySettings = (s: { apiKey: string; provider: string; model: string }) => { @@ -156,7 +155,7 @@ export default function SettingsPanel({ } const handleProviderChange = async (nextProvider: string) => { - const providerConfig = PROVIDERS.find((item) => item.value === nextProvider) || PROVIDERS[0] + const providerConfig = PROVIDERS.find((item) => item.value === nextProvider)! const nextModel = providerConfig.models[0].value setProvider(providerConfig.value) @@ -174,11 +173,9 @@ export default function SettingsPanel({ const handleSave = async () => { const hasKey = apiKey.trim().length > 0 await saveConfig({ provider, apiKey }) - setSaved(true) setConfigured(hasKey) setEditing(!hasKey) onApiKeyConfiguredChange(hasKey) - setTimeout(() => setSaved(false), 2000) } const handleChange = () => { @@ -194,7 +191,7 @@ export default function SettingsPanel({ if (loading) return null - const currentProvider = PROVIDERS.find((item) => item.value === provider) || PROVIDERS[0] + const currentProvider = PROVIDERS.find((item) => item.value === provider)! return (
@@ -249,7 +246,7 @@ export default function SettingsPanel({ marginTop: 2, }} > - {configured ? t(language, 'settings.change') : t(language, 'settings.configure')} + {t(language, 'settings.change')}
) : ( @@ -286,11 +283,11 @@ export default function SettingsPanel({ disabled={!apiKey.trim()} style={{ ...btnStyle, - background: saved ? 'var(--color-success)' : 'var(--color-accent)', + background: 'var(--color-accent)', flex: 1, }} > - {saved ? t(language, 'settings.saved') : t(language, 'settings.save')} + {t(language, 'settings.save')} {configured && (