Files
MailUI4Agents/client/electron/test/components/AddressInput.test.tsx

265 lines
9.2 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 { describe, expect, it, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import AddressInput from '../../src/components/AddressInput';
import * as api from '../../src/api/client';
import type { SessionCandidate } from '../../src/types';
/**
* 三段式地址补全。
*
* 每条断言都对应一个真实存在过或很容易犯的错:
* - 问错层(写了 @ 还在问 name 的候选)
* - 过滤后 suggestions 与 candidates 错位,标题挂到别的别名上
* - 选中 name 后没自动补 `@`,人得自己敲
* - session 段选完还开着下拉,挡住下面的输入框
*/
/** 造一个 suggestAddress 的响应。candidates 与 suggestions 必须同序同长。 */
function res(
kind: 'name' | 'path' | 'session',
suggestions: string[],
candidates?: Partial<SessionCandidate>[]
) {
return {
kind,
suggestions,
candidates: candidates?.map((c, i) => ({
alias: suggestions[i],
title: '',
source: 'mail' as const,
unread: 0,
...c
}))
};
}
/** 渲染一个受控的 AddressInput返回 input 与「当前值」读取器。 */
function setup(initial = '') {
let current = initial;
const onChange = vi.fn((v: string) => {
current = v;
rerender();
});
const { rerender: rr } = render(
React.createElement(AddressInput, { value: current, onChange })
);
function rerender() {
rr(React.createElement(AddressInput, { value: current, onChange }));
}
return {
onChange,
value: () => current,
input: () => screen.getByRole('textbox') as HTMLInputElement
};
}
describe('AddressInput 三段式补全', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('还没写 @ 时问 name 层', async () => {
const spy = vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('name', ['dsh', 'opencode']));
const { input } = setup();
await userEvent.click(input());
await userEvent.type(input(), 'ds');
await waitFor(() => expect(spy).toHaveBeenCalled());
// 问 name 层时不带任何参数
expect(spy.mock.calls[spy.mock.calls.length - 1]).toEqual([]);
});
it('写了 @ 没写 . 时带 name 去问 path 层', async () => {
const spy = vi
.spyOn(api, 'suggestAddress')
.mockResolvedValue(res('path', ['/home/program/agentmail']));
const { input } = setup();
await userEvent.click(input());
await userEvent.type(input(), 'dsh@/home');
// 问错层的后果:候选里全是 Agent 名,人以为这个工作区下没有会话
await waitFor(() => expect(spy).toHaveBeenCalledWith('dsh'));
});
it('写了 . 时带 name 与 path 去问 session 层', async () => {
const spy = vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('session', ['refactor']));
const { input } = setup();
await userEvent.click(input());
await userEvent.type(input(), 'dsh@/home/x.ref');
await waitFor(() => expect(spy).toHaveBeenCalledWith('dsh', '/home/x'));
});
it('path 里的 . 不被当作 session 分隔符(按最后一个 . 切)', async () => {
const spy = vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('session', ['a']));
const { input } = setup();
await userEvent.click(input());
// path 自身含 .a.b/proj
await userEvent.type(input(), 'dsh@/home/a.b/proj.ref');
await waitFor(() => expect(spy).toHaveBeenCalledWith('dsh', '/home/a.b/proj'));
});
it('选中 name 候选后自动补 @', async () => {
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('name', ['dsh', 'opencode']));
const { input, value } = setup();
await userEvent.click(input());
await waitFor(() => expect(screen.getByText('dsh')).toBeInTheDocument());
await userEvent.click(screen.getByText('dsh'));
// 不补 @ 的话人得自己敲,而这是三段里唯一没有歧义的分隔符
expect(value()).toBe('dsh@');
});
it('选中 path 候选后自动补 .', async () => {
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('path', ['/home/program/agentmail']));
const { input, value } = setup('dsh@');
await userEvent.click(input());
await waitFor(() => expect(screen.getByText('/home/program/agentmail')).toBeInTheDocument());
await userEvent.click(screen.getByText('/home/program/agentmail'));
expect(value()).toBe('dsh@/home/program/agentmail.');
});
it('选中 session 候选后拼出完整地址并关掉下拉', async () => {
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('session', ['refactor', 'new']));
const { input, value } = setup('dsh@/home/x.');
await userEvent.click(input());
await waitFor(() => expect(screen.getByText('refactor')).toBeInTheDocument());
await userEvent.click(screen.getByText('refactor'));
expect(value()).toBe('dsh@/home/x.refactor');
// session 是最后一段,选完还开着下拉会挡住下面的输入框
await waitFor(() => expect(screen.queryByText('会话别名new 为新建)')).toBeNull());
});
it('标题参与过滤,且过滤后标题不错位到别的别名上', async () => {
// 三条候选,只有第二条的标题含「缓存」
vi.spyOn(api, 'suggestAddress').mockResolvedValue(
res(
'session',
['witty-planet', 'brisk-harbor', 'calm-river'],
[{ title: '重构导入路径' }, { title: '缓存选型讨论' }, { title: '修 CI' }]
)
);
const { input } = setup('dsh@/home/x.');
await userEvent.click(input());
await userEvent.type(input(), '缓存');
await waitFor(() => {
// 命中的是 brisk-harbor标题必须还是它自己的
expect(screen.getByText('brisk-harbor')).toBeInTheDocument();
expect(screen.getByText('缓存选型讨论')).toBeInTheDocument();
});
// 分别过滤两个数组会让 witty-planet 的标题挂到 brisk-harbor 上
expect(screen.queryByText('重构导入路径')).toBeNull();
expect(screen.queryByText('witty-planet')).toBeNull();
});
it('platform 来源的候选标出「平台」new 标出「新建会话」', async () => {
vi.spyOn(api, 'suggestAddress').mockResolvedValue(
res(
'session',
['ses-mirror', 'new'],
[
{ title: '平台侧在跑的会话', source: 'platform' },
{ source: 'new' }
]
)
);
const { input } = setup('dsh@/home/x.');
await userEvent.click(input());
await waitFor(() => {
// 不标的话人不知道这一封是「接入」一条已在跑的会话
expect(screen.getByText('平台')).toBeInTheDocument();
expect(screen.getByText('新建会话')).toBeInTheDocument();
});
});
it('未读数显示在候选上', async () => {
vi.spyOn(api, 'suggestAddress').mockResolvedValue(
res('session', ['busy-session'], [{ title: '有新消息', unread: 3 }])
);
const { input } = setup('dsh@/home/x.');
await userEvent.click(input());
await waitFor(() => expect(screen.getByText('3')).toBeInTheDocument());
});
it('键盘上下键移动选中项Enter 采用', async () => {
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('name', ['dsh', 'opencode']));
const { input, value } = setup();
await userEvent.click(input());
await waitFor(() => expect(screen.getByText('opencode')).toBeInTheDocument());
await userEvent.keyboard('{ArrowDown}{Enter}');
// 初始 active=0dsh下移一格到 opencode
expect(value()).toBe('opencode@');
});
it('Escape 关掉下拉但不清空已输入的内容', async () => {
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('name', ['dsh']));
const { input, value } = setup();
await userEvent.click(input());
await userEvent.type(input(), 'ds');
await waitFor(() => expect(screen.getByText('Agent 名')).toBeInTheDocument());
await userEvent.keyboard('{Escape}');
await waitFor(() => expect(screen.queryByText('Agent 名')).toBeNull());
expect(value()).toBe('ds');
});
it('接口失败时静默清空候选,不炸也不弹错', async () => {
vi.spyOn(api, 'suggestAddress').mockRejectedValue(new Error('network down'));
const { input, value } = setup();
await userEvent.click(input());
await userEvent.type(input(), 'ds');
// 补全只是便利功能,失败不该阻止人手动输入完整地址
await waitFor(() => expect(screen.queryByText('Agent 名')).toBeNull());
expect(value()).toBe('ds');
});
it('allowMultiple 时补全只作用于最后一段,前面的地址保留', async () => {
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('name', ['opencode']));
let current = 'dsh@/home/x.a, ';
const onChange = vi.fn((v: string) => {
current = v;
});
render(
React.createElement(AddressInput, {
value: current,
onChange,
allowMultiple: true
})
);
await userEvent.click(screen.getByRole('textbox'));
await waitFor(() => expect(screen.getByText('opencode')).toBeInTheDocument());
await userEvent.click(screen.getByText('opencode'));
// 抄送场景:前面已填好的地址不能被补全覆盖
expect(current).toBe('dsh@/home/x.a, opencode@');
});
});