"""P9 — ``liquid_history`` schema v3 + helper 单元测试。 测试覆盖: - :func:`append_liquid_history`:写 v3 entry / tracker 缺失 graceful / 滚动上限 - :func:`normalize_liquid_history`:v3 dict / v2 tuple / list[str] / 混合 / 非法 - :func:`well_current_liquid_name`:tracker.liquids 末项 / get_liquids fallback / 缺失 注:``LiquidHandlerAbstract.set_liquid`` 写 history 的集成("set" action)覆盖 逻辑相同(直接调用 :func:`append_liquid_history`),由本测试间接验证;端到端走 PLR 真实 ``Well.set_liquids`` 的集成测试在 ``tests/devices/liquid_handling/unit_test.py`` 范围内随 PLR 环境就绪后增补,本 P9 提交保持解耦。 详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md`` §8。 """ from __future__ import annotations from dataclasses import dataclass, field from typing import Any, List, Tuple import pytest # liquid_history 模块**不依赖** pylabrobot,可在 PLR 环境缺失时独立 import / 单测。 from unilabos.devices.liquid_handling.liquid_history import ( LIQUID_HISTORY_MAX_ENTRIES, LiquidHistoryEntry, append_liquid_history, normalize_liquid_history, well_current_liquid_name, ) # --------------------------------------------------------------------------- # Fixtures:DummyTracker / DummyWell(避免引入真实 PLR Well/VolumeTracker 依赖) # --------------------------------------------------------------------------- @dataclass class DummyTracker: """模拟 PLR VolumeTracker:仅暴露 P9 hook 关心的字段。""" liquid_history: List[Any] = field(default_factory=list) liquids: List[Tuple[Any, float]] = field(default_factory=list) max_volume: float = 200.0 is_disabled: bool = False @dataclass class DummyWell: """模拟 PLR Well:仅暴露 ``tracker``。""" name: str = "well_A1" max_volume: float = 200.0 tracker: DummyTracker = field(default_factory=DummyTracker) # --------------------------------------------------------------------------- # append_liquid_history # --------------------------------------------------------------------------- class TestAppendLiquidHistory: def test_append_creates_v3_entry(self) -> None: well = DummyWell() append_liquid_history(well, "Plasma", 100.0, "set") assert len(well.tracker.liquid_history) == 1 entry = well.tracker.liquid_history[0] assert entry["name"] == "Plasma" assert entry["volume"] == 100.0 assert entry["action"] == "set" assert "timestamp" in entry and isinstance(entry["timestamp"], str) def test_append_aspirate_negative_volume(self) -> None: well = DummyWell() append_liquid_history(well, "Water", -50.0, "aspirate") assert well.tracker.liquid_history[0]["volume"] == -50.0 assert well.tracker.liquid_history[0]["action"] == "aspirate" def test_append_with_empty_name_keeps_empty_string(self) -> None: """name 为空时应写入 ``""`` 而非字面 "unknown"(避免视觉混淆 bottom_type)。""" well = DummyWell() append_liquid_history(well, "", 50.0, "dispense") assert well.tracker.liquid_history[0]["name"] == "" def test_append_with_none_name_normalized_to_empty_string(self) -> None: well = DummyWell() append_liquid_history(well, None, 50.0, "dispense") # type: ignore[arg-type] assert well.tracker.liquid_history[0]["name"] == "" def test_append_initializes_history_if_missing(self) -> None: """tracker 没有 liquid_history 属性时 helper 自动创建空 list 并写入。""" well = DummyWell() del well.tracker.liquid_history # 模拟全新 PLR tracker append_liquid_history(well, "X", 10.0, "set") assert hasattr(well.tracker, "liquid_history") assert len(well.tracker.liquid_history) == 1 def test_append_no_tracker_is_graceful(self) -> None: """well 无 tracker 时静默不抛(保护主流程)。""" class NoTrackerWell: name = "no_tracker" well = NoTrackerWell() append_liquid_history(well, "X", 10.0, "set") # 不应抛 assert not hasattr(well, "tracker") def test_append_action_defaults_to_legacy_when_empty(self) -> None: well = DummyWell() append_liquid_history(well, "X", 1.0, "") assert well.tracker.liquid_history[0]["action"] == "legacy" def test_append_respects_max_entries_rolling(self) -> None: """超过 ``LIQUID_HISTORY_MAX_ENTRIES`` 时丢弃头部,保留最近 entries。""" well = DummyWell() well.tracker.liquid_history = [ {"name": f"old_{i}"} for i in range(LIQUID_HISTORY_MAX_ENTRIES + 5) ] append_liquid_history(well, "newest", 1.0, "set") assert len(well.tracker.liquid_history) == LIQUID_HISTORY_MAX_ENTRIES assert well.tracker.liquid_history[-1]["name"] == "newest" assert well.tracker.liquid_history[0]["name"] != "old_0" # --------------------------------------------------------------------------- # normalize_liquid_history # --------------------------------------------------------------------------- class TestNormalizeLiquidHistory: def test_v3_dict_passthrough_with_field_defaults(self) -> None: raw = [{"name": "A", "volume": 100, "action": "set", "timestamp": "2026-05-22T00:00:00Z"}] result = normalize_liquid_history(raw) assert result == [{ "name": "A", "volume": 100.0, "action": "set", "timestamp": "2026-05-22T00:00:00Z", }] def test_v3_dict_missing_optional_fields_filled_with_defaults(self) -> None: raw = [{"name": "A"}] result = normalize_liquid_history(raw) assert result == [{"name": "A", "volume": 0.0, "action": "legacy"}] assert "timestamp" not in result[0] def test_v2_tuple_upgraded_to_v3_legacy(self) -> None: raw = [("A", 100), ("B", 50.5)] result = normalize_liquid_history(raw) assert result == [ {"name": "A", "volume": 100.0, "action": "legacy"}, {"name": "B", "volume": 50.5, "action": "legacy"}, ] def test_list_of_strings_upgraded(self) -> None: raw = ["A", "B"] result = normalize_liquid_history(raw) assert result == [ {"name": "A", "volume": 0.0, "action": "legacy"}, {"name": "B", "volume": 0.0, "action": "legacy"}, ] def test_mixed_input_normalized(self) -> None: raw = [ {"name": "A", "volume": 1, "action": "set"}, ("B", 2), "C", ] result = normalize_liquid_history(raw) assert [e["name"] for e in result] == ["A", "B", "C"] assert [e["action"] for e in result] == ["set", "legacy", "legacy"] def test_invalid_entries_dropped(self) -> None: raw = [42, None, {"name": "A"}, ("only_one",)] result = normalize_liquid_history(raw) # 只保留 {"name": "A"} 这一条;其它都被丢弃 assert len(result) == 1 assert result[0]["name"] == "A" assert result[0]["volume"] == 0.0 # 缺省补 0 def test_non_list_input_returns_empty(self) -> None: assert normalize_liquid_history(None) == [] assert normalize_liquid_history("not_a_list") == [] assert normalize_liquid_history({"name": "X"}) == [] def test_tuple_with_unconvertible_volume_falls_back_to_zero(self) -> None: raw = [("A", "not_a_number")] result = normalize_liquid_history(raw) assert result[0]["volume"] == 0.0 # --------------------------------------------------------------------------- # well_current_liquid_name # --------------------------------------------------------------------------- class TestWellCurrentLiquidName: def test_returns_last_liquid_name_from_tuple(self) -> None: well = DummyWell() well.tracker.liquids = [("Water", 50.0), ("Plasma", 100.0)] assert well_current_liquid_name(well) == "Plasma" def test_returns_enum_like_name_attr(self) -> None: class FakeLiquid: name = "ETHANOL" well = DummyWell() well.tracker.liquids = [(FakeLiquid(), 100.0)] assert well_current_liquid_name(well) == "ETHANOL" def test_empty_liquids_returns_empty_string(self) -> None: well = DummyWell() well.tracker.liquids = [] assert well_current_liquid_name(well) == "" def test_no_tracker_returns_empty_string(self) -> None: class NoTrackerWell: name = "x" assert well_current_liquid_name(NoTrackerWell()) == "" def test_none_liquid_returns_empty_string(self) -> None: well = DummyWell() well.tracker.liquids = [(None, 100.0)] assert well_current_liquid_name(well) == "" def test_string_liquid_returned_as_is(self) -> None: well = DummyWell() well.tracker.liquids = ["Saline"] assert well_current_liquid_name(well) == "Saline"