Просмотр исходного кода

Show K-profile value on AMS slot card (#2854)

gyrene2083 2 недель назад
Родитель
Сommit
8d1daab23a

+ 328 - 0
frontend/src/__tests__/pages/PrintersPageKProfileDisplay.test.tsx

@@ -0,0 +1,328 @@
+/**
+ * Tests for #2532: K-profile value shown directly on the AMS slot card,
+ * not only inside the hover popup.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import { render } from '../utils';
+import { PrintersPage } from '../../pages/PrintersPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const mockPrinters = [
+  {
+    id: 1,
+    name: 'X1 Carbon',
+    ip_address: '192.168.1.100',
+    serial_number: '00M09A350100001',
+    access_code: '12345678',
+    model: 'X1C',
+    enabled: true,
+    is_active: true,
+    nozzle_diameter: 0.4,
+    nozzle_type: 'hardened_steel',
+    location: 'Workshop',
+    auto_archive: true,
+    created_at: '2024-01-01T00:00:00Z',
+    updated_at: '2024-01-01T00:00:00Z',
+  },
+];
+
+const mockPrinterStatus = {
+  connected: true,
+  state: 'IDLE',
+  awaiting_plate_clear: false,
+  progress: 0,
+  layer_num: 0,
+  total_layers: 0,
+  temperatures: { nozzle: 25, bed: 25, chamber: 25 },
+  remaining_time: 0,
+  filename: null,
+  wifi_signal: -50,
+  vt_tray: [],
+};
+
+describe('PrintersPage - K-profile always-visible display (#2532)', () => {
+  beforeEach(() => {
+    localStorage.removeItem('printerCardSize');
+
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json(mockPrinters)),
+      http.get('/api/v1/settings/', () => HttpResponse.json({
+        auto_archive: true,
+        save_thumbnails: true,
+        capture_finish_photo: true,
+        default_filament_cost: 25.0,
+        currency: 'USD',
+        ams_humidity_good: 40,
+        ams_humidity_fair: 60,
+        ams_temp_good: 30,
+        ams_temp_fair: 35,
+        require_plate_clear: true,
+      })),
+      http.get('/api/v1/settings/ui-preferences', () => HttpResponse.json({
+        ams_humidity_good: 40,
+        ams_humidity_fair: 60,
+        ams_temp_good: 30,
+        ams_temp_fair: 35,
+        require_plate_clear: true,
+      })),
+      http.get('/api/v1/queue/', () => HttpResponse.json([])),
+      http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])),
+      http.get('/api/v1/spoolman/settings', () => HttpResponse.json({
+        spoolman_enabled: 'false', spoolman_url: '',
+      })),
+    );
+  });
+
+  it('shows the K-value on a loaded standard AMS slot without hovering', async () => {
+    server.use(
+      http.get('/api/v1/printers/:id/status', () => HttpResponse.json({
+        ...mockPrinterStatus,
+        // A standard (non-HT) AMS unit has 4 trays — htAms in PrintersPage.tsx
+        // filters on tray.length === 1, so a single-tray mock here would
+        // silently exercise the AMS-HT code path instead of this one.
+        ams: [{
+          id: 0,
+          tray: [
+            {
+              id: 0,
+              tray_type: 'PETG',
+              tray_color: 'FF0000FF',
+              tray_sub_brands: 'Bambu PETG HF',
+              k: 0.024,
+            },
+            { id: 1, tray_type: null, state: 9 },
+            { id: 2, tray_type: null, state: 9 },
+            { id: 3, tray_type: null, state: 9 },
+          ],
+        }],
+      })),
+    );
+
+    render(<PrintersPage />);
+
+    // No hover/mouseEnter simulated here on purpose — the whole point of
+    // #2532 is that this must be readable without one.
+    await waitFor(() => {
+      expect(screen.getByText('K 0.024')).toBeInTheDocument();
+    });
+
+    // The short "K" label is intentional (round-2 review: the full "K
+    // Factor" label was clipping the value at narrow slot widths), but the
+    // full localized name should still be reachable via the title attribute.
+    expect(screen.getByText('K 0.024')).toHaveAttribute('title', 'K Factor');
+  });
+
+  it('does not show a K-value on an empty slot', async () => {
+    server.use(
+      http.get('/api/v1/printers/:id/status', () => HttpResponse.json({
+        ...mockPrinterStatus,
+        ams: [{
+          id: 0,
+          tray: [
+            { id: 0, tray_type: null, state: 9 },
+            { id: 1, tray_type: null, state: 9 },
+            { id: 2, tray_type: null, state: 9 },
+            { id: 3, tray_type: null, state: 9 },
+          ],
+        }],
+      })),
+    );
+
+    render(<PrintersPage />);
+
+    await waitFor(() => {
+      // All 4 slots are empty, so several "Empty" labels render.
+      expect(screen.getAllByText('Empty').length).toBeGreaterThan(0);
+    });
+    // ... but formatKValue() defaults to 0.020 when there's no tray data,
+    // so this specifically guards against that default leaking onto an
+    // empty slot as a misleading "K 0.020".
+    expect(screen.queryByText(/^K 0\.020$/)).not.toBeInTheDocument();
+  });
+
+  it('does not show a fabricated K-value on a loaded slot with no reported K (review #2854)', async () => {
+    // Regression test for maziggy's review on #2854: a slot can be loaded
+    // (tray_type present) while the printer has simply never reported a K
+    // value for it — k is null, not merely absent. This is common on X1C,
+    // where K comes from a cali_idx lookup that can legitimately miss.
+    // formatKValue() defaults null to 0.020, which reads as a real measured
+    // value on this permanent, uncaptioned line, so the row must not render
+    // at all in this case rather than showing that default.
+    server.use(
+      http.get('/api/v1/printers/:id/status', () => HttpResponse.json({
+        ...mockPrinterStatus,
+        // 4 trays — see comment on the first test above re: htAms filtering.
+        ams: [{
+          id: 0,
+          tray: [
+            {
+              id: 0,
+              tray_type: 'PETG',
+              tray_color: 'FF0000FF',
+              tray_sub_brands: 'Bambu PETG HF',
+              k: null,
+            },
+            { id: 1, tray_type: null, state: 9 },
+            { id: 2, tray_type: null, state: 9 },
+            { id: 3, tray_type: null, state: 9 },
+          ],
+        }],
+      })),
+    );
+
+    render(<PrintersPage />);
+
+    await waitFor(() => {
+      // The slot itself is loaded and visible ...
+      expect(screen.getByText('PETG')).toBeInTheDocument();
+    });
+    // ... but no K-value line, fabricated or otherwise, should appear. Read the
+    // slot's own text rather than only querying for a "K " label: a guard that
+    // leaks a value without the label would pass the label query.
+    expect(screen.getByText('PETG').parentElement).toHaveTextContent(/^1PETG$/);
+  });
+
+  it('does not show a K-value when the printer reports exactly 0 (review #2854, round 2)', async () => {
+    // maziggy's round-2 question: does tray.k != null let a real firmware "0"
+    // through as a misleading "K 0.000"? It does -- printer_manager.py assigns
+    // the tray's own reported k verbatim and only filters falsy values when
+    // falling back to a stored K-profile, so a 0 does reach this component.
+    // Gate on a truthy tray.k, and render it through a ternary: `tray.k && ...`
+    // evaluates to the number 0, and React renders numbers, so the guard itself
+    // would print a bare "0" where the value belongs.
+    server.use(
+      http.get('/api/v1/printers/:id/status', () => HttpResponse.json({
+        ...mockPrinterStatus,
+        ams: [{
+          id: 0,
+          tray: [
+            {
+              id: 0,
+              tray_type: 'PETG',
+              tray_color: 'FF0000FF',
+              tray_sub_brands: 'Bambu PETG HF',
+              k: 0,
+            },
+            { id: 1, tray_type: null, state: 9 },
+            { id: 2, tray_type: null, state: 9 },
+            { id: 3, tray_type: null, state: 9 },
+          ],
+        }],
+      })),
+    );
+
+    render(<PrintersPage />);
+
+    await waitFor(() => {
+      expect(screen.getByText('PETG')).toBeInTheDocument();
+    });
+    // The slot number and the material, and nothing else -- no "K 0.000", and
+    // no stray "0" leaked by the guard.
+    expect(screen.getByText('PETG').parentElement).toHaveTextContent(/^1PETG$/);
+  });
+
+  it('does not leak a stray 0 on an external or AMS-HT slot reporting exactly 0', async () => {
+    // Same guard, same defect, on the two other slot types.
+    server.use(
+      http.get('/api/v1/printers/:id/status', () => HttpResponse.json({
+        ...mockPrinterStatus,
+        ams: [{ id: 1, tray: [{ id: 0, tray_type: 'ASA', tray_color: 'FFFFFFFF', k: 0 }] }],
+        vt_tray: [{ id: 254, tray_type: 'PLA', tray_color: '000000FF', k: 0 }],
+      })),
+    );
+
+    render(<PrintersPage />);
+
+    await waitFor(() => {
+      expect(screen.getByText('ASA')).toBeInTheDocument();
+    });
+    expect(screen.getByText('ASA').parentElement).toHaveTextContent(/^1ASA$/);
+    expect(screen.getByText('PLA').parentElement).toHaveTextContent(/^1PLA$/);
+  });
+
+  it('shows the K-value on a loaded AMS-HT slot without hovering', async () => {
+    // AMS-HT units are identified by having exactly one tray in their
+    // `tray` array (vs. four for a standard AMS unit) — see the htAms
+    // filter in PrintersPage.tsx.
+    server.use(
+      http.get('/api/v1/printers/:id/status', () => HttpResponse.json({
+        ...mockPrinterStatus,
+        ams: [{
+          id: 1,
+          tray: [{
+            id: 0,
+            tray_type: 'ASA',
+            tray_color: 'FFFFFFFF',
+            tray_sub_brands: 'Bambu ASA',
+            k: 0.018,
+          }],
+        }],
+      })),
+    );
+
+    render(<PrintersPage />);
+
+    await waitFor(() => {
+      expect(screen.getByText('K 0.018')).toBeInTheDocument();
+    });
+  });
+
+  it('shows the K-value on a loaded external/dual-nozzle slot without hovering', async () => {
+    server.use(
+      http.get('/api/v1/printers/:id/status', () => HttpResponse.json({
+        ...mockPrinterStatus,
+        vt_tray: [{
+          id: 254,
+          tray_type: 'PLA',
+          tray_color: '000000FF',
+          tray_sub_brands: 'Bambu PLA Basic',
+          k: 0.022,
+        }],
+      })),
+    );
+
+    render(<PrintersPage />);
+
+    await waitFor(() => {
+      expect(screen.getByText('K 0.022')).toBeInTheDocument();
+    });
+  });
+
+  it('reserves the row on slots without a K-value so fill bars stay aligned', async () => {
+    // The K line is an extra block child, so on a partially calibrated unit --
+    // the normal state, not an edge case -- the calibrated slot's fill bar would
+    // sit a line below its neighbours'. The slots without a value hold the row
+    // open instead. A card with no calibrated slot anywhere keeps its old
+    // height, which the tests above assert.
+    server.use(
+      http.get('/api/v1/printers/:id/status', () => HttpResponse.json({
+        ...mockPrinterStatus,
+        ams: [{
+          id: 0,
+          tray: [
+            { id: 0, tray_type: 'PETG', tray_color: 'FF0000FF', k: 0.024 },
+            { id: 1, tray_type: 'PLA', tray_color: '00FF00FF', k: null },
+            { id: 2, tray_type: null, state: 9 },
+            { id: 3, tray_type: null, state: 9 },
+          ],
+        }],
+      })),
+    );
+
+    render(<PrintersPage />);
+
+    await waitFor(() => {
+      expect(screen.getByText('K 0.024')).toBeInTheDocument();
+    });
+
+    const calibrated = screen.getByText('PETG').parentElement as HTMLElement;
+    const uncalibrated = screen.getByText('PLA').parentElement as HTMLElement;
+    // Same number of children, so the fill bar sits at the same offset in both.
+    expect(uncalibrated.children.length).toBe(calibrated.children.length);
+    // The reserved row carries no readable text of its own.
+    expect(uncalibrated).toHaveTextContent(/^2PLA$/);
+  });
+});

+ 1 - 0
frontend/src/i18n/locales/de.ts

@@ -4897,6 +4897,7 @@ export default {
     externalSpool: 'Externe Spule',
     profile: 'Profil',
     kFactor: 'K-Faktor',
+    kFactorShort: 'K',
     fill: 'Füllstand',
     configure: 'Konfigurieren',
     used: 'verwendet',

+ 1 - 0
frontend/src/i18n/locales/en.ts

@@ -4941,6 +4941,7 @@ export default {
     externalSpool: 'External Spool',
     profile: 'Profile',
     kFactor: 'K Factor',
+    kFactorShort: 'K',
     fill: 'Fill',
     configure: 'Configure',
     used: 'used',

+ 1 - 0
frontend/src/i18n/locales/es.ts

@@ -4904,6 +4904,7 @@ export default {
     externalSpool: 'Bobina externa',
     profile: 'Perfil',
     kFactor: 'Factor K',
+    kFactorShort: 'K',
     fill: 'Rellenar',
     configure: 'Configurar',
     used: 'usado',

+ 1 - 0
frontend/src/i18n/locales/fr.ts

@@ -4886,6 +4886,7 @@ export default {
     externalSpool: 'Bobine externe',
     profile: 'Profil',
     kFactor: 'Facteur K',
+    kFactorShort: 'K',
     fill: 'Remplir',
     configure: 'Configurer',
     used: 'utilisé',

+ 1 - 0
frontend/src/i18n/locales/it.ts

@@ -4885,6 +4885,7 @@ export default {
     externalSpool: 'Bobina esterna',
     profile: 'Profilo',
     kFactor: 'Fattore K',
+    kFactorShort: 'K',
     fill: 'Livello',
     configure: 'Configura',
     used: 'utilizzato',

+ 1 - 0
frontend/src/i18n/locales/ja.ts

@@ -4897,6 +4897,7 @@ export default {
     externalSpool: '外部スプール',
     profile: 'プロファイル',
     kFactor: 'K値',
+    kFactorShort: 'K値',
     fill: '充填率',
     configure: '設定',
     used: '使用済み',

+ 1 - 0
frontend/src/i18n/locales/ko.ts

@@ -4666,6 +4666,7 @@ export default {
     externalSpool: '외부 스풀',
     profile: '프로필',
     kFactor: 'K 계수',
+    kFactorShort: 'K',
     fill: '채우기',
     configure: '구성',
     used: '사용됨',

+ 1 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -4885,6 +4885,7 @@ export default {
     externalSpool: 'Carretel Externo',
     profile: 'Perfil',
     kFactor: 'Fator K',
+    kFactorShort: 'K',
     fill: 'Preencher',
     configure: 'Configurar',
     used: 'usado',

+ 1 - 0
frontend/src/i18n/locales/ru.ts

@@ -4658,6 +4658,7 @@ export default {
     externalSpool: "Внешняя катушка",
     profile: "Профиль",
     kFactor: "Коэффициент K",
+    kFactorShort: "K",
     fill: "Заполнить",
     configure: "Настроить",
     used: "использовано",

+ 1 - 0
frontend/src/i18n/locales/tr.ts

@@ -4874,6 +4874,7 @@ export default {
     externalSpool: 'Harici Makara',
     profile: 'Profil',
     kFactor: 'K Faktörü',
+    kFactorShort: 'K',
     fill: 'Doldur',
     configure: 'Yapılandır',
     used: 'kullanılan',

+ 1 - 0
frontend/src/i18n/locales/uk.ts

@@ -4939,6 +4939,7 @@ export default {
     externalSpool: "Зовнішня котушка",
     profile: "Профіль",
     kFactor: "Фактор K",
+    kFactorShort: "K",
     fill: "Рівень заповнення",
     configure: "Налаштувати",
     used: "використовується",

+ 1 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -4885,6 +4885,7 @@ export default {
     externalSpool: '外置耗材',
     profile: '配置',
     kFactor: 'K 值',
+    kFactorShort: 'K 值',
     fill: '填充',
     configure: '配置',
     used: '已使用',

+ 1 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -4885,6 +4885,7 @@ export default {
     externalSpool: '外接耗材',
     profile: '設定',
     kFactor: 'K 值',
+    kFactorShort: 'K 值',
     fill: '填充',
     configure: '設定',
     used: '已使用',

+ 40 - 0
frontend/src/pages/PrintersPage.tsx

@@ -214,6 +214,36 @@ function formatKValue(k: number | null | undefined): string {
   return value.toFixed(3);
 }
 
+// K-profile value shown on the slot card itself (#2532) rather than only inside
+// the hover popup, the way BambuStudio shows it per slot.
+//
+// `k` arrives already gated on the slot being loaded; falsy means the printer
+// never reported a calibration for it. formatKValue() substitutes 0.020 for a
+// missing value, which is captioned inside the hover card but would read as a
+// real measurement on this permanent, uncaptioned line -- so an uncalibrated
+// slot gets no value at all. A ternary rather than `k && <div/>`: a firmware-
+// reported 0 makes that expression evaluate to the number 0, and React renders
+// numbers, putting a bare "0" under the material name.
+//
+// `reserve` holds the row's height open on the slots without a value whenever
+// some other slot on the same card has one, so every fill bar stays on one line.
+function KValueLine({ k, reserve }: { k: number | null | undefined; reserve: boolean }) {
+  const { t } = useTranslation();
+  const className = 'text-[length:var(--pc-t8,8px)] text-bambu-gray tabular-nums leading-none truncate';
+  if (!k) {
+    return reserve ? <div className={className} aria-hidden="true">&nbsp;</div> : null;
+  }
+  // Short label, full localized name on the title: "K Factor" / "K-Faktor" /
+  // "Facteur K" clipped the value itself below ~70px, and the slot grid floor is
+  // 3.5rem. tabular-nums rather than font-mono, which resolved to a different
+  // family per browser and so measured differently in Safari.
+  return (
+    <div className={className} title={t('ams.kFactor')}>
+      {t('ams.kFactorShort')} {formatKValue(k)}
+    </div>
+  );
+}
+
 // Nozzle side indicators (Bambu Lab style - square badge with L/R)
 function NozzleBadge({ side }: { side: 'L' | 'R' }) {
   const { mode } = useTheme();
@@ -2489,6 +2519,13 @@ function PrinterCard({
     }
   }, [status?.ams]);
   const amsData = (status?.ams && status.ams.length > 0) ? status.ams : cachedAmsData.current;
+  // #2532: the K-profile line only exists on slots the printer has actually
+  // calibrated. The AMS units and the external-spool group are flex siblings in
+  // one row, so on a card that shows a value anywhere, the slots without one
+  // reserve the same height -- otherwise their fill bars sit a line above their
+  // neighbours'. A card with no calibrated slot at all is unaffected.
+  const anySlotHasKValue = amsData.some(unit => unit.tray.some(tray => tray.k))
+    || (status?.vt_tray ?? []).some(tray => tray.k);
 
   // Confirm a drying cycle actually started (#2533). Firmware answers
   // ams_filament_drying with result=success even when it then silently declines,
@@ -5467,6 +5504,7 @@ function PrinterCard({
                                     <div className="text-[length:var(--pc-t9,9px)] text-white font-bold truncate">
                                       {tray?.tray_type || t(emptyKind === 'reset' ? 'ams.slotUnconfigured' : 'ams.slotEmpty')}
                                     </div>
+                                    <KValueLine k={filamentData ? tray?.k : null} reserve={anySlotHasKValue} />
                                     {/* Fill bar */}
                                     <div className="mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden">
                                       {effectiveFill !== null && effectiveFill >= 0 && !isEmpty && tray && (
@@ -5755,6 +5793,7 @@ function PrinterCard({
                             <div className="text-[length:var(--pc-t9,9px)] text-white font-bold truncate">
                               {tray?.tray_type || t(emptyKind === 'reset' ? 'ams.slotUnconfigured' : 'ams.slotEmpty')}
                             </div>
+                            <KValueLine k={filamentData ? tray?.k : null} reserve={anySlotHasKValue} />
                             {/* Fill bar */}
                             <div className="mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden">
                               {htEffectiveFill !== null && htEffectiveFill >= 0 && !isEmpty && (
@@ -6140,6 +6179,7 @@ function PrinterCard({
                                   <div className={`text-[length:var(--pc-t9,9px)] font-bold truncate ${isEmpty ? 'text-white/40' : 'text-white'}`}>
                                     {extTray.tray_type || t('ams.slotEmpty')}
                                   </div>
+                                  <KValueLine k={isEmpty ? null : extTray.k} reserve={anySlotHasKValue} />
                                   <div className="mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden">
                                     {extEffectiveFill !== null && extEffectiveFill >= 0 && !isEmpty && (
                                       <div

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-BumpPKBp.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-3zC9-lDQ.js"></script>
+    <script type="module" crossorigin src="/assets/index-BumpPKBp.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-kSJGQrMr.css">
   </head>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов