Compare commits

..

1 Commits

Author SHA1 Message Date
CodingOnStar
c4a3be7fb6 test: add comprehensive tests for billing integration and partner stack info handling
- Introduced a new test suite for the billing integration, covering the rendering of the billing page and plan components, ensuring all usage metrics are displayed correctly.
- Enhanced tests for the partner stack info hook to handle various scenarios, including invalid cookie JSON, absence of keys, and error handling during binding.
- Updated existing tests for the CloudPlanItem component to cover additional edge cases and ensure proper behavior during user interactions.
2026-02-10 12:39:46 +08:00
157 changed files with 5820 additions and 20718 deletions

View File

@@ -0,0 +1,996 @@
import type { UsagePlanInfo, UsageResetInfo } from '@/app/components/billing/type'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { defaultPlan, NUM_INFINITE } from '@/app/components/billing/config'
import { Plan } from '@/app/components/billing/type'
// ─── Module-level mock state ────────────────────────────────────────────────
let mockProviderCtx: Record<string, unknown> = {}
let mockAppCtx: Record<string, unknown> = {}
const mockSetShowPricingModal = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
// ─── Context mocks ──────────────────────────────────────────────────────────
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => mockProviderCtx,
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => mockAppCtx,
}))
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowPricingModal: mockSetShowPricingModal,
}),
useModalContextSelector: (selector: (s: Record<string, unknown>) => unknown) =>
selector({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
}))
vi.mock('@/context/i18n', () => ({
useGetLanguage: () => 'en-US',
useGetPricingPageLanguage: () => 'en',
}))
// ─── Service mocks ──────────────────────────────────────────────────────────
const mockRefetch = vi.fn().mockResolvedValue({ data: 'https://billing.example.com' })
vi.mock('@/service/use-billing', () => ({
useBillingUrl: () => ({
data: 'https://billing.example.com',
isFetching: false,
refetch: mockRefetch,
}),
useBindPartnerStackInfo: () => ({ mutateAsync: vi.fn() }),
}))
vi.mock('@/service/use-education', () => ({
useEducationVerify: () => ({
mutateAsync: vi.fn().mockResolvedValue({ token: 'test-token' }),
isPending: false,
}),
}))
// ─── Navigation mocks ───────────────────────────────────────────────────────
const mockRouterPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: mockRouterPush }),
usePathname: () => '/billing',
useSearchParams: () => new URLSearchParams(),
}))
vi.mock('@/hooks/use-async-window-open', () => ({
useAsyncWindowOpen: () => vi.fn(),
}))
// ─── External component mocks ───────────────────────────────────────────────
vi.mock('@/app/education-apply/verify-state-modal', () => ({
default: ({ isShow }: { isShow: boolean }) =>
isShow ? <div data-testid="verify-state-modal" /> : null,
}))
vi.mock('@/app/components/header/utils/util', () => ({
mailToSupport: () => 'mailto:support@test.com',
}))
// ─── Test data factories ────────────────────────────────────────────────────
type PlanOverrides = {
type?: string
usage?: Partial<UsagePlanInfo>
total?: Partial<UsagePlanInfo>
reset?: Partial<UsageResetInfo>
}
const createPlanData = (overrides: PlanOverrides = {}) => ({
...defaultPlan,
...overrides,
type: overrides.type ?? defaultPlan.type,
usage: { ...defaultPlan.usage, ...overrides.usage },
total: { ...defaultPlan.total, ...overrides.total },
reset: { ...defaultPlan.reset, ...overrides.reset },
})
const setupProviderContext = (planOverrides: PlanOverrides = {}, extra: Record<string, unknown> = {}) => {
mockProviderCtx = {
plan: createPlanData(planOverrides),
enableBilling: true,
isFetchedPlan: true,
enableEducationPlan: false,
isEducationAccount: false,
allowRefreshEducationVerify: false,
...extra,
}
}
const setupAppContext = (overrides: Record<string, unknown> = {}) => {
mockAppCtx = {
isCurrentWorkspaceManager: true,
userProfile: { email: 'test@example.com' },
langGeniusVersionInfo: { current_version: '1.0.0' },
...overrides,
}
}
// ─── Imports (after mocks) ──────────────────────────────────────────────────
// These must be imported after all vi.mock() calls
/* eslint-disable import/first */
import AnnotationFull from '@/app/components/billing/annotation-full'
import AnnotationFullModal from '@/app/components/billing/annotation-full/modal'
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import Billing from '@/app/components/billing/billing-page'
import HeaderBillingBtn from '@/app/components/billing/header-billing-btn'
import PlanComp from '@/app/components/billing/plan'
import PlanUpgradeModal from '@/app/components/billing/plan-upgrade-modal'
import PriorityLabel from '@/app/components/billing/priority-label'
import TriggerEventsLimitModal from '@/app/components/billing/trigger-events-limit-modal'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
import VectorSpaceFull from '@/app/components/billing/vector-space-full'
/* eslint-enable import/first */
// ═══════════════════════════════════════════════════════════════════════════
// 1. Billing Page + Plan Component Integration
// Tests the full data flow: BillingPage → PlanComp → UsageInfo → ProgressBar
// ═══════════════════════════════════════════════════════════════════════════
describe('Billing Page + Plan Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
setupAppContext()
})
// Verify that the billing page renders PlanComp with all 7 usage items
describe('Rendering complete plan information', () => {
it('should display all 7 usage metrics for sandbox plan', () => {
setupProviderContext({
type: Plan.sandbox,
usage: {
buildApps: 3,
teamMembers: 1,
documentsUploadQuota: 10,
vectorSpace: 20,
annotatedResponse: 5,
triggerEvents: 1000,
apiRateLimit: 2000,
},
total: {
buildApps: 5,
teamMembers: 1,
documentsUploadQuota: 50,
vectorSpace: 50,
annotatedResponse: 10,
triggerEvents: 3000,
apiRateLimit: 5000,
},
})
render(<Billing />)
// Plan name
expect(screen.getByText(/plans\.sandbox\.name/i)).toBeInTheDocument()
// All 7 usage items should be visible
expect(screen.getByText(/usagePage\.buildApps/i)).toBeInTheDocument()
expect(screen.getByText(/usagePage\.teamMembers/i)).toBeInTheDocument()
expect(screen.getByText(/usagePage\.documentsUploadQuota/i)).toBeInTheDocument()
expect(screen.getByText(/usagePage\.vectorSpace/i)).toBeInTheDocument()
expect(screen.getByText(/usagePage\.annotationQuota/i)).toBeInTheDocument()
expect(screen.getByText(/usagePage\.triggerEvents/i)).toBeInTheDocument()
expect(screen.getByText(/plansCommon\.apiRateLimit/i)).toBeInTheDocument()
})
it('should display usage values as "usage / total" format', () => {
setupProviderContext({
type: Plan.sandbox,
usage: { buildApps: 3, teamMembers: 1 },
total: { buildApps: 5, teamMembers: 1 },
})
render(<PlanComp loc="test" />)
// Check that the buildApps usage fraction "3 / 5" is rendered
const usageContainers = screen.getAllByText('3')
expect(usageContainers.length).toBeGreaterThan(0)
const totalContainers = screen.getAllByText('5')
expect(totalContainers.length).toBeGreaterThan(0)
})
it('should show "unlimited" for infinite quotas (professional API rate limit)', () => {
setupProviderContext({
type: Plan.professional,
total: { apiRateLimit: NUM_INFINITE },
})
render(<PlanComp loc="test" />)
expect(screen.getByText(/plansCommon\.unlimited/i)).toBeInTheDocument()
})
it('should display reset days for trigger events when applicable', () => {
setupProviderContext({
type: Plan.professional,
total: { triggerEvents: 20000 },
reset: { triggerEvents: 7 },
})
render(<PlanComp loc="test" />)
// Reset text should be visible
expect(screen.getByText(/usagePage\.resetsIn/i)).toBeInTheDocument()
})
})
// Verify billing URL button visibility and behavior
describe('Billing URL button', () => {
it('should show billing button when enableBilling and isCurrentWorkspaceManager', () => {
setupProviderContext({ type: Plan.sandbox })
setupAppContext({ isCurrentWorkspaceManager: true })
render(<Billing />)
expect(screen.getByText(/viewBillingTitle/i)).toBeInTheDocument()
expect(screen.getByText(/viewBillingAction/i)).toBeInTheDocument()
})
it('should hide billing button when user is not workspace manager', () => {
setupProviderContext({ type: Plan.sandbox })
setupAppContext({ isCurrentWorkspaceManager: false })
render(<Billing />)
expect(screen.queryByText(/viewBillingTitle/i)).not.toBeInTheDocument()
})
it('should hide billing button when billing is disabled', () => {
setupProviderContext({ type: Plan.sandbox }, { enableBilling: false })
render(<Billing />)
expect(screen.queryByText(/viewBillingTitle/i)).not.toBeInTheDocument()
})
})
})
// ═══════════════════════════════════════════════════════════════════════════
// 2. Plan Type Display Integration
// Tests that different plan types render correct visual elements
// ═══════════════════════════════════════════════════════════════════════════
describe('Plan Type Display Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
setupAppContext()
})
it('should render sandbox plan with upgrade button (premium badge)', () => {
setupProviderContext({ type: Plan.sandbox })
render(<PlanComp loc="test" />)
expect(screen.getByText(/plans\.sandbox\.name/i)).toBeInTheDocument()
expect(screen.getByText(/plans\.sandbox\.for/i)).toBeInTheDocument()
// Sandbox shows premium badge upgrade button (not plain)
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
})
it('should render professional plan with plain upgrade button', () => {
setupProviderContext({ type: Plan.professional })
render(<PlanComp loc="test" />)
expect(screen.getByText(/plans\.professional\.name/i)).toBeInTheDocument()
// Professional shows plain button because it's not team
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
})
it('should render team plan with plain-style upgrade button', () => {
setupProviderContext({ type: Plan.team })
render(<PlanComp loc="test" />)
expect(screen.getByText(/plans\.team\.name/i)).toBeInTheDocument()
// Team plan has isPlain=true, so shows "upgradeBtn.plain" text
expect(screen.getByText(/upgradeBtn\.plain/i)).toBeInTheDocument()
})
it('should not render upgrade button for enterprise plan', () => {
setupProviderContext({ type: Plan.enterprise })
render(<PlanComp loc="test" />)
expect(screen.queryByText(/upgradeBtn\.encourageShort/i)).not.toBeInTheDocument()
expect(screen.queryByText(/upgradeBtn\.plain/i)).not.toBeInTheDocument()
})
it('should show education verify button when enableEducationPlan is true and not yet verified', () => {
setupProviderContext({ type: Plan.sandbox }, {
enableEducationPlan: true,
isEducationAccount: false,
})
render(<PlanComp loc="test" />)
expect(screen.getByText(/toVerified/i)).toBeInTheDocument()
})
})
// ═══════════════════════════════════════════════════════════════════════════
// 3. Upgrade Flow Integration
// Tests the flow: UpgradeBtn click → setShowPricingModal
// and PlanUpgradeModal → close + trigger pricing
// ═══════════════════════════════════════════════════════════════════════════
describe('Upgrade Flow Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
setupAppContext()
setupProviderContext({ type: Plan.sandbox })
})
// UpgradeBtn triggers pricing modal
describe('UpgradeBtn triggers pricing modal', () => {
it('should call setShowPricingModal when clicking premium badge upgrade button', async () => {
const user = userEvent.setup()
render(<UpgradeBtn />)
const badgeText = screen.getByText(/upgradeBtn\.encourage/i)
await user.click(badgeText)
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
})
it('should call setShowPricingModal when clicking plain upgrade button', async () => {
const user = userEvent.setup()
render(<UpgradeBtn isPlain />)
const button = screen.getByRole('button')
await user.click(button)
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
})
it('should use custom onClick when provided instead of setShowPricingModal', async () => {
const customOnClick = vi.fn()
const user = userEvent.setup()
render(<UpgradeBtn onClick={customOnClick} />)
const badgeText = screen.getByText(/upgradeBtn\.encourage/i)
await user.click(badgeText)
expect(customOnClick).toHaveBeenCalledTimes(1)
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
})
it('should fire gtag event with loc parameter when clicked', async () => {
const mockGtag = vi.fn()
;(window as unknown as Record<string, unknown>).gtag = mockGtag
const user = userEvent.setup()
render(<UpgradeBtn loc="billing-page" />)
const badgeText = screen.getByText(/upgradeBtn\.encourage/i)
await user.click(badgeText)
expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', { loc: 'billing-page' })
delete (window as unknown as Record<string, unknown>).gtag
})
})
// PlanUpgradeModal integration: close modal and trigger pricing
describe('PlanUpgradeModal upgrade flow', () => {
it('should call onClose and setShowPricingModal when clicking upgrade button in modal', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
render(
<PlanUpgradeModal
show={true}
onClose={onClose}
title="Upgrade Required"
description="You need a better plan"
/>,
)
// The modal should show title and description
expect(screen.getByText('Upgrade Required')).toBeInTheDocument()
expect(screen.getByText('You need a better plan')).toBeInTheDocument()
// Click the upgrade button inside the modal
const upgradeText = screen.getByText(/triggerLimitModal\.upgrade/i)
await user.click(upgradeText)
// Should close the current modal first
expect(onClose).toHaveBeenCalledTimes(1)
// Then open pricing modal
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
})
it('should call onClose and custom onUpgrade when provided', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
const onUpgrade = vi.fn()
render(
<PlanUpgradeModal
show={true}
onClose={onClose}
onUpgrade={onUpgrade}
title="Test"
description="Test"
/>,
)
const upgradeText = screen.getByText(/triggerLimitModal\.upgrade/i)
await user.click(upgradeText)
expect(onClose).toHaveBeenCalledTimes(1)
expect(onUpgrade).toHaveBeenCalledTimes(1)
// Custom onUpgrade replaces default setShowPricingModal
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
})
it('should call onClose when clicking dismiss button', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
render(
<PlanUpgradeModal
show={true}
onClose={onClose}
title="Test"
description="Test"
/>,
)
const dismissBtn = screen.getByText(/triggerLimitModal\.dismiss/i)
await user.click(dismissBtn)
expect(onClose).toHaveBeenCalledTimes(1)
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
})
})
// Upgrade from PlanComp: clicking upgrade button in plan component triggers pricing
describe('PlanComp upgrade button triggers pricing', () => {
it('should open pricing modal when clicking upgrade in sandbox plan', async () => {
const user = userEvent.setup()
setupProviderContext({ type: Plan.sandbox })
render(<PlanComp loc="test-loc" />)
const upgradeText = screen.getByText(/upgradeBtn\.encourageShort/i)
await user.click(upgradeText)
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
})
})
})
// ═══════════════════════════════════════════════════════════════════════════
// 4. Capacity Full Components Integration
// Tests AppsFull, VectorSpaceFull, AnnotationFull, TriggerEventsLimitModal
// with real child components (UsageInfo, ProgressBar, UpgradeBtn)
// ═══════════════════════════════════════════════════════════════════════════
describe('Capacity Full Components Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
setupAppContext()
})
// AppsFull renders with correct messaging and components
describe('AppsFull integration', () => {
it('should display upgrade tip and upgrade button for sandbox plan at capacity', () => {
setupProviderContext({
type: Plan.sandbox,
usage: { buildApps: 5 },
total: { buildApps: 5 },
})
render(<AppsFull loc="test" />)
// Should show "full" tip
expect(screen.getByText(/apps\.fullTip1$/i)).toBeInTheDocument()
// Should show upgrade button
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
// Should show usage/total fraction "5/5"
expect(screen.getByText(/5\/5/)).toBeInTheDocument()
// Should have a progress bar rendered
expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument()
})
it('should display upgrade tip and upgrade button for professional plan', () => {
setupProviderContext({
type: Plan.professional,
usage: { buildApps: 48 },
total: { buildApps: 50 },
})
render(<AppsFull loc="test" />)
expect(screen.getByText(/apps\.fullTip1$/i)).toBeInTheDocument()
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
})
it('should display contact tip and contact button for team plan', () => {
setupProviderContext({
type: Plan.team,
usage: { buildApps: 200 },
total: { buildApps: 200 },
})
render(<AppsFull loc="test" />)
// Team plan shows different tip
expect(screen.getByText(/apps\.fullTip2$/i)).toBeInTheDocument()
// Team plan shows "Contact Us" instead of upgrade
expect(screen.getByText(/apps\.contactUs/i)).toBeInTheDocument()
expect(screen.queryByText(/upgradeBtn\.encourageShort/i)).not.toBeInTheDocument()
})
it('should render progress bar with correct color based on usage percentage', () => {
// 100% usage should show error color
setupProviderContext({
type: Plan.sandbox,
usage: { buildApps: 5 },
total: { buildApps: 5 },
})
render(<AppsFull loc="test" />)
const progressBar = screen.getByTestId('billing-progress-bar')
expect(progressBar).toHaveClass('bg-components-progress-error-progress')
})
})
// VectorSpaceFull renders with VectorSpaceInfo and UpgradeBtn
describe('VectorSpaceFull integration', () => {
it('should display full tip, upgrade button, and vector space usage info', () => {
setupProviderContext({
type: Plan.sandbox,
usage: { vectorSpace: 50 },
total: { vectorSpace: 50 },
})
render(<VectorSpaceFull />)
// Should show full tip
expect(screen.getByText(/vectorSpace\.fullTip/i)).toBeInTheDocument()
expect(screen.getByText(/vectorSpace\.fullSolution/i)).toBeInTheDocument()
// Should show upgrade button
expect(screen.getByText(/upgradeBtn\.encourage$/i)).toBeInTheDocument()
// Should show vector space usage info
expect(screen.getByText(/usagePage\.vectorSpace/i)).toBeInTheDocument()
})
})
// AnnotationFull renders with Usage component and UpgradeBtn
describe('AnnotationFull integration', () => {
it('should display annotation full tip, upgrade button, and usage info', () => {
setupProviderContext({
type: Plan.sandbox,
usage: { annotatedResponse: 10 },
total: { annotatedResponse: 10 },
})
render(<AnnotationFull />)
expect(screen.getByText(/annotatedResponse\.fullTipLine1/i)).toBeInTheDocument()
expect(screen.getByText(/annotatedResponse\.fullTipLine2/i)).toBeInTheDocument()
// UpgradeBtn rendered
expect(screen.getByText(/upgradeBtn\.encourage$/i)).toBeInTheDocument()
// Usage component should show annotation quota
expect(screen.getByText(/annotatedResponse\.quotaTitle/i)).toBeInTheDocument()
})
})
// AnnotationFullModal shows modal with usage and upgrade button
describe('AnnotationFullModal integration', () => {
it('should render modal with annotation info and upgrade button when show is true', () => {
setupProviderContext({
type: Plan.sandbox,
usage: { annotatedResponse: 10 },
total: { annotatedResponse: 10 },
})
render(<AnnotationFullModal show={true} onHide={vi.fn()} />)
expect(screen.getByText(/annotatedResponse\.fullTipLine1/i)).toBeInTheDocument()
expect(screen.getByText(/annotatedResponse\.quotaTitle/i)).toBeInTheDocument()
expect(screen.getByText(/upgradeBtn\.encourage$/i)).toBeInTheDocument()
})
it('should not render content when show is false', () => {
setupProviderContext({
type: Plan.sandbox,
usage: { annotatedResponse: 10 },
total: { annotatedResponse: 10 },
})
render(<AnnotationFullModal show={false} onHide={vi.fn()} />)
expect(screen.queryByText(/annotatedResponse\.fullTipLine1/i)).not.toBeInTheDocument()
})
})
// TriggerEventsLimitModal renders PlanUpgradeModal with embedded UsageInfo
describe('TriggerEventsLimitModal integration', () => {
it('should display trigger limit title, usage info, and upgrade button', () => {
setupProviderContext({ type: Plan.professional })
render(
<TriggerEventsLimitModal
show={true}
onClose={vi.fn()}
onUpgrade={vi.fn()}
usage={18000}
total={20000}
resetInDays={5}
/>,
)
// Modal title and description
expect(screen.getByText(/triggerLimitModal\.title/i)).toBeInTheDocument()
expect(screen.getByText(/triggerLimitModal\.description/i)).toBeInTheDocument()
// Embedded UsageInfo with trigger events data
expect(screen.getByText(/triggerLimitModal\.usageTitle/i)).toBeInTheDocument()
expect(screen.getByText('18000')).toBeInTheDocument()
expect(screen.getByText('20000')).toBeInTheDocument()
// Reset info
expect(screen.getByText(/usagePage\.resetsIn/i)).toBeInTheDocument()
// Upgrade and dismiss buttons
expect(screen.getByText(/triggerLimitModal\.upgrade/i)).toBeInTheDocument()
expect(screen.getByText(/triggerLimitModal\.dismiss/i)).toBeInTheDocument()
})
it('should call onClose and onUpgrade when clicking upgrade', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
const onUpgrade = vi.fn()
setupProviderContext({ type: Plan.professional })
render(
<TriggerEventsLimitModal
show={true}
onClose={onClose}
onUpgrade={onUpgrade}
usage={20000}
total={20000}
/>,
)
const upgradeBtn = screen.getByText(/triggerLimitModal\.upgrade/i)
await user.click(upgradeBtn)
expect(onClose).toHaveBeenCalledTimes(1)
expect(onUpgrade).toHaveBeenCalledTimes(1)
})
})
})
// ═══════════════════════════════════════════════════════════════════════════
// 5. Header Billing Button Integration
// Tests HeaderBillingBtn behavior for different plan states
// ═══════════════════════════════════════════════════════════════════════════
describe('Header Billing Button Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
setupAppContext()
})
it('should render UpgradeBtn (premium badge) for sandbox plan', () => {
setupProviderContext({ type: Plan.sandbox })
render(<HeaderBillingBtn />)
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
})
it('should render "pro" badge for professional plan', () => {
setupProviderContext({ type: Plan.professional })
render(<HeaderBillingBtn />)
expect(screen.getByText('pro')).toBeInTheDocument()
expect(screen.queryByText(/upgradeBtn/i)).not.toBeInTheDocument()
})
it('should render "team" badge for team plan', () => {
setupProviderContext({ type: Plan.team })
render(<HeaderBillingBtn />)
expect(screen.getByText('team')).toBeInTheDocument()
})
it('should return null when billing is disabled', () => {
setupProviderContext({ type: Plan.sandbox }, { enableBilling: false })
const { container } = render(<HeaderBillingBtn />)
expect(container.innerHTML).toBe('')
})
it('should return null when plan is not fetched yet', () => {
setupProviderContext({ type: Plan.sandbox }, { isFetchedPlan: false })
const { container } = render(<HeaderBillingBtn />)
expect(container.innerHTML).toBe('')
})
it('should call onClick when clicking pro/team badge in non-display-only mode', async () => {
const user = userEvent.setup()
const onClick = vi.fn()
setupProviderContext({ type: Plan.professional })
render(<HeaderBillingBtn onClick={onClick} />)
await user.click(screen.getByText('pro'))
expect(onClick).toHaveBeenCalledTimes(1)
})
it('should not call onClick when isDisplayOnly is true', async () => {
const user = userEvent.setup()
const onClick = vi.fn()
setupProviderContext({ type: Plan.professional })
render(<HeaderBillingBtn onClick={onClick} isDisplayOnly />)
await user.click(screen.getByText('pro'))
expect(onClick).not.toHaveBeenCalled()
})
})
// ═══════════════════════════════════════════════════════════════════════════
// 6. PriorityLabel Integration
// Tests priority badge display for different plan types
// ═══════════════════════════════════════════════════════════════════════════
describe('PriorityLabel Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
setupAppContext()
})
it('should display "standard" priority for sandbox plan', () => {
setupProviderContext({ type: Plan.sandbox })
render(<PriorityLabel />)
expect(screen.getByText(/plansCommon\.priority\.standard/i)).toBeInTheDocument()
})
it('should display "priority" for professional plan with icon', () => {
setupProviderContext({ type: Plan.professional })
const { container } = render(<PriorityLabel />)
expect(screen.getByText(/plansCommon\.priority\.priority/i)).toBeInTheDocument()
// Professional plan should show the priority icon
expect(container.querySelector('svg')).toBeInTheDocument()
})
it('should display "top-priority" for team plan with icon', () => {
setupProviderContext({ type: Plan.team })
const { container } = render(<PriorityLabel />)
expect(screen.getByText(/plansCommon\.priority\.top-priority/i)).toBeInTheDocument()
expect(container.querySelector('svg')).toBeInTheDocument()
})
it('should display "top-priority" for enterprise plan', () => {
setupProviderContext({ type: Plan.enterprise })
render(<PriorityLabel />)
expect(screen.getByText(/plansCommon\.priority\.top-priority/i)).toBeInTheDocument()
})
})
// ═══════════════════════════════════════════════════════════════════════════
// 7. Usage Display Edge Cases
// Tests storage mode, threshold logic, and progress bar color integration
// ═══════════════════════════════════════════════════════════════════════════
describe('Usage Display Edge Cases', () => {
beforeEach(() => {
vi.clearAllMocks()
setupAppContext()
})
// Vector space storage mode behavior
describe('VectorSpace storage mode in PlanComp', () => {
it('should show "< 50" for sandbox plan with low vector space usage', () => {
setupProviderContext({
type: Plan.sandbox,
usage: { vectorSpace: 10 },
total: { vectorSpace: 50 },
})
render(<PlanComp loc="test" />)
// Storage mode: usage below threshold shows "< 50"
expect(screen.getByText(/</)).toBeInTheDocument()
})
it('should show indeterminate progress bar for usage below threshold', () => {
setupProviderContext({
type: Plan.sandbox,
usage: { vectorSpace: 10 },
total: { vectorSpace: 50 },
})
render(<PlanComp loc="test" />)
// Should have an indeterminate progress bar
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
})
it('should show actual usage for pro plan above threshold', () => {
setupProviderContext({
type: Plan.professional,
usage: { vectorSpace: 1024 },
total: { vectorSpace: 5120 },
})
render(<PlanComp loc="test" />)
// Pro plan above threshold shows actual value
expect(screen.getByText('1024')).toBeInTheDocument()
})
})
// Progress bar color logic through real components
describe('Progress bar color reflects usage severity', () => {
it('should show normal color for low usage percentage', () => {
setupProviderContext({
type: Plan.sandbox,
usage: { buildApps: 1 },
total: { buildApps: 5 },
})
render(<PlanComp loc="test" />)
// 20% usage - normal color
const progressBars = screen.getAllByTestId('billing-progress-bar')
// At least one should have the normal progress color
const hasNormalColor = progressBars.some(bar =>
bar.classList.contains('bg-components-progress-bar-progress-solid'),
)
expect(hasNormalColor).toBe(true)
})
})
// Reset days calculation in PlanComp
describe('Reset days integration', () => {
it('should not show reset for sandbox trigger events (no reset_date)', () => {
setupProviderContext({
type: Plan.sandbox,
total: { triggerEvents: 3000 },
reset: { triggerEvents: null },
})
render(<PlanComp loc="test" />)
// Find the trigger events section - should not have reset text
const triggerSection = screen.getByText(/usagePage\.triggerEvents/i)
const parent = triggerSection.closest('[class*="flex flex-col"]')
// No reset text should appear (sandbox doesn't show reset for triggerEvents)
expect(parent?.textContent).not.toContain('usagePage.resetsIn')
})
it('should show reset for professional trigger events with reset date', () => {
setupProviderContext({
type: Plan.professional,
total: { triggerEvents: 20000 },
reset: { triggerEvents: 14 },
})
render(<PlanComp loc="test" />)
// Professional plan with finite triggerEvents should show reset
const resetTexts = screen.getAllByText(/usagePage\.resetsIn/i)
expect(resetTexts.length).toBeGreaterThan(0)
})
})
})
// ═══════════════════════════════════════════════════════════════════════════
// 8. Cross-Component Upgrade Flow (End-to-End)
// Tests the complete chain: capacity alert → upgrade button → pricing
// ═══════════════════════════════════════════════════════════════════════════
describe('Cross-Component Upgrade Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
setupAppContext()
})
it('should trigger pricing from AppsFull upgrade button', async () => {
const user = userEvent.setup()
setupProviderContext({
type: Plan.sandbox,
usage: { buildApps: 5 },
total: { buildApps: 5 },
})
render(<AppsFull loc="app-create" />)
const upgradeText = screen.getByText(/upgradeBtn\.encourageShort/i)
await user.click(upgradeText)
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
})
it('should trigger pricing from VectorSpaceFull upgrade button', async () => {
const user = userEvent.setup()
setupProviderContext({
type: Plan.sandbox,
usage: { vectorSpace: 50 },
total: { vectorSpace: 50 },
})
render(<VectorSpaceFull />)
const upgradeText = screen.getByText(/upgradeBtn\.encourage$/i)
await user.click(upgradeText)
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
})
it('should trigger pricing from AnnotationFull upgrade button', async () => {
const user = userEvent.setup()
setupProviderContext({
type: Plan.sandbox,
usage: { annotatedResponse: 10 },
total: { annotatedResponse: 10 },
})
render(<AnnotationFull />)
const upgradeText = screen.getByText(/upgradeBtn\.encourage$/i)
await user.click(upgradeText)
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
})
it('should trigger pricing from TriggerEventsLimitModal through PlanUpgradeModal', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
setupProviderContext({ type: Plan.professional })
render(
<TriggerEventsLimitModal
show={true}
onClose={onClose}
onUpgrade={vi.fn()}
usage={20000}
total={20000}
/>,
)
// TriggerEventsLimitModal passes onUpgrade to PlanUpgradeModal
// PlanUpgradeModal's upgrade button calls onClose then onUpgrade
const upgradeBtn = screen.getByText(/triggerLimitModal\.upgrade/i)
await user.click(upgradeBtn)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should trigger pricing from AnnotationFullModal upgrade button', async () => {
const user = userEvent.setup()
setupProviderContext({
type: Plan.sandbox,
usage: { annotatedResponse: 10 },
total: { annotatedResponse: 10 },
})
render(<AnnotationFullModal show={true} onHide={vi.fn()} />)
const upgradeText = screen.getByText(/upgradeBtn\.encourage$/i)
await user.click(upgradeText)
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
})
})

View File

@@ -1,300 +0,0 @@
/**
* Integration Test: Create Dataset Flow
*
* Tests cross-module data flow: step-one data → step-two hooks → creation params → API call
* Validates data contracts between steps.
*/
import type { CustomFile } from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { act, renderHook } from '@testing-library/react'
import { ChunkingMode, DataSourceType, ProcessMode } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
const mockCreateFirstDocument = vi.fn()
const mockCreateDocument = vi.fn()
vi.mock('@/service/knowledge/use-create-dataset', () => ({
useCreateFirstDocument: () => ({ mutateAsync: mockCreateFirstDocument, isPending: false }),
useCreateDocument: () => ({ mutateAsync: mockCreateDocument, isPending: false }),
getNotionInfo: (pages: { page_id: string }[], credentialId: string) => ({
workspace_id: 'ws-1',
pages: pages.map(p => p.page_id),
notion_credential_id: credentialId,
}),
getWebsiteInfo: (opts: { websitePages: { url: string }[], websiteCrawlProvider: string }) => ({
urls: opts.websitePages.map(p => p.url),
only_main_content: true,
provider: opts.websiteCrawlProvider,
}),
}))
vi.mock('@/service/knowledge/use-dataset', () => ({
useInvalidDatasetList: () => vi.fn(),
}))
vi.mock('@/app/components/base/toast', () => ({
default: { notify: vi.fn() },
}))
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: vi.fn(),
}))
// Import hooks after mocks
const { useSegmentationState, DEFAULT_SEGMENT_IDENTIFIER, DEFAULT_MAXIMUM_CHUNK_LENGTH, DEFAULT_OVERLAP }
= await import('@/app/components/datasets/create/step-two/hooks')
const { useDocumentCreation, IndexingType }
= await import('@/app/components/datasets/create/step-two/hooks')
const createMockFile = (overrides?: Partial<CustomFile>): CustomFile => ({
id: 'file-1',
name: 'test.txt',
type: 'text/plain',
size: 1024,
extension: '.txt',
mime_type: 'text/plain',
created_at: 0,
created_by: '',
...overrides,
} as CustomFile)
describe('Create Dataset Flow - Cross-Step Data Contract', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Step-One → Step-Two: Segmentation Defaults', () => {
it('should initialise with correct default segmentation values', () => {
const { result } = renderHook(() => useSegmentationState())
expect(result.current.segmentIdentifier).toBe(DEFAULT_SEGMENT_IDENTIFIER)
expect(result.current.maxChunkLength).toBe(DEFAULT_MAXIMUM_CHUNK_LENGTH)
expect(result.current.overlap).toBe(DEFAULT_OVERLAP)
expect(result.current.segmentationType).toBe(ProcessMode.general) // 'custom'
})
it('should produce valid process rule for general chunking', () => {
const { result } = renderHook(() => useSegmentationState())
const processRule = result.current.getProcessRule(ChunkingMode.text)
// mode should be segmentationType = ProcessMode.general = 'custom'
expect(processRule.mode).toBe('custom')
expect(processRule.rules.segmentation).toEqual({
separator: '\n\n', // unescaped from \\n\\n
max_tokens: DEFAULT_MAXIMUM_CHUNK_LENGTH,
chunk_overlap: DEFAULT_OVERLAP,
})
// rules is empty initially since no default config loaded
expect(processRule.rules.pre_processing_rules).toEqual([])
})
it('should produce valid process rule for parent-child chunking', () => {
const { result } = renderHook(() => useSegmentationState())
const processRule = result.current.getProcessRule(ChunkingMode.parentChild)
expect(processRule.mode).toBe('hierarchical')
expect(processRule.rules.parent_mode).toBe('paragraph')
expect(processRule.rules.segmentation).toEqual({
separator: '\n\n',
max_tokens: 1024,
})
expect(processRule.rules.subchunk_segmentation).toEqual({
separator: '\n',
max_tokens: 512,
})
})
})
describe('Step-Two → Creation API: Params Building', () => {
it('should build valid creation params for file upload workflow', () => {
const files = [createMockFile()]
const { result: segResult } = renderHook(() => useSegmentationState())
const { result: creationResult } = renderHook(() =>
useDocumentCreation({
dataSourceType: DataSourceType.FILE,
files,
notionPages: [],
notionCredentialId: '',
websitePages: [],
}),
)
const processRule = segResult.current.getProcessRule(ChunkingMode.text)
const retrievalConfig: RetrievalConfig = {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0,
}
const params = creationResult.current.buildCreationParams(
ChunkingMode.text,
'English',
processRule,
retrievalConfig,
{ provider: 'openai', model: 'text-embedding-ada-002' },
IndexingType.QUALIFIED,
)
expect(params).not.toBeNull()
// File IDs come from file.id (not file.file.id)
expect(params!.data_source.type).toBe(DataSourceType.FILE)
expect(params!.data_source.info_list.file_info_list?.file_ids).toContain('file-1')
expect(params!.indexing_technique).toBe(IndexingType.QUALIFIED)
expect(params!.doc_form).toBe(ChunkingMode.text)
expect(params!.doc_language).toBe('English')
expect(params!.embedding_model).toBe('text-embedding-ada-002')
expect(params!.embedding_model_provider).toBe('openai')
expect(params!.process_rule.mode).toBe('custom')
})
it('should validate params: overlap must not exceed maxChunkLength', () => {
const { result } = renderHook(() =>
useDocumentCreation({
dataSourceType: DataSourceType.FILE,
files: [createMockFile()],
notionPages: [],
notionCredentialId: '',
websitePages: [],
}),
)
// validateParams returns false (invalid) when overlap > maxChunkLength for general mode
const isValid = result.current.validateParams({
segmentationType: 'general',
maxChunkLength: 100,
limitMaxChunkLength: 4000,
overlap: 200, // overlap > maxChunkLength
indexType: IndexingType.QUALIFIED,
embeddingModel: { provider: 'openai', model: 'text-embedding-ada-002' },
rerankModelList: [],
retrievalConfig: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0,
},
})
expect(isValid).toBe(false)
})
it('should validate params: maxChunkLength must not exceed limit', () => {
const { result } = renderHook(() =>
useDocumentCreation({
dataSourceType: DataSourceType.FILE,
files: [createMockFile()],
notionPages: [],
notionCredentialId: '',
websitePages: [],
}),
)
const isValid = result.current.validateParams({
segmentationType: 'general',
maxChunkLength: 5000,
limitMaxChunkLength: 4000, // limit < maxChunkLength
overlap: 50,
indexType: IndexingType.QUALIFIED,
embeddingModel: { provider: 'openai', model: 'text-embedding-ada-002' },
rerankModelList: [],
retrievalConfig: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0,
},
})
expect(isValid).toBe(false)
})
})
describe('Full Flow: Segmentation State → Process Rule → Creation Params Consistency', () => {
it('should keep segmentation values consistent across getProcessRule and buildCreationParams', () => {
const files = [createMockFile()]
const { result: segResult } = renderHook(() => useSegmentationState())
const { result: creationResult } = renderHook(() =>
useDocumentCreation({
dataSourceType: DataSourceType.FILE,
files,
notionPages: [],
notionCredentialId: '',
websitePages: [],
}),
)
// Change segmentation settings
act(() => {
segResult.current.setMaxChunkLength(2048)
segResult.current.setOverlap(100)
})
const processRule = segResult.current.getProcessRule(ChunkingMode.text)
expect(processRule.rules.segmentation.max_tokens).toBe(2048)
expect(processRule.rules.segmentation.chunk_overlap).toBe(100)
const params = creationResult.current.buildCreationParams(
ChunkingMode.text,
'Chinese',
processRule,
{
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0,
},
{ provider: 'openai', model: 'text-embedding-ada-002' },
IndexingType.QUALIFIED,
)
expect(params).not.toBeNull()
expect(params!.process_rule.rules.segmentation.max_tokens).toBe(2048)
expect(params!.process_rule.rules.segmentation.chunk_overlap).toBe(100)
expect(params!.doc_language).toBe('Chinese')
})
it('should support parent-child mode through the full pipeline', () => {
const files = [createMockFile()]
const { result: segResult } = renderHook(() => useSegmentationState())
const { result: creationResult } = renderHook(() =>
useDocumentCreation({
dataSourceType: DataSourceType.FILE,
files,
notionPages: [],
notionCredentialId: '',
websitePages: [],
}),
)
const processRule = segResult.current.getProcessRule(ChunkingMode.parentChild)
const params = creationResult.current.buildCreationParams(
ChunkingMode.parentChild,
'English',
processRule,
{
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0,
},
{ provider: 'openai', model: 'text-embedding-ada-002' },
IndexingType.QUALIFIED,
)
expect(params).not.toBeNull()
expect(params!.doc_form).toBe(ChunkingMode.parentChild)
expect(params!.process_rule.mode).toBe('hierarchical')
expect(params!.process_rule.rules.parent_mode).toBe('paragraph')
expect(params!.process_rule.rules.subchunk_segmentation).toBeDefined()
})
})
})

View File

@@ -1,451 +0,0 @@
/**
* Integration Test: Dataset Settings Flow
*
* Tests cross-module data contracts in the dataset settings form:
* useFormState hook ↔ index method config ↔ retrieval config ↔ permission state.
*
* The unit-level use-form-state.spec.ts validates the hook in isolation.
* This integration test verifies that changing one configuration dimension
* correctly cascades to dependent parts (index method → retrieval config,
* permission → member list visibility, embedding model → embedding available state).
*/
import type { DataSet } from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { act, renderHook, waitFor } from '@testing-library/react'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType, WeightedScoreEnum } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
// --- Mocks ---
const mockMutateDatasets = vi.fn()
const mockInvalidDatasetList = vi.fn()
const mockUpdateDatasetSetting = vi.fn().mockResolvedValue({})
vi.mock('@/context/app-context', () => ({
useSelector: () => false,
}))
vi.mock('@/service/datasets', () => ({
updateDatasetSetting: (...args: unknown[]) => mockUpdateDatasetSetting(...args),
}))
vi.mock('@/service/knowledge/use-dataset', () => ({
useInvalidDatasetList: () => mockInvalidDatasetList,
}))
vi.mock('@/service/use-common', () => ({
useMembers: () => ({
data: {
accounts: [
{ id: 'user-1', name: 'Alice', email: 'alice@example.com', role: 'owner', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' },
{ id: 'user-2', name: 'Bob', email: 'bob@example.com', role: 'admin', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' },
{ id: 'user-3', name: 'Charlie', email: 'charlie@example.com', role: 'normal', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' },
],
},
}),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelList: () => ({ data: [] }),
}))
vi.mock('@/app/components/datasets/common/check-rerank-model', () => ({
isReRankModelSelected: () => true,
}))
vi.mock('@/app/components/base/toast', () => ({
default: { notify: vi.fn() },
}))
// --- Dataset factory ---
const createMockDataset = (overrides?: Partial<DataSet>): DataSet => ({
id: 'ds-settings-1',
name: 'Settings Test Dataset',
description: 'Integration test dataset',
permission: DatasetPermission.onlyMe,
icon_info: {
icon_type: 'emoji',
icon: '📙',
icon_background: '#FFF4ED',
icon_url: '',
},
indexing_technique: 'high_quality',
indexing_status: 'completed',
data_source_type: DataSourceType.FILE,
doc_form: ChunkingMode.text,
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
embedding_available: true,
app_count: 2,
document_count: 10,
total_document_count: 10,
word_count: 5000,
provider: 'vendor',
tags: [],
partial_member_list: [],
external_knowledge_info: {
external_knowledge_id: '',
external_knowledge_api_id: '',
external_knowledge_api_name: '',
external_knowledge_api_endpoint: '',
},
external_retrieval_model: {
top_k: 2,
score_threshold: 0.5,
score_threshold_enabled: false,
},
retrieval_model_dict: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0,
} as RetrievalConfig,
retrieval_model: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0,
} as RetrievalConfig,
built_in_field_enabled: false,
keyword_number: 10,
created_by: 'user-1',
updated_by: 'user-1',
updated_at: Date.now(),
runtime_mode: 'general',
enable_api: true,
is_multimodal: false,
...overrides,
} as DataSet)
let mockDataset: DataSet = createMockDataset()
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: (
selector: (state: { dataset: DataSet | null, mutateDatasetRes: () => void }) => unknown,
) => selector({ dataset: mockDataset, mutateDatasetRes: mockMutateDatasets }),
}))
// Import after mocks are registered
const { useFormState } = await import(
'@/app/components/datasets/settings/form/hooks/use-form-state',
)
describe('Dataset Settings Flow - Cross-Module Configuration Cascade', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUpdateDatasetSetting.mockResolvedValue({})
mockDataset = createMockDataset()
})
describe('Form State Initialization from Dataset → Index Method → Retrieval Config Chain', () => {
it('should initialise all form dimensions from a QUALIFIED dataset', () => {
const { result } = renderHook(() => useFormState())
expect(result.current.name).toBe('Settings Test Dataset')
expect(result.current.description).toBe('Integration test dataset')
expect(result.current.indexMethod).toBe('high_quality')
expect(result.current.embeddingModel).toEqual({
provider: 'openai',
model: 'text-embedding-ada-002',
})
expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.semantic)
})
it('should initialise from an ECONOMICAL dataset with keyword retrieval', () => {
mockDataset = createMockDataset({
indexing_technique: IndexingType.ECONOMICAL,
embedding_model: '',
embedding_model_provider: '',
retrieval_model_dict: {
search_method: RETRIEVE_METHOD.keywordSearch,
reranking_enable: false,
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
top_k: 5,
score_threshold_enabled: false,
score_threshold: 0,
} as RetrievalConfig,
})
const { result } = renderHook(() => useFormState())
expect(result.current.indexMethod).toBe(IndexingType.ECONOMICAL)
expect(result.current.embeddingModel).toEqual({ provider: '', model: '' })
expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.keywordSearch)
})
})
describe('Index Method Change → Retrieval Config Sync', () => {
it('should allow switching index method from QUALIFIED to ECONOMICAL', () => {
const { result } = renderHook(() => useFormState())
expect(result.current.indexMethod).toBe('high_quality')
act(() => {
result.current.setIndexMethod(IndexingType.ECONOMICAL)
})
expect(result.current.indexMethod).toBe(IndexingType.ECONOMICAL)
})
it('should allow updating retrieval config after index method switch', () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setIndexMethod(IndexingType.ECONOMICAL)
})
act(() => {
result.current.setRetrievalConfig({
...result.current.retrievalConfig,
search_method: RETRIEVE_METHOD.keywordSearch,
reranking_enable: false,
})
})
expect(result.current.indexMethod).toBe(IndexingType.ECONOMICAL)
expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.keywordSearch)
expect(result.current.retrievalConfig.reranking_enable).toBe(false)
})
it('should preserve retrieval config when switching back to QUALIFIED', () => {
const { result } = renderHook(() => useFormState())
const originalConfig = { ...result.current.retrievalConfig }
act(() => {
result.current.setIndexMethod(IndexingType.ECONOMICAL)
})
act(() => {
result.current.setIndexMethod(IndexingType.QUALIFIED)
})
expect(result.current.indexMethod).toBe('high_quality')
expect(result.current.retrievalConfig.search_method).toBe(originalConfig.search_method)
})
})
describe('Permission Change → Member List Visibility Logic', () => {
it('should start with onlyMe permission and empty member selection', () => {
const { result } = renderHook(() => useFormState())
expect(result.current.permission).toBe(DatasetPermission.onlyMe)
expect(result.current.selectedMemberIDs).toEqual([])
})
it('should enable member selection when switching to partialMembers', () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setPermission(DatasetPermission.partialMembers)
})
expect(result.current.permission).toBe(DatasetPermission.partialMembers)
expect(result.current.memberList).toHaveLength(3)
expect(result.current.memberList.map(m => m.id)).toEqual(['user-1', 'user-2', 'user-3'])
})
it('should persist member selection through permission toggle', () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setPermission(DatasetPermission.partialMembers)
result.current.setSelectedMemberIDs(['user-1', 'user-3'])
})
act(() => {
result.current.setPermission(DatasetPermission.allTeamMembers)
})
act(() => {
result.current.setPermission(DatasetPermission.partialMembers)
})
expect(result.current.selectedMemberIDs).toEqual(['user-1', 'user-3'])
})
it('should include partial_member_list in save payload only for partialMembers', async () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setPermission(DatasetPermission.partialMembers)
result.current.setSelectedMemberIDs(['user-2'])
})
await act(async () => {
await result.current.handleSave()
})
expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({
datasetId: 'ds-settings-1',
body: expect.objectContaining({
permission: DatasetPermission.partialMembers,
partial_member_list: [
expect.objectContaining({ user_id: 'user-2', role: 'admin' }),
],
}),
})
})
it('should not include partial_member_list for allTeamMembers permission', async () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setPermission(DatasetPermission.allTeamMembers)
})
await act(async () => {
await result.current.handleSave()
})
const savedBody = mockUpdateDatasetSetting.mock.calls[0][0].body as Record<string, unknown>
expect(savedBody).not.toHaveProperty('partial_member_list')
})
})
describe('Form Submission Validation → All Fields Together', () => {
it('should reject empty name on save', async () => {
const Toast = await import('@/app/components/base/toast')
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setName('')
})
await act(async () => {
await result.current.handleSave()
})
expect(Toast.default.notify).toHaveBeenCalledWith({
type: 'error',
message: expect.any(String),
})
expect(mockUpdateDatasetSetting).not.toHaveBeenCalled()
})
it('should include all configuration dimensions in a successful save', async () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setName('Updated Name')
result.current.setDescription('Updated Description')
result.current.setIndexMethod(IndexingType.ECONOMICAL)
result.current.setKeywordNumber(15)
})
await act(async () => {
await result.current.handleSave()
})
expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({
datasetId: 'ds-settings-1',
body: expect.objectContaining({
name: 'Updated Name',
description: 'Updated Description',
indexing_technique: 'economy',
keyword_number: 15,
embedding_model: 'text-embedding-ada-002',
embedding_model_provider: 'openai',
}),
})
})
it('should call mutateDatasets and invalidDatasetList after successful save', async () => {
const { result } = renderHook(() => useFormState())
await act(async () => {
await result.current.handleSave()
})
await waitFor(() => {
expect(mockMutateDatasets).toHaveBeenCalled()
expect(mockInvalidDatasetList).toHaveBeenCalled()
})
})
})
describe('Embedding Model Change → Retrieval Config Cascade', () => {
it('should update embedding model independently of retrieval config', () => {
const { result } = renderHook(() => useFormState())
const originalRetrievalConfig = { ...result.current.retrievalConfig }
act(() => {
result.current.setEmbeddingModel({ provider: 'cohere', model: 'embed-english-v3.0' })
})
expect(result.current.embeddingModel).toEqual({
provider: 'cohere',
model: 'embed-english-v3.0',
})
expect(result.current.retrievalConfig.search_method).toBe(originalRetrievalConfig.search_method)
})
it('should propagate embedding model into weighted retrieval config on save', async () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setEmbeddingModel({ provider: 'cohere', model: 'embed-v3' })
result.current.setRetrievalConfig({
...result.current.retrievalConfig,
search_method: RETRIEVE_METHOD.hybrid,
weights: {
weight_type: WeightedScoreEnum.Customized,
vector_setting: {
vector_weight: 0.6,
embedding_provider_name: '',
embedding_model_name: '',
},
keyword_setting: { keyword_weight: 0.4 },
},
})
})
await act(async () => {
await result.current.handleSave()
})
expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({
datasetId: 'ds-settings-1',
body: expect.objectContaining({
embedding_model: 'embed-v3',
embedding_model_provider: 'cohere',
retrieval_model: expect.objectContaining({
weights: expect.objectContaining({
vector_setting: expect.objectContaining({
embedding_provider_name: 'cohere',
embedding_model_name: 'embed-v3',
}),
}),
}),
}),
})
})
it('should handle switching from semantic to hybrid search with embedding model', () => {
const { result } = renderHook(() => useFormState())
act(() => {
result.current.setRetrievalConfig({
...result.current.retrievalConfig,
search_method: RETRIEVE_METHOD.hybrid,
reranking_enable: true,
reranking_model: {
reranking_provider_name: 'cohere',
reranking_model_name: 'rerank-english-v3.0',
},
})
})
expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.hybrid)
expect(result.current.retrievalConfig.reranking_enable).toBe(true)
expect(result.current.embeddingModel.model).toBe('text-embedding-ada-002')
})
})
})

View File

@@ -1,334 +0,0 @@
/**
* Integration Test: Document Management Flow
*
* Tests cross-module interactions: query state (URL-based) → document list sorting →
* document selection → status filter utilities.
* Validates the data contract between documents page hooks and list component hooks.
*/
import type { SimpleDocumentDetail } from '@/models/datasets'
import { act, renderHook } from '@testing-library/react'
import { DataSourceType } from '@/models/datasets'
const mockPush = vi.fn()
vi.mock('next/navigation', () => ({
useSearchParams: () => new URLSearchParams(''),
useRouter: () => ({ push: mockPush }),
usePathname: () => '/datasets/ds-1/documents',
}))
const { sanitizeStatusValue, normalizeStatusForQuery } = await import(
'@/app/components/datasets/documents/status-filter',
)
const { useDocumentSort } = await import(
'@/app/components/datasets/documents/components/document-list/hooks/use-document-sort',
)
const { useDocumentSelection } = await import(
'@/app/components/datasets/documents/components/document-list/hooks/use-document-selection',
)
const { default: useDocumentListQueryState } = await import(
'@/app/components/datasets/documents/hooks/use-document-list-query-state',
)
type LocalDoc = SimpleDocumentDetail & { percent?: number }
const createDoc = (overrides?: Partial<LocalDoc>): LocalDoc => ({
id: `doc-${Math.random().toString(36).slice(2, 8)}`,
name: 'test-doc.txt',
word_count: 500,
hit_count: 10,
created_at: Date.now() / 1000,
data_source_type: DataSourceType.FILE,
display_status: 'available',
indexing_status: 'completed',
enabled: true,
archived: false,
doc_type: null,
doc_metadata: null,
position: 1,
dataset_process_rule_id: 'rule-1',
...overrides,
} as LocalDoc)
describe('Document Management Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Status Filter Utilities', () => {
it('should sanitize valid status values', () => {
expect(sanitizeStatusValue('all')).toBe('all')
expect(sanitizeStatusValue('available')).toBe('available')
expect(sanitizeStatusValue('error')).toBe('error')
})
it('should fallback to "all" for invalid values', () => {
expect(sanitizeStatusValue(null)).toBe('all')
expect(sanitizeStatusValue(undefined)).toBe('all')
expect(sanitizeStatusValue('')).toBe('all')
expect(sanitizeStatusValue('nonexistent')).toBe('all')
})
it('should handle URL aliases', () => {
// 'active' is aliased to 'available'
expect(sanitizeStatusValue('active')).toBe('available')
})
it('should normalize status for API query', () => {
expect(normalizeStatusForQuery('all')).toBe('all')
// 'enabled' normalized to 'available' for query
expect(normalizeStatusForQuery('enabled')).toBe('available')
})
})
describe('URL-based Query State', () => {
it('should parse default query from empty URL params', () => {
const { result } = renderHook(() => useDocumentListQueryState())
expect(result.current.query).toEqual({
page: 1,
limit: 10,
keyword: '',
status: 'all',
sort: '-created_at',
})
})
it('should update query and push to router', () => {
const { result } = renderHook(() => useDocumentListQueryState())
act(() => {
result.current.updateQuery({ keyword: 'test', page: 2 })
})
expect(mockPush).toHaveBeenCalled()
// The push call should contain the updated query params
const pushUrl = mockPush.mock.calls[0][0] as string
expect(pushUrl).toContain('keyword=test')
expect(pushUrl).toContain('page=2')
})
it('should reset query to defaults', () => {
const { result } = renderHook(() => useDocumentListQueryState())
act(() => {
result.current.resetQuery()
})
expect(mockPush).toHaveBeenCalled()
// Default query omits default values from URL
const pushUrl = mockPush.mock.calls[0][0] as string
expect(pushUrl).toBe('/datasets/ds-1/documents')
})
})
describe('Document Sort Integration', () => {
it('should return documents unsorted when no sort field set', () => {
const docs = [
createDoc({ id: 'doc-1', name: 'Banana.txt', word_count: 300 }),
createDoc({ id: 'doc-2', name: 'Apple.txt', word_count: 100 }),
createDoc({ id: 'doc-3', name: 'Cherry.txt', word_count: 200 }),
]
const { result } = renderHook(() => useDocumentSort({
documents: docs,
statusFilterValue: '',
remoteSortValue: '-created_at',
}))
expect(result.current.sortField).toBeNull()
expect(result.current.sortedDocuments).toHaveLength(3)
})
it('should sort by name descending', () => {
const docs = [
createDoc({ id: 'doc-1', name: 'Banana.txt' }),
createDoc({ id: 'doc-2', name: 'Apple.txt' }),
createDoc({ id: 'doc-3', name: 'Cherry.txt' }),
]
const { result } = renderHook(() => useDocumentSort({
documents: docs,
statusFilterValue: '',
remoteSortValue: '-created_at',
}))
act(() => {
result.current.handleSort('name')
})
expect(result.current.sortField).toBe('name')
expect(result.current.sortOrder).toBe('desc')
const names = result.current.sortedDocuments.map(d => d.name)
expect(names).toEqual(['Cherry.txt', 'Banana.txt', 'Apple.txt'])
})
it('should toggle sort order on same field click', () => {
const docs = [createDoc({ id: 'doc-1', name: 'A.txt' }), createDoc({ id: 'doc-2', name: 'B.txt' })]
const { result } = renderHook(() => useDocumentSort({
documents: docs,
statusFilterValue: '',
remoteSortValue: '-created_at',
}))
act(() => result.current.handleSort('name'))
expect(result.current.sortOrder).toBe('desc')
act(() => result.current.handleSort('name'))
expect(result.current.sortOrder).toBe('asc')
})
it('should filter by status before sorting', () => {
const docs = [
createDoc({ id: 'doc-1', name: 'A.txt', display_status: 'available' }),
createDoc({ id: 'doc-2', name: 'B.txt', display_status: 'error' }),
createDoc({ id: 'doc-3', name: 'C.txt', display_status: 'available' }),
]
const { result } = renderHook(() => useDocumentSort({
documents: docs,
statusFilterValue: 'available',
remoteSortValue: '-created_at',
}))
// Only 'available' documents should remain
expect(result.current.sortedDocuments).toHaveLength(2)
expect(result.current.sortedDocuments.every(d => d.display_status === 'available')).toBe(true)
})
})
describe('Document Selection Integration', () => {
it('should manage selection state externally', () => {
const docs = [
createDoc({ id: 'doc-1' }),
createDoc({ id: 'doc-2' }),
createDoc({ id: 'doc-3' }),
]
const onSelectedIdChange = vi.fn()
const { result } = renderHook(() => useDocumentSelection({
documents: docs,
selectedIds: [],
onSelectedIdChange,
}))
expect(result.current.isAllSelected).toBe(false)
expect(result.current.isSomeSelected).toBe(false)
})
it('should select all documents', () => {
const docs = [
createDoc({ id: 'doc-1' }),
createDoc({ id: 'doc-2' }),
]
const onSelectedIdChange = vi.fn()
const { result } = renderHook(() => useDocumentSelection({
documents: docs,
selectedIds: [],
onSelectedIdChange,
}))
act(() => {
result.current.onSelectAll()
})
expect(onSelectedIdChange).toHaveBeenCalledWith(
expect.arrayContaining(['doc-1', 'doc-2']),
)
})
it('should detect all-selected state', () => {
const docs = [
createDoc({ id: 'doc-1' }),
createDoc({ id: 'doc-2' }),
]
const { result } = renderHook(() => useDocumentSelection({
documents: docs,
selectedIds: ['doc-1', 'doc-2'],
onSelectedIdChange: vi.fn(),
}))
expect(result.current.isAllSelected).toBe(true)
})
it('should detect partial selection', () => {
const docs = [
createDoc({ id: 'doc-1' }),
createDoc({ id: 'doc-2' }),
createDoc({ id: 'doc-3' }),
]
const { result } = renderHook(() => useDocumentSelection({
documents: docs,
selectedIds: ['doc-1'],
onSelectedIdChange: vi.fn(),
}))
expect(result.current.isSomeSelected).toBe(true)
expect(result.current.isAllSelected).toBe(false)
})
it('should identify downloadable selected documents (FILE type only)', () => {
const docs = [
createDoc({ id: 'doc-1', data_source_type: DataSourceType.FILE }),
createDoc({ id: 'doc-2', data_source_type: DataSourceType.NOTION }),
]
const { result } = renderHook(() => useDocumentSelection({
documents: docs,
selectedIds: ['doc-1', 'doc-2'],
onSelectedIdChange: vi.fn(),
}))
expect(result.current.downloadableSelectedIds).toEqual(['doc-1'])
})
it('should clear selection', () => {
const onSelectedIdChange = vi.fn()
const docs = [createDoc({ id: 'doc-1' })]
const { result } = renderHook(() => useDocumentSelection({
documents: docs,
selectedIds: ['doc-1'],
onSelectedIdChange,
}))
act(() => {
result.current.clearSelection()
})
expect(onSelectedIdChange).toHaveBeenCalledWith([])
})
})
describe('Cross-Module: Query State → Sort → Selection Pipeline', () => {
it('should maintain consistent default state across all hooks', () => {
const docs = [createDoc({ id: 'doc-1' })]
const { result: queryResult } = renderHook(() => useDocumentListQueryState())
const { result: sortResult } = renderHook(() => useDocumentSort({
documents: docs,
statusFilterValue: queryResult.current.query.status,
remoteSortValue: queryResult.current.query.sort,
}))
const { result: selResult } = renderHook(() => useDocumentSelection({
documents: sortResult.current.sortedDocuments,
selectedIds: [],
onSelectedIdChange: vi.fn(),
}))
// Query defaults
expect(queryResult.current.query.sort).toBe('-created_at')
expect(queryResult.current.query.status).toBe('all')
// Sort inherits 'all' status → no filtering applied
expect(sortResult.current.sortedDocuments).toHaveLength(1)
// Selection starts empty
expect(selResult.current.isAllSelected).toBe(false)
})
})
})

View File

@@ -1,214 +0,0 @@
/**
* Integration Test: External Knowledge Base Creation Flow
*
* Tests the data contract, validation logic, and API interaction
* for external knowledge base creation.
*/
import type { CreateKnowledgeBaseReq } from '@/app/components/datasets/external-knowledge-base/create/declarations'
// --- Factory ---
const createFormData = (overrides?: Partial<CreateKnowledgeBaseReq>): CreateKnowledgeBaseReq => ({
name: 'My External KB',
description: 'A test external knowledge base',
external_knowledge_api_id: 'api-1',
external_knowledge_id: 'ext-kb-123',
external_retrieval_model: {
top_k: 4,
score_threshold: 0.5,
score_threshold_enabled: false,
},
provider: 'external',
...overrides,
})
describe('External Knowledge Base Creation Flow', () => {
describe('Data Contract: CreateKnowledgeBaseReq', () => {
it('should define a complete form structure', () => {
const form = createFormData()
expect(form).toHaveProperty('name')
expect(form).toHaveProperty('external_knowledge_api_id')
expect(form).toHaveProperty('external_knowledge_id')
expect(form).toHaveProperty('external_retrieval_model')
expect(form).toHaveProperty('provider')
expect(form.provider).toBe('external')
})
it('should include retrieval model settings', () => {
const form = createFormData()
expect(form.external_retrieval_model).toEqual({
top_k: 4,
score_threshold: 0.5,
score_threshold_enabled: false,
})
})
it('should allow partial overrides', () => {
const form = createFormData({
name: 'Custom Name',
external_retrieval_model: {
top_k: 10,
score_threshold: 0.8,
score_threshold_enabled: true,
},
})
expect(form.name).toBe('Custom Name')
expect(form.external_retrieval_model.top_k).toBe(10)
expect(form.external_retrieval_model.score_threshold_enabled).toBe(true)
})
})
describe('Form Validation Logic', () => {
const isFormValid = (form: CreateKnowledgeBaseReq): boolean => {
return (
form.name.trim() !== ''
&& form.external_knowledge_api_id !== ''
&& form.external_knowledge_id !== ''
&& form.external_retrieval_model.top_k !== undefined
&& form.external_retrieval_model.score_threshold !== undefined
)
}
it('should validate a complete form', () => {
const form = createFormData()
expect(isFormValid(form)).toBe(true)
})
it('should reject empty name', () => {
const form = createFormData({ name: '' })
expect(isFormValid(form)).toBe(false)
})
it('should reject whitespace-only name', () => {
const form = createFormData({ name: ' ' })
expect(isFormValid(form)).toBe(false)
})
it('should reject empty external_knowledge_api_id', () => {
const form = createFormData({ external_knowledge_api_id: '' })
expect(isFormValid(form)).toBe(false)
})
it('should reject empty external_knowledge_id', () => {
const form = createFormData({ external_knowledge_id: '' })
expect(isFormValid(form)).toBe(false)
})
})
describe('Form State Transitions', () => {
it('should start with empty default state', () => {
const defaultForm: CreateKnowledgeBaseReq = {
name: '',
description: '',
external_knowledge_api_id: '',
external_knowledge_id: '',
external_retrieval_model: {
top_k: 4,
score_threshold: 0.5,
score_threshold_enabled: false,
},
provider: 'external',
}
// Verify default state matches component's initial useState
expect(defaultForm.name).toBe('')
expect(defaultForm.external_knowledge_api_id).toBe('')
expect(defaultForm.external_knowledge_id).toBe('')
expect(defaultForm.provider).toBe('external')
})
it('should support immutable form updates', () => {
const form = createFormData({ name: '' })
const updated = { ...form, name: 'Updated Name' }
expect(form.name).toBe('')
expect(updated.name).toBe('Updated Name')
// Other fields should remain unchanged
expect(updated.external_knowledge_api_id).toBe(form.external_knowledge_api_id)
})
it('should support retrieval model updates', () => {
const form = createFormData()
const updated = {
...form,
external_retrieval_model: {
...form.external_retrieval_model,
top_k: 10,
score_threshold_enabled: true,
},
}
expect(updated.external_retrieval_model.top_k).toBe(10)
expect(updated.external_retrieval_model.score_threshold_enabled).toBe(true)
// Unchanged field
expect(updated.external_retrieval_model.score_threshold).toBe(0.5)
})
})
describe('API Call Data Contract', () => {
it('should produce a valid API payload from form data', () => {
const form = createFormData()
// The API expects the full CreateKnowledgeBaseReq
expect(form.name).toBeTruthy()
expect(form.external_knowledge_api_id).toBeTruthy()
expect(form.external_knowledge_id).toBeTruthy()
expect(form.provider).toBe('external')
expect(typeof form.external_retrieval_model.top_k).toBe('number')
expect(typeof form.external_retrieval_model.score_threshold).toBe('number')
expect(typeof form.external_retrieval_model.score_threshold_enabled).toBe('boolean')
})
it('should support optional description', () => {
const formWithDesc = createFormData({ description: 'Some description' })
const formWithoutDesc = createFormData({ description: '' })
expect(formWithDesc.description).toBe('Some description')
expect(formWithoutDesc.description).toBe('')
})
it('should validate retrieval model bounds', () => {
const form = createFormData({
external_retrieval_model: {
top_k: 0,
score_threshold: 0,
score_threshold_enabled: false,
},
})
expect(form.external_retrieval_model.top_k).toBe(0)
expect(form.external_retrieval_model.score_threshold).toBe(0)
})
})
describe('External API List Integration', () => {
it('should validate API item structure', () => {
const apiItem = {
id: 'api-1',
name: 'Production API',
settings: {
endpoint: 'https://api.example.com',
api_key: 'key-123',
},
}
expect(apiItem).toHaveProperty('id')
expect(apiItem).toHaveProperty('name')
expect(apiItem).toHaveProperty('settings')
expect(apiItem.settings).toHaveProperty('endpoint')
expect(apiItem.settings).toHaveProperty('api_key')
})
it('should link API selection to form data', () => {
const selectedApi = { id: 'api-2', name: 'Staging API' }
const form = createFormData({
external_knowledge_api_id: selectedApi.id,
})
expect(form.external_knowledge_api_id).toBe('api-2')
})
})
})

View File

@@ -1,404 +0,0 @@
/**
* Integration Test: Hit Testing Flow
*
* Tests the query submission → API response → callback chain flow
* by rendering the actual QueryInput component and triggering user interactions.
* Validates that the production onSubmit logic correctly constructs payloads
* and invokes callbacks on success/failure.
*/
import type {
HitTestingResponse,
Query,
} from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import QueryInput from '@/app/components/datasets/hit-testing/components/query-input'
import { RETRIEVE_METHOD } from '@/types/app'
// --- Mocks ---
vi.mock('@/context/dataset-detail', () => ({
default: {},
useDatasetDetailContext: vi.fn(() => ({ dataset: undefined })),
useDatasetDetailContextWithSelector: vi.fn(() => false),
}))
vi.mock('use-context-selector', () => ({
useContext: vi.fn(() => ({})),
useContextSelector: vi.fn(() => false),
createContext: vi.fn(() => ({})),
}))
vi.mock('@/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing', () => ({
default: ({ textArea, actionButton }: { textArea: React.ReactNode, actionButton: React.ReactNode }) => (
<div data-testid="image-uploader-mock">
{textArea}
{actionButton}
</div>
),
}))
// --- Factories ---
const createRetrievalConfig = (overrides = {}): RetrievalConfig => ({
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_mode: undefined,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
weights: undefined,
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0.5,
...overrides,
} as RetrievalConfig)
const createHitTestingResponse = (numResults: number): HitTestingResponse => ({
query: {
content: 'What is Dify?',
tsne_position: { x: 0, y: 0 },
},
records: Array.from({ length: numResults }, (_, i) => ({
segment: {
id: `seg-${i}`,
document: {
id: `doc-${i}`,
data_source_type: 'upload_file',
name: `document-${i}.txt`,
doc_type: null as unknown as import('@/models/datasets').DocType,
},
content: `Result content ${i}`,
sign_content: `Result content ${i}`,
position: i + 1,
word_count: 100 + i * 50,
tokens: 50 + i * 25,
keywords: ['test', 'dify'],
hit_count: i * 5,
index_node_hash: `hash-${i}`,
answer: '',
},
content: {
id: `seg-${i}`,
document: {
id: `doc-${i}`,
data_source_type: 'upload_file',
name: `document-${i}.txt`,
doc_type: null as unknown as import('@/models/datasets').DocType,
},
content: `Result content ${i}`,
sign_content: `Result content ${i}`,
position: i + 1,
word_count: 100 + i * 50,
tokens: 50 + i * 25,
keywords: ['test', 'dify'],
hit_count: i * 5,
index_node_hash: `hash-${i}`,
answer: '',
},
score: 0.95 - i * 0.1,
tsne_position: { x: 0, y: 0 },
child_chunks: null,
files: [],
})),
})
const createTextQuery = (content: string): Query[] => [
{ content, content_type: 'text_query', file_info: null },
]
// --- Helpers ---
const findSubmitButton = () => {
const buttons = screen.getAllByRole('button')
const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]'))
expect(submitButton).toBeTruthy()
return submitButton!
}
// --- Tests ---
describe('Hit Testing Flow', () => {
const mockHitTestingMutation = vi.fn()
const mockExternalMutation = vi.fn()
const mockSetHitResult = vi.fn()
const mockSetExternalHitResult = vi.fn()
const mockOnUpdateList = vi.fn()
const mockSetQueries = vi.fn()
const mockOnClickRetrievalMethod = vi.fn()
const mockOnSubmit = vi.fn()
const createDefaultProps = (overrides: Record<string, unknown> = {}) => ({
onUpdateList: mockOnUpdateList,
setHitResult: mockSetHitResult,
setExternalHitResult: mockSetExternalHitResult,
loading: false,
queries: [] as Query[],
setQueries: mockSetQueries,
isExternal: false,
onClickRetrievalMethod: mockOnClickRetrievalMethod,
retrievalConfig: createRetrievalConfig(),
isEconomy: false,
onSubmit: mockOnSubmit,
hitTestingMutation: mockHitTestingMutation,
externalKnowledgeBaseHitTestingMutation: mockExternalMutation,
...overrides,
})
beforeEach(() => {
vi.clearAllMocks()
})
describe('Query Submission → API Call', () => {
it('should call hitTestingMutation with correct payload including retrieval model', async () => {
const retrievalConfig = createRetrievalConfig({
search_method: RETRIEVE_METHOD.semantic,
top_k: 3,
score_threshold_enabled: false,
})
mockHitTestingMutation.mockResolvedValue(createHitTestingResponse(3))
render(
<QueryInput {...createDefaultProps({
queries: createTextQuery('How does RAG work?'),
retrievalConfig,
})}
/>,
)
fireEvent.click(findSubmitButton())
await waitFor(() => {
expect(mockHitTestingMutation).toHaveBeenCalledWith(
expect.objectContaining({
query: 'How does RAG work?',
attachment_ids: [],
retrieval_model: expect.objectContaining({
search_method: RETRIEVE_METHOD.semantic,
top_k: 3,
score_threshold_enabled: false,
}),
}),
expect.objectContaining({
onSuccess: expect.any(Function),
}),
)
})
})
it('should override search_method to keywordSearch when isEconomy is true', async () => {
const retrievalConfig = createRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic })
mockHitTestingMutation.mockResolvedValue(createHitTestingResponse(1))
render(
<QueryInput {...createDefaultProps({
queries: createTextQuery('test query'),
retrievalConfig,
isEconomy: true,
})}
/>,
)
fireEvent.click(findSubmitButton())
await waitFor(() => {
expect(mockHitTestingMutation).toHaveBeenCalledWith(
expect.objectContaining({
retrieval_model: expect.objectContaining({
search_method: RETRIEVE_METHOD.keywordSearch,
}),
}),
expect.anything(),
)
})
})
it('should handle empty results by calling setHitResult with empty records', async () => {
const emptyResponse = createHitTestingResponse(0)
mockHitTestingMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: HitTestingResponse) => void }) => {
options?.onSuccess?.(emptyResponse)
return emptyResponse
})
render(
<QueryInput {...createDefaultProps({
queries: createTextQuery('nonexistent topic'),
})}
/>,
)
fireEvent.click(findSubmitButton())
await waitFor(() => {
expect(mockSetHitResult).toHaveBeenCalledWith(
expect.objectContaining({ records: [] }),
)
})
})
it('should not call success callbacks when mutation resolves without onSuccess', async () => {
// Simulate a mutation that resolves but does not invoke the onSuccess callback
mockHitTestingMutation.mockResolvedValue(undefined)
render(
<QueryInput {...createDefaultProps({
queries: createTextQuery('test'),
})}
/>,
)
fireEvent.click(findSubmitButton())
await waitFor(() => {
expect(mockHitTestingMutation).toHaveBeenCalled()
})
// Success callbacks should not fire when onSuccess is not invoked
expect(mockSetHitResult).not.toHaveBeenCalled()
expect(mockOnUpdateList).not.toHaveBeenCalled()
expect(mockOnSubmit).not.toHaveBeenCalled()
})
})
describe('API Response → Results Data Contract', () => {
it('should produce results with required segment fields for rendering', () => {
const response = createHitTestingResponse(3)
// Validate each result has the fields needed by ResultItem component
response.records.forEach((record) => {
expect(record.segment).toHaveProperty('id')
expect(record.segment).toHaveProperty('content')
expect(record.segment).toHaveProperty('position')
expect(record.segment).toHaveProperty('word_count')
expect(record.segment).toHaveProperty('document')
expect(record.segment.document).toHaveProperty('name')
expect(record.score).toBeGreaterThanOrEqual(0)
expect(record.score).toBeLessThanOrEqual(1)
})
})
it('should maintain correct score ordering', () => {
const response = createHitTestingResponse(5)
for (let i = 1; i < response.records.length; i++) {
expect(response.records[i - 1].score).toBeGreaterThanOrEqual(response.records[i].score)
}
})
it('should include document metadata for result item display', () => {
const response = createHitTestingResponse(1)
const record = response.records[0]
expect(record.segment.document.name).toBeTruthy()
expect(record.segment.document.data_source_type).toBeTruthy()
})
})
describe('Successful Submission → Callback Chain', () => {
it('should call setHitResult, onUpdateList, and onSubmit after successful submission', async () => {
const response = createHitTestingResponse(3)
mockHitTestingMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: HitTestingResponse) => void }) => {
options?.onSuccess?.(response)
return response
})
render(
<QueryInput {...createDefaultProps({
queries: createTextQuery('Test query'),
})}
/>,
)
fireEvent.click(findSubmitButton())
await waitFor(() => {
expect(mockSetHitResult).toHaveBeenCalledWith(response)
expect(mockOnUpdateList).toHaveBeenCalledTimes(1)
expect(mockOnSubmit).toHaveBeenCalledTimes(1)
})
})
it('should trigger records list refresh via onUpdateList after query', async () => {
const response = createHitTestingResponse(1)
mockHitTestingMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: HitTestingResponse) => void }) => {
options?.onSuccess?.(response)
return response
})
render(
<QueryInput {...createDefaultProps({
queries: createTextQuery('new query'),
})}
/>,
)
fireEvent.click(findSubmitButton())
await waitFor(() => {
expect(mockOnUpdateList).toHaveBeenCalledTimes(1)
})
})
})
describe('External KB Hit Testing', () => {
it('should use external mutation with correct payload for external datasets', async () => {
mockExternalMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: { records: never[] }) => void }) => {
const response = { records: [] }
options?.onSuccess?.(response)
return response
})
render(
<QueryInput {...createDefaultProps({
queries: createTextQuery('test'),
isExternal: true,
})}
/>,
)
fireEvent.click(findSubmitButton())
await waitFor(() => {
expect(mockExternalMutation).toHaveBeenCalledWith(
expect.objectContaining({
query: 'test',
external_retrieval_model: expect.objectContaining({
top_k: 4,
score_threshold: 0.5,
score_threshold_enabled: false,
}),
}),
expect.objectContaining({
onSuccess: expect.any(Function),
}),
)
// Internal mutation should NOT be called
expect(mockHitTestingMutation).not.toHaveBeenCalled()
})
})
it('should call setExternalHitResult and onUpdateList on successful external submission', async () => {
const externalResponse = { records: [] }
mockExternalMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: { records: never[] }) => void }) => {
options?.onSuccess?.(externalResponse)
return externalResponse
})
render(
<QueryInput {...createDefaultProps({
queries: createTextQuery('external query'),
isExternal: true,
})}
/>,
)
fireEvent.click(findSubmitButton())
await waitFor(() => {
expect(mockSetExternalHitResult).toHaveBeenCalledWith(externalResponse)
expect(mockOnUpdateList).toHaveBeenCalledTimes(1)
})
})
})
})

View File

@@ -1,337 +0,0 @@
/**
* Integration Test: Metadata Management Flow
*
* Tests the cross-module composition of metadata name validation, type constraints,
* and duplicate detection across the metadata management hooks.
*
* The unit-level use-check-metadata-name.spec.ts tests the validation hook alone.
* This integration test verifies:
* - Name validation combined with existing metadata list (duplicate detection)
* - Metadata type enum constraints matching expected data model
* - Full add/rename workflow: validate name → check duplicates → allow or reject
* - Name uniqueness logic: existing metadata keeps its own name, cannot take another's
*/
import type { MetadataItemWithValueLength } from '@/app/components/datasets/metadata/types'
import { renderHook } from '@testing-library/react'
import { DataType } from '@/app/components/datasets/metadata/types'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
const { default: useCheckMetadataName } = await import(
'@/app/components/datasets/metadata/hooks/use-check-metadata-name',
)
// --- Factory functions ---
const createMetadataItem = (
id: string,
name: string,
type = DataType.string,
count = 0,
): MetadataItemWithValueLength => ({
id,
name,
type,
count,
})
const createMetadataList = (): MetadataItemWithValueLength[] => [
createMetadataItem('meta-1', 'author', DataType.string, 5),
createMetadataItem('meta-2', 'created_date', DataType.time, 10),
createMetadataItem('meta-3', 'page_count', DataType.number, 3),
createMetadataItem('meta-4', 'source_url', DataType.string, 8),
createMetadataItem('meta-5', 'version', DataType.number, 2),
]
describe('Metadata Management Flow - Cross-Module Validation Composition', () => {
describe('Name Validation Flow: Format Rules', () => {
it('should accept valid lowercase names with underscores', () => {
const { result } = renderHook(() => useCheckMetadataName())
expect(result.current.checkName('valid_name').errorMsg).toBe('')
expect(result.current.checkName('author').errorMsg).toBe('')
expect(result.current.checkName('page_count').errorMsg).toBe('')
expect(result.current.checkName('v2_field').errorMsg).toBe('')
})
it('should reject empty names', () => {
const { result } = renderHook(() => useCheckMetadataName())
expect(result.current.checkName('').errorMsg).toBeTruthy()
})
it('should reject names with invalid characters', () => {
const { result } = renderHook(() => useCheckMetadataName())
expect(result.current.checkName('Author').errorMsg).toBeTruthy()
expect(result.current.checkName('my-field').errorMsg).toBeTruthy()
expect(result.current.checkName('field name').errorMsg).toBeTruthy()
expect(result.current.checkName('1field').errorMsg).toBeTruthy()
expect(result.current.checkName('_private').errorMsg).toBeTruthy()
})
it('should reject names exceeding 255 characters', () => {
const { result } = renderHook(() => useCheckMetadataName())
const longName = 'a'.repeat(256)
expect(result.current.checkName(longName).errorMsg).toBeTruthy()
const maxName = 'a'.repeat(255)
expect(result.current.checkName(maxName).errorMsg).toBe('')
})
})
describe('Metadata Type Constraints: Enum Values Match Expected Set', () => {
it('should define exactly three data types', () => {
const typeValues = Object.values(DataType)
expect(typeValues).toHaveLength(3)
})
it('should include string, number, and time types', () => {
expect(DataType.string).toBe('string')
expect(DataType.number).toBe('number')
expect(DataType.time).toBe('time')
})
it('should use consistent types in metadata items', () => {
const metadataList = createMetadataList()
const stringItems = metadataList.filter(m => m.type === DataType.string)
const numberItems = metadataList.filter(m => m.type === DataType.number)
const timeItems = metadataList.filter(m => m.type === DataType.time)
expect(stringItems).toHaveLength(2)
expect(numberItems).toHaveLength(2)
expect(timeItems).toHaveLength(1)
})
it('should enforce type-safe metadata item construction', () => {
const item = createMetadataItem('test-1', 'test_field', DataType.number, 0)
expect(item.id).toBe('test-1')
expect(item.name).toBe('test_field')
expect(item.type).toBe(DataType.number)
expect(item.count).toBe(0)
})
})
describe('Duplicate Name Detection: Add Metadata → Check Name → Detect Duplicates', () => {
it('should detect duplicate names against an existing metadata list', () => {
const { result } = renderHook(() => useCheckMetadataName())
const existingMetadata = createMetadataList()
const checkDuplicate = (newName: string): boolean => {
const formatCheck = result.current.checkName(newName)
if (formatCheck.errorMsg)
return false
return existingMetadata.some(m => m.name === newName)
}
expect(checkDuplicate('author')).toBe(true)
expect(checkDuplicate('created_date')).toBe(true)
expect(checkDuplicate('page_count')).toBe(true)
})
it('should allow names that do not conflict with existing metadata', () => {
const { result } = renderHook(() => useCheckMetadataName())
const existingMetadata = createMetadataList()
const isNameAvailable = (newName: string): boolean => {
const formatCheck = result.current.checkName(newName)
if (formatCheck.errorMsg)
return false
return !existingMetadata.some(m => m.name === newName)
}
expect(isNameAvailable('category')).toBe(true)
expect(isNameAvailable('file_size')).toBe(true)
expect(isNameAvailable('language')).toBe(true)
})
it('should reject names that fail format validation before duplicate check', () => {
const { result } = renderHook(() => useCheckMetadataName())
const validateAndCheckDuplicate = (newName: string): { valid: boolean, reason: string } => {
const formatCheck = result.current.checkName(newName)
if (formatCheck.errorMsg)
return { valid: false, reason: 'format' }
return { valid: true, reason: '' }
}
expect(validateAndCheckDuplicate('Author').reason).toBe('format')
expect(validateAndCheckDuplicate('').reason).toBe('format')
expect(validateAndCheckDuplicate('valid_name').valid).toBe(true)
})
})
describe('Name Uniqueness Across Edits: Rename Workflow', () => {
it('should allow an existing metadata item to keep its own name', () => {
const { result } = renderHook(() => useCheckMetadataName())
const existingMetadata = createMetadataList()
const isRenameValid = (itemId: string, newName: string): boolean => {
const formatCheck = result.current.checkName(newName)
if (formatCheck.errorMsg)
return false
// Allow keeping the same name (skip self in duplicate check)
return !existingMetadata.some(m => m.name === newName && m.id !== itemId)
}
// Author keeping its own name should be valid
expect(isRenameValid('meta-1', 'author')).toBe(true)
// page_count keeping its own name should be valid
expect(isRenameValid('meta-3', 'page_count')).toBe(true)
})
it('should reject renaming to another existing metadata name', () => {
const { result } = renderHook(() => useCheckMetadataName())
const existingMetadata = createMetadataList()
const isRenameValid = (itemId: string, newName: string): boolean => {
const formatCheck = result.current.checkName(newName)
if (formatCheck.errorMsg)
return false
return !existingMetadata.some(m => m.name === newName && m.id !== itemId)
}
// Author trying to rename to "page_count" (taken by meta-3)
expect(isRenameValid('meta-1', 'page_count')).toBe(false)
// version trying to rename to "source_url" (taken by meta-4)
expect(isRenameValid('meta-5', 'source_url')).toBe(false)
})
it('should allow renaming to a completely new valid name', () => {
const { result } = renderHook(() => useCheckMetadataName())
const existingMetadata = createMetadataList()
const isRenameValid = (itemId: string, newName: string): boolean => {
const formatCheck = result.current.checkName(newName)
if (formatCheck.errorMsg)
return false
return !existingMetadata.some(m => m.name === newName && m.id !== itemId)
}
expect(isRenameValid('meta-1', 'document_author')).toBe(true)
expect(isRenameValid('meta-2', 'publish_date')).toBe(true)
expect(isRenameValid('meta-3', 'total_pages')).toBe(true)
})
it('should reject renaming with an invalid format even if name is unique', () => {
const { result } = renderHook(() => useCheckMetadataName())
const existingMetadata = createMetadataList()
const isRenameValid = (itemId: string, newName: string): boolean => {
const formatCheck = result.current.checkName(newName)
if (formatCheck.errorMsg)
return false
return !existingMetadata.some(m => m.name === newName && m.id !== itemId)
}
expect(isRenameValid('meta-1', 'New Author')).toBe(false)
expect(isRenameValid('meta-2', '2024_date')).toBe(false)
expect(isRenameValid('meta-3', '')).toBe(false)
})
})
describe('Full Metadata Management Workflow', () => {
it('should support a complete add-validate-check-duplicate cycle', () => {
const { result } = renderHook(() => useCheckMetadataName())
const existingMetadata = createMetadataList()
const addMetadataField = (
name: string,
type: DataType,
): { success: boolean, error?: string } => {
const formatCheck = result.current.checkName(name)
if (formatCheck.errorMsg)
return { success: false, error: 'invalid_format' }
if (existingMetadata.some(m => m.name === name))
return { success: false, error: 'duplicate_name' }
existingMetadata.push(createMetadataItem(`meta-${existingMetadata.length + 1}`, name, type))
return { success: true }
}
// Add a valid new field
const result1 = addMetadataField('department', DataType.string)
expect(result1.success).toBe(true)
expect(existingMetadata).toHaveLength(6)
// Try to add a duplicate
const result2 = addMetadataField('author', DataType.string)
expect(result2.success).toBe(false)
expect(result2.error).toBe('duplicate_name')
expect(existingMetadata).toHaveLength(6)
// Try to add an invalid name
const result3 = addMetadataField('Invalid Name', DataType.string)
expect(result3.success).toBe(false)
expect(result3.error).toBe('invalid_format')
expect(existingMetadata).toHaveLength(6)
// Add another valid field
const result4 = addMetadataField('priority_level', DataType.number)
expect(result4.success).toBe(true)
expect(existingMetadata).toHaveLength(7)
})
it('should support a complete rename workflow with validation chain', () => {
const { result } = renderHook(() => useCheckMetadataName())
const existingMetadata = createMetadataList()
const renameMetadataField = (
itemId: string,
newName: string,
): { success: boolean, error?: string } => {
const formatCheck = result.current.checkName(newName)
if (formatCheck.errorMsg)
return { success: false, error: 'invalid_format' }
if (existingMetadata.some(m => m.name === newName && m.id !== itemId))
return { success: false, error: 'duplicate_name' }
const item = existingMetadata.find(m => m.id === itemId)
if (!item)
return { success: false, error: 'not_found' }
// Simulate the rename in-place
const index = existingMetadata.indexOf(item)
existingMetadata[index] = { ...item, name: newName }
return { success: true }
}
// Rename author to document_author
expect(renameMetadataField('meta-1', 'document_author').success).toBe(true)
expect(existingMetadata.find(m => m.id === 'meta-1')?.name).toBe('document_author')
// Try renaming created_date to page_count (already taken)
expect(renameMetadataField('meta-2', 'page_count').error).toBe('duplicate_name')
// Rename to invalid format
expect(renameMetadataField('meta-3', 'Page Count').error).toBe('invalid_format')
// Rename non-existent item
expect(renameMetadataField('meta-999', 'something').error).toBe('not_found')
})
it('should maintain validation consistency across multiple operations', () => {
const { result } = renderHook(() => useCheckMetadataName())
// Validate the same name multiple times for consistency
const name = 'consistent_field'
const results = Array.from({ length: 5 }, () => result.current.checkName(name))
expect(results.every(r => r.errorMsg === '')).toBe(true)
// Validate an invalid name multiple times
const invalidResults = Array.from({ length: 5 }, () => result.current.checkName('Invalid'))
expect(invalidResults.every(r => r.errorMsg !== '')).toBe(true)
})
})
})

View File

@@ -1,477 +0,0 @@
/**
* Integration Test: Pipeline Data Source Store Composition
*
* Tests cross-slice interactions in the pipeline data source Zustand store.
* The unit-level slice specs test each slice in isolation.
* This integration test verifies:
* - Store initialization produces correct defaults across all slices
* - Cross-slice coordination (e.g. credential shared across slices)
* - State isolation: changes in one slice do not affect others
* - Full workflow simulation through credential → source → data path
*/
import type { NotionPage } from '@/models/common'
import type { CrawlResultItem, FileItem } from '@/models/datasets'
import type { OnlineDriveFile } from '@/models/pipeline'
import { createDataSourceStore } from '@/app/components/datasets/documents/create-from-pipeline/data-source/store'
import { CrawlStep } from '@/models/datasets'
import { OnlineDriveFileType } from '@/models/pipeline'
// --- Factory functions ---
const createFileItem = (id: string): FileItem => ({
fileID: id,
file: { id, name: `${id}.txt`, size: 1024 } as FileItem['file'],
progress: 100,
})
const createCrawlResultItem = (url: string, title?: string): CrawlResultItem => ({
title: title ?? `Page: ${url}`,
markdown: `# ${title ?? url}\n\nContent for ${url}`,
description: `Description for ${url}`,
source_url: url,
})
const createOnlineDriveFile = (id: string, name: string, type = OnlineDriveFileType.file): OnlineDriveFile => ({
id,
name,
size: 2048,
type,
})
const createNotionPage = (pageId: string): NotionPage => ({
page_id: pageId,
page_name: `Page ${pageId}`,
page_icon: null,
is_bound: true,
parent_id: 'parent-1',
type: 'page',
workspace_id: 'ws-1',
})
describe('Pipeline Data Source Store Composition - Cross-Slice Integration', () => {
describe('Store Initialization → All Slices Have Correct Defaults', () => {
it('should create a store with all five slices combined', () => {
const store = createDataSourceStore()
const state = store.getState()
// Common slice defaults
expect(state.currentCredentialId).toBe('')
expect(state.currentNodeIdRef.current).toBe('')
// Local file slice defaults
expect(state.localFileList).toEqual([])
expect(state.currentLocalFile).toBeUndefined()
// Online document slice defaults
expect(state.documentsData).toEqual([])
expect(state.onlineDocuments).toEqual([])
expect(state.searchValue).toBe('')
expect(state.selectedPagesId).toEqual(new Set())
// Website crawl slice defaults
expect(state.websitePages).toEqual([])
expect(state.step).toBe(CrawlStep.init)
expect(state.previewIndex).toBe(-1)
// Online drive slice defaults
expect(state.breadcrumbs).toEqual([])
expect(state.prefix).toEqual([])
expect(state.keywords).toBe('')
expect(state.selectedFileIds).toEqual([])
expect(state.onlineDriveFileList).toEqual([])
expect(state.bucket).toBe('')
expect(state.hasBucket).toBe(false)
})
})
describe('Cross-Slice Coordination: Shared Credential', () => {
it('should set credential that is accessible from the common slice', () => {
const store = createDataSourceStore()
store.getState().setCurrentCredentialId('cred-abc-123')
expect(store.getState().currentCredentialId).toBe('cred-abc-123')
})
it('should allow credential update independently of all other slices', () => {
const store = createDataSourceStore()
store.getState().setLocalFileList([createFileItem('f1')])
store.getState().setCurrentCredentialId('cred-xyz')
expect(store.getState().currentCredentialId).toBe('cred-xyz')
expect(store.getState().localFileList).toHaveLength(1)
})
})
describe('Local File Workflow: Set Files → Verify List → Clear', () => {
it('should set and retrieve local file list', () => {
const store = createDataSourceStore()
const files = [createFileItem('f1'), createFileItem('f2'), createFileItem('f3')]
store.getState().setLocalFileList(files)
expect(store.getState().localFileList).toHaveLength(3)
expect(store.getState().localFileList[0].fileID).toBe('f1')
expect(store.getState().localFileList[2].fileID).toBe('f3')
})
it('should update preview ref when setting file list', () => {
const store = createDataSourceStore()
const files = [createFileItem('f-preview')]
store.getState().setLocalFileList(files)
expect(store.getState().previewLocalFileRef.current).toBeDefined()
})
it('should clear files by setting empty list', () => {
const store = createDataSourceStore()
store.getState().setLocalFileList([createFileItem('f1')])
expect(store.getState().localFileList).toHaveLength(1)
store.getState().setLocalFileList([])
expect(store.getState().localFileList).toHaveLength(0)
})
it('should set and clear current local file selection', () => {
const store = createDataSourceStore()
const file = { id: 'current-file', name: 'current.txt' } as FileItem['file']
store.getState().setCurrentLocalFile(file)
expect(store.getState().currentLocalFile).toBeDefined()
expect(store.getState().currentLocalFile?.id).toBe('current-file')
store.getState().setCurrentLocalFile(undefined)
expect(store.getState().currentLocalFile).toBeUndefined()
})
})
describe('Online Document Workflow: Set Documents → Select Pages → Verify', () => {
it('should set documents data and online documents', () => {
const store = createDataSourceStore()
const pages = [createNotionPage('page-1'), createNotionPage('page-2')]
store.getState().setOnlineDocuments(pages)
expect(store.getState().onlineDocuments).toHaveLength(2)
expect(store.getState().onlineDocuments[0].page_id).toBe('page-1')
})
it('should update preview ref when setting online documents', () => {
const store = createDataSourceStore()
const pages = [createNotionPage('page-preview')]
store.getState().setOnlineDocuments(pages)
expect(store.getState().previewOnlineDocumentRef.current).toBeDefined()
expect(store.getState().previewOnlineDocumentRef.current?.page_id).toBe('page-preview')
})
it('should track selected page IDs', () => {
const store = createDataSourceStore()
const pages = [createNotionPage('p1'), createNotionPage('p2'), createNotionPage('p3')]
store.getState().setOnlineDocuments(pages)
store.getState().setSelectedPagesId(new Set(['p1', 'p3']))
expect(store.getState().selectedPagesId.size).toBe(2)
expect(store.getState().selectedPagesId.has('p1')).toBe(true)
expect(store.getState().selectedPagesId.has('p2')).toBe(false)
expect(store.getState().selectedPagesId.has('p3')).toBe(true)
})
it('should manage search value for filtering documents', () => {
const store = createDataSourceStore()
store.getState().setSearchValue('meeting notes')
expect(store.getState().searchValue).toBe('meeting notes')
})
it('should set and clear current document selection', () => {
const store = createDataSourceStore()
const page = createNotionPage('current-page')
store.getState().setCurrentDocument(page)
expect(store.getState().currentDocument?.page_id).toBe('current-page')
store.getState().setCurrentDocument(undefined)
expect(store.getState().currentDocument).toBeUndefined()
})
})
describe('Website Crawl Workflow: Set Pages → Track Step → Preview', () => {
it('should set website pages and update preview ref', () => {
const store = createDataSourceStore()
const pages = [
createCrawlResultItem('https://example.com'),
createCrawlResultItem('https://example.com/about'),
]
store.getState().setWebsitePages(pages)
expect(store.getState().websitePages).toHaveLength(2)
expect(store.getState().previewWebsitePageRef.current?.source_url).toBe('https://example.com')
})
it('should manage crawl step transitions', () => {
const store = createDataSourceStore()
expect(store.getState().step).toBe(CrawlStep.init)
store.getState().setStep(CrawlStep.running)
expect(store.getState().step).toBe(CrawlStep.running)
store.getState().setStep(CrawlStep.finished)
expect(store.getState().step).toBe(CrawlStep.finished)
})
it('should set crawl result with data and timing', () => {
const store = createDataSourceStore()
const result = {
data: [createCrawlResultItem('https://test.com')],
time_consuming: 3.5,
}
store.getState().setCrawlResult(result)
expect(store.getState().crawlResult?.data).toHaveLength(1)
expect(store.getState().crawlResult?.time_consuming).toBe(3.5)
})
it('should manage preview index for page navigation', () => {
const store = createDataSourceStore()
store.getState().setPreviewIndex(2)
expect(store.getState().previewIndex).toBe(2)
store.getState().setPreviewIndex(-1)
expect(store.getState().previewIndex).toBe(-1)
})
it('should set and clear current website selection', () => {
const store = createDataSourceStore()
const page = createCrawlResultItem('https://current.com')
store.getState().setCurrentWebsite(page)
expect(store.getState().currentWebsite?.source_url).toBe('https://current.com')
store.getState().setCurrentWebsite(undefined)
expect(store.getState().currentWebsite).toBeUndefined()
})
})
describe('Online Drive Workflow: Breadcrumbs → File Selection → Navigation', () => {
it('should manage breadcrumb navigation', () => {
const store = createDataSourceStore()
store.getState().setBreadcrumbs(['root', 'folder-a', 'subfolder'])
expect(store.getState().breadcrumbs).toEqual(['root', 'folder-a', 'subfolder'])
})
it('should support breadcrumb push/pop pattern', () => {
const store = createDataSourceStore()
store.getState().setBreadcrumbs(['root'])
store.getState().setBreadcrumbs([...store.getState().breadcrumbs, 'level-1'])
store.getState().setBreadcrumbs([...store.getState().breadcrumbs, 'level-2'])
expect(store.getState().breadcrumbs).toEqual(['root', 'level-1', 'level-2'])
// Pop back one level
store.getState().setBreadcrumbs(store.getState().breadcrumbs.slice(0, -1))
expect(store.getState().breadcrumbs).toEqual(['root', 'level-1'])
})
it('should manage file list and selection', () => {
const store = createDataSourceStore()
const files = [
createOnlineDriveFile('drive-1', 'report.pdf'),
createOnlineDriveFile('drive-2', 'data.csv'),
createOnlineDriveFile('drive-3', 'images', OnlineDriveFileType.folder),
]
store.getState().setOnlineDriveFileList(files)
expect(store.getState().onlineDriveFileList).toHaveLength(3)
store.getState().setSelectedFileIds(['drive-1', 'drive-2'])
expect(store.getState().selectedFileIds).toEqual(['drive-1', 'drive-2'])
})
it('should update preview ref when selecting files', () => {
const store = createDataSourceStore()
const files = [
createOnlineDriveFile('drive-a', 'file-a.txt'),
createOnlineDriveFile('drive-b', 'file-b.txt'),
]
store.getState().setOnlineDriveFileList(files)
store.getState().setSelectedFileIds(['drive-b'])
expect(store.getState().previewOnlineDriveFileRef.current?.id).toBe('drive-b')
})
it('should manage bucket and prefix for S3-like navigation', () => {
const store = createDataSourceStore()
store.getState().setBucket('my-data-bucket')
store.getState().setPrefix(['data', '2024'])
store.getState().setHasBucket(true)
expect(store.getState().bucket).toBe('my-data-bucket')
expect(store.getState().prefix).toEqual(['data', '2024'])
expect(store.getState().hasBucket).toBe(true)
})
it('should manage keywords for search filtering', () => {
const store = createDataSourceStore()
store.getState().setKeywords('quarterly report')
expect(store.getState().keywords).toBe('quarterly report')
})
})
describe('State Isolation: Changes to One Slice Do Not Affect Others', () => {
it('should keep local file state independent from online document state', () => {
const store = createDataSourceStore()
store.getState().setLocalFileList([createFileItem('local-1')])
store.getState().setOnlineDocuments([createNotionPage('notion-1')])
expect(store.getState().localFileList).toHaveLength(1)
expect(store.getState().onlineDocuments).toHaveLength(1)
// Clearing local files should not affect online documents
store.getState().setLocalFileList([])
expect(store.getState().localFileList).toHaveLength(0)
expect(store.getState().onlineDocuments).toHaveLength(1)
})
it('should keep website crawl state independent from online drive state', () => {
const store = createDataSourceStore()
store.getState().setWebsitePages([createCrawlResultItem('https://site.com')])
store.getState().setOnlineDriveFileList([createOnlineDriveFile('d1', 'file.txt')])
expect(store.getState().websitePages).toHaveLength(1)
expect(store.getState().onlineDriveFileList).toHaveLength(1)
// Clearing website pages should not affect drive files
store.getState().setWebsitePages([])
expect(store.getState().websitePages).toHaveLength(0)
expect(store.getState().onlineDriveFileList).toHaveLength(1)
})
it('should create fully independent store instances', () => {
const storeA = createDataSourceStore()
const storeB = createDataSourceStore()
storeA.getState().setCurrentCredentialId('cred-A')
storeA.getState().setLocalFileList([createFileItem('fa-1')])
expect(storeA.getState().currentCredentialId).toBe('cred-A')
expect(storeB.getState().currentCredentialId).toBe('')
expect(storeB.getState().localFileList).toEqual([])
})
})
describe('Full Workflow Simulation: Credential → Source → Data → Verify', () => {
it('should support a complete local file upload workflow', () => {
const store = createDataSourceStore()
// Step 1: Set credential
store.getState().setCurrentCredentialId('upload-cred-1')
// Step 2: Set file list
const files = [createFileItem('upload-1'), createFileItem('upload-2')]
store.getState().setLocalFileList(files)
// Step 3: Select current file for preview
store.getState().setCurrentLocalFile(files[0].file)
// Verify all state is consistent
expect(store.getState().currentCredentialId).toBe('upload-cred-1')
expect(store.getState().localFileList).toHaveLength(2)
expect(store.getState().currentLocalFile?.id).toBe('upload-1')
expect(store.getState().previewLocalFileRef.current).toBeDefined()
})
it('should support a complete website crawl workflow', () => {
const store = createDataSourceStore()
// Step 1: Set credential
store.getState().setCurrentCredentialId('crawl-cred-1')
// Step 2: Init crawl
store.getState().setStep(CrawlStep.running)
// Step 3: Crawl completes with results
const crawledPages = [
createCrawlResultItem('https://docs.example.com/guide'),
createCrawlResultItem('https://docs.example.com/api'),
createCrawlResultItem('https://docs.example.com/faq'),
]
store.getState().setCrawlResult({ data: crawledPages, time_consuming: 12.5 })
store.getState().setStep(CrawlStep.finished)
// Step 4: Set website pages from results
store.getState().setWebsitePages(crawledPages)
// Step 5: Set preview
store.getState().setPreviewIndex(1)
// Verify all state
expect(store.getState().currentCredentialId).toBe('crawl-cred-1')
expect(store.getState().step).toBe(CrawlStep.finished)
expect(store.getState().websitePages).toHaveLength(3)
expect(store.getState().crawlResult?.time_consuming).toBe(12.5)
expect(store.getState().previewIndex).toBe(1)
expect(store.getState().previewWebsitePageRef.current?.source_url).toBe('https://docs.example.com/guide')
})
it('should support a complete online drive navigation workflow', () => {
const store = createDataSourceStore()
// Step 1: Set credential
store.getState().setCurrentCredentialId('drive-cred-1')
// Step 2: Set bucket
store.getState().setBucket('company-docs')
store.getState().setHasBucket(true)
// Step 3: Navigate into folders
store.getState().setBreadcrumbs(['company-docs'])
store.getState().setPrefix(['projects'])
const folderFiles = [
createOnlineDriveFile('proj-1', 'project-alpha', OnlineDriveFileType.folder),
createOnlineDriveFile('proj-2', 'project-beta', OnlineDriveFileType.folder),
createOnlineDriveFile('readme', 'README.md', OnlineDriveFileType.file),
]
store.getState().setOnlineDriveFileList(folderFiles)
// Step 4: Navigate deeper
store.getState().setBreadcrumbs([...store.getState().breadcrumbs, 'project-alpha'])
store.getState().setPrefix([...store.getState().prefix, 'project-alpha'])
// Step 5: Select files
store.getState().setOnlineDriveFileList([
createOnlineDriveFile('doc-1', 'spec.pdf'),
createOnlineDriveFile('doc-2', 'design.fig'),
])
store.getState().setSelectedFileIds(['doc-1'])
// Verify full state
expect(store.getState().currentCredentialId).toBe('drive-cred-1')
expect(store.getState().bucket).toBe('company-docs')
expect(store.getState().breadcrumbs).toEqual(['company-docs', 'project-alpha'])
expect(store.getState().prefix).toEqual(['projects', 'project-alpha'])
expect(store.getState().onlineDriveFileList).toHaveLength(2)
expect(store.getState().selectedFileIds).toEqual(['doc-1'])
expect(store.getState().previewOnlineDriveFileRef.current?.name).toBe('spec.pdf')
})
})
})

View File

@@ -1,300 +0,0 @@
/**
* Integration Test: Segment CRUD Flow
*
* Tests segment selection, search/filter, and modal state management across hooks.
* Validates cross-hook data contracts in the completed segment module.
*/
import type { SegmentDetailModel } from '@/models/datasets'
import { act, renderHook } from '@testing-library/react'
import { useModalState } from '@/app/components/datasets/documents/detail/completed/hooks/use-modal-state'
import { useSearchFilter } from '@/app/components/datasets/documents/detail/completed/hooks/use-search-filter'
import { useSegmentSelection } from '@/app/components/datasets/documents/detail/completed/hooks/use-segment-selection'
const createSegment = (id: string, content = 'Test segment content'): SegmentDetailModel => ({
id,
position: 1,
document_id: 'doc-1',
content,
sign_content: content,
answer: '',
word_count: 50,
tokens: 25,
keywords: ['test'],
index_node_id: 'idx-1',
index_node_hash: 'hash-1',
hit_count: 0,
enabled: true,
disabled_at: 0,
disabled_by: '',
status: 'completed',
created_by: 'user-1',
created_at: Date.now(),
indexing_at: Date.now(),
completed_at: Date.now(),
error: null,
stopped_at: 0,
updated_at: Date.now(),
attachments: [],
} as SegmentDetailModel)
describe('Segment CRUD Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Search and Filter → Segment List Query', () => {
it('should manage search input with debounce', () => {
vi.useFakeTimers()
const onPageChange = vi.fn()
const { result } = renderHook(() => useSearchFilter({ onPageChange }))
act(() => {
result.current.handleInputChange('keyword')
})
expect(result.current.inputValue).toBe('keyword')
expect(result.current.searchValue).toBe('')
act(() => {
vi.advanceTimersByTime(500)
})
expect(result.current.searchValue).toBe('keyword')
expect(onPageChange).toHaveBeenCalledWith(1)
vi.useRealTimers()
})
it('should manage status filter state', () => {
const onPageChange = vi.fn()
const { result } = renderHook(() => useSearchFilter({ onPageChange }))
// status value 1 maps to !!1 = true (enabled)
act(() => {
result.current.onChangeStatus({ value: 1, name: 'enabled' })
})
// onChangeStatus converts: value === 'all' ? 'all' : !!value
expect(result.current.selectedStatus).toBe(true)
act(() => {
result.current.onClearFilter()
})
expect(result.current.selectedStatus).toBe('all')
expect(result.current.inputValue).toBe('')
})
it('should provide status list for filter dropdown', () => {
const { result } = renderHook(() => useSearchFilter({ onPageChange: vi.fn() }))
expect(result.current.statusList).toBeInstanceOf(Array)
expect(result.current.statusList.length).toBe(3) // all, disabled, enabled
})
it('should compute selectDefaultValue based on selectedStatus', () => {
const { result } = renderHook(() => useSearchFilter({ onPageChange: vi.fn() }))
// Initial state: 'all'
expect(result.current.selectDefaultValue).toBe('all')
// Set to enabled (true)
act(() => {
result.current.onChangeStatus({ value: 1, name: 'enabled' })
})
expect(result.current.selectDefaultValue).toBe(1)
// Set to disabled (false)
act(() => {
result.current.onChangeStatus({ value: 0, name: 'disabled' })
})
expect(result.current.selectDefaultValue).toBe(0)
})
})
describe('Segment Selection → Batch Operations', () => {
const segments = [
createSegment('seg-1'),
createSegment('seg-2'),
createSegment('seg-3'),
]
it('should manage individual segment selection', () => {
const { result } = renderHook(() => useSegmentSelection(segments))
act(() => {
result.current.onSelected('seg-1')
})
expect(result.current.selectedSegmentIds).toContain('seg-1')
act(() => {
result.current.onSelected('seg-2')
})
expect(result.current.selectedSegmentIds).toContain('seg-1')
expect(result.current.selectedSegmentIds).toContain('seg-2')
expect(result.current.selectedSegmentIds).toHaveLength(2)
})
it('should toggle selection on repeated click', () => {
const { result } = renderHook(() => useSegmentSelection(segments))
act(() => {
result.current.onSelected('seg-1')
})
expect(result.current.selectedSegmentIds).toContain('seg-1')
act(() => {
result.current.onSelected('seg-1')
})
expect(result.current.selectedSegmentIds).not.toContain('seg-1')
})
it('should support select all toggle', () => {
const { result } = renderHook(() => useSegmentSelection(segments))
act(() => {
result.current.onSelectedAll()
})
expect(result.current.selectedSegmentIds).toHaveLength(3)
expect(result.current.isAllSelected).toBe(true)
act(() => {
result.current.onSelectedAll()
})
expect(result.current.selectedSegmentIds).toHaveLength(0)
expect(result.current.isAllSelected).toBe(false)
})
it('should detect partial selection via isSomeSelected', () => {
const { result } = renderHook(() => useSegmentSelection(segments))
act(() => {
result.current.onSelected('seg-1')
})
// After selecting one of three, isSomeSelected should be true
expect(result.current.selectedSegmentIds).toEqual(['seg-1'])
expect(result.current.isSomeSelected).toBe(true)
expect(result.current.isAllSelected).toBe(false)
})
it('should clear selection via onCancelBatchOperation', () => {
const { result } = renderHook(() => useSegmentSelection(segments))
act(() => {
result.current.onSelected('seg-1')
result.current.onSelected('seg-2')
})
expect(result.current.selectedSegmentIds).toHaveLength(2)
act(() => {
result.current.onCancelBatchOperation()
})
expect(result.current.selectedSegmentIds).toHaveLength(0)
})
})
describe('Modal State Management', () => {
const onNewSegmentModalChange = vi.fn()
it('should open segment detail modal on card click', () => {
const { result } = renderHook(() => useModalState({ onNewSegmentModalChange }))
const segment = createSegment('seg-detail-1', 'Detail content')
act(() => {
result.current.onClickCard(segment)
})
expect(result.current.currSegment.showModal).toBe(true)
expect(result.current.currSegment.segInfo).toBeDefined()
expect(result.current.currSegment.segInfo!.id).toBe('seg-detail-1')
})
it('should close segment detail modal', () => {
const { result } = renderHook(() => useModalState({ onNewSegmentModalChange }))
const segment = createSegment('seg-1')
act(() => {
result.current.onClickCard(segment)
})
expect(result.current.currSegment.showModal).toBe(true)
act(() => {
result.current.onCloseSegmentDetail()
})
expect(result.current.currSegment.showModal).toBe(false)
})
it('should manage full screen toggle', () => {
const { result } = renderHook(() => useModalState({ onNewSegmentModalChange }))
expect(result.current.fullScreen).toBe(false)
act(() => {
result.current.toggleFullScreen()
})
expect(result.current.fullScreen).toBe(true)
act(() => {
result.current.toggleFullScreen()
})
expect(result.current.fullScreen).toBe(false)
})
it('should manage collapsed state', () => {
const { result } = renderHook(() => useModalState({ onNewSegmentModalChange }))
expect(result.current.isCollapsed).toBe(true)
act(() => {
result.current.toggleCollapsed()
})
expect(result.current.isCollapsed).toBe(false)
})
it('should manage new child segment modal', () => {
const { result } = renderHook(() => useModalState({ onNewSegmentModalChange }))
expect(result.current.showNewChildSegmentModal).toBe(false)
act(() => {
result.current.handleAddNewChildChunk('chunk-parent-1')
})
expect(result.current.showNewChildSegmentModal).toBe(true)
expect(result.current.currChunkId).toBe('chunk-parent-1')
act(() => {
result.current.onCloseNewChildChunkModal()
})
expect(result.current.showNewChildSegmentModal).toBe(false)
})
})
describe('Cross-Hook Data Flow: Search → Selection → Modal', () => {
it('should maintain independent state across all three hooks', () => {
const segments = [createSegment('seg-1'), createSegment('seg-2')]
const { result: filterResult } = renderHook(() =>
useSearchFilter({ onPageChange: vi.fn() }),
)
const { result: selectionResult } = renderHook(() =>
useSegmentSelection(segments),
)
const { result: modalResult } = renderHook(() =>
useModalState({ onNewSegmentModalChange: vi.fn() }),
)
// Set search filter to enabled
act(() => {
filterResult.current.onChangeStatus({ value: 1, name: 'enabled' })
})
// Select a segment
act(() => {
selectionResult.current.onSelected('seg-1')
})
// Open detail modal
act(() => {
modalResult.current.onClickCard(segments[0])
})
// All states should be independent
expect(filterResult.current.selectedStatus).toBe(true) // !!1
expect(selectionResult.current.selectedSegmentIds).toContain('seg-1')
expect(modalResult.current.currSegment.showModal).toBe(true)
})
})
})

View File

@@ -162,10 +162,8 @@ describe('useEmbeddedChatbot', () => {
await waitFor(() => {
expect(mockFetchChatList).toHaveBeenCalledWith('conversation-1', AppSourceType.webApp, 'app-1')
})
await waitFor(() => {
expect(result.current.pinnedConversationList).toEqual(pinnedData.data)
expect(result.current.conversationList).toEqual(listData.data)
})
expect(result.current.pinnedConversationList).toEqual(pinnedData.data)
expect(result.current.conversationList).toEqual(listData.data)
})
})

View File

@@ -193,4 +193,107 @@ describe('usePSInfo', () => {
domain: '.dify.ai',
})
})
// Cookie parse failure: covers catch block (L14-16)
it('should fall back to empty object when cookie contains invalid JSON', () => {
const { get } = ensureCookieMocks()
get.mockReturnValue('not-valid-json{{{')
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
setSearchParams({
ps_partner_key: 'from-url',
ps_xid: 'click-url',
})
const { result } = renderHook(() => usePSInfo())
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to parse partner stack info from cookie:',
expect.any(SyntaxError),
)
// Should still pick up values from search params
expect(result.current.psPartnerKey).toBe('from-url')
expect(result.current.psClickId).toBe('click-url')
consoleSpy.mockRestore()
})
// No keys at all: covers saveOrUpdate early return (L30) and bind no-op (L45 false branch)
it('should not save or bind when neither search params nor cookie have keys', () => {
const { get, set } = ensureCookieMocks()
get.mockReturnValue('{}')
setSearchParams({})
const { result } = renderHook(() => usePSInfo())
expect(result.current.psPartnerKey).toBeUndefined()
expect(result.current.psClickId).toBeUndefined()
act(() => {
result.current.saveOrUpdate()
})
expect(set).not.toHaveBeenCalled()
})
it('should not call mutateAsync when keys are missing during bind', async () => {
const { get } = ensureCookieMocks()
get.mockReturnValue('{}')
setSearchParams({})
const { result } = renderHook(() => usePSInfo())
const mutate = ensureMutateAsync()
await act(async () => {
await result.current.bind()
})
expect(mutate).not.toHaveBeenCalled()
})
// Non-400 error: covers L55 false branch (shouldRemoveCookie stays false)
it('should not remove cookie when bind fails with non-400 error', async () => {
const mutate = ensureMutateAsync()
mutate.mockRejectedValueOnce({ status: 500 })
setSearchParams({
ps_partner_key: 'bind-partner',
ps_xid: 'bind-click',
})
const { result } = renderHook(() => usePSInfo())
await act(async () => {
await result.current.bind()
})
const { remove } = ensureCookieMocks()
expect(remove).not.toHaveBeenCalled()
})
// Fallback to cookie values: covers L19-20 right side of || operator
it('should use cookie values when search params are absent', () => {
const { get } = ensureCookieMocks()
get.mockReturnValue(JSON.stringify({
partnerKey: 'cookie-partner',
clickId: 'cookie-click',
}))
setSearchParams({})
const { result } = renderHook(() => usePSInfo())
expect(result.current.psPartnerKey).toBe('cookie-partner')
expect(result.current.psClickId).toBe('cookie-click')
})
// Partial key missing: only partnerKey present, no clickId
it('should not save when only one key is available', () => {
const { get, set } = ensureCookieMocks()
get.mockReturnValue('{}')
setSearchParams({ ps_partner_key: 'partial-key' })
const { result } = renderHook(() => usePSInfo())
act(() => {
result.current.saveOrUpdate()
})
expect(set).not.toHaveBeenCalled()
})
})

View File

@@ -66,13 +66,6 @@ beforeAll(() => {
})
})
afterAll(() => {
Object.defineProperty(window, 'location', {
configurable: true,
value: originalLocation,
})
})
beforeEach(() => {
vi.clearAllMocks()
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: true })
@@ -82,6 +75,13 @@ beforeEach(() => {
assignedHref = ''
})
afterAll(() => {
Object.defineProperty(window, 'location', {
configurable: true,
value: originalLocation,
})
})
describe('CloudPlanItem', () => {
// Static content for each plan
describe('Rendering', () => {
@@ -192,5 +192,128 @@ describe('CloudPlanItem', () => {
expect(assignedHref).toBe('https://subscription.example')
})
})
// Covers L92-93: isFreePlan guard inside handleGetPayUrl
it('should do nothing when clicking sandbox plan CTA that is not the current plan', async () => {
render(
<CloudPlanItem
plan={Plan.sandbox}
currentPlan={Plan.professional}
planRange={PlanRange.monthly}
canPay
/>,
)
// Sandbox viewed from a higher plan is disabled, but let's verify no API calls
const button = screen.getByRole('button')
fireEvent.click(button)
await waitFor(() => {
expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled()
expect(mockBillingInvoices).not.toHaveBeenCalled()
expect(assignedHref).toBe('')
})
})
// Covers L95: yearly subscription URL ('year' parameter)
it('should fetch yearly subscription url when planRange is yearly', async () => {
render(
<CloudPlanItem
plan={Plan.team}
currentPlan={Plan.sandbox}
planRange={PlanRange.yearly}
canPay
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.getStarted' }))
await waitFor(() => {
expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.team, 'year')
expect(assignedHref).toBe('https://subscription.example')
})
})
// Covers L62-63: loading guard prevents double click
it('should ignore second click while loading', async () => {
// Make the first fetch hang until we resolve it
let resolveFirst!: (v: { url: string }) => void
mockFetchSubscriptionUrls.mockImplementationOnce(
() => new Promise((resolve) => { resolveFirst = resolve }),
)
render(
<CloudPlanItem
plan={Plan.professional}
currentPlan={Plan.sandbox}
planRange={PlanRange.monthly}
canPay
/>,
)
const button = screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' })
// First click starts loading
fireEvent.click(button)
// Second click while loading should be ignored
fireEvent.click(button)
// Resolve first request
resolveFirst({ url: 'https://first.example' })
await waitFor(() => {
expect(mockFetchSubscriptionUrls).toHaveBeenCalledTimes(1)
})
})
// Covers L82-83, L85-87: openAsyncWindow error path when invoices returns no url
it('should invoke onError when billing invoices returns empty url', async () => {
mockBillingInvoices.mockResolvedValue({ url: '' })
const openWindow = vi.fn(async (cb: () => Promise<string>, opts: { onError?: (e: Error) => void }) => {
try {
await cb()
}
catch (e) {
opts.onError?.(e as Error)
}
})
mockUseAsyncWindowOpen.mockReturnValue(openWindow)
render(
<CloudPlanItem
plan={Plan.professional}
currentPlan={Plan.professional}
planRange={PlanRange.monthly}
canPay
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.currentPlan' }))
await waitFor(() => {
expect(openWindow).toHaveBeenCalledTimes(1)
// The onError callback should have been passed to openAsyncWindow
const callArgs = openWindow.mock.calls[0]
expect(callArgs[1]).toHaveProperty('onError')
})
})
// Covers monthly price display (L139 !isYear branch for price)
it('should display monthly pricing without discount', () => {
render(
<CloudPlanItem
plan={Plan.team}
currentPlan={Plan.sandbox}
planRange={PlanRange.monthly}
canPay
/>,
)
const teamPlan = ALL_PLANS[Plan.team]
expect(screen.getByText(`$${teamPlan.price}`)).toBeInTheDocument()
expect(screen.getByText(/billing\.plansCommon\.priceTip.*billing\.plansCommon\.month/)).toBeInTheDocument()
// Should NOT show crossed-out yearly price
expect(screen.queryByText(`$${teamPlan.price * 12}`)).not.toBeInTheDocument()
})
})
})

View File

@@ -1,309 +1,111 @@
import type { QA } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { ChunkContainer, ChunkLabel, QAPreview } from './chunk'
vi.mock('../base/icons/src/public/knowledge', () => ({
SelectionMod: (props: React.ComponentProps<'svg'>) => (
<svg data-testid="selection-mod-icon" {...props} />
),
}))
function createQA(overrides: Partial<QA> = {}): QA {
return {
question: 'What is Dify?',
answer: 'Dify is an open-source LLM app development platform.',
...overrides,
}
}
afterEach(() => {
cleanup()
})
describe('ChunkLabel', () => {
beforeEach(() => {
vi.clearAllMocks()
it('should render label text', () => {
render(<ChunkLabel label="Chunk 1" characterCount={100} />)
expect(screen.getByText('Chunk 1')).toBeInTheDocument()
})
describe('Rendering', () => {
it('should render the label text', () => {
render(<ChunkLabel label="Chunk #1" characterCount={100} />)
expect(screen.getByText('Chunk #1')).toBeInTheDocument()
})
it('should render the character count with unit', () => {
render(<ChunkLabel label="Chunk #1" characterCount={256} />)
expect(screen.getByText('256 characters')).toBeInTheDocument()
})
it('should render the SelectionMod icon', () => {
render(<ChunkLabel label="Chunk" characterCount={10} />)
expect(screen.getByTestId('selection-mod-icon')).toBeInTheDocument()
})
it('should render a middle dot separator between label and count', () => {
render(<ChunkLabel label="Chunk" characterCount={10} />)
expect(screen.getByText('·')).toBeInTheDocument()
})
it('should render character count', () => {
render(<ChunkLabel label="Chunk 1" characterCount={150} />)
expect(screen.getByText('150 characters')).toBeInTheDocument()
})
describe('Props', () => {
it('should display zero character count', () => {
render(<ChunkLabel label="Empty Chunk" characterCount={0} />)
expect(screen.getByText('0 characters')).toBeInTheDocument()
})
it('should display large character counts', () => {
render(<ChunkLabel label="Large" characterCount={999999} />)
expect(screen.getByText('999999 characters')).toBeInTheDocument()
})
it('should render separator dot', () => {
render(<ChunkLabel label="Chunk 1" characterCount={100} />)
expect(screen.getByText('·')).toBeInTheDocument()
})
describe('Edge Cases', () => {
it('should render with empty label', () => {
render(<ChunkLabel label="" characterCount={50} />)
it('should render with zero character count', () => {
render(<ChunkLabel label="Empty Chunk" characterCount={0} />)
expect(screen.getByText('0 characters')).toBeInTheDocument()
})
expect(screen.getByText('50 characters')).toBeInTheDocument()
})
it('should render with special characters in label', () => {
render(<ChunkLabel label="Chunk <#1> & 'test'" characterCount={10} />)
expect(screen.getByText('Chunk <#1> & \'test\'')).toBeInTheDocument()
})
it('should render with large character count', () => {
render(<ChunkLabel label="Large Chunk" characterCount={999999} />)
expect(screen.getByText('999999 characters')).toBeInTheDocument()
})
})
// Tests for ChunkContainer - wraps ChunkLabel with children content area
describe('ChunkContainer', () => {
beforeEach(() => {
vi.clearAllMocks()
it('should render label and character count', () => {
render(<ChunkContainer label="Container 1" characterCount={200}>Content</ChunkContainer>)
expect(screen.getByText('Container 1')).toBeInTheDocument()
expect(screen.getByText('200 characters')).toBeInTheDocument()
})
describe('Rendering', () => {
it('should render ChunkLabel with correct props', () => {
render(
<ChunkContainer label="Chunk #1" characterCount={200}>
Content here
</ChunkContainer>,
)
expect(screen.getByText('Chunk #1')).toBeInTheDocument()
expect(screen.getByText('200 characters')).toBeInTheDocument()
})
it('should render children in the content area', () => {
render(
<ChunkContainer label="Chunk" characterCount={50}>
<p>Paragraph content</p>
</ChunkContainer>,
)
expect(screen.getByText('Paragraph content')).toBeInTheDocument()
})
it('should render the SelectionMod icon via ChunkLabel', () => {
render(
<ChunkContainer label="Chunk" characterCount={10}>
Content
</ChunkContainer>,
)
expect(screen.getByTestId('selection-mod-icon')).toBeInTheDocument()
})
it('should render children content', () => {
render(<ChunkContainer label="Container 1" characterCount={200}>Test Content</ChunkContainer>)
expect(screen.getByText('Test Content')).toBeInTheDocument()
})
describe('Structure', () => {
it('should have space-y-2 on the outer container', () => {
const { container } = render(
<ChunkContainer label="Chunk" characterCount={10}>Content</ChunkContainer>,
)
expect(container.firstElementChild).toHaveClass('space-y-2')
})
it('should render children inside a styled content div', () => {
render(
<ChunkContainer label="Chunk" characterCount={10}>
<span>Test child</span>
</ChunkContainer>,
)
const contentDiv = screen.getByText('Test child').parentElement
expect(contentDiv).toHaveClass('body-md-regular', 'text-text-secondary')
})
it('should render with complex children', () => {
render(
<ChunkContainer label="Container" characterCount={100}>
<div data-testid="child-div">
<span>Nested content</span>
</div>
</ChunkContainer>,
)
expect(screen.getByTestId('child-div')).toBeInTheDocument()
expect(screen.getByText('Nested content')).toBeInTheDocument()
})
describe('Edge Cases', () => {
it('should render without children', () => {
const { container } = render(
<ChunkContainer label="Empty" characterCount={0} />,
)
expect(container.firstElementChild).toBeInTheDocument()
expect(screen.getByText('Empty')).toBeInTheDocument()
})
it('should render multiple children', () => {
render(
<ChunkContainer label="Multi" characterCount={100}>
<span>First</span>
<span>Second</span>
</ChunkContainer>,
)
expect(screen.getByText('First')).toBeInTheDocument()
expect(screen.getByText('Second')).toBeInTheDocument()
})
it('should render with string children', () => {
render(
<ChunkContainer label="Text" characterCount={5}>
Plain text content
</ChunkContainer>,
)
expect(screen.getByText('Plain text content')).toBeInTheDocument()
})
it('should render empty children', () => {
render(<ChunkContainer label="Empty" characterCount={0}>{null}</ChunkContainer>)
expect(screen.getByText('Empty')).toBeInTheDocument()
})
})
// Tests for QAPreview - displays question and answer pair
describe('QAPreview', () => {
beforeEach(() => {
vi.clearAllMocks()
const mockQA = {
question: 'What is the meaning of life?',
answer: 'The meaning of life is 42.',
}
it('should render question text', () => {
render(<QAPreview qa={mockQA} />)
expect(screen.getByText('What is the meaning of life?')).toBeInTheDocument()
})
describe('Rendering', () => {
it('should render the question text', () => {
const qa = createQA()
render(<QAPreview qa={qa} />)
expect(screen.getByText('What is Dify?')).toBeInTheDocument()
})
it('should render the answer text', () => {
const qa = createQA()
render(<QAPreview qa={qa} />)
expect(screen.getByText('Dify is an open-source LLM app development platform.')).toBeInTheDocument()
})
it('should render Q and A labels', () => {
const qa = createQA()
render(<QAPreview qa={qa} />)
expect(screen.getByText('Q')).toBeInTheDocument()
expect(screen.getByText('A')).toBeInTheDocument()
})
it('should render answer text', () => {
render(<QAPreview qa={mockQA} />)
expect(screen.getByText('The meaning of life is 42.')).toBeInTheDocument()
})
describe('Structure', () => {
it('should render Q label as a label element', () => {
const qa = createQA()
render(<QAPreview qa={qa} />)
const qLabel = screen.getByText('Q')
expect(qLabel.tagName).toBe('LABEL')
})
it('should render A label as a label element', () => {
const qa = createQA()
render(<QAPreview qa={qa} />)
const aLabel = screen.getByText('A')
expect(aLabel.tagName).toBe('LABEL')
})
it('should render question in a p element', () => {
const qa = createQA()
render(<QAPreview qa={qa} />)
const questionEl = screen.getByText(qa.question)
expect(questionEl.tagName).toBe('P')
})
it('should render answer in a p element', () => {
const qa = createQA()
render(<QAPreview qa={qa} />)
const answerEl = screen.getByText(qa.answer)
expect(answerEl.tagName).toBe('P')
})
it('should have the outer container with flex column layout', () => {
const qa = createQA()
const { container } = render(<QAPreview qa={qa} />)
expect(container.firstElementChild).toHaveClass('flex', 'flex-col', 'gap-y-2')
})
it('should apply text styling classes to question paragraph', () => {
const qa = createQA()
render(<QAPreview qa={qa} />)
const questionEl = screen.getByText(qa.question)
expect(questionEl).toHaveClass('body-md-regular', 'text-text-secondary')
})
it('should apply text styling classes to answer paragraph', () => {
const qa = createQA()
render(<QAPreview qa={qa} />)
const answerEl = screen.getByText(qa.answer)
expect(answerEl).toHaveClass('body-md-regular', 'text-text-secondary')
})
it('should render Q label', () => {
render(<QAPreview qa={mockQA} />)
expect(screen.getByText('Q')).toBeInTheDocument()
})
describe('Edge Cases', () => {
it('should render with empty question', () => {
const qa = createQA({ question: '' })
render(<QAPreview qa={qa} />)
it('should render A label', () => {
render(<QAPreview qa={mockQA} />)
expect(screen.getByText('A')).toBeInTheDocument()
})
expect(screen.getByText('Q')).toBeInTheDocument()
expect(screen.getByText('A')).toBeInTheDocument()
})
it('should render with empty strings', () => {
render(<QAPreview qa={{ question: '', answer: '' }} />)
expect(screen.getByText('Q')).toBeInTheDocument()
expect(screen.getByText('A')).toBeInTheDocument()
})
it('should render with empty answer', () => {
const qa = createQA({ answer: '' })
render(<QAPreview qa={qa} />)
it('should render with long text', () => {
const longQuestion = 'Q'.repeat(500)
const longAnswer = 'A'.repeat(500)
render(<QAPreview qa={{ question: longQuestion, answer: longAnswer }} />)
expect(screen.getByText(longQuestion)).toBeInTheDocument()
expect(screen.getByText(longAnswer)).toBeInTheDocument()
})
expect(screen.getByText('Q')).toBeInTheDocument()
expect(screen.getByText(qa.question)).toBeInTheDocument()
})
it('should render with long text', () => {
const longText = 'x'.repeat(1000)
const qa = createQA({ question: longText, answer: longText })
render(<QAPreview qa={qa} />)
const elements = screen.getAllByText(longText)
expect(elements).toHaveLength(2)
})
it('should render with special characters in question and answer', () => {
const qa = createQA({
question: 'What about <html> & "quotes"?',
answer: 'It handles \'single\' & "double" quotes.',
})
render(<QAPreview qa={qa} />)
expect(screen.getByText('What about <html> & "quotes"?')).toBeInTheDocument()
expect(screen.getByText('It handles \'single\' & "double" quotes.')).toBeInTheDocument()
})
it('should render with multiline text', () => {
const qa = createQA({
question: 'Line1\nLine2',
answer: 'Answer1\nAnswer2',
})
render(<QAPreview qa={qa} />)
expect(screen.getByText(/Line1/)).toBeInTheDocument()
expect(screen.getByText(/Answer1/)).toBeInTheDocument()
})
it('should render with special characters', () => {
render(<QAPreview qa={{ question: 'What about <script>?', answer: '& special chars!' }} />)
expect(screen.getByText('What about <script>?')).toBeInTheDocument()
expect(screen.getByText('& special chars!')).toBeInTheDocument()
})
})

View File

@@ -1,49 +0,0 @@
import type { DocumentItem } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import DocumentList from './document-list'
vi.mock('../document-file-icon', () => ({
default: ({ name, extension }: { name?: string, extension?: string }) => (
<span data-testid="file-icon">
{name}
.
{extension}
</span>
),
}))
describe('DocumentList', () => {
const mockList = [
{ id: 'doc-1', name: 'report', extension: 'pdf' },
{ id: 'doc-2', name: 'data', extension: 'csv' },
] as DocumentItem[]
const onChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('should render all documents', () => {
render(<DocumentList list={mockList} onChange={onChange} />)
expect(screen.getByText('report')).toBeInTheDocument()
expect(screen.getByText('data')).toBeInTheDocument()
})
it('should render file icons', () => {
render(<DocumentList list={mockList} onChange={onChange} />)
expect(screen.getAllByTestId('file-icon')).toHaveLength(2)
})
it('should call onChange with document on click', () => {
render(<DocumentList list={mockList} onChange={onChange} />)
fireEvent.click(screen.getByText('report'))
expect(onChange).toHaveBeenCalledWith(mockList[0])
})
it('should render empty list without errors', () => {
const { container } = render(<DocumentList list={[]} onChange={onChange} />)
expect(container.firstChild).toBeInTheDocument()
})
})

View File

@@ -1,145 +0,0 @@
/* eslint-disable next/no-img-element */
import type { ReactNode } from 'react'
import type { IndexingStatusResponse } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DataSourceType } from '@/models/datasets'
import IndexingProgressItem from './indexing-progress-item'
vi.mock('next/image', () => ({
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => <img {...props} />,
}))
vi.mock('@/app/components/billing/priority-label', () => ({
default: () => <span data-testid="priority-label">Priority</span>,
}))
vi.mock('../../common/document-file-icon', () => ({
default: ({ name }: { name?: string }) => <span data-testid="file-icon">{name}</span>,
}))
vi.mock('@/app/components/base/notion-icon', () => ({
default: ({ src }: { src?: string }) => <span data-testid="notion-icon">{src}</span>,
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children, popupContent }: { children?: ReactNode, popupContent?: ReactNode }) => (
<div data-testid="tooltip" data-content={popupContent}>{children}</div>
),
}))
describe('IndexingProgressItem', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const makeDetail = (overrides: Partial<IndexingStatusResponse> = {}): IndexingStatusResponse => ({
id: 'doc-1',
indexing_status: 'indexing',
processing_started_at: 0,
parsing_completed_at: 0,
cleaning_completed_at: 0,
splitting_completed_at: 0,
completed_at: null,
paused_at: null,
error: null,
stopped_at: null,
completed_segments: 50,
total_segments: 100,
...overrides,
})
it('should render name and progress for embedding status', () => {
render(
<IndexingProgressItem
detail={makeDetail()}
name="test.pdf"
sourceType={DataSourceType.FILE}
/>,
)
// Name appears in both the file-icon mock and the display div; verify at least one
expect(screen.getAllByText('test.pdf').length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('50%')).toBeInTheDocument()
})
it('should render file icon for FILE source type', () => {
render(
<IndexingProgressItem
detail={makeDetail()}
name="report.docx"
sourceType={DataSourceType.FILE}
/>,
)
expect(screen.getByTestId('file-icon')).toBeInTheDocument()
})
it('should render notion icon for NOTION source type', () => {
render(
<IndexingProgressItem
detail={makeDetail()}
name="My Page"
sourceType={DataSourceType.NOTION}
notionIcon="notion-icon-url"
/>,
)
expect(screen.getByTestId('notion-icon')).toBeInTheDocument()
})
it('should render success icon for completed status', () => {
render(
<IndexingProgressItem
detail={makeDetail({ indexing_status: 'completed' })}
name="done.pdf"
/>,
)
// No progress percentage should be shown for completed
expect(screen.queryByText('%')).not.toBeInTheDocument()
})
it('should render error icon with tooltip for error status', () => {
render(
<IndexingProgressItem
detail={makeDetail({ indexing_status: 'error', error: 'Parse failed' })}
name="broken.pdf"
/>,
)
expect(screen.getByTestId('tooltip')).toHaveAttribute('data-content', 'Parse failed')
})
it('should show priority label when billing is enabled', () => {
render(
<IndexingProgressItem
detail={makeDetail()}
name="test.pdf"
enableBilling={true}
/>,
)
expect(screen.getByTestId('priority-label')).toBeInTheDocument()
})
it('should not show priority label when billing is disabled', () => {
render(
<IndexingProgressItem
detail={makeDetail()}
name="test.pdf"
enableBilling={false}
/>,
)
expect(screen.queryByTestId('priority-label')).not.toBeInTheDocument()
})
it('should apply error styling for error status', () => {
const { container } = render(
<IndexingProgressItem
detail={makeDetail({ indexing_status: 'error' })}
name="error.pdf"
/>,
)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('bg-state-destructive-hover-alt')
})
})

View File

@@ -1,154 +0,0 @@
/* eslint-disable next/no-img-element */
import type { ProcessRuleResponse } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ProcessMode } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import RuleDetail from './rule-detail'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) => `${opts?.ns ? `${opts.ns}.` : ''}${key}`,
}),
}))
vi.mock('next/image', () => ({
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => <img {...props} />,
}))
vi.mock('@/app/components/datasets/documents/detail/metadata', () => ({
FieldInfo: ({ label, displayedValue }: { label: string, displayedValue: string }) => (
<div data-testid="field-info">
<span data-testid="field-label">{label}</span>
<span data-testid="field-value">{displayedValue}</span>
</div>
),
}))
vi.mock('../icons', () => ({
indexMethodIcon: { economical: '/icons/economical.svg', high_quality: '/icons/hq.svg' },
retrievalIcon: { fullText: '/icons/ft.svg', hybrid: '/icons/hy.svg', vector: '/icons/vec.svg' },
}))
describe('RuleDetail', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const makeSourceData = (overrides: Partial<ProcessRuleResponse> = {}): ProcessRuleResponse => ({
mode: ProcessMode.general,
rules: {
segmentation: { separator: '\n', max_tokens: 500, chunk_overlap: 50 },
pre_processing_rules: [
{ id: 'remove_extra_spaces', enabled: true },
{ id: 'remove_urls_emails', enabled: false },
],
},
...overrides,
} as ProcessRuleResponse)
it('should render mode, segment length, text cleaning, index mode, and retrieval fields', () => {
render(
<RuleDetail
sourceData={makeSourceData()}
indexingType="high_quality"
retrievalMethod={RETRIEVE_METHOD.semantic}
/>,
)
const fieldInfos = screen.getAllByTestId('field-info')
// mode, segmentLength, textCleaning, indexMode, retrievalSetting = 5
expect(fieldInfos.length).toBe(5)
})
it('should display "custom" for general mode', () => {
render(
<RuleDetail
sourceData={makeSourceData({ mode: ProcessMode.general })}
indexingType="high_quality"
/>,
)
const values = screen.getAllByTestId('field-value')
expect(values[0].textContent).toContain('embedding.custom')
})
it('should display hierarchical mode with parent mode label', () => {
render(
<RuleDetail
sourceData={makeSourceData({
mode: ProcessMode.parentChild,
rules: {
parent_mode: 'paragraph',
segmentation: { separator: '\n', max_tokens: 1000, chunk_overlap: 50 },
subchunk_segmentation: { max_tokens: 200 },
pre_processing_rules: [],
} as unknown as ProcessRuleResponse['rules'],
})}
indexingType="high_quality"
/>,
)
const values = screen.getAllByTestId('field-value')
expect(values[0].textContent).toContain('embedding.hierarchical')
})
it('should display "-" when no sourceData mode', () => {
render(
<RuleDetail
sourceData={makeSourceData({ mode: undefined as unknown as ProcessMode })}
indexingType="high_quality"
/>,
)
const values = screen.getAllByTestId('field-value')
expect(values[0].textContent).toBe('-')
})
it('should display segment length for general mode', () => {
render(
<RuleDetail
sourceData={makeSourceData()}
indexingType="high_quality"
/>,
)
const values = screen.getAllByTestId('field-value')
expect(values[1].textContent).toBe('500')
})
it('should display enabled pre-processing rules', () => {
render(
<RuleDetail
sourceData={makeSourceData()}
indexingType="high_quality"
/>,
)
const values = screen.getAllByTestId('field-value')
// Only remove_extra_spaces is enabled
expect(values[2].textContent).toContain('stepTwo.removeExtraSpaces')
})
it('should display economical index mode', () => {
render(
<RuleDetail
sourceData={makeSourceData()}
indexingType="economy"
/>,
)
const values = screen.getAllByTestId('field-value')
// Index mode field is 4th (index 3)
expect(values[3].textContent).toContain('stepTwo.economical')
})
it('should display qualified index mode for high_quality', () => {
render(
<RuleDetail
sourceData={makeSourceData()}
indexingType="high_quality"
/>,
)
const values = screen.getAllByTestId('field-value')
expect(values[3].textContent).toContain('stepTwo.qualified')
})
})

View File

@@ -1,34 +0,0 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import UpgradeBanner from './upgrade-banner'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/app/components/base/icons/src/vender/solid/general', () => ({
ZapFast: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="zap-icon" {...props} />,
}))
vi.mock('@/app/components/billing/upgrade-btn', () => ({
default: ({ loc }: { loc: string }) => <button data-testid="upgrade-btn" data-loc={loc}>Upgrade</button>,
}))
describe('UpgradeBanner', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render the banner with icon, text, and upgrade button', () => {
render(<UpgradeBanner />)
expect(screen.getByTestId('zap-icon')).toBeInTheDocument()
expect(screen.getByText('plansCommon.documentProcessingPriorityUpgrade')).toBeInTheDocument()
expect(screen.getByTestId('upgrade-btn')).toBeInTheDocument()
})
it('should pass correct loc to UpgradeBtn', () => {
render(<UpgradeBanner />)
expect(screen.getByTestId('upgrade-btn')).toHaveAttribute('data-loc', 'knowledge-speed-up')
})
})

View File

@@ -1,179 +0,0 @@
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useIndexingStatusPolling } from './use-indexing-status-polling'
const mockFetchIndexingStatusBatch = vi.fn()
vi.mock('@/service/datasets', () => ({
fetchIndexingStatusBatch: (...args: unknown[]) => mockFetchIndexingStatusBatch(...args),
}))
describe('useIndexingStatusPolling', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
})
afterEach(() => {
vi.useRealTimers()
})
const defaultParams = { datasetId: 'ds-1', batchId: 'batch-1' }
it('should initialize with empty status list', async () => {
mockFetchIndexingStatusBatch.mockReturnValue(new Promise(() => {}))
const { result } = renderHook(() => useIndexingStatusPolling(defaultParams))
expect(result.current.statusList).toEqual([])
expect(result.current.isEmbedding).toBe(false)
expect(result.current.isEmbeddingCompleted).toBe(false)
})
it('should fetch status on mount and update state', async () => {
mockFetchIndexingStatusBatch.mockResolvedValue({
data: [{ indexing_status: 'indexing', completed_segments: 5, total_segments: 10 }],
})
const { result } = renderHook(() => useIndexingStatusPolling(defaultParams))
// Flush the resolved promise
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
expect(mockFetchIndexingStatusBatch).toHaveBeenCalledWith({
datasetId: 'ds-1',
batchId: 'batch-1',
})
expect(result.current.statusList).toHaveLength(1)
expect(result.current.isEmbedding).toBe(true)
expect(result.current.isEmbeddingCompleted).toBe(false)
})
it('should stop polling when all completed', async () => {
mockFetchIndexingStatusBatch.mockResolvedValue({
data: [{ indexing_status: 'completed' }],
})
const { result } = renderHook(() => useIndexingStatusPolling(defaultParams))
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
expect(result.current.isEmbeddingCompleted).toBe(true)
expect(result.current.isEmbedding).toBe(false)
// Should not schedule another poll
const callCount = mockFetchIndexingStatusBatch.mock.calls.length
await act(async () => {
await vi.advanceTimersByTimeAsync(5000)
})
expect(mockFetchIndexingStatusBatch).toHaveBeenCalledTimes(callCount)
})
it('should continue polling on fetch error', async () => {
mockFetchIndexingStatusBatch
.mockRejectedValueOnce(new Error('network'))
.mockResolvedValueOnce({
data: [{ indexing_status: 'completed' }],
})
const { result } = renderHook(() => useIndexingStatusPolling(defaultParams))
// First call: rejects
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
// Advance past polling interval for retry
await act(async () => {
await vi.advanceTimersByTimeAsync(2500)
})
expect(result.current.isEmbeddingCompleted).toBe(true)
})
it('should detect embedding statuses', async () => {
mockFetchIndexingStatusBatch.mockResolvedValue({
data: [
{ indexing_status: 'splitting' },
{ indexing_status: 'parsing' },
],
})
const { result } = renderHook(() => useIndexingStatusPolling(defaultParams))
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
expect(result.current.isEmbedding).toBe(true)
expect(result.current.isEmbeddingCompleted).toBe(false)
})
it('should detect mixed statuses (some completed, some embedding)', async () => {
mockFetchIndexingStatusBatch.mockResolvedValue({
data: [
{ indexing_status: 'completed' },
{ indexing_status: 'indexing' },
],
})
const { result } = renderHook(() => useIndexingStatusPolling(defaultParams))
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
expect(result.current.statusList).toHaveLength(2)
expect(result.current.isEmbedding).toBe(true)
expect(result.current.isEmbeddingCompleted).toBe(false)
})
it('should cleanup on unmount', async () => {
mockFetchIndexingStatusBatch.mockResolvedValue({
data: [{ indexing_status: 'indexing' }],
})
const { unmount } = renderHook(() => useIndexingStatusPolling(defaultParams))
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
const callCount = mockFetchIndexingStatusBatch.mock.calls.length
unmount()
await act(async () => {
await vi.advanceTimersByTimeAsync(5000)
})
expect(mockFetchIndexingStatusBatch).toHaveBeenCalledTimes(callCount)
})
it('should treat error and paused as completed statuses', async () => {
mockFetchIndexingStatusBatch.mockResolvedValue({
data: [
{ indexing_status: 'error' },
{ indexing_status: 'paused' },
],
})
const { result } = renderHook(() => useIndexingStatusPolling(defaultParams))
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
expect(result.current.isEmbeddingCompleted).toBe(true)
expect(result.current.isEmbedding).toBe(false)
})
it('should poll at 2500ms intervals', async () => {
mockFetchIndexingStatusBatch.mockResolvedValue({
data: [{ indexing_status: 'indexing' }],
})
renderHook(() => useIndexingStatusPolling(defaultParams))
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
expect(mockFetchIndexingStatusBatch).toHaveBeenCalledTimes(1)
await act(async () => {
await vi.advanceTimersByTimeAsync(2500)
})
expect(mockFetchIndexingStatusBatch).toHaveBeenCalledTimes(2)
})
})

View File

@@ -1,140 +0,0 @@
import type { DataSourceInfo, FullDocumentDetail, IndexingStatusResponse } from '@/models/datasets'
import { describe, expect, it } from 'vitest'
import { createDocumentLookup, getFileType, getSourcePercent, isLegacyDataSourceInfo, isSourceEmbedding } from './utils'
describe('isLegacyDataSourceInfo', () => {
it('should return true when upload_file object exists', () => {
const info = { upload_file: { id: '1', name: 'test.pdf' } } as unknown as DataSourceInfo
expect(isLegacyDataSourceInfo(info)).toBe(true)
})
it('should return false when upload_file is absent', () => {
const info = { notion_page_icon: 'icon' } as unknown as DataSourceInfo
expect(isLegacyDataSourceInfo(info)).toBe(false)
})
it('should return false for null', () => {
expect(isLegacyDataSourceInfo(null as unknown as DataSourceInfo)).toBe(false)
})
it('should return false when upload_file is a string', () => {
const info = { upload_file: 'not-an-object' } as unknown as DataSourceInfo
expect(isLegacyDataSourceInfo(info)).toBe(false)
})
})
describe('isSourceEmbedding', () => {
const embeddingStatuses = ['indexing', 'splitting', 'parsing', 'cleaning', 'waiting']
const nonEmbeddingStatuses = ['completed', 'error', 'paused', 'unknown']
it.each(embeddingStatuses)('should return true for status "%s"', (status) => {
expect(isSourceEmbedding({ indexing_status: status } as IndexingStatusResponse)).toBe(true)
})
it.each(nonEmbeddingStatuses)('should return false for status "%s"', (status) => {
expect(isSourceEmbedding({ indexing_status: status } as IndexingStatusResponse)).toBe(false)
})
})
describe('getSourcePercent', () => {
it('should calculate correct percentage', () => {
expect(getSourcePercent({ completed_segments: 50, total_segments: 100 } as IndexingStatusResponse)).toBe(50)
})
it('should return 0 when total is 0', () => {
expect(getSourcePercent({ completed_segments: 0, total_segments: 0 } as IndexingStatusResponse)).toBe(0)
})
it('should cap at 100', () => {
expect(getSourcePercent({ completed_segments: 150, total_segments: 100 } as IndexingStatusResponse)).toBe(100)
})
it('should round to nearest integer', () => {
expect(getSourcePercent({ completed_segments: 1, total_segments: 3 } as IndexingStatusResponse)).toBe(33)
})
it('should handle undefined segments as 0', () => {
expect(getSourcePercent({} as IndexingStatusResponse)).toBe(0)
})
})
describe('getFileType', () => {
it('should extract extension from filename', () => {
expect(getFileType('document.pdf')).toBe('pdf')
})
it('should return last extension for multi-dot names', () => {
expect(getFileType('archive.tar.gz')).toBe('gz')
})
it('should default to "txt" for undefined', () => {
expect(getFileType(undefined)).toBe('txt')
})
it('should default to "txt" for empty string', () => {
expect(getFileType('')).toBe('txt')
})
})
describe('createDocumentLookup', () => {
const documents = [
{
id: 'doc-1',
name: 'test.pdf',
data_source_type: 'upload_file',
data_source_info: {
upload_file: { id: 'f1', name: 'test.pdf' },
notion_page_icon: undefined,
},
},
{
id: 'doc-2',
name: 'notion-page',
data_source_type: 'notion_import',
data_source_info: {
upload_file: { id: 'f2', name: '' },
notion_page_icon: 'https://icon.url',
},
},
] as unknown as FullDocumentDetail[]
it('should get document by id', () => {
const lookup = createDocumentLookup(documents)
expect(lookup.getDocument('doc-1')).toBe(documents[0])
})
it('should return undefined for non-existent id', () => {
const lookup = createDocumentLookup(documents)
expect(lookup.getDocument('non-existent')).toBeUndefined()
})
it('should get name by id', () => {
const lookup = createDocumentLookup(documents)
expect(lookup.getName('doc-1')).toBe('test.pdf')
})
it('should get source type by id', () => {
const lookup = createDocumentLookup(documents)
expect(lookup.getSourceType('doc-1')).toBe('upload_file')
})
it('should get notion icon for legacy data source', () => {
const lookup = createDocumentLookup(documents)
expect(lookup.getNotionIcon('doc-2')).toBe('https://icon.url')
})
it('should return undefined notion icon for non-legacy info', () => {
const docs = [{
id: 'doc-3',
data_source_info: { some_other: 'field' },
}] as unknown as FullDocumentDetail[]
const lookup = createDocumentLookup(docs)
expect(lookup.getNotionIcon('doc-3')).toBeUndefined()
})
it('should handle empty documents list', () => {
const lookup = createDocumentLookup([])
expect(lookup.getDocument('any')).toBeUndefined()
expect(lookup.getName('any')).toBeUndefined()
})
})

View File

@@ -1,66 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DataSourceType } from '@/models/datasets'
// Mock config to control web crawl feature flags
vi.mock('@/config', () => ({
ENABLE_WEBSITE_FIRECRAWL: true,
ENABLE_WEBSITE_JINAREADER: true,
ENABLE_WEBSITE_WATERCRAWL: false,
}))
// Mock CSS module
vi.mock('../../index.module.css', () => ({
default: {
dataSourceItem: 'ds-item',
active: 'active',
disabled: 'disabled',
datasetIcon: 'icon',
notion: 'notion-icon',
web: 'web-icon',
},
}))
const { default: DataSourceTypeSelector } = await import('./data-source-type-selector')
describe('DataSourceTypeSelector', () => {
const defaultProps = {
currentType: DataSourceType.FILE,
disabled: false,
onChange: vi.fn(),
onClearPreviews: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('rendering', () => {
it('should render file, notion, and web options', () => {
render(<DataSourceTypeSelector {...defaultProps} />)
expect(screen.getByText('datasetCreation.stepOne.dataSourceType.file')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepOne.dataSourceType.notion')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepOne.dataSourceType.web')).toBeInTheDocument()
})
it('should render as a 3-column grid', () => {
const { container } = render(<DataSourceTypeSelector {...defaultProps} />)
expect(container.firstElementChild).toHaveClass('grid-cols-3')
})
})
describe('interactions', () => {
it('should call onChange and onClearPreviews on type click', () => {
render(<DataSourceTypeSelector {...defaultProps} />)
fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.notion'))
expect(defaultProps.onChange).toHaveBeenCalledWith(DataSourceType.NOTION)
expect(defaultProps.onClearPreviews).toHaveBeenCalledWith(DataSourceType.NOTION)
})
it('should not call onChange when disabled', () => {
render(<DataSourceTypeSelector {...defaultProps} disabled />)
fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.notion'))
expect(defaultProps.onChange).not.toHaveBeenCalled()
})
})
})

View File

@@ -1,48 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import NextStepButton from './next-step-button'
describe('NextStepButton', () => {
const defaultProps = {
disabled: false,
onClick: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render button text', () => {
render(<NextStepButton {...defaultProps} />)
expect(screen.getByText('datasetCreation.stepOne.button')).toBeInTheDocument()
})
it('should render a primary variant button', () => {
render(<NextStepButton {...defaultProps} />)
const btn = screen.getByRole('button')
expect(btn).toBeInTheDocument()
})
it('should call onClick when clicked', () => {
render(<NextStepButton {...defaultProps} />)
fireEvent.click(screen.getByRole('button'))
expect(defaultProps.onClick).toHaveBeenCalledOnce()
})
it('should be disabled when disabled prop is true', () => {
render(<NextStepButton disabled onClick={defaultProps.onClick} />)
expect(screen.getByRole('button')).toBeDisabled()
})
it('should not call onClick when disabled', () => {
render(<NextStepButton disabled onClick={defaultProps.onClick} />)
fireEvent.click(screen.getByRole('button'))
expect(defaultProps.onClick).not.toHaveBeenCalled()
})
it('should render arrow icon', () => {
const { container } = render(<NextStepButton {...defaultProps} />)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
})

View File

@@ -1,119 +0,0 @@
import type { NotionPage } from '@/models/common'
import type { CrawlResultItem } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Mock child components - paths must match source file's imports (relative to source)
vi.mock('../../file-preview', () => ({
default: ({ file, hidePreview }: { file: { name: string }, hidePreview: () => void }) => (
<div data-testid="file-preview">
<span>{file.name}</span>
<button data-testid="close-file" onClick={hidePreview}>close-file</button>
</div>
),
}))
vi.mock('../../notion-page-preview', () => ({
default: ({ currentPage, hidePreview }: { currentPage: { page_name: string }, hidePreview: () => void }) => (
<div data-testid="notion-preview">
<span>{currentPage.page_name}</span>
<button data-testid="close-notion" onClick={hidePreview}>close-notion</button>
</div>
),
}))
vi.mock('../../website/preview', () => ({
default: ({ payload, hidePreview }: { payload: { title: string }, hidePreview: () => void }) => (
<div data-testid="website-preview">
<span>{payload.title}</span>
<button data-testid="close-website" onClick={hidePreview}>close-website</button>
</div>
),
}))
vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({
default: ({ show, onClose, title }: { show: boolean, onClose: () => void, title: string }) => show
? (
<div data-testid="plan-upgrade-modal">
<span>{title}</span>
<button data-testid="close-modal" onClick={onClose}>close-modal</button>
</div>
)
: null,
}))
const { default: PreviewPanel } = await import('./preview-panel')
describe('PreviewPanel', () => {
const defaultProps = {
currentFile: undefined,
currentNotionPage: undefined,
currentWebsite: undefined,
notionCredentialId: 'cred-1',
isShowPlanUpgradeModal: false,
hideFilePreview: vi.fn(),
hideNotionPagePreview: vi.fn(),
hideWebsitePreview: vi.fn(),
hidePlanUpgradeModal: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('rendering', () => {
it('should render nothing when no preview is active', () => {
const { container } = render(<PreviewPanel {...defaultProps} />)
expect(container.querySelector('[data-testid]')).toBeNull()
})
it('should render file preview when currentFile is set', () => {
render(<PreviewPanel {...defaultProps} currentFile={{ name: 'test.pdf' } as unknown as File} />)
expect(screen.getByTestId('file-preview')).toBeInTheDocument()
expect(screen.getByText('test.pdf')).toBeInTheDocument()
})
it('should render notion preview when currentNotionPage is set', () => {
render(<PreviewPanel {...defaultProps} currentNotionPage={{ page_name: 'My Page' } as unknown as NotionPage} />)
expect(screen.getByTestId('notion-preview')).toBeInTheDocument()
expect(screen.getByText('My Page')).toBeInTheDocument()
})
it('should render website preview when currentWebsite is set', () => {
render(<PreviewPanel {...defaultProps} currentWebsite={{ title: 'My Site' } as unknown as CrawlResultItem} />)
expect(screen.getByTestId('website-preview')).toBeInTheDocument()
expect(screen.getByText('My Site')).toBeInTheDocument()
})
it('should render plan upgrade modal when isShowPlanUpgradeModal is true', () => {
render(<PreviewPanel {...defaultProps} isShowPlanUpgradeModal />)
expect(screen.getByTestId('plan-upgrade-modal')).toBeInTheDocument()
})
})
describe('interactions', () => {
it('should call hideFilePreview when file preview close clicked', () => {
render(<PreviewPanel {...defaultProps} currentFile={{ name: 'test.pdf' } as unknown as File} />)
fireEvent.click(screen.getByTestId('close-file'))
expect(defaultProps.hideFilePreview).toHaveBeenCalledOnce()
})
it('should call hidePlanUpgradeModal when modal close clicked', () => {
render(<PreviewPanel {...defaultProps} isShowPlanUpgradeModal />)
fireEvent.click(screen.getByTestId('close-modal'))
expect(defaultProps.hidePlanUpgradeModal).toHaveBeenCalledOnce()
})
it('should call hideNotionPagePreview when notion preview close clicked', () => {
render(<PreviewPanel {...defaultProps} currentNotionPage={{ page_name: 'My Page' } as unknown as NotionPage} />)
fireEvent.click(screen.getByTestId('close-notion'))
expect(defaultProps.hideNotionPagePreview).toHaveBeenCalledOnce()
})
it('should call hideWebsitePreview when website preview close clicked', () => {
render(<PreviewPanel {...defaultProps} currentWebsite={{ title: 'My Site' } as unknown as CrawlResultItem} />)
fireEvent.click(screen.getByTestId('close-website'))
expect(defaultProps.hideWebsitePreview).toHaveBeenCalledOnce()
})
})
})

View File

@@ -1,60 +0,0 @@
import type { NotionPage } from '@/models/common'
import type { CrawlResultItem } from '@/models/datasets'
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import usePreviewState from './use-preview-state'
describe('usePreviewState', () => {
it('should initialize with all previews undefined', () => {
const { result } = renderHook(() => usePreviewState())
expect(result.current.currentFile).toBeUndefined()
expect(result.current.currentNotionPage).toBeUndefined()
expect(result.current.currentWebsite).toBeUndefined()
})
it('should show and hide file preview', () => {
const { result } = renderHook(() => usePreviewState())
const file = new File(['content'], 'test.pdf')
act(() => {
result.current.showFilePreview(file)
})
expect(result.current.currentFile).toBe(file)
act(() => {
result.current.hideFilePreview()
})
expect(result.current.currentFile).toBeUndefined()
})
it('should show and hide notion page preview', () => {
const { result } = renderHook(() => usePreviewState())
const page = { page_id: 'p1', page_name: 'Test' } as unknown as NotionPage
act(() => {
result.current.showNotionPagePreview(page)
})
expect(result.current.currentNotionPage).toBe(page)
act(() => {
result.current.hideNotionPagePreview()
})
expect(result.current.currentNotionPage).toBeUndefined()
})
it('should show and hide website preview', () => {
const { result } = renderHook(() => usePreviewState())
const website = { title: 'Example', source_url: 'https://example.com' } as unknown as CrawlResultItem
act(() => {
result.current.showWebsitePreview(website)
})
expect(result.current.currentWebsite).toBe(website)
act(() => {
result.current.hideWebsitePreview()
})
expect(result.current.currentWebsite).toBeUndefined()
})
})

View File

@@ -1,9 +1,11 @@
import type { DataSourceAuth } from '@/app/components/header/account-setting/data-source-page-new/types'
import type { NotionPage } from '@/models/common'
import type { CrawlOptions, CrawlResultItem, DataSet, FileItem } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { act, fireEvent, render, renderHook, screen } from '@testing-library/react'
import { Plan } from '@/app/components/billing/type'
import { DataSourceType } from '@/models/datasets'
import { DataSourceTypeSelector, NextStepButton, PreviewPanel } from './components'
import { usePreviewState } from './hooks'
import StepOne from './index'
// ==========================================
@@ -209,14 +211,541 @@ const defaultProps = {
}
// ==========================================
// NOTE: Child component unit tests (usePreviewState, DataSourceTypeSelector,
// NextStepButton, PreviewPanel) have been moved to their own dedicated spec files:
// - ./hooks/use-preview-state.spec.ts
// - ./components/data-source-type-selector.spec.tsx
// - ./components/next-step-button.spec.tsx
// - ./components/preview-panel.spec.tsx
// This file now focuses exclusively on StepOne parent component tests.
// usePreviewState Hook Tests
// ==========================================
describe('usePreviewState Hook', () => {
// --------------------------------------------------------------------------
// Initial State Tests
// --------------------------------------------------------------------------
describe('Initial State', () => {
it('should initialize with all preview states undefined', () => {
// Arrange & Act
const { result } = renderHook(() => usePreviewState())
// Assert
expect(result.current.currentFile).toBeUndefined()
expect(result.current.currentNotionPage).toBeUndefined()
expect(result.current.currentWebsite).toBeUndefined()
})
})
// --------------------------------------------------------------------------
// File Preview Tests
// --------------------------------------------------------------------------
describe('File Preview', () => {
it('should show file preview when showFilePreview is called', () => {
// Arrange
const { result } = renderHook(() => usePreviewState())
const mockFile = new File(['test'], 'test.txt')
// Act
act(() => {
result.current.showFilePreview(mockFile)
})
// Assert
expect(result.current.currentFile).toBe(mockFile)
})
it('should hide file preview when hideFilePreview is called', () => {
// Arrange
const { result } = renderHook(() => usePreviewState())
const mockFile = new File(['test'], 'test.txt')
act(() => {
result.current.showFilePreview(mockFile)
})
// Act
act(() => {
result.current.hideFilePreview()
})
// Assert
expect(result.current.currentFile).toBeUndefined()
})
})
// --------------------------------------------------------------------------
// Notion Page Preview Tests
// --------------------------------------------------------------------------
describe('Notion Page Preview', () => {
it('should show notion page preview when showNotionPagePreview is called', () => {
// Arrange
const { result } = renderHook(() => usePreviewState())
const mockPage = createMockNotionPage()
// Act
act(() => {
result.current.showNotionPagePreview(mockPage)
})
// Assert
expect(result.current.currentNotionPage).toBe(mockPage)
})
it('should hide notion page preview when hideNotionPagePreview is called', () => {
// Arrange
const { result } = renderHook(() => usePreviewState())
const mockPage = createMockNotionPage()
act(() => {
result.current.showNotionPagePreview(mockPage)
})
// Act
act(() => {
result.current.hideNotionPagePreview()
})
// Assert
expect(result.current.currentNotionPage).toBeUndefined()
})
})
// --------------------------------------------------------------------------
// Website Preview Tests
// --------------------------------------------------------------------------
describe('Website Preview', () => {
it('should show website preview when showWebsitePreview is called', () => {
// Arrange
const { result } = renderHook(() => usePreviewState())
const mockWebsite = createMockCrawlResult()
// Act
act(() => {
result.current.showWebsitePreview(mockWebsite)
})
// Assert
expect(result.current.currentWebsite).toBe(mockWebsite)
})
it('should hide website preview when hideWebsitePreview is called', () => {
// Arrange
const { result } = renderHook(() => usePreviewState())
const mockWebsite = createMockCrawlResult()
act(() => {
result.current.showWebsitePreview(mockWebsite)
})
// Act
act(() => {
result.current.hideWebsitePreview()
})
// Assert
expect(result.current.currentWebsite).toBeUndefined()
})
})
// --------------------------------------------------------------------------
// Callback Stability Tests (Memoization)
// --------------------------------------------------------------------------
describe('Callback Stability', () => {
it('should maintain stable showFilePreview callback reference', () => {
// Arrange
const { result, rerender } = renderHook(() => usePreviewState())
const initialCallback = result.current.showFilePreview
// Act
rerender()
// Assert
expect(result.current.showFilePreview).toBe(initialCallback)
})
it('should maintain stable hideFilePreview callback reference', () => {
// Arrange
const { result, rerender } = renderHook(() => usePreviewState())
const initialCallback = result.current.hideFilePreview
// Act
rerender()
// Assert
expect(result.current.hideFilePreview).toBe(initialCallback)
})
it('should maintain stable showNotionPagePreview callback reference', () => {
// Arrange
const { result, rerender } = renderHook(() => usePreviewState())
const initialCallback = result.current.showNotionPagePreview
// Act
rerender()
// Assert
expect(result.current.showNotionPagePreview).toBe(initialCallback)
})
it('should maintain stable hideNotionPagePreview callback reference', () => {
// Arrange
const { result, rerender } = renderHook(() => usePreviewState())
const initialCallback = result.current.hideNotionPagePreview
// Act
rerender()
// Assert
expect(result.current.hideNotionPagePreview).toBe(initialCallback)
})
it('should maintain stable showWebsitePreview callback reference', () => {
// Arrange
const { result, rerender } = renderHook(() => usePreviewState())
const initialCallback = result.current.showWebsitePreview
// Act
rerender()
// Assert
expect(result.current.showWebsitePreview).toBe(initialCallback)
})
it('should maintain stable hideWebsitePreview callback reference', () => {
// Arrange
const { result, rerender } = renderHook(() => usePreviewState())
const initialCallback = result.current.hideWebsitePreview
// Act
rerender()
// Assert
expect(result.current.hideWebsitePreview).toBe(initialCallback)
})
})
})
// ==========================================
// DataSourceTypeSelector Component Tests
// ==========================================
describe('DataSourceTypeSelector', () => {
const defaultSelectorProps = {
currentType: DataSourceType.FILE,
disabled: false,
onChange: vi.fn(),
onClearPreviews: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render all data source options when web is enabled', () => {
// Arrange & Act
render(<DataSourceTypeSelector {...defaultSelectorProps} />)
// Assert
expect(screen.getByText('datasetCreation.stepOne.dataSourceType.file')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepOne.dataSourceType.notion')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepOne.dataSourceType.web')).toBeInTheDocument()
})
it('should highlight active type', () => {
// Arrange & Act
const { container } = render(
<DataSourceTypeSelector {...defaultSelectorProps} currentType={DataSourceType.NOTION} />,
)
// Assert - The active item should have the active class
const items = container.querySelectorAll('[class*="dataSourceItem"]')
expect(items.length).toBeGreaterThan(0)
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call onChange when a type is clicked', () => {
// Arrange
const onChange = vi.fn()
render(<DataSourceTypeSelector {...defaultSelectorProps} onChange={onChange} />)
// Act
fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.notion'))
// Assert
expect(onChange).toHaveBeenCalledWith(DataSourceType.NOTION)
})
it('should call onClearPreviews when a type is clicked', () => {
// Arrange
const onClearPreviews = vi.fn()
render(<DataSourceTypeSelector {...defaultSelectorProps} onClearPreviews={onClearPreviews} />)
// Act
fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.web'))
// Assert
expect(onClearPreviews).toHaveBeenCalledWith(DataSourceType.WEB)
})
it('should not call onChange when disabled', () => {
// Arrange
const onChange = vi.fn()
render(<DataSourceTypeSelector {...defaultSelectorProps} disabled onChange={onChange} />)
// Act
fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.notion'))
// Assert
expect(onChange).not.toHaveBeenCalled()
})
it('should not call onClearPreviews when disabled', () => {
// Arrange
const onClearPreviews = vi.fn()
render(<DataSourceTypeSelector {...defaultSelectorProps} disabled onClearPreviews={onClearPreviews} />)
// Act
fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.notion'))
// Assert
expect(onClearPreviews).not.toHaveBeenCalled()
})
})
})
// ==========================================
// NextStepButton Component Tests
// ==========================================
describe('NextStepButton', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render with correct label', () => {
// Arrange & Act
render(<NextStepButton disabled={false} onClick={vi.fn()} />)
// Assert
expect(screen.getByText('datasetCreation.stepOne.button')).toBeInTheDocument()
})
it('should render with arrow icon', () => {
// Arrange & Act
const { container } = render(<NextStepButton disabled={false} onClick={vi.fn()} />)
// Assert
const svgIcon = container.querySelector('svg')
expect(svgIcon).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Props Tests
// --------------------------------------------------------------------------
describe('Props', () => {
it('should be disabled when disabled prop is true', () => {
// Arrange & Act
render(<NextStepButton disabled onClick={vi.fn()} />)
// Assert
expect(screen.getByRole('button')).toBeDisabled()
})
it('should be enabled when disabled prop is false', () => {
// Arrange & Act
render(<NextStepButton disabled={false} onClick={vi.fn()} />)
// Assert
expect(screen.getByRole('button')).not.toBeDisabled()
})
it('should call onClick when clicked and not disabled', () => {
// Arrange
const onClick = vi.fn()
render(<NextStepButton disabled={false} onClick={onClick} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert
expect(onClick).toHaveBeenCalledTimes(1)
})
it('should not call onClick when clicked and disabled', () => {
// Arrange
const onClick = vi.fn()
render(<NextStepButton disabled onClick={onClick} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert
expect(onClick).not.toHaveBeenCalled()
})
})
})
// ==========================================
// PreviewPanel Component Tests
// ==========================================
describe('PreviewPanel', () => {
const defaultPreviewProps = {
currentFile: undefined as File | undefined,
currentNotionPage: undefined as NotionPage | undefined,
currentWebsite: undefined as CrawlResultItem | undefined,
notionCredentialId: 'cred-1',
isShowPlanUpgradeModal: false,
hideFilePreview: vi.fn(),
hideNotionPagePreview: vi.fn(),
hideWebsitePreview: vi.fn(),
hidePlanUpgradeModal: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Conditional Rendering Tests
// --------------------------------------------------------------------------
describe('Conditional Rendering', () => {
it('should not render FilePreview when currentFile is undefined', () => {
// Arrange & Act
render(<PreviewPanel {...defaultPreviewProps} />)
// Assert
expect(screen.queryByTestId('file-preview')).not.toBeInTheDocument()
})
it('should render FilePreview when currentFile is defined', () => {
// Arrange
const file = new File(['test'], 'test.txt')
// Act
render(<PreviewPanel {...defaultPreviewProps} currentFile={file} />)
// Assert
expect(screen.getByTestId('file-preview')).toBeInTheDocument()
})
it('should not render NotionPagePreview when currentNotionPage is undefined', () => {
// Arrange & Act
render(<PreviewPanel {...defaultPreviewProps} />)
// Assert
expect(screen.queryByTestId('notion-page-preview')).not.toBeInTheDocument()
})
it('should render NotionPagePreview when currentNotionPage is defined', () => {
// Arrange
const page = createMockNotionPage()
// Act
render(<PreviewPanel {...defaultPreviewProps} currentNotionPage={page} />)
// Assert
expect(screen.getByTestId('notion-page-preview')).toBeInTheDocument()
})
it('should not render WebsitePreview when currentWebsite is undefined', () => {
// Arrange & Act
render(<PreviewPanel {...defaultPreviewProps} />)
// Assert - pagePreview is the title shown in WebsitePreview
expect(screen.queryByText('datasetCreation.stepOne.pagePreview')).not.toBeInTheDocument()
})
it('should render WebsitePreview when currentWebsite is defined', () => {
// Arrange
const website = createMockCrawlResult()
// Act
render(<PreviewPanel {...defaultPreviewProps} currentWebsite={website} />)
// Assert - Check for the preview title and source URL
expect(screen.getByText('datasetCreation.stepOne.pagePreview')).toBeInTheDocument()
expect(screen.getByText(website.source_url)).toBeInTheDocument()
})
it('should not render PlanUpgradeModal when isShowPlanUpgradeModal is false', () => {
// Arrange & Act
render(<PreviewPanel {...defaultPreviewProps} isShowPlanUpgradeModal={false} />)
// Assert
expect(screen.queryByTestId('plan-upgrade-modal')).not.toBeInTheDocument()
})
it('should render PlanUpgradeModal when isShowPlanUpgradeModal is true', () => {
// Arrange & Act
render(<PreviewPanel {...defaultPreviewProps} isShowPlanUpgradeModal />)
// Assert
expect(screen.getByTestId('plan-upgrade-modal')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Event Handler Tests
// --------------------------------------------------------------------------
describe('Event Handlers', () => {
it('should call hideFilePreview when file preview close is clicked', () => {
// Arrange
const hideFilePreview = vi.fn()
const file = new File(['test'], 'test.txt')
render(<PreviewPanel {...defaultPreviewProps} currentFile={file} hideFilePreview={hideFilePreview} />)
// Act
fireEvent.click(screen.getByTestId('hide-file-preview'))
// Assert
expect(hideFilePreview).toHaveBeenCalledTimes(1)
})
it('should call hideNotionPagePreview when notion preview close is clicked', () => {
// Arrange
const hideNotionPagePreview = vi.fn()
const page = createMockNotionPage()
render(<PreviewPanel {...defaultPreviewProps} currentNotionPage={page} hideNotionPagePreview={hideNotionPagePreview} />)
// Act
fireEvent.click(screen.getByTestId('hide-notion-preview'))
// Assert
expect(hideNotionPagePreview).toHaveBeenCalledTimes(1)
})
it('should call hideWebsitePreview when website preview close is clicked', () => {
// Arrange
const hideWebsitePreview = vi.fn()
const website = createMockCrawlResult()
const { container } = render(<PreviewPanel {...defaultPreviewProps} currentWebsite={website} hideWebsitePreview={hideWebsitePreview} />)
// Act - Find the close button (div with cursor-pointer class containing the XMarkIcon)
const closeButton = container.querySelector('.cursor-pointer')
expect(closeButton).toBeInTheDocument()
fireEvent.click(closeButton!)
// Assert
expect(hideWebsitePreview).toHaveBeenCalledTimes(1)
})
it('should call hidePlanUpgradeModal when modal close is clicked', () => {
// Arrange
const hidePlanUpgradeModal = vi.fn()
render(<PreviewPanel {...defaultPreviewProps} isShowPlanUpgradeModal hidePlanUpgradeModal={hidePlanUpgradeModal} />)
// Act
fireEvent.click(screen.getByTestId('close-upgrade-modal'))
// Assert
expect(hidePlanUpgradeModal).toHaveBeenCalledTimes(1)
})
})
})
// ==========================================
// StepOne Component Tests

View File

@@ -1,107 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import UpgradeCard from './upgrade-card'
const mockSetShowPricingModal = vi.fn()
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowPricingModal: mockSetShowPricingModal,
}),
}))
vi.mock('@/app/components/billing/upgrade-btn', () => ({
default: ({ onClick, className }: { onClick?: () => void, className?: string }) => (
<button type="button" className={className} onClick={onClick} data-testid="upgrade-btn">
upgrade
</button>
),
}))
describe('UpgradeCard', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
render(<UpgradeCard />)
// Assert - title and description i18n keys are rendered
expect(screen.getByText(/uploadMultipleFiles\.title/i)).toBeInTheDocument()
})
it('should render the upgrade title text', () => {
// Arrange & Act
render(<UpgradeCard />)
// Assert
expect(screen.getByText(/uploadMultipleFiles\.title/i)).toBeInTheDocument()
})
it('should render the upgrade description text', () => {
// Arrange & Act
render(<UpgradeCard />)
// Assert
expect(screen.getByText(/uploadMultipleFiles\.description/i)).toBeInTheDocument()
})
it('should render the upgrade button', () => {
// Arrange & Act
render(<UpgradeCard />)
// Assert
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call setShowPricingModal when upgrade button is clicked', () => {
// Arrange
render(<UpgradeCard />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
})
it('should not call setShowPricingModal without user interaction', () => {
// Arrange & Act
render(<UpgradeCard />)
// Assert
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
})
it('should call setShowPricingModal on each button click', () => {
// Arrange
render(<UpgradeCard />)
// Act
const button = screen.getByRole('button')
fireEvent.click(button)
fireEvent.click(button)
// Assert
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(2)
})
})
describe('Memoization', () => {
it('should maintain rendering after rerender with same props', () => {
// Arrange
const { rerender } = render(<UpgradeCard />)
// Act
rerender(<UpgradeCard />)
// Assert
expect(screen.getByText(/uploadMultipleFiles\.title/i)).toBeInTheDocument()
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
})

View File

@@ -1,174 +0,0 @@
import type { PreProcessingRule } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChunkingMode } from '@/models/datasets'
import { GeneralChunkingOptions } from './general-chunking-options'
vi.mock('next/image', () => ({
default: ({ alt, ...props }: { alt?: string, src?: string, width?: number, height?: number }) => (
<img alt={alt} {...props} />
),
}))
vi.mock('@/app/components/datasets/settings/summary-index-setting', () => ({
default: ({ onSummaryIndexSettingChange }: { onSummaryIndexSettingChange?: (val: Record<string, unknown>) => void }) => (
<div data-testid="summary-index-setting">
<button data-testid="summary-toggle" onClick={() => onSummaryIndexSettingChange?.({ enable: true })}>Toggle</button>
</div>
),
}))
vi.mock('@/config', () => ({
IS_CE_EDITION: true,
}))
const ns = 'datasetCreation'
const createRules = (): PreProcessingRule[] => [
{ id: 'remove_extra_spaces', enabled: true },
{ id: 'remove_urls_emails', enabled: false },
]
const defaultProps = {
segmentIdentifier: '\\n',
maxChunkLength: 500,
overlap: 50,
rules: createRules(),
currentDocForm: ChunkingMode.text,
docLanguage: 'English',
isActive: true,
isInUpload: false,
isNotUploadInEmptyDataset: false,
hasCurrentDatasetDocForm: false,
onSegmentIdentifierChange: vi.fn(),
onMaxChunkLengthChange: vi.fn(),
onOverlapChange: vi.fn(),
onRuleToggle: vi.fn(),
onDocFormChange: vi.fn(),
onDocLanguageChange: vi.fn(),
onPreview: vi.fn(),
onReset: vi.fn(),
locale: 'en',
}
describe('GeneralChunkingOptions', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render general chunking title', () => {
render(<GeneralChunkingOptions {...defaultProps} />)
expect(screen.getByText(`${ns}.stepTwo.general`)).toBeInTheDocument()
})
it('should render delimiter, max length and overlap inputs when active', () => {
render(<GeneralChunkingOptions {...defaultProps} />)
expect(screen.getByText(`${ns}.stepTwo.separator`)).toBeInTheDocument()
expect(screen.getByText(`${ns}.stepTwo.maxLength`)).toBeInTheDocument()
expect(screen.getAllByText(new RegExp(`${ns}.stepTwo.overlap`)).length).toBeGreaterThan(0)
})
it('should render preprocessing rules as checkboxes', () => {
render(<GeneralChunkingOptions {...defaultProps} />)
expect(screen.getByText(`${ns}.stepTwo.removeExtraSpaces`)).toBeInTheDocument()
expect(screen.getByText(`${ns}.stepTwo.removeUrlEmails`)).toBeInTheDocument()
})
it('should render preview and reset buttons when active', () => {
render(<GeneralChunkingOptions {...defaultProps} />)
expect(screen.getByText(`${ns}.stepTwo.previewChunk`)).toBeInTheDocument()
expect(screen.getByText(`${ns}.stepTwo.reset`)).toBeInTheDocument()
})
it('should not render body when not active', () => {
render(<GeneralChunkingOptions {...defaultProps} isActive={false} />)
expect(screen.queryByText(`${ns}.stepTwo.separator`)).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onPreview when preview button clicked', () => {
const onPreview = vi.fn()
render(<GeneralChunkingOptions {...defaultProps} onPreview={onPreview} />)
fireEvent.click(screen.getByText(`${ns}.stepTwo.previewChunk`))
expect(onPreview).toHaveBeenCalledOnce()
})
it('should call onReset when reset button clicked', () => {
const onReset = vi.fn()
render(<GeneralChunkingOptions {...defaultProps} onReset={onReset} />)
fireEvent.click(screen.getByText(`${ns}.stepTwo.reset`))
expect(onReset).toHaveBeenCalledOnce()
})
it('should call onRuleToggle when rule clicked', () => {
const onRuleToggle = vi.fn()
render(<GeneralChunkingOptions {...defaultProps} onRuleToggle={onRuleToggle} />)
fireEvent.click(screen.getByText(`${ns}.stepTwo.removeUrlEmails`))
expect(onRuleToggle).toHaveBeenCalledWith('remove_urls_emails')
})
it('should call onDocFormChange with text mode when card switched', () => {
const onDocFormChange = vi.fn()
render(<GeneralChunkingOptions {...defaultProps} isActive={false} onDocFormChange={onDocFormChange} />)
// OptionCard fires onSwitched which calls onDocFormChange(ChunkingMode.text)
// Since isActive=false, clicking the card triggers the switch
const titleEl = screen.getByText(`${ns}.stepTwo.general`)
fireEvent.click(titleEl.closest('[class*="rounded-xl"]')!)
expect(onDocFormChange).toHaveBeenCalledWith(ChunkingMode.text)
})
})
describe('QA Mode (CE Edition)', () => {
it('should render QA language checkbox', () => {
render(<GeneralChunkingOptions {...defaultProps} />)
expect(screen.getByText(`${ns}.stepTwo.useQALanguage`)).toBeInTheDocument()
})
it('should toggle QA mode when checkbox clicked', () => {
const onDocFormChange = vi.fn()
render(<GeneralChunkingOptions {...defaultProps} onDocFormChange={onDocFormChange} />)
fireEvent.click(screen.getByText(`${ns}.stepTwo.useQALanguage`))
expect(onDocFormChange).toHaveBeenCalledWith(ChunkingMode.qa)
})
it('should toggle back to text mode from QA mode', () => {
const onDocFormChange = vi.fn()
render(<GeneralChunkingOptions {...defaultProps} currentDocForm={ChunkingMode.qa} onDocFormChange={onDocFormChange} />)
fireEvent.click(screen.getByText(`${ns}.stepTwo.useQALanguage`))
expect(onDocFormChange).toHaveBeenCalledWith(ChunkingMode.text)
})
it('should not toggle QA mode when hasCurrentDatasetDocForm is true', () => {
const onDocFormChange = vi.fn()
render(<GeneralChunkingOptions {...defaultProps} hasCurrentDatasetDocForm onDocFormChange={onDocFormChange} />)
fireEvent.click(screen.getByText(`${ns}.stepTwo.useQALanguage`))
expect(onDocFormChange).not.toHaveBeenCalled()
})
it('should show QA warning tip when in QA mode', () => {
render(<GeneralChunkingOptions {...defaultProps} currentDocForm={ChunkingMode.qa} />)
expect(screen.getAllByText(`${ns}.stepTwo.QATip`).length).toBeGreaterThan(0)
})
})
describe('Summary Index Setting', () => {
it('should render SummaryIndexSetting when showSummaryIndexSetting is true', () => {
render(<GeneralChunkingOptions {...defaultProps} showSummaryIndexSetting />)
expect(screen.getByTestId('summary-index-setting')).toBeInTheDocument()
})
it('should not render SummaryIndexSetting when showSummaryIndexSetting is false', () => {
render(<GeneralChunkingOptions {...defaultProps} showSummaryIndexSetting={false} />)
expect(screen.queryByTestId('summary-index-setting')).not.toBeInTheDocument()
})
it('should call onSummaryIndexSettingChange', () => {
const onSummaryIndexSettingChange = vi.fn()
render(<GeneralChunkingOptions {...defaultProps} showSummaryIndexSetting onSummaryIndexSettingChange={onSummaryIndexSettingChange} />)
fireEvent.click(screen.getByTestId('summary-toggle'))
expect(onSummaryIndexSettingChange).toHaveBeenCalledWith({ enable: true })
})
})
})

View File

@@ -1,219 +0,0 @@
import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { RetrievalConfig } from '@/types/app'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChunkingMode } from '@/models/datasets'
import { IndexingType } from '../hooks'
import { IndexingModeSection } from './indexing-mode-section'
vi.mock('next/image', () => ({
default: ({ alt, ...props }: { alt?: string, src?: string, width?: number, height?: number }) => (
<img alt={alt} {...props} />
),
}))
vi.mock('next/link', () => ({
default: ({ children, href, ...props }: { children?: React.ReactNode, href?: string, className?: string }) => <a href={href} {...props}>{children}</a>,
}))
// Mock external domain components
vi.mock('@/app/components/datasets/common/retrieval-method-config', () => ({
default: ({ onChange, disabled }: { value?: RetrievalConfig, onChange?: (val: Record<string, unknown>) => void, disabled?: boolean }) => (
<div data-testid="retrieval-method-config" data-disabled={disabled}>
<button onClick={() => onChange?.({ search_method: 'updated' })}>Change Retrieval</button>
</div>
),
}))
vi.mock('@/app/components/datasets/common/economical-retrieval-method-config', () => ({
default: ({ disabled }: { value?: RetrievalConfig, onChange?: (val: Record<string, unknown>) => void, disabled?: boolean }) => (
<div data-testid="economical-retrieval-config" data-disabled={disabled}>
Economical Config
</div>
),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
default: ({ onSelect, readonly }: { onSelect?: (val: Record<string, string>) => void, readonly?: boolean }) => (
<div data-testid="model-selector" data-readonly={readonly}>
<button onClick={() => onSelect?.({ provider: 'openai', model: 'text-embedding-3-small' })}>Select Model</button>
</div>
),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
}))
const ns = 'datasetCreation'
const createDefaultModel = (overrides?: Partial<DefaultModel>): DefaultModel => ({
provider: 'openai',
model: 'text-embedding-ada-002',
...overrides,
})
const createRetrievalConfig = (): RetrievalConfig => ({
search_method: 'semantic_search' as RetrievalConfig['search_method'],
reranking_enable: false,
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0,
})
const defaultProps = {
indexType: IndexingType.QUALIFIED,
hasSetIndexType: false,
docForm: ChunkingMode.text,
embeddingModel: createDefaultModel(),
embeddingModelList: [],
retrievalConfig: createRetrievalConfig(),
showMultiModalTip: false,
isModelAndRetrievalConfigDisabled: false,
isQAConfirmDialogOpen: false,
onIndexTypeChange: vi.fn(),
onEmbeddingModelChange: vi.fn(),
onRetrievalConfigChange: vi.fn(),
onQAConfirmDialogClose: vi.fn(),
onQAConfirmDialogConfirm: vi.fn(),
}
describe('IndexingModeSection', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render index mode title', () => {
render(<IndexingModeSection {...defaultProps} />)
expect(screen.getByText(`${ns}.stepTwo.indexMode`)).toBeInTheDocument()
})
it('should render qualified option when not locked to economical', () => {
render(<IndexingModeSection {...defaultProps} />)
expect(screen.getByText(`${ns}.stepTwo.qualified`)).toBeInTheDocument()
})
it('should render economical option when not locked to qualified', () => {
render(<IndexingModeSection {...defaultProps} />)
expect(screen.getByText(`${ns}.stepTwo.economical`)).toBeInTheDocument()
})
it('should only show qualified option when hasSetIndexType and type is qualified', () => {
render(<IndexingModeSection {...defaultProps} hasSetIndexType indexType={IndexingType.QUALIFIED} />)
expect(screen.getByText(`${ns}.stepTwo.qualified`)).toBeInTheDocument()
expect(screen.queryByText(`${ns}.stepTwo.economical`)).not.toBeInTheDocument()
})
it('should only show economical option when hasSetIndexType and type is economical', () => {
render(<IndexingModeSection {...defaultProps} hasSetIndexType indexType={IndexingType.ECONOMICAL} />)
expect(screen.getByText(`${ns}.stepTwo.economical`)).toBeInTheDocument()
expect(screen.queryByText(`${ns}.stepTwo.qualified`)).not.toBeInTheDocument()
})
})
describe('Embedding Model', () => {
it('should show model selector when indexType is qualified', () => {
render(<IndexingModeSection {...defaultProps} indexType={IndexingType.QUALIFIED} />)
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
})
it('should not show model selector when indexType is economical', () => {
render(<IndexingModeSection {...defaultProps} indexType={IndexingType.ECONOMICAL} />)
expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument()
})
it('should mark model selector as readonly when disabled', () => {
render(<IndexingModeSection {...defaultProps} isModelAndRetrievalConfigDisabled />)
expect(screen.getByTestId('model-selector')).toHaveAttribute('data-readonly', 'true')
})
it('should call onEmbeddingModelChange when model selected', () => {
const onEmbeddingModelChange = vi.fn()
render(<IndexingModeSection {...defaultProps} onEmbeddingModelChange={onEmbeddingModelChange} />)
fireEvent.click(screen.getByText('Select Model'))
expect(onEmbeddingModelChange).toHaveBeenCalledWith({ provider: 'openai', model: 'text-embedding-3-small' })
})
})
describe('Retrieval Config', () => {
it('should show RetrievalMethodConfig when qualified', () => {
render(<IndexingModeSection {...defaultProps} indexType={IndexingType.QUALIFIED} />)
expect(screen.getByTestId('retrieval-method-config')).toBeInTheDocument()
})
it('should show EconomicalRetrievalMethodConfig when economical', () => {
render(<IndexingModeSection {...defaultProps} indexType={IndexingType.ECONOMICAL} />)
expect(screen.getByTestId('economical-retrieval-config')).toBeInTheDocument()
})
it('should call onRetrievalConfigChange from qualified config', () => {
const onRetrievalConfigChange = vi.fn()
render(<IndexingModeSection {...defaultProps} onRetrievalConfigChange={onRetrievalConfigChange} />)
fireEvent.click(screen.getByText('Change Retrieval'))
expect(onRetrievalConfigChange).toHaveBeenCalledWith({ search_method: 'updated' })
})
})
describe('Index Type Switching', () => {
it('should call onIndexTypeChange when switching to qualified', () => {
const onIndexTypeChange = vi.fn()
render(<IndexingModeSection {...defaultProps} indexType={IndexingType.ECONOMICAL} onIndexTypeChange={onIndexTypeChange} />)
const qualifiedCard = screen.getByText(`${ns}.stepTwo.qualified`).closest('[class*="rounded-xl"]')!
fireEvent.click(qualifiedCard)
expect(onIndexTypeChange).toHaveBeenCalledWith(IndexingType.QUALIFIED)
})
it('should disable economical when docForm is QA', () => {
render(<IndexingModeSection {...defaultProps} docForm={ChunkingMode.qa} />)
// The economical option card should have disabled styling
const economicalText = screen.getByText(`${ns}.stepTwo.economical`)
const card = economicalText.closest('[class*="rounded-xl"]')
expect(card).toHaveClass('pointer-events-none')
})
})
describe('High Quality Tip', () => {
it('should show high quality tip when qualified is selected and not locked', () => {
render(<IndexingModeSection {...defaultProps} indexType={IndexingType.QUALIFIED} hasSetIndexType={false} />)
expect(screen.getByText(`${ns}.stepTwo.highQualityTip`)).toBeInTheDocument()
})
it('should not show high quality tip when index type is locked', () => {
render(<IndexingModeSection {...defaultProps} indexType={IndexingType.QUALIFIED} hasSetIndexType />)
expect(screen.queryByText(`${ns}.stepTwo.highQualityTip`)).not.toBeInTheDocument()
})
})
describe('QA Confirm Dialog', () => {
it('should call onQAConfirmDialogClose when cancel clicked', () => {
const onClose = vi.fn()
render(<IndexingModeSection {...defaultProps} isQAConfirmDialogOpen onQAConfirmDialogClose={onClose} />)
const cancelBtns = screen.getAllByText(`${ns}.stepTwo.cancel`)
fireEvent.click(cancelBtns[0])
expect(onClose).toHaveBeenCalled()
})
it('should call onQAConfirmDialogConfirm when confirm clicked', () => {
const onConfirm = vi.fn()
render(<IndexingModeSection {...defaultProps} isQAConfirmDialogOpen onQAConfirmDialogConfirm={onConfirm} />)
fireEvent.click(screen.getByText(`${ns}.stepTwo.switch`))
expect(onConfirm).toHaveBeenCalled()
})
})
describe('Dataset Settings Link', () => {
it('should show settings link when economical and hasSetIndexType', () => {
render(<IndexingModeSection {...defaultProps} hasSetIndexType indexType={IndexingType.ECONOMICAL} datasetId="ds-123" />)
expect(screen.getByText(`${ns}.stepTwo.datasetSettingLink`)).toBeInTheDocument()
expect(screen.getByText(`${ns}.stepTwo.datasetSettingLink`).closest('a')).toHaveAttribute('href', '/datasets/ds-123/settings')
})
it('should show settings link under model selector when disabled', () => {
render(<IndexingModeSection {...defaultProps} isModelAndRetrievalConfigDisabled datasetId="ds-456" />)
const links = screen.getAllByText(`${ns}.stepTwo.datasetSettingLink`)
expect(links.length).toBeGreaterThan(0)
})
})
})

View File

@@ -1,92 +0,0 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DelimiterInput, MaxLengthInput, OverlapInput } from './inputs'
// i18n mock returns namespaced keys like "datasetCreation.stepTwo.separator"
const ns = 'datasetCreation'
describe('DelimiterInput', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render separator label', () => {
render(<DelimiterInput />)
expect(screen.getByText(`${ns}.stepTwo.separator`)).toBeInTheDocument()
})
it('should render text input with placeholder', () => {
render(<DelimiterInput />)
const input = screen.getByPlaceholderText(`${ns}.stepTwo.separatorPlaceholder`)
expect(input).toBeInTheDocument()
expect(input).toHaveAttribute('type', 'text')
})
it('should pass through value and onChange props', () => {
const onChange = vi.fn()
render(<DelimiterInput value="test-val" onChange={onChange} />)
expect(screen.getByDisplayValue('test-val')).toBeInTheDocument()
})
it('should render tooltip content', () => {
render(<DelimiterInput />)
// Tooltip triggers render; component mounts without error
expect(screen.getByText(`${ns}.stepTwo.separator`)).toBeInTheDocument()
})
})
describe('MaxLengthInput', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render max length label', () => {
render(<MaxLengthInput onChange={vi.fn()} />)
expect(screen.getByText(`${ns}.stepTwo.maxLength`)).toBeInTheDocument()
})
it('should render number input', () => {
render(<MaxLengthInput onChange={vi.fn()} />)
const input = screen.getByRole('spinbutton')
expect(input).toBeInTheDocument()
})
it('should accept value prop', () => {
render(<MaxLengthInput value={500} onChange={vi.fn()} />)
expect(screen.getByDisplayValue('500')).toBeInTheDocument()
})
it('should have min of 1', () => {
render(<MaxLengthInput onChange={vi.fn()} />)
const input = screen.getByRole('spinbutton')
expect(input).toHaveAttribute('min', '1')
})
})
describe('OverlapInput', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render overlap label', () => {
render(<OverlapInput onChange={vi.fn()} />)
expect(screen.getAllByText(new RegExp(`${ns}.stepTwo.overlap`)).length).toBeGreaterThan(0)
})
it('should render number input', () => {
render(<OverlapInput onChange={vi.fn()} />)
const input = screen.getByRole('spinbutton')
expect(input).toBeInTheDocument()
})
it('should accept value prop', () => {
render(<OverlapInput value={50} onChange={vi.fn()} />)
expect(screen.getByDisplayValue('50')).toBeInTheDocument()
})
it('should have min of 1', () => {
render(<OverlapInput onChange={vi.fn()} />)
const input = screen.getByRole('spinbutton')
expect(input).toHaveAttribute('min', '1')
})
})

View File

@@ -1,159 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { OptionCard, OptionCardHeader } from './option-card'
vi.mock('next/image', () => ({
default: ({ src, alt, ...props }: { src?: string, alt?: string, width?: number, height?: number }) => (
<img src={src} alt={alt} {...props} />
),
}))
describe('OptionCardHeader', () => {
const defaultProps = {
icon: <span data-testid="icon">icon</span>,
title: <span>Test Title</span>,
description: 'Test description',
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render icon, title and description', () => {
render(<OptionCardHeader {...defaultProps} />)
expect(screen.getByTestId('icon')).toBeInTheDocument()
expect(screen.getByText('Test Title')).toBeInTheDocument()
expect(screen.getByText('Test description')).toBeInTheDocument()
})
it('should show effect image when active and effectImg provided', () => {
const { container } = render(
<OptionCardHeader {...defaultProps} isActive effectImg="/effect.png" />,
)
const img = container.querySelector('img')
expect(img).toBeInTheDocument()
})
it('should not show effect image when not active', () => {
const { container } = render(
<OptionCardHeader {...defaultProps} isActive={false} effectImg="/effect.png" />,
)
expect(container.querySelector('img')).not.toBeInTheDocument()
})
it('should apply cursor-pointer when not disabled', () => {
const { container } = render(<OptionCardHeader {...defaultProps} />)
expect(container.firstChild).toHaveClass('cursor-pointer')
})
it('should not apply cursor-pointer when disabled', () => {
const { container } = render(<OptionCardHeader {...defaultProps} disabled />)
expect(container.firstChild).not.toHaveClass('cursor-pointer')
})
it('should apply activeClassName when active', () => {
const { container } = render(
<OptionCardHeader {...defaultProps} isActive activeClassName="custom-active" />,
)
expect(container.firstChild).toHaveClass('custom-active')
})
it('should not apply activeClassName when not active', () => {
const { container } = render(
<OptionCardHeader {...defaultProps} isActive={false} activeClassName="custom-active" />,
)
expect(container.firstChild).not.toHaveClass('custom-active')
})
})
describe('OptionCard', () => {
const defaultProps = {
icon: <span data-testid="icon">icon</span>,
title: <span>Card Title</span> as React.ReactNode,
description: 'Card description',
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render header content', () => {
render(<OptionCard {...defaultProps} />)
expect(screen.getByText('Card Title')).toBeInTheDocument()
expect(screen.getByText('Card description')).toBeInTheDocument()
})
it('should call onSwitched when clicked while not active and not disabled', () => {
const onSwitched = vi.fn()
const { container } = render(
<OptionCard {...defaultProps} isActive={false} onSwitched={onSwitched} />,
)
fireEvent.click(container.firstChild!)
expect(onSwitched).toHaveBeenCalledOnce()
})
it('should not call onSwitched when already active', () => {
const onSwitched = vi.fn()
const { container } = render(
<OptionCard {...defaultProps} isActive onSwitched={onSwitched} />,
)
fireEvent.click(container.firstChild!)
expect(onSwitched).not.toHaveBeenCalled()
})
it('should not call onSwitched when disabled', () => {
const onSwitched = vi.fn()
const { container } = render(
<OptionCard {...defaultProps} disabled onSwitched={onSwitched} />,
)
fireEvent.click(container.firstChild!)
expect(onSwitched).not.toHaveBeenCalled()
})
it('should show children and actions when active', () => {
render(
<OptionCard {...defaultProps} isActive actions={<button>Action</button>}>
<div>Body Content</div>
</OptionCard>,
)
expect(screen.getByText('Body Content')).toBeInTheDocument()
expect(screen.getByText('Action')).toBeInTheDocument()
})
it('should not show children when not active', () => {
render(
<OptionCard {...defaultProps} isActive={false}>
<div>Body Content</div>
</OptionCard>,
)
expect(screen.queryByText('Body Content')).not.toBeInTheDocument()
})
it('should apply selected border style when active and not noHighlight', () => {
const { container } = render(<OptionCard {...defaultProps} isActive />)
expect(container.firstChild).toHaveClass('border-components-option-card-option-selected-border')
})
it('should not apply selected border when noHighlight is true', () => {
const { container } = render(<OptionCard {...defaultProps} isActive noHighlight />)
expect(container.firstChild).not.toHaveClass('border-components-option-card-option-selected-border')
})
it('should apply disabled opacity and pointer-events styles', () => {
const { container } = render(<OptionCard {...defaultProps} disabled />)
expect(container.firstChild).toHaveClass('pointer-events-none')
expect(container.firstChild).toHaveClass('opacity-50')
})
it('should forward custom className', () => {
const { container } = render(<OptionCard {...defaultProps} className="custom-class" />)
expect(container.firstChild).toHaveClass('custom-class')
})
it('should forward custom style', () => {
const { container } = render(
<OptionCard {...defaultProps} style={{ maxWidth: '300px' }} />,
)
expect((container.firstChild as HTMLElement).style.maxWidth).toBe('300px')
})
})

View File

@@ -1,156 +0,0 @@
import type { ParentChildConfig } from '../hooks'
import type { PreProcessingRule } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChunkingMode } from '@/models/datasets'
import { ParentChildOptions } from './parent-child-options'
vi.mock('next/image', () => ({
default: ({ alt, ...props }: { alt?: string, src?: string, width?: number, height?: number }) => (
<img alt={alt} {...props} />
),
}))
vi.mock('@/app/components/datasets/settings/summary-index-setting', () => ({
default: ({ onSummaryIndexSettingChange }: { onSummaryIndexSettingChange?: (val: Record<string, unknown>) => void }) => (
<div data-testid="summary-index-setting">
<button data-testid="summary-toggle" onClick={() => onSummaryIndexSettingChange?.({ enable: true })}>Toggle</button>
</div>
),
}))
vi.mock('@/config', () => ({
IS_CE_EDITION: true,
}))
const ns = 'datasetCreation'
const createRules = (): PreProcessingRule[] => [
{ id: 'remove_extra_spaces', enabled: true },
{ id: 'remove_urls_emails', enabled: false },
]
const createParentChildConfig = (overrides?: Partial<ParentChildConfig>): ParentChildConfig => ({
chunkForContext: 'paragraph',
parent: { delimiter: '\\n\\n', maxLength: 2000 },
child: { delimiter: '\\n', maxLength: 500 },
...overrides,
})
const defaultProps = {
parentChildConfig: createParentChildConfig(),
rules: createRules(),
currentDocForm: ChunkingMode.parentChild,
isActive: true,
isInUpload: false,
isNotUploadInEmptyDataset: false,
onDocFormChange: vi.fn(),
onChunkForContextChange: vi.fn(),
onParentDelimiterChange: vi.fn(),
onParentMaxLengthChange: vi.fn(),
onChildDelimiterChange: vi.fn(),
onChildMaxLengthChange: vi.fn(),
onRuleToggle: vi.fn(),
onPreview: vi.fn(),
onReset: vi.fn(),
}
describe('ParentChildOptions', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render parent-child title', () => {
render(<ParentChildOptions {...defaultProps} />)
expect(screen.getByText(`${ns}.stepTwo.parentChild`)).toBeInTheDocument()
})
it('should render parent chunk context section when active', () => {
render(<ParentChildOptions {...defaultProps} />)
expect(screen.getByText(`${ns}.stepTwo.parentChunkForContext`)).toBeInTheDocument()
expect(screen.getByText(`${ns}.stepTwo.paragraph`)).toBeInTheDocument()
expect(screen.getByText(`${ns}.stepTwo.fullDoc`)).toBeInTheDocument()
})
it('should render child chunk retrieval section when active', () => {
render(<ParentChildOptions {...defaultProps} />)
expect(screen.getByText(`${ns}.stepTwo.childChunkForRetrieval`)).toBeInTheDocument()
})
it('should render rules section when active', () => {
render(<ParentChildOptions {...defaultProps} />)
expect(screen.getByText(`${ns}.stepTwo.removeExtraSpaces`)).toBeInTheDocument()
expect(screen.getByText(`${ns}.stepTwo.removeUrlEmails`)).toBeInTheDocument()
})
it('should render preview and reset buttons when active', () => {
render(<ParentChildOptions {...defaultProps} />)
expect(screen.getByText(`${ns}.stepTwo.previewChunk`)).toBeInTheDocument()
expect(screen.getByText(`${ns}.stepTwo.reset`)).toBeInTheDocument()
})
it('should not render body when not active', () => {
render(<ParentChildOptions {...defaultProps} isActive={false} />)
expect(screen.queryByText(`${ns}.stepTwo.parentChunkForContext`)).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onPreview when preview button clicked', () => {
const onPreview = vi.fn()
render(<ParentChildOptions {...defaultProps} onPreview={onPreview} />)
fireEvent.click(screen.getByText(`${ns}.stepTwo.previewChunk`))
expect(onPreview).toHaveBeenCalledOnce()
})
it('should call onReset when reset button clicked', () => {
const onReset = vi.fn()
render(<ParentChildOptions {...defaultProps} onReset={onReset} />)
fireEvent.click(screen.getByText(`${ns}.stepTwo.reset`))
expect(onReset).toHaveBeenCalledOnce()
})
it('should call onRuleToggle when rule clicked', () => {
const onRuleToggle = vi.fn()
render(<ParentChildOptions {...defaultProps} onRuleToggle={onRuleToggle} />)
fireEvent.click(screen.getByText(`${ns}.stepTwo.removeUrlEmails`))
expect(onRuleToggle).toHaveBeenCalledWith('remove_urls_emails')
})
it('should call onDocFormChange with parentChild when card switched', () => {
const onDocFormChange = vi.fn()
render(<ParentChildOptions {...defaultProps} isActive={false} onDocFormChange={onDocFormChange} />)
const titleEl = screen.getByText(`${ns}.stepTwo.parentChild`)
fireEvent.click(titleEl.closest('[class*="rounded-xl"]')!)
expect(onDocFormChange).toHaveBeenCalledWith(ChunkingMode.parentChild)
})
it('should call onChunkForContextChange when full-doc chosen', () => {
const onChunkForContextChange = vi.fn()
render(<ParentChildOptions {...defaultProps} onChunkForContextChange={onChunkForContextChange} />)
fireEvent.click(screen.getByText(`${ns}.stepTwo.fullDoc`))
expect(onChunkForContextChange).toHaveBeenCalledWith('full-doc')
})
it('should call onChunkForContextChange when paragraph chosen', () => {
const onChunkForContextChange = vi.fn()
const config = createParentChildConfig({ chunkForContext: 'full-doc' })
render(<ParentChildOptions {...defaultProps} parentChildConfig={config} onChunkForContextChange={onChunkForContextChange} />)
fireEvent.click(screen.getByText(`${ns}.stepTwo.paragraph`))
expect(onChunkForContextChange).toHaveBeenCalledWith('paragraph')
})
})
describe('Summary Index Setting', () => {
it('should render SummaryIndexSetting when showSummaryIndexSetting is true', () => {
render(<ParentChildOptions {...defaultProps} showSummaryIndexSetting />)
expect(screen.getByTestId('summary-index-setting')).toBeInTheDocument()
})
it('should not render SummaryIndexSetting when showSummaryIndexSetting is false', () => {
render(<ParentChildOptions {...defaultProps} showSummaryIndexSetting={false} />)
expect(screen.queryByTestId('summary-index-setting')).not.toBeInTheDocument()
})
})
})

View File

@@ -1,176 +0,0 @@
import type { ParentChildConfig } from '../hooks'
import type { FileIndexingEstimateResponse } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChunkingMode, DataSourceType } from '@/models/datasets'
import { PreviewPanel } from './preview-panel'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, opts?: { count?: number }) => opts?.count !== undefined ? `${key}-${opts.count}` : key,
}),
}))
vi.mock('@remixicon/react', () => ({
RiSearchEyeLine: () => <span data-testid="search-icon" />,
}))
vi.mock('@/app/components/base/float-right-container', () => ({
default: ({ children }: { children: React.ReactNode }) => <div data-testid="float-container">{children}</div>,
}))
vi.mock('@/app/components/base/badge', () => ({
default: ({ text }: { text: string }) => <span data-testid="badge">{text}</span>,
}))
vi.mock('@/app/components/base/skeleton', () => ({
SkeletonContainer: ({ children }: { children: React.ReactNode }) => <div data-testid="skeleton">{children}</div>,
SkeletonPoint: () => <span />,
SkeletonRectangle: () => <span />,
SkeletonRow: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
vi.mock('../../../chunk', () => ({
ChunkContainer: ({ children, label }: { children: React.ReactNode, label: string }) => (
<div data-testid="chunk-container">
{label}
:
{' '}
{children}
</div>
),
QAPreview: ({ qa }: { qa: { question: string } }) => <div data-testid="qa-preview">{qa.question}</div>,
}))
vi.mock('../../../common/document-picker/preview-document-picker', () => ({
default: () => <div data-testid="doc-picker" />,
}))
vi.mock('../../../documents/detail/completed/common/summary-label', () => ({
default: ({ summary }: { summary: string }) => <span data-testid="summary">{summary}</span>,
}))
vi.mock('../../../formatted-text/flavours/preview-slice', () => ({
PreviewSlice: ({ label, text }: { label: string, text: string }) => (
<span data-testid="preview-slice">
{label}
:
{' '}
{text}
</span>
),
}))
vi.mock('../../../formatted-text/formatted', () => ({
FormattedText: ({ children }: { children: React.ReactNode }) => <p data-testid="formatted-text">{children}</p>,
}))
vi.mock('../../../preview/container', () => ({
default: ({ children, header }: { children: React.ReactNode, header: React.ReactNode }) => (
<div data-testid="preview-container">
{header}
{children}
</div>
),
}))
vi.mock('../../../preview/header', () => ({
PreviewHeader: ({ children, title }: { children: React.ReactNode, title: string }) => (
<div data-testid="preview-header">
{title}
{children}
</div>
),
}))
vi.mock('@/config', () => ({
FULL_DOC_PREVIEW_LENGTH: 3,
}))
describe('PreviewPanel', () => {
const defaultProps = {
isMobile: false,
dataSourceType: DataSourceType.FILE,
currentDocForm: ChunkingMode.text,
parentChildConfig: { chunkForContext: 'paragraph' } as ParentChildConfig,
pickerFiles: [{ id: '1', name: 'file.pdf', extension: 'pdf' }],
pickerValue: { id: '1', name: 'file.pdf', extension: 'pdf' },
isIdle: false,
isPending: false,
onPickerChange: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render preview header with title', () => {
render(<PreviewPanel {...defaultProps} />)
expect(screen.getByTestId('preview-header')).toHaveTextContent('stepTwo.preview')
})
it('should render document picker', () => {
render(<PreviewPanel {...defaultProps} />)
expect(screen.getByTestId('doc-picker')).toBeInTheDocument()
})
it('should show idle state when isIdle is true', () => {
render(<PreviewPanel {...defaultProps} isIdle={true} />)
expect(screen.getByText('stepTwo.previewChunkTip')).toBeInTheDocument()
})
it('should show loading skeletons when isPending', () => {
render(<PreviewPanel {...defaultProps} isPending={true} />)
expect(screen.getAllByTestId('skeleton')).toHaveLength(10)
})
it('should render text preview chunks', () => {
const estimate: Partial<FileIndexingEstimateResponse> = {
total_segments: 2,
preview: [
{ content: 'chunk 1 text', child_chunks: [], summary: '' },
{ content: 'chunk 2 text', child_chunks: [], summary: 'summary text' },
],
}
render(<PreviewPanel {...defaultProps} estimate={estimate as FileIndexingEstimateResponse} />)
expect(screen.getAllByTestId('chunk-container')).toHaveLength(2)
})
it('should render QA preview', () => {
const estimate: Partial<FileIndexingEstimateResponse> = {
qa_preview: [
{ question: 'Q1', answer: 'A1' },
],
}
render(
<PreviewPanel
{...defaultProps}
currentDocForm={ChunkingMode.qa}
estimate={estimate as FileIndexingEstimateResponse}
/>,
)
expect(screen.getByTestId('qa-preview')).toHaveTextContent('Q1')
})
it('should render parent-child preview', () => {
const estimate: Partial<FileIndexingEstimateResponse> = {
preview: [
{ content: 'parent chunk', child_chunks: ['child1', 'child2'], summary: '' },
],
}
render(
<PreviewPanel
{...defaultProps}
currentDocForm={ChunkingMode.parentChild}
estimate={estimate as FileIndexingEstimateResponse}
/>,
)
expect(screen.getAllByTestId('preview-slice')).toHaveLength(2)
})
it('should show badge with chunk count for non-QA mode', () => {
const estimate: Partial<FileIndexingEstimateResponse> = { total_segments: 5, preview: [] }
render(<PreviewPanel {...defaultProps} estimate={estimate as FileIndexingEstimateResponse} />)
expect(screen.getByTestId('badge')).toBeInTheDocument()
})
})

View File

@@ -1,53 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { StepTwoFooter } from './step-two-footer'
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))
vi.mock('@remixicon/react', () => ({
RiArrowLeftLine: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="arrow-left" {...props} />,
}))
describe('StepTwoFooter', () => {
const defaultProps = {
isCreating: false,
onPrevious: vi.fn(),
onCreate: vi.fn(),
onCancel: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render previous and next buttons when not isSetting', () => {
render(<StepTwoFooter {...defaultProps} />)
expect(screen.getByText('stepTwo.previousStep')).toBeInTheDocument()
expect(screen.getByText('stepTwo.nextStep')).toBeInTheDocument()
})
it('should render save and cancel buttons when isSetting', () => {
render(<StepTwoFooter {...defaultProps} isSetting />)
expect(screen.getByText('stepTwo.save')).toBeInTheDocument()
expect(screen.getByText('stepTwo.cancel')).toBeInTheDocument()
})
it('should call onPrevious on previous button click', () => {
render(<StepTwoFooter {...defaultProps} />)
fireEvent.click(screen.getByText('stepTwo.previousStep'))
expect(defaultProps.onPrevious).toHaveBeenCalledOnce()
})
it('should call onCreate on next button click', () => {
render(<StepTwoFooter {...defaultProps} />)
fireEvent.click(screen.getByText('stepTwo.nextStep'))
expect(defaultProps.onCreate).toHaveBeenCalledOnce()
})
it('should call onCancel on cancel button click in settings mode', () => {
render(<StepTwoFooter {...defaultProps} isSetting />)
fireEvent.click(screen.getByText('stepTwo.cancel'))
expect(defaultProps.onCancel).toHaveBeenCalledOnce()
})
})

View File

@@ -1,76 +0,0 @@
import { describe, expect, it } from 'vitest'
import escape from './escape'
describe('escape', () => {
// Basic special character escaping
it('should escape null character', () => {
expect(escape('\0')).toBe('\\0')
})
it('should escape backspace', () => {
expect(escape('\b')).toBe('\\b')
})
it('should escape form feed', () => {
expect(escape('\f')).toBe('\\f')
})
it('should escape newline', () => {
expect(escape('\n')).toBe('\\n')
})
it('should escape carriage return', () => {
expect(escape('\r')).toBe('\\r')
})
it('should escape tab', () => {
expect(escape('\t')).toBe('\\t')
})
it('should escape vertical tab', () => {
expect(escape('\v')).toBe('\\v')
})
it('should escape single quote', () => {
expect(escape('\'')).toBe('\\\'')
})
// Multiple special characters in one string
it('should escape multiple special characters', () => {
expect(escape('line1\nline2\ttab')).toBe('line1\\nline2\\ttab')
})
it('should escape mixed special characters', () => {
expect(escape('\n\r\t')).toBe('\\n\\r\\t')
})
// Edge cases
it('should return empty string for null input', () => {
expect(escape(null as unknown as string)).toBe('')
})
it('should return empty string for undefined input', () => {
expect(escape(undefined as unknown as string)).toBe('')
})
it('should return empty string for empty string input', () => {
expect(escape('')).toBe('')
})
it('should return empty string for non-string input', () => {
expect(escape(123 as unknown as string)).toBe('')
})
// Pass-through for normal strings
it('should leave normal text unchanged', () => {
expect(escape('hello world')).toBe('hello world')
})
it('should leave special regex characters unchanged', () => {
expect(escape('a.b*c+d')).toBe('a.b*c+d')
})
it('should handle strings with no special characters', () => {
expect(escape('abc123')).toBe('abc123')
})
})

View File

@@ -1,97 +0,0 @@
import { describe, expect, it } from 'vitest'
import unescape from './unescape'
describe('unescape', () => {
// Basic escape sequences
it('should unescape \\n to newline', () => {
expect(unescape('\\n')).toBe('\n')
})
it('should unescape \\t to tab', () => {
expect(unescape('\\t')).toBe('\t')
})
it('should unescape \\r to carriage return', () => {
expect(unescape('\\r')).toBe('\r')
})
it('should unescape \\b to backspace', () => {
expect(unescape('\\b')).toBe('\b')
})
it('should unescape \\f to form feed', () => {
expect(unescape('\\f')).toBe('\f')
})
it('should unescape \\v to vertical tab', () => {
expect(unescape('\\v')).toBe('\v')
})
it('should unescape \\0 to null character', () => {
expect(unescape('\\0')).toBe('\0')
})
it('should unescape \\\\ to backslash', () => {
expect(unescape('\\\\')).toBe('\\')
})
it('should unescape \\\' to single quote', () => {
expect(unescape('\\\'')).toBe('\'')
})
it('should unescape \\" to double quote', () => {
expect(unescape('\\"')).toBe('"')
})
// Hex escape sequences (\\xNN)
it('should unescape 2-digit hex sequences', () => {
expect(unescape('\\x41')).toBe('A')
expect(unescape('\\x61')).toBe('a')
})
// Unicode escape sequences (\\uNNNN)
it('should unescape 4-digit unicode sequences', () => {
expect(unescape('\\u0041')).toBe('A')
expect(unescape('\\u4e2d')).toBe('中')
})
// Variable-length unicode (\\u{NNNN})
it('should unescape variable-length unicode sequences', () => {
expect(unescape('\\u{41}')).toBe('A')
expect(unescape('\\u{1F600}')).toBe('😀')
})
// Octal escape sequences
it('should unescape octal sequences', () => {
expect(unescape('\\101')).toBe('A') // 0o101 = 65 = 'A'
expect(unescape('\\12')).toBe('\n') // 0o12 = 10 = '\n'
})
// Python-style 8-digit unicode (\\UNNNNNNNN)
it('should unescape Python-style 8-digit unicode', () => {
expect(unescape('\\U0001F3B5')).toBe('🎵')
})
// Multiple escape sequences
it('should unescape multiple sequences in one string', () => {
expect(unescape('line1\\nline2\\ttab')).toBe('line1\nline2\ttab')
})
// Mixed content
it('should leave non-escape content unchanged', () => {
expect(unescape('hello world')).toBe('hello world')
})
it('should handle mixed escaped and non-escaped content', () => {
expect(unescape('before\\nafter')).toBe('before\nafter')
})
// Edge cases
it('should handle empty string', () => {
expect(unescape('')).toBe('')
})
it('should handle string with no escape sequences', () => {
expect(unescape('abc123')).toBe('abc123')
})
})

View File

@@ -1,186 +0,0 @@
import type { CustomFile, FullDocumentDetail, ProcessRule } from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChunkingMode, DataSourceType } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
// Hoisted mocks
const mocks = vi.hoisted(() => ({
toastNotify: vi.fn(),
mutateAsync: vi.fn(),
isReRankModelSelected: vi.fn(() => true),
trackEvent: vi.fn(),
invalidDatasetList: vi.fn(),
}))
vi.mock('@/app/components/base/toast', () => ({
default: { notify: mocks.toastNotify },
}))
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: mocks.trackEvent,
}))
vi.mock('@/app/components/datasets/common/check-rerank-model', () => ({
isReRankModelSelected: mocks.isReRankModelSelected,
}))
vi.mock('@/service/knowledge/use-create-dataset', () => ({
useCreateFirstDocument: () => ({ mutateAsync: mocks.mutateAsync, isPending: false }),
useCreateDocument: () => ({ mutateAsync: mocks.mutateAsync, isPending: false }),
getNotionInfo: vi.fn(() => []),
getWebsiteInfo: vi.fn(() => ({})),
}))
vi.mock('@/service/knowledge/use-dataset', () => ({
useInvalidDatasetList: () => mocks.invalidDatasetList,
}))
const { useDocumentCreation } = await import('./use-document-creation')
const { IndexingType } = await import('./use-indexing-config')
describe('useDocumentCreation', () => {
const defaultOptions = {
dataSourceType: DataSourceType.FILE,
files: [{ id: 'f-1', name: 'test.txt' }] as CustomFile[],
notionPages: [],
notionCredentialId: '',
websitePages: [],
}
const defaultValidationParams = {
segmentationType: 'general',
maxChunkLength: 1024,
limitMaxChunkLength: 4000,
overlap: 50,
indexType: IndexingType.QUALIFIED,
embeddingModel: { provider: 'openai', model: 'text-embedding-3-small' },
rerankModelList: [],
retrievalConfig: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0.5,
} as RetrievalConfig,
}
beforeEach(() => {
vi.clearAllMocks()
mocks.isReRankModelSelected.mockReturnValue(true)
})
describe('validateParams', () => {
it('should return true for valid params', () => {
const { result } = renderHook(() => useDocumentCreation(defaultOptions))
expect(result.current.validateParams(defaultValidationParams)).toBe(true)
})
it('should return false when overlap > maxChunkLength', () => {
const { result } = renderHook(() => useDocumentCreation(defaultOptions))
const invalid = { ...defaultValidationParams, overlap: 2000, maxChunkLength: 1000 }
expect(result.current.validateParams(invalid)).toBe(false)
expect(mocks.toastNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
})
it('should return false when maxChunkLength > limitMaxChunkLength', () => {
const { result } = renderHook(() => useDocumentCreation(defaultOptions))
const invalid = { ...defaultValidationParams, maxChunkLength: 5000, limitMaxChunkLength: 4000 }
expect(result.current.validateParams(invalid)).toBe(false)
})
it('should return false when qualified but no embedding model', () => {
const { result } = renderHook(() => useDocumentCreation(defaultOptions))
const invalid = {
...defaultValidationParams,
indexType: IndexingType.QUALIFIED,
embeddingModel: { provider: '', model: '' },
}
expect(result.current.validateParams(invalid)).toBe(false)
})
it('should return false when rerank model not selected', () => {
mocks.isReRankModelSelected.mockReturnValue(false)
const { result } = renderHook(() => useDocumentCreation(defaultOptions))
expect(result.current.validateParams(defaultValidationParams)).toBe(false)
})
it('should skip embedding/rerank checks when isSetting is true', () => {
mocks.isReRankModelSelected.mockReturnValue(false)
const { result } = renderHook(() =>
useDocumentCreation({ ...defaultOptions, isSetting: true }),
)
const params = {
...defaultValidationParams,
embeddingModel: { provider: '', model: '' },
}
expect(result.current.validateParams(params)).toBe(true)
})
})
describe('buildCreationParams', () => {
it('should build params for FILE data source', () => {
const { result } = renderHook(() => useDocumentCreation(defaultOptions))
const processRule = { mode: 'custom', rules: {} } as unknown as ProcessRule
const retrievalConfig = defaultValidationParams.retrievalConfig
const embeddingModel = { provider: 'openai', model: 'text-embedding-3-small' }
const params = result.current.buildCreationParams(
ChunkingMode.text,
'English',
processRule,
retrievalConfig,
embeddingModel,
'high_quality',
)
expect(params).not.toBeNull()
expect(params!.data_source!.type).toBe(DataSourceType.FILE)
expect(params!.data_source!.info_list.file_info_list?.file_ids).toContain('f-1')
expect(params!.embedding_model).toBe('text-embedding-3-small')
expect(params!.embedding_model_provider).toBe('openai')
})
it('should build params for isSetting mode', () => {
const detail = { id: 'doc-1' } as FullDocumentDetail
const { result } = renderHook(() =>
useDocumentCreation({ ...defaultOptions, isSetting: true, documentDetail: detail }),
)
const params = result.current.buildCreationParams(
ChunkingMode.text,
'English',
{ mode: 'custom', rules: {} } as unknown as ProcessRule,
defaultValidationParams.retrievalConfig,
{ provider: 'openai', model: 'text-embedding-3-small' },
'high_quality',
)
expect(params!.original_document_id).toBe('doc-1')
expect(params!.data_source).toBeUndefined()
})
})
describe('validatePreviewParams', () => {
it('should return true when maxChunkLength is within limit', () => {
const { result } = renderHook(() => useDocumentCreation(defaultOptions))
expect(result.current.validatePreviewParams(1024)).toBe(true)
})
it('should return false when maxChunkLength exceeds limit', () => {
const { result } = renderHook(() => useDocumentCreation(defaultOptions))
expect(result.current.validatePreviewParams(999999)).toBe(false)
expect(mocks.toastNotify).toHaveBeenCalled()
})
})
describe('isCreating', () => {
it('should reflect mutation pending state', () => {
const { result } = renderHook(() => useDocumentCreation(defaultOptions))
expect(result.current.isCreating).toBe(false)
})
})
})

View File

@@ -1,161 +0,0 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { RETRIEVE_METHOD } from '@/types/app'
// Hoisted mock state
const mocks = vi.hoisted(() => ({
rerankModelList: [] as Array<{ provider: { provider: string }, model: string }>,
rerankDefaultModel: null as { provider: { provider: string }, model: string } | null,
isRerankDefaultModelValid: null as { provider: { provider: string }, model: string } | null,
embeddingModelList: [] as Array<{ provider: { provider: string }, model: string }>,
defaultEmbeddingModel: null as { provider: { provider: string }, model: string } | null,
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({
modelList: mocks.rerankModelList,
defaultModel: mocks.rerankDefaultModel,
currentModel: mocks.isRerankDefaultModelValid,
}),
useModelList: () => ({ data: mocks.embeddingModelList }),
useDefaultModel: () => ({ data: mocks.defaultEmbeddingModel }),
}))
vi.mock('@/app/components/datasets/settings/utils', () => ({
checkShowMultiModalTip: vi.fn(() => false),
}))
const { IndexingType, useIndexingConfig } = await import('./use-indexing-config')
describe('useIndexingConfig', () => {
const defaultOptions = {
isAPIKeySet: true,
hasSetIndexType: false,
}
beforeEach(() => {
vi.clearAllMocks()
mocks.rerankModelList = []
mocks.rerankDefaultModel = null
mocks.isRerankDefaultModelValid = null
mocks.embeddingModelList = []
mocks.defaultEmbeddingModel = null
})
describe('initial state', () => {
it('should default to QUALIFIED when API key is set', () => {
const { result } = renderHook(() => useIndexingConfig(defaultOptions))
expect(result.current.indexType).toBe(IndexingType.QUALIFIED)
})
it('should default to ECONOMICAL when API key is not set', () => {
const { result } = renderHook(() =>
useIndexingConfig({ ...defaultOptions, isAPIKeySet: false }),
)
expect(result.current.indexType).toBe(IndexingType.ECONOMICAL)
})
it('should use initial index type when provided', () => {
const { result } = renderHook(() =>
useIndexingConfig({
...defaultOptions,
initialIndexType: IndexingType.ECONOMICAL,
}),
)
expect(result.current.indexType).toBe(IndexingType.ECONOMICAL)
})
it('should use initial embedding model when provided', () => {
const { result } = renderHook(() =>
useIndexingConfig({
...defaultOptions,
initialEmbeddingModel: { provider: 'openai', model: 'text-embedding-3-small' },
}),
)
expect(result.current.embeddingModel).toEqual({
provider: 'openai',
model: 'text-embedding-3-small',
})
})
it('should use initial retrieval config when provided', () => {
const config = {
search_method: RETRIEVE_METHOD.fullText,
reranking_enable: false,
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
top_k: 5,
score_threshold_enabled: true,
score_threshold: 0.8,
}
const { result } = renderHook(() =>
useIndexingConfig({ ...defaultOptions, initialRetrievalConfig: config }),
)
expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.fullText)
expect(result.current.retrievalConfig.top_k).toBe(5)
})
})
describe('setters', () => {
it('should update index type', () => {
const { result } = renderHook(() => useIndexingConfig(defaultOptions))
act(() => {
result.current.setIndexType(IndexingType.ECONOMICAL)
})
expect(result.current.indexType).toBe(IndexingType.ECONOMICAL)
})
it('should update embedding model', () => {
const { result } = renderHook(() => useIndexingConfig(defaultOptions))
act(() => {
result.current.setEmbeddingModel({ provider: 'cohere', model: 'embed-v3' })
})
expect(result.current.embeddingModel).toEqual({ provider: 'cohere', model: 'embed-v3' })
})
it('should update retrieval config', () => {
const { result } = renderHook(() => useIndexingConfig(defaultOptions))
const newConfig = {
...result.current.retrievalConfig,
top_k: 10,
}
act(() => {
result.current.setRetrievalConfig(newConfig)
})
expect(result.current.retrievalConfig.top_k).toBe(10)
})
})
describe('getIndexingTechnique', () => {
it('should return initialIndexType when provided', () => {
const { result } = renderHook(() =>
useIndexingConfig({
...defaultOptions,
initialIndexType: IndexingType.ECONOMICAL,
}),
)
expect(result.current.getIndexingTechnique()).toBe(IndexingType.ECONOMICAL)
})
it('should return current indexType when no initialIndexType', () => {
const { result } = renderHook(() => useIndexingConfig(defaultOptions))
expect(result.current.getIndexingTechnique()).toBe(IndexingType.QUALIFIED)
})
})
describe('computed properties', () => {
it('should expose hasSetIndexType from options', () => {
const { result } = renderHook(() =>
useIndexingConfig({ ...defaultOptions, hasSetIndexType: true }),
)
expect(result.current.hasSetIndexType).toBe(true)
})
it('should expose showMultiModalTip as boolean', () => {
const { result } = renderHook(() => useIndexingConfig(defaultOptions))
expect(typeof result.current.showMultiModalTip).toBe('boolean')
})
})
})

View File

@@ -1,127 +0,0 @@
import type { IndexingType } from './use-indexing-config'
import type { NotionPage } from '@/models/common'
import type { ChunkingMode, CrawlResultItem, CustomFile, ProcessRule } from '@/models/datasets'
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DataSourceType } from '@/models/datasets'
// Hoisted mocks
const mocks = vi.hoisted(() => ({
fileMutate: vi.fn(),
fileReset: vi.fn(),
notionMutate: vi.fn(),
notionReset: vi.fn(),
webMutate: vi.fn(),
webReset: vi.fn(),
}))
vi.mock('@/service/knowledge/use-create-dataset', () => ({
useFetchFileIndexingEstimateForFile: () => ({
mutate: mocks.fileMutate,
reset: mocks.fileReset,
data: { tokens: 100, total_segments: 5 },
isIdle: true,
isPending: false,
}),
useFetchFileIndexingEstimateForNotion: () => ({
mutate: mocks.notionMutate,
reset: mocks.notionReset,
data: null,
isIdle: true,
isPending: false,
}),
useFetchFileIndexingEstimateForWeb: () => ({
mutate: mocks.webMutate,
reset: mocks.webReset,
data: null,
isIdle: true,
isPending: false,
}),
}))
const { useIndexingEstimate } = await import('./use-indexing-estimate')
describe('useIndexingEstimate', () => {
const defaultOptions = {
dataSourceType: DataSourceType.FILE,
currentDocForm: 'text_model' as ChunkingMode,
docLanguage: 'English',
files: [{ id: 'f-1', name: 'test.txt' }] as unknown as CustomFile[],
previewNotionPage: {} as unknown as NotionPage,
notionCredentialId: '',
previewWebsitePage: {} as unknown as CrawlResultItem,
indexingTechnique: 'high_quality' as unknown as IndexingType,
processRule: { mode: 'custom', rules: {} } as unknown as ProcessRule,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('currentMutation selection', () => {
it('should select file mutation for FILE type', () => {
const { result } = renderHook(() => useIndexingEstimate(defaultOptions))
expect(result.current.estimate).toEqual({ tokens: 100, total_segments: 5 })
})
it('should select notion mutation for NOTION type', () => {
const { result } = renderHook(() => useIndexingEstimate({
...defaultOptions,
dataSourceType: DataSourceType.NOTION,
}))
expect(result.current.estimate).toBeNull()
})
it('should select web mutation for WEB type', () => {
const { result } = renderHook(() => useIndexingEstimate({
...defaultOptions,
dataSourceType: DataSourceType.WEB,
}))
expect(result.current.estimate).toBeNull()
})
})
describe('fetchEstimate', () => {
it('should call file mutate for FILE type', () => {
const { result } = renderHook(() => useIndexingEstimate(defaultOptions))
result.current.fetchEstimate()
expect(mocks.fileMutate).toHaveBeenCalledOnce()
})
it('should call notion mutate for NOTION type', () => {
const { result } = renderHook(() => useIndexingEstimate({
...defaultOptions,
dataSourceType: DataSourceType.NOTION,
}))
result.current.fetchEstimate()
expect(mocks.notionMutate).toHaveBeenCalledOnce()
})
it('should call web mutate for WEB type', () => {
const { result } = renderHook(() => useIndexingEstimate({
...defaultOptions,
dataSourceType: DataSourceType.WEB,
}))
result.current.fetchEstimate()
expect(mocks.webMutate).toHaveBeenCalledOnce()
})
})
describe('state properties', () => {
it('should expose isIdle', () => {
const { result } = renderHook(() => useIndexingEstimate(defaultOptions))
expect(result.current.isIdle).toBe(true)
})
it('should expose isPending', () => {
const { result } = renderHook(() => useIndexingEstimate(defaultOptions))
expect(result.current.isPending).toBe(false)
})
it('should expose reset function', () => {
const { result } = renderHook(() => useIndexingEstimate(defaultOptions))
result.current.reset()
expect(mocks.fileReset).toHaveBeenCalledOnce()
})
})
})

View File

@@ -1,198 +0,0 @@
import type { NotionPage } from '@/models/common'
import type { CrawlResultItem, CustomFile } from '@/models/datasets'
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DataSourceType } from '@/models/datasets'
import { usePreviewState } from './use-preview-state'
// Factory functions
const createFile = (id: string, name: string): CustomFile => ({
id,
name,
size: 1024,
type: 'text/plain',
extension: 'txt',
created_by: 'user',
created_at: Date.now(),
} as unknown as CustomFile)
const createNotionPage = (pageId: string, pageName: string): NotionPage => ({
page_id: pageId,
page_name: pageName,
page_icon: null,
parent_id: '',
type: 'page',
is_bound: true,
} as unknown as NotionPage)
const createWebsitePage = (url: string, title: string): CrawlResultItem => ({
source_url: url,
title,
markdown: '',
description: '',
} as unknown as CrawlResultItem)
describe('usePreviewState', () => {
const files = [createFile('f-1', 'file1.txt'), createFile('f-2', 'file2.txt')]
const notionPages = [createNotionPage('np-1', 'Page 1'), createNotionPage('np-2', 'Page 2')]
const websitePages = [createWebsitePage('https://a.com', 'Site A'), createWebsitePage('https://b.com', 'Site B')]
beforeEach(() => {
vi.clearAllMocks()
})
describe('initial state for FILE', () => {
it('should set first file as preview', () => {
const { result } = renderHook(() => usePreviewState({
dataSourceType: DataSourceType.FILE,
files,
notionPages: [],
websitePages: [],
}))
expect(result.current.previewFile).toBe(files[0])
})
})
describe('initial state for NOTION', () => {
it('should set first notion page as preview', () => {
const { result } = renderHook(() => usePreviewState({
dataSourceType: DataSourceType.NOTION,
files: [],
notionPages,
websitePages: [],
}))
expect(result.current.previewNotionPage).toBe(notionPages[0])
})
})
describe('initial state for WEB', () => {
it('should set first website page as preview', () => {
const { result } = renderHook(() => usePreviewState({
dataSourceType: DataSourceType.WEB,
files: [],
notionPages: [],
websitePages,
}))
expect(result.current.previewWebsitePage).toBe(websitePages[0])
})
})
describe('getPreviewPickerItems', () => {
it('should return files for FILE type', () => {
const { result } = renderHook(() => usePreviewState({
dataSourceType: DataSourceType.FILE,
files,
notionPages: [],
websitePages: [],
}))
const items = result.current.getPreviewPickerItems()
expect(items).toHaveLength(2)
})
it('should return mapped notion pages for NOTION type', () => {
const { result } = renderHook(() => usePreviewState({
dataSourceType: DataSourceType.NOTION,
files: [],
notionPages,
websitePages: [],
}))
const items = result.current.getPreviewPickerItems()
expect(items).toHaveLength(2)
expect(items[0]).toEqual({ id: 'np-1', name: 'Page 1', extension: 'md' })
})
it('should return mapped website pages for WEB type', () => {
const { result } = renderHook(() => usePreviewState({
dataSourceType: DataSourceType.WEB,
files: [],
notionPages: [],
websitePages,
}))
const items = result.current.getPreviewPickerItems()
expect(items).toHaveLength(2)
expect(items[0]).toEqual({ id: 'https://a.com', name: 'Site A', extension: 'md' })
})
})
describe('getPreviewPickerValue', () => {
it('should return current preview file for FILE type', () => {
const { result } = renderHook(() => usePreviewState({
dataSourceType: DataSourceType.FILE,
files,
notionPages: [],
websitePages: [],
}))
const value = result.current.getPreviewPickerValue()
expect(value).toBe(files[0])
})
it('should return mapped notion page value for NOTION type', () => {
const { result } = renderHook(() => usePreviewState({
dataSourceType: DataSourceType.NOTION,
files: [],
notionPages,
websitePages: [],
}))
const value = result.current.getPreviewPickerValue()
expect(value).toEqual({ id: 'np-1', name: 'Page 1', extension: 'md' })
})
})
describe('handlePreviewChange', () => {
it('should change preview file for FILE type', () => {
const { result } = renderHook(() => usePreviewState({
dataSourceType: DataSourceType.FILE,
files,
notionPages: [],
websitePages: [],
}))
act(() => {
result.current.handlePreviewChange({ id: 'f-2', name: 'file2.txt' })
})
expect(result.current.previewFile).toEqual({ id: 'f-2', name: 'file2.txt' })
})
it('should change preview notion page for NOTION type', () => {
const { result } = renderHook(() => usePreviewState({
dataSourceType: DataSourceType.NOTION,
files: [],
notionPages,
websitePages: [],
}))
act(() => {
result.current.handlePreviewChange({ id: 'np-2', name: 'Page 2' })
})
expect(result.current.previewNotionPage).toBe(notionPages[1])
})
it('should change preview website page for WEB type', () => {
const { result } = renderHook(() => usePreviewState({
dataSourceType: DataSourceType.WEB,
files: [],
notionPages: [],
websitePages,
}))
act(() => {
result.current.handlePreviewChange({ id: 'https://b.com', name: 'Site B' })
})
expect(result.current.previewWebsitePage).toBe(websitePages[1])
})
it('should not change if selected page not found (NOTION)', () => {
const { result } = renderHook(() => usePreviewState({
dataSourceType: DataSourceType.NOTION,
files: [],
notionPages,
websitePages: [],
}))
act(() => {
result.current.handlePreviewChange({ id: 'non-existent', name: 'x' })
})
expect(result.current.previewNotionPage).toBe(notionPages[0])
})
})
})

View File

@@ -1,373 +0,0 @@
import type { PreProcessingRule, Rules } from '@/models/datasets'
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChunkingMode, ProcessMode } from '@/models/datasets'
import {
DEFAULT_MAXIMUM_CHUNK_LENGTH,
DEFAULT_OVERLAP,
DEFAULT_SEGMENT_IDENTIFIER,
defaultParentChildConfig,
useSegmentationState,
} from './use-segmentation-state'
describe('useSegmentationState', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// --- Default state ---
describe('default state', () => {
it('should initialize with default values', () => {
const { result } = renderHook(() => useSegmentationState())
expect(result.current.segmentationType).toBe(ProcessMode.general)
expect(result.current.segmentIdentifier).toBe(DEFAULT_SEGMENT_IDENTIFIER)
expect(result.current.maxChunkLength).toBe(DEFAULT_MAXIMUM_CHUNK_LENGTH)
expect(result.current.overlap).toBe(DEFAULT_OVERLAP)
expect(result.current.rules).toEqual([])
expect(result.current.parentChildConfig).toEqual(defaultParentChildConfig)
})
it('should accept initial segmentation type', () => {
const { result } = renderHook(() =>
useSegmentationState({ initialSegmentationType: ProcessMode.parentChild }),
)
expect(result.current.segmentationType).toBe(ProcessMode.parentChild)
})
it('should accept initial summary index setting', () => {
const setting = { enable: true }
const { result } = renderHook(() =>
useSegmentationState({ initialSummaryIndexSetting: setting }),
)
expect(result.current.summaryIndexSetting).toEqual(setting)
})
})
// --- Setters ---
describe('setters', () => {
it('should update segmentation type', () => {
const { result } = renderHook(() => useSegmentationState())
act(() => {
result.current.setSegmentationType(ProcessMode.parentChild)
})
expect(result.current.segmentationType).toBe(ProcessMode.parentChild)
})
it('should update max chunk length', () => {
const { result } = renderHook(() => useSegmentationState())
act(() => {
result.current.setMaxChunkLength(2048)
})
expect(result.current.maxChunkLength).toBe(2048)
})
it('should update overlap', () => {
const { result } = renderHook(() => useSegmentationState())
act(() => {
result.current.setOverlap(100)
})
expect(result.current.overlap).toBe(100)
})
it('should update rules', () => {
const newRules: PreProcessingRule[] = [
{ id: 'remove_extra_spaces', enabled: true },
{ id: 'remove_urls_emails', enabled: false },
]
const { result } = renderHook(() => useSegmentationState())
act(() => {
result.current.setRules(newRules)
})
expect(result.current.rules).toEqual(newRules)
})
})
// --- Segment identifier with escaping ---
describe('setSegmentIdentifier', () => {
it('should escape the value when setting', () => {
const { result } = renderHook(() => useSegmentationState())
act(() => {
result.current.setSegmentIdentifier('\n\n')
})
expect(result.current.segmentIdentifier).toBe('\\n\\n')
})
it('should reset to default when empty and canEmpty is false', () => {
const { result } = renderHook(() => useSegmentationState())
act(() => {
result.current.setSegmentIdentifier('')
})
expect(result.current.segmentIdentifier).toBe(DEFAULT_SEGMENT_IDENTIFIER)
})
it('should allow empty value when canEmpty is true', () => {
const { result } = renderHook(() => useSegmentationState())
act(() => {
result.current.setSegmentIdentifier('', true)
})
expect(result.current.segmentIdentifier).toBe('')
})
})
// --- Toggle rule ---
describe('toggleRule', () => {
it('should toggle a rule enabled state', () => {
const { result } = renderHook(() => useSegmentationState())
const rules: PreProcessingRule[] = [
{ id: 'remove_extra_spaces', enabled: true },
{ id: 'remove_urls_emails', enabled: false },
]
act(() => {
result.current.setRules(rules)
})
act(() => {
result.current.toggleRule('remove_extra_spaces')
})
expect(result.current.rules[0].enabled).toBe(false)
expect(result.current.rules[1].enabled).toBe(false)
})
it('should toggle second rule without affecting first', () => {
const { result } = renderHook(() => useSegmentationState())
const rules: PreProcessingRule[] = [
{ id: 'remove_extra_spaces', enabled: true },
{ id: 'remove_urls_emails', enabled: false },
]
act(() => {
result.current.setRules(rules)
})
act(() => {
result.current.toggleRule('remove_urls_emails')
})
expect(result.current.rules[0].enabled).toBe(true)
expect(result.current.rules[1].enabled).toBe(true)
})
})
// --- Parent-child config ---
describe('parent-child config', () => {
it('should update parent delimiter with escaping', () => {
const { result } = renderHook(() => useSegmentationState())
act(() => {
result.current.updateParentConfig('delimiter', '\n')
})
expect(result.current.parentChildConfig.parent.delimiter).toBe('\\n')
})
it('should update parent maxLength', () => {
const { result } = renderHook(() => useSegmentationState())
act(() => {
result.current.updateParentConfig('maxLength', 2048)
})
expect(result.current.parentChildConfig.parent.maxLength).toBe(2048)
})
it('should update child delimiter with escaping', () => {
const { result } = renderHook(() => useSegmentationState())
act(() => {
result.current.updateChildConfig('delimiter', '\t')
})
expect(result.current.parentChildConfig.child.delimiter).toBe('\\t')
})
it('should update child maxLength', () => {
const { result } = renderHook(() => useSegmentationState())
act(() => {
result.current.updateChildConfig('maxLength', 256)
})
expect(result.current.parentChildConfig.child.maxLength).toBe(256)
})
it('should set empty delimiter when value is empty', () => {
const { result } = renderHook(() => useSegmentationState())
act(() => {
result.current.updateParentConfig('delimiter', '')
})
expect(result.current.parentChildConfig.parent.delimiter).toBe('')
})
it('should set chunk for context mode', () => {
const { result } = renderHook(() => useSegmentationState())
act(() => {
result.current.setChunkForContext('full-doc')
})
expect(result.current.parentChildConfig.chunkForContext).toBe('full-doc')
})
})
// --- Reset to defaults ---
describe('resetToDefaults', () => {
it('should reset to default config when defaults are set', () => {
const { result } = renderHook(() => useSegmentationState())
const defaultRules: Rules = {
pre_processing_rules: [{ id: 'remove_extra_spaces', enabled: true }],
segmentation: {
separator: '---',
max_tokens: 500,
chunk_overlap: 25,
},
parent_mode: 'paragraph',
subchunk_segmentation: {
separator: '\n',
max_tokens: 200,
},
}
act(() => {
result.current.setDefaultConfig(defaultRules)
})
// Change values
act(() => {
result.current.setMaxChunkLength(2048)
result.current.setOverlap(200)
})
// Reset
act(() => {
result.current.resetToDefaults()
})
expect(result.current.maxChunkLength).toBe(500)
expect(result.current.overlap).toBe(25)
expect(result.current.parentChildConfig).toEqual(defaultParentChildConfig)
})
it('should reset parent-child config even without default config', () => {
const { result } = renderHook(() => useSegmentationState())
act(() => {
result.current.updateParentConfig('maxLength', 9999)
})
act(() => {
result.current.resetToDefaults()
})
expect(result.current.parentChildConfig).toEqual(defaultParentChildConfig)
})
})
// --- applyConfigFromRules ---
describe('applyConfigFromRules', () => {
it('should apply general config from rules', () => {
const { result } = renderHook(() => useSegmentationState())
const rulesConfig: Rules = {
pre_processing_rules: [{ id: 'remove_extra_spaces', enabled: true }],
segmentation: {
separator: '|||',
max_tokens: 800,
chunk_overlap: 30,
},
parent_mode: 'paragraph',
subchunk_segmentation: {
separator: '\n',
max_tokens: 200,
},
}
act(() => {
result.current.applyConfigFromRules(rulesConfig, false)
})
expect(result.current.maxChunkLength).toBe(800)
expect(result.current.overlap).toBe(30)
expect(result.current.rules).toEqual(rulesConfig.pre_processing_rules)
})
it('should apply hierarchical config from rules', () => {
const { result } = renderHook(() => useSegmentationState())
const rulesConfig: Rules = {
pre_processing_rules: [],
segmentation: {
separator: '\n\n',
max_tokens: 1024,
chunk_overlap: 50,
},
parent_mode: 'full-doc',
subchunk_segmentation: {
separator: '\n',
max_tokens: 256,
},
}
act(() => {
result.current.applyConfigFromRules(rulesConfig, true)
})
expect(result.current.parentChildConfig.chunkForContext).toBe('full-doc')
expect(result.current.parentChildConfig.child.maxLength).toBe(256)
})
})
// --- getProcessRule ---
describe('getProcessRule', () => {
it('should build general process rule', () => {
const { result } = renderHook(() => useSegmentationState())
const rule = result.current.getProcessRule(ChunkingMode.text)
expect(rule.mode).toBe(ProcessMode.general)
expect(rule.rules!.segmentation.max_tokens).toBe(DEFAULT_MAXIMUM_CHUNK_LENGTH)
expect(rule.rules!.segmentation.chunk_overlap).toBe(DEFAULT_OVERLAP)
})
it('should build parent-child process rule', () => {
const { result } = renderHook(() => useSegmentationState())
const rule = result.current.getProcessRule(ChunkingMode.parentChild)
expect(rule.mode).toBe('hierarchical')
expect(rule.rules!.parent_mode).toBe('paragraph')
expect(rule.rules!.subchunk_segmentation).toBeDefined()
})
it('should include summary index setting in process rule', () => {
const setting = { enable: true }
const { result } = renderHook(() =>
useSegmentationState({ initialSummaryIndexSetting: setting }),
)
const rule = result.current.getProcessRule(ChunkingMode.text)
expect(rule.summary_index_setting).toEqual(setting)
})
})
// --- Summary index setting ---
describe('handleSummaryIndexSettingChange', () => {
it('should update summary index setting', () => {
const { result } = renderHook(() =>
useSegmentationState({ initialSummaryIndexSetting: { enable: false } }),
)
act(() => {
result.current.handleSummaryIndexSettingChange({ enable: true })
})
expect(result.current.summaryIndexSetting).toEqual({ enable: true })
})
it('should merge with existing setting', () => {
const { result } = renderHook(() =>
useSegmentationState({ initialSummaryIndexSetting: { enable: true } }),
)
act(() => {
result.current.handleSummaryIndexSettingChange({ enable: false })
})
expect(result.current.summaryIndexSetting?.enable).toBe(false)
})
})
})

View File

@@ -10,7 +10,7 @@ import type {
Rules,
} from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { act, cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react'
import { act, fireEvent, render, renderHook, screen } from '@testing-library/react'
import { ConfigurationMethodEnum, ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { ChunkingMode, DataSourceType, ProcessMode } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
@@ -30,8 +30,12 @@ import {
} from './hooks'
import escape from './hooks/escape'
import unescape from './hooks/unescape'
import StepTwo from './index'
// ============================================
// Mock external dependencies
// ============================================
// Mock dataset detail context
const mockDataset = {
id: 'test-dataset-id',
doc_form: ChunkingMode.text,
@@ -56,6 +60,10 @@ vi.mock('@/context/dataset-detail', () => ({
selector({ dataset: mockCurrentDataset, mutateDatasetRes: mockMutateDatasetRes }),
}))
// Note: @/context/i18n is globally mocked in vitest.setup.ts, no need to mock here
// Note: @/hooks/use-breakpoints uses real import
// Mock model hooks
const mockEmbeddingModelList = [
{ provider: 'openai', model: 'text-embedding-ada-002' },
{ provider: 'cohere', model: 'embed-english-v3.0' },
@@ -162,55 +170,18 @@ vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: vi.fn(),
}))
// Enable IS_CE_EDITION to show QA checkbox in tests
vi.mock('@/config', async () => {
const actual = await vi.importActual('@/config')
return { ...actual, IS_CE_EDITION: true }
})
// Mock PreviewDocumentPicker to allow testing handlePickerChange
vi.mock('@/app/components/datasets/common/document-picker/preview-document-picker', () => ({
// eslint-disable-next-line ts/no-explicit-any
default: ({ onChange, value, files }: { onChange: (item: any) => void, value: any, files: any[] }) => (
<div data-testid="preview-picker">
<span>{value?.name}</span>
{files?.map((f: { id: string, name: string }) => (
<button key={f.id} data-testid={`picker-${f.id}`} onClick={() => onChange(f)}>
{f.name}
</button>
))}
</div>
),
}))
// Note: @/app/components/base/toast - uses real import (base component)
// Note: @/app/components/datasets/common/check-rerank-model - uses real import
// Note: @/app/components/base/float-right-container - uses real import (base component)
// Mock checkShowMultiModalTip - requires complex model list structure
vi.mock('@/app/components/datasets/settings/utils', () => ({
checkShowMultiModalTip: () => false,
}))
// Mock complex child components to avoid deep dependency chains when rendering StepTwo
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
default: ({ onSelect, readonly }: { onSelect?: (val: Record<string, string>) => void, readonly?: boolean }) => (
<div data-testid="model-selector" data-readonly={readonly}>
<button onClick={() => onSelect?.({ provider: 'openai', model: 'text-embedding-3-small' })}>Select Model</button>
</div>
),
}))
vi.mock('@/app/components/datasets/common/retrieval-method-config', () => ({
default: ({ disabled }: { disabled?: boolean }) => (
<div data-testid="retrieval-method-config" data-disabled={disabled}>
Retrieval Config
</div>
),
}))
vi.mock('@/app/components/datasets/common/economical-retrieval-method-config', () => ({
default: ({ disabled }: { disabled?: boolean }) => (
<div data-testid="economical-retrieval-config" data-disabled={disabled}>
Economical Config
</div>
),
}))
// ============================================
// Test data factories
// ============================================
const createMockFile = (overrides?: Partial<CustomFile>): CustomFile => ({
id: 'file-1',
@@ -400,6 +371,10 @@ describe('unescape utility', () => {
})
})
// ============================================
// useSegmentationState Hook Tests
// ============================================
describe('useSegmentationState', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -2220,364 +2195,3 @@ describe('Integration Scenarios', () => {
})
})
})
// ============================================
// StepTwo Component Tests
// ============================================
describe('StepTwo Component', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCurrentDataset = null
})
afterEach(() => {
cleanup()
})
const defaultStepTwoProps = {
dataSourceType: DataSourceType.FILE,
files: [createMockFile()],
isAPIKeySet: true,
onSetting: vi.fn(),
notionCredentialId: '',
onStepChange: vi.fn(),
}
describe('Rendering', () => {
it('should render without crashing', () => {
render(<StepTwo {...defaultStepTwoProps} />)
expect(screen.getByText(/stepTwo\.segmentation/i)).toBeInTheDocument()
})
it('should show general chunking options when not in upload', () => {
render(<StepTwo {...defaultStepTwoProps} />)
// Should render the segmentation section
expect(screen.getByText(/stepTwo\.segmentation/i)).toBeInTheDocument()
})
it('should show footer with Previous and Next buttons', () => {
render(<StepTwo {...defaultStepTwoProps} />)
expect(screen.getByText(/stepTwo\.previousStep/i)).toBeInTheDocument()
expect(screen.getByText(/stepTwo\.nextStep/i)).toBeInTheDocument()
})
})
describe('Initialization', () => {
it('should fetch default process rule when not in setting mode', () => {
render(<StepTwo {...defaultStepTwoProps} />)
expect(mockFetchDefaultProcessRuleMutate).toHaveBeenCalledWith('/datasets/process-rule')
})
it('should apply config from rules when in setting mode with document detail', () => {
const docDetail = createMockDocumentDetail()
render(
<StepTwo
{...defaultStepTwoProps}
isSetting={true}
documentDetail={docDetail}
datasetId="test-id"
/>,
)
// Should not fetch default rule when isSetting
expect(mockFetchDefaultProcessRuleMutate).not.toHaveBeenCalled()
})
})
describe('User Interactions', () => {
it('should call onStepChange(-1) when Previous button is clicked', () => {
const onStepChange = vi.fn()
render(<StepTwo {...defaultStepTwoProps} onStepChange={onStepChange} />)
fireEvent.click(screen.getByText(/stepTwo\.previousStep/i))
expect(onStepChange).toHaveBeenCalledWith(-1)
})
it('should trigger handleCreate when Next Step button is clicked', async () => {
const onStepChange = vi.fn()
render(<StepTwo {...defaultStepTwoProps} onStepChange={onStepChange} />)
await act(async () => {
fireEvent.click(screen.getByText(/stepTwo\.nextStep/i))
})
// handleCreate validates, builds params, and calls executeCreation
// which calls onStepChange(1) on success
expect(onStepChange).toHaveBeenCalledWith(1)
})
it('should trigger updatePreview when preview button is clicked', () => {
render(<StepTwo {...defaultStepTwoProps} />)
// GeneralChunkingOptions renders a "Preview Chunk" button
const previewButtons = screen.getAllByText(/stepTwo\.previewChunk/i)
fireEvent.click(previewButtons[0])
// updatePreview calls estimateHook.fetchEstimate()
// No error means the handler executed successfully
})
it('should trigger handleDocFormChange through parent-child option switch', () => {
render(<StepTwo {...defaultStepTwoProps} />)
// ParentChildOptions renders an OptionCard; find the title element and click its parent card
const parentChildTitles = screen.getAllByText(/stepTwo\.parentChild/i)
// The first match is the title; click it to trigger onDocFormChange
fireEvent.click(parentChildTitles[0])
// handleDocFormChange sets docForm, segmentationType, and resets estimate
})
})
describe('Conditional Rendering', () => {
it('should show options based on currentDataset doc_form', () => {
mockCurrentDataset = { ...mockDataset, doc_form: ChunkingMode.parentChild }
render(
<StepTwo
{...defaultStepTwoProps}
datasetId="test-id"
/>,
)
// When currentDataset has parentChild doc_form, should show parent-child option
expect(screen.getByText(/stepTwo\.segmentation/i)).toBeInTheDocument()
})
it('should render setting mode with Save/Cancel buttons', () => {
const docDetail = createMockDocumentDetail()
render(
<StepTwo
{...defaultStepTwoProps}
isSetting={true}
documentDetail={docDetail}
datasetId="test-id"
/>,
)
expect(screen.getByText(/stepTwo\.save/i)).toBeInTheDocument()
expect(screen.getByText(/stepTwo\.cancel/i)).toBeInTheDocument()
})
it('should call onCancel when Cancel button is clicked in setting mode', () => {
const onCancel = vi.fn()
const docDetail = createMockDocumentDetail()
render(
<StepTwo
{...defaultStepTwoProps}
isSetting={true}
documentDetail={docDetail}
datasetId="test-id"
onCancel={onCancel}
/>,
)
fireEvent.click(screen.getByText(/stepTwo\.cancel/i))
expect(onCancel).toHaveBeenCalled()
})
it('should trigger handleCreate (Save) in setting mode', async () => {
const onSave = vi.fn()
const docDetail = createMockDocumentDetail()
render(
<StepTwo
{...defaultStepTwoProps}
isSetting={true}
documentDetail={docDetail}
datasetId="test-id"
onSave={onSave}
/>,
)
await act(async () => {
fireEvent.click(screen.getByText(/stepTwo\.save/i))
})
// handleCreate → validateParams → buildCreationParams → executeCreation → onSave
expect(onSave).toHaveBeenCalled()
})
it('should show both general and parent-child options in create page', () => {
render(<StepTwo {...defaultStepTwoProps} />)
// When isInInit (no datasetId, no isSetting), both options should show
expect(screen.getByText('datasetCreation.stepTwo.general')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepTwo.parentChild')).toBeInTheDocument()
})
it('should only show parent-child option when dataset has parentChild doc_form', () => {
mockCurrentDataset = { ...mockDataset, doc_form: ChunkingMode.parentChild }
render(
<StepTwo
{...defaultStepTwoProps}
datasetId="test-id"
/>,
)
// showGeneralOption should be false (parentChild not in [text, qa])
// showParentChildOption should be true
expect(screen.getByText('datasetCreation.stepTwo.parentChild')).toBeInTheDocument()
})
it('should show general option only when dataset has text doc_form', () => {
mockCurrentDataset = { ...mockDataset, doc_form: ChunkingMode.text }
render(
<StepTwo
{...defaultStepTwoProps}
datasetId="test-id"
/>,
)
// showGeneralOption should be true (text is in [text, qa])
expect(screen.getByText('datasetCreation.stepTwo.general')).toBeInTheDocument()
})
})
describe('Upload in Dataset', () => {
it('should show general option when in upload with text doc_form', () => {
mockCurrentDataset = { ...mockDataset, doc_form: ChunkingMode.text }
render(
<StepTwo
{...defaultStepTwoProps}
datasetId="test-id"
/>,
)
expect(screen.getByText(/stepTwo\.segmentation/i)).toBeInTheDocument()
})
it('should show general option for empty dataset (no doc_form)', () => {
// eslint-disable-next-line ts/no-explicit-any
mockCurrentDataset = { ...mockDataset, doc_form: undefined as any }
render(
<StepTwo
{...defaultStepTwoProps}
datasetId="test-id"
/>,
)
expect(screen.getByText(/stepTwo\.segmentation/i)).toBeInTheDocument()
})
it('should show both options in empty dataset upload', () => {
// eslint-disable-next-line ts/no-explicit-any
mockCurrentDataset = { ...mockDataset, doc_form: undefined as any }
render(
<StepTwo
{...defaultStepTwoProps}
datasetId="test-id"
/>,
)
// isUploadInEmptyDataset=true shows both options
expect(screen.getByText('datasetCreation.stepTwo.general')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepTwo.parentChild')).toBeInTheDocument()
})
})
describe('Indexing Mode', () => {
it('should render indexing mode section', () => {
render(<StepTwo {...defaultStepTwoProps} />)
// IndexingModeSection renders the index mode title
expect(screen.getByText(/stepTwo\.indexMode/i)).toBeInTheDocument()
})
it('should render embedding model selector when QUALIFIED', () => {
render(<StepTwo {...defaultStepTwoProps} />)
// ModelSelector is mocked and rendered with data-testid
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
})
it('should render retrieval method config', () => {
render(<StepTwo {...defaultStepTwoProps} />)
// RetrievalMethodConfig is mocked with data-testid
expect(screen.getByTestId('retrieval-method-config')).toBeInTheDocument()
})
it('should disable model and retrieval config when datasetId has existing data source', () => {
mockCurrentDataset = { ...mockDataset, data_source_type: DataSourceType.FILE }
render(
<StepTwo
{...defaultStepTwoProps}
datasetId="test-id"
/>,
)
// isModelAndRetrievalConfigDisabled should be true
const modelSelector = screen.getByTestId('model-selector')
expect(modelSelector).toHaveAttribute('data-readonly', 'true')
})
})
describe('Preview Panel', () => {
it('should render preview panel', () => {
render(<StepTwo {...defaultStepTwoProps} />)
expect(screen.getByText('datasetCreation.stepTwo.preview')).toBeInTheDocument()
})
it('should hide document picker in setting mode', () => {
const docDetail = createMockDocumentDetail()
render(
<StepTwo
{...defaultStepTwoProps}
isSetting={true}
documentDetail={docDetail}
datasetId="test-id"
/>,
)
// Preview panel should still render
expect(screen.getByText('datasetCreation.stepTwo.preview')).toBeInTheDocument()
})
})
describe('Handler Functions - Uncovered Paths', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCurrentDataset = null
})
afterEach(() => {
cleanup()
})
it('should switch to QUALIFIED when selecting parentChild in ECONOMICAL mode', async () => {
render(<StepTwo {...defaultStepTwoProps} isAPIKeySet={false} />)
await vi.waitFor(() => {
expect(screen.getByText(/stepTwo\.segmentation/i)).toBeInTheDocument()
})
// Click parentChild option to trigger handleDocFormChange(ChunkingMode.parentChild) with ECONOMICAL
const parentChildTitles = screen.getAllByText(/stepTwo\.parentChild/i)
fireEvent.click(parentChildTitles[0])
})
it('should open QA confirm dialog and confirm switch when QA selected in ECONOMICAL mode', async () => {
render(<StepTwo {...defaultStepTwoProps} isAPIKeySet={false} />)
await vi.waitFor(() => {
expect(screen.getByText(/stepTwo\.segmentation/i)).toBeInTheDocument()
})
// Click QA checkbox (visible because IS_CE_EDITION is mocked as true)
const qaCheckbox = screen.getByText(/stepTwo\.useQALanguage/i)
fireEvent.click(qaCheckbox)
// Dialog should open → click Switch to confirm (triggers handleQAConfirm)
const switchButton = await screen.findByText(/stepTwo\.switch/i)
expect(switchButton).toBeInTheDocument()
fireEvent.click(switchButton)
})
it('should close QA confirm dialog when cancel is clicked', async () => {
render(<StepTwo {...defaultStepTwoProps} isAPIKeySet={false} />)
await vi.waitFor(() => {
expect(screen.getByText(/stepTwo\.segmentation/i)).toBeInTheDocument()
})
// Open QA confirm dialog
const qaCheckbox = screen.getByText(/stepTwo\.useQALanguage/i)
fireEvent.click(qaCheckbox)
// Click the dialog cancel button (onQAConfirmDialogClose)
const dialogCancelButtons = await screen.findAllByText(/stepTwo\.cancel/i)
fireEvent.click(dialogCancelButtons[0])
})
it('should handle picker change when selecting a different file', () => {
const files = [
createMockFile({ id: 'file-1', name: 'first.pdf', extension: 'pdf' }),
createMockFile({ id: 'file-2', name: 'second.pdf', extension: 'pdf' }),
]
render(<StepTwo {...defaultStepTwoProps} files={files} />)
// Click on the second file in the mocked picker (triggers handlePickerChange)
const pickerButton = screen.getByTestId('picker-file-2')
fireEvent.click(pickerButton)
})
it('should show error toast when preview is clicked with maxChunkLength exceeding limit', () => {
// Set a high maxChunkLength via the DOM attribute
document.body.setAttribute('data-public-indexing-max-segmentation-tokens-length', '100')
render(<StepTwo {...defaultStepTwoProps} />)
// The default maxChunkLength (1024) now exceeds the limit (100)
// Click preview button to trigger updatePreview error path
const previewButtons = screen.getAllByText(/stepTwo\.previewChunk/i)
fireEvent.click(previewButtons[0])
// Restore
document.body.removeAttribute('data-public-indexing-max-segmentation-tokens-length')
})
})
})

View File

@@ -1,32 +0,0 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { StepperStep } from './step'
describe('StepperStep', () => {
it('should render step name', () => {
render(<StepperStep name="Configure" index={0} activeIndex={0} />)
expect(screen.getByText('Configure')).toBeInTheDocument()
})
it('should show "STEP N" label for active step', () => {
render(<StepperStep name="Configure" index={1} activeIndex={1} />)
expect(screen.getByText('STEP 2')).toBeInTheDocument()
})
it('should show just number for non-active step', () => {
render(<StepperStep name="Configure" index={1} activeIndex={0} />)
expect(screen.getByText('2')).toBeInTheDocument()
})
it('should apply accent style for active step', () => {
render(<StepperStep name="Step A" index={0} activeIndex={0} />)
const nameEl = screen.getByText('Step A')
expect(nameEl.className).toContain('text-text-accent')
})
it('should apply disabled style for future step', () => {
render(<StepperStep name="Step C" index={2} activeIndex={0} />)
const nameEl = screen.getByText('Step C')
expect(nameEl.className).toContain('text-text-quaternary')
})
})

View File

@@ -1,43 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import CheckboxWithLabel from './checkbox-with-label'
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ popupContent }: { popupContent?: React.ReactNode }) => <div data-testid="tooltip">{popupContent}</div>,
}))
describe('CheckboxWithLabel', () => {
const onChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('should render label', () => {
render(<CheckboxWithLabel isChecked={false} onChange={onChange} label="Accept terms" />)
expect(screen.getByText('Accept terms')).toBeInTheDocument()
})
it('should render tooltip when provided', () => {
render(
<CheckboxWithLabel
isChecked={false}
onChange={onChange}
label="Option"
tooltip="Help text"
/>,
)
expect(screen.getByTestId('tooltip')).toBeInTheDocument()
})
it('should not render tooltip when not provided', () => {
render(<CheckboxWithLabel isChecked={false} onChange={onChange} label="Option" />)
expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument()
})
it('should toggle checked state on checkbox click', () => {
render(<CheckboxWithLabel isChecked={false} onChange={onChange} label="Toggle" testId="my-check" />)
fireEvent.click(screen.getByTestId('checkbox-my-check'))
expect(onChange).toHaveBeenCalledWith(true)
})
})

View File

@@ -1,47 +0,0 @@
import type { CrawlResultItem as CrawlResultItemType } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import CrawledResultItem from './crawled-result-item'
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))
describe('CrawledResultItem', () => {
const defaultProps = {
payload: { title: 'Example Page', source_url: 'https://example.com/page' } as CrawlResultItemType,
isChecked: false,
isPreview: false,
onCheckChange: vi.fn(),
onPreview: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render title and url', () => {
render(<CrawledResultItem {...defaultProps} />)
expect(screen.getByText('Example Page')).toBeInTheDocument()
expect(screen.getByText('https://example.com/page')).toBeInTheDocument()
})
it('should apply active styling when isPreview', () => {
const { container } = render(<CrawledResultItem {...defaultProps} isPreview={true} />)
expect((container.firstChild as HTMLElement).className).toContain('bg-state-base-active')
})
it('should call onCheckChange with true when unchecked checkbox is clicked', () => {
render(<CrawledResultItem {...defaultProps} isChecked={false} testId="crawl-item" />)
const checkbox = screen.getByTestId('checkbox-crawl-item')
fireEvent.click(checkbox)
expect(defaultProps.onCheckChange).toHaveBeenCalledWith(true)
})
it('should call onCheckChange with false when checked checkbox is clicked', () => {
render(<CrawledResultItem {...defaultProps} isChecked={true} testId="crawl-item" />)
const checkbox = screen.getByTestId('checkbox-crawl-item')
fireEvent.click(checkbox)
expect(defaultProps.onCheckChange).toHaveBeenCalledWith(false)
})
})

View File

@@ -1,313 +0,0 @@
import type { CrawlResultItem } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import CrawledResult from './crawled-result'
vi.mock('./checkbox-with-label', () => ({
default: ({ isChecked, onChange, label, testId }: {
isChecked: boolean
onChange: (checked: boolean) => void
label: string
testId?: string
}) => (
<label data-testid={testId}>
<input
type="checkbox"
checked={isChecked}
onChange={() => onChange(!isChecked)}
data-testid={`checkbox-${testId}`}
/>
<span>{label}</span>
</label>
),
}))
vi.mock('./crawled-result-item', () => ({
default: ({ payload, isChecked, isPreview, onCheckChange, onPreview, testId }: {
payload: CrawlResultItem
isChecked: boolean
isPreview: boolean
onCheckChange: (checked: boolean) => void
onPreview: () => void
testId?: string
}) => (
<div data-testid={testId} data-preview={isPreview}>
<input
type="checkbox"
checked={isChecked}
onChange={() => onCheckChange(!isChecked)}
data-testid={`check-${testId}`}
/>
<span>{payload.title}</span>
<span>{payload.source_url}</span>
<button onClick={onPreview} data-testid={`preview-${testId}`}>Preview</button>
</div>
),
}))
const createMockItem = (overrides: Partial<CrawlResultItem> = {}): CrawlResultItem => ({
title: 'Test Page',
markdown: '# Test',
description: 'A test page',
source_url: 'https://example.com',
...overrides,
})
const createMockList = (): CrawlResultItem[] => [
createMockItem({ title: 'Page 1', source_url: 'https://example.com/1' }),
createMockItem({ title: 'Page 2', source_url: 'https://example.com/2' }),
createMockItem({ title: 'Page 3', source_url: 'https://example.com/3' }),
]
describe('CrawledResult', () => {
const mockOnSelectedChange = vi.fn()
const mockOnPreview = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render select all checkbox', () => {
const list = createMockList()
render(
<CrawledResult
list={list}
checkedList={[]}
onSelectedChange={mockOnSelectedChange}
onPreview={mockOnPreview}
usedTime={1.5}
/>,
)
expect(screen.getByTestId('select-all')).toBeInTheDocument()
})
it('should render all items from list', () => {
const list = createMockList()
render(
<CrawledResult
list={list}
checkedList={[]}
onSelectedChange={mockOnSelectedChange}
onPreview={mockOnPreview}
usedTime={1.5}
/>,
)
expect(screen.getByTestId('item-0')).toBeInTheDocument()
expect(screen.getByTestId('item-1')).toBeInTheDocument()
expect(screen.getByTestId('item-2')).toBeInTheDocument()
})
it('should render scrap time info', () => {
const list = createMockList()
render(
<CrawledResult
list={list}
checkedList={[]}
onSelectedChange={mockOnSelectedChange}
onPreview={mockOnPreview}
usedTime={1.5}
/>,
)
expect(screen.getByText(/scrapTimeInfo/i)).toBeInTheDocument()
})
it('should apply custom className', () => {
const list = createMockList()
const { container } = render(
<CrawledResult
className="custom-class"
list={list}
checkedList={[]}
onSelectedChange={mockOnSelectedChange}
onPreview={mockOnPreview}
usedTime={1.5}
/>,
)
const rootElement = container.firstChild as HTMLElement
expect(rootElement).toHaveClass('custom-class')
})
})
describe('Select All', () => {
it('should call onSelectedChange with full list when not all checked', () => {
const list = createMockList()
render(
<CrawledResult
list={list}
checkedList={[list[0]]}
onSelectedChange={mockOnSelectedChange}
onPreview={mockOnPreview}
usedTime={1.5}
/>,
)
const selectAllCheckbox = screen.getByTestId('checkbox-select-all')
fireEvent.click(selectAllCheckbox)
expect(mockOnSelectedChange).toHaveBeenCalledWith(list)
})
it('should call onSelectedChange with empty array when all checked', () => {
const list = createMockList()
render(
<CrawledResult
list={list}
checkedList={list}
onSelectedChange={mockOnSelectedChange}
onPreview={mockOnPreview}
usedTime={1.5}
/>,
)
const selectAllCheckbox = screen.getByTestId('checkbox-select-all')
fireEvent.click(selectAllCheckbox)
expect(mockOnSelectedChange).toHaveBeenCalledWith([])
})
it('should show selectAll label when not all checked', () => {
const list = createMockList()
render(
<CrawledResult
list={list}
checkedList={[list[0]]}
onSelectedChange={mockOnSelectedChange}
onPreview={mockOnPreview}
usedTime={1.5}
/>,
)
expect(screen.getByText(/selectAll/i)).toBeInTheDocument()
})
it('should show resetAll label when all checked', () => {
const list = createMockList()
render(
<CrawledResult
list={list}
checkedList={list}
onSelectedChange={mockOnSelectedChange}
onPreview={mockOnPreview}
usedTime={1.5}
/>,
)
expect(screen.getByText(/resetAll/i)).toBeInTheDocument()
})
})
describe('Individual Item Check', () => {
it('should call onSelectedChange with added item when checking', () => {
const list = createMockList()
const checkedList = [list[0]]
render(
<CrawledResult
list={list}
checkedList={checkedList}
onSelectedChange={mockOnSelectedChange}
onPreview={mockOnPreview}
usedTime={1.5}
/>,
)
const item1Checkbox = screen.getByTestId('check-item-1')
fireEvent.click(item1Checkbox)
expect(mockOnSelectedChange).toHaveBeenCalledWith([list[0], list[1]])
})
it('should call onSelectedChange with removed item when unchecking', () => {
const list = createMockList()
const checkedList = [list[0], list[1]]
render(
<CrawledResult
list={list}
checkedList={checkedList}
onSelectedChange={mockOnSelectedChange}
onPreview={mockOnPreview}
usedTime={1.5}
/>,
)
const item0Checkbox = screen.getByTestId('check-item-0')
fireEvent.click(item0Checkbox)
expect(mockOnSelectedChange).toHaveBeenCalledWith([list[1]])
})
})
describe('Preview', () => {
it('should call onPreview with correct item when preview clicked', () => {
const list = createMockList()
render(
<CrawledResult
list={list}
checkedList={[]}
onSelectedChange={mockOnSelectedChange}
onPreview={mockOnPreview}
usedTime={1.5}
/>,
)
const previewButton = screen.getByTestId('preview-item-1')
fireEvent.click(previewButton)
expect(mockOnPreview).toHaveBeenCalledWith(list[1])
})
it('should update preview state when preview button is clicked', () => {
const list = createMockList()
render(
<CrawledResult
list={list}
checkedList={[]}
onSelectedChange={mockOnSelectedChange}
onPreview={mockOnPreview}
usedTime={1.5}
/>,
)
const previewButton = screen.getByTestId('preview-item-0')
fireEvent.click(previewButton)
const item0 = screen.getByTestId('item-0')
expect(item0).toHaveAttribute('data-preview', 'true')
})
})
describe('Edge Cases', () => {
it('should render empty list without crashing', () => {
render(
<CrawledResult
list={[]}
checkedList={[]}
onSelectedChange={mockOnSelectedChange}
onPreview={mockOnPreview}
usedTime={0}
/>,
)
expect(screen.getByTestId('select-all')).toBeInTheDocument()
})
it('should handle single item list', () => {
const list = [createMockItem()]
render(
<CrawledResult
list={list}
checkedList={[]}
onSelectedChange={mockOnSelectedChange}
onPreview={mockOnPreview}
usedTime={0.5}
/>,
)
expect(screen.getByTestId('item-0')).toBeInTheDocument()
})
})
})

View File

@@ -1,23 +0,0 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import Crawling from './crawling'
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))
vi.mock('@/app/components/base/icons/src/public/other', () => ({
RowStruct: (props: React.HTMLAttributes<HTMLDivElement>) => <div data-testid="row-struct" {...props} />,
}))
describe('Crawling', () => {
it('should render crawled count and total', () => {
render(<Crawling crawledNum={3} totalNum={10} />)
expect(screen.getByText(/3/)).toBeInTheDocument()
expect(screen.getByText(/10/)).toBeInTheDocument()
})
it('should render skeleton rows', () => {
render(<Crawling crawledNum={0} totalNum={5} />)
expect(screen.getAllByTestId('row-struct')).toHaveLength(4)
})
})

View File

@@ -1,29 +0,0 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import ErrorMessage from './error-message'
vi.mock('@/app/components/base/icons/src/vender/solid/alertsAndFeedback', () => ({
AlertTriangle: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="alert-icon" {...props} />,
}))
describe('ErrorMessage', () => {
it('should render title', () => {
render(<ErrorMessage title="Something went wrong" />)
expect(screen.getByText('Something went wrong')).toBeInTheDocument()
})
it('should render error message when provided', () => {
render(<ErrorMessage title="Error" errorMsg="Detailed error info" />)
expect(screen.getByText('Detailed error info')).toBeInTheDocument()
})
it('should not render error message when not provided', () => {
render(<ErrorMessage title="Error" />)
expect(screen.queryByText('Detailed error info')).not.toBeInTheDocument()
})
it('should render alert icon', () => {
render(<ErrorMessage title="Error" />)
expect(screen.getByTestId('alert-icon')).toBeInTheDocument()
})
})

View File

@@ -1,46 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Field from './field'
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ popupContent }: { popupContent?: React.ReactNode }) => <div data-testid="tooltip">{popupContent}</div>,
}))
describe('WebsiteField', () => {
const onChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('should render label', () => {
render(<Field label="URL" value="" onChange={onChange} />)
expect(screen.getByText('URL')).toBeInTheDocument()
})
it('should render required asterisk when isRequired', () => {
render(<Field label="URL" value="" onChange={onChange} isRequired />)
expect(screen.getByText('*')).toBeInTheDocument()
})
it('should not render required asterisk by default', () => {
render(<Field label="URL" value="" onChange={onChange} />)
expect(screen.queryByText('*')).not.toBeInTheDocument()
})
it('should render tooltip when provided', () => {
render(<Field label="URL" value="" onChange={onChange} tooltip="Enter full URL" />)
expect(screen.getByTestId('tooltip')).toBeInTheDocument()
})
it('should pass value and onChange to Input', () => {
render(<Field label="URL" value="https://example.com" onChange={onChange} />)
expect(screen.getByDisplayValue('https://example.com')).toBeInTheDocument()
})
it('should call onChange when input changes', () => {
render(<Field label="URL" value="" onChange={onChange} />)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'new' } })
expect(onChange).toHaveBeenCalledWith('new')
})
})

View File

@@ -1,50 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Header from './header'
vi.mock('@remixicon/react', () => ({
RiBookOpenLine: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="book-icon" {...props} />,
RiEqualizer2Line: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="config-icon" {...props} />,
}))
describe('WebsiteHeader', () => {
const defaultProps = {
title: 'Jina Reader',
docTitle: 'Documentation',
docLink: 'https://docs.example.com',
onClickConfiguration: vi.fn(),
buttonText: 'Config',
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render title', () => {
render(<Header {...defaultProps} />)
expect(screen.getByText('Jina Reader')).toBeInTheDocument()
})
it('should render doc link with correct href', () => {
render(<Header {...defaultProps} />)
const link = screen.getByText('Documentation').closest('a')
expect(link).toHaveAttribute('href', 'https://docs.example.com')
expect(link).toHaveAttribute('target', '_blank')
})
it('should render configuration button with text when not in pipeline', () => {
render(<Header {...defaultProps} />)
expect(screen.getByText('Config')).toBeInTheDocument()
})
it('should call onClickConfiguration on button click', () => {
render(<Header {...defaultProps} />)
fireEvent.click(screen.getByText('Config').closest('button')!)
expect(defaultProps.onClickConfiguration).toHaveBeenCalledOnce()
})
it('should hide button text when isInPipeline', () => {
render(<Header {...defaultProps} isInPipeline={true} />)
expect(screen.queryByText('Config')).not.toBeInTheDocument()
})
})

View File

@@ -1,52 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Input from './input'
describe('WebsiteInput', () => {
const onChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('should render text input by default', () => {
render(<Input value="hello" onChange={onChange} />)
const input = screen.getByDisplayValue('hello')
expect(input).toHaveAttribute('type', 'text')
})
it('should render number input when isNumber is true', () => {
render(<Input value={42} onChange={onChange} isNumber />)
const input = screen.getByDisplayValue('42')
expect(input).toHaveAttribute('type', 'number')
})
it('should call onChange with string value for text input', () => {
render(<Input value="" onChange={onChange} />)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'new value' } })
expect(onChange).toHaveBeenCalledWith('new value')
})
it('should call onChange with parsed integer for number input', () => {
render(<Input value={0} onChange={onChange} isNumber />)
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '10' } })
expect(onChange).toHaveBeenCalledWith(10)
})
it('should call onChange with empty string for NaN number input', () => {
render(<Input value={0} onChange={onChange} isNumber />)
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: 'abc' } })
expect(onChange).toHaveBeenCalledWith('')
})
it('should clamp negative numbers to 0', () => {
render(<Input value={0} onChange={onChange} isNumber />)
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '-5' } })
expect(onChange).toHaveBeenCalledWith(0)
})
it('should render placeholder', () => {
render(<Input value="" onChange={onChange} placeholder="Enter URL" />)
expect(screen.getByPlaceholderText('Enter URL')).toBeInTheDocument()
})
})

View File

@@ -1,51 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import OptionsWrap from './options-wrap'
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))
vi.mock('@remixicon/react', () => ({
RiEqualizer2Line: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="config-icon" {...props} />,
}))
vi.mock('@/app/components/base/icons/src/vender/line/arrows', () => ({
ChevronRight: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="chevron-icon" {...props} />,
}))
describe('OptionsWrap', () => {
it('should render children when not folded', () => {
render(
<OptionsWrap>
<div data-testid="child-content">Options here</div>
</OptionsWrap>,
)
expect(screen.getByTestId('child-content')).toBeInTheDocument()
})
it('should toggle fold on click', () => {
render(
<OptionsWrap>
<div data-testid="child-content">Options here</div>
</OptionsWrap>,
)
// Initially visible
expect(screen.getByTestId('child-content')).toBeInTheDocument()
// Click to fold
fireEvent.click(screen.getByText('stepOne.website.options'))
expect(screen.queryByTestId('child-content')).not.toBeInTheDocument()
// Click to unfold
fireEvent.click(screen.getByText('stepOne.website.options'))
expect(screen.getByTestId('child-content')).toBeInTheDocument()
})
it('should render options label', () => {
render(
<OptionsWrap>
<div>Content</div>
</OptionsWrap>,
)
expect(screen.getByText('stepOne.website.options')).toBeInTheDocument()
})
})

View File

@@ -1,286 +0,0 @@
import type { DataSourceAuth } from '@/app/components/header/account-setting/data-source-page-new/types'
import type { CrawlOptions, CrawlResultItem } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types'
import Website from './index'
const mockSetShowAccountSettingModal = vi.fn()
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
}))
vi.mock('./index.module.css', () => ({
default: {
jinaLogo: 'jina-logo',
watercrawlLogo: 'watercrawl-logo',
},
}))
vi.mock('./firecrawl', () => ({
default: (props: Record<string, unknown>) => <div data-testid="firecrawl-component" data-props={JSON.stringify(props)} />,
}))
vi.mock('./jina-reader', () => ({
default: (props: Record<string, unknown>) => <div data-testid="jina-reader-component" data-props={JSON.stringify(props)} />,
}))
vi.mock('./watercrawl', () => ({
default: (props: Record<string, unknown>) => <div data-testid="watercrawl-component" data-props={JSON.stringify(props)} />,
}))
vi.mock('./no-data', () => ({
default: ({ onConfig, provider }: { onConfig: () => void, provider: string }) => (
<div data-testid="no-data-component" data-provider={provider}>
<button onClick={onConfig} data-testid="no-data-config-button">Configure</button>
</div>
),
}))
let mockEnableJinaReader = true
let mockEnableFirecrawl = true
let mockEnableWatercrawl = true
vi.mock('@/config', () => ({
get ENABLE_WEBSITE_JINAREADER() { return mockEnableJinaReader },
get ENABLE_WEBSITE_FIRECRAWL() { return mockEnableFirecrawl },
get ENABLE_WEBSITE_WATERCRAWL() { return mockEnableWatercrawl },
}))
const createMockCrawlOptions = (overrides: Partial<CrawlOptions> = {}): CrawlOptions => ({
crawl_sub_pages: true,
limit: 10,
max_depth: 2,
excludes: '',
includes: '',
only_main_content: false,
use_sitemap: false,
...overrides,
})
const createMockDataSourceAuth = (
provider: string,
credentialsCount = 1,
): DataSourceAuth => ({
author: 'test',
provider,
plugin_id: `${provider}-plugin`,
plugin_unique_identifier: `${provider}-unique`,
icon: 'icon.png',
name: provider,
label: { en_US: provider, zh_Hans: provider },
description: { en_US: `${provider} description`, zh_Hans: `${provider} description` },
credentials_list: Array.from({ length: credentialsCount }, (_, i) => ({
credential: {},
type: CredentialTypeEnum.API_KEY,
name: `cred-${i}`,
id: `cred-${i}`,
is_default: i === 0,
avatar_url: '',
})),
})
type RenderProps = {
authedDataSourceList?: DataSourceAuth[]
enableJina?: boolean
enableFirecrawl?: boolean
enableWatercrawl?: boolean
}
const renderWebsite = ({
authedDataSourceList = [],
enableJina = true,
enableFirecrawl = true,
enableWatercrawl = true,
}: RenderProps = {}) => {
mockEnableJinaReader = enableJina
mockEnableFirecrawl = enableFirecrawl
mockEnableWatercrawl = enableWatercrawl
const props = {
onPreview: vi.fn() as (payload: CrawlResultItem) => void,
checkedCrawlResult: [] as CrawlResultItem[],
onCheckedCrawlResultChange: vi.fn() as (payload: CrawlResultItem[]) => void,
onCrawlProviderChange: vi.fn(),
onJobIdChange: vi.fn(),
crawlOptions: createMockCrawlOptions(),
onCrawlOptionsChange: vi.fn() as (payload: CrawlOptions) => void,
authedDataSourceList,
}
const result = render(<Website {...props} />)
return { ...result, props }
}
describe('Website', () => {
beforeEach(() => {
vi.clearAllMocks()
mockEnableJinaReader = true
mockEnableFirecrawl = true
mockEnableWatercrawl = true
})
describe('Rendering', () => {
it('should render provider selection section', () => {
renderWebsite()
expect(screen.getByText(/chooseProvider/i)).toBeInTheDocument()
})
it('should show Jina Reader button when ENABLE_WEBSITE_JINAREADER is true', () => {
renderWebsite({ enableJina: true })
expect(screen.getByText('Jina Reader')).toBeInTheDocument()
})
it('should not show Jina Reader button when ENABLE_WEBSITE_JINAREADER is false', () => {
renderWebsite({ enableJina: false })
expect(screen.queryByText('Jina Reader')).not.toBeInTheDocument()
})
it('should show Firecrawl button when ENABLE_WEBSITE_FIRECRAWL is true', () => {
renderWebsite({ enableFirecrawl: true })
expect(screen.getByText(/Firecrawl/)).toBeInTheDocument()
})
it('should not show Firecrawl button when ENABLE_WEBSITE_FIRECRAWL is false', () => {
renderWebsite({ enableFirecrawl: false })
expect(screen.queryByText(/Firecrawl/)).not.toBeInTheDocument()
})
it('should show WaterCrawl button when ENABLE_WEBSITE_WATERCRAWL is true', () => {
renderWebsite({ enableWatercrawl: true })
expect(screen.getByText('WaterCrawl')).toBeInTheDocument()
})
it('should not show WaterCrawl button when ENABLE_WEBSITE_WATERCRAWL is false', () => {
renderWebsite({ enableWatercrawl: false })
expect(screen.queryByText('WaterCrawl')).not.toBeInTheDocument()
})
})
describe('Provider Selection', () => {
it('should select Jina Reader by default', () => {
const authedDataSourceList = [createMockDataSourceAuth('jinareader')]
renderWebsite({ authedDataSourceList })
expect(screen.getByTestId('jina-reader-component')).toBeInTheDocument()
})
it('should switch to Firecrawl when Firecrawl button clicked', () => {
const authedDataSourceList = [
createMockDataSourceAuth('jinareader'),
createMockDataSourceAuth('firecrawl'),
]
renderWebsite({ authedDataSourceList })
const firecrawlButton = screen.getByText(/Firecrawl/)
fireEvent.click(firecrawlButton)
expect(screen.getByTestId('firecrawl-component')).toBeInTheDocument()
expect(screen.queryByTestId('jina-reader-component')).not.toBeInTheDocument()
})
it('should switch to WaterCrawl when WaterCrawl button clicked', () => {
const authedDataSourceList = [
createMockDataSourceAuth('jinareader'),
createMockDataSourceAuth('watercrawl'),
]
renderWebsite({ authedDataSourceList })
const watercrawlButton = screen.getByText('WaterCrawl')
fireEvent.click(watercrawlButton)
expect(screen.getByTestId('watercrawl-component')).toBeInTheDocument()
expect(screen.queryByTestId('jina-reader-component')).not.toBeInTheDocument()
})
it('should call onCrawlProviderChange when provider switched', () => {
const authedDataSourceList = [
createMockDataSourceAuth('jinareader'),
createMockDataSourceAuth('firecrawl'),
]
const { props } = renderWebsite({ authedDataSourceList })
const firecrawlButton = screen.getByText(/Firecrawl/)
fireEvent.click(firecrawlButton)
expect(props.onCrawlProviderChange).toHaveBeenCalledWith('firecrawl')
})
})
describe('Provider Content', () => {
it('should show JinaReader component when selected and available', () => {
const authedDataSourceList = [createMockDataSourceAuth('jinareader')]
renderWebsite({ authedDataSourceList })
expect(screen.getByTestId('jina-reader-component')).toBeInTheDocument()
})
it('should show Firecrawl component when selected and available', () => {
const authedDataSourceList = [
createMockDataSourceAuth('jinareader'),
createMockDataSourceAuth('firecrawl'),
]
renderWebsite({ authedDataSourceList })
const firecrawlButton = screen.getByText(/Firecrawl/)
fireEvent.click(firecrawlButton)
expect(screen.getByTestId('firecrawl-component')).toBeInTheDocument()
})
it('should show NoData when selected provider has no credentials', () => {
const authedDataSourceList = [createMockDataSourceAuth('jinareader', 0)]
renderWebsite({ authedDataSourceList })
expect(screen.getByTestId('no-data-component')).toBeInTheDocument()
})
it('should show NoData when no data source available for selected provider', () => {
renderWebsite({ authedDataSourceList: [] })
expect(screen.getByTestId('no-data-component')).toBeInTheDocument()
})
})
describe('NoData Config', () => {
it('should call setShowAccountSettingModal when NoData onConfig is triggered', () => {
renderWebsite({ authedDataSourceList: [] })
const configButton = screen.getByTestId('no-data-config-button')
fireEvent.click(configButton)
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: 'data-source',
})
})
})
describe('Edge Cases', () => {
it('should handle no providers enabled', () => {
renderWebsite({
enableJina: false,
enableFirecrawl: false,
enableWatercrawl: false,
})
expect(screen.queryByText('Jina Reader')).not.toBeInTheDocument()
expect(screen.queryByText(/Firecrawl/)).not.toBeInTheDocument()
expect(screen.queryByText('WaterCrawl')).not.toBeInTheDocument()
})
it('should handle only one provider enabled', () => {
renderWebsite({
enableJina: true,
enableFirecrawl: false,
enableWatercrawl: false,
})
expect(screen.getByText('Jina Reader')).toBeInTheDocument()
expect(screen.queryByText(/Firecrawl/)).not.toBeInTheDocument()
expect(screen.queryByText('WaterCrawl')).not.toBeInTheDocument()
})
})
})

View File

@@ -1,212 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ============================================================================
// Component Imports (after mocks)
// ============================================================================
import UrlInput from './url-input'
// ============================================================================
// Mock Setup
// ============================================================================
vi.mock('@/context/i18n', () => ({
useDocLink: vi.fn(() => () => 'https://docs.example.com'),
}))
// ============================================================================
// Jina Reader UrlInput Component Tests
// ============================================================================
describe('UrlInput (jina-reader)', () => {
const mockOnRun = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render input and run button', () => {
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render input with placeholder from docLink', () => {
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
expect(input).toHaveAttribute('placeholder', 'https://docs.example.com')
})
it('should show run text when not running', () => {
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const button = screen.getByRole('button')
expect(button).toHaveTextContent(/run/i)
})
it('should hide run text when running', () => {
render(<UrlInput isRunning={true} onRun={mockOnRun} />)
const button = screen.getByRole('button')
expect(button).not.toHaveTextContent(/run/i)
})
it('should show loading state on button when running', () => {
render(<UrlInput isRunning={true} onRun={mockOnRun} />)
const button = screen.getByRole('button')
expect(button).toHaveTextContent(/loading/i)
})
it('should not show loading state on button when not running', () => {
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const button = screen.getByRole('button')
expect(button).not.toHaveTextContent(/loading/i)
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should update url when user types in input', async () => {
const user = userEvent.setup()
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
await user.type(input, 'https://example.com')
expect(input).toHaveValue('https://example.com')
})
it('should call onRun with url when run button clicked and not running', async () => {
const user = userEvent.setup()
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
await user.type(input, 'https://example.com')
const button = screen.getByRole('button')
await user.click(button)
expect(mockOnRun).toHaveBeenCalledWith('https://example.com')
expect(mockOnRun).toHaveBeenCalledTimes(1)
})
it('should NOT call onRun when isRunning is true', async () => {
const user = userEvent.setup()
render(<UrlInput isRunning={true} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'https://example.com' } })
const button = screen.getByRole('button')
await user.click(button)
expect(mockOnRun).not.toHaveBeenCalled()
})
it('should call onRun with empty string when button clicked with empty input', async () => {
const user = userEvent.setup()
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const button = screen.getByRole('button')
await user.click(button)
expect(mockOnRun).toHaveBeenCalledWith('')
})
})
// --------------------------------------------------------------------------
// Props Variations Tests
// --------------------------------------------------------------------------
describe('Props Variations', () => {
it('should update button state when isRunning changes from false to true', () => {
const { rerender } = render(<UrlInput isRunning={false} onRun={mockOnRun} />)
expect(screen.getByRole('button')).toHaveTextContent(/run/i)
rerender(<UrlInput isRunning={true} onRun={mockOnRun} />)
expect(screen.getByRole('button')).not.toHaveTextContent(/run/i)
})
it('should preserve input value when isRunning prop changes', async () => {
const user = userEvent.setup()
const { rerender } = render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
await user.type(input, 'https://preserved.com')
expect(input).toHaveValue('https://preserved.com')
rerender(<UrlInput isRunning={true} onRun={mockOnRun} />)
expect(input).toHaveValue('https://preserved.com')
})
})
// --------------------------------------------------------------------------
// Edge Cases Tests
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle special characters in url', async () => {
const user = userEvent.setup()
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
const specialUrl = 'https://example.com/path?query=test&param=value#anchor'
await user.type(input, specialUrl)
const button = screen.getByRole('button')
await user.click(button)
expect(mockOnRun).toHaveBeenCalledWith(specialUrl)
})
it('should handle rapid input changes', () => {
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'a' } })
fireEvent.change(input, { target: { value: 'ab' } })
fireEvent.change(input, { target: { value: 'https://final.com' } })
expect(input).toHaveValue('https://final.com')
fireEvent.click(screen.getByRole('button'))
expect(mockOnRun).toHaveBeenCalledWith('https://final.com')
})
})
// --------------------------------------------------------------------------
// Integration Tests
// --------------------------------------------------------------------------
describe('Integration', () => {
it('should complete full workflow: type url -> click run -> verify callback', async () => {
const user = userEvent.setup()
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
await user.type(input, 'https://mywebsite.com')
const button = screen.getByRole('button')
await user.click(button)
expect(mockOnRun).toHaveBeenCalledWith('https://mywebsite.com')
})
it('should show correct states during running workflow', () => {
const { rerender } = render(<UrlInput isRunning={false} onRun={mockOnRun} />)
expect(screen.getByRole('button')).toHaveTextContent(/run/i)
rerender(<UrlInput isRunning={true} onRun={mockOnRun} />)
expect(screen.getByRole('button')).not.toHaveTextContent(/run/i)
rerender(<UrlInput isRunning={false} onRun={mockOnRun} />)
expect(screen.getByRole('button')).toHaveTextContent(/run/i)
})
})
})

View File

@@ -1,209 +0,0 @@
import type { CrawlOptions } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Options from './options'
// ============================================================================
// Test Data Factory
// ============================================================================
const createMockCrawlOptions = (overrides: Partial<CrawlOptions> = {}): CrawlOptions => ({
crawl_sub_pages: true,
limit: 10,
max_depth: 2,
excludes: '',
includes: '',
only_main_content: false,
use_sitemap: false,
...overrides,
})
// ============================================================================
// Jina Reader Options Component Tests
// ============================================================================
describe('Options (jina-reader)', () => {
const mockOnChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
const getCheckboxes = (container: HTMLElement) => {
return container.querySelectorAll('[data-testid^="checkbox-"]')
}
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render crawlSubPage and useSitemap checkboxes and limit field', () => {
const payload = createMockCrawlOptions()
render(<Options payload={payload} onChange={mockOnChange} />)
expect(screen.getByText(/crawlSubPage/i)).toBeInTheDocument()
expect(screen.getByText(/useSitemap/i)).toBeInTheDocument()
expect(screen.getByText(/limit/i)).toBeInTheDocument()
})
it('should render two checkboxes', () => {
const payload = createMockCrawlOptions()
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
expect(checkboxes.length).toBe(2)
})
it('should render limit field with required indicator', () => {
const payload = createMockCrawlOptions()
render(<Options payload={payload} onChange={mockOnChange} />)
const requiredIndicator = screen.getByText('*')
expect(requiredIndicator).toBeInTheDocument()
})
it('should render with custom className', () => {
const payload = createMockCrawlOptions()
const { container } = render(
<Options payload={payload} onChange={mockOnChange} className="custom-class" />,
)
const rootElement = container.firstChild as HTMLElement
expect(rootElement).toHaveClass('custom-class')
})
})
// --------------------------------------------------------------------------
// Props Display Tests
// --------------------------------------------------------------------------
describe('Props Display', () => {
it('should display crawl_sub_pages checkbox with check icon when true', () => {
const payload = createMockCrawlOptions({ crawl_sub_pages: true })
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
expect(checkboxes[0].querySelector('svg')).toBeInTheDocument()
})
it('should display crawl_sub_pages checkbox without check icon when false', () => {
const payload = createMockCrawlOptions({ crawl_sub_pages: false })
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
expect(checkboxes[0].querySelector('svg')).not.toBeInTheDocument()
})
it('should display use_sitemap checkbox with check icon when true', () => {
const payload = createMockCrawlOptions({ use_sitemap: true })
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
expect(checkboxes[1].querySelector('svg')).toBeInTheDocument()
})
it('should display use_sitemap checkbox without check icon when false', () => {
const payload = createMockCrawlOptions({ use_sitemap: false })
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
expect(checkboxes[1].querySelector('svg')).not.toBeInTheDocument()
})
it('should display limit value in input', () => {
const payload = createMockCrawlOptions({ limit: 25 })
render(<Options payload={payload} onChange={mockOnChange} />)
expect(screen.getByDisplayValue('25')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call onChange with updated crawl_sub_pages when checkbox is clicked', () => {
const payload = createMockCrawlOptions({ crawl_sub_pages: true })
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
fireEvent.click(checkboxes[0])
expect(mockOnChange).toHaveBeenCalledWith({
...payload,
crawl_sub_pages: false,
})
})
it('should call onChange with updated use_sitemap when checkbox is clicked', () => {
const payload = createMockCrawlOptions({ use_sitemap: false })
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
fireEvent.click(checkboxes[1])
expect(mockOnChange).toHaveBeenCalledWith({
...payload,
use_sitemap: true,
})
})
it('should call onChange with updated limit when input changes', () => {
const payload = createMockCrawlOptions({ limit: 10 })
render(<Options payload={payload} onChange={mockOnChange} />)
const limitInput = screen.getByDisplayValue('10')
fireEvent.change(limitInput, { target: { value: '50' } })
expect(mockOnChange).toHaveBeenCalledWith({
...payload,
limit: 50,
})
})
})
// --------------------------------------------------------------------------
// Edge Cases Tests
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle zero limit value', () => {
const payload = createMockCrawlOptions({ limit: 0 })
render(<Options payload={payload} onChange={mockOnChange} />)
const zeroInputs = screen.getAllByDisplayValue('0')
expect(zeroInputs.length).toBeGreaterThanOrEqual(1)
})
it('should preserve other payload fields when updating one field', () => {
const payload = createMockCrawlOptions({
crawl_sub_pages: true,
limit: 10,
use_sitemap: true,
})
render(<Options payload={payload} onChange={mockOnChange} />)
const limitInput = screen.getByDisplayValue('10')
fireEvent.change(limitInput, { target: { value: '20' } })
expect(mockOnChange).toHaveBeenCalledWith({
...payload,
limit: 20,
})
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should re-render when payload changes', () => {
const payload1 = createMockCrawlOptions({ limit: 10 })
const payload2 = createMockCrawlOptions({ limit: 20 })
const { rerender } = render(<Options payload={payload1} onChange={mockOnChange} />)
expect(screen.getByDisplayValue('10')).toBeInTheDocument()
rerender(<Options payload={payload2} onChange={mockOnChange} />)
expect(screen.getByDisplayValue('20')).toBeInTheDocument()
})
})
})

View File

@@ -1,230 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DataSourceProvider } from '@/models/common'
import NoData from './no-data'
// ============================================================================
// Mock Setup
// ============================================================================
// Mock CSS module
vi.mock('./index.module.css', () => ({
default: {
jinaLogo: 'jinaLogo',
watercrawlLogo: 'watercrawlLogo',
},
}))
// Feature flags - default all enabled
let mockEnableFirecrawl = true
let mockEnableJinaReader = true
let mockEnableWaterCrawl = true
vi.mock('@/config', () => ({
get ENABLE_WEBSITE_FIRECRAWL() { return mockEnableFirecrawl },
get ENABLE_WEBSITE_JINAREADER() { return mockEnableJinaReader },
get ENABLE_WEBSITE_WATERCRAWL() { return mockEnableWaterCrawl },
}))
// ============================================================================
// NoData Component Tests
// ============================================================================
describe('NoData', () => {
const mockOnConfig = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockEnableFirecrawl = true
mockEnableJinaReader = true
mockEnableWaterCrawl = true
})
// --------------------------------------------------------------------------
// Rendering Tests - Per Provider
// --------------------------------------------------------------------------
describe('Rendering per provider', () => {
it('should render fireCrawl provider with emoji and not-configured message', () => {
// Arrange & Act
render(<NoData onConfig={mockOnConfig} provider={DataSourceProvider.fireCrawl} />)
// Assert
expect(screen.getByText('🔥')).toBeInTheDocument()
const titleAndDesc = screen.getAllByText(/fireCrawlNotConfigured/i)
expect(titleAndDesc).toHaveLength(2)
})
it('should render jinaReader provider with jina logo and not-configured message', () => {
// Arrange & Act
render(<NoData onConfig={mockOnConfig} provider={DataSourceProvider.jinaReader} />)
// Assert
const titleAndDesc = screen.getAllByText(/jinaReaderNotConfigured/i)
expect(titleAndDesc).toHaveLength(2)
})
it('should render waterCrawl provider with emoji and not-configured message', () => {
// Arrange & Act
render(<NoData onConfig={mockOnConfig} provider={DataSourceProvider.waterCrawl} />)
// Assert
expect(screen.getByText('💧')).toBeInTheDocument()
const titleAndDesc = screen.getAllByText(/waterCrawlNotConfigured/i)
expect(titleAndDesc).toHaveLength(2)
})
it('should render configure button for each provider', () => {
// Arrange & Act
render(<NoData onConfig={mockOnConfig} provider={DataSourceProvider.fireCrawl} />)
// Assert
expect(screen.getByRole('button', { name: /configure/i })).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// User Interactions
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call onConfig when configure button is clicked', () => {
// Arrange
render(<NoData onConfig={mockOnConfig} provider={DataSourceProvider.fireCrawl} />)
// Act
fireEvent.click(screen.getByRole('button', { name: /configure/i }))
// Assert
expect(mockOnConfig).toHaveBeenCalledTimes(1)
})
it('should call onConfig for jinaReader provider', () => {
// Arrange
render(<NoData onConfig={mockOnConfig} provider={DataSourceProvider.jinaReader} />)
// Act
fireEvent.click(screen.getByRole('button', { name: /configure/i }))
// Assert
expect(mockOnConfig).toHaveBeenCalledTimes(1)
})
it('should call onConfig for waterCrawl provider', () => {
// Arrange
render(<NoData onConfig={mockOnConfig} provider={DataSourceProvider.waterCrawl} />)
// Act
fireEvent.click(screen.getByRole('button', { name: /configure/i }))
// Assert
expect(mockOnConfig).toHaveBeenCalledTimes(1)
})
})
// --------------------------------------------------------------------------
// Feature Flag Disabled - Returns null
// --------------------------------------------------------------------------
describe('Disabled providers (feature flag off)', () => {
it('should fall back to jinaReader when fireCrawl is disabled but jinaReader enabled', () => {
// Arrange — fireCrawl config is null, falls back to providerConfig.jinareader
mockEnableFirecrawl = false
// Act
const { container } = render(
<NoData onConfig={mockOnConfig} provider={DataSourceProvider.fireCrawl} />,
)
// Assert — renders the jinaReader fallback (not null)
expect(container.innerHTML).not.toBe('')
expect(screen.getAllByText(/jinaReaderNotConfigured/).length).toBeGreaterThan(0)
})
it('should return null when jinaReader is disabled', () => {
// Arrange — jinaReader is the only provider without a fallback
mockEnableJinaReader = false
// Act
const { container } = render(
<NoData onConfig={mockOnConfig} provider={DataSourceProvider.jinaReader} />,
)
// Assert
expect(container.innerHTML).toBe('')
})
it('should fall back to jinaReader when waterCrawl is disabled but jinaReader enabled', () => {
// Arrange — waterCrawl config is null, falls back to providerConfig.jinareader
mockEnableWaterCrawl = false
// Act
const { container } = render(
<NoData onConfig={mockOnConfig} provider={DataSourceProvider.waterCrawl} />,
)
// Assert — renders the jinaReader fallback (not null)
expect(container.innerHTML).not.toBe('')
expect(screen.getAllByText(/jinaReaderNotConfigured/).length).toBeGreaterThan(0)
})
})
// --------------------------------------------------------------------------
// Fallback behavior
// --------------------------------------------------------------------------
describe('Fallback behavior', () => {
it('should fall back to jinaReader config for unknown provider value', () => {
// Arrange - the || fallback goes to providerConfig.jinareader
// Since DataSourceProvider only has 3 values, we test the fallback
// by checking that jinaReader is the fallback when provider doesn't match
mockEnableJinaReader = true
// Act
render(<NoData onConfig={mockOnConfig} provider={DataSourceProvider.jinaReader} />)
// Assert
expect(screen.getAllByText(/jinaReaderNotConfigured/i).length).toBeGreaterThan(0)
})
})
// --------------------------------------------------------------------------
// Edge Cases
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should not call onConfig without user interaction', () => {
// Arrange & Act
render(<NoData onConfig={mockOnConfig} provider={DataSourceProvider.fireCrawl} />)
// Assert
expect(mockOnConfig).not.toHaveBeenCalled()
})
it('should render correctly when all providers are enabled', () => {
// Arrange - all flags are true by default
// Act
const { rerender } = render(
<NoData onConfig={mockOnConfig} provider={DataSourceProvider.fireCrawl} />,
)
expect(screen.getByText('🔥')).toBeInTheDocument()
rerender(<NoData onConfig={mockOnConfig} provider={DataSourceProvider.jinaReader} />)
expect(screen.getAllByText(/jinaReaderNotConfigured/i).length).toBeGreaterThan(0)
rerender(<NoData onConfig={mockOnConfig} provider={DataSourceProvider.waterCrawl} />)
expect(screen.getByText('💧')).toBeInTheDocument()
})
it('should return null when all providers are disabled and fireCrawl is selected', () => {
// Arrange
mockEnableFirecrawl = false
mockEnableJinaReader = false
mockEnableWaterCrawl = false
// Act
const { container } = render(
<NoData onConfig={mockOnConfig} provider={DataSourceProvider.fireCrawl} />,
)
// Assert
expect(container.innerHTML).toBe('')
})
})
})

View File

@@ -1,256 +0,0 @@
import type { CrawlResultItem } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import WebsitePreview from './preview'
// ============================================================================
// Mock Setup
// ============================================================================
// Mock the CSS module import - returns class names as-is
vi.mock('../file-preview/index.module.css', () => ({
default: {
filePreview: 'filePreview',
previewHeader: 'previewHeader',
title: 'title',
previewContent: 'previewContent',
fileContent: 'fileContent',
},
}))
// ============================================================================
// Test Data Factory
// ============================================================================
const createPayload = (overrides: Partial<CrawlResultItem> = {}): CrawlResultItem => ({
title: 'Test Page Title',
markdown: 'This is **markdown** content',
description: 'A test description',
source_url: 'https://example.com/page',
...overrides,
})
// ============================================================================
// WebsitePreview Component Tests
// ============================================================================
describe('WebsitePreview', () => {
const mockHidePreview = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange
const payload = createPayload()
// Act
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
// Assert
expect(screen.getByText('Test Page Title')).toBeInTheDocument()
})
it('should render the page preview header text', () => {
// Arrange
const payload = createPayload()
// Act
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
// Assert - i18n returns the key path
expect(screen.getByText(/pagePreview/i)).toBeInTheDocument()
})
it('should render the payload title', () => {
// Arrange
const payload = createPayload({ title: 'My Custom Page' })
// Act
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
// Assert
expect(screen.getByText('My Custom Page')).toBeInTheDocument()
})
it('should render the payload source_url', () => {
// Arrange
const payload = createPayload({ source_url: 'https://docs.dify.ai/intro' })
// Act
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
// Assert
const urlElement = screen.getByText('https://docs.dify.ai/intro')
expect(urlElement).toBeInTheDocument()
expect(urlElement).toHaveAttribute('title', 'https://docs.dify.ai/intro')
})
it('should render the payload markdown content', () => {
// Arrange
const payload = createPayload({ markdown: 'Hello world markdown' })
// Act
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
// Assert
expect(screen.getByText('Hello world markdown')).toBeInTheDocument()
})
it('should render the close button (XMarkIcon)', () => {
// Arrange
const payload = createPayload()
// Act
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
// Assert - the close button container is a div with cursor-pointer
const closeButton = screen.getByText(/pagePreview/i).parentElement?.querySelector('.cursor-pointer')
expect(closeButton).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// User Interactions
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call hidePreview when close button is clicked', () => {
// Arrange
const payload = createPayload()
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
// Act - find the close button div with cursor-pointer class
const closeButton = screen.getByText(/pagePreview/i)
.closest('[class*="title"]')!
.querySelector('.cursor-pointer') as HTMLElement
fireEvent.click(closeButton)
// Assert
expect(mockHidePreview).toHaveBeenCalledTimes(1)
})
it('should call hidePreview exactly once per click', () => {
// Arrange
const payload = createPayload()
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
// Act
const closeButton = screen.getByText(/pagePreview/i)
.closest('[class*="title"]')!
.querySelector('.cursor-pointer') as HTMLElement
fireEvent.click(closeButton)
fireEvent.click(closeButton)
// Assert
expect(mockHidePreview).toHaveBeenCalledTimes(2)
})
})
// --------------------------------------------------------------------------
// Props Display Tests
// --------------------------------------------------------------------------
describe('Props Display', () => {
it('should display all payload fields simultaneously', () => {
// Arrange
const payload = createPayload({
title: 'Full Title',
source_url: 'https://full.example.com',
markdown: 'Full markdown text',
})
// Act
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
// Assert
expect(screen.getByText('Full Title')).toBeInTheDocument()
expect(screen.getByText('https://full.example.com')).toBeInTheDocument()
expect(screen.getByText('Full markdown text')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Edge Cases
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should render with empty title', () => {
// Arrange
const payload = createPayload({ title: '' })
// Act
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
// Assert - component still renders, url is visible
expect(screen.getByText('https://example.com/page')).toBeInTheDocument()
})
it('should render with empty markdown', () => {
// Arrange
const payload = createPayload({ markdown: '' })
// Act
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
// Assert
expect(screen.getByText('Test Page Title')).toBeInTheDocument()
})
it('should render with empty source_url', () => {
// Arrange
const payload = createPayload({ source_url: '' })
// Act
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
// Assert
expect(screen.getByText('Test Page Title')).toBeInTheDocument()
})
it('should render with very long content', () => {
// Arrange
const longMarkdown = 'A'.repeat(5000)
const payload = createPayload({ markdown: longMarkdown })
// Act
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
// Assert
expect(screen.getByText(longMarkdown)).toBeInTheDocument()
})
it('should render with special characters in title', () => {
// Arrange
const payload = createPayload({ title: '<script>alert("xss")</script>' })
// Act
render(<WebsitePreview payload={payload} hidePreview={mockHidePreview} />)
// Assert - React escapes HTML by default
expect(screen.getByText('<script>alert("xss")</script>')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// CSS Module Classes
// --------------------------------------------------------------------------
describe('CSS Module Classes', () => {
it('should apply filePreview class to root container', () => {
// Arrange
const payload = createPayload()
// Act
const { container } = render(
<WebsitePreview payload={payload} hidePreview={mockHidePreview} />,
)
// Assert
const root = container.firstElementChild
expect(root?.className).toContain('filePreview')
expect(root?.className).toContain('h-full')
})
})
})

View File

@@ -1,294 +0,0 @@
import type { CrawlOptions } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Options from './options'
// ============================================================================
// Test Data Factory
// ============================================================================
const createMockCrawlOptions = (overrides: Partial<CrawlOptions> = {}): CrawlOptions => ({
crawl_sub_pages: true,
limit: 10,
max_depth: 2,
excludes: '',
includes: '',
only_main_content: false,
use_sitemap: false,
...overrides,
})
// ============================================================================
// WaterCrawl Options Component Tests
// ============================================================================
describe('Options (watercrawl)', () => {
const mockOnChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
const getCheckboxes = (container: HTMLElement) => {
return container.querySelectorAll('[data-testid^="checkbox-"]')
}
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render all form fields', () => {
const payload = createMockCrawlOptions()
render(<Options payload={payload} onChange={mockOnChange} />)
expect(screen.getByText(/crawlSubPage/i)).toBeInTheDocument()
expect(screen.getByText(/extractOnlyMainContent/i)).toBeInTheDocument()
expect(screen.getByText(/limit/i)).toBeInTheDocument()
expect(screen.getByText(/maxDepth/i)).toBeInTheDocument()
expect(screen.getByText(/excludePaths/i)).toBeInTheDocument()
expect(screen.getByText(/includeOnlyPaths/i)).toBeInTheDocument()
})
it('should render two checkboxes', () => {
const payload = createMockCrawlOptions()
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
expect(checkboxes.length).toBe(2)
})
it('should render limit field with required indicator', () => {
const payload = createMockCrawlOptions()
render(<Options payload={payload} onChange={mockOnChange} />)
const requiredIndicator = screen.getByText('*')
expect(requiredIndicator).toBeInTheDocument()
})
it('should render placeholder for excludes field', () => {
const payload = createMockCrawlOptions()
render(<Options payload={payload} onChange={mockOnChange} />)
expect(screen.getByPlaceholderText('blog/*, /about/*')).toBeInTheDocument()
})
it('should render placeholder for includes field', () => {
const payload = createMockCrawlOptions()
render(<Options payload={payload} onChange={mockOnChange} />)
expect(screen.getByPlaceholderText('articles/*')).toBeInTheDocument()
})
it('should render with custom className', () => {
const payload = createMockCrawlOptions()
const { container } = render(
<Options payload={payload} onChange={mockOnChange} className="custom-class" />,
)
const rootElement = container.firstChild as HTMLElement
expect(rootElement).toHaveClass('custom-class')
})
})
// --------------------------------------------------------------------------
// Props Display Tests
// --------------------------------------------------------------------------
describe('Props Display', () => {
it('should display crawl_sub_pages checkbox with check icon when true', () => {
const payload = createMockCrawlOptions({ crawl_sub_pages: true })
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
expect(checkboxes[0].querySelector('svg')).toBeInTheDocument()
})
it('should display crawl_sub_pages checkbox without check icon when false', () => {
const payload = createMockCrawlOptions({ crawl_sub_pages: false })
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
expect(checkboxes[0].querySelector('svg')).not.toBeInTheDocument()
})
it('should display only_main_content checkbox with check icon when true', () => {
const payload = createMockCrawlOptions({ only_main_content: true })
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
expect(checkboxes[1].querySelector('svg')).toBeInTheDocument()
})
it('should display only_main_content checkbox without check icon when false', () => {
const payload = createMockCrawlOptions({ only_main_content: false })
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
expect(checkboxes[1].querySelector('svg')).not.toBeInTheDocument()
})
it('should display limit value in input', () => {
const payload = createMockCrawlOptions({ limit: 25 })
render(<Options payload={payload} onChange={mockOnChange} />)
expect(screen.getByDisplayValue('25')).toBeInTheDocument()
})
it('should display max_depth value in input', () => {
const payload = createMockCrawlOptions({ max_depth: 5 })
render(<Options payload={payload} onChange={mockOnChange} />)
expect(screen.getByDisplayValue('5')).toBeInTheDocument()
})
it('should display excludes value in input', () => {
const payload = createMockCrawlOptions({ excludes: 'test/*' })
render(<Options payload={payload} onChange={mockOnChange} />)
expect(screen.getByDisplayValue('test/*')).toBeInTheDocument()
})
it('should display includes value in input', () => {
const payload = createMockCrawlOptions({ includes: 'docs/*' })
render(<Options payload={payload} onChange={mockOnChange} />)
expect(screen.getByDisplayValue('docs/*')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call onChange with updated crawl_sub_pages when checkbox is clicked', () => {
const payload = createMockCrawlOptions({ crawl_sub_pages: true })
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
fireEvent.click(checkboxes[0])
expect(mockOnChange).toHaveBeenCalledWith({
...payload,
crawl_sub_pages: false,
})
})
it('should call onChange with updated only_main_content when checkbox is clicked', () => {
const payload = createMockCrawlOptions({ only_main_content: false })
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
fireEvent.click(checkboxes[1])
expect(mockOnChange).toHaveBeenCalledWith({
...payload,
only_main_content: true,
})
})
it('should call onChange with updated limit when input changes', () => {
const payload = createMockCrawlOptions({ limit: 10 })
render(<Options payload={payload} onChange={mockOnChange} />)
const limitInput = screen.getByDisplayValue('10')
fireEvent.change(limitInput, { target: { value: '50' } })
expect(mockOnChange).toHaveBeenCalledWith({
...payload,
limit: 50,
})
})
it('should call onChange with updated max_depth when input changes', () => {
const payload = createMockCrawlOptions({ max_depth: 2 })
render(<Options payload={payload} onChange={mockOnChange} />)
const maxDepthInput = screen.getByDisplayValue('2')
fireEvent.change(maxDepthInput, { target: { value: '10' } })
expect(mockOnChange).toHaveBeenCalledWith({
...payload,
max_depth: 10,
})
})
it('should call onChange with updated excludes when input changes', () => {
const payload = createMockCrawlOptions({ excludes: '' })
render(<Options payload={payload} onChange={mockOnChange} />)
const excludesInput = screen.getByPlaceholderText('blog/*, /about/*')
fireEvent.change(excludesInput, { target: { value: 'admin/*' } })
expect(mockOnChange).toHaveBeenCalledWith({
...payload,
excludes: 'admin/*',
})
})
it('should call onChange with updated includes when input changes', () => {
const payload = createMockCrawlOptions({ includes: '' })
render(<Options payload={payload} onChange={mockOnChange} />)
const includesInput = screen.getByPlaceholderText('articles/*')
fireEvent.change(includesInput, { target: { value: 'public/*' } })
expect(mockOnChange).toHaveBeenCalledWith({
...payload,
includes: 'public/*',
})
})
})
// --------------------------------------------------------------------------
// Edge Cases Tests
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should preserve other payload fields when updating one field', () => {
const payload = createMockCrawlOptions({
crawl_sub_pages: true,
limit: 10,
max_depth: 2,
excludes: 'test/*',
includes: 'docs/*',
only_main_content: true,
})
render(<Options payload={payload} onChange={mockOnChange} />)
const limitInput = screen.getByDisplayValue('10')
fireEvent.change(limitInput, { target: { value: '20' } })
expect(mockOnChange).toHaveBeenCalledWith({
crawl_sub_pages: true,
limit: 20,
max_depth: 2,
excludes: 'test/*',
includes: 'docs/*',
only_main_content: true,
use_sitemap: false,
})
})
it('should handle zero values', () => {
const payload = createMockCrawlOptions({ limit: 0, max_depth: 0 })
render(<Options payload={payload} onChange={mockOnChange} />)
const zeroInputs = screen.getAllByDisplayValue('0')
expect(zeroInputs.length).toBeGreaterThanOrEqual(1)
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should re-render when payload changes', () => {
const payload1 = createMockCrawlOptions({ limit: 10 })
const payload2 = createMockCrawlOptions({ limit: 20 })
const { rerender } = render(<Options payload={payload1} onChange={mockOnChange} />)
expect(screen.getByDisplayValue('10')).toBeInTheDocument()
rerender(<Options payload={payload2} onChange={mockOnChange} />)
expect(screen.getByDisplayValue('20')).toBeInTheDocument()
})
})
})

View File

@@ -1,237 +0,0 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DocumentActionType } from '@/models/datasets'
import { useDocumentActions } from './use-document-actions'
const mockArchive = vi.fn()
const mockSummary = vi.fn()
const mockEnable = vi.fn()
const mockDisable = vi.fn()
const mockDelete = vi.fn()
const mockRetryIndex = vi.fn()
const mockDownloadZip = vi.fn()
let mockIsDownloadingZip = false
vi.mock('@/service/knowledge/use-document', () => ({
useDocumentArchive: () => ({ mutateAsync: mockArchive }),
useDocumentSummary: () => ({ mutateAsync: mockSummary }),
useDocumentEnable: () => ({ mutateAsync: mockEnable }),
useDocumentDisable: () => ({ mutateAsync: mockDisable }),
useDocumentDelete: () => ({ mutateAsync: mockDelete }),
useDocumentBatchRetryIndex: () => ({ mutateAsync: mockRetryIndex }),
useDocumentDownloadZip: () => ({ mutateAsync: mockDownloadZip, isPending: mockIsDownloadingZip }),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
const mockToastNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
default: { notify: (...args: unknown[]) => mockToastNotify(...args) },
}))
const mockDownloadBlob = vi.fn()
vi.mock('@/utils/download', () => ({
downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args),
}))
describe('useDocumentActions', () => {
const defaultOptions = {
datasetId: 'ds-1',
selectedIds: ['doc-1', 'doc-2'],
downloadableSelectedIds: ['doc-1'],
onUpdate: vi.fn(),
onClearSelection: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
mockIsDownloadingZip = false
})
it('should return expected functions and state', () => {
const { result } = renderHook(() => useDocumentActions(defaultOptions))
expect(result.current.handleAction).toBeInstanceOf(Function)
expect(result.current.handleBatchReIndex).toBeInstanceOf(Function)
expect(result.current.handleBatchDownload).toBeInstanceOf(Function)
expect(typeof result.current.isDownloadingZip).toBe('boolean')
})
describe('handleAction', () => {
it('should call archive API and show success toast', async () => {
mockArchive.mockResolvedValue({ result: 'success' })
const { result } = renderHook(() => useDocumentActions(defaultOptions))
await act(async () => {
await result.current.handleAction(DocumentActionType.archive)()
})
expect(mockArchive).toHaveBeenCalledWith({
datasetId: 'ds-1',
documentIds: ['doc-1', 'doc-2'],
})
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'success' }),
)
expect(defaultOptions.onUpdate).toHaveBeenCalled()
})
it('should call enable API on enable action', async () => {
mockEnable.mockResolvedValue({ result: 'success' })
const { result } = renderHook(() => useDocumentActions(defaultOptions))
await act(async () => {
await result.current.handleAction(DocumentActionType.enable)()
})
expect(mockEnable).toHaveBeenCalledWith({
datasetId: 'ds-1',
documentIds: ['doc-1', 'doc-2'],
})
expect(defaultOptions.onUpdate).toHaveBeenCalled()
})
it('should call disable API on disable action', async () => {
mockDisable.mockResolvedValue({ result: 'success' })
const { result } = renderHook(() => useDocumentActions(defaultOptions))
await act(async () => {
await result.current.handleAction(DocumentActionType.disable)()
})
expect(mockDisable).toHaveBeenCalled()
})
it('should call summary API on summary action', async () => {
mockSummary.mockResolvedValue({ result: 'success' })
const { result } = renderHook(() => useDocumentActions(defaultOptions))
await act(async () => {
await result.current.handleAction(DocumentActionType.summary)()
})
expect(mockSummary).toHaveBeenCalled()
})
it('should call onClearSelection on delete action success', async () => {
mockDelete.mockResolvedValue({ result: 'success' })
const { result } = renderHook(() => useDocumentActions(defaultOptions))
await act(async () => {
await result.current.handleAction(DocumentActionType.delete)()
})
expect(mockDelete).toHaveBeenCalled()
expect(defaultOptions.onClearSelection).toHaveBeenCalled()
expect(defaultOptions.onUpdate).toHaveBeenCalled()
})
it('should not call onClearSelection on non-delete action success', async () => {
mockArchive.mockResolvedValue({ result: 'success' })
const { result } = renderHook(() => useDocumentActions(defaultOptions))
await act(async () => {
await result.current.handleAction(DocumentActionType.archive)()
})
expect(defaultOptions.onClearSelection).not.toHaveBeenCalled()
})
it('should show error toast on action failure', async () => {
mockArchive.mockRejectedValue(new Error('fail'))
const { result } = renderHook(() => useDocumentActions(defaultOptions))
await act(async () => {
await result.current.handleAction(DocumentActionType.archive)()
})
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
expect(defaultOptions.onUpdate).not.toHaveBeenCalled()
})
})
describe('handleBatchReIndex', () => {
it('should call retry index API and show success toast', async () => {
mockRetryIndex.mockResolvedValue({ result: 'success' })
const { result } = renderHook(() => useDocumentActions(defaultOptions))
await act(async () => {
await result.current.handleBatchReIndex()
})
expect(mockRetryIndex).toHaveBeenCalledWith({
datasetId: 'ds-1',
documentIds: ['doc-1', 'doc-2'],
})
expect(defaultOptions.onClearSelection).toHaveBeenCalled()
expect(defaultOptions.onUpdate).toHaveBeenCalled()
})
it('should show error toast on reindex failure', async () => {
mockRetryIndex.mockRejectedValue(new Error('fail'))
const { result } = renderHook(() => useDocumentActions(defaultOptions))
await act(async () => {
await result.current.handleBatchReIndex()
})
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
})
})
describe('handleBatchDownload', () => {
it('should download blob on success', async () => {
const blob = new Blob(['test'])
mockDownloadZip.mockResolvedValue(blob)
const { result } = renderHook(() => useDocumentActions(defaultOptions))
await act(async () => {
await result.current.handleBatchDownload()
})
expect(mockDownloadZip).toHaveBeenCalledWith({
datasetId: 'ds-1',
documentIds: ['doc-1'],
})
expect(mockDownloadBlob).toHaveBeenCalledWith(
expect.objectContaining({
data: blob,
fileName: expect.stringContaining('-docs.zip'),
}),
)
})
it('should show error toast on download failure', async () => {
mockDownloadZip.mockRejectedValue(new Error('fail'))
const { result } = renderHook(() => useDocumentActions(defaultOptions))
await act(async () => {
await result.current.handleBatchDownload()
})
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
})
it('should show error toast when blob is null', async () => {
mockDownloadZip.mockResolvedValue(null)
const { result } = renderHook(() => useDocumentActions(defaultOptions))
await act(async () => {
await result.current.handleBatchDownload()
})
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
})
})
})

View File

@@ -1,33 +0,0 @@
import { render } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import DatasourceIcon from './datasource-icon'
describe('DatasourceIcon', () => {
it('should render icon with background image', () => {
const { container } = render(<DatasourceIcon iconUrl="https://example.com/icon.png" />)
const iconDiv = container.querySelector('[style*="background-image"]')
expect(iconDiv).not.toBeNull()
expect(iconDiv?.getAttribute('style')).toContain('https://example.com/icon.png')
})
it('should apply size class for sm', () => {
const { container } = render(<DatasourceIcon iconUrl="/icon.png" size="sm" />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('w-5')
expect(wrapper.className).toContain('h-5')
})
it('should apply size class for md', () => {
const { container } = render(<DatasourceIcon iconUrl="/icon.png" size="md" />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('w-6')
expect(wrapper.className).toContain('h-6')
})
it('should apply size class for xs', () => {
const { container } = render(<DatasourceIcon iconUrl="/icon.png" size="xs" />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('w-4')
expect(wrapper.className).toContain('h-4')
})
})

View File

@@ -1,141 +0,0 @@
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useDatasourceIcon } from './hooks'
const mockTransformDataSourceToTool = vi.fn()
vi.mock('@/app/components/workflow/block-selector/utils', () => ({
transformDataSourceToTool: (...args: unknown[]) => mockTransformDataSourceToTool(...args),
}))
let mockDataSourceListReturn: {
data: Array<{
plugin_id: string
provider: string
declaration: { identity: { icon: string, author: string } }
}> | undefined
isSuccess: boolean
}
vi.mock('@/service/use-pipeline', () => ({
useDataSourceList: () => mockDataSourceListReturn,
}))
vi.mock('@/utils/var', () => ({
basePath: '',
}))
const createMockDataSourceNode = (overrides?: Partial<DataSourceNodeType>): DataSourceNodeType => ({
plugin_id: 'plugin-abc',
provider_type: 'builtin',
provider_name: 'web-scraper',
datasource_name: 'scraper',
datasource_label: 'Web Scraper',
datasource_parameters: {},
datasource_configurations: {},
title: 'DataSource',
desc: '',
type: '' as DataSourceNodeType['type'],
...overrides,
} as DataSourceNodeType)
describe('useDatasourceIcon', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDataSourceListReturn = { data: undefined, isSuccess: false }
mockTransformDataSourceToTool.mockReset()
})
// Returns undefined when data has not loaded
describe('Loading State', () => {
it('should return undefined when data is not loaded (isSuccess false)', () => {
mockDataSourceListReturn = { data: undefined, isSuccess: false }
const { result } = renderHook(() =>
useDatasourceIcon(createMockDataSourceNode()),
)
expect(result.current).toBeUndefined()
})
})
// Returns correct icon when plugin_id matches
describe('Icon Resolution', () => {
it('should return correct icon when plugin_id matches', () => {
const mockIcon = 'https://example.com/icon.svg'
mockDataSourceListReturn = {
data: [
{
plugin_id: 'plugin-abc',
provider: 'web-scraper',
declaration: { identity: { icon: mockIcon, author: 'dify' } },
},
],
isSuccess: true,
}
mockTransformDataSourceToTool.mockImplementation((item: { plugin_id: string, declaration: { identity: { icon: string } } }) => ({
plugin_id: item.plugin_id,
icon: item.declaration.identity.icon,
}))
const { result } = renderHook(() =>
useDatasourceIcon(createMockDataSourceNode({ plugin_id: 'plugin-abc' })),
)
expect(result.current).toBe(mockIcon)
})
it('should return undefined when plugin_id does not match', () => {
mockDataSourceListReturn = {
data: [
{
plugin_id: 'plugin-xyz',
provider: 'other',
declaration: { identity: { icon: '/icon.svg', author: 'dify' } },
},
],
isSuccess: true,
}
mockTransformDataSourceToTool.mockImplementation((item: { plugin_id: string, declaration: { identity: { icon: string } } }) => ({
plugin_id: item.plugin_id,
icon: item.declaration.identity.icon,
}))
const { result } = renderHook(() =>
useDatasourceIcon(createMockDataSourceNode({ plugin_id: 'plugin-abc' })),
)
expect(result.current).toBeUndefined()
})
})
// basePath prepending
describe('basePath Prepending', () => {
it('should prepend basePath to icon URL when not already included', () => {
// basePath is mocked as '' so prepending '' to '/icon.png' results in '/icon.png'
// The important thing is that the forEach logic runs without error
mockDataSourceListReturn = {
data: [
{
plugin_id: 'plugin-abc',
provider: 'web-scraper',
declaration: { identity: { icon: '/icon.png', author: 'dify' } },
},
],
isSuccess: true,
}
mockTransformDataSourceToTool.mockImplementation((item: { plugin_id: string, declaration: { identity: { icon: string } } }) => ({
plugin_id: item.plugin_id,
icon: item.declaration.identity.icon,
}))
const { result } = renderHook(() =>
useDatasourceIcon(createMockDataSourceNode({ plugin_id: 'plugin-abc' })),
)
// With empty basePath, icon stays as '/icon.png'
expect(result.current).toBe('/icon.png')
})
})
})

View File

@@ -1,110 +0,0 @@
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import OptionCard from './option-card'
const TEST_ICON_URL = 'https://example.com/test-icon.png'
vi.mock('./hooks', () => ({
useDatasourceIcon: () => TEST_ICON_URL,
}))
vi.mock('./datasource-icon', () => ({
default: ({ iconUrl }: { iconUrl: string }) => (
<img data-testid="datasource-icon" src={iconUrl} alt="datasource" />
),
}))
const createMockNodeData = (overrides: Partial<DataSourceNodeType> = {}): DataSourceNodeType => ({
title: 'Test Node',
desc: '',
type: {} as DataSourceNodeType['type'],
plugin_id: 'test-plugin',
provider_type: 'builtin',
provider_name: 'test-provider',
datasource_name: 'test-ds',
datasource_label: 'Test DS',
datasource_parameters: {},
datasource_configurations: {},
...overrides,
} as DataSourceNodeType)
describe('OptionCard', () => {
const defaultProps = {
label: 'Google Drive',
selected: false,
nodeData: createMockNodeData(),
onClick: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering: label text and icon
describe('Rendering', () => {
it('should render label text', () => {
render(<OptionCard {...defaultProps} />)
expect(screen.getByText('Google Drive')).toBeInTheDocument()
})
it('should render datasource icon with correct URL', () => {
render(<OptionCard {...defaultProps} />)
const icon = screen.getByTestId('datasource-icon')
expect(icon).toHaveAttribute('src', TEST_ICON_URL)
})
it('should set title attribute on label element', () => {
render(<OptionCard {...defaultProps} />)
expect(screen.getByTitle('Google Drive')).toBeInTheDocument()
})
})
// User interactions: clicking the card
describe('User Interactions', () => {
it('should call onClick when clicked', () => {
render(<OptionCard {...defaultProps} />)
fireEvent.click(screen.getByText('Google Drive'))
expect(defaultProps.onClick).toHaveBeenCalledOnce()
})
it('should not throw when onClick is undefined', () => {
expect(() => {
const { container } = render(
<OptionCard {...defaultProps} onClick={undefined} />,
)
fireEvent.click(container.firstElementChild!)
}).not.toThrow()
})
})
// Props: selected state applies different styles
describe('Props', () => {
it('should apply selected styles when selected is true', () => {
const { container } = render(<OptionCard {...defaultProps} selected />)
const card = container.firstElementChild
expect(card?.className).toContain('border-components-option-card-option-selected-border')
expect(card?.className).toContain('bg-components-option-card-option-selected-bg')
})
it('should apply default styles when selected is false', () => {
const { container } = render(<OptionCard {...defaultProps} selected={false} />)
const card = container.firstElementChild
expect(card?.className).not.toContain('border-components-option-card-option-selected-border')
})
it('should apply text-text-primary class to label when selected', () => {
render(<OptionCard {...defaultProps} selected />)
const labelEl = screen.getByTitle('Google Drive')
expect(labelEl.className).toContain('text-text-primary')
})
})
})

View File

@@ -1,45 +0,0 @@
import type { DataSourceCredential } from '@/types/pipeline'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Item from './item'
vi.mock('@remixicon/react', () => ({
RiCheckLine: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="check-icon" {...props} />,
}))
vi.mock('@/app/components/datasets/common/credential-icon', () => ({
CredentialIcon: () => <span data-testid="credential-icon" />,
}))
describe('CredentialSelectorItem', () => {
const defaultProps = {
credential: { id: 'cred-1', name: 'My Account', avatar_url: 'https://example.com/avatar.png' } as DataSourceCredential,
isSelected: false,
onCredentialChange: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render credential name and icon', () => {
render(<Item {...defaultProps} />)
expect(screen.getByText('My Account')).toBeInTheDocument()
expect(screen.getByTestId('credential-icon')).toBeInTheDocument()
})
it('should show check icon when selected', () => {
render(<Item {...defaultProps} isSelected={true} />)
expect(screen.getByTestId('check-icon')).toBeInTheDocument()
})
it('should not show check icon when not selected', () => {
render(<Item {...defaultProps} isSelected={false} />)
expect(screen.queryByTestId('check-icon')).not.toBeInTheDocument()
})
it('should call onCredentialChange with credential id on click', () => {
render(<Item {...defaultProps} />)
fireEvent.click(screen.getByText('My Account'))
expect(defaultProps.onCredentialChange).toHaveBeenCalledWith('cred-1')
})
})

View File

@@ -1,46 +0,0 @@
import type { DataSourceCredential } from '@/types/pipeline'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import List from './list'
vi.mock('@remixicon/react', () => ({
RiCheckLine: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="check-icon" {...props} />,
}))
vi.mock('@/app/components/datasets/common/credential-icon', () => ({
CredentialIcon: () => <span data-testid="credential-icon" />,
}))
describe('CredentialSelectorList', () => {
const mockCredentials: DataSourceCredential[] = [
{ id: 'cred-1', name: 'Account A', avatar_url: '' } as DataSourceCredential,
{ id: 'cred-2', name: 'Account B', avatar_url: '' } as DataSourceCredential,
]
const defaultProps = {
currentCredentialId: 'cred-1',
credentials: mockCredentials,
onCredentialChange: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render all credentials', () => {
render(<List {...defaultProps} />)
expect(screen.getByText('Account A')).toBeInTheDocument()
expect(screen.getByText('Account B')).toBeInTheDocument()
})
it('should mark selected credential with check icon', () => {
render(<List {...defaultProps} />)
const checkIcons = screen.getAllByTestId('check-icon')
expect(checkIcons).toHaveLength(1)
})
it('should call onCredentialChange on item click', () => {
render(<List {...defaultProps} />)
fireEvent.click(screen.getByText('Account B'))
expect(defaultProps.onCredentialChange).toHaveBeenCalledWith('cred-2')
})
})

View File

@@ -1,49 +0,0 @@
import type { DataSourceCredential } from '@/types/pipeline'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import Trigger from './trigger'
vi.mock('@remixicon/react', () => ({
RiArrowDownSLine: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="arrow-icon" {...props} />,
}))
vi.mock('@/app/components/datasets/common/credential-icon', () => ({
CredentialIcon: () => <span data-testid="credential-icon" />,
}))
describe('CredentialSelectorTrigger', () => {
it('should render credential name when provided', () => {
render(
<Trigger
currentCredential={{ id: 'cred-1', name: 'Account A', avatar_url: '' } as DataSourceCredential}
isOpen={false}
/>,
)
expect(screen.getByText('Account A')).toBeInTheDocument()
})
it('should render empty name when no credential', () => {
render(<Trigger currentCredential={undefined} isOpen={false} />)
expect(screen.getByTestId('credential-icon')).toBeInTheDocument()
})
it('should render arrow icon', () => {
render(
<Trigger
currentCredential={{ id: 'cred-1', name: 'A', avatar_url: '' } as DataSourceCredential}
isOpen={false}
/>,
)
expect(screen.getByTestId('arrow-icon')).toBeInTheDocument()
})
it('should apply hover style when open', () => {
const { container } = render(
<Trigger
currentCredential={{ id: 'cred-1', name: 'A', avatar_url: '' } as DataSourceCredential}
isOpen={true}
/>,
)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('bg-state-base-hover')
})
})

View File

@@ -1,64 +1,658 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import type { DataSourceCredential } from '@/types/pipeline'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import Header from './header'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, opts?: { pluginName?: string }) => opts?.pluginName ? `${key}-${opts.pluginName}` : key,
}),
// Mock CredentialTypeEnum to avoid deep import chain issues
enum MockCredentialTypeEnum {
OAUTH2 = 'oauth2',
API_KEY = 'api_key',
}
// Mock plugin-auth module to avoid deep import chain issues
vi.mock('@/app/components/plugins/plugin-auth', () => ({
CredentialTypeEnum: {
OAUTH2: 'oauth2',
API_KEY: 'api_key',
},
}))
vi.mock('@remixicon/react', () => ({
RiBookOpenLine: () => <span data-testid="book-icon" />,
RiEqualizer2Line: ({ onClick }: { onClick?: () => void }) => <span data-testid="config-icon" onClick={onClick} />,
}))
vi.mock('@/app/components/base/button', () => ({
default: ({ children }: { children: React.ReactNode }) => <button>{children}</button>,
}))
vi.mock('@/app/components/base/divider', () => ({
default: () => <span data-testid="divider" />,
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children }: { children: React.ReactNode }) => <div data-testid="tooltip">{children}</div>,
}))
vi.mock('./credential-selector', () => ({
default: () => <div data-testid="credential-selector" />,
}))
describe('Header', () => {
const defaultProps = {
docTitle: 'Documentation',
docLink: 'https://docs.example.com',
onClickConfiguration: vi.fn(),
pluginName: 'TestPlugin',
credentials: [],
currentCredentialId: '',
onCredentialChange: vi.fn(),
// Mock portal-to-follow-elem - required for CredentialSelector
vi.mock('@/app/components/base/portal-to-follow-elem', () => {
const MockPortalToFollowElem = ({ children, open }: any) => {
return (
<div data-testid="portal-root" data-open={open}>
{React.Children.map(children, (child: any) => {
if (!child)
return null
return React.cloneElement(child, { __portalOpen: open })
})}
</div>
)
}
it('should render doc link with title', () => {
render(<Header {...defaultProps} />)
expect(screen.getByText('Documentation')).toBeInTheDocument()
const MockPortalToFollowElemTrigger = ({ children, onClick, className, __portalOpen }: any) => (
<div data-testid="portal-trigger" onClick={onClick} className={className} data-open={__portalOpen}>
{children}
</div>
)
const MockPortalToFollowElemContent = ({ children, className, __portalOpen }: any) => {
if (!__portalOpen)
return null
return (
<div data-testid="portal-content" className={className}>
{children}
</div>
)
}
return {
PortalToFollowElem: MockPortalToFollowElem,
PortalToFollowElemTrigger: MockPortalToFollowElemTrigger,
PortalToFollowElemContent: MockPortalToFollowElemContent,
}
})
// ==========================================
// Test Data Builders
// ==========================================
const createMockCredential = (overrides?: Partial<DataSourceCredential>): DataSourceCredential => ({
id: 'cred-1',
name: 'Test Credential',
avatar_url: 'https://example.com/avatar.png',
credential: { key: 'value' },
is_default: false,
type: MockCredentialTypeEnum.OAUTH2 as unknown as DataSourceCredential['type'],
...overrides,
})
const createMockCredentials = (count: number = 3): DataSourceCredential[] =>
Array.from({ length: count }, (_, i) =>
createMockCredential({
id: `cred-${i + 1}`,
name: `Credential ${i + 1}`,
avatar_url: `https://example.com/avatar-${i + 1}.png`,
is_default: i === 0,
}))
type HeaderProps = React.ComponentProps<typeof Header>
const createDefaultProps = (overrides?: Partial<HeaderProps>): HeaderProps => ({
docTitle: 'Documentation',
docLink: 'https://docs.example.com',
pluginName: 'Test Plugin',
currentCredentialId: 'cred-1',
onCredentialChange: vi.fn(),
credentials: createMockCredentials(),
...overrides,
})
describe('Header', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render credential selector', () => {
render(<Header {...defaultProps} />)
expect(screen.getByTestId('credential-selector')).toBeInTheDocument()
// ==========================================
// Rendering Tests
// ==========================================
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByText('Documentation')).toBeInTheDocument()
})
it('should render documentation link with correct attributes', () => {
// Arrange
const props = createDefaultProps({
docTitle: 'API Docs',
docLink: 'https://api.example.com/docs',
})
// Act
render(<Header {...props} />)
// Assert
const link = screen.getByRole('link', { name: /API Docs/i })
expect(link).toHaveAttribute('href', 'https://api.example.com/docs')
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
})
it('should render document title with title attribute', () => {
// Arrange
const props = createDefaultProps({ docTitle: 'My Documentation' })
// Act
render(<Header {...props} />)
// Assert
const titleSpan = screen.getByText('My Documentation')
expect(titleSpan).toHaveAttribute('title', 'My Documentation')
})
it('should render CredentialSelector with correct props', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert - CredentialSelector should render current credential name
expect(screen.getByText('Credential 1')).toBeInTheDocument()
})
it('should render configuration button', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render book icon in documentation link', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert - RiBookOpenLine renders as SVG
const link = screen.getByRole('link')
const svg = link.querySelector('svg')
expect(svg).toBeInTheDocument()
})
it('should render divider between credential selector and configuration button', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<Header {...props} />)
// Assert - Divider component should be rendered
// Divider typically renders as a div with specific styling
const divider = container.querySelector('[class*="divider"]') || container.querySelector('.mx-1.h-3\\.5')
expect(divider).toBeInTheDocument()
})
})
it('should render configuration button', () => {
render(<Header {...defaultProps} />)
expect(screen.getByTestId('config-icon')).toBeInTheDocument()
// ==========================================
// Props Testing
// ==========================================
describe('Props', () => {
describe('docTitle prop', () => {
it('should display the document title', () => {
// Arrange
const props = createDefaultProps({ docTitle: 'Getting Started Guide' })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByText('Getting Started Guide')).toBeInTheDocument()
})
it.each([
'Quick Start',
'API Reference',
'Configuration Guide',
'Plugin Documentation',
])('should display "%s" as document title', (title) => {
// Arrange
const props = createDefaultProps({ docTitle: title })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByText(title)).toBeInTheDocument()
})
})
describe('docLink prop', () => {
it('should set correct href on documentation link', () => {
// Arrange
const props = createDefaultProps({ docLink: 'https://custom.docs.com/guide' })
// Act
render(<Header {...props} />)
// Assert
const link = screen.getByRole('link')
expect(link).toHaveAttribute('href', 'https://custom.docs.com/guide')
})
it.each([
'https://docs.dify.ai',
'https://example.com/api',
'/local/docs',
])('should accept "%s" as docLink', (link) => {
// Arrange
const props = createDefaultProps({ docLink: link })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByRole('link')).toHaveAttribute('href', link)
})
})
describe('pluginName prop', () => {
it('should pass pluginName to translation function', () => {
// Arrange
const props = createDefaultProps({ pluginName: 'MyPlugin' })
// Act
render(<Header {...props} />)
// Assert - The translation mock returns the key with options
// Tooltip uses the translated content
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
describe('onClickConfiguration prop', () => {
it('should call onClickConfiguration when configuration icon is clicked', () => {
// Arrange
const mockOnClick = vi.fn()
const props = createDefaultProps({ onClickConfiguration: mockOnClick })
render(<Header {...props} />)
// Act - Find the configuration button and click the icon inside
// The button contains the RiEqualizer2Line icon with onClick handler
const configButton = screen.getByRole('button')
const configIcon = configButton.querySelector('svg')
expect(configIcon).toBeInTheDocument()
fireEvent.click(configIcon!)
// Assert
expect(mockOnClick).toHaveBeenCalledTimes(1)
})
it('should not crash when onClickConfiguration is undefined', () => {
// Arrange
const props = createDefaultProps({ onClickConfiguration: undefined })
render(<Header {...props} />)
// Act - Find the configuration button and click the icon inside
const configButton = screen.getByRole('button')
const configIcon = configButton.querySelector('svg')
expect(configIcon).toBeInTheDocument()
fireEvent.click(configIcon!)
// Assert - Component should still be rendered (no crash)
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
describe('CredentialSelector props passthrough', () => {
it('should pass currentCredentialId to CredentialSelector', () => {
// Arrange
const props = createDefaultProps({ currentCredentialId: 'cred-2' })
// Act
render(<Header {...props} />)
// Assert - Should display the second credential
expect(screen.getByText('Credential 2')).toBeInTheDocument()
})
it('should pass credentials to CredentialSelector', () => {
// Arrange
const customCredentials = [
createMockCredential({ id: 'custom-1', name: 'Custom Credential' }),
]
const props = createDefaultProps({
credentials: customCredentials,
currentCredentialId: 'custom-1',
})
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByText('Custom Credential')).toBeInTheDocument()
})
it('should pass onCredentialChange to CredentialSelector', () => {
// Arrange
const mockOnChange = vi.fn()
const props = createDefaultProps({ onCredentialChange: mockOnChange })
render(<Header {...props} />)
// Act - Open dropdown and select a credential
// Use getAllByTestId and select the first one (CredentialSelector's trigger)
const triggers = screen.getAllByTestId('portal-trigger')
fireEvent.click(triggers[0])
const credential2 = screen.getByText('Credential 2')
fireEvent.click(credential2)
// Assert
expect(mockOnChange).toHaveBeenCalledWith('cred-2')
})
})
})
it('should link to external doc', () => {
render(<Header {...defaultProps} />)
const link = screen.getByText('Documentation').closest('a')
expect(link).toHaveAttribute('href', 'https://docs.example.com')
expect(link).toHaveAttribute('target', '_blank')
// ==========================================
// User Interactions
// ==========================================
describe('User Interactions', () => {
it('should open external link in new tab when clicking documentation link', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert - Link has target="_blank" for new tab
const link = screen.getByRole('link')
expect(link).toHaveAttribute('target', '_blank')
})
it('should allow credential selection through CredentialSelector', () => {
// Arrange
const mockOnChange = vi.fn()
const props = createDefaultProps({ onCredentialChange: mockOnChange })
render(<Header {...props} />)
// Act - Open dropdown (use first trigger which is CredentialSelector's)
const triggers = screen.getAllByTestId('portal-trigger')
fireEvent.click(triggers[0])
// Assert - Dropdown should be open
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
})
it('should trigger configuration callback when clicking config icon', () => {
// Arrange
const mockOnConfig = vi.fn()
const props = createDefaultProps({ onClickConfiguration: mockOnConfig })
const { container } = render(<Header {...props} />)
// Act
const configIcon = container.querySelector('.h-4.w-4')
fireEvent.click(configIcon!)
// Assert
expect(mockOnConfig).toHaveBeenCalled()
})
})
// ==========================================
// Component Memoization
// ==========================================
describe('Component Memoization', () => {
it('should be wrapped with React.memo', () => {
// Assert
expect(Header.$$typeof).toBe(Symbol.for('react.memo'))
})
it('should not re-render when props remain the same', () => {
// Arrange
const props = createDefaultProps()
const renderSpy = vi.fn()
const TrackedHeader: React.FC<HeaderProps> = (trackedProps) => {
renderSpy()
return <Header {...trackedProps} />
}
const MemoizedTracked = React.memo(TrackedHeader)
// Act
const { rerender } = render(<MemoizedTracked {...props} />)
rerender(<MemoizedTracked {...props} />)
// Assert - Should only render once due to same props
expect(renderSpy).toHaveBeenCalledTimes(1)
})
it('should re-render when docTitle changes', () => {
// Arrange
const props = createDefaultProps({ docTitle: 'Original Title' })
const { rerender } = render(<Header {...props} />)
// Assert initial
expect(screen.getByText('Original Title')).toBeInTheDocument()
// Act
rerender(<Header {...props} docTitle="Updated Title" />)
// Assert
expect(screen.getByText('Updated Title')).toBeInTheDocument()
})
it('should re-render when currentCredentialId changes', () => {
// Arrange
const props = createDefaultProps({ currentCredentialId: 'cred-1' })
const { rerender } = render(<Header {...props} />)
// Assert initial
expect(screen.getByText('Credential 1')).toBeInTheDocument()
// Act
rerender(<Header {...props} currentCredentialId="cred-2" />)
// Assert
expect(screen.getByText('Credential 2')).toBeInTheDocument()
})
})
// ==========================================
// Edge Cases
// ==========================================
describe('Edge Cases', () => {
it('should handle empty docTitle', () => {
// Arrange
const props = createDefaultProps({ docTitle: '' })
// Act
render(<Header {...props} />)
// Assert - Should render without crashing
const link = screen.getByRole('link')
expect(link).toBeInTheDocument()
})
it('should handle very long docTitle', () => {
// Arrange
const longTitle = 'A'.repeat(200)
const props = createDefaultProps({ docTitle: longTitle })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByText(longTitle)).toBeInTheDocument()
})
it('should handle special characters in docTitle', () => {
// Arrange
const specialTitle = 'Docs & Guide <v2> "Special"'
const props = createDefaultProps({ docTitle: specialTitle })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByText(specialTitle)).toBeInTheDocument()
})
it('should handle empty credentials array', () => {
// Arrange
const props = createDefaultProps({
credentials: [],
currentCredentialId: '',
})
// Act
render(<Header {...props} />)
// Assert - Should render without crashing
expect(screen.getByRole('link')).toBeInTheDocument()
})
it('should handle special characters in pluginName', () => {
// Arrange
const props = createDefaultProps({ pluginName: 'Plugin & Tool <v1>' })
// Act
render(<Header {...props} />)
// Assert - Should render without crashing
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should handle unicode characters in docTitle', () => {
// Arrange
const props = createDefaultProps({ docTitle: '文档说明 📚' })
// Act
render(<Header {...props} />)
// Assert
expect(screen.getByText('文档说明 📚')).toBeInTheDocument()
})
})
// ==========================================
// Styling
// ==========================================
describe('Styling', () => {
it('should apply correct classes to container', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<Header {...props} />)
// Assert
const rootDiv = container.firstChild as HTMLElement
expect(rootDiv).toHaveClass('flex', 'items-center', 'justify-between', 'gap-x-2')
})
it('should apply correct classes to documentation link', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert
const link = screen.getByRole('link')
expect(link).toHaveClass('system-xs-medium', 'text-text-accent')
})
it('should apply shrink-0 to documentation link', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert
const link = screen.getByRole('link')
expect(link).toHaveClass('shrink-0')
})
})
// ==========================================
// Integration Tests
// ==========================================
describe('Integration', () => {
it('should work with full credential workflow', () => {
// Arrange
const mockOnCredentialChange = vi.fn()
const props = createDefaultProps({
onCredentialChange: mockOnCredentialChange,
currentCredentialId: 'cred-1',
})
render(<Header {...props} />)
// Assert initial state
expect(screen.getByText('Credential 1')).toBeInTheDocument()
// Act - Open dropdown and select different credential
// Use first trigger which is CredentialSelector's
const triggers = screen.getAllByTestId('portal-trigger')
fireEvent.click(triggers[0])
const credential3 = screen.getByText('Credential 3')
fireEvent.click(credential3)
// Assert
expect(mockOnCredentialChange).toHaveBeenCalledWith('cred-3')
})
it('should display all components together correctly', () => {
// Arrange
const mockOnConfig = vi.fn()
const props = createDefaultProps({
docTitle: 'Integration Test Docs',
docLink: 'https://test.com/docs',
pluginName: 'TestPlugin',
onClickConfiguration: mockOnConfig,
})
// Act
render(<Header {...props} />)
// Assert - All main elements present
expect(screen.getByText('Credential 1')).toBeInTheDocument() // CredentialSelector
expect(screen.getByRole('button')).toBeInTheDocument() // Config button
expect(screen.getByText('Integration Test Docs')).toBeInTheDocument() // Doc link
expect(screen.getByRole('link')).toHaveAttribute('href', 'https://test.com/docs')
})
})
// ==========================================
// Accessibility
// ==========================================
describe('Accessibility', () => {
it('should have accessible link', () => {
// Arrange
const props = createDefaultProps({ docTitle: 'Accessible Docs' })
// Act
render(<Header {...props} />)
// Assert
const link = screen.getByRole('link', { name: /Accessible Docs/i })
expect(link).toBeInTheDocument()
})
it('should have accessible button for configuration', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
})
it('should have noopener noreferrer for security on external links', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<Header {...props} />)
// Assert
const link = screen.getByRole('link')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
})
})
})

View File

@@ -1,100 +0,0 @@
import type { NotionPageTreeItem, NotionPageTreeMap } from './index'
import type { DataSourceNotionPageMap } from '@/models/common'
import { describe, expect, it } from 'vitest'
import { recursivePushInParentDescendants } from './utils'
const makePageEntry = (overrides: Partial<NotionPageTreeItem>): NotionPageTreeItem => ({
page_icon: null,
page_id: '',
page_name: '',
parent_id: '',
type: 'page',
is_bound: false,
children: new Set(),
descendants: new Set(),
depth: 0,
ancestors: [],
...overrides,
})
describe('recursivePushInParentDescendants', () => {
it('should add child to parent descendants', () => {
const pagesMap = {
parent1: { page_id: 'parent1', parent_id: 'root', page_name: 'Parent' },
child1: { page_id: 'child1', parent_id: 'parent1', page_name: 'Child' },
} as unknown as DataSourceNotionPageMap
const listTreeMap: NotionPageTreeMap = {
child1: makePageEntry({ page_id: 'child1', parent_id: 'parent1', page_name: 'Child' }),
}
recursivePushInParentDescendants(pagesMap, listTreeMap, listTreeMap.child1, listTreeMap.child1)
expect(listTreeMap.parent1).toBeDefined()
expect(listTreeMap.parent1.children.has('child1')).toBe(true)
expect(listTreeMap.parent1.descendants.has('child1')).toBe(true)
})
it('should recursively populate ancestors for deeply nested items', () => {
const pagesMap = {
grandparent: { page_id: 'grandparent', parent_id: 'root', page_name: 'Grandparent' },
parent: { page_id: 'parent', parent_id: 'grandparent', page_name: 'Parent' },
child: { page_id: 'child', parent_id: 'parent', page_name: 'Child' },
} as unknown as DataSourceNotionPageMap
const listTreeMap: NotionPageTreeMap = {
parent: makePageEntry({ page_id: 'parent', parent_id: 'grandparent', page_name: 'Parent' }),
child: makePageEntry({ page_id: 'child', parent_id: 'parent', page_name: 'Child' }),
}
recursivePushInParentDescendants(pagesMap, listTreeMap, listTreeMap.child, listTreeMap.child)
expect(listTreeMap.child.depth).toBe(2)
expect(listTreeMap.child.ancestors).toContain('Grandparent')
expect(listTreeMap.child.ancestors).toContain('Parent')
})
it('should do nothing for root parent', () => {
const pagesMap = {
root_child: { page_id: 'root_child', parent_id: 'root', page_name: 'Root Child' },
} as unknown as DataSourceNotionPageMap
const listTreeMap: NotionPageTreeMap = {
root_child: makePageEntry({ page_id: 'root_child', parent_id: 'root', page_name: 'Root Child' }),
}
recursivePushInParentDescendants(pagesMap, listTreeMap, listTreeMap.root_child, listTreeMap.root_child)
// No new entries should be added since parent is root
expect(Object.keys(listTreeMap)).toEqual(['root_child'])
})
it('should handle missing parent_id gracefully', () => {
const pagesMap = {} as DataSourceNotionPageMap
const current = makePageEntry({ page_id: 'orphan', parent_id: undefined as unknown as string })
const listTreeMap: NotionPageTreeMap = { orphan: current }
// Should not throw
recursivePushInParentDescendants(pagesMap, listTreeMap, current, current)
expect(listTreeMap.orphan.depth).toBe(0)
})
it('should add to existing parent entry when parent already in tree', () => {
const pagesMap = {
parent: { page_id: 'parent', parent_id: 'root', page_name: 'Parent' },
child1: { page_id: 'child1', parent_id: 'parent', page_name: 'Child1' },
child2: { page_id: 'child2', parent_id: 'parent', page_name: 'Child2' },
} as unknown as DataSourceNotionPageMap
const listTreeMap: NotionPageTreeMap = {
parent: makePageEntry({ page_id: 'parent', parent_id: 'root', children: new Set(['child1']), descendants: new Set(['child1']), page_name: 'Parent' }),
child2: makePageEntry({ page_id: 'child2', parent_id: 'parent', page_name: 'Child2' }),
}
recursivePushInParentDescendants(pagesMap, listTreeMap, listTreeMap.child2, listTreeMap.child2)
expect(listTreeMap.parent.children.has('child2')).toBe(true)
expect(listTreeMap.parent.descendants.has('child2')).toBe(true)
expect(listTreeMap.parent.children.has('child1')).toBe(true)
})
})

View File

@@ -1,16 +0,0 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import Title from './title'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) => `${key}:${(opts?.name as string) || ''}`,
}),
}))
describe('OnlineDocumentTitle', () => {
it('should render title with name prop', () => {
render(<Title name="Notion Workspace" />)
expect(screen.getByText('onlineDocument.pageSelectorTitle:Notion Workspace')).toBeInTheDocument()
})
})

View File

@@ -1,60 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Bucket from './bucket'
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))
vi.mock('@/app/components/base/icons/src/public/knowledge/online-drive', () => ({
BucketsGray: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="buckets-gray" {...props} />,
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
}))
describe('Bucket', () => {
const defaultProps = {
bucketName: 'my-bucket',
handleBackToBucketList: vi.fn(),
handleClickBucketName: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render bucket name', () => {
render(<Bucket {...defaultProps} />)
expect(screen.getByText('my-bucket')).toBeInTheDocument()
})
it('should render bucket icon', () => {
render(<Bucket {...defaultProps} />)
expect(screen.getByTestId('buckets-gray')).toBeInTheDocument()
})
it('should call handleBackToBucketList on icon button click', () => {
render(<Bucket {...defaultProps} />)
const buttons = screen.getAllByRole('button')
fireEvent.click(buttons[0])
expect(defaultProps.handleBackToBucketList).toHaveBeenCalledOnce()
})
it('should call handleClickBucketName on name click', () => {
render(<Bucket {...defaultProps} />)
fireEvent.click(screen.getByText('my-bucket'))
expect(defaultProps.handleClickBucketName).toHaveBeenCalledOnce()
})
it('should not call handleClickBucketName when disabled', () => {
render(<Bucket {...defaultProps} disabled={true} />)
fireEvent.click(screen.getByText('my-bucket'))
expect(defaultProps.handleClickBucketName).not.toHaveBeenCalled()
})
it('should show separator by default', () => {
render(<Bucket {...defaultProps} />)
const separators = screen.getAllByText('/')
expect(separators.length).toBeGreaterThanOrEqual(2) // One after icon, one after name
})
})

View File

@@ -1,61 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Drive from './drive'
describe('Drive', () => {
const defaultProps = {
breadcrumbs: [] as string[],
handleBackToRoot: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering: button text and separator visibility
describe('Rendering', () => {
it('should render "All Files" button text', () => {
render(<Drive {...defaultProps} />)
expect(screen.getByRole('button')).toHaveTextContent('datasetPipeline.onlineDrive.breadcrumbs.allFiles')
})
it('should show separator "/" when breadcrumbs has items', () => {
render(<Drive {...defaultProps} breadcrumbs={['Folder A']} />)
expect(screen.getByText('/')).toBeInTheDocument()
})
it('should hide separator when breadcrumbs is empty', () => {
render(<Drive {...defaultProps} breadcrumbs={[]} />)
expect(screen.queryByText('/')).not.toBeInTheDocument()
})
})
// Props: disabled state depends on breadcrumbs length
describe('Props', () => {
it('should disable button when breadcrumbs is empty', () => {
render(<Drive {...defaultProps} breadcrumbs={[]} />)
expect(screen.getByRole('button')).toBeDisabled()
})
it('should enable button when breadcrumbs has items', () => {
render(<Drive {...defaultProps} breadcrumbs={['Folder A', 'Folder B']} />)
expect(screen.getByRole('button')).not.toBeDisabled()
})
})
// User interactions: clicking the root button
describe('User Interactions', () => {
it('should call handleBackToRoot on click when enabled', () => {
render(<Drive {...defaultProps} breadcrumbs={['Folder A']} />)
fireEvent.click(screen.getByRole('button'))
expect(defaultProps.handleBackToRoot).toHaveBeenCalledOnce()
})
})
})

View File

@@ -1,44 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Item from './item'
describe('Item', () => {
const defaultProps = {
name: 'Documents',
index: 2,
onBreadcrumbClick: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering: verify the breadcrumb name is displayed
describe('Rendering', () => {
it('should render breadcrumb name', () => {
render(<Item {...defaultProps} />)
expect(screen.getByText('Documents')).toBeInTheDocument()
})
})
// User interactions: clicking triggers callback with correct index
describe('User Interactions', () => {
it('should call onBreadcrumbClick with correct index on click', () => {
render(<Item {...defaultProps} />)
fireEvent.click(screen.getByText('Documents'))
expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledOnce()
expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(2)
})
it('should pass different index values correctly', () => {
render(<Item {...defaultProps} index={5} />)
fireEvent.click(screen.getByText('Documents'))
expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(5)
})
})
})

View File

@@ -1,79 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Menu from './menu'
describe('Menu', () => {
const defaultProps = {
breadcrumbs: ['Folder A', 'Folder B', 'Folder C'],
startIndex: 1,
onBreadcrumbClick: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering: verify all breadcrumb items are displayed
describe('Rendering', () => {
it('should render all breadcrumb items', () => {
render(<Menu {...defaultProps} />)
expect(screen.getByText('Folder A')).toBeInTheDocument()
expect(screen.getByText('Folder B')).toBeInTheDocument()
expect(screen.getByText('Folder C')).toBeInTheDocument()
})
it('should render empty list when no breadcrumbs provided', () => {
const { container } = render(
<Menu breadcrumbs={[]} startIndex={0} onBreadcrumbClick={vi.fn()} />,
)
const menuContainer = container.firstElementChild
expect(menuContainer?.children).toHaveLength(0)
})
})
// Index mapping: startIndex offsets are applied correctly
describe('Index Mapping', () => {
it('should pass correct index (startIndex + offset) to each item', () => {
render(<Menu {...defaultProps} />)
fireEvent.click(screen.getByText('Folder A'))
expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(1)
fireEvent.click(screen.getByText('Folder B'))
expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(2)
fireEvent.click(screen.getByText('Folder C'))
expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(3)
})
it('should offset from startIndex of zero', () => {
render(
<Menu
breadcrumbs={['First', 'Second']}
startIndex={0}
onBreadcrumbClick={defaultProps.onBreadcrumbClick}
/>,
)
fireEvent.click(screen.getByText('First'))
expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(0)
fireEvent.click(screen.getByText('Second'))
expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(1)
})
})
// User interactions: clicking items triggers the callback
describe('User Interactions', () => {
it('should call onBreadcrumbClick with correct index when item clicked', () => {
render(<Menu {...defaultProps} />)
fireEvent.click(screen.getByText('Folder B'))
expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledOnce()
expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(2)
})
})
})

View File

@@ -1,48 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import BreadcrumbItem from './item'
describe('BreadcrumbItem', () => {
const defaultProps = {
name: 'Documents',
index: 2,
handleClick: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render name', () => {
render(<BreadcrumbItem {...defaultProps} />)
expect(screen.getByText('Documents')).toBeInTheDocument()
})
it('should show separator by default', () => {
render(<BreadcrumbItem {...defaultProps} />)
expect(screen.getByText('/')).toBeInTheDocument()
})
it('should hide separator when showSeparator is false', () => {
render(<BreadcrumbItem {...defaultProps} showSeparator={false} />)
expect(screen.queryByText('/')).not.toBeInTheDocument()
})
it('should call handleClick with index on click', () => {
render(<BreadcrumbItem {...defaultProps} />)
fireEvent.click(screen.getByText('Documents'))
expect(defaultProps.handleClick).toHaveBeenCalledWith(2)
})
it('should not call handleClick when disabled', () => {
render(<BreadcrumbItem {...defaultProps} disabled={true} />)
fireEvent.click(screen.getByText('Documents'))
expect(defaultProps.handleClick).not.toHaveBeenCalled()
})
it('should apply active styling', () => {
render(<BreadcrumbItem {...defaultProps} isActive={true} />)
const btn = screen.getByRole('button')
expect(btn.className).toContain('system-sm-medium')
})
})

View File

@@ -1,16 +1,38 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import EmptyFolder from './empty-folder'
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
afterEach(() => {
cleanup()
})
describe('EmptyFolder', () => {
it('should render empty folder message', () => {
it('should render without crashing', () => {
render(<EmptyFolder />)
expect(screen.getByText('onlineDrive.emptyFolder')).toBeInTheDocument()
})
it('should render the empty folder text', () => {
render(<EmptyFolder />)
expect(screen.getByText('onlineDrive.emptyFolder')).toBeInTheDocument()
})
it('should have proper styling classes', () => {
const { container } = render(<EmptyFolder />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('flex')
expect(wrapper).toHaveClass('items-center')
expect(wrapper).toHaveClass('justify-center')
})
it('should be wrapped with React.memo', () => {
expect((EmptyFolder as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
})
})

View File

@@ -1,34 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import EmptySearchResult from './empty-search-result'
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))
vi.mock('@/app/components/base/icons/src/vender/knowledge', () => ({
SearchMenu: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="search-icon" {...props} />,
}))
describe('EmptySearchResult', () => {
const onResetKeywords = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('should render empty state message', () => {
render(<EmptySearchResult onResetKeywords={onResetKeywords} />)
expect(screen.getByText('onlineDrive.emptySearchResult')).toBeInTheDocument()
})
it('should render reset button', () => {
render(<EmptySearchResult onResetKeywords={onResetKeywords} />)
expect(screen.getByText('onlineDrive.resetKeywords')).toBeInTheDocument()
})
it('should call onResetKeywords when reset button clicked', () => {
render(<EmptySearchResult onResetKeywords={onResetKeywords} />)
fireEvent.click(screen.getByText('onlineDrive.resetKeywords'))
expect(onResetKeywords).toHaveBeenCalledOnce()
})
})

View File

@@ -1,29 +0,0 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { OnlineDriveFileType } from '@/models/pipeline'
import FileIcon from './file-icon'
vi.mock('@/app/components/base/file-uploader/file-type-icon', () => ({
default: ({ type }: { type: string }) => <span data-testid="file-type-icon">{type}</span>,
}))
vi.mock('@/app/components/base/icons/src/public/knowledge/online-drive', () => ({
BucketsBlue: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="bucket-icon" {...props} />,
Folder: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="folder-icon" {...props} />,
}))
describe('FileIcon', () => {
it('should render bucket icon for bucket type', () => {
render(<FileIcon type={OnlineDriveFileType.bucket} fileName="" />)
expect(screen.getByTestId('bucket-icon')).toBeInTheDocument()
})
it('should render folder icon for folder type', () => {
render(<FileIcon type={OnlineDriveFileType.folder} fileName="" />)
expect(screen.getByTestId('folder-icon')).toBeInTheDocument()
})
it('should render file type icon for file type', () => {
render(<FileIcon type={OnlineDriveFileType.file} fileName="doc.pdf" />)
expect(screen.getByTestId('file-type-icon')).toBeInTheDocument()
})
})

View File

@@ -1,96 +0,0 @@
import type { OnlineDriveFile } from '@/models/pipeline'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Item from './item'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/app/components/base/checkbox', () => ({
default: ({ checked, onCheck, disabled }: { checked: boolean, onCheck: () => void, disabled?: boolean }) => (
<input type="checkbox" data-testid="checkbox" checked={checked} onChange={onCheck} disabled={disabled} />
),
}))
vi.mock('@/app/components/base/radio/ui', () => ({
default: ({ isChecked, onCheck }: { isChecked: boolean, onCheck: () => void }) => (
<input type="radio" data-testid="radio" checked={isChecked} onChange={onCheck} />
),
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => (
<div data-testid="tooltip" title={popupContent}>{children}</div>
),
}))
vi.mock('./file-icon', () => ({
default: () => <span data-testid="file-icon" />,
}))
describe('Item', () => {
const makeFile = (type: string, name = 'test.pdf', size = 1024): OnlineDriveFile => ({
id: 'f-1',
name,
type: type as OnlineDriveFile['type'],
size,
})
const defaultProps = {
file: makeFile('file'),
isSelected: false,
onSelect: vi.fn(),
onOpen: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render file name', () => {
render(<Item {...defaultProps} />)
expect(screen.getByText('test.pdf')).toBeInTheDocument()
})
it('should render checkbox for file type in multiple choice mode', () => {
render(<Item {...defaultProps} />)
expect(screen.getByTestId('checkbox')).toBeInTheDocument()
})
it('should render radio for file type in single choice mode', () => {
render(<Item {...defaultProps} isMultipleChoice={false} />)
expect(screen.getByTestId('radio')).toBeInTheDocument()
})
it('should not render checkbox for bucket type', () => {
render(<Item {...defaultProps} file={makeFile('bucket', 'my-bucket')} />)
expect(screen.queryByTestId('checkbox')).not.toBeInTheDocument()
})
it('should call onOpen for folder click', () => {
const file = makeFile('folder', 'my-folder')
render(<Item {...defaultProps} file={file} />)
fireEvent.click(screen.getByText('my-folder'))
expect(defaultProps.onOpen).toHaveBeenCalledWith(file)
})
it('should call onSelect for file click', () => {
render(<Item {...defaultProps} />)
fireEvent.click(screen.getByText('test.pdf'))
expect(defaultProps.onSelect).toHaveBeenCalledWith(defaultProps.file)
})
it('should not call handlers when disabled', () => {
render(<Item {...defaultProps} disabled={true} />)
fireEvent.click(screen.getByText('test.pdf'))
expect(defaultProps.onSelect).not.toHaveBeenCalled()
})
it('should render file icon', () => {
render(<Item {...defaultProps} />)
expect(screen.getByTestId('file-icon')).toBeInTheDocument()
})
})

View File

@@ -1,79 +0,0 @@
import { describe, expect, it } from 'vitest'
import { FileAppearanceTypeEnum } from '@/app/components/base/file-uploader/types'
import { getFileExtension, getFileType } from './utils'
describe('getFileExtension', () => {
it('should return extension for normal file', () => {
expect(getFileExtension('test.pdf')).toBe('pdf')
})
it('should return lowercase extension', () => {
expect(getFileExtension('test.PDF')).toBe('pdf')
})
it('should return last extension for multiple dots', () => {
expect(getFileExtension('my.file.name.txt')).toBe('txt')
})
it('should return empty string for no extension', () => {
expect(getFileExtension('noext')).toBe('')
})
it('should return empty string for empty string', () => {
expect(getFileExtension('')).toBe('')
})
it('should return empty string for dotfile with no extension', () => {
expect(getFileExtension('.gitignore')).toBe('')
})
})
describe('getFileType', () => {
it('should return pdf for .pdf files', () => {
expect(getFileType('doc.pdf')).toBe(FileAppearanceTypeEnum.pdf)
})
it('should return markdown for .md files', () => {
expect(getFileType('readme.md')).toBe(FileAppearanceTypeEnum.markdown)
})
it('should return markdown for .mdx files', () => {
expect(getFileType('page.mdx')).toBe(FileAppearanceTypeEnum.markdown)
})
it('should return excel for .xlsx files', () => {
expect(getFileType('data.xlsx')).toBe(FileAppearanceTypeEnum.excel)
})
it('should return excel for .csv files', () => {
expect(getFileType('data.csv')).toBe(FileAppearanceTypeEnum.excel)
})
it('should return word for .docx files', () => {
expect(getFileType('doc.docx')).toBe(FileAppearanceTypeEnum.word)
})
it('should return ppt for .pptx files', () => {
expect(getFileType('slides.pptx')).toBe(FileAppearanceTypeEnum.ppt)
})
it('should return code for .html files', () => {
expect(getFileType('page.html')).toBe(FileAppearanceTypeEnum.code)
})
it('should return code for .json files', () => {
expect(getFileType('config.json')).toBe(FileAppearanceTypeEnum.code)
})
it('should return gif for .gif files', () => {
expect(getFileType('animation.gif')).toBe(FileAppearanceTypeEnum.gif)
})
it('should return custom for unknown extension', () => {
expect(getFileType('file.xyz')).toBe(FileAppearanceTypeEnum.custom)
})
it('should return custom for no extension', () => {
expect(getFileType('noext')).toBe(FileAppearanceTypeEnum.custom)
})
})

View File

@@ -1,33 +0,0 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Header from './header'
vi.mock('@remixicon/react', () => ({
RiBookOpenLine: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="book-icon" {...props} />,
RiEqualizer2Line: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="config-icon" {...props} />,
}))
describe('OnlineDriveHeader', () => {
const defaultProps = {
docTitle: 'S3 Guide',
docLink: 'https://docs.aws.com/s3',
onClickConfiguration: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render doc link with title', () => {
render(<Header {...defaultProps} />)
const link = screen.getByText('S3 Guide').closest('a')
expect(link).toHaveAttribute('href', 'https://docs.aws.com/s3')
expect(link).toHaveAttribute('target', '_blank')
})
it('should render book and config icons', () => {
render(<Header {...defaultProps} />)
expect(screen.getByTestId('book-icon')).toBeInTheDocument()
expect(screen.getByTestId('config-icon')).toBeInTheDocument()
})
})

View File

@@ -1,105 +0,0 @@
import type { OnlineDriveData } from '@/types/pipeline'
import { describe, expect, it } from 'vitest'
import { OnlineDriveFileType } from '@/models/pipeline'
import { convertOnlineDriveData, isBucketListInitiation, isFile } from './utils'
describe('online-drive utils', () => {
describe('isFile', () => {
it('should return true for file type', () => {
expect(isFile('file')).toBe(true)
})
it('should return false for folder type', () => {
expect(isFile('folder')).toBe(false)
})
})
describe('isBucketListInitiation', () => {
it('should return true when data has buckets and no prefix/bucket set', () => {
const data = [
{ bucket: 'bucket-1', files: [], is_truncated: false, next_page_parameters: {} },
{ bucket: 'bucket-2', files: [], is_truncated: false, next_page_parameters: {} },
] as OnlineDriveData[]
expect(isBucketListInitiation(data, [], '')).toBe(true)
})
it('should return false when bucket is already set', () => {
const data = [
{ bucket: 'bucket-1', files: [], is_truncated: false, next_page_parameters: {} },
] as OnlineDriveData[]
expect(isBucketListInitiation(data, [], 'bucket-1')).toBe(false)
})
it('should return false when prefix is set', () => {
const data = [
{ bucket: 'bucket-1', files: [], is_truncated: false, next_page_parameters: {} },
] as OnlineDriveData[]
expect(isBucketListInitiation(data, ['folder/'], '')).toBe(false)
})
it('should return false when single bucket has files', () => {
const data = [
{
bucket: 'bucket-1',
files: [{ id: 'f1', name: 'test.txt', size: 100, type: 'file' as const }],
is_truncated: false,
next_page_parameters: {},
},
] as OnlineDriveData[]
expect(isBucketListInitiation(data, [], '')).toBe(false)
})
})
describe('convertOnlineDriveData', () => {
it('should return empty result for empty data', () => {
const result = convertOnlineDriveData([], [], '')
expect(result.fileList).toEqual([])
expect(result.isTruncated).toBe(false)
expect(result.hasBucket).toBe(false)
})
it('should convert bucket list initiation to bucket items', () => {
const data = [
{ bucket: 'bucket-1', files: [], is_truncated: false, next_page_parameters: {} },
{ bucket: 'bucket-2', files: [], is_truncated: false, next_page_parameters: {} },
] as OnlineDriveData[]
const result = convertOnlineDriveData(data, [], '')
expect(result.fileList).toHaveLength(2)
expect(result.fileList[0]).toEqual({
id: 'bucket-1',
name: 'bucket-1',
type: OnlineDriveFileType.bucket,
})
expect(result.hasBucket).toBe(true)
})
it('should convert files when not bucket list', () => {
const data = [
{
bucket: 'bucket-1',
files: [
{ id: 'f1', name: 'test.txt', size: 100, type: 'file' as const },
{ id: 'f2', name: 'folder', size: 0, type: 'folder' as const },
],
is_truncated: true,
next_page_parameters: { token: 'next' },
},
] as OnlineDriveData[]
const result = convertOnlineDriveData(data, [], 'bucket-1')
expect(result.fileList).toHaveLength(2)
expect(result.fileList[0].type).toBe(OnlineDriveFileType.file)
expect(result.fileList[0].size).toBe(100)
expect(result.fileList[1].type).toBe(OnlineDriveFileType.folder)
expect(result.fileList[1].size).toBeUndefined()
expect(result.isTruncated).toBe(true)
expect(result.nextPageParameters).toEqual({ token: 'next' })
expect(result.hasBucket).toBe(true)
})
})
})

View File

@@ -1,96 +0,0 @@
import type { FileItem } from '@/models/datasets'
import { render, renderHook } from '@testing-library/react'
import * as React from 'react'
import { describe, expect, it } from 'vitest'
import { createDataSourceStore, useDataSourceStore, useDataSourceStoreWithSelector } from './'
import DataSourceProvider from './provider'
describe('createDataSourceStore', () => {
it('should create a store with all slices combined', () => {
const store = createDataSourceStore()
const state = store.getState()
// Common slice
expect(state.currentCredentialId).toBe('')
expect(typeof state.setCurrentCredentialId).toBe('function')
// LocalFile slice
expect(state.localFileList).toEqual([])
expect(typeof state.setLocalFileList).toBe('function')
// OnlineDocument slice
expect(state.documentsData).toEqual([])
expect(typeof state.setDocumentsData).toBe('function')
// WebsiteCrawl slice
expect(state.websitePages).toEqual([])
expect(typeof state.setWebsitePages).toBe('function')
// OnlineDrive slice
expect(state.breadcrumbs).toEqual([])
expect(typeof state.setBreadcrumbs).toBe('function')
})
it('should allow cross-slice state updates', () => {
const store = createDataSourceStore()
store.getState().setCurrentCredentialId('cred-1')
store.getState().setLocalFileList([{ file: { id: 'f1' } }] as unknown as FileItem[])
expect(store.getState().currentCredentialId).toBe('cred-1')
expect(store.getState().localFileList).toHaveLength(1)
})
it('should create independent store instances', () => {
const store1 = createDataSourceStore()
const store2 = createDataSourceStore()
store1.getState().setCurrentCredentialId('cred-1')
expect(store2.getState().currentCredentialId).toBe('')
})
})
describe('useDataSourceStoreWithSelector', () => {
it('should throw when used outside provider', () => {
expect(() => {
renderHook(() => useDataSourceStoreWithSelector(s => s.currentCredentialId))
}).toThrow('Missing DataSourceContext.Provider in the tree')
})
it('should return selected state when used inside provider', () => {
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(DataSourceProvider, null, children)
const { result } = renderHook(
() => useDataSourceStoreWithSelector(s => s.currentCredentialId),
{ wrapper },
)
expect(result.current).toBe('')
})
})
describe('useDataSourceStore', () => {
it('should throw when used outside provider', () => {
expect(() => {
renderHook(() => useDataSourceStore())
}).toThrow('Missing DataSourceContext.Provider in the tree')
})
it('should return store when used inside provider', () => {
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(DataSourceProvider, null, children)
const { result } = renderHook(
() => useDataSourceStore(),
{ wrapper },
)
expect(result.current).toBeDefined()
expect(typeof result.current.getState).toBe('function')
})
})
describe('DataSourceProvider', () => {
it('should render children', () => {
const child = React.createElement('div', null, 'Child Content')
const { getByText } = render(React.createElement(DataSourceProvider, null, child))
expect(getByText('Child Content')).toBeInTheDocument()
})
})

View File

@@ -1,89 +0,0 @@
import { render, screen } from '@testing-library/react'
import { useContext } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import DataSourceProvider, { DataSourceContext } from './provider'
const mockStore = { getState: vi.fn(), setState: vi.fn(), subscribe: vi.fn() }
vi.mock('./', () => ({
createDataSourceStore: () => mockStore,
}))
// Test consumer component that reads from context
function ContextConsumer() {
const store = useContext(DataSourceContext)
return (
<div data-testid="context-value" data-has-store={store !== null}>
{store ? 'has-store' : 'no-store'}
</div>
)
}
describe('DataSourceProvider', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering: verifies children are passed through
describe('Rendering', () => {
it('should render children', () => {
render(
<DataSourceProvider>
<span data-testid="child">Hello</span>
</DataSourceProvider>,
)
expect(screen.getByTestId('child')).toBeInTheDocument()
expect(screen.getByText('Hello')).toBeInTheDocument()
})
})
// Context: verifies the store is provided to consumers
describe('Context', () => {
it('should provide store value to context consumers', () => {
render(
<DataSourceProvider>
<ContextConsumer />
</DataSourceProvider>,
)
expect(screen.getByTestId('context-value')).toHaveTextContent('has-store')
expect(screen.getByTestId('context-value')).toHaveAttribute('data-has-store', 'true')
})
it('should provide null when no provider wraps the consumer', () => {
render(<ContextConsumer />)
expect(screen.getByTestId('context-value')).toHaveTextContent('no-store')
expect(screen.getByTestId('context-value')).toHaveAttribute('data-has-store', 'false')
})
})
// Stability: verifies the store reference is stable across re-renders
describe('Store Stability', () => {
it('should reuse same store on re-render (stable reference)', () => {
const storeValues: Array<typeof mockStore | null> = []
function StoreCapture() {
const store = useContext(DataSourceContext)
storeValues.push(store as typeof mockStore | null)
return null
}
const { rerender } = render(
<DataSourceProvider>
<StoreCapture />
</DataSourceProvider>,
)
rerender(
<DataSourceProvider>
<StoreCapture />
</DataSourceProvider>,
)
expect(storeValues).toHaveLength(2)
expect(storeValues[0]).toBe(storeValues[1])
})
})
})

View File

@@ -1,29 +0,0 @@
import type { CommonShape } from './common'
import { describe, expect, it } from 'vitest'
import { createStore } from 'zustand'
import { createCommonSlice } from './common'
const createTestStore = () => createStore<CommonShape>((...args) => createCommonSlice(...args))
describe('createCommonSlice', () => {
it('should initialize with default values', () => {
const state = createTestStore().getState()
expect(state.currentCredentialId).toBe('')
expect(state.currentNodeIdRef.current).toBe('')
expect(state.currentCredentialIdRef.current).toBe('')
})
it('should update currentCredentialId', () => {
const store = createTestStore()
store.getState().setCurrentCredentialId('cred-123')
expect(store.getState().currentCredentialId).toBe('cred-123')
})
it('should update currentCredentialId multiple times', () => {
const store = createTestStore()
store.getState().setCurrentCredentialId('cred-1')
store.getState().setCurrentCredentialId('cred-2')
expect(store.getState().currentCredentialId).toBe('cred-2')
})
})

View File

@@ -1,49 +0,0 @@
import type { LocalFileSliceShape } from './local-file'
import type { CustomFile as File, FileItem } from '@/models/datasets'
import { describe, expect, it } from 'vitest'
import { createStore } from 'zustand'
import { createLocalFileSlice } from './local-file'
const createTestStore = () => createStore<LocalFileSliceShape>((...args) => createLocalFileSlice(...args))
describe('createLocalFileSlice', () => {
it('should initialize with default values', () => {
const state = createTestStore().getState()
expect(state.localFileList).toEqual([])
expect(state.currentLocalFile).toBeUndefined()
expect(state.previewLocalFileRef.current).toBeUndefined()
})
it('should set local file list and update preview ref to first file', () => {
const store = createTestStore()
const files = [
{ file: { id: 'f1', name: 'a.pdf' } },
{ file: { id: 'f2', name: 'b.pdf' } },
] as unknown as FileItem[]
store.getState().setLocalFileList(files)
expect(store.getState().localFileList).toEqual(files)
expect(store.getState().previewLocalFileRef.current).toEqual({ id: 'f1', name: 'a.pdf' })
})
it('should set preview ref to undefined for empty file list', () => {
const store = createTestStore()
store.getState().setLocalFileList([])
expect(store.getState().previewLocalFileRef.current).toBeUndefined()
})
it('should set current local file', () => {
const store = createTestStore()
const file = { id: 'f1', name: 'test.pdf' } as unknown as File
store.getState().setCurrentLocalFile(file)
expect(store.getState().currentLocalFile).toEqual(file)
})
it('should clear current local file with undefined', () => {
const store = createTestStore()
store.getState().setCurrentLocalFile({ id: 'f1' } as unknown as File)
store.getState().setCurrentLocalFile(undefined)
expect(store.getState().currentLocalFile).toBeUndefined()
})
})

View File

@@ -1,55 +0,0 @@
import type { OnlineDocumentSliceShape } from './online-document'
import type { DataSourceNotionWorkspace, NotionPage } from '@/models/common'
import { describe, expect, it } from 'vitest'
import { createStore } from 'zustand'
import { createOnlineDocumentSlice } from './online-document'
const createTestStore = () => createStore<OnlineDocumentSliceShape>((...args) => createOnlineDocumentSlice(...args))
describe('createOnlineDocumentSlice', () => {
it('should initialize with default values', () => {
const state = createTestStore().getState()
expect(state.documentsData).toEqual([])
expect(state.searchValue).toBe('')
expect(state.onlineDocuments).toEqual([])
expect(state.currentDocument).toBeUndefined()
expect(state.selectedPagesId).toEqual(new Set())
expect(state.previewOnlineDocumentRef.current).toBeUndefined()
})
it('should set documents data', () => {
const store = createTestStore()
const data = [{ workspace_id: 'w1', pages: [] }] as unknown as DataSourceNotionWorkspace[]
store.getState().setDocumentsData(data)
expect(store.getState().documentsData).toEqual(data)
})
it('should set search value', () => {
const store = createTestStore()
store.getState().setSearchValue('hello')
expect(store.getState().searchValue).toBe('hello')
})
it('should set online documents and update preview ref', () => {
const store = createTestStore()
const pages = [{ page_id: 'p1' }, { page_id: 'p2' }] as unknown as NotionPage[]
store.getState().setOnlineDocuments(pages)
expect(store.getState().onlineDocuments).toEqual(pages)
expect(store.getState().previewOnlineDocumentRef.current).toEqual({ page_id: 'p1' })
})
it('should set current document', () => {
const store = createTestStore()
const doc = { page_id: 'p1' } as unknown as NotionPage
store.getState().setCurrentDocument(doc)
expect(store.getState().currentDocument).toEqual(doc)
})
it('should set selected pages id', () => {
const store = createTestStore()
const ids = new Set(['p1', 'p2'])
store.getState().setSelectedPagesId(ids)
expect(store.getState().selectedPagesId).toEqual(ids)
})
})

View File

@@ -1,79 +0,0 @@
import type { OnlineDriveSliceShape } from './online-drive'
import type { OnlineDriveFile } from '@/models/pipeline'
import { describe, expect, it } from 'vitest'
import { createStore } from 'zustand'
import { createOnlineDriveSlice } from './online-drive'
const createTestStore = () => createStore<OnlineDriveSliceShape>((...args) => createOnlineDriveSlice(...args))
describe('createOnlineDriveSlice', () => {
it('should initialize with default values', () => {
const state = createTestStore().getState()
expect(state.breadcrumbs).toEqual([])
expect(state.prefix).toEqual([])
expect(state.keywords).toBe('')
expect(state.selectedFileIds).toEqual([])
expect(state.onlineDriveFileList).toEqual([])
expect(state.bucket).toBe('')
expect(state.nextPageParameters).toEqual({})
expect(state.isTruncated.current).toBe(false)
expect(state.previewOnlineDriveFileRef.current).toBeUndefined()
expect(state.hasBucket).toBe(false)
})
it('should set breadcrumbs', () => {
const store = createTestStore()
store.getState().setBreadcrumbs(['root', 'folder'])
expect(store.getState().breadcrumbs).toEqual(['root', 'folder'])
})
it('should set prefix', () => {
const store = createTestStore()
store.getState().setPrefix(['a', 'b'])
expect(store.getState().prefix).toEqual(['a', 'b'])
})
it('should set keywords', () => {
const store = createTestStore()
store.getState().setKeywords('search term')
expect(store.getState().keywords).toBe('search term')
})
it('should set selected file ids and update preview ref', () => {
const store = createTestStore()
const files = [
{ id: 'file-1', name: 'a.pdf', type: 'file' },
{ id: 'file-2', name: 'b.pdf', type: 'file' },
] as unknown as OnlineDriveFile[]
store.getState().setOnlineDriveFileList(files)
store.getState().setSelectedFileIds(['file-1'])
expect(store.getState().selectedFileIds).toEqual(['file-1'])
expect(store.getState().previewOnlineDriveFileRef.current).toEqual(files[0])
})
it('should set preview ref to undefined when selected id not found', () => {
const store = createTestStore()
store.getState().setSelectedFileIds(['non-existent'])
expect(store.getState().previewOnlineDriveFileRef.current).toBeUndefined()
})
it('should set bucket', () => {
const store = createTestStore()
store.getState().setBucket('my-bucket')
expect(store.getState().bucket).toBe('my-bucket')
})
it('should set next page parameters', () => {
const store = createTestStore()
store.getState().setNextPageParameters({ cursor: 'abc' })
expect(store.getState().nextPageParameters).toEqual({ cursor: 'abc' })
})
it('should set hasBucket', () => {
const store = createTestStore()
store.getState().setHasBucket(true)
expect(store.getState().hasBucket).toBe(true)
})
})

View File

@@ -1,65 +0,0 @@
import type { WebsiteCrawlSliceShape } from './website-crawl'
import type { CrawlResult, CrawlResultItem } from '@/models/datasets'
import { describe, expect, it } from 'vitest'
import { createStore } from 'zustand'
import { CrawlStep } from '@/models/datasets'
import { createWebsiteCrawlSlice } from './website-crawl'
const createTestStore = () => createStore<WebsiteCrawlSliceShape>((...args) => createWebsiteCrawlSlice(...args))
describe('createWebsiteCrawlSlice', () => {
it('should initialize with default values', () => {
const state = createTestStore().getState()
expect(state.websitePages).toEqual([])
expect(state.currentWebsite).toBeUndefined()
expect(state.crawlResult).toBeUndefined()
expect(state.step).toBe(CrawlStep.init)
expect(state.previewIndex).toBe(-1)
expect(state.previewWebsitePageRef.current).toBeUndefined()
})
it('should set website pages and update preview ref', () => {
const store = createTestStore()
const pages = [
{ title: 'Page 1', source_url: 'https://a.com' },
{ title: 'Page 2', source_url: 'https://b.com' },
] as unknown as CrawlResultItem[]
store.getState().setWebsitePages(pages)
expect(store.getState().websitePages).toEqual(pages)
expect(store.getState().previewWebsitePageRef.current).toEqual(pages[0])
})
it('should set current website', () => {
const store = createTestStore()
const website = { title: 'Page 1' } as unknown as CrawlResultItem
store.getState().setCurrentWebsite(website)
expect(store.getState().currentWebsite).toEqual(website)
})
it('should set crawl result', () => {
const store = createTestStore()
const result = { data: { count: 5 } } as unknown as CrawlResult
store.getState().setCrawlResult(result)
expect(store.getState().crawlResult).toEqual(result)
})
it('should set step', () => {
const store = createTestStore()
store.getState().setStep(CrawlStep.running)
expect(store.getState().step).toBe(CrawlStep.running)
})
it('should set preview index', () => {
const store = createTestStore()
store.getState().setPreviewIndex(3)
expect(store.getState().previewIndex).toBe(3)
})
it('should clear current website with undefined', () => {
const store = createTestStore()
store.getState().setCurrentWebsite({ title: 'X' } as unknown as CrawlResultItem)
store.getState().setCurrentWebsite(undefined)
expect(store.getState().currentWebsite).toBeUndefined()
})
})

View File

@@ -1,50 +0,0 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import CheckboxWithLabel from './checkbox-with-label'
vi.mock('@/app/components/base/checkbox', () => ({
default: ({ checked, onCheck }: { checked: boolean, onCheck: () => void }) => (
<input type="checkbox" data-testid="checkbox" checked={checked} onChange={onCheck} />
),
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ popupContent }: { popupContent: string }) => <div data-testid="tooltip">{popupContent}</div>,
}))
describe('CheckboxWithLabel', () => {
const defaultProps = {
isChecked: false,
onChange: vi.fn(),
label: 'Test Label',
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render label text', () => {
render(<CheckboxWithLabel {...defaultProps} />)
expect(screen.getByText('Test Label')).toBeInTheDocument()
})
it('should render checkbox', () => {
render(<CheckboxWithLabel {...defaultProps} />)
expect(screen.getByTestId('checkbox')).toBeInTheDocument()
})
it('should render tooltip when provided', () => {
render(<CheckboxWithLabel {...defaultProps} tooltip="Help text" />)
expect(screen.getByTestId('tooltip')).toBeInTheDocument()
})
it('should not render tooltip when not provided', () => {
render(<CheckboxWithLabel {...defaultProps} />)
expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument()
})
it('should apply custom className', () => {
const { container } = render(<CheckboxWithLabel {...defaultProps} className="custom-cls" />)
expect(container.querySelector('.custom-cls')).toBeInTheDocument()
})
})

View File

@@ -1,75 +0,0 @@
import type { CrawlResultItem as CrawlResultItemType } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import CrawledResultItem from './crawled-result-item'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/app/components/base/button', () => ({
default: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
<button data-testid="preview-button" onClick={onClick}>{children}</button>
),
}))
vi.mock('@/app/components/base/checkbox', () => ({
default: ({ checked, onCheck }: { checked: boolean, onCheck: () => void }) => (
<input type="checkbox" data-testid="checkbox" checked={checked} onChange={onCheck} />
),
}))
vi.mock('@/app/components/base/radio/ui', () => ({
default: ({ isChecked, onCheck }: { isChecked: boolean, onCheck: () => void }) => (
<input type="radio" data-testid="radio" checked={isChecked} onChange={onCheck} />
),
}))
describe('CrawledResultItem', () => {
const defaultProps = {
payload: {
title: 'Test Page',
source_url: 'https://example.com/page',
markdown: '',
description: '',
} satisfies CrawlResultItemType,
isChecked: false,
onCheckChange: vi.fn(),
isPreview: false,
showPreview: true,
onPreview: vi.fn(),
isMultipleChoice: true,
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render title and URL', () => {
render(<CrawledResultItem {...defaultProps} />)
expect(screen.getByText('Test Page')).toBeInTheDocument()
expect(screen.getByText('https://example.com/page')).toBeInTheDocument()
})
it('should render checkbox in multiple choice mode', () => {
render(<CrawledResultItem {...defaultProps} />)
expect(screen.getByTestId('checkbox')).toBeInTheDocument()
})
it('should render radio in single choice mode', () => {
render(<CrawledResultItem {...defaultProps} isMultipleChoice={false} />)
expect(screen.getByTestId('radio')).toBeInTheDocument()
})
it('should show preview button when showPreview is true', () => {
render(<CrawledResultItem {...defaultProps} />)
expect(screen.getByTestId('preview-button')).toBeInTheDocument()
})
it('should not show preview button when showPreview is false', () => {
render(<CrawledResultItem {...defaultProps} showPreview={false} />)
expect(screen.queryByTestId('preview-button')).not.toBeInTheDocument()
})
})

View File

@@ -1,218 +0,0 @@
import type { CrawlResultItem } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import CrawledResult from './crawled-result'
vi.mock('./checkbox-with-label', () => ({
default: ({ isChecked, onChange, label }: { isChecked: boolean, onChange: () => void, label: string }) => (
<label>
<input
type="checkbox"
checked={isChecked}
onChange={onChange}
data-testid="check-all-checkbox"
/>
{label}
</label>
),
}))
vi.mock('./crawled-result-item', () => ({
default: ({
payload,
isChecked,
onCheckChange,
onPreview,
}: {
payload: CrawlResultItem
isChecked: boolean
onCheckChange: (checked: boolean) => void
onPreview: () => void
}) => (
<div data-testid={`crawled-item-${payload.source_url}`}>
<span data-testid="item-url">{payload.source_url}</span>
<button data-testid={`check-${payload.source_url}`} onClick={() => onCheckChange(!isChecked)}>
{isChecked ? 'uncheck' : 'check'}
</button>
<button data-testid={`preview-${payload.source_url}`} onClick={onPreview}>
preview
</button>
</div>
),
}))
const createItem = (url: string): CrawlResultItem => ({
source_url: url,
title: `Title for ${url}`,
markdown: `# ${url}`,
description: `Desc for ${url}`,
})
const defaultList: CrawlResultItem[] = [
createItem('https://example.com/a'),
createItem('https://example.com/b'),
createItem('https://example.com/c'),
]
describe('CrawledResult', () => {
const defaultProps = {
list: defaultList,
checkedList: [] as CrawlResultItem[],
onSelectedChange: vi.fn(),
usedTime: 12.345,
}
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering tests
describe('Rendering', () => {
it('should render scrap time info with correct total and time', () => {
render(<CrawledResult {...defaultProps} />)
expect(
screen.getByText(/scrapTimeInfo/),
).toBeInTheDocument()
// The global i18n mock serialises params, so verify total and time appear
expect(screen.getByText(/"total":3/)).toBeInTheDocument()
expect(screen.getByText(/"time":"12.3"/)).toBeInTheDocument()
})
it('should render all items from list', () => {
render(<CrawledResult {...defaultProps} />)
for (const item of defaultList) {
expect(screen.getByTestId(`crawled-item-${item.source_url}`)).toBeInTheDocument()
}
})
it('should apply custom className', () => {
const { container } = render(
<CrawledResult {...defaultProps} className="my-custom-class" />,
)
expect(container.firstChild).toHaveClass('my-custom-class')
})
})
// Check-all checkbox visibility
describe('Check All Checkbox', () => {
it('should show check-all checkbox in multiple choice mode', () => {
render(<CrawledResult {...defaultProps} isMultipleChoice={true} />)
expect(screen.getByTestId('check-all-checkbox')).toBeInTheDocument()
})
it('should hide check-all checkbox in single choice mode', () => {
render(<CrawledResult {...defaultProps} isMultipleChoice={false} />)
expect(screen.queryByTestId('check-all-checkbox')).not.toBeInTheDocument()
})
})
// Toggle all items
describe('Toggle All', () => {
it('should select all when not all checked', () => {
const onSelectedChange = vi.fn()
render(
<CrawledResult
{...defaultProps}
checkedList={[defaultList[0]]}
onSelectedChange={onSelectedChange}
/>,
)
fireEvent.click(screen.getByTestId('check-all-checkbox'))
expect(onSelectedChange).toHaveBeenCalledWith(defaultList)
})
it('should deselect all when all checked', () => {
const onSelectedChange = vi.fn()
render(
<CrawledResult
{...defaultProps}
checkedList={[...defaultList]}
onSelectedChange={onSelectedChange}
/>,
)
fireEvent.click(screen.getByTestId('check-all-checkbox'))
expect(onSelectedChange).toHaveBeenCalledWith([])
})
})
// Individual item check
describe('Individual Item Check', () => {
it('should add item to selection in multiple choice mode', () => {
const onSelectedChange = vi.fn()
render(
<CrawledResult
{...defaultProps}
checkedList={[defaultList[0]]}
onSelectedChange={onSelectedChange}
isMultipleChoice={true}
/>,
)
// Click check on unchecked second item
fireEvent.click(screen.getByTestId(`check-${defaultList[1].source_url}`))
expect(onSelectedChange).toHaveBeenCalledWith([defaultList[0], defaultList[1]])
})
it('should replace selection in single choice mode', () => {
const onSelectedChange = vi.fn()
render(
<CrawledResult
{...defaultProps}
checkedList={[defaultList[0]]}
onSelectedChange={onSelectedChange}
isMultipleChoice={false}
/>,
)
// Click check on unchecked second item
fireEvent.click(screen.getByTestId(`check-${defaultList[1].source_url}`))
expect(onSelectedChange).toHaveBeenCalledWith([defaultList[1]])
})
it('should remove item from selection when unchecked', () => {
const onSelectedChange = vi.fn()
render(
<CrawledResult
{...defaultProps}
checkedList={[defaultList[0], defaultList[1]]}
onSelectedChange={onSelectedChange}
isMultipleChoice={true}
/>,
)
// Click uncheck on checked first item
fireEvent.click(screen.getByTestId(`check-${defaultList[0].source_url}`))
expect(onSelectedChange).toHaveBeenCalledWith([defaultList[1]])
})
})
// Preview
describe('Preview', () => {
it('should call onPreview with correct item and index', () => {
const onPreview = vi.fn()
render(
<CrawledResult
{...defaultProps}
onPreview={onPreview}
showPreview={true}
/>,
)
fireEvent.click(screen.getByTestId(`preview-${defaultList[1].source_url}`))
expect(onPreview).toHaveBeenCalledWith(defaultList[1], 1)
})
})
})

View File

@@ -1,27 +0,0 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import Crawling from './crawling'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
describe('Crawling', () => {
it('should render crawl progress', () => {
render(<Crawling crawledNum={5} totalNum={10} />)
expect(screen.getByText(/5/)).toBeInTheDocument()
expect(screen.getByText(/10/)).toBeInTheDocument()
})
it('should render total page scraped label', () => {
render(<Crawling crawledNum={0} totalNum={0} />)
expect(screen.getByText(/stepOne\.website\.totalPageScraped/)).toBeInTheDocument()
})
it('should apply custom className', () => {
const { container } = render(<Crawling crawledNum={1} totalNum={5} className="custom" />)
expect(container.querySelector('.custom')).toBeInTheDocument()
})
})

View File

@@ -1,35 +0,0 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import ErrorMessage from './error-message'
vi.mock('@remixicon/react', () => ({
RiErrorWarningFill: () => <span data-testid="error-icon" />,
}))
describe('ErrorMessage', () => {
it('should render title', () => {
render(<ErrorMessage title="Something went wrong" />)
expect(screen.getByText('Something went wrong')).toBeInTheDocument()
})
it('should render error icon', () => {
render(<ErrorMessage title="Error" />)
expect(screen.getByTestId('error-icon')).toBeInTheDocument()
})
it('should render error message when provided', () => {
render(<ErrorMessage title="Error" errorMsg="Detailed error info" />)
expect(screen.getByText('Detailed error info')).toBeInTheDocument()
})
it('should not render error message when not provided', () => {
const { container } = render(<ErrorMessage title="Error" />)
const textElements = container.querySelectorAll('.system-xs-regular')
expect(textElements).toHaveLength(0)
})
it('should apply custom className', () => {
const { container } = render(<ErrorMessage title="Error" className="custom-cls" />)
expect(container.querySelector('.custom-cls')).toBeInTheDocument()
})
})

View File

@@ -1,50 +0,0 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useAddDocumentsSteps } from './use-add-documents-steps'
describe('useAddDocumentsSteps', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should initialize with step 1', () => {
const { result } = renderHook(() => useAddDocumentsSteps())
expect(result.current.currentStep).toBe(1)
})
it('should return 3 steps', () => {
const { result } = renderHook(() => useAddDocumentsSteps())
expect(result.current.steps).toHaveLength(3)
})
it('should have correct step labels', () => {
const { result } = renderHook(() => useAddDocumentsSteps())
const labels = result.current.steps.map(s => s.label)
expect(labels[0]).toContain('chooseDatasource')
expect(labels[1]).toContain('processDocuments')
expect(labels[2]).toContain('processingDocuments')
})
it('should increment step on handleNextStep', () => {
const { result } = renderHook(() => useAddDocumentsSteps())
act(() => {
result.current.handleNextStep()
})
expect(result.current.currentStep).toBe(2)
})
it('should decrement step on handleBackStep', () => {
const { result } = renderHook(() => useAddDocumentsSteps())
act(() => {
result.current.handleNextStep()
})
act(() => {
result.current.handleNextStep()
})
expect(result.current.currentStep).toBe(3)
act(() => {
result.current.handleBackStep()
})
expect(result.current.currentStep).toBe(2)
})
})

View File

@@ -1,204 +0,0 @@
import type { Datasource } from '@/app/components/rag-pipeline/components/panel/test-run/types'
import type { DataSourceNotionPageMap, NotionPage } from '@/models/common'
import type { CrawlResultItem, DocumentItem, FileItem } from '@/models/datasets'
import type { OnlineDriveFile } from '@/models/pipeline'
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DatasourceType } from '@/models/pipeline'
import { createDataSourceStore } from '../data-source/store'
import { useDatasourceActions } from './use-datasource-actions'
const mockRunPublishedPipeline = vi.fn()
vi.mock('@/service/use-pipeline', () => ({
useRunPublishedPipeline: () => ({
mutateAsync: mockRunPublishedPipeline,
isIdle: true,
isPending: false,
}),
}))
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: vi.fn(),
}))
describe('useDatasourceActions', () => {
let store: ReturnType<typeof createDataSourceStore>
const defaultParams = () => ({
datasource: { nodeId: 'node-1', nodeData: { provider_type: DatasourceType.localFile } } as unknown as Datasource,
datasourceType: DatasourceType.localFile,
pipelineId: 'pipeline-1',
dataSourceStore: store,
setEstimateData: vi.fn(),
setBatchId: vi.fn(),
setDocuments: vi.fn(),
handleNextStep: vi.fn(),
PagesMapAndSelectedPagesId: {},
currentWorkspacePages: undefined as { page_id: string }[] | undefined,
clearOnlineDocumentData: vi.fn(),
clearWebsiteCrawlData: vi.fn(),
clearOnlineDriveData: vi.fn(),
setDatasource: vi.fn(),
})
beforeEach(() => {
vi.clearAllMocks()
store = createDataSourceStore()
})
it('should return all action functions', () => {
const params = defaultParams()
const { result } = renderHook(() => useDatasourceActions(params))
expect(typeof result.current.onClickProcess).toBe('function')
expect(typeof result.current.onClickPreview).toBe('function')
expect(typeof result.current.handleSubmit).toBe('function')
expect(typeof result.current.handlePreviewFileChange).toBe('function')
expect(typeof result.current.handlePreviewOnlineDocumentChange).toBe('function')
expect(typeof result.current.handlePreviewWebsiteChange).toBe('function')
expect(typeof result.current.handlePreviewOnlineDriveFileChange).toBe('function')
expect(typeof result.current.handleSelectAll).toBe('function')
expect(typeof result.current.handleSwitchDataSource).toBe('function')
expect(typeof result.current.handleCredentialChange).toBe('function')
expect(result.current.isIdle).toBe(true)
expect(result.current.isPending).toBe(false)
})
it('should handle credential change by clearing data and setting new credential', () => {
const params = defaultParams()
const { result } = renderHook(() => useDatasourceActions(params))
act(() => {
result.current.handleCredentialChange('cred-new')
})
expect(store.getState().currentCredentialId).toBe('cred-new')
})
it('should handle switch data source', () => {
const params = defaultParams()
const newDatasource = {
nodeId: 'node-2',
nodeData: { provider_type: DatasourceType.onlineDocument },
} as unknown as Datasource
const { result } = renderHook(() => useDatasourceActions(params))
act(() => {
result.current.handleSwitchDataSource(newDatasource)
})
expect(store.getState().currentCredentialId).toBe('')
expect(store.getState().currentNodeIdRef.current).toBe('node-2')
expect(params.setDatasource).toHaveBeenCalledWith(newDatasource)
})
it('should handle preview file change by updating ref', () => {
const params = defaultParams()
params.dataSourceStore = store
const { result } = renderHook(() => useDatasourceActions(params))
// Set up formRef to prevent null error
result.current.formRef.current = { submit: vi.fn() }
const file = { id: 'f1', name: 'test.pdf' } as unknown as DocumentItem
act(() => {
result.current.handlePreviewFileChange(file)
})
expect(store.getState().previewLocalFileRef.current).toEqual(file)
})
it('should handle preview online document change', () => {
const params = defaultParams()
const { result } = renderHook(() => useDatasourceActions(params))
result.current.formRef.current = { submit: vi.fn() }
const page = { page_id: 'p1', page_name: 'My Page' } as unknown as NotionPage
act(() => {
result.current.handlePreviewOnlineDocumentChange(page)
})
expect(store.getState().previewOnlineDocumentRef.current).toEqual(page)
})
it('should handle preview website change', () => {
const params = defaultParams()
const { result } = renderHook(() => useDatasourceActions(params))
result.current.formRef.current = { submit: vi.fn() }
const website = { title: 'Page', source_url: 'https://example.com' } as unknown as CrawlResultItem
act(() => {
result.current.handlePreviewWebsiteChange(website)
})
expect(store.getState().previewWebsitePageRef.current).toEqual(website)
})
it('should handle select all for online documents', () => {
const params = defaultParams()
params.datasourceType = DatasourceType.onlineDocument
params.currentWorkspacePages = [{ page_id: 'p1' }, { page_id: 'p2' }] as unknown as NotionPage[]
params.PagesMapAndSelectedPagesId = {
p1: { page_id: 'p1', page_name: 'A', workspace_id: 'w1' },
p2: { page_id: 'p2', page_name: 'B', workspace_id: 'w1' },
} as unknown as DataSourceNotionPageMap
const { result } = renderHook(() => useDatasourceActions(params))
// First call: select all
act(() => {
result.current.handleSelectAll()
})
expect(store.getState().onlineDocuments).toHaveLength(2)
// Second call: deselect all
act(() => {
result.current.handleSelectAll()
})
expect(store.getState().onlineDocuments).toEqual([])
})
it('should handle select all for online drive', () => {
const params = defaultParams()
params.datasourceType = DatasourceType.onlineDrive
store.getState().setOnlineDriveFileList([
{ id: 'f1', type: 'file' },
{ id: 'f2', type: 'file' },
{ id: 'b1', type: 'bucket' },
] as unknown as OnlineDriveFile[])
const { result } = renderHook(() => useDatasourceActions(params))
act(() => {
result.current.handleSelectAll()
})
// Should select f1, f2 but not b1 (bucket)
expect(store.getState().selectedFileIds).toEqual(['f1', 'f2'])
})
it('should handle submit with preview mode', async () => {
const params = defaultParams()
store.getState().setLocalFileList([{ file: { id: 'f1', name: 'test.pdf' } }] as unknown as FileItem[])
store.getState().previewLocalFileRef.current = { id: 'f1', name: 'test.pdf' } as unknown as DocumentItem
mockRunPublishedPipeline.mockResolvedValue({ data: { outputs: { tokens: 100 } } })
const { result } = renderHook(() => useDatasourceActions(params))
// Set preview mode
result.current.isPreview.current = true
await act(async () => {
await result.current.handleSubmit({ query: 'test' })
})
expect(mockRunPublishedPipeline).toHaveBeenCalledWith(
expect.objectContaining({
pipeline_id: 'pipeline-1',
is_preview: true,
start_node_id: 'node-1',
}),
expect.anything(),
)
})
})

View File

@@ -1,58 +0,0 @@
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
import type { Node } from '@/app/components/workflow/types'
import { renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/app/components/workflow/types', async () => {
const actual = await vi.importActual<Record<string, unknown>>('@/app/components/workflow/types')
const blockEnum = actual.BlockEnum as Record<string, string>
return {
...actual,
BlockEnum: {
...blockEnum,
DataSource: 'data-source',
},
}
})
const { useDatasourceOptions } = await import('./use-datasource-options')
describe('useDatasourceOptions', () => {
const createNode = (id: string, title: string, type: string): Node<DataSourceNodeType> => ({
id,
position: { x: 0, y: 0 },
data: {
type,
title,
provider_type: 'local_file',
},
} as unknown as Node<DataSourceNodeType>)
it('should return empty array for no datasource nodes', () => {
const nodes = [
createNode('n1', 'LLM Node', 'llm'),
]
const { result } = renderHook(() => useDatasourceOptions(nodes))
expect(result.current).toEqual([])
})
it('should return options for datasource nodes', () => {
const nodes = [
createNode('n1', 'File Upload', 'data-source'),
createNode('n2', 'Web Crawl', 'data-source'),
createNode('n3', 'LLM Node', 'llm'),
]
const { result } = renderHook(() => useDatasourceOptions(nodes))
expect(result.current).toHaveLength(2)
expect(result.current[0]).toEqual({
label: 'File Upload',
value: 'n1',
data: expect.objectContaining({ title: 'File Upload' }),
})
expect(result.current[1]).toEqual({
label: 'Web Crawl',
value: 'n2',
data: expect.objectContaining({ title: 'Web Crawl' }),
})
})
})

View File

@@ -1,207 +0,0 @@
import type { ReactNode } from 'react'
import type { DataSourceNotionWorkspace, NotionPage } from '@/models/common'
import type { CrawlResultItem, CustomFile as File, FileItem } from '@/models/datasets'
import type { OnlineDriveFile } from '@/models/pipeline'
import { act, renderHook } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CrawlStep } from '@/models/datasets'
import { createDataSourceStore } from '../data-source/store'
import { DataSourceContext } from '../data-source/store/provider'
import { useLocalFile, useOnlineDocument, useOnlineDrive, useWebsiteCrawl } from './use-datasource-store'
const createWrapper = (store: ReturnType<typeof createDataSourceStore>) => {
return ({ children }: { children: ReactNode }) =>
React.createElement(DataSourceContext.Provider, { value: store }, children)
}
describe('useLocalFile', () => {
let store: ReturnType<typeof createDataSourceStore>
beforeEach(() => {
vi.clearAllMocks()
store = createDataSourceStore()
})
it('should return local file list and initial state', () => {
const { result } = renderHook(() => useLocalFile(), { wrapper: createWrapper(store) })
expect(result.current.localFileList).toEqual([])
expect(result.current.allFileLoaded).toBe(false)
expect(result.current.currentLocalFile).toBeUndefined()
})
it('should compute allFileLoaded when all files have ids', () => {
store.getState().setLocalFileList([
{ file: { id: 'f1', name: 'a.pdf' } },
{ file: { id: 'f2', name: 'b.pdf' } },
] as unknown as FileItem[])
const { result } = renderHook(() => useLocalFile(), { wrapper: createWrapper(store) })
expect(result.current.allFileLoaded).toBe(true)
})
it('should compute allFileLoaded as false when some files lack ids', () => {
store.getState().setLocalFileList([
{ file: { id: 'f1', name: 'a.pdf' } },
{ file: { id: '', name: 'b.pdf' } },
] as unknown as FileItem[])
const { result } = renderHook(() => useLocalFile(), { wrapper: createWrapper(store) })
expect(result.current.allFileLoaded).toBe(false)
})
it('should hide preview local file', () => {
store.getState().setCurrentLocalFile({ id: 'f1' } as unknown as File)
const { result } = renderHook(() => useLocalFile(), { wrapper: createWrapper(store) })
act(() => {
result.current.hidePreviewLocalFile()
})
expect(store.getState().currentLocalFile).toBeUndefined()
})
})
describe('useOnlineDocument', () => {
let store: ReturnType<typeof createDataSourceStore>
beforeEach(() => {
vi.clearAllMocks()
store = createDataSourceStore()
})
it('should return initial state', () => {
const { result } = renderHook(() => useOnlineDocument(), { wrapper: createWrapper(store) })
expect(result.current.onlineDocuments).toEqual([])
expect(result.current.currentDocument).toBeUndefined()
expect(result.current.currentWorkspace).toBeUndefined()
})
it('should build PagesMapAndSelectedPagesId from documentsData', () => {
store.getState().setDocumentsData([
{ workspace_id: 'w1', pages: [{ page_id: 'p1', page_name: 'Page 1' }] },
] as unknown as DataSourceNotionWorkspace[])
const { result } = renderHook(() => useOnlineDocument(), { wrapper: createWrapper(store) })
expect(result.current.PagesMapAndSelectedPagesId).toHaveProperty('p1')
expect(result.current.PagesMapAndSelectedPagesId.p1.workspace_id).toBe('w1')
})
it('should hide preview online document', () => {
store.getState().setCurrentDocument({ page_id: 'p1' } as unknown as NotionPage)
const { result } = renderHook(() => useOnlineDocument(), { wrapper: createWrapper(store) })
act(() => {
result.current.hidePreviewOnlineDocument()
})
expect(store.getState().currentDocument).toBeUndefined()
})
it('should clear online document data', () => {
store.getState().setDocumentsData([{ workspace_id: 'w1', pages: [] }] as unknown as DataSourceNotionWorkspace[])
store.getState().setSearchValue('test')
store.getState().setOnlineDocuments([{ page_id: 'p1' }] as unknown as NotionPage[])
const { result } = renderHook(() => useOnlineDocument(), { wrapper: createWrapper(store) })
act(() => {
result.current.clearOnlineDocumentData()
})
expect(store.getState().documentsData).toEqual([])
expect(store.getState().searchValue).toBe('')
expect(store.getState().onlineDocuments).toEqual([])
})
})
describe('useWebsiteCrawl', () => {
let store: ReturnType<typeof createDataSourceStore>
beforeEach(() => {
vi.clearAllMocks()
store = createDataSourceStore()
})
it('should return initial state', () => {
const { result } = renderHook(() => useWebsiteCrawl(), { wrapper: createWrapper(store) })
expect(result.current.websitePages).toEqual([])
expect(result.current.currentWebsite).toBeUndefined()
})
it('should hide website preview', () => {
store.getState().setCurrentWebsite({ title: 'Test' } as unknown as CrawlResultItem)
store.getState().setPreviewIndex(2)
const { result } = renderHook(() => useWebsiteCrawl(), { wrapper: createWrapper(store) })
act(() => {
result.current.hideWebsitePreview()
})
expect(store.getState().currentWebsite).toBeUndefined()
expect(store.getState().previewIndex).toBe(-1)
})
it('should clear website crawl data', () => {
store.getState().setStep(CrawlStep.running)
store.getState().setWebsitePages([{ title: 'Test' }] as unknown as CrawlResultItem[])
const { result } = renderHook(() => useWebsiteCrawl(), { wrapper: createWrapper(store) })
act(() => {
result.current.clearWebsiteCrawlData()
})
expect(store.getState().step).toBe(CrawlStep.init)
expect(store.getState().websitePages).toEqual([])
expect(store.getState().currentWebsite).toBeUndefined()
})
})
describe('useOnlineDrive', () => {
let store: ReturnType<typeof createDataSourceStore>
beforeEach(() => {
vi.clearAllMocks()
store = createDataSourceStore()
})
it('should return initial state', () => {
const { result } = renderHook(() => useOnlineDrive(), { wrapper: createWrapper(store) })
expect(result.current.onlineDriveFileList).toEqual([])
expect(result.current.selectedFileIds).toEqual([])
expect(result.current.selectedOnlineDriveFileList).toEqual([])
})
it('should compute selected online drive file list', () => {
const files = [
{ id: 'f1', name: 'a.pdf' },
{ id: 'f2', name: 'b.pdf' },
{ id: 'f3', name: 'c.pdf' },
] as unknown as OnlineDriveFile[]
store.getState().setOnlineDriveFileList(files)
store.getState().setSelectedFileIds(['f1', 'f3'])
const { result } = renderHook(() => useOnlineDrive(), { wrapper: createWrapper(store) })
expect(result.current.selectedOnlineDriveFileList).toEqual([files[0], files[2]])
})
it('should clear online drive data', () => {
store.getState().setOnlineDriveFileList([{ id: 'f1' }] as unknown as OnlineDriveFile[])
store.getState().setBucket('b1')
store.getState().setPrefix(['p1'])
store.getState().setKeywords('kw')
store.getState().setSelectedFileIds(['f1'])
const { result } = renderHook(() => useOnlineDrive(), { wrapper: createWrapper(store) })
act(() => {
result.current.clearOnlineDriveData()
})
expect(store.getState().onlineDriveFileList).toEqual([])
expect(store.getState().bucket).toBe('')
expect(store.getState().prefix).toEqual([])
expect(store.getState().keywords).toBe('')
expect(store.getState().selectedFileIds).toEqual([])
})
})

View File

@@ -1,205 +0,0 @@
import type { Datasource } from '@/app/components/rag-pipeline/components/panel/test-run/types'
import type { OnlineDriveFile } from '@/models/pipeline'
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DatasourceType, OnlineDriveFileType } from '@/models/pipeline'
import { useDatasourceUIState } from './use-datasource-ui-state'
describe('useDatasourceUIState', () => {
const defaultParams = {
datasource: { nodeData: { provider_type: DatasourceType.localFile } } as unknown as Datasource,
allFileLoaded: true,
localFileListLength: 3,
onlineDocumentsLength: 0,
websitePagesLength: 0,
selectedFileIdsLength: 0,
onlineDriveFileList: [] as OnlineDriveFile[],
isVectorSpaceFull: false,
enableBilling: false,
currentWorkspacePagesLength: 0,
fileUploadConfig: { file_size_limit: 50, batch_count_limit: 20 },
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('datasourceType', () => {
it('should return provider_type from datasource', () => {
const { result } = renderHook(() => useDatasourceUIState(defaultParams))
expect(result.current.datasourceType).toBe(DatasourceType.localFile)
})
it('should return undefined when no datasource', () => {
const { result } = renderHook(() =>
useDatasourceUIState({ ...defaultParams, datasource: undefined }),
)
expect(result.current.datasourceType).toBeUndefined()
})
})
describe('isShowVectorSpaceFull', () => {
it('should be false when billing disabled', () => {
const { result } = renderHook(() =>
useDatasourceUIState({ ...defaultParams, isVectorSpaceFull: true }),
)
expect(result.current.isShowVectorSpaceFull).toBe(false)
})
it('should be true when billing enabled and space is full for local file', () => {
const { result } = renderHook(() =>
useDatasourceUIState({
...defaultParams,
isVectorSpaceFull: true,
enableBilling: true,
allFileLoaded: true,
}),
)
expect(result.current.isShowVectorSpaceFull).toBe(true)
})
it('should be false when no datasource', () => {
const { result } = renderHook(() =>
useDatasourceUIState({
...defaultParams,
datasource: undefined,
isVectorSpaceFull: true,
enableBilling: true,
}),
)
expect(result.current.isShowVectorSpaceFull).toBe(false)
})
})
describe('nextBtnDisabled', () => {
it('should be true when no datasource', () => {
const { result } = renderHook(() =>
useDatasourceUIState({ ...defaultParams, datasource: undefined }),
)
expect(result.current.nextBtnDisabled).toBe(true)
})
it('should be false when local files loaded', () => {
const { result } = renderHook(() => useDatasourceUIState(defaultParams))
expect(result.current.nextBtnDisabled).toBe(false)
})
it('should be true when local file list empty', () => {
const { result } = renderHook(() =>
useDatasourceUIState({ ...defaultParams, localFileListLength: 0 }),
)
expect(result.current.nextBtnDisabled).toBe(true)
})
it('should be true when files not all loaded', () => {
const { result } = renderHook(() =>
useDatasourceUIState({ ...defaultParams, allFileLoaded: false }),
)
expect(result.current.nextBtnDisabled).toBe(true)
})
it('should be false for online document with documents selected', () => {
const { result } = renderHook(() =>
useDatasourceUIState({
...defaultParams,
datasource: { nodeData: { provider_type: DatasourceType.onlineDocument } } as unknown as Datasource,
onlineDocumentsLength: 2,
}),
)
expect(result.current.nextBtnDisabled).toBe(false)
})
it('should be true for online document with no documents', () => {
const { result } = renderHook(() =>
useDatasourceUIState({
...defaultParams,
datasource: { nodeData: { provider_type: DatasourceType.onlineDocument } } as unknown as Datasource,
onlineDocumentsLength: 0,
}),
)
expect(result.current.nextBtnDisabled).toBe(true)
})
})
describe('showSelect', () => {
it('should be false for local file type', () => {
const { result } = renderHook(() => useDatasourceUIState(defaultParams))
expect(result.current.showSelect).toBe(false)
})
it('should be true for online document with workspace pages', () => {
const { result } = renderHook(() =>
useDatasourceUIState({
...defaultParams,
datasource: { nodeData: { provider_type: DatasourceType.onlineDocument } } as unknown as Datasource,
currentWorkspacePagesLength: 5,
}),
)
expect(result.current.showSelect).toBe(true)
})
it('should be true for online drive with non-bucket files', () => {
const { result } = renderHook(() =>
useDatasourceUIState({
...defaultParams,
datasource: { nodeData: { provider_type: DatasourceType.onlineDrive } } as unknown as Datasource,
onlineDriveFileList: [
{ id: '1', name: 'file.txt', type: OnlineDriveFileType.file },
],
}),
)
expect(result.current.showSelect).toBe(true)
})
it('should be false for online drive showing only buckets', () => {
const { result } = renderHook(() =>
useDatasourceUIState({
...defaultParams,
datasource: { nodeData: { provider_type: DatasourceType.onlineDrive } } as unknown as Datasource,
onlineDriveFileList: [
{ id: '1', name: 'bucket-1', type: OnlineDriveFileType.bucket },
],
}),
)
expect(result.current.showSelect).toBe(false)
})
})
describe('totalOptions and selectedOptions', () => {
it('should return workspace pages count for online document', () => {
const { result } = renderHook(() =>
useDatasourceUIState({
...defaultParams,
datasource: { nodeData: { provider_type: DatasourceType.onlineDocument } } as unknown as Datasource,
currentWorkspacePagesLength: 10,
onlineDocumentsLength: 3,
}),
)
expect(result.current.totalOptions).toBe(10)
expect(result.current.selectedOptions).toBe(3)
})
it('should return undefined for local file type', () => {
const { result } = renderHook(() => useDatasourceUIState(defaultParams))
expect(result.current.totalOptions).toBeUndefined()
expect(result.current.selectedOptions).toBeUndefined()
})
})
describe('tip', () => {
it('should return empty string for local file', () => {
const { result } = renderHook(() => useDatasourceUIState(defaultParams))
expect(result.current.tip).toBe('')
})
it('should return tip for online document', () => {
const { result } = renderHook(() =>
useDatasourceUIState({
...defaultParams,
datasource: { nodeData: { provider_type: DatasourceType.onlineDocument } } as unknown as Datasource,
}),
)
expect(result.current.tip).toContain('selectOnlineDocumentTip')
})
})
})

View File

@@ -1,115 +0,0 @@
import type { Step } from './step-indicator'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import LeftHeader from './left-header'
vi.mock('next/navigation', () => ({
useParams: () => ({ datasetId: 'test-ds-id' }),
}))
vi.mock('next/link', () => ({
default: ({ children, href }: { children: React.ReactNode, href: string }) => (
<a href={href} data-testid="back-link">{children}</a>
),
}))
vi.mock('@remixicon/react', () => ({
RiArrowLeftLine: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="arrow-left-icon" {...props} />,
}))
vi.mock('./step-indicator', () => ({
default: ({ steps, currentStep }: { steps: Step[], currentStep: number }) => (
<div data-testid="step-indicator" data-steps={steps.length} data-current={currentStep} />
),
}))
vi.mock('@/app/components/base/effect', () => ({
default: ({ className }: { className?: string }) => (
<div data-testid="effect" className={className} />
),
}))
const createSteps = (): Step[] => [
{ label: 'Data Source', value: 'data-source' },
{ label: 'Processing', value: 'processing' },
{ label: 'Complete', value: 'complete' },
]
describe('LeftHeader', () => {
const steps = createSteps()
const defaultProps = {
steps,
title: 'Add Documents',
currentStep: 1,
}
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering: title, step label, and step indicator
describe('Rendering', () => {
it('should render title text', () => {
render(<LeftHeader {...defaultProps} />)
expect(screen.getByText('Add Documents')).toBeInTheDocument()
})
it('should render current step label (steps[currentStep-1].label)', () => {
render(<LeftHeader {...defaultProps} currentStep={2} />)
expect(screen.getByText('Processing')).toBeInTheDocument()
})
it('should render step indicator component', () => {
render(<LeftHeader {...defaultProps} />)
expect(screen.getByTestId('step-indicator')).toBeInTheDocument()
})
it('should render separator between title and step indicator', () => {
render(<LeftHeader {...defaultProps} />)
expect(screen.getByText('/')).toBeInTheDocument()
})
})
// Back button visibility depends on currentStep vs total steps
describe('Back Button', () => {
it('should show back button when currentStep !== steps.length', () => {
render(<LeftHeader {...defaultProps} currentStep={1} />)
expect(screen.getByTestId('back-link')).toBeInTheDocument()
expect(screen.getByTestId('arrow-left-icon')).toBeInTheDocument()
})
it('should hide back button when currentStep === steps.length', () => {
render(<LeftHeader {...defaultProps} currentStep={steps.length} />)
expect(screen.queryByTestId('back-link')).not.toBeInTheDocument()
})
it('should link to correct URL using datasetId from params', () => {
render(<LeftHeader {...defaultProps} currentStep={1} />)
const link = screen.getByTestId('back-link')
expect(link).toHaveAttribute('href', '/datasets/test-ds-id/documents')
})
})
// Edge case: step label for boundary values
describe('Edge Cases', () => {
it('should render first step label when currentStep is 1', () => {
render(<LeftHeader {...defaultProps} currentStep={1} />)
expect(screen.getByText('Data Source')).toBeInTheDocument()
})
it('should render last step label when currentStep equals steps.length', () => {
render(<LeftHeader {...defaultProps} currentStep={3} />)
expect(screen.getByText('Complete')).toBeInTheDocument()
})
})
})

View File

@@ -1,77 +1,320 @@
import type { CustomFile } from '@/models/datasets'
import type { CustomFile as File } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as React from 'react'
import FilePreview from './file-preview'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@remixicon/react', () => ({
RiCloseLine: () => <span data-testid="close-icon" />,
}))
const mockFileData = { content: 'file content here with some text' }
let mockIsFetching = false
// Uses global react-i18next mock from web/vitest.setup.ts
// Mock useFilePreview hook - needs to be mocked to control return values
const mockUseFilePreview = vi.fn()
vi.mock('@/service/use-common', () => ({
useFilePreview: () => ({
data: mockIsFetching ? undefined : mockFileData,
isFetching: mockIsFetching,
}),
useFilePreview: (fileID: string) => mockUseFilePreview(fileID),
}))
vi.mock('../../../common/document-file-icon', () => ({
default: () => <span data-testid="file-icon" />,
}))
// Test data factory
const createMockFile = (overrides?: Partial<File>): File => ({
id: 'file-123',
name: 'test-document.pdf',
size: 2048,
type: 'application/pdf',
extension: 'pdf',
lastModified: Date.now(),
webkitRelativePath: '',
arrayBuffer: vi.fn() as () => Promise<ArrayBuffer>,
bytes: vi.fn() as () => Promise<Uint8Array>,
slice: vi.fn() as (start?: number, end?: number, contentType?: string) => Blob,
stream: vi.fn() as () => ReadableStream<Uint8Array>,
text: vi.fn() as () => Promise<string>,
...overrides,
} as File)
vi.mock('./loading', () => ({
default: () => <div data-testid="loading" />,
}))
const createMockFilePreviewData = (content: string = 'This is the file content') => ({
content,
})
const defaultProps = {
file: createMockFile(),
hidePreview: vi.fn(),
}
describe('FilePreview', () => {
const defaultProps = {
file: {
id: 'file-1',
name: 'document.pdf',
extension: 'pdf',
size: 1024,
} as CustomFile,
hidePreview: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
mockIsFetching = false
mockUseFilePreview.mockReturnValue({
data: undefined,
isFetching: false,
})
})
it('should render preview label', () => {
render(<FilePreview {...defaultProps} />)
expect(screen.getByText('addDocuments.stepOne.preview')).toBeInTheDocument()
describe('Rendering', () => {
it('should render the component with file information', () => {
render(<FilePreview {...defaultProps} />)
// i18n mock returns key by default
expect(screen.getByText('datasetPipeline.addDocuments.stepOne.preview')).toBeInTheDocument()
expect(screen.getByText('test-document.pdf')).toBeInTheDocument()
})
it('should display file extension in uppercase via CSS class', () => {
render(<FilePreview {...defaultProps} />)
// The extension is displayed in the info section (as uppercase via CSS class)
const extensionElement = screen.getByText('pdf')
expect(extensionElement).toBeInTheDocument()
expect(extensionElement).toHaveClass('uppercase')
})
it('should display formatted file size', () => {
render(<FilePreview {...defaultProps} />)
// Real formatFileSize: 2048 bytes => "2.00 KB"
expect(screen.getByText('2.00 KB')).toBeInTheDocument()
})
it('should render close button', () => {
render(<FilePreview {...defaultProps} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should call useFilePreview with correct fileID', () => {
const file = createMockFile({ id: 'specific-file-id' })
render(<FilePreview {...defaultProps} file={file} />)
expect(mockUseFilePreview).toHaveBeenCalledWith('specific-file-id')
})
})
it('should render file name', () => {
render(<FilePreview {...defaultProps} />)
expect(screen.getByText('document.pdf')).toBeInTheDocument()
describe('File Name Processing', () => {
it('should extract file name without extension', () => {
const file = createMockFile({ name: 'my-document.pdf', extension: 'pdf' })
render(<FilePreview {...defaultProps} file={file} />)
// The displayed text is `${fileName}.${extension}`, where fileName is name without ext
// my-document.pdf -> fileName = 'my-document', displayed as 'my-document.pdf'
expect(screen.getByText('my-document.pdf')).toBeInTheDocument()
})
it('should handle file name with multiple dots', () => {
const file = createMockFile({ name: 'my.file.name.pdf', extension: 'pdf' })
render(<FilePreview {...defaultProps} file={file} />)
// fileName = arr.slice(0, -1).join() = 'my,file,name', then displayed as 'my,file,name.pdf'
expect(screen.getByText('my,file,name.pdf')).toBeInTheDocument()
})
it('should handle empty file name', () => {
const file = createMockFile({ name: '', extension: '' })
render(<FilePreview {...defaultProps} file={file} />)
// fileName = '', displayed as '.'
expect(screen.getByText('.')).toBeInTheDocument()
})
it('should handle file without extension in name', () => {
const file = createMockFile({ name: 'noextension', extension: '' })
render(<FilePreview {...defaultProps} file={file} />)
// fileName = '' (slice returns empty for single element array), displayed as '.'
expect(screen.getByText('.')).toBeInTheDocument()
})
})
it('should render file content when loaded', () => {
render(<FilePreview {...defaultProps} />)
expect(screen.getByText('file content here with some text')).toBeInTheDocument()
describe('Loading State', () => {
it('should render loading component when fetching', () => {
mockUseFilePreview.mockReturnValue({
data: undefined,
isFetching: true,
})
render(<FilePreview {...defaultProps} />)
// Loading component renders skeleton
expect(document.querySelector('.overflow-hidden')).toBeInTheDocument()
})
it('should not render content when loading', () => {
mockUseFilePreview.mockReturnValue({
data: createMockFilePreviewData('Some content'),
isFetching: true,
})
render(<FilePreview {...defaultProps} />)
expect(screen.queryByText('Some content')).not.toBeInTheDocument()
})
})
it('should render loading state', () => {
mockIsFetching = true
render(<FilePreview {...defaultProps} />)
expect(screen.getByTestId('loading')).toBeInTheDocument()
describe('Content Display', () => {
it('should render file content when loaded', () => {
mockUseFilePreview.mockReturnValue({
data: createMockFilePreviewData('This is the file content'),
isFetching: false,
})
render(<FilePreview {...defaultProps} />)
expect(screen.getByText('This is the file content')).toBeInTheDocument()
})
it('should display character count when data is available', () => {
mockUseFilePreview.mockReturnValue({
data: createMockFilePreviewData('Hello'), // 5 characters
isFetching: false,
})
render(<FilePreview {...defaultProps} />)
// Real formatNumberAbbreviated returns "5" for numbers < 1000
expect(screen.getByText(/5/)).toBeInTheDocument()
})
it('should format large character counts', () => {
const longContent = 'a'.repeat(2500)
mockUseFilePreview.mockReturnValue({
data: createMockFilePreviewData(longContent),
isFetching: false,
})
render(<FilePreview {...defaultProps} />)
// Real formatNumberAbbreviated uses lowercase 'k': "2.5k"
expect(screen.getByText(/2\.5k/)).toBeInTheDocument()
})
it('should not display character count when data is not available', () => {
mockUseFilePreview.mockReturnValue({
data: undefined,
isFetching: false,
})
render(<FilePreview {...defaultProps} />)
// No character text shown
expect(screen.queryByText(/datasetPipeline\.addDocuments\.characters/)).not.toBeInTheDocument()
})
})
it('should call hidePreview when close button clicked', () => {
render(<FilePreview {...defaultProps} />)
const closeBtn = screen.getByTestId('close-icon').closest('button')!
fireEvent.click(closeBtn)
expect(defaultProps.hidePreview).toHaveBeenCalled()
describe('User Interactions', () => {
it('should call hidePreview when close button is clicked', () => {
const hidePreview = vi.fn()
render(<FilePreview {...defaultProps} hidePreview={hidePreview} />)
const closeButton = screen.getByRole('button')
fireEvent.click(closeButton)
expect(hidePreview).toHaveBeenCalledTimes(1)
})
})
describe('File Size Formatting', () => {
it('should format small file sizes in bytes', () => {
const file = createMockFile({ size: 500 })
render(<FilePreview {...defaultProps} file={file} />)
// Real formatFileSize: 500 => "500.00 bytes"
expect(screen.getByText('500.00 bytes')).toBeInTheDocument()
})
it('should format kilobyte file sizes', () => {
const file = createMockFile({ size: 5120 })
render(<FilePreview {...defaultProps} file={file} />)
// Real formatFileSize: 5120 => "5.00 KB"
expect(screen.getByText('5.00 KB')).toBeInTheDocument()
})
it('should format megabyte file sizes', () => {
const file = createMockFile({ size: 2097152 })
render(<FilePreview {...defaultProps} file={file} />)
// Real formatFileSize: 2097152 => "2.00 MB"
expect(screen.getByText('2.00 MB')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle undefined file id', () => {
const file = createMockFile({ id: undefined })
render(<FilePreview {...defaultProps} file={file} />)
expect(mockUseFilePreview).toHaveBeenCalledWith('')
})
it('should handle empty extension', () => {
const file = createMockFile({ extension: undefined })
render(<FilePreview {...defaultProps} file={file} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should handle zero file size', () => {
const file = createMockFile({ size: 0 })
render(<FilePreview {...defaultProps} file={file} />)
// Real formatFileSize returns 0 for falsy values
// The component still renders
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should handle very long file content', () => {
const veryLongContent = 'a'.repeat(1000000)
mockUseFilePreview.mockReturnValue({
data: createMockFilePreviewData(veryLongContent),
isFetching: false,
})
render(<FilePreview {...defaultProps} />)
// Real formatNumberAbbreviated: 1000000 => "1M"
expect(screen.getByText(/1M/)).toBeInTheDocument()
})
it('should handle empty content', () => {
mockUseFilePreview.mockReturnValue({
data: createMockFilePreviewData(''),
isFetching: false,
})
render(<FilePreview {...defaultProps} />)
// Real formatNumberAbbreviated: 0 => "0"
// Find the element that contains character count info
expect(screen.getByText(/0 datasetPipeline/)).toBeInTheDocument()
})
})
describe('useMemo for fileName', () => {
it('should extract file name when file exists', () => {
// When file exists, it should extract the name without extension
const file = createMockFile({ name: 'document.txt', extension: 'txt' })
render(<FilePreview {...defaultProps} file={file} />)
expect(screen.getByText('document.txt')).toBeInTheDocument()
})
it('should memoize fileName based on file prop', () => {
const file = createMockFile({ name: 'test.pdf', extension: 'pdf' })
const { rerender } = render(<FilePreview {...defaultProps} file={file} />)
// Same file should produce same result
rerender(<FilePreview {...defaultProps} file={file} />)
expect(screen.getByText('test.pdf')).toBeInTheDocument()
})
})
})

View File

@@ -1,58 +1,256 @@
import type { CrawlResultItem } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import WebPreview from './web-preview'
import * as React from 'react'
import WebsitePreview from './web-preview'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Uses global react-i18next mock from web/vitest.setup.ts
vi.mock('@remixicon/react', () => ({
RiCloseLine: () => <span data-testid="close-icon" />,
RiGlobalLine: () => <span data-testid="global-icon" />,
}))
// Test data factory
const createMockCrawlResult = (overrides?: Partial<CrawlResultItem>): CrawlResultItem => ({
title: 'Test Website Title',
markdown: 'This is the **markdown** content of the website.',
description: 'Test description',
source_url: 'https://example.com/page',
...overrides,
})
describe('WebPreview', () => {
const defaultProps = {
currentWebsite: {
title: 'Test Page',
source_url: 'https://example.com',
markdown: 'Hello **markdown** content',
description: '',
} satisfies CrawlResultItem,
hidePreview: vi.fn(),
}
const defaultProps = {
currentWebsite: createMockCrawlResult(),
hidePreview: vi.fn(),
}
describe('WebsitePreview', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render preview label', () => {
render(<WebPreview {...defaultProps} />)
expect(screen.getByText('addDocuments.stepOne.preview')).toBeInTheDocument()
describe('Rendering', () => {
it('should render the component with website information', () => {
render(<WebsitePreview {...defaultProps} />)
// i18n mock returns key by default
expect(screen.getByText('datasetPipeline.addDocuments.stepOne.preview')).toBeInTheDocument()
expect(screen.getByText('Test Website Title')).toBeInTheDocument()
})
it('should display the source URL', () => {
render(<WebsitePreview {...defaultProps} />)
expect(screen.getByText('https://example.com/page')).toBeInTheDocument()
})
it('should render close button', () => {
render(<WebsitePreview {...defaultProps} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render the markdown content', () => {
render(<WebsitePreview {...defaultProps} />)
expect(screen.getByText('This is the **markdown** content of the website.')).toBeInTheDocument()
})
})
it('should render page title', () => {
render(<WebPreview {...defaultProps} />)
expect(screen.getByText('Test Page')).toBeInTheDocument()
describe('Character Count', () => {
it('should display character count for small content', () => {
const currentWebsite = createMockCrawlResult({ markdown: 'Hello' }) // 5 characters
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
// Real formatNumberAbbreviated returns "5" for numbers < 1000
expect(screen.getByText(/5/)).toBeInTheDocument()
})
it('should format character count in thousands', () => {
const longContent = 'a'.repeat(2500)
const currentWebsite = createMockCrawlResult({ markdown: longContent })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
// Real formatNumberAbbreviated uses lowercase 'k': "2.5k"
expect(screen.getByText(/2\.5k/)).toBeInTheDocument()
})
it('should format character count in millions', () => {
const veryLongContent = 'a'.repeat(1500000)
const currentWebsite = createMockCrawlResult({ markdown: veryLongContent })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
expect(screen.getByText(/1\.5M/)).toBeInTheDocument()
})
it('should show 0 characters for empty markdown', () => {
const currentWebsite = createMockCrawlResult({ markdown: '' })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
expect(screen.getByText(/0/)).toBeInTheDocument()
})
})
it('should render source URL', () => {
render(<WebPreview {...defaultProps} />)
expect(screen.getByText('https://example.com')).toBeInTheDocument()
describe('User Interactions', () => {
it('should call hidePreview when close button is clicked', () => {
const hidePreview = vi.fn()
render(<WebsitePreview {...defaultProps} hidePreview={hidePreview} />)
const closeButton = screen.getByRole('button')
fireEvent.click(closeButton)
expect(hidePreview).toHaveBeenCalledTimes(1)
})
})
it('should render markdown content', () => {
render(<WebPreview {...defaultProps} />)
expect(screen.getByText('Hello **markdown** content')).toBeInTheDocument()
describe('URL Display', () => {
it('should display long URLs', () => {
const longUrl = 'https://example.com/very/long/path/to/page/with/many/segments'
const currentWebsite = createMockCrawlResult({ source_url: longUrl })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
const urlElement = screen.getByTitle(longUrl)
expect(urlElement).toBeInTheDocument()
expect(urlElement).toHaveTextContent(longUrl)
})
it('should display URL with title attribute', () => {
const currentWebsite = createMockCrawlResult({ source_url: 'https://test.com' })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
expect(screen.getByTitle('https://test.com')).toBeInTheDocument()
})
})
it('should call hidePreview when close button clicked', () => {
render(<WebPreview {...defaultProps} />)
const closeBtn = screen.getByTestId('close-icon').closest('button')!
fireEvent.click(closeBtn)
expect(defaultProps.hidePreview).toHaveBeenCalled()
describe('Content Display', () => {
it('should display the markdown content in content area', () => {
const currentWebsite = createMockCrawlResult({
markdown: 'Content with **bold** and *italic* text.',
})
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
expect(screen.getByText('Content with **bold** and *italic* text.')).toBeInTheDocument()
})
it('should handle multiline content', () => {
const multilineContent = 'Line 1\nLine 2\nLine 3'
const currentWebsite = createMockCrawlResult({ markdown: multilineContent })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
// Multiline content is rendered as-is
expect(screen.getByText((content) => {
return content.includes('Line 1') && content.includes('Line 2') && content.includes('Line 3')
})).toBeInTheDocument()
})
it('should handle special characters in content', () => {
const specialContent = '<script>alert("xss")</script> & < > " \''
const currentWebsite = createMockCrawlResult({ markdown: specialContent })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
expect(screen.getByText(specialContent)).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty title', () => {
const currentWebsite = createMockCrawlResult({ title: '' })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should handle empty source URL', () => {
const currentWebsite = createMockCrawlResult({ source_url: '' })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should handle very long title', () => {
const longTitle = 'A'.repeat(500)
const currentWebsite = createMockCrawlResult({ title: longTitle })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
expect(screen.getByText(longTitle)).toBeInTheDocument()
})
it('should handle unicode characters in content', () => {
const unicodeContent = '你好世界 🌍 مرحبا こんにちは'
const currentWebsite = createMockCrawlResult({ markdown: unicodeContent })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
expect(screen.getByText(unicodeContent)).toBeInTheDocument()
})
it('should handle URL with query parameters', () => {
const urlWithParams = 'https://example.com/page?query=test&param=value'
const currentWebsite = createMockCrawlResult({ source_url: urlWithParams })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
expect(screen.getByTitle(urlWithParams)).toBeInTheDocument()
})
it('should handle URL with hash fragment', () => {
const urlWithHash = 'https://example.com/page#section-1'
const currentWebsite = createMockCrawlResult({ source_url: urlWithHash })
render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />)
expect(screen.getByTitle(urlWithHash)).toBeInTheDocument()
})
})
describe('Styling', () => {
it('should apply container styles', () => {
const { container } = render(<WebsitePreview {...defaultProps} />)
const mainContainer = container.firstChild as HTMLElement
expect(mainContainer).toHaveClass('flex', 'h-full', 'w-full', 'flex-col')
})
})
describe('Multiple Renders', () => {
it('should update when currentWebsite changes', () => {
const website1 = createMockCrawlResult({ title: 'Website 1', markdown: 'Content 1' })
const website2 = createMockCrawlResult({ title: 'Website 2', markdown: 'Content 2' })
const { rerender } = render(<WebsitePreview {...defaultProps} currentWebsite={website1} />)
expect(screen.getByText('Website 1')).toBeInTheDocument()
expect(screen.getByText('Content 1')).toBeInTheDocument()
rerender(<WebsitePreview {...defaultProps} currentWebsite={website2} />)
expect(screen.getByText('Website 2')).toBeInTheDocument()
expect(screen.getByText('Content 2')).toBeInTheDocument()
})
it('should call new hidePreview when prop changes', () => {
const hidePreview1 = vi.fn()
const hidePreview2 = vi.fn()
const { rerender } = render(<WebsitePreview {...defaultProps} hidePreview={hidePreview1} />)
const closeButton = screen.getByRole('button')
fireEvent.click(closeButton)
expect(hidePreview1).toHaveBeenCalledTimes(1)
rerender(<WebsitePreview {...defaultProps} hidePreview={hidePreview2} />)
fireEvent.click(closeButton)
expect(hidePreview2).toHaveBeenCalledTimes(1)
expect(hidePreview1).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -1,73 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Actions from './actions'
vi.mock('@remixicon/react', () => ({
RiArrowLeftLine: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="arrow-left-icon" {...props} />,
}))
describe('Actions', () => {
const defaultProps = {
onBack: vi.fn(),
onProcess: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering: verify both action buttons render with correct labels
describe('Rendering', () => {
it('should render back button and process button', () => {
render(<Actions {...defaultProps} />)
const buttons = screen.getAllByRole('button')
expect(buttons).toHaveLength(2)
expect(screen.getByText('datasetPipeline.operations.dataSource')).toBeInTheDocument()
expect(screen.getByText('datasetPipeline.operations.saveAndProcess')).toBeInTheDocument()
})
})
// User interactions: clicking back and process buttons
describe('User Interactions', () => {
it('should call onBack when back button clicked', () => {
render(<Actions {...defaultProps} />)
fireEvent.click(screen.getByText('datasetPipeline.operations.dataSource'))
expect(defaultProps.onBack).toHaveBeenCalledOnce()
})
it('should call onProcess when process button clicked', () => {
render(<Actions {...defaultProps} />)
fireEvent.click(screen.getByText('datasetPipeline.operations.saveAndProcess'))
expect(defaultProps.onProcess).toHaveBeenCalledOnce()
})
})
// Props: disabled state for the process button
describe('Props', () => {
it('should disable process button when runDisabled is true', () => {
render(<Actions {...defaultProps} runDisabled />)
const processButton = screen.getByText('datasetPipeline.operations.saveAndProcess').closest('button')
expect(processButton).toBeDisabled()
})
it('should enable process button when runDisabled is false', () => {
render(<Actions {...defaultProps} runDisabled={false} />)
const processButton = screen.getByText('datasetPipeline.operations.saveAndProcess').closest('button')
expect(processButton).not.toBeDisabled()
})
it('should enable process button when runDisabled is undefined', () => {
render(<Actions {...defaultProps} />)
const processButton = screen.getByText('datasetPipeline.operations.saveAndProcess').closest('button')
expect(processButton).not.toBeDisabled()
})
})
})

View File

@@ -1,67 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Header from './header'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@remixicon/react', () => ({
RiSearchEyeLine: () => <span data-testid="search-icon" />,
}))
vi.mock('@/app/components/base/button', () => ({
default: ({ children, onClick, disabled, variant }: { children: React.ReactNode, onClick: () => void, disabled?: boolean, variant: string }) => (
<button data-testid={`btn-${variant}`} onClick={onClick} disabled={disabled}>
{children}
</button>
),
}))
describe('Header', () => {
const defaultProps = {
onReset: vi.fn(),
resetDisabled: false,
previewDisabled: false,
onPreview: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render chunk settings title', () => {
render(<Header {...defaultProps} />)
expect(screen.getByText('addDocuments.stepTwo.chunkSettings')).toBeInTheDocument()
})
it('should render reset and preview buttons', () => {
render(<Header {...defaultProps} />)
expect(screen.getByTestId('btn-ghost')).toBeInTheDocument()
expect(screen.getByTestId('btn-secondary-accent')).toBeInTheDocument()
})
it('should call onReset when reset clicked', () => {
render(<Header {...defaultProps} />)
fireEvent.click(screen.getByTestId('btn-ghost'))
expect(defaultProps.onReset).toHaveBeenCalled()
})
it('should call onPreview when preview clicked', () => {
render(<Header {...defaultProps} />)
fireEvent.click(screen.getByTestId('btn-secondary-accent'))
expect(defaultProps.onPreview).toHaveBeenCalled()
})
it('should disable reset button when resetDisabled is true', () => {
render(<Header {...defaultProps} resetDisabled={true} />)
expect(screen.getByTestId('btn-ghost')).toBeDisabled()
})
it('should disable preview button when previewDisabled is true', () => {
render(<Header {...defaultProps} previewDisabled={true} />)
expect(screen.getByTestId('btn-secondary-accent')).toBeDisabled()
})
})

Some files were not shown because too many files have changed in this diff Show More