'and', 0 => array( 'taxonomy' => 'product_visibility', 'terms' => array( get_term_by( 'slug', 'outofstock', 'product_visibility' )->term_taxonomy_id, get_term_by( 'slug', 'exclude-from-catalog', 'product_visibility' )->term_taxonomy_id, ), 'field' => 'term_taxonomy_id', 'operator' => 'NOT IN', ), ); if ( ! empty( $filter_colors ) ) { array_push( $tax_query, array( 'taxonomy' => 'pa_color', 'terms' => $filter_colors, 'field' => 'slug', 'operator' => $filter_operation, ) ); } if ( ! empty( $filter_styles ) ) { array_push( $tax_query, array( 'taxonomy' => 'pa_style', 'terms' => $filter_styles, 'field' => 'slug', 'operator' => $filter_operation, ) ); } $sut = $this ->getMockBuilder( WC_Widget_Layered_Nav::class ) ->setMethods( array( 'get_main_tax_query', 'get_main_meta_query', 'get_main_search_query_sql' ) ) ->getMock(); $sut->method( 'get_main_tax_query' )->willReturn( $tax_query ); $sut->method( 'get_main_meta_query' )->willReturn( array() ); $sut->method( 'get_main_search_query_sql' )->willReturn( null ); return $sut; } /** * Create a simple or variable product that has color attributes. * If a variable product is created, a variation will be created for each color. * * @param string $name Name of the product. * @param array $colors_in_stock Slugs of the colors whose variations will have stock. If null, a simple product is created. * @param array $colors_disabled Slugs of the colors whose variations will be disabled, N/A for a simple product. * @param array $styles Array where the key is the colors and the value is the style that will have the variation for that color. * * @return WC_Product_Simple|WC_Product_Variable The created product. */ private function create_colored_product( $name, $colors_in_stock, $colors_disabled = array(), $styles = array() ) { $create_as_simple = is_null( $colors_in_stock ); $main_product = $create_as_simple ? new WC_Product_Simple() : new WC_Product_Variable(); $main_product->set_props( array( 'name' => $name, 'sku' => 'SKU for' . $name, ) ); $existing_colors = array( 'black', 'brown', 'blue', 'green', 'pink', 'yellow' ); $existing_styles = array( 'classic', 'sport' ); $attributes = array( WC_Helper_Product::create_product_attribute_object( 'color', $existing_colors ), WC_Helper_Product::create_product_attribute_object( 'style', $existing_styles ), ); $main_product->set_attributes( $attributes ); $main_product->save(); if ( $create_as_simple ) { return $main_product; } $variation_objects = array(); foreach ( $existing_colors as $color ) { $variation_attributes = array( 'pa_color' => $color, 'pa_style' => array_key_exists( $color, $styles ) ? $styles[ $color ] : '', ); $variation_object = WC_Helper_Product::create_product_variation_object( $main_product->get_id(), "SKU for $color $name", 10, $variation_attributes ); if ( ! in_array( $color, $colors_in_stock, true ) ) { $variation_object->set_stock_status( 'outofstock' ); } $variation_object->save(); if ( in_array( $color, $colors_disabled, true ) ) { wp_update_post( array( 'ID' => $variation_object->get_id(), 'post_status' => 'draft', ) ); } array_push( $variation_objects, $variation_object->get_id() ); } $main_product->set_children( $variation_objects ); return $main_product; } /** * Invoke a protected method in an object. * * @param object $object Object whose method will be invoked. * @param string $method Name of the method to invoke. * @param array $args Arguments for the method. * * @return mixed Result from the method invocation. * @throws ReflectionException Error when dealing with reflection. */ private function invoke_protected( $object, $method, $args ) { $class = new ReflectionClass( $object ); $method = $class->getMethod( $method ); $method->setAccessible( true ); return $method->invokeArgs( $object, $args ); } /** * Invokes the get_filtered_term_product_counts method on an instance the widget, * for a given filtering request, and returns the resulting counts. * * @param string $operator Operator in the filtering request. * @param array $colors Slugs of the colors included in the filtering request. * @param array $styles Array where the key is the colors and the value is the style that will have the variation for that color. * * @return array An associative array where the keys are the color slugs and the values are the counts for each color. * @throws ReflectionException Error when dealing with reflection to invoke the method. */ private function run_get_filtered_term_product_counts( $operator, $colors, $styles = array() ) { $sut = $this->get_widget( $operator, $colors, $styles ); $color_terms = get_terms( 'pa_color', array( 'hide_empty' => '1' ) ); $color_term_ids = wp_list_pluck( $color_terms, 'term_id' ); $color_term_names = wp_list_pluck( $color_terms, 'slug' ); $color_names_by_id = array_combine( $color_term_ids, $color_term_names ); $counts = $this->invoke_protected( $sut, 'get_filtered_term_product_counts', array( $color_term_ids, 'pa_color', $operator, ) ); $counts_by_name = array(); foreach ( $counts as $id => $count ) { $counts_by_name[ $color_names_by_id[ $id ] ] = $count; } return $counts_by_name; } /** * Changes the status of a post to 'draft'. * * @param int $post_id Id of the post to change. */ private function set_post_as_draft( $post_id ) { global $wpdb; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared $wpdb->query( 'update ' . $wpdb->posts . " set post_status='draft' where ID=" . $post_id ); } /** * Data provider for test_product_count_per_attribute. * * @return array[] */ public function data_provider_for_test_product_count_per_attribute() { return array( // OR filtering, no attributes selected. // Should count all the visible variations of all the products. array( 'or', array(), false, array( 'black' => 1, 'brown' => 2, 'blue' => 2, 'green' => 2, 'pink' => 1, 'yellow' => 1, ), ), // OR filtering, some attributes selected // (doesn't matter, the result is the same as in the previous case). array( 'or', array( 'black', 'green' ), false, array( 'black' => 1, 'brown' => 2, 'blue' => 2, 'green' => 2, 'pink' => 1, 'yellow' => 1, ), ), // OR filtering, no attributes selected. Simple product is created too. // Now it should include all the attributes of the simple product too. array( 'or', array(), true, array( 'black' => 2, 'brown' => 3, 'blue' => 3, 'green' => 3, 'pink' => 2, 'yellow' => 2, ), ), // OR filtering, some attributes selected, Simple product is created too. // Again, the attributes selected don't change the result. array( 'or', array( 'black', 'green' ), true, array( 'black' => 2, 'brown' => 3, 'blue' => 3, 'green' => 3, 'pink' => 2, 'yellow' => 2, ), ), // AND filtering, no attributes selected. // Should count all the visible variations of all the products as in the 'or' case. array( 'and', array(), false, array( 'black' => 1, 'brown' => 2, 'blue' => 2, 'green' => 2, 'pink' => 1, 'yellow' => 1, ), ), // AND filtering, one attribute selected. // Should still count the visible variations for all products as in the 'or' case. array( 'and', array( 'green' ), false, array( 'black' => 1, 'brown' => 2, 'blue' => 2, 'green' => 2, 'pink' => 1, 'yellow' => 1, ), ), // AND filtering, more than one attribute selected. // Should still count the visible variations for all products as in the 'or' case. array( 'and', array( 'green', 'pink' ), false, array( 'black' => 1, 'brown' => 2, 'blue' => 2, 'green' => 2, 'pink' => 1, 'yellow' => 1, ), ), // AND filtering, no attributes selected, include simple product too. // Same case as 'or': it should include all the attributes of the simple product too. array( 'and', array(), true, array( 'black' => 2, 'brown' => 3, 'blue' => 3, 'green' => 3, 'pink' => 2, 'yellow' => 2, ), ), // AND filtering, select one attribute, include simple product too. // Again, the simple product is now included in all counters, since it has the selected attribute. array( 'and', array( 'green' ), true, array( 'black' => 2, 'brown' => 3, 'blue' => 3, 'green' => 3, 'pink' => 2, 'yellow' => 2, ), ), // AND filtering, select a couple of attributes, include simple product too. // The simple product is still included too in all counters, since it has all of the selected attributes. array( 'and', array( 'green', 'pink' ), true, array( 'black' => 2, 'brown' => 3, 'blue' => 3, 'green' => 3, 'pink' => 2, 'yellow' => 2, ), ), ); } /** * @testdox Test that the counters are correct for different filtering combinations, see the data provider method for details. * * @dataProvider data_provider_for_test_product_count_per_attribute * * @param string $filter_operator Filtering operator to use, 'or' or 'and'. * @param array $filter_terms Slugs of the colors selected for filtering. * @param bool $create_simple_product_too If true, create one simple product too. If false, create only the variable products. * @param array $expected_counts An associative array where the keys are the color slugs and the values are the counts for each color. */ public function test_product_count_per_attribute( $filter_operator, $filter_terms, $create_simple_product_too, $expected_counts ) { if ( $create_simple_product_too ) { $this->create_colored_product( 'Something with many colors', null ); } $this->create_colored_product( 'Big shoes', array( 'black', 'brown' ) ); $this->create_colored_product( 'Medium shoes', array( 'blue', 'brown' ) ); $this->create_colored_product( 'Small shoes', array( 'blue', 'green' ) ); $this->create_colored_product( 'Kids shoes', array( 'green', 'pink', 'yellow' ) ); $counts = $this->run_get_filtered_term_product_counts( $filter_operator, $filter_terms ); $this->assertEquals( $expected_counts, $counts ); } /** * @testdox Test that the counters are correct when using more than one filter simultaneously. * */ public function test_product_count_per_multiple_attributes() { $this->create_colored_product( 'Big shoes', array( 'black', 'brown' ) ); $this->create_colored_product( 'Medium shoes', array( 'blue', 'brown' ), array(), array( 'blue' => 'sport', 'brown' => 'classic', ) ); $this->create_colored_product( 'Small shoes', array( 'blue', 'green' ), array(), array( 'blue' => 'classic', 'green' => 'sport', ) ); $this->create_colored_product( 'Kids shoes', array( 'green', 'pink', 'yellow', 'blue' ), array(), array( 'green' => 'classic', 'blue' => 'classic', ) ); $counts = $this->run_get_filtered_term_product_counts( 'IN', array( 'blue' ), array( 'classic' ) ); $expected_counts = array( 'brown' => 1, 'blue' => 2, 'green' => 1, ); $this->assertEquals( $expected_counts, $counts ); } /** * @testdox When a variable product is not published, none of its variations should be included in the counts. * * @throws ReflectionException Error when dealing with reflection to invoke the tested method. */ public function test_product_count_per_attribute_with_parent_not_published() { $this->create_colored_product( 'Big shoes', array( 'black', 'brown' ) ); $medium = $this->create_colored_product( 'Medium shoes', array( 'blue', 'brown' ) ); $this->set_post_as_draft( $medium->get_id() ); $actual = $this->run_get_filtered_term_product_counts( 'or', array() ); $expected = array( 'black' => 1, 'brown' => 1, ); $this->assertEquals( $expected, $actual ); } /** * @testdox When a variation is not published it should not be included in the counts (but other variations of the same product should). * * @throws ReflectionException Error when dealing with reflection to invoke the tested method. */ public function test_product_count_per_attribute_with_variation_not_published() { $this->create_colored_product( 'Big shoes', array( 'black', 'brown' ) ); $this->create_colored_product( 'Medium shoes', array( 'blue', 'brown' ), array( 'brown' ) ); $actual = $this->run_get_filtered_term_product_counts( 'or', array() ); $expected = array( 'black' => 1, 'brown' => 1, 'blue' => 1, ); $this->assertEquals( $expected, $actual ); } }