diff --git a/openblade/api/routes_aml_move_medium.py b/openblade/api/routes_aml_move_medium.py index 4792452..8c40a6e 100644 --- a/openblade/api/routes_aml_move_medium.py +++ b/openblade/api/routes_aml_move_medium.py @@ -10,9 +10,9 @@ is a physical ``frame/rack/section/column/row`` + type keyed on a globally-unique SCSI element address. Modelling that faithfully is roadmap Phase 0 (``ScalarCoordinate``). -- ``moveClass`` is handled as small integers (0 = normal, 3 = unload-to-home). The - real contract is a bit field with different values (e.g. unload) — UNVERIFIED - against a capture. +- ``moveClass`` is parsed as the documented bit field (0=Normal, 8=Unload, + 16=No-Eject, 32=Closest-slot; the single-integer 3=Unload is deprecated but still + accepted) via ``MoveClass.from_wire`` — see docs/reference/i3-contract-notes.md. Because the emulator and the ``scalar_http`` client currently share these simplifications, client<->emulator tests passing does NOT prove i3 fidelity. That @@ -79,6 +79,13 @@ async def move_medium( except (TypeError, ValueError): move_class = MoveClass.NORMAL + if not move_class.is_supported_on_i3: + # Import(2)/Export(4) are documented as not supported on Scalar i3/i6. + raise HTTPException( + status_code=422, + detail="moveClass import/export is not supported on Scalar i3/i6", + ) + source = _parse_coordinate(data.get("sourceCoordinate")) if source is None: raise HTTPException( @@ -96,7 +103,13 @@ async def move_medium( ) # Real moveClass=8 unload takes the drive source only; if a destination # slot is supplied (client hint), honor it, else the library picks an - # empty/home slot (Web Services manual). + # empty/home slot (Web Services manual). Guard the destination type: + # slot and drive addresses overlap, so an accidental drive destination + # would otherwise silently misroute the cartridge. + if destination is not None and destination[0] != "slot": + raise HTTPException( + status_code=422, detail="Unload destination must be a storage slot" + ) target_slot = destination[1] if destination is not None else _first_empty_slot(context) result = context.library.unload(source_address, target_slot) elif destination is None: diff --git a/openblade/domain/scalar_coordinate.py b/openblade/domain/scalar_coordinate.py index 6eddea6..b541f26 100644 --- a/openblade/domain/scalar_coordinate.py +++ b/openblade/domain/scalar_coordinate.py @@ -8,12 +8,13 @@ magic integer. These models formalize the real structure so the convenient integer forms live only in the adapter boundary. -IMPORTANT — values vs structure: the STRUCTURE here (coordinate fields, moveClass -bit flags) mirrors the documented i3 shape. The exact wire VALUES for ``moveClass`` -are UNVERIFIED against a real appliance — the external review indicates the real -"unload" value differs from the emulator's current ``3``. The wire mapping is -centralized in :func:`MoveClass.from_wire`/:meth:`MoveClass.to_wire` so a captured -compatibility case can certify/replace it without touching call sites. +Values are from the Quantum Web Services manual (6-68185-01 Rev D); the moveClass +bit values (8=Unload, 16=No-Eject, 32=Closest-slot; single-integer 3=Unload +deprecated) and the coordinate shape are documented, not guessed — see +docs/reference/i3-contract-notes.md. Parsing/serialization is centralized in +:func:`MoveClass.from_wire`/:meth:`MoveClass.to_wire`. The doc is Rev D (2019) and +the target firmware is 341G, so appliance-level certification of the exact numbers +still awaits a capture; the compatibility corpus is wired to enforce it. """ from __future__ import annotations @@ -88,6 +89,15 @@ class MoveClass(IntFlag): def is_unload(self) -> bool: return bool(self & MoveClass.UNLOAD) + @property + def is_supported_on_i3(self) -> bool: + """Whether this move is supported on a Scalar i3/i6. + + The Web Services manual marks Import(2) and Export(4) as "Currently not + supported" on i3/i6; Normal/Unload/No-Eject/Closest-slot are supported. + """ + return not bool(self & (MoveClass.IMPORT | MoveClass.EXPORT)) + @classmethod def from_wire(cls, value: int) -> MoveClass: """Parse a moveClass integer into the structured flag. diff --git a/tests/i3/test_scalar_http_move_medium.py b/tests/i3/test_scalar_http_move_medium.py index 9e76a1b..73802bf 100644 --- a/tests/i3/test_scalar_http_move_medium.py +++ b/tests/i3/test_scalar_http_move_medium.py @@ -83,3 +83,56 @@ def test_load_into_nonexistent_drive_fails_gracefully(backend: ScalarHttpLibrary result = backend.load(slot_id, 9999) assert result.success is False # returned, not raised + + +def test_unload_returns_cartridge_to_the_specified_slot( + backend: ScalarHttpLibraryBackend, +) -> None: + # Regression for the moveClass=8 unload path: the cartridge must land in the + # SPECIFIC target slot the client asked for, not merely "some" slot. + slot_id = _first_occupied_slot(backend) + drive_id = _first_empty_drive(backend) + barcode = backend.get_slot(slot_id).barcode + assert barcode is not None + assert backend.load(slot_id, drive_id).success # source slot now empty + + result = backend.unload(drive_id, slot_id) + + assert result.success, result.message + landed = backend.get_slot(slot_id).barcode + assert landed is not None and landed.value == barcode.value + assert backend.find_drive_by_barcode(barcode.value) is None # drive is empty + + +def test_unload_rejects_a_drive_destination(backend: ScalarHttpLibraryBackend) -> None: + # moveClass=8 with a DRIVE destination must be rejected (slot/drive addresses + # overlap, so honoring it would silently misroute the cartridge). + slot_id = _first_occupied_slot(backend) + drive_id = _first_empty_drive(backend) + assert backend.load(slot_id, drive_id).success + + body = { + "moveMedium": { + "sourceCoordinate": {"elementType": "drive", "elementAddress": drive_id}, + "destinationCoordinate": {"elementType": "drive", "elementAddress": drive_id}, + "moveClass": 8, + } + } + resp = backend._session.request("POST", "/aml/media/operations/moveMedium", json=body) + assert resp.status_code == 422 + + +def test_moveMedium_rejects_import_export_on_i3(backend: ScalarHttpLibraryBackend) -> None: + # A real i3/i6 does not support Import(2)/Export(4) moveClass; the emulator + # rejects them rather than silently doing a normal move. + slot_id = _first_occupied_slot(backend) + for move_class in (2, 4): + body = { + "moveMedium": { + "sourceCoordinate": {"elementType": "slot", "elementAddress": slot_id}, + "destinationCoordinate": {"elementType": "slot", "elementAddress": slot_id}, + "moveClass": move_class, + } + } + resp = backend._session.request("POST", "/aml/media/operations/moveMedium", json=body) + assert resp.status_code == 422, move_class diff --git a/tests/unit/test_scalar_coordinate.py b/tests/unit/test_scalar_coordinate.py index 9bcb326..b48f93f 100644 --- a/tests/unit/test_scalar_coordinate.py +++ b/tests/unit/test_scalar_coordinate.py @@ -52,3 +52,12 @@ def test_moveclass_accepts_deprecated_unload_and_ignores_unknown_bits() -> None: # An unknown bit (1) that maps to no flag must NOT be treated as unload. assert MoveClass.from_wire(1) == MoveClass.NORMAL assert not MoveClass.from_wire(1).is_unload + + +def test_moveclass_i3_support_boundary() -> None: + # Import(2)/Export(4) are documented as not supported on i3/i6. + assert not MoveClass.from_wire(2).is_supported_on_i3 # import + assert not MoveClass.from_wire(4).is_supported_on_i3 # export + # Normal / Unload / No-Eject / Closest-slot are supported. + for value in (0, 8, 16, 24, 32, 40): + assert MoveClass.from_wire(value).is_supported_on_i3, value