BatchOrdersView.test.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  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. pending_count: 0,
  56. printing_count: 0,
  57. completed_count: 0,
  58. failed_count: 0,
  59. cancelled_count: 0,
  60. skipped_count: 0,
  61. has_targets: true,
  62. target_count: 6,
  63. remaining_count: 6,
  64. actual_cost: null,
  65. estimated_remaining_cost: null,
  66. filament_used_grams: null,
  67. print_time_seconds: 0,
  68. plates: [],
  69. dispatchable_count: 0,
  70. ...over,
  71. } satisfies PrintBatch;
  72. return { ...merged, dispatchable_count: over.dispatchable_count ?? merged.remaining_count };
  73. };
  74. const allow = () => true;
  75. const deny = () => false;
  76. const passthroughT = (key: string, options?: Record<string, unknown>) => {
  77. void options;
  78. return key;
  79. };
  80. describe('BatchOrdersView (#342)', () => {
  81. beforeEach(() => {
  82. vi.clearAllMocks();
  83. server.use(
  84. http.get('/api/v1/settings/', () => HttpResponse.json({ currency: 'EUR' })),
  85. http.get('/api/v1/queue/batches', () => HttpResponse.json([])),
  86. );
  87. });
  88. it('shows the empty state when nothing matches the filter', async () => {
  89. render(<BatchOrdersView hasPermission={allow} t={passthroughT} />);
  90. await waitFor(() =>
  91. expect(screen.getByText('queue.batchOrders.emptyTitle')).toBeInTheDocument(),
  92. );
  93. });
  94. it('surfaces a finished order that has no queue rows left', async () => {
  95. // The case the Queue and History tabs each miss: every run completed, so
  96. // nothing is pending, yet the order is the thing the user wants to see.
  97. server.use(
  98. http.get('/api/v1/queue/batches', ({ request }) => {
  99. const status = new URL(request.url).searchParams.get('status');
  100. if (status !== 'completed') return HttpResponse.json([]);
  101. return HttpResponse.json([
  102. batch({ status: 'completed', completed_count: 6, remaining_count: 0, completed_at: '2026-08-02T10:00:00Z' }),
  103. ]);
  104. }),
  105. );
  106. const user = userEvent.setup();
  107. render(<BatchOrdersView hasPermission={allow} t={passthroughT} />);
  108. await waitFor(() => expect(screen.getByText('queue.batchOrders.emptyTitle')).toBeInTheDocument());
  109. await user.click(screen.getByRole('button', { name: 'queue.batchOrders.filter.completed' }));
  110. await waitFor(() => expect(screen.getByText('Widget run')).toBeInTheDocument());
  111. expect(screen.getByText('queue.batchOrders.status.completed')).toBeInTheDocument();
  112. });
  113. it('offers to queue what a failed run still owes', async () => {
  114. let dispatched: { plate_id?: number | null; only_plate?: boolean } | null = null;
  115. server.use(
  116. http.get('/api/v1/queue/batches', () =>
  117. HttpResponse.json([
  118. batch({
  119. completed_count: 1,
  120. failed_count: 1,
  121. target_count: 2,
  122. remaining_count: 1,
  123. plates: [plate({ quantity_target: 2, dispatched: 1, remaining: 1, completed_count: 1, failed_count: 1 })],
  124. }),
  125. ]),
  126. ),
  127. http.post('/api/v1/queue/batches/:id/dispatch', async ({ request }) => {
  128. dispatched = (await request.json()) as { plate_id?: number | null };
  129. return HttpResponse.json(batch({ remaining_count: 0, pending_count: 1 }));
  130. }),
  131. );
  132. const user = userEvent.setup();
  133. render(<BatchOrdersView hasPermission={allow} t={passthroughT} />);
  134. await waitFor(() => expect(screen.getByText('Widget run')).toBeInTheDocument());
  135. // Reported at both levels: the order summary and the plate that burned.
  136. expect(screen.getAllByText('queue.batchOrders.failed')).toHaveLength(2);
  137. await user.click(screen.getByRole('button', { name: /queue.batchOrders.dispatchRemaining/ }));
  138. await waitFor(() => expect(dispatched).not.toBeNull());
  139. // Order-level dispatch covers every plate, so no plate filter is sent.
  140. expect(dispatched).toEqual({});
  141. });
  142. it('dispatches a single plate from its own row', async () => {
  143. let dispatched: { plate_id?: number | null; only_plate?: boolean } | null = null;
  144. server.use(
  145. http.get('/api/v1/queue/batches', () =>
  146. HttpResponse.json([
  147. batch({
  148. target_count: 4,
  149. remaining_count: 3,
  150. plates: [
  151. plate({ plate_id: 1, quantity_target: 1, remaining: 0 }),
  152. plate({ plate_id: 2, quantity_target: 3, dispatched: 0, completed_count: 0, remaining: 3 }),
  153. ],
  154. }),
  155. ]),
  156. ),
  157. http.post('/api/v1/queue/batches/:id/dispatch', async ({ request }) => {
  158. dispatched = (await request.json()) as { plate_id?: number | null };
  159. return HttpResponse.json(batch());
  160. }),
  161. );
  162. const user = userEvent.setup();
  163. render(<BatchOrdersView hasPermission={allow} t={passthroughT} />);
  164. await waitFor(() => expect(screen.getByText('Widget run')).toBeInTheDocument());
  165. // Only the plate with work outstanding offers the action.
  166. const plateButtons = screen.getAllByRole('button', { name: 'queue.batchOrders.dispatchPlate' });
  167. expect(plateButtons).toHaveLength(1);
  168. await user.click(plateButtons[0]);
  169. await waitFor(() => expect(dispatched).not.toBeNull());
  170. expect(dispatched).toEqual({ plate_id: 2, only_plate: true });
  171. });
  172. it('marks a legacy batch as grouping-only and offers no dispatch', async () => {
  173. server.use(
  174. http.get('/api/v1/queue/batches', () =>
  175. HttpResponse.json([
  176. batch({ has_targets: false, target_count: 3, remaining_count: 0, pending_count: 3, plates: [] }),
  177. ]),
  178. ),
  179. );
  180. render(<BatchOrdersView hasPermission={allow} t={passthroughT} />);
  181. await waitFor(() => expect(screen.getByText('queue.batchOrders.noTargets')).toBeInTheDocument());
  182. expect(
  183. screen.queryByRole('button', { name: /queue.batchOrders.dispatchRemaining/ }),
  184. ).not.toBeInTheDocument();
  185. });
  186. it('hides the dispatch and cancel actions without permission', async () => {
  187. server.use(
  188. http.get('/api/v1/queue/batches', () =>
  189. HttpResponse.json([
  190. batch({ pending_count: 2, remaining_count: 2, plates: [plate({ quantity_target: 3, remaining: 2 })] }),
  191. ]),
  192. ),
  193. );
  194. render(<BatchOrdersView hasPermission={deny} t={passthroughT} />);
  195. await waitFor(() => expect(screen.getByText('Widget run')).toBeInTheDocument());
  196. expect(
  197. screen.queryByRole('button', { name: /queue.batchOrders.dispatchRemaining/ }),
  198. ).not.toBeInTheDocument();
  199. expect(screen.queryByRole('button', { name: /queue.batchOrders.dispatchPlate/ })).not.toBeInTheDocument();
  200. expect(screen.queryByRole('button', { name: 'queue.cancelBatch' })).not.toBeInTheDocument();
  201. });
  202. it('shows cost only once a run has produced one', async () => {
  203. server.use(
  204. http.get('/api/v1/queue/batches', () =>
  205. HttpResponse.json([
  206. batch({ id: 1, name: 'Priced', actual_cost: 6, estimated_remaining_cost: 3, completed_count: 2 }),
  207. batch({ id: 2, name: 'Unpriced', actual_cost: null, estimated_remaining_cost: null }),
  208. ]),
  209. ),
  210. );
  211. render(<BatchOrdersView hasPermission={allow} t={passthroughT} />);
  212. await waitFor(() => expect(screen.getByText('Priced')).toBeInTheDocument());
  213. // One cost line, belonging to the priced order — never a fabricated 0.00.
  214. expect(screen.getAllByText('queue.batchOrders.costSoFar')).toHaveLength(1);
  215. });
  216. it('explains a plate that cannot be queued instead of offering a button that fails', async () => {
  217. // #2960: the plate's last queue item was deleted, so there is nothing left
  218. // to clone its configuration from. The card used to offer "Queue remaining"
  219. // anyway and answer with an error toast.
  220. server.use(
  221. http.get('/api/v1/queue/batches', () =>
  222. HttpResponse.json([
  223. batch({
  224. target_count: 3,
  225. remaining_count: 3,
  226. dispatchable_count: 0,
  227. plates: [
  228. plate({ plate_id: 1, quantity_target: 3, dispatched: 0, completed_count: 0, remaining: 3, can_dispatch: false }),
  229. ],
  230. }),
  231. ]),
  232. ),
  233. );
  234. render(<BatchOrdersView hasPermission={allow} t={passthroughT} />);
  235. await waitFor(() => expect(screen.getByText('Widget run')).toBeInTheDocument());
  236. expect(screen.getByText('queue.batchOrders.strandedNotice')).toBeInTheDocument();
  237. expect(screen.getByText('queue.batchOrders.strandedPlate')).toBeInTheDocument();
  238. expect(
  239. screen.queryByRole('button', { name: /queue.batchOrders.dispatchRemaining/ }),
  240. ).not.toBeInTheDocument();
  241. expect(screen.queryByRole('button', { name: 'queue.batchOrders.dispatchPlate' })).not.toBeInTheDocument();
  242. });
  243. it('still offers to close an order with nothing pending to cancel', async () => {
  244. // #2960: cancel is the only way out of an order whose runs were all
  245. // deleted, and that order has no pending items by definition.
  246. server.use(
  247. http.get('/api/v1/queue/batches', () =>
  248. HttpResponse.json([
  249. batch({
  250. pending_count: 0,
  251. target_count: 3,
  252. remaining_count: 3,
  253. dispatchable_count: 0,
  254. plates: [plate({ quantity_target: 3, dispatched: 0, completed_count: 0, remaining: 3, can_dispatch: false })],
  255. }),
  256. ]),
  257. ),
  258. );
  259. render(<BatchOrdersView hasPermission={allow} t={passthroughT} />);
  260. await waitFor(() => expect(screen.getByText('Widget run')).toBeInTheDocument());
  261. expect(screen.getByRole('button', { name: 'queue.cancelBatch' })).toBeInTheDocument();
  262. });
  263. });