mirror of
https://github.com/langgenius/dify.git
synced 2026-03-13 04:42:20 +08:00
test: add tests for base > date-time-picker (#32396)
This commit is contained in:
parent
3c4f5b45c4
commit
4d36a0707a
@ -0,0 +1,24 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { DaysOfWeek } from './days-of-week'
|
||||
|
||||
describe('DaysOfWeek', () => {
|
||||
// Rendering test
|
||||
describe('Rendering', () => {
|
||||
it('should render 7 day labels', () => {
|
||||
render(<DaysOfWeek />)
|
||||
|
||||
// The global i18n mock returns keys like "time.daysInWeek.Sun"
|
||||
const dayElements = screen.getAllByText(/daysInWeek/)
|
||||
expect(dayElements).toHaveLength(7)
|
||||
})
|
||||
|
||||
it('should render each day of the week', () => {
|
||||
render(<DaysOfWeek />)
|
||||
|
||||
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||
days.forEach((day) => {
|
||||
expect(screen.getByText(new RegExp(day))).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,114 @@
|
||||
import type { CalendarProps, Day } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import dayjs from '../utils/dayjs'
|
||||
import Calendar from './index'
|
||||
|
||||
// Mock scrollIntoView since jsdom doesn't implement it
|
||||
beforeAll(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
})
|
||||
|
||||
// Factory for creating mock days
|
||||
const createMockDays = (count: number = 7): Day[] => {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
date: dayjs('2024-06-01').add(i, 'day'),
|
||||
isCurrentMonth: true,
|
||||
}))
|
||||
}
|
||||
|
||||
// Factory for Calendar props
|
||||
const createCalendarProps = (overrides: Partial<CalendarProps> = {}): CalendarProps => ({
|
||||
days: createMockDays(),
|
||||
selectedDate: undefined,
|
||||
onDateClick: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('Calendar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should render days of week header', () => {
|
||||
const props = createCalendarProps()
|
||||
render(<Calendar {...props} />)
|
||||
|
||||
// DaysOfWeek component renders day labels
|
||||
const dayLabels = screen.getAllByText(/daysInWeek/)
|
||||
expect(dayLabels).toHaveLength(7)
|
||||
})
|
||||
|
||||
it('should render all calendar day items', () => {
|
||||
const days = createMockDays(7)
|
||||
const props = createCalendarProps({ days })
|
||||
render(<Calendar {...props} />)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons).toHaveLength(7)
|
||||
})
|
||||
|
||||
it('should accept wrapperClassName prop without errors', () => {
|
||||
const props = createCalendarProps({ wrapperClassName: 'custom-class' })
|
||||
const { container } = render(<Calendar {...props} />)
|
||||
|
||||
// Verify the component renders successfully with wrapperClassName
|
||||
const dayLabels = screen.getAllByText(/daysInWeek/)
|
||||
expect(dayLabels).toHaveLength(7)
|
||||
expect(container.firstChild).toHaveClass('custom-class')
|
||||
})
|
||||
})
|
||||
|
||||
// Interaction tests
|
||||
describe('Interactions', () => {
|
||||
it('should call onDateClick when a day is clicked', () => {
|
||||
const onDateClick = vi.fn()
|
||||
const days = createMockDays(3)
|
||||
const props = createCalendarProps({ days, onDateClick })
|
||||
render(<Calendar {...props} />)
|
||||
|
||||
const dayButtons = screen.getAllByRole('button')
|
||||
fireEvent.click(dayButtons[1])
|
||||
|
||||
expect(onDateClick).toHaveBeenCalledTimes(1)
|
||||
expect(onDateClick).toHaveBeenCalledWith(days[1].date)
|
||||
})
|
||||
})
|
||||
|
||||
// Disabled dates tests
|
||||
describe('Disabled Dates', () => {
|
||||
it('should not call onDateClick for disabled dates', () => {
|
||||
const onDateClick = vi.fn()
|
||||
const days = createMockDays(3)
|
||||
// Disable all dates
|
||||
const getIsDateDisabled = vi.fn().mockReturnValue(true)
|
||||
const props = createCalendarProps({ days, onDateClick, getIsDateDisabled })
|
||||
render(<Calendar {...props} />)
|
||||
|
||||
const dayButtons = screen.getAllByRole('button')
|
||||
fireEvent.click(dayButtons[0])
|
||||
|
||||
expect(onDateClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should pass getIsDateDisabled to CalendarItem', () => {
|
||||
const getIsDateDisabled = vi.fn().mockReturnValue(false)
|
||||
const days = createMockDays(2)
|
||||
const props = createCalendarProps({ days, getIsDateDisabled })
|
||||
render(<Calendar {...props} />)
|
||||
|
||||
expect(getIsDateDisabled).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases
|
||||
describe('Edge Cases', () => {
|
||||
it('should render empty calendar when days array is empty', () => {
|
||||
const props = createCalendarProps({ days: [] })
|
||||
render(<Calendar {...props} />)
|
||||
|
||||
expect(screen.queryAllByRole('button')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,137 @@
|
||||
import type { CalendarItemProps, Day } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import dayjs from '../utils/dayjs'
|
||||
import Item from './item'
|
||||
|
||||
const createMockDay = (overrides: Partial<Day> = {}): Day => ({
|
||||
date: dayjs('2024-06-15'),
|
||||
isCurrentMonth: true,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createItemProps = (overrides: Partial<CalendarItemProps> = {}): CalendarItemProps => ({
|
||||
day: createMockDay(),
|
||||
selectedDate: undefined,
|
||||
onClick: vi.fn(),
|
||||
isDisabled: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('CalendarItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render the day number', () => {
|
||||
const props = createItemProps()
|
||||
|
||||
render(<Item {...props} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: '15' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Visual States', () => {
|
||||
it('should have selected styles when date matches selectedDate', () => {
|
||||
const selectedDate = dayjs('2024-06-15')
|
||||
const props = createItemProps({ selectedDate })
|
||||
|
||||
render(<Item {...props} />)
|
||||
const button = screen.getByRole('button', { name: '15' })
|
||||
expect(button).toHaveClass('bg-components-button-primary-bg', 'text-components-button-primary-text')
|
||||
})
|
||||
|
||||
it('should not have selected styles when date does not match selectedDate', () => {
|
||||
const selectedDate = dayjs('2024-06-16')
|
||||
const props = createItemProps({ selectedDate })
|
||||
|
||||
render(<Item {...props} />)
|
||||
const button = screen.getByRole('button', { name: '15' })
|
||||
expect(button).not.toHaveClass('bg-components-button-primary-bg', 'text-components-button-primary-text')
|
||||
})
|
||||
|
||||
it('should have different styles when day is not in current month', () => {
|
||||
const props = createItemProps({
|
||||
day: createMockDay({ isCurrentMonth: false }),
|
||||
})
|
||||
|
||||
render(<Item {...props} />)
|
||||
const button = screen.getByRole('button', { name: '15' })
|
||||
expect(button).toHaveClass('text-text-quaternary')
|
||||
})
|
||||
|
||||
it('should have different styles when day is in current month', () => {
|
||||
const props = createItemProps({
|
||||
day: createMockDay({ isCurrentMonth: true }),
|
||||
})
|
||||
|
||||
render(<Item {...props} />)
|
||||
const button = screen.getByRole('button', { name: '15' })
|
||||
expect(button).toHaveClass('text-text-secondary')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Click Behavior', () => {
|
||||
it('should call onClick with the date when clicked', () => {
|
||||
const onClick = vi.fn()
|
||||
const day = createMockDay()
|
||||
const props = createItemProps({ day, onClick })
|
||||
|
||||
render(<Item {...props} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
expect(onClick).toHaveBeenCalledWith(day.date)
|
||||
})
|
||||
|
||||
it('should not call onClick when isDisabled is true', () => {
|
||||
const onClick = vi.fn()
|
||||
const props = createItemProps({ onClick, isDisabled: true })
|
||||
|
||||
render(<Item {...props} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(onClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Today Indicator', () => {
|
||||
it('should render today indicator when date is today', () => {
|
||||
const today = dayjs()
|
||||
const props = createItemProps({
|
||||
day: createMockDay({ date: today }),
|
||||
})
|
||||
|
||||
render(<Item {...props} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toBeInTheDocument()
|
||||
// Today's button should contain a child indicator element
|
||||
expect(button.children.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should not render today indicator when date is not today', () => {
|
||||
const notToday = dayjs('2020-01-01')
|
||||
const props = createItemProps({
|
||||
day: createMockDay({ date: notToday }),
|
||||
})
|
||||
|
||||
render(<Item {...props} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
// Non-today button should only contain the day number text, no extra children
|
||||
expect(button.children.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined selectedDate', () => {
|
||||
const props = createItemProps({ selectedDate: undefined })
|
||||
|
||||
render(<Item {...props} />)
|
||||
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,137 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import OptionListItem from './option-list-item'
|
||||
|
||||
describe('OptionListItem', () => {
|
||||
let originalScrollIntoView: Element['scrollIntoView']
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
originalScrollIntoView = Element.prototype.scrollIntoView
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Element.prototype.scrollIntoView = originalScrollIntoView
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render children content', () => {
|
||||
render(
|
||||
<OptionListItem isSelected={false} onClick={vi.fn()}>
|
||||
Test Item
|
||||
</OptionListItem>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Test Item')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render as a list item element', () => {
|
||||
render(
|
||||
<OptionListItem isSelected={false} onClick={vi.fn()}>
|
||||
Item
|
||||
</OptionListItem>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('listitem')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Selection State', () => {
|
||||
it('should have selected styles when isSelected is true', () => {
|
||||
render(
|
||||
<OptionListItem isSelected={true} onClick={vi.fn()}>
|
||||
Selected
|
||||
</OptionListItem>,
|
||||
)
|
||||
|
||||
const item = screen.getByRole('listitem')
|
||||
expect(item).toHaveClass('bg-components-button-ghost-bg-hover')
|
||||
})
|
||||
|
||||
it('should not have selected styles when isSelected is false', () => {
|
||||
render(
|
||||
<OptionListItem isSelected={false} onClick={vi.fn()}>
|
||||
Not Selected
|
||||
</OptionListItem>,
|
||||
)
|
||||
|
||||
const item = screen.getByRole('listitem')
|
||||
expect(item).not.toHaveClass('bg-components-button-ghost-bg-hover')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Auto-Scroll', () => {
|
||||
it('should scroll into view on mount when isSelected is true', () => {
|
||||
render(
|
||||
<OptionListItem isSelected={true} onClick={vi.fn()}>
|
||||
Selected
|
||||
</OptionListItem>,
|
||||
)
|
||||
|
||||
expect(Element.prototype.scrollIntoView).toHaveBeenCalledWith({ behavior: 'instant' })
|
||||
})
|
||||
|
||||
it('should not scroll into view on mount when isSelected is false', () => {
|
||||
render(
|
||||
<OptionListItem isSelected={false} onClick={vi.fn()}>
|
||||
Not Selected
|
||||
</OptionListItem>,
|
||||
)
|
||||
|
||||
expect(Element.prototype.scrollIntoView).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not scroll into view on mount when noAutoScroll is true', () => {
|
||||
render(
|
||||
<OptionListItem isSelected={true} noAutoScroll onClick={vi.fn()}>
|
||||
No Scroll
|
||||
</OptionListItem>,
|
||||
)
|
||||
|
||||
expect(Element.prototype.scrollIntoView).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Click Behavior', () => {
|
||||
it('should call onClick when clicked', () => {
|
||||
const handleClick = vi.fn()
|
||||
|
||||
render(
|
||||
<OptionListItem isSelected={false} onClick={handleClick}>
|
||||
Clickable
|
||||
</OptionListItem>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('listitem'))
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should scroll into view with smooth behavior on click', () => {
|
||||
render(
|
||||
<OptionListItem isSelected={false} onClick={vi.fn()}>
|
||||
Item
|
||||
</OptionListItem>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('listitem'))
|
||||
|
||||
expect(Element.prototype.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle rapid clicks without errors', () => {
|
||||
const handleClick = vi.fn()
|
||||
render(
|
||||
<OptionListItem isSelected={false} onClick={handleClick}>
|
||||
Rapid Click
|
||||
</OptionListItem>,
|
||||
)
|
||||
|
||||
const item = screen.getByRole('listitem')
|
||||
fireEvent.click(item)
|
||||
fireEvent.click(item)
|
||||
fireEvent.click(item)
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,97 @@
|
||||
import type { DatePickerFooterProps } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { ViewType } from '../types'
|
||||
import Footer from './footer'
|
||||
|
||||
// Factory for Footer props
|
||||
const createFooterProps = (overrides: Partial<DatePickerFooterProps> = {}): DatePickerFooterProps => ({
|
||||
needTimePicker: true,
|
||||
displayTime: '02:30 PM',
|
||||
view: ViewType.date,
|
||||
handleClickTimePicker: vi.fn(),
|
||||
handleSelectCurrentDate: vi.fn(),
|
||||
handleConfirmDate: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('DatePicker Footer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should render Now button and confirm button', () => {
|
||||
const props = createFooterProps()
|
||||
render(<Footer {...props} />)
|
||||
|
||||
expect(screen.getByText(/operation\.now/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/operation\.ok/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show time picker button when needTimePicker is true', () => {
|
||||
const props = createFooterProps({ needTimePicker: true, displayTime: '02:30 PM' })
|
||||
render(<Footer {...props} />)
|
||||
|
||||
expect(screen.getByText('02:30 PM')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show time picker button when needTimePicker is false', () => {
|
||||
const props = createFooterProps({ needTimePicker: false })
|
||||
render(<Footer {...props} />)
|
||||
|
||||
expect(screen.queryByText('02:30 PM')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// View-dependent rendering tests
|
||||
describe('View States', () => {
|
||||
it('should show display time when view is date', () => {
|
||||
const props = createFooterProps({ view: ViewType.date, displayTime: '10:00 AM' })
|
||||
render(<Footer {...props} />)
|
||||
|
||||
expect(screen.getByText('10:00 AM')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show pickDate text when view is time', () => {
|
||||
const props = createFooterProps({ view: ViewType.time })
|
||||
render(<Footer {...props} />)
|
||||
|
||||
expect(screen.getByText(/operation\.pickDate/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Interaction tests
|
||||
describe('Interactions', () => {
|
||||
it('should call handleClickTimePicker when time picker button is clicked', () => {
|
||||
const handleClickTimePicker = vi.fn()
|
||||
const props = createFooterProps({ handleClickTimePicker })
|
||||
render(<Footer {...props} />)
|
||||
|
||||
// Click the time picker toggle button (has the time display)
|
||||
fireEvent.click(screen.getByText('02:30 PM'))
|
||||
|
||||
expect(handleClickTimePicker).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call handleSelectCurrentDate when Now button is clicked', () => {
|
||||
const handleSelectCurrentDate = vi.fn()
|
||||
const props = createFooterProps({ handleSelectCurrentDate })
|
||||
render(<Footer {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.now/))
|
||||
|
||||
expect(handleSelectCurrentDate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call handleConfirmDate when OK button is clicked', () => {
|
||||
const handleConfirmDate = vi.fn()
|
||||
const props = createFooterProps({ handleConfirmDate })
|
||||
render(<Footer {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.ok/))
|
||||
|
||||
expect(handleConfirmDate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,78 @@
|
||||
import type { DatePickerHeaderProps } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import dayjs from '../utils/dayjs'
|
||||
import Header from './header'
|
||||
|
||||
// Factory for Header props
|
||||
const createHeaderProps = (overrides: Partial<DatePickerHeaderProps> = {}): DatePickerHeaderProps => ({
|
||||
handleOpenYearMonthPicker: vi.fn(),
|
||||
currentDate: dayjs('2024-06-15'),
|
||||
onClickNextMonth: vi.fn(),
|
||||
onClickPrevMonth: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('DatePicker Header', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should render month and year display', () => {
|
||||
const props = createHeaderProps({ currentDate: dayjs('2024-06-15') })
|
||||
render(<Header {...props} />)
|
||||
|
||||
// The useMonths hook returns translated keys; check for year
|
||||
expect(screen.getByText(/2024/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render navigation buttons', () => {
|
||||
const props = createHeaderProps()
|
||||
render(<Header {...props} />)
|
||||
|
||||
// There are 3 buttons: month/year display, prev, next
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
// Interaction tests
|
||||
describe('Interactions', () => {
|
||||
it('should call handleOpenYearMonthPicker when month/year button is clicked', () => {
|
||||
const handleOpenYearMonthPicker = vi.fn()
|
||||
const props = createHeaderProps({ handleOpenYearMonthPicker })
|
||||
render(<Header {...props} />)
|
||||
|
||||
// First button is the month/year display
|
||||
const buttons = screen.getAllByRole('button')
|
||||
fireEvent.click(buttons[0])
|
||||
|
||||
expect(handleOpenYearMonthPicker).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClickPrevMonth when previous button is clicked', () => {
|
||||
const onClickPrevMonth = vi.fn()
|
||||
const props = createHeaderProps({ onClickPrevMonth })
|
||||
render(<Header {...props} />)
|
||||
|
||||
// Second button is prev month
|
||||
const buttons = screen.getAllByRole('button')
|
||||
fireEvent.click(buttons[1])
|
||||
|
||||
expect(onClickPrevMonth).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClickNextMonth when next button is clicked', () => {
|
||||
const onClickNextMonth = vi.fn()
|
||||
const props = createHeaderProps({ onClickNextMonth })
|
||||
render(<Header {...props} />)
|
||||
|
||||
// Third button is next month
|
||||
const buttons = screen.getAllByRole('button')
|
||||
fireEvent.click(buttons[2])
|
||||
|
||||
expect(onClickNextMonth).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,616 @@
|
||||
import type { DatePickerProps } from '../types'
|
||||
import { act, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import dayjs from '../utils/dayjs'
|
||||
import DatePicker from './index'
|
||||
|
||||
// Mock scrollIntoView
|
||||
beforeAll(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
})
|
||||
|
||||
// Factory for DatePicker props
|
||||
const createDatePickerProps = (overrides: Partial<DatePickerProps> = {}): DatePickerProps => ({
|
||||
value: undefined,
|
||||
onChange: vi.fn(),
|
||||
onClear: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// Helper to open the picker
|
||||
const openPicker = () => {
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.click(input)
|
||||
}
|
||||
|
||||
describe('DatePicker', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should render with default placeholder', () => {
|
||||
const props = createDatePickerProps()
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with custom placeholder', () => {
|
||||
const props = createDatePickerProps({ placeholder: 'Select date' })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveAttribute('placeholder', 'Select date')
|
||||
})
|
||||
|
||||
it('should display formatted date value when value is provided', () => {
|
||||
const value = dayjs('2024-06-15T14:30:00')
|
||||
const props = createDatePickerProps({ value })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
expect(screen.getByRole('textbox').getAttribute('value')).not.toBe('')
|
||||
})
|
||||
|
||||
it('should render with empty value when no value is provided', () => {
|
||||
const props = createDatePickerProps()
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should normalize value with timezone applied', () => {
|
||||
const value = dayjs('2024-06-15T14:30:00')
|
||||
const props = createDatePickerProps({ value, timezone: 'America/New_York' })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
expect(screen.getByRole('textbox').getAttribute('value')).not.toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
// Open/close behavior
|
||||
describe('Open/Close Behavior', () => {
|
||||
it('should open the picker when trigger is clicked', () => {
|
||||
const props = createDatePickerProps()
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
expect(screen.getAllByText(/daysInWeek/).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should close when trigger is clicked while open', () => {
|
||||
const props = createDatePickerProps()
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
openPicker() // second click closes
|
||||
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should restore selected date from value when reopening', () => {
|
||||
const value = dayjs('2024-06-15')
|
||||
const props = createDatePickerProps({ value })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
// Calendar should be showing June 2024
|
||||
expect(screen.getByText(/2024/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close when clicking outside the container', () => {
|
||||
const props = createDatePickerProps()
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
// Simulate a mousedown event outside the container
|
||||
act(() => {
|
||||
document.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
||||
})
|
||||
|
||||
// The picker should now be closed - input shows its value
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Time Picker Integration
|
||||
describe('Time Picker Integration', () => {
|
||||
it('should show time display in footer when needTimePicker is true', () => {
|
||||
const props = createDatePickerProps({ needTimePicker: true })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
expect(screen.getByText('--:-- --')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show time toggle when needTimePicker is false', () => {
|
||||
const props = createDatePickerProps({ needTimePicker: false })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
expect(screen.queryByText('--:-- --')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should switch to time view when time picker button is clicked', () => {
|
||||
const props = createDatePickerProps({ needTimePicker: true })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
// Click the time display button to switch to time view
|
||||
fireEvent.click(screen.getByText('--:-- --'))
|
||||
|
||||
// In time view, the "pickDate" text should appear instead of the time
|
||||
expect(screen.getByText(/operation\.pickDate/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should switch back to date view when pickDate is clicked in time view', () => {
|
||||
const props = createDatePickerProps({ needTimePicker: true })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
// Switch to time view
|
||||
fireEvent.click(screen.getByText('--:-- --'))
|
||||
// Switch back to date view
|
||||
fireEvent.click(screen.getByText(/operation\.pickDate/))
|
||||
|
||||
// Days of week should be visible again
|
||||
expect(screen.getAllByText(/daysInWeek/).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should render time picker options in time view', () => {
|
||||
const props = createDatePickerProps({ needTimePicker: true, value: dayjs('2024-06-15T14:30:00') })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
// Switch to time view
|
||||
fireEvent.click(screen.getByText(/\d{2}:\d{2}\s(AM|PM)/))
|
||||
|
||||
// Should show AM/PM options (TimePickerOptions renders these)
|
||||
expect(screen.getByText('AM')).toBeInTheDocument()
|
||||
expect(screen.getByText('PM')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update selected time when hour is selected in time view', () => {
|
||||
const props = createDatePickerProps({ needTimePicker: true, value: dayjs('2024-06-15T14:30:00') })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
// Switch to time view
|
||||
fireEvent.click(screen.getByText(/\d{2}:\d{2}\s(AM|PM)/))
|
||||
|
||||
// Click hour "05" from the time options
|
||||
const allLists = screen.getAllByRole('list')
|
||||
const hourItems = within(allLists[0]).getAllByRole('listitem')
|
||||
fireEvent.click(hourItems[4])
|
||||
|
||||
// The picker should still be in time view
|
||||
expect(screen.getByText(/operation\.pickDate/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update selected time when minute is selected in time view', () => {
|
||||
const props = createDatePickerProps({ needTimePicker: true, value: dayjs('2024-06-15T14:30:00') })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
// Switch to time view
|
||||
fireEvent.click(screen.getByText(/\d{2}:\d{2}\s(AM|PM)/))
|
||||
|
||||
// Click minute "45" from the time options
|
||||
const allLists = screen.getAllByRole('list')
|
||||
const minuteItems = within(allLists[1]).getAllByRole('listitem')
|
||||
fireEvent.click(minuteItems[45])
|
||||
|
||||
expect(screen.getByText(/operation\.pickDate/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update selected time when period is changed in time view', () => {
|
||||
const props = createDatePickerProps({ needTimePicker: true, value: dayjs('2024-06-15T14:30:00') })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
// Switch to time view
|
||||
fireEvent.click(screen.getByText(/\d{2}:\d{2}\s(AM|PM)/))
|
||||
|
||||
// Click AM to switch period
|
||||
fireEvent.click(screen.getByText('AM'))
|
||||
|
||||
expect(screen.getByText(/operation\.pickDate/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update time when no selectedDate exists and hour is selected', () => {
|
||||
const props = createDatePickerProps({ needTimePicker: true })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
// Switch to time view (click on the "--:-- --" text)
|
||||
fireEvent.click(screen.getByText('--:-- --'))
|
||||
|
||||
// Click hour "03" from the time options
|
||||
const allLists = screen.getAllByRole('list')
|
||||
const hourItems = within(allLists[0]).getAllByRole('listitem')
|
||||
fireEvent.click(hourItems[2])
|
||||
|
||||
expect(screen.getByText(/operation\.pickDate/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Date selection
|
||||
describe('Date Selection', () => {
|
||||
it('should call onChange when Now button is clicked', () => {
|
||||
const onChange = vi.fn()
|
||||
const props = createDatePickerProps({ onChange })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
fireEvent.click(screen.getByText(/operation\.now/))
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onChange when OK button is clicked with a value', () => {
|
||||
const onChange = vi.fn()
|
||||
const props = createDatePickerProps({ onChange, value: dayjs('2024-06-15') })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
fireEvent.click(screen.getByText(/operation\.ok/))
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should select a calendar day when clicked', () => {
|
||||
const onChange = vi.fn()
|
||||
const props = createDatePickerProps({ onChange, value: dayjs('2024-06-15') })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
// Click on a day in the calendar - day "20"
|
||||
const dayButton = screen.getByRole('button', { name: '20' })
|
||||
fireEvent.click(dayButton)
|
||||
|
||||
// The date should now appear in the header/display
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should immediately confirm when noConfirm is true and a date is clicked', () => {
|
||||
const onChange = vi.fn()
|
||||
const props = createDatePickerProps({ onChange, noConfirm: true, value: dayjs('2024-06-15') })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
// Click on a day
|
||||
const dayButton = screen.getByRole('button', { name: '20' })
|
||||
fireEvent.click(dayButton)
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onChange with undefined when OK is clicked without a selected date', () => {
|
||||
const onChange = vi.fn()
|
||||
const props = createDatePickerProps({ onChange })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
// Clear selected date then confirm
|
||||
fireEvent.click(screen.getByText(/operation\.ok/))
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// Clear behavior
|
||||
describe('Clear Behavior', () => {
|
||||
it('should call onClear when clear is clicked while picker is closed', () => {
|
||||
const onClear = vi.fn()
|
||||
const renderTrigger = vi.fn(({ handleClear }) => (
|
||||
<button data-testid="clear-trigger" onClick={handleClear}>
|
||||
Clear
|
||||
</button>
|
||||
))
|
||||
const props = createDatePickerProps({
|
||||
value: dayjs('2024-06-15'),
|
||||
onClear,
|
||||
renderTrigger,
|
||||
})
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('clear-trigger'))
|
||||
|
||||
expect(onClear).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should clear selected date without calling onClear when picker is open', () => {
|
||||
const onClear = vi.fn()
|
||||
const onChange = vi.fn()
|
||||
const renderTrigger = vi.fn(({ handleClickTrigger, handleClear }) => (
|
||||
<div>
|
||||
<button data-testid="open-trigger" onClick={handleClickTrigger}>
|
||||
Open
|
||||
</button>
|
||||
<button data-testid="clear-trigger" onClick={handleClear}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
const props = createDatePickerProps({
|
||||
value: dayjs('2024-06-15'),
|
||||
onClear,
|
||||
onChange,
|
||||
renderTrigger,
|
||||
})
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('open-trigger'))
|
||||
fireEvent.click(screen.getByTestId('clear-trigger'))
|
||||
fireEvent.click(screen.getByText(/operation\.ok/))
|
||||
|
||||
expect(onClear).not.toHaveBeenCalled()
|
||||
expect(onChange).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
// Month navigation
|
||||
describe('Month Navigation', () => {
|
||||
it('should navigate to next month when next arrow is clicked', () => {
|
||||
const props = createDatePickerProps({ value: dayjs('2024-06-15') })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
// Find navigation buttons in the header
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
// The header has: month/year button, prev button, next button
|
||||
// Then calendar days are also buttons. We need the 3rd button (next month).
|
||||
// Header buttons come first in DOM order.
|
||||
fireEvent.click(allButtons[2]) // next month button
|
||||
|
||||
expect(screen.getAllByRole('button').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should navigate to previous month when prev arrow is clicked', () => {
|
||||
const props = createDatePickerProps({ value: dayjs('2024-06-15') })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
fireEvent.click(allButtons[1]) // prev month button
|
||||
|
||||
expect(screen.getAllByRole('button').length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// Year/Month picker
|
||||
describe('Year/Month Picker', () => {
|
||||
it('should open year/month picker when month/year header is clicked', () => {
|
||||
const props = createDatePickerProps({ value: dayjs('2024-06-15') })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
const headerButton = screen.getByText(/2024/)
|
||||
fireEvent.click(headerButton)
|
||||
|
||||
// Cancel button visible in year/month picker footer
|
||||
expect(screen.getByText(/operation\.cancel/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close year/month picker when cancel is clicked', () => {
|
||||
const props = createDatePickerProps({ value: dayjs('2024-06-15') })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
fireEvent.click(screen.getByText(/2024/))
|
||||
|
||||
// Cancel
|
||||
fireEvent.click(screen.getByText(/operation\.cancel/))
|
||||
|
||||
// Should be back to date view with days of week
|
||||
expect(screen.getAllByText(/daysInWeek/).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should confirm year/month selection when OK is clicked', () => {
|
||||
const props = createDatePickerProps({ value: dayjs('2024-06-15') })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
fireEvent.click(screen.getByText(/2024/))
|
||||
|
||||
// Select a different year
|
||||
fireEvent.click(screen.getByText('2023'))
|
||||
|
||||
// Confirm - click the last OK button (year/month footer)
|
||||
const okButtons = screen.getAllByText(/operation\.ok/)
|
||||
fireEvent.click(okButtons[okButtons.length - 1])
|
||||
|
||||
// Should return to date view
|
||||
expect(screen.getAllByText(/daysInWeek/).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should close year/month picker by clicking header button', () => {
|
||||
const props = createDatePickerProps({ value: dayjs('2024-06-15') })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
// Open year/month picker
|
||||
fireEvent.click(screen.getByText(/2024/))
|
||||
|
||||
// The header in year/month view shows selected month/year with an up arrow
|
||||
// Clicking it closes the year/month picker
|
||||
const headerButtons = screen.getAllByRole('button')
|
||||
fireEvent.click(headerButtons[0]) // First button in year/month view is the header
|
||||
|
||||
// Should return to date view
|
||||
expect(screen.getAllByText(/daysInWeek/).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should update month selection in year/month picker', () => {
|
||||
const props = createDatePickerProps({ value: dayjs('2024-06-15') })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
fireEvent.click(screen.getByText(/2024/))
|
||||
|
||||
// Select a different month using RTL queries
|
||||
const allLists = screen.getAllByRole('list')
|
||||
const monthItems = within(allLists[0]).getAllByRole('listitem')
|
||||
fireEvent.click(monthItems[0])
|
||||
|
||||
// Confirm the selection - click the last OK button (year/month footer)
|
||||
const okButtons = screen.getAllByText(/operation\.ok/)
|
||||
fireEvent.click(okButtons[okButtons.length - 1])
|
||||
|
||||
// Should return to date view
|
||||
expect(screen.getAllByText(/daysInWeek/).length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// noConfirm mode
|
||||
describe('noConfirm Mode', () => {
|
||||
it('should not show footer when noConfirm is true', () => {
|
||||
const props = createDatePickerProps({ noConfirm: true })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
expect(screen.queryByText(/operation\.ok/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Custom trigger
|
||||
describe('Custom Trigger', () => {
|
||||
it('should use renderTrigger when provided', () => {
|
||||
const renderTrigger = vi.fn(({ handleClickTrigger }) => (
|
||||
<button data-testid="custom-trigger" onClick={handleClickTrigger}>
|
||||
Custom
|
||||
</button>
|
||||
))
|
||||
|
||||
const props = createDatePickerProps({ renderTrigger })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
expect(screen.getByTestId('custom-trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open picker when custom trigger is clicked', () => {
|
||||
const renderTrigger = vi.fn(({ handleClickTrigger }) => (
|
||||
<button data-testid="custom-trigger" onClick={handleClickTrigger}>
|
||||
Custom
|
||||
</button>
|
||||
))
|
||||
|
||||
const props = createDatePickerProps({ renderTrigger })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('custom-trigger'))
|
||||
|
||||
expect(screen.getAllByText(/daysInWeek/).length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// Disabled dates
|
||||
describe('Disabled Dates', () => {
|
||||
it('should pass getIsDateDisabled to calendar', () => {
|
||||
const getIsDateDisabled = vi.fn().mockReturnValue(false)
|
||||
const props = createDatePickerProps({
|
||||
value: dayjs('2024-06-15'),
|
||||
getIsDateDisabled,
|
||||
})
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
expect(getIsDateDisabled).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Timezone
|
||||
describe('Timezone', () => {
|
||||
it('should render with timezone', () => {
|
||||
const props = createDatePickerProps({
|
||||
value: dayjs('2024-06-15'),
|
||||
timezone: 'UTC',
|
||||
})
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChange when timezone changes with a value', () => {
|
||||
const onChange = vi.fn()
|
||||
const props = createDatePickerProps({
|
||||
value: dayjs('2024-06-15T14:30:00'),
|
||||
timezone: 'UTC',
|
||||
onChange,
|
||||
})
|
||||
const { rerender } = render(<DatePicker {...props} />)
|
||||
|
||||
// Change timezone
|
||||
rerender(<DatePicker {...props} timezone="America/New_York" />)
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update currentDate when timezone changes without a value', () => {
|
||||
const onChange = vi.fn()
|
||||
const props = createDatePickerProps({
|
||||
timezone: 'UTC',
|
||||
onChange,
|
||||
})
|
||||
const { rerender } = render(<DatePicker {...props} />)
|
||||
|
||||
// Change timezone with no value
|
||||
rerender(<DatePicker {...props} timezone="America/New_York" />)
|
||||
|
||||
// onChange should NOT be called when there is no value
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update selectedDate when timezone changes and value is present', () => {
|
||||
const onChange = vi.fn()
|
||||
const value = dayjs('2024-06-15T14:30:00')
|
||||
const props = createDatePickerProps({
|
||||
value,
|
||||
timezone: 'UTC',
|
||||
onChange,
|
||||
})
|
||||
const { rerender } = render(<DatePicker {...props} />)
|
||||
|
||||
// Change timezone
|
||||
rerender(<DatePicker {...props} timezone="Asia/Tokyo" />)
|
||||
|
||||
// Should have been called with the new timezone-adjusted value
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
const emitted = onChange.mock.calls[0][0]
|
||||
expect(emitted.isValid()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// Display time when selected date exists
|
||||
describe('Time Display', () => {
|
||||
it('should show formatted time when selectedDate exists', () => {
|
||||
const value = dayjs('2024-06-15T14:30:00')
|
||||
const props = createDatePickerProps({ value, needTimePicker: true })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
// The footer should show the time from selectedDate (02:30 PM)
|
||||
expect(screen.getByText(/\d{2}:\d{2}\s(AM|PM)/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
94
web/app/components/base/date-and-time-picker/hooks.spec.ts
Normal file
94
web/app/components/base/date-and-time-picker/hooks.spec.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useDaysOfWeek, useMonths, useTimeOptions, useYearOptions } from './hooks'
|
||||
import { Period } from './types'
|
||||
import dayjs from './utils/dayjs'
|
||||
|
||||
describe('date-and-time-picker hooks', () => {
|
||||
// Tests for useDaysOfWeek hook
|
||||
describe('useDaysOfWeek', () => {
|
||||
it('should return 7 days of the week', () => {
|
||||
const { result } = renderHook(() => useDaysOfWeek())
|
||||
|
||||
expect(result.current).toHaveLength(7)
|
||||
})
|
||||
|
||||
it('should return translated day keys with namespace prefix', () => {
|
||||
const { result } = renderHook(() => useDaysOfWeek())
|
||||
|
||||
// Global i18n mock returns "time.daysInWeek.<day>" format
|
||||
expect(result.current[0]).toContain('daysInWeek.Sun')
|
||||
expect(result.current[6]).toContain('daysInWeek.Sat')
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for useMonths hook
|
||||
describe('useMonths', () => {
|
||||
it('should return 12 months', () => {
|
||||
const { result } = renderHook(() => useMonths())
|
||||
|
||||
expect(result.current).toHaveLength(12)
|
||||
})
|
||||
|
||||
it('should return translated month keys with namespace prefix', () => {
|
||||
const { result } = renderHook(() => useMonths())
|
||||
|
||||
expect(result.current[0]).toContain('months.January')
|
||||
expect(result.current[11]).toContain('months.December')
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for useYearOptions hook
|
||||
describe('useYearOptions', () => {
|
||||
it('should return 200 year options', () => {
|
||||
const { result } = renderHook(() => useYearOptions())
|
||||
|
||||
expect(result.current).toHaveLength(200)
|
||||
})
|
||||
|
||||
it('should center around the current year', () => {
|
||||
const { result } = renderHook(() => useYearOptions())
|
||||
const currentYear = dayjs().year()
|
||||
|
||||
expect(result.current).toContain(currentYear)
|
||||
// First year should be currentYear - 50 (YEAR_RANGE/2 = 50)
|
||||
expect(result.current[0]).toBe(currentYear - 50)
|
||||
// Last year should be currentYear + 149
|
||||
expect(result.current[199]).toBe(currentYear + 149)
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for useTimeOptions hook
|
||||
describe('useTimeOptions', () => {
|
||||
it('should return 12 hour options', () => {
|
||||
const { result } = renderHook(() => useTimeOptions())
|
||||
|
||||
expect(result.current.hourOptions).toHaveLength(12)
|
||||
})
|
||||
|
||||
it('should return hours from 01 to 12 zero-padded', () => {
|
||||
const { result } = renderHook(() => useTimeOptions())
|
||||
|
||||
expect(result.current.hourOptions[0]).toBe('01')
|
||||
expect(result.current.hourOptions[11]).toBe('12')
|
||||
})
|
||||
|
||||
it('should return 60 minute options', () => {
|
||||
const { result } = renderHook(() => useTimeOptions())
|
||||
|
||||
expect(result.current.minuteOptions).toHaveLength(60)
|
||||
})
|
||||
|
||||
it('should return minutes from 00 to 59 zero-padded', () => {
|
||||
const { result } = renderHook(() => useTimeOptions())
|
||||
|
||||
expect(result.current.minuteOptions[0]).toBe('00')
|
||||
expect(result.current.minuteOptions[59]).toBe('59')
|
||||
})
|
||||
|
||||
it('should return AM and PM period options', () => {
|
||||
const { result } = renderHook(() => useTimeOptions())
|
||||
|
||||
expect(result.current.periodOptions).toEqual([Period.AM, Period.PM])
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,50 @@
|
||||
import type { TimePickerFooterProps } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import Footer from './footer'
|
||||
|
||||
// Factory for TimePickerFooter props
|
||||
const createFooterProps = (overrides: Partial<TimePickerFooterProps> = {}): TimePickerFooterProps => ({
|
||||
handleSelectCurrentTime: vi.fn(),
|
||||
handleConfirm: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('TimePicker Footer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should render Now and OK buttons', () => {
|
||||
const props = createFooterProps()
|
||||
render(<Footer {...props} />)
|
||||
|
||||
expect(screen.getByText(/operation\.now/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/operation\.ok/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Interaction tests
|
||||
describe('Interactions', () => {
|
||||
it('should call handleSelectCurrentTime when Now button is clicked', () => {
|
||||
const handleSelectCurrentTime = vi.fn()
|
||||
const props = createFooterProps({ handleSelectCurrentTime })
|
||||
render(<Footer {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.now/))
|
||||
|
||||
expect(handleSelectCurrentTime).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call handleConfirm when OK button is clicked', () => {
|
||||
const handleConfirm = vi.fn()
|
||||
const props = createFooterProps({ handleConfirm })
|
||||
render(<Footer {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.ok/))
|
||||
|
||||
expect(handleConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,30 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Header from './header'
|
||||
|
||||
describe('TimePicker Header', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should render default title when no title prop is provided', () => {
|
||||
render(<Header />)
|
||||
|
||||
// Global i18n mock returns the key with namespace prefix
|
||||
expect(screen.getByText(/title\.pickTime/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render custom title when title prop is provided', () => {
|
||||
render(<Header title="Custom Title" />)
|
||||
|
||||
expect(screen.getByText('Custom Title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render default title when custom title is provided', () => {
|
||||
render(<Header title="Custom Title" />)
|
||||
|
||||
expect(screen.queryByText(/title\.pickTime/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,42 +1,12 @@
|
||||
import type { TimePickerProps } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import dayjs, { isDayjsObject } from '../utils/dayjs'
|
||||
import TimePicker from './index'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => {
|
||||
if (key === 'defaultPlaceholder')
|
||||
return 'Pick a time...'
|
||||
if (key === 'operation.now')
|
||||
return 'Now'
|
||||
if (key === 'operation.ok')
|
||||
return 'OK'
|
||||
if (key === 'operation.clear')
|
||||
return 'Clear'
|
||||
const prefix = options?.ns ? `${options.ns}.` : ''
|
||||
return `${prefix}${key}`
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||
PortalToFollowElem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: (e: React.MouseEvent) => void }) => (
|
||||
<div onClick={onClick}>{children}</div>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="timepicker-content">{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./options', () => ({
|
||||
default: () => <div data-testid="time-options" />,
|
||||
}))
|
||||
vi.mock('./header', () => ({
|
||||
default: () => <div data-testid="time-header" />,
|
||||
}))
|
||||
// Mock scrollIntoView since jsdom doesn't implement it
|
||||
beforeAll(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
})
|
||||
|
||||
describe('TimePicker', () => {
|
||||
const baseProps: Pick<TimePickerProps, 'onChange' | 'onClear' | 'value'> = {
|
||||
@ -73,10 +43,10 @@ describe('TimePicker', () => {
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.click(input)
|
||||
|
||||
const clearButton = screen.getByRole('button', { name: /clear/i })
|
||||
const clearButton = screen.getByRole('button', { name: /operation\.clear/i })
|
||||
fireEvent.click(clearButton)
|
||||
|
||||
const confirmButton = screen.getByRole('button', { name: 'OK' })
|
||||
const confirmButton = screen.getByRole('button', { name: /operation\.ok/i })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
expect(baseProps.onChange).toHaveBeenCalledTimes(1)
|
||||
@ -94,7 +64,10 @@ describe('TimePicker', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
const nowButton = screen.getByRole('button', { name: 'Now' })
|
||||
// Open the picker first to access content
|
||||
fireEvent.click(screen.getByRole('textbox'))
|
||||
|
||||
const nowButton = screen.getByRole('button', { name: /operation\.now/i })
|
||||
fireEvent.click(nowButton)
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
@ -103,6 +76,601 @@ describe('TimePicker', () => {
|
||||
expect(emitted?.utcOffset()).toBe(dayjs().tz('America/New_York').utcOffset())
|
||||
})
|
||||
|
||||
// Opening and closing behavior tests
|
||||
describe('Open/Close Behavior', () => {
|
||||
it('should show placeholder when no value is provided', () => {
|
||||
render(<TimePicker {...baseProps} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveAttribute('placeholder', expect.stringMatching(/defaultPlaceholder/i))
|
||||
})
|
||||
|
||||
it('should toggle open state when trigger is clicked', () => {
|
||||
render(<TimePicker {...baseProps} value="10:00 AM" timezone="UTC" />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
// Open
|
||||
fireEvent.click(input)
|
||||
expect(input).toHaveValue('')
|
||||
|
||||
// Close by clicking again
|
||||
fireEvent.click(input)
|
||||
expect(input).toHaveValue('10:00 AM')
|
||||
})
|
||||
|
||||
it('should call onClear when clear is clicked while picker is closed', () => {
|
||||
const onClear = vi.fn()
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onClear={onClear}
|
||||
value="10:00 AM"
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
const clearButton = screen.getByRole('button', { name: /operation\.clear/i })
|
||||
fireEvent.click(clearButton)
|
||||
|
||||
expect(onClear).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not call onClear when clear is clicked while picker is open', () => {
|
||||
const onClear = vi.fn()
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onClear={onClear}
|
||||
value="10:00 AM"
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Open picker first
|
||||
fireEvent.click(screen.getByRole('textbox'))
|
||||
// Then clear
|
||||
const clearButton = screen.getByRole('button', { name: /operation\.clear/i })
|
||||
fireEvent.click(clearButton)
|
||||
|
||||
expect(onClear).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should register click outside listener on mount', () => {
|
||||
const addEventSpy = vi.spyOn(document, 'addEventListener')
|
||||
render(<TimePicker {...baseProps} value="10:00 AM" timezone="UTC" />)
|
||||
|
||||
expect(addEventSpy).toHaveBeenCalledWith('mousedown', expect.any(Function))
|
||||
addEventSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should sync selectedTime from value when opening with stale state', () => {
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
value="10:00 AM"
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
// Open - this triggers handleClickTrigger which syncs selectedTime from value
|
||||
fireEvent.click(input)
|
||||
|
||||
// Confirm to verify selectedTime was synced from value prop ("10:00 AM")
|
||||
const confirmButton = screen.getByRole('button', { name: /operation\.ok/i })
|
||||
fireEvent.click(confirmButton)
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
|
||||
const emitted = onChange.mock.calls[0][0]
|
||||
expect(isDayjsObject(emitted)).toBe(true)
|
||||
expect(emitted.hour()).toBe(10)
|
||||
expect(emitted.minute()).toBe(0)
|
||||
})
|
||||
|
||||
it('should resync selectedTime when opening after internal clear', () => {
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
value={dayjs('2024-01-01T10:30:00Z')}
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
// Open
|
||||
fireEvent.click(input)
|
||||
|
||||
// Clear selected time internally
|
||||
const clearButton = screen.getByRole('button', { name: /operation\.clear/i })
|
||||
fireEvent.click(clearButton)
|
||||
|
||||
// Close
|
||||
fireEvent.click(input)
|
||||
|
||||
// Open again - should resync selectedTime from value prop
|
||||
fireEvent.click(input)
|
||||
|
||||
// Confirm to verify the value was resynced
|
||||
const confirmButton = screen.getByRole('button', { name: /operation\.ok/i })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
const emitted = onChange.mock.calls[0][0]
|
||||
expect(isDayjsObject(emitted)).toBe(true)
|
||||
// Resynced from value prop: dayjs('2024-01-01T10:30:00Z') in UTC = 10:30 AM
|
||||
expect(emitted.hour()).toBe(10)
|
||||
expect(emitted.minute()).toBe(30)
|
||||
})
|
||||
})
|
||||
|
||||
// Props tests
|
||||
describe('Props', () => {
|
||||
it('should show custom placeholder when provided', () => {
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
placeholder="Select time"
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveAttribute('placeholder', 'Select time')
|
||||
})
|
||||
|
||||
it('should render with triggerFullWidth prop without errors', () => {
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
triggerFullWidth={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Verify the component renders successfully with triggerFullWidth
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use renderTrigger when provided', () => {
|
||||
const renderTrigger = vi.fn(({ inputElem, onClick }) => (
|
||||
<div data-testid="custom-trigger" onClick={onClick}>
|
||||
{inputElem}
|
||||
</div>
|
||||
))
|
||||
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
renderTrigger={renderTrigger}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('custom-trigger')).toBeInTheDocument()
|
||||
expect(renderTrigger).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render with notClearable prop without errors', () => {
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
notClearable={true}
|
||||
value="10:00 AM"
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
// In test env the icon stays in DOM, but must remain hidden when notClearable is set
|
||||
expect(screen.getByRole('button', { name: /clear/i })).toHaveClass('hidden')
|
||||
})
|
||||
})
|
||||
|
||||
// Confirm behavior tests
|
||||
describe('Confirm Behavior', () => {
|
||||
it('should emit selected time when confirm is clicked with a value', () => {
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
value={dayjs('2024-01-01T10:30:00Z')}
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Open the picker first to access content
|
||||
fireEvent.click(screen.getByRole('textbox'))
|
||||
|
||||
const confirmButton = screen.getByRole('button', { name: /operation\.ok/i })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
const emitted = onChange.mock.calls[0][0]
|
||||
expect(isDayjsObject(emitted)).toBe(true)
|
||||
expect(emitted.hour()).toBe(10)
|
||||
expect(emitted.minute()).toBe(30)
|
||||
})
|
||||
})
|
||||
|
||||
// Time selection handler tests
|
||||
describe('Time Selection', () => {
|
||||
const openPicker = () => {
|
||||
fireEvent.click(screen.getByRole('textbox'))
|
||||
}
|
||||
|
||||
const getHourAndMinuteLists = () => {
|
||||
const allLists = screen.getAllByRole('list')
|
||||
const hourList = allLists.find(list =>
|
||||
within(list).queryByText('01')
|
||||
&& within(list).queryByText('12')
|
||||
&& !within(list).queryByText('59'))
|
||||
const minuteList = allLists.find(list =>
|
||||
within(list).queryByText('00')
|
||||
&& within(list).queryByText('59'))
|
||||
|
||||
expect(hourList).toBeTruthy()
|
||||
expect(minuteList).toBeTruthy()
|
||||
|
||||
return {
|
||||
hourList: hourList!,
|
||||
minuteList: minuteList!,
|
||||
}
|
||||
}
|
||||
|
||||
it('should update selectedTime when hour is selected', () => {
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
value={dayjs('2024-01-01T10:30:00Z')}
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
openPicker()
|
||||
|
||||
// Click hour "05" from the time options
|
||||
const { hourList } = getHourAndMinuteLists()
|
||||
fireEvent.click(within(hourList).getByText('05'))
|
||||
|
||||
// Now confirm to verify the selectedTime was updated
|
||||
const confirmButton = screen.getByRole('button', { name: /operation\.ok/i })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
const emitted = onChange.mock.calls[0][0]
|
||||
expect(isDayjsObject(emitted)).toBe(true)
|
||||
// Hour 05 in AM (since original was 10:30 AM) = 5
|
||||
expect(emitted.hour()).toBe(5)
|
||||
})
|
||||
|
||||
it('should update selectedTime when minute is selected', () => {
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
value={dayjs('2024-01-01T10:30:00Z')}
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
openPicker()
|
||||
|
||||
// Click minute "45" from the time options
|
||||
const { minuteList } = getHourAndMinuteLists()
|
||||
fireEvent.click(within(minuteList).getByText('45'))
|
||||
|
||||
// Confirm
|
||||
const confirmButton = screen.getByRole('button', { name: /operation\.ok/i })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
const emitted = onChange.mock.calls[0][0]
|
||||
expect(emitted.minute()).toBe(45)
|
||||
})
|
||||
|
||||
it('should update selectedTime when period is changed', () => {
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
value={dayjs('2024-01-01T10:30:00Z')}
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
openPicker()
|
||||
|
||||
// Click PM to switch period
|
||||
fireEvent.click(screen.getByText('PM'))
|
||||
|
||||
// Confirm
|
||||
const confirmButton = screen.getByRole('button', { name: /operation\.ok/i })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
const emitted = onChange.mock.calls[0][0]
|
||||
// Original was 10:30 AM, switching to PM makes it 22:30
|
||||
expect(emitted.hour()).toBe(22)
|
||||
})
|
||||
|
||||
it('should create new time when selecting hour without prior selectedTime', () => {
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
openPicker()
|
||||
|
||||
// Click hour "03" with no existing selectedTime
|
||||
const { hourList } = getHourAndMinuteLists()
|
||||
fireEvent.click(within(hourList).getByText('03'))
|
||||
|
||||
// Confirm
|
||||
const confirmButton = screen.getByRole('button', { name: /operation\.ok/i })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
const emitted = onChange.mock.calls[0][0]
|
||||
expect(isDayjsObject(emitted)).toBe(true)
|
||||
expect(emitted.hour()).toBe(3)
|
||||
})
|
||||
|
||||
it('should handle minute selection without prior selectedTime', () => {
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
openPicker()
|
||||
|
||||
// Click minute "15" with no existing selectedTime
|
||||
const { minuteList } = getHourAndMinuteLists()
|
||||
fireEvent.click(within(minuteList).getByText('15'))
|
||||
|
||||
// Confirm
|
||||
const confirmButton = screen.getByRole('button', { name: /operation\.ok/i })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
const emitted = onChange.mock.calls[0][0]
|
||||
expect(emitted.minute()).toBe(15)
|
||||
})
|
||||
|
||||
it('should handle period selection without prior selectedTime', () => {
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
openPicker()
|
||||
|
||||
// Click PM with no existing selectedTime
|
||||
fireEvent.click(screen.getByText('PM'))
|
||||
|
||||
// Confirm
|
||||
const confirmButton = screen.getByRole('button', { name: /operation\.ok/i })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
const emitted = onChange.mock.calls[0][0]
|
||||
expect(isDayjsObject(emitted)).toBe(true)
|
||||
expect(emitted.hour()).toBeGreaterThanOrEqual(12)
|
||||
})
|
||||
})
|
||||
|
||||
// Timezone change effect tests
|
||||
describe('Timezone Changes', () => {
|
||||
it('should call onChange when timezone changes with an existing value', () => {
|
||||
const onChange = vi.fn()
|
||||
const value = dayjs('2024-01-01T10:30:00Z')
|
||||
const { rerender } = render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Change timezone without changing value (same reference)
|
||||
rerender(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
timezone="America/New_York"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
const emitted = onChange.mock.calls[0][0]
|
||||
expect(isDayjsObject(emitted)).toBe(true)
|
||||
// 10:30 UTC converted to America/New_York (UTC-5 in Jan) = 05:30
|
||||
expect(emitted.utcOffset()).toBe(dayjs().tz('America/New_York').utcOffset())
|
||||
expect(emitted.hour()).toBe(5)
|
||||
expect(emitted.minute()).toBe(30)
|
||||
})
|
||||
|
||||
it('should update selectedTime when value changes', () => {
|
||||
const onChange = vi.fn()
|
||||
const { rerender } = render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
value={dayjs('2024-01-01T10:30:00Z')}
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Change value
|
||||
rerender(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
value={dayjs('2024-01-01T14:00:00Z')}
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
// onChange should not be called when only value changes (no timezone change)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
|
||||
// But the display should update
|
||||
expect(screen.getByDisplayValue('02:00 PM')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle timezone change when value is undefined', () => {
|
||||
const onChange = vi.fn()
|
||||
const { rerender } = render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Change timezone without a value
|
||||
rerender(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
timezone="America/New_York"
|
||||
/>,
|
||||
)
|
||||
|
||||
// onChange should not be called when value is undefined
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle timezone change when selectedTime exists but value becomes undefined', () => {
|
||||
const onChange = vi.fn()
|
||||
const { rerender } = render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
value={dayjs('2024-01-01T10:30:00Z')}
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
// Remove value and change timezone
|
||||
rerender(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
value={undefined}
|
||||
timezone="America/New_York"
|
||||
/>,
|
||||
)
|
||||
// Input should be empty now
|
||||
expect(screen.getByRole('textbox')).toHaveValue('')
|
||||
// onChange should not fire when value is undefined, even if selectedTime was set
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not update when neither timezone nor value changes', () => {
|
||||
const onChange = vi.fn()
|
||||
const value = dayjs('2024-01-01T10:30:00Z')
|
||||
const { rerender } = render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Rerender with same props
|
||||
rerender(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update display when both value and timezone change', () => {
|
||||
const onChange = vi.fn()
|
||||
const { rerender } = render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
value={dayjs('2024-01-01T10:30:00Z')}
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Change both value and timezone simultaneously
|
||||
rerender(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
value={dayjs('2024-01-01T15:00:00Z')}
|
||||
timezone="America/New_York"
|
||||
/>,
|
||||
)
|
||||
|
||||
// onChange should not be called since both changed (timezoneChanged && !valueChanged is false)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
|
||||
// 15:00 UTC in America/New_York (UTC-5) = 10:00 AM
|
||||
expect(screen.getByDisplayValue('10:00 AM')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Format time value tests
|
||||
describe('Format Time Value', () => {
|
||||
it('should return empty string when value is undefined', () => {
|
||||
render(<TimePicker {...baseProps} />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should format dayjs value correctly', () => {
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
value={dayjs('2024-01-01T14:30:00Z')}
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByDisplayValue('02:30 PM')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should format string value correctly', () => {
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
value="09:15"
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByDisplayValue('09:15 AM')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Timezone Label Integration', () => {
|
||||
it('should not display timezone label by default', () => {
|
||||
render(
|
||||
|
||||
@ -0,0 +1,97 @@
|
||||
import type { TimeOptionsProps } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import dayjs from '../utils/dayjs'
|
||||
import Options from './options'
|
||||
|
||||
beforeAll(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
})
|
||||
|
||||
const createOptionsProps = (overrides: Partial<TimeOptionsProps> = {}): TimeOptionsProps => ({
|
||||
selectedTime: undefined,
|
||||
handleSelectHour: vi.fn(),
|
||||
handleSelectMinute: vi.fn(),
|
||||
handleSelectPeriod: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('TimePickerOptions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render hour options', () => {
|
||||
const props = createOptionsProps()
|
||||
|
||||
render(<Options {...props} />)
|
||||
|
||||
const allItems = screen.getAllByRole('listitem')
|
||||
expect(allItems.length).toBeGreaterThan(12)
|
||||
})
|
||||
|
||||
it('should render all hour, minute, and period options by default', () => {
|
||||
const props = createOptionsProps()
|
||||
render(<Options {...props} />)
|
||||
const allItems = screen.getAllByRole('listitem')
|
||||
// 12 hours + 60 minutes + 2 periods
|
||||
expect(allItems).toHaveLength(74)
|
||||
})
|
||||
|
||||
it('should render AM and PM period options', () => {
|
||||
const props = createOptionsProps()
|
||||
|
||||
render(<Options {...props} />)
|
||||
|
||||
expect(screen.getByText('AM')).toBeInTheDocument()
|
||||
expect(screen.getByText('PM')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Minute Filter', () => {
|
||||
it('should apply minuteFilter when provided', () => {
|
||||
const minuteFilter = (minutes: string[]) => minutes.filter(m => Number(m) % 15 === 0)
|
||||
const props = createOptionsProps({ minuteFilter })
|
||||
|
||||
render(<Options {...props} />)
|
||||
|
||||
const allItems = screen.getAllByRole('listitem')
|
||||
expect(allItems).toHaveLength(18)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interactions', () => {
|
||||
it('should render selected hour in the list', () => {
|
||||
const props = createOptionsProps({ selectedTime: dayjs('2024-01-01 05:30:00') })
|
||||
render(<Options {...props} />)
|
||||
const selectedHour = screen.getAllByRole('listitem').find(item => item.textContent === '05')
|
||||
expect(selectedHour).toHaveClass('bg-components-button-ghost-bg-hover')
|
||||
})
|
||||
it('should render selected minute in the list', () => {
|
||||
const props = createOptionsProps({ selectedTime: dayjs('2024-01-01 05:30:00') })
|
||||
render(<Options {...props} />)
|
||||
const selectedMinute = screen.getAllByRole('listitem').find(item => item.textContent === '30')
|
||||
expect(selectedMinute).toHaveClass('bg-components-button-ghost-bg-hover')
|
||||
})
|
||||
|
||||
it('should call handleSelectPeriod when AM is clicked', () => {
|
||||
const handleSelectPeriod = vi.fn()
|
||||
const props = createOptionsProps({ handleSelectPeriod })
|
||||
|
||||
render(<Options {...props} />)
|
||||
fireEvent.click(screen.getAllByText('AM')[0])
|
||||
|
||||
expect(handleSelectPeriod).toHaveBeenCalledWith('AM')
|
||||
})
|
||||
|
||||
it('should call handleSelectPeriod when PM is clicked', () => {
|
||||
const handleSelectPeriod = vi.fn()
|
||||
const props = createOptionsProps({ handleSelectPeriod })
|
||||
|
||||
render(<Options {...props} />)
|
||||
fireEvent.click(screen.getAllByText('PM')[0])
|
||||
|
||||
expect(handleSelectPeriod).toHaveBeenCalledWith('PM')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,366 @@
|
||||
import dayjs, {
|
||||
clearMonthMapCache,
|
||||
cloneTime,
|
||||
formatDateForOutput,
|
||||
getDateWithTimezone,
|
||||
getDaysInMonth,
|
||||
getHourIn12Hour,
|
||||
parseDateWithFormat,
|
||||
toDayjs,
|
||||
} from './dayjs'
|
||||
|
||||
describe('dayjs extended utilities', () => {
|
||||
// Tests for cloneTime
|
||||
describe('cloneTime', () => {
|
||||
it('should copy hour and minute from source to target', () => {
|
||||
const target = dayjs('2024-01-15')
|
||||
const source = dayjs('2024-06-20 14:30')
|
||||
|
||||
const result = cloneTime(target, source)
|
||||
|
||||
expect(result.hour()).toBe(14)
|
||||
expect(result.minute()).toBe(30)
|
||||
expect(result.date()).toBe(15)
|
||||
expect(result.month()).toBe(0) // January
|
||||
})
|
||||
|
||||
it('should not mutate the original target date', () => {
|
||||
const target = dayjs('2024-01-15 08:00')
|
||||
const source = dayjs('2024-06-20 14:30')
|
||||
|
||||
cloneTime(target, source)
|
||||
|
||||
expect(target.hour()).toBe(8)
|
||||
expect(target.minute()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for getDaysInMonth
|
||||
describe('getDaysInMonth', () => {
|
||||
beforeEach(() => {
|
||||
clearMonthMapCache()
|
||||
})
|
||||
|
||||
it('should return an array of Day objects', () => {
|
||||
const date = dayjs('2024-06-15')
|
||||
const days = getDaysInMonth(date)
|
||||
|
||||
expect(days.length).toBeGreaterThan(0)
|
||||
days.forEach((day) => {
|
||||
expect(day).toHaveProperty('date')
|
||||
expect(day).toHaveProperty('isCurrentMonth')
|
||||
})
|
||||
})
|
||||
|
||||
it('should include days from previous and next month to fill the grid', () => {
|
||||
const date = dayjs('2024-06-15') // June 2024 starts on Saturday
|
||||
const days = getDaysInMonth(date)
|
||||
|
||||
const prevMonthDays = days.filter(d => !d.isCurrentMonth && d.date.month() < date.month())
|
||||
const nextMonthDays = days.filter(d => !d.isCurrentMonth && d.date.month() > date.month())
|
||||
|
||||
// June 2024 starts on Saturday (6), so there are 6 days from previous month
|
||||
expect(prevMonthDays.length).toBeGreaterThan(0)
|
||||
expect(nextMonthDays.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should mark current month days correctly', () => {
|
||||
const date = dayjs('2024-06-15')
|
||||
const days = getDaysInMonth(date)
|
||||
|
||||
const currentMonthDays = days.filter(d => d.isCurrentMonth)
|
||||
// June has 30 days
|
||||
expect(currentMonthDays).toHaveLength(30)
|
||||
})
|
||||
|
||||
it('should cache results for the same month', () => {
|
||||
const date1 = dayjs('2024-06-15')
|
||||
const date2 = dayjs('2024-06-20')
|
||||
|
||||
const days1 = getDaysInMonth(date1)
|
||||
const days2 = getDaysInMonth(date2)
|
||||
|
||||
// Same reference since it's cached
|
||||
expect(days1).toBe(days2)
|
||||
})
|
||||
|
||||
it('should return different results for different months', () => {
|
||||
const june = dayjs('2024-06-15')
|
||||
const july = dayjs('2024-07-15')
|
||||
|
||||
const juneDays = getDaysInMonth(june)
|
||||
const julyDays = getDaysInMonth(july)
|
||||
|
||||
expect(juneDays).not.toBe(julyDays)
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for clearMonthMapCache
|
||||
describe('clearMonthMapCache', () => {
|
||||
it('should clear the cache so new days are generated', () => {
|
||||
const date = dayjs('2024-06-15')
|
||||
|
||||
const days1 = getDaysInMonth(date)
|
||||
clearMonthMapCache()
|
||||
const days2 = getDaysInMonth(date)
|
||||
|
||||
// After clearing cache, a new array should be created
|
||||
expect(days1).not.toBe(days2)
|
||||
// But should have the same length
|
||||
expect(days1.length).toBe(days2.length)
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for getHourIn12Hour
|
||||
describe('getHourIn12Hour', () => {
|
||||
it('should return 12 for midnight (hour 0)', () => {
|
||||
const date = dayjs('2024-01-01 00:00')
|
||||
expect(getHourIn12Hour(date)).toBe(12)
|
||||
})
|
||||
|
||||
it('should return hour as-is for 1-11 AM', () => {
|
||||
expect(getHourIn12Hour(dayjs('2024-01-01 01:00'))).toBe(1)
|
||||
expect(getHourIn12Hour(dayjs('2024-01-01 11:00'))).toBe(11)
|
||||
})
|
||||
|
||||
it('should return 0 for noon (hour 12)', () => {
|
||||
const date = dayjs('2024-01-01 12:00')
|
||||
expect(getHourIn12Hour(date)).toBe(0)
|
||||
})
|
||||
|
||||
it('should return hour - 12 for PM hours (13-23)', () => {
|
||||
expect(getHourIn12Hour(dayjs('2024-01-01 13:00'))).toBe(1)
|
||||
expect(getHourIn12Hour(dayjs('2024-01-01 23:00'))).toBe(11)
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for getDateWithTimezone
|
||||
describe('getDateWithTimezone', () => {
|
||||
it('should return a cloned date when no timezone is provided', () => {
|
||||
const date = dayjs('2024-06-15')
|
||||
const result = getDateWithTimezone({ date })
|
||||
|
||||
expect(result.format('YYYY-MM-DD')).toBe('2024-06-15')
|
||||
})
|
||||
|
||||
it('should return current date clone when neither date nor timezone is provided', () => {
|
||||
const result = getDateWithTimezone({})
|
||||
const now = dayjs()
|
||||
|
||||
expect(result.format('YYYY-MM-DD')).toBe(now.format('YYYY-MM-DD'))
|
||||
})
|
||||
|
||||
it('should apply timezone to provided date', () => {
|
||||
const date = dayjs('2024-06-15T12:00:00')
|
||||
const result = getDateWithTimezone({ date, timezone: 'America/New_York' })
|
||||
|
||||
// dayjs.tz converts the date to the given timezone
|
||||
expect(result).toBeDefined()
|
||||
expect(result.isValid()).toBe(true)
|
||||
})
|
||||
|
||||
it('should return current time in timezone when only timezone is provided', () => {
|
||||
const result = getDateWithTimezone({ timezone: 'Asia/Tokyo' })
|
||||
|
||||
expect(result.utcOffset()).toBe(dayjs().tz('Asia/Tokyo').utcOffset())
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for toDayjs additional edge cases
|
||||
describe('toDayjs edge cases', () => {
|
||||
it('should return undefined for empty string', () => {
|
||||
expect(toDayjs('')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return undefined for undefined', () => {
|
||||
expect(toDayjs(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should handle Dayjs object input', () => {
|
||||
const date = dayjs('2024-06-15')
|
||||
const result = toDayjs(date)
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.format('YYYY-MM-DD')).toBe('2024-06-15')
|
||||
})
|
||||
|
||||
it('should handle Dayjs object with timezone', () => {
|
||||
const date = dayjs('2024-06-15T12:00:00')
|
||||
const result = toDayjs(date, { timezone: 'UTC' })
|
||||
|
||||
expect(result).toBeDefined()
|
||||
})
|
||||
|
||||
it('should parse with custom format when format matches common formats', () => {
|
||||
// Uses a format from COMMON_PARSE_FORMATS
|
||||
const result = toDayjs('2024-06-15', { format: 'YYYY-MM-DD' })
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.format('YYYY-MM-DD')).toBe('2024-06-15')
|
||||
})
|
||||
|
||||
it('should fall back when custom format does not match', () => {
|
||||
// dayjs strict mode with format requires customParseFormat plugin
|
||||
// which is not loaded, so invalid format falls through to other parsing
|
||||
const result = toDayjs('2024-06-15', { format: 'INVALID', timezone: 'UTC' })
|
||||
|
||||
// It will still be parsed by fallback mechanisms
|
||||
expect(result).toBeDefined()
|
||||
})
|
||||
|
||||
it('should parse time with seconds', () => {
|
||||
const result = toDayjs('14:30:45', { timezone: 'UTC' })
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.hour()).toBe(14)
|
||||
expect(result?.minute()).toBe(30)
|
||||
expect(result?.second()).toBe(45)
|
||||
})
|
||||
|
||||
it('should parse time with milliseconds', () => {
|
||||
const result = toDayjs('14:30:45.123', { timezone: 'UTC' })
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.millisecond()).toBe(123)
|
||||
})
|
||||
|
||||
it('should normalize short milliseconds by padding', () => {
|
||||
const result = toDayjs('14:30:45.1', { timezone: 'UTC' })
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.millisecond()).toBe(100)
|
||||
})
|
||||
|
||||
it('should truncate long milliseconds to 3 digits', () => {
|
||||
// The time regex only captures up to 3 digits for ms, so 4+ digit values
|
||||
// don't match the regex and fall through to common format parsing
|
||||
const result = toDayjs('14:30:45.12', { timezone: 'UTC' })
|
||||
|
||||
expect(result).toBeDefined()
|
||||
// 2-digit ms "12" gets padded to "120"
|
||||
expect(result?.millisecond()).toBe(120)
|
||||
})
|
||||
|
||||
it('should parse 12-hour AM time', () => {
|
||||
const result = toDayjs('07:15 AM', { timezone: 'UTC' })
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.hour()).toBe(7)
|
||||
expect(result?.minute()).toBe(15)
|
||||
})
|
||||
|
||||
it('should parse 12-hour time with seconds', () => {
|
||||
const result = toDayjs('07:15:30 PM', { timezone: 'UTC' })
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.hour()).toBe(19)
|
||||
expect(result?.second()).toBe(30)
|
||||
})
|
||||
|
||||
it('should handle 12 PM correctly', () => {
|
||||
const result = toDayjs('12:00 PM', { timezone: 'UTC' })
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.hour()).toBe(12)
|
||||
})
|
||||
|
||||
it('should handle 12 AM correctly', () => {
|
||||
const result = toDayjs('12:00 AM', { timezone: 'UTC' })
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.hour()).toBe(0)
|
||||
})
|
||||
|
||||
it('should use custom formats array when provided', () => {
|
||||
const result = toDayjs('2024.06.15', { formats: ['YYYY.MM.DD'] })
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.format('YYYY-MM-DD')).toBe('2024-06-15')
|
||||
})
|
||||
|
||||
it('should fall back to native parsing for ISO strings', () => {
|
||||
const result = toDayjs('2024-06-15T12:00:00.000Z')
|
||||
|
||||
expect(result).toBeDefined()
|
||||
})
|
||||
|
||||
it('should return undefined for completely unparseable value', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const result = toDayjs('not-a-date')
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for parseDateWithFormat
|
||||
describe('parseDateWithFormat', () => {
|
||||
it('should return null for empty string', () => {
|
||||
expect(parseDateWithFormat('')).toBeNull()
|
||||
})
|
||||
|
||||
it('should parse with provided format from common formats', () => {
|
||||
// Uses YYYY-MM-DD which is in COMMON_PARSE_FORMATS
|
||||
const result = parseDateWithFormat('2024-06-15', 'YYYY-MM-DD')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.format('YYYY-MM-DD')).toBe('2024-06-15')
|
||||
})
|
||||
|
||||
it('should return null for invalid date with format', () => {
|
||||
const result = parseDateWithFormat('not-a-date', 'YYYY-MM-DD')
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should try common formats when no format is specified', () => {
|
||||
const result = parseDateWithFormat('2024-06-15')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.format('YYYY-MM-DD')).toBe('2024-06-15')
|
||||
})
|
||||
|
||||
it('should parse ISO datetime format', () => {
|
||||
const result = parseDateWithFormat('2024-06-15T12:00:00')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should return null for unparseable string without format', () => {
|
||||
const result = parseDateWithFormat('gibberish')
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for formatDateForOutput
|
||||
describe('formatDateForOutput', () => {
|
||||
it('should return empty string for invalid date', () => {
|
||||
const invalidDate = dayjs('invalid')
|
||||
expect(formatDateForOutput(invalidDate)).toBe('')
|
||||
})
|
||||
|
||||
it('should format date-only output without time', () => {
|
||||
const date = dayjs('2024-06-15T12:00:00')
|
||||
const result = formatDateForOutput(date)
|
||||
|
||||
expect(result).toBe('2024-06-15')
|
||||
})
|
||||
|
||||
it('should format with time when includeTime is true', () => {
|
||||
const date = dayjs('2024-06-15T12:00:00')
|
||||
const result = formatDateForOutput(date, true)
|
||||
|
||||
expect(result).toContain('2024-06-15')
|
||||
expect(result).toContain('12:00:00')
|
||||
})
|
||||
|
||||
it('should default to date-only format', () => {
|
||||
const date = dayjs('2024-06-15T14:30:00')
|
||||
const result = formatDateForOutput(date)
|
||||
|
||||
expect(result).toBe('2024-06-15')
|
||||
expect(result).not.toContain('14:30')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,50 @@
|
||||
import type { YearAndMonthPickerFooterProps } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import Footer from './footer'
|
||||
|
||||
// Factory for Footer props
|
||||
const createFooterProps = (overrides: Partial<YearAndMonthPickerFooterProps> = {}): YearAndMonthPickerFooterProps => ({
|
||||
handleYearMonthCancel: vi.fn(),
|
||||
handleYearMonthConfirm: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('YearAndMonthPicker Footer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should render Cancel and OK buttons', () => {
|
||||
const props = createFooterProps()
|
||||
render(<Footer {...props} />)
|
||||
|
||||
expect(screen.getByText(/operation\.cancel/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/operation\.ok/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Interaction tests
|
||||
describe('Interactions', () => {
|
||||
it('should call handleYearMonthCancel when Cancel button is clicked', () => {
|
||||
const handleYearMonthCancel = vi.fn()
|
||||
const props = createFooterProps({ handleYearMonthCancel })
|
||||
render(<Footer {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.cancel/))
|
||||
|
||||
expect(handleYearMonthCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call handleYearMonthConfirm when OK button is clicked', () => {
|
||||
const handleYearMonthConfirm = vi.fn()
|
||||
const props = createFooterProps({ handleYearMonthConfirm })
|
||||
render(<Footer {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.ok/))
|
||||
|
||||
expect(handleYearMonthConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,47 @@
|
||||
import type { YearAndMonthPickerHeaderProps } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import Header from './header'
|
||||
|
||||
// Factory for Header props
|
||||
const createHeaderProps = (overrides: Partial<YearAndMonthPickerHeaderProps> = {}): YearAndMonthPickerHeaderProps => ({
|
||||
selectedYear: 2024,
|
||||
selectedMonth: 5, // June (0-indexed)
|
||||
onClick: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('YearAndMonthPicker Header', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should display the selected year', () => {
|
||||
const props = createHeaderProps({ selectedYear: 2024 })
|
||||
render(<Header {...props} />)
|
||||
|
||||
expect(screen.getByText(/2024/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a clickable button', () => {
|
||||
const props = createHeaderProps()
|
||||
render(<Header {...props} />)
|
||||
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Interaction tests
|
||||
describe('Interactions', () => {
|
||||
it('should call onClick when the header button is clicked', () => {
|
||||
const onClick = vi.fn()
|
||||
const props = createHeaderProps({ onClick })
|
||||
render(<Header {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,81 @@
|
||||
import type { YearAndMonthPickerOptionsProps } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import Options from './options'
|
||||
|
||||
beforeAll(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
})
|
||||
|
||||
const createOptionsProps = (overrides: Partial<YearAndMonthPickerOptionsProps> = {}): YearAndMonthPickerOptionsProps => ({
|
||||
selectedMonth: 5,
|
||||
selectedYear: 2024,
|
||||
handleMonthSelect: vi.fn(),
|
||||
handleYearSelect: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('YearAndMonthPicker Options', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render month options', () => {
|
||||
const props = createOptionsProps()
|
||||
|
||||
render(<Options {...props} />)
|
||||
|
||||
const monthItems = screen.getAllByText(/months\./)
|
||||
expect(monthItems).toHaveLength(12)
|
||||
})
|
||||
|
||||
it('should render year options', () => {
|
||||
const props = createOptionsProps()
|
||||
|
||||
render(<Options {...props} />)
|
||||
|
||||
const allItems = screen.getAllByRole('listitem')
|
||||
expect(allItems).toHaveLength(212)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interactions', () => {
|
||||
it('should call handleMonthSelect when a month is clicked', () => {
|
||||
const handleMonthSelect = vi.fn()
|
||||
const props = createOptionsProps({ handleMonthSelect })
|
||||
render(<Options {...props} />)
|
||||
// The mock returns 'time.months.January' for the first month
|
||||
fireEvent.click(screen.getByText(/months\.January/))
|
||||
expect(handleMonthSelect).toHaveBeenCalledWith(0)
|
||||
})
|
||||
|
||||
it('should call handleYearSelect when a year is clicked', () => {
|
||||
const handleYearSelect = vi.fn()
|
||||
const props = createOptionsProps({ handleYearSelect })
|
||||
|
||||
render(<Options {...props} />)
|
||||
fireEvent.click(screen.getByText('2024'))
|
||||
|
||||
expect(handleYearSelect).toHaveBeenCalledWith(2024)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Selection', () => {
|
||||
it('should render selected month in the list', () => {
|
||||
const props = createOptionsProps({ selectedMonth: 0 })
|
||||
|
||||
render(<Options {...props} />)
|
||||
|
||||
const monthItems = screen.getAllByText(/months\./)
|
||||
expect(monthItems.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should render selected year in the list', () => {
|
||||
const props = createOptionsProps({ selectedYear: 2024 })
|
||||
|
||||
render(<Options {...props} />)
|
||||
|
||||
expect(screen.getByText('2024')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user