test_spoolman_inventory_helpers.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. """Unit tests for _safe_int, _safe_float, and _map_spoolman_spool helpers."""
  2. import math
  3. import pytest
  4. from backend.app.api.routes._spoolman_helpers import (
  5. _map_spoolman_spool,
  6. _safe_float,
  7. _safe_int,
  8. )
  9. # ---------------------------------------------------------------------------
  10. # _safe_int
  11. # ---------------------------------------------------------------------------
  12. class TestSafeInt:
  13. def test_normal_int(self):
  14. assert _safe_int(1000, 0) == 1000
  15. def test_float_rounds_down(self):
  16. assert _safe_int(750.9, 0) == 750
  17. def test_none_returns_fallback(self):
  18. assert _safe_int(None, 999) == 999
  19. def test_nan_returns_fallback(self):
  20. assert _safe_int(math.nan, 999) == 999
  21. def test_inf_returns_fallback(self):
  22. assert _safe_int(math.inf, 999) == 999
  23. def test_neg_inf_returns_fallback(self):
  24. assert _safe_int(-math.inf, 999) == 999
  25. def test_string_numeric(self):
  26. assert _safe_int("500", 0) == 500
  27. def test_string_non_numeric_returns_fallback(self):
  28. assert _safe_int("abc", 42) == 42
  29. def test_zero(self):
  30. assert _safe_int(0, 999) == 0
  31. # ---------------------------------------------------------------------------
  32. # _safe_float
  33. # ---------------------------------------------------------------------------
  34. class TestSafeFloat:
  35. def test_normal_float(self):
  36. assert _safe_float(123.45, 0.0) == pytest.approx(123.45)
  37. def test_none_returns_fallback(self):
  38. assert _safe_float(None, -1.0) == -1.0
  39. def test_nan_returns_fallback(self):
  40. assert _safe_float(math.nan, -1.0) == -1.0
  41. def test_inf_returns_fallback(self):
  42. assert _safe_float(math.inf, -1.0) == -1.0
  43. def test_neg_inf_returns_fallback(self):
  44. assert _safe_float(-math.inf, -1.0) == -1.0
  45. def test_string_numeric(self):
  46. assert _safe_float("3.14", 0.0) == pytest.approx(3.14)
  47. def test_string_non_numeric_returns_fallback(self):
  48. assert _safe_float("bad", 0.0) == 0.0
  49. def test_zero(self):
  50. assert _safe_float(0.0, 99.0) == 0.0
  51. # ---------------------------------------------------------------------------
  52. # _map_spoolman_spool
  53. # ---------------------------------------------------------------------------
  54. MINIMAL_SPOOL = {
  55. "id": 1,
  56. "filament": {
  57. "material": "PLA",
  58. "name": "PLA Basic",
  59. "color_hex": "FF0000",
  60. "weight": 1000.0,
  61. "vendor": {"name": "Bambu Lab"},
  62. },
  63. "used_weight": 250.0,
  64. "archived": False,
  65. "registered": "2024-01-01T00:00:00Z",
  66. }
  67. class TestMapSpoolmanSpool:
  68. def test_basic_mapping(self):
  69. result = _map_spoolman_spool(MINIMAL_SPOOL)
  70. assert result["id"] == 1
  71. assert result["material"] == "PLA"
  72. assert result["rgba"] == "FF0000FF"
  73. assert result["label_weight"] == 1000
  74. assert result["weight_used"] == pytest.approx(250.0)
  75. assert result["data_origin"] == "spoolman"
  76. def test_missing_id_raises(self):
  77. spool = {k: v for k, v in MINIMAL_SPOOL.items() if k != "id"}
  78. with pytest.raises(ValueError, match="missing required 'id'"):
  79. _map_spoolman_spool(spool)
  80. def test_none_id_raises(self):
  81. with pytest.raises(ValueError):
  82. _map_spoolman_spool({**MINIMAL_SPOOL, "id": None})
  83. def test_string_id_raises(self):
  84. with pytest.raises(ValueError, match="not a valid integer"):
  85. _map_spoolman_spool({**MINIMAL_SPOOL, "id": "abc"})
  86. def test_zero_id_raises(self):
  87. with pytest.raises(ValueError, match="positive integer"):
  88. _map_spoolman_spool({**MINIMAL_SPOOL, "id": 0})
  89. def test_negative_id_raises(self):
  90. with pytest.raises(ValueError, match="positive integer"):
  91. _map_spoolman_spool({**MINIMAL_SPOOL, "id": -5})
  92. def test_numeric_string_id_accepted(self):
  93. result = _map_spoolman_spool({**MINIMAL_SPOOL, "id": "42"})
  94. assert result["id"] == 42
  95. def test_zero_price_not_converted_to_none(self):
  96. spool = {**MINIMAL_SPOOL, "price": 0.0}
  97. result = _map_spoolman_spool(spool)
  98. assert result["cost_per_kg"] == 0.0
  99. def test_nonzero_price_preserved(self):
  100. spool = {**MINIMAL_SPOOL, "price": 9.99}
  101. result = _map_spoolman_spool(spool)
  102. assert result["cost_per_kg"] == pytest.approx(9.99)
  103. def test_none_price_stays_none(self):
  104. spool = {**MINIMAL_SPOOL, "price": None}
  105. result = _map_spoolman_spool(spool)
  106. assert result["cost_per_kg"] is None
  107. def test_infinity_weight_falls_back(self):
  108. spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "weight": math.inf}}
  109. result = _map_spoolman_spool(spool)
  110. assert result["label_weight"] == 1000
  111. def test_nan_used_weight_falls_back(self):
  112. spool = {**MINIMAL_SPOOL, "used_weight": math.nan}
  113. result = _map_spoolman_spool(spool)
  114. assert result["weight_used"] == 0.0
  115. def test_invalid_color_hex_falls_back_to_grey(self):
  116. spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "color_hex": "ZZZZZZ"}}
  117. result = _map_spoolman_spool(spool)
  118. assert result["rgba"] == "808080FF"
  119. def test_short_color_hex_falls_back(self):
  120. spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "color_hex": "FFF"}}
  121. result = _map_spoolman_spool(spool)
  122. assert result["rgba"] == "808080FF"
  123. def test_eight_char_color_hex_falls_back(self):
  124. # Only 6-char hex is valid from Spoolman; 8-char (RGBA) should fall back
  125. spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "color_hex": "FF0000FF"}}
  126. result = _map_spoolman_spool(spool)
  127. assert result["rgba"] == "808080FF"
  128. def test_color_hex_with_hash_prefix_stripped(self):
  129. spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "color_hex": "#00FF00"}}
  130. result = _map_spoolman_spool(spool)
  131. assert result["rgba"] == "00FF00FF"
  132. def test_color_hex_lowercase_normalised(self):
  133. spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "color_hex": "ff0000"}}
  134. result = _map_spoolman_spool(spool)
  135. assert result["rgba"] == "FF0000FF"
  136. def test_none_filament(self):
  137. spool = {**MINIMAL_SPOOL, "filament": None}
  138. result = _map_spoolman_spool(spool)
  139. assert result["material"] == ""
  140. assert result["rgba"] == "808080FF"
  141. assert result["label_weight"] == 1000
  142. def test_archived_spool_has_archived_at(self):
  143. spool = {**MINIMAL_SPOOL, "archived": True}
  144. result = _map_spoolman_spool(spool)
  145. assert result["archived_at"] is not None
  146. def test_subtype_strips_material_prefix(self):
  147. spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "material": "PLA", "name": "PLA Basic"}}
  148. result = _map_spoolman_spool(spool)
  149. assert result["subtype"] == "Basic"
  150. def test_brand_from_vendor(self):
  151. result = _map_spoolman_spool(MINIMAL_SPOOL)
  152. assert result["brand"] == "Bambu Lab"
  153. def test_no_vendor_brand_is_none(self):
  154. spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "vendor": None}}
  155. result = _map_spoolman_spool(spool)
  156. assert result["brand"] is None
  157. def test_spoolman_location_mapped_to_storage_location(self):
  158. spool = {**MINIMAL_SPOOL, "location": "Shelf A"}
  159. result = _map_spoolman_spool(spool)
  160. assert result["storage_location"] == "Shelf A"
  161. def test_no_location_gives_none_storage_location(self):
  162. result = _map_spoolman_spool(MINIMAL_SPOOL)
  163. assert result["storage_location"] is None
  164. def test_empty_location_gives_none_storage_location(self):
  165. spool = {**MINIMAL_SPOOL, "location": ""}
  166. result = _map_spoolman_spool(spool)
  167. assert result["storage_location"] is None
  168. def test_spoolman_location_key_not_in_result(self):
  169. spool = {**MINIMAL_SPOOL, "location": "Shelf A"}
  170. result = _map_spoolman_spool(spool)
  171. assert "spoolman_location" not in result
  172. def test_core_weight_from_filament_spool_weight(self):
  173. spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": 196}}
  174. result = _map_spoolman_spool(spool)
  175. assert result["core_weight"] == 196
  176. def test_core_weight_fallback_when_spool_weight_missing(self):
  177. result = _map_spoolman_spool(MINIMAL_SPOOL)
  178. assert result["core_weight"] == 250
  179. def test_core_weight_fallback_when_spool_weight_none(self):
  180. spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": None}}
  181. result = _map_spoolman_spool(spool)
  182. assert result["core_weight"] == 250
  183. def test_core_weight_float_truncated_to_int(self):
  184. spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": 180.9}}
  185. result = _map_spoolman_spool(spool)
  186. assert result["core_weight"] == 180