Files
makelore/tests/unit/ai-hardware-page.test.tsx

409 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import { 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<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => { resolve = done; });
return { promise, resolve };
}
async function openConfigurationEditor(): Promise<void> {
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<AiHardwareOverview>();
api.getAiHardwareOverview.mockReturnValueOnce(pending.promise);
const first = render(<AiHardware />);
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(<AiHardware />);
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(<AiHardware />);
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(<AiHardware />);
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(<AiHardware />);
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(<AiHardware />);
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(<AiHardware />);
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('音量 (-100100)'), { 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<AiHardwareOverview>();
api.recoverAiHardwareCredential.mockReturnValueOnce(pending.promise);
render(<AiHardware />);
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(<AiHardware />);
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(<AiHardware />);
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(<AiHardware />);
await openConfigurationEditor();
fireEvent.click(screen.getByText('高级设置'));
const values: Record<string, string> = {
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(<AiHardware />);
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(<AiHardware />);
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(<AiHardware />);
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(<AiHardware />);
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(<AiHardware />);
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(<AiHardware />);
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('loads the current assignment revision before reassigning a device', async () => {
render(<AiHardware />);
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(<AiHardware />);
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: '保留的本地提示' },
]);
});
});