SpoolFormModal.test.tsx 55 KB

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