"""Rejecting the wrong plate must not cost the archive its name (#3126). The reporter's X2D was sent a print from Bambu Studio, which filed it on internal eMMC (``"url": "brtc://emmc/..."``). FTPS cannot serve that, but the bounded probe found a *same-named* file at the card's root -- an earlier slice of the same project, plate 4, while the running print was plate 1. #1204's guard caught the contradiction and refused the file, which is right: archiving plate 4's thumbnail, filament and cost against this print is the swap #2957 removed. What it then did with the name was not. The guard asks ``swap_plate_suffix`` for a corrected name and blanked ``subtask_name`` whenever it came back None -- but None covers two unrelated cases, and only one of them is a name that could mislead. ``防雨防虫_模块化_排气口(50_75_80_100管可用)`` carries no ``- Plate N`` suffix at all, so it holds no stale plate number to be wrong about; blanking it dropped the project name too and the row fell through to the gcode_file path, titled ``plate_1``. #1204's own premise is consecutive plates *of the same model*, so the project part of a lagging name is right either way. Fixing that is only half of it, and the other half is why the title lives in its own variable. The name is kept for *display*; every lookup still disowns it, ``_active_prints`` included. Key the row under a name the guard just watched fetch the wrong plate and the cover endpoint -- which downloads that very name for the running print's thumbnail -- hands the bytes to the recovery path, which checks a candidate is a readable 3MF and never which plate it holds. The row would be filled in with the file this branch had just deleted. Pinned here: a name without a plate suffix survives the rejection intact as the title, a name with a stale one still gets its number corrected, and the rejected name keys nothing. """ from unittest.mock import AsyncMock, MagicMock, patch import pytest from backend.app.main import ( _active_prints, _expected_print_creators, _expected_print_registered_at, _expected_prints, _print_ams_mappings, _timelapse_baselines, ) pytestmark = pytest.mark.unit DISPATCH = "/data/Metadata/plate_1.gcode" # The reporter's own subtask_name. Non-ASCII and parenthesised, with no plate # suffix anywhere in it -- exactly the shape that used to be thrown away. PROJECT = "防雨防虫_模块化_排气口(50_75_80_100管可用)" @pytest.fixture(autouse=True) 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 # Every unset MagicMock attribute is truthy, and leaving this one implicit # runs the plate-detection camera grab against a printer that is not there. printer.plate_detection_enabled = False printer.name = "X2D" printer.model = "X2D" printer.ip_address = "172.25.12.149" printer.access_code = "12345678" return printer async def _run_print_start(subtask: str): """Drive on_print_start to the wrong-plate rejection and return the row. Every download succeeds and every 3MF peeks as plate 4, while the dispatch says plate 1 -- so the initial fetch is rejected and no re-download can satisfy the guard either, which is the reporter's sequence. """ 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=f"ftp://{subtask}.gcode.3mf", sdcard=True, sdcard_reported=True, ) with ( patch("backend.app.main.async_session") as session_maker, patch("backend.app.main.notification_service") as notif, patch("backend.app.main.smart_plug_manager") as plug, patch("backend.app.main.ws_manager") as ws, patch("backend.app.main.mqtt_relay") as relay, patch("backend.app.main.printer_manager") as pm, patch("backend.app.main.download_file_async", new=AsyncMock(return_value=True)), patch("backend.app.main.download_file_try_paths_async", new=AsyncMock(return_value=None)), patch("backend.app.main.get_cached_3mf", return_value=None), patch("backend.app.main.cache_3mf_download"), # Plate 4 on the card, plate 1 on the printer -- the mismatch itself. patch("backend.app.main.peek_plate_index_in_3mf", return_value=4), # Imported inside the function, so patching it anywhere else lets the # directory walk open real sockets and the test hangs on connect. patch("backend.app.services.bambu_ftp.list_files_async", new=AsyncMock(return_value=[])), patch("backend.app.main.ftps_handshake_blocked", return_value=False), patch("backend.app.main.get_ftp_retry_settings", new=AsyncMock(return_value=(False, 3, 2.0, 30))), patch("backend.app.main._record_energy_start", new_callable=AsyncMock), patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock), patch("backend.app.main._maybe_start_layer_timelapse"), patch("backend.app.main._capture_timelapse_baseline_at_start", new_callable=AsyncMock), # Real, it would spawn a task that outlives the test by a minute. patch("backend.app.main._schedule_fallback_3mf_retry", new=MagicMock()), ): 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() # Awaited between creating the fallback row and the rest of the # handler: a plain MagicMock raises, and the handler swallows it. 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="TEST3126")) from backend.app.main import on_print_start await on_print_start(1, {"filename": DISPATCH, "subtask_name": subtask}) # Snapshot before the autouse fixture clears it in teardown. keys = {name for (_pid, name) in _active_prints} for row in added: extra = getattr(row, "extra_data", None) if isinstance(extra, dict) and extra.get("no_3mf_available"): return row, keys return None, keys class TestANameWithNoPlateSuffixSurvives: @pytest.mark.asyncio async def test_the_reported_case_keeps_the_project_name(self): """The regression. This row used to be titled ``plate_1``.""" row, _keys = await _run_print_start(PROJECT) assert row is not None assert row.print_name == PROJECT @pytest.mark.asyncio async def test_the_original_subtask_is_still_recorded(self): """Nothing reads this field today -- it is there for support, and a row that records the dispatch path under both its name and its subtask tells whoever reads the bundle nothing about the print.""" row, _keys = await _run_print_start(PROJECT) assert row.extra_data["original_subtask"] == PROJECT @pytest.mark.asyncio async def test_an_ascii_name_too(self): """Nothing here is about the encoding -- any single-plate project name reaches the same branch.""" row, _keys = await _run_print_start("Fan_Shroud") assert row.print_name == "Fan_Shroud" class TestAStalePlateSuffixIsStillCorrected: """#1204's actual fix, which the change above must not undo.""" @pytest.mark.asyncio async def test_the_spaced_form_gets_the_running_plate(self): row, _keys = await _run_print_start("Fan_Shroud - Plate 4") assert row.print_name == "Fan_Shroud - Plate 1" @pytest.mark.asyncio async def test_the_underscored_form_too(self): row, _keys = await _run_print_start("Fan_Shroud_plate_4") assert row.print_name == "Fan_Shroud_plate_1" class TestTheDisownedNameStillFindsNoFiles: """The other half, and the reason the name is kept in its own variable. A name the guard just watched fetch another plate's 3MF must not key ``_active_prints``. The cover endpoint downloads that same name for the running print's thumbnail and offers the bytes to ``try_recover_fallback_archive``, which matches on those keys and hands whatever it gets to ``_recover_fallback_archive`` -- and that checks a candidate is a readable 3MF, never which plate it holds. Key the row under the rejected name and the cover endpoint fills it in with the exact file this branch just deleted, which is #2957's swap coming back in through a different door. """ @pytest.mark.asyncio async def test_the_rejected_name_is_not_registered(self): row, keys = await _run_print_start(PROJECT) assert row.print_name == PROJECT, "the title is the whole point of the fix" assert PROJECT not in keys assert f"{PROJECT}.3mf" not in keys @pytest.mark.asyncio async def test_the_dispatch_path_still_is(self): """Disowning the subtask name must not leave the archive unfindable at print completion -- the gcode_file key is what matches it there.""" _row, keys = await _run_print_start(PROJECT) assert DISPATCH in keys @pytest.mark.asyncio async def test_a_corrected_name_is_registered(self): """#1204's case is different: the swapped name points at the plate that really is running, so a file found under it is the right file.""" _row, keys = await _run_print_start("Fan_Shroud - Plate 4") assert "Fan_Shroud - Plate 1" in keys assert "Fan_Shroud - Plate 4" not in keys