mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
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.
This commit is contained in:
@@ -312,3 +312,187 @@ async def test_time_accuracy_excludes_multi_plate_plate_by_plate_outliers(
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user