|
@@ -0,0 +1,383 @@
|
|
|
|
|
+"""Spoolman spools get the per-print usage history built-in spools have (#3407).
|
|
|
|
|
+
|
|
|
|
|
+Bambuddy charges Spoolman for every print but kept no record of it, so Spoolman
|
|
|
|
|
+mode had no usage history: no trend for the stock forecast and no usage list
|
|
|
|
|
+on the spool. Each charge now writes a ``spool_usage_history`` row naming the
|
|
|
|
|
+Spoolman spool in ``spoolman_spool_id``, with ``spool_id`` NULL so a Spoolman
|
|
|
|
|
+id never reads as a built-in spool's (#3406).
|
|
|
|
|
+"""
|
|
|
|
|
+
|
|
|
|
|
+from datetime import timedelta
|
|
|
|
|
+from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
|
+
|
|
|
|
|
+import pytest
|
|
|
|
|
+from httpx import AsyncClient
|
|
|
|
|
+from sqlalchemy import select
|
|
|
|
|
+
|
|
|
|
|
+from backend.app.models.settings import Settings
|
|
|
|
|
+from backend.app.models.spool import Spool
|
|
|
|
|
+from backend.app.models.spool_usage_history import SpoolUsageHistory
|
|
|
|
|
+from backend.app.utils.local_time import utcnow_naive
|
|
|
|
|
+
|
|
|
|
|
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _row(spoolman_spool_id=None, spool_id=None, *, grams=10.0, days_ago=1.0, archive_id=None, cost=None):
|
|
|
|
|
+ return SpoolUsageHistory(
|
|
|
|
|
+ spool_id=spool_id,
|
|
|
|
|
+ spoolman_spool_id=spoolman_spool_id,
|
|
|
|
|
+ weight_used=grams,
|
|
|
|
|
+ percent_used=1,
|
|
|
|
|
+ status="completed",
|
|
|
|
|
+ archive_id=archive_id,
|
|
|
|
|
+ cost=cost,
|
|
|
|
|
+ created_at=utcnow_naive() - timedelta(days=days_ago),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _built_in_spool(db) -> Spool:
|
|
|
|
|
+ spool = Spool(material="PLA", label_weight=1000, core_weight=250, weight_used=0)
|
|
|
|
|
+ db.add(spool)
|
|
|
|
|
+ await db.commit()
|
|
|
|
|
+ await db.refresh(spool)
|
|
|
|
|
+ return spool
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _rows(db) -> list[SpoolUsageHistory]:
|
|
|
|
|
+ return list((await db.execute(select(SpoolUsageHistory).execution_options(populate_existing=True))).scalars().all())
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def test_a_failed_print_records_its_partial_usage_with_its_status(db_session, printer_factory, archive_factory):
|
|
|
|
|
+ from backend.app.models.active_print_spoolman import ActivePrintSpoolman
|
|
|
|
|
+ from backend.app.services import spoolman_tracking
|
|
|
|
|
+
|
|
|
|
|
+ printer = await printer_factory()
|
|
|
|
|
+ archive = await archive_factory(printer.id, print_name="Bracket", status="aborted")
|
|
|
|
|
+ db_session.add(ActivePrintSpoolman(printer_id=printer.id, archive_id=archive.id, filament_usage=[], ams_trays={}))
|
|
|
|
|
+ await db_session.commit()
|
|
|
|
|
+
|
|
|
|
|
+ async def _partial(printer_id, tracking, *, last_layer_num=None, last_progress=None, cost_out=None):
|
|
|
|
|
+ cost_out.add(40.0, {"id": 5, "price": 20.0, "filament": {"weight": 1000}}, "slot 1", spool_id=5)
|
|
|
|
|
+
|
|
|
|
|
+ with patch.object(spoolman_tracking, "_report_partial_usage", _partial):
|
|
|
|
|
+ await spoolman_tracking.cleanup_tracking(printer.id, archive.id, db_session, status="aborted")
|
|
|
|
|
+
|
|
|
|
|
+ ((row,),) = [await _rows(db_session)]
|
|
|
|
|
+ assert (row.spool_id, row.spoolman_spool_id, row.weight_used, row.status, row.print_name) == (
|
|
|
|
|
+ None,
|
|
|
|
|
+ 5,
|
|
|
|
|
+ 40.0,
|
|
|
|
|
+ "aborted",
|
|
|
|
|
+ "Bracket",
|
|
|
|
|
+ )
|
|
|
|
|
+ assert (row.percent_used, row.cost) == (4, 0.8)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def test_an_unpriced_spool_is_recorded_without_a_price(db_session, printer_factory, archive_factory):
|
|
|
|
|
+ """As completion leaves the archive's cost alone for it, so a recalculation
|
|
|
|
|
+ keeps that figure too (see the recalculation test below)."""
|
|
|
|
|
+ from backend.app.services.spoolman_tracking import _PrintCost, _record_usage_history
|
|
|
|
|
+
|
|
|
|
|
+ printer = await printer_factory()
|
|
|
|
|
+ archive = await archive_factory(printer.id)
|
|
|
|
|
+ cost = _PrintCost()
|
|
|
|
|
+ cost.add(100.0, {"id": 6, "filament": {"weight": 1000}}, "slot 2", spool_id=6)
|
|
|
|
|
+
|
|
|
|
|
+ await _record_usage_history(db_session, printer.id, archive.id, "completed", cost)
|
|
|
|
|
+
|
|
|
|
|
+ ((row,),) = [await _rows(db_session)]
|
|
|
|
|
+ assert (row.weight_used, row.cost) == (100.0, None)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def test_recalculating_keeps_the_cost_of_a_print_from_unpriced_spools(
|
|
|
|
|
+ async_client: AsyncClient, db_session, printer_factory, archive_factory
|
|
|
|
|
+):
|
|
|
|
|
+ """The archive keeps what it was priced at (here the catalogue's 30/kg),
|
|
|
|
|
+ not the 25/kg default the unpriced row would otherwise stand for."""
|
|
|
|
|
+ db_session.add(Settings(key="spoolman_enabled", value="true"))
|
|
|
|
|
+ db_session.add(Settings(key="default_filament_cost", value="25"))
|
|
|
|
|
+ printer = await printer_factory()
|
|
|
|
|
+ archive = await archive_factory(printer.id, status="completed", cost=3.0)
|
|
|
|
|
+ archive.filament_used_grams = 100.0
|
|
|
|
|
+ db_session.add(_row(spoolman_spool_id=41, grams=100.0, archive_id=archive.id, cost=None))
|
|
|
|
|
+ await db_session.commit()
|
|
|
|
|
+
|
|
|
|
|
+ assert (await async_client.post("/api/v1/archives/recalculate-costs")).status_code == 200
|
|
|
|
|
+
|
|
|
|
|
+ assert (await async_client.get(f"/api/v1/archives/{archive.id}")).json()["cost"] == 3.0
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def test_the_global_usage_list_is_one_inventorys(async_client: AsyncClient, db_session):
|
|
|
|
|
+ spool = await _built_in_spool(db_session)
|
|
|
|
|
+ db_session.add_all([_row(spool_id=spool.id), _row(spoolman_spool_id=spool.id)])
|
|
|
|
|
+ await db_session.commit()
|
|
|
|
|
+
|
|
|
|
|
+ built_in = (await async_client.get("/api/v1/inventory/usage")).json()
|
|
|
|
|
+ spoolman = (await async_client.get("/api/v1/inventory/usage", params={"spoolman": "true"})).json()
|
|
|
|
|
+
|
|
|
|
|
+ assert [(r["spool_id"], r["spoolman_spool_id"]) for r in built_in] == [(spool.id, None)]
|
|
|
|
|
+ assert [(r["spool_id"], r["spoolman_spool_id"]) for r in spoolman] == [(None, spool.id)]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def test_a_spoolman_spool_lists_and_clears_its_own_usage(async_client: AsyncClient, db_session):
|
|
|
|
|
+ """Never a built-in spool's that shares its id."""
|
|
|
|
|
+ spool = await _built_in_spool(db_session)
|
|
|
|
|
+ db_session.add_all([_row(spoolman_spool_id=spool.id, grams=12.0), _row(spool_id=spool.id, grams=99.0)])
|
|
|
|
|
+ await db_session.commit()
|
|
|
|
|
+ url = f"/api/v1/spoolman/inventory/spools/{spool.id}/usage"
|
|
|
|
|
+
|
|
|
|
|
+ listed = (await async_client.get(url)).json()
|
|
|
|
|
+ assert [r["weight_used"] for r in listed] == [12.0]
|
|
|
|
|
+
|
|
|
|
|
+ assert (await async_client.delete(url)).status_code == 200
|
|
|
|
|
+ assert [(r.spool_id, r.weight_used) for r in await _rows(db_session)] == [(spool.id, 99.0)]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def test_deleting_the_spoolman_spool_keeps_its_usage_without_its_id(async_client: AsyncClient, db_session):
|
|
|
|
|
+ """Spoolman can give the id to a new spool, which must not inherit it."""
|
|
|
|
|
+ db_session.add(Settings(key="spoolman_enabled", value="true"))
|
|
|
|
|
+ db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
|
|
|
|
|
+ db_session.add_all([_row(spoolman_spool_id=5), _row(spoolman_spool_id=6)])
|
|
|
|
|
+ await db_session.commit()
|
|
|
|
|
+ client = MagicMock()
|
|
|
|
|
+ client.delete_spool = AsyncMock()
|
|
|
|
|
+
|
|
|
|
|
+ with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=client)):
|
|
|
|
|
+ response = await async_client.delete("/api/v1/spoolman/inventory/spools/5")
|
|
|
|
|
+
|
|
|
|
|
+ assert response.status_code == 200, response.text
|
|
|
|
|
+ assert sorted((r.spoolman_spool_id or 0) for r in await _rows(db_session)) == [0, 6]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def test_the_stock_alerts_use_the_spoolman_rows_in_spoolman_mode(db_session):
|
|
|
|
|
+ """And with them the trend, as built-in spools have it."""
|
|
|
|
|
+ from backend.app.services.print_scheduler import PrintScheduler
|
|
|
|
|
+ from backend.app.services.stock_forecast import UsageRecord, history_rate
|
|
|
|
|
+
|
|
|
|
|
+ db_session.add(Settings(key="spoolman_enabled", value="true"))
|
|
|
|
|
+ days = (30, 20, 10)
|
|
|
|
|
+ db_session.add_all([_row(spoolman_spool_id=7, grams=30.0, days_ago=d) for d in days])
|
|
|
|
|
+ # A built-in spool 7, from before the switch: not the Spoolman spool's.
|
|
|
|
|
+ db_session.add_all([_row(spool_id=None, grams=500.0, days_ago=d) for d in (5, 4)])
|
|
|
|
|
+ await db_session.commit()
|
|
|
|
|
+ spoolman = MagicMock()
|
|
|
|
|
+ spoolman.get_all_spools = AsyncMock(
|
|
|
|
|
+ return_value=[
|
|
|
|
|
+ {
|
|
|
|
|
+ "id": 7,
|
|
|
|
|
+ "remaining_weight": 900.0,
|
|
|
|
|
+ "used_weight": 100.0,
|
|
|
|
|
+ "registered": (utcnow_naive() - timedelta(days=100)).isoformat() + "Z",
|
|
|
|
|
+ "filament": {"id": 1, "material": "PLA", "weight": 1000, "vendor": {"name": "Bambu Lab"}},
|
|
|
|
|
+ }
|
|
|
|
|
+ ]
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=spoolman)):
|
|
|
|
|
+ ((forecast, _),) = (await PrintScheduler()._stock_forecasts(db_session)).values()
|
|
|
|
|
+
|
|
|
|
|
+ trend = history_rate([UsageRecord(utcnow_naive() - timedelta(days=d), 30.0) for d in days], utcnow_naive())
|
|
|
|
|
+ assert forecast.daily_rate_g == pytest.approx(trend[0], rel=1e-3)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def test_recalculating_costs_rebuilds_a_spoolman_archive_from_its_rows(
|
|
|
|
|
+ async_client: AsyncClient, db_session, printer_factory, archive_factory
|
|
|
|
|
+):
|
|
|
|
|
+ """The rows carry what the charges were priced at, so the figure stays."""
|
|
|
|
|
+ db_session.add(Settings(key="spoolman_enabled", value="true"))
|
|
|
|
|
+ db_session.add(Settings(key="default_filament_cost", value="25"))
|
|
|
|
|
+ printer = await printer_factory()
|
|
|
|
|
+ archive = await archive_factory(printer.id, status="completed", cost=4.0)
|
|
|
|
|
+ archive.filament_used_grams = 100.0
|
|
|
|
|
+ db_session.add(_row(spoolman_spool_id=41, grams=100.0, archive_id=archive.id, cost=4.0))
|
|
|
|
|
+ await db_session.commit()
|
|
|
|
|
+
|
|
|
|
|
+ assert (await async_client.post("/api/v1/archives/recalculate-costs")).status_code == 200
|
|
|
|
|
+
|
|
|
|
|
+ assert (await async_client.get(f"/api/v1/archives/{archive.id}")).json()["cost"] == 4.0
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def test_a_backup_restores_the_spoolman_spool(db_session):
|
|
|
|
|
+ from backend.app.services.github_restore import GitHubRestoreService, _CategoryTally
|
|
|
|
|
+
|
|
|
|
|
+ usage = {
|
|
|
|
|
+ "usage_history": [
|
|
|
|
|
+ {
|
|
|
|
|
+ "id": 1,
|
|
|
|
|
+ "spool_id": None,
|
|
|
|
|
+ "spoolman_spool_id": 41,
|
|
|
|
|
+ "weight_used": 12.5,
|
|
|
|
|
+ "created_at": "2026-01-06 09:00:00",
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ "id": 2,
|
|
|
|
|
+ "spool_id": None,
|
|
|
|
|
+ "spoolman_spool_id": True,
|
|
|
|
|
+ "weight_used": 1.0,
|
|
|
|
|
+ "created_at": "2026-01-06 10:00:00",
|
|
|
|
|
+ },
|
|
|
|
|
+ ]
|
|
|
|
|
+ }
|
|
|
|
|
+ tally = _CategoryTally()
|
|
|
|
|
+ await GitHubRestoreService()._restore_spool_usage(db_session, usage, tally, {}, {})
|
|
|
|
|
+ await db_session.commit()
|
|
|
|
|
+
|
|
|
|
|
+ assert sorted((r.spoolman_spool_id or 0, r.weight_used) for r in await _rows(db_session)) == [(0, 1.0), (41, 12.5)]
|
|
|
|
|
+
|
|
|
|
|
+ # Two Spoolman spools charged the same at the same moment are two rows.
|
|
|
|
|
+ twins = {
|
|
|
|
|
+ "usage_history": [
|
|
|
|
|
+ {"id": 3, "spoolman_spool_id": 50, "weight_used": 7.0, "created_at": "2026-01-07 09:00:00"},
|
|
|
|
|
+ {"id": 4, "spoolman_spool_id": 51, "weight_used": 7.0, "created_at": "2026-01-07 09:00:00"},
|
|
|
|
|
+ ]
|
|
|
|
|
+ }
|
|
|
|
|
+ await GitHubRestoreService()._restore_spool_usage(db_session, twins, _CategoryTally(), {}, {})
|
|
|
|
|
+ await db_session.commit()
|
|
|
|
|
+ assert sorted(r.spoolman_spool_id or 0 for r in await _rows(db_session) if r.weight_used == 7.0) == [50, 51]
|
|
|
|
|
+ usage["usage_history"] += twins["usage_history"]
|
|
|
|
|
+
|
|
|
|
|
+ # Restored again: recognised, not doubled.
|
|
|
|
|
+ await GitHubRestoreService()._restore_spool_usage(db_session, usage, _CategoryTally(), {}, {})
|
|
|
|
|
+ await db_session.commit()
|
|
|
|
|
+ assert len(await _rows(db_session)) == 4
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def test_a_backup_without_built_in_spools_restores_the_usage(db_session):
|
|
|
|
|
+ """A Spoolman-mode backup has no spools/inventory.json to go with it."""
|
|
|
|
|
+ from backend.app.services.github_restore import GitHubRestoreService, _CategoryTally
|
|
|
|
|
+
|
|
|
|
|
+ usage = {"usage_history": [{"id": 1, "spoolman_spool_id": 41, "weight_used": 5.0, "created_at": "2026-01-06"}]}
|
|
|
|
|
+ await GitHubRestoreService()._restore_spools(db_session, None, usage, False, _CategoryTally(), {})
|
|
|
|
|
+ await db_session.commit()
|
|
|
|
|
+
|
|
|
|
|
+ assert [r.spoolman_spool_id for r in await _rows(db_session)] == [41]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def test_a_backup_carries_the_spoolman_spool(db_session):
|
|
|
|
|
+ from backend.app.services.github_backup import GitHubBackupService
|
|
|
|
|
+
|
|
|
|
|
+ db_session.add(_row(spoolman_spool_id=41))
|
|
|
|
|
+ await db_session.commit()
|
|
|
|
|
+
|
|
|
|
|
+ files: dict = {}
|
|
|
|
|
+ await GitHubBackupService()._collect_spools(db_session, files)
|
|
|
|
|
+ assert [u["spoolman_spool_id"] for u in files["spools/usage_history.json"]["usage_history"]] == [41]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def test_each_inventory_reads_its_own_rows_up_to_the_limit(db_session, monkeypatch):
|
|
|
|
|
+ """The read is capped; newer rows of the other inventory must not use it up."""
|
|
|
|
|
+ from backend.app.services import print_scheduler
|
|
|
|
|
+
|
|
|
|
|
+ built_in = await _built_in_spool(db_session)
|
|
|
|
|
+ db_session.add(_row(spool_id=built_in.id, days_ago=5))
|
|
|
|
|
+ db_session.add_all([_row(spoolman_spool_id=9, days_ago=d) for d in (1, 2)])
|
|
|
|
|
+ await db_session.commit()
|
|
|
|
|
+ monkeypatch.setattr(print_scheduler, "_STOCK_FORECAST_HISTORY_LIMIT", 1)
|
|
|
|
|
+
|
|
|
|
|
+ scheduler = print_scheduler.PrintScheduler()
|
|
|
|
|
+ assert list(await scheduler._stock_usage_history(db_session)) == [built_in.id]
|
|
|
|
|
+ assert list(await scheduler._stock_usage_history(db_session, spoolman=True)) == [9]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def test_the_spoolman_stock_alerts_spread_a_spool_from_its_first_use(db_session):
|
|
|
|
|
+ """Registered 120 days ago, first printed with 21 days ago, last 2 days ago."""
|
|
|
|
|
+ from backend.app.services.print_scheduler import PrintScheduler
|
|
|
|
|
+
|
|
|
|
|
+ db_session.add(Settings(key="spoolman_enabled", value="true"))
|
|
|
|
|
+ await db_session.commit()
|
|
|
|
|
+
|
|
|
|
|
+ def _iso(days):
|
|
|
|
|
+ return (utcnow_naive() - timedelta(days=days)).isoformat() + "Z"
|
|
|
|
|
+
|
|
|
|
|
+ spoolman = MagicMock()
|
|
|
|
|
+ spoolman.get_all_spools = AsyncMock(
|
|
|
|
|
+ return_value=[
|
|
|
|
|
+ {
|
|
|
|
|
+ "id": 3,
|
|
|
|
|
+ "remaining_weight": 900.0,
|
|
|
|
|
+ "used_weight": 100.0,
|
|
|
|
|
+ "registered": _iso(120),
|
|
|
|
|
+ "first_used": _iso(21),
|
|
|
|
|
+ "last_used": _iso(2),
|
|
|
|
|
+ "filament": {"id": 1, "material": "PLA", "weight": 1000, "vendor": {"name": "Bambu Lab"}},
|
|
|
|
|
+ }
|
|
|
|
|
+ ]
|
|
|
|
|
+ )
|
|
|
|
|
+ with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=spoolman)):
|
|
|
|
|
+ ((forecast, _),) = (await PrintScheduler()._stock_forecasts(db_session)).values()
|
|
|
|
|
+
|
|
|
|
|
+ assert forecast.daily_rate_g == pytest.approx(100 / 21, rel=1e-3)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def test_the_built_in_stock_alerts_end_a_spool_at_its_last_use(db_session):
|
|
|
|
|
+ """Created 120 days ago, last used 100 days ago: nothing in the last 90."""
|
|
|
|
|
+ from backend.app.services.print_scheduler import PrintScheduler
|
|
|
|
|
+
|
|
|
|
|
+ spool = await _built_in_spool(db_session)
|
|
|
|
|
+ spool.weight_used = 300.0
|
|
|
|
|
+ spool.created_at = utcnow_naive() - timedelta(days=120)
|
|
|
|
|
+ spool.last_used = utcnow_naive() - timedelta(days=100)
|
|
|
|
|
+ await db_session.commit()
|
|
|
|
|
+
|
|
|
|
|
+ ((forecast, _),) = (await PrintScheduler()._stock_forecasts(db_session)).values()
|
|
|
|
|
+
|
|
|
|
|
+ assert forecast.daily_rate_g == 0
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def test_the_spoolman_stock_alerts_end_a_spool_at_its_last_use(db_session):
|
|
|
|
|
+ """Used from 150 to 60 days ago: 30 of its 90 days of use are in the window,
|
|
|
|
|
+ so 30 of its 90 g. Same as the panel's test."""
|
|
|
|
|
+ from backend.app.services.print_scheduler import PrintScheduler
|
|
|
|
|
+
|
|
|
|
|
+ db_session.add(Settings(key="spoolman_enabled", value="true"))
|
|
|
|
|
+ await db_session.commit()
|
|
|
|
|
+
|
|
|
|
|
+ def _iso(days):
|
|
|
|
|
+ return (utcnow_naive() - timedelta(days=days)).isoformat() + "Z"
|
|
|
|
|
+
|
|
|
|
|
+ spoolman = MagicMock()
|
|
|
|
|
+ spoolman.get_all_spools = AsyncMock(
|
|
|
|
|
+ return_value=[
|
|
|
|
|
+ {
|
|
|
|
|
+ "id": 4,
|
|
|
|
|
+ "remaining_weight": 910.0,
|
|
|
|
|
+ "used_weight": 90.0,
|
|
|
|
|
+ "registered": _iso(200),
|
|
|
|
|
+ "first_used": _iso(150),
|
|
|
|
|
+ "last_used": _iso(60),
|
|
|
|
|
+ "filament": {"id": 1, "material": "PLA", "weight": 1000, "vendor": {"name": "Bambu Lab"}},
|
|
|
|
|
+ }
|
|
|
|
|
+ ]
|
|
|
|
|
+ )
|
|
|
|
|
+ with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=spoolman)):
|
|
|
|
|
+ ((forecast, _),) = (await PrintScheduler()._stock_forecasts(db_session)).values()
|
|
|
|
|
+
|
|
|
|
|
+ assert forecast.daily_rate_g == pytest.approx(30 / 90, rel=1e-3)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def test_rows_of_an_earlier_spool_with_the_same_id_are_not_its_own(db_session):
|
|
|
|
|
+ """Spoolman handed id 6 to a new spool, registered 15 days ago; rows from
|
|
|
|
|
+ before that are the deleted spool's."""
|
|
|
|
|
+ from backend.app.services.print_scheduler import PrintScheduler
|
|
|
|
|
+ from backend.app.services.stock_forecast import UsageRecord, history_rate
|
|
|
|
|
+
|
|
|
|
|
+ db_session.add(Settings(key="spoolman_enabled", value="true"))
|
|
|
|
|
+ db_session.add_all([_row(spoolman_spool_id=6, grams=30.0, days_ago=d) for d in (40, 30, 10, 5)])
|
|
|
|
|
+ await db_session.commit()
|
|
|
|
|
+ spoolman = MagicMock()
|
|
|
|
|
+ spoolman.get_all_spools = AsyncMock(
|
|
|
|
|
+ return_value=[
|
|
|
|
|
+ {
|
|
|
|
|
+ "id": 6,
|
|
|
|
|
+ "remaining_weight": 940.0,
|
|
|
|
|
+ "used_weight": 60.0,
|
|
|
|
|
+ "registered": (utcnow_naive() - timedelta(days=15)).isoformat() + "Z",
|
|
|
|
|
+ "filament": {"id": 1, "material": "PLA", "weight": 1000, "vendor": {"name": "Bambu Lab"}},
|
|
|
|
|
+ }
|
|
|
|
|
+ ]
|
|
|
|
|
+ )
|
|
|
|
|
+ with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=spoolman)):
|
|
|
|
|
+ ((forecast, _),) = (await PrintScheduler()._stock_forecasts(db_session)).values()
|
|
|
|
|
+
|
|
|
|
|
+ own = history_rate([UsageRecord(utcnow_naive() - timedelta(days=d), 30.0) for d in (10, 5)], utcnow_naive())
|
|
|
|
|
+ assert forecast.daily_rate_g == pytest.approx(own[0], rel=1e-3)
|