mirror of
https://github.com/langgenius/dify.git
synced 2026-04-28 22:48:07 +08:00
merge main
This commit is contained in:
@ -71,7 +71,7 @@ const DatasetInfo: FC<DatasetInfoProps> = ({
|
||||
</div>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">
|
||||
{isExternalProvider && t('externalTag', { ns: 'dataset' })}
|
||||
{!isExternalProvider && isPipelinePublished && dataset.doc_form && dataset.indexing_technique && (
|
||||
{!!(!isExternalProvider && isPipelinePublished && dataset.doc_form && dataset.indexing_technique) && (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span>{t(`chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`, { ns: 'dataset' })}</span>
|
||||
<span>{formatIndexingTechniqueAndMethod(dataset.indexing_technique, dataset.retrieval_model_dict?.search_method)}</span>
|
||||
|
||||
@ -114,7 +114,7 @@ const DatasetSidebarDropdown = ({
|
||||
</div>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">
|
||||
{isExternalProvider && t('externalTag', { ns: 'dataset' })}
|
||||
{!isExternalProvider && dataset.doc_form && dataset.indexing_technique && (
|
||||
{!!(!isExternalProvider && dataset.doc_form && dataset.indexing_technique) && (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span>{t(`chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`, { ns: 'dataset' })}</span>
|
||||
<span>{formatIndexingTechniqueAndMethod(dataset.indexing_technique, dataset.retrieval_model_dict?.search_method)}</span>
|
||||
|
||||
@ -144,7 +144,7 @@ const EditAnnotationModal: FC<Props> = ({
|
||||
<MessageCheckRemove />
|
||||
<div>{t('editModal.removeThisCache', { ns: 'appAnnotation' })}</div>
|
||||
</div>
|
||||
{createdAt && (
|
||||
{!!createdAt && (
|
||||
<div>
|
||||
{t('editModal.createdAt', { ns: 'appAnnotation' })}
|
||||
|
||||
|
||||
@ -203,7 +203,7 @@ const Annotation: FC<Props> = (props) => {
|
||||
</Filter>
|
||||
{isLoading
|
||||
? <Loading type="app" />
|
||||
// eslint-disable-next-line sonarjs/no-nested-conditional
|
||||
|
||||
: total > 0
|
||||
? (
|
||||
<List
|
||||
|
||||
@ -28,16 +28,16 @@ const FeaturePanel: FC<IFeaturePanelProps> = ({
|
||||
<div className={cn('px-3 pt-2', hasHeaderBottomBorder && 'border-b border-divider-subtle')} data-testid="feature-panel-header">
|
||||
<div className="flex h-8 items-center justify-between">
|
||||
<div className="flex shrink-0 items-center space-x-1">
|
||||
{headerIcon && <div className="flex h-6 w-6 items-center justify-center">{headerIcon}</div>}
|
||||
{!!headerIcon && <div className="flex h-6 w-6 items-center justify-center">{headerIcon}</div>}
|
||||
<div className="system-sm-semibold text-text-secondary">{title}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{headerRight && <div>{headerRight}</div>}
|
||||
{!!headerRight && <div>{headerRight}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Body */}
|
||||
{children && (
|
||||
{!!children && (
|
||||
<div className={cn(!noBodySpacing && 'mt-1 px-3')} data-testid="feature-panel-body">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@ -134,7 +134,6 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
|
||||
},
|
||||
] as const
|
||||
|
||||
// eslint-disable-next-line sonarjs/no-nested-template-literals, sonarjs/no-nested-conditional
|
||||
const [instructionFromSessionStorage, setInstruction] = useSessionStorageState<string>(`improve-instruction-${flowId}${isBasicMode ? '' : `-${nodeId}${editorId ? `-${editorId}` : ''}`}`)
|
||||
const instruction = instructionFromSessionStorage || ''
|
||||
const [ideaOutput, setIdeaOutput] = useState<string>('')
|
||||
|
||||
@ -91,7 +91,7 @@ const Item: FC<ItemProps> = ({
|
||||
</ActionButton>
|
||||
</div>
|
||||
{
|
||||
config.indexing_technique && (
|
||||
!!config.indexing_technique && (
|
||||
<Badge
|
||||
className="shrink-0 group-hover:hidden"
|
||||
text={formatIndexingTechniqueAndMethod(config.indexing_technique, config.retrieval_model_dict?.search_method)}
|
||||
|
||||
@ -175,7 +175,7 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({
|
||||
</div>
|
||||
)}
|
||||
{
|
||||
item.indexing_technique && (
|
||||
!!item.indexing_technique && (
|
||||
<Badge
|
||||
className="shrink-0"
|
||||
text={formatIndexingTechniqueAndMethod(item.indexing_technique, item.retrieval_model_dict?.search_method)}
|
||||
|
||||
@ -247,7 +247,7 @@ const SettingsModal: FC<SettingsModalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{currentDataset && currentDataset.indexing_technique && (
|
||||
{!!(currentDataset && currentDataset.indexing_technique) && (
|
||||
<div className={cn(rowClass)}>
|
||||
<div className={labelClass}>
|
||||
<div className="system-sm-semibold text-text-secondary">{t('form.indexMethod', { ns: 'datasetSettings' })}</div>
|
||||
|
||||
@ -7,6 +7,7 @@ import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import type { Inputs, ModelConfig } from '@/models/debug'
|
||||
import type { PromptVariable } from '@/types/app'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { DEFAULT_AGENT_SETTING, DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
|
||||
import { AppModeEnum, ModelModeType, Resolution, TransferMethod } from '@/types/app'
|
||||
import { APP_CHAT_WITH_MULTIPLE_MODEL } from '../types'
|
||||
@ -21,9 +22,7 @@ type PromptVariableWithMeta = Omit<PromptVariable, 'type' | 'required'> & {
|
||||
const mockUseDebugConfigurationContext = vi.fn()
|
||||
const mockUseFeaturesSelector = vi.fn()
|
||||
const mockUseEventEmitterContext = vi.fn()
|
||||
const mockUseAppStoreSelector = vi.fn()
|
||||
const mockEventEmitter = { emit: vi.fn() }
|
||||
const mockSetShowAppConfigureFeaturesModal = vi.fn()
|
||||
let capturedChatInputProps: MockChatInputAreaProps | null = null
|
||||
let modelIdCounter = 0
|
||||
let featureState: FeatureStoreState
|
||||
@ -63,10 +62,6 @@ vi.mock('@/context/event-emitter', () => ({
|
||||
useEventEmitterContextContext: () => mockUseEventEmitterContext(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: { setShowAppConfigureFeaturesModal: typeof mockSetShowAppConfigureFeaturesModal }) => unknown) => mockUseAppStoreSelector(selector),
|
||||
}))
|
||||
|
||||
vi.mock('./debug-item', () => ({
|
||||
default: ({
|
||||
modelAndParameter,
|
||||
@ -191,7 +186,6 @@ describe('DebugWithMultipleModel', () => {
|
||||
featureState = createFeatureState()
|
||||
mockUseFeaturesSelector.mockImplementation(selector => selector(featureState))
|
||||
mockUseEventEmitterContext.mockReturnValue({ eventEmitter: mockEventEmitter })
|
||||
mockUseAppStoreSelector.mockImplementation(selector => selector({ setShowAppConfigureFeaturesModal: mockSetShowAppConfigureFeaturesModal }))
|
||||
mockUseDebugConfigurationContext.mockReturnValue(createDebugConfiguration())
|
||||
})
|
||||
|
||||
@ -438,7 +432,7 @@ describe('DebugWithMultipleModel', () => {
|
||||
expect(capturedChatInputProps?.showFileUpload).toBe(false)
|
||||
expect(capturedChatInputProps?.speechToTextConfig).toEqual(featureState.features.speech2text)
|
||||
expect(capturedChatInputProps?.visionConfig).toEqual(featureState.features.file)
|
||||
expect(mockSetShowAppConfigureFeaturesModal).toHaveBeenCalledWith(true)
|
||||
expect(useAppStore.getState().showAppConfigureFeaturesModal).toBe(true)
|
||||
})
|
||||
|
||||
it('should render chat input in agent chat mode', () => {
|
||||
|
||||
@ -7,6 +7,7 @@ import type { ProviderContextState } from '@/context/provider-context'
|
||||
import type { DatasetConfigs, ModelConfig } from '@/models/debug'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { createRef } from 'react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { ConfigurationMethodEnum, ModelFeatureEnum, ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { PromptMode } from '@/models/debug'
|
||||
@ -376,15 +377,7 @@ vi.mock('../hooks', () => ({
|
||||
useFormattingChangedSubscription: mockUseFormattingChangedSubscription,
|
||||
}))
|
||||
|
||||
const mockSetShowAppConfigureFeaturesModal = vi.fn()
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: vi.fn((selector?: (state: { setShowAppConfigureFeaturesModal: typeof mockSetShowAppConfigureFeaturesModal }) => unknown) => {
|
||||
if (typeof selector === 'function')
|
||||
return selector({ setShowAppConfigureFeaturesModal: mockSetShowAppConfigureFeaturesModal })
|
||||
return mockSetShowAppConfigureFeaturesModal
|
||||
}),
|
||||
}))
|
||||
// Use real store - global zustand mock will auto-reset between tests
|
||||
|
||||
// Mock event emitter context
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
@ -472,8 +465,8 @@ vi.mock('@/app/components/base/chat/chat', () => ({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{questionIcon && <div data-testid="question-icon">{questionIcon}</div>}
|
||||
{answerIcon && <div data-testid="answer-icon">{answerIcon}</div>}
|
||||
{!!questionIcon && <div data-testid="question-icon">{questionIcon}</div>}
|
||||
{!!answerIcon && <div data-testid="answer-icon">{answerIcon}</div>}
|
||||
<textarea
|
||||
data-testid="chat-input"
|
||||
placeholder="Type a message"
|
||||
@ -659,7 +652,7 @@ describe('DebugWithSingleModel', () => {
|
||||
|
||||
fireEvent.click(screen.getByTestId('feature-bar-button'))
|
||||
|
||||
expect(mockSetShowAppConfigureFeaturesModal).toHaveBeenCalledWith(true)
|
||||
expect(useAppStore.getState().showAppConfigureFeaturesModal).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -2,14 +2,11 @@ import type { IPromptValuePanelProps } from './index'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useStore } from '@/app/components/app/store'
|
||||
import ConfigContext from '@/context/debug-configuration'
|
||||
import { AppModeEnum, ModelModeType, Resolution } from '@/types/app'
|
||||
import PromptValuePanel from './index'
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: vi.fn(),
|
||||
}))
|
||||
// Use real store - global zustand mock will auto-reset between tests
|
||||
vi.mock('@/app/components/base/features/new-feature-panel/feature-bar', () => ({
|
||||
default: ({ onFeatureBarClick }: { onFeatureBarClick: () => void }) => (
|
||||
<button type="button" onClick={onFeatureBarClick}>
|
||||
@ -18,8 +15,6 @@ vi.mock('@/app/components/base/features/new-feature-panel/feature-bar', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
const mockSetShowAppConfigureFeaturesModal = vi.fn()
|
||||
const mockUseStore = vi.mocked(useStore)
|
||||
const mockSetInputs = vi.fn()
|
||||
const mockOnSend = vi.fn()
|
||||
|
||||
@ -69,20 +64,9 @@ const renderPanel = (options: {
|
||||
|
||||
describe('PromptValuePanel', () => {
|
||||
beforeEach(() => {
|
||||
mockUseStore.mockImplementation(selector => selector({
|
||||
setShowAppConfigureFeaturesModal: mockSetShowAppConfigureFeaturesModal,
|
||||
appSidebarExpand: '',
|
||||
currentLogModalActiveTab: 'prompt',
|
||||
showPromptLogModal: false,
|
||||
showAgentLogModal: false,
|
||||
setShowPromptLogModal: vi.fn(),
|
||||
setShowAgentLogModal: vi.fn(),
|
||||
showMessageLogModal: false,
|
||||
showAppConfigureFeaturesModal: false,
|
||||
} as any))
|
||||
vi.clearAllMocks()
|
||||
mockSetInputs.mockClear()
|
||||
mockOnSend.mockClear()
|
||||
mockSetShowAppConfigureFeaturesModal.mockClear()
|
||||
})
|
||||
|
||||
it('updates inputs, clears values, and triggers run when ready', async () => {
|
||||
|
||||
@ -175,7 +175,7 @@ describe('SettingsModal', () => {
|
||||
renderSettingsModal()
|
||||
fireEvent.click(screen.getByText('appOverview.overview.appInfo.settings.more.entry'))
|
||||
const privacyInput = screen.getByPlaceholderText('appOverview.overview.appInfo.settings.more.privacyPolicyPlaceholder')
|
||||
// eslint-disable-next-line sonarjs/no-clear-text-protocols
|
||||
|
||||
fireEvent.change(privacyInput, { target: { value: 'ftp://invalid-url' } })
|
||||
|
||||
fireEvent.click(screen.getByText('common.operation.save'))
|
||||
|
||||
@ -2,6 +2,7 @@ import type { App } from '@/types/app'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
@ -17,10 +18,7 @@ vi.mock('next/navigation', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockSetAppDetail = vi.fn()
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: any) => unknown) => selector({ setAppDetail: mockSetAppDetail }),
|
||||
}))
|
||||
// Use real store - global zustand mock will auto-reset between tests
|
||||
|
||||
const mockSwitchApp = vi.fn()
|
||||
const mockDeleteApp = vi.fn()
|
||||
@ -137,9 +135,17 @@ const renderComponent = (overrides: Partial<React.ComponentProps<typeof SwitchAp
|
||||
}
|
||||
}
|
||||
|
||||
const setAppDetailSpy = vi.fn()
|
||||
|
||||
describe('SwitchAppModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Spy on setAppDetail
|
||||
const originalSetAppDetail = useAppStore.getState().setAppDetail
|
||||
setAppDetailSpy.mockImplementation((...args: Parameters<typeof originalSetAppDetail>) => {
|
||||
originalSetAppDetail(...args)
|
||||
})
|
||||
useAppStore.setState({ setAppDetail: setAppDetailSpy as typeof originalSetAppDetail })
|
||||
mockIsEditor = true
|
||||
mockEnableBilling = false
|
||||
mockPlan = {
|
||||
@ -275,7 +281,7 @@ describe('SwitchAppModal', () => {
|
||||
})
|
||||
expect(mockReplace).toHaveBeenCalledWith('/app/new-app-002/workflow')
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
expect(mockSetAppDetail).toHaveBeenCalledTimes(1)
|
||||
expect(setAppDetailSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should notify error when switch app fails', async () => {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
// Import after mocks
|
||||
@ -123,18 +124,7 @@ vi.mock('@/service/use-apps', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock tag store
|
||||
vi.mock('@/app/components/base/tag-management/store', () => ({
|
||||
useStore: (selector: (state: { tagList: any[], setTagList: any, showTagManagementModal: boolean, setShowTagManagementModal: any }) => any) => {
|
||||
const state = {
|
||||
tagList: [{ id: 'tag-1', name: 'Test Tag', type: 'app' }],
|
||||
setTagList: vi.fn(),
|
||||
showTagManagementModal: false,
|
||||
setShowTagManagementModal: vi.fn(),
|
||||
}
|
||||
return selector(state)
|
||||
},
|
||||
}))
|
||||
// Use real tag store - global zustand mock will auto-reset between tests
|
||||
|
||||
// Mock tag service to avoid API calls in TagFilter
|
||||
vi.mock('@/service/tag', () => ({
|
||||
@ -247,6 +237,11 @@ beforeAll(() => {
|
||||
describe('List', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Set up tag store state
|
||||
useTagStore.setState({
|
||||
tagList: [{ id: 'tag-1', name: 'Test Tag', type: 'app', binding_count: 0 }],
|
||||
showTagManagementModal: false,
|
||||
})
|
||||
mockIsCurrentWorkspaceEditor.mockReturnValue(true)
|
||||
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(false)
|
||||
mockDragging = false
|
||||
|
||||
@ -72,7 +72,7 @@ const ToolCallItem: FC<Props> = ({ toolCall, isLLM = false, isFinal, tokens, obs
|
||||
{toolName}
|
||||
</div>
|
||||
<div className="shrink-0 text-xs leading-[18px] text-text-tertiary">
|
||||
{toolCall.time_cost && (
|
||||
{!!toolCall.time_cost && (
|
||||
<span>{getTime(toolCall.time_cost || 0)}</span>
|
||||
)}
|
||||
{isLLM && (
|
||||
|
||||
@ -212,7 +212,7 @@ const Answer: FC<AnswerProps> = ({
|
||||
)
|
||||
}
|
||||
{
|
||||
item.siblingCount && item.siblingCount > 1 && item.siblingIndex !== undefined && (
|
||||
!!(item.siblingCount && item.siblingCount > 1 && item.siblingIndex !== undefined) && (
|
||||
<ContentSwitch
|
||||
count={item.siblingCount}
|
||||
currentIndex={item.siblingIndex}
|
||||
|
||||
@ -29,7 +29,7 @@ const More: FC<MoreProps> = ({
|
||||
>
|
||||
{`${t('detail.tokenCost', { ns: 'appLog' })} ${formatNumber(more.tokens)}`}
|
||||
</div>
|
||||
{more.tokens_per_second && (
|
||||
{!!more.tokens_per_second && (
|
||||
<div
|
||||
className="mr-2 max-w-[25%] shrink-0 truncate"
|
||||
title={`${more.tokens_per_second} tokens/s`}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { FC } from 'react'
|
||||
import type { FC, MouseEvent } from 'react'
|
||||
import type { Resources } from './index'
|
||||
import Link from 'next/link'
|
||||
import { Fragment, useState } from 'react'
|
||||
@ -18,6 +18,8 @@ import {
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { useDocumentDownload } from '@/service/knowledge/use-document'
|
||||
import { downloadUrl } from '@/utils/download'
|
||||
import ProgressTooltip from './progress-tooltip'
|
||||
import Tooltip from './tooltip'
|
||||
|
||||
@ -36,6 +38,30 @@ const Popup: FC<PopupProps> = ({
|
||||
? (/\.([^.]*)$/.exec(data.documentName)?.[1] || '')
|
||||
: 'notion'
|
||||
|
||||
const { mutateAsync: downloadDocument, isPending: isDownloading } = useDocumentDownload()
|
||||
|
||||
/**
|
||||
* Download the original uploaded file for citations whose data source is upload-file.
|
||||
* We request a signed URL from the dataset document download endpoint, then trigger browser download.
|
||||
*/
|
||||
const handleDownloadUploadFile = async (e: MouseEvent<HTMLElement>) => {
|
||||
// Prevent toggling the citation popup when user clicks the download link.
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
// Only upload-file citations can be downloaded this way (needs dataset/document ids).
|
||||
const isUploadFile = data.dataSourceType === 'upload_file' || data.dataSourceType === 'file'
|
||||
const datasetId = data.sources?.[0]?.dataset_id
|
||||
const documentId = data.documentId || data.sources?.[0]?.document_id
|
||||
if (!isUploadFile || !datasetId || !documentId || isDownloading)
|
||||
return
|
||||
|
||||
// Fetch signed URL (usually points to `/files/<id>/file-preview?...&as_attachment=true`).
|
||||
const res = await downloadDocument({ datasetId, documentId })
|
||||
if (res?.url)
|
||||
downloadUrl({ url: res.url, fileName: data.documentName })
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
@ -49,6 +75,7 @@ const Popup: FC<PopupProps> = ({
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
||||
<div className="flex h-7 max-w-[240px] items-center rounded-lg bg-components-button-secondary-bg px-2">
|
||||
<FileIcon type={fileType} className="mr-1 h-4 w-4 shrink-0" />
|
||||
{/* Keep the trigger purely for opening the popup (no download link here). */}
|
||||
<div className="truncate text-xs text-text-tertiary">{data.documentName}</div>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
@ -57,7 +84,21 @@ const Popup: FC<PopupProps> = ({
|
||||
<div className="px-4 pb-2 pt-3">
|
||||
<div className="flex h-[18px] items-center">
|
||||
<FileIcon type={fileType} className="mr-1 h-4 w-4 shrink-0" />
|
||||
<div className="system-xs-medium truncate text-text-tertiary">{data.documentName}</div>
|
||||
<div className="system-xs-medium truncate text-text-tertiary">
|
||||
{/* If it's an upload-file reference, the title becomes a download link. */}
|
||||
{(data.dataSourceType === 'upload_file' || data.dataSourceType === 'file') && !!data.sources?.[0]?.dataset_id
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer truncate text-text-tertiary hover:underline"
|
||||
onClick={handleDownloadUploadFile}
|
||||
disabled={isDownloading}
|
||||
>
|
||||
{data.documentName}
|
||||
</button>
|
||||
)
|
||||
: data.documentName}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[450px] overflow-y-auto rounded-lg bg-components-panel-bg px-4 py-0.5">
|
||||
@ -105,7 +146,7 @@ const Popup: FC<PopupProps> = ({
|
||||
icon={<BezierCurve03 className="mr-1 h-3 w-3" />}
|
||||
/>
|
||||
{
|
||||
source.score && (
|
||||
!!source.score && (
|
||||
<ProgressTooltip data={Number(source.score.toFixed(2))} />
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import type { UnsafeUnwrappedHeaders } from 'next/headers'
|
||||
import type { FC } from 'react'
|
||||
import { headers } from 'next/headers'
|
||||
import Script from 'next/script'
|
||||
@ -26,14 +25,14 @@ const extractNonceFromCSP = (cspHeader: string | null): string | undefined => {
|
||||
return nonceMatch ? nonceMatch[1] : undefined
|
||||
}
|
||||
|
||||
const GA: FC<IGAProps> = ({
|
||||
const GA: FC<IGAProps> = async ({
|
||||
gaType,
|
||||
}) => {
|
||||
if (IS_CE_EDITION)
|
||||
return null
|
||||
|
||||
const cspHeader = IS_PROD
|
||||
? (headers() as unknown as UnsafeUnwrappedHeaders).get('content-security-policy')
|
||||
? (await headers()).get('content-security-policy')
|
||||
: null
|
||||
const nonce = extractNonceFromCSP(cspHeader)
|
||||
|
||||
|
||||
@ -90,7 +90,7 @@ const UploaderButton: FC<UploaderButtonProps> = ({
|
||||
<PortalToFollowElemContent className="z-50">
|
||||
<div className="w-[260px] rounded-lg border-[0.5px] border-gray-200 bg-white p-2 shadow-lg">
|
||||
<ImageLinkInput onUpload={handleUpload} disabled={disabled} />
|
||||
{hasUploadFromLocal && (
|
||||
{!!hasUploadFromLocal && (
|
||||
<>
|
||||
<div className="mt-2 flex items-center px-2 text-xs font-medium text-gray-400">
|
||||
<div className="mr-3 h-px w-[93px] bg-gradient-to-l from-[#F3F4F6]" />
|
||||
|
||||
@ -109,7 +109,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
/>
|
||||
{showClearIcon && value && !disabled && !destructive && (
|
||||
{!!(showClearIcon && value && !disabled && !destructive) && (
|
||||
<div
|
||||
className={cn('group absolute right-2 top-1/2 -translate-y-1/2 cursor-pointer p-[1px]')}
|
||||
onClick={onClear}
|
||||
|
||||
@ -205,7 +205,7 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
|
||||
}
|
||||
catch {
|
||||
try {
|
||||
// eslint-disable-next-line no-new-func, sonarjs/code-eval
|
||||
// eslint-disable-next-line no-new-func
|
||||
const result = new Function(`return ${trimmedContent}`)()
|
||||
if (typeof result === 'object' && result !== null) {
|
||||
setFinalChartOption(result)
|
||||
@ -250,7 +250,7 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
|
||||
}
|
||||
catch {
|
||||
try {
|
||||
// eslint-disable-next-line no-new-func, sonarjs/code-eval
|
||||
// eslint-disable-next-line no-new-func
|
||||
const result = new Function(`return ${trimmedContent}`)()
|
||||
if (typeof result === 'object' && result !== null) {
|
||||
setFinalChartOption(result)
|
||||
|
||||
@ -52,7 +52,7 @@ export default function Modal({
|
||||
<div className={cn('flex min-h-full items-center justify-center p-4 text-center', containerClassName)}>
|
||||
<TransitionChild>
|
||||
<DialogPanel className={cn('relative w-full max-w-[480px] rounded-2xl bg-components-panel-bg p-6 text-left align-middle shadow-xl transition-all', overflowVisible ? 'overflow-visible' : 'overflow-hidden', 'duration-100 ease-in data-[closed]:scale-95 data-[closed]:opacity-0', 'data-[enter]:scale-100 data-[enter]:opacity-100', 'data-[enter]:scale-95 data-[leave]:opacity-0', className)}>
|
||||
{title && (
|
||||
{!!title && (
|
||||
<DialogTitle
|
||||
as="h3"
|
||||
className="title-2xl-semi-bold text-text-primary"
|
||||
@ -60,7 +60,7 @@ export default function Modal({
|
||||
{title}
|
||||
</DialogTitle>
|
||||
)}
|
||||
{description && (
|
||||
{!!description && (
|
||||
<div className="body-md-regular mt-2 text-text-secondary">
|
||||
{description}
|
||||
</div>
|
||||
|
||||
@ -86,7 +86,7 @@ const Modal = ({
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
children && (
|
||||
!!children && (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-3">{children}</div>
|
||||
)
|
||||
}
|
||||
@ -125,7 +125,7 @@ const Modal = ({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{bottomSlot && (
|
||||
{!!bottomSlot && (
|
||||
<div className="shrink-0">
|
||||
{bottomSlot}
|
||||
</div>
|
||||
|
||||
@ -56,7 +56,7 @@ const RadioCard: FC<Props> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{((isChosen && chosenConfig) || noRadio) && (
|
||||
{!!((isChosen && chosenConfig) || noRadio) && (
|
||||
<div className="mt-2 flex gap-x-2">
|
||||
<div className="size-8 shrink-0"></div>
|
||||
<div className={cn(chosenConfigWrapClassName, 'grow')}>
|
||||
|
||||
@ -52,7 +52,7 @@ export default function Radio({
|
||||
)}
|
||||
onClick={() => handleChange(value)}
|
||||
>
|
||||
{children && (
|
||||
{!!children && (
|
||||
<label
|
||||
className={
|
||||
cn(labelClassName, 'cursor-pointer text-sm')
|
||||
|
||||
@ -130,7 +130,7 @@ export const SegmentedControl = <T extends string | number | symbol>({
|
||||
{text && (
|
||||
<div className={cn('inline-flex items-center gap-x-1', ItemTextWrapperVariants({ size }))}>
|
||||
<span>{text}</span>
|
||||
{count && size === 'large' && (
|
||||
{!!(count && size === 'large') && (
|
||||
<div className="system-2xs-medium-uppercase inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] text-text-tertiary">
|
||||
{count}
|
||||
</div>
|
||||
|
||||
@ -379,7 +379,7 @@ const PortalSelect: FC<PortalSelectProps> = ({
|
||||
{selectedItem?.name ?? localPlaceholder}
|
||||
</span>
|
||||
<div className="mx-0.5">
|
||||
{installedValue && selectedItem && selectedItem.value !== installedValue && (
|
||||
{!!(installedValue && selectedItem && selectedItem.value !== installedValue) && (
|
||||
<Badge>
|
||||
{installedValue}
|
||||
{' '}
|
||||
|
||||
@ -80,7 +80,7 @@ const Toast = ({
|
||||
<div className="system-sm-semibold text-text-primary [word-break:break-word]">{message}</div>
|
||||
{customComponent}
|
||||
</div>
|
||||
{children && (
|
||||
{!!children && (
|
||||
<div className="system-xs-regular text-text-secondary">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@ -12,11 +12,11 @@ export const ToolTipContent: FC<ToolTipContentProps> = ({
|
||||
}) => {
|
||||
return (
|
||||
<div className="w-[180px]">
|
||||
{title && (
|
||||
{!!title && (
|
||||
<div className="mb-1.5 font-semibold text-text-secondary">{title}</div>
|
||||
)}
|
||||
<div className="mb-1.5 text-text-tertiary">{children}</div>
|
||||
{action && <div className="cursor-pointer text-text-accent">{action}</div>}
|
||||
{!!action && <div className="cursor-pointer text-text-accent">{action}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -109,7 +109,7 @@ const Tooltip: FC<TooltipProps> = ({
|
||||
<PortalToFollowElemContent
|
||||
className={cn('z-[9999]', portalContentClassName || '')}
|
||||
>
|
||||
{popupContent && (
|
||||
{!!popupContent && (
|
||||
<div
|
||||
className={cn(
|
||||
!noDecoration && 'system-xs-regular relative max-w-[300px] break-words rounded-md bg-components-panel-bg px-3 py-2 text-left text-text-tertiary shadow-lg',
|
||||
|
||||
@ -0,0 +1,101 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import EconomicalRetrievalMethodConfig from './index'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../settings/option-card', () => ({
|
||||
default: ({ children, title, description, disabled, id }: {
|
||||
children?: React.ReactNode
|
||||
title?: string
|
||||
description?: React.ReactNode
|
||||
disabled?: boolean
|
||||
id?: string
|
||||
}) => (
|
||||
<div data-testid="option-card" data-title={title} data-id={id} data-disabled={disabled}>
|
||||
<div>{description}</div>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../retrieval-param-config', () => ({
|
||||
default: ({ value, onChange, type }: {
|
||||
value: Record<string, unknown>
|
||||
onChange: (value: Record<string, unknown>) => void
|
||||
type?: string
|
||||
}) => (
|
||||
<div data-testid="retrieval-param-config" data-type={type}>
|
||||
<button onClick={() => onChange({ ...value, newProp: 'changed' })}>
|
||||
Change Value
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/icons/src/vender/knowledge', () => ({
|
||||
VectorSearch: () => <svg data-testid="vector-search-icon" />,
|
||||
}))
|
||||
|
||||
describe('EconomicalRetrievalMethodConfig', () => {
|
||||
const mockOnChange = vi.fn()
|
||||
const defaultProps = {
|
||||
value: {
|
||||
search_method: RETRIEVE_METHOD.keywordSearch,
|
||||
reranking_enable: false,
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
top_k: 2,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0.5,
|
||||
},
|
||||
onChange: mockOnChange,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render correctly', () => {
|
||||
render(<EconomicalRetrievalMethodConfig {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('option-card')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('retrieval-param-config')).toBeInTheDocument()
|
||||
// Check if title and description are rendered (mocked i18n returns key)
|
||||
expect(screen.getByText('dataset.retrieval.keyword_search.description')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass correct props to OptionCard', () => {
|
||||
render(<EconomicalRetrievalMethodConfig {...defaultProps} disabled={true} />)
|
||||
|
||||
const card = screen.getByTestId('option-card')
|
||||
expect(card).toHaveAttribute('data-disabled', 'true')
|
||||
expect(card).toHaveAttribute('data-id', RETRIEVE_METHOD.keywordSearch)
|
||||
})
|
||||
|
||||
it('should pass correct props to RetrievalParamConfig', () => {
|
||||
render(<EconomicalRetrievalMethodConfig {...defaultProps} />)
|
||||
|
||||
const config = screen.getByTestId('retrieval-param-config')
|
||||
expect(config).toHaveAttribute('data-type', RETRIEVE_METHOD.keywordSearch)
|
||||
})
|
||||
|
||||
it('should handle onChange events', () => {
|
||||
render(<EconomicalRetrievalMethodConfig {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Change Value'))
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledTimes(1)
|
||||
expect(mockOnChange).toHaveBeenCalledWith({
|
||||
...defaultProps.value,
|
||||
newProp: 'changed',
|
||||
})
|
||||
})
|
||||
|
||||
it('should default disabled prop to false', () => {
|
||||
render(<EconomicalRetrievalMethodConfig {...defaultProps} />)
|
||||
const card = screen.getByTestId('option-card')
|
||||
expect(card).toHaveAttribute('data-disabled', 'false')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,148 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import { retrievalIcon } from '../../create/icons'
|
||||
import RetrievalMethodInfo, { getIcon } from './index'
|
||||
|
||||
// Mock next/image
|
||||
vi.mock('next/image', () => ({
|
||||
default: ({ src, alt, className }: { src: string, alt: string, className?: string }) => (
|
||||
<img src={src} alt={alt || ''} className={className} data-testid="method-icon" />
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock RadioCard
|
||||
vi.mock('@/app/components/base/radio-card', () => ({
|
||||
default: ({ title, description, chosenConfig, icon }: { title: string, description: string, chosenConfig: ReactNode, icon: ReactNode }) => (
|
||||
<div data-testid="radio-card">
|
||||
<div data-testid="card-title">{title}</div>
|
||||
<div data-testid="card-description">{description}</div>
|
||||
<div data-testid="card-icon">{icon}</div>
|
||||
<div data-testid="chosen-config">{chosenConfig}</div>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock icons
|
||||
vi.mock('../../create/icons', () => ({
|
||||
retrievalIcon: {
|
||||
vector: 'vector-icon.png',
|
||||
fullText: 'fulltext-icon.png',
|
||||
hybrid: 'hybrid-icon.png',
|
||||
},
|
||||
}))
|
||||
|
||||
describe('RetrievalMethodInfo', () => {
|
||||
const defaultConfig = {
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: false,
|
||||
reranking_model: {
|
||||
reranking_provider_name: 'test-provider',
|
||||
reranking_model_name: 'test-model',
|
||||
},
|
||||
top_k: 5,
|
||||
score_threshold_enabled: true,
|
||||
score_threshold: 0.8,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render correctly with full config', () => {
|
||||
render(<RetrievalMethodInfo value={defaultConfig} />)
|
||||
|
||||
expect(screen.getByTestId('radio-card')).toBeInTheDocument()
|
||||
|
||||
// Check Title & Description (mocked i18n returns key prefixed with ns)
|
||||
expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.semantic_search.title')
|
||||
expect(screen.getByTestId('card-description')).toHaveTextContent('dataset.retrieval.semantic_search.description')
|
||||
|
||||
// Check Icon
|
||||
const icon = screen.getByTestId('method-icon')
|
||||
expect(icon).toHaveAttribute('src', 'vector-icon.png')
|
||||
|
||||
// Check Config Details
|
||||
expect(screen.getByText('test-model')).toBeInTheDocument() // Rerank model
|
||||
expect(screen.getByText('5')).toBeInTheDocument() // Top K
|
||||
expect(screen.getByText('0.8')).toBeInTheDocument() // Score threshold
|
||||
})
|
||||
|
||||
it('should not render reranking model if missing', () => {
|
||||
const configWithoutRerank = {
|
||||
...defaultConfig,
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
}
|
||||
|
||||
render(<RetrievalMethodInfo value={configWithoutRerank} />)
|
||||
|
||||
expect(screen.queryByText('test-model')).not.toBeInTheDocument()
|
||||
// Other fields should still be there
|
||||
expect(screen.getByText('5')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle different retrieval methods', () => {
|
||||
// Test Hybrid
|
||||
const hybridConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.hybrid }
|
||||
const { unmount } = render(<RetrievalMethodInfo value={hybridConfig} />)
|
||||
|
||||
expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.hybrid_search.title')
|
||||
expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'hybrid-icon.png')
|
||||
|
||||
unmount()
|
||||
|
||||
// Test FullText
|
||||
const fullTextConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.fullText }
|
||||
render(<RetrievalMethodInfo value={fullTextConfig} />)
|
||||
expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.full_text_search.title')
|
||||
expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'fulltext-icon.png')
|
||||
})
|
||||
|
||||
describe('getIcon utility', () => {
|
||||
it('should return correct icon for each type', () => {
|
||||
expect(getIcon(RETRIEVE_METHOD.semantic)).toBe(retrievalIcon.vector)
|
||||
expect(getIcon(RETRIEVE_METHOD.fullText)).toBe(retrievalIcon.fullText)
|
||||
expect(getIcon(RETRIEVE_METHOD.hybrid)).toBe(retrievalIcon.hybrid)
|
||||
expect(getIcon(RETRIEVE_METHOD.invertedIndex)).toBe(retrievalIcon.vector)
|
||||
expect(getIcon(RETRIEVE_METHOD.keywordSearch)).toBe(retrievalIcon.vector)
|
||||
})
|
||||
|
||||
it('should return default vector icon for unknown type', () => {
|
||||
// Test fallback branch when type is not in the mapping
|
||||
const unknownType = 'unknown_method' as RETRIEVE_METHOD
|
||||
expect(getIcon(unknownType)).toBe(retrievalIcon.vector)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not render score threshold if disabled', () => {
|
||||
const configWithoutScoreThreshold = {
|
||||
...defaultConfig,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0,
|
||||
}
|
||||
|
||||
render(<RetrievalMethodInfo value={configWithoutScoreThreshold} />)
|
||||
|
||||
// score_threshold is still rendered but may be undefined
|
||||
expect(screen.queryByText('0.8')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render correctly with invertedIndex search method', () => {
|
||||
const invertedIndexConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.invertedIndex }
|
||||
render(<RetrievalMethodInfo value={invertedIndexConfig} />)
|
||||
|
||||
// invertedIndex uses vector icon
|
||||
expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'vector-icon.png')
|
||||
})
|
||||
|
||||
it('should render correctly with keywordSearch search method', () => {
|
||||
const keywordSearchConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.keywordSearch }
|
||||
render(<RetrievalMethodInfo value={keywordSearchConfig} />)
|
||||
|
||||
// keywordSearch uses vector icon
|
||||
expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'vector-icon.png')
|
||||
})
|
||||
})
|
||||
@ -87,10 +87,10 @@ export const OptionCard: FC<OptionCardProps> = (
|
||||
disabled={disabled}
|
||||
/>
|
||||
{/** Body */}
|
||||
{isActive && (children || actions) && (
|
||||
{!!(isActive && (children || actions)) && (
|
||||
<div className="rounded-b-xl bg-components-panel-bg px-4 py-3">
|
||||
{children}
|
||||
{actions && (
|
||||
{!!actions && (
|
||||
<div className="mt-4 flex gap-2">
|
||||
{actions}
|
||||
</div>
|
||||
|
||||
@ -30,9 +30,10 @@ import { useDatasetDetailContextWithSelector as useDatasetDetailContext } from '
|
||||
import useTimestamp from '@/hooks/use-timestamp'
|
||||
import { ChunkingMode, DataSourceType, DocumentActionType } from '@/models/datasets'
|
||||
import { DatasourceType } from '@/models/pipeline'
|
||||
import { useDocumentArchive, useDocumentBatchRetryIndex, useDocumentDelete, useDocumentDisable, useDocumentEnable, useDocumentSummary } from '@/service/knowledge/use-document'
|
||||
import { useDocumentArchive, useDocumentBatchRetryIndex, useDocumentDelete, useDocumentDisable, useDocumentDownloadZip, useDocumentEnable, useDocumentSummary } from '@/service/knowledge/use-document'
|
||||
import { asyncRunSafe } from '@/utils'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { downloadBlob } from '@/utils/download'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import BatchAction from '../detail/completed/common/batch-action'
|
||||
import SummaryStatus from '../detail/completed/common/summary-status'
|
||||
@ -224,6 +225,7 @@ const DocumentList: FC<IDocumentListProps> = ({
|
||||
const { mutateAsync: disableDocument } = useDocumentDisable()
|
||||
const { mutateAsync: deleteDocument } = useDocumentDelete()
|
||||
const { mutateAsync: retryIndexDocument } = useDocumentBatchRetryIndex()
|
||||
const { mutateAsync: requestDocumentsZip, isPending: isDownloadingZip } = useDocumentDownloadZip()
|
||||
|
||||
const handleAction = (actionName: DocumentActionType) => {
|
||||
return async () => {
|
||||
@ -305,6 +307,39 @@ const DocumentList: FC<IDocumentListProps> = ({
|
||||
return dataSourceType === DatasourceType.onlineDrive
|
||||
}, [])
|
||||
|
||||
const downloadableSelectedIds = useMemo(() => {
|
||||
const selectedSet = new Set(selectedIds)
|
||||
return localDocs
|
||||
.filter(doc => selectedSet.has(doc.id) && doc.data_source_type === DataSourceType.FILE)
|
||||
.map(doc => doc.id)
|
||||
}, [localDocs, selectedIds])
|
||||
|
||||
/**
|
||||
* Generate a random ZIP filename for bulk document downloads.
|
||||
* We intentionally avoid leaking dataset info in the exported archive name.
|
||||
*/
|
||||
const generateDocsZipFileName = useCallback((): string => {
|
||||
// Prefer UUID for uniqueness; fall back to time+random when unavailable.
|
||||
const randomPart = (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
|
||||
return `${randomPart}-docs.zip`
|
||||
}, [])
|
||||
|
||||
const handleBatchDownload = useCallback(async () => {
|
||||
if (isDownloadingZip)
|
||||
return
|
||||
|
||||
// Download as a single ZIP to avoid browser caps on multiple automatic downloads.
|
||||
const [e, blob] = await asyncRunSafe(requestDocumentsZip({ datasetId, documentIds: downloadableSelectedIds }))
|
||||
if (e || !blob) {
|
||||
Toast.notify({ type: 'error', message: t('actionMsg.downloadUnsuccessfully', { ns: 'common' }) })
|
||||
return
|
||||
}
|
||||
|
||||
downloadBlob({ data: blob, fileName: generateDocsZipFileName() })
|
||||
}, [datasetId, downloadableSelectedIds, generateDocsZipFileName, isDownloadingZip, requestDocumentsZip, t])
|
||||
|
||||
return (
|
||||
<div className="relative mt-3 flex h-full w-full flex-col">
|
||||
<div className="relative h-0 grow overflow-x-auto">
|
||||
@ -476,6 +511,7 @@ const DocumentList: FC<IDocumentListProps> = ({
|
||||
onBatchSummary={handleAction(DocumentActionType.summary)}
|
||||
onBatchEnable={handleAction(DocumentActionType.enable)}
|
||||
onBatchDisable={handleAction(DocumentActionType.disable)}
|
||||
onBatchDownload={downloadableSelectedIds.length > 0 ? handleBatchDownload : undefined}
|
||||
onBatchDelete={handleAction(DocumentActionType.delete)}
|
||||
onEditMetadata={showEditModal}
|
||||
onBatchReIndex={hasErrorDocumentsSelected ? handleBatchReIndex : undefined}
|
||||
@ -485,7 +521,7 @@ const DocumentList: FC<IDocumentListProps> = ({
|
||||
/>
|
||||
)}
|
||||
{/* Show Pagination only if the total is more than the limit */}
|
||||
{pagination.total && (
|
||||
{!!pagination.total && (
|
||||
<Pagination
|
||||
{...pagination}
|
||||
className="w-full shrink-0"
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import type { OperationName } from '../types'
|
||||
import type { CommonResponse } from '@/models/common'
|
||||
import type { DocumentDownloadResponse } from '@/service/datasets'
|
||||
import {
|
||||
RiArchive2Line,
|
||||
RiDeleteBinLine,
|
||||
RiDownload2Line,
|
||||
RiEditLine,
|
||||
RiEqualizer2Line,
|
||||
RiLoopLeftLine,
|
||||
@ -29,6 +31,7 @@ import {
|
||||
useDocumentArchive,
|
||||
useDocumentDelete,
|
||||
useDocumentDisable,
|
||||
useDocumentDownload,
|
||||
useDocumentEnable,
|
||||
useDocumentPause,
|
||||
useDocumentResume,
|
||||
@ -39,6 +42,7 @@ import {
|
||||
} from '@/service/knowledge/use-document'
|
||||
import { asyncRunSafe } from '@/utils'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { downloadUrl } from '@/utils/download'
|
||||
import s from '../style.module.css'
|
||||
import RenameModal from './rename-modal'
|
||||
|
||||
@ -71,7 +75,7 @@ const Operations = ({
|
||||
scene = 'list',
|
||||
className = '',
|
||||
}: OperationsProps) => {
|
||||
const { id, enabled = false, archived = false, data_source_type, display_status } = detail || {}
|
||||
const { id, name, enabled = false, archived = false, data_source_type, display_status } = detail || {}
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const { notify } = useContext(ToastContext)
|
||||
@ -82,6 +86,7 @@ const Operations = ({
|
||||
const { mutateAsync: enableDocument } = useDocumentEnable()
|
||||
const { mutateAsync: disableDocument } = useDocumentDisable()
|
||||
const { mutateAsync: deleteDocument } = useDocumentDelete()
|
||||
const { mutateAsync: downloadDocument, isPending: isDownloading } = useDocumentDownload()
|
||||
const { mutateAsync: syncDocument } = useSyncDocument()
|
||||
const { mutateAsync: syncWebsite } = useSyncWebsite()
|
||||
const { mutateAsync: generateSummary } = useDocumentSummary()
|
||||
@ -164,6 +169,24 @@ const Operations = ({
|
||||
onUpdate()
|
||||
}, [onUpdate])
|
||||
|
||||
const handleDownload = useCallback(async () => {
|
||||
// Avoid repeated clicks while the signed URL request is in-flight.
|
||||
if (isDownloading)
|
||||
return
|
||||
|
||||
// Request a signed URL first (it points to `/files/<id>/file-preview?...&as_attachment=true`).
|
||||
const [e, res] = await asyncRunSafe<DocumentDownloadResponse>(
|
||||
downloadDocument({ datasetId, documentId: id }) as Promise<DocumentDownloadResponse>,
|
||||
)
|
||||
if (e || !res?.url) {
|
||||
notify({ type: 'error', message: t('actionMsg.downloadUnsuccessfully', { ns: 'common' }) })
|
||||
return
|
||||
}
|
||||
|
||||
// Trigger download without navigating away (helps avoid duplicate downloads in some browsers).
|
||||
downloadUrl({ url: res.url, fileName: name })
|
||||
}, [datasetId, downloadDocument, id, isDownloading, name, notify, t])
|
||||
|
||||
return (
|
||||
<div className="flex items-center" onClick={e => e.stopPropagation()}>
|
||||
{isListScene && !embeddingAvailable && (
|
||||
@ -220,6 +243,20 @@ const Operations = ({
|
||||
<RiEditLine className="h-4 w-4 text-text-tertiary" />
|
||||
<span className={s.actionName}>{t('list.table.rename', { ns: 'datasetDocuments' })}</span>
|
||||
</div>
|
||||
{data_source_type === DataSourceType.FILE && (
|
||||
<div
|
||||
className={s.actionItem}
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
evt.nativeEvent.stopImmediatePropagation?.()
|
||||
handleDownload()
|
||||
}}
|
||||
>
|
||||
<RiDownload2Line className="h-4 w-4 text-text-tertiary" />
|
||||
<span className={s.actionName}>{t('list.action.download', { ns: 'datasetDocuments' })}</span>
|
||||
</div>
|
||||
)}
|
||||
{['notion_import', DataSourceType.WEB].includes(data_source_type) && (
|
||||
<div className={s.actionItem} onClick={() => onOperate('sync')}>
|
||||
<RiLoopLeftLine className="h-4 w-4 text-text-tertiary" />
|
||||
@ -233,6 +270,23 @@ const Operations = ({
|
||||
<Divider className="my-1" />
|
||||
</>
|
||||
)}
|
||||
{archived && data_source_type === DataSourceType.FILE && (
|
||||
<>
|
||||
<div
|
||||
className={s.actionItem}
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
evt.nativeEvent.stopImmediatePropagation?.()
|
||||
handleDownload()
|
||||
}}
|
||||
>
|
||||
<RiDownload2Line className="h-4 w-4 text-text-tertiary" />
|
||||
<span className={s.actionName}>{t('list.action.download', { ns: 'datasetDocuments' })}</span>
|
||||
</div>
|
||||
<Divider className="my-1" />
|
||||
</>
|
||||
)}
|
||||
{!archived && display_status?.toLowerCase() === 'indexing' && (
|
||||
<div className={s.actionItem} onClick={() => onOperate('pause')}>
|
||||
<RiPauseCircleLine className="h-4 w-4 text-text-tertiary" />
|
||||
|
||||
@ -24,7 +24,7 @@ vi.mock('@/app/components/datasets/documents/detail/metadata', () => ({
|
||||
<div data-testid="field-info" data-label={label}>
|
||||
<span data-testid="field-label">{label}</span>
|
||||
<span data-testid="field-value">{displayedValue}</span>
|
||||
{valueIcon && <span data-testid="field-icon">{valueIcon}</span>}
|
||||
{!!valueIcon && <span data-testid="field-icon">{valueIcon}</span>}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
@ -28,10 +28,10 @@ const RuleDetail = ({
|
||||
case 'mode':
|
||||
value = !sourceData?.mode
|
||||
? value
|
||||
// eslint-disable-next-line sonarjs/no-nested-conditional
|
||||
|
||||
: sourceData.mode === ProcessMode.general
|
||||
? (t('embedding.custom', { ns: 'datasetDocuments' }) as string)
|
||||
// eslint-disable-next-line sonarjs/no-nested-conditional
|
||||
|
||||
: `${t('embedding.hierarchical', { ns: 'datasetDocuments' })} · ${sourceData?.rules?.parent_mode === 'paragraph'
|
||||
? t('parentMode.paragraph', { ns: 'dataset' })
|
||||
: t('parentMode.fullDoc', { ns: 'dataset' })}`
|
||||
@ -70,7 +70,7 @@ const RuleDetail = ({
|
||||
src={
|
||||
retrievalMethod === RETRIEVE_METHOD.fullText
|
||||
? retrievalIcon.fullText
|
||||
// eslint-disable-next-line sonarjs/no-nested-conditional
|
||||
|
||||
: retrievalMethod === RETRIEVE_METHOD.hybrid
|
||||
? retrievalIcon.hybrid
|
||||
: retrievalIcon.vector
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { FC } from 'react'
|
||||
import { RiArchive2Line, RiCheckboxCircleLine, RiCloseCircleLine, RiDeleteBinLine, RiDraftLine, RiRefreshLine } from '@remixicon/react'
|
||||
import { RiArchive2Line, RiCheckboxCircleLine, RiCloseCircleLine, RiDeleteBinLine, RiDownload2Line, RiDraftLine, RiRefreshLine } from '@remixicon/react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -15,6 +15,7 @@ type IBatchActionProps = {
|
||||
selectedIds: string[]
|
||||
onBatchEnable: () => void
|
||||
onBatchDisable: () => void
|
||||
onBatchDownload?: () => void
|
||||
onBatchDelete: () => Promise<void>
|
||||
onBatchSummary?: () => void
|
||||
onArchive?: () => void
|
||||
@ -29,6 +30,7 @@ const BatchAction: FC<IBatchActionProps> = ({
|
||||
onBatchEnable,
|
||||
onBatchDisable,
|
||||
onBatchSummary,
|
||||
onBatchDownload,
|
||||
onArchive,
|
||||
onBatchDelete,
|
||||
onEditMetadata,
|
||||
@ -115,6 +117,16 @@ const BatchAction: FC<IBatchActionProps> = ({
|
||||
<span className="px-0.5">{t(`${i18nPrefix}.reIndex`, { ns: 'dataset' })}</span>
|
||||
</Button>
|
||||
)}
|
||||
{onBatchDownload && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="gap-x-0.5 px-3"
|
||||
onClick={onBatchDownload}
|
||||
>
|
||||
<RiDownload2Line className="size-4" />
|
||||
<span className="px-0.5">{t(`${i18nPrefix}.download`, { ns: 'dataset' })}</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
destructive
|
||||
|
||||
@ -0,0 +1,46 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import EmbeddingSkeleton from './index'
|
||||
|
||||
// Mock Skeleton components
|
||||
vi.mock('@/app/components/base/skeleton', () => ({
|
||||
SkeletonContainer: ({ children }: { children?: React.ReactNode }) => <div data-testid="skeleton-container">{children}</div>,
|
||||
SkeletonPoint: () => <div data-testid="skeleton-point" />,
|
||||
SkeletonRectangle: () => <div data-testid="skeleton-rectangle" />,
|
||||
SkeletonRow: ({ children }: { children?: React.ReactNode }) => <div data-testid="skeleton-row">{children}</div>,
|
||||
}))
|
||||
|
||||
// Mock Divider
|
||||
vi.mock('@/app/components/base/divider', () => ({
|
||||
default: () => <div data-testid="divider" />,
|
||||
}))
|
||||
|
||||
describe('EmbeddingSkeleton', () => {
|
||||
it('should render correct number of skeletons', () => {
|
||||
render(<EmbeddingSkeleton />)
|
||||
|
||||
// It renders 5 CardSkeletons. Each CardSkelton has multiple SkeletonContainers.
|
||||
// Let's count the number of main wrapper divs (loop is 5)
|
||||
|
||||
// Each iteration renders a CardSkeleton and potentially a Divider.
|
||||
// The component structure is:
|
||||
// div.relative...
|
||||
// div.absolute... (mask)
|
||||
// map(5) -> div.w-full.px-11 -> CardSkelton + Divider (except last?)
|
||||
|
||||
// Actually the code says `index !== 9`, but the loop is length 5.
|
||||
// So `index` goes 0..4. All are !== 9. So 5 dividers should be rendered.
|
||||
|
||||
expect(screen.getAllByTestId('divider')).toHaveLength(5)
|
||||
|
||||
// Just ensure it renders without crashing and contains skeleton elements
|
||||
expect(screen.getAllByTestId('skeleton-container').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByTestId('skeleton-rectangle').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should render the mask overlay', () => {
|
||||
const { container } = render(<EmbeddingSkeleton />)
|
||||
// Check for the absolute positioned mask
|
||||
const mask = container.querySelector('.bg-dataset-chunk-list-mask-bg')
|
||||
expect(mask).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,239 @@
|
||||
import type { CreateExternalAPIReq, FormSchema } from '../declarations'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Form from './Form'
|
||||
|
||||
// Mock context for i18n doc link
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
|
||||
}))
|
||||
|
||||
describe('Form', () => {
|
||||
const defaultFormSchemas: FormSchema[] = [
|
||||
{
|
||||
variable: 'name',
|
||||
type: 'text',
|
||||
label: { en_US: 'Name', zh_CN: '名称' },
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
variable: 'endpoint',
|
||||
type: 'text',
|
||||
label: { en_US: 'API Endpoint', zh_CN: 'API 端点' },
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
variable: 'api_key',
|
||||
type: 'secret',
|
||||
label: { en_US: 'API Key', zh_CN: 'API 密钥' },
|
||||
required: true,
|
||||
},
|
||||
]
|
||||
|
||||
const defaultValue: CreateExternalAPIReq = {
|
||||
name: '',
|
||||
settings: {
|
||||
endpoint: '',
|
||||
api_key: '',
|
||||
},
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
value: defaultValue,
|
||||
onChange: vi.fn(),
|
||||
formSchemas: defaultFormSchemas,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<Form {...defaultProps} />)
|
||||
expect(container.querySelector('form')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all form fields based on formSchemas', () => {
|
||||
render(<Form {...defaultProps} />)
|
||||
expect(screen.getByLabelText(/name/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/api endpoint/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/api key/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render required indicator for required fields', () => {
|
||||
render(<Form {...defaultProps} />)
|
||||
const labels = screen.getAllByText('*')
|
||||
expect(labels.length).toBe(3) // All 3 fields are required
|
||||
})
|
||||
|
||||
it('should render documentation link for endpoint field', () => {
|
||||
render(<Form {...defaultProps} />)
|
||||
const docLink = screen.getByText('dataset.externalAPIPanelDocumentation')
|
||||
expect(docLink).toBeInTheDocument()
|
||||
expect(docLink.closest('a')).toHaveAttribute('href', expect.stringContaining('docs.example.com'))
|
||||
})
|
||||
|
||||
it('should render password type input for secret fields', () => {
|
||||
render(<Form {...defaultProps} />)
|
||||
const apiKeyInput = screen.getByLabelText(/api key/i)
|
||||
expect(apiKeyInput).toHaveAttribute('type', 'password')
|
||||
})
|
||||
|
||||
it('should render text type input for text fields', () => {
|
||||
render(<Form {...defaultProps} />)
|
||||
const nameInput = screen.getByLabelText(/name/i)
|
||||
expect(nameInput).toHaveAttribute('type', 'text')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className to form', () => {
|
||||
const { container } = render(<Form {...defaultProps} className="custom-form-class" />)
|
||||
expect(container.querySelector('form')).toHaveClass('custom-form-class')
|
||||
})
|
||||
|
||||
it('should apply itemClassName to form items', () => {
|
||||
const { container } = render(<Form {...defaultProps} itemClassName="custom-item-class" />)
|
||||
const items = container.querySelectorAll('.custom-item-class')
|
||||
expect(items.length).toBe(3)
|
||||
})
|
||||
|
||||
it('should apply fieldLabelClassName to labels', () => {
|
||||
const { container } = render(<Form {...defaultProps} fieldLabelClassName="custom-label-class" />)
|
||||
const labels = container.querySelectorAll('label.custom-label-class')
|
||||
expect(labels.length).toBe(3)
|
||||
})
|
||||
|
||||
it('should apply inputClassName to inputs', () => {
|
||||
render(<Form {...defaultProps} inputClassName="custom-input-class" />)
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
inputs.forEach((input) => {
|
||||
expect(input).toHaveClass('custom-input-class')
|
||||
})
|
||||
})
|
||||
|
||||
it('should display initial values', () => {
|
||||
const valueWithData: CreateExternalAPIReq = {
|
||||
name: 'Test API',
|
||||
settings: {
|
||||
endpoint: 'https://api.example.com',
|
||||
api_key: 'secret-key',
|
||||
},
|
||||
}
|
||||
render(<Form {...defaultProps} value={valueWithData} />)
|
||||
expect(screen.getByLabelText(/name/i)).toHaveValue('Test API')
|
||||
expect(screen.getByLabelText(/api endpoint/i)).toHaveValue('https://api.example.com')
|
||||
expect(screen.getByLabelText(/api key/i)).toHaveValue('secret-key')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onChange when name field changes', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<Form {...defaultProps} onChange={onChange} />)
|
||||
|
||||
const nameInput = screen.getByLabelText(/name/i)
|
||||
fireEvent.change(nameInput, { target: { value: 'New API Name' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
name: 'New API Name',
|
||||
settings: { endpoint: '', api_key: '' },
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onChange when endpoint field changes', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<Form {...defaultProps} onChange={onChange} />)
|
||||
|
||||
const endpointInput = screen.getByLabelText(/api endpoint/i)
|
||||
fireEvent.change(endpointInput, { target: { value: 'https://new-api.example.com' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
name: '',
|
||||
settings: { endpoint: 'https://new-api.example.com', api_key: '' },
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onChange when api_key field changes', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<Form {...defaultProps} onChange={onChange} />)
|
||||
|
||||
const apiKeyInput = screen.getByLabelText(/api key/i)
|
||||
fireEvent.change(apiKeyInput, { target: { value: 'new-secret-key' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
name: '',
|
||||
settings: { endpoint: '', api_key: 'new-secret-key' },
|
||||
})
|
||||
})
|
||||
|
||||
it('should update settings without affecting name', () => {
|
||||
const onChange = vi.fn()
|
||||
const initialValue: CreateExternalAPIReq = {
|
||||
name: 'Existing Name',
|
||||
settings: { endpoint: '', api_key: '' },
|
||||
}
|
||||
render(<Form {...defaultProps} value={initialValue} onChange={onChange} />)
|
||||
|
||||
const endpointInput = screen.getByLabelText(/api endpoint/i)
|
||||
fireEvent.change(endpointInput, { target: { value: 'https://api.example.com' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
name: 'Existing Name',
|
||||
settings: { endpoint: 'https://api.example.com', api_key: '' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty formSchemas', () => {
|
||||
const { container } = render(<Form {...defaultProps} formSchemas={[]} />)
|
||||
expect(container.querySelector('form')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle optional field (required: false)', () => {
|
||||
const schemasWithOptional: FormSchema[] = [
|
||||
{
|
||||
variable: 'description',
|
||||
type: 'text',
|
||||
label: { en_US: 'Description' },
|
||||
required: false,
|
||||
},
|
||||
]
|
||||
render(<Form {...defaultProps} formSchemas={schemasWithOptional} />)
|
||||
expect(screen.queryByText('*')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fallback to en_US label when current language label is not available', () => {
|
||||
const schemasWithEnOnly: FormSchema[] = [
|
||||
{
|
||||
variable: 'test',
|
||||
type: 'text',
|
||||
label: { en_US: 'Test Field' },
|
||||
required: false,
|
||||
},
|
||||
]
|
||||
render(<Form {...defaultProps} formSchemas={schemasWithEnOnly} />)
|
||||
expect(screen.getByLabelText(/test field/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should preserve existing settings when updating one field', () => {
|
||||
const onChange = vi.fn()
|
||||
const initialValue: CreateExternalAPIReq = {
|
||||
name: '',
|
||||
settings: { endpoint: 'https://existing.com', api_key: 'existing-key' },
|
||||
}
|
||||
render(<Form {...defaultProps} value={initialValue} onChange={onChange} />)
|
||||
|
||||
const endpointInput = screen.getByLabelText(/api endpoint/i)
|
||||
fireEvent.change(endpointInput, { target: { value: 'https://new.com' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
name: '',
|
||||
settings: { endpoint: 'https://new.com', api_key: 'existing-key' },
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,424 @@
|
||||
import type { CreateExternalAPIReq } from '../declarations'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
// Import mocked service
|
||||
import { createExternalAPI } from '@/service/datasets'
|
||||
|
||||
import AddExternalAPIModal from './index'
|
||||
|
||||
// Mock API service
|
||||
vi.mock('@/service/datasets', () => ({
|
||||
createExternalAPI: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock toast context
|
||||
const mockNotify = vi.fn()
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
useToastContext: () => ({
|
||||
notify: mockNotify,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('AddExternalAPIModal', () => {
|
||||
const defaultProps = {
|
||||
onSave: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
isEditMode: false,
|
||||
}
|
||||
|
||||
const initialData: CreateExternalAPIReq = {
|
||||
name: 'Test API',
|
||||
settings: {
|
||||
endpoint: 'https://api.example.com',
|
||||
api_key: 'test-key-12345',
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<AddExternalAPIModal {...defaultProps} />)
|
||||
expect(screen.getByText('dataset.createExternalAPI')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render create title when not in edit mode', () => {
|
||||
render(<AddExternalAPIModal {...defaultProps} isEditMode={false} />)
|
||||
expect(screen.getByText('dataset.createExternalAPI')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render edit title when in edit mode', () => {
|
||||
render(<AddExternalAPIModal {...defaultProps} isEditMode={true} data={initialData} />)
|
||||
expect(screen.getByText('dataset.editExternalAPIFormTitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render form fields', () => {
|
||||
render(<AddExternalAPIModal {...defaultProps} />)
|
||||
expect(screen.getByLabelText(/name/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/api endpoint/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/api key/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render cancel and save buttons', () => {
|
||||
render(<AddExternalAPIModal {...defaultProps} />)
|
||||
expect(screen.getByText('dataset.externalAPIForm.cancel')).toBeInTheDocument()
|
||||
expect(screen.getByText('dataset.externalAPIForm.save')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render encryption notice', () => {
|
||||
render(<AddExternalAPIModal {...defaultProps} />)
|
||||
expect(screen.getByText('PKCS1_OAEP')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render close button', () => {
|
||||
render(<AddExternalAPIModal {...defaultProps} />)
|
||||
// Close button is rendered in a portal
|
||||
const closeButton = document.body.querySelector('.action-btn')
|
||||
expect(closeButton).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edit Mode with Dataset Bindings', () => {
|
||||
it('should show warning when editing with dataset bindings', () => {
|
||||
const datasetBindings = [
|
||||
{ id: 'ds-1', name: 'Dataset 1' },
|
||||
{ id: 'ds-2', name: 'Dataset 2' },
|
||||
]
|
||||
render(
|
||||
<AddExternalAPIModal
|
||||
{...defaultProps}
|
||||
isEditMode={true}
|
||||
data={initialData}
|
||||
datasetBindings={datasetBindings}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('dataset.editExternalAPIFormWarning.front')).toBeInTheDocument()
|
||||
// Verify the count is displayed in the warning section
|
||||
const warningElement = screen.getByText('dataset.editExternalAPIFormWarning.front').parentElement
|
||||
expect(warningElement?.textContent).toContain('2')
|
||||
})
|
||||
|
||||
it('should not show warning when no dataset bindings', () => {
|
||||
render(
|
||||
<AddExternalAPIModal
|
||||
{...defaultProps}
|
||||
isEditMode={true}
|
||||
data={initialData}
|
||||
datasetBindings={[]}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByText('dataset.editExternalAPIFormWarning.front')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Interactions', () => {
|
||||
it('should update form values when input changes', () => {
|
||||
render(<AddExternalAPIModal {...defaultProps} />)
|
||||
|
||||
const nameInput = screen.getByLabelText(/name/i)
|
||||
fireEvent.change(nameInput, { target: { value: 'New API Name' } })
|
||||
expect(nameInput).toHaveValue('New API Name')
|
||||
})
|
||||
|
||||
it('should initialize form with data in edit mode', () => {
|
||||
render(<AddExternalAPIModal {...defaultProps} isEditMode={true} data={initialData} />)
|
||||
|
||||
expect(screen.getByLabelText(/name/i)).toHaveValue('Test API')
|
||||
expect(screen.getByLabelText(/api endpoint/i)).toHaveValue('https://api.example.com')
|
||||
expect(screen.getByLabelText(/api key/i)).toHaveValue('test-key-12345')
|
||||
})
|
||||
|
||||
it('should disable save button when form has empty inputs', () => {
|
||||
render(<AddExternalAPIModal {...defaultProps} />)
|
||||
|
||||
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')
|
||||
expect(saveButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should enable save button when all fields are filled', () => {
|
||||
render(<AddExternalAPIModal {...defaultProps} />)
|
||||
|
||||
const nameInput = screen.getByLabelText(/name/i)
|
||||
const endpointInput = screen.getByLabelText(/api endpoint/i)
|
||||
const apiKeyInput = screen.getByLabelText(/api key/i)
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'Test' } })
|
||||
fireEvent.change(endpointInput, { target: { value: 'https://test.com' } })
|
||||
fireEvent.change(apiKeyInput, { target: { value: 'key12345' } })
|
||||
|
||||
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')
|
||||
expect(saveButton).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Create Mode - Save', () => {
|
||||
it('should create API and call onSave on success', async () => {
|
||||
const mockResponse = {
|
||||
id: 'new-api-123',
|
||||
tenant_id: 'tenant-1',
|
||||
name: 'Test',
|
||||
description: '',
|
||||
settings: { endpoint: 'https://test.com', api_key: 'key12345' },
|
||||
dataset_bindings: [],
|
||||
created_by: 'user-1',
|
||||
created_at: '2021-01-01T00:00:00Z',
|
||||
}
|
||||
vi.mocked(createExternalAPI).mockResolvedValue(mockResponse)
|
||||
const onSave = vi.fn()
|
||||
const onCancel = vi.fn()
|
||||
|
||||
render(<AddExternalAPIModal {...defaultProps} onSave={onSave} onCancel={onCancel} />)
|
||||
|
||||
const nameInput = screen.getByLabelText(/name/i)
|
||||
const endpointInput = screen.getByLabelText(/api endpoint/i)
|
||||
const apiKeyInput = screen.getByLabelText(/api key/i)
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'Test' } })
|
||||
fireEvent.change(endpointInput, { target: { value: 'https://test.com' } })
|
||||
fireEvent.change(apiKeyInput, { target: { value: 'key12345' } })
|
||||
|
||||
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createExternalAPI).toHaveBeenCalledWith({
|
||||
body: {
|
||||
name: 'Test',
|
||||
settings: { endpoint: 'https://test.com', api_key: 'key12345' },
|
||||
},
|
||||
})
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'External API saved successfully',
|
||||
})
|
||||
expect(onSave).toHaveBeenCalledWith(mockResponse)
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show error notification when API key is too short', async () => {
|
||||
render(<AddExternalAPIModal {...defaultProps} />)
|
||||
|
||||
const nameInput = screen.getByLabelText(/name/i)
|
||||
const endpointInput = screen.getByLabelText(/api endpoint/i)
|
||||
const apiKeyInput = screen.getByLabelText(/api key/i)
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'Test' } })
|
||||
fireEvent.change(endpointInput, { target: { value: 'https://test.com' } })
|
||||
fireEvent.change(apiKeyInput, { target: { value: 'key' } }) // Less than 5 characters
|
||||
|
||||
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'common.apiBasedExtension.modal.apiKey.lengthError',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle create API error', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.mocked(createExternalAPI).mockRejectedValue(new Error('Create failed'))
|
||||
|
||||
render(<AddExternalAPIModal {...defaultProps} />)
|
||||
|
||||
const nameInput = screen.getByLabelText(/name/i)
|
||||
const endpointInput = screen.getByLabelText(/api endpoint/i)
|
||||
const apiKeyInput = screen.getByLabelText(/api key/i)
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'Test' } })
|
||||
fireEvent.change(endpointInput, { target: { value: 'https://test.com' } })
|
||||
fireEvent.change(apiKeyInput, { target: { value: 'key12345' } })
|
||||
|
||||
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'Failed to save/update External API',
|
||||
})
|
||||
})
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edit Mode - Save', () => {
|
||||
it('should call onEdit directly when editing without dataset bindings', async () => {
|
||||
const onEdit = vi.fn().mockResolvedValue(undefined)
|
||||
const onCancel = vi.fn()
|
||||
|
||||
render(
|
||||
<AddExternalAPIModal
|
||||
{...defaultProps}
|
||||
isEditMode={true}
|
||||
data={initialData}
|
||||
datasetBindings={[]}
|
||||
onEdit={onEdit}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
|
||||
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
// When no datasetBindings, onEdit is called directly with original form data
|
||||
expect(onEdit).toHaveBeenCalledWith({
|
||||
name: 'Test API',
|
||||
settings: {
|
||||
endpoint: 'https://api.example.com',
|
||||
api_key: 'test-key-12345',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should show confirm dialog when editing with dataset bindings', async () => {
|
||||
const datasetBindings = [{ id: 'ds-1', name: 'Dataset 1' }]
|
||||
const onEdit = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
render(
|
||||
<AddExternalAPIModal
|
||||
{...defaultProps}
|
||||
isEditMode={true}
|
||||
data={initialData}
|
||||
datasetBindings={datasetBindings}
|
||||
onEdit={onEdit}
|
||||
/>,
|
||||
)
|
||||
|
||||
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should proceed with save after confirming in edit mode with bindings', async () => {
|
||||
vi.mocked(createExternalAPI).mockResolvedValue({
|
||||
id: 'api-123',
|
||||
tenant_id: 'tenant-1',
|
||||
name: 'Test API',
|
||||
description: '',
|
||||
settings: { endpoint: 'https://api.example.com', api_key: 'test-key-12345' },
|
||||
dataset_bindings: [],
|
||||
created_by: 'user-1',
|
||||
created_at: '2021-01-01T00:00:00Z',
|
||||
})
|
||||
const datasetBindings = [{ id: 'ds-1', name: 'Dataset 1' }]
|
||||
const onCancel = vi.fn()
|
||||
|
||||
render(
|
||||
<AddExternalAPIModal
|
||||
{...defaultProps}
|
||||
isEditMode={true}
|
||||
data={initialData}
|
||||
datasetBindings={datasetBindings}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
|
||||
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const confirmButton = screen.getByRole('button', { name: /confirm/i })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'success' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should close confirm dialog when cancel is clicked', async () => {
|
||||
const datasetBindings = [{ id: 'ds-1', name: 'Dataset 1' }]
|
||||
|
||||
render(
|
||||
<AddExternalAPIModal
|
||||
{...defaultProps}
|
||||
isEditMode={true}
|
||||
data={initialData}
|
||||
datasetBindings={datasetBindings}
|
||||
/>,
|
||||
)
|
||||
|
||||
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// There are multiple cancel buttons, find the one in the confirm dialog
|
||||
const cancelButtons = screen.getAllByRole('button', { name: /cancel/i })
|
||||
const confirmDialogCancelButton = cancelButtons[cancelButtons.length - 1]
|
||||
fireEvent.click(confirmDialogCancelButton)
|
||||
|
||||
await waitFor(() => {
|
||||
// Confirm button should be gone after canceling
|
||||
expect(screen.queryAllByRole('button', { name: /confirm/i })).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cancel', () => {
|
||||
it('should call onCancel when cancel button is clicked', () => {
|
||||
const onCancel = vi.fn()
|
||||
render(<AddExternalAPIModal {...defaultProps} onCancel={onCancel} />)
|
||||
|
||||
const cancelButton = screen.getByText('dataset.externalAPIForm.cancel').closest('button')!
|
||||
fireEvent.click(cancelButton)
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onCancel when close button is clicked', () => {
|
||||
const onCancel = vi.fn()
|
||||
render(<AddExternalAPIModal {...defaultProps} onCancel={onCancel} />)
|
||||
|
||||
// Close button is rendered in a portal
|
||||
const closeButton = document.body.querySelector('.action-btn')!
|
||||
fireEvent.click(closeButton)
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined data in edit mode', () => {
|
||||
render(<AddExternalAPIModal {...defaultProps} isEditMode={true} data={undefined} />)
|
||||
expect(screen.getByLabelText(/name/i)).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should handle null datasetBindings', () => {
|
||||
render(
|
||||
<AddExternalAPIModal
|
||||
{...defaultProps}
|
||||
isEditMode={true}
|
||||
data={initialData}
|
||||
datasetBindings={undefined}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByText('dataset.editExternalAPIFormWarning.front')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render documentation link in encryption notice', () => {
|
||||
render(<AddExternalAPIModal {...defaultProps} />)
|
||||
const link = screen.getByRole('link', { name: 'PKCS1_OAEP' })
|
||||
expect(link).toHaveAttribute('href', 'https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html')
|
||||
expect(link).toHaveAttribute('target', '_blank')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,207 @@
|
||||
import type { ExternalAPIItem } from '@/models/datasets'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ExternalAPIPanel from './index'
|
||||
|
||||
// Mock external contexts (only mock context providers, not base components)
|
||||
const mockSetShowExternalKnowledgeAPIModal = vi.fn()
|
||||
const mockMutateExternalKnowledgeApis = vi.fn()
|
||||
let mockIsLoading = false
|
||||
let mockExternalKnowledgeApiList: ExternalAPIItem[] = []
|
||||
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowExternalKnowledgeAPIModal: mockSetShowExternalKnowledgeAPIModal,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/external-knowledge-api-context', () => ({
|
||||
useExternalKnowledgeApi: () => ({
|
||||
externalKnowledgeApiList: mockExternalKnowledgeApiList,
|
||||
mutateExternalKnowledgeApis: mockMutateExternalKnowledgeApis,
|
||||
isLoading: mockIsLoading,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
|
||||
}))
|
||||
|
||||
// Mock the ExternalKnowledgeAPICard to avoid mocking its internal dependencies
|
||||
vi.mock('../external-knowledge-api-card', () => ({
|
||||
default: ({ api }: { api: ExternalAPIItem }) => (
|
||||
<div data-testid={`api-card-${api.id}`}>{api.name}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// i18n mock returns 'namespace.key' format
|
||||
|
||||
describe('ExternalAPIPanel', () => {
|
||||
const defaultProps = {
|
||||
onClose: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsLoading = false
|
||||
mockExternalKnowledgeApiList = []
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<ExternalAPIPanel {...defaultProps} />)
|
||||
expect(screen.getByText('dataset.externalAPIPanelTitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render panel title and description', () => {
|
||||
render(<ExternalAPIPanel {...defaultProps} />)
|
||||
expect(screen.getByText('dataset.externalAPIPanelTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('dataset.externalAPIPanelDescription')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render documentation link', () => {
|
||||
render(<ExternalAPIPanel {...defaultProps} />)
|
||||
const docLink = screen.getByText('dataset.externalAPIPanelDocumentation')
|
||||
expect(docLink).toBeInTheDocument()
|
||||
expect(docLink.closest('a')).toHaveAttribute('href', 'https://docs.example.com/guides/knowledge-base/connect-external-knowledge-base')
|
||||
})
|
||||
|
||||
it('should render create button', () => {
|
||||
render(<ExternalAPIPanel {...defaultProps} />)
|
||||
expect(screen.getByText('dataset.createExternalAPI')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render close button', () => {
|
||||
const { container } = render(<ExternalAPIPanel {...defaultProps} />)
|
||||
const closeButton = container.querySelector('[class*="action-button"]') || screen.getAllByRole('button')[0]
|
||||
expect(closeButton).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Loading State', () => {
|
||||
it('should render loading indicator when isLoading is true', () => {
|
||||
mockIsLoading = true
|
||||
const { container } = render(<ExternalAPIPanel {...defaultProps} />)
|
||||
// Loading component should be rendered
|
||||
const loadingElement = container.querySelector('[class*="loading"]')
|
||||
|| container.querySelector('.animate-spin')
|
||||
|| screen.queryByRole('status')
|
||||
expect(loadingElement || container.textContent).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('API List Rendering', () => {
|
||||
it('should render empty list when no APIs exist', () => {
|
||||
mockExternalKnowledgeApiList = []
|
||||
render(<ExternalAPIPanel {...defaultProps} />)
|
||||
expect(screen.queryByTestId(/api-card-/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render API cards when APIs exist', () => {
|
||||
mockExternalKnowledgeApiList = [
|
||||
{
|
||||
id: 'api-1',
|
||||
tenant_id: 'tenant-1',
|
||||
name: 'Test API 1',
|
||||
description: '',
|
||||
settings: { endpoint: 'https://api1.example.com', api_key: 'key1' },
|
||||
dataset_bindings: [],
|
||||
created_by: 'user-1',
|
||||
created_at: '2021-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'api-2',
|
||||
tenant_id: 'tenant-1',
|
||||
name: 'Test API 2',
|
||||
description: '',
|
||||
settings: { endpoint: 'https://api2.example.com', api_key: 'key2' },
|
||||
dataset_bindings: [],
|
||||
created_by: 'user-1',
|
||||
created_at: '2021-01-01T00:00:00Z',
|
||||
},
|
||||
]
|
||||
render(<ExternalAPIPanel {...defaultProps} />)
|
||||
expect(screen.getByTestId('api-card-api-1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('api-card-api-2')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test API 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test API 2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClose when close button is clicked', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<ExternalAPIPanel onClose={onClose} />)
|
||||
// Find the close button (ActionButton with close icon)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const closeButton = buttons.find(btn => btn.querySelector('svg[class*="ri-close"]'))
|
||||
|| buttons[0]
|
||||
fireEvent.click(closeButton)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should open external API modal when create button is clicked', async () => {
|
||||
render(<ExternalAPIPanel {...defaultProps} />)
|
||||
const createButton = screen.getByText('dataset.createExternalAPI').closest('button')!
|
||||
fireEvent.click(createButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payload: { name: '', settings: { endpoint: '', api_key: '' } },
|
||||
datasetBindings: [],
|
||||
isEditMode: false,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should call mutateExternalKnowledgeApis in onSaveCallback', async () => {
|
||||
render(<ExternalAPIPanel {...defaultProps} />)
|
||||
const createButton = screen.getByText('dataset.createExternalAPI').closest('button')!
|
||||
fireEvent.click(createButton)
|
||||
|
||||
const callArgs = mockSetShowExternalKnowledgeAPIModal.mock.calls[0][0]
|
||||
callArgs.onSaveCallback()
|
||||
|
||||
expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call mutateExternalKnowledgeApis in onCancelCallback', async () => {
|
||||
render(<ExternalAPIPanel {...defaultProps} />)
|
||||
const createButton = screen.getByText('dataset.createExternalAPI').closest('button')!
|
||||
fireEvent.click(createButton)
|
||||
|
||||
const callArgs = mockSetShowExternalKnowledgeAPIModal.mock.calls[0][0]
|
||||
callArgs.onCancelCallback()
|
||||
|
||||
expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle single API in list', () => {
|
||||
mockExternalKnowledgeApiList = [
|
||||
{
|
||||
id: 'single-api',
|
||||
tenant_id: 'tenant-1',
|
||||
name: 'Single API',
|
||||
description: '',
|
||||
settings: { endpoint: 'https://single.example.com', api_key: 'key' },
|
||||
dataset_bindings: [],
|
||||
created_by: 'user-1',
|
||||
created_at: '2021-01-01T00:00:00Z',
|
||||
},
|
||||
]
|
||||
render(<ExternalAPIPanel {...defaultProps} />)
|
||||
expect(screen.getByTestId('api-card-single-api')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render documentation link with correct target', () => {
|
||||
render(<ExternalAPIPanel {...defaultProps} />)
|
||||
const docLink = screen.getByText('dataset.externalAPIPanelDocumentation').closest('a')
|
||||
expect(docLink).toHaveAttribute('target', '_blank')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,382 @@
|
||||
import type { ExternalAPIItem } from '@/models/datasets'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
// Import mocked services
|
||||
import { checkUsageExternalAPI, deleteExternalAPI, fetchExternalAPI } from '@/service/datasets'
|
||||
|
||||
import ExternalKnowledgeAPICard from './index'
|
||||
|
||||
// Mock API services
|
||||
vi.mock('@/service/datasets', () => ({
|
||||
fetchExternalAPI: vi.fn(),
|
||||
updateExternalAPI: vi.fn(),
|
||||
deleteExternalAPI: vi.fn(),
|
||||
checkUsageExternalAPI: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock contexts
|
||||
const mockSetShowExternalKnowledgeAPIModal = vi.fn()
|
||||
const mockMutateExternalKnowledgeApis = vi.fn()
|
||||
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowExternalKnowledgeAPIModal: mockSetShowExternalKnowledgeAPIModal,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/external-knowledge-api-context', () => ({
|
||||
useExternalKnowledgeApi: () => ({
|
||||
mutateExternalKnowledgeApis: mockMutateExternalKnowledgeApis,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('ExternalKnowledgeAPICard', () => {
|
||||
const mockApi: ExternalAPIItem = {
|
||||
id: 'api-123',
|
||||
tenant_id: 'tenant-1',
|
||||
name: 'Test External API',
|
||||
description: 'Test API description',
|
||||
settings: {
|
||||
endpoint: 'https://api.example.com/knowledge',
|
||||
api_key: 'secret-key-123',
|
||||
},
|
||||
dataset_bindings: [],
|
||||
created_by: 'user-1',
|
||||
created_at: '2021-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
api: mockApi,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
expect(screen.getByText('Test External API')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render API name', () => {
|
||||
render(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
expect(screen.getByText('Test External API')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render API endpoint', () => {
|
||||
render(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
expect(screen.getByText('https://api.example.com/knowledge')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render edit and delete buttons', () => {
|
||||
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
const buttons = container.querySelectorAll('button')
|
||||
expect(buttons.length).toBe(2)
|
||||
})
|
||||
|
||||
it('should render API connection icon', () => {
|
||||
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
const icon = container.querySelector('svg')
|
||||
expect(icon).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions - Edit', () => {
|
||||
it('should fetch API details and open modal when edit button is clicked', async () => {
|
||||
const mockResponse: ExternalAPIItem = {
|
||||
id: 'api-123',
|
||||
tenant_id: 'tenant-1',
|
||||
name: 'Test External API',
|
||||
description: 'Test API description',
|
||||
settings: {
|
||||
endpoint: 'https://api.example.com/knowledge',
|
||||
api_key: 'secret-key-123',
|
||||
},
|
||||
dataset_bindings: [{ id: 'ds-1', name: 'Dataset 1' }],
|
||||
created_by: 'user-1',
|
||||
created_at: '2021-01-01T00:00:00Z',
|
||||
}
|
||||
vi.mocked(fetchExternalAPI).mockResolvedValue(mockResponse)
|
||||
|
||||
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
const buttons = container.querySelectorAll('button')
|
||||
const editButton = buttons[0]
|
||||
|
||||
fireEvent.click(editButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchExternalAPI).toHaveBeenCalledWith({ apiTemplateId: 'api-123' })
|
||||
expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payload: {
|
||||
name: 'Test External API',
|
||||
settings: {
|
||||
endpoint: 'https://api.example.com/knowledge',
|
||||
api_key: 'secret-key-123',
|
||||
},
|
||||
},
|
||||
isEditMode: true,
|
||||
datasetBindings: [{ id: 'ds-1', name: 'Dataset 1' }],
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle fetch error gracefully', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.mocked(fetchExternalAPI).mockRejectedValue(new Error('Fetch failed'))
|
||||
|
||||
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
const buttons = container.querySelectorAll('button')
|
||||
const editButton = buttons[0]
|
||||
|
||||
fireEvent.click(editButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Error fetching external knowledge API data:',
|
||||
expect.any(Error),
|
||||
)
|
||||
})
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should call mutate on save callback', async () => {
|
||||
const mockResponse: ExternalAPIItem = {
|
||||
id: 'api-123',
|
||||
tenant_id: 'tenant-1',
|
||||
name: 'Test External API',
|
||||
description: 'Test API description',
|
||||
settings: {
|
||||
endpoint: 'https://api.example.com/knowledge',
|
||||
api_key: 'secret-key-123',
|
||||
},
|
||||
dataset_bindings: [],
|
||||
created_by: 'user-1',
|
||||
created_at: '2021-01-01T00:00:00Z',
|
||||
}
|
||||
vi.mocked(fetchExternalAPI).mockResolvedValue(mockResponse)
|
||||
|
||||
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
const editButton = container.querySelectorAll('button')[0]
|
||||
|
||||
fireEvent.click(editButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Simulate save callback
|
||||
const modalCall = mockSetShowExternalKnowledgeAPIModal.mock.calls[0][0]
|
||||
modalCall.onSaveCallback()
|
||||
|
||||
expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call mutate on cancel callback', async () => {
|
||||
const mockResponse: ExternalAPIItem = {
|
||||
id: 'api-123',
|
||||
tenant_id: 'tenant-1',
|
||||
name: 'Test External API',
|
||||
description: 'Test API description',
|
||||
settings: {
|
||||
endpoint: 'https://api.example.com/knowledge',
|
||||
api_key: 'secret-key-123',
|
||||
},
|
||||
dataset_bindings: [],
|
||||
created_by: 'user-1',
|
||||
created_at: '2021-01-01T00:00:00Z',
|
||||
}
|
||||
vi.mocked(fetchExternalAPI).mockResolvedValue(mockResponse)
|
||||
|
||||
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
const editButton = container.querySelectorAll('button')[0]
|
||||
|
||||
fireEvent.click(editButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Simulate cancel callback
|
||||
const modalCall = mockSetShowExternalKnowledgeAPIModal.mock.calls[0][0]
|
||||
modalCall.onCancelCallback()
|
||||
|
||||
expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions - Delete', () => {
|
||||
it('should check usage and show confirm dialog when delete button is clicked', async () => {
|
||||
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 })
|
||||
|
||||
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
const buttons = container.querySelectorAll('button')
|
||||
const deleteButton = buttons[1]
|
||||
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(checkUsageExternalAPI).toHaveBeenCalledWith({ apiTemplateId: 'api-123' })
|
||||
})
|
||||
|
||||
// Confirm dialog should be shown
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show usage count in confirm dialog when API is in use', async () => {
|
||||
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: true, count: 3 })
|
||||
|
||||
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
const deleteButton = container.querySelectorAll('button')[1]
|
||||
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/3/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should delete API and refresh list when confirmed', async () => {
|
||||
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 })
|
||||
vi.mocked(deleteExternalAPI).mockResolvedValue({ result: 'success' })
|
||||
|
||||
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
const deleteButton = container.querySelectorAll('button')[1]
|
||||
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const confirmButton = screen.getByRole('button', { name: /confirm/i })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteExternalAPI).toHaveBeenCalledWith({ apiTemplateId: 'api-123' })
|
||||
expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should close confirm dialog when cancel is clicked', async () => {
|
||||
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 })
|
||||
|
||||
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
const deleteButton = container.querySelectorAll('button')[1]
|
||||
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const cancelButton = screen.getByRole('button', { name: /cancel/i })
|
||||
fireEvent.click(cancelButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('button', { name: /confirm/i })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle delete error gracefully', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 })
|
||||
vi.mocked(deleteExternalAPI).mockRejectedValue(new Error('Delete failed'))
|
||||
|
||||
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
const deleteButton = container.querySelectorAll('button')[1]
|
||||
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const confirmButton = screen.getByRole('button', { name: /confirm/i })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Error deleting external knowledge API:',
|
||||
expect.any(Error),
|
||||
)
|
||||
})
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should handle check usage error gracefully', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.mocked(checkUsageExternalAPI).mockRejectedValue(new Error('Check failed'))
|
||||
|
||||
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
const deleteButton = container.querySelectorAll('button')[1]
|
||||
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Error checking external API usage:',
|
||||
expect.any(Error),
|
||||
)
|
||||
})
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Hover State', () => {
|
||||
it('should apply hover styles when delete button is hovered', () => {
|
||||
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
const deleteButton = container.querySelectorAll('button')[1]
|
||||
const cardContainer = container.querySelector('[class*="shadows-shadow"]')
|
||||
|
||||
fireEvent.mouseEnter(deleteButton)
|
||||
expect(cardContainer).toHaveClass('border-state-destructive-border')
|
||||
expect(cardContainer).toHaveClass('bg-state-destructive-hover')
|
||||
|
||||
fireEvent.mouseLeave(deleteButton)
|
||||
expect(cardContainer).not.toHaveClass('border-state-destructive-border')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle API with empty endpoint', () => {
|
||||
const apiWithEmptyEndpoint: ExternalAPIItem = {
|
||||
...mockApi,
|
||||
settings: { endpoint: '', api_key: 'key' },
|
||||
}
|
||||
render(<ExternalKnowledgeAPICard api={apiWithEmptyEndpoint} />)
|
||||
expect(screen.getByText('Test External API')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle delete response with unsuccessful result', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 })
|
||||
vi.mocked(deleteExternalAPI).mockResolvedValue({ result: 'error' })
|
||||
|
||||
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
const deleteButton = container.querySelectorAll('button')[1]
|
||||
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const confirmButton = screen.getByRole('button', { name: /confirm/i })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Failed to delete external API')
|
||||
})
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
92
web/app/components/datasets/extra-info/api-access/card.tsx
Normal file
92
web/app/components/datasets/extra-info/api-access/card.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import { RiArrowRightUpLine, RiBookOpenLine } from '@remixicon/react'
|
||||
import Link from 'next/link'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import { useSelector as useAppContextSelector } from '@/context/app-context'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url'
|
||||
import { useDisableDatasetServiceApi, useEnableDatasetServiceApi } from '@/service/knowledge/use-dataset'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type CardProps = {
|
||||
apiEnabled: boolean
|
||||
}
|
||||
|
||||
const Card = ({
|
||||
apiEnabled,
|
||||
}: CardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const datasetId = useDatasetDetailContextWithSelector(state => state.dataset?.id)
|
||||
const mutateDatasetRes = useDatasetDetailContextWithSelector(state => state.mutateDatasetRes)
|
||||
const { mutateAsync: enableDatasetServiceApi } = useEnableDatasetServiceApi()
|
||||
const { mutateAsync: disableDatasetServiceApi } = useDisableDatasetServiceApi()
|
||||
|
||||
const isCurrentWorkspaceManager = useAppContextSelector(state => state.isCurrentWorkspaceManager)
|
||||
|
||||
const apiReferenceUrl = useDatasetApiAccessUrl()
|
||||
|
||||
const onToggle = useCallback(async (state: boolean) => {
|
||||
let result: 'success' | 'fail'
|
||||
if (state)
|
||||
result = (await enableDatasetServiceApi(datasetId ?? '')).result
|
||||
else
|
||||
result = (await disableDatasetServiceApi(datasetId ?? '')).result
|
||||
if (result === 'success')
|
||||
mutateDatasetRes?.()
|
||||
}, [datasetId, enableDatasetServiceApi, mutateDatasetRes, disableDatasetServiceApi])
|
||||
|
||||
return (
|
||||
<div className="w-[208px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg">
|
||||
<div className="p-1">
|
||||
<div className="p-2">
|
||||
<div className="mb-1.5 flex justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<Indicator
|
||||
className="shrink-0"
|
||||
color={apiEnabled ? 'green' : 'yellow'}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'system-xs-semibold-uppercase',
|
||||
apiEnabled ? 'text-text-success' : 'text-text-warning',
|
||||
)}
|
||||
>
|
||||
{apiEnabled
|
||||
? t('serviceApi.enabled', { ns: 'dataset' })
|
||||
: t('serviceApi.disabled', { ns: 'dataset' })}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
defaultValue={apiEnabled}
|
||||
onChange={onToggle}
|
||||
disabled={!isCurrentWorkspaceManager}
|
||||
/>
|
||||
</div>
|
||||
<div className="system-xs-regular text-text-tertiary">
|
||||
{t('appMenus.apiAccessTip', { ns: 'common' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-px bg-divider-subtle"></div>
|
||||
<div className="p-1">
|
||||
<Link
|
||||
href={apiReferenceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex h-8 items-center space-x-[7px] rounded-lg px-2 text-text-tertiary hover:bg-state-base-hover"
|
||||
>
|
||||
<RiBookOpenLine className="size-3.5 shrink-0" />
|
||||
<div className="system-sm-regular grow truncate">
|
||||
{t('overview.apiInfo.doc', { ns: 'appOverview' })}
|
||||
</div>
|
||||
<RiArrowRightUpLine className="size-3.5 shrink-0" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Card)
|
||||
65
web/app/components/datasets/extra-info/api-access/index.tsx
Normal file
65
web/app/components/datasets/extra-info/api-access/index.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ApiAggregate } from '@/app/components/base/icons/src/vender/knowledge'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Card from './card'
|
||||
|
||||
type ApiAccessProps = {
|
||||
expand: boolean
|
||||
apiEnabled: boolean
|
||||
}
|
||||
|
||||
const ApiAccess = ({
|
||||
expand,
|
||||
apiEnabled,
|
||||
}: ApiAccessProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const handleToggle = () => {
|
||||
setOpen(!open)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-3 pt-2">
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="top-start"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: -4,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
className="w-full"
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<div className={cn(
|
||||
'relative flex h-8 cursor-pointer items-center gap-2 rounded-lg border border-components-panel-border px-3',
|
||||
!expand && 'w-8 justify-center',
|
||||
open ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<ApiAggregate className="size-4 shrink-0 text-text-secondary" />
|
||||
{expand && <div className="system-sm-medium grow text-text-secondary">{t('appMenus.apiAccess', { ns: 'common' })}</div>}
|
||||
<Indicator
|
||||
className={cn('shrink-0', !expand && 'absolute -right-px -top-px')}
|
||||
color={apiEnabled ? 'green' : 'yellow'}
|
||||
/>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[10]">
|
||||
<Card
|
||||
apiEnabled={apiEnabled}
|
||||
/>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ApiAccess)
|
||||
1169
web/app/components/datasets/extra-info/index.spec.tsx
Normal file
1169
web/app/components/datasets/extra-info/index.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,8 +1,7 @@
|
||||
import type { RelatedAppResponse } from '@/models/datasets'
|
||||
import * as React from 'react'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import { useDatasetApiBaseUrl } from '@/service/knowledge/use-dataset'
|
||||
import ServiceApi from './service-api'
|
||||
import ApiAccess from './api-access'
|
||||
import Statistics from './statistics'
|
||||
|
||||
type IExtraInfoProps = {
|
||||
@ -17,7 +16,6 @@ const ExtraInfo = ({
|
||||
expand,
|
||||
}: IExtraInfoProps) => {
|
||||
const apiEnabled = useDatasetDetailContextWithSelector(state => state.dataset?.enable_api)
|
||||
const { data: apiBaseInfo } = useDatasetApiBaseUrl()
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -28,9 +26,8 @@ const ExtraInfo = ({
|
||||
relatedApps={relatedApps}
|
||||
/>
|
||||
)}
|
||||
<ServiceApi
|
||||
<ApiAccess
|
||||
expand={expand}
|
||||
apiBaseUrl={apiBaseInfo?.api_base_url ?? ''}
|
||||
apiEnabled={apiEnabled ?? false}
|
||||
/>
|
||||
</>
|
||||
|
||||
@ -6,45 +6,22 @@ import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import CopyFeedback from '@/app/components/base/copy-feedback'
|
||||
import { ApiAggregate } from '@/app/components/base/icons/src/vender/knowledge'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import SecretKeyModal from '@/app/components/develop/secret-key/secret-key-modal'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import { useSelector as useAppContextSelector } from '@/context/app-context'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url'
|
||||
import { useDisableDatasetServiceApi, useEnableDatasetServiceApi } from '@/service/knowledge/use-dataset'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type CardProps = {
|
||||
apiEnabled: boolean
|
||||
apiBaseUrl: string
|
||||
}
|
||||
|
||||
const Card = ({
|
||||
apiEnabled,
|
||||
apiBaseUrl,
|
||||
}: CardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const datasetId = useDatasetDetailContextWithSelector(state => state.dataset?.id)
|
||||
const mutateDatasetRes = useDatasetDetailContextWithSelector(state => state.mutateDatasetRes)
|
||||
const { mutateAsync: enableDatasetServiceApi } = useEnableDatasetServiceApi()
|
||||
const { mutateAsync: disableDatasetServiceApi } = useDisableDatasetServiceApi()
|
||||
const [isSecretKeyModalVisible, setIsSecretKeyModalVisible] = useState(false)
|
||||
|
||||
const isCurrentWorkspaceManager = useAppContextSelector(state => state.isCurrentWorkspaceManager)
|
||||
|
||||
const apiReferenceUrl = useDatasetApiAccessUrl()
|
||||
|
||||
const onToggle = useCallback(async (state: boolean) => {
|
||||
let result: 'success' | 'fail'
|
||||
if (state)
|
||||
result = (await enableDatasetServiceApi(datasetId ?? '')).result
|
||||
else
|
||||
result = (await disableDatasetServiceApi(datasetId ?? '')).result
|
||||
if (result === 'success')
|
||||
mutateDatasetRes?.()
|
||||
}, [datasetId, enableDatasetServiceApi, disableDatasetServiceApi])
|
||||
|
||||
const handleOpenSecretKeyModal = useCallback(() => {
|
||||
setIsSecretKeyModalVisible(true)
|
||||
}, [])
|
||||
@ -68,24 +45,16 @@ const Card = ({
|
||||
<div className="flex items-center gap-x-1">
|
||||
<Indicator
|
||||
className="shrink-0"
|
||||
color={apiEnabled ? 'green' : 'yellow'}
|
||||
color={
|
||||
apiBaseUrl ? 'green' : 'yellow'
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'system-xs-semibold-uppercase',
|
||||
apiEnabled ? 'text-text-success' : 'text-text-warning',
|
||||
)}
|
||||
className="system-xs-semibold-uppercase text-text-success"
|
||||
>
|
||||
{apiEnabled
|
||||
? t('serviceApi.enabled', { ns: 'dataset' })
|
||||
: t('serviceApi.disabled', { ns: 'dataset' })}
|
||||
{t('serviceApi.enabled', { ns: 'dataset' })}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
defaultValue={apiEnabled}
|
||||
onChange={onToggle}
|
||||
disabled={!isCurrentWorkspaceManager}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="system-xs-regular leading-6 text-text-tertiary">
|
||||
|
||||
@ -1,22 +1,17 @@
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ApiAggregate } from '@/app/components/base/icons/src/vender/knowledge'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Card from './card'
|
||||
|
||||
type ServiceApiProps = {
|
||||
expand: boolean
|
||||
apiBaseUrl: string
|
||||
apiEnabled: boolean
|
||||
}
|
||||
|
||||
const ServiceApi = ({
|
||||
expand,
|
||||
apiBaseUrl,
|
||||
apiEnabled,
|
||||
}: ServiceApiProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
@ -26,7 +21,7 @@ const ServiceApi = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-3 pt-2">
|
||||
<div>
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
@ -41,22 +36,21 @@ const ServiceApi = ({
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<div className={cn(
|
||||
'relative flex h-8 cursor-pointer items-center gap-2 rounded-lg border border-components-panel-border px-3',
|
||||
!expand && 'w-8 justify-center',
|
||||
open ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
|
||||
'relative flex h-8 cursor-pointer items-center gap-2 rounded-lg border-[0.5px] border-components-button-secondary-border-hover bg-components-button-secondary-bg px-3',
|
||||
open ? 'bg-components-button-secondary-bg-hover' : 'hover:bg-components-button-secondary-bg-hover',
|
||||
)}
|
||||
>
|
||||
<ApiAggregate className="size-4 shrink-0 text-text-secondary" />
|
||||
{expand && <div className="system-sm-medium grow text-text-secondary">{t('serviceApi.title', { ns: 'dataset' })}</div>}
|
||||
<Indicator
|
||||
className={cn('shrink-0', !expand && 'absolute -right-px -top-px')}
|
||||
color={apiEnabled ? 'green' : 'yellow'}
|
||||
className={cn('shrink-0')}
|
||||
color={
|
||||
apiBaseUrl ? 'green' : 'yellow'
|
||||
}
|
||||
/>
|
||||
<div className="system-sm-medium grow text-text-secondary">{t('serviceApi.title', { ns: 'dataset' })}</div>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[10]">
|
||||
<Card
|
||||
apiEnabled={apiEnabled}
|
||||
apiBaseUrl={apiBaseUrl}
|
||||
/>
|
||||
</PortalToFollowElemContent>
|
||||
|
||||
2649
web/app/components/datasets/hit-testing/index.spec.tsx
Normal file
2649
web/app/components/datasets/hit-testing/index.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,125 @@
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
|
||||
import CornerLabels from './corner-labels'
|
||||
|
||||
describe('CornerLabels', () => {
|
||||
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
|
||||
id: 'dataset-1',
|
||||
name: 'Test Dataset',
|
||||
description: 'Test description',
|
||||
provider: 'vendor',
|
||||
permission: DatasetPermission.allTeamMembers,
|
||||
data_source_type: DataSourceType.FILE,
|
||||
indexing_technique: IndexingType.QUALIFIED,
|
||||
embedding_available: true,
|
||||
app_count: 5,
|
||||
document_count: 10,
|
||||
word_count: 1000,
|
||||
created_at: 1609459200,
|
||||
updated_at: 1609545600,
|
||||
tags: [],
|
||||
embedding_model: 'text-embedding-ada-002',
|
||||
embedding_model_provider: 'openai',
|
||||
created_by: 'user-1',
|
||||
doc_form: ChunkingMode.text,
|
||||
runtime_mode: 'general',
|
||||
...overrides,
|
||||
} as DataSet)
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing when embedding is available', () => {
|
||||
const dataset = createMockDataset({ embedding_available: true })
|
||||
const { container } = render(<CornerLabels dataset={dataset} />)
|
||||
// Should render null when embedding is available and not pipeline
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should render unavailable label when embedding is not available', () => {
|
||||
const dataset = createMockDataset({ embedding_available: false })
|
||||
render(<CornerLabels dataset={dataset} />)
|
||||
expect(screen.getByText(/unavailable/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render pipeline label when runtime_mode is rag_pipeline', () => {
|
||||
const dataset = createMockDataset({
|
||||
embedding_available: true,
|
||||
runtime_mode: 'rag_pipeline',
|
||||
})
|
||||
render(<CornerLabels dataset={dataset} />)
|
||||
expect(screen.getByText(/pipeline/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should not render when embedding is available and not pipeline', () => {
|
||||
const dataset = createMockDataset({
|
||||
embedding_available: true,
|
||||
runtime_mode: 'general',
|
||||
})
|
||||
const { container } = render(<CornerLabels dataset={dataset} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should prioritize unavailable label over pipeline label', () => {
|
||||
const dataset = createMockDataset({
|
||||
embedding_available: false,
|
||||
runtime_mode: 'rag_pipeline',
|
||||
})
|
||||
render(<CornerLabels dataset={dataset} />)
|
||||
// Should show unavailable since embedding_available is checked first
|
||||
expect(screen.getByText(/unavailable/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/pipeline/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styles', () => {
|
||||
it('should have correct positioning for unavailable label', () => {
|
||||
const dataset = createMockDataset({ embedding_available: false })
|
||||
const { container } = render(<CornerLabels dataset={dataset} />)
|
||||
const labelContainer = container.firstChild as HTMLElement
|
||||
expect(labelContainer).toHaveClass('absolute', 'right-0', 'top-0', 'z-10')
|
||||
})
|
||||
|
||||
it('should have correct positioning for pipeline label', () => {
|
||||
const dataset = createMockDataset({
|
||||
embedding_available: true,
|
||||
runtime_mode: 'rag_pipeline',
|
||||
})
|
||||
const { container } = render(<CornerLabels dataset={dataset} />)
|
||||
const labelContainer = container.firstChild as HTMLElement
|
||||
expect(labelContainer).toHaveClass('absolute', 'right-0', 'top-0', 'z-10')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined runtime_mode', () => {
|
||||
const dataset = createMockDataset({
|
||||
embedding_available: true,
|
||||
runtime_mode: undefined,
|
||||
})
|
||||
const { container } = render(<CornerLabels dataset={dataset} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle empty string runtime_mode', () => {
|
||||
const dataset = createMockDataset({
|
||||
embedding_available: true,
|
||||
runtime_mode: '' as DataSet['runtime_mode'],
|
||||
})
|
||||
const { container } = render(<CornerLabels dataset={dataset} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle all false conditions', () => {
|
||||
const dataset = createMockDataset({
|
||||
embedding_available: true,
|
||||
runtime_mode: 'general',
|
||||
})
|
||||
const { container } = render(<CornerLabels dataset={dataset} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,177 @@
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
|
||||
import DatasetCardFooter from './dataset-card-footer'
|
||||
|
||||
// Mock the useFormatTimeFromNow hook
|
||||
vi.mock('@/hooks/use-format-time-from-now', () => ({
|
||||
useFormatTimeFromNow: () => ({
|
||||
formatTimeFromNow: vi.fn((timestamp: number) => {
|
||||
const date = new Date(timestamp)
|
||||
return `${date.toLocaleDateString()}`
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('DatasetCardFooter', () => {
|
||||
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
|
||||
id: 'dataset-1',
|
||||
name: 'Test Dataset',
|
||||
description: 'Test description',
|
||||
provider: 'vendor',
|
||||
permission: DatasetPermission.allTeamMembers,
|
||||
data_source_type: DataSourceType.FILE,
|
||||
indexing_technique: IndexingType.QUALIFIED,
|
||||
embedding_available: true,
|
||||
app_count: 5,
|
||||
document_count: 10,
|
||||
word_count: 1000,
|
||||
created_at: 1609459200,
|
||||
updated_at: 1609545600,
|
||||
tags: [],
|
||||
embedding_model: 'text-embedding-ada-002',
|
||||
embedding_model_provider: 'openai',
|
||||
created_by: 'user-1',
|
||||
doc_form: ChunkingMode.text,
|
||||
total_available_documents: 10,
|
||||
...overrides,
|
||||
} as DataSet)
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const dataset = createMockDataset()
|
||||
render(<DatasetCardFooter dataset={dataset} />)
|
||||
expect(screen.getByText('10')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render document count', () => {
|
||||
const dataset = createMockDataset({ document_count: 25, total_available_documents: 25 })
|
||||
render(<DatasetCardFooter dataset={dataset} />)
|
||||
expect(screen.getByText('25')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app count for non-external provider', () => {
|
||||
const dataset = createMockDataset({ app_count: 8, provider: 'vendor' })
|
||||
render(<DatasetCardFooter dataset={dataset} />)
|
||||
expect(screen.getByText('8')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render app count for external provider', () => {
|
||||
const dataset = createMockDataset({ app_count: 8, provider: 'external' })
|
||||
render(<DatasetCardFooter dataset={dataset} />)
|
||||
// App count should not be rendered
|
||||
const appCounts = screen.queryAllByText('8')
|
||||
expect(appCounts.length).toBe(0)
|
||||
})
|
||||
|
||||
it('should render update time', () => {
|
||||
const dataset = createMockDataset()
|
||||
render(<DatasetCardFooter dataset={dataset} />)
|
||||
// Check for "updated" text with i18n key
|
||||
expect(screen.getByText(/updated/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should show partial document count when total_available_documents < document_count', () => {
|
||||
const dataset = createMockDataset({
|
||||
document_count: 20,
|
||||
total_available_documents: 15,
|
||||
})
|
||||
render(<DatasetCardFooter dataset={dataset} />)
|
||||
expect(screen.getByText('15 / 20')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show full document count when all documents are available', () => {
|
||||
const dataset = createMockDataset({
|
||||
document_count: 20,
|
||||
total_available_documents: 20,
|
||||
})
|
||||
render(<DatasetCardFooter dataset={dataset} />)
|
||||
expect(screen.getByText('20')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle zero documents', () => {
|
||||
const dataset = createMockDataset({
|
||||
document_count: 0,
|
||||
total_available_documents: 0,
|
||||
})
|
||||
render(<DatasetCardFooter dataset={dataset} />)
|
||||
expect(screen.getByText('0')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styles', () => {
|
||||
it('should have correct base styling when embedding is available', () => {
|
||||
const dataset = createMockDataset({ embedding_available: true })
|
||||
const { container } = render(<DatasetCardFooter dataset={dataset} />)
|
||||
const footer = container.firstChild as HTMLElement
|
||||
expect(footer).toHaveClass('flex', 'items-center', 'gap-x-3', 'px-4')
|
||||
})
|
||||
|
||||
it('should have opacity class when embedding is not available', () => {
|
||||
const dataset = createMockDataset({ embedding_available: false })
|
||||
const { container } = render(<DatasetCardFooter dataset={dataset} />)
|
||||
const footer = container.firstChild as HTMLElement
|
||||
expect(footer).toHaveClass('opacity-30')
|
||||
})
|
||||
|
||||
it('should not have opacity class when embedding is available', () => {
|
||||
const dataset = createMockDataset({ embedding_available: true })
|
||||
const { container } = render(<DatasetCardFooter dataset={dataset} />)
|
||||
const footer = container.firstChild as HTMLElement
|
||||
expect(footer).not.toHaveClass('opacity-30')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Icons', () => {
|
||||
it('should render document icon', () => {
|
||||
const dataset = createMockDataset()
|
||||
const { container } = render(<DatasetCardFooter dataset={dataset} />)
|
||||
// RiFileTextFill icon
|
||||
const icons = container.querySelectorAll('svg')
|
||||
expect(icons.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should render robot icon for non-external provider', () => {
|
||||
const dataset = createMockDataset({ provider: 'vendor' })
|
||||
const { container } = render(<DatasetCardFooter dataset={dataset} />)
|
||||
// Should have both file and robot icons
|
||||
const icons = container.querySelectorAll('svg')
|
||||
expect(icons.length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined total_available_documents', () => {
|
||||
const dataset = createMockDataset({
|
||||
document_count: 10,
|
||||
total_available_documents: undefined,
|
||||
})
|
||||
render(<DatasetCardFooter dataset={dataset} />)
|
||||
// Should show 0 / 10 since total_available_documents defaults to 0
|
||||
expect(screen.getByText('0 / 10')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle very large numbers', () => {
|
||||
const dataset = createMockDataset({
|
||||
document_count: 999999,
|
||||
total_available_documents: 999999,
|
||||
app_count: 888888,
|
||||
})
|
||||
render(<DatasetCardFooter dataset={dataset} />)
|
||||
expect(screen.getByText('999999')).toBeInTheDocument()
|
||||
expect(screen.getByText('888888')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle zero app count', () => {
|
||||
const dataset = createMockDataset({ app_count: 0, document_count: 5, total_available_documents: 5 })
|
||||
render(<DatasetCardFooter dataset={dataset} />)
|
||||
// Both document count and app count are shown
|
||||
const zeros = screen.getAllByText('0')
|
||||
expect(zeros.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,254 @@
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import DatasetCardHeader from './dataset-card-header'
|
||||
|
||||
// Mock useFormatTimeFromNow hook
|
||||
vi.mock('@/hooks/use-format-time-from-now', () => ({
|
||||
useFormatTimeFromNow: () => ({
|
||||
formatTimeFromNow: (timestamp: number) => {
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleDateString()
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useKnowledge hook
|
||||
vi.mock('@/hooks/use-knowledge', () => ({
|
||||
useKnowledge: () => ({
|
||||
formatIndexingTechniqueAndMethod: (technique: string, _method: string) => {
|
||||
if (technique === 'high_quality')
|
||||
return 'High Quality'
|
||||
if (technique === 'economy')
|
||||
return 'Economy'
|
||||
return ''
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('DatasetCardHeader', () => {
|
||||
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
|
||||
id: 'dataset-1',
|
||||
name: 'Test Dataset',
|
||||
description: 'Test description',
|
||||
indexing_status: 'completed',
|
||||
provider: 'vendor',
|
||||
permission: DatasetPermission.allTeamMembers,
|
||||
data_source_type: DataSourceType.FILE,
|
||||
indexing_technique: IndexingType.QUALIFIED,
|
||||
embedding_available: true,
|
||||
app_count: 5,
|
||||
document_count: 10,
|
||||
total_document_count: 10,
|
||||
word_count: 1000,
|
||||
updated_at: 1609545600,
|
||||
updated_by: 'user-1',
|
||||
tags: [],
|
||||
embedding_model: 'text-embedding-ada-002',
|
||||
embedding_model_provider: 'openai',
|
||||
created_by: 'user-1',
|
||||
doc_form: ChunkingMode.text,
|
||||
runtime_mode: 'general',
|
||||
is_published: true,
|
||||
enable_api: true,
|
||||
is_multimodal: false,
|
||||
built_in_field_enabled: false,
|
||||
icon_info: {
|
||||
icon: '📙',
|
||||
icon_type: 'emoji' as const,
|
||||
icon_background: '#FFF4ED',
|
||||
icon_url: '',
|
||||
},
|
||||
retrieval_model_dict: {
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
} as DataSet['retrieval_model_dict'],
|
||||
retrieval_model: {
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
} as DataSet['retrieval_model'],
|
||||
external_knowledge_info: {
|
||||
external_knowledge_id: '',
|
||||
external_knowledge_api_id: '',
|
||||
external_knowledge_api_name: '',
|
||||
external_knowledge_api_endpoint: '',
|
||||
},
|
||||
external_retrieval_model: {
|
||||
top_k: 3,
|
||||
score_threshold: 0.5,
|
||||
score_threshold_enabled: false,
|
||||
},
|
||||
author_name: 'Test User',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const dataset = createMockDataset()
|
||||
render(<DatasetCardHeader dataset={dataset} />)
|
||||
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render dataset name', () => {
|
||||
const dataset = createMockDataset({ name: 'Custom Dataset' })
|
||||
render(<DatasetCardHeader dataset={dataset} />)
|
||||
expect(screen.getByText('Custom Dataset')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render author name', () => {
|
||||
const dataset = createMockDataset({ author_name: 'John Doe' })
|
||||
render(<DatasetCardHeader dataset={dataset} />)
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render edit time', () => {
|
||||
const dataset = createMockDataset()
|
||||
render(<DatasetCardHeader dataset={dataset} />)
|
||||
// Should contain the formatted time
|
||||
expect(screen.getByText(/segment\.editedAt/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should show external knowledge base text for external provider', () => {
|
||||
const dataset = createMockDataset({ provider: 'external' })
|
||||
render(<DatasetCardHeader dataset={dataset} />)
|
||||
expect(screen.getByText(/externalKnowledgeBase/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show chunking mode for text_model doc_form', () => {
|
||||
const dataset = createMockDataset({ doc_form: ChunkingMode.text })
|
||||
render(<DatasetCardHeader dataset={dataset} />)
|
||||
// text_model maps to 'general' in DOC_FORM_TEXT
|
||||
expect(screen.getByText(/chunkingMode\.general/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show multimodal text when is_multimodal is true', () => {
|
||||
const dataset = createMockDataset({ is_multimodal: true })
|
||||
render(<DatasetCardHeader dataset={dataset} />)
|
||||
expect(screen.getByText(/multimodal/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show multimodal when is_multimodal is false', () => {
|
||||
const dataset = createMockDataset({ is_multimodal: false })
|
||||
render(<DatasetCardHeader dataset={dataset} />)
|
||||
expect(screen.queryByText(/^multimodal$/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Icon', () => {
|
||||
it('should render AppIcon component', () => {
|
||||
const dataset = createMockDataset()
|
||||
const { container } = render(<DatasetCardHeader dataset={dataset} />)
|
||||
// AppIcon should be rendered
|
||||
const iconContainer = container.querySelector('.relative.shrink-0')
|
||||
expect(iconContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use default icon when icon_info is missing', () => {
|
||||
const dataset = createMockDataset({ icon_info: undefined })
|
||||
render(<DatasetCardHeader dataset={dataset} />)
|
||||
// Should still render without crashing
|
||||
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render chunking mode icon for published pipeline', () => {
|
||||
const dataset = createMockDataset({
|
||||
doc_form: ChunkingMode.text,
|
||||
runtime_mode: 'rag_pipeline',
|
||||
is_published: true,
|
||||
})
|
||||
const { container } = render(<DatasetCardHeader dataset={dataset} />)
|
||||
// Should have the icon badge
|
||||
const iconBadge = container.querySelector('.absolute.-bottom-1.-right-1')
|
||||
expect(iconBadge).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styles', () => {
|
||||
it('should have opacity class when embedding is not available', () => {
|
||||
const dataset = createMockDataset({ embedding_available: false })
|
||||
const { container } = render(<DatasetCardHeader dataset={dataset} />)
|
||||
const header = container.firstChild as HTMLElement
|
||||
expect(header).toHaveClass('opacity-30')
|
||||
})
|
||||
|
||||
it('should not have opacity class when embedding is available', () => {
|
||||
const dataset = createMockDataset({ embedding_available: true })
|
||||
const { container } = render(<DatasetCardHeader dataset={dataset} />)
|
||||
const header = container.firstChild as HTMLElement
|
||||
expect(header).not.toHaveClass('opacity-30')
|
||||
})
|
||||
|
||||
it('should have correct base styling', () => {
|
||||
const dataset = createMockDataset()
|
||||
const { container } = render(<DatasetCardHeader dataset={dataset} />)
|
||||
const header = container.firstChild as HTMLElement
|
||||
expect(header).toHaveClass('flex', 'items-center', 'gap-x-3', 'px-4')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DocModeInfo', () => {
|
||||
it('should show doc mode info when all conditions are met', () => {
|
||||
const dataset = createMockDataset({
|
||||
doc_form: ChunkingMode.text,
|
||||
indexing_technique: IndexingType.QUALIFIED,
|
||||
retrieval_model_dict: { search_method: RETRIEVE_METHOD.semantic } as DataSet['retrieval_model_dict'],
|
||||
runtime_mode: 'general',
|
||||
})
|
||||
render(<DatasetCardHeader dataset={dataset} />)
|
||||
expect(screen.getByText(/chunkingMode/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show doc mode info for unpublished pipeline', () => {
|
||||
const dataset = createMockDataset({
|
||||
runtime_mode: 'rag_pipeline',
|
||||
is_published: false,
|
||||
})
|
||||
render(<DatasetCardHeader dataset={dataset} />)
|
||||
// DocModeInfo should not be rendered since isShowDocModeInfo is false
|
||||
expect(screen.queryByText(/High Quality/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show doc mode info for published pipeline', () => {
|
||||
const dataset = createMockDataset({
|
||||
doc_form: ChunkingMode.text,
|
||||
indexing_technique: IndexingType.QUALIFIED,
|
||||
retrieval_model_dict: { search_method: RETRIEVE_METHOD.semantic } as DataSet['retrieval_model_dict'],
|
||||
runtime_mode: 'rag_pipeline',
|
||||
is_published: true,
|
||||
})
|
||||
render(<DatasetCardHeader dataset={dataset} />)
|
||||
expect(screen.getByText(/chunkingMode/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle missing author_name', () => {
|
||||
const dataset = createMockDataset({ author_name: undefined })
|
||||
render(<DatasetCardHeader dataset={dataset} />)
|
||||
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty name', () => {
|
||||
const dataset = createMockDataset({ name: '' })
|
||||
render(<DatasetCardHeader dataset={dataset} />)
|
||||
// Should render without crashing
|
||||
const { container } = render(<DatasetCardHeader dataset={dataset} />)
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle missing retrieval_model_dict', () => {
|
||||
const dataset = createMockDataset({ retrieval_model_dict: undefined })
|
||||
render(<DatasetCardHeader dataset={dataset} />)
|
||||
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined doc_form', () => {
|
||||
const dataset = createMockDataset({ doc_form: undefined })
|
||||
render(<DatasetCardHeader dataset={dataset} />)
|
||||
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -49,7 +49,7 @@ const DocModeInfo = ({
|
||||
|
||||
return (
|
||||
<div className="system-2xs-medium-uppercase flex items-center gap-x-3 text-text-tertiary">
|
||||
{dataset.doc_form && (
|
||||
{!!dataset.doc_form && (
|
||||
<span
|
||||
className="min-w-0 max-w-full truncate"
|
||||
title={t(`chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`, { ns: 'dataset' })}
|
||||
|
||||
@ -0,0 +1,237 @@
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
|
||||
import DatasetCardModals from './dataset-card-modals'
|
||||
|
||||
// Mock RenameDatasetModal since it's from a different feature folder
|
||||
vi.mock('../../../rename-modal', () => ({
|
||||
default: ({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess?: () => void }) => (
|
||||
show
|
||||
? (
|
||||
<div data-testid="rename-modal">
|
||||
<button onClick={onClose}>Close Rename</button>
|
||||
<button onClick={onSuccess}>Success</button>
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
),
|
||||
}))
|
||||
|
||||
describe('DatasetCardModals', () => {
|
||||
const mockDataset: DataSet = {
|
||||
id: 'dataset-1',
|
||||
name: 'Test Dataset',
|
||||
description: 'Test description',
|
||||
indexing_status: 'completed',
|
||||
provider: 'vendor',
|
||||
permission: DatasetPermission.allTeamMembers,
|
||||
data_source_type: DataSourceType.FILE,
|
||||
indexing_technique: IndexingType.QUALIFIED,
|
||||
embedding_available: true,
|
||||
app_count: 5,
|
||||
document_count: 10,
|
||||
total_document_count: 10,
|
||||
word_count: 1000,
|
||||
updated_at: 1609545600,
|
||||
updated_by: 'user-1',
|
||||
tags: [],
|
||||
embedding_model: 'text-embedding-ada-002',
|
||||
embedding_model_provider: 'openai',
|
||||
created_by: 'user-1',
|
||||
doc_form: ChunkingMode.text,
|
||||
runtime_mode: 'general',
|
||||
enable_api: true,
|
||||
is_multimodal: false,
|
||||
built_in_field_enabled: false,
|
||||
icon_info: {
|
||||
icon: '📙',
|
||||
icon_type: 'emoji' as const,
|
||||
icon_background: '#FFF4ED',
|
||||
icon_url: '',
|
||||
},
|
||||
retrieval_model_dict: {} as DataSet['retrieval_model_dict'],
|
||||
retrieval_model: {} as DataSet['retrieval_model'],
|
||||
external_knowledge_info: {
|
||||
external_knowledge_id: '',
|
||||
external_knowledge_api_id: '',
|
||||
external_knowledge_api_name: '',
|
||||
external_knowledge_api_endpoint: '',
|
||||
},
|
||||
external_retrieval_model: {
|
||||
top_k: 3,
|
||||
score_threshold: 0.5,
|
||||
score_threshold_enabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
dataset: mockDataset,
|
||||
modalState: {
|
||||
showRenameModal: false,
|
||||
showConfirmDelete: false,
|
||||
confirmMessage: '',
|
||||
},
|
||||
onCloseRename: vi.fn(),
|
||||
onCloseConfirm: vi.fn(),
|
||||
onConfirmDelete: vi.fn(),
|
||||
onSuccess: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing when no modals are shown', () => {
|
||||
const { container } = render(<DatasetCardModals {...defaultProps} />)
|
||||
// Should render empty fragment
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('should render rename modal when showRenameModal is true', () => {
|
||||
render(
|
||||
<DatasetCardModals
|
||||
{...defaultProps}
|
||||
modalState={{ ...defaultProps.modalState, showRenameModal: true }}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('rename-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render confirm modal when showConfirmDelete is true', () => {
|
||||
render(
|
||||
<DatasetCardModals
|
||||
{...defaultProps}
|
||||
modalState={{
|
||||
...defaultProps.modalState,
|
||||
showConfirmDelete: true,
|
||||
confirmMessage: 'Are you sure?',
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
// Confirm modal should be rendered
|
||||
expect(screen.getByText('Are you sure?')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should pass dataset to rename modal', () => {
|
||||
render(
|
||||
<DatasetCardModals
|
||||
{...defaultProps}
|
||||
modalState={{ ...defaultProps.modalState, showRenameModal: true }}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('rename-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display confirmMessage in confirm modal', () => {
|
||||
const confirmMessage = 'This is a custom confirm message'
|
||||
render(
|
||||
<DatasetCardModals
|
||||
{...defaultProps}
|
||||
modalState={{
|
||||
...defaultProps.modalState,
|
||||
showConfirmDelete: true,
|
||||
confirmMessage,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText(confirmMessage)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onCloseRename when closing rename modal', () => {
|
||||
const onCloseRename = vi.fn()
|
||||
render(
|
||||
<DatasetCardModals
|
||||
{...defaultProps}
|
||||
onCloseRename={onCloseRename}
|
||||
modalState={{ ...defaultProps.modalState, showRenameModal: true }}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Close Rename'))
|
||||
expect(onCloseRename).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onConfirmDelete when confirming deletion', () => {
|
||||
const onConfirmDelete = vi.fn()
|
||||
render(
|
||||
<DatasetCardModals
|
||||
{...defaultProps}
|
||||
onConfirmDelete={onConfirmDelete}
|
||||
modalState={{
|
||||
...defaultProps.modalState,
|
||||
showConfirmDelete: true,
|
||||
confirmMessage: 'Delete?',
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Find and click the confirm button
|
||||
const confirmButton = screen.getByRole('button', { name: /confirm|ok|delete/i })
|
||||
|| screen.getAllByRole('button').find(btn => btn.textContent?.toLowerCase().includes('confirm'))
|
||||
if (confirmButton)
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
expect(onConfirmDelete).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onCloseConfirm when canceling deletion', () => {
|
||||
const onCloseConfirm = vi.fn()
|
||||
render(
|
||||
<DatasetCardModals
|
||||
{...defaultProps}
|
||||
onCloseConfirm={onCloseConfirm}
|
||||
modalState={{
|
||||
...defaultProps.modalState,
|
||||
showConfirmDelete: true,
|
||||
confirmMessage: 'Delete?',
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Find and click the cancel button
|
||||
const cancelButton = screen.getByRole('button', { name: /cancel/i })
|
||||
fireEvent.click(cancelButton)
|
||||
|
||||
expect(onCloseConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle both modals being true (render both)', () => {
|
||||
render(
|
||||
<DatasetCardModals
|
||||
{...defaultProps}
|
||||
modalState={{
|
||||
showRenameModal: true,
|
||||
showConfirmDelete: true,
|
||||
confirmMessage: 'Delete this dataset?',
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('rename-modal')).toBeInTheDocument()
|
||||
expect(screen.getByText('Delete this dataset?')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty confirmMessage', () => {
|
||||
render(
|
||||
<DatasetCardModals
|
||||
{...defaultProps}
|
||||
modalState={{
|
||||
...defaultProps.modalState,
|
||||
showConfirmDelete: true,
|
||||
confirmMessage: '',
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
// Should still render confirm modal
|
||||
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,107 @@
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
|
||||
import Description from './description'
|
||||
|
||||
describe('Description', () => {
|
||||
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
|
||||
id: 'dataset-1',
|
||||
name: 'Test Dataset',
|
||||
description: 'This is a test description',
|
||||
provider: 'vendor',
|
||||
permission: DatasetPermission.allTeamMembers,
|
||||
data_source_type: DataSourceType.FILE,
|
||||
indexing_technique: IndexingType.QUALIFIED,
|
||||
embedding_available: true,
|
||||
app_count: 5,
|
||||
document_count: 10,
|
||||
word_count: 1000,
|
||||
created_at: 1609459200,
|
||||
updated_at: 1609545600,
|
||||
tags: [],
|
||||
embedding_model: 'text-embedding-ada-002',
|
||||
embedding_model_provider: 'openai',
|
||||
created_by: 'user-1',
|
||||
doc_form: ChunkingMode.text,
|
||||
...overrides,
|
||||
} as DataSet)
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const dataset = createMockDataset()
|
||||
render(<Description dataset={dataset} />)
|
||||
expect(screen.getByText('This is a test description')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the description text', () => {
|
||||
const dataset = createMockDataset({ description: 'Custom description text' })
|
||||
render(<Description dataset={dataset} />)
|
||||
expect(screen.getByText('Custom description text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should set title attribute for tooltip', () => {
|
||||
const dataset = createMockDataset({ description: 'Tooltip description' })
|
||||
render(<Description dataset={dataset} />)
|
||||
const descDiv = screen.getByTitle('Tooltip description')
|
||||
expect(descDiv).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should display dataset description', () => {
|
||||
const description = 'A very detailed description of this dataset'
|
||||
const dataset = createMockDataset({ description })
|
||||
render(<Description dataset={dataset} />)
|
||||
expect(screen.getByText(description)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styles', () => {
|
||||
it('should have correct base styling when embedding is available', () => {
|
||||
const dataset = createMockDataset({ embedding_available: true })
|
||||
render(<Description dataset={dataset} />)
|
||||
const descDiv = screen.getByTitle(dataset.description)
|
||||
expect(descDiv).toHaveClass('system-xs-regular', 'line-clamp-2', 'h-10', 'px-4', 'py-1', 'text-text-tertiary')
|
||||
})
|
||||
|
||||
it('should have opacity class when embedding is not available', () => {
|
||||
const dataset = createMockDataset({ embedding_available: false })
|
||||
render(<Description dataset={dataset} />)
|
||||
const descDiv = screen.getByTitle(dataset.description)
|
||||
expect(descDiv).toHaveClass('opacity-30')
|
||||
})
|
||||
|
||||
it('should not have opacity class when embedding is available', () => {
|
||||
const dataset = createMockDataset({ embedding_available: true })
|
||||
render(<Description dataset={dataset} />)
|
||||
const descDiv = screen.getByTitle(dataset.description)
|
||||
expect(descDiv).not.toHaveClass('opacity-30')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty description', () => {
|
||||
const dataset = createMockDataset({ description: '' })
|
||||
render(<Description dataset={dataset} />)
|
||||
const descDiv = screen.getByTitle('')
|
||||
expect(descDiv).toBeInTheDocument()
|
||||
expect(descDiv).toHaveTextContent('')
|
||||
})
|
||||
|
||||
it('should handle very long description', () => {
|
||||
const longDescription = 'A'.repeat(500)
|
||||
const dataset = createMockDataset({ description: longDescription })
|
||||
render(<Description dataset={dataset} />)
|
||||
expect(screen.getByText(longDescription)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle description with special characters', () => {
|
||||
const description = '<script>alert("XSS")</script> & "quotes" \'single\''
|
||||
const dataset = createMockDataset({ description })
|
||||
render(<Description dataset={dataset} />)
|
||||
expect(screen.getByText(description)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,162 @@
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { fireEvent, render } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
|
||||
import OperationsPopover from './operations-popover'
|
||||
|
||||
describe('OperationsPopover', () => {
|
||||
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
|
||||
id: 'dataset-1',
|
||||
name: 'Test Dataset',
|
||||
description: 'Test description',
|
||||
provider: 'vendor',
|
||||
permission: DatasetPermission.allTeamMembers,
|
||||
data_source_type: DataSourceType.FILE,
|
||||
indexing_technique: IndexingType.QUALIFIED,
|
||||
embedding_available: true,
|
||||
app_count: 5,
|
||||
document_count: 10,
|
||||
word_count: 1000,
|
||||
updated_at: 1609545600,
|
||||
tags: [],
|
||||
embedding_model: 'text-embedding-ada-002',
|
||||
embedding_model_provider: 'openai',
|
||||
created_by: 'user-1',
|
||||
doc_form: ChunkingMode.text,
|
||||
runtime_mode: 'general',
|
||||
...overrides,
|
||||
} as DataSet)
|
||||
|
||||
const defaultProps = {
|
||||
dataset: createMockDataset(),
|
||||
isCurrentWorkspaceDatasetOperator: false,
|
||||
openRenameModal: vi.fn(),
|
||||
handleExportPipeline: vi.fn(),
|
||||
detectIsUsedByApp: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<OperationsPopover {...defaultProps} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the more icon button', () => {
|
||||
const { container } = render(<OperationsPopover {...defaultProps} />)
|
||||
const moreIcon = container.querySelector('svg')
|
||||
expect(moreIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render in hidden state initially (group-hover)', () => {
|
||||
const { container } = render(<OperationsPopover {...defaultProps} />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('hidden', 'group-hover:block')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should show delete option when not workspace dataset operator', () => {
|
||||
render(<OperationsPopover {...defaultProps} isCurrentWorkspaceDatasetOperator={false} />)
|
||||
|
||||
// Click to open popover
|
||||
const triggerButton = document.querySelector('[class*="cursor-pointer"]')
|
||||
if (triggerButton)
|
||||
fireEvent.click(triggerButton)
|
||||
|
||||
// showDelete should be true (inverse of isCurrentWorkspaceDatasetOperator)
|
||||
// This means delete operation will be visible
|
||||
})
|
||||
|
||||
it('should hide delete option when is workspace dataset operator', () => {
|
||||
render(<OperationsPopover {...defaultProps} isCurrentWorkspaceDatasetOperator={true} />)
|
||||
|
||||
// Click to open popover
|
||||
const triggerButton = document.querySelector('[class*="cursor-pointer"]')
|
||||
if (triggerButton)
|
||||
fireEvent.click(triggerButton)
|
||||
|
||||
// showDelete should be false
|
||||
})
|
||||
|
||||
it('should show export pipeline when runtime_mode is rag_pipeline', () => {
|
||||
const dataset = createMockDataset({ runtime_mode: 'rag_pipeline' })
|
||||
render(<OperationsPopover {...defaultProps} dataset={dataset} />)
|
||||
|
||||
// Click to open popover
|
||||
const triggerButton = document.querySelector('[class*="cursor-pointer"]')
|
||||
if (triggerButton)
|
||||
fireEvent.click(triggerButton)
|
||||
|
||||
// showExportPipeline should be true
|
||||
})
|
||||
|
||||
it('should hide export pipeline when runtime_mode is not rag_pipeline', () => {
|
||||
const dataset = createMockDataset({ runtime_mode: 'general' })
|
||||
render(<OperationsPopover {...defaultProps} dataset={dataset} />)
|
||||
|
||||
// Click to open popover
|
||||
const triggerButton = document.querySelector('[class*="cursor-pointer"]')
|
||||
if (triggerButton)
|
||||
fireEvent.click(triggerButton)
|
||||
|
||||
// showExportPipeline should be false
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styles', () => {
|
||||
it('should have correct positioning styles', () => {
|
||||
const { container } = render(<OperationsPopover {...defaultProps} />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('absolute', 'right-2', 'top-2', 'z-[15]')
|
||||
})
|
||||
|
||||
it('should have icon with correct size classes', () => {
|
||||
const { container } = render(<OperationsPopover {...defaultProps} />)
|
||||
const icon = container.querySelector('svg')
|
||||
expect(icon).toHaveClass('h-5', 'w-5', 'text-text-tertiary')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should pass openRenameModal to Operations', () => {
|
||||
const openRenameModal = vi.fn()
|
||||
render(<OperationsPopover {...defaultProps} openRenameModal={openRenameModal} />)
|
||||
|
||||
// The openRenameModal should be passed to Operations component
|
||||
expect(openRenameModal).not.toHaveBeenCalled() // Initially not called
|
||||
})
|
||||
|
||||
it('should pass handleExportPipeline to Operations', () => {
|
||||
const handleExportPipeline = vi.fn()
|
||||
render(<OperationsPopover {...defaultProps} handleExportPipeline={handleExportPipeline} />)
|
||||
|
||||
expect(handleExportPipeline).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should pass detectIsUsedByApp to Operations', () => {
|
||||
const detectIsUsedByApp = vi.fn()
|
||||
render(<OperationsPopover {...defaultProps} detectIsUsedByApp={detectIsUsedByApp} />)
|
||||
|
||||
expect(detectIsUsedByApp).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle dataset with external provider', () => {
|
||||
const dataset = createMockDataset({ provider: 'external' })
|
||||
const { container } = render(<OperationsPopover {...defaultProps} dataset={dataset} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle dataset with undefined runtime_mode', () => {
|
||||
const dataset = createMockDataset({ runtime_mode: undefined })
|
||||
const { container } = render(<OperationsPopover {...defaultProps} dataset={dataset} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,198 @@
|
||||
import type { Tag } from '@/app/components/base/tag-management/constant'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useRef } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
|
||||
import TagArea from './tag-area'
|
||||
|
||||
// Mock TagSelector as it's a complex component from base
|
||||
vi.mock('@/app/components/base/tag-management/selector', () => ({
|
||||
default: ({ value, selectedTags, onCacheUpdate, onChange }: {
|
||||
value: string[]
|
||||
selectedTags: Tag[]
|
||||
onCacheUpdate: (tags: Tag[]) => void
|
||||
onChange?: () => void
|
||||
}) => (
|
||||
<div data-testid="tag-selector">
|
||||
<div data-testid="tag-values">{value.join(',')}</div>
|
||||
<div data-testid="selected-count">
|
||||
{selectedTags.length}
|
||||
{' '}
|
||||
tags
|
||||
</div>
|
||||
<button onClick={() => onCacheUpdate([{ id: 'new-tag', name: 'New Tag', type: 'knowledge', binding_count: 0 }])}>
|
||||
Update Tags
|
||||
</button>
|
||||
<button onClick={onChange}>
|
||||
Trigger Change
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('TagArea', () => {
|
||||
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
|
||||
id: 'dataset-1',
|
||||
name: 'Test Dataset',
|
||||
description: 'Test description',
|
||||
provider: 'vendor',
|
||||
permission: DatasetPermission.allTeamMembers,
|
||||
data_source_type: DataSourceType.FILE,
|
||||
indexing_technique: IndexingType.QUALIFIED,
|
||||
embedding_available: true,
|
||||
app_count: 5,
|
||||
document_count: 10,
|
||||
word_count: 1000,
|
||||
updated_at: 1609545600,
|
||||
tags: [],
|
||||
embedding_model: 'text-embedding-ada-002',
|
||||
embedding_model_provider: 'openai',
|
||||
created_by: 'user-1',
|
||||
doc_form: ChunkingMode.text,
|
||||
...overrides,
|
||||
} as DataSet)
|
||||
|
||||
const mockTags: Tag[] = [
|
||||
{ id: 'tag-1', name: 'Tag 1', type: 'knowledge', binding_count: 0 },
|
||||
{ id: 'tag-2', name: 'Tag 2', type: 'knowledge', binding_count: 0 },
|
||||
]
|
||||
|
||||
const defaultProps = {
|
||||
dataset: createMockDataset(),
|
||||
tags: mockTags,
|
||||
setTags: vi.fn(),
|
||||
onSuccess: vi.fn(),
|
||||
isHoveringTagSelector: false,
|
||||
onClick: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<TagArea {...defaultProps} />)
|
||||
expect(screen.getByTestId('tag-selector')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render TagSelector with correct value', () => {
|
||||
render(<TagArea {...defaultProps} />)
|
||||
expect(screen.getByTestId('tag-values')).toHaveTextContent('tag-1,tag-2')
|
||||
})
|
||||
|
||||
it('should display selected tags count', () => {
|
||||
render(<TagArea {...defaultProps} />)
|
||||
expect(screen.getByTestId('selected-count')).toHaveTextContent('2 tags')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should pass dataset id to TagSelector', () => {
|
||||
const dataset = createMockDataset({ id: 'custom-dataset-id' })
|
||||
render(<TagArea {...defaultProps} dataset={dataset} />)
|
||||
expect(screen.getByTestId('tag-selector')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with empty tags', () => {
|
||||
render(<TagArea {...defaultProps} tags={[]} />)
|
||||
expect(screen.getByTestId('selected-count')).toHaveTextContent('0 tags')
|
||||
})
|
||||
|
||||
it('should forward ref correctly', () => {
|
||||
const TestComponent = () => {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
return <TagArea {...defaultProps} ref={ref} />
|
||||
}
|
||||
render(<TestComponent />)
|
||||
expect(screen.getByTestId('tag-selector')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClick when container is clicked', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(<TagArea {...defaultProps} onClick={onClick} />)
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
fireEvent.click(wrapper)
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call setTags when tags are updated', () => {
|
||||
const setTags = vi.fn()
|
||||
render(<TagArea {...defaultProps} setTags={setTags} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Update Tags'))
|
||||
|
||||
expect(setTags).toHaveBeenCalledWith([{ id: 'new-tag', name: 'New Tag', type: 'knowledge', binding_count: 0 }])
|
||||
})
|
||||
|
||||
it('should call onSuccess when onChange is triggered', () => {
|
||||
const onSuccess = vi.fn()
|
||||
render(<TagArea {...defaultProps} onSuccess={onSuccess} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Trigger Change'))
|
||||
|
||||
expect(onSuccess).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styles', () => {
|
||||
it('should have opacity class when embedding is not available', () => {
|
||||
const dataset = createMockDataset({ embedding_available: false })
|
||||
const { container } = render(<TagArea {...defaultProps} dataset={dataset} />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('opacity-30')
|
||||
})
|
||||
|
||||
it('should not have opacity class when embedding is available', () => {
|
||||
const dataset = createMockDataset({ embedding_available: true })
|
||||
const { container } = render(<TagArea {...defaultProps} dataset={dataset} />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).not.toHaveClass('opacity-30')
|
||||
})
|
||||
|
||||
it('should show mask when not hovering and has tags', () => {
|
||||
const { container } = render(<TagArea {...defaultProps} isHoveringTagSelector={false} tags={mockTags} />)
|
||||
const maskDiv = container.querySelector('.bg-tag-selector-mask-bg')
|
||||
expect(maskDiv).toBeInTheDocument()
|
||||
expect(maskDiv).not.toHaveClass('hidden')
|
||||
})
|
||||
|
||||
it('should hide mask when hovering', () => {
|
||||
const { container } = render(<TagArea {...defaultProps} isHoveringTagSelector={true} />)
|
||||
// When hovering, the mask div should have 'hidden' class
|
||||
const maskDiv = container.querySelector('.absolute.right-0.top-0')
|
||||
expect(maskDiv).toHaveClass('hidden')
|
||||
})
|
||||
|
||||
it('should make TagSelector visible when tags exist', () => {
|
||||
const { container } = render(<TagArea {...defaultProps} tags={mockTags} />)
|
||||
const tagSelectorWrapper = container.querySelector('.visible')
|
||||
expect(tagSelectorWrapper).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined onSuccess', () => {
|
||||
render(<TagArea {...defaultProps} onSuccess={undefined} />)
|
||||
// Should not throw when clicking Trigger Change
|
||||
expect(() => fireEvent.click(screen.getByText('Trigger Change'))).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle many tags', () => {
|
||||
const manyTags: Tag[] = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: `tag-${i}`,
|
||||
name: `Tag ${i}`,
|
||||
type: 'knowledge' as const,
|
||||
binding_count: 0,
|
||||
}))
|
||||
render(<TagArea {...defaultProps} tags={manyTags} />)
|
||||
expect(screen.getByTestId('selected-count')).toHaveTextContent('20 tags')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,427 @@
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
|
||||
import { useDatasetCardState } from './use-dataset-card-state'
|
||||
|
||||
// Mock Toast
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock service hooks
|
||||
const mockCheckUsage = vi.fn()
|
||||
const mockDeleteDataset = vi.fn()
|
||||
const mockExportPipeline = vi.fn()
|
||||
|
||||
vi.mock('@/service/use-dataset-card', () => ({
|
||||
useCheckDatasetUsage: () => ({ mutateAsync: mockCheckUsage }),
|
||||
useDeleteDataset: () => ({ mutateAsync: mockDeleteDataset }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-pipeline', () => ({
|
||||
useExportPipelineDSL: () => ({ mutateAsync: mockExportPipeline }),
|
||||
}))
|
||||
|
||||
describe('useDatasetCardState', () => {
|
||||
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
|
||||
id: 'dataset-1',
|
||||
name: 'Test Dataset',
|
||||
description: 'Test description',
|
||||
provider: 'vendor',
|
||||
permission: DatasetPermission.allTeamMembers,
|
||||
data_source_type: DataSourceType.FILE,
|
||||
indexing_technique: IndexingType.QUALIFIED,
|
||||
embedding_available: true,
|
||||
app_count: 5,
|
||||
document_count: 10,
|
||||
word_count: 1000,
|
||||
created_at: 1609459200,
|
||||
updated_at: 1609545600,
|
||||
tags: [{ id: 'tag-1', name: 'Tag 1', type: 'knowledge', binding_count: 0 }],
|
||||
embedding_model: 'text-embedding-ada-002',
|
||||
embedding_model_provider: 'openai',
|
||||
created_by: 'user-1',
|
||||
doc_form: ChunkingMode.text,
|
||||
pipeline_id: 'pipeline-1',
|
||||
...overrides,
|
||||
} as DataSet)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCheckUsage.mockResolvedValue({ is_using: false })
|
||||
mockDeleteDataset.mockResolvedValue({})
|
||||
mockExportPipeline.mockResolvedValue({ data: 'yaml content' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('Initial State', () => {
|
||||
it('should return tags from dataset', () => {
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
expect(result.current.tags).toEqual(dataset.tags)
|
||||
})
|
||||
|
||||
it('should have initial modal state closed', () => {
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
expect(result.current.modalState.showRenameModal).toBe(false)
|
||||
expect(result.current.modalState.showConfirmDelete).toBe(false)
|
||||
expect(result.current.modalState.confirmMessage).toBe('')
|
||||
})
|
||||
|
||||
it('should not be exporting initially', () => {
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
expect(result.current.exporting).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tags State', () => {
|
||||
it('should update tags when setTags is called', () => {
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.setTags([{ id: 'tag-2', name: 'Tag 2', type: 'knowledge', binding_count: 0 }])
|
||||
})
|
||||
|
||||
expect(result.current.tags).toEqual([{ id: 'tag-2', name: 'Tag 2', type: 'knowledge', binding_count: 0 }])
|
||||
})
|
||||
|
||||
it('should sync tags when dataset tags change', () => {
|
||||
const dataset = createMockDataset()
|
||||
const { result, rerender } = renderHook(
|
||||
({ dataset }) => useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
{ initialProps: { dataset } },
|
||||
)
|
||||
|
||||
const newTags = [{ id: 'tag-3', name: 'Tag 3', type: 'knowledge', binding_count: 0 }]
|
||||
const updatedDataset = createMockDataset({ tags: newTags })
|
||||
|
||||
rerender({ dataset: updatedDataset })
|
||||
|
||||
expect(result.current.tags).toEqual(newTags)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Modal Handlers', () => {
|
||||
it('should open rename modal when openRenameModal is called', () => {
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.openRenameModal()
|
||||
})
|
||||
|
||||
expect(result.current.modalState.showRenameModal).toBe(true)
|
||||
})
|
||||
|
||||
it('should close rename modal when closeRenameModal is called', () => {
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.openRenameModal()
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.closeRenameModal()
|
||||
})
|
||||
|
||||
expect(result.current.modalState.showRenameModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should close confirm delete modal when closeConfirmDelete is called', () => {
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
// First trigger show confirm delete
|
||||
act(() => {
|
||||
result.current.detectIsUsedByApp()
|
||||
})
|
||||
|
||||
waitFor(() => {
|
||||
expect(result.current.modalState.showConfirmDelete).toBe(true)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.closeConfirmDelete()
|
||||
})
|
||||
|
||||
expect(result.current.modalState.showConfirmDelete).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectIsUsedByApp', () => {
|
||||
it('should check usage and show confirm modal with not-in-use message', async () => {
|
||||
mockCheckUsage.mockResolvedValue({ is_using: false })
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.detectIsUsedByApp()
|
||||
})
|
||||
|
||||
expect(mockCheckUsage).toHaveBeenCalledWith('dataset-1')
|
||||
expect(result.current.modalState.showConfirmDelete).toBe(true)
|
||||
expect(result.current.modalState.confirmMessage).toContain('deleteDatasetConfirmContent')
|
||||
})
|
||||
|
||||
it('should show in-use message when dataset is used by app', async () => {
|
||||
mockCheckUsage.mockResolvedValue({ is_using: true })
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.detectIsUsedByApp()
|
||||
})
|
||||
|
||||
expect(result.current.modalState.confirmMessage).toContain('datasetUsedByApp')
|
||||
})
|
||||
})
|
||||
|
||||
describe('onConfirmDelete', () => {
|
||||
it('should delete dataset and call onSuccess', async () => {
|
||||
const onSuccess = vi.fn()
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess }),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onConfirmDelete()
|
||||
})
|
||||
|
||||
expect(mockDeleteDataset).toHaveBeenCalledWith('dataset-1')
|
||||
expect(onSuccess).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should close confirm modal after delete', async () => {
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
// First open confirm modal
|
||||
await act(async () => {
|
||||
await result.current.detectIsUsedByApp()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onConfirmDelete()
|
||||
})
|
||||
|
||||
expect(result.current.modalState.showConfirmDelete).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleExportPipeline', () => {
|
||||
it('should not export if pipeline_id is missing', async () => {
|
||||
const dataset = createMockDataset({ pipeline_id: undefined })
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExportPipeline()
|
||||
})
|
||||
|
||||
expect(mockExportPipeline).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should export pipeline with correct parameters', async () => {
|
||||
const dataset = createMockDataset({ pipeline_id: 'pipeline-1', name: 'Test Pipeline' })
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExportPipeline(true)
|
||||
})
|
||||
|
||||
expect(mockExportPipeline).toHaveBeenCalledWith({
|
||||
pipelineId: 'pipeline-1',
|
||||
include: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty tags array', () => {
|
||||
const dataset = createMockDataset({ tags: [] })
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
expect(result.current.tags).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle undefined onSuccess', async () => {
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset }),
|
||||
)
|
||||
|
||||
// Should not throw when onSuccess is undefined
|
||||
await act(async () => {
|
||||
await result.current.onConfirmDelete()
|
||||
})
|
||||
|
||||
expect(mockDeleteDataset).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should show error toast when export pipeline fails', async () => {
|
||||
const Toast = await import('@/app/components/base/toast')
|
||||
mockExportPipeline.mockRejectedValue(new Error('Export failed'))
|
||||
|
||||
const dataset = createMockDataset({ pipeline_id: 'pipeline-1' })
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExportPipeline()
|
||||
})
|
||||
|
||||
expect(Toast.default.notify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: expect.any(String),
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle Response error in detectIsUsedByApp', async () => {
|
||||
const Toast = await import('@/app/components/base/toast')
|
||||
const mockResponse = new Response(JSON.stringify({ message: 'API Error' }), {
|
||||
status: 400,
|
||||
})
|
||||
mockCheckUsage.mockRejectedValue(mockResponse)
|
||||
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.detectIsUsedByApp()
|
||||
})
|
||||
|
||||
expect(Toast.default.notify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: expect.stringContaining('API Error'),
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle generic Error in detectIsUsedByApp', async () => {
|
||||
const Toast = await import('@/app/components/base/toast')
|
||||
mockCheckUsage.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.detectIsUsedByApp()
|
||||
})
|
||||
|
||||
expect(Toast.default.notify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'Network error',
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle error without message in detectIsUsedByApp', async () => {
|
||||
const Toast = await import('@/app/components/base/toast')
|
||||
mockCheckUsage.mockRejectedValue({})
|
||||
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.detectIsUsedByApp()
|
||||
})
|
||||
|
||||
expect(Toast.default.notify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'Unknown error',
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle exporting state correctly', async () => {
|
||||
const dataset = createMockDataset({ pipeline_id: 'pipeline-1' })
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
// Exporting should initially be false
|
||||
expect(result.current.exporting).toBe(false)
|
||||
|
||||
// Export should work when not exporting
|
||||
await act(async () => {
|
||||
await result.current.handleExportPipeline()
|
||||
})
|
||||
|
||||
expect(mockExportPipeline).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reset exporting state after export completes', async () => {
|
||||
const dataset = createMockDataset({ pipeline_id: 'pipeline-1' })
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExportPipeline()
|
||||
})
|
||||
|
||||
expect(result.current.exporting).toBe(false)
|
||||
})
|
||||
|
||||
it('should reset exporting state even when export fails', async () => {
|
||||
mockExportPipeline.mockRejectedValue(new Error('Export failed'))
|
||||
|
||||
const dataset = createMockDataset({ pipeline_id: 'pipeline-1' })
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExportPipeline()
|
||||
})
|
||||
|
||||
expect(result.current.exporting).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
256
web/app/components/datasets/list/dataset-card/index.spec.tsx
Normal file
256
web/app/components/datasets/list/dataset-card/index.spec.tsx
Normal file
@ -0,0 +1,256 @@
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import DatasetCard from './index'
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = vi.fn()
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}))
|
||||
|
||||
// Mock ahooks useHover
|
||||
vi.mock('ahooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('ahooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useHover: () => false,
|
||||
}
|
||||
})
|
||||
|
||||
// Mock app context
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useSelector: () => false,
|
||||
}))
|
||||
|
||||
// Mock the useDatasetCardState hook
|
||||
vi.mock('./hooks/use-dataset-card-state', () => ({
|
||||
useDatasetCardState: () => ({
|
||||
tags: [],
|
||||
setTags: vi.fn(),
|
||||
modalState: {
|
||||
showRenameModal: false,
|
||||
showConfirmDelete: false,
|
||||
confirmMessage: '',
|
||||
},
|
||||
openRenameModal: vi.fn(),
|
||||
closeRenameModal: vi.fn(),
|
||||
closeConfirmDelete: vi.fn(),
|
||||
handleExportPipeline: vi.fn(),
|
||||
detectIsUsedByApp: vi.fn(),
|
||||
onConfirmDelete: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock the RenameDatasetModal
|
||||
vi.mock('../../rename-modal', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
// Mock useFormatTimeFromNow hook
|
||||
vi.mock('@/hooks/use-format-time-from-now', () => ({
|
||||
useFormatTimeFromNow: () => ({
|
||||
formatTimeFromNow: (timestamp: number) => {
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleDateString()
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useKnowledge hook
|
||||
vi.mock('@/hooks/use-knowledge', () => ({
|
||||
useKnowledge: () => ({
|
||||
formatIndexingTechniqueAndMethod: () => 'High Quality',
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('DatasetCard', () => {
|
||||
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
|
||||
id: 'dataset-1',
|
||||
name: 'Test Dataset',
|
||||
description: 'Test description',
|
||||
provider: 'vendor',
|
||||
permission: DatasetPermission.allTeamMembers,
|
||||
data_source_type: DataSourceType.FILE,
|
||||
indexing_technique: IndexingType.QUALIFIED,
|
||||
embedding_available: true,
|
||||
app_count: 5,
|
||||
document_count: 10,
|
||||
word_count: 1000,
|
||||
created_at: 1609459200,
|
||||
updated_at: 1609545600,
|
||||
tags: [],
|
||||
embedding_model: 'text-embedding-ada-002',
|
||||
embedding_model_provider: 'openai',
|
||||
created_by: 'user-1',
|
||||
doc_form: ChunkingMode.text,
|
||||
runtime_mode: 'general',
|
||||
is_published: true,
|
||||
total_available_documents: 10,
|
||||
icon_info: {
|
||||
icon: '📙',
|
||||
icon_type: 'emoji' as const,
|
||||
icon_background: '#FFF4ED',
|
||||
icon_url: '',
|
||||
},
|
||||
retrieval_model_dict: {
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
},
|
||||
author_name: 'Test User',
|
||||
...overrides,
|
||||
} as DataSet)
|
||||
|
||||
const defaultProps = {
|
||||
dataset: createMockDataset(),
|
||||
onSuccess: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<DatasetCard {...defaultProps} />)
|
||||
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render dataset name', () => {
|
||||
const dataset = createMockDataset({ name: 'Custom Dataset Name' })
|
||||
render(<DatasetCard {...defaultProps} dataset={dataset} />)
|
||||
expect(screen.getByText('Custom Dataset Name')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render dataset description', () => {
|
||||
const dataset = createMockDataset({ description: 'Custom Description' })
|
||||
render(<DatasetCard {...defaultProps} dataset={dataset} />)
|
||||
expect(screen.getByText('Custom Description')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render document count', () => {
|
||||
render(<DatasetCard {...defaultProps} />)
|
||||
expect(screen.getByText('10')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app count', () => {
|
||||
render(<DatasetCard {...defaultProps} />)
|
||||
expect(screen.getByText('5')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should handle external provider', () => {
|
||||
const dataset = createMockDataset({ provider: 'external' })
|
||||
render(<DatasetCard {...defaultProps} dataset={dataset} />)
|
||||
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle rag_pipeline runtime mode', () => {
|
||||
const dataset = createMockDataset({ runtime_mode: 'rag_pipeline', is_published: true })
|
||||
render(<DatasetCard {...defaultProps} dataset={dataset} />)
|
||||
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should navigate to documents page on click for regular dataset', () => {
|
||||
const dataset = createMockDataset({ provider: 'vendor' })
|
||||
render(<DatasetCard {...defaultProps} dataset={dataset} />)
|
||||
|
||||
const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]')
|
||||
fireEvent.click(card!)
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents')
|
||||
})
|
||||
|
||||
it('should navigate to hitTesting page on click for external provider', () => {
|
||||
const dataset = createMockDataset({ provider: 'external' })
|
||||
render(<DatasetCard {...defaultProps} dataset={dataset} />)
|
||||
|
||||
const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]')
|
||||
fireEvent.click(card!)
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/hitTesting')
|
||||
})
|
||||
|
||||
it('should navigate to pipeline page when pipeline is unpublished', () => {
|
||||
const dataset = createMockDataset({ runtime_mode: 'rag_pipeline', is_published: false })
|
||||
render(<DatasetCard {...defaultProps} dataset={dataset} />)
|
||||
|
||||
const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]')
|
||||
fireEvent.click(card!)
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/pipeline')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styles', () => {
|
||||
it('should have correct card styling', () => {
|
||||
render(<DatasetCard {...defaultProps} />)
|
||||
const card = screen.getByText('Test Dataset').closest('.group')
|
||||
expect(card).toHaveClass('h-[190px]', 'cursor-pointer', 'flex-col', 'rounded-xl')
|
||||
})
|
||||
|
||||
it('should have data-disable-nprogress attribute', () => {
|
||||
render(<DatasetCard {...defaultProps} />)
|
||||
const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]')
|
||||
expect(card).toHaveAttribute('data-disable-nprogress', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle dataset without description', () => {
|
||||
const dataset = createMockDataset({ description: '' })
|
||||
render(<DatasetCard {...defaultProps} dataset={dataset} />)
|
||||
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle embedding not available', () => {
|
||||
const dataset = createMockDataset({ embedding_available: false })
|
||||
render(<DatasetCard {...defaultProps} dataset={dataset} />)
|
||||
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined onSuccess', () => {
|
||||
render(<DatasetCard dataset={createMockDataset()} />)
|
||||
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tag Area Click', () => {
|
||||
it('should stop propagation and prevent default when tag area is clicked', () => {
|
||||
render(<DatasetCard {...defaultProps} />)
|
||||
|
||||
// Find tag area element (it's inside the card)
|
||||
const tagAreaWrapper = document.querySelector('[class*="px-3"]')
|
||||
if (tagAreaWrapper) {
|
||||
const stopPropagationSpy = vi.fn()
|
||||
const preventDefaultSpy = vi.fn()
|
||||
|
||||
const clickEvent = new MouseEvent('click', { bubbles: true })
|
||||
Object.defineProperty(clickEvent, 'stopPropagation', { value: stopPropagationSpy })
|
||||
Object.defineProperty(clickEvent, 'preventDefault', { value: preventDefaultSpy })
|
||||
|
||||
tagAreaWrapper.dispatchEvent(clickEvent)
|
||||
|
||||
expect(stopPropagationSpy).toHaveBeenCalled()
|
||||
expect(preventDefaultSpy).toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('should not navigate when clicking on tag area', () => {
|
||||
render(<DatasetCard {...defaultProps} />)
|
||||
|
||||
// Click on tag area should not trigger card navigation
|
||||
const tagArea = document.querySelector('[class*="px-3"]')
|
||||
if (tagArea) {
|
||||
fireEvent.click(tagArea)
|
||||
// mockPush should NOT be called when clicking tag area
|
||||
// (stopPropagation prevents it from reaching the card click handler)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,87 @@
|
||||
import { RiEditLine } from '@remixicon/react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import OperationItem from './operation-item'
|
||||
|
||||
describe('OperationItem', () => {
|
||||
const defaultProps = {
|
||||
Icon: RiEditLine,
|
||||
name: 'Edit',
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<OperationItem {...defaultProps} />)
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the icon', () => {
|
||||
const { container } = render(<OperationItem {...defaultProps} />)
|
||||
const icon = container.querySelector('svg')
|
||||
expect(icon).toBeInTheDocument()
|
||||
expect(icon).toHaveClass('size-4', 'text-text-tertiary')
|
||||
})
|
||||
|
||||
it('should render the name text', () => {
|
||||
render(<OperationItem {...defaultProps} />)
|
||||
const nameSpan = screen.getByText('Edit')
|
||||
expect(nameSpan).toHaveClass('system-md-regular', 'text-text-secondary')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should render different name', () => {
|
||||
render(<OperationItem {...defaultProps} name="Delete" />)
|
||||
expect(screen.getByText('Delete')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should be callable without handleClick', () => {
|
||||
render(<OperationItem {...defaultProps} />)
|
||||
const item = screen.getByText('Edit').closest('div')
|
||||
expect(() => fireEvent.click(item!)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call handleClick when clicked', () => {
|
||||
const handleClick = vi.fn()
|
||||
render(<OperationItem {...defaultProps} handleClick={handleClick} />)
|
||||
|
||||
const item = screen.getByText('Edit').closest('div')
|
||||
fireEvent.click(item!)
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should prevent default and stop propagation on click', () => {
|
||||
const handleClick = vi.fn()
|
||||
render(<OperationItem {...defaultProps} handleClick={handleClick} />)
|
||||
|
||||
const item = screen.getByText('Edit').closest('div')
|
||||
const clickEvent = new MouseEvent('click', { bubbles: true })
|
||||
const preventDefaultSpy = vi.spyOn(clickEvent, 'preventDefault')
|
||||
const stopPropagationSpy = vi.spyOn(clickEvent, 'stopPropagation')
|
||||
|
||||
item!.dispatchEvent(clickEvent)
|
||||
|
||||
expect(preventDefaultSpy).toHaveBeenCalled()
|
||||
expect(stopPropagationSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styles', () => {
|
||||
it('should have correct container styling', () => {
|
||||
render(<OperationItem {...defaultProps} />)
|
||||
const item = screen.getByText('Edit').closest('div')
|
||||
expect(item).toHaveClass('flex', 'cursor-pointer', 'items-center', 'gap-x-1', 'rounded-lg')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty name', () => {
|
||||
render(<OperationItem {...defaultProps} name="" />)
|
||||
const container = document.querySelector('.cursor-pointer')
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,119 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import Operations from './operations'
|
||||
|
||||
describe('Operations', () => {
|
||||
const defaultProps = {
|
||||
showDelete: true,
|
||||
showExportPipeline: true,
|
||||
openRenameModal: vi.fn(),
|
||||
handleExportPipeline: vi.fn(),
|
||||
detectIsUsedByApp: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Operations {...defaultProps} />)
|
||||
// Edit operation should always be visible
|
||||
expect(screen.getByText(/operation\.edit/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render edit operation', () => {
|
||||
render(<Operations {...defaultProps} />)
|
||||
expect(screen.getByText(/operation\.edit/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render export pipeline operation when showExportPipeline is true', () => {
|
||||
render(<Operations {...defaultProps} showExportPipeline={true} />)
|
||||
expect(screen.getByText(/exportPipeline/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render export pipeline operation when showExportPipeline is false', () => {
|
||||
render(<Operations {...defaultProps} showExportPipeline={false} />)
|
||||
expect(screen.queryByText(/exportPipeline/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render delete operation when showDelete is true', () => {
|
||||
render(<Operations {...defaultProps} showDelete={true} />)
|
||||
expect(screen.getByText(/operation\.delete/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render delete operation when showDelete is false', () => {
|
||||
render(<Operations {...defaultProps} showDelete={false} />)
|
||||
expect(screen.queryByText(/operation\.delete/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should render divider when showDelete is true', () => {
|
||||
const { container } = render(<Operations {...defaultProps} showDelete={true} />)
|
||||
const divider = container.querySelector('.bg-divider-subtle')
|
||||
expect(divider).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render divider when showDelete is false', () => {
|
||||
const { container } = render(<Operations {...defaultProps} showDelete={false} />)
|
||||
// Should not have the divider-subtle one (the separator before delete)
|
||||
expect(container.querySelector('.bg-divider-subtle')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call openRenameModal when edit is clicked', () => {
|
||||
const openRenameModal = vi.fn()
|
||||
render(<Operations {...defaultProps} openRenameModal={openRenameModal} />)
|
||||
|
||||
const editItem = screen.getByText(/operation\.edit/).closest('div')
|
||||
fireEvent.click(editItem!)
|
||||
|
||||
expect(openRenameModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call handleExportPipeline when export is clicked', () => {
|
||||
const handleExportPipeline = vi.fn()
|
||||
render(<Operations {...defaultProps} handleExportPipeline={handleExportPipeline} />)
|
||||
|
||||
const exportItem = screen.getByText(/exportPipeline/).closest('div')
|
||||
fireEvent.click(exportItem!)
|
||||
|
||||
expect(handleExportPipeline).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call detectIsUsedByApp when delete is clicked', () => {
|
||||
const detectIsUsedByApp = vi.fn()
|
||||
render(<Operations {...defaultProps} detectIsUsedByApp={detectIsUsedByApp} />)
|
||||
|
||||
const deleteItem = screen.getByText(/operation\.delete/).closest('div')
|
||||
fireEvent.click(deleteItem!)
|
||||
|
||||
expect(detectIsUsedByApp).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styles', () => {
|
||||
it('should have correct container styling', () => {
|
||||
const { container } = render(<Operations {...defaultProps} />)
|
||||
const operationsContainer = container.firstChild
|
||||
expect(operationsContainer).toHaveClass(
|
||||
'relative',
|
||||
'flex',
|
||||
'w-full',
|
||||
'flex-col',
|
||||
'rounded-xl',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render only edit when both showDelete and showExportPipeline are false', () => {
|
||||
render(<Operations {...defaultProps} showDelete={false} showExportPipeline={false} />)
|
||||
expect(screen.getByText(/operation\.edit/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/exportPipeline/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/operation\.delete/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,52 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import DatasetFooter from './index'
|
||||
|
||||
describe('DatasetFooter', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<DatasetFooter />)
|
||||
expect(screen.getByRole('contentinfo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the main heading', () => {
|
||||
render(<DatasetFooter />)
|
||||
expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description paragraph', () => {
|
||||
render(<DatasetFooter />)
|
||||
// The paragraph contains multiple text spans
|
||||
expect(screen.getByText(/intro1/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should be memoized', () => {
|
||||
// DatasetFooter is wrapped with React.memo
|
||||
expect(DatasetFooter).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styles', () => {
|
||||
it('should have correct footer styling', () => {
|
||||
render(<DatasetFooter />)
|
||||
const footer = screen.getByRole('contentinfo')
|
||||
expect(footer).toHaveClass('shrink-0', 'px-12', 'py-6')
|
||||
})
|
||||
|
||||
it('should have gradient text on heading', () => {
|
||||
render(<DatasetFooter />)
|
||||
const heading = screen.getByRole('heading', { level: 3 })
|
||||
expect(heading).toHaveClass('text-gradient')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Content Structure', () => {
|
||||
it('should render accent spans for highlighted text', () => {
|
||||
render(<DatasetFooter />)
|
||||
const accentSpans = document.querySelectorAll('.text-text-accent')
|
||||
expect(accentSpans.length).toBe(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
485
web/app/components/datasets/list/datasets.spec.tsx
Normal file
485
web/app/components/datasets/list/datasets.spec.tsx
Normal file
@ -0,0 +1,485 @@
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import Datasets from './datasets'
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}))
|
||||
|
||||
// Mock ahooks
|
||||
vi.mock('ahooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('ahooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useHover: () => false,
|
||||
}
|
||||
})
|
||||
|
||||
// Mock useFormatTimeFromNow hook
|
||||
vi.mock('@/hooks/use-format-time-from-now', () => ({
|
||||
useFormatTimeFromNow: () => ({
|
||||
formatTimeFromNow: (timestamp: number) => new Date(timestamp).toLocaleDateString(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useKnowledge hook
|
||||
vi.mock('@/hooks/use-knowledge', () => ({
|
||||
useKnowledge: () => ({
|
||||
formatIndexingTechniqueAndMethod: () => 'High Quality',
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock service hooks - will be overridden in individual tests
|
||||
const mockFetchNextPage = vi.fn()
|
||||
const mockInvalidDatasetList = vi.fn()
|
||||
|
||||
vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
useDatasetList: vi.fn(() => ({
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
data: [
|
||||
createMockDataset({ id: 'dataset-1', name: 'Dataset 1' }),
|
||||
createMockDataset({ id: 'dataset-2', name: 'Dataset 2' }),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
})),
|
||||
useInvalidDatasetList: () => mockInvalidDatasetList,
|
||||
}))
|
||||
|
||||
// Mock app context - will be overridden in tests
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useSelector: vi.fn(() => true),
|
||||
}))
|
||||
|
||||
// Mock useDatasetCardState hook
|
||||
vi.mock('./dataset-card/hooks/use-dataset-card-state', () => ({
|
||||
useDatasetCardState: () => ({
|
||||
tags: [],
|
||||
setTags: vi.fn(),
|
||||
modalState: {
|
||||
showRenameModal: false,
|
||||
showConfirmDelete: false,
|
||||
confirmMessage: '',
|
||||
},
|
||||
openRenameModal: vi.fn(),
|
||||
closeRenameModal: vi.fn(),
|
||||
closeConfirmDelete: vi.fn(),
|
||||
handleExportPipeline: vi.fn(),
|
||||
detectIsUsedByApp: vi.fn(),
|
||||
onConfirmDelete: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock RenameDatasetModal
|
||||
vi.mock('../rename-modal', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
function createMockDataset(overrides: Partial<DataSet> = {}): DataSet {
|
||||
return {
|
||||
id: 'dataset-1',
|
||||
name: 'Test Dataset',
|
||||
description: 'Test description',
|
||||
provider: 'vendor',
|
||||
permission: DatasetPermission.allTeamMembers,
|
||||
data_source_type: DataSourceType.FILE,
|
||||
indexing_technique: IndexingType.QUALIFIED,
|
||||
embedding_available: true,
|
||||
app_count: 5,
|
||||
document_count: 10,
|
||||
word_count: 1000,
|
||||
created_at: 1609459200,
|
||||
updated_at: 1609545600,
|
||||
tags: [],
|
||||
embedding_model: 'text-embedding-ada-002',
|
||||
embedding_model_provider: 'openai',
|
||||
created_by: 'user-1',
|
||||
doc_form: ChunkingMode.text,
|
||||
runtime_mode: 'general',
|
||||
is_published: true,
|
||||
total_available_documents: 10,
|
||||
icon_info: {
|
||||
icon: '📙',
|
||||
icon_type: 'emoji' as const,
|
||||
icon_background: '#FFF4ED',
|
||||
icon_url: '',
|
||||
},
|
||||
retrieval_model_dict: {
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
},
|
||||
author_name: 'Test User',
|
||||
...overrides,
|
||||
} as DataSet
|
||||
}
|
||||
|
||||
// Store IntersectionObserver callbacks for testing
|
||||
let intersectionObserverCallback: IntersectionObserverCallback | null = null
|
||||
const mockObserve = vi.fn()
|
||||
const mockDisconnect = vi.fn()
|
||||
const mockUnobserve = vi.fn()
|
||||
|
||||
// Custom IntersectionObserver mock
|
||||
class MockIntersectionObserver {
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
intersectionObserverCallback = callback
|
||||
}
|
||||
|
||||
observe = mockObserve
|
||||
disconnect = mockDisconnect
|
||||
unobserve = mockUnobserve
|
||||
root = null
|
||||
rootMargin = ''
|
||||
thresholds = []
|
||||
takeRecords = () => []
|
||||
}
|
||||
|
||||
describe('Datasets', () => {
|
||||
const defaultProps = {
|
||||
tags: [],
|
||||
keywords: '',
|
||||
includeAll: false,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
intersectionObserverCallback = null
|
||||
document.title = ''
|
||||
|
||||
// Setup IntersectionObserver mock
|
||||
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Datasets {...defaultProps} />)
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render NewDatasetCard when user is editor', async () => {
|
||||
const { useSelector } = await import('@/context/app-context')
|
||||
vi.mocked(useSelector).mockReturnValue(true)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
expect(screen.getByText(/createDataset/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should NOT render NewDatasetCard when user is NOT editor', async () => {
|
||||
const { useSelector } = await import('@/context/app-context')
|
||||
vi.mocked(useSelector).mockReturnValue(false)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
expect(screen.queryByText(/createDataset/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render dataset cards from data', () => {
|
||||
render(<Datasets {...defaultProps} />)
|
||||
expect(screen.getByText('Dataset 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Dataset 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render anchor div for infinite scroll', () => {
|
||||
render(<Datasets {...defaultProps} />)
|
||||
const anchor = document.querySelector('.h-0')
|
||||
expect(anchor).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should pass tags to useDatasetList', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
render(<Datasets {...defaultProps} tags={['tag-1', 'tag-2']} />)
|
||||
expect(useDatasetList).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tag_ids: ['tag-1', 'tag-2'],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should pass keywords to useDatasetList', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
render(<Datasets {...defaultProps} keywords="search term" />)
|
||||
expect(useDatasetList).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
keyword: 'search term',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should pass includeAll to useDatasetList', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
render(<Datasets {...defaultProps} includeAll={true} />)
|
||||
expect(useDatasetList).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
include_all: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Document Title', () => {
|
||||
it('should set document title on mount', async () => {
|
||||
render(<Datasets {...defaultProps} />)
|
||||
await waitFor(() => {
|
||||
expect(document.title).toContain('dataset.knowledge')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Loading States', () => {
|
||||
it('should show Loading component when isFetchingNextPage is true', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: { pages: [{ data: [] }] },
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: true,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: true,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
// Loading component renders a div with loading classes
|
||||
const nav = screen.getByRole('navigation')
|
||||
expect(nav).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should NOT show Loading component when isFetchingNextPage is false', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: { pages: [{ data: [] }] },
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: true,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DatasetList null handling', () => {
|
||||
it('should handle null datasetList gracefully', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: null,
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined datasetList gracefully', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: undefined,
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty pages array', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: { pages: [] },
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('IntersectionObserver', () => {
|
||||
it('should setup IntersectionObserver on mount', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: { pages: [{ data: [] }] },
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: true,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
|
||||
// Should observe the anchor element
|
||||
expect(mockObserve).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call fetchNextPage when isIntersecting, hasNextPage, and not isFetching', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: { pages: [{ data: [] }] },
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: true,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
|
||||
// Simulate intersection
|
||||
if (intersectionObserverCallback) {
|
||||
intersectionObserverCallback(
|
||||
[{ isIntersecting: true } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
}
|
||||
|
||||
expect(mockFetchNextPage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should NOT call fetchNextPage when isIntersecting is false', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: { pages: [{ data: [] }] },
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: true,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
|
||||
if (intersectionObserverCallback) {
|
||||
intersectionObserverCallback(
|
||||
[{ isIntersecting: false } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
}
|
||||
|
||||
expect(mockFetchNextPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should NOT call fetchNextPage when hasNextPage is false', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: { pages: [{ data: [] }] },
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: false, // No more pages
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
|
||||
if (intersectionObserverCallback) {
|
||||
intersectionObserverCallback(
|
||||
[{ isIntersecting: true } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
}
|
||||
|
||||
expect(mockFetchNextPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should NOT call fetchNextPage when isFetching is true', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: { pages: [{ data: [] }] },
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: true,
|
||||
isFetching: true, // Already fetching
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
|
||||
if (intersectionObserverCallback) {
|
||||
intersectionObserverCallback(
|
||||
[{ isIntersecting: true } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
}
|
||||
|
||||
expect(mockFetchNextPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should disconnect observer on unmount', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: { pages: [{ data: [] }] },
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: true,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
const { unmount } = render(<Datasets {...defaultProps} />)
|
||||
|
||||
// Unmount the component
|
||||
unmount()
|
||||
|
||||
// disconnect should be called during cleanup
|
||||
expect(mockDisconnect).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styles', () => {
|
||||
it('should have correct grid styling', () => {
|
||||
render(<Datasets {...defaultProps} />)
|
||||
const nav = screen.getByRole('navigation')
|
||||
expect(nav).toHaveClass('grid', 'grow', 'gap-3', 'px-12')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty tags array', () => {
|
||||
render(<Datasets {...defaultProps} tags={[]} />)
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty keywords', () => {
|
||||
render(<Datasets {...defaultProps} keywords="" />)
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle multiple pages of data', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: {
|
||||
pages: [
|
||||
{ data: [createMockDataset({ id: 'ds-1', name: 'Page 1 Dataset' })] },
|
||||
{ data: [createMockDataset({ id: 'ds-2', name: 'Page 2 Dataset' })] },
|
||||
],
|
||||
},
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
expect(screen.getByText('Page 1 Dataset')).toBeInTheDocument()
|
||||
expect(screen.getByText('Page 2 Dataset')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
368
web/app/components/datasets/list/index.spec.tsx
Normal file
368
web/app/components/datasets/list/index.spec.tsx
Normal file
@ -0,0 +1,368 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import List from './index'
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = vi.fn()
|
||||
const mockReplace = vi.fn()
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
replace: mockReplace,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock ahooks
|
||||
vi.mock('ahooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('ahooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useBoolean: () => [false, { toggle: vi.fn(), setTrue: vi.fn(), setFalse: vi.fn() }],
|
||||
useDebounceFn: (fn: () => void) => ({ run: fn }),
|
||||
useHover: () => false,
|
||||
}
|
||||
})
|
||||
|
||||
// Mock app context
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
currentWorkspace: { role: 'admin' },
|
||||
isCurrentWorkspaceOwner: true,
|
||||
}),
|
||||
useSelector: () => true,
|
||||
}))
|
||||
|
||||
// Mock global public context
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: () => ({
|
||||
systemFeatures: {
|
||||
branding: { enabled: false },
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock external api panel context
|
||||
const mockSetShowExternalApiPanel = vi.fn()
|
||||
vi.mock('@/context/external-api-panel-context', () => ({
|
||||
useExternalApiPanel: () => ({
|
||||
showExternalApiPanel: false,
|
||||
setShowExternalApiPanel: mockSetShowExternalApiPanel,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock tag management store
|
||||
vi.mock('@/app/components/base/tag-management/store', () => ({
|
||||
useStore: () => false,
|
||||
}))
|
||||
|
||||
// Mock useDocumentTitle hook
|
||||
vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock useFormatTimeFromNow hook
|
||||
vi.mock('@/hooks/use-format-time-from-now', () => ({
|
||||
useFormatTimeFromNow: () => ({
|
||||
formatTimeFromNow: (timestamp: number) => new Date(timestamp).toLocaleDateString(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useKnowledge hook
|
||||
vi.mock('@/hooks/use-knowledge', () => ({
|
||||
useKnowledge: () => ({
|
||||
formatIndexingTechniqueAndMethod: () => 'High Quality',
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock service hooks
|
||||
vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
useDatasetList: vi.fn(() => ({
|
||||
data: { pages: [{ data: [] }] },
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
})),
|
||||
useInvalidDatasetList: () => vi.fn(),
|
||||
useDatasetApiBaseUrl: () => ({
|
||||
data: { api_base_url: 'https://api.example.com' },
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock Datasets component
|
||||
vi.mock('./datasets', () => ({
|
||||
default: ({ tags, keywords, includeAll }: { tags: string[], keywords: string, includeAll: boolean }) => (
|
||||
<div data-testid="datasets-component">
|
||||
<span data-testid="tags">{tags.join(',')}</span>
|
||||
<span data-testid="keywords">{keywords}</span>
|
||||
<span data-testid="include-all">{includeAll ? 'true' : 'false'}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock DatasetFooter component
|
||||
vi.mock('./dataset-footer', () => ({
|
||||
default: () => <footer data-testid="dataset-footer">Footer</footer>,
|
||||
}))
|
||||
|
||||
// Mock ExternalAPIPanel component
|
||||
vi.mock('../external-api/external-api-panel', () => ({
|
||||
default: ({ onClose }: { onClose: () => void }) => (
|
||||
<div data-testid="external-api-panel">
|
||||
<button onClick={onClose}>Close Panel</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock TagManagementModal
|
||||
vi.mock('@/app/components/base/tag-management', () => ({
|
||||
default: () => <div data-testid="tag-management-modal" />,
|
||||
}))
|
||||
|
||||
// Mock TagFilter
|
||||
vi.mock('@/app/components/base/tag-management/filter', () => ({
|
||||
default: ({ onChange }: { value: string[], onChange: (val: string[]) => void }) => (
|
||||
<div data-testid="tag-filter">
|
||||
<button onClick={() => onChange(['tag-1', 'tag-2'])}>Select Tags</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock CheckboxWithLabel
|
||||
vi.mock('@/app/components/datasets/create/website/base/checkbox-with-label', () => ({
|
||||
default: ({ isChecked, onChange, label }: { isChecked: boolean, onChange: () => void, label: string }) => (
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={onChange}
|
||||
data-testid="include-all-checkbox"
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('List', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByTestId('datasets-component')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the search input', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tag filter', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByTestId('tag-filter')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render external API panel button', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByText(/externalAPIPanelTitle/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render dataset footer when branding is disabled', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByTestId('dataset-footer')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should pass includeAll prop to Datasets', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByTestId('include-all')).toHaveTextContent('false')
|
||||
})
|
||||
|
||||
it('should pass empty keywords initially', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByTestId('keywords')).toHaveTextContent('')
|
||||
})
|
||||
|
||||
it('should pass empty tags initially', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByTestId('tags')).toHaveTextContent('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should open external API panel when button is clicked', () => {
|
||||
render(<List />)
|
||||
|
||||
const button = screen.getByText(/externalAPIPanelTitle/)
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(mockSetShowExternalApiPanel).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should update search input value', () => {
|
||||
render(<List />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'test search' } })
|
||||
|
||||
expect(input).toHaveValue('test search')
|
||||
})
|
||||
|
||||
it('should trigger tag filter change', () => {
|
||||
render(<List />)
|
||||
// Tag filter is rendered and interactive
|
||||
const selectTagsBtn = screen.getByText('Select Tags')
|
||||
expect(selectTagsBtn).toBeInTheDocument()
|
||||
fireEvent.click(selectTagsBtn)
|
||||
// The onChange callback was triggered (debounced)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Conditional Rendering', () => {
|
||||
it('should show include all checkbox for workspace owner', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByTestId('include-all-checkbox')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styles', () => {
|
||||
it('should have correct container styling', () => {
|
||||
const { container } = render(<List />)
|
||||
const mainContainer = container.firstChild as HTMLElement
|
||||
expect(mainContainer).toHaveClass('scroll-container', 'relative', 'flex', 'grow', 'flex-col')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty state gracefully', () => {
|
||||
render(<List />)
|
||||
// Should render without errors even with empty data
|
||||
expect(screen.getByTestId('datasets-component')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Branch Coverage', () => {
|
||||
it('should redirect normal role users to /apps', async () => {
|
||||
// Re-mock useAppContext with normal role
|
||||
vi.doMock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
currentWorkspace: { role: 'normal' },
|
||||
isCurrentWorkspaceOwner: false,
|
||||
}),
|
||||
useSelector: () => true,
|
||||
}))
|
||||
|
||||
// Clear module cache and re-import
|
||||
vi.resetModules()
|
||||
const { default: ListComponent } = await import('./index')
|
||||
|
||||
render(<ListComponent />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockReplace).toHaveBeenCalledWith('/apps')
|
||||
})
|
||||
})
|
||||
|
||||
it('should clear search input when onClear is called', () => {
|
||||
render(<List />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
// First set a value
|
||||
fireEvent.change(input, { target: { value: 'test search' } })
|
||||
expect(input).toHaveValue('test search')
|
||||
|
||||
// Find and click the clear button
|
||||
const clearButton = document.querySelector('[class*="clear"], button[aria-label*="clear"]')
|
||||
if (clearButton) {
|
||||
fireEvent.click(clearButton)
|
||||
expect(input).toHaveValue('')
|
||||
}
|
||||
})
|
||||
|
||||
it('should show ExternalAPIPanel when showExternalApiPanel is true', async () => {
|
||||
// Re-mock to show external API panel
|
||||
vi.doMock('@/context/external-api-panel-context', () => ({
|
||||
useExternalApiPanel: () => ({
|
||||
showExternalApiPanel: true,
|
||||
setShowExternalApiPanel: mockSetShowExternalApiPanel,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.resetModules()
|
||||
const { default: ListComponent } = await import('./index')
|
||||
|
||||
render(<ListComponent />)
|
||||
|
||||
expect(screen.getByTestId('external-api-panel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close ExternalAPIPanel when onClose is called', async () => {
|
||||
vi.doMock('@/context/external-api-panel-context', () => ({
|
||||
useExternalApiPanel: () => ({
|
||||
showExternalApiPanel: true,
|
||||
setShowExternalApiPanel: mockSetShowExternalApiPanel,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.resetModules()
|
||||
const { default: ListComponent } = await import('./index')
|
||||
|
||||
render(<ListComponent />)
|
||||
|
||||
const closeButton = screen.getByText('Close Panel')
|
||||
fireEvent.click(closeButton)
|
||||
|
||||
expect(mockSetShowExternalApiPanel).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should show TagManagementModal when showTagManagementModal is true', async () => {
|
||||
vi.doMock('@/app/components/base/tag-management/store', () => ({
|
||||
useStore: () => true, // showTagManagementModal is true
|
||||
}))
|
||||
|
||||
vi.resetModules()
|
||||
const { default: ListComponent } = await import('./index')
|
||||
|
||||
render(<ListComponent />)
|
||||
|
||||
expect(screen.getByTestId('tag-management-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show DatasetFooter when branding is enabled', async () => {
|
||||
vi.doMock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: () => ({
|
||||
systemFeatures: {
|
||||
branding: { enabled: true },
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.resetModules()
|
||||
const { default: ListComponent } = await import('./index')
|
||||
|
||||
render(<ListComponent />)
|
||||
|
||||
expect(screen.queryByTestId('dataset-footer')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show include all checkbox when not workspace owner', async () => {
|
||||
vi.doMock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
currentWorkspace: { role: 'editor' },
|
||||
isCurrentWorkspaceOwner: false,
|
||||
}),
|
||||
useSelector: () => true,
|
||||
}))
|
||||
|
||||
vi.resetModules()
|
||||
const { default: ListComponent } = await import('./index')
|
||||
|
||||
render(<ListComponent />)
|
||||
|
||||
expect(screen.queryByTestId('include-all-checkbox')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -14,13 +14,14 @@ import TagFilter from '@/app/components/base/tag-management/filter'
|
||||
// Hooks
|
||||
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
|
||||
import CheckboxWithLabel from '@/app/components/datasets/create/website/base/checkbox-with-label'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useAppContext, useSelector as useAppContextSelector } from '@/context/app-context'
|
||||
import { useExternalApiPanel } from '@/context/external-api-panel-context'
|
||||
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useDatasetApiBaseUrl } from '@/service/knowledge/use-dataset'
|
||||
// Components
|
||||
import ExternalAPIPanel from '../external-api/external-api-panel'
|
||||
import ServiceApi from '../extra-info/service-api'
|
||||
import DatasetFooter from './dataset-footer'
|
||||
import Datasets from './datasets'
|
||||
|
||||
@ -58,6 +59,9 @@ const List = () => {
|
||||
return router.replace('/apps')
|
||||
}, [currentWorkspace, router])
|
||||
|
||||
const isCurrentWorkspaceManager = useAppContextSelector(state => state.isCurrentWorkspaceManager)
|
||||
const { data: apiBaseInfo } = useDatasetApiBaseUrl()
|
||||
|
||||
return (
|
||||
<div className="scroll-container relative flex grow flex-col overflow-y-auto bg-background-body">
|
||||
<div className="sticky top-0 z-10 flex items-center justify-end gap-x-1 bg-background-body px-12 pb-2 pt-4">
|
||||
@ -81,6 +85,11 @@ const List = () => {
|
||||
onChange={e => handleKeywordsChange(e.target.value)}
|
||||
onClear={() => handleKeywordsChange('')}
|
||||
/>
|
||||
{
|
||||
isCurrentWorkspaceManager && (
|
||||
<ServiceApi apiBaseUrl={apiBaseInfo?.api_base_url ?? ''} />
|
||||
)
|
||||
}
|
||||
<div className="h-4 w-[1px] bg-divider-regular" />
|
||||
<Button
|
||||
className="shadows-shadow-xs gap-0.5"
|
||||
@ -96,7 +105,6 @@ const List = () => {
|
||||
{showTagManagementModal && (
|
||||
<TagManagementModal type="knowledge" show={showTagManagementModal} />
|
||||
)}
|
||||
|
||||
{showExternalApiPanel && <ExternalAPIPanel onClose={() => setShowExternalApiPanel(false)} />}
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -0,0 +1,76 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import CreateAppCard from './index'
|
||||
|
||||
describe('CreateAppCard', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<CreateAppCard />)
|
||||
expect(screen.getAllByRole('link')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should render create dataset option', () => {
|
||||
render(<CreateAppCard />)
|
||||
expect(screen.getByText(/createDataset/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render create from pipeline option', () => {
|
||||
render(<CreateAppCard />)
|
||||
expect(screen.getByText(/createFromPipeline/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render connect dataset option', () => {
|
||||
render(<CreateAppCard />)
|
||||
expect(screen.getByText(/connectDataset/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should have correct displayName', () => {
|
||||
expect(CreateAppCard.displayName).toBe('CreateAppCard')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Links', () => {
|
||||
it('should have correct href for create dataset', () => {
|
||||
render(<CreateAppCard />)
|
||||
const links = screen.getAllByRole('link')
|
||||
expect(links[0]).toHaveAttribute('href', '/datasets/create')
|
||||
})
|
||||
|
||||
it('should have correct href for create from pipeline', () => {
|
||||
render(<CreateAppCard />)
|
||||
const links = screen.getAllByRole('link')
|
||||
expect(links[1]).toHaveAttribute('href', '/datasets/create-from-pipeline')
|
||||
})
|
||||
|
||||
it('should have correct href for connect dataset', () => {
|
||||
render(<CreateAppCard />)
|
||||
const links = screen.getAllByRole('link')
|
||||
expect(links[2]).toHaveAttribute('href', '/datasets/connect')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styles', () => {
|
||||
it('should have correct card styling', () => {
|
||||
const { container } = render(<CreateAppCard />)
|
||||
const card = container.firstChild as HTMLElement
|
||||
expect(card).toHaveClass('h-[190px]', 'flex', 'flex-col', 'rounded-xl')
|
||||
})
|
||||
|
||||
it('should have border separator for connect option', () => {
|
||||
const { container } = render(<CreateAppCard />)
|
||||
const borderDiv = container.querySelector('.border-t-\\[0\\.5px\\]')
|
||||
expect(borderDiv).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Icons', () => {
|
||||
it('should render three icons for three options', () => {
|
||||
const { container } = render(<CreateAppCard />)
|
||||
// Each option has an icon
|
||||
const icons = container.querySelectorAll('svg')
|
||||
expect(icons.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,78 @@
|
||||
import { RiAddLine } from '@remixicon/react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import Option from './option'
|
||||
|
||||
describe('Option', () => {
|
||||
const defaultProps = {
|
||||
Icon: RiAddLine,
|
||||
text: 'Test Option',
|
||||
href: '/test-path',
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Option {...defaultProps} />)
|
||||
expect(screen.getByRole('link')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the text content', () => {
|
||||
render(<Option {...defaultProps} />)
|
||||
expect(screen.getByText('Test Option')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the icon', () => {
|
||||
render(<Option {...defaultProps} />)
|
||||
// Icon should be rendered with correct size class
|
||||
const icon = document.querySelector('.h-4.w-4')
|
||||
expect(icon).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should have correct href attribute', () => {
|
||||
render(<Option {...defaultProps} />)
|
||||
const link = screen.getByRole('link')
|
||||
expect(link).toHaveAttribute('href', '/test-path')
|
||||
})
|
||||
|
||||
it('should render different text based on props', () => {
|
||||
render(<Option {...defaultProps} text="Different Text" />)
|
||||
expect(screen.getByText('Different Text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render different href based on props', () => {
|
||||
render(<Option {...defaultProps} href="/different-path" />)
|
||||
const link = screen.getByRole('link')
|
||||
expect(link).toHaveAttribute('href', '/different-path')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styles', () => {
|
||||
it('should have correct base styling', () => {
|
||||
render(<Option {...defaultProps} />)
|
||||
const link = screen.getByRole('link')
|
||||
expect(link).toHaveClass('flex', 'w-full', 'items-center', 'gap-x-2', 'rounded-lg')
|
||||
})
|
||||
|
||||
it('should have text span with correct styling', () => {
|
||||
render(<Option {...defaultProps} />)
|
||||
const textSpan = screen.getByText('Test Option')
|
||||
expect(textSpan).toHaveClass('system-sm-medium', 'grow', 'text-left')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty text', () => {
|
||||
render(<Option {...defaultProps} text="" />)
|
||||
const link = screen.getByRole('link')
|
||||
expect(link).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle long text', () => {
|
||||
const longText = 'A'.repeat(100)
|
||||
render(<Option {...defaultProps} text={longText} />)
|
||||
expect(screen.getByText(longText)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,92 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import AddedMetadataButton from './add-metadata-button'
|
||||
|
||||
describe('AddedMetadataButton', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<AddedMetadataButton />)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with translated text', () => {
|
||||
render(<AddedMetadataButton />)
|
||||
// The button should contain text from i18n
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render add icon', () => {
|
||||
const { container } = render(<AddedMetadataButton />)
|
||||
// Check if there's an SVG element (the RiAddLine icon)
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
render(<AddedMetadataButton className="custom-class" />)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should apply default classes', () => {
|
||||
render(<AddedMetadataButton />)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveClass('flex', 'w-full', 'items-center')
|
||||
})
|
||||
|
||||
it('should merge custom className with default classes', () => {
|
||||
render(<AddedMetadataButton className="my-custom-class" />)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveClass('flex', 'w-full', 'items-center', 'my-custom-class')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClick when button is clicked', () => {
|
||||
const handleClick = vi.fn()
|
||||
render(<AddedMetadataButton onClick={handleClick} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not throw when onClick is not provided and button is clicked', () => {
|
||||
render(<AddedMetadataButton />)
|
||||
|
||||
expect(() => {
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should call onClick multiple times on multiple clicks', () => {
|
||||
const handleClick = vi.fn()
|
||||
render(<AddedMetadataButton onClick={handleClick} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render with undefined className', () => {
|
||||
render(<AddedMetadataButton className={undefined} />)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with empty className', () => {
|
||||
render(<AddedMetadataButton className="" />)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with undefined onClick', () => {
|
||||
render(<AddedMetadataButton onClick={undefined} />)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
287
web/app/components/datasets/metadata/base/date-picker.spec.tsx
Normal file
287
web/app/components/datasets/metadata/base/date-picker.spec.tsx
Normal file
@ -0,0 +1,287 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import WrappedDatePicker from './date-picker'
|
||||
|
||||
type TriggerArgs = {
|
||||
handleClickTrigger: () => void
|
||||
}
|
||||
|
||||
type DatePickerProps = {
|
||||
onChange: (value: Date | null) => void
|
||||
onClear: () => void
|
||||
renderTrigger: (args: TriggerArgs) => React.ReactNode
|
||||
value?: Date
|
||||
}
|
||||
|
||||
// Mock the base date picker component
|
||||
vi.mock('@/app/components/base/date-and-time-picker/date-picker', () => ({
|
||||
default: ({ onChange, onClear, renderTrigger, value }: DatePickerProps) => {
|
||||
const trigger = renderTrigger({
|
||||
handleClickTrigger: () => {},
|
||||
})
|
||||
return (
|
||||
<div data-testid="date-picker-wrapper">
|
||||
{trigger}
|
||||
<button data-testid="select-date" onClick={() => onChange(value || null)}>
|
||||
Select Date
|
||||
</button>
|
||||
<button data-testid="clear-date" onClick={() => onClear()}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock useTimestamp hook
|
||||
vi.mock('@/hooks/use-timestamp', () => ({
|
||||
default: () => ({
|
||||
formatTime: (timestamp: number) => {
|
||||
if (!timestamp)
|
||||
return ''
|
||||
return new Date(timestamp * 1000).toLocaleDateString()
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('WrappedDatePicker', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<WrappedDatePicker onChange={handleChange} />)
|
||||
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render placeholder text when no value', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<WrappedDatePicker onChange={handleChange} />)
|
||||
// When no value, should show placeholder from i18n
|
||||
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render formatted date when value is provided', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
|
||||
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render calendar icon', () => {
|
||||
const handleChange = vi.fn()
|
||||
const { container } = render(<WrappedDatePicker onChange={handleChange} />)
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render select date button', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<WrappedDatePicker onChange={handleChange} />)
|
||||
expect(screen.getByTestId('select-date')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render clear date button', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<WrappedDatePicker onChange={handleChange} />)
|
||||
expect(screen.getByTestId('clear-date')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render close icon for clearing', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
const { container } = render(
|
||||
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
|
||||
)
|
||||
// RiCloseCircleFill should be rendered
|
||||
const closeIcon = container.querySelectorAll('svg')
|
||||
expect(closeIcon.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
const handleChange = vi.fn()
|
||||
const { container } = render(
|
||||
<WrappedDatePicker className="custom-class" onChange={handleChange} />,
|
||||
)
|
||||
const triggerElement = container.querySelector('.custom-class')
|
||||
expect(triggerElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should accept undefined value', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<WrappedDatePicker value={undefined} onChange={handleChange} />)
|
||||
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should accept number value', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = 1609459200 // 2021-01-01
|
||||
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
|
||||
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onChange with timestamp when date is selected', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('select-date'))
|
||||
|
||||
expect(handleChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onChange with null when date is cleared via onClear', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('clear-date'))
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('should call onChange with null when close icon is clicked directly', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
const { container } = render(
|
||||
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
// Find the RiCloseCircleFill icon (it has specific classes)
|
||||
const closeIcon = container.querySelector('.cursor-pointer.hover\\:text-components-input-text-filled')
|
||||
if (closeIcon) {
|
||||
fireEvent.click(closeIcon)
|
||||
expect(handleChange).toHaveBeenCalledWith(null)
|
||||
}
|
||||
})
|
||||
|
||||
it('should show close button on hover when value exists', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
const { container } = render(
|
||||
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
// The close icon should be present but hidden initially
|
||||
const triggerGroup = container.querySelector('.group')
|
||||
expect(triggerGroup).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle clicking on trigger element', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
const { container } = render(
|
||||
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
const trigger = container.querySelector('.group.flex')
|
||||
if (trigger)
|
||||
fireEvent.click(trigger)
|
||||
|
||||
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should have tertiary text color when no value', () => {
|
||||
const handleChange = vi.fn()
|
||||
const { container } = render(<WrappedDatePicker onChange={handleChange} />)
|
||||
const textElement = container.querySelector('.text-text-tertiary')
|
||||
expect(textElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have secondary text color when value exists', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
const { container } = render(
|
||||
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
|
||||
)
|
||||
const textElement = container.querySelector('.text-text-secondary')
|
||||
expect(textElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have input background styling', () => {
|
||||
const handleChange = vi.fn()
|
||||
const { container } = render(<WrappedDatePicker onChange={handleChange} />)
|
||||
const bgElement = container.querySelector('.bg-components-input-bg-normal')
|
||||
expect(bgElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have quaternary text color for close icon when value exists', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
const { container } = render(
|
||||
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
|
||||
)
|
||||
const closeIcon = container.querySelector('.text-text-quaternary')
|
||||
expect(closeIcon).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle timestamp of 0', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<WrappedDatePicker value={0} onChange={handleChange} />)
|
||||
// 0 is falsy but is a valid timestamp (epoch)
|
||||
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle very large timestamp', () => {
|
||||
const handleChange = vi.fn()
|
||||
const farFuture = 4102444800 // 2100-01-01
|
||||
render(<WrappedDatePicker value={farFuture} onChange={handleChange} />)
|
||||
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle switching between no value and value', () => {
|
||||
const handleChange = vi.fn()
|
||||
const { rerender } = render(
|
||||
<WrappedDatePicker onChange={handleChange} />,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
|
||||
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
rerender(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
|
||||
|
||||
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle clearing date multiple times', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('clear-date'))
|
||||
fireEvent.click(screen.getByTestId('clear-date'))
|
||||
fireEvent.click(screen.getByTestId('clear-date'))
|
||||
|
||||
expect(handleChange).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('should handle rapid date selections', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('select-date'))
|
||||
fireEvent.click(screen.getByTestId('select-date'))
|
||||
fireEvent.click(screen.getByTestId('select-date'))
|
||||
|
||||
expect(handleChange).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('should handle onChange with date object that has unix method', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<WrappedDatePicker onChange={handleChange} />)
|
||||
|
||||
// The mock triggers onChange with the value prop
|
||||
fireEvent.click(screen.getByTestId('select-date'))
|
||||
|
||||
// onChange should have been called
|
||||
expect(handleChange).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,257 @@
|
||||
import type { MetadataItemWithEdit } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DataType } from '../types'
|
||||
import AddRow from './add-row'
|
||||
|
||||
type InputCombinedProps = {
|
||||
type: DataType
|
||||
value: string | number | null
|
||||
onChange: (value: string | number) => void
|
||||
}
|
||||
|
||||
type LabelProps = {
|
||||
text: string
|
||||
}
|
||||
|
||||
// Mock InputCombined component
|
||||
vi.mock('./input-combined', () => ({
|
||||
default: ({ type, value, onChange }: InputCombinedProps) => (
|
||||
<input
|
||||
data-testid="input-combined"
|
||||
data-type={type}
|
||||
value={value || ''}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock Label component
|
||||
vi.mock('./label', () => ({
|
||||
default: ({ text }: LabelProps) => <div data-testid="label">{text}</div>,
|
||||
}))
|
||||
|
||||
describe('AddRow', () => {
|
||||
const mockPayload: MetadataItemWithEdit = {
|
||||
id: 'test-id',
|
||||
name: 'test_field',
|
||||
type: DataType.string,
|
||||
value: 'test value',
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
const { container } = render(
|
||||
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render label with payload name', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
render(
|
||||
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
expect(screen.getByTestId('label')).toHaveTextContent('test_field')
|
||||
})
|
||||
|
||||
it('should render input combined component', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
render(
|
||||
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
expect(screen.getByTestId('input-combined')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render remove button icon', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
const { container } = render(
|
||||
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass correct type to input combined', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
render(
|
||||
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
expect(screen.getByTestId('input-combined')).toHaveAttribute('data-type', DataType.string)
|
||||
})
|
||||
|
||||
it('should pass correct value to input combined', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
render(
|
||||
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
expect(screen.getByTestId('input-combined')).toHaveValue('test value')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
const { container } = render(
|
||||
<AddRow
|
||||
payload={mockPayload}
|
||||
onChange={handleChange}
|
||||
onRemove={handleRemove}
|
||||
className="custom-class"
|
||||
/>,
|
||||
)
|
||||
expect(container.firstChild).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should have default flex styling', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
const { container } = render(
|
||||
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
expect(container.firstChild).toHaveClass('flex', 'h-6', 'items-center', 'space-x-0.5')
|
||||
})
|
||||
|
||||
it('should handle different data types', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
const numberPayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
type: DataType.number,
|
||||
value: 42,
|
||||
}
|
||||
render(
|
||||
<AddRow payload={numberPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
expect(screen.getByTestId('input-combined')).toHaveAttribute('data-type', DataType.number)
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onChange with updated payload when input changes', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
render(
|
||||
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByTestId('input-combined'), { target: { value: 'new value' } })
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith({
|
||||
...mockPayload,
|
||||
value: 'new value',
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onRemove when remove button is clicked', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
const { container } = render(
|
||||
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
|
||||
const removeButton = container.querySelector('.cursor-pointer')
|
||||
if (removeButton)
|
||||
fireEvent.click(removeButton)
|
||||
|
||||
expect(handleRemove).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should preserve other payload properties on change', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
render(
|
||||
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByTestId('input-combined'), { target: { value: 'updated' } })
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'test-id',
|
||||
name: 'test_field',
|
||||
type: DataType.string,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Remove Button Styling', () => {
|
||||
it('should have hover styling on remove button', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
const { container } = render(
|
||||
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
const removeButton = container.querySelector('.cursor-pointer')
|
||||
expect(removeButton).toHaveClass('hover:bg-state-destructive-hover', 'hover:text-text-destructive')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle null value', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
const nullPayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
value: null,
|
||||
}
|
||||
render(
|
||||
<AddRow payload={nullPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
expect(screen.getByTestId('input-combined')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty string value', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
const emptyPayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
value: '',
|
||||
}
|
||||
render(
|
||||
<AddRow payload={emptyPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
expect(screen.getByTestId('input-combined')).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should handle time type payload', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
const timePayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
type: DataType.time,
|
||||
value: 1609459200,
|
||||
}
|
||||
render(
|
||||
<AddRow payload={timePayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
expect(screen.getByTestId('input-combined')).toHaveAttribute('data-type', DataType.time)
|
||||
})
|
||||
|
||||
it('should handle multiple onRemove calls', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
const { container } = render(
|
||||
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
|
||||
const removeButton = container.querySelector('.cursor-pointer')
|
||||
if (removeButton) {
|
||||
fireEvent.click(removeButton)
|
||||
fireEvent.click(removeButton)
|
||||
fireEvent.click(removeButton)
|
||||
}
|
||||
|
||||
expect(handleRemove).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,395 @@
|
||||
import type { MetadataItemWithEdit } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DataType, UpdateType } from '../types'
|
||||
import EditMetadatabatchItem from './edit-row'
|
||||
|
||||
type InputCombinedProps = {
|
||||
type: DataType
|
||||
value: string | number | null
|
||||
onChange: (value: string | number) => void
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
type MultipleValueInputProps = {
|
||||
onClear: () => void
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
type LabelProps = {
|
||||
text: string
|
||||
isDeleted?: boolean
|
||||
}
|
||||
|
||||
type EditedBeaconProps = {
|
||||
onReset: () => void
|
||||
}
|
||||
|
||||
// Mock InputCombined component
|
||||
vi.mock('./input-combined', () => ({
|
||||
default: ({ type, value, onChange, readOnly }: InputCombinedProps) => (
|
||||
<input
|
||||
data-testid="input-combined"
|
||||
data-type={type}
|
||||
value={value || ''}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock InputHasSetMultipleValue component
|
||||
vi.mock('./input-has-set-multiple-value', () => ({
|
||||
default: ({ onClear, readOnly }: MultipleValueInputProps) => (
|
||||
<div data-testid="multiple-value-input" data-readonly={readOnly}>
|
||||
<button data-testid="clear-multiple" onClick={onClear}>Clear Multiple</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock Label component
|
||||
vi.mock('./label', () => ({
|
||||
default: ({ text, isDeleted }: LabelProps) => (
|
||||
<div data-testid="label" data-deleted={isDeleted}>{text}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock EditedBeacon component
|
||||
vi.mock('./edited-beacon', () => ({
|
||||
default: ({ onReset }: EditedBeaconProps) => (
|
||||
<button data-testid="edited-beacon" onClick={onReset}>Reset</button>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('EditMetadatabatchItem', () => {
|
||||
const mockPayload: MetadataItemWithEdit = {
|
||||
id: 'test-id',
|
||||
name: 'test_field',
|
||||
type: DataType.string,
|
||||
value: 'test value',
|
||||
isMultipleValue: false,
|
||||
isUpdated: false,
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(
|
||||
<EditMetadatabatchItem
|
||||
payload={mockPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render label with payload name', () => {
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={mockPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('label')).toHaveTextContent('test_field')
|
||||
})
|
||||
|
||||
it('should render input combined for single value', () => {
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={mockPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('input-combined')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render multiple value input when isMultipleValue is true', () => {
|
||||
const multiplePayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
isMultipleValue: true,
|
||||
}
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={multiplePayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('multiple-value-input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render delete button icon', () => {
|
||||
const { container } = render(
|
||||
<EditMetadatabatchItem
|
||||
payload={mockPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Updated State', () => {
|
||||
it('should show edited beacon when isUpdated is true', () => {
|
||||
const updatedPayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
isUpdated: true,
|
||||
}
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={updatedPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('edited-beacon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show edited beacon when isUpdated is false', () => {
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={mockPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByTestId('edited-beacon')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Deleted State', () => {
|
||||
it('should pass isDeleted to label when updateType is delete', () => {
|
||||
const deletedPayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
updateType: UpdateType.delete,
|
||||
}
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={deletedPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('label')).toHaveAttribute('data-deleted', 'true')
|
||||
})
|
||||
|
||||
it('should set readOnly on input when deleted', () => {
|
||||
const deletedPayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
updateType: UpdateType.delete,
|
||||
}
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={deletedPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('input-combined')).toHaveAttribute('readonly')
|
||||
})
|
||||
|
||||
it('should have destructive styling on delete button when deleted', () => {
|
||||
const deletedPayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
updateType: UpdateType.delete,
|
||||
}
|
||||
const { container } = render(
|
||||
<EditMetadatabatchItem
|
||||
payload={deletedPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
const deleteButton = container.querySelector('.bg-state-destructive-hover')
|
||||
expect(deleteButton).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onChange with updated payload when input changes', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={mockPayload}
|
||||
onChange={handleChange}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByTestId('input-combined'), { target: { value: 'new value' } })
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
...mockPayload,
|
||||
value: 'new value',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should call onRemove with id when delete button is clicked', () => {
|
||||
const handleRemove = vi.fn()
|
||||
const { container } = render(
|
||||
<EditMetadatabatchItem
|
||||
payload={mockPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={handleRemove}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const deleteButton = container.querySelector('.cursor-pointer')
|
||||
if (deleteButton)
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
expect(handleRemove).toHaveBeenCalledWith('test-id')
|
||||
})
|
||||
|
||||
it('should call onReset with id when reset beacon is clicked', () => {
|
||||
const handleReset = vi.fn()
|
||||
const updatedPayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
isUpdated: true,
|
||||
}
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={updatedPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={handleReset}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('edited-beacon'))
|
||||
|
||||
expect(handleReset).toHaveBeenCalledWith('test-id')
|
||||
})
|
||||
|
||||
it('should call onChange to clear multiple value', () => {
|
||||
const handleChange = vi.fn()
|
||||
const multiplePayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
isMultipleValue: true,
|
||||
}
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={multiplePayload}
|
||||
onChange={handleChange}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('clear-multiple'))
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
value: null,
|
||||
isMultipleValue: false,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Multiple Value State', () => {
|
||||
it('should render multiple value input when isMultipleValue is true', () => {
|
||||
const multiplePayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
isMultipleValue: true,
|
||||
}
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={multiplePayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('multiple-value-input')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('input-combined')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass readOnly to multiple value input when deleted', () => {
|
||||
const multipleDeletedPayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
isMultipleValue: true,
|
||||
updateType: UpdateType.delete,
|
||||
}
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={multipleDeletedPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('multiple-value-input')).toHaveAttribute('data-readonly', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle payload with number type', () => {
|
||||
const numberPayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
type: DataType.number,
|
||||
value: 42,
|
||||
}
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={numberPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('input-combined')).toHaveAttribute('data-type', DataType.number)
|
||||
})
|
||||
|
||||
it('should handle payload with time type', () => {
|
||||
const timePayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
type: DataType.time,
|
||||
value: 1609459200,
|
||||
}
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={timePayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('input-combined')).toHaveAttribute('data-type', DataType.time)
|
||||
})
|
||||
|
||||
it('should handle null value', () => {
|
||||
const nullPayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
value: null,
|
||||
}
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={nullPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('input-combined')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,179 @@
|
||||
import { fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import EditedBeacon from './edited-beacon'
|
||||
|
||||
describe('EditedBeacon', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const handleReset = vi.fn()
|
||||
const { container } = render(<EditedBeacon onReset={handleReset} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with correct size', () => {
|
||||
const handleReset = vi.fn()
|
||||
const { container } = render(<EditedBeacon onReset={handleReset} />)
|
||||
expect(container.firstChild).toHaveClass('size-4', 'cursor-pointer')
|
||||
})
|
||||
|
||||
it('should render beacon dot by default (not hovering)', () => {
|
||||
const handleReset = vi.fn()
|
||||
const { container } = render(<EditedBeacon onReset={handleReset} />)
|
||||
// When not hovering, should show the small beacon dot
|
||||
const beaconDot = container.querySelector('.size-1')
|
||||
expect(beaconDot).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Hover State', () => {
|
||||
it('should show reset icon on hover', async () => {
|
||||
const handleReset = vi.fn()
|
||||
const { container } = render(<EditedBeacon onReset={handleReset} />)
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
fireEvent.mouseEnter(wrapper)
|
||||
|
||||
await waitFor(() => {
|
||||
// On hover, should show the reset icon (RiResetLeftLine)
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show beacon dot when not hovering', () => {
|
||||
const handleReset = vi.fn()
|
||||
const { container } = render(<EditedBeacon onReset={handleReset} />)
|
||||
|
||||
// By default (not hovering), should show beacon dot
|
||||
const beaconDot = container.querySelector('.size-1.rounded-full.bg-text-accent-secondary')
|
||||
expect(beaconDot).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide beacon dot on hover', async () => {
|
||||
const handleReset = vi.fn()
|
||||
const { container } = render(<EditedBeacon onReset={handleReset} />)
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
fireEvent.mouseEnter(wrapper)
|
||||
|
||||
await waitFor(() => {
|
||||
// On hover, the small beacon dot should be hidden
|
||||
const beaconDot = container.querySelector('.size-1.rounded-full.bg-text-accent-secondary')
|
||||
expect(beaconDot).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show beacon dot again on mouse leave', async () => {
|
||||
const handleReset = vi.fn()
|
||||
const { container } = render(<EditedBeacon onReset={handleReset} />)
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
|
||||
// Hover
|
||||
fireEvent.mouseEnter(wrapper)
|
||||
|
||||
await waitFor(() => {
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Leave
|
||||
fireEvent.mouseLeave(wrapper)
|
||||
|
||||
await waitFor(() => {
|
||||
const beaconDot = container.querySelector('.size-1.rounded-full.bg-text-accent-secondary')
|
||||
expect(beaconDot).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onReset when reset button is clicked', async () => {
|
||||
const handleReset = vi.fn()
|
||||
const { container } = render(<EditedBeacon onReset={handleReset} />)
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
|
||||
// Hover to show reset button
|
||||
fireEvent.mouseEnter(wrapper)
|
||||
|
||||
await waitFor(() => {
|
||||
const resetButton = container.querySelector('.bg-text-accent-secondary')
|
||||
expect(resetButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Find and click the reset button (the clickable element with onClick)
|
||||
const clickableElement = container.querySelector('.flex.size-4.items-center.justify-center.rounded-full.bg-text-accent-secondary')
|
||||
if (clickableElement) {
|
||||
fireEvent.click(clickableElement)
|
||||
}
|
||||
|
||||
expect(handleReset).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not call onReset when clicking beacon dot (not hovering)', () => {
|
||||
const handleReset = vi.fn()
|
||||
const { container } = render(<EditedBeacon onReset={handleReset} />)
|
||||
|
||||
// Click on the wrapper when not hovering
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
fireEvent.click(wrapper)
|
||||
|
||||
// onReset should not be called because we're not hovering
|
||||
expect(handleReset).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tooltip', () => {
|
||||
it('should render tooltip on hover', async () => {
|
||||
const handleReset = vi.fn()
|
||||
const { container } = render(<EditedBeacon onReset={handleReset} />)
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
fireEvent.mouseEnter(wrapper)
|
||||
|
||||
// Tooltip should be rendered (it wraps the reset button)
|
||||
await waitFor(() => {
|
||||
const resetIcon = container.querySelector('svg')
|
||||
expect(resetIcon).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle multiple hover/leave cycles', async () => {
|
||||
const handleReset = vi.fn()
|
||||
const { container } = render(<EditedBeacon onReset={handleReset} />)
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
fireEvent.mouseEnter(wrapper)
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.mouseLeave(wrapper)
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('.size-1.rounded-full')).toBeInTheDocument()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('should handle rapid hover/leave', async () => {
|
||||
const handleReset = vi.fn()
|
||||
const { container } = render(<EditedBeacon onReset={handleReset} />)
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
|
||||
// Rapid hover/leave
|
||||
fireEvent.mouseEnter(wrapper)
|
||||
fireEvent.mouseLeave(wrapper)
|
||||
fireEvent.mouseEnter(wrapper)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,269 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DataType } from '../types'
|
||||
import InputCombined from './input-combined'
|
||||
|
||||
type DatePickerProps = {
|
||||
value: number | null
|
||||
onChange: (value: number) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Mock the base date-picker component
|
||||
vi.mock('../base/date-picker', () => ({
|
||||
default: ({ value, onChange, className }: DatePickerProps) => (
|
||||
<div data-testid="date-picker" className={className} onClick={() => onChange(Date.now())}>
|
||||
{value || 'Pick date'}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('InputCombined', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const handleChange = vi.fn()
|
||||
const { container } = render(
|
||||
<InputCombined type={DataType.string} value="" onChange={handleChange} />,
|
||||
)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render text input for string type', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.string} value="test" onChange={handleChange} />,
|
||||
)
|
||||
const input = screen.getByDisplayValue('test')
|
||||
expect(input).toBeInTheDocument()
|
||||
expect(input.tagName.toLowerCase()).toBe('input')
|
||||
})
|
||||
|
||||
it('should render number input for number type', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.number} value={42} onChange={handleChange} />,
|
||||
)
|
||||
const input = screen.getByDisplayValue('42')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render date picker for time type', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.time} value={Date.now()} onChange={handleChange} />,
|
||||
)
|
||||
expect(screen.getByTestId('date-picker')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('String Input', () => {
|
||||
it('should call onChange with input value for string type', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.string} value="" onChange={handleChange} />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'new value' } })
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith('new value')
|
||||
})
|
||||
|
||||
it('should display current value for string type', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.string} value="existing value" onChange={handleChange} />,
|
||||
)
|
||||
|
||||
expect(screen.getByDisplayValue('existing value')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply readOnly prop to string input', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.string} value="test" onChange={handleChange} readOnly />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveAttribute('readonly')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Number Input', () => {
|
||||
it('should call onChange with number value for number type', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.number} value={0} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
fireEvent.change(input, { target: { value: '123' } })
|
||||
|
||||
expect(handleChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should display current value for number type', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.number} value={999} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
expect(screen.getByDisplayValue('999')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply readOnly prop to number input', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.number} value={42} onChange={handleChange} readOnly />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveAttribute('readonly')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Time/Date Input', () => {
|
||||
it('should render date picker for time type', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.time} value={1234567890} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('date-picker')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChange when date is selected', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.time} value={null} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('date-picker'))
|
||||
expect(handleChange).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
const handleChange = vi.fn()
|
||||
const { container } = render(
|
||||
<InputCombined
|
||||
type={DataType.string}
|
||||
value=""
|
||||
onChange={handleChange}
|
||||
className="custom-class"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Check that custom class is applied to wrapper
|
||||
const wrapper = container.querySelector('.custom-class')
|
||||
expect(wrapper).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle null value for string type', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.string} value={null} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined value for string type', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.string} value={undefined as unknown as string} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle null value for number type', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.number} value={null} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should have correct base styling for string input', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.string} value="" onChange={handleChange} />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveClass('h-6', 'grow', 'p-0.5', 'text-xs', 'rounded-md')
|
||||
})
|
||||
|
||||
it('should have correct styling for number input', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.number} value={0} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveClass('rounded-l-md')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty string value', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.string} value="" onChange={handleChange} />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should handle zero value for number', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.number} value={0} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
expect(screen.getByDisplayValue('0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle negative number', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.number} value={-100} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
expect(screen.getByDisplayValue('-100')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle special characters in string', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.string} value={'<script>alert("xss")</script>'} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
expect(screen.getByDisplayValue('<script>alert("xss")</script>')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle switching between types', () => {
|
||||
const handleChange = vi.fn()
|
||||
const { rerender } = render(
|
||||
<InputCombined type={DataType.string} value="test" onChange={handleChange} />,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<InputCombined type={DataType.number} value={42} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,147 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import InputHasSetMultipleValue from './input-has-set-multiple-value'
|
||||
|
||||
describe('InputHasSetMultipleValue', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with correct wrapper styling', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
|
||||
expect(container.firstChild).toHaveClass('h-6', 'grow', 'rounded-md', 'bg-components-input-bg-normal', 'p-0.5')
|
||||
})
|
||||
|
||||
it('should render multiple value text', () => {
|
||||
const handleClear = vi.fn()
|
||||
render(<InputHasSetMultipleValue onClear={handleClear} />)
|
||||
// The text should come from i18n
|
||||
expect(screen.getByText(/multipleValue|Multiple/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render close icon when not readOnly', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
|
||||
// Should have close icon (RiCloseLine)
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should not show close icon when readOnly is true', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} readOnly />)
|
||||
// Should not have close icon
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show close icon when readOnly is false', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} readOnly={false} />)
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show close icon when readOnly is undefined', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} readOnly={undefined} />)
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply pr-1.5 padding when readOnly', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} readOnly />)
|
||||
const badge = container.querySelector('.inline-flex')
|
||||
expect(badge).toHaveClass('pr-1.5')
|
||||
})
|
||||
|
||||
it('should apply pr-0.5 padding when not readOnly', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
|
||||
const badge = container.querySelector('.inline-flex')
|
||||
expect(badge).toHaveClass('pr-0.5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClear when close icon is clicked', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
|
||||
|
||||
const closeIcon = container.querySelector('svg')
|
||||
expect(closeIcon).toBeInTheDocument()
|
||||
|
||||
if (closeIcon) {
|
||||
fireEvent.click(closeIcon)
|
||||
}
|
||||
|
||||
expect(handleClear).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not call onClear when readOnly and clicking on component', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} readOnly />)
|
||||
|
||||
// Click on the wrapper
|
||||
fireEvent.click(container.firstChild as HTMLElement)
|
||||
|
||||
expect(handleClear).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onClear multiple times on multiple clicks', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
|
||||
|
||||
const closeIcon = container.querySelector('svg')
|
||||
|
||||
if (closeIcon) {
|
||||
fireEvent.click(closeIcon)
|
||||
fireEvent.click(closeIcon)
|
||||
fireEvent.click(closeIcon)
|
||||
}
|
||||
|
||||
expect(handleClear).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should have badge styling', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
|
||||
const badge = container.querySelector('.inline-flex')
|
||||
expect(badge).toHaveClass('h-5', 'items-center', 'rounded-[5px]', 'border-[0.5px]')
|
||||
})
|
||||
|
||||
it('should have hover styles on close button wrapper', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
|
||||
const closeWrapper = container.querySelector('.cursor-pointer')
|
||||
expect(closeWrapper).toHaveClass('hover:bg-state-base-hover', 'hover:text-text-secondary')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render correctly when switching readOnly state', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container, rerender } = render(<InputHasSetMultipleValue onClear={handleClear} />)
|
||||
|
||||
// Initially not readOnly
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
|
||||
// Switch to readOnly
|
||||
rerender(<InputHasSetMultipleValue onClear={handleClear} readOnly />)
|
||||
expect(container.querySelector('svg')).not.toBeInTheDocument()
|
||||
|
||||
// Switch back to not readOnly
|
||||
rerender(<InputHasSetMultipleValue onClear={handleClear} readOnly={false} />)
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,113 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import Label from './label'
|
||||
|
||||
describe('Label', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Label text="Test Label" />)
|
||||
expect(screen.getByText('Test Label')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render text with correct styling', () => {
|
||||
render(<Label text="My Label" />)
|
||||
const labelElement = screen.getByText('My Label')
|
||||
expect(labelElement).toHaveClass('system-xs-medium', 'w-[136px]', 'shrink-0', 'truncate', 'text-text-tertiary')
|
||||
})
|
||||
|
||||
it('should not have deleted styling by default', () => {
|
||||
render(<Label text="Label" />)
|
||||
const labelElement = screen.getByText('Label')
|
||||
expect(labelElement).not.toHaveClass('text-text-quaternary', 'line-through')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
render(<Label text="Label" className="custom-class" />)
|
||||
const labelElement = screen.getByText('Label')
|
||||
expect(labelElement).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should merge custom className with default classes', () => {
|
||||
render(<Label text="Label" className="my-custom-class" />)
|
||||
const labelElement = screen.getByText('Label')
|
||||
expect(labelElement).toHaveClass('system-xs-medium', 'my-custom-class')
|
||||
})
|
||||
|
||||
it('should apply deleted styling when isDeleted is true', () => {
|
||||
render(<Label text="Label" isDeleted />)
|
||||
const labelElement = screen.getByText('Label')
|
||||
expect(labelElement).toHaveClass('text-text-quaternary', 'line-through')
|
||||
})
|
||||
|
||||
it('should not apply deleted styling when isDeleted is false', () => {
|
||||
render(<Label text="Label" isDeleted={false} />)
|
||||
const labelElement = screen.getByText('Label')
|
||||
expect(labelElement).not.toHaveClass('text-text-quaternary', 'line-through')
|
||||
})
|
||||
|
||||
it('should render different text values', () => {
|
||||
const { rerender } = render(<Label text="First" />)
|
||||
expect(screen.getByText('First')).toBeInTheDocument()
|
||||
|
||||
rerender(<Label text="Second" />)
|
||||
expect(screen.getByText('Second')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Deleted State', () => {
|
||||
it('should have strikethrough when deleted', () => {
|
||||
render(<Label text="Deleted Label" isDeleted />)
|
||||
const labelElement = screen.getByText('Deleted Label')
|
||||
expect(labelElement).toHaveClass('line-through')
|
||||
})
|
||||
|
||||
it('should have quaternary text color when deleted', () => {
|
||||
render(<Label text="Deleted Label" isDeleted />)
|
||||
const labelElement = screen.getByText('Deleted Label')
|
||||
expect(labelElement).toHaveClass('text-text-quaternary')
|
||||
})
|
||||
|
||||
it('should combine deleted styling with custom className', () => {
|
||||
render(<Label text="Label" isDeleted className="custom" />)
|
||||
const labelElement = screen.getByText('Label')
|
||||
expect(labelElement).toHaveClass('line-through', 'custom')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render with empty text', () => {
|
||||
const { container } = render(<Label text="" />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with long text (truncation)', () => {
|
||||
const longText = 'This is a very long label text that should be truncated'
|
||||
render(<Label text={longText} />)
|
||||
const labelElement = screen.getByText(longText)
|
||||
expect(labelElement).toHaveClass('truncate')
|
||||
})
|
||||
|
||||
it('should render with undefined className', () => {
|
||||
render(<Label text="Label" className={undefined} />)
|
||||
expect(screen.getByText('Label')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with undefined isDeleted', () => {
|
||||
render(<Label text="Label" isDeleted={undefined} />)
|
||||
const labelElement = screen.getByText('Label')
|
||||
expect(labelElement).not.toHaveClass('line-through')
|
||||
})
|
||||
|
||||
it('should handle special characters in text', () => {
|
||||
render(<Label text={'Label & "chars"'} />)
|
||||
expect(screen.getByText('Label & "chars"')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle numbers in text', () => {
|
||||
render(<Label text="Label 123" />)
|
||||
expect(screen.getByText('Label 123')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,548 @@
|
||||
import type { MetadataItemInBatchEdit, MetadataItemWithEdit } from '../types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DataType, UpdateType } from '../types'
|
||||
import EditMetadataBatchModal from './modal'
|
||||
|
||||
// Mock service/API calls
|
||||
const mockDoAddMetaData = vi.fn().mockResolvedValue({})
|
||||
vi.mock('@/service/knowledge/use-metadata', () => ({
|
||||
useCreateMetaData: () => ({
|
||||
mutate: mockDoAddMetaData,
|
||||
}),
|
||||
useDatasetMetaData: () => ({
|
||||
data: {
|
||||
doc_metadata: [
|
||||
{ id: 'existing-1', name: 'existing_field', type: DataType.string },
|
||||
{ id: 'existing-2', name: 'another_field', type: DataType.number },
|
||||
],
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock check name hook to control validation
|
||||
let mockCheckNameResult = { errorMsg: '' }
|
||||
vi.mock('../hooks/use-check-metadata-name', () => ({
|
||||
default: () => ({
|
||||
checkName: () => mockCheckNameResult,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock Toast to verify notifications
|
||||
const mockToastNotify = vi.fn()
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: (args: unknown) => mockToastNotify(args),
|
||||
},
|
||||
}))
|
||||
|
||||
// Type definitions for mock props
|
||||
type EditRowProps = {
|
||||
payload: MetadataItemWithEdit
|
||||
onChange: (item: MetadataItemWithEdit) => void
|
||||
onRemove: (id: string) => void
|
||||
onReset: (id: string) => void
|
||||
}
|
||||
|
||||
type AddRowProps = {
|
||||
payload: MetadataItemWithEdit
|
||||
onChange: (item: MetadataItemWithEdit) => void
|
||||
onRemove: () => void
|
||||
}
|
||||
|
||||
type SelectModalProps = {
|
||||
trigger: React.ReactNode
|
||||
onSelect: (item: MetadataItemInBatchEdit) => void
|
||||
onSave: (data: { name: string, type: DataType }) => Promise<void>
|
||||
onManage: () => void
|
||||
}
|
||||
|
||||
// Mock child components with callback exposure
|
||||
vi.mock('./edit-row', () => ({
|
||||
default: ({ payload, onChange, onRemove, onReset }: EditRowProps) => (
|
||||
<div data-testid="edit-row" data-id={payload.id}>
|
||||
<span data-testid="edit-row-name">{payload.name}</span>
|
||||
<button data-testid={`change-${payload.id}`} onClick={() => onChange({ ...payload, value: 'changed', isUpdated: true, updateType: UpdateType.changeValue })}>Change</button>
|
||||
<button data-testid={`remove-${payload.id}`} onClick={() => onRemove(payload.id)}>Remove</button>
|
||||
<button data-testid={`reset-${payload.id}`} onClick={() => onReset(payload.id)}>Reset</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./add-row', () => ({
|
||||
default: ({ payload, onChange, onRemove }: AddRowProps) => (
|
||||
<div data-testid="add-row" data-id={payload.id}>
|
||||
<span data-testid="add-row-name">{payload.name}</span>
|
||||
<button data-testid={`add-change-${payload.id}`} onClick={() => onChange({ ...payload, value: 'new_value' })}>Change</button>
|
||||
<button data-testid="add-remove" onClick={onRemove}>Remove</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../metadata-dataset/select-metadata-modal', () => ({
|
||||
default: ({ trigger, onSelect, onSave, onManage }: SelectModalProps) => (
|
||||
<div data-testid="select-modal">
|
||||
{trigger}
|
||||
<button data-testid="select-metadata" onClick={() => onSelect({ id: 'new-1', name: 'new_field', type: DataType.string, value: null, isMultipleValue: false })}>Select</button>
|
||||
<button data-testid="save-metadata" onClick={() => onSave({ name: 'created_field', type: DataType.string }).catch(() => {})}>Save</button>
|
||||
<button data-testid="manage-metadata" onClick={onManage}>Manage</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('EditMetadataBatchModal', () => {
|
||||
const mockList: MetadataItemInBatchEdit[] = [
|
||||
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1', isMultipleValue: false },
|
||||
{ id: '2', name: 'field_two', type: DataType.number, value: 42, isMultipleValue: false },
|
||||
]
|
||||
|
||||
const defaultProps = {
|
||||
datasetId: 'ds-1',
|
||||
documentNum: 5,
|
||||
list: mockList,
|
||||
onSave: vi.fn(),
|
||||
onHide: vi.fn(),
|
||||
onShowManage: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCheckNameResult = { errorMsg: '' }
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', async () => {
|
||||
render(<EditMetadataBatchModal {...defaultProps} />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render document count', async () => {
|
||||
render(<EditMetadataBatchModal {...defaultProps} />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/5/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render all edit rows for existing items', async () => {
|
||||
render(<EditMetadataBatchModal {...defaultProps} />)
|
||||
await waitFor(() => {
|
||||
const editRows = screen.getAllByTestId('edit-row')
|
||||
expect(editRows).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
it('should render field names for existing items', async () => {
|
||||
render(<EditMetadataBatchModal {...defaultProps} />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('field_one')).toBeInTheDocument()
|
||||
expect(screen.getByText('field_two')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render checkbox for apply to all', async () => {
|
||||
render(<EditMetadataBatchModal {...defaultProps} />)
|
||||
await waitFor(() => {
|
||||
const checkboxes = document.querySelectorAll('[data-testid*="checkbox"]')
|
||||
expect(checkboxes.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('should render select metadata modal', async () => {
|
||||
render(<EditMetadataBatchModal {...defaultProps} />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('select-modal')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onHide when cancel button is clicked', async () => {
|
||||
const onHide = vi.fn()
|
||||
render(<EditMetadataBatchModal {...defaultProps} onHide={onHide} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const cancelButton = screen.getByText(/cancel/i)
|
||||
fireEvent.click(cancelButton)
|
||||
|
||||
expect(onHide).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onSave when save button is clicked', async () => {
|
||||
const onSave = vi.fn()
|
||||
render(<EditMetadataBatchModal {...defaultProps} onSave={onSave} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Find the primary save button (not the one in SelectMetadataModal)
|
||||
const saveButtons = screen.getAllByText(/save/i)
|
||||
const modalSaveButton = saveButtons.find(btn => btn.closest('button')?.classList.contains('btn-primary'))
|
||||
if (modalSaveButton)
|
||||
fireEvent.click(modalSaveButton)
|
||||
|
||||
expect(onSave).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should toggle apply to all checkbox', async () => {
|
||||
render(<EditMetadataBatchModal {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const checkboxContainer = document.querySelector('[data-testid*="checkbox"]')
|
||||
expect(checkboxContainer).toBeInTheDocument()
|
||||
|
||||
if (checkboxContainer) {
|
||||
fireEvent.click(checkboxContainer)
|
||||
await waitFor(() => {
|
||||
const checkIcon = checkboxContainer.querySelector('svg')
|
||||
expect(checkIcon).toBeInTheDocument()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('should call onHide when modal close button is clicked', async () => {
|
||||
const onHide = vi.fn()
|
||||
render(<EditMetadataBatchModal {...defaultProps} onHide={onHide} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edit Row Operations', () => {
|
||||
it('should update item value when change is triggered', async () => {
|
||||
render(<EditMetadataBatchModal {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('change-1'))
|
||||
|
||||
// The component should update internally
|
||||
expect(screen.getAllByTestId('edit-row').length).toBe(2)
|
||||
})
|
||||
|
||||
it('should mark item as deleted when remove is clicked', async () => {
|
||||
render(<EditMetadataBatchModal {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('remove-1'))
|
||||
|
||||
// The component should update internally - item marked as deleted
|
||||
expect(screen.getAllByTestId('edit-row').length).toBe(2)
|
||||
})
|
||||
|
||||
it('should reset item when reset is clicked', async () => {
|
||||
render(<EditMetadataBatchModal {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// First change the item
|
||||
fireEvent.click(screen.getByTestId('change-1'))
|
||||
// Then reset it
|
||||
fireEvent.click(screen.getByTestId('reset-1'))
|
||||
|
||||
expect(screen.getAllByTestId('edit-row').length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Add Metadata Operations', () => {
|
||||
it('should add new item when metadata is selected', async () => {
|
||||
render(<EditMetadataBatchModal {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('select-metadata'))
|
||||
|
||||
// Should now have add-row for the new item
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('add-row')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should remove added item when remove is clicked', async () => {
|
||||
render(<EditMetadataBatchModal {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// First add an item
|
||||
fireEvent.click(screen.getByTestId('select-metadata'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('add-row')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Then remove it
|
||||
fireEvent.click(screen.getByTestId('add-remove'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('add-row')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should update added item when change is triggered', async () => {
|
||||
render(<EditMetadataBatchModal {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// First add an item
|
||||
fireEvent.click(screen.getByTestId('select-metadata'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('add-row')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Then change it
|
||||
fireEvent.click(screen.getByTestId('add-change-new-1'))
|
||||
|
||||
expect(screen.getByTestId('add-row')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call doAddMetaData when saving new metadata with valid name', async () => {
|
||||
mockCheckNameResult = { errorMsg: '' }
|
||||
|
||||
render(<EditMetadataBatchModal {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('save-metadata'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDoAddMetaData).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show success toast when saving with valid name', async () => {
|
||||
mockCheckNameResult = { errorMsg: '' }
|
||||
|
||||
render(<EditMetadataBatchModal {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('save-metadata'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDoAddMetaData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'success',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should show error toast when saving with invalid name', async () => {
|
||||
mockCheckNameResult = { errorMsg: 'Name already exists' }
|
||||
|
||||
render(<EditMetadataBatchModal {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('save-metadata'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'error',
|
||||
message: 'Name already exists',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onShowManage when manage is clicked', async () => {
|
||||
const onShowManage = vi.fn()
|
||||
render(<EditMetadataBatchModal {...defaultProps} onShowManage={onShowManage} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('manage-metadata'))
|
||||
|
||||
expect(onShowManage).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should pass correct datasetId', async () => {
|
||||
render(<EditMetadataBatchModal {...defaultProps} datasetId="custom-ds" />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should display correct document number', async () => {
|
||||
render(<EditMetadataBatchModal {...defaultProps} documentNum={10} />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/10/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle empty list', async () => {
|
||||
render(<EditMetadataBatchModal {...defaultProps} list={[]} />)
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('edit-row')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle list with multiple value items', async () => {
|
||||
const multipleValueList: MetadataItemInBatchEdit[] = [
|
||||
{ id: '1', name: 'field', type: DataType.string, value: null, isMultipleValue: true },
|
||||
]
|
||||
render(<EditMetadataBatchModal {...defaultProps} list={multipleValueList} />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('edit-row')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle rapid save clicks', async () => {
|
||||
const onSave = vi.fn()
|
||||
render(<EditMetadataBatchModal {...defaultProps} onSave={onSave} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Find the primary save button
|
||||
const saveButtons = screen.getAllByText(/save/i)
|
||||
const saveBtn = saveButtons.find(btn => btn.closest('button')?.classList.contains('btn-primary'))
|
||||
if (saveBtn) {
|
||||
fireEvent.click(saveBtn)
|
||||
fireEvent.click(saveBtn)
|
||||
fireEvent.click(saveBtn)
|
||||
}
|
||||
|
||||
expect(onSave).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('should pass correct arguments to onSave', async () => {
|
||||
const onSave = vi.fn()
|
||||
render(<EditMetadataBatchModal {...defaultProps} onSave={onSave} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const saveButtons = screen.getAllByText(/save/i)
|
||||
const saveBtn = saveButtons.find(btn => btn.closest('button')?.classList.contains('btn-primary'))
|
||||
if (saveBtn)
|
||||
fireEvent.click(saveBtn)
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
expect.any(Array),
|
||||
expect.any(Boolean),
|
||||
)
|
||||
})
|
||||
|
||||
it('should pass isApplyToAllSelectDocument as true when checked', async () => {
|
||||
const onSave = vi.fn()
|
||||
render(<EditMetadataBatchModal {...defaultProps} onSave={onSave} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const checkboxContainer = document.querySelector('[data-testid*="checkbox"]')
|
||||
if (checkboxContainer)
|
||||
fireEvent.click(checkboxContainer)
|
||||
|
||||
const saveButtons = screen.getAllByText(/save/i)
|
||||
const saveBtn = saveButtons.find(btn => btn.closest('button')?.classList.contains('btn-primary'))
|
||||
if (saveBtn)
|
||||
fireEvent.click(saveBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
expect.any(Array),
|
||||
true,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should filter out deleted items when saving', async () => {
|
||||
const onSave = vi.fn()
|
||||
render(<EditMetadataBatchModal {...defaultProps} onSave={onSave} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Remove an item
|
||||
fireEvent.click(screen.getByTestId('remove-1'))
|
||||
|
||||
// Save
|
||||
const saveButtons = screen.getAllByText(/save/i)
|
||||
const saveBtn = saveButtons.find(btn => btn.closest('button')?.classList.contains('btn-primary'))
|
||||
if (saveBtn)
|
||||
fireEvent.click(saveBtn)
|
||||
|
||||
expect(onSave).toHaveBeenCalled()
|
||||
// The first argument should not contain the deleted item (id '1')
|
||||
const savedList = onSave.mock.calls[0][0] as MetadataItemInBatchEdit[]
|
||||
const hasDeletedItem = savedList.some(item => item.id === '1')
|
||||
expect(hasDeletedItem).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle multiple add and remove operations', async () => {
|
||||
render(<EditMetadataBatchModal {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Add first item
|
||||
fireEvent.click(screen.getByTestId('select-metadata'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('add-row')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Remove it
|
||||
fireEvent.click(screen.getByTestId('add-remove'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('add-row')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Add again
|
||||
fireEvent.click(screen.getByTestId('select-metadata'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('add-row')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,647 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DataType, UpdateType } from '../types'
|
||||
import useBatchEditDocumentMetadata from './use-batch-edit-document-metadata'
|
||||
|
||||
type DocMetadataItem = {
|
||||
id: string
|
||||
name: string
|
||||
type: DataType
|
||||
value: string | number | null
|
||||
}
|
||||
|
||||
type DocListItem = {
|
||||
id: string
|
||||
name?: string
|
||||
doc_metadata?: DocMetadataItem[] | null
|
||||
}
|
||||
|
||||
type MetadataItemWithEdit = {
|
||||
id: string
|
||||
name: string
|
||||
type: DataType
|
||||
value: string | number | null
|
||||
isMultipleValue?: boolean
|
||||
updateType?: UpdateType
|
||||
}
|
||||
|
||||
// Mock useBatchUpdateDocMetadata
|
||||
const mockMutateAsync = vi.fn().mockResolvedValue({})
|
||||
vi.mock('@/service/knowledge/use-metadata', () => ({
|
||||
useBatchUpdateDocMetadata: () => ({
|
||||
mutateAsync: mockMutateAsync,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock Toast
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('useBatchEditDocumentMetadata', () => {
|
||||
const mockDocList: DocListItem[] = [
|
||||
{
|
||||
id: 'doc-1',
|
||||
name: 'Document 1',
|
||||
doc_metadata: [
|
||||
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
|
||||
{ id: '2', name: 'field_two', type: DataType.number, value: 42 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'doc-2',
|
||||
name: 'Document 2',
|
||||
doc_metadata: [
|
||||
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 2' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const defaultProps = {
|
||||
datasetId: 'ds-1',
|
||||
docList: mockDocList as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
|
||||
onUpdate: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Hook Initialization', () => {
|
||||
it('should initialize with isShowEditModal as false', () => {
|
||||
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
|
||||
expect(result.current.isShowEditModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should return showEditModal function', () => {
|
||||
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
|
||||
expect(typeof result.current.showEditModal).toBe('function')
|
||||
})
|
||||
|
||||
it('should return hideEditModal function', () => {
|
||||
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
|
||||
expect(typeof result.current.hideEditModal).toBe('function')
|
||||
})
|
||||
|
||||
it('should return originalList', () => {
|
||||
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
|
||||
expect(Array.isArray(result.current.originalList)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return handleSave function', () => {
|
||||
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
|
||||
expect(typeof result.current.handleSave).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Modal Control', () => {
|
||||
it('should show modal when showEditModal is called', () => {
|
||||
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
|
||||
|
||||
act(() => {
|
||||
result.current.showEditModal()
|
||||
})
|
||||
|
||||
expect(result.current.isShowEditModal).toBe(true)
|
||||
})
|
||||
|
||||
it('should hide modal when hideEditModal is called', () => {
|
||||
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
|
||||
|
||||
act(() => {
|
||||
result.current.showEditModal()
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.hideEditModal()
|
||||
})
|
||||
|
||||
expect(result.current.isShowEditModal).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Original List Processing', () => {
|
||||
it('should compute originalList from docList metadata', () => {
|
||||
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
|
||||
|
||||
expect(result.current.originalList.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should filter out built-in metadata', () => {
|
||||
const docListWithBuiltIn: DocListItem[] = [
|
||||
{
|
||||
id: 'doc-1',
|
||||
doc_metadata: [
|
||||
{ id: 'built-in', name: 'created_at', type: DataType.time, value: 123 },
|
||||
{ id: '1', name: 'custom', type: DataType.string, value: 'test' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useBatchEditDocumentMetadata({
|
||||
...defaultProps,
|
||||
docList: docListWithBuiltIn as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
|
||||
}),
|
||||
)
|
||||
|
||||
const hasBuiltIn = result.current.originalList.some(item => item.id === 'built-in')
|
||||
expect(hasBuiltIn).toBe(false)
|
||||
})
|
||||
|
||||
it('should mark items with multiple values', () => {
|
||||
const docListWithDifferentValues: DocListItem[] = [
|
||||
{
|
||||
id: 'doc-1',
|
||||
doc_metadata: [
|
||||
{ id: '1', name: 'field', type: DataType.string, value: 'Value A' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'doc-2',
|
||||
doc_metadata: [
|
||||
{ id: '1', name: 'field', type: DataType.string, value: 'Value B' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useBatchEditDocumentMetadata({
|
||||
...defaultProps,
|
||||
docList: docListWithDifferentValues as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
|
||||
}),
|
||||
)
|
||||
|
||||
const fieldItem = result.current.originalList.find(item => item.id === '1')
|
||||
expect(fieldItem?.isMultipleValue).toBe(true)
|
||||
})
|
||||
|
||||
it('should not mark items with same values as multiple', () => {
|
||||
const docListWithSameValues: DocListItem[] = [
|
||||
{
|
||||
id: 'doc-1',
|
||||
doc_metadata: [
|
||||
{ id: '1', name: 'field', type: DataType.string, value: 'Same Value' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'doc-2',
|
||||
doc_metadata: [
|
||||
{ id: '1', name: 'field', type: DataType.string, value: 'Same Value' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useBatchEditDocumentMetadata({
|
||||
...defaultProps,
|
||||
docList: docListWithSameValues as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
|
||||
}),
|
||||
)
|
||||
|
||||
const fieldItem = result.current.originalList.find(item => item.id === '1')
|
||||
expect(fieldItem?.isMultipleValue).toBe(false)
|
||||
})
|
||||
|
||||
it('should skip already marked multiple value items', () => {
|
||||
// Three docs with same field but different values
|
||||
const docListThreeDocs: DocListItem[] = [
|
||||
{
|
||||
id: 'doc-1',
|
||||
doc_metadata: [
|
||||
{ id: '1', name: 'field', type: DataType.string, value: 'Value A' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'doc-2',
|
||||
doc_metadata: [
|
||||
{ id: '1', name: 'field', type: DataType.string, value: 'Value B' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'doc-3',
|
||||
doc_metadata: [
|
||||
{ id: '1', name: 'field', type: DataType.string, value: 'Value C' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useBatchEditDocumentMetadata({
|
||||
...defaultProps,
|
||||
docList: docListThreeDocs as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
|
||||
}),
|
||||
)
|
||||
|
||||
// Should only have one item for field '1', marked as multiple
|
||||
const fieldItems = result.current.originalList.filter(item => item.id === '1')
|
||||
expect(fieldItems.length).toBe(1)
|
||||
expect(fieldItems[0].isMultipleValue).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleSave', () => {
|
||||
it('should call mutateAsync with correct data', async () => {
|
||||
const onUpdate = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useBatchEditDocumentMetadata({ ...defaultProps, onUpdate }),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave([], [], false)
|
||||
})
|
||||
|
||||
expect(mockMutateAsync).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onUpdate after successful save', async () => {
|
||||
const onUpdate = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useBatchEditDocumentMetadata({ ...defaultProps, onUpdate }),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave([], [], false)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUpdate).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should hide modal after successful save', async () => {
|
||||
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
|
||||
|
||||
act(() => {
|
||||
result.current.showEditModal()
|
||||
})
|
||||
|
||||
expect(result.current.isShowEditModal).toBe(true)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave([], [], false)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isShowEditModal).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle edited items with changeValue updateType', async () => {
|
||||
const docListSingleDoc: DocListItem[] = [
|
||||
{
|
||||
id: 'doc-1',
|
||||
doc_metadata: [
|
||||
{ id: '1', name: 'field_one', type: DataType.string, value: 'Old Value' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useBatchEditDocumentMetadata({
|
||||
...defaultProps,
|
||||
docList: docListSingleDoc as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
|
||||
}),
|
||||
)
|
||||
|
||||
const editedList: MetadataItemWithEdit[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'field_one',
|
||||
type: DataType.string,
|
||||
value: 'New Value',
|
||||
updateType: UpdateType.changeValue,
|
||||
},
|
||||
]
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave(editedList, [], false)
|
||||
})
|
||||
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metadata_list: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
document_id: 'doc-1',
|
||||
metadata_list: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: '1',
|
||||
value: 'New Value',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle removed items', async () => {
|
||||
const docListSingleDoc: DocListItem[] = [
|
||||
{
|
||||
id: 'doc-1',
|
||||
doc_metadata: [
|
||||
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
|
||||
{ id: '2', name: 'field_two', type: DataType.number, value: 42 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useBatchEditDocumentMetadata({
|
||||
...defaultProps,
|
||||
docList: docListSingleDoc as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
|
||||
}),
|
||||
)
|
||||
|
||||
// Only pass field_one in editedList, field_two should be removed
|
||||
const editedList: MetadataItemWithEdit[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'field_one',
|
||||
type: DataType.string,
|
||||
value: 'Value 1',
|
||||
},
|
||||
]
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave(editedList, [], false)
|
||||
})
|
||||
|
||||
expect(mockMutateAsync).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle added items', async () => {
|
||||
const docListSingleDoc: DocListItem[] = [
|
||||
{
|
||||
id: 'doc-1',
|
||||
doc_metadata: [
|
||||
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useBatchEditDocumentMetadata({
|
||||
...defaultProps,
|
||||
docList: docListSingleDoc as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
|
||||
}),
|
||||
)
|
||||
|
||||
const addedList = [
|
||||
{
|
||||
id: 'new-1',
|
||||
name: 'new_field',
|
||||
type: DataType.string,
|
||||
value: 'New Value',
|
||||
isMultipleValue: false,
|
||||
},
|
||||
]
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave([], addedList, false)
|
||||
})
|
||||
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metadata_list: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
metadata_list: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'new_field',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should add missing metadata when isApplyToAllSelectDocument is true', async () => {
|
||||
// Doc 1 has field, Doc 2 doesn't have it
|
||||
const docListMissingField: DocListItem[] = [
|
||||
{
|
||||
id: 'doc-1',
|
||||
doc_metadata: [
|
||||
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'doc-2',
|
||||
doc_metadata: [],
|
||||
},
|
||||
]
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useBatchEditDocumentMetadata({
|
||||
...defaultProps,
|
||||
docList: docListMissingField as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
|
||||
}),
|
||||
)
|
||||
|
||||
const editedList: MetadataItemWithEdit[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'field_one',
|
||||
type: DataType.string,
|
||||
value: 'Updated Value',
|
||||
isMultipleValue: false,
|
||||
updateType: UpdateType.changeValue,
|
||||
},
|
||||
]
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave(editedList, [], true)
|
||||
})
|
||||
|
||||
// Both documents should have the field after applying to all
|
||||
expect(mockMutateAsync).toHaveBeenCalled()
|
||||
const callArgs = mockMutateAsync.mock.calls[0][0]
|
||||
expect(callArgs.metadata_list.length).toBe(2)
|
||||
})
|
||||
|
||||
it('should not add missing metadata for multiple value items when isApplyToAllSelectDocument is true', async () => {
|
||||
// Two docs with different values for same field
|
||||
const docListDifferentValues: DocListItem[] = [
|
||||
{
|
||||
id: 'doc-1',
|
||||
doc_metadata: [
|
||||
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value A' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'doc-2',
|
||||
doc_metadata: [
|
||||
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value B' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'doc-3',
|
||||
doc_metadata: [],
|
||||
},
|
||||
]
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useBatchEditDocumentMetadata({
|
||||
...defaultProps,
|
||||
docList: docListDifferentValues as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
|
||||
}),
|
||||
)
|
||||
|
||||
// Mark it as multiple value item - should not be added to doc-3
|
||||
const editedList: MetadataItemWithEdit[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'field_one',
|
||||
type: DataType.string,
|
||||
value: null,
|
||||
isMultipleValue: true,
|
||||
updateType: UpdateType.changeValue,
|
||||
},
|
||||
]
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave(editedList, [], true)
|
||||
})
|
||||
|
||||
expect(mockMutateAsync).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update existing items in the list', async () => {
|
||||
const docListSingleDoc: DocListItem[] = [
|
||||
{
|
||||
id: 'doc-1',
|
||||
doc_metadata: [
|
||||
{ id: '1', name: 'field_one', type: DataType.string, value: 'Old Value' },
|
||||
{ id: '2', name: 'field_two', type: DataType.number, value: 100 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useBatchEditDocumentMetadata({
|
||||
...defaultProps,
|
||||
docList: docListSingleDoc as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
|
||||
}),
|
||||
)
|
||||
|
||||
// Edit both items
|
||||
const editedList: MetadataItemWithEdit[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'field_one',
|
||||
type: DataType.string,
|
||||
value: 'New Value 1',
|
||||
updateType: UpdateType.changeValue,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'field_two',
|
||||
type: DataType.number,
|
||||
value: 200,
|
||||
updateType: UpdateType.changeValue,
|
||||
},
|
||||
]
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave(editedList, [], false)
|
||||
})
|
||||
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metadata_list: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
metadata_list: expect.arrayContaining([
|
||||
expect.objectContaining({ id: '1', value: 'New Value 1' }),
|
||||
expect.objectContaining({ id: '2', value: 200 }),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Selected Document IDs', () => {
|
||||
it('should use selectedDocumentIds when provided', async () => {
|
||||
const selectedIds = ['doc-1']
|
||||
const { result } = renderHook(() =>
|
||||
useBatchEditDocumentMetadata({
|
||||
...defaultProps,
|
||||
selectedDocumentIds: selectedIds,
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave([], [], false)
|
||||
})
|
||||
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
dataset_id: 'ds-1',
|
||||
metadata_list: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
document_id: 'doc-1',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle selectedDocumentIds not in docList', async () => {
|
||||
// Select a document that's not in docList
|
||||
const selectedIds = ['doc-1', 'doc-not-in-list']
|
||||
const { result } = renderHook(() =>
|
||||
useBatchEditDocumentMetadata({
|
||||
...defaultProps,
|
||||
selectedDocumentIds: selectedIds,
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave([], [], false)
|
||||
})
|
||||
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metadata_list: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
document_id: 'doc-not-in-list',
|
||||
partial_update: true,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty docList', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useBatchEditDocumentMetadata({
|
||||
...defaultProps,
|
||||
docList: [] as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.originalList).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle documents without metadata', () => {
|
||||
const docListNoMetadata: DocListItem[] = [
|
||||
{ id: 'doc-1', name: 'Doc 1' },
|
||||
{ id: 'doc-2', name: 'Doc 2', doc_metadata: null },
|
||||
]
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useBatchEditDocumentMetadata({
|
||||
...defaultProps,
|
||||
docList: docListNoMetadata as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.originalList).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,166 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import useCheckMetadataName from './use-check-metadata-name'
|
||||
|
||||
describe('useCheckMetadataName', () => {
|
||||
describe('Hook Initialization', () => {
|
||||
it('should return an object with checkName function', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
expect(result.current).toHaveProperty('checkName')
|
||||
expect(typeof result.current.checkName).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkName - Empty Name Validation', () => {
|
||||
it('should return error for empty string', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const { errorMsg } = result.current.checkName('')
|
||||
expect(errorMsg).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should return error for whitespace-only string', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
// Whitespace is not valid since it doesn't match the pattern
|
||||
const { errorMsg } = result.current.checkName(' ')
|
||||
expect(errorMsg).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkName - Pattern Validation', () => {
|
||||
it('should return error for name starting with number', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const { errorMsg } = result.current.checkName('1name')
|
||||
expect(errorMsg).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should return error for name starting with uppercase', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const { errorMsg } = result.current.checkName('Name')
|
||||
expect(errorMsg).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should return error for name starting with underscore', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const { errorMsg } = result.current.checkName('_name')
|
||||
expect(errorMsg).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should return error for name with spaces', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const { errorMsg } = result.current.checkName('my name')
|
||||
expect(errorMsg).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should return error for name with special characters', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const { errorMsg } = result.current.checkName('name-with-dash')
|
||||
expect(errorMsg).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should return error for name with dots', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const { errorMsg } = result.current.checkName('name.with.dot')
|
||||
expect(errorMsg).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should accept valid name starting with lowercase letter', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const { errorMsg } = result.current.checkName('validname')
|
||||
expect(errorMsg).toBe('')
|
||||
})
|
||||
|
||||
it('should accept valid name with numbers after first character', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const { errorMsg } = result.current.checkName('name123')
|
||||
expect(errorMsg).toBe('')
|
||||
})
|
||||
|
||||
it('should accept valid name with underscores after first character', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const { errorMsg } = result.current.checkName('name_with_underscore')
|
||||
expect(errorMsg).toBe('')
|
||||
})
|
||||
|
||||
it('should accept single lowercase letter', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const { errorMsg } = result.current.checkName('a')
|
||||
expect(errorMsg).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkName - Length Validation', () => {
|
||||
it('should return error for name longer than 255 characters', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const longName = 'a'.repeat(256)
|
||||
const { errorMsg } = result.current.checkName(longName)
|
||||
expect(errorMsg).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should accept name with exactly 255 characters', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const maxLengthName = 'a'.repeat(255)
|
||||
const { errorMsg } = result.current.checkName(maxLengthName)
|
||||
expect(errorMsg).toBe('')
|
||||
})
|
||||
|
||||
it('should accept name with less than 255 characters', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const shortName = 'a'.repeat(100)
|
||||
const { errorMsg } = result.current.checkName(shortName)
|
||||
expect(errorMsg).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkName - Edge Cases', () => {
|
||||
it('should validate all lowercase letters', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const { errorMsg } = result.current.checkName('abcdefghijklmnopqrstuvwxyz')
|
||||
expect(errorMsg).toBe('')
|
||||
})
|
||||
|
||||
it('should validate name with mixed numbers and underscores', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const { errorMsg } = result.current.checkName('a1_2_3_test')
|
||||
expect(errorMsg).toBe('')
|
||||
})
|
||||
|
||||
it('should reject uppercase letters anywhere in name', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const { errorMsg } = result.current.checkName('nameWithUppercase')
|
||||
expect(errorMsg).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should reject unicode characters', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const { errorMsg } = result.current.checkName('名字')
|
||||
expect(errorMsg).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should reject emoji characters', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const { errorMsg } = result.current.checkName('name😀')
|
||||
expect(errorMsg).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Return Value Structure', () => {
|
||||
it('should return object with errorMsg property', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const returnValue = result.current.checkName('test')
|
||||
expect(returnValue).toHaveProperty('errorMsg')
|
||||
})
|
||||
|
||||
it('should return empty string for valid name', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const { errorMsg } = result.current.checkName('valid_name')
|
||||
expect(errorMsg).toBe('')
|
||||
})
|
||||
|
||||
it('should return non-empty string for invalid name', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const { errorMsg } = result.current.checkName('')
|
||||
expect(typeof errorMsg).toBe('string')
|
||||
expect(errorMsg.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,308 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DataType } from '../types'
|
||||
import useEditDatasetMetadata from './use-edit-dataset-metadata'
|
||||
|
||||
// Mock service hooks
|
||||
const mockDoAddMetaData = vi.fn().mockResolvedValue({})
|
||||
const mockDoRenameMetaData = vi.fn().mockResolvedValue({})
|
||||
const mockDoDeleteMetaData = vi.fn().mockResolvedValue({})
|
||||
const mockToggleBuiltInStatus = vi.fn().mockResolvedValue({})
|
||||
|
||||
vi.mock('@/service/knowledge/use-metadata', () => ({
|
||||
useDatasetMetaData: () => ({
|
||||
data: {
|
||||
doc_metadata: [
|
||||
{ id: '1', name: 'field_one', type: DataType.string, count: 5 },
|
||||
{ id: '2', name: 'field_two', type: DataType.number, count: 3 },
|
||||
],
|
||||
built_in_field_enabled: false,
|
||||
},
|
||||
}),
|
||||
useCreateMetaData: () => ({
|
||||
mutate: mockDoAddMetaData,
|
||||
}),
|
||||
useRenameMeta: () => ({
|
||||
mutate: mockDoRenameMetaData,
|
||||
}),
|
||||
useDeleteMetaData: () => ({
|
||||
mutateAsync: mockDoDeleteMetaData,
|
||||
}),
|
||||
useUpdateBuiltInStatus: () => ({
|
||||
mutateAsync: mockToggleBuiltInStatus,
|
||||
}),
|
||||
useBuiltInMetaDataFields: () => ({
|
||||
data: {
|
||||
fields: [
|
||||
{ name: 'created_at', type: DataType.time },
|
||||
{ name: 'modified_at', type: DataType.time },
|
||||
],
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock Toast
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock useCheckMetadataName
|
||||
vi.mock('./use-check-metadata-name', () => ({
|
||||
default: () => ({
|
||||
checkName: (name: string) => ({
|
||||
errorMsg: name && /^[a-z][a-z0-9_]*$/.test(name) ? '' : 'Invalid name',
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
}
|
||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
|
||||
|
||||
describe('useEditDatasetMetadata', () => {
|
||||
const defaultProps = {
|
||||
datasetId: 'ds-1',
|
||||
onUpdateDocList: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorageMock.getItem.mockReturnValue(null)
|
||||
})
|
||||
|
||||
describe('Hook Initialization', () => {
|
||||
it('should initialize with isShowEditModal as false', () => {
|
||||
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
|
||||
expect(result.current.isShowEditModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should return showEditModal function', () => {
|
||||
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
|
||||
expect(typeof result.current.showEditModal).toBe('function')
|
||||
})
|
||||
|
||||
it('should return hideEditModal function', () => {
|
||||
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
|
||||
expect(typeof result.current.hideEditModal).toBe('function')
|
||||
})
|
||||
|
||||
it('should return datasetMetaData', () => {
|
||||
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
|
||||
expect(result.current.datasetMetaData).toBeDefined()
|
||||
})
|
||||
|
||||
it('should return handleAddMetaData function', () => {
|
||||
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
|
||||
expect(typeof result.current.handleAddMetaData).toBe('function')
|
||||
})
|
||||
|
||||
it('should return handleRename function', () => {
|
||||
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
|
||||
expect(typeof result.current.handleRename).toBe('function')
|
||||
})
|
||||
|
||||
it('should return handleDeleteMetaData function', () => {
|
||||
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
|
||||
expect(typeof result.current.handleDeleteMetaData).toBe('function')
|
||||
})
|
||||
|
||||
it('should return builtInMetaData', () => {
|
||||
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
|
||||
expect(result.current.builtInMetaData).toBeDefined()
|
||||
})
|
||||
|
||||
it('should return builtInEnabled', () => {
|
||||
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
|
||||
expect(typeof result.current.builtInEnabled).toBe('boolean')
|
||||
})
|
||||
|
||||
it('should return setBuiltInEnabled function', () => {
|
||||
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
|
||||
expect(typeof result.current.setBuiltInEnabled).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Modal Control', () => {
|
||||
it('should show modal when showEditModal is called', () => {
|
||||
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
|
||||
|
||||
act(() => {
|
||||
result.current.showEditModal()
|
||||
})
|
||||
|
||||
expect(result.current.isShowEditModal).toBe(true)
|
||||
})
|
||||
|
||||
it('should hide modal when hideEditModal is called', () => {
|
||||
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
|
||||
|
||||
act(() => {
|
||||
result.current.showEditModal()
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.hideEditModal()
|
||||
})
|
||||
|
||||
expect(result.current.isShowEditModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle toggle of modal state', () => {
|
||||
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
|
||||
|
||||
// Initially closed
|
||||
expect(result.current.isShowEditModal).toBe(false)
|
||||
|
||||
// Show, hide, show
|
||||
act(() => result.current.showEditModal())
|
||||
expect(result.current.isShowEditModal).toBe(true)
|
||||
|
||||
act(() => result.current.hideEditModal())
|
||||
expect(result.current.isShowEditModal).toBe(false)
|
||||
|
||||
act(() => result.current.showEditModal())
|
||||
expect(result.current.isShowEditModal).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleAddMetaData', () => {
|
||||
it('should call doAddMetaData with valid name', async () => {
|
||||
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleAddMetaData({
|
||||
name: 'valid_name',
|
||||
type: DataType.string,
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockDoAddMetaData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reject invalid name', async () => {
|
||||
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
|
||||
|
||||
await expect(
|
||||
act(async () => {
|
||||
await result.current.handleAddMetaData({
|
||||
name: '',
|
||||
type: DataType.string,
|
||||
})
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleRename', () => {
|
||||
it('should call doRenameMetaData with valid name', async () => {
|
||||
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleRename({
|
||||
id: '1',
|
||||
name: 'new_valid_name',
|
||||
type: DataType.string,
|
||||
count: 5,
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockDoRenameMetaData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onUpdateDocList after rename', async () => {
|
||||
const onUpdateDocList = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useEditDatasetMetadata({ ...defaultProps, onUpdateDocList }),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleRename({
|
||||
id: '1',
|
||||
name: 'renamed',
|
||||
type: DataType.string,
|
||||
count: 5,
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUpdateDocList).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should reject invalid name for rename', async () => {
|
||||
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
|
||||
|
||||
await expect(
|
||||
act(async () => {
|
||||
await result.current.handleRename({
|
||||
id: '1',
|
||||
name: 'Invalid Name',
|
||||
type: DataType.string,
|
||||
count: 5,
|
||||
})
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleDeleteMetaData', () => {
|
||||
it('should call doDeleteMetaData', async () => {
|
||||
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDeleteMetaData('1')
|
||||
})
|
||||
|
||||
expect(mockDoDeleteMetaData).toHaveBeenCalledWith('1')
|
||||
})
|
||||
|
||||
it('should call onUpdateDocList after delete', async () => {
|
||||
const onUpdateDocList = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useEditDatasetMetadata({ ...defaultProps, onUpdateDocList }),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDeleteMetaData('1')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUpdateDocList).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Built-in Status', () => {
|
||||
it('should toggle built-in status', async () => {
|
||||
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.setBuiltInEnabled(true)
|
||||
})
|
||||
|
||||
expect(mockToggleBuiltInStatus).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle different datasetIds', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
props => useEditDatasetMetadata(props),
|
||||
{ initialProps: defaultProps },
|
||||
)
|
||||
|
||||
expect(result.current).toBeDefined()
|
||||
|
||||
rerender({ ...defaultProps, datasetId: 'ds-2' })
|
||||
|
||||
expect(result.current).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,587 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DataType } from '../types'
|
||||
import useMetadataDocument from './use-metadata-document'
|
||||
|
||||
type DocDetail = {
|
||||
id: string
|
||||
name: string
|
||||
data_source_type: string
|
||||
word_count: number
|
||||
language?: string
|
||||
hit_count?: number
|
||||
segment_count?: number
|
||||
}
|
||||
|
||||
// Mock service hooks
|
||||
const mockMutateAsync = vi.fn().mockResolvedValue({})
|
||||
const mockDoAddMetaData = vi.fn().mockResolvedValue({})
|
||||
|
||||
vi.mock('@/service/knowledge/use-metadata', () => ({
|
||||
useBatchUpdateDocMetadata: () => ({
|
||||
mutateAsync: mockMutateAsync,
|
||||
}),
|
||||
useCreateMetaData: () => ({
|
||||
mutateAsync: mockDoAddMetaData,
|
||||
}),
|
||||
useDocumentMetaData: () => ({
|
||||
data: {
|
||||
doc_metadata: [
|
||||
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
|
||||
{ id: '2', name: 'field_two', type: DataType.number, value: 42 },
|
||||
{ id: 'built-in', name: 'created_at', type: DataType.time, value: 1609459200 },
|
||||
],
|
||||
},
|
||||
}),
|
||||
useDatasetMetaData: () => ({
|
||||
data: {
|
||||
built_in_field_enabled: true,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useDatasetDetailContext
|
||||
vi.mock('@/context/dataset-detail', () => ({
|
||||
useDatasetDetailContext: () => ({
|
||||
dataset: {
|
||||
embedding_available: true,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useMetadataMap and useLanguages with comprehensive field definitions
|
||||
vi.mock('@/hooks/use-metadata', () => ({
|
||||
useMetadataMap: () => ({
|
||||
originInfo: {
|
||||
subFieldsMap: {
|
||||
data_source_type: { label: 'Source Type', inputType: 'text' },
|
||||
language: { label: 'Language', inputType: 'select' },
|
||||
empty_field: { label: 'Empty Field', inputType: 'text' },
|
||||
},
|
||||
},
|
||||
technicalParameters: {
|
||||
subFieldsMap: {
|
||||
word_count: { label: 'Word Count', inputType: 'text' },
|
||||
hit_count: {
|
||||
label: 'Hit Count',
|
||||
inputType: 'text',
|
||||
render: (val: number, segmentCount?: number) => `${val}/${segmentCount || 0}`,
|
||||
},
|
||||
custom_render: {
|
||||
label: 'Custom Render',
|
||||
inputType: 'text',
|
||||
render: (val: string) => `Rendered: ${val}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
useLanguages: () => ({
|
||||
en: 'English',
|
||||
zh: 'Chinese',
|
||||
ja: 'Japanese',
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock Toast
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock useCheckMetadataName
|
||||
vi.mock('./use-check-metadata-name', () => ({
|
||||
default: () => ({
|
||||
checkName: (name: string) => ({
|
||||
errorMsg: name && /^[a-z][a-z0-9_]*$/.test(name) ? '' : 'Invalid name',
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('useMetadataDocument', () => {
|
||||
const mockDocDetail: DocDetail = {
|
||||
id: 'doc-1',
|
||||
name: 'Test Document',
|
||||
data_source_type: 'upload_file',
|
||||
word_count: 100,
|
||||
language: 'en',
|
||||
hit_count: 50,
|
||||
segment_count: 10,
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
datasetId: 'ds-1',
|
||||
documentId: 'doc-1',
|
||||
docDetail: mockDocDetail as Parameters<typeof useMetadataDocument>[0]['docDetail'],
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Hook Initialization', () => {
|
||||
it('should return embeddingAvailable', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
expect(result.current.embeddingAvailable).toBe(true)
|
||||
})
|
||||
|
||||
it('should return isEdit as false initially', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
expect(result.current.isEdit).toBe(false)
|
||||
})
|
||||
|
||||
it('should return setIsEdit function', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
expect(typeof result.current.setIsEdit).toBe('function')
|
||||
})
|
||||
|
||||
it('should return list without built-in items', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
const hasBuiltIn = result.current.list.some(item => item.id === 'built-in')
|
||||
expect(hasBuiltIn).toBe(false)
|
||||
})
|
||||
|
||||
it('should return builtList with only built-in items', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
const allBuiltIn = result.current.builtList.every(item => item.id === 'built-in')
|
||||
expect(allBuiltIn).toBe(true)
|
||||
})
|
||||
|
||||
it('should return tempList', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
expect(Array.isArray(result.current.tempList)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return setTempList function', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
expect(typeof result.current.setTempList).toBe('function')
|
||||
})
|
||||
|
||||
it('should return hasData based on list length', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
expect(result.current.hasData).toBe(result.current.list.length > 0)
|
||||
})
|
||||
|
||||
it('should return builtInEnabled', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
expect(typeof result.current.builtInEnabled).toBe('boolean')
|
||||
})
|
||||
|
||||
it('should return originInfo', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
expect(Array.isArray(result.current.originInfo)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return technicalParameters', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
expect(Array.isArray(result.current.technicalParameters)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edit Mode', () => {
|
||||
it('should enter edit mode when startToEdit is called', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
|
||||
act(() => {
|
||||
result.current.startToEdit()
|
||||
})
|
||||
|
||||
expect(result.current.isEdit).toBe(true)
|
||||
})
|
||||
|
||||
it('should exit edit mode when handleCancel is called', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
|
||||
act(() => {
|
||||
result.current.startToEdit()
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleCancel()
|
||||
})
|
||||
|
||||
expect(result.current.isEdit).toBe(false)
|
||||
})
|
||||
|
||||
it('should reset tempList when handleCancel is called', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
|
||||
act(() => {
|
||||
result.current.startToEdit()
|
||||
})
|
||||
|
||||
const originalLength = result.current.list.length
|
||||
|
||||
act(() => {
|
||||
result.current.setTempList([])
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleCancel()
|
||||
})
|
||||
|
||||
expect(result.current.tempList.length).toBe(originalLength)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleSelectMetaData', () => {
|
||||
it('should add metadata to tempList if not exists', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
|
||||
act(() => {
|
||||
result.current.startToEdit()
|
||||
})
|
||||
|
||||
const initialLength = result.current.tempList.length
|
||||
|
||||
act(() => {
|
||||
result.current.handleSelectMetaData({
|
||||
id: 'new-id',
|
||||
name: 'new_field',
|
||||
type: DataType.string,
|
||||
value: null,
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current.tempList.length).toBe(initialLength + 1)
|
||||
})
|
||||
|
||||
it('should not add duplicate metadata', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
|
||||
act(() => {
|
||||
result.current.startToEdit()
|
||||
})
|
||||
|
||||
const initialLength = result.current.tempList.length
|
||||
|
||||
// Try to add existing item
|
||||
if (result.current.tempList.length > 0) {
|
||||
act(() => {
|
||||
result.current.handleSelectMetaData(result.current.tempList[0])
|
||||
})
|
||||
|
||||
expect(result.current.tempList.length).toBe(initialLength)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleAddMetaData', () => {
|
||||
it('should call doAddMetaData with valid name', async () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleAddMetaData({
|
||||
name: 'valid_field',
|
||||
type: DataType.string,
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockDoAddMetaData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reject invalid name', async () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
|
||||
await expect(
|
||||
act(async () => {
|
||||
await result.current.handleAddMetaData({
|
||||
name: '',
|
||||
type: DataType.string,
|
||||
})
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleSave', () => {
|
||||
it('should call mutateAsync to save metadata', async () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
|
||||
act(() => {
|
||||
result.current.startToEdit()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave()
|
||||
})
|
||||
|
||||
expect(mockMutateAsync).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should exit edit mode after save', async () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
|
||||
act(() => {
|
||||
result.current.startToEdit()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isEdit).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getReadOnlyMetaData - originInfo', () => {
|
||||
it('should return origin info with correct structure', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
|
||||
expect(result.current.originInfo).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: DataType.string,
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('should use languageMap for language field (select type)', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
|
||||
// Find language field in originInfo
|
||||
const languageField = result.current.originInfo.find(
|
||||
item => item.name === 'Language',
|
||||
)
|
||||
|
||||
// If language field exists and docDetail has language 'en', value should be 'English'
|
||||
if (languageField)
|
||||
expect(languageField.value).toBe('English')
|
||||
})
|
||||
|
||||
it('should return dash for empty field values', () => {
|
||||
const docDetailWithEmpty: DocDetail = {
|
||||
id: 'doc-1',
|
||||
name: 'Test Document',
|
||||
data_source_type: 'upload_file',
|
||||
word_count: 100,
|
||||
}
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useMetadataDocument({
|
||||
...defaultProps,
|
||||
docDetail: docDetailWithEmpty as Parameters<typeof useMetadataDocument>[0]['docDetail'],
|
||||
}),
|
||||
)
|
||||
|
||||
// Check if there's any field with '-' value (meaning empty)
|
||||
const hasEmptyField = result.current.originInfo.some(
|
||||
item => item.value === '-',
|
||||
)
|
||||
// language field should return '-' since it's not set
|
||||
expect(hasEmptyField).toBe(true)
|
||||
})
|
||||
|
||||
it('should return empty object for non-language select fields', () => {
|
||||
// This tests the else branch of getTargetMap where field !== 'language'
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
|
||||
// The data_source_type field is a text field, not select
|
||||
const sourceTypeField = result.current.originInfo.find(
|
||||
item => item.name === 'Source Type',
|
||||
)
|
||||
|
||||
// It should return the raw value since it's not a select type
|
||||
if (sourceTypeField)
|
||||
expect(sourceTypeField.value).toBe('upload_file')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getReadOnlyMetaData - technicalParameters', () => {
|
||||
it('should return technical parameters with correct structure', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
|
||||
expect(result.current.technicalParameters).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: DataType.string,
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('should use render function when available', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
|
||||
// Find hit_count field which has a render function
|
||||
const hitCountField = result.current.technicalParameters.find(
|
||||
item => item.name === 'Hit Count',
|
||||
)
|
||||
|
||||
// The render function should format as "val/segmentCount"
|
||||
if (hitCountField)
|
||||
expect(hitCountField.value).toBe('50/10')
|
||||
})
|
||||
|
||||
it('should return raw value when no render function', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
|
||||
// Find word_count field which has no render function
|
||||
const wordCountField = result.current.technicalParameters.find(
|
||||
item => item.name === 'Word Count',
|
||||
)
|
||||
|
||||
if (wordCountField)
|
||||
expect(wordCountField.value).toBe(100)
|
||||
})
|
||||
|
||||
it('should handle fields with render function and undefined segment_count', () => {
|
||||
const docDetailNoSegment: DocDetail = {
|
||||
id: 'doc-1',
|
||||
name: 'Test Document',
|
||||
data_source_type: 'upload_file',
|
||||
word_count: 100,
|
||||
hit_count: 25,
|
||||
}
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useMetadataDocument({
|
||||
...defaultProps,
|
||||
docDetail: docDetailNoSegment as Parameters<typeof useMetadataDocument>[0]['docDetail'],
|
||||
}),
|
||||
)
|
||||
|
||||
const hitCountField = result.current.technicalParameters.find(
|
||||
item => item.name === 'Hit Count',
|
||||
)
|
||||
|
||||
// Should use 0 as default for segment_count
|
||||
if (hitCountField)
|
||||
expect(hitCountField.value).toBe('25/0')
|
||||
})
|
||||
|
||||
it('should return dash for null/undefined values', () => {
|
||||
const docDetailWithNull: DocDetail = {
|
||||
id: 'doc-1',
|
||||
name: 'Test Document',
|
||||
data_source_type: '',
|
||||
word_count: 0,
|
||||
}
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useMetadataDocument({
|
||||
...defaultProps,
|
||||
docDetail: docDetailWithNull as Parameters<typeof useMetadataDocument>[0]['docDetail'],
|
||||
}),
|
||||
)
|
||||
|
||||
// 0 should still be shown, but empty string should show '-'
|
||||
const sourceTypeField = result.current.originInfo.find(
|
||||
item => item.name === 'Source Type',
|
||||
)
|
||||
|
||||
if (sourceTypeField)
|
||||
expect(sourceTypeField.value).toBe('-')
|
||||
})
|
||||
|
||||
it('should handle 0 value correctly (not treated as empty)', () => {
|
||||
const docDetailWithZero: DocDetail = {
|
||||
id: 'doc-1',
|
||||
name: 'Test Document',
|
||||
data_source_type: 'upload_file',
|
||||
word_count: 0,
|
||||
}
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useMetadataDocument({
|
||||
...defaultProps,
|
||||
docDetail: docDetailWithZero as Parameters<typeof useMetadataDocument>[0]['docDetail'],
|
||||
}),
|
||||
)
|
||||
|
||||
// word_count of 0 should still show 0, not '-'
|
||||
const wordCountField = result.current.technicalParameters.find(
|
||||
item => item.name === 'Word Count',
|
||||
)
|
||||
|
||||
if (wordCountField)
|
||||
expect(wordCountField.value).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty docDetail', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useMetadataDocument({
|
||||
...defaultProps,
|
||||
docDetail: {} as Parameters<typeof useMetadataDocument>[0]['docDetail'],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current).toBeDefined()
|
||||
})
|
||||
|
||||
it('should handle different datasetIds', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
props => useMetadataDocument(props),
|
||||
{ initialProps: defaultProps },
|
||||
)
|
||||
|
||||
expect(result.current).toBeDefined()
|
||||
|
||||
rerender({ ...defaultProps, datasetId: 'ds-2' })
|
||||
|
||||
expect(result.current).toBeDefined()
|
||||
})
|
||||
|
||||
it('should handle docDetail with all fields', () => {
|
||||
const fullDocDetail: DocDetail = {
|
||||
id: 'doc-1',
|
||||
name: 'Full Document',
|
||||
data_source_type: 'website',
|
||||
word_count: 500,
|
||||
language: 'zh',
|
||||
hit_count: 100,
|
||||
segment_count: 20,
|
||||
}
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useMetadataDocument({
|
||||
...defaultProps,
|
||||
docDetail: fullDocDetail as Parameters<typeof useMetadataDocument>[0]['docDetail'],
|
||||
}),
|
||||
)
|
||||
|
||||
// Language should be mapped
|
||||
const languageField = result.current.originInfo.find(
|
||||
item => item.name === 'Language',
|
||||
)
|
||||
if (languageField)
|
||||
expect(languageField.value).toBe('Chinese')
|
||||
|
||||
// Hit count should be rendered
|
||||
const hitCountField = result.current.technicalParameters.find(
|
||||
item => item.name === 'Hit Count',
|
||||
)
|
||||
if (hitCountField)
|
||||
expect(hitCountField.value).toBe('100/20')
|
||||
})
|
||||
|
||||
it('should handle unknown language', () => {
|
||||
const unknownLangDetail: DocDetail = {
|
||||
id: 'doc-1',
|
||||
name: 'Unknown Lang Document',
|
||||
data_source_type: 'upload_file',
|
||||
word_count: 100,
|
||||
language: 'unknown_lang',
|
||||
}
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useMetadataDocument({
|
||||
...defaultProps,
|
||||
docDetail: unknownLangDetail as Parameters<typeof useMetadataDocument>[0]['docDetail'],
|
||||
}),
|
||||
)
|
||||
|
||||
// Unknown language should return undefined from the map
|
||||
const languageField = result.current.originInfo.find(
|
||||
item => item.name === 'Language',
|
||||
)
|
||||
// When language is not in map, it returns undefined
|
||||
expect(languageField?.value).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,268 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DataType } from '../types'
|
||||
import CreateContent from './create-content'
|
||||
|
||||
type ModalLikeWrapProps = {
|
||||
children: React.ReactNode
|
||||
title: string
|
||||
onClose?: () => void
|
||||
onConfirm: () => void
|
||||
beforeHeader?: React.ReactNode
|
||||
}
|
||||
|
||||
type OptionCardProps = {
|
||||
title: string
|
||||
selected: boolean
|
||||
onSelect: () => void
|
||||
}
|
||||
|
||||
type FieldProps = {
|
||||
label: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
// Mock ModalLikeWrap
|
||||
vi.mock('../../../base/modal-like-wrap', () => ({
|
||||
default: ({ children, title, onClose, onConfirm, beforeHeader }: ModalLikeWrapProps) => (
|
||||
<div data-testid="modal-wrap">
|
||||
<div data-testid="modal-title">{title}</div>
|
||||
{!!beforeHeader && <div data-testid="before-header">{beforeHeader}</div>}
|
||||
<div data-testid="modal-content">{children}</div>
|
||||
<button data-testid="close-btn" onClick={onClose}>Close</button>
|
||||
<button data-testid="confirm-btn" onClick={onConfirm}>Confirm</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock OptionCard
|
||||
vi.mock('../../../workflow/nodes/_base/components/option-card', () => ({
|
||||
default: ({ title, selected, onSelect }: OptionCardProps) => (
|
||||
<button
|
||||
data-testid={`option-${title.toLowerCase()}`}
|
||||
data-selected={selected}
|
||||
onClick={onSelect}
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock Field
|
||||
vi.mock('./field', () => ({
|
||||
default: ({ label, children }: FieldProps) => (
|
||||
<div data-testid="field">
|
||||
<label data-testid="field-label">{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('CreateContent', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const handleSave = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} />)
|
||||
expect(screen.getByTestId('modal-wrap')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render modal title', () => {
|
||||
const handleSave = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} />)
|
||||
expect(screen.getByTestId('modal-title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render type selection options', () => {
|
||||
const handleSave = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} />)
|
||||
expect(screen.getByTestId('option-string')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('option-number')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('option-time')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render name input field', () => {
|
||||
const handleSave = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} />)
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render confirm button', () => {
|
||||
const handleSave = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} />)
|
||||
expect(screen.getByTestId('confirm-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render close button', () => {
|
||||
const handleSave = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} />)
|
||||
expect(screen.getByTestId('close-btn')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Type Selection', () => {
|
||||
it('should default to string type', () => {
|
||||
const handleSave = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} />)
|
||||
expect(screen.getByTestId('option-string')).toHaveAttribute('data-selected', 'true')
|
||||
})
|
||||
|
||||
it('should select number type when clicked', () => {
|
||||
const handleSave = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('option-number'))
|
||||
|
||||
expect(screen.getByTestId('option-number')).toHaveAttribute('data-selected', 'true')
|
||||
})
|
||||
|
||||
it('should select time type when clicked', () => {
|
||||
const handleSave = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('option-time'))
|
||||
|
||||
expect(screen.getByTestId('option-time')).toHaveAttribute('data-selected', 'true')
|
||||
})
|
||||
|
||||
it('should deselect previous type when new type is selected', () => {
|
||||
const handleSave = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} />)
|
||||
|
||||
// Initially string is selected
|
||||
expect(screen.getByTestId('option-string')).toHaveAttribute('data-selected', 'true')
|
||||
|
||||
// Select number
|
||||
fireEvent.click(screen.getByTestId('option-number'))
|
||||
|
||||
expect(screen.getByTestId('option-string')).toHaveAttribute('data-selected', 'false')
|
||||
expect(screen.getByTestId('option-number')).toHaveAttribute('data-selected', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Name Input', () => {
|
||||
it('should update name when typing', () => {
|
||||
const handleSave = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'new_field' } })
|
||||
|
||||
expect(input).toHaveValue('new_field')
|
||||
})
|
||||
|
||||
it('should start with empty name', () => {
|
||||
const handleSave = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveValue('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onSave with type and name when confirmed', () => {
|
||||
const handleSave = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'test_field' } })
|
||||
fireEvent.click(screen.getByTestId('confirm-btn'))
|
||||
|
||||
expect(handleSave).toHaveBeenCalledWith({
|
||||
type: DataType.string,
|
||||
name: 'test_field',
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onSave with correct type after changing type', () => {
|
||||
const handleSave = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('option-number'))
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'num_field' } })
|
||||
fireEvent.click(screen.getByTestId('confirm-btn'))
|
||||
|
||||
expect(handleSave).toHaveBeenCalledWith({
|
||||
type: DataType.number,
|
||||
name: 'num_field',
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onClose when close button is clicked', () => {
|
||||
const handleSave = vi.fn()
|
||||
const handleClose = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} onClose={handleClose} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('close-btn'))
|
||||
|
||||
expect(handleClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Back Button', () => {
|
||||
it('should show back button when hasBack is true', () => {
|
||||
const handleSave = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} hasBack />)
|
||||
|
||||
expect(screen.getByTestId('before-header')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show back button when hasBack is false', () => {
|
||||
const handleSave = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} hasBack={false} />)
|
||||
|
||||
expect(screen.queryByTestId('before-header')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onBack when back button is clicked', () => {
|
||||
const handleSave = vi.fn()
|
||||
const handleBack = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} hasBack onBack={handleBack} />)
|
||||
|
||||
const backButton = screen.getByTestId('before-header')
|
||||
// Find the clickable element inside
|
||||
const clickable = backButton.querySelector('.cursor-pointer') || backButton.firstChild
|
||||
if (clickable)
|
||||
fireEvent.click(clickable)
|
||||
|
||||
// The back functionality is tested through the actual implementation
|
||||
expect(screen.getByTestId('before-header')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty name submission', () => {
|
||||
const handleSave = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-btn'))
|
||||
|
||||
expect(handleSave).toHaveBeenCalledWith({
|
||||
type: DataType.string,
|
||||
name: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle type cycling', () => {
|
||||
const handleSave = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} />)
|
||||
|
||||
// Cycle through all types
|
||||
fireEvent.click(screen.getByTestId('option-number'))
|
||||
fireEvent.click(screen.getByTestId('option-time'))
|
||||
fireEvent.click(screen.getByTestId('option-string'))
|
||||
|
||||
expect(screen.getByTestId('option-string')).toHaveAttribute('data-selected', 'true')
|
||||
})
|
||||
|
||||
it('should handle special characters in name', () => {
|
||||
const handleSave = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'test_field_123' } })
|
||||
|
||||
expect(input).toHaveValue('test_field_123')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,246 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DataType } from '../types'
|
||||
import CreateMetadataModal from './create-metadata-modal'
|
||||
|
||||
type PortalProps = {
|
||||
children: React.ReactNode
|
||||
open: boolean
|
||||
}
|
||||
|
||||
type TriggerProps = {
|
||||
children: React.ReactNode
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
type ContentProps = {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
type CreateContentProps = {
|
||||
onSave: (data: { type: DataType, name: string }) => void
|
||||
onClose?: () => void
|
||||
onBack?: () => void
|
||||
hasBack?: boolean
|
||||
}
|
||||
|
||||
// Mock PortalToFollowElem components
|
||||
vi.mock('../../../base/portal-to-follow-elem', () => ({
|
||||
PortalToFollowElem: ({ children, open }: PortalProps) => (
|
||||
<div data-testid="portal-wrapper" data-open={open}>{children}</div>
|
||||
),
|
||||
PortalToFollowElemTrigger: ({ children, onClick }: TriggerProps) => (
|
||||
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children, className }: ContentProps) => (
|
||||
<div data-testid="portal-content" className={className}>{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock CreateContent component
|
||||
vi.mock('./create-content', () => ({
|
||||
default: ({ onSave, onClose, onBack, hasBack }: CreateContentProps) => (
|
||||
<div data-testid="create-content">
|
||||
<span data-testid="has-back">{String(hasBack)}</span>
|
||||
<button data-testid="save-btn" onClick={() => onSave({ type: DataType.string, name: 'test' })}>Save</button>
|
||||
<button data-testid="close-btn" onClick={onClose}>Close</button>
|
||||
{hasBack && <button data-testid="back-btn" onClick={onBack}>Back</button>}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('CreateMetadataModal', () => {
|
||||
const mockTrigger = <button data-testid="trigger-button">Open Modal</button>
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render trigger when closed', () => {
|
||||
render(
|
||||
<CreateMetadataModal
|
||||
open={false}
|
||||
setOpen={vi.fn()}
|
||||
trigger={mockTrigger}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
// Portal wrapper should exist but closed
|
||||
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('portal-wrapper')).toHaveAttribute('data-open', 'false')
|
||||
})
|
||||
|
||||
it('should render content when open', () => {
|
||||
render(
|
||||
<CreateMetadataModal
|
||||
open={true}
|
||||
setOpen={vi.fn()}
|
||||
trigger={mockTrigger}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('create-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render trigger element', () => {
|
||||
render(
|
||||
<CreateMetadataModal
|
||||
open={false}
|
||||
setOpen={vi.fn()}
|
||||
trigger={mockTrigger}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('trigger-button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should pass hasBack to CreateContent', () => {
|
||||
render(
|
||||
<CreateMetadataModal
|
||||
open={true}
|
||||
setOpen={vi.fn()}
|
||||
trigger={mockTrigger}
|
||||
onSave={vi.fn()}
|
||||
hasBack
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('has-back')).toHaveTextContent('true')
|
||||
})
|
||||
|
||||
it('should pass hasBack=undefined when not provided', () => {
|
||||
render(
|
||||
<CreateMetadataModal
|
||||
open={true}
|
||||
setOpen={vi.fn()}
|
||||
trigger={mockTrigger}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('has-back')).toHaveTextContent('undefined')
|
||||
})
|
||||
|
||||
it('should accept custom popupLeft', () => {
|
||||
render(
|
||||
<CreateMetadataModal
|
||||
open={true}
|
||||
setOpen={vi.fn()}
|
||||
trigger={mockTrigger}
|
||||
onSave={vi.fn()}
|
||||
popupLeft={50}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should toggle open state when trigger is clicked', () => {
|
||||
const setOpen = vi.fn()
|
||||
render(
|
||||
<CreateMetadataModal
|
||||
open={false}
|
||||
setOpen={setOpen}
|
||||
trigger={mockTrigger}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
expect(setOpen).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should call onSave when save button is clicked', () => {
|
||||
const handleSave = vi.fn()
|
||||
render(
|
||||
<CreateMetadataModal
|
||||
open={true}
|
||||
setOpen={vi.fn()}
|
||||
trigger={mockTrigger}
|
||||
onSave={handleSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('save-btn'))
|
||||
|
||||
expect(handleSave).toHaveBeenCalledWith({
|
||||
type: DataType.string,
|
||||
name: 'test',
|
||||
})
|
||||
})
|
||||
|
||||
it('should close modal when close button is clicked', () => {
|
||||
const setOpen = vi.fn()
|
||||
render(
|
||||
<CreateMetadataModal
|
||||
open={true}
|
||||
setOpen={setOpen}
|
||||
trigger={mockTrigger}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('close-btn'))
|
||||
|
||||
expect(setOpen).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should close modal when back button is clicked', () => {
|
||||
const setOpen = vi.fn()
|
||||
render(
|
||||
<CreateMetadataModal
|
||||
open={true}
|
||||
setOpen={setOpen}
|
||||
trigger={mockTrigger}
|
||||
onSave={vi.fn()}
|
||||
hasBack
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('back-btn'))
|
||||
|
||||
expect(setOpen).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle switching open state', () => {
|
||||
const { rerender } = render(
|
||||
<CreateMetadataModal
|
||||
open={false}
|
||||
setOpen={vi.fn()}
|
||||
trigger={mockTrigger}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('portal-wrapper')).toHaveAttribute('data-open', 'false')
|
||||
|
||||
rerender(
|
||||
<CreateMetadataModal
|
||||
open={true}
|
||||
setOpen={vi.fn()}
|
||||
trigger={mockTrigger}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('portal-wrapper')).toHaveAttribute('data-open', 'true')
|
||||
})
|
||||
|
||||
it('should handle different trigger elements', () => {
|
||||
const customTrigger = <div data-testid="custom-trigger">Custom</div>
|
||||
render(
|
||||
<CreateMetadataModal
|
||||
open={false}
|
||||
setOpen={vi.fn()}
|
||||
trigger={customTrigger}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('custom-trigger')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,587 @@
|
||||
import type { BuiltInMetadataItem, MetadataItemWithValueLength } from '../types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DataType } from '../types'
|
||||
import DatasetMetadataDrawer from './dataset-metadata-drawer'
|
||||
|
||||
// Mock service/API calls
|
||||
vi.mock('@/service/knowledge/use-metadata', () => ({
|
||||
useDatasetMetaData: () => ({
|
||||
data: {
|
||||
doc_metadata: [
|
||||
{ id: '1', name: 'existing_field', type: DataType.string },
|
||||
],
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock check name hook
|
||||
vi.mock('../hooks/use-check-metadata-name', () => ({
|
||||
default: () => ({
|
||||
checkName: () => ({ errorMsg: '' }),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock Toast
|
||||
const mockToastNotify = vi.fn()
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: (args: unknown) => mockToastNotify(args),
|
||||
},
|
||||
}))
|
||||
|
||||
// Type definitions for mock props
|
||||
type CreateModalProps = {
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
trigger: React.ReactNode
|
||||
onSave: (data: BuiltInMetadataItem) => void
|
||||
}
|
||||
|
||||
// Mock CreateModal to expose callbacks
|
||||
vi.mock('@/app/components/datasets/metadata/metadata-dataset/create-metadata-modal', () => ({
|
||||
default: ({ open, setOpen, trigger, onSave }: CreateModalProps) => (
|
||||
<div data-testid="create-modal-wrapper">
|
||||
<div data-testid="create-trigger" onClick={() => setOpen(true)}>{trigger}</div>
|
||||
{open && (
|
||||
<div data-testid="create-modal">
|
||||
<button data-testid="create-save" onClick={() => onSave({ name: 'new_field', type: DataType.string })}>
|
||||
Save
|
||||
</button>
|
||||
<button data-testid="create-close" onClick={() => setOpen(false)}>Close</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('DatasetMetadataDrawer', () => {
|
||||
const mockUserMetadata: MetadataItemWithValueLength[] = [
|
||||
{ id: '1', name: 'field_one', type: DataType.string, count: 5 },
|
||||
{ id: '2', name: 'field_two', type: DataType.number, count: 3 },
|
||||
]
|
||||
|
||||
const mockBuiltInMetadata: BuiltInMetadataItem[] = [
|
||||
{ name: 'created_at', type: DataType.time },
|
||||
{ name: 'modified_at', type: DataType.time },
|
||||
]
|
||||
|
||||
const defaultProps = {
|
||||
userMetadata: mockUserMetadata,
|
||||
builtInMetadata: mockBuiltInMetadata,
|
||||
isBuiltInEnabled: false,
|
||||
onIsBuiltInEnabledChange: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
onAdd: vi.fn().mockResolvedValue({}),
|
||||
onRename: vi.fn().mockResolvedValue({}),
|
||||
onRemove: vi.fn().mockResolvedValue({}),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', async () => {
|
||||
render(<DatasetMetadataDrawer {...defaultProps} />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render user metadata items', async () => {
|
||||
render(<DatasetMetadataDrawer {...defaultProps} />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('field_one')).toBeInTheDocument()
|
||||
expect(screen.getByText('field_two')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render built-in metadata items', async () => {
|
||||
render(<DatasetMetadataDrawer {...defaultProps} />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('created_at')).toBeInTheDocument()
|
||||
expect(screen.getByText('modified_at')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render metadata type for each item', async () => {
|
||||
render(<DatasetMetadataDrawer {...defaultProps} />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText(DataType.string).length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText(DataType.number).length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('should render add metadata button', async () => {
|
||||
render(<DatasetMetadataDrawer {...defaultProps} />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-trigger')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render switch for built-in toggle', async () => {
|
||||
render(<DatasetMetadataDrawer {...defaultProps} />)
|
||||
await waitFor(() => {
|
||||
const switchBtn = screen.getByRole('switch')
|
||||
expect(switchBtn).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onIsBuiltInEnabledChange when switch is toggled', async () => {
|
||||
const onIsBuiltInEnabledChange = vi.fn()
|
||||
render(
|
||||
<DatasetMetadataDrawer
|
||||
{...defaultProps}
|
||||
onIsBuiltInEnabledChange={onIsBuiltInEnabledChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const switchBtn = screen.getByRole('switch')
|
||||
fireEvent.click(switchBtn)
|
||||
|
||||
expect(onIsBuiltInEnabledChange).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Add Metadata', () => {
|
||||
it('should open create modal when add button is clicked', async () => {
|
||||
render(<DatasetMetadataDrawer {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const trigger = screen.getByTestId('create-trigger')
|
||||
fireEvent.click(trigger)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-modal')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onAdd and show success toast when metadata is added', async () => {
|
||||
const onAdd = vi.fn().mockResolvedValue({})
|
||||
render(<DatasetMetadataDrawer {...defaultProps} onAdd={onAdd} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Open create modal
|
||||
const trigger = screen.getByTestId('create-trigger')
|
||||
fireEvent.click(trigger)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Save new metadata
|
||||
fireEvent.click(screen.getByTestId('create-save'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onAdd).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'success',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should close create modal after save', async () => {
|
||||
const onAdd = vi.fn().mockResolvedValue({})
|
||||
render(<DatasetMetadataDrawer {...defaultProps} onAdd={onAdd} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Open create modal
|
||||
fireEvent.click(screen.getByTestId('create-trigger'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Save
|
||||
fireEvent.click(screen.getByTestId('create-save'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('create-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rename Metadata', () => {
|
||||
it('should open rename modal when edit icon is clicked', async () => {
|
||||
render(<DatasetMetadataDrawer {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Find user metadata items with group/item class (these have edit/delete icons)
|
||||
const dialog = screen.getByRole('dialog')
|
||||
const items = dialog.querySelectorAll('.group\\/item')
|
||||
expect(items.length).toBe(2) // 2 user metadata items
|
||||
|
||||
// Find the hidden container with edit/delete icons
|
||||
const actionsContainer = items[0].querySelector('.hidden.items-center')
|
||||
expect(actionsContainer).toBeTruthy()
|
||||
|
||||
// Find and click the first SVG (edit icon)
|
||||
if (actionsContainer) {
|
||||
const svgs = actionsContainer.querySelectorAll('svg')
|
||||
expect(svgs.length).toBeGreaterThan(0)
|
||||
fireEvent.click(svgs[0])
|
||||
}
|
||||
|
||||
// Wait for rename modal (contains input)
|
||||
await waitFor(() => {
|
||||
const inputs = document.querySelectorAll('input')
|
||||
expect(inputs.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onRename when rename is saved', async () => {
|
||||
const onRename = vi.fn().mockResolvedValue({})
|
||||
render(<DatasetMetadataDrawer {...defaultProps} onRename={onRename} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Find and click edit icon
|
||||
const dialog = screen.getByRole('dialog')
|
||||
const items = dialog.querySelectorAll('.group\\/item')
|
||||
const actionsContainer = items[0].querySelector('.hidden.items-center')
|
||||
if (actionsContainer) {
|
||||
const svgs = actionsContainer.querySelectorAll('svg')
|
||||
fireEvent.click(svgs[0])
|
||||
}
|
||||
|
||||
// Change name and save
|
||||
await waitFor(() => {
|
||||
const inputs = document.querySelectorAll('input')
|
||||
expect(inputs.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
const inputs = document.querySelectorAll('input')
|
||||
fireEvent.change(inputs[0], { target: { value: 'renamed_field' } })
|
||||
|
||||
// Find and click save button
|
||||
const saveBtns = screen.getAllByText(/save/i)
|
||||
const primaryBtn = saveBtns.find(btn =>
|
||||
btn.closest('button')?.classList.contains('btn-primary'),
|
||||
)
|
||||
if (primaryBtn)
|
||||
fireEvent.click(primaryBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onRename).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'success',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should close rename modal when cancel is clicked', async () => {
|
||||
render(<DatasetMetadataDrawer {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Find and click edit icon
|
||||
const dialog = screen.getByRole('dialog')
|
||||
const items = dialog.querySelectorAll('.group\\/item')
|
||||
const actionsContainer = items[0].querySelector('.hidden.items-center')
|
||||
if (actionsContainer) {
|
||||
const svgs = actionsContainer.querySelectorAll('svg')
|
||||
fireEvent.click(svgs[0])
|
||||
}
|
||||
|
||||
// Wait for modal and click cancel
|
||||
await waitFor(() => {
|
||||
const inputs = document.querySelectorAll('input')
|
||||
expect(inputs.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
// Change name first
|
||||
const inputs = document.querySelectorAll('input')
|
||||
fireEvent.change(inputs[0], { target: { value: 'changed_name' } })
|
||||
|
||||
// Find and click cancel button
|
||||
const cancelBtns = screen.getAllByText(/cancel/i)
|
||||
const cancelBtn = cancelBtns.find(btn =>
|
||||
!btn.closest('button')?.classList.contains('btn-primary'),
|
||||
)
|
||||
if (cancelBtn)
|
||||
fireEvent.click(cancelBtn)
|
||||
|
||||
// Verify input resets or modal closes
|
||||
await waitFor(() => {
|
||||
const currentInputs = document.querySelectorAll('input')
|
||||
// Either no inputs (modal closed) or value reset
|
||||
expect(currentInputs.length === 0 || currentInputs[0].value !== 'changed_name').toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should close rename modal when modal close button is clicked', async () => {
|
||||
render(<DatasetMetadataDrawer {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Find and click edit icon
|
||||
const dialog = screen.getByRole('dialog')
|
||||
const items = dialog.querySelectorAll('.group\\/item')
|
||||
const actionsContainer = items[0].querySelector('.hidden.items-center')
|
||||
if (actionsContainer) {
|
||||
const svgs = actionsContainer.querySelectorAll('svg')
|
||||
fireEvent.click(svgs[0])
|
||||
}
|
||||
|
||||
// Wait for rename modal
|
||||
await waitFor(() => {
|
||||
const inputs = document.querySelectorAll('input')
|
||||
expect(inputs.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
// Find and click the modal close button (X button)
|
||||
// The Modal component has a close button in the header
|
||||
const dialogs = screen.getAllByRole('dialog')
|
||||
const renameModal = dialogs.find(d => d.querySelector('input'))
|
||||
if (renameModal) {
|
||||
// Find close button by looking for a button with close-related class or X icon
|
||||
const closeButtons = renameModal.querySelectorAll('button')
|
||||
for (const btn of Array.from(closeButtons)) {
|
||||
// Skip cancel/save buttons
|
||||
if (!btn.textContent?.toLowerCase().includes('cancel')
|
||||
&& !btn.textContent?.toLowerCase().includes('save')
|
||||
&& btn.querySelector('svg')) {
|
||||
fireEvent.click(btn)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Delete Metadata', () => {
|
||||
it('should show confirm dialog when delete icon is clicked', async () => {
|
||||
render(<DatasetMetadataDrawer {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Find user metadata items
|
||||
const dialog = screen.getByRole('dialog')
|
||||
const items = dialog.querySelectorAll('.group\\/item')
|
||||
|
||||
// Find the delete container
|
||||
const deleteContainer = items[0].querySelector('.hover\\:text-text-destructive')
|
||||
expect(deleteContainer).toBeTruthy()
|
||||
|
||||
// Click delete icon
|
||||
if (deleteContainer) {
|
||||
const deleteIcon = deleteContainer.querySelector('svg')
|
||||
if (deleteIcon)
|
||||
fireEvent.click(deleteIcon)
|
||||
}
|
||||
|
||||
// Confirm dialog should appear
|
||||
await waitFor(() => {
|
||||
const confirmBtns = screen.getAllByRole('button')
|
||||
const hasConfirmBtn = confirmBtns.some(btn =>
|
||||
btn.textContent?.toLowerCase().includes('confirm'),
|
||||
)
|
||||
expect(hasConfirmBtn).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onRemove when delete is confirmed', async () => {
|
||||
const onRemove = vi.fn().mockResolvedValue({})
|
||||
render(<DatasetMetadataDrawer {...defaultProps} onRemove={onRemove} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Find and click delete icon
|
||||
const dialog = screen.getByRole('dialog')
|
||||
const items = dialog.querySelectorAll('.group\\/item')
|
||||
const deleteContainer = items[0].querySelector('.hover\\:text-text-destructive')
|
||||
if (deleteContainer) {
|
||||
const deleteIcon = deleteContainer.querySelector('svg')
|
||||
if (deleteIcon)
|
||||
fireEvent.click(deleteIcon)
|
||||
}
|
||||
|
||||
// Wait for confirm dialog
|
||||
await waitFor(() => {
|
||||
const confirmBtns = screen.getAllByRole('button')
|
||||
const hasConfirmBtn = confirmBtns.some(btn =>
|
||||
btn.textContent?.toLowerCase().includes('confirm'),
|
||||
)
|
||||
expect(hasConfirmBtn).toBe(true)
|
||||
})
|
||||
|
||||
// Click confirm
|
||||
const confirmBtns = screen.getAllByRole('button')
|
||||
const confirmBtn = confirmBtns.find(btn =>
|
||||
btn.textContent?.toLowerCase().includes('confirm'),
|
||||
)
|
||||
if (confirmBtn)
|
||||
fireEvent.click(confirmBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onRemove).toHaveBeenCalledWith('1')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'success',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should close confirm dialog when cancel is clicked', async () => {
|
||||
render(<DatasetMetadataDrawer {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Find and click delete icon
|
||||
const dialog = screen.getByRole('dialog')
|
||||
const items = dialog.querySelectorAll('.group\\/item')
|
||||
const deleteContainer = items[0].querySelector('.hover\\:text-text-destructive')
|
||||
if (deleteContainer) {
|
||||
const deleteIcon = deleteContainer.querySelector('svg')
|
||||
if (deleteIcon)
|
||||
fireEvent.click(deleteIcon)
|
||||
}
|
||||
|
||||
// Wait for confirm dialog
|
||||
await waitFor(() => {
|
||||
const confirmBtns = screen.getAllByRole('button')
|
||||
const hasConfirmBtn = confirmBtns.some(btn =>
|
||||
btn.textContent?.toLowerCase().includes('confirm'),
|
||||
)
|
||||
expect(hasConfirmBtn).toBe(true)
|
||||
})
|
||||
|
||||
// Click cancel
|
||||
const cancelBtns = screen.getAllByRole('button')
|
||||
const cancelBtn = cancelBtns.find(btn =>
|
||||
btn.textContent?.toLowerCase().includes('cancel'),
|
||||
)
|
||||
if (cancelBtn)
|
||||
fireEvent.click(cancelBtn)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should handle empty userMetadata', async () => {
|
||||
render(<DatasetMetadataDrawer {...defaultProps} userMetadata={[]} />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle empty builtInMetadata', async () => {
|
||||
render(<DatasetMetadataDrawer {...defaultProps} builtInMetadata={[]} />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Built-in Items State', () => {
|
||||
it('should show disabled styling when built-in is disabled', async () => {
|
||||
render(
|
||||
<DatasetMetadataDrawer {...defaultProps} isBuiltInEnabled={false} />,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const dialog = screen.getByRole('dialog')
|
||||
const disabledItems = dialog.querySelectorAll('.opacity-30')
|
||||
expect(disabledItems.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should not show disabled styling when built-in is enabled', async () => {
|
||||
render(
|
||||
<DatasetMetadataDrawer {...defaultProps} isBuiltInEnabled />,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle items with special characters in name', async () => {
|
||||
const specialMetadata: MetadataItemWithValueLength[] = [
|
||||
{ id: '1', name: 'field_with_underscore', type: DataType.string, count: 1 },
|
||||
]
|
||||
render(<DatasetMetadataDrawer {...defaultProps} userMetadata={specialMetadata} />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('field_with_underscore')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle single user metadata item', async () => {
|
||||
const singleMetadata: MetadataItemWithValueLength[] = [
|
||||
{ id: '1', name: 'only_field', type: DataType.string, count: 10 },
|
||||
]
|
||||
render(<DatasetMetadataDrawer {...defaultProps} userMetadata={singleMetadata} />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('only_field')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle single built-in metadata item', async () => {
|
||||
const singleBuiltIn: BuiltInMetadataItem[] = [
|
||||
{ name: 'created_at', type: DataType.time },
|
||||
]
|
||||
render(<DatasetMetadataDrawer {...defaultProps} builtInMetadata={singleBuiltIn} />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('created_at')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle metadata with zero count', async () => {
|
||||
const zeroCountMetadata: MetadataItemWithValueLength[] = [
|
||||
{ id: '1', name: 'empty_field', type: DataType.string, count: 0 },
|
||||
]
|
||||
render(<DatasetMetadataDrawer {...defaultProps} userMetadata={zeroCountMetadata} />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('empty_field')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,122 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import Field from './field'
|
||||
|
||||
describe('Field', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Field label="Test Label">Content</Field>)
|
||||
expect(screen.getByText('Test Label')).toBeInTheDocument()
|
||||
expect(screen.getByText('Content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render label with correct styling', () => {
|
||||
render(<Field label="My Label">Content</Field>)
|
||||
const labelElement = screen.getByText('My Label')
|
||||
expect(labelElement).toHaveClass('system-sm-semibold', 'py-1', 'text-text-secondary')
|
||||
})
|
||||
|
||||
it('should render children in content container', () => {
|
||||
const { container } = render(<Field label="Label">Child Content</Field>)
|
||||
// The children wrapper has mt-1 class
|
||||
const contentWrapper = container.querySelector('.mt-1')
|
||||
expect(contentWrapper).toBeInTheDocument()
|
||||
expect(contentWrapper).toHaveTextContent('Child Content')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(<Field label="Label" className="custom-class">Content</Field>)
|
||||
expect(container.firstChild).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should render with string children', () => {
|
||||
render(<Field label="Label">Simple Text</Field>)
|
||||
expect(screen.getByText('Simple Text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with complex children', () => {
|
||||
render(
|
||||
<Field label="Label">
|
||||
<div data-testid="complex-child">
|
||||
<span>Nested Content</span>
|
||||
</div>
|
||||
</Field>,
|
||||
)
|
||||
expect(screen.getByTestId('complex-child')).toBeInTheDocument()
|
||||
expect(screen.getByText('Nested Content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with multiple children', () => {
|
||||
render(
|
||||
<Field label="Label">
|
||||
<span>First</span>
|
||||
<span>Second</span>
|
||||
</Field>,
|
||||
)
|
||||
expect(screen.getByText('First')).toBeInTheDocument()
|
||||
expect(screen.getByText('Second')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render different labels correctly', () => {
|
||||
const { rerender } = render(<Field label="First Label">Content</Field>)
|
||||
expect(screen.getByText('First Label')).toBeInTheDocument()
|
||||
|
||||
rerender(<Field label="Second Label">Content</Field>)
|
||||
expect(screen.getByText('Second Label')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Layout', () => {
|
||||
it('should have label above content', () => {
|
||||
const { container } = render(<Field label="Label">Content</Field>)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper?.children).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should render label element first', () => {
|
||||
const { container } = render(<Field label="Label">Content</Field>)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
const firstChild = wrapper?.firstChild as HTMLElement
|
||||
expect(firstChild).toHaveClass('system-sm-semibold')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render with undefined className', () => {
|
||||
render(<Field label="Label" className={undefined}>Content</Field>)
|
||||
expect(screen.getByText('Content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with empty className', () => {
|
||||
render(<Field label="Label" className="">Content</Field>)
|
||||
expect(screen.getByText('Content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with empty label', () => {
|
||||
render(<Field label="">Content</Field>)
|
||||
expect(screen.getByText('Content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with empty children', () => {
|
||||
const { container } = render(<Field label="Label"><span></span></Field>)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with null children', () => {
|
||||
const { container } = render(<Field label="Label">{null}</Field>)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with number as children', () => {
|
||||
render(<Field label="Label">{42}</Field>)
|
||||
expect(screen.getByText('42')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle special characters in label', () => {
|
||||
render(<Field label={'Label & "chars"'}>Content</Field>)
|
||||
expect(screen.getByText('Label & "chars"')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,348 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DataType } from '../types'
|
||||
import SelectMetadataModal from './select-metadata-modal'
|
||||
|
||||
type MetadataItem = {
|
||||
id: string
|
||||
name: string
|
||||
type: DataType
|
||||
}
|
||||
|
||||
type PortalProps = {
|
||||
children: React.ReactNode
|
||||
open: boolean
|
||||
}
|
||||
|
||||
type TriggerProps = {
|
||||
children: React.ReactNode
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
type ContentProps = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
type SelectMetadataProps = {
|
||||
onSelect: (item: MetadataItem) => void
|
||||
onNew: () => void
|
||||
onManage: () => void
|
||||
list: MetadataItem[]
|
||||
}
|
||||
|
||||
type CreateContentProps = {
|
||||
onSave: (data: { type: DataType, name: string }) => void
|
||||
onBack?: () => void
|
||||
onClose?: () => void
|
||||
hasBack?: boolean
|
||||
}
|
||||
|
||||
// Mock useDatasetMetaData hook
|
||||
vi.mock('@/service/knowledge/use-metadata', () => ({
|
||||
useDatasetMetaData: () => ({
|
||||
data: {
|
||||
doc_metadata: [
|
||||
{ id: '1', name: 'field_one', type: DataType.string },
|
||||
{ id: '2', name: 'field_two', type: DataType.number },
|
||||
],
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock PortalToFollowElem components
|
||||
vi.mock('../../../base/portal-to-follow-elem', () => ({
|
||||
PortalToFollowElem: ({ children, open }: PortalProps) => (
|
||||
<div data-testid="portal-wrapper" data-open={open}>{children}</div>
|
||||
),
|
||||
PortalToFollowElemTrigger: ({ children, onClick }: TriggerProps) => (
|
||||
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children }: ContentProps) => (
|
||||
<div data-testid="portal-content">{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock SelectMetadata component
|
||||
vi.mock('./select-metadata', () => ({
|
||||
default: ({ onSelect, onNew, onManage, list }: SelectMetadataProps) => (
|
||||
<div data-testid="select-metadata">
|
||||
<span data-testid="list-count">{list?.length || 0}</span>
|
||||
<button data-testid="select-item" onClick={() => onSelect({ id: '1', name: 'field_one', type: DataType.string })}>Select</button>
|
||||
<button data-testid="new-btn" onClick={onNew}>New</button>
|
||||
<button data-testid="manage-btn" onClick={onManage}>Manage</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock CreateContent component
|
||||
vi.mock('./create-content', () => ({
|
||||
default: ({ onSave, onBack, onClose, hasBack }: CreateContentProps) => (
|
||||
<div data-testid="create-content">
|
||||
<button data-testid="save-btn" onClick={() => onSave({ type: DataType.string, name: 'new_field' })}>Save</button>
|
||||
{hasBack && <button data-testid="back-btn" onClick={onBack}>Back</button>}
|
||||
<button data-testid="close-btn" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('SelectMetadataModal', () => {
|
||||
const mockTrigger = <button data-testid="trigger-button">Select Metadata</button>
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(
|
||||
<SelectMetadataModal
|
||||
datasetId="dataset-1"
|
||||
trigger={mockTrigger}
|
||||
onSelect={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render trigger element', () => {
|
||||
render(
|
||||
<SelectMetadataModal
|
||||
datasetId="dataset-1"
|
||||
trigger={mockTrigger}
|
||||
onSelect={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('trigger-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render SelectMetadata by default', () => {
|
||||
render(
|
||||
<SelectMetadataModal
|
||||
datasetId="dataset-1"
|
||||
trigger={mockTrigger}
|
||||
onSelect={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('select-metadata')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass dataset metadata to SelectMetadata', () => {
|
||||
render(
|
||||
<SelectMetadataModal
|
||||
datasetId="dataset-1"
|
||||
trigger={mockTrigger}
|
||||
onSelect={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('list-count')).toHaveTextContent('2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should toggle open state when trigger is clicked', () => {
|
||||
render(
|
||||
<SelectMetadataModal
|
||||
datasetId="dataset-1"
|
||||
trigger={mockTrigger}
|
||||
onSelect={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
// State should toggle
|
||||
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onSelect and close when item is selected', () => {
|
||||
const handleSelect = vi.fn()
|
||||
render(
|
||||
<SelectMetadataModal
|
||||
datasetId="dataset-1"
|
||||
trigger={mockTrigger}
|
||||
onSelect={handleSelect}
|
||||
onSave={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('select-item'))
|
||||
|
||||
expect(handleSelect).toHaveBeenCalledWith({
|
||||
id: '1',
|
||||
name: 'field_one',
|
||||
type: DataType.string,
|
||||
})
|
||||
})
|
||||
|
||||
it('should switch to create step when new button is clicked', async () => {
|
||||
render(
|
||||
<SelectMetadataModal
|
||||
datasetId="dataset-1"
|
||||
trigger={mockTrigger}
|
||||
onSelect={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('new-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onManage when manage button is clicked', () => {
|
||||
const handleManage = vi.fn()
|
||||
render(
|
||||
<SelectMetadataModal
|
||||
datasetId="dataset-1"
|
||||
trigger={mockTrigger}
|
||||
onSelect={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onManage={handleManage}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('manage-btn'))
|
||||
|
||||
expect(handleManage).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Create Flow', () => {
|
||||
it('should switch back to select when back is clicked in create step', async () => {
|
||||
render(
|
||||
<SelectMetadataModal
|
||||
datasetId="dataset-1"
|
||||
trigger={mockTrigger}
|
||||
onSelect={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Go to create step
|
||||
fireEvent.click(screen.getByTestId('new-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Go back to select step
|
||||
fireEvent.click(screen.getByTestId('back-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('select-metadata')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onSave and return to select step when save is clicked', async () => {
|
||||
const handleSave = vi.fn().mockResolvedValue(undefined)
|
||||
render(
|
||||
<SelectMetadataModal
|
||||
datasetId="dataset-1"
|
||||
trigger={mockTrigger}
|
||||
onSelect={vi.fn()}
|
||||
onSave={handleSave}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Go to create step
|
||||
fireEvent.click(screen.getByTestId('new-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Save new metadata
|
||||
fireEvent.click(screen.getByTestId('save-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(handleSave).toHaveBeenCalledWith({
|
||||
type: DataType.string,
|
||||
name: 'new_field',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should accept custom popupPlacement', () => {
|
||||
render(
|
||||
<SelectMetadataModal
|
||||
datasetId="dataset-1"
|
||||
trigger={mockTrigger}
|
||||
onSelect={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
popupPlacement="bottom-start"
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should accept custom popupOffset', () => {
|
||||
render(
|
||||
<SelectMetadataModal
|
||||
datasetId="dataset-1"
|
||||
trigger={mockTrigger}
|
||||
onSelect={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
popupOffset={{ mainAxis: 10, crossAxis: 5 }}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle different datasetIds', () => {
|
||||
const { rerender } = render(
|
||||
<SelectMetadataModal
|
||||
datasetId="dataset-1"
|
||||
trigger={mockTrigger}
|
||||
onSelect={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<SelectMetadataModal
|
||||
datasetId="dataset-2"
|
||||
trigger={mockTrigger}
|
||||
onSelect={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty trigger', () => {
|
||||
render(
|
||||
<SelectMetadataModal
|
||||
datasetId="dataset-1"
|
||||
trigger={<span data-testid="empty-trigger" />}
|
||||
onSelect={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('empty-trigger')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,332 @@
|
||||
import type { MetadataItem } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DataType } from '../types'
|
||||
import SelectMetadata from './select-metadata'
|
||||
|
||||
type IconProps = {
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Mock getIcon utility
|
||||
vi.mock('../utils/get-icon', () => ({
|
||||
getIcon: () => (props: IconProps) => <span data-testid="icon" className={props.className}>Icon</span>,
|
||||
}))
|
||||
|
||||
describe('SelectMetadata', () => {
|
||||
const mockList: MetadataItem[] = [
|
||||
{ id: '1', name: 'field_one', type: DataType.string },
|
||||
{ id: '2', name: 'field_two', type: DataType.number },
|
||||
{ id: '3', name: 'field_three', type: DataType.time },
|
||||
]
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(
|
||||
<SelectMetadata
|
||||
list={mockList}
|
||||
onSelect={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render search input', () => {
|
||||
render(
|
||||
<SelectMetadata
|
||||
list={mockList}
|
||||
onSelect={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all metadata items', () => {
|
||||
render(
|
||||
<SelectMetadata
|
||||
list={mockList}
|
||||
onSelect={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('field_one')).toBeInTheDocument()
|
||||
expect(screen.getByText('field_two')).toBeInTheDocument()
|
||||
expect(screen.getByText('field_three')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render new action button', () => {
|
||||
render(
|
||||
<SelectMetadata
|
||||
list={mockList}
|
||||
onSelect={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
// New action button should be present (from i18n)
|
||||
expect(screen.getByText(/new/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render manage action button', () => {
|
||||
render(
|
||||
<SelectMetadata
|
||||
list={mockList}
|
||||
onSelect={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
// Manage action button should be present (from i18n)
|
||||
expect(screen.getByText(/manage/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display type for each item', () => {
|
||||
render(
|
||||
<SelectMetadata
|
||||
list={mockList}
|
||||
onSelect={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getAllByText(DataType.string).length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText(DataType.number).length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText(DataType.time).length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Search Functionality', () => {
|
||||
it('should filter items based on search query', () => {
|
||||
render(
|
||||
<SelectMetadata
|
||||
list={mockList}
|
||||
onSelect={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const searchInput = screen.getByRole('textbox')
|
||||
fireEvent.change(searchInput, { target: { value: 'one' } })
|
||||
|
||||
expect(screen.getByText('field_one')).toBeInTheDocument()
|
||||
expect(screen.queryByText('field_two')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('field_three')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should be case insensitive search', () => {
|
||||
render(
|
||||
<SelectMetadata
|
||||
list={mockList}
|
||||
onSelect={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const searchInput = screen.getByRole('textbox')
|
||||
fireEvent.change(searchInput, { target: { value: 'ONE' } })
|
||||
|
||||
expect(screen.getByText('field_one')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show all items when search is cleared', () => {
|
||||
render(
|
||||
<SelectMetadata
|
||||
list={mockList}
|
||||
onSelect={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const searchInput = screen.getByRole('textbox')
|
||||
|
||||
// Search for something
|
||||
fireEvent.change(searchInput, { target: { value: 'one' } })
|
||||
expect(screen.queryByText('field_two')).not.toBeInTheDocument()
|
||||
|
||||
// Clear search
|
||||
fireEvent.change(searchInput, { target: { value: '' } })
|
||||
expect(screen.getByText('field_two')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show no results when search matches nothing', () => {
|
||||
render(
|
||||
<SelectMetadata
|
||||
list={mockList}
|
||||
onSelect={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const searchInput = screen.getByRole('textbox')
|
||||
fireEvent.change(searchInput, { target: { value: 'xyz' } })
|
||||
|
||||
expect(screen.queryByText('field_one')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('field_two')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('field_three')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onSelect with item data when item is clicked', () => {
|
||||
const handleSelect = vi.fn()
|
||||
render(
|
||||
<SelectMetadata
|
||||
list={mockList}
|
||||
onSelect={handleSelect}
|
||||
onNew={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('field_one'))
|
||||
|
||||
expect(handleSelect).toHaveBeenCalledWith({
|
||||
id: '1',
|
||||
name: 'field_one',
|
||||
type: DataType.string,
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onNew when new button is clicked', () => {
|
||||
const handleNew = vi.fn()
|
||||
render(
|
||||
<SelectMetadata
|
||||
list={mockList}
|
||||
onSelect={vi.fn()}
|
||||
onNew={handleNew}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Find and click the new action button
|
||||
const newButton = screen.getByText(/new/i)
|
||||
fireEvent.click(newButton.closest('div') || newButton)
|
||||
|
||||
expect(handleNew).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onManage when manage button is clicked', () => {
|
||||
const handleManage = vi.fn()
|
||||
render(
|
||||
<SelectMetadata
|
||||
list={mockList}
|
||||
onSelect={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onManage={handleManage}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Find and click the manage action button
|
||||
const manageButton = screen.getByText(/manage/i)
|
||||
fireEvent.click(manageButton.closest('div') || manageButton)
|
||||
|
||||
expect(handleManage).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Empty State', () => {
|
||||
it('should render empty list', () => {
|
||||
const { container } = render(
|
||||
<SelectMetadata
|
||||
list={[]}
|
||||
onSelect={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should still show new and manage buttons with empty list', () => {
|
||||
render(
|
||||
<SelectMetadata
|
||||
list={[]}
|
||||
onSelect={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText(/new/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/manage/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should have correct container styling', () => {
|
||||
const { container } = render(
|
||||
<SelectMetadata
|
||||
list={mockList}
|
||||
onSelect={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(container.firstChild).toHaveClass('w-[320px]', 'rounded-xl')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle single item list', () => {
|
||||
const singleItem: MetadataItem[] = [
|
||||
{ id: '1', name: 'only_one', type: DataType.string },
|
||||
]
|
||||
render(
|
||||
<SelectMetadata
|
||||
list={singleItem}
|
||||
onSelect={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('only_one')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle item with long name', () => {
|
||||
const longNameItem: MetadataItem[] = [
|
||||
{ id: '1', name: 'this_is_a_very_long_field_name_that_might_overflow', type: DataType.string },
|
||||
]
|
||||
render(
|
||||
<SelectMetadata
|
||||
list={longNameItem}
|
||||
onSelect={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('this_is_a_very_long_field_name_that_might_overflow')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle rapid search input changes', () => {
|
||||
render(
|
||||
<SelectMetadata
|
||||
list={mockList}
|
||||
onSelect={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const searchInput = screen.getByRole('textbox')
|
||||
|
||||
// Rapid typing
|
||||
fireEvent.change(searchInput, { target: { value: 'f' } })
|
||||
fireEvent.change(searchInput, { target: { value: 'fi' } })
|
||||
fireEvent.change(searchInput, { target: { value: 'fie' } })
|
||||
fireEvent.change(searchInput, { target: { value: 'fiel' } })
|
||||
fireEvent.change(searchInput, { target: { value: 'field' } })
|
||||
|
||||
expect(screen.getByText('field_one')).toBeInTheDocument()
|
||||
expect(screen.getByText('field_two')).toBeInTheDocument()
|
||||
expect(screen.getByText('field_three')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,113 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import Field from './field'
|
||||
|
||||
describe('Field', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Field label="Test Label">Content</Field>)
|
||||
expect(screen.getByText('Test Label')).toBeInTheDocument()
|
||||
expect(screen.getByText('Content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render label with correct styling', () => {
|
||||
render(<Field label="My Label">Content</Field>)
|
||||
const labelElement = screen.getByText('My Label')
|
||||
expect(labelElement).toHaveClass('system-xs-medium', 'w-[128px]', 'shrink-0', 'truncate', 'py-1', 'text-text-tertiary')
|
||||
})
|
||||
|
||||
it('should render children in correct container', () => {
|
||||
const { container } = render(<Field label="Label">Child Content</Field>)
|
||||
// The children are wrapped in a div with w-[244px] class
|
||||
const contentWrapper = container.querySelector('.w-\\[244px\\]')
|
||||
expect(contentWrapper).toBeInTheDocument()
|
||||
expect(contentWrapper).toHaveClass('shrink-0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should render with string children', () => {
|
||||
render(<Field label="Label">Simple Text</Field>)
|
||||
expect(screen.getByText('Simple Text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with complex children', () => {
|
||||
render(
|
||||
<Field label="Label">
|
||||
<div data-testid="complex-child">
|
||||
<span>Nested Content</span>
|
||||
</div>
|
||||
</Field>,
|
||||
)
|
||||
expect(screen.getByTestId('complex-child')).toBeInTheDocument()
|
||||
expect(screen.getByText('Nested Content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with multiple children', () => {
|
||||
render(
|
||||
<Field label="Label">
|
||||
<span>First</span>
|
||||
<span>Second</span>
|
||||
</Field>,
|
||||
)
|
||||
expect(screen.getByText('First')).toBeInTheDocument()
|
||||
expect(screen.getByText('Second')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render different labels correctly', () => {
|
||||
const { rerender } = render(<Field label="First Label">Content</Field>)
|
||||
expect(screen.getByText('First Label')).toBeInTheDocument()
|
||||
|
||||
rerender(<Field label="Second Label">Content</Field>)
|
||||
expect(screen.getByText('Second Label')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Layout', () => {
|
||||
it('should have flex layout with space between elements', () => {
|
||||
const { container } = render(<Field label="Label">Content</Field>)
|
||||
const wrapper = container.firstChild
|
||||
expect(wrapper).toHaveClass('flex', 'items-start', 'space-x-2')
|
||||
})
|
||||
|
||||
it('should render label and content side by side', () => {
|
||||
const { container } = render(<Field label="Label">Content</Field>)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper?.children).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render with empty label', () => {
|
||||
render(<Field label="">Content</Field>)
|
||||
expect(screen.getByText('Content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with long label (truncation)', () => {
|
||||
const longLabel = 'This is a very long label that should be truncated'
|
||||
render(<Field label={longLabel}>Content</Field>)
|
||||
const labelElement = screen.getByText(longLabel)
|
||||
expect(labelElement).toHaveClass('truncate')
|
||||
})
|
||||
|
||||
it('should render with empty children', () => {
|
||||
const { container } = render(<Field label="Label"><span></span></Field>)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with null children', () => {
|
||||
const { container } = render(<Field label="Label">{null}</Field>)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with number as children', () => {
|
||||
render(<Field label="Label">{42}</Field>)
|
||||
expect(screen.getByText('42')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle special characters in label', () => {
|
||||
render(<Field label={'Label & "chars"'}>Content</Field>)
|
||||
expect(screen.getByText('Label & "chars"')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,752 @@
|
||||
import type { MetadataItemWithValue } from '../types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DataType } from '../types'
|
||||
import MetadataDocument from './index'
|
||||
|
||||
type MockHookReturn = {
|
||||
embeddingAvailable: boolean
|
||||
isEdit: boolean
|
||||
setIsEdit: ReturnType<typeof vi.fn>
|
||||
list: MetadataItemWithValue[]
|
||||
tempList: MetadataItemWithValue[]
|
||||
setTempList: ReturnType<typeof vi.fn>
|
||||
handleSelectMetaData: ReturnType<typeof vi.fn>
|
||||
handleAddMetaData: ReturnType<typeof vi.fn>
|
||||
hasData: boolean
|
||||
builtList: MetadataItemWithValue[]
|
||||
builtInEnabled: boolean
|
||||
startToEdit: ReturnType<typeof vi.fn>
|
||||
handleSave: ReturnType<typeof vi.fn>
|
||||
handleCancel: ReturnType<typeof vi.fn>
|
||||
originInfo: MetadataItemWithValue[]
|
||||
technicalParameters: MetadataItemWithValue[]
|
||||
}
|
||||
|
||||
// Mock useMetadataDocument hook - need to control state
|
||||
const mockUseMetadataDocument = vi.fn<() => MockHookReturn>()
|
||||
vi.mock('../hooks/use-metadata-document', () => ({
|
||||
default: () => mockUseMetadataDocument(),
|
||||
}))
|
||||
|
||||
// Mock service calls
|
||||
vi.mock('@/service/knowledge/use-metadata', () => ({
|
||||
useDatasetMetaData: () => ({
|
||||
data: {
|
||||
doc_metadata: [],
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock check name hook
|
||||
vi.mock('../hooks/use-check-metadata-name', () => ({
|
||||
default: () => ({
|
||||
checkName: () => ({ errorMsg: '' }),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('MetadataDocument', () => {
|
||||
const mockDocDetail = {
|
||||
id: 'doc-1',
|
||||
name: 'Test Document',
|
||||
data_source_type: 'upload_file',
|
||||
indexing_status: 'completed',
|
||||
created_at: 1609459200,
|
||||
word_count: 100,
|
||||
}
|
||||
|
||||
const mockList: MetadataItemWithValue[] = [
|
||||
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
|
||||
{ id: '2', name: 'field_two', type: DataType.number, value: 42 },
|
||||
]
|
||||
|
||||
const defaultHookReturn: MockHookReturn = {
|
||||
embeddingAvailable: true,
|
||||
isEdit: false,
|
||||
setIsEdit: vi.fn(),
|
||||
list: mockList,
|
||||
tempList: mockList,
|
||||
setTempList: vi.fn(),
|
||||
handleSelectMetaData: vi.fn(),
|
||||
handleAddMetaData: vi.fn(),
|
||||
hasData: true,
|
||||
builtList: [],
|
||||
builtInEnabled: false,
|
||||
startToEdit: vi.fn(),
|
||||
handleSave: vi.fn(),
|
||||
handleCancel: vi.fn(),
|
||||
originInfo: [],
|
||||
technicalParameters: [],
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseMetadataDocument.mockReturnValue(defaultHookReturn)
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render metadata fields when hasData is true', () => {
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('field_one')).toBeInTheDocument()
|
||||
expect(screen.getByText('field_two')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render no-data state when hasData is false and not in edit mode', () => {
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
hasData: false,
|
||||
list: [],
|
||||
tempList: [],
|
||||
isEdit: false,
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getAllByText(/metadata/i).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should render edit UI when in edit mode', () => {
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
isEdit: true,
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/save/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/cancel/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render built-in section when builtInEnabled is true', () => {
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
builtInEnabled: true,
|
||||
builtList: [{ id: 'built-in', name: 'created_at', type: DataType.time, value: 1609459200 }],
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('created_at')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render divider when builtInEnabled is true', () => {
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
builtInEnabled: true,
|
||||
builtList: [{ id: 'built-in', name: 'created_at', type: DataType.time, value: 1609459200 }],
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
const divider = container.querySelector('[class*="bg-gradient"]')
|
||||
expect(divider).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render origin info section', () => {
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
originInfo: [{ id: 'origin-1', name: 'source', type: DataType.string, value: 'upload' }],
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('source')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render technical parameters section', () => {
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
technicalParameters: [{ id: 'tech-1', name: 'word_count', type: DataType.number, value: 100 }],
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('word_count')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all sections together', () => {
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
builtInEnabled: true,
|
||||
builtList: [{ id: 'built-1', name: 'created_at', type: DataType.time, value: 1609459200 }],
|
||||
originInfo: [{ id: 'origin-1', name: 'source', type: DataType.string, value: 'upload' }],
|
||||
technicalParameters: [{ id: 'tech-1', name: 'word_count', type: DataType.number, value: 100 }],
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('field_one')).toBeInTheDocument()
|
||||
expect(screen.getByText('created_at')).toBeInTheDocument()
|
||||
expect(screen.getByText('source')).toBeInTheDocument()
|
||||
expect(screen.getByText('word_count')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edit Mode', () => {
|
||||
it('should show edit button when not in edit mode and embedding available', () => {
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText(/edit/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call startToEdit when edit button is clicked', () => {
|
||||
const startToEdit = vi.fn()
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
isEdit: false,
|
||||
startToEdit,
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/edit/i))
|
||||
expect(startToEdit).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call handleSave when save button is clicked', () => {
|
||||
const handleSave = vi.fn()
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
isEdit: true,
|
||||
handleSave,
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/save/i))
|
||||
expect(handleSave).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call handleCancel when cancel button is clicked', () => {
|
||||
const handleCancel = vi.fn()
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
isEdit: true,
|
||||
handleCancel,
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/cancel/i))
|
||||
expect(handleCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call setIsEdit(true) when start button is clicked in no-data state', () => {
|
||||
const setIsEdit = vi.fn()
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
hasData: false,
|
||||
list: [],
|
||||
tempList: [],
|
||||
isEdit: false,
|
||||
setIsEdit,
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
const startBtn = screen.queryByText(/start/i)
|
||||
if (startBtn) {
|
||||
fireEvent.click(startBtn)
|
||||
expect(setIsEdit).toHaveBeenCalledWith(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('should show InfoGroup when in edit mode without data', () => {
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
hasData: false,
|
||||
list: [],
|
||||
tempList: [],
|
||||
isEdit: true,
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Should show save/cancel buttons
|
||||
expect(screen.getByText(/save/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/cancel/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Data Operations', () => {
|
||||
it('should call setTempList when field value changes', async () => {
|
||||
const setTempList = vi.fn()
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
isEdit: true,
|
||||
setTempList,
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
const inputs = container.querySelectorAll('input')
|
||||
if (inputs.length > 0) {
|
||||
fireEvent.change(inputs[0], { target: { value: 'new value' } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setTempList).toHaveBeenCalled()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('should have handleAddMetaData function available', () => {
|
||||
const handleAddMetaData = vi.fn()
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
isEdit: true,
|
||||
handleAddMetaData,
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(typeof handleAddMetaData).toBe('function')
|
||||
})
|
||||
|
||||
it('should have handleSelectMetaData function available', () => {
|
||||
const handleSelectMetaData = vi.fn()
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
isEdit: true,
|
||||
handleSelectMetaData,
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(typeof handleSelectMetaData).toBe('function')
|
||||
})
|
||||
|
||||
it('should pass onChange callback to InfoGroup', async () => {
|
||||
const setTempList = vi.fn()
|
||||
const tempList = [
|
||||
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
|
||||
]
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
isEdit: true,
|
||||
tempList,
|
||||
setTempList,
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
const inputs = container.querySelectorAll('input')
|
||||
if (inputs.length > 0) {
|
||||
fireEvent.change(inputs[0], { target: { value: 'updated' } })
|
||||
await waitFor(() => {
|
||||
expect(setTempList).toHaveBeenCalled()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('should pass onDelete callback to InfoGroup', async () => {
|
||||
const setTempList = vi.fn()
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
isEdit: true,
|
||||
tempList: mockList,
|
||||
setTempList,
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Look for delete buttons - they are inside hover:bg-state-destructive-hover divs
|
||||
const deleteContainers = container.querySelectorAll('.hover\\:bg-state-destructive-hover')
|
||||
expect(deleteContainers.length).toBeGreaterThan(0)
|
||||
|
||||
// Click the delete icon (SVG inside the container)
|
||||
if (deleteContainers.length > 0) {
|
||||
const deleteIcon = deleteContainers[0].querySelector('svg')
|
||||
if (deleteIcon)
|
||||
fireEvent.click(deleteIcon)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setTempList).toHaveBeenCalled()
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
className="custom-class"
|
||||
/>,
|
||||
)
|
||||
expect(container.firstChild).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should use tempList when in edit mode', () => {
|
||||
const tempList = [{ id: 'temp-1', name: 'temp_field', type: DataType.string, value: 'temp' }]
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
isEdit: true,
|
||||
tempList,
|
||||
list: mockList,
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('temp_field')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use list when not in edit mode', () => {
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('field_one')).toBeInTheDocument()
|
||||
expect(screen.getByText('field_two')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass datasetId to child components', () => {
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="custom-ds-id"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
// Component should render without errors
|
||||
expect(screen.getByText('field_one')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Embedding Availability', () => {
|
||||
it('should not show edit button when embedding is not available', () => {
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
embeddingAvailable: false,
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText(/^edit$/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show NoData when embedding is not available', () => {
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
embeddingAvailable: false,
|
||||
hasData: false,
|
||||
list: [],
|
||||
tempList: [],
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
// NoData component should not be rendered
|
||||
expect(screen.queryByText(/start/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show edit buttons in edit mode when embedding not available', () => {
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
embeddingAvailable: false,
|
||||
isEdit: false,
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
// headerRight should be null/undefined
|
||||
expect(screen.queryByText(/^edit$/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty lists', () => {
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
list: [],
|
||||
tempList: [],
|
||||
hasData: false,
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render correctly with minimal props', () => {
|
||||
const { container } = render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle switching between view and edit mode', () => {
|
||||
const { unmount } = render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/edit/i)).toBeInTheDocument()
|
||||
|
||||
unmount()
|
||||
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
isEdit: true,
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/save/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/cancel/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle multiple items in all sections', () => {
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
list: [
|
||||
{ id: '1', name: 'user_field_1', type: DataType.string, value: 'v1' },
|
||||
{ id: '2', name: 'user_field_2', type: DataType.number, value: 42 },
|
||||
],
|
||||
builtInEnabled: true,
|
||||
builtList: [
|
||||
{ id: 'b1', name: 'created_at', type: DataType.time, value: 1609459200 },
|
||||
{ id: 'b2', name: 'modified_at', type: DataType.time, value: 1609459200 },
|
||||
],
|
||||
originInfo: [
|
||||
{ id: 'o1', name: 'source', type: DataType.string, value: 'file' },
|
||||
{ id: 'o2', name: 'format', type: DataType.string, value: 'txt' },
|
||||
],
|
||||
technicalParameters: [
|
||||
{ id: 't1', name: 'word_count', type: DataType.number, value: 100 },
|
||||
{ id: 't2', name: 'char_count', type: DataType.number, value: 500 },
|
||||
],
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('user_field_1')).toBeInTheDocument()
|
||||
expect(screen.getByText('user_field_2')).toBeInTheDocument()
|
||||
expect(screen.getByText('created_at')).toBeInTheDocument()
|
||||
expect(screen.getByText('source')).toBeInTheDocument()
|
||||
expect(screen.getByText('word_count')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle null values in metadata', () => {
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
list: [
|
||||
{ id: '1', name: 'null_field', type: DataType.string, value: null },
|
||||
],
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('null_field')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined values in metadata', () => {
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
list: [
|
||||
{ id: '1', name: 'undefined_field', type: DataType.string, value: undefined as unknown as null },
|
||||
],
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('undefined_field')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,341 @@
|
||||
import type { MetadataItemWithValue } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DataType } from '../types'
|
||||
import InfoGroup from './info-group'
|
||||
|
||||
type SelectModalProps = {
|
||||
trigger: React.ReactNode
|
||||
onSelect: (item: MetadataItemWithValue) => void
|
||||
onSave: (data: { name: string, type: DataType }) => void
|
||||
onManage: () => void
|
||||
}
|
||||
|
||||
type FieldProps = {
|
||||
label: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
type InputCombinedProps = {
|
||||
value: string | number | null
|
||||
onChange: (value: string | number) => void
|
||||
type: DataType
|
||||
}
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useTimestamp
|
||||
vi.mock('@/hooks/use-timestamp', () => ({
|
||||
default: () => ({
|
||||
formatTime: (timestamp: number) => {
|
||||
if (!timestamp)
|
||||
return ''
|
||||
return new Date(timestamp * 1000).toLocaleDateString()
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock AddMetadataButton
|
||||
vi.mock('../add-metadata-button', () => ({
|
||||
default: () => <button data-testid="add-metadata-btn">Add Metadata</button>,
|
||||
}))
|
||||
|
||||
// Mock InputCombined
|
||||
vi.mock('../edit-metadata-batch/input-combined', () => ({
|
||||
default: ({ value, onChange, type }: InputCombinedProps) => (
|
||||
<input
|
||||
data-testid="input-combined"
|
||||
data-type={type}
|
||||
value={value || ''}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock SelectMetadataModal
|
||||
vi.mock('../metadata-dataset/select-metadata-modal', () => ({
|
||||
default: ({ trigger, onSelect, onSave, onManage }: SelectModalProps) => (
|
||||
<div data-testid="select-metadata-modal">
|
||||
{trigger}
|
||||
<button data-testid="select-action" onClick={() => onSelect({ id: '1', name: 'test', type: DataType.string, value: null })}>Select</button>
|
||||
<button data-testid="save-action" onClick={() => onSave({ name: 'new_field', type: DataType.string })}>Save</button>
|
||||
<button data-testid="manage-action" onClick={onManage}>Manage</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock Field
|
||||
vi.mock('./field', () => ({
|
||||
default: ({ label, children }: FieldProps) => (
|
||||
<div data-testid="field">
|
||||
<span data-testid="field-label">{label}</span>
|
||||
<div data-testid="field-content">{children}</div>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('InfoGroup', () => {
|
||||
const mockList: MetadataItemWithValue[] = [
|
||||
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
|
||||
{ id: '2', name: 'field_two', type: DataType.number, value: 42 },
|
||||
{ id: '3', name: 'built-in', type: DataType.time, value: 1609459200 },
|
||||
]
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(
|
||||
<InfoGroup dataSetId="ds-1" list={mockList} />,
|
||||
)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render title when provided', () => {
|
||||
render(
|
||||
<InfoGroup dataSetId="ds-1" list={mockList} title="Test Title" />,
|
||||
)
|
||||
expect(screen.getByText('Test Title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render header when noHeader is true', () => {
|
||||
render(
|
||||
<InfoGroup dataSetId="ds-1" list={mockList} title="Test Title" noHeader />,
|
||||
)
|
||||
expect(screen.queryByText('Test Title')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all list items', () => {
|
||||
render(
|
||||
<InfoGroup dataSetId="ds-1" list={mockList} />,
|
||||
)
|
||||
const fields = screen.getAllByTestId('field')
|
||||
expect(fields).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should render tooltip when titleTooltip is provided', () => {
|
||||
render(
|
||||
<InfoGroup
|
||||
dataSetId="ds-1"
|
||||
list={mockList}
|
||||
title="Test"
|
||||
titleTooltip="This is a tooltip"
|
||||
/>,
|
||||
)
|
||||
// Tooltip icon should be present
|
||||
const tooltipIcon = screen.getByText('Test').closest('.flex')?.querySelector('svg')
|
||||
expect(tooltipIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render headerRight content', () => {
|
||||
render(
|
||||
<InfoGroup
|
||||
dataSetId="ds-1"
|
||||
list={mockList}
|
||||
title="Test"
|
||||
headerRight={<button data-testid="header-right-btn">Action</button>}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('header-right-btn')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edit Mode', () => {
|
||||
it('should render add metadata button when isEdit is true', () => {
|
||||
render(
|
||||
<InfoGroup dataSetId="ds-1" list={mockList} isEdit />,
|
||||
)
|
||||
expect(screen.getByTestId('add-metadata-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render add metadata button when isEdit is false', () => {
|
||||
render(
|
||||
<InfoGroup dataSetId="ds-1" list={mockList} isEdit={false} />,
|
||||
)
|
||||
expect(screen.queryByTestId('add-metadata-btn')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render input combined for each item in edit mode', () => {
|
||||
render(
|
||||
<InfoGroup dataSetId="ds-1" list={mockList} isEdit />,
|
||||
)
|
||||
const inputs = screen.getAllByTestId('input-combined')
|
||||
expect(inputs).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should render delete icons in edit mode', () => {
|
||||
const { container } = render(
|
||||
<InfoGroup dataSetId="ds-1" list={mockList} isEdit />,
|
||||
)
|
||||
const deleteIcons = container.querySelectorAll('.cursor-pointer svg')
|
||||
expect(deleteIcons.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onChange when input value changes', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InfoGroup dataSetId="ds-1" list={mockList} isEdit onChange={handleChange} />,
|
||||
)
|
||||
|
||||
const inputs = screen.getAllByTestId('input-combined')
|
||||
fireEvent.change(inputs[0], { target: { value: 'New Value' } })
|
||||
|
||||
expect(handleChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onDelete when delete icon is clicked', () => {
|
||||
const handleDelete = vi.fn()
|
||||
const { container } = render(
|
||||
<InfoGroup dataSetId="ds-1" list={mockList} isEdit onDelete={handleDelete} />,
|
||||
)
|
||||
|
||||
// Find delete icons (RiDeleteBinLine SVGs inside cursor-pointer divs)
|
||||
const deleteButtons = container.querySelectorAll('svg.size-4')
|
||||
if (deleteButtons.length > 0)
|
||||
fireEvent.click(deleteButtons[0])
|
||||
|
||||
expect(handleDelete).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onSelect when metadata is selected', () => {
|
||||
const handleSelect = vi.fn()
|
||||
render(
|
||||
<InfoGroup dataSetId="ds-1" list={mockList} isEdit onSelect={handleSelect} />,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('select-action'))
|
||||
|
||||
expect(handleSelect).toHaveBeenCalledWith({
|
||||
id: '1',
|
||||
name: 'test',
|
||||
type: DataType.string,
|
||||
value: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onAdd when new metadata is saved', () => {
|
||||
const handleAdd = vi.fn()
|
||||
render(
|
||||
<InfoGroup dataSetId="ds-1" list={mockList} isEdit onAdd={handleAdd} />,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('save-action'))
|
||||
|
||||
expect(handleAdd).toHaveBeenCalledWith({
|
||||
name: 'new_field',
|
||||
type: DataType.string,
|
||||
})
|
||||
})
|
||||
|
||||
it('should navigate to documents page when manage is clicked', () => {
|
||||
render(
|
||||
<InfoGroup dataSetId="ds-1" list={mockList} isEdit />,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('manage-action'))
|
||||
|
||||
// The onManage callback triggers the navigation
|
||||
expect(screen.getByTestId('manage-action')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(
|
||||
<InfoGroup dataSetId="ds-1" list={mockList} className="custom-class" />,
|
||||
)
|
||||
expect(container.firstChild).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should apply contentClassName', () => {
|
||||
const { container } = render(
|
||||
<InfoGroup dataSetId="ds-1" list={mockList} contentClassName="content-custom" />,
|
||||
)
|
||||
const contentDiv = container.querySelector('.content-custom')
|
||||
expect(contentDiv).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use uppercase title by default', () => {
|
||||
render(
|
||||
<InfoGroup dataSetId="ds-1" list={mockList} title="Test Title" />,
|
||||
)
|
||||
const titleElement = screen.getByText('Test Title')
|
||||
expect(titleElement).toHaveClass('system-xs-semibold-uppercase')
|
||||
})
|
||||
|
||||
it('should not use uppercase when uppercaseTitle is false', () => {
|
||||
render(
|
||||
<InfoGroup dataSetId="ds-1" list={mockList} title="Test Title" uppercaseTitle={false} />,
|
||||
)
|
||||
const titleElement = screen.getByText('Test Title')
|
||||
expect(titleElement).toHaveClass('system-md-semibold')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Value Display', () => {
|
||||
it('should display string value directly', () => {
|
||||
const stringList: MetadataItemWithValue[] = [
|
||||
{ id: '1', name: 'field', type: DataType.string, value: 'Test Value' },
|
||||
]
|
||||
render(
|
||||
<InfoGroup dataSetId="ds-1" list={stringList} />,
|
||||
)
|
||||
expect(screen.getByText('Test Value')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display number value', () => {
|
||||
const numberList: MetadataItemWithValue[] = [
|
||||
{ id: '1', name: 'field', type: DataType.number, value: 123 },
|
||||
]
|
||||
render(
|
||||
<InfoGroup dataSetId="ds-1" list={numberList} />,
|
||||
)
|
||||
expect(screen.getByText('123')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should format time value', () => {
|
||||
const timeList: MetadataItemWithValue[] = [
|
||||
{ id: '1', name: 'field', type: DataType.time, value: 1609459200 },
|
||||
]
|
||||
render(
|
||||
<InfoGroup dataSetId="ds-1" list={timeList} />,
|
||||
)
|
||||
// The mock formatTime returns formatted date
|
||||
expect(screen.getByTestId('field-content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty list', () => {
|
||||
const { container } = render(
|
||||
<InfoGroup dataSetId="ds-1" list={[]} />,
|
||||
)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle null value in list', () => {
|
||||
const nullList: MetadataItemWithValue[] = [
|
||||
{ id: '1', name: 'field', type: DataType.string, value: null },
|
||||
]
|
||||
render(
|
||||
<InfoGroup dataSetId="ds-1" list={nullList} />,
|
||||
)
|
||||
expect(screen.getByTestId('field')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle items with built-in id', () => {
|
||||
const builtInList: MetadataItemWithValue[] = [
|
||||
{ id: 'built-in', name: 'field', type: DataType.string, value: 'test' },
|
||||
]
|
||||
render(
|
||||
<InfoGroup dataSetId="ds-1" list={builtInList} />,
|
||||
)
|
||||
expect(screen.getByTestId('field')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,131 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import NoData from './no-data'
|
||||
|
||||
describe('NoData', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const handleStart = vi.fn()
|
||||
const { container } = render(<NoData onStart={handleStart} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with gradient background', () => {
|
||||
const handleStart = vi.fn()
|
||||
const { container } = render(<NoData onStart={handleStart} />)
|
||||
expect(container.firstChild).toHaveClass('rounded-xl', 'bg-gradient-to-r', 'p-4', 'pt-3')
|
||||
})
|
||||
|
||||
it('should render title text', () => {
|
||||
const handleStart = vi.fn()
|
||||
const { container } = render(<NoData onStart={handleStart} />)
|
||||
// Title should have correct styling
|
||||
const title = container.querySelector('.text-xs.font-semibold')
|
||||
expect(title).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description text', () => {
|
||||
const handleStart = vi.fn()
|
||||
const { container } = render(<NoData onStart={handleStart} />)
|
||||
// Description should have correct styling
|
||||
const description = container.querySelector('.system-xs-regular.mt-1')
|
||||
expect(description).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render start labeling button', () => {
|
||||
const handleStart = vi.fn()
|
||||
render(<NoData onStart={handleStart} />)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render arrow icon in button', () => {
|
||||
const handleStart = vi.fn()
|
||||
const { container } = render(<NoData onStart={handleStart} />)
|
||||
// RiArrowRightLine icon should be present
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should accept onStart prop', () => {
|
||||
const handleStart = vi.fn()
|
||||
render(<NoData onStart={handleStart} />)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onStart when button is clicked', () => {
|
||||
const handleStart = vi.fn()
|
||||
render(<NoData onStart={handleStart} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(handleStart).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onStart multiple times on multiple clicks', () => {
|
||||
const handleStart = vi.fn()
|
||||
render(<NoData onStart={handleStart} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
fireEvent.click(button)
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(handleStart).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('should have been called when button is clicked', () => {
|
||||
const handleStart = vi.fn()
|
||||
render(<NoData onStart={handleStart} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
// The onClick handler passes the event to onStart
|
||||
expect(handleStart).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Button Styling', () => {
|
||||
it('should have primary variant button', () => {
|
||||
const handleStart = vi.fn()
|
||||
render(<NoData onStart={handleStart} />)
|
||||
const button = screen.getByRole('button')
|
||||
// Button should have primary styling
|
||||
expect(button).toHaveClass('mt-2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Layout', () => {
|
||||
it('should have correct title styling', () => {
|
||||
const handleStart = vi.fn()
|
||||
const { container } = render(<NoData onStart={handleStart} />)
|
||||
const title = container.querySelector('.text-xs.font-semibold')
|
||||
expect(title).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have correct description styling', () => {
|
||||
const handleStart = vi.fn()
|
||||
const { container } = render(<NoData onStart={handleStart} />)
|
||||
const description = container.querySelector('.system-xs-regular.mt-1')
|
||||
expect(description).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle rapid clicks', () => {
|
||||
const handleStart = vi.fn()
|
||||
render(<NoData onStart={handleStart} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
for (let i = 0; i < 10; i++) {
|
||||
fireEvent.click(button)
|
||||
}
|
||||
|
||||
expect(handleStart).toHaveBeenCalledTimes(10)
|
||||
})
|
||||
})
|
||||
})
|
||||
45
web/app/components/datasets/metadata/utils/get-icon.spec.ts
Normal file
45
web/app/components/datasets/metadata/utils/get-icon.spec.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { RiHashtag, RiTextSnippet, RiTimeLine } from '@remixicon/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { DataType } from '../types'
|
||||
import { getIcon } from './get-icon'
|
||||
|
||||
describe('getIcon', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should return RiTextSnippet for DataType.string', () => {
|
||||
const result = getIcon(DataType.string)
|
||||
expect(result).toBe(RiTextSnippet)
|
||||
})
|
||||
|
||||
it('should return RiHashtag for DataType.number', () => {
|
||||
const result = getIcon(DataType.number)
|
||||
expect(result).toBe(RiHashtag)
|
||||
})
|
||||
|
||||
it('should return RiTimeLine for DataType.time', () => {
|
||||
const result = getIcon(DataType.time)
|
||||
expect(result).toBe(RiTimeLine)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should return RiTextSnippet as fallback for unknown type', () => {
|
||||
const result = getIcon('unknown' as DataType)
|
||||
expect(result).toBe(RiTextSnippet)
|
||||
})
|
||||
|
||||
it('should return RiTextSnippet for undefined type', () => {
|
||||
const result = getIcon(undefined as unknown as DataType)
|
||||
expect(result).toBe(RiTextSnippet)
|
||||
})
|
||||
|
||||
it('should return RiTextSnippet for null type', () => {
|
||||
const result = getIcon(null as unknown as DataType)
|
||||
expect(result).toBe(RiTextSnippet)
|
||||
})
|
||||
|
||||
it('should return RiTextSnippet for empty string type', () => {
|
||||
const result = getIcon('' as DataType)
|
||||
expect(result).toBe(RiTextSnippet)
|
||||
})
|
||||
})
|
||||
})
|
||||
1173
web/app/components/datasets/rename-modal/index.spec.tsx
Normal file
1173
web/app/components/datasets/rename-modal/index.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,239 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useChunkStructure } from './hooks'
|
||||
import { EffectColor } from './types'
|
||||
|
||||
// Note: react-i18next is globally mocked in vitest.setup.ts
|
||||
|
||||
describe('useChunkStructure', () => {
|
||||
describe('Hook Initialization', () => {
|
||||
it('should return options array', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
expect(result.current.options).toBeDefined()
|
||||
expect(Array.isArray(result.current.options)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return exactly 3 options', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
expect(result.current.options).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('General Option', () => {
|
||||
it('should have correct id for General option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const generalOption = result.current.options[0]
|
||||
expect(generalOption.id).toBe('text_model')
|
||||
})
|
||||
|
||||
it('should have icon for General option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const generalOption = result.current.options[0]
|
||||
expect(generalOption.icon).toBeDefined()
|
||||
})
|
||||
|
||||
it('should have correct iconActiveColor for General option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const generalOption = result.current.options[0]
|
||||
expect(generalOption.iconActiveColor).toBe('text-util-colors-indigo-indigo-600')
|
||||
})
|
||||
|
||||
it('should have title for General option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const generalOption = result.current.options[0]
|
||||
expect(generalOption.title).toBe('General')
|
||||
})
|
||||
|
||||
it('should have description for General option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const generalOption = result.current.options[0]
|
||||
expect(generalOption.description).toBeDefined()
|
||||
})
|
||||
|
||||
it('should have indigo effectColor for General option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const generalOption = result.current.options[0]
|
||||
expect(generalOption.effectColor).toBe(EffectColor.indigo)
|
||||
})
|
||||
|
||||
it('should have showEffectColor true for General option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const generalOption = result.current.options[0]
|
||||
expect(generalOption.showEffectColor).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Parent-Child Option', () => {
|
||||
it('should have correct id for Parent-Child option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const parentChildOption = result.current.options[1]
|
||||
expect(parentChildOption.id).toBe('hierarchical_model')
|
||||
})
|
||||
|
||||
it('should have icon for Parent-Child option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const parentChildOption = result.current.options[1]
|
||||
expect(parentChildOption.icon).toBeDefined()
|
||||
})
|
||||
|
||||
it('should have correct iconActiveColor for Parent-Child option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const parentChildOption = result.current.options[1]
|
||||
expect(parentChildOption.iconActiveColor).toBe('text-util-colors-blue-light-blue-light-500')
|
||||
})
|
||||
|
||||
it('should have title for Parent-Child option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const parentChildOption = result.current.options[1]
|
||||
expect(parentChildOption.title).toBe('Parent-Child')
|
||||
})
|
||||
|
||||
it('should have description for Parent-Child option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const parentChildOption = result.current.options[1]
|
||||
expect(parentChildOption.description).toBeDefined()
|
||||
})
|
||||
|
||||
it('should have blueLight effectColor for Parent-Child option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const parentChildOption = result.current.options[1]
|
||||
expect(parentChildOption.effectColor).toBe(EffectColor.blueLight)
|
||||
})
|
||||
|
||||
it('should have showEffectColor true for Parent-Child option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const parentChildOption = result.current.options[1]
|
||||
expect(parentChildOption.showEffectColor).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Q&A Option', () => {
|
||||
it('should have correct id for Q&A option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const qaOption = result.current.options[2]
|
||||
expect(qaOption.id).toBe('qa_model')
|
||||
})
|
||||
|
||||
it('should have icon for Q&A option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const qaOption = result.current.options[2]
|
||||
expect(qaOption.icon).toBeDefined()
|
||||
})
|
||||
|
||||
it('should have title for Q&A option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const qaOption = result.current.options[2]
|
||||
expect(qaOption.title).toBe('Q&A')
|
||||
})
|
||||
|
||||
it('should have description for Q&A option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const qaOption = result.current.options[2]
|
||||
expect(qaOption.description).toBeDefined()
|
||||
})
|
||||
|
||||
it('should not have effectColor for Q&A option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const qaOption = result.current.options[2]
|
||||
expect(qaOption.effectColor).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should not have showEffectColor for Q&A option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const qaOption = result.current.options[2]
|
||||
expect(qaOption.showEffectColor).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should not have iconActiveColor for Q&A option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const qaOption = result.current.options[2]
|
||||
expect(qaOption.iconActiveColor).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Options Structure', () => {
|
||||
it('should return options in correct order', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const ids = result.current.options.map(opt => opt.id)
|
||||
expect(ids).toEqual(['text_model', 'hierarchical_model', 'qa_model'])
|
||||
})
|
||||
|
||||
it('should return all options with required id property', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
result.current.options.forEach((option) => {
|
||||
expect(option.id).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('should return all options with required title property', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
result.current.options.forEach((option) => {
|
||||
expect(option.title).toBeDefined()
|
||||
expect(typeof option.title).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
it('should return all options with description property', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
result.current.options.forEach((option) => {
|
||||
expect(option.description).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('should return all options with icon property', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
result.current.options.forEach((option) => {
|
||||
expect(option.icon).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Hook Stability', () => {
|
||||
it('should return consistent options on multiple renders', () => {
|
||||
const { result, rerender } = renderHook(() => useChunkStructure())
|
||||
|
||||
const firstRenderOptions = result.current.options.map(opt => opt.id)
|
||||
rerender()
|
||||
const secondRenderOptions = result.current.options.map(opt => opt.id)
|
||||
|
||||
expect(firstRenderOptions).toEqual(secondRenderOptions)
|
||||
})
|
||||
|
||||
it('should return options with stable structure', () => {
|
||||
const { result, rerender } = renderHook(() => useChunkStructure())
|
||||
|
||||
const firstLength = result.current.options.length
|
||||
rerender()
|
||||
const secondLength = result.current.options.length
|
||||
|
||||
expect(firstLength).toBe(secondLength)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,125 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { ChunkingMode } from '@/models/datasets'
|
||||
import ChunkStructure from './index'
|
||||
|
||||
// Note: react-i18next is globally mocked in vitest.setup.ts
|
||||
|
||||
describe('ChunkStructure', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
|
||||
expect(screen.getByText('General')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all three options', () => {
|
||||
render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
|
||||
expect(screen.getByText('General')).toBeInTheDocument()
|
||||
expect(screen.getByText('Parent-Child')).toBeInTheDocument()
|
||||
expect(screen.getByText('Q&A')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render in a vertical layout', () => {
|
||||
const { container } = render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
|
||||
const wrapper = container.firstChild
|
||||
expect(wrapper).toHaveClass('flex-col')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Active State', () => {
|
||||
it('should mark General option as active when chunkStructure is text', () => {
|
||||
const { container } = render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
|
||||
// The active card has ring styling
|
||||
const activeCards = container.querySelectorAll('.ring-\\[1px\\]')
|
||||
expect(activeCards).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should mark Parent-Child option as active when chunkStructure is parentChild', () => {
|
||||
const { container } = render(<ChunkStructure chunkStructure={ChunkingMode.parentChild} />)
|
||||
const activeCards = container.querySelectorAll('.ring-\\[1px\\]')
|
||||
expect(activeCards).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should mark Q&A option as active when chunkStructure is qa', () => {
|
||||
const { container } = render(<ChunkStructure chunkStructure={ChunkingMode.qa} />)
|
||||
const activeCards = container.querySelectorAll('.ring-\\[1px\\]')
|
||||
expect(activeCards).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Disabled State', () => {
|
||||
it('should render all options as disabled', () => {
|
||||
const { container } = render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
|
||||
// All cards should have cursor-not-allowed (disabled)
|
||||
const disabledCards = container.querySelectorAll('.cursor-not-allowed')
|
||||
expect(disabledCards.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Option Cards', () => {
|
||||
it('should render option cards with correct structure', () => {
|
||||
render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
|
||||
|
||||
// All options should have descriptions
|
||||
expect(screen.getByText(/stepTwo\.generalTip/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/stepTwo\.parentChildTip/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/stepTwo\.qaTip/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render icons for all options', () => {
|
||||
const { container } = render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
|
||||
// Each option card should have an icon (SVG elements)
|
||||
const svgs = container.querySelectorAll('svg')
|
||||
expect(svgs.length).toBeGreaterThanOrEqual(3) // At least 3 icons
|
||||
})
|
||||
})
|
||||
|
||||
describe('Effect Colors', () => {
|
||||
it('should show effect color for active General option', () => {
|
||||
const { container } = render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
|
||||
const effectElement = container.querySelector('.bg-util-colors-indigo-indigo-600')
|
||||
expect(effectElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show effect color for active Parent-Child option', () => {
|
||||
const { container } = render(<ChunkStructure chunkStructure={ChunkingMode.parentChild} />)
|
||||
const effectElement = container.querySelector('.bg-util-colors-blue-light-blue-light-600')
|
||||
expect(effectElement).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should update active state when chunkStructure prop changes', () => {
|
||||
const { rerender, container } = render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
|
||||
|
||||
// Initially one card is active
|
||||
let activeCards = container.querySelectorAll('.ring-\\[1px\\]')
|
||||
expect(activeCards).toHaveLength(1)
|
||||
|
||||
// Change to parentChild
|
||||
rerender(<ChunkStructure chunkStructure={ChunkingMode.parentChild} />)
|
||||
|
||||
// Still one card should be active
|
||||
activeCards = container.querySelectorAll('.ring-\\[1px\\]')
|
||||
expect(activeCards).toHaveLength(1)
|
||||
|
||||
// Change to qa
|
||||
rerender(<ChunkStructure chunkStructure={ChunkingMode.qa} />)
|
||||
|
||||
// Still one card should be active
|
||||
activeCards = container.querySelectorAll('.ring-\\[1px\\]')
|
||||
expect(activeCards).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Integration with useChunkStructure hook', () => {
|
||||
it('should use options from useChunkStructure hook', () => {
|
||||
render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
|
||||
|
||||
// Verify all expected options are rendered
|
||||
const expectedTitles = ['General', 'Parent-Child', 'Q&A']
|
||||
expectedTitles.forEach((title) => {
|
||||
expect(screen.getByText(title)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -273,7 +273,7 @@ const Form = () => {
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
currentDataset?.doc_form && (
|
||||
!!currentDataset?.doc_form && (
|
||||
<>
|
||||
<Divider
|
||||
type="horizontal"
|
||||
@ -306,13 +306,13 @@ const Form = () => {
|
||||
</>
|
||||
)
|
||||
}
|
||||
{(isShowIndexMethod || indexMethod === 'high_quality') && (
|
||||
{!!(isShowIndexMethod || indexMethod === 'high_quality') && (
|
||||
<Divider
|
||||
type="horizontal"
|
||||
className="my-1 h-px bg-divider-subtle"
|
||||
/>
|
||||
)}
|
||||
{isShowIndexMethod && (
|
||||
{!!isShowIndexMethod && (
|
||||
<div className={rowClass}>
|
||||
<div className={labelClass}>
|
||||
<div className="system-sm-semibold text-text-secondary">{t('form.indexMethod', { ns: 'datasetSettings' })}</div>
|
||||
@ -428,7 +428,7 @@ const Form = () => {
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
// eslint-disable-next-line sonarjs/no-nested-conditional
|
||||
|
||||
: indexMethod
|
||||
? (
|
||||
<>
|
||||
|
||||
208
web/app/components/datasets/settings/index-method/index.spec.tsx
Normal file
208
web/app/components/datasets/settings/index-method/index.spec.tsx
Normal file
@ -0,0 +1,208 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { IndexingType } from '../../create/step-two'
|
||||
import IndexMethod from './index'
|
||||
|
||||
// Note: react-i18next is globally mocked in vitest.setup.ts
|
||||
|
||||
describe('IndexMethod', () => {
|
||||
const defaultProps = {
|
||||
value: IndexingType.QUALIFIED,
|
||||
onChange: vi.fn(),
|
||||
keywordNumber: 10,
|
||||
onKeywordNumberChange: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<IndexMethod {...defaultProps} />)
|
||||
expect(screen.getByText(/stepTwo\.qualified/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render High Quality option', () => {
|
||||
render(<IndexMethod {...defaultProps} />)
|
||||
expect(screen.getByText(/stepTwo\.qualified/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Economy option', () => {
|
||||
render(<IndexMethod {...defaultProps} />)
|
||||
expect(screen.getAllByText(/form\.indexMethodEconomy/).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should render High Quality description', () => {
|
||||
render(<IndexMethod {...defaultProps} />)
|
||||
expect(screen.getByText(/form\.indexMethodHighQualityTip/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Economy description', () => {
|
||||
render(<IndexMethod {...defaultProps} />)
|
||||
expect(screen.getByText(/form\.indexMethodEconomyTip/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render recommended badge on High Quality', () => {
|
||||
render(<IndexMethod {...defaultProps} />)
|
||||
expect(screen.getByText(/stepTwo\.recommend/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Active State', () => {
|
||||
it('should mark High Quality as active when value is QUALIFIED', () => {
|
||||
const { container } = render(<IndexMethod {...defaultProps} value={IndexingType.QUALIFIED} />)
|
||||
const activeCards = container.querySelectorAll('.ring-\\[1px\\]')
|
||||
expect(activeCards).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should mark Economy as active when value is ECONOMICAL', () => {
|
||||
const { container } = render(<IndexMethod {...defaultProps} value={IndexingType.ECONOMICAL} />)
|
||||
const activeCards = container.querySelectorAll('.ring-\\[1px\\]')
|
||||
expect(activeCards).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onChange with QUALIFIED when High Quality is clicked', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<IndexMethod {...defaultProps} value={IndexingType.ECONOMICAL} onChange={handleChange} />)
|
||||
|
||||
// Find and click High Quality option
|
||||
const highQualityTitle = screen.getByText(/stepTwo\.qualified/)
|
||||
const card = highQualityTitle.closest('div')?.parentElement?.parentElement?.parentElement
|
||||
fireEvent.click(card!)
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith(IndexingType.QUALIFIED)
|
||||
})
|
||||
|
||||
it('should call onChange with ECONOMICAL when Economy is clicked', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<IndexMethod {...defaultProps} value={IndexingType.QUALIFIED} onChange={handleChange} currentValue={IndexingType.ECONOMICAL} />)
|
||||
|
||||
// Find and click Economy option - use getAllByText and get the first one (title)
|
||||
const economyTitles = screen.getAllByText(/form\.indexMethodEconomy/)
|
||||
const economyTitle = economyTitles[0]
|
||||
const card = economyTitle.closest('div')?.parentElement?.parentElement?.parentElement
|
||||
fireEvent.click(card!)
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith(IndexingType.ECONOMICAL)
|
||||
})
|
||||
|
||||
it('should not call onChange when clicking already active option', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<IndexMethod {...defaultProps} value={IndexingType.QUALIFIED} onChange={handleChange} />)
|
||||
|
||||
// Click on already active High Quality
|
||||
const highQualityTitle = screen.getByText(/stepTwo\.qualified/)
|
||||
const card = highQualityTitle.closest('div')?.parentElement?.parentElement?.parentElement
|
||||
fireEvent.click(card!)
|
||||
|
||||
expect(handleChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Disabled State', () => {
|
||||
it('should disable both options when disabled is true', () => {
|
||||
const { container } = render(<IndexMethod {...defaultProps} disabled={true} />)
|
||||
const disabledCards = container.querySelectorAll('.cursor-not-allowed')
|
||||
expect(disabledCards.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should disable Economy option when currentValue is QUALIFIED', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<IndexMethod {...defaultProps} currentValue={IndexingType.QUALIFIED} onChange={handleChange} value={IndexingType.ECONOMICAL} />)
|
||||
|
||||
// Try to click Economy option - use getAllByText and get the first one (title)
|
||||
const economyTitles = screen.getAllByText(/form\.indexMethodEconomy/)
|
||||
const economyTitle = economyTitles[0]
|
||||
const card = economyTitle.closest('div')?.parentElement?.parentElement?.parentElement
|
||||
fireEvent.click(card!)
|
||||
|
||||
// Should not call onChange because Economy is disabled when current is QUALIFIED
|
||||
expect(handleChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('KeywordNumber', () => {
|
||||
it('should render KeywordNumber component inside Economy option', () => {
|
||||
render(<IndexMethod {...defaultProps} />)
|
||||
// KeywordNumber has a slider
|
||||
expect(screen.getByRole('slider')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass keywordNumber to KeywordNumber component', () => {
|
||||
render(<IndexMethod {...defaultProps} keywordNumber={25} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(25)
|
||||
})
|
||||
|
||||
it('should call onKeywordNumberChange when KeywordNumber changes', () => {
|
||||
const handleKeywordChange = vi.fn()
|
||||
render(<IndexMethod {...defaultProps} onKeywordNumberChange={handleKeywordChange} />)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
fireEvent.change(input, { target: { value: '30' } })
|
||||
|
||||
expect(handleKeywordChange).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tooltip', () => {
|
||||
it('should show tooltip when hovering over disabled Economy option', () => {
|
||||
// The tooltip is shown via PortalToFollowElem when hovering
|
||||
// This is controlled by useHover hook
|
||||
render(<IndexMethod {...defaultProps} currentValue={IndexingType.QUALIFIED} />)
|
||||
// The tooltip content should exist in DOM but may not be visible
|
||||
// We just verify the component renders without error
|
||||
expect(screen.getAllByText(/form\.indexMethodEconomy/).length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Effect Colors', () => {
|
||||
it('should show orange effect color for High Quality option', () => {
|
||||
const { container } = render(<IndexMethod {...defaultProps} />)
|
||||
const orangeEffect = container.querySelector('.bg-util-colors-orange-orange-500')
|
||||
expect(orangeEffect).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show indigo effect color for Economy option', () => {
|
||||
const { container } = render(<IndexMethod {...defaultProps} />)
|
||||
const indigoEffect = container.querySelector('.bg-util-colors-indigo-indigo-600')
|
||||
expect(indigoEffect).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should update active state when value prop changes', () => {
|
||||
const { rerender, container } = render(<IndexMethod {...defaultProps} value={IndexingType.QUALIFIED} />)
|
||||
|
||||
let activeCards = container.querySelectorAll('.ring-\\[1px\\]')
|
||||
expect(activeCards).toHaveLength(1)
|
||||
|
||||
rerender(<IndexMethod {...defaultProps} value={IndexingType.ECONOMICAL} currentValue={IndexingType.ECONOMICAL} />)
|
||||
|
||||
activeCards = container.querySelectorAll('.ring-\\[1px\\]')
|
||||
expect(activeCards).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined currentValue', () => {
|
||||
render(<IndexMethod {...defaultProps} currentValue={undefined} />)
|
||||
// Should render without error
|
||||
expect(screen.getByText(/stepTwo\.qualified/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle keywordNumber of 0', () => {
|
||||
render(<IndexMethod {...defaultProps} keywordNumber={0} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(0)
|
||||
})
|
||||
|
||||
it('should handle max keywordNumber', () => {
|
||||
render(<IndexMethod {...defaultProps} keywordNumber={50} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(50)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,171 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import KeyWordNumber from './keyword-number'
|
||||
|
||||
// Note: react-i18next is globally mocked in vitest.setup.ts
|
||||
|
||||
describe('KeyWordNumber', () => {
|
||||
const defaultProps = {
|
||||
keywordNumber: 10,
|
||||
onKeywordNumberChange: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<KeyWordNumber {...defaultProps} />)
|
||||
expect(screen.getByText(/form\.numberOfKeywords/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render label text', () => {
|
||||
render(<KeyWordNumber {...defaultProps} />)
|
||||
expect(screen.getByText(/form\.numberOfKeywords/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tooltip with question icon', () => {
|
||||
render(<KeyWordNumber {...defaultProps} />)
|
||||
// RiQuestionLine renders as an svg
|
||||
const container = screen.getByText(/form\.numberOfKeywords/).closest('div')?.parentElement
|
||||
const questionIcon = container?.querySelector('svg')
|
||||
expect(questionIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render slider', () => {
|
||||
render(<KeyWordNumber {...defaultProps} />)
|
||||
// Slider has a slider role
|
||||
expect(screen.getByRole('slider')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render input number field', () => {
|
||||
render(<KeyWordNumber {...defaultProps} />)
|
||||
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should display correct keywordNumber value in input', () => {
|
||||
render(<KeyWordNumber {...defaultProps} keywordNumber={25} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(25)
|
||||
})
|
||||
|
||||
it('should display different keywordNumber values', () => {
|
||||
const values = [1, 10, 25, 50]
|
||||
|
||||
values.forEach((value) => {
|
||||
const { unmount } = render(<KeyWordNumber {...defaultProps} keywordNumber={value} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(value)
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass correct value to slider', () => {
|
||||
render(<KeyWordNumber {...defaultProps} keywordNumber={30} />)
|
||||
const slider = screen.getByRole('slider')
|
||||
expect(slider).toHaveAttribute('aria-valuenow', '30')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should render slider that accepts onChange', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
|
||||
|
||||
const slider = screen.getByRole('slider')
|
||||
// Verify slider is rendered and interactive
|
||||
expect(slider).toBeInTheDocument()
|
||||
expect(slider).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('should call onKeywordNumberChange when input value changes', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
fireEvent.change(input, { target: { value: '30' } })
|
||||
|
||||
expect(handleChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call onKeywordNumberChange with undefined value', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
|
||||
// When value is empty/undefined, handleInputChange should not call onKeywordNumberChange
|
||||
expect(handleChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Slider Configuration', () => {
|
||||
it('should have max value of 50', () => {
|
||||
render(<KeyWordNumber {...defaultProps} />)
|
||||
const slider = screen.getByRole('slider')
|
||||
expect(slider).toHaveAttribute('aria-valuemax', '50')
|
||||
})
|
||||
|
||||
it('should have min value of 0', () => {
|
||||
render(<KeyWordNumber {...defaultProps} />)
|
||||
const slider = screen.getByRole('slider')
|
||||
expect(slider).toHaveAttribute('aria-valuemin', '0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle minimum value (0)', () => {
|
||||
render(<KeyWordNumber {...defaultProps} keywordNumber={0} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(0)
|
||||
})
|
||||
|
||||
it('should handle maximum value (50)', () => {
|
||||
render(<KeyWordNumber {...defaultProps} keywordNumber={50} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(50)
|
||||
})
|
||||
|
||||
it('should handle value updates correctly', () => {
|
||||
const { rerender } = render(<KeyWordNumber {...defaultProps} keywordNumber={10} />)
|
||||
|
||||
let input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(10)
|
||||
|
||||
rerender(<KeyWordNumber {...defaultProps} keywordNumber={25} />)
|
||||
input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(25)
|
||||
})
|
||||
|
||||
it('should handle rapid value changes', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
|
||||
// Simulate rapid changes via input with different values
|
||||
fireEvent.change(input, { target: { value: '15' } })
|
||||
fireEvent.change(input, { target: { value: '25' } })
|
||||
fireEvent.change(input, { target: { value: '35' } })
|
||||
|
||||
expect(handleChange).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have accessible slider', () => {
|
||||
render(<KeyWordNumber {...defaultProps} />)
|
||||
const slider = screen.getByRole('slider')
|
||||
expect(slider).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have accessible input', () => {
|
||||
render(<KeyWordNumber {...defaultProps} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user