streetmix/streetmix

View on GitHub
client/src/info_bubble/InfoBubbleControls/UpDownInput.test.tsx

Summary

Maintainability
A
2 hrs
Test Coverage
import React from 'react'
import { vi } from 'vitest'
import { render, screen, act, waitFor } from '@testing-library/react'
import { userEvent } from '@testing-library/user-event'

import UpDownInput from './UpDownInput'

// Mock the `debounce` method so that the debounced `onUpdatedValue` callback
// will be executed immediately when called (we are not implementing the
// debounce in this test)
vi.mock('just-debounce-it', () => ({
  default: vi.fn((fn) => fn)
}))

const handleUp = vi.fn()
const handleDown = vi.fn()
const handleUpdate = vi.fn()

const defaultProps = {
  value: 5,
  minValue: 1,
  maxValue: 10,
  onClickUp: handleUp,
  onClickDown: handleDown,
  onUpdatedValue: handleUpdate,
  upTooltip: 'up',
  downTooltip: 'down'
}

describe('UpDownInput', () => {
  afterEach(() => {
    handleUp.mockClear()
    handleDown.mockClear()
    handleUpdate.mockClear()
  })

  it('behaves', async () => {
    const user = userEvent.setup()

    render(<UpDownInput {...defaultProps} allowAutoUpdate={true} />)

    const inputEl = screen.getByRole<HTMLInputElement>('textbox')
    const upButton = screen.getByTitle('up')
    const downButton = screen.getByTitle('down')

    // Expect input value to be displayed
    expect(inputEl.value).toBe('5')

    // Ensure handler functions are called
    await user.click(upButton)
    expect(handleUp).toHaveBeenCalled()

    await user.click(downButton)
    expect(handleDown).toHaveBeenCalled()

    await user.type(inputEl, 'abc')
    expect(handleUpdate).toHaveBeenCalledTimes(3)
  })

  // If we don't handle nullish values, React throws a warning about
  // uncontrolled components, but that won't fail the test because the <input>
  // element has, by default, an empty string value when the value isn't
  // explicitly set. If we don't handle nullish values, we will see warnings
  // in the console log (rather than failures)
  it('renders empty string in input if the value prop is null', () => {
    render(<UpDownInput {...defaultProps} value={null} />)
    expect(screen.getByRole<HTMLInputElement>('textbox').value).toBe('')
  })

  it('formats values using `inputValueFormatter` and `displayValueFormatter`', async () => {
    const user = userEvent.setup()

    render(
      <UpDownInput
        {...defaultProps}
        inputValueFormatter={(value) => value.toString()}
        displayValueFormatter={(value) => `${value} bar`}
      />
    )

    // When first rendered, the display formatter is called
    const inputEl = screen.getByRole<HTMLInputElement>('textbox')
    expect(inputEl.value).toBe('5 bar')

    // User clicks on the input, so the input formatter is called
    await act(async () => {
      await user.click(inputEl)
    })
    expect(inputEl.value).toBe('5')

    // User clicks outside the input, setting active element elsewhere.
    // The display formatter should now be called again
    await user.click(inputEl.parentNode as Element)
    expect(inputEl.value).toBe('5 bar')
  })

  // Same as above, but with mouse interaction
  it('formats values when hovering over or out on the input element', async () => {
    const user = userEvent.setup()

    render(
      <UpDownInput
        {...defaultProps}
        inputValueFormatter={(value) => value.toString()}
        displayValueFormatter={(value) => `${value} bar`}
      />
    )

    // When first rendered, the display formatter is called
    const inputEl = screen.getByRole<HTMLInputElement>('textbox')
    expect(inputEl.value).toBe('5 bar')

    // User hovers over the input, so the input formatter is called
    // Note: the input content is also selected, but we are not testing for that
    await user.hover(inputEl)
    expect(inputEl.value).toBe('5')

    // User unhovers over the input
    // The display formatter should now be called again
    await user.unhover(inputEl)
    expect(inputEl.value).toBe('5 bar')
  })

  // Test fails (is only ever called with '5'), likely because there needs to
  // be a parent component controlling the input value state, not rendered
  // in this test right now.
  // However, this is working in practice.
  it.skip('handles "Enter" key as confirm action', async () => {
    const user = userEvent.setup()

    render(<UpDownInput {...defaultProps} />)

    const inputEl = screen.getByRole('textbox')

    await user.clear(inputEl)
    await user.type(inputEl, '3{enter}')

    await waitFor(() => {
      expect(handleUpdate).toHaveBeenLastCalledWith('3')
    })
  })

  it('handles "Escape" key to revert input', async () => {
    const user = userEvent.setup()

    render(<UpDownInput {...defaultProps} />)

    const inputEl = screen.getByRole('textbox')

    await user.clear(inputEl)
    await user.type(inputEl, '3{Escape}')

    // When this is reverted, the `handleUpdate` callback is called
    // with the original value (which has been cast to string, to match
    // the type of the value of the input element)
    expect(handleUpdate).toHaveBeenLastCalledWith('5')
  })

  it('renders inputs as disabled', () => {
    render(<UpDownInput {...defaultProps} disabled={true} />)

    const inputEl = screen.getByRole('textbox')
    const upButton = screen.getByTitle('up')
    const downButton = screen.getByTitle('down')

    expect(inputEl).toBeDisabled()
    expect(upButton).toBeDisabled()
    expect(downButton).toBeDisabled()
  })

  it('disables down button when value is the min value', () => {
    render(<UpDownInput {...defaultProps} value={1} />)

    const upButton = screen.getByTitle('up')
    const downButton = screen.getByTitle('down')

    expect(upButton).not.toBeDisabled()
    expect(downButton).toBeDisabled()
  })

  it('disables up button when value is the max value', () => {
    render(<UpDownInput {...defaultProps} value={10} />)

    const upButton = screen.getByTitle('up')
    const downButton = screen.getByTitle('down')

    expect(upButton).toBeDisabled()
    expect(downButton).not.toBeDisabled()
  })

  // Fixes a bug where dirty input could be remembered between different
  // types of UI interactions. This test is currently skipped, because
  // the input value is not being confirmed (see above test)
  it.skip('resets "dirty" input if user switches to +/- buttons', async () => {
    const user = userEvent.setup()

    render(<UpDownInput {...defaultProps} />)

    const inputEl = screen.getByRole<HTMLInputElement>('textbox')
    const upButton = screen.getByTitle('up')

    await user.clear(inputEl)
    await user.type(inputEl, '6{enter}')
    // NOTE: Test fails here
    expect(inputEl.value).toBe('6')

    await user.click(upButton)
    expect(inputEl.value).toBe('7')

    // Expect value on hover to reflect new value (7), not previous "dirty"
    // value (6)
    await user.hover(inputEl)
    expect(inputEl.value).toBe('7')

    // User unhovers over the input
    await user.unhover(inputEl)
    expect(inputEl.value).toBe('7')
  })
})