Commit 0e51c6f7038 for woocommerce
commit 0e51c6f7038870513b548e2e66275eab66f41f0c
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Wed Sep 16 00:48:59 2026 +0300
[tests] Reduce Product Collection editor E2E tests from 62 to 22 (#68617)
* test(blocks): Reduce Product Collection editor E2E tests from 62 to 22
Four specs ran sixty-two browser titles over the Product Collection
editor: one per built-in collection, one per inspector control, and
one per picker. Each opened a post or a template in the Site Editor
and asserted the same three things again and again: which collection
definition was inserted, which query attributes a control wrote, and
which products the resulting query returned.
The collection definitions and the control payloads are decided in
the block's own JavaScript, and the query is built in PHP. Add Jest
contracts for the fourteen built-in collections, the inspector
controls, and the pickers, and PHPUnit tests for the collection
presets and for the queries the inspector's attributes produce.
Keep twenty-two browser titles for what only a browser proves: the
collection chooser and the toolbar switch, a built-in collection
rendering its products, archive inheritance, Related Products, the
display controls rendered on the front end, the Query Type control's
visibility and fallback rules, and the product and order contexts a
plugin can register.
Consolidates the mega-branch slices:
- Slice 029: test(blocks): Move Product Collection controls below E2E
- Slice 050: test(blocks): Move Product Collection chooser behavior
below E2E
- refactor(e2e): simplify migrated Blocks test contracts, for the
permalink-path helper and the two spec hunks it needs
Refs TESTOPS-234
Refs #68046
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(blocks): Match the columns class exactly
`toHaveClass( /columns-4/ )` is unanchored, so it also passes on
`columns-40`. `toContainClass( 'columns-4' )` matches the class token
itself, which is what these assertions mean.
This PR moves one of the two call sites; the other was already on trunk
and is changed with it, since two identical assertions in one file
should not disagree about how they match.
Refs #68617
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* style(blocks): Apply prettier formatting to the columns assertions
Refs #68617
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(blocks): Stop the collection presets test restoring base-class state
The finally block deleted its products, restored the posts_clauses hook
stack and rebuilt the wc_products_onsale transient rows by hand. The
transaction rollback covers the products and both transient rows -- they
are options-table rows like any other -- and _restore_hooks() rebuilds
$wp_filter from the suite baseline.
$products stays: it is not teardown bookkeeping, it feeds the post__in
the query under test runs against. $terms only ever fed the delete loop,
so the one line that appended to it goes with the loop.
Refs TESTOPS-234
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(blocks): Pin the Product Collection query defaults against literals
Every collection case in collection-contracts builds its expected query
by spreading DEFAULT_QUERY and then asserts the registered variation
equals it, so the shared defaults are only ever compared against
themselves. Changing perPage from 9 to 99 left all 53 tests in the three
new contract files green.
That matters because this PR deletes the E2E titles that were holding
those values -- "Items per page, offset & max page to show" and the
title sort case -- so nothing else pins them.
Add one test asserting the seven defaults against literals. The same
mutation now fails exactly one test, and only that one, which also
confirms the other sixteen genuinely cannot see it.
Refs TESTOPS-234
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(blocks): Add a keyword search row to the inspector query results
Keyword search had a test for the control writing `search`, but nothing
checked that the resulting query returns the matching products. The new
row sets search to Alpha and expects the Alpha product alone.
Removing `'s' => $search` from get_final_frontend_query() fails this row
and no other.
Refs TESTOPS-234
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(blocks): Cover on-sale and featured in the hidden-control contract
The hidden-control table covered order, keyword, offset, max pages and
products per page, but not On Sale or Featured, so hiding either could
stop working unnoticed.
The checkbox write cases already prove both controls render when not
hidden, so the new absence rows cannot pass vacuously. Forcing either
control visible fails its row.
Refs TESTOPS-234
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(blocks): Say what the components mock proves in control contracts
The suite swaps most of @wordpress/components for native inputs, so it
proves setAttributes payloads rather than the real controls. Say so
above the mock, and point at the retained E2E journeys that still drive
the real inspector.
Refs TESTOPS-234
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(blocks): Make CollectionPresetsTest setUp public
CodeRabbit's review of this PR flagged the new test class's
protected setUp(). Both visibilities work, because the nearest
parent that declares setUp() is protected too, but the repository's
PHPUnit guidance in .cursor/rules/woo-phpunit.mdc asks for public
lifecycle methods, and most test files under tests/php/src follow
it (341 public, 61 protected). The file is new in this PR, so it
should match that convention rather than the older protected
setUp() in QueryBuilder.php next to it.
Refs TESTOPS-234
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(blocks): Share the Product Collection contract test setup
The three contract suites added in this PR each carried their own copy
of the collection registration setup, and two of them carried the same
getTaxonomies stub. picker-contracts.tsx also kept a hard-coded list of
collection names to unregister, which could drift from what
registerCollections() actually adds.
Move both into product-collection/test/utils/contract-setup.ts.
registerCoreCollections() unregisters exactly the variations it added
and restores window.wp, and mockProductTaxonomies() installs the stub.
The helper sits in a test/utils/ subfolder, so the Jest preset's
**/test/*.[jt]s?(x) match does not collect it as a suite.
Refs TESTOPS-234
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(blocks): Read the Product Collection query through the page object
collection-pickers and product-picker each defined a local page.evaluate
helper that found the product-collection block in the block-editor
store and returned its query, or one key of it. The two copies differed
only in what they returned, while ProductCollectionPage already holds
the suite's shared helpers, including getProductPermalinkPaths() from
this PR.
Add ProductCollectionPage.getProductCollectionQuery() and call it from
both specs. The product picker reads productReference from the returned
query, which is still undefined when the block has no reference.
Refs TESTOPS-234
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(blocks): Wait for the second hand-picked selection before reading it
The hand-picked journey clicked the Beanie checkbox and read the block's
stored query straight away. The store write is synchronous today
(checkbox onChange -> onSelect -> setQueryAttribute -> setAttributes),
so the read does not race, but nothing tied it to the second selection
the way the Done button check ties it to the first one.
Assert that the Beanie checkbox is checked before reading the stored
IDs. The picker renders that checkbox from the block's stored
hand-picked IDs, so the read now waits for the write to land if it
ever stops being synchronous.
Refs TESTOPS-234
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/testops-234-product-collection-editor b/plugins/woocommerce/changelog/testops-234-product-collection-editor
new file mode 100644
index 00000000000..74e721b468d
--- /dev/null
+++ b/plugins/woocommerce/changelog/testops-234-product-collection-editor
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Reduce Product Collection editor E2E tests from 62 to 22; Jest owns the built-in collection definitions, the inspector controls, and the pickers, and PHPUnit owns the collection presets and the queries the inspector's attributes produce.
+
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/test/collection-contracts.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/test/collection-contracts.tsx
new file mode 100644
index 00000000000..ef0a43c8839
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/test/collection-contracts.tsx
@@ -0,0 +1,436 @@
+/**
+ * External dependencies
+ */
+import { getBlockVariations, type BlockVariation } from '@wordpress/blocks';
+
+/**
+ * Internal dependencies
+ */
+import bestSellers from '../best-sellers';
+import byBrand from '../by-brand';
+import byCategory from '../by-category';
+import byTag from '../by-tag';
+import cartContents from '../cart-contents';
+import crossSells from '../cross-sells';
+import featured from '../featured';
+import handPicked from '../hand-picked';
+import newArrivals from '../new-arrivals';
+import onSale from '../on-sale';
+import productCatalog from '../product-collection';
+import related from '../related';
+import topRated from '../top-rated';
+import upsells from '../upsells';
+import {
+ DEFAULT_ATTRIBUTES,
+ DEFAULT_QUERY,
+ INNER_BLOCKS_NO_RESULTS_TEMPLATE,
+ INNER_BLOCKS_PAGINATION_TEMPLATE,
+ INNER_BLOCKS_PRODUCT_TEMPLATE,
+ INNER_BLOCKS_TEMPLATE,
+ PRODUCT_COLLECTION_BLOCK_NAME,
+} from '../../constants';
+import { registerCoreCollections } from '../../test/utils/contract-setup';
+import { CoreCollectionNames, CoreFilterNames } from '../../types';
+
+const productGrid = {
+ type: 'flex',
+ columns: 5,
+ shrinkColumns: true,
+};
+
+const linkedProductGrid = {
+ type: 'flex',
+ columns: 4,
+ shrinkColumns: true,
+};
+
+const chooserScope = [ 'inserter', 'block' ];
+
+const heading = ( content: string, textAlign = 'center' ) => [
+ 'core/heading',
+ {
+ textAlign,
+ level: 2,
+ content,
+ style: { spacing: { margin: { bottom: '1rem' } } },
+ },
+];
+
+const collectionRows = [
+ {
+ caseName: 'Product Catalog',
+ definition: productCatalog,
+ name: CoreCollectionNames.PRODUCT_CATALOG,
+ scope: [],
+ query: DEFAULT_QUERY,
+ displayLayout: DEFAULT_ATTRIBUTES.displayLayout,
+ hideControls: [ CoreFilterNames.INHERIT ],
+ usesReference: undefined,
+ innerBlocks: INNER_BLOCKS_TEMPLATE,
+ },
+ {
+ caseName: 'Featured Products',
+ definition: featured,
+ name: CoreCollectionNames.FEATURED,
+ scope: chooserScope,
+ query: { ...DEFAULT_QUERY, featured: true, perPage: 5, pages: 1 },
+ displayLayout: productGrid,
+ hideControls: [
+ CoreFilterNames.INHERIT,
+ CoreFilterNames.FEATURED,
+ CoreFilterNames.FILTERABLE,
+ ],
+ usesReference: undefined,
+ innerBlocks: [
+ heading( 'Featured products' ),
+ INNER_BLOCKS_PRODUCT_TEMPLATE,
+ ],
+ },
+ {
+ caseName: 'New Arrivals',
+ definition: newArrivals,
+ name: CoreCollectionNames.NEW_ARRIVALS,
+ scope: chooserScope,
+ query: {
+ ...DEFAULT_QUERY,
+ orderBy: 'date',
+ order: 'desc',
+ perPage: 5,
+ pages: 1,
+ timeFrame: { operator: 'in', value: '-7 days' },
+ },
+ displayLayout: productGrid,
+ hideControls: [
+ CoreFilterNames.INHERIT,
+ CoreFilterNames.ORDER,
+ CoreFilterNames.FILTERABLE,
+ ],
+ usesReference: undefined,
+ innerBlocks: [
+ heading( 'New arrivals' ),
+ INNER_BLOCKS_PRODUCT_TEMPLATE,
+ ],
+ },
+ {
+ caseName: 'On Sale Products',
+ definition: onSale,
+ name: CoreCollectionNames.ON_SALE,
+ scope: chooserScope,
+ query: {
+ ...DEFAULT_QUERY,
+ woocommerceOnSale: true,
+ perPage: 5,
+ pages: 1,
+ },
+ displayLayout: productGrid,
+ hideControls: [
+ CoreFilterNames.INHERIT,
+ CoreFilterNames.ON_SALE,
+ CoreFilterNames.FILTERABLE,
+ ],
+ usesReference: undefined,
+ innerBlocks: [
+ heading( 'On sale products' ),
+ INNER_BLOCKS_PRODUCT_TEMPLATE,
+ ],
+ },
+ {
+ caseName: 'Best Sellers',
+ definition: bestSellers,
+ name: CoreCollectionNames.BEST_SELLERS,
+ scope: chooserScope,
+ query: {
+ ...DEFAULT_QUERY,
+ orderBy: 'popularity',
+ order: 'desc',
+ perPage: 5,
+ pages: 1,
+ },
+ displayLayout: productGrid,
+ hideControls: [
+ CoreFilterNames.INHERIT,
+ CoreFilterNames.ORDER,
+ CoreFilterNames.FILTERABLE,
+ ],
+ usesReference: undefined,
+ innerBlocks: [
+ heading( 'Best selling products' ),
+ INNER_BLOCKS_PRODUCT_TEMPLATE,
+ ],
+ },
+ {
+ caseName: 'Top Rated Products',
+ definition: topRated,
+ name: CoreCollectionNames.TOP_RATED,
+ scope: chooserScope,
+ query: {
+ ...DEFAULT_QUERY,
+ orderBy: 'rating',
+ order: 'desc',
+ perPage: 5,
+ pages: 1,
+ },
+ displayLayout: productGrid,
+ hideControls: [
+ CoreFilterNames.INHERIT,
+ CoreFilterNames.ORDER,
+ CoreFilterNames.FILTERABLE,
+ ],
+ usesReference: undefined,
+ innerBlocks: [
+ heading( 'Top rated products' ),
+ INNER_BLOCKS_PRODUCT_TEMPLATE,
+ ],
+ },
+ {
+ caseName: 'Hand-Picked Products',
+ definition: handPicked,
+ name: CoreCollectionNames.HAND_PICKED,
+ scope: chooserScope,
+ query: { ...DEFAULT_QUERY, orderBy: 'post__in' },
+ displayLayout: productGrid,
+ hideControls: [
+ CoreFilterNames.INHERIT,
+ CoreFilterNames.HAND_PICKED,
+ CoreFilterNames.FILTERABLE,
+ CoreFilterNames.ORDER,
+ ],
+ usesReference: undefined,
+ innerBlocks: [
+ heading( 'Recommended products' ),
+ INNER_BLOCKS_PRODUCT_TEMPLATE,
+ INNER_BLOCKS_PAGINATION_TEMPLATE,
+ ],
+ },
+ {
+ caseName: 'Products by Category',
+ definition: byCategory,
+ name: CoreCollectionNames.BY_CATEGORY,
+ scope: chooserScope,
+ query: DEFAULT_QUERY,
+ displayLayout: productGrid,
+ hideControls: [
+ CoreFilterNames.INHERIT,
+ CoreFilterNames.HAND_PICKED,
+ CoreFilterNames.FILTERABLE,
+ ],
+ usesReference: undefined,
+ innerBlocks: [
+ heading( 'Products by Category' ),
+ INNER_BLOCKS_PRODUCT_TEMPLATE,
+ INNER_BLOCKS_PAGINATION_TEMPLATE,
+ ],
+ },
+ {
+ caseName: 'Products by Tag',
+ definition: byTag,
+ name: CoreCollectionNames.BY_TAG,
+ scope: chooserScope,
+ query: DEFAULT_QUERY,
+ displayLayout: productGrid,
+ hideControls: [
+ CoreFilterNames.INHERIT,
+ CoreFilterNames.HAND_PICKED,
+ CoreFilterNames.FILTERABLE,
+ ],
+ usesReference: undefined,
+ innerBlocks: [
+ heading( 'Products by Tag' ),
+ INNER_BLOCKS_PRODUCT_TEMPLATE,
+ INNER_BLOCKS_PAGINATION_TEMPLATE,
+ ],
+ },
+ {
+ caseName: 'Products by Brand',
+ definition: byBrand,
+ name: CoreCollectionNames.BY_BRAND,
+ scope: chooserScope,
+ query: DEFAULT_QUERY,
+ displayLayout: productGrid,
+ hideControls: [
+ CoreFilterNames.INHERIT,
+ CoreFilterNames.HAND_PICKED,
+ CoreFilterNames.FILTERABLE,
+ ],
+ usesReference: undefined,
+ innerBlocks: [
+ heading( 'Products by Brand' ),
+ INNER_BLOCKS_PRODUCT_TEMPLATE,
+ INNER_BLOCKS_PAGINATION_TEMPLATE,
+ ],
+ },
+ {
+ caseName: 'Related Products',
+ definition: related,
+ name: CoreCollectionNames.RELATED,
+ scope: chooserScope,
+ query: { ...DEFAULT_QUERY, perPage: 4, pages: 1 },
+ displayLayout: linkedProductGrid,
+ hideControls: [ CoreFilterNames.INHERIT ],
+ usesReference: [ 'product' ],
+ innerBlocks: [
+ heading( 'Related Products' ),
+ INNER_BLOCKS_PRODUCT_TEMPLATE,
+ ],
+ },
+ {
+ caseName: 'Upsells',
+ definition: upsells,
+ name: CoreCollectionNames.UPSELLS,
+ scope: chooserScope,
+ query: { ...DEFAULT_QUERY, perPage: 8, pages: 1 },
+ displayLayout: linkedProductGrid,
+ hideControls: [ CoreFilterNames.INHERIT, CoreFilterNames.FILTERABLE ],
+ usesReference: [ 'product', 'cart', 'order' ],
+ innerBlocks: [
+ heading( 'You may also like', 'left' ),
+ INNER_BLOCKS_PRODUCT_TEMPLATE,
+ ],
+ },
+ {
+ caseName: 'Cross-Sells',
+ definition: crossSells,
+ name: CoreCollectionNames.CROSS_SELLS,
+ scope: chooserScope,
+ query: { ...DEFAULT_QUERY, perPage: 8, pages: 1 },
+ displayLayout: linkedProductGrid,
+ hideControls: [ CoreFilterNames.INHERIT, CoreFilterNames.FILTERABLE ],
+ usesReference: [ 'product', 'cart', 'order' ],
+ innerBlocks: [
+ heading( 'You may be interested in…', 'left' ),
+ INNER_BLOCKS_PRODUCT_TEMPLATE,
+ ],
+ },
+] as const;
+
+const emailRows = [
+ {
+ caseName: 'Cart Contents',
+ definition: cartContents,
+ name: CoreCollectionNames.CART_CONTENTS,
+ scope: chooserScope,
+ query: {
+ ...DEFAULT_QUERY,
+ inherit: false,
+ perPage: 10,
+ pages: 1,
+ },
+ displayLayout: {
+ type: 'flex',
+ columns: 1,
+ shrinkColumns: true,
+ },
+ hideControls: [
+ CoreFilterNames.INHERIT,
+ CoreFilterNames.ATTRIBUTES,
+ CoreFilterNames.KEYWORD,
+ CoreFilterNames.ORDER,
+ CoreFilterNames.DEFAULT_ORDER,
+ CoreFilterNames.FEATURED,
+ CoreFilterNames.ON_SALE,
+ CoreFilterNames.STOCK_STATUS,
+ CoreFilterNames.HAND_PICKED,
+ CoreFilterNames.TAXONOMY,
+ CoreFilterNames.FILTERABLE,
+ CoreFilterNames.CREATED,
+ CoreFilterNames.PRICE_RANGE,
+ ],
+ usesReference: undefined,
+ innerBlocks: [ heading( 'Your Cart' ), INNER_BLOCKS_PRODUCT_TEMPLATE ],
+ },
+] as const;
+
+const allRows = [ ...collectionRows, ...emailRows ];
+
+const getRegisteredCollection = ( name: string ): BlockVariation => {
+ const variation = getBlockVariations( PRODUCT_COLLECTION_BLOCK_NAME )?.find(
+ ( candidate ) => candidate.name === name
+ );
+
+ if ( ! variation ) {
+ throw new Error( `Expected registered collection "${ name }".` );
+ }
+
+ return variation;
+};
+
+let unregisterCoreCollections: () => void;
+
+beforeAll( () => {
+ unregisterCoreCollections = registerCoreCollections();
+} );
+
+afterAll( () => {
+ unregisterCoreCollections();
+} );
+
+describe( 'Product Collection built-in collection contracts', () => {
+ it( 'registers chooser and email collections in their declared order', () => {
+ expect(
+ getBlockVariations( PRODUCT_COLLECTION_BLOCK_NAME )?.map(
+ ( variation ) => variation.name
+ )
+ ).toEqual( allRows.map( ( row ) => row.name ) );
+ } );
+
+ it.each( allRows )(
+ 'declares $caseName collection contract',
+ ( {
+ caseName,
+ definition,
+ name,
+ scope,
+ query,
+ displayLayout,
+ hideControls,
+ usesReference,
+ innerBlocks,
+ } ) => {
+ const variation = getRegisteredCollection( name );
+
+ expect( variation.title ).toBe( caseName );
+ expect( variation.scope ).toEqual( scope );
+ expect( variation.attributes?.collection ).toBe( name );
+ expect( variation.attributes?.query ).toEqual( query );
+ expect( variation.attributes?.displayLayout ).toEqual(
+ displayLayout
+ );
+ expect( variation.attributes?.hideControls ).toEqual(
+ hideControls
+ );
+ expect( variation.innerBlocks ).toEqual( innerBlocks );
+ expect(
+ 'usesReference' in definition
+ ? definition.usesReference
+ : undefined
+ ).toEqual( usesReference );
+ }
+ );
+
+ // Every case above builds its expected query by spreading DEFAULT_QUERY, so the
+ // shared defaults are only ever compared against themselves: changing perPage
+ // from 9 leaves all of them green. The deleted "Items per page, offset & max
+ // page to show" and "sort products by title" E2E titles were the only thing
+ // holding these values, so pin them against literals here.
+ it( 'pins the query defaults every collection inherits', () => {
+ expect( DEFAULT_QUERY ).toMatchObject( {
+ perPage: 9,
+ pages: 0,
+ offset: 0,
+ postType: 'product',
+ order: 'asc',
+ orderBy: 'title',
+ inherit: false,
+ } );
+ } );
+
+ it( 'keeps the default no-results block in the catalog template', () => {
+ expect(
+ getRegisteredCollection( CoreCollectionNames.PRODUCT_CATALOG )
+ ).toHaveProperty( 'innerBlocks', [
+ INNER_BLOCKS_PRODUCT_TEMPLATE,
+ INNER_BLOCKS_PAGINATION_TEMPLATE,
+ INNER_BLOCKS_NO_RESULTS_TEMPLATE,
+ ] );
+ } );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/inspector-controls/test/control-contracts.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/inspector-controls/test/control-contracts.tsx
new file mode 100644
index 00000000000..a64dc3bb23b
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/inspector-controls/test/control-contracts.tsx
@@ -0,0 +1,708 @@
+/**
+ * External dependencies
+ */
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import ProductCollectionInspectorControls from '..';
+import { DEFAULT_ATTRIBUTES, DEFAULT_QUERY } from '../../../constants';
+import { mockProductTaxonomies } from '../../../test/utils/contract-setup';
+import type {
+ ProductCollectionContentProps,
+ ProductCollectionAttributes,
+} from '../../../types';
+import { CoreFilterNames } from '../../../types';
+import { LocationType } from '../../../../product-template/utils';
+
+let mockIsEmailEditor = false;
+
+jest.mock( '@wordpress/block-editor', () => {
+ const actual = jest.requireActual( '@wordpress/block-editor' );
+ const React = jest.requireActual( 'react' );
+
+ return {
+ ...actual,
+ InspectorControls: ( { children } ) =>
+ React.createElement( 'div', null, children ),
+ };
+} );
+
+jest.mock( '@woocommerce/email-editor', () => ( {
+ useIsEmailEditor: () => mockIsEmailEditor,
+} ) );
+
+jest.mock( '@woocommerce/settings', () => ( {
+ ...jest.requireActual( '@woocommerce/settings' ),
+ ADMIN_URL: 'https://example.test/wp-admin/',
+ getSetting: jest.fn( ( setting, defaultValue ) =>
+ setting === 'stockStatusOptions'
+ ? {
+ instock: 'In stock',
+ outofstock: 'Out of stock',
+ }
+ : defaultValue
+ ),
+} ) );
+
+// Native inputs stand in for the real controls, so this suite proves the
+// setAttributes payloads, not the controls themselves. The retained E2E journeys
+// still drive the real inspector.
+jest.mock( '@wordpress/components', () => {
+ const actual = jest.requireActual( '@wordpress/components' );
+ const React = jest.requireActual( 'react' );
+ const passThrough = ( { children } ) =>
+ React.createElement( React.Fragment, null, children );
+ const BaseControl = ( { children, label } ) =>
+ React.createElement( 'fieldset', { 'aria-label': label }, children );
+ BaseControl.VisualLabel = passThrough;
+
+ return {
+ ...actual,
+ BaseControl,
+ ExternalLink: ( { children, href } ) =>
+ React.createElement( 'a', { href }, children ),
+ Flex: passThrough,
+ FlexItem: passThrough,
+ FormTokenField: ( { label, onChange, value = [] } ) =>
+ React.createElement( 'input', {
+ 'aria-label': label,
+ onChange: ( event ) =>
+ onChange(
+ event.target.value
+ ? event.target.value
+ .split( ',' )
+ .map( ( token ) => token.trim() )
+ : []
+ ),
+ value: value.join( ', ' ),
+ } ),
+ Notice: passThrough,
+ PanelBody: passThrough,
+ RadioControl: ( { onChange, options, selected } ) =>
+ React.createElement(
+ 'select',
+ {
+ 'aria-label': 'Created period',
+ onChange: ( event ) => onChange( event.target.value ),
+ value: selected || '',
+ },
+ React.createElement( 'option', { value: '' }, 'Select' ),
+ options.map( ( option ) =>
+ React.createElement(
+ 'option',
+ { key: option.value, value: option.value },
+ option.label
+ )
+ )
+ ),
+ RangeControl: ( { label, max, min, onChange, value } ) =>
+ React.createElement( 'input', {
+ 'aria-label': label,
+ max,
+ min,
+ onChange: ( event ) => onChange( Number( event.target.value ) ),
+ type: 'range',
+ value,
+ } ),
+ SelectControl: ( { label, onChange, options, value } ) =>
+ React.createElement(
+ 'select',
+ {
+ 'aria-label': label,
+ onChange: ( event ) => onChange( event.target.value ),
+ value,
+ },
+ options.map( ( option ) =>
+ React.createElement(
+ 'option',
+ { key: option.value, value: option.value },
+ option.label
+ )
+ )
+ ),
+ TextControl: ( { label, onChange, value } ) =>
+ React.createElement( 'input', {
+ 'aria-label': label,
+ onChange: ( event ) => onChange( event.target.value ),
+ value,
+ } ),
+ ToggleControl: ( { checked, label, onChange } ) =>
+ React.createElement( 'input', {
+ 'aria-label': label,
+ checked,
+ onChange: ( event ) => onChange( event.target.checked ),
+ type: 'checkbox',
+ } ),
+ __experimentalHStack: passThrough,
+ __experimentalInputControl: ( {
+ label,
+ onBlur,
+ onChange,
+ onKeyDown,
+ value,
+ } ) =>
+ React.createElement( 'input', {
+ 'aria-label': label,
+ onBlur,
+ onChange: ( event ) => onChange( event.target.value ),
+ onKeyDown,
+ value: value || '',
+ } ),
+ __experimentalInputControlPrefixWrapper: passThrough,
+ __experimentalNumberControl: ( { label, min, onChange, value } ) =>
+ React.createElement( 'input', {
+ 'aria-label': label,
+ min,
+ onChange: ( event ) => onChange( Number( event.target.value ) ),
+ type: 'number',
+ value,
+ } ),
+ __experimentalToggleGroupControl: ( {
+ children,
+ label,
+ onChange,
+ value,
+ } ) =>
+ React.createElement(
+ 'select',
+ {
+ 'aria-label': label,
+ onChange: ( event ) => onChange( event.target.value ),
+ value,
+ },
+ children
+ ),
+ __experimentalToggleGroupControlOption: ( { label, value } ) =>
+ React.createElement( 'option', { value }, label ),
+ __experimentalToolsPanel: ( { children, label } ) =>
+ React.createElement( 'section', { 'aria-label': label }, children ),
+ __experimentalToolsPanelItem: ( { children, label, onDeselect } ) =>
+ React.createElement(
+ 'section',
+ { 'aria-label': label },
+ children,
+ onDeselect &&
+ React.createElement(
+ 'button',
+ { onClick: onDeselect, type: 'button' },
+ `Reset ${ label }`
+ )
+ ),
+ __experimentalUnitControl: ( { onChange, value } ) =>
+ React.createElement( 'input', {
+ 'aria-label': 'Fixed width',
+ onChange: ( event ) => onChange( event.target.value ),
+ value,
+ } ),
+ };
+} );
+
+jest.mock(
+ '@woocommerce/editor-components/product-attribute-term-control',
+ () => ( props ) => {
+ const React = jest.requireActual( 'react' );
+ return React.createElement(
+ 'button',
+ {
+ onClick: () =>
+ props.onChange( [ { id: 41, value: 'pa_color' } ] ),
+ type: 'button',
+ },
+ 'Choose product attribute'
+ );
+ }
+);
+
+jest.mock(
+ '@woocommerce/editor-components/product-category-control',
+ () => ( props ) => {
+ const React = jest.requireActual( 'react' );
+ return React.createElement(
+ 'button',
+ {
+ onClick: () => props.onChange( [ { id: 31 } ] ),
+ type: 'button',
+ },
+ 'Choose product category'
+ );
+ }
+);
+
+jest.mock(
+ '@woocommerce/editor-components/product-tag-control',
+ () => ( props ) => {
+ const React = jest.requireActual( 'react' );
+ return React.createElement(
+ 'button',
+ {
+ onClick: () => props.onChange( [ { id: 32 } ] ),
+ type: 'button',
+ },
+ 'Choose product tag'
+ );
+ }
+);
+
+jest.mock(
+ '@woocommerce/editor-components/product-brand-control',
+ () => ( props ) => {
+ const React = jest.requireActual( 'react' );
+ return React.createElement(
+ 'button',
+ {
+ onClick: () => props.onChange( [ { id: 33 } ] ),
+ type: 'button',
+ },
+ 'Choose product brand'
+ );
+ }
+);
+
+jest.mock( '@woocommerce/editor-components/utils', () => ( {
+ ...jest.requireActual( '@woocommerce/editor-components/utils' ),
+ getProducts: jest.fn().mockResolvedValue( [
+ { id: 71, name: 'Beanie' },
+ { id: 72, name: 'Cap' },
+ ] ),
+} ) );
+
+jest.mock( '@woocommerce/tracks', () => ( {
+ recordEvent: jest.fn(),
+} ) );
+
+const createAttributes = (
+ overrides: Partial< ProductCollectionAttributes > = {}
+): ProductCollectionAttributes => ( {
+ ...DEFAULT_ATTRIBUTES,
+ collection: undefined,
+ convertedFromProducts: false,
+ hideControls: [ CoreFilterNames.HAND_PICKED ],
+ query: { ...DEFAULT_QUERY },
+ queryContext: [ { page: 1 } ],
+ queryId: 1,
+ templateSlug: '',
+ ...overrides,
+} );
+
+const renderInspector = ( {
+ attributes = createAttributes(),
+ templateSlug = 'single-product',
+}: {
+ attributes?: ProductCollectionAttributes;
+ templateSlug?: string;
+} = {} ) => {
+ const setAttributes = jest.fn();
+ const props = {
+ attributes,
+ clientId: 'product-collection-test',
+ context: { templateSlug },
+ insertBlocksAfter: jest.fn(),
+ isSelected: true,
+ isUsingReferencePreviewMode: false,
+ location: { type: LocationType.Site },
+ name: 'woocommerce/product-collection',
+ onReplace: jest.fn(),
+ openCollectionSelectionModal: jest.fn(),
+ setAttributes,
+ tracksLocation: 'single-product',
+ } as unknown as ProductCollectionContentProps;
+
+ const renderResult = render(
+ <ProductCollectionInspectorControls { ...props } />
+ );
+
+ return { ...renderResult, setAttributes };
+};
+
+describe( 'Product Collection inspector control contracts', () => {
+ beforeEach( () => {
+ mockIsEmailEditor = false;
+ mockProductTaxonomies();
+ } );
+
+ afterEach( () => {
+ jest.restoreAllMocks();
+ } );
+
+ it( 'writes the complete nested query from the products-per-page control', () => {
+ const { setAttributes } = renderInspector();
+
+ fireEvent.change(
+ screen.getByRole( 'slider', { name: 'Products per page' } ),
+ { target: { value: '12' } }
+ );
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ query: {
+ ...DEFAULT_QUERY,
+ perPage: 12,
+ },
+ } );
+ } );
+
+ it.each( [
+ [ 'order', 'Order by', 'combobox' ],
+ [ 'keyword', 'Keyword', 'textbox' ],
+ [ 'offset', 'Offset', 'spinbutton' ],
+ [ 'max-pages-to-show', 'Max pages to show', 'spinbutton' ],
+ [ 'products-per-page', 'Products per page', 'slider' ],
+ [ 'on-sale', 'Show only products on sale', 'checkbox' ],
+ [ 'featured', 'Show only featured products', 'checkbox' ],
+ ] as const )(
+ 'honors the %s hidden-control contract',
+ ( control, label, role ) => {
+ renderInspector( {
+ attributes: createAttributes( {
+ hideControls: [ CoreFilterNames.HAND_PICKED, control ],
+ } ),
+ } );
+
+ expect(
+ screen.queryByRole( role, { name: label } )
+ ).not.toBeInTheDocument();
+ }
+ );
+
+ it( 'writes the debounced keyword to the complete nested query', () => {
+ jest.useFakeTimers();
+ const { setAttributes, unmount } = renderInspector();
+
+ try {
+ fireEvent.change(
+ screen.getByRole( 'textbox', { name: 'Keyword' } ),
+ { target: { value: 'beanie' } }
+ );
+ expect( setAttributes ).not.toHaveBeenCalled();
+
+ act( () => {
+ jest.advanceTimersByTime( 250 );
+ } );
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ query: {
+ ...DEFAULT_QUERY,
+ search: 'beanie',
+ },
+ } );
+ } finally {
+ unmount();
+ jest.useRealTimers();
+ }
+ } );
+
+ it.each( [
+ [ 'category', 'product_cat', 31 ],
+ [ 'tag', 'product_tag', 32 ],
+ [ 'brand', 'product_brand', 33 ],
+ ] as const )(
+ 'writes the %s taxonomy selection to the complete nested query',
+ ( control, taxonomy, termId ) => {
+ const { setAttributes } = renderInspector();
+
+ fireEvent.click(
+ screen.getByRole( 'button', {
+ name: `Choose product ${ control }`,
+ } )
+ );
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ query: {
+ ...DEFAULT_QUERY,
+ taxQuery: { [ taxonomy ]: [ termId ] },
+ },
+ } );
+ }
+ );
+
+ it( 'switches the visible controls for inherited, carousel, and email contexts', () => {
+ const inherited = createAttributes( {
+ query: { ...DEFAULT_QUERY, inherit: true },
+ } );
+ const { rerender } = renderInspector( {
+ attributes: inherited,
+ templateSlug: 'archive-product',
+ } );
+
+ expect(
+ screen.getByRole( 'combobox', { name: 'Query type' } )
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole( 'region', { name: 'Filters' } )
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole( 'spinbutton', { name: 'Offset' } )
+ ).not.toBeInTheDocument();
+
+ const carousel = {
+ ...createAttributes(),
+ displayLayout: {
+ ...DEFAULT_ATTRIBUTES.displayLayout,
+ type: 'carousel',
+ },
+ } as ProductCollectionAttributes;
+ rerender(
+ <ProductCollectionInspectorControls
+ { ...( {
+ attributes: carousel,
+ clientId: 'product-collection-test',
+ context: { templateSlug: 'single-product' },
+ location: { type: LocationType.Site },
+ setAttributes: jest.fn(),
+ } as unknown as ProductCollectionContentProps ) }
+ />
+ );
+ expect(
+ screen.getByRole( 'slider', { name: 'Products in carousel' } )
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole( 'slider', { name: 'Columns' } )
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole( 'spinbutton', {
+ name: 'Max pages to show',
+ } )
+ ).not.toBeInTheDocument();
+
+ mockIsEmailEditor = true;
+ rerender(
+ <ProductCollectionInspectorControls
+ { ...( {
+ attributes: createAttributes(),
+ clientId: 'product-collection-test',
+ context: { templateSlug: 'single-product' },
+ location: { type: LocationType.Site },
+ setAttributes: jest.fn(),
+ } as unknown as ProductCollectionContentProps ) }
+ />
+ );
+ expect(
+ screen.getByRole( 'slider', { name: 'Number of products' } )
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole( 'combobox', { name: 'Layout' } )
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole( 'combobox', { name: 'Width' } )
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole( 'spinbutton', { name: 'Offset' } )
+ ).not.toBeInTheDocument();
+ } );
+
+ it.each( [
+ [
+ 'Order by',
+ 'combobox',
+ 'title/desc',
+ { orderBy: 'title', order: 'desc' },
+ ],
+ [ 'Offset', 'spinbutton', '4', { offset: 4 } ],
+ [ 'Max pages to show', 'spinbutton', '2', { pages: 2 } ],
+ ] as const )(
+ 'writes the complete nested query from %s',
+ ( label, role, value, update ) => {
+ const { setAttributes } = renderInspector();
+ fireEvent.change( screen.getByRole( role, { name: label } ), {
+ target: { value },
+ } );
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ query: { ...DEFAULT_QUERY, ...update },
+ } );
+ }
+ );
+
+ it.each( [
+ [ 'Show only products on sale', { woocommerceOnSale: true } ],
+ [ 'Show only featured products', { featured: true } ],
+ ] as const )(
+ 'writes the complete nested query from %s',
+ ( label, update ) => {
+ const { setAttributes } = renderInspector();
+ fireEvent.click( screen.getByRole( 'checkbox', { name: label } ) );
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ query: { ...DEFAULT_QUERY, ...update },
+ } );
+ }
+ );
+
+ it( 'writes stock and attribute filters', () => {
+ const { setAttributes } = renderInspector();
+
+ fireEvent.change(
+ screen.getByRole( 'textbox', { name: 'Stock Status' } ),
+ {
+ target: { value: 'In stock' },
+ }
+ );
+ expect( setAttributes ).toHaveBeenLastCalledWith( {
+ query: {
+ ...DEFAULT_QUERY,
+ woocommerceStockStatus: [ 'instock' ],
+ },
+ } );
+
+ fireEvent.click( screen.getByText( 'Choose product attribute' ) );
+ expect( setAttributes ).toHaveBeenLastCalledWith( {
+ query: {
+ ...DEFAULT_QUERY,
+ woocommerceAttributes: [ { taxonomy: 'pa_color', termId: 41 } ],
+ },
+ } );
+ } );
+
+ it( 'writes the created period with its default within operator', () => {
+ const { setAttributes } = renderInspector();
+
+ fireEvent.change(
+ screen.getByRole( 'combobox', {
+ name: 'Created period',
+ } ),
+ {
+ target: { value: '-7 days' },
+ }
+ );
+ expect( setAttributes ).toHaveBeenLastCalledWith( {
+ query: {
+ ...DEFAULT_QUERY,
+ timeFrame: { operator: 'in', value: '-7 days' },
+ },
+ } );
+ } );
+
+ it.each( [
+ [ 'in', 'not-in' ],
+ [ 'not-in', 'in' ],
+ ] as const )(
+ 'writes the created operator from %s to %s',
+ ( initialOperator, nextOperator ) => {
+ const attributes = createAttributes( {
+ query: {
+ ...DEFAULT_QUERY,
+ timeFrame: {
+ operator: initialOperator,
+ value: '-7 days',
+ },
+ },
+ } );
+ const { setAttributes } = renderInspector( { attributes } );
+
+ fireEvent.change(
+ screen.getByRole( 'combobox', { name: 'Created' } ),
+ { target: { value: nextOperator } }
+ );
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ query: {
+ ...attributes.query,
+ timeFrame: {
+ operator: nextOperator,
+ value: '-7 days',
+ },
+ },
+ } );
+ }
+ );
+
+ it.each( [
+ [ 'MIN', '10', { max: 20, min: 10 } ],
+ [ 'MAX', '30', { max: 30, min: 5 } ],
+ ] as const )(
+ 'writes the %s price boundary',
+ ( label, value, expectedPriceRange ) => {
+ const attributes = createAttributes( {
+ query: {
+ ...DEFAULT_QUERY,
+ priceRange: { max: 20, min: 5 },
+ },
+ } );
+ const { setAttributes } = renderInspector( { attributes } );
+
+ fireEvent.change( screen.getByRole( 'textbox', { name: label } ), {
+ target: { value },
+ } );
+ fireEvent.blur( screen.getByRole( 'textbox', { name: label } ) );
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ query: {
+ ...attributes.query,
+ priceRange: expectedPriceRange,
+ },
+ } );
+ }
+ );
+
+ it( 'resets the price range', () => {
+ const attributes = createAttributes( {
+ query: {
+ ...DEFAULT_QUERY,
+ priceRange: { max: 20, min: 5 },
+ },
+ } );
+ const { setAttributes } = renderInspector( { attributes } );
+
+ fireEvent.click(
+ screen.getByRole( 'button', { name: 'Reset Price Range' } )
+ );
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ query: {
+ ...attributes.query,
+ priceRange: undefined,
+ },
+ } );
+ } );
+
+ it( 'writes the hand-picked product filter after remote data resolves', async () => {
+ const { setAttributes } = renderInspector( {
+ attributes: createAttributes( { hideControls: [] } ),
+ } );
+ const field = screen.getByRole( 'textbox', { name: 'Hand-Picked' } );
+ await waitFor( () => expect( field ).toHaveValue( '' ) );
+
+ fireEvent.change( field, {
+ target: { value: 'Cap, Beanie' },
+ } );
+ expect( setAttributes ).toHaveBeenLastCalledWith( {
+ query: {
+ ...DEFAULT_QUERY,
+ woocommerceHandPickedProducts: [ '72', '71' ],
+ },
+ } );
+ } );
+
+ it( 'writes display settings and resets the products-per-page query', () => {
+ const attributes = createAttributes( {
+ query: { ...DEFAULT_QUERY, perPage: 12 },
+ } );
+ const { setAttributes } = renderInspector( { attributes } );
+
+ fireEvent.change( screen.getByRole( 'slider', { name: 'Columns' } ), {
+ target: { value: '4' },
+ } );
+ expect( setAttributes ).toHaveBeenLastCalledWith( {
+ displayLayout: {
+ ...DEFAULT_ATTRIBUTES.displayLayout,
+ columns: 4,
+ },
+ } );
+
+ fireEvent.click(
+ screen.getByRole( 'button', {
+ name: 'Reset Products per page',
+ } )
+ );
+ expect( setAttributes ).toHaveBeenLastCalledWith( {
+ query: { ...attributes.query, perPage: DEFAULT_QUERY.perPage },
+ } );
+ } );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/test/picker-contracts.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/test/picker-contracts.tsx
new file mode 100644
index 00000000000..3d47af928bf
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/test/picker-contracts.tsx
@@ -0,0 +1,648 @@
+/**
+ * External dependencies
+ */
+import { act, render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import {
+ BlockEditorProvider,
+ store as blockEditorStore,
+} from '@wordpress/block-editor';
+import { type BlockInstance } from '@wordpress/blocks';
+import { dispatch } from '@wordpress/data';
+import { useState, type ReactNode } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import Edit from '..';
+import LinkedProductControl from '../inspector-controls/linked-product-control';
+import MultiProductPicker from '../multi-product-picker';
+import SingleProductPicker from '../single-product-picker';
+import TaxonomyPicker from '../taxonomy-picker';
+import {
+ DEFAULT_ATTRIBUTES,
+ DEFAULT_QUERY,
+ PRODUCT_COLLECTION_BLOCK_NAME,
+} from '../../constants';
+import {
+ mockProductTaxonomies,
+ registerCoreCollections,
+} from '../../test/utils/contract-setup';
+import {
+ CoreCollectionNames,
+ type ProductCollectionAttributes,
+ type ProductCollectionEditComponentProps,
+ type ProductCollectionQuery,
+ type ProductCollectionSetAttributes,
+} from '../../types';
+import {
+ LocationType,
+ type WooCommerceBlockLocation,
+} from '../../../product-template/utils';
+
+jest.mock( '@woocommerce/editor-components/products-control', () => {
+ const React = jest.requireActual( 'react' );
+
+ return ( { onChange, selected } ) =>
+ React.createElement(
+ 'button',
+ {
+ 'aria-label': 'Choose hand-picked products',
+ 'data-selected': selected.join( ',' ),
+ onClick: () => onChange( [ { id: 71 }, { id: 72 } ] ),
+ type: 'button',
+ },
+ 'Choose hand-picked products'
+ );
+} );
+
+jest.mock( '@woocommerce/editor-components/product-category-control', () => {
+ const React = jest.requireActual( 'react' );
+
+ return ( { onChange, selected } ) =>
+ React.createElement(
+ 'button',
+ {
+ 'aria-label': 'Choose product category',
+ 'data-selected': selected.join( ',' ),
+ onClick: () => onChange( [ { id: 31 } ] ),
+ type: 'button',
+ },
+ 'Choose product category'
+ );
+} );
+
+jest.mock( '@woocommerce/editor-components/product-tag-control', () => {
+ const React = jest.requireActual( 'react' );
+
+ return ( { onChange, selected } ) =>
+ React.createElement(
+ 'button',
+ {
+ 'aria-label': 'Choose product tag',
+ 'data-selected': selected.join( ',' ),
+ onClick: () => onChange( [ { id: '32' } ] ),
+ type: 'button',
+ },
+ 'Choose product tag'
+ );
+} );
+
+jest.mock( '@woocommerce/editor-components/product-brand-control', () => {
+ const React = jest.requireActual( 'react' );
+
+ return ( { onChange, selected } ) =>
+ React.createElement(
+ 'button',
+ {
+ 'aria-label': 'Choose product brand',
+ 'data-selected': selected.join( ',' ),
+ onClick: () => onChange( [ { id: 33 } ] ),
+ type: 'button',
+ },
+ 'Choose product brand'
+ );
+} );
+
+jest.mock( '@woocommerce/editor-components/product-control', () => {
+ const React = jest.requireActual( 'react' );
+
+ return ( { onChange, selected } ) =>
+ React.createElement(
+ 'button',
+ {
+ 'aria-label': 'Choose product reference',
+ 'data-selected': String( selected ?? '' ),
+ onClick: () => onChange( [ { id: 73 } ] ),
+ type: 'button',
+ },
+ 'Choose product reference'
+ );
+} );
+
+jest.mock(
+ '@woocommerce/editor-components/product-attribute-term-control',
+ () => {
+ const React = jest.requireActual( 'react' );
+
+ return ( { onChange } ) =>
+ React.createElement(
+ 'button',
+ {
+ 'aria-label': 'Choose product attribute',
+ onClick: () =>
+ onChange( [ { id: 41, value: 'pa_color' } ] ),
+ type: 'button',
+ },
+ 'Choose product attribute'
+ );
+ }
+);
+
+jest.mock( '@woocommerce/editor-components/utils', () => ( {
+ ...jest.requireActual( '@woocommerce/editor-components/utils' ),
+ getProduct: jest.fn( ( id: number ) =>
+ Promise.resolve( {
+ id,
+ images: [],
+ name: id === 71 ? 'Beanie' : `Product ${ id }`,
+ sku: `sku-${ id }`,
+ } )
+ ),
+ getProducts: jest.fn().mockResolvedValue( [
+ { id: 71, name: 'Beanie' },
+ { id: 72, name: 'Cap' },
+ ] ),
+} ) );
+
+const createAttributes = (
+ collection: string,
+ query: Partial< ProductCollectionQuery > = {}
+): ProductCollectionAttributes => ( {
+ ...DEFAULT_ATTRIBUTES,
+ collection,
+ convertedFromProducts: false,
+ filterable: false,
+ hideControls: [],
+ query: { ...DEFAULT_QUERY, ...query },
+ queryContext: [ { page: 1 } ],
+ queryId: 1,
+ templateSlug: '',
+} );
+
+const makeEditProps = (
+ attributes: ProductCollectionAttributes,
+ setAttributes: ProductCollectionSetAttributes,
+ usesReference?: string[]
+): ProductCollectionEditComponentProps =>
+ ( {
+ attributes,
+ clientId: 'product-collection-picker-contract',
+ context: { templateSlug: '' },
+ insertBlocksAfter: jest.fn(),
+ isSelected: true,
+ name: PRODUCT_COLLECTION_BLOCK_NAME,
+ onReplace: jest.fn(),
+ setAttributes,
+ tracksLocation: 'other',
+ usesReference,
+ } ) as unknown as ProductCollectionEditComponentProps;
+
+const GutenbergProvider = ( { children }: { children: ReactNode } ) => {
+ const [ blocks, setBlocks ] = useState< BlockInstance[] >( [] );
+
+ return (
+ <BlockEditorProvider
+ value={ blocks }
+ onInput={ setBlocks }
+ onChange={ setBlocks }
+ settings={ {} }
+ >
+ { children }
+ </BlockEditorProvider>
+ );
+};
+
+const StatefulAttributes = ( {
+ children,
+ initialAttributes,
+ onSetAttributes = jest.fn(),
+}: {
+ children: (
+ attributes: ProductCollectionAttributes,
+ setAttributes: ProductCollectionSetAttributes
+ ) => ReactNode;
+ initialAttributes: ProductCollectionAttributes;
+ onSetAttributes?: jest.Mock;
+} ) => {
+ const [ attributes, setCurrentAttributes ] = useState( initialAttributes );
+ const setAttributes: ProductCollectionSetAttributes = ( updates ) => {
+ onSetAttributes( updates );
+ setCurrentAttributes( ( current ) => ( {
+ ...current,
+ ...updates,
+ } ) );
+ };
+
+ return (
+ <>
+ { children( attributes, setAttributes ) }
+ <output data-testid="current-query">
+ { JSON.stringify( attributes.query ) }
+ </output>
+ </>
+ );
+};
+
+const renderInEditor = ( children: ReactNode ) =>
+ render( <GutenbergProvider>{ children }</GutenbergProvider> );
+
+const click = async (
+ user: ReturnType< typeof userEvent.setup >,
+ element: HTMLElement
+) => {
+ await act( async () => {
+ await user.click( element );
+ } );
+};
+
+let unregisterCoreCollections: () => void;
+
+beforeAll( () => {
+ unregisterCoreCollections = registerCoreCollections();
+} );
+
+beforeEach( () => {
+ dispatch( blockEditorStore ).resetBlocks( [] );
+ mockProductTaxonomies();
+} );
+
+afterEach( () => {
+ jest.restoreAllMocks();
+} );
+
+afterAll( () => {
+ unregisterCoreCollections();
+} );
+
+describe( 'collection-specific picker contracts', () => {
+ it( 'writes hand-picked products and enables Done before dismissal', async () => {
+ const user = userEvent.setup();
+ const onDone = jest.fn();
+ const onSetAttributes = jest.fn();
+ const attributes = createAttributes( CoreCollectionNames.HAND_PICKED, {
+ search: 'shirts',
+ } );
+
+ renderInEditor(
+ <StatefulAttributes
+ initialAttributes={ attributes }
+ onSetAttributes={ onSetAttributes }
+ >
+ { ( currentAttributes, setAttributes ) => (
+ <MultiProductPicker
+ { ...makeEditProps( currentAttributes, setAttributes ) }
+ onDone={ onDone }
+ />
+ ) }
+ </StatefulAttributes>
+ );
+
+ const done = screen.getByRole( 'button', { name: 'Done' } );
+ expect( done ).toBeDisabled();
+
+ await click(
+ user,
+ screen.getByRole( 'button', {
+ name: 'Choose hand-picked products',
+ } )
+ );
+
+ expect( onSetAttributes ).toHaveBeenLastCalledWith( {
+ query: {
+ ...attributes.query,
+ woocommerceHandPickedProducts: [ '71', '72' ],
+ },
+ } );
+ expect( done ).toBeEnabled();
+ await click( user, done );
+ expect( onDone ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( 'writes Products by Category taxonomy selection', async () => {
+ const user = userEvent.setup();
+ const onDone = jest.fn();
+ const onSetAttributes = jest.fn();
+ const attributes = createAttributes( CoreCollectionNames.BY_CATEGORY, {
+ search: 'hoodie',
+ taxQuery: { product_tag: [ 17 ] },
+ } );
+
+ renderInEditor(
+ <StatefulAttributes
+ initialAttributes={ attributes }
+ onSetAttributes={ onSetAttributes }
+ >
+ { ( currentAttributes, setAttributes ) => (
+ <TaxonomyPicker
+ { ...makeEditProps( currentAttributes, setAttributes ) }
+ onDone={ onDone }
+ />
+ ) }
+ </StatefulAttributes>
+ );
+
+ const done = screen.getByRole( 'button', { name: 'Done' } );
+ expect( done ).toBeDisabled();
+ await click(
+ user,
+ screen.getByRole( 'button', {
+ name: 'Choose product category',
+ } )
+ );
+
+ expect( onSetAttributes ).toHaveBeenLastCalledWith( {
+ query: {
+ ...attributes.query,
+ taxQuery: {
+ product_tag: [ 17 ],
+ product_cat: [ 31 ],
+ },
+ },
+ } );
+ expect( done ).toBeEnabled();
+ await click( user, done );
+ expect( onDone ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it.each( [
+ {
+ caseName: 'Products by Tag',
+ collection: CoreCollectionNames.BY_TAG,
+ controlName: 'Choose product tag',
+ taxonomy: 'product_tag',
+ termId: 32,
+ },
+ {
+ caseName: 'Products by Brand',
+ collection: CoreCollectionNames.BY_BRAND,
+ controlName: 'Choose product brand',
+ taxonomy: 'product_brand',
+ termId: 33,
+ },
+ ] )( 'writes $caseName taxonomy selection', async ( row ) => {
+ const user = userEvent.setup();
+ const onSetAttributes = jest.fn();
+ const attributes = createAttributes( row.collection );
+
+ renderInEditor(
+ <StatefulAttributes
+ initialAttributes={ attributes }
+ onSetAttributes={ onSetAttributes }
+ >
+ { ( currentAttributes, setAttributes ) => (
+ <TaxonomyPicker
+ { ...makeEditProps( currentAttributes, setAttributes ) }
+ onDone={ jest.fn() }
+ />
+ ) }
+ </StatefulAttributes>
+ );
+
+ await click(
+ user,
+ screen.getByRole( 'button', { name: row.controlName } )
+ );
+
+ expect( onSetAttributes ).toHaveBeenLastCalledWith( {
+ query: {
+ ...attributes.query,
+ taxQuery: { [ row.taxonomy ]: [ row.termId ] },
+ },
+ } );
+ expect( screen.getByRole( 'button', { name: 'Done' } ) ).toBeEnabled();
+ } );
+
+ it( 'writes an exact single-product reference', async () => {
+ const user = userEvent.setup();
+ const onSetAttributes = jest.fn();
+ const attributes = createAttributes( CoreCollectionNames.RELATED, {
+ search: 'hoodie',
+ } );
+
+ renderInEditor(
+ <StatefulAttributes
+ initialAttributes={ attributes }
+ onSetAttributes={ onSetAttributes }
+ >
+ { ( currentAttributes, setAttributes ) => (
+ <SingleProductPicker
+ { ...makeEditProps( currentAttributes, setAttributes, [
+ 'product',
+ ] ) }
+ isDeletedProductReference={ false }
+ />
+ ) }
+ </StatefulAttributes>
+ );
+
+ await click(
+ user,
+ screen.getByRole( 'button', {
+ name: 'Choose product reference',
+ } )
+ );
+ expect( onSetAttributes ).toHaveBeenLastCalledWith( {
+ query: { ...attributes.query, productReference: 73 },
+ } );
+ } );
+} );
+
+describe( 'Edit picker-state contracts', () => {
+ it( 'dismisses and reactivates the hand-picked picker', async () => {
+ const user = userEvent.setup();
+ const setAttributes = jest.fn();
+ const empty = createAttributes( CoreCollectionNames.HAND_PICKED );
+ const selected = createAttributes( CoreCollectionNames.HAND_PICKED, {
+ woocommerceHandPickedProducts: [ '71', '72' ],
+ } );
+ const { rerender } = renderInEditor(
+ <Edit { ...makeEditProps( empty, setAttributes ) } />
+ );
+
+ expect(
+ await screen.findByRole( 'button', {
+ name: 'Choose hand-picked products',
+ } )
+ ).toBeInTheDocument();
+
+ rerender(
+ <GutenbergProvider>
+ <Edit { ...makeEditProps( selected, setAttributes ) } />
+ </GutenbergProvider>
+ );
+ const done = screen.getByRole( 'button', { name: 'Done' } );
+ expect( done ).toBeEnabled();
+ await click( user, done );
+ await waitFor( () =>
+ expect(
+ screen.queryByRole( 'button', {
+ name: 'Choose hand-picked products',
+ } )
+ ).not.toBeInTheDocument()
+ );
+
+ rerender(
+ <GutenbergProvider>
+ <Edit { ...makeEditProps( empty, setAttributes ) } />
+ </GutenbergProvider>
+ );
+ expect(
+ await screen.findByRole( 'button', {
+ name: 'Choose hand-picked products',
+ } )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'drops stale pickers when collections switch', async () => {
+ const setAttributes = jest.fn();
+ const { rerender } = renderInEditor(
+ <Edit
+ { ...makeEditProps(
+ createAttributes( CoreCollectionNames.HAND_PICKED ),
+ setAttributes
+ ) }
+ />
+ );
+
+ expect(
+ await screen.findByRole( 'button', {
+ name: 'Choose hand-picked products',
+ } )
+ ).toBeInTheDocument();
+
+ rerender(
+ <GutenbergProvider>
+ <Edit
+ { ...makeEditProps(
+ createAttributes( CoreCollectionNames.BY_CATEGORY ),
+ setAttributes
+ ) }
+ />
+ </GutenbergProvider>
+ );
+ expect(
+ await screen.findByRole( 'button', {
+ name: 'Choose product category',
+ } )
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole( 'button', {
+ name: 'Choose hand-picked products',
+ } )
+ ).not.toBeInTheDocument();
+
+ setAttributes.mockClear();
+ rerender(
+ <GutenbergProvider>
+ <Edit
+ { ...makeEditProps(
+ createAttributes( CoreCollectionNames.FEATURED ),
+ setAttributes
+ ) }
+ />
+ </GutenbergProvider>
+ );
+ await waitFor( () =>
+ expect( setAttributes ).toHaveBeenCalledWith(
+ expect.objectContaining( {
+ collection: CoreCollectionNames.FEATURED,
+ } )
+ )
+ );
+ expect(
+ screen.queryByRole( 'button', {
+ name: 'Choose hand-picked products',
+ } )
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole( 'button', {
+ name: 'Choose product category',
+ } )
+ ).not.toBeInTheDocument();
+ } );
+} );
+
+describe( 'linked-product reference contracts', () => {
+ it.each( [
+ {
+ caseName: 'product',
+ label: 'From the current product',
+ location: LocationType.Product,
+ usesReference: [ 'product' ],
+ },
+ {
+ caseName: 'cart',
+ label: 'From products in the cart',
+ location: LocationType.Cart,
+ usesReference: [ 'cart' ],
+ },
+ {
+ caseName: 'order',
+ label: 'From products in the order',
+ location: LocationType.Order,
+ usesReference: [ 'order' ],
+ },
+ {
+ caseName: 'multiple',
+ label: 'From the current product',
+ location: LocationType.Product,
+ usesReference: [ 'product', 'cart', 'order' ],
+ },
+ ] )( 'defaults $caseName context to its current reference', ( row ) => {
+ renderInEditor(
+ <LinkedProductControl
+ query={ { ...DEFAULT_QUERY } }
+ setAttributes={ jest.fn() }
+ location={ { type: row.location } as WooCommerceBlockLocation }
+ usesReference={ row.usesReference }
+ />
+ );
+
+ expect(
+ screen.getByRole( 'radio', { name: row.label } )
+ ).toBeChecked();
+ expect(
+ screen.getByRole( 'radio', {
+ name: 'From a specific product',
+ } )
+ ).not.toBeChecked();
+ } );
+
+ it( 'replaces the exact linked product and preserves the query', async () => {
+ const user = userEvent.setup();
+ const onSetAttributes = jest.fn();
+ const initialQuery = {
+ ...DEFAULT_QUERY,
+ productReference: 71,
+ search: 'hoodie',
+ };
+
+ renderInEditor(
+ <StatefulAttributes
+ initialAttributes={ createAttributes(
+ CoreCollectionNames.RELATED,
+ initialQuery
+ ) }
+ onSetAttributes={ onSetAttributes }
+ >
+ { ( attributes, setAttributes ) => (
+ <LinkedProductControl
+ query={ attributes.query }
+ setAttributes={ setAttributes }
+ location={ { type: LocationType.Site } }
+ usesReference={ [ 'product' ] }
+ />
+ ) }
+ </StatefulAttributes>
+ );
+
+ await act( async () => {
+ await Promise.resolve();
+ } );
+ await click(
+ user,
+ await screen.findByRole( 'button', { name: /Beanie/ } )
+ );
+ await click(
+ user,
+ screen.getByRole( 'button', {
+ name: 'Choose product reference',
+ } )
+ );
+
+ expect( onSetAttributes ).toHaveBeenLastCalledWith( {
+ query: { ...initialQuery, productReference: 73 },
+ } );
+ } );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/test/utils/contract-setup.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/test/utils/contract-setup.ts
new file mode 100644
index 00000000000..5d52db86c85
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/test/utils/contract-setup.ts
@@ -0,0 +1,106 @@
+/**
+ * External dependencies
+ */
+import {
+ getBlockVariations,
+ registerBlockVariation,
+ unregisterBlockVariation,
+} from '@wordpress/blocks';
+import { store as coreStore } from '@wordpress/core-data';
+import { select } from '@wordpress/data';
+import { removeFilter } from '@wordpress/hooks';
+
+/**
+ * Internal dependencies
+ */
+import {
+ registerCollections,
+ registerEmailCollections,
+} from '../../collections';
+import { PRODUCT_COLLECTION_BLOCK_NAME } from '../../constants';
+
+type Taxonomy = {
+ name: string;
+ slug: string;
+ visibility: { publicly_queryable: boolean };
+};
+
+const getCollectionNames = () =>
+ ( getBlockVariations( PRODUCT_COLLECTION_BLOCK_NAME ) ?? [] ).map(
+ ( variation ) => variation.name
+ );
+
+/**
+ * Registers the chooser and email collections the way the editor does.
+ *
+ * Returns a function that unregisters the collections it added and restores `window.wp`.
+ */
+export const registerCoreCollections = (): ( () => void ) => {
+ const originalWpDescriptor = Object.getOwnPropertyDescriptor(
+ window,
+ 'wp'
+ );
+ const existingNames = new Set( getCollectionNames() );
+
+ // registerProductCollection() calls the global wp.blocks.registerBlockVariation.
+ Object.defineProperty( window, 'wp', {
+ configurable: true,
+ writable: true,
+ value: {
+ ...window.wp,
+ blocks: {
+ ...window.wp?.blocks,
+ registerBlockVariation,
+ },
+ },
+ } );
+
+ registerCollections();
+ registerEmailCollections();
+
+ const registeredNames = getCollectionNames().filter(
+ ( name ) => ! existingNames.has( name )
+ );
+
+ return () => {
+ for ( const name of registeredNames ) {
+ unregisterBlockVariation( PRODUCT_COLLECTION_BLOCK_NAME, name );
+ removeFilter( 'editor.BlockEdit', name );
+ }
+
+ if ( originalWpDescriptor ) {
+ Object.defineProperty( window, 'wp', originalWpDescriptor );
+ } else {
+ Reflect.deleteProperty( window, 'wp' );
+ }
+ };
+};
+
+/**
+ * Makes the core data store report the product category, tag and brand taxonomies.
+ */
+export const mockProductTaxonomies = () =>
+ jest
+ .spyOn(
+ select( coreStore ) as unknown as {
+ getTaxonomies: () => Taxonomy[];
+ },
+ 'getTaxonomies'
+ )
+ .mockReturnValue( [
+ {
+ name: 'product categories',
+ slug: 'product_cat',
+ visibility: { publicly_queryable: true },
+ },
+ {
+ name: 'product tags',
+ slug: 'product_tag',
+ visibility: { publicly_queryable: true },
+ },
+ {
+ name: 'product brands',
+ slug: 'product_brand',
+ visibility: { publicly_queryable: true },
+ },
+ ] );
diff --git a/plugins/woocommerce/client/blocks/changelog/testops-234-product-collection-editor b/plugins/woocommerce/client/blocks/changelog/testops-234-product-collection-editor
new file mode 100644
index 00000000000..74e721b468d
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/changelog/testops-234-product-collection-editor
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Reduce Product Collection editor E2E tests from 62 to 22; Jest owns the built-in collection definitions, the inspector controls, and the pickers, and PHPUnit owns the collection presets and the queries the inspector's attributes produce.
+
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/collection-pickers.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/collection-pickers.block_theme.spec.ts
index dec7e2c0127..69509cc1ccb 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/collection-pickers.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/collection-pickers.block_theme.spec.ts
@@ -6,10 +6,7 @@ import { test as base, expect } from '@woocommerce/e2e-utils';
/**
* Internal dependencies
*/
-import ProductCollectionPage, {
- Collections,
- SELECTORS,
-} from './product-collection.page';
+import ProductCollectionPage, { SELECTORS } from './product-collection.page';
const test = base.extend< { pageObject: ProductCollectionPage } >( {
pageObject: async ( { page, admin, editor }, use ) => {
@@ -22,45 +19,13 @@ const test = base.extend< { pageObject: ProductCollectionPage } >( {
},
} );
-/**
- * Taxonomy-based collections configuration for parameterized tests.
- */
-const taxonomyCollections: {
- slug: Collections;
- name: string;
- termName: string;
- termLabel: string;
- expectedProductCount: number;
-}[] = [
- {
- slug: 'productsByCategory',
- name: 'Products by Category',
- termName: 'categories',
- termLabel: 'Accessories',
- expectedProductCount: 5,
- },
- {
- slug: 'productsByTag',
- name: 'Products by Tag',
- termName: 'tags',
- termLabel: 'Recommended',
- expectedProductCount: 2,
- },
- {
- slug: 'productsByBrand',
- name: 'Products by Brand',
- termName: 'brands',
- termLabel: 'WooCommerce',
- expectedProductCount: 3,
- },
-];
-
test.describe( 'Product Collection: Collection Pickers', () => {
test.describe( 'Hand-Picked Products', () => {
- test( 'Can select multiple products and Done button becomes enabled', async ( {
+ test( 'Products are displayed on frontend', async ( {
pageObject,
admin,
editor,
+ page,
} ) => {
await admin.createNewPost();
await pageObject.insertProductCollection();
@@ -72,171 +37,119 @@ test.describe( 'Product Collection: Collection Pickers', () => {
const doneButton = productPicker.locator(
SELECTORS.pickerDoneButton
);
-
- // Initially disabled
await expect( doneButton ).toBeDisabled();
- // Select first product
await productPicker
.getByRole( 'checkbox', { name: 'Album (woo-album)' } )
.click();
-
- // Done button should now be enabled
await expect( doneButton ).toBeEnabled();
+ const beanieCheckbox = productPicker.getByRole( 'checkbox', {
+ name: 'Beanie (woo-beanie)',
+ } );
+ await beanieCheckbox.click();
+ await expect( beanieCheckbox ).toBeChecked();
+
+ const selectedQuery = await pageObject.getProductCollectionQuery();
+ const selectedIds = selectedQuery.woocommerceHandPickedProducts;
+ expect( selectedIds ).toHaveLength( 2 );
+ expect(
+ selectedIds.every( ( id: string | number ) =>
+ /^[1-9]\d*$/.test( String( id ) )
+ )
+ ).toBe( true );
- // Select second product
- await productPicker
- .getByRole( 'checkbox', { name: 'Beanie (woo-beanie)' } )
- .click();
-
- // Click Done
await doneButton.click();
-
- // Picker should be hidden and products should be displayed
await expect( productPicker ).toBeHidden();
await pageObject.refreshLocators( 'editor' );
- await expect( pageObject.products ).toHaveCount( 2 );
- } );
-
- test( 'Picker is not shown after save and refresh', async ( {
- pageObject,
- admin,
- editor,
- page,
- } ) => {
- await admin.createNewPost();
- await pageObject.insertProductCollection();
- await pageObject.chooseCollectionInPost( 'handPicked' );
-
- // Select a product and click Done
- const productPicker = editor.canvas.locator(
- SELECTORS.productPicker
- );
- await productPicker
- .getByRole( 'checkbox', { name: 'Album (woo-album)' } )
- .click();
- await productPicker.locator( SELECTORS.pickerDoneButton ).click();
+ await expect( pageObject.productTitles ).toHaveText( [
+ 'Album',
+ 'Beanie',
+ ] );
- // Save and refresh
await editor.saveDraft();
await page.reload();
await editor.canvas.locator( 'body' ).waitFor();
-
- // Click on the block to select it
await editor.canvas
.locator( '[data-type="woocommerce/product-collection"]' )
.first()
.click();
+ await expect(
+ editor.canvas.locator( SELECTORS.productPicker )
+ ).toBeHidden();
+ expect(
+ ( await pageObject.getProductCollectionQuery() )
+ .woocommerceHandPickedProducts
+ ).toEqual( selectedIds );
- // Picker should not be shown
- const pickerAfterRefresh = editor.canvas.locator(
- SELECTORS.productPicker
- );
- await expect( pickerAfterRefresh ).toBeHidden();
-
- // Products should be visible
await pageObject.refreshLocators( 'editor' );
- await expect( pageObject.products ).toHaveCount( 1 );
+ await expect( pageObject.productTitles ).toHaveText( [
+ 'Album',
+ 'Beanie',
+ ] );
+ await pageObject.publishAndGoToFrontend();
+ await expect( pageObject.productTitles ).toHaveText( [
+ 'Album',
+ 'Beanie',
+ ] );
} );
+ } );
- test( 'Products are displayed on frontend', async ( {
+ test.describe( 'Products by Category', () => {
+ test( 'Products from selected categories are displayed on frontend', async ( {
pageObject,
admin,
editor,
} ) => {
await admin.createNewPost();
await pageObject.insertProductCollection();
- await pageObject.chooseCollectionInPost( 'handPicked' );
+ await pageObject.chooseCollectionInPost( 'productsByCategory' );
- // Select products and click Done
- const productPicker = editor.canvas.locator(
- SELECTORS.productPicker
+ const taxonomyPicker = editor.canvas.locator(
+ SELECTORS.taxonomyPicker
);
- await productPicker
- .getByRole( 'checkbox', { name: 'Album (woo-album)' } )
- .click();
- await productPicker
- .getByRole( 'checkbox', { name: 'Beanie (woo-beanie)' } )
+ const doneButton = taxonomyPicker.locator(
+ SELECTORS.pickerDoneButton
+ );
+ await expect( doneButton ).toBeDisabled();
+ await taxonomyPicker
+ .getByRole( 'checkbox', { name: 'Accessories' } )
.click();
- await productPicker.locator( SELECTORS.pickerDoneButton ).click();
+ await expect( doneButton ).toBeEnabled();
+
+ const taxQuery = ( await pageObject.getProductCollectionQuery() )
+ .taxQuery;
+ expect( Object.keys( taxQuery ) ).toEqual( [ 'product_cat' ] );
+ expect( taxQuery.product_cat ).toHaveLength( 1 );
+ expect( String( taxQuery.product_cat[ 0 ] ) ).toMatch(
+ /^[1-9]\d*$/
+ );
+ await doneButton.click();
+ await expect( taxonomyPicker ).toBeHidden();
await pageObject.refreshLocators( 'editor' );
+ await expect( pageObject.products ).toHaveCount( 5 );
await pageObject.publishAndGoToFrontend();
- await expect( pageObject.products ).toHaveCount( 2 );
- } );
- } );
-
- for ( const collection of taxonomyCollections ) {
- test.describe( `${ collection.name }`, () => {
- test( `Can select ${ collection.termName } and Done button becomes enabled`, async ( {
- pageObject,
- admin,
- editor,
- } ) => {
- await admin.createNewPost();
- await pageObject.insertProductCollection();
- await pageObject.chooseCollectionInPost( collection.slug );
-
- const taxonomyPicker = editor.canvas.locator(
- SELECTORS.taxonomyPicker
- );
- const doneButton = taxonomyPicker.locator(
- SELECTORS.pickerDoneButton
- );
-
- // Initially disabled
- await expect( doneButton ).toBeDisabled();
-
- // Select a term
- await taxonomyPicker
- .getByRole( 'checkbox', { name: collection.termLabel } )
- .click();
-
- // Done button should now be enabled
- await expect( doneButton ).toBeEnabled();
-
- // Click Done
- await doneButton.click();
-
- // Picker should be hidden and products should be displayed
- await expect( taxonomyPicker ).toBeHidden();
- await pageObject.refreshLocators( 'editor' );
- await expect( pageObject.products ).toHaveCount(
- collection.expectedProductCount
+ await expect( pageObject.products ).toHaveCount( 5 );
+ await expect
+ .poll( async () =>
+ (
+ await pageObject.productTitles.allTextContents()
+ ).toSorted()
+ )
+ .toEqual(
+ [
+ 'Beanie',
+ 'Beanie with Logo',
+ 'Belt',
+ 'Cap',
+ 'Protected: Sunglasses',
+ ].toSorted()
);
- } );
-
- test( `Products from selected ${ collection.termName } are displayed on frontend`, async ( {
- pageObject,
- admin,
- editor,
- } ) => {
- await admin.createNewPost();
- await pageObject.insertProductCollection();
- await pageObject.chooseCollectionInPost( collection.slug );
-
- // Select term and click Done
- const taxonomyPicker = editor.canvas.locator(
- SELECTORS.taxonomyPicker
- );
- await taxonomyPicker
- .getByRole( 'checkbox', { name: collection.termLabel } )
- .click();
- await taxonomyPicker
- .locator( SELECTORS.pickerDoneButton )
- .click();
-
- await pageObject.refreshLocators( 'editor' );
- await pageObject.publishAndGoToFrontend();
- await expect( pageObject.products ).toHaveCount(
- collection.expectedProductCount
- );
- } );
} );
- }
+ } );
test.describe( 'Collection switching', () => {
- test( 'Switching from Hand-Picked to Products by Category shows taxonomy picker', async ( {
+ test( 'Switching to a non-picker collection displays products immediately', async ( {
pageObject,
admin,
editor,
@@ -245,7 +158,6 @@ test.describe( 'Product Collection: Collection Pickers', () => {
await pageObject.insertProductCollection();
await pageObject.chooseCollectionInPost( 'handPicked' );
- // Select a product and click Done
const productPicker = editor.canvas.locator(
SELECTORS.productPicker
);
@@ -253,50 +165,32 @@ test.describe( 'Product Collection: Collection Pickers', () => {
.getByRole( 'checkbox', { name: 'Album (woo-album)' } )
.click();
await productPicker.locator( SELECTORS.pickerDoneButton ).click();
+ await expect( productPicker ).toBeHidden();
- // Switch to Products by Category using toolbar
await pageObject.changeCollectionUsingToolbar(
'productsByCategory'
);
-
- // Taxonomy picker should now be shown
const taxonomyPicker = editor.canvas.locator(
SELECTORS.taxonomyPicker
);
+ await expect( productPicker ).toBeHidden();
await expect( taxonomyPicker ).toBeVisible();
- } );
-
- test( 'Switching to a non-picker collection displays products immediately', async ( {
- pageObject,
- admin,
- editor,
- } ) => {
- await admin.createNewPost();
- await pageObject.insertProductCollection();
- await pageObject.chooseCollectionInPost( 'handPicked' );
-
- // Select a product and click Done
- const productPicker = editor.canvas.locator(
- SELECTORS.productPicker
- );
- await productPicker
- .getByRole( 'checkbox', { name: 'Album (woo-album)' } )
+ await taxonomyPicker
+ .getByRole( 'checkbox', { name: 'Accessories' } )
.click();
- await productPicker.locator( SELECTORS.pickerDoneButton ).click();
+ await taxonomyPicker.locator( SELECTORS.pickerDoneButton ).click();
+ await expect( taxonomyPicker ).toBeHidden();
- // Switch to Featured Products (no picker needed)
await pageObject.changeCollectionUsingToolbar( 'featured' );
-
- // No picker should be shown
await expect( productPicker ).toBeHidden();
- const taxonomyPicker = editor.canvas.locator(
- SELECTORS.taxonomyPicker
- );
await expect( taxonomyPicker ).toBeHidden();
-
- // Products should be displayed
await pageObject.refreshLocators( 'editor' );
- await expect( pageObject.products ).toHaveCount( 4 );
+ await expect( pageObject.productTitles ).toHaveText( [
+ 'Cap',
+ 'Hoodie with Zipper',
+ 'Sunglasses',
+ 'V-Neck T-Shirt',
+ ] );
} );
} );
} );
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/collections.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/collections.block_theme.spec.ts
index d69486af313..3a6f5f9d935 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/collections.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/collections.block_theme.spec.ts
@@ -20,25 +20,9 @@ const test = base.extend< { pageObject: ProductCollectionPage } >( {
} );
test.describe( 'Product Collection: Collections', () => {
- test( 'New Arrivals collection can be added and displays proper products', async ( {
- pageObject,
- } ) => {
- await pageObject.createNewPostAndInsertBlock( 'newArrivals' );
-
- // New Arrivals are by default filtered to display products from last 7 days.
- // Products in our test env have creation date set to much older, hence
- // no products are expected to be displayed by default.
- await expect( pageObject.products ).toHaveCount( 0 );
-
- await pageObject.publishAndGoToFrontend();
-
- await expect( pageObject.products ).toHaveCount( 0 );
- } );
-
test( 'Top Rated Products collection can be added and displays proper products', async ( {
pageObject,
} ) => {
- await pageObject.createNewPostAndInsertBlock( 'topRated' );
const topRatedProducts = [
'V-Neck T-Shirt',
'Hoodie',
@@ -47,97 +31,13 @@ test.describe( 'Product Collection: Collections', () => {
'Beanie',
];
+ await pageObject.createNewPostAndInsertBlock( 'topRated' );
await expect( pageObject.productTitles ).toHaveText( topRatedProducts );
await pageObject.publishAndGoToFrontend();
-
await expect( pageObject.productTitles ).toHaveText( topRatedProducts );
} );
- test( 'Best Sellers collection can be added and displays proper products', async ( {
- pageObject,
- } ) => {
- await pageObject.createNewPostAndInsertBlock( 'bestSellers' );
- const bestSellersProducts = [
- 'Album',
- 'Hoodie',
- 'Single',
- 'Hoodie with Logo',
- 'T-Shirt with Logo',
- ];
-
- await expect( pageObject.productTitles ).toHaveText(
- bestSellersProducts
- );
-
- await pageObject.publishAndGoToFrontend();
-
- await expect( pageObject.productTitles ).toHaveText(
- bestSellersProducts
- );
- } );
-
- test( 'On Sale Products collection can be added and displays proper products', async ( {
- pageObject,
- } ) => {
- await pageObject.createNewPostAndInsertBlock( 'onSale' );
-
- const onSaleProducts = [
- 'Beanie',
- 'Beanie with Logo',
- 'Belt',
- 'Cap',
- 'Hoodie',
- ];
-
- await expect( pageObject.products ).toHaveCount( 5 );
- await expect( pageObject.productTitles ).toHaveText( onSaleProducts );
-
- await pageObject.publishAndGoToFrontend();
-
- await expect( pageObject.products ).toHaveCount( 5 );
- } );
-
- test( 'Featured Products collection can be added and displays proper products', async ( {
- pageObject,
- } ) => {
- await pageObject.createNewPostAndInsertBlock( 'featured' );
-
- const featuredProducts = [
- 'Cap',
- 'Hoodie with Zipper',
- 'Sunglasses',
- 'V-Neck T-Shirt',
- ];
-
- await expect( pageObject.products ).toHaveCount( 4 );
- await expect( pageObject.productTitles ).toHaveText( featuredProducts );
-
- await pageObject.publishAndGoToFrontend();
-
- await expect( pageObject.products ).toHaveCount( 4 );
- } );
-
- test( 'Product Catalog collection can be added in post and syncs query with template', async ( {
- pageObject,
- } ) => {
- await pageObject.createNewPostAndInsertBlock( 'productCatalog' );
-
- const queryTypeLocator = pageObject
- .locateSidebarSettings()
- .getByLabel( SELECTORS.usePageContextControl );
-
- await expect( queryTypeLocator.getByLabel( 'Default' ) ).toBeChecked();
- await expect(
- queryTypeLocator.getByLabel( 'Custom' )
- ).not.toBeChecked();
- await expect( pageObject.products ).toHaveCount( 9 );
-
- await pageObject.publishAndGoToFrontend();
-
- await expect( pageObject.products ).toHaveCount( 9 );
- } );
-
test( 'Product Catalog Collection can be added in product archive and syncs query with template', async ( {
pageObject,
editor,
@@ -148,64 +48,36 @@ test.describe( 'Product Collection: Collections', () => {
postType: 'wp_template',
canvas: 'edit',
} );
-
await editor.setContent( '' );
-
await pageObject.insertProductCollection();
await pageObject.chooseCollectionInTemplate();
await editor.openDocumentSettingsSidebar();
- const queryTypeLocator = pageObject
+ const queryType = pageObject
.locateSidebarSettings()
.getByLabel( SELECTORS.usePageContextControl );
+ await expect( queryType.getByLabel( 'Default' ) ).toBeChecked();
+ await expect( queryType.getByLabel( 'Custom' ) ).not.toBeChecked();
- await expect( queryTypeLocator.getByLabel( 'Default' ) ).toBeChecked();
+ await pageObject.refreshLocators( 'editor' );
+ await expect( pageObject.products.first() ).toBeVisible();
await expect(
- queryTypeLocator.getByLabel( 'Custom' )
- ).not.toBeChecked();
- } );
-
- test.describe( 'Have hidden implementation in UI', () => {
- test( 'New Arrivals', async ( { pageObject } ) => {
- await pageObject.createNewPostAndInsertBlock( 'newArrivals' );
- const input = await pageObject.getOrderByElement();
+ pageObject.productTitles.getByRole( 'link' ).first()
+ ).toBeVisible();
+ const editorProductPaths = await pageObject.getProductPermalinkPaths();
+ expect( editorProductPaths ).not.toHaveLength( 0 );
- await expect( input ).toBeHidden();
- } );
-
- test( 'Top Rated Products', async ( { pageObject } ) => {
- await pageObject.createNewPostAndInsertBlock( 'topRated' );
- const input = await pageObject.getOrderByElement();
-
- await expect( input ).toBeHidden();
- } );
-
- test( 'Best Sellers', async ( { pageObject } ) => {
- await pageObject.createNewPostAndInsertBlock( 'bestSellers' );
- const input = await pageObject.getOrderByElement();
-
- await expect( input ).toBeHidden();
- } );
-
- test( 'On Sale Products', async ( { pageObject } ) => {
- await pageObject.createNewPostAndInsertBlock( 'onSale' );
- const sidebarSettings = pageObject.locateSidebarSettings();
- const input = sidebarSettings.getByLabel(
- SELECTORS.onSaleControlLabel
- );
-
- await expect( input ).toBeHidden();
- } );
-
- test( 'Featured Products', async ( { pageObject } ) => {
- await pageObject.createNewPostAndInsertBlock( 'featured' );
- const sidebarSettings = pageObject.locateSidebarSettings();
- const input = sidebarSettings.getByLabel(
- SELECTORS.featuredControlLabel
- );
-
- await expect( input ).toBeHidden();
+ await editor.saveSiteEditorEntities( {
+ isOnlyCurrentEntityDirty: true,
} );
+ await pageObject.goToProductCatalogFrontend();
+ await expect( pageObject.products.first() ).toBeVisible();
+ await expect(
+ pageObject.productTitles.getByRole( 'link' ).first()
+ ).toBeVisible();
+ await expect
+ .poll( async () => pageObject.getProductPermalinkPaths() )
+ .toEqual( editorProductPaths );
} );
test.describe( 'Related Products collection', () => {
@@ -213,24 +85,19 @@ test.describe( 'Product Collection: Collections', () => {
pageObject,
editor,
} ) => {
- // "Related by" control shouldn't be visible for other collections
await pageObject.createNewPostAndInsertBlock( 'bestSellers' );
const sidebarSettings = pageObject.locateSidebarSettings();
-
const relatedByControl = sidebarSettings.locator(
'.wc-block-editor-product-collection-inspector-controls__relate-by'
);
await expect( relatedByControl ).toBeHidden();
- // Change collection type to "Related Products"
- // And verify that "Related by" control is visible
await pageObject.changeCollectionUsingToolbar( 'relatedProducts' );
await pageObject.chooseProductInEditorProductPickerIfAvailable(
editor.canvas
);
await expect( relatedByControl ).toBeVisible();
- // Verify that both checkboxes (Categories and Tags) are checked by default
const categoriesCheckbox =
sidebarSettings.getByLabel( 'Categories' );
const tagsCheckbox = sidebarSettings.getByLabel( 'Tags' );
@@ -238,7 +105,6 @@ test.describe( 'Product Collection: Collections', () => {
await expect( tagsCheckbox ).toBeChecked();
await expect( pageObject.productTitles ).toHaveText( [ 'Single' ] );
- // Uncheck "Categories" checkbox
await categoriesCheckbox.uncheck();
await expect(
editor.canvas.getByText(
@@ -246,8 +112,8 @@ test.describe( 'Product Collection: Collections', () => {
)
).toBeVisible();
- // Verify on frontend
await categoriesCheckbox.check();
+ await expect( pageObject.productTitles ).toHaveText( [ 'Single' ] );
await pageObject.publishAndGoToFrontend();
await expect( pageObject.productTitles ).toHaveText( [ 'Single' ] );
} );
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/inspector-controls.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/inspector-controls.block_theme.spec.ts
index a192d7e60f9..e17194b2c35 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/inspector-controls.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/inspector-controls.block_theme.spec.ts
@@ -20,395 +20,240 @@ const test = base.extend< { pageObject: ProductCollectionPage } >( {
} );
test.describe( 'Product Collection: Inspector Controls', () => {
- test( 'Reflects the correct number of columns according to sidebar settings', async ( {
+ test( 'persists display controls and renders their frontend output', async ( {
pageObject,
+ page,
+ editor,
} ) => {
await pageObject.createNewPostAndInsertBlock();
-
- await pageObject.setNumberOfColumns( 2 );
- await expect( pageObject.productTemplate ).toHaveClass( /columns-2/ );
-
await pageObject.setNumberOfColumns( 4 );
- await expect( pageObject.productTemplate ).toHaveClass( /columns-4/ );
+ await expect( pageObject.productTemplate ).toContainClass(
+ 'columns-4'
+ );
- await pageObject.publishAndGoToFrontend();
+ await page.getByRole( 'button', { name: 'Settings options' } ).click();
+ await page.getByRole( 'menuitemcheckbox', { name: 'Offset' } ).click();
+ await page
+ .getByRole( 'menuitemcheckbox', { name: 'Max pages to show' } )
+ .click();
+ await page.getByRole( 'button', { name: 'Settings options' } ).click();
- await expect( pageObject.productTemplate ).toHaveClass( /columns-4/ );
- } );
+ const settingsPanel = page.locator(
+ '.wc-block-editor-product-collection__settings_panel'
+ );
+ await settingsPanel
+ .getByRole( 'spinbutton', { name: 'Products per page' } )
+ .fill( '3' );
+ await pageObject.refreshLocators( 'editor' );
+ await expect( pageObject.products ).toHaveCount( 3 );
- test( 'Order By - sort products by title in descending order correctly', async ( {
- pageObject,
- } ) => {
- await pageObject.createNewPostAndInsertBlock();
+ await settingsPanel
+ .getByRole( 'spinbutton', { name: 'Offset' } )
+ .fill( '1' );
+ await expect(
+ editor.canvas.locator( '.wc-block-product-template__spinner' )
+ ).toBeHidden();
+ await pageObject.refreshLocators( 'editor' );
+ await expect( pageObject.productTitles.first() ).toHaveText( 'Beanie' );
- const sortedTitles = [
- 'WordPress Pennant',
- 'V-Neck T-Shirt',
- 'T-Shirt with Logo',
- 'T-Shirt',
- /Sunglasses/, // In the frontend it's "Protected: Sunglasses"
- 'Single',
- 'Polo',
- 'Long Sleeve Tee',
- 'Logo Collection',
- ];
-
- await pageObject.setOrderBy( 'title/desc' );
- await expect( pageObject.productTitles ).toHaveText( sortedTitles );
+ await settingsPanel
+ .getByRole( 'spinbutton', { name: 'Max pages to show' } )
+ .fill( '2' );
await pageObject.publishAndGoToFrontend();
- await expect( pageObject.productTitles ).toHaveText( sortedTitles );
+ await expect( pageObject.productTemplate ).toContainClass(
+ 'columns-4'
+ );
+ await expect( pageObject.products ).toHaveCount( 3 );
+ await expect( pageObject.productTitles.first() ).toHaveText( 'Beanie' );
+ await expect(
+ page
+ .locator( SELECTORS.pagination.onFrontend )
+ .locator( '.page-numbers' )
+ ).toHaveCount( 2 );
} );
- test( 'Products can be filtered based on "on sale" status', async ( {
+ test( 'inherits the Product Catalog query and preserves custom criteria', async ( {
pageObject,
+ editor,
} ) => {
- await pageObject.createNewPostAndInsertBlock();
-
- let allProducts = pageObject.products;
- let saleProducts = pageObject.products.filter( {
- hasText: 'Product on sale',
- } );
+ await pageObject.goToEditorTemplate();
+ await pageObject.focusProductCollection();
+ await editor.openDocumentSettingsSidebar();
- await expect( allProducts ).toHaveCount( 9 );
- await expect( saleProducts ).toHaveCount( 6 );
+ const sidebarSettings = pageObject.locateSidebarSettings();
+ const queryType = sidebarSettings.getByLabel(
+ SELECTORS.usePageContextControl
+ );
+ const defaultQueryType = queryType.getByLabel( 'Default' );
+ const customQueryType = queryType.getByLabel( 'Custom' );
+ const onSaleControl = sidebarSettings.getByLabel(
+ SELECTORS.onSaleControlLabel
+ );
+ await expect( defaultQueryType ).toBeChecked();
+ await expect(
+ pageObject.productTitles.getByRole( 'link' ).first()
+ ).toBeVisible();
+ const inheritedProductPaths =
+ await pageObject.getProductPermalinkPaths();
+ expect( inheritedProductPaths ).not.toHaveLength( 0 );
+ await expect( onSaleControl ).toBeHidden();
+
+ await customQueryType.click();
+ await expect( onSaleControl ).toBeVisible();
await pageObject.setShowOnlyProductsOnSale( {
onSale: true,
+ isLocatorsRefreshNeeded: false,
} );
-
- await expect( allProducts ).toHaveCount( 6 );
- await expect( saleProducts ).toHaveCount( 6 );
-
- await pageObject.publishAndGoToFrontend();
- await pageObject.refreshLocators( 'frontend' );
- allProducts = pageObject.products;
- saleProducts = pageObject.products.filter( {
- hasText: 'Product on sale',
+ await expect
+ .poll( async () => {
+ const productPaths =
+ await pageObject.getProductPermalinkPaths();
+ return (
+ productPaths.length > 0 &&
+ JSON.stringify( productPaths ) !==
+ JSON.stringify( inheritedProductPaths )
+ );
+ } )
+ .toBeTruthy();
+ const customProductPaths = await pageObject.getProductPermalinkPaths();
+ expect( customProductPaths ).not.toHaveLength( 0 );
+ expect( customProductPaths ).not.toEqual( inheritedProductPaths );
+
+ await defaultQueryType.click();
+ await expect( onSaleControl ).toBeHidden();
+ await expect
+ .poll( async () => pageObject.getProductPermalinkPaths() )
+ .toEqual( inheritedProductPaths );
+ await customQueryType.click();
+ await expect( onSaleControl ).toBeChecked();
+ await expect
+ .poll( async () => pageObject.getProductPermalinkPaths() )
+ .toEqual( customProductPaths );
+
+ await editor.saveSiteEditorEntities( {
+ isOnlyCurrentEntityDirty: true,
} );
-
- await expect( allProducts ).toHaveCount( 6 );
- await expect( saleProducts ).toHaveCount( 6 );
- } );
-
- test( 'Products can be filtered based on selection in handpicked products option', async ( {
- pageObject,
- } ) => {
- await pageObject.createNewPostAndInsertBlock();
-
- await pageObject.addFilter( 'Show Hand-picked' );
-
- const filterName = 'Hand-picked';
- await pageObject.setFilterComboboxValue( filterName, [ 'Album' ] );
- await expect( pageObject.products ).toHaveCount( 1 );
-
- const productNames = [ 'Album', 'Cap' ];
- await pageObject.setFilterComboboxValue( filterName, productNames );
- await expect( pageObject.products ).toHaveCount( 2 );
- await expect( pageObject.productTitles ).toHaveText( productNames );
-
- await pageObject.publishAndGoToFrontend();
- await expect( pageObject.products ).toHaveCount( 2 );
- await expect( pageObject.productTitles ).toHaveText( productNames );
- } );
-
- test( 'Products can be filtered based on keyword.', async ( {
- pageObject,
- } ) => {
- await pageObject.createNewPostAndInsertBlock();
-
- await pageObject.addFilter( 'Keyword' );
-
- await pageObject.setKeyword( 'Album' );
- await expect( pageObject.productTitles ).toHaveText( [ 'Album' ] );
-
- await pageObject.setKeyword( 'Cap' );
- await expect( pageObject.productTitles ).toHaveText( [ 'Cap' ] );
-
- await pageObject.publishAndGoToFrontend();
- await expect( pageObject.productTitles ).toHaveText( [ 'Cap' ] );
+ await pageObject.goToProductCatalogFrontend();
+ await expect(
+ pageObject.productTitles.getByRole( 'link' ).first()
+ ).toBeVisible();
+ await expect
+ .poll( async () => pageObject.getProductPermalinkPaths() )
+ .toEqual( customProductPaths );
} );
- test( 'Products can be filtered based on category.', async ( {
+ test( 'correctly combines editor and front-end filters', async ( {
pageObject,
+ editor,
+ page,
+ requestUtils,
} ) => {
+ await requestUtils.setFeatureFlag( 'experimental-blocks', true );
await pageObject.createNewPostAndInsertBlock();
await pageObject.addFilter( 'Show product categories' );
- await pageObject.checkTaxonomyTerm( 'categories', 'Clothing' );
- await expect( pageObject.products ).toHaveCount( 9 );
-
- // Switch to Accessories
- await pageObject.uncheckTaxonomyTerm( 'categories', 'Clothing' );
- await pageObject.checkTaxonomyTerm( 'categories', 'Accessories' );
- const accessoriesProductNames = [
- 'Beanie',
- 'Beanie with Logo',
- 'Belt',
- 'Cap',
- 'Sunglasses',
- ];
- await expect( pageObject.productTitles ).toHaveText(
- accessoriesProductNames
- );
+ await pageObject.checkTaxonomyTerm( 'categories', 'Music' );
- await pageObject.publishAndGoToFrontend();
-
- const frontendAccessoriesProductNames = [
- 'Beanie',
- 'Beanie with Logo',
- 'Belt',
- 'Cap',
- 'Protected: Sunglasses',
- ];
- await expect( pageObject.productTitles ).toHaveText(
- frontendAccessoriesProductNames
+ const productCollectionBlock = await editor.getBlockByName(
+ 'woocommerce/product-collection'
+ );
+ const productCollectionClientId =
+ ( await productCollectionBlock
+ .last()
+ .getAttribute( 'data-block' ) ) ?? '';
+ await editor.insertBlock(
+ { name: 'woocommerce/product-filters' },
+ { clientId: productCollectionClientId }
);
- } );
-
- test( 'Products can be filtered based on tags.', async ( {
- pageObject,
- } ) => {
- await pageObject.createNewPostAndInsertBlock();
-
- await pageObject.addFilter( 'Show product tags' );
- await pageObject.checkTaxonomyTerm( 'tags', 'Recommended' );
- await expect( pageObject.productTitles ).toHaveText( [
- 'Beanie',
- 'Hoodie',
- ] );
-
- await pageObject.publishAndGoToFrontend();
- await expect( pageObject.productTitles ).toHaveText( [
- 'Beanie',
- 'Hoodie',
- ] );
- } );
-
- test( 'Products can be filtered based on brands.', async ( {
- pageObject,
- } ) => {
- await pageObject.createNewPostAndInsertBlock();
-
- await pageObject.addFilter( 'Show Product Brands' );
- await pageObject.checkTaxonomyTerm( 'brands', 'WooCommerce' );
- await expect( pageObject.productTitles ).toHaveText( [
- 'Album',
- 'Beanie',
- 'Hoodie',
- ] );
-
- await pageObject.publishAndGoToFrontend();
- await expect( pageObject.productTitles ).toHaveText( [
- 'Album',
- 'Beanie',
- 'Hoodie',
- ] );
- } );
-
- test( 'Products can be filtered based on product attributes like color, size etc.', async ( {
- pageObject,
- } ) => {
- await pageObject.createNewPostAndInsertBlock();
-
- await pageObject.addFilter( 'Show Product Attributes' );
- await pageObject.setProductAttribute( 'Color', 'Green' );
-
- await expect( pageObject.products ).toHaveCount( 3 );
-
- await pageObject.setProductAttribute( 'Size', 'Large' );
-
- await expect( pageObject.products ).toHaveCount( 1 );
-
- await pageObject.publishAndGoToFrontend();
-
- await expect( pageObject.products ).toHaveCount( 1 );
- } );
-
- test( 'Products can be filtered based on stock status (in stock, out of stock, or backorder).', async ( {
- pageObject,
- } ) => {
- await pageObject.createNewPostAndInsertBlock();
-
- await pageObject.setFilterComboboxValue( 'Stock status', [
- 'Out of stock',
- ] );
-
- await expect( pageObject.productTitles ).toHaveText( [
- 'T-Shirt with Logo',
- ] );
-
- await pageObject.publishAndGoToFrontend();
-
- await expect( pageObject.productTitles ).toHaveText( [
- 'T-Shirt with Logo',
- ] );
- } );
-
- test( 'Products can be filtered based on featured status.', async ( {
- pageObject,
- } ) => {
- await pageObject.createNewPostAndInsertBlock();
-
- await expect( pageObject.products ).toHaveCount( 9 );
-
- await pageObject.addFilter( 'Featured' );
- await pageObject.setShowOnlyFeaturedProducts( {
- featured: true,
- } );
-
- // In test data we have only 4 featured products.
- await expect( pageObject.products ).toHaveCount( 4 );
-
- await pageObject.publishAndGoToFrontend();
-
- await expect( pageObject.products ).toHaveCount( 4 );
- } );
-
- test( 'Products can be filtered based on created date.', async ( {
- pageObject,
- } ) => {
- await pageObject.createNewPostAndInsertBlock();
-
- await expect( pageObject.products ).toHaveCount( 9 );
-
- await pageObject.addFilter( 'Created' );
- await pageObject.setCreatedFilter( {
- operator: 'within',
- range: 'last3months',
- } );
-
- // Products are created with the fixed publish date back in 2019
- // so there's no products published in last 3 months.
- await expect( pageObject.products ).toHaveCount( 0 );
-
- await pageObject.setCreatedFilter( {
- operator: 'before',
- range: 'last3months',
- } );
-
- await expect( pageObject.products ).toHaveCount( 9 );
-
- await pageObject.publishAndGoToFrontend();
-
- await expect( pageObject.products ).toHaveCount( 9 );
- } );
-
- test( 'Products can be filtered based on price range.', async ( {
- pageObject,
- } ) => {
- await pageObject.createNewPostAndInsertBlock();
-
- await expect( pageObject.products ).toHaveCount( 9 );
-
- await pageObject.addFilter( 'Price Range' );
- await pageObject.setPriceRange( {
- min: '25',
- } );
-
- await expect( pageObject.products ).toHaveCount( 7 );
-
- await pageObject.setPriceRange( {
- min: '15.28',
- max: '17.21',
- } );
+ await expect( pageObject.products ).toHaveCount( 2 );
+ const postId = await editor.publishPost();
+ await page.goto( `/?p=${ postId }` );
+ await pageObject.refreshLocators( 'frontend' );
await expect( pageObject.products ).toHaveCount( 2 );
- await pageObject.setPriceRange( {
- max: '17.29',
- } );
-
- await expect( pageObject.products ).toHaveCount( 5 );
-
- await pageObject.publishAndGoToFrontend();
-
- await expect( pageObject.products ).toHaveCount( 5 );
+ await page
+ .getByRole( 'textbox', {
+ name: 'Filter products by maximum price',
+ } )
+ .dblclick();
+ await page.keyboard.type( '5' );
+ await page.keyboard.press( 'Tab' );
+ await expect( pageObject.products ).toHaveCount( 1 );
} );
- // See https://github.com/woocommerce/woocommerce/pull/49917
- test( 'Price range is inclusive in both editor and frontend.', async ( {
- page,
+ test( 'scopes Query Type and Product Filters to the first eligible collection', async ( {
pageObject,
editor,
+ page,
+ requestUtils,
} ) => {
+ await requestUtils.setFeatureFlag( 'experimental-blocks', true );
await pageObject.createNewPostAndInsertBlock();
- await expect( pageObject.products ).toHaveCount( 9 );
-
- await pageObject.addFilter( 'Price Range' );
- await pageObject.setPriceRange( {
- min: '45',
- max: '55',
- } );
-
- // Wait for the products to be filtered.
- await expect( pageObject.products ).not.toHaveCount( 9 );
+ const productCollection = editor.canvas.getByLabel(
+ 'Block: Product Collection',
+ { exact: true }
+ );
+ const queryType = pageObject
+ .locateSidebarSettings()
+ .getByLabel( SELECTORS.usePageContextControl );
+ const defaultQueryType = queryType.getByLabel( 'Default' );
+ const customQueryType = queryType.getByLabel( 'Custom' );
+
+ await editor.selectBlocks( productCollection.first() );
+ await expect( defaultQueryType ).toBeChecked();
+
+ await pageObject.insertProductCollection();
+ await pageObject.chooseCollectionInPost( 'productCatalog' );
+ await editor.selectBlocks( productCollection.last() );
+ await expect( customQueryType ).toBeChecked();
+ await expect( pageObject.products ).toHaveCount( 18 );
+
+ const productCollectionBlock = await editor.getBlockByName(
+ 'woocommerce/product-collection'
+ );
+ const secondCollectionClientId =
+ ( await productCollectionBlock
+ .last()
+ .getAttribute( 'data-block' ) ) ?? '';
+ await editor.insertBlock(
+ { name: 'woocommerce/product-filters' },
+ { clientId: secondCollectionClientId }
+ );
+ const postId = await editor.publishPost();
+ await page.goto( `/?p=${ postId }` );
+ const collections = page.locator(
+ '.wp-block-woocommerce-product-collection'
+ );
await expect(
- pageObject.products.filter( { hasText: '$45.00' } )
- ).not.toHaveCount( 0 );
+ collections.first().locator( SELECTORS.product )
+ ).toHaveCount( 9 );
await expect(
- pageObject.products.filter( { hasText: '$55.00' } )
- ).not.toHaveCount( 0 );
-
- // Reset the price range.
- await pageObject.setPriceRange( {
- min: '0',
- max: '0',
- } );
-
- await expect( pageObject.products ).toHaveCount( 9 );
-
- await editor.insertBlock( {
- name: 'woocommerce/filter-wrapper',
- attributes: { filterType: 'price-filter' },
- } );
-
- await pageObject.publishAndGoToFrontend();
-
- await expect( pageObject.products ).toHaveCount( 9 );
+ collections.last().locator( SELECTORS.product )
+ ).toHaveCount( 9 );
await page
.getByRole( 'textbox', {
- name: 'Filter products by minimum',
+ name: 'Filter products by maximum price',
} )
.dblclick();
- await page.keyboard.type( '45' );
-
- await page
- .getByRole( 'textbox', {
- name: 'Filter products by maximum',
- } )
- .dblclick();
- await page.keyboard.type( '55' );
-
+ await page.keyboard.type( '10' );
await page.keyboard.press( 'Tab' );
- // Wait for the products to be filtered.
- await expect( pageObject.products ).not.toHaveCount( 9 );
-
await expect(
- pageObject.products.filter( { hasText: '$45.00' } )
- ).not.toHaveCount( 0 );
+ collections.first().locator( SELECTORS.product )
+ ).toHaveCount( 1 );
await expect(
- pageObject.products.filter( { hasText: '$55.00' } )
- ).not.toHaveCount( 0 );
+ collections.last().locator( SELECTORS.product )
+ ).toHaveCount( 9 );
} );
- test.describe( '"Query Type" control', () => {
- test( 'should be visible on posts', async ( { pageObject } ) => {
- await pageObject.createNewPostAndInsertBlock();
-
- await expect(
- pageObject
- .locateSidebarSettings()
- .getByLabel( SELECTORS.usePageContextControl )
- ).toBeVisible();
- } );
-
+ test.describe( '"Query Type" control fallback', () => {
[
- `${ BLOCK_THEME_SLUG }//archive-product`,
`${ BLOCK_THEME_SLUG }//taxonomy-product_attribute`,
`${ BLOCK_THEME_SLUG }//product-search-results`,
].forEach( ( slug ) => {
@@ -491,57 +336,6 @@ test.describe( 'Product Collection: Inspector Controls', () => {
} );
} );
- test( 'should work as expected in Product Catalog template', async ( {
- pageObject,
- editor,
- } ) => {
- await pageObject.goToEditorTemplate();
- await pageObject.focusProductCollection();
- await editor.openDocumentSettingsSidebar();
-
- const sidebarSettings = pageObject.locateSidebarSettings();
- const queryTypeLocator = sidebarSettings.getByLabel(
- SELECTORS.usePageContextControl
- );
-
- const defaultQueryType = queryTypeLocator.getByLabel( 'Default' );
- const customQueryType = queryTypeLocator.getByLabel( 'Custom' );
-
- // Inherit query from template should be visible & enabled by default
- await expect( defaultQueryType ).toBeChecked();
-
- // "On sale control" should be hidden when inherit query from template is enabled
- await expect(
- sidebarSettings.getByLabel( SELECTORS.onSaleControlLabel )
- ).toBeHidden();
-
- // "On sale control" should be visible when inherit query from template is disabled
- await customQueryType.click();
- await expect(
- sidebarSettings.getByLabel( SELECTORS.onSaleControlLabel )
- ).toBeVisible();
-
- // "On sale control" should retain its state when inherit query from template is enabled again
- await pageObject.setShowOnlyProductsOnSale( {
- onSale: true,
- isLocatorsRefreshNeeded: false,
- } );
- await expect(
- sidebarSettings.getByLabel( SELECTORS.onSaleControlLabel )
- ).toBeChecked();
- await defaultQueryType.click();
- await expect(
- sidebarSettings.getByLabel( SELECTORS.onSaleControlLabel )
- ).toBeHidden();
- await customQueryType.click();
- await expect(
- sidebarSettings.getByLabel( SELECTORS.onSaleControlLabel )
- ).toBeVisible();
- await expect(
- sidebarSettings.getByLabel( SELECTORS.onSaleControlLabel )
- ).toBeChecked();
- } );
-
test( 'is enabled by default unless already enabled elsewhere', async ( {
pageObject,
editor,
@@ -590,175 +384,5 @@ test.describe( 'Product Collection: Inspector Controls', () => {
await expect( defaultQueryType ).toBeChecked();
await expect( customQueryType ).not.toBeChecked();
} );
-
- test( 'allows filtering in non-archive context', async ( {
- pageObject,
- editor,
- page,
- requestUtils,
- } ) => {
- await requestUtils.setFeatureFlag( 'experimental-blocks', true );
- await pageObject.createNewPostAndInsertBlock();
-
- await expect( pageObject.products ).toHaveCount( 9 );
-
- await pageObject.insertProductCollection();
- await pageObject.chooseCollectionInPost( 'productCatalog' );
-
- await expect( pageObject.products ).toHaveCount( 18 );
-
- const productCollectionBlock = await editor.getBlockByName(
- 'woocommerce/product-collection'
- );
- const productCollectionClientId =
- ( await productCollectionBlock
- .last()
- .getAttribute( 'data-block' ) ) ?? '';
- await editor.insertBlock(
- { name: 'woocommerce/product-filters' },
- { clientId: productCollectionClientId }
- );
-
- const postId = await editor.publishPost();
- await page.goto( `/?p=${ postId }` );
-
- const productCollection = page.locator(
- '.wp-block-woocommerce-product-collection'
- );
-
- await expect(
- productCollection.first().locator( SELECTORS.product )
- ).toHaveCount( 9 );
- await expect(
- productCollection.last().locator( SELECTORS.product )
- ).toHaveCount( 9 );
-
- await page
- .getByRole( 'textbox', {
- name: 'Filter products by maximum price',
- } )
- .dblclick();
- await page.keyboard.type( '10' );
- await page.keyboard.press( 'Tab' );
-
- await expect(
- productCollection.first().locator( SELECTORS.product )
- ).toHaveCount( 1 );
- await expect(
- productCollection.last().locator( SELECTORS.product )
- ).toHaveCount( 9 );
- } );
-
- test( 'correctly combines editor and front-end filters', async ( {
- pageObject,
- editor,
- page,
- requestUtils,
- } ) => {
- await requestUtils.setFeatureFlag( 'experimental-blocks', true );
-
- await pageObject.createNewPostAndInsertBlock();
-
- await expect( pageObject.products ).toHaveCount( 9 );
-
- await pageObject.addFilter( 'Show product categories' );
- await pageObject.checkTaxonomyTerm( 'categories', 'Music' );
-
- const productCollectionBlock = await editor.getBlockByName(
- 'woocommerce/product-collection'
- );
- const productCollectionClientId =
- ( await productCollectionBlock
- .last()
- .getAttribute( 'data-block' ) ) ?? '';
- await editor.insertBlock(
- { name: 'woocommerce/product-filters' },
- { clientId: productCollectionClientId }
- );
-
- await expect( pageObject.products ).toHaveCount( 2 );
-
- const postId = await editor.publishPost();
- await page.goto( `/?p=${ postId }` );
- await pageObject.refreshLocators( 'frontend' );
-
- await expect( pageObject.products ).toHaveCount( 2 );
-
- await page
- .getByRole( 'textbox', {
- name: 'Filter products by maximum price',
- } )
- .dblclick();
- await page.keyboard.type( '5' );
- await page.keyboard.press( 'Tab' );
-
- await expect( pageObject.products ).toHaveCount( 1 );
- } );
- } );
-
- test( 'Display -> Items per page, offset & max page to show', async ( {
- pageObject,
- page,
- editor,
- } ) => {
- await pageObject.createNewPostAndInsertBlock();
-
- // Add all display controls
- await page.getByRole( 'button', { name: 'Settings options' } ).click();
- await page.getByRole( 'menuitemcheckbox', { name: 'Offset' } ).click();
- await page
- .getByRole( 'menuitemcheckbox', { name: 'Max pages to show' } )
- .click();
- await page.getByRole( 'button', { name: 'Settings options' } ).click();
-
- // Get the products per page input
- const settingsPanel = page.locator(
- '.wc-block-editor-product-collection__settings_panel'
- );
- const productsPerPageInput = settingsPanel.getByRole( 'spinbutton', {
- name: 'Products per page',
- } );
-
- // Test setting products per page to 2
- await productsPerPageInput.fill( '2' );
- await pageObject.refreshLocators( 'editor' );
- await expect( pageObject.products ).toHaveCount( 2 );
-
- // Test setting products per page to 3
- await productsPerPageInput.fill( '3' );
- await pageObject.refreshLocators( 'editor' );
- await expect( pageObject.products ).toHaveCount( 3 );
-
- // Set offset to 1 and verify it skips the first product
- const offsetInput = settingsPanel.getByRole( 'spinbutton', {
- name: 'Offset',
- } );
- await offsetInput.fill( '1' );
- const loadingSpinner = editor.canvas.locator(
- '.wc-block-product-template__spinner'
- );
- await expect( loadingSpinner ).toBeHidden();
- await pageObject.refreshLocators( 'editor' );
- await expect( pageObject.productTitles.first() ).toHaveText( 'Beanie' );
-
- // Set max pages to show
- const maxPagesInput = settingsPanel.getByRole( 'spinbutton', {
- name: 'Max pages to show',
- } );
- await maxPagesInput.fill( '2' );
-
- await pageObject.publishAndGoToFrontend();
-
- // Frontend: Verify products are displayed correctly
- await expect( pageObject.products ).toHaveCount( 3 );
- await expect( pageObject.productTitles.first() ).toHaveText( 'Beanie' );
-
- // Frontend: Verify pagination is limited to 2 pages
- const paginationContainer = page.locator(
- SELECTORS.pagination.onFrontend
- );
- const paginationNumbers =
- paginationContainer.locator( '.page-numbers' );
- await expect( paginationNumbers ).toHaveCount( 2 );
} );
} );
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/product-collection.page.ts b/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/product-collection.page.ts
index b0e68158d70..759cd0eaaa0 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/product-collection.page.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/product-collection.page.ts
@@ -832,6 +832,31 @@ class ProductCollectionPage {
}
}
+ async getProductPermalinkPaths(): Promise< string[] > {
+ return this.productTitles
+ .getByRole( 'link' )
+ .evaluateAll( ( links ) =>
+ links.map(
+ ( link ) =>
+ new URL( ( link as HTMLAnchorElement ).href ).pathname
+ )
+ );
+ }
+
+ async getProductCollectionQuery() {
+ return this.page.evaluate( () => {
+ const block = window.wp.data
+ .select( 'core/block-editor' )
+ .getBlocks()
+ .find(
+ ( candidate: { name: string } ) =>
+ candidate.name === 'woocommerce/product-collection'
+ );
+
+ return block?.attributes.query ?? {};
+ } );
+ }
+
private async initializeLocatorsForEditor() {
this.productTemplate = this.editor.canvas.locator(
SELECTORS.productTemplate
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/product-picker.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/product-picker.block_theme.spec.ts
index 9b2bf5d9f59..4b087aad7db 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/product-picker.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/product-picker.block_theme.spec.ts
@@ -6,10 +6,7 @@ import { test as base, expect, BLOCK_THEME_SLUG } from '@woocommerce/e2e-utils';
/**
* Internal dependencies
*/
-import ProductCollectionPage, {
- Collections,
- SELECTORS,
-} from './product-collection.page';
+import ProductCollectionPage, { SELECTORS } from './product-collection.page';
const test = base.extend< { pageObject: ProductCollectionPage } >( {
pageObject: async ( { page, admin, editor }, use ) => {
@@ -23,168 +20,62 @@ const test = base.extend< { pageObject: ProductCollectionPage } >( {
} );
test.describe( 'Product Collection: Product Picker', () => {
- const CUSTOM_COLLECTIONS = [
- {
- id: 'myCustomCollectionWithProductContext',
- name: 'My Custom Collection - Product Context',
- label: 'Block: My Custom Collection - Product Context',
- collection:
- 'woocommerce/product-collection/my-custom-collection-product-context',
- },
- {
- id: 'myCustomCollectionMultipleContexts',
- name: 'My Custom Collection - Multiple Contexts',
- label: 'Block: My Custom Collection - Multiple Contexts',
- collection:
- 'woocommerce/product-collection/my-custom-collection-multiple-contexts',
- },
- ];
-
- // Activate plugin which registers custom product collections
test.beforeEach( async ( { requestUtils } ) => {
await requestUtils.activatePlugin(
'woocommerce-blocks-test-register-product-collection'
);
} );
- CUSTOM_COLLECTIONS.forEach( ( collection ) => {
- test( `For collection "${ collection.name }" - manually selected product reference should be available on Frontend in a post`, async ( {
- pageObject,
- admin,
- page,
- editor,
- } ) => {
- await admin.createNewPost();
- await pageObject.insertProductCollection();
- await pageObject.chooseCollectionInPost(
- collection.id as Collections
- );
-
- // Verify that product picker is shown in Editor
- const editorProductPicker = editor.canvas.locator(
- SELECTORS.productPicker
- );
- await expect( editorProductPicker ).toBeVisible();
-
- // Once a product is selected, the product picker should be hidden
- await pageObject.chooseProductInEditorProductPickerIfAvailable(
- editor.canvas
- );
- await expect( editorProductPicker ).toBeHidden();
-
- // On Frontend, verify that product reference is a number
- await pageObject.publishAndGoToFrontend();
- const collectionWithProductContext = page.locator(
- `[data-collection="${ collection.collection }"]`
- );
- const queryAttribute = JSON.parse(
- ( await collectionWithProductContext.getAttribute(
- 'data-query'
- ) ) || '{}'
- );
- expect( typeof queryAttribute?.productReference ).toBe( 'number' );
- } );
-
- test( `For collection "${ collection.name }" - changing product using inspector control`, async ( {
- pageObject,
- admin,
- page,
- editor,
- } ) => {
- await admin.createNewPost();
- await pageObject.insertProductCollection();
- await pageObject.chooseCollectionInPost(
- collection.id as Collections
- );
-
- // Verify that product picker is shown in Editor
- const editorProductPicker = editor.canvas.locator(
- SELECTORS.productPicker
- );
- await expect( editorProductPicker ).toBeVisible();
-
- // Once a product is selected, the product picker should be hidden
- await pageObject.chooseProductInEditorProductPickerIfAvailable(
- editor.canvas
- );
- await expect( editorProductPicker ).toBeHidden();
-
- // Verify that Album is selected
- await expect(
- admin.page.locator( SELECTORS.linkedProductControl.button )
- ).toContainText( 'Album' );
-
- // Change product using inspector control to Beanie
- await admin.page
- .locator( SELECTORS.linkedProductControl.button )
- .click();
- await admin.page
- .locator( SELECTORS.linkedProductControl.popoverContent )
- .getByLabel( 'Beanie', { exact: true } )
- .click();
- await expect(
- admin.page.locator( SELECTORS.linkedProductControl.button )
- ).toContainText( 'Beanie' );
-
- // On Frontend, verify that product reference is a number
- await pageObject.publishAndGoToFrontend();
- const collectionWithProductContext = page.locator(
- `[data-collection="${ collection.collection }"]`
- );
- const queryAttribute = JSON.parse(
- ( await collectionWithProductContext.getAttribute(
- 'data-query'
- ) ) || '{}'
- );
- expect( typeof queryAttribute?.productReference ).toBe( 'number' );
- } );
-
- test( `For collection "${ collection.name }" - "From current product" is chosen by default`, async ( {
- pageObject,
- admin,
- editor,
- } ) => {
- await admin.visitSiteEditor( {
- postId: `${ BLOCK_THEME_SLUG }//single-product`,
- postType: 'wp_template',
- canvas: 'edit',
- } );
- await editor.canvas.locator( 'body' ).click();
- await pageObject.insertProductCollection();
- await pageObject.chooseCollectionInTemplate(
- collection.id as Collections
- );
-
- const productToShowControl = admin.page.getByText(
- 'From the current product'
- );
- await expect( productToShowControl ).toBeChecked();
- } );
- } );
-
- test( `For collection "Block: My Custom Collection - Cart Context" - "From products in the cart" is chosen by default in Cart Template`, async ( {
+ test( 'For collection "My Custom Collection - Product Context" - manually selected product reference should be available on Frontend in a post', async ( {
pageObject,
admin,
+ page,
editor,
+ requestUtils,
} ) => {
- await admin.visitSiteEditor( {
- postId: `${ BLOCK_THEME_SLUG }//page-cart`,
- postType: 'wp_template',
- canvas: 'edit',
+ const matchingProducts = await requestUtils.rest<
+ Array< { id: number; name: string } >
+ >( {
+ method: 'GET',
+ path: 'wc/v3/products',
+ params: { search: 'Album' },
} );
- await editor.canvas.locator( 'body' ).click();
+ const exactAlbums = matchingProducts.filter(
+ ( product ) => product.name === 'Album'
+ );
+ expect( exactAlbums ).toHaveLength( 1 );
+ const albumId = exactAlbums[ 0 ].id;
+
+ await admin.createNewPost();
await pageObject.insertProductCollection();
- await pageObject.chooseCollectionInTemplate(
- 'myCustomCollectionWithCartContext'
+ await pageObject.chooseCollectionInPost(
+ 'myCustomCollectionWithProductContext'
+ );
+
+ const productPicker = editor.canvas.locator( SELECTORS.productPicker );
+ await expect( productPicker ).toBeVisible();
+ await pageObject.chooseProductInEditorProductPickerIfAvailable(
+ editor.canvas,
+ 'Album'
);
+ await expect( productPicker ).toBeHidden();
- const fromProductsInCartRadioButton = admin.page.getByText(
- 'From products in the cart'
+ const selectedProductId = (
+ await pageObject.getProductCollectionQuery()
+ ).productReference;
+ expect( selectedProductId ).toBe( albumId );
+
+ await pageObject.publishAndGoToFrontend();
+ const collection = page.locator(
+ '[data-collection="woocommerce/product-collection/my-custom-collection-product-context"]'
+ );
+ const query = JSON.parse(
+ ( await collection.getAttribute( 'data-query' ) ) || '{}'
);
- await expect( fromProductsInCartRadioButton ).toBeChecked();
+ expect( query.productReference ).toBe( selectedProductId );
} );
- test( `For collection "Block: My Custom Collection - Order Context" - "From products in the order" is chosen by default in Order Confirmation Template`, async ( {
+ test( 'For collection "Block: My Custom Collection - Order Context" - "From products in the order" is chosen by default in Order Confirmation Template', async ( {
pageObject,
admin,
editor,
@@ -200,49 +91,74 @@ test.describe( 'Product Collection: Product Picker', () => {
'myCustomCollectionWithOrderContext'
);
- const fromProductsInOrderRadioButton = admin.page.getByText(
- 'From products in the order'
- );
- await expect( fromProductsInOrderRadioButton ).toBeChecked();
+ await expect(
+ admin.page.getByText( 'From products in the order' )
+ ).toBeChecked();
} );
test( 'Product picker should work as expected while changing collection using "Choose collection" button from Toolbar', async ( {
pageObject,
admin,
editor,
+ requestUtils,
} ) => {
+ const matchingProducts = await requestUtils.rest<
+ Array< { id: number; name: string } >
+ >( {
+ method: 'GET',
+ path: 'wc/v3/products',
+ params: { search: 'Album' },
+ } );
+ const exactAlbums = matchingProducts.filter(
+ ( product ) => product.name === 'Album'
+ );
+ expect( exactAlbums ).toHaveLength( 1 );
+ const albumId = exactAlbums[ 0 ].id;
+
await admin.createNewPost();
await pageObject.insertProductCollection();
await pageObject.chooseCollectionInPost(
'myCustomCollectionWithProductContext'
);
- // Verify that product picker is shown in Editor
- const editorProductPicker = editor.canvas.locator(
- SELECTORS.productPicker
- );
- await expect( editorProductPicker ).toBeVisible();
-
- // Once a product is selected, the product picker should be hidden
+ const productPicker = editor.canvas.locator( SELECTORS.productPicker );
+ await expect( productPicker ).toBeVisible();
await pageObject.chooseProductInEditorProductPickerIfAvailable(
- editor.canvas
+ editor.canvas,
+ 'Album'
);
- await expect( editorProductPicker ).toBeHidden();
+ await expect( productPicker ).toBeHidden();
+ const firstProductId = ( await pageObject.getProductCollectionQuery() )
+ .productReference;
+ expect( firstProductId ).toBe( albumId );
- // Change collection using Toolbar
await pageObject.changeCollectionUsingToolbar(
'myCustomCollectionMultipleContexts'
);
- await expect( editorProductPicker ).toBeVisible();
+ await expect( productPicker ).toBeVisible();
+ expect(
+ ( await pageObject.getProductCollectionQuery() ).productReference
+ ).toBeUndefined();
- // Once a product is selected, the product picker should be hidden
await pageObject.chooseProductInEditorProductPickerIfAvailable(
editor.canvas
);
- await expect( editorProductPicker ).toBeHidden();
+ await expect( productPicker ).toBeHidden();
+ const secondProductId = ( await pageObject.getProductCollectionQuery() )
+ .productReference;
+ expect( secondProductId ).toBe( albumId );
- // Product picker should be hidden for collections that don't need product
await pageObject.changeCollectionUsingToolbar( 'featured' );
- await expect( editorProductPicker ).toBeHidden();
+ await expect( productPicker ).toBeHidden();
+ expect(
+ ( await pageObject.getProductCollectionQuery() ).productReference
+ ).toBeUndefined();
+ await pageObject.refreshLocators( 'editor' );
+ await expect( pageObject.productTitles ).toHaveText( [
+ 'Cap',
+ 'Hoodie with Zipper',
+ 'Sunglasses',
+ 'V-Neck T-Shirt',
+ ] );
} );
} );
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/CollectionPresetsTest.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/CollectionPresetsTest.php
new file mode 100644
index 00000000000..a74693e4e78
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/CollectionPresetsTest.php
@@ -0,0 +1,248 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Blocks\BlockTypes\ProductCollection;
+
+use Automattic\WooCommerce\Enums\ProductStatus;
+use Automattic\WooCommerce\Enums\ProductStockStatus;
+use Automattic\WooCommerce\Tests\Blocks\Helpers\FixtureData;
+use Automattic\WooCommerce\Tests\Blocks\Mocks\ProductCollectionMock;
+use WC_Product;
+use WP_Query;
+
+/**
+ * Tests Product Collection preset queries against controlled products.
+ */
+class CollectionPresetsTest extends \WP_UnitTestCase {
+ /**
+ * Product Collection controller using the real query builder and handlers.
+ *
+ * @var ProductCollectionMock
+ */
+ private $block_instance;
+
+ /**
+ * Fixture factory.
+ *
+ * @var FixtureData
+ */
+ private $fixtures;
+
+ /**
+ * Set up the real Product Collection query seam.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ $this->block_instance = new ProductCollectionMock();
+ $this->fixtures = new FixtureData();
+ }
+
+ /**
+ * Presets and the exact collection query behavior each row exercises.
+ *
+ * @return array<string, array{string}>
+ */
+ public function provider_collection_presets(): array {
+ return array(
+ 'new arrivals' => array( 'new-arrivals' ),
+ 'top rated' => array( 'top-rated' ),
+ 'best sellers' => array( 'best-sellers' ),
+ 'on sale' => array( 'on-sale' ),
+ 'featured' => array( 'featured' ),
+ 'related categories' => array( 'related-categories' ),
+ 'related tags' => array( 'related-tags' ),
+ );
+ }
+
+ /**
+ * @testdox $scenario preset returns the exact ordered controlled products
+ *
+ * @dataProvider provider_collection_presets
+ *
+ * @param string $scenario Preset scenario.
+ */
+ public function test_collection_preset_result( string $scenario ): void {
+ $products = array();
+ $parsed_block = Utils::get_base_parsed_block();
+ $expected_ids = array();
+ $distractor_id = 0;
+
+ switch ( $scenario ) {
+ case 'new-arrivals':
+ $recent = $this->create_product(
+ $products,
+ array(
+ 'name' => 'Preset recent target',
+ 'date_created' => gmdate( 'Y-m-d H:i:s', strtotime( '-1 day' ) ),
+ )
+ );
+ $old = $this->create_product(
+ $products,
+ array(
+ 'name' => 'Preset old distractor',
+ 'date_created' => gmdate( 'Y-m-d H:i:s', strtotime( '-30 days' ) ),
+ )
+ );
+ $expected_ids = array( $recent->get_id() );
+ $distractor_id = $old->get_id();
+ $parsed_block['attrs']['collection'] = 'woocommerce/product-collection/new-arrivals';
+ $parsed_block['attrs']['query']['orderBy'] = 'date';
+ $parsed_block['attrs']['query']['order'] = 'desc';
+ $parsed_block['attrs']['query']['timeFrame'] = array(
+ 'operator' => 'in',
+ 'value' => '-7 days',
+ );
+ break;
+
+ case 'top-rated':
+ $highest = $this->create_product( $products, array( 'name' => 'Preset five-star target' ) );
+ $second = $this->create_product( $products, array( 'name' => 'Preset four-star target' ) );
+ $distractor = $this->create_product( $products, array( 'name' => 'Preset one-star distractor' ) );
+ $this->set_rating( $highest, 5.0, 5 );
+ $this->set_rating( $second, 4.0, 4 );
+ $this->set_rating( $distractor, 1.0, 1 );
+ $expected_ids = array( $highest->get_id(), $second->get_id() );
+ $distractor_id = $distractor->get_id();
+ $parsed_block['attrs']['collection'] = 'woocommerce/product-collection/top-rated';
+ $parsed_block['attrs']['query']['orderBy'] = 'rating';
+ $parsed_block['attrs']['query']['order'] = 'desc';
+ $parsed_block['attrs']['query']['perPage'] = 2;
+ break;
+
+ case 'best-sellers':
+ $highest = $this->create_product( $products, array( 'name' => 'Preset highest-sales target' ) );
+ $second = $this->create_product( $products, array( 'name' => 'Preset second-sales target' ) );
+ $distractor = $this->create_product( $products, array( 'name' => 'Preset low-sales distractor' ) );
+ $this->set_total_sales( $highest, 30 );
+ $this->set_total_sales( $second, 20 );
+ $this->set_total_sales( $distractor, 1 );
+ $expected_ids = array( $highest->get_id(), $second->get_id() );
+ $distractor_id = $distractor->get_id();
+ $parsed_block['attrs']['collection'] = 'woocommerce/product-collection/best-sellers';
+ $parsed_block['attrs']['query']['orderBy'] = 'popularity';
+ $parsed_block['attrs']['query']['order'] = 'desc';
+ $parsed_block['attrs']['query']['perPage'] = 2;
+ break;
+
+ case 'on-sale':
+ $on_sale = $this->create_product(
+ $products,
+ array(
+ 'name' => 'Preset sale target',
+ 'regular_price' => '20',
+ 'sale_price' => '10',
+ )
+ );
+ $distractor = $this->create_product( $products, array( 'name' => 'Preset regular-price distractor' ) );
+ $expected_ids = array( $on_sale->get_id() );
+ $distractor_id = $distractor->get_id();
+ $parsed_block['attrs']['collection'] = 'woocommerce/product-collection/on-sale';
+ $parsed_block['attrs']['query']['woocommerceOnSale'] = true;
+ delete_transient( 'wc_products_onsale' );
+ break;
+
+ case 'featured':
+ $featured = $this->create_product( $products, array( 'name' => 'Preset featured target' ) );
+ $featured->set_featured( true );
+ $featured->save();
+ $distractor = $this->create_product( $products, array( 'name' => 'Preset ordinary distractor' ) );
+ $expected_ids = array( $featured->get_id() );
+ $distractor_id = $distractor->get_id();
+ $parsed_block['attrs']['collection'] = 'woocommerce/product-collection/featured';
+ $parsed_block['attrs']['query']['featured'] = true;
+ break;
+
+ case 'related-categories':
+ case 'related-tags':
+ $taxonomy = 'related-categories' === $scenario ? 'product_cat' : 'product_tag';
+ $term = wp_insert_term( "Preset {$scenario}", $taxonomy );
+ if ( is_wp_error( $term ) ) {
+ throw new \RuntimeException( esc_html( $term->get_error_message() ) );
+ }
+ $term_id = (int) $term['term_id'];
+
+ $reference = $this->create_product( $products, array( 'name' => "Preset {$scenario} reference" ) );
+ $related = $this->create_product( $products, array( 'name' => "Preset {$scenario} target" ) );
+ $distractor = $this->create_product( $products, array( 'name' => "Preset {$scenario} distractor" ) );
+ wp_set_object_terms( $reference->get_id(), array( $term_id ), $taxonomy );
+ wp_set_object_terms( $related->get_id(), array( $term_id ), $taxonomy );
+ $expected_ids = array( $related->get_id() );
+ $distractor_id = $distractor->get_id();
+ $parsed_block['attrs']['collection'] = 'woocommerce/product-collection/related';
+ $parsed_block['attrs']['query']['productReference'] = $reference->get_id();
+ $parsed_block['attrs']['query']['relatedBy'] = array(
+ 'categories' => 'product_cat' === $taxonomy,
+ 'tags' => 'product_tag' === $taxonomy,
+ );
+ break;
+
+ default:
+ $this->fail( "Unknown Product Collection preset scenario: {$scenario}" );
+ }
+
+ $fixture_ids = array_map(
+ static function ( WC_Product $product ): int {
+ return $product->get_id();
+ },
+ $products
+ );
+ $parsed_block['attrs']['query']['post__in'] = $fixture_ids;
+ $query_args = Utils::initialize_merged_query( $this->block_instance, $parsed_block );
+ $query = new WP_Query( $query_args );
+ $actual_ids = wp_list_pluck( $query->posts, 'ID' );
+
+ $this->assertNotEmpty( $actual_ids, 'The preset must return a controlled product.' );
+ $this->assertSame( $expected_ids, $actual_ids, 'The preset must return the exact ordered controlled products.' );
+ $this->assertNotContains( $distractor_id, $actual_ids, 'The controlled distractor must be excluded.' );
+ }
+
+ /**
+ * Create and track a published, in-stock simple product.
+ *
+ * @param WC_Product[] $products Tracked products.
+ * @param array $props Product properties.
+ * @return WC_Product
+ */
+ private function create_product( array &$products, array $props ): WC_Product {
+ $product = $this->fixtures->get_simple_product(
+ wp_parse_args(
+ $props,
+ array(
+ 'regular_price' => '10',
+ 'status' => ProductStatus::PUBLISH,
+ 'stock_status' => ProductStockStatus::IN_STOCK,
+ )
+ )
+ );
+ $products[] = $product;
+
+ return $product;
+ }
+
+ /**
+ * Persist rating data used by the Product Collection rating order.
+ *
+ * @param WC_Product $product Product.
+ * @param float $rating Average rating.
+ * @param int $count Rating count.
+ */
+ private function set_rating( WC_Product $product, float $rating, int $count ): void {
+ $product->set_average_rating( $rating );
+ $product->set_rating_counts( array( (int) $rating => $count ) );
+ $product->set_review_count( $count );
+ $product->save();
+ }
+
+ /**
+ * Persist total-sales data used by the Product Collection popularity order.
+ *
+ * @param WC_Product $product Product.
+ * @param int $sales Total sales.
+ */
+ private function set_total_sales( WC_Product $product, int $sales ): void {
+ $product->set_total_sales( $sales );
+ $product->save();
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php
index d70242ae316..301ab506c2d 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php
@@ -1379,4 +1379,317 @@ class QueryBuilder extends \WP_UnitTestCase {
$this->assertSame( 0, $merged_query['offset'] );
$this->assertSame( 9, $merged_query['posts_per_page'] );
}
+
+ /**
+ * @testdox Inspector query controls return exact product identities through the real Controller to QueryBuilder path.
+ *
+ * @dataProvider inspector_query_result_provider
+ *
+ * @param string $scenario Inspector query scenario.
+ * @param string[] $expected Expected product identities in order.
+ */
+ public function test_inspector_query_result( string $scenario, array $expected ): void {
+ $products = array();
+ $terms = array();
+ $registered_taxonomies = array();
+
+ try {
+ $fixtures = new FixtureData();
+ $products[] = $fixtures->get_simple_product(
+ array(
+ 'name' => 'Alpha inspector target',
+ 'regular_price' => '10',
+ 'status' => 'publish',
+ 'stock_status' => ProductStockStatus::OUT_OF_STOCK,
+ )
+ );
+ $products[] = $fixtures->get_simple_product(
+ array(
+ 'name' => 'Zulu inspector distractor',
+ 'regular_price' => '15',
+ 'status' => 'publish',
+ 'stock_status' => ProductStockStatus::IN_STOCK,
+ )
+ );
+ $products[] = $fixtures->get_simple_product(
+ array(
+ 'name' => 'Below inspector distractor',
+ 'regular_price' => '5',
+ 'status' => 'publish',
+ 'stock_status' => ProductStockStatus::IN_STOCK,
+ )
+ );
+
+ $product_ids = array_map(
+ static function ( \WC_Product $product ): int {
+ return $product->get_id();
+ },
+ $products
+ );
+ $query = array(
+ 'post__in' => $product_ids,
+ );
+ $base_query = array();
+
+ switch ( $scenario ) {
+ case 'category':
+ case 'tag':
+ case 'brand':
+ $taxonomy = array(
+ 'category' => 'product_cat',
+ 'tag' => 'product_tag',
+ 'brand' => 'product_brand',
+ )[ $scenario ];
+ if ( ! taxonomy_exists( $taxonomy ) ) {
+ register_taxonomy( $taxonomy, 'product' );
+ $registered_taxonomies[] = $taxonomy;
+ }
+ $term = wp_insert_term( "Inspector {$scenario} target", $taxonomy );
+ if ( is_wp_error( $term ) ) {
+ throw new \RuntimeException( $term->get_error_message() );
+ }
+ $term_id = (int) $term['term_id'];
+ $terms[] = array( $term_id, $taxonomy );
+ wp_set_object_terms( $product_ids[0], array( $term_id ), $taxonomy );
+ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
+ $base_query['tax_query'] = array(
+ array(
+ 'field' => 'term_id',
+ 'taxonomy' => $taxonomy,
+ 'terms' => array( $term_id ),
+ ),
+ );
+ break;
+ case 'attributes':
+ foreach ( array( 'pa_inspector_color', 'pa_inspector_size' ) as $taxonomy ) {
+ register_taxonomy( $taxonomy, 'product' );
+ $registered_taxonomies[] = $taxonomy;
+
+ $term = wp_insert_term( "{$taxonomy} target", $taxonomy );
+ if ( is_wp_error( $term ) ) {
+ throw new \RuntimeException( $term->get_error_message() );
+ }
+ $term_id = (int) $term['term_id'];
+ $terms[] = array( $term_id, $taxonomy );
+ wp_set_object_terms( $product_ids[0], array( $term_id ), $taxonomy );
+ if ( 'pa_inspector_color' === $taxonomy ) {
+ wp_set_object_terms( $product_ids[1], array( $term_id ), $taxonomy );
+ }
+ $query['woocommerceAttributes'][] = array(
+ 'taxonomy' => $taxonomy,
+ 'termId' => $term_id,
+ );
+ }
+ break;
+ case 'search':
+ $query['search'] = 'Alpha';
+ break;
+ case 'stock':
+ $query['woocommerceStockStatus'] = array( ProductStockStatus::OUT_OF_STOCK );
+ break;
+ case 'minimum-price':
+ $query['priceRange'] = array(
+ 'min' => 10,
+ );
+ $query['orderBy'] = 'title';
+ $query['order'] = 'asc';
+ break;
+ case 'maximum-price':
+ $query['priceRange'] = array(
+ 'max' => 10,
+ );
+ $query['orderBy'] = 'title';
+ $query['order'] = 'asc';
+ break;
+ case 'inclusive-price':
+ $query['priceRange'] = array(
+ 'min' => 10,
+ 'max' => 10,
+ );
+ $query['orderBy'] = 'title';
+ $query['order'] = 'asc';
+ break;
+ case 'created-before':
+ case 'created-within':
+ wp_update_post(
+ array(
+ 'ID' => $product_ids[0],
+ 'post_date' => '2024-01-01 00:00:00',
+ 'post_date_gmt' => '2024-01-01 00:00:00',
+ )
+ );
+ wp_update_post(
+ array(
+ 'ID' => $product_ids[1],
+ 'post_date' => '2024-02-01 00:00:00',
+ 'post_date_gmt' => '2024-02-01 00:00:00',
+ )
+ );
+ $query['post__in'] = array( $product_ids[0], $product_ids[1] );
+ $query['timeFrame'] = array(
+ 'operator' => 'created-before' === $scenario ? 'not-in' : 'in',
+ 'value' => '2024-01-15 00:00:00',
+ );
+ break;
+ case 'hand-picked':
+ unset( $query['post__in'] );
+ wp_update_post(
+ array(
+ 'ID' => $product_ids[0],
+ 'post_date' => '2024-03-01 00:00:00',
+ 'post_date_gmt' => '2024-03-01 00:00:00',
+ )
+ );
+ wp_update_post(
+ array(
+ 'ID' => $product_ids[1],
+ 'post_date' => '2024-01-01 00:00:00',
+ 'post_date_gmt' => '2024-01-01 00:00:00',
+ )
+ );
+ $query['woocommerceHandPickedProducts'] = array( $product_ids[1], $product_ids[0] );
+ $query['orderBy'] = 'post__in';
+ break;
+ case 'title-descending':
+ wp_update_post(
+ array(
+ 'ID' => $product_ids[0],
+ 'post_date' => '2024-03-01 00:00:00',
+ 'post_date_gmt' => '2024-03-01 00:00:00',
+ )
+ );
+ wp_update_post(
+ array(
+ 'ID' => $product_ids[1],
+ 'post_date' => '2024-01-01 00:00:00',
+ 'post_date_gmt' => '2024-01-01 00:00:00',
+ )
+ );
+ $query['post__in'] = array( $product_ids[0], $product_ids[1] );
+ $query['orderBy'] = 'title';
+ $query['order'] = 'desc';
+ break;
+ }
+
+ $parsed_block = Utils::get_base_parsed_block();
+
+ $parsed_block['attrs']['query'] = array_merge( $parsed_block['attrs']['query'], $query );
+
+ $parsed_block['attrs']['query']['inherit'] = false;
+
+ $merged_query = Utils::initialize_merged_query( $this->block_instance, $parsed_block, $base_query );
+
+ $result = new WP_Query( $merged_query );
+
+ $product_names = wp_list_pluck( $result->posts, 'post_title' );
+
+ $this->assertSame( $expected, $product_names, "Unexpected product order for {$scenario}." );
+ } finally {
+ foreach ( $products as $product ) {
+ $product->delete( true );
+ }
+ foreach ( $terms as list( $term_id, $taxonomy ) ) {
+ wp_delete_term( $term_id, $taxonomy );
+ }
+ foreach ( $registered_taxonomies as $taxonomy ) {
+ unregister_taxonomy( $taxonomy );
+ }
+ }
+ }
+
+ /**
+ * Inspector query result cases.
+ *
+ * @return array<string, array{string, string[]}>
+ */
+ public function inspector_query_result_provider(): array {
+ return array(
+ 'category' => array( 'category', array( 'Alpha inspector target' ) ),
+ 'tag' => array( 'tag', array( 'Alpha inspector target' ) ),
+ 'brand' => array( 'brand', array( 'Alpha inspector target' ) ),
+ 'combined attrs' => array( 'attributes', array( 'Alpha inspector target' ) ),
+ 'keyword search' => array( 'search', array( 'Alpha inspector target' ) ),
+ 'stock' => array( 'stock', array( 'Alpha inspector target' ) ),
+ 'minimum price' => array( 'minimum-price', array( 'Alpha inspector target', 'Zulu inspector distractor' ) ),
+ 'maximum price' => array( 'maximum-price', array( 'Alpha inspector target', 'Below inspector distractor' ) ),
+ 'inclusive price' => array( 'inclusive-price', array( 'Alpha inspector target' ) ),
+ 'created before' => array( 'created-before', array( 'Alpha inspector target' ) ),
+ 'created within' => array( 'created-within', array( 'Zulu inspector distractor' ) ),
+ 'hand picked' => array( 'hand-picked', array( 'Zulu inspector distractor', 'Alpha inspector target' ) ),
+ 'title descending' => array( 'title-descending', array( 'Zulu inspector distractor', 'Alpha inspector target' ) ),
+ );
+ }
+
+ /**
+ * @testdox Inspector pagination controls produce nonempty page-one and page-two windows through the Controller page argument.
+ *
+ * @dataProvider inspector_pagination_result_provider
+ *
+ * @param int $page Requested page.
+ * @param int $expected_offset Expected query offset.
+ * @param string[] $expected_names Expected product names.
+ */
+ public function test_inspector_pagination_result( int $page, int $expected_offset, array $expected_names ): void {
+ $products = array();
+
+ try {
+ $fixtures = new FixtureData();
+ foreach ( array( 'Alpha', 'Beta', 'Gamma', 'Kappa', 'Omega' ) as $name ) {
+ $products[] = $fixtures->get_simple_product(
+ array(
+ 'name' => "{$name} pagination product",
+ 'regular_price' => '10',
+ 'status' => 'publish',
+ )
+ );
+ }
+
+ $parsed_block = Utils::get_base_parsed_block();
+
+ $parsed_block['attrs']['query']['inherit'] = false;
+
+ $parsed_block['attrs']['query']['orderBy'] = 'title';
+
+ $parsed_block['attrs']['query']['order'] = 'asc';
+
+ $parsed_block['attrs']['query']['perPage'] = 2;
+
+ $parsed_block['attrs']['query']['offset'] = 1;
+
+ $parsed_block['attrs']['query']['post__in'] = array_map(
+ static function ( \WC_Product $product ): int {
+ return $product->get_id();
+ },
+ $products
+ );
+
+ $this->block_instance->set_parsed_block( $parsed_block );
+ $block = new \stdClass();
+ $block->context = $parsed_block['attrs'];
+
+ $merged_query = $this->block_instance->build_frontend_query( array(), $block, $page );
+
+ $result = new WP_Query( $merged_query );
+
+ $this->assertSame( 2, $merged_query['posts_per_page'], 'The requested page size should reach WP_Query.' );
+ $this->assertSame( $expected_offset, $merged_query['offset'], "Page {$page} should use the cumulative inspector offset." );
+ $this->assertSame( $expected_names, wp_list_pluck( $result->posts, 'post_title' ), "Page {$page} should contain the expected nonempty product window." );
+ } finally {
+ foreach ( $products as $product ) {
+ $product->delete( true );
+ }
+ }
+ }
+
+ /**
+ * Inspector pagination result cases.
+ *
+ * @return array<string, array{int, int, string[]}>
+ */
+ public function inspector_pagination_result_provider(): array {
+ return array(
+ 'page one' => array( 1, 1, array( 'Beta pagination product', 'Gamma pagination product' ) ),
+ 'page two' => array( 2, 3, array( 'Kappa pagination product', 'Omega pagination product' ) ),
+ );
+ }
}