SpoolFormLastDried.test.tsx 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  1. /**
  2. * The "Last dried" field in the spool form (#2863).
  3. *
  4. * An AMS drying run stamps the date in the background, so the form must only
  5. * send it when the user changed it — otherwise saving an unrelated edit would
  6. * put back the date the form loaded with.
  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. vi.mock('../../api/client', () => ({
  15. api: {
  16. getSettings: vi.fn().mockResolvedValue({}),
  17. getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
  18. getCloudStatus: vi.fn().mockResolvedValue({ is_authenticated: false }),
  19. orcaCloudStatus: vi.fn().mockResolvedValue({ connected: false }),
  20. orcaCloudListProfiles: vi.fn().mockResolvedValue({ filament: [] }),
  21. getFilamentPresets: vi.fn().mockResolvedValue([]),
  22. getSpoolCatalog: vi.fn().mockResolvedValue([]),
  23. getLocations: vi.fn().mockResolvedValue([]),
  24. // Elegoo is only known for PLA here — the pairing that used to hide it
  25. // from the brand list as soon as ASA was selected.
  26. getColorCatalog: vi.fn().mockResolvedValue([
  27. { manufacturer: 'Elegoo', color_name: 'Red', hex_color: 'FF0000', material: 'PLA' },
  28. { manufacturer: 'Polymaker', color_name: 'Blue', hex_color: '0000FF', material: 'ASA' },
  29. ]),
  30. getLocalPresets: vi.fn().mockResolvedValue({ filament: [] }),
  31. getBuiltinFilaments: vi.fn().mockResolvedValue([
  32. { filament_id: 'GFA05', name: 'Generic ASA' },
  33. ]),
  34. getPrinters: vi.fn().mockResolvedValue([]),
  35. getPrinterStatus: vi.fn().mockResolvedValue(null),
  36. getSpoolUsageHistory: vi.fn().mockResolvedValue([]),
  37. createSpool: vi.fn().mockResolvedValue({ id: 99 }),
  38. updateSpool: vi.fn().mockResolvedValue({ id: 7 }),
  39. saveSpoolKProfiles: vi.fn().mockResolvedValue([]),
  40. getSpoolFilamentPresets: vi.fn().mockResolvedValue([]),
  41. saveSpoolFilamentPresets: vi.fn().mockResolvedValue([]),
  42. getSpoolmanFilamentPresets: vi.fn().mockResolvedValue([]),
  43. saveSpoolmanFilamentPresets: vi.fn().mockResolvedValue([]),
  44. getSpoolmanInventoryFilaments: vi.fn().mockResolvedValue([]),
  45. getAssignments: vi.fn().mockResolvedValue([]),
  46. unassignSpool: vi.fn().mockResolvedValue({}),
  47. },
  48. ApiError: class ApiError extends Error {
  49. status: number;
  50. constructor(message: string, status: number) {
  51. super(message);
  52. this.status = status;
  53. }
  54. },
  55. }));
  56. const mockShowToast = vi.fn();
  57. vi.mock('../../contexts/ToastContext', async (importOriginal) => {
  58. const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();
  59. return {
  60. ...actual,
  61. useToast: () => ({ showToast: mockShowToast }),
  62. };
  63. });
  64. import { api } from '../../api/client';
  65. const driedSpool: InventorySpool = {
  66. id: 7,
  67. material: 'ASA',
  68. subtype: null,
  69. brand: null,
  70. color_name: null,
  71. rgba: '808080FF',
  72. extra_colors: null,
  73. effect_type: null,
  74. label_weight: 1000,
  75. core_weight: 250,
  76. core_weight_catalog_id: null,
  77. weight_used: 0,
  78. slicer_filament: null,
  79. slicer_filament_name: null,
  80. nozzle_temp_min: null,
  81. nozzle_temp_max: null,
  82. note: null,
  83. added_full: null,
  84. last_used: null,
  85. encode_time: null,
  86. tag_uid: null,
  87. tray_uuid: null,
  88. data_origin: null,
  89. tag_type: null,
  90. archived_at: null,
  91. created_at: '2025-01-01T00:00:00Z',
  92. updated_at: '2025-01-01T00:00:00Z',
  93. k_profiles: [],
  94. last_dried_at: '2026-09-01T12:00:00',
  95. last_dried_temp: 55,
  96. last_dried_hours: 8,
  97. } as unknown as InventorySpool;
  98. async function openAdditional(mode: 'edit' | 'copy' | 'create' = 'edit') {
  99. render(
  100. <SpoolFormModal
  101. isOpen={true}
  102. onClose={vi.fn()}
  103. spool={mode === 'create' ? undefined : driedSpool}
  104. mode={mode}
  105. currencySymbol="$"
  106. />,
  107. );
  108. await waitFor(() => expect(screen.getAllByRole('heading').length).toBeGreaterThan(0));
  109. fireEvent.click(screen.getByRole('button', { name: /Color & Cost/ }));
  110. }
  111. describe('SpoolFormModal last dried (#2863)', () => {
  112. beforeEach(() => {
  113. vi.clearAllMocks();
  114. });
  115. it('loads the stored date into the field', async () => {
  116. await openAdditional();
  117. const input = await screen.findByLabelText('Last dried');
  118. const expected = new Date('2026-09-01T12:00:00Z');
  119. const pad = (n: number) => String(n).padStart(2, '0');
  120. expect(input).toHaveValue(
  121. `${expected.getFullYear()}-${pad(expected.getMonth() + 1)}-${pad(expected.getDate())}T${pad(expected.getHours())}:${pad(expected.getMinutes())}`,
  122. );
  123. });
  124. it('does not send the date when it was not changed', async () => {
  125. await openAdditional();
  126. await screen.findByLabelText('Last dried');
  127. fireEvent.click(screen.getByRole('button', { name: /save/i }));
  128. await waitFor(() => expect(api.updateSpool).toHaveBeenCalled());
  129. const payload = vi.mocked(api.updateSpool).mock.calls[0][1] as Record<string, unknown>;
  130. expect(payload).not.toHaveProperty('last_dried_at');
  131. });
  132. it('sends the time "Now" sets, as UTC', async () => {
  133. await openAdditional();
  134. await screen.findByLabelText('Last dried');
  135. const before = Date.now();
  136. fireEvent.click(screen.getByRole('button', { name: 'Now' }));
  137. fireEvent.click(screen.getByRole('button', { name: /save/i }));
  138. await waitFor(() => expect(api.updateSpool).toHaveBeenCalled());
  139. const payload = vi.mocked(api.updateSpool).mock.calls[0][1] as Record<string, unknown>;
  140. const sent = new Date(payload.last_dried_at as string).getTime();
  141. expect(payload.last_dried_at as string).toMatch(/Z$/);
  142. // datetime-local has minute precision.
  143. expect(Math.abs(sent - before)).toBeLessThan(61_000);
  144. });
  145. it('sends null when the field is cleared', async () => {
  146. await openAdditional();
  147. const input = await screen.findByLabelText('Last dried');
  148. fireEvent.change(input, { target: { value: '' } });
  149. fireEvent.click(screen.getByRole('button', { name: /save/i }));
  150. await waitFor(() => expect(api.updateSpool).toHaveBeenCalled());
  151. const payload = vi.mocked(api.updateSpool).mock.calls[0][1] as Record<string, unknown>;
  152. expect(payload.last_dried_at).toBeNull();
  153. });
  154. it('is not offered when copying a spool', async () => {
  155. await openAdditional('copy');
  156. await screen.findByText('Note');
  157. expect(screen.queryByLabelText('Last dried')).not.toBeInTheDocument();
  158. });
  159. });