Commit b919d0d2af9 for woocommerce

commit b919d0d2af97995f67849832732c9598adf35c10
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Mon Sep 14 17:26:10 2026 +0300

    [tests] Add Jest and PHPUnit coverage for the Add to Cart with Options stores (#68656)

    test(blocks): Add lower-layer coverage for Add to Cart with Options

    The Add to Cart with Options block and its three selectors had most of
    their behaviour proven only through Playwright. Which cart request a
    selection produces, whether a quantity validates, which variation a
    set of attributes resolves to, which children a grouped selection
    sends: each of those cost an editor round trip and a page load to
    answer.

    Four Jest suites take those contracts over, one per store. They
    register each Interactivity API store through a virtual
    @wordpress/interactivity mock and drive the real module: the parent
    store's add-to-cart payload and quantity validation, the
    grouped-product selector's child vector and bounds checks, the
    quantity selector's blur, clamp and sold-individually handling, and
    the variation selector's attribute matching, autoselection and
    quantity constraints.

    Two contracts land in PHP instead, because they are server-rendered.
    AddToCartWithOptions covers the legacy form markup chosen per product
    type and the AJAX archive setting. WC_Cart_Test covers the classic
    form handler's grouped-product quantity map, the non-block path the
    same shopper action takes.

    This commit removes no test. Every browser title the block has today
    still runs, and this coverage is added underneath it. The migration
    branch also consolidates the block's spec from twenty-four titles to
    sixteen; that half is held back because its consolidated grouped
    products test fails in a full-file run, and it will follow separately
    once that is resolved.

    class-wc-cart-test.php is shared with another pull request in this
    split. This branch carries only the method its own commit adds and is
    additive against trunk: fifty test methods to fifty-one, with no
    deleted lines.

    Consolidates the mega-branch slices:
    - 760478af4c: test(blocks): Move Add to Cart options below E2E
    - 146dc0d9f7: test(blocks): isolate zero quantity validation
    - db53de026a: test(blocks): strengthen quantity selector state observers
    - 60ade39ce8: test(blocks): isolate variation autoselection guards

    Refs TESTOPS-234
    Refs #68046

    Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/testops-234-add-to-cart-with-options b/plugins/woocommerce/changelog/testops-234-add-to-cart-with-options
new file mode 100644
index 00000000000..9d8048070cd
--- /dev/null
+++ b/plugins/woocommerce/changelog/testops-234-add-to-cart-with-options
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Add Jest coverage for the four Add to Cart with Options Interactivity API stores, plus PHPUnit coverage for the legacy form markup and the grouped-product quantity handler. No E2E tests are removed.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/grouped-product-selector/test/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/grouped-product-selector/test/frontend.ts
new file mode 100644
index 00000000000..2fc82779cea
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/grouped-product-selector/test/frontend.ts
@@ -0,0 +1,206 @@
+/**
+ * Internal dependencies
+ */
+import type { Context } from '../../frontend';
+import type { GroupedProductAddToCartWithOptionsStore } from '../frontend';
+
+const mockClearErrors = jest.fn();
+const mockAddError = jest.fn();
+const mockBatchAddCartItems = jest.fn();
+const mockGetConfig = jest.fn();
+
+let mockContext: Context;
+let mockRegisteredStore: GroupedProductAddToCartWithOptionsStore | null;
+let mockAddToCartStore: GroupedProductAddToCartWithOptionsStore;
+
+const mockProductsState = {
+	findProduct: jest.fn(),
+};
+
+const mockStore = jest.fn( ( namespace, definition ) => {
+	if ( namespace === 'woocommerce/products' ) {
+		return { state: mockProductsState };
+	}
+
+	if ( namespace === 'woocommerce/add-to-cart-with-options' ) {
+		if ( definition?.actions ) {
+			Object.assign( mockAddToCartStore.actions, definition.actions );
+		}
+		if ( definition?.callbacks ) {
+			Object.assign( mockAddToCartStore.callbacks, definition.callbacks );
+		}
+		mockRegisteredStore = mockAddToCartStore;
+		return mockAddToCartStore;
+	}
+
+	if ( namespace === 'woocommerce' ) {
+		return {
+			actions: {
+				batchAddCartItems: mockBatchAddCartItems,
+			},
+		};
+	}
+
+	return {};
+} );
+
+jest.mock(
+	'@wordpress/interactivity',
+	() => ( {
+		store: mockStore,
+		getContext: jest.fn( () => mockContext ),
+		getConfig: mockGetConfig,
+	} ),
+	{ virtual: true }
+);
+
+jest.mock( '@woocommerce/stores/woocommerce/cart', () => ( {} ) );
+jest.mock( '@woocommerce/stores/woocommerce/products', () => ( {} ) );
+
+const getRegisteredStore = (): GroupedProductAddToCartWithOptionsStore => {
+	if ( ! mockRegisteredStore ) {
+		throw new Error( 'Grouped product selector store was not registered.' );
+	}
+	return mockRegisteredStore;
+};
+
+const runGenerator = async ( iterator: Generator ) => {
+	let result = iterator.next();
+	while ( ! result.done ) {
+		await result.value;
+		result = iterator.next();
+	}
+};
+
+describe( 'Add to Cart + Options grouped product selector store', () => {
+	beforeEach( () => {
+		jest.resetModules();
+		jest.clearAllMocks();
+
+		mockContext = {
+			selectedAttributes: [],
+			quantity: {},
+			validationErrors: [],
+			tempQuantity: 0,
+			groupedProductIds: [],
+		};
+		mockRegisteredStore = null;
+		mockAddToCartStore = {
+			state: {} as GroupedProductAddToCartWithOptionsStore[ 'state' ],
+			actions: {
+				clearErrors: mockClearErrors,
+				addError: mockAddError,
+			} as unknown as GroupedProductAddToCartWithOptionsStore[ 'actions' ],
+			callbacks:
+				{} as GroupedProductAddToCartWithOptionsStore[ 'callbacks' ],
+		};
+		mockGetConfig.mockReturnValue( {
+			errorMessages: {
+				groupedProductAddToCartMissingItems: 'Choose products.',
+				invalidQuantities: 'Choose valid quantities.',
+			},
+		} );
+		mockProductsState.findProduct.mockReturnValue( null );
+
+		jest.isolateModules( () => {
+			jest.requireActual( '../frontend' );
+		} );
+	} );
+
+	it( 'reports an empty grouped selection', () => {
+		mockContext.quantity = { 11: 0, 12: 0 };
+
+		getRegisteredStore().callbacks.validateQuantities();
+
+		expect( mockClearErrors ).toHaveBeenCalledWith( 'invalid-quantities' );
+		expect( mockAddError ).toHaveBeenCalledWith( {
+			code: 'groupedProductAddToCartMissingItems',
+			message: 'Choose products.',
+			group: 'invalid-quantities',
+		} );
+	} );
+
+	it( 'reports nonzero child quantities outside the product bounds', () => {
+		mockContext.quantity = { 11: 4, 12: 1 };
+		mockProductsState.findProduct.mockImplementation( ( { id } ) => ( {
+			id,
+			add_to_cart: { minimum: 1, maximum: id === 11 ? 3 : 1 },
+		} ) );
+
+		getRegisteredStore().actions.validateGroupedProductQuantity();
+
+		expect( mockAddError ).toHaveBeenCalledWith( {
+			code: 'invalidQuantities',
+			message: 'Choose valid quantities.',
+			group: 'invalid-quantities',
+		} );
+	} );
+
+	it( 'reports a positive child quantity below the product minimum', () => {
+		mockContext.quantity = { 11: 2, 12: 0 };
+		mockProductsState.findProduct.mockImplementation( ( { id } ) => ( {
+			id,
+			add_to_cart: { minimum: id === 11 ? 3 : 1, maximum: 5 },
+		} ) );
+
+		getRegisteredStore().actions.validateGroupedProductQuantity();
+
+		expect( mockAddError ).toHaveBeenCalledWith( {
+			code: 'invalidQuantities',
+			message: 'Choose valid quantities.',
+			group: 'invalid-quantities',
+		} );
+	} );
+
+	it( 'accepts zero optional children and valid selected quantities', () => {
+		mockContext.quantity = { 11: 0, 12: 1 };
+		mockProductsState.findProduct.mockImplementation( ( { id } ) => ( {
+			id,
+			add_to_cart: { minimum: 1, maximum: 2 },
+		} ) );
+
+		getRegisteredStore().actions.validateGroupedProductQuantity();
+
+		expect( mockAddError ).not.toHaveBeenCalled();
+	} );
+
+	it( 'sends exact nonzero child vectors including sold-individually choices', async () => {
+		mockContext.groupedProductIds = [ 11, 12, 13, 14 ];
+		mockContext.quantity = { 11: 2, 12: 0, 13: 1, 14: 3 };
+		mockContext.selectedAttributes = [
+			{ attribute: 'attribute_pa_color', value: 'blue' },
+		];
+		mockProductsState.findProduct.mockImplementation( ( { id } ) => {
+			if ( id === 14 ) {
+				return null;
+			}
+			return {
+				id,
+				type: 'simple',
+				sold_individually: id === 13,
+			};
+		} );
+
+		await runGenerator(
+			getRegisteredStore().actions.batchAddToCart() as Generator
+		);
+
+		expect( mockBatchAddCartItems ).toHaveBeenCalledWith(
+			[
+				{
+					id: 11,
+					quantityToAdd: 2,
+					variation: mockContext.selectedAttributes,
+					type: 'simple',
+				},
+				{
+					id: 13,
+					quantityToAdd: 1,
+					variation: mockContext.selectedAttributes,
+					type: 'simple',
+				},
+			],
+			{ showCartUpdatesNotices: false }
+		);
+	} );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/quantity-selector/test/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/quantity-selector/test/frontend.ts
new file mode 100644
index 00000000000..7beb0b3b9a3
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/quantity-selector/test/frontend.ts
@@ -0,0 +1,205 @@
+/**
+ * Internal dependencies
+ */
+import type { QuantitySelectorStore } from '../frontend';
+
+const mockSetQuantity = jest.fn();
+const mockGetElement = jest.fn();
+
+let mockContext: {
+	allowZero?: boolean;
+	inputElement?: HTMLInputElement | null;
+};
+let mockRegisteredStore: QuantitySelectorStore | null;
+
+const mockProductsState = {
+	productInContext: null as Record< string, unknown > | null,
+};
+
+const mockAddToCartStore = {
+	state: {
+		quantity: {} as Record< number, number >,
+	},
+	actions: {
+		setQuantity: mockSetQuantity,
+	},
+};
+
+const mockStore = jest.fn( ( namespace, definition ) => {
+	if ( namespace === 'woocommerce/products' ) {
+		return { state: mockProductsState };
+	}
+	if ( namespace === 'woocommerce/add-to-cart-with-options' ) {
+		return mockAddToCartStore;
+	}
+	if (
+		namespace === 'woocommerce/add-to-cart-with-options-quantity-selector'
+	) {
+		mockRegisteredStore = definition;
+		return definition;
+	}
+	return {};
+} );
+
+jest.mock(
+	'@wordpress/interactivity',
+	() => ( {
+		store: mockStore,
+		getContext: jest.fn( () => mockContext ),
+		getElement: mockGetElement,
+	} ),
+	{ virtual: true }
+);
+
+const getRegisteredStore = (): QuantitySelectorStore => {
+	if ( ! mockRegisteredStore ) {
+		throw new Error( 'Quantity selector store was not registered.' );
+	}
+	return mockRegisteredStore;
+};
+
+const createInput = ( value: string ) => {
+	const input = document.createElement( 'input' );
+	input.type = 'number';
+	input.value = value;
+	return input;
+};
+
+describe( 'Add to Cart + Options quantity selector store', () => {
+	beforeEach( () => {
+		jest.resetModules();
+		jest.clearAllMocks();
+
+		mockContext = {};
+		mockRegisteredStore = null;
+		mockProductsState.productInContext = {
+			id: 42,
+			is_in_stock: true,
+			sold_individually: false,
+			add_to_cart: {
+				minimum: 4,
+				maximum: 8,
+				multiple_of: 2,
+			},
+		};
+		mockAddToCartStore.state.quantity = { 42: 4 };
+
+		jest.isolateModules( () => {
+			jest.requireActual( '../frontend' );
+		} );
+	} );
+
+	it( 'derives quantity controls from stock, sold-individually, and bounds', () => {
+		const state = getRegisteredStore().state;
+
+		expect( state.allowsQuantityChange ).toBe( true );
+		expect( state.allowsDecrease ).toBe( false );
+		expect( state.allowsIncrease ).toBe( true );
+
+		mockProductsState.productInContext = {
+			...mockProductsState.productInContext,
+			is_in_stock: false,
+		};
+		expect( state.allowsQuantityChange ).toBe( false );
+
+		mockProductsState.productInContext = {
+			...mockProductsState.productInContext,
+			is_in_stock: true,
+		};
+
+		mockAddToCartStore.state.quantity[ 42 ] = 8;
+		expect( state.allowsIncrease ).toBe( false );
+
+		mockAddToCartStore.state.quantity[ 42 ] = 4;
+		mockContext.allowZero = true;
+		expect( state.allowsDecrease ).toBe( true );
+
+		mockProductsState.productInContext = {
+			...mockProductsState.productInContext,
+			sold_individually: true,
+		};
+		expect( state.allowsQuantityChange ).toBe( false );
+	} );
+
+	it( 'clamps increase and decrease button actions to product bounds', () => {
+		mockContext.inputElement = createInput( '7' );
+
+		getRegisteredStore().actions.increaseQuantity();
+
+		expect( mockSetQuantity ).toHaveBeenLastCalledWith( 42, 8 );
+
+		mockSetQuantity.mockClear();
+		mockContext.inputElement.value = '5';
+		getRegisteredStore().actions.decreaseQuantity();
+		expect( mockSetQuantity ).toHaveBeenLastCalledWith( 42, 4 );
+
+		mockSetQuantity.mockClear();
+		mockContext.allowZero = true;
+		mockContext.inputElement.value = '4';
+		getRegisteredStore().actions.decreaseQuantity();
+		expect( mockSetQuantity ).toHaveBeenLastCalledWith( 42, 0 );
+	} );
+
+	it.each( [
+		{ label: 'zero', value: '0' },
+		{ label: 'empty', value: '' },
+	] )(
+		'resets $label simple-product input to the minimum on blur',
+		( { value } ) => {
+			mockContext.inputElement = createInput( value );
+
+			getRegisteredStore().actions.handleQuantityBlur();
+
+			expect( mockSetQuantity ).toHaveBeenCalledWith( 42, 4 );
+		}
+	);
+
+	it.each( [
+		{ label: 'zero', value: '0' },
+		{ label: 'empty', value: '' },
+	] )(
+		'keeps $label grouped-product input at zero when zero is allowed',
+		( { value } ) => {
+			mockContext.allowZero = true;
+			mockContext.inputElement = createInput( value );
+
+			getRegisteredStore().actions.handleQuantityBlur();
+
+			expect( mockSetQuantity ).toHaveBeenCalledWith( 42, 0 );
+		}
+	);
+
+	it( 'preserves a positive manual value for validation by the parent store', () => {
+		mockContext.inputElement = createInput( '3' );
+
+		getRegisteredStore().actions.handleQuantityBlur();
+
+		expect( mockSetQuantity ).toHaveBeenCalledWith( 42, 3 );
+	} );
+
+	it( 'maps a sold-individually checkbox to zero or one', () => {
+		const checkbox = document.createElement( 'input' );
+		checkbox.type = 'checkbox';
+		mockGetElement.mockReturnValue( { ref: checkbox } );
+
+		checkbox.checked = true;
+		getRegisteredStore().actions.handleQuantityCheckboxChange();
+		expect( mockSetQuantity ).toHaveBeenLastCalledWith( 42, 1 );
+
+		checkbox.checked = false;
+		getRegisteredStore().actions.handleQuantityCheckboxChange();
+		expect( mockSetQuantity ).toHaveBeenLastCalledWith( 42, 0 );
+	} );
+
+	it( 'stores the native quantity input from the rendered wrapper', () => {
+		const wrapper = document.createElement( 'div' );
+		const input = createInput( '4' );
+		input.className = 'qty';
+		wrapper.appendChild( input );
+		mockGetElement.mockReturnValue( { ref: wrapper } );
+
+		getRegisteredStore().callbacks.storeInputElementRef();
+
+		expect( mockContext.inputElement ).toBe( input );
+	} );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/test/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/test/frontend.ts
new file mode 100644
index 00000000000..ef7cc574751
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/test/frontend.ts
@@ -0,0 +1,262 @@
+/**
+ * Internal dependencies
+ */
+import type { AddToCartWithOptionsStore, Context } from '../frontend';
+
+type RegisteredStore = {
+	state: AddToCartWithOptionsStore[ 'state' ];
+	actions: AddToCartWithOptionsStore[ 'actions' ];
+};
+
+const mockAddCartItem = jest.fn();
+const mockBatchAddCartItems = jest.fn();
+const mockAddNotice = jest.fn();
+const mockRemoveNotice = jest.fn();
+const mockGetConfig = jest.fn();
+
+let mockContext: Context;
+let mockQuantitySelectorContext: { inputElement?: HTMLInputElement };
+let mockRegisteredStore: RegisteredStore | null;
+let mockAddToCartStore: RegisteredStore;
+
+const mockProductsState = {
+	productId: 0,
+	productInContext: null as Record< string, unknown > | null,
+	mainProductInContext: null as Record< string, unknown > | null,
+	findProduct: jest.fn(),
+};
+
+const mockStore = jest.fn( ( namespace, definition ) => {
+	if ( namespace === 'woocommerce/products' ) {
+		return { state: mockProductsState };
+	}
+
+	if ( namespace === 'woocommerce/add-to-cart-with-options' ) {
+		if ( definition?.state ) {
+			Object.defineProperties(
+				mockAddToCartStore.state,
+				Object.getOwnPropertyDescriptors( definition.state )
+			);
+		}
+		if ( definition?.actions ) {
+			Object.assign( mockAddToCartStore.actions, definition.actions );
+		}
+		mockRegisteredStore = mockAddToCartStore;
+		return mockAddToCartStore;
+	}
+
+	if ( namespace === 'woocommerce/store-notices' ) {
+		return {
+			actions: {
+				addNotice: mockAddNotice,
+				removeNotice: mockRemoveNotice,
+			},
+		};
+	}
+
+	if ( namespace === 'woocommerce' ) {
+		return {
+			actions: {
+				addCartItem: mockAddCartItem,
+				batchAddCartItems: mockBatchAddCartItems,
+			},
+		};
+	}
+
+	return {};
+} );
+
+jest.mock(
+	'@wordpress/interactivity',
+	() => ( {
+		store: mockStore,
+		getContext: jest.fn( ( namespace?: string ) =>
+			namespace ===
+			'woocommerce/add-to-cart-with-options-quantity-selector'
+				? mockQuantitySelectorContext
+				: mockContext
+		),
+		getConfig: mockGetConfig,
+		withSyncEvent: ( action: unknown ) => action,
+	} ),
+	{ virtual: true }
+);
+
+jest.mock( '@woocommerce/stores/woocommerce/cart', () => ( {} ) );
+jest.mock( '@woocommerce/stores/woocommerce/products', () => ( {} ) );
+jest.mock( '@woocommerce/stores/store-notices', () => ( {} ) );
+
+const getRegisteredStore = (): RegisteredStore => {
+	if ( ! mockRegisteredStore ) {
+		throw new Error( 'Add to Cart + Options store was not registered.' );
+	}
+	return mockRegisteredStore;
+};
+
+const runGenerator = async ( iterator: Generator ) => {
+	let result = iterator.next();
+	while ( ! result.done ) {
+		await result.value;
+		result = iterator.next();
+	}
+};
+
+describe( 'Add to Cart + Options interactivity store', () => {
+	beforeEach( () => {
+		jest.resetModules();
+		jest.clearAllMocks();
+
+		mockContext = {
+			selectedAttributes: [],
+			quantity: { 42: 2 },
+			validationErrors: [],
+			tempQuantity: 0,
+			groupedProductIds: [],
+		};
+		mockQuantitySelectorContext = {};
+		mockRegisteredStore = null;
+		mockAddToCartStore = {
+			state: {} as AddToCartWithOptionsStore[ 'state' ],
+			actions: {} as AddToCartWithOptionsStore[ 'actions' ],
+		};
+		mockProductsState.productId = 42;
+		mockProductsState.productInContext = {
+			id: 42,
+			type: 'simple',
+			is_purchasable: true,
+			is_in_stock: true,
+			add_to_cart: {
+				minimum: 1,
+				maximum: 5,
+			},
+		};
+		mockProductsState.mainProductInContext =
+			mockProductsState.productInContext;
+		mockGetConfig.mockReturnValue( {
+			errorMessages: {
+				invalidQuantities: 'Choose a valid quantity.',
+			},
+		} );
+
+		jest.isolateModules( () => {
+			jest.requireActual( '../frontend' );
+		} );
+	} );
+
+	it( 'validates zero and out-of-range quantities with the configured message', () => {
+		const registeredStore = getRegisteredStore();
+		mockProductsState.productInContext = {
+			...mockProductsState.productInContext,
+			add_to_cart: {
+				minimum: 0,
+				maximum: 5,
+			},
+		};
+
+		registeredStore.actions.validateQuantity( 42, 0 );
+
+		expect( registeredStore.state.validationErrors ).toEqual( [
+			{
+				code: 'invalidQuantities',
+				message: 'Choose a valid quantity.',
+				group: 'invalid-quantities',
+			},
+		] );
+		expect( registeredStore.state.isFormValid ).toBe( false );
+
+		registeredStore.actions.validateQuantity( 42, 6 );
+		expect( registeredStore.state.validationErrors ).toHaveLength( 1 );
+
+		registeredStore.actions.validateQuantity( 42, 3 );
+		expect( registeredStore.state.validationErrors ).toEqual( [] );
+		expect( registeredStore.state.isFormValid ).toBe( true );
+	} );
+
+	it( 'rejects a positive quantity below the product minimum', () => {
+		mockProductsState.productInContext = {
+			...mockProductsState.productInContext,
+			add_to_cart: {
+				minimum: 4,
+				maximum: 5,
+			},
+		};
+
+		getRegisteredStore().actions.validateQuantity( 42, 2 );
+
+		expect( getRegisteredStore().state.validationErrors ).toEqual( [
+			{
+				code: 'invalidQuantities',
+				message: 'Choose a valid quantity.',
+				group: 'invalid-quantities',
+			},
+		] );
+	} );
+
+	it.each( [
+		{
+			title: 'simple product',
+			type: 'simple',
+			selectedAttributes: [],
+		},
+		{
+			title: 'selected variation',
+			type: 'variation',
+			selectedAttributes: [
+				{ attribute: 'attribute_pa_color', value: 'blue' },
+				{ attribute: 'Logo', value: 'No' },
+			],
+		},
+	] )(
+		'forwards the exact $title cart payload',
+		async ( { type, selectedAttributes } ) => {
+			mockContext.selectedAttributes = selectedAttributes;
+			mockProductsState.productInContext = {
+				...mockProductsState.productInContext,
+				id: 42,
+				type,
+			};
+			const preventDefault = jest.fn();
+
+			await runGenerator(
+				getRegisteredStore().actions.addToCart( {
+					preventDefault,
+				} as unknown as SubmitEvent )
+			);
+
+			expect( preventDefault ).toHaveBeenCalledTimes( 1 );
+			expect( mockAddCartItem ).toHaveBeenCalledWith(
+				{
+					id: 42,
+					quantityToAdd: 2,
+					variation: selectedAttributes,
+					type,
+				},
+				{ showCartUpdatesNotices: false }
+			);
+		}
+	);
+
+	it( 'surfaces validation errors without sending a cart request', async () => {
+		const registeredStore = getRegisteredStore();
+		registeredStore.actions.addError( {
+			code: 'missingVariation',
+			group: 'variable-product',
+			message: 'Choose product options.',
+		} );
+		mockAddNotice.mockReturnValue( 'notice-1' );
+
+		await runGenerator(
+			registeredStore.actions.addToCart( {
+				preventDefault: jest.fn(),
+			} as unknown as SubmitEvent )
+		);
+
+		expect( mockAddNotice ).toHaveBeenCalledWith( {
+			notice: 'Choose product options.',
+			type: 'error',
+			dismissible: true,
+		} );
+		expect( registeredStore.state.noticeIds ).toEqual( [ 'notice-1' ] );
+		expect( mockAddCartItem ).not.toHaveBeenCalled();
+	} );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/test/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/test/frontend.ts
new file mode 100644
index 00000000000..449b34cd73c
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/test/frontend.ts
@@ -0,0 +1,385 @@
+/**
+ * External dependencies
+ */
+import type { SelectedAttributes } from '@woocommerce/stores/woocommerce/cart';
+
+/**
+ * Internal dependencies
+ */
+import type { VariableProductAddToCartWithOptionsStore } from '../frontend';
+
+type RegisteredStore = VariableProductAddToCartWithOptionsStore;
+
+type VariationContext = {
+	name: string;
+	selectedValue: string | null;
+	selectedAttributes: SelectedAttributes[];
+	variationAttributeOptions: Array< {
+		id: string;
+		label: string;
+		value: string;
+	} >;
+	autoselect: boolean;
+	disabledAttributesAction?: 'disable' | 'hide';
+	quantity: Record< number, number >;
+};
+
+const mockClearErrors = jest.fn();
+const mockAddError = jest.fn();
+const mockSetQuantity = jest.fn();
+const mockGetConfig = jest.fn();
+const mockGetElement = jest.fn();
+
+let mockContext: VariationContext;
+let mockProductContext: { variationId?: number | null } | null;
+let mockRegisteredStore: RegisteredStore | null;
+let mockAddToCartStore: RegisteredStore;
+
+const mockProductsState = {
+	productId: 100,
+	variationId: null as number | null,
+	mainProductInContext: null as Record< string, unknown > | null,
+	productVariationInContext: null as Record< string, unknown > | null,
+	productVariations: {} as Record< number, Record< string, unknown > >,
+	findProduct: jest.fn(),
+};
+
+const mockStore = jest.fn( ( namespace, definition ) => {
+	if ( namespace === 'woocommerce/products' ) {
+		return { state: mockProductsState };
+	}
+
+	if ( namespace === 'woocommerce/add-to-cart-with-options' ) {
+		if ( definition?.state ) {
+			Object.defineProperties(
+				mockAddToCartStore.state,
+				Object.getOwnPropertyDescriptors( definition.state )
+			);
+		}
+		if ( definition?.actions ) {
+			Object.assign( mockAddToCartStore.actions, definition.actions );
+		}
+		if ( definition?.callbacks ) {
+			Object.assign( mockAddToCartStore.callbacks, definition.callbacks );
+		}
+		mockRegisteredStore = mockAddToCartStore;
+		return mockAddToCartStore;
+	}
+
+	return {};
+} );
+
+jest.mock(
+	'@wordpress/interactivity',
+	() => ( {
+		store: mockStore,
+		getContext: jest.fn( ( namespace?: string ) =>
+			namespace === 'woocommerce/products'
+				? mockProductContext
+				: mockContext
+		),
+		getConfig: mockGetConfig,
+		getElement: mockGetElement,
+	} ),
+	{ virtual: true }
+);
+
+jest.mock( '@woocommerce/stores/woocommerce/products', () => ( {} ) );
+
+const getRegisteredStore = (): RegisteredStore => {
+	if ( ! mockRegisteredStore ) {
+		throw new Error( 'Variation selector store was not registered.' );
+	}
+	return mockRegisteredStore;
+};
+
+const variation = (
+	id: number,
+	attributes: Array< { name: string; value: string | null } >
+) => ( { id, attributes } );
+
+describe( 'Add to Cart + Options variation selector store', () => {
+	beforeEach( () => {
+		jest.resetModules();
+		jest.clearAllMocks();
+
+		mockContext = {
+			name: 'Color',
+			selectedValue: '',
+			selectedAttributes: [],
+			variationAttributeOptions: [],
+			autoselect: false,
+			disabledAttributesAction: 'disable',
+			quantity: {},
+		};
+		mockProductContext = { variationId: null };
+		mockRegisteredStore = null;
+		mockAddToCartStore = {
+			state: {} as RegisteredStore[ 'state' ],
+			actions: {
+				clearErrors: mockClearErrors,
+				addError: mockAddError,
+				setQuantity: mockSetQuantity,
+			} as unknown as RegisteredStore[ 'actions' ],
+			callbacks: {} as RegisteredStore[ 'callbacks' ],
+		};
+		mockProductsState.mainProductInContext = null;
+		mockProductsState.productVariationInContext = null;
+		mockProductsState.productVariations = {};
+		mockProductsState.findProduct.mockReturnValue( null );
+		mockGetConfig.mockReturnValue( {
+			errorMessages: {
+				variableProductMissingAttributes: 'Choose options.',
+				variableProductOutOfStock: 'Variation unavailable.',
+			},
+		} );
+
+		jest.isolateModules( () => {
+			jest.requireActual( '../frontend' );
+		} );
+	} );
+
+	it.each( [
+		{ action: 'disable', hidden: false },
+		{ action: 'hide', hidden: true },
+	] as const )(
+		'marks invalid choices for the $action action',
+		( { action, hidden } ) => {
+			mockProductsState.mainProductInContext = {
+				id: 100,
+				type: 'variable',
+				variations: [
+					variation( 101, [
+						{ name: 'Color', value: 'blue' },
+						{ name: 'Size', value: 'xl' },
+					] ),
+					variation( 102, [
+						{ name: 'Color', value: 'red' },
+						{ name: 'Size', value: 'l' },
+					] ),
+				],
+			};
+			mockContext = {
+				...mockContext,
+				name: 'Size',
+				disabledAttributesAction: action,
+				selectedAttributes: [
+					{ attribute: 'attribute_pa_color', value: 'blue' },
+				],
+				variationAttributeOptions: [
+					{ id: 'size-l', label: 'L', value: 'l' },
+					{ id: 'size-xl', label: 'XL', value: 'xl' },
+				],
+			};
+
+			const selectableByValue = Object.fromEntries(
+				getRegisteredStore().state.selectableItems.map( ( item ) => [
+					item.value,
+					item,
+				] )
+			);
+
+			expect( selectableByValue.l ).toMatchObject( {
+				id: 'size-l',
+				selected: false,
+				disabled: true,
+				hidden,
+			} );
+			expect( selectableByValue.xl ).toMatchObject( {
+				id: 'size-xl',
+				selected: false,
+				disabled: false,
+				hidden: false,
+			} );
+		}
+	);
+
+	it( 'preserves a custom attribute slug while matching its Store API label', () => {
+		mockProductsState.mainProductInContext = {
+			id: 100,
+			type: 'variable',
+			variations: [
+				variation( 101, [ { name: 'Numeric Size', value: '42' } ] ),
+			],
+		};
+		mockContext = {
+			...mockContext,
+			name: 'attribute_pa_numeric-size',
+			variationAttributeOptions: [
+				{ id: 'numeric-42', label: '42', value: '42' },
+			],
+		};
+
+		const item = getRegisteredStore().state.selectableItems[ 0 ];
+		expect( item.disabled ).toBe( false );
+
+		getRegisteredStore().actions.toggle( item );
+
+		expect( mockContext.selectedAttributes ).toEqual( [
+			{ attribute: 'attribute_pa_numeric-size', value: '42' },
+		] );
+	} );
+
+	it( 'autoselects only unique valid options outside the changed attribute', () => {
+		mockProductsState.mainProductInContext = {
+			id: 100,
+			type: 'variable',
+			variations: [
+				variation( 101, [
+					{ name: 'Type', value: 't-shirt' },
+					{ name: 'Color', value: 'blue' },
+					{ name: 'Size', value: 'xl' },
+					{ name: 'Material', value: 'cotton' },
+				] ),
+				variation( 102, [
+					{ name: 'Type', value: 't-shirt' },
+					{ name: 'Color', value: 'blue' },
+					{ name: 'Size', value: 'xl' },
+					{ name: 'Material', value: 'linen' },
+				] ),
+				variation( 103, [
+					{ name: 'Type', value: 't-shirt' },
+					{ name: 'Color', value: 'green' },
+					{ name: 'Size', value: 's' },
+				] ),
+			],
+		};
+		mockContext.autoselect = true;
+		mockContext.selectedAttributes = [
+			{ attribute: 'Color', value: 'blue' },
+		];
+
+		getRegisteredStore().actions.autoselectAttributes( {
+			excludedAttributes: [ 'attribute_pa_color' ],
+		} );
+
+		expect( mockContext.selectedAttributes ).not.toContainEqual( {
+			attribute: 'Material',
+			value: 'cotton',
+		} );
+		expect( mockContext.selectedAttributes ).toEqual( [
+			{ attribute: 'Color', value: 'blue' },
+			{ attribute: 'Type', value: 't-shirt' },
+			{ attribute: 'Size', value: 'xl' },
+		] );
+	} );
+
+	it( 'does not rewrite a changed custom attribute slug when its Store API label is excluded', () => {
+		mockProductsState.mainProductInContext = {
+			id: 100,
+			type: 'variable',
+			variations: [
+				variation( 101, [ { name: 'Color', value: 'blue' } ] ),
+			],
+		};
+		mockContext = {
+			...mockContext,
+			autoselect: true,
+			selectedAttributes: [
+				{ attribute: 'attribute_pa_color', value: 'blue' },
+			],
+		};
+
+		getRegisteredStore().actions.autoselectAttributes( {
+			excludedAttributes: [ 'attribute_pa_color' ],
+		} );
+
+		expect( mockContext.selectedAttributes ).toEqual( [
+			{ attribute: 'attribute_pa_color', value: 'blue' },
+		] );
+	} );
+
+	it( 'accepts any-value variations when another selected attribute matches', () => {
+		mockProductsState.mainProductInContext = {
+			id: 100,
+			type: 'variable',
+			variations: [
+				variation( 101, [
+					{ name: 'Color', value: null },
+					{ name: 'Size', value: 'large' },
+				] ),
+			],
+		};
+		mockContext = {
+			...mockContext,
+			name: 'Color',
+			selectedAttributes: [ { attribute: 'Size', value: 'large' } ],
+			variationAttributeOptions: [
+				{ id: 'color-blue', label: 'Blue', value: 'blue' },
+			],
+		};
+
+		expect( getRegisteredStore().state.selectableItems[ 0 ].disabled ).toBe(
+			false
+		);
+	} );
+
+	it( 'updates the selected variation ID and reports missing or unavailable matches', () => {
+		const selectedAttributes = [ { attribute: 'Color', value: 'blue' } ];
+		mockProductsState.mainProductInContext = {
+			id: 100,
+			variations: [ variation( 101, [] ) ],
+		};
+		mockContext.selectedAttributes = selectedAttributes;
+		mockProductsState.findProduct.mockImplementation(
+			( { id, selectedAttributes: receivedAttributes } ) => {
+				const selectedColor = receivedAttributes?.find(
+					( attribute ) => attribute.attribute === 'Color'
+				);
+
+				return id === 100 &&
+					receivedAttributes?.length === 1 &&
+					selectedColor?.value === 'blue'
+					? { id: 101 }
+					: mockProductsState.mainProductInContext;
+			}
+		);
+		mockProductsState.productVariations[ 101 ] = {
+			id: 101,
+			is_in_stock: false,
+		};
+
+		getRegisteredStore().callbacks.setSelectedVariationId();
+		getRegisteredStore().callbacks.validateVariation();
+
+		expect( mockProductContext?.variationId ).toBe( 101 );
+		expect( mockAddError ).toHaveBeenCalledWith( {
+			code: 'variableProductOutOfStock',
+			message: 'Variation unavailable.',
+			group: 'variable-product',
+		} );
+
+		mockAddError.mockClear();
+		mockProductsState.findProduct.mockReturnValue(
+			mockProductsState.mainProductInContext
+		);
+		getRegisteredStore().callbacks.validateVariation();
+		expect( mockAddError ).toHaveBeenCalledWith( {
+			code: 'variableProductMissingAttributes',
+			message: 'Choose options.',
+			group: 'variable-product',
+		} );
+	} );
+
+	it.each( [
+		{ current: 2, expected: 4 },
+		{ current: 10, expected: 8 },
+	] )(
+		'clamps variation quantity $current to $expected when the input is idle',
+		( { current, expected } ) => {
+			const input = document.createElement( 'input' );
+			input.type = 'number';
+			input.value = String( current );
+			mockGetElement.mockReturnValue( { ref: input } );
+			mockProductsState.productVariationInContext = {
+				id: 101,
+				add_to_cart: { minimum: 4, maximum: 8 },
+			};
+			mockContext.quantity = { 101: current };
+
+			getRegisteredStore().callbacks.watchQuantityConstraints();
+
+			expect( mockSetQuantity ).toHaveBeenCalledWith( 101, expected );
+		}
+	);
+} );
diff --git a/plugins/woocommerce/client/blocks/changelog/testops-234-add-to-cart-with-options b/plugins/woocommerce/client/blocks/changelog/testops-234-add-to-cart-with-options
new file mode 100644
index 00000000000..9d8048070cd
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/changelog/testops-234-add-to-cart-with-options
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Add Jest coverage for the four Add to Cart with Options Interactivity API stores, plus PHPUnit coverage for the legacy form markup and the grouped-product quantity handler. No E2E tests are removed.
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 8ad1c601490..d8f8c35fc24 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
@@ -2267,4 +2267,76 @@ class WC_Cart_Test extends \WC_Unit_Test_Case {

 		return array( (string) $cart_item_key, WC()->cart->get_cart_item( (string) $cart_item_key ) );
 	}
+
+	/**
+	 * @testdox Should add only selected grouped children with their submitted quantities.
+	 */
+	public function test_add_to_cart_action_handles_grouped_product_quantities(): void {
+		$first_child = WC_Helper_Product::create_simple_product();
+		$first_child->set_name( 'First grouped child' );
+		$first_child->save();
+
+		$skipped_child = WC_Helper_Product::create_simple_product();
+		$skipped_child->set_name( 'Skipped grouped child' );
+		$skipped_child->save();
+
+		$single_child = WC_Helper_Product::create_simple_product();
+		$single_child->set_name( 'Sold individually grouped child' );
+		$single_child->set_sold_individually( true );
+		$single_child->save();
+
+		$grouped_product = new WC_Product_Grouped();
+		$grouped_product->set_name( 'Grouped request product' );
+		$grouped_product->set_children(
+			array(
+				$first_child->get_id(),
+				$skipped_child->get_id(),
+				$single_child->get_id(),
+			)
+		);
+		$grouped_product->save();
+
+		$original_redirect = get_option( 'woocommerce_cart_redirect_after_add' );
+
+		try {
+			update_option( 'woocommerce_cart_redirect_after_add', 'no' );
+			WC()->cart->empty_cart();
+
+			$grouped_quantities = array(
+				$first_child->get_id()   => 2,
+				$skipped_child->get_id() => 0,
+				$single_child->get_id()  => 1,
+			);
+
+			$_REQUEST['add-to-cart'] = $grouped_product->get_id();
+			$_REQUEST['quantity']    = $grouped_quantities;
+			$_POST['quantity']       = $grouped_quantities;
+
+			WC_Form_Handler::add_to_cart_action( false );
+
+			$cart_quantities = array();
+			foreach ( WC()->cart->get_cart() as $cart_item ) {
+				$cart_quantities[ $cart_item['product_id'] ] = (int) $cart_item['quantity'];
+			}
+
+			$this->assertSame(
+				array(
+					$first_child->get_id()  => 2,
+					$single_child->get_id() => 1,
+				),
+				$cart_quantities,
+				'Only positive grouped child quantities should be added to the cart.'
+			);
+			$this->assertArrayNotHasKey( $skipped_child->get_id(), $cart_quantities, 'A zero-quantity grouped child should be skipped.' );
+			$this->assertArrayNotHasKey( $grouped_product->get_id(), $cart_quantities, 'The grouped parent should not become a cart line.' );
+		} finally {
+			unset( $_REQUEST['add-to-cart'], $_REQUEST['quantity'], $_POST['quantity'] );
+			update_option( 'woocommerce_cart_redirect_after_add', $original_redirect );
+			WC()->cart->empty_cart();
+			$grouped_product->delete( true );
+			$single_child->delete( true );
+			$skipped_child->delete( true );
+			$first_child->delete( true );
+		}
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToCartWithOptions.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToCartWithOptions.php
index aa38548565e..7b22bf2ec6c 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToCartWithOptions.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToCartWithOptions.php
@@ -298,6 +298,125 @@ class AddToCartWithOptions extends \WP_UnitTestCase {
 		remove_filter( 'woocommerce_add_to_cart_form_action', array( $this, 'hook_into_woocommerce_add_to_cart_form_action_filter' ) );
 	}

+	/**
+	 * @testdox Legacy add-to-cart forms expose the request fields required by each product type.
+	 *
+	 * @dataProvider provider_legacy_form_fields
+	 *
+	 * @param string $product_type Product type under test.
+	 */
+	public function test_legacy_form_fields_for_product_types( string $product_type ): void {
+		global $product;
+
+		$previous_product  = $product;
+		$products          = array();
+		$original_redirect = get_option( 'woocommerce_cart_redirect_after_add' );
+
+		try {
+			if ( 'simple' === $product_type ) {
+				$product = new \WC_Product_Simple();
+				$product->set_name( 'Legacy Simple' );
+				$product->set_regular_price( '10' );
+				$product->save();
+				$products[]      = $product;
+				$expected_fields = array( 'name="add-to-cart"', 'name="quantity"' );
+			} elseif ( 'variable' === $product_type ) {
+				$product = new \WC_Product_Variable();
+				$product->set_name( 'Legacy Variable' );
+				$product->set_attributes(
+					array( \WC_Helper_Product::create_product_attribute_object( 'color', array( 'blue' ) ) )
+				);
+				$product->save();
+
+				$variation = new \WC_Product_Variation();
+				$variation->set_parent_id( $product->get_id() );
+				$variation->set_attributes( array( 'pa_color' => 'blue' ) );
+				$variation->set_regular_price( '10' );
+				$variation->save();
+				\WC_Product_Variable::sync( $product->get_id() );
+
+				$products[]      = $variation;
+				$products[]      = $product;
+				$expected_fields = array( 'name="add-to-cart"', 'name="product_id"', 'name="variation_id"', 'name="attribute_pa_color"', 'name="quantity"' );
+			} else {
+				$child = new \WC_Product_Simple();
+				$child->set_name( 'Legacy Grouped Child' );
+				$child->set_regular_price( '10' );
+				$child->save();
+
+				$product = new \WC_Product_Grouped();
+				$product->set_name( 'Legacy Grouped' );
+				$product->set_children( array( $child->get_id() ) );
+				$product->save();
+
+				$products[]      = $product;
+				$products[]      = $child;
+				$expected_fields = array( 'name="add-to-cart"', 'name="quantity[' . $child->get_id() . ']"' );
+			}
+
+			update_option( 'woocommerce_cart_redirect_after_add', 'yes' );
+			$markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product->get_id() . '} --><!-- wp:woocommerce/add-to-cart-with-options /--><!-- /wp:woocommerce/single-product -->' );
+
+			$this->assertStringContainsString( '<form ', $markup, 'A legacy HTML form should be rendered.' );
+			$this->assertStringContainsString( 'method="post"', $markup, 'The legacy form should submit with POST.' );
+			$this->assertStringContainsString( 'enctype="multipart/form-data"', $markup, 'The legacy form should retain multipart compatibility.' );
+			$this->assertStringNotContainsString( 'data-wp-on--submit="actions.addToCart"', $markup, 'The Interactivity API submit binding should be absent in legacy mode.' );
+			$this->assertStringContainsString( 'name="add-to-cart" value="' . $product->get_id() . '"', $markup, 'The parent product ID should be submitted.' );
+
+			foreach ( $expected_fields as $expected_field ) {
+				$this->assertStringContainsString( $expected_field, $markup, "The {$product_type} form should contain {$expected_field}." );
+			}
+		} finally {
+			update_option( 'woocommerce_cart_redirect_after_add', $original_redirect );
+			$product = $previous_product;
+			foreach ( $products as $created_product ) {
+				$created_product->delete( true );
+			}
+		}
+	}
+
+	/**
+	 * Data provider for legacy form field coverage.
+	 *
+	 * @return array<string, array{string}>
+	 */
+	public static function provider_legacy_form_fields(): array {
+		return array(
+			'simple product'   => array( 'simple' ),
+			'variable product' => array( 'variable' ),
+			'grouped product'  => array( 'grouped' ),
+		);
+	}
+
+	/**
+	 * @testdox Disabling archive AJAX add-to-cart leaves the block's Interactivity API submit binding enabled.
+	 */
+	public function test_ajax_archive_setting_does_not_disable_interactive_form(): void {
+		global $product;
+
+		$previous_product  = $product;
+		$original_ajax     = get_option( 'woocommerce_enable_ajax_add_to_cart' );
+		$original_redirect = get_option( 'woocommerce_cart_redirect_after_add' );
+		$product           = new \WC_Product_Simple();
+		$product->set_regular_price( '10' );
+		$product->save();
+
+		try {
+			update_option( 'woocommerce_enable_ajax_add_to_cart', 'no' );
+			update_option( 'woocommerce_cart_redirect_after_add', 'no' );
+
+			$markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product->get_id() . '} --><!-- wp:woocommerce/add-to-cart-with-options /--><!-- /wp:woocommerce/single-product -->' );
+
+			$this->assertStringContainsString( 'data-wp-on--submit="actions.addToCart"', $markup, 'The block should keep its Interactivity API submit binding.' );
+			$this->assertStringNotContainsString( 'method="post"', $markup, 'The archive AJAX option should not force the block into legacy mode.' );
+		} finally {
+			update_option( 'woocommerce_enable_ajax_add_to_cart', $original_ajax );
+			update_option( 'woocommerce_cart_redirect_after_add', $original_redirect );
+			$product->delete( true );
+			$product = $previous_product;
+		}
+	}
+
 	/**
 	 * Tests that the default attributes are selected when defined in product
 	 * data or in the URL parameters.