dify/web/app/components/datasets/hit-testing/__tests__/index.spec.tsx

614 lines
18 KiB
TypeScript

import type { ReactNode } from 'react'
import type { DataSet, HitTesting, HitTestingRecord, HitTestingResponse } from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { render } from '@/test/console/render'
import { RETRIEVE_METHOD } from '@/types/app'
import HitTestingPage from '../index'
vi.mock('@langgenius/dify-ui/pagination', () => ({
Pagination: ({
page,
totalPages,
onPageChange,
labels,
}: {
page: number
totalPages: number
onPageChange: (page: number) => void
labels: { next: string }
}) => (
<button
type="button"
aria-label={labels.next}
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
>
{page}/{totalPages}
</button>
),
}))
vi.mock('@/app/components/datasets/common/retrieval-method-config', () => ({
default: ({
value,
onChange,
}: {
value: RetrievalConfig
onChange: (value: RetrievalConfig) => void
}) => (
<button type="button" onClick={() => onChange({ ...value, top_k: 8 })}>
Use Top K 8
</button>
),
}))
// Note: These components use real implementations for integration testing:
// - Toast, FloatRightContainer, Drawer, Pagination, Loading
// - RetrievalMethodConfig, EconomicalRetrievalMethodConfig
// - ImageUploaderInRetrievalTesting, retrieval-method-info, check-rerank-model
// Mock RetrievalSettings to allow triggering onChange
vi.mock('@/app/components/datasets/external-knowledge-base/create/RetrievalSettings', () => ({
default: ({
onChange,
}: {
onChange: (data: {
top_k?: number
score_threshold?: number
score_threshold_enabled?: boolean
}) => void
}) => {
return (
<div data-testid="retrieval-settings-mock">
<button data-testid="change-top-k" onClick={() => onChange({ top_k: 8 })}>
Change Top K
</button>
<button
data-testid="change-score-threshold"
onClick={() => onChange({ score_threshold: 0.9 })}
>
Change Score Threshold
</button>
<button
data-testid="change-score-enabled"
onClick={() => onChange({ score_threshold_enabled: true })}
>
Change Score Enabled
</button>
</div>
)
},
}))
// Mock Setup
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
}),
usePathname: () => '/test',
useSearchParams: () => new URLSearchParams(),
}))
// Mock use-context-selector
const mockDataset = {
id: 'dataset-1',
name: 'Test Dataset',
provider: 'vendor',
indexing_technique: 'high_quality' as const,
retrieval_model_dict: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_mode: undefined,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
weights: undefined,
top_k: 10,
score_threshold_enabled: false,
score_threshold: 0.5,
},
is_multimodal: false,
permission_keys: ['dataset.acl.retrieval_recall'],
} as Partial<DataSet>
let mockConsoleState = {
userProfile: { id: 'user-1' },
workspacePermissionKeys: [] as string[],
}
vi.mock('use-context-selector', () => ({
useContext: vi.fn(() => ({ dataset: mockDataset })),
useContextSelector: vi.fn((_, selector) => selector({ dataset: mockDataset })),
createContext: vi.fn(() => ({})),
}))
// Mock dataset detail context
vi.mock('@/context/dataset-detail', () => ({
default: {},
useDatasetDetailContext: vi.fn(() => ({ dataset: mockDataset })),
useDatasetDetailContextWithSelector: vi.fn(
(selector: (v: { dataset?: typeof mockDataset }) => unknown) =>
selector({ dataset: mockDataset as DataSet }),
),
}))
vi.mock('@/context/account-state', async () => {
const { createAccountStateModuleMock } = await import('@/test/console/state-fixture')
return createAccountStateModuleMock(() => mockConsoleState)
})
vi.mock('@/context/workspace-state', async () => {
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
return createWorkspaceStateModuleMock(() => mockConsoleState)
})
vi.mock('@/context/permission-state', async () => {
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
return createPermissionStateModuleMock(() => mockConsoleState)
})
vi.mock('@/context/system-features-state', async () => {
const { createSystemFeaturesStateModuleMock } = await import('@/test/console/state-fixture')
return createSystemFeaturesStateModuleMock(() => mockConsoleState)
})
const mockRecordsRefetch = vi.fn()
const mockHitTestingMutateAsync = vi.fn()
const mockExternalHitTestingMutateAsync = vi.fn()
vi.mock('@/service/knowledge/use-dataset', () => ({
useDatasetTestingRecords: vi.fn(() => ({
data: {
data: [],
total: 0,
page: 1,
limit: 10,
has_more: false,
},
refetch: mockRecordsRefetch,
isLoading: false,
})),
}))
vi.mock('@/service/knowledge/use-hit-testing', () => ({
useHitTesting: vi.fn(() => ({
mutateAsync: mockHitTestingMutateAsync,
isPending: false,
})),
useExternalKnowledgeBaseHitTesting: vi.fn(() => ({
mutateAsync: mockExternalHitTestingMutateAsync,
isPending: false,
})),
}))
// Mock breakpoints hook
vi.mock('@/hooks/use-breakpoints', () => ({
default: vi.fn(() => 'pc'),
MediaType: {
mobile: 'mobile',
pc: 'pc',
},
}))
// Mock timestamp hook
vi.mock('@/hooks/use-timestamp', () => ({
default: vi.fn(() => ({
formatTime: vi.fn((timestamp: number, _format: string) =>
new Date(timestamp * 1000).toISOString(),
),
})),
}))
// Mock use-common to avoid QueryClient issues in nested hooks
vi.mock('@/service/use-common', () => ({
useFileUploadConfig: vi.fn(() => ({
data: {
file_size_limit: 10,
batch_count_limit: 5,
image_file_size_limit: 5,
},
isLoading: false,
})),
}))
// Store ref to ImageUploader onChange for testing
let _mockImageUploaderOnChange:
| ((
files: Array<{
sourceUrl?: string
uploadedId?: string
mimeType: string
name: string
size: number
extension: string
}>,
) => void)
| null = null
// Mock ImageUploaderInRetrievalTesting to capture onChange
vi.mock(
'@/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing',
() => ({
default: ({
textArea,
actionButton,
onChange,
}: {
textArea: React.ReactNode
actionButton: React.ReactNode
onChange: (
files: Array<{
sourceUrl?: string
uploadedId?: string
mimeType: string
name: string
size: number
extension: string
}>,
) => void
}) => {
_mockImageUploaderOnChange = onChange
return (
<div data-testid="image-uploader-mock">
{textArea}
{actionButton}
<button
data-testid="trigger-image-change"
onClick={() =>
onChange([
{
sourceUrl: 'http://example.com/new-image.png',
uploadedId: 'new-uploaded-id',
mimeType: 'image/png',
name: 'new-image.png',
size: 2000,
extension: 'png',
},
])
}
>
Add Image
</button>
</div>
)
},
}),
)
// Mock docLink hook
vi.mock('@/context/i18n', () => ({
useDocLink: vi.fn(() => () => 'https://docs.example.com'),
}))
// Mock provider context for retrieval method config
vi.mock('@/context/provider-context', () => ({
useProviderContext: vi.fn(() => ({
supportRetrievalMethods: ['semantic_search', 'full_text_search', 'hybrid_search'],
})),
}))
// Mock model list hook - include all exports used by child components
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelList: vi.fn(() => ({
data: [],
isLoading: false,
})),
useModelListAndDefaultModelAndCurrentProviderAndModel: vi.fn(() => ({
modelList: [],
defaultModel: undefined,
currentProvider: undefined,
currentModel: undefined,
})),
useModelListAndDefaultModel: vi.fn(() => ({
modelList: [],
defaultModel: undefined,
})),
useCurrentProviderAndModel: vi.fn(() => ({
currentProvider: undefined,
currentModel: undefined,
})),
useDefaultModel: vi.fn(() => ({
defaultModel: undefined,
})),
}))
// Test Wrapper with QueryClientProvider
const createConsoleQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
mutations: {
retry: false,
},
},
})
const TestWrapper = ({ children }: { children: ReactNode }) => {
const queryClient = createConsoleQueryClient()
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
const renderWithProviders = (ui: React.ReactElement) => {
return render(ui, { wrapper: TestWrapper })
}
// Test Factories
const createMockSegment = (overrides = {}) => ({
id: 'segment-1',
document: {
id: 'doc-1',
data_source_type: 'upload_file',
name: 'test-document.pdf',
doc_type: 'book' as const,
},
content: 'Test segment content',
sign_content: 'Test signed content',
position: 1,
word_count: 100,
tokens: 50,
keywords: ['test', 'keyword'],
hit_count: 5,
index_node_hash: 'hash-123',
answer: '',
...overrides,
})
const createMockHitTesting = (overrides = {}): HitTesting => ({
segment: createMockSegment() as HitTesting['segment'],
content: createMockSegment() as HitTesting['content'],
score: 0.85,
tsne_position: { x: 0.5, y: 0.5 },
child_chunks: null,
files: [],
...overrides,
})
const createMockRecord = (overrides = {}): HitTestingRecord => ({
id: 'record-1',
source: 'hit_testing',
source_app_id: 'app-1',
created_by_role: 'account',
created_by: 'user-1',
created_at: 1609459200,
queries: [{ content: 'Test query', content_type: 'text_query', file_info: null }],
...overrides,
})
const _createMockRetrievalConfig = (overrides = {}): RetrievalConfig =>
({
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_mode: undefined,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
weights: undefined,
top_k: 10,
score_threshold_enabled: false,
score_threshold: 0.5,
...overrides,
}) as RetrievalConfig
// HitTestingPage integration tests
describe('HitTestingPage', () => {
beforeEach(async () => {
vi.clearAllMocks()
mockRecordsRefetch.mockReset()
mockHitTestingMutateAsync.mockReset()
mockExternalHitTestingMutateAsync.mockReset()
Object.assign(mockDataset, {
provider: 'vendor',
indexing_technique: 'high_quality',
is_multimodal: false,
permission_keys: ['dataset.acl.retrieval_recall'],
})
mockConsoleState = {
userProfile: { id: 'user-1' },
workspacePermissionKeys: [],
}
const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetTestingRecords).mockReturnValue({
data: { data: [], total: 0, page: 1, limit: 10, has_more: false },
refetch: mockRecordsRefetch,
isLoading: false,
} as unknown as ReturnType<typeof useDatasetTestingRecords>)
const { useHitTesting, useExternalKnowledgeBaseHitTesting } =
await import('@/service/knowledge/use-hit-testing')
vi.mocked(useHitTesting).mockReturnValue({
mutateAsync: mockHitTestingMutateAsync,
isPending: false,
} as unknown as ReturnType<typeof useHitTesting>)
vi.mocked(useExternalKnowledgeBaseHitTesting).mockReturnValue({
mutateAsync: mockExternalHitTestingMutateAsync,
isPending: false,
} as unknown as ReturnType<typeof useExternalKnowledgeBaseHitTesting>)
const useBreakpoints = await import('@/hooks/use-breakpoints')
vi.mocked(useBreakpoints.default).mockReturnValue(
'pc' as unknown as ReturnType<typeof useBreakpoints.default>,
)
})
it('disables record loading and query input without retrieval recall permission', async () => {
const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset')
mockDataset.permission_keys = []
renderWithProviders(<HitTestingPage datasetId="dataset-1" />)
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
expect(vi.mocked(useDatasetTestingRecords)).toHaveBeenCalledWith(
'dataset-1',
{ limit: 10, page: 1 },
{ enabled: false },
)
})
it('shows the records loading state', async () => {
const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetTestingRecords).mockReturnValue({
data: undefined,
refetch: mockRecordsRefetch,
isLoading: true,
} as unknown as ReturnType<typeof useDatasetTestingRecords>)
renderWithProviders(<HitTestingPage datasetId="dataset-1" />)
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('shows both empty history and empty result states', () => {
renderWithProviders(<HitTestingPage datasetId="dataset-1" />)
expect(screen.getByText(/noRecentTip/)).toBeInTheDocument()
expect(screen.getByText(/hit.emptyTip/)).toBeInTheDocument()
})
it('loads a history record into the query input when selected', async () => {
const user = userEvent.setup()
const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetTestingRecords).mockReturnValue({
data: {
data: [
createMockRecord({
queries: [
{ content: 'Record query text', content_type: 'text_query', file_info: null },
],
}),
],
total: 1,
page: 1,
limit: 10,
has_more: false,
},
refetch: mockRecordsRefetch,
isLoading: false,
} as unknown as ReturnType<typeof useDatasetTestingRecords>)
renderWithProviders(<HitTestingPage datasetId="dataset-1" />)
await user.click(screen.getByText('Record query text'))
expect(screen.getByRole('textbox')).toHaveValue('Record query text')
})
it('requests the next records page through pagination', async () => {
const user = userEvent.setup()
const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetTestingRecords).mockReturnValue({
data: {
data: Array.from({ length: 10 }, (_, index) => createMockRecord({ id: `record-${index}` })),
total: 25,
page: 1,
limit: 10,
has_more: true,
},
refetch: mockRecordsRefetch,
isLoading: false,
} as unknown as ReturnType<typeof useDatasetTestingRecords>)
renderWithProviders(<HitTestingPage datasetId="dataset-1" />)
await user.click(screen.getByRole('button', { name: /pagination.next/ }))
expect(vi.mocked(useDatasetTestingRecords)).toHaveBeenLastCalledWith(
'dataset-1',
{ limit: 10, page: 2 },
{ enabled: true },
)
})
it('saves retrieval settings, submits a query, refreshes history, and renders results', async () => {
const user = userEvent.setup()
const response: HitTestingResponse = {
query: { content: 'Test query', tsne_position: { x: 0, y: 0 } },
records: [createMockHitTesting()],
}
mockHitTestingMutateAsync.mockImplementation(async (_params, options) => {
options?.onSuccess?.(response)
return response
})
renderWithProviders(<HitTestingPage datasetId="dataset-1" />)
await user.click(screen.getByText(/semantic_search/))
await user.click(screen.getByRole('button', { name: 'Use Top K 8' }))
await user.click(screen.getByRole('button', { name: /operation.save/ }))
await user.type(screen.getByRole('textbox'), 'Test query')
await user.click(screen.getByRole('button', { name: /input.testing/ }))
await waitFor(() => {
expect(mockHitTestingMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
query: 'Test query',
attachment_ids: [],
retrieval_model: expect.objectContaining({
search_method: RETRIEVE_METHOD.semantic,
top_k: 8,
}),
}),
expect.objectContaining({ onSuccess: expect.any(Function) }),
)
})
expect(mockRecordsRefetch).toHaveBeenCalledTimes(1)
expect(await screen.findByText('test-document.pdf')).toBeInTheDocument()
})
it('submits external retrieval settings and renders external results', async () => {
const user = userEvent.setup()
Object.assign(mockDataset, { provider: 'external' })
const response = {
query: { content: 'External query' },
records: [
{
title: 'External Result',
content: 'External content',
score: 0.9,
metadata: {},
},
],
}
mockExternalHitTestingMutateAsync.mockImplementation(async (_params, options) => {
options?.onSuccess?.(response)
return response
})
renderWithProviders(<HitTestingPage datasetId="dataset-1" />)
await user.click(screen.getByRole('button', { name: /settingTitle/ }))
await user.click(screen.getByRole('button', { name: 'Change Top K' }))
await user.click(screen.getByRole('button', { name: /operation.save/ }))
await user.type(screen.getByRole('textbox'), 'External query')
await user.click(screen.getByRole('button', { name: /input.testing/ }))
await waitFor(() => {
expect(mockExternalHitTestingMutateAsync).toHaveBeenCalledWith(
{
query: 'External query',
external_retrieval_model: {
top_k: 8,
score_threshold: 0.5,
score_threshold_enabled: false,
},
},
expect.objectContaining({ onSuccess: expect.any(Function) }),
)
})
expect(mockHitTestingMutateAsync).not.toHaveBeenCalled()
expect(mockRecordsRefetch).toHaveBeenCalledTimes(1)
expect(await screen.findByText('External content')).toBeInTheDocument()
})
})