diff --git a/src/support/grant-suggestions-handler.js b/src/support/grant-suggestions-handler.js index 7d19064a5..72c10d64f 100644 --- a/src/support/grant-suggestions-handler.js +++ b/src/support/grant-suggestions-handler.js @@ -50,6 +50,30 @@ function defaultSortFn(groupA, groupB) { return idA.localeCompare(idB); } +/** + * Determines the display/grant priority tier for an alt-text suggestion: + * 0 - non-decorative image with a generated fix (altText suggested) + * 1 - non-decorative image without a fix + * 2 - decorative image + * Lower tiers are granted/displayed first, implementing the fallback + * funnel: non-decorative-with-fix -> non-decorative -> any image + * (including decorative). A tier is only reached once higher tiers run + * out of suggestions to fill the bucket. + */ +function getAltTextTier(suggestion) { + const data = typeof suggestion?.getData === 'function' + ? suggestion.getData() + : suggestion?.data; + const recommendations = data?.recommendations ?? []; + const isDecorative = recommendations.some((r) => r?.isDecorative === true); + if (isDecorative) { + return 2; + } + const hasFix = recommendations.length > 0 + && recommendations.every((r) => typeof r?.altText === 'string' && r.altText.trim() !== ''); + return hasFix ? 0 : 1; +} + /** * Per-opportunity grouping and sorting strategies. * @@ -143,6 +167,20 @@ const OPPORTUNITY_STRATEGIES = { return idA.localeCompare(idB); }, }, + // Alt-text suggestions are displayed/granted via a fallback funnel: + // non-decorative images with a generated fix first, then non-decorative + // images without a fix, and finally decorative images - only once the + // higher tiers don't have enough images left to fill the bucket. Within + // a tier, falls back to the default sort (rank ascending, then id). + 'alt-text': { + sortFn: (groupA, groupB) => { + const tierDiff = getAltTextTier(groupA.items[0]) - getAltTextTier(groupB.items[0]); + if (tierDiff !== 0) { + return tierDiff; + } + return defaultSortFn(groupA, groupB); + }, + }, }; /** diff --git a/test/support/grant-suggestions-handler.test.js b/test/support/grant-suggestions-handler.test.js index 30e715057..1468bcc00 100644 --- a/test/support/grant-suggestions-handler.test.js +++ b/test/support/grant-suggestions-handler.test.js @@ -322,6 +322,78 @@ describe('grant-suggestions-handler', () => { const ids = groups.flatMap((g) => g.items.map((s) => s.getId())); expect(ids).to.include.members(['id-1', 'id-2']); }); + + describe('alt-text funnel (non-decorative+fix -> non-decorative -> any image)', () => { + const mk = (id, recommendations) => ({ + getId: () => id, + getRank: () => 1, + getData: () => ({ recommendations }), + }); + + it('ranks non-decorative-with-fix above non-decorative-without-fix and decorative', () => { + const withFix = mk('with-fix', [{ isDecorative: false, altText: 'a dog' }]); + const noFix = mk('no-fix', [{ isDecorative: false, altText: '' }]); + const decorative = mk('decorative', [{ isDecorative: true }]); + const groups = getTopSuggestions([decorative, noFix, withFix], 'alt-text'); + expect(groups.map((g) => g.items[0].getId())).to.deep.equal(['with-fix', 'no-fix', 'decorative']); + }); + + it('fills the bucket from lower tiers only once higher tiers run out', () => { + const withFix = mk('with-fix', [{ isDecorative: false, altText: 'a dog' }]); + const noFix1 = mk('no-fix-1', [{ isDecorative: false }]); + const noFix2 = mk('no-fix-2', [{ isDecorative: false }]); + const decorative = mk('decorative', [{ isDecorative: true }]); + const groups = getTopSuggestions( + [decorative, noFix2, noFix1, withFix], + 'alt-text', + ).slice(0, 3); + expect(groups.map((g) => g.items[0].getId())).to.deep.equal(['with-fix', 'no-fix-1', 'no-fix-2']); + }); + + it('falls back to decorative images when no non-decorative images exist', () => { + const decorative1 = mk('decorative-1', [{ isDecorative: true }]); + const decorative2 = mk('decorative-2', [{ isDecorative: true }]); + const groups = getTopSuggestions([decorative1, decorative2], 'alt-text'); + expect(groups.map((g) => g.items[0].getId())).to.deep.equal(['decorative-1', 'decorative-2']); + }); + + it('treats a suggestion as decorative if any of its recommendations is decorative', () => { + const mixed = mk('mixed', [{ isDecorative: false, altText: 'x' }, { isDecorative: true }]); + const withFix = mk('with-fix', [{ isDecorative: false, altText: 'a dog' }]); + const groups = getTopSuggestions([mixed, withFix], 'alt-text'); + expect(groups.map((g) => g.items[0].getId())).to.deep.equal(['with-fix', 'mixed']); + }); + + it('requires every recommendation to carry a fix to count as non-decorative-with-fix', () => { + const partialFix = mk('partial-fix', [{ isDecorative: false, altText: 'a dog' }, { isDecorative: false, altText: '' }]); + const fullFix = mk('full-fix', [{ isDecorative: false, altText: 'a dog' }]); + const groups = getTopSuggestions([partialFix, fullFix], 'alt-text'); + expect(groups.map((g) => g.items[0].getId())).to.deep.equal(['full-fix', 'partial-fix']); + }); + + it('treats missing or empty recommendations as non-decorative without a fix', () => { + const s1 = mk('id-1', []); + const s2 = { getId: () => 'id-2', getRank: () => 1, getData: () => ({}) }; + const groups = getTopSuggestions([s1, s2], 'alt-text'); + expect(groups).to.have.lengthOf(2); + }); + + it('works with plain object data (no getData())', () => { + const s1 = { id: 'id-1', rank: 1, data: { recommendations: [{ isDecorative: true }] } }; + const s2 = { id: 'id-2', rank: 1, data: { recommendations: [{ isDecorative: false, altText: 'x' }] } }; + const groups = getTopSuggestions([s1, s2], 'alt-text'); + expect(groups.map((g) => g.items[0])).to.deep.equal([s2, s1]); + }); + + it('breaks ties within a tier by rank ascending then id (default sort)', () => { + const s1 = mk('id-b', [{ isDecorative: false }]); + const s2 = mk('id-a', [{ isDecorative: false }]); + s1.getRank = () => 10; + s2.getRank = () => 5; + const groups = getTopSuggestions([s1, s2], 'alt-text'); + expect(groups.map((g) => g.items[0].getId())).to.deep.equal(['id-a', 'id-b']); + }); + }); }); describe('grantSuggestionsForOpportunity', () => {