SpoolFormModal.test.tsx 34 KB

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