ModelViewerModal.test.tsx 14 KB

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