fix: isolate settings and unify account controls with Code

This commit is contained in:
鲨鱼辣椒 committed 2026-10-13 15:15:27 +08:00
1 parent 430233f825
commit 2cc542f1f8
32 files changed
+759 -1091

No files matched your search

+108
View File
@@ -0,0 +1,108 @@
import { createRef, type MouseEvent } from 'react';
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AccountMenuTrigger } from '@/components/account/AccountMenuTrigger';
afterEach(cleanup);
describe('AccountMenuTrigger', () => {
it('forwards the button ref, click handler, menu accessibility attributes and test IDs', () => {
const ref = createRef<HTMLButtonElement>();
let clickedButton: HTMLButtonElement | null = null;
const onClick = vi.fn((event: MouseEvent<HTMLButtonElement>) => {
clickedButton = event.currentTarget;
});
render(<AccountMenuTrigger
ref={ref}
accountName="小麦"
onClick={onClick}
aria-label="打开账户菜单"
aria-haspopup="menu"
aria-expanded={true}
aria-controls="account-menu"
data-testid="account-trigger"
surfaceTestId="account-surface"
avatarTestId="account-avatar"
open
/>);
const button = screen.getByRole('button', { name: '打开账户菜单' });
expect(ref.current).toBe(button);
expect(screen.getByTestId('account-trigger')).toBe(button);
expect(button).toHaveAttribute('type', 'button');
expect(button).toHaveAttribute('aria-haspopup', 'menu');
expect(button).toHaveAttribute('aria-expanded', 'true');
expect(button).toHaveAttribute('aria-controls', 'account-menu');
expect(within(button).getByTestId('account-surface')).toContainElement(screen.getByTestId('account-avatar'));
expect(within(button).getByText('小麦')).toBeInTheDocument();
fireEvent.click(button);
expect(onClick).toHaveBeenCalledTimes(1);
expect(clickedButton).toBe(button);
expect(button).toHaveAttribute('aria-expanded', 'true');
});
it('supports callback refs and native button attributes, including disabled clicks', () => {
const ref = vi.fn<(node: HTMLButtonElement | null) => void>();
const onClick = vi.fn();
const view = render(<AccountMenuTrigger
ref={ref}
accountName="小麦"
aria-label="账户菜单"
type="submit"
title="查看账户"
disabled
onClick={onClick}
/>);
const button = screen.getByRole('button', { name: '账户菜单' });
expect(ref.mock.calls.at(-1)?.[0]).toBe(button);
expect(button).toHaveAttribute('type', 'submit');
expect(button).toHaveAttribute('title', '查看账户');
expect(button).toBeDisabled();
fireEvent.click(button);
expect(onClick).not.toHaveBeenCalled();
view.unmount();
expect(ref.mock.calls.at(-1)?.[0]).toBe(null);
});
it('preserves the avatar and accessible name when collapsed while hiding the account name and arrow', () => {
const view = render(<AccountMenuTrigger
accountName="小麦"
avatarUrl="https://example.test/avatar.png"
avatarTestId="account-avatar"
aria-label="账户菜单"
/>);
const button = screen.getByRole('button', { name: '账户菜单' });
const avatar = screen.getByTestId('account-avatar');
expect(within(button).getByText('小麦')).toBeInTheDocument();
expect(button.querySelector('svg')).toBeInTheDocument();
view.rerender(<AccountMenuTrigger
accountName="小麦"
avatarUrl="https://example.test/avatar.png"
avatarTestId="account-avatar"
aria-label="账户菜单"
collapsed
/>);
expect(screen.getByRole('button', { name: '账户菜单' })).toBe(button);
expect(screen.getByTestId('account-avatar')).toBe(avatar);
expect(avatar.querySelector('img')).toHaveAttribute('src', 'https://example.test/avatar.png');
expect(within(button).queryByText('小麦')).not.toBeInTheDocument();
expect(button.querySelector('svg')).not.toBeInTheDocument();
});
it('uses the default or preferred expanded width and fills its container when collapsed', () => {
const view = render(<AccountMenuTrigger accountName="小麦" aria-label="账户菜单" />);
const button = screen.getByRole('button', { name: '账户菜单' });
expect(button).toHaveStyle({ width: '150px' });
view.rerender(<AccountMenuTrigger accountName="小麦" aria-label="账户菜单" expandedWidth={188} />);
expect(button).toHaveStyle({ width: '188px' });
view.rerender(<AccountMenuTrigger accountName="小麦" aria-label="账户菜单" expandedWidth={188} collapsed />);
expect(button.style.width).toBe('');
});
});
-1
View File
@@ -964,7 +964,6 @@ it('switches editor pages while retaining unfinished knowledge form inputs', asy
expect(instructions).not.toBeVisible();
expect(screen.getByLabelText('模型')).toBeVisible();
expect(screen.queryByRole('navigation', { name: '配置分类' })).not.toBeInTheDocument();
fireEvent.click(screen.getByText('上传自己的文件'));
fireEvent.click(screen.getByRole('button', { name: '新建知识库' }));
const knowledgeName = screen.getByLabelText('知识库名称');
fireEvent.change(knowledgeName, { target: { value: '还没创建的资料' } });
+5 -5
View File
@@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { CloudKnowledgePanel } from '@/pages/CloudAgents/CloudKnowledge';
@@ -13,13 +13,13 @@ afterEach(cleanup);
function openCreation() {
render(<CloudKnowledgePanel slug={slug} onCreated={() => undefined} />);
fireEvent.click(screen.getByText('上传自己的文件'));
fireEvent.click(screen.getByRole('button', { name: '新建知识库' }));
}
it('explains who configures an empty embedding catalog and refreshes without losing the name', async () => {
api.call.mockResolvedValueOnce(empty).mockResolvedValueOnce(available);
openCreation();
expect(within(screen.getByRole('region', { name: '查看我的知识库' })).getByRole('button', { name: '从电脑上传', exact: true })).toBeDisabled();
expect(await screen.findByText('暂时没有可用的向量模型。Embedding 只在 Yuxi 配置,请联系平台管理员启用后刷新;向量化费用由平台承担,不计入用户用量或智能体费用上限。')).toBeVisible();
expect(screen.getByRole('combobox', { name: '向量模型' })).toBeDisabled();
expect(screen.getByText(/上传到知识库的文档可供/)).toHaveTextContent('embedding 向量化由平台承担,不计入用户用量、词元点数或智能体费用上限');
@@ -34,7 +34,7 @@ it('explains who configures an empty embedding catalog and refreshes without los
expect(screen.getByRole('textbox', { name: '知识库名称' })).toHaveValue('创作资料');
expect(screen.queryByText('管理员配置说明')).not.toBeInTheDocument();
fireEvent.change(screen.getByRole('combobox', { name: '向量模型' }), { target: { value: 'relay:fixture-embedding' } });
expect(screen.getByRole('button', { name: '创建并选用' })).toBeEnabled();
expect(screen.getByRole('button', { name: '创建知识库' })).toBeEnabled();
});
it('shows loading instead of suggesting configuration is missing before the catalog arrives', () => {
@@ -63,7 +63,7 @@ it('clears a removed selection after refresh so a new create cannot use an unava
fireEvent.change(screen.getByRole('combobox', { name: '向量模型' }), { target: { value: 'relay:fixture-embedding' } });
fireEvent.click(screen.getByRole('button', { name: '刷新知识库与模型' }));
await waitFor(() => expect(screen.getByRole('combobox', { name: '向量模型' })).toHaveValue(''));
expect(screen.getByRole('button', { name: '创建并选用' })).toBeDisabled();
expect(screen.getByRole('button', { name: '创建知识库' })).toBeDisabled();
expect(api.call.mock.calls.every(([operation]) => operation === 'knowledge')).toBe(true);
});
@@ -77,7 +77,7 @@ it('preserves an uncertain creation intent when its model disappears on refresh'
await screen.findByRole('option', { name: '资料向量模型' });
fireEvent.change(screen.getByRole('textbox', { name: '知识库名称' }), { target: { value: '创作资料' } });
fireEvent.change(screen.getByRole('combobox', { name: '向量模型' }), { target: { value: 'relay:fixture-embedding' } });
fireEvent.click(screen.getByRole('button', { name: '创建并选用' }));
fireEvent.click(screen.getByRole('button', { name: '创建知识库' }));
await screen.findByRole('alert');
fireEvent.click(screen.getByRole('button', { name: '刷新知识库与模型' }));
await screen.findByText('管理员配置说明');
-345
View File
@@ -1,345 +0,0 @@
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { CloudKnowledgeAttachmentPicker } from '@/pages/CloudAgents/CloudKnowledgeAttachmentPicker';
import { CloudKnowledgePanel } from '@/pages/CloudAgents/CloudKnowledge';
import type { CloudAttachment, CloudKnowledgeFile, CloudThread } from '../../shared/cloud-agents';
const api = vi.hoisted(() => ({ call: vi.fn(), list: vi.fn() }));
vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api }));
const slug = 'ml-' + 'a'.repeat(32), otherSlug = 'ml-' + 'b'.repeat(32);
const threads: CloudThread[] = [
{ thread_id: 'spring', title: '春天的观察讨论', agent_slug: slug, client_thread_id: null, updated_at: '2026-10-13T00:00:00Z', mode: 'published', run_id: null, status: 'completed', unread: false },
{ thread_id: 'reading', title: '图书阅读记录', agent_slug: otherSlug, client_thread_id: null, updated_at: '2026-10-12T00:00:00Z', mode: 'preview', run_id: null, status: 'completed', unread: false },
];
function attachment(id: string, name: string, size = 1536): CloudAttachment {
return { file_id: id, file_name: name, file_size: size, path: '/attachments/' + name, original_path: '/attachments/' + name };
}
function knowledgeFile(id: string): CloudKnowledgeFile {
return { file_id: 'knowledge-' + id, name: id + '.txt', size: 1536, status: 'uploaded', available: false, chunk_count: 0, error: null };
}
let sourceFiles: Record<string, CloudAttachment[]>;
const imported = vi.fn<(file: CloudKnowledgeFile) => Promise<void>>();
const pending = vi.fn();
const close = vi.fn();
const props = { slug, kbId: 'kb_own', kbName: '我的观察记录', busy: false, act: async (task: () => Promise<void>) => task(), onImported: imported, onPendingChange: pending, onClose: close };
beforeEach(() => {
vi.resetAllMocks(); imported.mockResolvedValue();
sourceFiles = { spring: [attachment('a', '观察报告.md'), attachment('b', '植物资料.pdf')], reading: [attachment('a', '阅读笔记.txt')] };
api.list.mockResolvedValue({ agents: [{ slug, name: '资料小助手' }], next_cursor: null });
api.call.mockImplementation(async (operation: string, input: { thread_id?: string; attachment_id?: string }) => {
if (operation === 'threads') return { threads, next_offset: null };
if (operation === 'received') return { agents: [{ slug: otherSlug, name: '阅读搭档' }] };
if (operation === 'attachments') return { attachments: sourceFiles[input.thread_id!] };
if (operation === 'importKnowledgeAttachment') return knowledgeFile(input.attachment_id!);
throw new Error('Unexpected operation ' + operation);
});
});
afterEach(cleanup);
async function chooseChat(title = threads[0].title) {
fireEvent.click(await screen.findByRole('button', { name: '选择对话 ' + title }));
}
async function chooseFile(name: string) { fireEvent.click(await screen.findByRole('checkbox', { name: '选择文件 ' + name })); }
function imports() { return api.call.mock.calls.filter(([operation]) => operation === 'importKnowledgeAttachment').map(([, input]) => input); }
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
it('searches named and dated chats, keeps selections across chats, and imports only explicitly selected attachments', async () => {
const original = api.call.getMockImplementation()!;
api.call.mockImplementation((operation, input) => operation === 'threads'
? Promise.resolve(input.offset ? { threads: [threads[1]], next_offset: null } : { threads: [threads[0]], next_offset: 50 }) : original(operation, input));
render(<CloudKnowledgeAttachmentPicker {...props} />);
expect(screen.getByRole('dialog', { name: '从聊天选择文件' })).toHaveTextContent('导入到「我的观察记录」');
await screen.findByText(/阅读搭档 · 2026/);
fireEvent.change(screen.getByRole('textbox', { name: '搜索对话' }), { target: { value: '资料小助手' } });
expect(screen.queryByRole('button', { name: '选择对话 图书阅读记录' })).not.toBeInTheDocument();
await chooseChat();
expect(await screen.findByText('MD · 2 KB')).toBeVisible();
fireEvent.change(screen.getByRole('textbox', { name: '搜索文件' }), { target: { value: '观察' } });
expect(screen.queryByRole('checkbox', { name: '选择文件 植物资料.pdf' })).not.toBeInTheDocument();
await chooseFile('观察报告.md');
fireEvent.change(screen.getByRole('textbox', { name: '搜索对话' }), { target: { value: '阅读' } });
await chooseChat(threads[1].title); await chooseFile('阅读笔记.txt');
expect(within(screen.getByRole('list', { name: '已选聊天文件' })).getAllByRole('listitem')).toHaveLength(2);
expect(imports()).toHaveLength(0);
fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' }));
await waitFor(() => expect(close).toHaveBeenCalledTimes(1));
expect(imports()).toEqual([
expect.objectContaining({ slug, kb_id: 'kb_own', thread_id: 'spring', attachment_id: 'a' }),
expect.objectContaining({ slug, kb_id: 'kb_own', thread_id: 'reading', attachment_id: 'a' }),
]);
expect(new Set(imports().map(input => input.operation_id)).size).toBe(2);
expect(imported).toHaveBeenCalledTimes(2);
expect(api.call.mock.calls.some(([operation]) => ['history', 'files', 'deleteAttachment'].includes(operation))).toBe(false);
expect(pending).toHaveBeenLastCalledWith(false);
});
it('retries only failed files with their original identity, retaining successful files and processing callbacks', async () => {
const original = api.call.getMockImplementation()!;
let failure = true;
api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' && input.attachment_id === 'b' && failure
? Promise.reject(new Error('连接超时')) : original(operation, input));
render(<CloudKnowledgeAttachmentPicker {...props} />);
await chooseChat(); await chooseFile('观察报告.md'); await chooseFile('植物资料.pdf');
fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' }));
await screen.findByText(/导入结果尚未确认:连接超时/);
expect(close).not.toHaveBeenCalled(); expect(imported).toHaveBeenCalledTimes(1);
expect(screen.getByRole('button', { name: '关闭文件选择' })).toBeDisabled();
expect(screen.getByRole('button', { name: '选择对话 图书阅读记录' })).toBeDisabled();
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' });
expect(close).not.toHaveBeenCalled();
const first = imports();
failure = false;
fireEvent.click(screen.getByRole('button', { name: '重试失败的 1 个文件' }));
await waitFor(() => expect(close).toHaveBeenCalledTimes(1));
expect(imports()).toHaveLength(3); expect(imports()[2]).toEqual(first[1]);
expect(imported).toHaveBeenCalledTimes(2);
});
it('never reimports a confirmed file after a processing callback failure and permits a later independent batch', async () => {
imported.mockRejectedValueOnce(new Error('处理失败'));
render(<CloudKnowledgeAttachmentPicker {...props} />);
await chooseChat(); await chooseFile('观察报告.md');
fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' }));
await screen.findByText(/文件已导入,处理未全部启动/);
expect(imports()).toHaveLength(1); expect(close).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: '关闭文件选择' })).toBeEnabled();
await chooseFile('植物资料.pdf');
fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' }));
await waitFor(() => expect(close).toHaveBeenCalledTimes(1));
expect(imports().map(input => input.attachment_id)).toEqual(['a', 'b']);
});
it('shows file types and sizes, disables unsupported legacy formats and oversize files, and accepts supported files at the size boundary', async () => {
sourceFiles.spring = [attachment('legacy-doc', '旧文档.doc'), attachment('legacy-xls', '旧表格.xls'), attachment('markdown', '资料.markdown'),
attachment('image', '图片.png'), attachment('large', '过大的资料.pdf', 5 * 1024 * 1024 + 1), attachment('boundary', '可导入.DOCX', 5 * 1024 * 1024)];
render(<CloudKnowledgeAttachmentPicker {...props} />); await chooseChat();
for (const name of ['旧文档.doc', '旧表格.xls', '资料.markdown', '图片.png', '过大的资料.pdf']) expect(await screen.findByRole('checkbox', { name: '选择文件 ' + name })).toBeDisabled();
expect(screen.getAllByText('此格式暂不支持知识库导入')).toHaveLength(4);
expect(screen.getByText('超过 5 MB,暂不能导入')).toBeVisible();
expect(screen.getByText('DOCX · 5.0 MB')).toBeVisible();
await chooseFile('可导入.DOCX');
fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' }));
await waitFor(() => expect(close).toHaveBeenCalledOnce());
expect(imports()).toEqual([expect.objectContaining({ attachment_id: 'boundary' })]);
});
it('distinguishes loading, empty chats, chat read failures and a successful retry', async () => {
const loading = deferred<{ threads: CloudThread[]; next_offset: null }>();
const original = api.call.getMockImplementation()!;
let mode = 'loading';
api.call.mockImplementation((operation, input) => {
if (operation !== 'threads') return original(operation, input);
return mode === 'loading' ? loading.promise : mode === 'error' ? Promise.reject(new Error('目录离线')) : Promise.resolve({ threads, next_offset: null });
});
const view = render(<CloudKnowledgeAttachmentPicker {...props} />);
expect(screen.getByText('正在读取对话…')).toBeVisible();
expect(screen.queryByText('还没有个人智能体对话。')).not.toBeInTheDocument();
await act(async () => loading.resolve({ threads: [], next_offset: null }));
expect(screen.getByText('还没有个人智能体对话。')).toBeVisible();
view.unmount(); mode = 'error'; render(<CloudKnowledgeAttachmentPicker {...props} />);
await screen.findByText('对话读取失败:目录离线');
expect(screen.queryByText('还没有个人智能体对话。')).not.toBeInTheDocument();
mode = 'ready'; fireEvent.click(screen.getByRole('button', { name: '重试读取对话' }));
await screen.findByRole('button', { name: '选择对话 春天的观察讨论' });
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
it('distinguishes file loading, failures, empty files and search results while ignoring a late response from a previous chat', async () => {
const late = deferred<{ attachments: CloudAttachment[] }>();
const original = api.call.getMockImplementation()!;
let readingFails = true;
api.call.mockImplementation((operation, input) => operation === 'attachments'
? input.thread_id === 'spring' ? late.promise : readingFails ? Promise.reject(new Error('文件离线')) : Promise.resolve({ attachments: [] })
: original(operation, input));
render(<CloudKnowledgeAttachmentPicker {...props} />); await chooseChat();
expect(screen.getByText('正在读取文件…')).toBeVisible();
expect(screen.queryByText('这段对话还没有上传文件。')).not.toBeInTheDocument();
await chooseChat(threads[1].title); await screen.findByText('文件读取失败:文件离线');
await act(async () => late.resolve({ attachments: sourceFiles.spring }));
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
readingFails = false; fireEvent.click(screen.getByRole('button', { name: '重试读取文件' }));
await screen.findByText('这段对话还没有上传文件。');
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
fireEvent.change(screen.getByRole('textbox', { name: '搜索对话' }), { target: { value: '不存在' } });
expect(screen.getByText('没有匹配的对话。')).toBeVisible();
});
it('guards X, escape and selection during an in-flight import, then closes only after all files finish', async () => {
const first = deferred<CloudKnowledgeFile>();
const original = api.call.getMockImplementation()!;
api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' && input.attachment_id === 'a' ? first.promise : original(operation, input));
render(<CloudKnowledgeAttachmentPicker {...props} />); await chooseChat(); await chooseFile('观察报告.md'); await chooseFile('植物资料.pdf');
fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' }));
expect(imports()).toHaveLength(1);
expect(screen.getByRole('button', { name: '关闭文件选择' })).toBeDisabled();
expect(screen.getByRole('checkbox', { name: '选择文件 观察报告.md' })).toBeDisabled();
expect(screen.queryByRole('button', { name: '放弃重试', exact: true })).not.toBeInTheDocument();
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' });
expect(close).not.toHaveBeenCalled();
await act(async () => first.resolve(knowledgeFile('a')));
await waitFor(() => expect(close).toHaveBeenCalledOnce());
expect(imports()).toHaveLength(2); expect(imported).toHaveBeenCalledTimes(2);
});
it('does not read or import without management permission and resumes a confirmed receipt without importing it twice', async () => {
const receipt = deferred<CloudKnowledgeFile>();
const original = api.call.getMockImplementation()!;
api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' ? receipt.promise : original(operation, input));
const view = render(<CloudKnowledgeAttachmentPicker {...props} canRead={false} />);
expect(api.call).not.toHaveBeenCalled(); expect(api.list).not.toHaveBeenCalled();
view.rerender(<CloudKnowledgeAttachmentPicker {...props} />); await chooseChat(); await chooseFile('观察报告.md');
fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' }));
view.rerender(<CloudKnowledgeAttachmentPicker {...props} canRead={false} />);
await act(async () => receipt.resolve(knowledgeFile('a')));
expect(imports()).toHaveLength(1); expect(imported).not.toHaveBeenCalled(); expect(close).not.toHaveBeenCalled();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(pending).toHaveBeenLastCalledWith(true);
view.rerender(<CloudKnowledgeAttachmentPicker {...props} />);
fireEvent.click(screen.getByRole('button', { name: '继续导入 1 个文件' }));
await waitFor(() => expect(close).toHaveBeenCalledOnce());
expect(imports()).toHaveLength(1); expect(imported).toHaveBeenCalledOnce();
});
it('ignores an old-target response and stops the old batch after a knowledge-base scope change', async () => {
const response = deferred<CloudKnowledgeFile>();
const original = api.call.getMockImplementation()!;
api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' ? response.promise : original(operation, input));
const view = render(<CloudKnowledgeAttachmentPicker {...props} />);
await chooseChat(); await chooseFile('观察报告.md'); await chooseFile('植物资料.pdf');
fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' }));
view.rerender(<CloudKnowledgeAttachmentPicker {...props} kbId="kb_other" kbName="其他资料" />);
await act(async () => response.resolve(knowledgeFile('a')));
expect(imports()).toHaveLength(1); expect(imported).not.toHaveBeenCalled(); expect(close).not.toHaveBeenCalled();
expect(screen.getByRole('dialog')).toHaveTextContent('导入到「其他资料」');
expect(screen.queryByRole('list', { name: '文件导入结果' })).not.toBeInTheDocument();
});
it('does not continue a batch or notify the old owner after unmount', async () => {
const response = deferred<CloudKnowledgeFile>();
const original = api.call.getMockImplementation()!;
api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' ? response.promise : original(operation, input));
const view = render(<CloudKnowledgeAttachmentPicker {...props} />);
await chooseChat(); await chooseFile('观察报告.md'); await chooseFile('植物资料.pdf');
fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' })); view.unmount();
await act(async () => response.resolve(knowledgeFile('a')));
expect(imports()).toHaveLength(1); expect(imported).not.toHaveBeenCalled(); expect(close).not.toHaveBeenCalled();
expect(pending).toHaveBeenLastCalledWith(false);
});
it('hides the portal after a confirmed loss of ownership so the parent can discard pending requests without importing again', async () => {
const response = deferred<CloudKnowledgeFile>();
const original = api.call.getMockImplementation()!;
let owned = true;
api.call.mockImplementation((operation, input) => {
if (operation === 'knowledge') return Promise.resolve({ databases: owned ? [{ kb_id: 'kb_own', name: '我的观察记录' }] : [], models: [] });
if (operation === 'knowledgeFiles') return Promise.resolve({ files: [], next_offset: null });
if (operation === 'importKnowledgeAttachment') return response.promise;
return original(operation, input);
});
const created = vi.fn();
const view = render(<CloudKnowledgePanel slug={slug} onCreated={created} />);
await screen.findByRole('option', { name: '我的观察记录' });
fireEvent.change(screen.getByRole('combobox', { name: '管理的知识库' }), { target: { value: 'kb_own' } });
fireEvent.click(await screen.findByRole('button', { name: '从聊天选择文件' }));
await chooseChat(); await chooseFile('观察报告.md'); await chooseFile('植物资料.pdf');
fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' }));
owned = false;
view.rerender(<CloudKnowledgePanel slug={slug} onCreated={created} refreshToken={1} />);
await waitFor(() => expect(screen.queryByRole('dialog', { name: '从聊天选择文件' })).not.toBeInTheDocument());
await act(async () => response.resolve(knowledgeFile('a')));
const discard = await screen.findByRole('button', { name: '放弃未确认操作并重新选择' });
await waitFor(() => expect(discard).toBeEnabled());
expect(imports()).toHaveLength(1);
expect(api.call.mock.calls.some(([operation]) => operation === 'processKnowledge')).toBe(false);
fireEvent.click(discard);
expect(screen.queryByRole('button', { name: '放弃未确认操作并重新选择' })).not.toBeInTheDocument();
expect(screen.getByRole('combobox', { name: '管理的知识库' })).toHaveValue('');
});
it('allows cancelling unsubmitted selections and does not mutate knowledge merely by choosing files', async () => {
render(<CloudKnowledgeAttachmentPicker {...props} />); await chooseChat(); await chooseFile('观察报告.md');
expect(pending).toHaveBeenLastCalledWith(true);
fireEvent.click(screen.getByRole('button', { name: '关闭文件选择' }));
expect(close).toHaveBeenCalledOnce(); expect(imports()).toHaveLength(0); expect(imported).not.toHaveBeenCalled();
});
it.each(['button', 'escape'])('allows %s dismissal during a catalog failure before any import has been sent', async control => {
const view = render(<CloudKnowledgeAttachmentPicker {...props} />);
await chooseChat(); await chooseFile('观察报告.md');
view.rerender(<CloudKnowledgeAttachmentPicker {...props} busy />);
expect(screen.getByRole('button', { name: '关闭文件选择' })).toBeEnabled();
if (control === 'button') fireEvent.click(screen.getByRole('button', { name: '关闭文件选择' }));
else fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' });
await waitFor(() => expect(close).toHaveBeenCalledOnce());
expect(imports()).toHaveLength(0);
});
it('offers a read-only catalog refresh while an uncertain import is blocked, then retries only the original operation', async () => {
const original = api.call.getMockImplementation()!;
let fails = true;
api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' && fails
? Promise.reject(new Error('结果未确认')) : original(operation, input));
const refresh = vi.fn();
const view = render(<CloudKnowledgeAttachmentPicker {...props} onRefreshKnowledge={refresh} />);
await chooseChat(); await chooseFile('观察报告.md');
fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' }));
await screen.findByText(/导入结果尚未确认:结果未确认/);
const request = imports()[0];
view.rerender(<CloudKnowledgeAttachmentPicker {...props} busy onRefreshKnowledge={refresh} />);
expect(screen.getByRole('button', { name: '关闭文件选择' })).toBeDisabled();
expect(screen.getByRole('button', { name: '重试失败的 1 个文件' })).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: '刷新知识库', exact: true }));
expect(refresh).toHaveBeenCalledOnce(); expect(imports()).toHaveLength(1);
fails = false;
view.rerender(<CloudKnowledgeAttachmentPicker {...props} onRefreshKnowledge={refresh} />);
fireEvent.click(screen.getByRole('button', { name: '重试失败的 1 个文件' }));
await waitFor(() => expect(close).toHaveBeenCalledOnce());
expect(imports()).toEqual([request, request]); expect(imported).toHaveBeenCalledOnce();
});
it('requires explicit confirmation to abandon a retry during a catalog failure, while keeping the request through cancelling that confirmation', async () => {
const original = api.call.getMockImplementation()!;
api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment'
? Promise.reject(new Error('附件已无法读取')) : original(operation, input));
const view = render(<CloudKnowledgeAttachmentPicker {...props} />);
await chooseChat(); await chooseFile('观察报告.md');
fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' }));
await screen.findByText(/导入结果尚未确认:附件已无法读取/);
view.rerender(<CloudKnowledgeAttachmentPicker {...props} busy />);
fireEvent.click(screen.getByRole('button', { name: '放弃重试', exact: true }));
expect(screen.getByText('这只会结束当前页面的重试。已保存的文件会保留,可刷新知识库确认。')).toBeVisible();
expect(close).not.toHaveBeenCalled(); expect(pending).toHaveBeenLastCalledWith(true);
fireEvent.click(screen.getByRole('button', { name: '继续保留' }));
expect(screen.getByRole('button', { name: '重试失败的 1 个文件' })).toBeDisabled();
expect(screen.getByRole('list', { name: '文件导入结果' })).toHaveTextContent('观察报告.md');
fireEvent.click(screen.getByRole('button', { name: '放弃重试', exact: true }));
fireEvent.click(screen.getByRole('button', { name: '确认放弃重试' }));
expect(close).toHaveBeenCalledOnce(); expect(pending).toHaveBeenLastCalledWith(false);
expect(imports()).toHaveLength(1); expect(imported).not.toHaveBeenCalled();
expect(screen.queryByRole('list', { name: '文件导入结果' })).not.toBeInTheDocument();
expect(screen.queryByRole('list', { name: '已选聊天文件' })).not.toBeInTheDocument();
});
it('exits a partly successful batch with a permanent source error without deleting or replaying successful imports', async () => {
const original = api.call.getMockImplementation()!;
api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' && input.attachment_id === 'b'
? Promise.reject(new Error('原附件已经删除')) : original(operation, input));
render(<CloudKnowledgeAttachmentPicker {...props} />);
await chooseChat(); await chooseFile('观察报告.md'); await chooseFile('植物资料.pdf');
fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' }));
await screen.findByText(/导入结果尚未确认:原附件已经删除/);
expect(imported).toHaveBeenCalledOnce();
expect(screen.getByRole('button', { name: '关闭文件选择' })).toBeDisabled();
expect(screen.queryByText(/可继续切换对话选择/)).not.toBeInTheDocument();
const before = api.call.mock.calls.length;
fireEvent.click(screen.getByRole('button', { name: '放弃重试', exact: true }));
fireEvent.click(screen.getByRole('button', { name: '确认放弃重试' }));
expect(close).toHaveBeenCalledOnce(); expect(pending).toHaveBeenLastCalledWith(false);
expect(imported).toHaveBeenCalledOnce(); expect(imports()).toHaveLength(2);
expect(api.call).toHaveBeenCalledTimes(before);
});
+80 -22
View File
@@ -19,9 +19,7 @@ function Harness({ initial = [], disabled = false, agentSlug = slug }: { initial
return <ConfigurationFields slug={agentSlug} value={value} disabled={disabled} onChange={next => { changes(next); setValue(next); }} />;
}
function choose(id: string) { fireEvent.change(screen.getByRole('combobox', { name: '选择知识库' }), { target: { value: id } }); }
function openUpload() { fireEvent.click(screen.getByText('上传自己的文件')); }
async function openOwn(readyText = '上传第一份文档,让智能体用你的资料回答。') {
openUpload();
await within(screen.getByRole('combobox', { name: '管理的知识库' })).findByRole('option', { name: own.name });
fireEvent.change(screen.getByRole('combobox', { name: '管理的知识库' }), { target: { value: own.kb_id } });
await screen.findByText(readyText);
@@ -47,6 +45,20 @@ it('joins readable IDs to the owned-only catalog and selects multiple libraries
const sharedGroup = await screen.findByRole('group', { name: 'MakeLore知识' });
expect(within(sharedGroup).getByRole('option', { name: '创作指南' })).toBeInTheDocument();
expect(within(screen.getByRole('group', { name: '我的知识库' })).getByRole('option', { name: own.name })).toBeInTheDocument();
const creation = screen.getByRole('region', { name: '上传自己的文件', exact: true });
const management = screen.getByRole('region', { name: '查看我的知识库', exact: true });
const selection = screen.getByRole('region', { name: '选择知识库', exact: true });
expect(within(creation).getByRole('button', { name: '新建知识库', exact: true })).toBeVisible();
expect(within(creation).queryByRole('combobox', { name: '管理的知识库' })).not.toBeInTheDocument();
expect(within(creation).queryByRole('button', { name: '从电脑上传', exact: true })).not.toBeInTheDocument();
expect(within(management).getByRole('combobox', { name: '管理的知识库' })).toBeVisible();
expect(within(management).getByRole('button', { name: '从电脑上传', exact: true })).toBeDisabled();
expect(within(management).queryByRole('button', { name: '新建知识库', exact: true })).not.toBeInTheDocument();
expect(within(selection).getByRole('combobox', { name: '选择知识库', exact: true })).toBeVisible();
expect(creation.compareDocumentPosition(management) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(management.compareDocumentPosition(selection) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(screen.queryByText('添加到我的知识库', { exact: true })).not.toBeInTheDocument();
expect(screen.queryByText('选择或新建自己的知识库,再从电脑上传资料。选用会加入右侧配置,保存并应用后生效。', { exact: true })).not.toBeInTheDocument();
expect(screen.getByText('可多选,每次选择一个知识库加入配置。')).toBeVisible();
choose('shared'); choose(own.kb_id); choose('other-shared');
expect(within(screen.getByRole('list', { name: '已选知识库' })).getAllByRole('listitem')).toHaveLength(3);
@@ -57,13 +69,13 @@ it('joins readable IDs to the owned-only catalog and selects multiple libraries
expect(changes).toHaveBeenLastCalledWith(expect.objectContaining({ knowledges: ['own', 'other-shared'] }));
});
it('never offers shared libraries to document management or mounts their mutating controls', async () => {
it('never offers shared libraries to document management or enables their upload controls', async () => {
databases = [];
render(<Harness initial={['shared']} />);
await screen.findByText('MakeLore知识 · 只读'); openUpload();
await screen.findByText('MakeLore知识 · 只读');
const manager = screen.getByRole('combobox', { name: '管理的知识库' });
expect(within(manager).queryByRole('option', { name: '创作指南' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '从电脑上传', exact: true })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '从电脑上传', exact: true })).toBeDisabled();
expect(screen.queryByRole('button', { name: '删除知识库', exact: true })).not.toBeInTheDocument();
expect(api.call.mock.calls.every(([operation]) => operation !== 'knowledgeFiles')).toBe(true);
});
@@ -76,9 +88,9 @@ it('allows readable binding after an owned-list failure while withholding docume
render(<Harness />);
await screen.findByText('我的知识库目录暂不可用,仍可选用可读取的知识;上传文件请刷新重试。');
expect(screen.queryByRole('group', { name: 'MakeLore知识' })).not.toBeInTheDocument();
choose('shared'); openUpload();
choose('shared');
expect(screen.getByRole('combobox', { name: '管理的知识库' })).toBeDisabled();
expect(screen.queryByRole('button', { name: '从电脑上传', exact: true })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '从电脑上传', exact: true })).toBeDisabled();
failed = false;
fireEvent.click(screen.getByRole('button', { name: '刷新知识库', exact: true }));
await screen.findByText('MakeLore知识 · 只读');
@@ -99,48 +111,92 @@ it('retains missing IDs for removal during catalog failure instead of treating t
expect(changes).toHaveBeenLastCalledWith(expect.objectContaining({ knowledges: [] }));
});
it('enforces the 100-library limit and restores selection after removal', async () => {
it('limits bindings to 100 libraries without preventing browsing or creation', async () => {
const selected = Array.from({ length: 100 }, (_, index) => 'old-' + index);
const original = api.call.getMockImplementation()!;
const created = { kb_id: 'new-own', name: '新资料' };
api.call.mockImplementation((operation, input) => {
if (operation === 'createKnowledge') {
databases.push(created);
catalog.resources.knowledges!.push({ key: created.kb_id, name: created.name });
return Promise.resolve(created);
}
return original(operation, input);
});
render(<Harness initial={selected} />);
await screen.findByRole('group', { name: 'MakeLore知识' });
expect(screen.getByRole('combobox', { name: '选择知识库' })).toBeDisabled();
openUpload(); expect(screen.getByRole('button', { name: '新建知识库' })).toBeDisabled();
await openOwn();
expect(screen.getByRole('button', { name: '从电脑上传', exact: true })).toBeEnabled();
expect(changes).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '新建知识库' }));
fireEvent.change(screen.getByRole('textbox', { name: '知识库名称' }), { target: { value: created.name } });
fireEvent.change(screen.getByRole('combobox', { name: '向量模型' }), { target: { value: 'embedding' } });
expect(screen.getByRole('button', { name: '创建知识库', exact: true })).toBeEnabled();
fireEvent.click(screen.getByRole('button', { name: '创建知识库', exact: true }));
await waitFor(() => expect(screen.getByRole('combobox', { name: '管理的知识库' })).toHaveValue(created.kb_id));
expect(changes).not.toHaveBeenCalled();
expect(screen.getByRole('combobox', { name: '选择知识库' })).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: '移除知识库 old-0' }));
choose('shared');
await within(screen.getByRole('combobox', { name: '选择知识库' })).findByRole('option', { name: created.name });
choose(created.kb_id);
expect(changes.mock.calls.at(-1)?.[0].knowledges).toHaveLength(100);
expect(changes.mock.calls.at(-1)?.[0].knowledges).toContain('shared');
expect(changes.mock.calls.at(-1)?.[0].knowledges).toContain(created.kb_id);
});
it('selects an existing owned library for uploads and cancelling a file picker does not process a file', async () => {
it('browses an owned library without binding it and only explicit selection changes the draft', async () => {
render(<Harness initial={['shared']} />);
await openOwn();
expect(changes).toHaveBeenLastCalledWith(expect.objectContaining({ knowledges: ['shared', own.kb_id] }));
expect(changes).not.toHaveBeenCalled();
expect(screen.queryByRole('button', { name: '移除知识库 ' + own.name })).not.toBeInTheDocument();
api.uploadKnowledge.mockResolvedValue(null);
fireEvent.click(screen.getByRole('button', { name: '从电脑上传', exact: true }));
await waitFor(() => expect(api.uploadKnowledge).toHaveBeenCalledTimes(1));
await waitFor(() => expect(screen.getByRole('button', { name: '从电脑上传', exact: true })).toBeEnabled());
expect(api.call.mock.calls.every(([operation]) => operation !== 'processKnowledge')).toBe(true);
expect(screen.getByRole('button', { name: '移除知识库 创作指南' })).toBeVisible();
expect(changes).not.toHaveBeenCalled();
choose(own.kb_id);
expect(changes).toHaveBeenLastCalledWith(expect.objectContaining({ knowledges: ['shared', own.kb_id] }));
});
it('appends a late creation result to the latest bindings after interleaved selection and removal', async () => {
it('keeps an upload failure in the management column while the creation form is open', async () => {
api.uploadKnowledge.mockRejectedValue(new Error('上传结果未确认'));
render(<Harness initial={['shared']} />);
await openOwn();
const creation = screen.getByRole('region', { name: '上传自己的文件', exact: true });
const management = screen.getByRole('region', { name: '查看我的知识库', exact: true });
fireEvent.click(within(creation).getByRole('button', { name: '新建知识库', exact: true }));
fireEvent.change(within(creation).getByRole('textbox', { name: '知识库名称' }), { target: { value: '还没创建的资料' } });
fireEvent.click(within(management).getByRole('button', { name: '从电脑上传', exact: true }));
expect(await within(management).findByRole('alert')).toHaveTextContent('上传结果未确认');
expect(within(creation).queryByRole('alert')).not.toBeInTheDocument();
expect(within(creation).getByRole('textbox', { name: '知识库名称' })).toHaveValue('还没创建的资料');
expect(changes).not.toHaveBeenCalled();
});
it('keeps a late creation separate from interleaved draft changes until explicitly selected', async () => {
catalog.models.push({ id: 'model2', name: '另一个模型' });
let resolve!: (value: CloudKnowledge) => void;
const original = api.call.getMockImplementation()!;
api.call.mockImplementation((operation, input) => operation === 'createKnowledge'
? new Promise(value => { resolve = value; }) : original(operation, input));
render(<Harness initial={['shared']} />);
await screen.findByRole('group', { name: '我的知识库' }); openUpload();
await screen.findByRole('group', { name: '我的知识库' });
fireEvent.click(screen.getByRole('button', { name: '新建知识库' }));
fireEvent.change(screen.getByRole('textbox', { name: '知识库名称' }), { target: { value: '新资料' } });
fireEvent.change(screen.getByRole('combobox', { name: '向量模型' }), { target: { value: 'embedding' } });
fireEvent.click(screen.getByRole('button', { name: '创建并选用' }));
fireEvent.click(screen.getByRole('button', { name: '创建知识库' }));
choose('other-shared'); fireEvent.change(screen.getByRole('combobox', { name: '模型' }), { target: { value: 'model2' } });
fireEvent.click(screen.getByRole('button', { name: '移除知识库 创作指南' }));
const created = { kb_id: 'new-own', name: '新资料' }; databases.push(created);
catalog.resources.knowledges!.push({ key: created.kb_id, name: created.name });
await act(async () => resolve(created));
await screen.findByRole('button', { name: '移除知识库 新资料' });
await waitFor(() => expect(screen.getByRole('combobox', { name: '管理的知识库' })).toHaveValue(created.kb_id));
expect(screen.queryByRole('button', { name: '移除知识库 新资料' })).not.toBeInTheDocument();
expect(changes).toHaveBeenLastCalledWith(expect.objectContaining({ model: 'model2', knowledges: ['other-shared'] }));
await within(screen.getByRole('combobox', { name: '选择知识库' })).findByRole('option', { name: created.name });
choose(created.kb_id);
expect(changes).toHaveBeenLastCalledWith(expect.objectContaining({ model: 'model2', knowledges: ['other-shared', 'new-own'] }));
});
@@ -284,22 +340,24 @@ it('finishes an authorized upload through a failed ownership refresh and does no
expect(api.call.mock.calls.filter(([operation]) => operation === 'processKnowledge')).toHaveLength(1);
});
it('adds an already authorized creation result while the editor is temporarily disabled by saving', async () => {
it('retains an authorized creation for management while saving without altering draft bindings', async () => {
let resolve!: (value: CloudKnowledge) => void;
const original = api.call.getMockImplementation()!;
api.call.mockImplementation((operation, input) => operation === 'createKnowledge'
? new Promise(value => { resolve = value; }) : original(operation, input));
const view = render(<Harness initial={['shared']} />);
await screen.findByRole('group', { name: '我的知识库' }); openUpload();
await screen.findByRole('group', { name: '我的知识库' });
fireEvent.click(screen.getByRole('button', { name: '新建知识库' }));
fireEvent.change(screen.getByRole('textbox', { name: '知识库名称' }), { target: { value: '新资料' } });
fireEvent.change(screen.getByRole('combobox', { name: '向量模型' }), { target: { value: 'embedding' } });
fireEvent.click(screen.getByRole('button', { name: '创建并选用' }));
fireEvent.click(screen.getByRole('button', { name: '创建知识库' }));
view.rerender(<Harness initial={['shared']} disabled />);
const created = { kb_id: 'new-own', name: '新资料' }; databases.push(created);
catalog.resources.knowledges!.push({ key: created.kb_id, name: created.name });
await act(async () => resolve(created));
expect(changes).toHaveBeenLastCalledWith(expect.objectContaining({ knowledges: ['shared', 'new-own'] }));
await waitFor(() => expect(screen.getByRole('combobox', { name: '管理的知识库' })).toHaveValue(created.kb_id));
expect(changes).not.toHaveBeenCalled();
expect(screen.queryByRole('button', { name: '移除知识库 新资料' })).not.toBeInTheDocument();
expect(screen.getByRole('combobox', { name: '选择知识库' })).toBeDisabled();
});
@@ -307,7 +365,7 @@ it('keeps an owned but unusable library manageable without adding an invalid bin
const unusable = { kb_id: 'disabled-embedding', name: '旧资料' }; databases.push(unusable);
render(<Harness initial={['shared', unusable.kb_id]} />);
await screen.findByRole('button', { name: '移除知识库 旧资料' });
expect(screen.getByText('已不可用')).toBeVisible(); openUpload();
expect(screen.getByText('已不可用')).toBeVisible();
const manager = screen.getByRole('combobox', { name: '管理的知识库' });
expect(within(manager).getByRole('option', { name: '旧资料(当前暂不可选用)' })).toBeEnabled();
fireEvent.click(screen.getByRole('button', { name: '移除知识库 旧资料' }));
+4 -18
View File
@@ -24,7 +24,6 @@ beforeEach(() => {
afterEach(() => { cleanup(); vi.useRealTimers(); });
async function open() {
render(<CloudKnowledgePanel slug="ml-agent" onCreated={() => undefined} />);
fireEvent.click(screen.getByText('上传自己的文件'));
await screen.findByRole('option', { name: '资料' });
fireEvent.change(screen.getByLabelText('管理的知识库'), { target: { value: 'kb' } });
}
@@ -33,10 +32,13 @@ async function tick() { await act(async () => { await vi.advanceTimersByTimeAsyn
it('uploads then processes automatically and refreshes through queue, parse, index and readiness', async () => {
await open();
await screen.findByText('上传第一份文档,让智能体用你的资料回答。');
expect(screen.queryByRole('button', { name: '从聊天选择文件' })).not.toBeInTheDocument();
vi.useFakeTimers();
api.uploadKnowledge.mockImplementation(async () => { items = [file]; return file; });
await act(async () => { fireEvent.click(screen.getByRole('button', { name: '从电脑上传', exact: true })); });
expect(screen.getByText('等待处理')).toBeVisible();
expect(within(screen.getByRole('region', { name: '查看我的知识库' })).getByRole('article', { name: file.name })).toBeVisible();
expect(within(screen.getByRole('region', { name: '上传自己的文件' })).queryByRole('article')).not.toBeInTheDocument();
expect(screen.queryByText(/0 个分块/)).not.toBeInTheDocument();
expect(api.call).toHaveBeenCalledWith('processKnowledge', expect.objectContaining({ kb_id: 'kb', file_id: 'file' }));
items = [{ ...file, status: 'parsing', processing_task: { task_id: 'task', status: 'running', error: null } }];
@@ -48,6 +50,7 @@ it('uploads then processes automatically and refreshes through queue, parse, ind
expect(screen.getByText('可用于回答')).toBeVisible();
expect(screen.getByText(/12 段可检索内容/)).toBeVisible();
expect(api.uploadKnowledge).toHaveBeenCalledTimes(1);
expect(api.call.mock.calls.some(([operation]) => ['threads', 'attachments', 'importKnowledgeAttachment'].includes(operation))).toBe(false);
});
it('does not call zero chunks searchable and distinguishes loading from an empty library', async () => {
@@ -162,23 +165,6 @@ it('keeps all loaded pages refreshing and ignores a late response from the previ
expect(screen.getByText('上传第一份文档,让智能体用你的资料回答。')).toBeVisible();
});
it('imports an attachment and processes it without claiming immediate readiness', async () => {
const original = api.call.getMockImplementation()!;
api.call.mockImplementation(async (operation, input) => {
if (operation === 'threads') return { threads: [{ thread_id: 'thread', title: '我的对话' }], next_offset: null };
if (operation === 'attachments') return { attachments: [{ file_id: 'attachment', file_name: '附件.pdf' }] };
if (operation === 'importKnowledgeAttachment') { items = [file]; return file; }
return original(operation, input);
});
await open();
fireEvent.click(screen.getByRole('button', { name: '从聊天选择文件' }));
fireEvent.click(await screen.findByRole('button', { name: '选择对话 我的对话' }));
fireEvent.click(await screen.findByRole('checkbox', { name: '选择文件 附件.pdf' }));
fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' }));
await screen.findByText('等待处理');
expect(screen.queryByText('可用于回答')).not.toBeInTheDocument();
});
it('keeps indexed content available when replacement cleanup fails and permits retry', async () => {
items = [{ ...file, status: 'indexed', chunk_count: 4, available: true, replaces_file_id: 'old',
processing_task: { task_id: 'task', status: 'failed', error: '旧文档清理失败' } }];
+21 -2
View File
@@ -6,8 +6,9 @@ import { useAuthStore } from '@/stores/auth';
import { useUserProfileStore } from '@/stores/user-profile';
import { billingSummary, permanentBalance } from '../fixtures/permanent-points';
const api = vi.hoisted(() => ({ fetch: vi.fn() }));
const api = vi.hoisted(() => ({ fetch: vi.fn(), info: vi.fn() }));
vi.mock('@/lib/host-api', () => ({ hostApiFetch: api.fetch }));
vi.mock('sonner', () => ({ toast: { info: api.info } }));
let profileName: string;
function profileResponse() {
@@ -17,7 +18,8 @@ function profileResponse() {
}
function LocationProbe() {
const location = useLocation();
return <output data-testid="location-path">{location.pathname}</output>;
return <><output data-testid="location-path">{location.pathname}</output>
<output data-testid="location-state">{JSON.stringify(location.state)}</output></>;
}
function renderHome() {
return render(<MemoryRouter initialEntries={['/module-select']}><Routes>
@@ -76,9 +78,26 @@ it('opens the existing profile dialog and navigates to settings without saving p
fireEvent.click(screen.getByTestId('home-account-menu-trigger'));
fireEvent.click(within(screen.getByTestId('home-account-menu')).getByRole('menuitem', { name: '设置', exact: true }));
expect(await screen.findByTestId('location-path')).toHaveTextContent('/settings');
expect(screen.getByTestId('location-state')).toHaveTextContent('"settingsReturnTo":"/module-select"');
expect(writes()).toEqual([]);
});
it('keeps the current page and inputs when its unsaved-content guard rejects opening settings', async () => {
renderHome();
await screen.findByText('小麦,欢迎回来!');
fireEvent.click(screen.getByTestId('home-account-menu-trigger'));
const preventLeaving = (event: Event) => event.preventDefault();
window.addEventListener('beforeunload', preventLeaving);
try {
fireEvent.click(within(screen.getByTestId('home-account-menu')).getByRole('menuitem', { name: '设置', exact: true }));
expect(screen.getByTestId('ai-module-selection-page')).toBeInTheDocument();
expect(screen.getByTestId('home-account-menu-trigger')).toHaveAttribute('aria-expanded', 'true');
expect(screen.queryByTestId('location-path')).not.toBeInTheDocument();
expect(api.info).toHaveBeenCalledWith('请先保存当前修改,再打开设置。');
expect(writes()).toEqual([]);
} finally { window.removeEventListener('beforeunload', preventLeaving); }
});
it('closes the menu on Escape and outside clicks while preserving the wallet instance', async () => {
renderHome();
const trigger = await screen.findByTestId('home-account-menu-trigger');
@@ -44,6 +44,9 @@ vi.mock('@/components/layout/TitleBar', () => ({
/>
),
}));
vi.mock('@/components/account/StandaloneAccountDock', () => ({
StandaloneAccountDock: () => <div data-testid="standalone-account-dock-stub" />,
}));
function RouteContent() {
routeRenderMock();
@@ -68,6 +71,16 @@ describe('MainLayout module isolation', () => {
useSettingsStore.setState({ sidebarCollapsed: false });
});
it.each(['/settings', '/settings/proxy'])('gives %s independent chrome without a project sidebar or collapse control', path => {
renderLayout(path);
expect(screen.getByTestId('settings-layout')).toBeVisible();
expect(screen.getByTestId('route-content')).toBeVisible();
expect(screen.queryByTestId('sidebar-stub')).not.toBeInTheDocument();
expect(screen.getByTestId('titlebar-stub')).toHaveAttribute('data-sidebar-controls', 'false');
expect(screen.getByTestId('titlebar-stub')).toHaveAttribute('data-page-title', '设置');
expect(screen.getByTestId('standalone-account-dock-stub')).toBeVisible();
});
it('does not overlay the local project initialization gate on AI painting', () => {
renderLayout('/image-canvas');
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { canLeaveCurrentPage, getSettingsReturnPath, getSettingsReturnState } from '@/lib/settings-navigation';
describe('global settings return navigation', () => {
it.each(['/module-select', '/chat?session=story', '/cloud-agents?view=channels#account', '/image-canvas?workspace=canvas'])(
'returns to the full local origin %s', path => {
const url = new URL(path, 'https://makelore.local');
const state = getSettingsReturnState({ pathname: url.pathname, search: url.search, hash: url.hash });
expect(getSettingsReturnPath(state)).toBe(path);
});
it.each([null, {}, { settingsReturnTo: '/settings' }, { settingsReturnTo: '//outside.example' },
{ settingsReturnTo: 'https://outside.example/chat' }, { settingsReturnTo: '/unknown' }, { settingsReturnTo: '/\\outside.example' }])(
'uses the module homepage for an absent or invalid origin', state => {
expect(getSettingsReturnPath(state)).toBe('/module-select');
});
it('keeps the origin when settings is opened again from its own account menu', () => {
expect(getSettingsReturnState({ pathname: '/settings', state: { settingsReturnTo: '/cloud-agents?view=mine' } }))
.toEqual({ settingsReturnTo: '/cloud-agents?view=mine' });
});
it('honors the page leave guard without discarding or saving input', () => {
const protect = (event: Event) => event.preventDefault();
window.addEventListener('beforeunload', protect);
try { expect(canLeaveCurrentPage()).toBe(false); }
finally { window.removeEventListener('beforeunload', protect); }
expect(canLeaveCurrentPage()).toBe(true);
});
});