SpoolFormModal.test.tsx 55 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744
  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. getSpoolFilamentPresets: vi.fn().mockResolvedValue([]),
  33. saveSpoolFilamentPresets: vi.fn().mockResolvedValue([]),
  34. getSpoolmanFilamentPresets: vi.fn().mockResolvedValue([]),
  35. saveSpoolmanFilamentPresets: vi.fn().mockResolvedValue([]),
  36. saveSpoolmanKProfiles: vi.fn().mockResolvedValue([]),
  37. updateSpoolmanInventorySpool: vi.fn().mockResolvedValue({ id: 42 }),
  38. bulkCreateSpoolmanInventorySpools: vi.fn().mockResolvedValue({
  39. created: [{ id: 1, material: 'PLA' }],
  40. requested_count: 1,
  41. failed_count: 0,
  42. }),
  43. getSpoolmanInventoryFilaments: vi.fn().mockResolvedValue([]),
  44. getAssignments: vi.fn().mockResolvedValue([]),
  45. getSpoolmanSlotAssignments: vi.fn().mockResolvedValue([]),
  46. unassignSpool: vi.fn().mockResolvedValue({}),
  47. unassignSpoolmanSlot: vi.fn().mockResolvedValue({}),
  48. // Suppliers (#2988) — the SupplierSection inside the form loads these.
  49. getSuppliers: vi.fn().mockResolvedValue([]),
  50. createSupplier: vi.fn().mockResolvedValue({ id: 1, name: 'S' }),
  51. setSpoolSuppliers: vi.fn().mockResolvedValue([]),
  52. setSpoolmanSpoolSuppliers: vi.fn().mockResolvedValue([]),
  53. },
  54. ApiError: class ApiError extends Error {
  55. status: number;
  56. constructor(message: string, status: number) {
  57. super(message);
  58. this.status = status;
  59. }
  60. },
  61. }));
  62. // Mock validateForm so we can bypass validation for the create-mode test
  63. // (editing tests pass validation naturally since the spool has material + slicer_filament)
  64. vi.mock('../../components/spool-form/types', async (importOriginal) => {
  65. const actual = await importOriginal<typeof import('../../components/spool-form/types')>();
  66. return {
  67. ...actual,
  68. validateForm: vi.fn().mockReturnValue({ isValid: true, errors: {} }),
  69. };
  70. });
  71. // Mock the toast context
  72. const mockShowToast = vi.fn();
  73. vi.mock('../../contexts/ToastContext', async (importOriginal) => {
  74. const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();
  75. return {
  76. ...actual,
  77. useToast: () => ({ showToast: mockShowToast }),
  78. };
  79. });
  80. import { api } from '../../api/client';
  81. /**
  82. * Open the spool form's "Color & Cost" tab.
  83. *
  84. * The form is split across three tabs -- Filament (identity + preset), Color &
  85. * Cost (colour, spool weights, price, category, location) and Printers
  86. * (per-model preset + per-hotend K profile). Fields that used to sit in one
  87. * long scroll under Filament now need their tab opened first.
  88. */
  89. function openColorAndCostTab() {
  90. fireEvent.click(screen.getByText('Color & Cost'));
  91. }
  92. const existingSpool: InventorySpool = {
  93. id: 1,
  94. material: 'PLA',
  95. subtype: 'Basic',
  96. brand: 'Polymaker',
  97. color_name: 'Red',
  98. rgba: 'FF0000FF',
  99. extra_colors: null,
  100. effect_type: null,
  101. label_weight: 1000,
  102. core_weight: 250,
  103. core_weight_catalog_id: null,
  104. weight_used: 300,
  105. slicer_filament: 'GFL99',
  106. slicer_filament_name: 'Generic PLA',
  107. nozzle_temp_min: null,
  108. nozzle_temp_max: null,
  109. note: null,
  110. added_full: null,
  111. last_used: null,
  112. encode_time: null,
  113. tag_uid: null,
  114. tray_uuid: null,
  115. data_origin: null,
  116. tag_type: null,
  117. archived_at: null,
  118. created_at: '2025-01-01T00:00:00Z',
  119. updated_at: '2025-01-01T00:00:00Z',
  120. k_profiles: [],
  121. };
  122. describe('SpoolFormModal weightTouched', () => {
  123. beforeEach(() => {
  124. vi.clearAllMocks();
  125. });
  126. it('excludes weight_used from PATCH when editing without changing weight', async () => {
  127. render(
  128. <SpoolFormModal
  129. isOpen={true}
  130. onClose={vi.fn()}
  131. spool={existingSpool}
  132. mode="edit"
  133. currencySymbol="$"
  134. />
  135. );
  136. // Wait for the modal to render with the edit title
  137. await waitFor(() => {
  138. expect(screen.getByText('Edit Spool')).toBeInTheDocument();
  139. });
  140. // Click Save without touching the weight field
  141. const saveButton = screen.getByRole('button', { name: /save/i });
  142. fireEvent.click(saveButton);
  143. await waitFor(() => {
  144. expect(api.updateSpool).toHaveBeenCalledTimes(1);
  145. });
  146. const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
  147. expect(spoolId).toBe(1);
  148. // weight_used must NOT be present in the payload
  149. expect(payload).not.toHaveProperty('weight_used');
  150. // Other fields should still be present
  151. expect(payload).toHaveProperty('material', 'PLA');
  152. expect(payload).toHaveProperty('label_weight', 1000);
  153. });
  154. it('includes weight_used in PATCH when editing and changing remaining weight', async () => {
  155. render(
  156. <SpoolFormModal
  157. isOpen={true}
  158. onClose={vi.fn()}
  159. spool={existingSpool}
  160. mode="edit"
  161. currencySymbol="$"
  162. />
  163. );
  164. await waitFor(() => {
  165. expect(screen.getByText('Edit Spool')).toBeInTheDocument();
  166. });
  167. openColorAndCostTab();
  168. // The remaining weight is (label_weight - weight_used) = 1000 - 300 = 700.
  169. // The input is a number input displaying 700. Find it by its displayed value.
  170. const remainingInput = screen.getByDisplayValue('700');
  171. expect(remainingInput).toBeInTheDocument();
  172. // Change the remaining weight from 700 to 500 (weight_used becomes 1000 - 500 = 500)
  173. fireEvent.change(remainingInput, { target: { value: '500' } });
  174. // Blur triggers updateField('weight_used', ...) which sets weightTouched
  175. fireEvent.blur(remainingInput);
  176. // Click Save
  177. const saveButton = screen.getByRole('button', { name: /save/i });
  178. fireEvent.click(saveButton);
  179. await waitFor(() => {
  180. expect(api.updateSpool).toHaveBeenCalledTimes(1);
  181. });
  182. const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
  183. expect(spoolId).toBe(1);
  184. // weight_used MUST be present since the user changed the weight
  185. expect(payload).toHaveProperty('weight_used', 500);
  186. });
  187. it('includes weight_used when creating a new spool', async () => {
  188. render(
  189. <SpoolFormModal
  190. isOpen={true}
  191. onClose={vi.fn()}
  192. currencySymbol="$"
  193. />
  194. );
  195. // Wait for the modal to render with the create title
  196. await waitFor(() => {
  197. expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
  198. });
  199. // Click the submit button (validation is mocked to always pass).
  200. // The default form data has weight_used=0, and for create mode the condition
  201. // if (!isEditing || weightTouched) { data.weight_used = formData.weight_used; }
  202. // always includes weight_used since isEditing is false.
  203. // The submit button also says "Add Spool" — use getAllByText and pick the button.
  204. const addButtons = screen.getAllByRole('button', { name: /add spool/i });
  205. const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
  206. expect(submitButton).toBeTruthy();
  207. fireEvent.click(submitButton!);
  208. await waitFor(() => {
  209. expect(api.createSpool).toHaveBeenCalledTimes(1);
  210. });
  211. const [payload] = vi.mocked(api.createSpool).mock.calls[0];
  212. // weight_used MUST be included for new spools (default value 0)
  213. expect(payload).toHaveProperty('weight_used', 0);
  214. });
  215. it('preserves core_weight_catalog_id when editing other fields', async () => {
  216. const spoolWithCatalogId: InventorySpool = {
  217. ...existingSpool,
  218. core_weight_catalog_id: 5,
  219. };
  220. render(
  221. <SpoolFormModal
  222. isOpen={true}
  223. onClose={vi.fn()}
  224. spool={spoolWithCatalogId}
  225. mode="edit"
  226. currencySymbol="$"
  227. />
  228. );
  229. await waitFor(() => {
  230. expect(screen.getByText('Edit Spool')).toBeInTheDocument();
  231. });
  232. openColorAndCostTab();
  233. // Change the note field (unrelated to catalog ID)
  234. const noteInputs = screen.getAllByPlaceholderText(/note/i);
  235. expect(noteInputs.length).toBeGreaterThan(0);
  236. fireEvent.change(noteInputs[0], { target: { value: 'Updated note' } });
  237. // Click Save
  238. const saveButton = screen.getByRole('button', { name: /save/i });
  239. fireEvent.click(saveButton);
  240. await waitFor(() => {
  241. expect(api.updateSpool).toHaveBeenCalledTimes(1);
  242. });
  243. const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
  244. expect(spoolId).toBe(1);
  245. // core_weight_catalog_id MUST be preserved when editing other fields
  246. expect(payload).toHaveProperty('core_weight_catalog_id', 5);
  247. // Other changes should also be present
  248. expect(payload).toHaveProperty('note', 'Updated note');
  249. });
  250. it('includes core_weight_catalog_id when selecting from catalog', async () => {
  251. const mockCatalog = [
  252. { id: 1, name: 'Generic 250g', weight: 250 },
  253. { id: 2, name: 'Bambu Lab 250g', weight: 250 },
  254. { id: 3, name: 'Standard 300g', weight: 300 },
  255. ];
  256. vi.mocked(api.getSpoolCatalog).mockResolvedValue(mockCatalog);
  257. render(
  258. <SpoolFormModal
  259. isOpen={true}
  260. onClose={vi.fn()}
  261. currencySymbol="$"
  262. />
  263. );
  264. await waitFor(() => {
  265. expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
  266. });
  267. openColorAndCostTab();
  268. // Wait for catalog to load
  269. await waitFor(() => {
  270. expect(api.getSpoolCatalog).toHaveBeenCalled();
  271. });
  272. // Click on the empty spool weight field to open dropdown
  273. const weightInputs = screen.getAllByPlaceholderText(/search/i);
  274. const weightPicker = weightInputs.find(input =>
  275. input.getAttribute('placeholder')?.toLowerCase().includes('spool')
  276. );
  277. expect(weightPicker).toBeTruthy();
  278. fireEvent.focus(weightPicker!);
  279. // Click on "Bambu Lab 250g" option
  280. const bambuOption = await screen.findByText('Bambu Lab 250g');
  281. fireEvent.click(bambuOption);
  282. // Click the add spool button
  283. const addButtons = screen.getAllByRole('button', { name: /add spool/i });
  284. const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
  285. expect(submitButton).toBeTruthy();
  286. fireEvent.click(submitButton!);
  287. await waitFor(() => {
  288. expect(api.createSpool).toHaveBeenCalledTimes(1);
  289. });
  290. const [payload] = vi.mocked(api.createSpool).mock.calls[0];
  291. // Both weight AND catalog ID should be sent
  292. expect(payload).toHaveProperty('core_weight', 250);
  293. expect(payload).toHaveProperty('core_weight_catalog_id', 2); // ID of "Bambu Lab 250g"
  294. });
  295. it('preserves cost_per_kg when editing spool', async () => {
  296. const spoolWithCost: InventorySpool = {
  297. ...existingSpool,
  298. cost_per_kg: 25.50,
  299. };
  300. render(
  301. <SpoolFormModal
  302. isOpen={true}
  303. onClose={vi.fn()}
  304. spool={spoolWithCost}
  305. mode="edit"
  306. currencySymbol="$"
  307. />
  308. );
  309. await waitFor(() => {
  310. expect(screen.getByText('Edit Spool')).toBeInTheDocument();
  311. });
  312. // Click Save without changing cost
  313. const saveButton = screen.getByRole('button', { name: /save/i });
  314. fireEvent.click(saveButton);
  315. await waitFor(() => {
  316. expect(api.updateSpool).toHaveBeenCalledTimes(1);
  317. });
  318. const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
  319. expect(spoolId).toBe(1);
  320. // cost_per_kg should be preserved in the update payload
  321. expect(payload).toHaveProperty('cost_per_kg', 25.50);
  322. });
  323. it('sends null cost_per_kg when spool has no cost', async () => {
  324. const spoolWithoutCost: InventorySpool = {
  325. ...existingSpool,
  326. cost_per_kg: null,
  327. };
  328. render(
  329. <SpoolFormModal
  330. isOpen={true}
  331. onClose={vi.fn()}
  332. spool={spoolWithoutCost}
  333. mode="edit"
  334. currencySymbol="$"
  335. />
  336. );
  337. await waitFor(() => {
  338. expect(screen.getByText('Edit Spool')).toBeInTheDocument();
  339. });
  340. const saveButton = screen.getByRole('button', { name: /save/i });
  341. fireEvent.click(saveButton);
  342. await waitFor(() => {
  343. expect(api.updateSpool).toHaveBeenCalledTimes(1);
  344. });
  345. const [, payload] = vi.mocked(api.updateSpool).mock.calls[0];
  346. // cost_per_kg should be null when not set
  347. expect(payload).toHaveProperty('cost_per_kg', null);
  348. });
  349. it('normalizes a malformed legacy rgba on edit-form load so PATCH is not rejected (#1055)', async () => {
  350. // #1055 regression guard: a spool with a legacy 7-char rgba (e.g. 'FFFFFFF')
  351. // was editable in the UI but any save 422'd because SpoolUpdate now enforces
  352. // the 8-char pattern. The form must sanitize the loaded value to a valid
  353. // default so users can edit unrelated fields without being forced to fix
  354. // a color they may not even have noticed was broken.
  355. const spoolWithBadRgba: InventorySpool = {
  356. ...existingSpool,
  357. rgba: 'FFFFFFF', // 7 chars — the exact #1055 trigger pattern
  358. };
  359. render(
  360. <SpoolFormModal
  361. isOpen={true}
  362. onClose={vi.fn()}
  363. spool={spoolWithBadRgba}
  364. mode="edit"
  365. currencySymbol="$"
  366. />
  367. );
  368. await waitFor(() => {
  369. expect(screen.getByText('Edit Spool')).toBeInTheDocument();
  370. });
  371. const saveButton = screen.getByRole('button', { name: /save/i });
  372. fireEvent.click(saveButton);
  373. await waitFor(() => {
  374. expect(api.updateSpool).toHaveBeenCalledTimes(1);
  375. });
  376. const [, payload] = vi.mocked(api.updateSpool).mock.calls[0];
  377. // The PATCH payload must carry a valid 8-char rgba — never the raw 7-char
  378. // value loaded from the stale DB row.
  379. expect(payload).toHaveProperty('rgba');
  380. expect(typeof (payload as { rgba: unknown }).rgba).toBe('string');
  381. expect((payload as { rgba: string }).rgba).toMatch(/^[0-9A-Fa-f]{8}$/);
  382. });
  383. it('preserves a valid existing rgba on edit (no forced default)', async () => {
  384. // Sanity: the normalization only kicks in for malformed values. A valid
  385. // 8-char rgba must round-trip untouched so untouched edits don't quietly
  386. // reset a user's chosen color.
  387. render(
  388. <SpoolFormModal
  389. isOpen={true}
  390. onClose={vi.fn()}
  391. spool={existingSpool} // rgba = 'FF0000FF' (valid)
  392. mode="edit"
  393. currencySymbol="$"
  394. />
  395. );
  396. await waitFor(() => {
  397. expect(screen.getByText('Edit Spool')).toBeInTheDocument();
  398. });
  399. const saveButton = screen.getByRole('button', { name: /save/i });
  400. fireEvent.click(saveButton);
  401. await waitFor(() => {
  402. expect(api.updateSpool).toHaveBeenCalledTimes(1);
  403. });
  404. const [, payload] = vi.mocked(api.updateSpool).mock.calls[0];
  405. expect((payload as { rgba: string }).rgba).toBe('FF0000FF');
  406. });
  407. it('shows warning toast on partial bulk-create in Spoolman mode (T1/partial)', async () => {
  408. vi.mocked(api.bulkCreateSpoolmanInventorySpools).mockResolvedValueOnce({
  409. created: [{ id: 1, material: 'PLA' } as InventorySpool],
  410. requested_count: 3,
  411. failed_count: 2,
  412. });
  413. render(
  414. <SpoolFormModal
  415. isOpen={true}
  416. onClose={vi.fn()}
  417. mode="create"
  418. currencySymbol="$"
  419. spoolmanMode={true}
  420. />
  421. );
  422. await waitFor(() => {
  423. expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
  424. });
  425. // Enable Quick Add mode so the quantity field appears
  426. const quickAddRow = screen.getByText('Quick Add (Stock)').closest('div[class*="justify-between"]');
  427. const toggleButton = quickAddRow?.querySelector('button[type="button"]');
  428. expect(toggleButton).toBeTruthy();
  429. fireEvent.click(toggleButton!);
  430. // Set quantity to 3 (triggers bulkCreateMutation instead of createMutation)
  431. const quantityContainer = screen.getByText('Quantity').closest('div');
  432. const quantityInput = quantityContainer?.querySelector('input[type="number"]');
  433. expect(quantityInput).toBeTruthy();
  434. fireEvent.change(quantityInput!, { target: { value: '3' } });
  435. // Click the submit button
  436. const addButtons = screen.getAllByRole('button', { name: /add spool/i });
  437. const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
  438. expect(submitButton).toBeTruthy();
  439. fireEvent.click(submitButton!);
  440. await waitFor(() => {
  441. expect(api.bulkCreateSpoolmanInventorySpools).toHaveBeenCalledTimes(1);
  442. });
  443. // Should show a warning toast for partial failure (1 created, 2 failed, 3 requested)
  444. expect(mockShowToast).toHaveBeenCalledWith(
  445. expect.stringContaining('1 of 3'),
  446. 'warning',
  447. );
  448. });
  449. it('shows success toast on full bulk-create success in Spoolman mode (T1/success)', async () => {
  450. vi.mocked(api.bulkCreateSpoolmanInventorySpools).mockResolvedValueOnce({
  451. created: [
  452. { id: 1, material: 'PLA' } as InventorySpool,
  453. { id: 2, material: 'PLA' } as InventorySpool,
  454. { id: 3, material: 'PLA' } as InventorySpool,
  455. ],
  456. requested_count: 3,
  457. failed_count: 0,
  458. });
  459. render(
  460. <SpoolFormModal
  461. isOpen={true}
  462. onClose={vi.fn()}
  463. mode="create"
  464. currencySymbol="$"
  465. spoolmanMode={true}
  466. />
  467. );
  468. await waitFor(() => {
  469. expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
  470. });
  471. // Enable Quick Add mode so the quantity field appears
  472. const quickAddRow = screen.getByText('Quick Add (Stock)').closest('div[class*="justify-between"]');
  473. const toggleButton = quickAddRow?.querySelector('button[type="button"]');
  474. expect(toggleButton).toBeTruthy();
  475. fireEvent.click(toggleButton!);
  476. // Set quantity to 3
  477. const quantityContainer = screen.getByText('Quantity').closest('div');
  478. const quantityInput = quantityContainer?.querySelector('input[type="number"]');
  479. expect(quantityInput).toBeTruthy();
  480. fireEvent.change(quantityInput!, { target: { value: '3' } });
  481. // Click the submit button
  482. const addButtons = screen.getAllByRole('button', { name: /add spool/i });
  483. const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
  484. expect(submitButton).toBeTruthy();
  485. fireEvent.click(submitButton!);
  486. await waitFor(() => {
  487. expect(api.bulkCreateSpoolmanInventorySpools).toHaveBeenCalledTimes(1);
  488. });
  489. // Should show a success toast listing the count of created spools
  490. expect(mockShowToast).toHaveBeenCalledWith(
  491. expect.stringContaining('3'),
  492. 'success',
  493. );
  494. });
  495. it('displays correct catalog name when duplicates exist', async () => {
  496. const spoolWithCatalogId: InventorySpool = {
  497. ...existingSpool,
  498. core_weight: 250,
  499. core_weight_catalog_id: 2, // "Bambu Lab 250g", not the first match
  500. };
  501. const mockCatalog = [
  502. { id: 1, name: 'Generic 250g', weight: 250 },
  503. { id: 2, name: 'Bambu Lab 250g', weight: 250 },
  504. { id: 3, name: 'Standard 300g', weight: 300 },
  505. ];
  506. vi.mocked(api.getSpoolCatalog).mockResolvedValue(mockCatalog);
  507. render(
  508. <SpoolFormModal
  509. isOpen={true}
  510. onClose={vi.fn()}
  511. spool={spoolWithCatalogId}
  512. mode="edit"
  513. currencySymbol="$"
  514. />
  515. );
  516. await waitFor(() => {
  517. expect(screen.getByText('Edit Spool')).toBeInTheDocument();
  518. });
  519. openColorAndCostTab();
  520. // Wait for catalog to load
  521. await waitFor(() => {
  522. expect(api.getSpoolCatalog).toHaveBeenCalled();
  523. });
  524. // Should display "Bambu Lab 250g" (by ID), not "Generic 250g" (first match by weight)
  525. await waitFor(() => {
  526. const weightInputs = screen.getAllByDisplayValue(/250|Bambu/i);
  527. const bambuFound = weightInputs.some(input =>
  528. input.value === 'Bambu Lab 250g' || input.getAttribute('value') === 'Bambu Lab 250g'
  529. );
  530. expect(bambuFound).toBeTruthy();
  531. });
  532. });
  533. });
  534. describe('SpoolFormModal Spoolman K-profile support', () => {
  535. const spoolmanSpool: InventorySpool = {
  536. ...{
  537. id: 42,
  538. material: 'PLA',
  539. subtype: 'Basic',
  540. brand: 'BrandX',
  541. color_name: 'Black',
  542. rgba: '000000FF',
  543. label_weight: 1000,
  544. core_weight: 250,
  545. core_weight_catalog_id: null,
  546. weight_used: 200,
  547. slicer_filament: '',
  548. slicer_filament_name: '',
  549. nozzle_temp_min: null,
  550. nozzle_temp_max: null,
  551. note: null,
  552. added_full: null,
  553. last_used: null,
  554. encode_time: null,
  555. tag_uid: null,
  556. tray_uuid: null,
  557. data_origin: 'spoolman',
  558. tag_type: 'spoolman',
  559. archived_at: null,
  560. created_at: '2025-01-01T00:00:00Z',
  561. updated_at: '2025-01-01T00:00:00Z',
  562. k_profiles: [],
  563. },
  564. } as InventorySpool;
  565. beforeEach(() => {
  566. vi.clearAllMocks();
  567. });
  568. it('shows the Printers tab for Spoolman spools in non-quickAdd mode', async () => {
  569. render(
  570. <SpoolFormModal
  571. isOpen={true}
  572. onClose={vi.fn()}
  573. spool={spoolmanSpool}
  574. mode="edit"
  575. currencySymbol="$"
  576. spoolmanMode={true}
  577. />
  578. );
  579. await waitFor(() => {
  580. expect(screen.getByText('Edit Spool')).toBeInTheDocument();
  581. });
  582. // Printers tab should be visible in Spoolman mode
  583. expect(screen.getByText('Printers')).toBeInTheDocument();
  584. });
  585. it('calls saveSpoolmanKProfiles (not saveSpoolKProfiles) on update in Spoolman mode', async () => {
  586. render(
  587. <SpoolFormModal
  588. isOpen={true}
  589. onClose={vi.fn()}
  590. spool={spoolmanSpool}
  591. mode="edit"
  592. currencySymbol="$"
  593. spoolmanMode={true}
  594. />
  595. );
  596. await waitFor(() => {
  597. expect(screen.getByText('Edit Spool')).toBeInTheDocument();
  598. });
  599. const saveButton = screen.getByRole('button', { name: /save/i });
  600. fireEvent.click(saveButton);
  601. await waitFor(() => {
  602. expect(api.updateSpoolmanInventorySpool).toHaveBeenCalledTimes(1);
  603. });
  604. // saveSpoolmanKProfiles is always called on update (even with empty list)
  605. await waitFor(() => {
  606. expect(api.saveSpoolmanKProfiles).toHaveBeenCalledWith(42, []);
  607. });
  608. expect(api.saveSpoolKProfiles).not.toHaveBeenCalled();
  609. });
  610. it('saves the per-model preset overrides alongside the K profiles', async () => {
  611. // Both are full replacements and both are written on every save: that is
  612. // how the user clears the last profile or the last override. The Spoolman
  613. // pair must be the one called in Spoolman mode -- the two inventory modes
  614. // have drifted apart on this path before (#1713).
  615. render(
  616. <SpoolFormModal
  617. isOpen={true}
  618. onClose={vi.fn()}
  619. spool={spoolmanSpool}
  620. mode="edit"
  621. currencySymbol="$"
  622. spoolmanMode={true}
  623. />
  624. );
  625. await waitFor(() => {
  626. expect(screen.getByText('Edit Spool')).toBeInTheDocument();
  627. });
  628. fireEvent.click(screen.getByRole('button', { name: /save/i }));
  629. await waitFor(() => {
  630. expect(api.saveSpoolmanFilamentPresets).toHaveBeenCalledWith(42, []);
  631. });
  632. expect(api.saveSpoolFilamentPresets).not.toHaveBeenCalled();
  633. });
  634. });
  635. // ---------------------------------------------------------------------------
  636. // T2: SpoolmanFilamentPicker integration with SpoolFormModal
  637. // ---------------------------------------------------------------------------
  638. vi.mock('../../components/spool-form/SpoolmanFilamentPicker', () => ({
  639. SpoolmanFilamentPicker: ({ onSelect, selectedId }: { onSelect: (f: unknown) => void; selectedId: number | null; isLoading: boolean; filaments: unknown[] }) => {
  640. return (
  641. <div>
  642. <span data-testid="picker-selected-id">{selectedId ?? 'none'}</span>
  643. <button data-testid="picker-select-btn" onClick={() => onSelect({
  644. id: 7,
  645. name: 'PLA Basic',
  646. material: 'PLA',
  647. color_hex: 'FF0000',
  648. color_name: 'Red',
  649. weight: 1000,
  650. spool_weight: 196,
  651. vendor: { id: 1, name: 'Bambu Lab' },
  652. })}>
  653. Select Filament
  654. </button>
  655. <button data-testid="picker-select-clear-btn" onClick={() => onSelect({
  656. id: 8,
  657. name: 'PLA Basic Clear',
  658. material: 'PLA',
  659. color_hex: '00000000',
  660. color_name: 'Clear',
  661. weight: 1000,
  662. spool_weight: 196,
  663. vendor: { id: 1, name: 'Bambu Lab' },
  664. })}>
  665. Select Clear Filament
  666. </button>
  667. </div>
  668. );
  669. },
  670. }));
  671. describe('SpoolFormModal — SpoolmanFilamentPicker integration (T2)', () => {
  672. beforeEach(() => {
  673. vi.clearAllMocks();
  674. });
  675. it('renders SpoolmanFilamentPicker in Spoolman create mode', async () => {
  676. render(
  677. <SpoolFormModal
  678. isOpen={true}
  679. onClose={vi.fn()}
  680. currencySymbol="$"
  681. spoolmanMode={true}
  682. />
  683. );
  684. await waitFor(() => {
  685. expect(screen.getByTestId('picker-select-btn')).toBeInTheDocument();
  686. });
  687. });
  688. it('does NOT render SpoolmanFilamentPicker in local inventory mode', async () => {
  689. render(
  690. <SpoolFormModal
  691. isOpen={true}
  692. onClose={vi.fn()}
  693. currencySymbol="$"
  694. spoolmanMode={false}
  695. />
  696. );
  697. await waitFor(() => {
  698. expect(screen.queryByTestId('picker-select-btn')).not.toBeInTheDocument();
  699. });
  700. });
  701. it('prefills form fields when a filament is selected from the picker', async () => {
  702. render(
  703. <SpoolFormModal
  704. isOpen={true}
  705. onClose={vi.fn()}
  706. currencySymbol="$"
  707. spoolmanMode={true}
  708. />
  709. );
  710. await waitFor(() => {
  711. expect(screen.getByTestId('picker-select-btn')).toBeInTheDocument();
  712. });
  713. fireEvent.click(screen.getByTestId('picker-select-btn'));
  714. // After selection, the picker should reflect the selected ID
  715. await waitFor(() => {
  716. expect(screen.getByTestId('picker-selected-id').textContent).toBe('7');
  717. });
  718. });
  719. it('prefills a translucent filament with its own alpha, not 808080FF (#2912)', async () => {
  720. // The guard here required exactly 6 hex chars and then appended FF. That was
  721. // unreachable while Bambuddy never wrote 8 characters; once a clear filament
  722. // is storable, picking it out of the Spoolman catalogue prefilled the form
  723. // with neutral grey — the frontend twin of the read-side regex.
  724. render(
  725. <SpoolFormModal
  726. isOpen={true}
  727. onClose={vi.fn()}
  728. currencySymbol="$"
  729. spoolmanMode={true}
  730. spoolsQueryKey={['spoolman-spools']}
  731. />
  732. );
  733. await waitFor(() => {
  734. expect(screen.getByTestId('picker-select-clear-btn')).toBeInTheDocument();
  735. });
  736. fireEvent.click(screen.getByTestId('picker-select-clear-btn'));
  737. const saveButton = screen.getByRole('button', { name: /save|add spool/i });
  738. fireEvent.click(saveButton);
  739. await waitFor(() => {
  740. expect(api.createSpoolmanInventorySpool).toHaveBeenCalledTimes(1);
  741. });
  742. const payload = vi.mocked(api.createSpoolmanInventorySpool).mock.calls[0][0] as Record<string, unknown>;
  743. expect(payload.rgba).toBe('00000000');
  744. });
  745. it('still appends the opaque alpha to a 6-char catalogue colour', async () => {
  746. render(
  747. <SpoolFormModal
  748. isOpen={true}
  749. onClose={vi.fn()}
  750. currencySymbol="$"
  751. spoolmanMode={true}
  752. spoolsQueryKey={['spoolman-spools']}
  753. />
  754. );
  755. await waitFor(() => {
  756. expect(screen.getByTestId('picker-select-btn')).toBeInTheDocument();
  757. });
  758. fireEvent.click(screen.getByTestId('picker-select-btn'));
  759. const saveButton = screen.getByRole('button', { name: /save|add spool/i });
  760. fireEvent.click(saveButton);
  761. await waitFor(() => {
  762. expect(api.createSpoolmanInventorySpool).toHaveBeenCalledTimes(1);
  763. });
  764. const payload = vi.mocked(api.createSpoolmanInventorySpool).mock.calls[0][0] as Record<string, unknown>;
  765. expect(payload.rgba).toBe('FF0000FF');
  766. });
  767. it('includes spoolman_filament_id in the submit payload when a filament is pre-selected', async () => {
  768. render(
  769. <SpoolFormModal
  770. isOpen={true}
  771. onClose={vi.fn()}
  772. currencySymbol="$"
  773. spoolmanMode={true}
  774. spoolsQueryKey={['spoolman-spools']}
  775. />
  776. );
  777. await waitFor(() => {
  778. expect(screen.getByTestId('picker-select-btn')).toBeInTheDocument();
  779. });
  780. // Select a filament
  781. fireEvent.click(screen.getByTestId('picker-select-btn'));
  782. // Submit the form
  783. const saveButton = screen.getByRole('button', { name: /save|add spool/i });
  784. fireEvent.click(saveButton);
  785. await waitFor(() => {
  786. expect(api.createSpoolmanInventorySpool).toHaveBeenCalledTimes(1);
  787. });
  788. const callArg = vi.mocked(api.createSpoolmanInventorySpool).mock.calls[0][0] as Record<string, unknown>;
  789. expect(callArg.spoolman_filament_id).toBe(7);
  790. });
  791. it('clears spoolman_filament_id and shows unlink toast when user edits a linked field', async () => {
  792. render(
  793. <SpoolFormModal
  794. isOpen={true}
  795. onClose={vi.fn()}
  796. currencySymbol="$"
  797. spoolmanMode={true}
  798. />
  799. );
  800. await waitFor(() => {
  801. expect(screen.getByTestId('picker-select-btn')).toBeInTheDocument();
  802. });
  803. // Select a filament from the catalog picker
  804. fireEvent.click(screen.getByTestId('picker-select-btn'));
  805. await waitFor(() => {
  806. expect(screen.getByTestId('picker-selected-id').textContent).toBe('7');
  807. });
  808. openColorAndCostTab();
  809. // Manually edit the color_name field (a linked field)
  810. const colorNameInput = screen.getByPlaceholderText('Jade White, Fire Red...');
  811. fireEvent.change(colorNameInput, { target: { value: 'Custom Blue' } });
  812. // Back to the Filament tab: the catalog picker only renders there, so the
  813. // link state has to be read where it lives.
  814. fireEvent.click(screen.getByText('Filament Info'));
  815. // spoolman_filament_id must be cleared (picker shows 'none')
  816. await waitFor(() => {
  817. expect(screen.getByTestId('picker-selected-id').textContent).toBe('none');
  818. });
  819. // Unlink toast must have been shown
  820. expect(mockShowToast).toHaveBeenCalledWith(
  821. expect.stringContaining('catalog link'),
  822. 'info',
  823. );
  824. });
  825. });
  826. describe('SpoolFormModal — assignment actions (#1336, #2978)', () => {
  827. const spoolmanSpool: InventorySpool = {
  828. id: 42,
  829. material: 'PLA',
  830. subtype: 'Basic',
  831. brand: 'BrandX',
  832. color_name: 'Black',
  833. rgba: '000000FF',
  834. extra_colors: null,
  835. effect_type: null,
  836. label_weight: 1000,
  837. core_weight: 250,
  838. core_weight_catalog_id: null,
  839. weight_used: 200,
  840. slicer_filament: '',
  841. slicer_filament_name: '',
  842. nozzle_temp_min: null,
  843. nozzle_temp_max: null,
  844. note: null,
  845. added_full: null,
  846. last_used: null,
  847. encode_time: null,
  848. tag_uid: null,
  849. tray_uuid: null,
  850. data_origin: 'spoolman',
  851. tag_type: 'spoolman',
  852. archived_at: null,
  853. created_at: '2025-01-01T00:00:00Z',
  854. updated_at: '2025-01-01T00:00:00Z',
  855. cost_per_kg: null,
  856. last_scale_weight: null,
  857. last_weighed_at: null,
  858. category: null,
  859. low_stock_threshold_pct: null,
  860. k_profiles: [],
  861. } as InventorySpool;
  862. beforeEach(() => {
  863. vi.clearAllMocks();
  864. });
  865. it('enables Unassign in Spoolman mode when a spoolman_slot_assignment exists for the spool', async () => {
  866. vi.mocked(api.getSpoolmanSlotAssignments).mockResolvedValueOnce([
  867. {
  868. printer_id: 1,
  869. printer_name: 'Test Printer',
  870. ams_id: 0,
  871. tray_id: 2,
  872. spoolman_spool_id: 42,
  873. ams_label: 'AMS 1',
  874. },
  875. ]);
  876. render(
  877. <SpoolFormModal
  878. isOpen={true}
  879. onClose={vi.fn()}
  880. spool={spoolmanSpool}
  881. mode="edit"
  882. currencySymbol="$"
  883. spoolmanMode={true}
  884. />
  885. );
  886. const unassignBtn = await screen.findByRole('button', { name: /unassign/i });
  887. await waitFor(() => {
  888. expect(unassignBtn).not.toBeDisabled();
  889. });
  890. fireEvent.click(unassignBtn);
  891. await waitFor(() => {
  892. expect(api.unassignSpoolmanSlot).toHaveBeenCalledWith(42);
  893. });
  894. expect(api.unassignSpool).not.toHaveBeenCalled();
  895. });
  896. it('offers direct assignment when no Spoolman slot assignment exists (#2978)', async () => {
  897. vi.mocked(api.getSpoolmanSlotAssignments).mockResolvedValueOnce([]);
  898. render(
  899. <SpoolFormModal
  900. isOpen={true}
  901. onClose={vi.fn()}
  902. spool={spoolmanSpool}
  903. mode="edit"
  904. currencySymbol="$"
  905. spoolmanMode={true}
  906. />
  907. );
  908. await waitFor(() => {
  909. expect(api.getSpoolmanSlotAssignments).toHaveBeenCalled();
  910. });
  911. expect(screen.queryByRole('button', { name: /unassign/i })).not.toBeInTheDocument();
  912. const assignBtn = screen.getByRole('button', { name: /assign spool/i });
  913. expect(assignBtn).not.toBeDisabled();
  914. fireEvent.click(assignBtn);
  915. expect(await screen.findByRole('heading', { name: /assign to AMS/i })).toBeInTheDocument();
  916. expect(screen.queryByLabelText(/color name/i)).not.toBeInTheDocument();
  917. });
  918. it('closes only the nested assignment dialog on the first Escape press', async () => {
  919. const onClose = vi.fn();
  920. vi.mocked(api.getSpoolmanSlotAssignments).mockResolvedValueOnce([]);
  921. render(
  922. <SpoolFormModal
  923. isOpen={true}
  924. onClose={onClose}
  925. spool={spoolmanSpool}
  926. mode="edit"
  927. currencySymbol="$"
  928. spoolmanMode={true}
  929. />
  930. );
  931. await waitFor(() => expect(api.getSpoolmanSlotAssignments).toHaveBeenCalled());
  932. fireEvent.click(screen.getByRole('button', { name: /assign spool/i }));
  933. expect(await screen.findByRole('heading', { name: /assign to AMS/i })).toBeInTheDocument();
  934. fireEvent.keyDown(document, { key: 'Escape' });
  935. await waitFor(() => {
  936. expect(screen.queryByRole('heading', { name: /assign to AMS/i })).not.toBeInTheDocument();
  937. });
  938. expect(onClose).not.toHaveBeenCalled();
  939. expect(screen.getByText('Edit Spool')).toBeInTheDocument();
  940. fireEvent.keyDown(document, { key: 'Escape' });
  941. expect(onClose).toHaveBeenCalledTimes(1);
  942. });
  943. });
  944. describe('SpoolFormModal — Clear RFID Tag for a tray-UUID-only spool (#3109)', () => {
  945. const trayUuidOnly = (overrides: Partial<InventorySpool>): InventorySpool =>
  946. ({
  947. ...existingSpool,
  948. id: 42,
  949. tag_uid: null,
  950. tray_uuid: 'A1B2C3D4E5F60718293A4B5C6D7E8F90',
  951. ...overrides,
  952. }) as InventorySpool;
  953. beforeEach(() => {
  954. vi.clearAllMocks();
  955. });
  956. it('clears the tag on a Spoolman spool linked only by its tray UUID', async () => {
  957. // _map_spoolman_spool splits extra.tag by length: a 32-char value becomes
  958. // tray_uuid and tag_uid stays None. That is every Bambu Lab spool synced
  959. // from the AMS, and not one of them could have its tag cleared here.
  960. render(
  961. <SpoolFormModal
  962. isOpen={true}
  963. onClose={vi.fn()}
  964. spool={trayUuidOnly({ data_origin: 'spoolman', tag_type: 'spoolman' })}
  965. mode="edit"
  966. currencySymbol="$"
  967. spoolmanMode={true}
  968. />
  969. );
  970. const clearBtn = await screen.findByRole('button', { name: /clear rfid tag/i });
  971. expect(clearBtn).not.toBeDisabled();
  972. fireEvent.click(clearBtn);
  973. await waitFor(() => {
  974. expect(api.updateSpoolmanInventorySpool).toHaveBeenCalledWith(
  975. 42,
  976. expect.objectContaining({ tag_uid: null, tray_uuid: null })
  977. );
  978. });
  979. expect(api.updateSpool).not.toHaveBeenCalled();
  980. });
  981. it('clears the tag on a built-in spool linked only by its tray UUID', async () => {
  982. // PATCH /inventory/spools/{id}/link-tag takes tray_uuid on its own, so the
  983. // built-in inventory reaches the same state without Spoolman involved.
  984. render(
  985. <SpoolFormModal
  986. isOpen={true}
  987. onClose={vi.fn()}
  988. spool={trayUuidOnly({})}
  989. mode="edit"
  990. currencySymbol="$"
  991. />
  992. );
  993. const clearBtn = await screen.findByRole('button', { name: /clear rfid tag/i });
  994. expect(clearBtn).not.toBeDisabled();
  995. fireEvent.click(clearBtn);
  996. await waitFor(() => {
  997. expect(api.updateSpool).toHaveBeenCalledWith(
  998. 42,
  999. expect.objectContaining({ tag_uid: null, tray_uuid: null })
  1000. );
  1001. });
  1002. expect(api.updateSpoolmanInventorySpool).not.toHaveBeenCalled();
  1003. });
  1004. it('stays disabled for a spool carrying neither identifier', async () => {
  1005. // The button still has something to gate on -- it is not simply always on.
  1006. render(
  1007. <SpoolFormModal
  1008. isOpen={true}
  1009. onClose={vi.fn()}
  1010. spool={trayUuidOnly({ tray_uuid: null })}
  1011. mode="edit"
  1012. currencySymbol="$"
  1013. />
  1014. );
  1015. const clearBtn = await screen.findByRole('button', { name: /clear rfid tag/i });
  1016. expect(clearBtn).toBeDisabled();
  1017. });
  1018. it('still clears the tag on a spool carrying a tag_uid', async () => {
  1019. const clearBtnSpool = trayUuidOnly({ tag_uid: 'DEADBEEF', tray_uuid: null });
  1020. render(
  1021. <SpoolFormModal
  1022. isOpen={true}
  1023. onClose={vi.fn()}
  1024. spool={clearBtnSpool}
  1025. mode="edit"
  1026. currencySymbol="$"
  1027. />
  1028. );
  1029. const clearBtn = await screen.findByRole('button', { name: /clear rfid tag/i });
  1030. expect(clearBtn).not.toBeDisabled();
  1031. fireEvent.click(clearBtn);
  1032. await waitFor(() => {
  1033. expect(api.updateSpool).toHaveBeenCalledWith(
  1034. 42,
  1035. expect.objectContaining({ tag_uid: null, tray_uuid: null })
  1036. );
  1037. });
  1038. });
  1039. });
  1040. describe('SpoolFormModal locationIdTouched', () => {
  1041. /**
  1042. * Regression tests for the round-trip bug: saving the edit modal without
  1043. * touching the Storage Location field must NOT include location_id in
  1044. * the PATCH payload, so Spoolman's location field is never overwritten with
  1045. * a stale cached value.
  1046. */
  1047. beforeEach(() => {
  1048. vi.clearAllMocks();
  1049. vi.mocked(api.getLocations).mockResolvedValue([
  1050. { id: 1, name: 'IKEAREGAL', identifier: null, spool_count: 1, created_at: '', updated_at: '' },
  1051. { id: 2, name: 'Shelf B', identifier: null, spool_count: 0, created_at: '', updated_at: '' },
  1052. ]);
  1053. });
  1054. const spoolWithStorageLocation: InventorySpool = {
  1055. ...existingSpool,
  1056. storage_location: 'IKEAREGAL',
  1057. location_id: 1,
  1058. };
  1059. it('excludes storage_location from PATCH when editing without changing it', async () => {
  1060. render(
  1061. <SpoolFormModal
  1062. isOpen={true}
  1063. onClose={vi.fn()}
  1064. spool={spoolWithStorageLocation}
  1065. mode="edit"
  1066. currencySymbol="$"
  1067. />
  1068. );
  1069. await waitFor(() => {
  1070. expect(screen.getByText('Edit Spool')).toBeInTheDocument();
  1071. });
  1072. // Save without touching the storage location field
  1073. const saveButton = screen.getByRole('button', { name: /save/i });
  1074. fireEvent.click(saveButton);
  1075. await waitFor(() => {
  1076. expect(api.updateSpool).toHaveBeenCalledTimes(1);
  1077. });
  1078. const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
  1079. expect(spoolId).toBe(1);
  1080. // storage_location must NOT be in the payload — prevents Spoolman location overwrite
  1081. expect(payload).not.toHaveProperty('storage_location');
  1082. expect(payload).not.toHaveProperty('location_id');
  1083. // Other fields should still be present
  1084. expect(payload).toHaveProperty('material', 'PLA');
  1085. });
  1086. it('includes location_id in PATCH when editing and changing it', async () => {
  1087. render(
  1088. <SpoolFormModal
  1089. isOpen={true}
  1090. onClose={vi.fn()}
  1091. spool={spoolWithStorageLocation}
  1092. mode="edit"
  1093. currencySymbol="$"
  1094. />
  1095. );
  1096. await waitFor(() => {
  1097. expect(screen.getByText('Edit Spool')).toBeInTheDocument();
  1098. });
  1099. openColorAndCostTab();
  1100. // Change storage location via the catalog dropdown
  1101. const locationSelect = screen.getByLabelText(/storage location/i);
  1102. fireEvent.change(locationSelect, { target: { value: '2' } });
  1103. const saveButton = screen.getByRole('button', { name: /save/i });
  1104. fireEvent.click(saveButton);
  1105. await waitFor(() => {
  1106. expect(api.updateSpool).toHaveBeenCalledTimes(1);
  1107. });
  1108. const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
  1109. expect(spoolId).toBe(1);
  1110. expect(payload).toHaveProperty('location_id', 2);
  1111. expect(payload).not.toHaveProperty('storage_location');
  1112. });
  1113. it('includes location_id when creating a new spool', async () => {
  1114. render(
  1115. <SpoolFormModal
  1116. isOpen={true}
  1117. onClose={vi.fn()}
  1118. currencySymbol="$"
  1119. />
  1120. );
  1121. await waitFor(() => {
  1122. expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
  1123. });
  1124. // Submit without setting storage_location (validation is mocked to pass)
  1125. const addButtons = screen.getAllByRole('button', { name: /add spool/i });
  1126. const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
  1127. expect(submitButton).toBeTruthy();
  1128. fireEvent.click(submitButton!);
  1129. await waitFor(() => {
  1130. expect(api.createSpool).toHaveBeenCalledTimes(1);
  1131. });
  1132. const [payload] = vi.mocked(api.createSpool).mock.calls[0];
  1133. expect(payload).toHaveProperty('location_id', null);
  1134. expect(payload).not.toHaveProperty('storage_location');
  1135. });
  1136. });
  1137. describe('SpoolFormModal copy mode', () => {
  1138. beforeEach(() => {
  1139. vi.clearAllMocks();
  1140. });
  1141. it('shows "Copy Spool" as the modal title when spool and mode="copy" are passed', async () => {
  1142. render(
  1143. <SpoolFormModal
  1144. isOpen={true}
  1145. onClose={vi.fn()}
  1146. spool={existingSpool}
  1147. mode="copy"
  1148. currencySymbol="$"
  1149. />
  1150. );
  1151. await waitFor(() => {
  1152. expect(screen.getByRole('heading', { name: 'Copy Spool' })).toBeInTheDocument();
  1153. });
  1154. });
  1155. it('calls api.createSpool (not api.updateSpool) when saving in copy mode', async () => {
  1156. render(
  1157. <SpoolFormModal
  1158. isOpen={true}
  1159. onClose={vi.fn()}
  1160. spool={existingSpool}
  1161. mode="copy"
  1162. currencySymbol="$"
  1163. />
  1164. );
  1165. await waitFor(() => {
  1166. expect(screen.getByRole('heading', { name: 'Copy Spool' })).toBeInTheDocument();
  1167. });
  1168. // The save button label is "Copy Spool" in copy mode
  1169. const saveBtn = screen.getAllByRole('button', { name: /copy spool/i })
  1170. .find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg'));
  1171. expect(saveBtn).toBeTruthy();
  1172. fireEvent.click(saveBtn!);
  1173. await waitFor(() => {
  1174. expect(api.createSpool).toHaveBeenCalledTimes(1);
  1175. });
  1176. expect(api.updateSpool).not.toHaveBeenCalled();
  1177. });
  1178. it('resets weight_used to 0 in the create payload when copying a spool with non-zero usage', async () => {
  1179. // existingSpool has weight_used: 300 — must become 0 on copy
  1180. render(
  1181. <SpoolFormModal
  1182. isOpen={true}
  1183. onClose={vi.fn()}
  1184. spool={existingSpool}
  1185. mode="copy"
  1186. currencySymbol="$"
  1187. />
  1188. );
  1189. await waitFor(() => {
  1190. expect(screen.getByRole('heading', { name: 'Copy Spool' })).toBeInTheDocument();
  1191. });
  1192. const saveBtn = screen.getAllByRole('button', { name: /copy spool/i })
  1193. .find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg'));
  1194. expect(saveBtn).toBeTruthy();
  1195. fireEvent.click(saveBtn!);
  1196. await waitFor(() => {
  1197. expect(api.createSpool).toHaveBeenCalledTimes(1);
  1198. });
  1199. const [payload] = vi.mocked(api.createSpool).mock.calls[0];
  1200. expect((payload as Record<string, unknown>).weight_used).toBe(0);
  1201. });
  1202. // The dialog seeds the supplier chips from the spool being copied and shows
  1203. // them, so the copy has to actually get them (#2988). The backend's
  1204. // inheritance is not a stand-in: it keys on the (material, subtype, brand,
  1205. // color_name) tuple, so it resolves to the NEWEST spool of that product
  1206. // rather than the one on screen, and in Spoolman mode it never runs at all.
  1207. const spoolWithSuppliers: InventorySpool = {
  1208. ...existingSpool,
  1209. suppliers: [
  1210. {
  1211. id: 11,
  1212. supplier_id: 4,
  1213. supplier_name: 'Extrudr',
  1214. supplier_article_number: 'EX-42',
  1215. quoted_price_per_kg: 21.5,
  1216. is_purchase_source: true,
  1217. },
  1218. ],
  1219. };
  1220. async function clickCopy() {
  1221. await waitFor(() => {
  1222. expect(screen.getByRole('heading', { name: 'Copy Spool' })).toBeInTheDocument();
  1223. });
  1224. const saveBtn = screen.getAllByRole('button', { name: /copy spool/i })
  1225. .find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg'));
  1226. expect(saveBtn).toBeTruthy();
  1227. fireEvent.click(saveBtn!);
  1228. }
  1229. it('saves the supplier assignments it displays when copying', async () => {
  1230. render(
  1231. <SpoolFormModal
  1232. isOpen={true}
  1233. onClose={vi.fn()}
  1234. spool={spoolWithSuppliers}
  1235. mode="copy"
  1236. currencySymbol="$"
  1237. />
  1238. );
  1239. await clickCopy();
  1240. await waitFor(() => {
  1241. expect(api.setSpoolSuppliers).toHaveBeenCalledTimes(1);
  1242. });
  1243. // createSpool is mocked to answer id 99.
  1244. expect(vi.mocked(api.setSpoolSuppliers).mock.calls[0]).toEqual([
  1245. 99,
  1246. [
  1247. {
  1248. supplier_id: 4,
  1249. supplier_article_number: 'EX-42',
  1250. quoted_price_per_kg: 21.5,
  1251. // Where a copy was bought is unknown — only the source list carries over.
  1252. is_purchase_source: false,
  1253. },
  1254. ],
  1255. ]);
  1256. });
  1257. it('saves them through the Spoolman endpoint in Spoolman mode', async () => {
  1258. render(
  1259. <SpoolFormModal
  1260. isOpen={true}
  1261. onClose={vi.fn()}
  1262. spool={spoolWithSuppliers}
  1263. mode="copy"
  1264. currencySymbol="$"
  1265. spoolmanMode={true}
  1266. />
  1267. );
  1268. await clickCopy();
  1269. await waitFor(() => {
  1270. expect(api.setSpoolmanSpoolSuppliers).toHaveBeenCalledTimes(1);
  1271. });
  1272. // createSpoolmanInventorySpool is mocked to answer id 88.
  1273. expect(vi.mocked(api.setSpoolmanSpoolSuppliers).mock.calls[0][0]).toBe(88);
  1274. expect(api.setSpoolSuppliers).not.toHaveBeenCalled();
  1275. });
  1276. it('leaves an untouched create to the backend inheritance', async () => {
  1277. render(<SpoolFormModal isOpen={true} onClose={vi.fn()} currencySymbol="$" />);
  1278. await waitFor(() => {
  1279. expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
  1280. });
  1281. const addButtons = screen.getAllByRole('button', { name: /add spool/i });
  1282. const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
  1283. fireEvent.click(submitButton!);
  1284. await waitFor(() => {
  1285. expect(api.createSpool).toHaveBeenCalledTimes(1);
  1286. });
  1287. // An empty replace-all here would wipe what the backend just inherited.
  1288. expect(api.setSpoolSuppliers).not.toHaveBeenCalled();
  1289. });
  1290. });
  1291. // The "#<id>" affordance in the modal header (#1385) is only meaningful when
  1292. // editing an existing spool — there's no ID yet on create, and the copy path
  1293. // is producing a new spool too. Guard all three cases so a future refactor
  1294. // can't quietly start leaking the source spool's ID into the Copy modal.
  1295. describe('SpoolFormModal header spool ID (#1385)', () => {
  1296. beforeEach(() => {
  1297. vi.clearAllMocks();
  1298. });
  1299. it('shows #<id> next to the title when editing an existing spool', async () => {
  1300. render(
  1301. <SpoolFormModal
  1302. isOpen={true}
  1303. onClose={vi.fn()}
  1304. spool={existingSpool}
  1305. mode="edit"
  1306. currencySymbol="$"
  1307. />
  1308. );
  1309. await waitFor(() => {
  1310. expect(screen.getByText('Edit Spool')).toBeInTheDocument();
  1311. });
  1312. // existingSpool.id is 1; render as "#1" in the modal header.
  1313. expect(screen.getByText('#1')).toBeInTheDocument();
  1314. });
  1315. it('does not show an ID when creating a new spool', async () => {
  1316. render(
  1317. <SpoolFormModal
  1318. isOpen={true}
  1319. onClose={vi.fn()}
  1320. currencySymbol="$"
  1321. />
  1322. );
  1323. await waitFor(() => {
  1324. expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
  1325. });
  1326. // No spool exists yet → header carries no "#..." token.
  1327. expect(screen.queryByText(/^#\d+$/)).not.toBeInTheDocument();
  1328. });
  1329. it('does not leak the source spool ID when copying', async () => {
  1330. // Copying produces a fresh spool — surfacing the source ID in the
  1331. // "Copy Spool" header would mislead the user into thinking the new
  1332. // spool inherits it.
  1333. render(
  1334. <SpoolFormModal
  1335. isOpen={true}
  1336. onClose={vi.fn()}
  1337. spool={existingSpool}
  1338. mode="copy"
  1339. currencySymbol="$"
  1340. />
  1341. );
  1342. await waitFor(() => {
  1343. expect(screen.getByRole('heading', { name: 'Copy Spool' })).toBeInTheDocument();
  1344. });
  1345. expect(screen.queryByText(/^#\d+$/)).not.toBeInTheDocument();
  1346. });
  1347. });
  1348. describe('SpoolFormModal — per-spool tare in Spoolman mode (#2908)', () => {
  1349. // The mapped Spoolman spool carries the tare it resolves to: its own
  1350. // spool_weight if set, else the filament type's. The form opens on that value.
  1351. const spoolmanSpool = {
  1352. ...existingSpool,
  1353. id: 42,
  1354. core_weight: 250,
  1355. core_weight_catalog_id: null,
  1356. data_origin: 'spoolman',
  1357. tag_type: 'spoolman',
  1358. } as InventorySpool;
  1359. const catalog = [
  1360. { id: 7, name: 'Bambu Lab 250g', weight: 250 },
  1361. { id: 3, name: 'Standard 300g', weight: 300 },
  1362. ];
  1363. beforeEach(() => {
  1364. vi.clearAllMocks();
  1365. vi.mocked(api.getSpoolCatalog).mockResolvedValue(catalog);
  1366. });
  1367. function weightPicker() {
  1368. const picker = screen
  1369. .getAllByPlaceholderText(/search/i)
  1370. .find((input) => input.getAttribute('placeholder')?.toLowerCase().includes('spool'));
  1371. expect(picker).toBeTruthy();
  1372. return picker!;
  1373. }
  1374. async function openEdit() {
  1375. render(
  1376. <SpoolFormModal
  1377. isOpen={true}
  1378. onClose={vi.fn()}
  1379. spool={spoolmanSpool}
  1380. mode="edit"
  1381. currencySymbol="$"
  1382. spoolmanMode={true}
  1383. />
  1384. );
  1385. await waitFor(() => {
  1386. expect(screen.getByText('Edit Spool')).toBeInTheDocument();
  1387. });
  1388. openColorAndCostTab();
  1389. await waitFor(() => {
  1390. expect(api.getSpoolCatalog).toHaveBeenCalled();
  1391. });
  1392. }
  1393. async function savedPayload() {
  1394. fireEvent.click(screen.getByRole('button', { name: /save/i }));
  1395. await waitFor(() => {
  1396. expect(api.updateSpoolmanInventorySpool).toHaveBeenCalledTimes(1);
  1397. });
  1398. return vi.mocked(api.updateSpoolmanInventorySpool).mock.calls[0][1] as Record<string, unknown>;
  1399. }
  1400. it('shows the empty spool weight picker, which it used to replace with a notice', async () => {
  1401. await openEdit();
  1402. expect(weightPicker()).toBeInTheDocument();
  1403. });
  1404. it('does not send the tare when the user left it alone', async () => {
  1405. // An untouched edit must not copy the inherited value onto the spool:
  1406. // that would stop it following its filament type.
  1407. await openEdit();
  1408. const payload = await savedPayload();
  1409. expect(payload).not.toHaveProperty('core_weight');
  1410. expect(payload).not.toHaveProperty('core_weight_catalog_id');
  1411. });
  1412. it('does not count the picker selecting a catalogue entry by itself as a touch', async () => {
  1413. // One catalogue row matches the opening weight, so the picker selects it
  1414. // on mount. That changes core_weight_catalog_id without the user doing
  1415. // anything, which is why the touched flag keys on core_weight instead.
  1416. await openEdit();
  1417. await screen.findByDisplayValue('Bambu Lab 250g');
  1418. const payload = await savedPayload();
  1419. expect(payload).not.toHaveProperty('core_weight');
  1420. });
  1421. it('sends the tare the user picked, without the catalogue id', async () => {
  1422. await openEdit();
  1423. fireEvent.focus(weightPicker());
  1424. fireEvent.click(await screen.findByText('Standard 300g'));
  1425. const payload = await savedPayload();
  1426. expect(payload).toHaveProperty('core_weight', 300);
  1427. // No field for it on SpoolmanInventoryCreate / Update; it would be dropped.
  1428. expect(payload).not.toHaveProperty('core_weight_catalog_id');
  1429. });
  1430. it('does not send the form default on create either', async () => {
  1431. render(<SpoolFormModal isOpen={true} onClose={vi.fn()} currencySymbol="$" spoolmanMode={true} />);
  1432. await waitFor(() => {
  1433. expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
  1434. });
  1435. const addButtons = screen.getAllByRole('button', { name: /add spool/i });
  1436. const submitButton = addButtons.find((btn) => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
  1437. fireEvent.click(submitButton!);
  1438. await waitFor(() => {
  1439. expect(api.createSpoolmanInventorySpool).toHaveBeenCalledTimes(1);
  1440. });
  1441. const payload = vi.mocked(api.createSpoolmanInventorySpool).mock.calls[0][0] as Record<string, unknown>;
  1442. expect(payload).not.toHaveProperty('core_weight');
  1443. });
  1444. it('loads a 0 g tare as 0, not the 250 fallback', async () => {
  1445. // A spool-less coil. `|| 250` read the real 0 as missing, so the form
  1446. // showed 250 and the measured-weight field subtracted it.
  1447. render(
  1448. <SpoolFormModal
  1449. isOpen={true}
  1450. onClose={vi.fn()}
  1451. spool={{ ...spoolmanSpool, core_weight: 0, core_weight_is_inherited: false }}
  1452. mode="edit"
  1453. currencySymbol="$"
  1454. spoolmanMode={true}
  1455. />
  1456. );
  1457. await waitFor(() => {
  1458. expect(screen.getByText('Edit Spool')).toBeInTheDocument();
  1459. });
  1460. openColorAndCostTab();
  1461. const tareInput = screen.getAllByRole('spinbutton').find((el) => el.getAttribute('max') === '2000');
  1462. expect(tareInput).toHaveValue(0);
  1463. });
  1464. async function copiedPayload(source: InventorySpool) {
  1465. render(
  1466. <SpoolFormModal isOpen={true} onClose={vi.fn()} spool={source} mode="copy" currencySymbol="$" spoolmanMode={true} />
  1467. );
  1468. await waitFor(() => {
  1469. expect(screen.getByRole('heading', { name: 'Copy Spool' })).toBeInTheDocument();
  1470. });
  1471. const submit = screen
  1472. .getAllByRole('button', { name: /copy spool/i })
  1473. .find((btn) => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
  1474. fireEvent.click(submit!);
  1475. await waitFor(() => {
  1476. expect(api.createSpoolmanInventorySpool).toHaveBeenCalledTimes(1);
  1477. });
  1478. return vi.mocked(api.createSpoolmanInventorySpool).mock.calls[0][0] as Record<string, unknown>;
  1479. }
  1480. it('carries a spool\'s own tare onto its copy', async () => {
  1481. // The copy's form shows 180; left untouched it used to send nothing, and
  1482. // the copy came out inheriting the filament's 250.
  1483. const payload = await copiedPayload({ ...spoolmanSpool, core_weight: 180, core_weight_is_inherited: false });
  1484. expect(payload).toHaveProperty('core_weight', 180);
  1485. expect(payload).not.toHaveProperty('core_weight_catalog_id');
  1486. });
  1487. it('lets a copy of an inheriting spool keep inheriting', async () => {
  1488. const payload = await copiedPayload({ ...spoolmanSpool, core_weight: 250, core_weight_is_inherited: true });
  1489. expect(payload).not.toHaveProperty('core_weight');
  1490. });
  1491. });