959 lines
51 KiB
TypeScript
959 lines
51 KiB
TypeScript
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: '保留的本地提示' },
|
||
]);
|
||
});
|
||
});
|