ProjectDetailPage.test.tsx 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. /**
  2. * Tests for the ProjectDetailPage component.
  3. * Covers: isSlicedFilename conditional print-button logic, linked folder file rendering,
  4. * and the PrintModal open trigger with projectId.
  5. */
  6. /// <reference types="@testing-library/jest-dom" />
  7. import { describe, it, expect, beforeEach, vi } from 'vitest';
  8. import { screen, waitFor } from '@testing-library/react';
  9. import userEvent from '@testing-library/user-event';
  10. import { render } from '../utils';
  11. import { ProjectDetailPage } from '../../pages/ProjectDetailPage';
  12. import { http, HttpResponse } from 'msw';
  13. import { server } from '../mocks/server';
  14. // Mock useParams so the component receives a fixed project id without a nested Router
  15. vi.mock('react-router-dom', async () => {
  16. const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
  17. return {
  18. ...actual,
  19. useParams: () => ({ id: '1' }),
  20. useNavigate: () => vi.fn(),
  21. };
  22. });
  23. const mockProject = {
  24. id: 1,
  25. name: 'Test Project',
  26. description: 'A test project',
  27. color: '#00ae42',
  28. status: 'active',
  29. priority: 'normal',
  30. due_date: null,
  31. notes: null,
  32. parent_id: null,
  33. archive_count: 0,
  34. total_print_time_seconds: 0,
  35. total_filament_grams: 0,
  36. created_at: '2024-01-01T00:00:00Z',
  37. updated_at: '2024-01-01T00:00:00Z',
  38. };
  39. const mockFolder = {
  40. id: 10,
  41. name: 'Sliced Files',
  42. project_id: 1,
  43. archive_id: null,
  44. parent_id: null,
  45. file_count: 3,
  46. created_at: '2024-01-01T00:00:00Z',
  47. updated_at: '2024-01-01T00:00:00Z',
  48. };
  49. function makeFile(overrides: { id: number; filename: string; file_type?: string }) {
  50. return {
  51. id: overrides.id,
  52. filename: overrides.filename,
  53. print_name: null,
  54. file_type: overrides.file_type ?? '3mf',
  55. folder_id: 10,
  56. project_id: 1,
  57. file_hash: null,
  58. file_size_bytes: 1024,
  59. thumbnail_path: null,
  60. created_at: '2024-01-01T00:00:00Z',
  61. updated_at: '2024-01-01T00:00:00Z',
  62. duplicate_count: 0,
  63. };
  64. }
  65. describe('ProjectDetailPage', () => {
  66. beforeEach(() => {
  67. server.use(
  68. http.get('/api/v1/projects/:id', () => {
  69. return HttpResponse.json(mockProject);
  70. }),
  71. http.get('/api/v1/projects/:id/archives', () => {
  72. return HttpResponse.json([]);
  73. }),
  74. http.get('/api/v1/projects/:id/bom', () => {
  75. return HttpResponse.json([]);
  76. }),
  77. http.get('/api/v1/projects/:id/timeline', () => {
  78. return HttpResponse.json([]);
  79. }),
  80. http.get('/api/v1/library/folders/by-project/:id', () => {
  81. return HttpResponse.json([mockFolder]);
  82. }),
  83. );
  84. });
  85. describe('isSlicedFilename — conditional print button', () => {
  86. it('shows print button for .gcode files', async () => {
  87. server.use(
  88. http.get('/api/v1/library/files', () => {
  89. return HttpResponse.json([makeFile({ id: 1, filename: 'benchy.gcode', file_type: 'gcode' })]);
  90. })
  91. );
  92. render(<ProjectDetailPage />);
  93. await waitFor(() => {
  94. expect(screen.getByTitle('Print Now')).toBeInTheDocument();
  95. });
  96. });
  97. it('shows print button for .gcode.3mf files', async () => {
  98. server.use(
  99. http.get('/api/v1/library/files', () => {
  100. return HttpResponse.json([makeFile({ id: 2, filename: 'benchy.gcode.3mf', file_type: '3mf' })]);
  101. })
  102. );
  103. render(<ProjectDetailPage />);
  104. await waitFor(() => {
  105. expect(screen.getByTitle('Print Now')).toBeInTheDocument();
  106. });
  107. });
  108. it('does NOT show print button for .gcode.bak files (regression for includes bug)', async () => {
  109. server.use(
  110. http.get('/api/v1/library/files', () => {
  111. return HttpResponse.json([makeFile({ id: 3, filename: 'benchy.gcode.bak', file_type: '3mf' })]);
  112. })
  113. );
  114. render(<ProjectDetailPage />);
  115. await waitFor(() => {
  116. expect(screen.getByText('benchy.gcode.bak')).toBeInTheDocument();
  117. });
  118. expect(screen.queryByTitle('Print Now')).not.toBeInTheDocument();
  119. });
  120. it('does NOT show print button for .stl files', async () => {
  121. server.use(
  122. http.get('/api/v1/library/files', () => {
  123. return HttpResponse.json([makeFile({ id: 4, filename: 'model.stl', file_type: 'stl' })]);
  124. })
  125. );
  126. render(<ProjectDetailPage />);
  127. await waitFor(() => {
  128. expect(screen.getByText('model.stl')).toBeInTheDocument();
  129. });
  130. expect(screen.queryByTitle('Print Now')).not.toBeInTheDocument();
  131. });
  132. });
  133. describe('linked folder file rendering', () => {
  134. it('renders filenames from linked folder', async () => {
  135. server.use(
  136. http.get('/api/v1/library/files', () => {
  137. return HttpResponse.json([
  138. makeFile({ id: 5, filename: 'part_a.gcode.3mf', file_type: '3mf' }),
  139. makeFile({ id: 6, filename: 'design.stl', file_type: 'stl' }),
  140. ]);
  141. })
  142. );
  143. render(<ProjectDetailPage />);
  144. await waitFor(() => {
  145. expect(screen.getByText('part_a.gcode.3mf')).toBeInTheDocument();
  146. expect(screen.getByText('design.stl')).toBeInTheDocument();
  147. });
  148. });
  149. it('renders the linked folder name', async () => {
  150. server.use(
  151. http.get('/api/v1/library/files', () => {
  152. return HttpResponse.json([]);
  153. })
  154. );
  155. render(<ProjectDetailPage />);
  156. await waitFor(() => {
  157. expect(screen.getByText('Sliced Files')).toBeInTheDocument();
  158. });
  159. });
  160. });
  161. describe('print modal trigger', () => {
  162. it('opens PrintModal when print button is clicked on a sliced file', async () => {
  163. const user = userEvent.setup();
  164. server.use(
  165. http.get('/api/v1/library/files', () => {
  166. return HttpResponse.json([makeFile({ id: 7, filename: 'cube.gcode.3mf', file_type: '3mf' })]);
  167. }),
  168. http.get('/api/v1/printers/', () => {
  169. return HttpResponse.json([]);
  170. }),
  171. http.get('/api/v1/library/files/:id', () => {
  172. return HttpResponse.json(makeFile({ id: 7, filename: 'cube.gcode.3mf', file_type: '3mf' }));
  173. }),
  174. http.get('/api/v1/library/files/:id/plates', () => {
  175. return HttpResponse.json({ is_multi_plate: false, plates: [] });
  176. }),
  177. http.get('/api/v1/library/files/:id/filament-requirements', () => {
  178. return HttpResponse.json({ file_id: 7, filename: 'cube.gcode.3mf', filaments: [] });
  179. }),
  180. );
  181. render(<ProjectDetailPage />);
  182. await waitFor(() => {
  183. expect(screen.getByTitle('Print Now')).toBeInTheDocument();
  184. });
  185. await user.click(screen.getByTitle('Print Now'));
  186. // PrintModal should open — look for the modal heading "Print"
  187. await waitFor(() => {
  188. expect(screen.getByRole('heading', { name: 'Print' })).toBeInTheDocument();
  189. });
  190. });
  191. });
  192. });