| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267 |
- """Each print of an archive is announced as its run (archive redesign P3).
- The print and archive events go out before the print's run exists, so they
- cannot name it. ``run/started`` and ``run/completed`` do, once it does: the
- run, its archive, printer and plate, and at the end its outcome and figures.
- Notifications of a print's end carry the archive and run ids too.
- """
- from __future__ import annotations
- from datetime import datetime
- from unittest.mock import AsyncMock, MagicMock, patch
- import pytest
- from sqlalchemy import select
- from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
- from backend.app import main as main_module
- from backend.app.models.archive import PrintArchive
- from backend.app.models.print_log import INCLUDE_OPEN_RUNS, PrintLogEntry
- from backend.app.services.mqtt_relay import MQTTRelayService
- pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
- T1 = datetime(2026, 10, 31, 9)
- @pytest.fixture
- def relay():
- relay = MagicMock(on_run_started=AsyncMock(), on_run_completed=AsyncMock())
- with patch.object(main_module, "mqtt_relay", relay):
- yield relay
- @pytest.fixture
- def sessions(test_engine):
- maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
- with patch.object(main_module, "async_session", maker):
- yield maker
- async def _archive(db_session, printer_id: int) -> int:
- archive = PrintArchive(
- printer_id=printer_id,
- filename="part.3mf",
- print_name="part",
- file_path="archives/test/part.3mf",
- file_size=1,
- status="printing",
- started_at=T1,
- subtask_id="job-1",
- )
- db_session.add(archive)
- await db_session.commit()
- return archive.id
- async def test_a_print_start_announces_its_run_once(db_session, printer_factory, relay, sessions):
- printer = await printer_factory(name="A")
- archive_id = await _archive(db_session, printer.id)
- await main_module._open_run_for(archive_id, printer.id, "job-1")
- # A restart resuming the print finds the run it opened.
- await main_module._open_run_for(archive_id, printer.id, "job-1")
- relay.on_run_started.assert_awaited_once()
- run, archive, serial, name = relay.on_run_started.await_args.args
- opened = await db_session.scalar(
- select(PrintLogEntry.id)
- .where(PrintLogEntry.archive_id == archive_id)
- .execution_options(**{INCLUDE_OPEN_RUNS: True})
- )
- assert (run.id, archive.id, serial, name) == (opened, archive_id, printer.serial_number, "A")
- async def test_a_print_end_announces_its_run(db_session, printer_factory, relay, sessions):
- printer = await printer_factory(name="A")
- archive_id = await _archive(db_session, printer.id)
- run = PrintLogEntry(archive_id=archive_id, printer_id=printer.id, status="failed", started_at=T1)
- db_session.add(run)
- await db_session.commit()
- await main_module._publish_run_completed(run.id, printer.id)
- relay.on_run_completed.assert_awaited_once()
- published, archive, serial, _ = relay.on_run_completed.await_args.args
- assert (published.id, archive.id, serial) == (run.id, archive_id, printer.serial_number)
- async def test_no_run_written_announces_nothing(relay, sessions):
- await main_module._publish_run_completed(None, 1)
- relay.on_run_completed.assert_not_awaited()
- def _relay() -> MQTTRelayService:
- service = MQTTRelayService()
- service.enabled = True
- service.connected = True
- service.topic_prefix = "bambuddy"
- service._publish = MagicMock()
- return service
- async def test_the_run_payloads():
- run = PrintLogEntry(
- id=7,
- archive_id=3,
- printer_id=2,
- plate_id=2,
- status="completed",
- started_at=T1,
- completed_at=T1.replace(hour=10),
- duration_seconds=3600,
- filament_used_grams=12.5,
- cost=0.4,
- print_name=None,
- )
- archive = PrintArchive(id=3, print_name="part", filename="part.3mf", file_path="", file_size=1)
- service = _relay()
- await service.on_run_started(run, archive, "SERIAL", "A")
- await service.on_run_completed(run, archive, "SERIAL", "A")
- (started_topic, started), (completed_topic, completed) = (c.args for c in service._publish.call_args_list)
- assert started_topic == "bambuddy/printers/SERIAL/run/started"
- assert {k: started[k] for k in ("run_id", "archive_id", "printer_id", "plate_id", "print_name")} == {
- "run_id": 7,
- "archive_id": 3,
- "printer_id": 2,
- "plate_id": 2,
- "print_name": "part",
- }
- assert completed_topic == "bambuddy/printers/SERIAL/run/completed"
- assert (completed["status"], completed["duration_seconds"], completed["filament_used_grams"]) == (
- "completed",
- 3600,
- 12.5,
- )
- async def test_a_print_end_notification_names_its_archive_and_run(db_session):
- from backend.app.services.notification_service import NotificationService
- service = NotificationService()
- provider = MagicMock(name="provider")
- with (
- patch.object(service, "_get_providers_for_event", AsyncMock(return_value=[provider])),
- patch.object(service, "_build_message_from_template", AsyncMock(return_value=("t", "m"))),
- patch.object(service, "_send_to_providers", AsyncMock()) as send,
- ):
- await service.on_print_complete(
- 1, "A", "completed", {"filename": "part.3mf"}, db_session, archive_data={"archive_id": 3, "run_id": 7}
- )
- variables = send.await_args.kwargs["variables"]
- assert (variables["archive_id"], variables["run_id"]) == ("3", "7")
- async def test_a_run_replaced_by_the_next_start_is_announced_gone(db_session, printer_factory, relay, sessions):
- """Its print ended unseen; a new print opened the printer's next run."""
- relay.on_run_discarded = AsyncMock()
- printer = await printer_factory(name="A")
- archive_id = await _archive(db_session, printer.id)
- await main_module._open_run_for(archive_id, printer.id, "job-1")
- first = await db_session.scalar(
- select(PrintLogEntry.id)
- .where(PrintLogEntry.archive_id == archive_id)
- .execution_options(**{INCLUDE_OPEN_RUNS: True})
- )
- archive = await db_session.get(PrintArchive, archive_id)
- archive.started_at, archive.subtask_id = T1.replace(hour=11), "job-2"
- await db_session.commit()
- await main_module._open_run_for(archive_id, printer.id, "job-2")
- relay.on_run_discarded.assert_awaited_once()
- assert relay.on_run_discarded.await_args.args[0].id == first
- assert relay.on_run_started.await_count == 2
- async def test_discarding_announces_each_run(db_session, printer_factory, relay, sessions):
- from backend.app.models.print_log import RUN_OPEN
- from backend.app.services.print_log import discard_open_runs
- relay.on_run_discarded = AsyncMock()
- printer = await printer_factory(name="A")
- archive_id = await _archive(db_session, printer.id)
- run = PrintLogEntry(archive_id=archive_id, printer_id=printer.id, status=RUN_OPEN, started_at=T1)
- db_session.add(run)
- await db_session.commit()
- run_id = run.id
- discarded = await discard_open_runs(db_session, printer.id)
- await db_session.commit()
- await main_module._announce_discarded_runs(discarded, printer.id)
- assert [r.id for r in discarded] == [run_id]
- relay.on_run_discarded.assert_awaited_once()
- assert relay.on_run_discarded.await_args.args[1] == printer.serial_number
- async def test_times_are_utc_and_an_unmeasured_duration_says_so():
- run = PrintLogEntry(
- id=7,
- archive_id=3,
- printer_id=2,
- status="completed",
- started_at=T1,
- completed_at=T1.replace(hour=10),
- duration_seconds=0,
- duration_unmeasured=True,
- )
- service = _relay()
- await service.on_run_completed(run, None, "SERIAL", "A")
- payload = service._publish.call_args.args[1]
- assert (payload["started_at"], payload["completed_at"]) == (
- "2026-10-31T09:00:00+00:00",
- "2026-10-31T10:00:00+00:00",
- )
- assert payload["duration_unmeasured"] is True
- async def test_the_kill_switch_notification_names_the_print(db_session, printer_factory, sessions):
- from backend.app.models.print_log import RUN_OPEN
- printer = await printer_factory(name="A")
- archive_id = await _archive(db_session, printer.id)
- run = PrintLogEntry(archive_id=archive_id, printer_id=printer.id, status=RUN_OPEN, started_at=T1)
- db_session.add(run)
- await db_session.commit()
- run_id = run.id
- with patch.object(main_module, "notification_service") as notify:
- notify.on_print_complete = AsyncMock()
- assert await main_module._send_kill_switch_provider_notification(printer.id, "A", {"filename": "part.3mf"})
- assert notify.on_print_complete.await_args.kwargs["archive_data"] == {"archive_id": archive_id, "run_id": run_id}
- async def test_a_stop_while_the_printer_is_offline_announces_the_run_end(
- async_client, db_session, printer_factory, relay
- ):
- from backend.app.models.print_log import RUN_OPEN
- from backend.app.models.print_queue import PrintQueueItem
- printer = await printer_factory(name="A")
- archive_id = await _archive(db_session, printer.id)
- item = PrintQueueItem(printer_id=printer.id, archive_id=archive_id, status="printing", position=1)
- db_session.add(item)
- run = PrintLogEntry(archive_id=archive_id, printer_id=printer.id, status=RUN_OPEN, started_at=T1)
- db_session.add(run)
- await db_session.commit()
- run_id, item_id = run.id, item.id
- with (
- patch("backend.app.services.print_control.stop_print_by_user", return_value=False),
- patch("backend.app.api.routes.print_queue.ws_manager") as ws,
- ):
- ws.send_archive_updated = AsyncMock()
- response = await async_client.post(f"/api/v1/queue/{item_id}/stop")
- assert response.status_code == 200, response.text
- relay.on_run_completed.assert_awaited_once()
- assert relay.on_run_completed.await_args.args[0].id == run_id
|