diff --git a/web/app/components/billing/apps-full-in-dialog/__tests__/index.spec.tsx b/web/app/components/billing/apps-full-in-dialog/__tests__/index.spec.tsx index 9d435456b1..acee660f46 100644 --- a/web/app/components/billing/apps-full-in-dialog/__tests__/index.spec.tsx +++ b/web/app/components/billing/apps-full-in-dialog/__tests__/index.spec.tsx @@ -197,61 +197,30 @@ describe('AppsFull', () => { }) describe('Edge Cases', () => { - it('should use the success color when usage is below 50%', () => { - ;(useProviderContext as Mock).mockReturnValue(buildProviderContext({ - plan: { - ...baseProviderContextValue.plan, - type: Plan.sandbox, - usage: buildUsage({ buildApps: 2 }), - total: buildUsage({ buildApps: 5 }), - reset: { - apiRateLimit: null, - triggerEvents: null, + it('should apply distinct progress bar styling at different usage levels', () => { + const renderWithUsage = (used: number, total: number) => { + ;(useProviderContext as Mock).mockReturnValue(buildProviderContext({ + plan: { + ...baseProviderContextValue.plan, + type: Plan.sandbox, + usage: buildUsage({ buildApps: used }), + total: buildUsage({ buildApps: total }), + reset: { apiRateLimit: null, triggerEvents: null }, }, - }, - })) + })) + const { unmount } = render() + const className = screen.getByTestId('billing-progress-bar').className + unmount() + return className + } - render() + const normalClass = renderWithUsage(2, 10) + const warningClass = renderWithUsage(6, 10) + const errorClass = renderWithUsage(8, 10) - expect(screen.getByTestId('billing-progress-bar')).toHaveClass('bg-components-progress-bar-progress-solid') - }) - - it('should use the warning color when usage is between 50% and 80%', () => { - ;(useProviderContext as Mock).mockReturnValue(buildProviderContext({ - plan: { - ...baseProviderContextValue.plan, - type: Plan.sandbox, - usage: buildUsage({ buildApps: 6 }), - total: buildUsage({ buildApps: 10 }), - reset: { - apiRateLimit: null, - triggerEvents: null, - }, - }, - })) - - render() - - expect(screen.getByTestId('billing-progress-bar')).toHaveClass('bg-components-progress-warning-progress') - }) - - it('should use the error color when usage is 80% or higher', () => { - ;(useProviderContext as Mock).mockReturnValue(buildProviderContext({ - plan: { - ...baseProviderContextValue.plan, - type: Plan.sandbox, - usage: buildUsage({ buildApps: 8 }), - total: buildUsage({ buildApps: 10 }), - reset: { - apiRateLimit: null, - triggerEvents: null, - }, - }, - })) - - render() - - expect(screen.getByTestId('billing-progress-bar')).toHaveClass('bg-components-progress-error-progress') + expect(normalClass).not.toBe(warningClass) + expect(warningClass).not.toBe(errorClass) + expect(normalClass).not.toBe(errorClass) }) }) }) diff --git a/web/app/components/billing/header-billing-btn/__tests__/index.spec.tsx b/web/app/components/billing/header-billing-btn/__tests__/index.spec.tsx index fa4825b1f1..818e0e9b1b 100644 --- a/web/app/components/billing/header-billing-btn/__tests__/index.spec.tsx +++ b/web/app/components/billing/header-billing-btn/__tests__/index.spec.tsx @@ -70,7 +70,7 @@ describe('HeaderBillingBtn', () => { expect(screen.getByTestId('upgrade-btn')).toBeInTheDocument() }) - it('renders team badge for team plan with correct styling', () => { + it('renders team badge for team plan', () => { ensureProviderContextMock().mockReturnValueOnce({ plan: { type: Plan.team }, enableBilling: true, @@ -79,9 +79,7 @@ describe('HeaderBillingBtn', () => { render() - const badge = screen.getByText('team').closest('div') - expect(badge).toBeInTheDocument() - expect(badge).toHaveClass('bg-[#E0EAFF]') + expect(screen.getByText('team')).toBeInTheDocument() }) it('renders nothing when plan is not fetched', () => { @@ -111,16 +109,11 @@ describe('HeaderBillingBtn', () => { const { rerender } = render() - const badge = screen.getByText('pro').closest('div') - - expect(badge).toHaveClass('cursor-pointer') - - fireEvent.click(badge!) + const badge = screen.getByText('pro').closest('div')! + fireEvent.click(badge) expect(onClick).toHaveBeenCalledTimes(1) rerender() - expect(screen.getByText('pro').closest('div')).toHaveClass('cursor-default') - fireEvent.click(screen.getByText('pro').closest('div')!) expect(onClick).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/billing/pricing/plan-switcher/__tests__/tab.spec.tsx b/web/app/components/billing/pricing/plan-switcher/__tests__/tab.spec.tsx index abb18b5126..ebe3ad43ef 100644 --- a/web/app/components/billing/pricing/plan-switcher/__tests__/tab.spec.tsx +++ b/web/app/components/billing/pricing/plan-switcher/__tests__/tab.spec.tsx @@ -47,8 +47,20 @@ describe('PlanSwitcherTab', () => { expect(handleClick).toHaveBeenCalledWith('self') }) - it('should apply active text class when isActive is true', () => { - render( + it('should apply distinct styling when isActive is true', () => { + const { rerender } = render( + , + ) + + const inactiveClassName = screen.getByText('Cloud').className + + rerender( { />, ) - expect(screen.getByText('Cloud')).toHaveClass('text-saas-dify-blue-accessible') + const activeClassName = screen.getByText('Cloud').className + expect(activeClassName).not.toBe(inactiveClassName) expect(screen.getByTestId('tab-icon')).toHaveAttribute('data-active', 'true') }) }) diff --git a/web/app/components/billing/progress-bar/__tests__/index.spec.tsx b/web/app/components/billing/progress-bar/__tests__/index.spec.tsx index ffdbfb30e7..4310fab19d 100644 --- a/web/app/components/billing/progress-bar/__tests__/index.spec.tsx +++ b/web/app/components/billing/progress-bar/__tests__/index.spec.tsx @@ -7,7 +7,6 @@ describe('ProgressBar', () => { render() const bar = screen.getByTestId('billing-progress-bar') - expect(bar).toHaveClass('bg-test-color') expect(bar.getAttribute('style')).toContain('width: 42%') }) @@ -18,11 +17,10 @@ describe('ProgressBar', () => { expect(bar.getAttribute('style')).toContain('width: 100%') }) - it('uses the default color when no color prop is provided', () => { + it('renders with default color when no color prop is provided', () => { render() const bar = screen.getByTestId('billing-progress-bar') - expect(bar).toHaveClass('bg-components-progress-bar-progress-solid') expect(bar.getAttribute('style')).toContain('width: 20%') }) }) @@ -31,9 +29,7 @@ describe('ProgressBar', () => { it('should render indeterminate progress bar when indeterminate is true', () => { render() - const bar = screen.getByTestId('billing-progress-bar-indeterminate') - expect(bar).toBeInTheDocument() - expect(bar).toHaveClass('bg-progress-bar-indeterminate-stripe') + expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument() }) it('should not render normal progress bar when indeterminate is true', () => { @@ -43,20 +39,20 @@ describe('ProgressBar', () => { expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument() }) - it('should render with default width (w-[30px]) when indeterminateFull is false', () => { - render() + it('should render with different width based on indeterminateFull prop', () => { + const { rerender } = render( + , + ) const bar = screen.getByTestId('billing-progress-bar-indeterminate') - expect(bar).toHaveClass('w-[30px]') - expect(bar).not.toHaveClass('w-full') - }) + const partialClassName = bar.className - it('should render with full width (w-full) when indeterminateFull is true', () => { - render() + rerender( + , + ) - const bar = screen.getByTestId('billing-progress-bar-indeterminate') - expect(bar).toHaveClass('w-full') - expect(bar).not.toHaveClass('w-[30px]') + const fullClassName = screen.getByTestId('billing-progress-bar-indeterminate').className + expect(partialClassName).not.toBe(fullClassName) }) }) }) diff --git a/web/app/components/billing/usage-info/__tests__/index.spec.tsx b/web/app/components/billing/usage-info/__tests__/index.spec.tsx index b781ef7746..3cbab5c662 100644 --- a/web/app/components/billing/usage-info/__tests__/index.spec.tsx +++ b/web/app/components/billing/usage-info/__tests__/index.spec.tsx @@ -71,8 +71,19 @@ describe('UsageInfo', () => { expect(screen.getByText('billing.plansCommon.unlimited')).toBeInTheDocument() }) - it('applies warning color when usage is close to the limit', () => { - render( + it('applies distinct styling when usage is close to or exceeds the limit', () => { + const { rerender } = render( + , + ) + + const normalBarClass = screen.getByTestId('billing-progress-bar').className + + rerender( { />, ) - const progressBar = screen.getByTestId('billing-progress-bar') - expect(progressBar).toHaveClass('bg-components-progress-warning-progress') - }) + const warningBarClass = screen.getByTestId('billing-progress-bar').className + expect(warningBarClass).not.toBe(normalBarClass) - it('applies error color when usage exceeds the limit', () => { - render( + rerender( { />, ) - const progressBar = screen.getByTestId('billing-progress-bar') - expect(progressBar).toHaveClass('bg-components-progress-error-progress') + const errorBarClass = screen.getByTestId('billing-progress-bar').className + expect(errorBarClass).not.toBe(normalBarClass) + expect(errorBarClass).not.toBe(warningBarClass) }) it('does not render the icon when hideIcon is true', () => { @@ -173,8 +183,8 @@ describe('UsageInfo', () => { expect(screen.getAllByText('MB').length).toBeGreaterThanOrEqual(1) }) - it('should render full-width indeterminate bar for sandbox users below threshold', () => { - render( + it('should render different indeterminate bar widths for sandbox vs non-sandbox', () => { + const { rerender } = render( { />, ) - const bar = screen.getByTestId('billing-progress-bar-indeterminate') - expect(bar).toHaveClass('w-full') - }) + const sandboxBarClass = screen.getByTestId('billing-progress-bar-indeterminate').className - it('should render narrow indeterminate bar for non-sandbox users below threshold', () => { - render( + rerender( { />, ) - const bar = screen.getByTestId('billing-progress-bar-indeterminate') - expect(bar).toHaveClass('w-[30px]') + const nonSandboxBarClass = screen.getByTestId('billing-progress-bar-indeterminate').className + expect(sandboxBarClass).not.toBe(nonSandboxBarClass) }) }) describe('Sandbox Full Capacity', () => { - it('should render error color progress bar when sandbox usage >= threshold', () => { + it('should render determinate progress bar when sandbox usage >= threshold', () => { render( { />, ) - const progressBar = screen.getByTestId('billing-progress-bar') - expect(progressBar).toHaveClass('bg-components-progress-error-progress') + expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument() + expect(screen.queryByTestId('billing-progress-bar-indeterminate')).not.toBeInTheDocument() }) it('should display "threshold / threshold unit" format when sandbox is at full capacity', () => { @@ -305,9 +312,7 @@ describe('UsageInfo', () => { />, ) - // Tooltip wrapper should contain cursor-default class - const tooltipWrapper = container.querySelector('.cursor-default') - expect(tooltipWrapper).toBeInTheDocument() + expect(container.querySelector('[data-state]')).toBeInTheDocument() }) }) }) diff --git a/web/app/components/billing/usage-info/__tests__/vector-space-info.spec.tsx b/web/app/components/billing/usage-info/__tests__/vector-space-info.spec.tsx index 3da67f02af..041845ab3b 100644 --- a/web/app/components/billing/usage-info/__tests__/vector-space-info.spec.tsx +++ b/web/app/components/billing/usage-info/__tests__/vector-space-info.spec.tsx @@ -61,11 +61,10 @@ describe('VectorSpaceInfo', () => { expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument() }) - it('should render full-width indeterminate bar for sandbox users', () => { + it('should render indeterminate bar for sandbox users', () => { render() - const bar = screen.getByTestId('billing-progress-bar-indeterminate') - expect(bar).toHaveClass('w-full') + expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument() }) it('should display "< 50" format for sandbox below threshold', () => { @@ -81,11 +80,11 @@ describe('VectorSpaceInfo', () => { mockVectorSpaceUsage = 50 }) - it('should render error color progress bar when at full capacity', () => { + it('should render determinate progress bar when at full capacity', () => { render() - const progressBar = screen.getByTestId('billing-progress-bar') - expect(progressBar).toHaveClass('bg-components-progress-error-progress') + expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument() + expect(screen.queryByTestId('billing-progress-bar-indeterminate')).not.toBeInTheDocument() }) it('should display "50 / 50 MB" format when at full capacity', () => { @@ -108,19 +107,10 @@ describe('VectorSpaceInfo', () => { expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument() }) - it('should render narrow indeterminate bar (not full width)', () => { - render() - - const bar = screen.getByTestId('billing-progress-bar-indeterminate') - expect(bar).toHaveClass('w-[30px]') - expect(bar).not.toHaveClass('w-full') - }) - it('should display "< 50 / total" format when below threshold', () => { render() expect(screen.getByText(/< 50/)).toBeInTheDocument() - // 5 GB = 5120 MB expect(screen.getByText('5120MB')).toBeInTheDocument() }) }) @@ -158,14 +148,6 @@ describe('VectorSpaceInfo', () => { expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument() }) - it('should render narrow indeterminate bar (not full width)', () => { - render() - - const bar = screen.getByTestId('billing-progress-bar-indeterminate') - expect(bar).toHaveClass('w-[30px]') - expect(bar).not.toHaveClass('w-full') - }) - it('should display "< 50 / total" format when below threshold', () => { render() @@ -196,51 +178,24 @@ describe('VectorSpaceInfo', () => { }) }) - describe('Pro/Team Plan Warning State', () => { - it('should show warning color when Professional plan usage approaches limit (80%+)', () => { + describe('Pro/Team Plan Usage States', () => { + const renderAndGetBarClass = (usage: number) => { mockPlanType = Plan.professional - // 5120 MB * 80% = 4096 MB - mockVectorSpaceUsage = 4100 + mockVectorSpaceUsage = usage + const { unmount } = render() + const className = screen.getByTestId('billing-progress-bar').className + unmount() + return className + } - render() + it('should show distinct progress bar styling at different usage levels', () => { + const normalClass = renderAndGetBarClass(100) + const warningClass = renderAndGetBarClass(4100) + const errorClass = renderAndGetBarClass(5200) - const progressBar = screen.getByTestId('billing-progress-bar') - expect(progressBar).toHaveClass('bg-components-progress-warning-progress') - }) - - it('should show warning color when Team plan usage approaches limit (80%+)', () => { - mockPlanType = Plan.team - // 20480 MB * 80% = 16384 MB - mockVectorSpaceUsage = 16500 - - render() - - const progressBar = screen.getByTestId('billing-progress-bar') - expect(progressBar).toHaveClass('bg-components-progress-warning-progress') - }) - }) - - describe('Pro/Team Plan Error State', () => { - it('should show error color when Professional plan usage exceeds limit', () => { - mockPlanType = Plan.professional - // Exceeds 5120 MB - mockVectorSpaceUsage = 5200 - - render() - - const progressBar = screen.getByTestId('billing-progress-bar') - expect(progressBar).toHaveClass('bg-components-progress-error-progress') - }) - - it('should show error color when Team plan usage exceeds limit', () => { - mockPlanType = Plan.team - // Exceeds 20480 MB - mockVectorSpaceUsage = 21000 - - render() - - const progressBar = screen.getByTestId('billing-progress-bar') - expect(progressBar).toHaveClass('bg-components-progress-error-progress') + expect(normalClass).not.toBe(warningClass) + expect(warningClass).not.toBe(errorClass) + expect(normalClass).not.toBe(errorClass) }) }) @@ -265,12 +220,10 @@ describe('VectorSpaceInfo', () => { expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument() }) - it('should render narrow indeterminate bar (not full width) for enterprise', () => { + it('should render indeterminate bar for enterprise below threshold', () => { render() - const bar = screen.getByTestId('billing-progress-bar-indeterminate') - expect(bar).toHaveClass('w-[30px]') - expect(bar).not.toHaveClass('w-full') + expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument() }) it('should display "< 50 / total" format when below threshold', () => { diff --git a/web/app/components/custom/custom-page/index.spec.tsx b/web/app/components/custom/custom-page/__tests__/index.spec.tsx similarity index 98% rename from web/app/components/custom/custom-page/index.spec.tsx rename to web/app/components/custom/custom-page/__tests__/index.spec.tsx index e30fe67ea7..0da27e06a6 100644 --- a/web/app/components/custom/custom-page/index.spec.tsx +++ b/web/app/components/custom/custom-page/__tests__/index.spec.tsx @@ -6,11 +6,8 @@ import { createMockProviderContextValue } from '@/__mocks__/provider-context' import { contactSalesUrl } from '@/app/components/billing/config' import { Plan } from '@/app/components/billing/type' import { useModalContext } from '@/context/modal-context' -// Get the mocked functions -// const { useProviderContext } = vi.requireMock('@/context/provider-context') -// const { useModalContext } = vi.requireMock('@/context/modal-context') import { useProviderContext } from '@/context/provider-context' -import CustomPage from './index' +import CustomPage from '../index' // Mock external dependencies only vi.mock('@/context/provider-context', () => ({ @@ -23,7 +20,7 @@ vi.mock('@/context/modal-context', () => ({ // Mock the complex CustomWebAppBrand component to avoid dependency issues // This is acceptable because it has complex dependencies (fetch, APIs) -vi.mock('../custom-web-app-brand', () => ({ +vi.mock('@/app/components/custom/custom-web-app-brand', () => ({ default: () =>
CustomWebAppBrand
, })) diff --git a/web/app/components/custom/custom-web-app-brand/index.spec.tsx b/web/app/components/custom/custom-web-app-brand/__tests__/index.spec.tsx similarity index 88% rename from web/app/components/custom/custom-web-app-brand/index.spec.tsx rename to web/app/components/custom/custom-web-app-brand/__tests__/index.spec.tsx index e50ca4e9b2..2ceb45235c 100644 --- a/web/app/components/custom/custom-web-app-brand/index.spec.tsx +++ b/web/app/components/custom/custom-web-app-brand/__tests__/index.spec.tsx @@ -7,7 +7,7 @@ import { useAppContext } from '@/context/app-context' import { useGlobalPublicStore } from '@/context/global-public-context' import { useProviderContext } from '@/context/provider-context' import { updateCurrentWorkspace } from '@/service/common' -import CustomWebAppBrand from './index' +import CustomWebAppBrand from '../index' vi.mock('@/app/components/base/toast', () => ({ useToastContext: vi.fn(), @@ -53,8 +53,8 @@ const renderComponent = () => render() describe('CustomWebAppBrand', () => { beforeEach(() => { vi.clearAllMocks() - mockUseToastContext.mockReturnValue({ notify: mockNotify } as any) - mockUpdateCurrentWorkspace.mockResolvedValue({} as any) + mockUseToastContext.mockReturnValue({ notify: mockNotify } as unknown as ReturnType) + mockUpdateCurrentWorkspace.mockResolvedValue({} as unknown as Awaited>) mockUseAppContext.mockReturnValue({ currentWorkspace: { custom_config: { @@ -64,7 +64,7 @@ describe('CustomWebAppBrand', () => { }, mutateCurrentWorkspace: vi.fn(), isCurrentWorkspaceManager: true, - } as any) + } as unknown as ReturnType) mockUseProviderContext.mockReturnValue({ plan: { type: Plan.professional, @@ -73,14 +73,14 @@ describe('CustomWebAppBrand', () => { reset: {}, }, enableBilling: false, - } as any) + } as unknown as ReturnType) const systemFeaturesState = { branding: { enabled: true, workspace_logo: 'https://example.com/workspace-logo.png', }, } - mockUseGlobalPublicStore.mockImplementation(selector => selector ? selector({ systemFeatures: systemFeaturesState } as any) : { systemFeatures: systemFeaturesState }) + mockUseGlobalPublicStore.mockImplementation(selector => selector ? selector({ systemFeatures: systemFeaturesState, setSystemFeatures: vi.fn() } as unknown as ReturnType) : { systemFeatures: systemFeaturesState }) mockGetImageUploadErrorMessage.mockReturnValue('upload error') }) @@ -94,7 +94,7 @@ describe('CustomWebAppBrand', () => { }, mutateCurrentWorkspace: vi.fn(), isCurrentWorkspaceManager: false, - } as any) + } as unknown as ReturnType) const { container } = renderComponent() const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement @@ -112,7 +112,7 @@ describe('CustomWebAppBrand', () => { }, mutateCurrentWorkspace: mutateMock, isCurrentWorkspaceManager: true, - } as any) + } as unknown as ReturnType) renderComponent() const switchInput = screen.getByRole('switch') diff --git a/web/app/components/datasets/common/image-uploader/__tests__/constants.spec.ts b/web/app/components/datasets/common/image-uploader/__tests__/constants.spec.ts new file mode 100644 index 0000000000..925fa3af23 --- /dev/null +++ b/web/app/components/datasets/common/image-uploader/__tests__/constants.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { + ACCEPT_TYPES, + DEFAULT_IMAGE_FILE_BATCH_LIMIT, + DEFAULT_IMAGE_FILE_SIZE_LIMIT, + DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT, +} from '../constants' + +describe('image-uploader constants', () => { + // Verify accepted image types + describe('ACCEPT_TYPES', () => { + it('should include standard image formats', () => { + expect(ACCEPT_TYPES).toContain('jpg') + expect(ACCEPT_TYPES).toContain('jpeg') + expect(ACCEPT_TYPES).toContain('png') + expect(ACCEPT_TYPES).toContain('gif') + }) + + it('should have exactly 4 types', () => { + expect(ACCEPT_TYPES).toHaveLength(4) + }) + }) + + // Verify numeric limits are positive + describe('Limits', () => { + it('should have a positive file size limit', () => { + expect(DEFAULT_IMAGE_FILE_SIZE_LIMIT).toBeGreaterThan(0) + expect(DEFAULT_IMAGE_FILE_SIZE_LIMIT).toBe(2) + }) + + it('should have a positive batch limit', () => { + expect(DEFAULT_IMAGE_FILE_BATCH_LIMIT).toBeGreaterThan(0) + expect(DEFAULT_IMAGE_FILE_BATCH_LIMIT).toBe(5) + }) + + it('should have a positive single chunk attachment limit', () => { + expect(DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT).toBeGreaterThan(0) + expect(DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT).toBe(10) + }) + }) +}) diff --git a/web/app/components/datasets/create/__tests__/icons.spec.ts b/web/app/components/datasets/create/__tests__/icons.spec.ts new file mode 100644 index 0000000000..780c0bf4c0 --- /dev/null +++ b/web/app/components/datasets/create/__tests__/icons.spec.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { indexMethodIcon, retrievalIcon } from '../icons' + +describe('create/icons', () => { + // Verify icon map exports have expected keys + describe('indexMethodIcon', () => { + it('should have high_quality and economical keys', () => { + expect(indexMethodIcon).toHaveProperty('high_quality') + expect(indexMethodIcon).toHaveProperty('economical') + }) + + it('should have truthy values for each key', () => { + expect(indexMethodIcon.high_quality).toBeTruthy() + expect(indexMethodIcon.economical).toBeTruthy() + }) + }) + + describe('retrievalIcon', () => { + it('should have vector, fullText, and hybrid keys', () => { + expect(retrievalIcon).toHaveProperty('vector') + expect(retrievalIcon).toHaveProperty('fullText') + expect(retrievalIcon).toHaveProperty('hybrid') + }) + + it('should have truthy values for each key', () => { + expect(retrievalIcon.vector).toBeTruthy() + expect(retrievalIcon.fullText).toBeTruthy() + expect(retrievalIcon.hybrid).toBeTruthy() + }) + }) +}) diff --git a/web/app/components/datasets/create/file-uploader/__tests__/constants.spec.ts b/web/app/components/datasets/create/file-uploader/__tests__/constants.spec.ts new file mode 100644 index 0000000000..3659ecce79 --- /dev/null +++ b/web/app/components/datasets/create/file-uploader/__tests__/constants.spec.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { + PROGRESS_COMPLETE, + PROGRESS_ERROR, + PROGRESS_NOT_STARTED, +} from '../constants' + +describe('file-uploader constants', () => { + // Verify progress sentinel values + describe('Progress Sentinels', () => { + it('should define PROGRESS_NOT_STARTED as -1', () => { + expect(PROGRESS_NOT_STARTED).toBe(-1) + }) + + it('should define PROGRESS_ERROR as -2', () => { + expect(PROGRESS_ERROR).toBe(-2) + }) + + it('should define PROGRESS_COMPLETE as 100', () => { + expect(PROGRESS_COMPLETE).toBe(100) + }) + + it('should have distinct values for all sentinels', () => { + const values = [PROGRESS_NOT_STARTED, PROGRESS_ERROR, PROGRESS_COMPLETE] + expect(new Set(values).size).toBe(values.length) + }) + + it('should have negative values for non-progress states', () => { + expect(PROGRESS_NOT_STARTED).toBeLessThan(0) + expect(PROGRESS_ERROR).toBeLessThan(0) + }) + }) +}) diff --git a/web/app/components/datasets/documents/components/__tests__/list.spec.tsx b/web/app/components/datasets/documents/components/__tests__/list.spec.tsx new file mode 100644 index 0000000000..a96afe3cb4 --- /dev/null +++ b/web/app/components/datasets/documents/components/__tests__/list.spec.tsx @@ -0,0 +1,240 @@ +import type { SimpleDocumentDetail } from '@/models/datasets' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useDocumentSort } from '../document-list/hooks' +import DocumentList from '../list' + +// Mock hooks used by DocumentList +const mockHandleSort = vi.fn() +const mockOnSelectAll = vi.fn() +const mockOnSelectOne = vi.fn() +const mockClearSelection = vi.fn() +const mockHandleAction = vi.fn(() => vi.fn()) +const mockHandleBatchReIndex = vi.fn() +const mockHandleBatchDownload = vi.fn() +const mockShowEditModal = vi.fn() +const mockHideEditModal = vi.fn() +const mockHandleSave = vi.fn() + +vi.mock('../document-list/hooks', () => ({ + useDocumentSort: vi.fn(() => ({ + sortField: null, + sortOrder: null, + handleSort: mockHandleSort, + sortedDocuments: [], + })), + useDocumentSelection: vi.fn(() => ({ + isAllSelected: false, + isSomeSelected: false, + onSelectAll: mockOnSelectAll, + onSelectOne: mockOnSelectOne, + hasErrorDocumentsSelected: false, + downloadableSelectedIds: [], + clearSelection: mockClearSelection, + })), + useDocumentActions: vi.fn(() => ({ + handleAction: mockHandleAction, + handleBatchReIndex: mockHandleBatchReIndex, + handleBatchDownload: mockHandleBatchDownload, + })), +})) + +vi.mock('@/app/components/datasets/metadata/hooks/use-batch-edit-document-metadata', () => ({ + default: vi.fn(() => ({ + isShowEditModal: false, + showEditModal: mockShowEditModal, + hideEditModal: mockHideEditModal, + originalList: [], + handleSave: mockHandleSave, + })), +})) + +vi.mock('@/context/dataset-detail', () => ({ + useDatasetDetailContextWithSelector: () => ({ + doc_form: 'text_model', + }), +})) + +// Mock child components that are complex +vi.mock('../document-list/components', () => ({ + DocumentTableRow: ({ doc, index }: { doc: SimpleDocumentDetail, index: number }) => ( + + {index + 1} + {doc.name} + + ), + renderTdValue: (val: string) => val || '-', + SortHeader: ({ field, label, onSort }: { field: string, label: string, onSort: (f: string) => void }) => ( + + ), +})) + +vi.mock('../../detail/completed/common/batch-action', () => ({ + default: ({ selectedIds, onCancel }: { selectedIds: string[], onCancel: () => void }) => ( +
+ {selectedIds.length} + +
+ ), +})) + +vi.mock('../../rename-modal', () => ({ + default: ({ name, onClose }: { name: string, onClose: () => void }) => ( +
+ {name} + +
+ ), +})) + +vi.mock('@/app/components/datasets/metadata/edit-metadata-batch/modal', () => ({ + default: ({ onHide }: { onHide: () => void }) => ( +
+ +
+ ), +})) + +function createDoc(overrides: Partial = {}): SimpleDocumentDetail { + return { + id: `doc-${Math.random().toString(36).slice(2, 8)}`, + name: 'Test Doc', + position: 1, + data_source_type: 'upload_file', + word_count: 100, + hit_count: 5, + indexing_status: 'completed', + enabled: true, + disabled_at: null, + disabled_by: null, + archived: false, + display_status: 'available', + created_from: 'web', + created_at: 1234567890, + ...overrides, + } as SimpleDocumentDetail +} + +const defaultProps = { + embeddingAvailable: true, + documents: [] as SimpleDocumentDetail[], + selectedIds: [] as string[], + onSelectedIdChange: vi.fn(), + datasetId: 'ds-1', + pagination: { total: 0, current: 1, limit: 10, onChange: vi.fn() }, + onUpdate: vi.fn(), + onManageMetadata: vi.fn(), + statusFilterValue: 'all', + remoteSortValue: '', +} + +describe('DocumentList', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Verify the table renders with column headers + describe('Rendering', () => { + it('should render the document table with headers', () => { + render() + + expect(screen.getByText('#')).toBeInTheDocument() + expect(screen.getByTestId('sort-name')).toBeInTheDocument() + expect(screen.getByTestId('sort-word_count')).toBeInTheDocument() + expect(screen.getByTestId('sort-hit_count')).toBeInTheDocument() + expect(screen.getByTestId('sort-created_at')).toBeInTheDocument() + }) + + it('should render select-all area when embeddingAvailable is true', () => { + const { container } = render() + + // Checkbox component renders inside the first td + const firstTd = container.querySelector('thead td') + expect(firstTd?.textContent).toContain('#') + }) + + it('should still render # column when embeddingAvailable is false', () => { + const { container } = render() + + const firstTd = container.querySelector('thead td') + expect(firstTd?.textContent).toContain('#') + }) + + it('should render document rows from sortedDocuments', () => { + const docs = [createDoc({ id: 'a', name: 'Doc A' }), createDoc({ id: 'b', name: 'Doc B' })] + vi.mocked(useDocumentSort).mockReturnValue({ + sortField: null, + sortOrder: 'desc', + handleSort: mockHandleSort, + sortedDocuments: docs, + } as unknown as ReturnType) + + render() + + expect(screen.getByTestId('doc-row-a')).toBeInTheDocument() + expect(screen.getByTestId('doc-row-b')).toBeInTheDocument() + }) + }) + + // Verify sort headers trigger sort handler + describe('Sorting', () => { + it('should call handleSort when sort header is clicked', () => { + render() + + fireEvent.click(screen.getByTestId('sort-name')) + + expect(mockHandleSort).toHaveBeenCalledWith('name') + }) + }) + + // Verify batch action bar appears when items selected + describe('Batch Actions', () => { + it('should show batch action bar when selectedIds is non-empty', () => { + render() + + expect(screen.getByTestId('batch-action')).toBeInTheDocument() + expect(screen.getByTestId('selected-count')).toHaveTextContent('1') + }) + + it('should not show batch action bar when no items selected', () => { + render() + + expect(screen.queryByTestId('batch-action')).not.toBeInTheDocument() + }) + + it('should call clearSelection when cancel is clicked in batch bar', () => { + render() + + fireEvent.click(screen.getByTestId('cancel-selection')) + + expect(mockClearSelection).toHaveBeenCalled() + }) + }) + + // Verify pagination renders when total > 0 + describe('Pagination', () => { + it('should not render pagination when total is 0', () => { + const { container } = render() + + expect(container.querySelector('[class*="pagination"]')).not.toBeInTheDocument() + }) + }) + + // Verify empty state + describe('Edge Cases', () => { + it('should render table with no document rows when sortedDocuments is empty', () => { + // Reset sort mock to return empty sorted list + vi.mocked(useDocumentSort).mockReturnValue({ + sortField: null, + sortOrder: 'desc', + handleSort: mockHandleSort, + sortedDocuments: [], + } as unknown as ReturnType) + + render() + + expect(screen.queryByTestId(/^doc-row-/)).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/form.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/form.spec.tsx new file mode 100644 index 0000000000..25ac817284 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/form.spec.tsx @@ -0,0 +1,167 @@ +import type { BaseConfiguration } from '@/app/components/base/form/form-scenarios/base/types' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import Toast from '@/app/components/base/toast' + +import Form from '../form' + +// Mock the Header component (sibling component, not a base component) +vi.mock('../header', () => ({ + default: ({ onReset, resetDisabled, onPreview, previewDisabled }: { + onReset: () => void + resetDisabled: boolean + onPreview: () => void + previewDisabled: boolean + }) => ( +
+ + +
+ ), +})) + +const schema = z.object({ + name: z.string().min(1, 'Name is required'), + value: z.string().optional(), +}) + +const defaultConfigs: BaseConfiguration[] = [ + { variable: 'name', type: 'text-input', label: 'Name', required: true, showConditions: [] } as BaseConfiguration, + { variable: 'value', type: 'text-input', label: 'Value', required: false, showConditions: [] } as BaseConfiguration, +] + +const defaultProps = { + initialData: { name: 'test', value: '' }, + configurations: defaultConfigs, + schema, + onSubmit: vi.fn(), + onPreview: vi.fn(), + ref: { current: null }, + isRunning: false, +} + +describe('Form (process-documents)', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() })) + }) + + // Verify basic rendering of form structure + describe('Rendering', () => { + it('should render form with header and fields', () => { + render(
) + + expect(screen.getByTestId('form-header')).toBeInTheDocument() + expect(screen.getByText('Name')).toBeInTheDocument() + expect(screen.getByText('Value')).toBeInTheDocument() + }) + + it('should render all configuration fields', () => { + const configs: BaseConfiguration[] = [ + { variable: 'a', type: 'text-input', label: 'A', required: false, showConditions: [] } as BaseConfiguration, + { variable: 'b', type: 'text-input', label: 'B', required: false, showConditions: [] } as BaseConfiguration, + { variable: 'c', type: 'text-input', label: 'C', required: false, showConditions: [] } as BaseConfiguration, + ] + + render() + + expect(screen.getByText('A')).toBeInTheDocument() + expect(screen.getByText('B')).toBeInTheDocument() + expect(screen.getByText('C')).toBeInTheDocument() + }) + }) + + // Verify form submission behavior + describe('Form Submission', () => { + it('should call onSubmit with valid data on form submit', async () => { + render() + const form = screen.getByTestId('form-header').closest('form')! + + fireEvent.submit(form) + + await waitFor(() => { + expect(defaultProps.onSubmit).toHaveBeenCalled() + }) + }) + + it('should call onSubmit with valid data via imperative handle', async () => { + const ref = { current: null as { submit: () => void } | null } + render() + + ref.current?.submit() + + await waitFor(() => { + expect(defaultProps.onSubmit).toHaveBeenCalled() + }) + }) + }) + + // Verify validation shows Toast on error + describe('Validation', () => { + it('should show toast error when validation fails', async () => { + render() + const form = screen.getByTestId('form-header').closest('form')! + + fireEvent.submit(form) + + await waitFor(() => { + expect(Toast.notify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + }) + + it('should not show toast error when validation passes', async () => { + render() + const form = screen.getByTestId('form-header').closest('form')! + + fireEvent.submit(form) + + await waitFor(() => { + expect(defaultProps.onSubmit).toHaveBeenCalled() + }) + expect(Toast.notify).not.toHaveBeenCalled() + }) + }) + + // Verify header button states + describe('Header Controls', () => { + it('should pass isRunning to previewDisabled', () => { + render() + + expect(screen.getByTestId('preview-btn')).toBeDisabled() + }) + + it('should call onPreview when preview button is clicked', () => { + render() + + fireEvent.click(screen.getByTestId('preview-btn')) + + expect(defaultProps.onPreview).toHaveBeenCalled() + }) + + it('should render reset button (disabled when form is not dirty)', () => { + render() + + // Reset button is rendered but disabled since form is not dirty initially + expect(screen.getByTestId('reset-btn')).toBeInTheDocument() + expect(screen.getByTestId('reset-btn')).toBeDisabled() + }) + }) + + // Verify edge cases + describe('Edge Cases', () => { + it('should render with empty configurations array', () => { + render() + + expect(screen.getByTestId('form-header')).toBeInTheDocument() + }) + + it('should render with empty initialData', () => { + render() + + expect(screen.getByTestId('form-header')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/documents/detail/metadata/components/__tests__/doc-type-selector.spec.tsx b/web/app/components/datasets/documents/detail/metadata/components/__tests__/doc-type-selector.spec.tsx new file mode 100644 index 0000000000..55295579f0 --- /dev/null +++ b/web/app/components/datasets/documents/detail/metadata/components/__tests__/doc-type-selector.spec.tsx @@ -0,0 +1,147 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import DocTypeSelector, { DocumentTypeDisplay } from '../doc-type-selector' + +vi.mock('@/hooks/use-metadata', () => ({ + useMetadataMap: () => ({ + book: { text: 'Book', iconName: 'book' }, + web_page: { text: 'Web Page', iconName: 'web' }, + paper: { text: 'Paper', iconName: 'paper' }, + social_media_post: { text: 'Social Media Post', iconName: 'social' }, + personal_document: { text: 'Personal Document', iconName: 'personal' }, + business_document: { text: 'Business Document', iconName: 'business' }, + wikipedia_entry: { text: 'Wikipedia', iconName: 'wiki' }, + }), +})) + +vi.mock('@/models/datasets', async (importOriginal) => { + const actual = await importOriginal() as Record + return { + ...actual, + CUSTOMIZABLE_DOC_TYPES: ['book', 'web_page', 'paper'], + } +}) + +describe('DocTypeSelector', () => { + const defaultProps = { + docType: '' as '' | 'book', + documentType: undefined as '' | 'book' | undefined, + tempDocType: '' as '' | 'book' | 'web_page', + onTempDocTypeChange: vi.fn(), + onConfirm: vi.fn(), + onCancel: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + // Verify first-time setup UI (no existing doc type) + describe('First Time Selection', () => { + it('should render description and selection title when no doc type exists', () => { + render() + + expect(screen.getByText(/metadata\.desc/)).toBeInTheDocument() + expect(screen.getByText(/metadata\.docTypeSelectTitle/)).toBeInTheDocument() + }) + + it('should render icon buttons for each doc type', () => { + const { container } = render() + + // Each doc type renders an IconButton wrapped in Radio + const iconButtons = container.querySelectorAll('button[type="button"]') + // 3 doc types + 1 confirm button = 4 buttons + expect(iconButtons.length).toBeGreaterThanOrEqual(3) + }) + + it('should render confirm button disabled when tempDocType is empty', () => { + render() + + const confirmBtn = screen.getByText(/metadata\.firstMetaAction/) + expect(confirmBtn.closest('button')).toBeDisabled() + }) + + it('should render confirm button enabled when tempDocType is set', () => { + render() + + const confirmBtn = screen.getByText(/metadata\.firstMetaAction/) + expect(confirmBtn.closest('button')).not.toBeDisabled() + }) + + it('should call onConfirm when confirm button is clicked', () => { + render() + + fireEvent.click(screen.getByText(/metadata\.firstMetaAction/)) + + expect(defaultProps.onConfirm).toHaveBeenCalled() + }) + }) + + // Verify change-type UI (has existing doc type) + describe('Change Doc Type', () => { + it('should render change title and warning when documentType exists', () => { + render() + + expect(screen.getByText(/metadata\.docTypeChangeTitle/)).toBeInTheDocument() + expect(screen.getByText(/metadata\.docTypeSelectWarning/)).toBeInTheDocument() + }) + + it('should render save and cancel buttons when documentType exists', () => { + render() + + expect(screen.getByText(/operation\.save/)).toBeInTheDocument() + expect(screen.getByText(/operation\.cancel/)).toBeInTheDocument() + }) + + it('should call onCancel when cancel button is clicked', () => { + render() + + fireEvent.click(screen.getByText(/operation\.cancel/)) + + expect(defaultProps.onCancel).toHaveBeenCalled() + }) + }) +}) + +describe('DocumentTypeDisplay', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Verify read-only display of current doc type + describe('Rendering', () => { + it('should render the doc type text', () => { + render() + + expect(screen.getByText('Book')).toBeInTheDocument() + }) + + it('should show change link when showChangeLink is true', () => { + render() + + expect(screen.getByText(/operation\.change/)).toBeInTheDocument() + }) + + it('should not show change link when showChangeLink is false', () => { + render() + + expect(screen.queryByText(/operation\.change/)).not.toBeInTheDocument() + }) + + it('should call onChangeClick when change link is clicked', () => { + const onClick = vi.fn() + render() + + fireEvent.click(screen.getByText(/operation\.change/)) + + expect(onClick).toHaveBeenCalled() + }) + + it('should fallback to "book" display when displayType is empty and no change link', () => { + render() + + expect(screen.getByText('Book')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/documents/detail/metadata/components/__tests__/field-info.spec.tsx b/web/app/components/datasets/documents/detail/metadata/components/__tests__/field-info.spec.tsx new file mode 100644 index 0000000000..8a826ada39 --- /dev/null +++ b/web/app/components/datasets/documents/detail/metadata/components/__tests__/field-info.spec.tsx @@ -0,0 +1,116 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import FieldInfo from '../field-info' + +vi.mock('@/utils', () => ({ + getTextWidthWithCanvas: (text: string) => text.length * 8, +})) + +describe('FieldInfo', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Verify read-only rendering + describe('Read-Only Mode', () => { + it('should render label and displayed value', () => { + render() + + expect(screen.getByText('Title')).toBeInTheDocument() + expect(screen.getByText('My Document')).toBeInTheDocument() + }) + + it('should render value icon when provided', () => { + render( + *} + />, + ) + + expect(screen.getByTestId('icon')).toBeInTheDocument() + }) + + it('should render displayedValue as plain text when not editing', () => { + render() + + expect(screen.getByText('John')).toBeInTheDocument() + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + }) + }) + + // Verify edit mode rendering for each inputType + describe('Edit Mode', () => { + it('should render input field by default in edit mode', () => { + render() + + const input = screen.getByRole('textbox') + expect(input).toBeInTheDocument() + expect(input).toHaveValue('Test') + }) + + it('should render textarea when inputType is textarea', () => { + render() + + const textarea = screen.getByRole('textbox') + expect(textarea).toBeInTheDocument() + expect(textarea).toHaveValue('Long text') + }) + + it('should render select when inputType is select', () => { + const options = [ + { value: 'en', name: 'English' }, + { value: 'zh', name: 'Chinese' }, + ] + render( + , + ) + + // SimpleSelect renders a button-like trigger + expect(screen.getByText('English')).toBeInTheDocument() + }) + + it('should call onUpdate when input value changes', () => { + const onUpdate = vi.fn() + render() + + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'New' } }) + + expect(onUpdate).toHaveBeenCalledWith('New') + }) + + it('should call onUpdate when textarea value changes', () => { + const onUpdate = vi.fn() + render() + + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Updated' } }) + + expect(onUpdate).toHaveBeenCalledWith('Updated') + }) + }) + + // Verify edge cases + describe('Edge Cases', () => { + it('should render with empty value and label', () => { + render() + + // Should not crash + const container = document.querySelector('.flex.min-h-5') + expect(container).toBeInTheDocument() + }) + + it('should render with default value prop', () => { + render() + + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/documents/detail/metadata/components/__tests__/metadata-field-list.spec.tsx b/web/app/components/datasets/documents/detail/metadata/components/__tests__/metadata-field-list.spec.tsx new file mode 100644 index 0000000000..cc5b16fc3e --- /dev/null +++ b/web/app/components/datasets/documents/detail/metadata/components/__tests__/metadata-field-list.spec.tsx @@ -0,0 +1,149 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import MetadataFieldList from '../metadata-field-list' + +vi.mock('@/hooks/use-metadata', () => ({ + useMetadataMap: () => ({ + book: { + text: 'Book', + subFieldsMap: { + title: { label: 'Title', inputType: 'input' }, + language: { label: 'Language', inputType: 'select' }, + author: { label: 'Author', inputType: 'input' }, + }, + }, + originInfo: { + text: 'Origin Info', + subFieldsMap: { + source: { label: 'Source', inputType: 'input' }, + hit_count: { label: 'Hit Count', inputType: 'input', render: (val: number, segCount?: number) => `${val} / ${segCount}` }, + }, + }, + }), + useLanguages: () => ({ en: 'English', zh: 'Chinese' }), + useBookCategories: () => ({ fiction: 'Fiction', nonfiction: 'Non-fiction' }), + usePersonalDocCategories: () => ({}), + useBusinessDocCategories: () => ({}), +})) + +describe('MetadataFieldList', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Verify rendering of metadata fields based on mainField + describe('Rendering', () => { + it('should render all fields for the given mainField', () => { + render( + , + ) + + expect(screen.getByText('Title')).toBeInTheDocument() + expect(screen.getByText('Language')).toBeInTheDocument() + expect(screen.getByText('Author')).toBeInTheDocument() + }) + + it('should return null when mainField is empty', () => { + const { container } = render( + , + ) + + expect(container.firstChild).toBeNull() + }) + + it('should display "-" for missing field values', () => { + render( + , + ) + + // All three fields should show "-" + const dashes = screen.getAllByText('-') + expect(dashes.length).toBeGreaterThanOrEqual(3) + }) + + it('should resolve select values to their display name', () => { + render( + , + ) + + expect(screen.getByText('English')).toBeInTheDocument() + }) + }) + + // Verify edit mode passes correct props + describe('Edit Mode', () => { + it('should render fields in edit mode when canEdit is true', () => { + render( + , + ) + + // In edit mode, FieldInfo renders input elements + const inputs = screen.getAllByRole('textbox') + expect(inputs.length).toBeGreaterThan(0) + }) + + it('should call onFieldUpdate when a field value changes', () => { + const onUpdate = vi.fn() + render( + , + ) + + // Find the first textbox and type in it + const inputs = screen.getAllByRole('textbox') + fireEvent.change(inputs[0], { target: { value: 'New Title' } }) + + expect(onUpdate).toHaveBeenCalled() + }) + }) + + // Verify fixed field types use docDetail as source + describe('Fixed Field Types', () => { + it('should use docDetail as source data for originInfo type', () => { + const docDetail = { source: 'Web', hit_count: 42, segment_count: 10 } + + render( + , + ) + + expect(screen.getByText('Source')).toBeInTheDocument() + expect(screen.getByText('Web')).toBeInTheDocument() + }) + + it('should render custom render function output for fields with render', () => { + const docDetail = { source: 'API', hit_count: 15, segment_count: 5 } + + render( + , + ) + + expect(screen.getByText('15 / 5')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/documents/detail/metadata/hooks/__tests__/use-metadata-state.spec.ts b/web/app/components/datasets/documents/detail/metadata/hooks/__tests__/use-metadata-state.spec.ts new file mode 100644 index 0000000000..ab1d45338f --- /dev/null +++ b/web/app/components/datasets/documents/detail/metadata/hooks/__tests__/use-metadata-state.spec.ts @@ -0,0 +1,164 @@ +import type { ReactNode } from 'react' +import type { FullDocumentDetail } from '@/models/datasets' +import { act, renderHook } from '@testing-library/react' +import * as React from 'react' +import { describe, expect, it, vi } from 'vitest' +import { ToastContext } from '@/app/components/base/toast' + +import { useMetadataState } from '../use-metadata-state' + +const { mockNotify, mockModifyDocMetadata } = vi.hoisted(() => ({ + mockNotify: vi.fn(), + mockModifyDocMetadata: vi.fn(), +})) + +vi.mock('../../../context', () => ({ + useDocumentContext: (selector: (state: { datasetId: string, documentId: string }) => unknown) => + selector({ datasetId: 'ds-1', documentId: 'doc-1' }), +})) + +vi.mock('@/service/datasets', () => ({ + modifyDocMetadata: (...args: unknown[]) => mockModifyDocMetadata(...args), +})) + +vi.mock('@/hooks/use-metadata', () => ({ useMetadataMap: () => ({}) })) + +vi.mock('@/utils', () => ({ + asyncRunSafe: async (promise: Promise) => { + try { + return [null, await promise] + } + catch (e) { return [e] } + }, +})) + +// Wrapper that provides ToastContext with the mock notify function +const wrapper = ({ children }: { children: ReactNode }) => + React.createElement(ToastContext.Provider, { value: { notify: mockNotify, close: vi.fn() }, children }) + +type DocDetail = Parameters[0]['docDetail'] + +const makeDoc = (overrides: Partial = {}): DocDetail => + ({ doc_type: 'book', doc_metadata: { title: 'Test Book', author: 'Author' }, ...overrides } as DocDetail) + +describe('useMetadataState', () => { + // Verify all metadata editing workflows using a stable docDetail reference + it('should manage the full metadata editing lifecycle', async () => { + mockModifyDocMetadata.mockResolvedValue({ result: 'ok' }) + const onUpdate = vi.fn() + + // IMPORTANT: Create a stable reference outside the render callback + // to prevent useEffect infinite loops on docDetail?.doc_metadata + const stableDocDetail = makeDoc() + + const { result } = renderHook(() => + useMetadataState({ docDetail: stableDocDetail, onUpdate }), { wrapper }) + + // --- Initialization --- + expect(result.current.docType).toBe('book') + expect(result.current.editStatus).toBe(false) + expect(result.current.showDocTypes).toBe(false) + expect(result.current.metadataParams.documentType).toBe('book') + expect(result.current.metadataParams.metadata).toEqual({ title: 'Test Book', author: 'Author' }) + + // --- Enable editing --- + act(() => { + result.current.enableEdit() + }) + expect(result.current.editStatus).toBe(true) + + // --- Update individual field --- + act(() => { + result.current.updateMetadataField('title', 'Modified Title') + }) + expect(result.current.metadataParams.metadata.title).toBe('Modified Title') + expect(result.current.metadataParams.metadata.author).toBe('Author') + + // --- Cancel edit restores original data --- + act(() => { + result.current.cancelEdit() + }) + expect(result.current.metadataParams.metadata.title).toBe('Test Book') + expect(result.current.editStatus).toBe(false) + + // --- Doc type selection: cancel restores previous --- + act(() => { + result.current.enableEdit() + }) + act(() => { + result.current.setShowDocTypes(true) + }) + act(() => { + result.current.setTempDocType('web_page') + }) + act(() => { + result.current.cancelDocType() + }) + expect(result.current.tempDocType).toBe('book') + expect(result.current.showDocTypes).toBe(false) + + // --- Confirm different doc type clears metadata --- + act(() => { + result.current.setShowDocTypes(true) + }) + act(() => { + result.current.setTempDocType('web_page') + }) + act(() => { + result.current.confirmDocType() + }) + expect(result.current.metadataParams.documentType).toBe('web_page') + expect(result.current.metadataParams.metadata).toEqual({}) + + // --- Save succeeds --- + await act(async () => { + await result.current.saveMetadata() + }) + expect(mockModifyDocMetadata).toHaveBeenCalledWith({ + datasetId: 'ds-1', + documentId: 'doc-1', + body: { doc_type: 'web_page', doc_metadata: {} }, + }) + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' })) + expect(onUpdate).toHaveBeenCalled() + expect(result.current.editStatus).toBe(false) + expect(result.current.saveLoading).toBe(false) + + // --- Save failure notifies error --- + mockNotify.mockClear() + mockModifyDocMetadata.mockRejectedValue(new Error('fail')) + act(() => { + result.current.enableEdit() + }) + await act(async () => { + await result.current.saveMetadata() + }) + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + }) + + // Verify empty doc type starts in editing mode + it('should initialize in editing mode when no doc type exists', () => { + const stableDocDetail = makeDoc({ doc_type: '' as FullDocumentDetail['doc_type'], doc_metadata: {} as FullDocumentDetail['doc_metadata'] }) + const { result } = renderHook(() => useMetadataState({ docDetail: stableDocDetail }), { wrapper }) + + expect(result.current.docType).toBe('') + expect(result.current.editStatus).toBe(true) + expect(result.current.showDocTypes).toBe(true) + }) + + // Verify "others" normalization + it('should normalize "others" doc_type to empty string', () => { + const stableDocDetail = makeDoc({ doc_type: 'others' as FullDocumentDetail['doc_type'] }) + const { result } = renderHook(() => useMetadataState({ docDetail: stableDocDetail }), { wrapper }) + + expect(result.current.docType).toBe('') + }) + + // Verify undefined docDetail handling + it('should handle undefined docDetail gracefully', () => { + const { result } = renderHook(() => useMetadataState({ docDetail: undefined }), { wrapper }) + + expect(result.current.docType).toBe('') + expect(result.current.editStatus).toBe(true) + }) +}) diff --git a/web/app/components/datasets/hit-testing/components/query-input/__tests__/index.spec.tsx b/web/app/components/datasets/hit-testing/components/query-input/__tests__/index.spec.tsx index b00e430575..4ed09de462 100644 --- a/web/app/components/datasets/hit-testing/components/query-input/__tests__/index.spec.tsx +++ b/web/app/components/datasets/hit-testing/components/query-input/__tests__/index.spec.tsx @@ -1,40 +1,49 @@ +import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types' import type { Query } from '@/models/datasets' import type { RetrievalConfig } from '@/types/app' -import { render, screen } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import QueryInput from '../index' -vi.mock('uuid', () => ({ - v4: () => 'mock-uuid', -})) - -vi.mock('@/app/components/base/button', () => ({ - default: ({ children, onClick, disabled, loading }: { children: React.ReactNode, onClick: () => void, disabled?: boolean, loading?: boolean }) => ( - - ), -})) - +// Capture onChange callback so tests can trigger handleImageChange +let capturedOnChange: ((files: FileEntity[]) => void) | null = null vi.mock('@/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing', () => ({ - default: ({ textArea, actionButton }: { textArea: React.ReactNode, actionButton: React.ReactNode }) => ( -
- {textArea} - {actionButton} -
- ), + default: ({ textArea, actionButton, onChange }: { textArea: React.ReactNode, actionButton: React.ReactNode, onChange?: (files: FileEntity[]) => void }) => { + capturedOnChange = onChange ?? null + return ( +
+ {textArea} + {actionButton} +
+ ) + }, })) vi.mock('@/app/components/datasets/common/retrieval-method-info', () => ({ getIcon: () => '/test-icon.png', })) +// Capture onSave callback for external retrieval modal +let _capturedModalOnSave: ((data: { top_k: number, score_threshold: number, score_threshold_enabled: boolean }) => void) | null = null vi.mock('@/app/components/datasets/hit-testing/modify-external-retrieval-modal', () => ({ - default: () =>
, + default: ({ onSave, onClose }: { onSave: (data: { top_k: number, score_threshold: number, score_threshold_enabled: boolean }) => void, onClose: () => void }) => { + _capturedModalOnSave = onSave + return ( +
+ + +
+ ) + }, })) +// Capture handleTextChange callback +let _capturedHandleTextChange: ((e: React.ChangeEvent) => void) | null = null vi.mock('../textarea', () => ({ - default: ({ text }: { text: string }) =>