diff --git a/packages/spacecat-shared-utils/src/aggregation/aggregation-strategies.js b/packages/spacecat-shared-utils/src/aggregation/aggregation-strategies.js new file mode 100644 index 000000000..23933565d --- /dev/null +++ b/packages/spacecat-shared-utils/src/aggregation/aggregation-strategies.js @@ -0,0 +1,245 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/** + * Accessibility suggestion aggregation strategies + * + * Defines how HTML elements with accessibility issues are grouped into database suggestions. + * Each granularity level has a function that builds an aggregation key from suggestion data. + */ + +/** + * Granularity levels for suggestion aggregation + * @enum {string} + */ +export const Granularity = { + /** One suggestion per HTML element - url|type|selector (e.g., page1|color-contrast|div.header) */ + INDIVIDUAL: 'INDIVIDUAL', + + /** + * One suggestion per issue type per page - url|type (e.g., page1|color-contrast) + */ + PER_PAGE_PER_COMPONENT: 'PER_PAGE_PER_COMPONENT', + + /** One suggestion per page - url (e.g., page1) */ + PER_PAGE: 'PER_PAGE', + + /** + * One suggestion per component type across all pages - type|selector + */ + PER_COMPONENT: 'PER_COMPONENT', + + /** One suggestion per issue type globally - type (e.g., color-contrast) */ + PER_TYPE: 'PER_TYPE', +}; + +/** + * Generic key builder that concatenates non-empty values with pipe separator + * @param {...string} parts - Variable number of key parts to concatenate + * @returns {string} Concatenated key + */ +function buildKey(...parts) { + return parts.filter((part) => part != null && part !== '').join('|'); +} + +/** + * Builds aggregation key for INDIVIDUAL granularity + * Key format: url|type|selector|source + */ +function buildIndividualKey({ + url, issueType, targetSelector, source, +}) { + return buildKey(url, issueType, targetSelector, source); +} + +/** + * Builds aggregation key for PER_PAGE_PER_COMPONENT granularity + * Key format: url|type|source + */ +function buildPerPagePerComponentKey({ url, issueType, source }) { + return buildKey(url, issueType, source); +} + +/** + * Builds aggregation key for PER_PAGE granularity + * Key format: url|source + */ +function buildPerPageKey({ url, source }) { + return buildKey(url, source); +} + +/** + * Builds aggregation key for COMPONENT granularity + * Key format: type|selector + */ +function buildComponentKey({ issueType, targetSelector }) { + return buildKey(issueType, targetSelector); +} + +/** + * Builds aggregation key for GLOBAL granularity + * Key format: type + */ +function buildGlobalKey({ issueType }) { + return buildKey(issueType); +} + +/** + * Registry of key-building functions by granularity level + */ +export const GRANULARITY_KEY_BUILDERS = { + [Granularity.INDIVIDUAL]: buildIndividualKey, + [Granularity.PER_PAGE_PER_COMPONENT]: buildPerPagePerComponentKey, + [Granularity.PER_PAGE]: buildPerPageKey, + [Granularity.PER_COMPONENT]: buildComponentKey, + [Granularity.PER_TYPE]: buildGlobalKey, +}; + +/** + * Maps issue types to their aggregation granularity + * Based on the nature of each issue and how they should be grouped + */ +export const ISSUE_GRANULARITY_MAP = { + 'color-contrast': Granularity.INDIVIDUAL, + list: Granularity.PER_COMPONENT, + 'aria-roles': Granularity.PER_PAGE_PER_COMPONENT, + 'image-alt': Granularity.PER_PAGE_PER_COMPONENT, + 'link-in-text-block': Granularity.PER_PAGE_PER_COMPONENT, + 'link-name': Granularity.PER_PAGE_PER_COMPONENT, + 'target-size': Granularity.PER_PAGE_PER_COMPONENT, + listitem: Granularity.PER_COMPONENT, + label: Granularity.PER_PAGE_PER_COMPONENT, + 'aria-prohibited-attr': Granularity.PER_TYPE, + 'button-name': Granularity.PER_PAGE_PER_COMPONENT, + 'frame-title': Granularity.PER_PAGE_PER_COMPONENT, + 'aria-valid-attr-value': Granularity.PER_PAGE_PER_COMPONENT, + 'aria-allowed-attr': Granularity.PER_TYPE, + 'aria-hidden-focus': Granularity.PER_PAGE_PER_COMPONENT, + 'nested-interactive': Granularity.PER_PAGE_PER_COMPONENT, + 'html-has-lang': Granularity.PER_PAGE, + 'meta-viewport': Granularity.PER_PAGE, + 'aria-required-children': Granularity.PER_PAGE_PER_COMPONENT, + 'aria-required-parent': Granularity.PER_PAGE_PER_COMPONENT, + 'meta-refresh': Granularity.PER_PAGE, + 'role-img-alt': Granularity.PER_PAGE_PER_COMPONENT, + 'aria-input-field-name': Granularity.PER_PAGE_PER_COMPONENT, + 'scrollable-region-focusable': Granularity.PER_PAGE_PER_COMPONENT, + 'select-name': Granularity.PER_PAGE_PER_COMPONENT, +}; + +/** + * Gets the granularity level for a specific issue type + * + * @param {string} issueType - The issue type (e.g., "color-contrast") + * @returns {string} The granularity level (defaults to PER_PAGE_PER_COMPONENT) + */ +export function getGranularityForIssueType(issueType) { + return ISSUE_GRANULARITY_MAP[issueType] || Granularity.PER_PAGE_PER_COMPONENT; +} + +/** + * Builds an aggregation key for grouping HTML elements during processing + * + * @param {string} issueType - The issue type + * @param {string} url - Page URL + * @param {string} targetSelector - CSS selector for the element + * @param {string} source - Optional source identifier + * @returns {string} The aggregation key based on the issue type's granularity + */ +export function buildAggregationKey(issueType, url, targetSelector, source) { + const granularity = getGranularityForIssueType(issueType); + const keyBuilder = GRANULARITY_KEY_BUILDERS[granularity]; + + if (!keyBuilder) { + // Fallback to INDIVIDUAL if builder not found + return buildIndividualKey({ + url, issueType, targetSelector, source, + }); + } + + return keyBuilder({ + url, issueType, targetSelector, source, + }); +} + +/** + * Builds an aggregation key from suggestion data. + * Extracts the necessary fields from a suggestion object and calls buildAggregationKey. + * + * @param {Object} suggestionData - The suggestion data object + * @param {string} suggestionData.url - Page URL + * @param {Array} suggestionData.issues - Array of issues + * @param {string} suggestionData.source - Optional source + * @returns {string|null} The aggregation key based on the issue type's granularity, or null if no issues + */ +export function buildAggregationKeyFromSuggestion(suggestionData) { + // Handle null, undefined, or non-object inputs + if (!suggestionData || typeof suggestionData !== 'object') { + return null; + } + + const { url, issues, source } = suggestionData; + + if (!issues || issues.length === 0) { + return null; + } + + const firstIssue = issues[0]; + if (!firstIssue || !firstIssue.type) { + return null; + } + + const issueType = firstIssue.type; + const htmlWithIssue = firstIssue.htmlWithIssues?.[0]; + + // Support both snake_case and camelCase for backwards compatibility + const targetSelector = htmlWithIssue?.target_selector || htmlWithIssue?.targetSelector || ''; + + return buildAggregationKey(issueType, url, targetSelector, source); +} + +/** + * Builds a database-level key for matching suggestions across audit runs. + * Used by syncSuggestions to identify existing suggestions. + * + * This ALWAYS uses INDIVIDUAL granularity (url|type|selector|source) to ensure + * each HTML element gets its own suggestion in the database. This prevents + * incorrect merging of different HTML elements. + * + * IMPORTANT: This maintains backwards compatibility with the original buildKey logic + * by including a trailing pipe when selector is empty (url|type|). + * + * @param {Object} suggestionData - The suggestion data object + * @param {string} suggestionData.url - Page URL + * @param {Array} suggestionData.issues - Array of issues + * @param {string} suggestionData.source - Optional source + * @returns {string} The key for suggestion matching + */ +export function buildSuggestionKey(suggestionData) { + const { url, issues, source } = suggestionData; + + if (!issues || issues.length === 0) { + return url; + } + + const firstIssue = issues[0]; + const issueType = firstIssue.type; + const targetSelector = firstIssue.htmlWithIssues?.[0]?.target_selector || ''; + + // Always build INDIVIDUAL-level key for database uniqueness + // Backwards compatible: url|type|selector|source or url|type| when selector is empty + let key = `${url}|${issueType}|${targetSelector}`; + if (source) { + key += `|${source}`; + } + return key; +} diff --git a/packages/spacecat-shared-utils/src/index.js b/packages/spacecat-shared-utils/src/index.js index 67f89f685..e7a4ab642 100644 --- a/packages/spacecat-shared-utils/src/index.js +++ b/packages/spacecat-shared-utils/src/index.js @@ -108,3 +108,13 @@ export * as schemas from './schemas.js'; export { detectLocale } from './locale-detect/locale-detect.js'; export { prettifyLogForwardingConfig } from './cdn-helpers.js'; + +export { + buildAggregationKey, + buildAggregationKeyFromSuggestion, + buildSuggestionKey, + getGranularityForIssueType, + Granularity, + GRANULARITY_KEY_BUILDERS, + ISSUE_GRANULARITY_MAP, +} from './aggregation/aggregation-strategies.js'; diff --git a/packages/spacecat-shared-utils/test/aggregation/aggregation-strategies.test.js b/packages/spacecat-shared-utils/test/aggregation/aggregation-strategies.test.js new file mode 100644 index 000000000..61494ffb3 --- /dev/null +++ b/packages/spacecat-shared-utils/test/aggregation/aggregation-strategies.test.js @@ -0,0 +1,476 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import * as chai from 'chai'; +import { + Granularity, + GRANULARITY_KEY_BUILDERS, + ISSUE_GRANULARITY_MAP, + getGranularityForIssueType, + buildAggregationKey, + buildAggregationKeyFromSuggestion, + buildSuggestionKey, +} from '../../src/aggregation/aggregation-strategies.js'; + +const { expect } = chai; + +describe('Aggregation Strategies', () => { + describe('Granularity Enum', () => { + it('should have all expected granularity levels', () => { + expect(Granularity.INDIVIDUAL).to.equal('INDIVIDUAL'); + expect(Granularity.PER_PAGE_PER_COMPONENT).to.equal('PER_PAGE_PER_COMPONENT'); + expect(Granularity.PER_PAGE).to.equal('PER_PAGE'); + expect(Granularity.PER_COMPONENT).to.equal('PER_COMPONENT'); + expect(Granularity.PER_TYPE).to.equal('PER_TYPE'); + }); + }); + + describe('GRANULARITY_KEY_BUILDERS', () => { + it('should have a builder for each granularity level', () => { + expect(GRANULARITY_KEY_BUILDERS[Granularity.INDIVIDUAL]).to.be.a('function'); + expect(GRANULARITY_KEY_BUILDERS[Granularity.PER_PAGE_PER_COMPONENT]).to.be.a('function'); + expect(GRANULARITY_KEY_BUILDERS[Granularity.PER_PAGE]).to.be.a('function'); + expect(GRANULARITY_KEY_BUILDERS[Granularity.PER_COMPONENT]).to.be.a('function'); + expect(GRANULARITY_KEY_BUILDERS[Granularity.PER_TYPE]).to.be.a('function'); + }); + }); + + describe('buildAggregationKey', () => { + describe('INDIVIDUAL granularity (color-contrast)', () => { + it('should build key with url|type|selector', () => { + const key = buildAggregationKey('color-contrast', 'https://example.com/page1', 'div.header', null); + expect(key).to.equal('https://example.com/page1|color-contrast|div.header'); + }); + + it('should include source when provided', () => { + const key = buildAggregationKey('color-contrast', 'https://example.com/page1', 'div.header', 'ahrefs'); + expect(key).to.equal('https://example.com/page1|color-contrast|div.header|ahrefs'); + }); + + it('should handle missing selector', () => { + const key = buildAggregationKey('color-contrast', 'https://example.com/page1', '', null); + // buildKey helper filters out empty values, so no trailing pipe + expect(key).to.equal('https://example.com/page1|color-contrast'); + }); + }); + + describe('PER_PAGE_PER_COMPONENT granularity (button-name)', () => { + it('should build key with url|type (no selector)', () => { + const key = buildAggregationKey('button-name', 'https://example.com/page1', 'button.submit', null); + expect(key).to.equal('https://example.com/page1|button-name'); + }); + + it('should group different selectors together', () => { + const key1 = buildAggregationKey('button-name', 'https://example.com/page1', 'button.submit', null); + const key2 = buildAggregationKey('button-name', 'https://example.com/page1', 'button.cancel', null); + expect(key1).to.equal(key2); // Should be same key! + }); + + it('should include source when provided', () => { + const key = buildAggregationKey('button-name', 'https://example.com/page1', 'button.submit', 'ahrefs'); + expect(key).to.equal('https://example.com/page1|button-name|ahrefs'); + }); + }); + + describe('PER_PAGE granularity (html-has-lang)', () => { + it('should build key with url only', () => { + const key = buildAggregationKey('html-has-lang', 'https://example.com/page1', 'html', null); + expect(key).to.equal('https://example.com/page1'); + }); + + it('should group all issue types on same page together', () => { + const key1 = buildAggregationKey('html-has-lang', 'https://example.com/page1', 'html', null); + const key2 = buildAggregationKey('meta-viewport', 'https://example.com/page1', 'meta', null); + expect(key1).to.equal(key2); // Should be same key! + }); + + it('should include source when provided', () => { + const key = buildAggregationKey('html-has-lang', 'https://example.com/page1', 'html', 'ahrefs'); + expect(key).to.equal('https://example.com/page1|ahrefs'); + }); + }); + + describe('PER_COMPONENT granularity (list)', () => { + it('should build key with type|selector', () => { + const key = buildAggregationKey('list', 'https://example.com/page1', 'ul.nav-menu', null); + expect(key).to.equal('list|ul.nav-menu'); + }); + + it('should group same component across different pages', () => { + const key1 = buildAggregationKey('list', 'https://example.com/page1', 'ul.nav-menu', null); + const key2 = buildAggregationKey('list', 'https://example.com/page2', 'ul.nav-menu', null); + expect(key1).to.equal(key2); // Should be same key! + }); + + it('should differentiate different component types', () => { + const key1 = buildAggregationKey('list', 'https://example.com/page1', 'ul.nav-menu', null); + const key2 = buildAggregationKey('list', 'https://example.com/page1', 'ol.steps', null); + expect(key1).to.not.equal(key2); // Different selectors = different keys + }); + }); + + describe('PER_TYPE granularity (aria-prohibited-attr)', () => { + it('should build key with type only', () => { + const key = buildAggregationKey('aria-prohibited-attr', 'https://example.com/page1', 'div[aria-hidden]', null); + expect(key).to.equal('aria-prohibited-attr'); + }); + + it('should group all instances globally', () => { + const key1 = buildAggregationKey('aria-prohibited-attr', 'https://example.com/page1', 'div.header', null); + const key2 = buildAggregationKey('aria-prohibited-attr', 'https://example.com/page2', 'span.label', null); + const key3 = buildAggregationKey('aria-prohibited-attr', 'https://example.com/page3', 'section.main', null); + expect(key1).to.equal(key2); + expect(key2).to.equal(key3); // All should be same key! + }); + }); + + describe('Unmapped issue types', () => { + it('should default to PER_PAGE_PER_COMPONENT', () => { + const key = buildAggregationKey('unknown-issue-type', 'https://example.com/page1', 'div.test', null); + // PER_PAGE_PER_COMPONENT format: url|type + expect(key).to.equal('https://example.com/page1|unknown-issue-type'); + }); + }); + }); + + describe('getGranularityForIssueType', () => { + it('should return correct granularity for mapped issues', () => { + expect(getGranularityForIssueType('color-contrast')).to.equal(Granularity.INDIVIDUAL); + expect(getGranularityForIssueType('button-name')).to.equal(Granularity.PER_PAGE_PER_COMPONENT); + expect(getGranularityForIssueType('html-has-lang')).to.equal(Granularity.PER_PAGE); + expect(getGranularityForIssueType('list')).to.equal(Granularity.PER_COMPONENT); + expect(getGranularityForIssueType('aria-prohibited-attr')).to.equal(Granularity.PER_TYPE); + }); + + it('should default to PER_PAGE_PER_COMPONENT for unmapped issues', () => { + expect(getGranularityForIssueType('unknown-issue')).to.equal(Granularity.PER_PAGE_PER_COMPONENT); + }); + }); + + describe('ISSUE_GRANULARITY_MAP', () => { + it('should only use valid granularity values', () => { + const validGranularities = Object.values(Granularity); + + Object.values(ISSUE_GRANULARITY_MAP).forEach((granularity) => { + expect(validGranularities).to.include(granularity); + }); + }); + }); + + describe('Key Consistency', () => { + it('should produce consistent keys for same data', () => { + const key1 = buildAggregationKey('button-name', 'https://example.com/page1', 'button.submit', 'ahrefs'); + const key2 = buildAggregationKey('button-name', 'https://example.com/page1', 'button.submit', 'ahrefs'); + expect(key1).to.equal(key2); + }); + + it('should produce different keys for different pages', () => { + const key1 = buildAggregationKey('button-name', 'https://example.com/page1', 'button.submit', 'ahrefs'); + const key2 = buildAggregationKey('button-name', 'https://example.com/page2', 'button.submit', 'ahrefs'); + expect(key1).to.not.equal(key2); + }); + + it('should produce different keys for different sources', () => { + const key1 = buildAggregationKey('button-name', 'https://example.com/page1', 'button.submit', 'ahrefs'); + const key2 = buildAggregationKey('button-name', 'https://example.com/page1', 'button.submit', 'semrush'); + expect(key1).to.not.equal(key2); + }); + }); + + describe('Grouping Behavior', () => { + describe('PER_PAGE_PER_COMPONENT should group', () => { + it('all elements with same issue type on same page', () => { + const keys = [ + buildAggregationKey('button-name', 'https://example.com/page1', 'button.submit', null), + buildAggregationKey('button-name', 'https://example.com/page1', 'button.cancel', null), + buildAggregationKey('button-name', 'https://example.com/page1', 'button.apply', null), + ]; + + // All should be same + expect(keys[0]).to.equal(keys[1]); + expect(keys[1]).to.equal(keys[2]); + }); + + it('but not elements on different pages', () => { + const key1 = buildAggregationKey('button-name', 'https://example.com/page1', 'button.submit', null); + const key2 = buildAggregationKey('button-name', 'https://example.com/page2', 'button.submit', null); + + expect(key1).to.not.equal(key2); + }); + + it('but not different issue types', () => { + const key1 = buildAggregationKey('button-name', 'https://example.com/page1', 'button.submit', null); + const key2 = buildAggregationKey('image-alt', 'https://example.com/page1', 'img.logo', null); + + expect(key1).to.not.equal(key2); + }); + }); + + describe('PER_COMPONENT should group', () => { + it('same component across all pages', () => { + const keys = [ + buildAggregationKey('list', 'https://example.com/page1', 'ul.nav-menu', null), + buildAggregationKey('list', 'https://example.com/page2', 'ul.nav-menu', null), + buildAggregationKey('list', 'https://example.com/page3', 'ul.nav-menu', null), + ]; + + expect(keys[0]).to.equal(keys[1]); + expect(keys[1]).to.equal(keys[2]); + }); + }); + + describe('PER_TYPE should group', () => { + it('all instances globally', () => { + const keys = [ + buildAggregationKey('aria-prohibited-attr', 'https://example.com/page1', 'div.header', null), + buildAggregationKey('aria-prohibited-attr', 'https://example.com/page2', 'span.label', null), + buildAggregationKey('aria-prohibited-attr', 'https://example.com/page3', 'section.main', null), + ]; + + expect(keys[0]).to.equal(keys[1]); + expect(keys[1]).to.equal(keys[2]); + expect(keys[0]).to.equal('aria-prohibited-attr'); + }); + }); + }); + + describe('buildAggregationKeyFromSuggestion', () => { + it('should build aggregation key from suggestion data with INDIVIDUAL granularity', () => { + const suggestionData = { + url: 'https://example.com/page1', + issues: [{ + type: 'color-contrast', + htmlWithIssues: [{ target_selector: 'div.header' }], + }], + }; + const key = buildAggregationKeyFromSuggestion(suggestionData); + expect(key).to.equal('https://example.com/page1|color-contrast|div.header'); + }); + + it('should build aggregation key with PER_TYPE granularity', () => { + const suggestionData = { + url: 'https://example.com/page1', + issues: [{ + type: 'aria-prohibited-attr', + htmlWithIssues: [{ target_selector: 'div.header' }], + }], + }; + const key = buildAggregationKeyFromSuggestion(suggestionData); + expect(key).to.equal('aria-prohibited-attr'); + }); + + it('should build aggregation key with PER_PAGE_PER_COMPONENT granularity', () => { + const suggestionData = { + url: 'https://example.com/page1', + issues: [{ + type: 'button-name', + htmlWithIssues: [{ target_selector: 'button.submit' }], + }], + }; + const key = buildAggregationKeyFromSuggestion(suggestionData); + expect(key).to.equal('https://example.com/page1|button-name'); + }); + + it('should include source when provided', () => { + const suggestionData = { + url: 'https://example.com/page1', + source: 'ahrefs', + issues: [{ + type: 'color-contrast', + htmlWithIssues: [{ target_selector: 'a.nav-link' }], + }], + }; + const key = buildAggregationKeyFromSuggestion(suggestionData); + expect(key).to.equal('https://example.com/page1|color-contrast|a.nav-link|ahrefs'); + }); + + it('should support both snake_case and camelCase for target_selector', () => { + const suggestionDataSnake = { + url: 'https://example.com/page1', + issues: [{ + type: 'color-contrast', + htmlWithIssues: [{ target_selector: 'div.header' }], + }], + }; + const suggestionDataCamel = { + url: 'https://example.com/page1', + issues: [{ + type: 'color-contrast', + htmlWithIssues: [{ targetSelector: 'div.header' }], + }], + }; + const key1 = buildAggregationKeyFromSuggestion(suggestionDataSnake); + const key2 = buildAggregationKeyFromSuggestion(suggestionDataCamel); + expect(key1).to.equal(key2); + expect(key1).to.equal('https://example.com/page1|color-contrast|div.header'); + }); + + it('should handle empty target selector', () => { + const suggestionData = { + url: 'https://example.com/page1', + issues: [{ + type: 'button-name', + htmlWithIssues: [{ target_selector: '' }], + }], + }; + const key = buildAggregationKeyFromSuggestion(suggestionData); + expect(key).to.equal('https://example.com/page1|button-name'); + }); + + it('should handle missing target selector', () => { + const suggestionData = { + url: 'https://example.com/page1', + issues: [{ + type: 'button-name', + htmlWithIssues: [{}], + }], + }; + const key = buildAggregationKeyFromSuggestion(suggestionData); + expect(key).to.equal('https://example.com/page1|button-name'); + }); + + it('should return null when no issues present', () => { + const suggestionData = { + url: 'https://example.com/page1', + issues: [], + }; + const key = buildAggregationKeyFromSuggestion(suggestionData); + expect(key).to.be.null; + }); + + it('should return null when issues is undefined', () => { + const suggestionData = { + url: 'https://example.com/page1', + }; + const key = buildAggregationKeyFromSuggestion(suggestionData); + expect(key).to.be.null; + }); + + it('should handle null suggestionData gracefully', () => { + const key = buildAggregationKeyFromSuggestion(null); + expect(key).to.be.null; + }); + + it('should handle undefined suggestionData gracefully', () => { + const key = buildAggregationKeyFromSuggestion(undefined); + expect(key).to.be.null; + }); + + it('should handle missing url field', () => { + const suggestionData = { + issues: [{ + type: 'button-name', + htmlWithIssues: [{ target_selector: 'button.submit' }], + }], + }; + const key = buildAggregationKeyFromSuggestion(suggestionData); + // button-name has PER_PAGE_PER_COMPONENT granularity, so url is in the key + // When URL is undefined, it gets passed through as undefined + expect(key).to.equal('button-name'); + }); + + it('should return null when first issue is null', () => { + const suggestionData = { + url: 'https://example.com/page1', + issues: [null], + }; + const key = buildAggregationKeyFromSuggestion(suggestionData); + expect(key).to.be.null; + }); + + it('should return null when first issue has no type', () => { + const suggestionData = { + url: 'https://example.com/page1', + issues: [{ + htmlWithIssues: [{ target_selector: 'div.header' }], + }], + }; + const key = buildAggregationKeyFromSuggestion(suggestionData); + expect(key).to.be.null; + }); + }); + + describe('buildSuggestionKey (Database Keys)', () => { + it('should always use INDIVIDUAL granularity regardless of issue type', () => { + // Test with button-name which has PER_PAGE_PER_COMPONENT granularity + const suggestionData1 = { + url: 'https://example.com/page1', + issues: [{ + type: 'button-name', + htmlWithIssues: [{ target_selector: 'button.submit' }], + }], + }; + const key1 = buildSuggestionKey(suggestionData1); + expect(key1).to.equal('https://example.com/page1|button-name|button.submit'); + + // Test with aria-prohibited-attr which has PER_TYPE granularity + const suggestionData2 = { + url: 'https://example.com/page2', + issues: [{ + type: 'aria-prohibited-attr', + htmlWithIssues: [{ target_selector: 'div.header' }], + }], + }; + const key2 = buildSuggestionKey(suggestionData2); + expect(key2).to.equal('https://example.com/page2|aria-prohibited-attr|div.header'); + }); + + it('should include trailing pipe for empty selector (backwards compatibility)', () => { + const suggestionData = { + url: 'https://example.com/page1', + issues: [{ + type: 'image-alt', + htmlWithIssues: [{ target_selector: '' }], + }], + }; + const key = buildSuggestionKey(suggestionData); + expect(key).to.equal('https://example.com/page1|image-alt|'); + }); + + it('should include trailing pipe for missing selector (backwards compatibility)', () => { + const suggestionData = { + url: 'https://example.com/page1', + issues: [{ + type: 'label', + htmlWithIssues: [{}], + }], + }; + const key = buildSuggestionKey(suggestionData); + expect(key).to.equal('https://example.com/page1|label|'); + }); + + it('should include source when provided', () => { + const suggestionData = { + url: 'https://example.com/page1', + source: 'ahrefs', + issues: [{ + type: 'link-name', + htmlWithIssues: [{ target_selector: 'a.nav-link' }], + }], + }; + const key = buildSuggestionKey(suggestionData); + expect(key).to.equal('https://example.com/page1|link-name|a.nav-link|ahrefs'); + }); + + it('should return url when no issues present', () => { + const suggestionData = { + url: 'https://example.com/page1', + issues: [], + }; + const key = buildSuggestionKey(suggestionData); + expect(key).to.equal('https://example.com/page1'); + }); + }); +}); +