PrintersPageDropOnBusy.test.tsx 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. /**
  2. * Dropping a file onto a busy or offline printer queues it (#2849).
  3. *
  4. * The card used to refuse the drop unless the printer was connected and
  5. * neither RUNNING nor PAUSE, showing a red "Printer busy" and silently
  6. * discarding the file. That gate was never needed: a dropped file always
  7. * becomes a queue item — dropping onto an idle printer just dispatches it
  8. * straight away — so a busy printer only means the item waits its turn. The
  9. * workaround was to upload to Archives and queue it from there by hand, which
  10. * is the same thing with more steps.
  11. */
  12. import { describe, it, expect, beforeEach, vi } from 'vitest';
  13. import { screen, waitFor, fireEvent } from '@testing-library/react';
  14. import { render } from '../utils';
  15. import { PrintersPage } from '../../pages/PrintersPage';
  16. import { http, HttpResponse } from 'msw';
  17. import { server } from '../mocks/server';
  18. const mockPrinter = {
  19. id: 1,
  20. name: 'X1C',
  21. ip_address: '192.168.1.100',
  22. serial_number: '01P00A000000001',
  23. access_code: '12345678',
  24. model: 'X1C',
  25. enabled: true,
  26. nozzle_diameter: 0.4,
  27. nozzle_type: 'stainless_steel',
  28. location: 'Workshop',
  29. auto_archive: true,
  30. created_at: '2024-01-01T00:00:00Z',
  31. updated_at: '2024-01-01T00:00:00Z',
  32. };
  33. function makeStatus(over: Record<string, unknown>) {
  34. return {
  35. connected: true,
  36. state: 'IDLE',
  37. progress: 0,
  38. layer_num: 0,
  39. total_layers: 0,
  40. temperatures: { nozzle: 25, bed: 25, chamber: 25 },
  41. remaining_time: 0,
  42. filename: null,
  43. wifi_signal: -29,
  44. speed_level: 2,
  45. vt_tray: [],
  46. ams: [],
  47. ...over,
  48. };
  49. }
  50. /** Records every library upload the page attempts. */
  51. const uploads: string[] = [];
  52. function renderWith(statusOver: Record<string, unknown>) {
  53. server.use(
  54. http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter])),
  55. http.get('/api/v1/printers/:id/status', () => HttpResponse.json(makeStatus(statusOver))),
  56. http.get('/api/v1/queue/', () => HttpResponse.json([])),
  57. http.post('/api/v1/library/files', () => {
  58. // Parsing multipart bodies in MSW depends on the Node.js FormData
  59. // implementation and is unrelated to this regression. Recording the
  60. // matched request proves the page attempted the upload without making
  61. // the test sensitive to that runtime detail.
  62. uploads.push('request');
  63. return HttpResponse.json({ id: 7, filename: 'part.gcode', metadata: {} });
  64. }),
  65. );
  66. return render(<PrintersPage />);
  67. }
  68. async function card(): Promise<HTMLElement> {
  69. await waitFor(() => expect(document.getElementById('printer-card-1')).not.toBeNull());
  70. return document.getElementById('printer-card-1') as HTMLElement;
  71. }
  72. const gcode = () => new File(['G28\n'], 'part.gcode', { type: 'text/plain' });
  73. describe('PrintersPage — drop onto a busy printer (#2849)', () => {
  74. beforeEach(() => {
  75. uploads.length = 0;
  76. vi.clearAllMocks();
  77. });
  78. it('offers to queue instead of refusing while a print is running', async () => {
  79. renderWith({ state: 'RUNNING' });
  80. fireEvent.dragEnter(await card(), { dataTransfer: { files: [gcode()] } });
  81. expect(await screen.findByText('Drop to queue')).toBeInTheDocument();
  82. // The old refusal copy must be gone, not merely restyled.
  83. expect(screen.queryByText('Printer busy')).toBeNull();
  84. expect(screen.queryByText('Drop to print')).toBeNull();
  85. });
  86. it('offers to queue while a print is paused', async () => {
  87. renderWith({ state: 'PAUSE' });
  88. fireEvent.dragEnter(await card(), { dataTransfer: { files: [gcode()] } });
  89. expect(await screen.findByText('Drop to queue')).toBeInTheDocument();
  90. });
  91. it('offers to queue for an offline printer so it can be scheduled', async () => {
  92. // The queue dispatches when the printer comes back, so refusing the drop
  93. // helped nobody.
  94. renderWith({ connected: false, state: 'IDLE' });
  95. fireEvent.dragEnter(await card(), { dataTransfer: { files: [gcode()] } });
  96. expect(await screen.findByText('Drop to queue')).toBeInTheDocument();
  97. });
  98. it('still says "print" when the printer would start it immediately', async () => {
  99. renderWith({ state: 'IDLE' });
  100. fireEvent.dragEnter(await card(), { dataTransfer: { files: [gcode()] } });
  101. expect(await screen.findByText('Drop to print')).toBeInTheDocument();
  102. expect(screen.queryByText('Drop to queue')).toBeNull();
  103. });
  104. it('says "queue" when an idle printer is drying, which also defers the start', async () => {
  105. renderWith({ state: 'IDLE', ams: [{ id: 0, dry_time: 240, tray: [] }] });
  106. fireEvent.dragEnter(await card(), { dataTransfer: { files: [gcode()] } });
  107. expect(await screen.findByText('Drop to queue')).toBeInTheDocument();
  108. });
  109. it('actually uploads the dropped file while the printer is running', async () => {
  110. // The regression itself: handleCardDrop returned early, so nothing was
  111. // uploaded and the file vanished with no feedback at all.
  112. renderWith({ state: 'RUNNING' });
  113. const el = await card();
  114. fireEvent.dragEnter(el, { dataTransfer: { files: [gcode()] } });
  115. fireEvent.drop(el, { dataTransfer: { files: [gcode()] } });
  116. // Asserted by count, not name: the test environment's FormData does not
  117. // preserve the filename through the multipart round trip. That an upload
  118. // happened at all is the whole regression.
  119. await waitFor(() => expect(uploads).toHaveLength(1));
  120. });
  121. it('keeps the Print button available while a print is running', async () => {
  122. // The button is the other half of "Print from Printer Card" and was hidden
  123. // by the same condition. Leaving it hidden while the drop zone accepted the
  124. // same file would have had the two routes disagree on the same card.
  125. renderWith({ state: 'RUNNING' });
  126. await card();
  127. expect(await screen.findByTitle('Print')).toBeInTheDocument();
  128. });
  129. it('keeps the Print button available while the printer is offline', async () => {
  130. renderWith({ connected: false, state: 'IDLE' });
  131. await card();
  132. expect(await screen.findByTitle('Print')).toBeInTheDocument();
  133. });
  134. it('opens the Print Modal after a drop on a printer that is mid-print', async () => {
  135. // Reaching the modal from a busy printer is what this change newly allows,
  136. // so it is worth proving the modal actually renders rather than the drop
  137. // ending in a dead end.
  138. renderWith({ state: 'RUNNING' });
  139. const el = await card();
  140. fireEvent.drop(el, { dataTransfer: { files: [gcode()] } });
  141. expect(await screen.findByText('Print Job')).toBeInTheDocument();
  142. expect(await screen.findByText('part.gcode')).toBeInTheDocument();
  143. });
  144. it('opens the Print Modal after a drop on an offline printer', async () => {
  145. // Offline is the further reach: the queue holds the job until the printer
  146. // reconnects, so the modal has to cope with having no live AMS data.
  147. renderWith({ connected: false, state: 'IDLE' });
  148. const el = await card();
  149. fireEvent.drop(el, { dataTransfer: { files: [gcode()] } });
  150. expect(await screen.findByText('Print Job')).toBeInTheDocument();
  151. expect(await screen.findByText('part.gcode')).toBeInTheDocument();
  152. // And the job is actually submittable — an offline printer must not leave
  153. // the user in a modal whose Print button never enables. The backend puts no
  154. // connectivity condition on queue creation either.
  155. await waitFor(() => {
  156. const submit = document.querySelector('button[type="submit"]') as HTMLButtonElement | null;
  157. expect(submit).not.toBeNull();
  158. expect(submit!.disabled).toBe(false);
  159. });
  160. });
  161. it('rejects a file that is not printable, busy or not', async () => {
  162. renderWith({ state: 'RUNNING' });
  163. const el = await card();
  164. const stl = new File(['solid'], 'model.stl', { type: 'model/stl' });
  165. fireEvent.drop(el, { dataTransfer: { files: [stl] } });
  166. await waitFor(() =>
  167. expect(screen.getByText('Only .gcode and .gcode.3mf files can be printed')).toBeInTheDocument()
  168. );
  169. expect(uploads).toEqual([]);
  170. });
  171. });