test_slice_request_schema.py 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  1. """Tests for `SliceRequest` validator — covers both the legacy bare-int
  2. shape and the new source-aware shape, plus the backwards-compat
  3. normalisation that lets the route handler ignore the difference.
  4. """
  5. import pytest
  6. from pydantic import ValidationError
  7. from backend.app.schemas.slicer import PresetRef, SliceBundleSpec, SliceRequest
  8. class TestLegacyBareIntegerShape:
  9. """Existing clients (and stale browser tabs after upgrade) keep
  10. sending bare integer ids. They must continue working unchanged."""
  11. def test_bare_int_ids_normalise_to_local_preset_ref(self):
  12. req = SliceRequest(printer_preset_id=1, process_preset_id=2, filament_preset_id=3)
  13. assert req.printer_preset == PresetRef(source="local", id="1")
  14. assert req.process_preset == PresetRef(source="local", id="2")
  15. assert req.filament_preset == PresetRef(source="local", id="3")
  16. def test_legacy_ids_unchanged_in_payload(self):
  17. """The legacy fields stay populated — no behaviour change for
  18. clients that read them back from the model."""
  19. req = SliceRequest(printer_preset_id=10, process_preset_id=20, filament_preset_id=30)
  20. assert req.printer_preset_id == 10
  21. assert req.process_preset_id == 20
  22. assert req.filament_preset_id == 30
  23. class TestNewSourceAwareShape:
  24. """The new modal sends source-aware refs explicitly."""
  25. def test_cloud_refs_pass_through(self):
  26. req = SliceRequest(
  27. printer_preset=PresetRef(source="cloud", id="PFUprinter"),
  28. process_preset=PresetRef(source="cloud", id="PFUprocess"),
  29. filament_preset=PresetRef(source="cloud", id="PFUfilament"),
  30. )
  31. assert req.printer_preset.source == "cloud"
  32. assert req.printer_preset.id == "PFUprinter"
  33. def test_mixed_sources_per_slot(self):
  34. """A user may pick cloud for printer, local for process, standard
  35. for filament — the modal is per-slot."""
  36. req = SliceRequest(
  37. printer_preset=PresetRef(source="cloud", id="PFU123"),
  38. process_preset=PresetRef(source="local", id="42"),
  39. filament_preset=PresetRef(source="standard", id="Bambu PLA Basic"),
  40. )
  41. assert req.printer_preset.source == "cloud"
  42. assert req.process_preset.source == "local"
  43. assert req.filament_preset.source == "standard"
  44. class TestValidationErrors:
  45. def test_missing_printer_slot_raises(self):
  46. with pytest.raises(ValidationError) as exc:
  47. SliceRequest(process_preset_id=2, filament_preset_id=3)
  48. assert "printer" in str(exc.value)
  49. def test_invalid_source_rejected(self):
  50. with pytest.raises(ValidationError):
  51. SliceRequest(
  52. printer_preset={"source": "made_up", "id": "x"},
  53. process_preset_id=2,
  54. filament_preset_id=3,
  55. )
  56. class TestPriorityWhenBothSet:
  57. """If a client sends BOTH the legacy id AND the new ref for the same
  58. slot (unlikely in practice, but ambiguous), the new ref wins. Tests
  59. pin the resolution order so a future schema change can't silently
  60. flip it."""
  61. def test_explicit_ref_wins_over_legacy_id(self):
  62. req = SliceRequest(
  63. printer_preset_id=999, # would resolve to local:999
  64. printer_preset=PresetRef(source="cloud", id="PFU"),
  65. process_preset_id=2,
  66. filament_preset_id=3,
  67. )
  68. # Validator only fills the ref when it's None — the explicit cloud
  69. # ref stays untouched.
  70. assert req.printer_preset == PresetRef(source="cloud", id="PFU")
  71. class TestFilamentPresetsList:
  72. """Multi-color: the new array shape carries one filament profile per
  73. plate slot in plate order. Backwards-compat: legacy clients still
  74. submit a singular `filament_preset` and the validator promotes it into
  75. a one-element list so the route handler only deals with one shape."""
  76. def test_explicit_list_passes_through(self):
  77. refs = [
  78. PresetRef(source="cloud", id="A"),
  79. PresetRef(source="local", id="2"),
  80. PresetRef(source="standard", id="Bambu PLA Basic"),
  81. ]
  82. req = SliceRequest(
  83. printer_preset_id=1,
  84. process_preset_id=2,
  85. filament_preset_id=99, # explicit legacy id — should be ignored
  86. filament_presets=refs,
  87. )
  88. assert req.filament_presets == refs
  89. # Precedence pin: when caller sends both shapes, the array wins and
  90. # the singular gets backfilled from the array's first entry — NOT
  91. # from the legacy id 99. Documents the migration ordering for a
  92. # future change that might quietly mix them.
  93. assert req.filament_preset == refs[0]
  94. def test_empty_list_is_backfilled_from_singular(self):
  95. req = SliceRequest(printer_preset_id=1, process_preset_id=2, filament_preset_id=3)
  96. # Legacy single-color path: validator promotes the singular into a
  97. # one-element list so route handlers can iterate uniformly.
  98. assert req.filament_presets == [PresetRef(source="local", id="3")]
  99. def test_explicit_empty_list_with_singular_set_uses_singular(self):
  100. # User of the new schema can leave `filament_presets` as the empty
  101. # default and rely on the legacy `filament_preset_id` — same path
  102. # as `test_empty_list_is_backfilled_from_singular`.
  103. req = SliceRequest(
  104. printer_preset_id=1,
  105. process_preset_id=2,
  106. filament_preset=PresetRef(source="cloud", id="PFU"),
  107. filament_presets=[],
  108. )
  109. assert req.filament_presets == [PresetRef(source="cloud", id="PFU")]
  110. def test_list_preserves_order(self):
  111. refs = [
  112. PresetRef(source="cloud", id="slot1"),
  113. PresetRef(source="cloud", id="slot2"),
  114. PresetRef(source="cloud", id="slot3"),
  115. ]
  116. req = SliceRequest(
  117. printer_preset_id=1,
  118. process_preset_id=2,
  119. filament_preset_id=3,
  120. filament_presets=refs,
  121. )
  122. assert [r.id for r in req.filament_presets] == ["slot1", "slot2", "slot3"]
  123. class TestBundleDispatchShape:
  124. """When SliceRequest.bundle is set, the dispatcher picks the JSON
  125. triplet from a sidecar-side bundle by name and PresetRef resolution
  126. is skipped entirely. Validator must accept "bundle alone" without
  127. flagging missing presets."""
  128. def test_bundle_alone_validates(self):
  129. req = SliceRequest(
  130. bundle=SliceBundleSpec(
  131. bundle_id="abc123def456abcd",
  132. printer_name="# Bambu Lab H2D 0.4 nozzle",
  133. process_name="# 0.20mm Standard @BBL H2D",
  134. filament_names=["# Bambu PLA Basic @BBL H2D"],
  135. ),
  136. )
  137. # PresetRef fields are absent; that's fine in bundle mode.
  138. assert req.bundle is not None
  139. assert req.printer_preset is None
  140. assert req.process_preset is None
  141. assert req.filament_presets == []
  142. def test_bundle_with_filament_list_preserves_order(self):
  143. req = SliceRequest(
  144. bundle=SliceBundleSpec(
  145. bundle_id="abc",
  146. printer_name="P",
  147. process_name="Q",
  148. filament_names=["red", "blue", "green"],
  149. ),
  150. )
  151. assert req.bundle.filament_names == ["red", "blue", "green"]
  152. def test_bundle_rejects_empty_filament_list(self):
  153. with pytest.raises(ValidationError):
  154. SliceBundleSpec(
  155. bundle_id="abc",
  156. printer_name="P",
  157. process_name="Q",
  158. filament_names=[],
  159. )
  160. def test_bundle_rejects_empty_id(self):
  161. with pytest.raises(ValidationError):
  162. SliceBundleSpec(
  163. bundle_id="",
  164. printer_name="P",
  165. process_name="Q",
  166. filament_names=["F"],
  167. )
  168. def test_no_bundle_no_presets_still_rejected(self):
  169. # Dropping the bundle escape-hatch must not bypass the existing
  170. # presets-required check.
  171. with pytest.raises(ValidationError):
  172. SliceRequest()
  173. def test_bundle_with_presets_keeps_both_fields(self):
  174. # Sending both is allowed (validator accepts the bundle and skips
  175. # preset normalisation) — the dispatch picks bundle on the route
  176. # side. Confirms the validator doesn't reject overlapping intent
  177. # so a future client that wants to record the legacy presets
  178. # alongside doesn't fail validation.
  179. req = SliceRequest(
  180. printer_preset=PresetRef(source="standard", id="X1C"),
  181. process_preset=PresetRef(source="standard", id="0.20"),
  182. filament_presets=[PresetRef(source="standard", id="PLA")],
  183. bundle=SliceBundleSpec(
  184. bundle_id="abc",
  185. printer_name="P",
  186. process_name="Q",
  187. filament_names=["F"],
  188. ),
  189. )
  190. assert req.bundle is not None
  191. # Presets stay populated; dispatch ignores them when bundle is set.
  192. assert req.printer_preset is not None