| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401 |
- """A "Print again" from the printer's screen is archived with its 3MF (#3307).
- On a touchscreen reprint the printer never names the file on the card. It
- reports its own history copy under a generic name
- (``file:///userdata/project_file.gcode.3mf``, which port 990 cannot serve), the
- 3MF's internal title as ``subtask_name`` -- "IKEA Skadis Support Feet", while
- the card file is "Support Feet 20mm Standoff_Build Sandbox.gcode.3mf" -- and
- ``/data/Metadata/plate_3.gcode``. Every name built from those misses, so the
- second run of a job archived fine the first time got a no-3MF fallback.
- The first run's archive holds that 3MF, and its ``_print_data`` records the
- same subtask name and plate. When the lookup comes back empty, the newest such
- archive on the same printer lends its 3MF to the new run. A layer count the
- printer has already reported must agree, so a re-slice under the same title is
- not mistaken for the same job. Not on a cool-off or a failed transfer: there
- the file was reachable and its own retry fetches the real one.
- """
- import shutil
- from contextlib import ExitStack
- from datetime import datetime, timedelta, timezone
- from types import SimpleNamespace
- from unittest.mock import AsyncMock, MagicMock, patch
- import pytest
- from backend.app.core.config import settings as app_settings
- from backend.app.main import (
- _active_prints,
- _archived_3mf_for_repeat,
- _expected_print_creators,
- _expected_print_registered_at,
- _expected_prints,
- _print_ams_mappings,
- _timelapse_baselines,
- )
- from backend.app.models.archive import PrintArchive
- from backend.app.models.printer import Printer
- from backend.app.services.bambu_ftp import FileNotOnPrinterError
- pytestmark = pytest.mark.unit
- TITLE = "IKEA Skadis Support Feet"
- PLATE_3 = "/data/Metadata/plate_3.gcode"
- CARD_FILE = "Support Feet 20mm Standoff_Build Sandbox.gcode.3mf"
- @pytest.fixture
- def base_dir(tmp_path):
- # archive_dir too: it is its own setting, and the copy lands in its temp/.
- with (
- patch.object(app_settings, "base_dir", tmp_path),
- patch.object(app_settings, "archive_dir", tmp_path / "archive"),
- ):
- yield tmp_path
- def _stored_3mf(base_dir, folder: str, content: bytes = b"PK\x03\x04 3mf") -> str:
- path = base_dir / "archive" / folder / CARD_FILE
- path.parent.mkdir(parents=True, exist_ok=True)
- path.write_bytes(content)
- return str(path.relative_to(base_dir))
- class TestFindingTheEarlierRun:
- @pytest.fixture
- async def printers(self, db_session):
- for n in (1, 2):
- db_session.add(
- Printer(id=n, name=f"H2C-{n}", serial_number=f"SN{n}", ip_address="192.0.2.1", access_code="1")
- )
- await db_session.commit()
- async def _archive(self, db, base_dir, folder, *, printer_id=1, subtask=TITLE, gcode=PLATE_3, age_min=60, **fields):
- archive = PrintArchive(
- printer_id=printer_id,
- filename=CARD_FILE,
- file_path=_stored_3mf(base_dir, folder),
- file_size=10,
- print_name=f"{TITLE} - Plate 3",
- status="completed",
- created_at=datetime.now(timezone.utc) - timedelta(minutes=age_min),
- extra_data={"_print_data": {"filename": gcode, "subtask_name": subtask}},
- **fields,
- )
- db.add(archive)
- await db.commit()
- return archive
- async def _find(self, db, *, printer_id=1, subtask=TITLE, gcode=PLATE_3, layers=None):
- return await _archived_3mf_for_repeat(db, printer_id, subtask, gcode, layers)
- @pytest.mark.asyncio
- async def test_the_same_job_on_the_same_printer_is_found(self, db_session, printers, base_dir):
- first = await self._archive(db_session, base_dir, "14")
- assert (await self._find(db_session)).id == first.id
- @pytest.mark.asyncio
- async def test_the_newest_run_wins(self, db_session, printers, base_dir):
- await self._archive(db_session, base_dir, "14", age_min=120)
- newer = await self._archive(db_session, base_dir, "15", age_min=10)
- assert (await self._find(db_session)).id == newer.id
- @pytest.mark.asyncio
- async def test_another_plate_is_not(self, db_session, printers, base_dir):
- await self._archive(db_session, base_dir, "14", gcode="/data/Metadata/plate_2.gcode")
- assert await self._find(db_session) is None
- @pytest.mark.asyncio
- async def test_another_title_is_not(self, db_session, printers, base_dir):
- await self._archive(db_session, base_dir, "14", subtask="IKEA Skadis Hook")
- assert await self._find(db_session) is None
- @pytest.mark.asyncio
- async def test_another_printer_is_not(self, db_session, printers, base_dir):
- await self._archive(db_session, base_dir, "14", printer_id=2)
- assert await self._find(db_session) is None
- @pytest.mark.asyncio
- async def test_a_deleted_archive_is_not(self, db_session, printers, base_dir):
- await self._archive(db_session, base_dir, "14", deleted_at=datetime.now(timezone.utc))
- assert await self._find(db_session) is None
- @pytest.mark.asyncio
- async def test_a_fallback_archive_without_a_3mf_is_not(self, db_session, printers, base_dir):
- archive = await self._archive(db_session, base_dir, "14")
- archive.file_path = ""
- await db_session.commit()
- assert await self._find(db_session) is None
- @pytest.mark.asyncio
- async def test_an_archive_whose_file_is_gone_is_skipped_for_an_older_one(self, db_session, printers, base_dir):
- older = await self._archive(db_session, base_dir, "14", age_min=120)
- await self._archive(db_session, base_dir, "15", age_min=10)
- shutil.rmtree(base_dir / "archive" / "15")
- assert (await self._find(db_session)).id == older.id
- @pytest.mark.asyncio
- async def test_a_different_layer_count_is_another_slice(self, db_session, printers, base_dir):
- await self._archive(db_session, base_dir, "14", total_layers=120)
- assert await self._find(db_session, layers=96) is None
- assert await self._find(db_session, layers=120) is not None
- @pytest.mark.asyncio
- async def test_an_unknown_layer_count_on_either_side_does_not_decide(self, db_session, printers, base_dir):
- await self._archive(db_session, base_dir, "14")
- assert await self._find(db_session, layers=96) is not None
- assert await self._find(db_session, layers=None) is not None
- @pytest.mark.asyncio
- async def test_nothing_to_match_on_finds_nothing(self, db_session, printers, base_dir):
- # A run that recorded no plate is no evidence for a print reporting none.
- await self._archive(db_session, base_dir, "14", gcode="")
- assert await self._find(db_session, gcode="") is None
- await self._archive(db_session, base_dir, "15", subtask="")
- assert await self._find(db_session, subtask="") is None
- @pytest.mark.asyncio
- async def test_fallback_archives_do_not_use_up_the_lookback(self, db_session, printers, base_dir):
- """Every failed run leaves a no-3MF row; they must not push the run
- that has the file out of the rows compared."""
- good = await self._archive(db_session, base_dir, "14", age_min=120)
- empty = await self._archive(db_session, base_dir, "17", age_min=10)
- empty.file_path = ""
- await db_session.commit()
- with patch("backend.app.main._REPEAT_LOOKBACK", 1):
- assert (await self._find(db_session)).id == good.id
- @pytest.mark.asyncio
- async def test_unreadable_extra_data_is_skipped(self, db_session, printers, base_dir):
- archive = await self._archive(db_session, base_dir, "14")
- archive.extra_data = {"_print_data": "not a dict"}
- await db_session.commit()
- assert await self._find(db_session) is None
- # --- on_print_start wiring ---------------------------------------------------
- @pytest.fixture
- def _clear_dicts():
- dicts = (
- _expected_prints,
- _expected_print_registered_at,
- _expected_print_creators,
- _print_ams_mappings,
- _active_prints,
- _timelapse_baselines,
- )
- for d in dicts:
- d.clear()
- yield
- for d in dicts:
- d.clear()
- def _printer():
- printer = MagicMock()
- printer.id = 1
- printer.auto_archive = True
- printer.external_camera_enabled = False
- printer.external_camera_url = None
- printer.plate_detection_enabled = False
- printer.name = "H2C"
- printer.model = "H2C"
- printer.ip_address = "192.0.2.10"
- printer.access_code = "12345678"
- return printer
- async def _print_again(
- base_dir,
- *,
- earlier,
- project_url="file:///userdata/project_file.gcode.3mf",
- download=None,
- listing=(),
- blocked=False,
- ):
- """Run on_print_start for the reporter's reprint; every lookup misses."""
- printer = _printer()
- def execute_router(stmt, *args, **kwargs):
- sql = str(stmt).lower()
- if "from printers" in sql or "from printer " in sql:
- return MagicMock(
- scalar_one_or_none=MagicMock(return_value=printer),
- scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[printer]))),
- )
- return MagicMock(
- scalar_one_or_none=MagicMock(return_value=None),
- scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))),
- )
- added: list = []
- session = AsyncMock()
- session.__aenter__ = AsyncMock(return_value=session)
- session.__aexit__ = AsyncMock()
- session.execute = AsyncMock(side_effect=execute_router)
- session.commit = AsyncMock()
- session.refresh = AsyncMock()
- session.add = MagicMock(side_effect=added.append)
- state = MagicMock(current_project_url=project_url, sdcard=True, sdcard_reported=True)
- archived = MagicMock(id=99, printer_id=1, status="printing", filename=CARD_FILE)
- service = MagicMock()
- finder = AsyncMock(return_value=earlier)
- copied: dict = {}
- async def archive_print(**kwargs):
- copied["bytes"] = kwargs["source_file"].read_bytes()
- return archived
- service.archive_print = AsyncMock(side_effect=archive_print)
- with ExitStack() as stack:
- session_maker = stack.enter_context(patch("backend.app.main.async_session"))
- notif = stack.enter_context(patch("backend.app.main.notification_service"))
- plug = stack.enter_context(patch("backend.app.main.smart_plug_manager"))
- ws = stack.enter_context(patch("backend.app.main.ws_manager"))
- relay = stack.enter_context(patch("backend.app.main.mqtt_relay"))
- pm = stack.enter_context(patch("backend.app.main.printer_manager"))
- stack.enter_context(
- patch(
- "backend.app.main.download_file_async",
- new=download or AsyncMock(side_effect=FileNotOnPrinterError("550")),
- )
- )
- stack.enter_context(patch("backend.app.main.download_file_try_paths_async", new=AsyncMock(return_value=None)))
- stack.enter_context(patch("backend.app.main.get_cached_3mf", return_value=None))
- stack.enter_context(patch("backend.app.main.cache_3mf_download"))
- stack.enter_context(patch("backend.app.main.peek_plate_index_in_3mf", return_value=3))
- stack.enter_context(
- patch("backend.app.services.bambu_ftp.list_files_async", new=AsyncMock(return_value=list(listing)))
- )
- stack.enter_context(patch("backend.app.main.ftps_handshake_blocked", return_value=blocked))
- stack.enter_context(
- patch("backend.app.main.get_ftp_retry_settings", new=AsyncMock(return_value=(False, 3, 2.0, 30)))
- )
- stack.enter_context(patch("backend.app.main._record_energy_start", new_callable=AsyncMock))
- stack.enter_context(patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock))
- stack.enter_context(patch("backend.app.main._maybe_start_layer_timelapse"))
- stack.enter_context(patch("backend.app.main._capture_timelapse_baseline_at_start", new_callable=AsyncMock))
- stack.enter_context(patch("backend.app.main._schedule_fallback_3mf_retry", new=MagicMock()))
- stack.enter_context(
- patch("backend.app.main._ask_outcome_for_external_print", new=AsyncMock(return_value=False))
- )
- stack.enter_context(
- patch("backend.app.main._drop_archive_of_deleted_printer", new=AsyncMock(return_value=False))
- )
- stack.enter_context(patch("backend.app.main._printer_gone", new=AsyncMock(return_value=False)))
- stack.enter_context(patch("backend.app.main.ArchiveService", return_value=service))
- stack.enter_context(patch("backend.app.main._archived_3mf_for_repeat", new=finder))
- session_maker.return_value = session
- notif.on_print_start = AsyncMock()
- plug.on_print_start = AsyncMock()
- ws.send_print_start = AsyncMock()
- ws.send_archive_updated = AsyncMock()
- ws.send_archive_created = AsyncMock()
- relay.on_print_start = AsyncMock()
- pm.get_status = MagicMock(return_value=state)
- pm.get_printer = MagicMock(return_value=MagicMock(serial_number="TEST3307"))
- from backend.app.main import on_print_start
- await on_print_start(1, {"filename": PLATE_3, "subtask_name": TITLE, "raw_data": {"total_layer_num": 120}})
- fallback = next(
- (r for r in added if isinstance(getattr(r, "extra_data", None), dict) and r.extra_data.get("no_3mf_available")),
- None,
- )
- return SimpleNamespace(service=service, finder=finder, fallback=fallback, copied=copied)
- def _earlier(base_dir, content=b"PK\x03\x04 the first run's 3MF"):
- return SimpleNamespace(id=14, filename=CARD_FILE, file_path=_stored_3mf(base_dir, "14", content))
- @pytest.mark.usefixtures("_clear_dicts")
- class TestPrintAgain:
- @pytest.mark.asyncio
- async def test_the_reported_case_is_archived_with_the_earlier_runs_3mf(self, base_dir):
- run = await _print_again(base_dir, earlier=_earlier(base_dir))
- assert run.fallback is None
- assert run.copied["bytes"] == b"PK\x03\x04 the first run's 3MF"
- kwargs = run.service.archive_print.await_args.kwargs
- # Said where it came from, for whoever reads the archive later.
- assert kwargs["print_data"]["_3mf_from_archive"] == 14
- # Named after the card file, not after the temp copy.
- assert kwargs["original_filename"] == CARD_FILE
- @pytest.mark.asyncio
- async def test_the_copy_is_per_printer(self, base_dir):
- """Two printers reprinting the same job at once must not share one
- temp file: one copy would truncate the other mid-archive."""
- run = await _print_again(base_dir, earlier=_earlier(base_dir))
- assert run.service.archive_print.await_args.kwargs["source_file"].name == f"reprint_1_{CARD_FILE}"
- @pytest.mark.asyncio
- async def test_a_copy_that_fails_leaves_the_fallback_archive(self, base_dir):
- earlier = SimpleNamespace(id=14, filename=CARD_FILE, file_path="archive/gone/" + CARD_FILE)
- run = await _print_again(base_dir, earlier=earlier)
- assert run.fallback is not None
- run.service.archive_print.assert_not_awaited()
- @pytest.mark.asyncio
- async def test_looked_up_by_title_plate_and_layer_count(self, base_dir):
- run = await _print_again(base_dir, earlier=_earlier(base_dir))
- assert run.finder.await_args.args[1:] == (1, TITLE, PLATE_3, 120)
- @pytest.mark.asyncio
- async def test_the_earlier_archive_keeps_its_own_file(self, base_dir):
- earlier = _earlier(base_dir)
- await _print_again(base_dir, earlier=earlier)
- assert (base_dir / earlier.file_path).read_bytes() == b"PK\x03\x04 the first run's 3MF"
- @pytest.mark.asyncio
- async def test_a_name_sweep_that_missed_too(self, base_dir):
- """The reporter's first failure (archive 17): no internal-storage URL,
- a full sweep and directory walk, all missing."""
- run = await _print_again(base_dir, earlier=_earlier(base_dir), project_url=None)
- assert run.fallback is None
- assert run.service.archive_print.await_count == 1
- @pytest.mark.asyncio
- async def test_no_earlier_run_is_a_fallback_archive_as_before(self, base_dir):
- run = await _print_again(base_dir, earlier=None)
- assert run.fallback is not None
- run.service.archive_print.assert_not_awaited()
- @pytest.mark.asyncio
- async def test_not_when_the_card_listed_a_file_for_it(self, base_dir):
- """The directory walk found a file named for the print and only its
- transfer failed: that file is on the card, and the retry fetches it --
- possibly a newer slice than the earlier run."""
- run = await _print_again(
- base_dir,
- earlier=_earlier(base_dir),
- project_url=None,
- download=AsyncMock(return_value=False),
- listing=[{"name": f"{TITLE}.gcode.3mf", "is_directory": False}],
- )
- run.finder.assert_not_awaited()
- assert run.fallback is not None
- @pytest.mark.asyncio
- async def test_a_sweep_path_that_timed_out_is_no_such_evidence(self, base_dir):
- """The retry would try the same names, which this print's file never
- had, so leaving it to the retry left the archive empty for good."""
- run = await _print_again(
- base_dir, earlier=_earlier(base_dir), project_url=None, download=AsyncMock(return_value=False)
- )
- assert run.fallback is None
- assert run.service.archive_print.await_count == 1
- @pytest.mark.asyncio
- async def test_nor_is_a_cool_off(self, base_dir):
- run = await _print_again(base_dir, earlier=_earlier(base_dir), project_url=None, blocked=True)
- assert run.fallback is None
- assert run.service.archive_print.await_count == 1
|