chore: directory migration - gateway→server, web→client/electron
This commit is contained in:
264
client/electron/test/components/AddressInput.test.tsx
Normal file
264
client/electron/test/components/AddressInput.test.tsx
Normal file
@ -0,0 +1,264 @@
|
||||
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=0(dsh),下移一格到 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@');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user