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 { fireEvent, render, screen } from '@testing-library/react'
|
||||||
import EditItem, { EditItemType } from './index'
|
import EditItem, { EditItemType } from './index'
|
||||||
|
|
||||||
jest.mock('react-i18next', () => ({
|
|
||||||
useTranslation: () => ({
|
|
||||||
t: (key: string) => key,
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
|
|
||||||
describe('AddAnnotationModal/EditItem', () => {
|
describe('AddAnnotationModal/EditItem', () => {
|
||||||
test('should render query inputs with user avatar and placeholder strings', () => {
|
test('should render query inputs with user avatar and placeholder strings', () => {
|
||||||
render(
|
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)
|
const anchorRef = useRef<HTMLDivElement>(null)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hasMore = data?.pages?.[0].hasMore ?? false
|
const hasMore = data?.pages?.[0]?.hasMore ?? false
|
||||||
let observer: IntersectionObserver | undefined
|
let observer: IntersectionObserver | undefined
|
||||||
if (anchorRef.current) {
|
if (anchorRef.current) {
|
||||||
observer = new IntersectionObserver((entries) => {
|
observer = new IntersectionObserver((entries) => {
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,6 @@ import AgentSetting from './index'
|
||||||
import { MAX_ITERATIONS_NUM } from '@/config'
|
import { MAX_ITERATIONS_NUM } from '@/config'
|
||||||
import type { AgentConfig } from '@/models/debug'
|
import type { AgentConfig } from '@/models/debug'
|
||||||
|
|
||||||
jest.mock('react-i18next', () => ({
|
|
||||||
useTranslation: () => ({
|
|
||||||
t: (key: string) => key,
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
|
|
||||||
jest.mock('ahooks', () => {
|
jest.mock('ahooks', () => {
|
||||||
const actual = jest.requireActual('ahooks')
|
const actual = jest.requireActual('ahooks')
|
||||||
return {
|
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', () => ({
|
jest.mock('ahooks', () => ({
|
||||||
useKeyPress: jest.fn((key: string, callback: () => void) => {
|
useKeyPress: jest.fn((_key: string, _callback: () => void) => {
|
||||||
// Mock implementation for testing
|
// Mock implementation for testing
|
||||||
return jest.fn()
|
return jest.fn()
|
||||||
}),
|
}),
|
||||||
|
|
@ -67,7 +67,7 @@ describe('CreateAppTemplateDialog', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not render create from blank button when onCreateFromBlank is not provided', () => {
|
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} />)
|
render(<CreateAppTemplateDialog {...propsWithoutOnCreate} show={true} />)
|
||||||
|
|
||||||
|
|
@ -259,7 +259,7 @@ describe('CreateAppTemplateDialog', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle missing optional onCreateFromBlank prop', () => {
|
it('should handle missing optional onCreateFromBlank prop', () => {
|
||||||
const { onCreateFromBlank, ...propsWithoutOnCreate } = defaultProps
|
const { onCreateFromBlank: _onCreateFromBlank, ...propsWithoutOnCreate } = defaultProps
|
||||||
|
|
||||||
expect(() => {
|
expect(() => {
|
||||||
render(<CreateAppTemplateDialog {...propsWithoutOnCreate} show={true} />)
|
render(<CreateAppTemplateDialog {...propsWithoutOnCreate} show={true} />)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
import { render, screen } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
import AnnotationFull from './index'
|
import AnnotationFull from './index'
|
||||||
|
|
||||||
let mockUsageProps: { className?: string } | null = null
|
|
||||||
jest.mock('./usage', () => ({
|
jest.mock('./usage', () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
default: (props: { className?: string }) => {
|
default: (props: { className?: string }) => {
|
||||||
mockUsageProps = props
|
|
||||||
return (
|
return (
|
||||||
<div data-testid='usage-component' data-classname={props.className ?? ''}>
|
<div data-testid='usage-component' data-classname={props.className ?? ''}>
|
||||||
usage
|
usage
|
||||||
|
|
@ -14,11 +12,9 @@ jest.mock('./usage', () => ({
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
let mockUpgradeBtnProps: { loc?: string } | null = null
|
|
||||||
jest.mock('../upgrade-btn', () => ({
|
jest.mock('../upgrade-btn', () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
default: (props: { loc?: string }) => {
|
default: (props: { loc?: string }) => {
|
||||||
mockUpgradeBtnProps = props
|
|
||||||
return (
|
return (
|
||||||
<button type='button' data-testid='upgrade-btn'>
|
<button type='button' data-testid='upgrade-btn'>
|
||||||
{props.loc}
|
{props.loc}
|
||||||
|
|
@ -30,8 +26,6 @@ jest.mock('../upgrade-btn', () => ({
|
||||||
describe('AnnotationFull', () => {
|
describe('AnnotationFull', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks()
|
jest.clearAllMocks()
|
||||||
mockUsageProps = null
|
|
||||||
mockUpgradeBtnProps = null
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Rendering marketing copy with action button
|
// Rendering marketing copy with action button
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
import { fireEvent, render, screen } from '@testing-library/react'
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
import AnnotationFullModal from './modal'
|
import AnnotationFullModal from './modal'
|
||||||
|
|
||||||
let mockUsageProps: { className?: string } | null = null
|
|
||||||
jest.mock('./usage', () => ({
|
jest.mock('./usage', () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
default: (props: { className?: string }) => {
|
default: (props: { className?: string }) => {
|
||||||
mockUsageProps = props
|
|
||||||
return (
|
return (
|
||||||
<div data-testid='usage-component' data-classname={props.className ?? ''}>
|
<div data-testid='usage-component' data-classname={props.className ?? ''}>
|
||||||
usage
|
usage
|
||||||
|
|
@ -59,7 +57,6 @@ jest.mock('../../base/modal', () => ({
|
||||||
describe('AnnotationFullModal', () => {
|
describe('AnnotationFullModal', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks()
|
jest.clearAllMocks()
|
||||||
mockUsageProps = null
|
|
||||||
mockUpgradeBtnProps = null
|
mockUpgradeBtnProps = null
|
||||||
mockModalProps = null
|
mockModalProps = null
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,6 @@ import PipelineSettings from './index'
|
||||||
import { DatasourceType } from '@/models/pipeline'
|
import { DatasourceType } from '@/models/pipeline'
|
||||||
import type { PipelineExecutionLogResponse } from '@/models/pipeline'
|
import type { PipelineExecutionLogResponse } from '@/models/pipeline'
|
||||||
|
|
||||||
// Mock i18n
|
|
||||||
jest.mock('react-i18next', () => ({
|
|
||||||
useTranslation: () => ({
|
|
||||||
t: (key: string) => key,
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Mock Next.js router
|
// Mock Next.js router
|
||||||
const mockPush = jest.fn()
|
const mockPush = jest.fn()
|
||||||
const mockBack = jest.fn()
|
const mockBack = jest.fn()
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,6 @@ import ProcessDocuments from './index'
|
||||||
import { PipelineInputVarType } from '@/models/pipeline'
|
import { PipelineInputVarType } from '@/models/pipeline'
|
||||||
import type { RAGPipelineVariable } 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
|
// Mock dataset detail context - required for useInputVariables hook
|
||||||
const mockPipelineId = 'pipeline-123'
|
const mockPipelineId = 'pipeline-123'
|
||||||
jest.mock('@/context/dataset-detail', () => ({
|
jest.mock('@/context/dataset-detail', () => ({
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import StatusItem from './index'
|
import StatusItem from './index'
|
||||||
import type { DocumentDisplayStatus } from '@/models/datasets'
|
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
|
// Mock ToastContext - required to verify notifications
|
||||||
const mockNotify = jest.fn()
|
const mockNotify = jest.fn()
|
||||||
jest.mock('use-context-selector', () => ({
|
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