Commit b54f9602dce for woocommerce

commit b54f9602dce2a43fd1616f3f14a1226f96ad9fd4
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date:   Thu May 7 13:09:08 2026 +0300

    Add accessible star-rating control for Review Order page (#64525)

    * Add accessible star-rating control for Review Order page

    Native `<input type="radio">` ✕ 5 wrapped in a `role="radiogroup"` container,
    visually replaced by SVG stars. Progressive enhancement adds:

    - Keyboard nav: ArrowLeft/Up, ArrowRight/Down, Home, End. Selecting also
      moves focus and dispatches `change`, so any future submission listener
      reacts naturally.
    - Dynamic caption underneath the stars, populated from the focused/checked
      radio's `data-label`. Defaults match the existing WC product-review
      labels (Very poor / Not that bad / Average / Good / Perfect) and are
      filterable via `woocommerce_review_order_rating_labels`.
    - High-contrast focus ring on the wrapping label whenever its input is
      `:focus-visible`.
    - Hover preview lights stars left of the cursor; selection persists when
      the cursor leaves.

    Without JavaScript the underlying radio group still works as a plain
    form field, so the page stays functional.

    Files:

    - `src/Internal/OrderReviews/StarRating.php` — server-side renderer.
      Builds the markup and exposes `get_labels()` (filter-aware, with safe
      fallbacks for buggy filters that drop slot keys).
    - `templates/order/star-rating.php` — theme-overridable partial.
    - `client/legacy/js/frontend/order-review.js` — vanilla JS, no jQuery.
    - `client/legacy/css/order-review.scss` — visual states.
    - `Internal/OrderReviews/Endpoint::enqueue_assets()` — script + style
      enqueued only when the review-order endpoint is actually rendering.
    - `templates/order/customer-review-order.php` — temporary preview render
      per item so reviewers can interact with the control. M4 (WOOPLUG-6595)
      swaps this preview for the real per-item form row.

    Tests:

    - `Internal\OrderReviews\StarRatingTest` — markup, defaults, filter
      override, filter fallback for missing keys.

    Closes #64308 (WOOPLUG-6594).

    * Add changefile(s) from automation for the following project(s): woocommerce

    * Remove duplicate changelog file (workflow generates the canonical one)

    * Address Copilot feedback on star-rating control PR

    - Route enqueue URLs through woocommerce_get_asset_url filter so
      CDN/asset-rewrite setups continue to work.
    - Add screen-reader-only 'Required' text alongside the visual asterisk
      on the rating label.
    - Correct the JS file header comment: only Arrow keys + Home/End are
      handled, not Space/Enter.

    * Fix StarRating docblock to use flat @param syntax

    The PHPDoc shape syntax (array{...}) trips the WC PHPCS sniff, which
    expects a single @param line per parameter. Reverted to plain
    @param array with the key descriptions in the body.

    * Drop the planning comment + minor tidy on star-rating styles

    * Load the RTL stylesheet variant on RTL sites

    wp_style_add_data( 'wc-order-review', 'rtl', 'replace' ) tells the
    classic-assets pipeline to swap order-review.css for order-review-rtl.css
    on right-to-left locales.

    * Trim defensive fallbacks and dedupe asset enqueue helpers

    - StarRating::get_labels(): replace the manual 1-5 fallback loop and
      reorder block with array_replace + array_intersect_key.
    - StarRating::render(): drop the unused $selected pre-cast; read it
      inline only when it's used.
    - Endpoint::enqueue_assets(): factor a local closure for the
      woocommerce_get_asset_url filter so we don't repeat the same call
      for the style and the script.
    - order/star-rating.php template: drop the data-caption-id attribute
      that duplicated aria-describedby.
    - order-review.js: read the caption id from aria-describedby and drop
      the empty-input early-out (init() already filters by class).

    No behaviour change. All six StarRating tests still pass.

    * Address review feedback on the star-rating control

    - Add @since 10.8.0 annotations on StarRating::render() and
      StarRating::get_labels() docblocks (per ayushpahwa).
    - Bound the `selected` argument: values outside 0-5 fall back to
      no-selection rather than passing an unrenderable value to the
      template (per ayushpahwa).
    - Cover the bound with a regression test.

    * Address remaining review feedback on the star-rating control

    - CSS: drop every `:has()` selector. The template now renders the
      inputs in reverse DOM order (5..1) and uses `flex-direction:
      row-reverse`; selected/hover/focus states are driven by the `~`
      sibling combinator and `:focus-visible + label`. Caption gets
      `order: -1` so it stays on the visual right.
    - JS: keyboard navigation flips so ArrowRight/Down still moves
      visually right (DOM-previous) and Home/End map to the visual
      leftmost/rightmost stars.
    - Template: pre-compute `$initial_caption` so the caption element no
      longer mixes selection logic, escaping, and output in a single
      expression.
    - Endpoint: enqueue the JS with `strategy => defer` (in addition to
      `in_footer => true`) since it only attaches DOM-ready listeners.

    ---------

    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/64525-wooplug-6594-accessible-star-rating-control b/plugins/woocommerce/changelog/64525-wooplug-6594-accessible-star-rating-control
new file mode 100644
index 00000000000..5813d0415f7
--- /dev/null
+++ b/plugins/woocommerce/changelog/64525-wooplug-6594-accessible-star-rating-control
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add the accessible 5-star rating control used by the Review Order page. Native radio inputs with keyboard navigation (arrows, Home, End), dynamic caption, and visible focus ring; filterable labels via `woocommerce_review_order_rating_labels`.
\ No newline at end of file
diff --git a/plugins/woocommerce/client/legacy/css/order-review.scss b/plugins/woocommerce/client/legacy/css/order-review.scss
new file mode 100644
index 00000000000..06ea797fe44
--- /dev/null
+++ b/plugins/woocommerce/client/legacy/css/order-review.scss
@@ -0,0 +1,85 @@
+/**
+ * Review Order page styles.
+ *
+ * Intentionally minimal. The active theme drives typography, color, and
+ * button styling; only the interactive star-rating control needs bespoke
+ * CSS to map radio inputs to SVG stars.
+ *
+ * The star control renders with `flex-direction: row-reverse`: the DOM
+ * order is 5..1 so that sibling combinators (`~`) can fill all stars up
+ * to the selected/hovered one without relying on `:has()`.
+ */
+
+.woocommerce-star-rating {
+	display: inline-flex;
+	flex-direction: row-reverse;
+	justify-content: flex-end;
+	align-items: center;
+	flex-wrap: wrap;
+	gap: 2px;
+	line-height: 1;
+
+	// Visually hide the native radios but keep them in the a11y tree.
+	&__input {
+		position: absolute;
+		width: 1px;
+		height: 1px;
+		padding: 0;
+		margin: -1px;
+		overflow: hidden;
+		clip: rect(0, 0, 0, 0);
+		white-space: nowrap;
+		border: 0;
+	}
+
+	&__star {
+		cursor: pointer;
+		display: inline-flex;
+		padding: 4px;
+		color: currentColor;
+	}
+
+	&__icon {
+		display: block;
+		width: 1.5em;
+		height: 1.5em;
+		fill: currentColor;
+		opacity: 0.3;
+		transition: opacity 100ms ease-in-out;
+	}
+
+	// Fill: a checked input fills its own label and every following sibling
+	// star (which appear visually before it under row-reverse).
+	&__input:checked ~ .woocommerce-star-rating__star .woocommerce-star-rating__icon {
+		opacity: 1;
+	}
+
+	// Hover preview: when hovering the rating, dim everything, then re-fill
+	// from the hovered star outward (visual left).
+	&:hover .woocommerce-star-rating__icon {
+		opacity: 0.3;
+	}
+
+	&__star:hover .woocommerce-star-rating__icon,
+	&__star:hover ~ .woocommerce-star-rating__star .woocommerce-star-rating__icon {
+		opacity: 1;
+	}
+
+	// Focus ring: the input sits next to its own label; style the label when
+	// the input has visible keyboard focus.
+	&__input:focus-visible + .woocommerce-star-rating__star {
+		outline: 2px solid currentColor;
+		outline-offset: 2px;
+	}
+
+	// Caption keeps a stable footprint so the layout doesn't shift when the
+	// label text changes. Negative order sends it to the visual right of the
+	// stars (under row-reverse, the lowest-ordered item lays out first along
+	// the main axis, which is the rightmost slot).
+	&__caption {
+		order: -1;
+		margin-left: 0.5em;
+		min-height: 1em;
+		min-width: 4em;
+	}
+}
diff --git a/plugins/woocommerce/client/legacy/js/frontend/order-review.js b/plugins/woocommerce/client/legacy/js/frontend/order-review.js
new file mode 100644
index 00000000000..9106800c736
--- /dev/null
+++ b/plugins/woocommerce/client/legacy/js/frontend/order-review.js
@@ -0,0 +1,86 @@
+/* global document, window */
+/**
+ * Progressive enhancement for the Review Order page.
+ *
+ * Adds keyboard navigation (Left/Right/Up/Down + Home/End) and a dynamic
+ * caption to every `.woocommerce-star-rating` group on the page.
+ * Without this script the underlying native radio inputs still work.
+ */
+( function () {
+	'use strict';
+
+	/**
+	 * @param {HTMLElement} container `.woocommerce-star-rating` element.
+	 */
+	function initGroup( container ) {
+		var inputs = Array.prototype.slice.call(
+			container.querySelectorAll( '.woocommerce-star-rating__input' )
+		);
+		var captionId = container.getAttribute( 'aria-describedby' );
+		var caption = captionId ? document.getElementById( captionId ) : null;
+
+		function syncCaption() {
+			if ( ! caption ) {
+				return;
+			}
+			var checked = inputs.filter( function ( input ) {
+				return input.checked;
+			} )[ 0 ];
+			caption.textContent = checked
+				? checked.getAttribute( 'data-label' ) || ''
+				: '';
+		}
+
+		function focusInput( input ) {
+			input.focus();
+			input.checked = true;
+			input.dispatchEvent( new window.Event( 'change', { bubbles: true } ) );
+		}
+
+		// DOM order is 5..1 (reversed for the CSS row-reverse layout), so
+		// "next visual star" is the previous DOM input and vice-versa.
+		// Home/End map to visual-leftmost / visual-rightmost = inputs[last] /
+		// inputs[0] in DOM order.
+		inputs.forEach( function ( input, index ) {
+			input.addEventListener( 'change', syncCaption );
+
+			input.addEventListener( 'keydown', function ( event ) {
+				var nextIndex = null;
+				switch ( event.key ) {
+					case 'ArrowRight':
+					case 'ArrowDown':
+						nextIndex =
+							( index - 1 + inputs.length ) % inputs.length;
+						break;
+					case 'ArrowLeft':
+					case 'ArrowUp':
+						nextIndex = ( index + 1 ) % inputs.length;
+						break;
+					case 'Home':
+						nextIndex = inputs.length - 1;
+						break;
+					case 'End':
+						nextIndex = 0;
+						break;
+					default:
+						return;
+				}
+				event.preventDefault();
+				focusInput( inputs[ nextIndex ] );
+			} );
+		} );
+
+		syncCaption();
+	}
+
+	function init() {
+		var groups = document.querySelectorAll( '.woocommerce-star-rating' );
+		Array.prototype.forEach.call( groups, initGroup );
+	}
+
+	if ( document.readyState === 'loading' ) {
+		document.addEventListener( 'DOMContentLoaded', init );
+	} else {
+		init();
+	}
+} )();
diff --git a/plugins/woocommerce/src/Internal/OrderReviews/Endpoint.php b/plugins/woocommerce/src/Internal/OrderReviews/Endpoint.php
index b628a3f9759..4e336a01871 100644
--- a/plugins/woocommerce/src/Internal/OrderReviews/Endpoint.php
+++ b/plugins/woocommerce/src/Internal/OrderReviews/Endpoint.php
@@ -7,6 +7,7 @@ declare( strict_types = 1 );

 namespace Automattic\WooCommerce\Internal\OrderReviews;

+use Automattic\Jetpack\Constants;
 use Automattic\WooCommerce\Enums\OrderStatus;
 use WC_Order;
 use WP_Post;
@@ -251,6 +252,10 @@ class Endpoint {
 			$this->render_404();
 			exit;
 		}
+
+		// template_redirect fires after wp_enqueue_scripts but before
+		// wp_head, so styles registered here are still output in <head>.
+		$this->enqueue_assets();
 	}

 	/**
@@ -399,6 +404,37 @@ class Endpoint {
 		return true;
 	}

+	/**
+	 * Enqueue the JS and CSS that progressively enhance the page.
+	 *
+	 * Both files live under `client/legacy/` and are built into
+	 * `assets/{js|css}/` by the classic-assets pipeline.
+	 */
+	private function enqueue_assets(): void {
+		$plugin_url = untrailingslashit( plugins_url( '', WC_PLUGIN_FILE ) );
+		$suffix     = defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ? '' : '.min';
+		$version    = Constants::get_constant( 'WC_VERSION' );
+		$asset_url  = static function ( string $path ) use ( $plugin_url ): string {
+			// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- documented in includes/class-wc-frontend-scripts.php.
+			return (string) apply_filters( 'woocommerce_get_asset_url', $plugin_url . $path, $path );
+		};
+
+		wp_enqueue_style( 'wc-order-review', $asset_url( '/assets/css/order-review.css' ), array(), $version );
+		// Tell WP to swap to the *-rtl.css variant on RTL sites.
+		wp_style_add_data( 'wc-order-review', 'rtl', 'replace' );
+
+		wp_enqueue_script(
+			'wc-order-review',
+			$asset_url( '/assets/js/frontend/order-review' . $suffix . '.js' ),
+			array(),
+			$version,
+			array(
+				'strategy'  => 'defer',
+				'in_footer' => true,
+			)
+		);
+	}
+
 	/**
 	 * Mark the current request as a 404 and load the theme's 404 template.
 	 *
diff --git a/plugins/woocommerce/src/Internal/OrderReviews/StarRating.php b/plugins/woocommerce/src/Internal/OrderReviews/StarRating.php
new file mode 100644
index 00000000000..b30b929f5d1
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/OrderReviews/StarRating.php
@@ -0,0 +1,103 @@
+<?php
+/**
+ * StarRating control class file.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\OrderReviews;
+
+/**
+ * Server-side renderer for the accessible 5-star rating control used on the
+ * Review Order page.
+ *
+ * The control degrades to native `<input type="radio">` elements without
+ * JavaScript and is enhanced by `client/legacy/js/frontend/order-review.js`
+ * for keyboard navigation, a visible focus ring, and the dynamic caption
+ * underneath the stars.
+ *
+ * @internal Just for internal use.
+ *
+ * @since 10.8.0
+ */
+class StarRating {
+
+	/**
+	 * Render the rating control. Returns the HTML; does not echo.
+	 *
+	 * Required keys in `$args`:
+	 *  - `name` (string): form field name (one per item).
+	 *  - `id_prefix` (string): prefix used to build unique radio ids.
+	 *  - `label_id` (string): id of the existing label element that describes
+	 *    the group via `aria-labelledby`.
+	 *
+	 * Optional:
+	 *  - `selected` (int): pre-selected value 0-5; pass `0` (the default)
+	 *    for no pre-selection. Values outside 0-5 are treated as no selection.
+	 *
+	 * @since 10.8.0
+	 *
+	 * @param array $args Render arguments. See description for required and optional keys.
+	 * @return string
+	 */
+	public static function render( array $args ): string {
+		$name      = (string) ( $args['name'] ?? '' );
+		$id_prefix = (string) ( $args['id_prefix'] ?? '' );
+		$label_id  = (string) ( $args['label_id'] ?? '' );
+
+		if ( '' === $name || '' === $id_prefix || '' === $label_id ) {
+			return '';
+		}
+
+		$selected = (int) ( $args['selected'] ?? 0 );
+		if ( $selected < 0 || $selected > 5 ) {
+			$selected = 0;
+		}
+
+		ob_start();
+		wc_get_template(
+			'order/star-rating.php',
+			array(
+				'name'      => $name,
+				'id_prefix' => $id_prefix,
+				'label_id'  => $label_id,
+				'selected'  => $selected,
+				'labels'    => self::get_labels(),
+			)
+		);
+		return (string) ob_get_clean();
+	}
+
+	/**
+	 * Default rating labels, indexed 1-5, after the customer-facing filter.
+	 *
+	 * Defaults match the long-standing WooCommerce product-review labels in
+	 * `templates/single-product-reviews.php` so customers see consistent
+	 * wording across the two review entry points.
+	 *
+	 * @since 10.8.0
+	 *
+	 * @return array<int, string>
+	 */
+	public static function get_labels(): array {
+		$labels = array(
+			1 => __( 'Very poor', 'woocommerce' ),
+			2 => __( 'Not that bad', 'woocommerce' ),
+			3 => __( 'Average', 'woocommerce' ),
+			4 => __( 'Good', 'woocommerce' ),
+			5 => __( 'Perfect', 'woocommerce' ),
+		);
+
+		/**
+		 * Filter the labels shown under the star-rating control.
+		 *
+		 * @since 10.8.0
+		 *
+		 * @param array<int, string> $labels Map of rating value (1-5) to label.
+		 */
+		$filtered = (array) apply_filters( 'woocommerce_review_order_rating_labels', $labels );
+
+		// Keep only known 1-5 keys; fall back to defaults for any the filter dropped.
+		return array_replace( $labels, array_intersect_key( $filtered, $labels ) );
+	}
+}
diff --git a/plugins/woocommerce/templates/order/star-rating.php b/plugins/woocommerce/templates/order/star-rating.php
new file mode 100644
index 00000000000..6941b759054
--- /dev/null
+++ b/plugins/woocommerce/templates/order/star-rating.php
@@ -0,0 +1,83 @@
+<?php
+/**
+ * Star-rating control partial.
+ *
+ * Theme-overridable. Copy to `yourtheme/woocommerce/order/star-rating.php`.
+ *
+ * Renders five native `<input type="radio">` elements wrapped in a
+ * `role="radiogroup"` container, with SVG icons added as decorative siblings
+ * for the visual stars and a caption span the JS module updates as the
+ * selection changes. Without JavaScript the customer still has a working
+ * (if visually plain) radio group.
+ *
+ * @see https://woocommerce.com/document/template-structure/
+ * @package WooCommerce\Templates
+ * @version 10.8.0
+ *
+ * @var string             $name      Form field name (e.g. `reviews[123][rating]`).
+ * @var string             $id_prefix Prefix used for unique radio ids.
+ * @var string             $label_id  Existing label id; bound via aria-labelledby.
+ * @var int                $selected  Pre-selected value (0 = none).
+ * @var array<int, string> $labels    Map of value (1-5) to caption text.
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+$caption_id      = $id_prefix . '-caption';
+$initial_caption = $selected > 0 && isset( $labels[ $selected ] ) ? $labels[ $selected ] : '';
+
+// Render in reverse (5 first, 1 last) so the visual layout — driven by
+// `flex-direction: row-reverse` — can use ~ sibling selectors for the
+// "fill stars 1..N" effect without depending on `:has()`.
+$reversed = array_reverse( $labels, true );
+?>
+<div
+	class="woocommerce-star-rating"
+	role="radiogroup"
+	aria-labelledby="<?php echo esc_attr( $label_id ); ?>"
+	aria-describedby="<?php echo esc_attr( $caption_id ); ?>"
+>
+	<?php foreach ( $reversed as $value => $label ) : ?>
+		<?php
+		$input_id = $id_prefix . '-' . $value;
+		$checked  = $value === $selected;
+		?>
+		<input
+			class="woocommerce-star-rating__input"
+			type="radio"
+			id="<?php echo esc_attr( $input_id ); ?>"
+			name="<?php echo esc_attr( $name ); ?>"
+			value="<?php echo esc_attr( (string) $value ); ?>"
+			data-label="<?php echo esc_attr( $label ); ?>"
+			<?php checked( $checked ); ?>
+		/>
+		<label class="woocommerce-star-rating__star" for="<?php echo esc_attr( $input_id ); ?>">
+			<span class="screen-reader-text">
+				<?php
+				printf(
+					/* translators: 1: numeric star rating 2: label text e.g. "Good" */
+					esc_html__( '%1$d out of 5 stars: %2$s', 'woocommerce' ),
+					(int) $value,
+					esc_html( $label )
+				);
+				?>
+			</span>
+			<svg
+				class="woocommerce-star-rating__icon"
+				width="24"
+				height="24"
+				viewBox="0 0 24 24"
+				aria-hidden="true"
+				focusable="false"
+			>
+				<path d="M12 2.5l2.92 6.36 6.99.74-5.21 4.74 1.46 6.86L12 17.77l-6.16 3.43 1.46-6.86L2.09 9.6l6.99-.74L12 2.5z" />
+			</svg>
+		</label>
+	<?php endforeach; ?>
+
+	<span
+		id="<?php echo esc_attr( $caption_id ); ?>"
+		class="woocommerce-star-rating__caption"
+		aria-live="polite"
+	><?php echo esc_html( $initial_caption ); ?></span>
+</div>
diff --git a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/StarRatingTest.php b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/StarRatingTest.php
new file mode 100644
index 00000000000..58be565de49
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/StarRatingTest.php
@@ -0,0 +1,165 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\OrderReviews;
+
+use Automattic\WooCommerce\Internal\OrderReviews\StarRating;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the accessible star-rating renderer.
+ */
+class StarRatingTest extends WC_Unit_Test_Case {
+
+	/**
+	 * Reset filter state between tests.
+	 */
+	public function tearDown(): void {
+		remove_all_filters( 'woocommerce_review_order_rating_labels' );
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox render() emits a radiogroup, five radios, and a caption span.
+	 */
+	public function test_render_emits_complete_markup(): void {
+		$html = StarRating::render(
+			array(
+				'name'      => 'reviews[42][rating]',
+				'id_prefix' => 'review-rating-42',
+				'label_id'  => 'review-rating-label-42',
+			)
+		);
+
+		$this->assertStringContainsString( 'role="radiogroup"', $html );
+		$this->assertStringContainsString( 'aria-labelledby="review-rating-label-42"', $html );
+		$this->assertStringContainsString( 'aria-describedby="review-rating-42-caption"', $html );
+
+		// Five inputs, each with a unique id.
+		foreach ( range( 1, 5 ) as $value ) {
+			$this->assertStringContainsString( 'id="review-rating-42-' . $value . '"', $html );
+			$this->assertStringContainsString( 'value="' . $value . '"', $html );
+		}
+
+		$this->assertStringContainsString( 'name="reviews[42][rating]"', $html );
+		$this->assertStringContainsString( 'aria-live="polite"', $html );
+	}
+
+	/**
+	 * @testdox render() returns an empty string when required args are missing.
+	 */
+	public function test_render_returns_empty_when_required_args_missing(): void {
+		$this->assertSame( '', StarRating::render( array() ) );
+		$this->assertSame(
+			'',
+			StarRating::render(
+				array(
+					'name'      => 'foo',
+					'id_prefix' => '',
+					'label_id'  => 'bar',
+				)
+			)
+		);
+	}
+
+	/**
+	 * @testdox render() pre-checks the supplied selected value.
+	 */
+	public function test_render_marks_selected_value_checked(): void {
+		$html = StarRating::render(
+			array(
+				'name'      => 'reviews[42][rating]',
+				'id_prefix' => 'review-rating-42',
+				'label_id'  => 'review-rating-label-42',
+				'selected'  => 4,
+			)
+		);
+
+		// The `checked()` helper emits `checked='checked'`.
+		$this->assertMatchesRegularExpression(
+			'#id="review-rating-42-4"[^>]*checked#',
+			$html
+		);
+		$this->assertMatchesRegularExpression( '#class="woocommerce-star-rating__caption"[^<]*>\s*Good\s*</span>#s', $html );
+	}
+
+	/**
+	 * @testdox render() treats an out-of-range selected value as no selection.
+	 */
+	public function test_render_rejects_out_of_range_selected(): void {
+		$args = array(
+			'name'      => 'reviews[42][rating]',
+			'id_prefix' => 'review-rating-42',
+			'label_id'  => 'review-rating-label-42',
+		);
+
+		$above = StarRating::render( array_merge( $args, array( 'selected' => 99 ) ) );
+		$below = StarRating::render( array_merge( $args, array( 'selected' => -3 ) ) );
+
+		$this->assertDoesNotMatchRegularExpression( '#<input[^>]*\bchecked\b#', $above );
+		$this->assertDoesNotMatchRegularExpression( '#<input[^>]*\bchecked\b#', $below );
+	}
+
+	/**
+	 * @testdox get_labels() returns the documented defaults out of the box.
+	 */
+	public function test_get_labels_returns_defaults(): void {
+		$labels = StarRating::get_labels();
+
+		$this->assertSame(
+			array(
+				1 => 'Very poor',
+				2 => 'Not that bad',
+				3 => 'Average',
+				4 => 'Good',
+				5 => 'Perfect',
+			),
+			$labels
+		);
+	}
+
+	/**
+	 * @testdox woocommerce_review_order_rating_labels filter overrides the defaults.
+	 */
+	public function test_filter_can_override_labels(): void {
+		add_filter(
+			'woocommerce_review_order_rating_labels',
+			static function () {
+				return array(
+					1 => 'Hated it',
+					2 => 'Meh',
+					3 => 'OK',
+					4 => 'Liked it',
+					5 => 'Loved it',
+				);
+			}
+		);
+
+		$this->assertSame( 'Loved it', StarRating::get_labels()[5] );
+	}
+
+	/**
+	 * @testdox A buggy filter that drops keys falls back to the defaults for the missing slots.
+	 */
+	public function test_filter_falls_back_when_keys_missing(): void {
+		add_filter(
+			'woocommerce_review_order_rating_labels',
+			static function () {
+				// Drop entries 2 and 4 entirely; replace 5.
+				return array(
+					1 => 'Tiny',
+					3 => 'Mid',
+					5 => 'Huge',
+				);
+			}
+		);
+
+		$labels = StarRating::get_labels();
+
+		$this->assertSame( 'Tiny', $labels[1] );
+		$this->assertSame( 'Not that bad', $labels[2] );
+		$this->assertSame( 'Mid', $labels[3] );
+		$this->assertSame( 'Good', $labels[4] );
+		$this->assertSame( 'Huge', $labels[5] );
+	}
+}