PrintersPageKProfileDisplay.test.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. /**
  2. * Tests for #2532: K-profile value shown directly on the AMS slot card,
  3. * not only inside the hover popup.
  4. */
  5. import { describe, it, expect, beforeEach } from 'vitest';
  6. import { screen, waitFor } from '@testing-library/react';
  7. import { render } from '../utils';
  8. import { PrintersPage } from '../../pages/PrintersPage';
  9. import { http, HttpResponse } from 'msw';
  10. import { server } from '../mocks/server';
  11. const mockPrinters = [
  12. {
  13. id: 1,
  14. name: 'X1 Carbon',
  15. ip_address: '192.168.1.100',
  16. serial_number: '00M09A350100001',
  17. access_code: '12345678',
  18. model: 'X1C',
  19. enabled: true,
  20. is_active: true,
  21. nozzle_diameter: 0.4,
  22. nozzle_type: 'hardened_steel',
  23. location: 'Workshop',
  24. auto_archive: true,
  25. created_at: '2024-01-01T00:00:00Z',
  26. updated_at: '2024-01-01T00:00:00Z',
  27. },
  28. ];
  29. const mockPrinterStatus = {
  30. connected: true,
  31. state: 'IDLE',
  32. awaiting_plate_clear: false,
  33. progress: 0,
  34. layer_num: 0,
  35. total_layers: 0,
  36. temperatures: { nozzle: 25, bed: 25, chamber: 25 },
  37. remaining_time: 0,
  38. filename: null,
  39. wifi_signal: -50,
  40. vt_tray: [],
  41. };
  42. describe('PrintersPage - K-profile always-visible display (#2532)', () => {
  43. beforeEach(() => {
  44. localStorage.removeItem('printerCardSize');
  45. server.use(
  46. http.get('/api/v1/printers/', () => HttpResponse.json(mockPrinters)),
  47. http.get('/api/v1/settings/', () => HttpResponse.json({
  48. auto_archive: true,
  49. save_thumbnails: true,
  50. capture_finish_photo: true,
  51. default_filament_cost: 25.0,
  52. currency: 'USD',
  53. ams_humidity_good: 40,
  54. ams_humidity_fair: 60,
  55. ams_temp_good: 30,
  56. ams_temp_fair: 35,
  57. require_plate_clear: true,
  58. })),
  59. http.get('/api/v1/settings/ui-preferences', () => HttpResponse.json({
  60. ams_humidity_good: 40,
  61. ams_humidity_fair: 60,
  62. ams_temp_good: 30,
  63. ams_temp_fair: 35,
  64. require_plate_clear: true,
  65. })),
  66. http.get('/api/v1/queue/', () => HttpResponse.json([])),
  67. http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])),
  68. http.get('/api/v1/spoolman/settings', () => HttpResponse.json({
  69. spoolman_enabled: 'false', spoolman_url: '',
  70. })),
  71. );
  72. });
  73. it('shows the K-value on a loaded standard AMS slot without hovering', async () => {
  74. server.use(
  75. http.get('/api/v1/printers/:id/status', () => HttpResponse.json({
  76. ...mockPrinterStatus,
  77. // A standard (non-HT) AMS unit has 4 trays — htAms in PrintersPage.tsx
  78. // filters on tray.length === 1, so a single-tray mock here would
  79. // silently exercise the AMS-HT code path instead of this one.
  80. ams: [{
  81. id: 0,
  82. tray: [
  83. {
  84. id: 0,
  85. tray_type: 'PETG',
  86. tray_color: 'FF0000FF',
  87. tray_sub_brands: 'Bambu PETG HF',
  88. k: 0.024,
  89. },
  90. { id: 1, tray_type: null, state: 9 },
  91. { id: 2, tray_type: null, state: 9 },
  92. { id: 3, tray_type: null, state: 9 },
  93. ],
  94. }],
  95. })),
  96. );
  97. render(<PrintersPage />);
  98. // No hover/mouseEnter simulated here on purpose — the whole point of
  99. // #2532 is that this must be readable without one.
  100. await waitFor(() => {
  101. expect(screen.getByText('K 0.024')).toBeInTheDocument();
  102. });
  103. // The short "K" label is intentional (round-2 review: the full "K
  104. // Factor" label was clipping the value at narrow slot widths), but the
  105. // full localized name should still be reachable via the title attribute.
  106. expect(screen.getByText('K 0.024')).toHaveAttribute('title', 'K Factor');
  107. });
  108. it('does not show a K-value on an empty slot', async () => {
  109. server.use(
  110. http.get('/api/v1/printers/:id/status', () => HttpResponse.json({
  111. ...mockPrinterStatus,
  112. ams: [{
  113. id: 0,
  114. tray: [
  115. { id: 0, tray_type: null, state: 9 },
  116. { id: 1, tray_type: null, state: 9 },
  117. { id: 2, tray_type: null, state: 9 },
  118. { id: 3, tray_type: null, state: 9 },
  119. ],
  120. }],
  121. })),
  122. );
  123. render(<PrintersPage />);
  124. await waitFor(() => {
  125. // All 4 slots are empty, so several "Empty" labels render.
  126. expect(screen.getAllByText('Empty').length).toBeGreaterThan(0);
  127. });
  128. // ... but formatKValue() defaults to 0.020 when there's no tray data,
  129. // so this specifically guards against that default leaking onto an
  130. // empty slot as a misleading "K 0.020".
  131. expect(screen.queryByText(/^K 0\.020$/)).not.toBeInTheDocument();
  132. });
  133. it('does not show a fabricated K-value on a loaded slot with no reported K (review #2854)', async () => {
  134. // Regression test for maziggy's review on #2854: a slot can be loaded
  135. // (tray_type present) while the printer has simply never reported a K
  136. // value for it — k is null, not merely absent. This is common on X1C,
  137. // where K comes from a cali_idx lookup that can legitimately miss.
  138. // formatKValue() defaults null to 0.020, which reads as a real measured
  139. // value on this permanent, uncaptioned line, so the row must not render
  140. // at all in this case rather than showing that default.
  141. server.use(
  142. http.get('/api/v1/printers/:id/status', () => HttpResponse.json({
  143. ...mockPrinterStatus,
  144. // 4 trays — see comment on the first test above re: htAms filtering.
  145. ams: [{
  146. id: 0,
  147. tray: [
  148. {
  149. id: 0,
  150. tray_type: 'PETG',
  151. tray_color: 'FF0000FF',
  152. tray_sub_brands: 'Bambu PETG HF',
  153. k: null,
  154. },
  155. { id: 1, tray_type: null, state: 9 },
  156. { id: 2, tray_type: null, state: 9 },
  157. { id: 3, tray_type: null, state: 9 },
  158. ],
  159. }],
  160. })),
  161. );
  162. render(<PrintersPage />);
  163. await waitFor(() => {
  164. // The slot itself is loaded and visible ...
  165. expect(screen.getByText('PETG')).toBeInTheDocument();
  166. });
  167. // ... but no K-value line, fabricated or otherwise, should appear. Read the
  168. // slot's own text rather than only querying for a "K " label: a guard that
  169. // leaks a value without the label would pass the label query.
  170. expect(screen.getByText('PETG').parentElement).toHaveTextContent(/^1PETG$/);
  171. });
  172. it('does not show a K-value when the printer reports exactly 0 (review #2854, round 2)', async () => {
  173. // maziggy's round-2 question: does tray.k != null let a real firmware "0"
  174. // through as a misleading "K 0.000"? It does -- printer_manager.py assigns
  175. // the tray's own reported k verbatim and only filters falsy values when
  176. // falling back to a stored K-profile, so a 0 does reach this component.
  177. // Gate on a truthy tray.k, and render it through a ternary: `tray.k && ...`
  178. // evaluates to the number 0, and React renders numbers, so the guard itself
  179. // would print a bare "0" where the value belongs.
  180. server.use(
  181. http.get('/api/v1/printers/:id/status', () => HttpResponse.json({
  182. ...mockPrinterStatus,
  183. ams: [{
  184. id: 0,
  185. tray: [
  186. {
  187. id: 0,
  188. tray_type: 'PETG',
  189. tray_color: 'FF0000FF',
  190. tray_sub_brands: 'Bambu PETG HF',
  191. k: 0,
  192. },
  193. { id: 1, tray_type: null, state: 9 },
  194. { id: 2, tray_type: null, state: 9 },
  195. { id: 3, tray_type: null, state: 9 },
  196. ],
  197. }],
  198. })),
  199. );
  200. render(<PrintersPage />);
  201. await waitFor(() => {
  202. expect(screen.getByText('PETG')).toBeInTheDocument();
  203. });
  204. // The slot number and the material, and nothing else -- no "K 0.000", and
  205. // no stray "0" leaked by the guard.
  206. expect(screen.getByText('PETG').parentElement).toHaveTextContent(/^1PETG$/);
  207. });
  208. it('does not leak a stray 0 on an external or AMS-HT slot reporting exactly 0', async () => {
  209. // Same guard, same defect, on the two other slot types.
  210. server.use(
  211. http.get('/api/v1/printers/:id/status', () => HttpResponse.json({
  212. ...mockPrinterStatus,
  213. ams: [{ id: 1, tray: [{ id: 0, tray_type: 'ASA', tray_color: 'FFFFFFFF', k: 0 }] }],
  214. vt_tray: [{ id: 254, tray_type: 'PLA', tray_color: '000000FF', k: 0 }],
  215. })),
  216. );
  217. render(<PrintersPage />);
  218. await waitFor(() => {
  219. expect(screen.getByText('ASA')).toBeInTheDocument();
  220. });
  221. expect(screen.getByText('ASA').parentElement).toHaveTextContent(/^1ASA$/);
  222. expect(screen.getByText('PLA').parentElement).toHaveTextContent(/^1PLA$/);
  223. });
  224. it('shows the K-value on a loaded AMS-HT slot without hovering', async () => {
  225. // AMS-HT units are identified by having exactly one tray in their
  226. // `tray` array (vs. four for a standard AMS unit) — see the htAms
  227. // filter in PrintersPage.tsx.
  228. server.use(
  229. http.get('/api/v1/printers/:id/status', () => HttpResponse.json({
  230. ...mockPrinterStatus,
  231. ams: [{
  232. id: 1,
  233. tray: [{
  234. id: 0,
  235. tray_type: 'ASA',
  236. tray_color: 'FFFFFFFF',
  237. tray_sub_brands: 'Bambu ASA',
  238. k: 0.018,
  239. }],
  240. }],
  241. })),
  242. );
  243. render(<PrintersPage />);
  244. await waitFor(() => {
  245. expect(screen.getByText('K 0.018')).toBeInTheDocument();
  246. });
  247. });
  248. it('shows the K-value on a loaded external/dual-nozzle slot without hovering', async () => {
  249. server.use(
  250. http.get('/api/v1/printers/:id/status', () => HttpResponse.json({
  251. ...mockPrinterStatus,
  252. vt_tray: [{
  253. id: 254,
  254. tray_type: 'PLA',
  255. tray_color: '000000FF',
  256. tray_sub_brands: 'Bambu PLA Basic',
  257. k: 0.022,
  258. }],
  259. })),
  260. );
  261. render(<PrintersPage />);
  262. await waitFor(() => {
  263. expect(screen.getByText('K 0.022')).toBeInTheDocument();
  264. });
  265. });
  266. it('reserves the row on slots without a K-value so fill bars stay aligned', async () => {
  267. // The K line is an extra block child, so on a partially calibrated unit --
  268. // the normal state, not an edge case -- the calibrated slot's fill bar would
  269. // sit a line below its neighbours'. The slots without a value hold the row
  270. // open instead. A card with no calibrated slot anywhere keeps its old
  271. // height, which the tests above assert.
  272. server.use(
  273. http.get('/api/v1/printers/:id/status', () => HttpResponse.json({
  274. ...mockPrinterStatus,
  275. ams: [{
  276. id: 0,
  277. tray: [
  278. { id: 0, tray_type: 'PETG', tray_color: 'FF0000FF', k: 0.024 },
  279. { id: 1, tray_type: 'PLA', tray_color: '00FF00FF', k: null },
  280. { id: 2, tray_type: null, state: 9 },
  281. { id: 3, tray_type: null, state: 9 },
  282. ],
  283. }],
  284. })),
  285. );
  286. render(<PrintersPage />);
  287. await waitFor(() => {
  288. expect(screen.getByText('K 0.024')).toBeInTheDocument();
  289. });
  290. const calibrated = screen.getByText('PETG').parentElement as HTMLElement;
  291. const uncalibrated = screen.getByText('PLA').parentElement as HTMLElement;
  292. // Same number of children, so the fill bar sits at the same offset in both.
  293. expect(uncalibrated.children.length).toBe(calibrated.children.length);
  294. // The reserved row carries no readable text of its own.
  295. expect(uncalibrated).toHaveTextContent(/^2PLA$/);
  296. });
  297. });