فهرست منبع

fix(stats): align Filament Used / By Time / Success Rate with Total Consumed and Total Prints (#1390 follow-up)

  Three independent root causes behind the divergences the reporter
  flagged after the archived-spool fix shipped — fixed together.

  (1) Filament Used vs Total Consumed.
  _compute_run_filament_grams returned the slicer estimate for completed
  prints even when inventory had measured the actual AMS weight delta.
  That made Stats and Inventory two different sources of truth: Stats
  showed slicer-estimate grams, Inventory showed AMS-tracked grams, and
  the two never agreed. Reordered the helper so the tracked spool delta
  (same source that drives weight_used behind Total Consumed) takes
  priority for every status. Slicer estimate stays as the fallback when
  no inventory was tracked; partial-progress scale stays as the fallback
  for failed/cancelled with no tracker. The _run_cost block right next
  to it was already tracker-first; only filament_used_grams was
  inconsistent.

  (2) Printer Stats By Time vs Quick Stats Print Time.
  /archives/slim only set actual_time_seconds when status == "completed".
  For failed/cancelled rows the frontend fell back to print_time_seconds
  (the slicer's full-print estimate — wrong number for a print that
  failed at 15%). Quick Stats already summed elapsed duration across
  all statuses, so the two halves of the page disagreed by the
  (estimate - actual-elapsed) gap on every non-completed event. Dropped
  the completed-only gate; failed/cancelled now report measured elapsed.

  (3) Success Rate %.
  Was successful / (successful + failed), excluding cancelled / stopped
  from the denominator. With "Total Prints: N" displayed right above
  the gauge that produced confusing numbers — 4 successful, 0 failed,
  48 cancelled showed 100% out of an apparent 52 prints. Switched to
  successful / total_prints — matches the count the user reads from
  the widget header.
maziggy 1 هفته پیش
والد
کامیت
fc32b388de

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
CHANGELOG.md


+ 8 - 5
backend/app/api/routes/archives.py

@@ -466,14 +466,17 @@ async def list_archives_slim(
             "print_name": r.print_name,
             "print_time_seconds": r.print_time_seconds,
             "actual_time_seconds": (
+                # Measured elapsed time for every status (#1390): failed /
+                # cancelled prints still ran for some duration, and Quick
+                # Stats already counts that. Widgets that fall back to
+                # print_time_seconds (slicer estimate) for non-completed
+                # events would diverge from Quick Stats — so expose the
+                # measured value here unconditionally.
                 r.duration_seconds
-                if r.duration_seconds and r.duration_seconds > 0 and r.status == "completed"
+                if r.duration_seconds and r.duration_seconds > 0
                 else (
                     int((r.completed_at - r.started_at).total_seconds())
-                    if r.started_at
-                    and r.completed_at
-                    and r.status == "completed"
-                    and (r.completed_at - r.started_at).total_seconds() > 0
+                    if r.started_at and r.completed_at and (r.completed_at - r.started_at).total_seconds() > 0
                     else None
                 )
             ),

+ 13 - 11
backend/app/main.py

@@ -593,22 +593,24 @@ def _compute_run_filament_grams(
     progress: float | int | None,
     usage_results: list[dict] | None,
 ) -> float | None:
-    """Per-run filament for PrintLogEntry, partial-aware (#1378).
-
-    For ``completed``: returns the archive's slicer estimate (which approximates
-    actual since the print finished). For failed / cancelled / stopped:
-        1. Sum of tracked spool deltas in ``usage_results`` (most accurate
-           when inventory is configured for the print).
-        2. ``estimate * progress%`` (when no inventory delta available).
-        3. ``None`` (no signal at all — e.g. progress=0 and no spool data).
+    """Per-run filament for PrintLogEntry, partial- and tracker-aware (#1378, #1390).
+
+    Priority for every status:
+        1. Sum of tracked spool deltas in ``usage_results`` (AMS-measured
+           weight delta — same source that drives "Total Consumed" on the
+           Inventory page, so Stats and Inventory totals stay aligned).
+        2. For ``completed``: the slicer estimate (no tracker available, fall
+           back to the canonical "this print used X" value).
+        3. For partial statuses: ``estimate * progress%``.
+        4. ``None`` if nothing is known.
     """
-    if status == "completed":
-        return archive_filament_used_grams
-
     tracked_grams = sum(r.get("weight_used") or 0 for r in (usage_results or []))
     if tracked_grams > 0:
         return round(tracked_grams, 1)
 
+    if status == "completed":
+        return archive_filament_used_grams
+
     if archive_filament_used_grams:
         scale = max(0.0, min(((progress or 0) / 100.0), 1.0))
         if scale > 0:

+ 6 - 3
backend/tests/integration/test_archives_api.py

@@ -529,10 +529,13 @@ class TestArchivesSlimAPI:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_slim_actual_time_null_for_failed(
+    async def test_slim_actual_time_for_failed_includes_elapsed(
         self, async_client: AsyncClient, archive_factory, printer_factory, db_session
     ):
-        """Verify actual_time_seconds is null for non-completed prints."""
+        """Failed prints report measured elapsed time so Printer Stats By Time
+        matches Quick Stats Print Time (#1390). Previously this returned null
+        and the frontend fell back to the slicer estimate, double-counting the
+        unfinished portion of the print."""
         from datetime import datetime, timezone
 
         printer = await printer_factory()
@@ -547,7 +550,7 @@ class TestArchivesSlimAPI:
 
         assert response.status_code == 200
         item = response.json()[0]
-        assert item["actual_time_seconds"] is None
+        assert item["actual_time_seconds"] == 3600
 
     @pytest.mark.asyncio
     @pytest.mark.integration

+ 11 - 9
backend/tests/unit/test_run_filament_helper.py

@@ -1,23 +1,25 @@
-"""Unit tests for the per-run filament helper (#1378).
+"""Unit tests for the per-run filament helper (#1378, #1390).
 
 The helper computes what value to write into PrintLogEntry.filament_used_grams
 for a given print event — partial-aware so failed / cancelled / stopped prints
-don't inflate stats with the full slicer estimate.
+don't inflate stats with the full slicer estimate, and tracker-aware so
+completed prints agree with the per-spool counter on the Inventory page.
 """
 
 from backend.app.main import _compute_run_filament_grams
 
 
 class TestComputeRunFilamentGrams:
-    def test_completed_returns_archive_estimate(self):
-        # Completed print: the slicer estimate is approximately what was used.
+    def test_completed_no_tracker_returns_archive_estimate(self):
+        # Completed print without inventory tracking: the slicer estimate is
+        # the canonical "this print used X" value.
         assert _compute_run_filament_grams("completed", 100.0, 100, []) == 100.0
 
-    def test_completed_returns_estimate_even_when_tracked_differs(self):
-        # When a print completes, the estimate is the canonical "this print used X"
-        # value — the tracked spool delta might be lower (some slots untracked)
-        # but the print is done, so the full estimate is the right answer.
-        assert _compute_run_filament_grams("completed", 100.0, 100, [{"weight_used": 10}]) == 100.0
+    def test_completed_prefers_tracked_over_estimate(self):
+        # #1390: when inventory tracked the AMS weight delta, Stats should
+        # reflect that — same source that drives "Total Consumed" on the
+        # Inventory page. Two halves of the app must show the same number.
+        assert _compute_run_filament_grams("completed", 100.0, 100, [{"weight_used": 96.5}]) == 96.5
 
     def test_failed_uses_tracked_spool_delta(self):
         # Failed reprint at 10g actual: inventory tracked the spool delta.

+ 66 - 1
frontend/src/__tests__/pages/StatsPage.test.tsx

@@ -210,10 +210,33 @@ describe('StatsPage', () => {
 
       await waitFor(() => {
         expect(screen.getByText('Success Rate')).toBeInTheDocument();
-        // Success rate: 140/(140+10) = 93%
+        // Success rate: 140 / 150 total = 93%
         expect(screen.getByText('93%')).toBeInTheDocument();
       });
     });
+
+    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.
+      server.use(
+        http.get('/api/v1/archives/stats', () =>
+          HttpResponse.json({
+            ...mockStats,
+            total_prints: 100,
+            successful_prints: 40,
+            failed_prints: 20,
+          }),
+        ),
+      );
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Success Rate')).toBeInTheDocument();
+        expect(screen.getByText('40%')).toBeInTheDocument();
+      });
+    });
   });
 
   describe('cost display', () => {
@@ -353,6 +376,48 @@ describe('StatsPage', () => {
       });
     });
 
+    it('Longest Print excludes failed prints (#1390)', async () => {
+      // After slim started populating actual_time_seconds for non-completed
+      // rows (so Printer Stats By Time would match Quick Stats), a failed
+      // print's elapsed duration could outrank successful prints in the
+      // Records widget. RecordsWidget gates "Longest Print" on
+      // status === 'completed' to preserve the pre-fix semantic.
+      server.use(
+        http.get('/api/v1/archives/slim', () =>
+          HttpResponse.json([
+            {
+              id: 10, created_at: '2024-02-01T10:00:00Z',
+              started_at: '2024-02-01T10:00:00Z', completed_at: null,
+              print_name: 'Aborted 25h Marathon', status: 'failed',
+              printer_id: 1, filament_type: 'PLA', filament_color: '#000000',
+              filament_used_grams: 50, actual_time_seconds: 90000,
+              print_time_seconds: 86400, cost: 1.50, quantity: 1,
+            },
+            {
+              id: 11, created_at: '2024-02-02T10:00:00Z',
+              started_at: '2024-02-02T10:00:00Z',
+              completed_at: '2024-02-02T18:00:00Z',
+              print_name: 'Successful 8h Print', status: 'completed',
+              printer_id: 1, filament_type: 'PLA', filament_color: '#FF0000',
+              filament_used_grams: 80, actual_time_seconds: 28800,
+              print_time_seconds: 27000, cost: 2.40, quantity: 1,
+            },
+          ]),
+        ),
+      );
+      render(<StatsPage />);
+
+      // Wait for the records widget itself to render.
+      await waitFor(() => {
+        expect(screen.getByText('Longest Print')).toBeInTheDocument();
+      });
+      // The failed 25h print must not surface as any record — its presence
+      // anywhere here would mean the status gate regressed.
+      expect(screen.queryByText('Aborted 25h Marathon')).not.toBeInTheDocument();
+      // The completed 8h print is the only candidate left, so it wins.
+      expect(screen.getAllByText('Successful 8h Print').length).toBeGreaterThan(0);
+    });
+
     it('shows heaviest print record', async () => {
       render(<StatsPage />);
 

+ 9 - 4
frontend/src/pages/StatsPage.tsx

@@ -197,9 +197,8 @@ function SuccessRateWidget({
   size?: 1 | 2 | 4;
 }) {
   const { t } = useTranslation();
-  const completedAndFailed = (stats?.successful_prints || 0) + (stats?.failed_prints || 0);
-  const successRate = completedAndFailed
-    ? Math.round((stats!.successful_prints / completedAndFailed) * 100)
+  const successRate = stats?.total_prints
+    ? Math.round(((stats.successful_prints || 0) / stats.total_prints) * 100)
     : 0;
 
   // Scale gauge size based on widget size
@@ -845,7 +844,13 @@ function RecordsWidget({ archives, currency }: { archives: ArchiveSlim[]; curren
       return { archive: best, value: bestVal };
     };
 
-    const longest = findMax(a => a.actual_time_seconds);
+    // Only completed prints qualify as the "longest" record. Pre-#1390 this
+    // happened implicitly because the slim endpoint returned null
+    // actual_time_seconds for non-completed rows; that gate moved up to the
+    // backend so failed/cancelled prints now carry their elapsed duration
+    // (Quick Stats Print Time needs that), but a partially-completed 20-hour
+    // run shouldn't outrank a successful 18-hour print here.
+    const longest = findMax(a => (a.status === 'completed' ? a.actual_time_seconds : null));
     if (longest.archive) {
       result.push({
         icon: Clock, iconColor: 'text-blue-400', label: t('stats.longestPrint'),

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/index-CDuyQ_oR.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BD1bsaGc.js"></script>
+    <script type="module" crossorigin src="/assets/index-CDuyQ_oR.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-KYwGxnG9.css">
   </head>
   <body>

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است