SpoolFormModal.test.tsx 44 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434
  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 — Clear RFID Tag for a tray-UUID-only spool (#3109)', () => {
  912. const trayUuidOnly = (overrides: Partial<InventorySpool>): InventorySpool =>
  913. ({
  914. ...existingSpool,
  915. id: 42,
  916. tag_uid: null,
  917. tray_uuid: 'A1B2C3D4E5F60718293A4B5C6D7E8F90',
  918. ...overrides,
  919. }) as InventorySpool;
  920. beforeEach(() => {
  921. vi.clearAllMocks();
  922. });
  923. it('clears the tag on a Spoolman spool linked only by its tray UUID', async () => {
  924. // _map_spoolman_spool splits extra.tag by length: a 32-char value becomes
  925. // tray_uuid and tag_uid stays None. That is every Bambu Lab spool synced
  926. // from the AMS, and not one of them could have its tag cleared here.
  927. render(
  928. <SpoolFormModal
  929. isOpen={true}
  930. onClose={vi.fn()}
  931. spool={trayUuidOnly({ data_origin: 'spoolman', tag_type: 'spoolman' })}
  932. mode="edit"
  933. currencySymbol="$"
  934. spoolmanMode={true}
  935. />
  936. );
  937. const clearBtn = await screen.findByRole('button', { name: /clear rfid tag/i });
  938. expect(clearBtn).not.toBeDisabled();
  939. fireEvent.click(clearBtn);
  940. await waitFor(() => {
  941. expect(api.updateSpoolmanInventorySpool).toHaveBeenCalledWith(
  942. 42,
  943. expect.objectContaining({ tag_uid: null, tray_uuid: null })
  944. );
  945. });
  946. expect(api.updateSpool).not.toHaveBeenCalled();
  947. });
  948. it('clears the tag on a built-in spool linked only by its tray UUID', async () => {
  949. // PATCH /inventory/spools/{id}/link-tag takes tray_uuid on its own, so the
  950. // built-in inventory reaches the same state without Spoolman involved.
  951. render(
  952. <SpoolFormModal
  953. isOpen={true}
  954. onClose={vi.fn()}
  955. spool={trayUuidOnly({})}
  956. mode="edit"
  957. currencySymbol="$"
  958. />
  959. );
  960. const clearBtn = await screen.findByRole('button', { name: /clear rfid tag/i });
  961. expect(clearBtn).not.toBeDisabled();
  962. fireEvent.click(clearBtn);
  963. await waitFor(() => {
  964. expect(api.updateSpool).toHaveBeenCalledWith(
  965. 42,
  966. expect.objectContaining({ tag_uid: null, tray_uuid: null })
  967. );
  968. });
  969. expect(api.updateSpoolmanInventorySpool).not.toHaveBeenCalled();
  970. });
  971. it('stays disabled for a spool carrying neither identifier', async () => {
  972. // The button still has something to gate on -- it is not simply always on.
  973. render(
  974. <SpoolFormModal
  975. isOpen={true}
  976. onClose={vi.fn()}
  977. spool={trayUuidOnly({ tray_uuid: null })}
  978. mode="edit"
  979. currencySymbol="$"
  980. />
  981. );
  982. const clearBtn = await screen.findByRole('button', { name: /clear rfid tag/i });
  983. expect(clearBtn).toBeDisabled();
  984. });
  985. it('still clears the tag on a spool carrying a tag_uid', async () => {
  986. const clearBtnSpool = trayUuidOnly({ tag_uid: 'DEADBEEF', tray_uuid: null });
  987. render(
  988. <SpoolFormModal
  989. isOpen={true}
  990. onClose={vi.fn()}
  991. spool={clearBtnSpool}
  992. mode="edit"
  993. currencySymbol="$"
  994. />
  995. );
  996. const clearBtn = await screen.findByRole('button', { name: /clear rfid tag/i });
  997. expect(clearBtn).not.toBeDisabled();
  998. fireEvent.click(clearBtn);
  999. await waitFor(() => {
  1000. expect(api.updateSpool).toHaveBeenCalledWith(
  1001. 42,
  1002. expect.objectContaining({ tag_uid: null, tray_uuid: null })
  1003. );
  1004. });
  1005. });
  1006. });
  1007. describe('SpoolFormModal locationIdTouched', () => {
  1008. /**
  1009. * Regression tests for the round-trip bug: saving the edit modal without
  1010. * touching the Storage Location field must NOT include location_id in
  1011. * the PATCH payload, so Spoolman's location field is never overwritten with
  1012. * a stale cached value.
  1013. */
  1014. beforeEach(() => {
  1015. vi.clearAllMocks();
  1016. vi.mocked(api.getLocations).mockResolvedValue([
  1017. { id: 1, name: 'IKEAREGAL', identifier: null, spool_count: 1, created_at: '', updated_at: '' },
  1018. { id: 2, name: 'Shelf B', identifier: null, spool_count: 0, created_at: '', updated_at: '' },
  1019. ]);
  1020. });
  1021. const spoolWithStorageLocation: InventorySpool = {
  1022. ...existingSpool,
  1023. storage_location: 'IKEAREGAL',
  1024. location_id: 1,
  1025. };
  1026. it('excludes storage_location from PATCH when editing without changing it', async () => {
  1027. render(
  1028. <SpoolFormModal
  1029. isOpen={true}
  1030. onClose={vi.fn()}
  1031. spool={spoolWithStorageLocation}
  1032. mode="edit"
  1033. currencySymbol="$"
  1034. />
  1035. );
  1036. await waitFor(() => {
  1037. expect(screen.getByText('Edit Spool')).toBeInTheDocument();
  1038. });
  1039. // Save without touching the storage location field
  1040. const saveButton = screen.getByRole('button', { name: /save/i });
  1041. fireEvent.click(saveButton);
  1042. await waitFor(() => {
  1043. expect(api.updateSpool).toHaveBeenCalledTimes(1);
  1044. });
  1045. const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
  1046. expect(spoolId).toBe(1);
  1047. // storage_location must NOT be in the payload — prevents Spoolman location overwrite
  1048. expect(payload).not.toHaveProperty('storage_location');
  1049. expect(payload).not.toHaveProperty('location_id');
  1050. // Other fields should still be present
  1051. expect(payload).toHaveProperty('material', 'PLA');
  1052. });
  1053. it('includes location_id in PATCH when editing and changing it', async () => {
  1054. render(
  1055. <SpoolFormModal
  1056. isOpen={true}
  1057. onClose={vi.fn()}
  1058. spool={spoolWithStorageLocation}
  1059. mode="edit"
  1060. currencySymbol="$"
  1061. />
  1062. );
  1063. await waitFor(() => {
  1064. expect(screen.getByText('Edit Spool')).toBeInTheDocument();
  1065. });
  1066. openColorAndCostTab();
  1067. // Change storage location via the catalog dropdown
  1068. const locationSelect = screen.getByLabelText(/storage location/i);
  1069. fireEvent.change(locationSelect, { target: { value: '2' } });
  1070. const saveButton = screen.getByRole('button', { name: /save/i });
  1071. fireEvent.click(saveButton);
  1072. await waitFor(() => {
  1073. expect(api.updateSpool).toHaveBeenCalledTimes(1);
  1074. });
  1075. const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
  1076. expect(spoolId).toBe(1);
  1077. expect(payload).toHaveProperty('location_id', 2);
  1078. expect(payload).not.toHaveProperty('storage_location');
  1079. });
  1080. it('includes location_id when creating a new spool', async () => {
  1081. render(
  1082. <SpoolFormModal
  1083. isOpen={true}
  1084. onClose={vi.fn()}
  1085. currencySymbol="$"
  1086. />
  1087. );
  1088. await waitFor(() => {
  1089. expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
  1090. });
  1091. // Submit without setting storage_location (validation is mocked to pass)
  1092. const addButtons = screen.getAllByRole('button', { name: /add spool/i });
  1093. const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
  1094. expect(submitButton).toBeTruthy();
  1095. fireEvent.click(submitButton!);
  1096. await waitFor(() => {
  1097. expect(api.createSpool).toHaveBeenCalledTimes(1);
  1098. });
  1099. const [payload] = vi.mocked(api.createSpool).mock.calls[0];
  1100. expect(payload).toHaveProperty('location_id', null);
  1101. expect(payload).not.toHaveProperty('storage_location');
  1102. });
  1103. });
  1104. describe('SpoolFormModal copy mode', () => {
  1105. beforeEach(() => {
  1106. vi.clearAllMocks();
  1107. });
  1108. it('shows "Copy Spool" as the modal title when spool and mode="copy" are passed', async () => {
  1109. render(
  1110. <SpoolFormModal
  1111. isOpen={true}
  1112. onClose={vi.fn()}
  1113. spool={existingSpool}
  1114. mode="copy"
  1115. currencySymbol="$"
  1116. />
  1117. );
  1118. await waitFor(() => {
  1119. expect(screen.getByRole('heading', { name: 'Copy Spool' })).toBeInTheDocument();
  1120. });
  1121. });
  1122. it('calls api.createSpool (not api.updateSpool) when saving in copy mode', async () => {
  1123. render(
  1124. <SpoolFormModal
  1125. isOpen={true}
  1126. onClose={vi.fn()}
  1127. spool={existingSpool}
  1128. mode="copy"
  1129. currencySymbol="$"
  1130. />
  1131. );
  1132. await waitFor(() => {
  1133. expect(screen.getByRole('heading', { name: 'Copy Spool' })).toBeInTheDocument();
  1134. });
  1135. // The save button label is "Copy Spool" in copy mode
  1136. const saveBtn = screen.getAllByRole('button', { name: /copy spool/i })
  1137. .find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg'));
  1138. expect(saveBtn).toBeTruthy();
  1139. fireEvent.click(saveBtn!);
  1140. await waitFor(() => {
  1141. expect(api.createSpool).toHaveBeenCalledTimes(1);
  1142. });
  1143. expect(api.updateSpool).not.toHaveBeenCalled();
  1144. });
  1145. it('resets weight_used to 0 in the create payload when copying a spool with non-zero usage', async () => {
  1146. // existingSpool has weight_used: 300 — must become 0 on copy
  1147. render(
  1148. <SpoolFormModal
  1149. isOpen={true}
  1150. onClose={vi.fn()}
  1151. spool={existingSpool}
  1152. mode="copy"
  1153. currencySymbol="$"
  1154. />
  1155. );
  1156. await waitFor(() => {
  1157. expect(screen.getByRole('heading', { name: 'Copy Spool' })).toBeInTheDocument();
  1158. });
  1159. const saveBtn = screen.getAllByRole('button', { name: /copy spool/i })
  1160. .find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg'));
  1161. expect(saveBtn).toBeTruthy();
  1162. fireEvent.click(saveBtn!);
  1163. await waitFor(() => {
  1164. expect(api.createSpool).toHaveBeenCalledTimes(1);
  1165. });
  1166. const [payload] = vi.mocked(api.createSpool).mock.calls[0];
  1167. expect((payload as Record<string, unknown>).weight_used).toBe(0);
  1168. });
  1169. });
  1170. // The "#<id>" affordance in the modal header (#1385) is only meaningful when
  1171. // editing an existing spool — there's no ID yet on create, and the copy path
  1172. // is producing a new spool too. Guard all three cases so a future refactor
  1173. // can't quietly start leaking the source spool's ID into the Copy modal.
  1174. describe('SpoolFormModal header spool ID (#1385)', () => {
  1175. beforeEach(() => {
  1176. vi.clearAllMocks();
  1177. });
  1178. it('shows #<id> next to the title when editing an existing spool', async () => {
  1179. render(
  1180. <SpoolFormModal
  1181. isOpen={true}
  1182. onClose={vi.fn()}
  1183. spool={existingSpool}
  1184. mode="edit"
  1185. currencySymbol="$"
  1186. />
  1187. );
  1188. await waitFor(() => {
  1189. expect(screen.getByText('Edit Spool')).toBeInTheDocument();
  1190. });
  1191. // existingSpool.id is 1; render as "#1" in the modal header.
  1192. expect(screen.getByText('#1')).toBeInTheDocument();
  1193. });
  1194. it('does not show an ID when creating a new spool', async () => {
  1195. render(
  1196. <SpoolFormModal
  1197. isOpen={true}
  1198. onClose={vi.fn()}
  1199. currencySymbol="$"
  1200. />
  1201. );
  1202. await waitFor(() => {
  1203. expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
  1204. });
  1205. // No spool exists yet → header carries no "#..." token.
  1206. expect(screen.queryByText(/^#\d+$/)).not.toBeInTheDocument();
  1207. });
  1208. it('does not leak the source spool ID when copying', async () => {
  1209. // Copying produces a fresh spool — surfacing the source ID in the
  1210. // "Copy Spool" header would mislead the user into thinking the new
  1211. // spool inherits it.
  1212. render(
  1213. <SpoolFormModal
  1214. isOpen={true}
  1215. onClose={vi.fn()}
  1216. spool={existingSpool}
  1217. mode="copy"
  1218. currencySymbol="$"
  1219. />
  1220. );
  1221. await waitFor(() => {
  1222. expect(screen.getByRole('heading', { name: 'Copy Spool' })).toBeInTheDocument();
  1223. });
  1224. expect(screen.queryByText(/^#\d+$/)).not.toBeInTheDocument();
  1225. });
  1226. });