test_wear_cost_694.py 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. """Printer wear cost per printing hour (#694).
  2. Each printer can carry an optional wear cost per printing hour. Every run's
  3. wear is its logged duration at that rate, written to the print log entry; the
  4. archive keeps the first run's wear, like energy (#1378). Totals that add up
  5. filament and energy cost add wear too, and billing keeps charging filament only.
  6. """
  7. from datetime import datetime, timedelta
  8. import pytest
  9. from httpx import AsyncClient
  10. from backend.app.models.archive import PrintArchive
  11. from backend.app.models.print_log import PrintLogEntry
  12. from backend.app.services.print_log import record_archive_wear, wear_cost_for_run, write_log_entry
  13. START = datetime(2026, 10, 1, 8, 0, 0)
  14. class TestWearCostForRun:
  15. def test_duration_at_the_hourly_rate(self):
  16. assert wear_cost_for_run(5400, 0.2) == 0.3
  17. @pytest.mark.parametrize("rate", [None, 0, 0.0, -1.0])
  18. def test_no_rate_means_no_wear(self, rate):
  19. assert wear_cost_for_run(3600, rate) is None
  20. @pytest.mark.parametrize("duration", [None, 0, -60])
  21. def test_no_measured_duration_means_no_wear(self, duration):
  22. # A reconciled run logs 0 seconds: its real end time is unknown.
  23. assert wear_cost_for_run(duration, 0.2) is None
  24. def test_a_value_that_is_not_a_number_is_ignored(self):
  25. assert wear_cost_for_run(3600, "0.2") is None
  26. async def _archive(db_session, **kwargs) -> PrintArchive:
  27. archive = PrintArchive(
  28. filename="cube.3mf", file_path="test/cube.3mf", file_size=1000, print_name="Cube", status="completed", **kwargs
  29. )
  30. db_session.add(archive)
  31. await db_session.commit()
  32. await db_session.refresh(archive)
  33. return archive
  34. @pytest.mark.asyncio
  35. @pytest.mark.integration
  36. class TestRecordingWear:
  37. async def test_log_entry_gets_the_runs_wear(self, db_session):
  38. entry = await write_log_entry(
  39. db_session,
  40. status="completed",
  41. started_at=START,
  42. completed_at=START + timedelta(hours=2),
  43. wear_cost_per_hour=0.25,
  44. )
  45. assert entry.wear_cost == 0.5
  46. async def test_reconciled_run_has_no_wear(self, db_session):
  47. entry = await write_log_entry(
  48. db_session,
  49. status="aborted",
  50. started_at=START,
  51. completed_at=START + timedelta(days=2),
  52. wear_cost_per_hour=0.25,
  53. reconciled=True,
  54. )
  55. assert entry.wear_cost is None
  56. async def test_a_failed_run_still_wears_the_printer(self, db_session):
  57. entry = await write_log_entry(
  58. db_session,
  59. status="failed",
  60. started_at=START,
  61. completed_at=START + timedelta(minutes=30),
  62. wear_cost_per_hour=1.0,
  63. )
  64. assert entry.wear_cost == 0.5
  65. async def test_first_run_sets_the_archives_wear(self, db_session):
  66. archive = await _archive(db_session)
  67. entry = await write_log_entry(
  68. db_session,
  69. archive_id=archive.id,
  70. status="completed",
  71. started_at=START,
  72. completed_at=START + timedelta(hours=1),
  73. wear_cost_per_hour=0.4,
  74. )
  75. await record_archive_wear(db_session, archive, entry)
  76. assert archive.wear_cost == 0.4
  77. async def test_a_reprint_leaves_the_archives_wear_alone(self, db_session):
  78. archive = await _archive(db_session, wear_cost=0.4)
  79. db_session.add(PrintLogEntry(archive_id=archive.id, status="completed", wear_cost=0.4))
  80. await db_session.commit()
  81. entry = await write_log_entry(
  82. db_session,
  83. archive_id=archive.id,
  84. status="completed",
  85. started_at=START,
  86. completed_at=START + timedelta(hours=3),
  87. wear_cost_per_hour=0.4,
  88. )
  89. await record_archive_wear(db_session, archive, entry)
  90. assert entry.wear_cost == 1.2
  91. assert archive.wear_cost == 0.4
  92. async def test_a_printer_without_a_rate_leaves_the_archive_empty(self, db_session):
  93. archive = await _archive(db_session)
  94. entry = await write_log_entry(
  95. db_session,
  96. archive_id=archive.id,
  97. status="completed",
  98. started_at=START,
  99. completed_at=START + timedelta(hours=1),
  100. )
  101. await record_archive_wear(db_session, archive, entry)
  102. assert archive.wear_cost is None
  103. @pytest.mark.asyncio
  104. @pytest.mark.integration
  105. class TestPrinterSetting:
  106. async def test_set_read_and_clear(self, async_client: AsyncClient, printer_factory):
  107. printer = await printer_factory()
  108. response = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"wear_cost_per_hour": 0.35})
  109. assert response.status_code == 200
  110. assert response.json()["wear_cost_per_hour"] == 0.35
  111. listed = (await async_client.get("/api/v1/printers/")).json()
  112. assert next(p for p in listed if p["id"] == printer.id)["wear_cost_per_hour"] == 0.35
  113. response = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"wear_cost_per_hour": None})
  114. assert response.status_code == 200
  115. assert response.json()["wear_cost_per_hour"] is None
  116. async def test_negative_rate_is_rejected(self, async_client: AsyncClient, printer_factory):
  117. printer = await printer_factory()
  118. response = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"wear_cost_per_hour": -1})
  119. assert response.status_code == 422
  120. @pytest.mark.asyncio
  121. @pytest.mark.integration
  122. class TestTotals:
  123. async def test_stats_total(self, async_client: AsyncClient, db_session):
  124. db_session.add(PrintLogEntry(printer_id=1, status="completed", duration_seconds=3600, wear_cost=0.3))
  125. db_session.add(PrintLogEntry(printer_id=1, status="failed", duration_seconds=1800, wear_cost=0.15))
  126. db_session.add(PrintLogEntry(printer_id=1, status="completed", duration_seconds=3600))
  127. await db_session.commit()
  128. stats = (await async_client.get("/api/v1/archives/stats")).json()
  129. assert stats["total_wear_cost"] == pytest.approx(0.45)
  130. # Filament cost stays filament only.
  131. assert stats["total_cost"] == 0
  132. async def test_print_log_returns_and_sorts_by_wear(self, async_client: AsyncClient, db_session):
  133. db_session.add(PrintLogEntry(print_name="cheap", status="completed", wear_cost=0.1))
  134. db_session.add(PrintLogEntry(print_name="dear", status="completed", wear_cost=2.0))
  135. await db_session.commit()
  136. response = await async_client.get("/api/v1/print-log/", params={"sort_by": "wear_cost", "sort_dir": "desc"})
  137. assert response.status_code == 200
  138. items = response.json()["items"]
  139. assert [i["print_name"] for i in items[:2]] == ["dear", "cheap"]
  140. assert items[0]["wear_cost"] == 2.0
  141. async def test_archive_response_carries_wear(self, async_client: AsyncClient, db_session):
  142. archive = await _archive(db_session, wear_cost=0.75)
  143. response = await async_client.get(f"/api/v1/archives/{archive.id}")
  144. assert response.status_code == 200
  145. assert response.json()["wear_cost"] == 0.75
  146. async def test_project_totals_include_wear(self, async_client: AsyncClient, db_session):
  147. from backend.app.models.project import Project
  148. project = Project(name="Wear project")
  149. db_session.add(project)
  150. await db_session.commit()
  151. await db_session.refresh(project)
  152. archive = await _archive(db_session, project_id=project.id)
  153. db_session.add(PrintLogEntry(archive_id=archive.id, status="completed", cost=1.0, wear_cost=0.5))
  154. db_session.add(PrintLogEntry(archive_id=archive.id, status="completed", cost=1.0, wear_cost=0.25))
  155. await db_session.commit()
  156. stats = (await async_client.get(f"/api/v1/projects/{project.id}")).json()["stats"]
  157. assert stats["total_wear_cost"] == pytest.approx(0.75)
  158. assert stats["estimated_cost"] == pytest.approx(2.0)
  159. async def test_batch_cost_includes_wear(
  160. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  161. ):
  162. from backend.app.models.print_queue import PrintQueueItem
  163. printer = await printer_factory()
  164. archive = await archive_factory(printer.id)
  165. order = (
  166. await async_client.post(
  167. "/api/v1/queue/batches",
  168. json={"name": "Order", "archive_id": archive.id, "plates": [{"plate_id": 1, "quantity_target": 2}]},
  169. )
  170. ).json()
  171. item = (
  172. await async_client.post(
  173. "/api/v1/queue/",
  174. json={"printer_id": printer.id, "archive_id": archive.id, "batch_id": order["id"], "plate_id": 1},
  175. )
  176. ).json()
  177. queued = await db_session.get(PrintQueueItem, item["id"])
  178. queued.status = "completed"
  179. db_session.add(
  180. PrintLogEntry(
  181. archive_id=archive.id,
  182. queue_item_id=item["id"],
  183. status="completed",
  184. cost=2.0,
  185. energy_cost=0.5,
  186. wear_cost=0.25,
  187. )
  188. )
  189. await db_session.commit()
  190. result = (await async_client.get(f"/api/v1/queue/batches/{order['id']}")).json()
  191. assert result["actual_cost"] == pytest.approx(2.75)