SpoolFormBulk.test.tsx 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. /**
  2. * Tests for bulk spool creation and quick-add mode.
  3. *
  4. * Verifies:
  5. * - Quick-add toggle appears only in create mode
  6. * - Quick-add mode shows brand and subtype as optional (no asterisk)
  7. * - Quick-add mode hides slicer preset field
  8. * - Quick-add mode hides PA Profile tab
  9. * - Quantity field is only rendered in quick-add mode
  10. * - Quantity field is hidden in edit mode
  11. * - Bulk create calls bulkCreateSpools when quantity > 1
  12. * - Single quantity calls createSpool as before
  13. * - validateForm with quickAdd=true only requires material
  14. */
  15. import React from 'react';
  16. import { describe, it, expect, vi, beforeEach } from 'vitest';
  17. import { screen, waitFor, fireEvent } from '@testing-library/react';
  18. import { render } from '../utils';
  19. import { SpoolFormModal } from '../../components/SpoolFormModal';
  20. import { validateForm, defaultFormData } from '../../components/spool-form/types';
  21. import type { InventorySpool } from '../../api/client';
  22. // Mock the API client
  23. vi.mock('../../api/client', () => ({
  24. api: {
  25. getSettings: vi.fn().mockResolvedValue({}),
  26. getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
  27. getCloudStatus: vi.fn().mockResolvedValue({ is_authenticated: false }),
  28. getFilamentPresets: vi.fn().mockResolvedValue([]),
  29. getSpoolCatalog: vi.fn().mockResolvedValue([]),
  30. getLocations: vi.fn().mockResolvedValue([]),
  31. getColorCatalog: vi.fn().mockResolvedValue([]),
  32. getLocalPresets: vi.fn().mockResolvedValue({ filament: [] }),
  33. getBuiltinFilaments: vi.fn().mockResolvedValue([]),
  34. getPrinters: vi.fn().mockResolvedValue([]),
  35. getSpoolUsageHistory: vi.fn().mockResolvedValue([]),
  36. createSpool: vi.fn().mockResolvedValue({ id: 99 }),
  37. bulkCreateSpools: vi.fn().mockResolvedValue([
  38. { id: 100, k_profiles: [] },
  39. { id: 101, k_profiles: [] },
  40. { id: 102, k_profiles: [] },
  41. ]),
  42. updateSpool: vi.fn().mockResolvedValue({ id: 1 }),
  43. saveSpoolKProfiles: vi.fn().mockResolvedValue([]),
  44. },
  45. }));
  46. // Mock the toast context
  47. const mockShowToast = vi.fn();
  48. vi.mock('../../contexts/ToastContext', async (importOriginal) => {
  49. const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();
  50. return {
  51. ...actual,
  52. useToast: () => ({ showToast: mockShowToast }),
  53. };
  54. });
  55. const existingSpool: InventorySpool = {
  56. id: 1,
  57. material: 'PLA',
  58. subtype: 'Basic',
  59. brand: 'Polymaker',
  60. color_name: 'Red',
  61. rgba: 'FF0000FF',
  62. extra_colors: null,
  63. effect_type: null,
  64. label_weight: 1000,
  65. core_weight: 250,
  66. core_weight_catalog_id: null,
  67. weight_used: 300,
  68. slicer_filament: 'GFL99',
  69. slicer_filament_name: 'Generic PLA',
  70. nozzle_temp_min: null,
  71. nozzle_temp_max: null,
  72. note: null,
  73. added_full: null,
  74. last_used: null,
  75. encode_time: null,
  76. tag_uid: null,
  77. tray_uuid: null,
  78. data_origin: null,
  79. tag_type: null,
  80. archived_at: null,
  81. created_at: '2025-01-01T00:00:00Z',
  82. updated_at: '2025-01-01T00:00:00Z',
  83. k_profiles: [],
  84. cost_per_kg: null,
  85. };
  86. describe('validateForm with quickAdd', () => {
  87. it('requires only material in quick-add mode', () => {
  88. const result = validateForm({ ...defaultFormData, material: 'PLA' }, true);
  89. expect(result.isValid).toBe(true);
  90. expect(result.errors).toEqual({});
  91. });
  92. it('rejects empty material in quick-add mode', () => {
  93. const result = validateForm({ ...defaultFormData, material: '' }, true);
  94. expect(result.isValid).toBe(false);
  95. expect(result.errors.material).toBeDefined();
  96. });
  97. it('does not require slicer_filament in quick-add mode', () => {
  98. const result = validateForm(
  99. { ...defaultFormData, material: 'PETG', slicer_filament: '' },
  100. true,
  101. );
  102. expect(result.isValid).toBe(true);
  103. });
  104. it('does not require brand in quick-add mode', () => {
  105. const result = validateForm(
  106. { ...defaultFormData, material: 'ABS', brand: '' },
  107. true,
  108. );
  109. expect(result.isValid).toBe(true);
  110. });
  111. it('does not require subtype in quick-add mode', () => {
  112. const result = validateForm(
  113. { ...defaultFormData, material: 'TPU', subtype: '' },
  114. true,
  115. );
  116. expect(result.isValid).toBe(true);
  117. });
  118. it('requires all fields in full mode (quickAdd=false)', () => {
  119. const result = validateForm(defaultFormData, false);
  120. expect(result.isValid).toBe(false);
  121. expect(result.errors.material).toBeDefined();
  122. expect(result.errors.slicer_filament).toBeDefined();
  123. expect(result.errors.brand).toBeDefined();
  124. expect(result.errors.subtype).toBeDefined();
  125. });
  126. });
  127. describe('SpoolFormModal quick-add toggle', () => {
  128. beforeEach(() => {
  129. vi.clearAllMocks();
  130. });
  131. it('shows quick-add toggle in create mode', async () => {
  132. render(
  133. <SpoolFormModal
  134. isOpen={true}
  135. onClose={vi.fn()}
  136. mode="create"
  137. currencySymbol="$"
  138. />,
  139. );
  140. await waitFor(() => {
  141. expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
  142. });
  143. expect(screen.getByText('Quick Add (Stock)')).toBeInTheDocument();
  144. });
  145. it('hides quick-add toggle in edit mode', async () => {
  146. render(
  147. <SpoolFormModal
  148. isOpen={true}
  149. onClose={vi.fn()}
  150. spool={existingSpool}
  151. mode="edit"
  152. currencySymbol="$"
  153. />,
  154. );
  155. await waitFor(() => {
  156. expect(screen.getByText('Edit Spool')).toBeInTheDocument();
  157. });
  158. expect(screen.queryByText('Quick Add (Stock)')).not.toBeInTheDocument();
  159. });
  160. it('hides PA Profile tab when quick-add is enabled', async () => {
  161. render(
  162. <SpoolFormModal
  163. isOpen={true}
  164. onClose={vi.fn()}
  165. mode="create"
  166. currencySymbol="$"
  167. />,
  168. );
  169. await waitFor(() => {
  170. expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
  171. });
  172. // PA Profile tab should be visible initially
  173. expect(screen.getByText('PA Profile')).toBeInTheDocument();
  174. // Toggle quick-add on — the toggle is a button[role="switch"] sibling of the label
  175. const toggleButtons = screen.getAllByRole('button');
  176. const quickAddToggle = toggleButtons.find(btn =>
  177. btn.getAttribute('type') === 'button' &&
  178. btn.className.includes('rounded-full') &&
  179. btn.closest('div')?.textContent?.includes('Quick Add')
  180. );
  181. expect(quickAddToggle).toBeTruthy();
  182. fireEvent.click(quickAddToggle!);
  183. // PA Profile tab should be hidden
  184. await waitFor(() => {
  185. expect(screen.queryByText('PA Profile')).not.toBeInTheDocument();
  186. });
  187. });
  188. it('hides quantity field by default (non-quick-add)', async () => {
  189. render(
  190. <SpoolFormModal
  191. isOpen={true}
  192. onClose={vi.fn()}
  193. mode="create"
  194. currencySymbol="$"
  195. />,
  196. );
  197. await waitFor(() => {
  198. expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
  199. });
  200. // Quantity field should NOT be visible in normal create mode
  201. expect(screen.queryByText('Quantity')).not.toBeInTheDocument();
  202. });
  203. it('shows quantity field only in quick-add mode', async () => {
  204. render(
  205. <SpoolFormModal
  206. isOpen={true}
  207. onClose={vi.fn()}
  208. mode="create"
  209. currencySymbol="$"
  210. />,
  211. );
  212. await waitFor(() => {
  213. expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
  214. });
  215. // Toggle quick-add on
  216. const toggleButtons = screen.getAllByRole('button');
  217. const quickAddToggle = toggleButtons.find(btn =>
  218. btn.getAttribute('type') === 'button' &&
  219. btn.className.includes('rounded-full') &&
  220. btn.closest('div')?.textContent?.includes('Quick Add')
  221. );
  222. expect(quickAddToggle).toBeTruthy();
  223. fireEvent.click(quickAddToggle!);
  224. // Quantity field should now be visible
  225. await waitFor(() => {
  226. expect(screen.getByText('Quantity')).toBeInTheDocument();
  227. });
  228. });
  229. it('hides quantity field in edit mode', async () => {
  230. render(
  231. <SpoolFormModal
  232. isOpen={true}
  233. onClose={vi.fn()}
  234. spool={existingSpool}
  235. mode="edit"
  236. currencySymbol="$"
  237. />,
  238. );
  239. await waitFor(() => {
  240. expect(screen.getByText('Edit Spool')).toBeInTheDocument();
  241. });
  242. // Quantity field should NOT be visible in edit mode
  243. expect(screen.queryByText('Quantity')).not.toBeInTheDocument();
  244. });
  245. it('shows brand and subtype in quick-add mode without asterisk', async () => {
  246. render(
  247. <SpoolFormModal
  248. isOpen={true}
  249. onClose={vi.fn()}
  250. mode="create"
  251. currencySymbol="$"
  252. />,
  253. );
  254. await waitFor(() => {
  255. expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
  256. });
  257. // Toggle quick-add on
  258. const toggleButtons = screen.getAllByRole('button');
  259. const quickAddToggle = toggleButtons.find(btn =>
  260. btn.getAttribute('type') === 'button' &&
  261. btn.className.includes('rounded-full') &&
  262. btn.closest('div')?.textContent?.includes('Quick Add')
  263. );
  264. fireEvent.click(quickAddToggle!);
  265. // Brand and Subtype should be visible (without asterisk = optional)
  266. await waitFor(() => {
  267. const brandLabel = screen.getByText('Brand');
  268. expect(brandLabel).toBeInTheDocument();
  269. expect(brandLabel.textContent).not.toContain('*');
  270. const subtypeLabel = screen.getByText('Subtype');
  271. expect(subtypeLabel).toBeInTheDocument();
  272. expect(subtypeLabel.textContent).not.toContain('*');
  273. });
  274. });
  275. });