Files
bambuddy/backend/tests/unit/test_archive_run_aggregation.py
T
maziggy cc25cbe774 fix(archives): #1608 suppress card time-accuracy badge for multi-run archives
compute_time_accuracy in routes/archives.py compares the archive row's
  own started_at / completed_at (which reflect the latest run only)
  against archive.print_time_seconds (which the #1593 parser fix
  correctly stores as the sum across plates). For a 3-plate file printed
  plate-by-plate the ratio is ~300%, producing a "+188%" card badge that
  means nothing — apples to oranges. The 5-500% sanity band catches
  truly broken values but lets this deterministic N×100% shape through.
  Reporter's archive #65 was 3 plates over 9 runs.

  compute_time_accuracy gains an optional run_aggregate argument and
  returns both actual_time_seconds and time_accuracy as null when the
  aggregate reports more than one logged run. The frontend already falls
  through to print_time_seconds for the time display
  (actual_time_seconds || print_time_seconds) and gates the badge on
  time_accuracy being truthy, so multi-run archives now show the slicer
  estimate with no badge. Single-run archives keep the original
  behaviour verbatim.

  The fix is applied at every call site that renders an archive card:
  archive_to_response now threads run_aggregate through, and the three
  endpoints that previously didn't load the aggregate (archives.py
  search fast-path and FTS path, single-archive PATCH, and
  projects.list_project_archives) now batch-load it via the existing
  _load_run_aggregates helper.

  The stats endpoint's per-run accuracy aggregation at archives.py:940
  already uses PrintLogEntry.duration_seconds with its own 50-200% band
  filter and is untouched.
2026-06-03 10:14:57 +02:00

499 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for the PrintRun-based stats aggregation (#1378).
Statistics and per-archive aggregates now come from PrintLogEntry rows rather
than PrintArchive's runtime fields, so a reprint contributes new totals
instead of overwriting the source archive's first-run data.
"""
from datetime import datetime, timezone
import pytest
from httpx import AsyncClient
from backend.app.models.print_log import PrintLogEntry
@pytest.mark.asyncio
@pytest.mark.integration
async def test_stats_count_reprints_independently(
async_client: AsyncClient, archive_factory, printer_factory, db_session
):
"""A reprint adds to stats instead of overwriting the source archive."""
printer = await printer_factory()
archive = await archive_factory(
printer.id,
status="completed",
filament_used_grams=100.0,
cost=2.5,
print_time_seconds=3600,
with_run=False,
)
# First run — completed, 100g.
db_session.add(
PrintLogEntry(
archive_id=archive.id,
printer_id=archive.printer_id,
status="completed",
started_at=datetime(2026, 5, 1, 10, 0, tzinfo=timezone.utc),
completed_at=datetime(2026, 5, 1, 11, 0, tzinfo=timezone.utc),
duration_seconds=3600,
filament_used_grams=100.0,
cost=2.5,
created_at=datetime(2026, 5, 1, 11, 0, tzinfo=timezone.utc),
)
)
# Reprint — failed at 10g.
db_session.add(
PrintLogEntry(
archive_id=archive.id,
printer_id=archive.printer_id,
status="failed",
started_at=datetime(2026, 5, 5, 10, 0, tzinfo=timezone.utc),
completed_at=datetime(2026, 5, 5, 10, 5, tzinfo=timezone.utc),
duration_seconds=300,
filament_used_grams=10.0,
cost=0.25,
failure_reason="Cancelled by user",
created_at=datetime(2026, 5, 5, 10, 5, tzinfo=timezone.utc),
)
)
await db_session.commit()
response = await async_client.get("/api/v1/archives/stats")
assert response.status_code == 200
body = response.json()
# Both runs counted, not the single archive row.
assert body["total_prints"] == 2
assert body["successful_prints"] == 1
assert body["failed_prints"] == 1
# 100g + 10g — NOT 10g (which is what archives.filament_used_grams alone
# would give if the archive's runtime fields were the source of truth).
assert body["total_filament_grams"] == pytest.approx(110.0)
assert body["total_cost"] == pytest.approx(2.75)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_archive_list_includes_run_aggregates(
async_client: AsyncClient, archive_factory, printer_factory, db_session
):
"""List response carries run_count, last_run_at, total_filament_actual_grams."""
printer = await printer_factory()
archive = await archive_factory(
printer.id,
status="completed",
filament_used_grams=100.0,
with_run=False,
)
db_session.add_all(
[
PrintLogEntry(
archive_id=archive.id,
printer_id=archive.printer_id,
status="completed",
started_at=datetime(2026, 5, 1, 10, 0, tzinfo=timezone.utc),
completed_at=datetime(2026, 5, 1, 11, 0, tzinfo=timezone.utc),
filament_used_grams=100.0,
created_at=datetime(2026, 5, 1, 11, 0, tzinfo=timezone.utc),
),
PrintLogEntry(
archive_id=archive.id,
printer_id=archive.printer_id,
status="failed",
started_at=datetime(2026, 5, 10, 10, 0, tzinfo=timezone.utc),
completed_at=datetime(2026, 5, 10, 10, 5, tzinfo=timezone.utc),
filament_used_grams=10.0,
created_at=datetime(2026, 5, 10, 10, 5, tzinfo=timezone.utc),
),
]
)
await db_session.commit()
response = await async_client.get("/api/v1/archives/")
assert response.status_code == 200
rows = response.json()
row = next(r for r in rows if r["id"] == archive.id)
assert row["run_count"] == 2
assert row["successful_run_count"] == 1
assert row["failed_run_count"] == 1
assert row["total_filament_actual_grams"] == pytest.approx(110.0)
assert row["last_run_at"] is not None # max(started_at) populated
@pytest.mark.asyncio
@pytest.mark.integration
async def test_runs_endpoint_returns_runs_newest_first(
async_client: AsyncClient, archive_factory, printer_factory, db_session
):
"""GET /archives/{id}/runs returns each PrintLogEntry for the archive."""
printer = await printer_factory()
archive = await archive_factory(
printer.id,
status="completed",
with_run=False,
)
db_session.add_all(
[
PrintLogEntry(
archive_id=archive.id,
printer_id=archive.printer_id,
status="completed",
started_at=datetime(2026, 4, 1, 10, 0, tzinfo=timezone.utc),
completed_at=datetime(2026, 4, 1, 11, 0, tzinfo=timezone.utc),
filament_used_grams=50.0,
),
PrintLogEntry(
archive_id=archive.id,
printer_id=archive.printer_id,
status="failed",
started_at=datetime(2026, 5, 1, 10, 0, tzinfo=timezone.utc),
completed_at=datetime(2026, 5, 1, 10, 5, tzinfo=timezone.utc),
filament_used_grams=5.0,
failure_reason="Cancelled by user",
),
]
)
await db_session.commit()
response = await async_client.get(f"/api/v1/archives/{archive.id}/runs")
assert response.status_code == 200
body = response.json()
assert body["total"] == 2
# Newest first
assert body["items"][0]["status"] == "failed"
assert body["items"][0]["failure_reason"] == "Cancelled by user"
assert body["items"][1]["status"] == "completed"
assert body["items"][1]["filament_used_grams"] == pytest.approx(50.0)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_purge_stats_also_deletes_linked_runs(
async_client: AsyncClient, archive_factory, printer_factory, db_session
):
"""``DELETE /archives/{id}?purge_stats=true`` hard-deletes linked PrintLogEntry
rows so their filament / cost / count contributions truly leave Quick Stats.
Without this, ON DELETE SET NULL on the FK would orphan the runs and they'd
keep showing up in the new aggregate-from-PrintLogEntry totals (#1378)."""
from sqlalchemy import func, select
printer = await printer_factory()
keep = await archive_factory(printer.id, status="completed", filament_used_grams=50.0)
purge = await archive_factory(printer.id, status="completed", filament_used_grams=100.0)
# Extra runs on the archive about to be purged, to prove they all go.
db_session.add_all(
[
PrintLogEntry(
archive_id=purge.id,
printer_id=purge.printer_id,
status="failed",
filament_used_grams=10.0,
),
PrintLogEntry(
archive_id=purge.id,
printer_id=purge.printer_id,
status="completed",
filament_used_grams=100.0,
),
]
)
await db_session.commit()
resp = await async_client.delete(f"/api/v1/archives/{purge.id}?purge_stats=true")
assert resp.status_code == 200
assert resp.json()["purged_from_stats"] is True
remaining = await db_session.execute(
select(func.count(PrintLogEntry.id)).where(PrintLogEntry.archive_id == purge.id)
)
assert remaining.scalar() == 0
# The OTHER archive's auto-synthesized run is still there.
keep_remaining = await db_session.execute(
select(func.count(PrintLogEntry.id)).where(PrintLogEntry.archive_id == keep.id)
)
assert keep_remaining.scalar() == 1
@pytest.mark.asyncio
@pytest.mark.integration
async def test_soft_delete_keeps_runs_for_stats(
async_client: AsyncClient, archive_factory, printer_factory, db_session
):
"""Default soft-delete (without ``purge_stats=true``) keeps the archive's
PrintLogEntry rows so the #1343 stats-preservation contract still holds —
the archive disappears from listings, but its filament / time / cost stay
in Quick Stats."""
from sqlalchemy import func, select
printer = await printer_factory()
archive = await archive_factory(printer.id, status="completed", filament_used_grams=75.0)
resp = await async_client.delete(f"/api/v1/archives/{archive.id}")
assert resp.status_code == 200
assert resp.json()["purged_from_stats"] is False
# The run row is still there for stats aggregation.
runs = await db_session.execute(select(func.count(PrintLogEntry.id)).where(PrintLogEntry.archive_id == archive.id))
assert runs.scalar() == 1
stats = (await async_client.get("/api/v1/archives/stats")).json()
assert stats["total_prints"] >= 1
assert stats["total_filament_grams"] >= 75.0
@pytest.mark.asyncio
@pytest.mark.integration
async def test_time_accuracy_excludes_multi_plate_plate_by_plate_outliers(
async_client: AsyncClient, archive_factory, printer_factory, db_session
):
"""Per-run accuracy clamps to a plausible 50%-200% band so multi-plate
archives printed plate-by-plate don't poison the printer-level average.
Pre-#1593 the parser stored plate-1-only time in
``PrintArchive.print_time_seconds``, so a plate-by-plate run produced a
near-100% ratio by accident. Post-#1593 the field is the sum across
plates, so each plate-by-plate run produces estimate/actual = N×100%
for an N-plate file. Without the band filter a single 3-plate file
printed plate-by-plate would drag the printer's accuracy reading to
~300%, which is pure noise. The metric is designed for the
single-plate-file case and should reflect real slicer drift there.
"""
printer = await printer_factory()
# Archive 1: single-plate file. Estimate 3600s, actual 3700s
# → ratio 97.3% (well within band).
single = await archive_factory(
printer.id,
print_time_seconds=3600,
with_run=False,
)
db_session.add(
PrintLogEntry(
archive_id=single.id,
printer_id=printer.id,
status="completed",
duration_seconds=3700,
)
)
# Archive 2: multi-plate file (3 plates totaling 18000s). Two runs
# printed plate-by-plate at ~6000s each — ratio 18000/6000 = 300%.
# Both must be filtered out so the printer average stays at the
# single-plate file's 97.3% reading.
multi = await archive_factory(
printer.id,
print_time_seconds=18000,
with_run=False,
)
db_session.add(
PrintLogEntry(
archive_id=multi.id,
printer_id=printer.id,
status="completed",
duration_seconds=6000,
)
)
db_session.add(
PrintLogEntry(
archive_id=multi.id,
printer_id=printer.id,
status="completed",
duration_seconds=6100,
)
)
await db_session.commit()
body = (await async_client.get("/api/v1/archives/stats")).json()
assert body["average_time_accuracy"] == pytest.approx(97.3, abs=0.1)
assert body["time_accuracy_by_printer"][str(printer.id)] == pytest.approx(97.3, abs=0.1)
# ---------------------------------------------------------------------------
# #1608: compute_time_accuracy suppresses the per-card badge for multi-run
# archives where the whole-file estimate is incommensurable with the
# latest-run actual.
# ---------------------------------------------------------------------------
class TestComputeTimeAccuracyMultiRun:
"""The card-level ``compute_time_accuracy`` runs against the archive's own
``started_at`` / ``completed_at`` (latest run only) and
``print_time_seconds`` (post-#1593 sum across plates). For multi-run
archives those describe different scopes — a 3-plate file printed
plate-by-plate over 3 runs produces estimate/actual = 300% → +200% badge,
which is pure noise. The reporter (#1608, archive #65) verified the
bug surfaces at +188% for a 3-plate file with 9 runs.
The fix: when the archive has more than one logged run, suppress BOTH
fields. The frontend then falls through to ``print_time_seconds`` for
the time display (so the user sees the slicer's whole-file estimate
instead of one run's wall-clock) and hides the badge.
"""
def _make_archive(self, *, print_time_seconds, started_at, completed_at, status="completed"):
from types import SimpleNamespace
return SimpleNamespace(
print_time_seconds=print_time_seconds,
started_at=started_at,
completed_at=completed_at,
status=status,
)
def test_single_run_keeps_original_behaviour(self):
"""``run_count == 1`` is the case the badge was designed for —
compute and return both actual + accuracy as before."""
from backend.app.api.routes.archives import compute_time_accuracy
archive = self._make_archive(
print_time_seconds=3600,
started_at=datetime(2026, 5, 1, 10, 0, tzinfo=timezone.utc),
completed_at=datetime(2026, 5, 1, 11, 0, tzinfo=timezone.utc),
)
result = compute_time_accuracy(archive, run_aggregate={"run_count": 1})
assert result["actual_time_seconds"] == 3600
assert result["time_accuracy"] == 100.0
def test_no_run_aggregate_keeps_original_behaviour(self):
"""Endpoints that don't yet load run_aggregates (legacy callers, or
contexts where the data isn't relevant) must keep the pre-fix
per-archive computation — never silently drop the badge."""
from backend.app.api.routes.archives import compute_time_accuracy
archive = self._make_archive(
print_time_seconds=3600,
started_at=datetime(2026, 5, 1, 10, 0, tzinfo=timezone.utc),
completed_at=datetime(2026, 5, 1, 10, 50, tzinfo=timezone.utc),
)
result = compute_time_accuracy(archive) # no run_aggregate
assert result["actual_time_seconds"] == 3000
assert result["time_accuracy"] == 120.0 # 3600/3000
def test_multi_run_archive_suppresses_both_fields(self):
"""Reporter's case (archive #65, 3 plates, 9 logged runs): one-run
actual (6364s) vs whole-file estimate (18354s) → +188% badge that
means nothing. Multi-run must clear both fields so the card falls
through to the estimate display with no badge."""
from backend.app.api.routes.archives import compute_time_accuracy
archive = self._make_archive(
print_time_seconds=18354,
started_at=datetime(2026, 5, 1, 10, 0, tzinfo=timezone.utc),
completed_at=datetime(2026, 5, 1, 11, 46, 4, tzinfo=timezone.utc), # ~6364s
)
result = compute_time_accuracy(archive, run_aggregate={"run_count": 9})
assert result["actual_time_seconds"] is None
assert result["time_accuracy"] is None
def test_run_count_zero_keeps_original_behaviour(self):
"""A run_aggregate that exists but reports zero runs (edge case from
the LEFT JOIN-style helper) must not trigger suppression — that's
not the multi-run shape, it's the no-runs shape, and the
per-archive timestamps are still meaningful (the archive was
marked completed without a PrintLogEntry trail, e.g. legacy
imports)."""
from backend.app.api.routes.archives import compute_time_accuracy
archive = self._make_archive(
print_time_seconds=3600,
started_at=datetime(2026, 5, 1, 10, 0, tzinfo=timezone.utc),
completed_at=datetime(2026, 5, 1, 11, 0, tzinfo=timezone.utc),
)
result = compute_time_accuracy(archive, run_aggregate={"run_count": 0})
assert result["actual_time_seconds"] == 3600
assert result["time_accuracy"] == 100.0
@pytest.mark.asyncio
@pytest.mark.integration
async def test_archive_list_suppresses_time_accuracy_for_multi_run_archives(
async_client: AsyncClient, archive_factory, printer_factory, db_session
):
"""#1608 integration: the card response from the main list endpoint
must report ``actual_time_seconds = null`` and ``time_accuracy = null``
for an archive with multiple logged runs, so the frontend renders the
slicer estimate without the misleading +N% badge."""
printer = await printer_factory()
# 3-plate file: estimate is the whole-file sum, latest run is one plate.
archive = await archive_factory(
printer.id,
status="completed",
print_time_seconds=18354, # all-plates estimate (post-#1593 parser)
started_at=datetime(2026, 5, 1, 10, 0, tzinfo=timezone.utc),
completed_at=datetime(2026, 5, 1, 11, 46, 4, tzinfo=timezone.utc), # ~6364s = one plate
with_run=False,
)
# Three runs each ~6364s — plate-by-plate.
for day in (1, 2, 3):
db_session.add(
PrintLogEntry(
archive_id=archive.id,
printer_id=archive.printer_id,
status="completed",
started_at=datetime(2026, 5, day, 10, 0, tzinfo=timezone.utc),
completed_at=datetime(2026, 5, day, 11, 46, 4, tzinfo=timezone.utc),
duration_seconds=6364,
)
)
await db_session.commit()
response = await async_client.get("/api/v1/archives/")
assert response.status_code == 200
row = next(r for r in response.json() if r["id"] == archive.id)
# Frontend renders archive.actual_time_seconds || archive.print_time_seconds —
# with actual cleared, it falls through to the estimate; with accuracy
# cleared, no badge renders.
assert row["actual_time_seconds"] is None, "multi-run actual is incommensurable with the estimate — must be null"
assert row["time_accuracy"] is None, "no badge for multi-run archives — the scopes don't match"
# The estimate itself is preserved so the card has something to display.
assert row["print_time_seconds"] == 18354
assert row["run_count"] == 3
@pytest.mark.asyncio
@pytest.mark.integration
async def test_archive_list_keeps_time_accuracy_for_single_run_archives(
async_client: AsyncClient, archive_factory, printer_factory, db_session
):
"""Sanity check for the #1608 fix: single-run archives (the case the
badge was designed for) keep their original badge behaviour."""
printer = await printer_factory()
archive = await archive_factory(
printer.id,
status="completed",
print_time_seconds=3600,
started_at=datetime(2026, 5, 1, 10, 0, tzinfo=timezone.utc),
completed_at=datetime(2026, 5, 1, 11, 0, tzinfo=timezone.utc),
with_run=False,
)
db_session.add(
PrintLogEntry(
archive_id=archive.id,
printer_id=archive.printer_id,
status="completed",
started_at=datetime(2026, 5, 1, 10, 0, tzinfo=timezone.utc),
completed_at=datetime(2026, 5, 1, 11, 0, tzinfo=timezone.utc),
duration_seconds=3600,
)
)
await db_session.commit()
row = next(r for r in (await async_client.get("/api/v1/archives/")).json() if r["id"] == archive.id)
assert row["actual_time_seconds"] == 3600
assert row["time_accuracy"] == 100.0
assert row["run_count"] == 1