mirror of
https://github.com/langgenius/dify.git
synced 2025-12-19 17:27:16 -05:00
Merge remote-tracking branch 'origin/main' into feat/trigger
This commit is contained in:
@@ -527,7 +527,7 @@ API_WORKFLOW_NODE_EXECUTION_REPOSITORY=repositories.sqlalchemy_api_workflow_node
|
||||
API_WORKFLOW_RUN_REPOSITORY=repositories.sqlalchemy_api_workflow_run_repository.DifyAPISQLAlchemyWorkflowRunRepository
|
||||
# Workflow log cleanup configuration
|
||||
# Enable automatic cleanup of workflow run logs to manage database size
|
||||
WORKFLOW_LOG_CLEANUP_ENABLED=true
|
||||
WORKFLOW_LOG_CLEANUP_ENABLED=false
|
||||
# Number of days to retain workflow run logs (default: 30 days)
|
||||
WORKFLOW_LOG_RETENTION_DAYS=30
|
||||
# Batch size for workflow log cleanup operations (default: 100)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import sys
|
||||
|
||||
|
||||
def is_db_command():
|
||||
def is_db_command() -> bool:
|
||||
if len(sys.argv) > 1 and sys.argv[0].endswith("flask") and sys.argv[1] == "db":
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -1190,7 +1190,7 @@ class AccountConfig(BaseSettings):
|
||||
|
||||
|
||||
class WorkflowLogConfig(BaseSettings):
|
||||
WORKFLOW_LOG_CLEANUP_ENABLED: bool = Field(default=True, description="Enable workflow run log cleanup")
|
||||
WORKFLOW_LOG_CLEANUP_ENABLED: bool = Field(default=False, description="Enable workflow run log cleanup")
|
||||
WORKFLOW_LOG_RETENTION_DAYS: int = Field(default=30, description="Retention days for workflow run logs")
|
||||
WORKFLOW_LOG_CLEANUP_BATCH_SIZE: int = Field(
|
||||
default=100, description="Batch size for workflow run log cleanup operations"
|
||||
|
||||
@@ -6,7 +6,7 @@ import sqlalchemy as sa
|
||||
from sqlalchemy import DateTime, String, func, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import Base
|
||||
from .base import Base, TypeBase
|
||||
from .engine import db
|
||||
from .types import StringUUID
|
||||
|
||||
@@ -41,7 +41,7 @@ class ProviderQuotaType(StrEnum):
|
||||
raise ValueError(f"No matching enum found for value '{value}'")
|
||||
|
||||
|
||||
class Provider(Base):
|
||||
class Provider(TypeBase):
|
||||
"""
|
||||
Provider model representing the API providers and their configurations.
|
||||
"""
|
||||
@@ -55,25 +55,27 @@ class Provider(Base):
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(StringUUID, server_default=text("uuid_generate_v4()"))
|
||||
id: Mapped[str] = mapped_column(StringUUID, primary_key=True, server_default=text("uuidv7()"), init=False)
|
||||
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
provider_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
provider_type: Mapped[str] = mapped_column(
|
||||
String(40), nullable=False, server_default=text("'custom'::character varying")
|
||||
String(40), nullable=False, server_default=text("'custom'::character varying"), default="custom"
|
||||
)
|
||||
is_valid: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=text("false"))
|
||||
last_used: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
credential_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
is_valid: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=text("false"), default=False)
|
||||
last_used: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, init=False)
|
||||
credential_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None)
|
||||
|
||||
quota_type: Mapped[str | None] = mapped_column(
|
||||
String(40), nullable=True, server_default=text("''::character varying")
|
||||
String(40), nullable=True, server_default=text("''::character varying"), default=""
|
||||
)
|
||||
quota_limit: Mapped[int | None] = mapped_column(sa.BigInteger, nullable=True)
|
||||
quota_used: Mapped[int | None] = mapped_column(sa.BigInteger, default=0)
|
||||
quota_limit: Mapped[int | None] = mapped_column(sa.BigInteger, nullable=True, default=None)
|
||||
quota_used: Mapped[int] = mapped_column(sa.BigInteger, nullable=False, default=0)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp())
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.current_timestamp(), init=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp()
|
||||
DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp(), init=False
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
|
||||
@@ -808,7 +808,11 @@ class DraftVariableSaver:
|
||||
# We only save conversation variable here.
|
||||
if selector[0] != CONVERSATION_VARIABLE_NODE_ID:
|
||||
continue
|
||||
segment = WorkflowDraftVariable.build_segment_with_type(segment_type=item.value_type, value=item.new_value)
|
||||
# Conversation variables are exposed as NUMBER in the UI even if their
|
||||
# persisted type is INTEGER. Allow float updates by loosening the type
|
||||
# to NUMBER here so downstream storage infers the precise subtype.
|
||||
segment_type = SegmentType.NUMBER if item.value_type == SegmentType.INTEGER else item.value_type
|
||||
segment = WorkflowDraftVariable.build_segment_with_type(segment_type=segment_type, value=item.new_value)
|
||||
draft_vars.append(
|
||||
WorkflowDraftVariable.new_conversation_variable(
|
||||
app_id=self._app_id,
|
||||
|
||||
@@ -8,9 +8,14 @@ cd "$SCRIPT_DIR/.."
|
||||
# Get the path argument if provided
|
||||
PATH_TO_CHECK="$1"
|
||||
|
||||
# run basedpyright checks
|
||||
if [ -n "$PATH_TO_CHECK" ]; then
|
||||
uv run --directory api --dev -- basedpyright --threads $(nproc) "$PATH_TO_CHECK"
|
||||
else
|
||||
uv run --directory api --dev -- basedpyright --threads $(nproc)
|
||||
fi
|
||||
# Determine CPU core count based on OS
|
||||
CPU_CORES=$(
|
||||
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||
sysctl -n hw.ncpu 2>/dev/null
|
||||
else
|
||||
nproc
|
||||
fi
|
||||
)
|
||||
|
||||
# Run basedpyright checks
|
||||
uv run --directory api --dev -- basedpyright --threads "$CPU_CORES" $PATH_TO_CHECK
|
||||
|
||||
@@ -305,9 +305,23 @@ export const useFile = (fileConfig: FileUpload) => {
|
||||
const text = e.clipboardData?.getData('text/plain')
|
||||
if (file && !text) {
|
||||
e.preventDefault()
|
||||
|
||||
const allowedFileTypes = fileConfig.allowed_file_types || []
|
||||
const fileType = getSupportFileType(file.name, file.type, allowedFileTypes?.includes(SupportUploadFileTypes.custom))
|
||||
const isFileTypeAllowed = allowedFileTypes.includes(fileType)
|
||||
|
||||
// Check if file type is in allowed list
|
||||
if (!isFileTypeAllowed || !fileConfig.enabled) {
|
||||
notify({
|
||||
type: 'error',
|
||||
message: t('common.fileUploader.fileExtensionNotSupport'),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
handleLocalFileUpload(file)
|
||||
}
|
||||
}, [handleLocalFileUpload])
|
||||
}, [handleLocalFileUpload, fileConfig, notify, t])
|
||||
|
||||
const [isDragActive, setIsDragActive] = useState(false)
|
||||
const handleDragFileEnter = useCallback((e: React.DragEvent<HTMLElement>) => {
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
/**
|
||||
* Test suite for useBreakpoints hook
|
||||
*
|
||||
* This hook provides responsive breakpoint detection based on window width.
|
||||
* It listens to window resize events and returns the current media type.
|
||||
*
|
||||
* Breakpoint definitions:
|
||||
* - mobile: width <= 640px
|
||||
* - tablet: 640px < width <= 768px
|
||||
* - pc: width > 768px
|
||||
*
|
||||
* The hook automatically updates when the window is resized and cleans up
|
||||
* event listeners on unmount to prevent memory leaks.
|
||||
*/
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import useBreakpoints, { MediaType } from './use-breakpoints'
|
||||
|
||||
describe('useBreakpoints', () => {
|
||||
const originalInnerWidth = window.innerWidth
|
||||
|
||||
// Mock the window resize event
|
||||
/**
|
||||
* Helper function to simulate window resize events
|
||||
* Updates window.innerWidth and dispatches a resize event
|
||||
*/
|
||||
const fireResize = (width: number) => {
|
||||
window.innerWidth = width
|
||||
act(() => {
|
||||
@@ -12,11 +29,18 @@ describe('useBreakpoints', () => {
|
||||
})
|
||||
}
|
||||
|
||||
// Restore the original innerWidth after tests
|
||||
/**
|
||||
* Restore the original innerWidth after all tests
|
||||
* Ensures tests don't affect each other or the test environment
|
||||
*/
|
||||
afterAll(() => {
|
||||
window.innerWidth = originalInnerWidth
|
||||
})
|
||||
|
||||
/**
|
||||
* Test mobile breakpoint detection
|
||||
* Mobile devices have width <= 640px
|
||||
*/
|
||||
it('should return mobile for width <= 640px', () => {
|
||||
// Mock window.innerWidth for mobile
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
@@ -29,6 +53,10 @@ describe('useBreakpoints', () => {
|
||||
expect(result.current).toBe(MediaType.mobile)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test tablet breakpoint detection
|
||||
* Tablet devices have width between 640px and 768px
|
||||
*/
|
||||
it('should return tablet for width > 640px and <= 768px', () => {
|
||||
// Mock window.innerWidth for tablet
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
@@ -41,6 +69,10 @@ describe('useBreakpoints', () => {
|
||||
expect(result.current).toBe(MediaType.tablet)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test desktop/PC breakpoint detection
|
||||
* Desktop devices have width > 768px
|
||||
*/
|
||||
it('should return pc for width > 768px', () => {
|
||||
// Mock window.innerWidth for pc
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
@@ -53,6 +85,10 @@ describe('useBreakpoints', () => {
|
||||
expect(result.current).toBe(MediaType.pc)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test dynamic breakpoint updates on window resize
|
||||
* The hook should react to window resize events and update the media type
|
||||
*/
|
||||
it('should update media type when window resizes', () => {
|
||||
// Start with desktop
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
@@ -73,6 +109,10 @@ describe('useBreakpoints', () => {
|
||||
expect(result.current).toBe(MediaType.mobile)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test proper cleanup of event listeners
|
||||
* Ensures no memory leaks by removing resize listeners on unmount
|
||||
*/
|
||||
it('should clean up event listeners on unmount', () => {
|
||||
// Spy on addEventListener and removeEventListener
|
||||
const addEventListenerSpy = jest.spyOn(window, 'addEventListener')
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
/**
|
||||
* Test suite for useDocumentTitle hook
|
||||
*
|
||||
* This hook manages the browser document title with support for:
|
||||
* - Custom branding (when enabled in system features)
|
||||
* - Default "Dify" branding
|
||||
* - Pending state handling (prevents title flicker during loading)
|
||||
* - Page-specific titles with automatic suffix
|
||||
*
|
||||
* Title format: "[Page Title] - [Brand Name]"
|
||||
* If no page title: "[Brand Name]"
|
||||
*/
|
||||
import { defaultSystemFeatures } from '@/types/feature'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import useDocumentTitle from './use-document-title'
|
||||
@@ -7,6 +19,10 @@ jest.mock('@/service/common', () => ({
|
||||
getSystemFeatures: jest.fn(() => ({ ...defaultSystemFeatures })),
|
||||
}))
|
||||
|
||||
/**
|
||||
* Test behavior when system features are still loading
|
||||
* Title should remain empty to prevent flicker
|
||||
*/
|
||||
describe('title should be empty if systemFeatures is pending', () => {
|
||||
act(() => {
|
||||
useGlobalPublicStore.setState({
|
||||
@@ -14,16 +30,26 @@ describe('title should be empty if systemFeatures is pending', () => {
|
||||
isGlobalPending: true,
|
||||
})
|
||||
})
|
||||
/**
|
||||
* Test that title stays empty during loading even when a title is provided
|
||||
*/
|
||||
it('document title should be empty if set title', () => {
|
||||
renderHook(() => useDocumentTitle('test'))
|
||||
expect(document.title).toBe('')
|
||||
})
|
||||
/**
|
||||
* Test that title stays empty during loading when no title is provided
|
||||
*/
|
||||
it('document title should be empty if not set title', () => {
|
||||
renderHook(() => useDocumentTitle(''))
|
||||
expect(document.title).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Test default Dify branding behavior
|
||||
* When custom branding is disabled, should use "Dify" as the brand name
|
||||
*/
|
||||
describe('use default branding', () => {
|
||||
beforeEach(() => {
|
||||
act(() => {
|
||||
@@ -33,17 +59,29 @@ describe('use default branding', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
/**
|
||||
* Test title format with page title and default branding
|
||||
* Format: "[page] - Dify"
|
||||
*/
|
||||
it('document title should be test-Dify if set title', () => {
|
||||
renderHook(() => useDocumentTitle('test'))
|
||||
expect(document.title).toBe('test - Dify')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test title with only default branding (no page title)
|
||||
* Format: "Dify"
|
||||
*/
|
||||
it('document title should be Dify if not set title', () => {
|
||||
renderHook(() => useDocumentTitle(''))
|
||||
expect(document.title).toBe('Dify')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Test custom branding behavior
|
||||
* When custom branding is enabled, should use the configured application_title
|
||||
*/
|
||||
describe('use specific branding', () => {
|
||||
beforeEach(() => {
|
||||
act(() => {
|
||||
@@ -53,11 +91,19 @@ describe('use specific branding', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
/**
|
||||
* Test title format with page title and custom branding
|
||||
* Format: "[page] - [Custom Brand]"
|
||||
*/
|
||||
it('document title should be test-Test if set title', () => {
|
||||
renderHook(() => useDocumentTitle('test'))
|
||||
expect(document.title).toBe('test - Test')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test title with only custom branding (no page title)
|
||||
* Format: "[Custom Brand]"
|
||||
*/
|
||||
it('document title should be Test if not set title', () => {
|
||||
renderHook(() => useDocumentTitle(''))
|
||||
expect(document.title).toBe('Test')
|
||||
|
||||
376
web/hooks/use-format-time-from-now.spec.ts
Normal file
376
web/hooks/use-format-time-from-now.spec.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
/**
|
||||
* Test suite for useFormatTimeFromNow hook
|
||||
*
|
||||
* This hook provides internationalized relative time formatting (e.g., "2 hours ago", "3 days ago")
|
||||
* using dayjs with the relativeTime plugin. It automatically uses the correct locale based on
|
||||
* the user's i18n settings.
|
||||
*
|
||||
* Key features:
|
||||
* - Supports 20+ locales with proper translations
|
||||
* - Automatically syncs with user's interface language
|
||||
* - Uses dayjs for consistent time calculations
|
||||
* - Returns human-readable relative time strings
|
||||
*/
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useFormatTimeFromNow } from './use-format-time-from-now'
|
||||
|
||||
// Mock the i18n context
|
||||
jest.mock('@/context/i18n', () => ({
|
||||
useI18N: jest.fn(() => ({
|
||||
locale: 'en-US',
|
||||
})),
|
||||
}))
|
||||
|
||||
// Import after mock to get the mocked version
|
||||
import { useI18N } from '@/context/i18n'
|
||||
|
||||
describe('useFormatTimeFromNow', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Basic functionality', () => {
|
||||
/**
|
||||
* Test that the hook returns a formatTimeFromNow function
|
||||
* This is the primary interface of the hook
|
||||
*/
|
||||
it('should return formatTimeFromNow function', () => {
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
expect(result.current).toHaveProperty('formatTimeFromNow')
|
||||
expect(typeof result.current.formatTimeFromNow).toBe('function')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test basic relative time formatting with English locale
|
||||
* Should return human-readable relative time strings
|
||||
*/
|
||||
it('should format time from now in English', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const now = Date.now()
|
||||
const oneHourAgo = now - (60 * 60 * 1000)
|
||||
const formatted = result.current.formatTimeFromNow(oneHourAgo)
|
||||
|
||||
// Should contain "hour" or "hours" and "ago"
|
||||
expect(formatted).toMatch(/hour|hours/)
|
||||
expect(formatted).toMatch(/ago/)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that recent times are formatted as "a few seconds ago"
|
||||
* Very recent timestamps should show seconds
|
||||
*/
|
||||
it('should format very recent times', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const now = Date.now()
|
||||
const fiveSecondsAgo = now - (5 * 1000)
|
||||
const formatted = result.current.formatTimeFromNow(fiveSecondsAgo)
|
||||
|
||||
expect(formatted).toMatch(/second|seconds|few seconds/)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test formatting of times in the past (days ago)
|
||||
* Should handle day-level granularity
|
||||
*/
|
||||
it('should format times from days ago', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const now = Date.now()
|
||||
const threeDaysAgo = now - (3 * 24 * 60 * 60 * 1000)
|
||||
const formatted = result.current.formatTimeFromNow(threeDaysAgo)
|
||||
|
||||
expect(formatted).toMatch(/day|days/)
|
||||
expect(formatted).toMatch(/ago/)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test formatting of future times
|
||||
* dayjs fromNow also supports future times (e.g., "in 2 hours")
|
||||
*/
|
||||
it('should format future times', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const now = Date.now()
|
||||
const twoHoursFromNow = now + (2 * 60 * 60 * 1000)
|
||||
const formatted = result.current.formatTimeFromNow(twoHoursFromNow)
|
||||
|
||||
expect(formatted).toMatch(/in/)
|
||||
expect(formatted).toMatch(/hour|hours/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Locale support', () => {
|
||||
/**
|
||||
* Test Chinese (Simplified) locale formatting
|
||||
* Should use Chinese characters for time units
|
||||
*/
|
||||
it('should format time in Chinese (Simplified)', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'zh-Hans' })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const now = Date.now()
|
||||
const oneHourAgo = now - (60 * 60 * 1000)
|
||||
const formatted = result.current.formatTimeFromNow(oneHourAgo)
|
||||
|
||||
// Chinese should contain Chinese characters
|
||||
expect(formatted).toMatch(/[\u4E00-\u9FA5]/)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test Spanish locale formatting
|
||||
* Should use Spanish words for relative time
|
||||
*/
|
||||
it('should format time in Spanish', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'es-ES' })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const now = Date.now()
|
||||
const oneHourAgo = now - (60 * 60 * 1000)
|
||||
const formatted = result.current.formatTimeFromNow(oneHourAgo)
|
||||
|
||||
// Spanish should contain "hace" (ago)
|
||||
expect(formatted).toMatch(/hace/)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test French locale formatting
|
||||
* Should use French words for relative time
|
||||
*/
|
||||
it('should format time in French', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'fr-FR' })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const now = Date.now()
|
||||
const oneHourAgo = now - (60 * 60 * 1000)
|
||||
const formatted = result.current.formatTimeFromNow(oneHourAgo)
|
||||
|
||||
// French should contain "il y a" (ago)
|
||||
expect(formatted).toMatch(/il y a/)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test Japanese locale formatting
|
||||
* Should use Japanese characters
|
||||
*/
|
||||
it('should format time in Japanese', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'ja-JP' })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const now = Date.now()
|
||||
const oneHourAgo = now - (60 * 60 * 1000)
|
||||
const formatted = result.current.formatTimeFromNow(oneHourAgo)
|
||||
|
||||
// Japanese should contain Japanese characters
|
||||
expect(formatted).toMatch(/[\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF]/)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test Portuguese (Brazil) locale formatting
|
||||
* Should use pt-br locale mapping
|
||||
*/
|
||||
it('should format time in Portuguese (Brazil)', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'pt-BR' })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const now = Date.now()
|
||||
const oneHourAgo = now - (60 * 60 * 1000)
|
||||
const formatted = result.current.formatTimeFromNow(oneHourAgo)
|
||||
|
||||
// Portuguese should contain "há" (ago)
|
||||
expect(formatted).toMatch(/há/)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test fallback to English for unsupported locales
|
||||
* Unknown locales should default to English
|
||||
*/
|
||||
it('should fallback to English for unsupported locale', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'xx-XX' as any })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const now = Date.now()
|
||||
const oneHourAgo = now - (60 * 60 * 1000)
|
||||
const formatted = result.current.formatTimeFromNow(oneHourAgo)
|
||||
|
||||
// Should still return a valid string (in English)
|
||||
expect(typeof formatted).toBe('string')
|
||||
expect(formatted.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge cases', () => {
|
||||
/**
|
||||
* Test handling of timestamp 0 (Unix epoch)
|
||||
* Should format as a very old date
|
||||
*/
|
||||
it('should handle timestamp 0', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const formatted = result.current.formatTimeFromNow(0)
|
||||
|
||||
expect(typeof formatted).toBe('string')
|
||||
expect(formatted.length).toBeGreaterThan(0)
|
||||
expect(formatted).toMatch(/year|years/)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test handling of very large timestamps
|
||||
* Should handle dates far in the future
|
||||
*/
|
||||
it('should handle very large timestamps', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const farFuture = Date.now() + (365 * 24 * 60 * 60 * 1000) // 1 year from now
|
||||
const formatted = result.current.formatTimeFromNow(farFuture)
|
||||
|
||||
expect(typeof formatted).toBe('string')
|
||||
expect(formatted).toMatch(/in/)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that the function is memoized based on locale
|
||||
* Changing locale should update the function
|
||||
*/
|
||||
it('should update when locale changes', () => {
|
||||
const { result, rerender } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const now = Date.now()
|
||||
const oneHourAgo = now - (60 * 60 * 1000)
|
||||
|
||||
// First render with English
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
|
||||
rerender()
|
||||
const englishResult = result.current.formatTimeFromNow(oneHourAgo)
|
||||
|
||||
// Second render with Spanish
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'es-ES' })
|
||||
rerender()
|
||||
const spanishResult = result.current.formatTimeFromNow(oneHourAgo)
|
||||
|
||||
// Results should be different
|
||||
expect(englishResult).not.toBe(spanishResult)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Time granularity', () => {
|
||||
/**
|
||||
* Test different time granularities (seconds, minutes, hours, days, months, years)
|
||||
* dayjs should automatically choose the appropriate unit
|
||||
*/
|
||||
it('should use appropriate time units for different durations', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
// Seconds
|
||||
const seconds = result.current.formatTimeFromNow(now - 30 * 1000)
|
||||
expect(seconds).toMatch(/second/)
|
||||
|
||||
// Minutes
|
||||
const minutes = result.current.formatTimeFromNow(now - 5 * 60 * 1000)
|
||||
expect(minutes).toMatch(/minute/)
|
||||
|
||||
// Hours
|
||||
const hours = result.current.formatTimeFromNow(now - 3 * 60 * 60 * 1000)
|
||||
expect(hours).toMatch(/hour/)
|
||||
|
||||
// Days
|
||||
const days = result.current.formatTimeFromNow(now - 5 * 24 * 60 * 60 * 1000)
|
||||
expect(days).toMatch(/day/)
|
||||
|
||||
// Months
|
||||
const months = result.current.formatTimeFromNow(now - 60 * 24 * 60 * 60 * 1000)
|
||||
expect(months).toMatch(/month/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Locale mapping', () => {
|
||||
/**
|
||||
* Test that all supported locales in the localeMap are handled correctly
|
||||
* This ensures the mapping from app locales to dayjs locales works
|
||||
*/
|
||||
it('should handle all mapped locales', () => {
|
||||
const locales = [
|
||||
'en-US', 'zh-Hans', 'zh-Hant', 'pt-BR', 'es-ES', 'fr-FR',
|
||||
'de-DE', 'ja-JP', 'ko-KR', 'ru-RU', 'it-IT', 'th-TH',
|
||||
'id-ID', 'uk-UA', 'vi-VN', 'ro-RO', 'pl-PL', 'hi-IN',
|
||||
'tr-TR', 'fa-IR', 'sl-SI',
|
||||
]
|
||||
|
||||
const now = Date.now()
|
||||
const oneHourAgo = now - (60 * 60 * 1000)
|
||||
|
||||
locales.forEach((locale) => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
const formatted = result.current.formatTimeFromNow(oneHourAgo)
|
||||
|
||||
// Should return a non-empty string for each locale
|
||||
expect(typeof formatted).toBe('string')
|
||||
expect(formatted.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Performance', () => {
|
||||
/**
|
||||
* Test that the hook doesn't create new functions on every render
|
||||
* The formatTimeFromNow function should be memoized with useCallback
|
||||
*/
|
||||
it('should memoize formatTimeFromNow function', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
|
||||
|
||||
const { result, rerender } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const firstFunction = result.current.formatTimeFromNow
|
||||
rerender()
|
||||
const secondFunction = result.current.formatTimeFromNow
|
||||
|
||||
// Same locale should return the same function reference
|
||||
expect(firstFunction).toBe(secondFunction)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that changing locale creates a new function
|
||||
* This ensures the memoization dependency on locale works correctly
|
||||
*/
|
||||
it('should create new function when locale changes', () => {
|
||||
const { result, rerender } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
|
||||
rerender()
|
||||
const englishFunction = result.current.formatTimeFromNow
|
||||
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'es-ES' })
|
||||
rerender()
|
||||
const spanishFunction = result.current.formatTimeFromNow
|
||||
|
||||
// Different locale should return different function reference
|
||||
expect(englishFunction).not.toBe(spanishFunction)
|
||||
})
|
||||
})
|
||||
})
|
||||
543
web/hooks/use-tab-searchparams.spec.ts
Normal file
543
web/hooks/use-tab-searchparams.spec.ts
Normal file
@@ -0,0 +1,543 @@
|
||||
/**
|
||||
* Test suite for useTabSearchParams hook
|
||||
*
|
||||
* This hook manages tab state through URL search parameters, enabling:
|
||||
* - Bookmarkable tab states (users can share URLs with specific tabs active)
|
||||
* - Browser history integration (back/forward buttons work with tabs)
|
||||
* - Configurable routing behavior (push vs replace)
|
||||
* - Optional search parameter syncing (can disable URL updates)
|
||||
*
|
||||
* The hook syncs a local tab state with URL search parameters, making tab
|
||||
* navigation persistent and shareable across sessions.
|
||||
*/
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useTabSearchParams } from './use-tab-searchparams'
|
||||
|
||||
// Mock Next.js navigation hooks
|
||||
const mockPush = jest.fn()
|
||||
const mockReplace = jest.fn()
|
||||
const mockPathname = '/test-path'
|
||||
const mockSearchParams = new URLSearchParams()
|
||||
|
||||
jest.mock('next/navigation', () => ({
|
||||
usePathname: jest.fn(() => mockPathname),
|
||||
useRouter: jest.fn(() => ({
|
||||
push: mockPush,
|
||||
replace: mockReplace,
|
||||
})),
|
||||
useSearchParams: jest.fn(() => mockSearchParams),
|
||||
}))
|
||||
|
||||
// Import after mocks
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
describe('useTabSearchParams', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockSearchParams.delete('category')
|
||||
mockSearchParams.delete('tab')
|
||||
})
|
||||
|
||||
describe('Basic functionality', () => {
|
||||
/**
|
||||
* Test that the hook returns a tuple with activeTab and setActiveTab
|
||||
* This is the primary interface matching React's useState pattern
|
||||
*/
|
||||
it('should return activeTab and setActiveTab function', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
const [activeTab, setActiveTab] = result.current
|
||||
|
||||
expect(typeof activeTab).toBe('string')
|
||||
expect(typeof setActiveTab).toBe('function')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that the hook initializes with the default tab
|
||||
* When no search param is present, should use defaultTab
|
||||
*/
|
||||
it('should initialize with default tab when no search param exists', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
const [activeTab] = result.current
|
||||
expect(activeTab).toBe('overview')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that the hook reads from URL search parameters
|
||||
* When a search param exists, it should take precedence over defaultTab
|
||||
*/
|
||||
it('should initialize with search param value when present', () => {
|
||||
mockSearchParams.set('category', 'settings')
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
const [activeTab] = result.current
|
||||
expect(activeTab).toBe('settings')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that setActiveTab updates the local state
|
||||
* The active tab should change when setActiveTab is called
|
||||
*/
|
||||
it('should update active tab when setActiveTab is called', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('settings')
|
||||
})
|
||||
|
||||
const [activeTab] = result.current
|
||||
expect(activeTab).toBe('settings')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Routing behavior', () => {
|
||||
/**
|
||||
* Test default push routing behavior
|
||||
* By default, tab changes should use router.push (adds to history)
|
||||
*/
|
||||
it('should use push routing by default', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('settings')
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/test-path?category=settings')
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/**
|
||||
* Test replace routing behavior
|
||||
* When routingBehavior is 'replace', should use router.replace (no history)
|
||||
*/
|
||||
it('should use replace routing when specified', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({
|
||||
defaultTab: 'overview',
|
||||
routingBehavior: 'replace',
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('settings')
|
||||
})
|
||||
|
||||
expect(mockReplace).toHaveBeenCalledWith('/test-path?category=settings')
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that URL encoding is applied to tab values
|
||||
* Special characters in tab names should be properly encoded
|
||||
*/
|
||||
it('should encode special characters in tab values', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('settings & config')
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith(
|
||||
'/test-path?category=settings%20%26%20config',
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that URL decoding is applied when reading from search params
|
||||
* Encoded values in the URL should be properly decoded
|
||||
*/
|
||||
it('should decode encoded values from search params', () => {
|
||||
mockSearchParams.set('category', 'settings%20%26%20config')
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
const [activeTab] = result.current
|
||||
expect(activeTab).toBe('settings & config')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Custom search parameter name', () => {
|
||||
/**
|
||||
* Test using a custom search parameter name
|
||||
* Should support different param names instead of default 'category'
|
||||
*/
|
||||
it('should use custom search param name', () => {
|
||||
mockSearchParams.set('tab', 'profile')
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({
|
||||
defaultTab: 'overview',
|
||||
searchParamName: 'tab',
|
||||
}),
|
||||
)
|
||||
|
||||
const [activeTab] = result.current
|
||||
expect(activeTab).toBe('profile')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that setActiveTab uses the custom param name in the URL
|
||||
*/
|
||||
it('should update URL with custom param name', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({
|
||||
defaultTab: 'overview',
|
||||
searchParamName: 'tab',
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('profile')
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/test-path?tab=profile')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Disabled search params mode', () => {
|
||||
/**
|
||||
* Test that disableSearchParams prevents URL updates
|
||||
* When disabled, tab state should be local only
|
||||
*/
|
||||
it('should not update URL when disableSearchParams is true', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({
|
||||
defaultTab: 'overview',
|
||||
disableSearchParams: true,
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('settings')
|
||||
})
|
||||
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that local state still updates when search params are disabled
|
||||
* The tab state should work even without URL syncing
|
||||
*/
|
||||
it('should still update local state when search params disabled', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({
|
||||
defaultTab: 'overview',
|
||||
disableSearchParams: true,
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('settings')
|
||||
})
|
||||
|
||||
const [activeTab] = result.current
|
||||
expect(activeTab).toBe('settings')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that disabled mode always uses defaultTab
|
||||
* Search params should be ignored when disabled
|
||||
*/
|
||||
it('should use defaultTab when search params disabled even if URL has value', () => {
|
||||
mockSearchParams.set('category', 'settings')
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({
|
||||
defaultTab: 'overview',
|
||||
disableSearchParams: true,
|
||||
}),
|
||||
)
|
||||
|
||||
const [activeTab] = result.current
|
||||
expect(activeTab).toBe('overview')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge cases', () => {
|
||||
/**
|
||||
* Test handling of empty string tab values
|
||||
* Empty strings should be handled gracefully
|
||||
*/
|
||||
it('should handle empty string tab values', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('')
|
||||
})
|
||||
|
||||
const [activeTab] = result.current
|
||||
expect(activeTab).toBe('')
|
||||
expect(mockPush).toHaveBeenCalledWith('/test-path?category=')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that special characters in tab names are properly encoded
|
||||
* This ensures URLs remain valid even with unusual tab names
|
||||
*/
|
||||
it('should handle tabs with various special characters', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
// Test tab with slashes
|
||||
act(() => result.current[1]('tab/with/slashes'))
|
||||
expect(result.current[0]).toBe('tab/with/slashes')
|
||||
|
||||
// Test tab with question marks
|
||||
act(() => result.current[1]('tab?with?questions'))
|
||||
expect(result.current[0]).toBe('tab?with?questions')
|
||||
|
||||
// Test tab with hash symbols
|
||||
act(() => result.current[1]('tab#with#hash'))
|
||||
expect(result.current[0]).toBe('tab#with#hash')
|
||||
|
||||
// Test tab with equals signs
|
||||
act(() => result.current[1]('tab=with=equals'))
|
||||
expect(result.current[0]).toBe('tab=with=equals')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test fallback when pathname is not available
|
||||
* Should use window.location.pathname as fallback
|
||||
*/
|
||||
it('should fallback to window.location.pathname when hook pathname is null', () => {
|
||||
;(usePathname as jest.Mock).mockReturnValue(null)
|
||||
|
||||
// Mock window.location.pathname
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { pathname: '/fallback-path' },
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('settings')
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/fallback-path?category=settings')
|
||||
|
||||
// Restore mock
|
||||
;(usePathname as jest.Mock).mockReturnValue(mockPathname)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Multiple instances', () => {
|
||||
/**
|
||||
* Test that multiple instances with different param names work independently
|
||||
* Different hooks should not interfere with each other
|
||||
*/
|
||||
it('should support multiple independent tab states', () => {
|
||||
mockSearchParams.set('category', 'overview')
|
||||
mockSearchParams.set('subtab', 'details')
|
||||
|
||||
const { result: result1 } = renderHook(() =>
|
||||
useTabSearchParams({
|
||||
defaultTab: 'home',
|
||||
searchParamName: 'category',
|
||||
}),
|
||||
)
|
||||
|
||||
const { result: result2 } = renderHook(() =>
|
||||
useTabSearchParams({
|
||||
defaultTab: 'info',
|
||||
searchParamName: 'subtab',
|
||||
}),
|
||||
)
|
||||
|
||||
const [activeTab1] = result1.current
|
||||
const [activeTab2] = result2.current
|
||||
|
||||
expect(activeTab1).toBe('overview')
|
||||
expect(activeTab2).toBe('details')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Integration scenarios', () => {
|
||||
/**
|
||||
* Test typical usage in a tabbed interface
|
||||
* Simulates real-world tab switching behavior
|
||||
*/
|
||||
it('should handle sequential tab changes', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
// Change to settings tab
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('settings')
|
||||
})
|
||||
|
||||
expect(result.current[0]).toBe('settings')
|
||||
expect(mockPush).toHaveBeenCalledWith('/test-path?category=settings')
|
||||
|
||||
// Change to profile tab
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('profile')
|
||||
})
|
||||
|
||||
expect(result.current[0]).toBe('profile')
|
||||
expect(mockPush).toHaveBeenCalledWith('/test-path?category=profile')
|
||||
|
||||
// Verify push was called twice
|
||||
expect(mockPush).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that the hook works with complex pathnames
|
||||
* Should handle nested routes and existing query params
|
||||
*/
|
||||
it('should work with complex pathnames', () => {
|
||||
;(usePathname as jest.Mock).mockReturnValue('/app/123/settings')
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('advanced')
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/app/123/settings?category=advanced')
|
||||
|
||||
// Restore mock
|
||||
;(usePathname as jest.Mock).mockReturnValue(mockPathname)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Type safety', () => {
|
||||
/**
|
||||
* Test that the return type is a const tuple
|
||||
* TypeScript should infer [string, (tab: string) => void] as const
|
||||
*/
|
||||
it('should return a const tuple type', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
// The result should be a tuple with exactly 2 elements
|
||||
expect(result.current).toHaveLength(2)
|
||||
expect(typeof result.current[0]).toBe('string')
|
||||
expect(typeof result.current[1]).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Performance', () => {
|
||||
/**
|
||||
* Test that the hook creates a new function on each render
|
||||
* Note: The current implementation doesn't use useCallback,
|
||||
* so setActiveTab is recreated on each render. This could lead to
|
||||
* unnecessary re-renders in child components that depend on this function.
|
||||
* TODO: Consider memoizing setActiveTab with useCallback for better performance.
|
||||
*/
|
||||
it('should create new setActiveTab function on each render', () => {
|
||||
const { result, rerender } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
const [, firstSetActiveTab] = result.current
|
||||
rerender()
|
||||
const [, secondSetActiveTab] = result.current
|
||||
|
||||
// Function reference changes on re-render (not memoized)
|
||||
expect(firstSetActiveTab).not.toBe(secondSetActiveTab)
|
||||
|
||||
// But both functions should work correctly
|
||||
expect(typeof firstSetActiveTab).toBe('function')
|
||||
expect(typeof secondSetActiveTab).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Browser history integration', () => {
|
||||
/**
|
||||
* Test that push behavior adds to browser history
|
||||
* This enables back/forward navigation through tabs
|
||||
*/
|
||||
it('should add to history with push behavior', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({
|
||||
defaultTab: 'overview',
|
||||
routingBehavior: 'push',
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('tab1')
|
||||
})
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('tab2')
|
||||
})
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('tab3')
|
||||
})
|
||||
|
||||
// Each tab change should create a history entry
|
||||
expect(mockPush).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that replace behavior doesn't add to history
|
||||
* This prevents cluttering browser history with tab changes
|
||||
*/
|
||||
it('should not add to history with replace behavior', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({
|
||||
defaultTab: 'overview',
|
||||
routingBehavior: 'replace',
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('tab1')
|
||||
})
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('tab2')
|
||||
})
|
||||
|
||||
// Should use replace instead of push
|
||||
expect(mockReplace).toHaveBeenCalledTimes(2)
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
170
web/service/utils.spec.ts
Normal file
170
web/service/utils.spec.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Test suite for service utility functions
|
||||
*
|
||||
* This module provides utilities for working with different flow types in the application.
|
||||
* Flow types determine the API endpoint prefix used for various operations.
|
||||
*
|
||||
* Key concepts:
|
||||
* - FlowType.appFlow: Standard application workflows (prefix: 'apps')
|
||||
* - FlowType.ragPipeline: RAG (Retrieval-Augmented Generation) pipelines (prefix: 'rag/pipelines')
|
||||
*
|
||||
* The getFlowPrefix function maps flow types to their corresponding API path prefixes,
|
||||
* with a fallback to 'apps' for undefined or unknown flow types.
|
||||
*/
|
||||
import { flowPrefixMap, getFlowPrefix } from './utils'
|
||||
import { FlowType } from '@/types/common'
|
||||
|
||||
describe('Service Utils', () => {
|
||||
describe('flowPrefixMap', () => {
|
||||
/**
|
||||
* Test that the flowPrefixMap object contains the expected mappings
|
||||
* This ensures the mapping configuration is correct
|
||||
*/
|
||||
it('should have correct flow type to prefix mappings', () => {
|
||||
expect(flowPrefixMap[FlowType.appFlow]).toBe('apps')
|
||||
expect(flowPrefixMap[FlowType.ragPipeline]).toBe('rag/pipelines')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that the map only contains the expected flow types
|
||||
* This helps catch unintended additions to the mapping
|
||||
*/
|
||||
it('should contain exactly two flow type mappings', () => {
|
||||
const keys = Object.keys(flowPrefixMap)
|
||||
expect(keys).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFlowPrefix', () => {
|
||||
/**
|
||||
* Test that appFlow type returns the correct prefix
|
||||
* This is the most common flow type for standard application workflows
|
||||
*/
|
||||
it('should return "apps" for appFlow type', () => {
|
||||
const result = getFlowPrefix(FlowType.appFlow)
|
||||
expect(result).toBe('apps')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that ragPipeline type returns the correct prefix
|
||||
* RAG pipelines have a different API structure with nested paths
|
||||
*/
|
||||
it('should return "rag/pipelines" for ragPipeline type', () => {
|
||||
const result = getFlowPrefix(FlowType.ragPipeline)
|
||||
expect(result).toBe('rag/pipelines')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test fallback behavior when no flow type is provided
|
||||
* Should default to 'apps' prefix for backward compatibility
|
||||
*/
|
||||
it('should return "apps" when flow type is undefined', () => {
|
||||
const result = getFlowPrefix(undefined)
|
||||
expect(result).toBe('apps')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test fallback behavior for unknown flow types
|
||||
* Any unrecognized flow type should default to 'apps'
|
||||
*/
|
||||
it('should return "apps" for unknown flow type', () => {
|
||||
// Cast to FlowType to test the fallback behavior
|
||||
const unknownType = 'unknown' as FlowType
|
||||
const result = getFlowPrefix(unknownType)
|
||||
expect(result).toBe('apps')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that the function handles null gracefully
|
||||
* Null should be treated the same as undefined
|
||||
*/
|
||||
it('should return "apps" when flow type is null', () => {
|
||||
const result = getFlowPrefix(null as any)
|
||||
expect(result).toBe('apps')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test consistency with flowPrefixMap
|
||||
* The function should return the same values as direct map access
|
||||
*/
|
||||
it('should return values consistent with flowPrefixMap', () => {
|
||||
expect(getFlowPrefix(FlowType.appFlow)).toBe(flowPrefixMap[FlowType.appFlow])
|
||||
expect(getFlowPrefix(FlowType.ragPipeline)).toBe(flowPrefixMap[FlowType.ragPipeline])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Integration scenarios', () => {
|
||||
/**
|
||||
* Test typical usage pattern in API path construction
|
||||
* This demonstrates how the function is used in real application code
|
||||
*/
|
||||
it('should construct correct API paths for different flow types', () => {
|
||||
const appId = '123'
|
||||
|
||||
// App flow path construction
|
||||
const appFlowPath = `/${getFlowPrefix(FlowType.appFlow)}/${appId}`
|
||||
expect(appFlowPath).toBe('/apps/123')
|
||||
|
||||
// RAG pipeline path construction
|
||||
const ragPipelinePath = `/${getFlowPrefix(FlowType.ragPipeline)}/${appId}`
|
||||
expect(ragPipelinePath).toBe('/rag/pipelines/123')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that the function can be used in conditional logic
|
||||
* Common pattern for determining which API endpoint to use
|
||||
*/
|
||||
it('should support conditional API routing logic', () => {
|
||||
const determineEndpoint = (flowType?: FlowType, resourceId?: string) => {
|
||||
const prefix = getFlowPrefix(flowType)
|
||||
return `/${prefix}/${resourceId || 'default'}`
|
||||
}
|
||||
|
||||
expect(determineEndpoint(FlowType.appFlow, 'app-1')).toBe('/apps/app-1')
|
||||
expect(determineEndpoint(FlowType.ragPipeline, 'pipeline-1')).toBe('/rag/pipelines/pipeline-1')
|
||||
expect(determineEndpoint(undefined, 'fallback')).toBe('/apps/fallback')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test behavior with empty string flow type
|
||||
* Empty strings should fall back to default
|
||||
*/
|
||||
it('should handle empty string as flow type', () => {
|
||||
const result = getFlowPrefix('' as any)
|
||||
expect(result).toBe('apps')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Type safety', () => {
|
||||
/**
|
||||
* Test that all FlowType enum values are handled
|
||||
* This ensures we don't miss any flow types in the mapping
|
||||
*/
|
||||
it('should handle all FlowType enum values', () => {
|
||||
// Get all enum values
|
||||
const flowTypes = Object.values(FlowType)
|
||||
|
||||
// Each flow type should return a valid prefix
|
||||
flowTypes.forEach((flowType) => {
|
||||
const prefix = getFlowPrefix(flowType)
|
||||
expect(prefix).toBeTruthy()
|
||||
expect(typeof prefix).toBe('string')
|
||||
expect(prefix.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that returned prefixes are valid path segments
|
||||
* Prefixes should not contain leading/trailing slashes or invalid characters
|
||||
*/
|
||||
it('should return valid path segments without leading/trailing slashes', () => {
|
||||
const appFlowPrefix = getFlowPrefix(FlowType.appFlow)
|
||||
const ragPipelinePrefix = getFlowPrefix(FlowType.ragPipeline)
|
||||
|
||||
expect(appFlowPrefix).not.toMatch(/^\//)
|
||||
expect(appFlowPrefix).not.toMatch(/\/$/)
|
||||
expect(ragPipelinePrefix).not.toMatch(/^\//)
|
||||
expect(ragPipelinePrefix).not.toMatch(/\/$/)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,13 @@
|
||||
/**
|
||||
* Test suite for clipboard utilities
|
||||
*
|
||||
* This module provides cross-browser clipboard functionality with automatic fallback:
|
||||
* 1. Modern Clipboard API (navigator.clipboard.writeText) - preferred method
|
||||
* 2. Legacy execCommand('copy') - fallback for older browsers
|
||||
*
|
||||
* The implementation ensures clipboard operations work across all supported browsers
|
||||
* while gracefully handling permissions and API availability.
|
||||
*/
|
||||
import { writeTextToClipboard } from './clipboard'
|
||||
|
||||
describe('Clipboard Utilities', () => {
|
||||
@@ -6,6 +16,10 @@ describe('Clipboard Utilities', () => {
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
/**
|
||||
* Test modern Clipboard API usage
|
||||
* When navigator.clipboard is available, should use the modern API
|
||||
*/
|
||||
it('should use navigator.clipboard.writeText when available', async () => {
|
||||
const mockWriteText = jest.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
@@ -18,6 +32,11 @@ describe('Clipboard Utilities', () => {
|
||||
expect(mockWriteText).toHaveBeenCalledWith('test text')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test fallback to legacy execCommand method
|
||||
* When Clipboard API is unavailable, should use document.execCommand('copy')
|
||||
* This involves creating a temporary textarea element
|
||||
*/
|
||||
it('should fallback to execCommand when clipboard API not available', async () => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: undefined,
|
||||
@@ -38,6 +57,10 @@ describe('Clipboard Utilities', () => {
|
||||
expect(removeChildSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/**
|
||||
* Test error handling when execCommand returns false
|
||||
* execCommand returns false when the operation fails
|
||||
*/
|
||||
it('should handle execCommand failure', async () => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: undefined,
|
||||
@@ -51,6 +74,10 @@ describe('Clipboard Utilities', () => {
|
||||
await expect(writeTextToClipboard('fail text')).rejects.toThrow()
|
||||
})
|
||||
|
||||
/**
|
||||
* Test error handling when execCommand throws an exception
|
||||
* Should propagate the error to the caller
|
||||
*/
|
||||
it('should handle execCommand exception', async () => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: undefined,
|
||||
@@ -66,6 +93,10 @@ describe('Clipboard Utilities', () => {
|
||||
await expect(writeTextToClipboard('error text')).rejects.toThrow('execCommand error')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test proper cleanup of temporary DOM elements
|
||||
* The temporary textarea should be removed after copying
|
||||
*/
|
||||
it('should clean up textarea after fallback', async () => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: undefined,
|
||||
@@ -81,6 +112,10 @@ describe('Clipboard Utilities', () => {
|
||||
expect(removeChildSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/**
|
||||
* Test copying empty strings
|
||||
* Should handle edge case of empty clipboard content
|
||||
*/
|
||||
it('should handle empty string', async () => {
|
||||
const mockWriteText = jest.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
@@ -93,6 +128,10 @@ describe('Clipboard Utilities', () => {
|
||||
expect(mockWriteText).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test copying text with special characters
|
||||
* Should preserve newlines, tabs, quotes, unicode, and emojis
|
||||
*/
|
||||
it('should handle special characters', async () => {
|
||||
const mockWriteText = jest.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
|
||||
253
web/utils/context.spec.ts
Normal file
253
web/utils/context.spec.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Test suite for React context creation utilities
|
||||
*
|
||||
* This module provides helper functions to create React contexts with better type safety
|
||||
* and automatic error handling when context is used outside of its provider.
|
||||
*
|
||||
* Two variants are provided:
|
||||
* - createCtx: Standard React context using useContext/createContext
|
||||
* - createSelectorCtx: Context with selector support using use-context-selector library
|
||||
*/
|
||||
import React from 'react'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { createCtx, createSelectorCtx } from './context'
|
||||
|
||||
describe('Context Utilities', () => {
|
||||
describe('createCtx', () => {
|
||||
/**
|
||||
* Test that createCtx creates a valid context with provider and hook
|
||||
* The function should return a tuple with [Provider, useContextValue, Context]
|
||||
* plus named properties for easier access
|
||||
*/
|
||||
it('should create context with provider and hook', () => {
|
||||
type TestContextValue = { value: string }
|
||||
const [Provider, useTestContext, Context] = createCtx<TestContextValue>({
|
||||
name: 'Test',
|
||||
})
|
||||
|
||||
expect(Provider).toBeDefined()
|
||||
expect(useTestContext).toBeDefined()
|
||||
expect(Context).toBeDefined()
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that the context hook returns the provided value correctly
|
||||
* when used within the context provider
|
||||
*/
|
||||
it('should provide and consume context value', () => {
|
||||
type TestContextValue = { value: string }
|
||||
const [Provider, useTestContext] = createCtx<TestContextValue>({
|
||||
name: 'Test',
|
||||
})
|
||||
|
||||
const testValue = { value: 'test-value' }
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(Provider, { value: testValue }, children)
|
||||
|
||||
const { result } = renderHook(() => useTestContext(), { wrapper })
|
||||
|
||||
expect(result.current).toEqual(testValue)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that accessing context outside of provider throws an error
|
||||
* This ensures developers are notified when they forget to wrap components
|
||||
*/
|
||||
it('should throw error when used outside provider', () => {
|
||||
type TestContextValue = { value: string }
|
||||
const [, useTestContext] = createCtx<TestContextValue>({
|
||||
name: 'Test',
|
||||
})
|
||||
|
||||
// Suppress console.error for this test
|
||||
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => { /* suppress error */ })
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => useTestContext())
|
||||
}).toThrow('No Test context found.')
|
||||
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that context works with default values
|
||||
* When a default value is provided, it should be accessible without a provider
|
||||
*/
|
||||
it('should use default value when provided', () => {
|
||||
type TestContextValue = { value: string }
|
||||
const defaultValue = { value: 'default' }
|
||||
const [, useTestContext] = createCtx<TestContextValue>({
|
||||
name: 'Test',
|
||||
defaultValue,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useTestContext())
|
||||
|
||||
expect(result.current).toEqual(defaultValue)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that the returned tuple has named properties for convenience
|
||||
* This allows destructuring or property access based on preference
|
||||
*/
|
||||
it('should expose named properties', () => {
|
||||
type TestContextValue = { value: string }
|
||||
const result = createCtx<TestContextValue>({ name: 'Test' })
|
||||
|
||||
expect(result.provider).toBe(result[0])
|
||||
expect(result.useContextValue).toBe(result[1])
|
||||
expect(result.context).toBe(result[2])
|
||||
})
|
||||
|
||||
/**
|
||||
* Test context with complex data types
|
||||
* Ensures type safety is maintained with nested objects and arrays
|
||||
*/
|
||||
it('should handle complex context values', () => {
|
||||
type ComplexContext = {
|
||||
user: { id: string; name: string }
|
||||
settings: { theme: string; locale: string }
|
||||
actions: Array<() => void>
|
||||
}
|
||||
|
||||
const [Provider, useComplexContext] = createCtx<ComplexContext>({
|
||||
name: 'Complex',
|
||||
})
|
||||
|
||||
const complexValue: ComplexContext = {
|
||||
user: { id: '123', name: 'Test User' },
|
||||
settings: { theme: 'dark', locale: 'en-US' },
|
||||
actions: [
|
||||
() => { /* empty action 1 */ },
|
||||
() => { /* empty action 2 */ },
|
||||
],
|
||||
}
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(Provider, { value: complexValue }, children)
|
||||
|
||||
const { result } = renderHook(() => useComplexContext(), { wrapper })
|
||||
|
||||
expect(result.current).toEqual(complexValue)
|
||||
expect(result.current.user.id).toBe('123')
|
||||
expect(result.current.settings.theme).toBe('dark')
|
||||
expect(result.current.actions).toHaveLength(2)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that context updates propagate to consumers
|
||||
* When provider value changes, hooks should receive the new value
|
||||
*/
|
||||
it('should update when context value changes', () => {
|
||||
type TestContextValue = { count: number }
|
||||
const [Provider, useTestContext] = createCtx<TestContextValue>({
|
||||
name: 'Test',
|
||||
})
|
||||
|
||||
let value = { count: 0 }
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(Provider, { value }, children)
|
||||
|
||||
const { result, rerender } = renderHook(() => useTestContext(), { wrapper })
|
||||
|
||||
expect(result.current.count).toBe(0)
|
||||
|
||||
value = { count: 5 }
|
||||
rerender()
|
||||
|
||||
expect(result.current.count).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createSelectorCtx', () => {
|
||||
/**
|
||||
* Test that createSelectorCtx creates a valid context with selector support
|
||||
* This variant uses use-context-selector for optimized re-renders
|
||||
*/
|
||||
it('should create selector context with provider and hook', () => {
|
||||
type TestContextValue = { value: string }
|
||||
const [Provider, useTestContext, Context] = createSelectorCtx<TestContextValue>({
|
||||
name: 'SelectorTest',
|
||||
})
|
||||
|
||||
expect(Provider).toBeDefined()
|
||||
expect(useTestContext).toBeDefined()
|
||||
expect(Context).toBeDefined()
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that selector context provides and consumes values correctly
|
||||
* The API should be identical to createCtx for basic usage
|
||||
*/
|
||||
it('should provide and consume context value with selector', () => {
|
||||
type TestContextValue = { value: string }
|
||||
const [Provider, useTestContext] = createSelectorCtx<TestContextValue>({
|
||||
name: 'SelectorTest',
|
||||
})
|
||||
|
||||
const testValue = { value: 'selector-test' }
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(Provider, { value: testValue }, children)
|
||||
|
||||
const { result } = renderHook(() => useTestContext(), { wrapper })
|
||||
|
||||
expect(result.current).toEqual(testValue)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test error handling for selector context
|
||||
* Should throw error when used outside provider, same as createCtx
|
||||
*/
|
||||
it('should throw error when used outside provider', () => {
|
||||
type TestContextValue = { value: string }
|
||||
const [, useTestContext] = createSelectorCtx<TestContextValue>({
|
||||
name: 'SelectorTest',
|
||||
})
|
||||
|
||||
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => { /* suppress error */ })
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => useTestContext())
|
||||
}).toThrow('No SelectorTest context found.')
|
||||
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that selector context works with default values
|
||||
*/
|
||||
it('should use default value when provided', () => {
|
||||
type TestContextValue = { value: string }
|
||||
const defaultValue = { value: 'selector-default' }
|
||||
const [, useTestContext] = createSelectorCtx<TestContextValue>({
|
||||
name: 'SelectorTest',
|
||||
defaultValue,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useTestContext())
|
||||
|
||||
expect(result.current).toEqual(defaultValue)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Context without name', () => {
|
||||
/**
|
||||
* Test that contexts can be created without a name
|
||||
* The error message should use a generic fallback
|
||||
*/
|
||||
it('should create context without name and show generic error', () => {
|
||||
type TestContextValue = { value: string }
|
||||
const [, useTestContext] = createCtx<TestContextValue>()
|
||||
|
||||
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => { /* suppress error */ })
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => useTestContext())
|
||||
}).toThrow('No related context found.')
|
||||
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
819
web/utils/model-config.spec.ts
Normal file
819
web/utils/model-config.spec.ts
Normal file
@@ -0,0 +1,819 @@
|
||||
/**
|
||||
* Test suite for model configuration transformation utilities
|
||||
*
|
||||
* This module handles the conversion between two different representations of user input forms:
|
||||
* 1. UserInputFormItem: The form structure used in the UI
|
||||
* 2. PromptVariable: The variable structure used in prompts and model configuration
|
||||
*
|
||||
* Key functions:
|
||||
* - userInputsFormToPromptVariables: Converts UI form items to prompt variables
|
||||
* - promptVariablesToUserInputsForm: Converts prompt variables back to form items
|
||||
* - formatBooleanInputs: Ensures boolean inputs are properly typed
|
||||
*/
|
||||
import {
|
||||
formatBooleanInputs,
|
||||
promptVariablesToUserInputsForm,
|
||||
userInputsFormToPromptVariables,
|
||||
} from './model-config'
|
||||
import type { UserInputFormItem } from '@/types/app'
|
||||
import type { PromptVariable } from '@/models/debug'
|
||||
|
||||
describe('Model Config Utilities', () => {
|
||||
describe('userInputsFormToPromptVariables', () => {
|
||||
/**
|
||||
* Test handling of null or undefined input
|
||||
* Should return empty array when no inputs provided
|
||||
*/
|
||||
it('should return empty array for null input', () => {
|
||||
const result = userInputsFormToPromptVariables(null)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
/**
|
||||
* Test conversion of text-input (string) type
|
||||
* Text inputs are the most common form field type
|
||||
*/
|
||||
it('should convert text-input to string prompt variable', () => {
|
||||
const userInputs: UserInputFormItem[] = [
|
||||
{
|
||||
'text-input': {
|
||||
label: 'User Name',
|
||||
variable: 'user_name',
|
||||
required: true,
|
||||
max_length: 100,
|
||||
default: '',
|
||||
hide: false,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const result = userInputsFormToPromptVariables(userInputs)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toEqual({
|
||||
key: 'user_name',
|
||||
name: 'User Name',
|
||||
required: true,
|
||||
type: 'string',
|
||||
max_length: 100,
|
||||
options: [],
|
||||
is_context_var: false,
|
||||
hide: false,
|
||||
default: '',
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Test conversion of paragraph type
|
||||
* Paragraphs are multi-line text inputs
|
||||
*/
|
||||
it('should convert paragraph to paragraph prompt variable', () => {
|
||||
const userInputs: UserInputFormItem[] = [
|
||||
{
|
||||
paragraph: {
|
||||
label: 'Description',
|
||||
variable: 'description',
|
||||
required: false,
|
||||
max_length: 500,
|
||||
default: '',
|
||||
hide: false,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const result = userInputsFormToPromptVariables(userInputs)
|
||||
|
||||
expect(result[0]).toEqual({
|
||||
key: 'description',
|
||||
name: 'Description',
|
||||
required: false,
|
||||
type: 'paragraph',
|
||||
max_length: 500,
|
||||
options: [],
|
||||
is_context_var: false,
|
||||
hide: false,
|
||||
default: '',
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Test conversion of number type
|
||||
* Number inputs should preserve numeric constraints
|
||||
*/
|
||||
it('should convert number input to number prompt variable', () => {
|
||||
const userInputs: UserInputFormItem[] = [
|
||||
{
|
||||
number: {
|
||||
label: 'Age',
|
||||
variable: 'age',
|
||||
required: true,
|
||||
default: '',
|
||||
hide: false,
|
||||
},
|
||||
} as any,
|
||||
]
|
||||
|
||||
const result = userInputsFormToPromptVariables(userInputs)
|
||||
|
||||
expect(result[0]).toEqual({
|
||||
key: 'age',
|
||||
name: 'Age',
|
||||
required: true,
|
||||
type: 'number',
|
||||
options: [],
|
||||
hide: false,
|
||||
default: '',
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Test conversion of checkbox (boolean) type
|
||||
* Checkboxes are converted to 'checkbox' type in prompt variables
|
||||
*/
|
||||
it('should convert checkbox to checkbox prompt variable', () => {
|
||||
const userInputs: UserInputFormItem[] = [
|
||||
{
|
||||
checkbox: {
|
||||
label: 'Accept Terms',
|
||||
variable: 'accept_terms',
|
||||
required: true,
|
||||
default: '',
|
||||
hide: false,
|
||||
},
|
||||
} as any,
|
||||
]
|
||||
|
||||
const result = userInputsFormToPromptVariables(userInputs)
|
||||
|
||||
expect(result[0]).toEqual({
|
||||
key: 'accept_terms',
|
||||
name: 'Accept Terms',
|
||||
required: true,
|
||||
type: 'checkbox',
|
||||
options: [],
|
||||
hide: false,
|
||||
default: '',
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Test conversion of select (dropdown) type
|
||||
* Select inputs include options array
|
||||
*/
|
||||
it('should convert select input to select prompt variable', () => {
|
||||
const userInputs: UserInputFormItem[] = [
|
||||
{
|
||||
select: {
|
||||
label: 'Country',
|
||||
variable: 'country',
|
||||
required: true,
|
||||
options: ['USA', 'Canada', 'Mexico'],
|
||||
default: 'USA',
|
||||
hide: false,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const result = userInputsFormToPromptVariables(userInputs)
|
||||
|
||||
expect(result[0]).toEqual({
|
||||
key: 'country',
|
||||
name: 'Country',
|
||||
required: true,
|
||||
type: 'select',
|
||||
options: ['USA', 'Canada', 'Mexico'],
|
||||
is_context_var: false,
|
||||
hide: false,
|
||||
default: 'USA',
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Test conversion of file upload type
|
||||
* File inputs include configuration for allowed types and upload methods
|
||||
*/
|
||||
it('should convert file input to file prompt variable', () => {
|
||||
const userInputs: UserInputFormItem[] = [
|
||||
{
|
||||
file: {
|
||||
label: 'Profile Picture',
|
||||
variable: 'profile_pic',
|
||||
required: false,
|
||||
allowed_file_types: ['image'],
|
||||
allowed_file_extensions: ['.jpg', '.png'],
|
||||
allowed_file_upload_methods: ['local_file', 'remote_url'],
|
||||
default: '',
|
||||
hide: false,
|
||||
},
|
||||
} as any,
|
||||
]
|
||||
|
||||
const result = userInputsFormToPromptVariables(userInputs)
|
||||
|
||||
expect(result[0]).toEqual({
|
||||
key: 'profile_pic',
|
||||
name: 'Profile Picture',
|
||||
required: false,
|
||||
type: 'file',
|
||||
config: {
|
||||
allowed_file_types: ['image'],
|
||||
allowed_file_extensions: ['.jpg', '.png'],
|
||||
allowed_file_upload_methods: ['local_file', 'remote_url'],
|
||||
number_limits: 1,
|
||||
},
|
||||
hide: false,
|
||||
default: '',
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Test conversion of file-list type
|
||||
* File lists allow multiple file uploads with a max_length constraint
|
||||
*/
|
||||
it('should convert file-list input to file-list prompt variable', () => {
|
||||
const userInputs: UserInputFormItem[] = [
|
||||
{
|
||||
'file-list': {
|
||||
label: 'Documents',
|
||||
variable: 'documents',
|
||||
required: true,
|
||||
allowed_file_types: ['document'],
|
||||
allowed_file_extensions: ['.pdf', '.docx'],
|
||||
allowed_file_upload_methods: ['local_file'],
|
||||
max_length: 5,
|
||||
default: '',
|
||||
hide: false,
|
||||
},
|
||||
} as any,
|
||||
]
|
||||
|
||||
const result = userInputsFormToPromptVariables(userInputs)
|
||||
|
||||
expect(result[0]).toEqual({
|
||||
key: 'documents',
|
||||
name: 'Documents',
|
||||
required: true,
|
||||
type: 'file-list',
|
||||
config: {
|
||||
allowed_file_types: ['document'],
|
||||
allowed_file_extensions: ['.pdf', '.docx'],
|
||||
allowed_file_upload_methods: ['local_file'],
|
||||
number_limits: 5,
|
||||
},
|
||||
hide: false,
|
||||
default: '',
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Test conversion of external_data_tool type
|
||||
* External data tools have custom configuration and icons
|
||||
*/
|
||||
it('should convert external_data_tool to prompt variable', () => {
|
||||
const userInputs: UserInputFormItem[] = [
|
||||
{
|
||||
external_data_tool: {
|
||||
label: 'API Data',
|
||||
variable: 'api_data',
|
||||
type: 'api',
|
||||
enabled: true,
|
||||
required: false,
|
||||
config: { endpoint: 'https://api.example.com' },
|
||||
icon: 'api-icon',
|
||||
icon_background: '#FF5733',
|
||||
hide: false,
|
||||
},
|
||||
} as any,
|
||||
]
|
||||
|
||||
const result = userInputsFormToPromptVariables(userInputs)
|
||||
|
||||
expect(result[0]).toEqual({
|
||||
key: 'api_data',
|
||||
name: 'API Data',
|
||||
required: false,
|
||||
type: 'api',
|
||||
enabled: true,
|
||||
config: { endpoint: 'https://api.example.com' },
|
||||
icon: 'api-icon',
|
||||
icon_background: '#FF5733',
|
||||
is_context_var: false,
|
||||
hide: false,
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Test handling of dataset_query_variable
|
||||
* When a variable matches the dataset_query_variable, is_context_var should be true
|
||||
*/
|
||||
it('should mark variable as context var when matching dataset_query_variable', () => {
|
||||
const userInputs: UserInputFormItem[] = [
|
||||
{
|
||||
'text-input': {
|
||||
label: 'Query',
|
||||
variable: 'query',
|
||||
required: true,
|
||||
max_length: 200,
|
||||
default: '',
|
||||
hide: false,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const result = userInputsFormToPromptVariables(userInputs, 'query')
|
||||
|
||||
expect(result[0].is_context_var).toBe(true)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test conversion of multiple mixed input types
|
||||
* Should handle an array with different input types correctly
|
||||
*/
|
||||
it('should convert multiple mixed input types', () => {
|
||||
const userInputs: UserInputFormItem[] = [
|
||||
{
|
||||
'text-input': {
|
||||
label: 'Name',
|
||||
variable: 'name',
|
||||
required: true,
|
||||
max_length: 50,
|
||||
default: '',
|
||||
hide: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
number: {
|
||||
label: 'Age',
|
||||
variable: 'age',
|
||||
required: false,
|
||||
default: '',
|
||||
hide: false,
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
select: {
|
||||
label: 'Gender',
|
||||
variable: 'gender',
|
||||
required: true,
|
||||
options: ['Male', 'Female', 'Other'],
|
||||
default: '',
|
||||
hide: false,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const result = userInputsFormToPromptVariables(userInputs)
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0].type).toBe('string')
|
||||
expect(result[1].type).toBe('number')
|
||||
expect(result[2].type).toBe('select')
|
||||
})
|
||||
})
|
||||
|
||||
describe('promptVariablesToUserInputsForm', () => {
|
||||
/**
|
||||
* Test conversion of string prompt variable back to text-input
|
||||
*/
|
||||
it('should convert string prompt variable to text-input', () => {
|
||||
const promptVariables: PromptVariable[] = [
|
||||
{
|
||||
key: 'user_name',
|
||||
name: 'User Name',
|
||||
required: true,
|
||||
type: 'string',
|
||||
max_length: 100,
|
||||
options: [],
|
||||
},
|
||||
]
|
||||
|
||||
const result = promptVariablesToUserInputsForm(promptVariables)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toEqual({
|
||||
'text-input': {
|
||||
label: 'User Name',
|
||||
variable: 'user_name',
|
||||
required: true,
|
||||
max_length: 100,
|
||||
default: '',
|
||||
hide: undefined,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Test conversion of paragraph prompt variable
|
||||
*/
|
||||
it('should convert paragraph prompt variable to paragraph input', () => {
|
||||
const promptVariables: PromptVariable[] = [
|
||||
{
|
||||
key: 'description',
|
||||
name: 'Description',
|
||||
required: false,
|
||||
type: 'paragraph',
|
||||
max_length: 500,
|
||||
options: [],
|
||||
},
|
||||
]
|
||||
|
||||
const result = promptVariablesToUserInputsForm(promptVariables)
|
||||
|
||||
expect(result[0]).toEqual({
|
||||
paragraph: {
|
||||
label: 'Description',
|
||||
variable: 'description',
|
||||
required: false,
|
||||
max_length: 500,
|
||||
default: '',
|
||||
hide: undefined,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Test conversion of number prompt variable
|
||||
*/
|
||||
it('should convert number prompt variable to number input', () => {
|
||||
const promptVariables: PromptVariable[] = [
|
||||
{
|
||||
key: 'age',
|
||||
name: 'Age',
|
||||
required: true,
|
||||
type: 'number',
|
||||
options: [],
|
||||
},
|
||||
]
|
||||
|
||||
const result = promptVariablesToUserInputsForm(promptVariables)
|
||||
|
||||
expect(result[0]).toEqual({
|
||||
number: {
|
||||
label: 'Age',
|
||||
variable: 'age',
|
||||
required: true,
|
||||
default: '',
|
||||
hide: undefined,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Test conversion of checkbox prompt variable
|
||||
*/
|
||||
it('should convert checkbox prompt variable to checkbox input', () => {
|
||||
const promptVariables: PromptVariable[] = [
|
||||
{
|
||||
key: 'accept_terms',
|
||||
name: 'Accept Terms',
|
||||
required: true,
|
||||
type: 'checkbox',
|
||||
options: [],
|
||||
},
|
||||
]
|
||||
|
||||
const result = promptVariablesToUserInputsForm(promptVariables)
|
||||
|
||||
expect(result[0]).toEqual({
|
||||
checkbox: {
|
||||
label: 'Accept Terms',
|
||||
variable: 'accept_terms',
|
||||
required: true,
|
||||
default: '',
|
||||
hide: undefined,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Test conversion of select prompt variable
|
||||
*/
|
||||
it('should convert select prompt variable to select input', () => {
|
||||
const promptVariables: PromptVariable[] = [
|
||||
{
|
||||
key: 'country',
|
||||
name: 'Country',
|
||||
required: true,
|
||||
type: 'select',
|
||||
options: ['USA', 'Canada', 'Mexico'],
|
||||
default: 'USA',
|
||||
},
|
||||
]
|
||||
|
||||
const result = promptVariablesToUserInputsForm(promptVariables)
|
||||
|
||||
expect(result[0]).toEqual({
|
||||
select: {
|
||||
label: 'Country',
|
||||
variable: 'country',
|
||||
required: true,
|
||||
options: ['USA', 'Canada', 'Mexico'],
|
||||
default: 'USA',
|
||||
hide: undefined,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Test filtering of invalid prompt variables
|
||||
* Variables without key or name should be filtered out
|
||||
*/
|
||||
it('should filter out variables with empty key or name', () => {
|
||||
const promptVariables: PromptVariable[] = [
|
||||
{
|
||||
key: '',
|
||||
name: 'Empty Key',
|
||||
required: true,
|
||||
type: 'string',
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
key: 'valid',
|
||||
name: '',
|
||||
required: true,
|
||||
type: 'string',
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
key: ' ',
|
||||
name: 'Whitespace Key',
|
||||
required: true,
|
||||
type: 'string',
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
key: 'valid_key',
|
||||
name: 'Valid Name',
|
||||
required: true,
|
||||
type: 'string',
|
||||
options: [],
|
||||
},
|
||||
]
|
||||
|
||||
const result = promptVariablesToUserInputsForm(promptVariables)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect((result[0] as any)['text-input']?.variable).toBe('valid_key')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test conversion of external data tool prompt variable
|
||||
*/
|
||||
it('should convert external data tool prompt variable', () => {
|
||||
const promptVariables: PromptVariable[] = [
|
||||
{
|
||||
key: 'api_data',
|
||||
name: 'API Data',
|
||||
required: false,
|
||||
type: 'api',
|
||||
enabled: true,
|
||||
config: { endpoint: 'https://api.example.com' },
|
||||
icon: 'api-icon',
|
||||
icon_background: '#FF5733',
|
||||
},
|
||||
]
|
||||
|
||||
const result = promptVariablesToUserInputsForm(promptVariables)
|
||||
|
||||
expect(result[0]).toEqual({
|
||||
external_data_tool: {
|
||||
label: 'API Data',
|
||||
variable: 'api_data',
|
||||
enabled: true,
|
||||
type: 'api',
|
||||
config: { endpoint: 'https://api.example.com' },
|
||||
required: false,
|
||||
icon: 'api-icon',
|
||||
icon_background: '#FF5733',
|
||||
hide: undefined,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that required defaults to true when not explicitly set to false
|
||||
*/
|
||||
it('should default required to true when not false', () => {
|
||||
const promptVariables: PromptVariable[] = [
|
||||
{
|
||||
key: 'test1',
|
||||
name: 'Test 1',
|
||||
required: undefined,
|
||||
type: 'string',
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
key: 'test2',
|
||||
name: 'Test 2',
|
||||
required: false,
|
||||
type: 'string',
|
||||
options: [],
|
||||
},
|
||||
]
|
||||
|
||||
const result = promptVariablesToUserInputsForm(promptVariables)
|
||||
|
||||
expect((result[0] as any)['text-input']?.required).toBe(true)
|
||||
expect((result[1] as any)['text-input']?.required).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatBooleanInputs', () => {
|
||||
/**
|
||||
* Test that null or undefined inputs are handled gracefully
|
||||
*/
|
||||
it('should return inputs unchanged when useInputs is null', () => {
|
||||
const inputs = { key1: 'value1', key2: 'value2' }
|
||||
const result = formatBooleanInputs(null, inputs)
|
||||
expect(result).toEqual(inputs)
|
||||
})
|
||||
|
||||
it('should return inputs unchanged when useInputs is undefined', () => {
|
||||
const inputs = { key1: 'value1', key2: 'value2' }
|
||||
const result = formatBooleanInputs(undefined, inputs)
|
||||
expect(result).toEqual(inputs)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test conversion of boolean input values to actual boolean type
|
||||
* This is important for proper type handling in the backend
|
||||
* Note: checkbox inputs are converted to type 'checkbox' by userInputsFormToPromptVariables
|
||||
*/
|
||||
it('should convert boolean inputs to boolean type', () => {
|
||||
const useInputs: PromptVariable[] = [
|
||||
{
|
||||
key: 'accept_terms',
|
||||
name: 'Accept Terms',
|
||||
required: true,
|
||||
type: 'checkbox',
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
key: 'subscribe',
|
||||
name: 'Subscribe',
|
||||
required: false,
|
||||
type: 'checkbox',
|
||||
options: [],
|
||||
},
|
||||
]
|
||||
|
||||
const inputs = {
|
||||
accept_terms: 'true',
|
||||
subscribe: '',
|
||||
other_field: 'value',
|
||||
}
|
||||
|
||||
const result = formatBooleanInputs(useInputs, inputs)
|
||||
|
||||
expect(result).toEqual({
|
||||
accept_terms: true,
|
||||
subscribe: false,
|
||||
other_field: 'value',
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that non-boolean inputs are not affected
|
||||
*/
|
||||
it('should not modify non-boolean inputs', () => {
|
||||
const useInputs: PromptVariable[] = [
|
||||
{
|
||||
key: 'name',
|
||||
name: 'Name',
|
||||
required: true,
|
||||
type: 'string',
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
key: 'age',
|
||||
name: 'Age',
|
||||
required: true,
|
||||
type: 'number',
|
||||
options: [],
|
||||
},
|
||||
]
|
||||
|
||||
const inputs = {
|
||||
name: 'John Doe',
|
||||
age: 30,
|
||||
}
|
||||
|
||||
const result = formatBooleanInputs(useInputs, inputs)
|
||||
|
||||
expect(result).toEqual(inputs)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test handling of truthy and falsy values for boolean conversion
|
||||
* Note: checkbox inputs are converted to type 'checkbox' by userInputsFormToPromptVariables
|
||||
*/
|
||||
it('should handle various truthy and falsy values', () => {
|
||||
const useInputs: PromptVariable[] = [
|
||||
{
|
||||
key: 'bool1',
|
||||
name: 'Bool 1',
|
||||
required: true,
|
||||
type: 'checkbox',
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
key: 'bool2',
|
||||
name: 'Bool 2',
|
||||
required: true,
|
||||
type: 'checkbox',
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
key: 'bool3',
|
||||
name: 'Bool 3',
|
||||
required: true,
|
||||
type: 'checkbox',
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
key: 'bool4',
|
||||
name: 'Bool 4',
|
||||
required: true,
|
||||
type: 'checkbox',
|
||||
options: [],
|
||||
},
|
||||
]
|
||||
|
||||
const inputs = {
|
||||
bool1: 1,
|
||||
bool2: 0,
|
||||
bool3: 'yes',
|
||||
bool4: null as any,
|
||||
}
|
||||
|
||||
const result = formatBooleanInputs(useInputs, inputs)
|
||||
|
||||
expect(result?.bool1).toBe(true)
|
||||
expect(result?.bool2).toBe(false)
|
||||
expect(result?.bool3).toBe(true)
|
||||
expect(result?.bool4).toBe(false)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that the function creates a new object and doesn't mutate the original
|
||||
* Note: checkbox inputs are converted to type 'checkbox' by userInputsFormToPromptVariables
|
||||
*/
|
||||
it('should not mutate original inputs object', () => {
|
||||
const useInputs: PromptVariable[] = [
|
||||
{
|
||||
key: 'flag',
|
||||
name: 'Flag',
|
||||
required: true,
|
||||
type: 'checkbox',
|
||||
options: [],
|
||||
},
|
||||
]
|
||||
|
||||
const inputs = { flag: 'true', other: 'value' }
|
||||
const originalInputs = { ...inputs }
|
||||
|
||||
formatBooleanInputs(useInputs, inputs)
|
||||
|
||||
expect(inputs).toEqual(originalInputs)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Round-trip conversion', () => {
|
||||
/**
|
||||
* Test that converting from UserInputForm to PromptVariable and back
|
||||
* preserves the essential data (though some fields may have defaults applied)
|
||||
*/
|
||||
it('should preserve data through round-trip conversion', () => {
|
||||
const originalUserInputs: UserInputFormItem[] = [
|
||||
{
|
||||
'text-input': {
|
||||
label: 'Name',
|
||||
variable: 'name',
|
||||
required: true,
|
||||
max_length: 50,
|
||||
default: '',
|
||||
hide: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
select: {
|
||||
label: 'Type',
|
||||
variable: 'type',
|
||||
required: false,
|
||||
options: ['A', 'B', 'C'],
|
||||
default: 'A',
|
||||
hide: false,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const promptVars = userInputsFormToPromptVariables(originalUserInputs)
|
||||
const backToUserInputs = promptVariablesToUserInputsForm(promptVars)
|
||||
|
||||
expect(backToUserInputs).toHaveLength(2)
|
||||
expect((backToUserInputs[0] as any)['text-input']?.variable).toBe('name')
|
||||
expect((backToUserInputs[1] as any).select?.variable).toBe('type')
|
||||
expect((backToUserInputs[1] as any).select?.options).toEqual(['A', 'B', 'C'])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -200,7 +200,7 @@ export const formatBooleanInputs = (useInputs?: PromptVariable[] | null, inputs?
|
||||
return inputs
|
||||
const res = { ...inputs }
|
||||
useInputs.forEach((item) => {
|
||||
const isBooleanInput = item.type === 'boolean'
|
||||
const isBooleanInput = item.type === 'checkbox'
|
||||
if (isBooleanInput) {
|
||||
// Convert boolean inputs to boolean type
|
||||
res[item.key] = !!res[item.key]
|
||||
|
||||
Reference in New Issue
Block a user