From af807009bacff705d0e3312578deb1a90a9a9824 Mon Sep 17 00:00:00 2001 From: Jethary Date: Sun, 31 Mar 2024 21:29:51 -0400 Subject: [PATCH] add some test coverage --- .../TipPositionField/TipPositionModal.tsx | 87 ++++++------ .../TipPositionField/ZTipPositionModal.tsx | 11 +- .../__tests__/TipPositionField.test.tsx | 112 ++++++++++++++++ .../__tests__/TipPositionModal.test.tsx | 124 ++++++++++++++++++ .../fields/TipPositionField/index.tsx | 16 ++- .../fields/TipPositionField/utils.ts | 2 +- .../src/localization/en/modal.json | 6 + 7 files changed, 300 insertions(+), 58 deletions(-) create mode 100644 protocol-designer/src/components/StepEditForm/fields/TipPositionField/__tests__/TipPositionField.test.tsx create mode 100644 protocol-designer/src/components/StepEditForm/fields/TipPositionField/__tests__/TipPositionModal.test.tsx diff --git a/protocol-designer/src/components/StepEditForm/fields/TipPositionField/TipPositionModal.tsx b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/TipPositionModal.tsx index 1b48bd28be1..0d79a39ae9a 100644 --- a/protocol-designer/src/components/StepEditForm/fields/TipPositionField/TipPositionModal.tsx +++ b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/TipPositionModal.tsx @@ -26,7 +26,7 @@ type Offset = 'x' | 'y' | 'z' interface PositionSpec { name: StepFieldName value: number | null - updateValue: (val: number | null | undefined) => void + updateValue: (val?: number | null) => void } export type PositionSpecs = Record @@ -108,24 +108,17 @@ export const TipPositionModal = ( wellXWidthMm ) - const zErrors = utils.getErrors({ - isDefault, - minMm: minMmFromBottom, - maxMm: maxMmFromBottom, - value: zValue, - }) - const xErrors = utils.getErrors({ - isDefault, - minMm: xMinWidth, - maxMm: xMaxWidth, - value: xValue, - }) - const yErrors = utils.getErrors({ - isDefault, - minMm: yMinWidth, - maxMm: yMaxWidth, - value: yValue, - }) + const createErrors = ( + value: string | null, + min: number, + max: number + ): utils.Error[] => { + return utils.getErrors({ isDefault, minMm: min, maxMm: max, value }) + } + const zErrors = createErrors(zValue, minMmFromBottom, maxMmFromBottom) + const xErrors = createErrors(xValue, xMinWidth, xMaxWidth) + const yErrors = createErrors(yValue, yMinWidth, yMaxWidth) + const hasErrors = zErrors.length > 0 || xErrors.length > 0 || yErrors.length > 0 const hasVisibleErrors = isPristine @@ -134,33 +127,20 @@ export const TipPositionModal = ( yErrors.includes(TOO_MANY_DECIMALS) : hasErrors - const zErrorText = utils.getErrorText({ - errors: zErrors, - maxMm: maxMmFromBottom, - minMm: minMmFromBottom, - isPristine, - t, - }) + const createErrorText = ( + errors: utils.Error[], + min: number, + max: number + ): string | null => { + return utils.getErrorText({ errors, minMm: min, maxMm: max, isPristine, t }) + } - const xErrorText = utils.getErrorText({ - errors: xErrors, - minMm: xMinWidth, - maxMm: xMaxWidth, - isPristine, - t, - }) - - const yErrorText = utils.getErrorText({ - errors: yErrors, - minMm: yMinWidth, - maxMm: yMaxWidth, - isPristine, - t, - }) + const zErrorText = createErrorText(zErrors, minMmFromBottom, maxMmFromBottom) + const xErrorText = createErrorText(xErrors, xMinWidth, xMaxWidth) + const yErrorText = createErrorText(yErrors, yMinWidth, yMaxWidth) const handleDone = (): void => { setPristine(false) - if (!hasErrors) { if (isDefault) { zSpec?.updateValue(null) @@ -246,7 +226,10 @@ export const TipPositionModal = ( {t('tip_position.field_titles.x_position')} { const TipPositionInputField = !isDefault && ( { }} options={[ { - name: `${defaultMmFromBottom} mm from the bottom (default)`, + name: t('tip_position.radio_button.default', { + defaultMmFromBottom, + }), value: 'default', }, { - name: 'Custom', + name: t('tip_position.radio_button.custom'), value: 'custom', }, ]} diff --git a/protocol-designer/src/components/StepEditForm/fields/TipPositionField/__tests__/TipPositionField.test.tsx b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/__tests__/TipPositionField.test.tsx new file mode 100644 index 00000000000..de91943de75 --- /dev/null +++ b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/__tests__/TipPositionField.test.tsx @@ -0,0 +1,112 @@ +import * as React from 'react' +import { fireEvent, screen } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderWithProviders } from '../../../../../__testing-utils__' +import { i18n } from '../../../../../localization' +import { TipPositionModal } from '../TipPositionModal' +import { TipPositionField } from '../index' +import { getLabwareEntities } from '../../../../../step-forms/selectors' +import { LabwareDefinition2, fixture96Plate } from '@opentrons/shared-data' +import { ZTipPositionModal } from '../ZTipPositionModal' + +vi.mock('../../../../../step-forms/selectors') +vi.mock('../ZTipPositionModal') +vi.mock('../TipPositionModal') +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} +const mockDelay = 'aspirate_delay_mmFromBottom' +const mockAspirate = 'aspirate_mmFromBottom' +const mockLabwareId = 'mockId' +describe('TipPositionField', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + zField: mockDelay, + labwareId: mockLabwareId, + propsForFields: { + [mockDelay]: { + name: mockDelay, + value: null, + updateValue: vi.fn(), + tooltipContent: 'mock content', + isIndeterminate: false, + disabled: false, + } as any, + }, + } + vi.mocked(TipPositionModal).mockReturnValue( +
mock TipPositionModal
+ ) + vi.mocked(ZTipPositionModal).mockReturnValue( +
mock ZTipPositionModal
+ ) + vi.mocked(getLabwareEntities).mockReturnValue({ + [mockLabwareId]: { + id: mockLabwareId, + labwareDefURI: 'mock uri', + def: fixture96Plate as LabwareDefinition2, + }, + }) + }) + it('renders the input field and header when x and y fields are not provided', () => { + render(props) + screen.getByText('mm') + fireEvent.click(screen.getByRole('textbox', { name: '' })) + expect(screen.getByRole('textbox', { name: '' })).not.toBeDisabled() + screen.getByText('mock ZTipPositionModal') + }) + it('renders the input field but it is disabled', () => { + props = { + ...props, + propsForFields: { + [mockDelay]: { + name: mockDelay, + value: null, + updateValue: vi.fn(), + tooltipContent: 'mock content', + isIndeterminate: false, + disabled: true, + } as any, + }, + } + render(props) + expect(screen.getByRole('textbox', { name: '' })).toBeDisabled() + }) + it('renders the icon when x,y, and z fields are provided', () => { + const mockX = 'aspirate_x_position' + const mockY = 'aspirate_y_position' + props = { + zField: mockAspirate, + xField: mockX, + yField: mockY, + labwareId: mockLabwareId, + propsForFields: { + [mockAspirate]: { + name: mockAspirate, + value: null, + updateValue: vi.fn(), + tooltipContent: 'mock content', + isIndeterminate: false, + disabled: false, + } as any, + [mockX]: { + name: mockX, + value: null, + updateValue: vi.fn(), + } as any, + [mockY]: { + name: mockY, + value: null, + updateValue: vi.fn(), + } as any, + }, + } + render(props) + fireEvent.click(screen.getByTestId('TipPositionIcon_aspirate_mmFromBottom')) + screen.getByText('mock TipPositionModal') + }) +}) diff --git a/protocol-designer/src/components/StepEditForm/fields/TipPositionField/__tests__/TipPositionModal.test.tsx b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/__tests__/TipPositionModal.test.tsx new file mode 100644 index 00000000000..5fccf40a480 --- /dev/null +++ b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/__tests__/TipPositionModal.test.tsx @@ -0,0 +1,124 @@ +import * as React from 'react' +import { fireEvent, screen } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderWithProviders } from '../../../../../__testing-utils__' +import { i18n } from '../../../../../localization' +import { TipPositionModal } from '../TipPositionModal' +import { TipPositionAllViz } from '../TipPositionAllViz' + +vi.mock('../TipPositionAllViz') +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +const mockUpdateZSpec = vi.fn() +const mockUpdateXSpec = vi.fn() +const mockUpdateYSpec = vi.fn() + +describe('TipPositionModal', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + closeModal: vi.fn(), + wellDepthMm: 50, + wellXWidthMm: 10.3, + wellYWidthMm: 10.5, + isIndeterminate: false, + specs: { + z: { + name: 'aspirate_mmFromBottom', + value: null, + updateValue: mockUpdateZSpec, + }, + y: { + name: 'aspirate_y_position', + value: 0, + updateValue: mockUpdateXSpec, + }, + x: { + name: 'aspirate_x_position', + value: 0, + updateValue: mockUpdateYSpec, + }, + }, + } + vi.mocked(TipPositionAllViz).mockReturnValue(
mock TipPositionViz
) + }) + it('renders the modal text and radio button text', () => { + render(props) + screen.getByText('Tip Positioning') + screen.getByText('Change from where in the well the robot aspirates') + screen.getByRole('radio', { name: '1 mm from the bottom center (default)' }) + screen.getByRole('radio', { name: 'Custom' }) + fireEvent.click(screen.getByText('cancel')) + expect(props.closeModal).toHaveBeenCalled() + fireEvent.click(screen.getByText('done')) + expect(props.closeModal).toHaveBeenCalled() + expect(mockUpdateXSpec).toHaveBeenCalled() + expect(mockUpdateYSpec).toHaveBeenCalled() + expect(mockUpdateZSpec).toHaveBeenCalled() + }) + it('renders the custom options, captions, and visual', () => { + render(props) + fireEvent.click(screen.getByRole('radio', { name: 'Custom' })) + expect(screen.getAllByRole('textbox', { name: '' })).toHaveLength(3) + screen.getByText('X position') + screen.getByText('between -5.15 and 5.15') + screen.getByText('Y position') + screen.getByText('between -5.25 and 5.25') + screen.getByText('Z position') + screen.getByText('between 0 and 100') + screen.getByText('mock TipPositionViz') + }) + it('renders a custom input field and clicks on it, calling the mock updates', () => { + render(props) + fireEvent.click(screen.getByRole('radio', { name: 'Custom' })) + const xInputField = screen.getAllByRole('textbox', { name: '' })[0] + fireEvent.change(xInputField, { target: { value: 3 } }) + const yInputField = screen.getAllByRole('textbox', { name: '' })[1] + fireEvent.change(yInputField, { target: { value: -2 } }) + const zInputField = screen.getAllByRole('textbox', { name: '' })[2] + fireEvent.change(zInputField, { target: { value: 10 } }) + fireEvent.click(screen.getByText('done')) + expect(props.closeModal).toHaveBeenCalled() + expect(mockUpdateXSpec).toHaveBeenCalled() + expect(mockUpdateYSpec).toHaveBeenCalled() + expect(mockUpdateZSpec).toHaveBeenCalled() + }) + it('renders custom input fields and displays error texts', () => { + props = { + ...props, + specs: { + z: { + name: 'aspirate_mmFromBottom', + value: 101, + updateValue: mockUpdateZSpec, + }, + y: { + name: 'aspirate_y_position', + value: -500, + updateValue: mockUpdateXSpec, + }, + x: { + name: 'aspirate_x_position', + value: 10.7, + updateValue: mockUpdateYSpec, + }, + }, + } + render(props) + fireEvent.click(screen.getByText('done')) + // display out of bounds error + screen.getByText('accepted range is 0 to 100') + screen.getByText('accepted range is -5.25 to 5.25') + screen.getByText('accepted range is -5.15 to 5.15') + const xInputField = screen.getAllByRole('textbox', { name: '' })[0] + fireEvent.change(xInputField, { target: { value: 3.55555 } }) + fireEvent.click(screen.getByText('done')) + // display too many decimals error + screen.getByText('a max of 1 decimal place is allowed') + }) +}) diff --git a/protocol-designer/src/components/StepEditForm/fields/TipPositionField/index.tsx b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/index.tsx index c4e9d457718..9c10d98d3ce 100644 --- a/protocol-designer/src/components/StepEditForm/fields/TipPositionField/index.tsx +++ b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/index.tsx @@ -55,7 +55,7 @@ export function TipPositionField(props: TipPositionFieldProps): JSX.Element { const { t } = useTranslation('application') const [targetProps, tooltipProps] = useHoverTooltip() - const [isModalOpen, setModalOpen] = React.useState(false) + const [isModalOpen, setModalOpen] = React.useState(false) const labwareEntities = useSelector(stepFormSelectors.getLabwareEntities) const labwareDef = labwareId != null && labwareEntities[labwareId] != null @@ -65,6 +65,7 @@ export function TipPositionField(props: TipPositionFieldProps): JSX.Element { let wellDepthMm = 0 let wellXWidthMm = 0 let wellYWidthMm = 0 + if (labwareDef != null) { // NOTE: only taking depth of first well in labware def, UI not currently equipped for multiple depths/widths const firstWell = labwareDef.wells.A1 @@ -85,8 +86,11 @@ export function TipPositionField(props: TipPositionFieldProps): JSX.Element { ) } - const handleOpen = (): void => { - if (wellDepthMm && wellXWidthMm && wellYWidthMm) { + const handleOpen = (has3Specs: boolean): void => { + if (has3Specs && wellDepthMm && wellXWidthMm && wellYWidthMm) { + setModalOpen(true) + } + if (!has3Specs && wellDepthMm) { setModalOpen(true) } } @@ -156,7 +160,6 @@ export function TipPositionField(props: TipPositionFieldProps): JSX.Element { /> ) } - console.log('disabled', disabled) return ( <> @@ -170,8 +173,9 @@ export function TipPositionField(props: TipPositionFieldProps): JSX.Element { > {yField != null && xField != null ? ( handleOpen(true) : undefined} id={`TipPositionIcon_${zName}`} + data-testid={`TipPositionIcon_${zName}`} width="5rem" > handleOpen(false)} value={String(zValue)} isIndeterminate={isIndeterminate} units={t('units.millimeter')} diff --git a/protocol-designer/src/components/StepEditForm/fields/TipPositionField/utils.ts b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/utils.ts index e3fc3cf6d60..5db860b0313 100644 --- a/protocol-designer/src/components/StepEditForm/fields/TipPositionField/utils.ts +++ b/protocol-designer/src/components/StepEditForm/fields/TipPositionField/utils.ts @@ -51,7 +51,7 @@ export const roundValue = (value: number | string | null): number => { } const OUT_OF_BOUNDS: 'OUT_OF_BOUNDS' = 'OUT_OF_BOUNDS' -type Error = typeof TOO_MANY_DECIMALS | typeof OUT_OF_BOUNDS +export type Error = typeof TOO_MANY_DECIMALS | typeof OUT_OF_BOUNDS export const getErrorText = (args: { errors: Error[] diff --git a/protocol-designer/src/localization/en/modal.json b/protocol-designer/src/localization/en/modal.json index c6372cba279..03e92e5ea55 100644 --- a/protocol-designer/src/localization/en/modal.json +++ b/protocol-designer/src/localization/en/modal.json @@ -61,6 +61,12 @@ }, "tip_position": { "title": "Tip Positioning", + "caption": "between {{min}} and {{max}}", + "radio_button": { + "default": "{{defaultMmFromBottom}} mm from the bottom center (default)", + "mix": "Aspirate 1mm, Dispense 0.5mm from the bottom center (default)", + "custom": "Custom" + }, "body": { "aspirate_mmFromBottom": "Change from where in the well the robot aspirates", "dispense_mmFromBottom": "Change from where in the well the robot dispenses",