| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295 |
- """Every print of an archive keeps its own record, start to end (archive redesign P0).
- The archive redesign moves what describes one print -- status, times, plate,
- failure reason, charge -- from the archive onto the run. These tests drive
- real print starts and completions against the database and pin what each run
- records today, so every step of the move is checked against the same outcome.
- They assert on the print log first; what the archive row shows is pinned
- where the archive page reads it today, and changes there are meant to be
- made here on purpose.
- """
- from __future__ import annotations
- import asyncio
- from types import SimpleNamespace
- import pytest
- from sqlalchemy import func, select
- from backend.app import main as main_module
- from backend.app.main import register_expected_print
- from backend.app.models.archive import PrintArchive
- from backend.app.models.finance import CostCenter, WalletTransaction
- from backend.app.models.print_log import PrintLogEntry
- from backend.app.models.print_queue import PrintQueueItem
- from backend.app.models.settings import Settings
- from backend.app.models.user import User
- from backend.tests.integration.test_external_print_confirmation_1898 import ( # noqa: F401
- SUBTASK,
- _clear_print_state,
- _drive_print_start,
- )
- from backend.tests.integration.test_print_complete_end_to_end import _complete as _complete_concurrently
- pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
- FILE = f"{SUBTASK}.gcode.3mf"
- async def _complete(test_engine, printer_id: int, status: str, name: str):
- """``on_print_complete`` with its background work run one task at a time.
- The SQLite test database is one shared connection, so tasks writing at
- the same time fail each other ("cannot commit transaction - SQL
- statements in progress"), which a real database does not. The finish
- photo is left out of the turn-taking: the notification task waits for it.
- """
- from unittest.mock import patch
- from backend.app.core.tasks import spawn_background_task
- turn = asyncio.Lock()
- async def in_turn(coro):
- async with turn:
- return await coro
- def spawn(coro, name=None, **kwargs):
- if name != "background-finish-photo":
- coro = in_turn(coro)
- return spawn_background_task(coro, name=name, **kwargs)
- with patch("backend.app.main.spawn_background_task", side_effect=spawn):
- return await _complete_concurrently(test_engine, printer_id, status, name=name)
- @pytest.fixture(autouse=True)
- def _clean():
- from backend.app.services.printer_manager import printer_manager
- main_module._print_plate_ids.clear()
- main_module._archive_runs_finishing.clear()
- yield
- main_module._print_plate_ids.clear()
- main_module._archive_runs_finishing.clear()
- printer_manager._awaiting_plate_clear.clear()
- @pytest.fixture
- async def farm(db_session, printer_factory):
- """A printer, a user paying from a cost centre, and an archive of a
- multi-plate file printed before."""
- db_session.add_all(
- [Settings(key="capture_finish_photo", value="false"), Settings(key="billing_enabled", value="true")]
- )
- printer = await printer_factory(name="P")
- user = User(username="payer", password_hash="x", is_active=True)
- center = CostCenter(name="Lab", is_active=True, is_private=False)
- db_session.add_all([user, center])
- await db_session.flush()
- archive = PrintArchive(
- printer_id=printer.id,
- filename=FILE,
- file_path=f"archives/test/{FILE}",
- file_size=1,
- print_name=SUBTASK,
- status="completed",
- cost=5.0,
- created_by_id=user.id,
- )
- db_session.add(archive)
- await db_session.commit()
- return SimpleNamespace(printer=printer.id, user=user.id, center=center.id, archive=archive.id)
- async def _run(test_engine, db_session, farm, *, plate: int, status: str, run_id: str) -> int:
- """One queued print of the archive's plate, from dispatch to its end.
- Returns the queue item's id."""
- item = PrintQueueItem(
- printer_id=farm.printer,
- archive_id=farm.archive,
- status="printing",
- position=1,
- plate_id=plate,
- created_by_id=farm.user,
- cost_center_id=farm.center,
- estimated_cost=5.0,
- billing_run_id=run_id,
- )
- db_session.add(item)
- # As the scheduler's dispatch writes it (_start_print).
- (await db_session.get(PrintArchive, farm.archive)).plate_id = plate
- await db_session.commit()
- item_id = item.id
- register_expected_print(farm.printer, FILE, farm.archive, plate_id=plate, cost_center_id=farm.center)
- await _drive_print_start(test_engine, SimpleNamespace(id=farm.printer), download_ok=False)
- await _complete(test_engine, farm.printer, status, name=SUBTASK)
- return item_id
- async def _runs(db_session, archive_id: int) -> list[PrintLogEntry]:
- db_session.expire_all()
- return list(
- (
- await db_session.scalars(
- select(PrintLogEntry).where(PrintLogEntry.archive_id == archive_id).order_by(PrintLogEntry.id)
- )
- ).all()
- )
- async def test_a_queued_print_of_an_archive_is_one_run_on_it(test_engine, db_session, farm):
- item = await _run(test_engine, db_session, farm, plate=1, status="completed", run_id="run-1")
- runs = await _runs(db_session, farm.archive)
- assert [(r.status, r.plate_id, r.queue_item_id, r.printer_id) for r in runs] == [
- ("completed", 1, item, farm.printer)
- ]
- assert runs[0].started_at is not None and runs[0].completed_at is not None
- assert runs[0].completed_at >= runs[0].started_at
- archive = await db_session.get(PrintArchive, farm.archive)
- assert (archive.status, archive.plate_id) == ("completed", 1)
- assert await db_session.scalar(select(func.count()).select_from(PrintArchive)) == 1
- async def test_each_run_keeps_its_own_plate_outcome_and_times(test_engine, db_session, farm):
- """Plate 1 fails, then plate 2 prints: neither run takes the other's."""
- first = await _run(test_engine, db_session, farm, plate=1, status="failed", run_id="run-1")
- second = await _run(test_engine, db_session, farm, plate=2, status="completed", run_id="run-2")
- runs = await _runs(db_session, farm.archive)
- assert [(r.status, r.plate_id, r.queue_item_id) for r in runs] == [
- ("failed", 1, first),
- ("completed", 2, second),
- ]
- assert runs[1].started_at >= runs[0].completed_at, "the second run's start is its own"
- assert runs[1].failure_reason is None, "a completed run carries no reason of the failed one"
- # The archive page shows the latest run.
- archive = await db_session.get(PrintArchive, farm.archive)
- assert (archive.status, archive.plate_id, archive.failure_reason) == ("completed", 2, None)
- async def test_each_run_is_charged_once_under_its_own_run_id(test_engine, db_session, farm):
- await _run(test_engine, db_session, farm, plate=1, status="completed", run_id="run-1")
- await _run(test_engine, db_session, farm, plate=2, status="completed", run_id="run-2")
- charges = (
- await db_session.scalars(
- select(WalletTransaction)
- .where(WalletTransaction.transaction_type == "print_charge")
- .order_by(WalletTransaction.id)
- )
- ).all()
- assert [(c.print_run_id, c.print_archive_id, c.cost_center_id) for c in charges] == [
- ("run-1", farm.archive, farm.center),
- ("run-2", farm.archive, farm.center),
- ]
- async def test_a_print_started_on_the_printer_is_an_archive_with_one_run(test_engine, db_session, printer_factory):
- db_session.add(Settings(key="capture_finish_photo", value="false"))
- await db_session.commit()
- printer_id = (await printer_factory(name="P")).id
- await _drive_print_start(test_engine, SimpleNamespace(id=printer_id), download_ok=False)
- await _complete(test_engine, printer_id, "completed", name=SUBTASK)
- archives = (await db_session.scalars(select(PrintArchive))).all()
- assert len(archives) == 1
- runs = await _runs(db_session, archives[0].id)
- assert [(r.status, r.queue_item_id, r.printer_id) for r in runs] == [("completed", None, printer_id)]
- async def test_a_reported_end_after_an_offline_stop_replaces_the_run(test_engine, db_session, farm, async_client):
- """The stop could not reach the printer and logged the run as cancelled;
- the printer then reports how it really ended. Still one run."""
- from unittest.mock import AsyncMock, patch
- item = PrintQueueItem(
- printer_id=farm.printer,
- archive_id=farm.archive,
- status="printing",
- position=1,
- plate_id=2,
- created_by_id=farm.user,
- billing_run_id="run-1",
- )
- db_session.add(item)
- await db_session.commit()
- register_expected_print(farm.printer, FILE, farm.archive, plate_id=2)
- await _drive_print_start(test_engine, SimpleNamespace(id=farm.printer), download_ok=False)
- 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()
- assert (await async_client.post(f"/api/v1/queue/{item.id}/stop")).status_code == 200
- assert [(r.status, r.plate_id) for r in await _runs(db_session, farm.archive)] == [("cancelled", 2)]
- main_module._active_prints[(farm.printer, FILE)] = farm.archive
- await _complete(test_engine, farm.printer, "completed", name=SUBTASK)
- assert [(r.status, r.plate_id) for r in await _runs(db_session, farm.archive)] == [("completed", 2)]
- async def test_each_run_gets_its_own_energy(test_engine, db_session, farm):
- """The plug's counter at each start and end: the archive card shows the
- first run's, each run its own."""
- from unittest.mock import AsyncMock, MagicMock, patch
- plug = MagicMock(id=1)
- plug.name = "Plug"
- counters = iter([10.5, 20.3])
- async def reading(candidates, read, db):
- return plug, {"total": next(counters)}
- async def started_at(kwh):
- archive = await db_session.get(PrintArchive, farm.archive)
- archive.energy_start_kwh = kwh
- await db_session.commit()
- with (
- patch("backend.app.main.energy_plug_candidates", AsyncMock(return_value=[plug])),
- patch("backend.app.main.select_energy_reading", side_effect=reading),
- patch("backend.app.services.energy_price.print_energy_cost", AsyncMock(return_value=0.1)),
- patch("backend.app.services.energy_price.current_price", AsyncMock(return_value=0.3)),
- ):
- for plate, kwh in ((1, 10.0), (2, 20.0)):
- item = PrintQueueItem(
- printer_id=farm.printer, archive_id=farm.archive, status="printing", position=1, plate_id=plate
- )
- db_session.add(item)
- await db_session.commit()
- register_expected_print(farm.printer, FILE, farm.archive, plate_id=plate)
- await _drive_print_start(test_engine, SimpleNamespace(id=farm.printer), download_ok=False)
- await started_at(kwh)
- await _complete(test_engine, farm.printer, "completed", name=SUBTASK)
- runs = await _runs(db_session, farm.archive)
- assert [(r.plate_id, r.energy_kwh) for r in runs] == [(1, 0.5), (2, pytest.approx(0.3))]
- archive = await db_session.get(PrintArchive, farm.archive)
- assert archive.energy_kwh == 0.5
- async def test_each_run_keeps_its_own_verdict(test_engine, db_session, farm):
- """An answered outcome prompt belongs to its run; the next run asks again."""
- from backend.app.services.print_confirmation import apply_outcome_verdict
- archive = await db_session.get(PrintArchive, farm.archive)
- archive.confirm_requested = True
- await db_session.commit()
- await _run(test_engine, db_session, farm, plate=1, status="completed", run_id="run-1")
- archive = await db_session.get(PrintArchive, farm.archive)
- assert await apply_outcome_verdict(db_session, archive, "reject", source="dialog")
- await db_session.commit()
- await _run(test_engine, db_session, farm, plate=2, status="completed", run_id="run-2")
- runs = await _runs(db_session, farm.archive)
- assert [(r.plate_id, r.user_verdict) for r in runs] == [(1, "reject"), (2, None)]
- archive = await db_session.get(PrintArchive, farm.archive)
- assert archive.user_verdict is None, "the latest run is not judged yet"
|