Commit 779e7bb1a20 for woocommerce
commit 779e7bb1a20a7463e11508ad4d509bb1e0ef09e3
Author: Bogdan Ungureanu <bogdanungureanu21@gmail.com>
Date: Thu Sep 24 17:57:34 2026 +0300
Add weight placeholder in shipping cost (#68835)
* Add a [weight] placeholder to flat rate shipping costs
A package whose items have no weight resolves to 0, so formulas dividing by [weight] are
rejected at save time rather than silently offering free shipping. [weight min="1"] sets a floor for that case.
* Add validation for max/min
* Fix negative item weights in flat rate shipping
* Fix review issues
* Fix malformed weight placeholder matching
* Remove description and bump version
---------
Co-authored-by: Sam Najian <dev@najian.info>
diff --git a/plugins/woocommerce/changelog/add-flat-rate-weight-placeholder b/plugins/woocommerce/changelog/add-flat-rate-weight-placeholder
new file mode 100644
index 00000000000..6b9812e031b
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-flat-rate-weight-placeholder
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a `[weight]` placeholder to flat rate shipping cost formulas, resolving to the total weight of the shippable items in the package or shipping class, in the store's weight unit. Accepts optional `min` and `max` attributes, e.g. `[weight min="1" max="20"]`. Invalid weight limits produce a validation error when saving; decimal limits use a dot and no thousands separators.
diff --git a/plugins/woocommerce/client/legacy/js/admin/utils/number-validation.js b/plugins/woocommerce/client/legacy/js/admin/utils/number-validation.js
index 61fa7bdc130..358721b72a2 100644
--- a/plugins/woocommerce/client/legacy/js/admin/utils/number-validation.js
+++ b/plugins/woocommerce/client/legacy/js/admin/utils/number-validation.js
@@ -29,6 +29,10 @@ function isValidFormattedNumber( value, config ) {
return false;
}
+ // Whitespace or "]" must follow "weight", excluding names such as [weight-foo].
+ // Attributes stop at the first closing bracket; the server validates their dot-decimal limits on save.
+ value = value.replace( /\[weight(?=\s|\])[^\]]*\]/g, '[weight]' );
+
var decimalSeparator = config.decimalSeparator || '.';
var thousandSeparator = config.thousandSeparator || ',';
diff --git a/plugins/woocommerce/client/legacy/js/admin/utils/test/number-validation.test.js b/plugins/woocommerce/client/legacy/js/admin/utils/test/number-validation.test.js
index 973872d7252..3bf7396f989 100644
--- a/plugins/woocommerce/client/legacy/js/admin/utils/test/number-validation.test.js
+++ b/plugins/woocommerce/client/legacy/js/admin/utils/test/number-validation.test.js
@@ -53,6 +53,39 @@ describe( 'Number Validation Utils - isValidFormattedNumber', () => {
} );
} );
+ describe( 'Weight placeholder limits', () => {
+ const config = {
+ decimalSeparator: ',',
+ thousandSeparator: '.',
+ };
+
+ test.each( [
+ '[weight min="0.5"]',
+ '10 * [weight max="1000.5"]',
+ '[weight min="0.5" max="1.5"] + 2,5',
+ '[weight min="0.5"] + [weight max="1.5"]',
+ ] )( 'allows dot-decimal weight limits in %s for server validation', ( value ) => {
+ expect( isValidFormattedNumber( value, config ) ).toBe( true );
+ } );
+
+ test( 'still validates the decimal separator outside weight limits', () => {
+ expect( isValidFormattedNumber( '[weight min="0.5"] * 2.5', config ) ).toBe( false );
+ } );
+
+ test.each( [
+ '[weightless min="0.5"]',
+ '[weight-foo]',
+ '[weight.foo]',
+ '[weight-foo min="0.5"]',
+ '[weight.foo max="1.5"]',
+ '[weight min="0.5"',
+ '[weight min="0.5"] + [weightless min="1.5"]',
+ '[weight min="0.5"] + [weight max="1.5"',
+ ] )( 'does not bypass number validation in %s', ( value ) => {
+ expect( isValidFormattedNumber( value, config ) ).toBe( false );
+ } );
+ } );
+
describe( 'Formula validation - US format', () => {
const config = {
decimalSeparator: '.',
diff --git a/plugins/woocommerce/includes/shipping/flat-rate/class-wc-shipping-flat-rate.php b/plugins/woocommerce/includes/shipping/flat-rate/class-wc-shipping-flat-rate.php
index f3ad1192c03..c17a7dd7470 100644
--- a/plugins/woocommerce/includes/shipping/flat-rate/class-wc-shipping-flat-rate.php
+++ b/plugins/woocommerce/includes/shipping/flat-rate/class-wc-shipping-flat-rate.php
@@ -8,6 +8,8 @@
defined( 'ABSPATH' ) || exit;
+use Automattic\WooCommerce\Internal\Shipping\FlatRate\WeightPlaceholder;
+
/**
* WC_Shipping_Flat_Rate class.
*/
@@ -69,7 +71,7 @@ class WC_Shipping_Flat_Rate extends WC_Shipping_Method {
* Evaluate a cost from a sum/string.
*
* @param string $sum Sum of shipping.
- * @param array $args Args, must contain `cost` and `qty` keys. Having `array()` as default is for back compat reasons.
+ * @param array $args Args, must contain `cost` and `qty` keys, and may contain a numeric `weight` key (since 11.3.0), which defaults to 0. Having `array()` as default is for back compat reasons.
* @return string
*/
protected function evaluate_cost( $sum, $args = array() ) {
@@ -86,6 +88,9 @@ class WC_Shipping_Flat_Rate extends WC_Shipping_Method {
$decimals = array( wc_get_price_decimal_separator(), $locale['decimal_point'], $locale['mon_decimal_point'], ',' );
$this->fee_cost = $args['cost'];
+ // Expanded before the shortcodes below, since the weight is substituted rather than registered as one.
+ $sum = $this->get_weight_placeholder()->expand( (string) $sum, $args['weight'] ?? null );
+
// Expand shortcodes.
add_shortcode( 'fee', array( $this, 'fee' ) );
@@ -103,7 +108,7 @@ class WC_Shipping_Flat_Rate extends WC_Shipping_Method {
)
);
- remove_shortcode( 'fee', array( $this, 'fee' ) );
+ remove_shortcode( 'fee' );
// Remove whitespace from string.
$sum = preg_replace( '/\s+/', '', $sum );
@@ -155,6 +160,17 @@ class WC_Shipping_Flat_Rate extends WC_Shipping_Method {
return (string) $calculated_fee;
}
+ /**
+ * Get the [weight] placeholder handler.
+ *
+ * Private so that the placeholder adds no new subclassing surface.
+ *
+ * @return WeightPlaceholder
+ */
+ private function get_weight_placeholder() {
+ return wc_get_container()->get( WeightPlaceholder::class );
+ }
+
/**
* Calculate the shipping costs.
*
@@ -177,8 +193,9 @@ class WC_Shipping_Flat_Rate extends WC_Shipping_Method {
$rate['cost'] = $this->evaluate_cost(
$cost,
array(
- 'qty' => $this->get_package_item_qty( $package ),
- 'cost' => $package['contents_cost'],
+ 'qty' => $this->get_package_item_qty( $package ),
+ 'cost' => $package['contents_cost'],
+ 'weight' => $this->get_weight_placeholder()->get_for_package( $package ),
)
);
}
@@ -203,8 +220,9 @@ class WC_Shipping_Flat_Rate extends WC_Shipping_Method {
$class_cost = $this->evaluate_cost(
$class_cost_string,
array(
- 'qty' => array_sum( wp_list_pluck( $products, 'quantity' ) ),
- 'cost' => array_sum( wp_list_pluck( $products, 'line_total' ) ),
+ 'qty' => array_sum( wp_list_pluck( $products, 'quantity' ) ),
+ 'cost' => array_sum( wp_list_pluck( $products, 'line_total' ) ),
+ 'weight' => $this->get_weight_placeholder()->get_for_items( $products ),
)
);
@@ -310,6 +328,7 @@ class WC_Shipping_Flat_Rate extends WC_Shipping_Method {
public function sanitize_cost( $value ) {
$value = is_null( $value ) ? '' : $value;
$value = wp_kses_post( trim( wp_unslash( $value ) ) );
+ $this->get_weight_placeholder()->validate( $value );
$value = str_replace( array( get_woocommerce_currency_symbol(), html_entity_decode( get_woocommerce_currency_symbol() ) ), '', $value );
$contains_shortcodes = false !== strpos( $value, '[' ) || false !== strpos( $value, ']' );
@@ -322,13 +341,29 @@ class WC_Shipping_Flat_Rate extends WC_Shipping_Method {
$dummy_cost = $this->evaluate_cost(
$value,
array(
- 'cost' => 1,
- 'qty' => 1,
+ 'cost' => 1,
+ 'qty' => 1,
+ 'weight' => 1,
)
);
if ( false === $dummy_cost ) {
throw new Exception( WC_Eval_Math::$last_error );
}
+
+ // A package weighs nothing when none of its items have a weight set, which is a common configuration.
+ // Reject costs that can't be evaluated in that case, rather than silently offering the rate for free.
+ $zero_weight_cost = $this->evaluate_cost(
+ $value,
+ array(
+ 'cost' => 1,
+ 'qty' => 1,
+ 'weight' => 0,
+ )
+ );
+ if ( false === $zero_weight_cost ) {
+ throw new Exception( esc_html__( 'This cost can\'t be calculated when no item in the cart has a weight. Use [weight min="1"] to set a minimum billable weight.', 'woocommerce' ) );
+ }
+
return $value;
}
}
diff --git a/plugins/woocommerce/includes/shipping/flat-rate/includes/settings-flat-rate.php b/plugins/woocommerce/includes/shipping/flat-rate/includes/settings-flat-rate.php
index 98f3da92f79..6f79de707b6 100644
--- a/plugins/woocommerce/includes/shipping/flat-rate/includes/settings-flat-rate.php
+++ b/plugins/woocommerce/includes/shipping/flat-rate/includes/settings-flat-rate.php
@@ -8,8 +8,15 @@
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Enums\ProductTaxStatus;
+use Automattic\WooCommerce\Utilities\I18nUtil;
-$cost_desc = __( 'Enter a cost (excl. tax) or sum, e.g. <code>10.00 * [qty]</code>.', 'woocommerce' ) . '<br/><br/>' . __( 'Use <code>[qty]</code> for the number of items, <br/><code>[cost]</code> for the total cost of items, and <code>[fee percent="10" min_fee="20" max_fee=""]</code> for percentage based fees.', 'woocommerce' );
+// This description is shown as a tooltip, and wc_sanitize_tooltip() strips <code> tags, so the placeholders are
+// listed as `placeholder = meaning` pairs rather than relying on markup to delimit them.
+$cost_desc = __( 'Enter a cost (excl. tax) or sum, e.g. 10.00 * [qty].', 'woocommerce' ) . '<br/><br/>' . sprintf(
+ /* translators: %s: store weight unit label, e.g. kg */
+ __( 'Supports the following placeholders: [qty] = number of items, [cost] = total cost of items, [weight] = total weight of items in %s, [fee percent="10" min_fee="20" max_fee=""] = percentage based fee.', 'woocommerce' ),
+ I18nUtil::get_weight_unit_label( get_option( 'woocommerce_weight_unit', 'kg' ) )
+) . '<br/><br/>' . __( 'The weight accepts an optional minimum and maximum, e.g. [weight min="1" max="20"]. Use a dot for decimal values and no thousands separators in weight limits.', 'woocommerce' );
$cost_link = sprintf( '<span id="wc-shipping-advanced-costs-help-text">%s <a target="_blank" href="https://woocommerce.com/document/flat-rate-shipping/#advanced-costs">%s</a>.</span>', __( 'Charge a flat rate per item, or enter a cost formula to charge a percentage based cost or a minimum fee. Learn more about', 'woocommerce' ), __( 'advanced costs', 'woocommerce' ) );
$settings = array(
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 7ffab833507..2d95727e16e 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -30580,12 +30580,6 @@ parameters:
count: 1
path: includes/shipping/flat-rate/class-wc-shipping-flat-rate.php
- -
- message: '#^Function remove_shortcode invoked with 2 parameters, 1 required\.$#'
- identifier: arguments.count
- count: 1
- path: includes/shipping/flat-rate/class-wc-shipping-flat-rate.php
-
-
message: '#^Method WC_Shipping_Flat_Rate\:\:calculate_shipping\(\) has no return type specified\.$#'
identifier: missingType.return
diff --git a/plugins/woocommerce/src/Internal/Shipping/FlatRate/WeightPlaceholder.php b/plugins/woocommerce/src/Internal/Shipping/FlatRate/WeightPlaceholder.php
new file mode 100644
index 00000000000..0491ffbc961
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Shipping/FlatRate/WeightPlaceholder.php
@@ -0,0 +1,176 @@
+<?php
+/**
+ * WeightPlaceholder class file.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\Shipping\FlatRate;
+
+use WC_Product;
+
+/**
+ * The [weight] placeholder used in flat rate shipping cost formulas.
+ *
+ * Works out the weight a package resolves to, and substitutes it into a cost formula.
+ */
+class WeightPlaceholder {
+
+ /**
+ * Matches [weight] along with its optional attributes, e.g. [weight min="1" max="20"].
+ *
+ * Whitespace or "]" must follow "weight". Attributes stop at the first closing bracket.
+ */
+ private const PLACEHOLDER_PATTERN = '/\[weight(?=\s|\])(?<attributes>[^]]*)]/';
+
+ /**
+ * Get the total weight of the shippable items in a package, in the store's weight unit.
+ *
+ * @param array $package Package of items from the cart.
+ * @return float
+ *
+ * @since 11.3.0
+ */
+ public function get_for_package( array $package ): float {
+ $items = $package['contents'] ?? array();
+
+ return is_array( $items ) ? $this->get_for_items( $items ) : 0.0;
+ }
+
+ /**
+ * Sum the weight of the given items, multiplied by their quantity.
+ *
+ * Items that don't need shipping, and items without a weight, contribute nothing. To adjust the weight a
+ * cost formula sees, hook `woocommerce_evaluate_shipping_cost_args` and change its `weight` argument.
+ *
+ * @param array $items Cart items, in the shape of a package's `contents`.
+ * @return float
+ *
+ * @since 11.3.0
+ */
+ public function get_for_items( array $items ): float {
+ $total_weight = 0.0;
+
+ foreach ( $items as $values ) {
+ if ( ! is_array( $values ) ) {
+ continue;
+ }
+
+ $product = $values['data'] ?? null;
+ $quantity = $values['quantity'] ?? 0;
+
+ if ( $product instanceof WC_Product && is_numeric( $quantity ) && $quantity > 0 && $product->needs_shipping() && $product->has_weight() ) {
+ $total_weight += $this->normalize( $product->get_weight() ) * $quantity;
+ }
+ }
+
+ return $total_weight;
+ }
+
+ /**
+ * Validate weight limits before saving a cost formula.
+ *
+ * @since 11.3.0
+ *
+ * @param string $sum Cost formula.
+ * @return void
+ * @throws \InvalidArgumentException If a weight limit or range is invalid.
+ */
+ public function validate( string $sum ): void {
+ preg_match_all( self::PLACEHOLDER_PATTERN, $sum, $matches, PREG_SET_ORDER );
+
+ foreach ( $matches as $match ) {
+ $atts = (array) shortcode_parse_atts( $match['attributes'] );
+
+ foreach ( array( 'min', 'max' ) as $attribute ) {
+ $limit = $atts[ $attribute ] ?? '';
+
+ if ( '' !== $limit && ( ! is_numeric( $limit ) || (float) $limit < 0 ) ) {
+ throw new \InvalidArgumentException(
+ sprintf(
+ /* translators: %s: weight placeholder attribute, either min or max. */
+ esc_html__( 'The [weight] %s value must be a non-negative number with a dot decimal separator and no thousands separators, e.g. 1000.5.', 'woocommerce' ),
+ esc_html( $attribute )
+ )
+ );
+ }
+ }
+
+ $min = $atts['min'] ?? '';
+ $max = $atts['max'] ?? '';
+
+ if ( '' !== $min && '' !== $max && (float) $min > (float) $max ) {
+ throw new \InvalidArgumentException( esc_html__( 'The [weight] min value cannot be greater than the max value.', 'woocommerce' ) );
+ }
+ }
+ }
+
+ /**
+ * Replace every [weight] placeholder in a cost formula with the given weight.
+ *
+ * Parsed directly rather than registered as a shortcode, so that no global shortcode is added for the
+ * duration of the calculation and nothing else in the formula is expanded.
+ *
+ * @param string $sum Cost formula.
+ * @param mixed $weight Package weight. Anything non-numeric counts as zero.
+ * @return string
+ *
+ * @since 11.3.0
+ */
+ public function expand( string $sum, $weight ): string {
+ $weight = $this->normalize( $weight );
+
+ return preg_replace_callback(
+ self::PLACEHOLDER_PATTERN,
+ function ( $matches ) use ( $weight ) {
+ return $this->clamp( $weight, shortcode_parse_atts( $matches['attributes'] ) );
+ },
+ $sum
+ ) ?? $sum;
+ }
+
+ /**
+ * Coerce an untrusted weight into a non-negative float.
+ *
+ * Weights reach this class through filters, so they can be anything. Negative weights are clamped the same
+ * way wc_get_weight() clamps them.
+ *
+ * @param mixed $weight Weight to coerce.
+ * @return float
+ */
+ private function normalize( $weight ): float {
+ return is_numeric( $weight ) ? max( 0.0, (float) $weight ) : 0.0;
+ }
+
+ /**
+ * Clamp a weight to the optional `min` and `max` attributes of a placeholder, and format it for the
+ * expression evaluator.
+ *
+ * @param float $weight Weight, in the store's weight unit.
+ * @param array|string $atts Placeholder attributes, as returned by shortcode_parse_atts().
+ * @return string
+ */
+ private function clamp( float $weight, $atts ): string {
+ // shortcode_parse_atts() returns the raw string when there are no attributes to parse.
+ $atts = shortcode_atts(
+ array(
+ 'min' => '',
+ 'max' => '',
+ ),
+ is_array( $atts ) ? $atts : array()
+ );
+
+ // is_numeric() rather than a truthiness test, so that min="0" and max="0" are honoured.
+ if ( is_numeric( $atts['min'] ) && $weight < (float) $atts['min'] ) {
+ $weight = (float) $atts['min'];
+ }
+
+ if ( is_numeric( $atts['max'] ) && $weight > (float) $atts['max'] ) {
+ $weight = (float) $atts['max'];
+ }
+
+ // WC_Eval_Math reads `e` as a constant and rejects scientific notation, so the weight has to be a plain
+ // dot-decimal string. A zero weight yields "0" rather than "", so `10 * [weight]` isn't trimmed to `10`.
+ return wc_format_decimal( $weight, false, true );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/includes/shipping/flat-rate/class-wc-shipping-flat-rate-test.php b/plugins/woocommerce/tests/php/includes/shipping/flat-rate/class-wc-shipping-flat-rate-test.php
index 7d8fcffe831..a71eb981e69 100644
--- a/plugins/woocommerce/tests/php/includes/shipping/flat-rate/class-wc-shipping-flat-rate-test.php
+++ b/plugins/woocommerce/tests/php/includes/shipping/flat-rate/class-wc-shipping-flat-rate-test.php
@@ -206,6 +206,17 @@ class WC_Shipping_Flat_Rate_Test extends WC_Unit_Test_Case {
'empty string' => array( '', '.', ',' ),
'shortcode qty' => array( '[qty]', '.', ',' ),
'shortcode expression' => array( '10.00 * [qty]', '.', ',' ),
+ 'shortcode weight' => array( '[weight]', '.', ',' ),
+ 'shortcode weight expression' => array( '2.50 * [weight] + 1', '.', ',' ),
+ 'shortcode weight with min' => array( '[weight min="1"]', '.', ',' ),
+ 'shortcode weight min and max' => array( '2 * [weight min="1" max="20"]', '.', ',' ),
+ 'weight min zero' => array( '2 * [weight min="0"]', '.', ',' ),
+ 'weight max zero' => array( '2 * [weight max="0"]', '.', ',' ),
+ 'weight equal limits' => array( '2 * [weight min="1" max="1"]', '.', ',' ),
+ 'weight empty limits' => array( '2 * [weight min="" max=""]', '.', ',' ),
+
+ // Safe because the minimum keeps the divisor away from zero.
+ 'weight division with min' => array( '10 / [weight min="1"]', '.', ',' ),
// period decimal, comma thousand.
'simple division' => array( '3.50 / 1.21', '.', ',' ),
@@ -246,6 +257,182 @@ class WC_Shipping_Flat_Rate_Test extends WC_Unit_Test_Case {
// Invalid characters.
'alphabetic string' => array( 'abc', '.', ',' ),
'alphanumeric' => array( '10abc', '.', ',' ),
+ 'hyphenated weight name' => array( '[weight-foo]', '.', ',' ),
+ 'dotted weight name' => array( '[weight.foo]', '.', ',' ),
+ 'hyphenated weight with limits' => array( '2 * [weight-foo min="1"]', ',', '.' ),
+ 'dotted weight with limits' => array( '2 * [weight.foo max="2"]', ',', '.' ),
+
+ // Divides by zero on any package whose items have no weight set.
+ 'weight division' => array( '10 / [weight]', '.', ',' ),
+ 'weight division with min zero' => array( '10 / [weight min="0"]', '.', ',' ),
+ );
+ }
+
+ /**
+ * @testdox The [weight] placeholder is replaced with the package weight.
+ *
+ * @dataProvider provider_weight_substitution
+ *
+ * @param string $sum Cost expression to evaluate.
+ * @param int|float|string $weight Weight passed in the args.
+ * @param float $expected Expected result.
+ */
+ public function test_evaluate_cost_substitutes_weight( string $sum, $weight, float $expected ): void {
+ $val = $this->call_evaluate_cost->call(
+ $this->sut,
+ $sum,
+ array(
+ 'qty' => 2,
+ 'cost' => 100,
+ 'weight' => $weight,
+ )
+ );
+
+ $this->assertFloatEquals( $expected, (float) $val, null, "Expected '{$sum}' to evaluate to {$expected}." );
+ }
+
+ /**
+ * Weight substitution cases.
+ *
+ * Only enough to prove the placeholder is wired into evaluate_cost() and survives the expression
+ * evaluator. Substitution and clamping are covered exhaustively in WeightPlaceholderTest.
+ *
+ * Format: [ expression, weight, expected result ]. Quantity is always 2 and cost is always 100.
+ *
+ * @return array
+ */
+ public function provider_weight_substitution(): array {
+ return array(
+ 'weight on its own' => array( '[weight]', 3, 3.0 ),
+ 'weight multiplied' => array( '2 * [weight]', 1.5, 3.0 ),
+ 'weight combined with qty' => array( '[weight] + [qty]', 2.25, 4.25 ),
+ 'zero weight keeps expression' => array( '10 * [weight]', 0, 0.0 ),
+ 'min raises a low weight' => array( '[weight min="1"]', 0, 1.0 ),
+ 'min keeps divisor non-zero' => array( '10 / [weight min="2"]', 0, 5.0 ),
+ );
+ }
+
+ /**
+ * @testdox The [weight] placeholder falls back to zero when callers omit the weight argument.
+ */
+ public function test_evaluate_cost_weight_defaults_to_zero_when_arg_missing(): void {
+ $val = $this->call_evaluate_cost->call(
+ $this->sut,
+ '10 + [weight]',
+ array(
+ 'qty' => 1,
+ 'cost' => 1,
+ )
+ );
+
+ $this->assertFloatEquals( 10.0, (float) $val, null, 'Subclasses that pass only cost and qty should still evaluate [weight] as zero.' );
+ }
+
+ /**
+ * @testdox A non-numeric weight coming from the args filter is treated as zero.
+ */
+ public function test_evaluate_cost_ignores_non_numeric_weight_from_filter(): void {
+ $callback = function ( $args ) {
+ $args['weight'] = 'not-a-number';
+ return $args;
+ };
+ add_filter( 'woocommerce_evaluate_shipping_cost_args', $callback );
+
+ try {
+ $val = $this->call_evaluate_cost->call(
+ $this->sut,
+ '5 + [weight]',
+ array(
+ 'qty' => 1,
+ 'cost' => 1,
+ 'weight' => 2,
+ )
+ );
+ } finally {
+ remove_filter( 'woocommerce_evaluate_shipping_cost_args', $callback );
+ }
+
+ $this->assertFloatEquals( 5.0, (float) $val, null, 'A non-numeric weight from a filter should fall back to zero.' );
+ }
+
+ /**
+ * @testdox The [weight] placeholder works alongside a comma decimal separator.
+ */
+ public function test_evaluate_cost_weight_with_comma_decimal_separator(): void {
+ $val = $this->call_evaluate_cost->call(
+ $this->sut,
+ '1,5 * [weight]',
+ array(
+ 'qty' => 1,
+ 'cost' => 1,
+ 'weight' => 2.5,
+ )
+ );
+
+ $this->assertFloatEquals( 3.75, (float) $val, null, 'The substituted weight should survive decimal separator normalisation.' );
+ }
+
+ /**
+ * @testdox Weight limits accept dot decimals even when the store uses a comma decimal separator.
+ *
+ * @testWith ["10 * [weight min=\"0.5\"]", 0.0, 5.0]
+ * ["10 * [weight max=\"1.5\"]", 3.0, 15.0]
+ *
+ * @param string $sum Cost formula with a decimal weight limit.
+ * @param float $weight Package weight in the store's unit.
+ * @param float $expected Expected shipping cost.
+ */
+ public function test_evaluate_cost_weight_with_decimal_bounds( string $sum, float $weight, float $expected ): void {
+ $val = $this->call_evaluate_cost->call(
+ $this->sut,
+ $this->sut->sanitize_cost( $sum ),
+ array(
+ 'qty' => 1,
+ 'cost' => 1,
+ 'weight' => $weight,
+ )
+ );
+
+ $this->assertFloatEquals( $expected, (float) $val, null, "Expected '{$sum}' to honor its decimal weight limit." );
+ }
+
+ /**
+ * @testdox Invalid weight limits produce a validation error when saving the cost.
+ *
+ * @testWith ["10 * [weight min=\"0,5\"]"]
+ * ["10 * [weight max=\"1,5\"]"]
+ * ["10 * [weight min=\"100,000.50\"]"]
+ * ["10 * [weight max=\"100,00.5\"]"]
+ * ["10 * [weight min=\"1.000,50\"]"]
+ * ["10 * [weight max=\"1 000.50\"]"]
+ * ["10 * [weight min=\"abc\"]"]
+ * ["10 * [weight max=\"abc\"]"]
+ * ["10 * [weight min=\"-1\"]"]
+ * ["10 * [weight max=\"-1\"]"]
+ * ["10 * [weight min=\"20\" max=\"10\"]"]
+ * ["10 * [weight min=\"0.5\" max=\"0\"]"]
+ *
+ * @param string $sum Cost formula with an invalid weight limit.
+ */
+ public function test_sanitize_cost_rejects_invalid_weight_limits( string $sum ): void {
+ $this->expectException( InvalidArgumentException::class );
+ $this->sut->sanitize_cost( $sum );
+ }
+
+ /**
+ * @testdox The [fee] shortcode stays based on cost when a weight is supplied.
+ */
+ public function test_evaluate_cost_weight_does_not_change_fee_base(): void {
+ $val = $this->call_evaluate_cost->call(
+ $this->sut,
+ '[fee percent="10"] + [weight]',
+ array(
+ 'qty' => 1,
+ 'cost' => 100,
+ 'weight' => 2,
+ )
);
+
+ $this->assertFloatEquals( 12.0, (float) $val, null, 'The fee should be a percentage of cost, with the weight added on top.' );
}
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Shipping/FlatRate/WeightPlaceholderTest.php b/plugins/woocommerce/tests/php/src/Internal/Shipping/FlatRate/WeightPlaceholderTest.php
new file mode 100644
index 00000000000..37891745719
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Shipping/FlatRate/WeightPlaceholderTest.php
@@ -0,0 +1,256 @@
+<?php
+/**
+ * WeightPlaceholderTest class file.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Shipping\FlatRate;
+
+use Automattic\WooCommerce\Internal\Shipping\FlatRate\WeightPlaceholder;
+use WC_Helper_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for WeightPlaceholder.
+ */
+class WeightPlaceholderTest extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var WeightPlaceholder
+ */
+ private $sut;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->sut = $this->get_instance_of( WeightPlaceholder::class );
+ }
+
+ /**
+ * @testdox Package weight sums shippable items with a weight, multiplied by quantity.
+ */
+ public function test_get_for_package_sums_shippable_weighted_items(): void {
+ $package = array(
+ 'contents' => array(
+ 'weighted' => array(
+ 'data' => WC_Helper_Product::create_simple_product( false, array( 'weight' => '1.5' ) ),
+ 'quantity' => 2,
+ ),
+ 'no_weight' => array(
+ 'data' => WC_Helper_Product::create_simple_product( false, array( 'weight' => '' ) ),
+ 'quantity' => 1,
+ ),
+ 'virtual' => array(
+ 'data' => WC_Helper_Product::create_simple_product(
+ false,
+ array(
+ 'weight' => '5',
+ 'virtual' => true,
+ )
+ ),
+ 'quantity' => 3,
+ ),
+ 'no_quantity' => array(
+ 'data' => WC_Helper_Product::create_simple_product( false, array( 'weight' => '9' ) ),
+ 'quantity' => 0,
+ ),
+ ),
+ );
+
+ $this->assertFloatEquals( 3.0, $this->sut->get_for_package( $package ), null, 'Only shippable items that have a weight should contribute to the package weight.' );
+ }
+
+ /**
+ * @testdox Package weight uses the parent weight for variations that have none of their own.
+ */
+ public function test_get_for_package_uses_parent_weight_for_variations(): void {
+ $variable_product = WC_Helper_Product::create_variation_product();
+ $variable_product->set_weight( '2' );
+ $variable_product->save();
+
+ $children = $variable_product->get_children();
+ $variation = wc_get_product( $children[0] );
+
+ $package = array(
+ 'contents' => array(
+ 'variation' => array(
+ 'data' => $variation,
+ 'quantity' => 2,
+ ),
+ ),
+ );
+
+ $this->assertFloatEquals( 4.0, $this->sut->get_for_package( $package ), null, 'A variation without its own weight should inherit the parent product weight.' );
+ }
+
+ /**
+ * @testdox Package weight is zero when the package has no contents.
+ */
+ public function test_get_for_package_returns_zero_for_empty_contents(): void {
+ $this->assertSame( 0.0, $this->sut->get_for_package( array( 'contents' => array() ) ), 'An empty package should weigh zero.' );
+ }
+
+ /**
+ * @testdox Package weight is zero when the package has no contents key at all.
+ */
+ public function test_get_for_package_returns_zero_when_contents_missing(): void {
+ $this->assertSame( 0.0, $this->sut->get_for_package( array() ), 'A package without contents should weigh zero.' );
+ }
+
+ /**
+ * @testdox Non-array package contents do not cause a type error.
+ *
+ * @testWith [null]
+ * ["invalid"]
+ * [false]
+ * [12]
+ *
+ * @param mixed $contents Invalid package contents.
+ */
+ public function test_get_for_package_ignores_invalid_contents( $contents ): void {
+ $this->assertSame( 0.0, $this->sut->get_for_package( array( 'contents' => $contents ) ), 'Invalid contents must not contribute weight.' );
+ }
+
+ /**
+ * @testdox Malformed items do not prevent valid items from contributing weight.
+ */
+ public function test_get_for_items_ignores_malformed_items(): void {
+ $items = $this->single_item( '2.5', 2 );
+ $product = $items['item']['data'];
+
+ $items['missing_quantity'] = array( 'data' => $product );
+ $items['invalid_quantity'] = array(
+ 'data' => $product,
+ 'quantity' => 'invalid',
+ );
+ $items['missing_product'] = array( 'quantity' => 2 );
+ $items['invalid_product'] = array(
+ 'data' => false,
+ 'quantity' => 2,
+ );
+ $items['invalid_item'] = 'invalid';
+ $items['object_item'] = new \stdClass();
+
+ $this->assertSame( 5.0, $this->sut->get_for_items( $items ), 'Only valid items should contribute to the weight.' );
+ }
+
+ /**
+ * @testdox A negative product weight is clamped to zero.
+ */
+ public function test_get_for_items_clamps_negative_product_weight(): void {
+ $this->assertSame(
+ 0.0,
+ $this->sut->get_for_items( $this->single_item( '-5', 2 ) ),
+ 'A negative weight should be clamped, the same way wc_get_weight() clamps it.'
+ );
+ }
+
+ /**
+ * @testdox Negative product weights do not reduce the weight of other items.
+ *
+ * @testWith ["-1"]
+ * ["-5"]
+ *
+ * @param string $negative_weight Weight of the product that must contribute nothing.
+ */
+ public function test_get_for_items_does_not_subtract_negative_product_weights( string $negative_weight ): void {
+ $items = array(
+ array(
+ 'data' => WC_Helper_Product::create_simple_product( false, array( 'weight' => '2.5' ) ),
+ 'quantity' => 2,
+ ),
+ array(
+ 'data' => WC_Helper_Product::create_simple_product( false, array( 'weight' => $negative_weight ) ),
+ 'quantity' => 2,
+ ),
+ );
+
+ $this->assertSame( 5.0, $this->sut->get_for_items( $items ), 'Negative product weights must not cancel out positive weights.' );
+ }
+
+ /**
+ * @testdox The [weight] placeholder is replaced with the weight.
+ *
+ * @dataProvider provider_placeholder_replacement
+ *
+ * @param string $sum Cost formula.
+ * @param mixed $weight Weight to substitute.
+ * @param string $expected Expected formula after replacement.
+ */
+ public function test_expand( string $sum, $weight, string $expected ): void {
+ $this->assertSame( $expected, $this->sut->expand( $sum, $weight ), "Expected '{$sum}' to expand to '{$expected}'." );
+ }
+
+ /**
+ * Placeholder replacement cases.
+ *
+ * Format: [ formula, weight, expected formula ].
+ *
+ * @return array
+ */
+ public function provider_placeholder_replacement(): array {
+ return array(
+ 'bare placeholder' => array( '[weight]', 3, '3' ),
+ 'placeholder in an expression' => array( '2 * [weight]', 1.5, '2 * 1.5' ),
+ 'repeated placeholder' => array( '[weight] + [weight]', 2, '2 + 2' ),
+ 'independent placeholder limits' => array( '[weight min="5"] + [weight max="2"]', 3, '5 + 2' ),
+ 'longer placeholder name' => array( '[weightless min="1"]', 3, '[weightless min="1"]' ),
+ 'hyphenated placeholder name' => array( '[weight-foo]', 3, '[weight-foo]' ),
+ 'dotted placeholder name' => array( '[weight.foo]', 3, '[weight.foo]' ),
+ 'hyphenated name with limits' => array( '[weight-foo min="1"]', 3, '[weight-foo min="1"]' ),
+ 'dotted name with limits' => array( '[weight.foo max="2"]', 3, '[weight.foo max="2"]' ),
+ 'unterminated placeholder' => array( '[weight min="1"', 3, '[weight min="1"' ),
+ 'no placeholder is untouched' => array( '10 * [qty]', 5, '10 * [qty]' ),
+ 'numeric string weight' => array( '[weight]', '2.5', '2.5' ),
+
+ // A zero weight must not collapse to an empty string, or `10 * [weight]` would become `10 *`.
+ 'zero weight keeps the operand' => array( '10 * [weight]', 0, '10 * 0' ),
+
+ // Anything non-numeric or negative counts as zero.
+ 'non-numeric weight' => array( '[weight]', 'not-a-number', '0' ),
+ 'null weight' => array( '[weight]', null, '0' ),
+ 'array weight' => array( '[weight]', array( 1 ), '0' ),
+ 'negative weight clamped' => array( '[weight]', -5, '0' ),
+
+ // WC_Eval_Math reads `e` as a constant and has no thousands separator.
+ 'small weight is not an exponent' => array( '[weight]', 0.00001, '0.00001' ),
+ 'large weight has no separator' => array( '[weight]', 1000000.0, '1000000' ),
+ 'float imprecision normalised' => array( '[weight]', 0.1 + 0.2, '0.3' ),
+
+ // wc_get_rounding_precision() is 6 by default, so anything finer truncates away.
+ 'weight below rounding precision' => array( '[weight]', 0.0000001, '0' ),
+
+ // Minimum and maximum attributes.
+ 'min raises a low weight' => array( '[weight min="1"]', 0, '1' ),
+ 'min ignored above the floor' => array( '[weight min="1"]', 4, '4' ),
+ 'max caps a high weight' => array( '[weight max="20"]', 50, '20' ),
+ 'max ignored below the ceiling' => array( '[weight max="20"]', 5, '5' ),
+ 'min and max together' => array( '[weight min="1" max="20"]', 0, '1' ),
+ 'explicit min zero is honoured' => array( '[weight min="0"]', 3, '3' ),
+ 'min keeps a divisor non-zero' => array( '10 / [weight min="2"]', 0, '10 / 2' ),
+ 'unknown attributes are ignored' => array( '[weight foo="bar"]', 3, '3' ),
+ 'non-numeric min is ignored' => array( '[weight min="abc"]', 3, '3' ),
+ );
+ }
+
+ /**
+ * Build a package contents array holding a single simple product.
+ *
+ * @param string $weight Product weight.
+ * @param int $quantity Item quantity.
+ * @return array
+ */
+ private function single_item( string $weight, int $quantity ): array {
+ return array(
+ 'item' => array(
+ 'data' => WC_Helper_Product::create_simple_product( false, array( 'weight' => $weight ) ),
+ 'quantity' => $quantity,
+ ),
+ );
+ }
+}