forked from boos/BoosAPI
feat(desktop): embed BoosAPI Desktop client as a subproject
Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as a monorepo subdirectory under desktop/. Source only — node_modules, dist, Cargo target and the upstream .git history are excluded. desktop/ keeps its own independent Cargo workspace (desktop/src-tauri with crates/*); it is not part of the backend boosapi crate, so a `cargo check` at the metapi root still compiles only the backend. Build the desktop app from desktop/src-tauri per desktop/README.md. Note: app icons are still placeholder assets pending the final BoosAPI logo. The updater signing keys live outside the repo and are not included.
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import { App } from 'antd';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ChatImageNode } from '../ChatImageNode';
|
||||
|
||||
const imageActions = vi.hoisted(() => ({
|
||||
copyChatImage: vi.fn(async (_src: string) => undefined),
|
||||
saveChatImage: vi.fn(async (_src: string, _defaultName?: string) => true),
|
||||
getDefaultImageFilename: vi.fn((_src: string, _alt?: string | null) => 'boosapi-image.png'),
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (_key: string, fallback?: string) => fallback ?? _key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/chatImageActions', () => ({
|
||||
copyChatImage: (src: string) => imageActions.copyChatImage(src),
|
||||
saveChatImage: (src: string, defaultName?: string) => imageActions.saveChatImage(src, defaultName),
|
||||
getDefaultImageFilename: (src: string, alt?: string | null) => imageActions.getDefaultImageFilename(src, alt),
|
||||
}));
|
||||
|
||||
function renderNode(src = 'data:image/png;base64,aGVsbG8=') {
|
||||
return render(
|
||||
<App>
|
||||
<ChatImageNode
|
||||
node={{
|
||||
type: 'image',
|
||||
src,
|
||||
alt: 'generated preview',
|
||||
title: null,
|
||||
raw: ``,
|
||||
}}
|
||||
/>
|
||||
</App>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ChatImageNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
imageActions.copyChatImage.mockResolvedValue(undefined);
|
||||
imageActions.saveChatImage.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it('shows a loading region until the image finishes loading', () => {
|
||||
renderNode();
|
||||
|
||||
expect(screen.getByText('图片加载中...')).toBeInTheDocument();
|
||||
|
||||
fireEvent.load(screen.getByAltText('generated preview'));
|
||||
|
||||
expect(screen.queryByText('图片加载中...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders an antd error alert with the image link when loading fails', () => {
|
||||
const src = 'https://example.com/missing.png';
|
||||
renderNode(src);
|
||||
|
||||
fireEvent.error(screen.getByAltText('generated preview'));
|
||||
|
||||
expect(screen.getByText('图片加载失败')).toBeInTheDocument();
|
||||
expect(screen.getByText(src)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reads image details from html img node attributes', () => {
|
||||
const src = 'https://example.com/generated.webp';
|
||||
render(
|
||||
<App>
|
||||
<ChatImageNode
|
||||
node={{
|
||||
type: 'img',
|
||||
attrs: [
|
||||
['src', src],
|
||||
['alt', 'html preview'],
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</App>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('图片加载中...')).toBeInTheDocument();
|
||||
|
||||
fireEvent.load(screen.getByAltText('html preview'));
|
||||
|
||||
expect(screen.queryByText('图片加载中...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('copies and saves a loaded image through the image actions', async () => {
|
||||
const user = userEvent.setup();
|
||||
const src = 'data:image/png;base64,aGVsbG8=';
|
||||
renderNode(src);
|
||||
|
||||
fireEvent.load(screen.getByAltText('generated preview'));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '复制图片' }));
|
||||
await waitFor(() => expect(imageActions.copyChatImage).toHaveBeenCalledWith(src));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '保存图片' }));
|
||||
await waitFor(() => expect(imageActions.saveChatImage).toHaveBeenCalledWith(src, 'boosapi-image.png'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ChatMessageRenderBoundary } from '../ChatMessageRenderBoundary';
|
||||
|
||||
function ThrowingMessage(): ReactElement {
|
||||
throw new Error('render failed');
|
||||
}
|
||||
|
||||
describe('ChatMessageRenderBoundary', () => {
|
||||
it('falls back to plain text for a single failed message render', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
render(
|
||||
<div>
|
||||
<div>previous message</div>
|
||||
<ChatMessageRenderBoundary fallback={<div style={{ whiteSpace: 'pre-wrap' }}>plain fallback text</div>}>
|
||||
<ThrowingMessage />
|
||||
</ChatMessageRenderBoundary>
|
||||
<div>next message</div>
|
||||
</div>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('previous message')).toBeInTheDocument();
|
||||
expect(screen.getByText('plain fallback text')).toBeInTheDocument();
|
||||
expect(screen.getByText('next message')).toBeInTheDocument();
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { act, fireEvent, render, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ChatScrollIndicator } from '../ChatScrollIndicator';
|
||||
|
||||
function createScrollableBubbleList() {
|
||||
const scrollBox = document.createElement('div');
|
||||
scrollBox.className = 'ant-bubble-list-scroll-box';
|
||||
scrollBox.style.flexDirection = 'column-reverse';
|
||||
|
||||
let scrollTop = 0;
|
||||
Object.defineProperties(scrollBox, {
|
||||
clientHeight: { configurable: true, value: 800 },
|
||||
scrollHeight: { configurable: true, value: 2000 },
|
||||
scrollTop: {
|
||||
configurable: true,
|
||||
get: () => scrollTop,
|
||||
set: (value: number) => {
|
||||
scrollTop = value;
|
||||
scrollBox.dispatchEvent(new Event('scroll'));
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
document.body.appendChild(scrollBox);
|
||||
return scrollBox;
|
||||
}
|
||||
|
||||
describe('ChatScrollIndicator', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('reports user scroll intent before dragging the custom scrollbar thumb', async () => {
|
||||
createScrollableBubbleList();
|
||||
const onUserScrollIntent = vi.fn();
|
||||
|
||||
render(<ChatScrollIndicator onUserScrollIntent={onUserScrollIntent} />);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.chat-scroll-indicator')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const thumb = document.querySelector('.chat-scroll-indicator') as HTMLElement;
|
||||
fireEvent.pointerDown(thumb, { clientY: 10 });
|
||||
|
||||
expect(onUserScrollIntent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports user scroll intent before jumping from the custom scrollbar track', async () => {
|
||||
createScrollableBubbleList();
|
||||
const onUserScrollIntent = vi.fn();
|
||||
|
||||
render(<ChatScrollIndicator onUserScrollIntent={onUserScrollIntent} />);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.chat-scroll-indicator-track')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const track = document.querySelector('.chat-scroll-indicator-track') as HTMLElement;
|
||||
fireEvent.pointerDown(track, { clientY: 400 });
|
||||
|
||||
expect(onUserScrollIntent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,523 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ChatSidebar } from '../ChatSidebar';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
confirm: vi.fn(),
|
||||
deleteConversation: vi.fn(),
|
||||
setActiveConversation: vi.fn(),
|
||||
createConversation: vi.fn(),
|
||||
updateConversation: vi.fn(),
|
||||
togglePin: vi.fn(),
|
||||
toggleArchive: vi.fn(),
|
||||
fetchArchivedConversations: vi.fn(),
|
||||
batchDelete: vi.fn(),
|
||||
batchArchive: vi.fn(),
|
||||
regenerateTitle: vi.fn(),
|
||||
saveSettings: vi.fn(),
|
||||
fetchCategories: vi.fn(),
|
||||
createCategory: vi.fn(),
|
||||
updateCategory: vi.fn(),
|
||||
deleteCategory: vi.fn(),
|
||||
setCollapsed: vi.fn(),
|
||||
}));
|
||||
|
||||
const conversationState: any = {
|
||||
conversations: [
|
||||
{
|
||||
id: 'conv-1',
|
||||
title: '快捷删除测试',
|
||||
provider_id: 'provider-1',
|
||||
model_id: 'model-1',
|
||||
category_id: null,
|
||||
parent_conversation_id: null,
|
||||
is_pinned: false,
|
||||
is_archived: false,
|
||||
message_count: 0,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
},
|
||||
],
|
||||
activeConversationId: 'conv-1',
|
||||
setActiveConversation: mocks.setActiveConversation,
|
||||
createConversation: mocks.createConversation,
|
||||
deleteConversation: mocks.deleteConversation,
|
||||
updateConversation: mocks.updateConversation,
|
||||
togglePin: mocks.togglePin,
|
||||
toggleArchive: mocks.toggleArchive,
|
||||
archivedConversations: [],
|
||||
fetchArchivedConversations: mocks.fetchArchivedConversations,
|
||||
batchDelete: mocks.batchDelete,
|
||||
batchArchive: mocks.batchArchive,
|
||||
streamingConversationId: null,
|
||||
titleGeneratingConversationId: null,
|
||||
regenerateTitle: mocks.regenerateTitle,
|
||||
};
|
||||
|
||||
const providerState = {
|
||||
providers: [
|
||||
{
|
||||
id: 'provider-1',
|
||||
enabled: true,
|
||||
models: [
|
||||
{
|
||||
provider_id: 'provider-1',
|
||||
model_id: 'model-1',
|
||||
enabled: true,
|
||||
model_type: 'Chat',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const settingsState = {
|
||||
settings: {
|
||||
default_provider_id: 'provider-1',
|
||||
default_model_id: 'model-1',
|
||||
last_selected_conversation_id: null,
|
||||
},
|
||||
loading: false,
|
||||
saveSettings: mocks.saveSettings,
|
||||
};
|
||||
|
||||
const categoryState: any = {
|
||||
categories: [],
|
||||
fetchCategories: mocks.fetchCategories,
|
||||
createCategory: mocks.createCategory,
|
||||
updateCategory: mocks.updateCategory,
|
||||
deleteCategory: mocks.deleteCategory,
|
||||
setCollapsed: mocks.setCollapsed,
|
||||
};
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, string>) => ({
|
||||
'chat.delete': '删除',
|
||||
'chat.directDeleteHint': '按住 Ctrl 可直接删除',
|
||||
'chat.deleteConfirm': '确定删除此对话?',
|
||||
'chat.searchPlaceholder': '搜索对话...',
|
||||
'chat.archived': '已归档',
|
||||
'chat.createCategory': '新建分类',
|
||||
'chat.newConversation': '新建对话',
|
||||
'chat.newConversationInCurrentCategory': `在 ${options?.category ?? '当前分类'} 下新建`,
|
||||
'chat.newStandaloneConversation': '独立新建',
|
||||
'chat.multiSelect': '多选',
|
||||
'chat.noConversations': '暂无对话',
|
||||
'chat.today': '今天',
|
||||
'chat.yesterday': '昨天',
|
||||
'chat.thisWeek': '本周',
|
||||
'chat.thisMonth': '本月',
|
||||
'chat.earlier': '更早',
|
||||
'chat.pin': '置顶',
|
||||
'chat.unpin': '取消置顶',
|
||||
'chat.pinned': '已置顶',
|
||||
'chat.archive': '归档',
|
||||
'chat.rename': '重命名',
|
||||
'chat.generateTitle': '生成标题',
|
||||
'chat.generatingTitle': '正在生成标题',
|
||||
'chat.export': '导出',
|
||||
}[key] ?? key),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('antd', () => ({
|
||||
App: {
|
||||
useApp: () => ({
|
||||
message: { success: vi.fn(), warning: vi.fn(), error: vi.fn() },
|
||||
modal: { confirm: mocks.confirm },
|
||||
}),
|
||||
},
|
||||
Button: ({ children, icon, onClick, 'aria-label': ariaLabel, title, disabled }: any) => (
|
||||
<button type="button" aria-label={ariaLabel ?? title} disabled={disabled} onClick={onClick}>
|
||||
{icon}
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Input: (props: any) => <input {...props} />,
|
||||
Tooltip: ({ children, title }: any) => (
|
||||
<span title={typeof title === 'string' ? title : undefined}>{children}</span>
|
||||
),
|
||||
Checkbox: ({ checked, onChange, onClick }: any) => (
|
||||
<input type="checkbox" checked={checked} onChange={onChange} onClick={onClick} readOnly />
|
||||
),
|
||||
Dropdown: ({ children, menu }: any) => (
|
||||
<div>
|
||||
{children}
|
||||
{menu?.items?.map((item: any) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
aria-label={typeof item.label === 'string' ? item.label : undefined}
|
||||
disabled={item.disabled}
|
||||
onClick={() => menu.onClick?.({ key: item.key, domEvent: { stopPropagation: vi.fn() } })}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
Empty: ({ description }: any) => <div>{description}</div>,
|
||||
Avatar: () => null,
|
||||
theme: {
|
||||
useToken: () => ({
|
||||
token: {
|
||||
colorPrimary: '#1677ff',
|
||||
colorPrimaryBg: '#e6f4ff',
|
||||
colorBgContainer: '#fff',
|
||||
colorFillContent: '#f5f5f5',
|
||||
colorTextSecondary: '#666',
|
||||
colorTextQuaternary: '#aaa',
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@ant-design/x/es/conversations', () => ({
|
||||
default: ({ items, menu }: any) => (
|
||||
<ul>
|
||||
{items.map((item: any) => {
|
||||
const menuConfig = typeof menu === 'function' ? menu(item) : menu;
|
||||
const trigger = menuConfig?.trigger
|
||||
? menuConfig.trigger(item, { originNode: <button type="button" aria-label="更多" /> })
|
||||
: <button type="button" aria-label="更多" />;
|
||||
|
||||
return (
|
||||
<li key={item.key} data-conv-id={item['data-conv-id']}>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
{trigger}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="菜单删除"
|
||||
onClick={() => menuConfig?.onClick?.({ key: 'delete', domEvent: {} })}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@dnd-kit/core', () => ({
|
||||
DndContext: ({ children }: any) => <>{children}</>,
|
||||
DragOverlay: ({ children }: any) => <>{children}</>,
|
||||
closestCenter: vi.fn(),
|
||||
PointerSensor: vi.fn(),
|
||||
useSensor: vi.fn(() => ({})),
|
||||
useSensors: vi.fn(() => []),
|
||||
useDraggable: () => ({
|
||||
attributes: {},
|
||||
listeners: {},
|
||||
setNodeRef: vi.fn(),
|
||||
isDragging: false,
|
||||
}),
|
||||
useDroppable: () => ({
|
||||
setNodeRef: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@lobehub/icons', () => ({
|
||||
ModelIcon: () => null,
|
||||
modelMappings: [],
|
||||
}));
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useConversationStore: Object.assign(
|
||||
(selector: (state: typeof conversationState) => unknown) => selector(conversationState),
|
||||
{ getState: () => ({ ...conversationState, fetchConversations: vi.fn() }) },
|
||||
),
|
||||
useProviderStore: (selector: (state: typeof providerState) => unknown) => selector(providerState),
|
||||
useSettingsStore: Object.assign(
|
||||
(selector: (state: typeof settingsState) => unknown) => selector(settingsState),
|
||||
{ getState: () => settingsState },
|
||||
),
|
||||
useCategoryStore: Object.assign(
|
||||
(selector: (state: typeof categoryState) => unknown) => selector(categoryState),
|
||||
{ setState: vi.fn(), getState: () => categoryState },
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useResolvedAvatarSrc', () => ({
|
||||
useResolvedAvatarSrc: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/convIcon', () => ({
|
||||
getConvIcon: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/exportChat', () => ({
|
||||
exportAsMarkdown: vi.fn(),
|
||||
exportAsText: vi.fn(),
|
||||
exportAsPNG: vi.fn(),
|
||||
exportAsJSON: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/invoke', () => ({
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/shortcuts', () => ({
|
||||
getShortcutBinding: () => 'CmdOrCtrl+N',
|
||||
formatShortcutForDisplay: () => 'Ctrl+N',
|
||||
}));
|
||||
|
||||
vi.mock('../CategoryEditModal', () => ({
|
||||
CategoryEditModal: () => null,
|
||||
}));
|
||||
|
||||
describe('ChatSidebar direct delete shortcut', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
conversationState.conversations = [
|
||||
{
|
||||
id: 'conv-1',
|
||||
title: '快捷删除测试',
|
||||
provider_id: 'provider-1',
|
||||
model_id: 'model-1',
|
||||
category_id: null,
|
||||
parent_conversation_id: null,
|
||||
is_pinned: false,
|
||||
is_archived: false,
|
||||
message_count: 0,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
},
|
||||
];
|
||||
conversationState.activeConversationId = 'conv-1';
|
||||
conversationState.titleGeneratingConversationId = null;
|
||||
categoryState.categories = [];
|
||||
mocks.regenerateTitle.mockResolvedValue(undefined);
|
||||
mocks.createConversation.mockResolvedValue({
|
||||
id: 'conv-new',
|
||||
title: '新建对话',
|
||||
provider_id: 'provider-1',
|
||||
model_id: 'model-1',
|
||||
category_id: null,
|
||||
parent_conversation_id: null,
|
||||
is_pinned: false,
|
||||
is_archived: false,
|
||||
message_count: 0,
|
||||
created_at: 2,
|
||||
updated_at: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the confirmation dialog for a normal menu delete click', () => {
|
||||
render(<ChatSidebar />);
|
||||
|
||||
expect(screen.getByRole('button', { name: '更多' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '删除' })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '菜单删除' }));
|
||||
|
||||
expect(mocks.confirm).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.deleteConversation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders long conversation titles inside a constrained truncation element', () => {
|
||||
const longTitle = '这是一个非常长的用户首条消息标题,用来模拟标题总结模型失败时回退到用户输入导致侧边栏被撑高的问题';
|
||||
conversationState.conversations[0].title = longTitle;
|
||||
|
||||
render(<ChatSidebar />);
|
||||
|
||||
const title = screen.getByText(longTitle);
|
||||
expect(title).toHaveClass('boosapi-chat-conversation-title');
|
||||
expect(title).toHaveAttribute('title', longTitle);
|
||||
expect(title).toHaveStyle({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
});
|
||||
|
||||
it('turns the more trigger into direct delete while Ctrl is held', async () => {
|
||||
render(<ChatSidebar />);
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Control', ctrlKey: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: '删除' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除' }), { ctrlKey: true });
|
||||
|
||||
expect(mocks.confirm).not.toHaveBeenCalled();
|
||||
expect(mocks.deleteConversation).toHaveBeenCalledWith('conv-1');
|
||||
});
|
||||
|
||||
it('turns the more trigger into direct delete while Cmd is held', async () => {
|
||||
render(<ChatSidebar />);
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Meta', metaKey: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: '删除' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除' }), { metaKey: true });
|
||||
|
||||
expect(mocks.confirm).not.toHaveBeenCalled();
|
||||
expect(mocks.deleteConversation).toHaveBeenCalledWith('conv-1');
|
||||
});
|
||||
|
||||
it('does not add a separate row delete action and hides the delete trigger on active rows until hover', () => {
|
||||
const source = fs.readFileSync(
|
||||
path.resolve(process.cwd(), 'src/components/chat/ChatSidebar.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
expect(source).not.toContain('boosapi-chat-conversation-direct-delete');
|
||||
expect(source).not.toContain('PanelLeftClose');
|
||||
expect(source).not.toContain('onCollapse');
|
||||
expect(source).not.toContain('chat.collapseSidebar');
|
||||
expect(source).toContain('.ant-conversations .ant-conversations-item-active .boosapi-chat-conversation-menu-delete');
|
||||
expect(source).toContain('opacity: 0;');
|
||||
});
|
||||
|
||||
it('offers current-category and standalone choices when creating from a categorized conversation', async () => {
|
||||
conversationState.conversations[0].category_id = 'cat-work';
|
||||
categoryState.categories = [
|
||||
{
|
||||
id: 'cat-work',
|
||||
name: '工作',
|
||||
icon_type: null,
|
||||
icon_value: null,
|
||||
system_prompt: '工作分类提示词',
|
||||
default_provider_id: null,
|
||||
default_model_id: null,
|
||||
default_temperature: null,
|
||||
default_max_tokens: null,
|
||||
default_top_p: null,
|
||||
default_frequency_penalty: null,
|
||||
sort_order: 0,
|
||||
is_collapsed: false,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
},
|
||||
];
|
||||
|
||||
render(<ChatSidebar />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '在 工作 下新建' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.createConversation).toHaveBeenCalledWith(
|
||||
'新建对话',
|
||||
'model-1',
|
||||
'provider-1',
|
||||
{ categoryId: 'cat-work' },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('lets users create a standalone conversation from a categorized conversation', async () => {
|
||||
conversationState.conversations[0].category_id = 'cat-work';
|
||||
categoryState.categories = [
|
||||
{
|
||||
id: 'cat-work',
|
||||
name: '工作',
|
||||
icon_type: null,
|
||||
icon_value: null,
|
||||
system_prompt: '工作分类提示词',
|
||||
default_provider_id: null,
|
||||
default_model_id: null,
|
||||
default_temperature: null,
|
||||
default_max_tokens: null,
|
||||
default_top_p: null,
|
||||
default_frequency_penalty: null,
|
||||
sort_order: 0,
|
||||
is_collapsed: false,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
},
|
||||
];
|
||||
|
||||
render(<ChatSidebar />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '独立新建' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.createConversation).toHaveBeenCalledWith(
|
||||
'新建对话',
|
||||
'model-1',
|
||||
'provider-1',
|
||||
{ categoryId: null },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('wraps long sidebar conversation titles in a truncation target with the full title available', () => {
|
||||
const longTitle = '这是一个非常长的会话标题,用于验证侧边栏不会因为标题过长而撑高或者挤压操作按钮';
|
||||
conversationState.conversations[0].title = longTitle;
|
||||
|
||||
render(<ChatSidebar />);
|
||||
|
||||
const title = screen.getByText(longTitle);
|
||||
expect(title).toHaveClass('boosapi-chat-conversation-title');
|
||||
expect(title).toHaveAttribute('title', longTitle);
|
||||
});
|
||||
|
||||
it('shows an inline loading status on the conversation row while generating its title', () => {
|
||||
conversationState.titleGeneratingConversationId = 'conv-1';
|
||||
|
||||
render(<ChatSidebar />);
|
||||
|
||||
const status = screen.getByRole('status', { name: '正在生成标题' });
|
||||
expect(status).toHaveClass('boosapi-chat-conversation-title-generating');
|
||||
});
|
||||
|
||||
it('keeps long title truncation separate from the title generation loading indicator', () => {
|
||||
const longTitle = '这是一个非常长的会话标题,用于验证侧边栏不会因为标题过长而撑高或者挤压操作按钮';
|
||||
conversationState.conversations[0].title = longTitle;
|
||||
conversationState.titleGeneratingConversationId = 'conv-1';
|
||||
|
||||
render(<ChatSidebar />);
|
||||
|
||||
const title = screen.getByText(longTitle);
|
||||
expect(title).toHaveClass('boosapi-chat-conversation-title');
|
||||
expect(title).toHaveAttribute('title', longTitle);
|
||||
expect(title).not.toHaveTextContent('正在生成标题');
|
||||
expect(screen.getByRole('status', { name: '正在生成标题' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds generate title after rename in the right-click menu and calls the title regeneration action', async () => {
|
||||
render(<ChatSidebar />);
|
||||
|
||||
fireEvent.contextMenu(screen.getByText('快捷删除测试'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: '生成标题' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const menuLabels = screen
|
||||
.getAllByRole('button')
|
||||
.map((button) => button.getAttribute('aria-label') ?? button.textContent)
|
||||
.filter(Boolean);
|
||||
expect(menuLabels.indexOf('生成标题')).toBeGreaterThan(menuLabels.indexOf('重命名'));
|
||||
expect(menuLabels.indexOf('生成标题')).toBeLessThan(menuLabels.indexOf('导出'));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成标题' }));
|
||||
|
||||
expect(mocks.regenerateTitle).toHaveBeenCalledWith('conv-1');
|
||||
});
|
||||
|
||||
it('disables generate title while the conversation title is already generating', async () => {
|
||||
conversationState.titleGeneratingConversationId = 'conv-1';
|
||||
|
||||
render(<ChatSidebar />);
|
||||
|
||||
fireEvent.contextMenu(screen.getByText('快捷删除测试'));
|
||||
|
||||
const generateTitleButton = await screen.findByRole('button', { name: '生成标题' });
|
||||
expect(generateTitleButton).toBeDisabled();
|
||||
|
||||
fireEvent.click(generateTitleButton);
|
||||
|
||||
expect(mocks.regenerateTitle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('ChatView sidebar collapse control', () => {
|
||||
const readSource = () => fs.readFileSync(
|
||||
path.resolve(process.cwd(), 'src/components/chat/ChatView.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
it('renders the sidebar toggle before the active conversation icon in the top bar', () => {
|
||||
const source = readSource();
|
||||
const topBarIndex = source.indexOf('{/* Top Bar */}');
|
||||
const toggleIndex = source.indexOf('{renderChatSidebarToggle()}', topBarIndex);
|
||||
const iconIndex = source.indexOf('{renderConvIconForChat(24)}', topBarIndex);
|
||||
|
||||
expect(toggleIndex).toBeGreaterThan(topBarIndex);
|
||||
expect(toggleIndex).toBeLessThan(iconIndex);
|
||||
});
|
||||
|
||||
it('renders the sidebar toggle before the welcome title when no conversation is active', () => {
|
||||
const source = readSource();
|
||||
const activeIconIndex = source.indexOf('{renderConvIconForChat(24)}');
|
||||
const inactiveToggleIndex = source.indexOf('{renderChatSidebarToggle()}', activeIconIndex + 1);
|
||||
const welcomeIndex = source.indexOf("<Typography.Text type=\"secondary\">{t('chat.welcome')}</Typography.Text>");
|
||||
|
||||
expect(inactiveToggleIndex).toBeGreaterThan(activeIconIndex);
|
||||
expect(inactiveToggleIndex).toBeLessThan(welcomeIndex);
|
||||
});
|
||||
|
||||
it('persists the inverse chat_sidebar_collapsed setting from the title bar button', () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('PanelLeftClose');
|
||||
expect(source).toContain('PanelLeftOpen');
|
||||
expect(source).toContain('const saveSettings = useSettingsStore((s) => s.saveSettings);');
|
||||
expect(source).toContain('saveSettings({ chat_sidebar_collapsed: !chatSidebarCollapsed })');
|
||||
});
|
||||
|
||||
it('does not render a tooltip for the title bar sidebar toggle', () => {
|
||||
const source = readSource();
|
||||
const toggleStart = source.indexOf('const renderChatSidebarToggle = useCallback(() => {');
|
||||
const toggleEnd = source.indexOf('// ── User avatar helper', toggleStart);
|
||||
const toggleSource = source.slice(toggleStart, toggleEnd);
|
||||
|
||||
expect(toggleSource).not.toContain('<Tooltip');
|
||||
expect(toggleSource).toContain('aria-label={label}');
|
||||
});
|
||||
|
||||
it('constrains long active conversation titles in the top bar', () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('boosapi-chat-title-shell');
|
||||
expect(source).toContain('boosapi-chat-title-text');
|
||||
expect(source).toContain('ellipsis={{ tooltip: activeConversation.title }}');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import type { Message } from '@/types';
|
||||
import {
|
||||
buildAssistantDisplayContent,
|
||||
splitAssistantErrorDisplayContent,
|
||||
} from '../toolCallDisplay';
|
||||
import {
|
||||
resolveAssistantMessageForBubbleKey,
|
||||
} from '../chatMessageLookup';
|
||||
|
||||
function makeMessage(overrides: Partial<Message>): Message {
|
||||
return {
|
||||
id: 'msg-1',
|
||||
conversation_id: 'conv-1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
provider_id: null,
|
||||
model_id: null,
|
||||
token_count: null,
|
||||
attachments: [],
|
||||
thinking: null,
|
||||
tool_calls_json: null,
|
||||
tool_call_id: null,
|
||||
created_at: 1,
|
||||
parent_message_id: null,
|
||||
version_index: 0,
|
||||
is_active: true,
|
||||
status: 'complete',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ChatView assistant display policy', () => {
|
||||
it('does not mount AssistantFooter while an assistant message is streaming', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/components/chat/ChatView.tsx'), 'utf8');
|
||||
const footerBranch = source.match(/footer:\s*msg && activeConversationId \? \([\s\S]*?\) : footerLoading \?/);
|
||||
|
||||
expect(footerBranch?.[0]).toContain('{!isStreaming && (');
|
||||
expect(footerBranch?.[0]).not.toContain('isStreaming={isStreaming}');
|
||||
});
|
||||
|
||||
it('temporarily closes live streaming think content before rendering', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/components/chat/ChatView.tsx'), 'utf8');
|
||||
|
||||
expect(source).toMatch(/renderContentNode\(\s*closeStreamingThinkBlock\(\s*liveContent,/);
|
||||
});
|
||||
|
||||
it('uses a finite live markdown window instead of placeholder batching', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/components/chat/ChatView.tsx'), 'utf8');
|
||||
const propsBlock = source.match(/const CHAT_RENDER_BATCH_PROPS = \{[\s\S]*?\} as const;/)?.[0] ?? '';
|
||||
|
||||
expect(propsBlock).toContain('deferNodesUntilVisible: false');
|
||||
expect(propsBlock).toMatch(/maxLiveNodes:\s*[1-9]\d*,/);
|
||||
expect(propsBlock).not.toMatch(/maxLiveNodes:\s*(?:0|Infinity),/);
|
||||
});
|
||||
|
||||
it('routes plain text and markdown messages through chat typography classes', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/components/chat/ChatView.tsx'), 'utf8');
|
||||
|
||||
expect(source).toContain('className="boosapi-chat-text"');
|
||||
expect(source).toContain('className="boosapi-chat-markdown"');
|
||||
});
|
||||
|
||||
it('defines chat typography CSS without overriding code font settings', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/index.css'), 'utf8');
|
||||
const chatBlock = source.match(/\.boosapi-chat-text,[\s\S]*?\.boosapi-chat-markdown \.markdown-renderer \{[\s\S]*?\}/)?.[0] ?? '';
|
||||
const codeBlock = source.match(/\.boosapi-chat-markdown (?:pre|code)[\s\S]*?\}/)?.[0] ?? '';
|
||||
|
||||
expect(chatBlock).toContain('font-family: var(--chat-font-family');
|
||||
expect(chatBlock).toContain('font-size: var(--chat-font-size');
|
||||
expect(chatBlock).toContain('line-height: var(--chat-line-height');
|
||||
expect(chatBlock).toContain('font-weight: var(--chat-font-weight');
|
||||
expect(codeBlock).toContain('font-family: var(--code-font-family');
|
||||
expect(codeBlock).not.toContain('--chat-font-family');
|
||||
});
|
||||
|
||||
it('resolves stable ai bubble keys through their parent message id', () => {
|
||||
const assistant = makeMessage({
|
||||
id: 'assistant-1',
|
||||
parent_message_id: 'user-1',
|
||||
content: 'final answer',
|
||||
});
|
||||
const assistantByParentId = new Map<string, Message>([['user-1', assistant]]);
|
||||
const messageById = new Map<string, Message>([
|
||||
['user-1', makeMessage({ id: 'user-1', role: 'user', content: 'question' })],
|
||||
['assistant-1', assistant],
|
||||
]);
|
||||
|
||||
expect(resolveAssistantMessageForBubbleKey(
|
||||
'ai:user-1',
|
||||
assistantByParentId,
|
||||
messageById,
|
||||
)).toBe(assistant);
|
||||
});
|
||||
|
||||
it('injects a web-search display card for normal assistant replies with searched parent messages', () => {
|
||||
const user = makeMessage({
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
content: '<!-- search:{"sources":[{"title":"A","url":"https://example.com"}],"query":"BoosApi 下载"} -->\nsearch\n---\n\n用户问题',
|
||||
});
|
||||
const assistant = makeMessage({
|
||||
id: 'assistant-1',
|
||||
parent_message_id: 'user-1',
|
||||
content: 'final answer',
|
||||
});
|
||||
|
||||
const content = buildAssistantDisplayContent(assistant, [user, assistant]);
|
||||
|
||||
expect(content).toContain('<web-search status="done" data-boosapi="1"');
|
||||
expect(content).toContain('query="BoosApi 下载"');
|
||||
expect(content).toContain('final answer');
|
||||
});
|
||||
|
||||
it('still injects web-search display when streaming thinking content is present', () => {
|
||||
const user = makeMessage({
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
content: '<!-- search:{"sources":[{"title":"A","url":"https://example.com"}],"query":"BoosApi 下载"} -->\nsearch\n---\n\n用户问题',
|
||||
});
|
||||
const assistant = makeMessage({
|
||||
id: 'assistant-1',
|
||||
parent_message_id: 'user-1',
|
||||
content: '<think data-boosapi="1">\n正在分析搜索结果',
|
||||
status: 'partial',
|
||||
});
|
||||
|
||||
const content = buildAssistantDisplayContent(assistant, [user, assistant]);
|
||||
|
||||
expect(content).toContain('<web-search status="done" data-boosapi="1"');
|
||||
expect(content).toContain('<think data-boosapi="1">');
|
||||
});
|
||||
|
||||
it('does not inject a web-search display card into assistant error text', () => {
|
||||
const user = makeMessage({
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
content: '<!-- search:{"sources":[{"title":"A","url":"https://example.com"}]} -->\nsearch\n---\n\n用户问题',
|
||||
});
|
||||
const assistant = makeMessage({
|
||||
id: 'assistant-1',
|
||||
parent_message_id: 'user-1',
|
||||
content: '模型首包超时',
|
||||
status: 'error',
|
||||
});
|
||||
|
||||
const content = buildAssistantDisplayContent(assistant, [user, assistant]);
|
||||
|
||||
expect(content).toBe('模型首包超时');
|
||||
expect(content).not.toContain('<web-search');
|
||||
});
|
||||
|
||||
it('splits display-only prefixes away from assistant error messages', () => {
|
||||
const display = splitAssistantErrorDisplayContent(
|
||||
'<web-search status="done" data-boosapi="1">[{"title":"A"}]</web-search>\n\n模型首包超时',
|
||||
);
|
||||
|
||||
expect(display.prefix).toContain('<web-search status="done" data-boosapi="1">');
|
||||
expect(display.message).toBe('模型首包超时');
|
||||
expect(display.message).not.toContain('[{"title":"A"}]');
|
||||
});
|
||||
|
||||
it('splits partial streamed content away from stream error messages', () => {
|
||||
const display = splitAssistantErrorDisplayContent(
|
||||
'已生成的前半段\n\n<!-- boosapi-stream-error -->\n模型响应空闲超时',
|
||||
);
|
||||
|
||||
expect(display.prefix).toBe('已生成的前半段');
|
||||
expect(display.message).toBe('模型响应空闲超时');
|
||||
});
|
||||
|
||||
it('truncates persisted MCP blocks before markdown rendering', () => {
|
||||
const longOutput = 'x'.repeat(25_000);
|
||||
const assistant = makeMessage({
|
||||
id: 'assistant-1',
|
||||
content: `before\n\n:::mcp {"name":"server","tool":"fetch_url"}\n${longOutput}\n:::\n\nafter`,
|
||||
});
|
||||
|
||||
const content = buildAssistantDisplayContent(assistant, [assistant]);
|
||||
|
||||
expect(content).toContain('before');
|
||||
expect(content).toContain('after');
|
||||
expect(content).toContain('MCP output truncated for rendering');
|
||||
expect(content.length).toBeLessThan(longOutput.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ConversationModelIcon } from '../ConversationModelIcon';
|
||||
|
||||
vi.mock('@lobehub/icons', () => ({
|
||||
modelMappings: [{ keywords: ['deepseek'] }],
|
||||
ModelIcon: ({ model, size }: { model: string; size: number }) => (
|
||||
<span data-testid="model-icon" data-model={model} data-size={size} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
Brain: ({ size, strokeWidth }: { size: number; strokeWidth: number }) => (
|
||||
<span data-testid="brain-icon" data-size={size} data-stroke-width={strokeWidth} />
|
||||
),
|
||||
}));
|
||||
|
||||
describe('ConversationModelIcon', () => {
|
||||
it('wraps model avatars in a fixed centered box', () => {
|
||||
render(<ConversationModelIcon model="deepseek-chat" size={20} />);
|
||||
|
||||
const wrapper = screen.getByTestId('model-icon').parentElement;
|
||||
expect(wrapper).toHaveClass('boosapi-conversation-model-icon');
|
||||
expect(wrapper).toHaveStyle({
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '50%',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses a centered local fallback for unknown model avatars', () => {
|
||||
render(<ConversationModelIcon model="unknown-model" size={20} />);
|
||||
|
||||
expect(screen.queryByTestId('model-icon')).not.toBeInTheDocument();
|
||||
|
||||
const avatar = screen.getByTestId('brain-icon').closest('.boosapi-conversation-model-icon');
|
||||
expect(avatar).toHaveClass('boosapi-conversation-model-icon');
|
||||
expect(avatar).toHaveStyle({
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { HtmlRenderNode } from '../HtmlRenderNode';
|
||||
|
||||
describe('HtmlRenderNode', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders sanitized html directly', () => {
|
||||
render(
|
||||
<HtmlRenderNode
|
||||
node={{
|
||||
type: 'html-render',
|
||||
raw: '<html-render><div style="color:red" onclick="bad()">safe</div><script>alert(1)</script></html-render>',
|
||||
content: '<div style="color:red" onclick="bad()">safe</div><script>alert(1)</script>',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const content = screen.getByTestId('html-render-content');
|
||||
expect(content.innerHTML).toContain('style="color:red"');
|
||||
expect(content.innerHTML).toContain('safe');
|
||||
expect(content.innerHTML).not.toContain('onclick');
|
||||
expect(content.innerHTML).not.toContain('<script');
|
||||
});
|
||||
|
||||
it('removes unsafe urls from rendered links', () => {
|
||||
render(
|
||||
<HtmlRenderNode
|
||||
node={{
|
||||
type: 'html-render',
|
||||
raw: '<html-render><a href="javascript:alert(1)">bad</a></html-render>',
|
||||
content: '<a href="javascript:alert(1)">bad</a>',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const link = screen.getByText('bad');
|
||||
expect(link).not.toHaveAttribute('href');
|
||||
});
|
||||
|
||||
it('does not render display chrome around the html fragment', () => {
|
||||
render(
|
||||
<HtmlRenderNode
|
||||
node={{
|
||||
type: 'html-render',
|
||||
raw: '<html-render><section><h1>Title</h1></section></html-render>',
|
||||
content: '<section><h1>Title</h1></section>',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const content = screen.getByTestId('html-render-content');
|
||||
expect(content).toHaveTextContent('Title');
|
||||
expect(screen.queryByText('HTML Render')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '预览' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '源码' })).toBeNull();
|
||||
});
|
||||
|
||||
it('coalesces streaming html updates until the next animation frame', () => {
|
||||
const frameCallbacks: FrameRequestCallback[] = [];
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
|
||||
frameCallbacks.push(callback);
|
||||
return frameCallbacks.length;
|
||||
});
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {});
|
||||
|
||||
const { rerender } = render(
|
||||
<HtmlRenderNode
|
||||
node={{
|
||||
type: 'html-render',
|
||||
loading: true,
|
||||
content: '<div>first</div>',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('html-render-content')).toHaveTextContent('first');
|
||||
|
||||
rerender(
|
||||
<HtmlRenderNode
|
||||
node={{
|
||||
type: 'html-render',
|
||||
loading: true,
|
||||
content: '<div>second</div>',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
rerender(
|
||||
<HtmlRenderNode
|
||||
node={{
|
||||
type: 'html-render',
|
||||
loading: true,
|
||||
content: '<div>third</div>',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('html-render-content')).toHaveTextContent('first');
|
||||
expect(frameCallbacks).toHaveLength(1);
|
||||
|
||||
act(() => {
|
||||
frameCallbacks[0]?.(16);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('html-render-content')).toHaveTextContent('third');
|
||||
});
|
||||
|
||||
it('renders final html updates immediately without waiting for animation frame', () => {
|
||||
const frameCallbacks: FrameRequestCallback[] = [];
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
|
||||
frameCallbacks.push(callback);
|
||||
return frameCallbacks.length;
|
||||
});
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {});
|
||||
|
||||
const { rerender } = render(
|
||||
<HtmlRenderNode
|
||||
node={{
|
||||
type: 'html-render',
|
||||
loading: true,
|
||||
content: '<div>draft</div>',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<HtmlRenderNode
|
||||
node={{
|
||||
type: 'html-render',
|
||||
loading: false,
|
||||
content: '<div>final</div>',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('html-render-content')).toHaveTextContent('final');
|
||||
});
|
||||
|
||||
it('adapts common light inline colors for dark mode', () => {
|
||||
render(
|
||||
<HtmlRenderNode
|
||||
{...({
|
||||
isDark: true,
|
||||
node: {
|
||||
type: 'html-render',
|
||||
content: '<div style="color:#111;background:#fff;border:1px solid #eee">dark friendly</div>',
|
||||
},
|
||||
} as any)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const content = screen.getByTestId('html-render-content');
|
||||
expect(content).toHaveStyle({ colorScheme: 'dark' });
|
||||
expect(content.innerHTML).toContain('var(--boosapi-html-fg)');
|
||||
expect(content.innerHTML).toContain('rgba(255,255,255,0.06)');
|
||||
expect(content.innerHTML).toContain('rgba(255,255,255,0.18)');
|
||||
});
|
||||
|
||||
it('removes style tags from html render fragments', () => {
|
||||
render(
|
||||
<HtmlRenderNode
|
||||
node={{
|
||||
type: 'html-render',
|
||||
content: '<style>.x{color:red}</style><div>safe</div>',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const content = screen.getByTestId('html-render-content');
|
||||
expect(content).toHaveTextContent('safe');
|
||||
expect(content.innerHTML).not.toContain('<style');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
import { App } from 'antd';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { AppSettings } from '@/types';
|
||||
import { InputArea } from '../InputArea';
|
||||
|
||||
const sendMessage = vi.fn();
|
||||
const createConversation = vi.fn();
|
||||
const setSearchEnabled = vi.fn();
|
||||
const setSearchProviderId = vi.fn();
|
||||
const loadSearchProviders = vi.fn();
|
||||
const loadMcpServers = vi.fn();
|
||||
const toggleMcpServer = vi.fn();
|
||||
const loadKnowledgeBases = vi.fn();
|
||||
const toggleKnowledgeBase = vi.fn();
|
||||
const loadMemoryNamespaces = vi.fn();
|
||||
const toggleMemoryNamespace = vi.fn();
|
||||
const setThinkingBudget = vi.fn();
|
||||
const setThinkingLevel = vi.fn();
|
||||
const insertContextClear = vi.fn();
|
||||
const getContextUsage = vi.fn();
|
||||
const setActivePage = vi.fn();
|
||||
const setSettingsSection = vi.fn();
|
||||
|
||||
const conversationState = {
|
||||
streaming: false,
|
||||
compressingConversationId: null as string | null,
|
||||
activeConversationId: 'conv-1',
|
||||
sendMessage,
|
||||
createConversation,
|
||||
messages: [],
|
||||
conversations: [
|
||||
{
|
||||
id: 'conv-1',
|
||||
title: 'Test',
|
||||
provider_id: 'provider-1',
|
||||
model_id: 'model-1',
|
||||
},
|
||||
],
|
||||
searchEnabled: true,
|
||||
searchProviderId: 'search-1',
|
||||
setSearchEnabled,
|
||||
setSearchProviderId,
|
||||
enabledMcpServerIds: [] as string[],
|
||||
toggleMcpServer,
|
||||
enabledKnowledgeBaseIds: [] as string[],
|
||||
toggleKnowledgeBase,
|
||||
enabledMemoryNamespaceIds: [] as string[],
|
||||
toggleMemoryNamespace,
|
||||
thinkingBudget: null as number | null,
|
||||
thinkingLevel: null as string | null,
|
||||
setThinkingBudget,
|
||||
setThinkingLevel,
|
||||
insertContextClear,
|
||||
getContextUsage,
|
||||
};
|
||||
|
||||
const providerState = {
|
||||
providers: [
|
||||
{
|
||||
id: 'provider-1',
|
||||
provider_type: 'gemini',
|
||||
enabled: true,
|
||||
models: [
|
||||
{
|
||||
provider_id: 'provider-1',
|
||||
model_id: 'model-1',
|
||||
name: 'model-1',
|
||||
model_type: 'Chat',
|
||||
enabled: true,
|
||||
capabilities: [] as string[],
|
||||
max_tokens: 128000,
|
||||
param_overrides: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const settingsState: { settings: Partial<AppSettings> } = {
|
||||
settings: {
|
||||
default_provider_id: null,
|
||||
default_model_id: null,
|
||||
document_attachment_reading_enabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
const searchState = {
|
||||
providers: [
|
||||
{
|
||||
id: 'search-1',
|
||||
name: 'Test Search',
|
||||
providerType: 'tavily',
|
||||
},
|
||||
],
|
||||
loadProviders: loadSearchProviders,
|
||||
};
|
||||
|
||||
const mcpState = {
|
||||
servers: [],
|
||||
loadServers: loadMcpServers,
|
||||
};
|
||||
|
||||
const knowledgeState = {
|
||||
bases: [],
|
||||
loadBases: loadKnowledgeBases,
|
||||
};
|
||||
|
||||
const memoryState = {
|
||||
namespaces: [],
|
||||
loadNamespaces: loadMemoryNamespaces,
|
||||
};
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (_key: string, fallback?: string) => fallback ?? _key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useConversationStore: Object.assign(
|
||||
(selector: (state: typeof conversationState) => unknown) => selector(conversationState),
|
||||
{ getState: () => conversationState },
|
||||
),
|
||||
useProviderStore: Object.assign(
|
||||
(selector: (state: typeof providerState) => unknown) => selector(providerState),
|
||||
{ getState: () => providerState },
|
||||
),
|
||||
useSettingsStore: (selector: (state: typeof settingsState) => unknown) => selector(settingsState),
|
||||
useSearchStore: (selector: (state: typeof searchState) => unknown) => selector(searchState),
|
||||
useMcpStore: (selector: (state: typeof mcpState) => unknown) => selector(mcpState),
|
||||
useKnowledgeStore: (selector: (state: typeof knowledgeState) => unknown) => selector(knowledgeState),
|
||||
useMemoryStore: (selector: (state: typeof memoryState) => unknown) => selector(memoryState),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/uiStore', () => ({
|
||||
useUIStore: (selector: (state: { setActivePage: typeof setActivePage; setSettingsSection: typeof setSettingsSection }) => unknown) =>
|
||||
selector({ setActivePage, setSettingsSection }),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/modelCapabilities', () => ({
|
||||
findModelByIds: (providers: typeof providerState.providers, providerId: string, modelId: string) =>
|
||||
providers.find((provider) => provider.id === providerId)?.models.find((model) => model.model_id === modelId) ?? null,
|
||||
supportsReasoning: (model: { capabilities?: string[] } | null | undefined) => model?.capabilities?.includes('Reasoning') ?? false,
|
||||
modelHasCapability: (model: { capabilities?: string[] } | null | undefined, capability: string) =>
|
||||
model?.capabilities?.includes(capability) ?? false,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/shared/SearchProviderIcon', () => ({
|
||||
SearchProviderTypeIcon: () => null,
|
||||
PROVIDER_TYPE_LABELS: {
|
||||
tavily: 'Tavily',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@lobehub/icons', () => ({
|
||||
ModelIcon: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../VoiceCall', () => ({
|
||||
VoiceCall: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../ConversationSettingsModal', () => ({
|
||||
ConversationSettingsModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../ModelSelector', () => ({
|
||||
ModelSelector: () => null,
|
||||
}));
|
||||
|
||||
describe('InputArea', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
providerState.providers[0].provider_type = 'gemini';
|
||||
providerState.providers[0].models[0].model_id = 'model-1';
|
||||
providerState.providers[0].models[0].name = 'model-1';
|
||||
providerState.providers[0].models[0].capabilities = [];
|
||||
providerState.providers[0].models[0].param_overrides = null;
|
||||
conversationState.conversations[0].model_id = 'model-1';
|
||||
conversationState.thinkingBudget = null;
|
||||
conversationState.thinkingLevel = null;
|
||||
conversationState.compressingConversationId = null;
|
||||
getContextUsage.mockResolvedValue(null);
|
||||
settingsState.settings.document_attachment_reading_enabled = false;
|
||||
});
|
||||
|
||||
const waitForNextFrame = () =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => resolve());
|
||||
});
|
||||
|
||||
it('focuses the chat textarea when the window regains focus without another active input', async () => {
|
||||
render(
|
||||
<App>
|
||||
<InputArea />
|
||||
</App>,
|
||||
);
|
||||
|
||||
const textarea = screen.getByPlaceholderText('chat.inputPlaceholder') as HTMLTextAreaElement;
|
||||
expect(document.activeElement).not.toBe(textarea);
|
||||
|
||||
window.dispatchEvent(new Event('focus'));
|
||||
await waitForNextFrame();
|
||||
|
||||
expect(document.activeElement).toBe(textarea);
|
||||
});
|
||||
|
||||
it('does not steal focus from another focused input when the window regains focus', async () => {
|
||||
render(
|
||||
<>
|
||||
<App>
|
||||
<InputArea />
|
||||
</App>
|
||||
<input aria-label="external-input" />
|
||||
</>,
|
||||
);
|
||||
|
||||
const textarea = screen.getByPlaceholderText('chat.inputPlaceholder') as HTMLTextAreaElement;
|
||||
const externalInput = screen.getByLabelText('external-input') as HTMLInputElement;
|
||||
externalInput.focus();
|
||||
expect(document.activeElement).toBe(externalInput);
|
||||
|
||||
window.dispatchEvent(new Event('focus'));
|
||||
await waitForNextFrame();
|
||||
|
||||
expect(document.activeElement).toBe(externalInput);
|
||||
expect(document.activeElement).not.toBe(textarea);
|
||||
});
|
||||
|
||||
it('clears the textarea immediately after sending even while search-backed send is still pending', async () => {
|
||||
let resolveSend!: () => void;
|
||||
sendMessage.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveSend = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<App>
|
||||
<InputArea />
|
||||
</App>,
|
||||
);
|
||||
|
||||
const textarea = screen.getByPlaceholderText('chat.inputPlaceholder') as HTMLTextAreaElement;
|
||||
await userEvent.type(textarea, 'search me');
|
||||
|
||||
expect(textarea.value).toBe('search me');
|
||||
|
||||
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' });
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith('search me', undefined, 'search-1');
|
||||
expect(textarea.value).toBe('');
|
||||
|
||||
resolveSend();
|
||||
});
|
||||
|
||||
it('renders model-specific reasoning options for Gemini 3.1 models', async () => {
|
||||
providerState.providers[0].provider_type = 'gemini';
|
||||
providerState.providers[0].models[0].model_id = 'gemini-3.1-flash';
|
||||
providerState.providers[0].models[0].name = 'Gemini 3.1 Flash';
|
||||
providerState.providers[0].models[0].capabilities = ['Reasoning'];
|
||||
conversationState.conversations[0].model_id = 'gemini-3.1-flash';
|
||||
|
||||
render(
|
||||
<App>
|
||||
<InputArea />
|
||||
</App>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByLabelText('chat.thinkingIntensity'));
|
||||
|
||||
expect(await screen.findByText('Minimal')).toBeInTheDocument();
|
||||
expect(screen.getByText('Low')).toBeInTheDocument();
|
||||
expect(screen.getByText('Medium')).toBeInTheDocument();
|
||||
expect(screen.getByText('High')).toBeInTheDocument();
|
||||
expect(screen.queryByText('XHigh')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders DeepSeek V4 reasoning options from the provider profile', async () => {
|
||||
providerState.providers[0].provider_type = 'deepseek';
|
||||
providerState.providers[0].models[0].model_id = 'deepseek-v4-flash';
|
||||
providerState.providers[0].models[0].name = 'DeepSeek v4 Flash';
|
||||
providerState.providers[0].models[0].capabilities = ['Reasoning'];
|
||||
conversationState.conversations[0].model_id = 'deepseek-v4-flash';
|
||||
|
||||
render(
|
||||
<App>
|
||||
<InputArea />
|
||||
</App>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByLabelText('chat.thinkingIntensity'));
|
||||
|
||||
expect(await screen.findByText('High')).toBeInTheDocument();
|
||||
expect(screen.getByText('Max')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Low')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Medium')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('XHigh')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows backend context usage instead of a loaded-message estimate', async () => {
|
||||
getContextUsage.mockResolvedValueOnce({
|
||||
used_tokens: 720000,
|
||||
max_tokens: 1000000,
|
||||
threshold_tokens: 700000,
|
||||
has_summary: true,
|
||||
compressed_until_message_id: 'msg-1',
|
||||
messages_after_boundary: 3,
|
||||
});
|
||||
|
||||
render(
|
||||
<App>
|
||||
<InputArea />
|
||||
</App>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(getContextUsage).toHaveBeenCalledWith('conv-1'));
|
||||
await userEvent.hover(screen.getByLabelText('上下文 tokens'));
|
||||
|
||||
expect(await screen.findByText('720,000 / 1,000,000 tokens (72%)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows document attachment controls for non-vision models when document reading is enabled', () => {
|
||||
settingsState.settings.document_attachment_reading_enabled = true;
|
||||
|
||||
render(
|
||||
<App>
|
||||
<InputArea />
|
||||
</App>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText('chat.attachFile')).toBeInTheDocument();
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement | null;
|
||||
expect(input?.accept).toContain('.pdf');
|
||||
expect(input?.accept).toContain('.doc');
|
||||
expect(input?.accept).toContain('.docx');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
import type React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Conversation, ProviderConfig } from '@/types';
|
||||
import { ModelSelector } from '../ModelSelector';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
updateConversation: vi.fn(),
|
||||
saveSettings: vi.fn(),
|
||||
setActivePage: vi.fn(),
|
||||
setSettingsSection: vi.fn(),
|
||||
setSelectedProviderId: vi.fn(),
|
||||
}));
|
||||
|
||||
let providers: ProviderConfig[] = [];
|
||||
let conversations: Conversation[] = [];
|
||||
let settings: Record<string, unknown> = {};
|
||||
|
||||
function makeProvider(overrides: Partial<ProviderConfig> = {}): ProviderConfig {
|
||||
return {
|
||||
id: 'provider-1',
|
||||
name: 'OpenAI Compatible',
|
||||
provider_type: 'openai',
|
||||
api_host: 'https://api.example.com',
|
||||
api_path: '/v1/chat/completions',
|
||||
enabled: true,
|
||||
models: [],
|
||||
keys: [],
|
||||
proxy_config: null,
|
||||
custom_headers: null,
|
||||
icon: null,
|
||||
builtin_id: null,
|
||||
sort_order: 0,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeConversation(overrides: Partial<Conversation> = {}): Conversation {
|
||||
return {
|
||||
id: 'conv-1',
|
||||
title: 'Test',
|
||||
model_id: 'gpt-5.4',
|
||||
provider_id: 'provider-1',
|
||||
system_prompt: null,
|
||||
temperature: null,
|
||||
max_tokens: null,
|
||||
top_p: null,
|
||||
frequency_penalty: null,
|
||||
search_enabled: false,
|
||||
search_provider_id: null,
|
||||
thinking_budget: null,
|
||||
thinking_level: null,
|
||||
enabled_mcp_server_ids: [],
|
||||
enabled_knowledge_base_ids: [],
|
||||
enabled_memory_namespace_ids: [],
|
||||
is_pinned: false,
|
||||
is_archived: false,
|
||||
context_compression: false,
|
||||
category_id: null,
|
||||
parent_conversation_id: null,
|
||||
mode: 'chat',
|
||||
message_count: 0,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock('@lobehub/icons', () => ({
|
||||
ModelIcon: () => <span data-testid="model-icon" />,
|
||||
}));
|
||||
|
||||
vi.mock('../ConversationModelIcon', () => ({
|
||||
ConversationModelIcon: ({ model, size }: { model: string; size: number }) => (
|
||||
<span data-testid="conversation-model-icon" data-model={model} data-size={size} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/providerIcons', () => ({
|
||||
SmartProviderIcon: () => <span data-testid="provider-icon" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/shortcuts', () => ({
|
||||
getShortcutBinding: () => null,
|
||||
formatShortcutForDisplay: () => '',
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/modelCapabilities', () => ({
|
||||
getVisibleModelCapabilities: () => [],
|
||||
}));
|
||||
|
||||
vi.mock('@tanstack/react-virtual', () => ({
|
||||
useVirtualizer: ({ count, getItemKey }: { count: number; getItemKey: (index: number) => React.Key }) => ({
|
||||
getTotalSize: () => count * 40,
|
||||
getVirtualItems: () =>
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
index,
|
||||
key: getItemKey(index),
|
||||
start: index * 40,
|
||||
})),
|
||||
measureElement: vi.fn(),
|
||||
scrollToIndex: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string) => fallback ?? key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('antd', () => ({
|
||||
Tag: ({ children }: { children?: React.ReactNode }) => <span>{children}</span>,
|
||||
Modal: ({ open, children, footer, title }: { open?: boolean; children?: React.ReactNode; footer?: React.ReactNode; title?: React.ReactNode }) => (
|
||||
open ? <div>{title}{children}{footer}</div> : null
|
||||
),
|
||||
Input: ({ prefix, placeholder, value, onChange, onKeyDown }: {
|
||||
prefix?: React.ReactNode;
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
onChange?: React.ChangeEventHandler<HTMLInputElement>;
|
||||
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
|
||||
}) => (
|
||||
<label>
|
||||
{prefix}
|
||||
<input placeholder={placeholder} value={value} onChange={onChange} onKeyDown={onKeyDown} />
|
||||
</label>
|
||||
),
|
||||
Tooltip: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
||||
Button: ({ children, onClick }: { children?: React.ReactNode; onClick?: React.MouseEventHandler<HTMLButtonElement> }) => (
|
||||
<button onClick={onClick}>{children}</button>
|
||||
),
|
||||
Checkbox: ({ checked }: { checked?: boolean }) => <input type="checkbox" readOnly checked={checked} />,
|
||||
theme: {
|
||||
useToken: () => ({
|
||||
token: {
|
||||
colorPrimary: '#1677ff',
|
||||
colorPrimaryBg: '#e6f4ff',
|
||||
colorFillSecondary: '#f5f5f5',
|
||||
colorFillTertiary: '#fafafa',
|
||||
colorTextSecondary: '#666',
|
||||
colorTextQuaternary: '#999',
|
||||
colorBorderSecondary: '#eee',
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useProviderStore: (selector?: (state: { providers: ProviderConfig[] }) => unknown) => {
|
||||
const state = { providers };
|
||||
return selector ? selector(state) : state;
|
||||
},
|
||||
useConversationStore: (selector?: (state: { activeConversationId: string; conversations: Conversation[]; updateConversation: typeof mocks.updateConversation }) => unknown) => {
|
||||
const state = {
|
||||
activeConversationId: 'conv-1',
|
||||
conversations,
|
||||
updateConversation: mocks.updateConversation,
|
||||
};
|
||||
return selector ? selector(state) : state;
|
||||
},
|
||||
useSettingsStore: (selector: (state: { settings: Record<string, unknown>; saveSettings: typeof mocks.saveSettings }) => unknown) =>
|
||||
selector({
|
||||
settings,
|
||||
saveSettings: mocks.saveSettings,
|
||||
}),
|
||||
useUIStore: (selector: (state: {
|
||||
setActivePage: typeof mocks.setActivePage;
|
||||
setSettingsSection: typeof mocks.setSettingsSection;
|
||||
setSelectedProviderId: typeof mocks.setSelectedProviderId;
|
||||
}) => unknown) =>
|
||||
selector({
|
||||
setActivePage: mocks.setActivePage,
|
||||
setSettingsSection: mocks.setSettingsSection,
|
||||
setSelectedProviderId: mocks.setSelectedProviderId,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('ModelSelector', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
providers = [
|
||||
makeProvider({
|
||||
models: [
|
||||
{
|
||||
provider_id: 'provider-1',
|
||||
model_id: 'gpt-5.4',
|
||||
name: 'GPT 5.4',
|
||||
group_name: null,
|
||||
model_type: 'Chat',
|
||||
capabilities: ['TextChat'],
|
||||
max_tokens: 128000,
|
||||
enabled: true,
|
||||
param_overrides: null,
|
||||
},
|
||||
{
|
||||
provider_id: 'provider-1',
|
||||
model_id: 'gpt-image-2',
|
||||
name: 'GPT Image 2',
|
||||
group_name: null,
|
||||
model_type: 'Image',
|
||||
capabilities: [],
|
||||
max_tokens: null,
|
||||
enabled: true,
|
||||
param_overrides: null,
|
||||
},
|
||||
{
|
||||
provider_id: 'provider-1',
|
||||
model_id: 'text-embedding-3-large',
|
||||
name: 'Text Embedding 3 Large',
|
||||
group_name: null,
|
||||
model_type: 'Embedding',
|
||||
capabilities: [],
|
||||
max_tokens: null,
|
||||
enabled: true,
|
||||
param_overrides: null,
|
||||
},
|
||||
{
|
||||
provider_id: 'provider-1',
|
||||
model_id: 'jina-reranker-v3',
|
||||
name: 'Jina Reranker v3',
|
||||
group_name: null,
|
||||
model_type: 'Rerank',
|
||||
capabilities: [],
|
||||
max_tokens: null,
|
||||
enabled: true,
|
||||
param_overrides: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
conversations = [makeConversation()];
|
||||
settings = {
|
||||
default_provider_id: null,
|
||||
default_model_id: null,
|
||||
show_image_models_in_model_selector: false,
|
||||
};
|
||||
});
|
||||
|
||||
it('shows only enabled chat models in the conversation model selector', () => {
|
||||
render(<ModelSelector open onOpenChange={vi.fn()} />);
|
||||
|
||||
expect(screen.getAllByText('GPT 5.4').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('GPT Image 2')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Text Embedding 3 Large')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Jina Reranker v3')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show pinned non-chat models', () => {
|
||||
localStorage.setItem('boosapi_pinned_models', JSON.stringify(['provider-1::gpt-image-2']));
|
||||
|
||||
render(
|
||||
<ModelSelector
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
excludeModelKeys={['provider-1::gpt-5.4']}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('chat.pinnedModels')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('GPT Image 2')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows enabled image models when the setting is enabled', () => {
|
||||
settings = {
|
||||
...settings,
|
||||
show_image_models_in_model_selector: true,
|
||||
};
|
||||
|
||||
render(<ModelSelector open onOpenChange={vi.fn()} />);
|
||||
|
||||
expect(screen.getAllByText('GPT 5.4').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('GPT Image 2').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('Text Embedding 3 Large')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Jina Reranker v3')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows pinned image models when the setting is enabled', () => {
|
||||
settings = {
|
||||
...settings,
|
||||
show_image_models_in_model_selector: true,
|
||||
};
|
||||
localStorage.setItem('boosapi_pinned_models', JSON.stringify(['provider-1::gpt-image-2']));
|
||||
|
||||
render(
|
||||
<ModelSelector
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
excludeModelKeys={['provider-1::gpt-5.4']}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('chat.pinnedModels')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('GPT Image 2').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('uses the centered conversation model fallback in the current model trigger', () => {
|
||||
conversations = [makeConversation({ model_id: 'unknown-model', provider_id: 'missing-provider' })];
|
||||
|
||||
render(<ModelSelector />);
|
||||
|
||||
const icon = screen.getByTestId('conversation-model-icon');
|
||||
expect(icon).toHaveAttribute('data-model', 'unknown-model');
|
||||
expect(icon).toHaveAttribute('data-size', '16');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,361 @@
|
||||
import { App } from 'antd';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type React from 'react';
|
||||
import type { Message } from '@/types';
|
||||
import { clearLiveStreamContent, setLiveStreamContent, useConversationStore } from '@/stores';
|
||||
import { MultiModelDisplay } from '../MultiModelDisplay';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string) => fallback ?? key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@lobehub/icons', () => ({
|
||||
ModelIcon: ({ model }: { model: string }) => <span data-testid="model-icon">{model}</span>,
|
||||
}));
|
||||
|
||||
vi.mock('overlayscrollbars', () => ({
|
||||
OverlayScrollbars: vi.fn(() => ({ destroy: vi.fn() })),
|
||||
}));
|
||||
|
||||
vi.mock('../ModelSelector', () => ({
|
||||
ModelSelector: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||
}));
|
||||
|
||||
function makeMessage(overrides: Partial<Message> & Pick<Message, 'id' | 'model_id' | 'content'>): Message {
|
||||
return {
|
||||
id: overrides.id,
|
||||
conversation_id: 'conv-1',
|
||||
role: 'assistant',
|
||||
content: overrides.content,
|
||||
provider_id: overrides.provider_id ?? 'provider-1',
|
||||
model_id: overrides.model_id,
|
||||
token_count: null,
|
||||
prompt_tokens: null,
|
||||
completion_tokens: null,
|
||||
attachments: [],
|
||||
thinking: null,
|
||||
tool_calls_json: null,
|
||||
tool_call_id: null,
|
||||
created_at: overrides.created_at ?? 1,
|
||||
parent_message_id: overrides.parent_message_id ?? 'user-1',
|
||||
version_index: overrides.version_index ?? 0,
|
||||
is_active: overrides.is_active ?? true,
|
||||
status: overrides.status ?? 'complete',
|
||||
tokens_per_second: null,
|
||||
first_token_latency_ms: null,
|
||||
};
|
||||
}
|
||||
|
||||
function renderDisplay(
|
||||
versions: Message[],
|
||||
activeMessageId = versions[0]?.id ?? '',
|
||||
mode: 'side-by-side' | 'stacked' = 'side-by-side',
|
||||
props: Partial<React.ComponentProps<typeof MultiModelDisplay>> = {},
|
||||
) {
|
||||
return (
|
||||
<App>
|
||||
<MultiModelDisplay
|
||||
versions={versions}
|
||||
activeMessageId={activeMessageId}
|
||||
mode={mode}
|
||||
conversationId="conv-1"
|
||||
onSwitchVersion={vi.fn()}
|
||||
onDeleteVersion={vi.fn()}
|
||||
streamingMessageId={null}
|
||||
multiModelDoneMessageIds={[]}
|
||||
getModelDisplayInfo={(modelId) => ({ modelName: modelId ?? '', providerName: '' })}
|
||||
renderContent={(message) => <div>{message.content}</div>}
|
||||
{...props}
|
||||
/>
|
||||
</App>
|
||||
);
|
||||
}
|
||||
|
||||
function renderDisplayWithStreamingLabel(versions: Message[], streamingMessageId: string | null) {
|
||||
return (
|
||||
<App>
|
||||
<MultiModelDisplay
|
||||
versions={versions}
|
||||
activeMessageId={versions[0]?.id ?? ''}
|
||||
mode="side-by-side"
|
||||
conversationId="conv-1"
|
||||
onSwitchVersion={vi.fn()}
|
||||
onDeleteVersion={vi.fn()}
|
||||
streamingMessageId={streamingMessageId}
|
||||
multiModelDoneMessageIds={[]}
|
||||
getModelDisplayInfo={(modelId) => ({ modelName: modelId ?? '', providerName: '' })}
|
||||
renderContent={(message, isStreaming) => (
|
||||
<div data-testid={`content-${message.id}`}>
|
||||
{isStreaming ? 'streaming' : 'stable'}:{message.content}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</App>
|
||||
);
|
||||
}
|
||||
|
||||
describe('MultiModelDisplay', () => {
|
||||
beforeEach(() => {
|
||||
useConversationStore.setState({
|
||||
messages: [],
|
||||
activeConversationId: 'conv-1',
|
||||
streaming: false,
|
||||
streamingConversationId: null,
|
||||
streamingMessageId: null,
|
||||
});
|
||||
clearLiveStreamContent('assistant-a');
|
||||
clearLiveStreamContent('assistant-b');
|
||||
});
|
||||
|
||||
it('does not fall back to the error boundary when deleting down to one model', () => {
|
||||
const modelA = makeMessage({ id: 'assistant-a', model_id: 'model-a', content: 'alpha' });
|
||||
const modelB = makeMessage({ id: 'assistant-b', model_id: 'model-b', content: 'beta', is_active: false, version_index: 1 });
|
||||
|
||||
const { rerender } = render(renderDisplay([modelA, modelB]));
|
||||
|
||||
expect(screen.getByText('alpha')).toBeInTheDocument();
|
||||
expect(screen.getByText('beta')).toBeInTheDocument();
|
||||
|
||||
rerender(renderDisplay([modelA]));
|
||||
|
||||
expect(screen.queryByText('Multi-model display error')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('alpha')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates an inactive streaming card from the store without rerendering the parent bubble item', () => {
|
||||
const modelA = makeMessage({ id: 'assistant-a', model_id: 'model-a', content: 'alpha' });
|
||||
const modelB = makeMessage({ id: 'assistant-b', model_id: 'model-b', content: '', is_active: false, status: 'partial', version_index: 1 });
|
||||
useConversationStore.setState({ messages: [modelA, modelB] });
|
||||
|
||||
render(renderDisplay([modelA, modelB]));
|
||||
|
||||
expect(screen.queryByText('streamed token')).not.toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
useConversationStore.setState({
|
||||
messages: [modelA, { ...modelB, content: 'streamed token' }],
|
||||
});
|
||||
});
|
||||
|
||||
expect(screen.getByText('streamed token')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates an inactive streaming card from live stream content without replacing store messages', () => {
|
||||
const modelA = makeMessage({ id: 'assistant-a', model_id: 'model-a', content: 'alpha' });
|
||||
const modelB = makeMessage({ id: 'assistant-b', model_id: 'model-b', content: '', is_active: false, status: 'partial', version_index: 1 });
|
||||
useConversationStore.setState({
|
||||
messages: [modelA, modelB],
|
||||
streaming: true,
|
||||
streamingConversationId: 'conv-1',
|
||||
streamingMessageId: null,
|
||||
});
|
||||
|
||||
render(renderDisplay([modelA, modelB]));
|
||||
|
||||
act(() => {
|
||||
setLiveStreamContent('assistant-b', 'streamed token');
|
||||
});
|
||||
|
||||
expect(screen.getByText('streamed token')).toBeInTheDocument();
|
||||
expect(useConversationStore.getState().messages.find((message) => message.id === 'assistant-b')?.content).toBe('');
|
||||
});
|
||||
|
||||
it('shows the active same-model version in side-by-side mode', () => {
|
||||
const modelAOld = makeMessage({
|
||||
id: 'assistant-a-old',
|
||||
model_id: 'model-a',
|
||||
content: 'old same-model answer',
|
||||
is_active: true,
|
||||
version_index: 0,
|
||||
created_at: 1,
|
||||
});
|
||||
const modelALatest = makeMessage({
|
||||
id: 'assistant-a-latest',
|
||||
model_id: 'model-a',
|
||||
content: 'latest same-model answer',
|
||||
is_active: false,
|
||||
version_index: 1,
|
||||
created_at: 2,
|
||||
});
|
||||
const modelB = makeMessage({
|
||||
id: 'assistant-b',
|
||||
model_id: 'model-b',
|
||||
content: 'other model answer',
|
||||
is_active: false,
|
||||
version_index: 0,
|
||||
created_at: 3,
|
||||
});
|
||||
|
||||
render(renderDisplay([modelAOld, modelALatest, modelB], modelAOld.id, 'side-by-side'));
|
||||
|
||||
expect(screen.getByText('old same-model answer')).toBeInTheDocument();
|
||||
expect(screen.queryByText('latest same-model answer')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('other model answer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the active same-model version in stacked mode', () => {
|
||||
const modelAOld = makeMessage({
|
||||
id: 'assistant-a-old',
|
||||
model_id: 'model-a',
|
||||
content: 'old stacked answer',
|
||||
is_active: true,
|
||||
version_index: 0,
|
||||
created_at: 1,
|
||||
});
|
||||
const modelALatest = makeMessage({
|
||||
id: 'assistant-a-latest',
|
||||
model_id: 'model-a',
|
||||
content: 'latest stacked answer',
|
||||
is_active: false,
|
||||
version_index: 1,
|
||||
created_at: 2,
|
||||
});
|
||||
const modelB = makeMessage({
|
||||
id: 'assistant-b',
|
||||
model_id: 'model-b',
|
||||
content: 'stacked other model answer',
|
||||
is_active: false,
|
||||
version_index: 0,
|
||||
created_at: 3,
|
||||
});
|
||||
|
||||
render(renderDisplay([modelAOld, modelALatest, modelB], modelAOld.id, 'stacked'));
|
||||
|
||||
expect(screen.getByText('old stacked answer')).toBeInTheDocument();
|
||||
expect(screen.queryByText('latest stacked answer')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('stacked other model answer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('treats partial cards as streaming while their conversation is streaming even without a matching streamingMessageId', () => {
|
||||
const modelA = makeMessage({ id: 'assistant-a', model_id: 'model-a', content: 'alpha' });
|
||||
const modelB = makeMessage({
|
||||
id: 'assistant-b',
|
||||
model_id: 'model-b',
|
||||
content: '```ts\nconst token = 1;',
|
||||
is_active: false,
|
||||
status: 'partial',
|
||||
version_index: 1,
|
||||
});
|
||||
useConversationStore.setState({
|
||||
messages: [modelA, modelB],
|
||||
streaming: true,
|
||||
streamingConversationId: 'conv-1',
|
||||
streamingMessageId: null,
|
||||
});
|
||||
|
||||
render(renderDisplayWithStreamingLabel([modelA, modelB], null));
|
||||
|
||||
expect(screen.getByTestId('content-assistant-b')).toHaveTextContent('streaming:```ts');
|
||||
});
|
||||
|
||||
it('routes per-card actions to the displayed message without switching context', () => {
|
||||
const modelA = makeMessage({ id: 'assistant-a', model_id: 'model-a', content: 'alpha' });
|
||||
const modelB = makeMessage({ id: 'assistant-b', model_id: 'model-b', content: 'beta', is_active: false, version_index: 1 });
|
||||
const onRegenerateVersion = vi.fn();
|
||||
const onSetContextVersion = vi.fn();
|
||||
const onSwitchVersion = vi.fn();
|
||||
|
||||
render(renderDisplay([modelA, modelB], modelA.id, 'side-by-side', {
|
||||
onRegenerateVersion,
|
||||
onSetContextVersion,
|
||||
onSwitchVersion,
|
||||
}));
|
||||
|
||||
fireEvent.click(screen.getByTestId('multi-model-regenerate-assistant-b'));
|
||||
expect(onRegenerateVersion).toHaveBeenCalledWith(modelB);
|
||||
expect(onSwitchVersion).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByTestId('multi-model-set-context-assistant-b'));
|
||||
expect(onSetContextVersion).toHaveBeenCalledWith(modelB);
|
||||
});
|
||||
|
||||
it('keeps context selection in the card header while operations stay in the footer', () => {
|
||||
const modelA = makeMessage({ id: 'assistant-a', model_id: 'model-a', content: 'alpha' });
|
||||
const modelB = makeMessage({ id: 'assistant-b', model_id: 'model-b', content: 'beta', is_active: false, version_index: 1 });
|
||||
|
||||
render(renderDisplay([modelA, modelB], modelA.id, 'side-by-side', {
|
||||
onRegenerateVersion: vi.fn(),
|
||||
onSetContextVersion: vi.fn(),
|
||||
}));
|
||||
|
||||
expect(screen.getByTestId('multi-model-set-context-assistant-b').closest('.multi-model-card-header-actions')).not.toBeNull();
|
||||
expect(screen.getByTestId('multi-model-set-context-assistant-b').closest('.multi-model-card-footer-actions')).toBeNull();
|
||||
expect(screen.getByTestId('multi-model-regenerate-assistant-b').closest('.multi-model-card-footer-actions')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('stretches card content so footer actions stay pinned to the bottom', () => {
|
||||
const modelA = makeMessage({
|
||||
id: 'assistant-a',
|
||||
model_id: 'model-a',
|
||||
content: 'alpha\n\nalpha\n\nalpha\n\nalpha',
|
||||
});
|
||||
const modelB = makeMessage({
|
||||
id: 'assistant-b',
|
||||
model_id: 'model-b',
|
||||
content: 'beta',
|
||||
is_active: false,
|
||||
version_index: 1,
|
||||
});
|
||||
|
||||
render(renderDisplay([modelA, modelB], modelA.id, 'side-by-side', {
|
||||
onRegenerateVersion: vi.fn(),
|
||||
}));
|
||||
|
||||
const shortCard = screen.getByTestId('multi-model-card-assistant-b');
|
||||
const shortContent = screen.getByTestId('multi-model-card-content-assistant-b');
|
||||
|
||||
expect(shortCard).toHaveStyle({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
});
|
||||
expect(shortContent.getAttribute('style')).toContain('flex: 1');
|
||||
expect(shortContent).toHaveStyle({ minHeight: '0' });
|
||||
expect(screen.getByTestId('multi-model-regenerate-assistant-b').closest('.multi-model-card-footer-actions')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('switches the displayed same-model version locally without setting context', () => {
|
||||
const modelAOld = makeMessage({
|
||||
id: 'assistant-a-old',
|
||||
model_id: 'model-a',
|
||||
content: 'old same-model answer',
|
||||
is_active: true,
|
||||
version_index: 0,
|
||||
created_at: 1,
|
||||
});
|
||||
const modelALatest = makeMessage({
|
||||
id: 'assistant-a-latest',
|
||||
model_id: 'model-a',
|
||||
content: 'latest same-model answer',
|
||||
is_active: false,
|
||||
version_index: 1,
|
||||
created_at: 2,
|
||||
});
|
||||
const modelB = makeMessage({
|
||||
id: 'assistant-b',
|
||||
model_id: 'model-b',
|
||||
content: 'other model answer',
|
||||
is_active: false,
|
||||
version_index: 0,
|
||||
created_at: 3,
|
||||
});
|
||||
const onDisplayVersionChange = vi.fn();
|
||||
const onSwitchVersion = vi.fn();
|
||||
|
||||
render(renderDisplay([modelAOld, modelALatest, modelB], modelAOld.id, 'side-by-side', {
|
||||
onDisplayVersionChange,
|
||||
onSwitchVersion,
|
||||
}));
|
||||
|
||||
fireEvent.click(screen.getByTestId('multi-model-version-next-assistant-a-old'));
|
||||
|
||||
expect(onDisplayVersionChange).toHaveBeenCalledWith(
|
||||
modelAOld.parent_message_id,
|
||||
'provider-1:model-a',
|
||||
modelALatest.id,
|
||||
);
|
||||
expect(onSwitchVersion).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { WebSearchNode, WebSearchQueryNode } from '../WebSearchNode';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string) => ({
|
||||
'chat.search.searching': '正在联网搜索...',
|
||||
'chat.search.summarizingQuery': '正在总结搜索语句...',
|
||||
'chat.search.querySummary': '搜索语句总结完成',
|
||||
'chat.search.querySummaryFailed': '搜索语句总结失败',
|
||||
'chat.search.resultsCount': '1个搜索结果',
|
||||
'chat.search.noResults': '未找到相关搜索结果',
|
||||
'chat.search.query': '搜索语句',
|
||||
'chat.search.error': '搜索失败',
|
||||
})[key] ?? fallback ?? key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('WebSearchNode', () => {
|
||||
it('shows the searching state when attrs come from tuple pairs', () => {
|
||||
render(
|
||||
<WebSearchNode
|
||||
node={{
|
||||
type: 'web-search',
|
||||
attrs: [['status', 'searching']],
|
||||
content: '',
|
||||
loading: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('正在联网搜索...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the query summarization state', () => {
|
||||
render(
|
||||
<WebSearchQueryNode
|
||||
node={{
|
||||
type: 'web-search-query',
|
||||
attrs: { status: 'summarizing' },
|
||||
content: '',
|
||||
loading: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('正在总结搜索语句...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reveals the summarized search query when expanded', () => {
|
||||
render(
|
||||
<WebSearchQueryNode
|
||||
node={{
|
||||
type: 'web-search-query',
|
||||
attrs: {
|
||||
status: 'done',
|
||||
query: 'BoosApi Desktop 0.0.76 Windows 下载地址',
|
||||
},
|
||||
content: '',
|
||||
loading: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('BoosApi Desktop 0.0.76 Windows 下载地址')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('搜索语句总结完成'));
|
||||
expect(screen.getByText('搜索语句总结完成')).toBeInTheDocument();
|
||||
expect(screen.getByText('BoosApi Desktop 0.0.76 Windows 下载地址')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows query summary errors below the fold title with an Ant alert', () => {
|
||||
const { container } = render(
|
||||
<WebSearchQueryNode
|
||||
node={{
|
||||
type: 'web-search-query',
|
||||
attrs: {
|
||||
status: 'error',
|
||||
query: 'BoosApi 产品详情',
|
||||
},
|
||||
content: '模型返回空搜索语句',
|
||||
loading: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const title = screen.getByText('搜索语句总结失败');
|
||||
expect(title).toBeInTheDocument();
|
||||
expect(title.closest('[data-boosapi-search-query-status="error"]')).toBeInTheDocument();
|
||||
expect(title.closest('[data-boosapi-search-query-status="error"]')).toHaveStyle({
|
||||
color: '#ff4d4f',
|
||||
});
|
||||
expect(screen.getByText('模型返回空搜索语句')).toBeInTheDocument();
|
||||
expect(screen.getByText('BoosApi 产品详情')).toBeInTheDocument();
|
||||
expect(container.querySelector('.ant-alert-error')).toBeInTheDocument();
|
||||
|
||||
const errorNode = screen.getByText('模型返回空搜索语句');
|
||||
expect(title.compareDocumentPosition(errorNode) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows an empty result state instead of rendering nothing', () => {
|
||||
render(
|
||||
<WebSearchNode
|
||||
node={{
|
||||
type: 'web-search',
|
||||
attrs: { status: 'done' },
|
||||
content: '[]',
|
||||
loading: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('未找到相关搜索结果')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the concrete search error message', () => {
|
||||
const { container } = render(
|
||||
<WebSearchNode
|
||||
node={{
|
||||
type: 'web-search',
|
||||
attrs: { status: 'error' },
|
||||
content: 'Tavily request failed',
|
||||
loading: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Tavily request failed')).toBeInTheDocument();
|
||||
expect(container.querySelector('.ant-alert-error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
CHAT_AUTO_SCROLL_BOTTOM_THRESHOLD,
|
||||
CHAT_SCROLL_IS_REVERSED,
|
||||
getDistanceToHistoryTop,
|
||||
getScrollTopAfterPrepend,
|
||||
hasMeasuredScrollLayoutChanged,
|
||||
hasScrollLayoutMetricsChanged,
|
||||
resolveChatScrollElements,
|
||||
shouldIgnoreScrollDepartureFromBottom,
|
||||
shouldKeepAutoScroll,
|
||||
shouldStickToBottomOnLayoutChange,
|
||||
shouldShowScrollToBottom,
|
||||
} from '../chatScroll';
|
||||
|
||||
describe('chat scroll helpers', () => {
|
||||
it('exposes the chat bubble list as a normal scroll container when Bubble.List autoScroll is disabled', () => {
|
||||
expect(CHAT_SCROLL_IS_REVERSED).toBe(false);
|
||||
});
|
||||
|
||||
it('uses the app scroll mode to show the bottom button when the user leaves the latest message area', () => {
|
||||
expect(shouldShowScrollToBottom(2000, 1200, 800, CHAT_SCROLL_IS_REVERSED)).toBe(false);
|
||||
expect(shouldShowScrollToBottom(2000, 1040, 800, CHAT_SCROLL_IS_REVERSED)).toBe(false);
|
||||
expect(shouldShowScrollToBottom(2000, 900, 800, CHAT_SCROLL_IS_REVERSED)).toBe(true);
|
||||
});
|
||||
|
||||
it('treats reversed bubble scroll near zero as the latest-message position', () => {
|
||||
expect(shouldShowScrollToBottom(2000, 0, 800, true)).toBe(false);
|
||||
expect(shouldShowScrollToBottom(2000, -80, 800, true)).toBe(false);
|
||||
expect(shouldShowScrollToBottom(2000, -240, 800, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('measures distance to the logical history top for auto-loading older pages', () => {
|
||||
expect(getDistanceToHistoryTop(2000, -1200, 800, true)).toBe(0);
|
||||
expect(getDistanceToHistoryTop(2000, 0, 800, true)).toBe(1200);
|
||||
expect(getDistanceToHistoryTop(2000, 0, 800, false)).toBe(0);
|
||||
});
|
||||
|
||||
it('stops auto-scroll as soon as the user meaningfully leaves the bottom', () => {
|
||||
expect(CHAT_AUTO_SCROLL_BOTTOM_THRESHOLD).toBe(8);
|
||||
expect(shouldKeepAutoScroll(2000, 0, 800, true)).toBe(true);
|
||||
expect(shouldKeepAutoScroll(2000, -7, 800, true)).toBe(true);
|
||||
expect(shouldKeepAutoScroll(2000, -12, 800, true)).toBe(false);
|
||||
expect(shouldKeepAutoScroll(2000, 1200, 800, false)).toBe(true);
|
||||
expect(shouldKeepAutoScroll(2000, 1193, 800, false)).toBe(true);
|
||||
expect(shouldKeepAutoScroll(2000, 1180, 800, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('detects layout changes that happen after content finishes rendering', () => {
|
||||
expect(hasScrollLayoutMetricsChanged(
|
||||
{ scrollHeight: 1200, clientHeight: 800 },
|
||||
{ scrollHeight: 1248, clientHeight: 800 },
|
||||
)).toBe(true);
|
||||
|
||||
expect(hasScrollLayoutMetricsChanged(
|
||||
{ scrollHeight: 1200, clientHeight: 800 },
|
||||
{ scrollHeight: 1200, clientHeight: 760 },
|
||||
)).toBe(true);
|
||||
|
||||
expect(hasScrollLayoutMetricsChanged(
|
||||
{ scrollHeight: 1200, clientHeight: 800 },
|
||||
{ scrollHeight: 1200.5, clientHeight: 800 },
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not treat the first unknown scroll measurement as a layout departure', () => {
|
||||
const hasLayoutChanged = hasMeasuredScrollLayoutChanged(
|
||||
{ scrollHeight: 0, clientHeight: 0 },
|
||||
{ scrollHeight: 2000, clientHeight: 800 },
|
||||
);
|
||||
|
||||
expect(hasLayoutChanged).toBe(false);
|
||||
expect(shouldIgnoreScrollDepartureFromBottom(false, true, false, hasLayoutChanged)).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves the chat scroll elements from the message area before the Bubble.List ref is ready', () => {
|
||||
const root = document.createElement('div');
|
||||
const scrollBox = document.createElement('div');
|
||||
const scrollContent = document.createElement('div');
|
||||
scrollBox.className = 'ant-bubble-list-scroll-box';
|
||||
scrollContent.className = 'ant-bubble-list-scroll-content';
|
||||
scrollBox.appendChild(scrollContent);
|
||||
root.appendChild(scrollBox);
|
||||
|
||||
expect(resolveChatScrollElements(root, null)).toEqual({ scrollBox, scrollContent });
|
||||
});
|
||||
|
||||
it('keeps bottom lock on post-render layout changes only when the user was pinned', () => {
|
||||
expect(shouldStickToBottomOnLayoutChange(
|
||||
{ scrollHeight: 1200, clientHeight: 800 },
|
||||
{ scrollHeight: 1280, clientHeight: 800 },
|
||||
true,
|
||||
)).toBe(true);
|
||||
|
||||
expect(shouldStickToBottomOnLayoutChange(
|
||||
{ scrollHeight: 1200, clientHeight: 800 },
|
||||
{ scrollHeight: 1280, clientHeight: 800 },
|
||||
false,
|
||||
)).toBe(false);
|
||||
|
||||
expect(shouldStickToBottomOnLayoutChange(
|
||||
{ scrollHeight: 1200, clientHeight: 800 },
|
||||
{ scrollHeight: 1280, clientHeight: 800 },
|
||||
true,
|
||||
true,
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores non-user scroll departures only when layout size changed while pinned', () => {
|
||||
expect(shouldIgnoreScrollDepartureFromBottom(false, true, false, true)).toBe(true);
|
||||
expect(shouldIgnoreScrollDepartureFromBottom(false, true, false, false)).toBe(false);
|
||||
expect(shouldIgnoreScrollDepartureFromBottom(false, true, true, true)).toBe(false);
|
||||
expect(shouldIgnoreScrollDepartureFromBottom(true, true, false, true)).toBe(false);
|
||||
expect(shouldIgnoreScrollDepartureFromBottom(false, false, false, true)).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves the viewport anchor when older messages are prepended in reversed chat mode', () => {
|
||||
expect(getScrollTopAfterPrepend(0, 1200, 1600, true)).toBe(-400);
|
||||
expect(getScrollTopAfterPrepend(-240, 1200, 1600, true)).toBe(-640);
|
||||
});
|
||||
|
||||
it('preserves the viewport anchor when older messages are prepended in regular scroll mode', () => {
|
||||
expect(getScrollTopAfterPrepend(0, 1200, 1600, false)).toBe(400);
|
||||
expect(getScrollTopAfterPrepend(240, 1200, 1600, false)).toBe(640);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getStreamingLoadingState,
|
||||
getStreamingStatusPresentation,
|
||||
hasBoosapiDisplayContent,
|
||||
hasModelVisibleContent,
|
||||
THINKING_LOADING_MARKER,
|
||||
closeStreamingThinkBlock,
|
||||
isAssistantStreamingForRender,
|
||||
shouldRenderAssistantMarkdownFromContent,
|
||||
shouldShowInitialStreamingDots,
|
||||
shouldShowInlineStreamingStatus,
|
||||
splitLeadingBoosapiDisplayContent,
|
||||
stripLeadingBoosapiDisplayTags,
|
||||
} from '../chatStreaming';
|
||||
|
||||
describe('chat streaming helpers', () => {
|
||||
it('derives bubble and footer loading state from stream progress and content presence', () => {
|
||||
expect(getStreamingLoadingState(true, '')).toEqual({
|
||||
bubbleLoading: true,
|
||||
footerLoading: false,
|
||||
});
|
||||
|
||||
expect(getStreamingLoadingState(true, 'hello')).toEqual({
|
||||
bubbleLoading: false,
|
||||
footerLoading: true,
|
||||
});
|
||||
|
||||
expect(getStreamingLoadingState(false, 'hello')).toEqual({
|
||||
bubbleLoading: false,
|
||||
footerLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps streamed assistant messages on the content renderer after completion', () => {
|
||||
expect(shouldRenderAssistantMarkdownFromContent(true, false)).toBe(true);
|
||||
expect(shouldRenderAssistantMarkdownFromContent(false, true)).toBe(true);
|
||||
expect(shouldRenderAssistantMarkdownFromContent(false, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('shows initial streaming dots for empty model content', () => {
|
||||
const stripDisplayTags = (content: string) => content
|
||||
.replace(/<knowledge-retrieval [^>]*data-boosapi="1"[^>]*>[\s\S]*?<\/knowledge-retrieval>\s*/g, '')
|
||||
.trim();
|
||||
|
||||
expect(shouldShowInitialStreamingDots(true, '', stripDisplayTags)).toBe(true);
|
||||
expect(shouldShowInitialStreamingDots(true, '<knowledge-retrieval status="done" data-boosapi="1">[]</knowledge-retrieval>', stripDisplayTags)).toBe(true);
|
||||
expect(shouldShowInitialStreamingDots(true, 'answer', stripDisplayTags)).toBe(false);
|
||||
expect(shouldShowInitialStreamingDots(false, '', stripDisplayTags)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps inline streaming status visible while only thinking content is streaming', () => {
|
||||
expect(shouldShowInlineStreamingStatus({
|
||||
isStreaming: true,
|
||||
hasDisplayContent: false,
|
||||
hasActiveThinkingOnly: true,
|
||||
hasRenderedModelText: false,
|
||||
})).toBe(true);
|
||||
|
||||
expect(shouldShowInlineStreamingStatus({
|
||||
isStreaming: true,
|
||||
hasDisplayContent: false,
|
||||
hasActiveThinkingOnly: true,
|
||||
hasRenderedModelText: true,
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('describes waiting before the first model chunk', () => {
|
||||
expect(getStreamingStatusPresentation({
|
||||
isStreaming: true,
|
||||
activity: {
|
||||
startedAt: 1_000,
|
||||
firstChunkAt: null,
|
||||
lastChunkAt: null,
|
||||
providerId: 'provider-1',
|
||||
modelId: 'model-1',
|
||||
phase: 'waiting_first_packet',
|
||||
},
|
||||
now: 5_000,
|
||||
hasModelText: false,
|
||||
})!.labelKey).toBe('chat.streamingStatus.waitingFirstPacket');
|
||||
|
||||
expect(getStreamingStatusPresentation({
|
||||
isStreaming: true,
|
||||
activity: {
|
||||
startedAt: 1_000,
|
||||
firstChunkAt: null,
|
||||
lastChunkAt: null,
|
||||
providerId: 'provider-1',
|
||||
modelId: 'model-1',
|
||||
phase: 'waiting_first_packet',
|
||||
},
|
||||
now: 12_000,
|
||||
hasModelText: false,
|
||||
})!.labelKey).toBe('chat.streamingStatus.waitingProvider');
|
||||
});
|
||||
|
||||
it('describes active and idle generation after model chunks arrive', () => {
|
||||
expect(getStreamingStatusPresentation({
|
||||
isStreaming: true,
|
||||
activity: {
|
||||
startedAt: 1_000,
|
||||
firstChunkAt: 2_000,
|
||||
lastChunkAt: 9_000,
|
||||
providerId: 'provider-1',
|
||||
modelId: 'model-1',
|
||||
phase: 'streaming',
|
||||
},
|
||||
now: 12_000,
|
||||
hasModelText: true,
|
||||
})!.labelKey).toBe('chat.streamingStatus.generating');
|
||||
|
||||
expect(getStreamingStatusPresentation({
|
||||
isStreaming: true,
|
||||
activity: {
|
||||
startedAt: 1_000,
|
||||
firstChunkAt: 2_000,
|
||||
lastChunkAt: 3_000,
|
||||
providerId: 'provider-1',
|
||||
modelId: 'model-1',
|
||||
phase: 'streaming',
|
||||
},
|
||||
now: 19_000,
|
||||
hasModelText: true,
|
||||
})!.labelKey).toBe('chat.streamingStatus.waitingNextChunk');
|
||||
});
|
||||
|
||||
it('ignores display-only tags when deciding whether model text exists', () => {
|
||||
const stripDisplayTags = (content: string) => content
|
||||
.replace(/<knowledge-retrieval [^>]*data-boosapi="1"[^>]*>[\s\S]*?<\/knowledge-retrieval>\s*/g, '')
|
||||
.replace(/<think[^>]*>[\s\S]*?<\/think>\s*/g, '')
|
||||
.trim();
|
||||
|
||||
expect(hasModelVisibleContent(
|
||||
'<knowledge-retrieval status="done" data-boosapi="1">[]</knowledge-retrieval>',
|
||||
stripDisplayTags,
|
||||
)).toBe(false);
|
||||
expect(hasModelVisibleContent(
|
||||
'<knowledge-retrieval status="done" data-boosapi="1">[]</knowledge-retrieval>\n\nanswer',
|
||||
stripDisplayTags,
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('detects BoosApi display tags independently from model text', () => {
|
||||
expect(hasBoosapiDisplayContent(
|
||||
'<knowledge-retrieval status="done" data-boosapi="1">[]</knowledge-retrieval>',
|
||||
)).toBe(true);
|
||||
expect(hasBoosapiDisplayContent('answer')).toBe(false);
|
||||
});
|
||||
|
||||
it('splits leading BoosApi display tags from streamed model text', () => {
|
||||
const knowledge = '<knowledge-retrieval status="done" data-boosapi="1">[]</knowledge-retrieval>\n\n';
|
||||
const memory = '<memory-retrieval status="done" data-boosapi="1">[]</memory-retrieval>\n\n';
|
||||
|
||||
expect(splitLeadingBoosapiDisplayContent(`${knowledge}${memory}answer`)).toEqual({
|
||||
prefix: `${knowledge}${memory}`,
|
||||
body: 'answer',
|
||||
});
|
||||
expect(splitLeadingBoosapiDisplayContent(`answer\n${knowledge}`)).toEqual({
|
||||
prefix: '',
|
||||
body: `answer\n${knowledge}`,
|
||||
});
|
||||
});
|
||||
|
||||
it('strips selected leading display tags while preserving other display prefixes', () => {
|
||||
const web = '<web-search status="done" data-boosapi="1">[]</web-search>\n\n';
|
||||
const query = '<web-search-query status="done" data-boosapi="1">query</web-search-query>\n\n';
|
||||
const knowledge = '<knowledge-retrieval status="done" data-boosapi="1">[]</knowledge-retrieval>\n\n';
|
||||
|
||||
expect(stripLeadingBoosapiDisplayTags(
|
||||
`${query}${web}${knowledge}answer`,
|
||||
['knowledge-retrieval', 'memory-retrieval'],
|
||||
)).toBe(`${query}${web}answer`);
|
||||
});
|
||||
|
||||
it('temporarily closes streamed native think blocks without dropping leading text', () => {
|
||||
const content = '<think>好的,用户让我讲个笑话。';
|
||||
|
||||
expect(closeStreamingThinkBlock(content, true)).toBe(
|
||||
`<think>\n好的,用户让我讲个笑话。${THINKING_LOADING_MARKER}\n</think>\n\n`,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not alter completed, incomplete, or non-streaming think content', () => {
|
||||
expect(closeStreamingThinkBlock('<think>done</think>\n\nanswer', true)).toBe('<think>done</think>\n\nanswer');
|
||||
expect(closeStreamingThinkBlock('<thi', true)).toBe('<thi');
|
||||
expect(closeStreamingThinkBlock('<think>still thinking', false)).toBe('<think>still thinking');
|
||||
});
|
||||
|
||||
it('temporarily closes streamed think blocks with attributes', () => {
|
||||
expect(closeStreamingThinkBlock('<think data-boosapi="1">\nreasoning', true)).toBe(
|
||||
`<think data-boosapi="1">\nreasoning${THINKING_LOADING_MARKER}\n</think>\n\n`,
|
||||
);
|
||||
expect(closeStreamingThinkBlock('<think totalMs="123">done</think>\n\nanswer', true)).toBe(
|
||||
'<think totalMs="123">done</think>\n\nanswer',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps partial assistant messages on the streaming renderer even when ids differ', () => {
|
||||
expect(isAssistantStreamingForRender({
|
||||
isStreaming: true,
|
||||
messageId: 'real-message-id',
|
||||
streamingMessageId: 'temp-assistant-id',
|
||||
status: 'partial',
|
||||
})).toBe(true);
|
||||
expect(isAssistantStreamingForRender({
|
||||
isStreaming: true,
|
||||
messageId: 'real-message-id',
|
||||
streamingMessageId: 'temp-assistant-id',
|
||||
status: 'complete',
|
||||
})).toBe(false);
|
||||
expect(isAssistantStreamingForRender({
|
||||
isStreaming: false,
|
||||
messageId: 'real-message-id',
|
||||
streamingMessageId: 'temp-assistant-id',
|
||||
status: 'partial',
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatChatTime, normalizeChatTimestamp } from '../chatTime';
|
||||
|
||||
describe('chatTime', () => {
|
||||
it('formats backend unix-second timestamps the same as millisecond timestamps', () => {
|
||||
const timestampMs = Date.UTC(2026, 3, 30, 13, 0, 0);
|
||||
const timestampSeconds = Math.floor(timestampMs / 1000);
|
||||
|
||||
expect(normalizeChatTimestamp(timestampSeconds)).toBe(timestampMs);
|
||||
expect(formatChatTime(timestampSeconds, timestampMs)).toBe(formatChatTime(timestampMs, timestampMs));
|
||||
});
|
||||
|
||||
it('keeps frontend millisecond timestamps unchanged', () => {
|
||||
const timestampMs = Date.UTC(2026, 3, 30, 13, 45, 0);
|
||||
|
||||
expect(normalizeChatTimestamp(timestampMs)).toBe(timestampMs);
|
||||
});
|
||||
|
||||
it('shows only the time for messages from today', () => {
|
||||
const messageTime = new Date(2026, 3, 30, 21, 0, 5).getTime();
|
||||
const now = new Date(2026, 3, 30, 21, 30, 0).getTime();
|
||||
|
||||
expect(formatChatTime(messageTime, now)).toBe('21:00:05');
|
||||
});
|
||||
|
||||
it('shows the local date and time for messages outside today', () => {
|
||||
const messageTime = new Date(2026, 3, 29, 21, 0, 5).getTime();
|
||||
const now = new Date(2026, 3, 30, 21, 30, 0).getTime();
|
||||
|
||||
expect(formatChatTime(messageTime, now)).toBe('2026-04-29 21:00:05');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
function readSource(...segments: string[]) {
|
||||
return fs.readFileSync(path.resolve(process.cwd(), ...segments), 'utf8');
|
||||
}
|
||||
|
||||
describe('Phase A feedback regressions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('keeps chat scroll handling aligned with the shared Bubble.List scroll mode', () => {
|
||||
const chatViewSource = readSource('src/components/chat/ChatView.tsx');
|
||||
const chatScrollSource = readSource('src/components/chat/chatScroll.ts');
|
||||
|
||||
expect(chatScrollSource).toContain('CHAT_SCROLL_IS_REVERSED = false');
|
||||
expect(chatViewSource).toContain('CHAT_SCROLL_IS_REVERSED');
|
||||
expect(chatViewSource).not.toContain('shouldShowScrollToBottom(\n target.scrollHeight,\n target.scrollTop,\n target.clientHeight,\n false,');
|
||||
expect(chatViewSource).not.toContain('shouldKeepAutoScroll(\n target.scrollHeight,\n target.scrollTop,\n target.clientHeight,\n false,');
|
||||
expect(chatViewSource).not.toContain('getDistanceToHistoryTop(\n target.scrollHeight,\n target.scrollTop,\n target.clientHeight,\n false,');
|
||||
});
|
||||
|
||||
it('gives the chat textarea a dedicated visible-scrollbar hook instead of relying on globally hidden scrollbars', () => {
|
||||
const inputAreaSource = readSource('src/components/chat/InputArea.tsx');
|
||||
const cssSource = readSource('src/index.css');
|
||||
|
||||
expect(inputAreaSource).toContain('className="boosapi-input-textarea"');
|
||||
expect(cssSource).toContain('.boosapi-input-textarea');
|
||||
expect(cssSource).toMatch(/\.boosapi-input-textarea[\s\S]*scrollbar-width:\s*thin/i);
|
||||
});
|
||||
|
||||
it('feeds normalized assistant markdown content into the stream renderer', () => {
|
||||
const chatViewSource = readSource('src/components/chat/ChatView.tsx');
|
||||
|
||||
expect(chatViewSource).toContain('const rendererContent = displaySplit.body;');
|
||||
expect(chatViewSource).not.toContain('const rendererContent = displaySplit.prefix ? displaySplit.body : content;');
|
||||
});
|
||||
|
||||
it('keeps a null conversation max tokens override visually off instead of hydrating it from the global default', () => {
|
||||
const modalSource = readSource('src/components/chat/ConversationSettingsModal.tsx');
|
||||
|
||||
expect(modalSource).toContain('setMaxTokens(conversation.max_tokens ?? null)');
|
||||
expect(modalSource).not.toContain('setMaxTokens(conversation.max_tokens ?? settings.default_max_tokens ?? 4096)');
|
||||
});
|
||||
|
||||
it('treats max tokens clearing as an explicit nullable contract from modal to TypeScript types to Rust persistence', () => {
|
||||
const modalSource = readSource('src/components/chat/ConversationSettingsModal.tsx');
|
||||
const typeSource = readSource('src/types/index.ts');
|
||||
const rustTypeSource = readSource('src-tauri/crates/core/src/types.rs');
|
||||
const repoSource = readSource('src-tauri/crates/core/src/repo/conversation.rs');
|
||||
|
||||
expect(modalSource).toContain('max_tokens: maxTokens,');
|
||||
expect(typeSource).toMatch(/max_tokens\?: number \| null;/);
|
||||
expect(rustTypeSource).toMatch(/deserialize_double_option"\)\]\s*pub max_tokens: Option<Option<i64>>/);
|
||||
expect(repoSource).toContain('if let Some(max_tokens) = input.max_tokens {');
|
||||
expect(repoSource).toContain('am.max_tokens = Set(max_tokens);');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function readSource(...segments: string[]) {
|
||||
return fs.readFileSync(path.resolve(process.cwd(), ...segments), 'utf8');
|
||||
}
|
||||
|
||||
describe('Phase B category template regressions', () => {
|
||||
it('extends category contracts with default model and parameter template fields', () => {
|
||||
const typeSource = readSource('src/types/index.ts');
|
||||
const rustTypeSource = readSource('src-tauri/crates/core/src/types.rs');
|
||||
const entitySource = readSource('src-tauri/crates/core/src/entity/conversation_categories.rs');
|
||||
|
||||
expect(typeSource).toMatch(/default_provider_id: string \| null;/);
|
||||
expect(typeSource).toMatch(/default_model_id: string \| null;/);
|
||||
expect(typeSource).toMatch(/default_temperature: number \| null;/);
|
||||
expect(typeSource).toMatch(/default_max_tokens: number \| null;/);
|
||||
expect(typeSource).toMatch(/default_top_p: number \| null;/);
|
||||
expect(typeSource).toMatch(/default_frequency_penalty: number \| null;/);
|
||||
|
||||
expect(rustTypeSource).toMatch(/pub default_provider_id: Option<String>/);
|
||||
expect(rustTypeSource).toMatch(/pub default_model_id: Option<String>/);
|
||||
expect(rustTypeSource).toMatch(/pub default_temperature: Option<f64>/);
|
||||
expect(rustTypeSource).toMatch(/pub default_max_tokens: Option<i64>/);
|
||||
expect(rustTypeSource).toMatch(/pub default_top_p: Option<f64>/);
|
||||
expect(rustTypeSource).toMatch(/pub default_frequency_penalty: Option<f64>/);
|
||||
|
||||
expect(entitySource).toMatch(/pub default_provider_id: Option<String>/);
|
||||
expect(entitySource).toMatch(/pub default_model_id: Option<String>/);
|
||||
});
|
||||
|
||||
it('lets the category editor configure a default model plus model params', () => {
|
||||
const modalSource = readSource('src/components/chat/CategoryEditModal.tsx');
|
||||
|
||||
expect(modalSource).toContain('ModelSelect');
|
||||
expect(modalSource).toContain('ModelParamSliders');
|
||||
expect(modalSource).toContain('default_provider_id');
|
||||
expect(modalSource).toContain('default_model_id');
|
||||
expect(modalSource).toContain('default_temperature');
|
||||
expect(modalSource).toContain('default_max_tokens');
|
||||
expect(modalSource).toContain('default_top_p');
|
||||
expect(modalSource).toContain('default_frequency_penalty');
|
||||
});
|
||||
|
||||
it('adds a category-scoped new conversation action instead of forcing users to create then move', () => {
|
||||
const sidebarSource = readSource('src/components/chat/ChatSidebar.tsx');
|
||||
|
||||
expect(sidebarSource).toContain('onCreateConversation');
|
||||
expect(sidebarSource).toContain("key: 'new'");
|
||||
expect(sidebarSource).toContain('handleNewConversation(cat.id)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function readSource(...segments: string[]) {
|
||||
return fs.readFileSync(path.resolve(process.cwd(), ...segments), 'utf8');
|
||||
}
|
||||
|
||||
describe('Phase C output control regressions', () => {
|
||||
it('lets assistant replies enter the shared edit flow instead of restricting edits to user prompts', () => {
|
||||
const source = readSource('src/components/chat/ChatView.tsx');
|
||||
|
||||
expect(source).toContain('editingMessageRole');
|
||||
expect(source).toContain("msg.role === 'assistant'");
|
||||
expect(source).toContain("key: 'edit'");
|
||||
expect(source).toContain("editingMessageRole === 'assistant'");
|
||||
});
|
||||
|
||||
it('shows a per-turn total token summary alongside prompt and completion counts', () => {
|
||||
const source = readSource('src/components/chat/ChatView.tsx');
|
||||
|
||||
expect(source).toContain('const totalTokens = (msg.prompt_tokens ?? 0) + (msg.completion_tokens ?? 0);');
|
||||
expect(source).toContain('{formatTokenCount(totalTokens)} tokens');
|
||||
expect(source).not.toContain("t('chat.totalTokens'");
|
||||
});
|
||||
|
||||
it('adds transcript copy and no-thinking export variants at chat level', () => {
|
||||
const source = readSource('src/components/chat/ChatView.tsx');
|
||||
|
||||
expect(source).toContain("key: 'copy-md'");
|
||||
expect(source).toContain("key: 'export-md-no-thinking'");
|
||||
expect(source).toContain("key: 'export-json-no-thinking'");
|
||||
});
|
||||
|
||||
it('lets export helpers optionally strip thinking content before saving or copying', () => {
|
||||
const source = readSource('src/lib/exportChat.ts');
|
||||
|
||||
expect(source).toContain('includeThinking');
|
||||
expect(source).toContain('stripBoosapiTags');
|
||||
expect(source).toContain('copyTranscript');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { Message } from '@/types';
|
||||
import { buildAssistantDisplayContent, shouldHideAssistantBubble } from '../toolCallDisplay';
|
||||
|
||||
function makeMessage(overrides: Partial<Message>): Message {
|
||||
return {
|
||||
id: 'msg-1',
|
||||
conversation_id: 'conv-1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
provider_id: null,
|
||||
model_id: null,
|
||||
token_count: null,
|
||||
attachments: [],
|
||||
thinking: null,
|
||||
tool_calls_json: null,
|
||||
tool_call_id: null,
|
||||
created_at: 1,
|
||||
parent_message_id: null,
|
||||
version_index: 0,
|
||||
is_active: true,
|
||||
status: 'complete',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildAssistantDisplayContent', () => {
|
||||
it('returns content as-is for assistant messages', () => {
|
||||
const msg = makeMessage({ content: 'Hello world' });
|
||||
expect(buildAssistantDisplayContent(msg, [])).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('returns content with :::mcp containers unchanged', () => {
|
||||
const content = ':::mcp {"name":"@boosapi/fetch","tool":"fetch_url"}\nresult\n:::\n';
|
||||
const msg = makeMessage({ content });
|
||||
expect(buildAssistantDisplayContent(msg, [])).toBe(content);
|
||||
});
|
||||
|
||||
it('does not hide an empty normal assistant placeholder bubble while streaming', () => {
|
||||
const assistant = makeMessage({
|
||||
id: 'assistant-streaming',
|
||||
content: '',
|
||||
tool_calls_json: null,
|
||||
});
|
||||
|
||||
expect(shouldHideAssistantBubble(assistant, '')).toBe(false);
|
||||
});
|
||||
|
||||
it('hides assistant bubble with empty content but present tool_calls_json', () => {
|
||||
const assistant = makeMessage({
|
||||
content: '',
|
||||
tool_calls_json: '[{"id":"call-1","type":"function","function":{"name":"fetch","arguments":"{}"}}]',
|
||||
});
|
||||
|
||||
expect(shouldHideAssistantBubble(assistant, '')).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user