test_spool_filament_preset_cascade.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. """The per-printer-model preset cascade.
  2. ``Spool.slicer_filament`` holds one preset and is printer-agnostic by design.
  3. That breaks as soon as a spool is used on two printer models, because a cloud
  4. or Orca preset is bound to a model (``@BBL X1C``): the AMS slot on an H2C gets
  5. configured with a preset that machine has no profile for.
  6. ``services.spool_filament_preset`` resolves, most specific first:
  7. (model, diameter) -> (model, "") -> the spool's own slicer_filament
  8. These pin the resolution order itself, including the two cases that are easy
  9. to get backwards: a spool with no overrides must behave exactly as it did
  10. before the feature existed, and a stored row whose preset is blank is the
  11. user clearing the preset for that model, not a hole to fall through.
  12. """
  13. from __future__ import annotations
  14. import pytest
  15. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  16. from backend.app.models.spool import Spool
  17. from backend.app.models.spool_filament_preset import SpoolFilamentPreset, SpoolmanFilamentPreset
  18. from backend.app.services.spool_filament_preset import resolve_spool_preset, resolve_spoolman_preset
  19. pytestmark = pytest.mark.asyncio
  20. DEFAULT = ("GFSA00", "Bambu PLA Basic @BBL X1C")
  21. async def _spool(engine) -> tuple[async_sessionmaker, int]:
  22. maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
  23. async with maker() as db:
  24. spool = Spool(
  25. brand="Bambu",
  26. material="PLA",
  27. color_name="Charcoal",
  28. slicer_filament=DEFAULT[0],
  29. slicer_filament_name=DEFAULT[1],
  30. )
  31. db.add(spool)
  32. await db.commit()
  33. await db.refresh(spool)
  34. return maker, spool.id
  35. async def _resolve(maker, spool_id, model, diameter):
  36. async with maker() as db:
  37. return await resolve_spool_preset(
  38. db,
  39. spool_id=spool_id,
  40. printer_model=model,
  41. nozzle_diameter=diameter,
  42. fallback_filament=DEFAULT[0],
  43. fallback_name=DEFAULT[1],
  44. )
  45. async def _add(maker, spool_id, model, diameter, code, name):
  46. async with maker() as db:
  47. db.add(
  48. SpoolFilamentPreset(
  49. spool_id=spool_id,
  50. printer_model=model,
  51. nozzle_diameter=diameter,
  52. slicer_filament=code,
  53. slicer_filament_name=name,
  54. )
  55. )
  56. await db.commit()
  57. class TestNoOverrides:
  58. """Every spool in every existing install is this case."""
  59. async def test_falls_back_to_the_spools_own_preset(self, test_engine):
  60. maker, spool_id = await _spool(test_engine)
  61. assert await _resolve(maker, spool_id, "H2C", "0.4") == DEFAULT
  62. async def test_an_unknown_model_falls_back(self, test_engine):
  63. maker, spool_id = await _spool(test_engine)
  64. await _add(maker, spool_id, "X1C", "", "GFSA01", "PLA @X1C")
  65. assert await _resolve(maker, spool_id, "P1S", "0.4") == DEFAULT
  66. async def test_no_model_at_all_falls_back(self, test_engine):
  67. """A printer that has not reported its model yet cannot be more
  68. specific than the spool itself -- it must not match some other row."""
  69. maker, spool_id = await _spool(test_engine)
  70. await _add(maker, spool_id, "X1C", "", "GFSA01", "PLA @X1C")
  71. assert await _resolve(maker, spool_id, None, "0.4") == DEFAULT
  72. assert await _resolve(maker, spool_id, "", "0.4") == DEFAULT
  73. class TestModelDefault:
  74. """Diameter "" = any nozzle of the model.
  75. The spool form writes one row per nozzle size and never this one, but the
  76. API accepts it and it has to keep resolving -- these pin that level of the
  77. cascade so a client using it does not break silently.
  78. """
  79. async def test_model_row_wins_over_the_spool_default(self, test_engine):
  80. maker, spool_id = await _spool(test_engine)
  81. await _add(maker, spool_id, "H2C", "", "GFSA09", "Bambu PLA Basic @BBL H2C")
  82. assert await _resolve(maker, spool_id, "H2C", "0.4") == ("GFSA09", "Bambu PLA Basic @BBL H2C")
  83. async def test_it_applies_to_every_nozzle_of_that_model(self, test_engine):
  84. maker, spool_id = await _spool(test_engine)
  85. await _add(maker, spool_id, "H2C", "", "GFSA09", "Bambu PLA Basic @BBL H2C")
  86. for diameter in ("0.2", "0.4", "0.6", "0.8", ""):
  87. assert (await _resolve(maker, spool_id, "H2C", diameter))[0] == "GFSA09", diameter
  88. async def test_other_models_are_untouched(self, test_engine):
  89. maker, spool_id = await _spool(test_engine)
  90. await _add(maker, spool_id, "H2C", "", "GFSA09", "Bambu PLA Basic @BBL H2C")
  91. await _add(maker, spool_id, "A1 mini", "", "GFSA20", "Bambu PLA Basic @BBL A1M")
  92. assert (await _resolve(maker, spool_id, "H2C", "0.4"))[0] == "GFSA09"
  93. assert (await _resolve(maker, spool_id, "A1 mini", "0.4"))[0] == "GFSA20"
  94. assert (await _resolve(maker, spool_id, "X1C", "0.4"))[0] == DEFAULT[0]
  95. class TestPerDiameterException:
  96. """Why diameter is in the key at all: the preset lands on an AMS slot and
  97. a slot feeds exactly one nozzle, so a machine with two diameters fitted
  98. needs two answers for one model."""
  99. async def test_exact_diameter_beats_the_model_default(self, test_engine):
  100. maker, spool_id = await _spool(test_engine)
  101. await _add(maker, spool_id, "A1 mini", "", "GFSA20", "Bambu PLA Basic @BBL A1M")
  102. await _add(maker, spool_id, "A1 mini", "0.2", "GFSA21", "Bambu PLA Basic @BBL A1M 0.2 nozzle")
  103. assert (await _resolve(maker, spool_id, "A1 mini", "0.2"))[0] == "GFSA21"
  104. assert (await _resolve(maker, spool_id, "A1 mini", "0.4"))[0] == "GFSA20"
  105. async def test_a_diameter_row_alone_still_leaves_other_nozzles_on_the_default(self, test_engine):
  106. maker, spool_id = await _spool(test_engine)
  107. await _add(maker, spool_id, "A1 mini", "0.2", "GFSA21", "Bambu PLA Basic @BBL A1M 0.2 nozzle")
  108. assert (await _resolve(maker, spool_id, "A1 mini", "0.2"))[0] == "GFSA21"
  109. assert (await _resolve(maker, spool_id, "A1 mini", "0.4"))[0] == DEFAULT[0]
  110. async def test_the_two_hotends_of_one_machine_resolve_differently(self, test_engine):
  111. """The case that put diameter in the key: 0.4 on one hotend, 0.2 on
  112. the other, one model, one spool."""
  113. maker, spool_id = await _spool(test_engine)
  114. await _add(maker, spool_id, "H2C", "0.4", "GFSA09", "Bambu PLA Basic @BBL H2C")
  115. await _add(maker, spool_id, "H2C", "0.2", "GFSA10", "Bambu PLA Basic @BBL H2C 0.2 nozzle")
  116. assert (await _resolve(maker, spool_id, "H2C", "0.4"))[0] == "GFSA09"
  117. assert (await _resolve(maker, spool_id, "H2C", "0.2"))[0] == "GFSA10"
  118. class TestClearedPreset:
  119. async def test_a_blank_row_means_none_not_fall_through(self, test_engine):
  120. """The user picking the empty entry for a model is a decision. Falling
  121. back to the spool's preset would silently reinstate what they cleared,
  122. and it is the spool's preset that is wrong on that model."""
  123. maker, spool_id = await _spool(test_engine)
  124. await _add(maker, spool_id, "H2C", "", None, None)
  125. assert await _resolve(maker, spool_id, "H2C", "0.4") == (None, None)
  126. class TestSpoolmanFlavour:
  127. """Spoolman spools live in Spoolman; the override is Bambuddy's, keyed by
  128. the remote id. Same cascade -- the two inventory modes must not drift."""
  129. async def test_same_cascade(self, test_engine):
  130. maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  131. async with maker() as db:
  132. db.add(
  133. SpoolmanFilamentPreset(
  134. spoolman_spool_id=7,
  135. printer_model="H2C",
  136. nozzle_diameter="",
  137. slicer_filament="GFSA09",
  138. slicer_filament_name="Bambu PLA Basic @BBL H2C",
  139. )
  140. )
  141. db.add(
  142. SpoolmanFilamentPreset(
  143. spoolman_spool_id=7,
  144. printer_model="H2C",
  145. nozzle_diameter="0.2",
  146. slicer_filament="GFSA10",
  147. slicer_filament_name="Bambu PLA Basic @BBL H2C 0.2 nozzle",
  148. )
  149. )
  150. await db.commit()
  151. async def resolve(model, diameter, spool_id=7):
  152. async with maker() as db:
  153. return await resolve_spoolman_preset(
  154. db,
  155. spoolman_spool_id=spool_id,
  156. printer_model=model,
  157. nozzle_diameter=diameter,
  158. fallback_filament=DEFAULT[0],
  159. fallback_name=DEFAULT[1],
  160. )
  161. assert (await resolve("H2C", "0.4"))[0] == "GFSA09"
  162. assert (await resolve("H2C", "0.2"))[0] == "GFSA10"
  163. assert (await resolve("X1C", "0.4"))[0] == DEFAULT[0]
  164. # Another spool's rows must not leak into this one.
  165. assert (await resolve("H2C", "0.4", spool_id=8))[0] == DEFAULT[0]