Commit 5ae90950a41 for woocommerce
commit 5ae90950a4142ee72c537c8eec86699582de66f7
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Wed Sep 2 16:46:16 2026 +0300
Fix missing selected Any variation values in item names (#67527)
* fix: include selected Any variation values in item names
When a variation attribute is configured as "Any", the shopper-selected
value exists only in cart and checkout context, so item names on the
classic cart, mini-cart, checkout review and newly created order items
showed it as a detached meta row instead of merging it into the name
the way defined attribute values are merged.
A new internal SelectedVariationName service appends the missing
selected values to the stored variation name, following the same title
policy as WC_Product_Variation_Data_Store_CPT::generate_product_title().
Templates seed that contextual name through the new public
WC_Cart::get_item_product_name() as the initial value of the existing
woocommerce_cart_item_name filter, and pass it as the new optional
third argument of wc_get_formatted_cart_item_data() so the value is not
repeated as metadata. No global filters are added: outdated template
overrides keep their previous display instead of risking duplicated
values. Checkout persists the contextual name with raw attribute values
so order-side meta dedup keeps matching, and that dedup now compares
entity-decoded copies because wp_kses_post() normalizes special
characters that item names store raw.
Refs #36538
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: add selected Any variation name changelog
Refs #36538
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: correct version annotations for selected Any variation names
The branch was authored against 11.1.0-dev, but trunk has since moved
to 11.2.0-dev and the PR targets trunk with no backport milestone, so
the new symbols and the touched templates first ship in 11.2.0.
The separator hook's docblock also claimed 3.0.0; the hook first
appeared in 3.0.2 (it is absent from the 3.0.1 tag).
Refs #36538
* refactor: share the variation title policy through the data store
SelectedVariationName carried a private copy of the "should the title
include attributes" rules from
WC_Product_Variation_Data_Store_CPT::generate_product_title(), with a
comment asking future editors to keep the two in sync. Duplicated
policy drifts, and reviewers found the copy hard to reason about.
Move the rules into a public should_include_attributes_in_title()
method on the data store, keeping core's explanatory comments and
adding a (bool) cast on the filter result. generate_product_title()
delegates to it, and SelectedVariationName calls it through the
WC_Data_Store proxy guarded by has_callable(), falling back to the
stored variation name for third-party stores without the method. The
cast cannot change generate_product_title()'s outcome since the value
only feeds a ternary.
The new method is additive public surface on an overridable data
store class; subclasses may override it to change the policy for
stored and contextual names together.
Refs #36538
* fix: skip non-scalar cart variation values before term lookups
wc_get_formatted_cart_item_data() validated each variation value only
after get_term_by() and the woocommerce_variation_option_name filter
had already received it. get_term_by() casts slug lookups to string,
so an array value raised a warning and an object fataled before the
guard could run. Cart contents can be reshaped by filters, and the
checkout side already treats non-array variation data as empty.
Check the raw value at the top of the loop and skip anything that is
not a non-empty scalar. The post-filter check stays because the option
filter can still return an unrenderable value.
Refs #36538
* test: simplify the selected Any variation tests
WP_UnitTestCase backs up all hooks in set_up() and restores them in
tear_down(), so the remove_filter() calls these tests wrapped in
try/finally blocks were redundant and made the files inconsistent with
each other. Several tests also registered filters their code path
never reached, which implied coverage that did not exist, and one
persisted a fixture it never cleaned up.
Drop the hook cleanup and the inert filters, rename the affected tests
after the behaviour they actually verify, build the swapped product in
memory, and explain why a custom-attribute filter sees
"pa_attribute_finish". The malformed-variation checkout test stays: it
is what guards the array normalisation in front of
SelectedVariationName::get_product_name(), which otherwise throws a
TypeError on non-array cart data.
Refs #36538
* chore: note the order meta dedupe fix in the changelog
get_formatted_meta_data() now compares entity-decoded values, which
also stops attribute values containing characters such as "&" from
being repeated as a meta row on existing orders. That is a visible
change to historic orders and belongs in the release notes.
Refs #36538
* test: clear planted shipping session data after the cart session test
test_setting_session_should_not_clear_shipping_data_when_cart_is_not_empty
stores placeholder arrays under shipping_for_package_0/1 and never
removes them. WC_Cart_Session only clears those keys through a
persisted WC_Session_Handler, which the test session handlers never
provide, so the placeholders outlive the test. Any later test that
calculates shipping then fails inside WC_Shipping with
"Undefined array key package_hash", which add_to_cart() swallows into a
false return. Default order hides this because intermediate tests
overwrite the keys; random order (as used by infection's initial run)
exposes it.
Null the five planted keys once the assertions are done.
Refs #36538
* chore: describe only user-visible changes in the changelog
The entry listed the new WC_Cart::get_item_product_name() method, the
added wc_get_formatted_cart_item_data() parameter, and the overridden
template caveat. Changelog entries feed the release notes, where that
implementation detail tells a merchant nothing about what changed for
them.
Describe the visible behaviour instead. Keep "classic" on the cart and
checkout displays, since the blocks Cart and Checkout render names from
Store API responses and are unchanged, and keep "on existing orders",
since get_formatted_meta_data() runs at display time. The template
override caveat stays in the PR description's backward compatibility
section.
Refs #36538
* fix: decode URL-encoded variation values in cart item metadata
wc_get_formatted_cart_item_data() compared and displayed the raw
variation value while decoding only HTML entities. Variation titles
come from wc_get_formatted_variation(), which rawurldecodes, so a
stored attribute value carrying a percent escape never matched the
title. It was rendered a second time as a metadata row, and rendered
encoded: "Finish: Black%20White" sitting under a title reading
"Black White".
This reaches the cart when a fixed attribute holds such a value and
the item is added by variation ID without posted attributes, so the
value comes from the variation's own data rather than through
wc_clean(), which strips percent escapes before validation.
Decode the value after the term lookup and the option filter, before
both the title comparison and the metadata row, so the cart normalises
it the way the title already does. Callbacks on
woocommerce_get_item_data now receive decoded values; only values
containing percent escapes change.
Refs #36538
* chore: cover cart metadata dedupe in the changelog
The entry described the duplicate metadata fix as applying to order
item names only, but the same repeated row appears in the cart. Widen
the wording so the release notes cover both.
Refs #36538
* test: clear planted shipping session data in teardown
The cart session test nulled the shipping keys it planted only after
its own assertions, so a failing assertion skipped the cleanup.
Verified with a probe pair: shipping_for_package_0 does survive a
failed test, because the parent teardown never removes it —
WC_Shipping::reset_shipping() clears only chosen_shipping_methods. The
planted placeholder rates then make later shipping calculations fail on
a missing package hash. Two sibling tests in the file plant the same
keys and share the gap.
Move the cleanup into the class teardown, which runs whatever the
assertions do, and drop the now-redundant inline block.
Refs #36538
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/36538-selected-any-variation-names b/plugins/woocommerce/changelog/36538-selected-any-variation-names
new file mode 100644
index 00000000000..29961f046ea
--- /dev/null
+++ b/plugins/woocommerce/changelog/36538-selected-any-variation-names
@@ -0,0 +1,4 @@
+Significance: minor
+Type: fix
+
+Include selected "Any" variation values in classic cart and checkout displays and in new order item names, and stop variation values already shown in an item name from being repeated as cart or order metadata, including on existing orders.
diff --git a/plugins/woocommerce/includes/class-wc-cart.php b/plugins/woocommerce/includes/class-wc-cart.php
index ac2f70399dc..4e8ff09e292 100644
--- a/plugins/woocommerce/includes/class-wc-cart.php
+++ b/plugins/woocommerce/includes/class-wc-cart.php
@@ -13,6 +13,7 @@ use Automattic\WooCommerce\Blocks\Utils\CartCheckoutUtils;
use Automattic\WooCommerce\Enums\ProductStatus;
use Automattic\WooCommerce\Enums\ProductType;
use Automattic\WooCommerce\Enums\TaxDisplayMode;
+use Automattic\WooCommerce\Internal\ProductVariations\SelectedVariationName;
use Automattic\WooCommerce\Internal\Tax\TaxRateDataStore;
use Automattic\WooCommerce\StoreApi\Utilities\LocalPickupUtils;
use Automattic\WooCommerce\Utilities\DiscountsUtil;
@@ -939,6 +940,35 @@ class WC_Cart extends WC_Legacy_Cart {
return wc_get_formatted_cart_item_data( $cart_item, $flat );
}
+ /**
+ * Gets the display name for a cart item.
+ *
+ * For variations, selected "Any" attribute values that are missing from the
+ * stored variation name are appended so the name matches fully defined
+ * variations. The stored product and variation names are not modified.
+ *
+ * @since 11.2.0
+ * @param array $cart_item Cart item.
+ * @param WC_Product|null $product Optional product object to use as the name source,
+ * e.g. the result of the `woocommerce_cart_item_product` filter.
+ * Defaults to the cart item's product.
+ * @return string The product name including any selected "Any" attribute values,
+ * or an empty string when no product can be resolved from the arguments.
+ */
+ public function get_item_product_name( $cart_item, $product = null ) {
+ if ( ! $product instanceof WC_Product ) {
+ $product = is_array( $cart_item ) && isset( $cart_item['data'] ) && $cart_item['data'] instanceof WC_Product ? $cart_item['data'] : null;
+ }
+
+ if ( ! $product instanceof WC_Product ) {
+ return '';
+ }
+
+ $variation = isset( $cart_item['variation'] ) && is_array( $cart_item['variation'] ) ? $cart_item['variation'] : array();
+
+ return wc_get_container()->get( SelectedVariationName::class )->get_product_name( $product, $variation, true );
+ }
+
/**
* Gets cross sells based on the items in the cart.
*
diff --git a/plugins/woocommerce/includes/class-wc-checkout.php b/plugins/woocommerce/includes/class-wc-checkout.php
index 142a1c32ee2..418d0e7d579 100644
--- a/plugins/woocommerce/includes/class-wc-checkout.php
+++ b/plugins/woocommerce/includes/class-wc-checkout.php
@@ -12,6 +12,7 @@ use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\Enums\ProductTaxStatus;
use Automattic\WooCommerce\Enums\ProductType;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareTrait;
+use Automattic\WooCommerce\Internal\ProductVariations\SelectedVariationName;
use Automattic\WooCommerce\Internal\Tax\TaxRateDataStore;
defined( 'ABSPATH' ) || exit;
@@ -564,6 +565,8 @@ class WC_Checkout {
*/
public function create_order_line_items( &$order, $cart ) {
foreach ( $cart->get_cart() as $cart_item_key => $values ) {
+ $variation = is_array( $values['variation'] ?? null ) ? $values['variation'] : array();
+
/**
* Filter hook to get initial item object.
*
@@ -576,7 +579,7 @@ class WC_Checkout {
$item->set_props(
array(
'quantity' => $values['quantity'],
- 'variation' => $values['variation'],
+ 'variation' => $variation,
'subtotal' => $values['line_subtotal'],
'total' => $values['line_total'],
'subtotal_tax' => $values['line_subtotal_tax'],
@@ -590,7 +593,7 @@ class WC_Checkout {
if ( $product ) {
$item->set_props(
array(
- 'name' => $product->get_name(),
+ 'name' => wc_get_container()->get( SelectedVariationName::class )->get_product_name( $product, $variation ),
'tax_class' => $product->get_tax_class(),
'product_id' => $product->is_type( ProductType::VARIATION ) ? $product->get_parent_id() : $product->get_id(),
'variation_id' => $product->is_type( ProductType::VARIATION ) ? $product->get_id() : 0,
diff --git a/plugins/woocommerce/includes/class-wc-order-item.php b/plugins/woocommerce/includes/class-wc-order-item.php
index 22858fcd59a..62d3450793c 100644
--- a/plugins/woocommerce/includes/class-wc-order-item.php
+++ b/plugins/woocommerce/includes/class-wc-order-item.php
@@ -339,6 +339,10 @@ class WC_Order_Item extends WC_Data implements ArrayAccess {
$product = is_callable( array( $this, 'get_product' ) ) ? $this->get_product() : false;
$order_item_name = $this->get_name();
+ // Compare entity-decoded copies below: wp_kses_post() normalizes special characters
+ // in display values (e.g. "&" to "&") while the item name stores them raw.
+ $decoded_item_name = ! $include_all && $product && $product->is_type( ProductType::VARIATION ) ? wp_specialchars_decode( $order_item_name, ENT_QUOTES ) : null;
+
foreach ( $meta_data as $meta ) {
if ( empty( $meta->id ) || '' === $meta->value || ! is_scalar( $meta->value ) || ( $hideprefix_length && substr( $meta->key, 0, $hideprefix_length ) === $hideprefix ) ) {
continue;
@@ -358,7 +362,7 @@ class WC_Order_Item extends WC_Data implements ArrayAccess {
}
// Skip items with values already in the product details area of the product name.
- if ( ! $include_all && $product && $product->is_type( ProductType::VARIATION ) && wc_is_attribute_in_product_name( $display_value, $order_item_name ) ) {
+ if ( null !== $decoded_item_name && wc_is_attribute_in_product_name( wp_specialchars_decode( $display_value, ENT_QUOTES ), $decoded_item_name ) ) {
continue;
}
diff --git a/plugins/woocommerce/includes/data-stores/class-wc-product-variation-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/class-wc-product-variation-data-store-cpt.php
index f9ec70e6246..e23406c1a56 100644
--- a/plugins/woocommerce/includes/data-stores/class-wc-product-variation-data-store-cpt.php
+++ b/plugins/woocommerce/includes/data-stores/class-wc-product-variation-data-store-cpt.php
@@ -294,6 +294,42 @@ class WC_Product_Variation_Data_Store_CPT extends WC_Product_Data_Store_CPT impl
* @return string
*/
protected function generate_product_title( $product ) {
+ $should_include_attributes = $this->should_include_attributes_in_title( $product );
+
+ /**
+ * Filters the separator used between a variation product title and its attributes.
+ *
+ * @since 3.0.2
+ * @param string $separator Separator between the product title and attributes.
+ * @param WC_Product $product Variation product object.
+ */
+ $separator = apply_filters( 'woocommerce_product_variation_title_attributes_separator', ' - ', $product );
+ $title_base = get_post_field( 'post_title', $product->get_parent_id() );
+ $title_suffix = $should_include_attributes ? wc_get_formatted_variation( $product, true, false ) : '';
+
+ /**
+ * Filters the generated variation product title.
+ *
+ * @since 3.0.0
+ * @param string $title Generated variation title.
+ * @param WC_Product $product Variation product object.
+ * @param string $title_base Parent product title.
+ * @param string $title_suffix Formatted attribute values, or an empty string.
+ */
+ return apply_filters( 'woocommerce_product_variation_title', $title_suffix ? $title_base . $separator . $title_suffix : $title_base, $product, $title_base, $title_suffix );
+ }
+
+ /**
+ * Checks whether a variation title should include its attribute values.
+ *
+ * Shared by the stored variation title and the contextual names built for
+ * selected "Any" attributes, so both follow the same rules.
+ *
+ * @since 11.2.0
+ * @param WC_Product $product Variation product object.
+ * @return bool
+ */
+ public function should_include_attributes_in_title( $product ): bool {
$attributes = (array) $product->get_attributes();
// Do not include attributes if the product has 3+ attributes.
@@ -310,12 +346,14 @@ class WC_Product_Variation_Data_Store_CPT extends WC_Product_Data_Store_CPT impl
}
}
- $should_include_attributes = apply_filters( 'woocommerce_product_variation_title_include_attributes', $should_include_attributes, $product );
- $separator = apply_filters( 'woocommerce_product_variation_title_attributes_separator', ' - ', $product );
- $title_base = get_post_field( 'post_title', $product->get_parent_id() );
- $title_suffix = $should_include_attributes ? wc_get_formatted_variation( $product, true, false ) : '';
-
- return apply_filters( 'woocommerce_product_variation_title', $title_suffix ? $title_base . $separator . $title_suffix : $title_base, $product, $title_base, $title_suffix );
+ /**
+ * Filters whether variation product titles should include attributes.
+ *
+ * @since 3.0.2
+ * @param bool $should_include_attributes Whether attributes should be included.
+ * @param WC_Product $product Variation product object.
+ */
+ return (bool) apply_filters( 'woocommerce_product_variation_title_include_attributes', $should_include_attributes, $product );
}
/**
diff --git a/plugins/woocommerce/includes/wc-template-functions.php b/plugins/woocommerce/includes/wc-template-functions.php
index 0dd33771932..dfc7bfdca9a 100644
--- a/plugins/woocommerce/includes/wc-template-functions.php
+++ b/plugins/woocommerce/includes/wc-template-functions.php
@@ -4519,17 +4519,27 @@ function wc_get_theme_slug_for_templates() {
* Gets and formats a list of cart item data + variations for display on the frontend.
*
* @since 3.3.0
- * @param array $cart_item Cart item object.
- * @param bool $flat Should the data be returned flat or in a list.
+ * @since 11.2.0 Added the `$product_name` parameter.
+ * @param array $cart_item Cart item object.
+ * @param bool $flat Should the data be returned flat or in a list.
+ * @param string|null $product_name Product name displayed for the cart item, used to avoid
+ * duplicating variation attributes. Defaults to the stored product name.
* @return string
*/
-function wc_get_formatted_cart_item_data( $cart_item, $flat = false ) {
+function wc_get_formatted_cart_item_data( $cart_item, $flat = false, $product_name = null ) {
$item_data = array();
// Variation values are shown only if they are not found in the title as of 3.0.
// This is because variation titles display the attributes.
if ( $cart_item['data']->is_type( ProductType::VARIATION ) && is_array( $cart_item['variation'] ) ) {
+ $product_name = is_string( $product_name ) ? $product_name : $cart_item['data']->get_name();
+ $product_name = wp_specialchars_decode( wp_strip_all_tags( $product_name ), ENT_QUOTES );
+
foreach ( $cart_item['variation'] as $name => $value ) {
+ if ( ! is_scalar( $value ) || '' === (string) $value ) {
+ continue;
+ }
+ $value = (string) $value;
$taxonomy = wc_attribute_taxonomy_name( str_replace( 'attribute_pa_', '', urldecode( $name ) ) );
if ( taxonomy_exists( $taxonomy ) ) {
@@ -4545,8 +4555,18 @@ function wc_get_formatted_cart_item_data( $cart_item, $flat = false ) {
$label = wc_attribute_label( str_replace( 'attribute_', '', $name ), $cart_item['data'] );
}
- // Check the nicename against the title.
- if ( '' === $value || wc_is_attribute_in_product_name( $value, $cart_item['data']->get_name() ) ) {
+ // The option-name filter can return anything; skip values that cannot be rendered.
+ if ( ! is_scalar( $value ) || '' === (string) $value ) {
+ continue;
+ }
+
+ // Variation values can be stored URL-encoded, while variation titles are built
+ // from decoded values by wc_get_formatted_variation(). Decode here so an encoded
+ // value is matched against the title and displayed the same way the title shows it.
+ $value = rawurldecode( (string) $value );
+
+ // Check the display value against the title.
+ if ( wc_is_attribute_in_product_name( wp_specialchars_decode( $value, ENT_QUOTES ), $product_name ) ) {
continue;
}
diff --git a/plugins/woocommerce/src/Internal/ProductVariations/SelectedVariationName.php b/plugins/woocommerce/src/Internal/ProductVariations/SelectedVariationName.php
new file mode 100644
index 00000000000..51bffe55e65
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductVariations/SelectedVariationName.php
@@ -0,0 +1,139 @@
+<?php
+/**
+ * Contextual variation product names.
+ *
+ * @package WooCommerce\Internal\ProductVariations
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\ProductVariations;
+
+use Automattic\WooCommerce\Enums\ProductType;
+use WC_Data_Store;
+use WC_Product;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Builds variation names from selected cart and checkout attributes.
+ *
+ * @internal
+ *
+ * @since 11.2.0
+ */
+class SelectedVariationName {
+
+ /**
+ * Gets a variation product name using selected variation attributes from cart or checkout context.
+ *
+ * @param WC_Product $product Variation product.
+ * @param array<string, mixed> $variation_attributes Selected variation attributes.
+ * @param bool $filter_custom_attributes Whether to filter custom attribute values for cart display.
+ * @return string
+ *
+ * @since 11.2.0
+ */
+ public function get_product_name( WC_Product $product, array $variation_attributes, bool $filter_custom_attributes = false ): string {
+ if ( ! $product->is_type( ProductType::VARIATION ) || empty( $variation_attributes ) ) {
+ return $product->get_name();
+ }
+
+ $product_name = $product->get_name();
+ $product_attributes = (array) $product->get_attributes();
+
+ if ( ! in_array( '', $product_attributes, true ) ) {
+ return $product_name;
+ }
+
+ $data_store = $product->get_data_store();
+
+ // Stores without the shared title policy keep the stored variation name.
+ if ( ! $data_store instanceof WC_Data_Store || ! $data_store->has_callable( 'should_include_attributes_in_title' ) ) {
+ return $product_name;
+ }
+
+ // @phpstan-ignore method.notFound (the call is proxied by WC_Data_Store::__call() and guarded by has_callable() above)
+ if ( ! $data_store->should_include_attributes_in_title( $product ) ) {
+ return $product_name;
+ }
+
+ $selected_attributes = array();
+
+ foreach ( $variation_attributes as $name => $value ) {
+ $selected_attributes[ str_replace( 'attribute_', '', rawurldecode( (string) $name ) ) ] = $value;
+ }
+
+ $missing_attributes = array();
+ $missing_display = array();
+ $has_attributes_in_name = false;
+
+ foreach ( $product_attributes as $name => $value ) {
+ if ( ! array_key_exists( $name, $selected_attributes ) || ! is_scalar( $selected_attributes[ $name ] ) ) {
+ continue;
+ }
+
+ $selected_value = (string) $selected_attributes[ $name ];
+ $display_value = wc_get_formatted_variation( array( $name => $selected_value ), true, false );
+
+ if ( '' === $display_value ) {
+ continue;
+ }
+
+ if ( wc_is_attribute_in_product_name( $display_value, $product_name ) ) {
+ $has_attributes_in_name = true;
+ continue;
+ }
+
+ if ( '' !== (string) $value ) {
+ continue;
+ }
+
+ $missing_attributes[ $name ] = $selected_value;
+
+ if ( $filter_custom_attributes && ! taxonomy_exists( $name ) ) {
+ /**
+ * Filters the display name for a selected variation option.
+ *
+ * @since 3.4.0
+ * @param string $value Selected variation option value.
+ * @param WP_Term|null $term Term object when available.
+ * @param string $name Attribute taxonomy name.
+ * @param WC_Product|null $product Product object.
+ */
+ $display_value = apply_filters( 'woocommerce_variation_option_name', $selected_value, null, wc_attribute_taxonomy_name( 'attribute_' . $name ), $product );
+ $display_value = is_scalar( $display_value ) ? rawurldecode( (string) $display_value ) : '';
+ }
+
+ if ( '' !== $display_value ) {
+ $missing_display[] = $display_value;
+ }
+ }
+
+ if ( empty( $missing_attributes ) ) {
+ return $product_name;
+ }
+
+ if ( $has_attributes_in_name ) {
+ $separator = ', ';
+ } else {
+ /**
+ * Filters the separator used between a variation product title and its attributes.
+ *
+ * @since 3.0.2
+ * @param string $separator Separator between the product title and attributes.
+ * @param WC_Product $product Variation product object.
+ */
+ $separator = apply_filters( 'woocommerce_product_variation_title_attributes_separator', ' - ', $product );
+ $separator = is_scalar( $separator ) ? (string) $separator : ' - ';
+ }
+
+ $missing_values = implode( ', ', $missing_display );
+
+ if ( '' === $missing_values ) {
+ return $product_name;
+ }
+
+ return $product_name . $separator . $missing_values;
+ }
+}
diff --git a/plugins/woocommerce/templates/cart/cart.php b/plugins/woocommerce/templates/cart/cart.php
index 927ec42ee94..45bcd8f3cc7 100644
--- a/plugins/woocommerce/templates/cart/cart.php
+++ b/plugins/woocommerce/templates/cart/cart.php
@@ -12,7 +12,7 @@
*
* @see https://woocommerce.com/document/template-structure/
* @package WooCommerce\Templates
- * @version 11.0.0
+ * @version 11.2.0
*/
defined( 'ABSPATH' ) || exit;
@@ -52,6 +52,8 @@ do_action( 'woocommerce_before_cart' ); ?>
$visible = apply_filters( 'woocommerce_cart_item_visible', true, $cart_item, $cart_item_key );
if ( $_product instanceof WC_Product && $_product->exists() && $cart_item['quantity'] > 0 && $visible ) {
+ $cart_item_name = WC()->cart->get_item_product_name( $cart_item, $_product );
+
/**
* Filter the product name.
*
@@ -60,7 +62,7 @@ do_action( 'woocommerce_before_cart' ); ?>
* @param array $cart_item The product in the cart.
* @param string $cart_item_key Key for the product in the cart.
*/
- $product_name = apply_filters( 'woocommerce_cart_item_name', $_product->get_name(), $cart_item, $cart_item_key );
+ $product_name = apply_filters( 'woocommerce_cart_item_name', $cart_item_name, $cart_item, $cart_item_key );
$product_permalink = apply_filters( 'woocommerce_cart_item_permalink', $_product->is_visible() ? $_product->get_permalink( $cart_item ) : '', $cart_item, $cart_item_key );
?>
<tr class="woocommerce-cart-form__cart-item <?php echo esc_attr( apply_filters( 'woocommerce_cart_item_class', 'cart_item', $cart_item, $cart_item_key ) ); ?>">
@@ -117,13 +119,14 @@ do_action( 'woocommerce_before_cart' ); ?>
*
* @since 2.1.0
*/
- echo wp_kses_post( apply_filters( 'woocommerce_cart_item_name', sprintf( '<a href="%s">%s</a>', esc_url( $product_permalink ), $_product->get_name() ), $cart_item, $cart_item_key ) );
+ echo wp_kses_post( apply_filters( 'woocommerce_cart_item_name', sprintf( '<a href="%s">%s</a>', esc_url( $product_permalink ), $cart_item_name ), $cart_item, $cart_item_key ) );
}
do_action( 'woocommerce_after_cart_item_name', $cart_item, $cart_item_key );
// Meta data.
- echo wc_get_formatted_cart_item_data( $cart_item ); // PHPCS: XSS ok.
+ // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ echo wc_get_formatted_cart_item_data( $cart_item, false, $cart_item_name );
// Backorder notification.
if ( $_product->backorders_require_notification() && $_product->is_on_backorder( $cart_item['quantity'] ) ) {
diff --git a/plugins/woocommerce/templates/cart/mini-cart.php b/plugins/woocommerce/templates/cart/mini-cart.php
index af1c260d2da..598cff58c31 100644
--- a/plugins/woocommerce/templates/cart/mini-cart.php
+++ b/plugins/woocommerce/templates/cart/mini-cart.php
@@ -14,7 +14,7 @@
*
* @see https://woocommerce.com/document/template-structure/
* @package WooCommerce\Templates
- * @version 11.0.0
+ * @version 11.2.0
*/
defined( 'ABSPATH' ) || exit;
@@ -42,12 +42,13 @@ do_action( 'woocommerce_before_mini_cart' ); ?>
$visible = apply_filters( 'woocommerce_widget_cart_item_visible', true, $cart_item, $cart_item_key );
if ( $_product instanceof WC_Product && $_product->exists() && $cart_item['quantity'] > 0 && $visible ) {
+ $cart_item_name = WC()->cart->get_item_product_name( $cart_item, $_product );
/**
* This filter is documented in woocommerce/templates/cart/cart.php.
*
* @since 2.1.0
*/
- $product_name = apply_filters( 'woocommerce_cart_item_name', $_product->get_name(), $cart_item, $cart_item_key );
+ $product_name = apply_filters( 'woocommerce_cart_item_name', $cart_item_name, $cart_item, $cart_item_key );
$thumbnail = apply_filters( 'woocommerce_cart_item_thumbnail', $_product->get_image(), $cart_item, $cart_item_key );
$product_price = apply_filters( 'woocommerce_cart_item_price', WC()->cart->get_product_price( $_product ), $cart_item, $cart_item_key );
$product_permalink = apply_filters( 'woocommerce_cart_item_permalink', $_product->is_visible() ? $_product->get_permalink( $cart_item ) : '', $cart_item, $cart_item_key );
@@ -77,7 +78,7 @@ do_action( 'woocommerce_before_mini_cart' ); ?>
<?php echo $thumbnail . wp_kses_post( $product_name ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</a>
<?php endif; ?>
- <?php echo wc_get_formatted_cart_item_data( $cart_item ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
+ <?php echo wc_get_formatted_cart_item_data( $cart_item, false, $cart_item_name ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<?php echo apply_filters( 'woocommerce_widget_cart_item_quantity', '<span class="quantity">' . sprintf( '%s × %s', $cart_item['quantity'], $product_price ) . '</span>', $cart_item, $cart_item_key ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</li>
<?php
diff --git a/plugins/woocommerce/templates/checkout/review-order.php b/plugins/woocommerce/templates/checkout/review-order.php
index 08a3459d057..c297b8f1a1f 100644
--- a/plugins/woocommerce/templates/checkout/review-order.php
+++ b/plugins/woocommerce/templates/checkout/review-order.php
@@ -12,7 +12,7 @@
*
* @see https://woocommerce.com/document/template-structure/
* @package WooCommerce\Templates
- * @version 11.0.0
+ * @version 11.2.0
*/
defined( 'ABSPATH' ) || exit;
@@ -45,9 +45,18 @@ defined( 'ABSPATH' ) || exit;
?>
<tr class="<?php echo esc_attr( apply_filters( 'woocommerce_cart_item_class', 'cart_item', $cart_item, $cart_item_key ) ); ?>">
<td class="product-name">
- <?php echo wp_kses_post( apply_filters( 'woocommerce_cart_item_name', $_product->get_name(), $cart_item, $cart_item_key ) ) . ' '; ?>
+ <?php
+ $cart_item_name = WC()->cart->get_item_product_name( $cart_item, $_product );
+ /**
+ * This filter is documented in woocommerce/templates/cart/cart.php.
+ *
+ * @since 2.1.0
+ */
+ $product_name = apply_filters( 'woocommerce_cart_item_name', $cart_item_name, $cart_item, $cart_item_key );
+ ?>
+ <?php echo wp_kses_post( $product_name ) . ' '; ?>
<?php echo apply_filters( 'woocommerce_checkout_cart_item_quantity', ' <strong class="product-quantity">' . sprintf( '× %s', $cart_item['quantity'] ) . '</strong>', $cart_item, $cart_item_key ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
- <?php echo wc_get_formatted_cart_item_data( $cart_item ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
+ <?php echo wc_get_formatted_cart_item_data( $cart_item, false, $cart_item_name ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</td>
<td class="product-total">
<?php echo apply_filters( 'woocommerce_cart_item_subtotal', WC()->cart->get_product_subtotal( $_product, $cart_item['quantity'] ), $cart_item, $cart_item_key ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
diff --git a/plugins/woocommerce/tests/legacy/framework/helpers/class-wc-helper-product.php b/plugins/woocommerce/tests/legacy/framework/helpers/class-wc-helper-product.php
index d8c13e7df67..fbcfb8cb46c 100644
--- a/plugins/woocommerce/tests/legacy/framework/helpers/class-wc-helper-product.php
+++ b/plugins/woocommerce/tests/legacy/framework/helpers/class-wc-helper-product.php
@@ -444,4 +444,40 @@ class WC_Helper_Product {
public static function save_post_test_update_meta_data_direct( $id ) {
update_post_meta( $id, '_test2', 'world' );
}
+
+ /**
+ * Creates a variable product with global attributes and a single variation.
+ *
+ * Variation attribute values may be term slugs or empty strings ("Any" attributes).
+ *
+ * @param string $product_name Product name.
+ * @param array $variation_attributes Variation attributes, e.g. array( 'pa_size' => 'huge', 'pa_number' => '' ).
+ * @param array $attribute_terms Attribute terms keyed by raw attribute name (without 'pa_' prefix).
+ *
+ * @return array The variable product and its variation: array( WC_Product_Variable, WC_Product_Variation ).
+ */
+ public static function create_variation_product_with_global_attributes( $product_name, $variation_attributes, $attribute_terms = array(
+ 'size' => array( 'small', 'huge' ),
+ 'number' => array( '0', '1' ),
+ ) ) {
+ $product = new WC_Product_Variable();
+ $product->set_name( $product_name );
+
+ $attributes = array();
+ foreach ( $attribute_terms as $attribute_name => $terms ) {
+ $attributes[] = self::create_product_attribute_object( $attribute_name, $terms );
+ }
+
+ $product->set_attributes( $attributes );
+ $product->save();
+
+ $variation = self::create_product_variation_object(
+ $product->get_id(),
+ $product_name . ' variation',
+ 10,
+ $variation_attributes
+ );
+
+ return array( $product, wc_get_product( $variation->get_id() ) );
+ }
}
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php b/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
index 5f519de19a6..8ad1c601490 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
@@ -39,6 +39,14 @@ class WC_Cart_Test extends \WC_Unit_Test_Case {
WC()->customer->set_is_vat_exempt( false );
WC()->session->set( 'wc_notices', null );
+ // The parent teardown only clears chosen_shipping_methods, through
+ // WC_Shipping::reset_shipping(). Planted shipping_for_package_* rates survive and
+ // make later shipping calculations fail on a missing package hash, so clear them
+ // here, where a failing assertion cannot skip it.
+ foreach ( array( 'shipping_method_counts', 'previous_shipping_methods', 'shipping_for_package_0', 'shipping_for_package_1', 'chosen_shipping_methods' ) as $key ) {
+ WC()->session->set( $key, null );
+ }
+
remove_filter( 'woocommerce_add_to_cart_quantity', array( $this, 'capture_add_to_cart_quantity_filter_args' ), 10 );
}
@@ -279,6 +287,370 @@ class WC_Cart_Test extends \WC_Unit_Test_Case {
$product->delete( true );
}
+ /**
+ * @testdox Cart item product names include selected Any variation attributes.
+ *
+ * @dataProvider selected_any_variation_name_provider
+ *
+ * @param string $product_name Product name.
+ * @param array<string, string> $stored_attributes Stored variation attributes.
+ * @param string $expected_name Expected contextual cart item name.
+ */
+ public function test_cart_item_product_name_includes_selected_any_variation_attributes( string $product_name, array $stored_attributes, string $expected_name ): void {
+ list( $product, $variation ) = WC_Helper_Product::create_variation_product_with_global_attributes( $product_name, $stored_attributes );
+ $option_filter_calls = 0;
+ $option_filter = function ( $value ) use ( &$option_filter_calls ) {
+ ++$option_filter_calls;
+
+ return 'Filtered ' . $value;
+ };
+ add_filter( 'woocommerce_variation_option_name', $option_filter );
+
+ try {
+ list( , $cart_item ) = $this->add_variation_to_cart( $product, $variation );
+ $name = WC()->cart->get_item_product_name( $cart_item );
+
+ $this->assertSame( $expected_name, $name );
+ $this->assertSame( '', trim( wc_get_formatted_cart_item_data( $cart_item, true, $name ) ) );
+ $this->assertSame( 0, $option_filter_calls );
+ } finally {
+ $variation->delete( true );
+ $product->delete( true );
+ }
+ }
+
+ /**
+ * Provides stored variation attribute shapes for contextual name cases.
+ *
+ * @return array<string, array{string, array<string, string>, string}>
+ */
+ public static function selected_any_variation_name_provider(): array {
+ return array(
+ 'one fixed, one Any' => array(
+ 'Cart Any Product',
+ array(
+ 'pa_size' => 'huge',
+ 'pa_number' => '',
+ ),
+ 'Cart Any Product - huge, 1',
+ ),
+ 'all Any' => array(
+ 'shirt',
+ array(
+ 'pa_size' => '',
+ 'pa_number' => '',
+ ),
+ 'shirt - huge, 1',
+ ),
+ );
+ }
+
+ /**
+ * @testdox Cart item product names honor swapped product objects and non-variation items.
+ */
+ public function test_cart_item_product_name_honors_swapped_products_and_non_variations(): void {
+ $simple = WC_Helper_Product::create_simple_product();
+
+ try {
+ $cart_item = array(
+ 'data' => $simple,
+ 'variation' => array(),
+ );
+
+ $this->assertSame( $simple->get_name(), WC()->cart->get_item_product_name( $cart_item ) );
+
+ $swapped = new WC_Product_Simple();
+ $swapped->set_name( 'Swapped Display Product' );
+
+ $this->assertSame( 'Swapped Display Product', WC()->cart->get_item_product_name( $cart_item, $swapped ) );
+ $this->assertSame( '', WC()->cart->get_item_product_name( array() ) );
+ } finally {
+ $simple->delete( true );
+ }
+ }
+
+ /**
+ * @testdox Cart item names preserve filtered custom Any attribute labels without duplicate metadata.
+ */
+ public function test_cart_item_name_preserves_filtered_custom_any_attribute_labels(): void {
+ $variation = new WC_Product_Variation();
+ $variation->set_name( 'Custom Any Product' );
+ $variation->set_attributes( array( 'finish' => '' ) );
+
+ // For custom attributes, core passes wc_attribute_taxonomy_name( 'attribute_finish' ) as the
+ // attribute name, so the filter sees "pa_attribute_finish" rather than "finish".
+ $filter_option_name = function ( $value, $term, $attribute_name ) {
+ unset( $term );
+
+ return 'pa_attribute_finish' === $attribute_name && 'gloss' === $value ? 'Polished' : $value;
+ };
+ add_filter( 'woocommerce_variation_option_name', $filter_option_name, 10, 3 );
+
+ $cart_item = array(
+ 'data' => $variation,
+ 'variation' => array( 'attribute_finish' => 'gloss' ),
+ );
+
+ $rendered_name = WC()->cart->get_item_product_name( $cart_item );
+
+ $this->assertSame( 'Custom Any Product - Polished', $rendered_name );
+ $this->assertSame( '', trim( wc_get_formatted_cart_item_data( $cart_item, true, $rendered_name ) ) );
+
+ $cart_item['variation']['attribute_finish'] = 'Black & White';
+ $this->assertSame( '', trim( wc_get_formatted_cart_item_data( $cart_item, true, 'Custom Any Product - Black & White' ) ) );
+
+ $variation->set_name( 'Custom Any Product - Black & White' );
+ $this->assertSame( '', trim( wc_get_formatted_cart_item_data( $cart_item, true, false ) ) );
+ }
+
+ /**
+ * @testdox Two-argument cart item formatting preserves selected Any variation metadata.
+ */
+ public function test_formatted_cart_item_data_preserves_selected_any_value_when_product_name_is_omitted(): void {
+ $variation = new WC_Product_Variation();
+ $variation->set_name( 'Legacy Any Product' );
+ $variation->set_attributes( array( 'finish' => '' ) );
+ $option_filter_calls = 0;
+ $option_filter = function () use ( &$option_filter_calls ) {
+ ++$option_filter_calls;
+
+ return 'Filtered ' . $option_filter_calls;
+ };
+ $attribute_filter_calls = 0;
+ $attribute_filter = function ( $is_in_name ) use ( &$attribute_filter_calls ) {
+ ++$attribute_filter_calls;
+
+ return $is_in_name;
+ };
+ add_filter( 'woocommerce_variation_option_name', $option_filter );
+ add_filter( 'woocommerce_is_attribute_in_product_name', $attribute_filter );
+
+ $cart_item = array(
+ 'data' => $variation,
+ 'variation' => array( 'attribute_finish' => 'Black%20White' ),
+ );
+
+ $this->assertSame( 'finish: Filtered 1', trim( wc_get_formatted_cart_item_data( $cart_item, true ) ) );
+ $this->assertSame( 1, $option_filter_calls );
+ $this->assertSame( 1, $attribute_filter_calls );
+ }
+
+ /**
+ * @testdox Cart item formatting omits custom Any metadata when filtered display values cannot be rendered.
+ * @dataProvider unrenderable_variation_option_label_provider
+ *
+ * @param mixed $filtered_value Filtered variation option label.
+ */
+ public function test_formatted_cart_item_data_omits_unrenderable_custom_any_metadata( $filtered_value ): void {
+ $variation = new WC_Product_Variation();
+ $variation->set_name( 'Unrenderable Any Product' );
+ $variation->set_attributes( array( 'finish' => '' ) );
+
+ $option_filter = static function () use ( $filtered_value ) {
+ return $filtered_value;
+ };
+ add_filter( 'woocommerce_variation_option_name', $option_filter );
+
+ $cart_item = array(
+ 'data' => $variation,
+ 'variation' => array( 'attribute_finish' => 'gloss' ),
+ );
+
+ $this->assertSame( '', trim( wc_get_formatted_cart_item_data( $cart_item, true ) ) );
+ }
+
+ /**
+ * Provides filtered variation option labels that cannot be rendered.
+ *
+ * @return array<string, array{mixed}>
+ */
+ public static function unrenderable_variation_option_label_provider(): array {
+ return array(
+ 'false' => array( false ),
+ 'non-scalar array' => array( array( 'unexpected' ) ),
+ );
+ }
+
+ /**
+ * @testdox Cart item formatting skips non-scalar variation values before term lookups and option filters.
+ * @dataProvider non_scalar_variation_value_provider
+ *
+ * @param mixed $raw_value Raw cart variation value.
+ */
+ public function test_formatted_cart_item_data_skips_non_scalar_variation_values( $raw_value ): void {
+ list( $product, $variation ) = WC_Helper_Product::create_variation_product_with_global_attributes(
+ 'Non-scalar Variation Product',
+ array( 'pa_size' => '' )
+ );
+
+ $option_filter_calls = 0;
+ $option_filter = function ( $value ) use ( &$option_filter_calls ) {
+ ++$option_filter_calls;
+
+ return $value;
+ };
+ add_filter( 'woocommerce_variation_option_name', $option_filter );
+
+ $cart_item = array(
+ 'data' => $variation,
+ 'variation' => array(
+ 'attribute_pa_size' => $raw_value,
+ 'attribute_finish' => $raw_value,
+ ),
+ );
+
+ try {
+ $this->assertSame( '', trim( wc_get_formatted_cart_item_data( $cart_item, true ) ) );
+ $this->assertSame( 0, $option_filter_calls );
+ } finally {
+ $variation->delete( true );
+ $product->delete( true );
+ }
+ }
+
+ /**
+ * Provides non-scalar cart variation values.
+ *
+ * @return array<string, array{mixed}>
+ */
+ public static function non_scalar_variation_value_provider(): array {
+ return array(
+ 'array' => array( array( 'gloss' ) ),
+ 'object' => array( new stdClass() ),
+ );
+ }
+
+ /**
+ * @testdox Cart item formatting decodes taxonomy term entities when checking the rendered product name for duplicate metadata.
+ */
+ public function test_formatted_cart_item_data_decodes_taxonomy_term_entities_for_name_comparison(): void {
+ $taxonomy = 'pa_encoded_finish';
+ $term = false;
+
+ register_taxonomy( $taxonomy, array( 'product' ) );
+
+ try {
+ $term = wp_insert_term( 'Black & White', $taxonomy, array( 'slug' => 'black-white' ) );
+ $this->assertNotWPError( $term );
+
+ $variation = new WC_Product_Variation();
+ $variation->set_name( 'Encoded Any Product' );
+ $variation->set_attributes( array( $taxonomy => '' ) );
+
+ $cart_item = array(
+ 'data' => $variation,
+ 'variation' => array( 'attribute_' . $taxonomy => 'black-white' ),
+ );
+
+ $this->assertSame( '', trim( wc_get_formatted_cart_item_data( $cart_item, true, 'Encoded Any Product - Black & White' ) ) );
+ } finally {
+ if ( is_array( $term ) ) {
+ wp_delete_term( $term['term_id'], $taxonomy );
+ }
+
+ unregister_taxonomy( $taxonomy );
+ }
+ }
+
+ /**
+ * @testdox Cart item metadata decodes URL-encoded custom attribute values for the name comparison and for display.
+ */
+ public function test_formatted_cart_item_data_decodes_url_encoded_custom_values(): void {
+ // A fixed attribute whose stored value carries a percent escape. wc_get_formatted_variation()
+ // decodes it when generating the variation title, so the cart value must be decoded to match.
+ $variation = new WC_Product_Variation();
+ $variation->set_name( 'Encoded Fixed Product - Black White' );
+ $variation->set_attributes( array( 'finish' => 'Black%20White' ) );
+
+ $cart_item = array(
+ 'data' => $variation,
+ 'variation' => array( 'attribute_finish' => 'Black%20White' ),
+ );
+
+ $this->assertSame(
+ '',
+ trim( wc_get_formatted_cart_item_data( $cart_item, true, $variation->get_name() ) ),
+ 'A decoded value already shown in the name must not be repeated as metadata.'
+ );
+
+ $this->assertSame(
+ 'finish: Black White',
+ trim( wc_get_formatted_cart_item_data( $cart_item, true, 'Encoded Fixed Product' ) ),
+ 'A value missing from the name must display decoded, matching how the name renders it.'
+ );
+
+ // The same normalisation applies to a selected "Any" value reaching the cart.
+ $any_variation = new WC_Product_Variation();
+ $any_variation->set_name( 'Encoded Any Product' );
+ $any_variation->set_attributes( array( 'finish' => '' ) );
+
+ $any_cart_item = array(
+ 'data' => $any_variation,
+ 'variation' => array( 'attribute_finish' => 'Black%20White' ),
+ );
+
+ $this->assertSame( 'Encoded Any Product - Black White', WC()->cart->get_item_product_name( $any_cart_item ) );
+ $this->assertSame( '', trim( wc_get_formatted_cart_item_data( $any_cart_item, true, 'Encoded Any Product - Black White' ) ) );
+ }
+
+ /**
+ * @testdox Cart item metadata omits fixed taxonomy attributes already shown in the variation name.
+ */
+ public function test_formatted_cart_item_data_omits_metadata_for_fixed_taxonomy_attributes(): void {
+ list( $product, $variation ) = WC_Helper_Product::create_variation_product_with_global_attributes(
+ 'Cart Fixed Taxonomy Product',
+ array(
+ 'pa_size' => 'huge',
+ 'pa_number' => '1',
+ )
+ );
+
+ try {
+ list( , $cart_item ) = $this->add_variation_to_cart( $product, $variation );
+ $this->assertSame( '', trim( wc_get_formatted_cart_item_data( $cart_item, true ) ) );
+ } finally {
+ $variation->delete( true );
+ $product->delete( true );
+ }
+ }
+
+ /**
+ * @testdox Cart item metadata dedup keys on the template-provided name regardless of name filters.
+ */
+ public function test_formatted_cart_item_data_dedupes_against_the_provided_name_regardless_of_name_filters(): void {
+ list( $product, $variation ) = WC_Helper_Product::create_variation_product_with_global_attributes(
+ 'Cart Replaced Name Product',
+ array(
+ 'pa_size' => 'huge',
+ 'pa_number' => '',
+ )
+ );
+
+ $replace_name = function () {
+ return 'Custom cart label';
+ };
+ add_filter( 'woocommerce_cart_item_name', $replace_name, 20 );
+
+ try {
+ list( $cart_item_key, $cart_item ) = $this->add_variation_to_cart( $product, $variation );
+
+ $name = WC()->cart->get_item_product_name( $cart_item );
+ /**
+ * This filter is documented in woocommerce/templates/cart/cart.php.
+ *
+ * @since 2.1.0
+ */
+ $rendered_name = apply_filters( 'woocommerce_cart_item_name', $name, $cart_item, (string) $cart_item_key );
+
+ $this->assertSame( 'Custom cart label', $rendered_name );
+ $this->assertSame( 'Cart Replaced Name Product - huge, 1', $name );
+ $this->assertSame( '', trim( wc_get_formatted_cart_item_data( $cart_item, true, $name ) ), 'Dedup must key on the template-provided name, not on name-filter output.' );
+ } finally {
+ $variation->delete( true );
+ $product->delete( true );
+ }
+ }
+
/**
* @testdox should throw a notice to the cart if using variation_id
* that doesn't belong to specified variable product.
@@ -1850,4 +2222,49 @@ class WC_Cart_Test extends \WC_Unit_Test_Case {
$product->delete( true );
$coupon->delete( true );
}
+
+ /**
+ * @testdox The mini-cart template renders selected Any values in the name exactly once.
+ */
+ public function test_mini_cart_template_renders_selected_any_values_once(): void {
+ list( $product, $variation ) = WC_Helper_Product::create_variation_product_with_global_attributes(
+ 'Mini Cart Any Product',
+ array(
+ 'pa_size' => 'huge',
+ 'pa_number' => '',
+ )
+ );
+
+ try {
+ $this->add_variation_to_cart( $product, $variation );
+
+ ob_start();
+ woocommerce_mini_cart();
+ $html = ob_get_clean();
+
+ $this->assertStringContainsString( 'Mini Cart Any Product - huge, 1', $html, 'The merged name must render.' );
+ $this->assertStringNotContainsString( '<dl class="variation"', $html, 'No variation meta list must render for values already in the name.' );
+ } finally {
+ $variation->delete( true );
+ $product->delete( true );
+ }
+ }
+
+ /**
+ * Adds a variation to the cart, asserting success.
+ *
+ * @param WC_Product $product Variable product.
+ * @param WC_Product $variation Variation to add.
+ * @param array $attributes Selected variation attributes.
+ * @return array The cart item key and cart item: array( string, array ).
+ */
+ private function add_variation_to_cart( $product, $variation, array $attributes = array(
+ 'attribute_pa_size' => 'huge',
+ 'attribute_pa_number' => '1',
+ ) ): array {
+ $cart_item_key = WC()->cart->add_to_cart( $product->get_id(), 1, $variation->get_id(), $attributes );
+ $this->assertNotFalse( $cart_item_key, 'The variation should be added to the cart.' );
+
+ return array( (string) $cart_item_key, WC()->cart->get_cart_item( (string) $cart_item_key ) );
+ }
}
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php b/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php
index f6f9f4f01fe..de2b3f92be1 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php
@@ -783,4 +783,157 @@ class WC_Checkout_Test extends \WC_Unit_Test_Case {
$this->assertSame( 'checkout-error', $result->get_error_code(), 'Error code should come from the checkout try/catch path.' );
$this->assertStringContainsString( 'Order items could not be saved', $result->get_error_message(), 'Error message should surface the defense-in-depth guard message.' );
}
+
+ /**
+ * @testdox Checkout tolerates non-array cart variation data when building contextual item names.
+ */
+ public function test_create_order_line_items_tolerates_malformed_variation_data(): void {
+ list( $product, $variation ) = WC_Helper_Product::create_variation_product_with_global_attributes(
+ 'Checkout Malformed Variation Product',
+ array(
+ 'pa_size' => 'huge',
+ 'pa_number' => '',
+ )
+ );
+
+ $order = wc_create_order();
+
+ $malformed_variation_filter = function ( $cart_contents ) {
+ foreach ( $cart_contents as &$cart_item ) {
+ $cart_item['variation'] = 'malformed variation data';
+ }
+ unset( $cart_item );
+
+ return $cart_contents;
+ };
+
+ try {
+ $this->add_variation_to_cart( $product, $variation );
+ add_filter( 'woocommerce_get_cart_contents', $malformed_variation_filter );
+
+ $this->sut->create_order_line_items( $order, WC()->cart );
+ $order->save();
+
+ $items = array_values( wc_get_order( $order->get_id() )->get_items() );
+ $this->assertCount( 1, $items );
+ $this->assertSame( $variation->get_name(), $items[0]->get_name() );
+ $this->assertCount( 0, $items[0]->get_meta_data(), 'Malformed variation data should not be stored as item meta.' );
+ } finally {
+ WC()->cart->empty_cart();
+ $order->delete( true );
+ $variation->delete( true );
+ $product->delete( true );
+ }
+ }
+
+ /**
+ * @testdox Checkout merges selected taxonomy Any attributes into the item name without duplicating them as metadata.
+ */
+ public function test_create_order_line_items_merges_taxonomy_any_attributes_and_dedupes_meta(): void {
+ list( $product, $variation ) = WC_Helper_Product::create_variation_product_with_global_attributes(
+ 'Checkout Taxonomy Any Product',
+ array(
+ 'pa_size' => 'huge',
+ 'pa_number' => '',
+ )
+ );
+
+ $order = wc_create_order();
+
+ try {
+ $this->add_variation_to_cart( $product, $variation );
+ $this->sut->create_order_line_items( $order, WC()->cart );
+ $order->save();
+
+ $items = array_values( $order->get_items() );
+ $this->assertCount( 1, $items );
+ $this->assertSame( 'Checkout Taxonomy Any Product - huge, 1', $items[0]->get_name() );
+ $this->assertSame( '1', $items[0]->get_meta( 'pa_number' ), 'The selected Any value should remain stored as item meta.' );
+ $this->assertCount( 0, $items[0]->get_formatted_meta_data(), 'Selected Any values included in the item name should not be duplicated as metadata.' );
+ } finally {
+ WC()->cart->empty_cart();
+ $order->delete( true );
+ $variation->delete( true );
+ $product->delete( true );
+ }
+ }
+
+ /**
+ * @testdox Persisted order item names use raw custom Any values so order meta dedup keeps working.
+ *
+ * @dataProvider custom_any_value_provider
+ *
+ * @param string $selected_value Selected custom attribute value.
+ * @param string $expected_name Expected persisted order item name.
+ */
+ public function test_create_order_line_items_persists_raw_custom_any_values( string $selected_value, string $expected_name ): void {
+ $attribute = new WC_Product_Attribute();
+ $attribute->set_id( 0 );
+ $attribute->set_name( 'finish' );
+ $attribute->set_options( array( 'gloss', 'matte', 'Black & White' ) );
+ $attribute->set_visible( true );
+ $attribute->set_variation( true );
+
+ $product = new WC_Product_Variable();
+ $product->set_name( 'Canonical Name Product' );
+ $product->set_attributes( array( $attribute ) );
+ $product->save();
+
+ $variation = new WC_Product_Variation();
+ $variation->set_parent_id( $product->get_id() );
+ $variation->set_attributes( array( 'finish' => '' ) );
+ $variation->set_regular_price( '10' );
+ $variation->save();
+
+ $filter_option_name = static function ( $value ) {
+ return 'gloss' === $value ? 'Polished' : $value;
+ };
+ add_filter( 'woocommerce_variation_option_name', $filter_option_name );
+
+ $order = wc_create_order();
+
+ try {
+ $this->add_variation_to_cart( $product, $variation, array( 'attribute_finish' => $selected_value ) );
+ $this->sut->create_order_line_items( $order, WC()->cart );
+ $order->save();
+
+ $items = array_values( $order->get_items() );
+ $this->assertCount( 1, $items );
+ $this->assertSame( $expected_name, $items[0]->get_name(), 'Persisted names must use raw values, not woocommerce_variation_option_name output.' );
+ $this->assertCount( 0, $items[0]->get_formatted_meta_data(), 'The raw value in the name must keep order meta dedup working.' );
+ } finally {
+ WC()->cart->empty_cart();
+ $order->delete( true );
+ $variation->delete( true );
+ $product->delete( true );
+ }
+ }
+
+ /**
+ * Provides selected custom Any values and their expected persisted names.
+ *
+ * @return array<string, array{string, string}>
+ */
+ public static function custom_any_value_provider(): array {
+ return array(
+ 'raw value wins over filtered label' => array( 'gloss', 'Canonical Name Product - gloss' ),
+ 'entity-bearing value dedupes' => array( 'Black & White', 'Canonical Name Product - Black & White' ),
+ );
+ }
+
+ /**
+ * Adds a variation to the cart, asserting success, and calculates totals.
+ *
+ * @param WC_Product $product Variable product.
+ * @param WC_Product $variation Variation to add.
+ * @param array $attributes Selected variation attributes.
+ */
+ private function add_variation_to_cart( $product, $variation, array $attributes = array(
+ 'attribute_pa_size' => 'huge',
+ 'attribute_pa_number' => '1',
+ ) ): void {
+ $cart_item_key = WC()->cart->add_to_cart( $product->get_id(), 1, $variation->get_id(), $attributes );
+ $this->assertNotFalse( $cart_item_key, 'The variation should be added to the cart.' );
+ WC()->cart->calculate_totals();
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductVariations/SelectedVariationNameTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductVariations/SelectedVariationNameTest.php
new file mode 100644
index 00000000000..ea921e08f9d
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductVariations/SelectedVariationNameTest.php
@@ -0,0 +1,252 @@
+<?php
+/**
+ * SelectedVariationName tests.
+ *
+ * @package WooCommerce\Tests\Internal\ProductVariations
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\ProductVariations;
+
+use Automattic\WooCommerce\Internal\ProductVariations\SelectedVariationName;
+use WC_Data_Store;
+use WC_Helper_Product;
+use WC_Product_Variation;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for SelectedVariationName.
+ */
+class SelectedVariationNameTest extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var SelectedVariationName
+ */
+ private $sut;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->sut = new SelectedVariationName();
+ }
+
+ /**
+ * @testdox Selected Any attributes are included contextually without changing the stored variation name.
+ */
+ public function test_get_product_name_includes_selected_any_attributes(): void {
+ list( $product, $variation ) = WC_Helper_Product::create_variation_product_with_global_attributes(
+ 'Selected Any Product',
+ array(
+ 'pa_size' => 'huge',
+ 'pa_number' => '',
+ )
+ );
+ $filter_calls = 0;
+ $option_filter = function ( $value ) use ( &$filter_calls ) {
+ ++$filter_calls;
+
+ return 'Filtered ' . $value;
+ };
+ add_filter( 'woocommerce_variation_option_name', $option_filter );
+
+ try {
+ $stored_name = $variation->get_name();
+ $name = $this->sut->get_product_name(
+ $variation,
+ array(
+ 'attribute_pa_size' => 'huge',
+ 'attribute_pa_number' => '1',
+ )
+ );
+
+ $this->assertSame( 'Selected Any Product - huge, 1', $name );
+ $this->assertSame( 0, $filter_calls, 'Canonical order values must not use cart option-label filters.' );
+ $this->assertSame( $stored_name, $variation->get_name(), 'The contextual name must not mutate the variation object.' );
+ $this->assertSame( $stored_name, wc_get_product( $variation->get_id() )->get_name(), 'The contextual name must not change the persisted variation.' );
+ } finally {
+ $variation->delete( true );
+ $product->delete( true );
+ }
+ }
+
+ /**
+ * @testdox Data stores without the shared title policy keep the stored variation name.
+ */
+ public function test_get_product_name_keeps_stored_name_without_data_store_title_policy(): void {
+ $data_store = $this->getMockBuilder( WC_Data_Store::class )->disableOriginalConstructor()->onlyMethods( array( 'has_callable' ) )->getMock();
+ $data_store->method( 'has_callable' )->with( 'should_include_attributes_in_title' )->willReturn( false );
+
+ $variation = $this->getMockBuilder( WC_Product_Variation::class )->onlyMethods( array( 'get_data_store' ) )->getMock();
+ $variation->method( 'get_data_store' )->willReturn( $data_store );
+ $variation->set_name( 'Legacy Store Product' );
+ $variation->set_attributes( array( 'finish' => '' ) );
+
+ $this->assertSame( 'Legacy Store Product', $this->sut->get_product_name( $variation, array( 'attribute_finish' => 'gloss' ) ) );
+ }
+
+ /**
+ * @testdox Fixed variation names return without evaluating contextual title policy.
+ */
+ public function test_get_product_name_returns_early_for_fixed_variations(): void {
+ $variation = new WC_Product_Variation();
+ $variation->set_name( 'Fixed Product - huge, 1' );
+ $variation->set_attributes(
+ array(
+ 'pa_size' => 'huge',
+ 'pa_number' => '1',
+ )
+ );
+
+ $title_policy_calls = 0;
+ $title_policy_filter = function ( $should_include ) use ( &$title_policy_calls ) {
+ ++$title_policy_calls;
+
+ return $should_include;
+ };
+ add_filter( 'woocommerce_product_variation_title_include_attributes', $title_policy_filter );
+
+ $name = $this->sut->get_product_name(
+ $variation,
+ array(
+ 'attribute_pa_size' => 'huge',
+ 'attribute_pa_number' => '1',
+ )
+ );
+
+ $this->assertSame( 'Fixed Product - huge, 1', $name );
+ $this->assertSame( 0, $title_policy_calls );
+ }
+
+ /**
+ * @testdox Contextual variation names retain parent-only titles when title policy omits attributes.
+ *
+ * @dataProvider title_policy_provider
+ *
+ * @param string $product_name Product name.
+ * @param array<string, string> $stored_attributes Stored variation attributes.
+ * @param array<string, string> $selected_attributes Selected cart attributes.
+ * @param bool $use_exclude_filter Whether to force exclusion via the include-attributes filter.
+ */
+ public function test_get_product_name_respects_title_policy( string $product_name, array $stored_attributes, array $selected_attributes, bool $use_exclude_filter = false ): void {
+ $variation = new WC_Product_Variation();
+ $variation->set_name( $product_name );
+ $variation->set_attributes( $stored_attributes );
+
+ if ( $use_exclude_filter ) {
+ add_filter( 'woocommerce_product_variation_title_include_attributes', '__return_false' );
+ }
+
+ $this->assertSame( $product_name, $this->sut->get_product_name( $variation, $selected_attributes ) );
+ }
+
+ /**
+ * Provides variation-title policy cases.
+ *
+ * @return array<string, array{0: string, 1: array<string, string>, 2: array<string, string>, 3?: bool}>
+ */
+ public static function title_policy_provider(): array {
+ return array(
+ 'three attributes' => array(
+ 'Three Attribute Product',
+ array(
+ 'pa_size' => 'huge',
+ 'pa_colour' => 'blue',
+ 'pa_number' => '',
+ ),
+ array(
+ 'attribute_pa_size' => 'huge',
+ 'attribute_pa_colour' => 'blue',
+ 'attribute_pa_number' => '1',
+ ),
+ ),
+ 'multi-word attribute' => array(
+ 'Multi Word Attribute Product',
+ array(
+ 'pa_mount-colour' => '',
+ 'pa_size' => 'large',
+ ),
+ array(
+ 'attribute_pa_mount-colour' => 'black',
+ 'attribute_pa_size' => 'large',
+ ),
+ ),
+ 'include filter returns false' => array(
+ 'Filtered Include Product',
+ array(
+ 'pa_size' => 'huge',
+ 'pa_number' => '',
+ ),
+ array(
+ 'attribute_pa_size' => 'huge',
+ 'attribute_pa_number' => '1',
+ ),
+ true,
+ ),
+ );
+ }
+
+ /**
+ * @testdox Filtered Any variation labels that cannot be displayed are omitted.
+ * @dataProvider filtered_any_variation_label_provider
+ *
+ * @param mixed $filtered_value Filtered variation option label.
+ */
+ public function test_get_product_name_omits_filtered_any_variation_labels_that_cannot_be_displayed( $filtered_value ): void {
+ $variation = new WC_Product_Variation();
+ $variation->set_name( 'Filtered Any Product' );
+ $variation->set_attributes( array( 'finish' => '' ) );
+
+ $option_filter = static function () use ( $filtered_value ) {
+ return $filtered_value;
+ };
+ $separator_filter_calls = 0;
+ $separator_filter = static function ( $separator ) use ( &$separator_filter_calls ) {
+ ++$separator_filter_calls;
+
+ return $separator;
+ };
+ add_filter( 'woocommerce_variation_option_name', $option_filter );
+ add_filter( 'woocommerce_product_variation_title_attributes_separator', $separator_filter );
+
+ $name = $this->sut->get_product_name( $variation, array( 'attribute_finish' => 'gloss' ), true );
+
+ $this->assertSame( 'Filtered Any Product', $name );
+ $this->assertSame( 1, $separator_filter_calls, 'The title separator filter must retain its existing timing.' );
+ }
+
+ /**
+ * Provides filtered Any variation labels that cannot be displayed.
+ *
+ * @return array<string, array{mixed}>
+ */
+ public static function filtered_any_variation_label_provider(): array {
+ return array(
+ 'false' => array( false ),
+ 'empty string' => array( '' ),
+ 'non-scalar array' => array( array( 'unexpected' ) ),
+ );
+ }
+
+ /**
+ * @testdox Non-string separator filter values fall back to the default separator.
+ */
+ public function test_get_product_name_tolerates_non_string_separator_filter_values(): void {
+ $variation = new WC_Product_Variation();
+ $variation->set_name( 'Guarded Separator Product' );
+ $variation->set_attributes( array( 'finish' => '' ) );
+
+ add_filter(
+ 'woocommerce_product_variation_title_attributes_separator',
+ static function () {
+ return array( 'not', 'a', 'string' );
+ }
+ );
+
+ $this->assertSame( 'Guarded Separator Product - gloss', $this->sut->get_product_name( $variation, array( 'attribute_finish' => 'gloss' ) ) );
+ }
+}