sub-balancer-form-modal.test.tsx 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. import { describe, it, expect, vi } from 'vitest';
  2. import { fireEvent, waitFor } from '@testing-library/react';
  3. import SubBalancerFormModal from '@/pages/settings/SubBalancerFormModal';
  4. import type { SubBalancer } from '@/schemas/subBalancer';
  5. import { renderWithProviders } from './test-utils';
  6. vi.mock('@/api/queries/useInboundOptions', () => ({
  7. useInboundOptions: () => ({
  8. data: [
  9. { id: 1, tag: 'inb-vless', remark: 'First', protocol: 'vless', port: 443, enable: true },
  10. { id: 2, tag: 'inb-ws', remark: 'Second', protocol: 'vmess', port: 8443, enable: true },
  11. { id: 3, tag: 'inb-off', remark: 'Disabled', protocol: 'vless', port: 8080, enable: false },
  12. ],
  13. isLoading: false,
  14. }),
  15. }));
  16. function renderModal(balancer: SubBalancer | null, onConfirm = vi.fn()) {
  17. renderWithProviders(
  18. <SubBalancerFormModal open balancer={balancer} onClose={() => {}} onConfirm={onConfirm} />,
  19. );
  20. return { onConfirm };
  21. }
  22. function primaryButton(): HTMLElement {
  23. const btn = document.querySelector('.ant-modal-footer .ant-btn-primary');
  24. if (!btn) throw new Error('Primary button not found');
  25. return btn as HTMLElement;
  26. }
  27. function erroredItemCount(): number {
  28. return document.querySelectorAll('.ant-form-item-has-error').length;
  29. }
  30. function remarkInput(): HTMLInputElement {
  31. const el = Array.from(document.querySelectorAll('.ant-modal input')).find((i) =>
  32. (i as HTMLInputElement).placeholder.includes('Auto'),
  33. );
  34. if (!el) throw new Error('Remark input not found');
  35. return el as HTMLInputElement;
  36. }
  37. function inboundOptionTitles(): string[] {
  38. const multi = document.querySelector('.ant-select-multiple');
  39. if (!multi) throw new Error('Inbound multi-select not found');
  40. fireEvent.mouseDown(multi as HTMLElement);
  41. return Array.from(document.querySelectorAll('.ant-select-item-option')).map((o) =>
  42. (o.getAttribute('title') ?? o.textContent ?? '').trim(),
  43. );
  44. }
  45. function selectInbound(optionTitle: string) {
  46. const multi = document.querySelector('.ant-select-multiple');
  47. if (!multi) throw new Error('Inbound multi-select not found');
  48. // AntD 6 multiple selects have no .ant-select-selector; mousedown on the
  49. // root toggles the dropdown.
  50. fireEvent.mouseDown(multi as HTMLElement);
  51. const option = Array.from(document.querySelectorAll('.ant-select-item-option')).find(
  52. (o) => (o.getAttribute('title') ?? o.textContent ?? '').trim() === optionTitle,
  53. );
  54. if (!option) throw new Error(`Option '${optionTitle}' not found`);
  55. fireEvent.click(option);
  56. fireEvent.keyDown(multi, { key: 'Escape' });
  57. }
  58. function selectStrategy(label: string) {
  59. const single = Array.from(document.querySelectorAll('.ant-select')).find(
  60. (s) => !s.classList.contains('ant-select-multiple'),
  61. );
  62. if (!single) throw new Error('Strategy select not found');
  63. fireEvent.mouseDown(single as HTMLElement);
  64. const option = Array.from(document.querySelectorAll('.ant-select-item-option')).find(
  65. (o) => (o.getAttribute('title') ?? o.textContent ?? '').trim() === label,
  66. );
  67. if (!option) throw new Error(`Strategy option '${label}' not found`);
  68. fireEvent.click(option);
  69. fireEvent.keyDown(single, { key: 'Escape' });
  70. }
  71. function weightInputs(): HTMLInputElement[] {
  72. return Array.from(
  73. document.querySelectorAll<HTMLInputElement>('.sub-balancer-weights .ant-input-number-input'),
  74. );
  75. }
  76. describe('SubBalancerFormModal', () => {
  77. it('shows no validation errors when freshly opened in add mode', () => {
  78. renderModal(null);
  79. expect(document.querySelector('.ant-modal')).toBeTruthy();
  80. expect(erroredItemCount()).toBe(0);
  81. expect(primaryButton().hasAttribute('disabled')).toBe(false);
  82. });
  83. it('reveals required-field errors after a save attempt, without confirming', async () => {
  84. const { onConfirm } = renderModal(null);
  85. fireEvent.click(primaryButton());
  86. await waitFor(() => expect(erroredItemCount()).toBe(2));
  87. expect(onConfirm).not.toHaveBeenCalled();
  88. });
  89. it('confirms with parsed values once remark and an inbound are set', async () => {
  90. const { onConfirm } = renderModal(null);
  91. fireEvent.change(remarkInput(), { target: { value: ' auto ' } });
  92. selectInbound('First');
  93. fireEvent.click(primaryButton());
  94. await waitFor(() => expect(onConfirm).toHaveBeenCalledTimes(1));
  95. expect(onConfirm).toHaveBeenCalledWith({
  96. remark: 'auto',
  97. strategy: 'random',
  98. inboundIds: [1],
  99. sortOrder: 1,
  100. enabled: true,
  101. });
  102. });
  103. it('seeds the form from the edited balancer', async () => {
  104. const { onConfirm } = renderModal({
  105. id: 7,
  106. remark: 'existing',
  107. strategy: 'leastPing',
  108. inboundIds: [2],
  109. sortOrder: 3,
  110. enabled: false,
  111. });
  112. expect(remarkInput().value).toBe('existing');
  113. fireEvent.click(primaryButton());
  114. await waitFor(() => expect(onConfirm).toHaveBeenCalledTimes(1));
  115. expect(onConfirm).toHaveBeenCalledWith({
  116. remark: 'existing',
  117. strategy: 'leastPing',
  118. inboundIds: [2],
  119. sortOrder: 3,
  120. enabled: false,
  121. });
  122. });
  123. // A disabled member is dropped by the sub server, so offering it here would
  124. // silently stop the balancer document from being emitted (#5645).
  125. it('hides disabled inbounds from the member picker', () => {
  126. renderModal(null);
  127. expect(inboundOptionTitles()).toEqual(['First', 'Second']);
  128. });
  129. it('keeps an already-selected disabled inbound visible when editing', () => {
  130. renderModal({
  131. id: 8,
  132. remark: 'existing',
  133. strategy: 'random',
  134. inboundIds: [3],
  135. sortOrder: 1,
  136. enabled: true,
  137. });
  138. expect(inboundOptionTitles()).toContain('Disabled');
  139. });
  140. // Weights are a leastLoad-only xray knob; the inputs must not exist under
  141. // other strategies rather than merely being hidden.
  142. it('shows weight inputs for selected inbounds only under leastLoad', async () => {
  143. const { onConfirm } = renderModal(null);
  144. fireEvent.change(remarkInput(), { target: { value: 'weighted' } });
  145. selectInbound('First');
  146. selectInbound('Second');
  147. selectStrategy('Least load');
  148. await waitFor(() => expect(weightInputs()).toHaveLength(2));
  149. fireEvent.change(weightInputs()[0], { target: { value: '0.5' } });
  150. fireEvent.click(primaryButton());
  151. await waitFor(() => expect(onConfirm).toHaveBeenCalledTimes(1));
  152. expect(onConfirm).toHaveBeenCalledWith(
  153. expect.objectContaining({ strategy: 'leastLoad', memberWeights: { '1': 0.5 } }),
  154. );
  155. });
  156. it('omits memberWeights when a non-leastLoad strategy is saved', async () => {
  157. const { onConfirm } = renderModal(null);
  158. fireEvent.change(remarkInput(), { target: { value: 'plain' } });
  159. selectInbound('First');
  160. selectStrategy('Least load');
  161. await waitFor(() => expect(weightInputs()).toHaveLength(1));
  162. fireEvent.change(weightInputs()[0], { target: { value: '0.5' } });
  163. selectStrategy('Random');
  164. await waitFor(() => expect(document.querySelector('.sub-balancer-weights')).toBeNull());
  165. fireEvent.click(primaryButton());
  166. await waitFor(() => expect(onConfirm).toHaveBeenCalledTimes(1));
  167. expect(onConfirm).toHaveBeenCalledWith({
  168. remark: 'plain',
  169. strategy: 'random',
  170. inboundIds: [1],
  171. sortOrder: 1,
  172. enabled: true,
  173. });
  174. });
  175. it('seeds weight values from the edited balancer', async () => {
  176. renderModal({
  177. id: 9,
  178. remark: 'existing',
  179. strategy: 'leastLoad',
  180. inboundIds: [2],
  181. memberWeights: { '2': 1.5 },
  182. sortOrder: 1,
  183. enabled: true,
  184. });
  185. await waitFor(() => expect(weightInputs()).toHaveLength(1));
  186. expect(weightInputs()[0].value).toBe('1.5');
  187. });
  188. });