FileManagerPage.test.tsx 45 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386
  1. /**
  2. * Tests for the FileManagerPage component.
  3. */
  4. import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
  5. import { screen, waitFor, within } from '@testing-library/react';
  6. import userEvent from '@testing-library/user-event';
  7. import { render } from '../utils';
  8. import { FileManagerPage } from '../../pages/FileManagerPage';
  9. import { openInSlicer } from '../../utils/slicer';
  10. import { setAuthToken } from '../../api/client';
  11. import { http, HttpResponse } from 'msw';
  12. import { server } from '../mocks/server';
  13. // Only the protocol-handler launch is stubbed — it would navigate the jsdom
  14. // window. Everything else in the module is a pure predicate, so keep the real
  15. // implementations: isSliceableFilename decides which rows even offer the
  16. // action these tests click.
  17. vi.mock('../../utils/slicer', async (importOriginal) => ({
  18. ...(await importOriginal<typeof import('../../utils/slicer')>()),
  19. openInSlicer: vi.fn(),
  20. }));
  21. vi.mock('../../components/SliceModal', () => ({
  22. SliceModal: ({ source }: { source: { filename: string } }) => (
  23. <div data-testid="slice-modal">{source.filename}</div>
  24. ),
  25. }));
  26. // Mock data
  27. const mockFolders = [
  28. {
  29. id: 1,
  30. name: 'Functional Parts',
  31. parent_id: null,
  32. file_count: 5,
  33. project_id: null,
  34. archive_id: null,
  35. project_name: null,
  36. archive_name: null,
  37. // #2680: distinctive year so the folder-pane display test can assert on it
  38. // without colliding with the file mtimes below.
  39. latest_activity_at: '2031-04-05T10:00:00Z',
  40. children: [
  41. {
  42. id: 2,
  43. name: 'Brackets',
  44. parent_id: 1,
  45. file_count: 3,
  46. project_id: null,
  47. archive_id: null,
  48. project_name: null,
  49. archive_name: null,
  50. latest_activity_at: '2032-06-07T10:00:00Z',
  51. children: [],
  52. },
  53. ],
  54. },
  55. {
  56. id: 3,
  57. name: 'Art Projects',
  58. parent_id: null,
  59. file_count: 2,
  60. project_id: 1,
  61. archive_id: null,
  62. project_name: 'My Art Project',
  63. archive_name: null,
  64. // No activity timestamp — must render no date line rather than an
  65. // "Invalid Date" placeholder.
  66. latest_activity_at: null,
  67. children: [],
  68. },
  69. ];
  70. const mockFiles = [
  71. {
  72. id: 1,
  73. filename: 'benchy.gcode.3mf',
  74. file_path: '/library/benchy.gcode.3mf',
  75. file_size: 1048576,
  76. file_type: '3mf',
  77. folder_id: null,
  78. thumbnail_path: '/thumbnails/1.png',
  79. print_name: 'Benchy',
  80. print_time_seconds: 3600,
  81. print_count: 5,
  82. duplicate_count: 0,
  83. created_at: '2024-01-01T00:00:00Z',
  84. // #2680: real on-disk mtime in a distinctive year so the display test can
  85. // prove fs_modified_at is preferred over created_at (2024).
  86. fs_modified_at: '2030-06-15T12:00:00Z',
  87. },
  88. {
  89. id: 2,
  90. filename: 'bracket.stl',
  91. file_path: '/library/bracket.stl',
  92. file_size: 524288,
  93. file_type: 'stl',
  94. folder_id: null,
  95. thumbnail_path: null,
  96. print_name: null,
  97. print_time_seconds: null,
  98. print_count: 0,
  99. duplicate_count: 2,
  100. created_at: '2024-01-02T00:00:00Z',
  101. },
  102. {
  103. id: 3,
  104. filename: 'cube.gcode.3mf',
  105. file_path: '/library/cube.gcode.3mf',
  106. file_size: 2048576,
  107. file_type: '3mf',
  108. folder_id: null,
  109. thumbnail_path: '/thumbnails/3.png',
  110. print_name: 'Cube',
  111. print_time_seconds: 1800,
  112. print_count: 2,
  113. duplicate_count: 0,
  114. created_at: '2024-01-03T00:00:00Z',
  115. },
  116. ];
  117. const mockStats = {
  118. total_files: 10,
  119. total_folders: 3,
  120. total_size_bytes: 104857600,
  121. disk_free_bytes: 10737418240,
  122. disk_total_bytes: 107374182400,
  123. };
  124. describe('FileManagerPage', () => {
  125. beforeEach(() => {
  126. // Clear localStorage to ensure consistent view mode
  127. localStorage.clear();
  128. server.use(
  129. http.get('/api/v1/library/folders', () => {
  130. return HttpResponse.json(mockFolders);
  131. }),
  132. http.get('/api/v1/library/files', () => {
  133. return HttpResponse.json(mockFiles);
  134. }),
  135. http.get('/api/v1/library/stats', () => {
  136. return HttpResponse.json(mockStats);
  137. }),
  138. http.get('/api/v1/settings/', () => {
  139. return HttpResponse.json({
  140. check_updates: false,
  141. check_printer_firmware: false,
  142. library_disk_warning_gb: 5,
  143. });
  144. }),
  145. http.post('/api/v1/library/folders', async ({ request }) => {
  146. const body = await request.json() as { name: string };
  147. return HttpResponse.json({ id: 4, name: body.name, parent_id: null, children: [] });
  148. }),
  149. http.delete('/api/v1/library/folders/:id', () => {
  150. return HttpResponse.json({ success: true });
  151. }),
  152. http.delete('/api/v1/library/files/:id', () => {
  153. return HttpResponse.json({ success: true });
  154. }),
  155. http.post('/api/v1/library/files/move', () => {
  156. return HttpResponse.json({ success: true });
  157. }),
  158. http.post('/api/v1/library/files/add-to-queue', () => {
  159. return HttpResponse.json({ added: [{ file_id: 1, queue_id: 1 }], errors: [] });
  160. }),
  161. http.get('/api/v1/projects/', () => {
  162. return HttpResponse.json([{ id: 1, name: 'Test Project', color: '#00ae42' }]);
  163. }),
  164. http.get('/api/v1/archives/', () => {
  165. return HttpResponse.json([{ id: 1, print_name: 'Test Archive', filename: 'test.3mf' }]);
  166. })
  167. );
  168. });
  169. describe('rendering', () => {
  170. it('renders the page title', async () => {
  171. render(<FileManagerPage />);
  172. await waitFor(() => {
  173. expect(screen.getByText('File Manager')).toBeInTheDocument();
  174. });
  175. });
  176. it('renders the page description', async () => {
  177. render(<FileManagerPage />);
  178. await waitFor(() => {
  179. expect(screen.getByText('Organize and manage your print files')).toBeInTheDocument();
  180. });
  181. });
  182. it('shows New Folder button', async () => {
  183. render(<FileManagerPage />);
  184. await waitFor(() => {
  185. expect(screen.getByText('New Folder')).toBeInTheDocument();
  186. });
  187. });
  188. it('shows Upload button', async () => {
  189. render(<FileManagerPage />);
  190. await waitFor(() => {
  191. expect(screen.getByText('Upload')).toBeInTheDocument();
  192. });
  193. });
  194. });
  195. describe('stats display', () => {
  196. it('shows file count', async () => {
  197. render(<FileManagerPage />);
  198. await waitFor(() => {
  199. expect(screen.getByText('Files:')).toBeInTheDocument();
  200. expect(screen.getByText('10')).toBeInTheDocument();
  201. });
  202. });
  203. it('shows folder count', async () => {
  204. render(<FileManagerPage />);
  205. await waitFor(() => {
  206. expect(screen.getByText('Folders:')).toBeInTheDocument();
  207. // Folder count appears multiple places, just verify the label is present
  208. const foldersLabel = screen.getByText('Folders:');
  209. expect(foldersLabel.nextElementSibling?.textContent).toBe('3');
  210. });
  211. });
  212. it('shows total size', async () => {
  213. render(<FileManagerPage />);
  214. await waitFor(() => {
  215. expect(screen.getByText('Size:')).toBeInTheDocument();
  216. expect(screen.getByText('100.0 MB')).toBeInTheDocument();
  217. });
  218. });
  219. it('shows free space', async () => {
  220. render(<FileManagerPage />);
  221. await waitFor(() => {
  222. expect(screen.getByText('Free:')).toBeInTheDocument();
  223. });
  224. });
  225. });
  226. describe('folder sidebar', () => {
  227. it('shows All Files option', async () => {
  228. render(<FileManagerPage />);
  229. await waitFor(() => {
  230. expect(screen.getByText('All Files')).toBeInTheDocument();
  231. });
  232. });
  233. it('shows folder tree', async () => {
  234. render(<FileManagerPage />);
  235. await waitFor(() => {
  236. expect(screen.getByText('Functional Parts')).toBeInTheDocument();
  237. expect(screen.getByText('Art Projects')).toBeInTheDocument();
  238. });
  239. });
  240. it('shows nested folders', async () => {
  241. render(<FileManagerPage />);
  242. await waitFor(() => {
  243. expect(screen.getByText('Brackets')).toBeInTheDocument();
  244. });
  245. });
  246. it('shows linked folder indicator', async () => {
  247. render(<FileManagerPage />);
  248. await waitFor(() => {
  249. // Art Projects has a project_id
  250. expect(screen.getByText('Art Projects')).toBeInTheDocument();
  251. });
  252. });
  253. });
  254. describe('file display', () => {
  255. it('shows files in grid', async () => {
  256. render(<FileManagerPage />);
  257. await waitFor(() => {
  258. expect(screen.getByText('Benchy')).toBeInTheDocument();
  259. });
  260. });
  261. it('shows file type badges', async () => {
  262. render(<FileManagerPage />);
  263. await waitFor(() => {
  264. // File type badges show uppercase type
  265. expect(screen.getAllByText('3MF').length).toBeGreaterThan(0);
  266. expect(screen.getAllByText('STL').length).toBeGreaterThan(0);
  267. });
  268. });
  269. it('shows print count', async () => {
  270. render(<FileManagerPage />);
  271. await waitFor(() => {
  272. expect(screen.getByText('Printed 5x')).toBeInTheDocument();
  273. });
  274. });
  275. it('shows duplicate badge', async () => {
  276. render(<FileManagerPage />);
  277. await waitFor(() => {
  278. // Duplicate badge shows count, there may be multiple "2"s on the page
  279. // so we check that at least one element with "2" exists
  280. const elements = screen.getAllByText('2');
  281. expect(elements.length).toBeGreaterThan(0);
  282. });
  283. });
  284. });
  285. describe('view modes', () => {
  286. it('has grid view button', async () => {
  287. render(<FileManagerPage />);
  288. await waitFor(() => {
  289. expect(screen.getByTitle('Grid view')).toBeInTheDocument();
  290. });
  291. });
  292. it('has list view button', async () => {
  293. render(<FileManagerPage />);
  294. await waitFor(() => {
  295. expect(screen.getByTitle('List view')).toBeInTheDocument();
  296. });
  297. });
  298. it('can switch to list view', async () => {
  299. const user = userEvent.setup();
  300. render(<FileManagerPage />);
  301. // Wait for files to load first
  302. await waitFor(() => {
  303. expect(screen.getByText('Benchy')).toBeInTheDocument();
  304. });
  305. // Both view mode buttons should be present and clickable
  306. const gridButton = screen.getByTitle('Grid view');
  307. const listButton = screen.getByTitle('List view');
  308. expect(gridButton).toBeInTheDocument();
  309. expect(listButton).toBeInTheDocument();
  310. // Click list view button - verify no errors occur
  311. await user.click(listButton);
  312. // Clicking grid button should also work
  313. await user.click(gridButton);
  314. // Verify files are still displayed after toggling
  315. expect(screen.getByText('Benchy')).toBeInTheDocument();
  316. });
  317. });
  318. describe('search and filter', () => {
  319. it('has search input', async () => {
  320. render(<FileManagerPage />);
  321. await waitFor(() => {
  322. expect(screen.getByPlaceholderText('Search files...')).toBeInTheDocument();
  323. });
  324. });
  325. it('has type filter', async () => {
  326. render(<FileManagerPage />);
  327. await waitFor(() => {
  328. expect(screen.getByText('All types')).toBeInTheDocument();
  329. });
  330. });
  331. it('has sort options', async () => {
  332. render(<FileManagerPage />);
  333. await waitFor(() => {
  334. // Sort dropdown should show Name as default option (persisted to localStorage)
  335. expect(screen.getByDisplayValue('Name')).toBeInTheDocument();
  336. });
  337. });
  338. });
  339. describe('selection', () => {
  340. it('shows select all button', async () => {
  341. render(<FileManagerPage />);
  342. await waitFor(() => {
  343. expect(screen.getByText('Select All')).toBeInTheDocument();
  344. });
  345. });
  346. it('can select files', async () => {
  347. const user = userEvent.setup();
  348. render(<FileManagerPage />);
  349. await waitFor(() => {
  350. expect(screen.getByText('Benchy')).toBeInTheDocument();
  351. });
  352. // Click on the file card to select it
  353. const fileCard = screen.getByText('Benchy').closest('div[class*="cursor-pointer"]');
  354. if (fileCard) {
  355. await user.click(fileCard);
  356. }
  357. await waitFor(() => {
  358. expect(screen.getByText('1 selected')).toBeInTheDocument();
  359. });
  360. });
  361. it('shows bulk actions when files selected', async () => {
  362. const user = userEvent.setup();
  363. render(<FileManagerPage />);
  364. await waitFor(() => {
  365. expect(screen.getByText('Select All')).toBeInTheDocument();
  366. });
  367. await user.click(screen.getByText('Select All'));
  368. await waitFor(() => {
  369. expect(screen.getByText('Move')).toBeInTheDocument();
  370. expect(screen.getByText('Delete')).toBeInTheDocument();
  371. });
  372. });
  373. });
  374. describe('new folder modal', () => {
  375. it('opens new folder modal', async () => {
  376. const user = userEvent.setup();
  377. render(<FileManagerPage />);
  378. await waitFor(() => {
  379. expect(screen.getByText('New Folder')).toBeInTheDocument();
  380. });
  381. await user.click(screen.getByText('New Folder'));
  382. await waitFor(() => {
  383. expect(screen.getByText('Folder Name')).toBeInTheDocument();
  384. expect(screen.getByPlaceholderText('e.g., Functional Parts')).toBeInTheDocument();
  385. });
  386. });
  387. it('can create a folder', async () => {
  388. const user = userEvent.setup();
  389. render(<FileManagerPage />);
  390. await waitFor(() => {
  391. expect(screen.getByText('New Folder')).toBeInTheDocument();
  392. });
  393. await user.click(screen.getByText('New Folder'));
  394. await waitFor(() => {
  395. expect(screen.getByPlaceholderText('e.g., Functional Parts')).toBeInTheDocument();
  396. });
  397. const input = screen.getByPlaceholderText('e.g., Functional Parts');
  398. await user.type(input, 'My New Folder');
  399. const createButton = screen.getByRole('button', { name: 'Create' });
  400. await user.click(createButton);
  401. // Modal should close after creation
  402. await waitFor(() => {
  403. expect(screen.queryByText('Folder Name')).not.toBeInTheDocument();
  404. });
  405. });
  406. });
  407. describe('empty state', () => {
  408. it('shows empty state when no files', async () => {
  409. server.use(
  410. http.get('/api/v1/library/files', () => {
  411. return HttpResponse.json([]);
  412. })
  413. );
  414. render(<FileManagerPage />);
  415. await waitFor(() => {
  416. expect(screen.getByText('No files yet')).toBeInTheDocument();
  417. expect(screen.getByText('Upload Files')).toBeInTheDocument();
  418. });
  419. });
  420. });
  421. describe('bulk-action print button', () => {
  422. // PR #1625 consolidated print actions: the old single-file-selected
  423. // "Schedule" button now opens the unified PrintModal (which carries
  424. // schedule options inside). The bulk-action toolbar shows a single
  425. // "Print" button only when exactly one sliced file is selected, and
  426. // hides it for multi-selection. The button is targeted by its accessible
  427. // name ("Print") + role to disambiguate from the file-card dropdown's
  428. // own Print entry, which stays collapsed unless its kebab is opened.
  429. it('shows a Print button in the bulk toolbar when one sliced file is selected', async () => {
  430. const user = userEvent.setup();
  431. render(<FileManagerPage />);
  432. await waitFor(() => {
  433. expect(screen.getByText('Benchy')).toBeInTheDocument();
  434. });
  435. // Select a sliced file (benchy.gcode.3mf) by clicking on its card
  436. const fileCard = screen.getByText('Benchy').closest('div[class*="cursor-pointer"]');
  437. if (fileCard) {
  438. await user.click(fileCard);
  439. }
  440. await waitFor(() => {
  441. expect(screen.getByRole('button', { name: /^Print$/ })).toBeInTheDocument();
  442. });
  443. });
  444. it('hides the bulk Print button when multiple files are selected', async () => {
  445. const user = userEvent.setup();
  446. render(<FileManagerPage />);
  447. await waitFor(() => {
  448. expect(screen.getByText('Select All')).toBeInTheDocument();
  449. });
  450. // Select all files
  451. await user.click(screen.getByText('Select All'));
  452. await waitFor(() => {
  453. expect(screen.queryByRole('button', { name: /^Print$/ })).not.toBeInTheDocument();
  454. });
  455. });
  456. });
  457. describe('STL thumbnail generation', () => {
  458. it('shows Generate Thumbnails button', async () => {
  459. render(<FileManagerPage />);
  460. await waitFor(() => {
  461. expect(screen.getByText('Generate Thumbnails')).toBeInTheDocument();
  462. });
  463. });
  464. it('Generate Thumbnails button has correct title', async () => {
  465. render(<FileManagerPage />);
  466. await waitFor(() => {
  467. const button = screen.getByTitle('Generate thumbnails for STL files missing them');
  468. expect(button).toBeInTheDocument();
  469. });
  470. });
  471. it('can click Generate Thumbnails button', async () => {
  472. const user = userEvent.setup();
  473. server.use(
  474. http.post('/api/v1/library/generate-stl-thumbnails', () => {
  475. return HttpResponse.json({
  476. processed: 1,
  477. succeeded: 1,
  478. failed: 0,
  479. results: [{ file_id: 2, success: true }],
  480. });
  481. })
  482. );
  483. render(<FileManagerPage />);
  484. await waitFor(() => {
  485. expect(screen.getByText('Generate Thumbnails')).toBeInTheDocument();
  486. });
  487. const button = screen.getByText('Generate Thumbnails');
  488. await user.click(button);
  489. // Button should work without error
  490. await waitFor(() => {
  491. expect(screen.getByText('Generate Thumbnails')).toBeInTheDocument();
  492. });
  493. });
  494. it('shows STL file without thumbnail in file list', async () => {
  495. render(<FileManagerPage />);
  496. await waitFor(() => {
  497. // bracket.stl has no thumbnail_path
  498. expect(screen.getByText('bracket.stl')).toBeInTheDocument();
  499. expect(screen.getAllByText('STL').length).toBeGreaterThan(0);
  500. });
  501. });
  502. });
  503. describe('upload modal (FileUploadModal)', () => {
  504. it('opens upload modal when Upload button is clicked', async () => {
  505. const user = userEvent.setup();
  506. render(<FileManagerPage />);
  507. await waitFor(() => {
  508. expect(screen.getByText('Upload')).toBeInTheDocument();
  509. });
  510. await user.click(screen.getByText('Upload'));
  511. await waitFor(() => {
  512. expect(screen.getByText('Upload Files')).toBeInTheDocument();
  513. expect(screen.getByText(/Drag & drop/)).toBeInTheDocument();
  514. });
  515. });
  516. it('closes upload modal when Cancel is clicked', async () => {
  517. const user = userEvent.setup();
  518. render(<FileManagerPage />);
  519. await waitFor(() => {
  520. expect(screen.getByText('Upload')).toBeInTheDocument();
  521. });
  522. await user.click(screen.getByText('Upload'));
  523. await waitFor(() => {
  524. expect(screen.getByText('Upload Files')).toBeInTheDocument();
  525. });
  526. await user.click(screen.getByRole('button', { name: 'Cancel' }));
  527. await waitFor(() => {
  528. expect(screen.queryByText('Upload Files')).not.toBeInTheDocument();
  529. });
  530. });
  531. it('shows 3MF extraction info when 3MF file is added', async () => {
  532. const user = userEvent.setup();
  533. render(<FileManagerPage />);
  534. await waitFor(() => {
  535. expect(screen.getByText('Upload')).toBeInTheDocument();
  536. });
  537. await user.click(screen.getByText('Upload'));
  538. await waitFor(() => {
  539. expect(screen.getByText('Upload Files')).toBeInTheDocument();
  540. });
  541. const threemfFile = new File(['content'], 'model.gcode.3mf', { type: 'application/octet-stream' });
  542. const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
  543. expect(fileInput).toBeInTheDocument();
  544. await user.upload(fileInput, threemfFile);
  545. await waitFor(() => {
  546. expect(screen.getByText('3MF files detected')).toBeInTheDocument();
  547. expect(screen.getByText(/Printer model.*will be automatically extracted/i)).toBeInTheDocument();
  548. });
  549. });
  550. it('shows STL thumbnail option when STL file is added', async () => {
  551. const user = userEvent.setup();
  552. render(<FileManagerPage />);
  553. await waitFor(() => {
  554. expect(screen.getByText('Upload')).toBeInTheDocument();
  555. });
  556. await user.click(screen.getByText('Upload'));
  557. await waitFor(() => {
  558. expect(screen.getByText('Upload Files')).toBeInTheDocument();
  559. });
  560. const stlFile = new File(['solid test'], 'model.stl', { type: 'application/sla' });
  561. const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
  562. expect(fileInput).toBeInTheDocument();
  563. await user.upload(fileInput, stlFile);
  564. await waitFor(() => {
  565. expect(screen.getByText('STL thumbnail generation')).toBeInTheDocument();
  566. expect(screen.getByText(/Thumbnails can be generated/i)).toBeInTheDocument();
  567. });
  568. });
  569. it('shows ZIP options when ZIP file is added', async () => {
  570. const user = userEvent.setup();
  571. render(<FileManagerPage />);
  572. await waitFor(() => {
  573. expect(screen.getByText('Upload')).toBeInTheDocument();
  574. });
  575. await user.click(screen.getByText('Upload'));
  576. await waitFor(() => {
  577. expect(screen.getByText('Upload Files')).toBeInTheDocument();
  578. });
  579. const zipFile = new File(['pk'], 'models.zip', { type: 'application/zip' });
  580. const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
  581. await user.upload(fileInput, zipFile);
  582. await waitFor(() => {
  583. expect(screen.getByText('ZIP files detected')).toBeInTheDocument();
  584. expect(screen.getByText(/Preserve folder structure/)).toBeInTheDocument();
  585. });
  586. });
  587. it('can add a file via the file input', async () => {
  588. const user = userEvent.setup();
  589. render(<FileManagerPage />);
  590. await waitFor(() => {
  591. expect(screen.getByText('Upload')).toBeInTheDocument();
  592. });
  593. await user.click(screen.getByText('Upload'));
  594. await waitFor(() => {
  595. expect(screen.getByText('Upload Files')).toBeInTheDocument();
  596. });
  597. const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
  598. const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
  599. await user.upload(fileInput, file);
  600. await waitFor(() => {
  601. expect(screen.getByText('model.3mf')).toBeInTheDocument();
  602. expect(screen.getByRole('button', { name: /Upload \(1\)/i })).toBeInTheDocument();
  603. });
  604. });
  605. it('uploads file and refreshes file list', async () => {
  606. server.use(
  607. http.post('/api/v1/library/files', () => {
  608. return HttpResponse.json({
  609. id: 10,
  610. filename: 'uploaded.3mf',
  611. file_type: '3mf',
  612. file_size: 1024,
  613. thumbnail_path: null,
  614. duplicate_of: null,
  615. metadata: null,
  616. });
  617. })
  618. );
  619. const user = userEvent.setup();
  620. render(<FileManagerPage />);
  621. await waitFor(() => {
  622. expect(screen.getByText('Upload')).toBeInTheDocument();
  623. });
  624. await user.click(screen.getByText('Upload'));
  625. await waitFor(() => {
  626. expect(screen.getByText('Upload Files')).toBeInTheDocument();
  627. });
  628. const file = new File(['content'], 'uploaded.3mf', { type: 'application/octet-stream' });
  629. const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
  630. await user.upload(fileInput, file);
  631. const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
  632. await user.click(uploadButton);
  633. // Modal should auto-close after upload completes
  634. await waitFor(() => {
  635. expect(screen.queryByText('Upload Files')).not.toBeInTheDocument();
  636. });
  637. });
  638. });
  639. describe('authentication-based UI changes', () => {
  640. it('hides "Uploaded By" column and user filter when auth is disabled', async () => {
  641. // Mock auth disabled (default)
  642. server.use(
  643. http.get('*/api/v1/auth/status', () => {
  644. return HttpResponse.json({
  645. auth_enabled: false,
  646. requires_setup: false,
  647. });
  648. }),
  649. http.get('/api/v1/library/files', () => {
  650. return HttpResponse.json([
  651. {
  652. id: 1,
  653. filename: 'test.3mf',
  654. file_path: '/library/test.3mf',
  655. file_size: 1048576,
  656. file_type: '3mf',
  657. folder_id: null,
  658. thumbnail_path: null,
  659. print_name: 'Test File',
  660. print_time_seconds: 3600,
  661. print_count: 0,
  662. duplicate_count: 0,
  663. created_at: '2024-01-01T00:00:00Z',
  664. created_by_username: 'testuser',
  665. },
  666. ]);
  667. })
  668. );
  669. render(<FileManagerPage />);
  670. // Switch to list view to see the column headers
  671. await waitFor(() => {
  672. expect(screen.getByText('Test File')).toBeInTheDocument();
  673. });
  674. const user = userEvent.setup();
  675. const listViewButton = screen.getByRole('button', { name: /list/i });
  676. await user.click(listViewButton);
  677. // "Uploaded By" column header should not be present
  678. await waitFor(() => {
  679. expect(screen.queryByText('Uploaded By')).not.toBeInTheDocument();
  680. });
  681. // User filter dropdown should not be present
  682. expect(screen.queryByPlaceholderText('Filter by user')).not.toBeInTheDocument();
  683. });
  684. it('shows "Uploaded By" column and user filter when auth is enabled', async () => {
  685. // Mock auth enabled
  686. server.use(
  687. http.get('*/api/v1/auth/status', () => {
  688. return HttpResponse.json({
  689. auth_enabled: true,
  690. requires_setup: false,
  691. });
  692. }),
  693. http.get('/api/v1/library/files', () => {
  694. return HttpResponse.json([
  695. {
  696. id: 1,
  697. filename: 'test.3mf',
  698. file_path: '/library/test.3mf',
  699. file_size: 1048576,
  700. file_type: '3mf',
  701. folder_id: null,
  702. thumbnail_path: null,
  703. print_name: 'Test File',
  704. print_time_seconds: 3600,
  705. print_count: 0,
  706. duplicate_count: 0,
  707. created_at: '2024-01-01T00:00:00Z',
  708. created_by_username: 'testuser',
  709. },
  710. ]);
  711. }),
  712. http.get('/api/v1/users/', () => {
  713. return HttpResponse.json([
  714. { id: 1, username: 'testuser' },
  715. { id: 2, username: 'admin' },
  716. ]);
  717. })
  718. );
  719. render(<FileManagerPage />);
  720. // Switch to list view to see the column headers
  721. await waitFor(() => {
  722. expect(screen.getByText('Test File')).toBeInTheDocument();
  723. });
  724. const user = userEvent.setup();
  725. const listViewButton = screen.getByRole('button', { name: /list/i });
  726. await user.click(listViewButton);
  727. // "Uploaded By" column header should be present
  728. await waitFor(() => {
  729. expect(screen.getByText('Uploaded By')).toBeInTheDocument();
  730. });
  731. // User filter dropdown should be present
  732. expect(screen.getByPlaceholderText('Filter by user')).toBeInTheDocument();
  733. // Username should be displayed in the column
  734. expect(screen.getByText('testuser')).toBeInTheDocument();
  735. });
  736. });
  737. describe('folder tree collapse preference (#996)', () => {
  738. // localStorage is globally mocked in setup.ts (returns undefined by default),
  739. // so we program each test's getItem return value explicitly.
  740. const getItemMock = localStorage.getItem as ReturnType<typeof vi.fn>;
  741. const setItemMock = localStorage.setItem as ReturnType<typeof vi.fn>;
  742. beforeEach(() => {
  743. getItemMock.mockReset();
  744. setItemMock.mockReset();
  745. });
  746. // The mock is module-global, so an implementation left behind here would
  747. // silently change every later describe (e.g. collapsing the folder tree).
  748. afterEach(() => {
  749. getItemMock.mockReset();
  750. setItemMock.mockReset();
  751. });
  752. it('defaults to expanded (nested folders visible) when library-collapse-folders is unset', async () => {
  753. getItemMock.mockReturnValue(null);
  754. render(<FileManagerPage />);
  755. await waitFor(() => {
  756. expect(screen.getByText('Functional Parts')).toBeInTheDocument();
  757. });
  758. expect(screen.getByText('Brackets')).toBeInTheDocument();
  759. });
  760. it('honors library-collapse-folders=true on load (nested folders hidden)', async () => {
  761. getItemMock.mockImplementation((key: string) =>
  762. key === 'library-collapse-folders' ? 'true' : null
  763. );
  764. render(<FileManagerPage />);
  765. await waitFor(() => {
  766. expect(screen.getByText('Functional Parts')).toBeInTheDocument();
  767. });
  768. expect(screen.queryByText('Brackets')).not.toBeInTheDocument();
  769. });
  770. it('collapses nested folders and persists preference when Collapse is clicked', async () => {
  771. getItemMock.mockReturnValue(null);
  772. const user = userEvent.setup();
  773. render(<FileManagerPage />);
  774. await waitFor(() => {
  775. expect(screen.getByText('Brackets')).toBeInTheDocument();
  776. });
  777. // The Collapse button sits next to Wrap in the sidebar header.
  778. // Its text content is "Collapse" (from fileManager.collapse).
  779. await user.click(screen.getByRole('button', { name: 'Collapse' }));
  780. await waitFor(() => {
  781. expect(screen.queryByText('Brackets')).not.toBeInTheDocument();
  782. });
  783. expect(setItemMock).toHaveBeenCalledWith('library-collapse-folders', 'true');
  784. });
  785. it('re-expands nested folders and persists preference when Collapse is toggled off', async () => {
  786. getItemMock.mockImplementation((key: string) =>
  787. key === 'library-collapse-folders' ? 'true' : null
  788. );
  789. const user = userEvent.setup();
  790. render(<FileManagerPage />);
  791. await waitFor(() => {
  792. expect(screen.getByText('Functional Parts')).toBeInTheDocument();
  793. });
  794. expect(screen.queryByText('Brackets')).not.toBeInTheDocument();
  795. await user.click(screen.getByRole('button', { name: 'Collapse' }));
  796. await waitFor(() => {
  797. expect(screen.getByText('Brackets')).toBeInTheDocument();
  798. });
  799. expect(setItemMock).toHaveBeenCalledWith('library-collapse-folders', 'false');
  800. });
  801. });
  802. describe('Internal / External top-level views (#1621)', () => {
  803. const externalMockFolders = [
  804. ...mockFolders,
  805. {
  806. id: 99,
  807. name: 'NAS Library',
  808. parent_id: null,
  809. file_count: 200,
  810. project_id: null,
  811. archive_id: null,
  812. project_name: null,
  813. archive_name: null,
  814. is_external: true,
  815. external_readonly: false,
  816. external_path: '/mnt/nas',
  817. children: [],
  818. },
  819. ];
  820. it('shows the External sidebar entry only when at least one external folder is linked', async () => {
  821. // Default mockFolders have no is_external entries → no External row.
  822. const { unmount } = render(<FileManagerPage />);
  823. await waitFor(() => {
  824. expect(screen.getByText('All Files')).toBeInTheDocument();
  825. });
  826. expect(screen.queryByText('External')).not.toBeInTheDocument();
  827. unmount();
  828. // With an external folder linked, the row appears.
  829. server.use(
  830. http.get('/api/v1/library/folders', () => HttpResponse.json(externalMockFolders)),
  831. );
  832. render(<FileManagerPage />);
  833. await waitFor(() => {
  834. expect(screen.getByText('External')).toBeInTheDocument();
  835. });
  836. });
  837. it('sends internal_only=true by default ("All Files" = managed storage only)', async () => {
  838. const scopes: string[] = [];
  839. server.use(
  840. http.get('/api/v1/library/folders', () => HttpResponse.json(externalMockFolders)),
  841. http.get('/api/v1/library/files', ({ request }) => {
  842. const url = new URL(request.url);
  843. scopes.push(
  844. url.searchParams.get('internal_only') === 'true'
  845. ? 'internal'
  846. : url.searchParams.get('external_only') === 'true'
  847. ? 'external'
  848. : 'all',
  849. );
  850. return HttpResponse.json(mockFiles);
  851. }),
  852. );
  853. render(<FileManagerPage />);
  854. await waitFor(() => {
  855. expect(scopes).toContain('internal');
  856. });
  857. });
  858. it('switches to external_only=true when the External sidebar entry is clicked', async () => {
  859. const scopes: string[] = [];
  860. server.use(
  861. http.get('/api/v1/library/folders', () => HttpResponse.json(externalMockFolders)),
  862. http.get('/api/v1/library/files', ({ request }) => {
  863. const url = new URL(request.url);
  864. scopes.push(
  865. url.searchParams.get('internal_only') === 'true'
  866. ? 'internal'
  867. : url.searchParams.get('external_only') === 'true'
  868. ? 'external'
  869. : 'all',
  870. );
  871. return HttpResponse.json([]);
  872. }),
  873. );
  874. const { default: userEvent } = await import('@testing-library/user-event');
  875. const user = userEvent.setup();
  876. render(<FileManagerPage />);
  877. await waitFor(() => expect(screen.getByText('External')).toBeInTheDocument());
  878. await user.click(screen.getByText('External'));
  879. await waitFor(() => {
  880. expect(scopes).toContain('external');
  881. });
  882. });
  883. });
  884. describe('"All Files" view (#1499)', () => {
  885. it('requests every file (include_root=false) so subfolder contents are visible', async () => {
  886. const rootFile = {
  887. id: 10,
  888. filename: 'root-file.3mf',
  889. file_path: '/library/root-file.3mf',
  890. file_size: 1024,
  891. file_type: '3mf',
  892. folder_id: null,
  893. thumbnail_path: null,
  894. print_name: 'Root File',
  895. print_time_seconds: 0,
  896. print_count: 0,
  897. duplicate_count: 0,
  898. created_at: '2024-01-01T00:00:00Z',
  899. };
  900. const nestedFile = {
  901. ...rootFile,
  902. id: 11,
  903. filename: 'nested-file.3mf',
  904. file_path: '/library/Functional Parts/nested-file.3mf',
  905. folder_id: 1,
  906. print_name: 'Nested File',
  907. };
  908. const includeRootValues: string[] = [];
  909. server.use(
  910. http.get('/api/v1/library/files', ({ request }) => {
  911. const url = new URL(request.url);
  912. const includeRoot = url.searchParams.get('include_root');
  913. includeRootValues.push(includeRoot ?? '');
  914. // Mirror the backend: include_root=false returns everything; true
  915. // returns only files with folder_id IS NULL.
  916. if (includeRoot === 'false') {
  917. return HttpResponse.json([rootFile, nestedFile]);
  918. }
  919. return HttpResponse.json([rootFile]);
  920. }),
  921. );
  922. render(<FileManagerPage />);
  923. await waitFor(() => {
  924. expect(screen.getByText('Root File')).toBeInTheDocument();
  925. expect(screen.getByText('Nested File')).toBeInTheDocument();
  926. });
  927. // Sanity-check: the buggy call would have sent include_root=true here.
  928. expect(includeRootValues).toContain('false');
  929. });
  930. });
  931. describe('last-modified date display (#2680)', () => {
  932. it('is hidden by default and revealed by the toolbar toggle', async () => {
  933. const user = userEvent.setup();
  934. render(<FileManagerPage />);
  935. await waitFor(() => {
  936. expect(screen.getByText('Benchy')).toBeInTheDocument();
  937. });
  938. // Hidden by default.
  939. expect(screen.queryByText(/2030/)).not.toBeInTheDocument();
  940. // Toggle on via the toolbar button.
  941. await user.click(screen.getByTitle('Show modified dates'));
  942. // benchy carries fs_modified_at in 2030, which must be preferred over its
  943. // created_at (2024) — proving the real on-disk mtime drives the display.
  944. await waitFor(() => {
  945. expect(screen.getByText(/2030/)).toBeInTheDocument();
  946. });
  947. // Toggling off hides it again.
  948. await user.click(screen.getByTitle('Hide modified dates'));
  949. await waitFor(() => {
  950. expect(screen.queryByText(/2030/)).not.toBeInTheDocument();
  951. });
  952. });
  953. it('the same toggle reveals latest activity on folder rows, including nested ones', async () => {
  954. const user = userEvent.setup();
  955. render(<FileManagerPage />);
  956. await waitFor(() => {
  957. expect(screen.getByText('Functional Parts')).toBeInTheDocument();
  958. });
  959. expect(screen.queryByText(/2031/)).not.toBeInTheDocument();
  960. await user.click(screen.getByTitle('Show modified dates'));
  961. await waitFor(() => {
  962. expect(screen.getByText(/2031/)).toBeInTheDocument();
  963. });
  964. // Nested folders get it too — the prop must survive the recursion.
  965. expect(screen.getByText(/2032/)).toBeInTheDocument();
  966. // A folder with no activity timestamp renders nothing rather than an
  967. // "Invalid Date" string.
  968. const artRow = screen.getByText('Art Projects').closest('div.group')!;
  969. expect(artRow.textContent).not.toMatch(/Invalid/);
  970. await user.click(screen.getByTitle('Hide modified dates'));
  971. await waitFor(() => {
  972. expect(screen.queryByText(/2031/)).not.toBeInTheDocument();
  973. });
  974. });
  975. });
  976. describe('slice action', () => {
  977. beforeEach(() => {
  978. vi.mocked(openInSlicer).mockClear();
  979. server.use(
  980. http.post('/api/v1/library/files/:id/slicer-token', () => HttpResponse.json({ token: 'test-token' })),
  981. );
  982. });
  983. afterEach(() => {
  984. // Permission tests set a token; clear it so it can't leak into the
  985. // list-view tests that follow (mirrors FileManagerFolderDelete.test.tsx).
  986. setAuthToken(null);
  987. });
  988. const openMenu = async (user: ReturnType<typeof userEvent.setup>, filename: string) => {
  989. const card = screen.getByText(filename).closest('.group') as HTMLElement;
  990. // Target the kebab (ellipsis) toggle specifically rather than the card's
  991. // first button — a button added ahead of the kebab would otherwise
  992. // break the menu-opening assumption.
  993. const kebab = card.querySelector('.lucide-ellipsis-vertical')?.closest('button') as HTMLButtonElement;
  994. await user.click(kebab);
  995. return card;
  996. };
  997. it('opens the desktop slicer when the slicer API is disabled', async () => {
  998. const user = userEvent.setup();
  999. render(<FileManagerPage />);
  1000. await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
  1001. const card = await openMenu(user, 'bracket.stl');
  1002. await user.click(within(card).getByText('Slice'));
  1003. await waitFor(() => {
  1004. expect(openInSlicer).toHaveBeenCalledWith(
  1005. expect.stringContaining('/library/files/2/dl/test-token/'),
  1006. 'bambu_studio',
  1007. );
  1008. });
  1009. });
  1010. it('opens the in-app SliceModal when the slicer API is enabled', async () => {
  1011. server.use(
  1012. http.get('/api/v1/settings/', () => HttpResponse.json({ use_slicer_api: true })),
  1013. );
  1014. const user = userEvent.setup();
  1015. render(<FileManagerPage />);
  1016. await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
  1017. const card = await openMenu(user, 'bracket.stl');
  1018. await user.click(within(card).getByText('Slice'));
  1019. expect(await screen.findByTestId('slice-modal')).toBeInTheDocument();
  1020. expect(openInSlicer).not.toHaveBeenCalled();
  1021. });
  1022. it('hides the slice item for already-sliced files', async () => {
  1023. const user = userEvent.setup();
  1024. render(<FileManagerPage />);
  1025. await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
  1026. const card = await openMenu(user, 'Benchy');
  1027. expect(within(card).queryByText('Slice')).not.toBeInTheDocument();
  1028. });
  1029. // Permission gating is the security-relevant half of the slice action: the
  1030. // in-app API path needs library:upload, and the desktop handoff mirrors the
  1031. // ownership check the slicer-token endpoint runs — library:read_all or
  1032. // library:read_own. The legacy library:read is deliberately not accepted;
  1033. // it satisfies neither the token endpoint nor the folder listing that gets
  1034. // a user to this page at all.
  1035. const mockAuthUser = (permissions: string[]) => {
  1036. setAuthToken('test-token', 'session');
  1037. server.use(
  1038. http.get('*/api/v1/auth/status', () =>
  1039. HttpResponse.json({ auth_enabled: true, requires_setup: false }),
  1040. ),
  1041. http.get('*/api/v1/auth/me', () =>
  1042. HttpResponse.json({
  1043. id: 7,
  1044. username: 'operator1',
  1045. is_admin: false,
  1046. permissions,
  1047. }),
  1048. ),
  1049. http.get('/api/v1/users/', () => HttpResponse.json([])),
  1050. );
  1051. };
  1052. it('disables the Slice menu item without library:upload when the slicer API is enabled', async () => {
  1053. mockAuthUser([]);
  1054. server.use(
  1055. http.get('/api/v1/settings/', () => HttpResponse.json({ use_slicer_api: true })),
  1056. );
  1057. const user = userEvent.setup();
  1058. render(<FileManagerPage />);
  1059. await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
  1060. const card = await openMenu(user, 'bracket.stl');
  1061. const sliceItem = within(card).getByText('Slice').closest('button');
  1062. expect(sliceItem).toBeDisabled();
  1063. await user.click(sliceItem!);
  1064. expect(openInSlicer).not.toHaveBeenCalled();
  1065. });
  1066. it('enables the Slice menu item with library:upload when the slicer API is enabled', async () => {
  1067. mockAuthUser(['library:upload']);
  1068. server.use(
  1069. http.get('/api/v1/settings/', () => HttpResponse.json({ use_slicer_api: true })),
  1070. );
  1071. const user = userEvent.setup();
  1072. render(<FileManagerPage />);
  1073. await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
  1074. const card = await openMenu(user, 'bracket.stl');
  1075. const sliceItem = within(card).getByText('Slice').closest('button');
  1076. expect(sliceItem).not.toBeDisabled();
  1077. });
  1078. it('disables the Slice menu item without any library read permission for the desktop handoff', async () => {
  1079. mockAuthUser(['library:upload']);
  1080. const user = userEvent.setup();
  1081. render(<FileManagerPage />);
  1082. await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
  1083. const card = await openMenu(user, 'bracket.stl');
  1084. const sliceItem = within(card).getByText('Slice').closest('button');
  1085. expect(sliceItem).toBeDisabled();
  1086. await user.click(sliceItem!);
  1087. expect(openInSlicer).not.toHaveBeenCalled();
  1088. });
  1089. it('enables the Slice menu item with library:read_own for the desktop handoff', async () => {
  1090. mockAuthUser(['library:read_own']);
  1091. const user = userEvent.setup();
  1092. render(<FileManagerPage />);
  1093. await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
  1094. const card = await openMenu(user, 'bracket.stl');
  1095. const sliceItem = within(card).getByText('Slice').closest('button');
  1096. expect(sliceItem).not.toBeDisabled();
  1097. });
  1098. it('does not accept the legacy library:read for the desktop handoff', async () => {
  1099. // require_ownership_permission(LIBRARY_READ_ALL, LIBRARY_READ_OWN) does no
  1100. // legacy expansion, so this group 403s on the slicer-token endpoint.
  1101. // Enabling the item would offer an action the server refuses, and the
  1102. // failure would look like "no slicer installed" once the fallback URL is
  1103. // handed over.
  1104. mockAuthUser(['library:read']);
  1105. const user = userEvent.setup();
  1106. render(<FileManagerPage />);
  1107. await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
  1108. const card = await openMenu(user, 'bracket.stl');
  1109. const sliceItem = within(card).getByText('Slice').closest('button');
  1110. expect(sliceItem).toBeDisabled();
  1111. await user.click(sliceItem!);
  1112. expect(openInSlicer).not.toHaveBeenCalled();
  1113. });
  1114. it('slices from the list-view button when the slicer API is disabled', async () => {
  1115. const user = userEvent.setup();
  1116. render(<FileManagerPage />);
  1117. await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
  1118. await user.click(screen.getByTitle('List view'));
  1119. await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
  1120. const row = screen.getByText('bracket.stl').closest('div[class*="cursor-pointer"]') as HTMLElement;
  1121. await user.click(within(row).getByTitle('Slice'));
  1122. await waitFor(() => {
  1123. expect(openInSlicer).toHaveBeenCalledWith(
  1124. expect.stringContaining('/library/files/2/dl/test-token/'),
  1125. 'bambu_studio',
  1126. );
  1127. });
  1128. });
  1129. it('slices from the list-view button into the in-app modal when the slicer API is enabled', async () => {
  1130. server.use(
  1131. http.get('/api/v1/settings/', () => HttpResponse.json({ use_slicer_api: true })),
  1132. );
  1133. const user = userEvent.setup();
  1134. render(<FileManagerPage />);
  1135. await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
  1136. await user.click(screen.getByTitle('List view'));
  1137. await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
  1138. const row = screen.getByText('bracket.stl').closest('div[class*="cursor-pointer"]') as HTMLElement;
  1139. await user.click(within(row).getByTitle('Slice'));
  1140. expect(await screen.findByTestId('slice-modal')).toBeInTheDocument();
  1141. expect(openInSlicer).not.toHaveBeenCalled();
  1142. });
  1143. });
  1144. });