Browse Source

Fix queued ETA visibility and add rendering tests

maziggy 1 tháng trước cách đây
mục cha
commit
234809fad1

+ 149 - 1
frontend/src/__tests__/pages/QueuePage.test.tsx

@@ -3,7 +3,7 @@
  */
 
 import { describe, it, expect, beforeEach, vi } from 'vitest';
-import { screen, waitFor } from '@testing-library/react';
+import { screen, waitFor, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { render } from '../utils';
 import { QueuePage } from '../../pages/QueuePage';
@@ -197,6 +197,154 @@ describe('QueuePage', () => {
       });
     });
 
+    it('shows one if-started-now ETA for an eligible pending item', async () => {
+      render(<QueuePage />);
+
+      const name = await screen.findByText('Test Print 1');
+      const row = name.closest('.group');
+
+      expect(row).not.toBeNull();
+      expect(
+        within(row as HTMLElement).getAllByTestId('queue-item-eta'),
+      ).toHaveLength(1);
+    });
+
+    it('shows one if-started-now ETA for a staged item', async () => {
+      server.use(
+        http.get('/api/v1/queue/', () => {
+          return HttpResponse.json([
+            {
+              ...mockQueueItems[0],
+              archive_name: 'Staged Print',
+              manual_start: true,
+            },
+          ]);
+        }),
+      );
+
+      render(<QueuePage />);
+
+      const name = await screen.findByText('Staged Print');
+      const row = name.closest('.group');
+
+      expect(row).not.toBeNull();
+      expect(
+        within(row as HTMLElement).getAllByTestId('queue-item-eta'),
+      ).toHaveLength(1);
+    });
+
+    it('shows exactly one live ETA for a printing item', async () => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', ({ params }) => {
+          return HttpResponse.json({
+            id: Number(params.id),
+            name: 'Test Printer',
+            connected: true,
+            state: 'RUNNING',
+            progress: 50,
+            remaining_time: 60,
+            layer_num: 50,
+            total_layers: 100,
+            filename: 'active.3mf',
+          });
+        }),
+      );
+
+      render(<QueuePage />);
+
+      const name = await screen.findByText('Active Print');
+      const row = name.closest('.group');
+
+      expect(row).not.toBeNull();
+
+      await waitFor(() => {
+        expect(
+          within(row as HTMLElement).getAllByText(/^ETA\s/),
+        ).toHaveLength(1);
+      });
+
+      expect(
+        within(row as HTMLElement).queryByTestId('queue-item-eta'),
+      ).not.toBeInTheDocument();
+    });
+
+    it('does not show an ETA for a waiting item', async () => {
+      server.use(
+        http.get('/api/v1/queue/', () => {
+          return HttpResponse.json([
+            {
+              ...mockQueueItems[0],
+              archive_name: 'Waiting Print',
+              waiting_reason: 'Waiting for matching printer',
+            },
+          ]);
+        }),
+      );
+
+      render(<QueuePage />);
+
+      const name = await screen.findByText('Waiting Print');
+      const row = name.closest('.group');
+
+      expect(row).not.toBeNull();
+      expect(
+        within(row as HTMLElement).queryByTestId('queue-item-eta'),
+      ).not.toBeInTheDocument();
+    });
+
+    it('does not show an if-started-now ETA for a scheduled item', async () => {
+      server.use(
+        http.get('/api/v1/queue/', () => {
+          return HttpResponse.json([
+            {
+              ...mockQueueItems[0],
+              archive_name: 'Scheduled Print',
+              scheduled_time: new Date(
+                Date.now() + 5 * 60 * 60 * 1000,
+              ).toISOString(),
+            },
+          ]);
+        }),
+      );
+
+      render(<QueuePage />);
+
+      const name = await screen.findByText('Scheduled Print');
+      const row = name.closest('.group');
+
+      expect(row).not.toBeNull();
+      expect(
+        within(row as HTMLElement).queryByTestId('queue-item-eta'),
+      ).not.toBeInTheDocument();
+    });
+
+    it('does not render a dangling ETA for an invalid duration', async () => {
+      server.use(
+        http.get('/api/v1/queue/', () => {
+          return HttpResponse.json([
+            {
+              ...mockQueueItems[0],
+              archive_name: 'Invalid Duration Print',
+              print_time_seconds: -60,
+            },
+          ]);
+        }),
+      );
+
+      render(<QueuePage />);
+
+      const name = await screen.findByText('Invalid Duration Print');
+      const row = name.closest('.group');
+
+      expect(row).not.toBeNull();
+      expect(
+        within(row as HTMLElement).queryByTestId('queue-item-eta'),
+      ).not.toBeInTheDocument();
+      expect(
+        within(row as HTMLElement).queryByText(/^ETA(?:\s|$)/),
+      ).not.toBeInTheDocument();
+    });
+
     it('shows completed items in history', async () => {
       const user = userEvent.setup();
       render(<QueuePage />);

+ 0 - 27
frontend/src/__tests__/utils/queueEta.test.ts

@@ -1,27 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { formatETA } from '../../utils/date';
-import { formatQueueItemETA } from '../../utils/queueEta';
-
-describe('formatQueueItemETA', () => {
-  beforeEach(() => {
-    vi.useFakeTimers();
-    vi.setSystemTime(new Date('2026-07-31T18:00:00Z'));
-  });
-
-  afterEach(() => {
-    vi.useRealTimers();
-  });
-
-  it('calculates the ETA from the current time and job duration', () => {
-    expect(formatQueueItemETA(90 * 60, '24h')).toBe(
-      formatETA(90, '24h'),
-    );
-  });
-
-  it('returns null without a usable print duration', () => {
-    expect(formatQueueItemETA(null)).toBeNull();
-    expect(formatQueueItemETA(undefined)).toBeNull();
-    expect(formatQueueItemETA(0)).toBeNull();
-    expect(formatQueueItemETA(-60)).toBeNull();
-  });
-});

+ 24 - 15
frontend/src/pages/QueuePage.tsx

@@ -66,7 +66,6 @@ import { api, ApiError } from '../api/client';
 import { PipelineRunsView } from './PipelineRunsPage';
 import { type TimeFormat, formatETA, formatDuration, formatRelativeTime, parseUTCDate } from '../utils/date';
 import { getBedTypeInfo } from '../utils/bedType';
-import { formatQueueItemETA } from '../utils/queueEta';
 import type { PrintQueueItem, PrintQueueBulkUpdate, Permission, CalibrationMode } from '../api/client';
 import { Card } from '../components/Card';
 import { Button } from '../components/Button';
@@ -429,6 +428,17 @@ function SortableQueueItem({
   const isPending = item.status === 'pending';
   const isHistory = ['completed', 'failed', 'skipped', 'cancelled'].includes(item.status);
 
+  // This is an "if started now" estimate, not a cumulative queue forecast.
+  // Do not show it for active, blocked, scheduled, or invalid queue items.
+  const queueItemEta =
+    isPending &&
+    !item.waiting_reason &&
+    !item.scheduled_time &&
+    item.print_time_seconds != null &&
+    item.print_time_seconds > 0
+      ? formatETA(item.print_time_seconds / 60, timeFormat, t)
+      : null;
+
   const isMobileSelectable = isPending && onToggleSelect;
 
   return (
@@ -599,20 +609,19 @@ function SortableQueueItem({
               </span>
             </span>
             {item.print_time_seconds && (
-              <>
-                <span className="flex items-center gap-1 sm:gap-1.5">
-                  <Timer className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
-                  {formatDuration(item.print_time_seconds)}
-                </span>
-                {!item.waiting_reason && (
-                  <span
-                    className="text-bambu-green font-medium"
-                    title={t('printers.estimatedCompletion')}
-                  >
-                    ETA {formatQueueItemETA(item.print_time_seconds, timeFormat, t)}
-                  </span>
-                )}
-              </>
+              <span className="flex items-center gap-1 sm:gap-1.5">
+                <Timer className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
+                {formatDuration(item.print_time_seconds)}
+              </span>
+            )}
+            {queueItemEta && (
+              <span
+                data-testid="queue-item-eta"
+                className="text-bambu-green font-medium"
+                title={t('printers.estimatedCompletion')}
+              >
+                ETA {queueItemEta}
+              </span>
             )}
             {item.filament_used_grams && (
               <span className="flex items-center gap-1 sm:gap-1.5">

+ 0 - 18
frontend/src/utils/queueEta.ts

@@ -1,18 +0,0 @@
-import { formatETA, type TimeFormat } from './date';
-
-/**
- * Formats the estimated completion time for a queue item if it were
- * started at the current time.
- *
- * This is deliberately a per-job estimate rather than a cumulative
- * queue forecast.
- */
-export function formatQueueItemETA(
-  printTimeSeconds: number | null | undefined,
-  timeFormat: TimeFormat = 'system',
-  t?: Parameters<typeof formatETA>[2],
-): string | null {
-  if (printTimeSeconds == null || printTimeSeconds <= 0) return null;
-
-  return formatETA(printTimeSeconds / 60, timeFormat, t);
-}