mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-20 17:50:12 +02:00
Add notification for no spool assigned for active trays, improve usage tracker logic in edge cases (#789)
Add notification for no spool assigned for active trays, improve usage tracker logic in edge cases (#789)
This commit is contained in:
@@ -427,6 +427,7 @@ def notification_provider_factory(db_session):
|
||||
"on_print_failed": True,
|
||||
"on_print_stopped": True,
|
||||
"on_print_progress": False,
|
||||
"on_print_missing_spool_assignment": False,
|
||||
"on_printer_offline": False,
|
||||
"on_printer_error": False,
|
||||
"on_filament_low": False,
|
||||
|
||||
@@ -391,6 +391,42 @@ class TestNotificationsAPI:
|
||||
assert result["on_bed_cooled"] is False
|
||||
assert result["on_first_layer_complete"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_create_provider_with_missing_spool_assignment_toggle(self, async_client: AsyncClient):
|
||||
"""Verify missing spool assignment toggle persists on create."""
|
||||
data = {
|
||||
"name": "Missing Spool Assignment Test",
|
||||
"provider_type": "ntfy",
|
||||
"config": {"server": "https://ntfy.sh", "topic": "test"},
|
||||
"on_print_missing_spool_assignment": True,
|
||||
}
|
||||
|
||||
response = await async_client.post("/api/v1/notifications/", json=data)
|
||||
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["on_print_missing_spool_assignment"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_update_missing_spool_assignment_toggle(
|
||||
self, async_client: AsyncClient, notification_provider_factory, db_session
|
||||
):
|
||||
"""CRITICAL: Verify missing spool assignment toggle persists correctly."""
|
||||
provider = await notification_provider_factory(on_print_missing_spool_assignment=False)
|
||||
|
||||
response = await async_client.patch(
|
||||
f"/api/v1/notifications/{provider.id}",
|
||||
json={"on_print_missing_spool_assignment": True},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["on_print_missing_spool_assignment"] is True
|
||||
|
||||
response = await async_client.get(f"/api/v1/notifications/{provider.id}")
|
||||
assert response.json()["on_print_missing_spool_assignment"] is True
|
||||
|
||||
|
||||
class TestNotificationTemplatesAPI:
|
||||
"""Integration tests for /api/v1/notification-templates/ endpoints."""
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Unit tests for spool assignment notification service."""
|
||||
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.services.spool_assignment_notifications import notify_missing_spool_assignments_on_print_start
|
||||
|
||||
|
||||
class _FakeAssignmentsResult:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def fetchall(self):
|
||||
return self._rows
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self, printer_name: str, assignments: list[SimpleNamespace]):
|
||||
self._printer = SimpleNamespace(name=printer_name)
|
||||
self._assignments = assignments
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
async def get(self, model, key):
|
||||
return self._printer
|
||||
|
||||
async def execute(self, statement):
|
||||
return _FakeAssignmentsResult(self._assignments)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_assignment_broadcasts_websocket_event_and_push_notification():
|
||||
"""When a mapped tray is unassigned, service emits websocket and notification events."""
|
||||
logger = logging.getLogger(__name__)
|
||||
data = {
|
||||
"ams_mapping": [1],
|
||||
"raw_data": {},
|
||||
}
|
||||
|
||||
# Assignment exists for A1 (global tray 0), but print uses A2 (global tray 1).
|
||||
assignments = [SimpleNamespace(ams_id=0, tray_id=0)]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.app.services.spool_assignment_notifications.async_session",
|
||||
return_value=_FakeSession("Printer A", assignments),
|
||||
),
|
||||
patch("backend.app.services.spool_assignment_notifications.printer_manager.get_status", return_value=None),
|
||||
patch(
|
||||
"backend.app.services.spool_assignment_notifications.ws_manager.send_missing_spool_assignment",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_ws,
|
||||
patch(
|
||||
"backend.app.services.spool_assignment_notifications.notification_service.on_print_missing_spool_assignment",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_notify,
|
||||
):
|
||||
await notify_missing_spool_assignments_on_print_start(1, data, logger)
|
||||
|
||||
mock_ws.assert_awaited_once()
|
||||
ws_kwargs = mock_ws.await_args.kwargs
|
||||
assert ws_kwargs["printer_id"] == 1
|
||||
assert ws_kwargs["printer_name"] == "Printer A"
|
||||
assert ws_kwargs["missing_slots"] == [{"slot": "A2", "profile": "Unknown", "color": "Unknown"}]
|
||||
|
||||
mock_notify.assert_awaited_once()
|
||||
notify_kwargs = mock_notify.await_args.kwargs
|
||||
assert notify_kwargs["printer_id"] == 1
|
||||
assert notify_kwargs["printer_name"] == "Printer A"
|
||||
assert notify_kwargs["missing_slots"] == [{"slot": "A2", "profile": "Unknown", "color": "Unknown"}]
|
||||
@@ -32,13 +32,14 @@ def _make_spool(*, id=1, label_weight=1000, weight_used=0, tag_uid=None, tray_uu
|
||||
return spool
|
||||
|
||||
|
||||
def _make_assignment(*, spool_id=1, printer_id=1, ams_id=0, tray_id=0):
|
||||
def _make_assignment(*, spool_id=1, printer_id=1, ams_id=0, tray_id=0, created_at=None):
|
||||
"""Create a mock SpoolAssignment object."""
|
||||
assignment = MagicMock()
|
||||
assignment.spool_id = spool_id
|
||||
assignment.printer_id = printer_id
|
||||
assignment.ams_id = ams_id
|
||||
assignment.tray_id = tray_id
|
||||
assignment.created_at = created_at or datetime.now(timezone.utc)
|
||||
return assignment
|
||||
|
||||
|
||||
@@ -570,10 +571,11 @@ class TestSpoolAssignmentSnapshot:
|
||||
ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]
|
||||
pm = _make_printer_manager(_make_printer_state(ams_data))
|
||||
|
||||
# db only returns spool (NO assignment query)
|
||||
# db returns no live assignment, then spool from snapshot spool_id
|
||||
db = AsyncMock()
|
||||
db.execute = AsyncMock(
|
||||
side_effect=[
|
||||
MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
|
||||
MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
|
||||
]
|
||||
)
|
||||
@@ -657,13 +659,15 @@ class TestSpoolAssignmentSnapshot:
|
||||
|
||||
filament_usage = [{"slot_id": 1, "used_g": 14.2, "type": "PLA", "color": "#FF0000"}]
|
||||
|
||||
# db: archive, queue_item(None), spool, then cost aggregation queries
|
||||
# db: archive, queue_item(None), live assignment(None), spool,
|
||||
# then cost aggregation queries
|
||||
# NOTE: No assignment in db — it was deleted by on_ams_change mid-print!
|
||||
db = AsyncMock()
|
||||
db.execute = AsyncMock(
|
||||
side_effect=[
|
||||
MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
|
||||
MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
|
||||
MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
|
||||
MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
|
||||
# Cost aggregation: sum query (uses .scalar()), archive lookup
|
||||
MagicMock(scalar=MagicMock(return_value=0)),
|
||||
|
||||
@@ -5,7 +5,7 @@ AMS remain% delta as fallback, per-layer gcode for partial prints,
|
||||
slot-to-tray mapping resolution, and notification variable formatting.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -327,6 +327,107 @@ class TestOnPrintComplete:
|
||||
class TestTrackFrom3mf:
|
||||
"""Tests for _track_from_3mf() — per-layer, linear scaling, and slot mapping."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prefers_live_assignment_when_reassigned_mid_print(self):
|
||||
"""If tray assignment changed during print, track usage on the new spool."""
|
||||
spool_old = _make_spool(spool_id=1, label_weight=1000)
|
||||
spool_new = _make_spool(spool_id=2, label_weight=1000)
|
||||
archive = _make_archive(archive_id=80)
|
||||
|
||||
live_assignment = _make_assignment(spool_id=2, ams_id=0, tray_id=0)
|
||||
started_at = datetime.now(timezone.utc)
|
||||
live_assignment.created_at = started_at + timedelta(seconds=5)
|
||||
|
||||
# db: archive, queue_item(None), live assignment lookup, spool_new lookup
|
||||
db = _mock_db_sequential([archive, None, live_assignment, spool_new])
|
||||
|
||||
printer_manager = MagicMock()
|
||||
printer_manager.get_status.return_value = SimpleNamespace(
|
||||
progress=100,
|
||||
layer_num=50,
|
||||
tray_now=0,
|
||||
)
|
||||
|
||||
filament_usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": ""}]
|
||||
handled_trays: set[tuple[int, int]] = set()
|
||||
|
||||
with (
|
||||
patch("backend.app.core.config.settings") as mock_settings,
|
||||
patch(
|
||||
"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
|
||||
return_value=filament_usage,
|
||||
),
|
||||
):
|
||||
mock_settings.base_dir = MagicMock()
|
||||
mock_path = MagicMock()
|
||||
mock_path.exists.return_value = True
|
||||
mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
|
||||
|
||||
results = await _track_from_3mf(
|
||||
printer_id=1,
|
||||
archive_id=80,
|
||||
status="completed",
|
||||
print_name="MidPrintReassign",
|
||||
handled_trays=handled_trays,
|
||||
printer_manager=printer_manager,
|
||||
db=db,
|
||||
spool_assignments={(0, 0): spool_old.id},
|
||||
print_started_at=started_at,
|
||||
)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0]["spool_id"] == spool_new.id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keeps_snapshot_when_live_assignment_predates_print(self):
|
||||
"""If live assignment predates print start, preserve snapshot spool mapping."""
|
||||
spool_old = _make_spool(spool_id=1, label_weight=1000)
|
||||
archive = _make_archive(archive_id=81)
|
||||
|
||||
live_assignment = _make_assignment(spool_id=2, ams_id=0, tray_id=0)
|
||||
started_at = datetime.now(timezone.utc)
|
||||
live_assignment.created_at = started_at - timedelta(seconds=5)
|
||||
|
||||
# db: archive, queue_item(None), live assignment lookup, spool_old lookup
|
||||
db = _mock_db_sequential([archive, None, live_assignment, spool_old])
|
||||
|
||||
printer_manager = MagicMock()
|
||||
printer_manager.get_status.return_value = SimpleNamespace(
|
||||
progress=100,
|
||||
layer_num=50,
|
||||
tray_now=0,
|
||||
)
|
||||
|
||||
filament_usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": ""}]
|
||||
handled_trays: set[tuple[int, int]] = set()
|
||||
|
||||
with (
|
||||
patch("backend.app.core.config.settings") as mock_settings,
|
||||
patch(
|
||||
"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
|
||||
return_value=filament_usage,
|
||||
),
|
||||
):
|
||||
mock_settings.base_dir = MagicMock()
|
||||
mock_path = MagicMock()
|
||||
mock_path.exists.return_value = True
|
||||
mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
|
||||
|
||||
results = await _track_from_3mf(
|
||||
printer_id=1,
|
||||
archive_id=81,
|
||||
status="completed",
|
||||
print_name="SnapshotPreserved",
|
||||
handled_trays=handled_trays,
|
||||
printer_manager=printer_manager,
|
||||
db=db,
|
||||
spool_assignments={(0, 0): spool_old.id},
|
||||
print_started_at=started_at,
|
||||
)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0]["spool_id"] == spool_old.id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_linear_fallback_for_partial_print(self):
|
||||
"""Falls back to linear scaling when gcode layer data unavailable."""
|
||||
|
||||
Reference in New Issue
Block a user