test_print_lifecycle.py 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  1. """
  2. Integration tests for the full print lifecycle.
  3. These tests verify that:
  4. 1. Print start creates a new archive
  5. 2. Print complete updates archive status
  6. 3. Callbacks are properly executed
  7. 4. Energy tracking works
  8. 5. Notifications are sent
  9. Note: These tests use mocking to avoid database conflicts.
  10. Full end-to-end tests require the actual database setup.
  11. """
  12. import asyncio
  13. import pytest
  14. from datetime import datetime
  15. from unittest.mock import AsyncMock, MagicMock, patch
  16. from sqlalchemy import select
  17. class TestPrintStartLogic:
  18. """Test print start callback logic without database integration."""
  19. @pytest.mark.asyncio
  20. async def test_print_start_calls_notification_service(self, capture_logs):
  21. """Verify on_print_start triggers notification service."""
  22. with patch('backend.app.main.async_session') as mock_session_maker, \
  23. patch('backend.app.main.notification_service') as mock_notif, \
  24. patch('backend.app.main.smart_plug_manager') as mock_plug, \
  25. patch('backend.app.main.ws_manager') as mock_ws:
  26. mock_notif.on_print_start = AsyncMock()
  27. mock_plug.on_print_start = AsyncMock()
  28. mock_ws.send_print_start = AsyncMock()
  29. # Mock the database session
  30. mock_session = AsyncMock()
  31. mock_session.__aenter__ = AsyncMock(return_value=mock_session)
  32. mock_session.__aexit__ = AsyncMock()
  33. mock_session.execute = AsyncMock(return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=None)))
  34. mock_session_maker.return_value = mock_session
  35. from backend.app.main import on_print_start
  36. await on_print_start(1, {
  37. "filename": "/data/Metadata/test.gcode",
  38. "subtask_name": "Test",
  39. })
  40. # Verify WebSocket notification was sent
  41. mock_ws.send_print_start.assert_called_once()
  42. # Verify no import shadowing errors
  43. errors = [r for r in capture_logs.get_errors()
  44. if "cannot access local variable" in str(r.message)]
  45. assert not errors, f"Import shadowing error: {capture_logs.format_errors()}"
  46. class TestPrintCompleteLogic:
  47. """Test print complete callback logic."""
  48. @pytest.mark.asyncio
  49. async def test_print_complete_no_import_errors(self, capture_logs):
  50. """Verify on_print_complete doesn't have import shadowing issues."""
  51. with patch('backend.app.main.async_session') as mock_session_maker, \
  52. patch('backend.app.main.notification_service') as mock_notif, \
  53. patch('backend.app.main.smart_plug_manager') as mock_plug, \
  54. patch('backend.app.main.ws_manager') as mock_ws:
  55. mock_notif.on_print_complete = AsyncMock()
  56. mock_plug.on_print_complete = AsyncMock()
  57. mock_ws.send_print_complete = AsyncMock()
  58. # Mock the database session
  59. mock_session = AsyncMock()
  60. mock_session.__aenter__ = AsyncMock(return_value=mock_session)
  61. mock_session.__aexit__ = AsyncMock()
  62. mock_session.execute = AsyncMock(return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=None)))
  63. mock_session_maker.return_value = mock_session
  64. from backend.app.main import on_print_complete
  65. await on_print_complete(1, {
  66. "status": "completed",
  67. "filename": "/data/Metadata/test.gcode",
  68. "subtask_name": "Test",
  69. "timelapse_was_active": False,
  70. })
  71. # Verify no import shadowing errors - this would have caught the ArchiveService bug
  72. errors = [r for r in capture_logs.get_errors()
  73. if "cannot access local variable" in str(r.message)]
  74. assert not errors, f"Import shadowing error: {capture_logs.format_errors()}"
  75. class TestTimelapseTracking:
  76. """Test timelapse detection during prints."""
  77. @pytest.mark.asyncio
  78. async def test_timelapse_detected_in_same_message_as_print_start(self):
  79. """Verify timelapse is detected when xcam and state come together."""
  80. from backend.app.services.bambu_mqtt import BambuMQTTClient
  81. client = BambuMQTTClient(
  82. ip_address="192.168.1.100",
  83. serial_number="TEST123",
  84. access_code="12345678",
  85. )
  86. client.on_print_start = lambda data: None
  87. # Initial state
  88. client._was_running = False
  89. client._timelapse_during_print = False
  90. # Message with both state and timelapse
  91. client._process_message({
  92. "print": {
  93. "gcode_state": "RUNNING",
  94. "gcode_file": "/data/Metadata/test.gcode",
  95. "subtask_name": "Test",
  96. "xcam": {"timelapse": "enable"},
  97. }
  98. })
  99. assert client._was_running is True
  100. assert client._timelapse_during_print is True, \
  101. "Timelapse should be detected even when xcam is parsed before state"
  102. @pytest.mark.asyncio
  103. async def test_timelapse_flag_included_in_completion_callback(self):
  104. """Verify completion callback receives timelapse_was_active flag."""
  105. from backend.app.services.bambu_mqtt import BambuMQTTClient
  106. client = BambuMQTTClient(
  107. ip_address="192.168.1.100",
  108. serial_number="TEST123",
  109. access_code="12345678",
  110. )
  111. completion_data = {}
  112. def on_complete(data):
  113. completion_data.update(data)
  114. client.on_print_start = lambda data: None
  115. client.on_print_complete = on_complete
  116. # Start with timelapse
  117. client._process_message({
  118. "print": {
  119. "gcode_state": "RUNNING",
  120. "gcode_file": "/data/Metadata/test.gcode",
  121. "subtask_name": "Test",
  122. "xcam": {"timelapse": "enable"},
  123. }
  124. })
  125. # Complete print
  126. client._process_message({
  127. "print": {
  128. "gcode_state": "FINISH",
  129. "gcode_file": "/data/Metadata/test.gcode",
  130. "subtask_name": "Test",
  131. }
  132. })
  133. assert "timelapse_was_active" in completion_data
  134. assert completion_data["timelapse_was_active"] is True
  135. class TestCallbackErrorHandling:
  136. """Test that callback errors are properly logged."""
  137. @pytest.mark.asyncio
  138. async def test_callback_errors_are_logged(self, capture_logs):
  139. """Verify that exceptions in callbacks are logged, not swallowed."""
  140. from backend.app.services.printer_manager import PrinterManager
  141. manager = PrinterManager()
  142. # Set up event loop
  143. loop = asyncio.get_event_loop()
  144. manager.set_event_loop(loop)
  145. # Create a callback that raises an error
  146. error_raised = False
  147. async def failing_callback(printer_id, data):
  148. nonlocal error_raised
  149. error_raised = True
  150. raise ValueError("Test error in callback")
  151. manager.set_print_complete_callback(failing_callback)
  152. # The _schedule_async should log the error
  153. # This is tested indirectly - if exception handling is broken,
  154. # the error would be swallowed silently
  155. class TestNoImportShadowing:
  156. """Verify no import shadowing issues exist in callbacks."""
  157. @pytest.mark.asyncio
  158. async def test_on_print_complete_no_import_errors(self, capture_logs):
  159. """Verify on_print_complete doesn't have import shadowing issues."""
  160. # Import the module to check for syntax/import errors
  161. from backend.app import main
  162. # The ArchiveService should be accessible
  163. from backend.app.services.archive import ArchiveService
  164. # Verify we can instantiate it (would fail with shadowing bug)
  165. assert ArchiveService is not None
  166. # Check logs for any import-related errors
  167. errors = capture_logs.get_errors()
  168. import_errors = [e for e in errors if "import" in str(e.message).lower()
  169. or "local variable" in str(e.message).lower()]
  170. assert not import_errors, f"Import errors found: {import_errors}"