mirror of https://github.com/langgenius/dify.git
chore: add some jest tests (#29800)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
c474177a16
commit
9812dc2cb2
|
|
@ -2,12 +2,6 @@ import React from 'react'
|
|||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import EditItem, { EditItemType } from './index'
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('AddAnnotationModal/EditItem', () => {
|
||||
test('should render query inputs with user avatar and placeholder strings', () => {
|
||||
render(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,388 @@
|
|||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import AccessControl from './index'
|
||||
import AccessControlDialog from './access-control-dialog'
|
||||
import AccessControlItem from './access-control-item'
|
||||
import AddMemberOrGroupDialog from './add-member-or-group-pop'
|
||||
import SpecificGroupsOrMembers from './specific-groups-or-members'
|
||||
import useAccessControlStore from '@/context/access-control-store'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control'
|
||||
import { AccessMode, SubjectType } from '@/models/access-control'
|
||||
import Toast from '../../base/toast'
|
||||
import { defaultSystemFeatures } from '@/types/feature'
|
||||
import type { App } from '@/types/app'
|
||||
|
||||
const mockUseAppWhiteListSubjects = jest.fn()
|
||||
const mockUseSearchForWhiteListCandidates = jest.fn()
|
||||
const mockMutateAsync = jest.fn()
|
||||
const mockUseUpdateAccessMode = jest.fn(() => ({
|
||||
isPending: false,
|
||||
mutateAsync: mockMutateAsync,
|
||||
}))
|
||||
|
||||
jest.mock('@/context/app-context', () => ({
|
||||
useSelector: <T,>(selector: (value: { userProfile: { email: string; id?: string; name?: string; avatar?: string; avatar_url?: string; is_password_set?: boolean } }) => T) => selector({
|
||||
userProfile: {
|
||||
id: 'current-user',
|
||||
name: 'Current User',
|
||||
email: 'member@example.com',
|
||||
avatar: '',
|
||||
avatar_url: '',
|
||||
is_password_set: true,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
jest.mock('@/service/common', () => ({
|
||||
fetchCurrentWorkspace: jest.fn(),
|
||||
fetchLangGeniusVersion: jest.fn(),
|
||||
fetchUserProfile: jest.fn(),
|
||||
getSystemFeatures: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/service/access-control', () => ({
|
||||
useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args),
|
||||
useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args),
|
||||
useUpdateAccessMode: () => mockUseUpdateAccessMode(),
|
||||
}))
|
||||
|
||||
jest.mock('@headlessui/react', () => {
|
||||
const DialogComponent: any = ({ children, className, ...rest }: any) => (
|
||||
<div role="dialog" className={className} {...rest}>{children}</div>
|
||||
)
|
||||
DialogComponent.Panel = ({ children, className, ...rest }: any) => (
|
||||
<div className={className} {...rest}>{children}</div>
|
||||
)
|
||||
const DialogTitle = ({ children, className, ...rest }: any) => (
|
||||
<div className={className} {...rest}>{children}</div>
|
||||
)
|
||||
const DialogDescription = ({ children, className, ...rest }: any) => (
|
||||
<div className={className} {...rest}>{children}</div>
|
||||
)
|
||||
const TransitionChild = ({ children }: any) => (
|
||||
<>{typeof children === 'function' ? children({}) : children}</>
|
||||
)
|
||||
const Transition = ({ show = true, children }: any) => (
|
||||
show ? <>{typeof children === 'function' ? children({}) : children}</> : null
|
||||
)
|
||||
Transition.Child = TransitionChild
|
||||
return {
|
||||
Dialog: DialogComponent,
|
||||
Transition,
|
||||
DialogTitle,
|
||||
Description: DialogDescription,
|
||||
}
|
||||
})
|
||||
|
||||
jest.mock('ahooks', () => {
|
||||
const actual = jest.requireActual('ahooks')
|
||||
return {
|
||||
...actual,
|
||||
useDebounce: (value: unknown) => value,
|
||||
}
|
||||
})
|
||||
|
||||
const createGroup = (overrides: Partial<AccessControlGroup> = {}): AccessControlGroup => ({
|
||||
id: 'group-1',
|
||||
name: 'Group One',
|
||||
groupSize: 5,
|
||||
...overrides,
|
||||
} as AccessControlGroup)
|
||||
|
||||
const createMember = (overrides: Partial<AccessControlAccount> = {}): AccessControlAccount => ({
|
||||
id: 'member-1',
|
||||
name: 'Member One',
|
||||
email: 'member@example.com',
|
||||
avatar: '',
|
||||
avatarUrl: '',
|
||||
...overrides,
|
||||
} as AccessControlAccount)
|
||||
|
||||
const baseGroup = createGroup()
|
||||
const baseMember = createMember()
|
||||
const groupSubject: Subject = {
|
||||
subjectId: baseGroup.id,
|
||||
subjectType: SubjectType.GROUP,
|
||||
groupData: baseGroup,
|
||||
} as Subject
|
||||
const memberSubject: Subject = {
|
||||
subjectId: baseMember.id,
|
||||
subjectType: SubjectType.ACCOUNT,
|
||||
accountData: baseMember,
|
||||
} as Subject
|
||||
|
||||
const resetAccessControlStore = () => {
|
||||
useAccessControlStore.setState({
|
||||
appId: '',
|
||||
specificGroups: [],
|
||||
specificMembers: [],
|
||||
currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS,
|
||||
selectedGroupsForBreadcrumb: [],
|
||||
})
|
||||
}
|
||||
|
||||
const resetGlobalStore = () => {
|
||||
useGlobalPublicStore.setState({
|
||||
systemFeatures: defaultSystemFeatures,
|
||||
isGlobalPending: false,
|
||||
})
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
class MockIntersectionObserver {
|
||||
observe = jest.fn(() => undefined)
|
||||
disconnect = jest.fn(() => undefined)
|
||||
unobserve = jest.fn(() => undefined)
|
||||
}
|
||||
// @ts-expect-error jsdom does not implement IntersectionObserver
|
||||
globalThis.IntersectionObserver = MockIntersectionObserver
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
resetAccessControlStore()
|
||||
resetGlobalStore()
|
||||
mockMutateAsync.mockResolvedValue(undefined)
|
||||
mockUseUpdateAccessMode.mockReturnValue({
|
||||
isPending: false,
|
||||
mutateAsync: mockMutateAsync,
|
||||
})
|
||||
mockUseAppWhiteListSubjects.mockReturnValue({
|
||||
isPending: false,
|
||||
data: {
|
||||
groups: [baseGroup],
|
||||
members: [baseMember],
|
||||
},
|
||||
})
|
||||
mockUseSearchForWhiteListCandidates.mockReturnValue({
|
||||
isLoading: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: jest.fn(),
|
||||
data: { pages: [{ currPage: 1, subjects: [groupSubject, memberSubject], hasMore: false }] },
|
||||
})
|
||||
})
|
||||
|
||||
// AccessControlItem handles selected vs. unselected styling and click state updates
|
||||
describe('AccessControlItem', () => {
|
||||
it('should update current menu when selecting a different access type', () => {
|
||||
useAccessControlStore.setState({ currentMenu: AccessMode.PUBLIC })
|
||||
render(
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||
<span>Organization Only</span>
|
||||
</AccessControlItem>,
|
||||
)
|
||||
|
||||
const option = screen.getByText('Organization Only').parentElement as HTMLElement
|
||||
expect(option).toHaveClass('cursor-pointer')
|
||||
|
||||
fireEvent.click(option)
|
||||
|
||||
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.ORGANIZATION)
|
||||
})
|
||||
|
||||
it('should render selected styles when the current menu matches the type', () => {
|
||||
useAccessControlStore.setState({ currentMenu: AccessMode.ORGANIZATION })
|
||||
render(
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||
<span>Organization Only</span>
|
||||
</AccessControlItem>,
|
||||
)
|
||||
|
||||
const option = screen.getByText('Organization Only').parentElement as HTMLElement
|
||||
expect(option.className).toContain('border-[1.5px]')
|
||||
expect(option.className).not.toContain('cursor-pointer')
|
||||
})
|
||||
})
|
||||
|
||||
// AccessControlDialog renders a headless UI dialog with a manual close control
|
||||
describe('AccessControlDialog', () => {
|
||||
it('should render dialog content when visible', () => {
|
||||
render(
|
||||
<AccessControlDialog show className="custom-dialog">
|
||||
<div>Dialog Content</div>
|
||||
</AccessControlDialog>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
expect(screen.getByText('Dialog Content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should trigger onClose when clicking the close control', async () => {
|
||||
const handleClose = jest.fn()
|
||||
const { container } = render(
|
||||
<AccessControlDialog show onClose={handleClose}>
|
||||
<div>Dialog Content</div>
|
||||
</AccessControlDialog>,
|
||||
)
|
||||
|
||||
const closeButton = container.querySelector('.absolute.right-5.top-5') as HTMLElement
|
||||
fireEvent.click(closeButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(handleClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// SpecificGroupsOrMembers syncs store state with fetched data and supports removals
|
||||
describe('SpecificGroupsOrMembers', () => {
|
||||
it('should render collapsed view when not in specific selection mode', () => {
|
||||
useAccessControlStore.setState({ currentMenu: AccessMode.ORGANIZATION })
|
||||
|
||||
render(<SpecificGroupsOrMembers />)
|
||||
|
||||
expect(screen.getByText('app.accessControlDialog.accessItems.specific')).toBeInTheDocument()
|
||||
expect(screen.queryByText(baseGroup.name)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show loading state while pending', async () => {
|
||||
useAccessControlStore.setState({ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS })
|
||||
mockUseAppWhiteListSubjects.mockReturnValue({
|
||||
isPending: true,
|
||||
data: undefined,
|
||||
})
|
||||
|
||||
const { container } = render(<SpecificGroupsOrMembers />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('.spin-animation')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render fetched groups and members and support removal', async () => {
|
||||
useAccessControlStore.setState({ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS })
|
||||
|
||||
render(<SpecificGroupsOrMembers />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(baseGroup.name)).toBeInTheDocument()
|
||||
expect(screen.getByText(baseMember.name)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const groupItem = screen.getByText(baseGroup.name).closest('div')
|
||||
const groupRemove = groupItem?.querySelector('.h-4.w-4.cursor-pointer') as HTMLElement
|
||||
fireEvent.click(groupRemove)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(baseGroup.name)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
const memberItem = screen.getByText(baseMember.name).closest('div')
|
||||
const memberRemove = memberItem?.querySelector('.h-4.w-4.cursor-pointer') as HTMLElement
|
||||
fireEvent.click(memberRemove)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(baseMember.name)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// AddMemberOrGroupDialog renders search results and updates store selections
|
||||
describe('AddMemberOrGroupDialog', () => {
|
||||
it('should open search popover and display candidates', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<AddMemberOrGroupDialog />)
|
||||
|
||||
await user.click(screen.getByText('common.operation.add'))
|
||||
|
||||
expect(screen.getByPlaceholderText('app.accessControlDialog.operateGroupAndMember.searchPlaceholder')).toBeInTheDocument()
|
||||
expect(screen.getByText(baseGroup.name)).toBeInTheDocument()
|
||||
expect(screen.getByText(baseMember.name)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should allow selecting members and expanding groups', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<AddMemberOrGroupDialog />)
|
||||
|
||||
await user.click(screen.getByText('common.operation.add'))
|
||||
|
||||
const expandButton = screen.getByText('app.accessControlDialog.operateGroupAndMember.expand')
|
||||
await user.click(expandButton)
|
||||
expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([baseGroup])
|
||||
|
||||
const memberLabel = screen.getByText(baseMember.name)
|
||||
const memberCheckbox = memberLabel.parentElement?.previousElementSibling as HTMLElement
|
||||
fireEvent.click(memberCheckbox)
|
||||
|
||||
expect(useAccessControlStore.getState().specificMembers).toEqual([baseMember])
|
||||
})
|
||||
|
||||
it('should show empty state when no candidates are returned', async () => {
|
||||
mockUseSearchForWhiteListCandidates.mockReturnValue({
|
||||
isLoading: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: jest.fn(),
|
||||
data: { pages: [] },
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
render(<AddMemberOrGroupDialog />)
|
||||
|
||||
await user.click(screen.getByText('common.operation.add'))
|
||||
|
||||
expect(screen.getByText('app.accessControlDialog.operateGroupAndMember.noResult')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// AccessControl integrates dialog, selection items, and confirm flow
|
||||
describe('AccessControl', () => {
|
||||
it('should initialize menu from app and call update on confirm', async () => {
|
||||
const onClose = jest.fn()
|
||||
const onConfirm = jest.fn()
|
||||
const toastSpy = jest.spyOn(Toast, 'notify').mockReturnValue({})
|
||||
useAccessControlStore.setState({
|
||||
specificGroups: [baseGroup],
|
||||
specificMembers: [baseMember],
|
||||
})
|
||||
const app = {
|
||||
id: 'app-id-1',
|
||||
access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
|
||||
} as App
|
||||
|
||||
render(
|
||||
<AccessControl
|
||||
app={app}
|
||||
onClose={onClose}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.SPECIFIC_GROUPS_MEMBERS)
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('common.operation.confirm'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({
|
||||
appId: app.id,
|
||||
accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
|
||||
subjects: [
|
||||
{ subjectId: baseGroup.id, subjectType: SubjectType.GROUP },
|
||||
{ subjectId: baseMember.id, subjectType: SubjectType.ACCOUNT },
|
||||
],
|
||||
})
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
expect(onConfirm).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should expose the external members tip when SSO is disabled', () => {
|
||||
const app = {
|
||||
id: 'app-id-2',
|
||||
access_mode: AccessMode.PUBLIC,
|
||||
} as App
|
||||
|
||||
render(
|
||||
<AccessControl
|
||||
app={app}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('app.accessControlDialog.accessItems.external')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.accessControlDialog.accessItems.anyone')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
@ -32,7 +32,7 @@ export default function AddMemberOrGroupDialog() {
|
|||
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
const hasMore = data?.pages?.[0].hasMore ?? false
|
||||
const hasMore = data?.pages?.[0]?.hasMore ?? false
|
||||
let observer: IntersectionObserver | undefined
|
||||
if (anchorRef.current) {
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
|
|
|
|||
|
|
@ -4,12 +4,6 @@ import AgentSetting from './index'
|
|||
import { MAX_ITERATIONS_NUM } from '@/config'
|
||||
import type { AgentConfig } from '@/models/debug'
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
jest.mock('ahooks', () => {
|
||||
const actual = jest.requireActual('ahooks')
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,392 @@
|
|||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ConfigContent from './config-content'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { ChunkingMode, DataSourceType, DatasetPermission, RerankingModeEnum, WeightedScoreEnum } from '@/models/datasets'
|
||||
import type { DatasetConfigs } from '@/models/debug'
|
||||
import { RETRIEVE_METHOD, RETRIEVE_TYPE } from '@/types/app'
|
||||
import type { RetrievalConfig } from '@/types/app'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import type { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import {
|
||||
useCurrentProviderAndModel,
|
||||
useModelListAndDefaultModelAndCurrentProviderAndModel,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
|
||||
jest.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => {
|
||||
type Props = {
|
||||
defaultModel?: { provider: string; model: string }
|
||||
onSelect?: (model: { provider: string; model: string }) => void
|
||||
}
|
||||
|
||||
const MockModelSelector = ({ defaultModel, onSelect }: Props) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect?.(defaultModel ?? { provider: 'mock-provider', model: 'mock-model' })}
|
||||
>
|
||||
Mock ModelSelector
|
||||
</button>
|
||||
)
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
default: MockModelSelector,
|
||||
}
|
||||
})
|
||||
|
||||
jest.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="model-parameter-modal" />,
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/base/toast', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
notify: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useModelListAndDefaultModelAndCurrentProviderAndModel: jest.fn(),
|
||||
useCurrentProviderAndModel: jest.fn(),
|
||||
}))
|
||||
|
||||
const mockedUseModelListAndDefaultModelAndCurrentProviderAndModel = useModelListAndDefaultModelAndCurrentProviderAndModel as jest.MockedFunction<typeof useModelListAndDefaultModelAndCurrentProviderAndModel>
|
||||
const mockedUseCurrentProviderAndModel = useCurrentProviderAndModel as jest.MockedFunction<typeof useCurrentProviderAndModel>
|
||||
|
||||
const mockToastNotify = Toast.notify as unknown as jest.Mock
|
||||
|
||||
const baseRetrievalConfig: RetrievalConfig = {
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: false,
|
||||
reranking_model: {
|
||||
reranking_provider_name: 'provider',
|
||||
reranking_model_name: 'rerank-model',
|
||||
},
|
||||
top_k: 4,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0,
|
||||
}
|
||||
|
||||
const defaultIndexingTechnique: IndexingType = 'high_quality' as IndexingType
|
||||
|
||||
const createDataset = (overrides: Partial<DataSet> = {}): DataSet => {
|
||||
const {
|
||||
retrieval_model,
|
||||
retrieval_model_dict,
|
||||
icon_info,
|
||||
...restOverrides
|
||||
} = overrides
|
||||
|
||||
const resolvedRetrievalModelDict = {
|
||||
...baseRetrievalConfig,
|
||||
...retrieval_model_dict,
|
||||
}
|
||||
const resolvedRetrievalModel = {
|
||||
...baseRetrievalConfig,
|
||||
...(retrieval_model ?? retrieval_model_dict),
|
||||
}
|
||||
|
||||
const defaultIconInfo = {
|
||||
icon: '📘',
|
||||
icon_type: 'emoji',
|
||||
icon_background: '#FFEAD5',
|
||||
icon_url: '',
|
||||
}
|
||||
|
||||
const resolvedIconInfo = ('icon_info' in overrides)
|
||||
? icon_info
|
||||
: defaultIconInfo
|
||||
|
||||
return {
|
||||
id: 'dataset-id',
|
||||
name: 'Dataset Name',
|
||||
indexing_status: 'completed',
|
||||
icon_info: resolvedIconInfo as DataSet['icon_info'],
|
||||
description: 'A test dataset',
|
||||
permission: DatasetPermission.onlyMe,
|
||||
data_source_type: DataSourceType.FILE,
|
||||
indexing_technique: defaultIndexingTechnique,
|
||||
author_name: 'author',
|
||||
created_by: 'creator',
|
||||
updated_by: 'updater',
|
||||
updated_at: 0,
|
||||
app_count: 0,
|
||||
doc_form: ChunkingMode.text,
|
||||
document_count: 0,
|
||||
total_document_count: 0,
|
||||
total_available_documents: 0,
|
||||
word_count: 0,
|
||||
provider: 'dify',
|
||||
embedding_model: 'text-embedding',
|
||||
embedding_model_provider: 'openai',
|
||||
embedding_available: true,
|
||||
retrieval_model_dict: resolvedRetrievalModelDict,
|
||||
retrieval_model: resolvedRetrievalModel,
|
||||
tags: [],
|
||||
external_knowledge_info: {
|
||||
external_knowledge_id: 'external-id',
|
||||
external_knowledge_api_id: 'api-id',
|
||||
external_knowledge_api_name: 'api-name',
|
||||
external_knowledge_api_endpoint: 'https://endpoint',
|
||||
},
|
||||
external_retrieval_model: {
|
||||
top_k: 2,
|
||||
score_threshold: 0.5,
|
||||
score_threshold_enabled: true,
|
||||
},
|
||||
built_in_field_enabled: true,
|
||||
doc_metadata: [],
|
||||
keyword_number: 3,
|
||||
pipeline_id: 'pipeline-id',
|
||||
is_published: true,
|
||||
runtime_mode: 'general',
|
||||
enable_api: true,
|
||||
is_multimodal: false,
|
||||
...restOverrides,
|
||||
}
|
||||
}
|
||||
|
||||
const createDatasetConfigs = (overrides: Partial<DatasetConfigs> = {}): DatasetConfigs => {
|
||||
return {
|
||||
retrieval_model: RETRIEVE_TYPE.multiWay,
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
top_k: 4,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0,
|
||||
datasets: {
|
||||
datasets: [],
|
||||
},
|
||||
reranking_mode: RerankingModeEnum.WeightedScore,
|
||||
weights: {
|
||||
weight_type: WeightedScoreEnum.Customized,
|
||||
vector_setting: {
|
||||
vector_weight: 0.5,
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding',
|
||||
},
|
||||
keyword_setting: {
|
||||
keyword_weight: 0.5,
|
||||
},
|
||||
},
|
||||
reranking_enable: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('ConfigContent', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockedUseModelListAndDefaultModelAndCurrentProviderAndModel.mockReturnValue({
|
||||
modelList: [],
|
||||
defaultModel: undefined,
|
||||
currentProvider: undefined,
|
||||
currentModel: undefined,
|
||||
})
|
||||
mockedUseCurrentProviderAndModel.mockReturnValue({
|
||||
currentProvider: undefined,
|
||||
currentModel: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
// State management
|
||||
describe('Effects', () => {
|
||||
it('should normalize oneWay retrieval mode to multiWay', async () => {
|
||||
// Arrange
|
||||
const onChange = jest.fn<void, [DatasetConfigs, boolean?]>()
|
||||
const datasetConfigs = createDatasetConfigs({ retrieval_model: RETRIEVE_TYPE.oneWay })
|
||||
|
||||
// Act
|
||||
render(<ConfigContent datasetConfigs={datasetConfigs} onChange={onChange} />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
const [nextConfigs] = onChange.mock.calls[0]
|
||||
expect(nextConfigs.retrieval_model).toBe(RETRIEVE_TYPE.multiWay)
|
||||
})
|
||||
})
|
||||
|
||||
// Rendering tests (REQUIRED)
|
||||
describe('Rendering', () => {
|
||||
it('should render weighted score panel when datasets are high-quality and consistent', () => {
|
||||
// Arrange
|
||||
const onChange = jest.fn<void, [DatasetConfigs, boolean?]>()
|
||||
const datasetConfigs = createDatasetConfigs({
|
||||
reranking_mode: RerankingModeEnum.WeightedScore,
|
||||
})
|
||||
const selectedDatasets: DataSet[] = [
|
||||
createDataset({
|
||||
indexing_technique: 'high_quality' as IndexingType,
|
||||
provider: 'dify',
|
||||
embedding_model: 'text-embedding',
|
||||
embedding_model_provider: 'openai',
|
||||
retrieval_model_dict: {
|
||||
...baseRetrievalConfig,
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
// Act
|
||||
render(
|
||||
<ConfigContent
|
||||
datasetConfigs={datasetConfigs}
|
||||
onChange={onChange}
|
||||
selectedDatasets={selectedDatasets}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('dataset.weightedScore.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.modelProvider.rerankModel.key')).toBeInTheDocument()
|
||||
expect(screen.getByText('dataset.weightedScore.semantic')).toBeInTheDocument()
|
||||
expect(screen.getByText('dataset.weightedScore.keyword')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User interactions
|
||||
describe('User Interactions', () => {
|
||||
it('should update weights when user changes weighted score slider', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const onChange = jest.fn<void, [DatasetConfigs, boolean?]>()
|
||||
const datasetConfigs = createDatasetConfigs({
|
||||
reranking_mode: RerankingModeEnum.WeightedScore,
|
||||
weights: {
|
||||
weight_type: WeightedScoreEnum.Customized,
|
||||
vector_setting: {
|
||||
vector_weight: 0.5,
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding',
|
||||
},
|
||||
keyword_setting: {
|
||||
keyword_weight: 0.5,
|
||||
},
|
||||
},
|
||||
})
|
||||
const selectedDatasets: DataSet[] = [
|
||||
createDataset({
|
||||
indexing_technique: 'high_quality' as IndexingType,
|
||||
provider: 'dify',
|
||||
embedding_model: 'text-embedding',
|
||||
embedding_model_provider: 'openai',
|
||||
retrieval_model_dict: {
|
||||
...baseRetrievalConfig,
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
// Act
|
||||
render(
|
||||
<ConfigContent
|
||||
datasetConfigs={datasetConfigs}
|
||||
onChange={onChange}
|
||||
selectedDatasets={selectedDatasets}
|
||||
/>,
|
||||
)
|
||||
|
||||
const weightedScoreSlider = screen.getAllByRole('slider')
|
||||
.find(slider => slider.getAttribute('aria-valuemax') === '1')
|
||||
expect(weightedScoreSlider).toBeDefined()
|
||||
await user.click(weightedScoreSlider!)
|
||||
const callsBefore = onChange.mock.calls.length
|
||||
await user.keyboard('{ArrowRight}')
|
||||
|
||||
// Assert
|
||||
expect(onChange.mock.calls.length).toBeGreaterThan(callsBefore)
|
||||
const [nextConfigs] = onChange.mock.calls.at(-1) ?? []
|
||||
expect(nextConfigs?.weights?.vector_setting.vector_weight).toBeCloseTo(0.6, 5)
|
||||
expect(nextConfigs?.weights?.keyword_setting.keyword_weight).toBeCloseTo(0.4, 5)
|
||||
})
|
||||
|
||||
it('should warn when switching to rerank model mode without a valid model', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const onChange = jest.fn<void, [DatasetConfigs, boolean?]>()
|
||||
const datasetConfigs = createDatasetConfigs({
|
||||
reranking_mode: RerankingModeEnum.WeightedScore,
|
||||
})
|
||||
const selectedDatasets: DataSet[] = [
|
||||
createDataset({
|
||||
indexing_technique: 'high_quality' as IndexingType,
|
||||
provider: 'dify',
|
||||
embedding_model: 'text-embedding',
|
||||
embedding_model_provider: 'openai',
|
||||
retrieval_model_dict: {
|
||||
...baseRetrievalConfig,
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
// Act
|
||||
render(
|
||||
<ConfigContent
|
||||
datasetConfigs={datasetConfigs}
|
||||
onChange={onChange}
|
||||
selectedDatasets={selectedDatasets}
|
||||
/>,
|
||||
)
|
||||
await user.click(screen.getByText('common.modelProvider.rerankModel.key'))
|
||||
|
||||
// Assert
|
||||
expect(mockToastNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'workflow.errorMsg.rerankModelRequired',
|
||||
})
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
reranking_mode: RerankingModeEnum.RerankingModel,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should warn when enabling rerank without a valid model in manual toggle mode', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const onChange = jest.fn<void, [DatasetConfigs, boolean?]>()
|
||||
const datasetConfigs = createDatasetConfigs({
|
||||
reranking_enable: false,
|
||||
})
|
||||
const selectedDatasets: DataSet[] = [
|
||||
createDataset({
|
||||
indexing_technique: 'economy' as IndexingType,
|
||||
provider: 'dify',
|
||||
embedding_model: 'text-embedding',
|
||||
embedding_model_provider: 'openai',
|
||||
retrieval_model_dict: {
|
||||
...baseRetrievalConfig,
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
// Act
|
||||
render(
|
||||
<ConfigContent
|
||||
datasetConfigs={datasetConfigs}
|
||||
onChange={onChange}
|
||||
selectedDatasets={selectedDatasets}
|
||||
/>,
|
||||
)
|
||||
await user.click(screen.getByRole('switch'))
|
||||
|
||||
// Assert
|
||||
expect(mockToastNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'workflow.errorMsg.rerankModelRequired',
|
||||
})
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
reranking_enable: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,242 @@
|
|||
import * as React from 'react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ParamsConfig from './index'
|
||||
import ConfigContext from '@/context/debug-configuration'
|
||||
import type { DatasetConfigs } from '@/models/debug'
|
||||
import { RerankingModeEnum } from '@/models/datasets'
|
||||
import { RETRIEVE_TYPE } from '@/types/app'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import {
|
||||
useCurrentProviderAndModel,
|
||||
useModelListAndDefaultModelAndCurrentProviderAndModel,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
|
||||
jest.mock('@/app/components/base/modal', () => {
|
||||
type Props = {
|
||||
isShow: boolean
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
const MockModal = ({ isShow, children }: Props) => {
|
||||
if (!isShow) return null
|
||||
return <div role="dialog">{children}</div>
|
||||
}
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
default: MockModal,
|
||||
}
|
||||
})
|
||||
|
||||
jest.mock('@/app/components/base/toast', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
notify: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useModelListAndDefaultModelAndCurrentProviderAndModel: jest.fn(),
|
||||
useCurrentProviderAndModel: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => {
|
||||
type Props = {
|
||||
defaultModel?: { provider: string; model: string }
|
||||
onSelect?: (model: { provider: string; model: string }) => void
|
||||
}
|
||||
|
||||
const MockModelSelector = ({ defaultModel, onSelect }: Props) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect?.(defaultModel ?? { provider: 'mock-provider', model: 'mock-model' })}
|
||||
>
|
||||
Mock ModelSelector
|
||||
</button>
|
||||
)
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
default: MockModelSelector,
|
||||
}
|
||||
})
|
||||
|
||||
jest.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="model-parameter-modal" />,
|
||||
}))
|
||||
|
||||
const mockedUseModelListAndDefaultModelAndCurrentProviderAndModel = useModelListAndDefaultModelAndCurrentProviderAndModel as jest.MockedFunction<typeof useModelListAndDefaultModelAndCurrentProviderAndModel>
|
||||
const mockedUseCurrentProviderAndModel = useCurrentProviderAndModel as jest.MockedFunction<typeof useCurrentProviderAndModel>
|
||||
const mockToastNotify = Toast.notify as unknown as jest.Mock
|
||||
|
||||
const createDatasetConfigs = (overrides: Partial<DatasetConfigs> = {}): DatasetConfigs => {
|
||||
return {
|
||||
retrieval_model: RETRIEVE_TYPE.multiWay,
|
||||
reranking_model: {
|
||||
reranking_provider_name: 'provider',
|
||||
reranking_model_name: 'rerank-model',
|
||||
},
|
||||
top_k: 4,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0,
|
||||
datasets: {
|
||||
datasets: [],
|
||||
},
|
||||
reranking_enable: false,
|
||||
reranking_mode: RerankingModeEnum.RerankingModel,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const renderParamsConfig = ({
|
||||
datasetConfigs = createDatasetConfigs(),
|
||||
initialModalOpen = false,
|
||||
disabled,
|
||||
}: {
|
||||
datasetConfigs?: DatasetConfigs
|
||||
initialModalOpen?: boolean
|
||||
disabled?: boolean
|
||||
} = {}) => {
|
||||
const setDatasetConfigsSpy = jest.fn<void, [DatasetConfigs]>()
|
||||
const setModalOpenSpy = jest.fn<void, [boolean]>()
|
||||
|
||||
const Wrapper = ({ children }: { children: React.ReactNode }) => {
|
||||
const [datasetConfigsState, setDatasetConfigsState] = React.useState(datasetConfigs)
|
||||
const [modalOpen, setModalOpen] = React.useState(initialModalOpen)
|
||||
|
||||
const contextValue = {
|
||||
datasetConfigs: datasetConfigsState,
|
||||
setDatasetConfigs: (next: DatasetConfigs) => {
|
||||
setDatasetConfigsSpy(next)
|
||||
setDatasetConfigsState(next)
|
||||
},
|
||||
rerankSettingModalOpen: modalOpen,
|
||||
setRerankSettingModalOpen: (open: boolean) => {
|
||||
setModalOpenSpy(open)
|
||||
setModalOpen(open)
|
||||
},
|
||||
} as unknown as React.ComponentProps<typeof ConfigContext.Provider>['value']
|
||||
|
||||
return (
|
||||
<ConfigContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</ConfigContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
render(
|
||||
<ParamsConfig
|
||||
disabled={disabled}
|
||||
selectedDatasets={[]}
|
||||
/>,
|
||||
{ wrapper: Wrapper },
|
||||
)
|
||||
|
||||
return {
|
||||
setDatasetConfigsSpy,
|
||||
setModalOpenSpy,
|
||||
}
|
||||
}
|
||||
|
||||
describe('dataset-config/params-config', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockedUseModelListAndDefaultModelAndCurrentProviderAndModel.mockReturnValue({
|
||||
modelList: [],
|
||||
defaultModel: undefined,
|
||||
currentProvider: undefined,
|
||||
currentModel: undefined,
|
||||
})
|
||||
mockedUseCurrentProviderAndModel.mockReturnValue({
|
||||
currentProvider: undefined,
|
||||
currentModel: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
// Rendering tests (REQUIRED)
|
||||
describe('Rendering', () => {
|
||||
it('should disable settings trigger when disabled is true', () => {
|
||||
// Arrange
|
||||
renderParamsConfig({ disabled: true })
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('button', { name: 'dataset.retrievalSettings' })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// User Interactions
|
||||
describe('User Interactions', () => {
|
||||
it('should open modal and persist changes when save is clicked', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const { setDatasetConfigsSpy } = renderParamsConfig()
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
|
||||
await screen.findByRole('dialog')
|
||||
|
||||
// Change top_k via the first number input increment control.
|
||||
const incrementButtons = screen.getAllByRole('button', { name: 'increment' })
|
||||
await user.click(incrementButtons[0])
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
// Assert
|
||||
expect(setDatasetConfigsSpy).toHaveBeenCalledWith(expect.objectContaining({ top_k: 5 }))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should discard changes when cancel is clicked', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const { setDatasetConfigsSpy } = renderParamsConfig()
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
|
||||
await screen.findByRole('dialog')
|
||||
|
||||
const incrementButtons = screen.getAllByRole('button', { name: 'increment' })
|
||||
await user.click(incrementButtons[0])
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Re-open and save without changes.
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
|
||||
await screen.findByRole('dialog')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
// Assert - should save original top_k rather than the canceled change.
|
||||
expect(setDatasetConfigsSpy).toHaveBeenCalledWith(expect.objectContaining({ top_k: 4 }))
|
||||
})
|
||||
|
||||
it('should prevent saving when rerank model is required but invalid', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const { setDatasetConfigsSpy } = renderParamsConfig({
|
||||
datasetConfigs: createDatasetConfigs({
|
||||
reranking_enable: true,
|
||||
reranking_mode: RerankingModeEnum.RerankingModel,
|
||||
}),
|
||||
initialModalOpen: true,
|
||||
})
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
// Assert
|
||||
expect(mockToastNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'appDebug.datasetConfig.rerankModelRequired',
|
||||
})
|
||||
expect(setDatasetConfigsSpy).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import WeightedScore from './weighted-score'
|
||||
|
||||
describe('WeightedScore', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests (REQUIRED)
|
||||
describe('Rendering', () => {
|
||||
it('should render semantic and keyword weights', () => {
|
||||
// Arrange
|
||||
const onChange = jest.fn<void, [{ value: number[] }]>()
|
||||
const value = { value: [0.3, 0.7] }
|
||||
|
||||
// Act
|
||||
render(<WeightedScore value={value} onChange={onChange} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('dataset.weightedScore.semantic')).toBeInTheDocument()
|
||||
expect(screen.getByText('dataset.weightedScore.keyword')).toBeInTheDocument()
|
||||
expect(screen.getByText('0.3')).toBeInTheDocument()
|
||||
expect(screen.getByText('0.7')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should format a weight of 1 as 1.0', () => {
|
||||
// Arrange
|
||||
const onChange = jest.fn<void, [{ value: number[] }]>()
|
||||
const value = { value: [1, 0] }
|
||||
|
||||
// Act
|
||||
render(<WeightedScore value={value} onChange={onChange} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('1.0')).toBeInTheDocument()
|
||||
expect(screen.getByText('0')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User Interactions
|
||||
describe('User Interactions', () => {
|
||||
it('should emit complementary weights when the slider value changes', async () => {
|
||||
// Arrange
|
||||
const onChange = jest.fn<void, [{ value: number[] }]>()
|
||||
const value = { value: [0.5, 0.5] }
|
||||
const user = userEvent.setup()
|
||||
render(<WeightedScore value={value} onChange={onChange} />)
|
||||
|
||||
// Act
|
||||
await user.tab()
|
||||
const slider = screen.getByRole('slider')
|
||||
expect(slider).toHaveFocus()
|
||||
const callsBefore = onChange.mock.calls.length
|
||||
await user.keyboard('{ArrowRight}')
|
||||
|
||||
// Assert
|
||||
expect(onChange.mock.calls.length).toBeGreaterThan(callsBefore)
|
||||
const lastCall = onChange.mock.calls.at(-1)?.[0]
|
||||
expect(lastCall?.value[0]).toBeCloseTo(0.6, 5)
|
||||
expect(lastCall?.value[1]).toBeCloseTo(0.4, 5)
|
||||
})
|
||||
|
||||
it('should not call onChange when readonly is true', async () => {
|
||||
// Arrange
|
||||
const onChange = jest.fn<void, [{ value: number[] }]>()
|
||||
const value = { value: [0.5, 0.5] }
|
||||
const user = userEvent.setup()
|
||||
render(<WeightedScore value={value} onChange={onChange} readonly />)
|
||||
|
||||
// Act
|
||||
await user.tab()
|
||||
const slider = screen.getByRole('slider')
|
||||
expect(slider).toHaveFocus()
|
||||
await user.keyboard('{ArrowRight}')
|
||||
|
||||
// Assert
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -26,7 +26,7 @@ jest.mock('./app-list', () => {
|
|||
})
|
||||
|
||||
jest.mock('ahooks', () => ({
|
||||
useKeyPress: jest.fn((key: string, callback: () => void) => {
|
||||
useKeyPress: jest.fn((_key: string, _callback: () => void) => {
|
||||
// Mock implementation for testing
|
||||
return jest.fn()
|
||||
}),
|
||||
|
|
@ -67,7 +67,7 @@ describe('CreateAppTemplateDialog', () => {
|
|||
})
|
||||
|
||||
it('should not render create from blank button when onCreateFromBlank is not provided', () => {
|
||||
const { onCreateFromBlank, ...propsWithoutOnCreate } = defaultProps
|
||||
const { onCreateFromBlank: _onCreateFromBlank, ...propsWithoutOnCreate } = defaultProps
|
||||
|
||||
render(<CreateAppTemplateDialog {...propsWithoutOnCreate} show={true} />)
|
||||
|
||||
|
|
@ -259,7 +259,7 @@ describe('CreateAppTemplateDialog', () => {
|
|||
})
|
||||
|
||||
it('should handle missing optional onCreateFromBlank prop', () => {
|
||||
const { onCreateFromBlank, ...propsWithoutOnCreate } = defaultProps
|
||||
const { onCreateFromBlank: _onCreateFromBlank, ...propsWithoutOnCreate } = defaultProps
|
||||
|
||||
expect(() => {
|
||||
render(<CreateAppTemplateDialog {...propsWithoutOnCreate} show={true} />)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import AnnotationFull from './index'
|
||||
|
||||
let mockUsageProps: { className?: string } | null = null
|
||||
jest.mock('./usage', () => ({
|
||||
__esModule: true,
|
||||
default: (props: { className?: string }) => {
|
||||
mockUsageProps = props
|
||||
return (
|
||||
<div data-testid='usage-component' data-classname={props.className ?? ''}>
|
||||
usage
|
||||
|
|
@ -14,11 +12,9 @@ jest.mock('./usage', () => ({
|
|||
},
|
||||
}))
|
||||
|
||||
let mockUpgradeBtnProps: { loc?: string } | null = null
|
||||
jest.mock('../upgrade-btn', () => ({
|
||||
__esModule: true,
|
||||
default: (props: { loc?: string }) => {
|
||||
mockUpgradeBtnProps = props
|
||||
return (
|
||||
<button type='button' data-testid='upgrade-btn'>
|
||||
{props.loc}
|
||||
|
|
@ -30,8 +26,6 @@ jest.mock('../upgrade-btn', () => ({
|
|||
describe('AnnotationFull', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockUsageProps = null
|
||||
mockUpgradeBtnProps = null
|
||||
})
|
||||
|
||||
// Rendering marketing copy with action button
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import AnnotationFullModal from './modal'
|
||||
|
||||
let mockUsageProps: { className?: string } | null = null
|
||||
jest.mock('./usage', () => ({
|
||||
__esModule: true,
|
||||
default: (props: { className?: string }) => {
|
||||
mockUsageProps = props
|
||||
return (
|
||||
<div data-testid='usage-component' data-classname={props.className ?? ''}>
|
||||
usage
|
||||
|
|
@ -59,7 +57,6 @@ jest.mock('../../base/modal', () => ({
|
|||
describe('AnnotationFullModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockUsageProps = null
|
||||
mockUpgradeBtnProps = null
|
||||
mockModalProps = null
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,13 +4,6 @@ import PipelineSettings from './index'
|
|||
import { DatasourceType } from '@/models/pipeline'
|
||||
import type { PipelineExecutionLogResponse } from '@/models/pipeline'
|
||||
|
||||
// Mock i18n
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock Next.js router
|
||||
const mockPush = jest.fn()
|
||||
const mockBack = jest.fn()
|
||||
|
|
|
|||
|
|
@ -4,13 +4,6 @@ import ProcessDocuments from './index'
|
|||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import type { RAGPipelineVariable } from '@/models/pipeline'
|
||||
|
||||
// Mock i18n
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock dataset detail context - required for useInputVariables hook
|
||||
const mockPipelineId = 'pipeline-123'
|
||||
jest.mock('@/context/dataset-detail', () => ({
|
||||
|
|
|
|||
|
|
@ -3,13 +3,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|||
import StatusItem from './index'
|
||||
import type { DocumentDisplayStatus } from '@/models/datasets'
|
||||
|
||||
// Mock i18n - required for translation
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock ToastContext - required to verify notifications
|
||||
const mockNotify = jest.fn()
|
||||
jest.mock('use-context-selector', () => ({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,578 @@
|
|||
import React from 'react'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { UsagePlanInfo } from '@/app/components/billing/type'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { createMockPlan, createMockPlanTotal, createMockPlanUsage } from '@/__mocks__/provider-context'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import CreateAppModal from './index'
|
||||
import type { CreateAppModalProps } from './index'
|
||||
|
||||
let mockTranslationOverrides: Record<string, string | undefined> = {}
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) => {
|
||||
const override = mockTranslationOverrides[key]
|
||||
if (override !== undefined)
|
||||
return override
|
||||
if (options?.returnObjects)
|
||||
return [`${key}-feature-1`, `${key}-feature-2`]
|
||||
if (options)
|
||||
return `${key}:${JSON.stringify(options)}`
|
||||
return key
|
||||
},
|
||||
i18n: {
|
||||
language: 'en',
|
||||
changeLanguage: jest.fn(),
|
||||
},
|
||||
}),
|
||||
Trans: ({ children }: { children?: React.ReactNode }) => children,
|
||||
initReactI18next: {
|
||||
type: '3rdParty',
|
||||
init: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// ky is an ESM-only package; mock it to keep Jest (CJS) specs running.
|
||||
jest.mock('ky', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
create: () => ({
|
||||
extend: () => async () => new Response(),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
// Avoid heavy emoji dataset initialization during unit tests.
|
||||
jest.mock('emoji-mart', () => ({
|
||||
init: jest.fn(),
|
||||
SearchIndex: { search: jest.fn().mockResolvedValue([]) },
|
||||
}))
|
||||
jest.mock('@emoji-mart/data', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
categories: [
|
||||
{ id: 'people', emojis: ['😀'] },
|
||||
],
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('next/navigation', () => ({
|
||||
useParams: () => ({}),
|
||||
}))
|
||||
|
||||
jest.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
userProfile: { email: 'test@example.com' },
|
||||
langGeniusVersionInfo: { current_version: '0.0.0' },
|
||||
}),
|
||||
}))
|
||||
|
||||
const createPlanInfo = (buildApps: number): UsagePlanInfo => ({
|
||||
vectorSpace: 0,
|
||||
buildApps,
|
||||
teamMembers: 0,
|
||||
annotatedResponse: 0,
|
||||
documentsUploadQuota: 0,
|
||||
apiRateLimit: 0,
|
||||
triggerEvents: 0,
|
||||
})
|
||||
|
||||
let mockEnableBilling = false
|
||||
let mockPlanType: Plan = Plan.team
|
||||
let mockUsagePlanInfo: UsagePlanInfo = createPlanInfo(1)
|
||||
let mockTotalPlanInfo: UsagePlanInfo = createPlanInfo(10)
|
||||
|
||||
jest.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => {
|
||||
const withPlan = createMockPlan(mockPlanType)
|
||||
const withUsage = createMockPlanUsage(mockUsagePlanInfo, withPlan)
|
||||
const withTotal = createMockPlanTotal(mockTotalPlanInfo, withUsage)
|
||||
return { ...withTotal, enableBilling: mockEnableBilling }
|
||||
},
|
||||
}))
|
||||
|
||||
type ConfirmPayload = Parameters<CreateAppModalProps['onConfirm']>[0]
|
||||
|
||||
const setup = (overrides: Partial<CreateAppModalProps> = {}) => {
|
||||
const onConfirm = jest.fn<Promise<void>, [ConfirmPayload]>().mockResolvedValue(undefined)
|
||||
const onHide = jest.fn<void, []>()
|
||||
|
||||
const props: CreateAppModalProps = {
|
||||
show: true,
|
||||
isEditModal: false,
|
||||
appName: 'Test App',
|
||||
appDescription: 'Test description',
|
||||
appIconType: 'emoji',
|
||||
appIcon: '🤖',
|
||||
appIconBackground: '#FFEAD5',
|
||||
appIconUrl: null,
|
||||
appMode: AppModeEnum.CHAT,
|
||||
appUseIconAsAnswerIcon: false,
|
||||
max_active_requests: null,
|
||||
onConfirm,
|
||||
confirmDisabled: false,
|
||||
onHide,
|
||||
...overrides,
|
||||
}
|
||||
|
||||
render(<CreateAppModal {...props} />)
|
||||
return { onConfirm, onHide }
|
||||
}
|
||||
|
||||
const getAppIconTrigger = (): HTMLElement => {
|
||||
const nameInput = screen.getByPlaceholderText('app.newApp.appNamePlaceholder')
|
||||
const iconRow = nameInput.parentElement?.parentElement
|
||||
const iconTrigger = iconRow?.firstElementChild
|
||||
if (!(iconTrigger instanceof HTMLElement))
|
||||
throw new Error('Failed to locate app icon trigger')
|
||||
return iconTrigger
|
||||
}
|
||||
|
||||
describe('CreateAppModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockTranslationOverrides = {}
|
||||
mockEnableBilling = false
|
||||
mockPlanType = Plan.team
|
||||
mockUsagePlanInfo = createPlanInfo(1)
|
||||
mockTotalPlanInfo = createPlanInfo(10)
|
||||
})
|
||||
|
||||
// The title and form sections vary based on the modal mode (create vs edit).
|
||||
describe('Rendering', () => {
|
||||
test('should render create title and actions when creating', () => {
|
||||
setup({ appName: 'My App', isEditModal: false })
|
||||
|
||||
expect(screen.getByText('explore.appCustomize.title:{"name":"My App"}')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('should render edit-only fields when editing a chat app', () => {
|
||||
setup({ isEditModal: true, appMode: AppModeEnum.CHAT, max_active_requests: 5 })
|
||||
|
||||
expect(screen.getByText('app.editAppTitle')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
expect((screen.getByRole('spinbutton') as HTMLInputElement).value).toBe('5')
|
||||
})
|
||||
|
||||
test.each([AppModeEnum.ADVANCED_CHAT, AppModeEnum.AGENT_CHAT])('should render answer icon switch when editing %s app', (mode) => {
|
||||
setup({ isEditModal: true, appMode: mode })
|
||||
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('should not render answer icon switch when editing a non-chat app', () => {
|
||||
setup({ isEditModal: true, appMode: AppModeEnum.COMPLETION })
|
||||
|
||||
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('should not render modal content when hidden', () => {
|
||||
setup({ show: false })
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'common.operation.create' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Disabled states prevent submission and reflect parent-driven props.
|
||||
describe('Props', () => {
|
||||
test('should disable confirm action when confirmDisabled is true', () => {
|
||||
setup({ confirmDisabled: true })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('should disable confirm action when appName is empty', () => {
|
||||
setup({ appName: ' ' })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// Defensive coverage for falsy input values and translation edge cases.
|
||||
describe('Edge Cases', () => {
|
||||
test('should default description to empty string when appDescription is empty', () => {
|
||||
setup({ appDescription: '' })
|
||||
|
||||
expect((screen.getByPlaceholderText('app.newApp.appDescriptionPlaceholder') as HTMLTextAreaElement).value).toBe('')
|
||||
})
|
||||
|
||||
test('should fall back to empty placeholders when translations return empty string', () => {
|
||||
mockTranslationOverrides = {
|
||||
'app.newApp.appNamePlaceholder': '',
|
||||
'app.newApp.appDescriptionPlaceholder': '',
|
||||
}
|
||||
|
||||
setup()
|
||||
|
||||
expect((screen.getByDisplayValue('Test App') as HTMLInputElement).placeholder).toBe('')
|
||||
expect((screen.getByDisplayValue('Test description') as HTMLTextAreaElement).placeholder).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
// The modal should close from user-initiated cancellation actions.
|
||||
describe('User Interactions', () => {
|
||||
test('should call onHide when cancel button is clicked', () => {
|
||||
const { onConfirm, onHide } = setup()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
|
||||
expect(onHide).toHaveBeenCalledTimes(1)
|
||||
expect(onConfirm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should call onHide when pressing Escape while visible', () => {
|
||||
const { onHide } = setup()
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Escape', keyCode: 27 })
|
||||
|
||||
expect(onHide).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('should not call onHide when pressing Escape while hidden', () => {
|
||||
const { onHide } = setup({ show: false })
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Escape', keyCode: 27 })
|
||||
|
||||
expect(onHide).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// When billing limits are reached, the modal blocks app creation and shows quota guidance.
|
||||
describe('Quota Gating', () => {
|
||||
test('should show AppsFull and disable create when apps quota is reached', () => {
|
||||
mockEnableBilling = true
|
||||
mockPlanType = Plan.team
|
||||
mockUsagePlanInfo = createPlanInfo(10)
|
||||
mockTotalPlanInfo = createPlanInfo(10)
|
||||
|
||||
setup({ isEditModal: false })
|
||||
|
||||
expect(screen.getByText('billing.apps.fullTip2')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('should allow saving when apps quota is reached in edit mode', () => {
|
||||
mockEnableBilling = true
|
||||
mockPlanType = Plan.team
|
||||
mockUsagePlanInfo = createPlanInfo(10)
|
||||
mockTotalPlanInfo = createPlanInfo(10)
|
||||
|
||||
setup({ isEditModal: true })
|
||||
|
||||
expect(screen.queryByText('billing.apps.fullTip2')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeEnabled()
|
||||
})
|
||||
})
|
||||
|
||||
// Shortcut handlers are important for power users and must respect gating rules.
|
||||
describe('Keyboard Shortcuts', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
test.each([
|
||||
['meta+enter', { metaKey: true }],
|
||||
['ctrl+enter', { ctrlKey: true }],
|
||||
])('should submit when %s is pressed while visible', (_, modifier) => {
|
||||
const { onConfirm, onHide } = setup()
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, ...modifier })
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(onHide).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('should not submit when modal is hidden', () => {
|
||||
const { onConfirm, onHide } = setup({ show: false })
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, metaKey: true })
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
expect(onConfirm).not.toHaveBeenCalled()
|
||||
expect(onHide).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should not submit when apps quota is reached in create mode', () => {
|
||||
mockEnableBilling = true
|
||||
mockPlanType = Plan.team
|
||||
mockUsagePlanInfo = createPlanInfo(10)
|
||||
mockTotalPlanInfo = createPlanInfo(10)
|
||||
|
||||
const { onConfirm, onHide } = setup({ isEditModal: false })
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, metaKey: true })
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
expect(onConfirm).not.toHaveBeenCalled()
|
||||
expect(onHide).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should submit when apps quota is reached in edit mode', () => {
|
||||
mockEnableBilling = true
|
||||
mockPlanType = Plan.team
|
||||
mockUsagePlanInfo = createPlanInfo(10)
|
||||
mockTotalPlanInfo = createPlanInfo(10)
|
||||
|
||||
const { onConfirm, onHide } = setup({ isEditModal: true })
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, metaKey: true })
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(onHide).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('should not submit when name is empty', () => {
|
||||
const { onConfirm, onHide } = setup({ appName: ' ' })
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, metaKey: true })
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
expect(onConfirm).not.toHaveBeenCalled()
|
||||
expect(onHide).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// The app icon picker is a key user flow for customizing metadata.
|
||||
describe('App Icon Picker', () => {
|
||||
test('should open and close the picker when cancel is clicked', () => {
|
||||
setup({
|
||||
appIconType: 'image',
|
||||
appIcon: 'file-123',
|
||||
appIconUrl: 'https://example.com/icon.png',
|
||||
})
|
||||
|
||||
fireEvent.click(getAppIconTrigger())
|
||||
|
||||
expect(screen.getByRole('button', { name: 'app.iconPicker.cancel' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'app.iconPicker.cancel' }))
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'app.iconPicker.cancel' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('should update icon payload when selecting emoji and confirming', () => {
|
||||
jest.useFakeTimers()
|
||||
try {
|
||||
const { onConfirm } = setup({
|
||||
appIconType: 'image',
|
||||
appIcon: 'file-123',
|
||||
appIconUrl: 'https://example.com/icon.png',
|
||||
})
|
||||
|
||||
fireEvent.click(getAppIconTrigger())
|
||||
|
||||
const emoji = document.querySelector('em-emoji[id="😀"]')
|
||||
if (!(emoji instanceof HTMLElement))
|
||||
throw new Error('Failed to locate emoji option in icon picker')
|
||||
fireEvent.click(emoji)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'app.iconPicker.ok' }))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
const payload = onConfirm.mock.calls[0][0]
|
||||
expect(payload).toMatchObject({
|
||||
icon_type: 'emoji',
|
||||
icon: '😀',
|
||||
icon_background: '#FFEAD5',
|
||||
})
|
||||
}
|
||||
finally {
|
||||
jest.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
test('should reset emoji icon to initial props when picker is cancelled', () => {
|
||||
setup({
|
||||
appIconType: 'emoji',
|
||||
appIcon: '🤖',
|
||||
appIconBackground: '#FFEAD5',
|
||||
})
|
||||
|
||||
expect(document.querySelector('em-emoji[id="🤖"]')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(getAppIconTrigger())
|
||||
|
||||
const emoji = document.querySelector('em-emoji[id="😀"]')
|
||||
if (!(emoji instanceof HTMLElement))
|
||||
throw new Error('Failed to locate emoji option in icon picker')
|
||||
fireEvent.click(emoji)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'app.iconPicker.ok' }))
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'app.iconPicker.cancel' })).not.toBeInTheDocument()
|
||||
expect(document.querySelector('em-emoji[id="😀"]')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(getAppIconTrigger())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'app.iconPicker.cancel' }))
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'app.iconPicker.cancel' })).not.toBeInTheDocument()
|
||||
expect(document.querySelector('em-emoji[id="🤖"]')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Submitting uses a debounced handler and builds a payload from current form state.
|
||||
describe('Submitting', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
test('should call onConfirm with emoji payload and hide when create is clicked', () => {
|
||||
const { onConfirm, onHide } = setup({
|
||||
appName: 'My App',
|
||||
appDescription: 'My description',
|
||||
appIconType: 'emoji',
|
||||
appIcon: '😀',
|
||||
appIconBackground: '#000000',
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(onHide).toHaveBeenCalledTimes(1)
|
||||
|
||||
const payload = onConfirm.mock.calls[0][0]
|
||||
expect(payload).toMatchObject({
|
||||
name: 'My App',
|
||||
icon_type: 'emoji',
|
||||
icon: '😀',
|
||||
icon_background: '#000000',
|
||||
description: 'My description',
|
||||
use_icon_as_answer_icon: false,
|
||||
})
|
||||
expect(payload).not.toHaveProperty('max_active_requests')
|
||||
})
|
||||
|
||||
test('should include updated description when textarea is changed before submitting', () => {
|
||||
const { onConfirm } = setup({ appDescription: 'Old description' })
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('app.newApp.appDescriptionPlaceholder'), { target: { value: 'Updated description' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(onConfirm.mock.calls[0][0]).toMatchObject({ description: 'Updated description' })
|
||||
})
|
||||
|
||||
test('should omit icon_background when submitting with image icon', () => {
|
||||
const { onConfirm } = setup({
|
||||
appIconType: 'image',
|
||||
appIcon: 'file-123',
|
||||
appIconUrl: 'https://example.com/icon.png',
|
||||
appIconBackground: null,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
const payload = onConfirm.mock.calls[0][0]
|
||||
expect(payload).toMatchObject({
|
||||
icon_type: 'image',
|
||||
icon: 'file-123',
|
||||
})
|
||||
expect(payload.icon_background).toBeUndefined()
|
||||
})
|
||||
|
||||
test('should include max_active_requests and updated answer icon when saving', () => {
|
||||
const { onConfirm } = setup({
|
||||
isEditModal: true,
|
||||
appMode: AppModeEnum.CHAT,
|
||||
appUseIconAsAnswerIcon: false,
|
||||
max_active_requests: 3,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '12' } })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
const payload = onConfirm.mock.calls[0][0]
|
||||
expect(payload).toMatchObject({
|
||||
use_icon_as_answer_icon: true,
|
||||
max_active_requests: 12,
|
||||
})
|
||||
})
|
||||
|
||||
test('should omit max_active_requests when input is empty', () => {
|
||||
const { onConfirm } = setup({ isEditModal: true, max_active_requests: null })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
const payload = onConfirm.mock.calls[0][0]
|
||||
expect(payload.max_active_requests).toBeUndefined()
|
||||
})
|
||||
|
||||
test('should omit max_active_requests when input is not a number', () => {
|
||||
const { onConfirm } = setup({ isEditModal: true, max_active_requests: null })
|
||||
|
||||
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: 'abc' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
const payload = onConfirm.mock.calls[0][0]
|
||||
expect(payload.max_active_requests).toBeUndefined()
|
||||
})
|
||||
|
||||
test('should show toast error and not submit when name becomes empty before debounced submit runs', () => {
|
||||
const { onConfirm, onHide } = setup({ appName: 'My App' })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
|
||||
fireEvent.change(screen.getByPlaceholderText('app.newApp.appNamePlaceholder'), { target: { value: ' ' } })
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
expect(screen.getByText('explore.appCustomize.nameRequired')).toBeInTheDocument()
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(6000)
|
||||
})
|
||||
expect(screen.queryByText('explore.appCustomize.nameRequired')).not.toBeInTheDocument()
|
||||
expect(onConfirm).not.toHaveBeenCalled()
|
||||
expect(onHide).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import ChatVariableTrigger from './chat-variable-trigger'
|
||||
|
||||
const mockUseNodesReadOnly = jest.fn()
|
||||
const mockUseIsChatMode = jest.fn()
|
||||
|
||||
jest.mock('@/app/components/workflow/hooks', () => ({
|
||||
__esModule: true,
|
||||
useNodesReadOnly: () => mockUseNodesReadOnly(),
|
||||
}))
|
||||
|
||||
jest.mock('../../hooks', () => ({
|
||||
__esModule: true,
|
||||
useIsChatMode: () => mockUseIsChatMode(),
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/workflow/header/chat-variable-button', () => ({
|
||||
__esModule: true,
|
||||
default: ({ disabled }: { disabled: boolean }) => (
|
||||
<button data-testid='chat-variable-button' type='button' disabled={disabled}>
|
||||
ChatVariableButton
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('ChatVariableTrigger', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
// Verifies conditional rendering when chat mode is off.
|
||||
describe('Rendering', () => {
|
||||
it('should not render when not in chat mode', () => {
|
||||
// Arrange
|
||||
mockUseIsChatMode.mockReturnValue(false)
|
||||
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false })
|
||||
|
||||
// Act
|
||||
render(<ChatVariableTrigger />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId('chat-variable-button')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Verifies the disabled state reflects read-only nodes.
|
||||
describe('Props', () => {
|
||||
it('should render enabled ChatVariableButton when nodes are editable', () => {
|
||||
// Arrange
|
||||
mockUseIsChatMode.mockReturnValue(true)
|
||||
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false })
|
||||
|
||||
// Act
|
||||
render(<ChatVariableTrigger />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('chat-variable-button')).toBeEnabled()
|
||||
})
|
||||
|
||||
it('should render disabled ChatVariableButton when nodes are read-only', () => {
|
||||
// Arrange
|
||||
mockUseIsChatMode.mockReturnValue(true)
|
||||
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: true })
|
||||
|
||||
// Act
|
||||
render(<ChatVariableTrigger />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('chat-variable-button')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,458 @@
|
|||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
|
||||
import FeaturesTrigger from './features-trigger'
|
||||
|
||||
const mockUseIsChatMode = jest.fn()
|
||||
const mockUseTheme = jest.fn()
|
||||
const mockUseNodesReadOnly = jest.fn()
|
||||
const mockUseChecklist = jest.fn()
|
||||
const mockUseChecklistBeforePublish = jest.fn()
|
||||
const mockUseNodesSyncDraft = jest.fn()
|
||||
const mockUseToastContext = jest.fn()
|
||||
const mockUseFeatures = jest.fn()
|
||||
const mockUseProviderContext = jest.fn()
|
||||
const mockUseNodes = jest.fn()
|
||||
const mockUseEdges = jest.fn()
|
||||
const mockUseAppStoreSelector = jest.fn()
|
||||
|
||||
const mockNotify = jest.fn()
|
||||
const mockHandleCheckBeforePublish = jest.fn()
|
||||
const mockHandleSyncWorkflowDraft = jest.fn()
|
||||
const mockPublishWorkflow = jest.fn()
|
||||
const mockUpdatePublishedWorkflow = jest.fn()
|
||||
const mockResetWorkflowVersionHistory = jest.fn()
|
||||
const mockInvalidateAppTriggers = jest.fn()
|
||||
const mockFetchAppDetail = jest.fn()
|
||||
const mockSetAppDetail = jest.fn()
|
||||
const mockSetPublishedAt = jest.fn()
|
||||
const mockSetLastPublishedHasUserInput = jest.fn()
|
||||
|
||||
const mockWorkflowStoreSetState = jest.fn()
|
||||
const mockWorkflowStoreSetShowFeaturesPanel = jest.fn()
|
||||
|
||||
let workflowStoreState = {
|
||||
showFeaturesPanel: false,
|
||||
isRestoring: false,
|
||||
setShowFeaturesPanel: mockWorkflowStoreSetShowFeaturesPanel,
|
||||
setPublishedAt: mockSetPublishedAt,
|
||||
setLastPublishedHasUserInput: mockSetLastPublishedHasUserInput,
|
||||
}
|
||||
|
||||
const mockWorkflowStore = {
|
||||
getState: () => workflowStoreState,
|
||||
setState: mockWorkflowStoreSetState,
|
||||
}
|
||||
|
||||
let capturedAppPublisherProps: Record<string, unknown> | null = null
|
||||
|
||||
jest.mock('@/app/components/workflow/hooks', () => ({
|
||||
__esModule: true,
|
||||
useChecklist: (...args: unknown[]) => mockUseChecklist(...args),
|
||||
useChecklistBeforePublish: () => mockUseChecklistBeforePublish(),
|
||||
useNodesReadOnly: () => mockUseNodesReadOnly(),
|
||||
useNodesSyncDraft: () => mockUseNodesSyncDraft(),
|
||||
useIsChatMode: () => mockUseIsChatMode(),
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/workflow/store', () => ({
|
||||
__esModule: true,
|
||||
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
|
||||
const state: Record<string, unknown> = {
|
||||
publishedAt: null,
|
||||
draftUpdatedAt: null,
|
||||
toolPublished: false,
|
||||
lastPublishedHasUserInput: false,
|
||||
}
|
||||
return selector(state)
|
||||
},
|
||||
useWorkflowStore: () => mockWorkflowStore,
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/base/features/hooks', () => ({
|
||||
__esModule: true,
|
||||
useFeatures: (selector: (state: Record<string, unknown>) => unknown) => mockUseFeatures(selector),
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/base/toast', () => ({
|
||||
__esModule: true,
|
||||
useToastContext: () => mockUseToastContext(),
|
||||
}))
|
||||
|
||||
jest.mock('@/context/provider-context', () => ({
|
||||
__esModule: true,
|
||||
useProviderContext: () => mockUseProviderContext(),
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
|
||||
__esModule: true,
|
||||
default: () => mockUseNodes(),
|
||||
}))
|
||||
|
||||
jest.mock('reactflow', () => ({
|
||||
__esModule: true,
|
||||
useEdges: () => mockUseEdges(),
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/app/app-publisher', () => ({
|
||||
__esModule: true,
|
||||
default: (props: Record<string, unknown>) => {
|
||||
capturedAppPublisherProps = props
|
||||
return (
|
||||
<div
|
||||
data-testid='app-publisher'
|
||||
data-disabled={String(Boolean(props.disabled))}
|
||||
data-publish-disabled={String(Boolean(props.publishDisabled))}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/service/use-workflow', () => ({
|
||||
__esModule: true,
|
||||
useInvalidateAppWorkflow: () => mockUpdatePublishedWorkflow,
|
||||
usePublishWorkflow: () => ({ mutateAsync: mockPublishWorkflow }),
|
||||
useResetWorkflowVersionHistory: () => mockResetWorkflowVersionHistory,
|
||||
}))
|
||||
|
||||
jest.mock('@/service/use-tools', () => ({
|
||||
__esModule: true,
|
||||
useInvalidateAppTriggers: () => mockInvalidateAppTriggers,
|
||||
}))
|
||||
|
||||
jest.mock('@/service/apps', () => ({
|
||||
__esModule: true,
|
||||
fetchAppDetail: (...args: unknown[]) => mockFetchAppDetail(...args),
|
||||
}))
|
||||
|
||||
jest.mock('@/hooks/use-theme', () => ({
|
||||
__esModule: true,
|
||||
default: () => mockUseTheme(),
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/app/store', () => ({
|
||||
__esModule: true,
|
||||
useStore: (selector: (state: { appDetail?: { id: string }; setAppDetail: typeof mockSetAppDetail }) => unknown) => mockUseAppStoreSelector(selector),
|
||||
}))
|
||||
|
||||
const createProviderContext = ({
|
||||
type = Plan.sandbox,
|
||||
isFetchedPlan = true,
|
||||
}: {
|
||||
type?: Plan
|
||||
isFetchedPlan?: boolean
|
||||
}) => ({
|
||||
plan: { type },
|
||||
isFetchedPlan,
|
||||
})
|
||||
|
||||
describe('FeaturesTrigger', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
capturedAppPublisherProps = null
|
||||
workflowStoreState = {
|
||||
showFeaturesPanel: false,
|
||||
isRestoring: false,
|
||||
setShowFeaturesPanel: mockWorkflowStoreSetShowFeaturesPanel,
|
||||
setPublishedAt: mockSetPublishedAt,
|
||||
setLastPublishedHasUserInput: mockSetLastPublishedHasUserInput,
|
||||
}
|
||||
|
||||
mockUseTheme.mockReturnValue({ theme: 'light' })
|
||||
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false, getNodesReadOnly: () => false })
|
||||
mockUseChecklist.mockReturnValue([])
|
||||
mockUseChecklistBeforePublish.mockReturnValue({ handleCheckBeforePublish: mockHandleCheckBeforePublish })
|
||||
mockHandleCheckBeforePublish.mockResolvedValue(true)
|
||||
mockUseNodesSyncDraft.mockReturnValue({ handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft })
|
||||
mockUseToastContext.mockReturnValue({ notify: mockNotify })
|
||||
mockUseFeatures.mockImplementation((selector: (state: Record<string, unknown>) => unknown) => selector({ features: { file: {} } }))
|
||||
mockUseProviderContext.mockReturnValue(createProviderContext({}))
|
||||
mockUseNodes.mockReturnValue([])
|
||||
mockUseEdges.mockReturnValue([])
|
||||
mockUseAppStoreSelector.mockImplementation(selector => selector({ appDetail: { id: 'app-id' }, setAppDetail: mockSetAppDetail }))
|
||||
mockFetchAppDetail.mockResolvedValue({ id: 'app-id' })
|
||||
mockPublishWorkflow.mockResolvedValue({ created_at: '2024-01-01T00:00:00Z' })
|
||||
})
|
||||
|
||||
// Verifies the feature toggle button only appears in chatflow mode.
|
||||
describe('Rendering', () => {
|
||||
it('should not render the features button when not in chat mode', () => {
|
||||
// Arrange
|
||||
mockUseIsChatMode.mockReturnValue(false)
|
||||
|
||||
// Act
|
||||
render(<FeaturesTrigger />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByRole('button', { name: /workflow\.common\.features/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the features button when in chat mode', () => {
|
||||
// Arrange
|
||||
mockUseIsChatMode.mockReturnValue(true)
|
||||
|
||||
// Act
|
||||
render(<FeaturesTrigger />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('button', { name: /workflow\.common\.features/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply dark theme styling when theme is dark', () => {
|
||||
// Arrange
|
||||
mockUseIsChatMode.mockReturnValue(true)
|
||||
mockUseTheme.mockReturnValue({ theme: 'dark' })
|
||||
|
||||
// Act
|
||||
render(<FeaturesTrigger />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('button', { name: /workflow\.common\.features/i })).toHaveClass('rounded-lg')
|
||||
})
|
||||
})
|
||||
|
||||
// Verifies user clicks toggle the features panel visibility.
|
||||
describe('User Interactions', () => {
|
||||
it('should toggle features panel when clicked and nodes are editable', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
mockUseIsChatMode.mockReturnValue(true)
|
||||
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false, getNodesReadOnly: () => false })
|
||||
|
||||
render(<FeaturesTrigger />)
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByRole('button', { name: /workflow\.common\.features/i }))
|
||||
|
||||
// Assert
|
||||
expect(mockWorkflowStoreSetShowFeaturesPanel).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
// Covers read-only gating that prevents toggling unless restoring.
|
||||
describe('Edge Cases', () => {
|
||||
it('should not toggle features panel when nodes are read-only and not restoring', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
mockUseIsChatMode.mockReturnValue(true)
|
||||
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: true, getNodesReadOnly: () => true })
|
||||
workflowStoreState = {
|
||||
...workflowStoreState,
|
||||
isRestoring: false,
|
||||
}
|
||||
|
||||
render(<FeaturesTrigger />)
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByRole('button', { name: /workflow\.common\.features/i }))
|
||||
|
||||
// Assert
|
||||
expect(mockWorkflowStoreSetShowFeaturesPanel).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Verifies the publisher reflects the presence of workflow nodes.
|
||||
describe('Props', () => {
|
||||
it('should disable AppPublisher when there are no workflow nodes', () => {
|
||||
// Arrange
|
||||
mockUseIsChatMode.mockReturnValue(false)
|
||||
mockUseNodes.mockReturnValue([])
|
||||
|
||||
// Act
|
||||
render(<FeaturesTrigger />)
|
||||
|
||||
// Assert
|
||||
expect(capturedAppPublisherProps?.disabled).toBe(true)
|
||||
expect(screen.getByTestId('app-publisher')).toHaveAttribute('data-disabled', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
// Verifies derived props passed into AppPublisher (variables, limits, and triggers).
|
||||
describe('Computed Props', () => {
|
||||
it('should append image input when file image upload is enabled', () => {
|
||||
// Arrange
|
||||
mockUseFeatures.mockImplementation((selector: (state: Record<string, unknown>) => unknown) => selector({
|
||||
features: { file: { image: { enabled: true } } },
|
||||
}))
|
||||
mockUseNodes.mockReturnValue([
|
||||
{ id: 'start', data: { type: BlockEnum.Start } },
|
||||
])
|
||||
|
||||
// Act
|
||||
render(<FeaturesTrigger />)
|
||||
|
||||
// Assert
|
||||
const inputs = (capturedAppPublisherProps?.inputs as unknown as Array<{ type?: string; variable?: string }>) || []
|
||||
expect(inputs).toContainEqual({
|
||||
type: InputVarType.files,
|
||||
variable: '__image',
|
||||
required: false,
|
||||
label: 'files',
|
||||
})
|
||||
})
|
||||
|
||||
it('should set startNodeLimitExceeded when sandbox entry limit is exceeded', () => {
|
||||
// Arrange
|
||||
mockUseNodes.mockReturnValue([
|
||||
{ id: 'start', data: { type: BlockEnum.Start } },
|
||||
{ id: 'trigger-1', data: { type: BlockEnum.TriggerWebhook } },
|
||||
{ id: 'trigger-2', data: { type: BlockEnum.TriggerSchedule } },
|
||||
{ id: 'end', data: { type: BlockEnum.End } },
|
||||
])
|
||||
|
||||
// Act
|
||||
render(<FeaturesTrigger />)
|
||||
|
||||
// Assert
|
||||
expect(capturedAppPublisherProps?.startNodeLimitExceeded).toBe(true)
|
||||
expect(capturedAppPublisherProps?.publishDisabled).toBe(true)
|
||||
expect(capturedAppPublisherProps?.hasTriggerNode).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// Verifies callbacks wired from AppPublisher to stores and draft syncing.
|
||||
describe('Callbacks', () => {
|
||||
it('should set toolPublished when AppPublisher refreshes data', () => {
|
||||
// Arrange
|
||||
render(<FeaturesTrigger />)
|
||||
const refresh = capturedAppPublisherProps?.onRefreshData as unknown as (() => void) | undefined
|
||||
expect(refresh).toBeDefined()
|
||||
|
||||
// Act
|
||||
refresh?.()
|
||||
|
||||
// Assert
|
||||
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ toolPublished: true })
|
||||
})
|
||||
|
||||
it('should sync workflow draft when AppPublisher toggles on', () => {
|
||||
// Arrange
|
||||
render(<FeaturesTrigger />)
|
||||
const onToggle = capturedAppPublisherProps?.onToggle as unknown as ((state: boolean) => void) | undefined
|
||||
expect(onToggle).toBeDefined()
|
||||
|
||||
// Act
|
||||
onToggle?.(true)
|
||||
|
||||
// Assert
|
||||
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should not sync workflow draft when AppPublisher toggles off', () => {
|
||||
// Arrange
|
||||
render(<FeaturesTrigger />)
|
||||
const onToggle = capturedAppPublisherProps?.onToggle as unknown as ((state: boolean) => void) | undefined
|
||||
expect(onToggle).toBeDefined()
|
||||
|
||||
// Act
|
||||
onToggle?.(false)
|
||||
|
||||
// Assert
|
||||
expect(mockHandleSyncWorkflowDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Verifies publishing behavior across warnings, validation, and success.
|
||||
describe('Publishing', () => {
|
||||
it('should notify error and reject publish when checklist has warning nodes', async () => {
|
||||
// Arrange
|
||||
mockUseChecklist.mockReturnValue([{ id: 'warning' }])
|
||||
render(<FeaturesTrigger />)
|
||||
|
||||
const onPublish = capturedAppPublisherProps?.onPublish as unknown as (() => Promise<void>) | undefined
|
||||
expect(onPublish).toBeDefined()
|
||||
|
||||
// Act
|
||||
await expect(onPublish?.()).rejects.toThrow('Checklist has unresolved items')
|
||||
|
||||
// Assert
|
||||
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'workflow.panel.checklistTip' })
|
||||
})
|
||||
|
||||
it('should reject publish when checklist before publish fails', async () => {
|
||||
// Arrange
|
||||
mockHandleCheckBeforePublish.mockResolvedValue(false)
|
||||
render(<FeaturesTrigger />)
|
||||
|
||||
const onPublish = capturedAppPublisherProps?.onPublish as unknown as (() => Promise<void>) | undefined
|
||||
expect(onPublish).toBeDefined()
|
||||
|
||||
// Act & Assert
|
||||
await expect(onPublish?.()).rejects.toThrow('Checklist failed')
|
||||
})
|
||||
|
||||
it('should publish workflow and update related stores when validation passes', async () => {
|
||||
// Arrange
|
||||
mockUseNodes.mockReturnValue([
|
||||
{ id: 'start', data: { type: BlockEnum.Start } },
|
||||
])
|
||||
mockUseEdges.mockReturnValue([
|
||||
{ source: 'start' },
|
||||
])
|
||||
render(<FeaturesTrigger />)
|
||||
|
||||
const onPublish = capturedAppPublisherProps?.onPublish as unknown as (() => Promise<void>) | undefined
|
||||
expect(onPublish).toBeDefined()
|
||||
|
||||
// Act
|
||||
await onPublish?.()
|
||||
|
||||
// Assert
|
||||
expect(mockPublishWorkflow).toHaveBeenCalledWith({
|
||||
url: '/apps/app-id/workflows/publish',
|
||||
title: '',
|
||||
releaseNotes: '',
|
||||
})
|
||||
expect(mockUpdatePublishedWorkflow).toHaveBeenCalledWith('app-id')
|
||||
expect(mockInvalidateAppTriggers).toHaveBeenCalledWith('app-id')
|
||||
expect(mockSetPublishedAt).toHaveBeenCalledWith('2024-01-01T00:00:00Z')
|
||||
expect(mockSetLastPublishedHasUserInput).toHaveBeenCalledWith(true)
|
||||
expect(mockResetWorkflowVersionHistory).toHaveBeenCalled()
|
||||
expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'common.api.actionSuccess' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-id' })
|
||||
expect(mockSetAppDetail).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass publish params to workflow publish mutation', async () => {
|
||||
// Arrange
|
||||
render(<FeaturesTrigger />)
|
||||
|
||||
const onPublish = capturedAppPublisherProps?.onPublish as unknown as ((params: { title: string; releaseNotes: string }) => Promise<void>) | undefined
|
||||
expect(onPublish).toBeDefined()
|
||||
|
||||
// Act
|
||||
await onPublish?.({ title: 'Test title', releaseNotes: 'Test notes' })
|
||||
|
||||
// Assert
|
||||
expect(mockPublishWorkflow).toHaveBeenCalledWith({
|
||||
url: '/apps/app-id/workflows/publish',
|
||||
title: 'Test title',
|
||||
releaseNotes: 'Test notes',
|
||||
})
|
||||
})
|
||||
|
||||
it('should log error when app detail refresh fails after publish', async () => {
|
||||
// Arrange
|
||||
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
mockFetchAppDetail.mockRejectedValue(new Error('fetch failed'))
|
||||
|
||||
render(<FeaturesTrigger />)
|
||||
|
||||
const onPublish = capturedAppPublisherProps?.onPublish as unknown as (() => Promise<void>) | undefined
|
||||
expect(onPublish).toBeDefined()
|
||||
|
||||
// Act
|
||||
await onPublish?.()
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(consoleErrorSpy).toHaveBeenCalled()
|
||||
})
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
import { render } from '@testing-library/react'
|
||||
import type { App } from '@/types/app'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import type { HeaderProps } from '@/app/components/workflow/header'
|
||||
import WorkflowHeader from './index'
|
||||
import { fetchWorkflowRunHistory } from '@/service/workflow'
|
||||
|
||||
const mockUseAppStoreSelector = jest.fn()
|
||||
const mockSetCurrentLogItem = jest.fn()
|
||||
const mockSetShowMessageLogModal = jest.fn()
|
||||
const mockResetWorkflowVersionHistory = jest.fn()
|
||||
|
||||
let capturedHeaderProps: HeaderProps | null = null
|
||||
let appDetail: App
|
||||
|
||||
jest.mock('ky', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
create: () => ({
|
||||
extend: () => async () => ({
|
||||
status: 200,
|
||||
headers: new Headers(),
|
||||
json: async () => ({}),
|
||||
blob: async () => new Blob(),
|
||||
clone: () => ({
|
||||
status: 200,
|
||||
json: async () => ({}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/app/store', () => ({
|
||||
__esModule: true,
|
||||
useStore: (selector: (state: { appDetail?: App; setCurrentLogItem: typeof mockSetCurrentLogItem; setShowMessageLogModal: typeof mockSetShowMessageLogModal }) => unknown) => mockUseAppStoreSelector(selector),
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/workflow/header', () => ({
|
||||
__esModule: true,
|
||||
default: (props: HeaderProps) => {
|
||||
capturedHeaderProps = props
|
||||
return <div data-testid='workflow-header' />
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/service/workflow', () => ({
|
||||
__esModule: true,
|
||||
fetchWorkflowRunHistory: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/service/use-workflow', () => ({
|
||||
__esModule: true,
|
||||
useResetWorkflowVersionHistory: () => mockResetWorkflowVersionHistory,
|
||||
}))
|
||||
|
||||
describe('WorkflowHeader', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
capturedHeaderProps = null
|
||||
appDetail = { id: 'app-id', mode: AppModeEnum.COMPLETION } as unknown as App
|
||||
|
||||
mockUseAppStoreSelector.mockImplementation(selector => selector({
|
||||
appDetail,
|
||||
setCurrentLogItem: mockSetCurrentLogItem,
|
||||
setShowMessageLogModal: mockSetShowMessageLogModal,
|
||||
}))
|
||||
})
|
||||
|
||||
// Verifies the wrapper renders the workflow header shell.
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
// Act
|
||||
render(<WorkflowHeader />)
|
||||
|
||||
// Assert
|
||||
expect(capturedHeaderProps).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// Verifies chat mode affects which primary action is shown in the header.
|
||||
describe('Props', () => {
|
||||
it('should configure preview mode when app is in advanced chat mode', () => {
|
||||
// Arrange
|
||||
appDetail = { id: 'app-id', mode: AppModeEnum.ADVANCED_CHAT } as unknown as App
|
||||
mockUseAppStoreSelector.mockImplementation(selector => selector({
|
||||
appDetail,
|
||||
setCurrentLogItem: mockSetCurrentLogItem,
|
||||
setShowMessageLogModal: mockSetShowMessageLogModal,
|
||||
}))
|
||||
|
||||
// Act
|
||||
render(<WorkflowHeader />)
|
||||
|
||||
// Assert
|
||||
expect(capturedHeaderProps?.normal?.runAndHistoryProps?.showRunButton).toBe(false)
|
||||
expect(capturedHeaderProps?.normal?.runAndHistoryProps?.showPreviewButton).toBe(true)
|
||||
expect(capturedHeaderProps?.normal?.runAndHistoryProps?.viewHistoryProps?.historyUrl).toBe('/apps/app-id/advanced-chat/workflow-runs')
|
||||
expect(capturedHeaderProps?.normal?.runAndHistoryProps?.viewHistoryProps?.historyFetcher).toBe(fetchWorkflowRunHistory)
|
||||
})
|
||||
|
||||
it('should configure run mode when app is not in advanced chat mode', () => {
|
||||
// Arrange
|
||||
appDetail = { id: 'app-id', mode: AppModeEnum.COMPLETION } as unknown as App
|
||||
mockUseAppStoreSelector.mockImplementation(selector => selector({
|
||||
appDetail,
|
||||
setCurrentLogItem: mockSetCurrentLogItem,
|
||||
setShowMessageLogModal: mockSetShowMessageLogModal,
|
||||
}))
|
||||
|
||||
// Act
|
||||
render(<WorkflowHeader />)
|
||||
|
||||
// Assert
|
||||
expect(capturedHeaderProps?.normal?.runAndHistoryProps?.showRunButton).toBe(true)
|
||||
expect(capturedHeaderProps?.normal?.runAndHistoryProps?.showPreviewButton).toBe(false)
|
||||
expect(capturedHeaderProps?.normal?.runAndHistoryProps?.viewHistoryProps?.historyUrl).toBe('/apps/app-id/workflow-runs')
|
||||
})
|
||||
})
|
||||
|
||||
// Verifies callbacks clear log state as expected.
|
||||
describe('User Interactions', () => {
|
||||
it('should clear log and close message modal when clearing history modal state', () => {
|
||||
// Arrange
|
||||
render(<WorkflowHeader />)
|
||||
|
||||
const clear = capturedHeaderProps?.normal?.runAndHistoryProps?.viewHistoryProps?.onClearLogAndMessageModal
|
||||
expect(clear).toBeDefined()
|
||||
|
||||
// Act
|
||||
clear?.()
|
||||
|
||||
// Assert
|
||||
expect(mockSetCurrentLogItem).toHaveBeenCalledWith()
|
||||
expect(mockSetShowMessageLogModal).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
// Ensures restoring callback is wired to reset version history.
|
||||
describe('Edge Cases', () => {
|
||||
it('should use resetWorkflowVersionHistory as restore settled handler', () => {
|
||||
// Act
|
||||
render(<WorkflowHeader />)
|
||||
|
||||
// Assert
|
||||
expect(capturedHeaderProps?.restoring?.onRestoreSettled).toBe(mockResetWorkflowVersionHistory)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue