771 lines
59 KiB
TypeScript
771 lines
59 KiB
TypeScript
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()
|
||
})
|