Files
makelore/tests/unit/cloud-agent-attachments.test.tsx
T

262 lines
16 KiB
TypeScript

import { useState, type ComponentProps } from 'react';
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { CloudComposerAttachments } from '@/pages/CloudAgents/CloudComposerAttachments';
import type { CloudAttachment, CloudFile } 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 ensureThread = vi.fn();
const onBusy = vi.fn();
const upload: CloudAttachment = {
file_id: 'upload-1', file_name: 'notes.txt', file_size: 1024,
path: '/notes.md', original_path: '/notes.txt',
};
function file(path: string): CloudFile {
return { name: path.split('/').at(-1)!, path, directory_path: path.split('/').slice(0, -1).join('/') || '/', is_dir: false, size: 2048 };
}
function folder(path: string): CloudFile {
return { name: path.split('/').at(-1)!, path, directory_path: path, is_dir: true, size: 0 };
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>(done => { resolve = done; });
return { promise, resolve };
}
type HarnessProps = Pick<ComponentProps<typeof CloudComposerAttachments>, 'threadId' | 'headerTarget' | 'filesRevision' | 'activeView'> & { initialSelected?: string[] };
function Harness({ initialSelected = [], ...props }: HarnessProps) {
const [selected, setSelected] = useState(initialSelected);
return <CloudComposerAttachments {...props} ensureThread={ensureThread} selected={selected} onSelected={setSelected} onBusy={onBusy} disabled={false}>
<textarea aria-label="消息" />
</CloudComposerAttachments>;
}
async function openLibrary(count: number) {
const trigger = await screen.findByRole('button', { name: `附件,共 ${count} 个文件` });
fireEvent.click(trigger);
return screen.findByRole('dialog', { name: '本次对话的附件' });
}
beforeEach(() => {
vi.resetAllMocks();
ensureThread.mockResolvedValue('created-thread');
api.download.mockResolvedValue({ saved: true });
api.call.mockImplementation(async (operation: string) => {
if (operation === 'attachments') return { attachments: [] };
if (operation === 'files') return { files: [] };
throw new Error('Unexpected operation: ' + operation);
});
});
afterEach(cleanup);
it.each([undefined, 'empty-thread'])('hides the library for an empty conversation (%s) and keeps adding available', async threadId => {
render(<Harness threadId={threadId} />);
if (threadId) {
await waitFor(() => expect(api.call).toHaveBeenCalledWith('files', { thread_id: threadId, path: '/' }));
await act(async () => undefined);
}
expect(screen.queryByRole('button', { name: /^附件/ })).not.toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '添加文件或图片' })).toBeEnabled();
expect(api.upload).not.toHaveBeenCalled();
expect(ensureThread).not.toHaveBeenCalled();
if (!threadId) expect(api.call).not.toHaveBeenCalled();
});
it('counts generated files in nested folders once and offers the library without uploaded files', async () => {
api.call.mockImplementation(async (operation, input) => {
if (operation === 'attachments') return { attachments: [] };
if (operation === 'files') return { files: input.path === '/'
? [file('/poster.png'), folder('/outputs')]
: input.path === '/outputs'
? [file('/outputs/story.txt'), folder('/outputs/audio'), folder('/outputs'), file('/poster.png')]
: [file('/outputs/audio/story.mp3')] };
throw new Error(operation);
});
render(<Harness threadId="generated-thread" />);
const dialog = await openLibrary(3);
const generated = within(dialog).getByRole('region', { name: '助手生成的' });
expect(within(generated).getAllByRole('button', { name: /^保存 / })).toHaveLength(3);
expect(within(generated).getByText('story.mp3')).toBeVisible();
expect(within(dialog).getByText('还没有上传文件。')).toBeVisible();
expect(within(dialog).queryByRole('checkbox')).not.toBeInTheDocument();
expect(api.call).toHaveBeenCalledWith('files', { thread_id: 'generated-thread', path: '/outputs/audio' });
expect(ensureThread).not.toHaveBeenCalled();
});
it('excludes uploaded and parsed copies from generated files and saves each original scoped path without selecting it', async () => {
api.call.mockImplementation(async (operation, input) => {
if (operation === 'attachments') return { attachments: [upload] };
if (operation === 'files') return { files: input.path === '/'
? [file('/notes.txt'), file('/notes.md'), folder('/outputs')]
: [file('/outputs/story.txt')] };
throw new Error(operation);
});
render(<Harness threadId="download-thread" />);
const dialog = await openLibrary(2);
const uploaded = within(dialog).getByRole('region', { name: '你上传的' });
const generated = within(dialog).getByRole('region', { name: '助手生成的' });
expect(within(generated).queryByText('notes.txt')).not.toBeInTheDocument();
expect(within(generated).queryByText('notes.md')).not.toBeInTheDocument();
expect(within(generated).getAllByRole('button', { name: /^保存 / })).toHaveLength(1);
fireEvent.click(within(uploaded).getByRole('button', { name: '保存 notes.txt' }));
await waitFor(() => expect(api.download).toHaveBeenCalledWith('download-thread', '/notes.txt'));
await waitFor(() => expect(within(generated).getByRole('button', { name: '保存 story.txt' })).toBeEnabled());
fireEvent.click(within(generated).getByRole('button', { name: '保存 story.txt' }));
await waitFor(() => expect(api.download).toHaveBeenCalledWith('download-thread', '/outputs/story.txt'));
expect(within(uploaded).getByRole('checkbox', { name: '选择附件 notes.txt' })).not.toBeChecked();
expect(screen.queryByLabelText('本条消息的附件')).not.toBeInTheDocument();
expect(api.download).toHaveBeenCalledTimes(2);
expect(api.call.mock.calls.some(([operation]) => operation === 'deleteAttachment')).toBe(false);
});
it('portals the entry into the identity header, moves focus into the Sheet and restores it after Escape', async () => {
api.call.mockImplementation(async operation => operation === 'attachments' ? { attachments: [upload] } : { files: [] });
const header = document.createElement('header');
document.body.appendChild(header);
try {
const view = render(<Harness threadId="focus-thread" headerTarget={header} />);
const trigger = await screen.findByRole('button', { name: '附件,共 1 个文件' });
expect(header).toContainElement(trigger);
expect(view.container).not.toContainElement(trigger);
trigger.focus();
fireEvent.click(trigger);
const dialog = await screen.findByRole('dialog', { name: '本次对话的附件' });
expect(view.container).not.toContainElement(dialog);
await waitFor(() => expect(dialog.contains(document.activeElement)).toBe(true));
expect(dialog).toHaveAccessibleDescription('1 个文件,随时找回来。');
fireEvent.keyDown(document.activeElement!, { key: 'Escape' });
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
await waitFor(() => expect(trigger).toHaveFocus());
} finally {
cleanup();
header.remove();
}
});
it('keeps known uploads, generated files and message selections when refresh fails, then clears errors on retry', async () => {
let fail = false;
api.call.mockImplementation(async operation => {
if (fail) throw new Error(operation === 'attachments' ? '上传列表暂不可用' : '生成列表暂不可用');
return operation === 'attachments' ? { attachments: [upload] } : { files: [file('/poster.png')] };
});
render(<Harness threadId="refresh-thread" initialSelected={[upload.file_id]} />);
const dialog = await openLibrary(2);
await screen.findByRole('checkbox', { name: '选择附件 notes.txt' });
fail = true;
fireEvent.click(within(dialog).getByRole('button', { name: '刷新附件' }));
expect(await within(dialog).findByText('上传列表暂不可用')).toBeVisible();
expect(await within(dialog).findByText('生成列表暂不可用')).toBeVisible();
expect(within(dialog).getByRole('checkbox', { name: '选择附件 notes.txt' })).toBeChecked();
expect(within(dialog).getByRole('button', { name: '保存 poster.png' })).toBeEnabled();
fail = false;
fireEvent.click(within(dialog).getByRole('button', { name: '刷新附件' }));
await waitFor(() => expect(within(dialog).queryByRole('alert')).not.toBeInTheDocument());
fireEvent.click(within(dialog).getByRole('button', { name: '返回聊天 · 已选 1 个附件' }));
expect(await screen.findByRole('button', { name: '移除附件 notes.txt' })).toBeVisible();
expect(screen.getByRole('button', { name: '附件,共 2 个文件' })).toBeVisible();
});
it.each(['attachments', 'files'] as const)('does not claim a complete count or unknown file origin after the first %s read fails, then recovers on refresh', async failedOperation => {
let fail = true;
const error = failedOperation === 'attachments' ? '上传列表首次读取失败' : '文件列表首次读取失败';
api.call.mockImplementation(async operation => {
if (fail && operation === failedOperation) throw new Error(error);
return operation === 'attachments' ? { attachments: [upload] } : { files: [file('/notes.txt'), file('/notes.md'), file('/poster.png')] };
});
render(<Harness threadId="first-read-thread" />);
const trigger = await screen.findByRole('button', { name: '附件', exact: true });
await act(async () => undefined);
expect(trigger).toHaveTextContent(/^附件$/);
expect(screen.queryByRole('button', { name: /^附件,共/ })).not.toBeInTheDocument();
fireEvent.click(trigger);
const dialog = await screen.findByRole('dialog', { name: '本次对话的附件' });
expect(await within(dialog).findByText(error)).toBeVisible();
expect(dialog).toHaveAccessibleDescription('这次聊天用到的文件都在这里。');
const generated = within(dialog).getByRole('region', { name: '助手生成的' });
expect(within(generated).queryByText('notes.txt')).not.toBeInTheDocument();
expect(within(generated).queryByText('notes.md')).not.toBeInTheDocument();
expect(within(generated).queryByText('poster.png')).not.toBeInTheDocument();
expect(within(generated).queryByText('还没有生成文件。')).not.toBeInTheDocument();
if (failedOperation === 'attachments') {
expect(within(dialog).queryByRole('checkbox')).not.toBeInTheDocument();
expect(within(dialog).queryByText('还没有上传文件。')).not.toBeInTheDocument();
} else {
expect(within(dialog).getByRole('checkbox', { name: '选择附件 notes.txt' })).toBeVisible();
expect(within(generated).getByRole('alert')).toHaveTextContent(error);
}
fail = false;
fireEvent.click(within(dialog).getByRole('button', { name: '刷新附件' }));
await waitFor(() => expect(dialog).toHaveAccessibleDescription('2 个文件,随时找回来。'));
expect(within(dialog).getByRole('checkbox', { name: '选择附件 notes.txt' })).toBeVisible();
expect(within(generated).getByRole('button', { name: '保存 poster.png' })).toBeEnabled();
expect(within(generated).getAllByRole('button', { name: /^保存 / })).toHaveLength(1);
expect(within(dialog).queryByRole('alert')).not.toBeInTheDocument();
fireEvent.click(within(dialog).getByRole('button', { name: '返回聊天' }));
expect(await screen.findByRole('button', { name: '附件,共 2 个文件' })).toHaveTextContent('2');
});
it('discards late files and uploads after switching conversations and stops reading old subfolders', async () => {
const oldUploads = deferred<{ attachments: CloudAttachment[] }>();
const oldFiles = deferred<{ files: CloudFile[] }>();
api.call.mockImplementation(async (operation, input) => {
if (input.thread_id === 'old-thread') return operation === 'attachments' ? oldUploads.promise : oldFiles.promise;
return operation === 'attachments' ? { attachments: [{ ...upload, file_id: 'new-upload', file_name: 'new-notes.txt' }] } : { files: [file('/new-poster.png')] };
});
const view = render(<Harness threadId="old-thread" />);
await waitFor(() => expect(api.call).toHaveBeenCalledWith('files', { thread_id: 'old-thread', path: '/' }));
view.rerender(<Harness threadId="new-thread" />);
const dialog = await openLibrary(2);
expect(within(dialog).getByText('new-notes.txt')).toBeVisible();
await act(async () => {
oldUploads.resolve({ attachments: [upload] });
oldFiles.resolve({ files: [file('/old-poster.png'), folder('/old-output')] });
});
expect(within(dialog).queryByText('notes.txt')).not.toBeInTheDocument();
expect(within(dialog).queryByText('old-poster.png')).not.toBeInTheDocument();
expect(within(dialog).getByText('new-poster.png')).toBeVisible();
expect(api.call).not.toHaveBeenCalledWith('files', { thread_id: 'old-thread', path: '/old-output' });
fireEvent.click(within(dialog).getByRole('button', { name: '保存 new-poster.png' }));
await waitFor(() => expect(api.download).toHaveBeenCalledWith('new-thread', '/new-poster.png'));
});
it('refreshes generated files when authoritative history revision changes while retaining the current conversation', async () => {
let generated = false;
api.call.mockImplementation(async operation => operation === 'attachments' ? { attachments: [] } : { files: generated ? [file('/answer.txt')] : [] });
const view = render(<Harness threadId="revision-thread" filesRevision="message-1:run-1:running" />);
await waitFor(() => expect(api.call).toHaveBeenCalledWith('files', { thread_id: 'revision-thread', path: '/' }));
await act(async () => undefined);
expect(screen.queryByRole('button', { name: /^附件/ })).not.toBeInTheDocument();
generated = true;
view.rerender(<Harness threadId="revision-thread" filesRevision="message-2:run-1:completed" />);
const dialog = await openLibrary(1);
expect(within(dialog).getByText('answer.txt')).toBeVisible();
expect(api.call.mock.calls.filter(([operation, input]) => operation === 'files' && input.thread_id === 'revision-thread').length).toBeGreaterThanOrEqual(2);
expect(ensureThread).not.toHaveBeenCalled();
});
it('offers reading choices from the first upload and confirms the chosen method on the same created thread', async () => {
api.upload.mockResolvedValue({ object_name: 'temporary/notes.txt', file_name: 'notes.txt', file_type: 'txt', parse_supported: true, parse_methods: ['disable', 'rapid_ocr'] });
const base = api.call.getMockImplementation()!;
api.call.mockImplementation(async (operation, input) => {
if (operation === 'parseAttachment') return { parsed_object_name: 'parsed/notes.md' };
if (operation === 'confirmAttachment') return { attachments: [upload] };
return base(operation, input);
});
render(<Harness />);
fireEvent.keyDown(screen.getByRole('button', { name: '添加文件或图片' }), { key: 'ArrowDown' });
fireEvent.click(await screen.findByRole('menuitem', { name: '添加并选择读取方式' }));
const method = await screen.findByRole('combobox', { name: '附件读取方式' });
expect(ensureThread).not.toHaveBeenCalled();
expect(api.call.mock.calls.some(([operation]) => operation === 'confirmAttachment')).toBe(false);
fireEvent.change(method, { target: { value: 'rapid_ocr' } });
fireEvent.click(screen.getByRole('button', { name: '添加到消息' }));
await waitFor(() => expect(api.call).toHaveBeenCalledWith('confirmAttachment', { thread_id: 'created-thread', attachments: [{ object_name: 'temporary/notes.txt', file_type: 'txt', parsed_object_name: 'parsed/notes.md' }] }));
expect(api.call).toHaveBeenCalledWith('parseAttachment', { object_name: 'temporary/notes.txt', parse_method: 'rapid_ocr' });
expect(await screen.findByRole('button', { name: '移除附件 notes.txt' })).toBeVisible();
expect(api.upload).toHaveBeenCalledTimes(1);
expect(ensureThread).toHaveBeenCalledTimes(1);
});