SpoolFormModal.test.tsx 41 KB

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