Commit 3274337ce24 for woocommerce

commit 3274337ce24bfba8bca81c042fa23cbc0e1dab03
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date:   Fri Oct 9 14:42:03 2026 +0300

    [tests] Pin which pickup locations are offered, and what each option carries (#69203)

    * [tests] Pin which pickup locations are offered, and what each option carries

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

    * [tests] Assert two pickup options are labelled differently

    * [tests] Pin that the rest of the store treats a pickup order as collected

    * [tests] Correct why the registered pickup list leaves the legacy method out

    * [tests] Pin that an earlier filter cannot drop local pickup from the collection list

    * [tests] Reload shipping methods so pickup identity does not depend on test order

    * [tests] Pin that no branch holds the method back, and that closing one keeps the list in place

    * [tests] Cover the block checkout naming a pickup branch by its own name and address, and showing its cost

    * [tests] Format the pickup option matcher to satisfy prettier

    ---------

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

diff --git a/plugins/woocommerce/changelog/69203-wooplug-7840-shipping-tests-pickup-identity b/plugins/woocommerce/changelog/69203-wooplug-7840-shipping-tests-pickup-identity
new file mode 100644
index 00000000000..c553d96be44
--- /dev/null
+++ b/plugins/woocommerce/changelog/69203-wooplug-7840-shipping-tests-pickup-identity
@@ -0,0 +1,5 @@
+Significance: patch
+Type: dev
+Comment: Cover which local pickup locations are offered and what each option carries; no production change.
+
+
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-pickup-options-block/test/block.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-pickup-options-block/test/block.tsx
index dd2b2292ed1..4ef542292f5 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-pickup-options-block/test/block.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-pickup-options-block/test/block.tsx
@@ -252,3 +252,79 @@ test( 'description is not shown if rate is not selected', async () => {
 	expect( screen.queryByText( 'Store 1 details.' ) ).not.toBeInTheDocument();
 	expect( screen.getByText( 'Store 2 details.' ) ).toBeInTheDocument();
 } );
+
+test( "labels a location by its own name and address, not the shipping method's", async () => {
+	const packageData = generateShippingPackage( {
+		packageId: 0,
+		shippingRates: [
+			generateShippingRate( {
+				rateId: 'pickup_location:1',
+				name: 'Local pickup',
+				methodID: 'pickup_nyc',
+				price: '0',
+				instanceID: 0,
+				meta_data: [
+					{ key: 'pickup_location', value: 'Downtown Store' },
+					{
+						key: 'pickup_address',
+						value: '12 High Street, London',
+					},
+				],
+			} ),
+		],
+	} );
+	( useShippingData as jest.Mock ).mockImplementation( () => {
+		return {
+			selectShippingRate: jest.fn(),
+			isSelectingRate: false,
+			shippingRates: [ packageData ],
+		};
+	} );
+
+	render( <CheckoutPickupOptionsBlock /> );
+
+	// Named by the branch, which is how two branches are told apart, rather than by the
+	// method all of them share.
+	expect(
+		await screen.findByRole( 'radio', { name: /Downtown Store/ } )
+	).toBeInTheDocument();
+	expect(
+		screen.queryByRole( 'radio', { name: /Local pickup/ } )
+	).not.toBeInTheDocument();
+	expect( screen.getByText( '12 High Street, London' ) ).toBeInTheDocument();
+} );
+
+test( 'shows the pickup cost instead of free once the merchant sets one', async () => {
+	const packageData = generateShippingPackage( {
+		packageId: 0,
+		shippingRates: [
+			generateShippingRate( {
+				rateId: 'pickup_location:1',
+				name: 'Pickup New York City',
+				methodID: 'pickup_nyc',
+				price: '500',
+				instanceID: 0,
+				meta_data: [
+					{ key: 'pickup_location', value: 'New York City' },
+				],
+			} ),
+		],
+	} );
+	( useShippingData as jest.Mock ).mockImplementation( () => {
+		return {
+			selectShippingRate: jest.fn(),
+			isSelectingRate: false,
+			shippingRates: [ packageData ],
+		};
+	} );
+
+	render( <CheckoutPickupOptionsBlock /> );
+
+	// The amount is rendered in parts ($ then 5.00), so read it off the option's accessible name.
+	expect(
+		await screen.findByRole( 'radio', {
+			name: /New York City.*\$\s*5\.00/,
+		} )
+	).toBeInTheDocument();
+	expect( screen.queryByText( 'free' ) ).not.toBeInTheDocument();
+} );
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Shipping/PickupIdentityTest.php b/plugins/woocommerce/tests/php/src/Blocks/Shipping/PickupIdentityTest.php
new file mode 100644
index 00000000000..78f7bb32649
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/Shipping/PickupIdentityTest.php
@@ -0,0 +1,206 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\Shipping;
+
+use Automattic\WooCommerce\StoreApi\Utilities\LocalPickupUtils;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests that the rest of the store agrees an order is being collected.
+ *
+ * The settings screen promises that local pickup "will appear as an option on the block based
+ * checkout". What follows from a shopper taking that option is that the order is treated as
+ * collected everywhere else: no delivery address is asked for, and the order shows where to
+ * collect from instead of where it is being sent.
+ */
+class PickupIdentityTest extends WC_Unit_Test_Case {
+
+	/**
+	 * The checkout page id to put back.
+	 *
+	 * @var int|string
+	 */
+	private $original_checkout_page_id;
+
+	/**
+	 * Local pickup is only registered while the checkout is the block one, and it is registered on
+	 * woocommerce_load_shipping_methods, which fires once. Anything that loaded the methods earlier
+	 * in the process did so against the old page, so the methods have to be loaded again here.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$this->original_checkout_page_id = get_option( 'woocommerce_checkout_page_id' );
+		update_option(
+			'woocommerce_checkout_page_id',
+			$this->factory->post->create(
+				array(
+					'post_type'    => 'page',
+					'post_title'   => 'Checkout',
+					'post_content' => '<!-- wp:woocommerce/checkout /-->',
+					'post_status'  => 'publish',
+				)
+			)
+		);
+
+		$this->reload_shipping_methods();
+	}
+
+	/**
+	 * Put the checkout page back, and the shipping methods with it.
+	 */
+	public function tearDown(): void {
+		try {
+			update_option( 'woocommerce_checkout_page_id', $this->original_checkout_page_id );
+			$this->reload_shipping_methods();
+		} finally {
+			parent::tearDown();
+		}
+	}
+
+	/**
+	 * Rebuild the registered shipping methods against the checkout page currently set.
+	 */
+	private function reload_shipping_methods(): void {
+		WC()->shipping()->unregister_shipping_methods();
+		WC()->shipping()->load_shipping_methods();
+	}
+
+	/**
+	 * Build an order carrying one shipping line of the given method.
+	 *
+	 * @param string $method_id Shipping method id.
+	 * @param array  $meta      Meta to put on the shipping line.
+	 * @return \WC_Order
+	 */
+	private function order_shipped_by( string $method_id, array $meta = array() ): \WC_Order {
+		$order = new \WC_Order();
+		$item  = new \WC_Order_Item_Shipping();
+		$item->set_method_id( $method_id );
+		$item->set_method_title( 'Shipping' );
+
+		foreach ( $meta as $key => $value ) {
+			$item->add_meta_data( $key, $value );
+		}
+
+		$order->add_item( $item );
+		$order->save();
+
+		return wc_get_order( $order->get_id() );
+	}
+
+	/**
+	 * @testdox An order being collected is not asked for a delivery address, while a delivered one is.
+	 *
+	 * @testWith ["pickup_location", false]
+	 *           ["flat_rate", true]
+	 *
+	 * @param string $method_id Method the order was placed with.
+	 * @param bool   $expected  Whether a delivery address is still wanted.
+	 */
+	public function test_a_collected_order_is_not_asked_for_a_delivery_address( string $method_id, bool $expected ): void {
+		$order = $this->order_shipped_by( $method_id );
+
+		$this->assertSame(
+			$expected,
+			$order->needs_shipping_address(),
+			'An order placed with ' . $method_id . '.'
+		);
+	}
+
+	/**
+	 * The list is a filter so that extensions can add their own collection method, and asserting a
+	 * closed list would pin the very thing the filter exists to open.
+	 *
+	 * @testdox A collection method an extension adds is treated as collection too.
+	 */
+	public function test_a_collection_method_an_extension_adds_is_treated_as_collection(): void {
+		$order = $this->order_shipped_by( 'depot_collection' );
+
+		$this->assertTrue( $order->needs_shipping_address(), 'Before the extension speaks up, it is an ordinary delivery.' );
+
+		add_filter(
+			'woocommerce_order_hide_shipping_address',
+			static function ( $methods ) {
+				$methods[] = 'depot_collection';
+				return $methods;
+			}
+		);
+
+		$this->assertFalse( $order->needs_shipping_address(), 'Once the extension says it is collection, no delivery address is wanted.' );
+	}
+
+	/**
+	 * ShippingController re-adds the pickup ids at priority 10, so a filter that runs before it
+	 * cannot take them out. It can still win by running later.
+	 *
+	 * @testdox An earlier filter cannot drop local pickup from the collection list.
+	 */
+	public function test_an_earlier_filter_cannot_drop_local_pickup(): void {
+		$order = $this->order_shipped_by( 'local_pickup' );
+
+		$this->assertFalse( $order->needs_shipping_address(), 'Local pickup should not want a delivery address to begin with.' );
+
+		$empty_it = static function () {
+			return array();
+		};
+
+		add_filter( 'woocommerce_order_hide_shipping_address', $empty_it, 5 );
+		$this->assertFalse(
+			$order->needs_shipping_address(),
+			'A filter running before the controller should not be able to turn collection back into delivery.'
+		);
+		remove_filter( 'woocommerce_order_hide_shipping_address', $empty_it, 5 );
+
+		add_filter( 'woocommerce_order_hide_shipping_address', $empty_it, 20 );
+		$this->assertTrue(
+			$order->needs_shipping_address(),
+			'Running after the controller, the same filter does have the last word.'
+		);
+		remove_filter( 'woocommerce_order_hide_shipping_address', $empty_it, 20 );
+	}
+
+	/**
+	 * Two lists answer "is this collection" and they do not agree. The canonical
+	 * `woocommerce_local_pickup_methods` list names `legacy_local_pickup` so that an order placed
+	 * with the pre-zones method is still taxed at the shop. The registered list is built from the
+	 * methods that declare `local-pickup` support, and the legacy class never declares it, so it is
+	 * missing there even on a store where it is loaded and enabled.
+	 *
+	 * This records the divergence rather than blessing it. The two lists are read by different
+	 * callers, so the same order can be taxed as collection and still asked for a delivery address.
+	 *
+	 * @testdox The canonical list names a method the registered list leaves out.
+	 */
+	public function test_the_canonical_list_keeps_a_method_the_registered_list_does_not(): void {
+		// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Documented in WC_Abstract_Order::get_tax_location().
+		$canonical  = apply_filters( 'woocommerce_local_pickup_methods', array( 'legacy_local_pickup', 'local_pickup' ) );
+		$registered = LocalPickupUtils::get_local_pickup_method_ids();
+
+		$this->assertContains( 'legacy_local_pickup', $canonical, 'An order placed with the legacy method still has to be recognised.' );
+		$this->assertNotContains( 'legacy_local_pickup', $registered, 'The registered list leaves it out, because that class never declares local-pickup support.' );
+		$this->assertContains( 'pickup_location', $canonical, 'The block method joins the canonical list through the filter.' );
+		$this->assertContains( 'pickup_location', $registered, 'And it is registered, so it is in the other list as well.' );
+	}
+
+	/**
+	 * @testdox The order shows where to collect from, in place of where it would have been sent.
+	 */
+	public function test_the_order_shows_where_to_collect_from(): void {
+		$order = $this->order_shipped_by(
+			'pickup_location',
+			array(
+				'pickup_location' => 'Downtown',
+				'pickup_address'  => '1 Market St, San Francisco, CA 94105',
+				'pickup_details'  => 'Ring the bell at the side door.',
+			)
+		);
+
+		$shown = $order->get_shipping_to_display();
+
+		$this->assertStringContainsString( 'Downtown', $shown, 'The shopper should be told which branch to collect from.' );
+		$this->assertStringContainsString( 'Market St', $shown, 'And where that branch is.' );
+		$this->assertStringContainsString( 'Ring the bell', $shown, 'And whatever the merchant told them to do on arrival.' );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Shipping/PickupLocationTest.php b/plugins/woocommerce/tests/php/src/Blocks/Shipping/PickupLocationTest.php
new file mode 100644
index 00000000000..975b58415e6
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/Shipping/PickupLocationTest.php
@@ -0,0 +1,460 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\Shipping;
+
+use Automattic\WooCommerce\Blocks\Shipping\PickupLocation;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests the options PickupLocation offers the shopper.
+ *
+ * Every expected value here is taken from what the Local pickup settings screen promises the
+ * merchant: that enabling it makes pickup "appear as an option on the block based checkout", that
+ * the title is "the shipping method title shown to customers", that the cost is an "Optional cost
+ * to charge for local pickup" and that "by default, the local pickup shipping method is free",
+ * and that each location carries a required Location name and its Pickup details.
+ *
+ * Two expectations here are not merchant promises. The rate id format is one, pinned only
+ * relatively because it is what a shopper's stored choice points at. The `_pickup_location_address`
+ * meta is the other: it is hidden internal data the order uses to work out its tax location.
+ *
+ * One expectation is recorded rather than endorsed. `has_valid_pickup_location()` short-circuits to
+ * valid as soon as city, postcode and state are filled in, without asking the country what it needs,
+ * so a location saved with no street line counts as having a usable address even in a country that
+ * requires one. Whether that is right is a question for the team rather than for a test: a stall
+ * inside a mall may genuinely have no street line, and the screen does not say. It is reported, and
+ * `test_records_that_a_missing_street_line_still_counts_as_usable()` pins what happens today so a
+ * change to it is a deliberate edit rather than a surprise.
+ */
+class PickupLocationTest extends WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var PickupLocation
+	 */
+	private $sut;
+
+	/**
+	 * Configure local pickup the way the settings screen would, then build the method.
+	 *
+	 * @param array $locations Pickup locations.
+	 * @param array $settings  Method settings, merged over the defaults.
+	 */
+	private function pickup_configured_with( array $locations, array $settings = array() ): void {
+		update_option(
+			'woocommerce_pickup_location_settings',
+			array_merge(
+				array(
+					'enabled'    => 'yes',
+					'title'      => 'Pickup',
+					'tax_status' => 'taxable',
+					'cost'       => '',
+				),
+				$settings
+			)
+		);
+		update_option( 'pickup_location_pickup_locations', $locations );
+
+		$this->sut = new PickupLocation();
+	}
+
+	/**
+	 * Build a location as the settings screen stores one.
+	 *
+	 * @param string $name    Location name.
+	 * @param bool   $enabled Whether the location is switched on.
+	 * @param array  $address Address, defaulting to a complete one.
+	 * @param string $details Pickup details.
+	 * @return array
+	 */
+	private function location( string $name, bool $enabled = true, array $address = array(), string $details = '' ): array {
+		return array(
+			'name'    => $name,
+			'address' => $address ? $address : array(
+				'address_1' => '1 Market St',
+				'city'      => 'San Francisco',
+				'state'     => 'CA',
+				'postcode'  => '94105',
+				'country'   => 'US',
+			),
+			'details' => $details,
+			'enabled' => $enabled,
+		);
+	}
+
+	/**
+	 * Ask the method what it offers for an ordinary package.
+	 *
+	 * @return array Rates keyed by rate id.
+	 */
+	private function offered_rates(): array {
+		// Unsaved on purpose: add_rate() only reads the name and the quantity off the package.
+		$product = new \WC_Product_Simple();
+		$product->set_name( 'Fixture product' );
+
+		$this->sut->calculate_shipping(
+			array(
+				'contents'      => array(
+					'item' => array(
+						'data'     => $product,
+						'quantity' => 1,
+					),
+				),
+				'contents_cost' => 10.0,
+				'destination'   => array(
+					'country'  => 'US',
+					'state'    => 'CA',
+					'postcode' => '94105',
+				),
+			)
+		);
+
+		return $this->sut->rates;
+	}
+
+	/**
+	 * Ask the method for the one option a single-location store should offer.
+	 *
+	 * @return \WC_Shipping_Rate
+	 */
+	private function single_offered_rate(): \WC_Shipping_Rate {
+		$rates = $this->offered_rates();
+		$this->assertCount( 1, $rates, 'One open branch should give the shopper one option.' );
+
+		return current( $rates );
+	}
+
+	/**
+	 * @testdox Each enabled location is offered as its own option, named so the shopper can tell the branches apart.
+	 */
+	public function test_every_enabled_location_is_offered(): void {
+		$this->pickup_configured_with(
+			array(
+				$this->location( 'Downtown' ),
+				$this->location( 'Airport' ),
+			)
+		);
+
+		$rates = $this->offered_rates();
+
+		$this->assertCount( 2, $rates, 'Two open branches should give the shopper two options.' );
+
+		$names = array_map(
+			static function ( $rate ) {
+				return $rate->get_meta_data()['pickup_location'];
+			},
+			array_values( $rates )
+		);
+
+		$this->assertSame( array( 'Downtown', 'Airport' ), $names, 'Each option should carry its own location name.' );
+
+		$labels = array_map(
+			static function ( $rate ) {
+				return $rate->get_label();
+			},
+			array_values( $rates )
+		);
+
+		$this->assertStringContainsString( 'Downtown', $labels[0], 'The first option should name its own branch to the shopper.' );
+		$this->assertStringContainsString( 'Airport', $labels[1], 'And the second should name its own.' );
+		$this->assertNotSame( $labels[0], $labels[1], 'Two options a shopper cannot tell apart are no better than one.' );
+	}
+
+	/**
+	 * @testdox A location that is switched off is not offered.
+	 */
+	public function test_a_disabled_location_is_not_offered(): void {
+		$this->pickup_configured_with(
+			array(
+				$this->location( 'Downtown' ),
+				$this->location( 'Closed for refurbishment', false ),
+			)
+		);
+
+		$rates = $this->offered_rates();
+
+		$this->assertCount( 1, $rates, 'A location switched off should not be offered.' );
+		$this->assertSame( 'Downtown', current( $rates )->get_meta_data()['pickup_location'], 'The open branch is the one that survives.' );
+	}
+
+	/**
+	 * The screen lets the method be enabled before any location exists, so this is a state a
+	 * merchant passes through rather than an error.
+	 *
+	 * @testdox With no location open there is nothing to offer, even with pickup enabled.
+	 */
+	public function test_no_open_location_means_no_option(): void {
+		$this->pickup_configured_with( array( $this->location( 'Closed', false ) ) );
+
+		$this->assertEmpty( $this->offered_rates(), 'Pickup enabled with every branch closed should offer nothing.' );
+	}
+
+	/**
+	 * "By default, the local pickup shipping method is free", and the cost, when set, is charged
+	 * for local pickup rather than per branch.
+	 *
+	 * @testdox Pickup is free by default, and a configured cost is charged at every branch.
+	 */
+	public function test_cost_is_free_by_default_and_otherwise_shared(): void {
+		$this->pickup_configured_with(
+			array(
+				$this->location( 'Downtown' ),
+				$this->location( 'Airport' ),
+			)
+		);
+
+		$rates = $this->offered_rates();
+		$this->assertCount( 2, $rates, 'Both branches should be offered before their cost is examined.' );
+
+		foreach ( $rates as $rate ) {
+			$this->assertSame( '0', $rate->get_cost(), 'With no cost set, pickup should be free.' );
+		}
+
+		$this->pickup_configured_with(
+			array(
+				$this->location( 'Downtown' ),
+				$this->location( 'Airport' ),
+			),
+			array( 'cost' => '5' )
+		);
+
+		$rates = $this->offered_rates();
+		$this->assertCount( 2, $rates, 'Both branches should still be offered once a cost is set.' );
+
+		foreach ( $rates as $rate ) {
+			$this->assertSame( '5', $rate->get_cost(), 'A configured cost should be charged whichever branch is chosen.' );
+		}
+	}
+
+	/**
+	 * @testdox The title shown to customers appears on every option.
+	 */
+	public function test_the_customer_facing_title_appears_on_the_option(): void {
+		$this->pickup_configured_with(
+			array( $this->location( 'Downtown' ) ),
+			array( 'title' => 'Collect in store' )
+		);
+
+		$label = $this->single_offered_rate()->get_label();
+
+		$this->assertStringContainsString( 'Collect in store', $label, 'The option should carry the title the merchant set for customers.' );
+		$this->assertStringContainsString( 'Downtown', $label, 'And the branch name, or a shopper cannot tell two branches apart.' );
+	}
+
+	/**
+	 * @testdox The pickup details the merchant wrote travel with the option.
+	 */
+	public function test_pickup_details_travel_with_the_option(): void {
+		$this->pickup_configured_with(
+			array( $this->location( 'Downtown', true, array(), 'Ring the bell at the side door.' ) )
+		);
+
+		$this->assertSame(
+			'Ring the bell at the side door.',
+			$this->single_offered_rate()->get_meta_data()['pickup_details'],
+			'Details the merchant wrote for the shopper should reach the shopper.'
+		);
+	}
+
+	/**
+	 * A branch is a place the shopper can walk into, so it stays on offer whatever state its
+	 * address is in. What the address decides is whether the shopper is told where to go, and
+	 * whether the order has somewhere to work its tax out from.
+	 *
+	 * An address counts as usable when it holds what that country actually asks for. The editor
+	 * collects Address, City, Country / State and Postcode / ZIP, and only the Location name is
+	 * required of the merchant, so an address is not judged against a fixed list of fields.
+	 *
+	 * @testdox A location is always offered, and carries an address only when its country has what it needs.
+	 *
+	 * @dataProvider provider_address_shapes
+	 *
+	 * @param array  $address    The address the merchant saved.
+	 * @param bool   $is_usable  Whether the shopper should be given the address.
+	 * @param string $why        What makes this address the shape it is.
+	 */
+	public function test_an_address_is_judged_against_what_its_country_asks_for( array $address, bool $is_usable, string $why ): void {
+		$this->pickup_configured_with( array( $this->location( 'Branch', true, $address ) ) );
+
+		$rates = $this->offered_rates();
+		$this->assertCount( 1, $rates, 'The branch should be offered whatever its address looks like: ' . $why );
+
+		$meta = current( $rates )->get_meta_data();
+		$this->assertSame( 'Branch', $meta['pickup_location'], 'It should be named either way.' );
+
+		if ( $is_usable ) {
+			$this->assertStringContainsString( $address['city'], $meta['pickup_address'], 'The shopper should be told where to collect from: ' . $why );
+			$this->assertStringContainsString( $address['address_1'], $meta['pickup_address'], 'Including the street line the merchant saved.' );
+			$this->assertSame( $address, $meta['_pickup_location_address'], 'The order should keep the address it will work tax out from.' );
+		} else {
+			$this->assertSame( '', $meta['pickup_address'], 'A half-built address should be left empty rather than shown: ' . $why );
+			$this->assertSame( array(), $meta['_pickup_location_address'], 'And nothing should be kept for the order to tax against.' );
+		}
+	}
+
+	/**
+	 * Address shapes, and whether they give the shopper somewhere to go.
+	 *
+	 * @return array
+	 */
+	public function provider_address_shapes(): array {
+		$us = array(
+			'address_1' => '1 Market St',
+			'city'      => 'San Francisco',
+			'state'     => 'CA',
+			'postcode'  => '94105',
+			'country'   => 'US',
+		);
+
+		return array(
+			'a complete address'               => array( $us, true, 'everything filled in' ),
+			'no country at all'                => array( array_merge( $us, array( 'country' => '' ) ), false, 'a country is the one thing always needed' ),
+			'a country that wants a postcode'  => array( array_merge( $us, array( 'postcode' => '' ) ), false, 'the US asks for a postcode and none was given' ),
+			'a country that wants no postcode' => array(
+				array(
+					'address_1' => '1 Sheikh Zayed Rd',
+					'city'      => 'Dubai',
+					'state'     => '',
+					'postcode'  => '',
+					'country'   => 'AE',
+				),
+				true,
+				'the UAE asks for neither a postcode nor a state',
+			),
+		);
+	}
+
+	/**
+	 * Rate ids are the array positions of the saved locations, so what an id means depends on the
+	 * array holding still. Switching a branch off leaves it in place and the others keep their ids.
+	 *
+	 * @testdox Switching one location off does not change which option the others are.
+	 */
+	public function test_switching_one_location_off_does_not_renumber_the_others(): void {
+		$this->pickup_configured_with(
+			array(
+				$this->location( 'Downtown' ),
+				$this->location( 'Airport' ),
+				$this->location( 'Harbour' ),
+			)
+		);
+
+		$before = array();
+		$rates  = $this->offered_rates();
+		$this->assertCount( 3, $rates, 'All three branches should be offered to begin with.' );
+
+		foreach ( $rates as $id => $rate ) {
+			$before[ $rate->get_meta_data()['pickup_location'] ] = $id;
+		}
+
+		$this->pickup_configured_with(
+			array(
+				$this->location( 'Downtown' ),
+				$this->location( 'Airport', false ),
+				$this->location( 'Harbour' ),
+			)
+		);
+
+		$after = array();
+		$rates = $this->offered_rates();
+		$this->assertCount( 2, $rates, 'Closing one branch should leave two.' );
+
+		foreach ( $rates as $id => $rate ) {
+			$after[ $rate->get_meta_data()['pickup_location'] ] = $id;
+		}
+
+		$this->assertSame( $before['Downtown'], $after['Downtown'], 'Downtown should still be the same option.' );
+		$this->assertSame( $before['Harbour'], $after['Harbour'], 'Harbour should still be the same option, not take over the closed branch.' );
+		$this->assertNotSame( $after['Downtown'], $after['Harbour'], 'And the two should remain separate options.' );
+	}
+
+	/**
+	 * The screen says local pickup "will appear as an option on the block based checkout" when it
+	 * is enabled, which is the whole point of the switch.
+	 *
+	 * @testdox The method is offered only while local pickup is switched on.
+	 *
+	 * @testWith ["yes", true]
+	 *           ["no", false]
+	 *
+	 * @param string $enabled  The stored setting.
+	 * @param bool   $expected Whether the method should offer itself at all.
+	 */
+	public function test_the_method_is_offered_only_while_it_is_switched_on( string $enabled, bool $expected ): void {
+		$this->pickup_configured_with( array( $this->location( 'Downtown' ) ), array( 'enabled' => $enabled ) );
+
+		$this->assertSame(
+			$expected,
+			$this->sut->is_available( array( 'destination' => array( 'country' => 'US' ) ) ),
+			'Local pickup switched ' . $enabled . '.'
+		);
+	}
+
+	/**
+	 * The method answers for itself rather than for any one branch, so nothing about the branches
+	 * can hold it back. A store that has not added one yet, and one whose only branch is closed,
+	 * are both still stores where local pickup is switched on. The sibling test covers the switch
+	 * being off, which needs no locations to say anything.
+	 *
+	 * @testdox Being offered follows the switch, and no branch can hold it back.
+	 */
+	public function test_the_switch_decides_on_its_own_whatever_locations_exist(): void {
+		$package = array( 'destination' => array( 'country' => 'US' ) );
+
+		$this->pickup_configured_with( array(), array( 'enabled' => 'yes' ) );
+		$this->assertTrue( $this->sut->is_available( $package ), 'With nowhere to collect from yet, the method is still the one that was switched on.' );
+
+		$this->pickup_configured_with( array( $this->location( 'Downtown', false ) ), array( 'enabled' => 'yes' ) );
+		$this->assertTrue( $this->sut->is_available( $package ), 'A branch being closed is not the method being switched off.' );
+	}
+
+	/**
+	 * The settings screen keeps limited HTML in the pickup details, so a merchant can save markup
+	 * there. What the shopper is handed on the rate is plain text either way: `add_meta_data()`
+	 * cleans every value it stores. The wording survives, the markup does not.
+	 *
+	 * @testdox Markup a merchant saved in the pickup details does not travel on the rate.
+	 */
+	public function test_markup_in_the_pickup_details_does_not_reach_the_rate(): void {
+		$this->pickup_configured_with(
+			array( $this->location( 'Downtown', true, array(), 'Ring the <strong>side</strong> door.' ) )
+		);
+
+		$meta = $this->single_offered_rate()->get_meta_data();
+
+		$this->assertArrayHasKey( 'pickup_details', $meta, 'The details should reach the shopper.' );
+		$this->assertSame( 'Ring the side door.', $meta['pickup_details'], 'With the wording intact and the markup gone.' );
+	}
+
+	/**
+	 * Records the behaviour this file's docblock reports to the team, rather than endorsing it.
+	 * `has_valid_pickup_location()` stops as soon as city, postcode and state are filled and never
+	 * asks the country whether it also wants a street line, so a US location saved without one is
+	 * treated as having a usable address.
+	 *
+	 * @testdox Today, a location with no street line still counts as having a usable address.
+	 */
+	public function test_records_that_a_missing_street_line_still_counts_as_usable(): void {
+		$this->pickup_configured_with(
+			array(
+				$this->location(
+					'Mall stand',
+					true,
+					array(
+						'address_1' => '',
+						'city'      => 'San Francisco',
+						'state'     => 'CA',
+						'postcode'  => '94105',
+						'country'   => 'US',
+					)
+				),
+			)
+		);
+
+		$meta = $this->single_offered_rate()->get_meta_data();
+
+		$this->assertStringContainsString( 'San Francisco', $meta['pickup_address'], 'Recorded behaviour: the address is treated as usable even though the US asks for a street line.' );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Shipping/PickupLocationsRestControllerTest.php b/plugins/woocommerce/tests/php/src/Blocks/Shipping/PickupLocationsRestControllerTest.php
index fa682edda2d..b209f60e831 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/Shipping/PickupLocationsRestControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/Shipping/PickupLocationsRestControllerTest.php
@@ -378,4 +378,50 @@ class PickupLocationsRestControllerTest extends WC_Unit_Test_Case {
 		$this->assertStringNotContainsString( 'alert(1)', $saved['cost'], 'Inline script payload must not survive cost sanitization.' );
 		$this->assertStringContainsString( '5 + 1.50', $saved['cost'], 'Math formula syntax must be preserved in cost — must not be coerced to float.' );
 	}
+
+	/**
+	 * Rate ids are the positions of the saved locations, so what a shopper's stored choice points
+	 * at depends on the array holding still. Switching a branch off through the settings screen
+	 * keeps it in the list, which is what keeps the branches either side of it where they were.
+	 *
+	 * @testdox Switching a location off leaves it in the list, so the others keep their positions.
+	 */
+	public function test_switching_a_location_off_keeps_the_list_in_place(): void {
+		wp_set_current_user( $this->shop_manager_id );
+
+		$branch = function ( string $name, bool $enabled ): array {
+			return array(
+				'name'    => $name,
+				'address' => array(
+					'address_1' => '1 Market St',
+					'city'      => 'San Francisco',
+					'state'     => 'CA',
+					'postcode'  => '94105',
+					'country'   => 'US',
+				),
+				'details' => '',
+				'enabled' => $enabled,
+			);
+		};
+
+		$request = new \WP_REST_Request( 'POST', '/wc/v3/pickup-locations' );
+		$request->set_param(
+			'pickup_locations',
+			array(
+				$branch( 'Downtown', true ),
+				$branch( 'Airport', false ),
+				$branch( 'Harbour', true ),
+			)
+		);
+
+		$this->sut->update_settings( $request );
+
+		$saved = get_option( 'pickup_location_pickup_locations' );
+
+		$this->assertCount( 3, $saved, 'A branch that was switched off is still a branch the merchant has.' );
+		$this->assertSame( 'Downtown', $saved[0]['name'], 'The branch before it should not have moved.' );
+		$this->assertSame( 'Airport', $saved[1]['name'], 'The closed branch should hold its own place.' );
+		$this->assertSame( 'Harbour', $saved[2]['name'], 'And the branch after it should not have moved up.' );
+		$this->assertFalse( $saved[1]['enabled'], 'It should be saved as closed rather than removed.' );
+	}
 }