SpoolFormBulk.test.tsx 8.9 KB

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