gongxiangp2/components/admin/AiModelManagementPanel.test.tsx

505 lines
38 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, 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()
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('具备图片输出能力,当前参考图协议待适配')
expect(screen.getByText('生图 · 待适配')).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()
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' ? '请先启用上游连接' : '请先配置上游密钥')
expect(within(screen.getByLabelText('new-vision-model · 现有上游', { selector: 'div' })).getByRole('status')).toHaveTextContent(reason === 'disabled' ? '请先启用上游连接' : '请先配置上游密钥')
})
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('结果未知,暂停核查')
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('等待上游结果,可查询')).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.getByText('暂未接入详情页用途')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /测试 new-vision-model/ })).not.toBeInTheDocument()
expect(screen.getByRole('checkbox', { name: '开放 new-vision-model · 现有上游 给用户' })).toBeDisabled()
})