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

This commit is contained in:
gyrene2083
2026-08-19 09:22:30 +02:00
committed by GitHub
parent 3901043238
commit 8d1daab23a
17 changed files with 516 additions and 135 deletions
@@ -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
View File
@@ -4897,6 +4897,7 @@ export default {
externalSpool: 'Externe Spule',
profile: 'Profil',
kFactor: 'K-Faktor',
kFactorShort: 'K',
fill: 'Füllstand',
configure: 'Konfigurieren',
used: 'verwendet',
+1
View File
@@ -4941,6 +4941,7 @@ export default {
externalSpool: 'External Spool',
profile: 'Profile',
kFactor: 'K Factor',
kFactorShort: 'K',
fill: 'Fill',
configure: 'Configure',
used: 'used',
+1
View File
@@ -4904,6 +4904,7 @@ export default {
externalSpool: 'Bobina externa',
profile: 'Perfil',
kFactor: 'Factor K',
kFactorShort: 'K',
fill: 'Rellenar',
configure: 'Configurar',
used: 'usado',
+1
View File
@@ -4886,6 +4886,7 @@ export default {
externalSpool: 'Bobine externe',
profile: 'Profil',
kFactor: 'Facteur K',
kFactorShort: 'K',
fill: 'Remplir',
configure: 'Configurer',
used: 'utilisé',
+1
View File
@@ -4885,6 +4885,7 @@ export default {
externalSpool: 'Bobina esterna',
profile: 'Profilo',
kFactor: 'Fattore K',
kFactorShort: 'K',
fill: 'Livello',
configure: 'Configura',
used: 'utilizzato',
+1
View File
@@ -4897,6 +4897,7 @@ export default {
externalSpool: '外部スプール',
profile: 'プロファイル',
kFactor: 'K値',
kFactorShort: 'K値',
fill: '充填率',
configure: '設定',
used: '使用済み',
+1
View File
@@ -4666,6 +4666,7 @@ export default {
externalSpool: '외부 스풀',
profile: '프로필',
kFactor: 'K 계수',
kFactorShort: 'K',
fill: '채우기',
configure: '구성',
used: '사용됨',
+1
View File
@@ -4885,6 +4885,7 @@ export default {
externalSpool: 'Carretel Externo',
profile: 'Perfil',
kFactor: 'Fator K',
kFactorShort: 'K',
fill: 'Preencher',
configure: 'Configurar',
used: 'usado',
+1
View File
@@ -4658,6 +4658,7 @@ export default {
externalSpool: "Внешняя катушка",
profile: "Профиль",
kFactor: "Коэффициент K",
kFactorShort: "K",
fill: "Заполнить",
configure: "Настроить",
used: "использовано",
+1
View File
@@ -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
View File
@@ -4939,6 +4939,7 @@ export default {
externalSpool: "Зовнішня котушка",
profile: "Профіль",
kFactor: "Фактор K",
kFactorShort: "K",
fill: "Рівень заповнення",
configure: "Налаштувати",
used: "використовується",
+1
View File
@@ -4885,6 +4885,7 @@ export default {
externalSpool: '外置耗材',
profile: '配置',
kFactor: 'K 值',
kFactorShort: 'K 值',
fill: '填充',
configure: '配置',
used: '已使用',
+1
View File
@@ -4885,6 +4885,7 @@ export default {
externalSpool: '外接耗材',
profile: '設定',
kFactor: 'K 值',
kFactorShort: 'K 值',
fill: '填充',
configure: '設定',
used: '已使用',
+40
View File
@@ -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
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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>