SpoolFormModal.test.tsx 36 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183
  1. /**
  2. * Tests for the SpoolFormModal weightTouched behavior.
  3. *
  4. * Verifies that weight_used is only included in the PATCH payload when the user
  5. * explicitly changes the remaining weight field. This prevents stale React Query
  6. * cache values from overwriting usage-tracked weight data on the backend.
  7. */
  8. import React from 'react';
  9. import { describe, it, expect, vi, beforeEach } from 'vitest';
  10. import { screen, waitFor, fireEvent } from '@testing-library/react';
  11. import { render } from '../utils';
  12. import { SpoolFormModal } from '../../components/SpoolFormModal';
  13. import type { InventorySpool } from '../../api/client';
  14. // Mock the API client
  15. vi.mock('../../api/client', () => ({
  16. api: {
  17. getSettings: vi.fn().mockResolvedValue({}),
  18. getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
  19. getCloudStatus: vi.fn().mockResolvedValue({ is_authenticated: false }),
  20. getFilamentPresets: vi.fn().mockResolvedValue([]),
  21. getSpoolCatalog: vi.fn().mockResolvedValue([]),
  22. getColorCatalog: vi.fn().mockResolvedValue([]),
  23. getLocalPresets: vi.fn().mockResolvedValue({ filament: [] }),
  24. getBuiltinFilaments: vi.fn().mockResolvedValue([]),
  25. getPrinters: vi.fn().mockResolvedValue([]),
  26. getSpoolUsageHistory: vi.fn().mockResolvedValue([]),
  27. createSpool: vi.fn().mockResolvedValue({ id: 99 }),
  28. createSpoolmanInventorySpool: vi.fn().mockResolvedValue({ id: 88 }),
  29. updateSpool: vi.fn().mockResolvedValue({ id: 1 }),
  30. saveSpoolKProfiles: vi.fn().mockResolvedValue([]),
  31. saveSpoolmanKProfiles: vi.fn().mockResolvedValue([]),
  32. updateSpoolmanInventorySpool: vi.fn().mockResolvedValue({ id: 42 }),
  33. bulkCreateSpoolmanInventorySpools: vi.fn().mockResolvedValue({
  34. created: [{ id: 1, material: 'PLA' }],
  35. requested_count: 1,
  36. failed_count: 0,
  37. }),
  38. getSpoolmanInventoryFilaments: vi.fn().mockResolvedValue([]),
  39. getAssignments: vi.fn().mockResolvedValue([]),
  40. getSpoolmanSlotAssignments: vi.fn().mockResolvedValue([]),
  41. unassignSpool: vi.fn().mockResolvedValue({}),
  42. unassignSpoolmanSlot: vi.fn().mockResolvedValue({}),
  43. },
  44. ApiError: class ApiError extends Error {
  45. status: number;
  46. constructor(message: string, status: number) {
  47. super(message);
  48. this.status = status;
  49. }
  50. },
  51. }));
  52. // Mock validateForm so we can bypass validation for the create-mode test
  53. // (editing tests pass validation naturally since the spool has material + slicer_filament)
  54. vi.mock('../../components/spool-form/types', async (importOriginal) => {
  55. const actual = await importOriginal<typeof import('../../components/spool-form/types')>();
  56. return {
  57. ...actual,
  58. validateForm: vi.fn().mockReturnValue({ isValid: true, errors: {} }),
  59. };
  60. });
  61. // Mock the toast context
  62. const mockShowToast = vi.fn();
  63. vi.mock('../../contexts/ToastContext', async (importOriginal) => {
  64. const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();
  65. return {
  66. ...actual,
  67. useToast: () => ({ showToast: mockShowToast }),
  68. };
  69. });
  70. import { api } from '../../api/client';
  71. const existingSpool: InventorySpool = {
  72. id: 1,
  73. material: 'PLA',
  74. subtype: 'Basic',
  75. brand: 'Polymaker',
  76. color_name: 'Red',
  77. rgba: 'FF0000FF',
  78. extra_colors: null,
  79. effect_type: null,
  80. label_weight: 1000,
  81. core_weight: 250,
  82. core_weight_catalog_id: null,
  83. weight_used: 300,
  84. slicer_filament: 'GFL99',
  85. slicer_filament_name: 'Generic PLA',
  86. nozzle_temp_min: null,
  87. nozzle_temp_max: null,
  88. note: null,
  89. added_full: null,
  90. last_used: null,
  91. encode_time: null,
  92. tag_uid: null,
  93. tray_uuid: null,
  94. data_origin: null,
  95. tag_type: null,
  96. archived_at: null,
  97. created_at: '2025-01-01T00:00:00Z',
  98. updated_at: '2025-01-01T00:00:00Z',
  99. k_profiles: [],
  100. };
  101. describe('SpoolFormModal weightTouched', () => {
  102. beforeEach(() => {
  103. vi.clearAllMocks();
  104. });
  105. it('excludes weight_used from PATCH when editing without changing weight', async () => {
  106. render(
  107. <SpoolFormModal
  108. isOpen={true}
  109. onClose={vi.fn()}
  110. spool={existingSpool}
  111. mode="edit"
  112. currencySymbol="$"
  113. />
  114. );
  115. // Wait for the modal to render with the edit title
  116. await waitFor(() => {
  117. expect(screen.getByText('Edit Spool')).toBeInTheDocument();
  118. });
  119. // Click Save without touching the weight field
  120. const saveButton = screen.getByRole('button', { name: /save/i });
  121. fireEvent.click(saveButton);
  122. await waitFor(() => {
  123. expect(api.updateSpool).toHaveBeenCalledTimes(1);
  124. });
  125. const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
  126. expect(spoolId).toBe(1);
  127. // weight_used must NOT be present in the payload
  128. expect(payload).not.toHaveProperty('weight_used');
  129. // Other fields should still be present
  130. expect(payload).toHaveProperty('material', 'PLA');
  131. expect(payload).toHaveProperty('label_weight', 1000);
  132. });
  133. it('includes weight_used in PATCH when editing and changing remaining weight', async () => {
  134. render(
  135. <SpoolFormModal
  136. isOpen={true}
  137. onClose={vi.fn()}
  138. spool={existingSpool}
  139. mode="edit"
  140. currencySymbol="$"
  141. />
  142. );
  143. await waitFor(() => {
  144. expect(screen.getByText('Edit Spool')).toBeInTheDocument();
  145. });
  146. // The remaining weight is (label_weight - weight_used) = 1000 - 300 = 700.
  147. // The input is a number input displaying 700. Find it by its displayed value.
  148. const remainingInput = screen.getByDisplayValue('700');
  149. expect(remainingInput).toBeInTheDocument();
  150. // Change the remaining weight from 700 to 500 (weight_used becomes 1000 - 500 = 500)
  151. fireEvent.change(remainingInput, { target: { value: '500' } });
  152. // Blur triggers updateField('weight_used', ...) which sets weightTouched
  153. fireEvent.blur(remainingInput);
  154. // Click Save
  155. const saveButton = screen.getByRole('button', { name: /save/i });
  156. fireEvent.click(saveButton);
  157. await waitFor(() => {
  158. expect(api.updateSpool).toHaveBeenCalledTimes(1);
  159. });
  160. const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
  161. expect(spoolId).toBe(1);
  162. // weight_used MUST be present since the user changed the weight
  163. expect(payload).toHaveProperty('weight_used', 500);
  164. });
  165. it('includes weight_used when creating a new spool', async () => {
  166. render(
  167. <SpoolFormModal
  168. isOpen={true}
  169. onClose={vi.fn()}
  170. currencySymbol="$"
  171. />
  172. );
  173. // Wait for the modal to render with the create title
  174. await waitFor(() => {
  175. expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
  176. });
  177. // Click the submit button (validation is mocked to always pass).
  178. // The default form data has weight_used=0, and for create mode the condition
  179. // if (!isEditing || weightTouched) { data.weight_used = formData.weight_used; }
  180. // always includes weight_used since isEditing is false.
  181. // The submit button also says "Add Spool" — use getAllByText and pick the button.
  182. const addButtons = screen.getAllByRole('button', { name: /add spool/i });
  183. const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
  184. expect(submitButton).toBeTruthy();
  185. fireEvent.click(submitButton!);
  186. await waitFor(() => {
  187. expect(api.createSpool).toHaveBeenCalledTimes(1);
  188. });
  189. const [payload] = vi.mocked(api.createSpool).mock.calls[0];
  190. // weight_used MUST be included for new spools (default value 0)
  191. expect(payload).toHaveProperty('weight_used', 0);
  192. });
  193. it('preserves core_weight_catalog_id when editing other fields', async () => {
  194. const spoolWithCatalogId: InventorySpool = {
  195. ...existingSpool,
  196. core_weight_catalog_id: 5,
  197. };
  198. render(
  199. <SpoolFormModal
  200. isOpen={true}
  201. onClose={vi.fn()}
  202. spool={spoolWithCatalogId}
  203. mode="edit"
  204. currencySymbol="$"
  205. />
  206. );
  207. await waitFor(() => {
  208. expect(screen.getByText('Edit Spool')).toBeInTheDocument();
  209. });
  210. // Change the note field (unrelated to catalog ID)
  211. const noteInputs = screen.getAllByPlaceholderText(/note/i);
  212. expect(noteInputs.length).toBeGreaterThan(0);
  213. fireEvent.change(noteInputs[0], { target: { value: 'Updated note' } });
  214. // Click Save
  215. const saveButton = screen.getByRole('button', { name: /save/i });
  216. fireEvent.click(saveButton);
  217. await waitFor(() => {
  218. expect(api.updateSpool).toHaveBeenCalledTimes(1);
  219. });
  220. const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
  221. expect(spoolId).toBe(1);
  222. // core_weight_catalog_id MUST be preserved when editing other fields
  223. expect(payload).toHaveProperty('core_weight_catalog_id', 5);
  224. // Other changes should also be present
  225. expect(payload).toHaveProperty('note', 'Updated note');
  226. });
  227. it('includes core_weight_catalog_id when selecting from catalog', async () => {
  228. const mockCatalog = [
  229. { id: 1, name: 'Generic 250g', weight: 250 },
  230. { id: 2, name: 'Bambu Lab 250g', weight: 250 },
  231. { id: 3, name: 'Standard 300g', weight: 300 },
  232. ];
  233. vi.mocked(api.getSpoolCatalog).mockResolvedValue(mockCatalog);
  234. render(
  235. <SpoolFormModal
  236. isOpen={true}
  237. onClose={vi.fn()}
  238. currencySymbol="$"
  239. />
  240. );
  241. await waitFor(() => {
  242. expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
  243. });
  244. // Wait for catalog to load
  245. await waitFor(() => {
  246. expect(api.getSpoolCatalog).toHaveBeenCalled();
  247. });
  248. // Click on the empty spool weight field to open dropdown
  249. const weightInputs = screen.getAllByPlaceholderText(/search/i);
  250. const weightPicker = weightInputs.find(input =>
  251. input.getAttribute('placeholder')?.toLowerCase().includes('spool')
  252. );
  253. expect(weightPicker).toBeTruthy();
  254. fireEvent.focus(weightPicker!);
  255. // Click on "Bambu Lab 250g" option
  256. const bambuOption = await screen.findByText('Bambu Lab 250g');
  257. fireEvent.click(bambuOption);
  258. // Click the add spool button
  259. const addButtons = screen.getAllByRole('button', { name: /add spool/i });
  260. const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
  261. expect(submitButton).toBeTruthy();
  262. fireEvent.click(submitButton!);
  263. await waitFor(() => {
  264. expect(api.createSpool).toHaveBeenCalledTimes(1);
  265. });
  266. const [payload] = vi.mocked(api.createSpool).mock.calls[0];
  267. // Both weight AND catalog ID should be sent
  268. expect(payload).toHaveProperty('core_weight', 250);
  269. expect(payload).toHaveProperty('core_weight_catalog_id', 2); // ID of "Bambu Lab 250g"
  270. });
  271. it('preserves cost_per_kg when editing spool', async () => {
  272. const spoolWithCost: InventorySpool = {
  273. ...existingSpool,
  274. cost_per_kg: 25.50,
  275. };
  276. render(
  277. <SpoolFormModal
  278. isOpen={true}
  279. onClose={vi.fn()}
  280. spool={spoolWithCost}
  281. mode="edit"
  282. currencySymbol="$"
  283. />
  284. );
  285. await waitFor(() => {
  286. expect(screen.getByText('Edit Spool')).toBeInTheDocument();
  287. });
  288. // Click Save without changing cost
  289. const saveButton = screen.getByRole('button', { name: /save/i });
  290. fireEvent.click(saveButton);
  291. await waitFor(() => {
  292. expect(api.updateSpool).toHaveBeenCalledTimes(1);
  293. });
  294. const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
  295. expect(spoolId).toBe(1);
  296. // cost_per_kg should be preserved in the update payload
  297. expect(payload).toHaveProperty('cost_per_kg', 25.50);
  298. });
  299. it('sends null cost_per_kg when spool has no cost', async () => {
  300. const spoolWithoutCost: InventorySpool = {
  301. ...existingSpool,
  302. cost_per_kg: null,
  303. };
  304. render(
  305. <SpoolFormModal
  306. isOpen={true}
  307. onClose={vi.fn()}
  308. spool={spoolWithoutCost}
  309. mode="edit"
  310. currencySymbol="$"
  311. />
  312. );
  313. await waitFor(() => {
  314. expect(screen.getByText('Edit Spool')).toBeInTheDocument();
  315. });
  316. const saveButton = screen.getByRole('button', { name: /save/i });
  317. fireEvent.click(saveButton);
  318. await waitFor(() => {
  319. expect(api.updateSpool).toHaveBeenCalledTimes(1);
  320. });
  321. const [, payload] = vi.mocked(api.updateSpool).mock.calls[0];
  322. // cost_per_kg should be null when not set
  323. expect(payload).toHaveProperty('cost_per_kg', null);
  324. });
  325. it('normalizes a malformed legacy rgba on edit-form load so PATCH is not rejected (#1055)', async () => {
  326. // #1055 regression guard: a spool with a legacy 7-char rgba (e.g. 'FFFFFFF')
  327. // was editable in the UI but any save 422'd because SpoolUpdate now enforces
  328. // the 8-char pattern. The form must sanitize the loaded value to a valid
  329. // default so users can edit unrelated fields without being forced to fix
  330. // a color they may not even have noticed was broken.
  331. const spoolWithBadRgba: InventorySpool = {
  332. ...existingSpool,
  333. rgba: 'FFFFFFF', // 7 chars — the exact #1055 trigger pattern
  334. };
  335. render(
  336. <SpoolFormModal
  337. isOpen={true}
  338. onClose={vi.fn()}
  339. spool={spoolWithBadRgba}
  340. mode="edit"
  341. currencySymbol="$"
  342. />
  343. );
  344. await waitFor(() => {
  345. expect(screen.getByText('Edit Spool')).toBeInTheDocument();
  346. });
  347. const saveButton = screen.getByRole('button', { name: /save/i });
  348. fireEvent.click(saveButton);
  349. await waitFor(() => {
  350. expect(api.updateSpool).toHaveBeenCalledTimes(1);
  351. });
  352. const [, payload] = vi.mocked(api.updateSpool).mock.calls[0];
  353. // The PATCH payload must carry a valid 8-char rgba — never the raw 7-char
  354. // value loaded from the stale DB row.
  355. expect(payload).toHaveProperty('rgba');
  356. expect(typeof (payload as { rgba: unknown }).rgba).toBe('string');
  357. expect((payload as { rgba: string }).rgba).toMatch(/^[0-9A-Fa-f]{8}$/);
  358. });
  359. it('preserves a valid existing rgba on edit (no forced default)', async () => {
  360. // Sanity: the normalization only kicks in for malformed values. A valid
  361. // 8-char rgba must round-trip untouched so untouched edits don't quietly
  362. // reset a user's chosen color.
  363. render(
  364. <SpoolFormModal
  365. isOpen={true}
  366. onClose={vi.fn()}
  367. spool={existingSpool} // rgba = 'FF0000FF' (valid)
  368. mode="edit"
  369. currencySymbol="$"
  370. />
  371. );
  372. await waitFor(() => {
  373. expect(screen.getByText('Edit Spool')).toBeInTheDocument();
  374. });
  375. const saveButton = screen.getByRole('button', { name: /save/i });
  376. fireEvent.click(saveButton);
  377. await waitFor(() => {
  378. expect(api.updateSpool).toHaveBeenCalledTimes(1);
  379. });
  380. const [, payload] = vi.mocked(api.updateSpool).mock.calls[0];
  381. expect((payload as { rgba: string }).rgba).toBe('FF0000FF');
  382. });
  383. it('shows warning toast on partial bulk-create in Spoolman mode (T1/partial)', async () => {
  384. vi.mocked(api.bulkCreateSpoolmanInventorySpools).mockResolvedValueOnce({
  385. created: [{ id: 1, material: 'PLA' } as InventorySpool],
  386. requested_count: 3,
  387. failed_count: 2,
  388. });
  389. render(
  390. <SpoolFormModal
  391. isOpen={true}
  392. onClose={vi.fn()}
  393. mode="create"
  394. currencySymbol="$"
  395. spoolmanMode={true}
  396. />
  397. );
  398. await waitFor(() => {
  399. expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
  400. });
  401. // Enable Quick Add mode so the quantity field appears
  402. const quickAddRow = screen.getByText('Quick Add (Stock)').closest('div[class*="justify-between"]');
  403. const toggleButton = quickAddRow?.querySelector('button[type="button"]');
  404. expect(toggleButton).toBeTruthy();
  405. fireEvent.click(toggleButton!);
  406. // Set quantity to 3 (triggers bulkCreateMutation instead of createMutation)
  407. const quantityContainer = screen.getByText('Quantity').closest('div');
  408. const quantityInput = quantityContainer?.querySelector('input[type="number"]');
  409. expect(quantityInput).toBeTruthy();
  410. fireEvent.change(quantityInput!, { target: { value: '3' } });
  411. // Click the submit button
  412. const addButtons = screen.getAllByRole('button', { name: /add spool/i });
  413. const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
  414. expect(submitButton).toBeTruthy();
  415. fireEvent.click(submitButton!);
  416. await waitFor(() => {
  417. expect(api.bulkCreateSpoolmanInventorySpools).toHaveBeenCalledTimes(1);
  418. });
  419. // Should show a warning toast for partial failure (1 created, 2 failed, 3 requested)
  420. expect(mockShowToast).toHaveBeenCalledWith(
  421. expect.stringContaining('1 of 3'),
  422. 'warning',
  423. );
  424. });
  425. it('shows success toast on full bulk-create success in Spoolman mode (T1/success)', async () => {
  426. vi.mocked(api.bulkCreateSpoolmanInventorySpools).mockResolvedValueOnce({
  427. created: [
  428. { id: 1, material: 'PLA' } as InventorySpool,
  429. { id: 2, material: 'PLA' } as InventorySpool,
  430. { id: 3, material: 'PLA' } as InventorySpool,
  431. ],
  432. requested_count: 3,
  433. failed_count: 0,
  434. });
  435. render(
  436. <SpoolFormModal
  437. isOpen={true}
  438. onClose={vi.fn()}
  439. mode="create"
  440. currencySymbol="$"
  441. spoolmanMode={true}
  442. />
  443. );
  444. await waitFor(() => {
  445. expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
  446. });
  447. // Enable Quick Add mode so the quantity field appears
  448. const quickAddRow = screen.getByText('Quick Add (Stock)').closest('div[class*="justify-between"]');
  449. const toggleButton = quickAddRow?.querySelector('button[type="button"]');
  450. expect(toggleButton).toBeTruthy();
  451. fireEvent.click(toggleButton!);
  452. // Set quantity to 3
  453. const quantityContainer = screen.getByText('Quantity').closest('div');
  454. const quantityInput = quantityContainer?.querySelector('input[type="number"]');
  455. expect(quantityInput).toBeTruthy();
  456. fireEvent.change(quantityInput!, { target: { value: '3' } });
  457. // Click the submit button
  458. const addButtons = screen.getAllByRole('button', { name: /add spool/i });
  459. const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
  460. expect(submitButton).toBeTruthy();
  461. fireEvent.click(submitButton!);
  462. await waitFor(() => {
  463. expect(api.bulkCreateSpoolmanInventorySpools).toHaveBeenCalledTimes(1);
  464. });
  465. // Should show a success toast listing the count of created spools
  466. expect(mockShowToast).toHaveBeenCalledWith(
  467. expect.stringContaining('3'),
  468. 'success',
  469. );
  470. });
  471. it('displays correct catalog name when duplicates exist', async () => {
  472. const spoolWithCatalogId: InventorySpool = {
  473. ...existingSpool,
  474. core_weight: 250,
  475. core_weight_catalog_id: 2, // "Bambu Lab 250g", not the first match
  476. };
  477. const mockCatalog = [
  478. { id: 1, name: 'Generic 250g', weight: 250 },
  479. { id: 2, name: 'Bambu Lab 250g', weight: 250 },
  480. { id: 3, name: 'Standard 300g', weight: 300 },
  481. ];
  482. vi.mocked(api.getSpoolCatalog).mockResolvedValue(mockCatalog);
  483. render(
  484. <SpoolFormModal
  485. isOpen={true}
  486. onClose={vi.fn()}
  487. spool={spoolWithCatalogId}
  488. mode="edit"
  489. currencySymbol="$"
  490. />
  491. );
  492. await waitFor(() => {
  493. expect(screen.getByText('Edit Spool')).toBeInTheDocument();
  494. });
  495. // Wait for catalog to load
  496. await waitFor(() => {
  497. expect(api.getSpoolCatalog).toHaveBeenCalled();
  498. });
  499. // Should display "Bambu Lab 250g" (by ID), not "Generic 250g" (first match by weight)
  500. await waitFor(() => {
  501. const weightInputs = screen.getAllByDisplayValue(/250|Bambu/i);
  502. const bambuFound = weightInputs.some(input =>
  503. input.value === 'Bambu Lab 250g' || input.getAttribute('value') === 'Bambu Lab 250g'
  504. );
  505. expect(bambuFound).toBeTruthy();
  506. });
  507. });
  508. });
  509. describe('SpoolFormModal Spoolman K-profile support', () => {
  510. const spoolmanSpool: InventorySpool = {
  511. ...{
  512. id: 42,
  513. material: 'PLA',
  514. subtype: 'Basic',
  515. brand: 'BrandX',
  516. color_name: 'Black',
  517. rgba: '000000FF',
  518. label_weight: 1000,
  519. core_weight: 250,
  520. core_weight_catalog_id: null,
  521. weight_used: 200,
  522. slicer_filament: '',
  523. slicer_filament_name: '',
  524. nozzle_temp_min: null,
  525. nozzle_temp_max: null,
  526. note: null,
  527. added_full: null,
  528. last_used: null,
  529. encode_time: null,
  530. tag_uid: null,
  531. tray_uuid: null,
  532. data_origin: 'spoolman',
  533. tag_type: 'spoolman',
  534. archived_at: null,
  535. created_at: '2025-01-01T00:00:00Z',
  536. updated_at: '2025-01-01T00:00:00Z',
  537. k_profiles: [],
  538. },
  539. } as InventorySpool;
  540. beforeEach(() => {
  541. vi.clearAllMocks();
  542. });
  543. it('shows PA Profile tab for Spoolman spools in non-quickAdd mode', async () => {
  544. render(
  545. <SpoolFormModal
  546. isOpen={true}
  547. onClose={vi.fn()}
  548. spool={spoolmanSpool}
  549. mode="edit"
  550. currencySymbol="$"
  551. spoolmanMode={true}
  552. />
  553. );
  554. await waitFor(() => {
  555. expect(screen.getByText('Edit Spool')).toBeInTheDocument();
  556. });
  557. // PA Profile tab should be visible in Spoolman mode
  558. expect(screen.getByText('PA Profile')).toBeInTheDocument();
  559. });
  560. it('calls saveSpoolmanKProfiles (not saveSpoolKProfiles) on update in Spoolman mode', async () => {
  561. render(
  562. <SpoolFormModal
  563. isOpen={true}
  564. onClose={vi.fn()}
  565. spool={spoolmanSpool}
  566. mode="edit"
  567. currencySymbol="$"
  568. spoolmanMode={true}
  569. />
  570. );
  571. await waitFor(() => {
  572. expect(screen.getByText('Edit Spool')).toBeInTheDocument();
  573. });
  574. const saveButton = screen.getByRole('button', { name: /save/i });
  575. fireEvent.click(saveButton);
  576. await waitFor(() => {
  577. expect(api.updateSpoolmanInventorySpool).toHaveBeenCalledTimes(1);
  578. });
  579. // saveSpoolmanKProfiles is always called on update (even with empty list)
  580. await waitFor(() => {
  581. expect(api.saveSpoolmanKProfiles).toHaveBeenCalledWith(42, []);
  582. });
  583. expect(api.saveSpoolKProfiles).not.toHaveBeenCalled();
  584. });
  585. });
  586. // ---------------------------------------------------------------------------
  587. // T2: SpoolmanFilamentPicker integration with SpoolFormModal
  588. // ---------------------------------------------------------------------------
  589. vi.mock('../../components/spool-form/SpoolmanFilamentPicker', () => ({
  590. SpoolmanFilamentPicker: ({ onSelect, selectedId }: { onSelect: (f: unknown) => void; selectedId: number | null; isLoading: boolean; filaments: unknown[] }) => {
  591. return (
  592. <div>
  593. <span data-testid="picker-selected-id">{selectedId ?? 'none'}</span>
  594. <button data-testid="picker-select-btn" onClick={() => onSelect({
  595. id: 7,
  596. name: 'PLA Basic',
  597. material: 'PLA',
  598. color_hex: 'FF0000',
  599. color_name: 'Red',
  600. weight: 1000,
  601. spool_weight: 196,
  602. vendor: { id: 1, name: 'Bambu Lab' },
  603. })}>
  604. Select Filament
  605. </button>
  606. </div>
  607. );
  608. },
  609. }));
  610. describe('SpoolFormModal — SpoolmanFilamentPicker integration (T2)', () => {
  611. beforeEach(() => {
  612. vi.clearAllMocks();
  613. });
  614. it('renders SpoolmanFilamentPicker in Spoolman create mode', async () => {
  615. render(
  616. <SpoolFormModal
  617. isOpen={true}
  618. onClose={vi.fn()}
  619. currencySymbol="$"
  620. spoolmanMode={true}
  621. />
  622. );
  623. await waitFor(() => {
  624. expect(screen.getByTestId('picker-select-btn')).toBeInTheDocument();
  625. });
  626. });
  627. it('does NOT render SpoolmanFilamentPicker in local inventory mode', async () => {
  628. render(
  629. <SpoolFormModal
  630. isOpen={true}
  631. onClose={vi.fn()}
  632. currencySymbol="$"
  633. spoolmanMode={false}
  634. />
  635. );
  636. await waitFor(() => {
  637. expect(screen.queryByTestId('picker-select-btn')).not.toBeInTheDocument();
  638. });
  639. });
  640. it('prefills form fields when a filament is selected from the picker', async () => {
  641. render(
  642. <SpoolFormModal
  643. isOpen={true}
  644. onClose={vi.fn()}
  645. currencySymbol="$"
  646. spoolmanMode={true}
  647. />
  648. );
  649. await waitFor(() => {
  650. expect(screen.getByTestId('picker-select-btn')).toBeInTheDocument();
  651. });
  652. fireEvent.click(screen.getByTestId('picker-select-btn'));
  653. // After selection, the picker should reflect the selected ID
  654. await waitFor(() => {
  655. expect(screen.getByTestId('picker-selected-id').textContent).toBe('7');
  656. });
  657. });
  658. it('includes spoolman_filament_id in the submit payload when a filament is pre-selected', async () => {
  659. render(
  660. <SpoolFormModal
  661. isOpen={true}
  662. onClose={vi.fn()}
  663. currencySymbol="$"
  664. spoolmanMode={true}
  665. spoolsQueryKey={['spoolman-spools']}
  666. />
  667. );
  668. await waitFor(() => {
  669. expect(screen.getByTestId('picker-select-btn')).toBeInTheDocument();
  670. });
  671. // Select a filament
  672. fireEvent.click(screen.getByTestId('picker-select-btn'));
  673. // Submit the form
  674. const saveButton = screen.getByRole('button', { name: /save|add spool/i });
  675. fireEvent.click(saveButton);
  676. await waitFor(() => {
  677. expect(api.createSpoolmanInventorySpool).toHaveBeenCalledTimes(1);
  678. });
  679. const callArg = vi.mocked(api.createSpoolmanInventorySpool).mock.calls[0][0] as Record<string, unknown>;
  680. expect(callArg.spoolman_filament_id).toBe(7);
  681. });
  682. it('clears spoolman_filament_id and shows unlink toast when user edits a linked field', async () => {
  683. render(
  684. <SpoolFormModal
  685. isOpen={true}
  686. onClose={vi.fn()}
  687. currencySymbol="$"
  688. spoolmanMode={true}
  689. />
  690. );
  691. await waitFor(() => {
  692. expect(screen.getByTestId('picker-select-btn')).toBeInTheDocument();
  693. });
  694. // Select a filament from the catalog picker
  695. fireEvent.click(screen.getByTestId('picker-select-btn'));
  696. await waitFor(() => {
  697. expect(screen.getByTestId('picker-selected-id').textContent).toBe('7');
  698. });
  699. // Manually edit the color_name field (a linked field)
  700. const colorNameInput = screen.getByPlaceholderText('Jade White, Fire Red...');
  701. fireEvent.change(colorNameInput, { target: { value: 'Custom Blue' } });
  702. // spoolman_filament_id must be cleared (picker shows 'none')
  703. await waitFor(() => {
  704. expect(screen.getByTestId('picker-selected-id').textContent).toBe('none');
  705. });
  706. // Unlink toast must have been shown
  707. expect(mockShowToast).toHaveBeenCalledWith(
  708. expect.stringContaining('catalog link'),
  709. 'info',
  710. );
  711. });
  712. });
  713. describe('SpoolFormModal — Unassign button (#1336)', () => {
  714. const spoolmanSpool: InventorySpool = {
  715. id: 42,
  716. material: 'PLA',
  717. subtype: 'Basic',
  718. brand: 'BrandX',
  719. color_name: 'Black',
  720. rgba: '000000FF',
  721. extra_colors: null,
  722. effect_type: null,
  723. label_weight: 1000,
  724. core_weight: 250,
  725. core_weight_catalog_id: null,
  726. weight_used: 200,
  727. slicer_filament: '',
  728. slicer_filament_name: '',
  729. nozzle_temp_min: null,
  730. nozzle_temp_max: null,
  731. note: null,
  732. added_full: null,
  733. last_used: null,
  734. encode_time: null,
  735. tag_uid: null,
  736. tray_uuid: null,
  737. data_origin: 'spoolman',
  738. tag_type: 'spoolman',
  739. archived_at: null,
  740. created_at: '2025-01-01T00:00:00Z',
  741. updated_at: '2025-01-01T00:00:00Z',
  742. cost_per_kg: null,
  743. last_scale_weight: null,
  744. last_weighed_at: null,
  745. category: null,
  746. low_stock_threshold_pct: null,
  747. k_profiles: [],
  748. } as InventorySpool;
  749. beforeEach(() => {
  750. vi.clearAllMocks();
  751. });
  752. it('enables Unassign in Spoolman mode when a spoolman_slot_assignment exists for the spool', async () => {
  753. vi.mocked(api.getSpoolmanSlotAssignments).mockResolvedValueOnce([
  754. {
  755. printer_id: 1,
  756. printer_name: 'Test Printer',
  757. ams_id: 0,
  758. tray_id: 2,
  759. spoolman_spool_id: 42,
  760. ams_label: 'AMS 1',
  761. },
  762. ]);
  763. render(
  764. <SpoolFormModal
  765. isOpen={true}
  766. onClose={vi.fn()}
  767. spool={spoolmanSpool}
  768. mode="edit"
  769. currencySymbol="$"
  770. spoolmanMode={true}
  771. />
  772. );
  773. const unassignBtn = await screen.findByRole('button', { name: /unassign/i });
  774. await waitFor(() => {
  775. expect(unassignBtn).not.toBeDisabled();
  776. });
  777. fireEvent.click(unassignBtn);
  778. await waitFor(() => {
  779. expect(api.unassignSpoolmanSlot).toHaveBeenCalledWith(42);
  780. });
  781. expect(api.unassignSpool).not.toHaveBeenCalled();
  782. });
  783. it('keeps Unassign disabled in Spoolman mode when no slot assignment exists', async () => {
  784. vi.mocked(api.getSpoolmanSlotAssignments).mockResolvedValueOnce([]);
  785. render(
  786. <SpoolFormModal
  787. isOpen={true}
  788. onClose={vi.fn()}
  789. spool={spoolmanSpool}
  790. mode="edit"
  791. currencySymbol="$"
  792. spoolmanMode={true}
  793. />
  794. );
  795. const unassignBtn = await screen.findByRole('button', { name: /unassign/i });
  796. // Wait one tick for the (empty) query result to settle so the disabled state is final.
  797. await waitFor(() => {
  798. expect(api.getSpoolmanSlotAssignments).toHaveBeenCalled();
  799. });
  800. expect(unassignBtn).toBeDisabled();
  801. });
  802. });
  803. describe('SpoolFormModal storageLocationTouched', () => {
  804. /**
  805. * Regression tests for the round-trip bug: saving the edit modal without
  806. * touching the Storage Location field must NOT include storage_location in
  807. * the PATCH payload, so Spoolman's location field is never overwritten with
  808. * a stale cached value.
  809. */
  810. beforeEach(() => {
  811. vi.clearAllMocks();
  812. });
  813. const spoolWithStorageLocation: InventorySpool = {
  814. ...existingSpool,
  815. storage_location: 'IKEAREGAL',
  816. };
  817. it('excludes storage_location from PATCH when editing without changing it', async () => {
  818. render(
  819. <SpoolFormModal
  820. isOpen={true}
  821. onClose={vi.fn()}
  822. spool={spoolWithStorageLocation}
  823. mode="edit"
  824. currencySymbol="$"
  825. />
  826. );
  827. await waitFor(() => {
  828. expect(screen.getByText('Edit Spool')).toBeInTheDocument();
  829. });
  830. // Save without touching the storage location field
  831. const saveButton = screen.getByRole('button', { name: /save/i });
  832. fireEvent.click(saveButton);
  833. await waitFor(() => {
  834. expect(api.updateSpool).toHaveBeenCalledTimes(1);
  835. });
  836. const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
  837. expect(spoolId).toBe(1);
  838. // storage_location must NOT be in the payload — prevents Spoolman location overwrite
  839. expect(payload).not.toHaveProperty('storage_location');
  840. // Other fields should still be present
  841. expect(payload).toHaveProperty('material', 'PLA');
  842. });
  843. it('includes storage_location in PATCH when editing and changing it', async () => {
  844. render(
  845. <SpoolFormModal
  846. isOpen={true}
  847. onClose={vi.fn()}
  848. spool={spoolWithStorageLocation}
  849. mode="edit"
  850. currencySymbol="$"
  851. />
  852. );
  853. await waitFor(() => {
  854. expect(screen.getByText('Edit Spool')).toBeInTheDocument();
  855. });
  856. // Find the storage location input and change it
  857. const locationInput = screen.getByPlaceholderText('e.g. Shelf A, Drawer 1');
  858. fireEvent.change(locationInput, { target: { value: 'Shelf B' } });
  859. const saveButton = screen.getByRole('button', { name: /save/i });
  860. fireEvent.click(saveButton);
  861. await waitFor(() => {
  862. expect(api.updateSpool).toHaveBeenCalledTimes(1);
  863. });
  864. const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
  865. expect(spoolId).toBe(1);
  866. // storage_location MUST be present since the user changed it
  867. expect(payload).toHaveProperty('storage_location', 'Shelf B');
  868. });
  869. it('includes storage_location when creating a new spool', async () => {
  870. render(
  871. <SpoolFormModal
  872. isOpen={true}
  873. onClose={vi.fn()}
  874. currencySymbol="$"
  875. />
  876. );
  877. await waitFor(() => {
  878. expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
  879. });
  880. // Submit without setting storage_location (validation is mocked to pass)
  881. const addButtons = screen.getAllByRole('button', { name: /add spool/i });
  882. const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
  883. expect(submitButton).toBeTruthy();
  884. fireEvent.click(submitButton!);
  885. await waitFor(() => {
  886. expect(api.createSpool).toHaveBeenCalledTimes(1);
  887. });
  888. const [payload] = vi.mocked(api.createSpool).mock.calls[0];
  889. // storage_location MUST be included for new spools (default empty string → null)
  890. expect(payload).toHaveProperty('storage_location', null);
  891. });
  892. });
  893. describe('SpoolFormModal copy mode', () => {
  894. beforeEach(() => {
  895. vi.clearAllMocks();
  896. });
  897. it('shows "Copy Spool" as the modal title when spool and mode="copy" are passed', async () => {
  898. render(
  899. <SpoolFormModal
  900. isOpen={true}
  901. onClose={vi.fn()}
  902. spool={existingSpool}
  903. mode="copy"
  904. currencySymbol="$"
  905. />
  906. );
  907. await waitFor(() => {
  908. expect(screen.getByRole('heading', { name: 'Copy Spool' })).toBeInTheDocument();
  909. });
  910. });
  911. it('calls api.createSpool (not api.updateSpool) when saving in copy mode', async () => {
  912. render(
  913. <SpoolFormModal
  914. isOpen={true}
  915. onClose={vi.fn()}
  916. spool={existingSpool}
  917. mode="copy"
  918. currencySymbol="$"
  919. />
  920. );
  921. await waitFor(() => {
  922. expect(screen.getByRole('heading', { name: 'Copy Spool' })).toBeInTheDocument();
  923. });
  924. // The save button label is "Copy Spool" in copy mode
  925. const saveBtn = screen.getAllByRole('button', { name: /copy spool/i })
  926. .find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg'));
  927. expect(saveBtn).toBeTruthy();
  928. fireEvent.click(saveBtn!);
  929. await waitFor(() => {
  930. expect(api.createSpool).toHaveBeenCalledTimes(1);
  931. });
  932. expect(api.updateSpool).not.toHaveBeenCalled();
  933. });
  934. it('resets weight_used to 0 in the create payload when copying a spool with non-zero usage', async () => {
  935. // existingSpool has weight_used: 300 — must become 0 on copy
  936. render(
  937. <SpoolFormModal
  938. isOpen={true}
  939. onClose={vi.fn()}
  940. spool={existingSpool}
  941. mode="copy"
  942. currencySymbol="$"
  943. />
  944. );
  945. await waitFor(() => {
  946. expect(screen.getByRole('heading', { name: 'Copy Spool' })).toBeInTheDocument();
  947. });
  948. const saveBtn = screen.getAllByRole('button', { name: /copy spool/i })
  949. .find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg'));
  950. expect(saveBtn).toBeTruthy();
  951. fireEvent.click(saveBtn!);
  952. await waitFor(() => {
  953. expect(api.createSpool).toHaveBeenCalledTimes(1);
  954. });
  955. const [payload] = vi.mocked(api.createSpool).mock.calls[0];
  956. expect((payload as Record<string, unknown>).weight_used).toBe(0);
  957. });
  958. });
  959. // The "#<id>" affordance in the modal header (#1385) is only meaningful when
  960. // editing an existing spool — there's no ID yet on create, and the copy path
  961. // is producing a new spool too. Guard all three cases so a future refactor
  962. // can't quietly start leaking the source spool's ID into the Copy modal.
  963. describe('SpoolFormModal header spool ID (#1385)', () => {
  964. beforeEach(() => {
  965. vi.clearAllMocks();
  966. });
  967. it('shows #<id> next to the title when editing an existing spool', async () => {
  968. render(
  969. <SpoolFormModal
  970. isOpen={true}
  971. onClose={vi.fn()}
  972. spool={existingSpool}
  973. mode="edit"
  974. currencySymbol="$"
  975. />
  976. );
  977. await waitFor(() => {
  978. expect(screen.getByText('Edit Spool')).toBeInTheDocument();
  979. });
  980. // existingSpool.id is 1; render as "#1" in the modal header.
  981. expect(screen.getByText('#1')).toBeInTheDocument();
  982. });
  983. it('does not show an ID when creating a new spool', async () => {
  984. render(
  985. <SpoolFormModal
  986. isOpen={true}
  987. onClose={vi.fn()}
  988. currencySymbol="$"
  989. />
  990. );
  991. await waitFor(() => {
  992. expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
  993. });
  994. // No spool exists yet → header carries no "#..." token.
  995. expect(screen.queryByText(/^#\d+$/)).not.toBeInTheDocument();
  996. });
  997. it('does not leak the source spool ID when copying', async () => {
  998. // Copying produces a fresh spool — surfacing the source ID in the
  999. // "Copy Spool" header would mislead the user into thinking the new
  1000. // spool inherits it.
  1001. render(
  1002. <SpoolFormModal
  1003. isOpen={true}
  1004. onClose={vi.fn()}
  1005. spool={existingSpool}
  1006. mode="copy"
  1007. currencySymbol="$"
  1008. />
  1009. );
  1010. await waitFor(() => {
  1011. expect(screen.getByRole('heading', { name: 'Copy Spool' })).toBeInTheDocument();
  1012. });
  1013. expect(screen.queryByText(/^#\d+$/)).not.toBeInTheDocument();
  1014. });
  1015. });