PrintersPageCardVerdict.test.tsx 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  1. /**
  2. * Good / Reject on the printer card (#1898).
  3. *
  4. * The card offers the verdict where the operator is standing: the plate-clear
  5. * gate is up, the print that just finished asked for a verdict, and answering
  6. * it should not cost a trip to the archive.
  7. *
  8. * What is pinned here is which button does what. Good is answered on the spot;
  9. * Reject wants a reason and possibly a reprint, so it opens the dialog and
  10. * records nothing by itself. The card's mutation therefore only ever sends
  11. * "good", and its type says so — a verdict it cannot send must not be able to
  12. * reach a success message that calls it good.
  13. */
  14. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  15. import { screen, waitFor } from '@testing-library/react';
  16. import userEvent from '@testing-library/user-event';
  17. import { http, HttpResponse } from 'msw';
  18. import { render } from '../utils';
  19. import { server } from '../mocks/server';
  20. import { PrintersPage } from '../../pages/PrintersPage';
  21. import { setAuthToken } from '../../api/client';
  22. const printer = {
  23. id: 1,
  24. name: 'H2S-1',
  25. ip_address: '192.168.20.10',
  26. model: 'H2S',
  27. serial_number: '00M09A350100001',
  28. access_code: '12345678',
  29. enabled: true,
  30. is_active: true,
  31. nozzle_diameter: 0.4,
  32. nozzle_type: 'hardened_steel',
  33. location: 'Workshop',
  34. auto_archive: true,
  35. created_at: '2024-01-01T00:00:00Z',
  36. updated_at: '2024-01-01T00:00:00Z',
  37. };
  38. const status = {
  39. connected: true,
  40. state: 'FINISH',
  41. awaiting_plate_clear: true,
  42. progress: 100,
  43. layer_num: 0,
  44. total_layers: 0,
  45. temperatures: { nozzle: 25, bed: 25, chamber: 25 },
  46. remaining_time: 0,
  47. filename: 'bracket.gcode.3mf',
  48. wifi_signal: -50,
  49. vt_tray: [],
  50. };
  51. const uiPreferences = {
  52. ams_humidity_good: 40,
  53. ams_humidity_fair: 60,
  54. ams_temp_good: 30,
  55. ams_temp_fair: 35,
  56. require_plate_clear: true,
  57. };
  58. const finishedPrint = {
  59. id: 42,
  60. printer_id: 1,
  61. filename: 'bracket.gcode.3mf',
  62. print_name: 'Bracket',
  63. status: 'completed',
  64. confirm_requested: true,
  65. user_verdict: null,
  66. user_verdict_source: null,
  67. photos: null,
  68. created_by_id: 7,
  69. };
  70. describe('PrintersPage — verdict on the card', () => {
  71. let patched: Array<Record<string, unknown>>;
  72. beforeEach(() => {
  73. patched = [];
  74. server.use(
  75. http.get('/api/v1/printers/', () => HttpResponse.json([printer])),
  76. http.get('/api/v1/printers/:id/status', () => HttpResponse.json(status)),
  77. http.get('/api/v1/settings/ui-preferences', () => HttpResponse.json(uiPreferences)),
  78. http.get('/api/v1/settings/', () => HttpResponse.json({ ...uiPreferences, auto_archive: true })),
  79. http.get('/api/v1/archives/last-per-printer', () => HttpResponse.json([finishedPrint])),
  80. http.patch('/api/v1/archives/:id', async ({ request }) => {
  81. const body = (await request.json()) as Record<string, unknown>;
  82. patched.push(body);
  83. return HttpResponse.json({ ...finishedPrint, ...body });
  84. }),
  85. );
  86. });
  87. it('records good from the card and says so', async () => {
  88. const user = userEvent.setup();
  89. render(<PrintersPage />);
  90. await user.click(await screen.findByTitle('Good'));
  91. await waitFor(() => expect(patched).toHaveLength(1));
  92. expect(patched[0].user_verdict).toBe('good');
  93. expect(patched[0].user_verdict_source).toBe('printer_card');
  94. expect(await screen.findByText('Marked as good part')).toBeInTheDocument();
  95. });
  96. it('answers its own print when the archive printed on more printers', async () => {
  97. // Plates of one archive are runs of their own; the card's row names its run.
  98. const user = userEvent.setup();
  99. server.use(
  100. http.get('/api/v1/archives/last-per-printer', () => HttpResponse.json([{ ...finishedPrint, run_id: 7 }])),
  101. );
  102. render(<PrintersPage />);
  103. await user.click(await screen.findByTitle('Good'));
  104. await waitFor(() => expect(patched).toHaveLength(1));
  105. expect(patched[0].run_id).toBe(7);
  106. });
  107. // The card reads its row out of one page-wide answer; another printer's
  108. // archive must not stand in for its own.
  109. it('ignores the last print of another printer', async () => {
  110. let served = false;
  111. server.use(
  112. http.get('/api/v1/archives/last-per-printer', () => {
  113. served = true;
  114. return HttpResponse.json([{ ...finishedPrint, id: 43, printer_id: 2 }]);
  115. }),
  116. );
  117. render(<PrintersPage />);
  118. await waitFor(() => expect(served).toBe(true));
  119. expect(await screen.findByRole('button', { name: 'Mark plate as cleared' })).toBeInTheDocument();
  120. expect(screen.queryByTitle('Good')).toBeNull();
  121. expect(screen.queryByText(/Last: Bracket/)).toBeNull();
  122. });
  123. it('sends the reject to the dialog and records nothing on its own', async () => {
  124. const user = userEvent.setup();
  125. render(<PrintersPage />);
  126. await user.click(await screen.findByTitle('Reject'));
  127. expect(await screen.findByTestId('confirm-outcome-dialog')).toBeInTheDocument();
  128. expect(patched).toHaveLength(0);
  129. expect(screen.queryByText('Marked as good part')).toBeNull();
  130. });
  131. // The PATCH checks archives:update against the archive's owner, so the pair
  132. // is only offered to whoever it can succeed for. Mark cleared, which has its
  133. // own permission, stays either way.
  134. describe('with auth enabled', () => {
  135. // Before /auth/me has answered, every permission check says yes, so an
  136. // absence is only worth asserting once both it and the archive are in.
  137. let meServed: boolean;
  138. let archiveServed: boolean;
  139. const settled = async () => {
  140. await waitFor(() => expect(meServed && archiveServed).toBe(true));
  141. expect(await screen.findByRole('button', { name: 'Mark plate as cleared' })).toBeInTheDocument();
  142. };
  143. const signInWith = (permissions: string[], archive = finishedPrint) => {
  144. meServed = false;
  145. archiveServed = false;
  146. setAuthToken('card-verdict-token', 'session');
  147. server.use(
  148. http.get('*/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: true, requires_setup: false })),
  149. http.get('/api/v1/auth/me', () => {
  150. meServed = true;
  151. return HttpResponse.json({
  152. id: 7,
  153. username: 'operator',
  154. is_active: true,
  155. is_admin: false,
  156. groups: [],
  157. permissions,
  158. created_at: '2026-08-18T00:00:00Z',
  159. });
  160. }),
  161. http.get('/api/v1/archives/last-per-printer', () => {
  162. archiveServed = true;
  163. return HttpResponse.json([archive]);
  164. }),
  165. );
  166. };
  167. afterEach(() => {
  168. setAuthToken(null);
  169. });
  170. it('offers no verdict to a user who may not update archives', async () => {
  171. signInWith(['printers:read', 'printers:clear_plate', 'archives:read_all']);
  172. render(<PrintersPage />);
  173. await settled();
  174. await waitFor(() => expect(screen.queryByTitle('Good')).toBeNull());
  175. expect(screen.queryByTitle('Reject')).toBeNull();
  176. });
  177. it('offers no verdict on somebody else\'s print to a user who may update only their own', async () => {
  178. signInWith(
  179. ['printers:read', 'printers:clear_plate', 'archives:read_all', 'archives:update_own'],
  180. { ...finishedPrint, created_by_id: 8 },
  181. );
  182. render(<PrintersPage />);
  183. await settled();
  184. await waitFor(() => expect(screen.queryByTitle('Good')).toBeNull());
  185. });
  186. it('offers it to the owner', async () => {
  187. signInWith(['printers:read', 'printers:clear_plate', 'archives:read_all', 'archives:update_own']);
  188. render(<PrintersPage />);
  189. expect(await screen.findByTitle('Good')).toBeInTheDocument();
  190. expect(screen.getByTitle('Reject')).toBeInTheDocument();
  191. });
  192. });
  193. });