test: try to use Anthropic Skills to add tests for web/app/components/apps/ (#29607)

Signed-off-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
yyh 2025-12-16 10:42:34 +08:00 committed by GitHub
parent 7fc501915e
commit a232da564a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 3070 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,60 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import Empty from './empty'
// Mock react-i18next - return key as per testing skills
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
describe('Empty', () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Empty />)
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
})
it('should render 36 placeholder cards', () => {
const { container } = render(<Empty />)
const placeholderCards = container.querySelectorAll('.bg-background-default-lighter')
expect(placeholderCards).toHaveLength(36)
})
it('should display the no apps found message', () => {
render(<Empty />)
// Use pattern matching for resilient text assertions
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
})
})
describe('Styling', () => {
it('should have correct container styling for overlay', () => {
const { container } = render(<Empty />)
const overlay = container.querySelector('.pointer-events-none')
expect(overlay).toBeInTheDocument()
expect(overlay).toHaveClass('absolute', 'inset-0', 'z-20')
})
it('should have correct styling for placeholder cards', () => {
const { container } = render(<Empty />)
const card = container.querySelector('.bg-background-default-lighter')
expect(card).toHaveClass('inline-flex', 'h-[160px]', 'rounded-xl')
})
})
describe('Edge Cases', () => {
it('should handle multiple renders without issues', () => {
const { rerender } = render(<Empty />)
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
rerender(<Empty />)
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,101 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import Footer from './footer'
// Mock react-i18next - return key as per testing skills
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
describe('Footer', () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Footer />)
expect(screen.getByRole('contentinfo')).toBeInTheDocument()
})
it('should display the community heading', () => {
render(<Footer />)
// Use pattern matching for resilient text assertions
expect(screen.getByText('app.join')).toBeInTheDocument()
})
it('should display the community intro text', () => {
render(<Footer />)
expect(screen.getByText('app.communityIntro')).toBeInTheDocument()
})
})
describe('Links', () => {
it('should render GitHub link with correct href', () => {
const { container } = render(<Footer />)
const githubLink = container.querySelector('a[href="https://github.com/langgenius/dify"]')
expect(githubLink).toBeInTheDocument()
})
it('should render Discord link with correct href', () => {
const { container } = render(<Footer />)
const discordLink = container.querySelector('a[href="https://discord.gg/FngNHpbcY7"]')
expect(discordLink).toBeInTheDocument()
})
it('should render Forum link with correct href', () => {
const { container } = render(<Footer />)
const forumLink = container.querySelector('a[href="https://forum.dify.ai"]')
expect(forumLink).toBeInTheDocument()
})
it('should have 3 community links', () => {
render(<Footer />)
const links = screen.getAllByRole('link')
expect(links).toHaveLength(3)
})
it('should open links in new tab', () => {
render(<Footer />)
const links = screen.getAllByRole('link')
links.forEach((link) => {
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
})
})
})
describe('Styling', () => {
it('should have correct footer styling', () => {
render(<Footer />)
const footer = screen.getByRole('contentinfo')
expect(footer).toHaveClass('relative', 'shrink-0', 'grow-0')
})
it('should have gradient text styling on heading', () => {
render(<Footer />)
const heading = screen.getByText('app.join')
expect(heading).toHaveClass('text-gradient')
})
})
describe('Icons', () => {
it('should render icons within links', () => {
const { container } = render(<Footer />)
const svgElements = container.querySelectorAll('svg')
expect(svgElements.length).toBeGreaterThanOrEqual(3)
})
})
describe('Edge Cases', () => {
it('should handle multiple renders without issues', () => {
const { rerender } = render(<Footer />)
expect(screen.getByRole('contentinfo')).toBeInTheDocument()
rerender(<Footer />)
expect(screen.getByRole('contentinfo')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,363 @@
/**
* Test suite for useAppsQueryState hook
*
* This hook manages app filtering state through URL search parameters, enabling:
* - Bookmarkable filter states (users can share URLs with specific filters active)
* - Browser history integration (back/forward buttons work with filters)
* - Multiple filter types: tagIDs, keywords, isCreatedByMe
*
* The hook syncs local filter state with URL search parameters, making filter
* navigation persistent and shareable across sessions.
*/
import { act, renderHook } from '@testing-library/react'
// Mock Next.js navigation hooks
const mockPush = jest.fn()
const mockPathname = '/apps'
let mockSearchParams = new URLSearchParams()
jest.mock('next/navigation', () => ({
usePathname: jest.fn(() => mockPathname),
useRouter: jest.fn(() => ({
push: mockPush,
})),
useSearchParams: jest.fn(() => mockSearchParams),
}))
// Import the hook after mocks are set up
import useAppsQueryState from './use-apps-query-state'
describe('useAppsQueryState', () => {
beforeEach(() => {
jest.clearAllMocks()
mockSearchParams = new URLSearchParams()
})
describe('Basic functionality', () => {
it('should return query object and setQuery function', () => {
const { result } = renderHook(() => useAppsQueryState())
expect(result.current.query).toBeDefined()
expect(typeof result.current.setQuery).toBe('function')
})
it('should initialize with empty query when no search params exist', () => {
const { result } = renderHook(() => useAppsQueryState())
expect(result.current.query.tagIDs).toBeUndefined()
expect(result.current.query.keywords).toBeUndefined()
expect(result.current.query.isCreatedByMe).toBe(false)
})
})
describe('Parsing search params', () => {
it('should parse tagIDs from URL', () => {
mockSearchParams.set('tagIDs', 'tag1;tag2;tag3')
const { result } = renderHook(() => useAppsQueryState())
expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2', 'tag3'])
})
it('should parse single tagID from URL', () => {
mockSearchParams.set('tagIDs', 'single-tag')
const { result } = renderHook(() => useAppsQueryState())
expect(result.current.query.tagIDs).toEqual(['single-tag'])
})
it('should parse keywords from URL', () => {
mockSearchParams.set('keywords', 'search term')
const { result } = renderHook(() => useAppsQueryState())
expect(result.current.query.keywords).toBe('search term')
})
it('should parse isCreatedByMe as true from URL', () => {
mockSearchParams.set('isCreatedByMe', 'true')
const { result } = renderHook(() => useAppsQueryState())
expect(result.current.query.isCreatedByMe).toBe(true)
})
it('should parse isCreatedByMe as false for other values', () => {
mockSearchParams.set('isCreatedByMe', 'false')
const { result } = renderHook(() => useAppsQueryState())
expect(result.current.query.isCreatedByMe).toBe(false)
})
it('should parse all params together', () => {
mockSearchParams.set('tagIDs', 'tag1;tag2')
mockSearchParams.set('keywords', 'test')
mockSearchParams.set('isCreatedByMe', 'true')
const { result } = renderHook(() => useAppsQueryState())
expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2'])
expect(result.current.query.keywords).toBe('test')
expect(result.current.query.isCreatedByMe).toBe(true)
})
})
describe('Updating query state', () => {
it('should update keywords via setQuery', () => {
const { result } = renderHook(() => useAppsQueryState())
act(() => {
result.current.setQuery({ keywords: 'new search' })
})
expect(result.current.query.keywords).toBe('new search')
})
it('should update tagIDs via setQuery', () => {
const { result } = renderHook(() => useAppsQueryState())
act(() => {
result.current.setQuery({ tagIDs: ['tag1', 'tag2'] })
})
expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2'])
})
it('should update isCreatedByMe via setQuery', () => {
const { result } = renderHook(() => useAppsQueryState())
act(() => {
result.current.setQuery({ isCreatedByMe: true })
})
expect(result.current.query.isCreatedByMe).toBe(true)
})
it('should support partial updates via callback', () => {
const { result } = renderHook(() => useAppsQueryState())
act(() => {
result.current.setQuery({ keywords: 'initial' })
})
act(() => {
result.current.setQuery(prev => ({ ...prev, isCreatedByMe: true }))
})
expect(result.current.query.keywords).toBe('initial')
expect(result.current.query.isCreatedByMe).toBe(true)
})
})
describe('URL synchronization', () => {
it('should sync keywords to URL', async () => {
const { result } = renderHook(() => useAppsQueryState())
act(() => {
result.current.setQuery({ keywords: 'search' })
})
// Wait for useEffect to run
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0))
})
expect(mockPush).toHaveBeenCalledWith(
expect.stringContaining('keywords=search'),
{ scroll: false },
)
})
it('should sync tagIDs to URL with semicolon separator', async () => {
const { result } = renderHook(() => useAppsQueryState())
act(() => {
result.current.setQuery({ tagIDs: ['tag1', 'tag2'] })
})
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0))
})
expect(mockPush).toHaveBeenCalledWith(
expect.stringContaining('tagIDs=tag1%3Btag2'),
{ scroll: false },
)
})
it('should sync isCreatedByMe to URL', async () => {
const { result } = renderHook(() => useAppsQueryState())
act(() => {
result.current.setQuery({ isCreatedByMe: true })
})
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0))
})
expect(mockPush).toHaveBeenCalledWith(
expect.stringContaining('isCreatedByMe=true'),
{ scroll: false },
)
})
it('should remove keywords from URL when empty', async () => {
mockSearchParams.set('keywords', 'existing')
const { result } = renderHook(() => useAppsQueryState())
act(() => {
result.current.setQuery({ keywords: '' })
})
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0))
})
// Should be called without keywords param
expect(mockPush).toHaveBeenCalled()
})
it('should remove tagIDs from URL when empty array', async () => {
mockSearchParams.set('tagIDs', 'tag1;tag2')
const { result } = renderHook(() => useAppsQueryState())
act(() => {
result.current.setQuery({ tagIDs: [] })
})
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0))
})
expect(mockPush).toHaveBeenCalled()
})
it('should remove isCreatedByMe from URL when false', async () => {
mockSearchParams.set('isCreatedByMe', 'true')
const { result } = renderHook(() => useAppsQueryState())
act(() => {
result.current.setQuery({ isCreatedByMe: false })
})
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0))
})
expect(mockPush).toHaveBeenCalled()
})
})
describe('Edge cases', () => {
it('should handle empty tagIDs string in URL', () => {
// NOTE: This test documents current behavior where ''.split(';') returns ['']
// This could potentially cause filtering issues as it's treated as a tag with empty name
// rather than absence of tags. Consider updating parseParams if this is problematic.
mockSearchParams.set('tagIDs', '')
const { result } = renderHook(() => useAppsQueryState())
expect(result.current.query.tagIDs).toEqual([''])
})
it('should handle empty keywords', () => {
mockSearchParams.set('keywords', '')
const { result } = renderHook(() => useAppsQueryState())
expect(result.current.query.keywords).toBeUndefined()
})
it('should handle undefined tagIDs', () => {
const { result } = renderHook(() => useAppsQueryState())
act(() => {
result.current.setQuery({ tagIDs: undefined })
})
expect(result.current.query.tagIDs).toBeUndefined()
})
it('should handle special characters in keywords', () => {
// Use URLSearchParams constructor to properly simulate URL decoding behavior
// URLSearchParams.get() decodes URL-encoded characters
mockSearchParams = new URLSearchParams('keywords=test%20with%20spaces')
const { result } = renderHook(() => useAppsQueryState())
expect(result.current.query.keywords).toBe('test with spaces')
})
})
describe('Memoization', () => {
it('should return memoized object reference when query unchanged', () => {
const { result, rerender } = renderHook(() => useAppsQueryState())
const firstResult = result.current
rerender()
const secondResult = result.current
expect(firstResult.query).toBe(secondResult.query)
})
it('should return new object reference when query changes', () => {
const { result } = renderHook(() => useAppsQueryState())
const firstQuery = result.current.query
act(() => {
result.current.setQuery({ keywords: 'changed' })
})
expect(result.current.query).not.toBe(firstQuery)
})
})
describe('Integration scenarios', () => {
it('should handle sequential updates', async () => {
const { result } = renderHook(() => useAppsQueryState())
act(() => {
result.current.setQuery({ keywords: 'first' })
})
act(() => {
result.current.setQuery(prev => ({ ...prev, tagIDs: ['tag1'] }))
})
act(() => {
result.current.setQuery(prev => ({ ...prev, isCreatedByMe: true }))
})
expect(result.current.query.keywords).toBe('first')
expect(result.current.query.tagIDs).toEqual(['tag1'])
expect(result.current.query.isCreatedByMe).toBe(true)
})
it('should clear all filters', () => {
mockSearchParams.set('tagIDs', 'tag1;tag2')
mockSearchParams.set('keywords', 'search')
mockSearchParams.set('isCreatedByMe', 'true')
const { result } = renderHook(() => useAppsQueryState())
act(() => {
result.current.setQuery({
tagIDs: undefined,
keywords: undefined,
isCreatedByMe: false,
})
})
expect(result.current.query.tagIDs).toBeUndefined()
expect(result.current.query.keywords).toBeUndefined()
expect(result.current.query.isCreatedByMe).toBe(false)
})
})
})

View File

@ -0,0 +1,493 @@
/**
* Test suite for useDSLDragDrop hook
*
* This hook provides drag-and-drop functionality for DSL files, enabling:
* - File drag detection with visual feedback (dragging state)
* - YAML/YML file filtering (only accepts .yaml and .yml files)
* - Enable/disable toggle for conditional drag-and-drop
* - Cleanup on unmount (removes event listeners)
*/
import { act, renderHook } from '@testing-library/react'
import { useDSLDragDrop } from './use-dsl-drag-drop'
describe('useDSLDragDrop', () => {
let container: HTMLDivElement
let mockOnDSLFileDropped: jest.Mock
beforeEach(() => {
jest.clearAllMocks()
container = document.createElement('div')
document.body.appendChild(container)
mockOnDSLFileDropped = jest.fn()
})
afterEach(() => {
document.body.removeChild(container)
})
// Helper to create drag events
const createDragEvent = (type: string, files: File[] = []) => {
const dataTransfer = {
types: files.length > 0 ? ['Files'] : [],
files,
}
const event = new Event(type, { bubbles: true, cancelable: true }) as DragEvent
Object.defineProperty(event, 'dataTransfer', {
value: dataTransfer,
writable: false,
})
Object.defineProperty(event, 'preventDefault', {
value: jest.fn(),
writable: false,
})
Object.defineProperty(event, 'stopPropagation', {
value: jest.fn(),
writable: false,
})
return event
}
// Helper to create a mock file
const createMockFile = (name: string) => {
return new File(['content'], name, { type: 'application/x-yaml' })
}
describe('Basic functionality', () => {
it('should return dragging state', () => {
const containerRef = { current: container }
const { result } = renderHook(() =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
}),
)
expect(result.current.dragging).toBe(false)
})
it('should initialize with dragging as false', () => {
const containerRef = { current: container }
const { result } = renderHook(() =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
}),
)
expect(result.current.dragging).toBe(false)
})
})
describe('Drag events', () => {
it('should set dragging to true on dragenter with files', () => {
const containerRef = { current: container }
const { result } = renderHook(() =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
}),
)
const file = createMockFile('test.yaml')
const event = createDragEvent('dragenter', [file])
act(() => {
container.dispatchEvent(event)
})
expect(result.current.dragging).toBe(true)
})
it('should not set dragging on dragenter without files', () => {
const containerRef = { current: container }
const { result } = renderHook(() =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
}),
)
const event = createDragEvent('dragenter', [])
act(() => {
container.dispatchEvent(event)
})
expect(result.current.dragging).toBe(false)
})
it('should handle dragover event', () => {
const containerRef = { current: container }
renderHook(() =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
}),
)
const event = createDragEvent('dragover')
act(() => {
container.dispatchEvent(event)
})
expect(event.preventDefault).toHaveBeenCalled()
expect(event.stopPropagation).toHaveBeenCalled()
})
it('should set dragging to false on dragleave when leaving container', () => {
const containerRef = { current: container }
const { result } = renderHook(() =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
}),
)
// First, enter with files
const enterEvent = createDragEvent('dragenter', [createMockFile('test.yaml')])
act(() => {
container.dispatchEvent(enterEvent)
})
expect(result.current.dragging).toBe(true)
// Then leave with null relatedTarget (leaving container)
const leaveEvent = createDragEvent('dragleave')
Object.defineProperty(leaveEvent, 'relatedTarget', {
value: null,
writable: false,
})
act(() => {
container.dispatchEvent(leaveEvent)
})
expect(result.current.dragging).toBe(false)
})
it('should not set dragging to false on dragleave when within container', () => {
const containerRef = { current: container }
const childElement = document.createElement('div')
container.appendChild(childElement)
const { result } = renderHook(() =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
}),
)
// First, enter with files
const enterEvent = createDragEvent('dragenter', [createMockFile('test.yaml')])
act(() => {
container.dispatchEvent(enterEvent)
})
expect(result.current.dragging).toBe(true)
// Then leave but to a child element
const leaveEvent = createDragEvent('dragleave')
Object.defineProperty(leaveEvent, 'relatedTarget', {
value: childElement,
writable: false,
})
act(() => {
container.dispatchEvent(leaveEvent)
})
expect(result.current.dragging).toBe(true)
container.removeChild(childElement)
})
})
describe('Drop functionality', () => {
it('should call onDSLFileDropped for .yaml file', () => {
const containerRef = { current: container }
renderHook(() =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
}),
)
const file = createMockFile('test.yaml')
const dropEvent = createDragEvent('drop', [file])
act(() => {
container.dispatchEvent(dropEvent)
})
expect(mockOnDSLFileDropped).toHaveBeenCalledWith(file)
})
it('should call onDSLFileDropped for .yml file', () => {
const containerRef = { current: container }
renderHook(() =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
}),
)
const file = createMockFile('test.yml')
const dropEvent = createDragEvent('drop', [file])
act(() => {
container.dispatchEvent(dropEvent)
})
expect(mockOnDSLFileDropped).toHaveBeenCalledWith(file)
})
it('should call onDSLFileDropped for uppercase .YAML file', () => {
const containerRef = { current: container }
renderHook(() =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
}),
)
const file = createMockFile('test.YAML')
const dropEvent = createDragEvent('drop', [file])
act(() => {
container.dispatchEvent(dropEvent)
})
expect(mockOnDSLFileDropped).toHaveBeenCalledWith(file)
})
it('should not call onDSLFileDropped for non-yaml file', () => {
const containerRef = { current: container }
renderHook(() =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
}),
)
const file = createMockFile('test.json')
const dropEvent = createDragEvent('drop', [file])
act(() => {
container.dispatchEvent(dropEvent)
})
expect(mockOnDSLFileDropped).not.toHaveBeenCalled()
})
it('should set dragging to false on drop', () => {
const containerRef = { current: container }
const { result } = renderHook(() =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
}),
)
// First, enter with files
const enterEvent = createDragEvent('dragenter', [createMockFile('test.yaml')])
act(() => {
container.dispatchEvent(enterEvent)
})
expect(result.current.dragging).toBe(true)
// Then drop
const dropEvent = createDragEvent('drop', [createMockFile('test.yaml')])
act(() => {
container.dispatchEvent(dropEvent)
})
expect(result.current.dragging).toBe(false)
})
it('should handle drop with no dataTransfer', () => {
const containerRef = { current: container }
renderHook(() =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
}),
)
const event = new Event('drop', { bubbles: true, cancelable: true }) as DragEvent
Object.defineProperty(event, 'dataTransfer', {
value: null,
writable: false,
})
Object.defineProperty(event, 'preventDefault', {
value: jest.fn(),
writable: false,
})
Object.defineProperty(event, 'stopPropagation', {
value: jest.fn(),
writable: false,
})
act(() => {
container.dispatchEvent(event)
})
expect(mockOnDSLFileDropped).not.toHaveBeenCalled()
})
it('should handle drop with empty files array', () => {
const containerRef = { current: container }
renderHook(() =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
}),
)
const dropEvent = createDragEvent('drop', [])
act(() => {
container.dispatchEvent(dropEvent)
})
expect(mockOnDSLFileDropped).not.toHaveBeenCalled()
})
it('should only process the first file when multiple files are dropped', () => {
const containerRef = { current: container }
renderHook(() =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
}),
)
const file1 = createMockFile('test1.yaml')
const file2 = createMockFile('test2.yaml')
const dropEvent = createDragEvent('drop', [file1, file2])
act(() => {
container.dispatchEvent(dropEvent)
})
expect(mockOnDSLFileDropped).toHaveBeenCalledTimes(1)
expect(mockOnDSLFileDropped).toHaveBeenCalledWith(file1)
})
})
describe('Enabled prop', () => {
it('should not add event listeners when enabled is false', () => {
const containerRef = { current: container }
const { result } = renderHook(() =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
enabled: false,
}),
)
const file = createMockFile('test.yaml')
const enterEvent = createDragEvent('dragenter', [file])
act(() => {
container.dispatchEvent(enterEvent)
})
expect(result.current.dragging).toBe(false)
})
it('should return dragging as false when enabled is false even if state is true', () => {
const containerRef = { current: container }
const { result, rerender } = renderHook(
({ enabled }) =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
enabled,
}),
{ initialProps: { enabled: true } },
)
// Set dragging state
const enterEvent = createDragEvent('dragenter', [createMockFile('test.yaml')])
act(() => {
container.dispatchEvent(enterEvent)
})
expect(result.current.dragging).toBe(true)
// Disable the hook
rerender({ enabled: false })
expect(result.current.dragging).toBe(false)
})
it('should default enabled to true', () => {
const containerRef = { current: container }
const { result } = renderHook(() =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
}),
)
const enterEvent = createDragEvent('dragenter', [createMockFile('test.yaml')])
act(() => {
container.dispatchEvent(enterEvent)
})
expect(result.current.dragging).toBe(true)
})
})
describe('Cleanup', () => {
it('should remove event listeners on unmount', () => {
const containerRef = { current: container }
const removeEventListenerSpy = jest.spyOn(container, 'removeEventListener')
const { unmount } = renderHook(() =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
}),
)
unmount()
expect(removeEventListenerSpy).toHaveBeenCalledWith('dragenter', expect.any(Function))
expect(removeEventListenerSpy).toHaveBeenCalledWith('dragover', expect.any(Function))
expect(removeEventListenerSpy).toHaveBeenCalledWith('dragleave', expect.any(Function))
expect(removeEventListenerSpy).toHaveBeenCalledWith('drop', expect.any(Function))
removeEventListenerSpy.mockRestore()
})
})
describe('Edge cases', () => {
it('should handle null containerRef', () => {
const containerRef = { current: null }
const { result } = renderHook(() =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
}),
)
expect(result.current.dragging).toBe(false)
})
it('should handle containerRef changing to null', () => {
const containerRef = { current: container as HTMLDivElement | null }
const { result, rerender } = renderHook(() =>
useDSLDragDrop({
onDSLFileDropped: mockOnDSLFileDropped,
containerRef,
}),
)
containerRef.current = null
rerender()
expect(result.current.dragging).toBe(false)
})
})
})

View File

@ -0,0 +1,113 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
// Mock react-i18next - return key as per testing skills
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Track mock calls
let documentTitleCalls: string[] = []
let educationInitCalls: number = 0
// Mock useDocumentTitle hook
jest.mock('@/hooks/use-document-title', () => ({
__esModule: true,
default: (title: string) => {
documentTitleCalls.push(title)
},
}))
// Mock useEducationInit hook
jest.mock('@/app/education-apply/hooks', () => ({
useEducationInit: () => {
educationInitCalls++
},
}))
// Mock List component
jest.mock('./list', () => ({
__esModule: true,
default: () => {
const React = require('react')
return React.createElement('div', { 'data-testid': 'apps-list' }, 'Apps List')
},
}))
// Import after mocks
import Apps from './index'
describe('Apps', () => {
beforeEach(() => {
jest.clearAllMocks()
documentTitleCalls = []
educationInitCalls = 0
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Apps />)
expect(screen.getByTestId('apps-list')).toBeInTheDocument()
})
it('should render List component', () => {
render(<Apps />)
expect(screen.getByText('Apps List')).toBeInTheDocument()
})
it('should have correct container structure', () => {
const { container } = render(<Apps />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('relative', 'flex', 'h-0', 'shrink-0', 'grow', 'flex-col')
})
})
describe('Hooks', () => {
it('should call useDocumentTitle with correct title', () => {
render(<Apps />)
expect(documentTitleCalls).toContain('common.menus.apps')
})
it('should call useEducationInit', () => {
render(<Apps />)
expect(educationInitCalls).toBeGreaterThan(0)
})
})
describe('Integration', () => {
it('should render full component tree', () => {
render(<Apps />)
// Verify container exists
expect(screen.getByTestId('apps-list')).toBeInTheDocument()
// Verify hooks were called
expect(documentTitleCalls.length).toBeGreaterThanOrEqual(1)
expect(educationInitCalls).toBeGreaterThanOrEqual(1)
})
it('should handle multiple renders', () => {
const { rerender } = render(<Apps />)
expect(screen.getByTestId('apps-list')).toBeInTheDocument()
rerender(<Apps />)
expect(screen.getByTestId('apps-list')).toBeInTheDocument()
})
})
describe('Styling', () => {
it('should have overflow-y-auto class', () => {
const { container } = render(<Apps />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('overflow-y-auto')
})
it('should have background styling', () => {
const { container } = render(<Apps />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('bg-background-body')
})
})
})

View File

@ -0,0 +1,580 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { AppModeEnum } from '@/types/app'
// Mock react-i18next - return key as per testing skills
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock next/navigation
const mockReplace = jest.fn()
const mockRouter = { replace: mockReplace }
jest.mock('next/navigation', () => ({
useRouter: () => mockRouter,
}))
// Mock app context
const mockIsCurrentWorkspaceEditor = jest.fn(() => true)
const mockIsCurrentWorkspaceDatasetOperator = jest.fn(() => false)
jest.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor(),
isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator(),
}),
}))
// Mock global public store
jest.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: () => ({
systemFeatures: {
branding: { enabled: false },
},
}),
}))
// Mock custom hooks
const mockSetQuery = jest.fn()
jest.mock('./hooks/use-apps-query-state', () => ({
__esModule: true,
default: () => ({
query: { tagIDs: [], keywords: '', isCreatedByMe: false },
setQuery: mockSetQuery,
}),
}))
jest.mock('./hooks/use-dsl-drag-drop', () => ({
useDSLDragDrop: () => ({
dragging: false,
}),
}))
const mockSetActiveTab = jest.fn()
jest.mock('@/hooks/use-tab-searchparams', () => ({
useTabSearchParams: () => ['all', mockSetActiveTab],
}))
// Mock service hooks
const mockRefetch = jest.fn()
jest.mock('@/service/use-apps', () => ({
useInfiniteAppList: () => ({
data: {
pages: [{
data: [
{
id: 'app-1',
name: 'Test App 1',
description: 'Description 1',
mode: AppModeEnum.CHAT,
icon: '🤖',
icon_type: 'emoji',
icon_background: '#FFEAD5',
tags: [],
author_name: 'Author 1',
created_at: 1704067200,
updated_at: 1704153600,
},
{
id: 'app-2',
name: 'Test App 2',
description: 'Description 2',
mode: AppModeEnum.WORKFLOW,
icon: '⚙️',
icon_type: 'emoji',
icon_background: '#E4FBCC',
tags: [],
author_name: 'Author 2',
created_at: 1704067200,
updated_at: 1704153600,
},
],
total: 2,
}],
},
isLoading: false,
isFetchingNextPage: false,
fetchNextPage: jest.fn(),
hasNextPage: false,
error: null,
refetch: mockRefetch,
}),
}))
// Mock tag store
jest.mock('@/app/components/base/tag-management/store', () => ({
useStore: () => false,
}))
// Mock config
jest.mock('@/config', () => ({
NEED_REFRESH_APP_LIST_KEY: 'needRefreshAppList',
}))
// Mock pay hook
jest.mock('@/hooks/use-pay', () => ({
CheckModal: () => null,
}))
// Mock debounce hook
jest.mock('ahooks', () => ({
useDebounceFn: (fn: () => void) => ({ run: fn }),
}))
// Mock dynamic imports
jest.mock('next/dynamic', () => {
const React = require('react')
return (importFn: () => Promise<any>) => {
const fnString = importFn.toString()
if (fnString.includes('tag-management')) {
return function MockTagManagement() {
return React.createElement('div', { 'data-testid': 'tag-management-modal' })
}
}
if (fnString.includes('create-from-dsl-modal')) {
return function MockCreateFromDSLModal({ show, onClose }: any) {
if (!show) return null
return React.createElement('div', { 'data-testid': 'create-dsl-modal' },
React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-dsl-modal' }, 'Close'),
)
}
}
return () => null
}
})
/**
* Mock child components for focused List component testing.
* These mocks isolate the List component's behavior from its children.
* Each child component (AppCard, NewAppCard, Empty, Footer) has its own dedicated tests.
*/
jest.mock('./app-card', () => ({
__esModule: true,
default: ({ app }: any) => {
const React = require('react')
return React.createElement('div', { 'data-testid': `app-card-${app.id}`, 'role': 'article' }, app.name)
},
}))
jest.mock('./new-app-card', () => {
const React = require('react')
return React.forwardRef((_props: any, _ref: any) => {
return React.createElement('div', { 'data-testid': 'new-app-card', 'role': 'button' }, 'New App Card')
})
})
jest.mock('./empty', () => ({
__esModule: true,
default: () => {
const React = require('react')
return React.createElement('div', { 'data-testid': 'empty-state', 'role': 'status' }, 'No apps found')
},
}))
jest.mock('./footer', () => ({
__esModule: true,
default: () => {
const React = require('react')
return React.createElement('footer', { 'data-testid': 'footer', 'role': 'contentinfo' }, 'Footer')
},
}))
/**
* Mock base components that have deep dependency chains or require controlled test behavior.
*
* Per frontend testing skills (mocking.md), we generally should NOT mock base components.
* However, the following require mocking due to:
* - Deep dependency chains importing ES modules (like ky) incompatible with Jest
* - Need for controlled interaction behavior in tests (onChange, onClear handlers)
* - Complex internal state that would make tests flaky
*
* These mocks preserve the component's props interface to test List's integration correctly.
*/
jest.mock('@/app/components/base/tab-slider-new', () => ({
__esModule: true,
default: ({ value, onChange, options }: any) => {
const React = require('react')
return React.createElement('div', { 'data-testid': 'tab-slider', 'role': 'tablist' },
options.map((opt: any) =>
React.createElement('button', {
'key': opt.value,
'data-testid': `tab-${opt.value}`,
'role': 'tab',
'aria-selected': value === opt.value,
'onClick': () => onChange(opt.value),
}, opt.text),
),
)
},
}))
jest.mock('@/app/components/base/input', () => ({
__esModule: true,
default: ({ value, onChange, onClear }: any) => {
const React = require('react')
return React.createElement('div', { 'data-testid': 'search-input' },
React.createElement('input', {
'data-testid': 'search-input-field',
'role': 'searchbox',
'value': value || '',
onChange,
}),
React.createElement('button', {
'data-testid': 'clear-search',
'aria-label': 'Clear search',
'onClick': onClear,
}, 'Clear'),
)
},
}))
jest.mock('@/app/components/base/tag-management/filter', () => ({
__esModule: true,
default: ({ value, onChange }: any) => {
const React = require('react')
return React.createElement('div', { 'data-testid': 'tag-filter', 'role': 'listbox' },
React.createElement('button', {
'data-testid': 'add-tag-filter',
'onClick': () => onChange([...value, 'new-tag']),
}, 'Add Tag'),
)
},
}))
jest.mock('@/app/components/datasets/create/website/base/checkbox-with-label', () => ({
__esModule: true,
default: ({ label, isChecked, onChange }: any) => {
const React = require('react')
return React.createElement('label', { 'data-testid': 'created-by-me-checkbox' },
React.createElement('input', {
'type': 'checkbox',
'role': 'checkbox',
'checked': isChecked,
'aria-checked': isChecked,
onChange,
'data-testid': 'created-by-me-input',
}),
label,
)
},
}))
// Import after mocks
import List from './list'
describe('List', () => {
beforeEach(() => {
jest.clearAllMocks()
mockIsCurrentWorkspaceEditor.mockReturnValue(true)
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(false)
localStorage.clear()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<List />)
expect(screen.getByTestId('tab-slider')).toBeInTheDocument()
})
it('should render tab slider with all app types', () => {
render(<List />)
expect(screen.getByTestId('tab-all')).toBeInTheDocument()
expect(screen.getByTestId(`tab-${AppModeEnum.WORKFLOW}`)).toBeInTheDocument()
expect(screen.getByTestId(`tab-${AppModeEnum.ADVANCED_CHAT}`)).toBeInTheDocument()
expect(screen.getByTestId(`tab-${AppModeEnum.CHAT}`)).toBeInTheDocument()
expect(screen.getByTestId(`tab-${AppModeEnum.AGENT_CHAT}`)).toBeInTheDocument()
expect(screen.getByTestId(`tab-${AppModeEnum.COMPLETION}`)).toBeInTheDocument()
})
it('should render search input', () => {
render(<List />)
expect(screen.getByTestId('search-input')).toBeInTheDocument()
})
it('should render tag filter', () => {
render(<List />)
expect(screen.getByTestId('tag-filter')).toBeInTheDocument()
})
it('should render created by me checkbox', () => {
render(<List />)
expect(screen.getByTestId('created-by-me-checkbox')).toBeInTheDocument()
})
it('should render app cards when apps exist', () => {
render(<List />)
expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument()
expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument()
})
it('should render new app card for editors', () => {
render(<List />)
expect(screen.getByTestId('new-app-card')).toBeInTheDocument()
})
it('should render footer when branding is disabled', () => {
render(<List />)
expect(screen.getByTestId('footer')).toBeInTheDocument()
})
it('should render drop DSL hint for editors', () => {
render(<List />)
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
})
})
describe('Tab Navigation', () => {
it('should call setActiveTab when tab is clicked', () => {
render(<List />)
fireEvent.click(screen.getByTestId(`tab-${AppModeEnum.WORKFLOW}`))
expect(mockSetActiveTab).toHaveBeenCalledWith(AppModeEnum.WORKFLOW)
})
it('should call setActiveTab for all tab', () => {
render(<List />)
fireEvent.click(screen.getByTestId('tab-all'))
expect(mockSetActiveTab).toHaveBeenCalledWith('all')
})
})
describe('Search Functionality', () => {
it('should render search input field', () => {
render(<List />)
expect(screen.getByTestId('search-input-field')).toBeInTheDocument()
})
it('should handle search input change', () => {
render(<List />)
const input = screen.getByTestId('search-input-field')
fireEvent.change(input, { target: { value: 'test search' } })
expect(mockSetQuery).toHaveBeenCalled()
})
it('should clear search when clear button is clicked', () => {
render(<List />)
fireEvent.click(screen.getByTestId('clear-search'))
expect(mockSetQuery).toHaveBeenCalled()
})
})
describe('Tag Filter', () => {
it('should render tag filter component', () => {
render(<List />)
expect(screen.getByTestId('tag-filter')).toBeInTheDocument()
})
it('should handle tag filter change', () => {
render(<List />)
fireEvent.click(screen.getByTestId('add-tag-filter'))
// Tag filter change triggers debounced setTagIDs
expect(screen.getByTestId('tag-filter')).toBeInTheDocument()
})
})
describe('Created By Me Filter', () => {
it('should render checkbox with correct label', () => {
render(<List />)
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
})
it('should handle checkbox change', () => {
render(<List />)
const checkbox = screen.getByTestId('created-by-me-input')
fireEvent.click(checkbox)
expect(mockSetQuery).toHaveBeenCalled()
})
})
describe('Non-Editor User', () => {
it('should not render new app card for non-editors', () => {
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
render(<List />)
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
})
it('should not render drop DSL hint for non-editors', () => {
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
render(<List />)
expect(screen.queryByText(/drop dsl file to create app/i)).not.toBeInTheDocument()
})
})
describe('Dataset Operator Redirect', () => {
it('should redirect dataset operators to datasets page', () => {
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(true)
render(<List />)
expect(mockReplace).toHaveBeenCalledWith('/datasets')
})
})
describe('Local Storage Refresh', () => {
it('should call refetch when refresh key is set in localStorage', () => {
localStorage.setItem('needRefreshAppList', '1')
render(<List />)
expect(mockRefetch).toHaveBeenCalled()
expect(localStorage.getItem('needRefreshAppList')).toBeNull()
})
})
describe('Edge Cases', () => {
it('should handle multiple renders without issues', () => {
const { rerender } = render(<List />)
expect(screen.getByTestId('tab-slider')).toBeInTheDocument()
rerender(<List />)
expect(screen.getByTestId('tab-slider')).toBeInTheDocument()
})
it('should render app cards correctly', () => {
render(<List />)
expect(screen.getByText('Test App 1')).toBeInTheDocument()
expect(screen.getByText('Test App 2')).toBeInTheDocument()
})
it('should render with all filter options visible', () => {
render(<List />)
expect(screen.getByTestId('search-input')).toBeInTheDocument()
expect(screen.getByTestId('tag-filter')).toBeInTheDocument()
expect(screen.getByTestId('created-by-me-checkbox')).toBeInTheDocument()
})
})
describe('Dragging State', () => {
it('should show drop hint when DSL feature is enabled for editors', () => {
render(<List />)
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
})
})
describe('App Type Tabs', () => {
it('should render all app type tabs', () => {
render(<List />)
expect(screen.getByTestId('tab-all')).toBeInTheDocument()
expect(screen.getByTestId(`tab-${AppModeEnum.WORKFLOW}`)).toBeInTheDocument()
expect(screen.getByTestId(`tab-${AppModeEnum.ADVANCED_CHAT}`)).toBeInTheDocument()
expect(screen.getByTestId(`tab-${AppModeEnum.CHAT}`)).toBeInTheDocument()
expect(screen.getByTestId(`tab-${AppModeEnum.AGENT_CHAT}`)).toBeInTheDocument()
expect(screen.getByTestId(`tab-${AppModeEnum.COMPLETION}`)).toBeInTheDocument()
})
it('should call setActiveTab for each app type', () => {
render(<List />)
const appModes = [
AppModeEnum.WORKFLOW,
AppModeEnum.ADVANCED_CHAT,
AppModeEnum.CHAT,
AppModeEnum.AGENT_CHAT,
AppModeEnum.COMPLETION,
]
appModes.forEach((mode) => {
fireEvent.click(screen.getByTestId(`tab-${mode}`))
expect(mockSetActiveTab).toHaveBeenCalledWith(mode)
})
})
})
describe('Search and Filter Integration', () => {
it('should display search input with correct attributes', () => {
render(<List />)
const input = screen.getByTestId('search-input-field')
expect(input).toBeInTheDocument()
expect(input).toHaveAttribute('value', '')
})
it('should have tag filter component', () => {
render(<List />)
const tagFilter = screen.getByTestId('tag-filter')
expect(tagFilter).toBeInTheDocument()
})
it('should display created by me label', () => {
render(<List />)
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
})
})
describe('App List Display', () => {
it('should display all app cards from data', () => {
render(<List />)
expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument()
expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument()
})
it('should display app names correctly', () => {
render(<List />)
expect(screen.getByText('Test App 1')).toBeInTheDocument()
expect(screen.getByText('Test App 2')).toBeInTheDocument()
})
})
describe('Footer Visibility', () => {
it('should render footer when branding is disabled', () => {
render(<List />)
expect(screen.getByTestId('footer')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Additional Coverage Tests
// --------------------------------------------------------------------------
describe('Additional Coverage', () => {
it('should render dragging state overlay when dragging', () => {
// Test dragging state is handled
const { container } = render(<List />)
// Component should render successfully
expect(container).toBeInTheDocument()
})
it('should handle app mode filter in query params', () => {
// Test that different modes are handled in query
render(<List />)
const workflowTab = screen.getByTestId(`tab-${AppModeEnum.WORKFLOW}`)
fireEvent.click(workflowTab)
expect(mockSetActiveTab).toHaveBeenCalledWith(AppModeEnum.WORKFLOW)
})
it('should render new app card for editors', () => {
render(<List />)
expect(screen.getByTestId('new-app-card')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,294 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
// Mock react-i18next - return key as per testing skills
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock next/navigation
const mockReplace = jest.fn()
jest.mock('next/navigation', () => ({
useRouter: () => ({
replace: mockReplace,
}),
useSearchParams: () => new URLSearchParams(),
}))
// Mock provider context
const mockOnPlanInfoChanged = jest.fn()
jest.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
onPlanInfoChanged: mockOnPlanInfoChanged,
}),
}))
// Mock next/dynamic to immediately resolve components
jest.mock('next/dynamic', () => {
const React = require('react')
return (importFn: () => Promise<any>) => {
const fnString = importFn.toString()
if (fnString.includes('create-app-modal') && !fnString.includes('create-from-dsl-modal')) {
return function MockCreateAppModal({ show, onClose, onSuccess, onCreateFromTemplate }: any) {
if (!show) return null
return React.createElement('div', { 'data-testid': 'create-app-modal' },
React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-create-modal' }, 'Close'),
React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-create-modal' }, 'Success'),
React.createElement('button', { 'onClick': onCreateFromTemplate, 'data-testid': 'to-template-modal' }, 'To Template'),
)
}
}
if (fnString.includes('create-app-dialog')) {
return function MockCreateAppTemplateDialog({ show, onClose, onSuccess, onCreateFromBlank }: any) {
if (!show) return null
return React.createElement('div', { 'data-testid': 'create-template-dialog' },
React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-template-dialog' }, 'Close'),
React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-template-dialog' }, 'Success'),
React.createElement('button', { 'onClick': onCreateFromBlank, 'data-testid': 'to-blank-modal' }, 'To Blank'),
)
}
}
if (fnString.includes('create-from-dsl-modal')) {
return function MockCreateFromDSLModal({ show, onClose, onSuccess }: any) {
if (!show) return null
return React.createElement('div', { 'data-testid': 'create-dsl-modal' },
React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-dsl-modal' }, 'Close'),
React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-dsl-modal' }, 'Success'),
)
}
}
return () => null
}
})
// Mock CreateFromDSLModalTab enum
jest.mock('@/app/components/app/create-from-dsl-modal', () => ({
CreateFromDSLModalTab: {
FROM_URL: 'from-url',
},
}))
// Import after mocks
import CreateAppCard from './new-app-card'
describe('CreateAppCard', () => {
const defaultRef = { current: null } as React.RefObject<HTMLDivElement | null>
beforeEach(() => {
jest.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<CreateAppCard ref={defaultRef} />)
// Use pattern matching for resilient text assertions
expect(screen.getByText('app.createApp')).toBeInTheDocument()
})
it('should render three create buttons', () => {
render(<CreateAppCard ref={defaultRef} />)
expect(screen.getByText('app.newApp.startFromBlank')).toBeInTheDocument()
expect(screen.getByText('app.newApp.startFromTemplate')).toBeInTheDocument()
expect(screen.getByText('app.importDSL')).toBeInTheDocument()
})
it('should render all buttons as clickable', () => {
render(<CreateAppCard ref={defaultRef} />)
const buttons = screen.getAllByRole('button')
expect(buttons).toHaveLength(3)
buttons.forEach((button) => {
expect(button).not.toBeDisabled()
})
})
})
describe('Props', () => {
it('should apply custom className', () => {
const { container } = render(
<CreateAppCard ref={defaultRef} className="custom-class" />,
)
const card = container.firstChild as HTMLElement
expect(card).toHaveClass('custom-class')
})
it('should render with selectedAppType prop', () => {
render(<CreateAppCard ref={defaultRef} selectedAppType="chat" />)
expect(screen.getByText('app.createApp')).toBeInTheDocument()
})
})
describe('User Interactions - Create App Modal', () => {
it('should open create app modal when clicking Start from Blank', () => {
render(<CreateAppCard ref={defaultRef} />)
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
})
it('should close create app modal when clicking close button', () => {
render(<CreateAppCard ref={defaultRef} />)
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('close-create-modal'))
expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument()
})
it('should call onSuccess and onPlanInfoChanged on create app success', () => {
const mockOnSuccess = jest.fn()
render(<CreateAppCard ref={defaultRef} onSuccess={mockOnSuccess} />)
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
fireEvent.click(screen.getByTestId('success-create-modal'))
expect(mockOnPlanInfoChanged).toHaveBeenCalled()
expect(mockOnSuccess).toHaveBeenCalled()
})
it('should switch from create modal to template dialog', () => {
render(<CreateAppCard ref={defaultRef} />)
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('to-template-modal'))
expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument()
expect(screen.getByTestId('create-template-dialog')).toBeInTheDocument()
})
})
describe('User Interactions - Template Dialog', () => {
it('should open template dialog when clicking Start from Template', () => {
render(<CreateAppCard ref={defaultRef} />)
fireEvent.click(screen.getByText('app.newApp.startFromTemplate'))
expect(screen.getByTestId('create-template-dialog')).toBeInTheDocument()
})
it('should close template dialog when clicking close button', () => {
render(<CreateAppCard ref={defaultRef} />)
fireEvent.click(screen.getByText('app.newApp.startFromTemplate'))
expect(screen.getByTestId('create-template-dialog')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('close-template-dialog'))
expect(screen.queryByTestId('create-template-dialog')).not.toBeInTheDocument()
})
it('should call onSuccess and onPlanInfoChanged on template success', () => {
const mockOnSuccess = jest.fn()
render(<CreateAppCard ref={defaultRef} onSuccess={mockOnSuccess} />)
fireEvent.click(screen.getByText('app.newApp.startFromTemplate'))
fireEvent.click(screen.getByTestId('success-template-dialog'))
expect(mockOnPlanInfoChanged).toHaveBeenCalled()
expect(mockOnSuccess).toHaveBeenCalled()
})
it('should switch from template dialog to create modal', () => {
render(<CreateAppCard ref={defaultRef} />)
fireEvent.click(screen.getByText('app.newApp.startFromTemplate'))
expect(screen.getByTestId('create-template-dialog')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('to-blank-modal'))
expect(screen.queryByTestId('create-template-dialog')).not.toBeInTheDocument()
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
})
})
describe('User Interactions - DSL Import Modal', () => {
it('should open DSL modal when clicking Import DSL', () => {
render(<CreateAppCard ref={defaultRef} />)
fireEvent.click(screen.getByText('app.importDSL'))
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
})
it('should close DSL modal when clicking close button', () => {
render(<CreateAppCard ref={defaultRef} />)
fireEvent.click(screen.getByText('app.importDSL'))
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('close-dsl-modal'))
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
})
it('should call onSuccess and onPlanInfoChanged on DSL import success', () => {
const mockOnSuccess = jest.fn()
render(<CreateAppCard ref={defaultRef} onSuccess={mockOnSuccess} />)
fireEvent.click(screen.getByText('app.importDSL'))
fireEvent.click(screen.getByTestId('success-dsl-modal'))
expect(mockOnPlanInfoChanged).toHaveBeenCalled()
expect(mockOnSuccess).toHaveBeenCalled()
})
})
describe('Styling', () => {
it('should have correct card container styling', () => {
const { container } = render(<CreateAppCard ref={defaultRef} />)
const card = container.firstChild as HTMLElement
expect(card).toHaveClass('h-[160px]', 'rounded-xl')
})
it('should have proper button styling', () => {
render(<CreateAppCard ref={defaultRef} />)
const buttons = screen.getAllByRole('button')
buttons.forEach((button) => {
expect(button).toHaveClass('cursor-pointer')
})
})
})
describe('Edge Cases', () => {
it('should handle multiple modal opens/closes', () => {
render(<CreateAppCard ref={defaultRef} />)
// Open and close create modal
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
fireEvent.click(screen.getByTestId('close-create-modal'))
// Open and close template dialog
fireEvent.click(screen.getByText('app.newApp.startFromTemplate'))
fireEvent.click(screen.getByTestId('close-template-dialog'))
// Open and close DSL modal
fireEvent.click(screen.getByText('app.importDSL'))
fireEvent.click(screen.getByTestId('close-dsl-modal'))
// No modals should be visible
expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument()
expect(screen.queryByTestId('create-template-dialog')).not.toBeInTheDocument()
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
})
it('should handle onSuccess not being provided', () => {
render(<CreateAppCard ref={defaultRef} />)
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
// This should not throw an error
expect(() => {
fireEvent.click(screen.getByTestId('success-create-modal'))
}).not.toThrow()
expect(mockOnPlanInfoChanged).toHaveBeenCalled()
})
})
})