gongxiangp2/components/admin/AiModelManagementPanel.test.tsx

771 lines
59 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import AiModelManagementPanel from './AiModelManagementPanel'
import type { AiModelAdminState, AiModelCatalogEntry, AiModelVerificationSummary } from '@/lib/ai-model-types'
function response(body: unknown, status = 200) { return { ok: status < 400, status, json: async () => body } as Response }
const initial = (): AiModelAdminState => ({ providers: [], revision: 0, defaults: { analysis: null, copy: null, image: null }, encryptionConfigured: true, legacyAvailable: true })
const verification = (status: AiModelVerificationSummary['status'] = 'untested') => ({ analysis: { status, id: null, checkedAt: null, requestId: null, message: null }, copy: { status, id: null, checkedAt: null, requestId: null, message: null }, image: { status, id: null, checkedAt: null, requestId: null, message: null } })
const configured = (): AiModelAdminState => ({
...initial(), revision: 2, legacyAvailable: false,
providers: [{ id: 'provider-1', name: '现有上游', baseUrl: 'https://api.example.com', enabled: true, credentialSource: 'environment', hasApiKey: true, revision: 3, syncedAt: null, models: [
{ key: 'model-text', id: 'gemini-2.5-flash', enabled: true, stages: ['analysis', 'copy'], present: true, verification: verification('passed'), capabilities: { kind: 'vision', allowedStages: ['analysis', 'copy'], source: 'verified', reason: '支持图片理解与文字输出' } },
{ key: 'model-image', id: 'nano-banana-2', enabled: true, stages: ['image'], present: true, verification: verification('passed'), capabilities: { kind: 'image', allowedStages: ['image'], source: 'verified', reason: '支持图片生成' } },
{ key: 'model-new', id: 'new-vision-model', enabled: false, stages: [], present: true, verification: verification(), capabilities: { kind: 'vision', allowedStages: ['analysis', 'copy'], source: 'upstream', reason: '上游声明图片输入、文字输出' } },
{ key: 'model-missing', id: 'removed-model', enabled: false, stages: [], present: false, verification: verification(), capabilities: { kind: 'unknown', allowedStages: [], source: 'unknown', reason: '目录未提供能力信息,待核验' } },
] }],
defaults: { analysis: 'model-text', copy: 'model-text', image: 'model-image' },
})
afterEach(() => jest.restoreAllMocks())
beforeEach(() => sessionStorage.clear())
test('展开详情显示服务端实际请求参数和参考图传法,不会调用或修改模型', async () => {
const next = configured(), model = next.providers[0].models[1]
model.capabilities.protocols = { image: { id: 'async-image-task', path: '/extensions/v1/tasks', inputField: 'file_urls', referenceTransport: 'json-inline', maxPromptLength: 32000, requestParameters: { size: 'auto', resolution: '1K', aspect_ratio: '3:4' } } }
global.fetch = jest.fn(async () => response(next)) as typeof fetch
render(<AiModelManagementPanel />)
const row = await screen.findByLabelText('nano-banana-2 · 现有上游', { selector: 'div' })
fireEvent.click(within(row).getByRole('button', { name: 'nano-banana-2 · 现有上游 图片生成测试详情' }))
const parameters = within(row).getByLabelText('nano-banana-2 当前请求参数')
expect(within(parameters).getByText('尺寸')).toBeVisible()
expect(within(parameters).getByText('auto')).toBeVisible()
expect(within(parameters).getByText('1K')).toBeVisible()
expect(within(parameters).getByText('3:4')).toBeVisible()
expect(within(row).getByText('提示词:最多 32000 字')).toBeVisible()
expect(within(row).getByText('参考图参数:file_urls(内联图片)')).toBeVisible()
expect((global.fetch as jest.Mock).mock.calls.every(([, init]) => !init?.method)).toBe(true)
})
test('没有指定的异步图片参数明确显示为上游默认,不伪造尺寸和画质', async () => {
const next = configured(), model = next.providers[0].models[1]
model.capabilities.protocols = { image: { id: 'async-image-task', path: '/extensions/v1/tasks', requestParameters: {} } }
global.fetch = jest.fn(async () => response(next)) as typeof fetch
render(<AiModelManagementPanel />)
const row = await screen.findByLabelText('nano-banana-2 · 现有上游', { selector: 'div' })
fireEvent.click(within(row).getByRole('button', { name: 'nano-banana-2 · 现有上游 图片生成测试详情' }))
expect(within(row).getByText('未指定尺寸、比例和画质,使用上游默认值。')).toBeVisible()
expect(screen.queryByText('1024x1024')).not.toBeInTheDocument()
})
test.each(['untested', 'passed'] as const)('参考图数量以实测证据为准,%s 不把平台测试上限当成已支持', async status => {
const next = configured(), model = next.providers[0].models[1]
model.capabilities.protocols = { image: { id: 'openai-image-generate', path: '/v1/images/generations', maxImages: 8, referenceLimitSource: 'verification' } }
model.verification.image = { ...model.verification.image, status, ...(status === 'passed' ? { verifiedReferenceImageCount: 4 } : {}) }
global.fetch = jest.fn(async () => response(next)) as typeof fetch
render(<AiModelManagementPanel />)
const row = await screen.findByLabelText('nano-banana-2 · 现有上游', { selector: 'div' })
fireEvent.click(within(row).getByRole('button', { name: 'nano-banana-2 · 现有上游 图片生成测试详情' }))
expect(within(row).getByText(status === 'passed' ? '参考图:已逐张验证 4 张,本平台最多 4 张' : '参考图:数量待测试(本平台最多测试 8 张)')).toBeVisible()
})
test('数量选择、费用确认和实际测试请求使用同一个参考图数量', async () => {
const next = configured(), model = next.providers[0].models[1]
model.capabilities.protocols = { image: { id: 'openai-image-generate', path: '/v1/images/generations', maxImages: 8, referenceLimitSource: 'verification' } }
model.verification.image.verifiedReferenceImageCount = 4
global.fetch = jest.fn(async () => response(next)) as typeof fetch
render(<AiModelManagementPanel />)
const row = await screen.findByLabelText('nano-banana-2 · 现有上游', { selector: 'div' })
fireEvent.click(within(row).getByRole('button', { name: 'nano-banana-2 · 现有上游 图片生成测试详情' }))
fireEvent.change(within(row).getByRole('combobox', { name: 'nano-banana-2 · 现有上游 本次测试参考图数量' }), { target: { value: '2' } })
fireEvent.click(within(row).getByRole('button', { name: '测试 nano-banana-2 · 现有上游 的图片生成' }))
const dialog = await screen.findByRole('dialog')
expect(dialog).toHaveTextContent('逐张校验 2 张参考图,输出一张拼图。')
fireEvent.click(within(dialog).getByRole('button', { name: '开始测试' }))
await waitFor(() => expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/verify'))).toHaveLength(1))
expect(JSON.parse((global.fetch as jest.Mock).mock.calls.find(([url]) => url.endsWith('/verify'))[1].body)).toMatchObject({ stage: 'image', referenceImageCount: 2 })
})
function jointConfiguration() {
const next = configured()
next.providers[0].models[2].capabilities.combinedTextTest = true
return next
}
test.each(['failed', 'unknown'] as const)('生图 %s 显示明确状态,具体原因可展开,未知结果不能重试', async status => {
const next = configured(), model = next.providers[0].models[1]
model.enabled = false; model.stages = []
model.verification.image = { status, id: 'verification-image', checkedAt: null, requestId: 'request-image', message: '上游返回异常响应(502),请核查请求结果;勿重复提交', canReconcile: status === 'unknown' }
global.fetch = jest.fn(async () => response(next)) as typeof fetch
render(<AiModelManagementPanel />)
const row = await screen.findByLabelText('nano-banana-2 · 现有上游', { selector: 'div' })
expect(within(row).getByRole('status')).toHaveTextContent(status === 'failed' ? '测试失败' : '待核查')
expect(within(row).getByText(model.verification.image.message!, { selector: 'p' })).not.toBeVisible()
fireEvent.click(within(row).getByRole('button', { name: 'nano-banana-2 · 现有上游 图片生成测试详情' }))
expect(within(row).getByText(model.verification.image.message!, { selector: 'p' })).toBeVisible()
expect(within(row).getByRole('checkbox', { name: 'nano-banana-2 · 现有上游 用于图片生成' })).toBeDisabled()
if (status === 'unknown') expect(within(row).getByRole('button', { name: '核查 nano-banana-2 · 现有上游 的图片生成结果' })).toBeEnabled()
expect((global.fetch as jest.Mock).mock.calls.every(([, init]) => !init?.method)).toBe(true)
})
test('浏览器没有 randomUUID 时仍能提交一个有效 UUID 的联合测试', async () => {
const original = Object.getOwnPropertyDescriptor(globalThis.crypto, 'randomUUID')
Object.defineProperty(globalThis.crypto, 'randomUUID', { configurable: true, value: undefined })
try {
const next = jointConfiguration()
global.fetch = jest.fn(async () => response(next)) as typeof fetch
render(<AiModelManagementPanel />)
fireEvent.click(await screen.findByRole('button', { name: '测试 new-vision-model · 现有上游 的分析与文案' }))
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: '开始测试' }))
await waitFor(() => expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/verify'))).toHaveLength(1))
const call = (global.fetch as jest.Mock).mock.calls.find(([url]) => url.endsWith('/verify'))
expect(JSON.parse(call[1].body)).toMatchObject({ stage: 'analysis-copy', idempotencyKey: expect.stringMatching(/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/) })
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
} finally { if (original) Object.defineProperty(globalThis.crypto, 'randomUUID', original); else delete (globalThis.crypto as any).randomUUID }
})
test('文字联合测试的原结果可只读取回,核查凭证在详情内,已固定请求号不能替换', async () => {
const next = jointConfiguration(), model = next.providers[0].models[2]
const requestId = 'a1111111-1111-4111-8111-111111111111'
model.capabilities.protocols = { analysis: { id: 'openai-chat', path: '/v1/chat/completions' }, copy: { id: 'openai-chat', path: '/v1/chat/completions' } }
for (const stage of ['analysis', 'copy'] as const) model.verification[stage] = { status: 'unknown', id: `text-${stage}`, requestId, checkedAt: null, message: '原结果待核查', purpose: 'analysis-copy', canResume: true, canReconcile: true }
global.fetch = jest.fn(async () => response(next)) as typeof fetch
render(<AiModelManagementPanel />)
const row = await screen.findByLabelText('new-vision-model · 现有上游', { selector: 'div' })
fireEvent.click(within(row).getByRole('button', { name: '查询 new-vision-model · 现有上游 的分析与文案结果' }))
await waitFor(() => expect((global.fetch as jest.Mock).mock.calls.some(([url]) => url.endsWith('/refresh'))).toBe(true))
expect((global.fetch as jest.Mock).mock.calls.some(([url]) => url.endsWith('/verify'))).toBe(false)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
fireEvent.click(within(row).getByRole('button', { name: 'new-vision-model · 现有上游 分析与文案测试详情' }))
fireEvent.click(within(row).getByRole('button', { name: '核查 new-vision-model 的原请求凭证' }))
const field = screen.getByRole('textbox', { name: '客户请求编号' })
expect(field).toHaveValue(requestId)
expect(field).toHaveAttribute('readonly')
expect(screen.getByText(/未留存不代表执行失败/)).toBeVisible()
})
test.each([true, false])('展开联合结果时只合并相同请求号,保留不同请求的来源:shared=%s', async shared => {
const next = jointConfiguration(), model = next.providers[0].models[2]
const first = 'a1111111-1111-4111-8111-111111111111', second = shared ? first : 'b1111111-1111-4111-8111-111111111111'
model.verification.analysis = { ...model.verification.analysis, status: 'passed', requestId: first, checkedAt: '2026-10-16T02:00:00Z' }
model.verification.copy = { ...model.verification.copy, status: 'passed', requestId: second, checkedAt: '2026-10-16T02:00:00Z' }
global.fetch = jest.fn(async () => response(next)) as typeof fetch
render(<AiModelManagementPanel />)
const row = await screen.findByLabelText('new-vision-model · 现有上游', { selector: 'div' })
fireEvent.click(within(row).getByRole('button', { name: 'new-vision-model · 现有上游 分析与文案测试详情' }))
expect(within(row).getAllByText(`请求编号:${first}`)).toHaveLength(1)
if (!shared) expect(within(row).getByText(`请求编号:${second}`)).toBeVisible()
})
test('编号生成失败不会提交、刷新或伪造未知状态,恢复后可正常重试', async () => {
const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'crypto')!, original = globalThis.crypto
Object.defineProperty(globalThis, 'crypto', { configurable: true, value: {} })
try {
const next = jointConfiguration()
global.fetch = jest.fn(async () => response(next)) as typeof fetch
render(<AiModelManagementPanel />)
const button = await screen.findByRole('button', { name: '测试 new-vision-model · 现有上游 的分析与文案' })
fireEvent.click(button)
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: '开始测试' }))
expect(await screen.findByRole('alert')).toHaveTextContent('当前浏览器无法生成请求编号,请更新浏览器后再试。测试尚未提交。')
expect(global.fetch).toHaveBeenCalledTimes(1)
expect(screen.queryByRole('button', { name: /核查 new-vision-model/ })).not.toBeInTheDocument()
expect(button).toBeEnabled()
Object.defineProperty(globalThis, 'crypto', { configurable: true, value: original })
fireEvent.click(button)
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: '开始测试' }))
await waitFor(() => expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/verify'))).toHaveLength(1))
} finally { Object.defineProperty(globalThis, 'crypto', descriptor) }
})
test('多模态模型一次确认、一次请求,通过后两项可选但不会自动开放', async () => {
const next = jointConfiguration(), passed = jointConfiguration()
for (const stage of ['analysis', 'copy'] as const) passed.providers[0].models[2].verification[stage] = { status: 'passed', id: `joint-${stage}`, checkedAt: null, requestId: 'one-upstream-request', message: '该用途已通过', purpose: 'analysis-copy' }
global.fetch = jest.fn(async (url: string) => response(url.endsWith('/verify') ? passed : next)) as typeof fetch
render(<AiModelManagementPanel />)
const select = await screen.findByLabelText('new-vision-model · 现有上游 测试用途')
expect(select).toHaveValue('analysis-copy')
expect(within(select).getAllByRole('option')).toHaveLength(1)
fireEvent.click(screen.getByRole('button', { name: '测试 new-vision-model · 现有上游 的分析与文案' }))
const dialog = await screen.findByRole('dialog', { name: '测试分析与文案' })
expect(dialog).toHaveTextContent('一次调用,分别验证读图和文案能力。')
fireEvent.click(within(dialog).getByRole('button', { name: '开始测试' }))
await screen.findByText('分析:已通过')
expect(screen.getByText('文案:已通过')).toBeInTheDocument()
expect(screen.getByRole('checkbox', { name: 'new-vision-model · 现有上游 用于资料分析' })).toBeEnabled()
expect(screen.getByRole('checkbox', { name: 'new-vision-model · 现有上游 用于卖点文案' })).toBeEnabled()
expect(screen.getByRole('checkbox', { name: '开放 new-vision-model · 现有上游 给用户' })).not.toBeChecked()
const calls = (global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/verify'))
expect(calls).toHaveLength(1)
expect(JSON.parse(calls[0][1].body)).toMatchObject({ stage: 'analysis-copy', revision: 3 })
})
test.each(['analysis', 'copy'] as const)('联合测试只有 %s 通过时,可单独勾选并保存该用途', async passedStage => {
const next = jointConfiguration(), partial = jointConfiguration()
for (const stage of ['analysis', 'copy'] as const) partial.providers[0].models[2].verification[stage] = { status: stage === passedStage ? 'passed' : 'failed', id: `joint-${stage}`, checkedAt: null, requestId: 'partial-request', message: '独立判定', purpose: 'analysis-copy' }
global.fetch = jest.fn(async (url: string) => response(url.endsWith('/verify') || url.endsWith('/configuration') ? partial : next)) as typeof fetch
render(<AiModelManagementPanel />)
fireEvent.click(await screen.findByRole('button', { name: '测试 new-vision-model · 现有上游 的分析与文案' }))
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: '开始测试' }))
const passedName = passedStage === 'analysis' ? '资料分析' : '卖点文案', failedName = passedStage === 'analysis' ? '卖点文案' : '资料分析'
const checkbox = await screen.findByRole('checkbox', { name: `new-vision-model · 现有上游 用于${passedName}` })
await waitFor(() => expect(checkbox).toBeEnabled())
expect(screen.getByRole('checkbox', { name: `new-vision-model · 现有上游 用于${failedName}` })).toBeDisabled()
fireEvent.click(checkbox)
fireEvent.click(screen.getByRole('checkbox', { name: '开放 new-vision-model · 现有上游 给用户' }))
fireEvent.click(screen.getByRole('button', { name: '保存配置' }))
await screen.findByText('模型配置已保存')
const saved = (global.fetch as jest.Mock).mock.calls.find(([url]) => url.endsWith('/configuration'))
expect(JSON.parse(saved[1].body).providers[0].models.find((value: any) => value.key === 'model-new')).toEqual({ key: 'model-new', enabled: true, stages: [passedStage] })
})
test('刷新恢复联合测试提交,两项匹配同一请求编号后才解除暂停', async () => {
const key = '11111111-1111-4111-8111-111111111111', next = jointConfiguration()
sessionStorage.setItem('ai-model-pending-submissions-v1', JSON.stringify([['model-new', { stage: 'analysis-copy', requestId: key, previousId: null }]]))
next.providers[0].models[2].verification.analysis.submissionKey = key
global.fetch = jest.fn(async () => response(next)) as typeof fetch
render(<AiModelManagementPanel />)
expect(await screen.findByRole('button', { name: '核查 new-vision-model · 现有上游 的测试提交' })).toBeEnabled()
expect(screen.getByRole('checkbox', { name: 'new-vision-model · 现有上游 用于卖点文案' })).toBeDisabled()
expect((global.fetch as jest.Mock).mock.calls.every(([, init]) => !init?.method)).toBe(true)
})
test('开放范围有未保存修改时仍可测试尚未开放的模型,结果保留原草稿', async () => {
const next = configured()
const passed = configured()
passed.providers[0].models[2].verification.analysis = { status: 'passed', id: 'verification-draft', checkedAt: '2026-10-14T01:00:00Z', requestId: 'request-draft', message: '测试已通过' }
global.fetch = jest.fn(async (url: string) => response(url.endsWith('/verify') ? passed : next)) as typeof fetch
render(<AiModelManagementPanel />)
fireEvent.click(await screen.findByRole('checkbox', { name: '开放 nano-banana-2 · 现有上游 给用户' }))
const testButton = screen.getByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' })
expect(testButton).toBeEnabled()
fireEvent.click(testButton)
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: '开始测试' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'new-vision-model · 现有上游 用于资料分析' })).toBeEnabled())
expect(screen.getByRole('checkbox', { name: '开放 nano-banana-2 · 现有上游 给用户' })).not.toBeChecked()
expect(screen.getByLabelText('图片生成默认模型', { exact: false })).toHaveValue('')
expect(screen.getByRole('button', { name: '保存配置' })).toBeEnabled()
expect(screen.getByRole('checkbox', { name: '开放 new-vision-model · 现有上游 给用户' })).not.toBeChecked()
expect((global.fetch as jest.Mock).mock.calls.filter(([, init]) => init?.method === 'PUT')).toHaveLength(0)
})
test('显式迁入当前连接并同步上游目录,使用服务器版本号', async () => {
const next = configured()
global.fetch = jest.fn(async (url: string, init: RequestInit = {}) => {
if (url.endsWith('/import-environment')) return response(next)
if (url.endsWith('/sync')) return response({ ...next, providers: next.providers.map((provider) => ({ ...provider, revision: 4, syncedAt: '2026-10-12T02:00:00Z' })) })
return response(initial())
}) as typeof fetch
render(<AiModelManagementPanel />)
fireEvent.click(await screen.findByRole('button', { name: '迁入当前连接' }))
expect(await screen.findByText('gemini-2.5-flash')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '迁入当前连接' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '同步 现有上游 的模型目录' }))
await screen.findByText('模型目录已同步')
const call = (global.fetch as jest.Mock).mock.calls.find(([url]) => url.endsWith('/sync'))
expect(call[1].method).toBe('POST')
expect(JSON.parse(call[1].body)).toEqual({ revision: 3 })
expect((global.fetch as jest.Mock).mock.calls.every(([url]) => !url.includes('generate') && !url.includes('chat') && !url.endsWith('/verify'))).toBe(true)
})
test('搜索与状态筛选仅影响显示,配置保存包含全部模型和各版本号', async () => {
const next = configured()
next.providers[0].models[2].verification.analysis.status = 'passed'
global.fetch = jest.fn(async (_url: string, init: RequestInit = {}) => response(init.method === 'PUT' ? { ...next, revision: 3 } : next)) as typeof fetch
render(<AiModelManagementPanel />)
await screen.findByText('new-vision-model')
fireEvent.change(screen.getByLabelText('搜索模型或上游'), { target: { value: 'new-vision' } })
expect(screen.queryByText('removed-model')).not.toBeInTheDocument()
const model = screen.getByLabelText('new-vision-model · 现有上游', { selector: 'div' })
expect(within(model).getByRole('checkbox', { name: '开放 new-vision-model · 现有上游 给用户' })).toBeDisabled()
expect(within(model).queryByRole('checkbox', { name: /接口兼容/ })).not.toBeInTheDocument()
fireEvent.click(within(model).getByRole('checkbox', { name: 'new-vision-model · 现有上游 用于资料分析' }))
fireEvent.click(within(model).getByRole('checkbox', { name: '开放 new-vision-model · 现有上游 给用户' }))
fireEvent.change(screen.getByLabelText('资料分析默认模型', { exact: false }), { target: { value: 'model-new' } })
expect(screen.getByRole('button', { name: '同步 现有上游 的模型目录' })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: '保存配置' }))
await screen.findByText('模型配置已保存')
const call = (global.fetch as jest.Mock).mock.calls.find(([, init]) => init.method === 'PUT')
const body = JSON.parse(call[1].body)
expect(body.revision).toBe(2)
expect(body.defaults.analysis).toBe('model-new')
expect(body.providers[0].revision).toBe(3)
expect(body.providers[0].models).toHaveLength(4)
expect(body.providers[0].models.find((entry: any) => entry.key === 'model-new')).toEqual({ key: 'model-new', enabled: true, stages: ['analysis'] })
expect(JSON.stringify(body)).not.toMatch(/compatibility|verification/)
expect(JSON.stringify(body)).not.toContain('apiKey')
fireEvent.change(screen.getByLabelText('搜索模型或上游'), { target: { value: '' } })
fireEvent.change(screen.getByLabelText('按状态筛选模型'), { target: { value: 'missing' } })
expect(screen.getByText('removed-model')).toBeInTheDocument()
expect(screen.queryByText('nano-banana-2')).not.toBeInTheDocument()
})
test('编辑密钥不回显,留空保存不发送 apiKey', async () => {
const next = configured()
global.fetch = jest.fn(async () => response(next)) as typeof fetch
render(<AiModelManagementPanel />)
fireEvent.click(await screen.findByRole('button', { name: '编辑 现有上游' }))
const password = screen.getByLabelText('API Key')
expect(password).toHaveAttribute('type', 'password')
expect(password).toHaveValue('')
expect(password).toHaveAttribute('placeholder', '留空保留当前密钥')
fireEvent.change(screen.getByLabelText('上游名称'), { target: { value: '更新后的名称' } })
fireEvent.click(screen.getByRole('button', { name: '保存上游' }))
await screen.findByText('上游已保存')
const call = (global.fetch as jest.Mock).mock.calls.find(([, init]) => init.method === 'PATCH')
expect(JSON.parse(call[1].body)).toEqual({ name: '更新后的名称', baseUrl: 'https://api.example.com', revision: 3 })
expect(screen.queryByLabelText('API Key')).not.toBeInTheDocument()
})
test('新增上游保存失败保留输入,就近反馈错误,取消清除密钥', async () => {
global.fetch = jest.fn(async (_url: string, init: RequestInit = {}) => response(init.method === 'POST' ? { error: '该 API 地址已存在,请编辑已有上游' } : initial(), init.method === 'POST' ? 409 : 200)) as typeof fetch
render(<AiModelManagementPanel />)
fireEvent.click(await screen.findByRole('button', { name: '新增上游' }))
fireEvent.change(screen.getByLabelText('上游名称'), { target: { value: '新上游' } })
fireEvent.change(screen.getByLabelText('API 地址'), { target: { value: 'https://new.example.com' } })
fireEvent.change(screen.getByLabelText('API Key'), { target: { value: 'replace-only-test-key' } })
fireEvent.submit(screen.getByRole('form', { name: '新增上游连接' }))
expect(await screen.findByRole('alert')).toHaveTextContent('该 API 地址已存在,请编辑已有上游')
expect(screen.getByLabelText('API Key')).toHaveValue('replace-only-test-key')
expect(screen.queryByText('replace-only-test-key')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '取消' }))
expect(screen.queryByLabelText('API Key')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '新增上游' }))
expect(screen.getByLabelText('API Key')).toHaveValue('')
})
test('目录同步进行时禁止重复请求和配置修改', async () => {
let finish!: (value: Response) => void
global.fetch = jest.fn((url: string) => url.endsWith('/sync') ? new Promise<Response>((resolve) => { finish = resolve }) : Promise.resolve(response(configured()))) as typeof fetch
render(<AiModelManagementPanel />)
const sync = await screen.findByRole('button', { name: '同步 现有上游 的模型目录' })
fireEvent.click(sync); fireEvent.click(sync)
expect(sync).toBeDisabled()
expect(screen.getByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' })).toBeDisabled()
expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/sync'))).toHaveLength(1)
finish(response(configured()))
await waitFor(() => expect(sync).toBeEnabled())
})
test('缺少加密配置时禁止新增密钥,仍可显式迁入服务端连接', async () => {
global.fetch = jest.fn(async () => response({ ...initial(), encryptionConfigured: false })) as typeof fetch
render(<AiModelManagementPanel />)
expect(await screen.findByRole('button', { name: '新增上游' })).toBeDisabled()
expect(screen.getByRole('button', { name: '迁入当前连接' })).toBeEnabled()
expect(screen.getByText('服务端尚未配置密钥加密,暂不能新增或替换 API Key。')).toBeInTheDocument()
})
test('目录按 20 条分页,搜索回第一页,保存仍包含不可见模型', async () => {
const next = configured()
next.providers[0].models = Array.from({ length: 45 }, (_, index) => ({ key: `key-${index}`, id: `catalog-model-${String(index).padStart(2, '0')}`, enabled: false, stages: [], present: true, verification: verification('passed'), capabilities: { kind: 'vision', allowedStages: ['analysis', 'copy'], source: 'upstream', reason: '支持图片理解与文字输出' } }))
next.defaults = { analysis: null, copy: null, image: null }
global.fetch = jest.fn(async () => response(next)) as typeof fetch
render(<AiModelManagementPanel />)
await screen.findByText('45 个模型 · 第 1 / 3 页')
expect(screen.getByLabelText('catalog-model-00 · 现有上游', { selector: 'div' })).toBeInTheDocument()
expect(screen.queryByLabelText('catalog-model-20 · 现有上游', { selector: 'div' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '下一页' }))
expect(screen.getByLabelText('catalog-model-20 · 现有上游', { selector: 'div' })).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('搜索模型或上游'), { target: { value: 'catalog-model-44' } })
expect(screen.getByText('1 个模型 · 第 1 / 1 页')).toBeInTheDocument()
fireEvent.click(screen.getByRole('checkbox', { name: 'catalog-model-44 · 现有上游 用于资料分析' }))
fireEvent.click(screen.getByRole('button', { name: '保存配置' }))
await screen.findByText('模型配置已保存')
const call = (global.fetch as jest.Mock).mock.calls.find(([, init]) => init.method === 'PUT')
expect(JSON.parse(call[1].body).providers[0].models).toHaveLength(45)
})
test('按能力筛选未开放模型,纯对话不能分配生图或图片分析', async () => {
const next = configured()
next.providers[0].models[2] = { ...next.providers[0].models[2], id: 'text-chat-only', verification: verification('passed'), capabilities: { kind: 'text', allowedStages: ['copy'], source: 'upstream', reason: '仅支持文字输入与文字输出' } }
global.fetch = jest.fn(async () => response(next)) as typeof fetch
render(<AiModelManagementPanel />)
fireEvent.change(await screen.findByLabelText('按能力筛选模型'), { target: { value: 'text' } })
expect(screen.getByText('text-chat-only')).toBeInTheDocument()
expect(screen.queryByText('nano-banana-2')).not.toBeInTheDocument()
expect(screen.queryByText('new-vision-model')).not.toBeInTheDocument()
const image = screen.getByRole('checkbox', { name: 'text-chat-only · 现有上游 用于图片生成' })
expect(image).toBeDisabled()
expect(image).toHaveAccessibleDescription('当前未接入图片生成用途')
expect(image.closest('label')).toHaveAttribute('title', '当前未接入图片生成用途')
expect(screen.getByRole('checkbox', { name: 'text-chat-only · 现有上游 用于资料分析' })).toBeDisabled()
expect(screen.getByRole('checkbox', { name: 'text-chat-only · 现有上游 用于卖点文案' })).toBeEnabled()
expect(image).toBeDisabled()
expect(screen.getByRole('checkbox', { name: '开放 text-chat-only · 现有上游 给用户' })).toBeDisabled()
fireEvent.click(screen.getByRole('checkbox', { name: 'text-chat-only · 现有上游 用于卖点文案' }))
fireEvent.click(screen.getByRole('checkbox', { name: '开放 text-chat-only · 现有上游 给用户' }))
const analysis = screen.getByLabelText('资料分析默认模型', { exact: false })
const copy = screen.getByLabelText('卖点文案默认模型', { exact: false })
expect(within(analysis).queryByRole('option', { name: 'text-chat-only · 现有上游' })).not.toBeInTheDocument()
expect(within(copy).getByRole('option', { name: 'text-chat-only · 现有上游' })).toBeInTheDocument()
})
test('能力待识别模型没有人工兼容开关,不能解锁用途或实际测试', async () => {
const next = configured()
next.providers[0].models[2].capabilities = { kind: 'unknown', allowedStages: [], source: 'unknown', reason: '目录未提供能力信息,待核验' }
global.fetch = jest.fn(async () => response(next)) as typeof fetch
render(<AiModelManagementPanel />)
await screen.findByText('new-vision-model')
expect(screen.queryByRole('checkbox', { name: /接口兼容/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /测试 new-vision-model/ })).not.toBeInTheDocument()
for (const label of ['资料分析', '卖点文案', '图片生成']) expect(screen.getByRole('checkbox', { name: `new-vision-model · 现有上游 用于${label}` })).toBeDisabled()
expect(screen.getByRole('checkbox', { name: '开放 new-vision-model · 现有上游 给用户' })).toBeDisabled()
fireEvent.change(screen.getByLabelText('按能力筛选模型'), { target: { value: 'unknown' } })
expect(screen.getByText('new-vision-model')).toBeInTheDocument()
expect(screen.queryByText('gemini-2.5-flash')).not.toBeInTheDocument()
})
test('生图接口未接入时显示待适配,详情解释原因而不冒充测试结果', async () => {
const next = configured()
next.providers[0].models[2] = { ...next.providers[0].models[2], id: 'image-other-protocol', capabilities: { kind: 'image', allowedStages: [], source: 'upstream', reason: '参考图待适配:目录已声明图片输入,当前传入方式尚未接入' } }
global.fetch = jest.fn(async () => response(next)) as typeof fetch
render(<AiModelManagementPanel />)
const image = await screen.findByRole('checkbox', { name: 'image-other-protocol · 现有上游 用于图片生成' })
expect(image).toBeDisabled()
expect(image.closest('label')).toHaveAttribute('title', '参考图接入待适配,完成后可测试')
expect(image).toHaveAccessibleDescription('参考图接入待适配,完成后可测试')
const row = screen.getByLabelText('image-other-protocol · 现有上游', { selector: 'div' })
expect(within(row).getByRole('status')).toHaveTextContent('待适配')
expect(within(row).getByText('参考图待适配:目录已声明图片输入,当前传入方式尚未接入')).not.toBeVisible()
fireEvent.click(within(row).getByRole('button', { name: 'image-other-protocol · 现有上游 参考图接入详情' }))
expect(within(row).getByText('参考图待适配:目录已声明图片输入,当前传入方式尚未接入')).toBeVisible()
expect(within(row).queryByRole('button', { name: /^测试 / })).not.toBeInTheDocument()
fireEvent.change(screen.getByLabelText('按能力筛选模型'), { target: { value: 'image' } })
expect(screen.getByText('image-other-protocol')).toBeInTheDocument()
expect(screen.queryByText('gemini-2.5-flash')).not.toBeInTheDocument()
})
test('状态筛选区分待适配、未测试、真实失败、待核查和人工核查历史,筛选不触发调用', async () => {
const next = configured(), template = next.providers[0].models[1]
const entries = [
{ key: 'adapting', status: 'untested' }, { key: 'untested', status: 'untested' },
{ key: 'failed', status: 'failed' }, { key: 'unknown', status: 'unknown' },
{ key: 'reconciled', status: 'failed' }, { key: 'stale', status: 'stale' },
] as const
next.providers[0].models = entries.map(({ key, status }): AiModelCatalogEntry => ({
...template, key, id: `image-${key}`, enabled: false, stages: [],
capabilities: { ...template.capabilities, allowedStages: key === 'adapting' ? [] : ['image'], reason: key === 'adapting' ? '参考图待适配:当前目录尚未确认可用的参考图传入方式' : template.capabilities.reason },
verification: { ...verification(), image: { status, id: status === 'untested' ? null : `verification-${key}`, checkedAt: null, requestId: null, message: key === 'reconciled' ? '人工核查确认历史请求已结束' : null, reconciled: key === 'reconciled' } },
}))
next.defaults = { analysis: null, copy: null, image: null }
global.fetch = jest.fn(async () => response(next)) as typeof fetch
render(<AiModelManagementPanel />)
const filter = await screen.findByLabelText('按状态筛选模型')
for (const { key } of entries) {
fireEvent.change(filter, { target: { value: key } })
expect(screen.getByText(`image-${key}`)).toBeInTheDocument()
for (const other of entries.filter(entry => entry.key !== key)) expect(screen.queryByText(`image-${other.key}`)).not.toBeInTheDocument()
}
fireEvent.change(filter, { target: { value: 'reconciled' } })
const row = screen.getByLabelText('image-reconciled · 现有上游', { selector: 'div' })
expect(within(row).getByRole('status')).toHaveTextContent('已核查,可重测')
expect(within(row).queryByText('测试失败')).not.toBeInTheDocument()
expect(within(row).getByRole('checkbox', { name: 'image-reconciled · 现有上游 用于图片生成' })).toBeDisabled()
fireEvent.click(within(row).getByRole('button', { name: 'image-reconciled · 现有上游 图片生成测试详情' }))
expect(within(row).getByText('历史请求经人工核查结束,尚需重新测试。')).toBeVisible()
expect(within(row).getByText('人工核查确认历史请求已结束')).toBeVisible()
expect(global.fetch).toHaveBeenCalledTimes(1)
})
test('已适配但上游不可用的模型显示真实原因,不进入待适配或未测试筛选', async () => {
const next = configured(), model = next.providers[0].models[1]
model.enabled = false; model.stages = []
model.verification = verification()
model.capabilities = { kind: 'image', allowedStages: [], source: 'upstream', reason: '上游当前不可用,暂不能测试或开放' }
global.fetch = jest.fn(async () => response(next)) as typeof fetch
render(<AiModelManagementPanel />)
const row = await screen.findByLabelText('nano-banana-2 · 现有上游', { selector: 'div' })
expect(within(row).getByRole('status')).toHaveTextContent('上游当前不可用,暂不能测试或开放')
expect(within(row).queryByText('待适配')).not.toBeInTheDocument()
expect(within(row).queryByRole('button', { name: /^测试 / })).not.toBeInTheDocument()
expect(within(row).getByRole('checkbox', { name: '开放 nano-banana-2 · 现有上游 给用户' })).toBeDisabled()
expect(within(row).getByRole('checkbox', { name: 'nano-banana-2 · 现有上游 用于图片生成' })).toBeDisabled()
for (const value of ['adapting', 'untested']) {
fireEvent.change(screen.getByLabelText('按状态筛选模型'), { target: { value } })
expect(screen.queryByText('nano-banana-2')).not.toBeInTheDocument()
}
expect(global.fetch).toHaveBeenCalledTimes(1)
})
test('上游停用只阻止操作,不掩盖模型的真实失败状态', async () => {
const next = configured(), provider = next.providers[0], model = provider.models[1]
provider.enabled = false; model.enabled = false; model.stages = []
model.verification.image.status = 'failed'
global.fetch = jest.fn(async () => response(next)) as typeof fetch
render(<AiModelManagementPanel />)
const row = await screen.findByLabelText('nano-banana-2 · 现有上游', { selector: 'div' })
expect(within(row).getByRole('status')).toHaveTextContent('测试失败')
expect(within(row).getByText('请先启用上游连接')).toBeVisible()
expect(within(row).getByRole('button', { name: '测试 nano-banana-2 · 现有上游 的图片生成' })).toBeDisabled()
})
test('取消最后用途同步关闭开放并清空默认值,保存不会携带失效默认模型', async () => {
const next = configured()
global.fetch = jest.fn(async () => response(next)) as typeof fetch
render(<AiModelManagementPanel />)
fireEvent.click(await screen.findByRole('checkbox', { name: 'nano-banana-2 · 现有上游 用于图片生成' }))
expect(screen.getByRole('checkbox', { name: '开放 nano-banana-2 · 现有上游 给用户' })).not.toBeChecked()
expect(screen.getByRole('checkbox', { name: '开放 nano-banana-2 · 现有上游 给用户' })).toBeDisabled()
expect(screen.getByLabelText('图片生成默认模型', { exact: false })).toHaveValue('')
expect(screen.getByLabelText('资料分析默认模型', { exact: false })).toHaveValue('model-text')
fireEvent.click(screen.getByRole('button', { name: '保存配置' }))
await screen.findByText('模型配置已保存')
const call = (global.fetch as jest.Mock).mock.calls.find(([, init]) => init.method === 'PUT')
const body = JSON.parse(call[1].body)
expect(body.defaults).toEqual({ analysis: 'model-text', copy: 'model-text', image: null })
expect(body.providers[0].models.find((entry: any) => entry.key === 'model-image')).toMatchObject({ enabled: false, stages: [] })
expect(JSON.stringify(body)).not.toContain('capabilities')
})
test('关闭开放清空关联默认值,撤销可恢复;取消单一用途保留其它开放用途', async () => {
global.fetch = jest.fn(async () => response(configured())) as typeof fetch
render(<AiModelManagementPanel />)
fireEvent.click(await screen.findByRole('checkbox', { name: '开放 gemini-2.5-flash · 现有上游 给用户' }))
expect(screen.getByLabelText('资料分析默认模型', { exact: false })).toHaveValue('')
expect(screen.getByLabelText('卖点文案默认模型', { exact: false })).toHaveValue('')
fireEvent.click(screen.getByRole('button', { name: '撤销' }))
expect(screen.getByRole('checkbox', { name: '开放 gemini-2.5-flash · 现有上游 给用户' })).toBeChecked()
expect(screen.getByLabelText('资料分析默认模型', { exact: false })).toHaveValue('model-text')
fireEvent.click(screen.getByRole('checkbox', { name: 'gemini-2.5-flash · 现有上游 用于资料分析' }))
expect(screen.getByRole('checkbox', { name: '开放 gemini-2.5-flash · 现有上游 给用户' })).toBeChecked()
expect(screen.getByLabelText('资料分析默认模型', { exact: false })).toHaveValue('')
expect(screen.getByLabelText('卖点文案默认模型', { exact: false })).toHaveValue('model-text')
})
test('旧配置中能力不符的用途允许取消,取消后不能重新选入', async () => {
const next = configured()
next.providers[0].models[0].stages.push('image')
global.fetch = jest.fn(async () => response(next)) as typeof fetch
render(<AiModelManagementPanel />)
const image = await screen.findByRole('checkbox', { name: 'gemini-2.5-flash · 现有上游 用于图片生成' })
expect(image).toBeChecked()
expect(image).toBeEnabled()
fireEvent.click(image)
expect(image).not.toBeChecked()
expect(image).toBeDisabled()
expect(screen.getByRole('checkbox', { name: '开放 gemini-2.5-flash · 现有上游 给用户' })).toBeChecked()
})
test('实际测试需明确确认费用,取消不调用模型;通过后仍需管理员开放', async () => {
const next = configured()
const passed = configured()
passed.providers[0].models[2].verification.analysis = { status: 'passed', id: 'verification-1', checkedAt: '2026-10-13T01:00:00Z', requestId: 'request-1', message: '结构化分析与图片输入已验证' }
global.fetch = jest.fn(async (url: string) => response(url.endsWith('/verify') ? passed : next)) as typeof fetch
render(<AiModelManagementPanel />)
const testButton = await screen.findByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' })
expect(screen.getByRole('checkbox', { name: 'new-vision-model · 现有上游 用于资料分析' })).toBeDisabled()
fireEvent.click(testButton)
let dialog = await screen.findByRole('dialog', { name: '测试资料分析' })
expect(within(dialog).getByText('将实际调用模型,可能产生上游费用。')).toBeInTheDocument()
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }))
await waitFor(() => expect(testButton).toBeEnabled())
expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/verify'))).toHaveLength(0)
fireEvent.click(testButton)
dialog = await screen.findByRole('dialog', { name: '测试资料分析' })
fireEvent.click(within(dialog).getByRole('button', { name: '开始测试' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'new-vision-model · 现有上游 用于资料分析' })).toBeEnabled())
const calls = (global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/verify'))
expect(calls).toHaveLength(1)
expect(calls[0][0]).toBe('/api/admin/ai-models/providers/provider-1/models/model-new/verify')
expect(JSON.parse(calls[0][1].body)).toEqual({ stage: 'analysis', revision: 3, idempotencyKey: expect.stringMatching(/^[0-9a-f-]{36}$/i) })
expect(screen.getByRole('checkbox', { name: '开放 new-vision-model · 现有上游 给用户' })).not.toBeChecked()
expect(screen.getByRole('checkbox', { name: 'new-vision-model · 现有上游 用于卖点文案' })).toBeDisabled()
expect(screen.getByRole('button', { name: '保存配置' })).toBeDisabled()
expect(screen.getByLabelText('new-vision-model · 现有上游 测试用途')).toHaveValue('analysis')
})
test('未保存开放设置不阻止测试;每次仅提交所选用途', async () => {
const next = configured()
global.fetch = jest.fn(async () => response(next)) as typeof fetch
render(<AiModelManagementPanel />)
fireEvent.click(await screen.findByRole('checkbox', { name: '开放 nano-banana-2 · 现有上游 给用户' }))
expect(screen.getByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' })).toBeEnabled()
fireEvent.change(screen.getByLabelText('new-vision-model · 现有上游 测试用途'), { target: { value: 'copy' } })
fireEvent.click(screen.getByRole('button', { name: '测试 new-vision-model · 现有上游 的卖点文案' }))
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: '开始测试' }))
await waitFor(() => expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/verify'))).toHaveLength(1))
const call = (global.fetch as jest.Mock).mock.calls.find(([url]) => url.endsWith('/verify'))
expect(JSON.parse(call[1].body).stage).toBe('copy')
await waitFor(() => expect(screen.getByRole('button', { name: '测试 new-vision-model · 现有上游 的卖点文案' })).toBeEnabled())
expect(screen.getByRole('checkbox', { name: '开放 nano-banana-2 · 现有上游 给用户' })).not.toBeChecked()
})
test('测试刷新新状态不偷偷升级草稿版本,并发保存冲突继续保留修改', async () => {
const next = configured(), updated = configured()
updated.revision = 7
updated.providers[0].revision = 8
updated.providers[0].models[2].verification.analysis.status = 'passed'
global.fetch = jest.fn(async (url: string, init: RequestInit = {}) => {
if (url.endsWith('/verify')) return response(updated)
if (init.method === 'PUT') return response({ error: '配置已被其他管理员修改,请重新加载' }, 409)
return response(next)
}) as typeof fetch
render(<AiModelManagementPanel />)
fireEvent.click(await screen.findByRole('checkbox', { name: '开放 nano-banana-2 · 现有上游 给用户' }))
fireEvent.click(screen.getByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' }))
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: '开始测试' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'new-vision-model · 现有上游 用于资料分析' })).toBeEnabled())
fireEvent.change(screen.getByLabelText('new-vision-model · 现有上游 测试用途'), { target: { value: 'copy' } })
fireEvent.click(screen.getByRole('button', { name: '测试 new-vision-model · 现有上游 的卖点文案' }))
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: '开始测试' }))
await waitFor(() => expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/verify'))).toHaveLength(2))
await waitFor(() => expect(screen.getByRole('button', { name: '保存配置' })).toBeEnabled())
const verifyCalls = (global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/verify'))
expect(JSON.parse(verifyCalls[1][1].body).revision).toBe(8)
fireEvent.click(screen.getByRole('button', { name: '保存配置' }))
await screen.findByText('配置已被其他管理员修改,请重新加载')
const save = (global.fetch as jest.Mock).mock.calls.find(([, init]) => init?.method === 'PUT')
expect(JSON.parse(save[1].body)).toMatchObject({ revision: 2, providers: [{ revision: 3 }] })
expect(screen.getByRole('checkbox', { name: '开放 nano-banana-2 · 现有上游 给用户' })).not.toBeChecked()
})
test.each(['disabled', 'credential'] as const)('测试无法执行时就近显示可恢复原因:%s', async (reason) => {
const next = configured()
if (reason === 'disabled') next.providers[0].enabled = false
else next.providers[0].hasApiKey = false
global.fetch = jest.fn(async () => response(next)) as typeof fetch
render(<AiModelManagementPanel />)
const button = await screen.findByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' })
expect(button).toBeDisabled()
expect(button).toHaveAccessibleDescription(reason === 'disabled' ? '请先启用上游连接' : '请先配置上游密钥')
const row = screen.getByLabelText('new-vision-model · 现有上游', { selector: 'div' })
expect(within(row).getByRole('status')).toHaveTextContent('未测试')
expect(within(row).getByText(reason === 'disabled' ? '请先启用上游连接' : '请先配置上游密钥')).toBeVisible()
})
test('有开放设置草稿时查询原异步任务不重新测试且保留草稿', async () => {
const next = configured(), passed = configured()
next.providers[0].models[2].verification.analysis = { status: 'unknown', id: 'resume-with-draft', checkedAt: null, requestId: 'known-request', message: '等待结果', canResume: true }
passed.providers[0].models[2].verification.analysis.status = 'passed'
global.fetch = jest.fn(async (url: string) => response(url.endsWith('/refresh') ? passed : next)) as typeof fetch
render(<AiModelManagementPanel />)
fireEvent.click(await screen.findByRole('checkbox', { name: '开放 nano-banana-2 · 现有上游 给用户' }))
const query = screen.getByRole('button', { name: '查询 new-vision-model · 现有上游 的资料分析结果' })
expect(query).toBeEnabled()
expect(screen.getByRole('button', { name: '刷新测试状态' })).toBeEnabled()
fireEvent.click(query)
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'new-vision-model · 现有上游 用于资料分析' })).toBeEnabled())
expect(screen.getByRole('checkbox', { name: '开放 nano-banana-2 · 现有上游 给用户' })).not.toBeChecked()
expect(screen.getByRole('button', { name: '保存配置' })).toBeEnabled()
expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/verify'))).toHaveLength(0)
expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/refresh'))).toHaveLength(1)
})
test('编辑上游时阻止测试并说明先保存或取消,取消不发送任何付费请求', async () => {
global.fetch = jest.fn(async () => response(configured())) as typeof fetch
render(<AiModelManagementPanel />)
fireEvent.click(await screen.findByRole('button', { name: '编辑 现有上游' }))
const test = screen.getByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' })
expect(test).toBeDisabled()
expect(test).toHaveAccessibleDescription('请先保存或取消上游编辑')
fireEvent.click(screen.getByRole('button', { name: '取消' }))
expect(test).toBeEnabled()
expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/verify'))).toHaveLength(0)
})
test('测试处理中禁止重复请求;未知结果暂停整个模型并可只读刷新', async () => {
const next = configured()
const unknown = configured()
unknown.providers[0].models[2].verification.analysis = { status: 'unknown', id: 'verification-unknown', checkedAt: '2026-10-13T01:00:00Z', requestId: 'upstream-request-unknown', message: '上游响应中断,需先核查执行结果' }
let finish!: (value: Response) => void
global.fetch = jest.fn((url: string) => url.endsWith('/verify') ? new Promise<Response>((resolve) => { finish = resolve }) : Promise.resolve(response(next))) as typeof fetch
render(<AiModelManagementPanel />)
const testButton = await screen.findByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' })
fireEvent.click(testButton)
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: '开始测试' }))
await screen.findByText('正在调用模型,请稍候')
fireEvent.click(testButton)
expect(testButton).toBeDisabled()
expect(screen.getByRole('button', { name: '同步 现有上游 的模型目录' })).toBeDisabled()
expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/verify'))).toHaveLength(1)
finish(response(unknown))
await screen.findByText('待核查', { selector: 'p' })
expect(testButton).toBeDisabled()
fireEvent.change(screen.getByLabelText('new-vision-model · 现有上游 测试用途'), { target: { value: 'copy' } })
expect(screen.getByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' })).toBeDisabled()
expect(screen.getByText('请求编号:upstream-request-unknown')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '刷新测试状态' }))
await screen.findByText('测试状态已刷新')
expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/verify'))).toHaveLength(1)
})
test('付费请求响应丢失后仅查询状态,查询也失败则本地暂停而不重试', async () => {
const next = configured()
let reads = 0
global.fetch = jest.fn(async (url: string) => {
if (url.endsWith('/verify')) throw new Error('网络中断')
if (++reads > 1) throw new Error('状态不可达')
return response(next)
}) as typeof fetch
render(<AiModelManagementPanel />)
fireEvent.click(await screen.findByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' }))
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: '开始测试' }))
await screen.findByText('提交结果未确认,请核查')
expect(screen.getByRole('alert')).toHaveTextContent('网络中断;已停止提交,请核查测试状态。')
expect(screen.getByRole('button', { name: '核查 new-vision-model · 现有上游 的测试提交' })).toBeEnabled()
expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/verify'))).toHaveLength(1)
expect(reads).toBe(2)
})
test('响应丢失且 GET 仍为旧记录时保持暂停,手动刷新也不重复提交', async () => {
global.fetch = jest.fn(async (url: string) => {
if (url.endsWith('/verify')) throw new Error('连接中断')
return response(configured())
}) as typeof fetch
render(<AiModelManagementPanel />)
fireEvent.click(await screen.findByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' }))
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: '开始测试' }))
await screen.findByText('提交结果未确认,请核查')
const call = (global.fetch as jest.Mock).mock.calls.find(([url]) => url.endsWith('/verify'))
expect(screen.getByText(`请求编号:${JSON.parse(call[1].body).idempotencyKey}`)).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '刷新测试状态' }))
await screen.findByText('测试状态已刷新')
expect(screen.getByRole('button', { name: '核查 new-vision-model · 现有上游 的测试提交' })).toBeEnabled()
expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/verify'))).toHaveLength(1)
})
test('已有上游任务仅查询结果,无费用弹窗,不发送新验证请求', async () => {
const next = configured()
next.providers[0].models[2].verification.analysis = { status: 'unknown', id: 'existing-verification', checkedAt: null, requestId: 'existing-task', message: '上游任务仍在执行', canResume: true }
const completed = configured()
completed.providers[0].models[2].verification.analysis = { status: 'passed', id: 'existing-verification', checkedAt: '2026-10-13T01:00:00Z', requestId: 'existing-task', message: '测试通过' }
global.fetch = jest.fn(async (url: string) => response(url.endsWith('/refresh') ? completed : next)) as typeof fetch
render(<AiModelManagementPanel />)
expect(await screen.findByText('待核查', { selector: 'p' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '查询 new-vision-model · 现有上游 的资料分析结果' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'new-vision-model · 现有上游 用于资料分析' })).toBeEnabled())
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
const calls = (global.fetch as jest.Mock).mock.calls.filter(([, init]) => init.method === 'POST')
expect(calls).toHaveLength(1)
expect(calls[0][0]).toBe('/api/admin/ai-models/providers/provider-1/verifications/existing-verification/refresh')
expect(calls[0][1].body).toBeUndefined()
expect(screen.getByRole('checkbox', { name: '开放 new-vision-model · 现有上游 给用户' })).not.toBeChecked()
})
test('每个用途显示服务端绑定协议和路由,详情默认折叠且可键盘聚焦', async () => {
const next = configured()
next.providers[0].models[2].capabilities.protocols = {
analysis: { id: 'openai-chat', path: '/v1/chat/completions', scopeId: 'scope-a', routeId: 'vision-route', operationId: 'analyze' },
copy: { id: 'openai-responses', path: '/v1/responses', routeId: 'copy-route' },
}
global.fetch = jest.fn(async () => response(next)) as typeof fetch
render(<AiModelManagementPanel />)
const details = await screen.findByRole('button', { name: 'new-vision-model · 现有上游 资料分析测试详情' })
expect(details).toHaveAttribute('aria-expanded', 'false')
expect(screen.getByText('协议:openai-chat')).not.toBeVisible()
details.focus()
expect(details).toHaveFocus()
fireEvent.click(details)
expect(details).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText('接口:/v1/chat/completions')).toBeVisible()
expect(screen.getByText('路由:scope-a / vision-route / analyze')).toBeVisible()
fireEvent.change(screen.getByLabelText('new-vision-model · 现有上游 测试用途'), { target: { value: 'copy' } })
expect(screen.queryByText('协议:openai-chat')).not.toBeInTheDocument()
const copyDetails = screen.getByRole('button', { name: 'new-vision-model · 现有上游 卖点文案测试详情' })
expect(copyDetails).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(copyDetails)
expect(screen.getByText('协议:openai-responses')).toBeVisible()
expect(screen.getByText('路由:copy-route')).toBeVisible()
expect((global.fetch as jest.Mock).mock.calls).toHaveLength(1)
})
test.each([['tts', '语音合成'], ['asr', '语音识别'], ['music', '音乐'], ['video', '视频'], ['embedding', '向量'], ['image-tool', '图像工具'], ['three-d', '3D']] as const)('目录可筛选 %s,但未接入详情页的类型不能测试或开放', async (kind, label) => {
const next = configured()
next.providers[0].models[2].capabilities = { kind, allowedStages: [], source: 'upstream', reason: '当前详情页未接入该类型' }
global.fetch = jest.fn(async () => response(next)) as typeof fetch
render(<AiModelManagementPanel />)
const filter = await screen.findByLabelText('按能力筛选模型')
expect(within(filter).getByRole('option', { name: label })).toBeInTheDocument()
fireEvent.change(filter, { target: { value: kind } })
expect(screen.getByText('new-vision-model')).toBeInTheDocument()
expect(screen.queryByText('gemini-2.5-flash')).not.toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('当前详情页未接入该类型')
expect(screen.queryByRole('button', { name: /测试 new-vision-model/ })).not.toBeInTheDocument()
expect(screen.getByRole('checkbox', { name: '开放 new-vision-model · 现有上游 给用户' })).toBeDisabled()
})