+ AI connectors settings.', 'ai-performance-advisor' ), + esc_url( admin_url( 'options-connectors.php' ) ) + ); + echo wp_kses( $aipa_connect_message, array( 'a' => array( 'href' => array() ) ) ); + ?> +
++ +
++ + +
++ +
+ + ++ +
+ + + + ++ +
+ sprintf( + '%2$s', + esc_url( admin_url( 'options-general.php#ai-performance-advisor' ) ), + esc_html__( 'Settings', 'ai-performance-advisor' ) + ), + ), + $links + ); +} +add_filter( 'plugin_action_links_' . plugin_basename( AI_PERFORMANCE_ADVISOR_MAIN_FILE ), 'aipa_add_settings_action_link' ); diff --git a/plugins/ai-performance-advisor/tests/class-aipa-empty-context-provider.php b/plugins/ai-performance-advisor/tests/class-aipa-empty-context-provider.php new file mode 100644 index 0000000000..964ab61420 --- /dev/null +++ b/plugins/ai-performance-advisor/tests/class-aipa-empty-context-provider.php @@ -0,0 +1,43 @@ + An empty payload. + */ + public function collect(): array { + return array(); + } + } +} diff --git a/plugins/ai-performance-advisor/tests/class-aipa-fake-context-provider.php b/plugins/ai-performance-advisor/tests/class-aipa-fake-context-provider.php new file mode 100644 index 0000000000..f24eb3b18b --- /dev/null +++ b/plugins/ai-performance-advisor/tests/class-aipa-fake-context-provider.php @@ -0,0 +1,43 @@ + Fixed payload. + */ + public function collect(): array { + return array( 'note' => 'hermetic' ); + } + } +} diff --git a/plugins/ai-performance-advisor/tests/class-aipa-unavailable-context-provider.php b/plugins/ai-performance-advisor/tests/class-aipa-unavailable-context-provider.php new file mode 100644 index 0000000000..33df20b807 --- /dev/null +++ b/plugins/ai-performance-advisor/tests/class-aipa-unavailable-context-provider.php @@ -0,0 +1,52 @@ + A payload that should never be collected. + */ + public function collect(): array { + return array( 'should' => 'never appear' ); + } + } +} diff --git a/plugins/ai-performance-advisor/tests/test-analyzer.php b/plugins/ai-performance-advisor/tests/test-analyzer.php new file mode 100644 index 0000000000..c8ddfe077f --- /dev/null +++ b/plugins/ai-performance-advisor/tests/test-analyzer.php @@ -0,0 +1,247 @@ +ai_calls = 0; + delete_transient( AIPA_Analyzer::CACHE_KEY ); + add_action( 'aipa_register_context_providers', array( $this, 'use_hermetic_provider' ) ); + } + + public function tear_down(): void { + delete_transient( AIPA_Analyzer::CACHE_KEY ); + remove_all_filters( 'aipa_pre_is_ai_available' ); + remove_all_filters( 'aipa_pre_generate_text' ); + remove_all_actions( 'aipa_register_context_providers' ); + parent::tear_down(); + } + + /** + * Replaces the default providers with a single hermetic one (no network/Site Health). + * + * @param AIPA_Context_Provider_Registry $registry The registry being assembled. + */ + public function use_hermetic_provider( AIPA_Context_Provider_Registry $registry ): void { + foreach ( array( 'environment', 'site_health', 'site_health_tests', 'pagespeed', 'optimization_detective' ) as $key ) { + $registry->unregister( $key ); + } + $registry->register( new AIPA_Fake_Context_Provider() ); + } + + /** + * @covers AIPA_Analyzer + */ + public function test_analyze_returns_error_when_ai_unavailable(): void { + add_filter( 'aipa_pre_is_ai_available', '__return_false' ); + + $result = ( new AIPA_Analyzer() )->analyze(); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'aipa_ai_unavailable', $result->get_error_code() ); + } + + /** + * @covers AIPA_Analyzer + */ + public function test_analyze_returns_sanitized_recommendations(): void { + $this->force_available(); + $this->set_ai_response( + (string) wp_json_encode( + array( + array( + 'title' => 'Compress images', + 'summary' => 'Serve images as WebP and size them correctly.', + 'severity' => 'critical', + 'category' => 'images', + ), + ) + ) + ); + + $result = ( new AIPA_Analyzer() )->analyze(); + + $this->assertIsArray( $result ); + $this->assertCount( 1, $result ); + $this->assertSame( 'Compress images', $result[0]['title'] ); + $this->assertSame( 'critical', $result[0]['severity'] ); + + // The result is cached in a transient. + $cached = get_transient( AIPA_Analyzer::CACHE_KEY ); + $this->assertIsArray( $cached ); + $this->assertArrayHasKey( 'hash', $cached ); + $this->assertArrayHasKey( 'recommendations', $cached ); + } + + /** + * @covers AIPA_Analyzer + */ + public function test_analyze_uses_cache_then_bypasses_with_refresh(): void { + $this->force_available(); + $this->set_ai_response( + (string) wp_json_encode( + array( + array( + 'title' => 'First result', + 'summary' => 'From the first model call.', + ), + ) + ) + ); + + $analyzer = new AIPA_Analyzer(); + $first = $analyzer->analyze(); + $this->assertSame( 1, $this->ai_calls ); + $this->assertIsArray( $first ); + $this->assertSame( 'First result', $first[0]['title'] ); + + // A cached call with an unchanged context does not call the model again. + $second = $analyzer->analyze( true ); + $this->assertSame( 1, $this->ai_calls ); + $this->assertIsArray( $second ); + $this->assertSame( 'First result', $second[0]['title'] ); + + // Refreshing bypasses the cache and calls the model again. + $third = $analyzer->analyze( false ); + $this->assertSame( 2, $this->ai_calls ); + $this->assertIsArray( $third ); + $this->assertSame( 'First result', $third[0]['title'] ); + } + + /** + * @covers AIPA_Analyzer + */ + public function test_analyze_parses_code_fenced_json(): void { + $this->force_available(); + $this->set_ai_response( "```json\n[{\"title\":\"Enable caching\",\"summary\":\"Add a page cache.\"}]\n```" ); + + $result = ( new AIPA_Analyzer() )->analyze(); + $this->assertIsArray( $result ); + $this->assertCount( 1, $result ); + $this->assertSame( 'Enable caching', $result[0]['title'] ); + } + + /** + * @covers AIPA_Analyzer + */ + public function test_analyze_parses_json_wrapped_in_prose(): void { + $this->force_available(); + $this->set_ai_response( 'Here are the recommendations: [{"title":"Defer scripts","summary":"Defer non-critical JS."}] Hope this helps.' ); + + $result = ( new AIPA_Analyzer() )->analyze(); + $this->assertIsArray( $result ); + $this->assertCount( 1, $result ); + $this->assertSame( 'Defer scripts', $result[0]['title'] ); + } + + /** + * @covers AIPA_Analyzer + */ + public function test_analyze_returns_empty_for_unparsable_response(): void { + $this->force_available(); + $this->set_ai_response( 'totally not json' ); + + $result = ( new AIPA_Analyzer() )->analyze(); + $this->assertSame( array(), $result ); + } + + /** + * @covers AIPA_Analyzer + */ + public function test_analyze_propagates_empty_response_error(): void { + $this->force_available(); + $this->set_ai_response( '' ); + + $result = ( new AIPA_Analyzer() )->analyze(); + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'aipa_ai_empty_response', $result->get_error_code() ); + } + + /** + * @covers AIPA_Analyzer + */ + public function test_analyze_propagates_wp_error_from_model(): void { + $this->force_available(); + add_filter( + 'aipa_pre_generate_text', + static function () { + return new WP_Error( 'provider_down', 'The provider is unavailable.' ); + } + ); + + $result = ( new AIPA_Analyzer() )->analyze(); + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'provider_down', $result->get_error_code() ); + } + + /** + * @covers AIPA_Analyzer + */ + public function test_get_system_instruction_describes_output_contract(): void { + $method = new ReflectionMethod( AIPA_Analyzer::class, 'get_system_instruction' ); + $method->setAccessible( true ); + $instruction = (string) $method->invoke( new AIPA_Analyzer() ); + + $this->assertStringContainsString( 'JSON', $instruction ); + $this->assertStringContainsString( 'critical', $instruction ); + $this->assertStringContainsString( 'images', $instruction ); + } + + /** + * @covers AIPA_Analyzer + */ + public function test_request_returns_error_when_ai_client_throws(): void { + if ( function_exists( 'wp_ai_client_prompt' ) ) { + // The real AI Client API is present (WordPress >= 7.0), so the + // missing-client failure path (an undefined-function Throwable) cannot be + // exercised here. It is covered on environments without the AI Client. + $this->assertTrue( function_exists( 'wp_ai_client_prompt' ) ); + return; + } + + $this->force_available(); + // With no aipa_pre_generate_text filter, the analyzer calls the absent AI + // client, which throws and is converted into a WP_Error. + $result = ( new AIPA_Analyzer() )->analyze(); + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'aipa_ai_request_failed', $result->get_error_code() ); + } + + /** + * Forces the AI availability check to report "available". + */ + private function force_available(): void { + add_filter( 'aipa_pre_is_ai_available', '__return_true' ); + } + + /** + * Routes the model call through a canned response, counting invocations. + * + * @param string $response The canned model output. + */ + private function set_ai_response( string $response ): void { + add_filter( + 'aipa_pre_generate_text', + function () use ( $response ) { + ++$this->ai_calls; + return $response; + } + ); + } +} diff --git a/plugins/ai-performance-advisor/tests/test-context-providers.php b/plugins/ai-performance-advisor/tests/test-context-providers.php new file mode 100644 index 0000000000..24bd1fc9f4 --- /dev/null +++ b/plugins/ai-performance-advisor/tests/test-context-providers.php @@ -0,0 +1,438 @@ +assertTrue( $provider->is_available() ); + $this->assertSame( 'fake', $provider->get_key() ); + $this->assertSame( 'Fake provider', $provider->get_label() ); + } + + /** + * @covers AIPA_Provider_Environment + */ + public function test_environment_provider_shape(): void { + $provider = new AIPA_Provider_Environment(); + $this->assertSame( 'environment', $provider->get_key() ); + $this->assertNotEmpty( $provider->get_label() ); + $this->assertTrue( $provider->is_available() ); + + $data = $provider->collect(); + $this->assertArrayHasKey( 'wp_version', $data ); + $this->assertArrayHasKey( 'php_version', $data ); + $this->assertArrayHasKey( 'active_theme', $data ); + $this->assertArrayHasKey( 'active_plugins', $data ); + $this->assertIsArray( $data['active_plugins'] ); + $this->assertSame( PHP_VERSION, $data['php_version'] ); + } + + /** + * @covers AIPA_Provider_Site_Health + */ + public function test_site_health_provider_collects_and_excludes_private_fields(): void { + $provider = new AIPA_Provider_Site_Health(); + $this->assertSame( 'site_health', $provider->get_key() ); + $data = $provider->collect(); + + // No private secret should leak into the payload. + $flat = (string) wp_json_encode( $data ); + if ( defined( 'AUTH_KEY' ) && '' !== AUTH_KEY ) { + $this->assertStringNotContainsString( (string) AUTH_KEY, $flat ); + } + } + + /** + * @covers AIPA_Provider_Site_Health + */ + public function test_site_health_flatten_section_handles_all_value_types(): void { + $provider = new AIPA_Provider_Site_Health(); + $method = new ReflectionMethod( AIPA_Provider_Site_Health::class, 'flatten_section' ); + $method->setAccessible( true ); + + $section = array( + 'fields' => array( + 'secret' => array( + 'label' => 'Secret', + 'value' => 'sensitive', + 'private' => true, + ), + 'flag' => array( + 'label' => 'Flag', + 'value' => true, + ), + 'list' => array( + 'label' => 'List', + 'value' => array( 'a', 'b' ), + ), + 'obj' => array( + 'label' => 'Obj', + 'value' => new stdClass(), + ), + 'no_label' => array( + 'value' => 'no-label-value', + ), + 'not_array' => 'scalar-field', + ), + ); + + $flattened = $method->invoke( $provider, $section ); + + $this->assertArrayNotHasKey( 'Secret', $flattened ); + $this->assertSame( 'true', $flattened['Flag'] ); + $this->assertSame( wp_json_encode( array( 'a', 'b' ) ), $flattened['List'] ); + $this->assertSame( '', $flattened['Obj'] ); + $this->assertSame( 'no-label-value', $flattened['no_label'] ); + $this->assertArrayNotHasKey( 'scalar-field', $flattened ); + } + + /** + * @covers AIPA_Provider_Site_Health + */ + public function test_site_health_flatten_section_without_fields_returns_empty(): void { + $provider = new AIPA_Provider_Site_Health(); + $method = new ReflectionMethod( AIPA_Provider_Site_Health::class, 'flatten_section' ); + $method->setAccessible( true ); + $this->assertSame( array(), $method->invoke( $provider, array( 'no_fields_key' => true ) ) ); + } + + /** + * @covers AIPA_Provider_Site_Health_Tests + */ + public function test_site_health_tests_provider_runs_only_valid_direct_tests(): void { + // Return only the fake tests (ignoring core tests, some of which make network + // requests) so the provider's behavior can be asserted deterministically. + add_filter( + 'site_status_tests', + static function (): array { + return array( + 'direct' => array( + 'aipa_good' => array( + 'label' => 'Good', + 'test' => static function (): array { + return array( + 'label' => 'All good here', + 'status' => 'good', + 'description' => 'Nothing to do.
', + ); + }, + ), + 'aipa_noncallable' => array( + 'label' => 'Non callable', + 'test' => 'aipa_definitely_not_a_callable_function', + ), + 'aipa_throws' => array( + 'label' => 'Throws', + 'test' => static function (): array { + throw new RuntimeException( 'boom' ); + }, + ), + 'aipa_nonarray' => array( + 'label' => 'Non array', + 'test' => static function () { + return 'not-an-array'; + }, + ), + 'aipa_notest' => array( + 'label' => 'Missing test callback', + ), + ), + 'async' => array(), + ); + }, + 99 + ); + + $provider = new AIPA_Provider_Site_Health_Tests(); + $this->assertSame( 'site_health_tests', $provider->get_key() ); + $results = $provider->collect(); + + $this->assertArrayHasKey( 'aipa_good', $results ); + $this->assertSame( 'good', $results['aipa_good']['status'] ); + $this->assertSame( 'All good here', $results['aipa_good']['label'] ); + $this->assertSame( 'Nothing to do.', $results['aipa_good']['description'] ); + + $this->assertArrayNotHasKey( 'aipa_noncallable', $results ); + $this->assertArrayNotHasKey( 'aipa_throws', $results ); + $this->assertArrayNotHasKey( 'aipa_nonarray', $results ); + $this->assertArrayNotHasKey( 'aipa_notest', $results ); + } + + /** + * @covers AIPA_Provider_Optimization_Detective + */ + public function test_optimization_detective_provider(): void { + $provider = new AIPA_Provider_Optimization_Detective(); + $this->assertSame( 'optimization_detective', $provider->get_key() ); + $this->assertNotEmpty( $provider->get_label() ); + + if ( $provider->is_available() ) { + $data = $provider->collect(); + $this->assertTrue( $data['active'] ); + $this->assertArrayHasKey( 'version', $data ); + $this->assertIsInt( $data['measured_url_count'] ); + } else { + $this->assertFalse( $provider->is_available() ); + } + } + + /** + * @covers AIPA_Provider_PageSpeed + */ + public function test_pagespeed_provider_availability_follows_setting(): void { + $provider = new AIPA_Provider_PageSpeed(); + $this->assertSame( 'pagespeed', $provider->get_key() ); + + update_option( 'aipa_settings', array( 'include_pagespeed' => true ) ); + $this->assertTrue( $provider->is_available() ); + + update_option( 'aipa_settings', array( 'include_pagespeed' => false ) ); + $this->assertFalse( $provider->is_available() ); + } + + /** + * @covers AIPA_Provider_PageSpeed + */ + public function test_pagespeed_provider_compacts_successful_response(): void { + $body = (string) wp_json_encode( + array( + 'lighthouseResult' => array( + 'categories' => array( + 'performance' => array( 'score' => 0.42 ), + ), + 'audits' => array( + 'largest-contentful-paint' => array( 'displayValue' => '4.2 s' ), + 'cumulative-layout-shift' => array( 'displayValue' => '0.05' ), + 'render-blocking-resources' => array( + 'title' => 'Eliminate render-blocking resources', + 'displayValue' => 'Potential savings of 1,200 ms', + 'score' => 0.1, + 'details' => array( 'type' => 'opportunity' ), + ), + 'already-good-opportunity' => array( + 'title' => 'Already good', + 'score' => 1.0, + 'details' => array( 'type' => 'opportunity' ), + ), + 'not-an-opportunity' => array( + 'title' => 'Diagnostic', + 'score' => 0.1, + 'details' => array( 'type' => 'table' ), + ), + ), + ), + ) + ); + + $this->mock_http( 200, $body ); + + $provider = new AIPA_Provider_PageSpeed(); + $data = $provider->collect(); + + $this->assertSame( 42, $data['performance_score'] ); + $this->assertSame( '4.2 s', $data['metrics']['largest-contentful-paint'] ); + $this->assertSame( '0.05', $data['metrics']['cumulative-layout-shift'] ); + $this->assertSame( home_url( '/' ), $data['url'] ); + $this->assertSame( 'mobile', $data['strategy'] ); + + $titles = wp_list_pluck( $data['opportunities'], 'title' ); + $this->assertContains( 'Eliminate render-blocking resources', $titles ); + $this->assertNotContains( 'Already good', $titles ); + $this->assertNotContains( 'Diagnostic', $titles ); + + // The result is cached, so a second call does not perform another request. + $this->http_calls = 0; + $cached = $provider->collect(); + $this->assertSame( $data, $cached ); + $this->assertSame( 0, $this->http_calls ); + } + + /** + * @covers AIPA_Provider_PageSpeed + */ + public function test_pagespeed_provider_includes_api_key_in_request(): void { + update_option( + 'aipa_settings', + array( + 'include_pagespeed' => true, + 'pagespeed_api_key' => 'secret-key', + ) + ); + $this->mock_http( 200, (string) wp_json_encode( array( 'lighthouseResult' => array( 'audits' => array() ) ) ) ); + + ( new AIPA_Provider_PageSpeed() )->collect(); + + $this->assertStringContainsString( 'key=secret-key', $this->last_http_url ); + } + + /** + * @covers AIPA_Provider_PageSpeed + */ + public function test_pagespeed_provider_handles_http_error(): void { + add_filter( + 'pre_http_request', + static function () { + return new WP_Error( 'http_request_failed', 'Network down' ); + } + ); + $data = ( new AIPA_Provider_PageSpeed() )->collect(); + $this->assertArrayHasKey( 'error', $data ); + $this->assertStringContainsString( 'Network down', $data['error'] ); + } + + /** + * @covers AIPA_Provider_PageSpeed + */ + public function test_pagespeed_provider_handles_non_200(): void { + $this->mock_http( 500, 'Server error' ); + $data = ( new AIPA_Provider_PageSpeed() )->collect(); + $this->assertArrayHasKey( 'error', $data ); + $this->assertStringContainsString( '500', $data['error'] ); + } + + /** + * @covers AIPA_Provider_PageSpeed + */ + public function test_pagespeed_provider_handles_unparsable_body(): void { + $this->mock_http( 200, 'this is not json' ); + $data = ( new AIPA_Provider_PageSpeed() )->collect(); + $this->assertArrayHasKey( 'error', $data ); + } + + /** + * @covers AIPA_Provider_PageSpeed + */ + public function test_pagespeed_provider_returns_cached_value(): void { + $cached = array( + 'performance_score' => 99, + 'metrics' => array(), + 'opportunities' => array(), + ); + set_transient( AIPA_Provider_PageSpeed::CACHE_KEY, $cached, HOUR_IN_SECONDS ); + + $this->http_calls = 0; + add_filter( + 'pre_http_request', + function () { + ++$this->http_calls; + return new WP_Error( 'should_not_run', 'unexpected' ); + } + ); + + $data = ( new AIPA_Provider_PageSpeed() )->collect(); + $this->assertSame( $cached, $data ); + $this->assertSame( 0, $this->http_calls ); + } + + /** + * @covers AIPA_Context_Provider_Registry + */ + public function test_registry_register_unregister_and_available(): void { + $registry = new AIPA_Context_Provider_Registry(); + $registry->register( new AIPA_Fake_Context_Provider() ); + $registry->register( new AIPA_Unavailable_Context_Provider() ); + + $available = $registry->get_available_providers(); + $this->assertArrayHasKey( 'fake', $available ); + $this->assertArrayNotHasKey( 'unavailable', $available ); + + $this->assertSame( array( 'Fake provider' ), $registry->get_available_labels() ); + + $registry->unregister( 'fake' ); + $this->assertArrayNotHasKey( 'fake', $registry->get_available_providers() ); + } + + /** + * @covers AIPA_Context_Provider_Registry + */ + public function test_registry_collect_skips_empty_and_unavailable_and_applies_filter(): void { + $registry = new AIPA_Context_Provider_Registry(); + $registry->register( new AIPA_Fake_Context_Provider() ); + $registry->register( new AIPA_Empty_Context_Provider() ); + $registry->register( new AIPA_Unavailable_Context_Provider() ); + + $context = $registry->collect(); + $this->assertArrayHasKey( 'fake', $context ); + // A provider that returns no data contributes no key. + $this->assertArrayNotHasKey( 'empty', $context ); + // An unavailable provider is never collected. + $this->assertArrayNotHasKey( 'unavailable', $context ); + + add_filter( + 'aipa_context', + static function ( array $ctx ): array { + $ctx['injected'] = array( 'hello' => 'world' ); + return $ctx; + } + ); + $filtered = $registry->collect(); + $this->assertArrayHasKey( 'injected', $filtered ); + } + + /** + * Number of mocked HTTP requests performed. + * + * @var int + */ + private int $http_calls = 0; + + /** + * The URL of the most recent mocked HTTP request. + * + * @var string + */ + private string $last_http_url = ''; + + /** + * Intercepts outbound HTTP requests and returns a canned response. + * + * @param int $code Response status code. + * @param string $body Response body. + */ + private function mock_http( int $code, string $body ): void { + $this->http_calls = 0; + $this->last_http_url = ''; + add_filter( + 'pre_http_request', + function ( $preempt, $args, $url ) use ( $code, $body ) { + ++$this->http_calls; + $this->last_http_url = (string) $url; + return array( + 'headers' => array(), + 'body' => $body, + 'response' => array( + 'code' => $code, + 'message' => 'OK', + ), + 'cookies' => array(), + 'filename' => '', + ); + }, + 10, + 3 + ); + } +} diff --git a/plugins/ai-performance-advisor/tests/test-helper.php b/plugins/ai-performance-advisor/tests/test-helper.php new file mode 100644 index 0000000000..055ca43cb7 --- /dev/null +++ b/plugins/ai-performance-advisor/tests/test-helper.php @@ -0,0 +1,315 @@ +assertFalse( aipa_is_ai_available() ); + } + + /** + * @covers ::aipa_is_ai_available + */ + public function test_is_ai_available_can_be_forced_on(): void { + add_filter( 'aipa_pre_is_ai_available', '__return_true' ); + $this->assertTrue( aipa_is_ai_available() ); + } + + /** + * @covers ::aipa_is_ai_available + */ + public function test_is_ai_available_can_be_forced_off(): void { + add_filter( 'aipa_pre_is_ai_available', '__return_false' ); + $this->assertFalse( aipa_is_ai_available() ); + } + + /** + * @covers ::aipa_get_default_settings + */ + public function test_default_settings(): void { + $defaults = aipa_get_default_settings(); + $this->assertArrayHasKey( 'include_pagespeed', $defaults ); + $this->assertArrayHasKey( 'pagespeed_api_key', $defaults ); + $this->assertTrue( $defaults['include_pagespeed'] ); + $this->assertSame( '', $defaults['pagespeed_api_key'] ); + } + + /** + * @covers ::aipa_get_settings + */ + public function test_get_settings_merges_defaults(): void { + update_option( 'aipa_settings', array( 'include_pagespeed' => false ) ); + $settings = aipa_get_settings(); + $this->assertFalse( $settings['include_pagespeed'] ); + $this->assertSame( '', $settings['pagespeed_api_key'] ); + } + + /** + * @covers ::aipa_get_settings + */ + public function test_get_settings_tolerates_non_array_option(): void { + update_option( 'aipa_settings', 'not-an-array' ); + $settings = aipa_get_settings(); + $this->assertTrue( $settings['include_pagespeed'] ); + $this->assertSame( '', $settings['pagespeed_api_key'] ); + } + + /** + * @covers ::aipa_get_severities + * @covers ::aipa_get_categories + */ + public function test_severities_and_categories(): void { + $this->assertSame( array( 'critical', 'recommended', 'good', 'info' ), aipa_get_severities() ); + $this->assertContains( 'images', aipa_get_categories() ); + $this->assertContains( 'other', aipa_get_categories() ); + } + + /** + * @covers ::aipa_sanitize_recommendations + * @covers ::aipa_sanitize_recommendation_action + */ + public function test_sanitize_recommendations_returns_empty_for_non_array(): void { + $this->assertSame( array(), aipa_sanitize_recommendations( 'nope' ) ); + $this->assertSame( array(), aipa_sanitize_recommendations( null ) ); + } + + /** + * @covers ::aipa_sanitize_recommendations + * @covers ::aipa_sanitize_recommendation_action + */ + public function test_sanitize_recommendations_drops_invalid_and_sorts(): void { + $raw = array( + 'not-an-array-item', + array( + 'title' => 'Low priority tip', + 'summary' => 'Do this eventually.', + 'severity' => 'info', + 'category' => 'other', + ), + array( + // Missing summary, should be dropped. + 'title' => 'Incomplete', + ), + array( + 'title' => 'Urgent fix', + 'summary' => 'Fix this now.', + 'severity' => 'critical', + 'category' => 'images', + 'evidence' => array( 'LCP is 5s', '', 42 ), + 'details' => 'Use a **CDN** and remove blocking JS.', + ), + array( + 'title' => 'Bad severity', + 'summary' => 'Defaults applied.', + 'severity' => 'not-a-severity', + 'category' => 'not-a-category', + ), + ); + + $clean = aipa_sanitize_recommendations( $raw ); + + $this->assertCount( 3, $clean ); + // Critical sorts first. + $this->assertSame( 'Urgent fix', $clean[0]['title'] ); + $this->assertSame( 'critical', $clean[0]['severity'] ); + // Only valid, non-empty string evidence lines are kept. + $this->assertSame( array( 'LCP is 5s' ), $clean[0]['evidence'] ); + // Details are run through wp_kses_post, stripping the script tag. + $this->assertStringNotContainsString( '