diff --git a/frontend/src/__tests__/riexchange.test.ts b/frontend/src/__tests__/riexchange.test.ts index 671fe1afd..bdf128775 100644 --- a/frontend/src/__tests__/riexchange.test.ts +++ b/frontend/src/__tests__/riexchange.test.ts @@ -1594,12 +1594,40 @@ describe('excluded RIs notice', () => { await loadReshapeRecommendations(); const notice = document.getElementById('ri-exchange-recommendations-excluded'); - expect(notice?.textContent).toContain('2 convertible RIs were left out because AWS returned no term length'); + expect(notice?.textContent).toContain('2 convertible RIs were left out because no term'); expect(notice?.textContent).toContain('ri-0'); expect(notice?.textContent).toContain(''); expect(notice?.querySelector('img')).toBeNull(); }); + it('shows each excluded RI\'s server reason, grouped by reason', () => { + renderExcludedRIsNotice([ + { ri_id: 'ri-dur', reason: 'non-positive duration' }, + { ri_id: 'ri-price', reason: 'no pricing available' }, + ]); + const text = document.getElementById('ri-exchange-recommendations-excluded')?.textContent ?? ''; + expect(text).toContain('1 convertible RI was left out because non-positive duration: ri-dur'); + expect(text).toContain('1 convertible RI was left out because no pricing available: ri-price'); + expect(text).not.toContain('no term length'); + }); + + it('falls back to the term-length text when reason is empty or missing', () => { + renderExcludedRIsNotice([ + { ri_id: 'ri-a', reason: '' }, + { ri_id: 'ri-b' } as unknown as { ri_id: string; reason: string }, + ]); + expect(document.getElementById('ri-exchange-recommendations-excluded')?.textContent).toContain( + '2 convertible RIs were left out because AWS returned no term length: ri-a, ri-b', + ); + }); + + it('renders a reason containing HTML special characters as text', () => { + renderExcludedRIsNotice([{ ri_id: 'ri-x', reason: 'bad & "quoted"' }]); + const notice = document.getElementById('ri-exchange-recommendations-excluded'); + expect(notice?.textContent).toContain('bad & "quoted"'); + expect(notice?.querySelector('b')).toBeNull(); + }); + it('shows no notice when excluded_ris is empty or absent, and clears a stale one', () => { renderExcludedRIsNotice([{ ri_id: 'ri-0', reason: 'x' }]); expect(document.getElementById('ri-exchange-recommendations-excluded')?.textContent).toContain('1 convertible RI was left out'); diff --git a/frontend/src/riexchange.ts b/frontend/src/riexchange.ts index 2d973389d..d9340edb4 100644 --- a/frontend/src/riexchange.ts +++ b/frontend/src/riexchange.ts @@ -588,9 +588,18 @@ export function renderExcludedRIsNotice(excluded: ExcludedRI[] | undefined): voi notice.className = ''; return; } - const n = excluded.length; + const byReason = new Map(); + for (const e of excluded) { + const reason = e.reason || 'AWS returned no term length'; + const ids = byReason.get(reason); + if (ids) ids.push(e.ri_id); + else byReason.set(reason, [e.ri_id]); + } notice.className = 'freshness-banner warning'; - notice.textContent = `${n} convertible RI${n === 1 ? ' was' : 's were'} left out because AWS returned no term length: ${excluded.map((e) => e.ri_id).join(', ')}`; + // textContent keeps server-provided reasons and ids inert. + notice.textContent = [...byReason] + .map(([reason, ids]) => `${ids.length} convertible RI${ids.length === 1 ? ' was' : 's were'} left out because ${reason}: ${ids.join(', ')}`) + .join('; '); } /**