fix(stats): cancelled prints get their own bucket; gauge denominator excludes them (#1390 follow-up)

Reporter (@IndividualGhost1905) saw Total: 20 / Success: 18 / Failed: 1
  and asked where the 20th print went. The Quick Stats endpoint counted
  status == "completed" → Successful and status == "failed" → Failed, but
  used a raw count(*) for Total Prints, so the four other PrintLogEntry
  statuses (aborted, stopped, cancelled, skipped) silently inflated the
  total without showing up in any breakdown row. The earlier #1390 round
  had committed a test locking in this exact behaviour
  ("uses total_prints as denominator so cancelled/stopped events count"),
  which was wrong: it conflated user intent with print quality.

  Three-bucket classification, applied across the whole stats surface
  and matching how the rest of the codebase already groups statuses
  (main.py:430, 1729; failure_analysis status filter):

    successful = completed
    failed     = failed + aborted    (printer-detected quality failures)
    cancelled  = stopped + cancelled + skipped  (user/queue stopped)

  Quick Stats endpoint returns the new cancelled_prints field;
  ArchiveStats.cancelled_prints defaults to 0 so older fixtures still
  parse. SuccessRateWidget gauge now divides by successful + failed
  only — a cancelled roll no longer drags the gauge down — and a
  Cancelled row appears in the breakdown so the missing prints don't
  silently vanish from Total Prints.

  Failure Analysis service applies the same denominator fix to both
  the headline failure_rate and the per-week trend, so a week with
  several cancellations and zero failures reads as 0% rather than
  a misleading "failed / total".

  i18n: new stats.cancelled key in all 9 locales with real
  translations (no English fallback), parity script clean.

  Tests: the existing 'uses total_prints as denominator' assertion
  is inverted to assert the new behaviour (40 / 20 / 35 → 67% gauge,
  Cancelled: 35 visible). The unchanged-display path (140 / 10 / 0
  → 93%) still holds since 140 / (140 + 10) = 93.33% rounds the
  same. 33 StatsPage tests + 6 backend stats/failure tests green.
This commit is contained in:
maziggy
2026-05-24 15:21:10 +02:00
parent b9b06a7351
commit 1e734fb7c6
18 changed files with 492 additions and 423 deletions
@@ -14,6 +14,7 @@ const mockStats = {
total_prints: 150,
successful_prints: 140,
failed_prints: 10,
cancelled_prints: 0,
total_print_time_hours: 500.5,
total_filament_grams: 5500,
total_cost: 125.50,
@@ -215,11 +216,14 @@ describe('StatsPage', () => {
});
});
it('uses total_prints as denominator so cancelled/stopped events count (#1390)', async () => {
// 40 successful out of 100 total — with 20 failed and 40 cancelled/stopped
// mixed in. Old formula (successful / (successful + failed)) would have
// shown 40 / (40 + 20) = 67%. New formula shows 40 / 100 = 40%, which
// matches the "Total Prints: 100" the user reads right above the gauge.
it('excludes cancelled prints from the rate denominator and surfaces them in the breakdown (#1390)', async () => {
// 40 completed, 20 failed (printer-detected), 40 user-cancelled out of
// 100 total. The earlier fix divided by total_prints and reported 40%,
// which conflated user intent with print quality — cancelling a roll
// because you changed your mind shouldn't be counted against the
// printer's success rate. New behaviour: gauge = 40 / (40 + 20) = 67%;
// cancelled count still visible in the row breakdown so the missing
// 40 prints don't silently disappear.
server.use(
http.get('/api/v1/archives/stats', () =>
HttpResponse.json({
@@ -227,6 +231,7 @@ describe('StatsPage', () => {
total_prints: 100,
successful_prints: 40,
failed_prints: 20,
cancelled_prints: 35,
}),
),
);
@@ -234,7 +239,13 @@ describe('StatsPage', () => {
await waitFor(() => {
expect(screen.getByText('Success Rate')).toBeInTheDocument();
expect(screen.getByText('40%')).toBeInTheDocument();
expect(screen.getByText('67%')).toBeInTheDocument();
// Cancelled count surfaces in the breakdown so the missing prints
// aren't silently swallowed (was the original bug in #1390). Pick a
// value distinct from successful_prints/failed_prints to keep the
// getByText query unambiguous.
expect(screen.getByText('Cancelled:')).toBeInTheDocument();
expect(screen.getByText('35')).toBeInTheDocument();
});
});
});