import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AiHardware } from '@/pages/AiHardware'; import { AiHardwareApiError, type AiHardwareAgentConfiguration, type AiHardwareOverview, } from '@/lib/ai-hardware'; const api = vi.hoisted(() => ({ getAiHardwareOverview: vi.fn(), recoverAiHardwareCredential: vi.fn(), createAiHardwareAgent: vi.fn(), bindAiHardwareDevice: vi.fn(), getAiHardwareAgentConfiguration: vi.fn(), updateAiHardwareAgentConfiguration: vi.fn(), getAiHardwareAssignment: vi.fn(), updateAiHardwareAssignment: vi.fn(), })); vi.mock('@/lib/ai-hardware', () => { class MockAiHardwareApiError extends Error { status: number; code: string; retryable: boolean; retryAfterSeconds: number | null; operationId: string | null; constructor(options: { status: number; code: string; message: string; retryable?: boolean; retryAfterSeconds?: number | null; operationId?: string | null }) { super(options.message); this.name = 'AiHardwareApiError'; this.status = options.status; this.code = options.code; this.retryable = options.retryable ?? false; this.retryAfterSeconds = options.retryAfterSeconds ?? null; this.operationId = options.operationId ?? null; } } return { ...api, AiHardwareApiError: MockAiHardwareApiError }; }); const agentOne = { id: 'agent-local-1', name: '客厅助手', config_revision: 4 }; const agentTwo = { id: 'agent-local-2', name: '书房助手', config_revision: 2 }; const device = { id: 'device-local-1', agent_id: agentOne.id, assignment_revision: 3 }; const activeOverview: AiHardwareOverview = { status: 'active', agents: [agentOne, agentTwo], devices: [device] }; const configuration: AiHardwareAgentConfiguration = { ...agentOne, system_prompt: '保持简洁', lang_code: 'zh-CN', language: '中文', asr_model_id: null, vad_model_id: null, llm_model_id: null, slm_model_id: null, vllm_model_id: null, tts_model_id: null, tts_voice_id: 'voice-a', tts_language: null, tts_volume: 10, tts_rate: 5, tts_pitch: 0, mem_model_id: null, intent_model_id: null, chat_history_conf: 1, }; function deferred() { let resolve!: (value: T) => void; const promise = new Promise((done) => { resolve = done; }); return { promise, resolve }; } async function openConfigurationEditor(): Promise { const editButton = await screen.findByRole('button', { name: '编辑配置' }); await waitFor(() => expect(editButton).toBeEnabled()); fireEvent.click(editButton); await screen.findByRole('heading', { name: '编辑智能体配置' }); } describe('AI hardware page', () => { beforeEach(() => { api.getAiHardwareOverview.mockResolvedValue(activeOverview); api.recoverAiHardwareCredential.mockResolvedValue({ status: 'active', agents: [], devices: [] }); api.getAiHardwareAgentConfiguration.mockResolvedValue({ data: configuration, revision: 4 }); api.createAiHardwareAgent.mockResolvedValue(agentOne); api.bindAiHardwareDevice.mockResolvedValue(device); api.updateAiHardwareAgentConfiguration.mockResolvedValue({ data: configuration, revision: 5 }); api.getAiHardwareAssignment.mockResolvedValue({ data: device, revision: 3 }); api.updateAiHardwareAssignment.mockResolvedValue({ data: { ...device, agent_id: agentTwo.id }, revision: 4 }); }); it('shows loading, disabled, and credential recovery states without exposing diagnostics', async () => { const pending = deferred(); api.getAiHardwareOverview.mockReturnValueOnce(pending.promise); const first = render(); expect(screen.getByText('正在读取 AI 机器')).toBeInTheDocument(); first.unmount(); api.getAiHardwareOverview.mockRejectedValueOnce(new AiHardwareApiError({ status: 503, code: 'AI_HARDWARE_DISABLED', message: 'internal token=do-not-show', })); const disabled = render(); expect(await screen.findByText('AI 机器尚未启用')).toBeInTheDocument(); expect(screen.queryByText(/do-not-show|token=/)).not.toBeInTheDocument(); disabled.unmount(); api.getAiHardwareOverview.mockResolvedValueOnce({ status: 'credential_recovery_required', agents: [], devices: [] }); render(); expect(await screen.findByText('需要恢复设备凭据')).toBeInTheDocument(); }); it('shows the empty CTA and creates the first agent', async () => { api.getAiHardwareOverview .mockResolvedValueOnce({ status: 'active', agents: [], devices: [] }) .mockResolvedValueOnce(activeOverview); render(); fireEvent.click(await screen.findByRole('button', { name: '创建智能体' })); fireEvent.change(screen.getByLabelText('名称'), { target: { value: '客厅助手' } }); fireEvent.click(screen.getByRole('button', { name: '创建' })); await waitFor(() => expect(api.createAiHardwareAgent).toHaveBeenCalledWith('客厅助手')); expect((await screen.findAllByText('客厅助手')).length).toBeGreaterThan(0); }); it('provisions an unprovisioned account by creating its first agent', async () => { api.getAiHardwareOverview .mockResolvedValueOnce({ status: 'unprovisioned', agents: [], devices: [] }) .mockResolvedValueOnce(activeOverview); render(); expect(await screen.findByText('创建智能体将同时开通你的机器人工作台。')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '创建智能体' })); fireEvent.change(screen.getByLabelText('名称'), { target: { value: '首次智能体' } }); fireEvent.click(screen.getByRole('button', { name: '创建' })); await waitFor(() => expect(api.createAiHardwareAgent).toHaveBeenCalledWith('首次智能体')); expect(api.getAiHardwareOverview).toHaveBeenCalledTimes(2); }); it('validates activation codes and clears them after a failed binding', async () => { const secret = '031425'; api.bindAiHardwareDevice.mockRejectedValueOnce(new AiHardwareApiError({ status: 422, code: 'ai_hardware_activation_code_invalid', message: `invalid ${secret}`, })); render(); fireEvent.click(await screen.findByRole('button', { name: '绑定设备' })); const input = screen.getByLabelText('6 位激活码'); fireEvent.change(input, { target: { value: '12x' } }); fireEvent.click(screen.getByRole('button', { name: '绑定' })); expect(screen.getByRole('alert')).toHaveTextContent('请输入 6 位数字激活码'); expect(api.bindAiHardwareDevice).not.toHaveBeenCalled(); fireEvent.change(input, { target: { value: secret } }); fireEvent.click(screen.getByRole('button', { name: '绑定' })); await waitFor(() => expect(api.bindAiHardwareDevice).toHaveBeenCalledWith(secret, agentOne.id)); await waitFor(() => expect(input).toHaveValue('')); expect(screen.queryByText(new RegExp(secret))).not.toBeInTheDocument(); expect(screen.getByRole('alert')).toHaveTextContent('激活码无效'); }); it('sends cleared nullable configuration fields through clear_fields and never sends null', async () => { render(); await openConfigurationEditor(); fireEvent.change(screen.getByLabelText('名称'), { target: { value: '新的助手' } }); fireEvent.change(screen.getByLabelText('系统提示'), { target: { value: '' } }); fireEvent.change(screen.getByLabelText('TTS 语音 ID'), { target: { value: '' } }); fireEvent.change(screen.getByLabelText('音量 (-100–100)'), { target: { value: '20' } }); fireEvent.change(screen.getByLabelText('聊天记录'), { target: { value: '2' } }); fireEvent.click(screen.getByRole('button', { name: '保存' })); await waitFor(() => expect(api.updateAiHardwareAgentConfiguration).toHaveBeenCalled()); const [, revision, update] = api.updateAiHardwareAgentConfiguration.mock.calls[0]; expect(revision).toBe(4); expect(update).toMatchObject({ agent_name: '新的助手', tts_volume: 20, chat_history_conf: 2, clear_fields: expect.arrayContaining(['system_prompt', 'tts_voice_id']), }); expect(Object.values(update)).not.toContain(null); }); it.each(['credential_recovery_required', 'invalid'] as const)( 'recovers credentials from the %s state and enters the returned workspace', async (status) => { const restored: AiHardwareOverview = { status: 'active', agents: [], devices: [] }; api.getAiHardwareOverview.mockResolvedValueOnce({ status, agents: [], devices: [] }); const pending = deferred(); api.recoverAiHardwareCredential.mockReturnValueOnce(pending.promise); render(); const button = await screen.findByRole('button', { name: '恢复设备凭据' }); fireEvent.click(button); expect(button).toBeDisabled(); fireEvent.click(button); expect(api.recoverAiHardwareCredential).toHaveBeenCalledTimes(1); pending.resolve(restored); expect(await screen.findByText('创建第一个智能体')).toBeInTheDocument(); }, ); it('reuses a recovery operation id after a retryable safe error', async () => { const operationId = '123e4567-e89b-42d3-a456-426614174000'; api.getAiHardwareOverview.mockResolvedValueOnce({ status: 'credential_recovery_required', agents: [], devices: [] }); api.recoverAiHardwareCredential .mockRejectedValueOnce(new AiHardwareApiError({ status: 503, code: 'xiaozhi_hardware_unavailable', message: 'encrypted material=secret', retryable: true, operationId, })) .mockResolvedValueOnce({ status: 'active', agents: [], devices: [] }); render(); fireEvent.click(await screen.findByRole('button', { name: '恢复设备凭据' })); expect(await screen.findByRole('alert')).toHaveTextContent('服务暂时不可用'); expect(screen.queryByText(/encrypted material|secret/)).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '恢复设备凭据' })); await waitFor(() => expect(api.recoverAiHardwareCredential).toHaveBeenCalledTimes(2)); expect(api.recoverAiHardwareCredential.mock.calls[1]).toEqual([{ operationId }]); expect(await screen.findByText('创建第一个智能体')).toBeInTheDocument(); }); it('clears a stale recovery intent and refreshes when recovery is unavailable', async () => { const operationId = '123e4567-e89b-42d3-a456-426614174000'; api.getAiHardwareOverview .mockResolvedValueOnce({ status: 'credential_recovery_required', agents: [], devices: [] }) .mockResolvedValueOnce({ status: 'active', agents: [], devices: [] }); api.recoverAiHardwareCredential .mockRejectedValueOnce(new AiHardwareApiError({ status: 409, code: 'ai_hardware_credential_recovery_unavailable', message: 'provider secret', operationId, })) .mockResolvedValueOnce({ status: 'active', agents: [], devices: [] }); render(); fireEvent.click(await screen.findByRole('button', { name: '恢复设备凭据' })); expect(await screen.findByText('创建第一个智能体')).toBeInTheDocument(); expect(api.getAiHardwareOverview).toHaveBeenCalledTimes(2); expect(screen.queryByText(/provider secret|当前状态无需或不能恢复/)).not.toBeInTheDocument(); api.getAiHardwareOverview.mockResolvedValueOnce({ status: 'credential_recovery_required', agents: [], devices: [] }); fireEvent.click(screen.getByRole('button', { name: '刷新 AI 机器' })); fireEvent.click(await screen.findByRole('button', { name: '恢复设备凭据' })); await waitFor(() => expect(api.recoverAiHardwareCredential).toHaveBeenCalledTimes(2)); expect(api.recoverAiHardwareCredential.mock.calls[1]).toEqual([]); }); it('edits and clears every supported advanced configuration field', async () => { api.getAiHardwareAgentConfiguration.mockResolvedValueOnce({ data: { ...configuration, asr_model_id: 'asr-old', vad_model_id: 'vad-old', llm_model_id: 'llm-old', slm_model_id: 'slm-old', vllm_model_id: 'vllm-old', tts_model_id: 'tts-old', tts_language: 'zh', mem_model_id: 'mem-old', intent_model_id: 'intent-old', }, revision: 4, }); render(); await openConfigurationEditor(); fireEvent.click(screen.getByText('高级设置')); const values: Record = { asr_model_id: '', vad_model_id: 'vad-new', llm_model_id: '', slm_model_id: 'slm-new', vllm_model_id: '', tts_model_id: 'tts-new', tts_language: '', mem_model_id: 'mem-new', intent_model_id: '', }; for (const [key, value] of Object.entries(values)) { fireEvent.change(document.querySelector(`#hardware-${key}`) as HTMLInputElement, { target: { value } }); } fireEvent.click(screen.getByRole('button', { name: '保存' })); await waitFor(() => expect(api.updateAiHardwareAgentConfiguration).toHaveBeenCalled()); const update = api.updateAiHardwareAgentConfiguration.mock.calls[0][2]; expect(update).toMatchObject({ vad_model_id: 'vad-new', slm_model_id: 'slm-new', tts_model_id: 'tts-new', mem_model_id: 'mem-new', clear_fields: expect.arrayContaining([ 'asr_model_id', 'llm_model_id', 'vllm_model_id', 'tts_language', 'intent_model_id', ]), }); expect(Object.values(update)).not.toContain(null); }); it.each([ ['AI_HARDWARE_AUTH_REQUIRED', '请先登录'], ['ai_hardware_unconfigured', '尚未配置'], ['ai_hardware_credential_recovery_required', '恢复设备凭据'], ['ai_hardware_activation_code_invalid', '激活码无效'], ['ai_hardware_device_already_bound', '设备已被绑定'], ['AI_HARDWARE_RATE_LIMITED', '请求过于频繁'], ['AI_HARDWARE_REVISION_CONFLICT', '其他位置更新'], ])('shows a safe recovery message for %s', async (code, expected) => { api.bindAiHardwareDevice.mockRejectedValueOnce(new AiHardwareApiError({ status: 409, code, message: 'secret detail activation=031425', retryAfterSeconds: 7, })); render(); fireEvent.click(await screen.findByRole('button', { name: '绑定设备' })); fireEvent.change(screen.getByLabelText('6 位激活码'), { target: { value: '031425' } }); fireEvent.click(screen.getByRole('button', { name: '绑定' })); expect(await screen.findByRole('alert')).toHaveTextContent(expected); expect(screen.queryByText(/secret detail|031425/)).not.toBeInTheDocument(); }); it('shows retry timing for an operation still in progress', async () => { api.bindAiHardwareDevice.mockRejectedValueOnce(new AiHardwareApiError({ status: 409, code: 'ai_hardware_operation_in_progress', message: 'private', retryAfterSeconds: 7, })); render(); fireEvent.click(await screen.findByRole('button', { name: '绑定设备' })); fireEvent.change(screen.getByLabelText('6 位激活码'), { target: { value: '031425' } }); fireEvent.click(screen.getByRole('button', { name: '绑定' })); expect(await screen.findByRole('alert')).toHaveTextContent('7 秒后重试'); }); it('reuses the server operation id only when retrying the same binding body', async () => { api.bindAiHardwareDevice .mockRejectedValueOnce(new AiHardwareApiError({ status: 409, code: 'ai_hardware_operation_in_progress', message: 'private', retryable: true, operationId: '123e4567-e89b-42d3-a456-426614174000', })) .mockResolvedValueOnce(device); render(); fireEvent.click(await screen.findByRole('button', { name: '绑定设备' })); const input = screen.getByLabelText('6 位激活码'); fireEvent.change(input, { target: { value: '031425' } }); fireEvent.click(screen.getByRole('button', { name: '绑定' })); await waitFor(() => expect(input).toHaveValue('')); fireEvent.change(input, { target: { value: '031425' } }); fireEvent.click(screen.getByRole('button', { name: '绑定' })); await waitFor(() => expect(api.bindAiHardwareDevice).toHaveBeenCalledTimes(2)); expect(api.bindAiHardwareDevice.mock.calls[1]).toEqual([ '031425', agentOne.id, { operationId: '123e4567-e89b-42d3-a456-426614174000' }, ]); }); it('does not retain an activation code and starts a fresh operation for a different code', async () => { const operationId = '123e4567-e89b-42d3-a456-426614174000'; api.bindAiHardwareDevice .mockRejectedValueOnce(new AiHardwareApiError({ status: 409, code: 'ai_hardware_operation_in_progress', message: 'contains 031425', retryable: true, operationId, })) .mockResolvedValueOnce(device); render(); fireEvent.click(await screen.findByRole('button', { name: '绑定设备' })); const input = screen.getByLabelText('6 位激活码'); fireEvent.change(input, { target: { value: '031425' } }); fireEvent.click(screen.getByRole('button', { name: '绑定' })); await waitFor(() => expect(input).toHaveValue('')); expect(screen.queryByText(/031425/)).not.toBeInTheDocument(); fireEvent.change(input, { target: { value: '654321' } }); fireEvent.click(screen.getByRole('button', { name: '绑定' })); await waitFor(() => expect(api.bindAiHardwareDevice).toHaveBeenCalledTimes(2)); expect(api.bindAiHardwareDevice.mock.calls[1]).toEqual(['654321', agentOne.id]); await waitFor(() => expect(input).toHaveValue('')); expect(screen.queryByText(/031425|654321/)).not.toBeInTheDocument(); }); it('keeps an unset chat history unchanged when saving without edits', async () => { api.getAiHardwareAgentConfiguration.mockResolvedValueOnce({ data: { ...configuration, chat_history_conf: null }, revision: 4, }); render(); await openConfigurationEditor(); expect(screen.getByLabelText('聊天记录')).toHaveValue(''); fireEvent.click(screen.getByRole('button', { name: '保存' })); await waitFor(() => expect(screen.queryByRole('heading', { name: '编辑智能体配置' })).not.toBeInTheDocument()); expect(api.updateAiHardwareAgentConfiguration).not.toHaveBeenCalled(); }); it('clears the previous configuration while a newly selected agent is loading', async () => { const nextConfiguration = deferred<{ data: AiHardwareAgentConfiguration; revision: number }>(); api.getAiHardwareAgentConfiguration .mockResolvedValueOnce({ data: configuration, revision: 4 }) .mockReturnValueOnce(nextConfiguration.promise); render(); expect(await screen.findByText('保持简洁')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: /书房助手/ })); expect(screen.queryByText('保持简洁')).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: '编辑配置' })).toBeDisabled(); expect(screen.getByText('正在读取配置')).toBeInTheDocument(); }); it('stops loading and lets the user retry after configuration loading fails', async () => { api.getAiHardwareOverview.mockResolvedValueOnce({ status: 'active', agents: [agentOne], devices: [device], }); api.getAiHardwareAgentConfiguration .mockRejectedValueOnce(new AiHardwareApiError({ status: 502, code: 'xiaozhi_hardware_unavailable', message: 'private provider details', retryable: true, })) .mockResolvedValueOnce({ data: configuration, revision: 4 }); render(); expect(await screen.findByRole('alert')).toHaveTextContent('无法读取智能体配置'); expect(screen.queryByText('正在读取配置')).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '重试读取配置' })); await waitFor(() => expect(api.getAiHardwareAgentConfiguration).toHaveBeenCalledTimes(2)); expect(await screen.findByText('保持简洁')).toBeInTheDocument(); expect(screen.getByRole('button', { name: '编辑配置' })).toBeEnabled(); }); it('loads the current assignment revision before reassigning a device', async () => { render(); fireEvent.click(await screen.findByRole('button', { name: '重新指派' })); expect(await screen.findByRole('heading', { name: '重新指派设备' })).toBeInTheDocument(); fireEvent.change(screen.getByLabelText('智能体'), { target: { value: agentTwo.id } }); fireEvent.click(screen.getByRole('button', { name: '确认指派' })); await waitFor(() => expect(api.updateAiHardwareAssignment).toHaveBeenCalledWith(device.id, 3, agentTwo.id)); }); it('rebases only locally edited fields after a configuration conflict', async () => { const freshConfiguration = { ...configuration, config_revision: 5, language: 'English', }; api.getAiHardwareAgentConfiguration .mockResolvedValueOnce({ data: configuration, revision: 4 }) .mockResolvedValueOnce({ data: freshConfiguration, revision: 5 }); api.updateAiHardwareAgentConfiguration.mockRejectedValueOnce(new AiHardwareApiError({ status: 409, code: 'ai_hardware_revision_conflict', message: 'server details', })); render(); await openConfigurationEditor(); fireEvent.change(screen.getByLabelText('系统提示'), { target: { value: '保留的本地提示' } }); fireEvent.click(screen.getByRole('button', { name: '保存' })); expect(await screen.findByRole('alert')).toHaveTextContent('内容已在其他位置更新'); expect(screen.getByLabelText('系统提示')).toHaveValue('保留的本地提示'); expect(screen.getByLabelText('语言')).toHaveValue('English'); expect(api.getAiHardwareAgentConfiguration).toHaveBeenCalledTimes(2); fireEvent.click(screen.getByRole('button', { name: '保存' })); await waitFor(() => expect(api.updateAiHardwareAgentConfiguration).toHaveBeenCalledTimes(2)); expect(api.updateAiHardwareAgentConfiguration.mock.calls[1]).toEqual([ agentOne.id, 5, { system_prompt: '保留的本地提示' }, ]); }); });