diff --git a/pylabrobot/legacy/tip_tracker.py b/pylabrobot/legacy/tip_tracker.py index e010d1b5429..f901abd6bad 100644 --- a/pylabrobot/legacy/tip_tracker.py +++ b/pylabrobot/legacy/tip_tracker.py @@ -199,15 +199,20 @@ def serialize(self) -> dict: } def load_state(self, state: dict) -> None: - """Load a saved tip tracker state.""" + """Load committed and pending tips, including the committed tip's liquid state.""" tip_data, pending_tip_data = state.get("tip"), state.get("pending_tip") tip = Tip.deserialize(tip_data) if tip_data is not None else None pending_tip = Tip.deserialize(pending_tip_data) if pending_tip_data is not None else None - self._put(pending_tip) same = (tip is None) == (pending_tip is None) and ( tip is None or tip.serialize() == cast(Tip, pending_tip).serialize() ) + tip_state = state.get("tip_state") + if tip is not None and tip_state is not None: + tip.tracker.load_state(tip_state) + if same: + pending_tip = tip + self._put(pending_tip) self._before = _NOTHING_PENDING if same else tip def get_tip_origin(self) -> Optional["TipSpot"]: diff --git a/pylabrobot/legacy/tip_tracker_tests.py b/pylabrobot/legacy/tip_tracker_tests.py index b79ac977b85..e23ad317a16 100644 --- a/pylabrobot/legacy/tip_tracker_tests.py +++ b/pylabrobot/legacy/tip_tracker_tests.py @@ -8,6 +8,7 @@ from pylabrobot.resources import Coordinate from pylabrobot.resources.hamilton import HamiltonTip, hamilton_tip_300uL from pylabrobot.resources.hamilton.tip_creators import TIP_DIAMETER, TipSize +from pylabrobot.resources.opentrons import opentrons_96_tiprack_300ul from pylabrobot.resources.tip import Tip @@ -76,6 +77,64 @@ def test_load_empty_state(self): with self.assertRaises(NoTipError): tracker.get_tip() + def test_load_committed_tip_spot_state_remains_committed(self): + """A restored occupied spot retains its committed tip and liquid volume.""" + spot = opentrons_96_tiprack_300ul("tips").get_item("A1") + spot.tracker.get_tip().tracker.set_volume(17) + state = spot.serialize_state() + + spot.load_state(state) + + self.assertEqual(spot.serialize_state(), state) + spot.tracker.rollback() + self.assertEqual(spot.serialize_state(), state) + + def test_load_pending_tip_spot_removal_preserves_rollback(self): + """Rollback restores a removed tip with its saved liquid volume.""" + spot = opentrons_96_tiprack_300ul("tips").get_item("A1") + spot.tracker.get_tip().tracker.set_volume(17) + committed = spot.serialize_state() + spot.tracker.remove_tip(commit=False) + pending = spot.serialize_state() + + spot.load_state(pending) + + self.assertFalse(spot.tracker.has_tip) + self.assertEqual(spot.tracker.get_tip().name, committed["tip"]["name"]) + spot.tracker.rollback() + self.assertEqual(spot.serialize_state(), committed) + + def test_load_channel_tip_preserves_pending_liquid_state(self): + """Restoration preserves uncommitted liquid changes until explicit rollback.""" + tracker = TipTracker("channel") + tracker.add_tip(self.tip) + self.tip.tracker.set_volume(4) + self.tip.tracker.add_liquid(3) + saved = tracker.serialize() + + restored = TipTracker("restored") + restored.load_state(saved) + + self.assertEqual(restored.serialize(), saved) + restored.get_tip().tracker.rollback() + self.assertEqual(restored.get_tip().tracker.get_used_volume(), 4) + + def test_load_pending_pickup_preserves_tip_transaction(self): + """A pending pickup remains pending and can be rolled back after restoration.""" + tracker = TipTracker("channel") + tracker.add_tip(self.tip, commit=False) + saved = tracker.serialize() + + restored = TipTracker("restored") + restored.load_state(saved) + + self.assertEqual(restored.serialize(), saved) + self.assertTrue(restored.has_tip) + with self.assertRaises(NoTipError): + restored.get_tip() + restored.rollback() + self.assertFalse(restored.has_tip) + def test_a_pending_removal_keeps_the_tip_readable_until_it_commits(self): """A liquid handler removes a spot's tip before its backend asks the spot which tip it is.""" tracker = TipTracker(thing="tester")