diff --git a/api/src/opentrons/protocol_engine/__init__.py b/api/src/opentrons/protocol_engine/__init__.py index 22d04749c75..2538b67daf5 100644 --- a/api/src/opentrons/protocol_engine/__init__.py +++ b/api/src/opentrons/protocol_engine/__init__.py @@ -18,7 +18,15 @@ CommandType, CommandIntent, ) -from .state import State, StateView, StateSummary, CommandSlice, CommandPointer, Config +from .state import ( + State, + StateView, + StateSummary, + CommandSlice, + CommandPointer, + Config, + CommandErrorSlice, +) from .plugins import AbstractPlugin from .types import ( @@ -81,6 +89,7 @@ "State", "StateView", "CommandSlice", + "CommandErrorSlice", "CommandPointer", # public value interfaces and models "LabwareOffset", diff --git a/api/src/opentrons/protocol_engine/state/__init__.py b/api/src/opentrons/protocol_engine/state/__init__.py index 80a88350263..f9705905967 100644 --- a/api/src/opentrons/protocol_engine/state/__init__.py +++ b/api/src/opentrons/protocol_engine/state/__init__.py @@ -7,6 +7,7 @@ CommandState, CommandView, CommandSlice, + CommandErrorSlice, CommandPointer, ) from .command_history import CommandEntry @@ -39,6 +40,7 @@ "CommandState", "CommandView", "CommandSlice", + "CommandErrorSlice", "CommandPointer", "CommandEntry", # labware state and values diff --git a/api/src/opentrons/protocol_engine/state/commands.py b/api/src/opentrons/protocol_engine/state/commands.py index 1ad17867450..c725c561ac3 100644 --- a/api/src/opentrons/protocol_engine/state/commands.py +++ b/api/src/opentrons/protocol_engine/state/commands.py @@ -117,6 +117,15 @@ class CommandSlice: total_length: int +@dataclass(frozen=True) +class CommandErrorSlice: + """A subset of all commands errors in state.""" + + commands_errors: List[ErrorOccurrence] + cursor: int + total_length: int + + @dataclass(frozen=True) class CommandPointer: """Brief info about a command and where to find it.""" @@ -619,6 +628,26 @@ def get_slice( total_length=total_length, ) + def get_errors_slice( + self, + cursor: int, + length: int, + ) -> CommandErrorSlice: + """Get a subset of commands error around a given cursor.""" + # start is inclusive, stop is exclusive + all_errors = self.get_all_errors() + total_length = len(all_errors) + actual_cursor = max(0, min(cursor, total_length - 1)) + stop = min(total_length, actual_cursor + length) + + sliced_errors = all_errors[actual_cursor:stop] + + return CommandErrorSlice( + commands_errors=sliced_errors, + cursor=actual_cursor, + total_length=total_length, + ) + def get_error(self) -> Optional[ErrorOccurrence]: """Get the run's fatal error, if there was one.""" run_error = self._state.run_error diff --git a/api/src/opentrons/protocol_runner/run_orchestrator.py b/api/src/opentrons/protocol_runner/run_orchestrator.py index 7c748598f21..0dc57e0ba1f 100644 --- a/api/src/opentrons/protocol_runner/run_orchestrator.py +++ b/api/src/opentrons/protocol_runner/run_orchestrator.py @@ -21,7 +21,9 @@ StateSummary, CommandPointer, CommandSlice, + CommandErrorSlice, DeckType, + ErrorOccurrence, ) from ..protocol_engine.errors import RunStoppedError from ..protocol_engine.types import ( @@ -269,6 +271,23 @@ def get_command_slice( cursor=cursor, length=length ) + def get_command_error_slice( + self, + cursor: int, + length: int, + ) -> CommandErrorSlice: + """Get a slice of run commands errors. + + Args: + cursor: Requested index of first error in the returned slice. + If the cursor is omitted, a cursor will be selected automatically + based on the last error occurence. + length: Length of slice to return. + """ + return self._protocol_engine.state_view.commands.get_errors_slice( + cursor=cursor, length=length + ) + def get_command_recovery_target(self) -> Optional[CommandPointer]: """Get the current error recovery target.""" return self._protocol_engine.state_view.commands.get_recovery_target() @@ -281,6 +300,10 @@ def get_all_commands(self) -> List[Command]: """Get all run commands.""" return self._protocol_engine.state_view.commands.get_all() + def get_command_errors(self) -> List[ErrorOccurrence]: + """Get all run command errors.""" + return self._protocol_engine.state_view.commands.get_all_errors() + def get_run_status(self) -> EngineStatus: """Get the current execution status of the engine.""" return self._protocol_engine.state_view.commands.get_status() diff --git a/api/tests/opentrons/protocol_engine/state/test_command_view_old.py b/api/tests/opentrons/protocol_engine/state/test_command_view_old.py index 2b86fe9259f..5aa7d04a2ee 100644 --- a/api/tests/opentrons/protocol_engine/state/test_command_view_old.py +++ b/api/tests/opentrons/protocol_engine/state/test_command_view_old.py @@ -25,6 +25,7 @@ CommandState, CommandView, CommandSlice, + CommandErrorSlice, CommandPointer, RunResult, QueueStatus, @@ -903,7 +904,7 @@ def test_get_current() -> None: def test_get_slice_empty() -> None: """It should return a slice from the tail if no current command.""" subject = get_command_view(commands=[]) - result = subject.get_slice(cursor=None, length=2) + result = subject.get_slice(cursor=0, length=2) assert result == CommandSlice(commands=[], cursor=0, total_length=0) @@ -1005,24 +1006,37 @@ def test_get_slice_default_cursor_running() -> None: ) -def test_get_slice_default_cursor_queued() -> None: - """It should select a cursor automatically.""" - command_1 = create_succeeded_command(command_id="command-id-1") - command_2 = create_succeeded_command(command_id="command-id-2") - command_3 = create_succeeded_command(command_id="command-id-3") - command_4 = create_queued_command(command_id="command-id-4") - command_5 = create_queued_command(command_id="command-id-5") +def test_get_errors_slice_empty() -> None: + """It should return a slice from the tail if no current command.""" + subject = get_command_view(failed_command_errors=[]) + result = subject.get_errors_slice(cursor=0, length=2) + + assert result == CommandErrorSlice(commands_errors=[], cursor=0, total_length=0) + + +def test_get_errors_slice() -> None: + """It should return a slice of all command errors.""" + error_1 = ErrorOccurrence.construct(id="error-id-1") # type: ignore[call-arg] + error_2 = ErrorOccurrence.construct(id="error-id-2") # type: ignore[call-arg] + error_3 = ErrorOccurrence.construct(id="error-id-3") # type: ignore[call-arg] + error_4 = ErrorOccurrence.construct(id="error-id-4") # type: ignore[call-arg] subject = get_command_view( - commands=[command_1, command_2, command_3, command_4, command_5], - running_command_id=None, - queued_command_ids=[command_4.id, command_5.id], + failed_command_errors=[error_1, error_2, error_3, error_4] ) - result = subject.get_slice(cursor=None, length=2) + result = subject.get_errors_slice(cursor=1, length=3) - assert result == CommandSlice( - commands=[command_3, command_4], - cursor=2, - total_length=5, + assert result == CommandErrorSlice( + commands_errors=[error_2, error_3, error_4], + cursor=1, + total_length=4, + ) + + result = subject.get_errors_slice(cursor=-3, length=10) + + assert result == CommandErrorSlice( + commands_errors=[error_1, error_2, error_3, error_4], + cursor=0, + total_length=4, ) diff --git a/app/src/assets/localization/en/drop_tip_wizard.json b/app/src/assets/localization/en/drop_tip_wizard.json index 4bedd4bc8e6..fe673268a50 100644 --- a/app/src/assets/localization/en/drop_tip_wizard.json +++ b/app/src/assets/localization/en/drop_tip_wizard.json @@ -1,7 +1,7 @@ { "before_you_begin_do_you_want_to_blowout": "Before you begin, do you need to preserve aspirated liquid?", "begin_removal": "Begin removal", - "blowout_complete": "blowout complete", + "blowout_complete": "Blowout complete", "blowout_liquid": "Blow out liquid", "cant_safely_drop_tips": "Can't safely drop tips", "choose_blowout_location": "choose blowout location", @@ -9,7 +9,7 @@ "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_complete": "Tip drop complete", "drop_tip_failed": "The drop tip could not be completed. Contact customer support for assistance.", "drop_tips": "drop tips", "error_dropping_tips": "Error dropping tips", diff --git a/app/src/atoms/buttons/FloatingActionButton.tsx b/app/src/atoms/buttons/FloatingActionButton.tsx index b7745f26f12..5905bdd8fce 100644 --- a/app/src/atoms/buttons/FloatingActionButton.tsx +++ b/app/src/atoms/buttons/FloatingActionButton.tsx @@ -1,5 +1,4 @@ import * as React from 'react' -import { useTranslation } from 'react-i18next' import { css } from 'styled-components' import { @@ -12,29 +11,21 @@ import { Icon, POSITION_FIXED, SPACING, - LegacyStyledText, - TYPOGRAPHY, + StyledText, } from '@opentrons/components' -import type { IconName, StyleProps } from '@opentrons/components' +import type { IconName } from '@opentrons/components' -interface FloatingActionButtonProps extends StyleProps { - buttonText?: React.ReactNode +interface FloatingActionButtonProps extends React.ComponentProps { + buttonText: string disabled?: boolean iconName?: IconName - onClick: React.MouseEventHandler } export function FloatingActionButton( props: FloatingActionButtonProps ): JSX.Element { - const { t } = useTranslation('protocol_setup') - const { - buttonText = t('map_view'), - disabled = false, - iconName = 'deck-map', - ...buttonProps - } = props + const { buttonText, disabled = false, iconName, ...buttonProps } = props const contentColor = disabled ? COLORS.grey50 : COLORS.white const FLOATING_ACTION_BUTTON_STYLE = css` @@ -65,9 +56,6 @@ export function FloatingActionButton( bottom={SPACING.spacing24} css={FLOATING_ACTION_BUTTON_STYLE} disabled={disabled} - fontSize={TYPOGRAPHY.fontSize28} - fontWeight={TYPOGRAPHY.fontWeightSemiBold} - lineHeight={TYPOGRAPHY.lineHeight36} padding={`${SPACING.spacing12} ${SPACING.spacing24}`} position={POSITION_FIXED} right={SPACING.spacing24} @@ -78,13 +66,15 @@ export function FloatingActionButton( flexDirection={DIRECTION_ROW} gridGap={SPACING.spacing8} > - - {buttonText} + {iconName != null ? ( + + ) : null} + {buttonText} ) diff --git a/app/src/atoms/buttons/RadioButton.tsx b/app/src/atoms/buttons/RadioButton.tsx index 7876866d56d..bfed6273f1e 100644 --- a/app/src/atoms/buttons/RadioButton.tsx +++ b/app/src/atoms/buttons/RadioButton.tsx @@ -93,7 +93,7 @@ export function RadioButton(props: RadioButtonProps): JSX.Element { /> {buttonLabel} diff --git a/app/src/atoms/buttons/__tests__/FloatingActionButton.test.tsx b/app/src/atoms/buttons/__tests__/FloatingActionButton.test.tsx index 5f325c34d88..4d479fd93cf 100644 --- a/app/src/atoms/buttons/__tests__/FloatingActionButton.test.tsx +++ b/app/src/atoms/buttons/__tests__/FloatingActionButton.test.tsx @@ -31,9 +31,6 @@ describe('FloatingActionButton', () => { `padding: ${SPACING.spacing12} ${SPACING.spacing24}` ) expect(button).toHaveStyle(`background-color: ${COLORS.purple50}`) - expect(button).toHaveStyle(`font-size: ${TYPOGRAPHY.fontSize28}`) - expect(button).toHaveStyle(`font-weight: ${TYPOGRAPHY.fontWeightSemiBold}`) - expect(button).toHaveStyle(`line-height: ${TYPOGRAPHY.lineHeight36}`) expect(button).toHaveStyle(`border-radius: ${BORDERS.borderRadius40}`) expect(button).toHaveStyle( `text-transform: ${TYPOGRAPHY.textTransformNone}` diff --git a/app/src/molecules/SimpleWizardBody/SimpleWizardBodyContent.tsx b/app/src/molecules/SimpleWizardBody/SimpleWizardBodyContent.tsx index 61e6b6de67a..5e79b1ff8bd 100644 --- a/app/src/molecules/SimpleWizardBody/SimpleWizardBodyContent.tsx +++ b/app/src/molecules/SimpleWizardBody/SimpleWizardBodyContent.tsx @@ -141,8 +141,8 @@ export function SimpleWizardBodyContent(props: Props): JSX.Element { <> {isSuccess ? ( Success Icon diff --git a/app/src/organisms/DropTipWizardFlows/Success.tsx b/app/src/organisms/DropTipWizardFlows/Success.tsx index 7fb10ae9cc4..5e72dd66281 100644 --- a/app/src/organisms/DropTipWizardFlows/Success.tsx +++ b/app/src/organisms/DropTipWizardFlows/Success.tsx @@ -1,19 +1,23 @@ import * as React from 'react' -import { useTranslation } from 'react-i18next' import { - COLORS, + StyledText, PrimaryButton, TEXT_TRANSFORM_CAPITALIZE, JUSTIFY_FLEX_END, + ALIGN_CENTER, Flex, SPACING, + DIRECTION_COLUMN, + RESPONSIVENESS, + JUSTIFY_CENTER, } from '@opentrons/components' -import { SimpleWizardBody } from '../../molecules/SimpleWizardBody' import { SmallButton } from '../../atoms/buttons' +import SuccessIcon from '../../assets/images/icon_success.png' import type { DropTipWizardContainerProps } from './types' +import { css } from 'styled-components' type SuccessProps = DropTipWizardContainerProps & { message: string @@ -29,32 +33,58 @@ export const Success = (props: SuccessProps): JSX.Element => { issuedCommandsType, } = props - const { i18n } = useTranslation(['drop_tip_wizard', 'shared']) - return ( - <> - {issuedCommandsType === 'fixit' ? : null} - + + Success Icon + + {message} + + + {isOnDevice ? ( - - - + ) : ( {proceedText} )} - - + + ) } + +const WIZARD_CONTAINER_STYLE = css` + min-height: 394px; + flex-direction: ${DIRECTION_COLUMN}; + justify-content: ${JUSTIFY_CENTER}; + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + height: 472px; + } +` diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryInProgress.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryInProgress.tsx index 4a0d64f7661..0f3f3fdb227 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryInProgress.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryInProgress.tsx @@ -1,9 +1,18 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' +import { css } from 'styled-components' -import { InProgressModal } from '../../molecules/InProgressModal/InProgressModal' import { RECOVERY_MAP } from './constants' -import { Flex, ALIGN_CENTER, JUSTIFY_CENTER } from '@opentrons/components' +import { + Flex, + ALIGN_CENTER, + JUSTIFY_CENTER, + RESPONSIVENESS, + DIRECTION_COLUMN, + SPACING, +} from '@opentrons/components' + +import { InProgressModal } from '../../molecules/InProgressModal' import type { RobotMovingRoute, RecoveryContentProps } from './types' @@ -43,12 +52,20 @@ export function RecoveryInProgress({ const description = buildDescription() return ( - + ) } + +const CONTAINER_STYLE = css` + align-items: ${ALIGN_CENTER}; + justify-content: ${JUSTIFY_CENTER}; + flex-direction: ${DIRECTION_COLUMN}; + grid-gap: ${SPACING.spacing16}; + width: 100%; + + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + grid-gap: ${SPACING.spacing24}; + } +` diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SelectRecoveryOption.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SelectRecoveryOption.tsx index 7fba59a0a6f..d5fe9a80f59 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SelectRecoveryOption.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SelectRecoveryOption.tsx @@ -121,7 +121,7 @@ export function ODDRecoveryOptions({ return ( {validRecoveryOptions.map((recoveryOption: RecoveryRoute) => { @@ -135,6 +135,7 @@ export function ODDRecoveryOptions({ setSelectedRoute(recoveryOption) }} isSelected={recoveryOption === selectedRoute} + radioButtonType="large" /> ) })} diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryTakeover.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryTakeover.tsx index 3c4d0a7b261..13ad5c3e2eb 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryTakeover.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryTakeover.tsx @@ -1,19 +1,13 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' -import { css } from 'styled-components' import { - DIRECTION_COLUMN, Flex, SPACING, COLORS, Icon, StyledText, AlertPrimaryButton, - ALIGN_CENTER, - JUSTIFY_CENTER, - TEXT_ALIGN_CENTER, - JUSTIFY_SPACE_BETWEEN, } from '@opentrons/components' import { RUN_STATUS_AWAITING_RECOVERY, @@ -30,6 +24,10 @@ import type { UseUpdateClientDataRecoveryResult, } from '../../resources/client_data' import type { ErrorRecoveryFlowsProps } from '.' +import { + BANNER_TEXT_CONTAINER_STYLE, + BANNER_TEXT_CONTENT_STYLE, +} from './constants' // The takeover view, functionally similar to MaintenanceRunTakeover export function RecoveryTakeover(props: { @@ -128,8 +126,8 @@ export function RecoveryTakeoverDesktop({ desktopType={'desktop-small'} isOnDevice={false} > - - + + ) } - -const CONTAINER_STYLE = css` - flex-direction: ${DIRECTION_COLUMN}; - justify-content: ${JUSTIFY_SPACE_BETWEEN}; - align-items: ${ALIGN_CENTER}; - padding-top: ${SPACING.spacing12}; -` - -const CONTENT_STYLE = css` - flex-direction: ${DIRECTION_COLUMN}; - justify-content: ${JUSTIFY_CENTER}; - align-items: ${ALIGN_CENTER}; - - text-align: ${TEXT_ALIGN_CENTER}; - padding: ${SPACING.spacing40} ${SPACING.spacing40}; - grid-gap: ${SPACING.spacing16}; -` diff --git a/app/src/organisms/ErrorRecoveryFlows/RunPausedSplash.tsx b/app/src/organisms/ErrorRecoveryFlows/RunPausedSplash.tsx index db66388deaa..78a1ae1e43b 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RunPausedSplash.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RunPausedSplash.tsx @@ -9,7 +9,6 @@ import { ALIGN_CENTER, SPACING, COLORS, - BORDERS, DIRECTION_COLUMN, POSITION_ABSOLUTE, TYPOGRAPHY, @@ -18,7 +17,6 @@ import { JUSTIFY_SPACE_BETWEEN, TEXT_ALIGN_CENTER, StyledText, - JUSTIFY_END, PrimaryButton, SecondaryButton, } from '@opentrons/components' @@ -26,12 +24,12 @@ import { import { useErrorName } from './hooks' import { getErrorKind } from './utils' import { LargeButton } from '../../atoms/buttons' -import { RECOVERY_MAP } from './constants' import { - RecoveryInterventionModal, - RecoverySingleColumnContentWrapper, - StepInfo, -} from './shared' + BANNER_TEXT_CONTAINER_STYLE, + BANNER_TEXT_CONTENT_STYLE, + RECOVERY_MAP, +} from './constants' +import { RecoveryInterventionModal, StepInfo } from './shared' import type { RobotType } from '@opentrons/shared-data' import type { ErrorRecoveryFlowsProps } from '.' @@ -169,49 +167,32 @@ export function RunPausedSplash( - - + + + - {title} + - - {title} - - - + {t('cancel_run')} @@ -224,7 +205,7 @@ export function RunPausedSplash( - + ) } diff --git a/app/src/organisms/ErrorRecoveryFlows/constants.ts b/app/src/organisms/ErrorRecoveryFlows/constants.ts index 2af09a3bf97..faadf0730aa 100644 --- a/app/src/organisms/ErrorRecoveryFlows/constants.ts +++ b/app/src/organisms/ErrorRecoveryFlows/constants.ts @@ -1,6 +1,14 @@ import { css } from 'styled-components' -import { RESPONSIVENESS, SPACING } from '@opentrons/components' +import { + RESPONSIVENESS, + SPACING, + DIRECTION_COLUMN, + ALIGN_CENTER, + JUSTIFY_CENTER, + JUSTIFY_SPACE_BETWEEN, + TEXT_ALIGN_CENTER, +} from '@opentrons/components' import type { StepOrder } from './types' @@ -232,3 +240,22 @@ export const ICON_SIZE_ALERT_INFO_STYLE = css` height: ${SPACING.spacing60}; } ` + +export const BANNER_TEXT_CONTAINER_STYLE = css` + flex-direction: ${DIRECTION_COLUMN}; + justify-content: ${JUSTIFY_SPACE_BETWEEN}; + align-items: ${ALIGN_CENTER}; + padding-top: ${SPACING.spacing12}; + width: 100%; +` + +export const BANNER_TEXT_CONTENT_STYLE = css` + flex-direction: ${DIRECTION_COLUMN}; + justify-content: ${JUSTIFY_CENTER}; + align-items: ${ALIGN_CENTER}; + + text-align: ${TEXT_ALIGN_CENTER}; + padding: ${SPACING.spacing40} ${SPACING.spacing40}; + grid-gap: ${SPACING.spacing16}; + width: 100%; +` diff --git a/app/src/organisms/ProtocolSetupLabware/LabwareMapViewModal.tsx b/app/src/organisms/ProtocolSetupLabware/LabwareMapView.tsx similarity index 84% rename from app/src/organisms/ProtocolSetupLabware/LabwareMapViewModal.tsx rename to app/src/organisms/ProtocolSetupLabware/LabwareMapView.tsx index e985386d461..e74d478ce70 100644 --- a/app/src/organisms/ProtocolSetupLabware/LabwareMapViewModal.tsx +++ b/app/src/organisms/ProtocolSetupLabware/LabwareMapView.tsx @@ -1,14 +1,12 @@ import * as React from 'react' import map from 'lodash/map' -import { useTranslation } from 'react-i18next' -import { BaseDeck } from '@opentrons/components' +import { BaseDeck, Flex } from '@opentrons/components' import { FLEX_ROBOT_TYPE, getSimplestDeckConfigForProtocol, THERMOCYCLER_MODULE_V1, } from '@opentrons/shared-data' -import { Modal } from '../../molecules/Modal' import { getStandardDeckViewLayerBlockList } from '../Devices/ProtocolRun/utils/getStandardDeckViewLayerBlockList' import { getLabwareRenderInfo } from '../Devices/ProtocolRun/utils/getLabwareRenderInfo' @@ -18,44 +16,33 @@ import type { LabwareDefinition2, } from '@opentrons/shared-data' import type { LoadedLabwareByAdapter } from '@opentrons/api-client' -import type { ModalHeaderBaseProps } from '../../molecules/Modal/types' import type { AttachedProtocolModuleMatch } from '../ProtocolSetupModulesAndDeck/utils' -interface LabwareMapViewModalProps { +interface LabwareMapViewProps { attachedProtocolModuleMatches: AttachedProtocolModuleMatch[] handleLabwareClick: ( labwareDef: LabwareDefinition2, labwareId: string ) => void - onCloseClick: () => void initialLoadedLabwareByAdapter: LoadedLabwareByAdapter deckDef: DeckDefinition mostRecentAnalysis: CompletedProtocolAnalysis | null } -export function LabwareMapViewModal( - props: LabwareMapViewModalProps -): JSX.Element { +export function LabwareMapView(props: LabwareMapViewProps): JSX.Element { const { handleLabwareClick, - onCloseClick, attachedProtocolModuleMatches, initialLoadedLabwareByAdapter, deckDef, mostRecentAnalysis, } = props - const { t } = useTranslation('protocol_setup') const deckConfig = getSimplestDeckConfigForProtocol(mostRecentAnalysis) const labwareRenderInfo = mostRecentAnalysis != null ? getLabwareRenderInfo(mostRecentAnalysis, deckDef) : {} - const modalHeader: ModalHeaderBaseProps = { - title: t('map_view'), - hasExitIcon: true, - } - const modulesOnDeck = attachedProtocolModuleMatches.map(module => { const { moduleDef, nestedLabwareDef, nestedLabwareId, slotName } = module const labwareInAdapterInMod = @@ -112,7 +99,7 @@ export function LabwareMapViewModal( ) return ( - + - + ) } diff --git a/app/src/organisms/ProtocolSetupLabware/__tests__/LabwareMapViewModal.test.tsx b/app/src/organisms/ProtocolSetupLabware/__tests__/LabwareMapView.test.tsx similarity index 79% rename from app/src/organisms/ProtocolSetupLabware/__tests__/LabwareMapViewModal.test.tsx rename to app/src/organisms/ProtocolSetupLabware/__tests__/LabwareMapView.test.tsx index 04b4df83d09..532440223d2 100644 --- a/app/src/organisms/ProtocolSetupLabware/__tests__/LabwareMapViewModal.test.tsx +++ b/app/src/organisms/ProtocolSetupLabware/__tests__/LabwareMapView.test.tsx @@ -1,7 +1,6 @@ import * as React from 'react' import { MemoryRouter } from 'react-router-dom' import { when } from 'vitest-when' -import { fireEvent, screen } from '@testing-library/react' import { describe, it, vi, beforeEach, afterEach, expect } from 'vitest' import { BaseDeck, EXTENDED_DECK_CONFIG_FIXTURE } from '@opentrons/components' @@ -16,7 +15,7 @@ import { i18n } from '../../../i18n' import { getLabwareRenderInfo } from '../../Devices/ProtocolRun/utils/getLabwareRenderInfo' import { getStandardDeckViewLayerBlockList } from '../../Devices/ProtocolRun/utils/getStandardDeckViewLayerBlockList' import { mockProtocolModuleInfo } from '../__fixtures__' -import { LabwareMapViewModal } from '../LabwareMapViewModal' +import { LabwareMapView } from '../LabwareMapView' import type { getSimplestDeckConfigForProtocol, @@ -51,10 +50,10 @@ vi.mock('@opentrons/components', async importOriginal => { } }) -const render = (props: React.ComponentProps) => { +const render = (props: React.ComponentProps) => { return renderWithProviders( - + , { i18nInstance: i18n, @@ -62,38 +61,15 @@ const render = (props: React.ComponentProps) => { )[0] } -describe('LabwareMapViewModal', () => { +describe('LabwareMapView', () => { beforeEach(() => { vi.mocked(getLabwareRenderInfo).mockReturnValue({}) - // vi.mocked(getSimplestDeckConfigForProtocol).mockReturnValue([]) }) afterEach(() => { vi.resetAllMocks() }) - it('should render nothing on the deck and calls exit button', () => { - vi.mocked(BaseDeck).mockReturnValue(
mock base deck
) - - const props = { - handleLabwareClick: vi.fn(), - onCloseClick: vi.fn(), - deckDef: (deckDefFixture as unknown) as DeckDefinition, - mostRecentAnalysis: ({ - commands: [], - labware: [], - } as unknown) as CompletedProtocolAnalysis, - initialLoadedLabwareByAdapter: {}, - attachedProtocolModuleMatches: [], - } - - render(props) - screen.getByText('Map View') - screen.getByText('mock base deck') - fireEvent.click(screen.getByLabelText('closeIcon')) - expect(props.onCloseClick).toHaveBeenCalled() - }) - it('should render a deck with modules and labware', () => { const mockLabwareOnDeck = [ { @@ -136,7 +112,6 @@ describe('LabwareMapViewModal', () => { }) render({ handleLabwareClick: vi.fn(), - onCloseClick: vi.fn(), deckDef: (deckDefFixture as unknown) as DeckDefinition, mostRecentAnalysis: ({} as unknown) as CompletedProtocolAnalysis, initialLoadedLabwareByAdapter: {}, diff --git a/app/src/organisms/ProtocolSetupLabware/__tests__/ProtocolSetupLabware.test.tsx b/app/src/organisms/ProtocolSetupLabware/__tests__/ProtocolSetupLabware.test.tsx index 7bfb4f63871..8182a8b73b3 100644 --- a/app/src/organisms/ProtocolSetupLabware/__tests__/ProtocolSetupLabware.test.tsx +++ b/app/src/organisms/ProtocolSetupLabware/__tests__/ProtocolSetupLabware.test.tsx @@ -110,10 +110,12 @@ describe('ProtocolSetupLabware', () => { expect(mockSetSetupScreen).toHaveBeenCalledWith('prepare to run') }) - it('should launch and close the deck map', () => { + it('should toggle between map view and list view', () => { render() + expect(screen.queryByText('List View')).toBeNull() fireEvent.click(screen.getByRole('button', { name: 'Map View' })) - fireEvent.click(screen.getByLabelText('closeIcon')) + expect(screen.queryByText('Map View')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'List View' })) screen.getByText('Labware') }) diff --git a/app/src/organisms/ProtocolSetupLabware/index.tsx b/app/src/organisms/ProtocolSetupLabware/index.tsx index 9c962f55d7b..fa4d3926fdb 100644 --- a/app/src/organisms/ProtocolSetupLabware/index.tsx +++ b/app/src/organisms/ProtocolSetupLabware/index.tsx @@ -2,6 +2,7 @@ import * as React from 'react' import { createPortal } from 'react-dom' import { useTranslation } from 'react-i18next' import styled, { css } from 'styled-components' + import { ALIGN_CENTER, ALIGN_FLEX_START, @@ -43,11 +44,11 @@ import { Modal } from '../../molecules/Modal' import { useMostRecentCompletedAnalysis } from '../LabwarePositionCheck/useMostRecentCompletedAnalysis' import { getLabwareSetupItemGroups } from '../../pages/Protocols/utils' +import { useNotifyDeckConfigurationQuery } from '../../resources/deck_configuration' import { getProtocolModulesInfo } from '../Devices/ProtocolRun/utils/getProtocolModulesInfo' import { getAttachedProtocolModuleMatches } from '../ProtocolSetupModulesAndDeck/utils' import { getNestedLabwareInfo } from '../Devices/ProtocolRun/SetupLabware/getNestedLabwareInfo' -import { LabwareMapViewModal } from './LabwareMapViewModal' -import { useNotifyDeckConfigurationQuery } from '../../resources/deck_configuration' +import { LabwareMapView } from './LabwareMapView' import type { UseQueryResult } from 'react-query' import type { @@ -83,7 +84,7 @@ export function ProtocolSetupLabware({ setSetupScreen, }: ProtocolSetupLabwareProps): JSX.Element { const { t } = useTranslation('protocol_setup') - const [showDeckMapModal, setShowDeckMapModal] = React.useState(false) + const [showMapView, setShowMapView] = React.useState(false) const [ showLabwareDetailsModal, setShowLabwareDetailsModal, @@ -202,18 +203,6 @@ export function ProtocolSetupLabware({ <> {createPortal( <> - {showDeckMapModal ? ( - { - setShowDeckMapModal(false) - }} - initialLoadedLabwareByAdapter={initialLoadedLabwareByAdapter} - /> - ) : null} {showLabwareDetailsModal && selectedLabware != null ? ( { @@ -269,45 +258,58 @@ export function ProtocolSetupLabware({ gridGap={SPACING.spacing8} marginTop={SPACING.spacing32} > - - - {t('location')} - - - {t('labware_name')} - - - {[...onDeckItems, ...offDeckItems].map((labware, i) => { - const labwareOnAdapter = onDeckItems.find( - item => - labware.initialLocation !== 'offDeck' && - 'labwareId' in labware.initialLocation && - item.labwareId === labware.initialLocation.labwareId - ) - return mostRecentAnalysis != null && labwareOnAdapter == null ? ( - - ) : null - })} + {showMapView ? ( + + ) : ( + <> + + + {t('location')} + + + {t('labware_name')} + + + {[...onDeckItems, ...offDeckItems].map((labware, i) => { + const labwareOnAdapter = onDeckItems.find( + item => + labware.initialLocation !== 'offDeck' && + 'labwareId' in labware.initialLocation && + item.labwareId === labware.initialLocation.labwareId + ) + return mostRecentAnalysis != null && labwareOnAdapter == null ? ( + + ) : null + })} + + )} { - setShowDeckMapModal(true) + setShowMapView(mapView => !mapView) }} /> diff --git a/app/src/organisms/ProtocolSetupModulesAndDeck/ModulesAndDeckMapViewModal.tsx b/app/src/organisms/ProtocolSetupModulesAndDeck/ModulesAndDeckMapView.tsx similarity index 62% rename from app/src/organisms/ProtocolSetupModulesAndDeck/ModulesAndDeckMapViewModal.tsx rename to app/src/organisms/ProtocolSetupModulesAndDeck/ModulesAndDeckMapView.tsx index 13ade7df2d0..a6c3c6dddaa 100644 --- a/app/src/organisms/ProtocolSetupModulesAndDeck/ModulesAndDeckMapViewModal.tsx +++ b/app/src/organisms/ProtocolSetupModulesAndDeck/ModulesAndDeckMapView.tsx @@ -1,41 +1,28 @@ import React from 'react' -import { useTranslation } from 'react-i18next' -import { BaseDeck } from '@opentrons/components' +import { BaseDeck, Flex } from '@opentrons/components' import { FLEX_ROBOT_TYPE, getSimplestDeckConfigForProtocol, } from '@opentrons/shared-data' -import { Modal } from '../../molecules/Modal' import { ModuleInfo } from '../Devices/ModuleInfo' import { getStandardDeckViewLayerBlockList } from '../Devices/ProtocolRun/utils/getStandardDeckViewLayerBlockList' import type { CompletedProtocolAnalysis } from '@opentrons/shared-data' -import type { ModalHeaderBaseProps } from '../../molecules/Modal/types' import type { AttachedProtocolModuleMatch } from './utils' -// Note (kk:10/26/2023) once we are ready for removing ff, we will be able to update props -interface ModulesAndDeckMapViewModalProps { - setShowDeckMapModal: (showDeckMapModal: boolean) => void +interface ModulesAndDeckMapViewProps { attachedProtocolModuleMatches: AttachedProtocolModuleMatch[] runId: string protocolAnalysis: CompletedProtocolAnalysis | null } -export function ModulesAndDeckMapViewModal({ - setShowDeckMapModal, +export function ModulesAndDeckMapView({ attachedProtocolModuleMatches, runId, protocolAnalysis, -}: ModulesAndDeckMapViewModalProps): JSX.Element | null { - const { t } = useTranslation('protocol_setup') - - const modalHeader: ModalHeaderBaseProps = { - title: t('map_view'), - hasExitIcon: true, - } - +}: ModulesAndDeckMapViewProps): JSX.Element | null { if (protocolAnalysis == null) return null const deckConfig = getSimplestDeckConfigForProtocol(protocolAnalysis) @@ -54,13 +41,7 @@ export function ModulesAndDeckMapViewModal({ })) return ( - { - setShowDeckMapModal(false) - }} - > + - + ) } diff --git a/app/src/organisms/ProtocolSetupModulesAndDeck/__tests__/ModulesAndDeckMapViewModal.test.tsx b/app/src/organisms/ProtocolSetupModulesAndDeck/__tests__/ModulesAndDeckMapView.test.tsx similarity index 86% rename from app/src/organisms/ProtocolSetupModulesAndDeck/__tests__/ModulesAndDeckMapViewModal.test.tsx rename to app/src/organisms/ProtocolSetupModulesAndDeck/__tests__/ModulesAndDeckMapView.test.tsx index 283ef6fb2c3..e0551b3f4f3 100644 --- a/app/src/organisms/ProtocolSetupModulesAndDeck/__tests__/ModulesAndDeckMapViewModal.test.tsx +++ b/app/src/organisms/ProtocolSetupModulesAndDeck/__tests__/ModulesAndDeckMapView.test.tsx @@ -10,7 +10,7 @@ import { import { renderWithProviders } from '../../../__testing-utils__' import { i18n } from '../../../i18n' -import { ModulesAndDeckMapViewModal } from '../ModulesAndDeckMapViewModal' +import { ModulesAndDeckMapView } from '../ModulesAndDeckMapView' vi.mock('@opentrons/components/src/hardware-sim/BaseDeck') vi.mock('@opentrons/api-client') @@ -22,7 +22,6 @@ vi.mock('../../Devices/ModuleInfo') vi.mock('../../Devices/ProtocolRun/utils/getLabwareRenderInfo') const mockRunId = 'mockRunId' -const mockSetShowDeckMapModal = vi.fn() const PROTOCOL_ANALYSIS = { id: 'fake analysis', status: 'completed', @@ -101,20 +100,17 @@ vi.mock('@opentrons/components', async importOriginal => { } }) -const render = ( - props: React.ComponentProps -) => { - return renderWithProviders(, { +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { i18nInstance: i18n, })[0] } -describe('ModulesAndDeckMapViewModal', () => { - let props: React.ComponentProps +describe('ModulesAndDeckMapView', () => { + let props: React.ComponentProps beforeEach(() => { props = { - setShowDeckMapModal: mockSetShowDeckMapModal, attachedProtocolModuleMatches: mockAttachedProtocolModuleMatches, runId: mockRunId, protocolAnalysis: PROTOCOL_ANALYSIS, @@ -131,7 +127,6 @@ describe('ModulesAndDeckMapViewModal', () => { it('should render BaseDeck map view', () => { render(props) - screen.getByText('Map View') screen.getByText('mock BaseDeck') }) }) diff --git a/app/src/organisms/ProtocolSetupModulesAndDeck/__tests__/ProtocolSetupModulesAndDeck.test.tsx b/app/src/organisms/ProtocolSetupModulesAndDeck/__tests__/ProtocolSetupModulesAndDeck.test.tsx index 69f56957635..9f9cec5524d 100644 --- a/app/src/organisms/ProtocolSetupModulesAndDeck/__tests__/ProtocolSetupModulesAndDeck.test.tsx +++ b/app/src/organisms/ProtocolSetupModulesAndDeck/__tests__/ProtocolSetupModulesAndDeck.test.tsx @@ -33,7 +33,7 @@ import { LocationConflictModal } from '../../Devices/ProtocolRun/SetupModuleAndD import { ModuleWizardFlows } from '../../ModuleWizardFlows' import { SetupInstructionsModal } from '../SetupInstructionsModal' import { FixtureTable } from '../FixtureTable' -import { ModulesAndDeckMapViewModal } from '../ModulesAndDeckMapViewModal' +import { ModulesAndDeckMapView } from '../ModulesAndDeckMapView' import { ProtocolSetupModulesAndDeck } from '..' import { useNotifyDeckConfigurationQuery } from '../../../resources/deck_configuration' import { useRunStatus } from '../../RunTimeControl/hooks' @@ -54,7 +54,7 @@ vi.mock('../SetupInstructionsModal') vi.mock('../../ModuleWizardFlows') vi.mock('../FixtureTable') vi.mock('../../Devices/ProtocolRun/SetupModuleAndDeck/LocationConflictModal') -vi.mock('../ModulesAndDeckMapViewModal') +vi.mock('../ModulesAndDeckMapView') vi.mock('../../RunTimeControl/hooks') const ROBOT_NAME = 'otie' @@ -327,10 +327,10 @@ describe('ProtocolSetupModulesAndDeck', () => { screen.getByText('mock location conflict modal') }) - it('should render ModulesAndDeckMapViewModal when tapping map view button', () => { + it('should render ModulesAndDeckMapView when tapping map view button', () => { render() fireEvent.click(screen.getByText('Map View')) screen.debug() - expect(vi.mocked(ModulesAndDeckMapViewModal)).toHaveBeenCalled() + expect(vi.mocked(ModulesAndDeckMapView)).toHaveBeenCalled() }) }) diff --git a/app/src/organisms/ProtocolSetupModulesAndDeck/index.tsx b/app/src/organisms/ProtocolSetupModulesAndDeck/index.tsx index 349faeff2b6..60f8f4be6f9 100644 --- a/app/src/organisms/ProtocolSetupModulesAndDeck/index.tsx +++ b/app/src/organisms/ProtocolSetupModulesAndDeck/index.tsx @@ -31,7 +31,7 @@ import { import { SetupInstructionsModal } from './SetupInstructionsModal' import { FixtureTable } from './FixtureTable' import { ModuleTable } from './ModuleTable' -import { ModulesAndDeckMapViewModal } from './ModulesAndDeckMapViewModal' +import { ModulesAndDeckMapView } from './ModulesAndDeckMapView' import { useNotifyDeckConfigurationQuery } from '../../resources/deck_configuration' import type { CutoutId, CutoutFixtureId } from '@opentrons/shared-data' @@ -68,7 +68,7 @@ export function ProtocolSetupModulesAndDeck({ showSetupInstructionsModal, setShowSetupInstructionsModal, ] = React.useState(false) - const [showDeckMapModal, setShowDeckMapModal] = React.useState(false) + const [showMapView, setShowMapView] = React.useState(false) const [ clearModuleMismatchBanner, setClearModuleMismatchBanner, @@ -113,14 +113,6 @@ export function ProtocolSetupModulesAndDeck({ setShowSetupInstructionsModal={setShowSetupInstructionsModal} /> ) : null} - {showDeckMapModal ? ( - - ) : null} , getTopPortalEl() )} @@ -143,53 +135,68 @@ export function ProtocolSetupModulesAndDeck({ marginTop="5.75rem" marginBottom={SPACING.spacing80} > - {isModuleMismatch && !clearModuleMismatchBanner ? ( - { - e.stopPropagation() - setClearModuleMismatchBanner(true) - }} - heading={t('extra_module_attached')} - message={t('module_mismatch_body')} + {showMapView ? ( + - ) : null} - - - - {i18n.format(t('deck_hardware'), 'titleCase')} - - {t('location')} - {t('status')} - - - {hasModules ? ( - + {isModuleMismatch && !clearModuleMismatchBanner ? ( + { + e.stopPropagation() + setClearModuleMismatchBanner(true) + }} + heading={t('extra_module_attached')} + message={t('module_mismatch_body')} /> ) : null} - - - + + + + {i18n.format(t('deck_hardware'), 'titleCase')} + + + {t('location')} + + {t('status')} + + + {hasModules ? ( + + ) : null} + + + + + )} { - setShowDeckMapModal(true) + setShowMapView(mapView => !mapView) }} /> diff --git a/app/src/organisms/RunProgressMeter/__tests__/RunProgressMeter.test.tsx b/app/src/organisms/RunProgressMeter/__tests__/RunProgressMeter.test.tsx index e6d4d4dba4e..0cab1ef5adb 100644 --- a/app/src/organisms/RunProgressMeter/__tests__/RunProgressMeter.test.tsx +++ b/app/src/organisms/RunProgressMeter/__tests__/RunProgressMeter.test.tsx @@ -9,6 +9,7 @@ import { RUN_STATUS_IDLE, RUN_STATUS_RUNNING, RUN_STATUS_SUCCEEDED, + RUN_STATUS_STOPPED, } from '@opentrons/api-client' import { i18n } from '../../../i18n' @@ -34,6 +35,7 @@ import { import { RunProgressMeter } from '..' import { renderWithProviders } from '../../../__testing-utils__' import { useLastRunCommand } from '../../Devices/hooks/useLastRunCommand' +import { useRunningStepCounts } from '../../../resources/protocols/hooks' import type { RunCommandSummary } from '@opentrons/api-client' import type * as ApiClient from '@opentrons/react-api-client' @@ -52,6 +54,7 @@ vi.mock('../../Devices/hooks') vi.mock('../../../atoms/ProgressBar') vi.mock('../../InterventionModal') vi.mock('../../Devices/hooks/useLastRunCommand') +vi.mock('../../../resources/protocols/hooks') const render = (props: React.ComponentProps) => { return renderWithProviders(, { @@ -88,6 +91,11 @@ describe('RunProgressMeter', () => { .thenReturn({ key: NON_DETERMINISTIC_COMMAND_KEY } as RunCommandSummary) vi.mocked(useNotifyRunQuery).mockReturnValue({ data: null } as any) + vi.mocked(useRunningStepCounts).mockReturnValue({ + totalStepCount: null, + currentStepNumber: null, + hasRunDiverged: true, + }) props = { runId: NON_DETERMINISTIC_RUN_ID, @@ -100,7 +108,7 @@ describe('RunProgressMeter', () => { it('should show only the total count of commands in run and not show the meter when protocol is non-deterministic', () => { vi.mocked(useCommandQuery).mockReturnValue({ data: null } as any) render(props) - expect(screen.getByText('Current Step ?/?')).toBeTruthy() + expect(screen.getByText('Current Step ?/?:')).toBeTruthy() expect(screen.queryByText('MOCK PROGRESS BAR')).toBeFalsy() }) it('should give the correct info when run status is idle', () => { @@ -141,7 +149,19 @@ describe('RunProgressMeter', () => { it('should render the correct run status when run status is completed', () => { vi.mocked(useCommandQuery).mockReturnValue({ data: null } as any) vi.mocked(useRunStatus).mockReturnValue(RUN_STATUS_SUCCEEDED) + vi.mocked(useRunningStepCounts).mockReturnValue({ + totalStepCount: 10, + currentStepNumber: 10, + hasRunDiverged: false, + }) + render(props) + screen.getByText('Final Step 10/10:') + }) + + it('should render the correct step info when the run is cancelled before running', () => { + vi.mocked(useCommandQuery).mockReturnValue({ data: null } as any) + vi.mocked(useRunStatus).mockReturnValue(RUN_STATUS_STOPPED) render(props) - screen.getByText('Final Step ?/?') + screen.getByText('Final Step: N/A') }) }) diff --git a/app/src/organisms/RunProgressMeter/constants.ts b/app/src/organisms/RunProgressMeter/constants.ts new file mode 100644 index 00000000000..525cb55c5d3 --- /dev/null +++ b/app/src/organisms/RunProgressMeter/constants.ts @@ -0,0 +1,15 @@ +import { + RUN_STATUS_FAILED, + RUN_STATUS_FINISHING, + RUN_STATUS_STOPPED, + RUN_STATUS_SUCCEEDED, +} from '@opentrons/api-client' + +import type { RunStatus } from '@opentrons/api-client' + +export const TERMINAL_RUN_STATUSES: RunStatus[] = [ + RUN_STATUS_STOPPED, + RUN_STATUS_FAILED, + RUN_STATUS_FINISHING, + RUN_STATUS_SUCCEEDED, +] diff --git a/app/src/organisms/RunProgressMeter/hooks/index.ts b/app/src/organisms/RunProgressMeter/hooks/index.ts new file mode 100644 index 00000000000..1b8689ea274 --- /dev/null +++ b/app/src/organisms/RunProgressMeter/hooks/index.ts @@ -0,0 +1 @@ +export * from './useRunProgressCopy' diff --git a/app/src/organisms/RunProgressMeter/hooks/useRunProgressCopy.tsx b/app/src/organisms/RunProgressMeter/hooks/useRunProgressCopy.tsx new file mode 100644 index 00000000000..9484a785f82 --- /dev/null +++ b/app/src/organisms/RunProgressMeter/hooks/useRunProgressCopy.tsx @@ -0,0 +1,118 @@ +import { + RUN_STATUS_BLOCKED_BY_OPEN_DOOR, + RUN_STATUS_IDLE, +} from '@opentrons/api-client' +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { getCommandTextData } from '../../../molecules/Command/utils/getCommandTextData' +import { LegacyStyledText } from '@opentrons/components' +import { CommandText } from '../../../molecules/Command' +import { TERMINAL_RUN_STATUSES } from '../constants' + +import type { CommandDetail, RunStatus } from '@opentrons/api-client' +import type { + CompletedProtocolAnalysis, + RobotType, + RunTimeCommand, +} from '@opentrons/shared-data' + +interface UseRunProgressResult { + currentStepContents: React.ReactNode + stepCountStr: string | null + progressPercentage: number +} + +interface UseRunProgressProps { + runStatus: RunStatus | null + currentStepNumber: number | null + totalStepCount: number | null + analysis: CompletedProtocolAnalysis | null + hasRunDiverged: boolean + runCommandDetails: CommandDetail | null + robotType: RobotType + analysisCommands: RunTimeCommand[] +} + +// TODO(jh, 08-05-24): Testing is sufficiently covered by RunProgressMeter, but we should migrate relevant tests to this +// hook after devising a better way to test i18n outside of a component. +export function useRunProgressCopy({ + runStatus, + currentStepNumber, + totalStepCount, + hasRunDiverged, + runCommandDetails, + analysisCommands, + robotType, + analysis, +}: UseRunProgressProps): UseRunProgressResult { + const { t } = useTranslation('run_details') + + const runHasNotBeenStarted = + (currentStepNumber === 0 && + runStatus === RUN_STATUS_BLOCKED_BY_OPEN_DOOR) || + runStatus === RUN_STATUS_IDLE + + const currentStepContents = ((): JSX.Element | null => { + if (runHasNotBeenStarted) { + return {t('not_started_yet')} + } else if (analysis != null && !hasRunDiverged) { + return ( + + ) + } else if ( + analysis != null && + hasRunDiverged && + runCommandDetails != null + ) { + return ( + + ) + } else { + return null + } + })() + + const progressPercentage = runHasNotBeenStarted + ? 0 + : ((currentStepNumber as number) / analysisCommands.length) * 100 + + const stepCountStr = ((): string | null => { + if (runStatus == null) { + return null + } else { + const isTerminalStatus = TERMINAL_RUN_STATUSES.includes(runStatus) + const stepType = isTerminalStatus ? t('final_step') : t('current_step') + + if (runStatus === RUN_STATUS_IDLE) { + return `${stepType}:` + } else if (isTerminalStatus && currentStepNumber == null) { + return `${stepType}: N/A` + } else { + const getCountString = (): string => { + const current = currentStepNumber ?? '?' + const total = totalStepCount ?? '?' + + return `${current}/${total}` + } + + const countString = getCountString() + + return `${stepType} ${countString}:` + } + } + })() + + return { + currentStepContents, + stepCountStr, + progressPercentage, + } +} diff --git a/app/src/organisms/RunProgressMeter/index.tsx b/app/src/organisms/RunProgressMeter/index.tsx index c1c674d03b3..5f120904a41 100644 --- a/app/src/organisms/RunProgressMeter/index.tsx +++ b/app/src/organisms/RunProgressMeter/index.tsx @@ -22,18 +22,13 @@ import { import { useCommandQuery } from '@opentrons/react-api-client' import { RUN_STATUS_IDLE, - RUN_STATUS_STOPPED, - RUN_STATUS_FAILED, RUN_STATUS_FINISHING, - RUN_STATUS_SUCCEEDED, RUN_STATUS_RUNNING, - RUN_STATUS_BLOCKED_BY_OPEN_DOOR, } from '@opentrons/api-client' import { useMostRecentCompletedAnalysis } from '../LabwarePositionCheck/useMostRecentCompletedAnalysis' import { getModalPortalEl } from '../../App/portal' import { Tooltip } from '../../atoms/Tooltip' -import { CommandText } from '../../molecules/Command' import { useRunStatus } from '../RunTimeControl/hooks' import { InterventionModal } from '../InterventionModal' import { ProgressBar } from '../../atoms/ProgressBar' @@ -44,17 +39,9 @@ import { useNotifyRunQuery, useNotifyAllCommandsQuery, } from '../../resources/runs' -import { getCommandTextData } from '../../molecules/Command/utils/getCommandTextData' import { useRunningStepCounts } from '../../resources/protocols/hooks' - -import type { RunStatus } from '@opentrons/api-client' - -const TERMINAL_RUN_STATUSES: RunStatus[] = [ - RUN_STATUS_STOPPED, - RUN_STATUS_FAILED, - RUN_STATUS_FINISHING, - RUN_STATUS_SUCCEEDED, -] +import { TERMINAL_RUN_STATUSES } from './constants' +import { useRunProgressCopy } from './hooks' interface RunProgressMeterProps { runId: string @@ -104,36 +91,6 @@ export function RunProgressMeter(props: RunProgressMeterProps): JSX.Element { const { downloadRunLog } = useDownloadRunLog(robotName, runId) - const stepCountStr = `${currentStepNumber ?? '?'}/${totalStepCount ?? '?'}` - - const runHasNotBeenStarted = - (currentStepNumber === 0 && - runStatus === RUN_STATUS_BLOCKED_BY_OPEN_DOOR) || - runStatus === RUN_STATUS_IDLE - - let currentStepContents: React.ReactNode = null - if (runHasNotBeenStarted) { - currentStepContents = ( - {t('not_started_yet')} - ) - } else if (analysis != null && !hasRunDiverged) { - currentStepContents = ( - - ) - } else if (analysis != null && hasRunDiverged && runCommandDetails != null) { - currentStepContents = ( - - ) - } - React.useEffect(() => { if ( lastRunCommand != null && @@ -158,6 +115,21 @@ export function RunProgressMeter(props: RunProgressMeterProps): JSX.Element { downloadRunLog() } + const { + progressPercentage, + stepCountStr, + currentStepContents, + } = useRunProgressCopy({ + runStatus, + robotType, + currentStepNumber, + totalStepCount, + analysis, + analysisCommands, + runCommandDetails: runCommandDetails ?? null, + hasRunDiverged, + }) + return ( <> {interventionModalCommandKey != null && @@ -184,15 +156,9 @@ export function RunProgressMeter(props: RunProgressMeterProps): JSX.Element { {`${ - runStatus != null && TERMINAL_RUN_STATUSES.includes(runStatus) - ? t('final_step') - : t('current_step') - }${ - runStatus === RUN_STATUS_IDLE - ? ':' - : ` ${stepCountStr}${currentStepContents != null ? ': ' : ''}` - }`} + > + {stepCountStr} + {currentStepContents} @@ -226,12 +192,7 @@ export function RunProgressMeter(props: RunProgressMeterProps): JSX.Element { {!hasRunDiverged ? ( + definitionBottom?: LabwareDefinition2 + shouldRotateAdapterOrientation?: boolean + /** option to show well labels inside or outside of labware outline */ + wellLabelOption?: WellLabelOption +} + +export const LabwareStackRender = ( + props: LabwareStackRenderProps +): JSX.Element => { + const { + gRef, + definitionTop, + definitionBottom, + highlightTop, + wellLabelOption, + shouldRotateAdapterOrientation, + highlightBottom = false, + } = props + + const labwareLoadNameTop = definitionTop.parameters.loadName + const fillColorTop = highlightTop ? HIGHLIGHT_COLOR : COLORS.white + const fillColorBottom = highlightBottom ? HIGHLIGHT_COLOR : COLORS.white + + // only one labware (top) + if (definitionBottom == null) { + const { xDimension, yDimension } = definitionTop.dimensions + const isTopAdapter = definitionTop.metadata.displayCategory === 'adapter' + + return isTopAdapter ? ( + // adapter render + + + + + + ) : ( + // isometric view of labware + + + + {wellLabelOption != null ? ( + + ) : null} + + + + + ) + } + + return ( + + {/* bottom labware/adapter */} + + + {wellLabelOption != null && + definitionTop.metadata.displayCategory !== 'adapter' ? ( + + ) : null} + + + + {/* top labware/adapter */} + + + {wellLabelOption != null && + definitionTop.metadata.displayCategory !== 'adapter' ? ( + + ) : null} + + + + + ) +} diff --git a/components/src/hardware-sim/Labware/index.ts b/components/src/hardware-sim/Labware/index.ts index f8fba8d7946..cd943ec0a7a 100644 --- a/components/src/hardware-sim/Labware/index.ts +++ b/components/src/hardware-sim/Labware/index.ts @@ -1,5 +1,6 @@ export * from './labwareInternals/index' export * from './LabwareRender' +export * from './LabwareStackRender' export * from './Labware' export * from './labwareInternals/types' diff --git a/components/src/hardware-sim/Labware/labwareInternals/LabwareOutline.tsx b/components/src/hardware-sim/Labware/labwareInternals/LabwareOutline.tsx index 14a33b00aa0..9eda65289ec 100644 --- a/components/src/hardware-sim/Labware/labwareInternals/LabwareOutline.tsx +++ b/components/src/hardware-sim/Labware/labwareInternals/LabwareOutline.tsx @@ -18,6 +18,7 @@ export interface LabwareOutlineProps { highlight?: boolean /** [legacy] override the border color */ stroke?: CSSProperties['stroke'] + fill?: CSSProperties['fill'] } const OUTLINE_THICKNESS_MM = 1 @@ -30,13 +31,19 @@ export function LabwareOutline(props: LabwareOutlineProps): JSX.Element { isTiprack = false, highlight = false, stroke, + fill, } = props const { parameters = { isTiprack }, dimensions = { xDimension: width, yDimension: height }, } = definition ?? {} - const backgroundFill = parameters.isTiprack ? '#CCCCCC' : COLORS.white + let backgroundFill + if (fill != null) { + backgroundFill = fill + } else { + backgroundFill = parameters.isTiprack ? '#CCCCCC' : COLORS.white + } return ( <> {highlight ? ( diff --git a/components/src/hardware-sim/Labware/labwareInternals/StaticLabware.tsx b/components/src/hardware-sim/Labware/labwareInternals/StaticLabware.tsx index 6731197b575..c8341c94a07 100644 --- a/components/src/hardware-sim/Labware/labwareInternals/StaticLabware.tsx +++ b/components/src/hardware-sim/Labware/labwareInternals/StaticLabware.tsx @@ -5,11 +5,12 @@ import flatMap from 'lodash/flatMap' import { LabwareOutline } from './LabwareOutline' import { Well } from './Well' +import { STYLE_BY_WELL_CONTENTS } from './StyledWells' +import { COLORS } from '../../../helix-design-system' import type { LabwareDefinition2, LabwareWell } from '@opentrons/shared-data' import type { WellMouseEvent } from './types' -import { STYLE_BY_WELL_CONTENTS } from './StyledWells' -import { COLORS } from '../../../helix-design-system' +import type { CSSProperties } from 'styled-components' export interface StaticLabwareProps { /** Labware definition to render */ @@ -22,6 +23,7 @@ export interface StaticLabwareProps { onMouseEnterWell?: (e: WellMouseEvent) => unknown /** Optional callback to be executed when mouse leaves a well element */ onMouseLeaveWell?: (e: WellMouseEvent) => unknown + fill?: CSSProperties['fill'] } const TipDecoration = React.memo(function TipDecoration(props: { @@ -55,13 +57,18 @@ export function StaticLabwareComponent(props: StaticLabwareProps): JSX.Element { onLabwareClick, onMouseEnterWell, onMouseLeaveWell, + fill, } = props const { isTiprack } = definition.parameters return ( - + {flatMap( @@ -78,6 +85,7 @@ export function StaticLabwareComponent(props: StaticLabwareProps): JSX.Element { {...(isTiprack ? STYLE_BY_WELL_CONTENTS.tipPresent : STYLE_BY_WELL_CONTENTS.defaultWell)} + fill={fill} /> {isTiprack ? ( diff --git a/components/src/hardware-sim/Labware/labwareInternals/Well.tsx b/components/src/hardware-sim/Labware/labwareInternals/Well.tsx index 5a7d8760646..850d99f49d9 100644 --- a/components/src/hardware-sim/Labware/labwareInternals/Well.tsx +++ b/components/src/hardware-sim/Labware/labwareInternals/Well.tsx @@ -26,13 +26,15 @@ export function WellComponent(props: WellProps): JSX.Element { wellName, stroke = COLORS.black90, strokeWidth = 1, - fill = COLORS.white, + fill, onMouseEnterWell, onMouseLeaveWell, isInteractive = onMouseEnterWell != null || onMouseLeaveWell != null, } = props const { x, y } = well + const wellFill = fill ?? COLORS.white + const pointerEvents: React.CSSProperties['pointerEvents'] = isInteractive ? 'auto' : 'none' @@ -46,7 +48,7 @@ export function WellComponent(props: WellProps): JSX.Element { onMouseLeaveWell != null ? (event: React.MouseEvent) => onMouseLeaveWell({ wellName, event }) : undefined, - style: { pointerEvents, stroke, strokeWidth, fill }, + style: { pointerEvents, stroke, strokeWidth, fill: wellFill }, } if (well.shape === 'circular') { diff --git a/robot-server/robot_server/runs/router/base_router.py b/robot-server/robot_server/runs/router/base_router.py index 55d48acb463..7000882b965 100644 --- a/robot-server/robot_server/runs/router/base_router.py +++ b/robot-server/robot_server/runs/router/base_router.py @@ -7,13 +7,16 @@ from pathlib import Path from textwrap import dedent from typing import Optional, Union, Callable -from typing_extensions import Literal +from typing_extensions import Literal, Final from fastapi import APIRouter, Depends, status, Query from pydantic import BaseModel, Field from opentrons_shared_data.errors import ErrorCodes from opentrons.protocol_engine.types import CSVRuntimeParamPaths +from opentrons.protocol_engine import ( + errors as pe_errors, +) from robot_server.data_files.models import FileIdNotFound, FileIdNotFoundError from robot_server.data_files.dependencies import ( @@ -30,6 +33,7 @@ RequestModel, SimpleBody, SimpleEmptyBody, + SimpleMultiBody, MultiBody, MultiBodyMeta, ResourceLink, @@ -64,6 +68,8 @@ log = logging.getLogger(__name__) base_router = APIRouter() +_DEFAULT_COMMAND_ERROR_LIST_LENGTH: Final = 20 + class RunNotFound(ErrorDetails): """An error if a given run is not found.""" @@ -434,3 +440,79 @@ async def put_error_recovery_policy( content=SimpleEmptyBody.construct(), status_code=status.HTTP_200_OK, ) + + +@PydanticResponse.wrap_route( + base_router.get, + path="/runs/{runId}/commandErrors", + summary="Get a list of all command errors in the run", + description=( + "Get a list of all command errors in the run. " + "\n\n" + "The errors are returned in order from oldest to newest." + "\n\n" + "This endpoint returns the command error. Use " + "`GET /runs/{runId}/commands/{commandId}` to get all " + "information available for a given command." + ), + responses={ + status.HTTP_200_OK: {"model": SimpleMultiBody[pe_errors.ErrorOccurrence]}, + status.HTTP_409_CONFLICT: {"model": ErrorBody[RunStopped]}, + }, +) +async def get_run_commands_error( + runId: str, + cursor: Optional[int] = Query( + None, + description=( + "The starting index of the desired first command error in the list." + " If unspecified, a cursor will be selected automatically" + " based on the last error added." + ), + ), + pageLength: int = Query( + _DEFAULT_COMMAND_ERROR_LIST_LENGTH, + description="The maximum number of command errors in the list to return.", + ), + run_data_manager: RunDataManager = Depends(get_run_data_manager), +) -> PydanticResponse[SimpleMultiBody[pe_errors.ErrorOccurrence]]: + """Get a summary of a set of command errors in a run. + + Arguments: + runId: Requested run ID, from the URL + cursor: Cursor index for the collection response. + pageLength: Maximum number of items to return. + run_data_manager: Run data retrieval interface. + """ + try: + all_errors = run_data_manager.get_command_errors(run_id=runId) + total_length = len(all_errors) + + if cursor is None: + if len(all_errors) > 0: + # Get the most recent error, + # which we can find just at the end of the list. + cursor = total_length - 1 + else: + cursor = 0 + + command_error_slice = run_data_manager.get_command_error_slice( + run_id=runId, + cursor=cursor, + length=pageLength, + ) + except RunNotCurrentError as e: + raise RunStopped(detail=str(e)).as_error(status.HTTP_409_CONFLICT) from e + + meta = MultiBodyMeta( + cursor=command_error_slice.cursor, + totalLength=command_error_slice.total_length, + ) + + return await PydanticResponse.create( + content=SimpleMultiBody.construct( + data=command_error_slice.commands_errors, + meta=meta, + ), + status_code=status.HTTP_200_OK, + ) diff --git a/robot-server/robot_server/runs/router/commands_router.py b/robot-server/robot_server/runs/router/commands_router.py index cfa69c0253d..fdabab6778f 100644 --- a/robot-server/robot_server/runs/router/commands_router.py +++ b/robot-server/robot_server/runs/router/commands_router.py @@ -28,7 +28,10 @@ CommandLinkMeta, ) from ..run_models import RunCommandSummary -from ..run_data_manager import RunDataManager, PreSerializedCommandsNotAvailableError +from ..run_data_manager import ( + RunDataManager, + PreSerializedCommandsNotAvailableError, +) from ..run_orchestrator_store import RunOrchestratorStore from ..run_store import CommandNotFoundError, RunStore from ..run_models import RunNotFoundError diff --git a/robot-server/robot_server/runs/run_data_manager.py b/robot-server/robot_server/runs/run_data_manager.py index 0c9b24228a3..62b491e6617 100644 --- a/robot-server/robot_server/runs/run_data_manager.py +++ b/robot-server/robot_server/runs/run_data_manager.py @@ -9,8 +9,10 @@ LabwareOffsetCreate, StateSummary, CommandSlice, + CommandErrorSlice, CommandPointer, Command, + ErrorOccurrence, ) from opentrons.protocol_engine.types import ( PrimitiveRunTimeParamValuesType, @@ -386,6 +388,27 @@ def get_commands_slice( run_id=run_id, cursor=cursor, length=length ) + def get_command_error_slice( + self, run_id: str, cursor: int, length: int + ) -> CommandErrorSlice: + """Get a slice of run commands. + + Args: + run_id: ID of the run. + cursor: Requested index of first command in the returned slice. + length: Length of slice to return. + + Raises: + RunNotCurrentError: The given run identifier is not the current run. + """ + if run_id == self._run_orchestrator_store.current_run_id: + return self._run_orchestrator_store.get_command_error_slice( + cursor=cursor, length=length + ) + + # TODO(tz, 8-5-2024): Change this to return to error list from the DB when we implement https://opentrons.atlassian.net/browse/EXEC-655. + raise RunNotCurrentError() + def get_current_command(self, run_id: str) -> Optional[CommandPointer]: """Get the "current" command, if any. @@ -433,6 +456,14 @@ def get_command(self, run_id: str, command_id: str) -> Command: return self._run_store.get_command(run_id=run_id, command_id=command_id) + def get_command_errors(self, run_id: str) -> list[ErrorOccurrence]: + """Get all command errors.""" + if run_id == self._run_orchestrator_store.current_run_id: + return self._run_orchestrator_store.get_command_errors() + + # TODO(tz, 8-5-2024): Change this to return to error list from the DB when we implement https://opentrons.atlassian.net/browse/EXEC-655. + raise RunNotCurrentError() + def get_all_commands_as_preserialized_list(self, run_id: str) -> List[str]: """Get all commands of a run in a serialized json list.""" if ( diff --git a/robot-server/robot_server/runs/run_orchestrator_store.py b/robot-server/robot_server/runs/run_orchestrator_store.py index 13049d3b780..72fb80d1ef2 100644 --- a/robot-server/robot_server/runs/run_orchestrator_store.py +++ b/robot-server/robot_server/runs/run_orchestrator_store.py @@ -33,6 +33,7 @@ LabwareOffsetCreate, StateSummary, CommandSlice, + CommandErrorSlice, CommandPointer, Command, CommandCreate, @@ -41,6 +42,7 @@ error_recovery_policy, ) from opentrons.protocol_engine.create_protocol_engine import create_protocol_engine +from opentrons.protocol_engine import ErrorOccurrence from robot_server.protocols.protocol_store import ProtocolResource from opentrons.protocol_engine.types import ( @@ -340,6 +342,25 @@ def get_command_slice( """ return self.run_orchestrator.get_command_slice(cursor=cursor, length=length) + def get_command_error_slice( + self, + cursor: int, + length: int, + ) -> CommandErrorSlice: + """Get a slice of run commands error. + + Args: + cursor: Requested index of first command error in the returned slice. + length: Length of slice to return. + """ + return self.run_orchestrator.get_command_error_slice( + cursor=cursor, length=length + ) + + def get_command_errors(self) -> list[ErrorOccurrence]: + """Get all command errors.""" + return self.run_orchestrator.get_command_errors() + def get_command_recovery_target(self) -> Optional[CommandPointer]: """Get the current error recovery target.""" return self.run_orchestrator.get_command_recovery_target() diff --git a/robot-server/tests/runs/router/test_base_router.py b/robot-server/tests/runs/router/test_base_router.py index 2e9b532ad93..df778420a07 100644 --- a/robot-server/tests/runs/router/test_base_router.py +++ b/robot-server/tests/runs/router/test_base_router.py @@ -5,7 +5,12 @@ from pathlib import Path from opentrons.types import DeckSlotName -from opentrons.protocol_engine import LabwareOffsetCreate, types as pe_types +from opentrons.protocol_engine import ( + LabwareOffsetCreate, + types as pe_types, + errors as pe_errors, + CommandErrorSlice, +) from opentrons.protocol_reader import ProtocolSource, JsonProtocolConfig from robot_server.data_files.data_files_store import DataFilesStore, DataFileInfo @@ -42,6 +47,7 @@ remove_run, update_run, put_error_recovery_policy, + get_run_commands_error, ) from robot_server.deck_configuration.store import DeckConfigurationStore @@ -645,3 +651,118 @@ async def test_create_policies_raises_not_active_run( assert exc_info.value.status_code == 409 assert exc_info.value.content["errors"][0]["id"] == "RunStopped" + + +async def test_get_run_commands_errors( + decoy: Decoy, mock_run_data_manager: RunDataManager +) -> None: + """It should return a list of all commands errors in a run.""" + decoy.when( + mock_run_data_manager.get_command_error_slice( + run_id="run-id", + cursor=0, + length=42, + ) + ).then_raise(RunNotCurrentError("oh no!")) + + error = pe_errors.ErrorOccurrence( + id="error-id", + errorType="PrettyBadError", + createdAt=datetime(year=2024, month=4, day=4), + detail="Things are not looking good.", + ) + decoy.when(mock_run_data_manager.get_command_errors("run-id")).then_return([error]) + + with pytest.raises(ApiError): + result = await get_run_commands_error( + runId="run-id", + run_data_manager=mock_run_data_manager, + cursor=None, + pageLength=42, + ) + assert result.status_code == 409 + + +async def test_get_run_commands_errors_raises_no_run( + decoy: Decoy, mock_run_data_manager: RunDataManager +) -> None: + """It should return a list of all commands errors in a run.""" + error = pe_errors.ErrorOccurrence( + id="error-id", + errorType="PrettyBadError", + createdAt=datetime(year=2024, month=4, day=4), + detail="Things are not looking good.", + ) + decoy.when(mock_run_data_manager.get_command_errors("run-id")).then_return([error]) + + command_error_slice = CommandErrorSlice( + cursor=1, total_length=3, commands_errors=[error] + ) + + decoy.when( + mock_run_data_manager.get_command_error_slice( + run_id="run-id", + cursor=0, + length=42, + ) + ).then_return(command_error_slice) + + result = await get_run_commands_error( + runId="run-id", + run_data_manager=mock_run_data_manager, + cursor=None, + pageLength=42, + ) + + assert list(result.content.data) == [ + pe_errors.ErrorOccurrence( + id="error-id", + errorType="PrettyBadError", + createdAt=datetime(year=2024, month=4, day=4), + detail="Things are not looking good.", + ) + ] + assert result.content.meta == MultiBodyMeta(cursor=1, totalLength=3) + assert result.status_code == 200 + + +@pytest.mark.parametrize( + "error_list, expected_cursor_result", + [([], 0), ([pe_errors.ErrorOccurrence.construct(id="error-id")], 1)], +) +async def test_get_run_commands_errors_defualt_cursor( + decoy: Decoy, + mock_run_data_manager: RunDataManager, + error_list: list[pe_errors.ErrorOccurrence], + expected_cursor_result: int, +) -> None: + """It should return a list of all commands errors in a run.""" + print(error_list) + decoy.when(mock_run_data_manager.get_command_errors("run-id")).then_return( + error_list + ) + + command_error_slice = CommandErrorSlice( + cursor=expected_cursor_result, total_length=3, commands_errors=error_list + ) + + decoy.when( + mock_run_data_manager.get_command_error_slice( + run_id="run-id", + cursor=0, + length=42, + ) + ).then_return(command_error_slice) + + result = await get_run_commands_error( + runId="run-id", + run_data_manager=mock_run_data_manager, + cursor=None, + pageLength=42, + ) + + assert list(result.content.data) == error_list + assert result.content.meta == MultiBodyMeta( + cursor=expected_cursor_result, totalLength=3 + ) + assert result.status_code == 200 diff --git a/robot-server/tests/runs/test_run_data_manager.py b/robot-server/tests/runs/test_run_data_manager.py index 309c82747a2..dc19c8b4abc 100644 --- a/robot-server/tests/runs/test_run_data_manager.py +++ b/robot-server/tests/runs/test_run_data_manager.py @@ -12,6 +12,7 @@ commands, types as pe_types, CommandSlice, + CommandErrorSlice, CommandPointer, ErrorOccurrence, LoadedLabware, @@ -873,6 +874,43 @@ def test_get_commands_slice_current_run( assert expected_command_slice == result +def test_get_commands_errors_slice__not_current_run_raises( + decoy: Decoy, + subject: RunDataManager, + mock_run_orchestrator_store: RunOrchestratorStore, +) -> None: + """Should get a sliced command error list from engine store.""" + decoy.when(mock_run_orchestrator_store.current_run_id).then_return("run-not-id") + + with pytest.raises(RunNotCurrentError): + subject.get_command_error_slice("run-id", 1, 2) + + +def test_get_commands_errors_slice_current_run( + decoy: Decoy, + subject: RunDataManager, + mock_run_orchestrator_store: RunOrchestratorStore, + run_command: commands.Command, +) -> None: + """Should get a sliced command error list from engine store.""" + expected_commands_errors_result = [ + ErrorOccurrence.construct(id="error-id") # type: ignore[call-arg] + ] + + command_error_slice = CommandErrorSlice( + cursor=1, total_length=3, commands_errors=expected_commands_errors_result + ) + + decoy.when(mock_run_orchestrator_store.current_run_id).then_return("run-id") + decoy.when(mock_run_orchestrator_store.get_command_error_slice(1, 2)).then_return( + command_error_slice + ) + + result = subject.get_command_error_slice("run-id", 1, 2) + + assert command_error_slice == result + + def test_get_commands_slice_from_db_run_not_found( decoy: Decoy, subject: RunDataManager, mock_run_store: RunStore ) -> None: