381 lines
23 KiB
TypeScript
381 lines
23 KiB
TypeScript
import { useState } from 'react';
|
|
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { CloudComposerAttachments } from '@/pages/CloudAgents/CloudComposerAttachments';
|
|
import { CloudPendingContext } from '@/pages/CloudAgents/CloudPending';
|
|
import type { CloudAttachment, CloudHistory, CloudUpload } from '../../shared/cloud-agents';
|
|
|
|
const api = vi.hoisted(() => ({ call: vi.fn(), upload: vi.fn(), download: vi.fn() }));
|
|
vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api }));
|
|
|
|
const photo: CloudAttachment = { file_id: 'photo-id', file_name: '我的画.png', file_size: 8192, path: '/attachments/photo', original_path: '/original/photo' };
|
|
const document: CloudAttachment = { file_id: 'document-id', file_name: '故事.docx', file_size: 1200, path: '/attachments/story', original_path: '/original/story' };
|
|
const upload: CloudUpload = { object_name: 'temporary/photo-object', file_name: photo.file_name, file_type: 'image/png', parse_supported: true, parse_methods: ['disable', 'rapid_ocr'] };
|
|
|
|
function delivered(threadId: string, path: string): CloudHistory {
|
|
return { thread_id: threadId, next_offset: null, run: null, messages: [{ id: 1, role: 'assistant',
|
|
content: `[下载文件](sandbox:${path})`, request_id: 'delivery-request', run_id: null, created_at: '',
|
|
}] };
|
|
}
|
|
function deferred<T>() {
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (error: Error) => void;
|
|
const promise = new Promise<T>((done, fail) => { resolve = done; reject = fail; });
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
function operationCalls(operation: string) {
|
|
return api.call.mock.calls.filter(([name]) => name === operation);
|
|
}
|
|
|
|
function open({ initialSelected = [], threadId, disabled = false, deliveryHistory, ensureThread = vi.fn().mockResolvedValue('thread-created') }: {
|
|
initialSelected?: string[]; threadId?: string; disabled?: boolean; deliveryHistory?: CloudHistory; ensureThread?: () => Promise<string>;
|
|
} = {}) {
|
|
const onBusy = vi.fn();
|
|
const onSelected = vi.fn();
|
|
const pending = vi.fn();
|
|
const submit = vi.fn();
|
|
function Harness({ disabled, threadId }: { disabled: boolean; threadId?: string }) {
|
|
const [selected, setSelected] = useState(initialSelected);
|
|
const [text, setText] = useState('保留这些文字');
|
|
return <CloudPendingContext.Provider value={pending}><form onSubmit={event => { event.preventDefault(); submit(); }}>
|
|
<CloudComposerAttachments threadId={threadId} deliveryHistory={deliveryHistory} disabled={disabled} ensureThread={ensureThread} selected={selected} onBusy={onBusy}
|
|
onSelected={value => { onSelected(value); setSelected(value); }} footer={<span data-testid="composer-footer">发送区</span>}>
|
|
<textarea aria-label="消息" value={text} onChange={event => setText(event.target.value)} />
|
|
</CloudComposerAttachments>
|
|
</form></CloudPendingContext.Provider>;
|
|
}
|
|
const view = render(<Harness disabled={disabled} threadId={threadId} />);
|
|
const hasPending = () => [...new Map(pending.mock.calls.map(([id, value]) => [id, value])).values()].some(Boolean);
|
|
return { ...view, onBusy, onSelected, pending, submit, ensureThread, hasPending,
|
|
update: (next: { disabled?: boolean; threadId?: string }) => view.rerender(<Harness disabled={next.disabled ?? disabled} threadId={next.threadId} />),
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
api.upload.mockResolvedValue(upload);
|
|
api.download.mockResolvedValue({ saved: true });
|
|
api.call.mockImplementation(async (operation: string) => {
|
|
if (operation === 'attachments') return { attachments: [] };
|
|
if (operation === 'files') return { files: [] };
|
|
if (operation === 'confirmAttachment') return { attachments: [photo] };
|
|
if (operation === 'parseAttachment') return { parsed_object_name: 'temporary/parsed-object' };
|
|
throw new Error('Unexpected operation: ' + operation);
|
|
});
|
|
});
|
|
afterEach(cleanup);
|
|
|
|
async function chooseFileFromMenu(name = '添加文件或图片') {
|
|
fireEvent.keyDown(screen.getByRole('button', { name: '添加文件或图片' }), { key: 'ArrowDown' });
|
|
fireEvent.click(await screen.findByRole('menuitem', { name, exact: true }));
|
|
}
|
|
|
|
async function openLibrary(count: number) {
|
|
fireEvent.click(await screen.findByRole('button', { name: `附件,共 ${count} 个文件` }));
|
|
return screen.findByRole('dialog', { name: '本次对话的附件' });
|
|
}
|
|
|
|
describe('CloudComposerAttachments', () => {
|
|
it.each([
|
|
{ file: photo, type: 'image/png', kind: 'image', label: '图片文件' },
|
|
{ file: document, type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', kind: 'file', label: '文件' },
|
|
])('adds a $kind file directly with the original reading method', async ({ file, type, kind, label }) => {
|
|
api.upload.mockResolvedValue({ ...upload, file_name: file.file_name, file_type: type });
|
|
const base = api.call.getMockImplementation()!;
|
|
api.call.mockImplementation(async (operation, input) => operation === 'confirmAttachment' ? { attachments: [file] } : base(operation, input));
|
|
const view = open({ initialSelected: ['existing-id'] });
|
|
await chooseFileFromMenu();
|
|
const chip = await screen.findByRole('button', { name: '移除附件 ' + file.file_name });
|
|
expect(chip.closest('.agent-attachment-card')).toHaveAttribute('data-kind', kind);
|
|
expect(within(chip.closest('.agent-attachment-card') as HTMLElement).getByLabelText(label)).toBeInTheDocument();
|
|
expect(screen.queryByRole('img')).not.toBeInTheDocument();
|
|
expect(view.onSelected).toHaveBeenLastCalledWith(['existing-id', file.file_id]);
|
|
expect(operationCalls('confirmAttachment')).toEqual([['confirmAttachment', { thread_id: 'thread-created', attachments: [{ object_name: upload.object_name, file_type: type }] }]]);
|
|
expect(operationCalls('parseAttachment')).toHaveLength(0);
|
|
expect(view.ensureThread).toHaveBeenCalledTimes(1);
|
|
expect(view.onBusy.mock.calls).toEqual([[true], [false]]);
|
|
await waitFor(() => expect(view.hasPending()).toBe(false));
|
|
expect(screen.getByLabelText('消息')).toHaveValue('保留这些文字');
|
|
expect(screen.getByTestId('composer-footer').closest('.agent-attachment-toolbar')).toContainElement(screen.getByRole('button', { name: '添加文件或图片' }));
|
|
for (const button of screen.getAllByRole('button')) expect(button).toHaveAttribute('type', 'button');
|
|
expect(view.submit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not create a conversation or change existing input when the native picker is canceled', async () => {
|
|
api.upload.mockResolvedValue(null);
|
|
const view = open({ initialSelected: ['existing-id'] });
|
|
await chooseFileFromMenu();
|
|
await waitFor(() => expect(view.onBusy).toHaveBeenLastCalledWith(false));
|
|
expect(view.ensureThread).not.toHaveBeenCalled();
|
|
expect(operationCalls('confirmAttachment')).toHaveLength(0);
|
|
expect(view.onSelected).not.toHaveBeenCalled();
|
|
expect(screen.getByLabelText('消息')).toHaveValue('保留这些文字');
|
|
await waitFor(() => expect(view.hasPending()).toBe(false));
|
|
});
|
|
|
|
it('retains the uploaded object and resolved conversation for an explicit confirmation retry', async () => {
|
|
const base = api.call.getMockImplementation()!;
|
|
let attempt = 0;
|
|
api.call.mockImplementation(async (operation, input) => {
|
|
if (operation === 'confirmAttachment' && ++attempt === 1) throw new Error('确认时连接中断');
|
|
return base(operation, input);
|
|
});
|
|
const view = open({ initialSelected: ['existing-id'] });
|
|
await chooseFileFromMenu();
|
|
expect(await screen.findByRole('alert')).toHaveTextContent('确认时连接中断');
|
|
expect(view.hasPending()).toBe(true);
|
|
expect(view.onBusy).toHaveBeenLastCalledWith(false);
|
|
expect(view.onSelected).not.toHaveBeenCalled();
|
|
expect(screen.getByRole('button', { name: '添加文件或图片' })).toBeDisabled();
|
|
fireEvent.change(screen.getByLabelText('消息'), { target: { value: '失败后还可以继续写' } });
|
|
fireEvent.click(screen.getByRole('button', { name: '重试添加' }));
|
|
await screen.findByRole('button', { name: '移除附件 ' + photo.file_name });
|
|
expect(api.upload).toHaveBeenCalledTimes(1);
|
|
expect(view.ensureThread).toHaveBeenCalledTimes(1);
|
|
const attempts = operationCalls('confirmAttachment');
|
|
expect(attempts).toHaveLength(2);
|
|
expect(attempts[1]).toEqual(attempts[0]);
|
|
expect(view.onSelected).toHaveBeenLastCalledWith(['existing-id', photo.file_id]);
|
|
expect(screen.getByLabelText('消息')).toHaveValue('失败后还可以继续写');
|
|
expect(view.hasPending()).toBe(false);
|
|
});
|
|
|
|
it('keeps the picked object when ensuring the conversation fails and cancels without deleting files', async () => {
|
|
const ensureThread = vi.fn().mockRejectedValueOnce(new Error('对话暂时无法创建')).mockResolvedValue('same-client-thread');
|
|
const view = open({ ensureThread, initialSelected: ['existing-id'] });
|
|
await chooseFileFromMenu();
|
|
await screen.findByText('对话暂时无法创建');
|
|
expect(view.hasPending()).toBe(true);
|
|
fireEvent.click(screen.getByRole('button', { name: '重试添加' }));
|
|
await screen.findByRole('button', { name: '移除附件 ' + photo.file_name });
|
|
expect(api.upload).toHaveBeenCalledTimes(1);
|
|
expect(operationCalls('confirmAttachment')[0][1]).toMatchObject({ thread_id: 'same-client-thread' });
|
|
api.call.mockImplementation(async (operation: string) => {
|
|
if (operation === 'confirmAttachment') throw new Error('第二个文件未确认');
|
|
return operation === 'attachments' ? { attachments: [photo] } : { files: [] };
|
|
});
|
|
await chooseFileFromMenu();
|
|
await screen.findByText('第二个文件未确认');
|
|
fireEvent.click(screen.getByRole('button', { name: '取消添加' }));
|
|
expect(screen.queryByRole('button', { name: '重试添加' })).not.toBeInTheDocument();
|
|
expect(view.hasPending()).toBe(false);
|
|
expect(view.onSelected).toHaveBeenLastCalledWith(['existing-id', photo.file_id]);
|
|
expect(operationCalls('deleteAttachment')).toHaveLength(0);
|
|
});
|
|
|
|
it('only deselects a removed chip and can select the uploaded file again', async () => {
|
|
api.call.mockImplementation(async (operation: string) => operation === 'attachments' ? { attachments: [photo, document] } : { files: [] });
|
|
const view = open({ threadId: 'thread-existing', initialSelected: [photo.file_id] });
|
|
fireEvent.click(await screen.findByRole('button', { name: '移除附件 ' + photo.file_name }));
|
|
expect(view.onSelected).toHaveBeenLastCalledWith([]);
|
|
expect(operationCalls('deleteAttachment')).toHaveLength(0);
|
|
await openLibrary(2);
|
|
const choice = await screen.findByRole('checkbox', { name: '选择附件 ' + photo.file_name });
|
|
expect(choice).not.toBeChecked();
|
|
fireEvent.click(choice);
|
|
expect(view.onSelected).toHaveBeenLastCalledWith([photo.file_id]);
|
|
fireEvent.click(screen.getByRole('button', { name: '返回聊天 · 已选 1 个附件' }));
|
|
expect(await screen.findByRole('button', { name: '移除附件 ' + photo.file_name })).toBeVisible();
|
|
expect(api.upload).not.toHaveBeenCalled();
|
|
expect(view.ensureThread).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('prevents duplicate picker and confirmation calls while an operation is busy', async () => {
|
|
const picker = deferred<CloudUpload | null>();
|
|
const confirmation = deferred<{ attachments: CloudAttachment[] }>();
|
|
api.upload.mockReturnValue(picker.promise);
|
|
const base = api.call.getMockImplementation()!;
|
|
api.call.mockImplementation((operation, input) => operation === 'confirmAttachment' ? confirmation.promise : base(operation, input));
|
|
const view = open();
|
|
const add = screen.getByRole('button', { name: '添加文件或图片' });
|
|
await chooseFileFromMenu();
|
|
fireEvent.click(add);
|
|
expect(screen.queryAllByRole('menuitem')).toHaveLength(0);
|
|
expect(api.upload).toHaveBeenCalledTimes(1);
|
|
expect(add).toBeDisabled();
|
|
expect(screen.getByLabelText('消息')).toBeEnabled();
|
|
await act(async () => { picker.resolve(upload); });
|
|
await waitFor(() => expect(operationCalls('confirmAttachment')).toHaveLength(1));
|
|
fireEvent.click(add);
|
|
expect(api.upload).toHaveBeenCalledTimes(1);
|
|
await act(async () => { confirmation.resolve({ attachments: [photo] }); });
|
|
await screen.findByRole('button', { name: '移除附件 ' + photo.file_name });
|
|
expect(view.onSelected).toHaveBeenCalledTimes(1);
|
|
expect(view.onBusy.mock.calls).toEqual([[true], [false]]);
|
|
});
|
|
|
|
it('does not add, remove or select attachments when disabled', async () => {
|
|
api.call.mockImplementation(async (operation: string) => operation === 'attachments' ? { attachments: [photo] } : { files: [] });
|
|
const view = open({ threadId: 'thread-existing', disabled: true, initialSelected: [photo.file_id] });
|
|
const remove = await screen.findByRole('button', { name: '移除附件 ' + photo.file_name });
|
|
expect(remove).toBeDisabled();
|
|
fireEvent.click(remove);
|
|
const add = screen.getByRole('button', { name: '添加文件或图片' });
|
|
expect(add).toBeDisabled();
|
|
fireEvent.click(add);
|
|
expect(screen.queryAllByRole('menuitem')).toHaveLength(0);
|
|
await openLibrary(1);
|
|
const choice = screen.getByRole('checkbox', { name: '选择附件 ' + photo.file_name });
|
|
expect(choice).toBeDisabled();
|
|
fireEvent.click(choice);
|
|
const save = screen.getByRole('button', { name: '保存 ' + photo.file_name });
|
|
expect(save).toBeDisabled();
|
|
fireEvent.click(save);
|
|
expect(api.download).not.toHaveBeenCalled();
|
|
await screen.findByText('还没有生成文件。');
|
|
expect(api.upload).not.toHaveBeenCalled();
|
|
expect(view.onSelected).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('releases busy and pending state on unmount and ignores a later picker response', async () => {
|
|
const picker = deferred<CloudUpload | null>();
|
|
api.upload.mockReturnValue(picker.promise);
|
|
const view = open();
|
|
await chooseFileFromMenu();
|
|
expect(view.hasPending()).toBe(true);
|
|
view.unmount();
|
|
expect(view.onBusy.mock.calls).toEqual([[true], [false]]);
|
|
expect(view.hasPending()).toBe(false);
|
|
await act(async () => { picker.resolve(upload); });
|
|
expect(view.ensureThread).not.toHaveBeenCalled();
|
|
expect(operationCalls('confirmAttachment')).toHaveLength(0);
|
|
expect(view.onSelected).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('ignores a confirmation response after unmount without replacing selected input', async () => {
|
|
const confirmation = deferred<{ attachments: CloudAttachment[] }>();
|
|
const base = api.call.getMockImplementation()!;
|
|
api.call.mockImplementation((operation, input) => operation === 'confirmAttachment' ? confirmation.promise : base(operation, input));
|
|
const view = open();
|
|
await chooseFileFromMenu();
|
|
await waitFor(() => expect(operationCalls('confirmAttachment')).toHaveLength(1));
|
|
view.unmount();
|
|
await act(async () => { confirmation.resolve({ attachments: [photo] }); });
|
|
expect(view.onSelected).not.toHaveBeenCalled();
|
|
expect(view.onBusy.mock.calls).toEqual([[true], [false]]);
|
|
expect(view.hasPending()).toBe(false);
|
|
});
|
|
|
|
it('shows a loading error without a false empty state and restores long names on refresh', async () => {
|
|
const list = deferred<{ attachments: CloudAttachment[] }>();
|
|
const longName = '小朋友写的故事'.repeat(30) + '.docx';
|
|
const longFile = { ...document, file_name: longName };
|
|
let retry = false;
|
|
let filesReads = 0;
|
|
api.call.mockImplementation(async (operation: string) => {
|
|
if (operation === 'attachments') return retry ? { attachments: [longFile] } : list.promise;
|
|
if (operation === 'files' && ++filesReads === 1) throw new Error('文件列表首次读取中断');
|
|
return { files: [] };
|
|
});
|
|
const view = open({ threadId: 'thread-existing', initialSelected: [document.file_id], deliveryHistory: delivered('thread-existing', '/pending-only.txt') });
|
|
expect(screen.queryByRole('button', { name: /^附件/ })).not.toBeInTheDocument();
|
|
// A failed companion list opens the library while the upload list is still loading.
|
|
fireEvent.click(await screen.findByRole('button', { name: '附件', exact: true }));
|
|
const dialog = await screen.findByRole('dialog', { name: '本次对话的附件' });
|
|
expect(within(dialog).getAllByText('正在读取文件…').length).toBeGreaterThan(0);
|
|
expect(within(dialog).queryByText('还没有上传文件。')).not.toBeInTheDocument();
|
|
await act(async () => { list.reject(new Error('文件读取失败')); });
|
|
expect(await within(dialog).findByRole('alert')).toHaveTextContent('文件读取失败');
|
|
expect(screen.queryByText('还没有上传文件。')).not.toBeInTheDocument();
|
|
expect(view.onSelected).not.toHaveBeenCalled();
|
|
retry = true;
|
|
fireEvent.click(within(dialog).getByRole('button', { name: '刷新附件' }));
|
|
expect(await within(dialog).findByRole('checkbox', { name: '选择附件 ' + longName })).toBeChecked();
|
|
await waitFor(() => expect(within(dialog).queryByRole('alert')).not.toBeInTheDocument());
|
|
fireEvent.click(within(dialog).getByRole('button', { name: '返回聊天 · 已选 1 个附件' }));
|
|
const remove = await screen.findByRole('button', { name: '移除附件 ' + longName });
|
|
expect(within(remove.closest('.agent-attachment-card') as HTMLElement).getByTitle(longName)).toHaveClass('agent-attachment-name');
|
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
|
expect(view.submit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('lets the user retry or cancel a picker failure without touching existing input', async () => {
|
|
api.upload.mockRejectedValueOnce(new Error('上传失败')).mockResolvedValue(null);
|
|
const view = open({ initialSelected: ['existing-id'] });
|
|
await chooseFileFromMenu();
|
|
await screen.findByText('上传失败');
|
|
fireEvent.click(screen.getByRole('button', { name: '重试选择文件' }));
|
|
await waitFor(() => expect(api.upload).toHaveBeenCalledTimes(2));
|
|
await waitFor(() => expect(view.onBusy).toHaveBeenLastCalledWith(false));
|
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
|
expect(view.onSelected).not.toHaveBeenCalled();
|
|
expect(view.ensureThread).not.toHaveBeenCalled();
|
|
expect(screen.getByLabelText('消息')).toHaveValue('保留这些文字');
|
|
});
|
|
|
|
it('only parses when explicitly requested and reuses the parsed object after a confirmation failure', async () => {
|
|
const base = api.call.getMockImplementation()!;
|
|
let confirms = 0;
|
|
api.call.mockImplementation(async (operation, input) => {
|
|
if (operation === 'confirmAttachment' && ++confirms === 1) throw new Error('确认响应丢失');
|
|
return base(operation, input);
|
|
});
|
|
const view = open();
|
|
await chooseFileFromMenu('添加并选择读取方式');
|
|
const choice = await screen.findByRole('combobox', { name: '附件读取方式' });
|
|
expect(operationCalls('confirmAttachment')).toHaveLength(0);
|
|
expect(operationCalls('parseAttachment')).toHaveLength(0);
|
|
fireEvent.change(choice, { target: { value: 'rapid_ocr' } });
|
|
fireEvent.click(screen.getByRole('button', { name: '添加到消息' }));
|
|
await screen.findByText('确认响应丢失');
|
|
expect(screen.getByRole('combobox', { name: '附件读取方式' })).toBeDisabled();
|
|
fireEvent.click(screen.getByRole('button', { name: '重试添加' }));
|
|
await screen.findByRole('button', { name: '移除附件 ' + photo.file_name });
|
|
expect(operationCalls('parseAttachment')).toEqual([['parseAttachment', { object_name: upload.object_name, parse_method: 'rapid_ocr' }]]);
|
|
const confirmsMade = operationCalls('confirmAttachment');
|
|
expect(confirmsMade).toHaveLength(2);
|
|
expect(confirmsMade[1]).toEqual(confirmsMade[0]);
|
|
expect(confirmsMade[0][1]).toEqual({ thread_id: 'thread-created', attachments: [{ object_name: upload.object_name, file_type: 'image/png', parsed_object_name: 'temporary/parsed-object' }] });
|
|
expect(view.ensureThread).toHaveBeenCalledTimes(1);
|
|
expect(api.upload).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('collects nested generated files and uses the existing native save path', async () => {
|
|
api.call.mockImplementation(async (operation: string, input: { path?: string }) => {
|
|
if (operation === 'attachments') return { attachments: [] };
|
|
if (input.path === '/') return { files: [{ name: '作品', path: '/作品', directory_path: '/作品', is_dir: true, size: 0 }] };
|
|
return { files: [{ name: '故事.txt', path: '/作品/故事.txt', directory_path: '/作品', is_dir: false, size: 500 }] };
|
|
});
|
|
const view = open({ threadId: 'thread-existing', deliveryHistory: delivered('thread-existing', '/作品/故事.txt') });
|
|
await openLibrary(1);
|
|
expect(api.call).toHaveBeenCalledWith('files', { thread_id: 'thread-existing', path: '/作品' });
|
|
fireEvent.click(await screen.findByRole('button', { name: '保存 故事.txt' }));
|
|
await waitFor(() => expect(api.download).toHaveBeenCalledWith('thread-existing', '/作品/故事.txt'));
|
|
expect(screen.queryByRole('button', { name: '作品' })).not.toBeInTheDocument();
|
|
expect(screen.queryByRole('button', { name: '上一级' })).not.toBeInTheDocument();
|
|
expect(view.ensureThread).not.toHaveBeenCalled();
|
|
expect(view.submit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps file reads stable for equivalent delivery history and rechecks on explicit refresh or revision changes', async () => {
|
|
const history = delivered('stable-thread', '/stories/story.txt');
|
|
history.messages[0].content += '\n[下载笔记](sandbox:/stories/notes.txt)';
|
|
api.call.mockImplementation(async operation => operation === 'attachments' ? { attachments: [] } : { files: [
|
|
{ name: 'story.txt', path: '/stories/story.txt', directory_path: '/stories', is_dir: false, size: 500 },
|
|
{ name: 'notes.txt', path: '/stories/notes.txt', directory_path: '/stories', is_dir: false, size: 200 },
|
|
] });
|
|
const props = { threadId: 'stable-thread', ensureThread: vi.fn(), selected: [], onSelected: vi.fn(), onBusy: vi.fn(), disabled: false };
|
|
const view = render(<CloudComposerAttachments {...props} deliveryHistory={history} filesRevision="revision-1" />);
|
|
await screen.findByRole('button', { name: '附件,共 2 个文件' });
|
|
expect(operationCalls('files')).toHaveLength(1);
|
|
const equivalent = { ...history, messages: history.messages.map(item => ({ ...item,
|
|
content: '文件仍然是这两个。\n[笔记](sandbox:/stories/notes.txt)\n[故事](sandbox:/stories/story.txt)\n[再看故事](sandbox:/stories/story.txt)',
|
|
})) };
|
|
view.rerender(<CloudComposerAttachments {...props} deliveryHistory={equivalent} filesRevision="revision-1" />);
|
|
await act(async () => undefined);
|
|
expect(operationCalls('files')).toHaveLength(1);
|
|
expect(screen.getByRole('button', { name: '附件,共 2 个文件' })).toBeVisible();
|
|
const dialog = await openLibrary(2);
|
|
await waitFor(() => expect(operationCalls('files')).toHaveLength(2));
|
|
fireEvent.click(within(dialog).getByRole('button', { name: '刷新附件' }));
|
|
await waitFor(() => expect(operationCalls('files')).toHaveLength(3));
|
|
view.rerender(<CloudComposerAttachments {...props} deliveryHistory={equivalent} filesRevision="revision-2" />);
|
|
await waitFor(() => expect(operationCalls('files')).toHaveLength(4));
|
|
expect(operationCalls('files')).toEqual(Array.from({ length: 4 }, () => ['files', { thread_id: 'stable-thread', path: '/stories' }]));
|
|
expect(within(dialog).getByRole('button', { name: '保存 story.txt' })).toBeVisible();
|
|
expect(within(dialog).getByRole('button', { name: '保存 notes.txt' })).toBeVisible();
|
|
expect(props.ensureThread).not.toHaveBeenCalled();
|
|
});
|
|
});
|