SpoolFormModal.test.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  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. getPrinters: vi.fn().mockResolvedValue([]),
  25. getSpoolUsageHistory: vi.fn().mockResolvedValue([]),
  26. createSpool: vi.fn().mockResolvedValue({ id: 99 }),
  27. updateSpool: vi.fn().mockResolvedValue({ id: 1 }),
  28. saveSpoolKProfiles: vi.fn().mockResolvedValue([]),
  29. },
  30. }));
  31. // Mock validateForm so we can bypass validation for the create-mode test
  32. // (editing tests pass validation naturally since the spool has material + slicer_filament)
  33. vi.mock('../../components/spool-form/types', async (importOriginal) => {
  34. const actual = await importOriginal<typeof import('../../components/spool-form/types')>();
  35. return {
  36. ...actual,
  37. validateForm: vi.fn().mockReturnValue({ isValid: true, errors: {} }),
  38. };
  39. });
  40. // Mock the toast context
  41. const mockShowToast = vi.fn();
  42. vi.mock('../../contexts/ToastContext', async (importOriginal) => {
  43. const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();
  44. return {
  45. ...actual,
  46. useToast: () => ({ showToast: mockShowToast }),
  47. };
  48. });
  49. import { api } from '../../api/client';
  50. const existingSpool: InventorySpool = {
  51. id: 1,
  52. material: 'PLA',
  53. subtype: 'Basic',
  54. brand: 'Polymaker',
  55. color_name: 'Red',
  56. rgba: 'FF0000FF',
  57. label_weight: 1000,
  58. core_weight: 250,
  59. core_weight_catalog_id: null,
  60. weight_used: 300,
  61. slicer_filament: 'GFL99',
  62. slicer_filament_name: 'Generic PLA',
  63. nozzle_temp_min: null,
  64. nozzle_temp_max: null,
  65. note: null,
  66. added_full: null,
  67. last_used: null,
  68. encode_time: null,
  69. tag_uid: null,
  70. tray_uuid: null,
  71. data_origin: null,
  72. tag_type: null,
  73. archived_at: null,
  74. created_at: '2025-01-01T00:00:00Z',
  75. updated_at: '2025-01-01T00:00:00Z',
  76. k_profiles: [],
  77. };
  78. describe('SpoolFormModal weightTouched', () => {
  79. beforeEach(() => {
  80. vi.clearAllMocks();
  81. });
  82. it('excludes weight_used from PATCH when editing without changing weight', async () => {
  83. render(
  84. <SpoolFormModal
  85. isOpen={true}
  86. onClose={vi.fn()}
  87. spool={existingSpool}
  88. currencySymbol="$"
  89. />
  90. );
  91. // Wait for the modal to render with the edit title
  92. await waitFor(() => {
  93. expect(screen.getByText('Edit Spool')).toBeInTheDocument();
  94. });
  95. // Click Save without touching the weight field
  96. const saveButton = screen.getByRole('button', { name: /save/i });
  97. fireEvent.click(saveButton);
  98. await waitFor(() => {
  99. expect(api.updateSpool).toHaveBeenCalledTimes(1);
  100. });
  101. const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
  102. expect(spoolId).toBe(1);
  103. // weight_used must NOT be present in the payload
  104. expect(payload).not.toHaveProperty('weight_used');
  105. // Other fields should still be present
  106. expect(payload).toHaveProperty('material', 'PLA');
  107. expect(payload).toHaveProperty('label_weight', 1000);
  108. });
  109. it('includes weight_used in PATCH when editing and changing remaining weight', async () => {
  110. render(
  111. <SpoolFormModal
  112. isOpen={true}
  113. onClose={vi.fn()}
  114. spool={existingSpool}
  115. currencySymbol="$"
  116. />
  117. );
  118. await waitFor(() => {
  119. expect(screen.getByText('Edit Spool')).toBeInTheDocument();
  120. });
  121. // The remaining weight is (label_weight - weight_used) = 1000 - 300 = 700.
  122. // The input is a number input displaying 700. Find it by its displayed value.
  123. const remainingInput = screen.getByDisplayValue('700');
  124. expect(remainingInput).toBeInTheDocument();
  125. // Change the remaining weight from 700 to 500 (weight_used becomes 1000 - 500 = 500)
  126. fireEvent.change(remainingInput, { target: { value: '500' } });
  127. // Blur triggers updateField('weight_used', ...) which sets weightTouched
  128. fireEvent.blur(remainingInput);
  129. // Click Save
  130. const saveButton = screen.getByRole('button', { name: /save/i });
  131. fireEvent.click(saveButton);
  132. await waitFor(() => {
  133. expect(api.updateSpool).toHaveBeenCalledTimes(1);
  134. });
  135. const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
  136. expect(spoolId).toBe(1);
  137. // weight_used MUST be present since the user changed the weight
  138. expect(payload).toHaveProperty('weight_used', 500);
  139. });
  140. it('includes weight_used when creating a new spool', async () => {
  141. render(
  142. <SpoolFormModal
  143. isOpen={true}
  144. onClose={vi.fn()}
  145. currencySymbol="$"
  146. />
  147. );
  148. // Wait for the modal to render with the create title
  149. await waitFor(() => {
  150. expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
  151. });
  152. // Click the submit button (validation is mocked to always pass).
  153. // The default form data has weight_used=0, and for create mode the condition
  154. // if (!isEditing || weightTouched) { data.weight_used = formData.weight_used; }
  155. // always includes weight_used since isEditing is false.
  156. // The submit button also says "Add Spool" — use getAllByText and pick the button.
  157. const addButtons = screen.getAllByRole('button', { name: /add spool/i });
  158. const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
  159. expect(submitButton).toBeTruthy();
  160. fireEvent.click(submitButton!);
  161. await waitFor(() => {
  162. expect(api.createSpool).toHaveBeenCalledTimes(1);
  163. });
  164. const [payload] = vi.mocked(api.createSpool).mock.calls[0];
  165. // weight_used MUST be included for new spools (default value 0)
  166. expect(payload).toHaveProperty('weight_used', 0);
  167. });
  168. it('preserves core_weight_catalog_id when editing other fields', async () => {
  169. const spoolWithCatalogId: InventorySpool = {
  170. ...existingSpool,
  171. core_weight_catalog_id: 5,
  172. };
  173. render(
  174. <SpoolFormModal
  175. isOpen={true}
  176. onClose={vi.fn()}
  177. spool={spoolWithCatalogId}
  178. currencySymbol="$"
  179. />
  180. );
  181. await waitFor(() => {
  182. expect(screen.getByText('Edit Spool')).toBeInTheDocument();
  183. });
  184. // Change the note field (unrelated to catalog ID)
  185. const noteInputs = screen.getAllByPlaceholderText(/note/i);
  186. expect(noteInputs.length).toBeGreaterThan(0);
  187. fireEvent.change(noteInputs[0], { target: { value: 'Updated note' } });
  188. // Click Save
  189. const saveButton = screen.getByRole('button', { name: /save/i });
  190. fireEvent.click(saveButton);
  191. await waitFor(() => {
  192. expect(api.updateSpool).toHaveBeenCalledTimes(1);
  193. });
  194. const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
  195. expect(spoolId).toBe(1);
  196. // core_weight_catalog_id MUST be preserved when editing other fields
  197. expect(payload).toHaveProperty('core_weight_catalog_id', 5);
  198. // Other changes should also be present
  199. expect(payload).toHaveProperty('note', 'Updated note');
  200. });
  201. it('includes core_weight_catalog_id when selecting from catalog', async () => {
  202. const mockCatalog = [
  203. { id: 1, name: 'Generic 250g', weight: 250 },
  204. { id: 2, name: 'Bambu Lab 250g', weight: 250 },
  205. { id: 3, name: 'Standard 300g', weight: 300 },
  206. ];
  207. vi.mocked(api.getSpoolCatalog).mockResolvedValue(mockCatalog);
  208. render(
  209. <SpoolFormModal
  210. isOpen={true}
  211. onClose={vi.fn()}
  212. currencySymbol="$"
  213. />
  214. );
  215. await waitFor(() => {
  216. expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
  217. });
  218. // Wait for catalog to load
  219. await waitFor(() => {
  220. expect(api.getSpoolCatalog).toHaveBeenCalled();
  221. });
  222. // Click on the empty spool weight field to open dropdown
  223. const weightInputs = screen.getAllByPlaceholderText(/search/i);
  224. const weightPicker = weightInputs.find(input =>
  225. input.getAttribute('placeholder')?.toLowerCase().includes('spool')
  226. );
  227. expect(weightPicker).toBeTruthy();
  228. fireEvent.focus(weightPicker!);
  229. // Click on "Bambu Lab 250g" option
  230. const bambuOption = await screen.findByText('Bambu Lab 250g');
  231. fireEvent.click(bambuOption);
  232. // Click the add spool button
  233. const addButtons = screen.getAllByRole('button', { name: /add spool/i });
  234. const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
  235. expect(submitButton).toBeTruthy();
  236. fireEvent.click(submitButton!);
  237. await waitFor(() => {
  238. expect(api.createSpool).toHaveBeenCalledTimes(1);
  239. });
  240. const [payload] = vi.mocked(api.createSpool).mock.calls[0];
  241. // Both weight AND catalog ID should be sent
  242. expect(payload).toHaveProperty('core_weight', 250);
  243. expect(payload).toHaveProperty('core_weight_catalog_id', 2); // ID of "Bambu Lab 250g"
  244. });
  245. it('preserves cost_per_kg when editing spool', async () => {
  246. const spoolWithCost: InventorySpool = {
  247. ...existingSpool,
  248. cost_per_kg: 25.50,
  249. };
  250. render(
  251. <SpoolFormModal
  252. isOpen={true}
  253. onClose={vi.fn()}
  254. spool={spoolWithCost}
  255. currencySymbol="$"
  256. />
  257. );
  258. await waitFor(() => {
  259. expect(screen.getByText('Edit Spool')).toBeInTheDocument();
  260. });
  261. // Click Save without changing cost
  262. const saveButton = screen.getByRole('button', { name: /save/i });
  263. fireEvent.click(saveButton);
  264. await waitFor(() => {
  265. expect(api.updateSpool).toHaveBeenCalledTimes(1);
  266. });
  267. const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
  268. expect(spoolId).toBe(1);
  269. // cost_per_kg should be preserved in the update payload
  270. expect(payload).toHaveProperty('cost_per_kg', 25.50);
  271. });
  272. it('sends null cost_per_kg when spool has no cost', async () => {
  273. const spoolWithoutCost: InventorySpool = {
  274. ...existingSpool,
  275. cost_per_kg: null,
  276. };
  277. render(
  278. <SpoolFormModal
  279. isOpen={true}
  280. onClose={vi.fn()}
  281. spool={spoolWithoutCost}
  282. currencySymbol="$"
  283. />
  284. );
  285. await waitFor(() => {
  286. expect(screen.getByText('Edit Spool')).toBeInTheDocument();
  287. });
  288. const saveButton = screen.getByRole('button', { name: /save/i });
  289. fireEvent.click(saveButton);
  290. await waitFor(() => {
  291. expect(api.updateSpool).toHaveBeenCalledTimes(1);
  292. });
  293. const [, payload] = vi.mocked(api.updateSpool).mock.calls[0];
  294. // cost_per_kg should be null when not set
  295. expect(payload).toHaveProperty('cost_per_kg', null);
  296. });
  297. it('displays correct catalog name when duplicates exist', async () => {
  298. const spoolWithCatalogId: InventorySpool = {
  299. ...existingSpool,
  300. core_weight: 250,
  301. core_weight_catalog_id: 2, // "Bambu Lab 250g", not the first match
  302. };
  303. const mockCatalog = [
  304. { id: 1, name: 'Generic 250g', weight: 250 },
  305. { id: 2, name: 'Bambu Lab 250g', weight: 250 },
  306. { id: 3, name: 'Standard 300g', weight: 300 },
  307. ];
  308. vi.mocked(api.getSpoolCatalog).mockResolvedValue(mockCatalog);
  309. render(
  310. <SpoolFormModal
  311. isOpen={true}
  312. onClose={vi.fn()}
  313. spool={spoolWithCatalogId}
  314. currencySymbol="$"
  315. />
  316. );
  317. await waitFor(() => {
  318. expect(screen.getByText('Edit Spool')).toBeInTheDocument();
  319. });
  320. // Wait for catalog to load
  321. await waitFor(() => {
  322. expect(api.getSpoolCatalog).toHaveBeenCalled();
  323. });
  324. // Should display "Bambu Lab 250g" (by ID), not "Generic 250g" (first match by weight)
  325. await waitFor(() => {
  326. const weightInputs = screen.getAllByDisplayValue(/250|Bambu/i);
  327. const bambuFound = weightInputs.some(input =>
  328. input.value === 'Bambu Lab 250g' || input.getAttribute('value') === 'Bambu Lab 250g'
  329. );
  330. expect(bambuFound).toBeTruthy();
  331. });
  332. });
  333. });