SpoolCatalogSettings.test.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473
  1. import React from 'react';
  2. import { describe, it, expect, vi, beforeEach } from 'vitest';
  3. import { screen, waitFor, fireEvent } from '@testing-library/react';
  4. import { render } from '../utils';
  5. import { SpoolCatalogSettings } from '../../components/SpoolCatalogSettings';
  6. vi.mock('react-i18next', () => ({
  7. useTranslation: () => ({
  8. t: (key: string, fallback?: string) => fallback ?? key,
  9. }),
  10. }));
  11. const mockShowToast = vi.fn();
  12. vi.mock('../../contexts/ToastContext', async (importOriginal) => {
  13. const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();
  14. return { ...actual, useToast: () => ({ showToast: mockShowToast }) };
  15. });
  16. vi.mock('../../api/client', () => ({
  17. api: {
  18. getSettings: vi.fn().mockResolvedValue({}),
  19. getSpoolCatalog: vi.fn().mockResolvedValue([]),
  20. getSpoolmanInventoryFilaments: vi.fn().mockResolvedValue([]),
  21. patchSpoolmanFilament: vi.fn().mockResolvedValue({
  22. id: 1,
  23. name: 'PLA Basic',
  24. material: 'PLA',
  25. color_hex: 'FF0000',
  26. color_name: 'Red',
  27. weight: 1000,
  28. spool_weight: 196,
  29. vendor: { id: 1, name: 'Bambu Lab' },
  30. }),
  31. },
  32. ApiError: class ApiError extends Error {
  33. status: number;
  34. constructor(message: string, status: number) {
  35. super(message);
  36. this.status = status;
  37. }
  38. },
  39. }));
  40. import { api, ApiError } from '../../api/client';
  41. const sampleFilament = {
  42. id: 1,
  43. name: 'PLA Basic',
  44. material: 'PLA',
  45. color_hex: 'FF0000',
  46. color_name: 'Red',
  47. weight: 1000,
  48. spool_weight: 196,
  49. vendor: { id: 1, name: 'Bambu Lab' },
  50. };
  51. describe('SpoolCatalogSettings — mode switching', () => {
  52. beforeEach(() => {
  53. vi.clearAllMocks();
  54. vi.mocked(api.getSpoolCatalog).mockResolvedValue([]);
  55. });
  56. it('hides Spoolman table and shows local CRUD buttons when Spoolman is disabled (400)', async () => {
  57. vi.mocked(api.getSpoolmanInventoryFilaments).mockRejectedValue(
  58. new ApiError('disabled', 400)
  59. );
  60. render(<SpoolCatalogSettings />);
  61. await waitFor(() => {
  62. // Local mode: Add button visible
  63. expect(screen.getByText('common.add')).toBeTruthy();
  64. });
  65. // Spoolman table columns must NOT appear
  66. expect(screen.queryByText('settings.catalog.material')).toBeNull();
  67. expect(screen.queryByText('settings.catalog.spoolWeight')).toBeNull();
  68. // Spoolman catalog title must NOT appear
  69. expect(screen.queryByText('settings.spoolmanFilamentCatalogTitle')).toBeNull();
  70. });
  71. it('shows Spoolman error row when Spoolman is unreachable (503)', async () => {
  72. vi.mocked(api.getSpoolmanInventoryFilaments).mockRejectedValue(
  73. new ApiError('unreachable', 503)
  74. );
  75. render(<SpoolCatalogSettings />);
  76. await waitFor(() => {
  77. expect(screen.getByText('inventory.spoolmanCatalogLoadFailed')).toBeTruthy();
  78. });
  79. // Local CRUD buttons must NOT appear in Spoolman mode
  80. expect(screen.queryByText('common.add')).toBeNull();
  81. });
  82. it('shows empty state when Spoolman returns an empty list', async () => {
  83. vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([]);
  84. render(<SpoolCatalogSettings />);
  85. await waitFor(() => {
  86. expect(screen.getByText('inventory.noSpoolmanFilaments')).toBeTruthy();
  87. });
  88. // Local CRUD buttons must NOT appear
  89. expect(screen.queryByText('common.add')).toBeNull();
  90. });
  91. it('renders Spoolman filament rows with vendor and name combined', async () => {
  92. vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
  93. render(<SpoolCatalogSettings />);
  94. await waitFor(() => {
  95. expect(screen.getByText(/Bambu Lab — PLA Basic/)).toBeTruthy();
  96. });
  97. });
  98. it('(local mode) shows Export, Import, Reset, Add buttons when Spoolman disabled', async () => {
  99. vi.mocked(api.getSpoolmanInventoryFilaments).mockRejectedValue(
  100. new ApiError('disabled', 400)
  101. );
  102. render(<SpoolCatalogSettings />);
  103. await waitFor(() => {
  104. expect(screen.getByText('common.add')).toBeTruthy();
  105. });
  106. expect(screen.getByText('common.export')).toBeTruthy();
  107. expect(screen.getByText('common.import')).toBeTruthy();
  108. expect(screen.getByText('common.reset')).toBeTruthy();
  109. });
  110. it('(spoolman mode) hides Export, Import, Reset, Add buttons when Spoolman is enabled', async () => {
  111. vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
  112. render(<SpoolCatalogSettings />);
  113. await waitFor(() => {
  114. expect(screen.getByText(/Bambu Lab — PLA Basic/)).toBeTruthy();
  115. });
  116. expect(screen.queryByText('common.add')).toBeNull();
  117. expect(screen.queryByText('common.export')).toBeNull();
  118. expect(screen.queryByText('common.import')).toBeNull();
  119. expect(screen.queryByText('common.reset')).toBeNull();
  120. });
  121. it('(spoolman mode) renders correct column headers — Name, Material, Weight, Spool Weight', async () => {
  122. vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
  123. render(<SpoolCatalogSettings />);
  124. await waitFor(() => {
  125. expect(screen.getByText('common.name')).toBeTruthy();
  126. });
  127. expect(screen.getByText('settings.catalog.material')).toBeTruthy();
  128. expect(screen.getByText('settings.catalog.weight')).toBeTruthy();
  129. expect(screen.getByText('settings.catalog.spoolWeight')).toBeTruthy();
  130. });
  131. it('(spoolman mode) renders all data fields for a filament row', async () => {
  132. vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
  133. render(<SpoolCatalogSettings />);
  134. await waitFor(() => {
  135. expect(screen.getByText(/Bambu Lab — PLA Basic/)).toBeTruthy();
  136. });
  137. // Material column
  138. expect(screen.getByText('PLA')).toBeTruthy();
  139. // Filament weight
  140. expect(screen.getByText('1000g')).toBeTruthy();
  141. // Spool (empty) weight
  142. expect(screen.getByText('196g')).toBeTruthy();
  143. });
  144. it('(spoolman mode) renders color swatch with correct background color', async () => {
  145. vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([
  146. { ...sampleFilament, color_hex: 'FF5500' },
  147. ]);
  148. render(<SpoolCatalogSettings />);
  149. await waitFor(() => {
  150. expect(screen.getByText(/Bambu Lab — PLA Basic/)).toBeTruthy();
  151. });
  152. const swatch = screen.getByLabelText('inventory.spoolmanFilamentColorSwatch');
  153. const bg = (swatch as HTMLElement).style.backgroundColor;
  154. // Accepts both hex-like and rgb() representations
  155. expect(bg).toBeTruthy();
  156. expect(bg).not.toBe('');
  157. });
  158. it('(spoolman mode) renders fallback color when color_hex is null', async () => {
  159. vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([
  160. { ...sampleFilament, color_hex: null },
  161. ]);
  162. render(<SpoolCatalogSettings />);
  163. await waitFor(() => {
  164. expect(screen.getByText(/Bambu Lab — PLA Basic/)).toBeTruthy();
  165. });
  166. const swatch = screen.getByLabelText('inventory.spoolmanFilamentColorSwatch');
  167. expect((swatch as HTMLElement).style.backgroundColor).toContain('128');
  168. });
  169. it('(spoolman mode) renders dash for null material, weight, and spool_weight', async () => {
  170. vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([
  171. { ...sampleFilament, material: null, weight: null, spool_weight: null },
  172. ]);
  173. render(<SpoolCatalogSettings />);
  174. await waitFor(() => {
  175. expect(screen.getByText(/Bambu Lab — PLA Basic/)).toBeTruthy();
  176. });
  177. // All three nullable fields must show '—', not 'nullg' or empty string
  178. const dashes = screen.getAllByText('—');
  179. expect(dashes.length).toBeGreaterThanOrEqual(3);
  180. });
  181. it('(spoolman mode) shows Spoolman catalog title, not local catalog title', async () => {
  182. vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
  183. render(<SpoolCatalogSettings />);
  184. await waitFor(() => {
  185. expect(screen.getByText('settings.spoolmanFilamentCatalogTitle')).toBeTruthy();
  186. });
  187. expect(screen.queryByText('settings.catalog.spoolCatalog')).toBeNull();
  188. });
  189. it('(spoolman mode) shows pencil edit button in each filament row', async () => {
  190. vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
  191. render(<SpoolCatalogSettings />);
  192. await waitFor(() => {
  193. expect(screen.getByText(/Bambu Lab — PLA Basic/)).toBeTruthy();
  194. });
  195. const editButtons = screen.getAllByLabelText('common.edit');
  196. expect(editButtons.length).toBeGreaterThanOrEqual(1);
  197. });
  198. it('(spoolman mode) clicking pencil shows name and weight inputs', async () => {
  199. vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
  200. render(<SpoolCatalogSettings />);
  201. await waitFor(() => {
  202. expect(screen.getByLabelText('common.edit')).toBeTruthy();
  203. });
  204. fireEvent.click(screen.getByLabelText('common.edit'));
  205. await waitFor(() => {
  206. expect(screen.getByLabelText('common.name')).toBeTruthy();
  207. expect(screen.getByLabelText('settings.catalog.spoolWeight')).toBeTruthy();
  208. });
  209. });
  210. it('(spoolman mode) name input is pre-filled with current name', async () => {
  211. vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
  212. render(<SpoolCatalogSettings />);
  213. await waitFor(() => {
  214. expect(screen.getByLabelText('common.edit')).toBeTruthy();
  215. });
  216. fireEvent.click(screen.getByLabelText('common.edit'));
  217. await waitFor(() => {
  218. const nameInput = screen.getByLabelText('common.name') as HTMLInputElement;
  219. expect(nameInput.value).toBe('PLA Basic');
  220. });
  221. });
  222. it('(spoolman mode) weight input is pre-filled with current spool_weight', async () => {
  223. vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
  224. render(<SpoolCatalogSettings />);
  225. await waitFor(() => {
  226. expect(screen.getByLabelText('common.edit')).toBeTruthy();
  227. });
  228. fireEvent.click(screen.getByLabelText('common.edit'));
  229. await waitFor(() => {
  230. const weightInput = screen.getByLabelText('settings.catalog.spoolWeight') as HTMLInputElement;
  231. expect(weightInput.value).toBe('196');
  232. });
  233. });
  234. it('(spoolman mode) cancel edit restores read-only display', async () => {
  235. vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
  236. render(<SpoolCatalogSettings />);
  237. await waitFor(() => {
  238. expect(screen.getByLabelText('common.edit')).toBeTruthy();
  239. });
  240. fireEvent.click(screen.getByLabelText('common.edit'));
  241. await waitFor(() => {
  242. expect(screen.getByLabelText('common.cancel')).toBeTruthy();
  243. });
  244. fireEvent.click(screen.getByLabelText('common.cancel'));
  245. await waitFor(() => {
  246. expect(screen.queryByLabelText('common.name')).toBeNull();
  247. expect(screen.getByLabelText('common.edit')).toBeTruthy();
  248. });
  249. });
  250. it('(spoolman mode) empty name input disables save button', async () => {
  251. vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
  252. render(<SpoolCatalogSettings />);
  253. await waitFor(() => {
  254. expect(screen.getByLabelText('common.edit')).toBeTruthy();
  255. });
  256. fireEvent.click(screen.getByLabelText('common.edit'));
  257. await waitFor(() => {
  258. expect(screen.getByLabelText('common.name')).toBeTruthy();
  259. });
  260. const nameInput = screen.getByLabelText('common.name');
  261. fireEvent.change(nameInput, { target: { value: '' } });
  262. const saveBtn = screen.getByLabelText('common.save') as HTMLButtonElement;
  263. expect(saveBtn.disabled).toBe(true);
  264. });
  265. it('(spoolman mode) saving name-only calls patchSpoolmanFilament without modal', async () => {
  266. vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
  267. render(<SpoolCatalogSettings />);
  268. await waitFor(() => {
  269. expect(screen.getByLabelText('common.edit')).toBeTruthy();
  270. });
  271. fireEvent.click(screen.getByLabelText('common.edit'));
  272. await waitFor(() => {
  273. expect(screen.getByLabelText('common.name')).toBeTruthy();
  274. });
  275. const nameInput = screen.getByLabelText('common.name');
  276. fireEvent.change(nameInput, { target: { value: 'PLA Basic Renamed' } });
  277. fireEvent.click(screen.getByLabelText('common.save'));
  278. await waitFor(() => {
  279. expect(vi.mocked(api.patchSpoolmanFilament)).toHaveBeenCalledWith(1, { name: 'PLA Basic Renamed' });
  280. });
  281. // Modal must NOT appear
  282. expect(screen.queryByText('settings.catalog.updateSpoolWeight')).toBeNull();
  283. });
  284. it('(spoolman mode) saving changed spool_weight opens SpoolWeightUpdateModal', async () => {
  285. vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
  286. render(<SpoolCatalogSettings />);
  287. await waitFor(() => {
  288. expect(screen.getByLabelText('common.edit')).toBeTruthy();
  289. });
  290. fireEvent.click(screen.getByLabelText('common.edit'));
  291. await waitFor(() => {
  292. expect(screen.getByLabelText('settings.catalog.spoolWeight')).toBeTruthy();
  293. });
  294. const weightInput = screen.getByLabelText('settings.catalog.spoolWeight');
  295. fireEvent.change(weightInput, { target: { value: '100' } });
  296. fireEvent.click(screen.getByLabelText('common.save'));
  297. await waitFor(() => {
  298. expect(screen.getByText('settings.catalog.updateSpoolWeight')).toBeTruthy();
  299. });
  300. });
  301. it('(spoolman mode) confirming option B calls patchSpoolmanFilament with keep_existing_spools=false', async () => {
  302. vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
  303. render(<SpoolCatalogSettings />);
  304. await waitFor(() => { expect(screen.getByLabelText('common.edit')).toBeTruthy(); });
  305. fireEvent.click(screen.getByLabelText('common.edit'));
  306. await waitFor(() => { expect(screen.getByLabelText('settings.catalog.spoolWeight')).toBeTruthy(); });
  307. fireEvent.change(screen.getByLabelText('settings.catalog.spoolWeight'), { target: { value: '100' } });
  308. fireEvent.click(screen.getByLabelText('common.save'));
  309. await waitFor(() => { expect(screen.getByText('settings.catalog.updateSpoolWeight')).toBeTruthy(); });
  310. // Confirm with option B selected by default
  311. fireEvent.click(screen.getByText('common.confirm'));
  312. await waitFor(() => {
  313. expect(vi.mocked(api.patchSpoolmanFilament)).toHaveBeenCalledWith(
  314. 1,
  315. expect.objectContaining({ spool_weight: 100, keep_existing_spools: false }),
  316. );
  317. });
  318. });
  319. it('(spoolman mode) confirming option A calls patchSpoolmanFilament with keep_existing_spools=true', async () => {
  320. vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
  321. render(<SpoolCatalogSettings />);
  322. await waitFor(() => { expect(screen.getByLabelText('common.edit')).toBeTruthy(); });
  323. fireEvent.click(screen.getByLabelText('common.edit'));
  324. await waitFor(() => { expect(screen.getByLabelText('settings.catalog.spoolWeight')).toBeTruthy(); });
  325. fireEvent.change(screen.getByLabelText('settings.catalog.spoolWeight'), { target: { value: '100' } });
  326. fireEvent.click(screen.getByLabelText('common.save'));
  327. await waitFor(() => { expect(screen.getByText('settings.catalog.updateSpoolWeight')).toBeTruthy(); });
  328. // Select option A (keep existing)
  329. const radios = screen.getAllByRole('radio');
  330. fireEvent.click(radios[1]); // Option A = second radio = keepExisting=true
  331. fireEvent.click(screen.getByText('common.confirm'));
  332. await waitFor(() => {
  333. expect(vi.mocked(api.patchSpoolmanFilament)).toHaveBeenCalledWith(
  334. 1,
  335. expect.objectContaining({ spool_weight: 100, keep_existing_spools: true }),
  336. );
  337. });
  338. });
  339. it('(spoolman mode) negative weight input disables save button', async () => {
  340. vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
  341. render(<SpoolCatalogSettings />);
  342. await waitFor(() => { expect(screen.getByLabelText('common.edit')).toBeTruthy(); });
  343. fireEvent.click(screen.getByLabelText('common.edit'));
  344. await waitFor(() => { expect(screen.getByLabelText('settings.catalog.spoolWeight')).toBeTruthy(); });
  345. fireEvent.change(screen.getByLabelText('settings.catalog.spoolWeight'), { target: { value: '-5' } });
  346. const saveBtn = screen.getByLabelText('common.save') as HTMLButtonElement;
  347. expect(saveBtn.disabled).toBe(true);
  348. });
  349. });