SpoolFormBulk.test.tsx 9.2 KB

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