ProjectDetailPage.test.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  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')).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')).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')).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')).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')).toBeInTheDocument();
  184. });
  185. await user.click(screen.getByTitle('Print'));
  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. describe('per-file print progress (#1897)', () => {
  193. it('shows X / N badges and the Complete Sets bar when target_sets is set', async () => {
  194. server.use(
  195. http.get('/api/v1/projects/:id', () => {
  196. return HttpResponse.json({ ...mockProject, target_sets: 10 });
  197. }),
  198. http.get('/api/v1/library/files', () => {
  199. return HttpResponse.json([
  200. makeFile({ id: 5, filename: 'plate_1.gcode.3mf', file_type: '3mf' }),
  201. makeFile({ id: 6, filename: 'plate_2.gcode.3mf', file_type: '3mf' }),
  202. ]);
  203. }),
  204. http.get('/api/v1/projects/:id/file-progress', () => {
  205. return HttpResponse.json([{ file_id: 5, completed_count: 3 }]);
  206. }),
  207. );
  208. render(<ProjectDetailPage />);
  209. // Per-file badges: 3 / 10 for plate_1, 0 / 10 for the never-printed plate_2
  210. await waitFor(() => {
  211. expect(screen.getByTitle('3 of 10 completed prints')).toBeInTheDocument();
  212. });
  213. expect(screen.getByTitle('0 of 10 completed prints')).toBeInTheDocument();
  214. // Complete sets = min across printable files = 0
  215. expect(screen.getByText('Complete Sets')).toBeInTheDocument();
  216. expect(
  217. screen.getByText((_, element) => element?.tagName === 'SPAN' && element.textContent === '0 / 10 sets')
  218. ).toBeInTheDocument();
  219. });
  220. it('counts a complete set once every printable file reached the target', async () => {
  221. server.use(
  222. http.get('/api/v1/projects/:id', () => {
  223. return HttpResponse.json({ ...mockProject, target_sets: 2 });
  224. }),
  225. http.get('/api/v1/library/files', () => {
  226. return HttpResponse.json([
  227. makeFile({ id: 5, filename: 'plate_1.gcode.3mf', file_type: '3mf' }),
  228. // STL is not printable and must not drag the set count to 0
  229. makeFile({ id: 8, filename: 'source.stl', file_type: 'stl' }),
  230. makeFile({ id: 6, filename: 'plate_2.gcode.3mf', file_type: '3mf' }),
  231. ]);
  232. }),
  233. http.get('/api/v1/projects/:id/file-progress', () => {
  234. // plate_1 overshot the target; capped at 2 for the set count
  235. return HttpResponse.json([
  236. { file_id: 5, completed_count: 3 },
  237. { file_id: 6, completed_count: 2 },
  238. ]);
  239. }),
  240. );
  241. render(<ProjectDetailPage />);
  242. await waitFor(() => {
  243. expect(
  244. screen.getByText((_, element) => element?.tagName === 'SPAN' && element.textContent === '2 / 2 sets')
  245. ).toBeInTheDocument();
  246. });
  247. });
  248. it('shows a plain printed-count badge when no target_sets is set', async () => {
  249. server.use(
  250. http.get('/api/v1/library/files', () => {
  251. return HttpResponse.json([makeFile({ id: 5, filename: 'plate_1.gcode.3mf', file_type: '3mf' })]);
  252. }),
  253. http.get('/api/v1/projects/:id/file-progress', () => {
  254. return HttpResponse.json([{ file_id: 5, completed_count: 4 }]);
  255. }),
  256. );
  257. render(<ProjectDetailPage />);
  258. await waitFor(() => {
  259. expect(screen.getByText('4\u00d7')).toBeInTheDocument();
  260. });
  261. // No sets bar without a target
  262. expect(screen.queryByText('Complete Sets')).not.toBeInTheDocument();
  263. });
  264. });
  265. describe('sub-project roll-up (#1264)', () => {
  266. const withChildren = {
  267. ...mockProject,
  268. // Supplying `stats` opens the cost card, which reads `budget` — the API
  269. // always sends the key, so the mock has to as well.
  270. budget: null,
  271. descendant_count: 2,
  272. children: [
  273. {
  274. id: 2,
  275. name: 'Wing',
  276. color: '#ff0000',
  277. status: 'active',
  278. progress_percent: 50,
  279. descendant_count: 1,
  280. total_archives: 4,
  281. completed_prints: 4,
  282. total_print_time_hours: 6,
  283. total_filament_grams: 250,
  284. total_cost: 12.5,
  285. },
  286. ],
  287. stats: {
  288. total_archives: 1,
  289. total_items: 1,
  290. completed_prints: 1,
  291. failed_prints: 0,
  292. queued_prints: 0,
  293. in_progress_prints: 0,
  294. total_print_time_hours: 2,
  295. total_filament_grams: 100,
  296. progress_percent: null,
  297. parts_progress_percent: null,
  298. estimated_cost: 5,
  299. total_energy_kwh: 0,
  300. total_energy_cost: 0,
  301. remaining_prints: null,
  302. remaining_parts: null,
  303. bom_total_items: 0,
  304. bom_completed_items: 0,
  305. bom_cost: 0,
  306. },
  307. rollup_stats: {
  308. total_archives: 5,
  309. total_items: 5,
  310. completed_prints: 5,
  311. failed_prints: 0,
  312. queued_prints: 0,
  313. in_progress_prints: 0,
  314. total_print_time_hours: 8,
  315. total_filament_grams: 350,
  316. progress_percent: 40,
  317. parts_progress_percent: null,
  318. estimated_cost: 17.5,
  319. total_energy_kwh: 0,
  320. total_energy_cost: 0,
  321. remaining_prints: 3,
  322. remaining_parts: null,
  323. bom_total_items: 0,
  324. bom_completed_items: 0,
  325. bom_cost: 0,
  326. },
  327. };
  328. it('shows the whole programme alongside the project own numbers', async () => {
  329. server.use(http.get('/api/v1/projects/:id', () => HttpResponse.json(withChildren)));
  330. render(<ProjectDetailPage />);
  331. await waitFor(() => {
  332. expect(screen.getByText('Including 2 sub-projects')).toBeInTheDocument();
  333. });
  334. // Both sets are on screen at once, which is the point — the roll-up must
  335. // not quietly replace what the project itself printed.
  336. expect(screen.getByText('350g')).toBeInTheDocument();
  337. expect(screen.getByText('100g')).toBeInTheDocument();
  338. });
  339. it('stays quiet when the project has no sub-projects', async () => {
  340. // Reversion-proof: if the roll-up were computed unconditionally it would
  341. // print a second, identical set of figures under every ordinary project.
  342. server.use(
  343. http.get('/api/v1/projects/:id', () =>
  344. HttpResponse.json({ ...withChildren, children: [], descendant_count: 0, rollup_stats: null })
  345. )
  346. );
  347. render(<ProjectDetailPage />);
  348. await waitFor(() => {
  349. expect(screen.getByText('100g')).toBeInTheDocument();
  350. });
  351. expect(screen.queryByText(/Including .* sub-projects/)).not.toBeInTheDocument();
  352. });
  353. it('gives each listed sub-project its own branch total', async () => {
  354. server.use(http.get('/api/v1/projects/:id', () => HttpResponse.json(withChildren)));
  355. render(<ProjectDetailPage />);
  356. await waitFor(() => {
  357. expect(screen.getByText('Wing')).toBeInTheDocument();
  358. });
  359. expect(screen.getByText('4 jobs')).toBeInTheDocument();
  360. expect(screen.getByText('250g')).toBeInTheDocument();
  361. expect(screen.getByText('50%')).toBeInTheDocument();
  362. });
  363. it('marks a sub-project that is itself a parent', async () => {
  364. // Otherwise its figures look inflated for a single project rather than
  365. // covering the branch under it.
  366. server.use(http.get('/api/v1/projects/:id', () => HttpResponse.json(withChildren)));
  367. render(<ProjectDetailPage />);
  368. await waitFor(() => {
  369. expect(screen.getByText('Wing')).toBeInTheDocument();
  370. });
  371. const row = screen.getByText('Wing').closest('a');
  372. expect(row).not.toBeNull();
  373. expect(row!.textContent).toContain('1');
  374. });
  375. });
  376. });