From f923901d3f998b5d4ea8b6628cfc4e9d7f153383 Mon Sep 17 00:00:00 2001 From: Saumya Talwani <68903741+saumyatalwani@users.noreply.github.com> Date: Tue, 24 Feb 2026 10:31:45 +0530 Subject: [PATCH] test: add tests for base > features (#32397) Co-authored-by: sahil --- .../components/base/features/context.spec.tsx | 69 ++ .../components/base/features/hooks.spec.ts | 63 ++ .../annotation-ctrl-button.spec.tsx | 149 ++++ .../config-param-modal.spec.tsx | 415 +++++++++ .../annotation-reply/config-param.spec.tsx | 37 + .../annotation-reply/index.spec.tsx | 420 ++++++++++ .../score-slider/base-slider/index.spec.tsx | 50 ++ .../score-slider/index.spec.tsx | 50 ++ .../annotation-reply/type.spec.ts | 8 + .../use-annotation-config.spec.ts | 241 ++++++ .../new-feature-panel/citation.spec.tsx | 48 ++ .../conversation-opener/index.spec.tsx | 187 +++++ .../conversation-opener/modal.spec.tsx | 510 ++++++++++++ .../conversation-opener/modal.tsx | 31 +- .../new-feature-panel/dialog-wrapper.spec.tsx | 105 +++ .../new-feature-panel/dialog-wrapper.tsx | 4 +- .../new-feature-panel/feature-bar.spec.tsx | 172 ++++ .../new-feature-panel/feature-card.spec.tsx | 103 +++ .../file-upload/index.spec.tsx | 191 +++++ .../file-upload/setting-content.spec.tsx | 204 +++++ .../file-upload/setting-content.tsx | 19 +- .../file-upload/setting-modal.spec.tsx | 140 ++++ .../new-feature-panel/follow-up.spec.tsx | 48 ++ .../image-upload/index.spec.tsx | 194 +++++ .../features/new-feature-panel/index.spec.tsx | 215 +++++ .../moderation/form-generation.spec.tsx | 133 +++ .../moderation/index.spec.tsx | 427 ++++++++++ .../moderation/moderation-content.spec.tsx | 127 +++ .../moderation-setting-modal.spec.tsx | 787 ++++++++++++++++++ .../moderation/moderation-setting-modal.tsx | 28 +- .../new-feature-panel/more-like-this.spec.tsx | 55 ++ .../new-feature-panel/speech-to-text.spec.tsx | 48 ++ .../text-to-speech/index.spec.tsx | 115 +++ .../param-config-content.spec.tsx | 349 ++++++++ .../text-to-speech/param-config-content.tsx | 42 +- .../text-to-speech/voice-settings.spec.tsx | 105 +++ .../components/base/features/store.spec.ts | 180 ++++ web/eslint-suppressions.json | 24 - 38 files changed, 6028 insertions(+), 65 deletions(-) create mode 100644 web/app/components/base/features/context.spec.tsx create mode 100644 web/app/components/base/features/hooks.spec.ts create mode 100644 web/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-button.spec.tsx create mode 100644 web/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal.spec.tsx create mode 100644 web/app/components/base/features/new-feature-panel/annotation-reply/config-param.spec.tsx create mode 100644 web/app/components/base/features/new-feature-panel/annotation-reply/index.spec.tsx create mode 100644 web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/base-slider/index.spec.tsx create mode 100644 web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/index.spec.tsx create mode 100644 web/app/components/base/features/new-feature-panel/annotation-reply/type.spec.ts create mode 100644 web/app/components/base/features/new-feature-panel/annotation-reply/use-annotation-config.spec.ts create mode 100644 web/app/components/base/features/new-feature-panel/citation.spec.tsx create mode 100644 web/app/components/base/features/new-feature-panel/conversation-opener/index.spec.tsx create mode 100644 web/app/components/base/features/new-feature-panel/conversation-opener/modal.spec.tsx create mode 100644 web/app/components/base/features/new-feature-panel/dialog-wrapper.spec.tsx create mode 100644 web/app/components/base/features/new-feature-panel/feature-bar.spec.tsx create mode 100644 web/app/components/base/features/new-feature-panel/feature-card.spec.tsx create mode 100644 web/app/components/base/features/new-feature-panel/file-upload/index.spec.tsx create mode 100644 web/app/components/base/features/new-feature-panel/file-upload/setting-content.spec.tsx create mode 100644 web/app/components/base/features/new-feature-panel/file-upload/setting-modal.spec.tsx create mode 100644 web/app/components/base/features/new-feature-panel/follow-up.spec.tsx create mode 100644 web/app/components/base/features/new-feature-panel/image-upload/index.spec.tsx create mode 100644 web/app/components/base/features/new-feature-panel/index.spec.tsx create mode 100644 web/app/components/base/features/new-feature-panel/moderation/form-generation.spec.tsx create mode 100644 web/app/components/base/features/new-feature-panel/moderation/index.spec.tsx create mode 100644 web/app/components/base/features/new-feature-panel/moderation/moderation-content.spec.tsx create mode 100644 web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.spec.tsx create mode 100644 web/app/components/base/features/new-feature-panel/more-like-this.spec.tsx create mode 100644 web/app/components/base/features/new-feature-panel/speech-to-text.spec.tsx create mode 100644 web/app/components/base/features/new-feature-panel/text-to-speech/index.spec.tsx create mode 100644 web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.spec.tsx create mode 100644 web/app/components/base/features/new-feature-panel/text-to-speech/voice-settings.spec.tsx create mode 100644 web/app/components/base/features/store.spec.ts diff --git a/web/app/components/base/features/context.spec.tsx b/web/app/components/base/features/context.spec.tsx new file mode 100644 index 0000000000..e57cbd82c2 --- /dev/null +++ b/web/app/components/base/features/context.spec.tsx @@ -0,0 +1,69 @@ +import { render, screen } from '@testing-library/react' +import * as React from 'react' +import { useContext } from 'react' +import { FeaturesContext, FeaturesProvider } from './context' + +const TestConsumer = () => { + const store = useContext(FeaturesContext) + if (!store) + return
no store
+ + const { features } = store.getState() + return
{features.moreLikeThis?.enabled ? 'enabled' : 'disabled'}
+} + +describe('FeaturesProvider', () => { + it('should provide store to children when FeaturesProvider wraps them', () => { + render( + + + , + ) + + expect(screen.getByRole('status')).toHaveTextContent('disabled') + }) + + it('should provide initial features state when features prop is provided', () => { + render( + + + , + ) + + expect(screen.getByRole('status')).toHaveTextContent('enabled') + }) + + it('should maintain the same store reference across re-renders', () => { + const storeRefs: Array> = [] + + const StoreRefCollector = () => { + const store = useContext(FeaturesContext) + storeRefs.push(store) + return null + } + + const { rerender } = render( + + + , + ) + + rerender( + + + , + ) + + expect(storeRefs[0]).toBe(storeRefs[1]) + }) + + it('should handle empty features object', () => { + render( + + + , + ) + + expect(screen.getByRole('status')).toHaveTextContent('disabled') + }) +}) diff --git a/web/app/components/base/features/hooks.spec.ts b/web/app/components/base/features/hooks.spec.ts new file mode 100644 index 0000000000..aa0aa1e85e --- /dev/null +++ b/web/app/components/base/features/hooks.spec.ts @@ -0,0 +1,63 @@ +import { renderHook } from '@testing-library/react' +import * as React from 'react' +import { FeaturesContext } from './context' +import { useFeatures, useFeaturesStore } from './hooks' +import { createFeaturesStore } from './store' + +describe('useFeatures', () => { + it('should return selected state from the store when useFeatures is called with selector', () => { + const store = createFeaturesStore({ + features: { moreLikeThis: { enabled: true } }, + }) + + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(FeaturesContext.Provider, { value: store }, children) + + const { result } = renderHook( + () => useFeatures(s => s.features.moreLikeThis?.enabled), + { wrapper }, + ) + + expect(result.current).toBe(true) + }) + + it('should throw error when used outside FeaturesContext.Provider', () => { + // Act & Assert + expect(() => { + renderHook(() => useFeatures(s => s.features)) + }).toThrow('Missing FeaturesContext.Provider in the tree') + }) + + it('should return undefined when feature does not exist', () => { + const store = createFeaturesStore({ features: {} }) + + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(FeaturesContext.Provider, { value: store }, children) + + const { result } = renderHook( + () => useFeatures(s => (s.features as Record).nonexistent as boolean | undefined), + { wrapper }, + ) + + expect(result.current).toBeUndefined() + }) +}) + +describe('useFeaturesStore', () => { + it('should return the store from context when used within provider', () => { + const store = createFeaturesStore() + + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(FeaturesContext.Provider, { value: store }, children) + + const { result } = renderHook(() => useFeaturesStore(), { wrapper }) + + expect(result.current).toBe(store) + }) + + it('should return null when used outside provider', () => { + const { result } = renderHook(() => useFeaturesStore()) + + expect(result.current).toBeNull() + }) +}) diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-button.spec.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-button.spec.tsx new file mode 100644 index 0000000000..65f45d10de --- /dev/null +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-button.spec.tsx @@ -0,0 +1,149 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import AnnotationCtrlButton from './annotation-ctrl-button' + +const mockSetShowAnnotationFullModal = vi.fn() +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowAnnotationFullModal: mockSetShowAnnotationFullModal, + }), +})) + +let mockAnnotatedResponseUsage = 5 +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + plan: { + usage: { get annotatedResponse() { return mockAnnotatedResponseUsage } }, + total: { annotatedResponse: 100 }, + }, + enableBilling: true, + }), +})) + +const mockAddAnnotation = vi.fn().mockResolvedValue({ + id: 'annotation-1', + account: { name: 'Test User' }, +}) + +vi.mock('@/service/annotation', () => ({ + addAnnotation: (...args: unknown[]) => mockAddAnnotation(...args), +})) + +vi.mock('@/app/components/base/toast', () => ({ + default: { notify: vi.fn() }, +})) + +describe('AnnotationCtrlButton', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAnnotatedResponseUsage = 5 + }) + + it('should render edit button when cached', () => { + render( + , + ) + + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should call onEdit when edit button is clicked', () => { + const onEdit = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByRole('button')) + + expect(onEdit).toHaveBeenCalled() + }) + + it('should render add button when not cached and has answer', () => { + render( + , + ) + + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should not render any button when not cached and no answer', () => { + render( + , + ) + + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('should call addAnnotation and onAdded when add button is clicked', async () => { + const onAdded = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByRole('button')) + + await waitFor(() => { + expect(mockAddAnnotation).toHaveBeenCalledWith('test-app', { + message_id: 'msg-1', + question: 'test query', + answer: 'test answer', + }) + expect(onAdded).toHaveBeenCalledWith('annotation-1', 'Test User') + }) + }) + + it('should show annotation full modal when annotation limit is reached', () => { + mockAnnotatedResponseUsage = 100 + + render( + , + ) + + fireEvent.click(screen.getByRole('button')) + + expect(mockSetShowAnnotationFullModal).toHaveBeenCalled() + expect(mockAddAnnotation).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal.spec.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal.spec.tsx new file mode 100644 index 0000000000..d541c006f6 --- /dev/null +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal.spec.tsx @@ -0,0 +1,415 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import Toast from '@/app/components/base/toast' +import ConfigParamModal from './config-param-modal' + +let mockHooksReturn: { + modelList: { provider: { provider: string }, models: { model: string }[] }[] + defaultModel: { provider: { provider: string }, model: string } | undefined + currentModel: boolean | undefined +} = { + modelList: [{ provider: { provider: 'openai' }, models: [{ model: 'text-embedding-ada-002' }] }], + defaultModel: { provider: { provider: 'openai' }, model: 'text-embedding-ada-002' }, + currentModel: true, +} + +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useModelListAndDefaultModelAndCurrentProviderAndModel: () => mockHooksReturn, +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/declarations', () => ({ + ModelTypeEnum: { + textEmbedding: 'text-embedding', + }, +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ + default: ({ defaultModel, onSelect }: { defaultModel?: { provider: string, model: string }, onSelect: (val: { provider: string, model: string }) => void }) => ( +
+ Model Selector + +
+ ), +})) + +vi.mock('@/app/components/base/toast', () => ({ + default: { notify: vi.fn() }, +})) + +vi.mock('@/config', () => ({ + ANNOTATION_DEFAULT: { score_threshold: 0.9 }, +})) + +const defaultAnnotationConfig = { + id: 'test-id', + enabled: false, + score_threshold: 0.9, + embedding_model: { + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-ada-002', + }, +} + +describe('ConfigParamModal', () => { + beforeEach(() => { + vi.clearAllMocks() + mockHooksReturn = { + modelList: [{ provider: { provider: 'openai' }, models: [{ model: 'text-embedding-ada-002' }] }], + defaultModel: { provider: { provider: 'openai' }, model: 'text-embedding-ada-002' }, + currentModel: true, + } + }) + + it('should not render when isShow is false', () => { + render( + , + ) + + expect(screen.queryByText(/initSetup/)).not.toBeInTheDocument() + }) + + it('should render init title when isInit is true', () => { + render( + , + ) + + expect(screen.getByText(/initSetup\.title/)).toBeInTheDocument() + }) + + it('should render config title when isInit is false', () => { + render( + , + ) + + expect(screen.getByText(/initSetup\.configTitle/)).toBeInTheDocument() + }) + + it('should render score slider', () => { + render( + , + ) + + expect(screen.getByRole('slider')).toBeInTheDocument() + }) + + it('should render model selector', () => { + render( + , + ) + + expect(screen.getByTestId('model-selector')).toBeInTheDocument() + }) + + it('should render cancel and confirm buttons', () => { + render( + , + ) + + expect(screen.getByText(/operation\.cancel/)).toBeInTheDocument() + expect(screen.getByText(/initSetup\.confirmBtn/)).toBeInTheDocument() + }) + + it('should display score threshold value', () => { + render( + , + ) + + expect(screen.getByText('0.90')).toBeInTheDocument() + }) + + it('should render configConfirmBtn when isInit is false', () => { + render( + , + ) + + expect(screen.getByText(/initSetup\.configConfirmBtn/)).toBeInTheDocument() + }) + + it('should call onSave with embedding model and score when save is clicked', async () => { + const onSave = vi.fn().mockResolvedValue(undefined) + render( + , + ) + + // Click the confirm/save button + const buttons = screen.getAllByRole('button') + const saveBtn = buttons.find(b => b.textContent?.includes('initSetup')) + fireEvent.click(saveBtn!) + + await waitFor(() => { + expect(onSave).toHaveBeenCalledWith( + { embedding_provider_name: 'openai', embedding_model_name: 'text-embedding-ada-002' }, + 0.9, + ) + }) + }) + + it('should show error toast when embedding model is not set', () => { + const configWithoutModel = { + ...defaultAnnotationConfig, + embedding_model: undefined as unknown as typeof defaultAnnotationConfig.embedding_model, + } + + // Override hooks to return no default model and no valid current model + mockHooksReturn = { + modelList: [], + defaultModel: undefined, + currentModel: undefined, + } + + render( + , + ) + + const buttons = screen.getAllByRole('button') + const saveBtn = buttons.find(b => b.textContent?.includes('initSetup')) + fireEvent.click(saveBtn!) + + expect(Toast.notify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + + it('should call onHide when cancel is clicked and not loading', () => { + const onHide = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByText(/operation\.cancel/)) + + expect(onHide).toHaveBeenCalled() + }) + + it('should render slider with expected bounds and current value', () => { + render( + , + ) + + const slider = screen.getByRole('slider') + expect(slider).toHaveAttribute('aria-valuemin', '80') + expect(slider).toHaveAttribute('aria-valuemax', '100') + expect(slider).toHaveAttribute('aria-valuenow', '90') + }) + + it('should update embedding model when model selector is used', () => { + render( + , + ) + + // Click the select model button in mock + fireEvent.click(screen.getByTestId('select-model')) + + // Model selector should now show the new provider/model + expect(screen.getByTestId('model-selector')).toHaveAttribute('data-provider', 'cohere') + expect(screen.getByTestId('model-selector')).toHaveAttribute('data-model', 'embed-english') + }) + + it('should call onSave with updated score from annotation config', async () => { + const onSave = vi.fn().mockResolvedValue(undefined) + render( + , + ) + + // Save + const buttons = screen.getAllByRole('button') + const saveBtn = buttons.find(b => b.textContent?.includes('initSetup')) + fireEvent.click(saveBtn!) + + await waitFor(() => { + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ embedding_provider_name: 'openai' }), + 0.95, + ) + }) + }) + + it('should call onSave with updated model after model selector change', async () => { + const onSave = vi.fn().mockResolvedValue(undefined) + render( + , + ) + + // Change model + fireEvent.click(screen.getByTestId('select-model')) + + // Save + const buttons = screen.getAllByRole('button') + const saveBtn = buttons.find(b => b.textContent?.includes('initSetup')) + fireEvent.click(saveBtn!) + + await waitFor(() => { + expect(onSave).toHaveBeenCalledWith( + { embedding_provider_name: 'cohere', embedding_model_name: 'embed-english' }, + 0.9, + ) + }) + }) + + it('should use default model when annotation config has no embedding model', () => { + const configWithoutModel = { + ...defaultAnnotationConfig, + embedding_model: undefined as unknown as typeof defaultAnnotationConfig.embedding_model, + } + render( + , + ) + + // Model selector should be initialized with the default model + expect(screen.getByTestId('model-selector')).toHaveAttribute('data-provider', 'openai') + expect(screen.getByTestId('model-selector')).toHaveAttribute('data-model', 'text-embedding-ada-002') + }) + + it('should use ANNOTATION_DEFAULT score_threshold when config has no score_threshold', () => { + const configWithoutThreshold = { + ...defaultAnnotationConfig, + score_threshold: 0, + } + render( + , + ) + + expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '90') + }) + + it('should set loading state while saving', async () => { + let resolveOnSave: () => void + const onSave = vi.fn().mockImplementation(() => new Promise((resolve) => { + resolveOnSave = resolve + })) + const onHide = vi.fn() + + render( + , + ) + + // Click save + const buttons = screen.getAllByRole('button') + const saveBtn = buttons.find(b => b.textContent?.includes('initSetup')) + fireEvent.click(saveBtn!) + + // While loading, clicking cancel should not call onHide + fireEvent.click(screen.getByText(/operation\.cancel/)) + expect(onHide).not.toHaveBeenCalled() + + // Resolve the save + resolveOnSave!() + await waitFor(() => { + expect(onSave).toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/config-param.spec.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/config-param.spec.tsx new file mode 100644 index 0000000000..8d8a8e55cb --- /dev/null +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/config-param.spec.tsx @@ -0,0 +1,37 @@ +import { render, screen } from '@testing-library/react' +import { Item } from './config-param' + +describe('ConfigParam Item', () => { + it('should render title text', () => { + render( + +
children
+
, + ) + + expect(screen.getByText('Score Threshold')).toBeInTheDocument() + }) + + it('should render children', () => { + render( + +
Child
+
, + ) + + expect(screen.getByTestId('child-content')).toBeInTheDocument() + }) + + it('should render tooltip icon', () => { + render( + +
children
+
, + ) + + // Tooltip component renders an icon next to the title + expect(screen.getByText(/Title/)).toBeInTheDocument() + // The Tooltip component is rendered as a sibling, confirming the tooltip prop is used + expect(screen.getByText(/Title/).closest('div')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/index.spec.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/index.spec.tsx new file mode 100644 index 0000000000..ce9e2f7cf2 --- /dev/null +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/index.spec.tsx @@ -0,0 +1,420 @@ +import type { Features } from '../../types' +import type { OnFeaturesChange } from '@/app/components/base/features/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { FeaturesProvider } from '../../context' +import AnnotationReply from './index' + +const mockPush = vi.fn() +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockPush }), + usePathname: () => '/app/test-app-id/configuration', +})) + +let mockIsShowAnnotationConfigInit = false +let mockIsShowAnnotationFullModal = false +const mockHandleEnableAnnotation = vi.fn().mockResolvedValue(undefined) +const mockHandleDisableAnnotation = vi.fn().mockResolvedValue(undefined) +const mockSetIsShowAnnotationConfigInit = vi.fn((v: boolean) => { + mockIsShowAnnotationConfigInit = v +}) +const mockSetIsShowAnnotationFullModal = vi.fn((v: boolean) => { + mockIsShowAnnotationFullModal = v +}) + +let capturedSetAnnotationConfig: ((config: Record) => void) | null = null + +vi.mock('@/app/components/base/features/new-feature-panel/annotation-reply/use-annotation-config', () => ({ + default: ({ setAnnotationConfig }: { setAnnotationConfig: (config: Record) => void }) => { + capturedSetAnnotationConfig = setAnnotationConfig + return { + handleEnableAnnotation: mockHandleEnableAnnotation, + handleDisableAnnotation: mockHandleDisableAnnotation, + get isShowAnnotationConfigInit() { return mockIsShowAnnotationConfigInit }, + setIsShowAnnotationConfigInit: mockSetIsShowAnnotationConfigInit, + get isShowAnnotationFullModal() { return mockIsShowAnnotationFullModal }, + setIsShowAnnotationFullModal: mockSetIsShowAnnotationFullModal, + } + }, +})) + +vi.mock('@/app/components/billing/annotation-full/modal', () => ({ + default: ({ show, onHide }: { show: boolean, onHide: () => void }) => ( + show + ? ( +
+ +
+ ) + : null + ), +})) + +vi.mock('@/config', () => ({ + ANNOTATION_DEFAULT: { score_threshold: 0.9 }, +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({ + modelList: [{ provider: { provider: 'openai' }, models: [{ model: 'text-embedding-ada-002' }] }], + defaultModel: { provider: { provider: 'openai' }, model: 'text-embedding-ada-002' }, + currentModel: true, + }), +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/declarations', () => ({ + ModelTypeEnum: { + textEmbedding: 'text-embedding', + }, +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ + default: () => ( +
Model Selector
+ ), +})) + +const defaultFeatures: Features = { + moreLikeThis: { enabled: false }, + opening: { enabled: false }, + suggested: { enabled: false }, + text2speech: { enabled: false }, + speech2text: { enabled: false }, + citation: { enabled: false }, + moderation: { enabled: false }, + file: { enabled: false }, + annotationReply: { enabled: false }, +} + +const renderWithProvider = ( + props: { disabled?: boolean, onChange?: OnFeaturesChange } = {}, + featureOverrides?: Partial, +) => { + const features = { ...defaultFeatures, ...featureOverrides } + return render( + + + , + ) +} + +describe('AnnotationReply', () => { + beforeEach(() => { + vi.clearAllMocks() + mockIsShowAnnotationConfigInit = false + mockIsShowAnnotationFullModal = false + capturedSetAnnotationConfig = null + }) + + it('should render the annotation reply title', () => { + renderWithProvider() + + expect(screen.getByText(/feature\.annotation\.title/)).toBeInTheDocument() + }) + + it('should render description when not enabled', () => { + renderWithProvider() + + expect(screen.getByText(/feature\.annotation\.description/)).toBeInTheDocument() + }) + + it('should render a switch toggle', () => { + renderWithProvider() + + expect(screen.getByRole('switch')).toBeInTheDocument() + }) + + it('should call setIsShowAnnotationConfigInit when switch is toggled on', () => { + renderWithProvider() + + fireEvent.click(screen.getByRole('switch')) + + expect(mockSetIsShowAnnotationConfigInit).toHaveBeenCalledWith(true) + }) + + it('should call handleDisableAnnotation when switch is toggled off', () => { + renderWithProvider({}, { + annotationReply: { + enabled: true, + score_threshold: 0.9, + embedding_model: { + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-ada-002', + }, + }, + }) + + fireEvent.click(screen.getByRole('switch')) + + expect(mockHandleDisableAnnotation).toHaveBeenCalled() + }) + + it('should show score threshold and embedding model when enabled', () => { + renderWithProvider({}, { + annotationReply: { + enabled: true, + score_threshold: 0.9, + embedding_model: { + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-ada-002', + }, + }, + }) + + expect(screen.getByText('0.9')).toBeInTheDocument() + expect(screen.getByText('text-embedding-ada-002')).toBeInTheDocument() + }) + + it('should show dash when score threshold is not set', () => { + renderWithProvider({}, { + annotationReply: { + enabled: true, + embedding_model: { + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-ada-002', + }, + }, + }) + + expect(screen.getByText('-')).toBeInTheDocument() + }) + + it('should show buttons when hovering over enabled feature', () => { + renderWithProvider({}, { + annotationReply: { + enabled: true, + score_threshold: 0.9, + embedding_model: { + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-ada-002', + }, + }, + }) + + const card = screen.getByText(/feature\.annotation\.title/).closest('[class]')! + fireEvent.mouseEnter(card) + + expect(screen.getByText(/operation\.params/)).toBeInTheDocument() + expect(screen.getByText(/feature\.annotation\.cacheManagement/)).toBeInTheDocument() + }) + + it('should call setIsShowAnnotationConfigInit when params button is clicked', () => { + renderWithProvider({}, { + annotationReply: { + enabled: true, + score_threshold: 0.9, + embedding_model: { + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-ada-002', + }, + }, + }) + + const card = screen.getByText(/feature\.annotation\.title/).closest('[class]')! + fireEvent.mouseEnter(card) + fireEvent.click(screen.getByText(/operation\.params/)) + + expect(mockSetIsShowAnnotationConfigInit).toHaveBeenCalledWith(true) + }) + + it('should navigate to annotations page when cache management is clicked', () => { + renderWithProvider({}, { + annotationReply: { + enabled: true, + score_threshold: 0.9, + embedding_model: { + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-ada-002', + }, + }, + }) + + const card = screen.getByText(/feature\.annotation\.title/).closest('[class]')! + fireEvent.mouseEnter(card) + fireEvent.click(screen.getByText(/feature\.annotation\.cacheManagement/)) + + expect(mockPush).toHaveBeenCalledWith('/app/test-app-id/annotations') + }) + + it('should show config param modal when isShowAnnotationConfigInit is true', () => { + mockIsShowAnnotationConfigInit = true + renderWithProvider() + + expect(screen.getByText(/initSetup\.title/)).toBeInTheDocument() + }) + + it('should hide config modal when hide is clicked', () => { + mockIsShowAnnotationConfigInit = true + renderWithProvider() + + fireEvent.click(screen.getByRole('button', { name: /operation\.cancel/ })) + + expect(mockSetIsShowAnnotationConfigInit).toHaveBeenCalledWith(false) + }) + + it('should call handleEnableAnnotation when config save is clicked', async () => { + mockIsShowAnnotationConfigInit = true + renderWithProvider({}, { + annotationReply: { + enabled: true, + score_threshold: 0.9, + embedding_model: { + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-ada-002', + }, + }, + }) + + fireEvent.click(screen.getByText(/initSetup\.confirmBtn/)) + + expect(mockHandleEnableAnnotation).toHaveBeenCalled() + }) + + it('should show annotation full modal when isShowAnnotationFullModal is true', () => { + mockIsShowAnnotationFullModal = true + renderWithProvider() + + expect(screen.getByTestId('annotation-full-modal')).toBeInTheDocument() + }) + + it('should hide annotation full modal when hide is clicked', () => { + mockIsShowAnnotationFullModal = true + renderWithProvider() + + fireEvent.click(screen.getByTestId('full-hide')) + + expect(mockSetIsShowAnnotationFullModal).toHaveBeenCalledWith(false) + }) + + it('should call handleEnableAnnotation and hide config modal on save', async () => { + mockIsShowAnnotationConfigInit = true + renderWithProvider({}, { + annotationReply: { + enabled: true, + score_threshold: 0.9, + embedding_model: { + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-ada-002', + }, + }, + }) + + fireEvent.click(screen.getByText(/initSetup\.confirmBtn/)) + + // handleEnableAnnotation should be called with embedding model and score + expect(mockHandleEnableAnnotation).toHaveBeenCalledWith( + { embedding_provider_name: 'openai', embedding_model_name: 'text-embedding-ada-002' }, + 0.9, + ) + + // After save resolves, config init should be hidden + await vi.waitFor(() => { + expect(mockSetIsShowAnnotationConfigInit).toHaveBeenCalledWith(false) + }) + }) + + it('should update features and call onChange when updateAnnotationReply is invoked', () => { + const onChange = vi.fn() + renderWithProvider({ onChange }, { + annotationReply: { + enabled: true, + score_threshold: 0.9, + embedding_model: { + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-ada-002', + }, + }, + }) + + // The captured setAnnotationConfig is the component's updateAnnotationReply callback + expect(capturedSetAnnotationConfig).not.toBeNull() + capturedSetAnnotationConfig!({ + enabled: true, + score_threshold: 0.8, + embedding_model: { + embedding_provider_name: 'openai', + embedding_model_name: 'new-model', + }, + }) + + expect(onChange).toHaveBeenCalled() + }) + + it('should update features without calling onChange when onChange is not provided', () => { + renderWithProvider({}, { + annotationReply: { + enabled: true, + score_threshold: 0.9, + embedding_model: { + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-ada-002', + }, + }, + }) + + // Should not throw when onChange is not provided + expect(capturedSetAnnotationConfig).not.toBeNull() + expect(() => { + capturedSetAnnotationConfig!({ + enabled: true, + score_threshold: 0.7, + }) + }).not.toThrow() + }) + + it('should hide info display when hovering over enabled feature', () => { + renderWithProvider({}, { + annotationReply: { + enabled: true, + score_threshold: 0.9, + embedding_model: { + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-ada-002', + }, + }, + }) + + // Before hover, info is visible + expect(screen.getByText('0.9')).toBeInTheDocument() + + const card = screen.getByText(/feature\.annotation\.title/).closest('[class]')! + fireEvent.mouseEnter(card) + + // After hover, buttons shown instead of info + expect(screen.getByText(/operation\.params/)).toBeInTheDocument() + expect(screen.queryByText('0.9')).not.toBeInTheDocument() + }) + + it('should show info display again when mouse leaves', () => { + renderWithProvider({}, { + annotationReply: { + enabled: true, + score_threshold: 0.9, + embedding_model: { + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-ada-002', + }, + }, + }) + + const card = screen.getByText(/feature\.annotation\.title/).closest('[class]')! + fireEvent.mouseEnter(card) + fireEvent.mouseLeave(card) + + expect(screen.getByText('0.9')).toBeInTheDocument() + }) + + it('should pass isInit prop to ConfigParamModal', () => { + mockIsShowAnnotationConfigInit = true + renderWithProvider() + + expect(screen.getByText(/initSetup\.confirmBtn/)).toBeInTheDocument() + expect(screen.queryByText(/initSetup\.configConfirmBtn/)).not.toBeInTheDocument() + }) + + it('should not show annotation full modal when isShowAnnotationFullModal is false', () => { + mockIsShowAnnotationFullModal = false + renderWithProvider() + + expect(screen.queryByTestId('annotation-full-modal')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/base-slider/index.spec.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/base-slider/index.spec.tsx new file mode 100644 index 0000000000..21e187091c --- /dev/null +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/base-slider/index.spec.tsx @@ -0,0 +1,50 @@ +import { render, screen } from '@testing-library/react' +import Slider from './index' + +describe('BaseSlider', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render the slider component', () => { + render() + + expect(screen.getByRole('slider')).toBeInTheDocument() + }) + + it('should display the formatted value in the thumb', () => { + render() + + expect(screen.getByText('0.85')).toBeInTheDocument() + }) + + it('should use default min/max/step when not provided', () => { + render() + + const slider = screen.getByRole('slider') + expect(slider).toHaveAttribute('aria-valuemin', '0') + expect(slider).toHaveAttribute('aria-valuemax', '100') + expect(slider).toHaveAttribute('aria-valuenow', '50') + }) + + it('should use custom min/max/step when provided', () => { + render() + + const slider = screen.getByRole('slider') + expect(slider).toHaveAttribute('aria-valuemin', '80') + expect(slider).toHaveAttribute('aria-valuemax', '100') + expect(slider).toHaveAttribute('aria-valuenow', '90') + }) + + it('should handle NaN value as 0', () => { + render() + + expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '0') + }) + + it('should pass disabled prop', () => { + render() + + expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true') + }) +}) diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/index.spec.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/index.spec.tsx new file mode 100644 index 0000000000..008c6369e1 --- /dev/null +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/score-slider/index.spec.tsx @@ -0,0 +1,50 @@ +import { render, screen } from '@testing-library/react' +import ScoreSlider from './index' + +vi.mock('@/app/components/base/features/new-feature-panel/annotation-reply/score-slider/base-slider', () => ({ + default: ({ value, onChange, min, max }: { value: number, onChange: (v: number) => void, min: number, max: number }) => ( + onChange(Number(e.target.value))} + /> + ), +})) + +describe('ScoreSlider', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render the slider', () => { + render() + + expect(screen.getByTestId('slider')).toBeInTheDocument() + }) + + it('should display easy match and accurate match labels', () => { + render() + + expect(screen.getByText('0.8')).toBeInTheDocument() + expect(screen.getByText('1.0')).toBeInTheDocument() + expect(screen.getByText(/feature\.annotation\.scoreThreshold\.easyMatch/)).toBeInTheDocument() + expect(screen.getByText(/feature\.annotation\.scoreThreshold\.accurateMatch/)).toBeInTheDocument() + }) + + it('should render with custom className', () => { + const { container } = render() + + // Verifying the component renders successfully with a custom className + expect(screen.getByTestId('slider')).toBeInTheDocument() + expect(container.firstChild).toHaveClass('custom-class') + }) + + it('should pass value to the slider', () => { + render() + + expect(screen.getByTestId('slider')).toHaveValue('95') + }) +}) diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/type.spec.ts b/web/app/components/base/features/new-feature-panel/annotation-reply/type.spec.ts new file mode 100644 index 0000000000..0bbb6d695b --- /dev/null +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/type.spec.ts @@ -0,0 +1,8 @@ +import { PageType } from './type' + +describe('PageType', () => { + it('should have log and annotation values', () => { + expect(PageType.log).toBe('log') + expect(PageType.annotation).toBe('annotation') + }) +}) diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/use-annotation-config.spec.ts b/web/app/components/base/features/new-feature-panel/annotation-reply/use-annotation-config.spec.ts new file mode 100644 index 0000000000..f7ea3a0117 --- /dev/null +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/use-annotation-config.spec.ts @@ -0,0 +1,241 @@ +import type { AnnotationReplyConfig } from '@/models/debug' +import { act, renderHook } from '@testing-library/react' +import useAnnotationConfig from './use-annotation-config' + +let mockIsAnnotationFull = false +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + plan: { + usage: { annotatedResponse: mockIsAnnotationFull ? 100 : 5 }, + total: { annotatedResponse: 100 }, + }, + enableBilling: true, + }), +})) + +vi.mock('@/service/annotation', () => ({ + updateAnnotationStatus: vi.fn().mockResolvedValue({ job_id: 'test-job-id' }), + queryAnnotationJobStatus: vi.fn().mockResolvedValue({ job_status: 'completed' }), +})) + +vi.mock('@/utils', () => ({ + sleep: vi.fn().mockResolvedValue(undefined), +})) + +describe('useAnnotationConfig', () => { + const defaultConfig: AnnotationReplyConfig = { + id: 'test-id', + enabled: false, + score_threshold: 0.9, + embedding_model: { + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-ada-002', + }, + } + + beforeEach(() => { + vi.clearAllMocks() + mockIsAnnotationFull = false + }) + + it('should initialize with annotation config init hidden', () => { + const setAnnotationConfig = vi.fn() + const { result } = renderHook(() => useAnnotationConfig({ + appId: 'test-app', + annotationConfig: defaultConfig, + setAnnotationConfig, + })) + + expect(result.current.isShowAnnotationConfigInit).toBe(false) + expect(result.current.isShowAnnotationFullModal).toBe(false) + }) + + it('should show annotation config init modal', () => { + const setAnnotationConfig = vi.fn() + const { result } = renderHook(() => useAnnotationConfig({ + appId: 'test-app', + annotationConfig: defaultConfig, + setAnnotationConfig, + })) + + act(() => { + result.current.setIsShowAnnotationConfigInit(true) + }) + + expect(result.current.isShowAnnotationConfigInit).toBe(true) + }) + + it('should hide annotation config init modal', () => { + const setAnnotationConfig = vi.fn() + const { result } = renderHook(() => useAnnotationConfig({ + appId: 'test-app', + annotationConfig: defaultConfig, + setAnnotationConfig, + })) + + act(() => { + result.current.setIsShowAnnotationConfigInit(true) + }) + act(() => { + result.current.setIsShowAnnotationConfigInit(false) + }) + + expect(result.current.isShowAnnotationConfigInit).toBe(false) + }) + + it('should enable annotation and update config', async () => { + const setAnnotationConfig = vi.fn() + const { result } = renderHook(() => useAnnotationConfig({ + appId: 'test-app', + annotationConfig: defaultConfig, + setAnnotationConfig, + })) + + await act(async () => { + await result.current.handleEnableAnnotation({ + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-3-small', + }, 0.95) + }) + + expect(setAnnotationConfig).toHaveBeenCalled() + const updatedConfig = setAnnotationConfig.mock.calls[0][0] + expect(updatedConfig.enabled).toBe(true) + expect(updatedConfig.embedding_model.embedding_model_name).toBe('text-embedding-3-small') + }) + + it('should disable annotation and update config', async () => { + const enabledConfig = { ...defaultConfig, enabled: true } + const setAnnotationConfig = vi.fn() + const { result } = renderHook(() => useAnnotationConfig({ + appId: 'test-app', + annotationConfig: enabledConfig, + setAnnotationConfig, + })) + + await act(async () => { + await result.current.handleDisableAnnotation({ + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-ada-002', + }) + }) + + expect(setAnnotationConfig).toHaveBeenCalled() + const updatedConfig = setAnnotationConfig.mock.calls[0][0] + expect(updatedConfig.enabled).toBe(false) + }) + + it('should not disable when already disabled', async () => { + const setAnnotationConfig = vi.fn() + const { result } = renderHook(() => useAnnotationConfig({ + appId: 'test-app', + annotationConfig: defaultConfig, + setAnnotationConfig, + })) + + await act(async () => { + await result.current.handleDisableAnnotation({ + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-ada-002', + }) + }) + + expect(setAnnotationConfig).not.toHaveBeenCalled() + }) + + it('should set score threshold', () => { + const setAnnotationConfig = vi.fn() + const { result } = renderHook(() => useAnnotationConfig({ + appId: 'test-app', + annotationConfig: defaultConfig, + setAnnotationConfig, + })) + + act(() => { + result.current.setScore(0.85) + }) + + expect(setAnnotationConfig).toHaveBeenCalled() + const updatedConfig = setAnnotationConfig.mock.calls[0][0] + expect(updatedConfig.score_threshold).toBe(0.85) + }) + + it('should set score and embedding model together', () => { + const setAnnotationConfig = vi.fn() + const { result } = renderHook(() => useAnnotationConfig({ + appId: 'test-app', + annotationConfig: defaultConfig, + setAnnotationConfig, + })) + + act(() => { + result.current.setScore(0.95, { + embedding_provider_name: 'cohere', + embedding_model_name: 'embed-english', + }) + }) + + expect(setAnnotationConfig).toHaveBeenCalled() + const updatedConfig = setAnnotationConfig.mock.calls[0][0] + expect(updatedConfig.score_threshold).toBe(0.95) + expect(updatedConfig.embedding_model.embedding_provider_name).toBe('cohere') + }) + + it('should show annotation full modal instead of config init when annotation is full', () => { + mockIsAnnotationFull = true + const setAnnotationConfig = vi.fn() + const { result } = renderHook(() => useAnnotationConfig({ + appId: 'test-app', + annotationConfig: defaultConfig, + setAnnotationConfig, + })) + + act(() => { + result.current.setIsShowAnnotationConfigInit(true) + }) + + expect(result.current.isShowAnnotationFullModal).toBe(true) + expect(result.current.isShowAnnotationConfigInit).toBe(false) + }) + + it('should not enable annotation when annotation is full', async () => { + mockIsAnnotationFull = true + const setAnnotationConfig = vi.fn() + const { result } = renderHook(() => useAnnotationConfig({ + appId: 'test-app', + annotationConfig: defaultConfig, + setAnnotationConfig, + })) + + await act(async () => { + await result.current.handleEnableAnnotation({ + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-3-small', + }) + }) + + expect(setAnnotationConfig).not.toHaveBeenCalled() + }) + + it('should set default score_threshold when enabling without one', async () => { + const configWithoutThreshold = { ...defaultConfig, score_threshold: undefined as unknown as number } + const setAnnotationConfig = vi.fn() + const { result } = renderHook(() => useAnnotationConfig({ + appId: 'test-app', + annotationConfig: configWithoutThreshold, + setAnnotationConfig, + })) + + await act(async () => { + await result.current.handleEnableAnnotation({ + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-3-small', + }, 0.95) + }) + + expect(setAnnotationConfig).toHaveBeenCalled() + const updatedConfig = setAnnotationConfig.mock.calls[0][0] + expect(updatedConfig.enabled).toBe(true) + expect(updatedConfig.score_threshold).toBeDefined() + }) +}) diff --git a/web/app/components/base/features/new-feature-panel/citation.spec.tsx b/web/app/components/base/features/new-feature-panel/citation.spec.tsx new file mode 100644 index 0000000000..ed50ea9337 --- /dev/null +++ b/web/app/components/base/features/new-feature-panel/citation.spec.tsx @@ -0,0 +1,48 @@ +import type { OnFeaturesChange } from '../types' +import { fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' +import { FeaturesProvider } from '../context' +import Citation from './citation' + +const renderWithProvider = (props: { disabled?: boolean, onChange?: OnFeaturesChange } = {}) => { + return render( + + + , + ) +} + +describe('Citation', () => { + it('should render the citation feature card', () => { + renderWithProvider() + + expect(screen.getByText(/feature\.citation\.title/)).toBeInTheDocument() + }) + + it('should render description text', () => { + renderWithProvider() + + expect(screen.getByText(/feature\.citation\.description/)).toBeInTheDocument() + }) + + it('should render a switch toggle', () => { + renderWithProvider() + + expect(screen.getByRole('switch')).toBeInTheDocument() + }) + + it('should call onChange when toggled', () => { + const onChange = vi.fn() + renderWithProvider({ onChange }) + + fireEvent.click(screen.getByRole('switch')) + + expect(onChange).toHaveBeenCalledTimes(1) + }) + + it('should not throw when onChange is not provided', () => { + renderWithProvider() + + expect(() => fireEvent.click(screen.getByRole('switch'))).not.toThrow() + }) +}) diff --git a/web/app/components/base/features/new-feature-panel/conversation-opener/index.spec.tsx b/web/app/components/base/features/new-feature-panel/conversation-opener/index.spec.tsx new file mode 100644 index 0000000000..20e85c9378 --- /dev/null +++ b/web/app/components/base/features/new-feature-panel/conversation-opener/index.spec.tsx @@ -0,0 +1,187 @@ +import type { Features } from '../../types' +import type { OnFeaturesChange } from '@/app/components/base/features/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { FeaturesProvider } from '../../context' +import ConversationOpener from './index' + +const mockSetShowOpeningModal = vi.fn() +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowOpeningModal: mockSetShowOpeningModal, + }), +})) + +const defaultFeatures: Features = { + moreLikeThis: { enabled: false }, + opening: { enabled: false }, + suggested: { enabled: false }, + text2speech: { enabled: false }, + speech2text: { enabled: false }, + citation: { enabled: false }, + moderation: { enabled: false }, + file: { enabled: false }, + annotationReply: { enabled: false }, +} + +const renderWithProvider = ( + props: { disabled?: boolean, onChange?: OnFeaturesChange } = {}, + featureOverrides?: Partial, +) => { + const features = { ...defaultFeatures, ...featureOverrides } + return render( + + + , + ) +} + +describe('ConversationOpener', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render the conversation opener title', () => { + renderWithProvider() + + expect(screen.getByText(/feature\.conversationOpener\.title/)).toBeInTheDocument() + }) + + it('should render description when not enabled', () => { + renderWithProvider() + + expect(screen.getByText(/feature\.conversationOpener\.description/)).toBeInTheDocument() + }) + + it('should render a switch toggle', () => { + renderWithProvider() + + expect(screen.getByRole('switch')).toBeInTheDocument() + }) + + it('should call onChange when toggled', () => { + const onChange = vi.fn() + renderWithProvider({ onChange }) + + fireEvent.click(screen.getByRole('switch')) + + expect(onChange).toHaveBeenCalled() + }) + + it('should show opening statement when enabled and not hovering', () => { + renderWithProvider({}, { + opening: { enabled: true, opening_statement: 'Welcome to the app!' }, + }) + + expect(screen.getByText('Welcome to the app!')).toBeInTheDocument() + }) + + it('should show placeholder when enabled but no opening statement', () => { + renderWithProvider({}, { + opening: { enabled: true, opening_statement: '' }, + }) + + expect(screen.getByText(/openingStatement\.placeholder/)).toBeInTheDocument() + }) + + it('should show edit button when hovering over enabled feature', () => { + renderWithProvider({}, { + opening: { enabled: true, opening_statement: 'Hello' }, + }) + + const card = screen.getByText(/feature\.conversationOpener\.title/).closest('[class]')! + fireEvent.mouseEnter(card) + + expect(screen.getByText(/openingStatement\.writeOpener/)).toBeInTheDocument() + }) + + it('should open modal when edit button is clicked', () => { + renderWithProvider({}, { + opening: { enabled: true, opening_statement: 'Hello' }, + }) + + const card = screen.getByText(/feature\.conversationOpener\.title/).closest('[class]')! + fireEvent.mouseEnter(card) + fireEvent.click(screen.getByText(/openingStatement\.writeOpener/)) + + expect(mockSetShowOpeningModal).toHaveBeenCalled() + }) + + it('should not open modal when disabled', () => { + renderWithProvider({ disabled: true }, { + opening: { enabled: true, opening_statement: 'Hello' }, + }) + + const card = screen.getByText(/feature\.conversationOpener\.title/).closest('[class]')! + fireEvent.mouseEnter(card) + fireEvent.click(screen.getByText(/openingStatement\.writeOpener/)) + + expect(mockSetShowOpeningModal).not.toHaveBeenCalled() + }) + + it('should pass opening data to modal', () => { + renderWithProvider({}, { + opening: { enabled: true, opening_statement: 'Hello' }, + }) + + const card = screen.getByText(/feature\.conversationOpener\.title/).closest('[class]')! + fireEvent.mouseEnter(card) + fireEvent.click(screen.getByText(/openingStatement\.writeOpener/)) + + const modalCall = mockSetShowOpeningModal.mock.calls[0][0] + expect(modalCall.payload).toBeDefined() + expect(modalCall.onSaveCallback).toBeDefined() + expect(modalCall.onCancelCallback).toBeDefined() + }) + + it('should invoke onSaveCallback and update features', () => { + const onChange = vi.fn() + renderWithProvider({ onChange }, { + opening: { enabled: true, opening_statement: 'Hello' }, + }) + + const card = screen.getByText(/feature\.conversationOpener\.title/).closest('[class]')! + fireEvent.mouseEnter(card) + fireEvent.click(screen.getByText(/openingStatement\.writeOpener/)) + + const modalCall = mockSetShowOpeningModal.mock.calls[0][0] + modalCall.onSaveCallback({ enabled: true, opening_statement: 'Updated' }) + + expect(onChange).toHaveBeenCalled() + }) + + it('should invoke onCancelCallback', () => { + const onChange = vi.fn() + renderWithProvider({ onChange }, { + opening: { enabled: true, opening_statement: 'Hello' }, + }) + + const card = screen.getByText(/feature\.conversationOpener\.title/).closest('[class]')! + fireEvent.mouseEnter(card) + fireEvent.click(screen.getByText(/openingStatement\.writeOpener/)) + + const modalCall = mockSetShowOpeningModal.mock.calls[0][0] + modalCall.onCancelCallback() + + expect(onChange).toHaveBeenCalled() + }) + + it('should show info and hide when hovering over enabled feature', () => { + renderWithProvider({}, { + opening: { enabled: true, opening_statement: 'Welcome!' }, + }) + + // Before hover, opening statement visible + expect(screen.getByText('Welcome!')).toBeInTheDocument() + + const card = screen.getByText(/feature\.conversationOpener\.title/).closest('[class]')! + fireEvent.mouseEnter(card) + + // After hover, button visible, statement hidden + expect(screen.getByText(/openingStatement\.writeOpener/)).toBeInTheDocument() + + fireEvent.mouseLeave(card) + + // After leave, statement visible again + expect(screen.getByText('Welcome!')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/features/new-feature-panel/conversation-opener/modal.spec.tsx b/web/app/components/base/features/new-feature-panel/conversation-opener/modal.spec.tsx new file mode 100644 index 0000000000..c5acda4bd5 --- /dev/null +++ b/web/app/components/base/features/new-feature-panel/conversation-opener/modal.spec.tsx @@ -0,0 +1,510 @@ +import type { OpeningStatement } from '@/app/components/base/features/types' +import type { InputVar } from '@/app/components/workflow/types' +import { act, fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { InputVarType } from '@/app/components/workflow/types' +import OpeningSettingModal from './modal' + +const getPromptEditor = () => { + const editor = document.querySelector('[data-lexical-editor="true"]') + expect(editor).toBeInTheDocument() + return editor as HTMLElement +} + +vi.mock('@/utils/var', () => ({ + checkKeys: (_keys: string[]) => ({ isValid: true }), + getNewVar: (key: string, type: string) => ({ key, name: key, type, required: true }), +})) + +vi.mock('@/app/components/app/configuration/config-prompt/confirm-add-var', () => ({ + default: ({ varNameArr, onConfirm, onCancel }: { + varNameArr: string[] + onConfirm: () => void + onCancel: () => void + }) => ( +
+ {varNameArr.join(',')} + + +
+ ), +})) + +vi.mock('react-sortablejs', () => ({ + ReactSortable: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +const defaultData: OpeningStatement = { + enabled: true, + opening_statement: 'Hello, how can I help?', + suggested_questions: ['Question 1', 'Question 2'], +} + +const createMockInputVar = (overrides: Partial = {}): InputVar => ({ + variable: 'name', + label: 'Name', + type: InputVarType.textInput, + required: true, + ...overrides, +}) + +describe('OpeningSettingModal', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render the modal title', async () => { + await render( + , + ) + + expect(screen.getByText(/feature\.conversationOpener\.title/)).toBeInTheDocument() + }) + + it('should render the opening statement in the editor', async () => { + await render( + , + ) + + expect(getPromptEditor()).toHaveTextContent('Hello, how can I help?') + }) + + it('should render suggested questions', async () => { + await render( + , + ) + + expect(screen.getByDisplayValue('Question 1')).toBeInTheDocument() + expect(screen.getByDisplayValue('Question 2')).toBeInTheDocument() + }) + + it('should render cancel and save buttons', async () => { + await render( + , + ) + + expect(screen.getByText(/operation\.cancel/)).toBeInTheDocument() + expect(screen.getByText(/operation\.save/)).toBeInTheDocument() + }) + + it('should call onCancel when cancel is clicked', async () => { + const onCancel = vi.fn() + await render( + , + ) + + await userEvent.click(screen.getByText(/operation\.cancel/)) + + expect(onCancel).toHaveBeenCalled() + }) + + it('should call onCancel when close icon is clicked', async () => { + const onCancel = vi.fn() + await render( + , + ) + + const closeButton = screen.getByTestId('close-modal') + await userEvent.click(closeButton) + + expect(onCancel).toHaveBeenCalled() + }) + + it('should call onCancel when close icon receives Enter key', async () => { + const onCancel = vi.fn() + await render( + , + ) + + const closeButton = screen.getByTestId('close-modal') + closeButton.focus() + await userEvent.keyboard('{Enter}') + + expect(onCancel).toHaveBeenCalledTimes(1) + }) + + it('should call onCancel when close icon receives Space key', async () => { + const onCancel = vi.fn() + await render( + , + ) + + const closeButton = screen.getByTestId('close-modal') + closeButton.focus() + fireEvent.keyDown(closeButton, { key: ' ' }) + + expect(onCancel).toHaveBeenCalledTimes(1) + }) + + it('should call onSave with updated data when save is clicked', async () => { + const onSave = vi.fn() + await render( + , + ) + + await userEvent.click(screen.getByText(/operation\.save/)) + + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + opening_statement: 'Hello, how can I help?', + suggested_questions: ['Question 1', 'Question 2'], + })) + }) + + it('should disable save when opening statement is empty', async () => { + await render( + , + ) + + const saveButton = screen.getByText(/operation\.save/).closest('button') + expect(saveButton).toBeDisabled() + }) + + it('should add a new suggested question', async () => { + await render( + , + ) + + // Before adding: 2 existing questions + expect(screen.getByDisplayValue('Question 1')).toBeInTheDocument() + expect(screen.getByDisplayValue('Question 2')).toBeInTheDocument() + + await userEvent.click(screen.getByText(/variableConfig\.addOption/)) + + // After adding: the 2 existing questions still present plus 1 new empty one + expect(screen.getByDisplayValue('Question 1')).toBeInTheDocument() + expect(screen.getByDisplayValue('Question 2')).toBeInTheDocument() + // The new empty question renders as an input with empty value + const allInputs = screen.getAllByDisplayValue('') + expect(allInputs.length).toBeGreaterThanOrEqual(1) + }) + + it('should delete a suggested question via save verification', async () => { + const onSave = vi.fn() + await render( + , + ) + + // Question should be present initially + expect(screen.getByDisplayValue('Question 1')).toBeInTheDocument() + + const deleteIconWrapper = screen.getByTestId('delete-question-Question 1').parentElement + expect(deleteIconWrapper).toBeTruthy() + await userEvent.click(deleteIconWrapper!) + + // After deletion, Question 1 should be gone + expect(screen.queryByDisplayValue('Question 1')).not.toBeInTheDocument() + }) + + it('should update a suggested question value', async () => { + await render( + , + ) + + const input = screen.getByDisplayValue('Question 1') + await userEvent.clear(input) + await userEvent.type(input, 'Updated Question') + + expect(input).toHaveValue('Updated Question') + }) + + it('should show confirm dialog when variables are not in prompt', async () => { + await render( + , + ) + + await userEvent.click(screen.getByText(/operation\.save/)) + + expect(screen.getByTestId('confirm-add-var')).toBeInTheDocument() + }) + + it('should save without variable check when confirm cancel is clicked', async () => { + const onSave = vi.fn() + await render( + , + ) + + await userEvent.click(screen.getByText(/operation\.save/)) + await userEvent.click(screen.getByTestId('cancel-add')) + + expect(onSave).toHaveBeenCalled() + }) + + it('should show question count', async () => { + await render( + , + ) + + // Count is displayed as "2/10" across child elements + expect(screen.getByText(/openingStatement\.openingQuestion/)).toBeInTheDocument() + }) + + it('should call onAutoAddPromptVariable when confirm add is clicked', async () => { + const onAutoAddPromptVariable = vi.fn() + await render( + , + ) + + await userEvent.click(screen.getByText(/operation\.save/)) + // Confirm add var dialog should appear + await userEvent.click(screen.getByTestId('confirm-add')) + + expect(onAutoAddPromptVariable).toHaveBeenCalled() + }) + + it('should not show add button when max questions reached', async () => { + const questionsAtMax: OpeningStatement = { + enabled: true, + opening_statement: 'Hello', + suggested_questions: Array.from({ length: 10 }, (_, i) => `Q${i + 1}`), + } + await render( + , + ) + + expect(screen.queryByText(/variableConfig\.addOption/)).not.toBeInTheDocument() + }) + + it('should apply and remove focused styling on question input focus/blur', async () => { + await render( + , + ) + + const input = screen.getByDisplayValue('Question 1') as HTMLInputElement + const questionRow = input.parentElement + + expect(input).toBeInTheDocument() + expect(questionRow).not.toHaveClass('border-components-input-border-active') + + await userEvent.click(input) + expect(questionRow).toHaveClass('border-components-input-border-active') + + // Tab press to blur + await userEvent.tab() + expect(questionRow).not.toHaveClass('border-components-input-border-active') + }) + + it('should apply and remove deleting styling on delete icon hover', async () => { + await render( + , + ) + + const questionInput = screen.getByDisplayValue('Question 1') as HTMLInputElement + const questionRow = questionInput.parentElement + const deleteIconWrapper = screen.getByTestId('delete-question-Question 1').parentElement + + expect(questionRow).not.toHaveClass('border-components-input-border-destructive') + expect(deleteIconWrapper).toBeTruthy() + + await userEvent.hover(deleteIconWrapper!) + expect(questionRow).toHaveClass('border-components-input-border-destructive') + + await userEvent.unhover(deleteIconWrapper!) + expect(questionRow).not.toHaveClass('border-components-input-border-destructive') + }) + + it('should handle save with empty suggested questions', async () => { + const onSave = vi.fn() + await render( + , + ) + + await userEvent.click(screen.getByText(/operation\.save/)) + + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + suggested_questions: [], + })) + }) + + it('should not save when opening statement is only whitespace', async () => { + const onSave = vi.fn() + await render( + , + ) + + await userEvent.click(screen.getByText(/operation\.save/)) + + expect(onSave).not.toHaveBeenCalled() + }) + + it('should skip variable check when variables match prompt variables', async () => { + const onSave = vi.fn() + await render( + , + ) + + await userEvent.click(screen.getByText(/operation\.save/)) + + // Variable is in promptVariables, so no confirm dialog + expect(screen.queryByTestId('confirm-add-var')).not.toBeInTheDocument() + expect(onSave).toHaveBeenCalled() + }) + + it('should skip variable check when variables match workflow variables', async () => { + const onSave = vi.fn() + await render( + , + ) + + await userEvent.click(screen.getByText(/operation\.save/)) + + // Variable matches workflow variables, so no confirm dialog + expect(screen.queryByTestId('confirm-add-var')).not.toBeInTheDocument() + expect(onSave).toHaveBeenCalled() + }) + + it('should show confirm dialog when variables not in workflow variables', async () => { + await render( + , + ) + + await userEvent.click(screen.getByText(/operation\.save/)) + + expect(screen.getByTestId('confirm-add-var')).toBeInTheDocument() + }) + + it('should use updated opening statement after prop changes', async () => { + const onSave = vi.fn() + const view = await render( + , + ) + + await act(async () => { + view.rerender( + , + ) + await Promise.resolve() + await new Promise(resolve => setTimeout(resolve, 0)) + }) + + await userEvent.click(screen.getByText(/operation\.save/)) + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + opening_statement: 'New greeting!', + })) + }) + + it('should render empty opening statement with placeholder in editor', async () => { + await render( + , + ) + + const editor = getPromptEditor() + expect(editor.textContent?.trim()).toBe('') + expect(screen.getByText('appDebug.openingStatement.placeholder')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx b/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx index 79520134a4..a7f1e013f5 100644 --- a/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx +++ b/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx @@ -1,7 +1,6 @@ import type { OpeningStatement } from '@/app/components/base/features/types' import type { InputVar } from '@/app/components/workflow/types' import type { PromptVariable } from '@/models/debug' -import { RiAddLine, RiAsterisk, RiCloseLine, RiDeleteBinLine, RiDraggable } from '@remixicon/react' import { useBoolean } from 'ahooks' import { noop } from 'es-toolkit/function' import { produce } from 'immer' @@ -139,7 +138,7 @@ const OpeningSettingModal = ({ )} key={index} > - + setDeletingID(index)} onMouseLeave={() => setDeletingID(null)} > - + ) @@ -175,10 +174,10 @@ const OpeningSettingModal = ({ {tempSuggestedQuestions.length < MAX_QUESTION_NUM && (
{ setTempSuggestedQuestions([...tempSuggestedQuestions, '']) }} - className="mt-1 flex h-9 cursor-pointer items-center gap-2 rounded-lg bg-components-button-tertiary-bg px-3 text-components-button-tertiary-text hover:bg-components-button-tertiary-bg-hover" + className="mt-1 flex h-9 cursor-pointer items-center gap-2 rounded-lg bg-components-button-tertiary-bg px-3 text-components-button-tertiary-text hover:bg-components-button-tertiary-bg-hover" > - -
{t('variableConfig.addOption', { ns: 'appDebug' })}
+ +
{t('variableConfig.addOption', { ns: 'appDebug' })}
)} @@ -192,12 +191,26 @@ const OpeningSettingModal = ({ className="!mt-14 !w-[640px] !max-w-none !bg-components-panel-bg-blur !p-6" >
-
{t('feature.conversationOpener.title', { ns: 'appDebug' })}
-
+
{t('feature.conversationOpener.title', { ns: 'appDebug' })}
+
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onCancel() + } + }} + > + +
- +
{ + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render children when show is true', () => { + render( + +
Content
+
, + ) + + expect(screen.getByTestId('content')).toBeInTheDocument() + }) + + it('should not render children when show is false', () => { + render( + +
Content
+
, + ) + + expect(screen.queryByTestId('content')).not.toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should apply workflow styles by default', () => { + render( + +
Content
+
, + ) + + const wrapper = screen.getByTestId('content').parentElement + expect(wrapper).toHaveClass('rounded-l-2xl') + expect(wrapper).not.toHaveClass('rounded-2xl') + }) + + it('should apply non-workflow styles when inWorkflow is false', () => { + render( + +
Content
+
, + ) + + const content = screen.getByTestId('content') + const panel = content.parentElement + const layoutContainer = screen.getByTestId('dialog-layout-container') + + expect(layoutContainer).toHaveClass('pr-2') + expect(layoutContainer).toHaveClass('pt-[64px]') + expect(layoutContainer).not.toHaveClass('pt-[112px]') + + expect(panel).toHaveClass('rounded-2xl') + expect(panel).toHaveClass('border-[0.5px]') + expect(panel).not.toHaveClass('rounded-l-2xl') + }) + + it('should accept custom className', () => { + render( + +
Content
+
, + ) + + const wrapper = screen.getByTestId('content').parentElement + expect(wrapper).toHaveClass('custom-class') + }) + }) + + describe('Close behavior', () => { + it('should call onClose when escape is pressed', async () => { + const onClose = vi.fn() + + render( + +
Content
+
, + ) + + fireEvent.keyDown(document, { key: 'Escape' }) + + await waitFor(() => { + expect(onClose).toHaveBeenCalledTimes(1) + }) + }) + + it('should not throw when escape is pressed without onClose', () => { + render( + +
Content
+
, + ) + + expect(() => { + fireEvent.keyDown(document, { key: 'Escape' }) + }).not.toThrow() + }) + }) +}) diff --git a/web/app/components/base/features/new-feature-panel/dialog-wrapper.tsx b/web/app/components/base/features/new-feature-panel/dialog-wrapper.tsx index 5a312d1b64..c052b27136 100644 --- a/web/app/components/base/features/new-feature-panel/dialog-wrapper.tsx +++ b/web/app/components/base/features/new-feature-panel/dialog-wrapper.tsx @@ -33,12 +33,12 @@ const DialogWrapper = ({
-
+
void + hideEditEntrance?: boolean + } = {}, + featureOverrides?: Partial, +) => { + const features = { ...defaultFeatures, ...featureOverrides } + return render( + + + , + ) +} + +describe('FeatureBar', () => { + describe('Empty State', () => { + it('should render empty state when no features are enabled', () => { + renderWithProvider() + + expect(screen.getByText(/feature\.bar\.empty/)).toBeInTheDocument() + }) + + it('should call onFeatureBarClick when empty state is clicked', () => { + const onFeatureBarClick = vi.fn() + + renderWithProvider({ onFeatureBarClick }) + fireEvent.click(screen.getByText(/feature\.bar\.empty/)) + + expect(onFeatureBarClick).toHaveBeenCalledWith(true) + }) + }) + + describe('Enabled Features', () => { + it('should show enabled text when moreLikeThis is enabled', () => { + renderWithProvider({}, { + moreLikeThis: { enabled: true }, + }) + + expect(screen.getByText(/feature\.bar\.enableText/)).toBeInTheDocument() + }) + + it('should show manage button when features are enabled', () => { + renderWithProvider({}, { + moreLikeThis: { enabled: true }, + }) + + expect(screen.getByText(/feature\.bar\.manage/)).toBeInTheDocument() + }) + + it('should hide manage button when hideEditEntrance is true', () => { + renderWithProvider({ hideEditEntrance: true }, { + moreLikeThis: { enabled: true }, + }) + + expect(screen.queryByText(/feature\.bar\.manage/)).not.toBeInTheDocument() + }) + + it('should call onFeatureBarClick when manage button is clicked', () => { + const onFeatureBarClick = vi.fn() + + renderWithProvider({ onFeatureBarClick }, { + moreLikeThis: { enabled: true }, + }) + fireEvent.click(screen.getByText(/feature\.bar\.manage/)) + + expect(onFeatureBarClick).toHaveBeenCalledWith(true) + }) + }) + + describe('Chat Mode Features', () => { + it('should show enabled text when citation is enabled in chat mode', () => { + renderWithProvider({ isChatMode: true }, { + citation: { enabled: true }, + }) + + expect(screen.getByText(/feature\.bar\.enableText/)).toBeInTheDocument() + }) + + it('should show empty state when citation is enabled but not in chat mode', () => { + renderWithProvider({ isChatMode: false }, { + citation: { enabled: true }, + }) + + expect(screen.getByText(/feature\.bar\.empty/)).toBeInTheDocument() + }) + + it('should show enabled text when opening is enabled in chat mode', () => { + renderWithProvider({ isChatMode: true }, { + opening: { enabled: true }, + }) + + expect(screen.getByText(/feature\.bar\.enableText/)).toBeInTheDocument() + }) + + it('should show enabled text when file is enabled with showFileUpload', () => { + renderWithProvider({ showFileUpload: true }, { + file: { enabled: true }, + }) + + expect(screen.getByText(/feature\.bar\.enableText/)).toBeInTheDocument() + }) + + it('should show empty state when file is enabled but showFileUpload is false', () => { + renderWithProvider({ showFileUpload: false }, { + file: { enabled: true }, + }) + + expect(screen.getByText(/feature\.bar\.empty/)).toBeInTheDocument() + }) + + it('should show enabled text when speech2text is enabled in chat mode', () => { + renderWithProvider({ isChatMode: true }, { + speech2text: { enabled: true }, + }) + + expect(screen.getByText(/feature\.bar\.enableText/)).toBeInTheDocument() + }) + + it('should show enabled text when text2speech is enabled', () => { + renderWithProvider({ isChatMode: true }, { + text2speech: { enabled: true }, + }) + + expect(screen.getByText(/feature\.bar\.enableText/)).toBeInTheDocument() + }) + + it('should show enabled text when moderation is enabled', () => { + renderWithProvider({ isChatMode: true }, { + moderation: { enabled: true }, + }) + + expect(screen.getByText(/feature\.bar\.enableText/)).toBeInTheDocument() + }) + + it('should show enabled text when suggested is enabled', () => { + renderWithProvider({ isChatMode: true }, { + suggested: { enabled: true }, + }) + + expect(screen.getByText(/feature\.bar\.enableText/)).toBeInTheDocument() + }) + + it('should show enabled text when annotationReply is enabled in chat mode', () => { + renderWithProvider({ isChatMode: true }, { + annotationReply: { enabled: true }, + }) + + expect(screen.getByText(/feature\.bar\.enableText/)).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/base/features/new-feature-panel/feature-card.spec.tsx b/web/app/components/base/features/new-feature-panel/feature-card.spec.tsx new file mode 100644 index 0000000000..1f4f1b9fad --- /dev/null +++ b/web/app/components/base/features/new-feature-panel/feature-card.spec.tsx @@ -0,0 +1,103 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' +import FeatureCard from './feature-card' + +describe('FeatureCard', () => { + const defaultProps = { + icon:
icon
, + title: 'Test Feature', + value: false, + } + + it('should render icon and title', () => { + render() + + expect(screen.getByTestId('icon')).toBeInTheDocument() + expect(screen.getByText(/Test Feature/)).toBeInTheDocument() + }) + + it('should render description when provided', () => { + render() + + expect(screen.getByText(/A test description/)).toBeInTheDocument() + }) + + it('should not render description when not provided', () => { + render() + + expect(screen.queryByText(/description/i)).not.toBeInTheDocument() + }) + + it('should render a switch toggle', () => { + render() + + expect(screen.getByRole('switch')).toBeInTheDocument() + }) + + it('should call onChange when switch is toggled', () => { + const onChange = vi.fn() + render() + + fireEvent.click(screen.getByRole('switch')) + + expect(onChange).toHaveBeenCalledTimes(1) + }) + + it('should render tooltip when provided', () => { + render() + + // Tooltip text is passed as prop, verifying the component renders with it + expect(screen.getByText(/Test Feature/)).toBeInTheDocument() + }) + + it('should not render tooltip when not provided', () => { + render() + + // Without tooltip, the title should still render + expect(screen.getByText(/Test Feature/)).toBeInTheDocument() + }) + + it('should render children when provided', () => { + render( + +
Child
+
, + ) + + expect(screen.getByTestId('child-content')).toBeInTheDocument() + }) + + it('should call onMouseEnter when hovering', () => { + const onMouseEnter = vi.fn() + render() + + const card = screen.getByText(/Test Feature/).closest('[class]')! + fireEvent.mouseEnter(card) + + expect(onMouseEnter).toHaveBeenCalledTimes(1) + }) + + it('should call onMouseLeave when mouse leaves', () => { + const onMouseLeave = vi.fn() + render() + + const card = screen.getByText(/Test Feature/).closest('[class]')! + fireEvent.mouseLeave(card) + + expect(onMouseLeave).toHaveBeenCalledTimes(1) + }) + + it('should handle disabled state', () => { + render() + + const switchElement = screen.getByRole('switch') + expect(switchElement).toBeInTheDocument() + }) + + it('should not call onChange when onChange is not provided', () => { + render() + + // Should not throw when switch is clicked without onChange + expect(() => fireEvent.click(screen.getByRole('switch'))).not.toThrow() + }) +}) diff --git a/web/app/components/base/features/new-feature-panel/file-upload/index.spec.tsx b/web/app/components/base/features/new-feature-panel/file-upload/index.spec.tsx new file mode 100644 index 0000000000..b39156c196 --- /dev/null +++ b/web/app/components/base/features/new-feature-panel/file-upload/index.spec.tsx @@ -0,0 +1,191 @@ +import type { Features } from '../../types' +import type { OnFeaturesChange } from '@/app/components/base/features/types' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { FeaturesProvider } from '../../context' +import FileUpload from './index' + +vi.mock('@/service/use-common', () => ({ + useFileUploadConfig: () => ({ data: undefined }), +})) + +const defaultFeatures: Features = { + moreLikeThis: { enabled: false }, + opening: { enabled: false }, + suggested: { enabled: false }, + text2speech: { enabled: false }, + speech2text: { enabled: false }, + citation: { enabled: false }, + moderation: { enabled: false }, + file: { enabled: false }, + annotationReply: { enabled: false }, +} + +const renderWithProvider = ( + props: { disabled?: boolean, onChange?: OnFeaturesChange } = {}, + featureOverrides?: Partial, +) => { + const features = { ...defaultFeatures, ...featureOverrides } + return render( + + + , + ) +} + +describe('FileUpload', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render the file upload title', () => { + renderWithProvider() + + expect(screen.getByText(/feature\.fileUpload\.title/)).toBeInTheDocument() + }) + + it('should render description when disabled', () => { + renderWithProvider() + + expect(screen.getByText(/feature\.fileUpload\.description/)).toBeInTheDocument() + }) + + it('should render a switch toggle', () => { + renderWithProvider() + + expect(screen.getByRole('switch')).toBeInTheDocument() + }) + + it('should call onChange when toggled', () => { + const onChange = vi.fn() + renderWithProvider({ onChange }) + + fireEvent.click(screen.getByRole('switch')) + + expect(onChange).toHaveBeenCalled() + }) + + it('should show supported types when enabled', () => { + renderWithProvider({}, { + file: { + enabled: true, + allowed_file_types: ['image', 'document'], + number_limits: 5, + }, + }) + + expect(screen.getByText('image,document')).toBeInTheDocument() + }) + + it('should show number limits when enabled', () => { + renderWithProvider({}, { + file: { + enabled: true, + allowed_file_types: ['image'], + number_limits: 3, + }, + }) + + expect(screen.getByText('3')).toBeInTheDocument() + }) + + it('should show dash when no allowed file types', () => { + renderWithProvider({}, { + file: { + enabled: true, + }, + }) + + expect(screen.getByText('-')).toBeInTheDocument() + }) + + it('should show settings button when hovering', () => { + renderWithProvider({}, { + file: { enabled: true }, + }) + + const card = screen.getByText(/feature\.fileUpload\.title/).closest('[class]')! + fireEvent.mouseEnter(card) + + expect(screen.getByText(/operation\.settings/)).toBeInTheDocument() + }) + + it('should open setting modal when settings is clicked', async () => { + renderWithProvider({}, { + file: { enabled: true }, + }) + + const card = screen.getByText(/feature\.fileUpload\.title/).closest('[class]')! + fireEvent.mouseEnter(card) + fireEvent.click(screen.getByText(/operation\.settings/)) + + await waitFor(() => { + expect(screen.getByText(/feature\.fileUpload\.modalTitle/)).toBeInTheDocument() + }) + }) + + it('should show supported types label when enabled', () => { + renderWithProvider({}, { + file: { + enabled: true, + allowed_file_types: ['image'], + number_limits: 3, + }, + }) + + expect(screen.getByText(/feature\.fileUpload\.supportedTypes/)).toBeInTheDocument() + expect(screen.getByText(/feature\.fileUpload\.numberLimit/)).toBeInTheDocument() + }) + + it('should hide info display when hovering over enabled feature', () => { + renderWithProvider({}, { + file: { + enabled: true, + allowed_file_types: ['image'], + number_limits: 3, + }, + }) + + const card = screen.getByText(/feature\.fileUpload\.title/).closest('[class]')! + fireEvent.mouseEnter(card) + + // Info display should be hidden, settings button should appear + expect(screen.queryByText(/feature\.fileUpload\.supportedTypes/)).not.toBeInTheDocument() + expect(screen.getByText(/operation\.settings/)).toBeInTheDocument() + }) + + it('should show info display again when mouse leaves', () => { + renderWithProvider({}, { + file: { + enabled: true, + allowed_file_types: ['image'], + number_limits: 3, + }, + }) + + const card = screen.getByText(/feature\.fileUpload\.title/).closest('[class]')! + fireEvent.mouseEnter(card) + fireEvent.mouseLeave(card) + + expect(screen.getByText(/feature\.fileUpload\.supportedTypes/)).toBeInTheDocument() + }) + + it('should close setting modal when cancel is clicked', async () => { + renderWithProvider({}, { + file: { enabled: true }, + }) + + const card = screen.getByText(/feature\.fileUpload\.title/).closest('[class]')! + fireEvent.mouseEnter(card) + fireEvent.click(screen.getByText(/operation\.settings/)) + + await waitFor(() => { + expect(screen.getByText(/feature\.fileUpload\.modalTitle/)).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button', { name: /operation\.cancel/ })) + + await waitFor(() => { + expect(screen.queryByText(/feature\.fileUpload\.modalTitle/)).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/base/features/new-feature-panel/file-upload/setting-content.spec.tsx b/web/app/components/base/features/new-feature-panel/file-upload/setting-content.spec.tsx new file mode 100644 index 0000000000..ca5b4677bf --- /dev/null +++ b/web/app/components/base/features/new-feature-panel/file-upload/setting-content.spec.tsx @@ -0,0 +1,204 @@ +import type { Features } from '../../types' +import type { OnFeaturesChange } from '@/app/components/base/features/types' +import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { TransferMethod } from '@/types/app' +import { FeaturesProvider } from '../../context' +import SettingContent from './setting-content' + +vi.mock('@/app/components/workflow/nodes/_base/components/file-upload-setting', () => ({ + default: ({ payload, onChange }: { payload: Record, onChange: (p: Record) => void }) => ( +
+ {JSON.stringify(payload)} + + +
+ ), +})) + +vi.mock('@/app/components/workflow/types', () => ({ + SupportUploadFileTypes: { + image: 'image', + }, +})) + +const defaultFeatures: Features = { + moreLikeThis: { enabled: false }, + opening: { enabled: false }, + suggested: { enabled: false }, + text2speech: { enabled: false }, + speech2text: { enabled: false }, + citation: { enabled: false }, + moderation: { enabled: false }, + file: { + enabled: true, + allowed_file_upload_methods: [TransferMethod.local_file], + allowed_file_types: ['image'], + allowed_file_extensions: ['.jpg'], + number_limits: 5, + }, + annotationReply: { enabled: false }, +} + +const renderWithProvider = ( + props: { imageUpload?: boolean, onClose?: () => void, onChange?: OnFeaturesChange } = {}, + featureOverrides?: Partial, +) => { + const features = { ...defaultFeatures, ...featureOverrides } + return render( + + + , + ) +} + +describe('SettingContent', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render file upload modal title', () => { + renderWithProvider() + + expect(screen.getByText(/feature\.fileUpload\.modalTitle/)).toBeInTheDocument() + }) + + it('should render image upload modal title when imageUpload is true', () => { + renderWithProvider({ imageUpload: true }) + + expect(screen.getByText(/feature\.imageUpload\.modalTitle/)).toBeInTheDocument() + }) + + it('should render FileUploadSetting component with payload from file feature', () => { + renderWithProvider() + + expect(screen.getByTestId('file-upload-setting')).toBeInTheDocument() + const payload = screen.getByTestId('payload') + expect(payload.textContent).toContain('"allowed_file_upload_methods":["local_file"]') + expect(payload.textContent).toContain('"allowed_file_types":["image"]') + expect(payload.textContent).toContain('"allowed_file_extensions":[".jpg"]') + expect(payload.textContent).toContain('"max_length":5') + }) + + it('should use fallback payload values when file feature is undefined', () => { + renderWithProvider({}, { file: undefined }) + + const payload = screen.getByTestId('payload') + expect(payload.textContent).toContain('"allowed_file_upload_methods":["local_file","remote_url"]') + expect(payload.textContent).toContain('"allowed_file_types":["image"]') + expect(payload.textContent).toContain('"max_length":3') + }) + + it('should render cancel and save buttons', () => { + renderWithProvider() + + expect(screen.getByText(/operation\.cancel/)).toBeInTheDocument() + expect(screen.getByText(/operation\.save/)).toBeInTheDocument() + }) + + it('should call onClose when close icon is clicked', () => { + const onClose = vi.fn() + renderWithProvider({ onClose }) + + const closeIconButton = screen.getByTestId('close-setting-modal') + expect(closeIconButton).toBeInTheDocument() + if (!closeIconButton) + throw new Error('Close icon button should exist') + + fireEvent.click(closeIconButton) + + expect(onClose).toHaveBeenCalled() + }) + + it('should call onClose when close icon receives Enter key', async () => { + const onClose = vi.fn() + renderWithProvider({ onClose }) + + const closeIconButton = screen.getByTestId('close-setting-modal') + closeIconButton.focus() + await userEvent.keyboard('{Enter}') + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('should call onClose when close icon receives Space key', async () => { + const onClose = vi.fn() + renderWithProvider({ onClose }) + + const closeIconButton = screen.getByTestId('close-setting-modal') + closeIconButton.focus() + fireEvent.keyDown(closeIconButton, { key: ' ' }) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('should call onClose when cancel button is clicked to close', () => { + const onClose = vi.fn() + renderWithProvider({ onClose }) + + // Use the cancel button to test the close behavior + fireEvent.click(screen.getByText(/operation\.cancel/)) + + expect(onClose).toHaveBeenCalled() + }) + + it('should call onChange when save is clicked', () => { + const onChange = vi.fn() + renderWithProvider({ onChange }) + + fireEvent.click(screen.getByText(/operation\.save/)) + + expect(onChange).toHaveBeenCalled() + }) + + it('should not throw when save is clicked without onChange', () => { + renderWithProvider() + + expect(() => { + fireEvent.click(screen.getByText(/operation\.save/)) + }).not.toThrow() + }) + + it('should disable save button when allowed file types are empty', () => { + const onChange = vi.fn() + renderWithProvider({ onChange }) + + fireEvent.click(screen.getByTestId('clear-file-types')) + + const saveButton = screen.getByRole('button', { name: /operation\.save/ }) + expect(saveButton).toBeDisabled() + + fireEvent.click(saveButton) + + expect(onChange).not.toHaveBeenCalled() + }) + + it('should update temp payload when FileUploadSetting onChange is called', () => { + renderWithProvider() + + // Click the change button in mock FileUploadSetting to trigger setTempPayload + fireEvent.click(screen.getByTestId('change-setting')) + + // The payload should be updated with the new allowed_file_types + const payload = screen.getByTestId('payload') + expect(payload.textContent).toContain('document') + }) +}) diff --git a/web/app/components/base/features/new-feature-panel/file-upload/setting-content.tsx b/web/app/components/base/features/new-feature-panel/file-upload/setting-content.tsx index f871cb6b02..b6674abf07 100644 --- a/web/app/components/base/features/new-feature-panel/file-upload/setting-content.tsx +++ b/web/app/components/base/features/new-feature-panel/file-upload/setting-content.tsx @@ -1,6 +1,5 @@ import type { OnFeaturesChange } from '@/app/components/base/features/types' import type { UploadFileSetting } from '@/app/components/workflow/types' -import { RiCloseLine } from '@remixicon/react' import { produce } from 'immer' import * as React from 'react' import { useCallback, useMemo, useState } from 'react' @@ -58,8 +57,22 @@ const SettingContent = ({ return ( <>
-
{!imageUpload ? t('feature.fileUpload.modalTitle', { ns: 'appDebug' }) : t('feature.imageUpload.modalTitle', { ns: 'appDebug' })}
-
+
{!imageUpload ? t('feature.fileUpload.modalTitle', { ns: 'appDebug' }) : t('feature.imageUpload.modalTitle', { ns: 'appDebug' })}
+
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onClose() + } + }} + > + +
({ + useFileUploadConfig: () => ({ data: undefined }), +})) + +const defaultFeatures: Features = { + moreLikeThis: { enabled: false }, + opening: { enabled: false }, + suggested: { enabled: false }, + text2speech: { enabled: false }, + speech2text: { enabled: false }, + citation: { enabled: false }, + moderation: { enabled: false }, + file: { + enabled: true, + allowed_file_upload_methods: [TransferMethod.local_file], + allowed_file_types: ['image'], + allowed_file_extensions: ['.jpg'], + number_limits: 5, + }, + annotationReply: { enabled: false }, +} + +const renderWithProvider = (ui: React.ReactNode) => { + return render( + + {ui} + , + ) +} + +describe('FileUploadSettings (setting-modal)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render children in trigger', () => { + renderWithProvider( + + + , + ) + + expect(screen.getByText('Upload Settings')).toBeInTheDocument() + }) + + it('should render SettingContent in portal', async () => { + renderWithProvider( + + + , + ) + + await waitFor(() => { + expect(screen.getByText(/feature\.fileUpload\.modalTitle/)).toBeInTheDocument() + }) + }) + + it('should call onOpen with toggle function when trigger is clicked', () => { + const onOpen = vi.fn() + renderWithProvider( + + + , + ) + + fireEvent.click(screen.getByText('Upload Settings')) + + expect(onOpen).toHaveBeenCalled() + // The toggle function should flip the open state + const toggleFn = onOpen.mock.calls[0][0] + expect(typeof toggleFn).toBe('function') + expect(toggleFn(false)).toBe(true) + expect(toggleFn(true)).toBe(false) + }) + + it('should not call onOpen when disabled', () => { + const onOpen = vi.fn() + renderWithProvider( + + + , + ) + + fireEvent.click(screen.getByText('Upload Settings')) + + expect(onOpen).not.toHaveBeenCalled() + }) + + it('should call onOpen with false when cancel is clicked', async () => { + const onOpen = vi.fn() + renderWithProvider( + + + , + ) + + await waitFor(() => { + expect(screen.getByText(/operation\.cancel/)).toBeInTheDocument() + }) + fireEvent.click(screen.getByRole('button', { name: /operation\.cancel/ })) + + expect(onOpen).toHaveBeenCalledWith(false) + }) + + it('should call onChange and close when save is clicked', async () => { + const onChange = vi.fn() + const onOpen = vi.fn() + renderWithProvider( + + + , + ) + + await waitFor(() => { + expect(screen.getByText(/operation\.save/)).toBeInTheDocument() + }) + fireEvent.click(screen.getByRole('button', { name: /operation\.save/ })) + + expect(onChange).toHaveBeenCalled() + expect(onOpen).toHaveBeenCalledWith(false) + }) + + it('should pass imageUpload prop to SettingContent', async () => { + renderWithProvider( + + + , + ) + + await waitFor(() => { + expect(screen.getByText(/feature\.imageUpload\.modalTitle/)).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/base/features/new-feature-panel/follow-up.spec.tsx b/web/app/components/base/features/new-feature-panel/follow-up.spec.tsx new file mode 100644 index 0000000000..56df44df8f --- /dev/null +++ b/web/app/components/base/features/new-feature-panel/follow-up.spec.tsx @@ -0,0 +1,48 @@ +import type { OnFeaturesChange } from '../types' +import { fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' +import { FeaturesProvider } from '../context' +import FollowUp from './follow-up' + +const renderWithProvider = (props: { disabled?: boolean, onChange?: OnFeaturesChange } = {}) => { + return render( + + + , + ) +} + +describe('FollowUp', () => { + it('should render the follow-up feature card', () => { + renderWithProvider() + + expect(screen.getByText(/feature\.suggestedQuestionsAfterAnswer\.title/)).toBeInTheDocument() + }) + + it('should render description text', () => { + renderWithProvider() + + expect(screen.getByText(/feature\.suggestedQuestionsAfterAnswer\.description/)).toBeInTheDocument() + }) + + it('should render a switch toggle', () => { + renderWithProvider() + + expect(screen.getByRole('switch')).toBeInTheDocument() + }) + + it('should call onChange when toggled', () => { + const onChange = vi.fn() + renderWithProvider({ onChange }) + + fireEvent.click(screen.getByRole('switch')) + + expect(onChange).toHaveBeenCalledTimes(1) + }) + + it('should not throw when onChange is not provided', () => { + renderWithProvider() + + expect(() => fireEvent.click(screen.getByRole('switch'))).not.toThrow() + }) +}) diff --git a/web/app/components/base/features/new-feature-panel/image-upload/index.spec.tsx b/web/app/components/base/features/new-feature-panel/image-upload/index.spec.tsx new file mode 100644 index 0000000000..1590efdd75 --- /dev/null +++ b/web/app/components/base/features/new-feature-panel/image-upload/index.spec.tsx @@ -0,0 +1,194 @@ +import type { Features } from '../../types' +import type { OnFeaturesChange } from '@/app/components/base/features/types' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { FeaturesProvider } from '../../context' +import ImageUpload from './index' + +vi.mock('@/service/use-common', () => ({ + useFileUploadConfig: () => ({ data: undefined }), +})) + +const defaultFeatures: Features = { + moreLikeThis: { enabled: false }, + opening: { enabled: false }, + suggested: { enabled: false }, + text2speech: { enabled: false }, + speech2text: { enabled: false }, + citation: { enabled: false }, + moderation: { enabled: false }, + file: { enabled: false }, + annotationReply: { enabled: false }, +} + +const renderWithProvider = ( + props: { disabled?: boolean, onChange?: OnFeaturesChange } = {}, + featureOverrides?: Partial, +) => { + const features = { ...defaultFeatures, ...featureOverrides } + return render( + + + , + ) +} + +describe('ImageUpload', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render the image upload title', () => { + renderWithProvider() + + expect(screen.getByText(/feature\.imageUpload\.title/)).toBeInTheDocument() + }) + + it('should render LEGACY badge', () => { + renderWithProvider() + + expect(screen.getByText('LEGACY')).toBeInTheDocument() + }) + + it('should render description when disabled', () => { + renderWithProvider() + + expect(screen.getByText(/feature\.imageUpload\.description/)).toBeInTheDocument() + }) + + it('should render a switch toggle', () => { + renderWithProvider() + + expect(screen.getByRole('switch')).toBeInTheDocument() + }) + + it('should call onChange when toggled', () => { + const onChange = vi.fn() + renderWithProvider({ onChange }) + + fireEvent.click(screen.getByRole('switch')) + + expect(onChange).toHaveBeenCalled() + }) + + it('should show supported types when enabled', () => { + renderWithProvider({}, { + file: { + enabled: true, + allowed_file_types: ['image'], + number_limits: 3, + }, + }) + + expect(screen.getByText('image')).toBeInTheDocument() + }) + + it('should show number limits when enabled', () => { + renderWithProvider({}, { + file: { + enabled: true, + allowed_file_types: ['image'], + number_limits: 3, + }, + }) + + expect(screen.getByText('3')).toBeInTheDocument() + }) + + it('should show settings button when hovering', () => { + renderWithProvider({}, { + file: { enabled: true }, + }) + + const card = screen.getByText(/feature\.imageUpload\.title/).closest('[class]')! + fireEvent.mouseEnter(card) + + expect(screen.getByText(/operation\.settings/)).toBeInTheDocument() + }) + + it('should open image upload setting modal when settings is clicked', async () => { + renderWithProvider({}, { + file: { enabled: true }, + }) + + const card = screen.getByText(/feature\.imageUpload\.title/).closest('[class]')! + fireEvent.mouseEnter(card) + fireEvent.click(screen.getByText(/operation\.settings/)) + + await waitFor(() => { + expect(screen.getByText(/feature\.imageUpload\.modalTitle/)).toBeInTheDocument() + }) + }) + + it('should show supported types and number limit labels when enabled', () => { + renderWithProvider({}, { + file: { + enabled: true, + allowed_file_types: ['image'], + number_limits: 3, + }, + }) + + expect(screen.getByText(/feature\.imageUpload\.supportedTypes/)).toBeInTheDocument() + expect(screen.getByText(/feature\.imageUpload\.numberLimit/)).toBeInTheDocument() + }) + + it('should hide info display when hovering', () => { + renderWithProvider({}, { + file: { + enabled: true, + allowed_file_types: ['image'], + number_limits: 3, + }, + }) + + const card = screen.getByText(/feature\.imageUpload\.title/).closest('[class]')! + fireEvent.mouseEnter(card) + + expect(screen.queryByText(/feature\.imageUpload\.supportedTypes/)).not.toBeInTheDocument() + expect(screen.getByText(/operation\.settings/)).toBeInTheDocument() + }) + + it('should show info display again when mouse leaves', () => { + renderWithProvider({}, { + file: { + enabled: true, + allowed_file_types: ['image'], + number_limits: 3, + }, + }) + + const card = screen.getByText(/feature\.imageUpload\.title/).closest('[class]')! + fireEvent.mouseEnter(card) + fireEvent.mouseLeave(card) + + expect(screen.getByText(/feature\.imageUpload\.supportedTypes/)).toBeInTheDocument() + }) + + it('should show dash when no file types configured', () => { + renderWithProvider({}, { + file: { enabled: true }, + }) + + expect(screen.getByText('-')).toBeInTheDocument() + }) + + it('should close setting modal when cancel is clicked', async () => { + renderWithProvider({}, { + file: { enabled: true }, + }) + + const card = screen.getByText(/feature\.imageUpload\.title/).closest('[class]')! + fireEvent.mouseEnter(card) + fireEvent.click(screen.getByText(/operation\.settings/)) + + await waitFor(() => { + expect(screen.getByText(/feature\.imageUpload\.modalTitle/)).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button', { name: /operation\.cancel/ })) + + await waitFor(() => { + expect(screen.queryByText(/feature\.imageUpload\.modalTitle/)).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/base/features/new-feature-panel/index.spec.tsx b/web/app/components/base/features/new-feature-panel/index.spec.tsx new file mode 100644 index 0000000000..0122a148d3 --- /dev/null +++ b/web/app/components/base/features/new-feature-panel/index.spec.tsx @@ -0,0 +1,215 @@ +import type { Features } from '../types' +import { render, screen } from '@testing-library/react' +import { FeaturesProvider } from '../context' +import NewFeaturePanel from './index' + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn() }), + usePathname: () => '/app/test-app-id/configuration', +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useDefaultModel: (type: string) => { + if (type === 'speech2text' || type === 'tts') + return { data: { provider: 'openai', model: 'whisper-1' } } + return { data: null } + }, + useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({ + modelList: [{ provider: { provider: 'openai' }, models: [{ model: 'text-embedding-ada-002' }] }], + defaultModel: { provider: { provider: 'openai' }, model: 'text-embedding-ada-002' }, + currentModel: true, + }), +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/declarations', () => ({ + ModelTypeEnum: { + speech2text: 'speech2text', + tts: 'tts', + textEmbedding: 'text-embedding', + }, +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ + default: () =>
Model Selector
, +})) + +vi.mock('@/service/use-common', () => ({ + useCodeBasedExtensions: () => ({ data: undefined }), +})) + +const defaultFeatures: Features = { + moreLikeThis: { enabled: false }, + opening: { enabled: false }, + suggested: { enabled: false }, + text2speech: { enabled: false }, + speech2text: { enabled: false }, + citation: { enabled: false }, + moderation: { enabled: false }, + file: { enabled: false }, + annotationReply: { enabled: false }, +} + +const renderPanel = (props: Partial<{ + show: boolean + isChatMode: boolean + disabled: boolean + onChange: () => void + onClose: () => void + inWorkflow: boolean + showFileUpload: boolean +}> = {}) => { + return render( + + + , + ) +} + +describe('NewFeaturePanel', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should not render when show is false', () => { + renderPanel({ show: false }) + + expect(screen.queryByText(/common\.features/)).not.toBeInTheDocument() + }) + + it('should render header with title and description when show is true', () => { + renderPanel({ show: true }) + + expect(screen.getByText(/common\.featuresDescription/)).toBeInTheDocument() + expect(screen.getAllByText(/common\.features/).length).toBeGreaterThanOrEqual(1) + }) + }) + + describe('Chat Mode Features', () => { + it('should render conversation opener in chat mode', () => { + renderPanel({ isChatMode: true }) + + expect(screen.getByText(/feature\.conversationOpener\.title/)).toBeInTheDocument() + }) + + it('should render follow-up in chat mode', () => { + renderPanel({ isChatMode: true }) + + expect(screen.getByText(/feature\.suggestedQuestionsAfterAnswer\.title/)).toBeInTheDocument() + }) + + it('should render citation in chat mode', () => { + renderPanel({ isChatMode: true }) + + expect(screen.getByText(/feature\.citation\.title/)).toBeInTheDocument() + }) + + it('should render speech-to-text in chat mode when model is available', () => { + renderPanel({ isChatMode: true }) + + expect(screen.getByText(/feature\.speechToText\.title/)).toBeInTheDocument() + }) + + it('should render text-to-speech in chat mode when model is available', () => { + renderPanel({ isChatMode: true }) + + expect(screen.getByText(/feature\.textToSpeech\.title/)).toBeInTheDocument() + }) + + it('should render moderation in chat mode', () => { + renderPanel({ isChatMode: true }) + + expect(screen.getByText(/feature\.moderation\.title/)).toBeInTheDocument() + }) + }) + + describe('File Upload', () => { + it('should render file upload in chat mode with showFileUpload', () => { + renderPanel({ isChatMode: true, showFileUpload: true }) + + expect(screen.getByText(/feature\.fileUpload\.title/)).toBeInTheDocument() + }) + + it('should not render image upload in chat mode', () => { + renderPanel({ isChatMode: true, showFileUpload: true }) + + expect(screen.queryByText(/feature\.imageUpload\.title/)).not.toBeInTheDocument() + }) + + it('should render image upload in non-chat mode with showFileUpload', () => { + renderPanel({ isChatMode: false, showFileUpload: true }) + + expect(screen.queryByText(/feature\.fileUpload\.title/)).not.toBeInTheDocument() + expect(screen.getByText(/feature\.imageUpload\.title/)).toBeInTheDocument() + }) + + it('should not render file upload when showFileUpload is false', () => { + renderPanel({ isChatMode: true, showFileUpload: false }) + + expect(screen.queryByText(/feature\.fileUpload\.title/)).not.toBeInTheDocument() + expect(screen.queryByText(/feature\.imageUpload\.title/)).not.toBeInTheDocument() + }) + + it('should show file upload tip in chat mode with showFileUpload', () => { + renderPanel({ isChatMode: true, showFileUpload: true }) + + expect(screen.getByText(/common\.fileUploadTip/)).toBeInTheDocument() + }) + + it('should show image upload legacy tip in non-chat mode with showFileUpload', () => { + renderPanel({ isChatMode: false, showFileUpload: true }) + + expect(screen.getByText(/common\.ImageUploadLegacyTip/)).toBeInTheDocument() + }) + }) + + describe('MoreLikeThis Feature', () => { + it('should render MoreLikeThis in non-chat, non-workflow mode', () => { + renderPanel({ isChatMode: false, inWorkflow: false }) + + expect(screen.getByText(/feature\.moreLikeThis\.title/)).toBeInTheDocument() + }) + + it('should not render MoreLikeThis in chat mode', () => { + renderPanel({ isChatMode: true, inWorkflow: false }) + + expect(screen.queryByText(/feature\.moreLikeThis\.title/)).not.toBeInTheDocument() + }) + + it('should not render MoreLikeThis in workflow mode', () => { + renderPanel({ isChatMode: false, inWorkflow: true }) + + expect(screen.queryByText(/feature\.moreLikeThis\.title/)).not.toBeInTheDocument() + }) + }) + + describe('Annotation Reply Feature', () => { + it('should render AnnotationReply in chat mode when not in workflow', () => { + renderPanel({ isChatMode: true, inWorkflow: false }) + + expect(screen.getByText(/feature\.annotation\.title/)).toBeInTheDocument() + }) + + it('should not render AnnotationReply in workflow mode', () => { + renderPanel({ isChatMode: true, inWorkflow: true }) + + expect(screen.queryByText(/feature\.annotation\.title/)).not.toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should not show file upload tip when showFileUpload is false', () => { + renderPanel({ isChatMode: true, showFileUpload: false }) + + expect(screen.queryByText(/common\.fileUploadTip/)).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/base/features/new-feature-panel/moderation/form-generation.spec.tsx b/web/app/components/base/features/new-feature-panel/moderation/form-generation.spec.tsx new file mode 100644 index 0000000000..14f35dc6b4 --- /dev/null +++ b/web/app/components/base/features/new-feature-panel/moderation/form-generation.spec.tsx @@ -0,0 +1,133 @@ +import type { I18nText } from '@/i18n-config/language' +import type { CodeBasedExtensionForm } from '@/models/common' +import { fireEvent, render, screen } from '@testing-library/react' +import FormGeneration from './form-generation' + +const i18n = (en: string, zh = en): I18nText => + ({ 'en-US': en, 'zh-Hans': zh }) as unknown as I18nText + +const createForm = (overrides: Partial = {}): CodeBasedExtensionForm => ({ + type: 'text-input', + variable: 'api_key', + label: i18n('API Key', 'API 密钥'), + placeholder: 'Enter API key', + required: true, + options: [], + default: '', + max_length: 100, + ...overrides, +}) + +describe('FormGeneration', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render text-input form fields', () => { + const form = createForm() + render() + + expect(screen.getByText('API Key')).toBeInTheDocument() + expect(screen.getByPlaceholderText('Enter API key')).toBeInTheDocument() + }) + + it('should call onChange when text input value changes', () => { + const onChange = vi.fn() + const form = createForm() + render() + + fireEvent.change(screen.getByPlaceholderText('Enter API key'), { + target: { value: 'my-key' }, + }) + + expect(onChange).toHaveBeenCalledWith({ api_key: 'my-key' }) + }) + + it('should render paragraph form fields', () => { + const form = createForm({ + type: 'paragraph', + variable: 'description', + label: i18n('Description', '描述'), + placeholder: 'Enter description', + }) + render() + + expect(screen.getByText('Description')).toBeInTheDocument() + expect(screen.getByPlaceholderText('Enter description')).toBeInTheDocument() + }) + + it('should render select form fields', () => { + const form = createForm({ + type: 'select', + variable: 'model', + label: i18n('Model', '模型'), + options: [ + { label: i18n('GPT-4'), value: 'gpt-4' }, + { label: i18n('GPT-3.5'), value: 'gpt-3.5' }, + ], + }) + render() + + expect(screen.getByText('Model')).toBeInTheDocument() + }) + + it('should render multiple forms', () => { + const forms = [ + createForm({ variable: 'key1', label: i18n('Field 1', '字段1') }), + createForm({ variable: 'key2', label: i18n('Field 2', '字段2'), type: 'paragraph' }), + ] + render() + + expect(screen.getByText('Field 1')).toBeInTheDocument() + expect(screen.getByText('Field 2')).toBeInTheDocument() + }) + + it('should display existing values', () => { + const form = createForm() + render( + , + ) + + expect(screen.getByDisplayValue('existing-key')).toBeInTheDocument() + }) + + it('should call onChange when paragraph textarea value changes', () => { + const onChange = vi.fn() + const form = createForm({ + type: 'paragraph', + variable: 'description', + label: i18n('Description', '描述'), + placeholder: 'Enter description', + }) + render() + + fireEvent.change(screen.getByPlaceholderText('Enter description'), { + target: { value: 'my description' }, + }) + + expect(onChange).toHaveBeenCalledWith({ description: 'my description' }) + }) + + it('should call onChange when select option is chosen', () => { + const onChange = vi.fn() + const form = createForm({ + type: 'select', + variable: 'model', + label: i18n('Model', '模型'), + options: [ + { label: i18n('GPT-4'), value: 'gpt-4' }, + { label: i18n('GPT-3.5'), value: 'gpt-3.5' }, + ], + }) + render() + + fireEvent.click(screen.getByText(/placeholder\.select/)) + fireEvent.click(screen.getByText('GPT-4')) + + expect(onChange).toHaveBeenCalledWith({ model: 'gpt-4' }) + }) +}) diff --git a/web/app/components/base/features/new-feature-panel/moderation/index.spec.tsx b/web/app/components/base/features/new-feature-panel/moderation/index.spec.tsx new file mode 100644 index 0000000000..5c829f3560 --- /dev/null +++ b/web/app/components/base/features/new-feature-panel/moderation/index.spec.tsx @@ -0,0 +1,427 @@ +import type { Features } from '../../types' +import type { OnFeaturesChange } from '@/app/components/base/features/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { FeaturesProvider } from '../../context' +import Moderation from './index' + +const mockSetShowModerationSettingModal = vi.fn() +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowModerationSettingModal: mockSetShowModerationSettingModal, + }), +})) + +vi.mock('@/context/i18n', () => ({ + useLocale: () => 'en-US', +})) + +vi.mock('@/service/use-common', () => ({ + useCodeBasedExtensions: () => ({ data: { data: [] } }), +})) + +const defaultFeatures: Features = { + moreLikeThis: { enabled: false }, + opening: { enabled: false }, + suggested: { enabled: false }, + text2speech: { enabled: false }, + speech2text: { enabled: false }, + citation: { enabled: false }, + moderation: { enabled: false }, + file: { enabled: false }, + annotationReply: { enabled: false }, +} + +const renderWithProvider = ( + props: { disabled?: boolean, onChange?: OnFeaturesChange } = {}, + featureOverrides?: Partial, +) => { + const features = { ...defaultFeatures, ...featureOverrides } + return render( + + + , + ) +} + +describe('Moderation', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render the moderation title', () => { + renderWithProvider() + + expect(screen.getByText(/feature\.moderation\.title/)).toBeInTheDocument() + }) + + it('should render description when not enabled', () => { + renderWithProvider() + + expect(screen.getByText(/feature\.moderation\.description/)).toBeInTheDocument() + }) + + it('should render a switch toggle', () => { + renderWithProvider() + + expect(screen.getByRole('switch')).toBeInTheDocument() + }) + + it('should open moderation setting modal when enabled without type', () => { + renderWithProvider() + + fireEvent.click(screen.getByRole('switch')) + + expect(mockSetShowModerationSettingModal).toHaveBeenCalled() + }) + + it('should show provider info when enabled with openai_moderation type', () => { + renderWithProvider({}, { + moderation: { + enabled: true, + type: 'openai_moderation', + config: { + inputs_config: { enabled: true, preset_response: '' }, + outputs_config: { enabled: false, preset_response: '' }, + }, + }, + }) + + expect(screen.getByText(/feature\.moderation\.modal\.provider\.openai/)).toBeInTheDocument() + }) + + it('should show provider info when enabled with keywords type', () => { + renderWithProvider({}, { + moderation: { + enabled: true, + type: 'keywords', + config: { + inputs_config: { enabled: true, preset_response: '' }, + outputs_config: { enabled: false, preset_response: '' }, + }, + }, + }) + + expect(screen.getByText(/feature\.moderation\.modal\.provider\.keywords/)).toBeInTheDocument() + }) + + it('should show allEnabled when both inputs and outputs are enabled', () => { + renderWithProvider({}, { + moderation: { + enabled: true, + type: 'keywords', + config: { + inputs_config: { enabled: true, preset_response: '' }, + outputs_config: { enabled: true, preset_response: '' }, + }, + }, + }) + + expect(screen.getByText(/feature\.moderation\.allEnabled/)).toBeInTheDocument() + }) + + it('should show inputEnabled when only inputs are enabled', () => { + renderWithProvider({}, { + moderation: { + enabled: true, + type: 'keywords', + config: { + inputs_config: { enabled: true, preset_response: '' }, + outputs_config: { enabled: false, preset_response: '' }, + }, + }, + }) + + expect(screen.getByText(/feature\.moderation\.inputEnabled/)).toBeInTheDocument() + }) + + it('should show outputEnabled when only outputs are enabled', () => { + renderWithProvider({}, { + moderation: { + enabled: true, + type: 'keywords', + config: { + inputs_config: { enabled: false, preset_response: '' }, + outputs_config: { enabled: true, preset_response: '' }, + }, + }, + }) + + expect(screen.getByText(/feature\.moderation\.outputEnabled/)).toBeInTheDocument() + }) + + it('should show settings button when hovering over enabled feature', () => { + renderWithProvider({}, { + moderation: { + enabled: true, + type: 'keywords', + config: { + inputs_config: { enabled: true, preset_response: '' }, + }, + }, + }) + + const card = screen.getByText(/feature\.moderation\.title/).closest('[class]')! + fireEvent.mouseEnter(card) + + expect(screen.getByText(/operation\.settings/)).toBeInTheDocument() + }) + + it('should open moderation modal when settings button is clicked', () => { + renderWithProvider({}, { + moderation: { + enabled: true, + type: 'keywords', + config: { + inputs_config: { enabled: true, preset_response: '' }, + }, + }, + }) + + const card = screen.getByText(/feature\.moderation\.title/).closest('[class]')! + fireEvent.mouseEnter(card) + fireEvent.click(screen.getByText(/operation\.settings/)) + + expect(mockSetShowModerationSettingModal).toHaveBeenCalled() + }) + + it('should not open modal when disabled', () => { + renderWithProvider({ disabled: true }, { + moderation: { + enabled: true, + type: 'keywords', + config: { + inputs_config: { enabled: true, preset_response: '' }, + }, + }, + }) + + const card = screen.getByText(/feature\.moderation\.title/).closest('[class]')! + fireEvent.mouseEnter(card) + fireEvent.click(screen.getByText(/operation\.settings/)) + + expect(mockSetShowModerationSettingModal).not.toHaveBeenCalled() + }) + + it('should show api provider label when type is api', () => { + renderWithProvider({}, { + moderation: { + enabled: true, + type: 'api', + config: { + inputs_config: { enabled: true, preset_response: '' }, + }, + }, + }) + + expect(screen.getByText(/apiBasedExtension\.selector\.title/)).toBeInTheDocument() + }) + + it('should disable moderation and call onChange when switch is toggled off', () => { + const onChange = vi.fn() + renderWithProvider({ onChange }, { + moderation: { + enabled: true, + type: 'keywords', + config: { + inputs_config: { enabled: true, preset_response: '' }, + }, + }, + }) + + fireEvent.click(screen.getByRole('switch')) + + expect(onChange).toHaveBeenCalled() + }) + + it('should open modal with default config when enabling without existing type', () => { + const onChange = vi.fn() + renderWithProvider({ onChange }) + + fireEvent.click(screen.getByRole('switch')) + + expect(mockSetShowModerationSettingModal).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + enabled: true, + type: 'keywords', + }), + }), + ) + }) + + it('should invoke onSaveCallback from modal and update features', () => { + renderWithProvider() + + fireEvent.click(screen.getByRole('switch')) + + // Extract the onSaveCallback from the modal call + const modalCall = mockSetShowModerationSettingModal.mock.calls[0][0] + expect(modalCall.onSaveCallback).toBeDefined() + expect(modalCall.onCancelCallback).toBeDefined() + }) + + it('should invoke onCancelCallback from settings modal', () => { + const onChange = vi.fn() + renderWithProvider({ onChange }, { + moderation: { + enabled: true, + type: 'keywords', + config: { + inputs_config: { enabled: true, preset_response: '' }, + }, + }, + }) + + const card = screen.getByText(/feature\.moderation\.title/).closest('[class]')! + fireEvent.mouseEnter(card) + fireEvent.click(screen.getByText(/operation\.settings/)) + + const modalCall = mockSetShowModerationSettingModal.mock.calls[0][0] + modalCall.onCancelCallback() + + expect(onChange).toHaveBeenCalled() + }) + + it('should invoke onSaveCallback from settings modal', () => { + const onChange = vi.fn() + renderWithProvider({ onChange }, { + moderation: { + enabled: true, + type: 'keywords', + config: { + inputs_config: { enabled: true, preset_response: '' }, + }, + }, + }) + + const card = screen.getByText(/feature\.moderation\.title/).closest('[class]')! + fireEvent.mouseEnter(card) + fireEvent.click(screen.getByText(/operation\.settings/)) + + const modalCall = mockSetShowModerationSettingModal.mock.calls[0][0] + modalCall.onSaveCallback({ enabled: true, type: 'keywords', config: {} }) + + expect(onChange).toHaveBeenCalled() + }) + + it('should show code-based extension label for custom type', () => { + renderWithProvider({}, { + moderation: { + enabled: true, + type: 'custom-ext', + config: { + inputs_config: { enabled: true, preset_response: '' }, + }, + }, + }) + + // For unknown types, falls back to codeBasedExtensionList label or '-' + expect(screen.getByText('-')).toBeInTheDocument() + }) + + it('should not open setting modal when clicking settings button while disabled', () => { + renderWithProvider({ disabled: true }, { + moderation: { + enabled: true, + type: 'keywords', + config: { + inputs_config: { enabled: true, preset_response: '' }, + }, + }, + }) + + const card = screen.getByText(/feature\.moderation\.title/).closest('[class]')! + fireEvent.mouseEnter(card) + fireEvent.click(screen.getByText(/operation\.settings/)) + + // disabled check in handleOpenModerationSettingModal should prevent call + expect(mockSetShowModerationSettingModal).not.toHaveBeenCalled() + }) + + it('should invoke onSaveCallback from enable modal and update features', () => { + const onChange = vi.fn() + renderWithProvider({ onChange }) + + fireEvent.click(screen.getByRole('switch')) + + const modalCall = mockSetShowModerationSettingModal.mock.calls[0][0] + // Execute the onSaveCallback + modalCall.onSaveCallback({ enabled: true, type: 'keywords', config: {} }) + + expect(onChange).toHaveBeenCalled() + }) + + it('should invoke onCancelCallback from enable modal and set enabled false', () => { + const onChange = vi.fn() + renderWithProvider({ onChange }) + + fireEvent.click(screen.getByRole('switch')) + + const modalCall = mockSetShowModerationSettingModal.mock.calls[0][0] + // Execute the onCancelCallback + modalCall.onCancelCallback() + + expect(onChange).toHaveBeenCalled() + }) + + it('should not show modal when enabling with existing type', () => { + renderWithProvider({}, { + moderation: { + enabled: false, + type: 'keywords', + config: { + inputs_config: { enabled: true, preset_response: '' }, + }, + }, + }) + + fireEvent.click(screen.getByRole('switch')) + + // When type already exists, handleChange's first if-branch is skipped + // because features.moderation.type is already 'keywords' + // It should NOT call setShowModerationSettingModal for init + expect(mockSetShowModerationSettingModal).not.toHaveBeenCalled() + }) + + it('should hide info display when hovering over enabled feature', () => { + renderWithProvider({}, { + moderation: { + enabled: true, + type: 'keywords', + config: { + inputs_config: { enabled: true, preset_response: '' }, + outputs_config: { enabled: false, preset_response: '' }, + }, + }, + }) + + const card = screen.getByText(/feature\.moderation\.title/).closest('[class]')! + + // Info is visible before hover + expect(screen.getByText(/feature\.moderation\.modal\.provider\.keywords/)).toBeInTheDocument() + + fireEvent.mouseEnter(card) + + // Info hidden, settings button shown + expect(screen.getByText(/operation\.settings/)).toBeInTheDocument() + }) + + it('should show info display again when mouse leaves', () => { + renderWithProvider({}, { + moderation: { + enabled: true, + type: 'keywords', + config: { + inputs_config: { enabled: true, preset_response: '' }, + outputs_config: { enabled: false, preset_response: '' }, + }, + }, + }) + + const card = screen.getByText(/feature\.moderation\.title/).closest('[class]')! + fireEvent.mouseEnter(card) + fireEvent.mouseLeave(card) + + expect(screen.getByText(/feature\.moderation\.modal\.provider\.keywords/)).toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/features/new-feature-panel/moderation/moderation-content.spec.tsx b/web/app/components/base/features/new-feature-panel/moderation/moderation-content.spec.tsx new file mode 100644 index 0000000000..ef9bb8ebd4 --- /dev/null +++ b/web/app/components/base/features/new-feature-panel/moderation/moderation-content.spec.tsx @@ -0,0 +1,127 @@ +import type { ModerationContentConfig } from '@/models/debug' +import { fireEvent, render, screen } from '@testing-library/react' +import ModerationContent from './moderation-content' + +const defaultConfig: ModerationContentConfig = { + enabled: false, + preset_response: '', +} + +const renderComponent = (props: Partial<{ + title: string + info: string + showPreset: boolean + config: ModerationContentConfig + onConfigChange: (config: ModerationContentConfig) => void +}> = {}) => { + const onConfigChange = props.onConfigChange ?? vi.fn() + return render( + , + ) +} + +describe('ModerationContent', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render the title', () => { + renderComponent({ title: 'Input Content' }) + + expect(screen.getByText('Input Content')).toBeInTheDocument() + }) + + it('should render info text when provided', () => { + renderComponent({ info: 'Some info text' }) + + expect(screen.getByText('Some info text')).toBeInTheDocument() + }) + + it('should not render info when not provided', () => { + renderComponent() + + // When info is not provided, only the title "Test Title" should be shown + expect(screen.getByText(/Test Title/)).toBeInTheDocument() + expect(screen.queryByText(/Some info text/)).not.toBeInTheDocument() + }) + + it('should render a switch toggle', () => { + renderComponent() + + expect(screen.getByRole('switch')).toBeInTheDocument() + }) + + it('should call onConfigChange with enabled true when switch is toggled on', () => { + const onConfigChange = vi.fn() + renderComponent({ onConfigChange }) + + fireEvent.click(screen.getByRole('switch')) + + expect(onConfigChange).toHaveBeenCalledWith({ ...defaultConfig, enabled: true }) + }) + + it('should show preset textarea when enabled and showPreset is true', () => { + renderComponent({ + config: { enabled: true, preset_response: '' }, + showPreset: true, + }) + + expect(screen.getByText(/feature\.moderation\.modal\.content\.preset/)).toBeInTheDocument() + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + + it('should not show preset textarea when showPreset is false', () => { + renderComponent({ + config: { enabled: true, preset_response: '' }, + showPreset: false, + }) + + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + }) + + it('should call onConfigChange when preset_response is changed', () => { + const onConfigChange = vi.fn() + renderComponent({ + config: { enabled: true, preset_response: '' }, + onConfigChange, + }) + + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'test response' } }) + + expect(onConfigChange).toHaveBeenCalledWith({ + enabled: true, + preset_response: 'test response', + }) + }) + + it('should truncate preset_response to 100 characters', () => { + const onConfigChange = vi.fn() + const longText = 'a'.repeat(150) + renderComponent({ + config: { enabled: true, preset_response: '' }, + onConfigChange, + }) + + fireEvent.change(screen.getByRole('textbox'), { target: { value: longText } }) + + expect(onConfigChange).toHaveBeenCalledWith({ + enabled: true, + preset_response: 'a'.repeat(100), + }) + }) + + it('should display character count', () => { + renderComponent({ + config: { enabled: true, preset_response: 'hello' }, + }) + + expect(screen.getByText('5')).toBeInTheDocument() + expect(screen.getByText('100')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.spec.tsx b/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.spec.tsx new file mode 100644 index 0000000000..79098f6816 --- /dev/null +++ b/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.spec.tsx @@ -0,0 +1,787 @@ +import type { ModerationConfig } from '@/models/debug' +import { fireEvent, render, screen } from '@testing-library/react' +import ModerationSettingModal from './moderation-setting-modal' + +const mockNotify = vi.fn() +vi.mock('@/app/components/base/toast', () => ({ + useToastContext: () => ({ notify: mockNotify }), +})) + +const mockSetShowAccountSettingModal = vi.fn() +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowAccountSettingModal: mockSetShowAccountSettingModal, + }), +})) + +let mockCodeBasedExtensions: { data: { data: Record[] } } = { data: { data: [] } } +let mockModelProvidersData: { + data: { data: Record[] } + isPending: boolean + refetch: ReturnType +} = { + data: { + data: [{ + provider: 'langgenius/openai/openai', + system_configuration: { + enabled: true, + current_quota_type: 'paid', + quota_configurations: [{ quota_type: 'paid', is_valid: true }], + }, + custom_configuration: { status: 'active' }, + }], + }, + isPending: false, + refetch: vi.fn(), +} + +vi.mock('@/service/use-common', () => ({ + useCodeBasedExtensions: () => mockCodeBasedExtensions, + useModelProviders: () => mockModelProvidersData, +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/declarations', () => ({ + CustomConfigurationStatusEnum: { active: 'active' }, +})) + +vi.mock('@/app/components/header/account-setting/constants', () => ({ + ACCOUNT_SETTING_TAB: { PROVIDER: 'provider' }, +})) + +vi.mock('@/app/components/header/account-setting/api-based-extension-page/selector', () => ({ + default: ({ onChange }: { value: string, onChange: (v: string) => void }) => ( +
+ +
+ ), +})) + +const defaultData: ModerationConfig = { + enabled: true, + type: 'keywords', + config: { + keywords: 'bad\nword', + inputs_config: { enabled: true, preset_response: 'Input blocked' }, + outputs_config: { enabled: false, preset_response: '' }, + }, +} + +describe('ModerationSettingModal', () => { + const onSave = vi.fn() + beforeEach(() => { + vi.clearAllMocks() + mockCodeBasedExtensions = { data: { data: [] } } + mockModelProvidersData = { + data: { + data: [{ + provider: 'langgenius/openai/openai', + system_configuration: { + enabled: true, + current_quota_type: 'paid', + quota_configurations: [{ quota_type: 'paid', is_valid: true }], + }, + custom_configuration: { status: 'active' }, + }], + }, + isPending: false, + refetch: vi.fn(), + } + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should render the modal title', async () => { + await render( + , + ) + + expect(screen.getByText(/feature\.moderation\.modal\.title/)).toBeInTheDocument() + }) + + it('should render provider options', async () => { + await render( + , + ) + + expect(screen.getByText(/feature\.moderation\.modal\.provider\.openai/)).toBeInTheDocument() + // Keywords text appears both as provider option and section label + expect(screen.getAllByText(/feature\.moderation\.modal\.provider\.keywords/).length).toBeGreaterThanOrEqual(1) + expect(screen.getByText(/apiBasedExtension\.selector\.title/)).toBeInTheDocument() + }) + + it('should show keywords textarea when keywords type is selected', async () => { + await render( + , + ) + + const textarea = screen.getByPlaceholderText(/feature\.moderation\.modal\.keywords\.placeholder/) as HTMLTextAreaElement + expect(textarea).toBeInTheDocument() + expect(textarea).toHaveValue('bad\nword') + }) + + it('should render cancel and save buttons', async () => { + await render( + , + ) + + expect(screen.getByText(/operation\.cancel/)).toBeInTheDocument() + expect(screen.getByText(/operation\.save/)).toBeInTheDocument() + }) + + it('should call onCancel when cancel is clicked', async () => { + const onCancel = vi.fn() + await render( + , + ) + + fireEvent.click(screen.getByText(/operation\.cancel/)) + + expect(onCancel).toHaveBeenCalled() + }) + + it('should show error when saving without inputs or outputs enabled', async () => { + const data: ModerationConfig = { + ...defaultData, + config: { + keywords: 'test', + inputs_config: { enabled: false, preset_response: '' }, + outputs_config: { enabled: false, preset_response: '' }, + }, + } + await render( + , + ) + + fireEvent.click(screen.getByText(/operation\.save/)) + + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + + it('should show error when keywords type has no keywords', async () => { + const data: ModerationConfig = { + ...defaultData, + config: { + keywords: '', + inputs_config: { enabled: true, preset_response: 'blocked' }, + outputs_config: { enabled: false, preset_response: '' }, + }, + } + await render( + , + ) + + fireEvent.click(screen.getByText(/operation\.save/)) + + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + + it('should call onSave with formatted data when valid', async () => { + const data: ModerationConfig = { + ...defaultData, + config: { + keywords: 'bad\nword', + inputs_config: { enabled: true, preset_response: 'blocked' }, + outputs_config: { enabled: false, preset_response: '' }, + }, + } + await render( + , + ) + + fireEvent.click(screen.getByText(/operation\.save/)) + + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + type: 'keywords', + enabled: true, + config: expect.objectContaining({ + keywords: 'bad\nword', + inputs_config: expect.objectContaining({ enabled: true }), + }), + })) + }) + + it('should show api selector when api type is selected', async () => { + await render( + , + ) + + expect(screen.getByTestId('api-selector')).toBeInTheDocument() + }) + + it('should switch provider type when clicked', async () => { + await render( + , + ) + + // Click on openai_moderation provider + fireEvent.click(screen.getByText(/feature\.moderation\.modal\.provider\.openai/)) + + // The keywords textarea should no longer be visible since type changed + expect(screen.queryByPlaceholderText(/feature\.moderation\.modal\.keywords\.placeholder/)).not.toBeInTheDocument() + }) + + it('should update keywords on textarea change', async () => { + await render( + , + ) + + const textarea = screen.getByPlaceholderText(/feature\.moderation\.modal\.keywords\.placeholder/) as HTMLTextAreaElement + fireEvent.change(textarea, { target: { value: 'new\nkeywords' } }) + + expect(textarea).toHaveValue('new\nkeywords') + }) + + it('should render moderation content sections', async () => { + await render( + , + ) + + expect(screen.getByText(/feature\.moderation\.modal\.content\.input/)).toBeInTheDocument() + expect(screen.getByText(/feature\.moderation\.modal\.content\.output/)).toBeInTheDocument() + }) + + it('should show error when inputs enabled but no preset_response for keywords type', async () => { + const data: ModerationConfig = { + ...defaultData, + config: { + keywords: 'test', + inputs_config: { enabled: true, preset_response: '' }, + outputs_config: { enabled: false, preset_response: '' }, + }, + } + await render( + , + ) + + fireEvent.click(screen.getByText(/operation\.save/)) + + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + + it('should show error when api type has no api_based_extension_id', async () => { + const data: ModerationConfig = { + enabled: true, + type: 'api', + config: { + inputs_config: { enabled: true, preset_response: '' }, + outputs_config: { enabled: false, preset_response: '' }, + }, + } + await render( + , + ) + + fireEvent.click(screen.getByText(/operation\.save/)) + + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + + it('should save with api_based_extension_id in formatted data for api type', async () => { + const data: ModerationConfig = { + enabled: true, + type: 'api', + config: { + api_based_extension_id: 'ext-1', + inputs_config: { enabled: true, preset_response: '' }, + outputs_config: { enabled: false, preset_response: '' }, + }, + } + await render( + , + ) + + // api type doesn't require preset_response, so save should succeed + fireEvent.click(screen.getByText(/operation\.save/)) + + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + type: 'api', + config: expect.objectContaining({ + api_based_extension_id: 'ext-1', + }), + })) + }) + + it('should show error when outputs enabled but no preset_response for keywords type', async () => { + const data: ModerationConfig = { + ...defaultData, + config: { + keywords: 'test', + inputs_config: { enabled: false, preset_response: '' }, + outputs_config: { enabled: true, preset_response: '' }, + }, + } + await render( + , + ) + + fireEvent.click(screen.getByText(/operation\.save/)) + + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + + it('should toggle input moderation content', async () => { + await render( + , + ) + + const switches = screen.getAllByRole('switch') + expect(screen.getAllByPlaceholderText(/feature\.moderation\.modal\.content\.placeholder/)).toHaveLength(1) + + fireEvent.click(switches[0]) + + expect(screen.queryAllByPlaceholderText(/feature\.moderation\.modal\.content\.placeholder/)).toHaveLength(0) + }) + + it('should toggle output moderation content', async () => { + await render( + , + ) + + const switches = screen.getAllByRole('switch') + expect(screen.getAllByPlaceholderText(/feature\.moderation\.modal\.content\.placeholder/)).toHaveLength(1) + + fireEvent.click(switches[1]) + + expect(screen.getAllByPlaceholderText(/feature\.moderation\.modal\.content\.placeholder/)).toHaveLength(2) + }) + + it('should select api extension via api selector', async () => { + await render( + , + ) + + fireEvent.click(screen.getByTestId('select-api')) + + // Trigger save and confirm the chosen extension id is passed through + fireEvent.click(screen.getByText(/operation\.save/)) + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ api_based_extension_id: 'api-ext-1' }), + }), + ) + }) + + it('should save with openai_moderation type when configured', async () => { + await render( + , + ) + + fireEvent.click(screen.getByText(/operation\.save/)) + + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + type: 'openai_moderation', + })) + }) + + it('should handle keyword truncation to 100 chars per line and 100 lines', async () => { + await render( + , + ) + + const textarea = screen.getByPlaceholderText(/feature\.moderation\.modal\.keywords\.placeholder/) + // Create a long keyword that exceeds 100 chars + const longWord = 'a'.repeat(150) + fireEvent.change(textarea, { target: { value: longWord } }) + + // Value should be truncated to 100 chars + expect((textarea as HTMLTextAreaElement).value.length).toBeLessThanOrEqual(100) + }) + + it('should save with formatted outputs_config when both enabled', async () => { + const data: ModerationConfig = { + ...defaultData, + config: { + keywords: 'test', + inputs_config: { enabled: true, preset_response: 'input blocked' }, + outputs_config: { enabled: true, preset_response: 'output blocked' }, + }, + } + await render( + , + ) + + fireEvent.click(screen.getByText(/operation\.save/)) + + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + config: expect.objectContaining({ + inputs_config: expect.objectContaining({ enabled: true }), + outputs_config: expect.objectContaining({ enabled: true }), + }), + })) + }) + + it('should switch from keywords to api type', async () => { + await render( + , + ) + + // Click api provider + fireEvent.click(screen.getByText(/apiBasedExtension\.selector\.title/)) + + // API selector should now be visible, keywords textarea should be hidden + expect(screen.getByTestId('api-selector')).toBeInTheDocument() + expect(screen.queryByPlaceholderText(/feature\.moderation\.modal\.keywords\.placeholder/)).not.toBeInTheDocument() + }) + + it('should handle empty lines in keywords', async () => { + await render( + , + ) + + const textarea = screen.getByPlaceholderText(/feature\.moderation\.modal\.keywords\.placeholder/) as HTMLTextAreaElement + fireEvent.change(textarea, { target: { value: 'word1\n\nword2\n\n' } }) + + expect(textarea.value).toBe('word1\n\nword2\n') + }) + + it('should show OpenAI not configured warning when OpenAI provider is not set up', async () => { + mockModelProvidersData = { + data: { + data: [{ + provider: 'langgenius/openai/openai', + system_configuration: { + enabled: false, + current_quota_type: 'free', + quota_configurations: [], + }, + custom_configuration: { status: 'no-configure' }, + }], + }, + isPending: false, + refetch: vi.fn(), + } + + await render( + , + ) + + expect(screen.getByText(/feature\.moderation\.modal\.openaiNotConfig\.before/)).toBeInTheDocument() + }) + + it('should open settings modal when provider link is clicked in OpenAI warning', async () => { + mockModelProvidersData = { + data: { + data: [{ + provider: 'langgenius/openai/openai', + system_configuration: { + enabled: false, + current_quota_type: 'free', + quota_configurations: [], + }, + custom_configuration: { status: 'no-configure' }, + }], + }, + isPending: false, + refetch: vi.fn(), + } + + await render( + , + ) + + fireEvent.click(screen.getByText(/settings\.provider/)) + + expect(mockSetShowAccountSettingModal).toHaveBeenCalled() + }) + + it('should not save when OpenAI type is selected but not configured', async () => { + mockModelProvidersData = { + data: { + data: [{ + provider: 'langgenius/openai/openai', + system_configuration: { + enabled: false, + current_quota_type: 'free', + quota_configurations: [], + }, + custom_configuration: { status: 'no-configure' }, + }], + }, + isPending: false, + refetch: vi.fn(), + } + + await render( + , + ) + + fireEvent.click(screen.getByText(/operation\.save/)) + + expect(onSave).not.toHaveBeenCalled() + }) + + it('should render code-based extension providers', async () => { + mockCodeBasedExtensions = { + data: { + data: [{ + name: 'custom-ext', + label: { 'en-US': 'Custom Extension', 'zh-Hans': '自定义扩展' }, + form_schema: [ + { variable: 'api_url', label: { 'en-US': 'API URL', 'zh-Hans': 'API 地址' }, type: 'text-input', required: true, default: '', placeholder: 'Enter URL', options: [], max_length: 200 }, + ], + }], + }, + } + + await render( + , + ) + + expect(screen.getByText('Custom Extension')).toBeInTheDocument() + }) + + it('should show form generation when code-based extension is selected', async () => { + mockCodeBasedExtensions = { + data: { + data: [{ + name: 'custom-ext', + label: { 'en-US': 'Custom Extension', 'zh-Hans': '自定义扩展' }, + form_schema: [ + { variable: 'api_url', label: { 'en-US': 'API URL', 'zh-Hans': 'API 地址' }, type: 'text-input', required: true, default: '', placeholder: 'Enter URL', options: [], max_length: 200 }, + ], + }], + }, + } + + await render( + , + ) + + expect(screen.getByText('API URL')).toBeInTheDocument() + expect(screen.getByPlaceholderText('Enter URL')).toBeInTheDocument() + }) + + it('should initialize config from form schema when switching to code-based extension', async () => { + mockCodeBasedExtensions = { + data: { + data: [{ + name: 'custom-ext', + label: { 'en-US': 'Custom Extension', 'zh-Hans': '自定义扩展' }, + form_schema: [ + { variable: 'api_url', label: { 'en-US': 'API URL', 'zh-Hans': 'API 地址' }, type: 'text-input', required: true, default: 'https://default.com', placeholder: '', options: [], max_length: 200 }, + ], + }], + }, + } + + await render( + , + ) + + // Click on the custom extension provider + fireEvent.click(screen.getByText('Custom Extension')) + + // The form input should use the default value from form schema + expect(screen.getByDisplayValue('https://default.com')).toBeInTheDocument() + }) + + it('should show error when required form schema field is empty on save', async () => { + mockCodeBasedExtensions = { + data: { + data: [{ + name: 'custom-ext', + label: { 'en-US': 'Custom Extension', 'zh-Hans': '自定义扩展' }, + form_schema: [ + { variable: 'api_url', label: { 'en-US': 'API URL', 'zh-Hans': 'API 地址' }, type: 'text-input', required: true, default: '', placeholder: '', options: [], max_length: 200 }, + ], + }], + }, + } + + await render( + , + ) + + fireEvent.click(screen.getByText(/operation\.save/)) + + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + + it('should save with code-based extension config when valid', async () => { + mockCodeBasedExtensions = { + data: { + data: [{ + name: 'custom-ext', + label: { 'en-US': 'Custom Extension', 'zh-Hans': '自定义扩展' }, + form_schema: [ + { variable: 'api_url', label: { 'en-US': 'API URL', 'zh-Hans': 'API 地址' }, type: 'text-input', required: true, default: '', placeholder: '', options: [], max_length: 200 }, + ], + }], + }, + } + + await render( + , + ) + + fireEvent.click(screen.getByText(/operation\.save/)) + + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + type: 'custom-ext', + config: expect.objectContaining({ + api_url: 'https://example.com', + }), + })) + }) + + it('should show doc link for api type', async () => { + await render( + , + ) + + expect(screen.getByText(/apiBasedExtension\.link/)).toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx b/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx index c9455c98eb..c68abfd7b1 100644 --- a/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx +++ b/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx @@ -1,14 +1,11 @@ import type { ChangeEvent, FC } from 'react' import type { CodeBasedExtensionItem } from '@/models/common' import type { ModerationConfig, ModerationContentConfig } from '@/models/debug' -import { RiCloseLine } from '@remixicon/react' import { noop } from 'es-toolkit/function' import { useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import Divider from '@/app/components/base/divider' -import { BookOpen01 } from '@/app/components/base/icons/src/vender/line/education' -import { InfoCircle } from '@/app/components/base/icons/src/vender/line/general' import Modal from '@/app/components/base/modal' import { useToastContext } from '@/app/components/base/toast' import ApiBasedExtensionSelector from '@/app/components/header/account-setting/api-based-extension-page/selector' @@ -238,8 +235,21 @@ const ModerationSettingModal: FC = ({ className="!mt-14 !w-[600px] !max-w-none !p-6" >
-
{t('feature.moderation.modal.title', { ns: 'appDebug' })}
-
+
{t('feature.moderation.modal.title', { ns: 'appDebug' })}
+
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onCancel() + } + }} + > + +
@@ -251,9 +261,9 @@ const ModerationSettingModal: FC = ({
handleDataTypeChange(provider.key)} @@ -272,7 +282,7 @@ const ModerationSettingModal: FC = ({ { !isLoading && !isOpenAIProviderConfigured && localeData.type === 'openai_moderation' && (
- +
{t('feature.moderation.modal.openaiNotConfig.before', { ns: 'appDebug' })} = ({ rel="noopener noreferrer" className="group flex items-center text-xs text-text-tertiary hover:text-primary-600" > - + {t('apiBasedExtension.link', { ns: 'common' })}
diff --git a/web/app/components/base/features/new-feature-panel/more-like-this.spec.tsx b/web/app/components/base/features/new-feature-panel/more-like-this.spec.tsx new file mode 100644 index 0000000000..592e08a995 --- /dev/null +++ b/web/app/components/base/features/new-feature-panel/more-like-this.spec.tsx @@ -0,0 +1,55 @@ +import type { OnFeaturesChange } from '../types' +import { fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' +import { FeaturesProvider } from '../context' +import MoreLikeThis from './more-like-this' + +const renderWithProvider = (props: { disabled?: boolean, onChange?: OnFeaturesChange } = {}) => { + return render( + + + , + ) +} + +describe('MoreLikeThis', () => { + it('should render the more-like-this feature card', () => { + renderWithProvider() + + expect(screen.getByText(/feature\.moreLikeThis\.title/)).toBeInTheDocument() + }) + + it('should render description text', () => { + renderWithProvider() + + expect(screen.getByText(/feature\.moreLikeThis\.description/)).toBeInTheDocument() + }) + + it('should render a switch toggle', () => { + renderWithProvider() + + expect(screen.getByRole('switch')).toBeInTheDocument() + }) + + it('should call onChange when toggled', () => { + const onChange = vi.fn() + renderWithProvider({ onChange }) + + fireEvent.click(screen.getByRole('switch')) + + expect(onChange).toHaveBeenCalledTimes(1) + }) + + it('should not throw when onChange is not provided', () => { + renderWithProvider() + + expect(() => fireEvent.click(screen.getByRole('switch'))).not.toThrow() + }) + + it('should render tooltip for the feature', () => { + renderWithProvider() + + // MoreLikeThis has a tooltip prop, verifying the feature renders with title + expect(screen.getByText(/feature\.moreLikeThis\.title/)).toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/features/new-feature-panel/speech-to-text.spec.tsx b/web/app/components/base/features/new-feature-panel/speech-to-text.spec.tsx new file mode 100644 index 0000000000..341065fe21 --- /dev/null +++ b/web/app/components/base/features/new-feature-panel/speech-to-text.spec.tsx @@ -0,0 +1,48 @@ +import type { OnFeaturesChange } from '../types' +import { fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' +import { FeaturesProvider } from '../context' +import SpeechToText from './speech-to-text' + +const renderWithProvider = (props: { disabled?: boolean, onChange?: OnFeaturesChange } = {}) => { + return render( + + + , + ) +} + +describe('SpeechToText', () => { + it('should render the speech-to-text feature card', () => { + renderWithProvider() + + expect(screen.getByText(/feature\.speechToText\.title/)).toBeInTheDocument() + }) + + it('should render description text', () => { + renderWithProvider() + + expect(screen.getByText(/feature\.speechToText\.description/)).toBeInTheDocument() + }) + + it('should render a switch toggle', () => { + renderWithProvider() + + expect(screen.getByRole('switch')).toBeInTheDocument() + }) + + it('should call onChange when toggled', () => { + const onChange = vi.fn() + renderWithProvider({ onChange }) + + fireEvent.click(screen.getByRole('switch')) + + expect(onChange).toHaveBeenCalledTimes(1) + }) + + it('should not throw when onChange is not provided', () => { + renderWithProvider() + + expect(() => fireEvent.click(screen.getByRole('switch'))).not.toThrow() + }) +}) diff --git a/web/app/components/base/features/new-feature-panel/text-to-speech/index.spec.tsx b/web/app/components/base/features/new-feature-panel/text-to-speech/index.spec.tsx new file mode 100644 index 0000000000..a9623a8215 --- /dev/null +++ b/web/app/components/base/features/new-feature-panel/text-to-speech/index.spec.tsx @@ -0,0 +1,115 @@ +import type { Features } from '../../types' +import type { OnFeaturesChange } from '@/app/components/base/features/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { TtsAutoPlay } from '@/types/app' +import { FeaturesProvider } from '../../context' +import TextToSpeech from './index' + +vi.mock('@/i18n-config/language', () => ({ + languages: [ + { value: 'en-US', name: 'English', example: 'Hello world' }, + { value: 'zh-Hans', name: '中文', example: '你好' }, + ], +})) + +const defaultFeatures: Features = { + moreLikeThis: { enabled: false }, + opening: { enabled: false }, + suggested: { enabled: false }, + text2speech: { enabled: false }, + speech2text: { enabled: false }, + citation: { enabled: false }, + moderation: { enabled: false }, + file: { enabled: false }, + annotationReply: { enabled: false }, +} + +const renderWithProvider = ( + props: { disabled?: boolean, onChange?: OnFeaturesChange } = {}, + featureOverrides?: Partial, +) => { + const features = { ...defaultFeatures, ...featureOverrides } + return render( + + + , + ) +} + +describe('TextToSpeech', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render the text-to-speech title', () => { + renderWithProvider() + + expect(screen.getByText(/feature\.textToSpeech\.title/)).toBeInTheDocument() + }) + + it('should render description when disabled', () => { + renderWithProvider() + + expect(screen.getByText(/feature\.textToSpeech\.description/)).toBeInTheDocument() + }) + + it('should render a switch toggle', () => { + renderWithProvider() + + expect(screen.getByRole('switch')).toBeInTheDocument() + }) + + it('should call onChange when toggled', () => { + const onChange = vi.fn() + renderWithProvider({ onChange }) + + fireEvent.click(screen.getByRole('switch')) + + expect(onChange).toHaveBeenCalled() + }) + + it('should show language and voice info when enabled and not hovering', () => { + renderWithProvider({}, { + text2speech: { enabled: true, language: 'en-US', voice: 'alloy' }, + }) + + expect(screen.getByText('English')).toBeInTheDocument() + expect(screen.getByText('alloy')).toBeInTheDocument() + }) + + it('should show default display text when voice is not set', () => { + renderWithProvider({}, { + text2speech: { enabled: true, language: 'en-US' }, + }) + + expect(screen.getByText(/voice\.defaultDisplay/)).toBeInTheDocument() + }) + + it('should show voice settings button when hovering', () => { + renderWithProvider({}, { + text2speech: { enabled: true }, + }) + + // Simulate mouse enter on the feature card + const card = screen.getByText(/feature\.textToSpeech\.title/).closest('[class]')! + fireEvent.mouseEnter(card) + + expect(screen.getByText(/voice\.voiceSettings\.title/)).toBeInTheDocument() + }) + + it('should show autoPlay enabled text when autoPlay is enabled', () => { + renderWithProvider({}, { + text2speech: { enabled: true, language: 'en-US', autoPlay: TtsAutoPlay.enabled }, + }) + + expect(screen.getByText(/voice\.voiceSettings\.autoPlayEnabled/)).toBeInTheDocument() + }) + + it('should show autoPlay disabled text when autoPlay is not enabled', () => { + renderWithProvider({}, { + text2speech: { enabled: true, language: 'en-US' }, + }) + + expect(screen.getByText(/voice\.voiceSettings\.autoPlayDisabled/)).toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.spec.tsx b/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.spec.tsx new file mode 100644 index 0000000000..b4a0dafd91 --- /dev/null +++ b/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.spec.tsx @@ -0,0 +1,349 @@ +import type { Features } from '../../types' +import type { OnFeaturesChange } from '@/app/components/base/features/types' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { TtsAutoPlay } from '@/types/app' +import { FeaturesProvider } from '../../context' +import ParamConfigContent from './param-config-content' + +let mockLanguages = [ + { value: 'en-US', name: 'English', example: 'Hello world' }, + { value: 'zh-Hans', name: '中文', example: '你好' }, +] + +let mockPathname = '/app/test-app-id/configuration' + +let mockVoiceItems: { value: string, name: string }[] | undefined = [ + { value: 'alloy', name: 'Alloy' }, + { value: 'echo', name: 'Echo' }, +] + +const mockUseAppVoices = vi.fn((_appId: string, _language?: string) => ({ + data: mockVoiceItems, +})) + +vi.mock('next/navigation', () => ({ + usePathname: () => mockPathname, + useParams: () => ({}), +})) + +vi.mock('@/i18n-config/language', () => ({ + get languages() { + return mockLanguages + }, +})) + +vi.mock('@/service/use-apps', () => ({ + useAppVoices: (appId: string, language?: string) => mockUseAppVoices(appId, language), +})) + +const defaultFeatures: Features = { + moreLikeThis: { enabled: false }, + opening: { enabled: false }, + suggested: { enabled: false }, + text2speech: { enabled: true, language: 'en-US', voice: 'alloy', autoPlay: TtsAutoPlay.disabled }, + speech2text: { enabled: false }, + citation: { enabled: false }, + moderation: { enabled: false }, + file: { enabled: false }, + annotationReply: { enabled: false }, +} + +const renderWithProvider = ( + props: { onClose?: () => void, onChange?: OnFeaturesChange } = {}, + featureOverrides?: Partial, +) => { + const features = { ...defaultFeatures, ...featureOverrides } + return render( + + + , + ) +} + +describe('ParamConfigContent', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPathname = '/app/test-app-id/configuration' + mockLanguages = [ + { value: 'en-US', name: 'English', example: 'Hello world' }, + { value: 'zh-Hans', name: '中文', example: '你好' }, + ] + mockVoiceItems = [ + { value: 'alloy', name: 'Alloy' }, + { value: 'echo', name: 'Echo' }, + ] + }) + + // Rendering states and static UI sections. + describe('Rendering', () => { + it('should render voice settings title', () => { + renderWithProvider() + + expect(screen.getByText(/voice\.voiceSettings\.title/)).toBeInTheDocument() + }) + + it('should render language label', () => { + renderWithProvider() + + expect(screen.getByText(/voice\.voiceSettings\.language/)).toBeInTheDocument() + }) + + it('should render voice label', () => { + renderWithProvider() + + expect(screen.getByText(/voice\.voiceSettings\.voice/)).toBeInTheDocument() + }) + + it('should render autoPlay toggle', () => { + renderWithProvider() + + expect(screen.getByText(/voice\.voiceSettings\.autoPlay/)).toBeInTheDocument() + expect(screen.getByRole('switch')).toBeInTheDocument() + }) + + it('should render tooltip icon for language', () => { + renderWithProvider() + + const languageLabel = screen.getByText(/voice\.voiceSettings\.language/) + expect(languageLabel).toBeInTheDocument() + const tooltip = languageLabel.parentElement as HTMLElement + expect(tooltip.querySelector('svg')).toBeInTheDocument() + }) + + it('should display language listbox button', () => { + renderWithProvider() + + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBeGreaterThanOrEqual(1) + }) + + it('should display current voice in listbox button', () => { + renderWithProvider() + + const buttons = screen.getAllByRole('button') + const voiceButton = buttons.find(btn => btn.textContent?.includes('Alloy')) + expect(voiceButton).toBeInTheDocument() + }) + + it('should render audition button when language has example', () => { + renderWithProvider() + + const auditionButton = screen.queryByTestId('audition-button') + expect(auditionButton).toBeInTheDocument() + }) + + it('should not render audition button when language has no example', () => { + mockLanguages = [ + { value: 'en-US', name: 'English', example: '' }, + { value: 'zh-Hans', name: '中文', example: '' }, + ] + + renderWithProvider() + + const auditionButton = screen.queryByTestId('audition-button') + expect(auditionButton).toBeNull() + }) + + it('should render with no language set and use first as default', () => { + renderWithProvider({}, { + text2speech: { enabled: true, language: '', voice: '', autoPlay: TtsAutoPlay.disabled }, + }) + + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBeGreaterThan(0) + }) + + it('should render with no voice set and use first as default', () => { + renderWithProvider({}, { + text2speech: { enabled: true, language: 'en-US', voice: 'nonexistent', autoPlay: TtsAutoPlay.disabled }, + }) + + const buttons = screen.getAllByRole('button') + const voiceButton = buttons.find(btn => btn.textContent?.includes('Alloy')) + expect(voiceButton).toBeInTheDocument() + }) + }) + + // User-triggered behavior and callbacks. + describe('User Interactions', () => { + it('should call onClose when close button is clicked', async () => { + const onClose = vi.fn() + renderWithProvider({ onClose }) + + const closeButton = screen.getByRole('button', { name: /close/i }) + await userEvent.click(closeButton) + + expect(onClose).toHaveBeenCalled() + }) + + it('should call onClose when close button receives Enter key', async () => { + const onClose = vi.fn() + renderWithProvider({ onClose }) + + const closeButton = screen.getByRole('button', { name: /close/i }) + await userEvent.click(closeButton) + onClose.mockClear() + closeButton.focus() + await userEvent.keyboard('{Enter}') + + expect(onClose).toHaveBeenCalled() + }) + + it('should not call onClose when close button receives unrelated key', async () => { + const onClose = vi.fn() + renderWithProvider({ onClose }) + + const closeButton = screen.getByRole('button', { name: /close/i }) + closeButton.focus() + await userEvent.keyboard('{Escape}') + + expect(onClose).not.toHaveBeenCalled() + }) + + it('should toggle autoPlay switch and call onChange', async () => { + const onChange = vi.fn() + renderWithProvider({ onChange }) + + await userEvent.click(screen.getByRole('switch')) + + expect(onChange).toHaveBeenCalled() + }) + + it('should set autoPlay to disabled when toggled off from enabled state', async () => { + const onChange = vi.fn() + renderWithProvider( + { onChange }, + { text2speech: { enabled: true, language: 'en-US', voice: 'alloy', autoPlay: TtsAutoPlay.enabled } }, + ) + + const autoPlaySwitch = screen.getByRole('switch') + expect(autoPlaySwitch).toHaveAttribute('aria-checked', 'true') + + await userEvent.click(autoPlaySwitch) + + expect(autoPlaySwitch).toHaveAttribute('aria-checked', 'false') + expect(onChange).toHaveBeenCalled() + }) + + it('should call feature update without onChange callback', async () => { + renderWithProvider() + + await userEvent.click(screen.getByRole('switch')) + + expect(screen.getByRole('switch')).toBeInTheDocument() + }) + + it('should open language listbox and show options', async () => { + renderWithProvider() + + const buttons = screen.getAllByRole('button') + const languageButton = buttons.find(btn => btn.textContent?.includes('voice.language.')) + expect(languageButton).toBeDefined() + await userEvent.click(languageButton!) + + const options = await screen.findAllByRole('option') + expect(options.length).toBeGreaterThanOrEqual(2) + }) + + it('should handle language change', async () => { + const onChange = vi.fn() + renderWithProvider({ onChange }) + + const buttons = screen.getAllByRole('button') + const languageButton = buttons.find(btn => btn.textContent?.includes('voice.language.')) + expect(languageButton).toBeDefined() + await userEvent.click(languageButton!) + const options = await screen.findAllByRole('option') + expect(options.length).toBeGreaterThan(1) + await userEvent.click(options[1]) + expect(onChange).toHaveBeenCalled() + }) + + it('should handle voice change', async () => { + const onChange = vi.fn() + renderWithProvider({ onChange }) + + const buttons = screen.getAllByRole('button') + const voiceButton = buttons.find(btn => btn.textContent?.includes('Alloy')) + expect(voiceButton).toBeDefined() + await userEvent.click(voiceButton!) + const options = await screen.findAllByRole('option') + expect(options.length).toBeGreaterThan(1) + await userEvent.click(options[1]) + expect(onChange).toHaveBeenCalled() + }) + + it('should show selected language option in listbox', async () => { + renderWithProvider() + + const buttons = screen.getAllByRole('button') + const languageButton = buttons.find(btn => btn.textContent?.includes('voice.language.')) + expect(languageButton).toBeDefined() + await userEvent.click(languageButton!) + const options = await screen.findAllByRole('option') + expect(options.length).toBeGreaterThanOrEqual(1) + + const selectedOption = options.find(opt => opt.textContent?.includes('voice.language.enUS')) + expect(selectedOption).toBeDefined() + expect(selectedOption).toHaveAttribute('aria-selected', 'true') + }) + + it('should show selected voice option in listbox', async () => { + renderWithProvider() + + const buttons = screen.getAllByRole('button') + const voiceButton = buttons.find(btn => btn.textContent?.includes('Alloy')) + expect(voiceButton).toBeDefined() + await userEvent.click(voiceButton!) + const options = await screen.findAllByRole('option') + expect(options.length).toBeGreaterThanOrEqual(1) + + const selectedOption = options.find(opt => opt.textContent?.includes('Alloy')) + expect(selectedOption).toBeDefined() + expect(selectedOption).toHaveAttribute('aria-selected', 'true') + }) + }) + + // Fallback and boundary scenarios. + describe('Edge Cases', () => { + it('should show placeholder and disable voice selection when no languages are available', () => { + mockLanguages = [] + mockVoiceItems = undefined + + renderWithProvider({}, { + text2speech: { enabled: true, language: 'en-US', voice: 'alloy', autoPlay: TtsAutoPlay.disabled }, + }) + + const placeholderTexts = screen.getAllByText(/placeholder\.select/) + expect(placeholderTexts.length).toBeGreaterThanOrEqual(2) + + const disabledButtons = screen + .getAllByRole('button') + .filter(button => button.hasAttribute('disabled') || button.getAttribute('aria-disabled') === 'true') + + expect(disabledButtons.length).toBeGreaterThanOrEqual(1) + }) + + it('should call useAppVoices with empty appId when pathname has no app segment', () => { + mockPathname = '/configuration' + + renderWithProvider() + + expect(mockUseAppVoices).toHaveBeenCalledWith('', 'en-US') + }) + + it('should render language text when selected language value is empty string', () => { + mockLanguages = [{ value: '' as string, name: 'Unknown Language', example: '' }] + + renderWithProvider({}, { + text2speech: { enabled: true, language: '', voice: '', autoPlay: TtsAutoPlay.disabled }, + }) + + expect(screen.getByText(/voice\.language\./)).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx b/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx index 21b4f1e0cd..631691c42f 100644 --- a/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx +++ b/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx @@ -2,8 +2,6 @@ import type { OnFeaturesChange } from '@/app/components/base/features/types' import type { Item } from '@/app/components/base/select' import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } from '@headlessui/react' -import { CheckIcon, ChevronDownIcon } from '@heroicons/react/20/solid' -import { RiCloseLine } from '@remixicon/react' import { produce } from 'immer' import { usePathname } from 'next/navigation' import * as React from 'react' @@ -67,11 +65,25 @@ const VoiceParamConfig = ({ return ( <>
-
{t('voice.voiceSettings.title', { ns: 'appDebug' })}
-
+
{t('voice.voiceSettings.title', { ns: 'appDebug' })}
+
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onClose() + } + }} + > + +
-
+
{t('voice.voiceSettings.language', { ns: 'appDebug' })} -
-
+
{t('voice.voiceSettings.voice', { ns: 'appDebug' })}
@@ -173,10 +182,7 @@ const VoiceParamConfig = ({ {voiceItem?.name ?? localVoicePlaceholder} -
{languageItem?.example && ( -
+
-
+
{t('voice.voiceSettings.autoPlay', { ns: 'appDebug' })}
({ + usePathname: () => '/app/test-app-id/configuration', + useParams: () => ({ appId: 'test-app-id' }), +})) + +vi.mock('@/service/use-apps', () => ({ + useAppVoices: () => ({ + data: [{ name: 'alloy', value: 'alloy' }], + }), +})) + +const defaultFeatures: Features = { + moreLikeThis: { enabled: false }, + opening: { enabled: false }, + suggested: { enabled: false }, + text2speech: { enabled: true, language: 'en-US', voice: 'alloy' }, + speech2text: { enabled: false }, + citation: { enabled: false }, + moderation: { enabled: false }, + file: { enabled: false }, + annotationReply: { enabled: false }, +} + +const renderWithProvider = (ui: React.ReactNode) => { + return render( + + {ui} + , + ) +} + +describe('VoiceSettings', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render children in trigger', () => { + renderWithProvider( + + + , + ) + + expect(screen.getByText('Settings')).toBeInTheDocument() + }) + + it('should render ParamConfigContent in portal', () => { + renderWithProvider( + + + , + ) + + expect(screen.getByText(/voice\.voiceSettings\.title/)).toBeInTheDocument() + }) + + it('should call onOpen with toggle function when trigger is clicked', () => { + const onOpen = vi.fn() + renderWithProvider( + + + , + ) + + fireEvent.click(screen.getByText('Settings')) + + expect(onOpen).toHaveBeenCalled() + // The toggle function should flip the open state + const toggleFn = onOpen.mock.calls[0][0] + expect(typeof toggleFn).toBe('function') + expect(toggleFn(false)).toBe(true) + expect(toggleFn(true)).toBe(false) + }) + + it('should not call onOpen when disabled and trigger is clicked', () => { + const onOpen = vi.fn() + renderWithProvider( + + + , + ) + + fireEvent.click(screen.getByText('Settings')) + + expect(onOpen).not.toHaveBeenCalled() + }) + + it('should call onOpen with false when close is clicked', () => { + const onOpen = vi.fn() + renderWithProvider( + + + , + ) + + fireEvent.click(screen.getByRole('button', { name: /voice\.voiceSettings\.close/ })) + + expect(onOpen).toHaveBeenCalledWith(false) + }) +}) diff --git a/web/app/components/base/features/store.spec.ts b/web/app/components/base/features/store.spec.ts new file mode 100644 index 0000000000..fc2cf8822e --- /dev/null +++ b/web/app/components/base/features/store.spec.ts @@ -0,0 +1,180 @@ +import { Resolution, TransferMethod } from '@/types/app' +import { createFeaturesStore } from './store' + +describe('createFeaturesStore', () => { + describe('Default State', () => { + it('should create a store with moreLikeThis disabled by default', () => { + const store = createFeaturesStore() + const state = store.getState() + + expect(state.features.moreLikeThis?.enabled).toBe(false) + }) + + it('should create a store with opening disabled by default', () => { + const store = createFeaturesStore() + const state = store.getState() + + expect(state.features.opening?.enabled).toBe(false) + }) + + it('should create a store with suggested disabled by default', () => { + const store = createFeaturesStore() + const state = store.getState() + + expect(state.features.suggested?.enabled).toBe(false) + }) + + it('should create a store with text2speech disabled by default', () => { + const store = createFeaturesStore() + const state = store.getState() + + expect(state.features.text2speech?.enabled).toBe(false) + }) + + it('should create a store with speech2text disabled by default', () => { + const store = createFeaturesStore() + const state = store.getState() + + expect(state.features.speech2text?.enabled).toBe(false) + }) + + it('should create a store with citation disabled by default', () => { + const store = createFeaturesStore() + const state = store.getState() + + expect(state.features.citation?.enabled).toBe(false) + }) + + it('should create a store with moderation disabled by default', () => { + const store = createFeaturesStore() + const state = store.getState() + + expect(state.features.moderation?.enabled).toBe(false) + }) + + it('should create a store with annotationReply disabled by default', () => { + const store = createFeaturesStore() + const state = store.getState() + + expect(state.features.annotationReply?.enabled).toBe(false) + }) + }) + + describe('File Image Initialization', () => { + it('should initialize file image enabled as false', () => { + const store = createFeaturesStore() + const { features } = store.getState() + + expect(features.file?.image?.enabled).toBe(false) + }) + + it('should initialize file image detail as high resolution', () => { + const store = createFeaturesStore() + const { features } = store.getState() + + expect(features.file?.image?.detail).toBe(Resolution.high) + }) + + it('should initialize file image number_limits as 3', () => { + const store = createFeaturesStore() + const { features } = store.getState() + + expect(features.file?.image?.number_limits).toBe(3) + }) + + it('should initialize file image transfer_methods with local and remote options', () => { + const store = createFeaturesStore() + const { features } = store.getState() + + expect(features.file?.image?.transfer_methods).toEqual([ + TransferMethod.local_file, + TransferMethod.remote_url, + ]) + }) + }) + + describe('Feature Merging', () => { + it('should merge initial moreLikeThis enabled state', () => { + const store = createFeaturesStore({ + features: { + moreLikeThis: { enabled: true }, + }, + }) + const { features } = store.getState() + + expect(features.moreLikeThis?.enabled).toBe(true) + }) + + it('should merge initial opening enabled state', () => { + const store = createFeaturesStore({ + features: { + opening: { enabled: true }, + }, + }) + const { features } = store.getState() + + expect(features.opening?.enabled).toBe(true) + }) + + it('should preserve additional properties when merging', () => { + const store = createFeaturesStore({ + features: { + opening: { enabled: true, opening_statement: 'Hello!' }, + }, + }) + const { features } = store.getState() + + expect(features.opening?.enabled).toBe(true) + expect(features.opening?.opening_statement).toBe('Hello!') + }) + }) + + describe('setFeatures', () => { + it('should update moreLikeThis feature via setFeatures', () => { + const store = createFeaturesStore() + + store.getState().setFeatures({ + moreLikeThis: { enabled: true }, + }) + + expect(store.getState().features.moreLikeThis?.enabled).toBe(true) + }) + + it('should update multiple features via setFeatures', () => { + const store = createFeaturesStore() + + store.getState().setFeatures({ + moreLikeThis: { enabled: true }, + opening: { enabled: true }, + }) + + expect(store.getState().features.moreLikeThis?.enabled).toBe(true) + expect(store.getState().features.opening?.enabled).toBe(true) + }) + }) + + describe('showFeaturesModal', () => { + it('should initialize showFeaturesModal as false', () => { + const store = createFeaturesStore() + + expect(store.getState().showFeaturesModal).toBe(false) + }) + + it('should toggle showFeaturesModal to true', () => { + const store = createFeaturesStore() + + store.getState().setShowFeaturesModal(true) + + expect(store.getState().showFeaturesModal).toBe(true) + }) + + it('should toggle showFeaturesModal to false', () => { + const store = createFeaturesStore() + store.getState().setShowFeaturesModal(true) + + store.getState().setShowFeaturesModal(false) + + expect(store.getState().showFeaturesModal).toBe(false) + }) + }) +}) diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index 4bc0ce7e99..9f0104333f 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -1862,17 +1862,6 @@ "app/components/base/features/new-feature-panel/conversation-opener/modal.tsx": { "react-hooks-extra/no-direct-set-state-in-use-effect": { "count": 1 - }, - "tailwindcss/enforce-consistent-class-order": { - "count": 2 - }, - "tailwindcss/no-unnecessary-whitespace": { - "count": 1 - } - }, - "app/components/base/features/new-feature-panel/dialog-wrapper.tsx": { - "tailwindcss/no-unnecessary-whitespace": { - "count": 1 } }, "app/components/base/features/new-feature-panel/feature-bar.tsx": { @@ -1893,11 +1882,6 @@ "count": 5 } }, - "app/components/base/features/new-feature-panel/file-upload/setting-content.tsx": { - "tailwindcss/enforce-consistent-class-order": { - "count": 1 - } - }, "app/components/base/features/new-feature-panel/file-upload/setting-modal.tsx": { "ts/no-explicit-any": { "count": 1 @@ -1922,9 +1906,6 @@ } }, "app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx": { - "tailwindcss/enforce-consistent-class-order": { - "count": 3 - }, "ts/no-explicit-any": { "count": 2 } @@ -1934,11 +1915,6 @@ "count": 7 } }, - "app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx": { - "tailwindcss/enforce-consistent-class-order": { - "count": 4 - } - }, "app/components/base/features/new-feature-panel/text-to-speech/voice-settings.tsx": { "ts/no-explicit-any": { "count": 1