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[] ) { 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@'); }); });