mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
archives stop reporting near-zero cost (#1344)
Reporter @nicktags hit $0.01 on a 110.3g multi-color print with the
global default filament cost set to $10/kg. archive.py initial cost
calc was correct (~$1.10), then usage_tracker.on_print_complete
overwrote archive.cost with sum(r.cost for r in results) -- where
results only includes AMS trays mapped to a spool in Bambuddy's
inventory. On a multi-color print where 3 of 4 used trays had no
inventory spool, only the one tracked slot's tiny share (~1g) survived
and the archive recorded $0.01.
The overwrite logic dates to #505 (Feb 2026) and is correct for
fully-tracked single-color prints, but the multi-color slicer feature
in 0.2.4 (988c0055) made the partial-inventory state common -- users
slice + print multi-color from Bambuddy without first setting up an
inventory entry for every tray.
Cover the gap: any filament weight not represented in results gets
charged at the global default rate. For a fully-tracked print,
untracked grams = 0 and the top-up adds nothing, so the single-color
behavior is preserved. For a partial print, the missing slots are
priced at the user's documented default rate so the archive cost
reflects the whole print.
Three call sites updated to share the same logic:
- usage_tracker.py: live cost-update on print complete
- archives.py rescan_archive: per-archive manual recalc
- archives.py recalculate_all_costs: bulk recalc button
417 lines
14 KiB
Python
417 lines
14 KiB
Python
import pytest
|
|
from httpx import AsyncClient
|
|
from sqlalchemy import select
|
|
|
|
from backend.app.models.archive import PrintArchive
|
|
from backend.app.models.spool import Spool
|
|
from backend.app.models.spool_assignment import SpoolAssignment
|
|
from backend.app.models.spool_usage_history import SpoolUsageHistory
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def cleanup_test_archive_files():
|
|
yield
|
|
import glob
|
|
import os
|
|
|
|
# Remove any test archive files created in archives/test/
|
|
for f in glob.glob("archives/test/test_print*.3mf"):
|
|
try:
|
|
os.remove(f)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
"""Integration tests for cost tracking in archives and statistics.
|
|
|
|
Tests the full flow of cost tracking from usage to statistics:
|
|
- Archive cost field populated correctly
|
|
- Statistics endpoint aggregates costs
|
|
- Completed vs failed prints cost handling
|
|
"""
|
|
|
|
|
|
class TestArchiveCostTracking:
|
|
"""Tests for cost field in PrintArchive."""
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_archive_has_cost_field(
|
|
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
|
|
):
|
|
# Verify PrintArchive includes cost field in response.
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(
|
|
printer.id,
|
|
print_name="Test Archive",
|
|
status="completed",
|
|
cost=5.50, # Set a cost
|
|
)
|
|
|
|
response = await async_client.get(f"/api/v1/archives/{archive.id}")
|
|
|
|
assert response.status_code == 200
|
|
result = response.json()
|
|
assert "cost" in result
|
|
assert result["cost"] == 5.50
|
|
await db_session.rollback()
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_archive_cost_null_when_not_set(
|
|
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
|
|
):
|
|
# Verify cost is null when not set.
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(
|
|
printer.id,
|
|
print_name="Test Archive",
|
|
status="completed",
|
|
# cost not set
|
|
)
|
|
|
|
response = await async_client.get(f"/api/v1/archives/{archive.id}")
|
|
|
|
assert response.status_code == 200
|
|
result = response.json()
|
|
assert result["cost"] is None or result["cost"] == 0
|
|
await db_session.rollback()
|
|
|
|
|
|
class TestStatisticsCostAggregation:
|
|
"""Tests for cost aggregation in statistics endpoint."""
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_statistics_includes_total_cost(
|
|
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
|
|
):
|
|
# Verify statistics endpoint includes total_cost field.
|
|
printer = await printer_factory()
|
|
|
|
# Create archives with costs
|
|
await archive_factory(
|
|
printer.id,
|
|
status="completed",
|
|
cost=2.50,
|
|
filament_used_grams=100.0,
|
|
)
|
|
await archive_factory(
|
|
printer.id,
|
|
status="completed",
|
|
cost=3.75,
|
|
filament_used_grams=150.0,
|
|
)
|
|
|
|
response = await async_client.get("/api/v1/archives/stats")
|
|
|
|
assert response.status_code == 200
|
|
result = response.json()
|
|
assert "total_cost" in result
|
|
assert result["total_cost"] == 6.25
|
|
await db_session.rollback()
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_statistics_aggregates_costs_correctly(
|
|
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
|
|
):
|
|
# Verify statistics correctly sums costs from all archives.
|
|
printer = await printer_factory()
|
|
|
|
# Create multiple archives with different costs
|
|
costs = [1.25, 2.50, 0.75, 5.00, 0.50]
|
|
for cost in costs:
|
|
await archive_factory(
|
|
printer.id,
|
|
status="completed",
|
|
cost=cost,
|
|
filament_used_grams=50.0,
|
|
)
|
|
|
|
response = await async_client.get("/api/v1/archives/stats")
|
|
|
|
assert response.status_code == 200
|
|
result = response.json()
|
|
expected_total = sum(costs)
|
|
assert result["total_cost"] == expected_total
|
|
await db_session.rollback()
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_statistics_handles_null_costs(
|
|
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
|
|
):
|
|
# Verify statistics handles archives with null costs gracefully.
|
|
printer = await printer_factory()
|
|
|
|
# Mix of archives with and without costs
|
|
await archive_factory(printer.id, status="completed", cost=2.50)
|
|
await archive_factory(printer.id, status="completed", cost=None)
|
|
await archive_factory(printer.id, status="completed", cost=1.75)
|
|
await archive_factory(printer.id, status="completed") # No cost field
|
|
|
|
response = await async_client.get("/api/v1/archives/stats")
|
|
|
|
assert response.status_code == 200
|
|
result = response.json()
|
|
# Should sum only non-null costs
|
|
assert result["total_cost"] == 4.25
|
|
await db_session.rollback()
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_statistics_includes_failed_print_costs(
|
|
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
|
|
):
|
|
# Verify failed prints with costs are included in statistics.
|
|
printer = await printer_factory()
|
|
|
|
await archive_factory(printer.id, status="completed", cost=5.00)
|
|
await archive_factory(printer.id, status="failed", cost=2.50) # Failed but has cost
|
|
await archive_factory(printer.id, status="cancelled", cost=1.00)
|
|
|
|
response = await async_client.get("/api/v1/archives/stats")
|
|
|
|
assert response.status_code == 200
|
|
result = response.json()
|
|
# All prints should contribute to total cost
|
|
assert result["total_cost"] == 8.50
|
|
await db_session.rollback()
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_statistics_zero_cost_when_no_archives(self, async_client: AsyncClient):
|
|
"""Verify total_cost is 0 when no archives exist."""
|
|
response = await async_client.get("/api/v1/archives/stats")
|
|
|
|
assert response.status_code == 200
|
|
result = response.json()
|
|
assert result["total_cost"] == 0.0
|
|
|
|
|
|
class TestSpoolCostPersistence:
|
|
"""Tests for spool cost_per_kg field."""
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_spool_cost_fields_persist(self, async_client: AsyncClient, db_session):
|
|
# Verify cost_per_kg is saved and retrieved.
|
|
# Create a spool with cost
|
|
spool_data = {
|
|
"material": "PLA",
|
|
"brand": "TestBrand",
|
|
"label_weight": 1000,
|
|
"core_weight": 250,
|
|
"cost_per_kg": 25.50,
|
|
}
|
|
|
|
create_response = await async_client.post("/api/v1/inventory/spools", json=spool_data)
|
|
assert create_response.status_code == 200
|
|
spool_id = create_response.json()["id"]
|
|
|
|
# Retrieve and verify
|
|
get_response = await async_client.get(f"/api/v1/inventory/spools/{spool_id}")
|
|
assert get_response.status_code == 200
|
|
result = get_response.json()
|
|
|
|
assert result["cost_per_kg"] == 25.50
|
|
await db_session.rollback()
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_spool_update_cost_fields(self, async_client: AsyncClient, db_session):
|
|
# Verify cost fields can be updated.
|
|
# Create spool without cost
|
|
spool_data = {
|
|
"material": "PETG",
|
|
"brand": "TestBrand",
|
|
"label_weight": 1000,
|
|
"core_weight": 250,
|
|
}
|
|
|
|
create_response = await async_client.post("/api/v1/inventory/spools", json=spool_data)
|
|
assert create_response.status_code == 200
|
|
spool_id = create_response.json()["id"]
|
|
|
|
# Update with cost
|
|
update_data = {
|
|
"cost_per_kg": 30.00,
|
|
}
|
|
|
|
update_response = await async_client.patch(f"/api/v1/inventory/spools/{spool_id}", json=update_data)
|
|
assert update_response.status_code == 200
|
|
|
|
result = update_response.json()
|
|
assert result["cost_per_kg"] == 30.00
|
|
await db_session.rollback()
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_spool_cost_null_by_default(self, async_client: AsyncClient, db_session):
|
|
# Verify cost_per_kg defaults to null when not provided.
|
|
spool_data = {
|
|
"material": "ABS",
|
|
"label_weight": 1000,
|
|
"core_weight": 250,
|
|
}
|
|
|
|
create_response = await async_client.post("/api/v1/inventory/spools", json=spool_data)
|
|
assert create_response.status_code == 200
|
|
|
|
result = create_response.json()
|
|
assert result["cost_per_kg"] is None
|
|
await db_session.rollback()
|
|
|
|
|
|
class TestCostCalculationScenarios:
|
|
"""End-to-end tests for various cost calculation scenarios."""
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_cost_with_multiple_colors(self, async_client: AsyncClient, printer_factory, db_session):
|
|
# Verify cost tracking works for multi-color prints.
|
|
|
|
# Create two spools with different costs
|
|
spool1_data = {
|
|
"material": "ABS",
|
|
"brand": "TestBrand",
|
|
"label_weight": 1000,
|
|
"core_weight": 250,
|
|
"cost_per_kg": 20.00,
|
|
}
|
|
spool2_data = {
|
|
"material": "PLA",
|
|
"label_weight": 1000,
|
|
"core_weight": 250,
|
|
"cost_per_kg": 25.00,
|
|
}
|
|
|
|
spool1_response = await async_client.post("/api/v1/inventory/spools", json=spool1_data)
|
|
spool2_response = await async_client.post("/api/v1/inventory/spools", json=spool2_data)
|
|
|
|
assert spool1_response.status_code == 200
|
|
assert spool2_response.status_code == 200
|
|
|
|
# Verify spools created with correct costs
|
|
assert spool1_response.json()["cost_per_kg"] == 20.00
|
|
assert spool2_response.json()["cost_per_kg"] == 25.00
|
|
await db_session.rollback()
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_cost_precision(self, async_client: AsyncClient, db_session):
|
|
# Verify cost calculations maintain proper precision.
|
|
# Create spool with specific cost
|
|
spool_data = {
|
|
"material": "PLA",
|
|
"brand": "TestBrand",
|
|
"label_weight": 1000,
|
|
"core_weight": 250,
|
|
"cost_per_kg": 19.99, # Specific price
|
|
}
|
|
|
|
response = await async_client.post("/api/v1/inventory/spools", json=spool_data)
|
|
assert response.status_code == 200
|
|
|
|
result = response.json()
|
|
# Verify precision is maintained
|
|
assert result["cost_per_kg"] == 19.99
|
|
await db_session.rollback()
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_archive_cost_with_archive_id_and_print_name(
|
|
self, async_client, archive_factory, printer_factory, db_session
|
|
):
|
|
"""Test archive cost recalculation using both archive_id and print_name fallback."""
|
|
from backend.app.models.spool import Spool
|
|
from backend.app.models.spool_usage_history import SpoolUsageHistory
|
|
|
|
printer = await printer_factory()
|
|
|
|
# Create spools and commit
|
|
spool_new = Spool(
|
|
material="PLA",
|
|
brand="BrandA",
|
|
label_weight=1000,
|
|
core_weight=250,
|
|
cost_per_kg=20.0,
|
|
)
|
|
spool_old = Spool(
|
|
material="ABS",
|
|
brand="BrandB",
|
|
label_weight=1000,
|
|
core_weight=250,
|
|
cost_per_kg=15.0,
|
|
)
|
|
db_session.add_all([spool_new, spool_old])
|
|
await db_session.commit()
|
|
await db_session.refresh(spool_new)
|
|
await db_session.refresh(spool_old)
|
|
|
|
# Create archive with new SpoolUsageHistory (archive_id set).
|
|
# filament_used_grams matches the tracked weight so the #1344 default-
|
|
# rate top-up for untracked filament doesn't apply -- this test pins
|
|
# the query routing, not the top-up branch.
|
|
archive_new = await archive_factory(
|
|
printer.id,
|
|
print_name="UniquePrint",
|
|
status="completed",
|
|
cost=None,
|
|
filament_used_grams=20.0,
|
|
)
|
|
|
|
history_new = SpoolUsageHistory(
|
|
spool_id=spool_new.id,
|
|
printer_id=printer.id,
|
|
print_name="UniquePrint",
|
|
weight_used=20.0,
|
|
percent_used=20,
|
|
status="completed",
|
|
cost=0.50,
|
|
archive_id=archive_new.id,
|
|
)
|
|
db_session.add(history_new)
|
|
|
|
# Create archive with old SpoolUsageHistory (archive_id NULL — legacy record)
|
|
archive_old = await archive_factory(
|
|
printer.id,
|
|
print_name="LegacyPrint",
|
|
status="completed",
|
|
cost=None,
|
|
)
|
|
archive_old.filament_used_grams = 30.0
|
|
await db_session.commit()
|
|
|
|
history_old = SpoolUsageHistory(
|
|
spool_id=spool_old.id,
|
|
printer_id=printer.id,
|
|
print_name="LegacyPrint",
|
|
weight_used=30.0,
|
|
percent_used=30,
|
|
status="completed",
|
|
cost=0.45,
|
|
archive_id=None,
|
|
)
|
|
db_session.add(history_old)
|
|
|
|
await db_session.commit()
|
|
|
|
# Recalculate costs for all archives
|
|
recalc_response = await async_client.post("/api/v1/archives/recalculate-costs")
|
|
assert recalc_response.status_code == 200
|
|
assert recalc_response.json()["updated"] >= 1
|
|
|
|
# Verify archive_new cost from archive_id-linked SpoolUsageHistory
|
|
response_new = await async_client.get(f"/api/v1/archives/{archive_new.id}")
|
|
assert response_new.status_code == 200
|
|
assert response_new.json()["cost"] == 0.50
|
|
|
|
# Verify archive_old cost from legacy print_name fallback
|
|
response_old = await async_client.get(f"/api/v1/archives/{archive_old.id}")
|
|
assert response_old.status_code == 200
|
|
assert response_old.json()["cost"] == 0.45
|
|
|
|
await db_session.rollback()
|