diff --git a/frontend/src/__tests__/riexchange-automation-settings.test.ts b/frontend/src/__tests__/riexchange-automation-settings.test.ts index 79b281510..27142b3f2 100644 --- a/frontend/src/__tests__/riexchange-automation-settings.test.ts +++ b/frontend/src/__tests__/riexchange-automation-settings.test.ts @@ -240,3 +240,101 @@ describe('loadAutomationSettings: number fields are escaped into value attribute expect(value('ri-exchange-lookback')).toBe('30'); }); }); + +// Issue #1689: the caps showed a bare 0 with no help text, which reads as +// "no limit" while 0 refuses approved exchanges and skips automated ones. +describe('loadAutomationSettings: every field explains itself (issue #1689)', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const fieldIDs = [ + 'ri-exchange-enabled', + 'ri-exchange-mode', + 'ri-exchange-threshold', + 'ri-exchange-max-per-exchange', + 'ri-exchange-max-daily', + 'ri-exchange-lookback', + ]; + + const hintFor = (id: string): string => { + const describedBy = document.getElementById(id)?.getAttribute('aria-describedby') ?? ''; + return describedBy ? document.getElementById(describedBy)?.textContent ?? '' : ''; + }; + + const loadWithZeroCaps = async (): Promise => { + setupContainer(); + (api.getRIExchangeConfig as jest.Mock).mockResolvedValueOnce({ + auto_exchange_enabled: false, + mode: 'manual', + utilization_threshold: 95, + max_payment_per_exchange_usd: 0, + max_payment_daily_usd: 0, + lookback_days: 30, + }); + await loadAutomationSettings(); + }; + + test.each(fieldIDs)('%s has a visible hint linked by aria-describedby', async (id) => { + await loadWithZeroCaps(); + expect(hintFor(id).trim().length).toBeGreaterThan(20); + }); + + // The hints are built by concatenation; a dropped space ran two sentences + // together ("exchange.A recommendation"). + test.each(fieldIDs)('%s hint separates its sentences with a space', async (id) => { + await loadWithZeroCaps(); + expect(hintFor(id)).not.toMatch(/[.!?][A-Z]/); + expect(hintFor(id)).not.toMatch(/\s{2,}/); + }); + + test('the Fully Automated warning names the 4-eyes override and keeps the cap guidance', async () => { + setupContainer(); + (api.getRIExchangeConfig as jest.Mock).mockResolvedValueOnce({ + auto_exchange_enabled: true, mode: 'auto', utilization_threshold: 95, + max_payment_per_exchange_usd: 100, max_payment_daily_usd: 500, lookback_days: 30, + }); + await loadAutomationSettings(); + const banner = document.querySelector('#ri-exchange-warning-slot .warning-message')?.textContent ?? ''; + expect(banner).toContain('without manual approval, unless Require different approver (4-eyes mode) is on.'); + expect(banner).toContain('Ensure spending caps are configured properly.'); + }); + + test('the Enable hint says the mode decides whether exchanges are proposed or run', async () => { + await loadWithZeroCaps(); + expect(hintFor('ri-exchange-enabled')).toMatch(/propose or run exchanges/); + }); + + test.each(['ri-exchange-max-per-exchange', 'ri-exchange-max-daily'])( + '%s says what 0 means and that dialog exchanges use their own limit', + async (id) => { + await loadWithZeroCaps(); + const hint = hintFor(id); + expect(hint).toMatch(/\b0\b/); + expect(hint).toMatch(/refused/); + expect(hint).toMatch(/Exchange dialog/); + }, + ); + + test('the daily cap names its window', async () => { + await loadWithZeroCaps(); + expect(hintFor('ri-exchange-max-daily')).toContain('UTC day'); + }); + + // fourEyesExchangeMode runs every mode as manual while 4-eyes is on. + test('the Mode hint says 4-eyes mode overrides Fully Automated', async () => { + await loadWithZeroCaps(); + const hint = hintFor('ri-exchange-mode'); + expect(hint).toContain('4-eyes'); + expect(hint).toMatch(/every exchange waits for approval in either mode/); + }); + + // The per-exchange cap is checked before the mode branch (pkg/exchange + // auto.go), so it limits proposals in Manual Approval mode too. + test('the per-exchange hint says a higher payment is not proposed in either mode', async () => { + await loadWithZeroCaps(); + const hint = hintFor('ri-exchange-max-per-exchange'); + expect(hint).toMatch(/not proposed in either mode/); + expect(hint).toMatch(/At 0, no exchange with a payment due is proposed/); + }); +}); diff --git a/frontend/src/riexchange.ts b/frontend/src/riexchange.ts index a2395ea44..07f340180 100644 --- a/frontend/src/riexchange.ts +++ b/frontend/src/riexchange.ts @@ -1834,7 +1834,9 @@ function buildAutoWarningBanner(): HTMLDivElement { const strong = document.createElement('strong'); strong.textContent = 'Warning:'; banner.appendChild(strong); - banner.appendChild(document.createTextNode(' Fully Automated mode will execute RI exchanges without manual approval. Ensure spending caps are configured properly.')); + // fourEyesExchangeMode runs every mode as manual while 4-eyes is on. + banner.appendChild(document.createTextNode(' Fully Automated mode will execute RI exchanges without manual approval, ' + + 'unless Require different approver (4-eyes mode) is on. Ensure spending caps are configured properly.')); return banner; } @@ -1847,6 +1849,34 @@ function numberAttr(value: number | null | undefined): string { return escapeHtmlAttr(String(value ?? '')); } +// Help text for each Exchange Automation field (issue #1689). The caps only +// gate scheduled and approved exchanges; executeExchange (the Exchange +// dialog) uses the limit entered in the dialog. The per-exchange cap is +// checked before the mode branch (pkg/exchange auto.go), so it limits what +// is proposed in both modes; approving refuses while either cap is 0 +// (exchangeCapStrings). 4-eyes runs every mode as manual (fourEyesExchangeMode). +const DIALOG_NOTE = 'Exchanges started from the Exchange dialog use the limit entered there instead.'; +const AUTOMATION_HINTS: Record = { + 'ri-exchange-enabled': 'Lets CUDly check convertible RI utilization on its schedule and propose or run exchanges for underutilized RIs, depending on the mode below.', + 'ri-exchange-mode': 'Manual Approval queues each proposed exchange for someone to approve. Fully Automated runs it without approval, within the payment limits below. ' + + 'While Require different approver (4-eyes mode) is on, every exchange waits for approval in either mode.', + 'ri-exchange-threshold': 'Convertible RIs whose utilization over the lookback window is below this percentage are candidates for an exchange.', + 'ri-exchange-max-per-exchange': 'Largest payment due allowed for one scheduled or approved exchange. A recommendation with a higher payment due is not proposed in either mode. ' + + 'At 0, no exchange with a payment due is proposed, and approving any exchange is refused. ' + DIALOG_NOTE, + 'ri-exchange-max-daily': 'Total payment due allowed per UTC day across scheduled and approved exchanges. ' + + 'At 0, Fully Automated exchanges with a payment due do not run, and approving any exchange is refused. ' + DIALOG_NOTE, + 'ri-exchange-lookback': 'Days of utilization history used to judge whether a convertible RI is underutilized.', +}; + +// One settings row; the control must carry aria-describedby="-hint". +function automationRow(id: string, label: string, controlHTML: string): string { + return '
' + + '
' + + '

' + escapeHtml(AUTOMATION_HINTS[id]) + '

' + + '
' + controlHTML + '
' + + '
'; +} + function renderAutomationSettings(container: HTMLElement, config: RIExchangeConfig): void { const modeOptions = Object.entries(MODE_LABELS) .map(([value, label]) => { @@ -1861,33 +1891,21 @@ function renderAutomationSettings(container: HTMLElement, config: RIExchangeConf + '
' + 'Exchange Automation' + '
' - + '
' - + '
' - + '
' - + '
' - + '
' - + '
' - + '
' - + '
' - + '
' - + '
' - + '
' - + '
' - + '
' - + '
' - + '
' - + '
' - + '
' - + '
' - + '
' - + '
' - + '
' - + '
' - + '
' - + '
' + + automationRow('ri-exchange-enabled', 'Enable Automated Exchange', + '') + + automationRow('ri-exchange-mode', 'Mode', + '') + + automationRow('ri-exchange-threshold', 'Utilization Threshold (%)', + '') + + automationRow('ri-exchange-max-per-exchange', 'Max Payment Per Exchange (USD)', + '') + + automationRow('ri-exchange-max-daily', 'Max Payment Daily (USD)', + '') + + automationRow('ri-exchange-lookback', 'Lookback Days', + '') + '
' + '
' + '';