+
{children}
)}
diff --git a/web/app/components/billing/apps-full-in-dialog/index.spec.tsx b/web/app/components/billing/apps-full-in-dialog/index.spec.tsx
new file mode 100644
index 0000000000..a11b582b0f
--- /dev/null
+++ b/web/app/components/billing/apps-full-in-dialog/index.spec.tsx
@@ -0,0 +1,274 @@
+import type { Mock } from 'vitest'
+import type { UsagePlanInfo } from '@/app/components/billing/type'
+import type { AppContextValue } from '@/context/app-context'
+import type { ProviderContextState } from '@/context/provider-context'
+import type { ICurrentWorkspace, LangGeniusVersionResponse, UserProfileResponse } from '@/models/common'
+import { render, screen } from '@testing-library/react'
+import { Plan } from '@/app/components/billing/type'
+import { mailToSupport } from '@/app/components/header/utils/util'
+import { useAppContext } from '@/context/app-context'
+import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
+import AppsFull from './index'
+
+vi.mock('@/context/app-context', () => ({
+ useAppContext: vi.fn(),
+}))
+
+vi.mock('@/context/provider-context', async (importOriginal) => {
+ const actual = await importOriginal
()
+ return {
+ ...actual,
+ useProviderContext: vi.fn(),
+ }
+})
+
+vi.mock('@/context/modal-context', () => ({
+ useModalContext: () => ({
+ setShowPricingModal: vi.fn(),
+ }),
+}))
+
+vi.mock('@/app/components/header/utils/util', () => ({
+ mailToSupport: vi.fn(),
+}))
+
+const buildUsage = (overrides: Partial = {}): UsagePlanInfo => ({
+ buildApps: 0,
+ teamMembers: 0,
+ annotatedResponse: 0,
+ documentsUploadQuota: 0,
+ apiRateLimit: 0,
+ triggerEvents: 0,
+ vectorSpace: 0,
+ ...overrides,
+})
+
+const buildProviderContext = (overrides: Partial = {}): ProviderContextState => ({
+ ...baseProviderContextValue,
+ plan: {
+ ...baseProviderContextValue.plan,
+ type: Plan.sandbox,
+ usage: buildUsage({ buildApps: 2 }),
+ total: buildUsage({ buildApps: 10 }),
+ reset: {
+ apiRateLimit: null,
+ triggerEvents: null,
+ },
+ },
+ ...overrides,
+})
+
+const buildAppContext = (overrides: Partial = {}): AppContextValue => {
+ const userProfile: UserProfileResponse = {
+ id: 'user-id',
+ name: 'Test User',
+ email: 'user@example.com',
+ avatar: '',
+ avatar_url: '',
+ is_password_set: false,
+ }
+ const currentWorkspace: ICurrentWorkspace = {
+ id: 'workspace-id',
+ name: 'Workspace',
+ plan: '',
+ status: '',
+ created_at: 0,
+ role: 'normal',
+ providers: [],
+ }
+ const langGeniusVersionInfo: LangGeniusVersionResponse = {
+ current_env: '',
+ current_version: '1.0.0',
+ latest_version: '',
+ release_date: '',
+ release_notes: '',
+ version: '',
+ can_auto_update: false,
+ }
+ const base: Omit = {
+ userProfile,
+ currentWorkspace,
+ isCurrentWorkspaceManager: false,
+ isCurrentWorkspaceOwner: false,
+ isCurrentWorkspaceEditor: false,
+ isCurrentWorkspaceDatasetOperator: false,
+ mutateUserProfile: vi.fn(),
+ mutateCurrentWorkspace: vi.fn(),
+ langGeniusVersionInfo,
+ isLoadingCurrentWorkspace: false,
+ }
+ const useSelector: AppContextValue['useSelector'] = selector => selector({ ...base, useSelector })
+ return {
+ ...base,
+ useSelector,
+ ...overrides,
+ }
+}
+
+describe('AppsFull', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ ;(useProviderContext as Mock).mockReturnValue(buildProviderContext())
+ ;(useAppContext as Mock).mockReturnValue(buildAppContext())
+ ;(mailToSupport as Mock).mockReturnValue('mailto:support@example.com')
+ })
+
+ // Rendering behavior for non-team plans.
+ describe('Rendering', () => {
+ it('should render the sandbox messaging and upgrade button', () => {
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText('billing.apps.fullTip1')).toBeInTheDocument()
+ expect(screen.getByText('billing.apps.fullTip1des')).toBeInTheDocument()
+ expect(screen.getByText('billing.upgradeBtn.encourageShort')).toBeInTheDocument()
+ expect(screen.getByText('2/10')).toBeInTheDocument()
+ })
+ })
+
+ // Prop-driven behavior for team plans and contact CTA.
+ describe('Props', () => {
+ it('should render team messaging and contact button for non-sandbox plans', () => {
+ // Arrange
+ ;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
+ plan: {
+ ...baseProviderContextValue.plan,
+ type: Plan.team,
+ usage: buildUsage({ buildApps: 8 }),
+ total: buildUsage({ buildApps: 10 }),
+ reset: {
+ apiRateLimit: null,
+ triggerEvents: null,
+ },
+ },
+ }))
+ render()
+
+ // Assert
+ expect(screen.getByText('billing.apps.fullTip2')).toBeInTheDocument()
+ expect(screen.getByText('billing.apps.fullTip2des')).toBeInTheDocument()
+ expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument()
+ expect(screen.getByRole('link', { name: 'billing.apps.contactUs' })).toHaveAttribute('href', 'mailto:support@example.com')
+ expect(mailToSupport).toHaveBeenCalledWith('user@example.com', Plan.team, '1.0.0')
+ })
+
+ it('should render upgrade button for professional plans', () => {
+ // Arrange
+ ;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
+ plan: {
+ ...baseProviderContextValue.plan,
+ type: Plan.professional,
+ usage: buildUsage({ buildApps: 4 }),
+ total: buildUsage({ buildApps: 10 }),
+ reset: {
+ apiRateLimit: null,
+ triggerEvents: null,
+ },
+ },
+ }))
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText('billing.apps.fullTip1')).toBeInTheDocument()
+ expect(screen.getByText('billing.upgradeBtn.encourageShort')).toBeInTheDocument()
+ expect(screen.queryByText('billing.apps.contactUs')).not.toBeInTheDocument()
+ })
+
+ it('should render contact button for enterprise plans', () => {
+ // Arrange
+ ;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
+ plan: {
+ ...baseProviderContextValue.plan,
+ type: Plan.enterprise,
+ usage: buildUsage({ buildApps: 9 }),
+ total: buildUsage({ buildApps: 10 }),
+ reset: {
+ apiRateLimit: null,
+ triggerEvents: null,
+ },
+ },
+ }))
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText('billing.apps.fullTip1')).toBeInTheDocument()
+ expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument()
+ expect(screen.getByRole('link', { name: 'billing.apps.contactUs' })).toHaveAttribute('href', 'mailto:support@example.com')
+ expect(mailToSupport).toHaveBeenCalledWith('user@example.com', Plan.enterprise, '1.0.0')
+ })
+ })
+
+ // Edge cases for progress color thresholds.
+ describe('Edge Cases', () => {
+ it('should use the success color when usage is below 50%', () => {
+ // Arrange
+ ;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
+ plan: {
+ ...baseProviderContextValue.plan,
+ type: Plan.sandbox,
+ usage: buildUsage({ buildApps: 2 }),
+ total: buildUsage({ buildApps: 5 }),
+ reset: {
+ apiRateLimit: null,
+ triggerEvents: null,
+ },
+ },
+ }))
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('billing-progress-bar')).toHaveClass('bg-components-progress-bar-progress-solid')
+ })
+
+ it('should use the warning color when usage is between 50% and 80%', () => {
+ // Arrange
+ ;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
+ plan: {
+ ...baseProviderContextValue.plan,
+ type: Plan.sandbox,
+ usage: buildUsage({ buildApps: 6 }),
+ total: buildUsage({ buildApps: 10 }),
+ reset: {
+ apiRateLimit: null,
+ triggerEvents: null,
+ },
+ },
+ }))
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('billing-progress-bar')).toHaveClass('bg-components-progress-warning-progress')
+ })
+
+ it('should use the error color when usage is 80% or higher', () => {
+ // Arrange
+ ;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
+ plan: {
+ ...baseProviderContextValue.plan,
+ type: Plan.sandbox,
+ usage: buildUsage({ buildApps: 8 }),
+ total: buildUsage({ buildApps: 10 }),
+ reset: {
+ apiRateLimit: null,
+ triggerEvents: null,
+ },
+ },
+ }))
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('billing-progress-bar')).toHaveClass('bg-components-progress-error-progress')
+ })
+ })
+})
diff --git a/web/app/components/billing/pricing/assets/index.spec.tsx b/web/app/components/billing/pricing/assets/index.spec.tsx
new file mode 100644
index 0000000000..7980f9a182
--- /dev/null
+++ b/web/app/components/billing/pricing/assets/index.spec.tsx
@@ -0,0 +1,64 @@
+import { render } from '@testing-library/react'
+import {
+ Cloud,
+ Community,
+ Enterprise,
+ EnterpriseNoise,
+ NoiseBottom,
+ NoiseTop,
+ Premium,
+ PremiumNoise,
+ Professional,
+ Sandbox,
+ SelfHosted,
+ Team,
+} from './index'
+
+describe('Pricing Assets', () => {
+ // Rendering: each asset should render an svg.
+ describe('Rendering', () => {
+ it('should render static assets without crashing', () => {
+ // Arrange
+ const assets = [
+ ,
+ ,
+ ,
+ ,
+ ,
+ ,
+ ,
+ ,
+ ,
+ ,
+ ]
+
+ // Act / Assert
+ assets.forEach((asset) => {
+ const { container, unmount } = render(asset)
+ expect(container.querySelector('svg')).toBeInTheDocument()
+ unmount()
+ })
+ })
+ })
+
+ // Props: active state should change fill color for selectable assets.
+ describe('Props', () => {
+ it('should render active state for Cloud', () => {
+ // Arrange
+ const { container } = render()
+
+ // Assert
+ const rects = Array.from(container.querySelectorAll('rect'))
+ expect(rects.some(rect => rect.getAttribute('fill') === 'var(--color-saas-dify-blue-accessible)')).toBe(true)
+ })
+
+ it('should render inactive state for SelfHosted', () => {
+ // Arrange
+ const { container } = render()
+
+ // Assert
+ const rects = Array.from(container.querySelectorAll('rect'))
+ expect(rects.some(rect => rect.getAttribute('fill') === 'var(--color-text-primary)')).toBe(true)
+ })
+ })
+})
diff --git a/web/app/components/billing/pricing/footer.spec.tsx b/web/app/components/billing/pricing/footer.spec.tsx
new file mode 100644
index 0000000000..f8e7965f5e
--- /dev/null
+++ b/web/app/components/billing/pricing/footer.spec.tsx
@@ -0,0 +1,68 @@
+import { render, screen } from '@testing-library/react'
+import * as React from 'react'
+import { CategoryEnum } from '.'
+import Footer from './footer'
+
+let mockTranslations: Record = {}
+
+vi.mock('next/link', () => ({
+ default: ({ children, href, className, target }: { children: React.ReactNode, href: string, className?: string, target?: string }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('react-i18next', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ useTranslation: () => ({
+ t: (key: string) => mockTranslations[key] ?? key,
+ }),
+ }
+})
+
+describe('Footer', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockTranslations = {}
+ })
+
+ // Rendering behavior
+ describe('Rendering', () => {
+ it('should render tax tips and comparison link when in cloud category', () => {
+ // Arrange
+ render()
+
+ // Assert
+ expect(screen.getByText('billing.plansCommon.taxTip')).toBeInTheDocument()
+ expect(screen.getByText('billing.plansCommon.taxTipSecond')).toBeInTheDocument()
+ expect(screen.getByTestId('pricing-link')).toHaveAttribute('href', 'https://dify.ai/pricing#plans-and-features')
+ expect(screen.getByText('billing.plansCommon.comparePlanAndFeatures')).toBeInTheDocument()
+ })
+ })
+
+ // Prop-driven behavior
+ describe('Props', () => {
+ it('should hide tax tips when category is self-hosted', () => {
+ // Arrange
+ render()
+
+ // Assert
+ expect(screen.queryByText('billing.plansCommon.taxTip')).not.toBeInTheDocument()
+ expect(screen.queryByText('billing.plansCommon.taxTipSecond')).not.toBeInTheDocument()
+ })
+ })
+
+ // Edge case rendering behavior
+ describe('Edge Cases', () => {
+ it('should render link even when pricing URL is empty', () => {
+ // Arrange
+ render()
+
+ // Assert
+ expect(screen.getByTestId('pricing-link')).toHaveAttribute('href', '')
+ })
+ })
+})
diff --git a/web/app/components/billing/pricing/header.spec.tsx b/web/app/components/billing/pricing/header.spec.tsx
new file mode 100644
index 0000000000..0395e5dd48
--- /dev/null
+++ b/web/app/components/billing/pricing/header.spec.tsx
@@ -0,0 +1,72 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import * as React from 'react'
+import Header from './header'
+
+let mockTranslations: Record = {}
+
+vi.mock('react-i18next', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ useTranslation: () => ({
+ t: (key: string) => mockTranslations[key] ?? key,
+ }),
+ }
+})
+
+describe('Header', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockTranslations = {}
+ })
+
+ // Rendering behavior
+ describe('Rendering', () => {
+ it('should render title and description translations', () => {
+ // Arrange
+ const handleClose = vi.fn()
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText('billing.plansCommon.title.plans')).toBeInTheDocument()
+ expect(screen.getByText('billing.plansCommon.title.description')).toBeInTheDocument()
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+ })
+
+ // Prop-driven behavior
+ describe('Props', () => {
+ it('should invoke onClose when close button is clicked', () => {
+ // Arrange
+ const handleClose = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByRole('button'))
+
+ // Assert
+ expect(handleClose).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ // Edge case rendering behavior
+ describe('Edge Cases', () => {
+ it('should render structure when translations are empty strings', () => {
+ // Arrange
+ mockTranslations = {
+ 'billing.plansCommon.title.plans': '',
+ 'billing.plansCommon.title.description': '',
+ }
+
+ // Act
+ const { container } = render()
+
+ // Assert
+ expect(container.querySelector('span')).toBeInTheDocument()
+ expect(container.querySelector('p')).toBeInTheDocument()
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/billing/pricing/index.spec.tsx b/web/app/components/billing/pricing/index.spec.tsx
new file mode 100644
index 0000000000..141c2d9c96
--- /dev/null
+++ b/web/app/components/billing/pricing/index.spec.tsx
@@ -0,0 +1,119 @@
+import type { Mock } from 'vitest'
+import type { UsagePlanInfo } from '../type'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { useKeyPress } from 'ahooks'
+import * as React from 'react'
+import { useAppContext } from '@/context/app-context'
+import { useGetPricingPageLanguage } from '@/context/i18n'
+import { useProviderContext } from '@/context/provider-context'
+import { Plan } from '../type'
+import Pricing from './index'
+
+let mockTranslations: Record = {}
+let mockLanguage: string | null = 'en'
+
+vi.mock('next/link', () => ({
+ default: ({ children, href, className, target }: { children: React.ReactNode, href: string, className?: string, target?: string }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('ahooks', () => ({
+ useKeyPress: vi.fn(),
+}))
+
+vi.mock('@/context/app-context', () => ({
+ useAppContext: vi.fn(),
+}))
+
+vi.mock('@/context/provider-context', () => ({
+ useProviderContext: vi.fn(),
+}))
+
+vi.mock('@/context/i18n', () => ({
+ useGetPricingPageLanguage: vi.fn(),
+}))
+
+vi.mock('react-i18next', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ useTranslation: () => ({
+ t: (key: string, options?: { returnObjects?: boolean }) => {
+ if (options?.returnObjects)
+ return mockTranslations[key] ?? []
+ return mockTranslations[key] ?? key
+ },
+ }),
+ Trans: ({ i18nKey }: { i18nKey: string }) => {i18nKey},
+ }
+})
+
+const buildUsage = (): UsagePlanInfo => ({
+ buildApps: 0,
+ teamMembers: 0,
+ annotatedResponse: 0,
+ documentsUploadQuota: 0,
+ apiRateLimit: 0,
+ triggerEvents: 0,
+ vectorSpace: 0,
+})
+
+describe('Pricing', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockTranslations = {}
+ mockLanguage = 'en'
+ ;(useAppContext as Mock).mockReturnValue({ isCurrentWorkspaceManager: true })
+ ;(useProviderContext as Mock).mockReturnValue({
+ plan: {
+ type: Plan.sandbox,
+ usage: buildUsage(),
+ total: buildUsage(),
+ },
+ })
+ ;(useGetPricingPageLanguage as Mock).mockImplementation(() => mockLanguage)
+ })
+
+ // Rendering behavior
+ describe('Rendering', () => {
+ it('should render pricing header and localized footer link', () => {
+ // Arrange
+ render()
+
+ // Assert
+ expect(screen.getByText('billing.plansCommon.title.plans')).toBeInTheDocument()
+ expect(screen.getByTestId('pricing-link')).toHaveAttribute('href', 'https://dify.ai/en/pricing#plans-and-features')
+ })
+ })
+
+ // Prop-driven behavior
+ describe('Props', () => {
+ it('should register esc key handler and allow switching categories', () => {
+ // Arrange
+ const handleCancel = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByText('billing.plansCommon.self'))
+
+ // Assert
+ expect(useKeyPress).toHaveBeenCalledWith(['esc'], handleCancel)
+ expect(screen.queryByRole('switch')).not.toBeInTheDocument()
+ })
+ })
+
+ // Edge case rendering behavior
+ describe('Edge Cases', () => {
+ it('should fall back to default pricing URL when language is empty', () => {
+ // Arrange
+ mockLanguage = ''
+ render()
+
+ // Assert
+ expect(screen.getByTestId('pricing-link')).toHaveAttribute('href', 'https://dify.ai/pricing#plans-and-features')
+ })
+ })
+})
diff --git a/web/app/components/billing/pricing/plan-switcher/index.spec.tsx b/web/app/components/billing/pricing/plan-switcher/index.spec.tsx
new file mode 100644
index 0000000000..641d359bfd
--- /dev/null
+++ b/web/app/components/billing/pricing/plan-switcher/index.spec.tsx
@@ -0,0 +1,109 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import * as React from 'react'
+import { CategoryEnum } from '../index'
+import PlanSwitcher from './index'
+import { PlanRange } from './plan-range-switcher'
+
+let mockTranslations: Record = {}
+
+vi.mock('react-i18next', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ useTranslation: () => ({
+ t: (key: string) => mockTranslations[key] ?? key,
+ }),
+ }
+})
+
+describe('PlanSwitcher', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockTranslations = {}
+ })
+
+ // Rendering behavior
+ describe('Rendering', () => {
+ it('should render category tabs and plan range switcher for cloud', () => {
+ // Arrange
+ render(
+ ,
+ )
+
+ // Assert
+ expect(screen.getByText('billing.plansCommon.cloud')).toBeInTheDocument()
+ expect(screen.getByText('billing.plansCommon.self')).toBeInTheDocument()
+ expect(screen.getByRole('switch')).toBeInTheDocument()
+ })
+ })
+
+ // Prop-driven behavior
+ describe('Props', () => {
+ it('should call onChangeCategory when selecting a tab', () => {
+ // Arrange
+ const handleChangeCategory = vi.fn()
+ render(
+ ,
+ )
+
+ // Act
+ fireEvent.click(screen.getByText('billing.plansCommon.self'))
+
+ // Assert
+ expect(handleChangeCategory).toHaveBeenCalledTimes(1)
+ expect(handleChangeCategory).toHaveBeenCalledWith(CategoryEnum.SELF)
+ })
+
+ it('should hide plan range switcher when category is self-hosted', () => {
+ // Arrange
+ render(
+ ,
+ )
+
+ // Assert
+ expect(screen.queryByRole('switch')).not.toBeInTheDocument()
+ })
+ })
+
+ // Edge case rendering behavior
+ describe('Edge Cases', () => {
+ it('should render tabs when translation strings are empty', () => {
+ // Arrange
+ mockTranslations = {
+ 'billing.plansCommon.cloud': '',
+ 'billing.plansCommon.self': '',
+ }
+
+ // Act
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ const labels = container.querySelectorAll('span')
+ expect(labels).toHaveLength(2)
+ expect(labels[0]?.textContent).toBe('')
+ expect(labels[1]?.textContent).toBe('')
+ })
+ })
+})
diff --git a/web/app/components/billing/pricing/plan-switcher/plan-range-switcher.spec.tsx b/web/app/components/billing/pricing/plan-switcher/plan-range-switcher.spec.tsx
new file mode 100644
index 0000000000..0b4c00603c
--- /dev/null
+++ b/web/app/components/billing/pricing/plan-switcher/plan-range-switcher.spec.tsx
@@ -0,0 +1,81 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import * as React from 'react'
+import PlanRangeSwitcher, { PlanRange } from './plan-range-switcher'
+
+let mockTranslations: Record = {}
+
+vi.mock('react-i18next', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ useTranslation: () => ({
+ t: (key: string) => mockTranslations[key] ?? key,
+ }),
+ }
+})
+
+describe('PlanRangeSwitcher', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockTranslations = {}
+ })
+
+ // Rendering behavior
+ describe('Rendering', () => {
+ it('should render the annual billing label', () => {
+ // Arrange
+ render()
+
+ // Assert
+ expect(screen.getByText('billing.plansCommon.annualBilling')).toBeInTheDocument()
+ expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false')
+ })
+ })
+
+ // Prop-driven behavior
+ describe('Props', () => {
+ it('should switch to yearly when toggled from monthly', () => {
+ // Arrange
+ const handleChange = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByRole('switch'))
+
+ // Assert
+ expect(handleChange).toHaveBeenCalledTimes(1)
+ expect(handleChange).toHaveBeenCalledWith(PlanRange.yearly)
+ })
+
+ it('should switch to monthly when toggled from yearly', () => {
+ // Arrange
+ const handleChange = vi.fn()
+ render()
+
+ // Act
+ fireEvent.click(screen.getByRole('switch'))
+
+ // Assert
+ expect(handleChange).toHaveBeenCalledTimes(1)
+ expect(handleChange).toHaveBeenCalledWith(PlanRange.monthly)
+ })
+ })
+
+ // Edge case rendering behavior
+ describe('Edge Cases', () => {
+ it('should render when the translation string is empty', () => {
+ // Arrange
+ mockTranslations = {
+ 'billing.plansCommon.annualBilling': '',
+ }
+
+ // Act
+ const { container } = render()
+
+ // Assert
+ const label = container.querySelector('span')
+ expect(label).toBeInTheDocument()
+ expect(label?.textContent).toBe('')
+ })
+ })
+})
diff --git a/web/app/components/billing/pricing/plan-switcher/tab.spec.tsx b/web/app/components/billing/pricing/plan-switcher/tab.spec.tsx
new file mode 100644
index 0000000000..5c335e0dd1
--- /dev/null
+++ b/web/app/components/billing/pricing/plan-switcher/tab.spec.tsx
@@ -0,0 +1,95 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import * as React from 'react'
+import Tab from './tab'
+
+const Icon = ({ isActive }: { isActive: boolean }) => (
+
+)
+
+describe('PlanSwitcherTab', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ // Rendering behavior
+ describe('Rendering', () => {
+ it('should render label and icon', () => {
+ // Arrange
+ render(
+ ,
+ )
+
+ // Assert
+ expect(screen.getByText('Cloud')).toBeInTheDocument()
+ expect(screen.getByTestId('tab-icon')).toHaveAttribute('data-active', 'false')
+ })
+ })
+
+ // Prop-driven behavior
+ describe('Props', () => {
+ it('should call onClick with the provided value', () => {
+ // Arrange
+ const handleClick = vi.fn()
+ render(
+ ,
+ )
+
+ // Act
+ fireEvent.click(screen.getByText('Self'))
+
+ // Assert
+ expect(handleClick).toHaveBeenCalledTimes(1)
+ expect(handleClick).toHaveBeenCalledWith('self')
+ })
+
+ it('should apply active text class when isActive is true', () => {
+ // Arrange
+ render(
+ ,
+ )
+
+ // Assert
+ expect(screen.getByText('Cloud')).toHaveClass('text-saas-dify-blue-accessible')
+ expect(screen.getByTestId('tab-icon')).toHaveAttribute('data-active', 'true')
+ })
+ })
+
+ // Edge case rendering behavior
+ describe('Edge Cases', () => {
+ it('should render when label is empty', () => {
+ // Arrange
+ const { container } = render(
+ ,
+ )
+
+ // Assert
+ const label = container.querySelector('span')
+ expect(label).toBeInTheDocument()
+ expect(label?.textContent).toBe('')
+ })
+ })
+})
diff --git a/web/app/components/billing/progress-bar/index.tsx b/web/app/components/billing/progress-bar/index.tsx
index 383b516b61..c41fc53310 100644
--- a/web/app/components/billing/progress-bar/index.tsx
+++ b/web/app/components/billing/progress-bar/index.tsx
@@ -12,6 +12,7 @@ const ProgressBar = ({
return (