mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
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.
499 lines
19 KiB
Python
499 lines
19 KiB
Python
"""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
|