SpoolFormEditRelaxed.test.tsx 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. /**
  2. * Tests for #1905 — editing a spool that was created without a slicer preset.
  3. *
  4. * Covers:
  5. * - edit/copy no longer demand a slicer preset, brand or subtype
  6. * - picking a preset never overwrites identity fields the user already set
  7. * - the Quick Add layout can't leak from create mode into an edit
  8. * - brand/material dropdowns rank catalog pairings instead of filtering by them
  9. */
  10. import React from 'react';
  11. import { describe, it, expect, vi, beforeEach } from 'vitest';
  12. import { screen, waitFor, fireEvent } from '@testing-library/react';
  13. import { render } from '../utils';
  14. import { SpoolFormModal } from '../../components/SpoolFormModal';
  15. import type { InventorySpool } from '../../api/client';
  16. vi.mock('../../api/client', () => ({
  17. api: {
  18. getSettings: vi.fn().mockResolvedValue({}),
  19. getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
  20. getCloudStatus: vi.fn().mockResolvedValue({ is_authenticated: false }),
  21. orcaCloudStatus: vi.fn().mockResolvedValue({ connected: false }),
  22. orcaCloudListProfiles: vi.fn().mockResolvedValue({ filament: [] }),
  23. getFilamentPresets: vi.fn().mockResolvedValue([]),
  24. getSpoolCatalog: vi.fn().mockResolvedValue([]),
  25. getLocations: vi.fn().mockResolvedValue([]),
  26. // Elegoo is only known for PLA here — the pairing that used to hide it
  27. // from the brand list as soon as ASA was selected.
  28. getColorCatalog: vi.fn().mockResolvedValue([
  29. { manufacturer: 'Elegoo', color_name: 'Red', hex_color: 'FF0000', material: 'PLA' },
  30. { manufacturer: 'Polymaker', color_name: 'Blue', hex_color: '0000FF', material: 'ASA' },
  31. ]),
  32. getLocalPresets: vi.fn().mockResolvedValue({ filament: [] }),
  33. getBuiltinFilaments: vi.fn().mockResolvedValue([
  34. { filament_id: 'GFA05', name: 'Generic ASA' },
  35. ]),
  36. getPrinters: vi.fn().mockResolvedValue([]),
  37. getPrinterStatus: vi.fn().mockResolvedValue(null),
  38. getSpoolUsageHistory: vi.fn().mockResolvedValue([]),
  39. createSpool: vi.fn().mockResolvedValue({ id: 99 }),
  40. updateSpool: vi.fn().mockResolvedValue({ id: 7 }),
  41. saveSpoolKProfiles: vi.fn().mockResolvedValue([]),
  42. getSpoolFilamentPresets: vi.fn().mockResolvedValue([]),
  43. saveSpoolFilamentPresets: vi.fn().mockResolvedValue([]),
  44. getSpoolmanFilamentPresets: vi.fn().mockResolvedValue([]),
  45. saveSpoolmanFilamentPresets: vi.fn().mockResolvedValue([]),
  46. getSpoolmanInventoryFilaments: vi.fn().mockResolvedValue([]),
  47. getAssignments: vi.fn().mockResolvedValue([]),
  48. unassignSpool: vi.fn().mockResolvedValue({}),
  49. },
  50. ApiError: class ApiError extends Error {
  51. status: number;
  52. constructor(message: string, status: number) {
  53. super(message);
  54. this.status = status;
  55. }
  56. },
  57. }));
  58. const mockShowToast = vi.fn();
  59. vi.mock('../../contexts/ToastContext', async (importOriginal) => {
  60. const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();
  61. return {
  62. ...actual,
  63. useToast: () => ({ showToast: mockShowToast }),
  64. };
  65. });
  66. import { api } from '../../api/client';
  67. // A spool as produced by Quick Add / CSV import / an RFID scan: material only.
  68. const quickAddedSpool: InventorySpool = {
  69. id: 7,
  70. material: 'ASA',
  71. subtype: null,
  72. brand: null,
  73. color_name: null,
  74. rgba: '808080FF',
  75. extra_colors: null,
  76. effect_type: null,
  77. label_weight: 1000,
  78. core_weight: 250,
  79. core_weight_catalog_id: null,
  80. weight_used: 0,
  81. slicer_filament: null,
  82. slicer_filament_name: null,
  83. nozzle_temp_min: null,
  84. nozzle_temp_max: null,
  85. note: null,
  86. added_full: null,
  87. last_used: null,
  88. encode_time: null,
  89. tag_uid: null,
  90. tray_uuid: null,
  91. data_origin: null,
  92. tag_type: null,
  93. archived_at: null,
  94. created_at: '2025-01-01T00:00:00Z',
  95. updated_at: '2025-01-01T00:00:00Z',
  96. k_profiles: [],
  97. } as unknown as InventorySpool;
  98. const elegooAsaSpool: InventorySpool = {
  99. ...quickAddedSpool,
  100. id: 8,
  101. brand: 'Elegoo',
  102. subtype: 'Basic',
  103. } as unknown as InventorySpool;
  104. describe('SpoolFormModal relaxed edit/copy validation (#1905)', () => {
  105. beforeEach(() => {
  106. vi.clearAllMocks();
  107. });
  108. it('saves an edit of a preset-less spool without demanding a slicer preset', async () => {
  109. render(
  110. <SpoolFormModal
  111. isOpen={true}
  112. onClose={vi.fn()}
  113. spool={quickAddedSpool}
  114. mode="edit"
  115. currencySymbol="$"
  116. />,
  117. );
  118. await waitFor(() => expect(screen.getByRole('heading', { name: /Edit Spool/ })).toBeInTheDocument());
  119. fireEvent.click(screen.getByRole('button', { name: /save/i }));
  120. await waitFor(() => expect(api.updateSpool).toHaveBeenCalled());
  121. expect(screen.queryByText('Slicer preset is required')).not.toBeInTheDocument();
  122. });
  123. it('copies a preset-less spool without demanding a slicer preset', async () => {
  124. render(
  125. <SpoolFormModal
  126. isOpen={true}
  127. onClose={vi.fn()}
  128. spool={quickAddedSpool}
  129. mode="copy"
  130. currencySymbol="$"
  131. />,
  132. );
  133. await waitFor(() => expect(screen.getByRole('heading', { name: 'Copy Spool' })).toBeInTheDocument());
  134. fireEvent.click(screen.getByRole('button', { name: 'Copy Spool' }));
  135. await waitFor(() => expect(api.createSpool).toHaveBeenCalled());
  136. expect(screen.queryByText('Slicer preset is required')).not.toBeInTheDocument();
  137. });
  138. it('still requires a slicer preset when creating a spool', async () => {
  139. render(
  140. <SpoolFormModal isOpen={true} onClose={vi.fn()} mode="create" currencySymbol="$" />,
  141. );
  142. await waitFor(() => expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument());
  143. fireEvent.click(screen.getByRole('button', { name: 'Add Spool' }));
  144. await waitFor(() => expect(screen.getByText('Slicer preset is required')).toBeInTheDocument());
  145. expect(api.createSpool).not.toHaveBeenCalled();
  146. });
  147. it('keeps the spool brand when a preset is picked during an edit', async () => {
  148. render(
  149. <SpoolFormModal
  150. isOpen={true}
  151. onClose={vi.fn()}
  152. spool={elegooAsaSpool}
  153. mode="edit"
  154. currencySymbol="$"
  155. />,
  156. );
  157. await waitFor(() => expect(screen.getByRole('heading', { name: /Edit Spool/ })).toBeInTheDocument());
  158. const presetInput = screen.getByPlaceholderText('Search filament presets...');
  159. fireEvent.focus(presetInput);
  160. await waitFor(() => expect(screen.getByRole('button', { name: /^Generic ASA\b/ })).toBeInTheDocument());
  161. fireEvent.click(screen.getByRole('button', { name: /^Generic ASA\b/ }));
  162. // parsePresetName('Generic ASA') yields brand "Generic" — it must not
  163. // replace the manufacturer the spool already carries.
  164. expect(screen.getByPlaceholderText('Search brand...')).toHaveValue('Elegoo');
  165. });
  166. it('auto-fills empty identity fields from the preset when creating', async () => {
  167. render(
  168. <SpoolFormModal isOpen={true} onClose={vi.fn()} mode="create" currencySymbol="$" />,
  169. );
  170. await waitFor(() => expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument());
  171. const presetInput = screen.getByPlaceholderText('Search filament presets...');
  172. fireEvent.focus(presetInput);
  173. await waitFor(() => expect(screen.getByRole('button', { name: /^Generic ASA\b/ })).toBeInTheDocument());
  174. fireEvent.click(screen.getByRole('button', { name: /^Generic ASA\b/ }));
  175. expect(screen.getByPlaceholderText('Search brand...')).toHaveValue('Generic');
  176. expect(screen.getByPlaceholderText('Select material...')).toHaveValue('ASA');
  177. });
  178. it('offers brands the catalog does not pair with the selected material', async () => {
  179. render(
  180. <SpoolFormModal
  181. isOpen={true}
  182. onClose={vi.fn()}
  183. spool={quickAddedSpool}
  184. mode="edit"
  185. currencySymbol="$"
  186. />,
  187. );
  188. await waitFor(() => expect(screen.getByRole('heading', { name: /Edit Spool/ })).toBeInTheDocument());
  189. fireEvent.focus(screen.getByPlaceholderText('Search brand...'));
  190. // Polymaker is the known ASA brand, Elegoo is only catalogued for PLA —
  191. // both are selectable, the pairing only decides the order.
  192. await waitFor(() => expect(screen.getByRole('button', { name: 'Polymaker' })).toBeInTheDocument());
  193. expect(screen.getByRole('button', { name: 'Elegoo' })).toBeInTheDocument();
  194. expect(screen.getByText('Suggested')).toBeInTheDocument();
  195. expect(screen.getByText('All')).toBeInTheDocument();
  196. });
  197. it('does not carry Quick Add layout from a create into a later edit', async () => {
  198. const { rerender } = render(
  199. <SpoolFormModal isOpen={true} onClose={vi.fn()} mode="create" currencySymbol="$" />,
  200. );
  201. await waitFor(() => expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument());
  202. // Turn Quick Add on — the preset field disappears.
  203. fireEvent.click(screen.getByText('Quick Add (Stock)').closest('div')!.parentElement!.querySelector('button')!);
  204. await waitFor(() =>
  205. expect(screen.queryByPlaceholderText('Search filament presets...')).not.toBeInTheDocument(),
  206. );
  207. // Close, then reopen on an existing spool. The toggle only renders in
  208. // create mode, so a leaked quickAdd would strand the edit form.
  209. rerender(<SpoolFormModal isOpen={false} onClose={vi.fn()} mode="create" currencySymbol="$" />);
  210. rerender(
  211. <SpoolFormModal
  212. isOpen={true}
  213. onClose={vi.fn()}
  214. spool={quickAddedSpool}
  215. mode="edit"
  216. currencySymbol="$"
  217. />,
  218. );
  219. await waitFor(() => expect(screen.getByRole('heading', { name: /Edit Spool/ })).toBeInTheDocument());
  220. expect(screen.getByPlaceholderText('Search filament presets...')).toBeInTheDocument();
  221. });
  222. });