PrintersPageCardVerdict.test.tsx 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  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. // The card reads its row out of one page-wide answer; another printer's
  97. // archive must not stand in for its own.
  98. it('ignores the last print of another printer', async () => {
  99. let served = false;
  100. server.use(
  101. http.get('/api/v1/archives/last-per-printer', () => {
  102. served = true;
  103. return HttpResponse.json([{ ...finishedPrint, id: 43, printer_id: 2 }]);
  104. }),
  105. );
  106. render(<PrintersPage />);
  107. await waitFor(() => expect(served).toBe(true));
  108. expect(await screen.findByRole('button', { name: 'Mark plate as cleared' })).toBeInTheDocument();
  109. expect(screen.queryByTitle('Good')).toBeNull();
  110. expect(screen.queryByText(/Last: Bracket/)).toBeNull();
  111. });
  112. it('sends the reject to the dialog and records nothing on its own', async () => {
  113. const user = userEvent.setup();
  114. render(<PrintersPage />);
  115. await user.click(await screen.findByTitle('Reject'));
  116. expect(await screen.findByTestId('confirm-outcome-dialog')).toBeInTheDocument();
  117. expect(patched).toHaveLength(0);
  118. expect(screen.queryByText('Marked as good part')).toBeNull();
  119. });
  120. // The PATCH checks archives:update against the archive's owner, so the pair
  121. // is only offered to whoever it can succeed for. Mark cleared, which has its
  122. // own permission, stays either way.
  123. describe('with auth enabled', () => {
  124. // Before /auth/me has answered, every permission check says yes, so an
  125. // absence is only worth asserting once both it and the archive are in.
  126. let meServed: boolean;
  127. let archiveServed: boolean;
  128. const settled = async () => {
  129. await waitFor(() => expect(meServed && archiveServed).toBe(true));
  130. expect(await screen.findByRole('button', { name: 'Mark plate as cleared' })).toBeInTheDocument();
  131. };
  132. const signInWith = (permissions: string[], archive = finishedPrint) => {
  133. meServed = false;
  134. archiveServed = false;
  135. setAuthToken('card-verdict-token', 'session');
  136. server.use(
  137. http.get('*/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: true, requires_setup: false })),
  138. http.get('/api/v1/auth/me', () => {
  139. meServed = true;
  140. return HttpResponse.json({
  141. id: 7,
  142. username: 'operator',
  143. is_active: true,
  144. is_admin: false,
  145. groups: [],
  146. permissions,
  147. created_at: '2026-08-18T00:00:00Z',
  148. });
  149. }),
  150. http.get('/api/v1/archives/last-per-printer', () => {
  151. archiveServed = true;
  152. return HttpResponse.json([archive]);
  153. }),
  154. );
  155. };
  156. afterEach(() => {
  157. setAuthToken(null);
  158. });
  159. it('offers no verdict to a user who may not update archives', async () => {
  160. signInWith(['printers:read', 'printers:clear_plate', 'archives:read_all']);
  161. render(<PrintersPage />);
  162. await settled();
  163. await waitFor(() => expect(screen.queryByTitle('Good')).toBeNull());
  164. expect(screen.queryByTitle('Reject')).toBeNull();
  165. });
  166. it('offers no verdict on somebody else\'s print to a user who may update only their own', async () => {
  167. signInWith(
  168. ['printers:read', 'printers:clear_plate', 'archives:read_all', 'archives:update_own'],
  169. { ...finishedPrint, created_by_id: 8 },
  170. );
  171. render(<PrintersPage />);
  172. await settled();
  173. await waitFor(() => expect(screen.queryByTitle('Good')).toBeNull());
  174. });
  175. it('offers it to the owner', async () => {
  176. signInWith(['printers:read', 'printers:clear_plate', 'archives:read_all', 'archives:update_own']);
  177. render(<PrintersPage />);
  178. expect(await screen.findByTitle('Good')).toBeInTheDocument();
  179. expect(screen.getByTitle('Reject')).toBeInTheDocument();
  180. });
  181. });
  182. });