diff --git a/.github/workflows/hardware-lint-test.yaml b/.github/workflows/hardware-lint-test.yaml index 8714a40d2501..0cfd4597a5d7 100644 --- a/.github/workflows/hardware-lint-test.yaml +++ b/.github/workflows/hardware-lint-test.yaml @@ -41,7 +41,7 @@ jobs: lint-test: name: 'hardware package linting and tests' timeout-minutes: 20 - runs-on: 'ubuntu-20.04' + runs-on: 'ubuntu-24.04' steps: - name: Checkout opentrons repo uses: 'actions/checkout@v4' diff --git a/.github/workflows/hardware-testing-protocols.yaml b/.github/workflows/hardware-testing-protocols.yaml index 1573c69380ac..f22fd0d38475 100644 --- a/.github/workflows/hardware-testing-protocols.yaml +++ b/.github/workflows/hardware-testing-protocols.yaml @@ -35,7 +35,7 @@ jobs: lint-test: name: 'hardware-testing protocols' timeout-minutes: 20 - runs-on: 'ubuntu-20.04' + runs-on: 'ubuntu-24.04' steps: - name: Checkout opentrons repo uses: 'actions/checkout@v4' diff --git a/.github/workflows/hardware-testing.yaml b/.github/workflows/hardware-testing.yaml index 116ca872c85f..778c5ae51c48 100644 --- a/.github/workflows/hardware-testing.yaml +++ b/.github/workflows/hardware-testing.yaml @@ -40,7 +40,7 @@ jobs: lint-test: name: 'hardware--testing package linting and tests' timeout-minutes: 20 - runs-on: 'ubuntu-20.04' + runs-on: 'ubuntu-24.04' steps: - name: Checkout opentrons repo uses: 'actions/checkout@v4' diff --git a/api/docs/v2/conf.py b/api/docs/v2/conf.py index f0f5f49db2af..401dd2dbc6a4 100644 --- a/api/docs/v2/conf.py +++ b/api/docs/v2/conf.py @@ -445,6 +445,7 @@ ("py:class", r".*protocol_api\.config.*"), ("py:class", r".*opentrons_shared_data.*"), ("py:class", r".*protocol_api._parameters.Parameters.*"), + ("py:class", r".*protocol_api._liquid_properties.TransferProperties*"), ("py:class", r".*RobotContext"), # shh it's a secret (for now) ("py:class", r".*FlexStackerContext"), # ssh it's a secret (for now) ( diff --git a/api/docs/v2/new_protocol_api.rst b/api/docs/v2/new_protocol_api.rst index d7428799fe07..d3001450f4b3 100644 --- a/api/docs/v2/new_protocol_api.rst +++ b/api/docs/v2/new_protocol_api.rst @@ -48,6 +48,9 @@ Wells and Liquids .. autoclass:: opentrons.protocol_api.Liquid +.. autoclass:: opentrons.protocol_api.LiquidClass + :members: + .. _protocol-api-modules: Modules diff --git a/api/src/opentrons/hardware_control/backends/subsystem_manager.py b/api/src/opentrons/hardware_control/backends/subsystem_manager.py index fa3105a8bede..9d0b49e14c3c 100644 --- a/api/src/opentrons/hardware_control/backends/subsystem_manager.py +++ b/api/src/opentrons/hardware_control/backends/subsystem_manager.py @@ -58,7 +58,7 @@ class SubsystemManager: _expected_core_targets: Set[FirmwareTarget] _present_tools: tools.types.ToolSummary _tool_task_condition: asyncio.Condition - _tool_task_state: Union[bool, Exception] + _tool_task_state: Union[bool, BaseException] _updates_required: Dict[FirmwareTarget, FirmwareUpdateRequirements] _updates_ongoing: Dict[SubSystem, UpdateStatus] _update_bag: FirmwareUpdate @@ -370,7 +370,8 @@ def update_state(status: UpdateStatus) -> None: async def _tool_detection_task_main(self) -> None: try: await self._tool_detection_task_protected() - except Exception as e: + except BaseException as e: + log.exception("Tool reader task failed") async with self._tool_task_condition: self._tool_task_state = e self._tool_task_condition.notify_all() @@ -414,9 +415,7 @@ async def _tool_detection_task_protected(self) -> None: ) self._present_tools = await self._tool_detector.resolve(to_resolve, 10.0) log.info(f"Present tools are now {self._present_tools}") - await network.log_motor_usage_data( - self._can_messenger, list(set(base_can_nodes + [t for t in tool_nodes])) - ) + await network.log_motor_usage_data(self._can_messenger) async with self._tool_task_condition: self._tool_task_state = True self._tool_task_condition.notify_all() diff --git a/api/src/opentrons/protocol_api/core/engine/instrument.py b/api/src/opentrons/protocol_api/core/engine/instrument.py index 211547f64175..4e5f0a497e6a 100644 --- a/api/src/opentrons/protocol_api/core/engine/instrument.py +++ b/api/src/opentrons/protocol_api/core/engine/instrument.py @@ -1141,7 +1141,7 @@ def get_next_tip( result.nextTipInfo if isinstance(result.nextTipInfo, NextTipInfo) else None ) - def transfer_liquid( # noqa: C901 + def transfer_with_liquid_class( # noqa: C901 self, liquid_class: LiquidClass, volume: float, @@ -1335,7 +1335,7 @@ def _pick_up_tip() -> WellCore: _drop_tip() # TODO(spp, 2025-02-25): wire up return tip - def distribute_liquid( # noqa: C901 + def distribute_with_liquid_class( # noqa: C901 self, liquid_class: LiquidClass, volume: float, @@ -1417,7 +1417,7 @@ def distribute_liquid( # noqa: C901 tip_working_volume=working_volume, ) ): - self.transfer_liquid( + self.transfer_with_liquid_class( liquid_class=liquid_class, volume=volume, source=[source for _ in range(len(dest))], @@ -1657,7 +1657,7 @@ def _tip_can_hold_volume_for_multi_dispensing( <= tip_working_volume ) - def consolidate_liquid( # noqa: C901 + def consolidate_with_liquid_class( # noqa: C901 self, liquid_class: LiquidClass, volume: float, diff --git a/api/src/opentrons/protocol_api/core/instrument.py b/api/src/opentrons/protocol_api/core/instrument.py index 7ad0f8a8e310..b99b36264b7e 100644 --- a/api/src/opentrons/protocol_api/core/instrument.py +++ b/api/src/opentrons/protocol_api/core/instrument.py @@ -359,7 +359,7 @@ def configure_nozzle_layout( ... @abstractmethod - def transfer_liquid( + def transfer_with_liquid_class( self, liquid_class: LiquidClass, volume: float, @@ -374,7 +374,7 @@ def transfer_liquid( ... @abstractmethod - def distribute_liquid( + def distribute_with_liquid_class( self, liquid_class: LiquidClass, volume: float, @@ -392,7 +392,7 @@ def distribute_liquid( ... @abstractmethod - def consolidate_liquid( + def consolidate_with_liquid_class( self, liquid_class: LiquidClass, volume: float, diff --git a/api/src/opentrons/protocol_api/core/legacy/legacy_instrument_core.py b/api/src/opentrons/protocol_api/core/legacy/legacy_instrument_core.py index 6be6f64c2cf4..cfe372ed2e93 100644 --- a/api/src/opentrons/protocol_api/core/legacy/legacy_instrument_core.py +++ b/api/src/opentrons/protocol_api/core/legacy/legacy_instrument_core.py @@ -624,7 +624,7 @@ def configure_nozzle_layout( """This will never be called because it was added in API 2.16.""" pass - def transfer_liquid( + def transfer_with_liquid_class( self, liquid_class: LiquidClass, volume: float, @@ -638,7 +638,7 @@ def transfer_liquid( """This will never be called because it was added in API 2.23""" assert False, "transfer_liquid is not supported in legacy context" - def distribute_liquid( + def distribute_with_liquid_class( self, liquid_class: LiquidClass, volume: float, @@ -652,7 +652,7 @@ def distribute_liquid( """This will never be called because it was added in API 2.23""" assert False, "distribute_liquid is not supported in legacy context" - def consolidate_liquid( + def consolidate_with_liquid_class( self, liquid_class: LiquidClass, volume: float, diff --git a/api/src/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py b/api/src/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py index c216326ac0d0..e54c7d4cfaac 100644 --- a/api/src/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py +++ b/api/src/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py @@ -513,7 +513,7 @@ def configure_nozzle_layout( """This will never be called because it was added in API 2.15.""" pass - def transfer_liquid( + def transfer_with_liquid_class( self, liquid_class: LiquidClass, volume: float, @@ -527,7 +527,7 @@ def transfer_liquid( """This will never be called because it was added in API 2.23.""" assert False, "transfer_liquid is not supported in legacy context" - def distribute_liquid( + def distribute_with_liquid_class( self, liquid_class: LiquidClass, volume: float, @@ -541,7 +541,7 @@ def distribute_liquid( """This will never be called because it was added in API 2.23.""" assert False, "distribute_liquid is not supported in legacy context" - def consolidate_liquid( + def consolidate_with_liquid_class( self, liquid_class: LiquidClass, volume: float, diff --git a/api/src/opentrons/protocol_api/instrument_context.py b/api/src/opentrons/protocol_api/instrument_context.py index 3239a6dac339..5bba788b47aa 100644 --- a/api/src/opentrons/protocol_api/instrument_context.py +++ b/api/src/opentrons/protocol_api/instrument_context.py @@ -1509,7 +1509,7 @@ def _execute_transfer(self, plan: v1_transfer.TransferPlan) -> None: getattr(self, cmd["method"])(*cmd["args"], **cmd["kwargs"]) @requires_version(2, 23) - def transfer_liquid( + def transfer_with_liquid_class( self, liquid_class: LiquidClass, volume: float, @@ -1526,11 +1526,32 @@ def transfer_liquid( return_tip: bool = False, visit_every_well: bool = False, ) -> InstrumentContext: - """Transfer liquid from source to dest using the specified liquid class properties. + """Move a particular type of liquid from one well or group of wells to another. - TODO: Add args description. + :param liquid_class: The type of liquid to move. You must specify the liquid class, + even if you have used :py:meth:`.load_liquid` to indicate what liquid the + source contains. + :type liquid_class: :py:class:`.LiquidClass` - :meta private: + :param volume: The amount, in µL, to aspirate from each source and dispense to + each destination. + :param source: A single well or a list of wells to aspirate liquid from. + :param dest: A single well or a list of wells to dispense liquid into. + :param new_tip: When to pick up and drop tips during the command. + Defaults to ``"once"``. + + - ``"once"``: Use one tip for the entire command. + - ``"always"``: Use a new tip for each set of aspirate and dispense steps. + - ``"per source"``: Use one tip for each source well, even if + :ref:`tip refilling ` is required. + - ``"never"``: Do not pick up or drop tips at all. + + See :ref:`param-tip-handling` for details. + + :param trash_location: A trash container, well, or other location to dispose of + tips. Depending on the liquid class, the pipette may also blow out liquid here. + :param return_tip: Whether to drop used tips in their original locations + in the tip rack, instead of the trash. """ transfer_args = verify_and_normalize_transfer_args( source=source, @@ -1552,7 +1573,7 @@ def transfer_liquid( " to transfer liquid onto one destinations from many sources, use 'consolidate_liquid'." ) - self._core.transfer_liquid( + self._core.transfer_with_liquid_class( liquid_class=liquid_class, volume=volume, source=[ @@ -1574,7 +1595,7 @@ def transfer_liquid( return self @requires_version(2, 23) - def distribute_liquid( + def distribute_with_liquid_class( self, liquid_class: LiquidClass, volume: float, @@ -1590,12 +1611,30 @@ def distribute_liquid( visit_every_well: bool = False, ) -> InstrumentContext: """ - Distribute liquid from a single source to multiple destinations - using the specified liquid class properties. + Distribute a particular type of liquid from one well to a group of wells. - TODO: Add args description. + :param liquid_class: The type of liquid to move. You must specify the liquid class, + even if you have used :py:meth:`.load_liquid` to indicate what liquid the + source contains. + :type liquid_class: :py:class:`.LiquidClass` - :meta private: + :param volume: The amount, in µL, to aspirate from the source and dispense to + each destination. + :param source: A single well to aspirate liquid from. + :param dest: A list of wells to dispense liquid into. + :param new_tip: When to pick up and drop tips during the command. + Defaults to ``"once"``. + + - ``"once"`` or ``"per source"``: Use one tip for the entire command. + - ``"always"``: Use a new tip for each set of aspirate and dispense steps. + - ``"never"``: Do not pick up or drop tips at all. + + See :ref:`param-tip-handling` for details. + + :param trash_location: A trash container, well, or other location to dispose of + tips. Depending on the liquid class, the pipette may also blow out liquid here. + :param return_tip: Whether to drop used tips in their original locations + in the tip rack, instead of the trash. """ transfer_args = verify_and_normalize_transfer_args( source=source, @@ -1621,7 +1660,7 @@ def distribute_liquid( ) verified_source = transfer_args.sources_list[0] - self._core.distribute_liquid( + self._core.distribute_with_liquid_class( liquid_class=liquid_class, volume=volume, source=( @@ -1643,7 +1682,7 @@ def distribute_liquid( return self @requires_version(2, 23) - def consolidate_liquid( + def consolidate_with_liquid_class( self, liquid_class: LiquidClass, volume: float, @@ -1659,12 +1698,31 @@ def consolidate_liquid( visit_every_well: bool = False, ) -> InstrumentContext: """ - Consolidate liquid from multiple sources to a single destination - using the specified liquid class properties. + Consolidate a particular type of liquid from a group of wells to one well. - TODO: Add args description. + :param liquid_class: The type of liquid to move. You must specify the liquid class, + even if you have used :py:meth:`.load_liquid` to indicate what liquid the + source contains. + :type liquid_class: :py:class:`.LiquidClass` - :meta private: + :param volume: The amount, in µL, to aspirate from the source and dispense to + each destination. + :param source: A list of wells to aspirate liquid from. + :param dest: A single well to dispense liquid into. + :param new_tip: When to pick up and drop tips during the command. + Defaults to ``"once"``. + + - ``"once"``: Use one tip for the entire command. + - ``"always"``: Use a new tip for each set of aspirate and dispense steps. + - ``"per source"``: Not available when consolidating. + - ``"never"``: Do not pick up or drop tips at all. + + See :ref:`param-tip-handling` for details. + + :param trash_location: A trash container, well, or other location to dispose of + tips. Depending on the liquid class, the pipette may also blow out liquid here. + :param return_tip: Whether to drop used tips in their original locations + in the tip rack, instead of the trash. """ transfer_args = verify_and_normalize_transfer_args( source=source, @@ -1690,7 +1748,7 @@ def consolidate_liquid( ) verified_dest = transfer_args.destinations_list[0] - self._core.consolidate_liquid( + self._core.consolidate_with_liquid_class( liquid_class=liquid_class, volume=volume, source=[ diff --git a/api/src/opentrons/protocol_engine/commands/flex_stacker/empty.py b/api/src/opentrons/protocol_engine/commands/flex_stacker/empty.py index 95b7018191dd..5f033e6eff6a 100644 --- a/api/src/opentrons/protocol_engine/commands/flex_stacker/empty.py +++ b/api/src/opentrons/protocol_engine/commands/flex_stacker/empty.py @@ -3,10 +3,11 @@ from __future__ import annotations from __future__ import annotations -from typing import Optional, Literal, TYPE_CHECKING +from typing import Optional, Literal, TYPE_CHECKING, Annotated from typing_extensions import Type from pydantic import BaseModel, Field +from pydantic.json_schema import SkipJsonSchema from ..command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData from ...errors import ( @@ -39,12 +40,12 @@ class EmptyParams(BaseModel): ), ) - message: str | None = Field( + message: str | SkipJsonSchema[None] = Field( None, description="The message to display on connected clients during a manualWithPause strategy empty.", ) - count: int | None = Field( + count: Optional[Annotated[int, Field(ge=0)]] = Field( None, description=( "The new count of labware in the pool. If None, default to an empty pool. If this number is " @@ -52,7 +53,6 @@ class EmptyParams(BaseModel): "Do not use the value in the parameters as an outside observer; instead, use the count value " "from the results." ), - ge=0, ) @@ -66,11 +66,11 @@ class EmptyResult(BaseModel): ..., description="The labware definition URI of the primary labware.", ) - adapterLabwareURI: str | None = Field( + adapterLabwareURI: str | SkipJsonSchema[None] = Field( None, description="The labware definition URI of the adapter labware.", ) - lidLabwareURI: str | None = Field( + lidLabwareURI: str | SkipJsonSchema[None] = Field( None, description="The labware definition URI of the lid labware.", ) diff --git a/api/src/opentrons/protocol_engine/commands/flex_stacker/fill.py b/api/src/opentrons/protocol_engine/commands/flex_stacker/fill.py index 569c2d8bbe33..921f242230ba 100644 --- a/api/src/opentrons/protocol_engine/commands/flex_stacker/fill.py +++ b/api/src/opentrons/protocol_engine/commands/flex_stacker/fill.py @@ -1,10 +1,11 @@ """Command models to engage a user to empty a Flex Stacker.""" from __future__ import annotations -from typing import Optional, Literal, TYPE_CHECKING +from typing import Optional, Literal, TYPE_CHECKING, Annotated from typing_extensions import Type from pydantic import BaseModel, Field +from pydantic.json_schema import SkipJsonSchema from ..command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData from ...errors import ( @@ -37,12 +38,12 @@ class FillParams(BaseModel): ), ) - message: str | None = Field( + message: str | SkipJsonSchema[None] = Field( None, description="The message to display on connected clients during a manualWithPause strategy fill.", ) - count: int | None = Field( + count: Optional[Annotated[int, Field(ge=1)]] = Field( None, description=( "How full the labware pool should now be. If None, default to the maximum amount " @@ -52,7 +53,6 @@ class FillParams(BaseModel): "holds, it will be clamped to that minimum. Do not use the value in the parameters as " "an outside observer; instead, use the count value from the results." ), - ge=1, ) @@ -66,11 +66,11 @@ class FillResult(BaseModel): ..., description="The labware definition URI of the primary labware.", ) - adapterLabwareURI: str | None = Field( + adapterLabwareURI: str | SkipJsonSchema[None] = Field( None, description="The labware definition URI of the adapter labware.", ) - lidLabwareURI: str | None = Field( + lidLabwareURI: str | SkipJsonSchema[None] = Field( None, description="The labware definition URI of the lid labware.", ) diff --git a/api/src/opentrons/protocol_engine/commands/flex_stacker/retrieve.py b/api/src/opentrons/protocol_engine/commands/flex_stacker/retrieve.py index f4425a6a81a7..82ec6936222b 100644 --- a/api/src/opentrons/protocol_engine/commands/flex_stacker/retrieve.py +++ b/api/src/opentrons/protocol_engine/commands/flex_stacker/retrieve.py @@ -98,33 +98,33 @@ class RetrieveResult(BaseModel): ..., description="The labware ID of the primary retrieved labware.", ) - adapterId: str | None = Field( + adapterId: str | SkipJsonSchema[None] = Field( None, description="The optional Adapter Labware ID of the adapter under a primary labware.", ) - lidId: str | None = Field( + lidId: str | SkipJsonSchema[None] = Field( None, description="The optional Lid Labware ID of the lid on a primary labware.", ) primaryLocationSequence: LabwareLocationSequence = Field( ..., description="The origin location of the primary labware." ) - lidLocationSequence: LabwareLocationSequence | None = Field( + lidLocationSequence: LabwareLocationSequence | SkipJsonSchema[None] = Field( None, description="The origin location of the adapter labware under a primary labware.", ) - adapterLocationSequence: LabwareLocationSequence | None = Field( + adapterLocationSequence: LabwareLocationSequence | SkipJsonSchema[None] = Field( None, description="The origin location of the lid labware on a primary labware." ) primaryLabwareURI: str = Field( ..., description="The labware definition URI of the primary labware.", ) - adapterLabwareURI: str | None = Field( + adapterLabwareURI: str | SkipJsonSchema[None] = Field( None, description="The labware definition URI of the adapter labware.", ) - lidLabwareURI: str | None = Field( + lidLabwareURI: str | SkipJsonSchema[None] = Field( None, description="The labware definition URI of the lid labware.", ) diff --git a/api/src/opentrons/protocol_engine/commands/flex_stacker/set_stored_labware.py b/api/src/opentrons/protocol_engine/commands/flex_stacker/set_stored_labware.py index 4583b95e121f..7fa71b707818 100644 --- a/api/src/opentrons/protocol_engine/commands/flex_stacker/set_stored_labware.py +++ b/api/src/opentrons/protocol_engine/commands/flex_stacker/set_stored_labware.py @@ -1,10 +1,11 @@ """Command models to configure the stored labware pool of a Flex Stacker..""" from __future__ import annotations -from typing import Optional, Literal, TYPE_CHECKING +from typing import Optional, Literal, TYPE_CHECKING, Annotated from typing_extensions import Type from pydantic import BaseModel, Field +from pydantic.json_schema import SkipJsonSchema from opentrons_shared_data.labware.labware_definition import LabwareDefinition @@ -45,21 +46,20 @@ class SetStoredLabwareParams(BaseModel): ..., description="The details of the primary labware (i.e. not the lid or adapter, if any) stored in the stacker.", ) - lidLabware: StackerStoredLabwareDetails | None = Field( + lidLabware: StackerStoredLabwareDetails | SkipJsonSchema[None] = Field( default=None, description="The details of the lid on the primary labware, if any.", ) - adapterLabware: StackerStoredLabwareDetails | None = Field( + adapterLabware: StackerStoredLabwareDetails | SkipJsonSchema[None] = Field( default=None, description="The details of the adapter under the primary labware, if any.", ) - initialCount: int | None = Field( + initialCount: Optional[Annotated[int, Field(ge=0)]] = Field( None, description=( "The number of labware that should be initially stored in the stacker. This number will be silently clamped to " "the maximum number of labware that will fit; do not rely on the parameter to know how many labware are in the stacker." ), - ge=0, ) @@ -69,11 +69,11 @@ class SetStoredLabwareResult(BaseModel): primaryLabwareDefinition: LabwareDefinition = Field( ..., description="The definition of the primary labware." ) - lidLabwareDefinition: LabwareDefinition | None = Field( - ..., description="The definition of the lid on the primary labware, if any." + lidLabwareDefinition: LabwareDefinition | SkipJsonSchema[None] = Field( + None, description="The definition of the lid on the primary labware, if any." ) - adapterLabwareDefinition: LabwareDefinition | None = Field( - ..., + adapterLabwareDefinition: LabwareDefinition | SkipJsonSchema[None] = Field( + None, description="The definition of the adapter under the primary labware, if any.", ) count: int = Field( diff --git a/api/src/opentrons/protocol_engine/commands/flex_stacker/store.py b/api/src/opentrons/protocol_engine/commands/flex_stacker/store.py index 022a7dc478e9..806f63dbfbaa 100644 --- a/api/src/opentrons/protocol_engine/commands/flex_stacker/store.py +++ b/api/src/opentrons/protocol_engine/commands/flex_stacker/store.py @@ -4,6 +4,7 @@ from typing import Optional, Literal, TYPE_CHECKING, Type, Union from pydantic import BaseModel, Field +from pydantic.json_schema import SkipJsonSchema from ..command import ( AbstractCommandImpl, @@ -57,39 +58,41 @@ class StoreParams(BaseModel): class StoreResult(BaseModel): """Result data from a labware storage command.""" - eventualDestinationLocationSequence: LabwareLocationSequence | None = Field( + eventualDestinationLocationSequence: LabwareLocationSequence | SkipJsonSchema[ + None + ] = Field( None, description=( "The full location in which all labware moved by this command will eventually reside." ), ) - primaryOriginLocationSequence: LabwareLocationSequence | None = Field( - None, description=("The origin location of the primary labware.") - ) - primaryLabwareId: str | None = Field( + primaryOriginLocationSequence: LabwareLocationSequence | SkipJsonSchema[ + None + ] = Field(None, description=("The origin location of the primary labware.")) + primaryLabwareId: str | SkipJsonSchema[None] = Field( None, description="The primary labware in the stack that was stored." ) - adapterOriginLocationSequence: LabwareLocationSequence | None = Field( - None, description=("The origin location of the adapter labware, if any.") - ) - adapterLabwareId: str | None = Field( + adapterOriginLocationSequence: LabwareLocationSequence | SkipJsonSchema[ + None + ] = Field(None, description=("The origin location of the adapter labware, if any.")) + adapterLabwareId: str | SkipJsonSchema[None] = Field( None, description="The adapter in the stack that was stored, if any." ) - lidOriginLocationSequence: LabwareLocationSequence | None = Field( + lidOriginLocationSequence: LabwareLocationSequence | SkipJsonSchema[None] = Field( None, description=("The origin location of the lid labware, if any.") ) - lidLabwareId: str | None = Field( + lidLabwareId: str | SkipJsonSchema[None] = Field( None, description="The lid in the stack that was stored, if any." ) primaryLabwareURI: str = Field( ..., description="The labware definition URI of the primary labware.", ) - adapterLabwareURI: str | None = Field( + adapterLabwareURI: str | SkipJsonSchema[None] = Field( None, description="The labware definition URI of the adapter labware.", ) - lidLabwareURI: str | None = Field( + lidLabwareURI: str | SkipJsonSchema[None] = Field( None, description="The labware definition URI of the lid labware.", ) diff --git a/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_drop_tip_in_place.py b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_drop_tip_in_place.py index d6b0652a4c04..33542d37f60c 100644 --- a/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_drop_tip_in_place.py +++ b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_drop_tip_in_place.py @@ -78,7 +78,12 @@ async def execute( [Axis.of_main_tool_actuator(pipette_location.mount.to_hw_mount())] ) await self._tip_handler.drop_tip( - pipette_id=params.pipetteId, home_after=params.homeAfter + pipette_id=params.pipetteId, + home_after=params.homeAfter, + ignore_plunger=( + self._state_view.tips.get_pipette_active_channels(params.pipetteId) + == 96 + ), ) state_update = StateUpdate() diff --git a/api/src/opentrons/protocol_engine/labware_offset_standardization.py b/api/src/opentrons/protocol_engine/labware_offset_standardization.py index 74626af55953..768cc158f15b 100644 --- a/api/src/opentrons/protocol_engine/labware_offset_standardization.py +++ b/api/src/opentrons/protocol_engine/labware_offset_standardization.py @@ -135,7 +135,7 @@ def _offset_location_sequence_to_legacy_offset_location( ) ( cutout_id, - cutout_fixtures, + _cutout_fixtures, ) = deck_configuration_provider.get_potential_cutout_fixtures( last_element.addressableAreaName, deck_definition ) diff --git a/api/src/opentrons/protocol_engine/state/frustum_helpers.py b/api/src/opentrons/protocol_engine/state/frustum_helpers.py index f56cb77c65f5..f07faff5fa98 100644 --- a/api/src/opentrons/protocol_engine/state/frustum_helpers.py +++ b/api/src/opentrons/protocol_engine/state/frustum_helpers.py @@ -402,7 +402,7 @@ def _find_height_in_partial_frustum( section_top_height, section_volume = capacity if ( bottom_section_volume - < target_volume + <= target_volume <= (bottom_section_volume + section_volume) ): relative_target_volume = target_volume - bottom_section_volume diff --git a/api/src/opentrons/protocol_engine/state/geometry.py b/api/src/opentrons/protocol_engine/state/geometry.py index 75cf82018ff1..c91a7417b252 100644 --- a/api/src/opentrons/protocol_engine/state/geometry.py +++ b/api/src/opentrons/protocol_engine/state/geometry.py @@ -484,20 +484,20 @@ def validate_well_position( if z_offset < lld_min_height: if isinstance(well_location, PickUpTipWellLocation): raise OperationLocationNotInWellError( - f"Specifying {well_location.origin} with an offset of {well_location.offset} results in an operation location that could be below the bottom of the well" + f"Specifying {well_location.origin} with a height offset of {well_location.offset.z} results in a height of {z_offset} mm; the minimum allowed height for liquid tracking is {lld_min_height} mm" ) else: raise OperationLocationNotInWellError( - f"Specifying {well_location.origin} with an offset of {well_location.offset} and a volume offset of {well_location.volumeOffset} results in an operation location that could be below the bottom of the well" + f"Specifying {well_location.origin} with a height offset of {well_location.offset.z} results in a height of {z_offset} mm; the minimum allowed height for liquid tracking is {lld_min_height} mm" ) elif z_offset < 0: if isinstance(well_location, PickUpTipWellLocation): raise OperationLocationNotInWellError( - f"Specifying {well_location.origin} with an offset of {well_location.offset} results in an operation location below the bottom of the well" + f"Specifying {well_location.origin} with an offset of {well_location.offset.z} results in a location below the bottom of the well" ) else: raise OperationLocationNotInWellError( - f"Specifying {well_location.origin} with an offset of {well_location.offset} and a volume offset of {well_location.volumeOffset} results in an operation location below the bottom of the well" + f"Specifying {well_location.origin} with an offset of {well_location.offset.z} and a volume offset of {well_location.volumeOffset} results in a location below the bottom of the well" ) def get_well_position( diff --git a/api/src/opentrons/protocol_engine/types/labware.py b/api/src/opentrons/protocol_engine/types/labware.py index bb8a4656d58c..d0bd4fe720ad 100644 --- a/api/src/opentrons/protocol_engine/types/labware.py +++ b/api/src/opentrons/protocol_engine/types/labware.py @@ -34,6 +34,7 @@ class LabwareOffset(BaseModel): definitionUri: str = Field(..., description="The URI for the labware's definition.") location: LegacyLabwareOffsetLocation = Field( ..., + deprecated=True, description="Where the labware is located on the robot. Deprecated and present only for backwards compatibility; cannot represent certain locations. Use locationSequence instead.", ) locationSequence: Optional[LabwareOffsetLocationSequence] = Field( diff --git a/api/src/opentrons/protocol_engine/types/labware_offset_location.py b/api/src/opentrons/protocol_engine/types/labware_offset_location.py index 2c4a488be4bd..a6226afc90a7 100644 --- a/api/src/opentrons/protocol_engine/types/labware_offset_location.py +++ b/api/src/opentrons/protocol_engine/types/labware_offset_location.py @@ -18,7 +18,10 @@ class OnLabwareOffsetLocationSequenceComponent(BaseModel): kind: Literal["onLabware"] = "onLabware" labwareUri: str = Field( ..., - description="The definition URI of a labware that a labware can be loaded onto.", + description=( + "The definition URI of another labware, probably an adapter," + " that the labware will be loaded onto." + ), ) @@ -99,8 +102,8 @@ class LegacyLabwareOffsetLocation(BaseModel): definitionUri: Optional[str] = Field( None, description=( - "The definition URI of a labware that a labware can be loaded onto," - " if applicable." + "The definition URI of another labware, probably an adapter, that the" + " labware will be loaded onto, if applicable." "\n\n" "This can be combined with moduleModel if the labware is loaded on top of" " an adapter that is loaded on a module." diff --git a/api/src/opentrons/protocol_engine/types/location.py b/api/src/opentrons/protocol_engine/types/location.py index 435ae08a18d7..b86b058dff2d 100644 --- a/api/src/opentrons/protocol_engine/types/location.py +++ b/api/src/opentrons/protocol_engine/types/location.py @@ -4,6 +4,7 @@ from typing import Literal, Union, TypeGuard from pydantic import BaseModel, Field +from pydantic.json_schema import SkipJsonSchema from opentrons.types import DeckSlotName, StagingSlotName @@ -107,7 +108,7 @@ class OnLabwareLocationSequenceComponent(BaseModel): kind: Literal["onLabware"] = "onLabware" labwareId: str - lidId: str | None + lidId: str | SkipJsonSchema[None] = Field(None) class OnModuleLocationSequenceComponent(BaseModel): diff --git a/api/tests/opentrons/protocol_api/test_instrument_context.py b/api/tests/opentrons/protocol_api/test_instrument_context.py index 78e1101a1ee4..7348258e5286 100644 --- a/api/tests/opentrons/protocol_api/test_instrument_context.py +++ b/api/tests/opentrons/protocol_api/test_instrument_context.py @@ -1704,7 +1704,7 @@ def test_transfer_liquid_raises_for_invalid_locations( mock_well = decoy.mock(cls=Well) decoy.when(mock_protocol_core.robot_type).then_return(robot_type) with pytest.raises(ValueError): - subject.transfer_liquid( + subject.transfer_with_liquid_class( liquid_class=test_liq_class, volume=10, source=[], @@ -1733,7 +1733,7 @@ def test_transfer_liquid_raises_for_unequal_source_and_dest( with pytest.raises( ValueError, match="Sources and destinations should be of the same length" ): - subject.transfer_liquid( + subject.transfer_with_liquid_class( liquid_class=test_liq_class, volume=10, source=mock_well, @@ -1764,7 +1764,7 @@ def test_transfer_liquid_raises_for_non_liquid_handling_locations( ) ).then_raise(ValueError("Uh oh")) with pytest.raises(ValueError, match="Uh oh"): - subject.transfer_liquid( + subject.transfer_with_liquid_class( liquid_class=test_liq_class, volume=10, source=[mock_well], dest=[mock_well] ) @@ -1786,7 +1786,7 @@ def test_transfer_liquid_raises_for_bad_tip_policy( decoy.when(mock_nozzle_map.tip_count).then_return(1) decoy.when(mock_instrument_core.get_nozzle_map()).then_return(mock_nozzle_map) with pytest.raises(ValueError, match="invalid value for 'new_tip'"): - subject.transfer_liquid( + subject.transfer_with_liquid_class( liquid_class=test_liq_class, volume=10, source=[mock_well], @@ -1812,7 +1812,7 @@ def test_transfer_liquid_raises_for_no_tip( decoy.when(mock_nozzle_map.tip_count).then_return(1) decoy.when(mock_instrument_core.get_nozzle_map()).then_return(mock_nozzle_map) with pytest.raises(RuntimeError, match="Pipette has no tip"): - subject.transfer_liquid( + subject.transfer_with_liquid_class( liquid_class=test_liq_class, volume=10, source=[mock_well], @@ -1851,7 +1851,7 @@ def test_transfer_liquid_raises_if_tip_has_liquid( ).then_return((decoy.mock(cls=Labware), decoy.mock(cls=Well))) decoy.when(mock_instrument_core.get_current_volume()).then_return(1000) with pytest.raises(RuntimeError, match="liquid already in the tip"): - subject.transfer_liquid( + subject.transfer_with_liquid_class( liquid_class=test_liq_class, volume=10, source=[mock_well], @@ -1884,7 +1884,7 @@ def test_transfer_liquid_delegates_to_engine_core( decoy.when(mock_instrument_core.get_current_volume()).then_return(0) decoy.when(next_tiprack.uri).then_return("tiprack-uri") decoy.when(mock_instrument_core.get_pipette_name()).then_return("pipette-name") - subject.transfer_liquid( + subject.transfer_with_liquid_class( liquid_class=test_liq_class, volume=10, source=[mock_well], @@ -1894,7 +1894,7 @@ def test_transfer_liquid_delegates_to_engine_core( return_tip=True, ) decoy.verify( - mock_instrument_core.transfer_liquid( + mock_instrument_core.transfer_with_liquid_class( liquid_class=test_liq_class, volume=10, source=[(Location(Point(), labware=mock_well), mock_well._core)], @@ -1939,7 +1939,7 @@ def test_transfer_liquid_multi_channel_delegates_to_engine_core( decoy.when(mock_instrument_core.get_current_volume()).then_return(0) decoy.when(next_tiprack.uri).then_return("tiprack-uri") decoy.when(mock_instrument_core.get_pipette_name()).then_return("pipette-name") - subject.transfer_liquid( + subject.transfer_with_liquid_class( liquid_class=test_liq_class, volume=10, source=[[mock_well, mock_well]], @@ -1949,7 +1949,7 @@ def test_transfer_liquid_multi_channel_delegates_to_engine_core( return_tip=True, ) decoy.verify( - mock_instrument_core.transfer_liquid( + mock_instrument_core.transfer_with_liquid_class( liquid_class=test_liq_class, volume=10, source=[(Location(Point(), labware=mock_well), mock_well._core)], @@ -1980,7 +1980,7 @@ def test_distribute_liquid_raises_if_more_than_one_source( decoy.when(mock_instrument_core.get_nozzle_map()).then_return(mock_nozzle_map) decoy.when(mock_instrument_core.get_current_volume()).then_return(0) with pytest.raises(ValueError, match="Source should be a single well"): - subject.distribute_liquid( + subject.distribute_with_liquid_class( liquid_class=test_liq_class, volume=10, source=[mock_well, mock_well], @@ -2011,7 +2011,7 @@ def test_distribute_liquid_raises_for_non_liquid_handling_locations( ) ).then_raise(ValueError("Uh oh")) with pytest.raises(ValueError, match="Uh oh"): - subject.distribute_liquid( + subject.distribute_with_liquid_class( liquid_class=test_liq_class, volume=10, source=mock_well, dest=[mock_well] ) @@ -2033,7 +2033,7 @@ def test_distribute_liquid_raises_for_bad_tip_policy( decoy.when(mock_nozzle_map.tip_count).then_return(1) decoy.when(mock_instrument_core.get_nozzle_map()).then_return(mock_nozzle_map) with pytest.raises(ValueError, match="invalid value for 'new_tip'"): - subject.distribute_liquid( + subject.distribute_with_liquid_class( liquid_class=test_liq_class, volume=10, source=mock_well, @@ -2059,7 +2059,7 @@ def test_distribute_liquid_raises_for_no_tip( decoy.when(mock_nozzle_map.tip_count).then_return(1) decoy.when(mock_instrument_core.get_nozzle_map()).then_return(mock_nozzle_map) with pytest.raises(RuntimeError, match="Pipette has no tip"): - subject.distribute_liquid( + subject.distribute_with_liquid_class( liquid_class=test_liq_class, volume=10, source=mock_well, @@ -2098,7 +2098,7 @@ def test_distribute_liquid_raises_if_tip_has_liquid( ).then_return((decoy.mock(cls=Labware), decoy.mock(cls=Well))) decoy.when(mock_instrument_core.get_current_volume()).then_return(1000) with pytest.raises(RuntimeError, match="liquid already in the tip"): - subject.distribute_liquid( + subject.distribute_with_liquid_class( liquid_class=test_liq_class, volume=10, source=mock_well, @@ -2127,7 +2127,7 @@ def test_distribute_liquid_raises_if_tip_policy_per_source( with pytest.raises( RuntimeError, match='"per source" incompatible with distribute.' ): - subject.distribute_liquid( + subject.distribute_with_liquid_class( liquid_class=test_liq_class, volume=10, source=mock_well, @@ -2161,7 +2161,7 @@ def test_distribute_liquid_delegates_to_engine_core( decoy.when(mock_instrument_core.get_current_volume()).then_return(0) decoy.when(next_tiprack.uri).then_return("tiprack-uri") decoy.when(mock_instrument_core.get_pipette_name()).then_return("pipette-name") - subject.distribute_liquid( + subject.distribute_with_liquid_class( liquid_class=test_liq_class, volume=10, source=mock_well, @@ -2171,7 +2171,7 @@ def test_distribute_liquid_delegates_to_engine_core( return_tip=True, ) decoy.verify( - mock_instrument_core.distribute_liquid( + mock_instrument_core.distribute_with_liquid_class( liquid_class=test_liq_class, volume=10, source=(Location(Point(), labware=mock_well), mock_well._core), @@ -2221,7 +2221,7 @@ def test_distribute_liquid_multi_channel_delegates_to_engine_core( decoy.when(mock_instrument_core.get_current_volume()).then_return(0) decoy.when(next_tiprack.uri).then_return("tiprack-uri") decoy.when(mock_instrument_core.get_pipette_name()).then_return("pipette-name") - subject.distribute_liquid( + subject.distribute_with_liquid_class( liquid_class=test_liq_class, volume=10, source=[mock_well, mock_well, mock_well], @@ -2231,7 +2231,7 @@ def test_distribute_liquid_multi_channel_delegates_to_engine_core( return_tip=True, ) decoy.verify( - mock_instrument_core.distribute_liquid( + mock_instrument_core.distribute_with_liquid_class( liquid_class=test_liq_class, volume=10, source=(Location(Point(), labware=mock_well), mock_well._core), @@ -2265,7 +2265,7 @@ def test_consolidate_liquid_raises_if_more_than_one_destination( decoy.when(mock_instrument_core.get_nozzle_map()).then_return(mock_nozzle_map) decoy.when(mock_instrument_core.get_current_volume()).then_return(0) with pytest.raises(ValueError, match="Destination should be a single well"): - subject.consolidate_liquid( + subject.consolidate_with_liquid_class( liquid_class=test_liq_class, volume=10, source=[mock_well, mock_well], @@ -2296,7 +2296,7 @@ def test_consolidate_liquid_raises_for_non_liquid_handling_locations( ) ).then_raise(ValueError("Uh oh")) with pytest.raises(ValueError, match="Uh oh"): - subject.consolidate_liquid( + subject.consolidate_with_liquid_class( liquid_class=test_liq_class, volume=10, source=[mock_well], dest=mock_well ) @@ -2318,7 +2318,7 @@ def test_consolidate_liquid_raises_for_bad_tip_policy( decoy.when(mock_nozzle_map.tip_count).then_return(1) decoy.when(mock_instrument_core.get_nozzle_map()).then_return(mock_nozzle_map) with pytest.raises(ValueError, match="invalid value for 'new_tip'"): - subject.consolidate_liquid( + subject.consolidate_with_liquid_class( liquid_class=test_liq_class, volume=10, source=[mock_well], @@ -2344,7 +2344,7 @@ def test_consolidate_liquid_raises_for_no_tip( decoy.when(mock_nozzle_map.tip_count).then_return(1) decoy.when(mock_instrument_core.get_nozzle_map()).then_return(mock_nozzle_map) with pytest.raises(RuntimeError, match="Pipette has no tip"): - subject.consolidate_liquid( + subject.consolidate_with_liquid_class( liquid_class=test_liq_class, volume=10, source=[mock_well], @@ -2376,7 +2376,7 @@ def test_consolidate_liquid_raises_if_tip_has_liquid( decoy.when(mock_instrument_core.get_nozzle_map()).then_return(mock_nozzle_map) decoy.when(mock_instrument_core.get_current_volume()).then_return(1000) with pytest.raises(RuntimeError, match="liquid already in the tip"): - subject.consolidate_liquid( + subject.consolidate_with_liquid_class( liquid_class=test_liq_class, volume=10, source=[mock_well], @@ -2405,7 +2405,7 @@ def test_consolidate_liquid_raises_if_tip_policy_per_source( with pytest.raises( RuntimeError, match='"per source" incompatible with consolidate.' ): - subject.consolidate_liquid( + subject.consolidate_with_liquid_class( liquid_class=test_liq_class, volume=10, source=[mock_well], @@ -2440,7 +2440,7 @@ def test_consolidate_liquid_delegates_to_engine_core( decoy.when(next_tiprack.uri).then_return("tiprack-uri") decoy.when(mock_instrument_core.get_pipette_name()).then_return("pipette-name") - subject.consolidate_liquid( + subject.consolidate_with_liquid_class( liquid_class=test_liq_class, volume=10, source=[mock_well], @@ -2450,7 +2450,7 @@ def test_consolidate_liquid_delegates_to_engine_core( return_tip=True, ) decoy.verify( - mock_instrument_core.consolidate_liquid( + mock_instrument_core.consolidate_with_liquid_class( liquid_class=test_liq_class, volume=10, source=[(Location(Point(), labware=mock_well), mock_well._core)], @@ -2501,7 +2501,7 @@ def test_consolidate_liquid_multi_channel_delegates_to_engine_core( decoy.when(next_tiprack.uri).then_return("tiprack-uri") decoy.when(mock_instrument_core.get_pipette_name()).then_return("pipette-name") - subject.consolidate_liquid( + subject.consolidate_with_liquid_class( liquid_class=test_liq_class, volume=10, source=[[mock_well, mock_well]], @@ -2511,7 +2511,7 @@ def test_consolidate_liquid_multi_channel_delegates_to_engine_core( return_tip=True, ) decoy.verify( - mock_instrument_core.consolidate_liquid( + mock_instrument_core.consolidate_with_liquid_class( liquid_class=test_liq_class, volume=10, source=[ diff --git a/api/tests/opentrons/protocol_api_integration/test_transfer_with_liquid_classes.py b/api/tests/opentrons/protocol_api_integration/test_transfer_with_liquid_classes.py index 382da08393ec..3047131a7bf8 100644 --- a/api/tests/opentrons/protocol_api_integration/test_transfer_with_liquid_classes.py +++ b/api/tests/opentrons/protocol_api_integration/test_transfer_with_liquid_classes.py @@ -50,7 +50,7 @@ def test_water_transfer_with_volume_more_than_tip_max( mock_manager = mock.Mock() mock_manager.attach_mock(patched_pick_up_tip, "pick_up_tip") - pipette_1k.transfer_liquid( + pipette_1k.transfer_with_liquid_class( liquid_class=water, volume=60, source=nest_plate.rows()[0], @@ -61,7 +61,7 @@ def test_water_transfer_with_volume_more_than_tip_max( assert patched_pick_up_tip.call_count == 24 patched_pick_up_tip.reset_mock() - pipette_1k.transfer_liquid( + pipette_1k.transfer_with_liquid_class( liquid_class=water, volume=100, source=nest_plate.rows()[0], @@ -73,7 +73,7 @@ def test_water_transfer_with_volume_more_than_tip_max( patched_pick_up_tip.reset_mock() pipette_1k.pick_up_tip() - pipette_1k.transfer_liquid( + pipette_1k.transfer_with_liquid_class( liquid_class=water, volume=50, source=nest_plate.rows()[0], @@ -151,7 +151,7 @@ def test_order_of_water_transfer_steps( mock_manager.attach_mock(patched_aspirate, "aspirate_liquid_class") mock_manager.attach_mock(patched_dispense, "dispense_liquid_class") mock_manager.attach_mock(patched_drop_tip, "drop_tip_in_disposal_location") - pipette_50.transfer_liquid( + pipette_50.transfer_with_liquid_class( liquid_class=water, volume=40, source=nest_plate.rows()[0][:2], @@ -303,7 +303,7 @@ def test_order_of_water_transfer_steps_with_return_tip( mock_manager.attach_mock(patched_aspirate, "aspirate_liquid_class") mock_manager.attach_mock(patched_dispense, "dispense_liquid_class") mock_manager.attach_mock(patched_drop_tip, "drop_tip") - pipette_50.transfer_liquid( + pipette_50.transfer_with_liquid_class( liquid_class=water, volume=40, source=nest_plate.rows()[0][:2], @@ -459,7 +459,7 @@ def test_order_of_water_transfer_steps_with_no_new_tips( mock_manager.attach_mock(patched_aspirate, "aspirate_liquid_class") mock_manager.attach_mock(patched_dispense, "dispense_liquid_class") mock_manager.attach_mock(patched_drop_tip, "drop_tip_in_disposal_location") - pipette_50.transfer_liquid( + pipette_50.transfer_with_liquid_class( liquid_class=water, volume=40, source=nest_plate.rows()[0][:2], @@ -585,7 +585,7 @@ def test_order_of_water_consolidate_steps( mock_manager.attach_mock(patched_aspirate, "aspirate_liquid_class") mock_manager.attach_mock(patched_dispense, "dispense_liquid_class") mock_manager.attach_mock(patched_drop_tip, "drop_tip_in_disposal_location") - pipette_50.consolidate_liquid( + pipette_50.consolidate_with_liquid_class( liquid_class=water, volume=25, source=nest_plate.rows()[0][:2], @@ -713,7 +713,7 @@ def test_order_of_water_consolidate_steps_larger_volume_then_tip( mock_manager.attach_mock(patched_aspirate, "aspirate_liquid_class") mock_manager.attach_mock(patched_dispense, "dispense_liquid_class") mock_manager.attach_mock(patched_drop_tip, "drop_tip_in_disposal_location") - pipette_50.consolidate_liquid( + pipette_50.consolidate_with_liquid_class( liquid_class=water, volume=30, source=nest_plate.rows()[0][:2], @@ -866,7 +866,7 @@ def test_order_of_water_consolidate_steps_with_no_new_tips( mock_manager.attach_mock(patched_aspirate, "aspirate_liquid_class") mock_manager.attach_mock(patched_dispense, "dispense_liquid_class") mock_manager.attach_mock(patched_drop_tip, "drop_tip_in_disposal_location") - pipette_50.consolidate_liquid( + pipette_50.consolidate_with_liquid_class( liquid_class=water, volume=25, source=nest_plate.rows()[0][:2], @@ -981,7 +981,7 @@ def test_order_of_water_consolidate_steps_with_return_tip( mock_manager.attach_mock(patched_aspirate, "aspirate_liquid_class") mock_manager.attach_mock(patched_dispense, "dispense_liquid_class") mock_manager.attach_mock(patched_drop_tip, "drop_tip") - pipette_50.consolidate_liquid( + pipette_50.consolidate_with_liquid_class( liquid_class=water, volume=25, source=nest_plate.rows()[0][:2], @@ -1081,7 +1081,7 @@ def test_water_distribution_with_volume_more_than_tip_max( mock_manager = mock.Mock() mock_manager.attach_mock(patched_pick_up_tip, "pick_up_tip") - pipette_1k.distribute_liquid( + pipette_1k.distribute_with_liquid_class( liquid_class=water, volume=60, source=nest_plate.rows()[0][0], @@ -1092,7 +1092,7 @@ def test_water_distribution_with_volume_more_than_tip_max( assert patched_pick_up_tip.call_count == 1 patched_pick_up_tip.reset_mock() - pipette_1k.distribute_liquid( + pipette_1k.distribute_with_liquid_class( liquid_class=water, volume=100, source=nest_plate.rows()[0][0], @@ -1104,7 +1104,7 @@ def test_water_distribution_with_volume_more_than_tip_max( patched_pick_up_tip.reset_mock() pipette_1k.pick_up_tip() - pipette_1k.distribute_liquid( + pipette_1k.distribute_with_liquid_class( liquid_class=water, volume=50, source=nest_plate.rows()[0][0], @@ -1187,7 +1187,7 @@ def test_order_of_water_distribution_steps_using_multi_dispense( patched_dispense, "dispense_liquid_class_during_multi_dispense" ) mock_manager.attach_mock(patched_drop_tip, "drop_tip_in_disposal_location") - pipette_1k.distribute_liquid( + pipette_1k.distribute_with_liquid_class( liquid_class=water, volume=40, source=nest_plate.rows()[0][1], @@ -1351,7 +1351,7 @@ def test_order_of_water_distribute_steps_using_one_to_one_transfers( mock_manager.attach_mock(patched_aspirate, "aspirate_liquid_class") mock_manager.attach_mock(patched_dispense, "dispense_liquid_class") mock_manager.attach_mock(patched_drop_tip, "drop_tip_in_disposal_location") - pipette_50.distribute_liquid( + pipette_50.distribute_with_liquid_class( liquid_class=water, volume=distribute_volume, source=nest_plate.rows()[0][2], @@ -1524,7 +1524,7 @@ def test_order_of_water_distribution_steps_using_mixed_dispense( patched_multi_dispense, "dispense_liquid_class_during_multi_dispense" ) mock_manager.attach_mock(patched_drop_tip, "drop_tip_in_disposal_location") - pipette_1k.distribute_liquid( + pipette_1k.distribute_with_liquid_class( liquid_class=water, volume=400, source=nest_plate.rows()[0][1], @@ -1685,7 +1685,7 @@ def test_water_distribute_steps_with_return_tip( mock_manager = mock.Mock() mock_manager.attach_mock(patched_pick_up_tip, "pick_up_tip") mock_manager.attach_mock(patched_drop_tip, "drop_tip") - pipette_1k.distribute_liquid( + pipette_1k.distribute_with_liquid_class( liquid_class=water, volume=400, source=nest_plate.rows()[0][1], @@ -1752,7 +1752,7 @@ def test_water_distribution_raises_error_for_disposal_vol_without_blowout( RuntimeError, match="Specify a blowout location and enable blowout when using a disposal volume", ): - pipette_1k.distribute_liquid( + pipette_1k.distribute_with_liquid_class( liquid_class=water, volume=140, source=nest_plate.rows()[0][0], @@ -1804,7 +1804,7 @@ def test_water_transfer_with_lpd( ): mock_manager = mock.Mock() mock_manager.attach_mock(patched_liquid_probe, "liquid_probe_with_recovery") - pipette_1k.transfer_liquid( + pipette_1k.transfer_with_liquid_class( liquid_class=water, volume=1100, source=nest_plate.rows()[0], @@ -1856,7 +1856,7 @@ def test_water_transfer_does_lpd_only_once_for_a_source_well( ): mock_manager = mock.Mock() mock_manager.attach_mock(patched_liquid_probe, "liquid_probe_with_recovery") - pipette_1k.transfer_liquid( + pipette_1k.transfer_with_liquid_class( liquid_class=water, volume=1100, source=[nest_plate.wells_by_name()["A1"] for _ in range(3)], @@ -1911,7 +1911,7 @@ def test_water_distribution_with_lpd( ): mock_manager = mock.Mock() mock_manager.attach_mock(patched_liquid_probe, "liquid_probe_with_recovery") - pipette_1k.distribute_liquid( + pipette_1k.distribute_with_liquid_class( liquid_class=water, volume=40, source=nest_plate.rows()[0][1], @@ -1964,7 +1964,7 @@ def test_incompatible_transfers_skip_probing_even_with_lpd_on( ): mock_manager = mock.Mock() mock_manager.attach_mock(patched_liquid_probe, "liquid_probe_with_recovery") - pipette_1k.transfer_liquid( + pipette_1k.transfer_with_liquid_class( liquid_class=water, volume=40, source=nest_plate.rows()[0], @@ -1972,7 +1972,7 @@ def test_incompatible_transfers_skip_probing_even_with_lpd_on( new_tip="never", trash_location=trash, ) - pipette_1k.distribute_liquid( + pipette_1k.distribute_with_liquid_class( liquid_class=water, volume=40, source=nest_plate.rows()[0][1], @@ -1980,7 +1980,7 @@ def test_incompatible_transfers_skip_probing_even_with_lpd_on( new_tip="never", trash_location=trash, ) - pipette_1k.consolidate_liquid( + pipette_1k.consolidate_with_liquid_class( liquid_class=water, volume=40, source=nest_plate.rows()[0], @@ -1989,7 +1989,7 @@ def test_incompatible_transfers_skip_probing_even_with_lpd_on( trash_location=trash, ) pipette_1k.drop_tip() - pipette_1k.consolidate_liquid( + pipette_1k.consolidate_with_liquid_class( liquid_class=water, volume=40, source=nest_plate.rows()[0], @@ -1997,7 +1997,7 @@ def test_incompatible_transfers_skip_probing_even_with_lpd_on( new_tip="once", trash_location=trash, ) - pipette_1k.consolidate_liquid( + pipette_1k.consolidate_with_liquid_class( liquid_class=water, volume=40, source=nest_plate.rows()[0], @@ -2053,7 +2053,7 @@ def test_water_transfer_with_multi_channel_pipette( mock_manager = mock.Mock() mock_manager.attach_mock(patched_aspirate, "aspirate_liquid_class") mock_manager.attach_mock(patched_dispense, "dispense_liquid_class") - pipette_50.transfer_liquid( + pipette_50.transfer_with_liquid_class( liquid_class=water, volume=40, source=nest_plate.columns()[:2], diff --git a/api/tests/opentrons/protocol_engine/commands/unsafe/test_unsafe_drop_tip_in_place.py b/api/tests/opentrons/protocol_engine/commands/unsafe/test_unsafe_drop_tip_in_place.py index e7c684554c83..27f2964ab741 100644 --- a/api/tests/opentrons/protocol_engine/commands/unsafe/test_unsafe_drop_tip_in_place.py +++ b/api/tests/opentrons/protocol_engine/commands/unsafe/test_unsafe_drop_tip_in_place.py @@ -1,4 +1,5 @@ """Test unsafe drop tip in place commands.""" + from opentrons.protocol_engine.state.update_types import ( PipetteTipStateUpdate, PipetteUnknownFluidUpdate, @@ -29,11 +30,13 @@ def mock_tip_handler(decoy: Decoy) -> TipHandler: return decoy.mock(cls=TipHandler) +@pytest.mark.parametrize("channels", [1, 8, 96]) async def test_drop_tip_implementation( decoy: Decoy, mock_tip_handler: TipHandler, state_view: StateView, ot3_hardware_api: OT3HardwareControlAPI, + channels: int, ) -> None: """A DropTip command should have an execution implementation.""" subject = UnsafeDropTipInPlaceImplementation( @@ -46,6 +49,9 @@ async def test_drop_tip_implementation( decoy.when(state_view.motion.get_pipette_location(pipette_id="abc")).then_return( PipetteLocationData(mount=MountType.LEFT, critical_point=None) ) + decoy.when( + state_view.tips.get_pipette_active_channels(params.pipetteId) + ).then_return(channels) result = await subject.execute(params) @@ -61,6 +67,8 @@ async def test_drop_tip_implementation( decoy.verify( await ot3_hardware_api.update_axis_position_estimations([Axis.P_L]), - await mock_tip_handler.drop_tip(pipette_id="abc", home_after=False), + await mock_tip_handler.drop_tip( + pipette_id="abc", home_after=False, ignore_plunger=(channels == 96) + ), times=1, ) diff --git a/api/tests/opentrons/protocols/geometry/test_frustum_helpers.py b/api/tests/opentrons/protocols/geometry/test_frustum_helpers.py index 3a7d15f58e9c..4ec9a4a60322 100644 --- a/api/tests/opentrons/protocols/geometry/test_frustum_helpers.py +++ b/api/tests/opentrons/protocols/geometry/test_frustum_helpers.py @@ -317,10 +317,12 @@ def test_volume_and_height_spherical(well: List[Any]) -> None: @pytest.mark.parametrize("well", fake_frusta()) -def test_height_at_volume_within_section(well: List[Any]) -> None: - """Test that finding the height when volume ~= capacity works.""" +def test_height_at_volume_at_section_boundaries(well: List[Any]) -> None: + """Test that finding the height when volume 0 or ~= capacity works.""" for segment in well: segment_height = segment.topHeight - segment.bottomHeight + height = height_at_volume_within_section(segment, 0.0, segment_height) + assert isclose(height, 0.0) height = height_at_volume_within_section( segment, _get_segment_capacity(segment), segment_height ) diff --git a/app-shell/src/labware/validation.ts b/app-shell/src/labware/validation.ts index 0d8d51ee76ae..d8fe3e5efc77 100644 --- a/app-shell/src/labware/validation.ts +++ b/app-shell/src/labware/validation.ts @@ -1,6 +1,9 @@ import Ajv from 'ajv' import sortBy from 'lodash/sortBy' -import { labwareSchemaV2 as labwareSchema } from '@opentrons/shared-data' +import { + labwareSchemaV2 as labwareSchema, + validateCustomLabwareHelper, +} from '@opentrons/shared-data' import { sameIdentity } from './compare' import type { LabwareDefinition2 } from '@opentrons/shared-data' @@ -34,7 +37,10 @@ export function validateLabwareFiles( // check file against the schema // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions const definition = data && validateLabwareDefinition(data) - if (definition === null) { + + const hasValidWellInfo = validateCustomLabwareHelper(definition) + + if (definition === null || !hasValidWellInfo) { return { filename, modified, type: INVALID_LABWARE_FILE } } diff --git a/app/src/assets/images/labware/opentrons_evotip_short_adapter.png b/app/src/assets/images/labware/opentrons_evotip_short_adapter.png new file mode 100644 index 000000000000..88cd791a8f7c Binary files /dev/null and b/app/src/assets/images/labware/opentrons_evotip_short_adapter.png differ diff --git a/app/src/assets/images/labware/opentrons_evotip_tall_adapter.png b/app/src/assets/images/labware/opentrons_evotip_tall_adapter.png new file mode 100644 index 000000000000..06fe13c3f7c4 Binary files /dev/null and b/app/src/assets/images/labware/opentrons_evotip_tall_adapter.png differ diff --git a/app/src/assets/localization/en/app_settings.json b/app/src/assets/localization/en/app_settings.json index 487429d491fa..e47ff8ba30bd 100644 --- a/app/src/assets/localization/en/app_settings.json +++ b/app/src/assets/localization/en/app_settings.json @@ -5,9 +5,9 @@ "__dev_internal__lpcRedesign": "LPC Redesign", "__dev_internal__protocolStats": "Protocol Stats", "__dev_internal__protocolTimeline": "Protocol Timeline", + "__dev_internal__quickTransferExportPython": "Enable Python Export for Quick Transfer", "__dev_internal__reactQueryDevtools": "Enable React Query Devtools", "__dev_internal__reactScan": "Enable React Scan", - "__dev_internal__quickTransferExportPython": "Enable Python Export for Quick Transfer", "__dev_internal__liquidClassesForQuickTransfer": "Enable Liquid Classes for Quick Transfer", "add_folder_button": "Add labware source folder", "add_ip_button": "Add", diff --git a/app/src/assets/localization/en/device_details.json b/app/src/assets/localization/en/device_details.json index 11ecc8edc2bd..20f5276f8de2 100644 --- a/app/src/assets/localization/en/device_details.json +++ b/app/src/assets/localization/en/device_details.json @@ -186,8 +186,8 @@ "trash": "Trash", "update_now": "Update now", "updating_firmware": "Updating firmware...", - "usb_port_not_connected": "usb not connected", "usb_port": "usb-{{port}}{{hubPort}}", + "usb_port_not_connected": "usb not connected", "version": "Version {{version}}", "view": "View", "view_pipette_setting": "Pipette Settings", diff --git a/app/src/assets/localization/en/labware_position_check.json b/app/src/assets/localization/en/labware_position_check.json index b466fd7f3442..822963459ce3 100644 --- a/app/src/assets/localization/en/labware_position_check.json +++ b/app/src/assets/localization/en/labware_position_check.json @@ -52,6 +52,7 @@ "ensure_nozzle_position_desktop": "Ensure that the {{tip_type}} is centered above and level with {{item_location}}. If it isn't, use the controls below or your keyboard to jog the pipette until it is properly aligned.", "ensure_nozzle_position_odd": "Ensure that the {{tip_type}} is centered above and level with {{item_location}}. If it isn't, tap Move pipette and then jog the pipette until it is properly aligned.", "ensure_probe_attached": "Ensure it is properly attached before proceeding.", + "ensure_tip_rack_accurately_placed": "Ensure the tip rack is accurately placed in the slot as outlined above to prevent damage to your labware.", "exit": "Exit", "exit_screen_confirm_exit": "Exit and discard all labware offsets", "exit_screen_go_back": "Go back to labware position check", diff --git a/app/src/assets/localization/en/protocol_command_text.json b/app/src/assets/localization/en/protocol_command_text.json index dd50e103ca51..613dfe6e98a7 100644 --- a/app/src/assets/localization/en/protocol_command_text.json +++ b/app/src/assets/localization/en/protocol_command_text.json @@ -41,9 +41,9 @@ "left": "Left", "load_labware_to_display_location": "Load {{labware}} {{display_location}}", "load_lid": "Loading lid", - "load_liquid_class": "Loading {{liquidClassDisplayName}} Liquid Class", "load_lid_stack": "Load stack of {{quantity}} {{labware}} {{display_location}}", "load_lid_stack_empty": "Reserving new lid stack", + "load_liquid_class": "Loading {{liquidClassDisplayName}} Liquid Class", "load_liquids_info_protocol_setup": "Load {{liquid}} into {{labware}}", "load_module_protocol_setup": "Load {{module}} in Slot {{slot_name}}", "load_pipette_protocol_setup": "Load {{pipette_name}} in {{mount_name}} Mount", diff --git a/app/src/assets/localization/zh/anonymous.json b/app/src/assets/localization/zh/anonymous.json index 7abd8f086158..0981184ba676 100644 --- a/app/src/assets/localization/zh/anonymous.json +++ b/app/src/assets/localization/zh/anonymous.json @@ -30,6 +30,7 @@ "gripper_successfully_detached": "转板抓手已成功卸下", "help_us_improve_send_error_report": "通过向支持团队发送错误报告,帮助我们改进您的使用体验", "ip_description_second": "请联系网络管理员,为工作站分配静态IP地址。", + "labware_offsets_conflict_description": "设备存储的耗材校准参数已在最后一次运行后更新 {{timestamp}}。您想使用哪些耗材校准数据来再次运行此协议?", "language_preference_description": "除非您在下面选择其他语言,否则应用将与您的系统语言匹配。您可以稍后在应用设置中更改语言。", "learn_uninstalling": "了解更多有关卸载应用程序的信息", "loosen_screws_and_detach": "松开螺丝并卸下转板抓手", diff --git a/app/src/assets/localization/zh/app_settings.json b/app/src/assets/localization/zh/app_settings.json index b84f0c5b1e87..1552222b1c8e 100644 --- a/app/src/assets/localization/zh/app_settings.json +++ b/app/src/assets/localization/zh/app_settings.json @@ -5,6 +5,7 @@ "__dev_internal__lpcRedesign": "LPC 重新设计", "__dev_internal__protocolStats": "协议统计", "__dev_internal__protocolTimeline": "协议时间线", + "__dev_internal__quickTransferExportPython": "启用快速移液的Python导出功能", "__dev_internal__reactQueryDevtools": "启用开发者工具", "__dev_internal__reactScan": "启用 React 组件扫描", "add_folder_button": "添加实验耗材源文件夹", diff --git a/app/src/assets/localization/zh/branded.json b/app/src/assets/localization/zh/branded.json index b7cbc41d6849..c8354a184ff0 100644 --- a/app/src/assets/localization/zh/branded.json +++ b/app/src/assets/localization/zh/branded.json @@ -30,6 +30,7 @@ "gripper_successfully_detached": "Flex转板抓手已成功卸下", "help_us_improve_send_error_report": "通过向{{support_email}}发送错误报告,帮助我们改进您的使用体验", "ip_description_second": "Opentrons建议您联系网络管理员,为工作站分配静态IP地址。", + "labware_offsets_conflict_description": "您的 Flex 存储耗材校准数据已在最后一次运行后更新 {{timestamp}}。您想使用哪些耗材校准数据来再次运行此协议?", "language_preference_description": "Opentrons APP默认匹配与您的系统语言,您也可以选择使用下方其他语言。当然,后续您也可以在APP设置中进行语言更改。", "learn_uninstalling": "了解更多有关卸载Opentrons应用程序的信息", "loosen_screws_and_detach": "松开螺丝并卸下Flex转板抓手", diff --git a/app/src/assets/localization/zh/device_details.json b/app/src/assets/localization/zh/device_details.json index fdcab146c285..3936b3addcad 100644 --- a/app/src/assets/localization/zh/device_details.json +++ b/app/src/assets/localization/zh/device_details.json @@ -64,6 +64,7 @@ "firmware_update_occurring": "固件更新正在进行中...", "firmware_updated_successfully": "固件更新成功", "fixture": "配置模组", + "flex_stacker_door_status": "前门状态: {{status}}", "have_not_run": "无最近运行记录", "have_not_run_description": "运行一些协议后,它们会在这里显示。", "heater": "加热器", diff --git a/app/src/assets/localization/zh/device_settings.json b/app/src/assets/localization/zh/device_settings.json index 40e59b5c3549..591c9cfcb7ad 100644 --- a/app/src/assets/localization/zh/device_settings.json +++ b/app/src/assets/localization/zh/device_settings.json @@ -231,7 +231,6 @@ "privacy": "隐私", "problem_during_update": "此次更新耗时比平常要长。", "proceed_without_updating": "跳过更新以继续", - "protocol_run_history": "协议运行历史", "recalibrate_deck": "重新校准甲板", "recalibrate_gripper": "重新校准转板抓手", "recalibrate_module": "重新校准模块", diff --git a/app/src/assets/localization/zh/labware_position_check.json b/app/src/assets/localization/zh/labware_position_check.json index 57fd1552a65f..3c66cff922dc 100644 --- a/app/src/assets/localization/zh/labware_position_check.json +++ b/app/src/assets/localization/zh/labware_position_check.json @@ -14,8 +14,6 @@ "check_remaining_labware_with_primary_pipette_section": "使用{{primary_mount}}移液器和吸头检查剩余耗材", "check_tip_location": "A1的吸头尖端", "check_well_location": "耗材上的A1孔", - "clear_all_slots": "清空甲板上所有的耗材,模块原位保留", - "clear_all_slots_odd": "清空甲板上所有的耗材及模块", "cli_ssh": "命令行界面 (SSH)", "close_and_apply_offset_data": "关闭并保存耗材校准数据", "confirm_detached": "确认移除", @@ -32,7 +30,6 @@ "exit_screen_subtitle": "如果您现在退出,所有耗材校准数据都将不保留,且无法恢复。", "exit_screen_title": "确定要在完成耗材位置校准前退出?", "get_labware_offset_data": "获取耗材校准数据", - "install_probe": "从存储位置取出校准探头,将探头的锁套旋钮按顺时针方向拧松。对准图示位置,将校准探头向上轻推并压到顶部,使探头在{{location}}移液器吸嘴上压紧。随后将锁套旋钮按逆时针方向拧紧,并轻拉确认是否固定稳妥。", "jog_controls_adjustment": "需要进行调整吗?", "jupyter_notebook": "Jupyter Notebook", "labware": "耗材", @@ -99,7 +96,6 @@ "robot_has_offsets_from_previous_runs": "此工作站具有本协议中所用耗材的校准数据。如果您应用了这些校准数据,仍可通过耗材位置校准程序进行调整。", "robot_in_motion": "工作站正在运行,请远离。", "run": "运行", - "run_labware_position_check": "运行耗材位置校准程序", "secondary_pipette_tipracks_section": "使用{{secondary_mount}}移液器检查吸头盒", "see_how_offsets_work": "了解耗材校准的工作原理", "slot": "板位{{slotName}}", diff --git a/app/src/assets/localization/zh/pipette_wizard_flows.json b/app/src/assets/localization/zh/pipette_wizard_flows.json index a1bec2143bb5..da8bcbdb665a 100644 --- a/app/src/assets/localization/zh/pipette_wizard_flows.json +++ b/app/src/assets/localization/zh/pipette_wizard_flows.json @@ -46,7 +46,7 @@ "hold_and_loosen": "用手扶住移液器并拧松移液器螺丝。(螺丝是固定的,不会与移液器分离。)然后小心地卸下移液器。", "hold_pipette_carefully": "用手扶住移液器防止掉落。对准对接孔,安装移液器。使用螺丝刀拧紧前面的四个螺丝,确保稳固连接。", "how_to_reattach": "将右侧移液器支架推到Z轴的顶部。然后拧紧移液器支架右上方的固定螺丝。拧紧固定螺丝后,右侧支架应不能再自由上下移动。", - "install_probe": "从存放位置取出校准探头。确保其锁套旋钮已拧松。将校准探头对准移液器{{location}}位置,然后向上轻推并压到顶部。拧紧锁套旋钮。用手测试校准探头是否已固定。", + "install_probe": "从存放位置取出校准探头。确保其锁套旋钮已拧松。将校准探头对准移液器{{location}}位置,然后向上轻推并压到顶部。拧紧锁套旋钮。用手轻触探头测试是否稳固。", "loose_detach": "拧松螺丝并卸下", "move_gantry_to_front": "将龙门架移至前方", "must_detach_mounting_plate": "在使用其他移液器之前,您必须卸下安装板并重新连接移液器支架z轴板。我们不建议在完成前退出此过程。", diff --git a/app/src/assets/localization/zh/protocol_command_text.json b/app/src/assets/localization/zh/protocol_command_text.json index 4a33dac6a39d..12ecb07ad4b6 100644 --- a/app/src/assets/localization/zh/protocol_command_text.json +++ b/app/src/assets/localization/zh/protocol_command_text.json @@ -4,6 +4,7 @@ "absorbance_reader_open_lid": "正在打开吸光度读数器盖子", "absorbance_reader_read": "正在吸光度读数器中读取微孔板", "adapter_in_mod_in_slot": "{{adapter}}在{{slot}}的{{module}}上", + "adapter_in_mod_in_slot_plural": "{{adapter}} 在 {{module}}上", "adapter_in_slot": "{{adapter}}在{{slot}}上", "air_gap_in_place": "正在形成 {{volume}} µL 的空气间隙", "all_nozzles": "所有移液喷嘴", @@ -33,11 +34,16 @@ "dropping_tip_in_trash": "将吸头丢入{{trash}}", "engaging_magnetic_module": "抬升磁力架模块", "fixed_trash": "垃圾桶", + "get_next_tip": "计算下一个可用吸头的位置", "home_gantry": "复位所有龙门架、移液器和柱塞轴", "in_location": "在{{location}}", "latching_hs_latch": "在热震荡模块上锁定实验耗材", "left": "左", "load_labware_to_display_location": "{{display_location}}加载{{labware}}", + "load_lid": "加载PCR上盖", + "load_lid_stack": "加载 {{quantity}} {{labware}} {{display_location}}堆栈", + "load_lid_stack_empty": "保留新的PCR上盖堆栈", + "load_liquid_class": "{{liquidClassDisplayName}} 液体加载中", "load_liquids_info_protocol_setup": "将{{liquid}}加载到{{labware}}中", "load_module_protocol_setup": "在甲板槽{{slot_name}}中加载模块{{module}}", "load_pipette_protocol_setup": "在{{mount_name}}支架上加载{{pipette_name}}", @@ -47,6 +53,8 @@ "move_labware_manually": "手动将{{labware}}从{{old_location}}移动到{{new_location}}", "move_labware_on": "在{{robot_name}}上移动实验耗材", "move_labware_using_gripper": "使用转板抓手将{{labware}}从{{old_location}}移动到{{new_location}}", + "move_lid_stack_from_deck": "清除 {{slot_name}} PCR上盖堆栈", + "move_lid_stack_to_deck": "设置 {{slot_name}} 为PCR上盖堆栈", "move_relative": "沿{{axis}}轴移动{{distance}}毫米", "move_to_addressable_area": "移动到{{addressable_area}}", "move_to_addressable_area_drop_tip": "移动到{{addressable_area}}", diff --git a/app/src/assets/localization/zh/protocol_details.json b/app/src/assets/localization/zh/protocol_details.json index e52c38dc2244..bb958c97cb10 100644 --- a/app/src/assets/localization/zh/protocol_details.json +++ b/app/src/assets/localization/zh/protocol_details.json @@ -96,5 +96,6 @@ "unsuccessfully_sent": "发送失败", "value_out_of_range": "值必须在{{min}}-{{max}}之间", "view_run_details": "查看运行详情", - "view_unavailable_robots": "在设备页面查看不可用的工作站" + "view_unavailable_robots": "在设备页面查看不可用的工作站", + "with_lid_name": "和 {{lid}}" } diff --git a/app/src/assets/localization/zh/protocol_setup.json b/app/src/assets/localization/zh/protocol_setup.json index 990fe911f5f4..bd936d8540a1 100644 --- a/app/src/assets/localization/zh/protocol_setup.json +++ b/app/src/assets/localization/zh/protocol_setup.json @@ -4,6 +4,7 @@ "adapter_slot_location": "{{slotName}}号板位,{{adapterName}}", "adapter_slot_location_module": "{{slotName}}号板位,{{adapterName}}在{{moduleName}}上", "add_fixture": "将{{fixtureName}}添加到{{locationName}}", + "add_missing_labware_offsets": "添加缺失的耗材校准数据", "add_this_deck_hardware": "将此硬件添加到您的甲板配置中。它将在协议分析期间被引用。", "add_to_slot": "添加到{{slotName}}号板位", "additional_labware": "{{count}}个额外的耗材", @@ -11,6 +12,7 @@ "all_files_associated": "与协议运行相关文件的所有详细信息均可在工作站屏幕上获得。", "applied_labware_offset_data": "已应用的实验耗材偏移数据", "applied_labware_offsets": "已应用的实验耗材偏移", + "apply_offsets": "应用数据", "are_you_sure_you_want_to_proceed": "您确定要继续运行吗?", "attach": "连接", "attach_gripper": "连接转板抓手", @@ -22,6 +24,7 @@ "attach_pipette_failure_reason": "连接所需的移液器以继续", "attach_pipette_tip_length_calibration": "连接移液器以查看吸头长度校准信息", "back_to_top": "回到顶部", + "bottom_of_slot": "甲板位底部", "cal_all_pip": "首先校准移液器", "calibrate": "校准", "calibrate_deck_failure_reason": "校准甲板以继续", @@ -43,17 +46,18 @@ "calibration_required_calibrate_pipette_first": "需要校准,请先校准移液器", "calibration_status": "校准状态", "cancel_and_restart_to_edit": "取消运行并重新启动设置以进行编辑", + "check_locations_and_volumes": "检查位置和体积", "choose_csv_file": "选择CSV文件", "choose_enum": "选择{{displayName}}", + "cli_ssh": "命令行界面 (SSH)", "closing": "关闭中...", "complete_setup_before_proceeding": "完成设置后继续运行", "configure": "配置", "configured": "已配置", "confirm_heater_shaker_module_modal_description": "在开始运行之前,应使模块的两个锚固件完全伸出,以确保牢固连接。导热适配器应连接到模块上。", "confirm_heater_shaker_module_modal_title": "确认已连接热震荡模块", - "confirm_liquids": "确认液体", + "confirm_liquids": "确认试剂", "confirm_locations_and_volumes": "确认位置和体积", - "confirm_offsets": "确认偏移校准数据", "confirm_placements": "确认放置位置", "confirm_selection": "确认选择", "confirm_values": "确认这些值", @@ -62,6 +66,8 @@ "connect_modules_for_controls": "连接模块以查看控制", "connection_info_not_available": "一旦运行开始,连接信息不可用", "connection_status": "连接状态", + "copy_to_clipboard": "复制到剪贴板", + "count_offsets_applied": " 已应用数据 {{count}}", "csv_file": "CSV 文件", "csv_files_on_robot": "工作站上的CSV文件", "csv_files_on_usb": "USB上的CSV文件", @@ -85,6 +91,7 @@ "extension_mount": "扩展安装支架", "extra_attention_warning_title": "在继续运行前固定耗材和模块", "extra_module_attached": "附加额外模块", + "failed_to_apply_offsets": "无法应用数据", "feedback_form_link": "请告诉我们", "fixture": "装置", "fixture_name": "装置", @@ -96,8 +103,8 @@ "heater_shaker_labware_list_view": "要添加耗材,请使用切换键来控制闩锁", "how_offset_data_works": "耗材校准数据如何工作", "individiual_well_volume": "单个孔体积", - "initial_liquids_num": "{{count}}种初始液体", - "initial_liquids_num_plural": "{{count}}种初始液体", + "initial_liquids_num": "{{count}}种初始试剂", + "initial_liquids_num_plural": "{{count}}种初始试剂", "initial_location": "初始位置", "install_modules": "安装所需的模块。", "install_modules_and_fixtures": "安装并校准所需的模块。安装所需的装置。", @@ -107,11 +114,18 @@ "instruments": "硬件", "instruments_connected": "已连接{{count}}个硬件", "instruments_connected_plural": "已连接{{count}}个硬件", + "jupyter_notebook": "Jupyter Notebook", "labware": "耗材", + "labware_in": "耗材在", "labware_latch": "耗材闩锁", "labware_latch_instructions": "使用闩锁控制,便于放置耗材。", + "labware_liquids_setup_step_description": "将您的耗材和试剂放到甲板上,完成运行设置。", + "labware_liquids_setup_step_title": "耗材 & 试剂", "labware_location": "耗材位置", "labware_name": "耗材名称", + "labware_offset_data_code_snippets": "耗材校准数据代码片段", + "labware_offsets": "耗材校准数据", + "labware_offsets_conflict": "耗材校准数据冲突", "labware_placement": "实验耗材放置", "labware_position_check": "耗材位置校准", "labware_position_check_not_available": "运行开始后,耗材位置校准不可用", @@ -119,24 +133,25 @@ "labware_position_check_not_available_empty_protocol": "耗材位置校准需要协议加载耗材和移液器", "labware_position_check_step_description": "建议的工作流程可帮助您验证每个耗材在甲板上的位置。", "labware_position_check_step_title": "耗材位置校准", - "labware_position_check_text": "耗材位置校准流程可帮助您验证甲板上每个耗材的位置。在此位置校准过程中,您可以创建耗材校准数据,以调整工作站在 X、Y 和 Z 方向上的移动。", "labware_quantity": "数量:{{quantity}}", "labware_setup_step_description": "准备好以下耗材和完整的吸头盒。若不进行耗材位置校准直接运行协议,请将耗材放置在其初始位置并固定。", "labware_setup_step_title": "耗材", + "labware_type": "耗材类型", "last_calibrated": "最后校准:{{date}}", "learn_how_it_works": "了解它的工作原理", "learn_more": "了解更多", + "learn_more_about_labware_offsets": "了解有关耗材校准数据的更多信息", "learn_more_about_offset_data": "了解更多关于耗材校准数据的信息", "learn_more_about_robot_cal_link": "了解更多关于工作站校准的信息", - "liquid_information": "液体信息", - "liquid_name": "液体名称", - "liquid_setup_step_description": "查看液体的起始位置和体积", - "liquid_setup_step_title": "液体", - "liquids": "液体", - "liquids_confirmed": "液体已确认", - "liquids_not_in_setup": "此协议未使用液体", - "liquids_not_in_the_protocol": "此协议未指定液体。", - "liquids_ready": "液体准备", + "liquid_information": "试剂信息", + "liquid_name": "试剂名称", + "liquid_setup_step_description": "查看试剂的起始位置和体积", + "liquid_setup_step_title": "试剂", + "liquids": "试剂", + "liquids_confirmed": "试剂已确认", + "liquids_not_in_setup": "此协议未定义试剂", + "liquids_not_in_the_protocol": "此协议未指定试剂。", + "liquids_ready": "试剂准备", "list_view": "列表视图", "loading_data": "加载数据...", "loading_labware_offsets": "加载耗材校准数据", @@ -174,6 +189,7 @@ "mount": "{{mount}}安装支架", "mount_title": "{{mount}}安装支架:", "multiple_fixtures_missing": "缺少{{count}}个装置", + "multiple_liquid_layouts": "多种试剂布局", "multiple_modules": "相同类型的多个模块", "multiple_modules_example": "您的协议包含两个温控模块。连接到左侧第一个端口的温控模块对应协议中的第一个温控模块,连接到下一个端口的温控模块对应协议中的第二个温控模块。如果使用集线器,遵循相同的端口排序逻辑。", "multiple_modules_explanation": "在协议中使用多个相同类型的模块时,首先需要将协议中第一个模块连接到工作站编号最小的USB端口,然后以相同方式连接其他模块。", @@ -193,6 +209,8 @@ "no_modules_or_fixtures": "该协议中未指定任何模块或装置。", "no_modules_specified": "该协议中未指定任何模块。", "no_modules_used_in_this_protocol": "该协议中未使用硬件", + "no_offsets_found": "运行耗材校准以补充确实的数据", + "no_offsets_in_run": "此协议不需要耗材校准数据。", "no_parameters_specified": "未指定参数", "no_parameters_specified_in_protocol": "协议中未指定任何参数", "no_tiprack_loaded": "协议中必须加载一个吸头盒", @@ -202,11 +220,20 @@ "no_usb_required": "无需USB", "not_calibrated": "尚未校准", "not_configured": "未配置", + "num_missing_offsets": "{{num}} 缺失校准数据", + "num_offsets_applied": "{{num}} 已应用校准数据", + "number_of_liquids": "{{number}} 试剂", + "number_of_liquids_plural": "{{number}} 试剂", "off": "关闭", "off_deck": "甲板外", + "offset": "校准数据", "offset_data": "偏移校准数据", + "offset_type": "校准数据类型", + "offsets_already_applied": "已应用校准数据", "offsets_applied": "应用了{{count}}个偏移校准数据", - "offsets_confirmed": "偏移校准数据已确认", + "offsets_missing": "校准数据缺失", + "offsets_not_applied": "未应用校准数据", + "offsets_not_required": "无需校准数据", "offsets_ready": "偏移校准数据准备", "on": "开启", "on-deck_labware": "{{count}}个在甲板上的耗材", @@ -231,7 +258,7 @@ "prepare_to_run": "准备运行", "proceed_to_labware_position_check": "继续进行耗材位置校准", "proceed_to_labware_setup_step": "继续进行耗材设置", - "proceed_to_liquid_setup_step": "继续进行液体设置", + "proceed_to_liquid_setup_step": "继续进行试剂设置", "proceed_to_module_setup_step": "继续进行模块设置", "proceed_to_run": "继续运行", "protocol_analysis_failed": "协议分析失败", @@ -286,21 +313,26 @@ "start_run": "开始运行", "status": "状态", "step": "步骤{{index}}", + "tap_labware_to_view": "轻触耗材,查看试剂", "there_are_no_unconfigured_modules": "没有连接{{module}}。请连接一个模块并放置在{{slot}}号板位中。", "there_are_other_configured_modules": "已有一个{{module}}配置在不同的板位中。退出运行设置,并更新甲板配置以转到已连接的模块。或连接另一个{{module}}继续设置。", "tip_length_cal_description": "这将测量吸头底部与移液器喷嘴之间的Z轴距离。如果对用于校准移液器的吸头重新进行吸头长度校准,也需要重新进行移液器偏移校准。", "tip_length_cal_description_bullet": "对移液器上将会用到的每种类型的吸头执行吸头长度校准。", "tip_length_cal_title": "吸头长度校准", "tip_length_calibration": "吸头长度校准", + "top_of_slot": "甲板位顶部", "total_liquid_volume": "总体积", + "total_offsets": "总校准数据", + "total_stacked": "总堆叠数: {{quantity}}", "update_deck": "更新甲板", "update_deck_config": "更新甲板配置", - "update_offsets": "更新偏移校准数据", "updated": "已更新", "usb_connected_no_port_info": "USB端口已连接", "usb_drive_notification": "在运行开始前,请保持USB处于连接状态", "usb_port_connected": "USB端口{{port}}", "usb_port_number": "USB-{{port}}", + "use_previous_run_offsets": "使用之前运行的校准数据", + "use_updated_offsets": "使用更新的校准数据", "value": "值", "value_out_of_range": "值必须在{{min}}-{{max}}之间", "value_out_of_range_generic": "值必须在范围内", @@ -309,8 +341,11 @@ "view_current_offsets": "查看当前偏移量", "view_moam": "查看工作站中放置同类型模块的设置说明。", "view_setup_instructions": "查看设置说明", + "view_snippets": "查看 Jupyter Notebook/命令行界面 (SSH) 代码", "volume": "体积", "what_labware_offset_is": "耗材偏移校准是一种位置调整类型,用于补偿甲板上耗材整体位置的微小实际差异。耗材偏移校准数据是耗材、甲板位和工作站的特定组合。", + "with_lid": "和 {{lidDisplayName}}", "with_the_chosen_value": "使用选定的值时,发生以下错误:", + "you_can_still_adjust_offsets": "您仍然可以通过运行耗材校准来调整校准数据。", "you_havent_confirmed": "您尚未确认 {{missingSteps}}。在继续运行协议之前,请确保这些步骤正确无误。" } diff --git a/app/src/local-resources/commands/utils/lastRunCommandPromptedErrorRecovery.ts b/app/src/local-resources/commands/utils/lastRunCommandPromptedErrorRecovery.ts index 673ed4312202..49cfa6469b44 100644 --- a/app/src/local-resources/commands/utils/lastRunCommandPromptedErrorRecovery.ts +++ b/app/src/local-resources/commands/utils/lastRunCommandPromptedErrorRecovery.ts @@ -1,10 +1,10 @@ import type { RunCommandSummary } from '@opentrons/api-client' // Whether the last run protocol command prompted Error Recovery, if Error Recovery is enabled. export function lastRunCommandPromptedErrorRecovery( - summary: RunCommandSummary[] | null, + summary: RunCommandSummary[], isEREnabled: boolean ): boolean { - const lastProtocolCommand = summary?.findLast( + const lastProtocolCommand = summary.findLast( command => command.intent !== 'fixit' && command.error != null ) // All recoverable protocol commands have defined errors. diff --git a/app/src/local-resources/instruments/__tests__/hooks.test.ts b/app/src/local-resources/instruments/__tests__/hooks.test.ts index 45c800044dcd..0e36545b5dc5 100644 --- a/app/src/local-resources/instruments/__tests__/hooks.test.ts +++ b/app/src/local-resources/instruments/__tests__/hooks.test.ts @@ -37,11 +37,9 @@ const mockP1000V2Specs = { 'opentrons/opentrons_flex_96_tiprack_1000ul/1', 'opentrons/opentrons_flex_96_tiprack_200ul/1', 'opentrons/opentrons_flex_96_tiprack_50ul/1', - 'opentrons/opentrons_flex_96_tiprack_20ul/1', 'opentrons/opentrons_flex_96_filtertiprack_1000ul/1', 'opentrons/opentrons_flex_96_filtertiprack_200ul/1', 'opentrons/opentrons_flex_96_filtertiprack_50ul/1', - 'opentrons/opentrons_flex_96_filtertiprack_20ul/1', ], minVolume: 5, maxVolume: 1000, diff --git a/app/src/molecules/LabwareStackContents/index.tsx b/app/src/molecules/LabwareStackContents/index.tsx index 4af699689cb8..3ffef9b7959b 100644 --- a/app/src/molecules/LabwareStackContents/index.tsx +++ b/app/src/molecules/LabwareStackContents/index.tsx @@ -65,7 +65,8 @@ export function LabwareStackContents( key={index} radioButtonType="small" buttonLabel={truncateString(labware.displayName, MAX_CHARS)} - buttonValue={index} + buttonValue={labware.labwareId} + id={labware.labwareId} isSelected={isSelected} tagText={(labwareInStack.length - index).toString()} maxLines={2} diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/LabwareInfoOverlay.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/LabwareInfoOverlay.tsx index 789baf8004a4..c03bb6ab91b3 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/LabwareInfoOverlay.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/LabwareInfoOverlay.tsx @@ -1,6 +1,4 @@ import { css } from 'styled-components' -import { useTranslation } from 'react-i18next' -import { getLabwareDisplayName } from '@opentrons/shared-data' import { ALIGN_FLEX_START, Box, @@ -17,13 +15,10 @@ import { LegacyStyledText, TYPOGRAPHY, } from '@opentrons/components' -import { LegacyOffsetVector } from '/app/molecules/LegacyOffsetVector' import type { LabwareDefinition2 } from '@opentrons/shared-data' -import { useLabwareOffsetForLabware } from './useLabwareOffsetForLabware' interface LabwareInfoProps { - displayName: string | null - definitionDisplayName: string + displayName: string labwareId: string runId: string labwareHasLiquid?: boolean @@ -40,9 +35,7 @@ const labwareDisplayNameStyle = css` -webkit-box-orient: vertical; ` const LabwareInfo = (props: LabwareInfoProps): JSX.Element | null => { - const { displayName, definitionDisplayName, labwareId, runId, hover } = props - const { t } = useTranslation('protocol_setup') - const vector = useLabwareOffsetForLabware(runId, labwareId)?.vector + const { displayName, labwareId, hover } = props return ( { - {displayName ?? definitionDisplayName} + {displayName} {props.labwareHasLiquid && ( )} - {vector != null && ( - <> - - {t('offset_data')} - - - - )} ) } @@ -89,15 +70,16 @@ const LabwareInfo = (props: LabwareInfoProps): JSX.Element | null => { interface LabwareInfoOverlayProps { definition: LabwareDefinition2 labwareId: string - displayName: string | null + displayName: string runId: string - hover?: boolean labwareHasLiquid?: boolean + hover?: boolean } export const LabwareInfoOverlay = ( props: LabwareInfoOverlayProps ): JSX.Element => { - const { definition, labwareId, displayName, runId } = props + const { definition, labwareId, displayName, runId, labwareHasLiquid } = props + const width = definition.dimensions.xDimension const height = definition.dimensions.yDimension return ( @@ -113,11 +95,10 @@ export const LabwareInfoOverlay = ( > ) diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/hooks/useRunHeaderDropTip.ts b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/hooks/useRunHeaderDropTip.ts index 3363a225ec7f..662bf2a25f6a 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/hooks/useRunHeaderDropTip.ts +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/hooks/useRunHeaderDropTip.ts @@ -124,6 +124,7 @@ export function useRunHeaderDropTip({ } // Only run tip checking if it wasn't *just* handled during Error Recovery. else if ( + runSummaryNoFixit != null && !lastRunCommandPromptedErrorRecovery(runSummaryNoFixit, isEREnabled) && isRunCurrent && isTerminalRunStatus(runStatus) diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunSetup.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunSetup.tsx index d54c223a8a5b..00abf0f53cb0 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunSetup.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunSetup.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useDispatch, useSelector } from 'react-redux' @@ -64,6 +64,7 @@ import { SetupStep } from './SetupStep' import { EmptySetupStep } from './EmptySetupStep' import { LearnAboutOffsetsLink } from './LearnAboutOffsetsLink' import { useLPCFlows } from '/app/organisms/LabwarePositionCheck' +import { useUpdateClientLPC } from '/app/resources/client_data' import type { RefObject } from 'react' import type { Dispatch, State } from '/app/redux/types' @@ -129,10 +130,18 @@ export function ProtocolRunSetup({ const flexOffsetsMissing = useSelector( selectIsAnyNecessaryDefaultOffsetMissing(runId) ) + const { updateWithRunId: updateLPCStatusWithRunId } = useUpdateClientLPC() const flexOffsetsApplied = useSelector(selectAreOffsetsApplied(runId)) const noLwOffsetsInRun = useSelector(selectTotalCountLocationSpecificOffsets(runId)) === 0 && isFlex + // A separate app can apply offsets. We need to update the missing steps as a side effect. + useEffect(() => { + if (flexOffsetsApplied) { + dispatch(updateRunSetupStepsComplete(runId, { [LPC_STEP_KEY]: true })) + } + }, [flexOffsetsApplied]) + const offsetsConfirmed = isFlex ? flexOffsetsApplied && !missingSteps.includes(LPC_STEP_KEY) : !missingSteps.includes(LPC_STEP_KEY) @@ -266,11 +275,9 @@ export function ProtocolRunSetup({ { - dispatch( - updateRunSetupStepsComplete(runId, { [LPC_STEP_KEY]: confirmed }) - ) if (confirmed) { dispatch(appliedOffsetsToRun(runId)) + updateLPCStatusWithRunId(runId) setExpandedStepKey(LABWARE_SETUP_STEP_KEY) } diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabware/LabwareListItem.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabware/LabwareListItem.tsx index d80ce2fb40c4..8f24d42ded1d 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabware/LabwareListItem.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabware/LabwareListItem.tsx @@ -6,6 +6,7 @@ import { ALIGN_CENTER, Btn, Tag, + Box, COLORS, DeckInfoLabel, ListButton, @@ -35,7 +36,6 @@ import { } from '@opentrons/shared-data' import { getLabwareLiquidRenderInfoFromStack } from '/app/transformations/commands' import { ToggleButton } from '/app/atoms/buttons' -import { Divider } from '/app/atoms/structure' import { SecureLabwareModal } from './SecureLabwareModal' import type { MouseEvent } from 'react' @@ -223,7 +223,12 @@ export function LabwareListItem( } return ( - + {index !== labwareLiquidRenderInfo.length - 1 ? ( - + ) : null} ))} diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabware/OffDeckLabwareList.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabware/OffDeckLabwareList.tsx index 43e2a618d252..061f08009624 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabware/OffDeckLabwareList.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabware/OffDeckLabwareList.tsx @@ -34,7 +34,7 @@ export function OffDeckLabwareList( > {t('additional_off_deck_labware')} - + {labwareItems.map((labwareItem, index) => ( 0} /> ) : null} @@ -195,12 +196,13 @@ export function SetupLabwareMap({ setHoverLabwareId(null) }} > - {topLabwareDefinition != null ? ( + {topLabwareDisplayName != null ? ( 0} /> ) : null} diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabwarePositionCheck/FlexSetupLPC/LPCSetupFlexBtns.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabwarePositionCheck/FlexSetupLPC/LPCSetupFlexBtns.tsx index c9fc8fa24ac1..f22a4e780749 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabwarePositionCheck/FlexSetupLPC/LPCSetupFlexBtns.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/SetupLabwarePositionCheck/FlexSetupLPC/LPCSetupFlexBtns.tsx @@ -17,7 +17,6 @@ import { import { useToaster } from '/app/organisms/ToasterOven' import { useLPCDisabledReason } from '/app/resources/runs' import { - selectAreOffsetsApplied, selectIsAnyNecessaryDefaultOffsetMissing, selectLabwareOffsetsToAddToRun, selectTotalCountNonHardCodedLSOffsets, @@ -31,6 +30,7 @@ export interface LPCSetupFlexBtnsProps extends SetupLabwarePositionCheckProps { export function LPCSetupFlexBtns({ setOffsetsConfirmed, + offsetsConfirmed, launchLPC, runId, robotName, @@ -41,7 +41,6 @@ export function LPCSetupFlexBtns({ const isNecessaryDefaultOffsetMissing = useSelector( selectIsAnyNecessaryDefaultOffsetMissing(runId) ) - const offsetsConfirmed = useSelector(selectAreOffsetsApplied(runId)) const [runLPCTargetProps, runLPCTooltipProps] = useHoverTooltip({ placement: TOOLTIP_BOTTOM, }) @@ -79,6 +78,16 @@ export function LPCSetupFlexBtns({ } } + const runLPCDisabledTooltipText = (): string | null => { + if (lpcDisabledReason != null) { + return lpcDisabledReason + } else if (offsetsConfirmed) { + return t('offsets_already_applied') + } else { + return null + } + } + const onApplyOffsets = (): void => { if (!isApplyOffsets && lwOffsetsForRun != null) { setIsApplyingOffsets(true) @@ -103,12 +112,14 @@ export function LPCSetupFlexBtns({ onClick={launchLPC} id="LabwareSetup_checkLabwarePositionsButton" {...runLPCTargetProps} - disabled={lpcDisabledReason !== null} + disabled={lpcDisabledReason !== null || offsetsConfirmed} > {t('run_labware_position_check')} - {lpcDisabledReason !== null ? ( - {lpcDisabledReason} + {lpcDisabledReason !== null || offsetsConfirmed ? ( + + {runLPCDisabledTooltipText()} + ) : null} {t('calibrate_now')} diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/__tests__/LabwareInfoOverlay.test.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/__tests__/LabwareInfoOverlay.test.tsx index a47f37e11360..f8a4ee6a3b2e 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/__tests__/LabwareInfoOverlay.test.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/__tests__/LabwareInfoOverlay.test.tsx @@ -1,38 +1,12 @@ -import { when } from 'vitest-when' import { screen } from '@testing-library/react' -import { describe, it, beforeEach, vi, afterEach, expect } from 'vitest' -import { - getLabwareDisplayName, - fixtureTiprack300ul, -} from '@opentrons/shared-data' -import { nestedTextMatcher, renderWithProviders } from '/app/__testing-utils__' +import { describe, it, beforeEach, vi, afterEach } from 'vitest' +import { fixtureTiprack300ul } from '@opentrons/shared-data' +import { renderWithProviders } from '/app/__testing-utils__' import { i18n } from '/app/i18n' -import { useCurrentRun } from '/app/resources/runs' -import { getLabwareLocation } from '/app/transformations/commands' import { LabwareInfoOverlay } from '../LabwareInfoOverlay' -import { getLabwareDefinitionUri } from '/app/transformations/protocols' -import { useLabwareOffsetForLabware } from '../useLabwareOffsetForLabware' import type { ComponentProps } from 'react' -import type { - LabwareDefinition2, - ProtocolFile, - LoadedLabware, -} from '@opentrons/shared-data' - -vi.mock('/app/resources/runs') -vi.mock('/app/transformations/commands') -vi.mock('../../hooks') -vi.mock('/app/transformations/protocols') -vi.mock('../useLabwareOffsetForLabware') - -vi.mock('@opentrons/shared-data', async importOriginal => { - const actual = await importOriginal() - return { - ...actual, - getLabwareDisplayName: vi.fn(), - } -}) +import type { LabwareDefinition2 } from '@opentrons/shared-data' const render = (props: ComponentProps) => { return renderWithProviders( @@ -46,15 +20,10 @@ const render = (props: ComponentProps) => { } const MOCK_LABWARE_ID = 'some_labware_id' -const MOCK_LABWARE_DEFINITION_URI = 'some_labware_definition_uri' -const MOCK_SLOT_NAME = '4' -const MOCK_LABWARE_VECTOR = { x: 1, y: 2, z: 3 } const MOCK_RUN_ID = 'fake_run_id' describe('LabwareInfoOverlay', () => { let props: ComponentProps - let labware: LoadedLabware[] - let labwareDefinitions: ProtocolFile<{}>['labwareDefinitions'] beforeEach(() => { props = { definition: fixtureTiprack300ul as LabwareDefinition2, @@ -62,40 +31,6 @@ describe('LabwareInfoOverlay', () => { labwareId: MOCK_LABWARE_ID, runId: MOCK_RUN_ID, } - labware = [ - { - id: MOCK_LABWARE_ID, - definitionUri: MOCK_LABWARE_DEFINITION_URI, - } as LoadedLabware, - ] - labwareDefinitions = { - [MOCK_LABWARE_DEFINITION_URI]: fixtureTiprack300ul as LabwareDefinition2, - } - when(vi.mocked(getLabwareDisplayName)) - .calledWith(props.definition) - .thenReturn('mock definition display name') - - when(vi.mocked(useLabwareOffsetForLabware)) - .calledWith(MOCK_RUN_ID, MOCK_LABWARE_ID) - .thenReturn({ - id: 'fake_offset_id', - createdAt: 'fake_timestamp', - definitionUri: 'fake_def_uri', - location: { slotName: MOCK_SLOT_NAME }, - vector: MOCK_LABWARE_VECTOR, - }) - - when(vi.mocked(useCurrentRun)) - .calledWith() - .thenReturn({} as any) - - when(vi.mocked(getLabwareLocation)) - .calledWith(MOCK_LABWARE_ID, []) - .thenReturn({ slotName: MOCK_SLOT_NAME }) - - when(vi.mocked(getLabwareDefinitionUri)) - .calledWith(MOCK_LABWARE_ID, labware, labwareDefinitions) - .thenReturn(MOCK_LABWARE_DEFINITION_URI) }) afterEach(() => { vi.restoreAllMocks() @@ -105,39 +40,4 @@ describe('LabwareInfoOverlay', () => { render(props) screen.getByText('fresh tips') }) - - it('should render the labware def display name if no user displayName present', () => { - render({ - ...props, - displayName: null, - }) - screen.getByText('mock definition display name') - }) - - it('should render NOT render the offset data label when offset data does not exist', () => { - render(props) - expect(screen.queryByText('Labware Offsets')).toBeNull() - }) - - it('should render the offset data when offset data exists', () => { - when(vi.mocked(useCurrentRun)) - .calledWith() - .thenReturn({ - data: { - labwareOffsets: [ - { - id: '1', - definitionUri: MOCK_LABWARE_DEFINITION_URI, - location: { slotName: MOCK_SLOT_NAME }, - vector: MOCK_LABWARE_VECTOR, - }, - ], - }, - } as any) - render(props) - screen.getByText('Offset Data') - screen.getByText(nestedTextMatcher('X1.0')) - screen.getByText(nestedTextMatcher('Y2.0')) - screen.getByText(nestedTextMatcher('Z3.0')) - }) }) diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/useLabwareOffsetForLabware.ts b/app/src/organisms/Desktop/Devices/ProtocolRun/useLabwareOffsetForLabware.ts deleted file mode 100644 index 5046ddcf24c8..000000000000 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/useLabwareOffsetForLabware.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { getLoadedLabwareDefinitionsByUri } from '@opentrons/shared-data' - -import { getLabwareDefinitionUri } from '/app/transformations/protocols' -import { - getLegacyLabwareOffsetLocation, - getCurrentOffsetForLabwareInLocation, -} from '/app/transformations/analysis' -import { - useNotifyRunQuery, - useMostRecentCompletedAnalysis, -} from '/app/resources/runs' - -import type { LabwareOffset } from '@opentrons/api-client' - -export function useLabwareOffsetForLabware( - runId: string, - labwareId: string -): LabwareOffset | null { - const mostRecentAnalysis = useMostRecentCompletedAnalysis(runId) - const { data: runRecord } = useNotifyRunQuery(runId) - if (mostRecentAnalysis == null) return null - - const labwareDefinitionsByUri = getLoadedLabwareDefinitionsByUri( - mostRecentAnalysis.commands - ) - const labwareDefinitionUri = getLabwareDefinitionUri( - labwareId, - mostRecentAnalysis.labware, - labwareDefinitionsByUri - ) - - const labwareLocation = getLegacyLabwareOffsetLocation( - labwareId, - mostRecentAnalysis?.commands ?? [], - mostRecentAnalysis?.modules ?? [], - mostRecentAnalysis?.labware ?? [] - ) - if (labwareLocation == null || labwareDefinitionUri == null) return null - const labwareOffsets = runRecord?.data?.labwareOffsets ?? [] - - return ( - getCurrentOffsetForLabwareInLocation( - labwareOffsets, - labwareDefinitionUri, - labwareLocation - ) ?? null - ) -} diff --git a/app/src/organisms/Desktop/Labware/LabwareDetails/labware-images.ts b/app/src/organisms/Desktop/Labware/LabwareDetails/labware-images.ts index 4ec150339626..92e7408f5694 100644 --- a/app/src/organisms/Desktop/Labware/LabwareDetails/labware-images.ts +++ b/app/src/organisms/Desktop/Labware/LabwareDetails/labware-images.ts @@ -60,6 +60,8 @@ import flat_bottom_aluminum from '/app/assets/images/labware/flat_bottom_aluminu import opentrons_96_aluminumblock_side_view from '/app/assets/images/labware/opentrons_96_aluminumblock_side_view.jpg' import opentrons_96_deep_well_temp_mod_adapter_img from '/app/assets/images/labware/opentrons_96_deep_well_temp_mod_adapter.png' import opentrons_flex_deck_riser_img from '/app/assets/images/labware/opentrons_flex_deck_riser.png' +import opentrons_evotip_short_adapter_img from '/app/assets/images/labware/opentrons_evotip_short_adapter.png' +import opentrons_evotip_tall_adapter_img from '/app/assets/images/labware/opentrons_evotip_tall_adapter.png' export const labwareImages: Record = { agilent_1_reservoir_290ml: [agilent_1_reservoir_290ml_side_view], @@ -257,4 +259,6 @@ export const labwareImages: Record = { opentrons_96_deep_well_temp_mod_adapter_img, ], opentrons_flex_deck_riser: [opentrons_flex_deck_riser_img], + evotip_flex_tall_adapter: [opentrons_evotip_short_adapter_img], + evotip_flex_short_adapter: [opentrons_evotip_tall_adapter_img], } diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts index 27cee5eebafa..ea7e0ae1461a 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts @@ -270,7 +270,10 @@ function useTipSelectionUtils( // Use this labware to represent all tip racks for manual tip selection. const tipSelectorDef = useMemo( - () => getAllLabwareDefs().thermoscientificnunc96Wellplate1300UlV1, + () => + getAllLabwareDefs()[ + 'opentrons/thermoscientificnunc_96_wellplate_1300ul/1' + ], [] ) diff --git a/app/src/organisms/LabwareOffsetsTable/AccordionHeader.tsx b/app/src/organisms/LabwareOffsetsTable/AccordionHeader.tsx index 8ee76c97b303..381585a02862 100644 --- a/app/src/organisms/LabwareOffsetsTable/AccordionHeader.tsx +++ b/app/src/organisms/LabwareOffsetsTable/AccordionHeader.tsx @@ -9,6 +9,7 @@ import { StyledText, RESPONSIVENESS, COLORS, + NO_WRAP, } from '@opentrons/components' import { selectTotalOrMissingOffsetRequiredCountForLwCopy } from '/app/redux/protocol-runs' @@ -57,7 +58,11 @@ const ACCORDION_HEADER_CONTAINER_STYLE = css` ` const OFFSET_COPY_STYLE = css` + width: 6.85rem; + text-wrap: ${NO_WRAP}; + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + width: 10.25rem; color: ${COLORS.grey60}; } ` diff --git a/app/src/organisms/LabwareOffsetsTable/index.tsx b/app/src/organisms/LabwareOffsetsTable/index.tsx index f2984f50a1e7..5eba4624e443 100644 --- a/app/src/organisms/LabwareOffsetsTable/index.tsx +++ b/app/src/organisms/LabwareOffsetsTable/index.tsx @@ -9,6 +9,8 @@ import { RESPONSIVENESS, Flex, SPACING, + COLORS, + JUSTIFY_SPACE_BETWEEN, } from '@opentrons/components' import { selectAllLabwareInfoAndDefaultStatusSorted } from '/app/redux/protocol-runs' @@ -39,7 +41,7 @@ export function LabwareOffsetsTable( return ( - + ]}> {labwareInfo.map(aLwInfo => ( + + {t('labware_type')} + + + {t('total_offsets')} + + + ) +} + +const TABLE_HEADER_CONTAINER_STYLE = css` + justify-content: ${JUSTIFY_SPACE_BETWEEN}; + padding: 0 ${SPACING.spacing12}; + gap: ${SPACING.spacing24}; +` + +const TABLE_HEADER_COLUMN_ONE_TEXT_STYLE = css` + color: ${COLORS.grey60}; +` + +const TABLE_HEADER_COLUMN_TWO_TEXT_STYLE = css` + color: ${COLORS.grey60}; + padding-right: 4rem; + + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + padding-right: 7.5rem; + } +` diff --git a/app/src/organisms/LabwarePositionCheck/LPCFlows/__tests__/utils.test.ts b/app/src/organisms/LabwarePositionCheck/LPCFlows/__tests__/utils.test.ts new file mode 100644 index 000000000000..de4c4c3b1017 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/LPCFlows/__tests__/utils.test.ts @@ -0,0 +1,139 @@ +import { it, describe, expect } from 'vitest' + +import { FLEX_ROBOT_TYPE, OT2_ROBOT_TYPE } from '@opentrons/shared-data' +import { ANY_LOCATION } from '@opentrons/api-client' + +import { getRelevantOffsets } from '../utils' + +import type { LabwareOffset, StoredLabwareOffset } from '@opentrons/api-client' + +describe('utils', () => { + describe('getRelevantOffsets', () => { + const LABWARE_URI = 'labware-1' + const VECTOR = { x: 1, y: 2, z: 3 } + const SLOT_NAME = 'A1' + + const OT2_OFFSETS: LabwareOffset[] = [ + { + id: 'offset-1', + createdAt: '2023-01-01T00:00:00Z', + definitionUri: LABWARE_URI, + location: { slotName: SLOT_NAME }, + vector: VECTOR, + }, + { + id: 'offset-2', + createdAt: '2023-01-02T00:00:00Z', + definitionUri: LABWARE_URI, + location: { slotName: 'B1' }, + vector: { x: 4, y: 5, z: 6 }, + }, + ] as LabwareOffset[] + + const FLEX_OFFSETS: StoredLabwareOffset[] = [ + { + id: 'stored-offset-1', + createdAt: '2023-01-01T00:00:00Z', + definitionUri: LABWARE_URI, + locationSequence: [ + { kind: 'onAddressableArea', addressableAreaName: SLOT_NAME }, + ], + vector: VECTOR, + }, + { + id: 'stored-offset-2', + createdAt: '2023-01-02T00:00:00Z', + definitionUri: LABWARE_URI, + locationSequence: ANY_LOCATION, + vector: { x: 4, y: 5, z: 6 }, + }, + { + id: 'stored-offset-3', + createdAt: '2023-01-03T00:00:00Z', + definitionUri: LABWARE_URI, + locationSequence: [ + { kind: 'onAddressableArea', addressableAreaName: 'B1' }, + ], + vector: { x: 7, y: 8, z: 9 }, + }, + ] as StoredLabwareOffset[] + + it('should return transformed OT2 offsets for OT2 robot type', () => { + const result = getRelevantOffsets( + OT2_ROBOT_TYPE, + OT2_OFFSETS, + FLEX_OFFSETS + ) + + expect(result).toEqual([ + { + definitionUri: LABWARE_URI, + location: { slotName: SLOT_NAME }, + vector: VECTOR, + }, + { + definitionUri: LABWARE_URI, + location: { slotName: 'B1' }, + vector: { x: 4, y: 5, z: 6 }, + }, + ]) + }) + + it('should return transformed Flex offsets without ANY_LOCATION offsets for Flex robot type', () => { + const result = getRelevantOffsets( + FLEX_ROBOT_TYPE, + OT2_OFFSETS, + FLEX_OFFSETS + ) + + expect(result).toEqual([ + { + definitionUri: LABWARE_URI, + locationSequence: [ + { kind: 'onAddressableArea', addressableAreaName: SLOT_NAME }, + ], + vector: VECTOR, + }, + { + definitionUri: LABWARE_URI, + locationSequence: [ + { kind: 'onAddressableArea', addressableAreaName: 'B1' }, + ], + vector: { x: 7, y: 8, z: 9 }, + }, + ]) + }) + + it('should handle empty OT2 offsets for OT2 robot type', () => { + const result = getRelevantOffsets(OT2_ROBOT_TYPE, [], FLEX_OFFSETS) + + expect(result).toEqual([]) + }) + + it('should handle empty Flex offsets for Flex robot type', () => { + const result = getRelevantOffsets(FLEX_ROBOT_TYPE, OT2_OFFSETS, []) + + expect(result).toEqual([]) + }) + + it('should handle Flex offsets with only ANY_LOCATION offsets for Flex robot type', () => { + const onlyDefaultOffsets: StoredLabwareOffset[] = [ + { + id: 'stored-offset-2', + createdAt: '2023-01-02T00:00:00Z', + definitionUri: LABWARE_URI, + locationSequence: ANY_LOCATION, + vector: { x: 4, y: 5, z: 6 }, + }, + ] as StoredLabwareOffset[] + + const result = getRelevantOffsets( + FLEX_ROBOT_TYPE, + OT2_OFFSETS, + onlyDefaultOffsets + ) + + expect(result).toEqual([]) + }) + }) +}) diff --git a/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/__tests__/useHandleClientAppliedOffsets.test.ts b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/__tests__/useHandleClientAppliedOffsets.test.ts new file mode 100644 index 000000000000..daffd45d8010 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/__tests__/useHandleClientAppliedOffsets.test.ts @@ -0,0 +1,190 @@ +import { vi, it, describe, expect, beforeEach } from 'vitest' +import { renderHook } from '@testing-library/react' +import { useHandleClientAppliedOffsets } from '../useHandleClientAppliedOffsets' +import { useDispatch, useSelector } from 'react-redux' +import { + useClientDataLPC, + useUpdateClientLPC, +} from '/app/resources/client_data/' +import { appliedOffsetsToRun } from '/app/redux/protocol-runs' +import { useIsRunCurrent } from '/app/resources/runs' + +vi.mock('react-redux') +vi.mock('/app/resources/client_data/') +vi.mock('/app/redux/protocol-runs') +vi.mock('/app/resources/runs') + +describe('useHandleClientAppliedOffsets', () => { + const RUN_ID = 'run-123' + const OTHER_RUN_ID = 'other-run-456' + const USER_ID = 'user-789' + + const mockDispatch = vi.fn() + const mockClearClientData = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + + vi.mocked(useDispatch).mockReturnValue(mockDispatch) + + vi.mocked(appliedOffsetsToRun).mockImplementation( + (runId: string) => + ({ type: 'APPLIED_OFFSETS_TO_RUN', payload: runId } as any) + ) + + vi.mocked(useIsRunCurrent).mockImplementation( + (runId: string | null) => false + ) + + vi.mocked(useClientDataLPC).mockReturnValue({ + runId: null, + userId: null, + }) + + vi.mocked(useUpdateClientLPC).mockReturnValue({ + clearClientData: mockClearClientData, + } as any) + + vi.mocked(useSelector).mockImplementation(selector => { + if (typeof selector === 'function') { + return selector({}) + } + return null + }) + }) + + it('should not update client data when run is not current', () => { + vi.mocked(useIsRunCurrent).mockReturnValue(false) + vi.mocked(useClientDataLPC).mockReturnValue({ + runId: null, + userId: null, + }) + + renderHook(() => { + useHandleClientAppliedOffsets(RUN_ID) + }) + + expect(mockClearClientData).not.toHaveBeenCalled() + expect(mockDispatch).not.toHaveBeenCalled() + }) + + it('should clear client data when run is not current but client data contains this run ID', () => { + vi.mocked(useIsRunCurrent).mockReturnValue(false) + vi.mocked(useClientDataLPC).mockReturnValue({ + runId: RUN_ID, + userId: USER_ID, + }) + + renderHook(() => { + useHandleClientAppliedOffsets(RUN_ID) + }) + + expect(mockClearClientData).toHaveBeenCalledTimes(1) + expect(mockDispatch).not.toHaveBeenCalled() + }) + + it('should clear client data when run is current but client data contains a different run ID', () => { + vi.mocked(useIsRunCurrent).mockReturnValue(true) + vi.mocked(useClientDataLPC).mockReturnValue({ + runId: OTHER_RUN_ID, + userId: USER_ID, + }) + + renderHook(() => { + useHandleClientAppliedOffsets(RUN_ID) + }) + + expect(mockClearClientData).toHaveBeenCalledTimes(1) + expect(mockDispatch).not.toHaveBeenCalled() + }) + + it('should not take any action when run IDs match but no user ID is present', () => { + vi.mocked(useIsRunCurrent).mockReturnValue(true) + vi.mocked(useClientDataLPC).mockReturnValue({ + runId: RUN_ID, + userId: null, + }) + + renderHook(() => { + useHandleClientAppliedOffsets(RUN_ID) + }) + + expect(mockClearClientData).not.toHaveBeenCalled() + expect(mockDispatch).not.toHaveBeenCalled() + }) + + it('should dispatch applied offsets when run IDs match and user ID is present', () => { + vi.mocked(useIsRunCurrent).mockReturnValue(true) + vi.mocked(useClientDataLPC).mockReturnValue({ + runId: RUN_ID, + userId: USER_ID, + }) + + renderHook(() => { + useHandleClientAppliedOffsets(RUN_ID) + }) + + expect(mockClearClientData).not.toHaveBeenCalled() + expect(mockDispatch).toHaveBeenCalledWith(appliedOffsetsToRun(RUN_ID)) + }) + + it('should not dispatch applied offsets when run IDs do not match, even if user ID is present', () => { + vi.mocked(useIsRunCurrent).mockReturnValue(true) + vi.mocked(useClientDataLPC).mockReturnValue({ + runId: OTHER_RUN_ID, + userId: USER_ID, + }) + + renderHook(() => { + useHandleClientAppliedOffsets(RUN_ID) + }) + + expect(mockClearClientData).toHaveBeenCalledTimes(1) + expect(mockDispatch).not.toHaveBeenCalled() + }) + + it('should do nothing when run is current, client data has the same run ID and user ID, and offsets are already applied', () => { + vi.mocked(useIsRunCurrent).mockReturnValue(true) + vi.mocked(useClientDataLPC).mockReturnValue({ + runId: RUN_ID, + userId: USER_ID, + }) + + renderHook(() => { + useHandleClientAppliedOffsets(RUN_ID) + }) + + expect(mockClearClientData).not.toHaveBeenCalled() + expect(mockDispatch).toHaveBeenCalledWith(appliedOffsetsToRun(RUN_ID)) + }) + + it('should call clearClientData when not current with null thisRunId', () => { + vi.mocked(useIsRunCurrent).mockReturnValue(false) + vi.mocked(useClientDataLPC).mockReturnValue({ + runId: null, + userId: null, + }) + + renderHook(() => { + useHandleClientAppliedOffsets(null) + }) + + expect(mockClearClientData).toHaveBeenCalled() + expect(mockDispatch).not.toHaveBeenCalled() + }) + + it('should dispatch when run is current with null thisRunId and user ID is present', () => { + vi.mocked(useIsRunCurrent).mockReturnValue(true) + vi.mocked(useClientDataLPC).mockReturnValue({ + runId: null, + userId: USER_ID, + }) + + renderHook(() => { + useHandleClientAppliedOffsets(null) + }) + + expect(mockClearClientData).not.toHaveBeenCalled() + expect(mockDispatch).not.toHaveBeenCalled() + }) +}) diff --git a/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/__tests__/useMonitorMaintenanceRunForDeletion.test.ts b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/__tests__/useMonitorMaintenanceRunForDeletion.test.ts new file mode 100644 index 000000000000..c9a4c17f0811 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/__tests__/useMonitorMaintenanceRunForDeletion.test.ts @@ -0,0 +1,191 @@ +import { vi, it, describe, expect, beforeEach } from 'vitest' +import { renderHook } from '@testing-library/react' +import { useMonitorMaintenanceRunForDeletion } from '../useMonitorMaintenanceRunForDeletion' +import { useNotifyCurrentMaintenanceRun } from '/app/resources/maintenance_runs' + +vi.mock('/app/resources/maintenance_runs') + +describe('useMonitorMaintenanceRunForDeletion', () => { + const MAINTENANCE_RUN_ID = 'maintenance-run-123' + const DIFFERENT_MAINTENANCE_RUN_ID = 'maintenance-run-456' + const mockSetMaintenanceRunId = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(useNotifyCurrentMaintenanceRun).mockReturnValue({ + data: undefined, + } as any) + }) + + it('should not enable monitoring when maintenanceRunId is null', () => { + vi.mocked(useNotifyCurrentMaintenanceRun).mockReturnValue({ + data: undefined, + } as any) + + renderHook(() => { + useMonitorMaintenanceRunForDeletion({ + maintenanceRunId: null, + setMaintenanceRunId: mockSetMaintenanceRunId, + }) + }) + + expect(useNotifyCurrentMaintenanceRun).toHaveBeenCalledWith({ + refetchInterval: 5000, + enabled: false, + }) + expect(mockSetMaintenanceRunId).not.toHaveBeenCalled() + }) + + it('should enable monitoring when maintenanceRunId is not null', () => { + vi.mocked(useNotifyCurrentMaintenanceRun).mockReturnValue({ + data: undefined, + } as any) + + renderHook(() => { + useMonitorMaintenanceRunForDeletion({ + maintenanceRunId: MAINTENANCE_RUN_ID, + setMaintenanceRunId: mockSetMaintenanceRunId, + }) + }) + + expect(useNotifyCurrentMaintenanceRun).toHaveBeenCalledWith({ + refetchInterval: 5000, + enabled: true, + }) + }) + + it('should not call setMaintenanceRunId when data is not yet available', () => { + vi.mocked(useNotifyCurrentMaintenanceRun).mockReturnValue({ + data: undefined, + } as any) + + renderHook(() => { + useMonitorMaintenanceRunForDeletion({ + maintenanceRunId: MAINTENANCE_RUN_ID, + setMaintenanceRunId: mockSetMaintenanceRunId, + }) + }) + + expect(mockSetMaintenanceRunId).not.toHaveBeenCalled() + }) + + it('should not call setMaintenanceRunId when maintenanceRunId matches current run ID', () => { + vi.mocked(useNotifyCurrentMaintenanceRun).mockReturnValue({ + data: { + data: { + id: MAINTENANCE_RUN_ID, + }, + }, + } as any) + + const { rerender } = renderHook( + props => { + useMonitorMaintenanceRunForDeletion({ + maintenanceRunId: props.maintenanceRunId, + setMaintenanceRunId: mockSetMaintenanceRunId, + }) + }, + { + initialProps: { maintenanceRunId: MAINTENANCE_RUN_ID }, + } + ) + + expect(mockSetMaintenanceRunId).not.toHaveBeenCalled() + + rerender({ maintenanceRunId: MAINTENANCE_RUN_ID }) + expect(mockSetMaintenanceRunId).not.toHaveBeenCalled() + }) + + it('should call setMaintenanceRunId with null when run IDs differ after monitoring started', () => { + vi.mocked(useNotifyCurrentMaintenanceRun).mockReturnValue({ + data: { + data: { + id: MAINTENANCE_RUN_ID, + }, + }, + } as any) + + const { rerender } = renderHook( + props => { + useMonitorMaintenanceRunForDeletion({ + maintenanceRunId: props.maintenanceRunId, + setMaintenanceRunId: mockSetMaintenanceRunId, + }) + }, + { + initialProps: { maintenanceRunId: MAINTENANCE_RUN_ID }, + } + ) + + expect(mockSetMaintenanceRunId).not.toHaveBeenCalled() + + vi.mocked(useNotifyCurrentMaintenanceRun).mockReturnValue({ + data: { + data: { + id: DIFFERENT_MAINTENANCE_RUN_ID, + }, + }, + } as any) + + rerender({ maintenanceRunId: MAINTENANCE_RUN_ID }) + expect(mockSetMaintenanceRunId).toHaveBeenCalledWith(null) + }) + + it('should handle transition from null to valid maintenanceRunId', () => { + vi.mocked(useNotifyCurrentMaintenanceRun).mockReturnValue({ + data: undefined, + } as any) + + const { rerender } = renderHook( + props => { + useMonitorMaintenanceRunForDeletion({ + maintenanceRunId: props.maintenanceRunId, + setMaintenanceRunId: mockSetMaintenanceRunId, + }) + }, + { + initialProps: { maintenanceRunId: null }, + } + ) + + expect(mockSetMaintenanceRunId).not.toHaveBeenCalled() + + vi.mocked(useNotifyCurrentMaintenanceRun).mockReturnValue({ + data: { + data: { + id: MAINTENANCE_RUN_ID, + }, + }, + } as any) + + rerender({ maintenanceRunId: MAINTENANCE_RUN_ID } as any) + expect(mockSetMaintenanceRunId).not.toHaveBeenCalled() + }) + + it('should handle transition from valid maintenanceRunId to null', () => { + vi.mocked(useNotifyCurrentMaintenanceRun).mockReturnValue({ + data: { + data: { + id: MAINTENANCE_RUN_ID, + }, + }, + } as any) + + const { rerender } = renderHook( + props => { + useMonitorMaintenanceRunForDeletion({ + maintenanceRunId: props.maintenanceRunId, + setMaintenanceRunId: mockSetMaintenanceRunId, + }) + }, + { + initialProps: { maintenanceRunId: MAINTENANCE_RUN_ID }, + } + ) + + expect(mockSetMaintenanceRunId).not.toHaveBeenCalled() + + rerender({ maintenanceRunId: null } as any) + expect(mockSetMaintenanceRunId).not.toHaveBeenCalled() + }) +}) diff --git a/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/__tests__/useOffsetConflictTimestamp.test.ts b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/__tests__/useOffsetConflictTimestamp.test.ts new file mode 100644 index 000000000000..8e3de1829035 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/__tests__/useOffsetConflictTimestamp.test.ts @@ -0,0 +1,368 @@ +import { vi, it, describe, expect, beforeEach } from 'vitest' +import { renderHook } from '@testing-library/react' +import { useOffsetConflictTimestamp } from '../useOffsetConflictTimestamp' +import { useSelector, useDispatch } from 'react-redux' +import { useNotifyAllRunsQuery } from '/app/resources/runs' +import { + selectAreOffsetsApplied, + selectConflictTimestampInfo, + selectInitialDatabaseOffsets, + selectInitialRunRecordOffsets, + updateConflictTimestamp, +} from '/app/redux/protocol-runs' +import type { + LabwareOffset, + StoredLabwareOffset, + Run, + ANY_LOCATION, +} from '@opentrons/api-client' + +vi.mock('react-redux') +vi.mock('/app/resources/runs') +vi.mock('/app/redux/protocol-runs') + +describe('useOffsetConflictTimestamp', () => { + const RUN_ID = 'run-123' + const PROTOCOL_ID = 'protocol-456' + const CREATED_AT = '2024-03-15T12:00:00Z' + const LABWARE_URI = 'opentrons/labware-1' + + const mockDispatch = vi.fn() + const mockState = { + protocolRuns: { + [RUN_ID]: { + lpc: {}, + }, + }, + } + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(useDispatch).mockReturnValue(mockDispatch) + vi.mocked(useNotifyAllRunsQuery).mockReturnValue({ + data: { + data: [], + }, + } as any) + + vi.mocked(updateConflictTimestamp).mockReturnValue({ + type: 'UPDATE_CONFLICT_TIMESTAMP', + } as any) + + vi.mocked( + selectAreOffsetsApplied + ).mockImplementation((runId: string) => (state: any) => false) + + vi.mocked(selectConflictTimestampInfo).mockImplementation( + (runId: string) => (state: any) => ({ + isInitialized: false, + timestamp: null, + }) + ) + + vi.mocked( + selectInitialRunRecordOffsets + ).mockImplementation((runId: string) => (state: any) => []) + + vi.mocked( + selectInitialDatabaseOffsets + ).mockImplementation((runId: string) => (state: any) => []) + + vi.mocked(useSelector).mockImplementation(selector => { + if (typeof selector === 'function') { + return selector(mockState) + } + return null + }) + }) + + it('should do nothing when isFlex is false', () => { + renderHook(() => { + useOffsetConflictTimestamp(false, RUN_ID, { + data: { protocolId: PROTOCOL_ID }, + } as Run) + }) + + expect(mockDispatch).not.toHaveBeenCalled() + }) + + it('should do nothing when conflict info is already initialized', () => { + vi.mocked(selectConflictTimestampInfo).mockImplementation( + (runId: string) => (state: any) => ({ + isInitialized: true, + timestamp: null, + }) + ) + + renderHook(() => { + useOffsetConflictTimestamp(true, RUN_ID, { + data: { protocolId: PROTOCOL_ID }, + } as Run) + }) + + expect(mockDispatch).not.toHaveBeenCalled() + }) + + it('should do nothing when in initializing state', () => { + renderHook(() => { + useOffsetConflictTimestamp(true, null, undefined) + }) + + expect(mockDispatch).not.toHaveBeenCalled() + }) + + it('should do nothing when offsets are already applied', () => { + vi.mocked( + selectAreOffsetsApplied + ).mockImplementation((runId: string) => (state: any) => true) + + renderHook(() => { + useOffsetConflictTimestamp(true, RUN_ID, { + data: { protocolId: PROTOCOL_ID }, + } as Run) + }) + + expect(mockDispatch).not.toHaveBeenCalled() + }) + + it('should set timestamp to null when no outdated offsets exist', () => { + vi.mocked(useNotifyAllRunsQuery).mockReturnValue({ + data: { + data: [ + { current: false, protocolId: PROTOCOL_ID, createdAt: CREATED_AT }, + ], + }, + } as any) + + const runRecordOffset: LabwareOffset = { + id: 'offset-1', + createdAt: CREATED_AT, + definitionUri: LABWARE_URI, + location: { slotName: 'A1' }, + vector: { x: 1, y: 2, z: 3 }, + } + + const databaseOffset: StoredLabwareOffset = { + id: 'stored-offset-1', + createdAt: CREATED_AT, + definitionUri: LABWARE_URI, + locationSequence: [ + { kind: 'onAddressableArea', addressableAreaName: 'A1' }, + ], + vector: { x: 1, y: 2, z: 3 }, + } + + vi.mocked( + selectInitialRunRecordOffsets + ).mockImplementation((runId: string) => (state: any) => [runRecordOffset]) + + vi.mocked( + selectInitialDatabaseOffsets + ).mockImplementation((runId: string) => (state: any) => [databaseOffset]) + + renderHook(() => { + useOffsetConflictTimestamp(true, RUN_ID, { + data: { protocolId: PROTOCOL_ID }, + } as Run) + }) + + expect(mockDispatch).toHaveBeenCalledWith( + updateConflictTimestamp(RUN_ID, { isInitialized: true, timestamp: null }) + ) + }) + + it('should set timestamp when location-specific offset is outdated', () => { + vi.mocked(useNotifyAllRunsQuery).mockReturnValue({ + data: { + data: [ + { current: false, protocolId: PROTOCOL_ID, createdAt: CREATED_AT }, + ], + }, + } as any) + + const runRecordOffset: LabwareOffset = { + id: 'offset-1', + createdAt: CREATED_AT, + definitionUri: LABWARE_URI, + location: { slotName: 'A1' }, + locationSequence: [ + { kind: 'onAddressableArea', addressableAreaName: 'A1' }, + ], + vector: { x: 1, y: 2, z: 3 }, + } + + const databaseOffset: StoredLabwareOffset = { + id: 'stored-offset-1', + createdAt: CREATED_AT, + definitionUri: LABWARE_URI, + locationSequence: [ + { kind: 'onAddressableArea', addressableAreaName: 'A1' }, + ], + vector: { x: 4, y: 5, z: 6 }, + } + + vi.mocked( + selectInitialRunRecordOffsets + ).mockImplementation((runId: string) => (state: any) => [runRecordOffset]) + + vi.mocked( + selectInitialDatabaseOffsets + ).mockImplementation((runId: string) => (state: any) => [databaseOffset]) + + renderHook(() => { + useOffsetConflictTimestamp(true, RUN_ID, { + data: { protocolId: PROTOCOL_ID }, + } as Run) + }) + + expect(mockDispatch).toHaveBeenCalledWith( + updateConflictTimestamp(RUN_ID, { + isInitialized: true, + timestamp: CREATED_AT, + }) + ) + }) + + it('should set timestamp when default offset is outdated', () => { + vi.mocked(useNotifyAllRunsQuery).mockReturnValue({ + data: { + data: [ + { current: false, protocolId: PROTOCOL_ID, createdAt: CREATED_AT }, + ], + }, + } as any) + + const runRecordOffset: LabwareOffset = { + id: 'offset-1', + createdAt: CREATED_AT, + definitionUri: LABWARE_URI, + location: { slotName: 'A1' }, + vector: { x: 1, y: 2, z: 3 }, + } + + const defaultDatabaseOffset: StoredLabwareOffset = { + id: 'default-offset-1', + createdAt: CREATED_AT, + definitionUri: LABWARE_URI, + locationSequence: 'anyLocation' as typeof ANY_LOCATION, + vector: { x: 4, y: 5, z: 6 }, + } + + vi.mocked( + selectInitialRunRecordOffsets + ).mockImplementation((runId: string) => (state: any) => [runRecordOffset]) + + vi.mocked( + selectInitialDatabaseOffsets + ).mockImplementation((runId: string) => (state: any) => [ + defaultDatabaseOffset, + ]) + + renderHook(() => { + useOffsetConflictTimestamp(true, RUN_ID, { + data: { protocolId: PROTOCOL_ID }, + } as Run) + }) + + expect(mockDispatch).toHaveBeenCalledWith( + updateConflictTimestamp(RUN_ID, { + isInitialized: true, + timestamp: CREATED_AT, + }) + ) + }) + + it('should handle empty historic run data', () => { + vi.mocked(useNotifyAllRunsQuery).mockReturnValue({ + data: { + data: [], + }, + } as any) + + const runRecordOffset: LabwareOffset = { + id: 'offset-1', + createdAt: CREATED_AT, + definitionUri: LABWARE_URI, + location: { slotName: 'A1' }, + vector: { x: 1, y: 2, z: 3 }, + } + + const databaseOffset: StoredLabwareOffset = { + id: 'stored-offset-1', + createdAt: CREATED_AT, + definitionUri: LABWARE_URI, + locationSequence: [ + { kind: 'onAddressableArea', addressableAreaName: 'A1' }, + ], + vector: { x: 4, y: 5, z: 6 }, + } + + vi.mocked( + selectInitialRunRecordOffsets + ).mockImplementation((runId: string) => (state: any) => [runRecordOffset]) + + vi.mocked( + selectInitialDatabaseOffsets + ).mockImplementation((runId: string) => (state: any) => [databaseOffset]) + + renderHook(() => + useOffsetConflictTimestamp(true, RUN_ID, { + data: { protocolId: PROTOCOL_ID }, + } as Run) + ) + + expect(mockDispatch).toHaveBeenCalledWith( + updateConflictTimestamp(RUN_ID, { isInitialized: true, timestamp: '' }) + ) + }) + + it('should handle no matching protocol in historic runs', () => { + vi.mocked(useNotifyAllRunsQuery).mockReturnValue({ + data: { + data: [ + { + current: false, + protocolId: 'different-protocol-id', + createdAt: CREATED_AT, + }, + ], + }, + } as any) + + const runRecordOffset: LabwareOffset = { + id: 'offset-1', + createdAt: CREATED_AT, + definitionUri: LABWARE_URI, + location: { slotName: 'A1' }, + vector: { x: 1, y: 2, z: 3 }, + } + + const databaseOffset: StoredLabwareOffset = { + id: 'stored-offset-1', + createdAt: CREATED_AT, + definitionUri: LABWARE_URI, + locationSequence: [ + { kind: 'onAddressableArea', addressableAreaName: 'A1' }, + ], + vector: { x: 4, y: 5, z: 6 }, + } + + vi.mocked( + selectInitialRunRecordOffsets + ).mockImplementation((runId: string) => (state: any) => [runRecordOffset]) + + vi.mocked( + selectInitialDatabaseOffsets + ).mockImplementation((runId: string) => (state: any) => [databaseOffset]) + + renderHook(() => + useOffsetConflictTimestamp(true, RUN_ID, { + data: { protocolId: PROTOCOL_ID }, + } as Run) + ) + + expect(mockDispatch).toHaveBeenCalledWith( + updateConflictTimestamp(RUN_ID, { isInitialized: true, timestamp: '' }) + ) + }) +}) diff --git a/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/__tests__/useUpdateDeckConfig.test.ts b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/__tests__/useUpdateDeckConfig.test.ts new file mode 100644 index 000000000000..b1cedf5387a2 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/__tests__/useUpdateDeckConfig.test.ts @@ -0,0 +1,105 @@ +import { vi, it, describe, expect, beforeEach } from 'vitest' +import { renderHook } from '@testing-library/react' +import { useDispatch } from 'react-redux' + +import { useUpdateDeckConfig } from '../useUpdateDeckConfig' +import { updateLPCDeck } from '/app/redux/protocol-runs' + +vi.mock('react-redux') +vi.mock('/app/redux/protocol-runs') + +describe('useUpdateDeckConfig', () => { + const RUN_ID = 'run-123' + const MOCK_DECK_CONFIG = { id: 'deck-config-1' } as any + const mockDispatch = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(useDispatch).mockReturnValue(mockDispatch) + vi.mocked(updateLPCDeck).mockImplementation( + (runId: string, deckConfig: any) => + ({ type: 'UPDATE_LPC_DECK', runId, deckConfig } as any) + ) + }) + + it('should dispatch updateLPCDeck when runId and deckConfig are provided', () => { + renderHook(() => { + useUpdateDeckConfig(RUN_ID, MOCK_DECK_CONFIG) + }) + + expect(mockDispatch).toHaveBeenCalledTimes(1) + expect(updateLPCDeck).toHaveBeenCalledWith(RUN_ID, MOCK_DECK_CONFIG) + expect(mockDispatch).toHaveBeenCalledWith( + updateLPCDeck(RUN_ID, MOCK_DECK_CONFIG) + ) + }) + + it('should not dispatch when runId is null', () => { + renderHook(() => { + useUpdateDeckConfig(null, MOCK_DECK_CONFIG) + }) + + expect(mockDispatch).not.toHaveBeenCalled() + expect(updateLPCDeck).not.toHaveBeenCalled() + }) + + it('should not dispatch when deckConfig is undefined', () => { + renderHook(() => { + useUpdateDeckConfig(RUN_ID, undefined) + }) + + expect(mockDispatch).not.toHaveBeenCalled() + expect(updateLPCDeck).not.toHaveBeenCalled() + }) + + it('should not dispatch when both runId and deckConfig are undefined/null', () => { + renderHook(() => { + useUpdateDeckConfig(null, undefined) + }) + + expect(mockDispatch).not.toHaveBeenCalled() + expect(updateLPCDeck).not.toHaveBeenCalled() + }) + + it('should re-dispatch when deckConfig changes', () => { + const { rerender } = renderHook( + props => { + useUpdateDeckConfig(props.runId, props.deckConfig) + }, + { + initialProps: { runId: RUN_ID, deckConfig: MOCK_DECK_CONFIG }, + } + ) + + expect(mockDispatch).toHaveBeenCalledTimes(1) + mockDispatch.mockClear() + + const NEW_DECK_CONFIG = { id: 'deck-config-2' } as any + rerender({ runId: RUN_ID, deckConfig: NEW_DECK_CONFIG }) + + expect(mockDispatch).toHaveBeenCalledTimes(1) + expect(updateLPCDeck).toHaveBeenCalledWith(RUN_ID, NEW_DECK_CONFIG) + expect(mockDispatch).toHaveBeenCalledWith( + updateLPCDeck(RUN_ID, NEW_DECK_CONFIG) + ) + }) + + it('should not re-dispatch when runId changes but deckConfig remains the same', () => { + const { rerender } = renderHook( + props => { + useUpdateDeckConfig(props.runId, props.deckConfig) + }, + { + initialProps: { runId: RUN_ID, deckConfig: MOCK_DECK_CONFIG }, + } + ) + + expect(mockDispatch).toHaveBeenCalledTimes(1) + mockDispatch.mockClear() + + const NEW_RUN_ID = 'run-456' + rerender({ runId: NEW_RUN_ID, deckConfig: MOCK_DECK_CONFIG }) + + expect(mockDispatch).not.toHaveBeenCalled() + }) +}) diff --git a/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/__tests__/useUpdateLabwareInfo.test.ts b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/__tests__/useUpdateLabwareInfo.test.ts new file mode 100644 index 000000000000..ee04cfc1433d --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/__tests__/useUpdateLabwareInfo.test.ts @@ -0,0 +1,147 @@ +import { vi, it, describe, expect, beforeEach } from 'vitest' +import { renderHook } from '@testing-library/react' +import { useDispatch } from 'react-redux' + +import { useUpdateLabwareInfo } from '../useUpdateLabwareInfo' +import { updateLPCLabware } from '/app/redux/protocol-runs' +import type { LPCLabwareInfo } from '/app/redux/protocol-runs' + +vi.mock('react-redux') +vi.mock('/app/redux/protocol-runs') + +describe('useUpdateLabwareInfo', () => { + const RUN_ID = 'run-123' + const MAINTENANCE_RUN_ID = 'maintenance-456' + const MOCK_LABWARE_INFO = { areOffsetsApplied: true } as LPCLabwareInfo + const mockDispatch = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(useDispatch).mockReturnValue(mockDispatch) + vi.mocked(updateLPCLabware).mockImplementation( + (runId: string, labwareInfo: LPCLabwareInfo) => + ({ type: 'UPDATE_LPC_LABWARE', runId, labwareInfo } as any) + ) + }) + + it('should dispatch updateLPCLabware when runId is provided and maintenanceRunId is null', () => { + renderHook(() => useUpdateLabwareInfo(RUN_ID, null, MOCK_LABWARE_INFO)) + + expect(mockDispatch).toHaveBeenCalledTimes(1) + expect(updateLPCLabware).toHaveBeenCalledWith(RUN_ID, MOCK_LABWARE_INFO) + expect(mockDispatch).toHaveBeenCalledWith( + updateLPCLabware(RUN_ID, MOCK_LABWARE_INFO) + ) + }) + + it('should not dispatch when runId is null', () => { + renderHook(() => useUpdateLabwareInfo(null, null, MOCK_LABWARE_INFO)) + + expect(mockDispatch).not.toHaveBeenCalled() + expect(updateLPCLabware).not.toHaveBeenCalled() + }) + + it('should not dispatch when maintenanceRunId is provided', () => { + renderHook(() => + useUpdateLabwareInfo(RUN_ID, MAINTENANCE_RUN_ID, MOCK_LABWARE_INFO) + ) + + expect(mockDispatch).not.toHaveBeenCalled() + expect(updateLPCLabware).not.toHaveBeenCalled() + }) + + it('should re-dispatch when labwareInfo changes', () => { + const { rerender } = renderHook( + props => + useUpdateLabwareInfo( + props.runId, + props.maintenanceRunId, + props.labwareInfo + ), + { + initialProps: { + runId: RUN_ID, + maintenanceRunId: null, + labwareInfo: MOCK_LABWARE_INFO, + }, + } + ) + + expect(mockDispatch).toHaveBeenCalledTimes(1) + mockDispatch.mockClear() + + const NEW_LABWARE_INFO = { areOffsetsApplied: false } as LPCLabwareInfo + rerender({ + runId: RUN_ID, + maintenanceRunId: null, + labwareInfo: NEW_LABWARE_INFO, + }) + + expect(mockDispatch).toHaveBeenCalledTimes(1) + expect(updateLPCLabware).toHaveBeenCalledWith(RUN_ID, NEW_LABWARE_INFO) + expect(mockDispatch).toHaveBeenCalledWith( + updateLPCLabware(RUN_ID, NEW_LABWARE_INFO) + ) + }) + + it('should re-dispatch when maintenanceRunId changes from value to null', () => { + const { rerender } = renderHook( + props => { + useUpdateLabwareInfo( + props.runId, + props.maintenanceRunId, + props.labwareInfo + ) + }, + { + initialProps: { + runId: RUN_ID, + maintenanceRunId: MAINTENANCE_RUN_ID, + labwareInfo: MOCK_LABWARE_INFO, + }, + } + ) + + expect(mockDispatch).not.toHaveBeenCalled() + + rerender({ + runId: RUN_ID, + maintenanceRunId: null, + labwareInfo: MOCK_LABWARE_INFO, + } as any) + + expect(mockDispatch).toHaveBeenCalledTimes(1) + expect(updateLPCLabware).toHaveBeenCalledWith(RUN_ID, MOCK_LABWARE_INFO) + }) + + it('should not re-dispatch when runId changes but other params remain the same', () => { + const { rerender } = renderHook( + props => { + useUpdateLabwareInfo( + props.runId, + props.maintenanceRunId, + props.labwareInfo + ) + }, + { + initialProps: { + runId: RUN_ID, + maintenanceRunId: null, + labwareInfo: MOCK_LABWARE_INFO, + }, + } + ) + + expect(mockDispatch).toHaveBeenCalledTimes(1) + mockDispatch.mockClear() + + const NEW_RUN_ID = 'run-456' + rerender({ + runId: NEW_RUN_ID, + maintenanceRunId: null, + labwareInfo: MOCK_LABWARE_INFO, + }) + + expect(mockDispatch).not.toHaveBeenCalled() + }) +}) diff --git a/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/index.ts b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/index.ts index 5be21bfd5a2f..7808a464df5a 100644 --- a/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/index.ts +++ b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/index.ts @@ -5,3 +5,4 @@ export * from './useUpdateDeckConfig' export * from './useHandleClientAppliedOffsets' export * from './useOffsetConflictTimestamp' export * from './useUpdateLabwareInfo' +export * from './useMonitorMaintenanceRunForDeletion' diff --git a/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useHandleClientAppliedOffsets.ts b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useHandleClientAppliedOffsets.ts index 8cf32c07b128..aa0008cd9fdc 100644 --- a/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useHandleClientAppliedOffsets.ts +++ b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useHandleClientAppliedOffsets.ts @@ -1,13 +1,10 @@ -import { useDispatch, useSelector } from 'react-redux' +import { useDispatch } from 'react-redux' import { useEffect } from 'react' import { useClientDataLPC, useUpdateClientLPC, } from '/app/resources/client_data/' -import { - appliedOffsetsToRun, - selectAreOffsetsApplied, -} from '/app/redux/protocol-runs' +import { appliedOffsetsToRun } from '/app/redux/protocol-runs' import { useIsRunCurrent } from '/app/resources/runs' const CLIENT_DATA_INTERVAL_MS = 5000 @@ -15,12 +12,9 @@ const CLIENT_DATA_INTERVAL_MS = 5000 // Keep the applied offset state in sync between various apps using the same robot. export function useHandleClientAppliedOffsets(thisRunId: string | null): void { const dispatch = useDispatch() - const areOffsetsApplied = useSelector( - selectAreOffsetsApplied(thisRunId ?? '') - ) const isThisRunCurrent = useIsRunCurrent(thisRunId) - const { clearClientData, updateWithRunId } = useUpdateClientLPC() + const { clearClientData } = useUpdateClientLPC() const { runId: clientDataRunId, userId: clientDataUserId } = useClientDataLPC( { refetchInterval: CLIENT_DATA_INTERVAL_MS, @@ -32,14 +26,10 @@ export function useHandleClientAppliedOffsets(thisRunId: string | null): void { if (clientDataRunId !== thisRunId && clientDataRunId != null) { clearClientData() } - // Offsets applied locally but not by another user - update client data - else if (areOffsetsApplied && clientDataUserId == null) { - updateWithRunId(thisRunId) - } // Offsets applied by another user but not locally - mark as applied locally else if ( clientDataUserId != null && - !areOffsetsApplied && + clientDataRunId === thisRunId && thisRunId != null ) { dispatch(appliedOffsetsToRun(thisRunId)) @@ -49,11 +39,5 @@ export function useHandleClientAppliedOffsets(thisRunId: string | null): void { clearClientData() } } - }, [ - isThisRunCurrent, - clientDataRunId, - areOffsetsApplied, - clientDataUserId, - thisRunId, - ]) + }, [isThisRunCurrent, clientDataRunId, clientDataUserId, thisRunId]) } diff --git a/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useInitLPCStore/__tests__/sortRunRecord.test.ts b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useInitLPCStore/__tests__/sortRunRecord.test.ts new file mode 100644 index 000000000000..f1fea07a6939 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useInitLPCStore/__tests__/sortRunRecord.test.ts @@ -0,0 +1,177 @@ +import { it, describe, expect } from 'vitest' + +import { sortRunRecordOffsets } from '../sortRunRecordOffsets' + +import type { LabwareOffset } from '@opentrons/api-client' + +describe('sortRunRecordOffsets', () => { + const LABWARE_URI_1 = 'opentrons/labware-1' + const LABWARE_URI_2 = 'opentrons/labware-2' + const LOCATION_SEQUENCE_1 = [ + { kind: 'onAddressableArea', addressableAreaName: 'A1' }, + ] + const LOCATION_SEQUENCE_2 = [ + { kind: 'onAddressableArea', addressableAreaName: 'B1' }, + ] + + it('should sort offsets by most recent first', () => { + const mockOffsets: LabwareOffset[] = [ + { + id: 'offset-1', + createdAt: '2022-01-01T00:00:00Z', + definitionUri: LABWARE_URI_1, + location: { slotName: 'A1' }, + locationSequence: LOCATION_SEQUENCE_1, + vector: { x: 1, y: 2, z: 3 }, + }, + { + id: 'offset-2', + createdAt: '2023-01-01T00:00:00Z', + definitionUri: LABWARE_URI_1, + location: { slotName: 'A2' }, + locationSequence: LOCATION_SEQUENCE_2, + vector: { x: 4, y: 5, z: 6 }, + }, + ] as any + + const result = sortRunRecordOffsets(mockOffsets) + + expect(result[0].id).toBe('offset-2') + expect(result[1].id).toBe('offset-1') + }) + + it('should remove duplicates based on definitionUri and locationSequence', () => { + const mockOffsets: LabwareOffset[] = [ + { + id: 'offset-1', + createdAt: '2022-01-01T00:00:00Z', + definitionUri: LABWARE_URI_1, + location: { slotName: 'A1' }, + locationSequence: LOCATION_SEQUENCE_1, + vector: { x: 1, y: 2, z: 3 }, + }, + { + id: 'offset-2', + createdAt: '2023-01-01T00:00:00Z', + definitionUri: LABWARE_URI_1, + location: { slotName: 'A1' }, + locationSequence: LOCATION_SEQUENCE_1, + vector: { x: 4, y: 5, z: 6 }, + }, + ] as any + + const result = sortRunRecordOffsets(mockOffsets) + + expect(result.length).toBe(1) + expect(result[0].id).toBe('offset-2') + }) + + it('should keep offsets with different definitionUri', () => { + const mockOffsets: LabwareOffset[] = [ + { + id: 'offset-1', + createdAt: '2022-01-01T00:00:00Z', + definitionUri: LABWARE_URI_1, + location: { slotName: 'A1' }, + locationSequence: LOCATION_SEQUENCE_1, + vector: { x: 1, y: 2, z: 3 }, + }, + { + id: 'offset-2', + createdAt: '2023-01-01T00:00:00Z', + definitionUri: LABWARE_URI_2, + location: { slotName: 'A1' }, + locationSequence: LOCATION_SEQUENCE_1, + vector: { x: 4, y: 5, z: 6 }, + }, + ] as any + + const result = sortRunRecordOffsets(mockOffsets) + + expect(result.length).toBe(2) + expect(result[0].id).toBe('offset-2') + expect(result[1].id).toBe('offset-1') + }) + + it('should keep offsets with different locationSequence', () => { + const mockOffsets: LabwareOffset[] = [ + { + id: 'offset-1', + createdAt: '2022-01-01T00:00:00Z', + definitionUri: LABWARE_URI_1, + location: { slotName: 'A1' }, + locationSequence: LOCATION_SEQUENCE_1, + vector: { x: 1, y: 2, z: 3 }, + }, + { + id: 'offset-2', + createdAt: '2023-01-01T00:00:00Z', + definitionUri: LABWARE_URI_1, + location: { slotName: 'B1' }, + locationSequence: LOCATION_SEQUENCE_2, + vector: { x: 4, y: 5, z: 6 }, + }, + ] as any + + const result = sortRunRecordOffsets(mockOffsets) + + expect(result.length).toBe(2) + expect(result[0].id).toBe('offset-2') + expect(result[1].id).toBe('offset-1') + }) + + it('should handle empty array', () => { + const result = sortRunRecordOffsets([]) + + expect(result).toEqual([]) + }) + + it('should handle single offset', () => { + const mockOffset: LabwareOffset = { + id: 'offset-1', + createdAt: '2022-01-01T00:00:00Z', + definitionUri: LABWARE_URI_1, + location: { slotName: 'A1' }, + locationSequence: LOCATION_SEQUENCE_1, + vector: { x: 1, y: 2, z: 3 }, + } as any + + const result = sortRunRecordOffsets([mockOffset]) + + expect(result).toEqual([mockOffset]) + }) + + it('should handle multiple duplicates', () => { + const mockOffsets: LabwareOffset[] = [ + { + id: 'offset-1', + createdAt: '2021-01-01T00:00:00Z', + definitionUri: LABWARE_URI_1, + location: { slotName: 'A1' }, + locationSequence: LOCATION_SEQUENCE_1, + vector: { x: 1, y: 2, z: 3 }, + }, + { + id: 'offset-2', + createdAt: '2022-01-01T00:00:00Z', + definitionUri: LABWARE_URI_1, + location: { slotName: 'A1' }, + locationSequence: LOCATION_SEQUENCE_1, + vector: { x: 4, y: 5, z: 6 }, + }, + { + id: 'offset-3', + createdAt: '2023-01-01T00:00:00Z', + definitionUri: LABWARE_URI_1, + location: { slotName: 'A1' }, + locationSequence: LOCATION_SEQUENCE_1, + vector: { x: 7, y: 8, z: 9 }, + }, + ] as any + + const result = sortRunRecordOffsets(mockOffsets) + + expect(result.length).toBe(1) + expect(result[0].id).toBe('offset-3') + }) +}) diff --git a/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useInitLPCStore/__tests__/useInitLPCStore.test.ts b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useInitLPCStore/__tests__/useInitLPCStore.test.ts new file mode 100644 index 000000000000..58cf95a0a762 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useInitLPCStore/__tests__/useInitLPCStore.test.ts @@ -0,0 +1,286 @@ +import { vi, it, describe, expect, beforeEach } from 'vitest' +import { renderHook } from '@testing-library/react' +import { useDispatch, useSelector } from 'react-redux' +import { useInitLPCStore } from '..' +import { FLEX_ROBOT_TYPE, OT2_ROBOT_TYPE } from '@opentrons/shared-data' +import { + updateLPC, + LPC_STEPS, + OFFSETS_SOURCE_INITIALIZING, +} from '/app/redux/protocol-runs' +import { getActivePipetteId } from '/app/organisms/LabwarePositionCheck/LPCFlows/hooks/utils' +import { sortRunRecordOffsets } from '/app/organisms/LabwarePositionCheck/LPCFlows/hooks/useInitLPCStore/sortRunRecordOffsets' + +vi.mock('react-redux') +vi.mock('/app/redux/protocol-runs') +vi.mock('/app/organisms/LabwarePositionCheck/LPCFlows/hooks/utils') +vi.mock( + '/app/organisms/LabwarePositionCheck/LPCFlows/hooks/useInitLPCStore/sortRunRecordOffsets' +) + +describe('useInitLPCStore', () => { + const RUN_ID = 'run-123' + const MAINTENANCE_RUN_ID = 'maintenance-run-456' + const PROTOCOL_NAME = 'Test Protocol' + const ACTIVE_PIPETTE_ID = 'pipette-789' + + const MOCK_DISPATCH = vi.fn() + const MOCK_LABWARE_DEFS = [{ metadata: { displayName: 'Labware 1' } }] as any + const MOCK_LABWARE_INFO = { + labware: {}, + areOffsetsApplied: false, + selectedLabware: null, + initialRunRecordOffsets: [], + initialDatabaseOffsets: [], + conflictTimestampInfo: { timestamp: null, isInitialized: false }, + sourcedOffsets: 'initializing', + } + const MOCK_DECK_CONFIG = { deck_def: {} } as any + const MOCK_ANALYSIS = { + pipettes: [{ id: ACTIVE_PIPETTE_ID, mount: 'left' }], + } as any + const MOCK_RUN_RECORD = { + data: { + labwareOffsets: [{ id: 'offset-1' }], + }, + } as any + const MOCK_STORED_OFFSETS = [{ id: 'stored-offset-1' }] as any + + beforeEach(() => { + vi.clearAllMocks() + + vi.mocked(useDispatch).mockReturnValue(MOCK_DISPATCH) + vi.mocked(useSelector).mockReturnValue(null) + vi.mocked(getActivePipetteId).mockReturnValue(ACTIVE_PIPETTE_ID) + vi.mocked(sortRunRecordOffsets).mockImplementation(offsets => offsets) + + vi.mocked(updateLPC).mockImplementation((runId, state) => ({ + type: 'UPDATE_LPC', + payload: { runId, state }, + })) + }) + + it('should not dispatch updateLPC when lpcState already exists', () => { + vi.mocked(useSelector).mockReturnValue({}) + + renderHook(() => { + useInitLPCStore({ + runId: RUN_ID, + analysis: MOCK_ANALYSIS, + protocolName: PROTOCOL_NAME, + deckConfig: MOCK_DECK_CONFIG, + maintenanceRunId: MAINTENANCE_RUN_ID, + labwareDefs: MOCK_LABWARE_DEFS, + labwareInfo: MOCK_LABWARE_INFO, + runRecord: MOCK_RUN_RECORD, + robotType: FLEX_ROBOT_TYPE, + flexStoredOffsets: MOCK_STORED_OFFSETS, + } as any) + }) + + expect(MOCK_DISPATCH).not.toHaveBeenCalled() + }) + + it('should not dispatch updateLPC when runId is null', () => { + renderHook(() => { + useInitLPCStore({ + runId: null, + analysis: MOCK_ANALYSIS, + protocolName: PROTOCOL_NAME, + deckConfig: MOCK_DECK_CONFIG, + maintenanceRunId: MAINTENANCE_RUN_ID, + labwareDefs: MOCK_LABWARE_DEFS, + labwareInfo: MOCK_LABWARE_INFO, + runRecord: MOCK_RUN_RECORD, + robotType: FLEX_ROBOT_TYPE, + flexStoredOffsets: MOCK_STORED_OFFSETS, + } as any) + }) + + expect(MOCK_DISPATCH).not.toHaveBeenCalled() + }) + + it('should not dispatch updateLPC when analysis is null', () => { + renderHook(() => { + useInitLPCStore({ + runId: RUN_ID, + analysis: null, + protocolName: PROTOCOL_NAME, + deckConfig: MOCK_DECK_CONFIG, + maintenanceRunId: MAINTENANCE_RUN_ID, + labwareDefs: MOCK_LABWARE_DEFS, + labwareInfo: MOCK_LABWARE_INFO, + runRecord: MOCK_RUN_RECORD, + robotType: FLEX_ROBOT_TYPE, + flexStoredOffsets: MOCK_STORED_OFFSETS, + } as any) + }) + + expect(MOCK_DISPATCH).not.toHaveBeenCalled() + }) + + it('should not dispatch updateLPC when protocolName is undefined', () => { + renderHook(() => { + useInitLPCStore({ + runId: RUN_ID, + analysis: MOCK_ANALYSIS, + protocolName: undefined, + deckConfig: MOCK_DECK_CONFIG, + maintenanceRunId: MAINTENANCE_RUN_ID, + labwareDefs: MOCK_LABWARE_DEFS, + labwareInfo: MOCK_LABWARE_INFO, + runRecord: MOCK_RUN_RECORD, + robotType: FLEX_ROBOT_TYPE, + flexStoredOffsets: MOCK_STORED_OFFSETS, + } as any) + }) + + expect(MOCK_DISPATCH).not.toHaveBeenCalled() + }) + + it('should not dispatch updateLPC when deckConfig is undefined', () => { + renderHook(() => { + useInitLPCStore({ + runId: RUN_ID, + analysis: MOCK_ANALYSIS, + protocolName: PROTOCOL_NAME, + deckConfig: undefined, + maintenanceRunId: MAINTENANCE_RUN_ID, + labwareDefs: MOCK_LABWARE_DEFS, + labwareInfo: MOCK_LABWARE_INFO, + runRecord: MOCK_RUN_RECORD, + robotType: FLEX_ROBOT_TYPE, + flexStoredOffsets: MOCK_STORED_OFFSETS, + } as any) + }) + + expect(MOCK_DISPATCH).not.toHaveBeenCalled() + }) + + it('should not dispatch updateLPC when flexStoredOffsets is undefined', () => { + renderHook(() => { + useInitLPCStore({ + runId: RUN_ID, + analysis: MOCK_ANALYSIS, + protocolName: PROTOCOL_NAME, + deckConfig: MOCK_DECK_CONFIG, + maintenanceRunId: MAINTENANCE_RUN_ID, + labwareDefs: MOCK_LABWARE_DEFS, + labwareInfo: MOCK_LABWARE_INFO, + runRecord: MOCK_RUN_RECORD, + robotType: FLEX_ROBOT_TYPE, + flexStoredOffsets: undefined, + } as any) + }) + + expect(MOCK_DISPATCH).not.toHaveBeenCalled() + }) + + it('should not dispatch updateLPC when runRecord lacks labwareOffsets', () => { + renderHook(() => { + useInitLPCStore({ + runId: RUN_ID, + analysis: MOCK_ANALYSIS, + protocolName: PROTOCOL_NAME, + deckConfig: MOCK_DECK_CONFIG, + maintenanceRunId: MAINTENANCE_RUN_ID, + labwareDefs: MOCK_LABWARE_DEFS, + labwareInfo: MOCK_LABWARE_INFO, + runRecord: { data: {} } as any, + robotType: FLEX_ROBOT_TYPE, + flexStoredOffsets: MOCK_STORED_OFFSETS, + } as any) + }) + + expect(MOCK_DISPATCH).not.toHaveBeenCalled() + }) + + it('should not dispatch updateLPC for OT2 robot type', () => { + renderHook(() => { + useInitLPCStore({ + runId: RUN_ID, + analysis: MOCK_ANALYSIS, + protocolName: PROTOCOL_NAME, + deckConfig: MOCK_DECK_CONFIG, + maintenanceRunId: MAINTENANCE_RUN_ID, + labwareDefs: MOCK_LABWARE_DEFS, + labwareInfo: MOCK_LABWARE_INFO, + runRecord: MOCK_RUN_RECORD, + robotType: OT2_ROBOT_TYPE, + flexStoredOffsets: MOCK_STORED_OFFSETS, + } as any) + }) + + expect(MOCK_DISPATCH).not.toHaveBeenCalled() + }) + + it('should dispatch updateLPC when all required conditions are met for Flex robot', () => { + renderHook(() => { + useInitLPCStore({ + runId: RUN_ID, + analysis: MOCK_ANALYSIS, + protocolName: PROTOCOL_NAME, + deckConfig: MOCK_DECK_CONFIG, + maintenanceRunId: MAINTENANCE_RUN_ID, + labwareDefs: MOCK_LABWARE_DEFS, + labwareInfo: MOCK_LABWARE_INFO, + runRecord: MOCK_RUN_RECORD, + robotType: FLEX_ROBOT_TYPE, + flexStoredOffsets: MOCK_STORED_OFFSETS, + } as any) + }) + + expect(MOCK_DISPATCH).toHaveBeenCalledTimes(1) + expect(updateLPC).toHaveBeenCalledWith(RUN_ID, { + protocolData: MOCK_ANALYSIS, + labwareDefs: MOCK_LABWARE_DEFS, + activePipetteId: ACTIVE_PIPETTE_ID, + protocolName: PROTOCOL_NAME, + deckConfig: MOCK_DECK_CONFIG, + maintenanceRunId: MAINTENANCE_RUN_ID, + labwareInfo: { + ...MOCK_LABWARE_INFO, + sourcedOffsets: OFFSETS_SOURCE_INITIALIZING, + initialRunRecordOffsets: MOCK_RUN_RECORD.data.labwareOffsets, + initialDatabaseOffsets: MOCK_STORED_OFFSETS, + }, + steps: { + currentStepIndex: 0, + totalStepCount: LPC_STEPS.length, + all: LPC_STEPS, + lastStepIndices: null, + currentSubstep: null, + }, + }) + expect(sortRunRecordOffsets).toHaveBeenCalledWith( + MOCK_RUN_RECORD.data.labwareOffsets + ) + }) + + it('should use NO_PIPETTE when no active pipette is found', () => { + vi.mocked(getActivePipetteId).mockReturnValue(null) + + renderHook(() => { + useInitLPCStore({ + runId: RUN_ID, + analysis: MOCK_ANALYSIS, + protocolName: PROTOCOL_NAME, + deckConfig: MOCK_DECK_CONFIG, + maintenanceRunId: MAINTENANCE_RUN_ID, + labwareDefs: MOCK_LABWARE_DEFS, + labwareInfo: MOCK_LABWARE_INFO, + runRecord: MOCK_RUN_RECORD, + robotType: FLEX_ROBOT_TYPE, + flexStoredOffsets: MOCK_STORED_OFFSETS, + } as any) + }) + + expect(MOCK_DISPATCH).toHaveBeenCalledTimes(1) + expect(updateLPC).toHaveBeenCalledWith( + RUN_ID, + expect.objectContaining({ + activePipetteId: 'NO_PIPETTE', + }) + ) + }) +}) diff --git a/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useInitLPCStore/index.ts b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useInitLPCStore/index.ts index 1595843e7e79..5e91b29b94f0 100644 --- a/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useInitLPCStore/index.ts +++ b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useInitLPCStore/index.ts @@ -15,11 +15,11 @@ import { OFFSETS_SOURCE_INITIALIZING, } from '/app/redux/protocol-runs' import { getActivePipetteId } from '/app/organisms/LabwarePositionCheck/LPCFlows/hooks/utils' +import { sortRunRecordOffsets } from '/app/organisms/LabwarePositionCheck/LPCFlows/hooks/useInitLPCStore/sortRunRecordOffsets' import type { Run, StoredLabwareOffset } from '@opentrons/api-client' import type { LPCWizardState, LPCLabwareInfo } from '/app/redux/protocol-runs' import type { State } from '/app/redux/types' -import { sortUniqueOffsets } from '/app/organisms/LabwarePositionCheck/LPCFlows/hooks/useInitLPCStore/sortRunRecordOffsets' export interface UseLPCInitialStateProps { runId: string | null @@ -76,7 +76,7 @@ export function useInitLPCStore({ labwareInfo: { ...rest.labwareInfo, sourcedOffsets: OFFSETS_SOURCE_INITIALIZING, - initialRunRecordOffsets: sortUniqueOffsets(runRecordOffsets), + initialRunRecordOffsets: sortRunRecordOffsets(runRecordOffsets), initialDatabaseOffsets: flexStoredOffsets, }, steps: { diff --git a/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useInitLPCStore/sortRunRecordOffsets.ts b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useInitLPCStore/sortRunRecordOffsets.ts index 5eadb49bd331..e1219aac094a 100644 --- a/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useInitLPCStore/sortRunRecordOffsets.ts +++ b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useInitLPCStore/sortRunRecordOffsets.ts @@ -3,7 +3,9 @@ import isEqual from 'lodash/isEqual' import type { LabwareOffset } from '@opentrons/api-client' // Sort offsets by most recent first, removing duplicates. -export function sortUniqueOffsets(offsets: LabwareOffset[]): LabwareOffset[] { +export function sortRunRecordOffsets( + offsets: LabwareOffset[] +): LabwareOffset[] { return ( offsets .sort( diff --git a/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useLPCLabwareInfo/__tests__/useLPCLabwareInfo.test.ts b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useLPCLabwareInfo/__tests__/useLPCLabwareInfo.test.ts new file mode 100644 index 000000000000..b3d0c335054e --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useLPCLabwareInfo/__tests__/useLPCLabwareInfo.test.ts @@ -0,0 +1,191 @@ +import { vi, it, describe, expect, beforeEach } from 'vitest' +import { renderHook } from '@testing-library/react' +import { useLPCLabwareInfo } from '..' +import { FLEX_ROBOT_TYPE, OT2_ROBOT_TYPE } from '@opentrons/shared-data' +import { RUN_STATUS_IDLE } from '@opentrons/api-client' +import { getUniqueValidLwLocationInfoByAnalysis } from '../getUniqueValidLwLocationInfoByAnalysis' +import { getLPCLabwareInfoFrom } from '../getLPCLabwareInfoFrom' +import { getLPCSearchParams } from '../getLPCSearchParams' +import { useNotifySearchLabwareOffsets } from '/app/resources/labware_offsets' +import { useNotifyRunQuery, useRunStatus } from '/app/resources/runs' + +vi.mock('../getUniqueValidLwLocationInfoByAnalysis') +vi.mock('../getLPCLabwareInfoFrom') +vi.mock('../getLPCSearchParams') +vi.mock('/app/resources/labware_offsets') +vi.mock('/app/resources/runs') + +describe('useLPCLabwareInfo', () => { + const RUN_ID = 'run-123' + const PROTOCOL_DATA = { commands: [] } as any + const LABWARE_DEFS = [{ uri: 'labware-1' }] as any + const MOCK_LW_LOCATION_COMBOS = [{ definitionUri: 'labware-uri-1' }] as any + const MOCK_SEARCH_PARAMS = { definitionUris: ['labware-uri-1'] } as any + const MOCK_STORED_OFFSETS = [{ id: 'offset-1' }] as any + const MOCK_LEGACY_OFFSETS = [{ id: 'legacy-offset-1' }] as any + const MOCK_LABWARE_INFO = { areOffsetsApplied: true } as any + + beforeEach(() => { + vi.mocked(getUniqueValidLwLocationInfoByAnalysis).mockReturnValue( + MOCK_LW_LOCATION_COMBOS + ) + vi.mocked(getLPCSearchParams).mockReturnValue(MOCK_SEARCH_PARAMS) + vi.mocked(getLPCLabwareInfoFrom).mockReturnValue(MOCK_LABWARE_INFO) + + vi.mocked(useRunStatus).mockReturnValue(RUN_STATUS_IDLE) + vi.mocked(useNotifySearchLabwareOffsets).mockReturnValue({ + data: { data: MOCK_STORED_OFFSETS }, + } as any) + vi.mocked(useNotifyRunQuery).mockReturnValue({ + data: { data: { labwareOffsets: MOCK_LEGACY_OFFSETS } }, + } as any) + }) + + it('should return data from both OT2 and Flex hooks for Flex robot type', () => { + const { result } = renderHook(() => { + return useLPCLabwareInfo({ + runId: RUN_ID, + robotType: FLEX_ROBOT_TYPE, + labwareDefs: LABWARE_DEFS, + protocolData: PROTOCOL_DATA, + }) + }) + + expect(result.current).toEqual({ + labwareInfo: MOCK_LABWARE_INFO, + storedOffsets: MOCK_STORED_OFFSETS, + legacyOffsets: MOCK_LEGACY_OFFSETS, + }) + + expect(useRunStatus).toHaveBeenCalledWith(RUN_ID) + expect(getUniqueValidLwLocationInfoByAnalysis).toHaveBeenCalledWith({ + labwareDefs: LABWARE_DEFS, + protocolData: PROTOCOL_DATA, + robotType: FLEX_ROBOT_TYPE, + }) + expect(getLPCSearchParams).toHaveBeenCalledWith(MOCK_LW_LOCATION_COMBOS) + expect(useNotifySearchLabwareOffsets).toHaveBeenCalledWith( + MOCK_SEARCH_PARAMS, + { + enabled: true, + refetchInterval: 5000, + } + ) + expect(getLPCLabwareInfoFrom).toHaveBeenCalledWith({ + currentOffsets: MOCK_STORED_OFFSETS, + lwLocInfo: MOCK_LW_LOCATION_COMBOS, + labwareDefs: LABWARE_DEFS, + protocolData: PROTOCOL_DATA, + }) + }) + + it('should return data from both OT2 and Flex hooks for OT-2 robot type', () => { + const { result } = renderHook(() => { + return useLPCLabwareInfo({ + runId: RUN_ID, + robotType: OT2_ROBOT_TYPE, + labwareDefs: LABWARE_DEFS, + protocolData: PROTOCOL_DATA, + }) + }) + + expect(result.current).toEqual({ + labwareInfo: MOCK_LABWARE_INFO, + storedOffsets: MOCK_STORED_OFFSETS, + legacyOffsets: MOCK_LEGACY_OFFSETS, + }) + + expect(useNotifyRunQuery).toHaveBeenCalledWith(RUN_ID, { + enabled: true, + }) + }) + + it('should handle null runId', () => { + const { result } = renderHook(() => { + return useLPCLabwareInfo({ + runId: null, + robotType: FLEX_ROBOT_TYPE, + labwareDefs: LABWARE_DEFS, + protocolData: PROTOCOL_DATA, + }) + }) + + expect(result.current).toEqual({ + labwareInfo: MOCK_LABWARE_INFO, + storedOffsets: MOCK_STORED_OFFSETS, + legacyOffsets: MOCK_LEGACY_OFFSETS, + }) + + expect(useRunStatus).toHaveBeenCalledWith(null) + expect(useNotifyRunQuery).toHaveBeenCalledWith(null, { + enabled: false, + }) + }) + + it('should not enable offset search if run status is not idle', () => { + vi.mocked(useRunStatus).mockReturnValue('running' as any) + + const { result } = renderHook(() => { + return useLPCLabwareInfo({ + runId: RUN_ID, + robotType: FLEX_ROBOT_TYPE, + labwareDefs: LABWARE_DEFS, + protocolData: PROTOCOL_DATA, + }) + }) + + expect(result.current).toEqual({ + labwareInfo: MOCK_LABWARE_INFO, + storedOffsets: MOCK_STORED_OFFSETS, + legacyOffsets: MOCK_LEGACY_OFFSETS, + }) + + expect(useNotifySearchLabwareOffsets).toHaveBeenCalledWith( + MOCK_SEARCH_PARAMS, + { + enabled: false, + refetchInterval: 5000, + } + ) + }) + + it('should handle undefined stored offsets', () => { + vi.mocked(useNotifySearchLabwareOffsets).mockReturnValue({ + data: undefined, + } as any) + + const { result } = renderHook(() => { + return useLPCLabwareInfo({ + runId: RUN_ID, + robotType: FLEX_ROBOT_TYPE, + labwareDefs: LABWARE_DEFS, + protocolData: PROTOCOL_DATA, + }) + }) + + expect(result.current.storedOffsets).toBeUndefined() + expect(getLPCLabwareInfoFrom).toHaveBeenCalledWith({ + currentOffsets: undefined, + lwLocInfo: MOCK_LW_LOCATION_COMBOS, + labwareDefs: LABWARE_DEFS, + protocolData: PROTOCOL_DATA, + }) + }) + + it('should handle undefined run record', () => { + vi.mocked(useNotifyRunQuery).mockReturnValue({ + data: undefined, + } as any) + + const { result } = renderHook(() => { + return useLPCLabwareInfo({ + runId: RUN_ID, + robotType: OT2_ROBOT_TYPE, + labwareDefs: LABWARE_DEFS, + protocolData: PROTOCOL_DATA, + }) + }) + + expect(result.current.legacyOffsets).toEqual([]) + }) +}) diff --git a/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useLPCLabwareInfo/getLPCLabwareInfoFrom/__tests__/getDefaultOffsetForLabware.test.ts b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useLPCLabwareInfo/getLPCLabwareInfoFrom/__tests__/getDefaultOffsetForLabware.test.ts new file mode 100644 index 000000000000..54e16d8cbe59 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useLPCLabwareInfo/getLPCLabwareInfoFrom/__tests__/getDefaultOffsetForLabware.test.ts @@ -0,0 +1,205 @@ +import { vi, it, describe, expect } from 'vitest' +import { ANY_LOCATION } from '@opentrons/api-client' +import { getLabwareDefURI } from '@opentrons/shared-data' + +import { getDefaultOffsetDetailsForLabware } from '../getDefaultOffsetForLabware' +import { OFFSET_KIND_DEFAULT } from '/app/redux/protocol-runs' + +import type { StoredLabwareOffset } from '@opentrons/api-client' +import type { LocationSpecificOffsetDetails } from '/app/redux/protocol-runs' + +vi.mock(import('@opentrons/shared-data'), async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + getLabwareDefURI: vi.fn().mockImplementation(def => 'opentrons/labware-1'), + getModuleType: vi.fn(), + } +}) + +describe('getDefaultOffsetDetailsForLabware', () => { + const LABWARE_URI = 'opentrons/labware-1' + const LABWARE_ID = 'labware-123' + const ADAPTER_ID = 'adapter-456' + const ADAPTER_URI = 'opentrons/adapter-1' + + const MOCK_LW_LOC_COMBOS = [ + { definitionUri: LABWARE_URI, labwareId: LABWARE_ID }, + ] + + const MOCK_OFFSET: StoredLabwareOffset = { + id: 'offset-1', + createdAt: '2023-01-01T00:00:00Z', + definitionUri: LABWARE_URI, + locationSequence: ANY_LOCATION, + vector: { x: 1, y: 2, z: 3 }, + } + + const MOCK_LABWARE_DEF = { + parameters: { + quirks: [], + }, + } + + const MOCK_LABWARE_DEF_WITH_QUIRK = { + parameters: { + quirks: ['stackingOnly'], + }, + } + + const MOCK_LS_OFFSETS: LocationSpecificOffsetDetails[] = [ + { + existingOffset: null, + workingOffset: null, + locationDetails: { + labwareId: LABWARE_ID, + definitionUri: LABWARE_URI, + kind: 'location-specific', + addressableAreaName: 'A1', + lwOffsetLocSeq: [], + lwModOnlyStackupDetails: [], + closestBeneathAdapterId: ADAPTER_ID, + }, + }, + ] as any + + const MOCK_PROTOCOL_DATA = { + labware: [ + { + id: ADAPTER_ID, + definitionUri: ADAPTER_URI, + }, + ], + } + + it('should return default offset details with minimal params', () => { + const result = getDefaultOffsetDetailsForLabware({ + uri: LABWARE_URI, + lwLocInfo: MOCK_LW_LOC_COMBOS, + currentOffsets: [], + labwareDefs: [MOCK_LABWARE_DEF], + locationSpecificOffsetDetails: [], + protocolData: MOCK_PROTOCOL_DATA, + } as any) + + expect(result).toEqual({ + workingOffset: null, + existingOffset: null, + locationDetails: { + labwareId: LABWARE_ID, + definitionUri: LABWARE_URI, + kind: OFFSET_KIND_DEFAULT, + addressableAreaName: 'C2', + lwOffsetLocSeq: ANY_LOCATION, + closestBeneathAdapterId: undefined, + lwModOnlyStackupDetails: [ + { + kind: 'labware', + labwareUri: LABWARE_URI, + id: LABWARE_ID, + }, + ], + }, + }) + }) + + it('should include existing offset when found', () => { + const result = getDefaultOffsetDetailsForLabware({ + uri: LABWARE_URI, + lwLocInfo: MOCK_LW_LOC_COMBOS, + currentOffsets: [MOCK_OFFSET], + labwareDefs: [MOCK_LABWARE_DEF], + locationSpecificOffsetDetails: [], + protocolData: MOCK_PROTOCOL_DATA, + } as any) + + expect(result.existingOffset).toEqual(MOCK_OFFSET) + }) + + it('should handle labware with stackingOnly quirk', () => { + const result = getDefaultOffsetDetailsForLabware({ + uri: LABWARE_URI, + lwLocInfo: MOCK_LW_LOC_COMBOS, + currentOffsets: [], + labwareDefs: [MOCK_LABWARE_DEF_WITH_QUIRK], + locationSpecificOffsetDetails: MOCK_LS_OFFSETS, + protocolData: MOCK_PROTOCOL_DATA, + } as any) + + expect(result.locationDetails.closestBeneathAdapterId).toBe(ADAPTER_ID) + expect(result.locationDetails.lwModOnlyStackupDetails).toEqual([ + { + kind: 'labware', + labwareUri: ADAPTER_URI, + id: ADAPTER_ID, + }, + { + kind: 'labware', + labwareUri: LABWARE_URI, + id: LABWARE_ID, + }, + ]) + }) + + it('should handle when lwLocInfo does not contain matching labware', () => { + const result = getDefaultOffsetDetailsForLabware({ + uri: 'different-uri', + lwLocInfo: MOCK_LW_LOC_COMBOS, + currentOffsets: [], + labwareDefs: [MOCK_LABWARE_DEF], + locationSpecificOffsetDetails: [], + protocolData: MOCK_PROTOCOL_DATA, + } as any) + + expect(result.locationDetails.labwareId).toBe('') + }) + + it('should handle when no matching labware def is found', () => { + vi.mocked(getLabwareDefURI).mockReturnValue('different-uri') + + const result = getDefaultOffsetDetailsForLabware({ + uri: LABWARE_URI, + lwLocInfo: MOCK_LW_LOC_COMBOS, + currentOffsets: [], + labwareDefs: [MOCK_LABWARE_DEF], + locationSpecificOffsetDetails: [], + protocolData: MOCK_PROTOCOL_DATA, + } as any) + + expect(result.locationDetails.closestBeneathAdapterId).toBeUndefined() + expect(result.locationDetails.lwModOnlyStackupDetails).toHaveLength(1) + }) + + it('should handle when adapter is required but no location-specific offsets have adapters', () => { + const result = getDefaultOffsetDetailsForLabware({ + uri: LABWARE_URI, + lwLocInfo: MOCK_LW_LOC_COMBOS, + currentOffsets: [], + labwareDefs: [MOCK_LABWARE_DEF_WITH_QUIRK], + locationSpecificOffsetDetails: [], + protocolData: MOCK_PROTOCOL_DATA, + } as any) + + expect(result.locationDetails.closestBeneathAdapterId).toBeUndefined() + expect(result.locationDetails.lwModOnlyStackupDetails).toEqual([ + { + kind: 'labware', + labwareUri: LABWARE_URI, + id: LABWARE_ID, + }, + ]) + }) + + it('should handle undefined labware defs', () => { + const result = getDefaultOffsetDetailsForLabware({ + uri: LABWARE_URI, + lwLocInfo: MOCK_LW_LOC_COMBOS, + currentOffsets: [], + labwareDefs: undefined, + locationSpecificOffsetDetails: [], + protocolData: MOCK_PROTOCOL_DATA, + } as any) + + expect(result.locationDetails.closestBeneathAdapterId).toBeUndefined() + }) +}) diff --git a/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useLPCLabwareInfo/getLPCLabwareInfoFrom/__tests__/getLocationSpecificOffsetDetailsForLabware.test.ts b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useLPCLabwareInfo/getLPCLabwareInfoFrom/__tests__/getLocationSpecificOffsetDetailsForLabware.test.ts new file mode 100644 index 000000000000..9aaa926f2be1 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useLPCLabwareInfo/getLPCLabwareInfoFrom/__tests__/getLocationSpecificOffsetDetailsForLabware.test.ts @@ -0,0 +1,197 @@ +import { vi, it, describe, expect } from 'vitest' +import { getLocationSpecificOffsetDetailsForLabware } from '../getLocationSpecificOffsetDetailsForLabware' +import { OFFSET_KIND_LOCATION_SPECIFIC } from '/app/redux/protocol-runs' +import { ANY_LOCATION } from '@opentrons/api-client' +import type { StoredLabwareOffset } from '@opentrons/api-client' +import type { CompletedProtocolAnalysis } from '@opentrons/shared-data' + +vi.mock('/app/local-resources/offsets', () => ({ + getLwOffsetLocSeqFromLocSeq: vi.fn().mockImplementation(locSeq => locSeq), +})) + +describe('getLocationSpecificOffsetDetailsForLabware', () => { + const LABWARE_URI = 'opentrons/labware-1' + const LABWARE_ID = 'labware-123' + const OFFSET_ID = 'offset-456' + + const MOCK_OFFSET_LOC_SEQ = [ + { kind: 'onAddressableArea', addressableAreaName: 'A1' }, + ] + + const MOCK_LW_LOC_COMBOS = [ + { + definitionUri: LABWARE_URI, + labwareId: LABWARE_ID, + lwOffsetLocSeq: MOCK_OFFSET_LOC_SEQ, + addressableAreaName: 'A1', + lwModOnlyStackupDetails: [], + }, + ] + + const MOCK_OFFSET: StoredLabwareOffset = { + id: OFFSET_ID, + createdAt: '2023-01-01T00:00:00Z', + definitionUri: LABWARE_URI, + locationSequence: MOCK_OFFSET_LOC_SEQ, + vector: { x: 1, y: 2, z: 3 }, + } as any + + const MOCK_LOAD_COMMAND = { + commandType: 'loadLabware', + result: { + labwareId: LABWARE_ID, + locationSequence: MOCK_OFFSET_LOC_SEQ, + }, + } + + const MOCK_PROTOCOL_DATA: CompletedProtocolAnalysis = { + labware: [ + { + id: LABWARE_ID, + definitionUri: LABWARE_URI, + offsetId: OFFSET_ID, + }, + ], + commands: [MOCK_LOAD_COMMAND], + modules: [], + } as any + + it('should return empty array when no offsets match the URI', () => { + const result = getLocationSpecificOffsetDetailsForLabware({ + uri: 'different-uri', + lwLocInfo: MOCK_LW_LOC_COMBOS, + currentOffsets: [], + protocolData: null, + } as any) + + expect(result).toEqual([]) + }) + + it('should return location specific offset details when URI matches', () => { + const result = getLocationSpecificOffsetDetailsForLabware({ + uri: LABWARE_URI, + lwLocInfo: MOCK_LW_LOC_COMBOS, + currentOffsets: [], + protocolData: null, + } as any) + + expect(result).toEqual([ + { + existingOffset: null, + workingOffset: null, + locationDetails: { + labwareId: LABWARE_ID, + definitionUri: LABWARE_URI, + kind: OFFSET_KIND_LOCATION_SPECIFIC, + addressableAreaName: 'A1', + lwOffsetLocSeq: MOCK_OFFSET_LOC_SEQ, + lwModOnlyStackupDetails: [], + hardCodedOffsetId: null, + }, + }, + ]) + }) + + it('should filter out ANY_LOCATION entries', () => { + const combosWithAnyLocation = [ + ...MOCK_LW_LOC_COMBOS, + { + definitionUri: LABWARE_URI, + labwareId: LABWARE_ID, + lwOffsetLocSeq: ANY_LOCATION, + addressableAreaName: 'A2', + lwModOnlyStackupDetails: [], + }, + ] + + const result = getLocationSpecificOffsetDetailsForLabware({ + uri: LABWARE_URI, + lwLocInfo: combosWithAnyLocation, + currentOffsets: [], + protocolData: null, + } as any) + + expect(result.length).toBe(1) + expect(result[0].locationDetails.addressableAreaName).toBe('A1') + }) + + it('should include existing offset when one matches', () => { + const result = getLocationSpecificOffsetDetailsForLabware({ + uri: LABWARE_URI, + lwLocInfo: MOCK_LW_LOC_COMBOS, + currentOffsets: [MOCK_OFFSET], + protocolData: null, + } as any) + + expect(result[0].existingOffset).toEqual(MOCK_OFFSET) + }) + + it('should set hardCodedOffsetId when protocol data includes matching offset', () => { + const result = getLocationSpecificOffsetDetailsForLabware({ + uri: LABWARE_URI, + lwLocInfo: MOCK_LW_LOC_COMBOS, + currentOffsets: [], + protocolData: MOCK_PROTOCOL_DATA, + } as any) + + expect(result[0].locationDetails.hardCodedOffsetId).toBe(OFFSET_ID) + }) + + it('should handle null protocol data', () => { + const result = getLocationSpecificOffsetDetailsForLabware({ + uri: LABWARE_URI, + lwLocInfo: MOCK_LW_LOC_COMBOS, + currentOffsets: [], + protocolData: null, + } as any) + + expect(result[0].locationDetails.hardCodedOffsetId).toBe(null) + }) + + it('should handle when no labware has offsetId', () => { + const protocolDataWithoutOffsetId = { + ...MOCK_PROTOCOL_DATA, + labware: [ + { + id: LABWARE_ID, + definitionUri: LABWARE_URI, + }, + ], + } + + const result = getLocationSpecificOffsetDetailsForLabware({ + uri: LABWARE_URI, + lwLocInfo: MOCK_LW_LOC_COMBOS, + currentOffsets: [], + protocolData: protocolDataWithoutOffsetId, + } as any) + + expect(result[0].locationDetails.hardCodedOffsetId).toBe(null) + }) + + it('should handle multiple location-specific offsets', () => { + const multipleLocations = [ + ...MOCK_LW_LOC_COMBOS, + { + definitionUri: LABWARE_URI, + labwareId: 'labware-456', + lwOffsetLocSeq: [ + { kind: 'onAddressableArea', addressableAreaName: 'B1' }, + ], + addressableAreaName: 'B1', + lwModOnlyStackupDetails: [], + }, + ] + + const result = getLocationSpecificOffsetDetailsForLabware({ + uri: LABWARE_URI, + lwLocInfo: multipleLocations, + currentOffsets: [], + protocolData: null, + } as any) + + expect(result.length).toBe(2) + expect(result[0].locationDetails.addressableAreaName).toBe('A1') + expect(result[1].locationDetails.addressableAreaName).toBe('B1') + }) +}) diff --git a/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useLPCLabwareInfo/getUniqueValidLwLocationInfoByAnalysis/__tests__/getUniqueValidLwLocationInfoByAnalysis.test.ts b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useLPCLabwareInfo/getUniqueValidLwLocationInfoByAnalysis/__tests__/getUniqueValidLwLocationInfoByAnalysis.test.ts new file mode 100644 index 000000000000..04db0e5494df --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useLPCLabwareInfo/getUniqueValidLwLocationInfoByAnalysis/__tests__/getUniqueValidLwLocationInfoByAnalysis.test.ts @@ -0,0 +1,103 @@ +import { vi, it, describe, expect, beforeEach } from 'vitest' +import { getUniqueValidLwLocationInfoByAnalysis } from '..' +import { FLEX_ROBOT_TYPE, OT2_ROBOT_TYPE } from '@opentrons/shared-data' +import { getActivePipetteId } from '/app/organisms/LabwarePositionCheck/LPCFlows/hooks/utils' +import { getLPCUniqValidLabwareLocationInfo } from '../getLPCUniqValidLabwareLocationInfo' +import type { CompletedProtocolAnalysis } from '@opentrons/shared-data' + +vi.mock('/app/organisms/LabwarePositionCheck/LPCFlows/hooks/utils') +vi.mock('../getLPCUniqValidLabwareLocationInfo') + +describe('getUniqueValidLwLocationInfoByAnalysis', () => { + const ACTIVE_PIPETTE_ID = 'pipette-123' + const MOCK_LW_LOCATION_INFOS = [ + { definitionUri: 'labware-1', labwareId: 'lw-1' }, + { definitionUri: 'labware-2', labwareId: 'lw-2' }, + ] + const MOCK_PROTOCOL_DATA = { + pipettes: [{ id: ACTIVE_PIPETTE_ID, mount: 'left' }], + } as CompletedProtocolAnalysis + const MOCK_LABWARE_DEFS = [{ metadata: { displayName: 'Labware 1' } }] as any + + beforeEach(() => { + vi.mocked(getActivePipetteId).mockReturnValue(ACTIVE_PIPETTE_ID) + vi.mocked(getLPCUniqValidLabwareLocationInfo).mockReturnValue( + MOCK_LW_LOCATION_INFOS as any + ) + }) + + it('should return empty array when protocolData is null', () => { + const result = getUniqueValidLwLocationInfoByAnalysis({ + protocolData: null, + labwareDefs: MOCK_LABWARE_DEFS, + robotType: FLEX_ROBOT_TYPE, + }) + + expect(result).toEqual([]) + expect(getLPCUniqValidLabwareLocationInfo).not.toHaveBeenCalled() + }) + + it('should return empty array when labwareDefs is null', () => { + const result = getUniqueValidLwLocationInfoByAnalysis({ + protocolData: MOCK_PROTOCOL_DATA, + labwareDefs: null, + robotType: FLEX_ROBOT_TYPE, + }) + + expect(result).toEqual([]) + expect(getLPCUniqValidLabwareLocationInfo).not.toHaveBeenCalled() + }) + + it('should return empty array when activePipetteId is null', () => { + vi.mocked(getActivePipetteId).mockReturnValue(null) + + const result = getUniqueValidLwLocationInfoByAnalysis({ + protocolData: MOCK_PROTOCOL_DATA, + labwareDefs: MOCK_LABWARE_DEFS, + robotType: FLEX_ROBOT_TYPE, + }) + + expect(result).toEqual([]) + expect(getLPCUniqValidLabwareLocationInfo).not.toHaveBeenCalled() + }) + + it('should return empty array when robotType is not FLEX_ROBOT_TYPE', () => { + const result = getUniqueValidLwLocationInfoByAnalysis({ + protocolData: MOCK_PROTOCOL_DATA, + labwareDefs: MOCK_LABWARE_DEFS, + robotType: OT2_ROBOT_TYPE, + }) + + expect(result).toEqual([]) + expect(getLPCUniqValidLabwareLocationInfo).not.toHaveBeenCalled() + }) + + it('should return result from getLPCUniqValidLabwareLocationInfo when all conditions are met', () => { + const result = getUniqueValidLwLocationInfoByAnalysis({ + protocolData: MOCK_PROTOCOL_DATA, + labwareDefs: MOCK_LABWARE_DEFS, + robotType: FLEX_ROBOT_TYPE, + }) + + expect(result).toEqual(MOCK_LW_LOCATION_INFOS) + expect(getLPCUniqValidLabwareLocationInfo).toHaveBeenCalledWith( + MOCK_PROTOCOL_DATA, + MOCK_LABWARE_DEFS + ) + }) + + it('should pass empty array to getActivePipetteId when protocolData has no pipettes', () => { + const protocolDataWithoutPipettes = { + ...MOCK_PROTOCOL_DATA, + pipettes: undefined, + } + + getUniqueValidLwLocationInfoByAnalysis({ + protocolData: protocolDataWithoutPipettes, + labwareDefs: MOCK_LABWARE_DEFS, + robotType: FLEX_ROBOT_TYPE, + } as any) + + expect(getActivePipetteId).toHaveBeenCalledWith([]) + }) +}) diff --git a/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useLPCLabwareInfo/getUniqueValidLwLocationInfoByAnalysis/getLPCUniqValidLabwareLocationInfo/__tests__/appendUniqValidLocCombo.test.ts b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useLPCLabwareInfo/getUniqueValidLwLocationInfoByAnalysis/getLPCUniqValidLabwareLocationInfo/__tests__/appendUniqValidLocCombo.test.ts new file mode 100644 index 000000000000..674bb5f34c5f --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useLPCLabwareInfo/getUniqueValidLwLocationInfoByAnalysis/getLPCUniqValidLabwareLocationInfo/__tests__/appendUniqValidLocCombo.test.ts @@ -0,0 +1,268 @@ +import { vi, it, describe, expect, beforeEach } from 'vitest' + +import { getLabwareDefURI } from '@opentrons/shared-data' + +import { appendUniqValidLocCombo } from '../appendUniqValidLocCombo' + +import type { LabwareLocationInfoWithLocSeq } from '..' +import type { LabwareDefinition2 } from '@opentrons/shared-data' + +vi.mock('@opentrons/shared-data', () => ({ + FLEX_STAGING_ADDRESSABLE_AREAS: ['A1', 'C2'], + getLabwareDefURI: vi.fn(), +})) + +describe('appendUniqValidLocCombo', () => { + const LABWARE_ID = 'labware-1' + const LABWARE_URI = 'labware-1' + const ADAPTER_ID = 'adapter-1' + const MODULE_ID = 'module-1' + const MODULE_MODEL = 'thermocyclerModuleV2' + const ADDRESSABLE_AREA = 'B1' + + const BASIC_LABWARE_DEF: LabwareDefinition2 = { + metadata: { displayName: 'Basic Labware' }, + } as LabwareDefinition2 + + const LID_LABWARE_DEF: LabwareDefinition2 = { + metadata: { displayName: 'Lid Labware' }, + allowedRoles: ['lid'], + } as LabwareDefinition2 + + const ADAPTER_LABWARE_DEF: LabwareDefinition2 = { + metadata: { displayName: 'Adapter Labware' }, + allowedRoles: ['adapter'], + } as LabwareDefinition2 + + const SYSTEM_LABWARE_DEF: LabwareDefinition2 = { + metadata: { displayName: 'System Labware' }, + allowedRoles: ['system'], + } as LabwareDefinition2 + + const BASIC_LOCATION_COMBO: LabwareLocationInfoWithLocSeq = { + labwareId: LABWARE_ID, + definitionUri: LABWARE_URI, + addressableAreaName: ADDRESSABLE_AREA, + closestBeneathModuleId: MODULE_ID, + closestBeneathModuleModel: MODULE_MODEL, + closestBeneathAdapterId: ADAPTER_ID, + lwModOnlyStackupDetails: [], + lwOffsetLocSeq: [ + { kind: 'onAddressableArea', addressableAreaName: ADDRESSABLE_AREA }, + ], + locationSequence: [ + { kind: 'onAddressableArea', addressableAreaName: ADDRESSABLE_AREA }, + ], + } + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(getLabwareDefURI).mockImplementation((def: any) => { + if (def === BASIC_LABWARE_DEF) { + return LABWARE_URI + } + if (def === LID_LABWARE_DEF) { + return 'lid-labware' + } + if (def === ADAPTER_LABWARE_DEF) { + return 'adapter-labware' + } + if (def === SYSTEM_LABWARE_DEF) { + return 'system-labware' + } else { + return '' + } + }) + }) + + it('should return unchanged accumulator when combo is null', () => { + const acc: LabwareLocationInfoWithLocSeq[] = [BASIC_LOCATION_COMBO] + const lwDefs: LabwareDefinition2[] = [BASIC_LABWARE_DEF] + + const result = appendUniqValidLocCombo(acc, lwDefs, null) + + expect(result).toBe(acc) + }) + + it('should append valid and unique combo to accumulator', () => { + const acc: LabwareLocationInfoWithLocSeq[] = [] + const lwDefs: LabwareDefinition2[] = [BASIC_LABWARE_DEF] + + const result = appendUniqValidLocCombo(acc, lwDefs, BASIC_LOCATION_COMBO) + + expect(result).toEqual([BASIC_LOCATION_COMBO]) + }) + + it('should not append combo that already exists in accumulator', () => { + const acc: LabwareLocationInfoWithLocSeq[] = [BASIC_LOCATION_COMBO] + const lwDefs: LabwareDefinition2[] = [BASIC_LABWARE_DEF] + + const result = appendUniqValidLocCombo(acc, lwDefs, BASIC_LOCATION_COMBO) + + expect(result).toEqual([BASIC_LOCATION_COMBO]) + }) + + it('should not append combo with same offset sequence and definition URI', () => { + const acc: LabwareLocationInfoWithLocSeq[] = [BASIC_LOCATION_COMBO] + const lwDefs: LabwareDefinition2[] = [BASIC_LABWARE_DEF] + + const similarCombo: LabwareLocationInfoWithLocSeq = { + ...BASIC_LOCATION_COMBO, + labwareId: 'different-id', + } + + const result = appendUniqValidLocCombo(acc, lwDefs, similarCombo) + + expect(result).toEqual([BASIC_LOCATION_COMBO]) + }) + + it('should not append combo when labware definition is not found', () => { + const acc: LabwareLocationInfoWithLocSeq[] = [] + const lwDefs: LabwareDefinition2[] = [] + + const result = appendUniqValidLocCombo(acc, lwDefs, BASIC_LOCATION_COMBO) + + expect(result).toEqual([]) + }) + + it('should not append combo with lid role', () => { + const acc: LabwareLocationInfoWithLocSeq[] = [] + const lwDefs: LabwareDefinition2[] = [LID_LABWARE_DEF] + + const lidCombo: LabwareLocationInfoWithLocSeq = { + ...BASIC_LOCATION_COMBO, + definitionUri: 'lid-labware', + } + + const result = appendUniqValidLocCombo(acc, lwDefs, lidCombo) + + expect(result).toEqual([]) + }) + + it('should not append combo with adapter role', () => { + const acc: LabwareLocationInfoWithLocSeq[] = [] + const lwDefs: LabwareDefinition2[] = [ADAPTER_LABWARE_DEF] + + const adapterCombo: LabwareLocationInfoWithLocSeq = { + ...BASIC_LOCATION_COMBO, + definitionUri: 'adapter-labware', + } + + const result = appendUniqValidLocCombo(acc, lwDefs, adapterCombo) + + expect(result).toEqual([]) + }) + + it('should not append combo with system role', () => { + const acc: LabwareLocationInfoWithLocSeq[] = [] + const lwDefs: LabwareDefinition2[] = [SYSTEM_LABWARE_DEF] + + const systemCombo: LabwareLocationInfoWithLocSeq = { + ...BASIC_LOCATION_COMBO, + definitionUri: 'system-labware', + } + + const result = appendUniqValidLocCombo(acc, lwDefs, systemCombo) + + expect(result).toEqual([]) + }) + + it('should not append combo with empty offset location sequence', () => { + const acc: LabwareLocationInfoWithLocSeq[] = [] + const lwDefs: LabwareDefinition2[] = [BASIC_LABWARE_DEF] + + const comboWithEmptyOffsetLocSeq: LabwareLocationInfoWithLocSeq = { + ...BASIC_LOCATION_COMBO, + lwOffsetLocSeq: [], + } + + const result = appendUniqValidLocCombo( + acc, + lwDefs, + comboWithEmptyOffsetLocSeq + ) + + expect(result).toEqual([]) + }) + + it('should not append combo with notOnDeck kind', () => { + const acc: LabwareLocationInfoWithLocSeq[] = [] + const lwDefs: LabwareDefinition2[] = [BASIC_LABWARE_DEF] + + const notOnDeckCombo: LabwareLocationInfoWithLocSeq = { + ...BASIC_LOCATION_COMBO, + locationSequence: [ + { kind: 'onAddressableArea', addressableAreaName: ADDRESSABLE_AREA }, + { kind: 'notOnDeck', logicalLocationName: 'offDeck' }, + ], + } + + const result = appendUniqValidLocCombo(acc, lwDefs, notOnDeckCombo) + + expect(result).toEqual([]) + }) + + it('should not append combo with inStackerHopper kind', () => { + const acc: LabwareLocationInfoWithLocSeq[] = [] + const lwDefs: LabwareDefinition2[] = [BASIC_LABWARE_DEF] + + const inStackerHopperCombo: LabwareLocationInfoWithLocSeq = { + ...BASIC_LOCATION_COMBO, + locationSequence: [ + { kind: 'onAddressableArea', addressableAreaName: ADDRESSABLE_AREA }, + { kind: 'inStackerHopper', moduleId: 'mock-module-id' }, + ], + } + + const result = appendUniqValidLocCombo(acc, lwDefs, inStackerHopperCombo) + + expect(result).toEqual([]) + }) + + it('should not append combo in staging area', () => { + const acc: LabwareLocationInfoWithLocSeq[] = [] + const lwDefs: LabwareDefinition2[] = [BASIC_LABWARE_DEF] + + const stagingAreaCombo: LabwareLocationInfoWithLocSeq = { + ...BASIC_LOCATION_COMBO, + addressableAreaName: 'A1', + locationSequence: [ + { kind: 'onAddressableArea', addressableAreaName: 'A1' }, + ], + lwOffsetLocSeq: [ + { kind: 'onAddressableArea', addressableAreaName: 'A1' }, + ], + } + + const result = appendUniqValidLocCombo(acc, lwDefs, stagingAreaCombo) + + expect(result).toEqual([]) + }) + + it('should append valid combo with allowed roles that are not lid, adapter, or system', () => { + const acc: LabwareLocationInfoWithLocSeq[] = [] + const otherRoleLabwareDef: LabwareDefinition2 = { + metadata: { displayName: 'Other Role Labware' }, + allowedRoles: ['tipRack'], + } as any + + vi.mocked(getLabwareDefURI).mockImplementation((def: any) => { + if (def === otherRoleLabwareDef) { + return 'other-role-labware' + } else { + return '' + } + }) + + const lwDefs: LabwareDefinition2[] = [otherRoleLabwareDef] + + const otherRoleCombo: LabwareLocationInfoWithLocSeq = { + ...BASIC_LOCATION_COMBO, + definitionUri: 'other-role-labware', + } + + const result = appendUniqValidLocCombo(acc, lwDefs, otherRoleCombo) + + expect(result).toEqual([otherRoleCombo]) + }) +}) diff --git a/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useLPCLabwareInfo/getUniqueValidLwLocationInfoByAnalysis/getLPCUniqValidLabwareLocationInfo/__tests__/getAllPossibleLwURIsInRun.test.ts b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useLPCLabwareInfo/getUniqueValidLwLocationInfoByAnalysis/getLPCUniqValidLabwareLocationInfo/__tests__/getAllPossibleLwURIsInRun.test.ts new file mode 100644 index 000000000000..760838e0fcec --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useLPCLabwareInfo/getUniqueValidLwLocationInfoByAnalysis/getLPCUniqValidLabwareLocationInfo/__tests__/getAllPossibleLwURIsInRun.test.ts @@ -0,0 +1,123 @@ +import { vi, it, describe, expect, beforeEach } from 'vitest' +import { scanAllCommandsForAllLwUrisByLwId } from '../getAllPossibleLwURIsInRun' +import type { + LoadedLabware, + RunTimeCommand, + FlexStackerRetrieveRunTimeCommand, +} from '@opentrons/shared-data' + +describe('scanAllCommandsForAllLwUrisByLwId', () => { + const LABWARE_ID_1 = 'labware-1' + const LABWARE_URI_1 = 'labware-1' + const LABWARE_ID_2 = 'labware-2' + const LABWARE_URI_2 = 'labware-2' + const ADAPTER_ID = 'adapter-1' + const ADAPTER_URI = 'adapter-1' + + const MOCK_LOADED_LABWARE: LoadedLabware[] = [ + { id: LABWARE_ID_1, definitionUri: LABWARE_URI_1 }, + ] as LoadedLabware[] + + const MOCK_FLEX_STACKER_COMMAND: FlexStackerRetrieveRunTimeCommand = { + commandType: 'flexStacker/retrieve', + result: { + labwareId: LABWARE_ID_2, + primaryLabwareURI: LABWARE_URI_2, + adapterId: ADAPTER_ID, + adapterLabwareURI: ADAPTER_URI, + }, + } as FlexStackerRetrieveRunTimeCommand + + const MOCK_OTHER_COMMAND: RunTimeCommand = { + commandType: 'aspirate', + } as RunTimeCommand + + beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + it('should return labware URIs from loaded labware', () => { + const result = scanAllCommandsForAllLwUrisByLwId(MOCK_LOADED_LABWARE, []) + + expect(result).toEqual({ + [LABWARE_ID_1]: LABWARE_URI_1, + }) + }) + + it('should include labware URIs from flexStacker/retrieve commands', () => { + const result = scanAllCommandsForAllLwUrisByLwId(MOCK_LOADED_LABWARE, [ + MOCK_FLEX_STACKER_COMMAND, + ]) + + expect(result).toEqual({ + [LABWARE_ID_1]: LABWARE_URI_1, + [LABWARE_ID_2]: LABWARE_URI_2, + [ADAPTER_ID]: ADAPTER_URI, + }) + }) + + it('should ignore non-flexStacker/retrieve commands', () => { + const result = scanAllCommandsForAllLwUrisByLwId(MOCK_LOADED_LABWARE, [ + MOCK_OTHER_COMMAND, + ]) + + expect(result).toEqual({ + [LABWARE_ID_1]: LABWARE_URI_1, + }) + }) + + it('should handle multiple commands', () => { + const result = scanAllCommandsForAllLwUrisByLwId(MOCK_LOADED_LABWARE, [ + MOCK_FLEX_STACKER_COMMAND, + MOCK_OTHER_COMMAND, + ]) + + expect(result).toEqual({ + [LABWARE_ID_1]: LABWARE_URI_1, + [LABWARE_ID_2]: LABWARE_URI_2, + [ADAPTER_ID]: ADAPTER_URI, + }) + }) + + it('should handle flexStacker/retrieve command without adapter', () => { + const commandWithoutAdapter: FlexStackerRetrieveRunTimeCommand = { + ...MOCK_FLEX_STACKER_COMMAND, + result: { + labwareId: LABWARE_ID_2, + primaryLabwareURI: LABWARE_URI_2, + }, + } as FlexStackerRetrieveRunTimeCommand + + const result = scanAllCommandsForAllLwUrisByLwId(MOCK_LOADED_LABWARE, [ + commandWithoutAdapter, + ]) + + expect(result).toEqual({ + [LABWARE_ID_1]: LABWARE_URI_1, + [LABWARE_ID_2]: LABWARE_URI_2, + }) + }) + + it('should handle flexStacker/retrieve command with null result', () => { + const commandWithNullResult: FlexStackerRetrieveRunTimeCommand = { + ...MOCK_FLEX_STACKER_COMMAND, + result: null, + } as any + + const result = scanAllCommandsForAllLwUrisByLwId(MOCK_LOADED_LABWARE, [ + commandWithNullResult, + ]) + + expect(result).toEqual({ + [LABWARE_ID_1]: LABWARE_URI_1, + '': '', + }) + expect(console.error).toHaveBeenCalled() + }) + + it('should handle empty loaded labware and commands', () => { + const result = scanAllCommandsForAllLwUrisByLwId([], []) + + expect(result).toEqual({}) + }) +}) diff --git a/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useLPCLabwareInfo/getUniqueValidLwLocationInfoByAnalysis/getLPCUniqValidLabwareLocationInfo/__tests__/getLPCUniqValidLabwareLocationInfo.test.ts b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useLPCLabwareInfo/getUniqueValidLwLocationInfoByAnalysis/getLPCUniqValidLabwareLocationInfo/__tests__/getLPCUniqValidLabwareLocationInfo.test.ts new file mode 100644 index 000000000000..5274a506c5f6 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useLPCLabwareInfo/getUniqueValidLwLocationInfoByAnalysis/getLPCUniqValidLabwareLocationInfo/__tests__/getLPCUniqValidLabwareLocationInfo.test.ts @@ -0,0 +1,213 @@ +import { vi, it, describe, expect, beforeEach } from 'vitest' + +import { getLPCUniqValidLabwareLocationInfo } from '..' +import { appendUniqValidLocCombo } from '../appendUniqValidLocCombo' +import { getLoadLabwareLocationCombo } from '../getLoadLabwareLocationCombo' +import { getMoveLabwareLocationCombo } from '../getMoveLabwareLocationCombo' +import { scanAllCommandsForAllLwUrisByLwId } from '../getAllPossibleLwURIsInRun' + +import type { CompletedProtocolAnalysis } from '@opentrons/shared-data' +import type { LabwareLocationInfoWithLocSeq } from '..' + +vi.mock('../appendUniqValidLocCombo') +vi.mock('../getLoadLabwareLocationCombo') +vi.mock('../getMoveLabwareLocationCombo') +vi.mock('../getAllPossibleLwURIsInRun') + +describe('getLPCUniqValidLabwareLocationInfo', () => { + const LABWARE_ID_1 = 'labware-1' + const LABWARE_ID_2 = 'labware-2' + const LABWARE_URI_1 = 'opentrons/labware-1' + const LABWARE_URI_2 = 'opentrons/labware-2' + + const MOCK_LABWARE = [ + { id: LABWARE_ID_1, definitionUri: LABWARE_URI_1 }, + { id: LABWARE_ID_2, definitionUri: LABWARE_URI_2 }, + ] + + const MOCK_MODULES = [{ id: 'module-1', model: 'thermocyclerModuleV2' }] + + const MOCK_LOAD_COMMAND = { + commandType: 'loadLabware', + params: { labwareId: LABWARE_ID_1 }, + } + + const MOCK_MOVE_COMMAND = { + commandType: 'moveLabware', + params: { labwareId: LABWARE_ID_2 }, + } + + const MOCK_OTHER_COMMAND = { + commandType: 'aspirate', + params: {}, + } + + const MOCK_COMMANDS = [ + MOCK_LOAD_COMMAND, + MOCK_MOVE_COMMAND, + MOCK_OTHER_COMMAND, + ] + + const MOCK_PROTOCOL_DATA: CompletedProtocolAnalysis = { + labware: MOCK_LABWARE, + modules: MOCK_MODULES, + commands: MOCK_COMMANDS, + } as any + + const MOCK_LABWARE_DEFS = [ + { metadata: { displayName: 'Labware 1' } }, + { metadata: { displayName: 'Labware 2' } }, + ] as any + + const MOCK_LW_ID_URI_INFO = { + [LABWARE_ID_1]: LABWARE_URI_1, + [LABWARE_ID_2]: LABWARE_URI_2, + } + + const MOCK_LOAD_COMBO: LabwareLocationInfoWithLocSeq = { + definitionUri: LABWARE_URI_1, + labwareId: LABWARE_ID_1, + addressableAreaName: 'A1', + lwOffsetLocSeq: [], + lwModOnlyStackupDetails: [], + locationSequence: [], + } + + const MOCK_MOVE_COMBO: LabwareLocationInfoWithLocSeq = { + definitionUri: LABWARE_URI_2, + labwareId: LABWARE_ID_2, + addressableAreaName: 'B1', + lwOffsetLocSeq: [], + lwModOnlyStackupDetails: [], + locationSequence: [], + } + + beforeEach(() => { + vi.clearAllMocks() + + vi.mocked(scanAllCommandsForAllLwUrisByLwId).mockReturnValue( + MOCK_LW_ID_URI_INFO + ) + vi.mocked(getLoadLabwareLocationCombo).mockReturnValue(MOCK_LOAD_COMBO) + vi.mocked(getMoveLabwareLocationCombo).mockReturnValue(MOCK_MOVE_COMBO) + vi.mocked(appendUniqValidLocCombo).mockImplementation( + (acc, defs, combo) => { + if (combo) { + return [...acc, combo] + } + return acc + } + ) + }) + + it('should return empty array when protocolData is null', () => { + const result = getLPCUniqValidLabwareLocationInfo(null, MOCK_LABWARE_DEFS) + + expect(result).toEqual([]) + expect(scanAllCommandsForAllLwUrisByLwId).toHaveBeenCalledWith([], []) + }) + + it('should process each command and append valid combos', () => { + const result = getLPCUniqValidLabwareLocationInfo( + MOCK_PROTOCOL_DATA, + MOCK_LABWARE_DEFS + ) + + expect(scanAllCommandsForAllLwUrisByLwId).toHaveBeenCalledWith( + MOCK_LABWARE, + MOCK_COMMANDS + ) + expect(getLoadLabwareLocationCombo).toHaveBeenCalledWith( + MOCK_LOAD_COMMAND, + MOCK_LABWARE, + MOCK_MODULES + ) + expect(getMoveLabwareLocationCombo).toHaveBeenCalledWith( + MOCK_MOVE_COMMAND, + MOCK_LW_ID_URI_INFO, + MOCK_LABWARE, + MOCK_MODULES + ) + + expect(appendUniqValidLocCombo).toHaveBeenCalledTimes(3) + expect(appendUniqValidLocCombo).toHaveBeenCalledWith( + [], + MOCK_LABWARE_DEFS, + MOCK_LOAD_COMBO + ) + expect(appendUniqValidLocCombo).toHaveBeenCalledWith( + [MOCK_LOAD_COMBO], + MOCK_LABWARE_DEFS, + MOCK_MOVE_COMBO + ) + expect(appendUniqValidLocCombo).toHaveBeenCalledWith( + [MOCK_LOAD_COMBO, MOCK_MOVE_COMBO], + MOCK_LABWARE_DEFS, + null + ) + + expect(result).toEqual([ + { + definitionUri: LABWARE_URI_1, + labwareId: LABWARE_ID_1, + addressableAreaName: 'A1', + lwOffsetLocSeq: [], + lwModOnlyStackupDetails: [], + }, + { + definitionUri: LABWARE_URI_2, + labwareId: LABWARE_ID_2, + addressableAreaName: 'B1', + lwOffsetLocSeq: [], + lwModOnlyStackupDetails: [], + }, + ]) + }) + + it('should handle empty commands array', () => { + const emptyCommandsProtocolData = { + ...MOCK_PROTOCOL_DATA, + commands: [], + } + + const result = getLPCUniqValidLabwareLocationInfo( + emptyCommandsProtocolData, + MOCK_LABWARE_DEFS + ) + + expect(result).toEqual([]) + expect(scanAllCommandsForAllLwUrisByLwId).toHaveBeenCalledWith( + MOCK_LABWARE, + [] + ) + expect(appendUniqValidLocCombo).not.toHaveBeenCalled() + }) + + it('should skip commands that do not generate location combos', () => { + vi.mocked(getLoadLabwareLocationCombo).mockReturnValue(null) + vi.mocked(getMoveLabwareLocationCombo).mockReturnValue(null) + + const result = getLPCUniqValidLabwareLocationInfo( + MOCK_PROTOCOL_DATA, + MOCK_LABWARE_DEFS + ) + + expect(result).toEqual([]) + expect(appendUniqValidLocCombo).toHaveBeenCalledTimes(3) + expect(appendUniqValidLocCombo).toHaveBeenCalledWith( + [], + MOCK_LABWARE_DEFS, + null + ) + expect(appendUniqValidLocCombo).toHaveBeenCalledWith( + [], + MOCK_LABWARE_DEFS, + null + ) + expect(appendUniqValidLocCombo).toHaveBeenCalledWith( + [], + MOCK_LABWARE_DEFS, + null + ) + }) +}) diff --git a/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useLPCLabwareInfo/getUniqueValidLwLocationInfoByAnalysis/getLPCUniqValidLabwareLocationInfo/__tests__/getLoadLabwareLocationCombo.test.ts b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useLPCLabwareInfo/getUniqueValidLwLocationInfoByAnalysis/getLPCUniqValidLabwareLocationInfo/__tests__/getLoadLabwareLocationCombo.test.ts new file mode 100644 index 000000000000..6efcf0ebb3a7 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useLPCLabwareInfo/getUniqueValidLwLocationInfoByAnalysis/getLPCUniqValidLabwareLocationInfo/__tests__/getLoadLabwareLocationCombo.test.ts @@ -0,0 +1,197 @@ +import { vi, it, describe, expect, beforeEach } from 'vitest' +import { getLoadLabwareLocationCombo } from '../getLoadLabwareLocationCombo' +import { getLabwareDefURI } from '@opentrons/shared-data' +import { + getClosestBeneathAdapterId, + getClosestBeneathModuleId, + getClosestBeneathModuleModel, + getLwModStackupDetails, + getAddressableAreaNameFrom, +} from '../helpers' +import { getLwOffsetLocSeqFromLocSeq } from '/app/local-resources/offsets' +import type { + LoadLabwareRunTimeCommand, + LoadedLabware, + LoadedModule, +} from '@opentrons/shared-data' + +vi.mock('@opentrons/shared-data', () => ({ + getLabwareDefURI: vi.fn(), +})) +vi.mock('../helpers') +vi.mock('/app/local-resources/offsets') + +describe('getLoadLabwareLocationCombo', () => { + const LABWARE_ID = 'labware-123' + const LABWARE_URI = 'labware-1' + const ADAPTER_ID = 'adapter-456' + const MODULE_ID = 'module-789' + const MODULE_MODEL = 'thermocyclerModuleV2' + const ADDRESSABLE_AREA = 'B1' + + const LABWARE_DEFINITION = { metadata: { displayName: 'Test Labware' } } + + const LOCATION_SEQUENCE = [ + { kind: 'onAddressableArea', addressableAreaName: ADDRESSABLE_AREA }, + ] + + const OFFSET_LOCATION_SEQUENCE = [ + { kind: 'onAddressableArea', addressableAreaName: ADDRESSABLE_AREA }, + ] + + const LW_MOD_STACKUP_DETAILS = [ + { kind: 'labware', labwareUri: LABWARE_URI, id: LABWARE_ID }, + ] + + const MOCK_LABWARE: LoadedLabware[] = [ + { id: LABWARE_ID, definitionUri: LABWARE_URI }, + ] as LoadedLabware[] + + const MOCK_MODULES: LoadedModule[] = [ + { id: MODULE_ID, model: MODULE_MODEL }, + ] as LoadedModule[] + + const MOCK_LOAD_COMMAND: LoadLabwareRunTimeCommand = { + commandType: 'loadLabware', + params: { labwareId: LABWARE_ID }, + result: { + labwareId: LABWARE_ID, + definition: LABWARE_DEFINITION, + locationSequence: LOCATION_SEQUENCE, + }, + } as LoadLabwareRunTimeCommand + + beforeEach(() => { + vi.clearAllMocks() + + vi.mocked(getLabwareDefURI).mockReturnValue(LABWARE_URI) + vi.mocked(getAddressableAreaNameFrom).mockReturnValue(ADDRESSABLE_AREA) + vi.mocked(getClosestBeneathModuleId).mockReturnValue(MODULE_ID) + vi.mocked(getClosestBeneathModuleModel).mockReturnValue(MODULE_MODEL) + vi.mocked(getClosestBeneathAdapterId).mockReturnValue(ADAPTER_ID) + vi.mocked(getLwOffsetLocSeqFromLocSeq).mockReturnValue( + OFFSET_LOCATION_SEQUENCE as any + ) + vi.mocked(getLwModStackupDetails).mockReturnValue( + LW_MOD_STACKUP_DETAILS as any + ) + }) + + it('should return null when result is null', () => { + const commandWithNullResult = { + ...MOCK_LOAD_COMMAND, + result: null, + } + + const result = getLoadLabwareLocationCombo( + commandWithNullResult as any, + MOCK_LABWARE, + MOCK_MODULES + ) + + expect(result).toBeNull() + }) + + it('should return null when locationSequence is null', () => { + const commandWithNullLocSeq = { + ...MOCK_LOAD_COMMAND, + result: { + ...MOCK_LOAD_COMMAND.result, + locationSequence: null, + }, + } + + const result = getLoadLabwareLocationCombo( + commandWithNullLocSeq as any, + MOCK_LABWARE, + MOCK_MODULES + ) + + expect(result).toBeNull() + }) + + it('should return null when addressableAreaName is null', () => { + vi.mocked(getAddressableAreaNameFrom).mockReturnValue(null) + + const result = getLoadLabwareLocationCombo( + MOCK_LOAD_COMMAND, + MOCK_LABWARE, + MOCK_MODULES + ) + + expect(result).toBeNull() + expect(getAddressableAreaNameFrom).toHaveBeenCalledWith(LOCATION_SEQUENCE) + }) + + it('should return location combo when all required information is available', () => { + const result = getLoadLabwareLocationCombo( + MOCK_LOAD_COMMAND, + MOCK_LABWARE, + MOCK_MODULES + ) + + expect(result).toEqual({ + labwareId: LABWARE_ID, + closestBeneathModuleId: MODULE_ID, + closestBeneathModuleModel: MODULE_MODEL, + definitionUri: LABWARE_URI, + locationSequence: LOCATION_SEQUENCE, + lwOffsetLocSeq: OFFSET_LOCATION_SEQUENCE, + addressableAreaName: ADDRESSABLE_AREA, + lwModOnlyStackupDetails: LW_MOD_STACKUP_DETAILS, + closestBeneathAdapterId: ADAPTER_ID, + }) + + expect(getLabwareDefURI).toHaveBeenCalledWith(LABWARE_DEFINITION) + expect(getAddressableAreaNameFrom).toHaveBeenCalledWith(LOCATION_SEQUENCE) + expect(getClosestBeneathModuleId).toHaveBeenCalledWith(LOCATION_SEQUENCE) + expect(getClosestBeneathModuleModel).toHaveBeenCalledWith( + MODULE_ID, + MOCK_MODULES + ) + expect(getLwOffsetLocSeqFromLocSeq).toHaveBeenCalledWith( + LOCATION_SEQUENCE, + MOCK_LABWARE, + MOCK_MODULES + ) + expect(getLwModStackupDetails).toHaveBeenCalledWith( + OFFSET_LOCATION_SEQUENCE, + LOCATION_SEQUENCE, + LABWARE_ID, + LABWARE_URI + ) + expect(getClosestBeneathAdapterId).toHaveBeenCalledWith(LOCATION_SEQUENCE) + }) + + it('should handle when moduleId is undefined', () => { + vi.mocked(getClosestBeneathModuleId).mockReturnValue(undefined) + vi.mocked(getClosestBeneathModuleModel).mockReturnValue(undefined) + + const result = getLoadLabwareLocationCombo( + MOCK_LOAD_COMMAND, + MOCK_LABWARE, + MOCK_MODULES + ) + + expect(result).not.toBeNull() + expect(result?.closestBeneathModuleId).toBeUndefined() + expect(result?.closestBeneathModuleModel).toBeUndefined() + expect(getClosestBeneathModuleModel).toHaveBeenCalledWith( + undefined, + MOCK_MODULES + ) + }) + + it('should handle when adapterId is undefined', () => { + vi.mocked(getClosestBeneathAdapterId).mockReturnValue(undefined) + + const result = getLoadLabwareLocationCombo( + MOCK_LOAD_COMMAND, + MOCK_LABWARE, + MOCK_MODULES + ) + + expect(result).not.toBeNull() + expect(result?.closestBeneathAdapterId).toBeUndefined() + }) +}) diff --git a/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useLPCLabwareInfo/getUniqueValidLwLocationInfoByAnalysis/getLPCUniqValidLabwareLocationInfo/__tests__/getMoveLabwareLocationCombo.test.ts b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useLPCLabwareInfo/getUniqueValidLwLocationInfoByAnalysis/getLPCUniqValidLabwareLocationInfo/__tests__/getMoveLabwareLocationCombo.test.ts new file mode 100644 index 000000000000..4b4611fb4e88 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useLPCLabwareInfo/getUniqueValidLwLocationInfoByAnalysis/getLPCUniqValidLabwareLocationInfo/__tests__/getMoveLabwareLocationCombo.test.ts @@ -0,0 +1,203 @@ +import { vi, it, describe, expect, beforeEach } from 'vitest' +import { getMoveLabwareLocationCombo } from '../getMoveLabwareLocationCombo' +import { + getClosestBeneathAdapterId, + getClosestBeneathModuleId, + getClosestBeneathModuleModel, + getLabwareDefURIFrom, + getLwModStackupDetails, + getAddressableAreaNameFrom, +} from '../helpers' +import { getLwOffsetLocSeqFromLocSeq } from '/app/local-resources/offsets' +import type { + MoveLabwareRunTimeCommand, + LoadedLabware, + LoadedModule, +} from '@opentrons/shared-data' +import type { AnalysisLwURIsByLwId } from '../getAllPossibleLwURIsInRun' + +vi.mock('../helpers') +vi.mock('/app/local-resources/offsets') + +describe('getMoveLabwareLocationCombo', () => { + const LABWARE_ID = 'labware-123' + const LABWARE_URI = 'opentrons/labware-1' + const ADAPTER_ID = 'adapter-456' + const MODULE_ID = 'module-789' + const MODULE_MODEL = 'thermocyclerModuleV2' + const ADDRESSABLE_AREA = 'B1' + + const LOCATION_SEQUENCE = [ + { kind: 'onAddressableArea', addressableAreaName: ADDRESSABLE_AREA }, + ] + + const OFFSET_LOCATION_SEQUENCE = [ + { kind: 'onAddressableArea', addressableAreaName: ADDRESSABLE_AREA }, + ] + + const LW_MOD_STACKUP_DETAILS = [ + { kind: 'labware', labwareUri: LABWARE_URI, id: LABWARE_ID }, + ] + + const MOCK_LW_URIS_BY_ID: AnalysisLwURIsByLwId = { + [LABWARE_ID]: LABWARE_URI, + } + + const MOCK_LABWARE: LoadedLabware[] = [ + { id: LABWARE_ID, definitionUri: LABWARE_URI }, + ] as LoadedLabware[] + + const MOCK_MODULES: LoadedModule[] = [ + { id: MODULE_ID, model: MODULE_MODEL }, + ] as LoadedModule[] + + const MOCK_MOVE_COMMAND: MoveLabwareRunTimeCommand = { + commandType: 'moveLabware', + params: { labwareId: LABWARE_ID }, + result: { + eventualDestinationLocationSequence: LOCATION_SEQUENCE, + }, + } as MoveLabwareRunTimeCommand + + beforeEach(() => { + vi.clearAllMocks() + + vi.mocked(getAddressableAreaNameFrom).mockReturnValue(ADDRESSABLE_AREA) + vi.mocked(getClosestBeneathModuleId).mockReturnValue(MODULE_ID) + vi.mocked(getClosestBeneathModuleModel).mockReturnValue(MODULE_MODEL) + vi.mocked(getClosestBeneathAdapterId).mockReturnValue(ADAPTER_ID) + vi.mocked(getLabwareDefURIFrom).mockReturnValue(LABWARE_URI) + vi.mocked(getLwOffsetLocSeqFromLocSeq).mockReturnValue( + OFFSET_LOCATION_SEQUENCE as any + ) + vi.mocked(getLwModStackupDetails).mockReturnValue( + LW_MOD_STACKUP_DETAILS as any + ) + }) + + it('should return null when result is null', () => { + const commandWithNullResult = { + ...MOCK_MOVE_COMMAND, + result: null, + } + + const result = getMoveLabwareLocationCombo( + commandWithNullResult as any, + MOCK_LW_URIS_BY_ID, + MOCK_LABWARE, + MOCK_MODULES + ) + + expect(result).toBeNull() + }) + + it('should return null when eventualDestinationLocationSequence is null', () => { + const commandWithNullLocSeq = { + ...MOCK_MOVE_COMMAND, + result: { + eventualDestinationLocationSequence: null, + }, + } + + const result = getMoveLabwareLocationCombo( + commandWithNullLocSeq as any, + MOCK_LW_URIS_BY_ID, + MOCK_LABWARE, + MOCK_MODULES + ) + + expect(result).toBeNull() + }) + + it('should return null when addressableAreaName is null', () => { + vi.mocked(getAddressableAreaNameFrom).mockReturnValue(null) + + const result = getMoveLabwareLocationCombo( + MOCK_MOVE_COMMAND, + MOCK_LW_URIS_BY_ID, + MOCK_LABWARE, + MOCK_MODULES + ) + + expect(result).toBeNull() + expect(getAddressableAreaNameFrom).toHaveBeenCalledWith(LOCATION_SEQUENCE) + }) + + it('should return location combo when all required information is available', () => { + const result = getMoveLabwareLocationCombo( + MOCK_MOVE_COMMAND, + MOCK_LW_URIS_BY_ID, + MOCK_LABWARE, + MOCK_MODULES + ) + + expect(result).toEqual({ + labwareId: LABWARE_ID, + closestBeneathModuleId: MODULE_ID, + closestBeneathModuleModel: MODULE_MODEL, + definitionUri: LABWARE_URI, + locationSequence: LOCATION_SEQUENCE, + lwOffsetLocSeq: OFFSET_LOCATION_SEQUENCE, + addressableAreaName: ADDRESSABLE_AREA, + lwModOnlyStackupDetails: LW_MOD_STACKUP_DETAILS, + closestBeneathAdapterId: ADAPTER_ID, + }) + + expect(getAddressableAreaNameFrom).toHaveBeenCalledWith(LOCATION_SEQUENCE) + expect(getClosestBeneathModuleId).toHaveBeenCalledWith(LOCATION_SEQUENCE) + expect(getClosestBeneathModuleModel).toHaveBeenCalledWith( + MODULE_ID, + MOCK_MODULES + ) + expect(getLabwareDefURIFrom).toHaveBeenCalledWith( + LABWARE_ID, + MOCK_LW_URIS_BY_ID + ) + expect(getLwOffsetLocSeqFromLocSeq).toHaveBeenCalledWith( + LOCATION_SEQUENCE, + MOCK_LABWARE, + MOCK_MODULES + ) + expect(getLwModStackupDetails).toHaveBeenCalledWith( + OFFSET_LOCATION_SEQUENCE, + LOCATION_SEQUENCE, + LABWARE_ID, + LABWARE_URI + ) + expect(getClosestBeneathAdapterId).toHaveBeenCalledWith(LOCATION_SEQUENCE) + }) + + it('should handle when moduleId is undefined', () => { + vi.mocked(getClosestBeneathModuleId).mockReturnValue(undefined) + vi.mocked(getClosestBeneathModuleModel).mockReturnValue(undefined) + + const result = getMoveLabwareLocationCombo( + MOCK_MOVE_COMMAND, + MOCK_LW_URIS_BY_ID, + MOCK_LABWARE, + MOCK_MODULES + ) + + expect(result).not.toBeNull() + expect(result?.closestBeneathModuleId).toBeUndefined() + expect(result?.closestBeneathModuleModel).toBeUndefined() + expect(getClosestBeneathModuleModel).toHaveBeenCalledWith( + undefined, + MOCK_MODULES + ) + }) + + it('should handle when adapterId is undefined', () => { + vi.mocked(getClosestBeneathAdapterId).mockReturnValue(undefined) + + const result = getMoveLabwareLocationCombo( + MOCK_MOVE_COMMAND, + MOCK_LW_URIS_BY_ID, + MOCK_LABWARE, + MOCK_MODULES + ) + + expect(result).not.toBeNull() + expect(result?.closestBeneathAdapterId).toBeUndefined() + }) +}) diff --git a/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useLPCLabwareInfo/getUniqueValidLwLocationInfoByAnalysis/getLPCUniqValidLabwareLocationInfo/__tests__/helpers.test.ts b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useLPCLabwareInfo/getUniqueValidLwLocationInfoByAnalysis/getLPCUniqValidLabwareLocationInfo/__tests__/helpers.test.ts new file mode 100644 index 000000000000..b1b6945f4645 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useLPCLabwareInfo/getUniqueValidLwLocationInfoByAnalysis/getLPCUniqValidLabwareLocationInfo/__tests__/helpers.test.ts @@ -0,0 +1,388 @@ +import { vi, it, describe, expect, beforeEach } from 'vitest' + +import { + getClosestBeneathModuleId, + getClosestBeneathModuleModel, + getClosestBeneathAdapterId, + getAddressableAreaNameFrom, + getLabwareDefURIFrom, + getLwModStackupDetails, +} from '../helpers' + +import type { + LabwareLocationSequence, + LoadedModule, +} from '@opentrons/shared-data' +import type { LabwareOffsetLocationSequence } from '@opentrons/api-client' +import type { AnalysisLwURIsByLwId } from '../getAllPossibleLwURIsInRun' + +describe('getClosestBeneathModuleId', () => { + it('should return undefined when no module in sequence', () => { + const locSeq: LabwareLocationSequence = [ + { kind: 'onAddressableArea', addressableAreaName: 'A1' }, + ] + + const result = getClosestBeneathModuleId(locSeq) + + expect(result).toBeUndefined() + }) + + it('should return the moduleId of the last module in sequence', () => { + const locSeq: LabwareLocationSequence = [ + { kind: 'onAddressableArea', addressableAreaName: 'A1' }, + { kind: 'onModule', moduleId: 'module-1' }, + { kind: 'onModule', moduleId: 'module-2' }, + ] + + const result = getClosestBeneathModuleId(locSeq) + + expect(result).toBe('module-2') + }) + + it('should handle complex sequences and return the last module', () => { + const locSeq: LabwareLocationSequence = [ + { kind: 'onAddressableArea', addressableAreaName: 'A1' }, + { kind: 'onModule', moduleId: 'module-1' }, + { kind: 'onLabware', labwareId: 'adapter-1', lidId: null }, + { kind: 'onModule', moduleId: 'module-2' }, + { kind: 'onLabware', labwareId: 'labware-1', lidId: null }, + ] + + const result = getClosestBeneathModuleId(locSeq) + + expect(result).toBe('module-2') + }) +}) + +describe('getClosestBeneathModuleModel', () => { + const MOCK_MODULES: LoadedModule[] = [ + { id: 'module-1', model: 'thermocyclerModuleV2' }, + { id: 'module-2', model: 'magneticModuleV2' }, + ] as LoadedModule[] + + it('should return undefined when moduleId is undefined', () => { + const result = getClosestBeneathModuleModel(undefined, MOCK_MODULES) + + expect(result).toBeUndefined() + }) + + it('should return undefined when module not found', () => { + const result = getClosestBeneathModuleModel( + 'non-existent-module', + MOCK_MODULES + ) + + expect(result).toBeUndefined() + }) + + it('should return the model for the given moduleId', () => { + const result = getClosestBeneathModuleModel('module-1', MOCK_MODULES) + + expect(result).toBe('thermocyclerModuleV2') + }) +}) + +describe('getClosestBeneathAdapterId', () => { + it('should return undefined when no labware in sequence', () => { + const locSeq: LabwareLocationSequence = [ + { kind: 'onAddressableArea', addressableAreaName: 'A1' }, + { kind: 'onModule', moduleId: 'module-1' }, + ] + + const result = getClosestBeneathAdapterId(locSeq) + + expect(result).toBeUndefined() + }) + + it('should return the labwareId of the last labware in sequence', () => { + const locSeq: LabwareLocationSequence = [ + { kind: 'onAddressableArea', addressableAreaName: 'A1' }, + { kind: 'onLabware', labwareId: 'adapter-1', lidId: null }, + { kind: 'onLabware', labwareId: 'adapter-2', lidId: null }, + ] + + const result = getClosestBeneathAdapterId(locSeq) + + expect(result).toBe('adapter-2') + }) + + it('should handle complex sequences and return the last labware', () => { + const locSeq: LabwareLocationSequence = [ + { kind: 'onAddressableArea', addressableAreaName: 'A1' }, + { kind: 'onModule', moduleId: 'module-1' }, + { kind: 'onLabware', labwareId: 'adapter-1', lidId: null }, + { kind: 'onModule', moduleId: 'module-2' }, + { kind: 'onLabware', labwareId: 'adapter-2', lidId: null }, + ] + + const result = getClosestBeneathAdapterId(locSeq) + + expect(result).toBe('adapter-2') + }) +}) + +describe('getAddressableAreaNameFrom', () => { + it('should return null when no addressable area in sequence', () => { + const locSeq: LabwareLocationSequence = [ + { kind: 'onModule', moduleId: 'module-1' }, + { kind: 'onLabware', labwareId: 'adapter-1', lidId: null }, + ] + + const result = getAddressableAreaNameFrom(locSeq) + + expect(result).toBeNull() + }) + + it('should return the name of the last addressable area in sequence', () => { + const locSeq: LabwareLocationSequence = [ + { kind: 'onAddressableArea', addressableAreaName: 'A1' }, + { kind: 'onAddressableArea', addressableAreaName: 'B1' }, + ] + + const result = getAddressableAreaNameFrom(locSeq) + + expect(result).toBe('B1') + }) + + it('should handle complex sequences and return the last addressable area', () => { + const locSeq: LabwareLocationSequence = [ + { kind: 'onAddressableArea', addressableAreaName: 'A1' }, + { kind: 'onModule', moduleId: 'module-1' }, + { kind: 'onLabware', labwareId: 'adapter-1', lidId: null }, + { kind: 'onAddressableArea', addressableAreaName: 'B1' }, + ] + + const result = getAddressableAreaNameFrom(locSeq) + + expect(result).toBe('B1') + }) +}) + +describe('getLabwareDefURIFrom', () => { + const MOCK_LW_URI_BY_ID: AnalysisLwURIsByLwId = { + 'labware-1': 'opentrons/labware-1', + 'labware-2': 'opentrons/labware-2', + } + + beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + it('should return URI for the given labware ID', () => { + const result = getLabwareDefURIFrom('labware-1', MOCK_LW_URI_BY_ID) + + expect(result).toBe('opentrons/labware-1') + }) + + it('should return empty string and log error when labware ID not found', () => { + const result = getLabwareDefURIFrom( + 'non-existent-labware', + MOCK_LW_URI_BY_ID + ) + + expect(result).toBe('') + expect(console.error).toHaveBeenCalledWith( + 'Expected to find matching labware def for id: non-existent-labware' + ) + }) + + it('should return empty string and log error when URI is empty', () => { + const mockLwUriByIdWithEmpty = { + ...MOCK_LW_URI_BY_ID, + 'empty-uri-labware': '', + } + + const result = getLabwareDefURIFrom( + 'empty-uri-labware', + mockLwUriByIdWithEmpty + ) + + expect(result).toBe('') + expect(console.error).toHaveBeenCalledWith( + 'Expected to find matching labware def for id: empty-uri-labware' + ) + }) +}) + +describe('getLwModStackupDetails', () => { + const TOP_LW_ID = 'labware-top' + const TOP_LW_URI = 'opentrons/labware-top' + const ADAPTER_ID = 'adapter-1' + const ADAPTER_URI = 'opentrons/adapter' + const MODULE_ID = 'module-1' + const MODULE_MODEL = 'thermocyclerModuleV2' + + beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + it('should return empty array when offset sequence and location sequence lengths do not match', () => { + const offsetLocSeq: LabwareOffsetLocationSequence = [ + { kind: 'onAddressableArea', addressableAreaName: 'A1' }, + { kind: 'onModule', moduleModel: MODULE_MODEL }, + ] + + const locSeq: LabwareLocationSequence = [ + { kind: 'onAddressableArea', addressableAreaName: 'A1' }, + ] + + const result = getLwModStackupDetails( + offsetLocSeq, + locSeq, + TOP_LW_ID, + TOP_LW_URI + ) + + expect(result).toEqual([]) + expect(console.error).toHaveBeenCalled() + }) + + it('should correctly map location sequence to stackup details with only modules and labware', () => { + const offsetLocSeq: LabwareOffsetLocationSequence = [ + { kind: 'onAddressableArea', addressableAreaName: 'A1' }, + { kind: 'onModule', moduleModel: MODULE_MODEL }, + { kind: 'onLabware', labwareUri: ADAPTER_URI }, + ] + + const locSeq: LabwareLocationSequence = [ + { kind: 'onAddressableArea', addressableAreaName: 'A1' }, + { kind: 'onModule', moduleId: MODULE_ID }, + { kind: 'onLabware', labwareId: ADAPTER_ID, lidId: null }, + ] + + const result = getLwModStackupDetails( + offsetLocSeq, + locSeq, + TOP_LW_ID, + TOP_LW_URI + ) + + expect(result).toHaveLength(3) + + expect(result).toContainEqual({ + kind: 'module', + moduleModel: MODULE_MODEL, + id: MODULE_ID, + }) + + expect(result).toContainEqual({ + kind: 'labware', + labwareUri: ADAPTER_URI, + id: ADAPTER_ID, + }) + + expect(result).toContainEqual({ + kind: 'labware', + labwareUri: TOP_LW_URI, + id: TOP_LW_ID, + }) + + expect(result[result.length - 1]).toEqual({ + kind: 'labware', + labwareUri: TOP_LW_URI, + id: TOP_LW_ID, + }) + }) + + it('should correctly handle complex sequences with multiple modules and labware', () => { + const offsetLocSeq: LabwareOffsetLocationSequence = [ + { kind: 'onAddressableArea', addressableAreaName: 'A1' }, + { kind: 'onModule', moduleModel: 'magneticModuleV2' }, + { kind: 'onLabware', labwareUri: 'adapter-1' }, + { kind: 'onModule', moduleModel: 'thermocyclerModuleV2' }, + { kind: 'onLabware', labwareUri: 'adapter-2' }, + ] + + const locSeq: LabwareLocationSequence = [ + { kind: 'onAddressableArea', addressableAreaName: 'A1' }, + { kind: 'onModule', moduleId: 'module-1' }, + { kind: 'onLabware', labwareId: 'adapter-1', lidId: null }, + { kind: 'onModule', moduleId: 'module-2' }, + { kind: 'onLabware', labwareId: 'adapter-2', lidId: null }, + ] + + const result = getLwModStackupDetails( + offsetLocSeq, + locSeq, + TOP_LW_ID, + TOP_LW_URI + ) + + expect(result).toHaveLength(5) + + expect(result).toContainEqual({ + kind: 'module', + moduleModel: 'magneticModuleV2', + id: 'module-1', + }) + + expect(result).toContainEqual({ + kind: 'labware', + labwareUri: 'adapter-1', + id: 'adapter-1', + }) + + expect(result).toContainEqual({ + kind: 'module', + moduleModel: 'thermocyclerModuleV2', + id: 'module-2', + }) + + expect(result).toContainEqual({ + kind: 'labware', + labwareUri: 'adapter-2', + id: 'adapter-2', + }) + + expect(result).toContainEqual({ + kind: 'labware', + labwareUri: TOP_LW_URI, + id: TOP_LW_ID, + }) + + expect(result[result.length - 1]).toEqual({ + kind: 'labware', + labwareUri: TOP_LW_URI, + id: TOP_LW_ID, + }) + }) + + it('should filter out non-module and non-labware components', () => { + const offsetLocSeq: LabwareOffsetLocationSequence = [ + { kind: 'onAddressableArea', addressableAreaName: 'A1' }, + { kind: 'onModule', moduleModel: MODULE_MODEL }, + ] + + const locSeq: LabwareLocationSequence = [ + { kind: 'onAddressableArea', addressableAreaName: 'A1' }, + { kind: 'onModule', moduleId: MODULE_ID }, + ] + + const result = getLwModStackupDetails( + offsetLocSeq, + locSeq, + TOP_LW_ID, + TOP_LW_URI + ) + + expect(result).toHaveLength(2) + + expect(result).toContainEqual({ + kind: 'module', + moduleModel: MODULE_MODEL, + id: MODULE_ID, + }) + + expect(result).toContainEqual({ + kind: 'labware', + labwareUri: TOP_LW_URI, + id: TOP_LW_ID, + }) + + expect(result[result.length - 1]).toEqual({ + kind: 'labware', + labwareUri: TOP_LW_URI, + id: TOP_LW_ID, + }) + }) +}) diff --git a/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useMonitorMaintenanceRunForDeletion.ts b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useMonitorMaintenanceRunForDeletion.ts new file mode 100644 index 000000000000..64208e2e4db6 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/LPCFlows/hooks/useMonitorMaintenanceRunForDeletion.ts @@ -0,0 +1,49 @@ +import { useEffect, useState } from 'react' + +import { useNotifyCurrentMaintenanceRun } from '/app/resources/maintenance_runs' + +const RUN_REFETCH_INTERVAL = 5000 + +// TODO(jh, 01-02-25): Monitor for deletion behavior exists in several other flows. We should consolidate it. + +// Closes the modal in case the run was deleted by the terminate activity modal on the ODD +export function useMonitorMaintenanceRunForDeletion({ + maintenanceRunId, + setMaintenanceRunId, +}: { + maintenanceRunId: string | null + setMaintenanceRunId: (id: string | null) => void +}): void { + const [ + monitorMaintenanceRunForDeletion, + setMonitorMaintenanceRunForDeletion, + ] = useState(false) + + // We should start checking for run deletion only after the maintenance run is created + // and the useCurrentRun poll has returned that created id + const { data: maintenanceRunData } = useNotifyCurrentMaintenanceRun({ + refetchInterval: RUN_REFETCH_INTERVAL, + enabled: maintenanceRunId != null, + }) + + useEffect(() => { + if (maintenanceRunId === null) { + setMonitorMaintenanceRunForDeletion(false) + } else if ( + maintenanceRunId !== null && + maintenanceRunData?.data.id === maintenanceRunId + ) { + setMonitorMaintenanceRunForDeletion(true) + } else if ( + maintenanceRunData?.data.id !== maintenanceRunId && + monitorMaintenanceRunForDeletion + ) { + setMaintenanceRunId(null) + } + }, [ + maintenanceRunData?.data.id, + maintenanceRunId, + monitorMaintenanceRunForDeletion, + setMaintenanceRunId, + ]) +} diff --git a/app/src/organisms/LabwarePositionCheck/LPCFlows/useLPCFlows.ts b/app/src/organisms/LabwarePositionCheck/LPCFlows/useLPCFlows.ts index 7e72793b5819..c38851084f17 100644 --- a/app/src/organisms/LabwarePositionCheck/LPCFlows/useLPCFlows.ts +++ b/app/src/organisms/LabwarePositionCheck/LPCFlows/useLPCFlows.ts @@ -12,7 +12,6 @@ import { useMostRecentCompletedAnalysis, useNotifyRunQuery, } from '/app/resources/runs' -import { useNotifyCurrentMaintenanceRun } from '/app/resources/maintenance_runs' import { useNotifyDeckConfigurationQuery } from '/app/resources/deck_configuration' import { getRelevantOffsets } from '/app/organisms/LabwarePositionCheck/LPCFlows/utils' import { @@ -22,6 +21,7 @@ import { useHandleClientAppliedOffsets, useOffsetConflictTimestamp, useUpdateLabwareInfo, + useMonitorMaintenanceRunForDeletion, } from './hooks' import type { RobotType } from '@opentrons/shared-data' @@ -217,49 +217,3 @@ export function useLPCFlows({ showLPC, } } - -const RUN_REFETCH_INTERVAL = 5000 - -// TODO(jh, 01-02-25): Monitor for deletion behavior exists in several other flows. We should consolidate it. - -// Closes the modal in case the run was deleted by the terminate activity modal on the ODD -function useMonitorMaintenanceRunForDeletion({ - maintenanceRunId, - setMaintenanceRunId, -}: { - maintenanceRunId: string | null - setMaintenanceRunId: (id: string | null) => void -}): void { - const [ - monitorMaintenanceRunForDeletion, - setMonitorMaintenanceRunForDeletion, - ] = useState(false) - - // We should start checking for run deletion only after the maintenance run is created - // and the useCurrentRun poll has returned that created id - const { data: maintenanceRunData } = useNotifyCurrentMaintenanceRun({ - refetchInterval: RUN_REFETCH_INTERVAL, - enabled: maintenanceRunId != null, - }) - - useEffect(() => { - if (maintenanceRunId === null) { - setMonitorMaintenanceRunForDeletion(false) - } else if ( - maintenanceRunId !== null && - maintenanceRunData?.data.id === maintenanceRunId - ) { - setMonitorMaintenanceRunForDeletion(true) - } else if ( - maintenanceRunData?.data.id !== maintenanceRunId && - monitorMaintenanceRunForDeletion - ) { - setMaintenanceRunId(null) - } - }, [ - maintenanceRunData?.data.id, - maintenanceRunId, - monitorMaintenanceRunForDeletion, - setMaintenanceRunId, - ]) -} diff --git a/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/__tests__/useHandleClose.test.ts b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/__tests__/useHandleClose.test.ts new file mode 100644 index 000000000000..0c661be2b261 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/__tests__/useHandleClose.test.ts @@ -0,0 +1,85 @@ +import { vi, it, describe, expect, beforeEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' + +import { useChainMaintenanceCommands } from '/app/resources/maintenance_runs' +import { useHandleClose } from '../useHandleClose' +import { retractSafelyAndHomeCommands } from '../commands' + +vi.mock('/app/resources/maintenance_runs') +vi.mock('../commands') + +describe('useHandleClose', () => { + const mockMaintenanceRunId = 'mock_maintenance_run' + const mockOnCloseClick = vi.fn() + const mockChainRunCommands = vi.fn(() => Promise.resolve()) + + const mockProps = { + maintenanceRunId: mockMaintenanceRunId, + onCloseClick: mockOnCloseClick, + } as any + + beforeEach(() => { + vi.clearAllMocks() + + vi.mocked(retractSafelyAndHomeCommands).mockImplementation(() => [ + { + commandType: 'retractSafelyAndHome', + params: {}, + } as any, + ]) + + vi.mocked(useChainMaintenanceCommands).mockReturnValue({ + chainRunCommands: mockChainRunCommands, + } as any) + }) + + it('should initialize with isExiting as false', () => { + const { result } = renderHook(() => useHandleClose(mockProps)) + + expect(result.current).toHaveProperty('isExiting', false) + expect(result.current).toHaveProperty('handleHomeAndClose') + expect(result.current).toHaveProperty('handleCloseNoHome') + }) + + it('should set isExiting to true and call chainRunCommands when handleHomeAndClose is called', async () => { + const { result } = renderHook(() => useHandleClose(mockProps)) + + await act(async () => { + await result.current.handleHomeAndClose() + }) + + expect(result.current.isExiting).toBe(true) + expect(mockChainRunCommands).toHaveBeenCalledWith( + mockMaintenanceRunId, + [{ commandType: 'retractSafelyAndHome', params: {} }], + true + ) + expect(mockOnCloseClick).toHaveBeenCalled() + }) + + it('should call onCloseClick even if chainRunCommands fails', async () => { + mockChainRunCommands.mockRejectedValueOnce(new Error('Command failed')) + + const { result } = renderHook(() => useHandleClose(mockProps)) + + await act(async () => { + await result.current.handleHomeAndClose() + }) + + expect(result.current.isExiting).toBe(true) + expect(mockChainRunCommands).toHaveBeenCalled() + expect(mockOnCloseClick).toHaveBeenCalled() + }) + + it('should set isExiting to true and call onCloseClick when handleCloseNoHome is called', async () => { + const { result } = renderHook(() => useHandleClose(mockProps)) + + await act(async () => { + result.current.handleCloseNoHome() + }) + + expect(result.current.isExiting).toBe(true) + expect(mockChainRunCommands).not.toHaveBeenCalled() + expect(mockOnCloseClick).toHaveBeenCalled() + }) +}) diff --git a/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/__tests__/useHandleConfirmLwFinalPosition.test.ts b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/__tests__/useHandleConfirmLwFinalPosition.test.ts new file mode 100644 index 000000000000..5e88db8b7cd7 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/__tests__/useHandleConfirmLwFinalPosition.test.ts @@ -0,0 +1,209 @@ +import { vi, it, describe, expect, beforeEach } from 'vitest' +import { renderHook } from '@testing-library/react' + +import { + moduleCleanupDuringLPCCommands, + moveLabwareOffDeckCommands, + retractPipetteAxesSequentiallyCommands, + savePositionCommands, +} from '../commands' +import { useHandleConfirmLwFinalPosition } from '../useHandleConfirmLwFinalPosition' + +vi.mock('../commands') + +describe('useHandleConfirmLwFinalPosition', () => { + const mockSetErrorMessage = vi.fn() + const mockChainLPCCommands = vi.fn() + + const mockProps = { + setErrorMessage: mockSetErrorMessage, + chainLPCCommands: mockChainLPCCommands, + runId: 'mock_run_id', + maintenanceRunId: 'mock_maintenance_run_id', + } as any + + const mockPipette = { + id: 'pipette-123', + mount: 'left', + pipetteName: 'mock_pipette_name', + } as any + + const mockOffsetLocationDetails = { + labwareId: 'labware-456', + closestBeneathModuleId: 'module-789', + } as any + + const mockPosition = { x: 10, y: 20, z: 30 } + + beforeEach(() => { + vi.clearAllMocks() + + vi.mocked(savePositionCommands).mockImplementation(pipetteId => [ + { + commandType: 'savePosition', + params: { pipetteId }, + } as any, + ]) + + vi.mocked(retractPipetteAxesSequentiallyCommands).mockImplementation( + pipette => [ + { + commandType: 'retractPipetteAxesSequentially', + params: { pipetteId: pipette?.id }, + } as any, + ] + ) + + vi.mocked(moduleCleanupDuringLPCCommands).mockImplementation( + offsetDetails => [ + { + commandType: 'moduleCleanupDuringLPC', + params: { + closestBeneathModuleId: offsetDetails.closestBeneathModuleId, + }, + } as any, + ] + ) + + vi.mocked(moveLabwareOffDeckCommands).mockImplementation(offsetDetails => [ + { + commandType: 'moveLabwareOffDeck', + params: { labwareId: offsetDetails.labwareId }, + } as any, + ]) + + mockChainLPCCommands.mockResolvedValue([ + { + data: { + commandType: 'savePosition', + result: { + position: mockPosition, + }, + }, + }, + ]) + }) + + it('should return handleConfirmLwFinalPosition function', () => { + const { result } = renderHook(() => + useHandleConfirmLwFinalPosition(mockProps) + ) + + expect(result.current).toHaveProperty('handleConfirmLwFinalPosition') + expect(typeof result.current.handleConfirmLwFinalPosition).toBe('function') + }) + + it('should chain commands in the correct order when handleConfirmLwFinalPosition is called', async () => { + const { result } = renderHook(() => + useHandleConfirmLwFinalPosition(mockProps) + ) + + const position = await result.current.handleConfirmLwFinalPosition( + mockOffsetLocationDetails, + mockPipette + ) + + expect(savePositionCommands).toHaveBeenCalledWith(mockPipette.id) + expect(retractPipetteAxesSequentiallyCommands).toHaveBeenCalledWith( + mockPipette + ) + expect(moduleCleanupDuringLPCCommands).toHaveBeenCalledWith( + mockOffsetLocationDetails + ) + expect(moveLabwareOffDeckCommands).toHaveBeenCalledWith( + mockOffsetLocationDetails + ) + + expect(mockChainLPCCommands).toHaveBeenCalledWith( + [ + { commandType: 'savePosition', params: { pipetteId: mockPipette.id } }, + { + commandType: 'retractPipetteAxesSequentially', + params: { pipetteId: mockPipette.id }, + }, + { + commandType: 'moduleCleanupDuringLPC', + params: { + closestBeneathModuleId: + mockOffsetLocationDetails.closestBeneathModuleId, + }, + }, + { + commandType: 'moveLabwareOffDeck', + params: { labwareId: mockOffsetLocationDetails.labwareId }, + }, + ], + false + ) + + expect(position).toEqual(mockPosition) + }) + + it('should reject with error when command response is incorrect', async () => { + mockChainLPCCommands.mockResolvedValueOnce([ + { + data: { + commandType: 'unknownCommand', + result: null, + }, + }, + ]) + + const { result } = renderHook(() => + useHandleConfirmLwFinalPosition(mockProps) + ) + + await expect( + result.current.handleConfirmLwFinalPosition( + mockOffsetLocationDetails, + mockPipette + ) + ).rejects.toThrow('CheckItem failed to save final position.') + + expect(mockSetErrorMessage).toHaveBeenCalledWith( + 'CheckItem failed to save final position.' + ) + }) + + it('should reject with error when result is null', async () => { + mockChainLPCCommands.mockResolvedValueOnce([ + { + data: { + commandType: 'savePosition', + result: null, + }, + }, + ]) + + const { result } = renderHook(() => + useHandleConfirmLwFinalPosition(mockProps) + ) + + await expect( + result.current.handleConfirmLwFinalPosition( + mockOffsetLocationDetails, + mockPipette + ) + ).rejects.toThrow('CheckItem failed to save final position.') + + expect(mockSetErrorMessage).toHaveBeenCalledWith( + 'CheckItem failed to save final position.' + ) + }) + + it('should pass the error from chainLPCCommands if it fails', async () => { + const mockError = new Error('Command chain failed') + mockChainLPCCommands.mockRejectedValueOnce(mockError) + + const { result } = renderHook(() => + useHandleConfirmLwFinalPosition(mockProps) + ) + + await expect( + result.current.handleConfirmLwFinalPosition( + mockOffsetLocationDetails, + mockPipette + ) + ).rejects.toThrow('Command chain failed') + }) +}) diff --git a/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/__tests__/useHandleConfirmLwModulePlacement.test.ts b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/__tests__/useHandleConfirmLwModulePlacement.test.ts new file mode 100644 index 000000000000..b72c3c90883c --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/__tests__/useHandleConfirmLwModulePlacement.test.ts @@ -0,0 +1,284 @@ +import { vi, it, describe, expect, beforeEach } from 'vitest' +import { renderHook } from '@testing-library/react' + +import { + moduleInitDuringLPCCommands, + moveToWellCommands, + savePositionCommands, +} from '../commands' +import { useHandleConfirmLwModulePlacement } from '../useHandleConfirmLwModulePlacement' + +vi.mock('../commands') + +describe('useHandleConfirmLwModulePlacement', () => { + const mockSetErrorMessage = vi.fn() + const mockChainLPCCommands = vi.fn() + const mockAnalysis = {} + + const mockProps = { + setErrorMessage: mockSetErrorMessage, + chainLPCCommands: mockChainLPCCommands, + analysis: mockAnalysis, + runId: 'mock_run_id', + maintenanceRunId: 'mock_maintenance_run_id', + } as any + + const mockPipetteId = 'pipette-123' + const mockInitialVectorOffset = { x: 1, y: 2, z: 3 } + const mockPosition = { x: 10, y: 20, z: 30 } + + const mockOffsetLocationDetails = { + labwareId: 'labware-456', + well: 'A1', + addressableAreaName: 'C2', + lwModOnlyStackupDetails: [ + { kind: 'module', id: 'module-789' }, + { kind: 'labware', id: 'labware-456' }, + { kind: 'labware', id: 'labware-457' }, + ], + } as any + + beforeEach(() => { + vi.clearAllMocks() + + vi.mocked(moduleInitDuringLPCCommands).mockImplementation(analysis => [ + { + commandType: 'moduleInitDuringLPC', + params: { analysis }, + } as any, + ]) + + vi.mocked(moveToWellCommands).mockImplementation( + (offsetDetails, pipetteId, vectorOffset) => [ + { + commandType: 'moveToWell', + params: { + pipetteId, + offsetLocationDetails: offsetDetails, + offset: vectorOffset, + }, + } as any, + ] + ) + + vi.mocked(savePositionCommands).mockImplementation(pipetteId => [ + { + commandType: 'savePosition', + params: { pipetteId }, + } as any, + ]) + + mockChainLPCCommands.mockResolvedValue([ + { data: { commandType: 'moveLabware' } }, + { data: { commandType: 'moduleInitDuringLPC' } }, + { data: { commandType: 'moveToWell' } }, + { + data: { + commandType: 'savePosition', + result: { + position: mockPosition, + }, + }, + }, + ]) + }) + + it('should return handleConfirmLwModulePlacement function', () => { + const { result } = renderHook(() => + useHandleConfirmLwModulePlacement(mockProps) + ) + + expect(result.current).toHaveProperty('handleConfirmLwModulePlacement') + expect(typeof result.current.handleConfirmLwModulePlacement).toBe( + 'function' + ) + }) + + it('should chain commands in the correct order when handleConfirmLwModulePlacement is called', async () => { + const { result } = renderHook(() => + useHandleConfirmLwModulePlacement(mockProps) + ) + + const position = await result.current.handleConfirmLwModulePlacement( + mockOffsetLocationDetails, + mockPipetteId, + mockInitialVectorOffset + ) + + expect(moduleInitDuringLPCCommands).toHaveBeenCalledWith(mockAnalysis) + expect(moveToWellCommands).toHaveBeenCalledWith( + mockOffsetLocationDetails, + mockPipetteId, + mockInitialVectorOffset + ) + expect(savePositionCommands).toHaveBeenCalledWith(mockPipetteId) + + expect(mockChainLPCCommands).toHaveBeenCalled() + const commandsArg = mockChainLPCCommands.mock.calls[0][0] + + const moveLabwareCommands = commandsArg.filter( + (cmd: any) => cmd.commandType === 'moveLabware' + ) + expect(moveLabwareCommands.length).toBe(2) + + expect(moveLabwareCommands[0]).toEqual({ + commandType: 'moveLabware', + params: { + labwareId: 'labware-456', + newLocation: { moduleId: 'module-789' }, + strategy: 'manualMoveWithoutPause', + }, + }) + + expect(moveLabwareCommands[1]).toEqual({ + commandType: 'moveLabware', + params: { + labwareId: 'labware-457', + newLocation: { labwareId: 'labware-456' }, + strategy: 'manualMoveWithoutPause', + }, + }) + + expect(position).toEqual(mockPosition) + }) + + it('should handle labware placement on deck when no module or labware beneath', async () => { + const mockOffsetLocationDetailsNoPriorItem = { + ...mockOffsetLocationDetails, + lwModOnlyStackupDetails: [{ kind: 'labware', id: 'standalone-labware' }], + addressableAreaName: 'C2', + } as any + + const { result } = renderHook(() => + useHandleConfirmLwModulePlacement(mockProps) + ) + + await result.current.handleConfirmLwModulePlacement( + mockOffsetLocationDetailsNoPriorItem, + mockPipetteId + ) + + const commandsArg = mockChainLPCCommands.mock.calls[0][0] + const moveLabwareCommands = commandsArg.filter( + (cmd: any) => cmd.commandType === 'moveLabware' + ) + + expect(moveLabwareCommands[0]).toEqual({ + commandType: 'moveLabware', + params: { + labwareId: 'standalone-labware', + newLocation: { addressableAreaName: 'C2' }, + strategy: 'manualMoveWithoutPause', + }, + }) + }) + + it('should reject with error when final command response is incorrect', async () => { + mockChainLPCCommands.mockResolvedValueOnce([ + { data: { commandType: 'moveLabware' } }, + { data: { commandType: 'moduleInitDuringLPC' } }, + { data: { commandType: 'moveToWell' } }, + { + data: { + commandType: 'unknownCommand', + result: null, + }, + }, + ]) + + const { result } = renderHook(() => + useHandleConfirmLwModulePlacement(mockProps) + ) + + await expect( + result.current.handleConfirmLwModulePlacement( + mockOffsetLocationDetails, + mockPipetteId + ) + ).rejects.toThrow( + 'CheckItem failed to save position for initial placement.' + ) + + expect(mockSetErrorMessage).toHaveBeenCalledWith( + 'CheckItem failed to save position for initial placement.' + ) + }) + + it('should reject with error when result is null', async () => { + mockChainLPCCommands.mockResolvedValueOnce([ + { data: { commandType: 'moveLabware' } }, + { data: { commandType: 'moduleInitDuringLPC' } }, + { data: { commandType: 'moveToWell' } }, + { + data: { + commandType: 'savePosition', + result: null, + }, + }, + ]) + + const { result } = renderHook(() => + useHandleConfirmLwModulePlacement(mockProps) + ) + + await expect( + result.current.handleConfirmLwModulePlacement( + mockOffsetLocationDetails, + mockPipetteId + ) + ).rejects.toThrow( + 'CheckItem failed to save position for initial placement.' + ) + + expect(mockSetErrorMessage).toHaveBeenCalledWith( + 'CheckItem failed to save position for initial placement.' + ) + }) + + it('should pass the error from chainLPCCommands if it fails', async () => { + const mockError = new Error('Command chain failed') + mockChainLPCCommands.mockRejectedValueOnce(mockError) + + const { result } = renderHook(() => + useHandleConfirmLwModulePlacement(mockProps) + ) + + await expect( + result.current.handleConfirmLwModulePlacement( + mockOffsetLocationDetails, + mockPipetteId + ) + ).rejects.toThrow('Command chain failed') + }) + + it('should not include module components in moveLabware commands', async () => { + const mockOffsetLocationDetailsWithMultipleModules = { + ...mockOffsetLocationDetails, + lwModOnlyStackupDetails: [ + { kind: 'module', id: 'module-789' }, + { kind: 'module', id: 'module-790' }, + { kind: 'labware', id: 'labware-456' }, + ], + } as any + + const { result } = renderHook(() => + useHandleConfirmLwModulePlacement(mockProps) + ) + + await result.current.handleConfirmLwModulePlacement( + mockOffsetLocationDetailsWithMultipleModules, + mockPipetteId + ) + + const commandsArg = mockChainLPCCommands.mock.calls[0][0] + const moveLabwareCommands = commandsArg.filter( + (cmd: any) => cmd.commandType === 'moveLabware' + ) + + expect(moveLabwareCommands.length).toBe(1) + expect(moveLabwareCommands[0].params.labwareId).toBe('labware-456') + expect(moveLabwareCommands[0].params.newLocation).toEqual({ + moduleId: 'module-790', + }) + }) +}) diff --git a/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/__tests__/useHandleJog.test.ts b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/__tests__/useHandleJog.test.ts new file mode 100644 index 000000000000..fd69ddb2219a --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/__tests__/useHandleJog.test.ts @@ -0,0 +1,276 @@ +import { vi, it, describe, expect, beforeEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' + +import { useCreateMaintenanceCommandMutation } from '@opentrons/react-api-client' +import { selectActivePipette } from '/app/redux/protocol-runs' +import { useHandleJog } from '../useHandleJog' +import { useSelector } from 'react-redux' +import { moveRelativeCommand, moveToWellCommands } from '../commands' + +vi.mock('react-redux') +vi.mock('/app/redux/protocol-runs') +vi.mock('@opentrons/react-api-client') +vi.mock('../commands') + +describe('useHandleJog', () => { + vi.useFakeTimers() + + const mockPipetteId = 'mock_pipette' + const mockRunId = 'mock_run' + const mockMaintenanceRunId = 'mock_maintenance_run' + const mockSetErrorMessage = vi.fn() + const mockChainLPCCommands = vi.fn(() => Promise.resolve()) + const mockCreateSilentCommand = vi.fn() + + const mockCommandResult = { + data: { + result: { + position: { x: 10, y: 20, z: 30 }, + }, + }, + } + const mockProps = { + runId: mockRunId, + maintenanceRunId: mockMaintenanceRunId, + setErrorMessage: mockSetErrorMessage, + chainLPCCommands: mockChainLPCCommands, + } as any + const mockPipette = { + id: mockPipetteId, + pipetteName: 'p1000_single', + } as any + + beforeEach(() => { + vi.clearAllMocks() + + vi.mocked(moveRelativeCommand).mockImplementation( + ({ pipetteId, axis, dir, step }) => + ({ + commandType: 'moveRelative', + params: { pipetteId, axis, dir, step }, + } as any) + ) + vi.mocked(moveToWellCommands).mockImplementation( + (offsetLocationDetails, pipetteId, offset) => [ + { + commandType: 'moveToWell', + params: { pipetteId, offsetLocationDetails, offset } as any, + }, + ] + ) + vi.mocked(selectActivePipette).mockReturnValue(() => mockPipette) + vi.mocked(useSelector).mockImplementation(fn => fn(fn)) + vi.mocked(useCreateMaintenanceCommandMutation).mockReturnValue({ + createMaintenanceCommand: mockCreateSilentCommand, + } as any) + mockCreateSilentCommand.mockResolvedValue(mockCommandResult) + }) + + it('should initialize with empty queue', () => { + const { result } = renderHook(() => useHandleJog(mockProps)) + + expect(result.current).toHaveProperty('handleJog') + expect(result.current).toHaveProperty('resetJog') + }) + + it('should issue a jog command when handleJog is called', async () => { + const { result } = renderHook(() => useHandleJog(mockProps)) + const mockOnSuccess = vi.fn() + + act(() => { + result.current.handleJog('x', 1, 1, mockOnSuccess) + }) + + vi.runAllTimers() + + expect(mockCreateSilentCommand).toHaveBeenCalledWith({ + maintenanceRunId: mockMaintenanceRunId, + command: { + commandType: 'moveRelative', + params: { pipetteId: mockPipetteId, axis: 'x', dir: 1, step: 1 }, + }, + waitUntilComplete: true, + timeout: 10000, + }) + + await vi.runAllTimersAsync() + + expect(mockOnSuccess).toHaveBeenCalledWith({ x: 10, y: 20, z: 30 }) + }) + + it('should queue multiple jog commands and process them sequentially', async () => { + const { result } = renderHook(() => useHandleJog(mockProps)) + + mockCreateSilentCommand.mockClear() + + act(() => { + result.current.handleJog('x', 1, 1) + }) + + expect(mockCreateSilentCommand).toHaveBeenCalledTimes(1) + expect(mockCreateSilentCommand.mock.calls[0][0].command.params).toEqual({ + pipetteId: mockPipetteId, + axis: 'x', + dir: 1, + step: 1, + }) + + mockCreateSilentCommand.mockClear() + + act(() => { + result.current.handleJog('y', -1, 1) + }) + + await vi.runAllTimersAsync() + + expect(mockCreateSilentCommand).toHaveBeenCalledTimes(1) + expect(mockCreateSilentCommand.mock.calls[0][0].command.params).toEqual({ + pipetteId: mockPipetteId, + axis: 'y', + dir: -1, + step: 1, + }) + + mockCreateSilentCommand.mockClear() + + act(() => { + result.current.handleJog('z', 1, 0.1) + }) + + await vi.runAllTimersAsync() + + expect(mockCreateSilentCommand).toHaveBeenCalledTimes(1) + expect(mockCreateSilentCommand.mock.calls[0][0].command.params).toEqual({ + pipetteId: mockPipetteId, + axis: 'z', + dir: 1, + step: 0.1, + }) + }) + + it('should limit the queue to MAX_QUEUED_JOGS (3) commands plus the command that runs immediately', async () => { + const { result } = renderHook(() => useHandleJog(mockProps)) + + mockCreateSilentCommand.mockClear() + + await act(async () => { + result.current.handleJog('x', 1, 1) + result.current.handleJog('y', -1, 1) + result.current.handleJog('z', 1, 1) + result.current.handleJog('x', -1, 1) + result.current.handleJog('x', -1, 1) + result.current.handleJog('x', 1, 1) + result.current.handleJog('x', -1, 1) + }) + + expect(mockCreateSilentCommand).toHaveBeenCalledTimes(1) + + await vi.runAllTimersAsync() + + expect(mockCreateSilentCommand).toHaveBeenCalledTimes(4) + }) + + it('should set error message when command fails', async () => { + const mockError = new Error('Command failed') + mockCreateSilentCommand.mockRejectedValueOnce(mockError) + + const { result } = renderHook(() => useHandleJog(mockProps)) + + act(() => { + result.current.handleJog('x', 1, 1) + }) + + await vi.runAllTimersAsync() + + expect(mockSetErrorMessage).toHaveBeenCalledWith( + 'Error issuing jog command: Command failed' + ) + }) + + it('should set error message when pipette is not found', async () => { + vi.mocked(selectActivePipette).mockReturnValueOnce(() => null) + + const { result } = renderHook(() => useHandleJog(mockProps)) + + act(() => { + result.current.handleJog('x', 1, 1) + }) + + await vi.runAllTimersAsync() + + expect(mockSetErrorMessage).toHaveBeenCalledWith( + 'Could not find pipette to jog with id: ' + ) + }) + + it('should clear the queue when resetJog is called', async () => { + const { result } = renderHook(() => useHandleJog(mockProps)) + const mockOffsetLocationDetails = { labwareId: 'lw-123', well: 'A1' } as any + const mockOffset = { x: 1, y: 2, z: 3 } + + act(() => { + result.current.handleJog('x', 1, 1) + result.current.handleJog('y', -1, 1) + }) + + await act(async () => { + await result.current.resetJog( + mockOffsetLocationDetails, + mockPipetteId, + mockOffset + ) + }) + + expect(mockCreateSilentCommand).toHaveBeenCalledTimes(1) + + expect(mockChainLPCCommands).toHaveBeenCalledWith( + [ + { + commandType: 'moveToWell', + params: { + pipetteId: mockPipetteId, + offsetLocationDetails: mockOffsetLocationDetails, + offset: mockOffset, + }, + }, + ], + false + ) + + act(() => { + result.current.handleJog('z', 1, 0.1) + }) + + await vi.runAllTimersAsync() + + expect(mockCreateSilentCommand).toHaveBeenCalledTimes(2) + }) + + it('should debounce rapid jog requests', async () => { + const { result } = renderHook(() => useHandleJog(mockProps)) + + act(() => { + result.current.handleJog('x', 1, 1) + result.current.handleJog('x', 1, 1) + result.current.handleJog('x', 1, 1) + }) + + expect(mockCreateSilentCommand).toHaveBeenCalledTimes(1) + + await vi.runAllTimersAsync() + + expect(mockCreateSilentCommand).toHaveBeenCalledTimes(3) + }) + + it('should clean up debounce on unmount', () => { + const { unmount } = renderHook(() => useHandleJog(mockProps)) + + unmount() + + act(() => { + vi.runAllTimers() + }) + + expect(mockCreateSilentCommand).not.toHaveBeenCalled() + }) +}) diff --git a/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/__tests__/useHandlePrepModules.test.ts b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/__tests__/useHandlePrepModules.test.ts new file mode 100644 index 000000000000..f82811505e5a --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/__tests__/useHandlePrepModules.test.ts @@ -0,0 +1,93 @@ +import { vi, it, describe, expect, beforeEach } from 'vitest' +import { renderHook } from '@testing-library/react' + +import { modulePrepCommands } from '../commands' +import { useHandlePrepModules } from '../useHandlePrepModules' + +vi.mock('../commands') + +describe('useHandlePrepModules', () => { + const mockChainLPCCommands = vi.fn() + + const mockProps = { + chainLPCCommands: mockChainLPCCommands, + runId: 'mock_run_id', + maintenanceRunId: 'mock_maintenance_run_id', + } as any + + const mockOffsetLocationDetails = { + labwareId: 'labware-456', + closestBeneathModuleId: 'module-789', + } as any + + const mockCommandData = [ + { data: { commandType: 'mockPrepModule', result: { success: true } } }, + ] + + beforeEach(() => { + vi.clearAllMocks() + + vi.mocked(modulePrepCommands).mockImplementation(offsetDetails => [ + { + commandType: 'mockPrepModule', + params: { moduleId: offsetDetails.closestBeneathModuleId }, + } as any, + ]) + + mockChainLPCCommands.mockResolvedValue(mockCommandData) + }) + + it('should return handleCheckItemsPrepModules function', () => { + const { result } = renderHook(() => useHandlePrepModules(mockProps)) + + expect(result.current).toHaveProperty('handleCheckItemsPrepModules') + expect(typeof result.current.handleCheckItemsPrepModules).toBe('function') + }) + + it('should call chainLPCCommands with prep commands when handleCheckItemsPrepModules is called', async () => { + const { result } = renderHook(() => useHandlePrepModules(mockProps)) + + const commandData = await result.current.handleCheckItemsPrepModules( + mockOffsetLocationDetails + ) + + expect(modulePrepCommands).toHaveBeenCalledWith(mockOffsetLocationDetails) + expect(mockChainLPCCommands).toHaveBeenCalledWith( + [ + { + commandType: 'mockPrepModule', + params: { + moduleId: mockOffsetLocationDetails.closestBeneathModuleId, + }, + }, + ], + false + ) + expect(commandData).toEqual(mockCommandData) + }) + + it('should not call chainLPCCommands when there are no prep commands', async () => { + vi.mocked(modulePrepCommands).mockReturnValueOnce([]) + + const { result } = renderHook(() => useHandlePrepModules(mockProps)) + + const commandData = await result.current.handleCheckItemsPrepModules( + mockOffsetLocationDetails + ) + + expect(modulePrepCommands).toHaveBeenCalledWith(mockOffsetLocationDetails) + expect(mockChainLPCCommands).not.toHaveBeenCalled() + expect(commandData).toEqual([]) + }) + + it('should pass the error from chainLPCCommands if it fails', async () => { + const mockError = new Error('Command chain failed') + mockChainLPCCommands.mockRejectedValueOnce(mockError) + + const { result } = renderHook(() => useHandlePrepModules(mockProps)) + + await expect( + result.current.handleCheckItemsPrepModules(mockOffsetLocationDetails) + ).rejects.toThrow('Command chain failed') + }) +}) diff --git a/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/__tests__/useHandleProbe.test.ts b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/__tests__/useHandleProbe.test.ts new file mode 100644 index 000000000000..3c2ed0ba68ba --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/__tests__/useHandleProbe.test.ts @@ -0,0 +1,204 @@ +import { vi, it, describe, expect, beforeEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useSelector } from 'react-redux' + +import { + retractPipetteAxesSequentiallyCommands, + verifyProbeAttachmentAndHomeCommands, +} from '../commands' +import { useHandleProbeCommands } from '../useHandleProbeCommands' +import { LPC_STEP, selectCurrentStep } from '/app/redux/protocol-runs' + +import type { LPCStep } from '/app/redux/protocol-runs' + +vi.mock('react-redux') +vi.mock('../commands') +vi.mock('/app/redux/protocol-runs') + +describe('useHandleProbeCommands', () => { + const mockChainLPCCommands = vi.fn() + const mockRunId = 'mock_run_id' + let mockCurrentStep: LPCStep = LPC_STEP.ATTACH_PROBE + + const mockProps = { + chainLPCCommands: mockChainLPCCommands, + runId: mockRunId, + maintenanceRunId: 'mock_maintenance_run_id', + } as any + + const mockPipette = { + id: 'pipette-123', + mount: 'left', + pipetteName: 'mock_pipette_name', + } as any + + const mockOnSuccess = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + mockCurrentStep = LPC_STEP.ATTACH_PROBE + + vi.mocked(useSelector).mockImplementation(selector => { + if (selector === selectCurrentStep(mockRunId)) { + return mockCurrentStep + } + return null + }) + + vi.mocked(verifyProbeAttachmentAndHomeCommands).mockImplementation( + pipette => [ + { + commandType: 'verifyProbeAttachmentAndHome', + params: { pipetteId: pipette?.id }, + } as any, + ] + ) + + vi.mocked(retractPipetteAxesSequentiallyCommands).mockImplementation( + pipette => [ + { + commandType: 'retractPipetteAxesSequentially', + params: { pipetteId: pipette?.id }, + } as any, + ] + ) + + mockChainLPCCommands.mockResolvedValue([]) + }) + + it('should return expected functions and initial state', () => { + const { result } = renderHook(() => useHandleProbeCommands(mockProps)) + + expect(result.current).toHaveProperty('handleProbeAttachment') + expect(result.current).toHaveProperty('handleProbeDetachment') + expect(result.current).toHaveProperty('unableToDetect', false) + }) + + it('should call chainLPCCommands with correct commands when handleProbeAttachment is called', async () => { + const { result } = renderHook(() => useHandleProbeCommands(mockProps)) + + await act(async () => { + await result.current.handleProbeAttachment(mockPipette) + }) + + expect(verifyProbeAttachmentAndHomeCommands).toHaveBeenCalledWith( + mockPipette + ) + expect(mockChainLPCCommands).toHaveBeenCalledWith( + [ + { + commandType: 'verifyProbeAttachmentAndHome', + params: { pipetteId: mockPipette.id }, + }, + ], + false, + true + ) + expect(result.current.unableToDetect).toBe(false) + }) + + it('should set unableToDetect to true when probe attachment verification fails', async () => { + mockChainLPCCommands.mockRejectedValueOnce(new Error('Verification failed')) + + const { result } = renderHook(() => useHandleProbeCommands(mockProps)) + + await act(async () => { + await expect( + result.current.handleProbeAttachment(mockPipette) + ).rejects.toThrow('Unable to detect probe.') + }) + + expect(result.current.unableToDetect).toBe(true) + }) + + it('should call chainLPCCommands with correct commands when handleProbeDetachment is called', async () => { + const { result } = renderHook(() => useHandleProbeCommands(mockProps)) + + await act(async () => { + await result.current.handleProbeDetachment(mockPipette, mockOnSuccess) + }) + + expect(retractPipetteAxesSequentiallyCommands).toHaveBeenCalledWith( + mockPipette + ) + expect(mockChainLPCCommands).toHaveBeenCalledWith( + [ + { + commandType: 'retractPipetteAxesSequentially', + params: { pipetteId: mockPipette.id }, + }, + ], + false + ) + expect(mockOnSuccess).toHaveBeenCalled() + }) + + it('should reset unableToDetect when step changes from ATTACH_PROBE', async () => { + mockChainLPCCommands.mockRejectedValueOnce(new Error('Verification failed')) + + const { result, rerender } = renderHook(() => + useHandleProbeCommands(mockProps) + ) + + await act(async () => { + await expect( + result.current.handleProbeAttachment(mockPipette) + ).rejects.toThrow('Unable to detect probe.') + }) + + expect(result.current.unableToDetect).toBe(true) + + mockCurrentStep = LPC_STEP.BEFORE_BEGINNING + + act(() => { + rerender() + }) + + expect(result.current.unableToDetect).toBe(false) + }) + + it('should not reset unableToDetect when staying on ATTACH_PROBE step', async () => { + mockChainLPCCommands.mockRejectedValueOnce(new Error('Verification failed')) + + const { result, rerender } = renderHook(() => + useHandleProbeCommands(mockProps) + ) + + await act(async () => { + await expect( + result.current.handleProbeAttachment(mockPipette) + ).rejects.toThrow('Unable to detect probe.') + }) + + expect(result.current.unableToDetect).toBe(true) + + act(() => { + rerender() + }) + + expect(result.current.unableToDetect).toBe(true) + }) + + it('should handle null pipette for attachment commands', async () => { + const { result } = renderHook(() => useHandleProbeCommands(mockProps)) + + await act(async () => { + await result.current.handleProbeAttachment(null) + }) + + expect(verifyProbeAttachmentAndHomeCommands).toHaveBeenCalledWith(null) + expect(mockChainLPCCommands).toHaveBeenCalled() + }) + + it('should handle null pipette for detachment commands', async () => { + const { result } = renderHook(() => useHandleProbeCommands(mockProps)) + + await act(async () => { + await result.current.handleProbeDetachment(null, mockOnSuccess) + }) + + expect(retractPipetteAxesSequentiallyCommands).toHaveBeenCalledWith(null) + expect(mockChainLPCCommands).toHaveBeenCalled() + expect(mockOnSuccess).toHaveBeenCalled() + }) +}) diff --git a/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/__tests__/useHandleResetLwModulesOnDeck.test.ts b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/__tests__/useHandleResetLwModulesOnDeck.test.ts new file mode 100644 index 000000000000..71c19aea4031 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/__tests__/useHandleResetLwModulesOnDeck.test.ts @@ -0,0 +1,121 @@ +import { vi, it, describe, expect, beforeEach } from 'vitest' +import { renderHook } from '@testing-library/react' + +import { + fullHomeCommands, + modulePrepCommands, + moveLabwareOffDeckCommands, +} from '../commands' +import { useHandleResetLwModulesOnDeck } from '../useHandleResetLwModulesOnDeck' + +vi.mock('../commands') + +describe('useHandleResetLwModulesOnDeck', () => { + const mockChainLPCCommands = vi.fn() + + const mockProps = { + chainLPCCommands: mockChainLPCCommands, + runId: 'mock_run_id', + maintenanceRunId: 'mock_maintenance_run_id', + } as any + + const mockOffsetLocationDetails = { + labwareId: 'labware-456', + closestBeneathModuleId: 'module-789', + } as any + + beforeEach(() => { + vi.clearAllMocks() + + vi.mocked(modulePrepCommands).mockImplementation(offsetDetails => [ + { + commandType: 'modulePrepCommand', + params: { moduleId: offsetDetails.closestBeneathModuleId }, + } as any, + ]) + + vi.mocked(fullHomeCommands).mockImplementation(() => [ + { + commandType: 'home', + params: {}, + } as any, + ]) + + vi.mocked(moveLabwareOffDeckCommands).mockImplementation(offsetDetails => [ + { + commandType: 'moveLabwareOffDeck', + params: { labwareId: offsetDetails.labwareId }, + } as any, + ]) + + mockChainLPCCommands.mockResolvedValue([]) + }) + + it('should return handleResetLwModulesOnDeck function', () => { + const { result } = renderHook(() => + useHandleResetLwModulesOnDeck(mockProps) + ) + + expect(result.current).toHaveProperty('handleResetLwModulesOnDeck') + expect(typeof result.current.handleResetLwModulesOnDeck).toBe('function') + }) + + it('should chain commands in the correct order when handleResetLwModulesOnDeck is called', async () => { + const { result } = renderHook(() => + useHandleResetLwModulesOnDeck(mockProps) + ) + + await result.current.handleResetLwModulesOnDeck(mockOffsetLocationDetails) + + expect(modulePrepCommands).toHaveBeenCalledWith(mockOffsetLocationDetails) + expect(fullHomeCommands).toHaveBeenCalled() + expect(moveLabwareOffDeckCommands).toHaveBeenCalledWith( + mockOffsetLocationDetails + ) + + expect(mockChainLPCCommands).toHaveBeenCalledWith( + [ + { + commandType: 'modulePrepCommand', + params: { + moduleId: mockOffsetLocationDetails.closestBeneathModuleId, + }, + }, + { + commandType: 'home', + params: {}, + }, + { + commandType: 'moveLabwareOffDeck', + params: { labwareId: mockOffsetLocationDetails.labwareId }, + }, + ], + false + ) + }) + + it('should resolve with void when chainLPCCommands succeeds', async () => { + const { result } = renderHook(() => + useHandleResetLwModulesOnDeck(mockProps) + ) + + const returnValue = await result.current.handleResetLwModulesOnDeck( + mockOffsetLocationDetails + ) + + expect(returnValue).toBeUndefined() + }) + + it('should pass the error from chainLPCCommands if it fails', async () => { + const mockError = new Error('Command chain failed') + mockChainLPCCommands.mockRejectedValueOnce(mockError) + + const { result } = renderHook(() => + useHandleResetLwModulesOnDeck(mockProps) + ) + + await expect( + result.current.handleResetLwModulesOnDeck(mockOffsetLocationDetails) + ).rejects.toThrow('Command chain failed') + }) +}) diff --git a/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/__tests__/useHandleStartLPC.test.ts b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/__tests__/useHandleStartLPC.test.ts new file mode 100644 index 000000000000..af175a2c4ef6 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/__tests__/useHandleStartLPC.test.ts @@ -0,0 +1,258 @@ +import { vi, it, describe, expect, beforeEach } from 'vitest' +import { renderHook } from '@testing-library/react' + +import { + fullHomeCommands, + moduleInitBeforeAnyLPCCommands, + moveToMaintenancePosition, +} from '../commands' +import { mapFlexStackerLabware } from '../utils' +import { useHandleStartLPC } from '../useHandleStartLPC' + +vi.mock('../commands') +vi.mock('../utils') + +describe('useHandleStartLPC', () => { + const mockChainLPCCommands = vi.fn() + const mockOnSuccess = vi.fn() + + const mockPipette = { + id: 'pipette-123', + mount: 'left', + pipetteName: 'mock_pipette_name', + } as any + + const mockAnalysis = { + commands: [ + { + commandType: 'loadPipette', + params: { mount: 'left', pipetteName: 'mock_pipette_name' }, + result: { pipetteId: 'pipette-123' }, + }, + { + commandType: 'loadLabware', + params: { loadName: 'some_labware' }, + result: { labwareId: 'labware-456' }, + }, + { + commandType: 'loadModule', + params: { moduleType: 'magdeck' }, + result: { moduleId: 'module-789' }, + }, + { + commandType: 'otherCommand', + params: {}, + }, + ], + } as any + + const mockProps = { + chainLPCCommands: mockChainLPCCommands, + analysis: mockAnalysis, + runId: 'mock_run_id', + maintenanceRunId: 'mock_maintenance_run_id', + } as any + + const mockStackerLabware = [ + { + loadName: 'stacker_labware', + labwareId: 'stacker-labware-001', + }, + ] as any + + beforeEach(() => { + vi.clearAllMocks() + + vi.mocked(fullHomeCommands).mockImplementation(() => [ + { + commandType: 'fullHome', + params: {}, + } as any, + ]) + + vi.mocked(moduleInitBeforeAnyLPCCommands).mockImplementation(analysis => [ + { + commandType: 'moduleInitBeforeAnyLPC', + params: { analysis }, + } as any, + ]) + + vi.mocked(moveToMaintenancePosition).mockImplementation(pipette => [ + { + commandType: 'moveToMaintenancePosition', + params: { pipetteId: pipette?.id }, + } as any, + ]) + + vi.mocked(mapFlexStackerLabware).mockReturnValue(mockStackerLabware) + + mockChainLPCCommands.mockResolvedValue([]) + }) + + it('should return handleStartLPC function', () => { + const { result } = renderHook(() => useHandleStartLPC(mockProps)) + + expect(result.current).toHaveProperty('handleStartLPC') + expect(typeof result.current.handleStartLPC).toBe('function') + }) + + it('should chain commands in the correct order when handleStartLPC is called', async () => { + const { result } = renderHook(() => useHandleStartLPC(mockProps)) + + await result.current.handleStartLPC(mockPipette, mockOnSuccess) + + expect(moduleInitBeforeAnyLPCCommands).toHaveBeenCalledWith(mockAnalysis) + expect(fullHomeCommands).toHaveBeenCalled() + expect(moveToMaintenancePosition).toHaveBeenCalledWith(mockPipette) + expect(mapFlexStackerLabware).toHaveBeenCalledWith(mockAnalysis.commands) + + const commandsArg = mockChainLPCCommands.mock.calls[0][0] + + const loadPipetteCommands = commandsArg.filter( + (cmd: any) => cmd.commandType === 'loadPipette' + ) + expect(loadPipetteCommands.length).toBe(1) + expect(loadPipetteCommands[0]).toHaveProperty( + 'params.pipetteId', + 'pipette-123' + ) + expect(loadPipetteCommands[0]).toHaveProperty('params.mount', 'left') + expect(loadPipetteCommands[0]).toHaveProperty( + 'params.pipetteName', + 'mock_pipette_name' + ) + + const loadLabwareCommands = commandsArg.filter( + (cmd: any) => cmd.commandType === 'loadLabware' + ) + expect(loadLabwareCommands.length).toBe(2) + expect(loadLabwareCommands[0]).toHaveProperty( + 'params.labwareId', + 'labware-456' + ) + expect(loadLabwareCommands[0]).toHaveProperty('params.location', 'offDeck') + + const stackerCommand = loadLabwareCommands.find( + (cmd: any) => cmd.params.labwareId === 'stacker-labware-001' + ) + expect(stackerCommand).toBeDefined() + expect(stackerCommand).toHaveProperty('params.loadName', 'stacker_labware') + expect(stackerCommand).toHaveProperty('params.location', 'offDeck') + + const loadModuleCommands = commandsArg.filter( + (cmd: any) => cmd.commandType === 'loadModule' + ) + expect(loadModuleCommands.length).toBe(1) + expect(loadModuleCommands[0]).toHaveProperty( + 'params.moduleId', + 'module-789' + ) + expect(loadModuleCommands[0]).toHaveProperty('params.moduleType', 'magdeck') + + expect(commandsArg).toContainEqual({ + commandType: 'moduleInitBeforeAnyLPC', + params: { analysis: mockAnalysis }, + }) + + expect(commandsArg).toContainEqual({ + commandType: 'fullHome', + params: {}, + }) + + expect(commandsArg).toContainEqual({ + commandType: 'moveToMaintenancePosition', + params: { pipetteId: mockPipette.id }, + }) + + expect(mockChainLPCCommands).toHaveBeenCalledWith(expect.any(Array), false) + }) + + it('should only include load commands and exclude other commands', async () => { + const { result } = renderHook(() => useHandleStartLPC(mockProps)) + + await result.current.handleStartLPC(mockPipette, mockOnSuccess) + + const commandsArg = mockChainLPCCommands.mock.calls[0][0] + + const otherCommands = commandsArg.filter( + (cmd: any) => cmd.commandType === 'otherCommand' + ) + + expect(otherCommands.length).toBe(0) + }) + + it('should call onSuccess when chainLPCCommands succeeds', async () => { + const { result } = renderHook(() => useHandleStartLPC(mockProps)) + + await result.current.handleStartLPC(mockPipette, mockOnSuccess) + + expect(mockOnSuccess).toHaveBeenCalled() + }) + + it('should pass the error from chainLPCCommands if it fails', async () => { + const mockError = new Error('Command chain failed') + mockChainLPCCommands.mockRejectedValueOnce(mockError) + + const { result } = renderHook(() => useHandleStartLPC(mockProps)) + + await expect( + result.current.handleStartLPC(mockPipette, mockOnSuccess) + ).rejects.toThrow('Command chain failed') + + expect(mockOnSuccess).not.toHaveBeenCalled() + }) + + it('should handle null pipette', async () => { + const { result } = renderHook(() => useHandleStartLPC(mockProps)) + + await result.current.handleStartLPC(null, mockOnSuccess) + + expect(moveToMaintenancePosition).toHaveBeenCalledWith(null) + expect(mockChainLPCCommands).toHaveBeenCalled() + expect(mockOnSuccess).toHaveBeenCalled() + }) + + it('should correctly process protocol with no commandType matches', async () => { + const mockEmptyAnalysis = { + commands: [ + { + commandType: 'otherCommand', + params: {}, + }, + ], + } as any + + vi.mocked(mapFlexStackerLabware).mockReturnValueOnce([]) + + const mockPropsWithEmptyAnalysis = { + ...mockProps, + analysis: mockEmptyAnalysis, + } + + const { result } = renderHook(() => + useHandleStartLPC(mockPropsWithEmptyAnalysis) + ) + + await result.current.handleStartLPC(mockPipette, mockOnSuccess) + + const commandsArg = mockChainLPCCommands.mock.calls[0][0] + + const loadPipetteCommands = commandsArg.filter( + (cmd: any) => cmd.commandType === 'loadPipette' + ) + expect(loadPipetteCommands.length).toBe(0) + + const loadLabwareCommands = commandsArg.filter( + (cmd: any) => cmd.commandType === 'loadLabware' + ) + expect(loadLabwareCommands.length).toBe(0) + + const loadModuleCommands = commandsArg.filter( + (cmd: any) => cmd.commandType === 'loadModule' + ) + expect(loadModuleCommands.length).toBe(0) + + expect(mockChainLPCCommands).toHaveBeenCalled() + expect(mockOnSuccess).toHaveBeenCalled() + }) +}) diff --git a/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/__tests__/useHandleValidMoveToMaintenancePosition.test.ts b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/__tests__/useHandleValidMoveToMaintenancePosition.test.ts new file mode 100644 index 000000000000..3c313c760ab7 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/__tests__/useHandleValidMoveToMaintenancePosition.test.ts @@ -0,0 +1,110 @@ +import { vi, it, describe, expect, beforeEach } from 'vitest' +import { renderHook } from '@testing-library/react' + +import { moveToMaintenancePosition } from '../commands' +import { useHandleValidMoveToMaintenancePosition } from '../useHandleValidMoveToMaintenancePosition' + +vi.mock('../commands') + +describe('useHandleValidMoveToMaintenancePosition', () => { + const mockChainLPCCommands = vi.fn() + + const mockProps = { + chainLPCCommands: mockChainLPCCommands, + runId: 'mock_run_id', + maintenanceRunId: 'mock_maintenance_run_id', + } as any + + const mockPipette = { + id: 'pipette-123', + mount: 'left', + pipetteName: 'mock_pipette_name', + } as any + + const mockCommandData = [ + { + data: { + commandType: 'moveToMaintenancePosition', + result: { success: true }, + }, + }, + ] + + beforeEach(() => { + vi.mocked(moveToMaintenancePosition).mockImplementation(pipette => [ + { + commandType: 'moveToMaintenancePosition', + params: { pipetteId: pipette?.id }, + } as any, + ]) + + mockChainLPCCommands.mockResolvedValue(mockCommandData) + }) + + it('should return handleValidMoveToMaintenancePosition function', () => { + const { result } = renderHook(() => + useHandleValidMoveToMaintenancePosition(mockProps) + ) + + expect(result.current).toHaveProperty( + 'handleValidMoveToMaintenancePosition' + ) + expect(typeof result.current.handleValidMoveToMaintenancePosition).toBe( + 'function' + ) + }) + + it('should call chainLPCCommands with commands from moveToMaintenancePosition', async () => { + const { result } = renderHook(() => + useHandleValidMoveToMaintenancePosition(mockProps) + ) + + const response = await result.current.handleValidMoveToMaintenancePosition( + mockPipette + ) + + expect(moveToMaintenancePosition).toHaveBeenCalledWith(mockPipette) + expect(mockChainLPCCommands).toHaveBeenCalledWith( + [ + { + commandType: 'moveToMaintenancePosition', + params: { pipetteId: mockPipette.id }, + }, + ], + false + ) + expect(response).toEqual(mockCommandData) + }) + + it('should handle null pipette', async () => { + const { result } = renderHook(() => + useHandleValidMoveToMaintenancePosition(mockProps) + ) + + await result.current.handleValidMoveToMaintenancePosition(null) + + expect(moveToMaintenancePosition).toHaveBeenCalledWith(null) + expect(mockChainLPCCommands).toHaveBeenCalledWith( + [ + { + commandType: 'moveToMaintenancePosition', + params: { pipetteId: undefined }, + }, + ], + false + ) + }) + + it('should pass the error from chainLPCCommands if it fails', async () => { + const mockError = new Error('Command chain failed') + mockChainLPCCommands.mockRejectedValueOnce(mockError) + + const { result } = renderHook(() => + useHandleValidMoveToMaintenancePosition(mockProps) + ) + + await expect( + result.current.handleValidMoveToMaintenancePosition(mockPipette) + ).rejects.toThrow('Command chain failed') + }) +}) diff --git a/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/__tests__/useSaveWorkingOffsets.test.ts b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/__tests__/useSaveWorkingOffsets.test.ts new file mode 100644 index 000000000000..0c48d01abd2a --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/__tests__/useSaveWorkingOffsets.test.ts @@ -0,0 +1,189 @@ +import { vi, it, describe, expect, beforeEach, afterEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useSelector } from 'react-redux' + +import { + useCreateLabwareOffsetsMutation, + useDeleteLabwareOffsetMutation, +} from '@opentrons/react-api-client' + +import { selectPendingOffsetOperations } from '/app/redux/protocol-runs' +import { useSaveWorkingOffsets } from '../useSaveWorkingOffsets' + +vi.mock('react-redux') +vi.mock('@opentrons/react-api-client') +vi.mock('/app/redux/protocol-runs') + +describe('useSaveWorkingOffsets', () => { + const mockRunId = 'mock_run_id' + + const mockProps = { + runId: mockRunId, + } as any + + const mockCreateLabwareOffsets = vi.fn() + const mockDeleteLabwareOffset = vi.fn() + + const mockToUpdate = [ + { + id: 'offset-1', + labwareId: 'labware-1', + offsetVector: { x: 1, y: 1, z: 1 }, + }, + { + id: 'offset-2', + labwareId: 'labware-2', + offsetVector: { x: 2, y: 2, z: 2 }, + }, + ] + const mockToDelete = ['offset-3', 'offset-4'] + + const mockPendingOffsetOperations = { + toUpdate: mockToUpdate, + toDelete: mockToDelete, + } + + const mockStoredOffsets = [ + { id: 'offset-1', labwareId: 'labware-1', vector: { x: 1, y: 1, z: 1 } }, + { id: 'offset-2', labwareId: 'labware-2', vector: { x: 2, y: 2, z: 2 } }, + ] + + const mockDeletedOffsets = [ + { id: '', labwareId: '', vector: { x: 0, y: 0, z: 0 } }, + { id: '', labwareId: '', vector: { x: 0, y: 0, z: 0 } }, + ] + + beforeEach(() => { + vi.clearAllMocks() + + vi.mocked(useSelector).mockImplementation(selector => { + if (selector === selectPendingOffsetOperations(mockRunId)) { + return mockPendingOffsetOperations + } + }) + + vi.mocked(useCreateLabwareOffsetsMutation).mockReturnValue({ + createLabwareOffsets: mockCreateLabwareOffsets, + } as any) + + vi.mocked(useDeleteLabwareOffsetMutation).mockReturnValue({ + deleteLabwareOffset: mockDeleteLabwareOffset, + } as any) + + mockCreateLabwareOffsets.mockResolvedValue(mockStoredOffsets) + mockDeleteLabwareOffset.mockResolvedValue({ + id: '', + labwareId: '', + vector: { x: 0, y: 0, z: 0 }, + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should return expected functions and initial state', () => { + const { result } = renderHook(() => useSaveWorkingOffsets(mockProps)) + + expect(result.current).toHaveProperty('saveWorkingOffsets') + expect(result.current).toHaveProperty( + 'isSavingWorkingOffsetsLoading', + false + ) + }) + + it('should call createLabwareOffsets with toUpdate and deleteLabwareOffset with each toDelete id', async () => { + const { result } = renderHook(() => useSaveWorkingOffsets(mockProps)) + + let returnValue: any + + await act(async () => { + returnValue = await result.current.saveWorkingOffsets() + }) + + expect(mockCreateLabwareOffsets).toHaveBeenCalledWith(mockToUpdate) + expect(mockDeleteLabwareOffset).toHaveBeenCalledWith('offset-3') + expect(mockDeleteLabwareOffset).toHaveBeenCalledWith('offset-4') + expect(mockDeleteLabwareOffset).toHaveBeenCalledTimes(2) + expect(returnValue).toEqual([mockStoredOffsets, mockDeletedOffsets]) + }) + + it('should set isLoading to true during operation and false afterwards', async () => { + const { result } = renderHook(() => useSaveWorkingOffsets(mockProps)) + + let resolveCreate: Function + mockCreateLabwareOffsets.mockReturnValue( + new Promise(resolve => { + resolveCreate = resolve + }) + ) + let savePromise: Promise + + act(() => { + savePromise = result.current.saveWorkingOffsets() + }) + + expect(result.current.isSavingWorkingOffsetsLoading).toBe(true) + + await act(async () => { + resolveCreate(mockStoredOffsets) + await savePromise + }) + + expect(result.current.isSavingWorkingOffsetsLoading).toBe(false) + }) + + it('should handle createLabwareOffsets returning a single item', async () => { + const singleOffset = { + id: 'offset-1', + labwareId: 'labware-1', + vector: { x: 1, y: 1, z: 1 }, + } + mockCreateLabwareOffsets.mockResolvedValue(singleOffset) + + const { result } = renderHook(() => useSaveWorkingOffsets(mockProps)) + + let returnValue: any + + await act(async () => { + returnValue = await result.current.saveWorkingOffsets() + }) + + expect(returnValue).toEqual([[singleOffset], mockDeletedOffsets]) + }) + + it('should handle errors during save operation', async () => { + mockCreateLabwareOffsets.mockRejectedValue(new Error('Create error')) + + const { result } = renderHook(() => useSaveWorkingOffsets(mockProps)) + + let returnValue: any + + await act(async () => { + returnValue = await result.current.saveWorkingOffsets() + }) + + expect(result.current.isSavingWorkingOffsetsLoading).toBe(false) + expect(returnValue).toEqual([[], []]) + }) + + it('should handle empty toUpdate and toDelete arrays', async () => { + vi.mocked(useSelector).mockImplementationOnce(selector => { + if (selector === selectPendingOffsetOperations(mockRunId)) { + return { toUpdate: [], toDelete: [] } + } + }) + + const { result } = renderHook(() => useSaveWorkingOffsets(mockProps)) + + let returnValue: any + + await act(async () => { + returnValue = await result.current.saveWorkingOffsets() + }) + + expect(mockCreateLabwareOffsets).not.toHaveBeenCalled() + expect(mockDeleteLabwareOffset).not.toHaveBeenCalled() + expect(returnValue).toEqual([[], []]) + }) +}) diff --git a/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/commands/__tests__/gantry.test.ts b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/commands/__tests__/gantry.test.ts new file mode 100644 index 000000000000..41f8dd839656 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/commands/__tests__/gantry.test.ts @@ -0,0 +1,13 @@ +import { it, describe, expect } from 'vitest' + +import { fullHomeCommands } from '../gantry' + +describe('gantry commands', () => { + describe('fullHomeCommands', () => { + it('should return home command with empty params', () => { + const result = fullHomeCommands() + + expect(result).toEqual([{ commandType: 'home', params: {} }]) + }) + }) +}) diff --git a/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/commands/__tests__/labware.test.ts b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/commands/__tests__/labware.test.ts new file mode 100644 index 000000000000..99e72969bc41 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/commands/__tests__/labware.test.ts @@ -0,0 +1,163 @@ +import { it, describe, expect } from 'vitest' + +import { moveLabwareOffDeckCommands } from '../labware' + +import type { OffsetLocationDetails } from '/app/redux/protocol-runs' + +describe('labware commands', () => { + describe('moveLabwareOffDeckCommands', () => { + const LABWARE_URI_1 = 'opentrons/labware-1' + const LABWARE_URI_2 = 'opentrons/labware-2' + const LABWARE_ID_1 = 'labware-123' + const LABWARE_ID_2 = 'labware-456' + const MODULE_ID_1 = 'module-123' + const MODULE_MODEL_1 = 'thermocycler' + + it('should return empty array when no labware components exist', () => { + const mockOffsetLocationDetails = { + lwModOnlyStackupDetails: [ + { + kind: 'module', + moduleModel: MODULE_MODEL_1, + id: MODULE_ID_1, + }, + ], + } as any + + const result = moveLabwareOffDeckCommands(mockOffsetLocationDetails) + + expect(result).toEqual([]) + }) + + it('should return move commands for labware components only', () => { + const mockOffsetLocationDetails = { + lwModOnlyStackupDetails: [ + { + kind: 'module', + moduleModel: MODULE_MODEL_1, + id: MODULE_ID_1, + }, + { + kind: 'labware', + labwareUri: LABWARE_URI_1, + id: LABWARE_ID_1, + }, + ], + } as OffsetLocationDetails + + const result = moveLabwareOffDeckCommands(mockOffsetLocationDetails) + + expect(result).toEqual([ + { + commandType: 'moveLabware', + params: { + labwareId: LABWARE_ID_1, + newLocation: 'offDeck', + strategy: 'manualMoveWithoutPause', + }, + }, + ]) + }) + + it('should return move commands for multiple labware components in reverse order', () => { + const mockOffsetLocationDetails = { + lwModOnlyStackupDetails: [ + { + kind: 'module', + moduleModel: MODULE_MODEL_1, + id: MODULE_ID_1, + }, + { + kind: 'labware', + labwareUri: LABWARE_URI_1, + id: LABWARE_ID_1, + }, + { + kind: 'labware', + labwareUri: LABWARE_URI_2, + id: LABWARE_ID_2, + }, + ], + } as OffsetLocationDetails + + const result = moveLabwareOffDeckCommands(mockOffsetLocationDetails) + + expect(result).toEqual([ + { + commandType: 'moveLabware', + params: { + labwareId: LABWARE_ID_2, + newLocation: 'offDeck', + strategy: 'manualMoveWithoutPause', + }, + }, + { + commandType: 'moveLabware', + params: { + labwareId: LABWARE_ID_1, + newLocation: 'offDeck', + strategy: 'manualMoveWithoutPause', + }, + }, + ]) + }) + + it('should handle mixed labware and module components in the stackup', () => { + const mockOffsetLocationDetails = { + lwModOnlyStackupDetails: [ + { + kind: 'module', + moduleModel: MODULE_MODEL_1, + id: MODULE_ID_1, + }, + { + kind: 'labware', + labwareUri: LABWARE_URI_1, + id: LABWARE_ID_1, + }, + { + kind: 'module', + moduleModel: 'magdeck', + id: 'module-456', + }, + { + kind: 'labware', + labwareUri: LABWARE_URI_2, + id: LABWARE_ID_2, + }, + ], + } as OffsetLocationDetails + + const result = moveLabwareOffDeckCommands(mockOffsetLocationDetails) + + expect(result).toEqual([ + { + commandType: 'moveLabware', + params: { + labwareId: LABWARE_ID_2, + newLocation: 'offDeck', + strategy: 'manualMoveWithoutPause', + }, + }, + { + commandType: 'moveLabware', + params: { + labwareId: LABWARE_ID_1, + newLocation: 'offDeck', + strategy: 'manualMoveWithoutPause', + }, + }, + ]) + }) + + it('should handle empty stackup details', () => { + const mockOffsetLocationDetails = { + lwModOnlyStackupDetails: [], + } as any + + const result = moveLabwareOffDeckCommands(mockOffsetLocationDetails) + + expect(result).toEqual([]) + }) + }) +}) diff --git a/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/commands/__tests__/modules.test.ts b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/commands/__tests__/modules.test.ts new file mode 100644 index 000000000000..2f5f9786b567 --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/commands/__tests__/modules.test.ts @@ -0,0 +1,235 @@ +import { it, describe, expect } from 'vitest' + +import { + modulePrepCommands, + moduleInitBeforeAnyLPCCommands, + moduleInitDuringLPCCommands, + moduleCleanupDuringLPCCommands, +} from '../modules' + +import type { CompletedProtocolAnalysis } from '@opentrons/shared-data' +import type { OffsetLocationDetails } from '/app/redux/protocol-runs' + +describe('module commands', () => { + describe('modulePrepCommands', () => { + const MODULE_ID = 'module-123' + + it('should return empty array when no module exists', () => { + const mockOffsetLocationDetails = { + closestBeneathModuleId: null, + closestBeneathModuleModel: null, + } as any + + const result = modulePrepCommands(mockOffsetLocationDetails) + + expect(result).toEqual([]) + }) + + it('should return thermocycler commands when thermocycler module exists', () => { + const mockOffsetLocationDetails = { + closestBeneathModuleId: MODULE_ID, + closestBeneathModuleModel: 'thermocyclerModuleV2', + } as OffsetLocationDetails + + const result = modulePrepCommands(mockOffsetLocationDetails) + + expect(result).toEqual([ + { + commandType: 'thermocycler/openLid', + params: { moduleId: MODULE_ID }, + }, + ]) + }) + + it('should return heaterShaker commands when heaterShaker module exists', () => { + const mockOffsetLocationDetails = { + closestBeneathModuleId: MODULE_ID, + closestBeneathModuleModel: 'heaterShakerModuleV1', + } as OffsetLocationDetails + + const result = modulePrepCommands(mockOffsetLocationDetails) + + expect(result).toEqual([ + { + commandType: 'heaterShaker/closeLabwareLatch', + params: { moduleId: MODULE_ID }, + }, + { + commandType: 'heaterShaker/deactivateShaker', + params: { moduleId: MODULE_ID }, + }, + { + commandType: 'heaterShaker/openLabwareLatch', + params: { moduleId: MODULE_ID }, + }, + ]) + }) + + it('should return empty array for unsupported module types', () => { + const mockOffsetLocationDetails = { + closestBeneathModuleId: MODULE_ID, + closestBeneathModuleModel: 'magneticModuleV2', + } as OffsetLocationDetails + + const result = modulePrepCommands(mockOffsetLocationDetails) + + expect(result).toEqual([]) + }) + }) + + describe('moduleInitBeforeAnyLPCCommands', () => { + const THERMOCYCLER_ID = 'thermocycler-123' + const HEATERSHAKER_ID = 'heatershaker-123' + const ABSORBANCE_READER_ID = 'absorbancereader-123' + + it('should return commands for all supported modules', () => { + const mockAnalysis = { + modules: [ + { + id: THERMOCYCLER_ID, + model: 'thermocyclerModuleV2', + }, + { + id: HEATERSHAKER_ID, + model: 'heaterShakerModuleV1', + }, + { + id: ABSORBANCE_READER_ID, + model: 'absorbanceReaderV1', + }, + ], + } as CompletedProtocolAnalysis + + const result = moduleInitBeforeAnyLPCCommands(mockAnalysis) + + expect(result).toEqual([ + { + commandType: 'thermocycler/openLid', + params: { moduleId: THERMOCYCLER_ID }, + }, + { + commandType: 'home', + params: {}, + }, + { + commandType: 'absorbanceReader/openLid', + params: { moduleId: ABSORBANCE_READER_ID }, + }, + { + commandType: 'heaterShaker/closeLabwareLatch', + params: { moduleId: HEATERSHAKER_ID }, + }, + ]) + }) + + it('should return empty array when no modules exist', () => { + const mockAnalysis = { + modules: [], + } as any + + const result = moduleInitBeforeAnyLPCCommands(mockAnalysis) + + expect(result).toEqual([]) + }) + + it('should handle unsupported module types', () => { + const mockAnalysis = { + modules: [ + { + id: 'magnetic-123', + model: 'magneticModuleV2', + }, + ], + } as CompletedProtocolAnalysis + + const result = moduleInitBeforeAnyLPCCommands(mockAnalysis) + + expect(result).toEqual([]) + }) + }) + + describe('moduleInitDuringLPCCommands', () => { + const HEATERSHAKER_ID = 'heatershaker-123' + + it('should return heaterShaker init commands', () => { + const mockAnalysis = { + modules: [ + { + id: HEATERSHAKER_ID, + model: 'heaterShakerModuleV1', + }, + { + id: 'thermocycler-123', + model: 'thermocyclerModuleV2', + }, + ], + } as CompletedProtocolAnalysis + + const result = moduleInitDuringLPCCommands(mockAnalysis) + + expect(result).toEqual([ + { + commandType: 'heaterShaker/closeLabwareLatch', + params: { moduleId: HEATERSHAKER_ID }, + }, + ]) + }) + + it('should return empty array when no heaterShaker modules exist', () => { + const mockAnalysis = { + modules: [ + { + id: 'thermocycler-123', + model: 'thermocyclerModuleV2', + }, + ], + } as CompletedProtocolAnalysis + + const result = moduleInitDuringLPCCommands(mockAnalysis) + + expect(result).toEqual([]) + }) + }) + + describe('moduleCleanupDuringLPCCommands', () => { + const MODULE_ID = 'module-123' + + it('should return heaterShaker cleanup commands when heaterShaker module exists', () => { + const mockOffsetLocationDetails = { + closestBeneathModuleId: MODULE_ID, + closestBeneathModuleModel: 'heaterShakerModuleV1', + } as OffsetLocationDetails + + const result = moduleCleanupDuringLPCCommands(mockOffsetLocationDetails) + + expect(result).toEqual([ + { + commandType: 'heaterShaker/openLabwareLatch', + params: { moduleId: MODULE_ID }, + }, + ]) + }) + + it('should return empty array for non-heaterShaker modules', () => { + const mockOffsetLocationDetails = { + closestBeneathModuleId: MODULE_ID, + closestBeneathModuleModel: 'thermocyclerModuleV2', + } as OffsetLocationDetails + + const result = moduleCleanupDuringLPCCommands(mockOffsetLocationDetails) + + expect(result).toEqual([]) + }) + + it('should return empty array when no module exists', () => { + const mockOffsetLocationDetails = { + closestBeneathModuleId: null, + closestBeneathModuleModel: null, + } as any + + const result = moduleCleanupDuringLPCCommands(mockOffsetLocationDetails) + + expect(result).toEqual([]) + }) + }) +}) diff --git a/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/commands/__tests__/pipettes.test.ts b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/commands/__tests__/pipettes.test.ts new file mode 100644 index 000000000000..9a7e72a65cbc --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/commands/__tests__/pipettes.test.ts @@ -0,0 +1,356 @@ +import { it, describe, expect, vi } from 'vitest' + +import { + savePositionCommands, + moveToWellCommands, + retractSafelyAndHomeCommands, + retractPipetteAxesSequentiallyCommands, + moveRelativeCommand, + moveToMaintenancePosition, + verifyProbeAttachmentAndHomeCommands, +} from '../pipettes' +import { fullHomeCommands } from '../gantry' + +import type { LoadedPipette } from '@opentrons/shared-data' +import type { OffsetLocationDetails } from '/app/redux/protocol-runs' + +vi.mock('../gantry', () => ({ + fullHomeCommands: vi.fn(() => [ + { commandType: 'home', params: { axes: 'all' } }, + ]), +})) + +describe('pipette commands', () => { + const PIPETTE_ID = 'pipette-123' + const LEFT_MOUNT = 'left' + const RIGHT_MOUNT = 'right' + const LEFT_Z_AXIS = 'leftZ' + const RIGHT_Z_AXIS = 'rightZ' + const X_AXIS = 'x' + const Y_AXIS = 'y' + const LABWARE_ID = 'labware-456' + const PROBE_LENGTH_MM = 44.5 + + describe('savePositionCommands', () => { + it('should return save position command with pipette id', () => { + const result = savePositionCommands(PIPETTE_ID) + + expect(result).toEqual([ + { commandType: 'savePosition', params: { pipetteId: PIPETTE_ID } }, + ]) + }) + }) + + describe('moveToWellCommands', () => { + const mockOffsetLocationDetails = { + labwareId: LABWARE_ID, + } as OffsetLocationDetails + + it('should return move to well command with default offset when vectorOffset is not provided', () => { + const result = moveToWellCommands(mockOffsetLocationDetails, PIPETTE_ID) + + expect(result).toEqual([ + { + commandType: 'moveToWell', + params: { + pipetteId: PIPETTE_ID, + labwareId: LABWARE_ID, + wellName: 'A1', + wellLocation: { + origin: 'top', + offset: { x: 0, y: 0, z: PROBE_LENGTH_MM }, + }, + }, + }, + ]) + }) + + it('should return move to well command with adjusted offset when vectorOffset is provided', () => { + const vectorOffset = { x: 1, y: 2, z: 3 } + const result = moveToWellCommands( + mockOffsetLocationDetails, + PIPETTE_ID, + vectorOffset + ) + + expect(result).toEqual([ + { + commandType: 'moveToWell', + params: { + pipetteId: PIPETTE_ID, + labwareId: LABWARE_ID, + wellName: 'A1', + wellLocation: { + origin: 'top', + offset: { x: 1, y: 2, z: 3 + PROBE_LENGTH_MM }, + }, + }, + }, + ]) + }) + + it('should handle null vectorOffset', () => { + const result = moveToWellCommands( + mockOffsetLocationDetails, + PIPETTE_ID, + null + ) + + expect(result).toEqual([ + { + commandType: 'moveToWell', + params: { + pipetteId: PIPETTE_ID, + labwareId: LABWARE_ID, + wellName: 'A1', + wellLocation: { + origin: 'top', + offset: { x: 0, y: 0, z: PROBE_LENGTH_MM }, + }, + }, + }, + ]) + }) + }) + + describe('retractSafelyAndHomeCommands', () => { + it('should return retract commands for all axes followed by home command', () => { + const result = retractSafelyAndHomeCommands() + + expect(result).toEqual([ + { + commandType: 'retractAxis', + params: { axis: LEFT_Z_AXIS }, + }, + { + commandType: 'retractAxis', + params: { axis: RIGHT_Z_AXIS }, + }, + { + commandType: 'retractAxis', + params: { axis: X_AXIS }, + }, + { + commandType: 'retractAxis', + params: { axis: Y_AXIS }, + }, + { commandType: 'home', params: { axes: 'all' } }, + ]) + + expect(fullHomeCommands).toHaveBeenCalled() + }) + }) + + describe('retractPipetteAxesSequentiallyCommands', () => { + it('should return retract commands for left pipette axes in sequence', () => { + const mockPipette = { + id: PIPETTE_ID, + mount: LEFT_MOUNT, + } as LoadedPipette + + const result = retractPipetteAxesSequentiallyCommands(mockPipette) + + expect(result).toEqual([ + { + commandType: 'retractAxis', + params: { axis: LEFT_Z_AXIS }, + }, + { + commandType: 'retractAxis', + params: { axis: X_AXIS }, + }, + { + commandType: 'retractAxis', + params: { axis: Y_AXIS }, + }, + ]) + }) + + it('should return retract commands for right pipette axes in sequence', () => { + const mockPipette = { + id: PIPETTE_ID, + mount: RIGHT_MOUNT, + } as LoadedPipette + + const result = retractPipetteAxesSequentiallyCommands(mockPipette) + + expect(result).toEqual([ + { + commandType: 'retractAxis', + params: { axis: RIGHT_Z_AXIS }, + }, + { + commandType: 'retractAxis', + params: { axis: X_AXIS }, + }, + { + commandType: 'retractAxis', + params: { axis: Y_AXIS }, + }, + ]) + }) + + it('should handle null pipette', () => { + const result = retractPipetteAxesSequentiallyCommands(null) + + expect(result).toEqual([ + { + commandType: 'retractAxis', + params: { axis: 'rightZ' }, + }, + { + commandType: 'retractAxis', + params: { axis: X_AXIS }, + }, + { + commandType: 'retractAxis', + params: { axis: Y_AXIS }, + }, + ]) + }) + }) + + describe('moveRelativeCommand', () => { + it('should return move relative command with calculated distance', () => { + const params = { + pipetteId: PIPETTE_ID, + axis: 'x' as const, + dir: 1 as const, + step: 0.1, + } as any + + const result = moveRelativeCommand(params) + + expect(result).toEqual({ + commandType: 'moveRelative', + params: { + pipetteId: PIPETTE_ID, + distance: 0.1, + axis: 'x', + }, + }) + }) + + it('should calculate negative distance when direction is negative', () => { + const params = { + pipetteId: PIPETTE_ID, + axis: 'z' as const, + dir: -1 as const, + step: 0.5, + } as any + + const result = moveRelativeCommand(params) + + expect(result).toEqual({ + commandType: 'moveRelative', + params: { + pipetteId: PIPETTE_ID, + distance: -0.5, + axis: 'z', + }, + }) + }) + }) + + describe('moveToMaintenancePosition', () => { + it('should return move to maintenance position command with specified mount', () => { + const mockPipette = { + id: PIPETTE_ID, + mount: LEFT_MOUNT, + } as LoadedPipette + + const result = moveToMaintenancePosition(mockPipette) + + expect(result).toEqual([ + { + commandType: 'calibration/moveToMaintenancePosition', + params: { + mount: LEFT_MOUNT, + }, + }, + ]) + }) + + it('should default to left mount when pipette is null', () => { + const result = moveToMaintenancePosition(null) + + expect(result).toEqual([ + { + commandType: 'calibration/moveToMaintenancePosition', + params: { + mount: LEFT_MOUNT, + }, + }, + ]) + }) + }) + + describe('verifyProbeAttachmentAndHomeCommands', () => { + it('should return verify tip presence and home commands for left pipette', () => { + const mockPipette = { + id: PIPETTE_ID, + mount: LEFT_MOUNT, + } as LoadedPipette + + const result = verifyProbeAttachmentAndHomeCommands(mockPipette) + + expect(result).toEqual([ + { + commandType: 'verifyTipPresence', + params: { + pipetteId: PIPETTE_ID, + expectedState: 'present', + followSingularSensor: 'primary', + }, + }, + { + commandType: 'home', + params: { axes: [LEFT_Z_AXIS, X_AXIS, Y_AXIS] }, + }, + ]) + }) + + it('should return verify tip presence and home commands for right pipette', () => { + const mockPipette = { + id: PIPETTE_ID, + mount: RIGHT_MOUNT, + } as LoadedPipette + + const result = verifyProbeAttachmentAndHomeCommands(mockPipette) + + expect(result).toEqual([ + { + commandType: 'verifyTipPresence', + params: { + pipetteId: PIPETTE_ID, + expectedState: 'present', + followSingularSensor: 'primary', + }, + }, + { + commandType: 'home', + params: { axes: [RIGHT_Z_AXIS, X_AXIS, Y_AXIS] }, + }, + ]) + }) + + it('should handle null pipette', () => { + const result = verifyProbeAttachmentAndHomeCommands(null) + + expect(result).toEqual([ + { + commandType: 'verifyTipPresence', + params: { + pipetteId: '', + expectedState: 'present', + followSingularSensor: 'primary', + }, + }, + { + commandType: 'home', + params: { axes: [RIGHT_Z_AXIS, X_AXIS, Y_AXIS] }, + }, + ]) + }) + }) +}) diff --git a/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useHandleJog.ts b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useHandleJog.ts index b74e54925687..a00972bdb56c 100644 --- a/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useHandleJog.ts +++ b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useHandleJog.ts @@ -1,12 +1,13 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef } from 'react' import { useSelector } from 'react-redux' +import debounce from 'lodash/debounce' import { useCreateMaintenanceCommandMutation } from '@opentrons/react-api-client' import { moveRelativeCommand, moveToWellCommands } from './commands' import { selectActivePipette } from '/app/redux/protocol-runs' -import type { Coordinates, CreateCommand } from '@opentrons/shared-data' +import type { Coordinates } from '@opentrons/shared-data' import type { Axis, Jog, @@ -19,6 +20,7 @@ import type { OffsetLocationDetails } from '/app/redux/protocol-runs' const JOG_COMMAND_TIMEOUT_MS = 10000 const MAX_QUEUED_JOGS = 3 +const DEBOUNCE_TIME_MS = 50 interface UseHandleJogProps extends UseLPCCommandWithChainRunChildProps { setErrorMessage: (msg: string | null) => void @@ -41,65 +43,89 @@ export function useHandleJog({ setErrorMessage, chainLPCCommands, }: UseHandleJogProps): UseHandleJogResult { - const [isJogging, setIsJogging] = useState(false) - const [jogQueue, setJogQueue] = useState Promise>>([]) const pipette = useSelector(selectActivePipette(runId)) const pipetteId = pipette?.id const { createMaintenanceCommand: createSilentCommand, } = useCreateMaintenanceCommandMutation() - const executeJog = useCallback( - ( - axis: Axis, - dir: Sign, - step: StepSize, + const queueRef = useRef< + Array<{ + axis: Axis + dir: Sign + step: StepSize onSuccess?: (position: Coordinates | null) => void - ): Promise => { - return new Promise((resolve, reject) => { - if (pipetteId != null) { - createSilentCommand({ - maintenanceRunId, - command: moveRelativeCommand({ pipetteId, axis, dir, step }), - waitUntilComplete: true, - timeout: JOG_COMMAND_TIMEOUT_MS, - }) - .then(data => { - onSuccess?.( - (data?.data?.result?.position ?? null) as Coordinates | null - ) - resolve() - }) - .catch((e: Error) => { - setErrorMessage(`Error issuing jog command: ${e.message}`) - reject(e) - }) - } else { - const error = new Error( - `Could not find pipette to jog with id: ${pipetteId ?? ''}` - ) - setErrorMessage(error.message) - reject(error) - } - }) - }, - [pipetteId, maintenanceRunId, createSilentCommand, setErrorMessage] - ) + }> + >([]) + const processingRef = useRef(false) + + const processNextInQueue = useCallback(() => { + if (processingRef.current || queueRef.current.length === 0) { + return + } + + processingRef.current = true + const nextJog = queueRef.current.shift() + + if (nextJog == null) { + processingRef.current = false + return + } + + const { axis, dir, step, onSuccess } = nextJog - const processJogQueue = useCallback((): void => { - if (jogQueue.length > 0 && !isJogging) { - setIsJogging(true) - const nextJog = jogQueue[0] - setJogQueue(prevQueue => prevQueue.slice(1)) - void nextJog().finally(() => { - setIsJogging(false) + if (pipetteId != null) { + createSilentCommand({ + maintenanceRunId, + command: moveRelativeCommand({ pipetteId, axis, dir, step }), + waitUntilComplete: true, + timeout: JOG_COMMAND_TIMEOUT_MS, }) + .then(data => { + onSuccess?.( + (data?.data?.result?.position ?? null) as Coordinates | null + ) + }) + .catch((e: Error) => { + setErrorMessage(`Error issuing jog command: ${e.message}`) + }) + .finally(() => { + processingRef.current = false + // Use setTimeout to ensure we're outside the current call stack. + // This helps prevent stack overflow with rapid queue processing. + setTimeout(() => { + processNextInQueue() + }, DEBOUNCE_TIME_MS) + }) + } else { + const error = new Error( + `Could not find pipette to jog with id: ${pipetteId ?? ''}` + ) + setErrorMessage(error.message) + processingRef.current = false + setTimeout(() => { + processNextInQueue() + }, DEBOUNCE_TIME_MS) } - }, [jogQueue, isJogging]) + }, [pipetteId, maintenanceRunId, createSilentCommand, setErrorMessage]) + + const debouncedProcessQueue = useCallback( + debounce( + () => { + processNextInQueue() + }, + DEBOUNCE_TIME_MS, + { leading: true, trailing: true } + ), + [processNextInQueue] + ) + // Clear the queue on dismount so the pipette doesn't continue to jog. useEffect(() => { - processJogQueue() - }, [processJogQueue, jogQueue.length, isJogging]) + return () => { + debouncedProcessQueue.cancel() + } + }, [debouncedProcessQueue]) const handleJog = useCallback( ( @@ -108,29 +134,32 @@ export function useHandleJog({ step: StepSize, onSuccess?: (position: Coordinates | null) => void ): void => { - setJogQueue(prevQueue => { - if (prevQueue.length < MAX_QUEUED_JOGS) { - return [...prevQueue, () => executeJog(axis, dir, step, onSuccess)] - } - return prevQueue - }) + if (queueRef.current.length < MAX_QUEUED_JOGS) { + queueRef.current.push({ axis, dir, step, onSuccess }) + debouncedProcessQueue() + } }, - [executeJog] + [debouncedProcessQueue] ) - const resetJog = ( - offsetLocationDetails: OffsetLocationDetails, - pipetteId: string, - offset?: VectorOffset | null - ): Promise => { - const resetJogCommands: CreateCommand[] = [ - ...moveToWellCommands(offsetLocationDetails, pipetteId, offset), - ] - - return chainLPCCommands(resetJogCommands, false).then(() => - Promise.resolve() - ) - } + const resetJog = useCallback( + ( + offsetLocationDetails: OffsetLocationDetails, + pipetteId: string, + offset?: VectorOffset | null + ): Promise => { + queueRef.current = [] + + const resetJogCommands = [ + ...moveToWellCommands(offsetLocationDetails, pipetteId, offset), + ] + + return chainLPCCommands(resetJogCommands, false).then(() => + Promise.resolve() + ) + }, + [chainLPCCommands] + ) return { handleJog, resetJog } } diff --git a/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useHandleStartLPC.ts b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useHandleStartLPC.ts index 5f25de230e66..8e17d004aa7b 100644 --- a/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useHandleStartLPC.ts +++ b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useHandleStartLPC.ts @@ -3,6 +3,7 @@ import { moduleInitBeforeAnyLPCCommands, moveToMaintenancePosition, } from './commands' +import { mapFlexStackerLabware } from '/app/organisms/LabwarePositionCheck/hooks/useLPCCommands/utils' import type { CompletedProtocolAnalysis, @@ -14,7 +15,6 @@ import type { SetupCreateCommand, } from '@opentrons/shared-data' import type { UseLPCCommandWithChainRunChildProps } from './types' -import { mapFlexStackerLabware } from '/app/organisms/LabwarePositionCheck/hooks/useLPCCommands/utils' export interface UseHandleStartLPCResult { handleStartLPC: ( diff --git a/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useSaveWorkingOffsets.ts b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useSaveWorkingOffsets.ts index 307d0f5050aa..cf0030224dbe 100644 --- a/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useSaveWorkingOffsets.ts +++ b/app/src/organisms/LabwarePositionCheck/hooks/useLPCCommands/useSaveWorkingOffsets.ts @@ -9,11 +9,12 @@ import { import { selectPendingOffsetOperations } from '/app/redux/protocol-runs' import type { StoredLabwareOffset } from '@opentrons/api-client' +import type { SavedOffsets } from '/app/redux/protocol-runs' import type { UseLPCCommandChildProps } from '/app/organisms/LabwarePositionCheck/hooks/useLPCCommands/types' export interface UseBuildOffsetsToApplyResult { // Update the server with the current working offsets, returning the updated offsets. - saveWorkingOffsets: () => Promise + saveWorkingOffsets: () => Promise isSavingWorkingOffsetsLoading: boolean } @@ -29,28 +30,41 @@ export function useSaveWorkingOffsets({ const { deleteLabwareOffset } = useDeleteLabwareOffsetMutation() const deleteLabwareOffsets = (): Promise => { - // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression - const deletePromises = toDelete.map(id => deleteLabwareOffset(id)) - return Promise.all(deletePromises) + if (toDelete.length > 0) { + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + const deletePromises = toDelete.map(id => deleteLabwareOffset(id)) + return Promise.all(deletePromises) + } else { + return Promise.resolve([]) + } } - const saveWorkingOffsets = (): Promise => { + const createNecessaryLabwareOffsets = (): Promise => { + if (toUpdate.length > 0) { + return createLabwareOffsets(toUpdate).then(res => { + return Array.isArray(res) ? res : [res] + }) + } else { + return Promise.resolve([]) + } + } + + const saveWorkingOffsets = (): Promise => { setIsLoading(true) - return Promise.all([createLabwareOffsets(toUpdate), deleteLabwareOffsets()]) - .then(([createRes, deleteRes]) => { + return Promise.all([ + createNecessaryLabwareOffsets(), + deleteLabwareOffsets(), + ]) + .then(res => { setIsLoading(false) - if (Array.isArray(createRes)) { - return [...createRes, ...deleteRes] - } else { - return [createRes, ...deleteRes] - } + return res }) .catch(() => { setIsLoading(false) - return [] + return [[], []] }) } diff --git a/app/src/organisms/LabwarePositionCheck/steps/HandleLabware/EditOffset/PrepareLabware/PlaceItemInstruction.tsx b/app/src/organisms/LabwarePositionCheck/steps/HandleLabware/EditOffset/PrepareLabware/PlaceItemInstruction.tsx index 9033ca4bff1f..e1dd952299bf 100644 --- a/app/src/organisms/LabwarePositionCheck/steps/HandleLabware/EditOffset/PrepareLabware/PlaceItemInstruction.tsx +++ b/app/src/organisms/LabwarePositionCheck/steps/HandleLabware/EditOffset/PrepareLabware/PlaceItemInstruction.tsx @@ -1,7 +1,15 @@ import { Trans, useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' +import { css } from 'styled-components' -import { TYPOGRAPHY, LegacyStyledText } from '@opentrons/components' +import { + TYPOGRAPHY, + LegacyStyledText, + Flex, + DIRECTION_COLUMN, + SPACING, + RESPONSIVENESS, +} from '@opentrons/components' import { selectIsSelectedLwTipRack, @@ -9,6 +17,7 @@ import { OFFSET_KIND_DEFAULT, selectLwDisplayName, getFlexSlotNameOnly, + selectActivePipetteChannelCount, } from '/app/redux/protocol-runs' import { UnorderedList } from '/app/molecules/UnorderedList' import { DescriptionContent } from '/app/molecules/InterventionModal' @@ -23,6 +32,7 @@ import type { import type { State } from '/app/redux/types' import type { LPCWizardContentProps } from '/app/organisms/LabwarePositionCheck/types' import type { EditOffsetContentProps } from '/app/organisms/LabwarePositionCheck/steps/HandleLabware/EditOffset' +import { InlineNotification } from '/app/atoms/InlineNotification' export function PlaceItemInstruction( props: EditOffsetContentProps @@ -33,11 +43,14 @@ export function PlaceItemInstruction( const { protocolData } = useSelector( (state: State) => state.protocolRuns[runId]?.lpc as LPCWizardState ) + const isActivePipette96ch = + useSelector(selectActivePipetteChannelCount(runId)) === 96 const isLwTiprack = useSelector(selectIsSelectedLwTipRack(runId)) const selectedLwInfo = useSelector( selectSelectedLwOverview(runId) ) as SelectedLwOverview const offsetLocationDetails = selectedLwInfo.offsetLocationDetails as OffsetLocationDetails + const isDefaultOffset = offsetLocationDetails.kind === OFFSET_KIND_DEFAULT const buildHeader = (): string => t('prepare_item_in_location', { @@ -57,35 +70,52 @@ export function PlaceItemInstruction( ) as LabwareStackupDetail[] return ( - , - ...lwOnlyLocSeq.map((component, index) => ( - + - )), - ]} + />, + ...lwOnlyLocSeq.map((component, index) => ( + + )), + ]} + /> + } + /> + {isActivePipette96ch && isDefaultOffset && ( + - } - /> + )} + ) } +const CONATINER_STYLE = css` + flex-direction: ${DIRECTION_COLUMN}; + gap: ${SPACING.spacing12}; + + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + gap: ${SPACING.spacing24}; + } +` + interface PlaceItemInstructionContentProps extends LPCWizardContentProps { isLwTiprack: boolean slotOnlyDisplayLocation: string diff --git a/app/src/organisms/LabwarePositionCheck/steps/HandleLabware/EditOffset/PrepareLabware/__tests__/PlaceItemInstruction.test.tsx b/app/src/organisms/LabwarePositionCheck/steps/HandleLabware/EditOffset/PrepareLabware/__tests__/PlaceItemInstruction.test.tsx new file mode 100644 index 000000000000..0626a724ad1d --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/steps/HandleLabware/EditOffset/PrepareLabware/__tests__/PlaceItemInstruction.test.tsx @@ -0,0 +1,232 @@ +import { describe, expect, it, beforeEach, vi } from 'vitest' +import { screen } from '@testing-library/react' +import { useSelector } from 'react-redux' + +import { renderWithProviders } from '/app/__testing-utils__' +import { i18n } from '/app/i18n' +import { + selectIsSelectedLwTipRack, + selectSelectedLwOverview, + OFFSET_KIND_DEFAULT, + selectLwDisplayName, + getFlexSlotNameOnly, + selectActivePipetteChannelCount, + OFFSET_KIND_LOCATION_SPECIFIC, +} from '/app/redux/protocol-runs' +import { PlaceItemInstruction } from '../PlaceItemInstruction' + +vi.mock('react-redux', async () => { + const actual = await vi.importActual('react-redux') + return { + ...actual, + useSelector: vi.fn(), + } +}) +vi.mock('/app/redux/protocol-runs') + +describe('PlaceItemInstruction', () => { + const mockRunId = 'mock_run_id' + const mockProps = { + runId: mockRunId, + } + const mockSlotLocation = 'Slot C2' + const mockLwDisplayName = 'Mock Labware' + const mockTipRackDisplayName = 'Mock Tip Rack' + const mockLpcState = { + protocolData: {}, + } + + const mockTipRackStackup = { + offsetLocationDetails: { + kind: OFFSET_KIND_DEFAULT, + closestBeneathModuleModel: null, + lwModOnlyStackupDetails: [{ kind: 'labware', labwareUri: 'tiprack-uri' }], + }, + } + + const mockLabwareStackup = { + offsetLocationDetails: { + kind: OFFSET_KIND_DEFAULT, + closestBeneathModuleModel: null, + lwModOnlyStackupDetails: [{ kind: 'labware', labwareUri: 'labware-uri' }], + }, + } + + const mockLabwareWithModuleStackup = { + offsetLocationDetails: { + kind: OFFSET_KIND_LOCATION_SPECIFIC, + closestBeneathModuleModel: 'temperatureModule', + lwModOnlyStackupDetails: [ + { kind: 'module', moduleModel: 'temperatureModule' }, + { kind: 'labware', labwareUri: 'labware-uri' }, + ], + }, + } + + const render = () => { + // @ts-expect-error Not all props necessary for testing. + return renderWithProviders(, { + i18nInstance: i18n, + })[0] + } + + beforeEach(() => { + vi.clearAllMocks() + + vi.mocked(getFlexSlotNameOnly).mockReturnValue(mockSlotLocation) + vi.mocked(selectLwDisplayName).mockReturnValue(() => mockLwDisplayName) + + vi.mocked(selectIsSelectedLwTipRack).mockImplementation(() => () => false) + vi.mocked(selectSelectedLwOverview).mockImplementation(() => () => + mockLabwareStackup as any + ) + vi.mocked(selectActivePipetteChannelCount).mockImplementation(() => () => 8) + + vi.mocked(useSelector).mockImplementation(selector => { + return selector({ + protocolRuns: { + [mockRunId]: { + lpc: mockLpcState, + }, + }, + }) + }) + }) + + it('should render prepare labware instruction in slot location', () => { + render() + + screen.getByText(`Prepare labware in ${mockSlotLocation}`) + }) + + it('should render prepare tip rack instruction in slot location', () => { + vi.mocked(selectIsSelectedLwTipRack).mockImplementation(() => () => true) + vi.mocked(selectSelectedLwOverview).mockImplementation(() => () => + mockTipRackStackup as any + ) + + render() + + screen.getByText(`Prepare tip rack in ${mockSlotLocation}`) + }) + + it('should show clear deck instruction for default offsets', () => { + render() + + screen.getByText( + /Clear all deck slots of labware and remove any modules from/ + ) + }) + + it('should show clear deck but modules instruction for non-default offset with a module', () => { + vi.mocked(selectSelectedLwOverview).mockImplementation(() => () => + mockLabwareWithModuleStackup as any + ) + + render() + + screen.getByText( + 'Clear all deck slots of labware, leaving modules in place' + ) + }) + + it('should show a place labware instruction', () => { + render() + + const listItems = screen.getAllByRole('listitem') + const labwareItem = listItems.find( + li => + li.textContent?.includes('Place a') && + li.textContent.includes(mockLwDisplayName) && + li.textContent.includes(mockSlotLocation) + ) + + expect(labwareItem).toBeTruthy() + }) + + it('should show a place tip rack instruction', () => { + vi.mocked(selectIsSelectedLwTipRack).mockImplementation(() => () => true) + vi.mocked(selectSelectedLwOverview).mockImplementation(() => () => + mockTipRackStackup as any + ) + vi.mocked(selectLwDisplayName).mockReturnValue(() => mockTipRackDisplayName) + + render() + + const listItems = screen.getAllByRole('listitem') + const tipRackItem = listItems.find( + li => + li.textContent?.includes('Place') && + li.textContent.includes('full') && + li.textContent.includes(mockTipRackDisplayName) && + li.textContent.includes(mockSlotLocation) + ) + expect(tipRackItem).toBeTruthy() + }) + + it('should show inline notification for 96-channel pipette when calibrating a default offset', () => { + vi.mocked(selectIsSelectedLwTipRack).mockImplementation(() => () => true) + vi.mocked(selectSelectedLwOverview).mockImplementation(() => () => + mockTipRackStackup as any + ) + vi.mocked(selectActivePipetteChannelCount).mockImplementation(() => () => + 96 + ) + + render() + + screen.getByText( + 'Ensure the tip rack is accurately placed in the slot as outlined above to prevent damage to your labware.' + ) + }) + + it('should not show inline notification for other pipettes when calibrating a default offset', () => { + render() + + expect( + screen.queryByText( + 'Ensure the tip rack is accurately placed in the slot as outlined above to prevent damage to your labware.' + ) + ).not.toBeInTheDocument() + }) + + it('should show next place labware instruction for the second item in a stackup', () => { + const multiItemStackup = { + offsetLocationDetails: { + kind: OFFSET_KIND_DEFAULT, + closestBeneathModuleModel: null, + lwModOnlyStackupDetails: [ + { kind: 'labware', labwareUri: 'labware-uri-1' }, + { kind: 'labware', labwareUri: 'labware-uri-2' }, + ], + }, + } as any + + vi.mocked(selectSelectedLwOverview).mockImplementation(() => () => + multiItemStackup + ) + vi.mocked(selectLwDisplayName).mockImplementation((runId, uri) => { + return () => + uri === 'labware-uri-1' ? 'First Labware' : 'Second Labware' + }) + + render() + + const listItems = screen.getAllByRole('listitem') + + const firstLabwareItem = listItems.find( + li => + li.textContent?.includes('Place a') && + li.textContent?.includes('First Labware') + ) + + const secondLabwareItem = listItems.find( + li => + li.textContent?.includes('Next, place a') && + li.textContent?.includes('Second Labware') + ) + + expect(firstLabwareItem).toBeTruthy() + expect(secondLabwareItem).toBeTruthy() + }) +}) diff --git a/app/src/organisms/LabwarePositionCheck/steps/HandleLabware/LPCLabwareList.tsx b/app/src/organisms/LabwarePositionCheck/steps/HandleLabware/LPCLabwareList.tsx index 29e821a8432c..7534b3e29488 100644 --- a/app/src/organisms/LabwarePositionCheck/steps/HandleLabware/LPCLabwareList.tsx +++ b/app/src/organisms/LabwarePositionCheck/steps/HandleLabware/LPCLabwareList.tsx @@ -18,6 +18,7 @@ import { DIRECTION_ROW, DISPLAY_FLEX, RadioButton, + NO_WRAP, } from '@opentrons/components' import { @@ -208,6 +209,7 @@ const TEXT_CONTAINER_STYLE = css` const SUBTEXT_STYLE = css` color: ${COLORS.grey60}; + text-wrap: ${NO_WRAP}; ` const ICON_STYLE = css` diff --git a/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLabware/LabwareMapView.tsx b/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLabware/LabwareMapView.tsx index 436d844fc4cc..fe9a5fd08987 100644 --- a/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLabware/LabwareMapView.tsx +++ b/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupLabware/LabwareMapView.tsx @@ -39,12 +39,17 @@ export function LabwareMapView(props: LabwareMapViewProps): JSX.Element { const allDefinitions = getAllDefinitions() const modulesOnDeck = attachedProtocolModuleMatches.map(module => { const { moduleDef, slotName } = module - const stackOnModule = Object.entries(startingDeck).find(([key, value]) => + const slotAndStackOnModule = Object.entries( + startingDeck + ).find(([key, value]) => value.some( (stackItem): stackItem is ModuleInStack => 'moduleId' in stackItem && stackItem.moduleId === module.moduleId ) - )?.[1] + ) + const slotDisplayName = slotAndStackOnModule?.[0] + const stackOnModule = slotAndStackOnModule?.[1] + const topLabwareInfo = stackOnModule != null ? stackOnModule[0] : null const topLabwareDefinition = topLabwareInfo != null && 'labwareId' in topLabwareInfo @@ -70,7 +75,7 @@ export function LabwareMapView(props: LabwareMapViewProps): JSX.Element { nestedLabwareDef: topLabwareDefinition, nestedLabwareWellFill: wellFill, onLabwareClick: () => { - handleLabwareClick([slotName, stackOnModule ?? []]) + handleLabwareClick([slotDisplayName ?? slotName, stackOnModule ?? []]) }, highlightLabware: true, moduleChildren: null, diff --git a/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupOffsets/SetupOffsetsHeader.tsx b/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupOffsets/SetupOffsetsHeader.tsx index 4daf993143d5..09d705f23784 100644 --- a/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupOffsets/SetupOffsetsHeader.tsx +++ b/app/src/organisms/ODD/ProtocolSetup/ProtocolSetupOffsets/SetupOffsetsHeader.tsx @@ -18,6 +18,7 @@ import { } from '/app/redux/protocol-runs' import { useAddLabwareOffsetToRunMutation } from '@opentrons/react-api-client' import { useState } from 'react' +import { useUpdateClientLPC } from '/app/resources/client_data' export function SetupOffsetsHeader({ runId, @@ -32,6 +33,7 @@ export function SetupOffsetsHeader({ selectIsAnyNecessaryDefaultOffsetMissing(runId) ) const lwOffsetsForRun = useSelector(selectLabwareOffsetsToAddToRun(runId)) + const { updateWithRunId } = useUpdateClientLPC() const [isApplyOffsets, setIsApplyingOffsets] = useState(false) @@ -48,6 +50,7 @@ export function SetupOffsetsHeader({ ) .then(() => { dispatch(appliedOffsetsToRun(runId)) + updateWithRunId(runId) setSetupScreen('prepare to run') }) .catch(() => { diff --git a/app/src/pages/ODD/RunSummary/index.tsx b/app/src/pages/ODD/RunSummary/index.tsx index 57c6ffe96ee4..570bfb01b56e 100644 --- a/app/src/pages/ODD/RunSummary/index.tsx +++ b/app/src/pages/ODD/RunSummary/index.tsx @@ -245,7 +245,10 @@ export function RunSummary(): JSX.Element { useEffect(() => { // Only run tip checking if it wasn't *just* handled during Error Recovery. - if (!lastRunCommandPromptedErrorRecovery(runSummaryNoFixit, isEREnabled)) { + if ( + runSummaryNoFixit != null && + !lastRunCommandPromptedErrorRecovery(runSummaryNoFixit, isEREnabled) + ) { void determineTipStatus() } }, [isRunCurrent, runSummaryNoFixit, isEREnabled]) diff --git a/app/src/redux/protocol-runs/actions/lpc.ts b/app/src/redux/protocol-runs/actions/lpc.ts index c69b97a00f77..ae045f529e14 100644 --- a/app/src/redux/protocol-runs/actions/lpc.ts +++ b/app/src/redux/protocol-runs/actions/lpc.ts @@ -47,8 +47,8 @@ import type { UpdateLPCDeckAction, LPCLabwareInfo, UpdateLPCLabwareAction, + SavedOffsets, } from '../types' -import type { StoredLabwareOffset } from '@opentrons/api-client' import type { DeckConfiguration } from '@opentrons/shared-data' export const proceedStep = ( @@ -123,7 +123,7 @@ export const clearSelectedLabwareWorkingOffsets = ( export const applyWorkingOffsets = ( runId: string, - saveResult: StoredLabwareOffset[] + saveResult: SavedOffsets ): ApplyWorkingOffsetsAction => ({ type: APPLY_WORKING_OFFSETS, payload: { runId, saveResult }, diff --git a/app/src/redux/protocol-runs/reducer/transforms/lpc/handleApplyWorkingOffsets.ts b/app/src/redux/protocol-runs/reducer/transforms/lpc/handleApplyWorkingOffsets.ts index 31a31217d02b..662a367bb9c4 100644 --- a/app/src/redux/protocol-runs/reducer/transforms/lpc/handleApplyWorkingOffsets.ts +++ b/app/src/redux/protocol-runs/reducer/transforms/lpc/handleApplyWorkingOffsets.ts @@ -4,80 +4,161 @@ import { ANY_LOCATION } from '@opentrons/api-client' import type { ApplyWorkingOffsetsAction, + LocationSpecificOffsetDetails, LPCLabwareInfo, LPCWizardState, + LwGeometryDetails, } from '/app/redux/protocol-runs' +import type { + StoredLabwareOffset, + VectorOffset, + LabwareOffsetLocationSequenceComponent, +} from '@opentrons/api-client' -// Apply any working offsets to make them the new existing offsets. export function handleApplyWorkingOffsets( state: LPCWizardState, action: ApplyWorkingOffsetsAction ): LPCLabwareInfo['labware'] { const { saveResult } = action.payload + const [updatedOffsets, deletedOffsets] = saveResult return Object.entries(state.labwareInfo.labware).reduce< LPCLabwareInfo['labware'] >((acc, [definitionUri, details]) => { - const updatedDetails = { ...details } + let updatedDetails = { ...details } - // Find if this labware has any updates from the saveResult - const updates = saveResult.filter( - updatedLw => updatedLw.definitionUri === definitionUri + // Find offset updates. + const updates = updatedOffsets.filter( + offset => offset.definitionUri === definitionUri ) - // If no updates for this labware, just keep it as is. - if (updates.length === 0) { - acc[definitionUri] = updatedDetails - return acc + // Process offset updates, if any. + if (updates.length > 0) { + updatedDetails = processOffsetUpdates(updatedDetails, updates) } - // Else, process updates for this labware. - updates.forEach(updatedLw => { - const { vector, id, createdAt, locationSequence } = updatedLw - - // Process default offset. - if (updatedLw.locationSequence === ANY_LOCATION) { - updatedDetails.defaultOffsetDetails = { - ...updatedDetails.defaultOffsetDetails, - workingOffset: null, - existingOffset: { vector, id, createdAt }, - locationDetails: { - ...updatedDetails.defaultOffsetDetails.locationDetails, - }, - } - } - // Process location-specific offsets. - else { - const lsDetails = updatedDetails.locationSpecificOffsetDetails - const matchIndex = lsDetails.findIndex(lsDetail => - isEqual(lsDetail.locationDetails.lwOffsetLocSeq, locationSequence) - ) - - if (matchIndex === -1) { - console.error( - 'Expected to find matching location sequence for server-saved offset but did not.' - ) - } else { - const nonMatchingDetails = [ - ...lsDetails.slice(0, matchIndex), - ...lsDetails.slice(matchIndex + 1), - ] - - const updatedMatchingDetail = { - ...lsDetails[matchIndex], - workingOffset: null, - existingOffset: { vector, id, createdAt }, - } - - updatedDetails.locationSpecificOffsetDetails = [ - ...nonMatchingDetails, - updatedMatchingDetail, - ] - } - } - }) + // Find offset deletions. + const deletions = deletedOffsets.filter( + offset => offset.definitionUri === definitionUri + ) + + // Process offset deletions, if any. + if (deletions.length > 0) { + updatedDetails = processOffsetDeletions(updatedDetails, deletions) + } acc[definitionUri] = updatedDetails return acc }, {}) } + +function processOffsetUpdates( + updatedDetails: LwGeometryDetails, + updatedOffsets: StoredLabwareOffset[] +): LwGeometryDetails { + updatedOffsets.forEach(updatedOffset => { + const { vector, id, createdAt, locationSequence } = updatedOffset + const offsetData = { vector, id, createdAt } + + if (locationSequence === ANY_LOCATION) { + updatedDetails = updateDefaultOffset(updatedDetails, updatedOffset) + } else { + updatedDetails = updateLocationSpecificOffset( + updatedDetails, + updatedOffset, + offsetData + ) + } + }) + + return updatedDetails +} + +function processOffsetDeletions( + updatedDetails: LwGeometryDetails, + deletions: StoredLabwareOffset[] +): LwGeometryDetails { + // There is currently no support for deleting a default offset. + deletions.forEach(deletion => { + updatedDetails = updateLocationSpecificOffset( + updatedDetails, + deletion, + null + ) + }) + + return updatedDetails +} + +function updateDefaultOffset( + details: LwGeometryDetails, + offset: StoredLabwareOffset +): LwGeometryDetails { + const { vector, id, createdAt } = offset + + return { + ...details, + defaultOffsetDetails: { + ...details.defaultOffsetDetails, + workingOffset: null, + existingOffset: { vector, id, createdAt }, + locationDetails: { + ...details.defaultOffsetDetails.locationDetails, + }, + }, + } +} + +interface OffsetData { + vector: VectorOffset + id: string + createdAt: string +} + +function updateLocationSpecificOffset( + details: LwGeometryDetails, + offset: StoredLabwareOffset, + existingOffset: OffsetData | null +): LwGeometryDetails { + const { locationSequence } = offset + const lsDetails = details.locationSpecificOffsetDetails + const matchIndex = findMatchingLocationOffset( + lsDetails, + locationSequence as LabwareOffsetLocationSequenceComponent[] + ) + + if (matchIndex === -1) { + console.error( + 'Expected to find matching location sequence for offset but did not.' + ) + return details + } + + const nonMatchingDetails = [ + ...lsDetails.slice(0, matchIndex), + ...lsDetails.slice(matchIndex + 1), + ] + + const updatedMatchingDetail = { + ...lsDetails[matchIndex], + workingOffset: null, + existingOffset, + } + + return { + ...details, + locationSpecificOffsetDetails: [ + ...nonMatchingDetails, + updatedMatchingDetail, + ], + } +} + +function findMatchingLocationOffset( + locationOffsets: LocationSpecificOffsetDetails[], + locationSequence: LabwareOffsetLocationSequenceComponent[] +): number { + return locationOffsets.findIndex(detail => + isEqual(detail.locationDetails.lwOffsetLocSeq, locationSequence) + ) +} diff --git a/app/src/redux/protocol-runs/types/lpc/actions.ts b/app/src/redux/protocol-runs/types/lpc/actions.ts index eb8996539896..1e0a7c1e5fe0 100644 --- a/app/src/redux/protocol-runs/types/lpc/actions.ts +++ b/app/src/redux/protocol-runs/types/lpc/actions.ts @@ -5,8 +5,9 @@ import type { LPCStep, LPCWizardState, OffsetLocationDetails, + SavedOffsets, } from '/app/redux/protocol-runs/types/lpc' -import type { StoredLabwareOffset, VectorOffset } from '@opentrons/api-client' +import type { VectorOffset } from '@opentrons/api-client' import type { DeckConfiguration } from '@opentrons/shared-data' export interface PositionParams { @@ -88,7 +89,7 @@ export interface ResetLocationSpecificOffsetToDefaultAction { export interface ApplyWorkingOffsetsAction { type: 'APPLY_WORKING_OFFSETS' - payload: { runId: string; saveResult: StoredLabwareOffset[] } + payload: { runId: string; saveResult: SavedOffsets } } export interface ProceedHandleLwSubstepAction { diff --git a/app/src/redux/protocol-runs/types/lpc/offsets.ts b/app/src/redux/protocol-runs/types/lpc/offsets.ts index 7695454a254e..bd9d8eedc2a0 100644 --- a/app/src/redux/protocol-runs/types/lpc/offsets.ts +++ b/app/src/redux/protocol-runs/types/lpc/offsets.ts @@ -1,6 +1,7 @@ import type { ANY_LOCATION, LabwareOffsetLocationSequence, + StoredLabwareOffset, VectorOffset, } from '@opentrons/api-client' import type { @@ -122,3 +123,5 @@ interface WorkingBaseOffset { finalPosition: VectorOffset | null confirmedVector: VectorOffset | 'RESET_TO_DEFAULT' | null } + +export type SavedOffsets = [StoredLabwareOffset[], StoredLabwareOffset[]] // tuple of [updatedOffsets, deletedOffsets] diff --git a/app/src/resources/runs/__tests__/useCloneRun.test.tsx b/app/src/resources/runs/__tests__/useCloneRun.test.tsx index cf9de675f67f..87971872edeb 100644 --- a/app/src/resources/runs/__tests__/useCloneRun.test.tsx +++ b/app/src/resources/runs/__tests__/useCloneRun.test.tsx @@ -13,7 +13,7 @@ import { useCloneRun } from '../useCloneRun' import { useNotifyRunQuery } from '../useNotifyRunQuery' import type { FunctionComponent, ReactNode } from 'react' -import type { HostConfig } from '@opentrons/api-client' +import type { HostConfig, LabwareOffset } from '@opentrons/api-client' vi.mock('@opentrons/react-api-client') vi.mock('/app/resources/runs/useNotifyRunQuery') @@ -21,6 +21,7 @@ vi.mock('/app/resources/runs/useNotifyRunQuery') const HOST_CONFIG: HostConfig = { hostname: 'localhost' } const RUN_ID_NO_RTP: string = 'run_id_no_rtp' const RUN_ID_RTP: string = 'run_id_rtp' +const RUN_ID_DUPLICATE_OFFSETS: string = 'run_id_duplicate_offsets' describe('useCloneRun hook', () => { let wrapper: FunctionComponent<{ children: ReactNode }> @@ -34,7 +35,13 @@ describe('useCloneRun hook', () => { data: { id: RUN_ID_NO_RTP, protocolId: 'protocolId', - labwareOffsets: 'someOffset', + labwareOffsets: [ + { + definitionUri: 'uri1', + locationSequence: [1, 2], + offset: { x: 1, y: 1, z: 1 }, + }, + ], runTimeParameters: [ { type: 'int', @@ -59,7 +66,13 @@ describe('useCloneRun hook', () => { data: { id: RUN_ID_RTP, protocolId: 'protocolId', - labwareOffsets: 'someOffset', + labwareOffsets: [ + { + definitionUri: 'uri1', + locationSequence: [1, 2], + offset: { x: 1, y: 1, z: 1 }, + }, + ], runTimeParameters: [ { type: 'int', @@ -82,6 +95,38 @@ describe('useCloneRun hook', () => { }, }, } as any) + + const duplicateOffsets: LabwareOffset[] = [ + { + definitionUri: 'uri1', + locationSequence: [1, 2], + offset: { x: 1, y: 1, z: 1 }, + }, + { + definitionUri: 'uri2', + locationSequence: [3, 4], + offset: { x: 2, y: 2, z: 2 }, + }, + { + definitionUri: 'uri1', + locationSequence: [1, 2], + offset: { x: 3, y: 3, z: 3 }, + }, + ] as any + + when(vi.mocked(useNotifyRunQuery)) + .calledWith(RUN_ID_DUPLICATE_OFFSETS) + .thenReturn({ + data: { + data: { + id: RUN_ID_DUPLICATE_OFFSETS, + protocolId: 'protocolId', + labwareOffsets: duplicateOffsets, + runTimeParameters: [], + }, + }, + } as any) + when(vi.mocked(useCreateRunMutation)) .calledWith(expect.anything()) .thenReturn({ createRun: vi.fn() } as any) @@ -111,11 +156,18 @@ describe('useCloneRun hook', () => { result.current && result.current.cloneRun() expect(mockCreateRun).toHaveBeenCalledWith({ protocolId: 'protocolId', - labwareOffsets: 'someOffset', + labwareOffsets: [ + { + definitionUri: 'uri1', + locationSequence: [1, 2], + offset: { x: 1, y: 1, z: 1 }, + }, + ], runTimeParameterValues: {}, runTimeParameterFiles: {}, }) }) + it('should return a function that when called, calls createRun run with runTimeParameterValues overrides', async () => { const mockCreateRun = vi.fn() vi.mocked(useCreateRunMutation).mockReturnValue({ @@ -126,7 +178,74 @@ describe('useCloneRun hook', () => { result.current && result.current.cloneRun() expect(mockCreateRun).toHaveBeenCalledWith({ protocolId: 'protocolId', - labwareOffsets: 'someOffset', + labwareOffsets: [ + { + definitionUri: 'uri1', + locationSequence: [1, 2], + offset: { x: 1, y: 1, z: 1 }, + }, + ], + runTimeParameterValues: { + number_param: 2, + boolean_param: false, + }, + runTimeParameterFiles: { + file_param: 'fileId_123', + }, + }) + }) + + it('should filter duplicate labware offsets and keep only the most recent ones', async () => { + const mockCreateRun = vi.fn() + vi.mocked(useCreateRunMutation).mockReturnValue({ + createRun: mockCreateRun, + } as any) + + const { result } = renderHook(() => useCloneRun(RUN_ID_DUPLICATE_OFFSETS), { + wrapper, + }) + result.current && result.current.cloneRun() + + const expectedOffsets = [ + { + definitionUri: 'uri2', + locationSequence: [3, 4], + offset: { x: 2, y: 2, z: 2 }, + }, + { + definitionUri: 'uri1', + locationSequence: [1, 2], + offset: { x: 3, y: 3, z: 3 }, + }, + ] + + expect(mockCreateRun).toHaveBeenCalledWith({ + protocolId: 'protocolId', + labwareOffsets: expectedOffsets, + runTimeParameterValues: {}, + runTimeParameterFiles: {}, + }) + }) + + it('should handle analysis trigger when specified', async () => { + const mockCreateRun = vi.fn() + const mockCreateProtocolAnalysis = vi.fn() + + vi.mocked(useCreateRunMutation).mockReturnValue({ + createRun: mockCreateRun, + } as any) + vi.mocked(useCreateProtocolAnalysisMutation).mockReturnValue({ + createProtocolAnalysis: mockCreateProtocolAnalysis, + } as any) + + const { result } = renderHook( + () => useCloneRun(RUN_ID_RTP, undefined, true), + { wrapper } + ) + result.current && result.current.cloneRun() + + expect(mockCreateProtocolAnalysis).toHaveBeenCalledWith({ + protocolKey: 'protocolId', runTimeParameterValues: { number_param: 2, boolean_param: false, diff --git a/app/src/resources/runs/__tests__/useLPCDisabledReason.test.tsx b/app/src/resources/runs/__tests__/useLPCDisabledReason.test.tsx index c913de76ec8b..de3f9920e6bf 100644 --- a/app/src/resources/runs/__tests__/useLPCDisabledReason.test.tsx +++ b/app/src/resources/runs/__tests__/useLPCDisabledReason.test.tsx @@ -18,6 +18,10 @@ import { useRunCalibrationStatus } from '../useRunCalibrationStatus' import { useMostRecentCompletedAnalysis } from '../useMostRecentCompletedAnalysis' import { useRunHasStarted } from '../useRunHasStarted' import { useIsFlex } from '/app/redux-resources/robots' +import { + getIsFixtureMismatch, + useDeckConfigurationCompatibility, +} from '/app/resources/deck_configuration' import type { FunctionComponent, ReactNode } from 'react' import type { Store } from 'redux' @@ -30,6 +34,7 @@ vi.mock('../useMostRecentCompletedAnalysis') vi.mock('../useRunHasStarted') vi.mock('/app/resources/analysis') vi.mock('/app/redux-resources/robots') +vi.mock('/app/resources/deck_configuration') vi.mock('@opentrons/shared-data', async importOriginal => { const actualSharedData = await importOriginal() return { @@ -67,6 +72,8 @@ describe('useLPCDisabledReason', () => { _uncastedSimpleV6Protocol.labwareDefinitions as {} ) vi.mocked(useIsFlex).mockReturnValue(false) + vi.mocked(useDeckConfigurationCompatibility).mockReturnValue({} as any) + vi.mocked(getIsFixtureMismatch).mockReturnValue(false) }) afterEach(() => { vi.resetAllMocks() @@ -165,6 +172,16 @@ describe('useLPCDisabledReason', () => { 'Make sure all modules are connected before running Labware Position Check' ) }) + it('renders disabled reason for fixture mismatch', () => { + vi.mocked(getIsFixtureMismatch).mockReturnValue(true) + const { result } = renderHook( + () => useLPCDisabledReason({ robotName: 'otie', runId: RUN_ID_1 }), + { wrapper } + ) + expect(result.current).toStrictEqual( + 'Make sure all modules are connected before running Labware Position Check' + ) + }) it('renders disabled reason for run has started for odd', () => { vi.mocked(useRunHasStarted).mockReturnValue(true) @@ -327,4 +344,31 @@ describe('useLPCDisabledReason', () => { ) expect(result.current).toStrictEqual(null) }) + it('handles deck configuration compatibility check for OT-2', () => { + vi.mocked(useIsFlex).mockReturnValue(false) + vi.mocked(useDeckConfigurationCompatibility).mockReturnValue({} as any) + + vi.mocked(getIsFixtureMismatch).mockReturnValue(true) + + const { result } = renderHook( + () => useLPCDisabledReason({ robotName: 'otie', runId: RUN_ID_1 }), + { wrapper } + ) + expect(result.current).toStrictEqual( + 'Make sure all modules are connected before running Labware Position Check' + ) + }) + it('handles deck configuration compatibility check for Flex', () => { + vi.mocked(useIsFlex).mockReturnValue(true) + vi.mocked(useDeckConfigurationCompatibility).mockReturnValue({} as any) + vi.mocked(getIsFixtureMismatch).mockReturnValue(true) + + const { result } = renderHook( + () => useLPCDisabledReason({ robotName: 'flexie', runId: RUN_ID_1 }), + { wrapper } + ) + expect(result.current).toStrictEqual( + 'Make sure all modules are connected before running Labware Position Check' + ) + }) }) diff --git a/app/src/resources/runs/useCloneRun.ts b/app/src/resources/runs/useCloneRun.ts index 45f3a7a2c673..5dd3fdc9a4c6 100644 --- a/app/src/resources/runs/useCloneRun.ts +++ b/app/src/resources/runs/useCloneRun.ts @@ -11,7 +11,8 @@ import { getRunTimeParameterFilesForRun, } from '/app/transformations/runs' -import type { Run } from '@opentrons/api-client' +import type { LabwareOffset, Run } from '@opentrons/api-client' +import isEqual from 'lodash/isEqual' interface UseCloneRunResult { cloneRun: () => void @@ -70,7 +71,7 @@ export function useCloneRun( } createRun({ protocolId, - labwareOffsets, + labwareOffsets: mostRecentUniqueLabwareOffsets(labwareOffsets), runTimeParameterValues, runTimeParameterFiles, }) @@ -81,3 +82,19 @@ export function useCloneRun( return { cloneRun, isLoadingRun, isCloning } } + +// Returns the most recent, unique offsets for each labware uri + location pair. +// Assumes the most recent labware offsets are appended to the end of the list. +function mostRecentUniqueLabwareOffsets( + offsets: LabwareOffset[] | undefined +): LabwareOffset[] | undefined { + return offsets?.filter((offset, index, array) => { + return ( + array.findLastIndex( + firstOffset => + isEqual(firstOffset.locationSequence, offset.locationSequence) && + isEqual(firstOffset.definitionUri, offset.definitionUri) + ) === index + ) + }) +} diff --git a/app/src/resources/runs/useLPCDisabledReason.tsx b/app/src/resources/runs/useLPCDisabledReason.tsx index 16414111de9d..b9d118fcb3c1 100644 --- a/app/src/resources/runs/useLPCDisabledReason.tsx +++ b/app/src/resources/runs/useLPCDisabledReason.tsx @@ -1,13 +1,21 @@ import isEmpty from 'lodash/isEmpty' import some from 'lodash/some' import { useTranslation } from 'react-i18next' -import { getLoadedLabwareDefinitionsByUri } from '@opentrons/shared-data' +import { + FLEX_ROBOT_TYPE, + getLoadedLabwareDefinitionsByUri, + OT2_ROBOT_TYPE, +} from '@opentrons/shared-data' import { useStoredProtocolAnalysis } from '/app/resources/analysis' import { useMostRecentCompletedAnalysis } from './useMostRecentCompletedAnalysis' import { useRunCalibrationStatus } from './useRunCalibrationStatus' import { useRunHasStarted } from './useRunHasStarted' import { useUnmatchedModulesForProtocol } from './useUnmatchedModulesForProtocol' import { useIsFlex } from '/app/redux-resources/robots' +import { + getIsFixtureMismatch, + useDeckConfigurationCompatibility, +} from '/app/resources/deck_configuration' interface LPCDisabledReasonProps { runId: string @@ -39,10 +47,16 @@ export function useLPCDisabledReason( const robotProtocolAnalysis = useMostRecentCompletedAnalysis(runId) const storedProtocolAnalysis = useStoredProtocolAnalysis(runId) const protocolData = robotProtocolAnalysis ?? storedProtocolAnalysis + const deckConfigCompatibility = useDeckConfigurationCompatibility( + isFlex ? FLEX_ROBOT_TYPE : OT2_ROBOT_TYPE, + robotProtocolAnalysis + ) + const isFixtureMismatch = getIsFixtureMismatch(deckConfigCompatibility) const hasMissingModules = hasMissingModulesForOdd ?? missingModuleIds.length > 0 const calibrationIncomplete = !hasMissingModules && !isCalibrationComplete - const moduleSetupIncomplete = hasMissingModules && isCalibrationComplete + const moduleSetupIncomplete = + (hasMissingModules || isFixtureMismatch) && isCalibrationComplete const moduleAndCalibrationIncomplete = hasMissingModules && !isCalibrationComplete const labwareDefinitions = diff --git a/components/src/organisms/CommandText/useCommandTextString/utils/getLabwareLocation.tsx b/components/src/organisms/CommandText/useCommandTextString/utils/getLabwareLocation.tsx index 8e51d58c9f08..8d2356a1767c 100644 --- a/components/src/organisms/CommandText/useCommandTextString/utils/getLabwareLocation.tsx +++ b/components/src/organisms/CommandText/useCommandTextString/utils/getLabwareLocation.tsx @@ -80,7 +80,10 @@ export function getLabwareLocationFromSequence( ...acc, slotName: sequenceItem.logicalLocationName, } - } else if (sequenceItem.kind === 'onCutoutFixture') { + } else if ( + sequenceItem.kind === 'onCutoutFixture' && + acc.slotName == null + ) { return { ...acc, slotName: getCutoutDisplayName(sequenceItem.cutoutId as CutoutId), @@ -98,10 +101,7 @@ export function getLabwareLocationFromSequence( ...acc, slotName: sequenceItem.addressableAreaName, } - } else if ( - sequenceItem.kind === 'onAddressableArea' && - index === locationSequence.length - 1 - ) { + } else if (sequenceItem.kind === 'onAddressableArea') { const slotName = getSlotFromAddressableAreaName( sequenceItem.addressableAreaName as AddressableAreaName ) diff --git a/hardware/opentrons_hardware/drivers/can_bus/can_messenger.py b/hardware/opentrons_hardware/drivers/can_bus/can_messenger.py index c9adc2fc9b9d..8e1ddc4408cc 100644 --- a/hardware/opentrons_hardware/drivers/can_bus/can_messenger.py +++ b/hardware/opentrons_hardware/drivers/can_bus/can_messenger.py @@ -348,7 +348,11 @@ def add_listener( listener: MessageListenerCallback, filter: Optional[MessageListenerCallbackFilter] = None, ) -> None: - """Add a message listener.""" + """Add a message listener. + + Will not leak listener objects if called multiple times with the same-by-identity + function; it will overwrite the old entry each time in that case. + """ self._listeners[listener] = listener, filter def remove_listener(self, listener: MessageListenerCallback) -> None: diff --git a/hardware/opentrons_hardware/hardware_control/network.py b/hardware/opentrons_hardware/hardware_control/network.py index 406dae2df43c..aaf292dd0284 100644 --- a/hardware/opentrons_hardware/hardware_control/network.py +++ b/hardware/opentrons_hardware/hardware_control/network.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from itertools import chain import logging -from typing import Any, Dict, Set, Optional, Union, cast, Iterable, Tuple, List +from typing import Any, Dict, Set, Optional, Union, cast, Iterable, Tuple from .types import PCBARevision from opentrons_hardware.firmware_bindings import ArbitrationId from opentrons_hardware.firmware_bindings.constants import ( @@ -509,33 +509,31 @@ def _parse_can_device_info_response( return None -async def log_motor_usage_data( - can_messenger: CanMessenger, nodes: List[NodeId] -) -> None: - """Broadcasts a message to get motor usage request and waits for a list of expected nodes.""" - event = asyncio.Event() +def _listener(message: MessageDefinition, arb_id: ArbitrationId) -> None: + if isinstance(message, GetMotorUsageResponse): + usage_elements = message.payload.usage_elements + node = arb_id.parts.originating_node_id + logline = f"Usage from {node}: " + for m in usage_elements: + data_name = MotorUsageValueType(m.key).name + data_value = m.usage_value + logline += f"\n {data_name}: {data_value}" + log.info(logline) + + +async def log_motor_usage_data(can_messenger: CanMessenger) -> None: + """Broadcasts a message to get motor usage request and installs a listener to log responses. + + This is really only intended to make sure that usage data gets logged; it will return before + responses get sent and shouldn't be relied upon beyond logging. + """ + log.info(f"Getting usage data using listener {_listener}") def _filter(arb_id: ArbitrationId) -> bool: return MessageId(arb_id.parts.message_id) == MessageId.get_motor_usage_response - def _listener(message: MessageDefinition, arb_id: ArbitrationId) -> None: - if isinstance(message, GetMotorUsageResponse): - usage_elements = message.payload.usage_elements - node = arb_id.parts.originating_node_id - log.info(f"Usage from {node}: ") - for m in usage_elements: - data_name = MotorUsageValueType(m.key).name - data_value = m.usage_value - log.info(f" {data_name}: {data_value}") - nodes.remove(node) - if len(nodes) == 0: - event.set() - + # Note: this adds but does not remove a listener. this is safe asl ong as add_listener + # is implemented with a dictionary keyed on function object identity, because this + # function should be stable. It will not be okay if we ever change that. can_messenger.add_listener(_listener, _filter) await can_messenger.send(node_id=NodeId.broadcast, message=GetMotorUsageRequest()) - try: - await asyncio.wait_for(event.wait(), 1.0) - except TimeoutError: - log.error(f"Receiving usage data failed with {nodes} remaining") - finally: - can_messenger.remove_listener(_listener) diff --git a/labware-library/src/components/labware-ui/labware-images.ts b/labware-library/src/components/labware-ui/labware-images.ts index 384dcfa0ad16..cbe30e0e5b2d 100644 --- a/labware-library/src/components/labware-ui/labware-images.ts +++ b/labware-library/src/components/labware-ui/labware-images.ts @@ -477,4 +477,12 @@ export const labwareImages: Record = { opentrons_flex_deck_riser: [ new URL('../../images/opentrons_flex_deck_riser.png', import.meta.url).href, ], + evotip_flex_tall_adapter: [ + new URL('../../images/opentrons_evotip_tall_adapter.png', import.meta.url) + .href, + ], + evotip_flex_short_adapter: [ + new URL('../../images/opentrons_evotip_short_adapter.png', import.meta.url) + .href, + ], } diff --git a/labware-library/src/images/opentrons_evotip_short_adapter.png b/labware-library/src/images/opentrons_evotip_short_adapter.png new file mode 100644 index 000000000000..88cd791a8f7c Binary files /dev/null and b/labware-library/src/images/opentrons_evotip_short_adapter.png differ diff --git a/labware-library/src/images/opentrons_evotip_tall_adapter.png b/labware-library/src/images/opentrons_evotip_tall_adapter.png new file mode 100644 index 000000000000..06fe13c3f7c4 Binary files /dev/null and b/labware-library/src/images/opentrons_evotip_tall_adapter.png differ diff --git a/robot-server/robot_server/app_setup.py b/robot-server/robot_server/app_setup.py index 265a62700338..efc3d2c110b6 100644 --- a/robot-server/robot_server/app_setup.py +++ b/robot-server/robot_server/app_setup.py @@ -6,6 +6,8 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from server_utils.fastapi_utils.server_timing_middleware import server_timing_middleware + from opentrons import __version__ from .errors.exception_handlers import exception_handlers @@ -104,7 +106,6 @@ async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]: lifespan=_lifespan, ) -# cors app.add_middleware( CORSMiddleware, allow_origins=("*"), @@ -113,6 +114,8 @@ async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]: allow_headers=["*"], ) +app.middleware("http")(server_timing_middleware()) + # main router router.install_on_app(app) diff --git a/robot-server/robot_server/labware_offsets/router.py b/robot-server/robot_server/labware_offsets/router.py index 58e6a0e1de58..eed7a78a2053 100644 --- a/robot-server/robot_server/labware_offsets/router.py +++ b/robot-server/robot_server/labware_offsets/router.py @@ -79,8 +79,7 @@ async def post_labware_offsets( # noqa: D103 ) ] - for new_offset in new_offsets: - store.add(new_offset) + store.add(new_offsets) stored_offsets = [ StoredLabwareOffset.model_construct( diff --git a/robot-server/robot_server/labware_offsets/store.py b/robot-server/robot_server/labware_offsets/store.py index 3b8b990e54a1..4ffb20f7d73e 100644 --- a/robot-server/robot_server/labware_offsets/store.py +++ b/robot-server/robot_server/labware_offsets/store.py @@ -68,25 +68,26 @@ def __init__( def add( self, - offset: IncomingStoredLabwareOffset, + offsets: list[IncomingStoredLabwareOffset], ) -> None: - """Store a new labware offset.""" + """Store new labware offsets.""" with self._sql_engine.begin() as transaction: - offset_row_id = transaction.execute( - sqlalchemy.insert(labware_offset_table).values( - _pydantic_to_sql_offset(offset) - ) - ).inserted_primary_key.row_id - location_components_to_insert = list( - _pydantic_to_sql_location_sequence_iterator(offset, offset_row_id) - ) - if location_components_to_insert: - transaction.execute( - sqlalchemy.insert( - labware_offset_location_sequence_components_table - ), - location_components_to_insert, + for offset in offsets: + offset_row_id = transaction.execute( + sqlalchemy.insert(labware_offset_table).values( + _pydantic_to_sql_offset(offset) + ) + ).inserted_primary_key.row_id + location_components_to_insert = list( + _pydantic_to_sql_location_sequence_iterator(offset, offset_row_id) ) + if location_components_to_insert: + transaction.execute( + sqlalchemy.insert( + labware_offset_location_sequence_components_table + ), + location_components_to_insert, + ) self._publish_change_notification() def get_all(self) -> list[StoredLabwareOffset]: diff --git a/robot-server/robot_server/persistence/_migrations/_up_to_3_worker.py b/robot-server/robot_server/persistence/_migrations/_up_to_v03_worker.py similarity index 90% rename from robot-server/robot_server/persistence/_migrations/_up_to_3_worker.py rename to robot-server/robot_server/persistence/_migrations/_up_to_v03_worker.py index eb916a0a6f77..efb7ed79c72b 100644 --- a/robot-server/robot_server/persistence/_migrations/_up_to_3_worker.py +++ b/robot-server/robot_server/persistence/_migrations/_up_to_v03_worker.py @@ -21,13 +21,13 @@ from server_utils import sql_utils # noqa: E402 _imports.extend([commands, sql_utils]) -from robot_server.persistence.tables import schema_2, schema_3 # noqa: E402 +from robot_server.persistence.tables import schema_02, schema_03 # noqa: E402 from robot_server.persistence import ( # noqa: E402 database, pydantic as pydantic_helpers, _legacy_pickle, ) -_imports.extend([schema_2, schema_3, database, pydantic_helpers, _legacy_pickle]) +_imports.extend([schema_02, schema_03, database, pydantic_helpers, _legacy_pickle]) # fmt: on @@ -71,10 +71,10 @@ def migrate_commands_for_run( ) as source_engine, database.sql_engine_ctx( dest_db_file ) as dest_engine: - select_old_commands = sqlalchemy.select(schema_2.run_table.c.commands).where( - schema_2.run_table.c.id == run_id + select_old_commands = sqlalchemy.select(schema_02.run_table.c.commands).where( + schema_02.run_table.c.id == run_id ) - insert_new_command = sqlalchemy.insert(schema_3.run_command_table) + insert_new_command = sqlalchemy.insert(schema_03.run_command_table) with lock, source_engine.begin() as source_transaction: old_commands_bytes: typing.Optional[bytes] = source_transaction.execute( diff --git a/robot-server/robot_server/persistence/_migrations/up_to_2.py b/robot-server/robot_server/persistence/_migrations/up_to_v02.py similarity index 99% rename from robot-server/robot_server/persistence/_migrations/up_to_2.py rename to robot-server/robot_server/persistence/_migrations/up_to_v02.py index 2e6ba069b36c..d29693442170 100644 --- a/robot-server/robot_server/persistence/_migrations/up_to_2.py +++ b/robot-server/robot_server/persistence/_migrations/up_to_v02.py @@ -42,7 +42,7 @@ import sqlalchemy -from ..tables.schema_2 import analysis_table, migration_table, run_table +from ..tables.schema_02 import analysis_table, migration_table, run_table from .. import _legacy_pickle diff --git a/robot-server/robot_server/persistence/_migrations/up_to_3.py b/robot-server/robot_server/persistence/_migrations/up_to_v03.py similarity index 86% rename from robot-server/robot_server/persistence/_migrations/up_to_3.py rename to robot-server/robot_server/persistence/_migrations/up_to_v03.py index 9fc901d2c0cb..d666bb41b3d2 100644 --- a/robot-server/robot_server/persistence/_migrations/up_to_3.py +++ b/robot-server/robot_server/persistence/_migrations/up_to_v03.py @@ -30,7 +30,7 @@ sql_engine_ctx, sqlite_rowid, ) -from ..tables import schema_2, schema_3 +from ..tables import schema_02, schema_03 from .._folder_migrator import Migration from ..file_and_directory_names import ( DECK_CONFIGURATION_FILE, @@ -38,8 +38,8 @@ DB_FILE, ) from ._util import copy_rows_unmodified, copy_if_exists, copytree_if_exists -from . import up_to_2 -from . import _up_to_3_worker +from . import up_to_v02 +from . import _up_to_v03_worker _log = getLogger(__name__) @@ -60,11 +60,11 @@ def migrate(self, source_dir: Path, dest_dir: Path) -> None: with ExitStack() as exit_stack: source_engine = exit_stack.enter_context(sql_engine_ctx(source_db_file)) - schema_2.metadata.create_all(source_engine) - up_to_2.migrate(source_engine) + schema_02.metadata.create_all(source_engine) + up_to_v02.migrate(source_engine) dest_engine = exit_stack.enter_context(sql_engine_ctx(dest_db_file)) - schema_3.metadata.create_all(dest_engine) + schema_03.metadata.create_all(dest_engine) source_transaction = exit_stack.enter_context(source_engine.begin()) dest_transaction = exit_stack.enter_context(dest_engine.begin()) @@ -79,7 +79,7 @@ def migrate(self, source_dir: Path, dest_dir: Path) -> None: def _get_run_ids(*, schema_3_transaction: sqlalchemy.engine.Connection) -> List[str]: return ( - schema_3_transaction.execute(sqlalchemy.select(schema_3.run_table.c.id)) + schema_3_transaction.execute(sqlalchemy.select(schema_03.run_table.c.id)) .scalars() .all() ) @@ -90,8 +90,8 @@ def _migrate_db_excluding_commands( dest_transaction: sqlalchemy.engine.Connection, ) -> None: copy_rows_unmodified( - schema_2.protocol_table, - schema_3.protocol_table, + schema_02.protocol_table, + schema_03.protocol_table, source_transaction, dest_transaction, order_by_rowid=True, @@ -108,8 +108,8 @@ def _migrate_db_excluding_commands( ) copy_rows_unmodified( - schema_2.action_table, - schema_3.action_table, + schema_02.action_table, + schema_03.action_table, source_transaction, dest_transaction, order_by_rowid=True, @@ -121,15 +121,15 @@ def _migrate_run_table_excluding_commands( dest_transaction: sqlalchemy.engine.Connection, ) -> None: select_old_runs = sqlalchemy.select( - schema_2.run_table.c.id, - schema_2.run_table.c.created_at, - schema_2.run_table.c.protocol_id, - schema_2.run_table.c.state_summary, + schema_02.run_table.c.id, + schema_02.run_table.c.created_at, + schema_02.run_table.c.protocol_id, + schema_02.run_table.c.state_summary, # schema_2.run_table.c.commands deliberately omitted - schema_2.run_table.c.engine_status, - schema_2.run_table.c._updated_at, + schema_02.run_table.c.engine_status, + schema_02.run_table.c._updated_at, ).order_by(sqlite_rowid) - insert_new_run = sqlalchemy.insert(schema_3.run_table) + insert_new_run = sqlalchemy.insert(schema_03.run_table) for old_row in source_transaction.execute(select_old_runs).all(): try: @@ -163,10 +163,10 @@ def _migrate_analysis_table( source_transaction: sqlalchemy.engine.Connection, dest_transaction: sqlalchemy.engine.Connection, ) -> None: - select_old_analyses = sqlalchemy.select(schema_2.analysis_table).order_by( + select_old_analyses = sqlalchemy.select(schema_02.analysis_table).order_by( sqlite_rowid ) - insert_new_analysis = sqlalchemy.insert(schema_3.analysis_table) + insert_new_analysis = sqlalchemy.insert(schema_03.analysis_table) for old_row in source_transaction.execute(select_old_analyses).all(): dest_transaction.execute( insert_new_analysis, @@ -198,7 +198,7 @@ def _migrate_db_commands( return mp = multiprocessing.get_context("forkserver") - mp.set_forkserver_preload(_up_to_3_worker.imports) + mp.set_forkserver_preload(_up_to_v03_worker.imports) manager = mp.Manager() lock = manager.Lock() @@ -213,6 +213,6 @@ def _migrate_db_commands( processes=4 ) as pool: pool.starmap( - _up_to_3_worker.migrate_commands_for_run, + _up_to_v03_worker.migrate_commands_for_run, ((source_db_file, dest_db_file, run_id, lock) for run_id in run_ids), ) diff --git a/robot-server/robot_server/persistence/_migrations/v3_to_v4.py b/robot-server/robot_server/persistence/_migrations/v03_to_v04.py similarity index 78% rename from robot-server/robot_server/persistence/_migrations/v3_to_v4.py rename to robot-server/robot_server/persistence/_migrations/v03_to_v04.py index cdda9bc1a531..70bfa37c3bd8 100644 --- a/robot-server/robot_server/persistence/_migrations/v3_to_v4.py +++ b/robot-server/robot_server/persistence/_migrations/v03_to_v04.py @@ -12,7 +12,7 @@ from ._util import add_column, copy_contents from ..database import sql_engine_ctx from ..file_and_directory_names import DB_FILE -from ..tables import schema_4 +from ..tables import schema_04 from .._folder_migrator import Migration @@ -27,15 +27,15 @@ def migrate(self, source_dir: Path, dest_dir: Path) -> None: # Append the new column to existing analyses in v4 database with ExitStack() as exit_stack: dest_engine = exit_stack.enter_context(sql_engine_ctx(dest_db_file)) - schema_4.metadata.create_all(dest_engine) + schema_04.metadata.create_all(dest_engine) add_column( dest_engine, - schema_4.analysis_table.name, - schema_4.analysis_table.c.run_time_parameter_values_and_defaults, + schema_04.analysis_table.name, + schema_04.analysis_table.c.run_time_parameter_values_and_defaults, ) add_column( dest_engine, - schema_4.run_table.name, - schema_4.run_table.c.run_time_parameters, + schema_04.run_table.name, + schema_04.run_table.c.run_time_parameters, ) diff --git a/robot-server/robot_server/persistence/_migrations/v4_to_v5.py b/robot-server/robot_server/persistence/_migrations/v04_to_v05.py similarity index 84% rename from robot-server/robot_server/persistence/_migrations/v4_to_v5.py rename to robot-server/robot_server/persistence/_migrations/v04_to_v05.py index 8768ca1cb0e3..37cabdd6cffb 100644 --- a/robot-server/robot_server/persistence/_migrations/v4_to_v5.py +++ b/robot-server/robot_server/persistence/_migrations/v04_to_v05.py @@ -12,7 +12,7 @@ from ._util import add_column, copy_contents from ..database import sql_engine_ctx from ..file_and_directory_names import DB_FILE -from ..tables import schema_5 +from ..tables import schema_05 from .._folder_migrator import Migration @@ -27,10 +27,10 @@ def migrate(self, source_dir: Path, dest_dir: Path) -> None: # Append the new column to existing protocols in v4 database with ExitStack() as exit_stack: dest_engine = exit_stack.enter_context(sql_engine_ctx(dest_db_file)) - schema_5.metadata.create_all(dest_engine) + schema_05.metadata.create_all(dest_engine) add_column( dest_engine, - schema_5.protocol_table.name, - schema_5.protocol_table.c.protocol_kind, + schema_05.protocol_table.name, + schema_05.protocol_table.c.protocol_kind, ) diff --git a/robot-server/robot_server/persistence/_migrations/v5_to_v6.py b/robot-server/robot_server/persistence/_migrations/v05_to_v06.py similarity index 86% rename from robot-server/robot_server/persistence/_migrations/v5_to_v6.py rename to robot-server/robot_server/persistence/_migrations/v05_to_v06.py index 31bbfaa410f1..9dafd7adc1eb 100644 --- a/robot-server/robot_server/persistence/_migrations/v5_to_v6.py +++ b/robot-server/robot_server/persistence/_migrations/v05_to_v06.py @@ -23,7 +23,7 @@ import sqlalchemy from ..database import sql_engine_ctx, sqlite_rowid -from ..tables import schema_5, schema_6 +from ..tables import schema_05, schema_06 from .._folder_migrator import Migration from ._util import copy_rows_unmodified, copy_if_exists, copytree_if_exists @@ -57,7 +57,7 @@ def migrate(self, source_dir: Path, dest_dir: Path) -> None: source_engine = exit_stack.enter_context(sql_engine_ctx(source_db_file)) dest_engine = exit_stack.enter_context(sql_engine_ctx(dest_db_file)) - schema_6.metadata.create_all(dest_engine) + schema_06.metadata.create_all(dest_engine) source_transaction = exit_stack.enter_context(source_engine.begin()) dest_transaction = exit_stack.enter_context(dest_engine.begin()) @@ -70,8 +70,8 @@ def _migrate_db_with_changes( dest_transaction: sqlalchemy.engine.Connection, ) -> None: copy_rows_unmodified( - schema_5.data_files_table, - schema_6.data_files_table, + schema_05.data_files_table, + schema_06.data_files_table, source_transaction, dest_transaction, order_by_rowid=True, @@ -85,22 +85,22 @@ def _migrate_db_with_changes( dest_transaction, ) copy_rows_unmodified( - schema_5.run_table, - schema_6.run_table, + schema_05.run_table, + schema_06.run_table, source_transaction, dest_transaction, order_by_rowid=True, ) copy_rows_unmodified( - schema_5.action_table, - schema_6.action_table, + schema_05.action_table, + schema_06.action_table, source_transaction, dest_transaction, order_by_rowid=True, ) copy_rows_unmodified( - schema_5.run_command_table, - schema_6.run_command_table, + schema_05.run_command_table, + schema_06.run_command_table, source_transaction, dest_transaction, order_by_rowid=True, @@ -112,16 +112,16 @@ def _migrate_protocol_table_with_new_protocol_kind_col( dest_transaction: sqlalchemy.engine.Connection, ) -> None: """Add a new 'protocol_kind' column to protocols table.""" - select_old_protocols = sqlalchemy.select(schema_5.protocol_table).order_by( + select_old_protocols = sqlalchemy.select(schema_05.protocol_table).order_by( sqlite_rowid ) - insert_new_protocol = sqlalchemy.insert(schema_6.protocol_table) + insert_new_protocol = sqlalchemy.insert(schema_06.protocol_table) for old_row in source_transaction.execute(select_old_protocols).all(): new_protocol_kind = ( # Account for old_row.protocol_kind being NULL. - schema_6.ProtocolKindSQLEnum.QUICK_TRANSFER + schema_06.ProtocolKindSQLEnum.QUICK_TRANSFER if old_row.protocol_kind == "quick-transfer" - else schema_6.ProtocolKindSQLEnum.STANDARD + else schema_06.ProtocolKindSQLEnum.STANDARD ) dest_transaction.execute( insert_new_protocol, @@ -137,10 +137,10 @@ def _migrate_analysis_table_excluding_rtp_defaults_and_vals( dest_transaction: sqlalchemy.engine.Connection, ) -> None: """Remove run_time_parameter_values_and_defaults column from analysis_table.""" - select_old_analyses = sqlalchemy.select(schema_5.analysis_table).order_by( + select_old_analyses = sqlalchemy.select(schema_05.analysis_table).order_by( sqlite_rowid ) - insert_new_analyses = sqlalchemy.insert(schema_6.analysis_table) + insert_new_analyses = sqlalchemy.insert(schema_06.analysis_table) for old_row in source_transaction.execute(select_old_analyses).all(): dest_transaction.execute( insert_new_analyses, diff --git a/robot-server/robot_server/persistence/_migrations/v6_to_v7.py b/robot-server/robot_server/persistence/_migrations/v06_to_v07.py similarity index 80% rename from robot-server/robot_server/persistence/_migrations/v6_to_v7.py rename to robot-server/robot_server/persistence/_migrations/v06_to_v07.py index f4c8e35e9861..94aea50c0aa7 100644 --- a/robot-server/robot_server/persistence/_migrations/v6_to_v7.py +++ b/robot-server/robot_server/persistence/_migrations/v06_to_v07.py @@ -15,7 +15,7 @@ from ._util import add_column, copy_contents from ..database import sql_engine_ctx, sqlite_rowid -from ..tables import schema_7 +from ..tables import schema_07 from .._folder_migrator import Migration from ..file_and_directory_names import ( @@ -35,20 +35,20 @@ def migrate(self, source_dir: Path, dest_dir: Path) -> None: with ExitStack() as exit_stack: dest_engine = exit_stack.enter_context(sql_engine_ctx(dest_db_file)) - schema_7.metadata.create_all(dest_engine) + schema_07.metadata.create_all(dest_engine) dest_transaction = exit_stack.enter_context(dest_engine.begin()) add_column( dest_engine, - schema_7.run_command_table.name, - schema_7.run_command_table.c.command_intent, + schema_07.run_command_table.name, + schema_07.run_command_table.c.command_intent, ) add_column( dest_engine, - schema_7.data_files_table.name, - schema_7.data_files_table.c.source, + schema_07.data_files_table.name, + schema_07.data_files_table.c.source, ) _migrate_command_table_with_new_command_intent_col( @@ -64,7 +64,7 @@ def _migrate_command_table_with_new_command_intent_col( dest_transaction: sqlalchemy.engine.Connection, ) -> None: """Add a new 'command_intent' column to run_command_table table.""" - select_commands = sqlalchemy.select(schema_7.run_command_table).order_by( + select_commands = sqlalchemy.select(schema_07.run_command_table).order_by( sqlite_rowid ) for row in dest_transaction.execute(select_commands).all(): @@ -78,8 +78,8 @@ def _migrate_command_table_with_new_command_intent_col( ) dest_transaction.execute( - sqlalchemy.update(schema_7.run_command_table) - .where(schema_7.run_command_table.c.row_id == row.row_id) + sqlalchemy.update(schema_07.run_command_table) + .where(schema_07.run_command_table.c.row_id == row.row_id) .values(command_intent=new_command_intent), ) @@ -89,7 +89,7 @@ def _migrate_data_files_table_with_new_source_col( ) -> None: """Add a new 'source' column to data_files table.""" dest_transaction.execute( - sqlalchemy.update(schema_7.data_files_table).values( - {"source": schema_7.DataFileSourceSQLEnum.UPLOADED} + sqlalchemy.update(schema_07.data_files_table).values( + {"source": schema_07.DataFileSourceSQLEnum.UPLOADED} ) ) diff --git a/robot-server/robot_server/persistence/_migrations/v7_to_v8.py b/robot-server/robot_server/persistence/_migrations/v07_to_v08.py similarity index 90% rename from robot-server/robot_server/persistence/_migrations/v7_to_v8.py rename to robot-server/robot_server/persistence/_migrations/v07_to_v08.py index 2ef2d6ee957a..fc684b3e19b5 100644 --- a/robot-server/robot_server/persistence/_migrations/v7_to_v8.py +++ b/robot-server/robot_server/persistence/_migrations/v07_to_v08.py @@ -14,13 +14,13 @@ from ._util import add_column, copy_contents from ..database import sql_engine_ctx -from ..tables import schema_8 +from ..tables import schema_08 from .._folder_migrator import Migration from ..file_and_directory_names import ( DB_FILE, ) -from ..tables.schema_8 import CommandStatusSQLEnum +from ..tables.schema_08 import CommandStatusSQLEnum class Migration7to8(Migration): # noqa: D101 @@ -38,14 +38,14 @@ def migrate(self, source_dir: Path, dest_dir: Path) -> None: add_column( dest_engine, - schema_8.run_command_table.name, - schema_8.run_command_table.c.command_error, + schema_08.run_command_table.name, + schema_08.run_command_table.c.command_error, ) add_column( dest_engine, - schema_8.run_command_table.name, - schema_8.run_command_table.c.command_status, + schema_08.run_command_table.name, + schema_08.run_command_table.c.command_status, ) _add_missing_indexes(dest_transaction=dest_transaction) @@ -60,7 +60,7 @@ def _add_missing_indexes(dest_transaction: sqlalchemy.engine.Connection) -> None # https://opentrons.atlassian.net/browse/EXEC-827 index = next( index - for index in schema_8.run_command_table.indexes + for index in schema_08.run_command_table.indexes if index.name == "ix_run_run_id_command_status_index_in_run" ) index.create(dest_transaction) @@ -70,7 +70,7 @@ def _migrate_command_table_with_new_command_error_col_and_command_status( dest_transaction: sqlalchemy.engine.Connection, ) -> None: """Add a new 'command_error' and 'command_status' column to run_command_table table.""" - commands_table = schema_8.run_command_table + commands_table = schema_08.run_command_table select_commands = sqlalchemy.select(commands_table) commands_to_update = [] for row in dest_transaction.execute(select_commands).all(): diff --git a/robot-server/robot_server/persistence/_migrations/v8_to_v9.py b/robot-server/robot_server/persistence/_migrations/v08_to_v09.py similarity index 88% rename from robot-server/robot_server/persistence/_migrations/v8_to_v9.py rename to robot-server/robot_server/persistence/_migrations/v08_to_v09.py index 81b583e3c528..a2fe3c5cf98b 100644 --- a/robot-server/robot_server/persistence/_migrations/v8_to_v9.py +++ b/robot-server/robot_server/persistence/_migrations/v08_to_v09.py @@ -14,7 +14,7 @@ from robot_server.persistence.database import sqlite_rowid, sql_engine_ctx from robot_server.persistence.file_and_directory_names import DB_FILE from robot_server.persistence.pydantic import json_to_pydantic -from robot_server.persistence.tables import schema_9 +from robot_server.persistence.tables import schema_09 from ._util import copy_contents from .._folder_migrator import Migration @@ -28,7 +28,7 @@ def migrate(self, source_dir: Path, dest_dir: Path) -> None: with sql_engine_ctx( dest_dir / DB_FILE ) as engine, engine.begin() as transaction: - schema_9.labware_offset_table.create(transaction) + schema_09.labware_offset_table.create(transaction) _import_labware_offsets_from_runs(transaction) @@ -36,8 +36,8 @@ def _import_labware_offsets_from_runs(connection: sqlalchemy.engine.Connection) """Seed the new labware_offset table with records scraped from existing runs.""" raw_state_summaries = ( connection.execute( - sqlalchemy.select(schema_9.run_table.c.state_summary).where( - schema_9.run_table.c.state_summary.is_not(None) + sqlalchemy.select(schema_09.run_table.c.state_summary).where( + schema_09.run_table.c.state_summary.is_not(None) ) # Be careful to preserve order. # Offsets from newer runs should shadow offsets from older runs. @@ -58,7 +58,7 @@ def _import_labware_offsets_from_runs(connection: sqlalchemy.engine.Connection) for labware_offset in state_summary.labwareOffsets: converted = _pydantic_labware_offset_to_sql(labware_offset) connection.execute( - sqlalchemy.insert(schema_9.labware_offset_table).values(converted) + sqlalchemy.insert(schema_09.labware_offset_table).values(converted) ) diff --git a/robot-server/robot_server/persistence/_migrations/v9_to_v10.py b/robot-server/robot_server/persistence/_migrations/v09_to_v10.py similarity index 86% rename from robot-server/robot_server/persistence/_migrations/v9_to_v10.py rename to robot-server/robot_server/persistence/_migrations/v09_to_v10.py index e76ea7217b1f..972acf9bb88c 100644 --- a/robot-server/robot_server/persistence/_migrations/v9_to_v10.py +++ b/robot-server/robot_server/persistence/_migrations/v09_to_v10.py @@ -2,7 +2,9 @@ Summary of changes from schema 9: -- Adds a new `labware_offset_sequence_components` table. +- Change the way we represent locations in the `labware_offset` table: + replace its `location_*` columns with a separate + `labware_offset_sequence_components` table. """ from pathlib import Path @@ -23,7 +25,7 @@ from robot_server.persistence.database import sql_engine_ctx from robot_server.persistence.file_and_directory_names import DB_FILE -from robot_server.persistence.tables import schema_10, schema_9 +from robot_server.persistence.tables import schema_10, schema_09 from ._util import copy_contents from .._folder_migrator import Migration @@ -34,18 +36,22 @@ def migrate(self, source_dir: Path, dest_dir: Path) -> None: """Migrate the persistence directory from schema 9 to 10.""" copy_contents(source_dir=source_dir, dest_dir=dest_dir) - # First we create the new version of our labware offsets table and sequence table with sql_engine_ctx( dest_dir / DB_FILE ) as engine, engine.begin() as transaction: + assert ( + schema_09.labware_offset_table.name + != schema_10.labware_offset_table.name + ) + # First we create the new version of our labware offsets table and sequence table schema_10.labware_offset_table.create(transaction) schema_10.labware_offset_location_sequence_components_table.create( transaction ) # Then we upmigrate the data to the new tables _upmigrate_stored_offsets(transaction) - # Then, we drop the table with we don't care about anymore - schema_9.labware_offset_table.drop(transaction) + # Then, we drop the old table which we don't care about anymore + schema_09.labware_offset_table.drop(transaction) def _upmigrate_stored_offsets(connection: sqlalchemy.engine.Connection) -> None: @@ -54,7 +60,7 @@ def _upmigrate_stored_offsets(connection: sqlalchemy.engine.Connection) -> None: DeckType(guess_deck_type_from_global_config()), version=5 ) - offsets = connection.execute(sqlalchemy.select(schema_9.labware_offset_table)) + offsets = connection.execute(sqlalchemy.select(schema_09.labware_offset_table)) for offset in offsets: new_row = connection.execute( diff --git a/robot-server/robot_server/persistence/_migrations/v10_to_v11.py b/robot-server/robot_server/persistence/_migrations/v10_to_v11.py new file mode 100644 index 000000000000..7d59fb67ecae --- /dev/null +++ b/robot-server/robot_server/persistence/_migrations/v10_to_v11.py @@ -0,0 +1,107 @@ +"""Migrate the persistence directory from schema 10 to schema 11. + +Summary of changes from schema 10: + +- Update the values of the `state_summary` column in the `run` table. Each value is a + JSON document. In that JSON document, we migrate the `.labwareOffsets[*].location` field + to the newer `.labwareOffsets[*].locationSequence` field. + +- Add an index to labware_offset_with_sequence: (active, row_id). +""" + +import logging +from pathlib import Path + +import sqlalchemy + +from opentrons_shared_data.deck import load as load_deck + +from opentrons.protocols.api_support.deck_type import ( + guess_from_global_config as guess_deck_type_from_global_config, +) +from opentrons.protocol_engine import ( + DeckType, + StateSummary, +) +from opentrons.protocol_engine.labware_offset_standardization import ( + legacy_offset_location_to_offset_location_sequence, +) + +from robot_server.persistence.database import sql_engine_ctx +from robot_server.persistence.file_and_directory_names import DB_FILE +from robot_server.persistence.pydantic import ( + json_to_pydantic, + pydantic_to_json, +) +from robot_server.persistence.tables import schema_11 + +from ._util import copy_contents +from .._folder_migrator import Migration + + +_log = logging.getLogger(__name__) + + +class Migration10to11(Migration): # noqa: D101 + def migrate(self, source_dir: Path, dest_dir: Path) -> None: + """Migrate the persistence directory from schema 9 to 10.""" + copy_contents(source_dir=source_dir, dest_dir=dest_dir) + + with sql_engine_ctx( + dest_dir / DB_FILE + ) as engine, engine.begin() as transaction: + _add_new_index(transaction) + _upmigrate_labware_offsets_in_runs(transaction) + + +def _add_new_index(connection: sqlalchemy.engine.Connection) -> None: + index = next( + index + for index in schema_11.labware_offset_table.indexes + if index.name == "ix__labware_offset_with_sequence__active__row_id" + ) + index.create(connection) + + +def _upmigrate_labware_offsets_in_runs( + connection: sqlalchemy.engine.Connection, +) -> None: + # grab the deck def. middlewares aren't up yet so we can't use the nice version + deck_definition = load_deck( + DeckType(guess_deck_type_from_global_config()), version=5 + ) + + for run_id, raw_state_summary in connection.execute( + sqlalchemy.select(schema_11.run_table.c.id, schema_11.run_table.c.state_summary) + ).all(): + try: + if raw_state_summary is None: + new_raw_state_summary = raw_state_summary + else: + parsed_state_summary = json_to_pydantic(StateSummary, raw_state_summary) + + for labware_offset in parsed_state_summary.labwareOffsets: + if labware_offset.locationSequence is None: + labware_offset.locationSequence = ( + legacy_offset_location_to_offset_location_sequence( + labware_offset.location, deck_definition + ) + ) + else: + # .locationSequence should always be None in this old data as far + # as I know, but just to be safe, preserve it if it's not. + pass + + new_raw_state_summary = pydantic_to_json(parsed_state_summary) + + connection.execute( + sqlalchemy.update(schema_11.run_table) + .where(schema_11.run_table.c.id == run_id) + .values(state_summary=new_raw_state_summary) + ) + + except Exception: + _log.error( + f'Could not migrate labware offset locations in run "{run_id}".', + exc_info=True, + ) diff --git a/robot-server/robot_server/persistence/file_and_directory_names.py b/robot-server/robot_server/persistence/file_and_directory_names.py index 11613dab8014..f3f70c48fb30 100644 --- a/robot-server/robot_server/persistence/file_and_directory_names.py +++ b/robot-server/robot_server/persistence/file_and_directory_names.py @@ -8,7 +8,7 @@ from typing import Final -LATEST_VERSION_DIRECTORY: Final = "10" +LATEST_VERSION_DIRECTORY: Final = "11" DECK_CONFIGURATION_FILE: Final = "deck_configuration.json" PROTOCOLS_DIRECTORY: Final = "protocols" diff --git a/robot-server/robot_server/persistence/persistence_directory.py b/robot-server/robot_server/persistence/persistence_directory.py index 4df4a111e1cf..5cf4098ecc59 100644 --- a/robot-server/robot_server/persistence/persistence_directory.py +++ b/robot-server/robot_server/persistence/persistence_directory.py @@ -11,14 +11,15 @@ from ._folder_migrator import MigrationOrchestrator from ._migrations import ( - up_to_3, - v3_to_v4, - v4_to_v5, - v5_to_v6, - v6_to_v7, - v7_to_v8, - v8_to_v9, - v9_to_v10, + up_to_v03, + v03_to_v04, + v04_to_v05, + v05_to_v06, + v06_to_v07, + v07_to_v08, + v08_to_v09, + v09_to_v10, + v10_to_v11, ) from .file_and_directory_names import LATEST_VERSION_DIRECTORY @@ -60,17 +61,21 @@ def make_migration_orchestrator(prepared_root: Path) -> MigrationOrchestrator: return MigrationOrchestrator( root=prepared_root, migrations=[ - up_to_3.MigrationUpTo3(subdirectory="3"), - v3_to_v4.Migration3to4(subdirectory="4"), - v4_to_v5.Migration4to5(subdirectory="5"), - v5_to_v6.Migration5to6(subdirectory="6"), + up_to_v03.MigrationUpTo3(subdirectory="3"), + v03_to_v04.Migration3to4(subdirectory="4"), + v04_to_v05.Migration4to5(subdirectory="5"), + v05_to_v06.Migration5to6(subdirectory="6"), # Subdirectory "7" was previously used on our edge branch for an in-dev # schema that was never released to the public. It may be present on # internal robots. - v6_to_v7.Migration6to7(subdirectory="7.1"), - v7_to_v8.Migration7to8(subdirectory="8"), - v8_to_v9.Migration8to9(subdirectory="9"), - v9_to_v10.Migration9to10(subdirectory=LATEST_VERSION_DIRECTORY), + v06_to_v07.Migration6to7(subdirectory="7.1"), + v07_to_v08.Migration7to8(subdirectory="8"), + # Subdirectories "9" and "10" were used during robot software v8.4.0 + # development and were not released to the public. They may be present on + # internal robots. + v08_to_v09.Migration8to9(subdirectory="9"), + v09_to_v10.Migration9to10(subdirectory="10"), + v10_to_v11.Migration10to11(subdirectory=LATEST_VERSION_DIRECTORY), ], temp_file_prefix="temp-", ) diff --git a/robot-server/robot_server/persistence/tables/__init__.py b/robot-server/robot_server/persistence/tables/__init__.py index 42ed01005d6f..82a10f22b8c5 100644 --- a/robot-server/robot_server/persistence/tables/__init__.py +++ b/robot-server/robot_server/persistence/tables/__init__.py @@ -1,7 +1,7 @@ """SQL database schemas.""" # Re-export the latest schema. -from .schema_10 import ( +from .schema_11 import ( metadata, protocol_table, analysis_table, diff --git a/robot-server/robot_server/persistence/tables/schema_2.py b/robot-server/robot_server/persistence/tables/schema_02.py similarity index 100% rename from robot-server/robot_server/persistence/tables/schema_2.py rename to robot-server/robot_server/persistence/tables/schema_02.py diff --git a/robot-server/robot_server/persistence/tables/schema_3.py b/robot-server/robot_server/persistence/tables/schema_03.py similarity index 100% rename from robot-server/robot_server/persistence/tables/schema_3.py rename to robot-server/robot_server/persistence/tables/schema_03.py diff --git a/robot-server/robot_server/persistence/tables/schema_4.py b/robot-server/robot_server/persistence/tables/schema_04.py similarity index 100% rename from robot-server/robot_server/persistence/tables/schema_4.py rename to robot-server/robot_server/persistence/tables/schema_04.py diff --git a/robot-server/robot_server/persistence/tables/schema_5.py b/robot-server/robot_server/persistence/tables/schema_05.py similarity index 100% rename from robot-server/robot_server/persistence/tables/schema_5.py rename to robot-server/robot_server/persistence/tables/schema_05.py diff --git a/robot-server/robot_server/persistence/tables/schema_6.py b/robot-server/robot_server/persistence/tables/schema_06.py similarity index 100% rename from robot-server/robot_server/persistence/tables/schema_6.py rename to robot-server/robot_server/persistence/tables/schema_06.py diff --git a/robot-server/robot_server/persistence/tables/schema_7.py b/robot-server/robot_server/persistence/tables/schema_07.py similarity index 100% rename from robot-server/robot_server/persistence/tables/schema_7.py rename to robot-server/robot_server/persistence/tables/schema_07.py diff --git a/robot-server/robot_server/persistence/tables/schema_8.py b/robot-server/robot_server/persistence/tables/schema_08.py similarity index 100% rename from robot-server/robot_server/persistence/tables/schema_8.py rename to robot-server/robot_server/persistence/tables/schema_08.py diff --git a/robot-server/robot_server/persistence/tables/schema_9.py b/robot-server/robot_server/persistence/tables/schema_09.py similarity index 100% rename from robot-server/robot_server/persistence/tables/schema_9.py rename to robot-server/robot_server/persistence/tables/schema_09.py diff --git a/robot-server/robot_server/persistence/tables/schema_11.py b/robot-server/robot_server/persistence/tables/schema_11.py new file mode 100644 index 000000000000..c6681a89cbbe --- /dev/null +++ b/robot-server/robot_server/persistence/tables/schema_11.py @@ -0,0 +1,426 @@ +"""v11 of our SQLite schema.""" + +import enum +import sqlalchemy + +from robot_server.persistence._utc_datetime import UTCDateTime + + +metadata = sqlalchemy.MetaData() + + +class PrimitiveParamSQLEnum(enum.Enum): + """Enum type to store primitive param type.""" + + INT = "int" + FLOAT = "float" + BOOL = "bool" + STR = "str" + + +class ProtocolKindSQLEnum(enum.Enum): + """What kind a stored protocol is.""" + + STANDARD = "standard" + QUICK_TRANSFER = "quick-transfer" + + +class DataFileSourceSQLEnum(enum.Enum): + """The source this data file is from.""" + + UPLOADED = "uploaded" + GENERATED = "generated" + + +class CommandStatusSQLEnum(enum.Enum): + """Command status sql enum.""" + + QUEUED = "queued" + RUNNING = "running" + SUCCEEDED = "succeeded" + FAILED = "failed" + + +protocol_table = sqlalchemy.Table( + "protocol", + metadata, + sqlalchemy.Column( + "id", + sqlalchemy.String, + primary_key=True, + ), + sqlalchemy.Column( + "created_at", + UTCDateTime, + nullable=False, + ), + sqlalchemy.Column("protocol_key", sqlalchemy.String, nullable=True), + sqlalchemy.Column( + "protocol_kind", + sqlalchemy.Enum( + ProtocolKindSQLEnum, + values_callable=lambda obj: [e.value for e in obj], + validate_strings=True, + create_constraint=True, + ), + index=True, + nullable=False, + ), +) + + +analysis_table = sqlalchemy.Table( + "analysis", + metadata, + sqlalchemy.Column( + "id", + sqlalchemy.String, + primary_key=True, + ), + sqlalchemy.Column( + "protocol_id", + sqlalchemy.String, + sqlalchemy.ForeignKey("protocol.id"), + index=True, + nullable=False, + ), + sqlalchemy.Column( + "analyzer_version", + sqlalchemy.String, + nullable=False, + ), + sqlalchemy.Column( + "completed_analysis", + # Stores a JSON string. See CompletedAnalysisStore. + sqlalchemy.String, + nullable=False, + ), +) + + +analysis_primitive_type_rtp_table = sqlalchemy.Table( + "analysis_primitive_rtp_table", + metadata, + sqlalchemy.Column( + "row_id", + sqlalchemy.Integer, + primary_key=True, + ), + sqlalchemy.Column( + "analysis_id", + sqlalchemy.ForeignKey("analysis.id"), + nullable=False, + ), + sqlalchemy.Column( + "parameter_variable_name", + sqlalchemy.String, + nullable=False, + ), + sqlalchemy.Column( + "parameter_type", + sqlalchemy.Enum( + PrimitiveParamSQLEnum, + values_callable=lambda obj: [e.value for e in obj], + create_constraint=True, + # todo(mm, 2024-09-24): Can we add validate_strings=True here? + ), + nullable=False, + ), + sqlalchemy.Column( + "parameter_value", + sqlalchemy.String, + nullable=False, + ), +) + + +analysis_csv_rtp_table = sqlalchemy.Table( + "analysis_csv_rtp_table", + metadata, + sqlalchemy.Column( + "row_id", + sqlalchemy.Integer, + primary_key=True, + ), + sqlalchemy.Column( + "analysis_id", + sqlalchemy.ForeignKey("analysis.id"), + nullable=False, + ), + sqlalchemy.Column( + "parameter_variable_name", + sqlalchemy.String, + nullable=False, + ), + sqlalchemy.Column( + "file_id", + sqlalchemy.ForeignKey("data_files.id"), + nullable=True, + ), +) + + +run_table = sqlalchemy.Table( + "run", + metadata, + sqlalchemy.Column( + "id", + sqlalchemy.String, + primary_key=True, + ), + sqlalchemy.Column( + "created_at", + UTCDateTime, + nullable=False, + ), + sqlalchemy.Column( + "protocol_id", + sqlalchemy.String, + sqlalchemy.ForeignKey("protocol.id"), + nullable=True, + ), + sqlalchemy.Column( + "state_summary", + sqlalchemy.String, + nullable=True, + ), + sqlalchemy.Column("engine_status", sqlalchemy.String, nullable=True), + sqlalchemy.Column("_updated_at", UTCDateTime, nullable=True), + sqlalchemy.Column( + "run_time_parameters", + # Stores a JSON string. See RunStore. + sqlalchemy.String, + nullable=True, + ), +) + + +action_table = sqlalchemy.Table( + "action", + metadata, + sqlalchemy.Column( + "id", + sqlalchemy.String, + primary_key=True, + ), + sqlalchemy.Column("created_at", UTCDateTime, nullable=False), + sqlalchemy.Column("action_type", sqlalchemy.String, nullable=False), + sqlalchemy.Column( + "run_id", + sqlalchemy.String, + sqlalchemy.ForeignKey("run.id"), + nullable=False, + ), +) + + +run_command_table = sqlalchemy.Table( + "run_command", + metadata, + sqlalchemy.Column("row_id", sqlalchemy.Integer, primary_key=True), + sqlalchemy.Column( + "run_id", sqlalchemy.String, sqlalchemy.ForeignKey("run.id"), nullable=False + ), + # command_index in commands enumeration + sqlalchemy.Column("index_in_run", sqlalchemy.Integer, nullable=False), + sqlalchemy.Column("command_id", sqlalchemy.String, nullable=False), + sqlalchemy.Column("command", sqlalchemy.String, nullable=False), + sqlalchemy.Column( + "command_intent", + sqlalchemy.String, + # nullable=True to match the underlying SQL, which is nullable because of a bug + # in the migration that introduced this column. This is not intended to ever be + # null in practice. + nullable=True, + ), + sqlalchemy.Column("command_error", sqlalchemy.String, nullable=True), + sqlalchemy.Column( + "command_status", + sqlalchemy.Enum( + CommandStatusSQLEnum, + values_callable=lambda obj: [e.value for e in obj], + validate_strings=True, + # nullable=True because it was easier for the migration to add the column + # this way. This is not intended to ever be null in practice. + nullable=True, + # todo(mm, 2024-11-20): We want create_constraint=True here. Something + # about the way we compare SQL in test_tables.py is making that difficult-- + # even when we correctly add the constraint in the migration, the SQL + # doesn't compare equal to what create_constraint=True here would emit. + create_constraint=False, + ), + ), + sqlalchemy.Index( + "ix_run_run_id_command_id", # An arbitrary name for the index. + "run_id", + "command_id", + unique=True, + ), + sqlalchemy.Index( + "ix_run_run_id_index_in_run", # An arbitrary name for the index. + "run_id", + "index_in_run", + unique=True, + ), + sqlalchemy.Index( + "ix_run_run_id_command_status_index_in_run", # An arbitrary name for the index. + "run_id", + "command_status", + "index_in_run", + unique=True, + ), +) + + +data_files_table = sqlalchemy.Table( + "data_files", + metadata, + sqlalchemy.Column( + "id", + sqlalchemy.String, + primary_key=True, + ), + sqlalchemy.Column( + "name", + sqlalchemy.String, + nullable=False, + ), + sqlalchemy.Column( + "file_hash", + sqlalchemy.String, + nullable=False, + ), + sqlalchemy.Column( + "created_at", + UTCDateTime, + nullable=False, + ), + sqlalchemy.Column( + "source", + sqlalchemy.Enum( + DataFileSourceSQLEnum, + values_callable=lambda obj: [e.value for e in obj], + validate_strings=True, + # create_constraint=False to match the underlying SQL, which omits + # the constraint because of a bug in the migration that introduced this + # column. This is not intended to ever have values other than those in + # DataFileSourceSQLEnum. + create_constraint=False, + ), + # nullable=True to match the underlying SQL, which is nullable because of a bug + # in the migration that introduced this column. This is not intended to ever be + # null in practice. + nullable=True, + ), +) + + +run_csv_rtp_table = sqlalchemy.Table( + "run_csv_rtp_table", + metadata, + sqlalchemy.Column( + "row_id", + sqlalchemy.Integer, + primary_key=True, + ), + sqlalchemy.Column( + "run_id", + sqlalchemy.ForeignKey("run.id"), + nullable=False, + ), + sqlalchemy.Column( + "parameter_variable_name", + sqlalchemy.String, + nullable=False, + ), + sqlalchemy.Column( + "file_id", + sqlalchemy.ForeignKey("data_files.id"), + nullable=True, + ), +) + + +class BooleanSettingKey(enum.Enum): + """Keys for boolean settings.""" + + ENABLE_ERROR_RECOVERY = "enable_error_recovery" + + +boolean_setting_table = sqlalchemy.Table( + "boolean_setting", + metadata, + sqlalchemy.Column( + "key", + sqlalchemy.Enum( + BooleanSettingKey, + values_callable=lambda obj: [e.value for e in obj], + validate_strings=True, + create_constraint=True, + ), + primary_key=True, + ), + sqlalchemy.Column( + "value", + sqlalchemy.Boolean, + nullable=False, + ), +) + + +labware_offset_table = sqlalchemy.Table( + "labware_offset_with_sequence", + metadata, + # Numeric row ID for ordering: + sqlalchemy.Column("row_id", sqlalchemy.Integer, primary_key=True), + # String UUID for exposing over HTTP: + sqlalchemy.Column( + "offset_id", sqlalchemy.String, nullable=False, unique=True, index=True + ), + # The URI identifying the labware definition that this offset applies to. + sqlalchemy.Column("definition_uri", sqlalchemy.String, nullable=False), + # The offset itself: + sqlalchemy.Column("vector_x", sqlalchemy.Float, nullable=False), + sqlalchemy.Column("vector_y", sqlalchemy.Float, nullable=False), + sqlalchemy.Column("vector_z", sqlalchemy.Float, nullable=False), + # Whether this record is "active", i.e. whether it should be considered as a + # candidate to apply to runs and affect actual robot motion: + sqlalchemy.Column("active", sqlalchemy.Boolean, nullable=False), + # When this record was created: + sqlalchemy.Column("created_at", UTCDateTime, nullable=False), + sqlalchemy.Index( + "ix__labware_offset_with_sequence__active__row_id", # An arbitrary name for the index. + "active", + "row_id", + unique=True, + ), +) + +labware_offset_location_sequence_components_table = sqlalchemy.Table( + "labware_offset_sequence_components", + metadata, + # ID for this row, which largely won't be used + sqlalchemy.Column("row_id", sqlalchemy.Integer, primary_key=True), + # Which offset this belongs to + sqlalchemy.Column( + "offset_id", + sqlalchemy.ForeignKey( + "labware_offset_with_sequence.row_id", + ), + nullable=False, + index=True, + ), + # Its position within the sequence + sqlalchemy.Column("sequence_ordinal", sqlalchemy.Integer, nullable=False), + # An identifier for the component; in practice this will be an enum entry (of the kind values + # of the LabwareOffsetSequenceComponent models) but by keeping that out of the schema we don't + # have to change the schema if we add something new there + sqlalchemy.Column("component_kind", sqlalchemy.String, nullable=False), + # The value of the component, which will differ in kind by what component it is, and would be + # annoying to further schematize without yet more normalization. If we ever add a sequence component + # that has more than one value in it (think twice before doing this), pick a primary value that you'll + # be searching by and put that here. + sqlalchemy.Column("primary_component_value", sqlalchemy.String, nullable=False), + # If the value of the component has more than one thing in it, dump it to json and put it here. + sqlalchemy.Column("component_value_json", sqlalchemy.String, nullable=False), +) diff --git a/robot-server/tests/integration/http_api/persistence/test_compatibility.py b/robot-server/tests/integration/http_api/persistence/test_compatibility.py index b2952f5d7869..18c261dfdaf8 100644 --- a/robot-server/tests/integration/http_api/persistence/test_compatibility.py +++ b/robot-server/tests/integration/http_api/persistence/test_compatibility.py @@ -245,6 +245,13 @@ async def test_protocols_analyses_and_runs_available_from_older_persistence_dir( else: assert run["data"].get("dataError") is not None + # This .location field migrated to .locationSequence. + # The new field should always exist and always be non-empty. + for labware_offset in run["data"]["labwareOffsets"]: + location_sequence = labware_offset.get("locationSequence", None) + assert isinstance(location_sequence, list) + assert len(location_sequence) > 0 + all_command_summaries = ( await robot_client.get_run_commands( run_id=expected_run.id, diff --git a/robot-server/tests/labware_offsets/test_store.py b/robot-server/tests/labware_offsets/test_store.py index 5c569b979fa1..50bc78978c56 100644 --- a/robot-server/tests/labware_offsets/test_store.py +++ b/robot-server/tests/labware_offsets/test_store.py @@ -56,13 +56,15 @@ def subject( def test_empty_search(subject: LabwareOffsetStore) -> None: """Searching with no filters should return no results.""" subject.add( - IncomingStoredLabwareOffset( - id="id", - createdAt=datetime.now(timezone.utc), - definitionUri="namespace/load_name/1", - locationSequence="anyLocation", - vector=LabwareOffsetVector(x=1, y=2, z=3), - ) + [ + IncomingStoredLabwareOffset( + id="id", + createdAt=datetime.now(timezone.utc), + definitionUri="namespace/load_name/1", + locationSequence="anyLocation", + vector=LabwareOffsetVector(x=1, y=2, z=3), + ) + ] ) assert subject.search([]) == [] @@ -79,13 +81,15 @@ def test_search_most_recent_only(subject: LabwareOffsetStore) -> None: ] for id, definition_uri in ids_and_definition_uris: subject.add( - IncomingStoredLabwareOffset( - id=id, - definitionUri=definition_uri, - createdAt=datetime.now(timezone.utc), - locationSequence="anyLocation", - vector=LabwareOffsetVector(x=1, y=2, z=3), - ) + [ + IncomingStoredLabwareOffset( + id=id, + definitionUri=definition_uri, + createdAt=datetime.now(timezone.utc), + locationSequence="anyLocation", + vector=LabwareOffsetVector(x=1, y=2, z=3), + ) + ] ) results = subject.search([SearchFilter(mostRecentOnly=True)]) @@ -263,7 +267,7 @@ def test_filter_fields( ), } for offset in offsets.values(): - subject.add(offset) + subject.add([offset]) results = subject.search( [ SearchFilter( @@ -323,8 +327,7 @@ def test_filter_field_combinations(subject: LabwareOffsetStore) -> None: for offset in labware_offsets ] - for labware_offset in labware_offsets: - subject.add(labware_offset) + subject.add(labware_offsets) # Filter accepting any value for each field (i.e. a return-everything filter): result = subject.search([SearchFilter()]) @@ -368,13 +371,15 @@ def test_filter_combinations(subject: LabwareOffsetStore) -> None: ] for id, definition_uri in ids_and_definition_uris: subject.add( - IncomingStoredLabwareOffset( - id=id, - createdAt=datetime.now(timezone.utc), - definitionUri=definition_uri, - locationSequence=ANY_LOCATION, - vector=LabwareOffsetVector(x=1, y=2, z=3), - ) + [ + IncomingStoredLabwareOffset( + id=id, + createdAt=datetime.now(timezone.utc), + definitionUri=definition_uri, + locationSequence=ANY_LOCATION, + vector=LabwareOffsetVector(x=1, y=2, z=3), + ) + ] ) # Multiple filters should be OR'd together. @@ -429,9 +434,7 @@ def test_delete(subject: LabwareOffsetStore) -> None: with pytest.raises(LabwareOffsetNotFoundError): subject.delete("b") - subject.add(a) - subject.add(b) - subject.add(c) + subject.add([a, b, c]) assert subject.delete(b.id) == out_b assert subject.get_all() == [out_a, out_c] @@ -473,7 +476,7 @@ def test_handle_unknown( ], vector=incoming_valid.vector, ) - subject.add(incoming_valid) + subject.add([incoming_valid]) with sql_engine.begin() as transaction: transaction.execute( sqlalchemy.insert(labware_offset_location_sequence_components_table).values( @@ -496,13 +499,15 @@ def test_notifications( """It should publish notifications any time the set of labware offsets changes.""" decoy.verify(mock_labware_offsets_publisher.publish_labware_offsets(), times=0) subject.add( - IncomingStoredLabwareOffset( - id="id", - createdAt=datetime.now(timezone.utc), - definitionUri="definitionUri", - locationSequence=ANY_LOCATION, - vector=LabwareOffsetVector(x=1, y=2, z=3), - ) + [ + IncomingStoredLabwareOffset( + id="id", + createdAt=datetime.now(timezone.utc), + definitionUri="definitionUri", + locationSequence=ANY_LOCATION, + vector=LabwareOffsetVector(x=1, y=2, z=3), + ) + ] ) decoy.verify(mock_labware_offsets_publisher.publish_labware_offsets(), times=1) subject.delete("id") diff --git a/robot-server/tests/labware_offsets/test_store_hypothesis.py b/robot-server/tests/labware_offsets/test_store_hypothesis.py index da43f2e2eb37..2a84f8e245b7 100644 --- a/robot-server/tests/labware_offsets/test_store_hypothesis.py +++ b/robot-server/tests/labware_offsets/test_store_hypothesis.py @@ -122,7 +122,7 @@ def test_round_trip( with TemporaryDirectory() as tmp_dir, make_sql_engine(Path(tmp_dir)) as sql_engine: subject = LabwareOffsetStore(sql_engine, labware_offsets_publisher=None) - subject.add(offset_to_add) + subject.add([offset_to_add]) [offset_retrieved_by_get_all] = subject.get_all() [offset_retrieved_by_search] = subject.search([SearchFilter(id=id)]) @@ -142,20 +142,21 @@ class SimulatedStore: def __init__(self) -> None: self._entries: list[StoredLabwareOffset] = [] - def add(self, offset: IncomingStoredLabwareOffset) -> None: # noqa: D102 - id_already_exists = any( - existing_offset.id == offset.id for existing_offset in self._entries - ) - assert not id_already_exists - self._entries.append( - StoredLabwareOffset( - id=offset.id, - createdAt=offset.createdAt, - definitionUri=offset.definitionUri, - locationSequence=offset.locationSequence, - vector=offset.vector, + def add(self, offsets: list[IncomingStoredLabwareOffset]) -> None: # noqa: D102 + for offset in offsets: + id_already_exists = any( + existing_offset.id == offset.id for existing_offset in self._entries + ) + assert not id_already_exists + self._entries.append( + StoredLabwareOffset( + id=offset.id, + createdAt=offset.createdAt, + definitionUri=offset.definitionUri, + locationSequence=offset.locationSequence, + vector=offset.vector, + ) ) - ) def get_all(self) -> list[StoredLabwareOffset]: # noqa: D102 return self._entries @@ -274,8 +275,11 @@ def add( # noqa: D102 # different floats. Different floats are tried in the round-trip test. vector=LabwareOffsetVector(x=1, y=2, z=3), ) - self._subject.add(to_add) - self._simulated_model.add(to_add) + # todo(mm, 2025-04-01): We should probably Hypothesis-test adding multiple + # offsets in a single add() call, but this RuleBasedStateMachine is already + # taking a lot of time searching the state space--time to break it up? + self._subject.add([to_add]) + self._simulated_model.add([to_add]) self._added_ids.add(to_add.id) @hypothesis.stateful.rule() diff --git a/robot-server/tests/persistence/test_tables.py b/robot-server/tests/persistence/test_tables.py index bdb463427ce9..0ef5fc69edb2 100644 --- a/robot-server/tests/persistence/test_tables.py +++ b/robot-server/tests/persistence/test_tables.py @@ -11,15 +11,16 @@ from robot_server.persistence.persistence_directory import make_migration_orchestrator from robot_server.persistence.tables import ( metadata as latest_metadata, - schema_3, - schema_2, - schema_4, - schema_5, - schema_6, - schema_7, - schema_8, - schema_9, + schema_02, + schema_03, + schema_04, + schema_05, + schema_06, + schema_07, + schema_08, + schema_09, schema_10, + schema_11, ) # The statements that we expect to emit when we create a fresh database. @@ -173,6 +174,9 @@ ) """, """ + CREATE UNIQUE INDEX ix__labware_offset_with_sequence__active__row_id ON labware_offset_with_sequence (active, row_id) + """, + """ CREATE UNIQUE INDEX ix_labware_offset_with_sequence_offset_id ON labware_offset_with_sequence (offset_id) """, """ @@ -192,7 +196,167 @@ """, ] -EXPECTED_STATEMENTS_V10 = EXPECTED_STATEMENTS_LATEST + +EXPECTED_STATEMENTS_V11 = EXPECTED_STATEMENTS_LATEST + + +EXPECTED_STATEMENTS_V10 = [ + """ + CREATE TABLE protocol ( + id VARCHAR NOT NULL, + created_at DATETIME NOT NULL, + protocol_key VARCHAR, + protocol_kind VARCHAR(14) NOT NULL, + PRIMARY KEY (id), + CONSTRAINT protocolkindsqlenum CHECK (protocol_kind IN ('standard', 'quick-transfer')) + ) + """, + """ + CREATE TABLE analysis ( + id VARCHAR NOT NULL, + protocol_id VARCHAR NOT NULL, + analyzer_version VARCHAR NOT NULL, + completed_analysis VARCHAR NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY(protocol_id) REFERENCES protocol (id) + ) + """, + """ + CREATE TABLE analysis_primitive_rtp_table ( + row_id INTEGER NOT NULL, + analysis_id VARCHAR NOT NULL, + parameter_variable_name VARCHAR NOT NULL, + parameter_type VARCHAR(5) NOT NULL, + parameter_value VARCHAR NOT NULL, + PRIMARY KEY (row_id), + FOREIGN KEY(analysis_id) REFERENCES analysis (id), + CONSTRAINT primitiveparamsqlenum CHECK (parameter_type IN ('int', 'float', 'bool', 'str')) + ) + """, + """ + CREATE TABLE analysis_csv_rtp_table ( + row_id INTEGER NOT NULL, + analysis_id VARCHAR NOT NULL, + parameter_variable_name VARCHAR NOT NULL, + file_id VARCHAR, + PRIMARY KEY (row_id), + FOREIGN KEY(analysis_id) REFERENCES analysis (id), + FOREIGN KEY(file_id) REFERENCES data_files (id) + ) + """, + """ + CREATE INDEX ix_analysis_protocol_id ON analysis (protocol_id) + """, + """ + CREATE TABLE run ( + id VARCHAR NOT NULL, + created_at DATETIME NOT NULL, + protocol_id VARCHAR, + state_summary VARCHAR, + engine_status VARCHAR, + _updated_at DATETIME, + run_time_parameters VARCHAR, + PRIMARY KEY (id), + FOREIGN KEY(protocol_id) REFERENCES protocol (id) + ) + """, + """ + CREATE TABLE action ( + id VARCHAR NOT NULL, + created_at DATETIME NOT NULL, + action_type VARCHAR NOT NULL, + run_id VARCHAR NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY(run_id) REFERENCES run (id) + ) + """, + """ + CREATE TABLE run_command ( + row_id INTEGER NOT NULL, + run_id VARCHAR NOT NULL, + index_in_run INTEGER NOT NULL, + command_id VARCHAR NOT NULL, + command VARCHAR NOT NULL, + command_intent VARCHAR, + command_error VARCHAR, + command_status VARCHAR(9), + PRIMARY KEY (row_id), + FOREIGN KEY(run_id) REFERENCES run (id) + ) + """, + """ + CREATE UNIQUE INDEX ix_run_run_id_command_id ON run_command (run_id, command_id) + """, + """ + CREATE UNIQUE INDEX ix_run_run_id_index_in_run ON run_command (run_id, index_in_run) + """, + """ + CREATE UNIQUE INDEX ix_run_run_id_command_status_index_in_run ON run_command (run_id, command_status, index_in_run) + """, + """ + CREATE INDEX ix_protocol_protocol_kind ON protocol (protocol_kind) + """, + """ + CREATE TABLE data_files ( + id VARCHAR NOT NULL, + name VARCHAR NOT NULL, + file_hash VARCHAR NOT NULL, + created_at DATETIME NOT NULL, + source VARCHAR(9), + PRIMARY KEY (id) + ) + """, + """ + CREATE TABLE run_csv_rtp_table ( + row_id INTEGER NOT NULL, + run_id VARCHAR NOT NULL, + parameter_variable_name VARCHAR NOT NULL, + file_id VARCHAR, + PRIMARY KEY (row_id), + FOREIGN KEY(run_id) REFERENCES run (id), + FOREIGN KEY(file_id) REFERENCES data_files (id) + ) + """, + """ + CREATE TABLE boolean_setting ( + "key" VARCHAR(21) NOT NULL, + value BOOLEAN NOT NULL, + PRIMARY KEY ("key"), + CONSTRAINT booleansettingkey CHECK ("key" IN ('enable_error_recovery')) + ) + """, + """ + CREATE TABLE labware_offset_with_sequence ( + row_id INTEGER NOT NULL, + offset_id VARCHAR NOT NULL, + definition_uri VARCHAR NOT NULL, + vector_x FLOAT NOT NULL, + vector_y FLOAT NOT NULL, + vector_z FLOAT NOT NULL, + active BOOLEAN NOT NULL, + created_at DATETIME NOT NULL, + PRIMARY KEY (row_id) + ) + """, + """ + CREATE UNIQUE INDEX ix_labware_offset_with_sequence_offset_id ON labware_offset_with_sequence (offset_id) + """, + """ + CREATE TABLE labware_offset_sequence_components ( + row_id INTEGER NOT NULL, + offset_id INTEGER NOT NULL, + sequence_ordinal INTEGER NOT NULL, + component_kind VARCHAR NOT NULL, + primary_component_value VARCHAR NOT NULL, + component_value_json VARCHAR NOT NULL, + PRIMARY KEY (row_id), + FOREIGN KEY(offset_id) REFERENCES labware_offset_with_sequence (row_id) + ) + """, + """ + CREATE INDEX ix_labware_offset_sequence_components_offset_id ON labware_offset_sequence_components (offset_id) + """, +] EXPECTED_STATEMENTS_V9 = [ @@ -989,15 +1153,16 @@ def _normalize_statement(statement: str) -> str: ("metadata", "expected_statements"), [ (latest_metadata, EXPECTED_STATEMENTS_LATEST), + (schema_11.metadata, EXPECTED_STATEMENTS_V11), (schema_10.metadata, EXPECTED_STATEMENTS_V10), - (schema_9.metadata, EXPECTED_STATEMENTS_V9), - (schema_8.metadata, EXPECTED_STATEMENTS_V8), - (schema_7.metadata, EXPECTED_STATEMENTS_V7), - (schema_6.metadata, EXPECTED_STATEMENTS_V6), - (schema_5.metadata, EXPECTED_STATEMENTS_V5), - (schema_4.metadata, EXPECTED_STATEMENTS_V4), - (schema_3.metadata, EXPECTED_STATEMENTS_V3), - (schema_2.metadata, EXPECTED_STATEMENTS_V2), + (schema_09.metadata, EXPECTED_STATEMENTS_V9), + (schema_08.metadata, EXPECTED_STATEMENTS_V8), + (schema_07.metadata, EXPECTED_STATEMENTS_V7), + (schema_06.metadata, EXPECTED_STATEMENTS_V6), + (schema_05.metadata, EXPECTED_STATEMENTS_V5), + (schema_04.metadata, EXPECTED_STATEMENTS_V4), + (schema_03.metadata, EXPECTED_STATEMENTS_V3), + (schema_02.metadata, EXPECTED_STATEMENTS_V2), ], ) def test_creating_from_metadata_emits_expected_statements( diff --git a/server-utils/Pipfile b/server-utils/Pipfile index 537a0a670f70..326b08d93066 100755 --- a/server-utils/Pipfile +++ b/server-utils/Pipfile @@ -25,7 +25,7 @@ flake8-annotations = "==3.0.1" flake8-docstrings = "~=1.7.0" flake8-noqa = "~=1.4.0" decoy = "==2.1.1" -httpx = "==0.18.*" +httpx = "==0.26.0" black = "==22.3.0" types-requests = "~=2.31.0" types-mock = "~=5.1.0" diff --git a/server-utils/Pipfile.lock b/server-utils/Pipfile.lock index 768034d5cb46..e12b8f318877 100644 --- a/server-utils/Pipfile.lock +++ b/server-utils/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "6410a533fa68be2f8ba9ee2d77fd5f5d63653019d1a256ee8026ac52536d022e" + "sha256": "96bbfd714e482a3b55f4946febdb56bd1f46aa08356c764ebb4976970e18504e" }, "pipfile-spec": 6, "requires": { @@ -53,11 +53,11 @@ }, "attrs": { "hashes": [ - "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346", - "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2" + "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", + "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b" ], - "markers": "python_version >= '3.7'", - "version": "==24.2.0" + "markers": "python_version >= '3.8'", + "version": "==25.3.0" }, "black": { "hashes": [ @@ -91,115 +91,117 @@ }, "certifi": { "hashes": [ - "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", - "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9" + "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", + "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe" ], "markers": "python_version >= '3.6'", - "version": "==2024.8.30" + "version": "==2025.1.31" }, "charset-normalizer": { "hashes": [ - "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027", - "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087", - "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786", - "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8", - "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09", - "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185", - "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574", - "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e", - "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519", - "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898", - "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269", - "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3", - "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f", - "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6", - "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8", - "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a", - "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73", - "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc", - "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714", - "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2", - "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc", - "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce", - "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d", - "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e", - "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6", - "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269", - "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96", - "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d", - "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a", - "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4", - "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77", - "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d", - "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0", - "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed", - "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068", - "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac", - "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25", - "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8", - "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab", - "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26", - "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2", - "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db", - "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f", - "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5", - "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99", - "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c", - "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d", - "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811", - "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa", - "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a", - "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03", - "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b", - "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04", - "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c", - "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001", - "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458", - "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389", - "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99", - "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985", - "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537", - "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238", - "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f", - "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d", - "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796", - "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a", - "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143", - "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8", - "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c", - "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5", - "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5", - "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711", - "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4", - "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6", - "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c", - "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7", - "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4", - "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b", - "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae", - "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12", - "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c", - "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae", - "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8", - "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887", - "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b", - "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4", - "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f", - "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5", - "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33", - "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519", - "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561" + "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537", + "sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa", + "sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a", + "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294", + "sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b", + "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", + "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", + "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd", + "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4", + "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", + "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2", + "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", + "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd", + "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", + "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8", + "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1", + "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", + "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496", + "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d", + "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", + "sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e", + "sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a", + "sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4", + "sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca", + "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78", + "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408", + "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5", + "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", + "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", + "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a", + "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765", + "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6", + "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146", + "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6", + "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", + "sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd", + "sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c", + "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", + "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", + "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176", + "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770", + "sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824", + "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f", + "sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf", + "sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487", + "sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d", + "sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd", + "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", + "sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534", + "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f", + "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b", + "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", + "sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd", + "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125", + "sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9", + "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de", + "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", + "sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d", + "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", + "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f", + "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", + "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7", + "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a", + "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", + "sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8", + "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41", + "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", + "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f", + "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", + "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", + "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", + "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77", + "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76", + "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247", + "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", + "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb", + "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", + "sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e", + "sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6", + "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037", + "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", + "sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e", + "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807", + "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", + "sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c", + "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12", + "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3", + "sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089", + "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", + "sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e", + "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00", + "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616" ], - "markers": "python_full_version >= '3.7.0'", - "version": "==3.3.2" + "markers": "python_version >= '3.7'", + "version": "==3.4.1" }, "click": { "hashes": [ - "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", - "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de" + "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", + "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a" ], "markers": "python_version >= '3.7'", - "version": "==8.1.7" + "version": "==8.1.8" }, "colorama": { "hashes": [ @@ -214,81 +216,72 @@ "toml" ], "hashes": [ - "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca", - "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d", - "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6", - "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989", - "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c", - "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b", - "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223", - "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f", - "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56", - "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3", - "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8", - "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb", - "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388", - "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0", - "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a", - "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8", - "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f", - "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a", - "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962", - "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8", - "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391", - "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc", - "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2", - "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155", - "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb", - "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0", - "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c", - "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a", - "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004", - "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060", - "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232", - "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93", - "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129", - "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163", - "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de", - "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6", - "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23", - "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569", - "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d", - "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778", - "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d", - "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36", - "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a", - "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6", - "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34", - "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704", - "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106", - "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9", - "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862", - "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b", - "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255", - "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16", - "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3", - "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133", - "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb", - "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657", - "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d", - "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca", - "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36", - "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c", - "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e", - "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff", - "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7", - "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5", - "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02", - "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c", - "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df", - "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3", - "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a", - "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959", - "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234", - "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc" + "sha256:042e7841a26498fff7a37d6fda770d17519982f5b7d8bf5278d140b67b61095f", + "sha256:04bfec25a8ef1c5f41f5e7e5c842f6b615599ca8ba8391ec33a9290d9d2db3a3", + "sha256:0915742f4c82208ebf47a2b154a5334155ed9ef9fe6190674b8a46c2fb89cb05", + "sha256:18c5ae6d061ad5b3e7eef4363fb27a0576012a7447af48be6c75b88494c6cf25", + "sha256:2931f66991175369859b5fd58529cd4b73582461877ecfd859b6549869287ffe", + "sha256:2e4b6b87bb0c846a9315e3ab4be2d52fac905100565f4b92f02c445c8799e257", + "sha256:3043ba1c88b2139126fc72cb48574b90e2e0546d4c78b5299317f61b7f718b78", + "sha256:379fe315e206b14e21db5240f89dc0774bdd3e25c3c58c2c733c99eca96f1ada", + "sha256:42421e04069fb2cbcbca5a696c4050b84a43b05392679d4068acbe65449b5c64", + "sha256:4dfd9a93db9e78666d178d4f08a5408aa3f2474ad4d0e0378ed5f2ef71640cb6", + "sha256:52a523153c568d2c0ef8826f6cc23031dc86cffb8c6aeab92c4ff776e7951b28", + "sha256:554fec1199d93ab30adaa751db68acec2b41c5602ac944bb19187cb9a41a8067", + "sha256:581a40c7b94921fffd6457ffe532259813fc68eb2bdda60fa8cc343414ce3733", + "sha256:5a26c0c795c3e0b63ec7da6efded5f0bc856d7c0b24b2ac84b4d1d7bc578d676", + "sha256:5a570cd9bd20b85d1a0d7b009aaf6c110b52b5755c17be6962f8ccd65d1dbd23", + "sha256:5aaeb00761f985007b38cf463b1d160a14a22c34eb3f6a39d9ad6fc27cb73008", + "sha256:5ac46d0c2dd5820ce93943a501ac5f6548ea81594777ca585bf002aa8854cacd", + "sha256:5c8a5c139aae4c35cbd7cadca1df02ea8cf28a911534fc1b0456acb0b14234f3", + "sha256:6b8af63b9afa1031c0ef05b217faa598f3069148eeee6bb24b79da9012423b82", + "sha256:769773614e676f9d8e8a0980dd7740f09a6ea386d0f383db6821df07d0f08545", + "sha256:771eb7587a0563ca5bb6f622b9ed7f9d07bd08900f7589b4febff05f469bea00", + "sha256:77af0f6447a582fdc7de5e06fa3757a3ef87769fbb0fdbdeba78c23049140a47", + "sha256:7a3d62b3b03b4b6fd41a085f3574874cf946cb4604d2b4d3e8dca8cd570ca501", + "sha256:821f7bcbaa84318287115d54becb1915eece6918136c6f91045bb84e2f88739d", + "sha256:89b1f4af0d4afe495cd4787a68e00f30f1d15939f550e869de90a86efa7e0814", + "sha256:8a1d96e780bdb2d0cbb297325711701f7c0b6f89199a57f2049e90064c29f6bd", + "sha256:8a40fcf208e021eb14b0fac6bdb045c0e0cab53105f93ba0d03fd934c956143a", + "sha256:8f99eb72bf27cbb167b636eb1726f590c00e1ad375002230607a844d9e9a2318", + "sha256:90e7fbc6216ecaffa5a880cdc9c77b7418c1dcb166166b78dbc630d07f278cc3", + "sha256:94ec0be97723ae72d63d3aa41961a0b9a6f5a53ff599813c324548d18e3b9e8c", + "sha256:95aa6ae391a22bbbce1b77ddac846c98c5473de0372ba5c463480043a07bff42", + "sha256:96121edfa4c2dfdda409877ea8608dd01de816a4dc4a0523356067b305e4e17a", + "sha256:a1f406a8e0995d654b2ad87c62caf6befa767885301f3b8f6f73e6f3c31ec3a6", + "sha256:a321c61477ff8ee705b8a5fed370b5710c56b3a52d17b983d9215861e37b642a", + "sha256:a5761c70c017c1b0d21b0815a920ffb94a670c8d5d409d9b38857874c21f70d7", + "sha256:a9abbccd778d98e9c7e85038e35e91e67f5b520776781d9a1e2ee9d400869487", + "sha256:ad80e6b4a0c3cb6f10f29ae4c60e991f424e6b14219d46f1e7d442b938ee68a4", + "sha256:b44674870709017e4b4036e3d0d6c17f06a0e6d4436422e0ad29b882c40697d2", + "sha256:b571bf5341ba8c6bc02e0baeaf3b061ab993bf372d982ae509807e7f112554e9", + "sha256:b8194fb8e50d556d5849753de991d390c5a1edeeba50f68e3a9253fbd8bf8ccd", + "sha256:b87eb6fc9e1bb8f98892a2458781348fa37e6925f35bb6ceb9d4afd54ba36c73", + "sha256:bbb5cc845a0292e0c520656d19d7ce40e18d0e19b22cb3e0409135a575bf79fc", + "sha256:be945402e03de47ba1872cd5236395e0f4ad635526185a930735f66710e1bd3f", + "sha256:bf13d564d310c156d1c8e53877baf2993fb3073b2fc9f69790ca6a732eb4bfea", + "sha256:cf60dd2696b457b710dd40bf17ad269d5f5457b96442f7f85722bdb16fa6c899", + "sha256:d1ba00ae33be84066cfbe7361d4e04dec78445b2b88bdb734d0d1cbab916025a", + "sha256:d39fc4817fd67b3915256af5dda75fd4ee10621a3d484524487e33416c6f3543", + "sha256:d766a4f0e5aa1ba056ec3496243150698dc0481902e2b8559314368717be82b1", + "sha256:dbf364b4c5e7bae9250528167dfe40219b62e2d573c854d74be213e1e52069f7", + "sha256:dd19608788b50eed889e13a5d71d832edc34fc9dfce606f66e8f9f917eef910d", + "sha256:e013b07ba1c748dacc2a80e69a46286ff145935f260eb8c72df7185bf048f502", + "sha256:e5d2b9be5b0693cf21eb4ce0ec8d211efb43966f6657807f6859aab3814f946b", + "sha256:e5ff52d790c7e1628241ffbcaeb33e07d14b007b6eb00a19320c7b8a7024c040", + "sha256:e75a2ad7b647fd8046d58c3132d7eaf31b12d8a53c0e4b21fa9c4d23d6ee6d3c", + "sha256:e7ac22a0bb2c7c49f441f7a6d46c9c80d96e56f5a8bc6972529ed43c8b694e27", + "sha256:ed2144b8a78f9d94d9515963ed273d620e07846acd5d4b0a642d4849e8d91a0c", + "sha256:f017a61399f13aa6d1039f75cd467be388d157cd81f1a119b9d9a68ba6f2830d", + "sha256:f1d8a2a57b47142b10374902777e798784abf400a004b14f1b0b9eaf1e528ba4", + "sha256:f2d32f95922927186c6dbc8bc60df0d186b6edb828d299ab10898ef3f40052fe", + "sha256:f319bae0321bc838e205bf9e5bc28f0a3165f30c203b610f17ab5552cff90323", + "sha256:f3c38e4e5ccbdc9198aecc766cedbb134b2d89bf64533973678dfcf07effd883", + "sha256:f9983d01d7705b2d1f7a95e10bbe4091fabc03a46881a256c2787637b087003f", + "sha256:fa260de59dfb143af06dcf30c2be0b200bed2a73737a8a59248fcb9fa601ef0f" ], - "markers": "python_version >= '3.8'", - "version": "==7.6.1" + "markers": "python_version >= '3.9'", + "version": "==7.8.0" }, "decoy": { "hashes": [ @@ -361,28 +354,28 @@ }, "h11": { "hashes": [ - "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6", - "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042" + "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", + "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761" ], - "markers": "python_version >= '3.6'", - "version": "==0.12.0" + "markers": "python_version >= '3.7'", + "version": "==0.14.0" }, "httpcore": { "hashes": [ - "sha256:036f960468759e633574d7c121afba48af6419615d36ab8ede979f1ad6276fa3", - "sha256:369aa481b014cf046f7067fddd67d00560f2f00426e79569d99cb11245134af0" + "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", + "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd" ], - "markers": "python_version >= '3.6'", - "version": "==0.13.7" + "markers": "python_version >= '3.8'", + "version": "==1.0.7" }, "httpx": { "hashes": [ - "sha256:979afafecb7d22a1d10340bafb403cf2cb75aff214426ff206521fc79d26408c", - "sha256:9f99c15d33642d38bce8405df088c1c4cfd940284b4290cacbfb02e64f4877c6" + "sha256:451b55c30d5185ea6b23c2c793abf9bb237d2a7dfb901ced6ff69ad37ec1dfaf", + "sha256:8915f5a3627c4d47b73e8202457cb28f1266982d1159bd5779d86a80c0eab1cd" ], "index": "pypi", - "markers": "python_version >= '3.6'", - "version": "==0.18.2" + "markers": "python_version >= '3.8'", + "version": "==0.26.0" }, "idna": { "hashes": [ @@ -395,11 +388,11 @@ }, "iniconfig": { "hashes": [ - "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", - "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" + "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", + "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760" ], - "markers": "python_version >= '3.7'", - "version": "==2.0.0" + "markers": "python_version >= '3.8'", + "version": "==2.1.0" }, "mccabe": { "hashes": [ @@ -462,11 +455,11 @@ }, "packaging": { "hashes": [ - "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", - "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124" + "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", + "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f" ], "markers": "python_version >= '3.8'", - "version": "==24.1" + "version": "==24.2" }, "pathspec": { "hashes": [ @@ -478,11 +471,11 @@ }, "platformdirs": { "hashes": [ - "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee", - "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3" + "sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94", + "sha256:eb437d586b6a0986388f0d6f74aa0cde27b48d0e3d66843640bfb6bdcdb6e351" ], - "markers": "python_version >= '3.8'", - "version": "==4.2.2" + "markers": "python_version >= '3.9'", + "version": "==4.3.7" }, "pluggy": { "hashes": [ @@ -510,106 +503,116 @@ }, "pydantic": { "hashes": [ - "sha256:c7a8a9fdf7d100afa49647eae340e2d23efa382466a8d177efcd1381e9be5598", - "sha256:f66a7073abd93214a20c5f7b32d56843137a7a2e70d02111f3be287035c45370" + "sha256:442557d2910e75c991c39f4b4ab18963d57b9b55122c8b2a9cd176d8c29ce968", + "sha256:5b6c415eee9f8123a14d859be0c84363fec6b1feb6b688d6435801230b56e0b8" ], - "markers": "python_version >= '3.8'", - "version": "==2.9.0" + "markers": "python_version >= '3.9'", + "version": "==2.11.1" }, "pydantic-core": { "hashes": [ - "sha256:0102e49ac7d2df3379ef8d658d3bc59d3d769b0bdb17da189b75efa861fc07b4", - "sha256:0123655fedacf035ab10c23450163c2f65a4174f2bb034b188240a6cf06bb123", - "sha256:043ef8469f72609c4c3a5e06a07a1f713d53df4d53112c6d49207c0bd3c3bd9b", - "sha256:0448b81c3dfcde439551bb04a9f41d7627f676b12701865c8a2574bcea034437", - "sha256:05b366fb8fe3d8683b11ac35fa08947d7b92be78ec64e3277d03bd7f9b7cda79", - "sha256:07049ec9306ec64e955b2e7c40c8d77dd78ea89adb97a2013d0b6e055c5ee4c5", - "sha256:084414ffe9a85a52940b49631321d636dadf3576c30259607b75516d131fecd0", - "sha256:086c5db95157dc84c63ff9d96ebb8856f47ce113c86b61065a066f8efbe80acf", - "sha256:12625e69b1199e94b0ae1c9a95d000484ce9f0182f9965a26572f054b1537e44", - "sha256:16b25a4a120a2bb7dab51b81e3d9f3cde4f9a4456566c403ed29ac81bf49744f", - "sha256:19f1352fe4b248cae22a89268720fc74e83f008057a652894f08fa931e77dced", - "sha256:1a2ab4f410f4b886de53b6bddf5dd6f337915a29dd9f22f20f3099659536b2f6", - "sha256:1c7b81beaf7c7ebde978377dc53679c6cba0e946426fc7ade54251dfe24a7604", - "sha256:1cf842265a3a820ebc6388b963ead065f5ce8f2068ac4e1c713ef77a67b71f7c", - "sha256:1eb37f7d6a8001c0f86dc8ff2ee8d08291a536d76e49e78cda8587bb54d8b329", - "sha256:23af245b8f2f4ee9e2c99cb3f93d0e22fb5c16df3f2f643f5a8da5caff12a653", - "sha256:257d6a410a0d8aeb50b4283dea39bb79b14303e0fab0f2b9d617701331ed1515", - "sha256:276ae78153a94b664e700ac362587c73b84399bd1145e135287513442e7dfbc7", - "sha256:2b1a195efd347ede8bcf723e932300292eb13a9d2a3c1f84eb8f37cbbc905b7f", - "sha256:329a721253c7e4cbd7aad4a377745fbcc0607f9d72a3cc2102dd40519be75ed2", - "sha256:358331e21a897151e54d58e08d0219acf98ebb14c567267a87e971f3d2a3be59", - "sha256:3649bd3ae6a8ebea7dc381afb7f3c6db237fc7cebd05c8ac36ca8a4187b03b30", - "sha256:3713dc093d5048bfaedbba7a8dbc53e74c44a140d45ede020dc347dda18daf3f", - "sha256:3ef71ec876fcc4d3bbf2ae81961959e8d62f8d74a83d116668409c224012e3af", - "sha256:41ae8537ad371ec018e3c5da0eb3f3e40ee1011eb9be1da7f965357c4623c501", - "sha256:4a801c5e1e13272e0909c520708122496647d1279d252c9e6e07dac216accc41", - "sha256:4c83c64d05ffbbe12d4e8498ab72bdb05bcc1026340a4a597dc647a13c1605ec", - "sha256:4cebb9794f67266d65e7e4cbe5dcf063e29fc7b81c79dc9475bd476d9534150e", - "sha256:5668b3173bb0b2e65020b60d83f5910a7224027232c9f5dc05a71a1deac9f960", - "sha256:56e6a12ec8d7679f41b3750ffa426d22b44ef97be226a9bab00a03365f217b2b", - "sha256:582871902e1902b3c8e9b2c347f32a792a07094110c1bca6c2ea89b90150caac", - "sha256:5c8aa40f6ca803f95b1c1c5aeaee6237b9e879e4dfb46ad713229a63651a95fb", - "sha256:5d813fd871b3d5c3005157622ee102e8908ad6011ec915a18bd8fde673c4360e", - "sha256:5dd0ec5f514ed40e49bf961d49cf1bc2c72e9b50f29a163b2cc9030c6742aa73", - "sha256:5f3cf3721eaf8741cffaf092487f1ca80831202ce91672776b02b875580e174a", - "sha256:6294907eaaccf71c076abdd1c7954e272efa39bb043161b4b8aa1cd76a16ce43", - "sha256:64d094ea1aa97c6ded4748d40886076a931a8bf6f61b6e43e4a1041769c39dd2", - "sha256:6650a7bbe17a2717167e3e23c186849bae5cef35d38949549f1c116031b2b3aa", - "sha256:67b6655311b00581914aba481729971b88bb8bc7996206590700a3ac85e457b8", - "sha256:6b06c5d4e8701ac2ba99a2ef835e4e1b187d41095a9c619c5b185c9068ed2a49", - "sha256:6ce883906810b4c3bd90e0ada1f9e808d9ecf1c5f0b60c6b8831d6100bcc7dd6", - "sha256:6db09153d8438425e98cdc9a289c5fade04a5d2128faff8f227c459da21b9703", - "sha256:6f80fba4af0cb1d2344869d56430e304a51396b70d46b91a55ed4959993c0589", - "sha256:743e5811b0c377eb830150d675b0847a74a44d4ad5ab8845923d5b3a756d8100", - "sha256:753294d42fb072aa1775bfe1a2ba1012427376718fa4c72de52005a3d2a22178", - "sha256:7568f682c06f10f30ef643a1e8eec4afeecdafde5c4af1b574c6df079e96f96c", - "sha256:7706e15cdbf42f8fab1e6425247dfa98f4a6f8c63746c995d6a2017f78e619ae", - "sha256:785e7f517ebb9890813d31cb5d328fa5eda825bb205065cde760b3150e4de1f7", - "sha256:7a05c0240f6c711eb381ac392de987ee974fa9336071fb697768dfdb151345ce", - "sha256:7ce7eaf9a98680b4312b7cebcdd9352531c43db00fca586115845df388f3c465", - "sha256:7ce8e26b86a91e305858e018afc7a6e932f17428b1eaa60154bd1f7ee888b5f8", - "sha256:7d0324a35ab436c9d768753cbc3c47a865a2cbc0757066cb864747baa61f6ece", - "sha256:7e9b24cca4037a561422bf5dc52b38d390fb61f7bfff64053ce1b72f6938e6b2", - "sha256:810ca06cca91de9107718dc83d9ac4d2e86efd6c02cba49a190abcaf33fb0472", - "sha256:820f6ee5c06bc868335e3b6e42d7ef41f50dfb3ea32fbd523ab679d10d8741c0", - "sha256:82764c0bd697159fe9947ad59b6db6d7329e88505c8f98990eb07e84cc0a5d81", - "sha256:8ae65fdfb8a841556b52935dfd4c3f79132dc5253b12c0061b96415208f4d622", - "sha256:8d5b0ff3218858859910295df6953d7bafac3a48d5cd18f4e3ed9999efd2245f", - "sha256:95d6bf449a1ac81de562d65d180af5d8c19672793c81877a2eda8fde5d08f2fd", - "sha256:964c7aa318da542cdcc60d4a648377ffe1a2ef0eb1e996026c7f74507b720a78", - "sha256:96ef39add33ff58cd4c112cbac076726b96b98bb8f1e7f7595288dcfb2f10b57", - "sha256:a6612c2a844043e4d10a8324c54cdff0042c558eef30bd705770793d70b224aa", - "sha256:a8031074a397a5925d06b590121f8339d34a5a74cfe6970f8a1124eb8b83f4ac", - "sha256:aab9e522efff3993a9e98ab14263d4e20211e62da088298089a03056980a3e69", - "sha256:ae579143826c6f05a361d9546446c432a165ecf1c0b720bbfd81152645cb897d", - "sha256:ae90b9e50fe1bd115b24785e962b51130340408156d34d67b5f8f3fa6540938e", - "sha256:b18cf68255a476b927910c6873d9ed00da692bb293c5b10b282bd48a0afe3ae2", - "sha256:b7efb12e5071ad8d5b547487bdad489fbd4a5a35a0fc36a1941517a6ad7f23e0", - "sha256:c4d9f15ffe68bcd3898b0ad7233af01b15c57d91cd1667f8d868e0eacbfe3f87", - "sha256:c53100c8ee5a1e102766abde2158077d8c374bee0639201f11d3032e3555dfbc", - "sha256:c57e493a0faea1e4c38f860d6862ba6832723396c884fbf938ff5e9b224200e2", - "sha256:c8319e0bd6a7b45ad76166cc3d5d6a36c97d0c82a196f478c3ee5346566eebfd", - "sha256:caffda619099cfd4f63d48462f6aadbecee3ad9603b4b88b60cb821c1b258576", - "sha256:cc0c316fba3ce72ac3ab7902a888b9dc4979162d320823679da270c2d9ad0cad", - "sha256:cdd02a08205dc90238669f082747612cb3c82bd2c717adc60f9b9ecadb540f80", - "sha256:d50ac34835c6a4a0d456b5db559b82047403c4317b3bc73b3455fefdbdc54b0a", - "sha256:d6b9dd6aa03c812017411734e496c44fef29b43dba1e3dd1fa7361bbacfc1354", - "sha256:da3131ef2b940b99106f29dfbc30d9505643f766704e14c5d5e504e6a480c35e", - "sha256:da43cbe593e3c87d07108d0ebd73771dc414488f1f91ed2e204b0370b94b37ac", - "sha256:dd59638025160056687d598b054b64a79183f8065eae0d3f5ca523cde9943940", - "sha256:e1895e949f8849bc2757c0dbac28422a04be031204df46a56ab34bcf98507342", - "sha256:e1a79ad49f346aa1a2921f31e8dbbab4d64484823e813a002679eaa46cba39e1", - "sha256:e460475719721d59cd54a350c1f71c797c763212c836bf48585478c5514d2854", - "sha256:e64ffaf8f6e17ca15eb48344d86a7a741454526f3a3fa56bc493ad9d7ec63936", - "sha256:e6e3ccebdbd6e53474b0bb7ab8b88e83c0cfe91484b25e058e581348ee5a01a5", - "sha256:e758d271ed0286d146cf7c04c539a5169a888dd0b57026be621547e756af55bc", - "sha256:f087879f1ffde024dd2788a30d55acd67959dcf6c431e9d3682d1c491a0eb474", - "sha256:f477d26183e94eaafc60b983ab25af2a809a1b48ce4debb57b343f671b7a90b6", - "sha256:fc535cb898ef88333cf317777ecdfe0faac1c2a3187ef7eb061b6f7ecf7e6bae" + "sha256:024d136ae44d233e6322027bbf356712b3940bee816e6c948ce4b90f18471b3d", + "sha256:0310524c833d91403c960b8a3cf9f46c282eadd6afd276c8c5edc617bd705dc9", + "sha256:07b4ced28fccae3f00626eaa0c4001aa9ec140a29501770a88dbbb0966019a86", + "sha256:085d8985b1c1e48ef271e98a658f562f29d89bda98bf120502283efbc87313eb", + "sha256:0a98257451164666afafc7cbf5fb00d613e33f7e7ebb322fbcd99345695a9a61", + "sha256:0bcf0bab28995d483f6c8d7db25e0d05c3efa5cebfd7f56474359e7137f39856", + "sha256:138d31e3f90087f42aa6286fb640f3c7a8eb7bdae829418265e7e7474bd2574b", + "sha256:14229c1504287533dbf6b1fc56f752ce2b4e9694022ae7509631ce346158de11", + "sha256:1583539533160186ac546b49f5cde9ffc928062c96920f58bd95de32ffd7bffd", + "sha256:175ab598fb457a9aee63206a1993874badf3ed9a456e0654273e56f00747bbd6", + "sha256:1a69b7596c6603afd049ce7f3835bcf57dd3892fc7279f0ddf987bebed8caa5a", + "sha256:1a73be93ecef45786d7d95b0c5e9b294faf35629d03d5b145b09b81258c7cd6d", + "sha256:1b1262b912435a501fa04cd213720609e2cefa723a07c92017d18693e69bf00b", + "sha256:1b2ea72dea0825949a045fa4071f6d5b3d7620d2a208335207793cf29c5a182d", + "sha256:20d4275f3c4659d92048c70797e5fdc396c6e4446caf517ba5cad2db60cd39d3", + "sha256:23c3e77bf8a7317612e5c26a3b084c7edeb9552d645742a54a5867635b4f2453", + "sha256:26a4ea04195638dcd8c53dadb545d70badba51735b1594810e9768c2c0b4a5da", + "sha256:26bc7367c0961dec292244ef2549afa396e72e28cc24706210bd44d947582c59", + "sha256:2a0147c0bef783fd9abc9f016d66edb6cac466dc54a17ec5f5ada08ff65caf5d", + "sha256:2c0afd34f928383e3fd25740f2050dbac9d077e7ba5adbaa2227f4d4f3c8da5c", + "sha256:30369e54d6d0113d2aa5aee7a90d17f225c13d87902ace8fcd7bbf99b19124db", + "sha256:31860fbda80d8f6828e84b4a4d129fd9c4535996b8249cfb8c720dc2a1a00bb8", + "sha256:34e7fb3abe375b5c4e64fab75733d605dda0f59827752debc99c17cb2d5f3276", + "sha256:40eb8af662ba409c3cbf4a8150ad32ae73514cd7cb1f1a2113af39763dd616b3", + "sha256:41d698dcbe12b60661f0632b543dbb119e6ba088103b364ff65e951610cb7ce0", + "sha256:4726f1f3f42d6a25678c67da3f0b10f148f5655813c5aca54b0d1742ba821b8f", + "sha256:4927564be53239a87770a5f86bdc272b8d1fbb87ab7783ad70255b4ab01aa25b", + "sha256:4b6d77c75a57f041c5ee915ff0b0bb58eabb78728b69ed967bc5b780e8f701b8", + "sha256:4d9149e7528af8bbd76cc055967e6e04617dcb2a2afdaa3dea899406c5521faa", + "sha256:4deac83a8cc1d09e40683be0bc6d1fa4cde8df0a9bf0cda5693f9b0569ac01b6", + "sha256:4f1ab031feb8676f6bd7c85abec86e2935850bf19b84432c64e3e239bffeb1ec", + "sha256:502ed542e0d958bd12e7c3e9a015bce57deaf50eaa8c2e1c439b512cb9db1e3a", + "sha256:5461934e895968655225dfa8b3be79e7e927e95d4bd6c2d40edd2fa7052e71b6", + "sha256:58c1151827eef98b83d49b6ca6065575876a02d2211f259fb1a6b7757bd24dd8", + "sha256:5bdd36b362f419c78d09630cbaebc64913f66f62bda6d42d5fbb08da8cc4f181", + "sha256:5bf637300ff35d4f59c006fff201c510b2b5e745b07125458a5389af3c0dff8c", + "sha256:5bf68bb859799e9cec3d9dd8323c40c00a254aabb56fe08f907e437005932f2b", + "sha256:5d8dc9f63a26f7259b57f46a7aab5af86b2ad6fbe48487500bb1f4b27e051e4c", + "sha256:5f36afd0d56a6c42cf4e8465b6441cf546ed69d3a4ec92724cc9c8c61bd6ecf4", + "sha256:5f72914cfd1d0176e58ddc05c7a47674ef4222c8253bf70322923e73e14a4ac3", + "sha256:6291797cad239285275558e0a27872da735b05c75d5237bbade8736f80e4c225", + "sha256:62c151ce3d59ed56ebd7ce9ce5986a409a85db697d25fc232f8e81f195aa39a1", + "sha256:635702b2fed997e0ac256b2cfbdb4dd0bf7c56b5d8fba8ef03489c03b3eb40e2", + "sha256:64672fa888595a959cfeff957a654e947e65bbe1d7d82f550417cbd6898a1d6b", + "sha256:68504959253303d3ae9406b634997a2123a0b0c1da86459abbd0ffc921695eac", + "sha256:69297418ad644d521ea3e1aa2e14a2a422726167e9ad22b89e8f1130d68e1e9a", + "sha256:6c32a40712e3662bebe524abe8abb757f2fa2000028d64cc5a1006016c06af43", + "sha256:715c62af74c236bf386825c0fdfa08d092ab0f191eb5b4580d11c3189af9d330", + "sha256:71dffba8fe9ddff628c68f3abd845e91b028361d43c5f8e7b3f8b91d7d85413e", + "sha256:7419241e17c7fbe5074ba79143d5523270e04f86f1b3a0dff8df490f84c8273a", + "sha256:759871f00e26ad3709efc773ac37b4d571de065f9dfb1778012908bcc36b3a73", + "sha256:7a25493320203005d2a4dac76d1b7d953cb49bce6d459d9ae38e30dd9f29bc9c", + "sha256:7b79af799630af263eca9ec87db519426d8c9b3be35016eddad1832bac812d87", + "sha256:7c9c84749f5787781c1c45bb99f433402e484e515b40675a5d121ea14711cf61", + "sha256:7da333f21cd9df51d5731513a6d39319892947604924ddf2e24a4612975fb936", + "sha256:82a4eba92b7ca8af1b7d5ef5f3d9647eee94d1f74d21ca7c21e3a2b92e008358", + "sha256:89670d7a0045acb52be0566df5bc8b114ac967c662c06cf5e0c606e4aadc964b", + "sha256:8a1d581e8cdbb857b0e0e81df98603376c1a5c34dc5e54039dcc00f043df81e7", + "sha256:8ec86b5baa36f0a0bfb37db86c7d52652f8e8aa076ab745ef7725784183c3fdd", + "sha256:91301a0980a1d4530d4ba7e6a739ca1a6b31341252cb709948e0aca0860ce0ae", + "sha256:918f2013d7eadea1d88d1a35fd4a1e16aaf90343eb446f91cb091ce7f9b431a2", + "sha256:9cb2390355ba084c1ad49485d18449b4242da344dea3e0fe10babd1f0db7dcfc", + "sha256:9ee65f0cc652261744fd07f2c6e6901c914aa6c5ff4dcfaf1136bc394d0dd26b", + "sha256:a608a75846804271cf9c83e40bbb4dab2ac614d33c6fd5b0c6187f53f5c593ef", + "sha256:a66d931ea2c1464b738ace44b7334ab32a2fd50be023d863935eb00f42be1778", + "sha256:a7a7f2a3f628d2f7ef11cb6188bcf0b9e1558151d511b974dfea10a49afe192b", + "sha256:abaeec1be6ed535a5d7ffc2e6c390083c425832b20efd621562fbb5bff6dc518", + "sha256:abfa44cf2f7f7d7a199be6c6ec141c9024063205545aa09304349781b9a125e6", + "sha256:ade5dbcf8d9ef8f4b28e682d0b29f3008df9842bb5ac48ac2c17bc55771cc976", + "sha256:ae62032ef513fe6281ef0009e30838a01057b832dc265da32c10469622613885", + "sha256:aec79acc183865bad120b0190afac467c20b15289050648b876b07777e67ea48", + "sha256:b716294e721d8060908dbebe32639b01bfe61b15f9f57bcc18ca9a0e00d9520b", + "sha256:b9ec80eb5a5f45a2211793f1c4aeddff0c3761d1c70d684965c1807e923a588b", + "sha256:ba95691cf25f63df53c1d342413b41bd7762d9acb425df8858d7efa616c0870e", + "sha256:bccc06fa0372151f37f6b69834181aa9eb57cf8665ed36405fb45fbf6cac3bae", + "sha256:c860773a0f205926172c6644c394e02c25421dc9a456deff16f64c0e299487d3", + "sha256:ca1103d70306489e3d006b0f79db8ca5dd3c977f6f13b2c59ff745249431a606", + "sha256:ce72d46eb201ca43994303025bd54d8a35a3fc2a3495fac653d6eb7205ce04f4", + "sha256:d20cbb9d3e95114325780f3cfe990f3ecae24de7a2d75f978783878cce2ad585", + "sha256:dcfebee69cd5e1c0b76a17e17e347c84b00acebb8dd8edb22d4a03e88e82a207", + "sha256:e1c69aa459f5609dec2fa0652d495353accf3eda5bdb18782bc5a2ae45c9273a", + "sha256:e2762c568596332fdab56b07060c8ab8362c56cf2a339ee54e491cd503612c50", + "sha256:e37f10f6d4bc67c58fbd727108ae1d8b92b397355e68519f1e4a7babb1473442", + "sha256:e790954b5093dff1e3a9a2523fddc4e79722d6f07993b4cd5547825c3cbf97b5", + "sha256:e81a295adccf73477220e15ff79235ca9dcbcee4be459eb9d4ce9a2763b8386c", + "sha256:e925819a98318d17251776bd3d6aa9f3ff77b965762155bdad15d1a9265c4cfd", + "sha256:ea30239c148b6ef41364c6f51d103c2988965b643d62e10b233b5efdca8c0099", + "sha256:eabf946a4739b5237f4f56d77fa6668263bc466d06a8036c055587c130a46f7b", + "sha256:ecb158fb9b9091b515213bed3061eb7deb1d3b4e02327c27a0ea714ff46b0760", + "sha256:ecc6d02d69b54a2eb83ebcc6f29df04957f734bcf309d346b4f83354d8376862", + "sha256:eddb18a00bbb855325db27b4c2a89a4ba491cd6a0bd6d852b225172a1f54b36c", + "sha256:f00e8b59e1fc8f09d05594aa7d2b726f1b277ca6155fc84c0396db1b373c4555", + "sha256:f1fb026c575e16f673c61c7b86144517705865173f3d0907040ac30c4f9f5915", + "sha256:f200b2f20856b5a6c3a35f0d4e344019f805e363416e609e9b47c552d35fd5ea", + "sha256:f225f3a3995dbbc26affc191d0443c6c4aa71b83358fd4c2b7d63e2f6f0336f9", + "sha256:f22dab23cdbce2005f26a8f0c71698457861f97fc6318c75814a50c75e87d025", + "sha256:f3eb479354c62067afa62f53bb387827bee2f75c9c79ef25eef6ab84d4b1ae3b", + "sha256:fc53e05c16697ff0c1c7c2b98e45e131d4bfb78068fffff92a82d169cbb4c7b7", + "sha256:ff48a55be9da6930254565ff5238d71d5e9cd8c5487a191cb85df3bdb8c77365" ], - "markers": "python_version >= '3.8'", - "version": "==2.23.2" + "markers": "python_version >= '3.9'", + "version": "==2.33.0" }, "pydocstyle": { "hashes": [ @@ -728,16 +731,6 @@ "markers": "python_version >= '3.7'", "version": "==2.31.0" }, - "rfc3986": { - "extras": [ - "idna2008" - ], - "hashes": [ - "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835", - "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97" - ], - "version": "==1.5.0" - }, "server-utils": { "editable": true, "path": "." @@ -828,11 +821,41 @@ }, "tomli": { "hashes": [ - "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", - "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" + "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", + "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", + "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", + "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", + "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", + "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", + "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", + "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", + "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", + "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", + "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", + "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", + "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", + "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", + "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", + "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", + "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", + "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", + "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", + "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", + "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", + "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", + "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", + "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", + "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", + "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", + "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", + "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", + "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", + "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", + "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", + "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7" ], "markers": "python_version < '3.11'", - "version": "==2.0.1" + "version": "==2.2.1" }, "types-mock": { "hashes": [ @@ -854,27 +877,27 @@ }, "typing-extensions": { "hashes": [ - "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", - "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8" + "sha256:0a4ac55a5820789d87e297727d229866c9650f6521b64206413c4fbada24d95b", + "sha256:c8dd92cc0d6425a97c18fbb9d1954e5ff92c1ca881a309c45f06ebc0b79058e5" ], "markers": "python_version >= '3.8'", - "version": "==4.12.2" + "version": "==4.13.0" }, - "tzdata": { + "typing-inspection": { "hashes": [ - "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd", - "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252" + "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", + "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122" ], "markers": "python_version >= '3.9'", - "version": "==2024.1" + "version": "==0.4.0" }, "urllib3": { "hashes": [ - "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472", - "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168" + "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", + "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d" ], - "markers": "python_version >= '3.8'", - "version": "==2.2.2" + "markers": "python_version >= '3.9'", + "version": "==2.3.0" }, "uvicorn": { "hashes": [ diff --git a/server-utils/server_utils/fastapi_utils/server_timing_middleware.py b/server-utils/server_utils/fastapi_utils/server_timing_middleware.py new file mode 100644 index 000000000000..e28de05934b1 --- /dev/null +++ b/server-utils/server_utils/fastapi_utils/server_timing_middleware.py @@ -0,0 +1,76 @@ +"""Add server performance metrics to HTTP responses. + +This uses the standard Server-Timing response header, so the metrics will show up in +browser dev tools. +https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Server-Timing +""" + + +import logging +import time +from typing import Awaitable, Callable + +from fastapi import Request, Response + + +# These are inserted into the HTTP response header raw, so they should be short and +# avoid special characters. +# +# Chrome devtools uses the description as a label in the timing bar graph, +# adjacent to things like "Waiting for server response" and "Content download". +_METRIC_NAME = "opentrons-asgi" +_METRIC_DESC = "Time in Python (roughly)" + + +_log = logging.getLogger(__name__) + + +_CallNextType = Callable[[Request], Awaitable[Response]] + + +def server_timing_middleware( + clock: Callable[[], float] = time.perf_counter +) -> Callable[[Request, _CallNextType], Awaitable[Response]]: + """Return a function that can be used as a FastAPI middleware. + + Usage example: + + app = fastapi.FastAPI() + ... + + app.middleware("http")(server_timing_middleware()) + + + The `clock` param should return the current time in seconds. + """ + + async def middleware_function( + request: Request, call_next: _CallNextType + ) -> Response: + time_before = clock() + response = await call_next(request) + time_after = clock() + duration_ms = round((time_after - time_before) * 1000) + + _log.debug(f"{request.url}: {duration_ms} ms") + + response.headers["Server-Timing"] = _update_server_timing_header( + preexisting_header_value=response.headers.get("Server-Timing", None), + name=_METRIC_NAME, + desc=_METRIC_DESC, + dur=duration_ms, + ) + + return response + + return middleware_function + + +def _update_server_timing_header( + preexisting_header_value: str | None, name: str, dur: float, desc: str +) -> str: + new_metric = f'{name};dur={dur};desc="{desc}"' + if preexisting_header_value is None: + return new_metric + else: + return f"{preexisting_header_value},{new_metric}" diff --git a/server-utils/tests/fastapi_utils/__init__.py b/server-utils/tests/fastapi_utils/__init__.py new file mode 100644 index 000000000000..6e031999e7b6 --- /dev/null +++ b/server-utils/tests/fastapi_utils/__init__.py @@ -0,0 +1 @@ +# noqa: D104 diff --git a/server-utils/tests/fastapi_utils/test_server_timing_middleware.py b/server-utils/tests/fastapi_utils/test_server_timing_middleware.py new file mode 100644 index 000000000000..bb14e6651322 --- /dev/null +++ b/server-utils/tests/fastapi_utils/test_server_timing_middleware.py @@ -0,0 +1,53 @@ +# noqa: D100 + + +from fastapi import FastAPI, Response +from starlette.testclient import TestClient + +from server_utils.fastapi_utils.server_timing_middleware import server_timing_middleware + + +def test_server_timing_middleware() -> None: + """Test server timing middleware. + + It should add, or update, a Server-Timing header with the elapsed milliseconds. + """ + app = FastAPI() + + class TestClock: + """Start at t=100 seconds and increment by 1 second each call.""" + + def __init__(self) -> None: + self._time = 100.0 + + def __call__(self) -> float: + initial_time = self._time + self._time += 1 + return initial_time + + app.middleware("http")(server_timing_middleware(TestClock())) + + @app.get("/testEndpoint") + def get_test_endpoint() -> str: + return "Test response body" + + @app.get("/testEndpointWithPreexistingHeader") + def get_test_endpoint_with_preexisting_header(response: Response) -> str: + response.headers["Server-Timing"] = "something-preexisting" + return "Test response body" + + test_client = TestClient(app) + + response = test_client.get("/testEndpoint") + assert response.status_code == 200 + assert ( + response.headers["Server-Timing"] + == 'opentrons-asgi;dur=1000;desc="Time in Python (roughly)"' + ) + + response = test_client.get("/testEndpointWithPreexistingHeader") + assert response.status_code == 200 + assert ( + response.headers["Server-Timing"] + == 'something-preexisting,opentrons-asgi;dur=1000;desc="Time in Python (roughly)"' + ) diff --git a/shared-data/command/schemas/12.json b/shared-data/command/schemas/12.json index 3ab1b8919beb..7270f76c6895 100644 --- a/shared-data/command/schemas/12.json +++ b/shared-data/command/schemas/12.json @@ -1865,17 +1865,10 @@ "title": "Count" }, "message": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], "default": null, "description": "The message to display on connected clients during a manualWithPause strategy empty.", - "title": "Message" + "title": "Message", + "type": "string" }, "moduleId": { "description": "Unique ID of the Flex Stacker", @@ -2185,17 +2178,10 @@ "title": "Count" }, "message": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], "default": null, "description": "The message to display on connected clients during a manualWithPause strategy fill.", - "title": "Message" + "title": "Message", + "type": "string" }, "moduleId": { "description": "Unique ID of the Flex Stacker", @@ -5092,16 +5078,10 @@ "description": "Input parameters for a setStoredLabware command.", "properties": { "adapterLabware": { - "anyOf": [ - { - "$ref": "#/$defs/StackerStoredLabwareDetails" - }, - { - "type": "null" - } - ], + "$ref": "#/$defs/StackerStoredLabwareDetails", "default": null, - "description": "The details of the adapter under the primary labware, if any." + "description": "The details of the adapter under the primary labware, if any.", + "title": "Adapterlabware" }, "initialCount": { "anyOf": [ @@ -5118,16 +5098,10 @@ "title": "Initialcount" }, "lidLabware": { - "anyOf": [ - { - "$ref": "#/$defs/StackerStoredLabwareDetails" - }, - { - "type": "null" - } - ], + "$ref": "#/$defs/StackerStoredLabwareDetails", "default": null, - "description": "The details of the lid on the primary labware, if any." + "description": "The details of the lid on the primary labware, if any.", + "title": "Lidlabware" }, "moduleId": { "description": "Unique ID of the Flex Stacker.", diff --git a/shared-data/command/schemas/13.json b/shared-data/command/schemas/13.json index bca310c95732..6fee085abf65 100644 --- a/shared-data/command/schemas/13.json +++ b/shared-data/command/schemas/13.json @@ -1865,17 +1865,10 @@ "title": "Count" }, "message": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], "default": null, "description": "The message to display on connected clients during a manualWithPause strategy empty.", - "title": "Message" + "title": "Message", + "type": "string" }, "moduleId": { "description": "Unique ID of the Flex Stacker", @@ -2185,17 +2178,10 @@ "title": "Count" }, "message": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], "default": null, "description": "The message to display on connected clients during a manualWithPause strategy fill.", - "title": "Message" + "title": "Message", + "type": "string" }, "moduleId": { "description": "Unique ID of the Flex Stacker", @@ -5092,16 +5078,10 @@ "description": "Input parameters for a setStoredLabware command.", "properties": { "adapterLabware": { - "anyOf": [ - { - "$ref": "#/$defs/StackerStoredLabwareDetails" - }, - { - "type": "null" - } - ], + "$ref": "#/$defs/StackerStoredLabwareDetails", "default": null, - "description": "The details of the adapter under the primary labware, if any." + "description": "The details of the adapter under the primary labware, if any.", + "title": "Adapterlabware" }, "initialCount": { "anyOf": [ @@ -5118,16 +5098,10 @@ "title": "Initialcount" }, "lidLabware": { - "anyOf": [ - { - "$ref": "#/$defs/StackerStoredLabwareDetails" - }, - { - "type": "null" - } - ], + "$ref": "#/$defs/StackerStoredLabwareDetails", "default": null, - "description": "The details of the lid on the primary labware, if any." + "description": "The details of the lid on the primary labware, if any.", + "title": "Lidlabware" }, "moduleId": { "description": "Unique ID of the Flex Stacker.", diff --git a/shared-data/js/__tests__/pipettes.test.ts b/shared-data/js/__tests__/pipettes.test.ts index 9613539262b2..0392cbbb4ce9 100644 --- a/shared-data/js/__tests__/pipettes.test.ts +++ b/shared-data/js/__tests__/pipettes.test.ts @@ -86,11 +86,9 @@ describe('pipette data accessors', () => { 'opentrons/opentrons_flex_96_tiprack_1000ul/1', 'opentrons/opentrons_flex_96_tiprack_200ul/1', 'opentrons/opentrons_flex_96_tiprack_50ul/1', - 'opentrons/opentrons_flex_96_tiprack_20ul/1', 'opentrons/opentrons_flex_96_filtertiprack_1000ul/1', 'opentrons/opentrons_flex_96_filtertiprack_200ul/1', 'opentrons/opentrons_flex_96_filtertiprack_50ul/1', - 'opentrons/opentrons_flex_96_filtertiprack_20ul/1', ], minVolume: 5, maxVolume: 1000, @@ -176,9 +174,7 @@ describe('pipette data accessors', () => { $otSharedSchema: '#/pipette/schemas/2/pipetteLiquidPropertiesSchema.json', defaultTipracks: [ 'opentrons/opentrons_flex_96_tiprack_50ul/1', - 'opentrons/opentrons_flex_96_tiprack_20ul/1', 'opentrons/opentrons_flex_96_filtertiprack_50ul/1', - 'opentrons/opentrons_flex_96_filtertiprack_20ul/1', ], maxVolume: 50, minVolume: 5, @@ -259,9 +255,7 @@ describe('pipette data accessors', () => { $otSharedSchema: '#/pipette/schemas/2/pipetteLiquidPropertiesSchema.json', defaultTipracks: [ 'opentrons/opentrons_flex_96_tiprack_50ul/1', - 'opentrons/opentrons_flex_96_tiprack_20ul/1', 'opentrons/opentrons_flex_96_filtertiprack_50ul/1', - 'opentrons/opentrons_flex_96_filtertiprack_20ul/1', ], maxVolume: 30, minVolume: 1, diff --git a/shared-data/liquid-class/definitions/1/ethanol_80.json b/shared-data/liquid-class/definitions/1/ethanol_80.json index 767c53d44dab..71812c6be9f1 100644 --- a/shared-data/liquid-class/definitions/1/ethanol_80.json +++ b/shared-data/liquid-class/definitions/1/ethanol_80.json @@ -54,7 +54,7 @@ } } }, - "positionReference": "well-top", + "positionReference": "well-bottom", "offset": { "x": 0, "y": 0, @@ -288,7 +288,7 @@ } } }, - "positionReference": "well-top", + "positionReference": "well-bottom", "offset": { "x": 0, "y": 0, @@ -527,7 +527,7 @@ } } }, - "positionReference": "well-top", + "positionReference": "well-bottom", "offset": { "x": 0, "y": 0, @@ -761,7 +761,7 @@ } } }, - "positionReference": "well-top", + "positionReference": "well-bottom", "offset": { "x": 0, "y": 0, @@ -1000,7 +1000,7 @@ } } }, - "positionReference": "well-top", + "positionReference": "well-bottom", "offset": { "x": 0, "y": 0, @@ -1239,7 +1239,7 @@ } } }, - "positionReference": "well-top", + "positionReference": "well-bottom", "offset": { "x": 0, "y": 0, @@ -1478,7 +1478,7 @@ } } }, - "positionReference": "well-top", + "positionReference": "well-bottom", "offset": { "x": 0, "y": 0, @@ -1716,7 +1716,7 @@ } } }, - "positionReference": "well-top", + "positionReference": "well-bottom", "offset": { "x": 0, "y": 0, @@ -1954,7 +1954,7 @@ } } }, - "positionReference": "well-top", + "positionReference": "well-bottom", "offset": { "x": 0, "y": 0, @@ -2192,7 +2192,7 @@ } } }, - "positionReference": "well-top", + "positionReference": "well-bottom", "offset": { "x": 0, "y": 0, @@ -2435,7 +2435,7 @@ } } }, - "positionReference": "well-top", + "positionReference": "well-bottom", "offset": { "x": 0, "y": 0, @@ -2674,7 +2674,7 @@ } } }, - "positionReference": "well-top", + "positionReference": "well-bottom", "offset": { "x": 0, "y": 0, @@ -2913,7 +2913,7 @@ } } }, - "positionReference": "well-top", + "positionReference": "well-bottom", "offset": { "x": 0, "y": 0, @@ -3151,7 +3151,7 @@ } } }, - "positionReference": "well-top", + "positionReference": "well-bottom", "offset": { "x": 0, "y": 0, @@ -3389,7 +3389,7 @@ } } }, - "positionReference": "well-top", + "positionReference": "well-bottom", "offset": { "x": 0, "y": 0, @@ -3627,7 +3627,7 @@ } } }, - "positionReference": "well-top", + "positionReference": "well-bottom", "offset": { "x": 0, "y": 0, @@ -3870,7 +3870,7 @@ } } }, - "positionReference": "well-top", + "positionReference": "well-bottom", "offset": { "x": 0, "y": 0, @@ -4109,7 +4109,7 @@ } } }, - "positionReference": "well-top", + "positionReference": "well-bottom", "offset": { "x": 0, "y": 0, @@ -4348,7 +4348,7 @@ } } }, - "positionReference": "well-top", + "positionReference": "well-bottom", "offset": { "x": 0, "y": 0, @@ -4586,7 +4586,7 @@ } } }, - "positionReference": "well-top", + "positionReference": "well-bottom", "offset": { "x": 0, "y": 0, @@ -4824,7 +4824,7 @@ } } }, - "positionReference": "well-top", + "positionReference": "well-bottom", "offset": { "x": 0, "y": 0, @@ -5062,7 +5062,7 @@ } } }, - "positionReference": "well-top", + "positionReference": "well-bottom", "offset": { "x": 0, "y": 0, diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/default/3_5.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/default/3_5.json index 09401af422d1..ce7a81705ad7 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/default/3_5.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/default/3_5.json @@ -280,10 +280,8 @@ "opentrons/opentrons_flex_96_tiprack_1000ul/1", "opentrons/opentrons_flex_96_tiprack_200ul/1", "opentrons/opentrons_flex_96_tiprack_50ul/1", - "opentrons/opentrons_flex_96_tiprack_20ul/1", "opentrons/opentrons_flex_96_filtertiprack_1000ul/1", "opentrons/opentrons_flex_96_filtertiprack_200ul/1", - "opentrons/opentrons_flex_96_filtertiprack_50ul/1", - "opentrons/opentrons_flex_96_filtertiprack_20ul/1" + "opentrons/opentrons_flex_96_filtertiprack_50ul/1" ] } diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/3_5.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/3_5.json index ff9676e70eb3..d9f55a5ce918 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/3_5.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/3_5.json @@ -150,8 +150,6 @@ "minVolume": 5, "defaultTipracks": [ "opentrons/opentrons_flex_96_tiprack_50ul/1", - "opentrons/opentrons_flex_96_tiprack_20ul/1", - "opentrons/opentrons_flex_96_filtertiprack_50ul/1", - "opentrons/opentrons_flex_96_filtertiprack_20ul/1" + "opentrons/opentrons_flex_96_filtertiprack_50ul/1" ] } diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/lowVolumeDefault/3_5.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/lowVolumeDefault/3_5.json index 4d59ad3d7e4c..e453a5dca00b 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/lowVolumeDefault/3_5.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/lowVolumeDefault/3_5.json @@ -146,8 +146,6 @@ "minVolume": 1, "defaultTipracks": [ "opentrons/opentrons_flex_96_tiprack_50ul/1", - "opentrons/opentrons_flex_96_tiprack_20ul/1", - "opentrons/opentrons_flex_96_filtertiprack_50ul/1", - "opentrons/opentrons_flex_96_filtertiprack_20ul/1" + "opentrons/opentrons_flex_96_filtertiprack_50ul/1" ] } diff --git a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_5.json b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_5.json index ad7dbf435132..b12e1a1c4602 100644 --- a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_5.json +++ b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_5.json @@ -229,7 +229,6 @@ "maxVolume": 1000, "minVolume": 5, "defaultTipracks": [ - "opentrons/opentrons_flex_96_tiprack_20ul/1", "opentrons/opentrons_flex_96_tiprack_1000ul/1", "opentrons/opentrons_flex_96_tiprack_200ul/1", "opentrons/opentrons_flex_96_tiprack_50ul/1", diff --git a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_6.json b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_6.json index d2816c9955e5..a6269b72bc60 100644 --- a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_6.json +++ b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_6.json @@ -232,10 +232,8 @@ "opentrons/opentrons_flex_96_tiprack_1000ul/1", "opentrons/opentrons_flex_96_tiprack_200ul/1", "opentrons/opentrons_flex_96_tiprack_50ul/1", - "opentrons/opentrons_flex_96_tiprack_20ul/1", "opentrons/opentrons_flex_96_filtertiprack_1000ul/1", "opentrons/opentrons_flex_96_filtertiprack_200ul/1", - "opentrons/opentrons_flex_96_filtertiprack_50ul/1", - "opentrons/opentrons_flex_96_filtertiprack_20ul/1" + "opentrons/opentrons_flex_96_filtertiprack_50ul/1" ] } diff --git a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p200/default/3_0.json b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p200/default/3_0.json index fec59de15a62..4faa944089de 100644 --- a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p200/default/3_0.json +++ b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p200/default/3_0.json @@ -174,9 +174,7 @@ "defaultTipracks": [ "opentrons/opentrons_flex_96_tiprack_200ul/1", "opentrons/opentrons_flex_96_tiprack_50ul/1", - "opentrons/opentrons_flex_96_tiprack_20ul/1", "opentrons/opentrons_flex_96_filtertiprack_200ul/1", - "opentrons/opentrons_flex_96_filtertiprack_50ul/1", - "opentrons/opentrons_flex_96_filtertiprack_20ul/1" + "opentrons/opentrons_flex_96_filtertiprack_50ul/1" ] } diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_5.json b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_5.json index 9e126f536f5e..ca638186145f 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_5.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_5.json @@ -248,10 +248,8 @@ "opentrons/opentrons_flex_96_tiprack_1000ul/1", "opentrons/opentrons_flex_96_tiprack_200ul/1", "opentrons/opentrons_flex_96_tiprack_50ul/1", - "opentrons/opentrons_flex_96_tiprack_20ul/1", "opentrons/opentrons_flex_96_filtertiprack_1000ul/1", "opentrons/opentrons_flex_96_filtertiprack_200ul/1", - "opentrons/opentrons_flex_96_filtertiprack_50ul/1", - "opentrons/opentrons_flex_96_filtertiprack_20ul/1" + "opentrons/opentrons_flex_96_filtertiprack_50ul/1" ] } diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_6.json b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_6.json index 6b902337a7da..3c32c3881d38 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_6.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_6.json @@ -248,10 +248,8 @@ "opentrons/opentrons_flex_96_tiprack_1000ul/1", "opentrons/opentrons_flex_96_tiprack_200ul/1", "opentrons/opentrons_flex_96_tiprack_50ul/1", - "opentrons/opentrons_flex_96_tiprack_20ul/1", "opentrons/opentrons_flex_96_filtertiprack_1000ul/1", "opentrons/opentrons_flex_96_filtertiprack_200ul/1", - "opentrons/opentrons_flex_96_filtertiprack_50ul/1", - "opentrons/opentrons_flex_96_filtertiprack_20ul/1" + "opentrons/opentrons_flex_96_filtertiprack_50ul/1" ] } diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_7.json b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_7.json index 3822d30c2f4a..923108d6daf6 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_7.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_7.json @@ -248,10 +248,8 @@ "opentrons/opentrons_flex_96_tiprack_1000ul/1", "opentrons/opentrons_flex_96_tiprack_200ul/1", "opentrons/opentrons_flex_96_tiprack_50ul/1", - "opentrons/opentrons_flex_96_tiprack_20ul/1", "opentrons/opentrons_flex_96_filtertiprack_1000ul/1", "opentrons/opentrons_flex_96_filtertiprack_200ul/1", - "opentrons/opentrons_flex_96_filtertiprack_50ul/1", - "opentrons/opentrons_flex_96_filtertiprack_20ul/1" + "opentrons/opentrons_flex_96_filtertiprack_50ul/1" ] } diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/3_5.json b/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/3_5.json index 91bcb28e628c..09a792bb6518 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/3_5.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/3_5.json @@ -140,8 +140,6 @@ "minVolume": 5, "defaultTipracks": [ "opentrons/opentrons_flex_96_tiprack_50ul/1", - "opentrons/opentrons_flex_96_tiprack_20ul/1", - "opentrons/opentrons_flex_96_filtertiprack_50ul/1", - "opentrons/opentrons_flex_96_filtertiprack_20ul/1" + "opentrons/opentrons_flex_96_filtertiprack_50ul/1" ] } diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/3_6.json b/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/3_6.json index e2071093e131..e6506c77bb80 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/3_6.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/3_6.json @@ -140,8 +140,6 @@ "minVolume": 5, "defaultTipracks": [ "opentrons/opentrons_flex_96_tiprack_50ul/1", - "opentrons/opentrons_flex_96_tiprack_20ul/1", - "opentrons/opentrons_flex_96_filtertiprack_50ul/1", - "opentrons/opentrons_flex_96_filtertiprack_20ul/1" + "opentrons/opentrons_flex_96_filtertiprack_50ul/1" ] } diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p50/lowVolumeDefault/3_5.json b/shared-data/pipette/definitions/2/liquid/single_channel/p50/lowVolumeDefault/3_5.json index d5c3e11c7ba6..5a1540fc9914 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p50/lowVolumeDefault/3_5.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p50/lowVolumeDefault/3_5.json @@ -136,8 +136,6 @@ "minVolume": 1, "defaultTipracks": [ "opentrons/opentrons_flex_96_tiprack_50ul/1", - "opentrons/opentrons_flex_96_tiprack_20ul/1", - "opentrons/opentrons_flex_96_filtertiprack_50ul/1", - "opentrons/opentrons_flex_96_filtertiprack_20ul/1" + "opentrons/opentrons_flex_96_filtertiprack_50ul/1" ] } diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p50/lowVolumeDefault/3_6.json b/shared-data/pipette/definitions/2/liquid/single_channel/p50/lowVolumeDefault/3_6.json index a178f6cc7805..320494037fc6 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p50/lowVolumeDefault/3_6.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p50/lowVolumeDefault/3_6.json @@ -136,8 +136,6 @@ "minVolume": 1, "defaultTipracks": [ "opentrons/opentrons_flex_96_tiprack_50ul/1", - "opentrons/opentrons_flex_96_tiprack_20ul/1", - "opentrons/opentrons_flex_96_filtertiprack_50ul/1", - "opentrons/opentrons_flex_96_filtertiprack_20ul/1" + "opentrons/opentrons_flex_96_filtertiprack_50ul/1" ] } diff --git a/system-server/system_server/app_setup.py b/system-server/system_server/app_setup.py index e6fa9a20e84f..276b99b39eea 100644 --- a/system-server/system_server/app_setup.py +++ b/system-server/system_server/app_setup.py @@ -1,8 +1,11 @@ """Main FastAPI application.""" import logging +from typing import List, Any + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from typing import List, Any + +from server_utils.fastapi_utils.server_timing_middleware import server_timing_middleware from system_server._version import version from system_server.settings import get_settings @@ -22,7 +25,6 @@ redoc_url="/system/redoc", ) -# cors app.add_middleware( CORSMiddleware, allow_origins=("*"), @@ -31,6 +33,8 @@ allow_headers=["*"], ) +app.middleware("http")(server_timing_middleware()) + # main router app.include_router(router=router)