test_runtime_disconnect_gap.py 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
  1. """A printer's time offline is not counted as runtime (DB-C5).
  2. The runtime counter adds the time since its last tick while a printer is
  3. RUNNING. When a printer dropped off mid-print and came back hours later with a
  4. print running, that first tick added the whole gap: hours-based maintenance
  5. came due early. The timestamp is now cleared when a printer this process saw
  6. connected goes offline. A Bambuddy restart is still counted from the stored
  7. timestamp, as before -- those printers have not been seen connected yet.
  8. """
  9. import asyncio
  10. from datetime import datetime, timedelta
  11. from types import SimpleNamespace
  12. from unittest.mock import patch
  13. import pytest
  14. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  15. from backend.app import main as main_module
  16. from backend.app.models.printer import Printer
  17. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  18. @pytest.fixture(autouse=True)
  19. def _forget_seen():
  20. main_module._runtime_seen_connected.clear()
  21. yield
  22. main_module._runtime_seen_connected.clear()
  23. async def _ticks(test_engine, states: list) -> None:
  24. """Run the tracker for one tick per state, then stop it."""
  25. maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  26. feed = iter(states)
  27. current = {"state": None}
  28. sleeps = {"n": 0}
  29. async def fake_sleep(_seconds):
  30. sleeps["n"] += 1
  31. if sleeps["n"] > len(states):
  32. raise asyncio.CancelledError
  33. current["state"] = next(feed)
  34. with (
  35. patch.object(main_module, "async_session", maker),
  36. patch.object(main_module.asyncio, "sleep", fake_sleep),
  37. patch.object(main_module.printer_manager, "get_status", lambda _pid: current["state"]),
  38. ):
  39. try:
  40. await main_module.track_printer_runtime()
  41. except asyncio.CancelledError:
  42. pass
  43. async def _printer(db_session, printer_factory, last_update):
  44. printer = await printer_factory()
  45. printer.runtime_seconds = 1000
  46. printer.last_runtime_update = last_update
  47. await db_session.commit()
  48. return printer
  49. async def test_a_disconnect_clears_the_timestamp(test_engine, db_session, printer_factory):
  50. printer = await _printer(db_session, printer_factory, datetime.utcnow() - timedelta(seconds=60))
  51. running = SimpleNamespace(connected=True, state="RUNNING")
  52. offline = SimpleNamespace(connected=False, state="RUNNING")
  53. await _ticks(test_engine, [running, offline])
  54. await db_session.refresh(printer)
  55. assert printer.last_runtime_update is None
  56. async def test_a_restart_still_counts_the_stored_timestamp(test_engine, db_session, printer_factory):
  57. stored = datetime.utcnow() - timedelta(minutes=30)
  58. printer = await _printer(db_session, printer_factory, stored)
  59. # Still connecting after the restart, then printing.
  60. connecting = SimpleNamespace(connected=False, state="unknown")
  61. running = SimpleNamespace(connected=True, state="RUNNING")
  62. await _ticks(test_engine, [connecting, running])
  63. await db_session.refresh(printer)
  64. assert printer.runtime_seconds >= 1000 + 29 * 60
  65. async def test_a_printer_still_offline_after_the_startup_grace_is_not_credited(
  66. test_engine, db_session, printer_factory, monkeypatch
  67. ):
  68. """It dropped off before the restart and never came back during it: the
  69. stored timestamp is from before the gap, not from a print that ran on."""
  70. monkeypatch.setattr(main_module, "RUNTIME_STARTUP_GRACE_SECONDS", 0)
  71. printer = await _printer(db_session, printer_factory, datetime.utcnow() - timedelta(hours=10))
  72. offline = SimpleNamespace(connected=False, state="unknown")
  73. await _ticks(test_engine, [offline])
  74. await db_session.refresh(printer)
  75. assert printer.last_runtime_update is None
  76. async def test_a_clear_that_failed_is_tried_again(test_engine, db_session, printer_factory):
  77. printer = await _printer(db_session, printer_factory, datetime.utcnow() - timedelta(seconds=60))
  78. running = SimpleNamespace(connected=True, state="RUNNING")
  79. offline = SimpleNamespace(connected=False, state="RUNNING")
  80. real_get = AsyncSession.get
  81. calls = {"n": 0}
  82. async def flaky_get(self, *args, **kwargs):
  83. calls["n"] += 1
  84. if calls["n"] == 1:
  85. raise RuntimeError("database is locked")
  86. return await real_get(self, *args, **kwargs)
  87. with patch.object(AsyncSession, "get", flaky_get):
  88. await _ticks(test_engine, [running, offline, offline])
  89. await db_session.refresh(printer)
  90. assert printer.last_runtime_update is None