|
|
@@ -0,0 +1,376 @@
|
|
|
+"""Archives written before #2989 get their bed temperature read back off disk.
|
|
|
+
|
|
|
+The extractor looked for a ``bed_temperature`` key BambuStudio does not write,
|
|
|
+so every archive from a Bambu slice stored NULL -- 0 of 455 real 3MFs resolved
|
|
|
+on the install this was measured on. The forward fix reads the array the fitted
|
|
|
+plate points at, but only for archives made after it; everything already in the
|
|
|
+library stays blank, and preheat keeps falling back to the keep-warm bed
|
|
|
+temperature whenever one of those jobs is reprinted from the queue.
|
|
|
+
|
|
|
+This one-shot re-reads the 3MF that is already on disk. It fills NULLs and
|
|
|
+nothing else: no value is invented, none is overwritten, and an archive whose
|
|
|
+file is gone stays NULL rather than being guessed at.
|
|
|
+
|
|
|
+The 3MFs here are real zips rather than a patched extractor, because
|
|
|
+``extract_bed_temperature_from_3mf`` is itself new code and stubbing it would
|
|
|
+leave the only thing this migration depends on untested.
|
|
|
+"""
|
|
|
+
|
|
|
+import json
|
|
|
+import logging
|
|
|
+import zipfile
|
|
|
+
|
|
|
+import pytest
|
|
|
+from sqlalchemy import text
|
|
|
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
|
+
|
|
|
+import backend.app.models # noqa: F401 - populate Base.metadata
|
|
|
+from backend.app.core import database as database_module
|
|
|
+from backend.app.core.database import Base, _backfill_archive_bed_temperature
|
|
|
+from backend.app.models.archive import PrintArchive
|
|
|
+
|
|
|
+# A Textured PEI slice of a two-filament project, in the shape BambuStudio
|
|
|
+# writes: an array per plate type, and the fitted plate named separately.
|
|
|
+_PEI_55 = {
|
|
|
+ "curr_bed_type": "Textured PEI Plate",
|
|
|
+ "cool_plate_temp": ["0", "0"],
|
|
|
+ "eng_plate_temp": ["0", "0"],
|
|
|
+ "hot_plate_temp": ["0", "0"],
|
|
|
+ "textured_plate_temp_initial_layer": ["55", "55"],
|
|
|
+ "textured_plate_temp": ["55", "55"],
|
|
|
+ "supertack_plate_temp": ["0", "0"],
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+@pytest.fixture
|
|
|
+async def engine(tmp_path):
|
|
|
+ eng = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/t.db")
|
|
|
+ async with eng.begin() as conn:
|
|
|
+ await conn.run_sync(Base.metadata.create_all)
|
|
|
+ try:
|
|
|
+ yield eng
|
|
|
+ finally:
|
|
|
+ await eng.dispose()
|
|
|
+
|
|
|
+
|
|
|
+@pytest.fixture
|
|
|
+def data_dir(tmp_path, monkeypatch):
|
|
|
+ monkeypatch.setattr(database_module.settings, "base_dir", tmp_path)
|
|
|
+ return tmp_path
|
|
|
+
|
|
|
+
|
|
|
+def _write_3mf(data_dir, relative: str, config: dict | None) -> str:
|
|
|
+ """A 3MF on disk. ``config=None`` writes a file that is not a zip at all."""
|
|
|
+ path = data_dir / relative
|
|
|
+ path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
+ if config is None:
|
|
|
+ path.write_bytes(b"not a zip")
|
|
|
+ return relative
|
|
|
+ with zipfile.ZipFile(path, "w") as zf:
|
|
|
+ zf.writestr("Metadata/project_settings.config", json.dumps(config))
|
|
|
+ return relative
|
|
|
+
|
|
|
+
|
|
|
+async def _archive(db, file_path: str, *, bed_temperature=None) -> PrintArchive:
|
|
|
+ archive = PrintArchive(
|
|
|
+ filename="Benchy.gcode.3mf",
|
|
|
+ file_path=file_path,
|
|
|
+ file_size=1,
|
|
|
+ status="completed",
|
|
|
+ bed_temperature=bed_temperature,
|
|
|
+ )
|
|
|
+ db.add(archive)
|
|
|
+ await db.flush()
|
|
|
+ return archive
|
|
|
+
|
|
|
+
|
|
|
+async def _bed_temperature(engine, archive_id: int):
|
|
|
+ async with engine.begin() as conn:
|
|
|
+ return (
|
|
|
+ await conn.execute(text("SELECT bed_temperature FROM print_archives WHERE id = :id"), {"id": archive_id})
|
|
|
+ ).scalar_one()
|
|
|
+
|
|
|
+
|
|
|
+class TestItFillsWhatItCan:
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_a_null_is_read_from_the_plate_the_project_is_sliced_for(self, engine, data_dir):
|
|
|
+ sm = async_sessionmaker(engine, expire_on_commit=False)
|
|
|
+ async with sm() as db:
|
|
|
+ relative = _write_3mf(data_dir, "archive/1/20260828_Benchy/Benchy.gcode.3mf", _PEI_55)
|
|
|
+ archive = await _archive(db, relative)
|
|
|
+ await db.commit()
|
|
|
+
|
|
|
+ async with engine.begin() as conn:
|
|
|
+ await _backfill_archive_bed_temperature(conn)
|
|
|
+
|
|
|
+ assert await _bed_temperature(engine, archive.id) == 55
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_an_orca_export_still_resolves(self, engine, data_dir):
|
|
|
+ """The generic spelling is the fallback, not a second-class citizen."""
|
|
|
+ sm = async_sessionmaker(engine, expire_on_commit=False)
|
|
|
+ async with sm() as db:
|
|
|
+ relative = _write_3mf(data_dir, "archive/1/a/Benchy.gcode.3mf", {"bed_temperature": 60})
|
|
|
+ archive = await _archive(db, relative)
|
|
|
+ await db.commit()
|
|
|
+
|
|
|
+ async with engine.begin() as conn:
|
|
|
+ await _backfill_archive_bed_temperature(conn)
|
|
|
+
|
|
|
+ assert await _bed_temperature(engine, archive.id) == 60
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_several_archives_in_one_pass(self, engine, data_dir):
|
|
|
+ sm = async_sessionmaker(engine, expire_on_commit=False)
|
|
|
+ async with sm() as db:
|
|
|
+ first = await _archive(db, _write_3mf(data_dir, "archive/1/a/x.3mf", _PEI_55))
|
|
|
+ second = await _archive(
|
|
|
+ db,
|
|
|
+ _write_3mf(
|
|
|
+ data_dir, "archive/1/b/y.3mf", {"curr_bed_type": "High Temp Plate", "hot_plate_temp": ["100"]}
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ await db.commit()
|
|
|
+
|
|
|
+ async with engine.begin() as conn:
|
|
|
+ await _backfill_archive_bed_temperature(conn)
|
|
|
+
|
|
|
+ assert await _bed_temperature(engine, first.id) == 55
|
|
|
+ assert await _bed_temperature(engine, second.id) == 100
|
|
|
+
|
|
|
+
|
|
|
+class TestWhatItRefusesToTouch:
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_a_value_already_recorded_is_left_alone(self, engine, data_dir):
|
|
|
+ """Only NULLs. A temperature somebody set, or one a later archive read
|
|
|
+ correctly, must not be rewritten from the file."""
|
|
|
+ sm = async_sessionmaker(engine, expire_on_commit=False)
|
|
|
+ async with sm() as db:
|
|
|
+ relative = _write_3mf(data_dir, "archive/1/a/Benchy.gcode.3mf", _PEI_55)
|
|
|
+ archive = await _archive(db, relative, bed_temperature=90)
|
|
|
+ await db.commit()
|
|
|
+
|
|
|
+ async with engine.begin() as conn:
|
|
|
+ await _backfill_archive_bed_temperature(conn)
|
|
|
+
|
|
|
+ assert await _bed_temperature(engine, archive.id) == 90
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_a_no_3mf_archive_stays_null(self, engine, data_dir):
|
|
|
+ """``file_path == ""`` is the ordinary shape of a Studio-sent H2 print.
|
|
|
+ There is no file to read, and inventing one is the whole bug."""
|
|
|
+ sm = async_sessionmaker(engine, expire_on_commit=False)
|
|
|
+ async with sm() as db:
|
|
|
+ archive = await _archive(db, "")
|
|
|
+ await db.commit()
|
|
|
+
|
|
|
+ async with engine.begin() as conn:
|
|
|
+ await _backfill_archive_bed_temperature(conn)
|
|
|
+
|
|
|
+ assert await _bed_temperature(engine, archive.id) is None
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_a_file_that_is_gone_stays_null(self, engine, data_dir):
|
|
|
+ sm = async_sessionmaker(engine, expire_on_commit=False)
|
|
|
+ async with sm() as db:
|
|
|
+ archive = await _archive(db, "archive/1/a/deleted.gcode.3mf")
|
|
|
+ await db.commit()
|
|
|
+
|
|
|
+ async with engine.begin() as conn:
|
|
|
+ await _backfill_archive_bed_temperature(conn)
|
|
|
+
|
|
|
+ assert await _bed_temperature(engine, archive.id) is None
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_a_file_that_is_not_a_zip_stays_null(self, engine, data_dir):
|
|
|
+ """A truncated or corrupted 3MF must not take the whole boot down."""
|
|
|
+ sm = async_sessionmaker(engine, expire_on_commit=False)
|
|
|
+ async with sm() as db:
|
|
|
+ archive = await _archive(db, _write_3mf(data_dir, "archive/1/a/broken.3mf", None))
|
|
|
+ await db.commit()
|
|
|
+
|
|
|
+ async with engine.begin() as conn:
|
|
|
+ await _backfill_archive_bed_temperature(conn)
|
|
|
+
|
|
|
+ assert await _bed_temperature(engine, archive.id) is None
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_an_all_zero_plate_array_stays_null(self, engine, data_dir):
|
|
|
+ """0 means no filament in the project prints on this plate. Recording
|
|
|
+ it would read as a cold bed, which is worse than nothing."""
|
|
|
+ sm = async_sessionmaker(engine, expire_on_commit=False)
|
|
|
+ async with sm() as db:
|
|
|
+ relative = _write_3mf(
|
|
|
+ data_dir,
|
|
|
+ "archive/1/a/zero.3mf",
|
|
|
+ {"curr_bed_type": "Cool Plate", "cool_plate_temp": ["0", "0"]},
|
|
|
+ )
|
|
|
+ archive = await _archive(db, relative)
|
|
|
+ await db.commit()
|
|
|
+
|
|
|
+ async with engine.begin() as conn:
|
|
|
+ await _backfill_archive_bed_temperature(conn)
|
|
|
+
|
|
|
+ assert await _bed_temperature(engine, archive.id) is None
|
|
|
+
|
|
|
+
|
|
|
+class TestItCannotStopBambuddyBooting:
|
|
|
+ """The migration sequence has no handler above it.
|
|
|
+
|
|
|
+ ``run_migrations`` is awaited straight from ``init_db`` with no try/except,
|
|
|
+ so anything escaping this function stops startup -- and keeps stopping it,
|
|
|
+ because the one-shot flag is written inside the transaction that just rolled
|
|
|
+ back. Measured: two consecutive boots, same failure, flag never written. So
|
|
|
+ the guards here are load-bearing rather than tidy.
|
|
|
+ """
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_an_unexpected_exception_costs_one_archive_not_the_boot(self, engine, data_dir, monkeypatch, caplog):
|
|
|
+ """Not a listed zip error -- the point is that the guard does not depend
|
|
|
+ on having predicted which exception a bad file raises."""
|
|
|
+ import backend.app.utils.threemf_tools as tools
|
|
|
+
|
|
|
+ sm = async_sessionmaker(engine, expire_on_commit=False)
|
|
|
+ async with sm() as db:
|
|
|
+ bad = await _archive(db, _write_3mf(data_dir, "archive/1/a/bad.3mf", _PEI_55))
|
|
|
+ await db.commit()
|
|
|
+
|
|
|
+ def _explode(_path):
|
|
|
+ raise RecursionError("boom")
|
|
|
+
|
|
|
+ monkeypatch.setattr(tools, "extract_bed_temperature_from_3mf", _explode)
|
|
|
+
|
|
|
+ with caplog.at_level(logging.WARNING):
|
|
|
+ async with engine.begin() as conn:
|
|
|
+ await _backfill_archive_bed_temperature(conn)
|
|
|
+
|
|
|
+ assert await _bed_temperature(engine, bad.id) is None
|
|
|
+ assert any("could not read" in r.getMessage() for r in caplog.records)
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_one_bad_archive_does_not_stop_the_others(self, engine, data_dir, monkeypatch):
|
|
|
+ """The guard is per row, so the rest of the library is still repaired."""
|
|
|
+ import backend.app.utils.threemf_tools as tools
|
|
|
+
|
|
|
+ sm = async_sessionmaker(engine, expire_on_commit=False)
|
|
|
+ async with sm() as db:
|
|
|
+ bad = await _archive(db, _write_3mf(data_dir, "archive/1/a/bad.3mf", _PEI_55))
|
|
|
+ good = await _archive(db, _write_3mf(data_dir, "archive/1/b/good.3mf", _PEI_55))
|
|
|
+ await db.commit()
|
|
|
+
|
|
|
+ real = tools.extract_bed_temperature_from_3mf
|
|
|
+
|
|
|
+ def _explode_on_bad(path):
|
|
|
+ if path.name == "bad.3mf":
|
|
|
+ raise RecursionError("boom")
|
|
|
+ return real(path)
|
|
|
+
|
|
|
+ monkeypatch.setattr(tools, "extract_bed_temperature_from_3mf", _explode_on_bad)
|
|
|
+
|
|
|
+ async with engine.begin() as conn:
|
|
|
+ await _backfill_archive_bed_temperature(conn)
|
|
|
+
|
|
|
+ assert await _bed_temperature(engine, bad.id) is None
|
|
|
+ assert await _bed_temperature(engine, good.id) == 55
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_the_extractor_swallows_anything_a_file_can_throw(self, tmp_path):
|
|
|
+ """Its callers are inside startup, so None is the only outcome."""
|
|
|
+ from backend.app.utils.threemf_tools import extract_bed_temperature_from_3mf
|
|
|
+
|
|
|
+ missing = tmp_path / "nope.3mf"
|
|
|
+ directory = tmp_path / "adir.3mf"
|
|
|
+ directory.mkdir()
|
|
|
+ truncated = tmp_path / "cut.3mf"
|
|
|
+ truncated.write_bytes(b"PK\x03\x04 and then nothing")
|
|
|
+ empty = tmp_path / "empty.3mf"
|
|
|
+ empty.write_bytes(b"")
|
|
|
+
|
|
|
+ for candidate in (missing, directory, truncated, empty):
|
|
|
+ assert extract_bed_temperature_from_3mf(candidate) is None
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_the_extractor_swallows_what_no_one_predicted(self, tmp_path, monkeypatch):
|
|
|
+ """The four cases above all raise OSError or BadZipFile, so on their own
|
|
|
+ they would still pass with the guard narrowed back to that pair. This
|
|
|
+ one forces something outside it, which is the whole reason the catch is
|
|
|
+ broad -- the failure being guarded is an exception nobody listed."""
|
|
|
+ import backend.app.utils.threemf_tools as tools
|
|
|
+
|
|
|
+ good = tmp_path / "ok.3mf"
|
|
|
+ with zipfile.ZipFile(good, "w") as zf:
|
|
|
+ zf.writestr("Metadata/project_settings.config", json.dumps(_PEI_55))
|
|
|
+ assert tools.extract_bed_temperature_from_3mf(good) == 55
|
|
|
+
|
|
|
+ class _Exploding:
|
|
|
+ def __init__(self, *a, **k):
|
|
|
+ raise RecursionError("boom")
|
|
|
+
|
|
|
+ monkeypatch.setattr(tools.zipfile, "ZipFile", _Exploding)
|
|
|
+
|
|
|
+ assert tools.extract_bed_temperature_from_3mf(good) is None
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_a_flag_row_with_an_empty_value_does_not_re_run(self, engine, data_dir):
|
|
|
+ """``if already:`` would treat "" as not-done, re-run, and then fail the
|
|
|
+ unique key on the INSERT -- a boot loop from a single odd row."""
|
|
|
+ async with engine.begin() as conn:
|
|
|
+ await conn.execute(
|
|
|
+ text('INSERT INTO settings ("key", value) VALUES (:k, :v)'),
|
|
|
+ {"k": "_backfill_2989_bed_temperature_done", "v": ""},
|
|
|
+ )
|
|
|
+
|
|
|
+ async with engine.begin() as conn:
|
|
|
+ await _backfill_archive_bed_temperature(conn)
|
|
|
+ async with engine.begin() as conn:
|
|
|
+ await _backfill_archive_bed_temperature(conn)
|
|
|
+
|
|
|
+
|
|
|
+class TestItRunsExactlyOnce:
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_the_flag_is_written_even_when_nothing_matched(self, engine, data_dir):
|
|
|
+ """The rows it cannot fill are the ones it would reopen every boot."""
|
|
|
+ async with engine.begin() as conn:
|
|
|
+ await _backfill_archive_bed_temperature(conn)
|
|
|
+
|
|
|
+ async with engine.begin() as conn:
|
|
|
+ flag = (
|
|
|
+ await conn.execute(
|
|
|
+ text('SELECT value FROM settings WHERE "key" = :k'),
|
|
|
+ {"k": "_backfill_2989_bed_temperature_done"},
|
|
|
+ )
|
|
|
+ ).scalar_one_or_none()
|
|
|
+
|
|
|
+ assert flag == "true"
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_a_second_boot_does_not_rescan(self, engine, data_dir):
|
|
|
+ """An archive added after the one-shot has run is left to the forward
|
|
|
+ fix, which is what writes bed_temperature for anything new."""
|
|
|
+ sm = async_sessionmaker(engine, expire_on_commit=False)
|
|
|
+ async with sm() as db:
|
|
|
+ first = await _archive(db, _write_3mf(data_dir, "archive/1/a/x.3mf", _PEI_55))
|
|
|
+ await db.commit()
|
|
|
+
|
|
|
+ async with engine.begin() as conn:
|
|
|
+ await _backfill_archive_bed_temperature(conn)
|
|
|
+ assert await _bed_temperature(engine, first.id) == 55
|
|
|
+
|
|
|
+ async with sm() as db:
|
|
|
+ later = await _archive(db, _write_3mf(data_dir, "archive/1/b/y.3mf", _PEI_55))
|
|
|
+ await db.commit()
|
|
|
+
|
|
|
+ async with engine.begin() as conn:
|
|
|
+ await _backfill_archive_bed_temperature(conn)
|
|
|
+
|
|
|
+ assert await _bed_temperature(engine, later.id) is None
|
|
|
+ # And the flag was not written twice, which the settings table's unique
|
|
|
+ # key would refuse anyway -- the guard is the SELECT, not the database.
|
|
|
+ async with engine.begin() as conn:
|
|
|
+ count = (
|
|
|
+ await conn.execute(
|
|
|
+ text('SELECT COUNT(*) FROM settings WHERE "key" = :k'),
|
|
|
+ {"k": "_backfill_2989_bed_temperature_done"},
|
|
|
+ )
|
|
|
+ ).scalar_one()
|
|
|
+ assert count == 1
|