SpoolFormModal.test.tsx 36 KB

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