LocalProfilesView.test.tsx 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. /**
  2. * Tests for LocalProfilesView component.
  3. */
  4. import React from 'react';
  5. import { describe, it, expect, beforeEach, vi } from 'vitest';
  6. import { screen, waitFor, fireEvent, render as rawRender } from '@testing-library/react';
  7. import { http, HttpResponse } from 'msw';
  8. import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
  9. import { BrowserRouter } from 'react-router-dom';
  10. import { server } from '../mocks/server';
  11. import { render } from '../utils';
  12. import { ThemeProvider } from '../../contexts/ThemeContext';
  13. import { ToastProvider } from '../../contexts/ToastContext';
  14. import { AuthProvider } from '../../contexts/AuthContext';
  15. import { LocalProfilesView } from '../../components/LocalProfilesView';
  16. const mockLocalPresets = {
  17. filament: [
  18. {
  19. id: 1,
  20. name: 'Overture PLA Matte @BBL X1C',
  21. preset_type: 'filament',
  22. source: 'orcaslicer',
  23. filament_type: 'PLA',
  24. filament_vendor: 'Overture',
  25. nozzle_temp_min: 190,
  26. nozzle_temp_max: 230,
  27. pressure_advance: '["0.04"]',
  28. default_filament_colour: '["#FFAA00"]',
  29. filament_cost: '24.99',
  30. filament_density: '1.24',
  31. compatible_printers: '["Bambu Lab X1 Carbon 0.4 nozzle"]',
  32. inherits: 'Bambu PLA Basic @BBL X1C',
  33. version: '2.3.0.4',
  34. created_at: '2026-01-01T00:00:00Z',
  35. updated_at: '2026-01-01T00:00:00Z',
  36. },
  37. {
  38. id: 2,
  39. name: 'eSUN PETG @Bambu Lab H2D',
  40. preset_type: 'filament',
  41. source: 'orcaslicer',
  42. filament_type: 'PETG',
  43. filament_vendor: null,
  44. nozzle_temp_min: 220,
  45. nozzle_temp_max: 250,
  46. pressure_advance: null,
  47. default_filament_colour: null,
  48. filament_cost: null,
  49. filament_density: null,
  50. compatible_printers: null,
  51. inherits: null,
  52. version: null,
  53. created_at: '2026-01-01T00:00:00Z',
  54. updated_at: '2026-01-01T00:00:00Z',
  55. },
  56. ],
  57. process: [
  58. {
  59. id: 3,
  60. name: '0.20mm Standard @BBL X1C',
  61. preset_type: 'process',
  62. source: 'orcaslicer',
  63. filament_type: null,
  64. filament_vendor: null,
  65. nozzle_temp_min: null,
  66. nozzle_temp_max: null,
  67. pressure_advance: null,
  68. default_filament_colour: null,
  69. filament_cost: null,
  70. filament_density: null,
  71. compatible_printers: null,
  72. inherits: '0.20mm Standard @BBL X1C',
  73. version: '2.3.0.4',
  74. created_at: '2026-01-01T00:00:00Z',
  75. updated_at: '2026-01-01T00:00:00Z',
  76. },
  77. ],
  78. printer: [],
  79. };
  80. describe('LocalProfilesView', () => {
  81. beforeEach(() => {
  82. server.use(
  83. http.get('/api/v1/local-presets/', () => {
  84. return HttpResponse.json(mockLocalPresets);
  85. }),
  86. http.delete('/api/v1/local-presets/:id', () => {
  87. return HttpResponse.json({ success: true });
  88. }),
  89. );
  90. });
  91. it('renders filament and process columns', async () => {
  92. render(<LocalProfilesView />);
  93. await waitFor(() => {
  94. expect(screen.getByText('Overture PLA Matte @BBL X1C')).toBeInTheDocument();
  95. });
  96. expect(screen.getByText('eSUN PETG @Bambu Lab H2D')).toBeInTheDocument();
  97. expect(screen.getByText('0.20mm Standard @BBL X1C')).toBeInTheDocument();
  98. });
  99. it('shows material badges from filament_type', async () => {
  100. render(<LocalProfilesView />);
  101. await waitFor(() => {
  102. expect(screen.getByText('Overture PLA Matte @BBL X1C')).toBeInTheDocument();
  103. });
  104. // PLA badge should appear for the first preset
  105. const plaBadges = screen.getAllByText('PLA');
  106. expect(plaBadges.length).toBeGreaterThan(0);
  107. });
  108. it('shows vendor from filament_vendor field', async () => {
  109. render(<LocalProfilesView />);
  110. await waitFor(() => {
  111. expect(screen.getByText('Overture')).toBeInTheDocument();
  112. });
  113. });
  114. it('parses vendor from name when filament_vendor is null', async () => {
  115. render(<LocalProfilesView />);
  116. await waitFor(() => {
  117. expect(screen.getByText('eSUN PETG @Bambu Lab H2D')).toBeInTheDocument();
  118. });
  119. // eSUN should be parsed from the name
  120. expect(screen.getByText('eSUN')).toBeInTheDocument();
  121. });
  122. it('filters presets by search query', async () => {
  123. render(<LocalProfilesView />);
  124. await waitFor(() => {
  125. expect(screen.getByText('Overture PLA Matte @BBL X1C')).toBeInTheDocument();
  126. });
  127. const searchInput = screen.getByPlaceholderText(/search/i);
  128. fireEvent.change(searchInput, { target: { value: 'PETG' } });
  129. expect(screen.queryByText('Overture PLA Matte @BBL X1C')).not.toBeInTheDocument();
  130. expect(screen.getByText('eSUN PETG @Bambu Lab H2D')).toBeInTheDocument();
  131. });
  132. it('keeps the search bar visible when no presets match the query (#1470)', async () => {
  133. render(<LocalProfilesView />);
  134. await waitFor(() => {
  135. expect(screen.getByText('Overture PLA Matte @BBL X1C')).toBeInTheDocument();
  136. });
  137. const searchInput = screen.getByPlaceholderText(/search/i);
  138. fireEvent.change(searchInput, { target: { value: 'zzz-nothing-matches' } });
  139. // The search bar must stay mounted so the query can be cleared/edited
  140. // without a page refresh, and it keeps the typed value.
  141. expect(screen.getByPlaceholderText(/search/i)).toBeInTheDocument();
  142. expect(screen.getByPlaceholderText(/search/i)).toHaveValue('zzz-nothing-matches');
  143. // A no-matches message replaces the columns (not the "import some" empty state).
  144. expect(screen.getByText(/no presets match your search/i)).toBeInTheDocument();
  145. });
  146. it('shows empty state when no presets', async () => {
  147. server.use(
  148. http.get('/api/v1/local-presets/', () => {
  149. return HttpResponse.json({ filament: [], process: [], printer: [] });
  150. }),
  151. );
  152. render(<LocalProfilesView />);
  153. await waitFor(() => {
  154. expect(screen.getByText(/no local presets/i)).toBeInTheDocument();
  155. });
  156. });
  157. it('shows Local badge on preset cards', async () => {
  158. render(<LocalProfilesView />);
  159. await waitFor(() => {
  160. expect(screen.getByText('Overture PLA Matte @BBL X1C')).toBeInTheDocument();
  161. });
  162. const badges = screen.getAllByText(/^Local$/i);
  163. expect(badges.length).toBeGreaterThan(0);
  164. });
  165. it('shows delete confirmation modal', async () => {
  166. render(<LocalProfilesView />);
  167. await waitFor(() => {
  168. expect(screen.getByText('Overture PLA Matte @BBL X1C')).toBeInTheDocument();
  169. });
  170. // Click first delete button
  171. const deleteButtons = screen.getAllByTitle(/delete/i);
  172. fireEvent.click(deleteButtons[0]);
  173. await waitFor(() => {
  174. expect(screen.getByText(/are you sure/i)).toBeInTheDocument();
  175. });
  176. });
  177. it('shows import zone', async () => {
  178. render(<LocalProfilesView />);
  179. await waitFor(() => {
  180. expect(screen.getByText(/import profiles/i)).toBeInTheDocument();
  181. });
  182. expect(screen.getByText(/\.bbscfg/i)).toBeInTheDocument();
  183. });
  184. it('invalidates the slicerPresets query after a delete (#1581)', async () => {
  185. // Without this invalidation a preset deleted in Local Profiles still
  186. // shows in the SliceModal until the modal's ['slicerPresets'] query
  187. // staleTime (60s) expires + a refocus / remount. The bug report:
  188. // "Removed local profiles still show on the slice menu even tho they
  189. // have been deleted." We mirror the production provider tree but inject
  190. // our own QueryClient so we can spy on invalidateQueries.
  191. const queryClient = new QueryClient({
  192. defaultOptions: {
  193. queries: { retry: false, gcTime: 0 },
  194. mutations: { retry: false },
  195. },
  196. });
  197. const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
  198. rawRender(
  199. <QueryClientProvider client={queryClient}>
  200. <BrowserRouter>
  201. <AuthProvider>
  202. <ThemeProvider>
  203. <ToastProvider>
  204. <LocalProfilesView />
  205. </ToastProvider>
  206. </ThemeProvider>
  207. </AuthProvider>
  208. </BrowserRouter>
  209. </QueryClientProvider>,
  210. );
  211. await waitFor(() => {
  212. expect(screen.getByText('Overture PLA Matte @BBL X1C')).toBeInTheDocument();
  213. });
  214. // Open the delete confirmation, then confirm. The card-level delete
  215. // icon buttons and the modal's Delete button both expose the accessible
  216. // name "Delete"; the modal button is the last one to mount, so it's the
  217. // tail of the findAllByRole result.
  218. const deleteButtons = screen.getAllByTitle(/delete/i);
  219. fireEvent.click(deleteButtons[0]);
  220. await screen.findByText(/are you sure/i);
  221. const confirmButtons = await screen.findAllByRole('button', { name: /^delete$/i });
  222. fireEvent.click(confirmButtons[confirmButtons.length - 1]);
  223. await waitFor(() => {
  224. expect(
  225. invalidateSpy.mock.calls.some(
  226. ([arg]) => arg && (arg as { queryKey: unknown[] }).queryKey?.[0] === 'slicerPresets',
  227. ),
  228. ).toBe(true);
  229. });
  230. // Sanity: the local-only invalidation is still there too.
  231. expect(
  232. invalidateSpy.mock.calls.some(
  233. ([arg]) => arg && (arg as { queryKey: unknown[] }).queryKey?.[0] === 'localPresets',
  234. ),
  235. ).toBe(true);
  236. });
  237. });