mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-07 23:01:06 +02:00
Add variant-group data model for cross-model queue alternatives (#671)
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.
This commit is contained in:
@@ -3949,6 +3949,116 @@ async def run_migrations(conn):
|
||||
conn, "ALTER TABLE notification_providers ADD COLUMN on_plate_clear_required BOOLEAN DEFAULT false"
|
||||
)
|
||||
|
||||
# Migration: variant grouping for library files (#671 / #2570). The
|
||||
# `file_variant_groups` table itself needs no migration — create_all() above
|
||||
# builds it — but the two member-side columns do. INTEGER and the inline
|
||||
# REFERENCES clause are spelled identically on SQLite and Postgres, and
|
||||
# SQLite accepts a REFERENCES on ADD COLUMN (same form as the
|
||||
# pipeline_runs.parent_run_id migration at the top of this function).
|
||||
await _safe_execute(
|
||||
conn,
|
||||
"ALTER TABLE library_files ADD COLUMN variant_group_id INTEGER "
|
||||
"REFERENCES file_variant_groups(id) ON DELETE SET NULL",
|
||||
)
|
||||
await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN variant_position INTEGER DEFAULT 0")
|
||||
# The model declares index=True, so fresh installs get this from create_all();
|
||||
# migrated databases need it spelled out. Resolution looks members up by group
|
||||
# on every scheduler pass that touches a grouped item.
|
||||
await _safe_execute(
|
||||
conn,
|
||||
"CREATE INDEX IF NOT EXISTS ix_library_files_variant_group_id ON library_files (variant_group_id)",
|
||||
)
|
||||
await _migrate_backfill_variant_groups(conn)
|
||||
|
||||
|
||||
async def _migrate_backfill_variant_groups(conn) -> None:
|
||||
"""Build variant groups from the slice provenance already on disk (#671 / #2570).
|
||||
|
||||
``sliced_from_library_file_id`` has been stamped into ``file_metadata`` by the
|
||||
Slice button (routes/library.py) and the pipeline runner (routes/pipeline_runs.py)
|
||||
since those features shipped, and until now nothing ever read it back — the
|
||||
link existed but was inert. This promotes it to real group membership so an
|
||||
existing library arrives with its slice sets already grouped instead of
|
||||
requiring the user to re-declare by hand what Bambuddy itself recorded.
|
||||
|
||||
Only sources with **two or more** sliced children carrying **distinct**
|
||||
``sliced_for_model`` values produce a group:
|
||||
|
||||
- Fewer than two candidates is not a choice, and a one-member group would
|
||||
change nothing at print time while creating a row per sliced file in every
|
||||
library on earth.
|
||||
- Two children sliced for the same printer are not alternatives — the
|
||||
resolver has no basis to prefer one, so grouping them would turn a
|
||||
harmless duplicate into an arbitrary pick. Those sources are skipped
|
||||
whole; the user can still group them by hand and choose an order.
|
||||
|
||||
The unsliced source file is deliberately not a member. It has no
|
||||
``sliced_for_model``, so it can never be a dispatch candidate; showing it
|
||||
alongside its variants is a File Manager listing concern, which is out of
|
||||
scope.
|
||||
|
||||
Idempotent: only files with no group yet are considered, so a re-run after a
|
||||
partial apply resumes rather than duplicating, and a user who has since
|
||||
ungrouped files by hand does not get them silently regrouped.
|
||||
"""
|
||||
from sqlalchemy import text
|
||||
|
||||
from backend.app.models.library import FileVariantGroup
|
||||
|
||||
if is_sqlite():
|
||||
source_expr = "json_extract(file_metadata, '$.sliced_from_library_file_id')"
|
||||
model_expr = "json_extract(file_metadata, '$.sliced_for_model')"
|
||||
else:
|
||||
# file_metadata is JSON, not JSONB — cast before using the -> operators,
|
||||
# matching _migrate_drop_library_print_name above.
|
||||
source_expr = "file_metadata::jsonb->>'sliced_from_library_file_id'"
|
||||
model_expr = "file_metadata::jsonb->>'sliced_for_model'"
|
||||
|
||||
async with conn.begin_nested():
|
||||
rows = (
|
||||
await conn.execute(
|
||||
text(
|
||||
f"SELECT id, {source_expr} AS source_id, {model_expr} AS model " # noqa: S608 — dialect literals
|
||||
"FROM library_files "
|
||||
f"WHERE {source_expr} IS NOT NULL AND {model_expr} IS NOT NULL "
|
||||
"AND variant_group_id IS NULL AND deleted_at IS NULL "
|
||||
"ORDER BY id"
|
||||
)
|
||||
)
|
||||
).fetchall()
|
||||
|
||||
by_source: dict[str, list[tuple[int, str]]] = {}
|
||||
for file_id, source_id, model in rows:
|
||||
by_source.setdefault(str(source_id), []).append((file_id, str(model)))
|
||||
|
||||
for source_id, members in by_source.items():
|
||||
if len(members) < 2:
|
||||
continue
|
||||
models = [m for _, m in members]
|
||||
if len(set(models)) != len(models):
|
||||
# Same printer sliced twice — ambiguous, leave it to the user.
|
||||
continue
|
||||
|
||||
# Name the group after the source file when it is still around; its
|
||||
# filename is what the user recognises. A deleted source leaves the
|
||||
# variants perfectly usable, so fall back rather than skip.
|
||||
name_row = (
|
||||
await conn.execute(
|
||||
text("SELECT filename FROM library_files WHERE id = :sid"),
|
||||
{"sid": int(source_id)},
|
||||
)
|
||||
).fetchone()
|
||||
group_name = name_row[0] if name_row else f"{members[0][1]} + {len(members) - 1} more"
|
||||
|
||||
result = await conn.execute(FileVariantGroup.__table__.insert().values(name=group_name))
|
||||
group_id = result.inserted_primary_key[0]
|
||||
|
||||
for position, (file_id, _model) in enumerate(members):
|
||||
await conn.execute(
|
||||
text("UPDATE library_files SET variant_group_id = :gid, variant_position = :pos WHERE id = :fid"),
|
||||
{"gid": group_id, "pos": position, "fid": file_id},
|
||||
)
|
||||
|
||||
|
||||
_USER_PRINT_TEMPLATE_RENAMES: tuple[tuple[str, str, str], ...] = (
|
||||
("user_print_start", "User Print Started", "User Print Started Email"),
|
||||
|
||||
@@ -8,7 +8,7 @@ from backend.app.models.filament import Filament
|
||||
from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
|
||||
from backend.app.models.group import Group, user_groups
|
||||
from backend.app.models.kprofile_note import KProfileNote
|
||||
from backend.app.models.library import LibraryFile, LibraryFolder
|
||||
from backend.app.models.library import FileVariantGroup, LibraryFile, LibraryFolder
|
||||
from backend.app.models.local_preset import LocalPreset
|
||||
from backend.app.models.location import Location
|
||||
from backend.app.models.long_lived_token import LongLivedToken
|
||||
@@ -61,6 +61,7 @@ __all__ = [
|
||||
"PrintBatch",
|
||||
"LibraryFolder",
|
||||
"LibraryFile",
|
||||
"FileVariantGroup",
|
||||
"Location",
|
||||
"User",
|
||||
"Group",
|
||||
|
||||
@@ -60,6 +60,41 @@ class LibraryFolder(Base):
|
||||
archive: Mapped["PrintArchive | None"] = relationship()
|
||||
|
||||
|
||||
class FileVariantGroup(Base):
|
||||
"""A set of library files that are the same job sliced for different printers.
|
||||
|
||||
Members are peers, not a source/output hierarchy. The group answers one
|
||||
question — "which of these files goes to an H2S, and which to an H2C" — and
|
||||
both open features need that answer from opposite ends: the print queue
|
||||
picks the printer and needs the matching file (#671), the File Manager's
|
||||
print action has the printer already and needs the same match (#2570).
|
||||
|
||||
The group deliberately stores no model information of its own. Each
|
||||
member's target model comes from its own ``file_metadata['sliced_for_model']``,
|
||||
parsed out of the 3MF, so a group can never disagree with the files it
|
||||
contains. It also carries no pointer to an unsliced source file: that is a
|
||||
display concern for the grouped File Manager listing, which is not built.
|
||||
|
||||
Deleting a group ungroups its files rather than deleting them (the member
|
||||
side is ON DELETE SET NULL) — every member is independently printable.
|
||||
"""
|
||||
|
||||
__tablename__ = "file_variant_groups"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
|
||||
files: Mapped[list["LibraryFile"]] = relationship(
|
||||
back_populates="variant_group",
|
||||
order_by="LibraryFile.variant_position",
|
||||
)
|
||||
created_by: Mapped["User | None"] = relationship()
|
||||
|
||||
|
||||
class LibraryFile(Base):
|
||||
"""File stored in the library."""
|
||||
|
||||
@@ -98,6 +133,17 @@ class LibraryFile(Base):
|
||||
source_type: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
source_url: Mapped[str | None] = mapped_column(String(512), nullable=True, index=True)
|
||||
|
||||
# Variant grouping (#671 / #2570). A file belongs to at most one group of
|
||||
# "same job, sliced for a different printer" siblings. SET NULL on group
|
||||
# delete: ungrouping must never take the files with it. ``variant_position``
|
||||
# is the user's priority order within the group — when two printers are idle
|
||||
# at the same scheduler tick, the lowest position wins, so the pick is
|
||||
# reproducible instead of depending on which match the scheduler found first.
|
||||
variant_group_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("file_variant_groups.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
variant_position: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
|
||||
# User tracking (Issue #206)
|
||||
created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
|
||||
@@ -122,6 +168,7 @@ class LibraryFile(Base):
|
||||
folder: Mapped["LibraryFolder | None"] = relationship(back_populates="files")
|
||||
project: Mapped["Project | None"] = relationship()
|
||||
created_by: Mapped["User | None"] = relationship()
|
||||
variant_group: Mapped["FileVariantGroup | None"] = relationship(back_populates="files")
|
||||
# Tags (#1268). M2M via library_file_tags. Loaded explicitly via
|
||||
# ``selectinload`` in list_files so each row in the listing carries its
|
||||
# chip set without N+1 fetches.
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
"""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"
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user