mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
Adds file_variant_groups plus variant_group_id / variant_position on library_files, so a set of files that are the same job sliced for different printers can be resolved to whichever printer frees up first. Backfills groups from the sliced_from_library_file_id provenance that slice_and_persist and the pipeline runner have been writing into file_metadata since they shipped, and which nothing has ever read. Only sources with two or more children carrying distinct sliced_for_model values are grouped: a single candidate is not a choice, and two slices for the same printer give the resolver no basis to prefer one.
303 lines
11 KiB
Python
303 lines
11 KiB
Python
"""Tests for the variant-group backfill migration (#671 / #2570).
|
|
|
|
`sliced_from_library_file_id` has been written into `library_files.file_metadata`
|
|
by the Slice button and the pipeline runner since those features shipped, and
|
|
nothing ever read it back. The migration promotes that inert provenance into
|
|
real `file_variant_groups` membership so an existing library arrives with its
|
|
slice sets already grouped.
|
|
|
|
The interesting behaviour is all in what it refuses to group: a lone child, two
|
|
children sliced for the same printer, files the user has already grouped by
|
|
hand, and trashed rows.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pytest
|
|
from sqlalchemy import text
|
|
from sqlalchemy.ext.asyncio import create_async_engine
|
|
|
|
from backend.app.core.database import run_migrations
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def force_sqlite_dialect(monkeypatch):
|
|
"""Force the SQLite branch regardless of test env settings."""
|
|
from backend.app.core import db_dialect
|
|
|
|
monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
|
|
monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
|
|
from backend.app.core import database as database_module
|
|
|
|
monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
|
|
|
|
|
|
def _register_all_models():
|
|
from backend.app.models import ( # noqa: F401
|
|
ams_history,
|
|
ams_label,
|
|
api_key,
|
|
archive,
|
|
color_catalog,
|
|
external_link,
|
|
filament,
|
|
group,
|
|
kprofile_note,
|
|
library,
|
|
maintenance,
|
|
notification,
|
|
notification_template,
|
|
print_log,
|
|
print_queue,
|
|
printer,
|
|
project,
|
|
project_bom,
|
|
settings,
|
|
slot_preset,
|
|
smart_plug,
|
|
smart_plug_energy_snapshot,
|
|
spool,
|
|
spool_assignment,
|
|
spool_catalog,
|
|
spool_k_profile,
|
|
spool_usage_history,
|
|
spoolbuddy_device,
|
|
user,
|
|
user_email_pref,
|
|
virtual_printer,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
async def engine():
|
|
from backend.app.core.database import Base
|
|
|
|
_register_all_models()
|
|
|
|
eng = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
|
|
async with eng.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
yield eng
|
|
await eng.dispose()
|
|
|
|
|
|
async def _insert_file(
|
|
conn,
|
|
*,
|
|
file_id: int,
|
|
filename: str,
|
|
metadata: dict | None = None,
|
|
deleted: bool = False,
|
|
variant_group_id: int | None = None,
|
|
) -> None:
|
|
"""Insert a minimal LibraryFile row; only the columns the migration reads."""
|
|
await conn.execute(
|
|
text(
|
|
"INSERT INTO library_files "
|
|
"(id, filename, file_path, file_type, file_size, is_external, print_count, "
|
|
" file_metadata, deleted_at, variant_group_id, variant_position) "
|
|
"VALUES (:id, :filename, :path, 'gcode.3mf', 0, 0, 0, :meta, :deleted, :gid, 0)"
|
|
),
|
|
{
|
|
"id": file_id,
|
|
"filename": filename,
|
|
"path": f"/lib/{file_id}",
|
|
"meta": json.dumps(metadata) if metadata is not None else None,
|
|
"deleted": "2026-01-01 00:00:00" if deleted else None,
|
|
"gid": variant_group_id,
|
|
},
|
|
)
|
|
|
|
|
|
def _variant(source_id: int, model: str) -> dict:
|
|
return {"sliced_from_library_file_id": source_id, "sliced_for_model": model}
|
|
|
|
|
|
async def _members(conn) -> dict[int, tuple[int | None, int]]:
|
|
rows = (
|
|
await conn.execute(text("SELECT id, variant_group_id, variant_position FROM library_files ORDER BY id"))
|
|
).fetchall()
|
|
return {r[0]: (r[1], r[2]) for r in rows}
|
|
|
|
|
|
async def _group_count(conn) -> int:
|
|
return (await conn.execute(text("SELECT COUNT(*) FROM file_variant_groups"))).scalar()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_groups_two_variants_of_the_same_source(engine):
|
|
"""The whole point: an H2S slice and an H2C slice of one model become a group."""
|
|
async with engine.begin() as conn:
|
|
await _insert_file(conn, file_id=1, filename="bracket.3mf")
|
|
await _insert_file(conn, file_id=2, filename="bracket_h2s.gcode.3mf", metadata=_variant(1, "H2S"))
|
|
await _insert_file(conn, file_id=3, filename="bracket_h2c.gcode.3mf", metadata=_variant(1, "H2C"))
|
|
|
|
async with engine.begin() as conn:
|
|
await run_migrations(conn)
|
|
|
|
async with engine.connect() as conn:
|
|
assert await _group_count(conn) == 1
|
|
members = await _members(conn)
|
|
gid = members[2][0]
|
|
assert gid is not None
|
|
assert members[3][0] == gid, "both slices land in the same group"
|
|
assert members[1][0] is None, "the unsliced source is not a dispatch candidate"
|
|
assert (members[2][1], members[3][1]) == (0, 1), "position follows id order, deterministically"
|
|
|
|
name = (await conn.execute(text("SELECT name FROM file_variant_groups"))).scalar()
|
|
assert name == "bracket.3mf", "the group is named after the source the user recognises"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_single_variant_produces_no_group(engine):
|
|
"""One candidate is not a choice — grouping it would add a row per sliced
|
|
file in every library while changing nothing at print time."""
|
|
async with engine.begin() as conn:
|
|
await _insert_file(conn, file_id=1, filename="bracket.3mf")
|
|
await _insert_file(conn, file_id=2, filename="bracket_h2s.gcode.3mf", metadata=_variant(1, "H2S"))
|
|
|
|
async with engine.begin() as conn:
|
|
await run_migrations(conn)
|
|
|
|
async with engine.connect() as conn:
|
|
assert await _group_count(conn) == 0
|
|
assert (await _members(conn))[2][0] is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_duplicate_model_is_skipped_whole(engine):
|
|
"""Two slices for the same printer are not alternatives — the resolver would
|
|
have no basis to prefer one, so the source is left entirely alone."""
|
|
async with engine.begin() as conn:
|
|
await _insert_file(conn, file_id=1, filename="bracket.3mf")
|
|
await _insert_file(conn, file_id=2, filename="bracket_draft.gcode.3mf", metadata=_variant(1, "H2S"))
|
|
await _insert_file(conn, file_id=3, filename="bracket_fine.gcode.3mf", metadata=_variant(1, "H2S"))
|
|
|
|
async with engine.begin() as conn:
|
|
await run_migrations(conn)
|
|
|
|
async with engine.connect() as conn:
|
|
assert await _group_count(conn) == 0
|
|
members = await _members(conn)
|
|
assert members[2][0] is None and members[3][0] is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_variant_without_model_is_not_a_candidate(engine):
|
|
"""A child with no `sliced_for_model` can never be matched to a printer, so
|
|
it does not count towards the two-candidate threshold."""
|
|
async with engine.begin() as conn:
|
|
await _insert_file(conn, file_id=1, filename="bracket.3mf")
|
|
await _insert_file(conn, file_id=2, filename="bracket_h2s.gcode.3mf", metadata=_variant(1, "H2S"))
|
|
await _insert_file(
|
|
conn,
|
|
file_id=3,
|
|
filename="bracket_unknown.gcode.3mf",
|
|
metadata={"sliced_from_library_file_id": 1},
|
|
)
|
|
|
|
async with engine.begin() as conn:
|
|
await run_migrations(conn)
|
|
|
|
async with engine.connect() as conn:
|
|
assert await _group_count(conn) == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trashed_variants_are_excluded(engine):
|
|
"""A soft-deleted file is not printable, so it must not make up the second
|
|
candidate that tips a source into being grouped."""
|
|
async with engine.begin() as conn:
|
|
await _insert_file(conn, file_id=1, filename="bracket.3mf")
|
|
await _insert_file(conn, file_id=2, filename="bracket_h2s.gcode.3mf", metadata=_variant(1, "H2S"))
|
|
await _insert_file(conn, file_id=3, filename="bracket_h2c.gcode.3mf", metadata=_variant(1, "H2C"), deleted=True)
|
|
|
|
async with engine.begin() as conn:
|
|
await run_migrations(conn)
|
|
|
|
async with engine.connect() as conn:
|
|
assert await _group_count(conn) == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_source_still_groups_with_fallback_name(engine):
|
|
"""Deleting the source model does not make its slices any less usable
|
|
together, so the group is still built — just named differently."""
|
|
async with engine.begin() as conn:
|
|
await _insert_file(conn, file_id=2, filename="bracket_h2s.gcode.3mf", metadata=_variant(99, "H2S"))
|
|
await _insert_file(conn, file_id=3, filename="bracket_h2c.gcode.3mf", metadata=_variant(99, "H2C"))
|
|
|
|
async with engine.begin() as conn:
|
|
await run_migrations(conn)
|
|
|
|
async with engine.connect() as conn:
|
|
assert await _group_count(conn) == 1
|
|
name = (await conn.execute(text("SELECT name FROM file_variant_groups"))).scalar()
|
|
assert name == "H2S + 1 more"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_separate_sources_get_separate_groups(engine):
|
|
async with engine.begin() as conn:
|
|
await _insert_file(conn, file_id=1, filename="bracket.3mf")
|
|
await _insert_file(conn, file_id=2, filename="bracket_h2s.gcode.3mf", metadata=_variant(1, "H2S"))
|
|
await _insert_file(conn, file_id=3, filename="bracket_h2c.gcode.3mf", metadata=_variant(1, "H2C"))
|
|
await _insert_file(conn, file_id=4, filename="clip.3mf")
|
|
await _insert_file(conn, file_id=5, filename="clip_h2s.gcode.3mf", metadata=_variant(4, "H2S"))
|
|
await _insert_file(conn, file_id=6, filename="clip_h2c.gcode.3mf", metadata=_variant(4, "H2C"))
|
|
|
|
async with engine.begin() as conn:
|
|
await run_migrations(conn)
|
|
|
|
async with engine.connect() as conn:
|
|
assert await _group_count(conn) == 2
|
|
members = await _members(conn)
|
|
assert members[2][0] == members[3][0]
|
|
assert members[5][0] == members[6][0]
|
|
assert members[2][0] != members[5][0]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_backfill_is_idempotent(engine):
|
|
"""Every boot re-runs the migration set; the second pass must not clone the
|
|
group or renumber its members."""
|
|
async with engine.begin() as conn:
|
|
await _insert_file(conn, file_id=1, filename="bracket.3mf")
|
|
await _insert_file(conn, file_id=2, filename="bracket_h2s.gcode.3mf", metadata=_variant(1, "H2S"))
|
|
await _insert_file(conn, file_id=3, filename="bracket_h2c.gcode.3mf", metadata=_variant(1, "H2C"))
|
|
|
|
async with engine.begin() as conn:
|
|
await run_migrations(conn)
|
|
async with engine.connect() as conn:
|
|
first = await _members(conn)
|
|
|
|
async with engine.begin() as conn:
|
|
await run_migrations(conn)
|
|
async with engine.connect() as conn:
|
|
assert await _group_count(conn) == 1
|
|
assert await _members(conn) == first
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_hand_grouped_files_are_left_alone(engine):
|
|
"""A user who has already grouped (or deliberately ungrouped) files owns that
|
|
decision — the backfill only ever considers files with no group yet."""
|
|
async with engine.begin() as conn:
|
|
await conn.execute(text("INSERT INTO file_variant_groups (id, name) VALUES (7, 'my own grouping')"))
|
|
await _insert_file(conn, file_id=1, filename="bracket.3mf")
|
|
await _insert_file(
|
|
conn, file_id=2, filename="bracket_h2s.gcode.3mf", metadata=_variant(1, "H2S"), variant_group_id=7
|
|
)
|
|
await _insert_file(conn, file_id=3, filename="bracket_h2c.gcode.3mf", metadata=_variant(1, "H2C"))
|
|
|
|
async with engine.begin() as conn:
|
|
await run_migrations(conn)
|
|
|
|
async with engine.connect() as conn:
|
|
assert await _group_count(conn) == 1, "no second group is invented"
|
|
members = await _members(conn)
|
|
assert members[2][0] == 7, "the user's grouping survives"
|
|
assert members[3][0] is None, "and the leftover sibling is not force-joined to it"
|