BulkEditSpoolsModalWiring.test.tsx 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  1. /**
  2. * Wiring tests for BulkEditSpoolsModal: pin the exact patch handed to
  3. * onApply (which InventoryPage forwards to bulkUpdateSpools). Only ticked
  4. * fields may appear, so other spools' values are never overwritten, and
  5. * Spoolman mode must never emit internal-only keys.
  6. */
  7. import { describe, it, expect, vi, beforeEach } from 'vitest';
  8. import { screen, waitFor, within } from '@testing-library/react';
  9. import userEvent from '@testing-library/user-event';
  10. import { render } from '../utils';
  11. import { BulkEditSpoolsModal } from '../../components/BulkEditSpoolsModal';
  12. vi.mock('../../api/client', async (importOriginal) => {
  13. const actual = await importOriginal<typeof import('../../api/client')>();
  14. return {
  15. ...actual,
  16. api: {
  17. ...actual.api,
  18. getCloudStatus: vi.fn().mockResolvedValue({ is_authenticated: false }),
  19. orcaCloudStatus: vi.fn().mockResolvedValue({ connected: false }),
  20. getLocalPresets: vi.fn().mockResolvedValue({ filament: [] }),
  21. getBuiltinFilaments: vi.fn().mockResolvedValue([]),
  22. getSettings: vi.fn().mockResolvedValue({}),
  23. getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
  24. },
  25. };
  26. });
  27. const onApply = vi.fn();
  28. const onClose = vi.fn();
  29. const baseProps = {
  30. isOpen: true,
  31. selectedCount: 3,
  32. isPending: false,
  33. availableLocations: [{ id: 5, name: 'Shelf A' }],
  34. availableMaterials: [],
  35. availableSubtypes: [],
  36. availableBrands: [],
  37. availableCategories: [],
  38. availableMaterialNumbers: [],
  39. availableSlicerFilaments: [],
  40. availableSlicerFilamentNames: [],
  41. onClose,
  42. onApply,
  43. };
  44. /** The row container for a field, found via its exact label text. */
  45. function row(label: string): HTMLElement {
  46. const lbl = screen.getByText(label, { selector: 'label' });
  47. return lbl.closest('div.flex.items-start') as HTMLElement;
  48. }
  49. async function tickAndType(user: ReturnType<typeof userEvent.setup>, label: string, value: string) {
  50. const r = row(label);
  51. await user.click(within(r).getByRole('checkbox'));
  52. const input = r.querySelector('textarea, input[type="text"], input[type="number"]') as HTMLElement;
  53. await user.type(input, value);
  54. }
  55. describe('BulkEditSpoolsModal wiring', () => {
  56. beforeEach(() => {
  57. vi.clearAllMocks();
  58. });
  59. it('only the ticked material field ends up in the patch', async () => {
  60. const user = userEvent.setup();
  61. render(<BulkEditSpoolsModal {...baseProps} spoolmanMode={false} />);
  62. await tickAndType(user, 'Material', 'PETG-CF');
  63. // Close any open dropdown so the Apply button is reachable.
  64. await user.keyboard('{Escape}');
  65. await user.click(screen.getByRole('button', { name: /Apply to 3 spools/ }));
  66. await waitFor(() => expect(onApply).toHaveBeenCalledTimes(1));
  67. expect(onApply).toHaveBeenCalledWith({ material: 'PETG-CF' });
  68. });
  69. it('ticked but empty fields are left out; numbers are sent as numbers', async () => {
  70. const user = userEvent.setup();
  71. render(<BulkEditSpoolsModal {...baseProps} spoolmanMode={false} />);
  72. // Ticked, left blank -> must not blank the field on every selected spool.
  73. await user.click(within(row('Brand')).getByRole('checkbox'));
  74. await tickAndType(user, 'Cost per kg', '19.5');
  75. await user.click(screen.getByRole('button', { name: /Apply to 3 spools/ }));
  76. await waitFor(() => expect(onApply).toHaveBeenCalledTimes(1));
  77. expect(onApply).toHaveBeenCalledWith({ cost_per_kg: 19.5 });
  78. });
  79. it('internal mode: internal-only fields are sent when ticked', async () => {
  80. const user = userEvent.setup();
  81. render(<BulkEditSpoolsModal {...baseProps} spoolmanMode={false} />);
  82. await tickAndType(user, 'Material No.', 'MN-7');
  83. await user.keyboard('{Escape}');
  84. await user.click(screen.getByRole('button', { name: /Apply to 3 spools/ }));
  85. await waitFor(() => expect(onApply).toHaveBeenCalledWith({ material_number: 'MN-7' }));
  86. });
  87. it('Spoolman mode: patch holds only the ticked Spoolman-storable fields, no internal-only keys', async () => {
  88. const user = userEvent.setup();
  89. render(<BulkEditSpoolsModal {...baseProps} spoolmanMode />);
  90. await tickAndType(user, 'Material', 'ASA');
  91. await user.keyboard('{Escape}');
  92. await tickAndType(user, 'Note', 'restocked');
  93. await user.click(screen.getByRole('button', { name: /Apply to 3 spools/ }));
  94. await waitFor(() => expect(onApply).toHaveBeenCalledTimes(1));
  95. const patch = onApply.mock.calls[0][0] as Record<string, unknown>;
  96. expect(patch).toEqual({ material: 'ASA', note: 'restocked' });
  97. for (const k of ['category', 'low_stock_threshold_pct', 'material_number']) {
  98. expect(patch).not.toHaveProperty(k);
  99. }
  100. });
  101. it('Spoolman mode: ticking and filling EVERY offered field still yields no internal-only key', async () => {
  102. const user = userEvent.setup();
  103. render(<BulkEditSpoolsModal {...baseProps} spoolmanMode />);
  104. const rows = screen.getAllByRole('checkbox').map((cb) => cb.closest('div.flex.items-start') as HTMLElement);
  105. for (const r of rows) {
  106. await user.click(within(r).getByRole('checkbox'));
  107. const input = r.querySelector('textarea, input[type="text"], input[type="number"]') as HTMLInputElement;
  108. await user.type(input, input.type === 'number' ? '5' : input.placeholder?.includes('RRGGBB') ? 'FF0000' : 'X');
  109. await user.keyboard('{Escape}');
  110. }
  111. await user.click(screen.getByRole('button', { name: /Apply to 3 spools/ }));
  112. await waitFor(() => expect(onApply).toHaveBeenCalledTimes(1));
  113. const patch = onApply.mock.calls[0][0] as Record<string, unknown>;
  114. // Sanity: the fill reached the patch (material, note, ... are present).
  115. expect(patch).toHaveProperty('material', 'X');
  116. expect(patch).toHaveProperty('note', 'X');
  117. for (const k of ['category', 'low_stock_threshold_pct', 'material_number']) {
  118. expect(patch).not.toHaveProperty(k);
  119. }
  120. });
  121. it('Cancel sends nothing', async () => {
  122. const user = userEvent.setup();
  123. render(<BulkEditSpoolsModal {...baseProps} />);
  124. await tickAndType(user, 'Note', 'x');
  125. await user.click(screen.getByRole('button', { name: 'Cancel' }));
  126. expect(onClose).toHaveBeenCalled();
  127. expect(onApply).not.toHaveBeenCalled();
  128. });
  129. });