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

959 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AiHardware } from '@/pages/AiHardware';
import {
AiHardwareApiError,
type AiHardwareAgentConfiguration,
type AiHardwareConfigurationCatalog,
type AiHardwareOverview,
} from '@/lib/ai-hardware';
import { announceAiHardwareNavigation } from '@/lib/ai-hardware-navigation';
const api = vi.hoisted(() => ({
getAiHardwareOverview: vi.fn(),
getAiHardwareProvisioningCapabilities: vi.fn(),
scanAiHardwareProvisioningHotspots: vi.fn(),
connectAiHardwareProvisioningHotspot: vi.fn(),
openAiHardwareProvisioningPortal: vi.fn(),
recoverAiHardwareCredential: vi.fn(),
createAiHardwareAgent: vi.fn(),
bindAiHardwareDevice: vi.fn(),
getAiHardwareAgentConfiguration: vi.fn(),
getAiHardwareConfigurationCatalog: 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,
};
const catalog: AiHardwareConfigurationCatalog = {
schema_version: 1,
models: [
['ASR', 'asr-old', '旧语音识别'], ['ASR', 'asr-new', '新语音识别'],
['VAD', 'vad-old', '旧活动检测'], ['VAD', 'vad-new', '新活动检测'],
['LLM', 'llm-old', '旧语言模型'], ['LLM', 'llm-new', '新语言模型'],
['LLM', 'slm-new', '轻量语言模型'], ['VLLM', 'vllm-old', '旧视觉模型'],
['TTS', 'tts-old', '旧语音合成'], ['TTS', 'tts-new', '新语音合成'],
['Memory', 'mem-old', '旧记忆'], ['Memory', 'mem-new', '新记忆'],
['Intent', 'intent-old', '旧意图'], ['Intent', 'intent-new', '新意图'],
].map(([model_type, model_id, model_name]) => ({
model_type, model_id, model_name, supports_function_call: null,
})) as AiHardwareConfigurationCatalog['models'],
voices: [{
tts_model_id: 'tts-new', voice_id: 'voice-new', voice_name: '龙小夏',
languages: ['zh-CN', 'en-US'], is_clone: false,
}],
};
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => { resolve = done; });
return { promise, resolve };
}
function selectAgent(agentId: string): void {
act(() => announceAiHardwareNavigation({ agentId }));
}
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: '编辑智能体配置' });
}
async function openGuidedHotspotStep(): Promise<void> {
fireEvent.click(await screen.findByRole('button', { name: '为当前智能体绑定设备' }));
fireEvent.click(await screen.findByRole('button', { name: '开始引导配网' }));
fireEvent.click(screen.getByRole('button', { name: '机器人已进入配网模式' }));
await screen.findByRole('heading', { name: '连接设备热点' });
}
describe('AI hardware page', () => {
beforeEach(() => {
window.history.replaceState({}, '', '/ai-hardware');
api.getAiHardwareOverview.mockResolvedValue(activeOverview);
api.getAiHardwareProvisioningCapabilities.mockResolvedValue({ guidedHotspotBinding: false });
api.scanAiHardwareProvisioningHotspots.mockResolvedValue({ platform: 'windows', hotspots: [] });
api.connectAiHardwareProvisioningHotspot.mockResolvedValue({ connected: true, candidateId: 'candidate-a', ssid: 'Xiaozhi-A' });
api.openAiHardwareProvisioningPortal.mockResolvedValue({ opened: true });
api.recoverAiHardwareCredential.mockResolvedValue({ status: 'active', agents: [], devices: [] });
api.getAiHardwareAgentConfiguration.mockResolvedValue({ data: configuration, revision: 4 });
api.getAiHardwareConfigurationCatalog.mockResolvedValue(catalog);
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('presents basic settings as readable summary cards and a bounded prompt preview', async () => {
render(<AiHardware />);
const settings = await screen.findByTestId('ai-hardware-basic-settings');
expect(within(settings).getByTestId('ai-hardware-basic-settings-language')).toHaveTextContent('中文');
expect(within(settings).getByTestId('ai-hardware-basic-settings-language')).toHaveTextContent('zh-CN');
expect(within(settings).getByTestId('ai-hardware-basic-settings-voice')).toHaveTextContent('voice-a');
expect(within(settings).getByTestId('ai-hardware-system-prompt-preview')).toHaveTextContent('保持简洁');
expect(within(settings).getByText('配置版本 r4')).toBeInTheDocument();
});
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.findByText(/客厅助手.*台设备已绑定/)).toBeInTheDocument();
});
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(screen.getByLabelText('6 位激活码')).toHaveValue(''));
expect(screen.queryByText(new RegExp(secret))).not.toBeInTheDocument();
expect(screen.getByRole('alert')).toHaveTextContent('激活码无效');
});
it('keeps the existing direct-code binding experience when guided provisioning is disabled', async () => {
render(<AiHardware />);
fireEvent.click(await screen.findByRole('button', { name: '为当前智能体绑定设备' }));
expect(screen.getByRole('heading', { name: '输入 6 位激活码' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: '开始引导配网' })).not.toBeInTheDocument();
expect(api.openAiHardwareProvisioningPortal).not.toHaveBeenCalled();
expect(api.scanAiHardwareProvisioningHotspots).not.toHaveBeenCalled();
});
it('automatically scans, lists candidates, and advances only after an explicit verified connection', async () => {
api.getAiHardwareProvisioningCapabilities.mockResolvedValueOnce({ guidedHotspotBinding: true });
api.scanAiHardwareProvisioningHotspots.mockResolvedValueOnce({
platform: 'windows',
hotspots: [
{ candidateId: 'candidate-a', ssid: 'Xiaozhi-A', signalPercent: 82, connected: false },
{ candidateId: 'candidate-b', ssid: 'Xiaozhi-B', signalPercent: 47, connected: true },
],
});
api.connectAiHardwareProvisioningHotspot.mockResolvedValueOnce({
connected: true, candidateId: 'candidate-b', ssid: 'Xiaozhi-B',
});
render(<AiHardware />);
await waitFor(() => expect(api.getAiHardwareProvisioningCapabilities).toHaveBeenCalled());
await openGuidedHotspotStep();
await waitFor(() => expect(api.scanAiHardwareProvisioningHotspots).toHaveBeenCalledOnce());
expect(await screen.findByRole('button', { name: '选择 Xiaozhi-A' })).toHaveTextContent('信号 82%');
expect(screen.getByRole('button', { name: '选择 Xiaozhi-B' })).toHaveTextContent('已连接');
expect(screen.getByRole('button', { name: '连接所选热点' })).toBeDisabled();
expect(api.connectAiHardwareProvisioningHotspot).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '选择 Xiaozhi-B' }));
const connectButton = screen.getByRole('button', { name: '连接所选热点' });
fireEvent.click(connectButton);
fireEvent.click(connectButton);
await waitFor(() => expect(api.connectAiHardwareProvisioningHotspot).toHaveBeenCalledOnce());
expect(api.connectAiHardwareProvisioningHotspot).toHaveBeenCalledWith('candidate-b');
expect(await screen.findByRole('heading', { name: '配置机器人 Wi-Fi' })).toBeInTheDocument();
const cancelButton = screen.getByRole('button', { name: '取消' });
expect(cancelButton).toBeEnabled();
fireEvent.click(cancelButton);
await waitFor(() => expect(screen.queryByRole('heading', { name: '配置机器人 Wi-Fi' })).not.toBeInTheDocument());
});
it('shows an empty scan result and supports rescanning without selecting a hotspot', async () => {
api.getAiHardwareProvisioningCapabilities.mockResolvedValueOnce({ guidedHotspotBinding: true });
api.scanAiHardwareProvisioningHotspots
.mockResolvedValueOnce({ platform: 'windows', hotspots: [] })
.mockResolvedValueOnce({
platform: 'windows',
hotspots: [{ candidateId: 'candidate-new', ssid: 'Xiaozhi-New', signalPercent: 61, connected: false }],
});
render(<AiHardware />);
await waitFor(() => expect(api.getAiHardwareProvisioningCapabilities).toHaveBeenCalled());
await openGuidedHotspotStep();
expect(await screen.findByText('没有发现设备热点')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '连接所选热点' })).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: '重新扫描' }));
expect(await screen.findByRole('button', { name: '选择 Xiaozhi-New' })).toHaveTextContent('信号 61%');
expect(api.scanAiHardwareProvisioningHotspots).toHaveBeenCalledTimes(2);
});
it.each([
['AI_HARDWARE_HOTSPOT_PERMISSION_DENIED', '没有检查附近热点所需的系统权限'],
['AI_HARDWARE_HOTSPOT_UNSUPPORTED', '当前系统不支持在 Makelore 内连接热点'],
['AI_HARDWARE_HOTSPOT_BUSY', '系统正在处理其他 Wi-Fi 操作'],
['AI_HARDWARE_HOTSPOT_SCAN_FAILED', '暂时无法检查设备热点'],
])('shows a safe %s scan error and preserves the manual fallback', async (code, expected) => {
api.getAiHardwareProvisioningCapabilities.mockResolvedValueOnce({ guidedHotspotBinding: true });
api.scanAiHardwareProvisioningHotspots.mockRejectedValueOnce(new AiHardwareApiError({
status: 200, code, message: 'native adapter secret path',
}));
render(<AiHardware />);
await waitFor(() => expect(api.getAiHardwareProvisioningCapabilities).toHaveBeenCalled());
await openGuidedHotspotStep();
expect(await screen.findByRole('alert')).toHaveTextContent(expected);
expect(screen.queryByText(/native adapter secret path/)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '电脑已连接设备热点' }));
expect(screen.getByRole('heading', { name: '配置机器人 Wi-Fi' })).toBeInTheDocument();
});
it.each([
['AI_HARDWARE_HOTSPOT_BUSY', '系统正在处理其他 Wi-Fi 操作'],
['AI_HARDWARE_HOTSPOT_CANDIDATE_EXPIRED', '这个热点候选已失效'],
['AI_HARDWARE_HOTSPOT_CONNECT_FAILED', '没有成功连接并验证'],
])('shows a safe %s connection error and allows retrying the selected candidate', async (code, expected) => {
api.getAiHardwareProvisioningCapabilities.mockResolvedValueOnce({ guidedHotspotBinding: true });
api.scanAiHardwareProvisioningHotspots.mockResolvedValueOnce({
platform: 'macos',
hotspots: [{ candidateId: 'candidate-a', ssid: 'Xiaozhi-A', signalPercent: 70, connected: false }],
});
api.connectAiHardwareProvisioningHotspot.mockRejectedValueOnce(new AiHardwareApiError({
status: 200,
code,
message: 'native command secret',
}));
render(<AiHardware />);
await waitFor(() => expect(api.getAiHardwareProvisioningCapabilities).toHaveBeenCalled());
await openGuidedHotspotStep();
fireEvent.click(await screen.findByRole('button', { name: '选择 Xiaozhi-A' }));
fireEvent.click(screen.getByRole('button', { name: '连接所选热点' }));
expect(await screen.findByRole('alert')).toHaveTextContent(expected);
expect(screen.queryByText(/native command secret/)).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '连接所选热点' })).toBeEnabled();
});
it('ignores a late scan after dismiss and reopen', async () => {
const staleScan = deferred<{
platform: 'windows';
hotspots: Array<{ candidateId: string; ssid: string; signalPercent: number; connected: boolean }>;
}>();
api.getAiHardwareProvisioningCapabilities.mockResolvedValueOnce({ guidedHotspotBinding: true });
api.scanAiHardwareProvisioningHotspots
.mockReturnValueOnce(staleScan.promise)
.mockResolvedValueOnce({
platform: 'windows',
hotspots: [{ candidateId: 'fresh', ssid: 'Xiaozhi-Fresh', signalPercent: 75, connected: false }],
});
render(<AiHardware />);
await waitFor(() => expect(api.getAiHardwareProvisioningCapabilities).toHaveBeenCalled());
await openGuidedHotspotStep();
expect(screen.getByText(/正在检查附近的设备热点/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '取消' }));
await openGuidedHotspotStep();
expect(await screen.findByRole('button', { name: '选择 Xiaozhi-Fresh' })).toBeInTheDocument();
staleScan.resolve({
platform: 'windows',
hotspots: [{ candidateId: 'stale', ssid: 'Xiaozhi-Stale', signalPercent: 99, connected: false }],
});
await waitFor(() => expect(screen.queryByRole('button', { name: '选择 Xiaozhi-Stale' })).not.toBeInTheDocument());
expect(screen.getByRole('button', { name: '选择 Xiaozhi-Fresh' })).toBeInTheDocument();
});
it('shows and binds devices for the currently selected agent', async () => {
const agentOneDevice = { id: 'device-one', agent_id: agentOne.id, assignment_revision: 3 };
const agentTwoDevice = { id: 'device-two', agent_id: agentTwo.id, assignment_revision: 1 };
api.getAiHardwareOverview.mockResolvedValueOnce({
status: 'active',
agents: [agentOne, agentTwo],
devices: [agentOneDevice, agentTwoDevice],
});
api.bindAiHardwareDevice.mockResolvedValueOnce(agentTwoDevice);
render(<AiHardware />);
expect(await screen.findByText(/客厅助手.*1 台设备已绑定/)).toBeInTheDocument();
expect(await screen.findByText('设备 device-one')).toBeInTheDocument();
expect(screen.queryByText('设备 device-two')).not.toBeInTheDocument();
selectAgent(agentTwo.id);
expect(await screen.findByText('设备 device-two')).toBeInTheDocument();
expect(screen.queryByText('设备 device-one')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '为当前智能体绑定设备' }));
const bindingDialog = screen.getByRole('dialog');
expect(within(bindingDialog).queryByLabelText('智能体')).not.toBeInTheDocument();
expect(within(bindingDialog).getByText('绑定到当前智能体')).toBeInTheDocument();
expect(within(bindingDialog).getByText('书房助手')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('6 位激活码'), { target: { value: '654321' } });
fireEvent.click(screen.getByRole('button', { name: '绑定' }));
await waitFor(() => expect(api.bindAiHardwareDevice).toHaveBeenCalledWith('654321', agentTwo.id));
});
it('restores the selected agent from the URL and keeps navigation state shareable', async () => {
const agentTwoDevice = { id: 'device-two', agent_id: agentTwo.id, assignment_revision: 1 };
window.history.replaceState({}, '', `/ai-hardware?agent=${agentTwo.id}`);
api.getAiHardwareOverview.mockResolvedValueOnce({
status: 'active',
agents: [agentOne, agentTwo],
devices: [device, agentTwoDevice],
});
render(<AiHardware />);
expect(await screen.findByText('设备 device-two')).toBeInTheDocument();
expect(window.location.search).toBe(`?agent=${agentTwo.id}`);
selectAgent(agentOne.id);
expect(await screen.findByText(/设备 device.*al-1/)).toBeInTheDocument();
expect(window.location.search).toBe(`?agent=${agentOne.id}`);
});
it('guides the existing hotspot flow, locks navigation while binding, and avoids online claims', async () => {
api.getAiHardwareProvisioningCapabilities.mockResolvedValueOnce({ guidedHotspotBinding: true });
const binding = deferred<typeof device>();
api.bindAiHardwareDevice.mockReturnValueOnce(binding.promise);
render(<AiHardware />);
await waitFor(() => expect(api.getAiHardwareProvisioningCapabilities).toHaveBeenCalled());
fireEvent.click(await screen.findByRole('button', { name: '为当前智能体绑定设备' }));
fireEvent.click(await screen.findByRole('button', { name: '开始引导配网' }));
expect(screen.getByText(/设备热点没有加密保护/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '机器人已进入配网模式' }));
expect(screen.getByText(/设备热点未加密/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '上一步' }));
expect(screen.getByRole('heading', { name: '准备机器人' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '机器人已进入配网模式' }));
fireEvent.click(screen.getByRole('button', { name: '电脑已连接设备热点' }));
expect(screen.getByText(/Makelore 不读取或保存/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '打开设备配网页面' }));
await waitFor(() => expect(api.openAiHardwareProvisioningPortal).toHaveBeenCalledOnce());
fireEvent.click(screen.getByRole('button', { name: '我已完成设备配网' }));
expect(screen.getByText(/不会撤销机器人已经保存的 Wi-Fi 设置/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '电脑已恢复联网' }));
fireEvent.change(screen.getByLabelText('6 位激活码'), { target: { value: '031425' } });
fireEvent.click(screen.getByRole('button', { name: '绑定' }));
expect(await screen.findByRole('heading', { name: '正在绑定设备' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: '取消' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '上一步' })).not.toBeInTheDocument();
binding.resolve(device);
expect(await screen.findByRole('heading', { name: '绑定成功' })).toBeInTheDocument();
expect(screen.getByText('绑定成功不代表设备已经上线,请等待机器人完成连接。')).toBeInTheDocument();
expect(screen.queryByText(/设备已就绪/)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '完成' }));
await waitFor(() => expect(screen.queryByRole('heading', { name: '绑定成功' })).not.toBeInTheDocument());
});
it('supports every legal guided back transition and the direct-path return to the chooser', async () => {
api.getAiHardwareProvisioningCapabilities.mockResolvedValueOnce({ guidedHotspotBinding: true });
render(<AiHardware />);
await waitFor(() => expect(api.getAiHardwareProvisioningCapabilities).toHaveBeenCalled());
fireEvent.click(await screen.findByRole('button', { name: '为当前智能体绑定设备' }));
fireEvent.click(screen.getByRole('button', { name: '我已有 6 位激活码' }));
expect(screen.getByRole('heading', { name: '输入 6 位激活码' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '上一步' }));
expect(screen.getByRole('heading', { name: '绑定机器人设备' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '开始引导配网' }));
fireEvent.click(screen.getByRole('button', { name: '上一步' }));
expect(screen.getByRole('heading', { name: '绑定机器人设备' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '开始引导配网' }));
fireEvent.click(screen.getByRole('button', { name: '机器人已进入配网模式' }));
fireEvent.click(screen.getByRole('button', { name: '上一步' }));
expect(screen.getByRole('heading', { name: '准备机器人' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '机器人已进入配网模式' }));
fireEvent.click(screen.getByRole('button', { name: '电脑已连接设备热点' }));
fireEvent.click(screen.getByRole('button', { name: '上一步' }));
expect(screen.getByRole('heading', { name: '连接设备热点' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '电脑已连接设备热点' }));
fireEvent.click(screen.getByRole('button', { name: '打开设备配网页面' }));
fireEvent.click(await screen.findByRole('button', { name: '我已完成设备配网' }));
fireEvent.click(screen.getByRole('button', { name: '上一步' }));
expect(screen.getByRole('heading', { name: '配置机器人 Wi-Fi' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '我已完成设备配网' }));
fireEvent.click(screen.getByRole('button', { name: '电脑已恢复联网' }));
fireEvent.click(screen.getByRole('button', { name: '上一步' }));
expect(screen.getByRole('heading', { name: '恢复电脑网络' })).toBeInTheDocument();
});
it('cancels before portal handoff and starts a fresh chooser state', async () => {
api.getAiHardwareProvisioningCapabilities.mockResolvedValueOnce({ guidedHotspotBinding: true });
render(<AiHardware />);
await waitFor(() => expect(api.getAiHardwareProvisioningCapabilities).toHaveBeenCalled());
fireEvent.click(await screen.findByRole('button', { name: '为当前智能体绑定设备' }));
fireEvent.click(screen.getByRole('button', { name: '开始引导配网' }));
fireEvent.click(screen.getByRole('button', { name: '机器人已进入配网模式' }));
fireEvent.click(screen.getByRole('button', { name: '取消' }));
fireEvent.click(screen.getByRole('button', { name: '为当前智能体绑定设备' }));
expect(screen.getByRole('heading', { name: '绑定机器人设备' })).toBeInTheDocument();
expect(api.openAiHardwareProvisioningPortal).not.toHaveBeenCalled();
});
it('keeps the guided session mounted while the native portal opener is pending', async () => {
const opening = deferred<{ opened: true }>();
api.getAiHardwareProvisioningCapabilities.mockResolvedValueOnce({ guidedHotspotBinding: true });
api.openAiHardwareProvisioningPortal.mockReturnValueOnce(opening.promise);
render(<AiHardware />);
await waitFor(() => expect(api.getAiHardwareProvisioningCapabilities).toHaveBeenCalled());
fireEvent.click(await screen.findByRole('button', { name: '为当前智能体绑定设备' }));
fireEvent.click(screen.getByRole('button', { name: '开始引导配网' }));
fireEvent.click(screen.getByRole('button', { name: '机器人已进入配网模式' }));
fireEvent.click(screen.getByRole('button', { name: '电脑已连接设备热点' }));
fireEvent.click(screen.getByRole('button', { name: '打开设备配网页面' }));
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' });
expect(screen.getByRole('heading', { name: '配置机器人 Wi-Fi' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '打开设备配网页面' })).toBeDisabled();
opening.resolve({ opened: true });
fireEvent.click(await screen.findByRole('button', { name: '我已完成设备配网' }));
fireEvent.click(screen.getByRole('button', { name: '取消' }));
fireEvent.click(screen.getByRole('button', { name: '为当前智能体绑定设备' }));
expect(screen.getByRole('heading', { name: '绑定机器人设备' })).toBeInTheDocument();
});
it('shows a safe portal error and clears guided state when cancelled after portal handoff', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
api.getAiHardwareProvisioningCapabilities.mockResolvedValueOnce({ guidedHotspotBinding: true });
api.openAiHardwareProvisioningPortal
.mockRejectedValueOnce(new AiHardwareApiError({
status: 502,
code: 'AI_HARDWARE_PORTAL_OPEN_FAILED',
message: 'native secret path',
}))
.mockResolvedValueOnce({ opened: true });
render(<AiHardware />);
await waitFor(() => expect(api.getAiHardwareProvisioningCapabilities).toHaveBeenCalled());
fireEvent.click(await screen.findByRole('button', { name: '为当前智能体绑定设备' }));
fireEvent.click(screen.getByRole('button', { name: '开始引导配网' }));
fireEvent.click(screen.getByRole('button', { name: '机器人已进入配网模式' }));
fireEvent.click(screen.getByRole('button', { name: '电脑已连接设备热点' }));
fireEvent.click(screen.getByRole('button', { name: '打开设备配网页面' }));
expect(await screen.findByRole('alert')).toHaveTextContent('无法打开设备配网页面');
expect(screen.queryByText(/native secret path/)).not.toBeInTheDocument();
expect(screen.getByText('http://192.168.4.1/')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '复制地址' }));
await waitFor(() => expect(writeText).toHaveBeenCalledWith('http://192.168.4.1/'));
expect(screen.getByRole('status')).toHaveTextContent('固定地址已复制');
fireEvent.click(screen.getByRole('button', { name: '打开设备配网页面' }));
await waitFor(() => expect(api.openAiHardwareProvisioningPortal).toHaveBeenCalledTimes(2));
fireEvent.click(screen.getByRole('button', { name: '我已完成设备配网' }));
fireEvent.click(screen.getByRole('button', { name: '取消' }));
fireEvent.click(screen.getByRole('button', { name: '为当前智能体绑定设备' }));
expect(screen.getByRole('button', { name: '开始引导配网' })).toBeInTheDocument();
expect(screen.queryByText(/不会撤销机器人已经保存的 Wi-Fi 设置/)).not.toBeInTheDocument();
});
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('声音音色'), { target: { value: '' } });
fireEvent.change(screen.getByLabelText('音量'), { 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();
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('uses dynamic model, language, and voice choices instead of free-form IDs', async () => {
api.getAiHardwareAgentConfiguration.mockResolvedValueOnce({
data: { ...configuration, tts_model_id: 'tts-old', tts_voice_id: 'legacy-voice' },
revision: 4,
});
render(<AiHardware />);
await openConfigurationEditor();
expect(api.getAiHardwareConfigurationCatalog).toHaveBeenCalledWith('tts-old');
expect(screen.getByLabelText('语音合成 (TTS)')).toHaveValue('tts-old');
expect(screen.getByRole('option', { name: '旧语音合成' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: '当前值目录中不可用legacy-voice' })).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('语音合成 (TTS)'), { target: { value: 'tts-new' } });
await waitFor(() => expect(api.getAiHardwareConfigurationCatalog).toHaveBeenLastCalledWith('tts-new'));
expect(screen.getByRole('option', { name: '龙小夏' })).toBeInTheDocument();
expect(screen.getAllByRole('option', { name: 'zh-CN' }).length).toBeGreaterThan(0);
});
it('uses bounded sliders while preserving nullable provider defaults', async () => {
render(<AiHardware />);
await openConfigurationEditor();
const volume = screen.getByLabelText('音量');
expect(volume).toHaveAttribute('type', 'range');
expect(volume).toHaveAttribute('min', '-100');
expect(volume).toHaveAttribute('max', '100');
expect(screen.getByRole('button', { name: '音调使用默认值' })).toHaveClass('min-h-10');
fireEvent.change(volume, { target: { value: '24' } });
fireEvent.click(screen.getByRole('button', { name: '音调使用默认值' }));
fireEvent.click(screen.getByRole('button', { name: '保存' }));
await waitFor(() => expect(api.updateAiHardwareAgentConfiguration).toHaveBeenCalled());
expect(api.updateAiHardwareAgentConfiguration.mock.calls[0][2]).toMatchObject({
tts_volume: 24,
clear_fields: expect.arrayContaining(['tts_pitch']),
});
});
it('keeps current values when the catalog is unavailable and offers a safe retry', async () => {
api.getAiHardwareConfigurationCatalog
.mockRejectedValueOnce(new Error('provider token=secret'))
.mockResolvedValueOnce(catalog);
render(<AiHardware />);
await openConfigurationEditor();
expect(await screen.findByRole('alert')).toHaveTextContent('当前配置会保持不变');
expect(screen.getByRole('option', { name: '当前值目录中不可用voice-a' })).toBeInTheDocument();
expect(screen.queryByText(/provider token|secret/)).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '重新加载选项' })).toHaveClass('min-h-10');
fireEvent.click(screen.getByRole('button', { name: '重新加载选项' }));
await waitFor(() => expect(api.getAiHardwareConfigurationCatalog).toHaveBeenCalledTimes(2));
expect(await screen.findByRole('option', { name: '旧语音合成' })).toBeInTheDocument();
});
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.each([
['ai_hardware_device_already_bound', '设备已被绑定'],
['ai_hardware_state_conflict', '设备状态已变化'],
])('refreshes overview after the binding result %s', async (code, message) => {
api.bindAiHardwareDevice.mockRejectedValueOnce(new AiHardwareApiError({
status: 409,
code,
message: 'private upstream state',
}));
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(message);
await waitFor(() => expect(api.getAiHardwareOverview).toHaveBeenCalledTimes(2));
expect(screen.getByRole('heading', { name: '输入 6 位激活码' })).toBeInTheDocument();
expect(screen.queryByText(/private upstream state/)).not.toBeInTheDocument();
});
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: '绑定' }));
const retryInput = await screen.findByLabelText('6 位激活码');
expect(retryInput).toHaveValue('');
fireEvent.change(retryInput, { 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('refreshes overview on remount and never reuses a binding operation identity across restart', async () => {
const operationId = '123e4567-e89b-42d3-a456-426614174000';
api.bindAiHardwareDevice
.mockRejectedValueOnce(new AiHardwareApiError({
status: 409,
code: 'ai_hardware_operation_in_progress',
message: 'private',
retryable: true,
operationId,
}))
.mockResolvedValueOnce(device);
const first = render(<AiHardware />);
fireEvent.click(await screen.findByRole('button', { name: '为当前智能体绑定设备' }));
fireEvent.change(screen.getByLabelText('6 位激活码'), { target: { value: '031425' } });
fireEvent.click(screen.getByRole('button', { name: '绑定' }));
await screen.findByLabelText('6 位激活码');
first.unmount();
render(<AiHardware />);
fireEvent.click(await screen.findByRole('button', { name: '为当前智能体绑定设备' }));
fireEvent.change(screen.getByLabelText('6 位激活码'), { 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(api.getAiHardwareOverview).toHaveBeenCalledTimes(3));
});
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: '绑定' }));
const nextInput = await screen.findByLabelText('6 位激活码');
expect(nextInput).toHaveValue('');
expect(screen.queryByText(/031425/)).not.toBeInTheDocument();
fireEvent.change(nextInput, { 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 screen.findByRole('heading', { name: '绑定成功' });
expect(screen.queryByText(/031425|654321/)).not.toBeInTheDocument();
});
it('drops an invalid-code operation identity even if the error is incorrectly marked retryable', async () => {
const operationId = '123e4567-e89b-42d3-a456-426614174000';
api.bindAiHardwareDevice
.mockRejectedValueOnce(new AiHardwareApiError({
status: 422,
code: 'ai_hardware_activation_code_invalid',
message: 'private',
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: '绑定' }));
const retryInput = await screen.findByLabelText('6 位激活码');
expect(retryInput).toHaveValue('');
fireEvent.change(retryInput, { 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]);
});
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();
selectAgent(agentTwo.id);
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(<AiHardware />);
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(<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: '保留的本地提示' },
]);
});
});