QueuePage.test.tsx 59 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835
  1. /**
  2. * Tests for the QueuePage component.
  3. */
  4. import { describe, it, expect, beforeEach, vi } from 'vitest';
  5. import { screen, waitFor, within } from '@testing-library/react';
  6. import userEvent from '@testing-library/user-event';
  7. import { render } from '../utils';
  8. import { QueuePage } from '../../pages/QueuePage';
  9. import { http, HttpResponse } from 'msw';
  10. import { server } from '../mocks/server';
  11. // Mock queue data
  12. const mockQueueItems = [
  13. {
  14. id: 1,
  15. printer_id: 1,
  16. archive_id: 1,
  17. position: 1,
  18. status: 'pending',
  19. scheduled_time: null,
  20. require_previous_success: false,
  21. auto_off_after: false,
  22. manual_start: false,
  23. ams_mapping: null,
  24. plate_id: null,
  25. bed_levelling: 'on',
  26. flow_cali: 'off',
  27. vibration_cali: true,
  28. layer_inspect: false,
  29. timelapse: false,
  30. use_ams: true,
  31. started_at: null,
  32. completed_at: null,
  33. error_message: null,
  34. created_at: '2024-01-01T00:00:00Z',
  35. archive_name: 'Test Print 1',
  36. archive_thumbnail: '/thumb1.png',
  37. printer_name: 'Test Printer',
  38. print_time_seconds: 3600,
  39. },
  40. {
  41. id: 2,
  42. printer_id: 1,
  43. archive_id: 2,
  44. position: 2,
  45. status: 'printing',
  46. scheduled_time: null,
  47. require_previous_success: false,
  48. auto_off_after: true,
  49. manual_start: false,
  50. ams_mapping: null,
  51. plate_id: null,
  52. bed_levelling: 'on',
  53. flow_cali: 'off',
  54. vibration_cali: true,
  55. layer_inspect: false,
  56. timelapse: false,
  57. use_ams: true,
  58. started_at: '2024-01-01T10:00:00Z',
  59. completed_at: null,
  60. error_message: null,
  61. created_at: '2024-01-01T00:00:00Z',
  62. archive_name: 'Active Print',
  63. archive_thumbnail: '/thumb2.png',
  64. printer_name: 'Test Printer',
  65. print_time_seconds: 7200,
  66. },
  67. {
  68. id: 3,
  69. printer_id: 1,
  70. archive_id: 3,
  71. position: 3,
  72. status: 'completed',
  73. scheduled_time: null,
  74. require_previous_success: false,
  75. auto_off_after: false,
  76. manual_start: false,
  77. ams_mapping: null,
  78. plate_id: null,
  79. bed_levelling: 'on',
  80. flow_cali: 'off',
  81. vibration_cali: true,
  82. layer_inspect: false,
  83. timelapse: false,
  84. use_ams: true,
  85. started_at: '2024-01-01T08:00:00Z',
  86. completed_at: '2024-01-01T09:00:00Z',
  87. error_message: null,
  88. created_at: '2024-01-01T00:00:00Z',
  89. archive_name: 'Completed Print',
  90. archive_thumbnail: '/thumb3.png',
  91. printer_name: 'Test Printer',
  92. print_time_seconds: 1800,
  93. },
  94. ];
  95. const mockPrinters = [
  96. {
  97. id: 1,
  98. name: 'Test Printer',
  99. ip_address: '192.168.1.100',
  100. serial_number: 'TESTSERIAL0001',
  101. access_code: '12345678',
  102. model: 'X1C',
  103. enabled: true,
  104. created_at: '2024-01-01T00:00:00Z',
  105. },
  106. ];
  107. describe('QueuePage', () => {
  108. beforeEach(() => {
  109. // Mock localStorage.getItem to return expected defaults for queue page
  110. vi.mocked(localStorage.getItem).mockImplementation((key: string) => {
  111. if (key === 'queue.historyCollapsed') return 'false'; // expanded
  112. if (key === 'queue.viewMode') return 'list';
  113. return null;
  114. });
  115. // Setup MSW handlers for this test
  116. server.use(
  117. http.get('/api/v1/queue/', () => {
  118. return HttpResponse.json(mockQueueItems);
  119. }),
  120. http.get('/api/v1/printers/', () => {
  121. return HttpResponse.json(mockPrinters);
  122. }),
  123. http.delete('/api/v1/queue/:id', () => {
  124. return HttpResponse.json({ success: true });
  125. }),
  126. http.post('/api/v1/queue/:id/cancel', () => {
  127. return HttpResponse.json({ success: true });
  128. }),
  129. http.post('/api/v1/queue/:id/start', () => {
  130. return HttpResponse.json({ success: true });
  131. }),
  132. http.post('/api/v1/queue/:id/stop', () => {
  133. return HttpResponse.json({ success: true });
  134. }),
  135. http.post('/api/v1/queue/reorder', () => {
  136. return HttpResponse.json({ success: true });
  137. })
  138. );
  139. });
  140. describe('rendering', () => {
  141. it('renders the page title', async () => {
  142. render(<QueuePage />);
  143. await waitFor(() => {
  144. expect(screen.getByText('Print Queue')).toBeInTheDocument();
  145. });
  146. });
  147. it('renders the page description', async () => {
  148. render(<QueuePage />);
  149. await waitFor(() => {
  150. expect(screen.getByText('Schedule and manage your print jobs')).toBeInTheDocument();
  151. });
  152. });
  153. it('shows summary cards', async () => {
  154. render(<QueuePage />);
  155. await waitFor(() => {
  156. // Check for the page title (Print Queue is the h1)
  157. expect(screen.getByText('Print Queue')).toBeInTheDocument();
  158. });
  159. });
  160. it('shows filter dropdowns', async () => {
  161. render(<QueuePage />);
  162. await waitFor(() => {
  163. expect(screen.getByText('All Printers')).toBeInTheDocument();
  164. expect(screen.getByText('All Status')).toBeInTheDocument();
  165. });
  166. });
  167. });
  168. describe('queue items display', () => {
  169. it('shows pending queue items', async () => {
  170. render(<QueuePage />);
  171. await waitFor(() => {
  172. expect(screen.getByText('Test Print 1')).toBeInTheDocument();
  173. });
  174. });
  175. it('shows active printing items', async () => {
  176. render(<QueuePage />);
  177. await waitFor(() => {
  178. expect(screen.getByText('Active Print')).toBeInTheDocument();
  179. expect(screen.getByText('Currently Printing')).toBeInTheDocument();
  180. });
  181. });
  182. it('shows the queue override colour instead of the original 3MF colour (#3132)', async () => {
  183. const item = {
  184. ...mockQueueItems[0],
  185. id: 82,
  186. printer_id: null,
  187. target_model: 'P2S',
  188. archive_id: null,
  189. library_file_id: 13,
  190. archive_name: null,
  191. library_file_name: 'Override colour test',
  192. plate_id: 1,
  193. filament_used_grams: 23.66,
  194. filament_type: 'PLA',
  195. filament_color: '#7C4B00',
  196. filament_overrides: [
  197. {
  198. slot_id: 1,
  199. type: 'PLA',
  200. color: '#C2BAA7FF',
  201. color_name: 'Bone White',
  202. force_color_match: false,
  203. },
  204. ],
  205. };
  206. server.use(
  207. http.get('/api/v1/queue/', () => HttpResponse.json([item])),
  208. http.get('/api/v1/library/files/13/plates', () =>
  209. HttpResponse.json({
  210. file_id: 13,
  211. filename: 'override-colour.3mf',
  212. plates: [
  213. {
  214. index: 1,
  215. name: 'Plate 1',
  216. objects: ['Part'],
  217. has_thumbnail: false,
  218. thumbnail_url: null,
  219. print_time_seconds: 3095,
  220. filament_used_grams: 23.66,
  221. filaments: [
  222. {
  223. slot_id: 1,
  224. type: 'PLA',
  225. color: '#7C4B00',
  226. used_grams: 23.7,
  227. used_meters: 7.93,
  228. },
  229. ],
  230. },
  231. ],
  232. is_multi_plate: false,
  233. }),
  234. ),
  235. );
  236. render(<QueuePage />);
  237. const colourName = await screen.findByText('Bone White');
  238. const row = colourName.closest('.group');
  239. expect(row).not.toBeNull();
  240. expect(within(row as HTMLElement).getByTestId('filament-swatch')).toHaveAttribute(
  241. 'title',
  242. '#C2BAA7',
  243. );
  244. });
  245. it('shows the resolved AMS slot and bound spool when a physical mapping is stored (#3132)', async () => {
  246. const item = {
  247. ...mockQueueItems[0],
  248. id: 85,
  249. printer_id: 1,
  250. printer_name: 'Test Printer',
  251. archive_id: null,
  252. library_file_id: 16,
  253. archive_name: null,
  254. library_file_name: 'Resolved AMS mapping test',
  255. plate_id: 1,
  256. ams_mapping: [1],
  257. filament_overrides: [
  258. {
  259. slot_id: 1,
  260. type: 'PLA',
  261. color: '#8E351BFF',
  262. color_name: 'Caramel',
  263. force_color_match: false,
  264. },
  265. ],
  266. };
  267. server.use(
  268. http.get('/api/v1/queue/', () => HttpResponse.json([item])),
  269. http.get('/api/v1/library/files/16/plates', () =>
  270. HttpResponse.json({
  271. file_id: 16,
  272. filename: 'resolved-mapping.3mf',
  273. plates: [
  274. {
  275. index: 1,
  276. name: 'Plate 1',
  277. objects: ['Part'],
  278. has_thumbnail: false,
  279. thumbnail_url: null,
  280. print_time_seconds: 3600,
  281. filament_used_grams: 20,
  282. filaments: [
  283. {
  284. slot_id: 1,
  285. type: 'PLA',
  286. color: '#7C4B00',
  287. used_grams: 20,
  288. used_meters: 6.7,
  289. },
  290. ],
  291. },
  292. ],
  293. is_multi_plate: false,
  294. }),
  295. ),
  296. http.get('/api/v1/printers/:id/status', () =>
  297. HttpResponse.json({
  298. id: 1,
  299. name: 'Test Printer',
  300. connected: true,
  301. state: 'IDLE',
  302. ams: [
  303. {
  304. id: 0,
  305. // Regular AMS units expose four tray records. Keeping all four
  306. // here matters because buildLoadedFilaments distinguishes a
  307. // regular AMS from a single-slot AMS-HT by tray count.
  308. tray: [
  309. { id: 0, tray_type: null },
  310. {
  311. id: 1,
  312. tray_type: 'PLA',
  313. tray_color: 'C2BAA7FF',
  314. tray_sub_brands: 'PLA Basic',
  315. tray_info_idx: 'GFA00',
  316. remain: 80,
  317. },
  318. { id: 2, tray_type: null },
  319. { id: 3, tray_type: null },
  320. ],
  321. },
  322. ],
  323. vt_tray: [],
  324. nozzles: [],
  325. ams_extruder_map: {},
  326. }),
  327. ),
  328. http.get('/api/v1/printers/:id/inventory-remain', () =>
  329. HttpResponse.json({
  330. inventory_remain_g: { '1': 800 },
  331. slot_materials: [
  332. {
  333. ams_id: 0,
  334. tray_id: 1,
  335. global_tray_id: 1,
  336. material_key: 'PLA|basic|bone-white',
  337. remaining_g: 800,
  338. extruder: 0,
  339. spool: {
  340. brand: 'eSUN',
  341. material: 'PLA',
  342. subtype: 'Basic',
  343. color_name: 'Bone White',
  344. rgba: 'C2BAA7FF',
  345. },
  346. },
  347. ],
  348. }),
  349. ),
  350. );
  351. render(<QueuePage />);
  352. const name = await screen.findByText('Resolved AMS mapping test');
  353. const row = name.closest('.group');
  354. expect(row).not.toBeNull();
  355. await waitFor(() => {
  356. expect(
  357. within(row as HTMLElement).getByText('A2 · eSUN PLA Basic · Bone White'),
  358. ).toBeInTheDocument();
  359. });
  360. expect(within(row as HTMLElement).queryByText('Caramel')).not.toBeInTheDocument();
  361. });
  362. it('resolves a mapped tray on a second regular AMS unit (#3132)', async () => {
  363. const item = {
  364. ...mockQueueItems[0],
  365. id: 86,
  366. printer_id: 1,
  367. printer_name: 'Test Printer',
  368. archive_id: null,
  369. library_file_id: 17,
  370. archive_name: null,
  371. library_file_name: 'Second AMS mapping test',
  372. plate_id: 1,
  373. ams_mapping: [5],
  374. filament_overrides: null,
  375. };
  376. server.use(
  377. http.get('/api/v1/queue/', () => HttpResponse.json([item])),
  378. http.get('/api/v1/library/files/17/plates', () =>
  379. HttpResponse.json({
  380. file_id: 17,
  381. filename: 'second-ams.3mf',
  382. plates: [
  383. {
  384. index: 1,
  385. name: 'Plate 1',
  386. objects: ['Part'],
  387. has_thumbnail: false,
  388. thumbnail_url: null,
  389. print_time_seconds: 3600,
  390. filament_used_grams: 20,
  391. filaments: [
  392. {
  393. slot_id: 1,
  394. type: 'PLA',
  395. color: '#111111',
  396. used_grams: 20,
  397. used_meters: 6.7,
  398. },
  399. ],
  400. },
  401. ],
  402. is_multi_plate: false,
  403. }),
  404. ),
  405. http.get('/api/v1/printers/:id/status', () =>
  406. HttpResponse.json({
  407. id: 1,
  408. name: 'Test Printer',
  409. connected: true,
  410. state: 'IDLE',
  411. ams: [
  412. {
  413. id: 1,
  414. tray: [
  415. { id: 0, tray_type: null },
  416. {
  417. id: 1,
  418. tray_type: 'PLA',
  419. tray_color: 'C2BAA7FF',
  420. tray_sub_brands: 'PLA Basic',
  421. tray_info_idx: 'GFA00',
  422. remain: 80,
  423. },
  424. { id: 2, tray_type: null },
  425. { id: 3, tray_type: null },
  426. ],
  427. },
  428. ],
  429. vt_tray: [],
  430. nozzles: [],
  431. ams_extruder_map: {},
  432. }),
  433. ),
  434. http.get('/api/v1/printers/:id/inventory-remain', () =>
  435. HttpResponse.json({
  436. inventory_remain_g: { '5': 800 },
  437. slot_materials: [
  438. {
  439. ams_id: 1,
  440. tray_id: 1,
  441. global_tray_id: 5,
  442. material_key: 'PLA|basic|bone-white',
  443. remaining_g: 800,
  444. extruder: 0,
  445. spool: {
  446. brand: 'eSUN',
  447. material: 'PLA',
  448. subtype: 'Basic',
  449. color_name: 'Bone White',
  450. rgba: 'C2BAA7FF',
  451. },
  452. },
  453. ],
  454. }),
  455. ),
  456. );
  457. render(<QueuePage />);
  458. const name = await screen.findByText('Second AMS mapping test');
  459. const row = name.closest('.group');
  460. expect(row).not.toBeNull();
  461. await waitFor(() => {
  462. expect(
  463. within(row as HTMLElement).getByText('B2 · eSUN PLA Basic · Bone White'),
  464. ).toBeInTheDocument();
  465. });
  466. });
  467. it('falls back to the selected plate 3MF colour when there is no override (#3132)', async () => {
  468. const item = {
  469. ...mockQueueItems[0],
  470. id: 83,
  471. printer_id: null,
  472. target_model: 'P2S',
  473. archive_id: null,
  474. library_file_id: 14,
  475. archive_name: null,
  476. library_file_name: 'Original colour test',
  477. plate_id: 1,
  478. filament_color: '#FFFFFF',
  479. filament_overrides: null,
  480. };
  481. server.use(
  482. http.get('/api/v1/queue/', () => HttpResponse.json([item])),
  483. http.get('/api/v1/library/files/14/plates', () =>
  484. HttpResponse.json({
  485. file_id: 14,
  486. filename: 'original-colour.3mf',
  487. plates: [
  488. {
  489. index: 1,
  490. name: 'Plate 1',
  491. objects: ['Part'],
  492. has_thumbnail: false,
  493. thumbnail_url: null,
  494. print_time_seconds: 3600,
  495. filament_used_grams: 20,
  496. filaments: [
  497. {
  498. slot_id: 1,
  499. type: 'PLA',
  500. color: '#7C4B00',
  501. used_grams: 20,
  502. used_meters: 6.7,
  503. },
  504. ],
  505. },
  506. ],
  507. is_multi_plate: false,
  508. }),
  509. ),
  510. );
  511. render(<QueuePage />);
  512. await screen.findByText('Original colour test');
  513. const row = screen.getByText('Original colour test').closest('.group');
  514. expect(row).not.toBeNull();
  515. await waitFor(() => {
  516. expect(within(row as HTMLElement).getByTestId('filament-swatch')).toHaveAttribute(
  517. 'title',
  518. '#7C4B00',
  519. );
  520. });
  521. });
  522. it('shows all used plate colours and only overrides the matching slot (#3132)', async () => {
  523. const item = {
  524. ...mockQueueItems[0],
  525. id: 84,
  526. printer_id: null,
  527. target_model: 'P2S',
  528. archive_id: null,
  529. library_file_id: 15,
  530. archive_name: null,
  531. library_file_name: 'Multi colour test',
  532. plate_id: 1,
  533. filament_overrides: [
  534. {
  535. slot_id: 2,
  536. type: 'PLA',
  537. color: '#C2BAA7FF',
  538. color_name: 'Bone White',
  539. force_color_match: false,
  540. },
  541. ],
  542. };
  543. server.use(
  544. http.get('/api/v1/queue/', () => HttpResponse.json([item])),
  545. http.get('/api/v1/library/files/15/plates', () =>
  546. HttpResponse.json({
  547. file_id: 15,
  548. filename: 'multi-colour.3mf',
  549. plates: [
  550. {
  551. index: 1,
  552. name: 'Plate 1',
  553. objects: ['Part'],
  554. has_thumbnail: false,
  555. thumbnail_url: null,
  556. print_time_seconds: 3600,
  557. filament_used_grams: 30,
  558. filaments: [
  559. {
  560. slot_id: 1,
  561. type: 'PLA',
  562. color: '#000000',
  563. used_grams: 20,
  564. used_meters: 6.7,
  565. },
  566. {
  567. slot_id: 2,
  568. type: 'PLA',
  569. color: '#7C4B00',
  570. used_grams: 10,
  571. used_meters: 3.35,
  572. },
  573. ],
  574. },
  575. ],
  576. is_multi_plate: false,
  577. }),
  578. ),
  579. );
  580. render(<QueuePage />);
  581. const overrideName = await screen.findByText('Bone White');
  582. const row = overrideName.closest('.group');
  583. expect(row).not.toBeNull();
  584. await waitFor(() => {
  585. const swatches = within(row as HTMLElement).getAllByTestId('filament-swatch');
  586. expect(swatches).toHaveLength(2);
  587. expect(swatches.map((swatch) => swatch.getAttribute('title'))).toEqual([
  588. '#000000',
  589. '#C2BAA7',
  590. ]);
  591. });
  592. });
  593. it('keeps an eight-colour job compact and exposes details in the tooltip (#3132)', async () => {
  594. const colours = [
  595. '#FF0000',
  596. '#00FF00',
  597. '#0000FF',
  598. '#FFFF00',
  599. '#FF00FF',
  600. '#00FFFF',
  601. '#FFFFFF',
  602. '#000000',
  603. ];
  604. const item = {
  605. ...mockQueueItems[0],
  606. id: 87,
  607. printer_id: null,
  608. target_model: 'P2S',
  609. archive_id: null,
  610. library_file_id: 18,
  611. archive_name: null,
  612. library_file_name: 'Eight colour test',
  613. plate_id: 1,
  614. filament_overrides: colours.map((color, index) => ({
  615. slot_id: index + 1,
  616. type: 'PLA',
  617. color,
  618. color_name: `Colour ${index + 1}`,
  619. force_color_match: false,
  620. })),
  621. };
  622. server.use(
  623. http.get('/api/v1/queue/', () => HttpResponse.json([item])),
  624. http.get('/api/v1/library/files/18/plates', () =>
  625. HttpResponse.json({
  626. file_id: 18,
  627. filename: 'eight-colour.3mf',
  628. plates: [
  629. {
  630. index: 1,
  631. name: 'Plate 1',
  632. objects: ['Part'],
  633. has_thumbnail: false,
  634. thumbnail_url: null,
  635. print_time_seconds: 3600,
  636. filament_used_grams: 40,
  637. filaments: colours.map((color, index) => ({
  638. slot_id: index + 1,
  639. type: 'PLA',
  640. color,
  641. used_grams: 5,
  642. used_meters: 1.7,
  643. })),
  644. },
  645. ],
  646. is_multi_plate: false,
  647. }),
  648. ),
  649. );
  650. render(<QueuePage />);
  651. const name = await screen.findByText('Eight colour test');
  652. const row = name.closest('.group');
  653. expect(row).not.toBeNull();
  654. await waitFor(() => {
  655. const compact = within(row as HTMLElement).getByTestId('queue-filament-compact');
  656. expect(compact).toHaveAttribute(
  657. 'title',
  658. colours.map((_, index) => `Colour ${index + 1}`).join('\n'),
  659. );
  660. expect(within(compact).getAllByTestId('filament-swatch')).toHaveLength(8);
  661. });
  662. // Touch screens never show the tooltip; the same lines are the group's
  663. // accessible name.
  664. expect(screen.getByRole('img', { name: /^Colour 1, Colour 2, .*Colour 8$/ })).toBe(
  665. within(row as HTMLElement).getByTestId('queue-filament-compact'),
  666. );
  667. // Compact mode keeps the long labels out of the metadata row itself.
  668. expect(within(row as HTMLElement).queryByText('Colour 1')).not.toBeInTheDocument();
  669. expect(within(row as HTMLElement).queryByText('Colour 8')).not.toBeInTheDocument();
  670. });
  671. describe('stored mapping on the live printer (#3132)', () => {
  672. const mappedItem = (amsMapping: number[]) => ({
  673. ...mockQueueItems[0],
  674. id: 88,
  675. printer_id: 1,
  676. printer_name: 'Test Printer',
  677. archive_id: null,
  678. library_file_id: 19,
  679. archive_name: null,
  680. library_file_name: 'Mapped slot test',
  681. plate_id: 1,
  682. ams_mapping: amsMapping,
  683. filament_overrides: [
  684. {
  685. slot_id: 1,
  686. type: 'PLA',
  687. color: '#8E351BFF',
  688. color_name: 'Caramel',
  689. force_color_match: false,
  690. },
  691. ],
  692. });
  693. const useMappedPrinter = (amsMapping: number[], status: Record<string, unknown>) => {
  694. server.use(
  695. http.get('/api/v1/queue/', () => HttpResponse.json([mappedItem(amsMapping)])),
  696. http.get('/api/v1/library/files/19/plates', () =>
  697. HttpResponse.json({
  698. file_id: 19,
  699. filename: 'mapped-slot.3mf',
  700. plates: [
  701. {
  702. index: 1,
  703. name: 'Plate 1',
  704. objects: ['Part'],
  705. has_thumbnail: false,
  706. thumbnail_url: null,
  707. print_time_seconds: 3600,
  708. filament_used_grams: 20,
  709. filaments: [
  710. { slot_id: 1, type: 'PLA', color: '#7C4B00', used_grams: 20, used_meters: 6.7 },
  711. ],
  712. },
  713. ],
  714. is_multi_plate: false,
  715. }),
  716. ),
  717. http.get('/api/v1/printers/:id/status', () =>
  718. HttpResponse.json({
  719. id: 1,
  720. name: 'Test Printer',
  721. state: 'IDLE',
  722. nozzles: [],
  723. ams_extruder_map: {},
  724. ...status,
  725. }),
  726. ),
  727. http.get('/api/v1/printers/:id/inventory-remain', () =>
  728. HttpResponse.json({ inventory_remain_g: {}, slot_materials: [] }),
  729. ),
  730. );
  731. };
  732. // Regular AMS units expose four tray records; slot A3 (global 2) is empty.
  733. const amsWithEmptyA3 = [
  734. {
  735. id: 0,
  736. tray: [
  737. { id: 0, tray_type: 'PLA', tray_color: 'FFFFFFFF', remain: 50 },
  738. { id: 1, tray_type: 'PLA', tray_color: 'C2BAA7FF', remain: 80 },
  739. // Firmware confirms A3 has no spool (tray_exist_bits bit clear).
  740. { id: 2, tray_type: null, exists: false, state: 9 },
  741. { id: 3, tray_type: null },
  742. ],
  743. },
  744. ];
  745. const mappedRow = async () => {
  746. render(<QueuePage />);
  747. const name = await screen.findByText('Mapped slot test');
  748. const row = name.closest('.group');
  749. expect(row).not.toBeNull();
  750. return row as HTMLElement;
  751. };
  752. it('resolves a mapping to the external spool', async () => {
  753. useMappedPrinter([254], {
  754. connected: true,
  755. ams: [],
  756. vt_tray: [{ id: 254, tray_type: 'PETG', tray_color: 'C2BAA7FF', remain: 60 }],
  757. });
  758. const row = await mappedRow();
  759. await waitFor(() => {
  760. expect(within(row).getByText(/^External · PETG · /)).toBeInTheDocument();
  761. });
  762. expect(within(row).queryByText('Caramel')).not.toBeInTheDocument();
  763. });
  764. it('says the mapped slot is empty, keeping the intended colour', async () => {
  765. useMappedPrinter([2], { connected: true, ams: amsWithEmptyA3, vt_tray: [] });
  766. const row = await mappedRow();
  767. await waitFor(() => {
  768. expect(within(row).getByText('A3 · Empty · Caramel')).toBeInTheDocument();
  769. });
  770. expect(within(row).getByTestId('filament-swatch')).toHaveAttribute('title', '#8E351B');
  771. });
  772. it('never calls the external spool empty, having no presence signal', async () => {
  773. // Shape the status route really sends: an external tray carries no
  774. // state / exists, so an unconfigured spool and no spool look the same.
  775. let statusServed = false;
  776. useMappedPrinter([254], { connected: true, ams: [], vt_tray: [] });
  777. server.use(
  778. http.get('/api/v1/printers/:id/status', () => {
  779. statusServed = true;
  780. return HttpResponse.json({ id: 1, name: 'Test Printer', state: 'IDLE', connected: true, ams: [], vt_tray: [{ id: 254, tray_type: '' }], nozzles: [], ams_extruder_map: {} });
  781. }),
  782. );
  783. const row = await mappedRow();
  784. await waitFor(() => expect(statusServed).toBe(true));
  785. expect(await within(row).findByText('Caramel')).toBeInTheDocument();
  786. expect(within(row).queryByText(/Empty/)).not.toBeInTheDocument();
  787. });
  788. it('does not call a loaded but unconfigured spool empty (#2527)', async () => {
  789. // A non-RFID spool the firmware can't identify has no tray_type but is
  790. // physically present; the Printers page draws it as "?", not "Empty".
  791. const ams = [
  792. {
  793. id: 0,
  794. tray: [
  795. { id: 0, tray_type: 'PLA', tray_color: 'FFFFFFFF', remain: 50 },
  796. { id: 1, tray_type: 'PLA', tray_color: 'C2BAA7FF', remain: 80 },
  797. { id: 2, tray_type: '', exists: true, state: 3 },
  798. { id: 3, tray_type: null },
  799. ],
  800. },
  801. ];
  802. let statusServed = false;
  803. useMappedPrinter([2], { connected: true, ams, vt_tray: [] });
  804. server.use(
  805. http.get('/api/v1/printers/:id/status', () => {
  806. statusServed = true;
  807. return HttpResponse.json({ id: 1, name: 'Test Printer', state: 'IDLE', connected: true, ams, vt_tray: [], nozzles: [], ams_extruder_map: {} });
  808. }),
  809. );
  810. const row = await mappedRow();
  811. await waitFor(() => expect(statusServed).toBe(true));
  812. expect(await within(row).findByText('Caramel')).toBeInTheDocument();
  813. expect(within(row).queryByText(/Empty/)).not.toBeInTheDocument();
  814. });
  815. it('does not call a slot empty while the printer is offline', async () => {
  816. useMappedPrinter([2], { connected: false, ams: amsWithEmptyA3, vt_tray: [] });
  817. const row = await mappedRow();
  818. expect(await within(row).findByText('Caramel')).toBeInTheDocument();
  819. expect(within(row).queryByText(/Empty/)).not.toBeInTheDocument();
  820. });
  821. });
  822. it('shows one if-started-now ETA for an eligible pending item', async () => {
  823. // Printer 1 is free: nothing is printing on it and nothing is queued ahead.
  824. server.use(
  825. http.get('/api/v1/queue/', () => {
  826. return HttpResponse.json([mockQueueItems[0]]);
  827. }),
  828. );
  829. render(<QueuePage />);
  830. const name = await screen.findByText('Test Print 1');
  831. const row = name.closest('.group');
  832. expect(row).not.toBeNull();
  833. const etaEl = within(row as HTMLElement).getAllByTestId('queue-item-eta');
  834. expect(etaEl).toHaveLength(1);
  835. // The tooltip must say what the number actually means, not reuse the
  836. // printers-page "Estimated completion time" wording (which this is not).
  837. expect(etaEl[0]).toHaveAttribute(
  838. 'title',
  839. 'Completion time if this job started now',
  840. );
  841. });
  842. // The scheduler only writes waiting_reason on the model-based assignment
  843. // path, so an item pinned to a specific printer carries no marker at all
  844. // while it sits behind a running job. Without the printer-busy check every
  845. // one of these quoted the same wrong "starts now" time.
  846. it('does not show an ETA for an item pinned behind a running print', async () => {
  847. render(<QueuePage />);
  848. // mockQueueItems[1] ("Active Print") is printing on printer 1, and
  849. // "Test Print 1" is pending on the same printer with waiting_reason null.
  850. const name = await screen.findByText('Test Print 1');
  851. const row = name.closest('.group');
  852. expect(row).not.toBeNull();
  853. expect(
  854. within(row as HTMLElement).queryByTestId('queue-item-eta'),
  855. ).not.toBeInTheDocument();
  856. });
  857. it('shows the ETA only on the next item up when several share an idle printer', async () => {
  858. server.use(
  859. http.get('/api/v1/queue/', () => {
  860. return HttpResponse.json([
  861. { ...mockQueueItems[0], id: 10, position: 1, archive_name: 'First up' },
  862. { ...mockQueueItems[0], id: 11, position: 2, archive_name: 'Second up' },
  863. { ...mockQueueItems[0], id: 12, position: 3, archive_name: 'Third up' },
  864. ]);
  865. }),
  866. );
  867. render(<QueuePage />);
  868. await screen.findByText('Third up');
  869. const etaRowNames = screen
  870. .queryAllByTestId('queue-item-eta')
  871. .map((el) => el.closest('.group')?.textContent);
  872. expect(etaRowNames).toHaveLength(1);
  873. expect(etaRowNames[0]).toContain('First up');
  874. });
  875. it('shows an ETA for a staged item queued behind others on an idle printer', async () => {
  876. // The scheduler skips manual-start items without claiming the printer, so
  877. // a staged job is startable whenever its printer is free — queue order
  878. // does not gate it.
  879. server.use(
  880. http.get('/api/v1/queue/', () => {
  881. return HttpResponse.json([
  882. { ...mockQueueItems[0], id: 20, position: 1, archive_name: 'Auto first' },
  883. {
  884. ...mockQueueItems[0],
  885. id: 21,
  886. position: 2,
  887. archive_name: 'Staged second',
  888. manual_start: true,
  889. },
  890. ]);
  891. }),
  892. );
  893. render(<QueuePage />);
  894. const name = await screen.findByText('Staged second');
  895. const row = name.closest('.group');
  896. expect(row).not.toBeNull();
  897. expect(
  898. within(row as HTMLElement).getAllByTestId('queue-item-eta'),
  899. ).toHaveLength(1);
  900. });
  901. it('does not show an ETA for an item conditional on a previous print', async () => {
  902. server.use(
  903. http.get('/api/v1/queue/', () => {
  904. return HttpResponse.json([
  905. {
  906. ...mockQueueItems[0],
  907. archive_name: 'Conditional Print',
  908. require_previous_success: true,
  909. },
  910. ]);
  911. }),
  912. );
  913. render(<QueuePage />);
  914. const name = await screen.findByText('Conditional Print');
  915. const row = name.closest('.group');
  916. expect(row).not.toBeNull();
  917. expect(
  918. within(row as HTMLElement).queryByTestId('queue-item-eta'),
  919. ).not.toBeInTheDocument();
  920. });
  921. it('advances the ETA as time passes', async () => {
  922. vi.useFakeTimers({ shouldAdvanceTime: true });
  923. vi.setSystemTime(new Date('2026-08-02T10:00:00Z'));
  924. try {
  925. server.use(
  926. http.get('/api/v1/queue/', () => {
  927. return HttpResponse.json([mockQueueItems[0]]);
  928. }),
  929. );
  930. render(<QueuePage />);
  931. const name = await screen.findByText('Test Print 1');
  932. const row = name.closest('.group') as HTMLElement;
  933. const before = within(row).getByTestId('queue-item-eta').textContent;
  934. // The queue payload never changes, so react-query hands back the same
  935. // object and nothing here re-renders on its own. Only the page's own
  936. // clock can move this value.
  937. await vi.advanceTimersByTimeAsync(45 * 60 * 1000);
  938. await waitFor(() => {
  939. expect(
  940. within(row).getByTestId('queue-item-eta').textContent,
  941. ).not.toBe(before);
  942. });
  943. } finally {
  944. vi.useRealTimers();
  945. }
  946. });
  947. it('shows one if-started-now ETA for a staged item', async () => {
  948. server.use(
  949. http.get('/api/v1/queue/', () => {
  950. return HttpResponse.json([
  951. {
  952. ...mockQueueItems[0],
  953. archive_name: 'Staged Print',
  954. manual_start: true,
  955. },
  956. ]);
  957. }),
  958. );
  959. render(<QueuePage />);
  960. const name = await screen.findByText('Staged Print');
  961. const row = name.closest('.group');
  962. expect(row).not.toBeNull();
  963. expect(
  964. within(row as HTMLElement).getAllByTestId('queue-item-eta'),
  965. ).toHaveLength(1);
  966. });
  967. it('shows exactly one live ETA for a printing item', async () => {
  968. server.use(
  969. http.get('/api/v1/printers/:id/status', ({ params }) => {
  970. return HttpResponse.json({
  971. id: Number(params.id),
  972. name: 'Test Printer',
  973. connected: true,
  974. state: 'RUNNING',
  975. progress: 50,
  976. remaining_time: 60,
  977. layer_num: 50,
  978. total_layers: 100,
  979. filename: 'active.3mf',
  980. });
  981. }),
  982. );
  983. render(<QueuePage />);
  984. const name = await screen.findByText('Active Print');
  985. const row = name.closest('.group');
  986. expect(row).not.toBeNull();
  987. await waitFor(() => {
  988. expect(
  989. within(row as HTMLElement).getAllByText(/^ETA\s/),
  990. ).toHaveLength(1);
  991. });
  992. expect(
  993. within(row as HTMLElement).queryByTestId('queue-item-eta'),
  994. ).not.toBeInTheDocument();
  995. });
  996. it('does not show an ETA for a waiting item', async () => {
  997. server.use(
  998. http.get('/api/v1/queue/', () => {
  999. return HttpResponse.json([
  1000. {
  1001. ...mockQueueItems[0],
  1002. archive_name: 'Waiting Print',
  1003. waiting_reason: 'Waiting for matching printer',
  1004. },
  1005. ]);
  1006. }),
  1007. );
  1008. render(<QueuePage />);
  1009. const name = await screen.findByText('Waiting Print');
  1010. const row = name.closest('.group');
  1011. expect(row).not.toBeNull();
  1012. expect(
  1013. within(row as HTMLElement).queryByTestId('queue-item-eta'),
  1014. ).not.toBeInTheDocument();
  1015. });
  1016. it('does not show an if-started-now ETA for a scheduled item', async () => {
  1017. server.use(
  1018. http.get('/api/v1/queue/', () => {
  1019. return HttpResponse.json([
  1020. {
  1021. ...mockQueueItems[0],
  1022. archive_name: 'Scheduled Print',
  1023. scheduled_time: new Date(
  1024. Date.now() + 5 * 60 * 60 * 1000,
  1025. ).toISOString(),
  1026. },
  1027. ]);
  1028. }),
  1029. );
  1030. render(<QueuePage />);
  1031. const name = await screen.findByText('Scheduled Print');
  1032. const row = name.closest('.group');
  1033. expect(row).not.toBeNull();
  1034. expect(
  1035. within(row as HTMLElement).queryByTestId('queue-item-eta'),
  1036. ).not.toBeInTheDocument();
  1037. });
  1038. it('tells an unscheduled item when it runs, without naming a dispatch mode', async () => {
  1039. // ASAP and Queue differ only in where the item is inserted; neither is
  1040. // stored on it, so this column cannot tell them apart. Labelling every
  1041. // unscheduled item "ASAP" made a Queue choice look overridden, which is
  1042. // what both #2557 and #3018 opened on.
  1043. server.use(
  1044. http.get('/api/v1/queue/', () => {
  1045. return HttpResponse.json([
  1046. { ...mockQueueItems[0], archive_name: 'Queued Print', scheduled_time: null },
  1047. ]);
  1048. }),
  1049. );
  1050. render(<QueuePage />);
  1051. const name = await screen.findByText('Queued Print');
  1052. const row = name.closest('.group') as HTMLElement;
  1053. expect(row).not.toBeNull();
  1054. expect(within(row).getByText('When a printer is free')).toBeInTheDocument();
  1055. expect(within(row).queryByText('ASAP')).not.toBeInTheDocument();
  1056. });
  1057. it('does not render a dangling ETA for an invalid duration', async () => {
  1058. server.use(
  1059. http.get('/api/v1/queue/', () => {
  1060. return HttpResponse.json([
  1061. {
  1062. ...mockQueueItems[0],
  1063. archive_name: 'Invalid Duration Print',
  1064. print_time_seconds: -60,
  1065. },
  1066. ]);
  1067. }),
  1068. );
  1069. render(<QueuePage />);
  1070. const name = await screen.findByText('Invalid Duration Print');
  1071. const row = name.closest('.group');
  1072. expect(row).not.toBeNull();
  1073. expect(
  1074. within(row as HTMLElement).queryByTestId('queue-item-eta'),
  1075. ).not.toBeInTheDocument();
  1076. expect(
  1077. within(row as HTMLElement).queryByText(/^ETA(?:\s|$)/),
  1078. ).not.toBeInTheDocument();
  1079. });
  1080. it('shows completed items in history', async () => {
  1081. const user = userEvent.setup();
  1082. render(<QueuePage />);
  1083. // The History tab now owns the completed/cancelled/failed list.
  1084. await user.click(await screen.findByRole('button', { name: /^History/ }));
  1085. await waitFor(() => {
  1086. expect(screen.getByText('Completed Print')).toBeInTheDocument();
  1087. });
  1088. });
  1089. it('shows status badges', async () => {
  1090. render(<QueuePage />);
  1091. await waitFor(() => {
  1092. // Queue items should be visible with status indicators
  1093. expect(screen.getByText('Test Print 1')).toBeInTheDocument();
  1094. });
  1095. });
  1096. it('shows printer names', async () => {
  1097. render(<QueuePage />);
  1098. await waitFor(() => {
  1099. const printerElements = screen.getAllByText('Test Printer');
  1100. expect(printerElements.length).toBeGreaterThan(0);
  1101. });
  1102. });
  1103. it('renders queue items with plate_id correctly', async () => {
  1104. // Override with queue items that have plate_id set
  1105. server.use(
  1106. http.get('/api/v1/queue/', () => {
  1107. return HttpResponse.json([
  1108. {
  1109. ...mockQueueItems[0],
  1110. plate_id: 2,
  1111. archive_name: 'Multi-plate Print',
  1112. },
  1113. ]);
  1114. })
  1115. );
  1116. render(<QueuePage />);
  1117. await waitFor(() => {
  1118. expect(screen.getByText('Multi-plate Print')).toBeInTheDocument();
  1119. });
  1120. });
  1121. });
  1122. describe('history pagination', () => {
  1123. // #2682: History rendered the full count in the header but only ever drew
  1124. // the first 50 rows, with no way to reach the rest. It now paginates with
  1125. // a "Show more" control.
  1126. const manyHistory = Array.from({ length: 60 }, (_, i) => ({
  1127. ...mockQueueItems[2],
  1128. id: 100 + i,
  1129. batch_id: null,
  1130. archive_name: `History Item ${String(i).padStart(2, '0')}`,
  1131. // Descending completed_at so index 0 is newest and sorts first; the
  1132. // default History sort is by date, newest first.
  1133. completed_at: new Date(Date.UTC(2024, 0, 1, 0, 0, 0) - i * 60000).toISOString(),
  1134. }));
  1135. beforeEach(() => {
  1136. server.use(
  1137. http.get('/api/v1/queue/', () => {
  1138. return HttpResponse.json(manyHistory);
  1139. })
  1140. );
  1141. });
  1142. it('caps the History list at one page and reveals the rest on Show more', async () => {
  1143. const user = userEvent.setup();
  1144. render(<QueuePage />);
  1145. await user.click(await screen.findByRole('button', { name: /^History/ }));
  1146. // First page is drawn; an item past the 50-row cap is not.
  1147. await waitFor(() => {
  1148. expect(screen.getByText('History Item 00')).toBeInTheDocument();
  1149. });
  1150. expect(screen.queryByText('History Item 59')).not.toBeInTheDocument();
  1151. expect(screen.getByText('Showing 50 of 60')).toBeInTheDocument();
  1152. // Show more reveals the remainder and then disappears (nothing left).
  1153. await user.click(screen.getByRole('button', { name: /show more/i }));
  1154. await waitFor(() => {
  1155. expect(screen.getByText('History Item 59')).toBeInTheDocument();
  1156. });
  1157. expect(screen.queryByRole('button', { name: /show more/i })).not.toBeInTheDocument();
  1158. });
  1159. });
  1160. describe('empty state', () => {
  1161. it('shows empty state when no queue items', async () => {
  1162. server.use(
  1163. http.get('/api/v1/queue/', () => {
  1164. return HttpResponse.json([]);
  1165. })
  1166. );
  1167. render(<QueuePage />);
  1168. await waitFor(() => {
  1169. expect(screen.getByText('No prints scheduled')).toBeInTheDocument();
  1170. });
  1171. });
  1172. });
  1173. describe('filtering', () => {
  1174. it('has printer filter options', async () => {
  1175. const user = userEvent.setup();
  1176. render(<QueuePage />);
  1177. await waitFor(() => {
  1178. expect(screen.getByText('All Printers')).toBeInTheDocument();
  1179. });
  1180. const printerSelect = screen.getByDisplayValue('All Printers');
  1181. await user.click(printerSelect);
  1182. expect(screen.getByText('Unassigned')).toBeInTheDocument();
  1183. });
  1184. it('has status filter options', async () => {
  1185. const user = userEvent.setup();
  1186. render(<QueuePage />);
  1187. await waitFor(() => {
  1188. expect(screen.getByText('All Status')).toBeInTheDocument();
  1189. });
  1190. const statusSelect = screen.getByDisplayValue('All Status');
  1191. await user.click(statusSelect);
  1192. expect(screen.getByRole('option', { name: 'Pending' })).toBeInTheDocument();
  1193. expect(screen.getByRole('option', { name: 'Printing' })).toBeInTheDocument();
  1194. expect(screen.getByRole('option', { name: 'Completed' })).toBeInTheDocument();
  1195. });
  1196. });
  1197. describe('queue actions', () => {
  1198. it('shows edit button for pending items', async () => {
  1199. render(<QueuePage />);
  1200. await waitFor(() => {
  1201. expect(screen.getByText('Test Print 1')).toBeInTheDocument();
  1202. });
  1203. // Find the edit button (Pencil icon)
  1204. const editButtons = screen.getAllByTitle('Edit');
  1205. expect(editButtons.length).toBeGreaterThan(0);
  1206. });
  1207. it('shows cancel button for pending items', async () => {
  1208. render(<QueuePage />);
  1209. await waitFor(() => {
  1210. expect(screen.getByText('Test Print 1')).toBeInTheDocument();
  1211. });
  1212. const cancelButtons = screen.getAllByTitle('Cancel');
  1213. expect(cancelButtons.length).toBeGreaterThan(0);
  1214. });
  1215. it('shows stop button for printing items', async () => {
  1216. render(<QueuePage />);
  1217. await waitFor(() => {
  1218. expect(screen.getByText('Active Print')).toBeInTheDocument();
  1219. });
  1220. const stopButtons = screen.getAllByTitle('Stop Print');
  1221. expect(stopButtons.length).toBeGreaterThan(0);
  1222. });
  1223. it('shows re-queue button for history items', async () => {
  1224. const user = userEvent.setup();
  1225. render(<QueuePage />);
  1226. await user.click(await screen.findByRole('button', { name: /^History/ }));
  1227. await waitFor(() => {
  1228. expect(screen.getByText('Completed Print')).toBeInTheDocument();
  1229. });
  1230. const requeueButtons = screen.getAllByTitle('Re-queue');
  1231. expect(requeueButtons.length).toBeGreaterThan(0);
  1232. });
  1233. });
  1234. describe('clear history', () => {
  1235. it('shows clear history button when history exists', async () => {
  1236. const user = userEvent.setup();
  1237. render(<QueuePage />);
  1238. // Clear History only renders inside the History tab now.
  1239. await user.click(await screen.findByRole('button', { name: /^History/ }));
  1240. await waitFor(() => {
  1241. expect(screen.getByText('Clear History')).toBeInTheDocument();
  1242. });
  1243. });
  1244. it('opens confirm modal when clicking clear history', async () => {
  1245. const user = userEvent.setup();
  1246. render(<QueuePage />);
  1247. await user.click(await screen.findByRole('button', { name: /^History/ }));
  1248. await waitFor(() => {
  1249. expect(screen.getByText('Clear History')).toBeInTheDocument();
  1250. });
  1251. const clearButton = screen.getByRole('button', { name: /clear history/i });
  1252. await user.click(clearButton);
  1253. await waitFor(() => {
  1254. expect(screen.getByText(/Are you sure you want to remove all/i)).toBeInTheDocument();
  1255. });
  1256. });
  1257. });
  1258. describe('staged items', () => {
  1259. it('shows staged badge for manual_start items', async () => {
  1260. server.use(
  1261. http.get('/api/v1/queue/', () => {
  1262. return HttpResponse.json([
  1263. {
  1264. ...mockQueueItems[0],
  1265. manual_start: true,
  1266. },
  1267. ]);
  1268. })
  1269. );
  1270. render(<QueuePage />);
  1271. await waitFor(() => {
  1272. expect(screen.getByText('Staged')).toBeInTheDocument();
  1273. });
  1274. });
  1275. it('shows start button for staged items', async () => {
  1276. server.use(
  1277. http.get('/api/v1/queue/', () => {
  1278. return HttpResponse.json([
  1279. {
  1280. ...mockQueueItems[0],
  1281. manual_start: true,
  1282. },
  1283. ]);
  1284. })
  1285. );
  1286. render(<QueuePage />);
  1287. await waitFor(() => {
  1288. expect(screen.getByTitle('Start Print')).toBeInTheDocument();
  1289. });
  1290. });
  1291. });
  1292. describe('auto power off badge', () => {
  1293. it('shows power off badge when auto_off_after is true', async () => {
  1294. render(<QueuePage />);
  1295. await waitFor(() => {
  1296. expect(screen.getByText('Auto power off')).toBeInTheDocument();
  1297. });
  1298. });
  1299. });
  1300. describe('gcode injection badge', () => {
  1301. it('shows G-code badge when gcode_injection is true', async () => {
  1302. const itemsWithGcode = mockQueueItems.map((item, i) =>
  1303. i === 0 ? { ...item, gcode_injection: true } : item
  1304. );
  1305. server.use(
  1306. http.get('/api/v1/queue/', () => HttpResponse.json(itemsWithGcode)),
  1307. );
  1308. render(<QueuePage />);
  1309. await waitFor(() => {
  1310. expect(screen.getByText('G-code')).toBeInTheDocument();
  1311. });
  1312. });
  1313. it('does not show G-code badge when gcode_injection is false', async () => {
  1314. render(<QueuePage />);
  1315. await waitFor(() => {
  1316. expect(screen.getByText('Test Print 1')).toBeInTheDocument();
  1317. });
  1318. expect(screen.queryByText('G-code')).not.toBeInTheDocument();
  1319. });
  1320. });
  1321. describe('outcome prompt badge (#1898)', () => {
  1322. it('shows the badge only on items with confirm_outcome set', async () => {
  1323. const items = mockQueueItems.map((item, i) =>
  1324. i === 0 ? { ...item, confirm_outcome: true } : { ...item, confirm_outcome: false }
  1325. );
  1326. server.use(http.get('/api/v1/queue/', () => HttpResponse.json(items)));
  1327. render(<QueuePage />);
  1328. await waitFor(() => expect(screen.getByText('Test Print 1')).toBeInTheDocument());
  1329. const badges = screen.getAllByText('Outcome prompt');
  1330. expect(badges).toHaveLength(1);
  1331. expect(badges[0].closest('span')).toHaveAttribute('title', 'Asks how the print came out after it completes');
  1332. });
  1333. it('renders nothing when no item asks for an outcome', async () => {
  1334. render(<QueuePage />);
  1335. await waitFor(() => expect(screen.getByText('Test Print 1')).toBeInTheDocument());
  1336. expect(screen.queryByText('Outcome prompt')).not.toBeInTheDocument();
  1337. });
  1338. });
  1339. describe('filament-short ▶ flow (#1496)', () => {
  1340. /**
  1341. * The dispatch pre-flight flags a queue item as filament_short. The user
  1342. * clicks ▶, the backend re-checks live and either dispatches (no deficit
  1343. * anymore — clear flag) or returns 409 with the per-slot deficit so the
  1344. * frontend can render the "Print Anyway" confirm modal.
  1345. */
  1346. const shortItem = {
  1347. ...mockQueueItems[0],
  1348. manual_start: true,
  1349. filament_short: true,
  1350. };
  1351. it('renders the filament-short badge on a flagged pending row', async () => {
  1352. server.use(
  1353. http.get('/api/v1/queue/', () => HttpResponse.json([shortItem])),
  1354. );
  1355. render(<QueuePage />);
  1356. await waitFor(() => {
  1357. expect(screen.getByText(/Insufficient filament for the assigned spool/i)).toBeInTheDocument();
  1358. });
  1359. });
  1360. it('opens the Print Anyway modal when ▶ returns 409 and retries with skip_filament_check', async () => {
  1361. let secondCallSkippedCheck: boolean | null = null;
  1362. let attempts = 0;
  1363. server.use(
  1364. http.get('/api/v1/queue/', () => HttpResponse.json([shortItem])),
  1365. http.post('/api/v1/queue/:id/start', ({ request }) => {
  1366. attempts += 1;
  1367. const url = new URL(request.url);
  1368. const skip = url.searchParams.get('skip_filament_check') === 'true';
  1369. if (attempts === 1) {
  1370. return HttpResponse.json(
  1371. {
  1372. detail: {
  1373. code: 'insufficient_filament',
  1374. deficit: [
  1375. {
  1376. slot_id: 1,
  1377. ams_id: 0,
  1378. tray_id: 0,
  1379. filament_type: 'PLA',
  1380. required_grams: 270,
  1381. remaining_grams: 200,
  1382. },
  1383. ],
  1384. },
  1385. },
  1386. { status: 409 },
  1387. );
  1388. }
  1389. secondCallSkippedCheck = skip;
  1390. return HttpResponse.json({ ...shortItem, manual_start: false, filament_short: false });
  1391. }),
  1392. );
  1393. render(<QueuePage />);
  1394. const playButton = await screen.findByTitle(/Start Print|do not have permission to start prints/i);
  1395. await userEvent.click(playButton);
  1396. // Wait for the start endpoint to be hit (the 409 path returns to onError).
  1397. await waitFor(() => expect(attempts).toBe(1));
  1398. // Modal shows the deficit detail
  1399. await screen.findByRole('button', { name: /Print Anyway/i });
  1400. expect(
  1401. screen.getByText(/Slot 1: needs 270 g, 200 g remaining/i),
  1402. ).toBeInTheDocument();
  1403. await userEvent.click(screen.getByRole('button', { name: /Print Anyway/i }));
  1404. await waitFor(() => expect(secondCallSkippedCheck).toBe(true));
  1405. expect(attempts).toBe(2);
  1406. });
  1407. });
  1408. describe('filament-not-loaded ▶ flow (#2799)', () => {
  1409. /**
  1410. * The scheduler held the item because a filament it prints has no tray on
  1411. * the printer. ▶ asks the backend first: still missing is a 409 with the
  1412. * missing filaments, and "Print Anyway" retries with skip_filament_check.
  1413. */
  1414. const heldItem = {
  1415. ...mockQueueItems[0],
  1416. manual_start: true,
  1417. waiting_reason: 'Needs PETG #2850E0',
  1418. };
  1419. it('shows the missing filament and retries with skip_filament_check', async () => {
  1420. let secondCallSkippedCheck: boolean | null = null;
  1421. let attempts = 0;
  1422. server.use(
  1423. http.get('/api/v1/queue/', () => HttpResponse.json([heldItem])),
  1424. http.post('/api/v1/queue/:id/start', ({ request }) => {
  1425. attempts += 1;
  1426. if (attempts === 1) {
  1427. return HttpResponse.json(
  1428. { detail: { code: 'unmatched_filament', missing: ['PETG #2850E0'] } },
  1429. { status: 409 },
  1430. );
  1431. }
  1432. secondCallSkippedCheck = new URL(request.url).searchParams.get('skip_filament_check') === 'true';
  1433. return HttpResponse.json({ ...heldItem, manual_start: false });
  1434. }),
  1435. );
  1436. render(<QueuePage />);
  1437. const playButton = await screen.findByTitle(/Start Print|do not have permission to start prints/i);
  1438. await userEvent.click(playButton);
  1439. await waitFor(() => expect(attempts).toBe(1));
  1440. await screen.findByText('Filament not loaded');
  1441. // The dialog's message names what to load; the row says it too.
  1442. expect(screen.getByText(/no tray with this filament[\s\S]*PETG #2850E0/)).toBeInTheDocument();
  1443. await userEvent.click(screen.getByRole('button', { name: /Print Anyway/i }));
  1444. await waitFor(() => expect(secondCallSkippedCheck).toBe(true));
  1445. expect(attempts).toBe(2);
  1446. });
  1447. });
  1448. // #2667: mobile can't drag-reorder the queue, so pending rows get up/down
  1449. // arrows. They persist via the same POST /queue/reorder as drag. The
  1450. // buttons live in the DOM at every width (Tailwind `sm:hidden` is CSS-only),
  1451. // so they're clickable in jsdom.
  1452. describe('mobile reorder arrows (#2667)', () => {
  1453. const threePending = [1, 2, 3].map((n) => ({
  1454. ...mockQueueItems[0],
  1455. id: n,
  1456. archive_id: n,
  1457. position: n,
  1458. status: 'pending',
  1459. archive_name: `Pending ${n}`,
  1460. }));
  1461. it('renders Move Up / Move Down controls for pending items', async () => {
  1462. server.use(http.get('/api/v1/queue/', () => HttpResponse.json(threePending)));
  1463. render(<QueuePage />);
  1464. await waitFor(() => expect(screen.getByText('Pending 1')).toBeInTheDocument());
  1465. // One pair per pending row.
  1466. expect(screen.getAllByTitle('Move Up')).toHaveLength(3);
  1467. expect(screen.getAllByTitle('Move Down')).toHaveLength(3);
  1468. });
  1469. it('moving the first item down persists the swapped order', async () => {
  1470. let reorderBody: { items: { id: number; position: number }[] } | null = null;
  1471. server.use(
  1472. http.get('/api/v1/queue/', () => HttpResponse.json(threePending)),
  1473. http.post('/api/v1/queue/reorder', async ({ request }) => {
  1474. reorderBody = (await request.json()) as typeof reorderBody;
  1475. return HttpResponse.json({ message: 'ok' });
  1476. }),
  1477. );
  1478. render(<QueuePage />);
  1479. await waitFor(() => expect(screen.getByText('Pending 1')).toBeInTheDocument());
  1480. // First row's "Move Down": item 1 drops below item 2 → [2, 1, 3].
  1481. await userEvent.click(screen.getAllByTitle('Move Down')[0]);
  1482. await waitFor(() => expect(reorderBody).not.toBeNull());
  1483. expect(reorderBody!.items).toEqual([
  1484. { id: 2, position: 1 },
  1485. { id: 1, position: 2 },
  1486. { id: 3, position: 3 },
  1487. ]);
  1488. });
  1489. it('disables Move Up on the first row and Move Down on the last', async () => {
  1490. server.use(http.get('/api/v1/queue/', () => HttpResponse.json(threePending)));
  1491. render(<QueuePage />);
  1492. await waitFor(() => expect(screen.getByText('Pending 1')).toBeInTheDocument());
  1493. // Rows render top-to-bottom in position order.
  1494. expect(screen.getAllByTitle('Move Up')[0]).toBeDisabled();
  1495. expect(screen.getAllByTitle('Move Down')[2]).toBeDisabled();
  1496. });
  1497. });
  1498. describe('bulk edit G-code injection (#3058)', () => {
  1499. /**
  1500. * gcode_injection is a per-item column the single-item edit modal has
  1501. * always exposed, and PATCH /queue/bulk has always accepted — only the
  1502. * bulk dialog left it out, so turning injection on for a whole queue
  1503. * meant opening every item.
  1504. */
  1505. const settingsWithSnippets = {
  1506. auto_archive: true,
  1507. gcode_snippets: '{"X1C":{"start_gcode":"","end_gcode":"M400"}}',
  1508. };
  1509. /** Select the one pending row and open the bulk edit dialog. */
  1510. const openBulkEdit = async () => {
  1511. render(<QueuePage />);
  1512. await waitFor(() => expect(screen.getByText('Test Print 1')).toBeInTheDocument());
  1513. await userEvent.click(screen.getByText('Select All'));
  1514. await userEvent.click(await screen.findByTitle('Edit Selected'));
  1515. return screen.getByText('Edit 1 Item').closest('div')!.parentElement!;
  1516. };
  1517. it('offers the toggle when a G-code snippet is configured', async () => {
  1518. server.use(http.get('/api/v1/settings/', () => HttpResponse.json(settingsWithSnippets)));
  1519. await openBulkEdit();
  1520. expect(await screen.findByText('Inject G-code')).toBeInTheDocument();
  1521. });
  1522. it('hides the toggle when no snippet is configured', async () => {
  1523. await openBulkEdit();
  1524. // The dialog is open — other queue options are there, injection is not.
  1525. expect(screen.getByText('Staged (manual start)')).toBeInTheDocument();
  1526. expect(screen.queryByText('Inject G-code')).not.toBeInTheDocument();
  1527. });
  1528. it('sends gcode_injection with the bulk PATCH once toggled on', async () => {
  1529. let patchBody: Record<string, unknown> | null = null;
  1530. server.use(
  1531. http.get('/api/v1/settings/', () => HttpResponse.json(settingsWithSnippets)),
  1532. http.patch('/api/v1/queue/bulk', async ({ request }) => {
  1533. patchBody = (await request.json()) as Record<string, unknown>;
  1534. return HttpResponse.json({ updated_count: 1, skipped_count: 0, message: 'Updated 1 items' });
  1535. }),
  1536. );
  1537. const dialog = await openBulkEdit();
  1538. const row = (await screen.findByText('Inject G-code')).closest('div')!;
  1539. await userEvent.click(within(row).getByText('On'));
  1540. await userEvent.click(within(dialog).getByText('Apply Changes'));
  1541. await waitFor(() => expect(patchBody).not.toBeNull());
  1542. expect(patchBody!.item_ids).toEqual([1]);
  1543. expect(patchBody!.gcode_injection).toBe(true);
  1544. });
  1545. it('leaves gcode_injection out of the PATCH while it stays on "no change"', async () => {
  1546. let patchBody: Record<string, unknown> | null = null;
  1547. server.use(
  1548. http.get('/api/v1/settings/', () => HttpResponse.json(settingsWithSnippets)),
  1549. http.patch('/api/v1/queue/bulk', async ({ request }) => {
  1550. patchBody = (await request.json()) as Record<string, unknown>;
  1551. return HttpResponse.json({ updated_count: 1, skipped_count: 0, message: 'Updated 1 items' });
  1552. }),
  1553. );
  1554. const dialog = await openBulkEdit();
  1555. // Change something else entirely; injection must not ride along as false.
  1556. const stagedRow = screen.getByText('Staged (manual start)').closest('div')!;
  1557. await userEvent.click(within(stagedRow).getByText('On'));
  1558. await userEvent.click(within(dialog).getByText('Apply Changes'));
  1559. await waitFor(() => expect(patchBody).not.toBeNull());
  1560. expect(patchBody!.manual_start).toBe(true);
  1561. expect('gcode_injection' in patchBody!).toBe(false);
  1562. });
  1563. });
  1564. describe('bulk edit ask-for-outcome (#1898)', () => {
  1565. const openBulkEdit = async () => {
  1566. render(<QueuePage />);
  1567. await waitFor(() => expect(screen.getByText('Test Print 1')).toBeInTheDocument());
  1568. await userEvent.click(screen.getByText('Select All'));
  1569. await userEvent.click(await screen.findByTitle('Edit Selected'));
  1570. return screen.getByText('Edit 1 Item').closest('div')!.parentElement!;
  1571. };
  1572. const captureBulkPatch = () => {
  1573. const captured: { body: Record<string, unknown> | null } = { body: null };
  1574. server.use(
  1575. http.patch('/api/v1/queue/bulk', async ({ request }) => {
  1576. captured.body = (await request.json()) as Record<string, unknown>;
  1577. return HttpResponse.json({ updated_count: 1, skipped_count: 0, message: 'Updated 1 items' });
  1578. }),
  1579. );
  1580. return captured;
  1581. };
  1582. it('sends confirm_outcome with the bulk PATCH once toggled', async () => {
  1583. const captured = captureBulkPatch();
  1584. const dialog = await openBulkEdit();
  1585. const row = within(dialog).getByText('Ask for outcome').closest('div')!;
  1586. await userEvent.click(within(row).getByText('On'));
  1587. await userEvent.click(within(dialog).getByText('Apply Changes'));
  1588. await waitFor(() => expect(captured.body).not.toBeNull());
  1589. expect(captured.body!.item_ids).toEqual([1]);
  1590. expect(captured.body!.confirm_outcome).toBe(true);
  1591. });
  1592. it('leaves confirm_outcome out of the PATCH while it stays on "no change"', async () => {
  1593. const captured = captureBulkPatch();
  1594. const dialog = await openBulkEdit();
  1595. const timelapseRow = within(dialog).getByText('Timelapse').closest('div')!;
  1596. await userEvent.click(within(timelapseRow).getByText('On'));
  1597. await userEvent.click(within(dialog).getByText('Apply Changes'));
  1598. await waitFor(() => expect(captured.body).not.toBeNull());
  1599. expect(captured.body!.timelapse).toBe(true);
  1600. expect('confirm_outcome' in captured.body!).toBe(false);
  1601. });
  1602. });
  1603. });