FileManagerModal.test.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601
  1. /**
  2. * Tests for the FileManagerModal component.
  3. * Tests file browsing, selection, navigation, and file operations.
  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 { FileManagerModal } from '../../components/FileManagerModal';
  9. import { http, HttpResponse } from 'msw';
  10. import { server } from '../mocks/server';
  11. const mockFiles = [
  12. {
  13. name: 'cache',
  14. path: '/cache',
  15. size: 0,
  16. is_directory: true,
  17. mtime: '2024-01-15T10:00:00Z',
  18. },
  19. {
  20. name: 'model',
  21. path: '/model',
  22. size: 0,
  23. is_directory: true,
  24. mtime: '2024-01-15T10:00:00Z',
  25. },
  26. {
  27. name: 'benchy.3mf',
  28. path: '/benchy.3mf',
  29. size: 1048575,
  30. is_directory: false,
  31. mtime: '2024-01-15T10:00:00Z',
  32. },
  33. {
  34. name: 'print_job.gcode',
  35. path: '/print_job.gcode',
  36. size: 2048000,
  37. is_directory: false,
  38. mtime: '2024-01-14T10:00:00Z',
  39. },
  40. ];
  41. const mockStorage = {
  42. used_bytes: 1073741824, // 1 GB
  43. free_bytes: 3221225472, // 3 GB
  44. };
  45. describe('FileManagerModal', () => {
  46. const mockOnClose = vi.fn();
  47. beforeEach(() => {
  48. vi.clearAllMocks();
  49. server.use(
  50. http.get('/api/v1/printers/:id/files', () => {
  51. return HttpResponse.json({ files: mockFiles });
  52. }),
  53. http.get('/api/v1/printers/:id/storage', () => {
  54. return HttpResponse.json(mockStorage);
  55. }),
  56. http.delete('/api/v1/printers/:id/files', () => {
  57. return HttpResponse.json({ success: true });
  58. })
  59. );
  60. });
  61. describe('rendering', () => {
  62. it('renders the modal with header', async () => {
  63. render(
  64. <FileManagerModal
  65. printerId={1}
  66. printerName="X1 Carbon"
  67. onClose={mockOnClose}
  68. />
  69. );
  70. expect(screen.getByText('File Manager')).toBeInTheDocument();
  71. expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
  72. });
  73. it('renders storage info', async () => {
  74. render(
  75. <FileManagerModal
  76. printerId={1}
  77. printerName="X1 Carbon"
  78. onClose={mockOnClose}
  79. />
  80. );
  81. await waitFor(() => {
  82. expect(screen.getByText(/Used:/)).toBeInTheDocument();
  83. expect(screen.getByText(/Free:/)).toBeInTheDocument();
  84. });
  85. });
  86. it('renders quick navigation buttons', () => {
  87. render(
  88. <FileManagerModal
  89. printerId={1}
  90. printerName="X1 Carbon"
  91. onClose={mockOnClose}
  92. />
  93. );
  94. expect(screen.getByText('Root')).toBeInTheDocument();
  95. expect(screen.getByText('Cache')).toBeInTheDocument();
  96. expect(screen.getByText('Models')).toBeInTheDocument();
  97. expect(screen.getByText('Timelapse')).toBeInTheDocument();
  98. });
  99. it('renders file list', async () => {
  100. render(
  101. <FileManagerModal
  102. printerId={1}
  103. printerName="X1 Carbon"
  104. onClose={mockOnClose}
  105. />
  106. );
  107. await waitFor(() => {
  108. expect(screen.getByText('cache')).toBeInTheDocument();
  109. expect(screen.getByText('model')).toBeInTheDocument();
  110. expect(screen.getByText('benchy.3mf')).toBeInTheDocument();
  111. expect(screen.getByText('print_job.gcode')).toBeInTheDocument();
  112. });
  113. });
  114. it('shows file sizes for files', async () => {
  115. render(
  116. <FileManagerModal
  117. printerId={1}
  118. printerName="X1 Carbon"
  119. onClose={mockOnClose}
  120. />
  121. );
  122. await waitFor(() => {
  123. // 1024000 bytes = 1024.0 KB
  124. expect(screen.getByText('1024.0 KB')).toBeInTheDocument();
  125. });
  126. });
  127. });
  128. describe('navigation', () => {
  129. it('navigates into a folder when clicked', async () => {
  130. server.use(
  131. http.get('/api/v1/printers/:id/files', ({ request }) => {
  132. const url = new URL(request.url);
  133. const path = url.searchParams.get('path');
  134. if (path === '/cache') {
  135. return HttpResponse.json({
  136. files: [
  137. { name: 'temp.dat', path: '/cache/temp.dat', size: 512, is_directory: false },
  138. ],
  139. });
  140. }
  141. return HttpResponse.json({ files: mockFiles });
  142. })
  143. );
  144. render(
  145. <FileManagerModal
  146. printerId={1}
  147. printerName="X1 Carbon"
  148. onClose={mockOnClose}
  149. />
  150. );
  151. await waitFor(() => {
  152. expect(screen.getByText('cache')).toBeInTheDocument();
  153. });
  154. // Click on cache folder
  155. fireEvent.click(screen.getByText('cache'));
  156. await waitFor(() => {
  157. expect(screen.getByText('temp.dat')).toBeInTheDocument();
  158. });
  159. });
  160. it('shows current path', async () => {
  161. render(
  162. <FileManagerModal
  163. printerId={1}
  164. printerName="X1 Carbon"
  165. onClose={mockOnClose}
  166. />
  167. );
  168. expect(screen.getByText('/')).toBeInTheDocument();
  169. });
  170. });
  171. describe('file selection', () => {
  172. it('selects a file when checkbox is clicked', async () => {
  173. render(
  174. <FileManagerModal
  175. printerId={1}
  176. printerName="X1 Carbon"
  177. onClose={mockOnClose}
  178. />
  179. );
  180. await waitFor(() => {
  181. expect(screen.getByText('benchy.3mf')).toBeInTheDocument();
  182. });
  183. // Find and click a checkbox (files have checkboxes, directories don't)
  184. const checkboxes = screen.getAllByRole('button').filter(btn =>
  185. btn.querySelector('svg')?.classList.contains('lucide-square')
  186. );
  187. if (checkboxes.length > 0) {
  188. fireEvent.click(checkboxes[0]);
  189. await waitFor(() => {
  190. expect(screen.getByText('1 selected')).toBeInTheDocument();
  191. });
  192. }
  193. });
  194. it('selects the visible range between a click and a shift-click', async () => {
  195. server.use(
  196. http.get('/api/v1/printers/:id/files', () => HttpResponse.json({
  197. files: [
  198. ...mockFiles.filter(file => file.is_directory),
  199. { name: 'alpha.gcode', path: '/alpha.gcode', size: 1, is_directory: false, mtime: null },
  200. { name: 'bravo.gcode', path: '/bravo.gcode', size: 2, is_directory: false, mtime: null },
  201. { name: 'charlie.gcode', path: '/charlie.gcode', size: 3, is_directory: false, mtime: null },
  202. { name: 'delta.gcode', path: '/delta.gcode', size: 4, is_directory: false, mtime: null },
  203. ],
  204. })),
  205. );
  206. render(
  207. <FileManagerModal
  208. printerId={1}
  209. printerName="X1 Carbon"
  210. onClose={mockOnClose}
  211. />
  212. );
  213. fireEvent.click(await screen.findByRole('button', { name: 'Select alpha.gcode' }));
  214. fireEvent.click(screen.getByRole('button', { name: 'Select charlie.gcode' }), { shiftKey: true });
  215. expect(await screen.findByText('3 selected')).toBeInTheDocument();
  216. expect(screen.getByRole('button', { name: 'Deselect alpha.gcode' })).toBeInTheDocument();
  217. expect(screen.getByRole('button', { name: 'Deselect bravo.gcode' })).toBeInTheDocument();
  218. expect(screen.getByRole('button', { name: 'Deselect charlie.gcode' })).toBeInTheDocument();
  219. expect(screen.getByRole('button', { name: 'Select delta.gcode' })).toBeInTheDocument();
  220. });
  221. it('always selects a shift-clicked range even when the target is already selected', async () => {
  222. server.use(
  223. http.get('/api/v1/printers/:id/files', () => HttpResponse.json({
  224. files: [
  225. { name: 'alpha.gcode', path: '/alpha.gcode', size: 1, is_directory: false, mtime: null },
  226. { name: 'bravo.gcode', path: '/bravo.gcode', size: 2, is_directory: false, mtime: null },
  227. { name: 'charlie.gcode', path: '/charlie.gcode', size: 3, is_directory: false, mtime: null },
  228. ],
  229. })),
  230. );
  231. render(
  232. <FileManagerModal
  233. printerId={1}
  234. printerName="X1 Carbon"
  235. onClose={mockOnClose}
  236. />
  237. );
  238. fireEvent.click(await screen.findByRole('button', { name: 'Select alpha.gcode' }));
  239. fireEvent.click(screen.getByRole('button', { name: 'Select charlie.gcode' }));
  240. fireEvent.click(screen.getByRole('button', { name: 'Deselect alpha.gcode' }), { shiftKey: true });
  241. expect(await screen.findByText('3 selected')).toBeInTheDocument();
  242. expect(screen.getByRole('button', { name: 'Deselect bravo.gcode' })).toBeInTheDocument();
  243. });
  244. it('enables download button when files are selected', async () => {
  245. render(
  246. <FileManagerModal
  247. printerId={1}
  248. printerName="X1 Carbon"
  249. onClose={mockOnClose}
  250. />
  251. );
  252. await waitFor(() => {
  253. expect(screen.getByText('benchy.3mf')).toBeInTheDocument();
  254. });
  255. // Download button should be disabled initially
  256. const downloadButton = screen.getByRole('button', { name: /Download/i });
  257. expect(downloadButton).toBeDisabled();
  258. });
  259. it('shows Select All button when files exist', async () => {
  260. render(
  261. <FileManagerModal
  262. printerId={1}
  263. printerName="X1 Carbon"
  264. onClose={mockOnClose}
  265. />
  266. );
  267. await waitFor(() => {
  268. expect(screen.getByText('Select All')).toBeInTheDocument();
  269. });
  270. });
  271. it('starts a preparation job, uses a native download link, and reports partial results', async () => {
  272. let preparation: {
  273. paths: string[];
  274. sizes: Record<string, number>;
  275. filename: string;
  276. as_zip: boolean;
  277. } | null = null;
  278. server.use(
  279. http.post('/api/v1/printers/:id/files/download-job', async ({ request }) => {
  280. preparation = await request.json() as typeof preparation;
  281. return HttpResponse.json({
  282. job_id: 'job-id',
  283. state: 'ready',
  284. token: 'download-token',
  285. requested: 2,
  286. successful: 1,
  287. failed: 1,
  288. message: null,
  289. });
  290. }),
  291. );
  292. const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined);
  293. render(
  294. <FileManagerModal
  295. printerId={1}
  296. printerName="X1 Carbon"
  297. onClose={mockOnClose}
  298. />
  299. );
  300. await waitFor(() => expect(screen.getByText('Select All')).toBeInTheDocument());
  301. fireEvent.click(screen.getByText('Select All'));
  302. fireEvent.click(screen.getByRole('button', { name: /Download \(2\)/i }));
  303. await waitFor(() => expect(clickSpy).toHaveBeenCalledOnce());
  304. expect(preparation).toEqual({
  305. paths: ['/benchy.3mf', '/print_job.gcode'],
  306. sizes: {
  307. '/benchy.3mf': 1048575,
  308. '/print_job.gcode': 2048000,
  309. },
  310. filename: 'X1_Carbon-files.zip',
  311. as_zip: true,
  312. });
  313. expect(document.querySelector('a')).toBeNull();
  314. expect(await screen.findByText(
  315. 'ZIP download started with 1 of 2 files; the rest could not be retrieved',
  316. )).toBeInTheDocument();
  317. clickSpy.mockRestore();
  318. });
  319. });
  320. describe('search and filter', () => {
  321. it('renders search input', () => {
  322. render(
  323. <FileManagerModal
  324. printerId={1}
  325. printerName="X1 Carbon"
  326. onClose={mockOnClose}
  327. />
  328. );
  329. expect(screen.getByPlaceholderText('Filter files...')).toBeInTheDocument();
  330. });
  331. it('filters files based on search query', async () => {
  332. render(
  333. <FileManagerModal
  334. printerId={1}
  335. printerName="X1 Carbon"
  336. onClose={mockOnClose}
  337. />
  338. );
  339. await waitFor(() => {
  340. expect(screen.getByText('benchy.3mf')).toBeInTheDocument();
  341. });
  342. const searchInput = screen.getByPlaceholderText('Filter files...');
  343. fireEvent.change(searchInput, { target: { value: 'benchy' } });
  344. await waitFor(() => {
  345. expect(screen.getByText('benchy.3mf')).toBeInTheDocument();
  346. expect(screen.queryByText('print_job.gcode')).not.toBeInTheDocument();
  347. });
  348. });
  349. it('selects all files from the shared visible-file filter', async () => {
  350. render(
  351. <FileManagerModal
  352. printerId={1}
  353. printerName="X1 Carbon"
  354. onClose={mockOnClose}
  355. />
  356. );
  357. await waitFor(() => expect(screen.getByText('benchy.3mf')).toBeInTheDocument());
  358. fireEvent.change(screen.getByPlaceholderText('Filter files...'), { target: { value: 'benchy' } });
  359. fireEvent.click(screen.getByText('Select All'));
  360. expect(screen.getByText('1 selected')).toBeInTheDocument();
  361. expect(screen.getByRole('button', { name: 'Download' })).toBeEnabled();
  362. });
  363. it('keeps the selection when a refresh cannot reach the printer', async () => {
  364. render(
  365. <FileManagerModal printerId={1} printerName="X1 Carbon" onClose={mockOnClose} />
  366. );
  367. fireEvent.click(await screen.findByRole('button', { name: 'Select benchy.3mf' }));
  368. expect(screen.getByText('1 selected')).toBeInTheDocument();
  369. // An unreachable printer answers with an empty list plus a warning. That
  370. // is not the same statement as "those files are gone", and it must not
  371. // throw away a selection the user made moments ago.
  372. server.use(
  373. http.get('/api/v1/printers/:id/files', () =>
  374. HttpResponse.json({ files: [], warnings: ['printer_unavailable'] })
  375. )
  376. );
  377. fireEvent.click(screen.getByRole('button', { name: 'Refresh' }));
  378. expect(await screen.findByText(
  379. 'The printer file service is unavailable. Try again when the printer is reachable.',
  380. )).toBeInTheDocument();
  381. expect(screen.getByText('1 selected')).toBeInTheDocument();
  382. });
  383. it('drops hidden selections when the filter changes', async () => {
  384. render(
  385. <FileManagerModal printerId={1} printerName="X1 Carbon" onClose={mockOnClose} />
  386. );
  387. fireEvent.click(await screen.findByRole('button', { name: 'Select benchy.3mf' }));
  388. expect(screen.getByText('1 selected')).toBeInTheDocument();
  389. fireEvent.change(screen.getByPlaceholderText('Filter files...'), { target: { value: 'gcode' } });
  390. await waitFor(() => expect(screen.getByRole('button', { name: 'Download' })).toBeDisabled());
  391. });
  392. });
  393. describe('sorting', () => {
  394. it('renders sort dropdown', () => {
  395. render(
  396. <FileManagerModal
  397. printerId={1}
  398. printerName="X1 Carbon"
  399. onClose={mockOnClose}
  400. />
  401. );
  402. expect(screen.getByRole('combobox')).toBeInTheDocument();
  403. });
  404. it('has sort options available', () => {
  405. render(
  406. <FileManagerModal
  407. printerId={1}
  408. printerName="X1 Carbon"
  409. onClose={mockOnClose}
  410. />
  411. );
  412. const sortSelect = screen.getByRole('combobox');
  413. expect(sortSelect).toBeInTheDocument();
  414. // Check that options exist
  415. expect(screen.getByText('Name (A-Z)')).toBeInTheDocument();
  416. });
  417. });
  418. describe('close behavior', () => {
  419. it('calls onClose when X button is clicked', async () => {
  420. render(
  421. <FileManagerModal
  422. printerId={1}
  423. printerName="X1 Carbon"
  424. onClose={mockOnClose}
  425. />
  426. );
  427. const closeButton = screen.getAllByRole('button').find(btn =>
  428. btn.querySelector('.lucide-x')
  429. );
  430. if (closeButton) {
  431. fireEvent.click(closeButton);
  432. expect(mockOnClose).toHaveBeenCalled();
  433. }
  434. });
  435. it('calls onClose when clicking outside the modal', () => {
  436. render(
  437. <FileManagerModal
  438. printerId={1}
  439. printerName="X1 Carbon"
  440. onClose={mockOnClose}
  441. />
  442. );
  443. // Click on the backdrop
  444. const backdrop = document.querySelector('.fixed.inset-0');
  445. if (backdrop) {
  446. fireEvent.click(backdrop);
  447. expect(mockOnClose).toHaveBeenCalled();
  448. }
  449. });
  450. it('calls onClose when Escape key is pressed', () => {
  451. render(
  452. <FileManagerModal
  453. printerId={1}
  454. printerName="X1 Carbon"
  455. onClose={mockOnClose}
  456. />
  457. );
  458. fireEvent.keyDown(window, { key: 'Escape' });
  459. expect(mockOnClose).toHaveBeenCalled();
  460. });
  461. });
  462. describe('empty state', () => {
  463. it('shows empty message when directory has no files', async () => {
  464. server.use(
  465. http.get('/api/v1/printers/:id/files', () => {
  466. return HttpResponse.json({ files: [] });
  467. })
  468. );
  469. render(
  470. <FileManagerModal
  471. printerId={1}
  472. printerName="X1 Carbon"
  473. onClose={mockOnClose}
  474. />
  475. );
  476. await waitFor(() => {
  477. expect(screen.getByText('No files on printer')).toBeInTheDocument();
  478. });
  479. });
  480. it('distinguishes an unreachable printer from an empty directory', async () => {
  481. server.use(
  482. http.get('/api/v1/printers/:id/files', () => {
  483. return HttpResponse.json({ files: [], warnings: ['printer_unavailable'] });
  484. })
  485. );
  486. render(
  487. <FileManagerModal
  488. printerId={1}
  489. printerName="X1 Carbon"
  490. onClose={mockOnClose}
  491. />
  492. );
  493. expect(await screen.findByText(
  494. 'The printer file service is unavailable. Try again when the printer is reachable.',
  495. )).toBeInTheDocument();
  496. expect(screen.queryByText('No files on printer')).not.toBeInTheDocument();
  497. });
  498. });
  499. describe('loading state', () => {
  500. it('shows loading spinner while fetching files', () => {
  501. // Delay the response to see loading state
  502. server.use(
  503. http.get('/api/v1/printers/:id/files', async () => {
  504. await new Promise((r) => setTimeout(r, 100));
  505. return HttpResponse.json({ files: mockFiles });
  506. })
  507. );
  508. render(
  509. <FileManagerModal
  510. printerId={1}
  511. printerName="X1 Carbon"
  512. onClose={mockOnClose}
  513. />
  514. );
  515. // The loader should be present initially
  516. const loader = document.querySelector('.animate-spin');
  517. expect(loader).toBeInTheDocument();
  518. });
  519. });
  520. });