PrintersPage.test.tsx 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257
  1. /**
  2. * Tests for the PrintersPage component.
  3. */
  4. import { describe, it, expect, beforeEach } from 'vitest';
  5. import { screen, waitFor, fireEvent } from '@testing-library/react';
  6. import userEvent from '@testing-library/user-event';
  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. nozzle_diameter: 0.4,
  21. nozzle_type: 'hardened_steel',
  22. location: 'Workshop',
  23. auto_archive: true,
  24. created_at: '2024-01-01T00:00:00Z',
  25. updated_at: '2024-01-01T00:00:00Z',
  26. },
  27. {
  28. id: 2,
  29. name: 'P1S Backup',
  30. ip_address: '192.168.1.101',
  31. serial_number: '00W00A123456789',
  32. access_code: '87654321',
  33. model: 'P1S',
  34. enabled: false,
  35. nozzle_diameter: 0.4,
  36. nozzle_type: 'stainless_steel',
  37. location: null,
  38. auto_archive: true,
  39. created_at: '2024-01-02T00:00:00Z',
  40. updated_at: '2024-01-02T00:00:00Z',
  41. },
  42. ];
  43. const mockPrinterStatus = {
  44. connected: true,
  45. state: 'IDLE',
  46. awaiting_plate_clear: false,
  47. progress: 0,
  48. layer_num: 0,
  49. total_layers: 0,
  50. temperatures: {
  51. nozzle: 25,
  52. bed: 25,
  53. chamber: 25,
  54. },
  55. remaining_time: 0,
  56. filename: null,
  57. wifi_signal: -50,
  58. vt_tray: [],
  59. };
  60. const selectToolbarDropdownOption = async (triggerName: RegExp, optionName: RegExp) => {
  61. const user = userEvent.setup();
  62. await user.click(screen.getByRole('button', { name: triggerName }));
  63. await user.click(await screen.findByRole('button', { name: optionName }));
  64. };
  65. describe('PrintersPage', () => {
  66. beforeEach(() => {
  67. localStorage.removeItem('printerCardSize');
  68. server.use(
  69. http.get('/api/v1/printers/', () => {
  70. return HttpResponse.json(mockPrinters);
  71. }),
  72. http.get('/api/v1/printers/:id/status', () => {
  73. return HttpResponse.json(mockPrinterStatus);
  74. }),
  75. http.post('/api/v1/printers/:id/clear-plate', () => {
  76. return HttpResponse.json({ success: true, message: 'Plate cleared' });
  77. }),
  78. http.get('/api/v1/settings/', () => {
  79. return HttpResponse.json({
  80. auto_archive: true,
  81. save_thumbnails: true,
  82. capture_finish_photo: true,
  83. default_filament_cost: 25.0,
  84. currency: 'USD',
  85. ams_humidity_good: 40,
  86. ams_humidity_fair: 60,
  87. ams_temp_good: 30,
  88. ams_temp_fair: 35,
  89. require_plate_clear: true,
  90. });
  91. }),
  92. // PrintersPage now reads UI rendering fields from the public ui-preferences
  93. // endpoint instead of /settings (#1293) — admin pages still hit /settings.
  94. http.get('/api/v1/settings/ui-preferences', () => {
  95. return HttpResponse.json({
  96. ams_humidity_good: 40,
  97. ams_humidity_fair: 60,
  98. ams_temp_good: 30,
  99. ams_temp_fair: 35,
  100. require_plate_clear: true,
  101. });
  102. }),
  103. http.get('/api/v1/queue/', () => {
  104. return HttpResponse.json([]);
  105. })
  106. );
  107. });
  108. describe('rendering', () => {
  109. it('renders the page title', async () => {
  110. render(<PrintersPage />);
  111. await waitFor(() => {
  112. expect(screen.getByText('Printers')).toBeInTheDocument();
  113. });
  114. });
  115. it('shows printer cards', async () => {
  116. render(<PrintersPage />);
  117. await waitFor(() => {
  118. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  119. expect(screen.getByText('P1S Backup')).toBeInTheDocument();
  120. });
  121. });
  122. it('shows printer models', async () => {
  123. render(<PrintersPage />);
  124. await waitFor(() => {
  125. expect(screen.getByText('X1C')).toBeInTheDocument();
  126. expect(screen.getByText('P1S')).toBeInTheDocument();
  127. });
  128. });
  129. it('shows printer status', async () => {
  130. render(<PrintersPage />);
  131. await waitFor(() => {
  132. // Status should be shown - may vary based on state
  133. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  134. });
  135. });
  136. });
  137. describe('printer info', () => {
  138. it('shows IP address in printer info modal', async () => {
  139. render(<PrintersPage />);
  140. await waitFor(() => {
  141. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  142. });
  143. // IP address is shown in the PrinterInfoModal (accessed via 3-dot menu),
  144. // not directly on the card. Verify the printer data loaded correctly.
  145. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  146. });
  147. it('shows location when set', async () => {
  148. render(<PrintersPage />);
  149. await waitFor(() => {
  150. // Printers should render - location display may vary
  151. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  152. });
  153. });
  154. });
  155. describe('temperature display', () => {
  156. it('shows nozzle temperature', async () => {
  157. render(<PrintersPage />);
  158. await waitFor(() => {
  159. // Temperatures are shown in the UI
  160. expect(screen.getAllByText(/25/)).toBeTruthy();
  161. });
  162. });
  163. });
  164. describe('empty state', () => {
  165. it('shows empty state when no printers', async () => {
  166. server.use(
  167. http.get('/api/v1/printers/', () => {
  168. return HttpResponse.json([]);
  169. })
  170. );
  171. render(<PrintersPage />);
  172. await waitFor(() => {
  173. expect(screen.getByText(/no printers/i)).toBeInTheDocument();
  174. });
  175. });
  176. });
  177. describe('printer actions', () => {
  178. it('has action buttons', async () => {
  179. render(<PrintersPage />);
  180. await waitFor(() => {
  181. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  182. });
  183. // There should be some interactive elements for printer actions
  184. const buttons = screen.getAllByRole('button');
  185. expect(buttons.length).toBeGreaterThan(0);
  186. });
  187. it('shows plate clear status and action on finished printers when not cleared', async () => {
  188. server.use(
  189. http.get('/api/v1/printers/:id/status', () => {
  190. return HttpResponse.json({ ...mockPrinterStatus, state: 'FINISH', awaiting_plate_clear: true });
  191. })
  192. );
  193. render(<PrintersPage />);
  194. await waitFor(() => {
  195. expect(screen.getAllByText('Plate not Clear').length).toBeGreaterThan(0);
  196. });
  197. expect(screen.getAllByRole('button', { name: 'Mark plate as cleared' }).length).toBeGreaterThan(0);
  198. });
  199. it('shows plate clear status and action on failed printers when not cleared', async () => {
  200. server.use(
  201. http.get('/api/v1/printers/:id/status', () => {
  202. return HttpResponse.json({ ...mockPrinterStatus, state: 'FAILED', awaiting_plate_clear: true });
  203. })
  204. );
  205. render(<PrintersPage />);
  206. await waitFor(() => {
  207. expect(screen.getAllByText('Plate not Clear').length).toBeGreaterThan(0);
  208. });
  209. expect(screen.getAllByRole('button', { name: 'Mark plate as cleared' }).length).toBeGreaterThan(0);
  210. });
  211. it('keeps the clear action available when an idle printer is still awaiting acknowledgment', async () => {
  212. server.use(
  213. http.get('/api/v1/printers/:id/status', () => {
  214. return HttpResponse.json({ ...mockPrinterStatus, state: 'IDLE', awaiting_plate_clear: true });
  215. })
  216. );
  217. render(<PrintersPage />);
  218. await waitFor(() => {
  219. expect(screen.getAllByText('Plate not Clear').length).toBeGreaterThan(0);
  220. });
  221. expect(screen.getAllByRole('button', { name: 'Mark plate as cleared' }).length).toBeGreaterThan(0);
  222. });
  223. it('updates the plate clear status after using the printer card action', async () => {
  224. let awaitingPlateClear = true;
  225. server.use(
  226. http.get('/api/v1/printers/', () => {
  227. return HttpResponse.json([mockPrinters[0]]);
  228. }),
  229. http.get('/api/v1/printers/:id/status', () => {
  230. return HttpResponse.json({ ...mockPrinterStatus, state: 'FINISH', awaiting_plate_clear: awaitingPlateClear });
  231. }),
  232. http.post('/api/v1/printers/:id/clear-plate', () => {
  233. awaitingPlateClear = false;
  234. return HttpResponse.json({ success: true, message: 'Plate cleared' });
  235. })
  236. );
  237. render(<PrintersPage />);
  238. await waitFor(() => {
  239. expect(screen.getAllByText('Plate not Clear').length).toBeGreaterThan(0);
  240. });
  241. fireEvent.click(screen.getAllByRole('button', { name: 'Mark plate as cleared' })[0]);
  242. await waitFor(() => {
  243. expect(screen.queryByText('Plate not Clear')).not.toBeInTheDocument();
  244. });
  245. expect(screen.getAllByText('Plate Clear').length).toBeGreaterThan(0);
  246. });
  247. it('shows an icon-only plate clear action in small card view', async () => {
  248. let awaitingPlateClear = true;
  249. server.use(
  250. http.get('/api/v1/printers/', () => {
  251. return HttpResponse.json([mockPrinters[0]]);
  252. }),
  253. http.get('/api/v1/printers/:id/status', () => {
  254. return HttpResponse.json({ ...mockPrinterStatus, state: 'FINISH', awaiting_plate_clear: awaitingPlateClear });
  255. }),
  256. http.post('/api/v1/printers/:id/clear-plate', () => {
  257. awaitingPlateClear = false;
  258. return HttpResponse.json({ success: true, message: 'Plate cleared' });
  259. })
  260. );
  261. render(<PrintersPage />);
  262. await waitFor(() => {
  263. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  264. });
  265. fireEvent.click(screen.getByRole('button', { name: 'S' }));
  266. await waitFor(() => {
  267. expect(screen.queryByText('Mark plate as cleared')).not.toBeInTheDocument();
  268. });
  269. const clearButton = screen.getByRole('button', { name: 'Mark plate as cleared' });
  270. fireEvent.click(clearButton);
  271. await waitFor(() => {
  272. expect(screen.queryByRole('button', { name: 'Mark plate as cleared' })).not.toBeInTheDocument();
  273. });
  274. });
  275. it('shows plate clear status but no action while idle', async () => {
  276. render(<PrintersPage />);
  277. await waitFor(() => {
  278. expect(screen.getAllByText('Plate Clear').length).toBeGreaterThan(0);
  279. });
  280. expect(screen.queryByRole('button', { name: 'Mark plate as cleared' })).not.toBeInTheDocument();
  281. });
  282. it('shows plate in use status while printing and hides the clear action', async () => {
  283. server.use(
  284. http.get('/api/v1/printers/:id/status', () => {
  285. return HttpResponse.json({ ...mockPrinterStatus, state: 'RUNNING', awaiting_plate_clear: false });
  286. })
  287. );
  288. render(<PrintersPage />);
  289. await waitFor(() => {
  290. expect(screen.getAllByText('Plate in Use').length).toBeGreaterThan(0);
  291. });
  292. expect(screen.queryByRole('button', { name: 'Mark plate as cleared' })).not.toBeInTheDocument();
  293. });
  294. it('hides plate status and action when plate-clear confirmation is disabled', async () => {
  295. server.use(
  296. http.get('/api/v1/settings/', () => {
  297. return HttpResponse.json({
  298. auto_archive: true,
  299. save_thumbnails: true,
  300. capture_finish_photo: true,
  301. default_filament_cost: 25.0,
  302. currency: 'USD',
  303. ams_humidity_good: 40,
  304. ams_humidity_fair: 60,
  305. ams_temp_good: 30,
  306. ams_temp_fair: 35,
  307. require_plate_clear: false,
  308. });
  309. }),
  310. http.get('/api/v1/settings/ui-preferences', () => {
  311. return HttpResponse.json({
  312. ams_humidity_good: 40,
  313. ams_humidity_fair: 60,
  314. ams_temp_good: 30,
  315. ams_temp_fair: 35,
  316. require_plate_clear: false,
  317. });
  318. }),
  319. http.get('/api/v1/printers/:id/status', () => {
  320. return HttpResponse.json({ ...mockPrinterStatus, state: 'FINISH', awaiting_plate_clear: true });
  321. })
  322. );
  323. render(<PrintersPage />);
  324. await waitFor(() => {
  325. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  326. });
  327. expect(screen.queryByText('Plate not Clear')).not.toBeInTheDocument();
  328. expect(screen.queryByText('Plate Clear')).not.toBeInTheDocument();
  329. expect(screen.queryByText('Plate in Use')).not.toBeInTheDocument();
  330. expect(screen.queryByRole('button', { name: 'Mark plate as cleared' })).not.toBeInTheDocument();
  331. });
  332. });
  333. describe('disabled printer', () => {
  334. it('shows disabled state for disabled printers', async () => {
  335. render(<PrintersPage />);
  336. await waitFor(() => {
  337. expect(screen.getByText('P1S Backup')).toBeInTheDocument();
  338. });
  339. // Disabled printers have visual indication
  340. const disabledPrinter = screen.getByText('P1S Backup').closest('div');
  341. expect(disabledPrinter).toBeInTheDocument();
  342. });
  343. });
  344. describe('nozzle rack card', () => {
  345. const h2cStatus = {
  346. ...mockPrinterStatus,
  347. nozzle_rack: [
  348. { id: 0, nozzle_type: 'HS', nozzle_diameter: '0.4', wear: 5, stat: 1, max_temp: 300, serial_number: 'SN-L', filament_color: '', filament_id: '', filament_type: '' },
  349. { id: 1, nozzle_type: 'HS', nozzle_diameter: '0.4', wear: 3, stat: 0, max_temp: 300, serial_number: 'SN-R', filament_color: '', filament_id: '', filament_type: '' },
  350. { id: 16, nozzle_type: 'HS', nozzle_diameter: '0.4', wear: 10, stat: 0, max_temp: 300, serial_number: 'SN-16', filament_color: '', filament_id: '', filament_type: '' },
  351. { id: 17, nozzle_type: 'HH01', nozzle_diameter: '0.6', wear: 0, stat: 0, max_temp: 300, serial_number: 'SN-17', filament_color: '', filament_id: '', filament_type: '' },
  352. { id: 18, nozzle_type: 'HS', nozzle_diameter: '0.4', wear: 2, stat: 0, max_temp: 300, serial_number: 'SN-18', filament_color: '', filament_id: '', filament_type: '' },
  353. { id: 19, nozzle_type: '', nozzle_diameter: '', wear: null, stat: null, max_temp: 0, serial_number: '', filament_color: '', filament_id: '', filament_type: '' },
  354. { id: 20, nozzle_type: '', nozzle_diameter: '', wear: null, stat: null, max_temp: 0, serial_number: '', filament_color: '', filament_id: '', filament_type: '' },
  355. { id: 21, nozzle_type: '', nozzle_diameter: '', wear: null, stat: null, max_temp: 0, serial_number: '', filament_color: '', filament_id: '', filament_type: '' },
  356. ],
  357. };
  358. it('shows nozzle rack when H2C rack slots present', async () => {
  359. server.use(
  360. http.get('/api/v1/printers/:id/status', () => {
  361. return HttpResponse.json(h2cStatus);
  362. })
  363. );
  364. render(<PrintersPage />);
  365. await waitFor(() => {
  366. expect(screen.getAllByText('Nozzle Rack').length).toBeGreaterThan(0);
  367. });
  368. });
  369. it('shows 6 rack slot elements for H2C', async () => {
  370. server.use(
  371. http.get('/api/v1/printers/:id/status', () => {
  372. return HttpResponse.json(h2cStatus);
  373. })
  374. );
  375. render(<PrintersPage />);
  376. await waitFor(() => {
  377. expect(screen.getAllByText('Nozzle Rack').length).toBeGreaterThan(0);
  378. });
  379. // Rack shows diameters for occupied slots and dashes for empty ones
  380. const dashes = screen.getAllByText('—');
  381. expect(dashes.length).toBeGreaterThanOrEqual(3); // 3 empty rack positions (IDs 19,20,21)
  382. });
  383. it('keeps empty slot anchored to physical position when its nozzle is mounted (#943)', async () => {
  384. // H2C with rack slot 16 picked up into the hotend — firmware omits ID 16
  385. // entirely from nozzle.info. Each rack diameter is unique so we can assert
  386. // the ordering by tooltip lookup.
  387. const h2cSlot16Mounted = {
  388. ...mockPrinterStatus,
  389. nozzle_rack: [
  390. { id: 0, nozzle_type: 'HS', nozzle_diameter: '0.4', wear: 5, stat: 1, max_temp: 300, serial_number: 'SN-L', filament_color: '', filament_id: '', filament_type: '' },
  391. { id: 1, nozzle_type: 'HS', nozzle_diameter: '0.4', wear: 3, stat: 0, max_temp: 300, serial_number: 'SN-R', filament_color: '', filament_id: '', filament_type: '' },
  392. // ID 16 missing — currently in hotend
  393. { id: 17, nozzle_type: 'HS', nozzle_diameter: '0.2', wear: 0, stat: 0, max_temp: 300, serial_number: 'SN-17', filament_color: '', filament_id: '', filament_type: '' },
  394. { id: 18, nozzle_type: 'HS', nozzle_diameter: '0.6', wear: 0, stat: 0, max_temp: 300, serial_number: 'SN-18', filament_color: '', filament_id: '', filament_type: '' },
  395. { id: 19, nozzle_type: 'HS', nozzle_diameter: '0.8', wear: 0, stat: 0, max_temp: 300, serial_number: 'SN-19', filament_color: '', filament_id: '', filament_type: '' },
  396. { id: 20, nozzle_type: 'HH01', nozzle_diameter: '1.0', wear: 0, stat: 0, max_temp: 300, serial_number: 'SN-20', filament_color: '', filament_id: '', filament_type: '' },
  397. { id: 21, nozzle_type: 'HH01', nozzle_diameter: '1.2', wear: 0, stat: 0, max_temp: 300, serial_number: 'SN-21', filament_color: '', filament_id: '', filament_type: '' },
  398. ],
  399. };
  400. server.use(
  401. http.get('/api/v1/printers/:id/status', () => {
  402. return HttpResponse.json(h2cSlot16Mounted);
  403. })
  404. );
  405. render(<PrintersPage />);
  406. await waitFor(() => {
  407. expect(screen.getAllByText('Nozzle Rack').length).toBeGreaterThan(0);
  408. });
  409. // Slot 1 (leftmost, ID 16) should be the empty dash; slots 2..6 should
  410. // hold the 5 remaining nozzles in order 17, 18, 19, 20, 21.
  411. const rackLabel = screen.getAllByText('Nozzle Rack')[0];
  412. const rackCard = rackLabel.parentElement!;
  413. const slotRow = rackCard.querySelectorAll('div.flex')[0];
  414. const slotTexts = Array.from(slotRow.querySelectorAll('span')).map(s => s.textContent);
  415. expect(slotTexts).toEqual(['—', '0.2', '0.6', '0.8', '1.0', '1.2']);
  416. });
  417. it('hides nozzle rack when only L/R nozzles present (H2D)', async () => {
  418. const h2dStatus = {
  419. ...mockPrinterStatus,
  420. nozzle_rack: [
  421. { id: 0, nozzle_type: 'HS', nozzle_diameter: '0.4', wear: 5, stat: 1, max_temp: 300, serial_number: '', filament_color: '', filament_id: '', filament_type: '' },
  422. { id: 1, nozzle_type: 'HS', nozzle_diameter: '0.4', wear: 3, stat: 1, max_temp: 300, serial_number: '', filament_color: '', filament_id: '', filament_type: '' },
  423. ],
  424. };
  425. server.use(
  426. http.get('/api/v1/printers/:id/status', () => {
  427. return HttpResponse.json(h2dStatus);
  428. })
  429. );
  430. render(<PrintersPage />);
  431. await waitFor(() => {
  432. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  433. });
  434. expect(screen.queryByText('Nozzle Rack')).not.toBeInTheDocument();
  435. });
  436. });
  437. describe('firmware version badge', () => {
  438. const firmwareUpToDate = {
  439. printer_id: 1,
  440. current_version: '01.09.00.00',
  441. latest_version: '01.09.00.00',
  442. update_available: false,
  443. download_url: null,
  444. release_notes: 'Bug fixes and improvements.',
  445. };
  446. const firmwareUpdateAvailable = {
  447. printer_id: 1,
  448. current_version: '01.08.00.00',
  449. latest_version: '01.09.00.00',
  450. update_available: true,
  451. download_url: 'https://example.com/firmware.bin',
  452. release_notes: 'New features added.',
  453. };
  454. it('shows green badge when firmware is up to date', async () => {
  455. server.use(
  456. http.get('/api/v1/firmware/updates/:id', () => {
  457. return HttpResponse.json(firmwareUpToDate);
  458. }),
  459. http.get('/api/v1/settings/', () => {
  460. return HttpResponse.json({
  461. check_printer_firmware: true,
  462. auto_archive: true,
  463. save_thumbnails: true,
  464. });
  465. })
  466. );
  467. render(<PrintersPage />);
  468. await waitFor(() => {
  469. expect(screen.getAllByText('01.09.00.00').length).toBeGreaterThan(0);
  470. });
  471. const badge = screen.getAllByText('01.09.00.00')[0].closest('button');
  472. expect(badge).toBeInTheDocument();
  473. expect(badge?.className).toContain('text-status-ok');
  474. });
  475. it('shows orange badge when firmware update is available', async () => {
  476. server.use(
  477. http.get('/api/v1/firmware/updates/:id', () => {
  478. return HttpResponse.json(firmwareUpdateAvailable);
  479. }),
  480. http.get('/api/v1/settings/', () => {
  481. return HttpResponse.json({
  482. check_printer_firmware: true,
  483. auto_archive: true,
  484. save_thumbnails: true,
  485. });
  486. })
  487. );
  488. render(<PrintersPage />);
  489. await waitFor(() => {
  490. expect(screen.getAllByText('01.08.00.00').length).toBeGreaterThan(0);
  491. });
  492. const badge = screen.getAllByText('01.08.00.00')[0].closest('button');
  493. expect(badge).toBeInTheDocument();
  494. expect(badge?.className).toContain('text-orange-400');
  495. });
  496. it('hides badge when firmware check is disabled', async () => {
  497. server.use(
  498. http.get('/api/v1/settings/', () => {
  499. return HttpResponse.json({
  500. check_printer_firmware: false,
  501. auto_archive: true,
  502. save_thumbnails: true,
  503. });
  504. })
  505. );
  506. render(<PrintersPage />);
  507. await waitFor(() => {
  508. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  509. });
  510. // Version should not appear when firmware check is disabled
  511. expect(screen.queryByText('01.09.00.00')).not.toBeInTheDocument();
  512. expect(screen.queryByText('01.08.00.00')).not.toBeInTheDocument();
  513. });
  514. it('hides badge when API has no firmware data for the model', async () => {
  515. const firmwareNoData = {
  516. printer_id: 1,
  517. current_version: '01.01.03.00',
  518. latest_version: null,
  519. update_available: false,
  520. download_url: null,
  521. release_notes: null,
  522. };
  523. server.use(
  524. http.get('/api/v1/firmware/updates/:id', () => {
  525. return HttpResponse.json(firmwareNoData);
  526. }),
  527. http.get('/api/v1/settings/', () => {
  528. return HttpResponse.json({
  529. check_printer_firmware: true,
  530. auto_archive: true,
  531. save_thumbnails: true,
  532. });
  533. })
  534. );
  535. render(<PrintersPage />);
  536. await waitFor(() => {
  537. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  538. });
  539. // Badge should not appear when API returns no latest_version
  540. expect(screen.queryByText('01.01.03.00')).not.toBeInTheDocument();
  541. });
  542. });
  543. describe('bulk selection', () => {
  544. it('shows select button in toolbar', async () => {
  545. render(<PrintersPage />);
  546. await waitFor(() => {
  547. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  548. });
  549. // The Select button should be in the toolbar (title attribute)
  550. const selectButton = screen.getByTitle('Select');
  551. expect(selectButton).toBeInTheDocument();
  552. });
  553. it('shows selection toolbar after clicking select button', async () => {
  554. render(<PrintersPage />);
  555. await waitFor(() => {
  556. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  557. });
  558. // Click the Select button to enter selection mode
  559. fireEvent.click(screen.getByTitle('Select'));
  560. // The floating toolbar should appear with Select All
  561. await waitFor(() => {
  562. expect(screen.getByText('Select All')).toBeInTheDocument();
  563. });
  564. });
  565. it('shows selection count when printers are selected', async () => {
  566. render(<PrintersPage />);
  567. await waitFor(() => {
  568. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  569. });
  570. // Enter selection mode
  571. fireEvent.click(screen.getByTitle('Select'));
  572. await waitFor(() => {
  573. expect(screen.getByText('Select All')).toBeInTheDocument();
  574. });
  575. // Click Select All to select both printers
  576. fireEvent.click(screen.getByText('Select All'));
  577. // Should show "2 selected"
  578. await waitFor(() => {
  579. expect(screen.getByText('2 selected')).toBeInTheDocument();
  580. });
  581. });
  582. it('shows select by state dropdown', async () => {
  583. render(<PrintersPage />);
  584. await waitFor(() => {
  585. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  586. });
  587. // Enter selection mode
  588. fireEvent.click(screen.getByTitle('Select'));
  589. await waitFor(() => {
  590. expect(screen.getByText('Select by State')).toBeInTheDocument();
  591. });
  592. });
  593. it('exits selection mode on close button', async () => {
  594. render(<PrintersPage />);
  595. await waitFor(() => {
  596. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  597. });
  598. // Enter selection mode
  599. fireEvent.click(screen.getByTitle('Select'));
  600. await waitFor(() => {
  601. expect(screen.getByText('Select All')).toBeInTheDocument();
  602. });
  603. // Click the Select button again to exit (it toggles)
  604. fireEvent.click(screen.getByTitle('Select'));
  605. // Floating toolbar should disappear
  606. await waitFor(() => {
  607. expect(screen.queryByText('Select All')).not.toBeInTheDocument();
  608. });
  609. });
  610. });
  611. describe('search and filter', () => {
  612. beforeEach(() => {
  613. server.use(
  614. http.get('/api/v1/printers/', () => HttpResponse.json(mockPrinters)),
  615. http.get('/api/v1/printers/:id/status', () => HttpResponse.json(mockPrinterStatus)),
  616. http.get('/api/v1/queue/', () => HttpResponse.json([]))
  617. );
  618. });
  619. it('filters by name (case-insensitive)', async () => {
  620. render(<PrintersPage />);
  621. await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument());
  622. fireEvent.change(screen.getByPlaceholderText('Search printers...'), { target: { value: 'x1 carbon' } });
  623. await waitFor(() => {
  624. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  625. expect(screen.queryByText('P1S Backup')).not.toBeInTheDocument();
  626. });
  627. });
  628. it('trims leading and trailing whitespace from search', async () => {
  629. render(<PrintersPage />);
  630. await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument());
  631. // " X1 Carbon " with surrounding spaces must still match
  632. fireEvent.change(screen.getByPlaceholderText('Search printers...'), { target: { value: ' X1 Carbon ' } });
  633. await waitFor(() => {
  634. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  635. expect(screen.queryByText('P1S Backup')).not.toBeInTheDocument();
  636. });
  637. });
  638. it('filters by model', async () => {
  639. render(<PrintersPage />);
  640. await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument());
  641. fireEvent.change(screen.getByPlaceholderText('Search printers...'), { target: { value: 'P1S' } });
  642. await waitFor(() => {
  643. expect(screen.queryByText('X1 Carbon')).not.toBeInTheDocument();
  644. expect(screen.getByText('P1S Backup')).toBeInTheDocument();
  645. });
  646. });
  647. it('filters by serial number', async () => {
  648. render(<PrintersPage />);
  649. await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument());
  650. fireEvent.change(screen.getByPlaceholderText('Search printers...'), { target: { value: '00M09A' } });
  651. await waitFor(() => {
  652. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  653. expect(screen.queryByText('P1S Backup')).not.toBeInTheDocument();
  654. });
  655. });
  656. it('shows empty state when no printers match search', async () => {
  657. render(<PrintersPage />);
  658. await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument());
  659. fireEvent.change(screen.getByPlaceholderText('Search printers...'), { target: { value: 'ZZZ_NO_MATCH' } });
  660. await waitFor(() => {
  661. expect(screen.getByText('No printers match your search or filters')).toBeInTheDocument();
  662. });
  663. });
  664. it('clear button resets search and shows all printers', async () => {
  665. render(<PrintersPage />);
  666. await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument());
  667. fireEvent.change(screen.getByPlaceholderText('Search printers...'), { target: { value: 'X1 Carbon' } });
  668. await waitFor(() => expect(screen.queryByText('P1S Backup')).not.toBeInTheDocument());
  669. // Click the accessible clear button
  670. fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
  671. await waitFor(() => {
  672. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  673. expect(screen.getByText('P1S Backup')).toBeInTheDocument();
  674. });
  675. });
  676. it('filters by status (offline) via dropdown', async () => {
  677. // Override: printer 1 online, printer 2 offline
  678. server.use(
  679. http.get('/api/v1/printers/:id/status', ({ params }) => {
  680. if (Number(params.id) === 2) {
  681. return HttpResponse.json({ ...mockPrinterStatus, connected: false });
  682. }
  683. return HttpResponse.json(mockPrinterStatus);
  684. })
  685. );
  686. render(<PrintersPage />);
  687. await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument());
  688. await selectToolbarDropdownOption(/all statuses/i, /^offline$/i);
  689. await waitFor(() => {
  690. expect(screen.queryByText('X1 Carbon')).not.toBeInTheDocument();
  691. expect(screen.getByText('P1S Backup')).toBeInTheDocument();
  692. });
  693. });
  694. it('shows empty state when status filter matches nothing', async () => {
  695. render(<PrintersPage />);
  696. await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument());
  697. // Both printers are IDLE; filtering by "printing" should yield no results
  698. await selectToolbarDropdownOption(/all statuses/i, /^printing$/i);
  699. await waitFor(() => {
  700. expect(screen.getByText('No printers match your search or filters')).toBeInTheDocument();
  701. });
  702. });
  703. it('combines search and status filter', async () => {
  704. // Printer 1 = RUNNING (printing), printer 2 = IDLE
  705. server.use(
  706. http.get('/api/v1/printers/:id/status', ({ params }) => {
  707. if (Number(params.id) === 1) {
  708. return HttpResponse.json({ ...mockPrinterStatus, state: 'RUNNING' });
  709. }
  710. return HttpResponse.json(mockPrinterStatus);
  711. })
  712. );
  713. render(<PrintersPage />);
  714. await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument());
  715. // Filter to only "printing" printers
  716. await selectToolbarDropdownOption(/all statuses/i, /^printing$/i);
  717. // Then also search for a term that only matches printer 1
  718. fireEvent.change(screen.getByPlaceholderText('Search printers...'), { target: { value: 'X1' } });
  719. await waitFor(() => {
  720. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  721. expect(screen.queryByText('P1S Backup')).not.toBeInTheDocument();
  722. });
  723. });
  724. it('filters by location via dropdown', async () => {
  725. // Override: give printer 2 its own location so the dropdown has two options
  726. // and we can verify the filter picks the right one. Printer 1 stays at 'Workshop'.
  727. server.use(
  728. http.get('/api/v1/printers/', () =>
  729. HttpResponse.json([
  730. mockPrinters[0],
  731. { ...mockPrinters[1], location: 'Office' },
  732. ])
  733. )
  734. );
  735. render(<PrintersPage />);
  736. await waitFor(() => {
  737. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  738. expect(screen.getByText('P1S Backup')).toBeInTheDocument();
  739. });
  740. await selectToolbarDropdownOption(/all locations/i, /^workshop$/i);
  741. await waitFor(() => {
  742. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  743. expect(screen.queryByText('P1S Backup')).not.toBeInTheDocument();
  744. });
  745. await selectToolbarDropdownOption(/^workshop$/i, /^office$/i);
  746. await waitFor(() => {
  747. expect(screen.queryByText('X1 Carbon')).not.toBeInTheDocument();
  748. expect(screen.getByText('P1S Backup')).toBeInTheDocument();
  749. });
  750. });
  751. it('hides location filter when no printers have a location', async () => {
  752. // Both printers have null location — dropdown should not render at all
  753. server.use(
  754. http.get('/api/v1/printers/', () =>
  755. HttpResponse.json([
  756. { ...mockPrinters[0], location: null },
  757. { ...mockPrinters[1], location: null },
  758. ])
  759. )
  760. );
  761. render(<PrintersPage />);
  762. await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument());
  763. // Status filter is still there, but the location filter should be absent.
  764. expect(screen.getByRole('button', { name: /all statuses/i })).toBeInTheDocument();
  765. expect(screen.queryByRole('button', { name: /all locations/i })).not.toBeInTheDocument();
  766. });
  767. });
  768. describe('Spoolman loading guard', () => {
  769. it('does not show Assign Spool button while Spoolman queries are loading', async () => {
  770. // Spoolman enabled but inventory and slot-assignment queries never resolve
  771. server.use(
  772. http.get('/api/v1/spoolman/status', () =>
  773. HttpResponse.json({ enabled: true, connected: true })
  774. ),
  775. http.get('/api/v1/spoolman/inventory/spools', () =>
  776. new Promise(() => {}) // never resolves
  777. ),
  778. http.get('/api/v1/spoolman/inventory/slot-assignments/all', () =>
  779. new Promise(() => {}) // never resolves
  780. )
  781. );
  782. render(<PrintersPage />);
  783. // Wait for the page to render (printers should be visible)
  784. await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument());
  785. // While Spoolman queries are still loading, the "Assign Spool" button must
  786. // not appear (inventory prop is undefined → {inventory && ...} guard fires)
  787. expect(screen.queryByText('Assign Spool')).not.toBeInTheDocument();
  788. });
  789. });
  790. });
  791. /**
  792. * Phase 13 P13-1 (PrintersPage EmptySlotHoverCard onAssignSpool gate removal)
  793. *
  794. * Pre-Phase-13 each of the three EmptySlotHoverCard call-sites in PrintersPage
  795. * gated `onAssignSpool` on `spoolmanEnabled ? (...) : undefined`, so empty
  796. * slots in local-Inventory mode never showed an Assign action. Maintainer
  797. * Foto 7 confirmed users expect the button regardless of mode.
  798. *
  799. * To assert wiring without going through hover-card animations, we mock the
  800. * EmptySlotHoverCard component at module level and capture every props
  801. * payload. The same mock is active in both modes; tests differ only in the
  802. * spoolman-settings mock. The mock module covers BOTH FilamentHoverCard exports
  803. * so tests outside this `describe` aren't affected (we re-export the real
  804. * FilamentHoverCard).
  805. */
  806. const phase13EmptySlotProps: Array<Record<string, unknown>> = [];
  807. const phase14HoverCardProps: Array<Record<string, unknown>> = [];
  808. vi.mock('../../components/FilamentHoverCard', async (importOriginal) => {
  809. const actual = await importOriginal<typeof import('../../components/FilamentHoverCard')>();
  810. return {
  811. ...actual,
  812. EmptySlotHoverCard: (props: Record<string, unknown>) => {
  813. phase13EmptySlotProps.push({ ...props });
  814. return null;
  815. },
  816. FilamentHoverCard: (props: Record<string, unknown>) => {
  817. phase14HoverCardProps.push({ ...props });
  818. return null;
  819. },
  820. };
  821. });
  822. describe('PrintersPage Phase 13 — EmptySlotHoverCard onAssignSpool wiring', () => {
  823. beforeEach(() => {
  824. phase13EmptySlotProps.length = 0;
  825. localStorage.removeItem('printerCardSize');
  826. server.use(
  827. http.get('/api/v1/printers/', () => HttpResponse.json(mockPrinters)),
  828. // Status response includes an empty AMS slot so EmptySlotHoverCard renders.
  829. http.get('/api/v1/printers/:id/status', () => HttpResponse.json({
  830. ...mockPrinterStatus,
  831. ams: [{
  832. id: 0,
  833. tray: [{ id: 0, tray_type: '' }],
  834. }],
  835. })),
  836. http.get('/api/v1/settings/', () => HttpResponse.json({
  837. auto_archive: true, save_thumbnails: true, capture_finish_photo: true,
  838. default_filament_cost: 25.0, currency: 'USD',
  839. ams_humidity_good: 40, ams_humidity_fair: 60,
  840. ams_temp_good: 30, ams_temp_fair: 35,
  841. })),
  842. http.get('/api/v1/queue/', () => HttpResponse.json([])),
  843. );
  844. });
  845. it('P13-1 (local mode): EmptySlotHoverCard receives onAssignSpool callback', async () => {
  846. server.use(
  847. http.get('/api/v1/spoolman/settings', () => HttpResponse.json({
  848. spoolman_enabled: 'false', spoolman_url: '',
  849. })),
  850. );
  851. render(<PrintersPage />);
  852. // Wait for printer status to load and at least one EmptySlotHoverCard
  853. // to mount with an onAssignSpool callback. Pre-Phase-13 this would have
  854. // been undefined in local mode (the gate filtered it out).
  855. await waitFor(() => {
  856. const withCallback = phase13EmptySlotProps.filter(p => typeof p.onAssignSpool === 'function');
  857. expect(withCallback.length).toBeGreaterThan(0);
  858. }, { timeout: 3000 });
  859. });
  860. it('P13-1 (spoolman mode): EmptySlotHoverCard still receives onAssignSpool callback', async () => {
  861. server.use(
  862. http.get('/api/v1/spoolman/settings', () => HttpResponse.json({
  863. spoolman_enabled: 'true', spoolman_url: 'http://x:7912',
  864. })),
  865. http.get('/api/v1/spoolman/spools/inventory*', () => HttpResponse.json([])),
  866. http.get('/api/v1/spoolman/inventory/spools', () => HttpResponse.json([])),
  867. http.get('/api/v1/spoolman/inventory/slot-assignments/all', () => HttpResponse.json([])),
  868. );
  869. render(<PrintersPage />);
  870. await waitFor(() => {
  871. const withCallback = phase13EmptySlotProps.filter(p => typeof p.onAssignSpool === 'function');
  872. expect(withCallback.length).toBeGreaterThan(0);
  873. }, { timeout: 3000 });
  874. });
  875. });
  876. /**
  877. * Phase 14 — Local-Branch BL-detection symmetry.
  878. *
  879. * The Spoolman branch of every IIFE in PrintersPage already passes
  880. * isAssigned: !!slotAssignment || isBambuLabSpool(tray)
  881. * onUnassignSpool: (spoolmanSpool && !isBambuLabSpool(tray)) ? ... : undefined
  882. *
  883. * The local branch was missing both. As a result a BL-RFID-tagged slot in
  884. * local-Inventory mode showed an "Assign Spool" button (because no manual
  885. * SpoolAssignment exists), and a manually-assigned BL-RFID slot showed
  886. * "Unassign" — which would be overwritten on the next RFID re-read.
  887. *
  888. * The same FilamentHoverCard mock from the Phase 13 block above captures
  889. * inventory props on every render so we can inspect them after setup.
  890. */
  891. describe('PrintersPage Phase 14 — Local-Branch BL-detection symmetry', () => {
  892. beforeEach(() => {
  893. phase14HoverCardProps.length = 0;
  894. localStorage.removeItem('printerCardSize');
  895. server.use(
  896. http.get('/api/v1/printers/', () => HttpResponse.json(mockPrinters)),
  897. http.get('/api/v1/settings/', () => HttpResponse.json({
  898. auto_archive: true, save_thumbnails: true, capture_finish_photo: true,
  899. default_filament_cost: 25.0, currency: 'USD',
  900. ams_humidity_good: 40, ams_humidity_fair: 60,
  901. ams_temp_good: 30, ams_temp_fair: 35,
  902. })),
  903. http.get('/api/v1/queue/', () => HttpResponse.json([])),
  904. http.get('/api/v1/spoolman/settings', () => HttpResponse.json({
  905. spoolman_enabled: 'false', spoolman_url: '',
  906. })),
  907. );
  908. });
  909. it('P14-1a (local + BL-RFID + no assignment): inventory.isAssigned=true', async () => {
  910. server.use(
  911. http.get('/api/v1/printers/:id/status', () => HttpResponse.json({
  912. ...mockPrinterStatus,
  913. ams: [{
  914. id: 0,
  915. tray: [{
  916. id: 0,
  917. tray_type: 'PLA',
  918. tray_uuid: '11223344556677880011223344556677',
  919. tag_uid: '0000000000000000',
  920. tray_color: 'FF0000FF',
  921. tray_sub_brands: 'Bambu PLA Basic',
  922. }],
  923. }],
  924. })),
  925. http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])),
  926. );
  927. render(<PrintersPage />);
  928. await waitFor(() => {
  929. const matches = phase14HoverCardProps.filter(
  930. p => (p.inventory as { isAssigned?: boolean } | undefined)?.isAssigned === true
  931. );
  932. expect(matches.length).toBeGreaterThan(0);
  933. }, { timeout: 3000 });
  934. });
  935. it('P14-1b (local + non-BL + no assignment): inventory.isAssigned is falsy', async () => {
  936. server.use(
  937. http.get('/api/v1/printers/:id/status', () => HttpResponse.json({
  938. ...mockPrinterStatus,
  939. ams: [{
  940. id: 0,
  941. tray: [{
  942. id: 0,
  943. tray_type: 'PLA',
  944. tray_uuid: '00000000000000000000000000000000',
  945. tag_uid: '0000000000000000',
  946. tray_color: 'FF0000FF',
  947. tray_sub_brands: 'Generic PLA',
  948. }],
  949. }],
  950. })),
  951. http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])),
  952. );
  953. render(<PrintersPage />);
  954. // Wait for FilamentHoverCard to render at least once.
  955. await waitFor(() => {
  956. expect(phase14HoverCardProps.length).toBeGreaterThan(0);
  957. }, { timeout: 3000 });
  958. // No render should ever set isAssigned=true for this slot.
  959. const truthyMatches = phase14HoverCardProps.filter(
  960. p => (p.inventory as { isAssigned?: boolean } | undefined)?.isAssigned === true
  961. );
  962. expect(truthyMatches.length).toBe(0);
  963. });
  964. it('P14-1c (local + manual assignment): inventory.isAssigned=true', async () => {
  965. server.use(
  966. http.get('/api/v1/printers/:id/status', () => HttpResponse.json({
  967. ...mockPrinterStatus,
  968. ams: [{
  969. id: 0,
  970. tray: [{
  971. id: 0,
  972. tray_type: 'PLA',
  973. tray_uuid: '00000000000000000000000000000000',
  974. tag_uid: '0000000000000000',
  975. tray_color: 'FF0000FF',
  976. tray_sub_brands: 'Generic PLA',
  977. }],
  978. }],
  979. })),
  980. http.get('/api/v1/inventory/assignments', () => HttpResponse.json([
  981. {
  982. id: 1,
  983. spool_id: 42,
  984. printer_id: 1,
  985. ams_id: 0,
  986. tray_id: 0,
  987. printer_name: 'X1 Carbon',
  988. ams_label: null,
  989. spool: {
  990. id: 42,
  991. material: 'PLA',
  992. brand: 'Generic',
  993. color_name: 'Red',
  994. label_weight: 1000,
  995. weight_used: 0,
  996. rgba: 'FF0000FF',
  997. },
  998. },
  999. ])),
  1000. );
  1001. render(<PrintersPage />);
  1002. await waitFor(() => {
  1003. const matches = phase14HoverCardProps.filter(
  1004. p => (p.inventory as { isAssigned?: boolean } | undefined)?.isAssigned === true
  1005. );
  1006. expect(matches.length).toBeGreaterThan(0);
  1007. }, { timeout: 3000 });
  1008. });
  1009. it('P14-2 (local + BL-RFID + manual assignment): onUnassignSpool=undefined', async () => {
  1010. server.use(
  1011. http.get('/api/v1/printers/:id/status', () => HttpResponse.json({
  1012. ...mockPrinterStatus,
  1013. ams: [{
  1014. id: 0,
  1015. tray: [{
  1016. id: 0,
  1017. tray_type: 'PLA',
  1018. tray_uuid: '11223344556677880011223344556677',
  1019. tag_uid: '0000000000000000',
  1020. tray_color: 'FF0000FF',
  1021. tray_sub_brands: 'Bambu PLA Basic',
  1022. }],
  1023. }],
  1024. })),
  1025. http.get('/api/v1/inventory/assignments', () => HttpResponse.json([
  1026. {
  1027. id: 1,
  1028. spool_id: 42,
  1029. printer_id: 1,
  1030. ams_id: 0,
  1031. tray_id: 0,
  1032. printer_name: 'X1 Carbon',
  1033. ams_label: null,
  1034. spool: {
  1035. id: 42,
  1036. material: 'PLA',
  1037. brand: 'Bambu Lab',
  1038. color_name: 'Red',
  1039. label_weight: 1000,
  1040. weight_used: 0,
  1041. rgba: 'FF0000FF',
  1042. },
  1043. },
  1044. ])),
  1045. );
  1046. render(<PrintersPage />);
  1047. // Wait for FilamentHoverCard renders to settle.
  1048. await waitFor(() => {
  1049. expect(phase14HoverCardProps.length).toBeGreaterThan(0);
  1050. }, { timeout: 3000 });
  1051. // For BL-detected slots in local mode, onUnassignSpool must always be
  1052. // undefined — even when a manual assignment exists. Otherwise the user
  1053. // could unassign a BL-RFID slot that the printer would re-assign on the
  1054. // next re-read, surprising them with phantom ghost-assignments.
  1055. const definedUnassign = phase14HoverCardProps.filter(
  1056. p => typeof (p.inventory as { onUnassignSpool?: () => void } | undefined)?.onUnassignSpool === 'function'
  1057. );
  1058. expect(definedUnassign.length).toBe(0);
  1059. });
  1060. });