Files
bambuddy/backend/tests/unit/test_bed_temperature_backfill_2989.py
T
maziggy b0ecb8fd88 Repair the bed temperature on archives written before the fix (issue #2989)
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 - 0 of 455 real 3MFs had resolved.

A one-shot pass re-reads the 3MF already on disk, gated by a settings flag the
way #2614's repair is: the rows it cannot fill are exactly the ones it would
reopen every boot. It fills NULLs only. Nothing recorded is overwritten, an
archive whose file is gone stays NULL, and a corrupted 3MF is skipped rather
than failing startup.

The plate mapping moves to threemf_tools.bed_temperature_from_config so the
ingest path and the repair cannot read a 3MF differently - the same drift
move.

_extract_settings_from_content is deleted. Nothing called it anywhere in the
repo, and it carried the old bed_temperature mapping this issue fixed.
2026-08-29 09:23:48 +02:00

377 lines
15 KiB
Python

"""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