test_sync_ams_weights.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. """Unit tests for the AMS weight sync calculation logic.
  2. Tests the weight_used calculation and remain% validation extracted from
  3. the POST /inventory/sync-ams-weights endpoint, without requiring a database.
  4. """
  5. import pytest
  6. from backend.app.api.routes.inventory import _find_tray_in_ams_data
  7. def _calc_weight_used(label_weight: int | None, remain: int) -> float:
  8. """Reproduce the weight calculation from sync_weights_from_ams."""
  9. lw = label_weight or 1000
  10. return round(lw * (100 - remain) / 100.0, 1)
  11. def _is_valid_remain(remain_raw) -> tuple[bool, int]:
  12. """Reproduce the remain% validation from sync_weights_from_ams.
  13. Returns (is_valid, parsed_value). parsed_value is only meaningful
  14. when is_valid is True.
  15. """
  16. if remain_raw is None:
  17. return False, 0
  18. try:
  19. val = int(remain_raw)
  20. except (TypeError, ValueError):
  21. return False, 0
  22. if val < 0 or val > 100:
  23. return False, val
  24. return True, val
  25. class TestWeightCalculation:
  26. """Test the weight_used = label_weight * (100 - remain) / 100 formula."""
  27. def test_remain_100_means_no_usage(self):
  28. """A full spool (remain=100) should have weight_used=0."""
  29. assert _calc_weight_used(1000, 100) == 0.0
  30. def test_remain_50_with_1000g_spool(self):
  31. """Half-used 1000g spool should have weight_used=500."""
  32. assert _calc_weight_used(1000, 50) == 500.0
  33. def test_remain_0_means_fully_used(self):
  34. """An empty spool (remain=0) should have weight_used equal to label_weight.
  35. Unlike the on_ams_change guard, the sync endpoint processes remain=0
  36. since it is a manual recovery tool.
  37. """
  38. assert _calc_weight_used(1000, 0) == 1000.0
  39. def test_respects_label_weight_500g(self):
  40. """500g spool at remain=50 should have weight_used=250."""
  41. assert _calc_weight_used(500, 50) == 250.0
  42. def test_respects_label_weight_250g(self):
  43. """250g spool at remain=75 should have weight_used=62.5."""
  44. assert _calc_weight_used(250, 75) == 62.5
  45. def test_none_label_weight_defaults_to_1000(self):
  46. """When label_weight is None, it defaults to 1000g."""
  47. assert _calc_weight_used(None, 50) == 500.0
  48. def test_result_is_rounded_to_one_decimal(self):
  49. """Weight used should be rounded to 1 decimal place.
  50. For a 1000g spool at remain=33, weight_used = 1000 * 67 / 100 = 670.0
  51. """
  52. assert _calc_weight_used(1000, 33) == 670.0
  53. def test_odd_fraction_rounds_correctly(self):
  54. """750g spool at remain=33 → 750 * 67/100 = 502.5."""
  55. assert _calc_weight_used(750, 33) == 502.5
  56. def test_small_spool_small_remain(self):
  57. """200g spool at remain=1 → 200 * 99/100 = 198.0."""
  58. assert _calc_weight_used(200, 1) == 198.0
  59. class TestRemainValidation:
  60. """Test the remain% bounds and type validation."""
  61. def test_remain_minus_1_is_invalid(self):
  62. """remain=-1 (firmware 'unknown') should be skipped."""
  63. valid, _ = _is_valid_remain(-1)
  64. assert valid is False
  65. def test_remain_101_is_invalid(self):
  66. """remain=101 (out of range) should be skipped."""
  67. valid, _ = _is_valid_remain(101)
  68. assert valid is False
  69. def test_remain_negative_large_is_invalid(self):
  70. """Large negative remain values should be skipped."""
  71. valid, _ = _is_valid_remain(-50)
  72. assert valid is False
  73. def test_remain_200_is_invalid(self):
  74. """remain=200 should be skipped."""
  75. valid, _ = _is_valid_remain(200)
  76. assert valid is False
  77. def test_remain_none_is_invalid(self):
  78. """remain=None (missing from tray data) should be skipped."""
  79. valid, _ = _is_valid_remain(None)
  80. assert valid is False
  81. def test_remain_non_numeric_string_is_invalid(self):
  82. """Non-numeric string remain should be skipped."""
  83. valid, _ = _is_valid_remain("abc")
  84. assert valid is False
  85. def test_remain_0_is_valid(self):
  86. """remain=0 should be valid (manual recovery handles empty spools)."""
  87. valid, val = _is_valid_remain(0)
  88. assert valid is True
  89. assert val == 0
  90. def test_remain_100_is_valid(self):
  91. """remain=100 should be valid."""
  92. valid, val = _is_valid_remain(100)
  93. assert valid is True
  94. assert val == 100
  95. def test_remain_50_is_valid(self):
  96. """remain=50 should be valid."""
  97. valid, val = _is_valid_remain(50)
  98. assert valid is True
  99. assert val == 50
  100. def test_remain_string_number_is_valid(self):
  101. """Numeric string remain (e.g. '75') should be parsed as int."""
  102. valid, val = _is_valid_remain("75")
  103. assert valid is True
  104. assert val == 75
  105. class TestFindTrayInAmsData:
  106. """Test the _find_tray_in_ams_data helper used by the sync endpoint."""
  107. def test_finds_matching_tray(self):
  108. """Should return the matching tray dict."""
  109. ams_data = [
  110. {
  111. "id": 0,
  112. "tray": [
  113. {"id": 0, "remain": 80},
  114. {"id": 1, "remain": 50},
  115. ],
  116. },
  117. ]
  118. tray = _find_tray_in_ams_data(ams_data, ams_id=0, tray_id=1)
  119. assert tray is not None
  120. assert tray["remain"] == 50
  121. def test_returns_none_for_missing_ams_unit(self):
  122. """Should return None when the AMS unit ID is not found."""
  123. ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]
  124. assert _find_tray_in_ams_data(ams_data, ams_id=1, tray_id=0) is None
  125. def test_returns_none_for_missing_tray(self):
  126. """Should return None when the tray ID is not found."""
  127. ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]
  128. assert _find_tray_in_ams_data(ams_data, ams_id=0, tray_id=3) is None
  129. def test_returns_none_for_empty_data(self):
  130. """Should return None for empty AMS data."""
  131. assert _find_tray_in_ams_data([], ams_id=0, tray_id=0) is None
  132. def test_returns_none_for_none_data(self):
  133. """Should return None for None AMS data."""
  134. assert _find_tray_in_ams_data(None, ams_id=0, tray_id=0) is None
  135. def test_multi_ams_unit_lookup(self):
  136. """Should find trays across multiple AMS units."""
  137. ams_data = [
  138. {"id": 0, "tray": [{"id": 0, "remain": 80}]},
  139. {"id": 1, "tray": [{"id": 2, "remain": 30}]},
  140. ]
  141. tray = _find_tray_in_ams_data(ams_data, ams_id=1, tray_id=2)
  142. assert tray is not None
  143. assert tray["remain"] == 30
  144. def test_ams_ht_high_id(self):
  145. """Should find trays in AMS-HT units (id >= 128)."""
  146. ams_data = [{"id": 128, "tray": [{"id": 0, "remain": 65}]}]
  147. tray = _find_tray_in_ams_data(ams_data, ams_id=128, tray_id=0)
  148. assert tray is not None
  149. assert tray["remain"] == 65
  150. class TestSyncSkipLogic:
  151. """Test combinations that exercise the sync/skip decision path."""
  152. def test_same_value_is_skipped(self):
  153. """When old weight_used matches new, the spool is skipped (no DB write)."""
  154. # Simulating the endpoint logic: if round(old_used, 1) == new_used → skip
  155. label_weight = 1000
  156. remain = 50
  157. new_used = _calc_weight_used(label_weight, remain)
  158. old_used = 500.0 # Already matches
  159. assert round(old_used, 1) == new_used # → would be skipped
  160. def test_different_value_is_synced(self):
  161. """When old weight_used differs from new, the spool is synced."""
  162. label_weight = 1000
  163. remain = 50
  164. new_used = _calc_weight_used(label_weight, remain)
  165. old_used = 300.0 # Different
  166. assert round(old_used, 1) != new_used # → would be synced
  167. def test_none_old_used_treated_as_zero(self):
  168. """When old weight_used is None (new spool), it defaults to 0."""
  169. old_used = None
  170. effective_old = old_used or 0
  171. new_used = _calc_weight_used(1000, 80) # 200.0
  172. assert effective_old == 0
  173. assert round(effective_old, 1) != new_used # → would be synced
  174. def test_remain_0_synced_not_skipped(self):
  175. """remain=0 is valid and produces weight_used=label_weight.
  176. This is distinct from on_ams_change behavior where remain=0 is
  177. ignored. The sync endpoint processes it as a manual recovery tool.
  178. """
  179. valid, val = _is_valid_remain(0)
  180. assert valid is True
  181. new_used = _calc_weight_used(1000, val)
  182. assert new_used == 1000.0
  183. def test_remain_minus_1_never_reaches_calc(self):
  184. """remain=-1 fails validation before weight calculation."""
  185. valid, _ = _is_valid_remain(-1)
  186. assert valid is False
  187. # The endpoint would skip += 1 and continue
  188. def test_remain_101_never_reaches_calc(self):
  189. """remain=101 fails validation before weight calculation."""
  190. valid, _ = _is_valid_remain(101)
  191. assert valid is False