Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/support/grant-suggestions-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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);
},
},
};

/**
Expand Down
72 changes: 72 additions & 0 deletions test/support/grant-suggestions-handler.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading