test_spoolman_inventory_helpers.py 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785
  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. cost_per_kg_to_spoolman_price,
  9. spoolman_net_weight,
  10. spoolman_price_to_cost_per_kg,
  11. spoolman_tare,
  12. )
  13. # ---------------------------------------------------------------------------
  14. # _safe_int
  15. # ---------------------------------------------------------------------------
  16. class TestSafeInt:
  17. def test_normal_int(self):
  18. assert _safe_int(1000, 0) == 1000
  19. def test_float_rounds_down(self):
  20. assert _safe_int(750.9, 0) == 750
  21. def test_none_returns_fallback(self):
  22. assert _safe_int(None, 999) == 999
  23. def test_nan_returns_fallback(self):
  24. assert _safe_int(math.nan, 999) == 999
  25. def test_inf_returns_fallback(self):
  26. assert _safe_int(math.inf, 999) == 999
  27. def test_neg_inf_returns_fallback(self):
  28. assert _safe_int(-math.inf, 999) == 999
  29. def test_string_numeric(self):
  30. assert _safe_int("500", 0) == 500
  31. def test_string_non_numeric_returns_fallback(self):
  32. assert _safe_int("abc", 42) == 42
  33. def test_zero(self):
  34. assert _safe_int(0, 999) == 0
  35. # ---------------------------------------------------------------------------
  36. # _safe_float
  37. # ---------------------------------------------------------------------------
  38. class TestSafeFloat:
  39. def test_normal_float(self):
  40. assert _safe_float(123.45, 0.0) == pytest.approx(123.45)
  41. def test_none_returns_fallback(self):
  42. assert _safe_float(None, -1.0) == -1.0
  43. def test_nan_returns_fallback(self):
  44. assert _safe_float(math.nan, -1.0) == -1.0
  45. def test_inf_returns_fallback(self):
  46. assert _safe_float(math.inf, -1.0) == -1.0
  47. def test_neg_inf_returns_fallback(self):
  48. assert _safe_float(-math.inf, -1.0) == -1.0
  49. def test_string_numeric(self):
  50. assert _safe_float("3.14", 0.0) == pytest.approx(3.14)
  51. def test_string_non_numeric_returns_fallback(self):
  52. assert _safe_float("bad", 0.0) == 0.0
  53. def test_zero(self):
  54. assert _safe_float(0.0, 99.0) == 0.0
  55. # ---------------------------------------------------------------------------
  56. # _map_spoolman_spool
  57. # ---------------------------------------------------------------------------
  58. MINIMAL_SPOOL = {
  59. "id": 1,
  60. "filament": {
  61. "material": "PLA",
  62. "name": "PLA Basic",
  63. "color_hex": "FF0000",
  64. "weight": 1000.0,
  65. "vendor": {"name": "Bambu Lab"},
  66. },
  67. "used_weight": 250.0,
  68. "archived": False,
  69. "registered": "2024-01-01T00:00:00Z",
  70. }
  71. class TestMapSpoolmanSpool:
  72. def test_basic_mapping(self):
  73. result = _map_spoolman_spool(MINIMAL_SPOOL)
  74. assert result["id"] == 1
  75. assert result["material"] == "PLA"
  76. assert result["rgba"] == "FF0000FF"
  77. assert result["label_weight"] == 1000
  78. # No remaining_weight set → fallback path: weight_used = used_weight, baseline = 0.
  79. assert result["weight_used"] == pytest.approx(250.0)
  80. assert result["weight_used_baseline"] == pytest.approx(0.0)
  81. assert result["data_origin"] == "spoolman"
  82. def test_article_number_maps_to_material_number(self):
  83. """Spoolman's filament.article_number is the material number (#2870)."""
  84. spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "article_number": "15"}}
  85. assert _map_spoolman_spool(spool)["material_number"] == "15"
  86. def test_missing_or_blank_article_number_maps_to_none(self):
  87. assert _map_spoolman_spool(MINIMAL_SPOOL)["material_number"] is None
  88. for value in ("", " "):
  89. blank = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "article_number": value}}
  90. assert _map_spoolman_spool(blank)["material_number"] is None
  91. def test_padded_article_number_is_trimmed(self):
  92. """The filter chip matches exactly against trimmed options (#2870)."""
  93. spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "article_number": " 15 "}}
  94. assert _map_spoolman_spool(spool)["material_number"] == "15"
  95. def test_remaining_weight_drives_synthetic_used_for_parity(self):
  96. """When remaining_weight is set, weight_used = label - remaining and
  97. the baseline absorbs the used_weight delta. This mirrors the internal
  98. Spool model's split between consumed counter and physical depletion
  99. so the frontend computes the same display in both modes (#1390).
  100. """
  101. spool = {**MINIMAL_SPOOL, "used_weight": 250.0, "remaining_weight": 544.0}
  102. result = _map_spoolman_spool(spool)
  103. # Remaining = label - weight_used must equal real remaining_weight.
  104. assert result["label_weight"] - result["weight_used"] == pytest.approx(544.0)
  105. # Consumed = weight_used - baseline must equal real used_weight.
  106. assert result["weight_used"] - result["weight_used_baseline"] == pytest.approx(250.0)
  107. def test_remaining_weight_after_reset(self):
  108. """Spoolman reset: used_weight=0, remaining_weight unchanged. The
  109. mapper produces baseline = weight_used so the displayed consumed
  110. counter reads 0 while remaining stays at the real value.
  111. """
  112. spool = {**MINIMAL_SPOOL, "used_weight": 0.0, "remaining_weight": 544.0}
  113. result = _map_spoolman_spool(spool)
  114. assert result["weight_used"] == pytest.approx(456.0)
  115. assert result["weight_used_baseline"] == pytest.approx(456.0)
  116. assert result["weight_used"] - result["weight_used_baseline"] == pytest.approx(0.0)
  117. assert result["label_weight"] - result["weight_used"] == pytest.approx(544.0)
  118. def test_missing_id_raises(self):
  119. spool = {k: v for k, v in MINIMAL_SPOOL.items() if k != "id"}
  120. with pytest.raises(ValueError, match="missing required 'id'"):
  121. _map_spoolman_spool(spool)
  122. def test_none_id_raises(self):
  123. with pytest.raises(ValueError):
  124. _map_spoolman_spool({**MINIMAL_SPOOL, "id": None})
  125. def test_string_id_raises(self):
  126. with pytest.raises(ValueError, match="not a valid integer"):
  127. _map_spoolman_spool({**MINIMAL_SPOOL, "id": "abc"})
  128. def test_zero_id_raises(self):
  129. with pytest.raises(ValueError, match="positive integer"):
  130. _map_spoolman_spool({**MINIMAL_SPOOL, "id": 0})
  131. def test_negative_id_raises(self):
  132. with pytest.raises(ValueError, match="positive integer"):
  133. _map_spoolman_spool({**MINIMAL_SPOOL, "id": -5})
  134. def test_numeric_string_id_accepted(self):
  135. result = _map_spoolman_spool({**MINIMAL_SPOOL, "id": "42"})
  136. assert result["id"] == 42
  137. def test_zero_price_not_converted_to_none(self):
  138. spool = {**MINIMAL_SPOOL, "price": 0.0}
  139. result = _map_spoolman_spool(spool)
  140. assert result["cost_per_kg"] == 0.0
  141. def test_nonzero_price_preserved(self):
  142. spool = {**MINIMAL_SPOOL, "price": 9.99}
  143. result = _map_spoolman_spool(spool)
  144. assert result["cost_per_kg"] == pytest.approx(9.99)
  145. def test_none_price_stays_none(self):
  146. spool = {**MINIMAL_SPOOL, "price": None}
  147. result = _map_spoolman_spool(spool)
  148. assert result["cost_per_kg"] is None
  149. @pytest.mark.parametrize(
  150. ("spool_price", "filament_price", "own", "charged"),
  151. [(None, 24.0, None, 24.0), (30.0, 24.0, 30.0, 30.0), (None, None, None, None)],
  152. ids=["filament-only", "spool-wins", "unpriced"],
  153. )
  154. def test_the_rate_prints_are_charged_at(self, spool_price, filament_price, own, charged):
  155. """SM-C8: with only a filament price, Cost/kg read "-" although every
  156. print was charged at it. cost_per_kg stays the spool's own, which the
  157. edit form writes back."""
  158. filament = {**MINIMAL_SPOOL["filament"], "weight": 1000, "price": filament_price}
  159. result = _map_spoolman_spool({**MINIMAL_SPOOL, "price": spool_price, "filament": filament})
  160. assert result["cost_per_kg"] == (pytest.approx(own) if own is not None else None)
  161. assert result["effective_cost_per_kg"] == (pytest.approx(charged) if charged is not None else None)
  162. def test_infinity_weight_falls_back(self):
  163. spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "weight": math.inf}}
  164. result = _map_spoolman_spool(spool)
  165. assert result["label_weight"] == 1000
  166. def test_nan_used_weight_falls_back(self):
  167. spool = {**MINIMAL_SPOOL, "used_weight": math.nan}
  168. result = _map_spoolman_spool(spool)
  169. assert result["weight_used"] == 0.0
  170. def test_invalid_color_hex_falls_back_to_grey(self):
  171. spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "color_hex": "ZZZZZZ"}}
  172. result = _map_spoolman_spool(spool)
  173. assert result["rgba"] == "808080FF"
  174. def test_short_color_hex_falls_back(self):
  175. spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "color_hex": "FFF"}}
  176. result = _map_spoolman_spool(spool)
  177. assert result["rgba"] == "808080FF"
  178. def test_eight_char_color_hex_is_read_back_with_its_alpha(self):
  179. """#2912: 8-char color_hex is a value the write side stores on purpose for a
  180. translucent spool, so the read must return it rather than grey it out.
  181. This test previously asserted the opposite, on the premise that only 6-char
  182. hex was valid from Spoolman. Spoolman stores whatever it is given, and
  183. Bambuddy's own rgba fields advertise RRGGBBAA — the read was what turned a
  184. clear spool into neutral grey.
  185. """
  186. spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "color_hex": "FF000080"}}
  187. result = _map_spoolman_spool(spool)
  188. assert result["rgba"] == "FF000080"
  189. def test_fully_transparent_color_hex_survives_the_read(self):
  190. """The reported case: a clear spool stored as 00000000 must not come back
  191. as opaque black."""
  192. spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "color_hex": "00000000"}}
  193. result = _map_spoolman_spool(spool)
  194. assert result["rgba"] == "00000000"
  195. def test_six_char_color_hex_still_gains_the_opaque_alpha(self):
  196. """Existing data is 6-char and must keep round-tripping unchanged — the
  197. opaque byte is appended, not doubled onto an alpha that is already there."""
  198. spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "color_hex": "FF0000"}}
  199. result = _map_spoolman_spool(spool)
  200. assert result["rgba"] == "FF0000FF"
  201. def test_seven_char_color_hex_falls_back(self):
  202. """Only 6 or 8 are valid lengths; a 7-char value is malformed and still
  203. greys out rather than being padded into something plausible."""
  204. spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "color_hex": "FF0000F"}}
  205. result = _map_spoolman_spool(spool)
  206. assert result["rgba"] == "808080FF"
  207. def test_color_name_uses_explicit_field_when_present(self):
  208. """When Spoolman's filament has color_name set, that wins over the subtype fallback."""
  209. spool = {
  210. **MINIMAL_SPOOL,
  211. "filament": {**MINIMAL_SPOOL["filament"], "color_name": "Sunrise Orange"},
  212. }
  213. result = _map_spoolman_spool(spool)
  214. assert result["color_name"] == "Sunrise Orange"
  215. # Real stored value — not synthesised from subtype.
  216. assert result["color_name_is_synthesized"] is False
  217. def test_color_name_flags_synthesized_when_falling_back_to_subtype(self):
  218. """#1319: when the read falls back to subtype, the response must flag it
  219. so the edit form doesn't round-trip the synth value back to Spoolman."""
  220. spool = {
  221. **MINIMAL_SPOOL,
  222. "filament": {
  223. **MINIMAL_SPOOL["filament"],
  224. "name": "PLA Basic Red",
  225. # No color_name field.
  226. },
  227. }
  228. result = _map_spoolman_spool(spool)
  229. assert result["color_name"] == "Basic Red"
  230. assert result["color_name_is_synthesized"] is True
  231. def test_color_name_falls_back_to_subtype_when_field_missing(self):
  232. """Spoolman doesn't standardise color_name; the LinkSpoolModal would
  233. otherwise show 'Unknown color' for every Spoolman spool. The mapper
  234. falls back to the filament's name minus material prefix (which the
  235. subtype field already carries) so the user can tell spools apart at a
  236. glance even on installs that don't fill color_name.
  237. """
  238. spool = {
  239. **MINIMAL_SPOOL,
  240. "filament": {
  241. **MINIMAL_SPOOL["filament"],
  242. "name": "PLA Basic Red",
  243. # No color_name field — the common case for default Spoolman installs.
  244. },
  245. }
  246. result = _map_spoolman_spool(spool)
  247. # subtype is filament_name minus material prefix → "Basic Red"
  248. assert result["subtype"] == "Basic Red"
  249. # color_name falls back to subtype.
  250. assert result["color_name"] == "Basic Red"
  251. def test_color_name_read_from_spool_extra_first(self):
  252. """#1357: the canonical store for color_name is
  253. spool.extra.bambu_color_name (JSON-encoded). Read priority is
  254. extra > filament.color_name > subtype-synth. The user's
  255. Bambuddy-saved value MUST win even when Spoolman's own
  256. filament.color_name happens to be populated from some other source.
  257. """
  258. spool = {
  259. **MINIMAL_SPOOL,
  260. "extra": {"bambu_color_name": '"Galaxy Black"'},
  261. "filament": {
  262. **MINIMAL_SPOOL["filament"],
  263. "name": "PLA Glow",
  264. "color_name": "Glow", # would be picked up if extra weren't preferred
  265. },
  266. }
  267. result = _map_spoolman_spool(spool)
  268. assert result["color_name"] == "Galaxy Black"
  269. assert result["color_name_is_synthesized"] is False
  270. def test_color_name_empty_extra_falls_through_to_filament(self):
  271. """An explicit empty string in spool.extra.bambu_color_name (the
  272. "user cleared the field" shape) must NOT mask Spoolman's own
  273. filament.color_name if one exists — it falls through to the next
  274. layer instead of suppressing it."""
  275. spool = {
  276. **MINIMAL_SPOOL,
  277. "extra": {"bambu_color_name": '""'},
  278. "filament": {
  279. **MINIMAL_SPOOL["filament"],
  280. "color_name": "Sunset",
  281. },
  282. }
  283. result = _map_spoolman_spool(spool)
  284. assert result["color_name"] == "Sunset"
  285. assert result["color_name_is_synthesized"] is False
  286. def test_color_name_empty_extra_falls_through_to_synth(self):
  287. """When extra is cleared and filament has no color_name either,
  288. fall all the way through to the subtype synth — same UX as a fresh
  289. Spoolman install."""
  290. spool = {
  291. **MINIMAL_SPOOL,
  292. "extra": {"bambu_color_name": '""'},
  293. "filament": {
  294. **MINIMAL_SPOOL["filament"],
  295. "name": "PLA Basic Red",
  296. },
  297. }
  298. result = _map_spoolman_spool(spool)
  299. assert result["color_name"] == "Basic Red"
  300. assert result["color_name_is_synthesized"] is True
  301. def test_color_name_none_when_both_fields_empty(self):
  302. """If neither color_name nor a usable subtype exists, return None — UI
  303. falls back to its own 'Unknown color' string rather than showing a
  304. misleading material-only label.
  305. """
  306. spool = {
  307. **MINIMAL_SPOOL,
  308. "filament": {
  309. **MINIMAL_SPOOL["filament"],
  310. "name": "PLA", # name == material → subtype becomes None
  311. },
  312. }
  313. result = _map_spoolman_spool(spool)
  314. assert result["subtype"] is None
  315. assert result["color_name"] is None
  316. # No synth happened — nothing to fall back to.
  317. assert result["color_name_is_synthesized"] is False
  318. def test_color_hex_with_hash_prefix_stripped(self):
  319. spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "color_hex": "#00FF00"}}
  320. result = _map_spoolman_spool(spool)
  321. assert result["rgba"] == "00FF00FF"
  322. def test_color_hex_lowercase_normalised(self):
  323. spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "color_hex": "ff0000"}}
  324. result = _map_spoolman_spool(spool)
  325. assert result["rgba"] == "FF0000FF"
  326. def test_none_filament(self):
  327. spool = {**MINIMAL_SPOOL, "filament": None}
  328. result = _map_spoolman_spool(spool)
  329. assert result["material"] == ""
  330. assert result["rgba"] == "808080FF"
  331. assert result["label_weight"] == 1000
  332. def test_archived_spool_has_archived_at(self):
  333. spool = {**MINIMAL_SPOOL, "archived": True}
  334. result = _map_spoolman_spool(spool)
  335. assert result["archived_at"] is not None
  336. def test_subtype_strips_material_prefix(self):
  337. spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "material": "PLA", "name": "PLA Basic"}}
  338. result = _map_spoolman_spool(spool)
  339. assert result["subtype"] == "Basic"
  340. def test_brand_from_vendor(self):
  341. result = _map_spoolman_spool(MINIMAL_SPOOL)
  342. assert result["brand"] == "Bambu Lab"
  343. def test_no_vendor_brand_is_none(self):
  344. spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "vendor": None}}
  345. result = _map_spoolman_spool(spool)
  346. assert result["brand"] is None
  347. def test_spoolman_location_mapped_to_storage_location(self):
  348. spool = {**MINIMAL_SPOOL, "location": "Shelf A"}
  349. result = _map_spoolman_spool(spool)
  350. assert result["storage_location"] == "Shelf A"
  351. def test_no_location_gives_none_storage_location(self):
  352. result = _map_spoolman_spool(MINIMAL_SPOOL)
  353. assert result["storage_location"] is None
  354. def test_empty_location_gives_none_storage_location(self):
  355. spool = {**MINIMAL_SPOOL, "location": ""}
  356. result = _map_spoolman_spool(spool)
  357. assert result["storage_location"] is None
  358. def test_spoolman_location_key_not_in_result(self):
  359. spool = {**MINIMAL_SPOOL, "location": "Shelf A"}
  360. result = _map_spoolman_spool(spool)
  361. assert "spoolman_location" not in result
  362. def test_core_weight_from_filament_spool_weight(self):
  363. spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": 196}}
  364. result = _map_spoolman_spool(spool)
  365. assert result["core_weight"] == 196
  366. def test_core_weight_fallback_when_spool_weight_missing(self):
  367. result = _map_spoolman_spool(MINIMAL_SPOOL)
  368. assert result["core_weight"] == 250
  369. def test_core_weight_fallback_when_spool_weight_none(self):
  370. spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": None}}
  371. result = _map_spoolman_spool(spool)
  372. assert result["core_weight"] == 250
  373. def test_core_weight_float_truncated_to_int(self):
  374. spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": 180.9}}
  375. result = _map_spoolman_spool(spool)
  376. assert result["core_weight"] == 180
  377. def test_spool_level_spool_weight_takes_priority_over_filament(self):
  378. spool = {**MINIMAL_SPOOL, "spool_weight": 300, "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": 196}}
  379. assert _map_spoolman_spool(spool)["core_weight"] == 300
  380. def test_spool_level_zero_spool_weight_not_treated_as_missing(self):
  381. spool = {**MINIMAL_SPOOL, "spool_weight": 0, "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": 196}}
  382. assert _map_spoolman_spool(spool)["core_weight"] == 0
  383. def test_spool_level_none_falls_back_to_filament(self):
  384. spool = {**MINIMAL_SPOOL, "spool_weight": None, "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": 196}}
  385. assert _map_spoolman_spool(spool)["core_weight"] == 196
  386. @pytest.mark.parametrize(
  387. ("spool_level", "inherited"),
  388. [(180, False), (0, False), (None, True), ("absent", True)],
  389. )
  390. def test_core_weight_is_inherited_says_whose_tare_it_is(self, spool_level, inherited):
  391. """The spool form copies an own tare onto a copy and leaves an
  392. inherited one alone (#2908). 0 is an own tare, not a missing one."""
  393. spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": 196}}
  394. if spool_level != "absent":
  395. spool["spool_weight"] = spool_level
  396. assert _map_spoolman_spool(spool)["core_weight_is_inherited"] is inherited
  397. def test_spool_level_absent_falls_back_to_filament(self):
  398. spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": 196}}
  399. assert _map_spoolman_spool(spool)["core_weight"] == 196
  400. def test_both_levels_none_uses_fallback(self):
  401. spool = {**MINIMAL_SPOOL, "spool_weight": None, "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": None}}
  402. assert _map_spoolman_spool(spool)["core_weight"] == 250
  403. def test_core_weight_from_vendor_empty_spool_weight(self):
  404. """Spoolman's third tare level is the vendor's empty_spool_weight (#3195)."""
  405. spool = {
  406. **MINIMAL_SPOOL,
  407. "spool_weight": None,
  408. "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": None, "vendor": {"empty_spool_weight": 211.7}},
  409. }
  410. result = _map_spoolman_spool(spool)
  411. assert result["core_weight"] == 211
  412. # Still inherited: the spool has no tare of its own, so the form must not stamp it.
  413. assert result["core_weight_is_inherited"] is True
  414. class TestSpoolmanTare:
  415. """spoolman_tare resolves the tare the way Spoolman's /measure does (#3195)."""
  416. @staticmethod
  417. def _spool(spool_level=None, filament_level=None, vendor_level=None, vendor=True):
  418. filament: dict = {"spool_weight": filament_level}
  419. if vendor:
  420. filament["vendor"] = {"id": 5, "name": "Sunlu", "empty_spool_weight": vendor_level}
  421. return {"id": 1, "spool_weight": spool_level, "filament": filament}
  422. @pytest.mark.parametrize(
  423. ("levels", "expected"),
  424. [
  425. ((300.0, 180.0, 211.7), (300.0, "spool")),
  426. ((None, 180.0, 211.7), (180.0, "filament")),
  427. ((None, None, 211.7), (211.7, "vendor")),
  428. ((None, None, None), (250.0, "fallback")),
  429. ],
  430. )
  431. def test_resolution_order(self, levels, expected):
  432. assert spoolman_tare(self._spool(*levels)) == expected
  433. @pytest.mark.parametrize("level", ["spool", "filament", "vendor"])
  434. def test_zero_is_a_real_tare_at_every_level(self, level):
  435. levels = {"spool": (0, 180.0, 211.7), "filament": (None, 0, 211.7), "vendor": (None, None, 0)}[level]
  436. assert spoolman_tare(self._spool(*levels)) == (0.0, level)
  437. @pytest.mark.parametrize("bad", [float("nan"), float("inf"), "abc"])
  438. def test_non_finite_value_counts_as_missing(self, bad):
  439. assert spoolman_tare(self._spool(bad, None, 211.7)) == (211.7, "vendor")
  440. def test_missing_vendor_null_vendor_and_null_filament(self):
  441. assert spoolman_tare(self._spool(vendor=False)) == (250.0, "fallback")
  442. assert spoolman_tare({"id": 1, "filament": {"vendor": None}}) == (250.0, "fallback")
  443. assert spoolman_tare({"id": 1, "filament": None}) == (250.0, "fallback")
  444. assert spoolman_tare({"id": 1}) == (250.0, "fallback")
  445. # ---------------------------------------------------------------------------
  446. # F4: _safe_optional_float unit tests
  447. # ---------------------------------------------------------------------------
  448. class TestSafeOptionalFloat:
  449. """F4: Direct unit tests for _safe_optional_float (NaN/Inf safety)."""
  450. def test_normal_value(self):
  451. import pytest
  452. from backend.app.api.routes._spoolman_helpers import _safe_optional_float
  453. assert _safe_optional_float(9.99) == pytest.approx(9.99)
  454. def test_none_returns_none(self):
  455. from backend.app.api.routes._spoolman_helpers import _safe_optional_float
  456. assert _safe_optional_float(None) is None
  457. def test_nan_returns_none(self):
  458. import math
  459. from backend.app.api.routes._spoolman_helpers import _safe_optional_float
  460. assert _safe_optional_float(math.nan) is None
  461. def test_inf_returns_none(self):
  462. import math
  463. from backend.app.api.routes._spoolman_helpers import _safe_optional_float
  464. assert _safe_optional_float(math.inf) is None
  465. def test_neg_inf_returns_none(self):
  466. import math
  467. from backend.app.api.routes._spoolman_helpers import _safe_optional_float
  468. assert _safe_optional_float(-math.inf) is None
  469. def test_zero_returns_zero(self):
  470. from backend.app.api.routes._spoolman_helpers import _safe_optional_float
  471. assert _safe_optional_float(0.0) == 0.0
  472. def test_string_numeric(self):
  473. import pytest
  474. from backend.app.api.routes._spoolman_helpers import _safe_optional_float
  475. assert _safe_optional_float("3.14") == pytest.approx(3.14)
  476. def test_string_non_numeric_returns_none(self):
  477. from backend.app.api.routes._spoolman_helpers import _safe_optional_float
  478. assert _safe_optional_float("bad") is None
  479. class TestMapSpoolmanSpoolSlicerFilament:
  480. """slicer_filament round-trip via Spoolman extra dict.
  481. Spoolman has no native slicer_filament field, so we persist BambuStudio
  482. presets under bambu_slicer_filament[_name] keys in the spool's extra
  483. dict (JSON-encoded strings, like every Spoolman extra value). The map
  484. function unwraps those values and exposes them as slicer_filament /
  485. slicer_filament_name on the InventorySpool shape. Without this round-trip
  486. the user's selected slicer preset is silently dropped on save (#1114).
  487. """
  488. def test_slicer_filament_unwrapped_from_extra(self):
  489. spool = {
  490. **MINIMAL_SPOOL,
  491. "extra": {
  492. "bambu_slicer_filament": '"PFUSf543b298f8ea66"',
  493. "bambu_slicer_filament_name": '"Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle (Custom)"',
  494. },
  495. }
  496. result = _map_spoolman_spool(spool)
  497. assert result["slicer_filament"] == "PFUSf543b298f8ea66"
  498. assert result["slicer_filament_name"] == "Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle (Custom)"
  499. def test_slicer_filament_falls_back_to_filament_name(self):
  500. # Spool has no bambu_slicer_filament_name override → use Spoolman's filament.name
  501. spool = {**MINIMAL_SPOOL, "extra": {}}
  502. result = _map_spoolman_spool(spool)
  503. assert result["slicer_filament"] is None
  504. assert result["slicer_filament_name"] == "PLA Basic" # from filament.name
  505. def test_empty_string_extra_treated_as_unset(self):
  506. # JSON-encoded empty string is how the user clears the field
  507. spool = {
  508. **MINIMAL_SPOOL,
  509. "extra": {
  510. "bambu_slicer_filament": '""',
  511. "bambu_slicer_filament_name": '""',
  512. },
  513. }
  514. result = _map_spoolman_spool(spool)
  515. assert result["slicer_filament"] is None
  516. # Falls back to filament.name when the override is cleared
  517. assert result["slicer_filament_name"] == "PLA Basic"
  518. def test_non_json_extra_value_passed_through(self):
  519. # Tolerate bare-string values written without JSON encoding
  520. # (older data, manual writes via Spoolman UI, etc.)
  521. spool = {
  522. **MINIMAL_SPOOL,
  523. "extra": {"bambu_slicer_filament": "GFL05"},
  524. }
  525. result = _map_spoolman_spool(spool)
  526. assert result["slicer_filament"] == "GFL05"
  527. class TestExtractExtraStr:
  528. """JSON-encoded extra-string unwrapper used by _map_spoolman_spool."""
  529. def test_unwraps_quoted_string(self):
  530. from backend.app.api.routes._spoolman_helpers import _extract_extra_str
  531. assert _extract_extra_str({"k": '"hello"'}, "k") == "hello"
  532. def test_returns_empty_for_missing_key(self):
  533. from backend.app.api.routes._spoolman_helpers import _extract_extra_str
  534. assert _extract_extra_str({}, "k") == ""
  535. def test_returns_empty_for_non_string_value(self):
  536. from backend.app.api.routes._spoolman_helpers import _extract_extra_str
  537. # Spoolman extra values are stringified; numeric values shouldn't sneak in
  538. # but if they do we treat them as unset rather than crashing
  539. assert _extract_extra_str({"k": 42}, "k") == ""
  540. def test_returns_empty_for_json_null(self):
  541. from backend.app.api.routes._spoolman_helpers import _extract_extra_str
  542. # null isn't a string after decode → treat as unset
  543. assert _extract_extra_str({"k": "null"}, "k") == ""
  544. def test_passes_through_bare_string_on_decode_error(self):
  545. from backend.app.api.routes._spoolman_helpers import _extract_extra_str
  546. # Tolerate non-JSON-encoded values
  547. assert _extract_extra_str({"k": "GFL05"}, "k") == "GFL05"
  548. class TestMapSpoolmanSpoolPrice:
  549. """F4: NaN/Inf price in _map_spoolman_spool gives None cost_per_kg."""
  550. def test_nan_price_gives_none_cost_per_kg(self):
  551. import math
  552. from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
  553. spool = {**MINIMAL_SPOOL, "price": math.nan}
  554. assert _map_spoolman_spool(spool)["cost_per_kg"] is None
  555. def test_inf_price_gives_none_cost_per_kg(self):
  556. import math
  557. from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
  558. spool = {**MINIMAL_SPOOL, "price": math.inf}
  559. assert _map_spoolman_spool(spool)["cost_per_kg"] is None
  560. class TestSpoolmanNetWeight:
  561. """The spool's initial_weight wins over the filament's weight, as in Spoolman (#3194)."""
  562. @staticmethod
  563. def _spool(initial=None, filament_weight=1000.0):
  564. return {"id": 1, "initial_weight": initial, "filament": {"weight": filament_weight}}
  565. def test_initial_weight_beats_filament_weight(self):
  566. assert spoolman_net_weight(self._spool(250.0, 1000.0)) == 250.0
  567. def test_falls_back_to_filament_weight(self):
  568. assert spoolman_net_weight(self._spool(None, 1000.0)) == 1000.0
  569. def test_zero_initial_weight_counts_as_unset(self):
  570. """Spoolman's own /measure treats an initial_weight of 0 as missing."""
  571. assert spoolman_net_weight(self._spool(0, 750.0)) == 750.0
  572. @pytest.mark.parametrize("bad", [float("nan"), float("inf"), "abc", True])
  573. def test_invalid_initial_weight_counts_as_unset(self, bad):
  574. assert spoolman_net_weight(self._spool(bad, 750.0)) == 750.0
  575. def test_filament_weight_returned_as_stored(self):
  576. """A filament weight of 0 is left to the caller, which keeps its own handling."""
  577. assert spoolman_net_weight(self._spool(None, 0)) == 0.0
  578. @pytest.mark.parametrize("filament", [None, {}, "not a dict", {"weight": True}])
  579. def test_nothing_usable_returns_none(self, filament):
  580. assert spoolman_net_weight({"id": 1, "filament": filament}) is None
  581. class TestSpoolmanPriceConversion:
  582. """A Spoolman spool price is the price of the whole spool (#3194)."""
  583. def test_price_of_a_quarter_kilo_spool_as_a_rate(self):
  584. assert spoolman_price_to_cost_per_kg(6.25, 250) == pytest.approx(25.0)
  585. def test_rate_to_price_of_a_quarter_kilo_spool(self):
  586. assert cost_per_kg_to_spoolman_price(25.0, 250) == pytest.approx(6.25)
  587. def test_kilo_spool_price_equals_rate(self):
  588. assert spoolman_price_to_cost_per_kg(24.99, 1000) == 24.99
  589. assert cost_per_kg_to_spoolman_price(24.99, 1000) == 24.99
  590. @pytest.mark.parametrize("weight", [250, 333, 750, 1000, 2500])
  591. def test_round_trip_does_not_drift(self, weight):
  592. price = cost_per_kg_to_spoolman_price(19.99, weight)
  593. assert spoolman_price_to_cost_per_kg(price, weight) == pytest.approx(19.99, abs=1e-3)
  594. @pytest.mark.parametrize(("price", "weight"), [(None, 1000), (math.nan, 1000), ("abc", 1000), (10.0, 0)])
  595. def test_unusable_input_returns_none(self, price, weight):
  596. assert spoolman_price_to_cost_per_kg(price, weight) is None
  597. class TestMapperUsesSpoolInitialWeight:
  598. """#3194: a 250 g spool of a 1000 g filament maps as a 250 g spool."""
  599. SPOOL = {
  600. "id": 9,
  601. "initial_weight": 250.0,
  602. "remaining_weight": 200.0,
  603. "used_weight": 50.0,
  604. "price": 6.25,
  605. "filament": {"id": 1, "material": "PLA", "name": "PLA Basic", "weight": 1000.0},
  606. "extra": {},
  607. }
  608. def test_label_weight_is_the_spools(self):
  609. mapped = _map_spoolman_spool(self.SPOOL)
  610. assert mapped["label_weight"] == 250
  611. # remaining = label - weight_used must match Spoolman's 200 g
  612. assert mapped["label_weight"] - mapped["weight_used"] == pytest.approx(200.0)
  613. def test_cost_per_kg_is_the_spool_price_over_its_weight(self):
  614. assert _map_spoolman_spool(self.SPOOL)["cost_per_kg"] == pytest.approx(25.0)
  615. def test_without_initial_weight_the_filament_weight_is_used(self):
  616. spool = {**self.SPOOL, "initial_weight": None, "remaining_weight": 800.0, "used_weight": 200.0}
  617. mapped = _map_spoolman_spool(spool)
  618. assert mapped["label_weight"] == 1000
  619. assert mapped["cost_per_kg"] == pytest.approx(6.25)