RunWithPipelineModal.test.tsx 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  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('threads source kind="archive" through eligibility + run', async () => {
  129. mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [makePipeline()] });
  130. mockApi.checkPipelineEligibility.mockResolvedValue({
  131. ok: true,
  132. target_printer_id: 42,
  133. target_printer_name: 'X1C #2',
  134. issues: [],
  135. });
  136. mockApi.runPipeline.mockResolvedValue({ slice_job_id: 9001 });
  137. render(
  138. <SliceJobTrackerProvider>
  139. <RunWithPipelineModal
  140. source={{ kind: 'archive', id: 7, filename: 'archive.3mf' }}
  141. onClose={vi.fn()}
  142. />
  143. </SliceJobTrackerProvider>,
  144. );
  145. await waitFor(() => expect(screen.getByText('Production Batch')).toBeInTheDocument());
  146. const user = userEvent.setup();
  147. await user.click(screen.getByRole('button', { name: /Production Batch/i }));
  148. await waitFor(() => {
  149. expect(mockApi.checkPipelineEligibility).toHaveBeenCalledWith(1, {
  150. kind: 'archive',
  151. id: 7,
  152. });
  153. expect(mockApi.runPipeline).toHaveBeenCalledWith(
  154. 1,
  155. { kind: 'archive', id: 7 },
  156. false,
  157. 1,
  158. );
  159. });
  160. });
  161. it('shows the eligibility report + Run-anyway button when issues exist', async () => {
  162. mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [makePipeline()] });
  163. mockApi.checkPipelineEligibility.mockResolvedValue({
  164. ok: false,
  165. target_printer_id: 42,
  166. target_printer_name: 'X1C #2',
  167. issues: [
  168. {
  169. kind: 'filament_type_mismatch',
  170. slot_index: 0,
  171. expected: 'PLA',
  172. actual: 'PETG',
  173. },
  174. ],
  175. });
  176. mockApi.runPipeline.mockResolvedValue({});
  177. render(
  178. <SliceJobTrackerProvider>
  179. <RunWithPipelineModal source={{ kind: 'libraryFile', id: 99, filename: 'cube.3mf' }} onClose={vi.fn()} />
  180. </SliceJobTrackerProvider>,
  181. );
  182. await waitFor(() => expect(screen.getByText('Production Batch')).toBeInTheDocument());
  183. const user = userEvent.setup();
  184. await user.click(screen.getByRole('button', { name: /Production Batch/i }));
  185. // Confirmation view appears with the issue listed.
  186. await waitFor(() => {
  187. expect(screen.getByText(/expected PLA/i)).toBeInTheDocument();
  188. });
  189. // Clicking Run anyway fires the run with force=true and the new source-kind shape.
  190. await user.click(screen.getByRole('button', { name: /Run anyway/i }));
  191. await waitFor(() => {
  192. expect(mockApi.runPipeline).toHaveBeenCalledWith(1, { kind: 'libraryFile', id: 99 }, true, 1);
  193. });
  194. });
  195. });