BatchOrdersView.test.tsx 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  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. plate_id: 1,
  18. plate_name: null,
  19. quantity_target: 1,
  20. dispatched: 1,
  21. remaining: 0,
  22. pending_count: 0,
  23. printing_count: 0,
  24. completed_count: 1,
  25. failed_count: 0,
  26. cancelled_count: 0,
  27. skipped_count: 0,
  28. actual_cost: null,
  29. estimated_remaining_cost: null,
  30. filament_used_grams: null,
  31. print_time_seconds: 0,
  32. ...over,
  33. });
  34. const batch = (over: Partial<PrintBatch> = {}): PrintBatch => ({
  35. id: 1,
  36. name: 'Widget run',
  37. archive_id: 7,
  38. library_file_id: null,
  39. quantity: 6,
  40. status: 'active',
  41. created_at: '2026-08-01T10:00:00Z',
  42. completed_at: null,
  43. created_by_id: null,
  44. created_by_username: null,
  45. project_id: null,
  46. due_date: null,
  47. notes: null,
  48. pending_count: 0,
  49. printing_count: 0,
  50. completed_count: 0,
  51. failed_count: 0,
  52. cancelled_count: 0,
  53. skipped_count: 0,
  54. has_targets: true,
  55. target_count: 6,
  56. remaining_count: 6,
  57. actual_cost: null,
  58. estimated_remaining_cost: null,
  59. filament_used_grams: null,
  60. print_time_seconds: 0,
  61. plates: [],
  62. ...over,
  63. });
  64. const allow = () => true;
  65. const deny = () => false;
  66. const passthroughT = (key: string, options?: Record<string, unknown>) => {
  67. void options;
  68. return key;
  69. };
  70. describe('BatchOrdersView (#342)', () => {
  71. beforeEach(() => {
  72. vi.clearAllMocks();
  73. server.use(
  74. http.get('/api/v1/settings/', () => HttpResponse.json({ currency: 'EUR' })),
  75. http.get('/api/v1/queue/batches', () => HttpResponse.json([])),
  76. );
  77. });
  78. it('shows the empty state when nothing matches the filter', async () => {
  79. render(<BatchOrdersView hasPermission={allow} t={passthroughT} />);
  80. await waitFor(() =>
  81. expect(screen.getByText('queue.batchOrders.emptyTitle')).toBeInTheDocument(),
  82. );
  83. });
  84. it('surfaces a finished order that has no queue rows left', async () => {
  85. // The case the Queue and History tabs each miss: every run completed, so
  86. // nothing is pending, yet the order is the thing the user wants to see.
  87. server.use(
  88. http.get('/api/v1/queue/batches', ({ request }) => {
  89. const status = new URL(request.url).searchParams.get('status');
  90. if (status !== 'completed') return HttpResponse.json([]);
  91. return HttpResponse.json([
  92. batch({ status: 'completed', completed_count: 6, remaining_count: 0, completed_at: '2026-08-02T10:00:00Z' }),
  93. ]);
  94. }),
  95. );
  96. const user = userEvent.setup();
  97. render(<BatchOrdersView hasPermission={allow} t={passthroughT} />);
  98. await waitFor(() => expect(screen.getByText('queue.batchOrders.emptyTitle')).toBeInTheDocument());
  99. await user.click(screen.getByRole('button', { name: 'queue.batchOrders.filter.completed' }));
  100. await waitFor(() => expect(screen.getByText('Widget run')).toBeInTheDocument());
  101. expect(screen.getByText('queue.batchOrders.status.completed')).toBeInTheDocument();
  102. });
  103. it('offers to queue what a failed run still owes', async () => {
  104. let dispatched: { plate_id?: number | null; only_plate?: boolean } | null = null;
  105. server.use(
  106. http.get('/api/v1/queue/batches', () =>
  107. HttpResponse.json([
  108. batch({
  109. completed_count: 1,
  110. failed_count: 1,
  111. target_count: 2,
  112. remaining_count: 1,
  113. plates: [plate({ quantity_target: 2, dispatched: 1, remaining: 1, completed_count: 1, failed_count: 1 })],
  114. }),
  115. ]),
  116. ),
  117. http.post('/api/v1/queue/batches/:id/dispatch', async ({ request }) => {
  118. dispatched = (await request.json()) as { plate_id?: number | null };
  119. return HttpResponse.json(batch({ remaining_count: 0, pending_count: 1 }));
  120. }),
  121. );
  122. const user = userEvent.setup();
  123. render(<BatchOrdersView hasPermission={allow} t={passthroughT} />);
  124. await waitFor(() => expect(screen.getByText('Widget run')).toBeInTheDocument());
  125. // Reported at both levels: the order summary and the plate that burned.
  126. expect(screen.getAllByText('queue.batchOrders.failed')).toHaveLength(2);
  127. await user.click(screen.getByRole('button', { name: /queue.batchOrders.dispatchRemaining/ }));
  128. await waitFor(() => expect(dispatched).not.toBeNull());
  129. // Order-level dispatch covers every plate, so no plate filter is sent.
  130. expect(dispatched).toEqual({});
  131. });
  132. it('dispatches a single plate from its own row', async () => {
  133. let dispatched: { plate_id?: number | null; only_plate?: boolean } | null = null;
  134. server.use(
  135. http.get('/api/v1/queue/batches', () =>
  136. HttpResponse.json([
  137. batch({
  138. target_count: 4,
  139. remaining_count: 3,
  140. plates: [
  141. plate({ plate_id: 1, quantity_target: 1, remaining: 0 }),
  142. plate({ plate_id: 2, quantity_target: 3, dispatched: 0, completed_count: 0, remaining: 3 }),
  143. ],
  144. }),
  145. ]),
  146. ),
  147. http.post('/api/v1/queue/batches/:id/dispatch', async ({ request }) => {
  148. dispatched = (await request.json()) as { plate_id?: number | null };
  149. return HttpResponse.json(batch());
  150. }),
  151. );
  152. const user = userEvent.setup();
  153. render(<BatchOrdersView hasPermission={allow} t={passthroughT} />);
  154. await waitFor(() => expect(screen.getByText('Widget run')).toBeInTheDocument());
  155. // Only the plate with work outstanding offers the action.
  156. const plateButtons = screen.getAllByRole('button', { name: 'queue.batchOrders.dispatchPlate' });
  157. expect(plateButtons).toHaveLength(1);
  158. await user.click(plateButtons[0]);
  159. await waitFor(() => expect(dispatched).not.toBeNull());
  160. expect(dispatched).toEqual({ plate_id: 2, only_plate: true });
  161. });
  162. it('marks a legacy batch as grouping-only and offers no dispatch', async () => {
  163. server.use(
  164. http.get('/api/v1/queue/batches', () =>
  165. HttpResponse.json([
  166. batch({ has_targets: false, target_count: 3, remaining_count: 0, pending_count: 3, plates: [] }),
  167. ]),
  168. ),
  169. );
  170. render(<BatchOrdersView hasPermission={allow} t={passthroughT} />);
  171. await waitFor(() => expect(screen.getByText('queue.batchOrders.noTargets')).toBeInTheDocument());
  172. expect(
  173. screen.queryByRole('button', { name: /queue.batchOrders.dispatchRemaining/ }),
  174. ).not.toBeInTheDocument();
  175. });
  176. it('hides the dispatch and cancel actions without permission', async () => {
  177. server.use(
  178. http.get('/api/v1/queue/batches', () =>
  179. HttpResponse.json([
  180. batch({ pending_count: 2, remaining_count: 2, plates: [plate({ quantity_target: 3, remaining: 2 })] }),
  181. ]),
  182. ),
  183. );
  184. render(<BatchOrdersView hasPermission={deny} t={passthroughT} />);
  185. await waitFor(() => expect(screen.getByText('Widget run')).toBeInTheDocument());
  186. expect(
  187. screen.queryByRole('button', { name: /queue.batchOrders.dispatchRemaining/ }),
  188. ).not.toBeInTheDocument();
  189. expect(screen.queryByRole('button', { name: /queue.batchOrders.dispatchPlate/ })).not.toBeInTheDocument();
  190. expect(screen.queryByRole('button', { name: 'queue.cancelBatch' })).not.toBeInTheDocument();
  191. });
  192. it('shows cost only once a run has produced one', async () => {
  193. server.use(
  194. http.get('/api/v1/queue/batches', () =>
  195. HttpResponse.json([
  196. batch({ id: 1, name: 'Priced', actual_cost: 6, estimated_remaining_cost: 3, completed_count: 2 }),
  197. batch({ id: 2, name: 'Unpriced', actual_cost: null, estimated_remaining_cost: null }),
  198. ]),
  199. ),
  200. );
  201. render(<BatchOrdersView hasPermission={allow} t={passthroughT} />);
  202. await waitFor(() => expect(screen.getByText('Priced')).toBeInTheDocument());
  203. // One cost line, belonging to the priced order — never a fabricated 0.00.
  204. expect(screen.getAllByText('queue.batchOrders.costSoFar')).toHaveLength(1);
  205. });
  206. });