Skip to content

Commit

Permalink
fix(app): Handle Unsafe Move to Plunger during Drop-Tip (#14910)
Browse files Browse the repository at this point in the history
Closes EXEC-186

If the gantry is not homed and a powercycle occurs, drop-tip wizard cannot proceed with flows. An error is raised during the flow, and ultimately a home command is dispatched that has the side effect of potentially aspirating liquid into the pipette, damaging it. We special case home errors to prevent this. 

The primary functional difference is now that any time an error occurs, exiting the wizard via the header should not home the gantry. Homing as a result of an error should only occur when the "Confirm removal and home" button is presented and clicked.
  • Loading branch information
mjhuff authored and DerekMaggio committed Apr 16, 2024
1 parent 48001ac commit 6a222c1
Show file tree
Hide file tree
Showing 9 changed files with 601 additions and 248 deletions.
3 changes: 3 additions & 0 deletions app/src/assets/localization/en/drop_tip_wizard.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
"begin_removal": "Begin removal",
"blowout_complete": "blowout complete",
"blowout_liquid": "Blow out liquid",
"cant_safely_drop_tips": "Can't safely drop tips",
"choose_blowout_location": "choose blowout location",
"choose_drop_tip_location": "choose tip-drop location",
"confirm_blowout_location": "Is the pipette positioned where the liquids should be blown out?",
"confirm_drop_tip_location": "Is the pipette positioned where the tips should be dropped?",
"confirm_removal_and_home": "Confirm removal and home",
"drop_tip_complete": "tip drop complete",
"drop_tip_failed": "The drop tip could not be completed. Contact customer support for assistance.",
"drop_tips": "drop tips",
Expand All @@ -21,6 +23,7 @@
"position_the_pipette": "position the pipette",
"remove_the_tips": "You may want to remove the tips from the <strong>{{mount}} Pipette</strong> before using it again in a protocol.",
"remove_the_tips_from_pipette": "You may want to remove the tips from the pipette before using it again in a protocol.",
"remove_the_tips_manually": "Remove the tips manually. Then home the gantry. Homing with tips attached could pull liquid into the pipette and damage it.",
"remove_tips": "Remove tips",
"select_blowout_slot": "<block>You can blow out liquid into a labware or dispose of it.</block><block>Select the slot where you want to blow out the liquid on the deck map to the right. Once confirmed, the gantry will move to the chosen slot.</block>",
"select_blowout_slot_odd": "<block>You can blow out liquid into a labware or dispose of it.</block><br/><block>After the gantry moves to the chosen slot, use the jog controls to move the pipette to the exact position for blowing out.</block>",
Expand Down
24 changes: 3 additions & 21 deletions app/src/organisms/DropTipWizard/BeforeBeginning.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,30 +24,20 @@ import {

import { SmallButton, MediumButton } from '../../atoms/buttons'
import { InProgressModal } from '../../molecules/InProgressModal/InProgressModal'
// import { NeedHelpLink } from '../CalibrationPanels'

import blowoutVideo from '../../assets/videos/droptip-wizard/Blowout-Liquid.webm'
import droptipVideo from '../../assets/videos/droptip-wizard/Drop-tip.webm'

// TODO: get help link article URL
// const NEED_HELP_URL = ''

interface BeforeBeginningProps {
setShouldDispenseLiquid: (shouldDispenseLiquid: boolean) => void
createdMaintenanceRunId: string | null
isOnDevice: boolean
isRobotMoving: boolean
}

export const BeforeBeginning = (
props: BeforeBeginningProps
): JSX.Element | null => {
const {
setShouldDispenseLiquid,
createdMaintenanceRunId,
isOnDevice,
isRobotMoving,
} = props
const { setShouldDispenseLiquid, createdMaintenanceRunId, isOnDevice } = props
const { i18n, t } = useTranslation(['drop_tip_wizard', 'shared'])
const [flowType, setFlowType] = React.useState<
'liquid_and_tips' | 'only_tips' | null
Expand All @@ -57,16 +47,8 @@ export const BeforeBeginning = (
setShouldDispenseLiquid(flowType === 'liquid_and_tips')
}

if (isRobotMoving || createdMaintenanceRunId == null) {
return (
<InProgressModal
description={
createdMaintenanceRunId == null
? t('getting_ready')
: t('stand_back_exiting')
}
/>
)
if (createdMaintenanceRunId == null) {
return <InProgressModal description={t('getting_ready')} />
}

if (isOnDevice) {
Expand Down
16 changes: 4 additions & 12 deletions app/src/organisms/DropTipWizard/ChooseLocation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,13 @@ import {
import { getDeckDefFromRobotType } from '@opentrons/shared-data'

import { SmallButton } from '../../atoms/buttons'
import { InProgressModal } from '../../molecules/InProgressModal/InProgressModal'
// import { NeedHelpLink } from '../CalibrationPanels'
import { TwoUpTileLayout } from '../LabwarePositionCheck/TwoUpTileLayout'

import type { CommandData } from '@opentrons/api-client'
import type { AddressableAreaName, RobotType } from '@opentrons/shared-data'
import type { ErrorDetails } from './utils'

// TODO: get help link article URL
// const NEED_HELP_URL = ''

interface ChooseLocationProps {
handleProceed: () => void
Expand All @@ -41,9 +39,8 @@ interface ChooseLocationProps {
moveToAddressableArea: (
addressableArea: AddressableAreaName
) => Promise<CommandData | null>
isRobotMoving: boolean
isOnDevice: boolean
setErrorMessage: (arg0: string) => void
setErrorDetails: (errorDetails: ErrorDetails) => void
}

export const ChooseLocation = (
Expand All @@ -56,9 +53,8 @@ export const ChooseLocation = (
body,
robotType,
moveToAddressableArea,
isRobotMoving,
isOnDevice,
setErrorMessage,
setErrorDetails,
} = props
const { i18n, t } = useTranslation(['drop_tip_wizard', 'shared'])
const deckDef = getDeckDefFromRobotType(robotType)
Expand All @@ -74,14 +70,10 @@ export const ChooseLocation = (
if (deckSlot != null) {
moveToAddressableArea(deckSlot)
.then(() => handleProceed())
.catch(e => setErrorMessage(`${e.message}`))
.catch(e => setErrorDetails({ message: `${e.message}` }))
}
}

if (isRobotMoving) {
return <InProgressModal description={t('stand_back_exiting')} />
}

if (isOnDevice) {
return (
<Flex
Expand Down
8 changes: 1 addition & 7 deletions app/src/organisms/DropTipWizard/ExitConfirmation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,20 @@ import {
} from '@opentrons/components'
import { getIsOnDevice } from '../../redux/config'
import { SimpleWizardBody } from '../../molecules/SimpleWizardBody'
import { InProgressModal } from '../../molecules/InProgressModal/InProgressModal'
import { SmallButton } from '../../atoms/buttons'

interface ExitConfirmationProps {
handleExit: () => void
handleGoBack: () => void
isRobotMoving: boolean
}

export function ExitConfirmation(props: ExitConfirmationProps): JSX.Element {
const { handleGoBack, handleExit, isRobotMoving } = props
const { handleGoBack, handleExit } = props
const { i18n, t } = useTranslation(['drop_tip_wizard', 'shared'])

const flowTitle = t('drop_tips')
const isOnDevice = useSelector(getIsOnDevice)

if (isRobotMoving) {
return <InProgressModal description={t('stand_back_exiting')} />
}

return (
<SimpleWizardBody
iconColor={COLORS.yellow50}
Expand Down
11 changes: 2 additions & 9 deletions app/src/organisms/DropTipWizard/JogToPosition.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,6 @@ interface JogToPositionProps {
handleJog: Jog
handleProceed: () => void
body: string
isRobotMoving: boolean
currentStep: string
isOnDevice: boolean
}
Expand All @@ -161,7 +160,6 @@ export const JogToPosition = (
handleJog,
handleProceed,
body,
isRobotMoving,
currentStep,
isOnDevice,
} = props
Expand All @@ -171,10 +169,10 @@ export const JogToPosition = (
setShowPositionConfirmation,
] = React.useState(false)
// Includes special case homing only present in this step.
const [isRobotInMotion, setIsRobotInMotion] = React.useState(isRobotMoving)
const [isRobotInMotion, setIsRobotInMotion] = React.useState(false)

const onGoBack = (): void => {
setIsRobotInMotion(() => true)
setIsRobotInMotion(true)
handleGoBack()
}

Expand All @@ -201,11 +199,6 @@ export const JogToPosition = (
)
}

// Moving due to "Exit" or "Go back" click.
if (isRobotInMotion) {
return <InProgressModal description={t('stand_back_exiting')} />
}

if (isOnDevice) {
return (
<Flex
Expand Down
131 changes: 131 additions & 0 deletions app/src/organisms/DropTipWizard/__tests__/utils.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { screen, render, fireEvent } from '@testing-library/react'

import { useDropTipErrorComponents, useWizardExitHeader } from '../utils'
import { DROP_TIP_SPECIAL_ERROR_TYPES } from '../constants'

import type { Mock } from 'vitest'
import type {
UseDropTipErrorComponentsProps,
UseWizardExitHeaderProps,
} from '../utils'

const MOCK_MAINTENANCE_RUN_ID = 'MOCK_MAINTENANCE_RUN_ID'
const MOCK_ERROR_TYPE = 'MOCK_ERROR_TYPE'
const MOCK_ERROR_MESSAGE = 'MOCK_ERROR_MESSAGE'
const MOCK_ERROR_HEADER = 'MOCK_ERROR_HEADER'

describe('useDropTipErrorComponents', () => {
let props: UseDropTipErrorComponentsProps
let mockOnClose: Mock
let mockTranslation: Mock
let mockChainRunCommands: Mock

beforeEach(() => {
mockOnClose = vi.fn()
mockTranslation = vi.fn()
mockChainRunCommands = vi.fn()

props = {
maintenanceRunId: MOCK_MAINTENANCE_RUN_ID,
onClose: mockOnClose,
errorDetails: {
type: MOCK_ERROR_TYPE,
message: MOCK_ERROR_MESSAGE,
header: MOCK_ERROR_HEADER,
},
isOnDevice: true,
t: mockTranslation,
chainRunCommands: mockChainRunCommands,
}
})

it('should return the generic text and error message if there is are no special-cased error details', () => {
const result = useDropTipErrorComponents(props)
expect(result.button).toBeNull()
render(result.subHeader)
expect(mockTranslation).toHaveBeenCalledWith('drop_tip_failed')
screen.getByText(MOCK_ERROR_MESSAGE)
})

it('should return a generic message only if there are no error details', () => {
props.errorDetails = null
const result = useDropTipErrorComponents(props)
expect(result.button).toBeNull()
render(result.subHeader)
expect(mockTranslation).toHaveBeenCalledWith('drop_tip_failed')
expect(screen.queryByText(MOCK_ERROR_MESSAGE)).not.toBeInTheDocument()
})

it(`should return correct special components if error type is ${DROP_TIP_SPECIAL_ERROR_TYPES.MUST_HOME_ERROR}`, () => {
// @ts-expect-error errorDetails is in fact not null in the test.
props.errorDetails.type = DROP_TIP_SPECIAL_ERROR_TYPES.MUST_HOME_ERROR
const result = useDropTipErrorComponents(props)
expect(mockTranslation).toHaveBeenCalledWith('confirm_removal_and_home')

render(result.button)
const btn = screen.getByRole('button')
fireEvent.click(btn)
expect(mockOnClose).toHaveBeenCalled()
expect(mockChainRunCommands).toHaveBeenCalledWith(
MOCK_MAINTENANCE_RUN_ID,
[
{
commandType: 'home' as const,
params: {},
},
],
true
)

render(result.subHeader)
screen.getByText(MOCK_ERROR_MESSAGE)
})
})

describe('useWizardExitHeader', () => {
let props: UseWizardExitHeaderProps
let mockHandleCleanUpAndClose: Mock
let mockConfirmExit: Mock

beforeEach(() => {
mockHandleCleanUpAndClose = vi.fn()
mockConfirmExit = vi.fn()

props = {
isFinalStep: true,
hasInitiatedExit: false,
errorDetails: null,
handleCleanUpAndClose: mockHandleCleanUpAndClose,
confirmExit: mockConfirmExit,
}
})

it('should appropriately return handleCleanUpAndClose', () => {
const handleExit = useWizardExitHeader(props)
expect(handleExit).toEqual(props.handleCleanUpAndClose)
})

it('should appropriately return confirmExit', () => {
props = { ...props, isFinalStep: false }
const handleExit = useWizardExitHeader(props)
expect(handleExit).toEqual(props.confirmExit)
})

it('should appropriately return handleCleanUpAndClose with homeOnError = false', () => {
const errorDetails = { message: 'Some error occurred' }
const modifiedProps = { ...props, errorDetails }
const handleExit = useWizardExitHeader(modifiedProps)
expect(mockHandleCleanUpAndClose.mock.calls.length).toBe(0)
handleExit()
expect(mockHandleCleanUpAndClose).toHaveBeenCalledWith(false)
})

it('should appropriately return a function that does nothing ', () => {
const modifiedProps = { ...props, hasInitiatedExit: true }
const handleExit = useWizardExitHeader(modifiedProps)
handleExit()
expect(mockHandleCleanUpAndClose.mock.calls.length).toBe(0)
expect(mockConfirmExit.mock.calls.length).toBe(0)
})
})
4 changes: 4 additions & 0 deletions app/src/organisms/DropTipWizard/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,7 @@ export const DROP_TIP_STEPS = [
POSITION_AND_DROP_TIP,
DROP_TIP_SUCCESS,
]

export const DROP_TIP_SPECIAL_ERROR_TYPES = {
MUST_HOME_ERROR: 'MustHomeError',
} as const
Loading

0 comments on commit 6a222c1

Please sign in to comment.