test_variant_group_backfill_migration.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. """Tests for the variant-group backfill migration (#671 / #2570).
  2. `sliced_from_library_file_id` has been written into `library_files.file_metadata`
  3. by the Slice button and the pipeline runner since those features shipped, and
  4. nothing ever read it back. The migration promotes that inert provenance into
  5. real `file_variant_groups` membership so an existing library arrives with its
  6. slice sets already grouped.
  7. The interesting behaviour is all in what it refuses to group: a lone child, two
  8. children sliced for the same printer, files the user has already grouped by
  9. hand, and trashed rows.
  10. """
  11. from __future__ import annotations
  12. import json
  13. import pytest
  14. from sqlalchemy import text
  15. from sqlalchemy.ext.asyncio import create_async_engine
  16. from backend.app.core.database import run_migrations
  17. @pytest.fixture(autouse=True)
  18. def force_sqlite_dialect(monkeypatch):
  19. """Force the SQLite branch regardless of test env settings."""
  20. from backend.app.core import db_dialect
  21. monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
  22. monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
  23. from backend.app.core import database as database_module
  24. monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
  25. def _register_all_models():
  26. from backend.app.models import ( # noqa: F401
  27. ams_history,
  28. ams_label,
  29. api_key,
  30. archive,
  31. color_catalog,
  32. external_link,
  33. filament,
  34. group,
  35. kprofile_note,
  36. library,
  37. maintenance,
  38. notification,
  39. notification_template,
  40. print_log,
  41. print_queue,
  42. printer,
  43. project,
  44. project_bom,
  45. settings,
  46. slot_preset,
  47. smart_plug,
  48. smart_plug_energy_snapshot,
  49. spool,
  50. spool_assignment,
  51. spool_catalog,
  52. spool_k_profile,
  53. spool_usage_history,
  54. spoolbuddy_device,
  55. user,
  56. user_email_pref,
  57. virtual_printer,
  58. )
  59. @pytest.fixture
  60. async def engine():
  61. from backend.app.core.database import Base
  62. _register_all_models()
  63. eng = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  64. async with eng.begin() as conn:
  65. await conn.run_sync(Base.metadata.create_all)
  66. yield eng
  67. await eng.dispose()
  68. async def _insert_file(
  69. conn,
  70. *,
  71. file_id: int,
  72. filename: str,
  73. metadata: dict | None = None,
  74. deleted: bool = False,
  75. variant_group_id: int | None = None,
  76. ) -> None:
  77. """Insert a minimal LibraryFile row; only the columns the migration reads."""
  78. await conn.execute(
  79. text(
  80. "INSERT INTO library_files "
  81. "(id, filename, file_path, file_type, file_size, is_external, print_count, "
  82. " file_metadata, deleted_at, variant_group_id, variant_position) "
  83. "VALUES (:id, :filename, :path, 'gcode.3mf', 0, 0, 0, :meta, :deleted, :gid, 0)"
  84. ),
  85. {
  86. "id": file_id,
  87. "filename": filename,
  88. "path": f"/lib/{file_id}",
  89. "meta": json.dumps(metadata) if metadata is not None else None,
  90. "deleted": "2026-01-01 00:00:00" if deleted else None,
  91. "gid": variant_group_id,
  92. },
  93. )
  94. def _variant(source_id: int, model: str) -> dict:
  95. return {"sliced_from_library_file_id": source_id, "sliced_for_model": model}
  96. async def _members(conn) -> dict[int, tuple[int | None, int]]:
  97. rows = (
  98. await conn.execute(text("SELECT id, variant_group_id, variant_position FROM library_files ORDER BY id"))
  99. ).fetchall()
  100. return {r[0]: (r[1], r[2]) for r in rows}
  101. async def _group_count(conn) -> int:
  102. return (await conn.execute(text("SELECT COUNT(*) FROM file_variant_groups"))).scalar()
  103. @pytest.mark.asyncio
  104. async def test_groups_two_variants_of_the_same_source(engine):
  105. """The whole point: an H2S slice and an H2C slice of one model become a group."""
  106. async with engine.begin() as conn:
  107. await _insert_file(conn, file_id=1, filename="bracket.3mf")
  108. await _insert_file(conn, file_id=2, filename="bracket_h2s.gcode.3mf", metadata=_variant(1, "H2S"))
  109. await _insert_file(conn, file_id=3, filename="bracket_h2c.gcode.3mf", metadata=_variant(1, "H2C"))
  110. async with engine.begin() as conn:
  111. await run_migrations(conn)
  112. async with engine.connect() as conn:
  113. assert await _group_count(conn) == 1
  114. members = await _members(conn)
  115. gid = members[2][0]
  116. assert gid is not None
  117. assert members[3][0] == gid, "both slices land in the same group"
  118. assert members[1][0] is None, "the unsliced source is not a dispatch candidate"
  119. assert (members[2][1], members[3][1]) == (0, 1), "position follows id order, deterministically"
  120. name = (await conn.execute(text("SELECT name FROM file_variant_groups"))).scalar()
  121. assert name == "bracket.3mf", "the group is named after the source the user recognises"
  122. @pytest.mark.asyncio
  123. async def test_single_variant_produces_no_group(engine):
  124. """One candidate is not a choice — grouping it would add a row per sliced
  125. file in every library while changing nothing at print time."""
  126. async with engine.begin() as conn:
  127. await _insert_file(conn, file_id=1, filename="bracket.3mf")
  128. await _insert_file(conn, file_id=2, filename="bracket_h2s.gcode.3mf", metadata=_variant(1, "H2S"))
  129. async with engine.begin() as conn:
  130. await run_migrations(conn)
  131. async with engine.connect() as conn:
  132. assert await _group_count(conn) == 0
  133. assert (await _members(conn))[2][0] is None
  134. @pytest.mark.asyncio
  135. async def test_duplicate_model_is_skipped_whole(engine):
  136. """Two slices for the same printer are not alternatives — the resolver would
  137. have no basis to prefer one, so the source is left entirely alone."""
  138. async with engine.begin() as conn:
  139. await _insert_file(conn, file_id=1, filename="bracket.3mf")
  140. await _insert_file(conn, file_id=2, filename="bracket_draft.gcode.3mf", metadata=_variant(1, "H2S"))
  141. await _insert_file(conn, file_id=3, filename="bracket_fine.gcode.3mf", metadata=_variant(1, "H2S"))
  142. async with engine.begin() as conn:
  143. await run_migrations(conn)
  144. async with engine.connect() as conn:
  145. assert await _group_count(conn) == 0
  146. members = await _members(conn)
  147. assert members[2][0] is None and members[3][0] is None
  148. @pytest.mark.asyncio
  149. async def test_variant_without_model_is_not_a_candidate(engine):
  150. """A child with no `sliced_for_model` can never be matched to a printer, so
  151. it does not count towards the two-candidate threshold."""
  152. async with engine.begin() as conn:
  153. await _insert_file(conn, file_id=1, filename="bracket.3mf")
  154. await _insert_file(conn, file_id=2, filename="bracket_h2s.gcode.3mf", metadata=_variant(1, "H2S"))
  155. await _insert_file(
  156. conn,
  157. file_id=3,
  158. filename="bracket_unknown.gcode.3mf",
  159. metadata={"sliced_from_library_file_id": 1},
  160. )
  161. async with engine.begin() as conn:
  162. await run_migrations(conn)
  163. async with engine.connect() as conn:
  164. assert await _group_count(conn) == 0
  165. @pytest.mark.asyncio
  166. async def test_trashed_variants_are_excluded(engine):
  167. """A soft-deleted file is not printable, so it must not make up the second
  168. candidate that tips a source into being grouped."""
  169. async with engine.begin() as conn:
  170. await _insert_file(conn, file_id=1, filename="bracket.3mf")
  171. await _insert_file(conn, file_id=2, filename="bracket_h2s.gcode.3mf", metadata=_variant(1, "H2S"))
  172. await _insert_file(conn, file_id=3, filename="bracket_h2c.gcode.3mf", metadata=_variant(1, "H2C"), deleted=True)
  173. async with engine.begin() as conn:
  174. await run_migrations(conn)
  175. async with engine.connect() as conn:
  176. assert await _group_count(conn) == 0
  177. @pytest.mark.asyncio
  178. async def test_missing_source_still_groups_with_fallback_name(engine):
  179. """Deleting the source model does not make its slices any less usable
  180. together, so the group is still built — just named differently."""
  181. async with engine.begin() as conn:
  182. await _insert_file(conn, file_id=2, filename="bracket_h2s.gcode.3mf", metadata=_variant(99, "H2S"))
  183. await _insert_file(conn, file_id=3, filename="bracket_h2c.gcode.3mf", metadata=_variant(99, "H2C"))
  184. async with engine.begin() as conn:
  185. await run_migrations(conn)
  186. async with engine.connect() as conn:
  187. assert await _group_count(conn) == 1
  188. name = (await conn.execute(text("SELECT name FROM file_variant_groups"))).scalar()
  189. assert name == "H2S + 1 more"
  190. @pytest.mark.asyncio
  191. async def test_separate_sources_get_separate_groups(engine):
  192. async with engine.begin() as conn:
  193. await _insert_file(conn, file_id=1, filename="bracket.3mf")
  194. await _insert_file(conn, file_id=2, filename="bracket_h2s.gcode.3mf", metadata=_variant(1, "H2S"))
  195. await _insert_file(conn, file_id=3, filename="bracket_h2c.gcode.3mf", metadata=_variant(1, "H2C"))
  196. await _insert_file(conn, file_id=4, filename="clip.3mf")
  197. await _insert_file(conn, file_id=5, filename="clip_h2s.gcode.3mf", metadata=_variant(4, "H2S"))
  198. await _insert_file(conn, file_id=6, filename="clip_h2c.gcode.3mf", metadata=_variant(4, "H2C"))
  199. async with engine.begin() as conn:
  200. await run_migrations(conn)
  201. async with engine.connect() as conn:
  202. assert await _group_count(conn) == 2
  203. members = await _members(conn)
  204. assert members[2][0] == members[3][0]
  205. assert members[5][0] == members[6][0]
  206. assert members[2][0] != members[5][0]
  207. @pytest.mark.asyncio
  208. async def test_backfill_is_idempotent(engine):
  209. """Every boot re-runs the migration set; the second pass must not clone the
  210. group or renumber its members."""
  211. async with engine.begin() as conn:
  212. await _insert_file(conn, file_id=1, filename="bracket.3mf")
  213. await _insert_file(conn, file_id=2, filename="bracket_h2s.gcode.3mf", metadata=_variant(1, "H2S"))
  214. await _insert_file(conn, file_id=3, filename="bracket_h2c.gcode.3mf", metadata=_variant(1, "H2C"))
  215. async with engine.begin() as conn:
  216. await run_migrations(conn)
  217. async with engine.connect() as conn:
  218. first = await _members(conn)
  219. async with engine.begin() as conn:
  220. await run_migrations(conn)
  221. async with engine.connect() as conn:
  222. assert await _group_count(conn) == 1
  223. assert await _members(conn) == first
  224. @pytest.mark.asyncio
  225. async def test_hand_grouped_files_are_left_alone(engine):
  226. """A user who has already grouped (or deliberately ungrouped) files owns that
  227. decision — the backfill only ever considers files with no group yet."""
  228. async with engine.begin() as conn:
  229. await conn.execute(text("INSERT INTO file_variant_groups (id, name) VALUES (7, 'my own grouping')"))
  230. await _insert_file(conn, file_id=1, filename="bracket.3mf")
  231. await _insert_file(
  232. conn, file_id=2, filename="bracket_h2s.gcode.3mf", metadata=_variant(1, "H2S"), variant_group_id=7
  233. )
  234. await _insert_file(conn, file_id=3, filename="bracket_h2c.gcode.3mf", metadata=_variant(1, "H2C"))
  235. async with engine.begin() as conn:
  236. await run_migrations(conn)
  237. async with engine.connect() as conn:
  238. assert await _group_count(conn) == 1, "no second group is invented"
  239. members = await _members(conn)
  240. assert members[2][0] == 7, "the user's grouping survives"
  241. assert members[3][0] is None, "and the leftover sibling is not force-joined to it"