You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

877 lines
26 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import type { Locator, Page } from "playwright";
import type { QuoteRequest } from "@/modules/providers/quote-provider";
import { pageLocator } from "@/lib/rpa/locator";
import { clickFirstVisibleFromSpecs } from "@/lib/rpa/selector-specs";
import { RpaError } from "@/modules/rpa/errors";
import type { RPAContext } from "@/workers/rpa/kernel/context";
import type { AxelSelectionBridge } from "@/workers/rpa/axel-selection-bridge";
import {
getAddressConfirmationSurfaceText,
isAddressInputCommitted,
isAddressSuggestionDropdownOpen,
waitForAddressSuggestionLocked,
} from "@/workers/rpa/address-suggestions";
import { visibleStreetInput } from "@/workers/rpa/address-side";
type AddressFields = QuoteRequest["pickup"];
const POLL_MS = 200;
const ADDRESS_SIDE_COMMIT_TIMEOUT_MS = 12_000;
const ADDRESS_INPUT_STABLE_MS = 2_000;
export type AddressCommitSnapshot = {
side: "pickup" | "delivery";
inputValues: string[];
surfaceText: string;
widgetTextPreview: string;
dropdownOpen: boolean;
axelLocationHit: boolean;
};
function toQueryHint(address: AddressFields) {
return {
street: address.street,
city: address.city ?? "",
state: address.state ?? "",
zip: address.zip ?? "",
};
}
function normalizeWidgetText(text: string): string {
return text
.replace(/\bavenue\b/gi, "ave")
.replace(/\bboulevard\b/gi, "blvd")
.replace(/\bstreet\b/gi, "st")
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
}
function hasWordToken(text: string, token: string): boolean {
const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`\\b${escaped}\\b`, "i").test(text);
}
/** widget 内是否体现用户确认的 MotherShip 地址(防 widget 回退) */
export function widgetContainsConfirmedAddress(
widgetText: string,
address: AddressFields,
): boolean {
const label = address.mothershipDisplayLabel?.trim();
if (!label) {
return false;
}
const w = normalizeWidgetText(widgetText);
const streetNum = address.street.match(/^(\d+)/)?.[1];
const labelNorm = normalizeWidgetText(label);
if (
streetNum &&
labelNorm.includes(streetNum.toLowerCase()) &&
!w.includes(streetNum.toLowerCase())
) {
return false;
}
const state = address.state?.trim();
if (state && !w.includes(state.toLowerCase())) {
return false;
}
const city = address.city?.trim().toLowerCase();
if (city && city.length >= 3 && !w.includes(city)) {
const cityParts = city.split(/\s+/).filter((part) => part.length >= 2);
if (cityParts.length > 1) {
const allPartsPresent = cityParts.every((part) =>
hasWordToken(widgetText, part),
);
if (!allPartsPresent) {
return false;
}
} else {
const streetCore = normalizeWidgetText(address.street);
if (!streetCore || !w.includes(streetCore)) {
return false;
}
}
}
const labelTokens = label
.replace(/([a-z])([A-Z])/g, "$1 $2")
.split(/[,\s]+/)
.map((t) => t.toLowerCase())
.filter((t) => t.length > 3);
const streetCore = normalizeWidgetText(address.street);
if (
streetCore &&
!/\bbridge\b/.test(streetCore) &&
/\bbridge\b/.test(w) &&
w.includes(streetCore.split(/\s+/)[0] ?? "")
) {
return false;
}
return labelTokens.some((t) => w.includes(t));
}
async function readCommittedInputSurface(
page: Page,
widgetSelector: string,
): Promise<string> {
return page.evaluate(
`(function(sel) {
var widget = document.querySelector(sel);
if (!widget) return "";
var chunks = [];
var inputs = widget.querySelectorAll("input");
for (var i = 0; i < inputs.length; i++) {
var val = (inputs[i].value || "").replace(/\\s+/g, " ").trim();
if (val.length >= 8) chunks.push(val);
}
return chunks.join(" ");
})(${JSON.stringify(widgetSelector)})`,
) as Promise<string>;
}
async function readStreetInputValues(
page: Page,
widgetSelector: string,
): Promise<string[]> {
return page.evaluate(
`(function(sel) {
var widget = document.querySelector(sel);
if (!widget) return [];
var out = [];
var inputs = widget.querySelectorAll("input");
for (var i = 0; i < inputs.length; i++) {
var ph = (inputs[i].placeholder || "").toLowerCase();
if (!/search|搜索/.test(ph)) continue;
var val = (inputs[i].value || "").replace(/\\s+/g, " ").trim();
if (val) out.push(val);
}
return out;
})(${JSON.stringify(widgetSelector)})`,
) as Promise<string[]>;
}
export async function readWidgetCombinedText(
page: Page,
widgetSelector: string,
): Promise<string> {
const surfaceText = await getAddressConfirmationSurfaceText(
page,
widgetSelector,
);
const fullText = await pageLocator(page, widgetSelector)
.innerText()
.catch(() => "");
return [surfaceText, fullText]
.map((part) => part.replace(/\s+/g, " ").trim())
.filter(Boolean)
.join(" ");
}
export async function captureAddressCommitSnapshot(
ctx: RPAContext,
side: "pickup" | "delivery",
widgetSelector: string,
axelLocationHit = false,
): Promise<AddressCommitSnapshot> {
const widgetText = await readWidgetCombinedText(ctx.page, widgetSelector);
return {
side,
inputValues: await readStreetInputValues(ctx.page, widgetSelector),
surfaceText: await getAddressConfirmationSurfaceText(
ctx.page,
widgetSelector,
),
widgetTextPreview: widgetText.slice(0, 220),
dropdownOpen: await isAddressSuggestionDropdownOpen(ctx.page),
axelLocationHit,
};
}
export function logAddressCommitSnapshot(
phase: string,
snapshot: AddressCommitSnapshot,
): void {
console.log(
`[rpa] address-commit:${phase} side=${snapshot.side} inputs=${JSON.stringify(snapshot.inputValues)} dropdown=${snapshot.dropdownOpen} axelLocation=${snapshot.axelLocationHit} widget=${snapshot.widgetTextPreview}`,
);
}
export async function dismissAddressSuggestionDropdown(
page: Page,
streetInput?: Locator | null,
): Promise<boolean> {
const deadline = Date.now() + 4_000;
while (Date.now() < deadline) {
if (!(await isAddressSuggestionDropdownOpen(page))) {
return true;
}
if (streetInput) {
await streetInput.press("Enter").catch(() => undefined);
}
await page.keyboard.press("Escape").catch(() => undefined);
await page.keyboard.press("Tab").catch(() => undefined);
await page.waitForTimeout(POLL_MS);
}
return !(await isAddressSuggestionDropdownOpen(page));
}
/** 强制收起联想(仅键盘,避免误点 Use my location / 重置 widget */
export async function forceDismissAddressSuggestionDropdown(
page: Page,
_widgetSelector: string,
): Promise<void> {
for (let i = 0; i < 8; i += 1) {
if (!(await isAddressSuggestionDropdownOpen(page))) {
return;
}
await page.keyboard.press("Escape").catch(() => undefined);
await page.waitForTimeout(POLL_MS);
}
for (let i = 0; i < 3; i += 1) {
await page.keyboard.press("Tab").catch(() => undefined);
await page.waitForTimeout(POLL_MS);
if (!(await isAddressSuggestionDropdownOpen(page))) {
return;
}
}
}
async function readAddressSurfaceForCommit(
page: Page,
widgetSelector: string,
dropdownOpen: boolean,
): Promise<string> {
const surfaceText = await getAddressConfirmationSurfaceText(
page,
widgetSelector,
);
if (dropdownOpen) {
return surfaceText;
}
return readWidgetCombinedText(page, widgetSelector);
}
async function waitForInputSurfaceStable(
page: Page,
widgetSelector: string,
address: AddressFields,
stableMs: number,
timeoutMs: number,
): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
let lastSurface = "";
let stableSince = 0;
while (Date.now() < deadline) {
const surface = await getAddressConfirmationSurfaceText(page, widgetSelector);
const now = Date.now();
if (widgetContainsConfirmedAddress(surface, address)) {
if (surface === lastSurface) {
if (stableSince === 0) {
stableSince = now;
}
if (now - stableSince >= stableMs) {
return true;
}
} else {
lastSurface = surface;
stableSince = now;
}
} else {
lastSurface = "";
stableSince = 0;
}
await page.waitForTimeout(POLL_MS);
}
return false;
}
async function waitForInputValueStable(
input: Locator,
address: AddressFields,
stableMs: number,
timeoutMs: number,
): Promise<boolean> {
const queryHint = toQueryHint(address);
const deadline = Date.now() + timeoutMs;
let lastValue = "";
let stableSince = 0;
while (Date.now() < deadline) {
const value = ((await input.inputValue().catch(() => "")) || "")
.replace(/\s+/g, " ")
.trim();
const committed = await isAddressInputCommitted(input, queryHint);
const now = Date.now();
if (value.length >= 8 && committed && value === lastValue) {
if (stableSince === 0) {
stableSince = now;
}
if (now - stableSince >= stableMs) {
return true;
}
} else {
lastValue = value;
stableSince = committed && value.length >= 8 ? now : 0;
}
await input.page().waitForTimeout(POLL_MS);
}
return false;
}
/** 收起当前地址编辑焦点,避免双地址 focus 竞争 */
export async function blurAddressEditing(
ctx: RPAContext,
widgetSelector: string,
): Promise<void> {
const page = ctx.page;
await page.keyboard.press("Escape").catch(() => undefined);
await page.keyboard.press("Tab").catch(() => undefined);
const deadline = Date.now() + 3_000;
while (Date.now() < deadline) {
if (!(await isAddressSuggestionDropdownOpen(page))) {
return;
}
await page.keyboard.press("Escape").catch(() => undefined);
await page.waitForTimeout(POLL_MS);
}
}
/** pickup 提交后、打开 delivery 前:锁定提货侧,防止 React 未 commit 即切换 */
export async function settlePickupBeforeDelivery(
ctx: RPAContext,
widgetSelector: string,
pickup: AddressFields,
): Promise<void> {
await blurAddressEditing(ctx, widgetSelector);
await dismissAddressSuggestionDropdown(ctx.page);
await forceDismissAddressSuggestionDropdown(ctx.page, widgetSelector);
await ctx.page.keyboard.press("Tab").catch(() => undefined);
await ctx.page.waitForTimeout(POLL_MS);
const deadline = Date.now() + ADDRESS_SIDE_COMMIT_TIMEOUT_MS;
while (Date.now() < deadline) {
if (await isAddressPresentInWidget(ctx.page, widgetSelector, pickup)) {
break;
}
await ctx.page.waitForTimeout(POLL_MS);
}
if (!(await isAddressPresentInWidget(ctx.page, widgetSelector, pickup))) {
throw new RpaError(
"ADDRESS_NOT_CONFIRMED",
`提货地址未写入 widget${pickup.mothershipDisplayLabel}`,
{ retryable: true },
);
}
while (Date.now() < deadline) {
if (await isPickupReadyForDeliveryInput(ctx.page, widgetSelector, pickup)) {
break;
}
await releasePickupFieldForDelivery(ctx, widgetSelector, pickup);
await ctx.page.waitForTimeout(POLL_MS);
}
const snapshot = await captureAddressCommitSnapshot(
ctx,
"pickup",
widgetSelector,
);
logAddressCommitSnapshot("pickup-settled", snapshot);
if (!(await isPickupReadyForDeliveryInput(ctx.page, widgetSelector, pickup))) {
const diag = await captureAddressCommitSnapshot(ctx, "pickup", widgetSelector);
console.log("[rpa] pickup-commit-diagnostic", {
phase: "settlePickupBeforeDelivery",
placeId: pickup.placeId,
mothershipDisplayLabel: pickup.mothershipDisplayLabel,
commitState: "widget_not_locked",
placeRequestObserved: diag.axelLocationHit,
inputValues: diag.inputValues,
dropdownOpen: diag.dropdownOpen,
});
throw new RpaError(
"ADDRESS_NOT_CONFIRMED",
`提货地址未锁定,无法打开派送侧(${pickup.mothershipDisplayLabel}`,
{ retryable: true },
);
}
}
/** 等待单侧地址被 MotherShip widget 接受(须 place/{id} 200禁止仅以 input.value 判定) */
export async function waitForAddressSideCommit(
ctx: RPAContext,
side: "pickup" | "delivery",
address: AddressFields,
widgetSelector: string,
streetInput?: Locator | null,
placeCommit?: { bridge: AxelSelectionBridge; placeId: string },
): Promise<void> {
const page = ctx.page;
const placeId = placeCommit?.placeId?.trim();
const bridge = placeCommit?.bridge;
if (placeId && bridge) {
if (bridge.hasPlaceCommit(placeId)) {
const snapshot = await captureAddressCommitSnapshot(
ctx,
side,
widgetSelector,
true,
);
logAddressCommitSnapshot(`${side}-committed`, snapshot);
return;
}
const committed = await bridge.waitForPlaceCommit(
placeId,
ADDRESS_SIDE_COMMIT_TIMEOUT_MS,
);
if (committed) {
const snapshot = await captureAddressCommitSnapshot(
ctx,
side,
widgetSelector,
true,
);
logAddressCommitSnapshot(`${side}-committed`, snapshot);
return;
}
const snapshot = await captureAddressCommitSnapshot(
ctx,
side,
widgetSelector,
false,
);
logAddressCommitSnapshot(`${side}-commit-failed`, snapshot);
throw new RpaError(
"ADDRESS_NOT_CONFIRMED",
`${side === "pickup" ? "提货" : "派送"}地址 place/${placeId} 未 commit网络证据缺失`,
{ retryable: true },
);
}
const queryHint = toQueryHint(address);
const deadline = Date.now() + ADDRESS_SIDE_COMMIT_TIMEOUT_MS;
let consecutiveSurfaceMatches = 0;
while (Date.now() < deadline) {
const dropdownOpen = await isAddressSuggestionDropdownOpen(page);
if (dropdownOpen) {
await dismissAddressSuggestionDropdown(page, streetInput ?? undefined);
}
const surface = await readCommittedInputSurface(page, widgetSelector);
const surfaceOk = widgetContainsConfirmedAddress(surface, address);
if (surfaceOk) {
consecutiveSurfaceMatches += 1;
if (consecutiveSurfaceMatches * POLL_MS >= ADDRESS_INPUT_STABLE_MS) {
const snapshot = await captureAddressCommitSnapshot(
ctx,
side,
widgetSelector,
false,
);
logAddressCommitSnapshot(`${side}-committed`, snapshot);
return;
}
} else {
consecutiveSurfaceMatches = 0;
}
if (!surfaceOk && !dropdownOpen) {
const widgetText = await readWidgetCombinedText(page, widgetSelector);
if (widgetContainsConfirmedAddress(widgetText, address)) {
const snapshot = await captureAddressCommitSnapshot(
ctx,
side,
widgetSelector,
false,
);
logAddressCommitSnapshot(`${side}-committed`, snapshot);
return;
}
}
if (streetInput) {
const inputVisible = await streetInput.isVisible().catch(() => false);
const inputEditable = await streetInput.isEditable().catch(() => false);
if (inputVisible && inputEditable && (await isAddressInputCommitted(streetInput, queryHint))) {
const snapshot = await captureAddressCommitSnapshot(
ctx,
side,
widgetSelector,
false,
);
logAddressCommitSnapshot(`${side}-committed`, snapshot);
return;
}
}
await page.waitForTimeout(POLL_MS);
}
const snapshot = await captureAddressCommitSnapshot(
ctx,
side,
widgetSelector,
false,
);
logAddressCommitSnapshot(`${side}-commit-failed`, snapshot);
throw new RpaError(
"ADDRESS_NOT_CONFIRMED",
`${side === "pickup" ? "提货" : "派送"}地址未提交到 widget${snapshot.widgetTextPreview.slice(0, 100)}`,
{ retryable: true },
);
}
/** 双地址快照校验delivery 为 null 时仅校验 pickup 已锁定(非仅 input 文本回显) */
export async function assertDualAddressStable(
ctx: RPAContext,
widgetSelector: string,
pickup: AddressFields,
delivery: AddressFields | null,
): Promise<void> {
const page = ctx.page;
const deadline = Date.now() + ADDRESS_SIDE_COMMIT_TIMEOUT_MS;
let lastPickupOk = false;
let lastDeliveryOk = delivery === null;
while (Date.now() < deadline) {
if (delivery === null) {
lastPickupOk = await isPickupReadyForDeliveryInput(
page,
widgetSelector,
pickup,
);
} else {
const surface = await readCommittedInputSurface(page, widgetSelector);
const full = await readWidgetCombinedText(page, widgetSelector);
const combined = `${surface} ${full}`;
lastPickupOk =
widgetContainsConfirmedAddress(surface, pickup) ||
widgetContainsConfirmedAddress(full, pickup) ||
widgetContainsConfirmedAddress(combined, pickup);
lastDeliveryOk =
widgetContainsConfirmedAddress(surface, delivery) ||
widgetContainsConfirmedAddress(full, delivery) ||
widgetContainsConfirmedAddress(combined, delivery);
}
if (lastPickupOk && lastDeliveryOk) {
const snapshot = await captureAddressCommitSnapshot(
ctx,
"pickup",
widgetSelector,
);
logAddressCommitSnapshot("dual-stable", {
...snapshot,
side: "pickup",
widgetTextPreview: snapshot.widgetTextPreview.slice(0, 220),
});
return;
}
if (delivery && lastPickupOk === false) {
await blurAddressEditing(ctx, widgetSelector);
const pickupSections = ctx.selectors.specs("RPA_SELECTOR_PICKUP_SECTION");
if (pickupSections.length > 0) {
await clickFirstVisibleFromSpecs(page, pickupSections).catch(
() => undefined,
);
}
}
await page.waitForTimeout(POLL_MS);
}
const widgetText = await readWidgetCombinedText(page, widgetSelector);
if (!lastPickupOk) {
throw new RpaError(
"ADDRESS_NOT_CONFIRMED",
`双地址校验失败:提货地址已从 widget 消失(${pickup.mothershipDisplayLabel}preview=${widgetText.slice(0, 120)}`,
{ retryable: true },
);
}
if (delivery && !lastDeliveryOk) {
throw new RpaError(
"ADDRESS_NOT_CONFIRMED",
`双地址校验失败:派送地址未锁定(${delivery.mothershipDisplayLabel}preview=${widgetText.slice(0, 120)}`,
{ retryable: true },
);
}
}
export async function isAddressPresentInWidget(
page: Page,
widgetSelector: string,
address: AddressFields,
): Promise<boolean> {
const surface = await readCommittedInputSurface(page, widgetSelector);
const full = await readWidgetCombinedText(page, widgetSelector);
return widgetContainsConfirmedAddress(`${surface} ${full}`, address);
}
/** 纯函数widget 文案是否已体现双地址(截图态:两州码 / 两门牌 / Freight details 旁双址) */
export function evaluateAddressesVisuallyComplete(widgetText: string): boolean {
const normalized = widgetText.replace(/\s+/g, " ").trim();
if (!normalized) {
return false;
}
const stateCount = (normalized.match(/,\s*[A-Z]{2}\b/g) ?? []).length;
const streetMarkers = normalized.match(/\d{3,5}\s+\S+/g) ?? [];
const hasFreightChip = /freight details|货运详情/i.test(normalized);
if (stateCount >= 2) {
return true;
}
if (streetMarkers.length >= 2) {
return true;
}
if (hasFreightChip && (stateCount >= 1 || streetMarkers.length >= 1)) {
const hasDeliverTo = /deliver to|送货至/i.test(normalized);
if (hasDeliverTo || stateCount >= 1) {
return true;
}
}
return false;
}
/**
* 视觉优先:双地址已在 widget/地图体现即可进入货物步(不等 place 网络 / 下拉收起)。
*/
export async function isAddressesVisuallyCompleteForCargo(
page: Page,
widgetSelector: string,
pickup?: AddressFields,
delivery?: AddressFields,
): Promise<boolean> {
const widgetText = await readWidgetCombinedText(page, widgetSelector).catch(
() => "",
);
const surface = await readCommittedInputSurface(page, widgetSelector).catch(
() => "",
);
const combined = `${surface} ${widgetText}`.replace(/\s+/g, " ").trim();
if (pickup && delivery) {
const pickupOk = widgetContainsConfirmedAddress(combined, pickup);
const deliveryOk = widgetContainsConfirmedAddress(combined, delivery);
if (pickupOk && deliveryOk) {
return true;
}
}
if (evaluateAddressesVisuallyComplete(combined)) {
return true;
}
return page.evaluate(() => {
const body = (document.body.innerText || "").replace(/\s+/g, " ");
const stateHits = body.match(/,\s*[A-Z]{2}\b/g) ?? [];
if (stateHits.length < 2) {
return false;
}
const hasMap =
document.querySelector("canvas") !== null ||
document.querySelector("svg") !== null ||
document.querySelector('[class*="map" i]') !== null;
return hasMap;
});
}
async function hasEmptyEditableStreetInput(
page: Page,
widgetSelector: string,
): Promise<boolean> {
return page.evaluate(
`(function(sel) {
var widget = document.querySelector(sel);
if (!widget) return false;
var inputs = widget.querySelectorAll("input");
for (var i = 0; i < inputs.length; i++) {
var ph = (inputs[i].placeholder || "").toLowerCase();
if (!/search|搜索/.test(ph)) continue;
if (inputs[i].offsetParent === null) continue;
var val = (inputs[i].value || "").replace(/\\s+/g, " ").trim();
var editable =
!inputs[i].disabled &&
inputs[i].readOnly === false &&
inputs[i].getAttribute("aria-readonly") !== "true";
if (editable && val.length < 8) return true;
}
return false;
})(${JSON.stringify(widgetSelector)})`,
) as Promise<boolean>;
}
/** 提货已写入 widget 且派送侧可独立输入(非同一搜索框覆盖) */
export async function isPickupReadyForDeliveryInput(
page: Page,
widgetSelector: string,
pickup: AddressFields,
): Promise<boolean> {
await forceDismissAddressSuggestionDropdown(page, widgetSelector);
const values = await readStreetInputValues(page, widgetSelector);
const surface = await readCommittedInputSurface(page, widgetSelector);
const full = await readWidgetCombinedText(page, widgetSelector);
const combined = `${surface} ${full}`;
if (!widgetContainsConfirmedAddress(combined, pickup)) {
return false;
}
if (values.length >= 2) {
return true;
}
if (await hasEmptyEditableStreetInput(page, widgetSelector)) {
return true;
}
if (values.length === 1) {
const only = values[0]!.replace(/\s+/g, " ").trim().toLowerCase();
const streetNum = pickup.street.match(/^\d+/)?.[0]?.toLowerCase();
if (streetNum && only.includes(streetNum) && only.length >= 8) {
return false;
}
if (only.length >= 8) {
const deliverVisible = await pageLocator(page, widgetSelector)
.getByText(/^Deliver to$/i)
.first()
.isVisible()
.catch(() => false);
if (deliverVisible && widgetContainsConfirmedAddress(combined, pickup)) {
return true;
}
return false;
}
return only.length < 8;
}
// chip 态inputs 为空但 widget 已含提货且派送框可编辑
return values.length === 0 && (await hasEmptyEditableStreetInput(page, widgetSelector));
}
/** 提货点选后Enter/Tab 收起联想再点「Deliver to」释放派送框 */
export async function lockPickupIntoChip(
ctx: RPAContext,
widgetSelector: string,
streetInput?: Locator | null,
): Promise<void> {
const page = ctx.page;
const widget = pageLocator(page, widgetSelector);
await dismissAddressSuggestionDropdown(page);
if (streetInput) {
await streetInput.press("Enter").catch(() => undefined);
await page.waitForTimeout(250);
}
await page.keyboard.press("Tab").catch(() => undefined);
await page.waitForTimeout(200);
await forceDismissAddressSuggestionDropdown(page, widgetSelector);
const deliverTo = widget.getByText(/^Deliver to$/i).first();
if (await deliverTo.isVisible().catch(() => false)) {
await deliverTo.click({ force: true, timeout: 3_000 }).catch(() => undefined);
} else {
await clickFirstVisibleFromSpecs(
page,
ctx.selectors.specs("RPA_SELECTOR_DELIVERY_SECTION"),
).catch(() => undefined);
}
await page.waitForTimeout(400);
await dismissAddressSuggestionDropdown(page);
}
/** 点选提货后:收起下拉并打开派送侧,释放独立搜索框 */
export async function releasePickupFieldForDelivery(
ctx: RPAContext,
widgetSelector: string,
pickup: AddressFields,
streetInput?: Locator | null,
): Promise<boolean> {
const page = ctx.page;
if (await isPickupReadyForDeliveryInput(page, widgetSelector, pickup)) {
return true;
}
const pickupInput =
streetInput ?? (await visibleStreetInput(ctx, "pickup"));
await lockPickupIntoChip(ctx, widgetSelector, pickupInput ?? undefined);
return isPickupReadyForDeliveryInput(page, widgetSelector, pickup);
}
/** delivery 提交后若 pickup 被覆盖:交替 refill 直到双地址均出现在 widget */
export async function ensureDualAddressCommitted(
ctx: RPAContext,
widgetSelector: string,
pickup: AddressFields,
delivery: AddressFields,
refillSide: (
side: "pickup" | "delivery",
address: AddressFields,
) => Promise<void>,
): Promise<void> {
const page = ctx.page;
const MAX_REFILL_ROUNDS = 6;
for (let round = 0; round < MAX_REFILL_ROUNDS; round += 1) {
await dismissAddressSuggestionDropdown(page);
await blurAddressEditing(ctx, widgetSelector);
const pickupOk = await isAddressPresentInWidget(
page,
widgetSelector,
pickup,
);
const deliveryOk = await isAddressPresentInWidget(
page,
widgetSelector,
delivery,
);
if (pickupOk && deliveryOk) {
break;
}
if (!pickupOk) {
console.log(
`[rpa] address-commit:pickup-missing refill round=${round + 1}`,
);
await refillSide("pickup", pickup);
continue;
}
if (!deliveryOk) {
console.log(
`[rpa] address-commit:delivery-missing refill round=${round + 1}`,
);
await refillSide("delivery", delivery);
continue;
}
}
await assertDualAddressStable(ctx, widgetSelector, pickup, delivery);
}
export async function waitForPickupCommit(
ctx: RPAContext,
pickup: AddressFields,
widgetSelector: string,
streetInput?: Locator | null,
): Promise<void> {
await waitForAddressSideCommit(
ctx,
"pickup",
pickup,
widgetSelector,
streetInput,
);
}