Просмотр исходного кода

fix(inventory): filament of a print spanning a restart is no longer counted twice

maziggy 6 дней назад
Родитель
Сommit
5f13d5770c

+ 1 - 0
CHANGELOG.md

@@ -164,6 +164,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **Filament of a print that spans a Bambuddy restart is no longer counted twice** — when the restart found no saved print session, or one of another print, the AMS remaining-% sync raised the spool's used weight during the print, and completion then added the print's full usage on top.
 - **A print whose dispatch failed no longer leaves its cost centre behind** — the next print of the same archive without a cost centre of its own was charged to it, instead of the user or the archive's cost centre.
 - **Which plate of a multi-plate file printed** —
   - Print History logs the filament and cost of the plate that printed. A reprint of another plate of the same file used to log the first plate's estimate.

+ 3 - 0
backend/app/main.py

@@ -2805,6 +2805,9 @@ async def on_ams_change(printer_id: int, ams_data: list):
     # this; computed once, up front, so neither depends on the other having run.
     _unlink_state = printer_manager.get_status(printer_id)
     printing_now = (getattr(_unlink_state, "state", "") or "").upper() in ("RUNNING", "PAUSE")
+    # The session alone is not enough: a restart that finds a stale persisted
+    # session registers none for the print that is running.
+    _print_active = _print_active or printing_now
 
     # MQTT relay - publish AMS change
     try:

+ 84 - 0
backend/tests/integration/test_ams_remain_sync_while_printing.py

@@ -0,0 +1,84 @@
+"""The AMS remain% weight sync stays off while the printer prints.
+
+Usage is deducted precisely at completion from the 3MF; raising weight_used
+from remain% during the print as well charges it twice (#880). The guard used
+to be only the usage tracker's in-memory session, which a restart with a
+stale persisted session never registers.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from backend.app.models.spool import Spool
+from backend.app.models.spool_assignment import SpoolAssignment
+from backend.app.services.usage_tracker import _active_sessions
+
+
+@pytest.fixture(autouse=True)
+def _no_sessions():
+    _active_sessions.clear()
+    yield
+    _active_sessions.clear()
+
+
+async def _weight_after_ams_push(test_engine, db_session, printer_factory, state: str) -> float:
+    from backend.app.main import on_ams_change
+
+    printer = await printer_factory(name="X1C")
+    spool = Spool(material="PLA", label_weight=1000, weight_used=100)
+    db_session.add(spool)
+    await db_session.flush()
+    db_session.add(
+        SpoolAssignment(
+            spool_id=spool.id,
+            printer_id=printer.id,
+            ams_id=0,
+            tray_id=0,
+            fingerprint_color="FF0000FF",
+            fingerprint_type="PLA",
+        )
+    )
+    await db_session.commit()
+    spool_id = spool.id
+
+    ams_data = [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "FF0000FF", "remain": 50}]}]
+    status = MagicMock()
+    status.raw_data = {"ams": {"ams": ams_data}}
+    status.state = state
+    status.connected = True
+
+    real_db = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+    with (
+        patch("backend.app.main.async_session", real_db),
+        patch("backend.app.core.database.async_session", real_db),
+        patch("backend.app.main.printer_manager") as mock_pm,
+        patch("backend.app.main.mqtt_relay") as mock_relay,
+        patch("backend.app.main.ws_manager") as mock_ws,
+    ):
+        mock_pm.get_printer.return_value = MagicMock(name="X1C", serial_number="REMAINTEST")
+        mock_pm.get_status.return_value = status
+        mock_pm.get_client.return_value = MagicMock()
+        mock_pm.get_model.return_value = "X1C"
+        mock_relay.on_ams_change = AsyncMock()
+        mock_ws.send_printer_status = AsyncMock()
+        mock_ws.broadcast = AsyncMock()
+
+        await on_ams_change(printer.id, ams_data)
+
+    db_session.expire_all()
+    return (await db_session.get(Spool, spool_id)).weight_used
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+@pytest.mark.parametrize("state", ["RUNNING", "PAUSE"])
+async def test_no_sync_while_printing_without_a_tracker_session(test_engine, db_session, printer_factory, state):
+    assert await _weight_after_ams_push(test_engine, db_session, printer_factory, state) == 100
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_syncs_when_idle(test_engine, db_session, printer_factory):
+    assert await _weight_after_ams_push(test_engine, db_session, printer_factory, "IDLE") == 500