| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230 |
- """Printer wear cost per printing hour (#694).
- Each printer can carry an optional wear cost per printing hour. Every run's
- wear is its logged duration at that rate, written to the print log entry; the
- archive keeps the first run's wear, like energy (#1378). Totals that add up
- filament and energy cost add wear too, and billing keeps charging filament only.
- """
- from datetime import datetime, timedelta
- import pytest
- from httpx import AsyncClient
- from backend.app.models.archive import PrintArchive
- from backend.app.models.print_log import PrintLogEntry
- from backend.app.services.print_log import record_archive_wear, wear_cost_for_run, write_log_entry
- START = datetime(2026, 10, 1, 8, 0, 0)
- class TestWearCostForRun:
- def test_duration_at_the_hourly_rate(self):
- assert wear_cost_for_run(5400, 0.2) == 0.3
- @pytest.mark.parametrize("rate", [None, 0, 0.0, -1.0])
- def test_no_rate_means_no_wear(self, rate):
- assert wear_cost_for_run(3600, rate) is None
- @pytest.mark.parametrize("duration", [None, 0, -60])
- def test_no_measured_duration_means_no_wear(self, duration):
- # A reconciled run logs 0 seconds: its real end time is unknown.
- assert wear_cost_for_run(duration, 0.2) is None
- def test_a_value_that_is_not_a_number_is_ignored(self):
- assert wear_cost_for_run(3600, "0.2") is None
- async def _archive(db_session, **kwargs) -> PrintArchive:
- archive = PrintArchive(
- filename="cube.3mf", file_path="test/cube.3mf", file_size=1000, print_name="Cube", status="completed", **kwargs
- )
- db_session.add(archive)
- await db_session.commit()
- await db_session.refresh(archive)
- return archive
- @pytest.mark.asyncio
- @pytest.mark.integration
- class TestRecordingWear:
- async def test_log_entry_gets_the_runs_wear(self, db_session):
- entry = await write_log_entry(
- db_session,
- status="completed",
- started_at=START,
- completed_at=START + timedelta(hours=2),
- wear_cost_per_hour=0.25,
- )
- assert entry.wear_cost == 0.5
- async def test_reconciled_run_has_no_wear(self, db_session):
- entry = await write_log_entry(
- db_session,
- status="aborted",
- started_at=START,
- completed_at=START + timedelta(days=2),
- wear_cost_per_hour=0.25,
- reconciled=True,
- )
- assert entry.wear_cost is None
- async def test_a_failed_run_still_wears_the_printer(self, db_session):
- entry = await write_log_entry(
- db_session,
- status="failed",
- started_at=START,
- completed_at=START + timedelta(minutes=30),
- wear_cost_per_hour=1.0,
- )
- assert entry.wear_cost == 0.5
- async def test_first_run_sets_the_archives_wear(self, db_session):
- archive = await _archive(db_session)
- entry = await write_log_entry(
- db_session,
- archive_id=archive.id,
- status="completed",
- started_at=START,
- completed_at=START + timedelta(hours=1),
- wear_cost_per_hour=0.4,
- )
- await record_archive_wear(db_session, archive, entry)
- assert archive.wear_cost == 0.4
- async def test_a_reprint_leaves_the_archives_wear_alone(self, db_session):
- archive = await _archive(db_session, wear_cost=0.4)
- db_session.add(PrintLogEntry(archive_id=archive.id, status="completed", wear_cost=0.4))
- await db_session.commit()
- entry = await write_log_entry(
- db_session,
- archive_id=archive.id,
- status="completed",
- started_at=START,
- completed_at=START + timedelta(hours=3),
- wear_cost_per_hour=0.4,
- )
- await record_archive_wear(db_session, archive, entry)
- assert entry.wear_cost == 1.2
- assert archive.wear_cost == 0.4
- async def test_a_printer_without_a_rate_leaves_the_archive_empty(self, db_session):
- archive = await _archive(db_session)
- entry = await write_log_entry(
- db_session,
- archive_id=archive.id,
- status="completed",
- started_at=START,
- completed_at=START + timedelta(hours=1),
- )
- await record_archive_wear(db_session, archive, entry)
- assert archive.wear_cost is None
- @pytest.mark.asyncio
- @pytest.mark.integration
- class TestPrinterSetting:
- async def test_set_read_and_clear(self, async_client: AsyncClient, printer_factory):
- printer = await printer_factory()
- response = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"wear_cost_per_hour": 0.35})
- assert response.status_code == 200
- assert response.json()["wear_cost_per_hour"] == 0.35
- listed = (await async_client.get("/api/v1/printers/")).json()
- assert next(p for p in listed if p["id"] == printer.id)["wear_cost_per_hour"] == 0.35
- response = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"wear_cost_per_hour": None})
- assert response.status_code == 200
- assert response.json()["wear_cost_per_hour"] is None
- async def test_negative_rate_is_rejected(self, async_client: AsyncClient, printer_factory):
- printer = await printer_factory()
- response = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"wear_cost_per_hour": -1})
- assert response.status_code == 422
- @pytest.mark.asyncio
- @pytest.mark.integration
- class TestTotals:
- async def test_stats_total(self, async_client: AsyncClient, db_session):
- db_session.add(PrintLogEntry(printer_id=1, status="completed", duration_seconds=3600, wear_cost=0.3))
- db_session.add(PrintLogEntry(printer_id=1, status="failed", duration_seconds=1800, wear_cost=0.15))
- db_session.add(PrintLogEntry(printer_id=1, status="completed", duration_seconds=3600))
- await db_session.commit()
- stats = (await async_client.get("/api/v1/archives/stats")).json()
- assert stats["total_wear_cost"] == pytest.approx(0.45)
- # Filament cost stays filament only.
- assert stats["total_cost"] == 0
- async def test_print_log_returns_and_sorts_by_wear(self, async_client: AsyncClient, db_session):
- db_session.add(PrintLogEntry(print_name="cheap", status="completed", wear_cost=0.1))
- db_session.add(PrintLogEntry(print_name="dear", status="completed", wear_cost=2.0))
- await db_session.commit()
- response = await async_client.get("/api/v1/print-log/", params={"sort_by": "wear_cost", "sort_dir": "desc"})
- assert response.status_code == 200
- items = response.json()["items"]
- assert [i["print_name"] for i in items[:2]] == ["dear", "cheap"]
- assert items[0]["wear_cost"] == 2.0
- async def test_archive_response_carries_wear(self, async_client: AsyncClient, db_session):
- archive = await _archive(db_session, wear_cost=0.75)
- response = await async_client.get(f"/api/v1/archives/{archive.id}")
- assert response.status_code == 200
- assert response.json()["wear_cost"] == 0.75
- async def test_project_totals_include_wear(self, async_client: AsyncClient, db_session):
- from backend.app.models.project import Project
- project = Project(name="Wear project")
- db_session.add(project)
- await db_session.commit()
- await db_session.refresh(project)
- archive = await _archive(db_session, project_id=project.id)
- db_session.add(PrintLogEntry(archive_id=archive.id, status="completed", cost=1.0, wear_cost=0.5))
- db_session.add(PrintLogEntry(archive_id=archive.id, status="completed", cost=1.0, wear_cost=0.25))
- await db_session.commit()
- stats = (await async_client.get(f"/api/v1/projects/{project.id}")).json()["stats"]
- assert stats["total_wear_cost"] == pytest.approx(0.75)
- assert stats["estimated_cost"] == pytest.approx(2.0)
- async def test_batch_cost_includes_wear(
- self, async_client: AsyncClient, printer_factory, archive_factory, db_session
- ):
- from backend.app.models.print_queue import PrintQueueItem
- printer = await printer_factory()
- archive = await archive_factory(printer.id)
- order = (
- await async_client.post(
- "/api/v1/queue/batches",
- json={"name": "Order", "archive_id": archive.id, "plates": [{"plate_id": 1, "quantity_target": 2}]},
- )
- ).json()
- item = (
- await async_client.post(
- "/api/v1/queue/",
- json={"printer_id": printer.id, "archive_id": archive.id, "batch_id": order["id"], "plate_id": 1},
- )
- ).json()
- queued = await db_session.get(PrintQueueItem, item["id"])
- queued.status = "completed"
- db_session.add(
- PrintLogEntry(
- archive_id=archive.id,
- queue_item_id=item["id"],
- status="completed",
- cost=2.0,
- energy_cost=0.5,
- wear_cost=0.25,
- )
- )
- await db_session.commit()
- result = (await async_client.get(f"/api/v1/queue/batches/{order['id']}")).json()
- assert result["actual_cost"] == pytest.approx(2.75)
|