diff --git a/.phpcs.xml.dist b/.phpcs.xml.dist index 02b110c..fabf828 100644 --- a/.phpcs.xml.dist +++ b/.phpcs.xml.dist @@ -53,6 +53,8 @@ + + diff --git a/src/class-acm-widget.php b/src/class-acm-widget.php index b3e5d20..ec5724e 100644 --- a/src/class-acm-widget.php +++ b/src/class-acm-widget.php @@ -60,13 +60,35 @@ function update( $new_instance, $old_instance ) { // Display the widget function widget( $args, $instance ) { + // Capture the ad content to check if we have anything to display. + // This fixes issue #72: don't output empty widget wrapper when no ad code found. + ob_start(); + do_action( 'acm_tag', $instance['ad_zone'] ); + $ad_content = ob_get_clean(); + + /** + * Filters whether to display the widget when no ad content is found. + * + * @since 0.8.0 + * + * @param bool $display Whether to display the widget. Default false. + * @param string $ad_zone The ad zone ID. + * @param array $args Widget display arguments. + * @param array $instance Widget instance settings. + */ + if ( empty( $ad_content ) && ! apply_filters( 'acm_display_empty_widget', false, $instance['ad_zone'], $args, $instance ) ) { + return; + } + echo $args['before_widget']; $title = apply_filters( 'widget_title', $instance['title'] ); if ( ! empty( $title ) ) { echo $args['before_title'] . esc_html( $title ) . $args['after_title']; } - do_action( 'acm_tag', $instance['ad_zone'] ); + + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Ad content is escaped during token replacement in get_acm_tag(). + echo $ad_content; echo $args['after_widget']; } } diff --git a/tests/Integration/WidgetTest.php b/tests/Integration/WidgetTest.php new file mode 100644 index 0000000..3c3ac05 --- /dev/null +++ b/tests/Integration/WidgetTest.php @@ -0,0 +1,297 @@ +widget = new ACM_Ad_Zones(); + } + + /** + * Test widget outputs nothing when no ad code is found. + * + * This is the main fix for issue #72 - the widget should not output + * empty wrapper HTML when there's no ad content to display. + * + * @covers ACM_Ad_Zones::widget + */ + public function test_widget_outputs_nothing_when_no_ad_code_found(): void { + $args = array( + 'before_widget' => '
', + 'after_widget' => '
', + 'before_title' => '

', + 'after_title' => '

', + ); + $instance = array( + 'title' => 'Test Ad', + 'ad_zone' => 'nonexistent_zone', + ); + + ob_start(); + $this->widget->widget( $args, $instance ); + $output = ob_get_clean(); + + $this->assertEmpty( $output, 'Widget should output nothing when no ad code is found.' ); + } + + /** + * Test widget outputs nothing for empty ad zone. + * + * @covers ACM_Ad_Zones::widget + */ + public function test_widget_outputs_nothing_for_empty_ad_zone(): void { + $args = array( + 'before_widget' => '
', + 'after_widget' => '
', + 'before_title' => '

', + 'after_title' => '

', + ); + $instance = array( + 'title' => '', + 'ad_zone' => '', + ); + + ob_start(); + $this->widget->widget( $args, $instance ); + $output = ob_get_clean(); + + $this->assertEmpty( $output, 'Widget should output nothing for empty ad zone.' ); + } + + /** + * Test widget outputs wrapper when filter returns true for empty content. + * + * The acm_display_empty_widget filter allows themes to force display + * of the widget wrapper even when no ad content is found. + * + * @covers ACM_Ad_Zones::widget + */ + public function test_widget_outputs_wrapper_when_filter_returns_true(): void { + add_filter( 'acm_display_empty_widget', '__return_true' ); + + $args = array( + 'before_widget' => '
', + 'after_widget' => '
', + 'before_title' => '

', + 'after_title' => '

', + ); + $instance = array( + 'title' => '', + 'ad_zone' => 'nonexistent_zone', + ); + + ob_start(); + $this->widget->widget( $args, $instance ); + $output = ob_get_clean(); + + remove_filter( 'acm_display_empty_widget', '__return_true' ); + + $this->assertStringContainsString( '
', $output, 'Widget should output wrapper when filter returns true.' ); + $this->assertStringContainsString( '
', $output, 'Widget should output closing wrapper when filter returns true.' ); + } + + /** + * Test widget outputs title when filter allows empty content. + * + * @covers ACM_Ad_Zones::widget + */ + public function test_widget_outputs_title_when_filter_allows_empty_content(): void { + add_filter( 'acm_display_empty_widget', '__return_true' ); + + $args = array( + 'before_widget' => '
', + 'after_widget' => '
', + 'before_title' => '

', + 'after_title' => '

', + ); + $instance = array( + 'title' => 'Advertisement', + 'ad_zone' => 'nonexistent_zone', + ); + + ob_start(); + $this->widget->widget( $args, $instance ); + $output = ob_get_clean(); + + remove_filter( 'acm_display_empty_widget', '__return_true' ); + + $this->assertStringContainsString( '

Advertisement

', $output, 'Widget should output title when filter allows.' ); + } + + /** + * Test acm_display_empty_widget filter receives correct arguments. + * + * @covers ACM_Ad_Zones::widget + */ + public function test_filter_receives_correct_arguments(): void { + $received_args = array(); + + $filter_callback = function ( $display, $ad_zone, $args, $instance ) use ( &$received_args ) { + $received_args = array( + 'display' => $display, + 'ad_zone' => $ad_zone, + 'args' => $args, + 'instance' => $instance, + ); + return false; + }; + + add_filter( 'acm_display_empty_widget', $filter_callback, 10, 4 ); + + $args = array( + 'before_widget' => '
', + 'after_widget' => '
', + 'before_title' => '

', + 'after_title' => '

', + ); + $instance = array( + 'title' => 'Test', + 'ad_zone' => 'test_zone', + ); + + ob_start(); + $this->widget->widget( $args, $instance ); + ob_get_clean(); + + remove_filter( 'acm_display_empty_widget', $filter_callback, 10 ); + + $this->assertFalse( $received_args['display'], 'First argument should be false.' ); + $this->assertSame( 'test_zone', $received_args['ad_zone'], 'Second argument should be the ad zone.' ); + $this->assertSame( $args, $received_args['args'], 'Third argument should be widget args.' ); + $this->assertSame( $instance, $received_args['instance'], 'Fourth argument should be widget instance.' ); + } + + /** + * Test widget with valid ad code outputs content. + * + * @covers ACM_Ad_Zones::widget + */ + public function test_widget_outputs_content_with_valid_ad_code(): void { + // Create a valid ad code. + $ad_code_data = array(); + foreach ( $this->acm->current_provider->ad_code_args as $arg ) { + $ad_code_data[ $arg['key'] ] = 'test_value_' . $arg['key']; + } + $ad_code_data['priority'] = 10; + $ad_code_data['operator'] = 'AND'; + + // Need to set up a tag that's registered. + // First, let's check what tags are available. + $ad_tag_ids = $this->acm->ad_tag_ids; + if ( empty( $ad_tag_ids ) ) { + $this->markTestSkipped( 'No ad tag IDs available for testing.' ); + } + + // Get the first tag with enable_ui_mapping. + $test_tag = null; + foreach ( $ad_tag_ids as $tag ) { + if ( isset( $tag['enable_ui_mapping'] ) && $tag['enable_ui_mapping'] ) { + $test_tag = $tag['tag']; + break; + } + } + + if ( ! $test_tag ) { + $this->markTestSkipped( 'No tags with enable_ui_mapping available for testing.' ); + } + + // Set the tag in ad code data. + $ad_code_data['tag'] = $test_tag; + + // Create the ad code. + $ad_code_id = $this->acm->create_ad_code( $ad_code_data ); + $this->assertIsInt( $ad_code_id, 'Ad code should be created successfully.' ); + + // Register ad codes to make them available. + $this->acm->register_ad_codes( $this->acm->get_ad_codes() ); + + // Enable display of ad codes without conditionals. + add_filter( 'acm_display_ad_codes_without_conditionals', '__return_true' ); + + $args = array( + 'before_widget' => '
', + 'after_widget' => '
', + 'before_title' => '

', + 'after_title' => '

', + ); + $instance = array( + 'title' => 'Ad Widget', + 'ad_zone' => $test_tag, + ); + + ob_start(); + $this->widget->widget( $args, $instance ); + $output = ob_get_clean(); + + remove_filter( 'acm_display_ad_codes_without_conditionals', '__return_true' ); + + // Clean up. + $this->acm->delete_ad_code( $ad_code_id ); + + $this->assertStringContainsString( '
', $output, 'Widget should output wrapper with valid ad code.' ); + $this->assertStringContainsString( '
', $output, 'Widget should output closing wrapper.' ); + } + + /** + * Test that acm_tag action is still fired (backwards compatibility). + * + * @covers ACM_Ad_Zones::widget + */ + public function test_acm_tag_action_is_fired(): void { + $action_fired = false; + $action_tag = null; + + $action_callback = function ( $tag_id ) use ( &$action_fired, &$action_tag ) { + $action_fired = true; + $action_tag = $tag_id; + }; + + add_action( 'acm_tag', $action_callback, 5 ); // Priority 5 to run before the default handler. + + $args = array( + 'before_widget' => '
', + 'after_widget' => '
', + 'before_title' => '

', + 'after_title' => '

', + ); + $instance = array( + 'title' => '', + 'ad_zone' => 'test_action_zone', + ); + + ob_start(); + $this->widget->widget( $args, $instance ); + ob_get_clean(); + + remove_action( 'acm_tag', $action_callback, 5 ); + + $this->assertTrue( $action_fired, 'acm_tag action should be fired for backwards compatibility.' ); + $this->assertSame( 'test_action_zone', $action_tag, 'acm_tag action should receive the correct tag ID.' ); + } +} diff --git a/tests/Unit/AdCodeManagerTest.php b/tests/Unit/AdCodeManagerTest.php index 72827fe..c059873 100644 --- a/tests/Unit/AdCodeManagerTest.php +++ b/tests/Unit/AdCodeManagerTest.php @@ -32,17 +32,17 @@ protected function setUp(): void { parent::setUp(); // Stub WordPress functions. - Functions\stubs( [ '__' => null ] ); + Functions\stubs( array( '__' => null ) ); $this->ad_code_manager = new Ad_Code_Manager(); // Create a mock provider with whitelisted URLs. - $this->ad_code_manager->current_provider = new stdClass(); - $this->ad_code_manager->current_provider->whitelisted_script_urls = [ + $this->ad_code_manager->current_provider = new stdClass(); + $this->ad_code_manager->current_provider->whitelisted_script_urls = array( 'example.com', 'ads.google.com', 'secure.pagead2.googlesyndication.com', - ]; + ); } /** @@ -124,16 +124,16 @@ public function testValidateScriptUrlDeepSubdomain(): void { * Test filter_output_tokens adds URL vars as tokens. */ public function testFilterOutputTokensAddsUrlVars(): void { - $code_to_display = [ - 'url_vars' => [ - 'site_id' => '12345', - 'zone' => 'header', - 'width' => '728', - 'height' => '90', - ], - ]; + $code_to_display = array( + 'url_vars' => array( + 'site_id' => '12345', + 'zone' => 'header', + 'width' => '728', + 'height' => '90', + ), + ); - $output_tokens = $this->ad_code_manager->filter_output_tokens( [], 'test_tag', $code_to_display ); + $output_tokens = $this->ad_code_manager->filter_output_tokens( array(), 'test_tag', $code_to_display ); $this->assertArrayHasKey( '%site_id%', $output_tokens ); $this->assertArrayHasKey( '%zone%', $output_tokens ); @@ -147,8 +147,8 @@ public function testFilterOutputTokensAddsUrlVars(): void { * Test filter_output_tokens returns original tokens when no URL vars. */ public function testFilterOutputTokensReturnsOriginalWhenNoUrlVars(): void { - $code_to_display = []; - $original_tokens = [ '%existing%' => 'value' ]; + $code_to_display = array(); + $original_tokens = array( '%existing%' => 'value' ); $output_tokens = $this->ad_code_manager->filter_output_tokens( $original_tokens, @@ -163,12 +163,12 @@ public function testFilterOutputTokensReturnsOriginalWhenNoUrlVars(): void { * Test filter_output_tokens preserves existing tokens. */ public function testFilterOutputTokensPreservesExistingTokens(): void { - $code_to_display = [ - 'url_vars' => [ + $code_to_display = array( + 'url_vars' => array( 'new_var' => 'new_value', - ], - ]; - $original_tokens = [ '%existing%' => 'value' ]; + ), + ); + $original_tokens = array( '%existing%' => 'value' ); $output_tokens = $this->ad_code_manager->filter_output_tokens( $original_tokens, @@ -179,4 +179,64 @@ public function testFilterOutputTokensPreservesExistingTokens(): void { $this->assertArrayHasKey( '%existing%', $output_tokens ); $this->assertArrayHasKey( '%new_var%', $output_tokens ); } + + /** + * Test get_acm_tag returns empty string when ad rendering is disabled. + * + * @covers Ad_Code_Manager::get_acm_tag + */ + public function testGetAcmTagReturnsEmptyWhenRenderingDisabled(): void { + Functions\when( 'is_preview' )->justReturn( false ); + Functions\when( 'apply_filters' )->alias( + function ( $filter_name, $value ) { + if ( 'acm_disable_ad_rendering' === $filter_name ) { + return true; // Disable rendering. + } + return $value; + } + ); + + $result = $this->ad_code_manager->get_acm_tag( 'test_tag' ); + + $this->assertSame( '', $result ); + } + + /** + * Test get_acm_tag returns empty string when no ad codes registered for tag. + * + * This verifies the fix for issue #72 - no broken HTML output when no ad codes found. + * + * @covers Ad_Code_Manager::get_acm_tag + */ + public function testGetAcmTagReturnsEmptyWhenNoAdCodesRegistered(): void { + Functions\when( 'apply_filters' )->alias( + function ( $filter_name, $value ) { + if ( 'acm_disable_ad_rendering' === $filter_name ) { + return false; // Rendering enabled. + } + return $value; + } + ); + Functions\when( 'is_preview' )->justReturn( false ); + Functions\when( 'wp_cache_get' )->justReturn( false ); + Functions\when( 'wp_cache_add' )->justReturn( true ); + + // Ensure no ad codes are registered for this tag. + $this->assertArrayNotHasKey( 'nonexistent_tag', $this->ad_code_manager->ad_codes ); + + $result = $this->ad_code_manager->get_acm_tag( 'nonexistent_tag' ); + + $this->assertSame( '', $result ); + } + + /** + * Test get_matching_ad_code returns null when tag has no registered ad codes. + * + * @covers Ad_Code_Manager::get_matching_ad_code + */ + public function testGetMatchingAdCodeReturnsNullWhenNoAdCodes(): void { + $result = $this->ad_code_manager->get_matching_ad_code( 'nonexistent_tag' ); + + $this->assertNull( $result ); + } }