test_spoolman_inventory_helpers.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479
  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_name_uses_explicit_field_when_present(self):
  129. """When Spoolman's filament has color_name set, that wins over the subtype fallback."""
  130. spool = {
  131. **MINIMAL_SPOOL,
  132. "filament": {**MINIMAL_SPOOL["filament"], "color_name": "Sunrise Orange"},
  133. }
  134. result = _map_spoolman_spool(spool)
  135. assert result["color_name"] == "Sunrise Orange"
  136. def test_color_name_falls_back_to_subtype_when_field_missing(self):
  137. """Spoolman doesn't standardise color_name; the LinkSpoolModal would
  138. otherwise show 'Unknown color' for every Spoolman spool. The mapper
  139. falls back to the filament's name minus material prefix (which the
  140. subtype field already carries) so the user can tell spools apart at a
  141. glance even on installs that don't fill color_name.
  142. """
  143. spool = {
  144. **MINIMAL_SPOOL,
  145. "filament": {
  146. **MINIMAL_SPOOL["filament"],
  147. "name": "PLA Basic Red",
  148. # No color_name field — the common case for default Spoolman installs.
  149. },
  150. }
  151. result = _map_spoolman_spool(spool)
  152. # subtype is filament_name minus material prefix → "Basic Red"
  153. assert result["subtype"] == "Basic Red"
  154. # color_name falls back to subtype.
  155. assert result["color_name"] == "Basic Red"
  156. def test_color_name_none_when_both_fields_empty(self):
  157. """If neither color_name nor a usable subtype exists, return None — UI
  158. falls back to its own 'Unknown color' string rather than showing a
  159. misleading material-only label.
  160. """
  161. spool = {
  162. **MINIMAL_SPOOL,
  163. "filament": {
  164. **MINIMAL_SPOOL["filament"],
  165. "name": "PLA", # name == material → subtype becomes None
  166. },
  167. }
  168. result = _map_spoolman_spool(spool)
  169. assert result["subtype"] is None
  170. assert result["color_name"] is None
  171. def test_color_hex_with_hash_prefix_stripped(self):
  172. spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "color_hex": "#00FF00"}}
  173. result = _map_spoolman_spool(spool)
  174. assert result["rgba"] == "00FF00FF"
  175. def test_color_hex_lowercase_normalised(self):
  176. spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "color_hex": "ff0000"}}
  177. result = _map_spoolman_spool(spool)
  178. assert result["rgba"] == "FF0000FF"
  179. def test_none_filament(self):
  180. spool = {**MINIMAL_SPOOL, "filament": None}
  181. result = _map_spoolman_spool(spool)
  182. assert result["material"] == ""
  183. assert result["rgba"] == "808080FF"
  184. assert result["label_weight"] == 1000
  185. def test_archived_spool_has_archived_at(self):
  186. spool = {**MINIMAL_SPOOL, "archived": True}
  187. result = _map_spoolman_spool(spool)
  188. assert result["archived_at"] is not None
  189. def test_subtype_strips_material_prefix(self):
  190. spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "material": "PLA", "name": "PLA Basic"}}
  191. result = _map_spoolman_spool(spool)
  192. assert result["subtype"] == "Basic"
  193. def test_brand_from_vendor(self):
  194. result = _map_spoolman_spool(MINIMAL_SPOOL)
  195. assert result["brand"] == "Bambu Lab"
  196. def test_no_vendor_brand_is_none(self):
  197. spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "vendor": None}}
  198. result = _map_spoolman_spool(spool)
  199. assert result["brand"] is None
  200. def test_spoolman_location_mapped_to_storage_location(self):
  201. spool = {**MINIMAL_SPOOL, "location": "Shelf A"}
  202. result = _map_spoolman_spool(spool)
  203. assert result["storage_location"] == "Shelf A"
  204. def test_no_location_gives_none_storage_location(self):
  205. result = _map_spoolman_spool(MINIMAL_SPOOL)
  206. assert result["storage_location"] is None
  207. def test_empty_location_gives_none_storage_location(self):
  208. spool = {**MINIMAL_SPOOL, "location": ""}
  209. result = _map_spoolman_spool(spool)
  210. assert result["storage_location"] is None
  211. def test_spoolman_location_key_not_in_result(self):
  212. spool = {**MINIMAL_SPOOL, "location": "Shelf A"}
  213. result = _map_spoolman_spool(spool)
  214. assert "spoolman_location" not in result
  215. def test_core_weight_from_filament_spool_weight(self):
  216. spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": 196}}
  217. result = _map_spoolman_spool(spool)
  218. assert result["core_weight"] == 196
  219. def test_core_weight_fallback_when_spool_weight_missing(self):
  220. result = _map_spoolman_spool(MINIMAL_SPOOL)
  221. assert result["core_weight"] == 250
  222. def test_core_weight_fallback_when_spool_weight_none(self):
  223. spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": None}}
  224. result = _map_spoolman_spool(spool)
  225. assert result["core_weight"] == 250
  226. def test_core_weight_float_truncated_to_int(self):
  227. spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": 180.9}}
  228. result = _map_spoolman_spool(spool)
  229. assert result["core_weight"] == 180
  230. def test_spool_level_spool_weight_takes_priority_over_filament(self):
  231. spool = {**MINIMAL_SPOOL, "spool_weight": 300, "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": 196}}
  232. assert _map_spoolman_spool(spool)["core_weight"] == 300
  233. def test_spool_level_zero_spool_weight_not_treated_as_missing(self):
  234. spool = {**MINIMAL_SPOOL, "spool_weight": 0, "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": 196}}
  235. assert _map_spoolman_spool(spool)["core_weight"] == 0
  236. def test_spool_level_none_falls_back_to_filament(self):
  237. spool = {**MINIMAL_SPOOL, "spool_weight": None, "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": 196}}
  238. assert _map_spoolman_spool(spool)["core_weight"] == 196
  239. def test_spool_level_absent_falls_back_to_filament(self):
  240. spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": 196}}
  241. assert _map_spoolman_spool(spool)["core_weight"] == 196
  242. def test_both_levels_none_uses_fallback(self):
  243. spool = {**MINIMAL_SPOOL, "spool_weight": None, "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": None}}
  244. assert _map_spoolman_spool(spool)["core_weight"] == 250
  245. # ---------------------------------------------------------------------------
  246. # F4: _safe_optional_float unit tests
  247. # ---------------------------------------------------------------------------
  248. class TestSafeOptionalFloat:
  249. """F4: Direct unit tests for _safe_optional_float (NaN/Inf safety)."""
  250. def test_normal_value(self):
  251. import pytest
  252. from backend.app.api.routes._spoolman_helpers import _safe_optional_float
  253. assert _safe_optional_float(9.99) == pytest.approx(9.99)
  254. def test_none_returns_none(self):
  255. from backend.app.api.routes._spoolman_helpers import _safe_optional_float
  256. assert _safe_optional_float(None) is None
  257. def test_nan_returns_none(self):
  258. import math
  259. from backend.app.api.routes._spoolman_helpers import _safe_optional_float
  260. assert _safe_optional_float(math.nan) is None
  261. def test_inf_returns_none(self):
  262. import math
  263. from backend.app.api.routes._spoolman_helpers import _safe_optional_float
  264. assert _safe_optional_float(math.inf) is None
  265. def test_neg_inf_returns_none(self):
  266. import math
  267. from backend.app.api.routes._spoolman_helpers import _safe_optional_float
  268. assert _safe_optional_float(-math.inf) is None
  269. def test_zero_returns_zero(self):
  270. from backend.app.api.routes._spoolman_helpers import _safe_optional_float
  271. assert _safe_optional_float(0.0) == 0.0
  272. def test_string_numeric(self):
  273. import pytest
  274. from backend.app.api.routes._spoolman_helpers import _safe_optional_float
  275. assert _safe_optional_float("3.14") == pytest.approx(3.14)
  276. def test_string_non_numeric_returns_none(self):
  277. from backend.app.api.routes._spoolman_helpers import _safe_optional_float
  278. assert _safe_optional_float("bad") is None
  279. class TestMapSpoolmanSpoolSlicerFilament:
  280. """slicer_filament round-trip via Spoolman extra dict.
  281. Spoolman has no native slicer_filament field, so we persist BambuStudio
  282. presets under bambu_slicer_filament[_name] keys in the spool's extra
  283. dict (JSON-encoded strings, like every Spoolman extra value). The map
  284. function unwraps those values and exposes them as slicer_filament /
  285. slicer_filament_name on the InventorySpool shape. Without this round-trip
  286. the user's selected slicer preset is silently dropped on save (#1114).
  287. """
  288. def test_slicer_filament_unwrapped_from_extra(self):
  289. spool = {
  290. **MINIMAL_SPOOL,
  291. "extra": {
  292. "bambu_slicer_filament": '"PFUSf543b298f8ea66"',
  293. "bambu_slicer_filament_name": '"Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle (Custom)"',
  294. },
  295. }
  296. result = _map_spoolman_spool(spool)
  297. assert result["slicer_filament"] == "PFUSf543b298f8ea66"
  298. assert result["slicer_filament_name"] == "Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle (Custom)"
  299. def test_slicer_filament_falls_back_to_filament_name(self):
  300. # Spool has no bambu_slicer_filament_name override → use Spoolman's filament.name
  301. spool = {**MINIMAL_SPOOL, "extra": {}}
  302. result = _map_spoolman_spool(spool)
  303. assert result["slicer_filament"] is None
  304. assert result["slicer_filament_name"] == "PLA Basic" # from filament.name
  305. def test_empty_string_extra_treated_as_unset(self):
  306. # JSON-encoded empty string is how the user clears the field
  307. spool = {
  308. **MINIMAL_SPOOL,
  309. "extra": {
  310. "bambu_slicer_filament": '""',
  311. "bambu_slicer_filament_name": '""',
  312. },
  313. }
  314. result = _map_spoolman_spool(spool)
  315. assert result["slicer_filament"] is None
  316. # Falls back to filament.name when the override is cleared
  317. assert result["slicer_filament_name"] == "PLA Basic"
  318. def test_non_json_extra_value_passed_through(self):
  319. # Tolerate bare-string values written without JSON encoding
  320. # (older data, manual writes via Spoolman UI, etc.)
  321. spool = {
  322. **MINIMAL_SPOOL,
  323. "extra": {"bambu_slicer_filament": "GFL05"},
  324. }
  325. result = _map_spoolman_spool(spool)
  326. assert result["slicer_filament"] == "GFL05"
  327. class TestExtractExtraStr:
  328. """JSON-encoded extra-string unwrapper used by _map_spoolman_spool."""
  329. def test_unwraps_quoted_string(self):
  330. from backend.app.api.routes._spoolman_helpers import _extract_extra_str
  331. assert _extract_extra_str({"k": '"hello"'}, "k") == "hello"
  332. def test_returns_empty_for_missing_key(self):
  333. from backend.app.api.routes._spoolman_helpers import _extract_extra_str
  334. assert _extract_extra_str({}, "k") == ""
  335. def test_returns_empty_for_non_string_value(self):
  336. from backend.app.api.routes._spoolman_helpers import _extract_extra_str
  337. # Spoolman extra values are stringified; numeric values shouldn't sneak in
  338. # but if they do we treat them as unset rather than crashing
  339. assert _extract_extra_str({"k": 42}, "k") == ""
  340. def test_returns_empty_for_json_null(self):
  341. from backend.app.api.routes._spoolman_helpers import _extract_extra_str
  342. # null isn't a string after decode → treat as unset
  343. assert _extract_extra_str({"k": "null"}, "k") == ""
  344. def test_passes_through_bare_string_on_decode_error(self):
  345. from backend.app.api.routes._spoolman_helpers import _extract_extra_str
  346. # Tolerate non-JSON-encoded values
  347. assert _extract_extra_str({"k": "GFL05"}, "k") == "GFL05"
  348. class TestMapSpoolmanSpoolPrice:
  349. """F4: NaN/Inf price in _map_spoolman_spool gives None cost_per_kg."""
  350. def test_nan_price_gives_none_cost_per_kg(self):
  351. import math
  352. from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
  353. spool = {**MINIMAL_SPOOL, "price": math.nan}
  354. assert _map_spoolman_spool(spool)["cost_per_kg"] is None
  355. def test_inf_price_gives_none_cost_per_kg(self):
  356. import math
  357. from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
  358. spool = {**MINIMAL_SPOOL, "price": math.inf}
  359. assert _map_spoolman_spool(spool)["cost_per_kg"] is None