ModelViewerModal.test.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503
  1. /**
  2. * Tests for the ModelViewerModal component.
  3. * Tests fullscreen toggle, plate selector, object counts, and tab switching.
  4. */
  5. import { describe, it, expect, vi, beforeEach } from 'vitest';
  6. import { screen, fireEvent, waitFor } from '@testing-library/react';
  7. import { render } from '../utils';
  8. import { ModelViewerModal } from '../../components/ModelViewerModal';
  9. import { http, HttpResponse } from 'msw';
  10. import { server } from '../mocks/server';
  11. // Mock ModelViewer and GcodeViewer to avoid WebGL/Three.js issues in tests
  12. vi.mock('../../components/ModelViewer', () => ({
  13. ModelViewer: ({ className }: { className?: string }) => (
  14. <div data-testid="model-viewer" className={className}>
  15. Model Viewer Mock
  16. </div>
  17. ),
  18. }));
  19. vi.mock('../../components/GcodeViewer', () => ({
  20. GcodeViewer: ({ className }: { className?: string }) => (
  21. <div data-testid="gcode-viewer" className={className}>
  22. G-code Viewer Mock
  23. </div>
  24. ),
  25. }));
  26. const mockCapabilities = {
  27. has_model: true,
  28. has_gcode: true,
  29. has_source: false,
  30. build_volume: { x: 256, y: 256, z: 256 },
  31. filament_colors: ['#00ae42'],
  32. };
  33. const mockPlatesResponse = {
  34. is_multi_plate: true,
  35. plates: [
  36. {
  37. index: 1,
  38. name: 'Plate 1',
  39. has_thumbnail: true,
  40. thumbnail_url: '/api/v1/archives/1/plates/1/thumbnail',
  41. print_time_seconds: 3600,
  42. filament_used_grams: 50.5,
  43. object_count: 3,
  44. objects: ['Cube', 'Sphere', 'Cylinder'],
  45. filaments: [{ color: '#00ae42', type: 'PLA', name: 'Bambu PLA Basic' }],
  46. },
  47. {
  48. index: 2,
  49. name: 'Plate 2',
  50. has_thumbnail: true,
  51. thumbnail_url: '/api/v1/archives/1/plates/2/thumbnail',
  52. print_time_seconds: 1800,
  53. filament_used_grams: 25.0,
  54. object_count: 2,
  55. objects: ['Base', 'Cover'],
  56. filaments: [{ color: '#ff0000', type: 'PLA', name: 'Red PLA' }],
  57. },
  58. ],
  59. };
  60. const mockSinglePlateResponse = {
  61. is_multi_plate: false,
  62. plates: [
  63. {
  64. index: 1,
  65. name: null,
  66. has_thumbnail: false,
  67. thumbnail_url: null,
  68. print_time_seconds: 7200,
  69. filament_used_grams: 100.0,
  70. object_count: 5,
  71. objects: ['Model 1', 'Model 2', 'Model 3', 'Model 4', 'Model 5'],
  72. filaments: [],
  73. },
  74. ],
  75. };
  76. describe('ModelViewerModal', () => {
  77. const mockOnClose = vi.fn();
  78. beforeEach(() => {
  79. vi.clearAllMocks();
  80. server.use(
  81. http.get('/api/v1/archives/:id/capabilities', () => {
  82. return HttpResponse.json(mockCapabilities);
  83. }),
  84. http.get('/api/v1/archives/:id/plates', () => {
  85. return HttpResponse.json(mockPlatesResponse);
  86. })
  87. );
  88. });
  89. describe('rendering', () => {
  90. it('renders the modal with title', async () => {
  91. render(
  92. <ModelViewerModal
  93. archiveId={1}
  94. title="Test Model.3mf"
  95. onClose={mockOnClose}
  96. />
  97. );
  98. expect(screen.getByText('Test Model.3mf')).toBeInTheDocument();
  99. });
  100. it('renders Open in Slicer button', async () => {
  101. render(
  102. <ModelViewerModal
  103. archiveId={1}
  104. title="Test Model"
  105. onClose={mockOnClose}
  106. />
  107. );
  108. await waitFor(() => {
  109. expect(screen.getByText('Open in Slicer')).toBeInTheDocument();
  110. });
  111. });
  112. it('shows loading spinner while fetching capabilities', () => {
  113. server.use(
  114. http.get('/api/v1/archives/:id/capabilities', async () => {
  115. await new Promise((r) => setTimeout(r, 100));
  116. return HttpResponse.json(mockCapabilities);
  117. })
  118. );
  119. render(
  120. <ModelViewerModal
  121. archiveId={1}
  122. title="Test Model"
  123. onClose={mockOnClose}
  124. />
  125. );
  126. const loader = document.querySelector('.animate-spin');
  127. expect(loader).toBeInTheDocument();
  128. });
  129. });
  130. describe('tabs', () => {
  131. it('renders 3D Model and G-code tabs', async () => {
  132. render(
  133. <ModelViewerModal
  134. archiveId={1}
  135. title="Test Model"
  136. onClose={mockOnClose}
  137. />
  138. );
  139. await waitFor(() => {
  140. expect(screen.getByText('3D Model')).toBeInTheDocument();
  141. expect(screen.getByText('G-code Preview')).toBeInTheDocument();
  142. });
  143. });
  144. it('shows not available label when model is not available', async () => {
  145. server.use(
  146. http.get('/api/v1/archives/:id/capabilities', () => {
  147. return HttpResponse.json({
  148. ...mockCapabilities,
  149. has_model: false,
  150. });
  151. })
  152. );
  153. render(
  154. <ModelViewerModal
  155. archiveId={1}
  156. title="Test Model"
  157. onClose={mockOnClose}
  158. />
  159. );
  160. await waitFor(() => {
  161. expect(screen.getByText('(not available)')).toBeInTheDocument();
  162. });
  163. });
  164. it('shows not sliced label when gcode is not available', async () => {
  165. server.use(
  166. http.get('/api/v1/archives/:id/capabilities', () => {
  167. return HttpResponse.json({
  168. ...mockCapabilities,
  169. has_gcode: false,
  170. });
  171. })
  172. );
  173. render(
  174. <ModelViewerModal
  175. archiveId={1}
  176. title="Test Model"
  177. onClose={mockOnClose}
  178. />
  179. );
  180. await waitFor(() => {
  181. expect(screen.getByText('(not sliced)')).toBeInTheDocument();
  182. });
  183. });
  184. it('disables tab when capability is not available', async () => {
  185. server.use(
  186. http.get('/api/v1/archives/:id/capabilities', () => {
  187. return HttpResponse.json({
  188. ...mockCapabilities,
  189. has_gcode: false,
  190. });
  191. })
  192. );
  193. render(
  194. <ModelViewerModal
  195. archiveId={1}
  196. title="Test Model"
  197. onClose={mockOnClose}
  198. />
  199. );
  200. await waitFor(() => {
  201. const gcodeTab = screen.getByText('G-code Preview').closest('button');
  202. expect(gcodeTab).toBeDisabled();
  203. });
  204. });
  205. });
  206. describe('fullscreen', () => {
  207. it('renders fullscreen toggle button', async () => {
  208. render(
  209. <ModelViewerModal
  210. archiveId={1}
  211. title="Test Model"
  212. onClose={mockOnClose}
  213. />
  214. );
  215. await waitFor(() => {
  216. // Look for the maximize icon button
  217. const buttons = screen.getAllByRole('button');
  218. const fullscreenButton = buttons.find(
  219. (btn) => btn.querySelector('.lucide-maximize-2') || btn.title === 'Enter fullscreen'
  220. );
  221. expect(fullscreenButton).toBeInTheDocument();
  222. });
  223. });
  224. });
  225. describe('object count', () => {
  226. it('displays object count for multi-plate files', async () => {
  227. render(
  228. <ModelViewerModal
  229. archiveId={1}
  230. title="Test Model"
  231. onClose={mockOnClose}
  232. />
  233. );
  234. await waitFor(() => {
  235. // Total objects across both plates = 3 + 2 = 5
  236. // The header shows "All Plates: 5 objects" in a span
  237. const objectCountBadge = screen.getByText(/All Plates.*5 objects/);
  238. expect(objectCountBadge).toBeInTheDocument();
  239. });
  240. });
  241. it('updates object count when plate is selected', async () => {
  242. render(
  243. <ModelViewerModal
  244. archiveId={1}
  245. title="Test Model"
  246. onClose={mockOnClose}
  247. />
  248. );
  249. await waitFor(() => {
  250. expect(screen.getByText('Plate 1')).toBeInTheDocument();
  251. });
  252. // Click on Plate 1
  253. fireEvent.click(screen.getByText('Plate 1'));
  254. await waitFor(() => {
  255. // Plate 1 has 3 objects - header should update to show "Plate 1: 3 objects"
  256. const objectCountBadge = screen.getByText(/Plate 1.*3 objects/);
  257. expect(objectCountBadge).toBeInTheDocument();
  258. });
  259. });
  260. });
  261. describe('plate selector', () => {
  262. it('shows plates panel for multi-plate files', async () => {
  263. render(
  264. <ModelViewerModal
  265. archiveId={1}
  266. title="Test Model"
  267. onClose={mockOnClose}
  268. />
  269. );
  270. await waitFor(() => {
  271. expect(screen.getByText('Plates')).toBeInTheDocument();
  272. // Use getAllByText for "All Plates" since it appears in header and panel
  273. const allPlatesElements = screen.getAllByText('All Plates');
  274. expect(allPlatesElements.length).toBeGreaterThan(0);
  275. expect(screen.getByText('2 plates')).toBeInTheDocument();
  276. });
  277. });
  278. it('shows individual plate buttons', async () => {
  279. render(
  280. <ModelViewerModal
  281. archiveId={1}
  282. title="Test Model"
  283. onClose={mockOnClose}
  284. />
  285. );
  286. await waitFor(() => {
  287. expect(screen.getByText('Plate 1')).toBeInTheDocument();
  288. expect(screen.getByText('Plate 2')).toBeInTheDocument();
  289. });
  290. });
  291. it('shows object count for each plate', async () => {
  292. render(
  293. <ModelViewerModal
  294. archiveId={1}
  295. title="Test Model"
  296. onClose={mockOnClose}
  297. />
  298. );
  299. await waitFor(() => {
  300. // Each plate shows its object count in the grid
  301. expect(screen.getByText('3 objects')).toBeInTheDocument();
  302. expect(screen.getByText('2 objects')).toBeInTheDocument();
  303. });
  304. });
  305. it('hides plates panel for single-plate files', async () => {
  306. server.use(
  307. http.get('/api/v1/archives/:id/plates', () => {
  308. return HttpResponse.json(mockSinglePlateResponse);
  309. })
  310. );
  311. render(
  312. <ModelViewerModal
  313. archiveId={1}
  314. title="Test Model"
  315. onClose={mockOnClose}
  316. />
  317. );
  318. await waitFor(() => {
  319. // Should show object count but not plate selector
  320. expect(screen.getByText(/5 objects/)).toBeInTheDocument();
  321. });
  322. // Plates panel should not be shown for single plate
  323. expect(screen.queryByText('2 plates')).not.toBeInTheDocument();
  324. });
  325. it('selects All Plates by default', async () => {
  326. render(
  327. <ModelViewerModal
  328. archiveId={1}
  329. title="Test Model"
  330. onClose={mockOnClose}
  331. />
  332. );
  333. await waitFor(() => {
  334. // Find the All Plates button in the grid (the one with "2 plates" sibling text)
  335. const platesCountText = screen.getByText('2 plates');
  336. const allPlatesButton = platesCountText.closest('button');
  337. // The selected button should have the green border class
  338. expect(allPlatesButton).toHaveClass('border-bambu-green');
  339. });
  340. });
  341. it('allows plate selection via click', async () => {
  342. render(
  343. <ModelViewerModal
  344. archiveId={1}
  345. title="Test Model"
  346. onClose={mockOnClose}
  347. />
  348. );
  349. await waitFor(() => {
  350. expect(screen.getByText('Plate 1')).toBeInTheDocument();
  351. });
  352. // Click on Plate 1 - this should not throw
  353. const plate1Button = screen.getByText('Plate 1').closest('button');
  354. expect(plate1Button).toBeInTheDocument();
  355. fireEvent.click(plate1Button!);
  356. // After clicking, the header should show Plate 1 info
  357. await waitFor(() => {
  358. expect(screen.getByText(/Plate 1.*3 objects/)).toBeInTheDocument();
  359. });
  360. });
  361. });
  362. describe('close behavior', () => {
  363. it('calls onClose when X button is clicked', async () => {
  364. render(
  365. <ModelViewerModal
  366. archiveId={1}
  367. title="Test Model"
  368. onClose={mockOnClose}
  369. />
  370. );
  371. const closeButton = screen.getAllByRole('button').find(
  372. (btn) => btn.querySelector('.lucide-x')
  373. );
  374. if (closeButton) {
  375. fireEvent.click(closeButton);
  376. expect(mockOnClose).toHaveBeenCalled();
  377. }
  378. });
  379. it('calls onClose when Escape key is pressed', () => {
  380. render(
  381. <ModelViewerModal
  382. archiveId={1}
  383. title="Test Model"
  384. onClose={mockOnClose}
  385. />
  386. );
  387. fireEvent.keyDown(window, { key: 'Escape' });
  388. expect(mockOnClose).toHaveBeenCalled();
  389. });
  390. it('calls onClose when backdrop is clicked', () => {
  391. render(
  392. <ModelViewerModal
  393. archiveId={1}
  394. title="Test Model"
  395. onClose={mockOnClose}
  396. />
  397. );
  398. const backdrop = document.querySelector('.fixed.inset-0');
  399. if (backdrop) {
  400. fireEvent.click(backdrop);
  401. expect(mockOnClose).toHaveBeenCalled();
  402. }
  403. });
  404. });
  405. describe('library file mode', () => {
  406. it('renders for library file', async () => {
  407. server.use(
  408. http.get('/api/v1/library/files/:id/plates', () => {
  409. return HttpResponse.json(mockSinglePlateResponse);
  410. })
  411. );
  412. render(
  413. <ModelViewerModal
  414. libraryFileId={1}
  415. title="Library Model.3mf"
  416. fileType="3mf"
  417. onClose={mockOnClose}
  418. />
  419. );
  420. expect(screen.getByText('Library Model.3mf')).toBeInTheDocument();
  421. await waitFor(() => {
  422. expect(screen.getByText('3D Model')).toBeInTheDocument();
  423. });
  424. });
  425. it('disables Open in Slicer for non-3mf library files', async () => {
  426. render(
  427. <ModelViewerModal
  428. libraryFileId={1}
  429. title="Model.stl"
  430. fileType="stl"
  431. onClose={mockOnClose}
  432. />
  433. );
  434. await waitFor(() => {
  435. const slicerButton = screen.getByText('Open in Slicer').closest('button');
  436. expect(slicerButton).toBeDisabled();
  437. });
  438. });
  439. });
  440. });