|
|
@@ -0,0 +1,120 @@
|
|
|
+"""A printer's time offline is not counted as runtime (DB-C5).
|
|
|
+
|
|
|
+The runtime counter adds the time since its last tick while a printer is
|
|
|
+RUNNING. When a printer dropped off mid-print and came back hours later with a
|
|
|
+print running, that first tick added the whole gap: hours-based maintenance
|
|
|
+came due early. The timestamp is now cleared when a printer this process saw
|
|
|
+connected goes offline. A Bambuddy restart is still counted from the stored
|
|
|
+timestamp, as before -- those printers have not been seen connected yet.
|
|
|
+"""
|
|
|
+
|
|
|
+import asyncio
|
|
|
+from datetime import datetime, timedelta
|
|
|
+from types import SimpleNamespace
|
|
|
+from unittest.mock import patch
|
|
|
+
|
|
|
+import pytest
|
|
|
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
|
+
|
|
|
+from backend.app import main as main_module
|
|
|
+from backend.app.models.printer import Printer
|
|
|
+
|
|
|
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
|
|
|
+
|
|
|
+
|
|
|
+@pytest.fixture(autouse=True)
|
|
|
+def _forget_seen():
|
|
|
+ main_module._runtime_seen_connected.clear()
|
|
|
+ yield
|
|
|
+ main_module._runtime_seen_connected.clear()
|
|
|
+
|
|
|
+
|
|
|
+async def _ticks(test_engine, states: list) -> None:
|
|
|
+ """Run the tracker for one tick per state, then stop it."""
|
|
|
+ maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
|
|
|
+ feed = iter(states)
|
|
|
+ current = {"state": None}
|
|
|
+ sleeps = {"n": 0}
|
|
|
+
|
|
|
+ async def fake_sleep(_seconds):
|
|
|
+ sleeps["n"] += 1
|
|
|
+ if sleeps["n"] > len(states):
|
|
|
+ raise asyncio.CancelledError
|
|
|
+ current["state"] = next(feed)
|
|
|
+
|
|
|
+ with (
|
|
|
+ patch.object(main_module, "async_session", maker),
|
|
|
+ patch.object(main_module.asyncio, "sleep", fake_sleep),
|
|
|
+ patch.object(main_module.printer_manager, "get_status", lambda _pid: current["state"]),
|
|
|
+ ):
|
|
|
+ try:
|
|
|
+ await main_module.track_printer_runtime()
|
|
|
+ except asyncio.CancelledError:
|
|
|
+ pass
|
|
|
+
|
|
|
+
|
|
|
+async def _printer(db_session, printer_factory, last_update):
|
|
|
+ printer = await printer_factory()
|
|
|
+ printer.runtime_seconds = 1000
|
|
|
+ printer.last_runtime_update = last_update
|
|
|
+ await db_session.commit()
|
|
|
+ return printer
|
|
|
+
|
|
|
+
|
|
|
+async def test_a_disconnect_clears_the_timestamp(test_engine, db_session, printer_factory):
|
|
|
+ printer = await _printer(db_session, printer_factory, datetime.utcnow() - timedelta(seconds=60))
|
|
|
+ running = SimpleNamespace(connected=True, state="RUNNING")
|
|
|
+ offline = SimpleNamespace(connected=False, state="RUNNING")
|
|
|
+
|
|
|
+ await _ticks(test_engine, [running, offline])
|
|
|
+
|
|
|
+ await db_session.refresh(printer)
|
|
|
+ assert printer.last_runtime_update is None
|
|
|
+
|
|
|
+
|
|
|
+async def test_a_restart_still_counts_the_stored_timestamp(test_engine, db_session, printer_factory):
|
|
|
+ stored = datetime.utcnow() - timedelta(minutes=30)
|
|
|
+ printer = await _printer(db_session, printer_factory, stored)
|
|
|
+ # Still connecting after the restart, then printing.
|
|
|
+ connecting = SimpleNamespace(connected=False, state="unknown")
|
|
|
+ running = SimpleNamespace(connected=True, state="RUNNING")
|
|
|
+
|
|
|
+ await _ticks(test_engine, [connecting, running])
|
|
|
+
|
|
|
+ await db_session.refresh(printer)
|
|
|
+ assert printer.runtime_seconds >= 1000 + 29 * 60
|
|
|
+
|
|
|
+
|
|
|
+async def test_a_printer_still_offline_after_the_startup_grace_is_not_credited(
|
|
|
+ test_engine, db_session, printer_factory, monkeypatch
|
|
|
+):
|
|
|
+ """It dropped off before the restart and never came back during it: the
|
|
|
+ stored timestamp is from before the gap, not from a print that ran on."""
|
|
|
+ monkeypatch.setattr(main_module, "RUNTIME_STARTUP_GRACE_SECONDS", 0)
|
|
|
+ printer = await _printer(db_session, printer_factory, datetime.utcnow() - timedelta(hours=10))
|
|
|
+ offline = SimpleNamespace(connected=False, state="unknown")
|
|
|
+
|
|
|
+ await _ticks(test_engine, [offline])
|
|
|
+
|
|
|
+ await db_session.refresh(printer)
|
|
|
+ assert printer.last_runtime_update is None
|
|
|
+
|
|
|
+
|
|
|
+async def test_a_clear_that_failed_is_tried_again(test_engine, db_session, printer_factory):
|
|
|
+ printer = await _printer(db_session, printer_factory, datetime.utcnow() - timedelta(seconds=60))
|
|
|
+ running = SimpleNamespace(connected=True, state="RUNNING")
|
|
|
+ offline = SimpleNamespace(connected=False, state="RUNNING")
|
|
|
+ real_get = AsyncSession.get
|
|
|
+ calls = {"n": 0}
|
|
|
+
|
|
|
+ async def flaky_get(self, *args, **kwargs):
|
|
|
+ calls["n"] += 1
|
|
|
+ if calls["n"] == 1:
|
|
|
+ raise RuntimeError("database is locked")
|
|
|
+ return await real_get(self, *args, **kwargs)
|
|
|
+
|
|
|
+ with patch.object(AsyncSession, "get", flaky_get):
|
|
|
+ await _ticks(test_engine, [running, offline, offline])
|
|
|
+
|
|
|
+ await db_session.refresh(printer)
|
|
|
+ assert printer.last_runtime_update is None
|