diff --git a/.eslintrc.js b/.eslintrc.js index 7c71dc01c22..6e70df2ff27 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -52,6 +52,7 @@ module.exports = { 'useLastRunCommandKey', 'useCurrentMaintenanceRun', 'useDeckConfigurationQuery', + 'useAllCommandsAsPreSerializedList', ], message: 'The HTTP hook is deprecated. Utilize the equivalent notification wrapper (useNotifyX) instead.', diff --git a/api-client/src/runs/commands/getCommandsAsPreSerializedList.ts b/api-client/src/runs/commands/getCommandsAsPreSerializedList.ts new file mode 100644 index 00000000000..420f984b280 --- /dev/null +++ b/api-client/src/runs/commands/getCommandsAsPreSerializedList.ts @@ -0,0 +1,22 @@ +import { GET, request } from '../../request' + +import type { ResponsePromise } from '../../request' +import type { HostConfig } from '../../types' +import type { + CommandsAsPreSerializedListData, + GetCommandsParams, +} from './types' + +export function getCommandsAsPreSerializedList( + config: HostConfig, + runId: string, + params: GetCommandsParams +): ResponsePromise { + return request( + GET, + `/runs/${runId}/commandsAsPreSerializedList`, + null, + config, + params + ) +} diff --git a/api-client/src/runs/commands/types.ts b/api-client/src/runs/commands/types.ts index acea40e1880..d0b443b297a 100644 --- a/api-client/src/runs/commands/types.ts +++ b/api-client/src/runs/commands/types.ts @@ -34,6 +34,12 @@ export interface CommandsData { links: CommandsLinks } +export interface CommandsAsPreSerializedListData { + data: string[] + meta: GetCommandsParams & { totalLength: number } + links: CommandsLinks +} + export interface CreateCommandParams { waitUntilComplete?: boolean timeout?: number diff --git a/api-client/src/runs/index.ts b/api-client/src/runs/index.ts index 1d62755d4c5..01653713c81 100644 --- a/api-client/src/runs/index.ts +++ b/api-client/src/runs/index.ts @@ -7,6 +7,7 @@ export { createCommand } from './commands/createCommand' export { createLiveCommand } from './commands/createLiveCommand' export { getCommand } from './commands/getCommand' export { getCommands } from './commands/getCommands' +export { getCommandsAsPreSerializedList } from './commands/getCommandsAsPreSerializedList' export { createRunAction } from './createRunAction' export * from './createLabwareOffset' export * from './createLabwareDefinition' diff --git a/api-client/src/system/__tests__/utils.test.ts b/api-client/src/system/__tests__/utils.test.ts new file mode 100644 index 00000000000..3121c061a59 --- /dev/null +++ b/api-client/src/system/__tests__/utils.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { sanitizeFileName } from '../utils' + +describe('sanitizeFileName', () => { + it('returns original alphanumeric file name', () => { + expect(sanitizeFileName('an0ther_otie_logo.png')).toEqual( + 'an0ther_otie_logo.png' + ) + }) + + it('sanitizes a file name', () => { + expect( + sanitizeFileName( + `otie's birthday/party - (& the bouncy castle cost ~$100,000).jpeg` + ) + ).toEqual( + 'otie_s_birthday_party_-____the_bouncy_castle_cost___100_000_.jpeg' + ) + }) +}) diff --git a/api-client/src/system/createSplash.ts b/api-client/src/system/createSplash.ts index fd0b11bd575..abaa280b226 100644 --- a/api-client/src/system/createSplash.ts +++ b/api-client/src/system/createSplash.ts @@ -1,4 +1,5 @@ import { POST, request } from '../request' +import { sanitizeFileName } from './utils' import type { ResponsePromise } from '../request' import type { HostConfig } from '../types' @@ -6,8 +7,9 @@ export function createSplash( config: HostConfig, file: File ): ResponsePromise { - // sanitize file name to ensure no spaces - const renamedFile = new File([file], file.name.replace(' ', '_'), { + // sanitize file name to ensure no spaces or special characters + const newFileName = sanitizeFileName(file.name) + const renamedFile = new File([file], newFileName, { type: 'image/png', }) diff --git a/api-client/src/system/index.ts b/api-client/src/system/index.ts index 3c63202c31f..4dc86594d2c 100644 --- a/api-client/src/system/index.ts +++ b/api-client/src/system/index.ts @@ -3,3 +3,4 @@ export { createRegistration } from './createRegistration' export { createSplash } from './createSplash' export { getConnections } from './getConnections' export * from './types' +export * from './utils' diff --git a/api-client/src/system/utils.ts b/api-client/src/system/utils.ts new file mode 100644 index 00000000000..cc0eea11130 --- /dev/null +++ b/api-client/src/system/utils.ts @@ -0,0 +1,3 @@ +export function sanitizeFileName(fileName: string): string { + return fileName.replace(/[^a-zA-Z0-9-.]/gi, '_') +} diff --git a/api/src/opentrons/protocol_engine/state/commands.py b/api/src/opentrons/protocol_engine/state/commands.py index f9d7643b728..7500b16d631 100644 --- a/api/src/opentrons/protocol_engine/state/commands.py +++ b/api/src/opentrons/protocol_engine/state/commands.py @@ -362,6 +362,9 @@ def handle_action(self, action: Action) -> None: # noqa: C901 elif isinstance(action, StopAction): if not self._state.run_result: + if self._state.queue_status == QueueStatus.AWAITING_RECOVERY: + self._state.recovery_target_command_id = None + self._state.queue_status = QueueStatus.PAUSED if action.from_estop: self._state.stopped_by_estop = True diff --git a/api/tests/opentrons/protocol_engine/state/test_command_store_old.py b/api/tests/opentrons/protocol_engine/state/test_command_store_old.py index 60cdf27838f..7f376a0b019 100644 --- a/api/tests/opentrons/protocol_engine/state/test_command_store_old.py +++ b/api/tests/opentrons/protocol_engine/state/test_command_store_old.py @@ -638,6 +638,41 @@ def test_command_store_handles_stop_action( assert subject.state.command_history.get_setup_queue_ids() == OrderedSet() +def test_command_store_handles_stop_action_when_awaiting_recovery() -> None: + """It should mark the engine as non-gracefully stopped on StopAction.""" + subject = CommandStore(is_door_open=False, config=_make_config()) + + subject.handle_action( + PlayAction( + requested_at=datetime(year=2021, month=1, day=1), deck_configuration=[] + ) + ) + + subject.state.queue_status = QueueStatus.AWAITING_RECOVERY + + subject.handle_action(StopAction()) + + assert subject.state == CommandState( + command_history=CommandHistory(), + queue_status=QueueStatus.PAUSED, + run_result=RunResult.STOPPED, + run_completed_at=None, + is_door_blocking=False, + run_error=None, + finish_error=None, + failed_command=None, + command_error_recovery_types={}, + recovery_target_command_id=None, + run_started_at=datetime(year=2021, month=1, day=1), + latest_protocol_command_hash=None, + stopped_by_estop=False, + ) + assert subject.state.command_history.get_running_command() is None + assert subject.state.command_history.get_all_ids() == [] + assert subject.state.command_history.get_queue_ids() == OrderedSet() + assert subject.state.command_history.get_setup_queue_ids() == OrderedSet() + + def test_command_store_cannot_restart_after_should_stop() -> None: """It should reject a play action after finish.""" subject = CommandStore(is_door_open=False, config=_make_config()) diff --git a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[2eaf98de6a][Flex_S_v2_16_P1000_96_GRIP_HS_MB_TC_TM_TriggerPrepareForMountMovement].json b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[2eaf98de6a][Flex_S_v2_16_P1000_96_GRIP_HS_MB_TC_TM_TriggerPrepareForMountMovement].json index 9069637c0bc..cd97f5c0023 100644 --- a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[2eaf98de6a][Flex_S_v2_16_P1000_96_GRIP_HS_MB_TC_TM_TriggerPrepareForMountMovement].json +++ b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[2eaf98de6a][Flex_S_v2_16_P1000_96_GRIP_HS_MB_TC_TM_TriggerPrepareForMountMovement].json @@ -9496,7 +9496,7 @@ "notes": [], "params": { "addressableAreaName": "movableTrashB3", - "alternateDropLocation": false, + "alternateDropLocation": true, "forceDirect": false, "ignoreTipConfiguration": true, "offset": { @@ -9507,7 +9507,7 @@ }, "result": { "position": { - "x": 434.25, + "x": 466.25, "y": 257.0, "z": 40.0 } @@ -9629,7 +9629,7 @@ "notes": [], "params": { "addressableAreaName": "movableTrashB3", - "alternateDropLocation": false, + "alternateDropLocation": true, "forceDirect": false, "ignoreTipConfiguration": true, "offset": { @@ -9640,7 +9640,7 @@ }, "result": { "position": { - "x": 434.25, + "x": 402.25, "y": 257.0, "z": 40.0 } @@ -9762,7 +9762,7 @@ "notes": [], "params": { "addressableAreaName": "movableTrashB3", - "alternateDropLocation": false, + "alternateDropLocation": true, "forceDirect": false, "ignoreTipConfiguration": true, "offset": { @@ -9773,7 +9773,7 @@ }, "result": { "position": { - "x": 434.25, + "x": 466.25, "y": 257.0, "z": 40.0 } @@ -9895,7 +9895,7 @@ "notes": [], "params": { "addressableAreaName": "movableTrashB3", - "alternateDropLocation": false, + "alternateDropLocation": true, "forceDirect": false, "ignoreTipConfiguration": true, "offset": { @@ -9906,7 +9906,7 @@ }, "result": { "position": { - "x": 434.25, + "x": 402.25, "y": 257.0, "z": 40.0 } @@ -10028,7 +10028,7 @@ "notes": [], "params": { "addressableAreaName": "movableTrashB3", - "alternateDropLocation": false, + "alternateDropLocation": true, "forceDirect": false, "ignoreTipConfiguration": true, "offset": { @@ -10039,7 +10039,7 @@ }, "result": { "position": { - "x": 434.25, + "x": 466.25, "y": 257.0, "z": 40.0 } @@ -10161,7 +10161,7 @@ "notes": [], "params": { "addressableAreaName": "movableTrashB3", - "alternateDropLocation": false, + "alternateDropLocation": true, "forceDirect": false, "ignoreTipConfiguration": true, "offset": { @@ -10172,7 +10172,7 @@ }, "result": { "position": { - "x": 434.25, + "x": 402.25, "y": 257.0, "z": 40.0 } @@ -10294,7 +10294,7 @@ "notes": [], "params": { "addressableAreaName": "movableTrashB3", - "alternateDropLocation": false, + "alternateDropLocation": true, "forceDirect": false, "ignoreTipConfiguration": true, "offset": { @@ -10305,7 +10305,7 @@ }, "result": { "position": { - "x": 434.25, + "x": 466.25, "y": 257.0, "z": 40.0 } @@ -10427,7 +10427,7 @@ "notes": [], "params": { "addressableAreaName": "movableTrashB3", - "alternateDropLocation": false, + "alternateDropLocation": true, "forceDirect": false, "ignoreTipConfiguration": true, "offset": { @@ -10438,7 +10438,7 @@ }, "result": { "position": { - "x": 434.25, + "x": 402.25, "y": 257.0, "z": 40.0 } @@ -10560,7 +10560,7 @@ "notes": [], "params": { "addressableAreaName": "movableTrashB3", - "alternateDropLocation": false, + "alternateDropLocation": true, "forceDirect": false, "ignoreTipConfiguration": true, "offset": { @@ -10571,7 +10571,7 @@ }, "result": { "position": { - "x": 434.25, + "x": 466.25, "y": 257.0, "z": 40.0 } @@ -10693,7 +10693,7 @@ "notes": [], "params": { "addressableAreaName": "movableTrashB3", - "alternateDropLocation": false, + "alternateDropLocation": true, "forceDirect": false, "ignoreTipConfiguration": true, "offset": { @@ -10704,7 +10704,7 @@ }, "result": { "position": { - "x": 434.25, + "x": 402.25, "y": 257.0, "z": 40.0 } @@ -10826,7 +10826,7 @@ "notes": [], "params": { "addressableAreaName": "movableTrashB3", - "alternateDropLocation": false, + "alternateDropLocation": true, "forceDirect": false, "ignoreTipConfiguration": true, "offset": { @@ -10837,7 +10837,7 @@ }, "result": { "position": { - "x": 434.25, + "x": 466.25, "y": 257.0, "z": 40.0 } @@ -10959,7 +10959,7 @@ "notes": [], "params": { "addressableAreaName": "movableTrashB3", - "alternateDropLocation": false, + "alternateDropLocation": true, "forceDirect": false, "ignoreTipConfiguration": true, "offset": { @@ -10970,7 +10970,7 @@ }, "result": { "position": { - "x": 434.25, + "x": 402.25, "y": 257.0, "z": 40.0 } diff --git a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[9e56ee92f6][Flex_X_v2_16_P1000_96_GRIP_DropLabwareIntoTrashBin].json b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[9e56ee92f6][Flex_X_v2_16_P1000_96_GRIP_DropLabwareIntoTrashBin].json index 550f545cbcf..6916e6613a3 100644 --- a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[9e56ee92f6][Flex_X_v2_16_P1000_96_GRIP_DropLabwareIntoTrashBin].json +++ b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[9e56ee92f6][Flex_X_v2_16_P1000_96_GRIP_DropLabwareIntoTrashBin].json @@ -1261,7 +1261,7 @@ "notes": [], "params": { "addressableAreaName": "movableTrashC3", - "alternateDropLocation": false, + "alternateDropLocation": true, "forceDirect": false, "ignoreTipConfiguration": true, "offset": { @@ -1272,7 +1272,7 @@ }, "result": { "position": { - "x": 434.25, + "x": 466.25, "y": 150.0, "z": 40.0 } diff --git a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[c745e5824a][Flex_S_v2_16_P1000_96_GRIP_DeckConfiguration1NoModules].json b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[c745e5824a][Flex_S_v2_16_P1000_96_GRIP_DeckConfiguration1NoModules].json index 6bff22c30f4..ee20acd85cd 100644 --- a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[c745e5824a][Flex_S_v2_16_P1000_96_GRIP_DeckConfiguration1NoModules].json +++ b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[c745e5824a][Flex_S_v2_16_P1000_96_GRIP_DeckConfiguration1NoModules].json @@ -10629,7 +10629,7 @@ "notes": [], "params": { "addressableAreaName": "movableTrashC1", - "alternateDropLocation": false, + "alternateDropLocation": true, "forceDirect": false, "ignoreTipConfiguration": true, "offset": { @@ -10640,7 +10640,7 @@ }, "result": { "position": { - "x": 22.25, + "x": 54.25, "y": 150.0, "z": 40.0 } @@ -10888,7 +10888,7 @@ "notes": [], "params": { "addressableAreaName": "movableTrashD1", - "alternateDropLocation": false, + "alternateDropLocation": true, "forceDirect": false, "ignoreTipConfiguration": true, "offset": { @@ -10899,7 +10899,7 @@ }, "result": { "position": { - "x": 22.25, + "x": 54.25, "y": 43.0, "z": 40.0 } diff --git a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[c9e6e3d59d][OT2_X_v4_P300M_P20S_MM_TC1_TM_e2eTests].json b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[c9e6e3d59d][OT2_X_v4_P300M_P20S_MM_TC1_TM_e2eTests].json index 973666af416..ea5794cc6a8 100644 --- a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[c9e6e3d59d][OT2_X_v4_P300M_P20S_MM_TC1_TM_e2eTests].json +++ b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[c9e6e3d59d][OT2_X_v4_P300M_P20S_MM_TC1_TM_e2eTests].json @@ -6924,7 +6924,7 @@ "errorInfo": { "args": "('Cannot aspirate more than pipette max volume',)", "class": "AssertionError", - "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/legacy_commands/publisher.py\", line 113, in publish_context\n yield\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/instrument_context.py\", line 270, in aspirate\n self._core.aspirate(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py\", line 119, in aspirate\n new_volume <= self._pipette_dict[\"working_volume\"]\n" + "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/legacy_commands/publisher.py\", line 113, in publish_context\n yield\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/instrument_context.py\", line 272, in aspirate\n self._core.aspirate(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py\", line 119, in aspirate\n new_volume <= self._pipette_dict[\"working_volume\"]\n" }, "errorType": "PythonException", "wrappedErrors": [] @@ -6965,7 +6965,7 @@ "errorInfo": { "args": "('Cannot aspirate more than pipette max volume',)", "class": "AssertionError", - "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/task_queue.py\", line 84, in _run\n await self._run_func()\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/task_queue.py\", line 61, in _do_run\n await func(*args, **kwargs)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/protocol_runner.py\", line 219, in run_func\n await self._legacy_executor.execute(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/legacy_wrappers.py\", line 180, in execute\n await to_thread.run_sync(\n\n File \"/usr/local/lib/python3.10/site-packages/anyio/to_thread.py\", line 33, in run_sync\n return await get_asynclib().run_sync_in_worker_thread(\n\n File \"/usr/local/lib/python3.10/site-packages/anyio/_backends/_asyncio.py\", line 877, in run_sync_in_worker_thread\n return await future\n\n File \"/usr/local/lib/python3.10/site-packages/anyio/_backends/_asyncio.py\", line 807, in run\n result = context.run(func, *args)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/execution/execute.py\", line 63, in run_protocol\n execute_json_v4.dispatch_json(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/execution/execute_json_v4.py\", line 272, in dispatch_json\n pipette_command_map[command_type]( # type: ignore\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/execution/execute_json_v3.py\", line 159, in _aspirate\n pipette.aspirate(volume, location)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/api_support/util.py\", line 383, in _check_version_wrapper\n return decorated_obj(*args, **kwargs)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/instrument_context.py\", line 270, in aspirate\n self._core.aspirate(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py\", line 119, in aspirate\n new_volume <= self._pipette_dict[\"working_volume\"]\n" + "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/task_queue.py\", line 84, in _run\n await self._run_func()\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/task_queue.py\", line 61, in _do_run\n await func(*args, **kwargs)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/protocol_runner.py\", line 219, in run_func\n await self._legacy_executor.execute(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_runner/legacy_wrappers.py\", line 180, in execute\n await to_thread.run_sync(\n\n File \"/usr/local/lib/python3.10/site-packages/anyio/to_thread.py\", line 33, in run_sync\n return await get_asynclib().run_sync_in_worker_thread(\n\n File \"/usr/local/lib/python3.10/site-packages/anyio/_backends/_asyncio.py\", line 877, in run_sync_in_worker_thread\n return await future\n\n File \"/usr/local/lib/python3.10/site-packages/anyio/_backends/_asyncio.py\", line 807, in run\n result = context.run(func, *args)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/execution/execute.py\", line 63, in run_protocol\n execute_json_v4.dispatch_json(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/execution/execute_json_v4.py\", line 272, in dispatch_json\n pipette_command_map[command_type]( # type: ignore\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/execution/execute_json_v3.py\", line 159, in _aspirate\n pipette.aspirate(volume, location)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/api_support/util.py\", line 383, in _check_version_wrapper\n return decorated_obj(*args, **kwargs)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/instrument_context.py\", line 272, in aspirate\n self._core.aspirate(\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py\", line 119, in aspirate\n new_volume <= self._pipette_dict[\"working_volume\"]\n" }, "errorType": "PythonException", "wrappedErrors": [] diff --git a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[d8cb88b3b2][Flex_S_v2_16_P1000_96_TC_PartialTipPickupSingle].json b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[d8cb88b3b2][Flex_S_v2_16_P1000_96_TC_PartialTipPickupSingle].json index 5f18a326b43..bcd9c895119 100644 --- a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[d8cb88b3b2][Flex_S_v2_16_P1000_96_TC_PartialTipPickupSingle].json +++ b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[d8cb88b3b2][Flex_S_v2_16_P1000_96_TC_PartialTipPickupSingle].json @@ -3512,7 +3512,7 @@ "errorInfo": { "args": "('Nozzle layout configuration of style SINGLE is currently unsupported.',)", "class": "ValueError", - "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/execution/execute_python.py\", line 124, in run_python\n exec(\"run(__context)\", new_globs)\n\n File \"\", line 1, in \n\n File \"Flex_S_v2_16_P1000_96_TC_PartialTipPickupSingle.py\", line 16, in run\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/api_support/util.py\", line 383, in _check_version_wrapper\n return decorated_obj(*args, **kwargs)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/instrument_context.py\", line 1953, in configure_nozzle_layout\n raise ValueError(\n" + "traceback": " File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/execution/execute_python.py\", line 124, in run_python\n exec(\"run(__context)\", new_globs)\n\n File \"\", line 1, in \n\n File \"Flex_S_v2_16_P1000_96_TC_PartialTipPickupSingle.py\", line 16, in run\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocols/api_support/util.py\", line 383, in _check_version_wrapper\n return decorated_obj(*args, **kwargs)\n\n File \"/usr/local/lib/python3.10/site-packages/opentrons/protocol_api/instrument_context.py\", line 1961, in configure_nozzle_layout\n raise ValueError(\n" }, "errorType": "PythonException", "wrappedErrors": [] diff --git a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[f51172f73b][Flex_S_v2_16_P1000_96_GRIP_HS_MB_TC_TM_Smoke].json b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[f51172f73b][Flex_S_v2_16_P1000_96_GRIP_HS_MB_TC_TM_Smoke].json index de09e2915ff..05c042f1933 100644 --- a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[f51172f73b][Flex_S_v2_16_P1000_96_GRIP_HS_MB_TC_TM_Smoke].json +++ b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[f51172f73b][Flex_S_v2_16_P1000_96_GRIP_HS_MB_TC_TM_Smoke].json @@ -8291,7 +8291,7 @@ "notes": [], "params": { "addressableAreaName": "movableTrashB3", - "alternateDropLocation": false, + "alternateDropLocation": true, "forceDirect": false, "ignoreTipConfiguration": true, "offset": { @@ -8302,7 +8302,7 @@ }, "result": { "position": { - "x": 434.25, + "x": 466.25, "y": 257.0, "z": 40.0 } @@ -8399,7 +8399,7 @@ "notes": [], "params": { "addressableAreaName": "movableTrashB3", - "alternateDropLocation": false, + "alternateDropLocation": true, "forceDirect": false, "ignoreTipConfiguration": true, "offset": { @@ -8410,7 +8410,7 @@ }, "result": { "position": { - "x": 434.25, + "x": 402.25, "y": 257.0, "z": 40.0 } @@ -8507,7 +8507,7 @@ "notes": [], "params": { "addressableAreaName": "movableTrashB3", - "alternateDropLocation": false, + "alternateDropLocation": true, "forceDirect": false, "ignoreTipConfiguration": true, "offset": { @@ -8518,7 +8518,7 @@ }, "result": { "position": { - "x": 434.25, + "x": 466.25, "y": 257.0, "z": 40.0 } @@ -8615,7 +8615,7 @@ "notes": [], "params": { "addressableAreaName": "movableTrashB3", - "alternateDropLocation": false, + "alternateDropLocation": true, "forceDirect": false, "ignoreTipConfiguration": true, "offset": { @@ -8626,7 +8626,7 @@ }, "result": { "position": { - "x": 434.25, + "x": 402.25, "y": 257.0, "z": 40.0 } @@ -8723,7 +8723,7 @@ "notes": [], "params": { "addressableAreaName": "movableTrashB3", - "alternateDropLocation": false, + "alternateDropLocation": true, "forceDirect": false, "ignoreTipConfiguration": true, "offset": { @@ -8734,7 +8734,7 @@ }, "result": { "position": { - "x": 434.25, + "x": 466.25, "y": 257.0, "z": 40.0 } @@ -8831,7 +8831,7 @@ "notes": [], "params": { "addressableAreaName": "movableTrashB3", - "alternateDropLocation": false, + "alternateDropLocation": true, "forceDirect": false, "ignoreTipConfiguration": true, "offset": { @@ -8842,7 +8842,7 @@ }, "result": { "position": { - "x": 434.25, + "x": 402.25, "y": 257.0, "z": 40.0 } @@ -8939,7 +8939,7 @@ "notes": [], "params": { "addressableAreaName": "movableTrashB3", - "alternateDropLocation": false, + "alternateDropLocation": true, "forceDirect": false, "ignoreTipConfiguration": true, "offset": { @@ -8950,7 +8950,7 @@ }, "result": { "position": { - "x": 434.25, + "x": 466.25, "y": 257.0, "z": 40.0 } @@ -9047,7 +9047,7 @@ "notes": [], "params": { "addressableAreaName": "movableTrashB3", - "alternateDropLocation": false, + "alternateDropLocation": true, "forceDirect": false, "ignoreTipConfiguration": true, "offset": { @@ -9058,7 +9058,7 @@ }, "result": { "position": { - "x": 434.25, + "x": 402.25, "y": 257.0, "z": 40.0 } @@ -9155,7 +9155,7 @@ "notes": [], "params": { "addressableAreaName": "movableTrashB3", - "alternateDropLocation": false, + "alternateDropLocation": true, "forceDirect": false, "ignoreTipConfiguration": true, "offset": { @@ -9166,7 +9166,7 @@ }, "result": { "position": { - "x": 434.25, + "x": 466.25, "y": 257.0, "z": 40.0 } @@ -9263,7 +9263,7 @@ "notes": [], "params": { "addressableAreaName": "movableTrashB3", - "alternateDropLocation": false, + "alternateDropLocation": true, "forceDirect": false, "ignoreTipConfiguration": true, "offset": { @@ -9274,7 +9274,7 @@ }, "result": { "position": { - "x": 434.25, + "x": 402.25, "y": 257.0, "z": 40.0 } @@ -9371,7 +9371,7 @@ "notes": [], "params": { "addressableAreaName": "movableTrashB3", - "alternateDropLocation": false, + "alternateDropLocation": true, "forceDirect": false, "ignoreTipConfiguration": true, "offset": { @@ -9382,7 +9382,7 @@ }, "result": { "position": { - "x": 434.25, + "x": 466.25, "y": 257.0, "z": 40.0 } @@ -9479,7 +9479,7 @@ "notes": [], "params": { "addressableAreaName": "movableTrashB3", - "alternateDropLocation": false, + "alternateDropLocation": true, "forceDirect": false, "ignoreTipConfiguration": true, "offset": { @@ -9490,7 +9490,7 @@ }, "result": { "position": { - "x": 434.25, + "x": 402.25, "y": 257.0, "z": 40.0 } diff --git a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[f834b97da1][Flex_S_v2_16_P1000_96_GRIP_HS_MB_TC_TM_DeckConfiguration1].json b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[f834b97da1][Flex_S_v2_16_P1000_96_GRIP_HS_MB_TC_TM_DeckConfiguration1].json index 501aee6006d..d68d08d41ae 100644 --- a/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[f834b97da1][Flex_S_v2_16_P1000_96_GRIP_HS_MB_TC_TM_DeckConfiguration1].json +++ b/app-testing/tests/__snapshots__/analyses_snapshot_test/test_analysis_snapshot[f834b97da1][Flex_S_v2_16_P1000_96_GRIP_HS_MB_TC_TM_DeckConfiguration1].json @@ -12402,7 +12402,7 @@ "notes": [], "params": { "addressableAreaName": "movableTrashC1", - "alternateDropLocation": false, + "alternateDropLocation": true, "forceDirect": false, "ignoreTipConfiguration": true, "offset": { @@ -12413,7 +12413,7 @@ }, "result": { "position": { - "x": 22.25, + "x": 54.25, "y": 150.0, "z": 40.0 } @@ -12661,7 +12661,7 @@ "notes": [], "params": { "addressableAreaName": "movableTrashD1", - "alternateDropLocation": false, + "alternateDropLocation": true, "forceDirect": false, "ignoreTipConfiguration": true, "offset": { @@ -12672,7 +12672,7 @@ }, "result": { "position": { - "x": 22.25, + "x": 54.25, "y": 43.0, "z": 40.0 } diff --git a/app/src/organisms/CommandText/LoadCommandText.tsx b/app/src/organisms/CommandText/LoadCommandText.tsx index 62ce7cf1fd5..8dd2f8e64d1 100644 --- a/app/src/organisms/CommandText/LoadCommandText.tsx +++ b/app/src/organisms/CommandText/LoadCommandText.tsx @@ -131,7 +131,9 @@ export const LoadCommandText = ({ return null } } else { - const labware = command.result?.definition.metadata.displayName + const labware = + command.result?.definition.metadata.displayName ?? + command.params.displayName return command.params.location === 'offDeck' ? t('load_labware_info_protocol_setup_off_deck', { labware }) : t('load_labware_info_protocol_setup_no_module', { diff --git a/app/src/organisms/Devices/hooks/__tests__/useAttachedPipettesFromInstrumentsQuery.test.ts b/app/src/organisms/Devices/hooks/__tests__/useAttachedPipettesFromInstrumentsQuery.test.ts index 973e4837921..fbb72456a56 100644 --- a/app/src/organisms/Devices/hooks/__tests__/useAttachedPipettesFromInstrumentsQuery.test.ts +++ b/app/src/organisms/Devices/hooks/__tests__/useAttachedPipettesFromInstrumentsQuery.test.ts @@ -1,16 +1,22 @@ import * as React from 'react' -import { vi, it, expect, describe } from 'vitest' +import { vi, it, expect, describe, beforeEach } from 'vitest' import { renderHook } from '@testing-library/react' import { useInstrumentsQuery } from '@opentrons/react-api-client' import { instrumentsResponseLeftPipetteFixture, instrumentsResponseRightPipetteFixture, } from '@opentrons/api-client' +import { useIsOEMMode } from '../../../../resources/robot-settings/hooks' import { useAttachedPipettesFromInstrumentsQuery } from '..' vi.mock('@opentrons/react-api-client') +vi.mock('../../../../resources/robot-settings/hooks') describe('useAttachedPipettesFromInstrumentsQuery hook', () => { + beforeEach(() => { + vi.mocked(useIsOEMMode).mockReturnValue(false) + }) + let wrapper: React.FunctionComponent<{ children: React.ReactNode }> it('returns attached pipettes', () => { vi.mocked(useInstrumentsQuery).mockReturnValue({ diff --git a/app/src/organisms/Devices/hooks/useAttachedPipettesFromInstrumentsQuery.ts b/app/src/organisms/Devices/hooks/useAttachedPipettesFromInstrumentsQuery.ts index 770d71042fc..20427a60fbd 100644 --- a/app/src/organisms/Devices/hooks/useAttachedPipettesFromInstrumentsQuery.ts +++ b/app/src/organisms/Devices/hooks/useAttachedPipettesFromInstrumentsQuery.ts @@ -1,7 +1,10 @@ import { useInstrumentsQuery } from '@opentrons/react-api-client' -import { getPipetteModelSpecs, PipetteModel } from '@opentrons/shared-data' +import { LEFT, RIGHT } from '@opentrons/shared-data' +import { usePipetteModelSpecs } from '../../../resources/instruments/hooks' + import type { PipetteData } from '@opentrons/api-client' import type { Mount } from '@opentrons/components' +import type { PipetteModel } from '@opentrons/shared-data' export interface PipetteInformation extends PipetteData { displayName: string @@ -9,27 +12,32 @@ export interface PipetteInformation extends PipetteData { export type AttachedPipettesFromInstrumentsQuery = { [mount in Mount]: null | PipetteInformation } - export function useAttachedPipettesFromInstrumentsQuery(): AttachedPipettesFromInstrumentsQuery { - const { data: attachedInstruments } = useInstrumentsQuery() - return (attachedInstruments?.data ?? []).reduce( - (acc, instrumentData) => { - if (instrumentData.instrumentType !== 'pipette' || !instrumentData.ok) { - return acc - } - const { mount, instrumentModel } = instrumentData - return { - ...acc, - [mount as Mount]: { - ...instrumentData, - displayName: - instrumentModel != null - ? getPipetteModelSpecs(instrumentModel as PipetteModel) - ?.displayName ?? '' - : '', - }, - } - }, - { left: null, right: null } + const attachedInstruments = useInstrumentsQuery()?.data?.data ?? [] + + const okPipettes = attachedInstruments.filter( + (instrument): instrument is PipetteData => + instrument.instrumentType === 'pipette' && instrument.ok ) + + const leftPipette = okPipettes.find(({ mount }) => mount === LEFT) ?? null + const rightPipette = okPipettes.find(({ mount }) => mount === RIGHT) ?? null + + const leftDisplayName = + usePipetteModelSpecs(leftPipette?.instrumentModel as PipetteModel) + ?.displayName ?? '' + const rightDisplayName = + usePipetteModelSpecs(rightPipette?.instrumentModel as PipetteModel) + ?.displayName ?? '' + + return { + [LEFT]: + leftPipette != null + ? { ...leftPipette, displayName: leftDisplayName } + : null, + [RIGHT]: + rightPipette != null + ? { ...rightPipette, displayName: rightDisplayName } + : null, + } } diff --git a/app/src/organisms/FirmwareUpdateModal/UpdateResultsModal.tsx b/app/src/organisms/FirmwareUpdateModal/UpdateResultsModal.tsx index 27286bd8853..967d9a0632f 100644 --- a/app/src/organisms/FirmwareUpdateModal/UpdateResultsModal.tsx +++ b/app/src/organisms/FirmwareUpdateModal/UpdateResultsModal.tsx @@ -1,6 +1,5 @@ import * as React from 'react' import { useTranslation, Trans } from 'react-i18next' -import { getPipetteModelSpecs } from '@opentrons/shared-data' import { ALIGN_CENTER, BORDERS, @@ -14,8 +13,9 @@ import { } from '@opentrons/components' import { SmallButton } from '../../atoms/buttons' import { Modal } from '../../molecules/Modal' +import { usePipetteModelSpecs } from '../../resources/instruments/hooks' -import type { InstrumentData } from '@opentrons/api-client' +import type { InstrumentData, PipetteData } from '@opentrons/api-client' import type { ModalHeaderBaseProps } from '../../molecules/Modal/types' interface UpdateResultsModalProps { @@ -36,12 +36,16 @@ export function UpdateResultsModal( iconName: 'ot-alert', iconColor: COLORS.red50, } + + const pipetteDisplayName = usePipetteModelSpecs( + (instrument as PipetteData)?.instrumentModel + )?.displayName + let instrumentName = 'instrument' if (instrument?.ok) { instrumentName = instrument?.instrumentType === 'pipette' - ? getPipetteModelSpecs(instrument.instrumentModel)?.displayName ?? - 'pipette' + ? pipetteDisplayName ?? 'pipette' : 'Flex Gripper' } return ( diff --git a/app/src/organisms/InstrumentMountItem/LabeledMount.tsx b/app/src/organisms/InstrumentMountItem/LabeledMount.tsx index ae69c9331b3..65d562543af 100644 --- a/app/src/organisms/InstrumentMountItem/LabeledMount.tsx +++ b/app/src/organisms/InstrumentMountItem/LabeledMount.tsx @@ -40,7 +40,7 @@ interface LabeledMountProps { export function LabeledMount(props: LabeledMountProps): JSX.Element { const { t } = useTranslation('device_details') const { mount, instrumentName, handleClick } = props - const ninetySixDisplayName = 'Flex 96-Channel 1000 μL' + const isNinetySixChannel = instrumentName?.includes('96-Channel') ?? false return ( @@ -62,9 +62,7 @@ export function LabeledMount(props: LabeledMountProps): JSX.Element { fontSize={TYPOGRAPHY.fontSize28} width="15.625rem" > - {instrumentName === ninetySixDisplayName - ? t('left_right') - : t('mount', { side: mount })} + {isNinetySixChannel ? t('left_right') : t('mount', { side: mount })} fixture.cutoutId === WASTE_CHUTE_CUTOUT) ?? false + const pipetteDisplayName = usePipetteNameSpecs( + requiredPipette?.pipetteName as PipetteName + )?.displayName + if ( pipetteId == null && (flowType === FLOWS.CALIBRATE || flowType === FLOWS.DETACH) @@ -109,9 +116,7 @@ export const BeforeBeginning = ( bodyTranslationKey = 'remove_labware' let displayName: string | undefined if (requiredPipette != null) { - displayName = - getPipetteNameSpecs(requiredPipette.pipetteName)?.displayName ?? - requiredPipette.pipetteName + displayName = pipetteDisplayName ?? requiredPipette.pipetteName } if (selectedPipette === SINGLE_MOUNT_PIPETTES) { equipmentList = [ @@ -134,9 +139,7 @@ export const BeforeBeginning = ( } case FLOWS.DETACH: { if (requiredPipette != null) { - const displayName = - getPipetteNameSpecs(requiredPipette.pipetteName)?.displayName ?? - requiredPipette.pipetteName + const displayName = pipetteDisplayName ?? requiredPipette.pipetteName bodyTranslationKey = 'remove_labware' if (requiredPipette.pipetteName === 'p1000_96') { diff --git a/app/src/organisms/PipetteWizardFlows/Results.tsx b/app/src/organisms/PipetteWizardFlows/Results.tsx index fda57800151..5f652f3f895 100644 --- a/app/src/organisms/PipetteWizardFlows/Results.tsx +++ b/app/src/organisms/PipetteWizardFlows/Results.tsx @@ -12,19 +12,19 @@ import { StyledText, TYPOGRAPHY, } from '@opentrons/components' -import { - getPipetteNameSpecs, - LEFT, - RIGHT, - LoadedPipette, - MotorAxes, - NINETY_SIX_CHANNEL, -} from '@opentrons/shared-data' +import { LEFT, RIGHT, NINETY_SIX_CHANNEL } from '@opentrons/shared-data' +import { SmallButton } from '../../atoms/buttons' import { InProgressModal } from '../../molecules/InProgressModal/InProgressModal' import { SimpleWizardBody } from '../../molecules/SimpleWizardBody' -import { SmallButton } from '../../atoms/buttons' +import { usePipetteNameSpecs } from '../../resources/instruments/hooks' import { CheckPipetteButton } from './CheckPipetteButton' import { FLOWS } from './constants' + +import type { + LoadedPipette, + MotorAxes, + PipetteName, +} from '@opentrons/shared-data' import type { PipetteWizardStepProps } from './types' interface ResultsProps extends PipetteWizardStepProps { @@ -71,10 +71,11 @@ export const Results = (props: ResultsProps): JSX.Element => { const isCorrectPipette = requiredPipette != null && requiredPipette.pipetteName === attachedPipettes[mount]?.instrumentName + const requiredPipDisplayName = - requiredPipette != null - ? getPipetteNameSpecs(requiredPipette.pipetteName)?.displayName - : null + usePipetteNameSpecs(requiredPipette?.pipetteName as PipetteName) + ?.displayName ?? null + const [numberOfTryAgains, setNumberOfTryAgains] = React.useState(0) let header: string = 'unknown results screen' let iconColor: string = COLORS.green50 diff --git a/app/src/organisms/PipetteWizardFlows/__tests__/Results.test.tsx b/app/src/organisms/PipetteWizardFlows/__tests__/Results.test.tsx index bf5a1d4d7aa..b0cb919531c 100644 --- a/app/src/organisms/PipetteWizardFlows/__tests__/Results.test.tsx +++ b/app/src/organisms/PipetteWizardFlows/__tests__/Results.test.tsx @@ -12,6 +12,7 @@ import { useInstrumentsQuery } from '@opentrons/react-api-client' import { renderWithProviders } from '../../../__testing-utils__' import { mockAttachedPipetteInformation } from '../../../redux/pipettes/__fixtures__' +import { useIsOEMMode } from '../../../resources/robot-settings/hooks' import { i18n } from '../../../i18n' import { RUN_ID_1 } from '../../RunTimeControl/__fixtures__' import { Results } from '../Results' @@ -20,6 +21,7 @@ import { FLOWS } from '../constants' import type { Mock } from 'vitest' vi.mock('@opentrons/react-api-client') +vi.mock('../../../resources/robot-settings/hooks') const render = (props: React.ComponentProps) => { return renderWithProviders(, { @@ -57,6 +59,7 @@ describe('Results', () => { vi.mocked(useInstrumentsQuery).mockReturnValue({ refetch: mockRefetchInstruments, } as any) + vi.mocked(useIsOEMMode).mockReturnValue(false) }) it('renders the correct information when pipette cal is a success for calibrate flow', () => { props = { diff --git a/app/src/organisms/RunPreview/index.tsx b/app/src/organisms/RunPreview/index.tsx index 285daefde77..05178f0c05f 100644 --- a/app/src/organisms/RunPreview/index.tsx +++ b/app/src/organisms/RunPreview/index.tsx @@ -4,7 +4,6 @@ import { useTranslation } from 'react-i18next' import { ViewportList, ViewportListRef } from 'react-viewport-list' import { RUN_STATUSES_TERMINAL } from '@opentrons/api-client' -import { useAllCommandsQuery } from '@opentrons/react-api-client' import { ALIGN_CENTER, BORDERS, @@ -21,7 +20,11 @@ import { } from '@opentrons/components' import { useMostRecentCompletedAnalysis } from '../LabwarePositionCheck/useMostRecentCompletedAnalysis' -import { useNotifyLastRunCommand } from '../../resources/runs' +import { + useNotifyLastRunCommand, + useNotifyAllCommandsAsPreSerializedList, + } from '../../resources/runs' +>>>>>>> edge import { CommandText } from '../CommandText' import { Divider } from '../../atoms/structure' import { NAV_BAR_WIDTH } from '../../App/constants' @@ -33,6 +36,8 @@ import type { RobotType } from '@opentrons/shared-data' const COLOR_FADE_MS = 500 const LIVE_RUN_COMMANDS_POLL_MS = 3000 +// arbitrary large number of commands +const MAX_COMMANDS = 100000 interface RunPreviewProps { runId: string @@ -52,11 +57,17 @@ export const RunPreviewComponent = ( ? (RUN_STATUSES_TERMINAL as RunStatus[]).includes(runStatus) : false // we only ever want one request done for terminal runs because this is a heavy request - const commandsFromQuery = useAllCommandsQuery(runId, null, { - staleTime: Infinity, - cacheTime: Infinity, - enabled: isRunTerminal, - }).data?.data + const commandsFromQuery = useNotifyAllCommandsAsPreSerializedList( + runId, + { cursor: 0, pageLength: MAX_COMMANDS }, + { + staleTime: Infinity, + cacheTime: Infinity, + enabled: isRunTerminal, + } + ).data?.data + const nullCheckedCommandsFromQuery = + commandsFromQuery == null ? robotSideAnalysis?.commands : commandsFromQuery const viewPortRef = React.useRef(null) const currentRunCommandKey = useNotifyLastRunCommand(runId, { refetchInterval: LIVE_RUN_COMMANDS_POLL_MS, @@ -67,7 +78,9 @@ export const RunPreviewComponent = ( ] = React.useState(true) if (robotSideAnalysis == null) return null const commands = - (isRunTerminal ? commandsFromQuery : robotSideAnalysis.commands) ?? [] + (isRunTerminal + ? nullCheckedCommandsFromQuery + : robotSideAnalysis.commands) ?? [] const currentRunCommandIndex = commands.findIndex( c => c.key === currentRunCommandKey ) diff --git a/app/src/redux/shell/types.ts b/app/src/redux/shell/types.ts index 276d081fc71..8f485e24bd7 100644 --- a/app/src/redux/shell/types.ts +++ b/app/src/redux/shell/types.ts @@ -142,6 +142,7 @@ export type NotifyTopic = | 'robot-server/runs' | `robot-server/runs/${string}` | 'robot-server/deck_configuration' + | `robot-server/runs/pre_serialized_commands/${string}` export interface NotifySubscribeAction { type: 'shell:NOTIFY_SUBSCRIBE' diff --git a/app/src/resources/__tests__/useNotifyService.test.ts b/app/src/resources/__tests__/useNotifyService.test.ts index ce513e3e572..f2bf6bb516f 100644 --- a/app/src/resources/__tests__/useNotifyService.test.ts +++ b/app/src/resources/__tests__/useNotifyService.test.ts @@ -8,7 +8,6 @@ import { useNotifyService } from '../useNotifyService' import { appShellListener } from '../../redux/shell/remote' import { useTrackEvent } from '../../redux/analytics' import { notifySubscribeAction } from '../../redux/shell' -import { useIsFlex } from '../../organisms/Devices/hooks/useIsFlex' import type { Mock } from 'vitest' import type { HostConfig } from '@opentrons/api-client' @@ -21,7 +20,6 @@ vi.mock('../../redux/analytics') vi.mock('../../redux/shell/remote', () => ({ appShellListener: vi.fn(), })) -vi.mock('../../organisms/Devices/hooks/useIsFlex') const MOCK_HOST_CONFIG: HostConfig = { hostname: 'MOCK_HOST' } const MOCK_TOPIC = '/test/topic' as any @@ -41,7 +39,6 @@ describe('useNotifyService', () => { vi.mocked(useTrackEvent).mockReturnValue(mockTrackEvent) vi.mocked(useDispatch).mockReturnValue(mockDispatch) vi.mocked(useHost).mockReturnValue(MOCK_HOST_CONFIG) - vi.mocked(useIsFlex).mockReturnValue(true) vi.mocked(appShellListener).mockClear() }) diff --git a/app/src/resources/runs/index.ts b/app/src/resources/runs/index.ts index 78fe1d01011..91595d17f08 100644 --- a/app/src/resources/runs/index.ts +++ b/app/src/resources/runs/index.ts @@ -3,3 +3,4 @@ export * from './utils' export * from './useNotifyAllRunsQuery' export * from './useNotifyRunQuery' export * from './useNotifyLastRunCommand' +export * from './useNotifyAllCommandsAsPreSerializedList' diff --git a/app/src/resources/runs/useNotifyAllCommandsAsPreSerializedList.ts b/app/src/resources/runs/useNotifyAllCommandsAsPreSerializedList.ts new file mode 100644 index 00000000000..1410c23cef0 --- /dev/null +++ b/app/src/resources/runs/useNotifyAllCommandsAsPreSerializedList.ts @@ -0,0 +1,35 @@ +import * as React from 'react' + +import { useAllCommandsAsPreSerializedList } from '@opentrons/react-api-client' + +import { useNotifyService } from '../useNotifyService' + +import type { UseQueryResult } from 'react-query' +import type { AxiosError } from 'axios' +import type { CommandsData, GetCommandsParams } from '@opentrons/api-client' +import type { + QueryOptionsWithPolling, + HTTPRefetchFrequency, +} from '../useNotifyService' + +export function useNotifyAllCommandsAsPreSerializedList( + runId: string | null, + params?: GetCommandsParams | null, + options: QueryOptionsWithPolling = {} +): UseQueryResult { + const [refetch, setRefetch] = React.useState(null) + + useNotifyService({ + topic: `robot-server/runs/pre_serialized_commands/${runId}`, + setRefetch, + options, + }) + + const httpResponse = useAllCommandsAsPreSerializedList(runId, params, { + ...options, + enabled: options?.enabled !== false && refetch != null, + onSettled: refetch === 'once' ? () => setRefetch(null) : () => null, + }) + + return httpResponse +} diff --git a/app/src/resources/useNotifyService.ts b/app/src/resources/useNotifyService.ts index 19831dc9c62..d8422ba786f 100644 --- a/app/src/resources/useNotifyService.ts +++ b/app/src/resources/useNotifyService.ts @@ -10,7 +10,6 @@ import { useTrackEvent, ANALYTICS_NOTIFICATION_PORT_BLOCK_ERROR, } from '../redux/analytics' -import { useIsFlex } from '../organisms/Devices/hooks/useIsFlex' import type { UseQueryOptions } from 'react-query' import type { HostConfig } from '@opentrons/api-client' @@ -41,7 +40,6 @@ export function useNotifyService({ const host = hostOverride ?? hostFromProvider const hostname = host?.hostname ?? null const doTrackEvent = useTrackEvent() - const isFlex = useIsFlex(host?.robotName ?? '') const seenHostname = React.useRef(null) const { enabled, staleTime, forceHttpPolling } = options @@ -81,8 +79,7 @@ export function useNotifyService({ function onDataEvent(data: NotifyResponseData): void { if (data === 'ECONNFAILED' || data === 'ECONNREFUSED') { setRefetch('always') - // TODO(jh 2023-02-23): remove the robot type check once OT-2s support MQTT. - if (data === 'ECONNREFUSED' && isFlex) { + if (data === 'ECONNREFUSED') { doTrackEvent({ name: ANALYTICS_NOTIFICATION_PORT_BLOCK_ERROR, properties: {}, diff --git a/react-api-client/src/runs/index.ts b/react-api-client/src/runs/index.ts index a77e01b2b42..5790abb860b 100644 --- a/react-api-client/src/runs/index.ts +++ b/react-api-client/src/runs/index.ts @@ -10,6 +10,7 @@ export { usePauseRunMutation } from './usePauseRunMutation' export { useStopRunMutation } from './useStopRunMutation' export { useRunActionMutations } from './useRunActionMutations' export { useAllCommandsQuery } from './useAllCommandsQuery' +export { useAllCommandsAsPreSerializedList } from './useAllCommandsAsPreSerializedList' export { useCommandQuery } from './useCommandQuery' export * from './useCreateLabwareOffsetMutation' export * from './useCreateLabwareDefinitionMutation' diff --git a/react-api-client/src/runs/useAllCommandsAsPreSerializedList.ts b/react-api-client/src/runs/useAllCommandsAsPreSerializedList.ts new file mode 100644 index 00000000000..3d30d13c579 --- /dev/null +++ b/react-api-client/src/runs/useAllCommandsAsPreSerializedList.ts @@ -0,0 +1,52 @@ +import { UseQueryResult, useQuery } from 'react-query' +import { getCommandsAsPreSerializedList } from '@opentrons/api-client' +import { useHost } from '../api' +import type { UseQueryOptions } from 'react-query' +import type { + GetCommandsParams, + HostConfig, + CommandsData, + RunCommandSummary, +} from '@opentrons/api-client' + +const DEFAULT_PAGE_LENGTH = 30 +export const DEFAULT_PARAMS: GetCommandsParams = { + cursor: null, + pageLength: DEFAULT_PAGE_LENGTH, +} + +export function useAllCommandsAsPreSerializedList( + runId: string | null, + params?: GetCommandsParams | null, + options: UseQueryOptions = {} +): UseQueryResult { + const host = useHost() + const nullCheckedParams = params ?? DEFAULT_PARAMS + + const allOptions: UseQueryOptions = { + ...options, + enabled: host !== null && runId != null && options.enabled !== false, + } + const { cursor, pageLength } = nullCheckedParams + const query = useQuery( + [host, 'runs', runId, 'getCommandsAsPreSerializedList', cursor, pageLength], + () => { + return getCommandsAsPreSerializedList( + host as HostConfig, + runId as string, + nullCheckedParams + ).then(response => { + const responseData = response.data + return { + ...responseData, + data: responseData.data.map( + command => JSON.parse(command) as RunCommandSummary + ), + } + }) + }, + allOptions + ) + + return query +} diff --git a/robot-server/robot_server/runs/router/actions_router.py b/robot-server/robot_server/runs/router/actions_router.py index b662d59f554..25aae8cfd19 100644 --- a/robot-server/robot_server/runs/router/actions_router.py +++ b/robot-server/robot_server/runs/router/actions_router.py @@ -28,6 +28,7 @@ MaintenanceEngineStore, ) from robot_server.maintenance_runs.dependencies import get_maintenance_engine_store +from robot_server.service.notifications import get_runs_publisher, RunsPublisher log = logging.getLogger(__name__) actions_router = APIRouter() @@ -45,6 +46,7 @@ async def get_run_controller( task_runner: TaskRunner = Depends(get_task_runner), engine_store: EngineStore = Depends(get_engine_store), run_store: RunStore = Depends(get_run_store), + runs_publisher: RunsPublisher = Depends(get_runs_publisher), ) -> RunController: """Get a RunController for the current run. @@ -67,6 +69,7 @@ async def get_run_controller( task_runner=task_runner, engine_store=engine_store, run_store=run_store, + runs_publisher=runs_publisher, ) diff --git a/robot-server/robot_server/runs/router/commands_router.py b/robot-server/robot_server/runs/router/commands_router.py index 47a64c5d800..b220ae33c04 100644 --- a/robot-server/robot_server/runs/router/commands_router.py +++ b/robot-server/robot_server/runs/router/commands_router.py @@ -6,6 +6,7 @@ from anyio import move_on_after from fastapi import APIRouter, Depends, Query, status + from pydantic import BaseModel, Field from opentrons.protocol_engine import ( @@ -21,11 +22,12 @@ MultiBody, MultiBodyMeta, PydanticResponse, + SimpleMultiBody, ) from robot_server.robot.control.dependencies import require_estop_in_good_state from ..run_models import RunCommandSummary -from ..run_data_manager import RunDataManager +from ..run_data_manager import RunDataManager, PreSerializedCommandsNotAvailableError from ..engine_store import EngineStore from ..run_store import RunStore, CommandNotFoundError from ..run_models import RunNotFoundError @@ -70,6 +72,18 @@ class CommandNotAllowed(ErrorDetails): title: str = "Command Not Allowed" +class PreSerializedCommandsNotAvailable(ErrorDetails): + """An error if one tries to fetch pre-serialized commands before they are written to the database.""" + + id: Literal[ + "PreSerializedCommandsNotAvailable" + ] = "PreSerializedCommandsNotAvailable" + title: str = "Pre-Serialized commands not available." + detail: str = ( + "Pre-serialized commands are only available once a run has finished running." + ) + + class CommandLinkMeta(BaseModel): """Metadata about a command resource referenced in `links`.""" @@ -351,6 +365,56 @@ async def get_run_commands( ) +# TODO (spp, 2024-05-01): explore alternatives to returning commands as list of strings. +# Options: 1. JSON Lines +# 2. Simple de-serialized commands list w/o pydantic model conversion +@PydanticResponse.wrap_route( + commands_router.get, + path="/runs/{runId}/commandsAsPreSerializedList", + summary="Get all commands of a completed run as a list of pre-serialized commands", + description=( + "Get all commands of a completed run as a list of pre-serialized commands." + "**Warning:** This endpoint is experimental. We may change or remove it without warning." + "\n\n" + "The commands list will only be available after a run has completed" + " (whether successful, failed or stopped) and its data has been committed to the database." + " If a request is received before the run is completed, it will return a 503 Unavailable error." + " This is a faster alternative to fetching the full commands list using" + " `GET /runs/{runId}/commands`. For large protocols (10k+ commands), the above" + " endpoint can take minutes to respond, whereas this one should only take a few seconds." + ), + responses={ + status.HTTP_404_NOT_FOUND: {"model": ErrorBody[RunNotFound]}, + status.HTTP_503_SERVICE_UNAVAILABLE: { + "model": ErrorBody[PreSerializedCommandsNotAvailable] + }, + }, +) +async def get_run_commands_as_pre_serialized_list( + runId: str, + run_data_manager: RunDataManager = Depends(get_run_data_manager), +) -> PydanticResponse[SimpleMultiBody[str]]: + """Get all commands of a completed run as a list of pre-serialized (string encoded) commands. + + Arguments: + runId: Requested run ID, from the URL + run_data_manager: Run data retrieval interface. + """ + try: + commands = run_data_manager.get_all_commands_as_preserialized_list(runId) + except RunNotFoundError as e: + raise RunNotFound.from_exc(e).as_error(status.HTTP_404_NOT_FOUND) from e + except PreSerializedCommandsNotAvailableError as e: + raise PreSerializedCommandsNotAvailable.from_exc(e).as_error( + status.HTTP_503_SERVICE_UNAVAILABLE + ) from e + return await PydanticResponse.create( + content=SimpleMultiBody.construct( + data=commands, meta=MultiBodyMeta(cursor=0, totalLength=len(commands)) + ) + ) + + @PydanticResponse.wrap_route( commands_router.get, path="/runs/{runId}/commands/{commandId}", diff --git a/robot-server/robot_server/runs/run_controller.py b/robot-server/robot_server/runs/run_controller.py index 923c9cfa64e..e7e55080aed 100644 --- a/robot-server/robot_server/runs/run_controller.py +++ b/robot-server/robot_server/runs/run_controller.py @@ -13,6 +13,8 @@ from opentrons.protocol_engine.types import DeckConfigurationType +from robot_server.service.notifications import RunsPublisher + log = logging.getLogger(__name__) @@ -21,7 +23,7 @@ class RunActionNotAllowedError(RoboticsInteractionError): class RunController: - """An interface to manage the side-effects of requested run actions.""" + """An interface to manage the side effects of requested run actions.""" def __init__( self, @@ -29,11 +31,13 @@ def __init__( task_runner: TaskRunner, engine_store: EngineStore, run_store: RunStore, + runs_publisher: RunsPublisher, ) -> None: self._run_id = run_id self._task_runner = task_runner self._engine_store = engine_store self._run_store = run_store + self._runs_publisher = runs_publisher def create_action( self, @@ -108,3 +112,6 @@ async def _run_protocol_and_insert_result( commands=result.commands, run_time_parameters=result.parameters, ) + await self._runs_publisher.publish_pre_serialized_commands_notification( + self._run_id + ) diff --git a/robot-server/robot_server/runs/run_data_manager.py b/robot-server/robot_server/runs/run_data_manager.py index 8548104911b..311cfb93b40 100644 --- a/robot-server/robot_server/runs/run_data_manager.py +++ b/robot-server/robot_server/runs/run_data_manager.py @@ -112,6 +112,10 @@ class RunNotCurrentError(ValueError): """Error raised when a requested run is not the current run.""" +class PreSerializedCommandsNotAvailableError(LookupError): + """Error raised when a run's commands are not available as pre-serialized list of commands.""" + + class RunDataManager: """Collaborator to manage current and historical run data. @@ -290,10 +294,16 @@ async def delete(self, run_id: str) -> None: self._run_store.remove(run_id=run_id) async def update(self, run_id: str, current: Optional[bool]) -> Union[Run, BadRun]: - """Get and potentially archive a run. + """Get and potentially archive the current run. Args: run_id: The run to get and maybe archive. + current: Whether to mark the run as current or not. + If `current` set to False, then the run is 'un-current'ed by + stopping the run, saving the final run data to the run store, + and clearing the engine and runner. + If 'current' is True or not specified, we simply fetch the run's + data from memory and database. Returns: The updated run. @@ -320,6 +330,9 @@ async def update(self, run_id: str, current: Optional[bool]) -> Union[Run, BadRu commands=commands, run_time_parameters=parameters, ) + await self._runs_publisher.publish_pre_serialized_commands_notification( + run_id + ) else: state_summary = self._engine_store.engine.state_view.get_summary() parameters = self._engine_store.runner.run_time_parameters @@ -387,6 +400,17 @@ def get_command(self, run_id: str, command_id: str) -> Command: return self._run_store.get_command(run_id=run_id, command_id=command_id) + def get_all_commands_as_preserialized_list(self, run_id: str) -> List[str]: + """Get all commands of a run in a serialized json list.""" + if ( + run_id == self._engine_store.current_run_id + and not self._engine_store.engine.state_view.commands.get_is_terminal() + ): + raise PreSerializedCommandsNotAvailableError( + "Pre-serialized commands are only available after a run has ended." + ) + return self._run_store.get_all_commands_as_preserialized_list(run_id) + def _get_state_summary(self, run_id: str) -> Union[StateSummary, BadStateSummary]: if run_id == self._engine_store.current_run_id: return self._engine_store.engine.state_view.get_summary() diff --git a/robot-server/robot_server/runs/run_store.py b/robot-server/robot_server/runs/run_store.py index b86ec8e19ea..6cf86d14af1 100644 --- a/robot-server/robot_server/runs/run_store.py +++ b/robot-server/robot_server/runs/run_store.py @@ -428,7 +428,6 @@ def get_commands_slice( ) .order_by(run_command_table.c.index_in_run) ) - slice_result = transaction.execute(select_slice).all() sliced_commands: List[Command] = [ @@ -442,6 +441,19 @@ def get_commands_slice( commands=sliced_commands, ) + def get_all_commands_as_preserialized_list(self, run_id: str) -> List[str]: + """Get all commands of the run as a list of strings of json command objects.""" + with self._sql_engine.begin() as transaction: + if not self._run_exists(run_id, transaction): + raise RunNotFoundError(run_id=run_id) + select_commands = ( + sqlalchemy.select(run_command_table.c.command) + .where(run_command_table.c.run_id == run_id) + .order_by(run_command_table.c.index_in_run) + ) + commands_result = transaction.scalars(select_commands).all() + return commands_result + @lru_cache(maxsize=_CACHE_ENTRIES) def get_command(self, run_id: str, command_id: str) -> Command: """Get run command by id. diff --git a/robot-server/robot_server/service/notifications/publishers/runs_publisher.py b/robot-server/robot_server/service/notifications/publishers/runs_publisher.py index fef23c8a875..08b14899d0d 100644 --- a/robot-server/robot_server/service/notifications/publishers/runs_publisher.py +++ b/robot-server/robot_server/service/notifications/publishers/runs_publisher.py @@ -95,9 +95,23 @@ async def _publish_runs_advise_refetch_async(self, run_id: str) -> None: async def _publish_runs_advise_unsubscribe_async(self, run_id: str) -> None: """Publish an unsubscribe flag for relevant runs topics.""" - await self._client.publish_advise_unsubscribe_async( - topic=f"{Topics.RUNS}/{run_id}" - ) + if self._run_hooks is not None: + await self._client.publish_advise_unsubscribe_async( + topic=f"{Topics.RUNS}/{run_id}" + ) + await self._client.publish_advise_unsubscribe_async( + topic=Topics.RUNS_CURRENT_COMMAND + ) + await self._client.publish_advise_unsubscribe_async( + topic=f"{Topics.RUNS_PRE_SERIALIZED_COMMANDS}/{run_id}" + ) + + async def publish_pre_serialized_commands_notification(self, run_id: str) -> None: + """Publishes notification for GET /runs/:runId/commandsAsPreSerializedList.""" + if self._run_hooks is not None: + await self._client.publish_advise_refetch_async( + topic=f"{Topics.RUNS_PRE_SERIALIZED_COMMANDS}/{run_id}" + ) async def _handle_current_command_change(self) -> None: """Publish a refetch flag if the current command has changed.""" diff --git a/robot-server/robot_server/service/notifications/topics.py b/robot-server/robot_server/service/notifications/topics.py index 26d53cc3516..f8a6ecaf701 100644 --- a/robot-server/robot_server/service/notifications/topics.py +++ b/robot-server/robot_server/service/notifications/topics.py @@ -15,3 +15,4 @@ class Topics(str, Enum): RUNS_CURRENT_COMMAND = f"{_TOPIC_BASE}/runs/current_command" RUNS = f"{_TOPIC_BASE}/runs" DECK_CONFIGURATION = f"{_TOPIC_BASE}/deck_configuration" + RUNS_PRE_SERIALIZED_COMMANDS = f"{_TOPIC_BASE}/runs/pre_serialized_commands" diff --git a/robot-server/tests/integration/http_api/runs/test_persistence.py b/robot-server/tests/integration/http_api/runs/test_persistence.py index 45b55202fda..943f644e8d3 100644 --- a/robot-server/tests/integration/http_api/runs/test_persistence.py +++ b/robot-server/tests/integration/http_api/runs/test_persistence.py @@ -1,3 +1,4 @@ +import json from copy import deepcopy from datetime import datetime from typing import Any, AsyncGenerator, Dict, NamedTuple, cast @@ -250,6 +251,9 @@ async def test_run_commands_persist(client_and_server: ClientServerFixture) -> N get_persisted_command_response = await client.get_run_command( run_id=run_id, command_id=command_id ) + get_preserialized_commands_response = await client.get_preserialized_commands( + run_id=run_id + ) # ensure the persisted commands still match the original ones assert get_all_persisted_commands_response.json()["data"] == [ @@ -259,6 +263,11 @@ async def test_run_commands_persist(client_and_server: ClientServerFixture) -> N ] assert get_persisted_command_response.json()["data"] == expected_command + json_converted_command = json.loads( + get_preserialized_commands_response.json()["data"][0] + ) + assert json_converted_command == expected_command + async def test_runs_completed_started_at_persist_via_actions_router( client_and_server: ClientServerFixture, diff --git a/robot-server/tests/integration/http_api/runs/test_run_queued_protocol_commands.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_run_queued_protocol_commands.tavern.yaml index 0d4a0010281..9d188402deb 100644 --- a/robot-server/tests/integration/http_api/runs/test_run_queued_protocol_commands.tavern.yaml +++ b/robot-server/tests/integration/http_api/runs/test_run_queued_protocol_commands.tavern.yaml @@ -198,3 +198,19 @@ stages: createdAt: !re_search "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+\\+\\d{2}:\\d{2}$" startedAt: !re_search "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+\\+\\d{2}:\\d{2}$" completedAt: !re_search "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+\\+\\d{2}:\\d{2}$" + + - name: Get all the commands in the run as a pre-serialized list + request: + url: '{ot2_server_base_url}/runs/{run_id}/commandsAsPreSerializedList' + method: GET + response: + status_code: 200 + json: + data: + - !anystr + - !anystr + - !anystr + - !anystr + meta: + cursor: 0 + totalLength: 4 \ No newline at end of file diff --git a/robot-server/tests/integration/robot_client.py b/robot-server/tests/integration/robot_client.py index c4511f8d315..9af11d50cdb 100644 --- a/robot-server/tests/integration/robot_client.py +++ b/robot-server/tests/integration/robot_client.py @@ -220,6 +220,14 @@ async def get_run_command(self, run_id: str, command_id: str) -> Response: response.raise_for_status() return response + async def get_preserialized_commands(self, run_id: str) -> Response: + """GET /runs/:run_id/commandsAsPreSerializedList.""" + response = await self.httpx_client.get( + url=f"{self.base_url}/runs/{run_id}/commandsAsPreSerializedList", + ) + response.raise_for_status() + return response + async def post_labware_offset( self, run_id: str, diff --git a/robot-server/tests/runs/test_run_controller.py b/robot-server/tests/runs/test_run_controller.py index a844cdcc6d5..71fc92f8466 100644 --- a/robot-server/tests/runs/test_run_controller.py +++ b/robot-server/tests/runs/test_run_controller.py @@ -14,6 +14,7 @@ from opentrons.protocol_engine.types import RunTimeParameter, BooleanParameter from opentrons.protocol_runner import RunResult, JsonRunner, PythonAndLegacyRunner +from robot_server.service.notifications import RunsPublisher from robot_server.service.task_runner import TaskRunner from robot_server.runs.action_models import RunAction, RunActionType from robot_server.runs.engine_store import EngineStore @@ -41,6 +42,12 @@ def mock_task_runner(decoy: Decoy) -> TaskRunner: return decoy.mock(cls=TaskRunner) +@pytest.fixture() +def mock_runs_publisher(decoy: Decoy) -> RunsPublisher: + """Get a mock RunsPublisher.""" + return decoy.mock(cls=RunsPublisher) + + @pytest.fixture def run_id() -> str: """A run identifier value.""" @@ -90,6 +97,7 @@ def subject( mock_engine_store: EngineStore, mock_run_store: RunStore, mock_task_runner: TaskRunner, + mock_runs_publisher: RunsPublisher, ) -> RunController: """Get a RunController test subject.""" return RunController( @@ -97,6 +105,7 @@ def subject( engine_store=mock_engine_store, run_store=mock_run_store, task_runner=mock_task_runner, + runs_publisher=mock_runs_publisher, ) @@ -135,6 +144,7 @@ async def test_create_play_action_to_start( mock_engine_store: EngineStore, mock_run_store: RunStore, mock_task_runner: TaskRunner, + mock_runs_publisher: RunsPublisher, engine_state_summary: StateSummary, run_time_parameters: List[RunTimeParameter], protocol_commands: List[pe_commands.Command], @@ -181,6 +191,7 @@ async def test_create_play_action_to_start( commands=protocol_commands, run_time_parameters=run_time_parameters, ), + await mock_runs_publisher.publish_pre_serialized_commands_notification(run_id), times=1, ) diff --git a/robot-server/tests/runs/test_run_data_manager.py b/robot-server/tests/runs/test_run_data_manager.py index 547ec0a7b74..12ced28fdb0 100644 --- a/robot-server/tests/runs/test_run_data_manager.py +++ b/robot-server/tests/runs/test_run_data_manager.py @@ -23,7 +23,11 @@ from robot_server.protocols.protocol_store import ProtocolResource from robot_server.runs.engine_store import EngineStore, EngineConflictError -from robot_server.runs.run_data_manager import RunDataManager, RunNotCurrentError +from robot_server.runs.run_data_manager import ( + RunDataManager, + RunNotCurrentError, + PreSerializedCommandsNotAvailableError, +) from robot_server.runs.run_models import Run, BadRun, RunNotFoundError, RunDataError from robot_server.runs.run_store import ( RunStore, @@ -583,6 +587,7 @@ async def test_update_current( run_command: commands.Command, mock_engine_store: EngineStore, mock_run_store: RunStore, + mock_runs_publisher: RunsPublisher, subject: RunDataManager, ) -> None: """It should persist the current run and clear the engine on current=false.""" @@ -607,6 +612,10 @@ async def test_update_current( result = await subject.update(run_id=run_id, current=False) + decoy.verify( + await mock_runs_publisher.publish_pre_serialized_commands_notification(run_id), + times=1, + ) assert result == Run( current=False, id=run_resource.run_id, @@ -633,6 +642,7 @@ async def test_update_current_noop( run_command: commands.Command, mock_engine_store: EngineStore, mock_run_store: RunStore, + mock_runs_publisher: RunsPublisher, subject: RunDataManager, current: Optional[bool], ) -> None: @@ -657,6 +667,7 @@ async def test_update_current_noop( commands=matchers.Anything(), run_time_parameters=matchers.Anything(), ), + await mock_runs_publisher.publish_pre_serialized_commands_notification(run_id), times=0, ) @@ -932,6 +943,38 @@ def test_get_command_from_db_command_not_found( subject.get_command("run-id", "command-id") +def test_get_all_commands_as_preserialized_list( + decoy: Decoy, + subject: RunDataManager, + mock_run_store: RunStore, + mock_engine_store: EngineStore, +) -> None: + """It should return the pre-serialized commands list.""" + decoy.when(mock_engine_store.current_run_id).then_return(None) + decoy.when( + mock_run_store.get_all_commands_as_preserialized_list("run-id") + ).then_return(['{"id": command-1}', '{"id": command-2}']) + assert subject.get_all_commands_as_preserialized_list("run-id") == [ + '{"id": command-1}', + '{"id": command-2}', + ] + + +def test_get_all_commands_as_preserialized_list_errors_for_active_runs( + decoy: Decoy, + subject: RunDataManager, + mock_run_store: RunStore, + mock_engine_store: EngineStore, +) -> None: + """It should raise an error when fetching pre-serialized commands list while run is active.""" + decoy.when(mock_engine_store.current_run_id).then_return("current-run-id") + decoy.when( + mock_engine_store.engine.state_view.commands.get_is_terminal() + ).then_return(False) + with pytest.raises(PreSerializedCommandsNotAvailableError): + subject.get_all_commands_as_preserialized_list("current-run-id") + + async def test_get_current_run_labware_definition( decoy: Decoy, mock_engine_store: EngineStore, diff --git a/robot-server/tests/runs/test_run_store.py b/robot-server/tests/runs/test_run_store.py index c6108cf5407..ee7697107f6 100644 --- a/robot-server/tests/runs/test_run_store.py +++ b/robot-server/tests/runs/test_run_store.py @@ -734,3 +734,31 @@ def test_get_commands_slice_run_not_found(subject: RunStore) -> None: ) with pytest.raises(RunNotFoundError): subject.get_commands_slice(run_id="not-run-id", cursor=1, length=3) + + +def test_get_all_commands_as_preserialized_list( + subject: RunStore, + protocol_commands: List[pe_commands.Command], + state_summary: StateSummary, +) -> None: + """It should get all commands stored in DB as a pre-serialized list.""" + subject.insert( + run_id="run-id", + protocol_id=None, + created_at=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), + ) + subject.update_run_state( + run_id="run-id", + summary=state_summary, + commands=protocol_commands, + run_time_parameters=[], + ) + result = subject.get_all_commands_as_preserialized_list(run_id="run-id") + assert result == [ + '{"id": "pause-1", "createdAt": "2021-01-01T00:00:00", "commandType": "waitForResume",' + ' "key": "command-key", "status": "succeeded", "params": {"message": "hello world"}, "result": {}}', + '{"id": "pause-2", "createdAt": "2022-02-02T00:00:00", "commandType": "waitForResume",' + ' "key": "command-key", "status": "succeeded", "params": {"message": "hey world"}, "result": {}}', + '{"id": "pause-3", "createdAt": "2023-03-03T00:00:00", "commandType": "waitForResume",' + ' "key": "command-key", "status": "succeeded", "params": {"message": "sup world"}, "result": {}}', + ] diff --git a/robot-server/tests/service/notifications/publishers/test_runs_publisher.py b/robot-server/tests/service/notifications/publishers/test_runs_publisher.py index a889664cbee..f8fdaf0cf9f 100644 --- a/robot-server/tests/service/notifications/publishers/test_runs_publisher.py +++ b/robot-server/tests/service/notifications/publishers/test_runs_publisher.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock, AsyncMock from robot_server.service.notifications import RunsPublisher, Topics -from opentrons.protocol_engine import CurrentCommand, EngineStatus +from opentrons.protocol_engine import CurrentCommand, EngineStatus, StateSummary def mock_curent_command(command_id: str) -> CurrentCommand: @@ -17,6 +17,19 @@ def mock_curent_command(command_id: str) -> CurrentCommand: ) +def mock_state_summary(run_id: str) -> StateSummary: + return StateSummary.construct( + status=EngineStatus.FAILED, + errors=[], + labware=[], + pipettes=[], + modules=[], + labwareOffsets=[], + startedAt=None, + completedAt=datetime(year=2021, month=1, day=1), + ) + + @pytest.fixture def notification_client() -> AsyncMock: """Mocked notification client.""" @@ -80,6 +93,12 @@ async def test_clean_up_current_run( notification_client.publish_advise_unsubscribe_async.assert_any_await( topic=f"{Topics.RUNS}/1234" ) + notification_client.publish_advise_unsubscribe_async.assert_any_await( + topic=Topics.RUNS_CURRENT_COMMAND + ) + notification_client.publish_advise_unsubscribe_async.assert_any_await( + topic=f"{Topics.RUNS_PRE_SERIALIZED_COMMANDS}/1234" + ) @pytest.mark.asyncio @@ -143,3 +162,24 @@ async def test_handle_engine_status_change( notification_client.publish_advise_refetch_async.assert_any_await( topic=f"{Topics.RUNS}/1234" ) + + +async def test_publish_pre_serialized_commannds_notif( + runs_publisher: RunsPublisher, notification_client: AsyncMock +) -> None: + """It should send out a notification for pre serialized commands.""" + await runs_publisher.initialize( + "1234", lambda _: mock_curent_command("command1"), AsyncMock() + ) + + assert runs_publisher._run_hooks + assert runs_publisher._engine_state_slice + assert notification_client.publish_advise_refetch_async.call_count == 2 + + await runs_publisher.publish_pre_serialized_commands_notification(run_id="1234") + + assert notification_client.publish_advise_refetch_async.call_count == 3 + + notification_client.publish_advise_refetch_async.assert_any_await( + topic=f"{Topics.RUNS_PRE_SERIALIZED_COMMANDS}/1234" + ) diff --git a/system-server/system_server/system/oem_mode/router.py b/system-server/system_server/system/oem_mode/router.py index 0f3b9aa52f4..1b7119b6127 100644 --- a/system-server/system_server/system/oem_mode/router.py +++ b/system-server/system_server/system/oem_mode/router.py @@ -1,5 +1,6 @@ """Router for /system/register endpoint.""" +import re import os import filetype # type: ignore[import-untyped] from fastapi import ( @@ -11,11 +12,16 @@ File, HTTPException, ) +from pathlib import Path from .models import EnableOEMMode from ...settings import SystemServerSettings, get_settings, save_settings +# regex to sanitize the filename +FILENAME_REGEX = re.compile(r"[^a-zA-Z0-9-.]") + + oem_mode_router = APIRouter() @@ -78,7 +84,7 @@ async def upload_splash_image( # Get the file info file_info = filetype.guess(file.file) - if file_info is None: + if file_info is None or not file.filename: raise HTTPException( status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, detail="Unable to determine file type", @@ -115,8 +121,12 @@ async def upload_splash_image( if settings.oem_mode_splash_custom: os.unlink(settings.oem_mode_splash_custom) + # sanitize the filename + sanatized_filename = FILENAME_REGEX.sub("_", file.filename) + filename = f"{Path(sanatized_filename).stem}.{content_type}" + # file is valid, save to final location - filepath = f"{settings.persistence_directory}/{file.filename}" + filepath = f"{settings.persistence_directory}/{filename}" with open(filepath, "wb+") as f: f.write(file.file.read()) diff --git a/test-data-generation/Makefile b/test-data-generation/Makefile index 03c881dbf89..a4818b00ab1 100644 --- a/test-data-generation/Makefile +++ b/test-data-generation/Makefile @@ -29,4 +29,9 @@ wheel: .PHONY: test test: - $(pytest) tests -vvv \ No newline at end of file + $(pytest) tests \ + -s \ + --hypothesis-show-statistics \ + --hypothesis-verbosity=normal \ + --hypothesis-explain \ + -vvv \ No newline at end of file diff --git a/test-data-generation/src/test_data_generation/__init__.py b/test-data-generation/src/test_data_generation/__init__.py new file mode 100644 index 00000000000..45f2dcce037 --- /dev/null +++ b/test-data-generation/src/test_data_generation/__init__.py @@ -0,0 +1 @@ +"""Test data generation.""" diff --git a/test-data-generation/src/test_data_generation/deck_configuration/__init__.py b/test-data-generation/src/test_data_generation/deck_configuration/__init__.py new file mode 100644 index 00000000000..616f424694c --- /dev/null +++ b/test-data-generation/src/test_data_generation/deck_configuration/__init__.py @@ -0,0 +1 @@ +"""Test data generation for deck configuration tests.""" diff --git a/test-data-generation/src/test_data_generation/deck_configuration/datashapes.py b/test-data-generation/src/test_data_generation/deck_configuration/datashapes.py new file mode 100644 index 00000000000..94cf907e308 --- /dev/null +++ b/test-data-generation/src/test_data_generation/deck_configuration/datashapes.py @@ -0,0 +1,299 @@ +"""Data shapes for the deck configuration of a Flex.""" + +import enum +import dataclasses +import typing + +ColumnName = typing.Literal["1", "2", "3"] +RowName = typing.Literal["a", "b", "c", "d"] +SlotName = typing.Literal[ + "a1", "a2", "a3", "b1", "b2", "b3", "c1", "c2", "c3", "d1", "d2", "d3" +] + + +class PossibleSlotContents(enum.Enum): + """Possible contents of a slot on a Flex.""" + + # Implicitly defined fixtures + THERMOCYCLER_MODULE = enum.auto() + WASTE_CHUTE = enum.auto() + WASTE_CHUTE_NO_COVER = enum.auto() + STAGING_AREA = enum.auto() + STAGING_AREA_WITH_WASTE_CHUTE = enum.auto() + STAGING_AREA_WITH_WASTE_CHUTE_NO_COVER = enum.auto() + STAGING_AREA_WITH_MAGNETIC_BLOCK = enum.auto() + + # Explicitly defined fixtures + MAGNETIC_BLOCK_MODULE = enum.auto() + TEMPERATURE_MODULE = enum.auto() + HEATER_SHAKER_MODULE = enum.auto() + TRASH_BIN = enum.auto() + + # Other + LABWARE_SLOT = enum.auto() + + @classmethod + def longest_string(cls) -> int: + """Return the longest string representation of the slot content.""" + length = max([len(e.name) for e in PossibleSlotContents]) + return length if length % 2 == 0 else length + 1 + + def __str__(self) -> str: + """Return a string representation of the slot content.""" + return f"{self.name.replace('_', ' ')}" + + @classmethod + def all(cls) -> typing.List["PossibleSlotContents"]: + """Return all possible slot contents.""" + return list(cls) + + @property + def modules(self) -> typing.List["PossibleSlotContents"]: + """Return the modules.""" + return [ + PossibleSlotContents.THERMOCYCLER_MODULE, + PossibleSlotContents.MAGNETIC_BLOCK_MODULE, + PossibleSlotContents.TEMPERATURE_MODULE, + PossibleSlotContents.HEATER_SHAKER_MODULE, + ] + + @property + def staging_areas(self) -> typing.List["PossibleSlotContents"]: + """Return the staging areas.""" + return [ + PossibleSlotContents.STAGING_AREA, + PossibleSlotContents.STAGING_AREA_WITH_WASTE_CHUTE, + PossibleSlotContents.STAGING_AREA_WITH_WASTE_CHUTE_NO_COVER, + PossibleSlotContents.STAGING_AREA_WITH_MAGNETIC_BLOCK, + ] + + @property + def waste_chutes(self) -> typing.List["PossibleSlotContents"]: + """Return the waste chutes.""" + return [ + PossibleSlotContents.WASTE_CHUTE, + PossibleSlotContents.WASTE_CHUTE_NO_COVER, + PossibleSlotContents.STAGING_AREA_WITH_WASTE_CHUTE, + PossibleSlotContents.STAGING_AREA_WITH_WASTE_CHUTE_NO_COVER, + ] + + def is_one_of(self, contents: typing.List["PossibleSlotContents"]) -> bool: + """Return True if the slot contains one of the contents.""" + return any([self is content for content in contents]) + + def is_a_module(self) -> bool: + """Return True if the slot contains a module.""" + return self.is_one_of(self.modules) + + def is_module_or_trash_bin(self) -> bool: + """Return True if the slot contains a module or trash bin.""" + return self.is_one_of(self.modules + [PossibleSlotContents.TRASH_BIN]) + + def is_a_staging_area(self) -> bool: + """Return True if the slot contains a staging area.""" + return self.is_one_of(self.staging_areas) + + def is_a_waste_chute(self) -> bool: + """Return True if the slot contains a waste chute.""" + return self.is_one_of(self.waste_chutes) + + +@dataclasses.dataclass +class Slot: + """A slot on a Flex.""" + + row: RowName + col: ColumnName + contents: PossibleSlotContents + + def __str__(self) -> str: + """Return a string representation of the slot.""" + return f"{(self.row + self.col).center(self.contents.longest_string())}{self.contents}" + + @property + def __label(self) -> SlotName: + """Return the slot label.""" + return typing.cast(SlotName, f"{self.row}{self.col}") + + @property + def slot_label_string(self) -> str: + """Return the slot label.""" + return f"{self.__label.center(self.contents.longest_string())}" + + @property + def contents_string(self) -> str: + """Return the slot contents.""" + return f"{str(self.contents).center(self.contents.longest_string())}" + + +@dataclasses.dataclass +class Row: + """A row of slots on a Flex.""" + + row: RowName + + col1: Slot + col2: Slot + col3: Slot + + def __str__(self) -> str: + """Return a string representation of the row.""" + return f"{self.col1}{self.col2}{self.col3}" + + def slot_by_col_number(self, name: ColumnName) -> Slot: + """Return the slot by name.""" + return getattr(self, f"col{name}") # type: ignore + + @property + def slots(self) -> typing.List[Slot]: + """Iterate over the slots in the row.""" + return [self.col1, self.col2, self.col3] + + def __len__(self) -> int: + """Return the number of slots in the row.""" + return len(self.slots) + + def update_slot(self, slot: Slot) -> None: + """Update the slot in the row.""" + setattr(self, f"col{slot.col}", slot) + + +@dataclasses.dataclass +class Column: + """A column of slots on a Flex.""" + + col: ColumnName + + a: Slot + b: Slot + c: Slot + d: Slot + + def __str__(self) -> str: + """Return a string representation of the column.""" + return f"{self.a}{self.b}{self.c}{self.d}" + + @property + def slots(self) -> typing.List[Slot]: + """Return the slots in the column.""" + return [self.a, self.b, self.c, self.d] + + def slot_by_row(self, name: RowName) -> Slot: + """Return the slot by name.""" + return getattr(self, f"{name}") # type: ignore + + def number_of(self, contents: PossibleSlotContents) -> int: + """Return the number of slots with the contents.""" + return len([True for slot in self.slots if slot.contents is contents]) + + def slot_above(self, slot: Slot) -> typing.Optional[Slot]: + """Return the slot above the passed slot.""" + index = self.slots.index(slot) + if index == 0: + return None + return self.slots[index - 1] + + def slot_below(self, slot: Slot) -> typing.Optional[Slot]: + """Return the slot below the passed slot.""" + index = self.slots.index(slot) + if index == 3: + return None + return self.slots[index + 1] + + +@dataclasses.dataclass +class DeckConfiguration: + """The deck on a Flex.""" + + a: Row + b: Row + c: Row + d: Row + + def __str__(self) -> str: + """Return a string representation of the deck.""" + string_list = [] + dashed_line = "-" * (PossibleSlotContents.longest_string() * 3) + equal_line = "=" * (PossibleSlotContents.longest_string() * 3) + for row in self.rows: + string_list.append( + " | ".join([slot.slot_label_string for slot in row.slots]) + ) + string_list.append(" | ".join([slot.contents_string for slot in row.slots])) + if row != self.d: + string_list.append(dashed_line) + joined_string = "\n".join(string_list) + + return f"\n{joined_string}\n\n{equal_line}" + + def __hash__(self) -> int: + """Return the hash of the deck.""" + return hash(tuple(slot.contents.value for slot in self.slots)) + + def __eq__(self, other: typing.Any) -> bool: + """Return True if the deck is equal to the other deck.""" + if not isinstance(other, DeckConfiguration): + return False + return all( + slot.contents == other_slot.contents + for slot in self.slots + for other_slot in other.slots + ) + + @classmethod + def from_cols(cls, col1: Column, col2: Column, col3: Column) -> "DeckConfiguration": + """Create a deck configuration from columns.""" + return cls( + a=Row("a", col1.a, col2.a, col3.a), + b=Row("b", col1.b, col2.b, col3.b), + c=Row("c", col1.c, col2.c, col3.c), + d=Row("d", col1.d, col2.d, col3.d), + ) + + @property + def rows(self) -> typing.List[Row]: + """Return the rows of the deck.""" + return [self.a, self.b, self.c, self.d] + + def row_by_name(self, name: RowName) -> Row: + """Return the row by name.""" + return getattr(self, name) # type: ignore + + @property + def slots(self) -> typing.List[Slot]: + """Return the slots of the deck.""" + return [slot for row in self.rows for slot in row.slots] + + def slot_above(self, slot: Slot) -> typing.Optional[Slot]: + """Return the slot above the passed slot.""" + row_index = self.rows.index(self.row_by_name(slot.row)) + if row_index == 0: + return None + return self.rows[row_index - 1].slot_by_col_number(slot.col) + + def slot_below(self, slot: Slot) -> typing.Optional[Slot]: + """Return the slot below the passed slot.""" + row_index = self.rows.index(self.row_by_name(slot.row)) + if row_index == 3: + return None + return self.rows[row_index + 1].slot_by_col_number(slot.col) + + def number_of(self, contents: PossibleSlotContents) -> int: + """Return the number of slots with the contents.""" + return len([True for slot in self.slots if slot.contents is contents]) + + def override_with_column(self, column: Column) -> None: + """Override the deck configuration with the column.""" + for row in self.rows: + new_value = column.slot_by_row(row.row) + row.update_slot(new_value) + + def column_by_number(self, number: ColumnName) -> Column: + """Return the column by number.""" + return Column( + col=number, + a=self.a.slot_by_col_number(number), + b=self.b.slot_by_col_number(number), + c=self.c.slot_by_col_number(number), + d=self.d.slot_by_col_number(number), + ) diff --git a/test-data-generation/src/test_data_generation/deck_configuration/strategy/final_strategies.py b/test-data-generation/src/test_data_generation/deck_configuration/strategy/final_strategies.py new file mode 100644 index 00000000000..9bf70180f96 --- /dev/null +++ b/test-data-generation/src/test_data_generation/deck_configuration/strategy/final_strategies.py @@ -0,0 +1,81 @@ +"""Test data generation for deck configuration tests.""" +from hypothesis import assume, strategies as st +from test_data_generation.deck_configuration.datashapes import ( + Column, + DeckConfiguration, + Slot, + PossibleSlotContents as PSC, +) + +from test_data_generation.deck_configuration.strategy.helper_strategies import a_column + + +def _above_or_below_is_module_or_trash(col: Column, slot: Slot) -> bool: + """Return True if the deck has a module above or below the specified slot.""" + above = col.slot_above(slot) + below = col.slot_below(slot) + + return (above is not None and above.contents.is_module_or_trash_bin()) or ( + below is not None and below.contents.is_module_or_trash_bin() + ) + + +@st.composite +def a_deck_configuration_with_a_module_or_trash_slot_above_or_below_a_heater_shaker( + draw: st.DrawFn, +) -> DeckConfiguration: + """Generate a deck with a module or trash bin fixture above or below a heater shaker.""" + deck = draw( + st.builds( + DeckConfiguration.from_cols, + col1=a_column("1"), + col2=a_column( + "2", content_options=[PSC.LABWARE_SLOT, PSC.MAGNETIC_BLOCK_MODULE] + ), + col3=a_column("3"), + ) + ) + column = deck.column_by_number(draw(st.sampled_from(["1", "3"]))) + + assume(column.number_of(PSC.HEATER_SHAKER_MODULE) in [1, 2]) + for slot in column.slots: + if slot.contents is PSC.HEATER_SHAKER_MODULE: + assume(_above_or_below_is_module_or_trash(column, slot)) + deck.override_with_column(column) + + return deck + + +@st.composite +def a_deck_configuration_with_invalid_fixture_in_col_2( + draw: st.DrawFn, +) -> DeckConfiguration: + """Generate a deck with an invalid fixture in column 2.""" + POSSIBLE_FIXTURES = [ + PSC.LABWARE_SLOT, + PSC.TEMPERATURE_MODULE, + PSC.HEATER_SHAKER_MODULE, + PSC.TRASH_BIN, + PSC.MAGNETIC_BLOCK_MODULE, + ] + INVALID_FIXTURES = [ + PSC.HEATER_SHAKER_MODULE, + PSC.TRASH_BIN, + PSC.TEMPERATURE_MODULE, + ] + column2 = draw(a_column("2", content_options=POSSIBLE_FIXTURES)) + num_invalid_fixtures = len( + [True for slot in column2.slots if slot.contents.is_one_of(INVALID_FIXTURES)] + ) + assume(num_invalid_fixtures > 0) + + deck = draw( + st.builds( + DeckConfiguration.from_cols, + col1=a_column("1"), + col2=st.just(column2), + col3=a_column("3"), + ) + ) + + return deck diff --git a/test-data-generation/src/test_data_generation/deck_configuration/strategy/helper_strategies.py b/test-data-generation/src/test_data_generation/deck_configuration/strategy/helper_strategies.py new file mode 100644 index 00000000000..17950f63a39 --- /dev/null +++ b/test-data-generation/src/test_data_generation/deck_configuration/strategy/helper_strategies.py @@ -0,0 +1,117 @@ +"""Test data generation for deck configuration tests.""" +from typing import List +from hypothesis import strategies as st +from test_data_generation.deck_configuration.datashapes import ( + Column, + Row, + Slot, + PossibleSlotContents as PSC, +) + + +@st.composite +def a_slot( + draw: st.DrawFn, + row: str, + col: str, + content_options: List[PSC] = PSC.all(), +) -> Slot: + """Generate a slot with a random content. + + Any fixture that has it's location implicitly defined is captured here by the + filtering logic. + """ + no_thermocycler = [ + content for content in content_options if content is not PSC.THERMOCYCLER_MODULE + ] + no_waste_chute_or_staging_area = [ + content + for content in content_options + if not content.is_a_waste_chute() and not content.is_a_staging_area() + ] + + no_waste_chute_or_thermocycler = [ + content for content in no_thermocycler if not content.is_a_waste_chute() + ] + no_staging_area_or_waste_chute_or_thermocycler = [ + content + for content in no_waste_chute_or_thermocycler + if not content.is_a_staging_area() + ] + + if col == "1" and (row == "A" or row == "B"): + return draw( + st.builds( + Slot, + row=st.just(row), + col=st.just(col), + contents=st.sampled_from(no_waste_chute_or_staging_area), + ) + ) + elif col == "3": + if row == "D": + return draw( + st.builds( + Slot, + row=st.just(row), + col=st.just(col), + contents=st.sampled_from(no_thermocycler), + ) + ) + else: + return draw( + st.builds( + Slot, + row=st.just(row), + col=st.just(col), + contents=st.sampled_from(no_waste_chute_or_thermocycler), + ) + ) + else: + return draw( + st.builds( + Slot, + row=st.just(row), + col=st.just(col), + contents=st.sampled_from( + no_staging_area_or_waste_chute_or_thermocycler + ), + ) + ) + + +@st.composite +def a_row( + draw: st.DrawFn, + row: str, + content_options: List[PSC] = PSC.all(), +) -> Row: + """Generate a row with random slots.""" + return draw( + st.builds( + Row, + row=st.just(row), + col1=a_slot(row=row, col="1", content_options=content_options), + col2=a_slot(row=row, col="2", content_options=content_options), + col3=a_slot(row=row, col="3", content_options=content_options), + ) + ) + + +@st.composite +def a_column( + draw: st.DrawFn, + col: str, + content_options: List[PSC] = PSC.all(), +) -> Column: + """Generate a column with random slots.""" + return draw( + st.builds( + Column, + col=st.just(col), + a=a_slot(row="a", col=col, content_options=content_options), + b=a_slot(row="b", col=col, content_options=content_options), + c=a_slot(row="c", col=col, content_options=content_options), + d=a_slot(row="d", col=col, content_options=content_options), + ) + ) diff --git a/test-data-generation/tests/test_data_generation/deck_configuration/test_deck_configuration.py b/test-data-generation/tests/test_data_generation/deck_configuration/test_deck_configuration.py new file mode 100644 index 00000000000..02c4f125187 --- /dev/null +++ b/test-data-generation/tests/test_data_generation/deck_configuration/test_deck_configuration.py @@ -0,0 +1,41 @@ +"""Tests to ensure that the deck configuration is generated correctly.""" + +from hypothesis import given, settings, HealthCheck +from test_data_generation.deck_configuration.datashapes import DeckConfiguration +from test_data_generation.deck_configuration.strategy.final_strategies import ( + a_deck_configuration_with_a_module_or_trash_slot_above_or_below_a_heater_shaker, + a_deck_configuration_with_invalid_fixture_in_col_2, +) + +NUM_EXAMPLES = 100 + + +@given( + deck_config=a_deck_configuration_with_a_module_or_trash_slot_above_or_below_a_heater_shaker() +) +@settings( + max_examples=NUM_EXAMPLES, + suppress_health_check=[HealthCheck.filter_too_much, HealthCheck.too_slow], +) +def test_above_below_heater_shaker(deck_config: DeckConfiguration) -> None: + """I hypothesize, that any deck configuration with a non-labware slot fixture above or below a heater-shaker is invalid.""" + print(deck_config) + + # TODO: create protocol and run analysis + + # protocol = create_protocol(deck) + # with pytest.assertRaises as e: + # analyze(protocol) + # assert e.exception == "Some statement about the deck configuration being invalid because of the labware above or below the Heater-Shaker" + + +@given(deck_config=a_deck_configuration_with_invalid_fixture_in_col_2()) +@settings( + max_examples=NUM_EXAMPLES, + suppress_health_check=[HealthCheck.filter_too_much, HealthCheck.too_slow], +) +def test_invalid_fixture_in_col_2(deck_config: DeckConfiguration) -> None: + """I hypothesize, that any deck configuration that contains at least one, Heater-Shaker, Trash Bin, or Temperature module, in column 2 is invalid.""" + print(deck_config) + + # TODO: Same as above