244 lines
18 KiB
TypeScript
244 lines
18 KiB
TypeScript
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
|
import AiModelManagementPanel from './AiModelManagementPanel'
|
|
import type { AiModelAdminState } 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 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, compatibility: 'confirmed', capabilities: { kind: 'vision', allowedStages: ['analysis', 'copy'], source: 'verified', reason: '支持图片理解与文字输出' } },
|
|
{ key: 'model-image', id: 'nano-banana-2', enabled: true, stages: ['image'], present: true, compatibility: 'confirmed', capabilities: { kind: 'image', allowedStages: ['image'], source: 'verified', reason: '支持图片生成' } },
|
|
{ key: 'model-new', id: 'new-vision-model', enabled: false, stages: [], present: true, compatibility: 'unconfirmed', capabilities: { kind: 'vision', allowedStages: ['analysis', 'copy'], source: 'upstream', reason: '上游声明图片输入、文字输出' } },
|
|
{ key: 'model-missing', id: 'removed-model', enabled: false, stages: [], present: false, compatibility: 'unconfirmed', capabilities: { kind: 'unknown', allowedStages: [], source: 'unknown', reason: '目录未提供能力信息,待核验' } },
|
|
] }],
|
|
defaults: { analysis: 'model-text', copy: 'model-text', image: 'model-image' },
|
|
})
|
|
|
|
afterEach(() => jest.restoreAllMocks())
|
|
|
|
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'))).toBe(true)
|
|
})
|
|
|
|
test('搜索与状态筛选仅影响显示,配置保存包含全部模型和各版本号', async () => {
|
|
const next = configured()
|
|
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()
|
|
fireEvent.click(within(model).getByRole('checkbox', { name: '确认 new-vision-model · 现有上游 接口兼容' }))
|
|
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'], compatibility: 'confirmed' })
|
|
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('checkbox', { 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, compatibility: 'unconfirmed', 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', 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()
|
|
fireEvent.click(screen.getByRole('checkbox', { name: '确认 text-chat-only · 现有上游 接口兼容' }))
|
|
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 />)
|
|
fireEvent.click(await screen.findByRole('checkbox', { name: '确认 new-vision-model · 现有上游 接口兼容' }))
|
|
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()
|
|
})
|