SlicerBundlesPanel.test.tsx 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  1. /**
  2. * Tests for the SlicerBundlesPanel — Settings panel for managing
  3. * BambuStudio Printer Preset Bundles (.bbscfg) on the slicer sidecar.
  4. *
  5. * Coverage:
  6. * - Empty state when the sidecar has no bundles imported yet.
  7. * - List rendering with summary line (process / filament counts).
  8. * - Upload happy path → success toast + list invalidation.
  9. * - Upload error → error toast.
  10. * - Delete with confirmation → success toast + list invalidation.
  11. * - Delete error → error toast.
  12. */
  13. import { describe, it, expect, vi, beforeEach } from 'vitest';
  14. import { screen, fireEvent, waitFor } from '@testing-library/react';
  15. import { render } from '../utils';
  16. import { api } from '../../api/client';
  17. import { SlicerBundlesPanel } from '../../components/SlicerBundlesPanel';
  18. vi.mock('../../api/client', async () => {
  19. const actual: typeof import('../../api/client') = await vi.importActual(
  20. '../../api/client',
  21. );
  22. return {
  23. ...actual,
  24. api: {
  25. ...actual.api,
  26. listSlicerBundles: vi.fn(),
  27. importSlicerBundle: vi.fn(),
  28. deleteSlicerBundle: vi.fn(),
  29. },
  30. getAuthToken: vi.fn(() => null),
  31. };
  32. });
  33. const SAMPLE_BUNDLE = {
  34. id: 'abc123def456abcd',
  35. printer_preset_name: '# Bambu Lab H2D 0.4 nozzle',
  36. printer: ['# Bambu Lab H2D 0.4 nozzle'],
  37. process: [
  38. '# 0.20mm Standard @BBL H2D',
  39. '# 0.16mm Standard @BBL H2D',
  40. ],
  41. filament: [
  42. '# Bambu PLA Basic @BBL H2D',
  43. '# Bambu PETG HF @BBL H2D 0.4 nozzle',
  44. '# Bambu ABS @BBL H2D',
  45. ],
  46. version: '02.06.00.50',
  47. };
  48. beforeEach(() => {
  49. vi.clearAllMocks();
  50. });
  51. describe('SlicerBundlesPanel — empty state', () => {
  52. it('renders the empty-state message when no bundles exist', async () => {
  53. vi.mocked(api.listSlicerBundles).mockResolvedValueOnce([]);
  54. render(<SlicerBundlesPanel />);
  55. await waitFor(() =>
  56. expect(api.listSlicerBundles).toHaveBeenCalled(),
  57. );
  58. expect(
  59. await screen.findByText(/no bundles imported yet/i),
  60. ).toBeInTheDocument();
  61. });
  62. });
  63. describe('SlicerBundlesPanel — list rendering', () => {
  64. it('renders bundle name + summary (process and filament counts)', async () => {
  65. vi.mocked(api.listSlicerBundles).mockResolvedValueOnce([SAMPLE_BUNDLE]);
  66. render(<SlicerBundlesPanel />);
  67. expect(
  68. await screen.findByText('# Bambu Lab H2D 0.4 nozzle'),
  69. ).toBeInTheDocument();
  70. // Summary should reflect 2 process + 3 filament from the fixture.
  71. expect(
  72. await screen.findByText(/2 process · 3 filament/i),
  73. ).toBeInTheDocument();
  74. // Version suffix appended after the summary.
  75. expect(screen.getByText(/v02\.06\.00\.50/)).toBeInTheDocument();
  76. });
  77. });
  78. describe('SlicerBundlesPanel — upload flow', () => {
  79. it('imports a selected file and refreshes the list on success', async () => {
  80. // First listing call returns empty so the test can detect the post-import
  81. // re-fetch (second call) returning the new bundle.
  82. vi.mocked(api.listSlicerBundles)
  83. .mockResolvedValueOnce([])
  84. .mockResolvedValueOnce([SAMPLE_BUNDLE]);
  85. vi.mocked(api.importSlicerBundle).mockResolvedValueOnce(SAMPLE_BUNDLE);
  86. const { container } = render(<SlicerBundlesPanel />);
  87. // The file input is hidden (display: none for styling); grab it directly.
  88. const fileInput = container.querySelector(
  89. 'input[type="file"]',
  90. ) as HTMLInputElement;
  91. expect(fileInput).toBeTruthy();
  92. const file = new File([new Uint8Array([0x50, 0x4b, 0x03, 0x04])], 'H2D.bbscfg', {
  93. type: 'application/zip',
  94. });
  95. fireEvent.change(fileInput, { target: { files: [file] } });
  96. await waitFor(() =>
  97. expect(api.importSlicerBundle).toHaveBeenCalledWith(file),
  98. );
  99. // After the success, the list call should fire a second time (cache
  100. // invalidation by react-query).
  101. await waitFor(() =>
  102. expect(api.listSlicerBundles).toHaveBeenCalledTimes(2),
  103. );
  104. // The newly imported bundle should now be visible in the list.
  105. expect(
  106. await screen.findByText('# Bambu Lab H2D 0.4 nozzle'),
  107. ).toBeInTheDocument();
  108. });
  109. it('shows an error and does not refresh on upload failure', async () => {
  110. vi.mocked(api.listSlicerBundles).mockResolvedValueOnce([]);
  111. vi.mocked(api.importSlicerBundle).mockRejectedValueOnce(
  112. new Error('Bundle is missing bundle_structure.json'),
  113. );
  114. const { container } = render(<SlicerBundlesPanel />);
  115. await waitFor(() => expect(api.listSlicerBundles).toHaveBeenCalled());
  116. const fileInput = container.querySelector(
  117. 'input[type="file"]',
  118. ) as HTMLInputElement;
  119. const file = new File([new Uint8Array([0])], 'bad.bbscfg', {
  120. type: 'application/zip',
  121. });
  122. fireEvent.change(fileInput, { target: { files: [file] } });
  123. await waitFor(() =>
  124. expect(api.importSlicerBundle).toHaveBeenCalled(),
  125. );
  126. // Listing should NOT be re-called on failure — only the initial load.
  127. expect(api.listSlicerBundles).toHaveBeenCalledTimes(1);
  128. // Empty state still showing.
  129. expect(
  130. screen.getByText(/no bundles imported yet/i),
  131. ).toBeInTheDocument();
  132. });
  133. });
  134. describe('SlicerBundlesPanel — delete flow', () => {
  135. it('deletes a bundle after confirmation and refreshes the list', async () => {
  136. vi.mocked(api.listSlicerBundles)
  137. .mockResolvedValueOnce([SAMPLE_BUNDLE])
  138. .mockResolvedValueOnce([]);
  139. vi.mocked(api.deleteSlicerBundle).mockResolvedValueOnce(undefined);
  140. render(<SlicerBundlesPanel />);
  141. // Wait for the bundle to render.
  142. await screen.findByText('# Bambu Lab H2D 0.4 nozzle');
  143. // Click the trash button (aria-label="Delete").
  144. fireEvent.click(screen.getByRole('button', { name: /delete/i }));
  145. // ConfirmModal should appear with the bundle name in the message.
  146. const confirmMessage = await screen.findByText(
  147. /Slice requests that reference "# Bambu Lab H2D 0.4 nozzle" will fail/i,
  148. );
  149. expect(confirmMessage).toBeInTheDocument();
  150. // The modal renders its own "Delete" button — there are now two buttons
  151. // matching /delete/i. Click the one inside the dialog (last in document
  152. // order, since the modal portal renders after the panel).
  153. const deleteButtons = screen.getAllByRole('button', { name: /delete/i });
  154. fireEvent.click(deleteButtons[deleteButtons.length - 1]);
  155. await waitFor(() =>
  156. expect(api.deleteSlicerBundle).toHaveBeenCalledWith(
  157. 'abc123def456abcd',
  158. ),
  159. );
  160. // Cache invalidation should re-fire the list query.
  161. await waitFor(() =>
  162. expect(api.listSlicerBundles).toHaveBeenCalledTimes(2),
  163. );
  164. });
  165. it('keeps the bundle in the list when the user cancels the delete dialog', async () => {
  166. vi.mocked(api.listSlicerBundles).mockResolvedValueOnce([SAMPLE_BUNDLE]);
  167. render(<SlicerBundlesPanel />);
  168. await screen.findByText('# Bambu Lab H2D 0.4 nozzle');
  169. fireEvent.click(screen.getByRole('button', { name: /delete/i }));
  170. // Cancel by clicking the "Cancel" button on the ConfirmModal.
  171. const cancelButton = await screen.findByRole('button', { name: /cancel/i });
  172. fireEvent.click(cancelButton);
  173. // Delete API never called, list never re-fetched.
  174. expect(api.deleteSlicerBundle).not.toHaveBeenCalled();
  175. expect(api.listSlicerBundles).toHaveBeenCalledTimes(1);
  176. // Bundle still rendered.
  177. expect(
  178. screen.getByText('# Bambu Lab H2D 0.4 nozzle'),
  179. ).toBeInTheDocument();
  180. });
  181. });