BatchOrdersView.test.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. /**
  2. * Tests for the Batch Orders tab (#342).
  3. *
  4. * The point of this view is the gap the Queue and History tabs cannot show:
  5. * what an order asked for versus what has actually been produced, including
  6. * the runs that failed and are therefore still owed.
  7. */
  8. import { describe, it, expect, vi, beforeEach } from 'vitest';
  9. import { screen, waitFor } from '@testing-library/react';
  10. import userEvent from '@testing-library/user-event';
  11. import { http, HttpResponse } from 'msw';
  12. import { render } from '../utils';
  13. import { server } from '../mocks/server';
  14. import { BatchOrdersView } from '../../components/BatchOrdersView';
  15. import type { PrintBatch, PrintBatchPlateProgress } from '../../api/client';
  16. const plate = (over: Partial<PrintBatchPlateProgress> = {}): PrintBatchPlateProgress => {
  17. const merged = {
  18. plate_id: 1,
  19. plate_name: null,
  20. quantity_target: 1,
  21. dispatched: 1,
  22. remaining: 0,
  23. pending_count: 0,
  24. printing_count: 0,
  25. completed_count: 1,
  26. failed_count: 0,
  27. cancelled_count: 0,
  28. skipped_count: 0,
  29. actual_cost: null,
  30. estimated_remaining_cost: null,
  31. filament_used_grams: null,
  32. print_time_seconds: 0,
  33. can_dispatch: false,
  34. ...over,
  35. } satisfies PrintBatchPlateProgress;
  36. // A plate keeps something to clone unless a test says otherwise, so by
  37. // default anything still owed is queueable (#2960).
  38. return { ...merged, can_dispatch: over.can_dispatch ?? merged.remaining > 0 };
  39. };
  40. const batch = (over: Partial<PrintBatch> = {}): PrintBatch => {
  41. const merged = {
  42. id: 1,
  43. name: 'Widget run',
  44. archive_id: 7,
  45. library_file_id: null,
  46. quantity: 6,
  47. status: 'active',
  48. created_at: '2026-08-01T10:00:00Z',
  49. completed_at: null,
  50. created_by_id: null,
  51. created_by_username: null,
  52. project_id: null,
  53. due_date: null,
  54. notes: null,
  55. external_source: null,
  56. external_ref: null,
  57. pending_count: 0,
  58. printing_count: 0,
  59. completed_count: 0,
  60. failed_count: 0,
  61. cancelled_count: 0,
  62. skipped_count: 0,
  63. has_targets: true,
  64. target_count: 6,
  65. remaining_count: 6,
  66. actual_cost: null,
  67. estimated_remaining_cost: null,
  68. filament_used_grams: null,
  69. print_time_seconds: 0,
  70. plates: [],
  71. dispatchable_count: 0,
  72. ...over,
  73. } satisfies PrintBatch;
  74. return { ...merged, dispatchable_count: over.dispatchable_count ?? merged.remaining_count };
  75. };
  76. const allow = () => true;
  77. const deny = () => false;
  78. const passthroughT = (key: string, options?: Record<string, unknown>) => {
  79. void options;
  80. return key;
  81. };
  82. describe('BatchOrdersView (#342)', () => {
  83. beforeEach(() => {
  84. vi.clearAllMocks();
  85. server.use(
  86. http.get('/api/v1/settings/', () => HttpResponse.json({ currency: 'EUR' })),
  87. http.get('/api/v1/queue/batches', () => HttpResponse.json([])),
  88. );
  89. });
  90. it('shows the empty state when nothing matches the filter', async () => {
  91. render(<BatchOrdersView hasPermission={allow} t={passthroughT} />);
  92. await waitFor(() =>
  93. expect(screen.getByText('queue.batchOrders.emptyTitle')).toBeInTheDocument(),
  94. );
  95. });
  96. it('surfaces a finished order that has no queue rows left', async () => {
  97. // The case the Queue and History tabs each miss: every run completed, so
  98. // nothing is pending, yet the order is the thing the user wants to see.
  99. server.use(
  100. http.get('/api/v1/queue/batches', ({ request }) => {
  101. const status = new URL(request.url).searchParams.get('status');
  102. if (status !== 'completed') return HttpResponse.json([]);
  103. return HttpResponse.json([
  104. batch({ status: 'completed', completed_count: 6, remaining_count: 0, completed_at: '2026-08-02T10:00:00Z' }),
  105. ]);
  106. }),
  107. );
  108. const user = userEvent.setup();
  109. render(<BatchOrdersView hasPermission={allow} t={passthroughT} />);
  110. await waitFor(() => expect(screen.getByText('queue.batchOrders.emptyTitle')).toBeInTheDocument());
  111. await user.click(screen.getByRole('button', { name: 'queue.batchOrders.filter.completed' }));
  112. await waitFor(() => expect(screen.getByText('Widget run')).toBeInTheDocument());
  113. expect(screen.getByText('queue.batchOrders.status.completed')).toBeInTheDocument();
  114. });
  115. it('offers to queue what a failed run still owes', async () => {
  116. let dispatched: { plate_id?: number | null; only_plate?: boolean } | null = null;
  117. server.use(
  118. http.get('/api/v1/queue/batches', () =>
  119. HttpResponse.json([
  120. batch({
  121. completed_count: 1,
  122. failed_count: 1,
  123. target_count: 2,
  124. remaining_count: 1,
  125. plates: [plate({ quantity_target: 2, dispatched: 1, remaining: 1, completed_count: 1, failed_count: 1 })],
  126. }),
  127. ]),
  128. ),
  129. http.post('/api/v1/queue/batches/:id/dispatch', async ({ request }) => {
  130. dispatched = (await request.json()) as { plate_id?: number | null };
  131. return HttpResponse.json(batch({ remaining_count: 0, pending_count: 1 }));
  132. }),
  133. );
  134. const user = userEvent.setup();
  135. render(<BatchOrdersView hasPermission={allow} t={passthroughT} />);
  136. await waitFor(() => expect(screen.getByText('Widget run')).toBeInTheDocument());
  137. // Reported at both levels: the order summary and the plate that burned.
  138. expect(screen.getAllByText('queue.batchOrders.failed')).toHaveLength(2);
  139. await user.click(screen.getByRole('button', { name: /queue.batchOrders.dispatchRemaining/ }));
  140. await waitFor(() => expect(dispatched).not.toBeNull());
  141. // Order-level dispatch covers every plate, so no plate filter is sent.
  142. expect(dispatched).toEqual({});
  143. });
  144. it('dispatches a single plate from its own row', async () => {
  145. let dispatched: { plate_id?: number | null; only_plate?: boolean } | null = null;
  146. server.use(
  147. http.get('/api/v1/queue/batches', () =>
  148. HttpResponse.json([
  149. batch({
  150. target_count: 4,
  151. remaining_count: 3,
  152. plates: [
  153. plate({ plate_id: 1, quantity_target: 1, remaining: 0 }),
  154. plate({ plate_id: 2, quantity_target: 3, dispatched: 0, completed_count: 0, remaining: 3 }),
  155. ],
  156. }),
  157. ]),
  158. ),
  159. http.post('/api/v1/queue/batches/:id/dispatch', async ({ request }) => {
  160. dispatched = (await request.json()) as { plate_id?: number | null };
  161. return HttpResponse.json(batch());
  162. }),
  163. );
  164. const user = userEvent.setup();
  165. render(<BatchOrdersView hasPermission={allow} t={passthroughT} />);
  166. await waitFor(() => expect(screen.getByText('Widget run')).toBeInTheDocument());
  167. // Only the plate with work outstanding offers the action.
  168. const plateButtons = screen.getAllByRole('button', { name: 'queue.batchOrders.dispatchPlate' });
  169. expect(plateButtons).toHaveLength(1);
  170. await user.click(plateButtons[0]);
  171. await waitFor(() => expect(dispatched).not.toBeNull());
  172. expect(dispatched).toEqual({ plate_id: 2, only_plate: true });
  173. });
  174. it('marks a legacy batch as grouping-only and offers no dispatch', async () => {
  175. server.use(
  176. http.get('/api/v1/queue/batches', () =>
  177. HttpResponse.json([
  178. batch({ has_targets: false, target_count: 3, remaining_count: 0, pending_count: 3, plates: [] }),
  179. ]),
  180. ),
  181. );
  182. render(<BatchOrdersView hasPermission={allow} t={passthroughT} />);
  183. await waitFor(() => expect(screen.getByText('queue.batchOrders.noTargets')).toBeInTheDocument());
  184. expect(
  185. screen.queryByRole('button', { name: /queue.batchOrders.dispatchRemaining/ }),
  186. ).not.toBeInTheDocument();
  187. });
  188. it('hides the dispatch and cancel actions without permission', async () => {
  189. server.use(
  190. http.get('/api/v1/queue/batches', () =>
  191. HttpResponse.json([
  192. batch({ pending_count: 2, remaining_count: 2, plates: [plate({ quantity_target: 3, remaining: 2 })] }),
  193. ]),
  194. ),
  195. );
  196. render(<BatchOrdersView hasPermission={deny} t={passthroughT} />);
  197. await waitFor(() => expect(screen.getByText('Widget run')).toBeInTheDocument());
  198. expect(
  199. screen.queryByRole('button', { name: /queue.batchOrders.dispatchRemaining/ }),
  200. ).not.toBeInTheDocument();
  201. expect(screen.queryByRole('button', { name: /queue.batchOrders.dispatchPlate/ })).not.toBeInTheDocument();
  202. expect(screen.queryByRole('button', { name: 'queue.cancelBatch' })).not.toBeInTheDocument();
  203. });
  204. it('shows cost only once a run has produced one', async () => {
  205. server.use(
  206. http.get('/api/v1/queue/batches', () =>
  207. HttpResponse.json([
  208. batch({ id: 1, name: 'Priced', actual_cost: 6, estimated_remaining_cost: 3, completed_count: 2 }),
  209. batch({ id: 2, name: 'Unpriced', actual_cost: null, estimated_remaining_cost: null }),
  210. ]),
  211. ),
  212. );
  213. render(<BatchOrdersView hasPermission={allow} t={passthroughT} />);
  214. await waitFor(() => expect(screen.getByText('Priced')).toBeInTheDocument());
  215. // One cost line, belonging to the priced order — never a fabricated 0.00.
  216. expect(screen.getAllByText('queue.batchOrders.costSoFar')).toHaveLength(1);
  217. });
  218. it('explains a plate that cannot be queued instead of offering a button that fails', async () => {
  219. // #2960: the plate's last queue item was deleted, so there is nothing left
  220. // to clone its configuration from. The card used to offer "Queue remaining"
  221. // anyway and answer with an error toast.
  222. server.use(
  223. http.get('/api/v1/queue/batches', () =>
  224. HttpResponse.json([
  225. batch({
  226. target_count: 3,
  227. remaining_count: 3,
  228. dispatchable_count: 0,
  229. plates: [
  230. plate({ plate_id: 1, quantity_target: 3, dispatched: 0, completed_count: 0, remaining: 3, can_dispatch: false }),
  231. ],
  232. }),
  233. ]),
  234. ),
  235. );
  236. render(<BatchOrdersView hasPermission={allow} t={passthroughT} />);
  237. await waitFor(() => expect(screen.getByText('Widget run')).toBeInTheDocument());
  238. expect(screen.getByText('queue.batchOrders.strandedNotice')).toBeInTheDocument();
  239. expect(screen.getByText('queue.batchOrders.strandedPlate')).toBeInTheDocument();
  240. expect(
  241. screen.queryByRole('button', { name: /queue.batchOrders.dispatchRemaining/ }),
  242. ).not.toBeInTheDocument();
  243. expect(screen.queryByRole('button', { name: 'queue.batchOrders.dispatchPlate' })).not.toBeInTheDocument();
  244. });
  245. it('still offers to close an order with nothing pending to cancel', async () => {
  246. // #2960: cancel is the only way out of an order whose runs were all
  247. // deleted, and that order has no pending items by definition.
  248. server.use(
  249. http.get('/api/v1/queue/batches', () =>
  250. HttpResponse.json([
  251. batch({
  252. pending_count: 0,
  253. target_count: 3,
  254. remaining_count: 3,
  255. dispatchable_count: 0,
  256. plates: [plate({ quantity_target: 3, dispatched: 0, completed_count: 0, remaining: 3, can_dispatch: false })],
  257. }),
  258. ]),
  259. ),
  260. );
  261. render(<BatchOrdersView hasPermission={allow} t={passthroughT} />);
  262. await waitFor(() => expect(screen.getByText('Widget run')).toBeInTheDocument());
  263. expect(screen.getByRole('button', { name: 'queue.cancelBatch' })).toBeInTheDocument();
  264. });
  265. });
  266. describe('BatchOrdersView linked to one batch (/queue?batch=<id>)', () => {
  267. it('shows every status and highlights the linked batch', async () => {
  268. let statusAsked: string | null = 'unset';
  269. server.use(
  270. http.get('/api/v1/queue/batches', ({ request }) => {
  271. statusAsked = new URL(request.url).searchParams.get('status');
  272. return HttpResponse.json([
  273. batch({ id: 7, name: '#1001 PS-BLK', status: 'completed' }),
  274. batch({ id: 8, name: '#1002 CS-4' }),
  275. ]);
  276. }),
  277. );
  278. const scroll = vi.fn();
  279. Element.prototype.scrollIntoView = scroll;
  280. render(<BatchOrdersView hasPermission={allow} t={passthroughT} focusBatchId={7} />);
  281. await screen.findByText('#1001 PS-BLK');
  282. expect(statusAsked).toBeNull(); // "all": a finished batch still shows
  283. expect(document.getElementById('batch-7')).toHaveClass('ring-2');
  284. expect(document.getElementById('batch-8')).not.toHaveClass('ring-2');
  285. await waitFor(() => expect(scroll).toHaveBeenCalled());
  286. });
  287. });