RunWithPipelineModal.test.tsx 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. /**
  2. * Tests for RunWithPipelineModal (#1425 PR B).
  3. *
  4. * Pin the two-step flow: pick → optional confirmation → run. Verify the
  5. * fast path skips the modal step and the slow path renders the eligibility
  6. * issues with the "Run anyway" button.
  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 { render } from '../utils';
  12. import { RunWithPipelineModal } from '../../components/RunWithPipelineModal';
  13. import { SliceJobTrackerProvider } from '../../contexts/SliceJobTrackerContext';
  14. import { api, type Printer, type SlicerPipeline } from '../../api/client';
  15. vi.mock('../../api/client', () => ({
  16. api: {
  17. listSlicerPipelines: vi.fn(),
  18. getPrinters: vi.fn(),
  19. checkPipelineEligibility: vi.fn(),
  20. runPipeline: vi.fn(),
  21. // ThemeContext / AuthContext bootstrap touches these on mount.
  22. getSettings: vi.fn().mockResolvedValue({}),
  23. updateSettings: vi.fn().mockResolvedValue({}),
  24. },
  25. }));
  26. const mockApi = api as unknown as {
  27. listSlicerPipelines: ReturnType<typeof vi.fn>;
  28. getPrinters: ReturnType<typeof vi.fn>;
  29. checkPipelineEligibility: ReturnType<typeof vi.fn>;
  30. runPipeline: ReturnType<typeof vi.fn>;
  31. };
  32. function makePipeline(overrides: Partial<SlicerPipeline> = {}): SlicerPipeline {
  33. return {
  34. id: 1,
  35. name: 'Production Batch',
  36. description: null,
  37. printer_preset: { source: 'local', id: '1' },
  38. process_preset: { source: 'local', id: '2' },
  39. filament_presets: [{ source: 'local', id: '3' }],
  40. bed_type: null,
  41. target_kind: 'specific_printer',
  42. target_printer_id: 42,
  43. target_model_class: null,
  44. fanout_strategy: 'max_parallel',
  45. created_by: null,
  46. created_at: '2026-06-27T00:00:00Z',
  47. updated_at: '2026-06-27T00:00:00Z',
  48. ...overrides,
  49. };
  50. }
  51. function makePrinter(overrides: Partial<Printer> = {}): Printer {
  52. return {
  53. id: 42,
  54. name: 'X1C #2',
  55. serial_number: 'TEST',
  56. ip_address: '192.0.2.1',
  57. access_code: '****',
  58. model: 'X1C',
  59. location: null,
  60. is_active: true,
  61. nozzle_count: 1,
  62. ...overrides,
  63. } as unknown as Printer;
  64. }
  65. describe('RunWithPipelineModal', () => {
  66. beforeEach(() => {
  67. vi.clearAllMocks();
  68. mockApi.getPrinters.mockResolvedValue([makePrinter()]);
  69. });
  70. it('renders the empty state when no pipelines exist', async () => {
  71. mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
  72. render(
  73. <SliceJobTrackerProvider>
  74. <RunWithPipelineModal source={{ kind: 'libraryFile', id: 99, filename: 'cube.3mf' }} onClose={vi.fn()} />
  75. </SliceJobTrackerProvider>,
  76. );
  77. await waitFor(() => {
  78. expect(screen.getByText(/No pipelines saved yet/i)).toBeInTheDocument();
  79. });
  80. });
  81. it('disables pipelines with no target printer', async () => {
  82. mockApi.listSlicerPipelines.mockResolvedValue({
  83. pipelines: [makePipeline({ target_printer_id: null })],
  84. });
  85. render(
  86. <SliceJobTrackerProvider>
  87. <RunWithPipelineModal source={{ kind: 'libraryFile', id: 99, filename: 'cube.3mf' }} onClose={vi.fn()} />
  88. </SliceJobTrackerProvider>,
  89. );
  90. await waitFor(() => {
  91. expect(screen.getByText(/No target printer set/i)).toBeInTheDocument();
  92. });
  93. const button = screen.getByRole('button', { name: /Production Batch/i });
  94. expect((button as HTMLButtonElement).disabled).toBe(true);
  95. });
  96. it('fires the run immediately when eligibility is ok (fast path)', async () => {
  97. mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [makePipeline()] });
  98. mockApi.checkPipelineEligibility.mockResolvedValue({
  99. ok: true,
  100. target_printer_id: 42,
  101. target_printer_name: 'X1C #2',
  102. issues: [],
  103. });
  104. mockApi.runPipeline.mockResolvedValue({});
  105. const onClose = vi.fn();
  106. render(
  107. <SliceJobTrackerProvider>
  108. <RunWithPipelineModal source={{ kind: 'libraryFile', id: 99, filename: 'cube.3mf' }} onClose={onClose} />
  109. </SliceJobTrackerProvider>,
  110. );
  111. await waitFor(() => expect(screen.getByText('Production Batch')).toBeInTheDocument());
  112. const user = userEvent.setup();
  113. await user.click(screen.getByRole('button', { name: /Production Batch/i }));
  114. await waitFor(() => {
  115. expect(mockApi.checkPipelineEligibility).toHaveBeenCalledWith(1, {
  116. kind: 'libraryFile',
  117. id: 99,
  118. });
  119. expect(mockApi.runPipeline).toHaveBeenCalledWith(
  120. 1,
  121. { kind: 'libraryFile', id: 99 },
  122. false,
  123. 1,
  124. );
  125. expect(onClose).toHaveBeenCalled();
  126. });
  127. });
  128. it('shows why the eligibility check was refused instead of going back silently', async () => {
  129. mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [makePipeline()] });
  130. mockApi.checkPipelineEligibility.mockRejectedValue(new Error('Not allowed to print this archive again'));
  131. const onClose = vi.fn();
  132. render(
  133. <SliceJobTrackerProvider>
  134. <RunWithPipelineModal source={{ kind: 'archive', id: 7, filename: 'part.3mf' }} onClose={onClose} />
  135. </SliceJobTrackerProvider>,
  136. );
  137. await waitFor(() => expect(screen.getByText('Production Batch')).toBeInTheDocument());
  138. const user = userEvent.setup();
  139. await user.click(screen.getByRole('button', { name: /Production Batch/i }));
  140. expect(await screen.findByText('Not allowed to print this archive again')).toBeInTheDocument();
  141. expect(mockApi.runPipeline).not.toHaveBeenCalled();
  142. expect(onClose).not.toHaveBeenCalled();
  143. });
  144. it('threads source kind="archive" through eligibility + run', async () => {
  145. mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [makePipeline()] });
  146. mockApi.checkPipelineEligibility.mockResolvedValue({
  147. ok: true,
  148. target_printer_id: 42,
  149. target_printer_name: 'X1C #2',
  150. issues: [],
  151. });
  152. mockApi.runPipeline.mockResolvedValue({ slice_job_id: 9001 });
  153. render(
  154. <SliceJobTrackerProvider>
  155. <RunWithPipelineModal
  156. source={{ kind: 'archive', id: 7, filename: 'archive.3mf' }}
  157. onClose={vi.fn()}
  158. />
  159. </SliceJobTrackerProvider>,
  160. );
  161. await waitFor(() => expect(screen.getByText('Production Batch')).toBeInTheDocument());
  162. const user = userEvent.setup();
  163. await user.click(screen.getByRole('button', { name: /Production Batch/i }));
  164. await waitFor(() => {
  165. expect(mockApi.checkPipelineEligibility).toHaveBeenCalledWith(1, {
  166. kind: 'archive',
  167. id: 7,
  168. });
  169. expect(mockApi.runPipeline).toHaveBeenCalledWith(
  170. 1,
  171. { kind: 'archive', id: 7 },
  172. false,
  173. 1,
  174. );
  175. });
  176. });
  177. it('shows the eligibility report + Run-anyway button when issues exist', async () => {
  178. mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [makePipeline()] });
  179. mockApi.checkPipelineEligibility.mockResolvedValue({
  180. ok: false,
  181. target_printer_id: 42,
  182. target_printer_name: 'X1C #2',
  183. issues: [
  184. {
  185. kind: 'filament_type_mismatch',
  186. slot_index: 0,
  187. expected: 'PLA',
  188. actual: 'PETG',
  189. },
  190. ],
  191. });
  192. mockApi.runPipeline.mockResolvedValue({});
  193. render(
  194. <SliceJobTrackerProvider>
  195. <RunWithPipelineModal source={{ kind: 'libraryFile', id: 99, filename: 'cube.3mf' }} onClose={vi.fn()} />
  196. </SliceJobTrackerProvider>,
  197. );
  198. await waitFor(() => expect(screen.getByText('Production Batch')).toBeInTheDocument());
  199. const user = userEvent.setup();
  200. await user.click(screen.getByRole('button', { name: /Production Batch/i }));
  201. // Confirmation view appears with the issue listed.
  202. await waitFor(() => {
  203. expect(screen.getByText(/expected PLA/i)).toBeInTheDocument();
  204. });
  205. // Clicking Run anyway fires the run with force=true and the new source-kind shape.
  206. await user.click(screen.getByRole('button', { name: /Run anyway/i }));
  207. await waitFor(() => {
  208. expect(mockApi.runPipeline).toHaveBeenCalledWith(1, { kind: 'libraryFile', id: 99 }, true, 1);
  209. });
  210. });
  211. });