From 0638fd84a16ebea62698c79757afdc90670780ea Mon Sep 17 00:00:00 2001 From: Steve Jones Date: Fri, 20 Mar 2026 13:47:58 -0400 Subject: [PATCH 1/2] Add scan-to-scan accessibility regression detection --- includes/classes/class-rest-api.php | 23 +++++ includes/classes/class-summary-generator.php | 90 +++++++++++++++++++ package-lock.json | 4 +- .../components/Panels/AccessibilityStatus.js | 58 +++++++++++- .../sass/components/accessibility-status.scss | 20 ++++- .../classes/RestApiSidebarDataTest.php | 17 +++- .../includes/classes/SummaryGeneratorTest.php | 35 ++++++++ 7 files changed, 239 insertions(+), 8 deletions(-) diff --git a/includes/classes/class-rest-api.php b/includes/classes/class-rest-api.php index e364bfb81..579d719b3 100644 --- a/includes/classes/class-rest-api.php +++ b/includes/classes/class-rest-api.php @@ -874,6 +874,29 @@ private function get_summary_data( $post_id ) { 'warnings' => 0, 'ignored' => 0, 'readability' => 0, + 'regression' => [ + 'has_baseline' => false, + 'status' => 'stable', + 'delta' => [ + 'errors' => 0, + 'warnings' => 0, + 'contrast_errors' => 0, + 'passed_tests' => 0, + ], + 'scanned_at' => '', + ], + ]; + } elseif ( ! isset( $summary['regression'] ) || ! is_array( $summary['regression'] ) ) { + $summary['regression'] = [ + 'has_baseline' => false, + 'status' => 'stable', + 'delta' => [ + 'errors' => 0, + 'warnings' => 0, + 'contrast_errors' => 0, + 'passed_tests' => 0, + ], + 'scanned_at' => '', ]; } diff --git a/includes/classes/class-summary-generator.php b/includes/classes/class-summary-generator.php index ac64a4d7e..02cd88372 100644 --- a/includes/classes/class-summary-generator.php +++ b/includes/classes/class-summary-generator.php @@ -60,6 +60,7 @@ public function generate_summary() { $table_name = edac_get_valid_table_name( $wpdb->prefix . 'accessibility_checker' ); $summary = []; + $previous_summary = get_post_meta( $this->post_id, '_edac_summary', true ); if ( ! $table_name ) { return $summary; @@ -80,12 +81,62 @@ public function generate_summary() { $summary['content_grade'] = $this->calculate_content_grade(); $summary['readability'] = $this->get_readability( $summary ); $summary['simplified_summary'] = (bool) ( get_post_meta( $this->post_id, '_edac_simplified_summary', true ) ); + $summary['regression'] = $this->build_regression_data( $previous_summary, $summary ); $this->update_issue_density( $summary ); $this->save_summary_meta_data( $summary ); return $summary; } + /** + * Build regression/trend data by comparing the current summary with the previous scan summary. + * + * @param mixed $previous_summary The previously saved summary meta. + * @param array $current_summary The current summary data. + * + * @return array + */ + private function build_regression_data( $previous_summary, array $current_summary ) { + $metrics = [ + 'errors', + 'warnings', + 'contrast_errors', + 'passed_tests', + ]; + + $deltas = []; + foreach ( $metrics as $metric ) { + $current_value = absint( $current_summary[ $metric ] ?? 0 ); + $previous_value = is_array( $previous_summary ) ? absint( $previous_summary[ $metric ] ?? 0 ) : 0; + $deltas[ $metric ] = $current_value - $previous_value; + } + + $regression_score = max( 0, $deltas['errors'] ) + max( 0, $deltas['warnings'] ) + max( 0, $deltas['contrast_errors'] ); + + $status = 'stable'; + if ( $regression_score >= 5 || $deltas['passed_tests'] <= -10 ) { + $status = 'declining'; + } elseif ( $regression_score > 0 || $deltas['passed_tests'] < 0 ) { + $status = 'watch'; + } elseif ( $deltas['errors'] < 0 || $deltas['warnings'] < 0 || $deltas['contrast_errors'] < 0 || $deltas['passed_tests'] > 0 ) { + $status = 'improving'; + } + + $has_baseline = is_array( $previous_summary ) && ! empty( $previous_summary ); + + return [ + 'has_baseline' => (bool) $has_baseline, + 'status' => sanitize_key( $status ), + 'delta' => [ + 'errors' => intval( $deltas['errors'] ), + 'warnings' => intval( $deltas['warnings'] ), + 'contrast_errors' => intval( $deltas['contrast_errors'] ), + 'passed_tests' => intval( $deltas['passed_tests'] ), + ], + 'scanned_at' => current_time( 'mysql', true ), + ]; + } + /** * Calculates the percentage of passed tests based on the provided rules. * This method queries the database to find which rules have not been violated (passed) for @@ -330,6 +381,34 @@ private function save_summary_meta_data( $summary ) { update_post_meta( $this->post_id, '_edac_summary_warnings', absint( $summary['warnings'] ) ); update_post_meta( $this->post_id, '_edac_summary_ignored', absint( $summary['ignored'] ) ); update_post_meta( $this->post_id, '_edac_summary_contrast_errors', absint( $summary['contrast_errors'] ) ); + $this->update_summary_history( $summary ); + } + + /** + * Stores compact summary scan history used for trend/regression messaging. + * + * @param array $summary The latest summary. + * + * @return void + */ + private function update_summary_history( array $summary ) { + $history = get_post_meta( $this->post_id, '_edac_summary_history', true ); + $history = is_array( $history ) ? $history : []; + $timestamp = current_time( 'mysql', true ); + + $history[] = [ + 'scanned_at' => $timestamp, + 'errors' => absint( $summary['errors'] ?? 0 ), + 'warnings' => absint( $summary['warnings'] ?? 0 ), + 'contrast_errors' => absint( $summary['contrast_errors'] ?? 0 ), + 'passed_tests' => absint( $summary['passed_tests'] ?? 0 ), + ]; + + if ( count( $history ) > 10 ) { + $history = array_slice( $history, -10 ); + } + + update_post_meta( $this->post_id, '_edac_summary_history', $history ); } /** @@ -351,6 +430,17 @@ private function sanitize_summary_meta_data( array $summary ): array { 'content_grade' => absint( $summary['content_grade'] ?? 0 ), 'readability' => sanitize_text_field( $summary['readability'] ?? '' ), 'simplified_summary' => filter_var( $summary['simplified_summary'] ?? false, FILTER_VALIDATE_BOOLEAN ), + 'regression' => [ + 'has_baseline' => filter_var( $summary['regression']['has_baseline'] ?? false, FILTER_VALIDATE_BOOLEAN ), + 'status' => sanitize_key( $summary['regression']['status'] ?? 'stable' ), + 'delta' => [ + 'errors' => intval( $summary['regression']['delta']['errors'] ?? 0 ), + 'warnings' => intval( $summary['regression']['delta']['warnings'] ?? 0 ), + 'contrast_errors' => intval( $summary['regression']['delta']['contrast_errors'] ?? 0 ), + 'passed_tests' => intval( $summary['regression']['delta']['passed_tests'] ?? 0 ), + ], + 'scanned_at' => sanitize_text_field( $summary['regression']['scanned_at'] ?? '' ), + ], ]; } } diff --git a/package-lock.json b/package-lock.json index 16537c244..059f80146 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "accessibility-checker", - "version": "1.38.0", + "version": "1.39.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "accessibility-checker", - "version": "1.38.0", + "version": "1.39.0", "hasInstallScript": true, "license": "GPL-2.0+", "devDependencies": { diff --git a/src/sidebar/components/Panels/AccessibilityStatus.js b/src/sidebar/components/Panels/AccessibilityStatus.js index c329849e7..cd7557cbf 100644 --- a/src/sidebar/components/Panels/AccessibilityStatus.js +++ b/src/sidebar/components/Panels/AccessibilityStatus.js @@ -69,6 +69,8 @@ const AccessibilityStatus = () => { // Extract data from store const summary = data?.summary || {}; const readability = data?.readability || {}; + const regression = summary.regression || {}; + const regressionDelta = regression.delta || {}; const coveragePercent = summary.passed_tests || 0; const problems = ( summary.errors || 0 ) + ( summary.contrast_errors || 0 ); @@ -78,6 +80,46 @@ const AccessibilityStatus = () => { const postGradeReadable = readability.post_grade_readability || ''; const needsSummary = !! readability.post_grade_failed; const hasSummary = !! readability.simplified_summary; + const hasRegressionBaseline = !! regression.has_baseline; + const regressionStatus = regression.status || 'stable'; + const problemsDelta = + ( regressionDelta.errors || 0 ) + ( regressionDelta.contrast_errors || 0 ); + const warningDelta = regressionDelta.warnings || 0; + const passedDelta = regressionDelta.passed_tests || 0; + + const renderRegressionMessage = useCallback( + ( delta, noun, invertMeaning = false ) => { + if ( ! hasRegressionBaseline ) { + return __( 'Run another scan to start tracking trend changes.', 'accessibility-checker' ); + } + + if ( delta === 0 ) { + return sprintf( __( 'No change in %s since the last scan.', 'accessibility-checker' ), noun ); + } + + const increase = delta > 0; + const absDelta = Math.abs( delta ); + const directionWord = increase ? __( 'up', 'accessibility-checker' ) : __( 'down', 'accessibility-checker' ); + const impactWord = ( increase && ! invertMeaning ) || ( ! increase && invertMeaning ) + ? __( 'Regression', 'accessibility-checker' ) + : __( 'Improvement', 'accessibility-checker' ); + + return sprintf( + // translators: 1: regression/improvement label, 2: magnitude, 3: metric label, 4: up/down. + __( '%1$s: %2$d %3$s %4$s since last scan.', 'accessibility-checker' ), + impactWord, + absDelta, + noun, + directionWord, + ); + }, + [ hasRegressionBaseline ], + ); + + const retentionMessage = + regressionStatus === 'declining' + ? __( 'Accessibility is declining. If you stop scanning, this drift may go unnoticed until users are blocked.', 'accessibility-checker' ) + : __( 'Ongoing scans prove whether accessibility is improving or slipping over time.', 'accessibility-checker' ); // Determine status icon based on errors and warnings let statusIconName = 'check'; @@ -206,7 +248,9 @@ const AccessibilityStatus = () => {
{ problems }
- {/* Placeholder for 30-day trend - will be implemented later */} +
+ { renderRegressionMessage( problemsDelta, __( 'problems', 'accessibility-checker' ) ) } +
{/* Needs Review (Warnings) */} @@ -240,7 +284,9 @@ const AccessibilityStatus = () => {
{ needsReview }
- {/* Placeholder for 30-day trend - will be implemented later */} +
+ { renderRegressionMessage( warningDelta, __( 'warnings', 'accessibility-checker' ) ) } +
{/* Reading Level */}
{
{ coveragePercent }%
- {/* Placeholder for 30-day trend - will be implemented later */} +
+ { renderRegressionMessage( passedDelta, __( 'passed checks', 'accessibility-checker' ), true ) } +
+ +

{ retentionMessage }

+
); }; export default AccessibilityStatus; - diff --git a/src/sidebar/sass/components/accessibility-status.scss b/src/sidebar/sass/components/accessibility-status.scss index 1a635d42f..9eed89391 100644 --- a/src/sidebar/sass/components/accessibility-status.scss +++ b/src/sidebar/sass/components/accessibility-status.scss @@ -110,6 +110,25 @@ font-size: 10px; color: $info-blue; margin-top: 2px; + + &--trend { + line-height: 1.35; + min-height: 28px; + color: $subtext-gray; + } + } + } + + .edac-status-retention-message { + margin: 10px 0 0; + padding-top: 10px; + border-top: 1px dashed $outline-grey; + + p { + margin: 0; + font-size: 11px; + line-height: 1.4; + color: $info-grey; } } @@ -126,4 +145,3 @@ } } } - diff --git a/tests/phpunit/includes/classes/RestApiSidebarDataTest.php b/tests/phpunit/includes/classes/RestApiSidebarDataTest.php index c5678fc6d..6795bafa2 100644 --- a/tests/phpunit/includes/classes/RestApiSidebarDataTest.php +++ b/tests/phpunit/includes/classes/RestApiSidebarDataTest.php @@ -123,6 +123,17 @@ public function test_get_summary_data_returns_defaults() { 'warnings' => 0, 'ignored' => 0, 'readability' => 0, + 'regression' => [ + 'has_baseline' => false, + 'status' => 'stable', + 'delta' => [ + 'errors' => 0, + 'warnings' => 0, + 'contrast_errors' => 0, + 'passed_tests' => 0, + ], + 'scanned_at' => '', + ], ]; $this->assertSame( $expected, $summary ); @@ -146,7 +157,11 @@ public function test_get_summary_data_uses_meta() { $method = $this->get_private_method( $api, 'get_summary_data' ); $result = $method->invoke( $api, self::$post_id ); - $this->assertSame( $meta, $result ); + $this->assertSame( 5, $result['passed_tests'] ); + $this->assertSame( 2, $result['errors'] ); + $this->assertArrayHasKey( 'regression', $result ); + $this->assertFalse( $result['regression']['has_baseline'] ); + $this->assertSame( 'stable', $result['regression']['status'] ); } /** diff --git a/tests/phpunit/includes/classes/SummaryGeneratorTest.php b/tests/phpunit/includes/classes/SummaryGeneratorTest.php index 84cd13294..359c1303b 100644 --- a/tests/phpunit/includes/classes/SummaryGeneratorTest.php +++ b/tests/phpunit/includes/classes/SummaryGeneratorTest.php @@ -55,4 +55,39 @@ public function test_calculate_content_grade_returns_zero_when_post_missing() { $this->assertSame( 0, $method->invoke( $summary_generator ) ); } + + /** + * Ensures that regression data marks worsening scans as declining. + * + * @throws ReflectionException If the method does not exist this is thrown. + */ + public function test_build_regression_data_marks_declining_status() { + $post_id = self::factory()->post->create(); + $summary_generator = new Summary_Generator( $post_id ); + + $method = ( new ReflectionClass( get_class( $summary_generator ) ) ) + ->getMethod( 'build_regression_data' ); + $method->setAccessible( true ); + + $previous = [ + 'errors' => 1, + 'warnings' => 1, + 'contrast_errors' => 0, + 'passed_tests' => 85, + ]; + + $current = [ + 'errors' => 4, + 'warnings' => 3, + 'contrast_errors' => 0, + 'passed_tests' => 70, + ]; + + $regression = $method->invoke( $summary_generator, $previous, $current ); + + $this->assertTrue( $regression['has_baseline'] ); + $this->assertSame( 'declining', $regression['status'] ); + $this->assertSame( 3, $regression['delta']['errors'] ); + $this->assertSame( -15, $regression['delta']['passed_tests'] ); + } } From acc879400b5e62e89fcc5abfaf731a0b4e4a0bc1 Mon Sep 17 00:00:00 2001 From: Steve Jones Date: Fri, 20 Mar 2026 14:52:59 -0400 Subject: [PATCH 2/2] Enhance regression detection: preserve existing data when issue counts match and include content grade in summary updates --- includes/classes/class-summary-generator.php | 64 +++++++++++++++++-- .../components/Panels/AccessibilityStatus.js | 22 ++++--- 2 files changed, 71 insertions(+), 15 deletions(-) diff --git a/includes/classes/class-summary-generator.php b/includes/classes/class-summary-generator.php index 02cd88372..d3bd92dcc 100644 --- a/includes/classes/class-summary-generator.php +++ b/includes/classes/class-summary-generator.php @@ -58,8 +58,8 @@ public function __construct( $post_id ) { public function generate_summary() { global $wpdb; - $table_name = edac_get_valid_table_name( $wpdb->prefix . 'accessibility_checker' ); - $summary = []; + $table_name = edac_get_valid_table_name( $wpdb->prefix . 'accessibility_checker' ); + $summary = []; $previous_summary = get_post_meta( $this->post_id, '_edac_summary', true ); if ( ! $table_name ) { @@ -81,13 +81,45 @@ public function generate_summary() { $summary['content_grade'] = $this->calculate_content_grade(); $summary['readability'] = $this->get_readability( $summary ); $summary['simplified_summary'] = (bool) ( get_post_meta( $this->post_id, '_edac_simplified_summary', true ) ); - $summary['regression'] = $this->build_regression_data( $previous_summary, $summary ); + + // If issue counts haven't changed since the last save, preserve existing regression + // data to avoid a second generate_summary() call overwriting deltas with zeros. + if ( $this->issue_counts_match( $previous_summary, $summary ) && ! empty( $previous_summary['regression'] ) ) { + $summary['regression'] = $previous_summary['regression']; + } else { + $summary['regression'] = $this->build_regression_data( $previous_summary, $summary ); + } + $this->update_issue_density( $summary ); $this->save_summary_meta_data( $summary ); return $summary; } + /** + * Checks whether issue-related counts in the current summary match a previously saved summary. + * + * @param mixed $previous_summary The previously saved summary meta. + * @param array $current_summary The current summary data. + * + * @return bool True when all issue counts are identical. + */ + private function issue_counts_match( $previous_summary, array $current_summary ): bool { + if ( ! is_array( $previous_summary ) ) { + return false; + } + + $metrics = [ 'errors', 'warnings', 'contrast_errors', 'passed_tests', 'content_grade' ]; + + foreach ( $metrics as $metric ) { + if ( absint( $previous_summary[ $metric ] ?? 0 ) !== absint( $current_summary[ $metric ] ?? 0 ) ) { + return false; + } + } + + return true; + } + /** * Build regression/trend data by comparing the current summary with the previous scan summary. * @@ -102,12 +134,13 @@ private function build_regression_data( $previous_summary, array $current_summar 'warnings', 'contrast_errors', 'passed_tests', + 'content_grade', ]; $deltas = []; foreach ( $metrics as $metric ) { - $current_value = absint( $current_summary[ $metric ] ?? 0 ); - $previous_value = is_array( $previous_summary ) ? absint( $previous_summary[ $metric ] ?? 0 ) : 0; + $current_value = absint( $current_summary[ $metric ] ?? 0 ); + $previous_value = is_array( $previous_summary ) ? absint( $previous_summary[ $metric ] ?? 0 ) : 0; $deltas[ $metric ] = $current_value - $previous_value; } @@ -132,6 +165,7 @@ private function build_regression_data( $previous_summary, array $current_summar 'warnings' => intval( $deltas['warnings'] ), 'contrast_errors' => intval( $deltas['contrast_errors'] ), 'passed_tests' => intval( $deltas['passed_tests'] ), + 'content_grade' => intval( $deltas['content_grade'] ), ], 'scanned_at' => current_time( 'mysql', true ), ]; @@ -396,14 +430,31 @@ private function update_summary_history( array $summary ) { $history = is_array( $history ) ? $history : []; $timestamp = current_time( 'mysql', true ); - $history[] = [ + $new_entry = [ 'scanned_at' => $timestamp, 'errors' => absint( $summary['errors'] ?? 0 ), 'warnings' => absint( $summary['warnings'] ?? 0 ), 'contrast_errors' => absint( $summary['contrast_errors'] ?? 0 ), 'passed_tests' => absint( $summary['passed_tests'] ?? 0 ), + 'content_grade' => absint( $summary['content_grade'] ?? 0 ), ]; + // Skip duplicate entries when generate_summary() is called multiple times per scan. + if ( ! empty( $history ) ) { + $last = end( $history ); + if ( + absint( $last['errors'] ?? -1 ) === $new_entry['errors'] && + absint( $last['warnings'] ?? -1 ) === $new_entry['warnings'] && + absint( $last['contrast_errors'] ?? -1 ) === $new_entry['contrast_errors'] && + absint( $last['passed_tests'] ?? -1 ) === $new_entry['passed_tests'] && + absint( $last['content_grade'] ?? -1 ) === $new_entry['content_grade'] + ) { + return; + } + } + + $history[] = $new_entry; + if ( count( $history ) > 10 ) { $history = array_slice( $history, -10 ); } @@ -438,6 +489,7 @@ private function sanitize_summary_meta_data( array $summary ): array { 'warnings' => intval( $summary['regression']['delta']['warnings'] ?? 0 ), 'contrast_errors' => intval( $summary['regression']['delta']['contrast_errors'] ?? 0 ), 'passed_tests' => intval( $summary['regression']['delta']['passed_tests'] ?? 0 ), + 'content_grade' => intval( $summary['regression']['delta']['content_grade'] ?? 0 ), ], 'scanned_at' => sanitize_text_field( $summary['regression']['scanned_at'] ?? '' ), ], diff --git a/src/sidebar/components/Panels/AccessibilityStatus.js b/src/sidebar/components/Panels/AccessibilityStatus.js index cd7557cbf..ac693b288 100644 --- a/src/sidebar/components/Panels/AccessibilityStatus.js +++ b/src/sidebar/components/Panels/AccessibilityStatus.js @@ -86,6 +86,7 @@ const AccessibilityStatus = () => { ( regressionDelta.errors || 0 ) + ( regressionDelta.contrast_errors || 0 ); const warningDelta = regressionDelta.warnings || 0; const passedDelta = regressionDelta.passed_tests || 0; + const readingLevelDelta = regressionDelta.content_grade || 0; const renderRegressionMessage = useCallback( ( delta, noun, invertMeaning = false ) => { @@ -119,7 +120,7 @@ const AccessibilityStatus = () => { const retentionMessage = regressionStatus === 'declining' ? __( 'Accessibility is declining. If you stop scanning, this drift may go unnoticed until users are blocked.', 'accessibility-checker' ) - : __( 'Ongoing scans prove whether accessibility is improving or slipping over time.', 'accessibility-checker' ); + : ''; // Determine status icon based on errors and warnings let statusIconName = 'check'; @@ -319,11 +320,12 @@ const AccessibilityStatus = () => {
{ readingLevelText }
- { summaryStatus && ( -
- { summaryStatus } -
- ) } +
+ { summaryStatus && ( + { summaryStatus }. + ) } + { renderRegressionMessage( readingLevelDelta, __( 'reading level', 'accessibility-checker' ) ) } +
{/* Passed Checks (Coverage) */}
@@ -341,9 +343,11 @@ const AccessibilityStatus = () => {
- -

{ retentionMessage }

-
+ { retentionMessage && ( + +

{ retentionMessage }

+
+ ) } );