Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { SubmitHandler } from 'react-hook-form'
import { FormProvider, useForm } from 'react-hook-form'
import { DatePickerField } from './DatePickerField'
import { GustoTestProvider } from '@/test/GustoTestApiProvider'
import { assertDefined, assertInstanceOf } from '@/test-utils/assertions'

vi.mock('@/assets/icons/caret-down.svg?react', () => ({
default: () => <div data-testid="caret-down" />,
}))
vi.mock('@/assets/icons/caret-left.svg?react', () => ({
default: () => <div data-testid="caret-left" />,
}))
vi.mock('@/assets/icons/caret-right.svg?react', () => ({
default: () => <div data-testid="caret-right" />,
}))

const LABEL = 'Test Date'

interface DateTestFormValues {
testDate: Date | null | string
}

const TestForm = ({
defaultValues,
onSubmit = vi.fn<SubmitHandler<DateTestFormValues>>(),
}: {
defaultValues: DateTestFormValues
onSubmit?: SubmitHandler<DateTestFormValues>
}) => {
const methods = useForm<DateTestFormValues>({ defaultValues })
return (
<GustoTestProvider>
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
<DatePickerField name="testDate" label={LABEL} />
<button type="submit">Submit</button>
</form>
</FormProvider>
</GustoTestProvider>
)
}

async function typeDate(
user: ReturnType<typeof userEvent.setup>,
{ month, day, year }: { month: string; day: string; year: string },
) {
const group = screen.getByRole('group', { name: new RegExp(LABEL, 'i') })
await user.type(within(group).getByRole('spinbutton', { name: /^month/i }), month)
await user.type(within(group).getByRole('spinbutton', { name: /^day/i }), day)
await user.type(within(group).getByRole('spinbutton', { name: /^year/i }), year)
}

function getDateSegments() {
const group = screen.getByRole('group', { name: new RegExp(LABEL, 'i') })
return {
month: within(group).getByRole('spinbutton', { name: /^month/i }),
day: within(group).getByRole('spinbutton', { name: /^day/i }),
year: within(group).getByRole('spinbutton', { name: /^year/i }),
}
}

const JUNE_15_2026 = { month: '06', day: '15', year: '2026' }

describe('DatePickerField', () => {
const user = userEvent.setup()

beforeEach(() => {
vi.clearAllMocks()
})

describe('Date mode (defaultValue is Date | null)', () => {
it('submits a Date object at local midnight when user enters a date', async () => {
const onSubmit = vi.fn<SubmitHandler<DateTestFormValues>>()
render(<TestForm defaultValues={{ testDate: null }} onSubmit={onSubmit} />)

await typeDate(user, JUNE_15_2026)
await user.click(screen.getByRole('button', { name: 'Submit' }))

await waitFor(() => {
expect(onSubmit).toHaveBeenCalled()
})
const submitted = onSubmit.mock.calls[0]?.[0].testDate
assertInstanceOf(submitted, Date)
expect(submitted.getFullYear()).toBe(2026)
expect(submitted.getMonth()).toBe(5) // June = index 5
expect(submitted.getDate()).toBe(15)
})

it('renders correct date segments when given a Date defaultValue', () => {
render(<TestForm defaultValues={{ testDate: new Date(2026, 5, 15) }} onSubmit={vi.fn()} />)

const { month, day, year } = getDateSegments()
expect(month).toHaveAttribute('aria-valuenow', '6')
expect(day).toHaveAttribute('aria-valuenow', '15')
expect(year).toHaveAttribute('aria-valuenow', '2026')
})

it('submits null when the field is left empty', async () => {
const onSubmit = vi.fn<SubmitHandler<DateTestFormValues>>()
render(<TestForm defaultValues={{ testDate: null }} onSubmit={onSubmit} />)

await user.click(screen.getByRole('button', { name: 'Submit' }))

await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({ testDate: null }),
expect.anything(),
)
})
})
})

describe('String mode (defaultValue is string)', () => {
it('submits a YYYY-MM-DD string when user enters a date', async () => {
const onSubmit = vi.fn<SubmitHandler<DateTestFormValues>>()
render(<TestForm defaultValues={{ testDate: '' }} onSubmit={onSubmit} />)

await typeDate(user, JUNE_15_2026)
await user.click(screen.getByRole('button', { name: 'Submit' }))

await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({ testDate: '2026-06-15' }),
expect.anything(),
)
})
})

it('renders correct date segments when given a YYYY-MM-DD string defaultValue', () => {
render(<TestForm defaultValues={{ testDate: '2026-06-15' }} onSubmit={vi.fn()} />)

const { month, day, year } = getDateSegments()
expect(month).toHaveAttribute('aria-valuenow', '6')
expect(day).toHaveAttribute('aria-valuenow', '15')
expect(year).toHaveAttribute('aria-valuenow', '2026')
})

it('submits an empty string when the field is left empty', async () => {
const onSubmit = vi.fn<SubmitHandler<DateTestFormValues>>()
render(<TestForm defaultValues={{ testDate: '' }} onSubmit={onSubmit} />)

await user.click(screen.getByRole('button', { name: 'Submit' }))

await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({ testDate: '' }),
expect.anything(),
)
})
})
})

describe('UTC+ timezone edge cases', () => {
beforeEach(() => {
vi.stubEnv('TZ', 'Europe/Paris') // UTC+2 in summer: local midnight June 15 = UTC June 14 22:00
})

afterEach(() => {
vi.unstubAllEnvs()
})

it('Date mode: submits the correct date when local midnight falls in UTC previous day', async () => {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the test that failed when we first introduced it before the code change. (TDD with claude 🚀 )

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yay Claude TDD Club! 🎉

const onSubmit = vi.fn<SubmitHandler<DateTestFormValues>>()
// June 10 initial value keeps the calendar in June 2026 despite the UTC shift
render(<TestForm defaultValues={{ testDate: new Date(2026, 5, 10) }} onSubmit={onSubmit} />)

// Open the calendar via the toggle button inside the date picker group
const group = screen.getByRole('group', { name: new RegExp(LABEL, 'i') })
await user.click(within(group).getByRole('button'))
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())

const june15 = screen
.getAllByRole('button')
.find(btn => btn.getAttribute('aria-label')?.includes('June 15'))
assertDefined(june15)
await user.click(june15)

await user.click(screen.getByRole('button', { name: 'Submit' }))
await waitFor(() => {
expect(onSubmit).toHaveBeenCalled()
})

const submitted = onSubmit.mock.calls[0]?.[0].testDate
assertInstanceOf(submitted, Date)
expect(submitted.getFullYear()).toBe(2026)
expect(submitted.getMonth()).toBe(5) // June = index 5
expect(submitted.getDate()).toBe(15)
})

it('String mode: submits the correct YYYY-MM-DD when local midnight falls in UTC previous day', async () => {
const onSubmit = vi.fn<SubmitHandler<DateTestFormValues>>()
render(<TestForm defaultValues={{ testDate: '2026-06-10' }} onSubmit={onSubmit} />)

const group = screen.getByRole('group', { name: new RegExp(LABEL, 'i') })
await user.click(within(group).getByRole('button'))
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())

const june15 = screen
.getAllByRole('button')
.find(btn => btn.getAttribute('aria-label')?.includes('June 15'))
assertDefined(june15)
await user.click(june15)

await user.click(screen.getByRole('button', { name: 'Submit' }))
await waitFor(() => {
expect(onSubmit).toHaveBeenCalled()
})

expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({ testDate: '2026-06-15' }),
expect.anything(),
)
})
})
})
118 changes: 117 additions & 1 deletion src/components/Common/UI/DatePicker/DatePicker.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { screen, fireEvent } from '@testing-library/react'
import { screen, fireEvent, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi, describe, test, expect, beforeEach, it } from 'vitest'
import { DatePicker } from './DatePicker'
import { renderWithProviders } from '@/test-utils/renderWithProviders'
import { assertDefined, assertInstanceOf } from '@/test-utils/assertions'

vi.mock('@/assets/icons/caret-down.svg?react', () => ({
default: () => <div data-testid="caret-down" />,
Expand Down Expand Up @@ -139,6 +140,121 @@ describe('DatePicker Component', () => {
expect(screen.getByTestId(testId)).toBeInTheDocument()
})

describe('value and onChange', () => {
test('renders correct segments when given a Date value', () => {
renderDatePicker({ value: new Date(2026, 5, 15) })

const group = screen.getByRole('group', { name: /test date/i })
expect(within(group).getByRole('spinbutton', { name: /^month/i })).toHaveAttribute(
'aria-valuenow',
'6',
)
expect(within(group).getByRole('spinbutton', { name: /^day/i })).toHaveAttribute(
'aria-valuenow',
'15',
)
expect(within(group).getByRole('spinbutton', { name: /^year/i })).toHaveAttribute(
'aria-valuenow',
'2026',
)
})

test('onChange receives a Date at local midnight when user selects a date from the calendar', async () => {
const onChange = vi.fn<(value: Date | null) => void>()
// Provide a value so the calendar opens to June 2026
renderDatePicker({ value: new Date(2026, 5, 1), onChange })

await user.click(screen.getByRole('button'))
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())

// CalendarCell renders a <div role="button"> labeled with the full date
const june15 = screen
.getAllByRole('button')
.find(btn => btn.getAttribute('aria-label')?.includes('June 15'))
assertDefined(june15)
await user.click(june15)

expect(onChange).toHaveBeenCalled()
const received = onChange.mock.lastCall?.[0]
assertInstanceOf(received, Date)
expect(received.getFullYear()).toBe(2026)
expect(received.getMonth()).toBe(5) // June = index 5
expect(received.getDate()).toBe(15)
})

describe('timezone sensitivity', () => {
beforeEach(() => {
vi.stubEnv('TZ', 'Europe/Paris') // UTC+2 in summer: local midnight June 15 = UTC June 14 22:00
})

afterEach(() => {
vi.unstubAllEnvs()
})

it('renders the correct day segment when value is local midnight in a UTC+2 timezone', () => {
renderDatePicker({ value: new Date(2026, 5, 15) })
const group = screen.getByRole('group', { name: /test date/i })
expect(within(group).getByRole('spinbutton', { name: /^day/i })).toHaveAttribute(
'aria-valuenow',
'15',
)
})
})
})

describe('date constraints', () => {
test('minDate disables dates before the minimum in the calendar', async () => {
renderDatePicker({ value: new Date(2026, 5, 20), minDate: new Date(2026, 5, 15) })

await user.click(screen.getByRole('button'))
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())

const cells = screen.getAllByRole('gridcell')
const june8 = cells.find(c => c.textContent.trim() === '8')
assertDefined(june8)
expect(june8).toHaveAttribute('aria-disabled', 'true')

const june20 = cells.find(c => c.textContent.trim() === '20')
assertDefined(june20)
expect(june20).not.toHaveAttribute('aria-disabled')
})

test('maxDate disables dates after the maximum in the calendar', async () => {
renderDatePicker({ value: new Date(2026, 5, 10), maxDate: new Date(2026, 5, 15) })

await user.click(screen.getByRole('button'))
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())

const cells = screen.getAllByRole('gridcell')
const june25 = cells.find(c => c.textContent.trim() === '25')
assertDefined(june25)
expect(june25).toHaveAttribute('aria-disabled', 'true')

const june10 = cells.find(c => c.textContent.trim() === '10')
assertDefined(june10)
expect(june10).not.toHaveAttribute('aria-disabled')
})

test('isDateDisabled marks specific dates as unavailable in the calendar', async () => {
const isDateDisabled = (date: Date) =>
date.getFullYear() === 2026 && date.getMonth() === 5 && date.getDate() === 15

renderDatePicker({ value: new Date(2026, 5, 10), isDateDisabled })

await user.click(screen.getByRole('button'))
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())

const cells = screen.getAllByRole('gridcell')
const june15 = cells.find(c => c.textContent.trim() === '15')
assertDefined(june15)
expect(june15).toHaveAttribute('aria-disabled', 'true')

const june10 = cells.find(c => c.textContent.trim() === '10')
assertDefined(june10)
expect(june10).not.toHaveAttribute('aria-disabled')
})
})

describe('Accessibility', () => {
const testCases = [
{
Expand Down
Loading
Loading