|
|
/**
|
|
|
* Priority1 表单交互实现 — 与 form-logic-map.ts 字段定义一一对应
|
|
|
* form-logic-map 描述「填什么」;本文件描述「怎么点/怎么选」
|
|
|
*
|
|
|
* 录制参考:.rpa/recordings/priority1-*.js
|
|
|
* - Step1 FTL: #cb_st_ftl_sel(回退 #cb_st_ftl)
|
|
|
* - Open Deck Type: select 选项如 48 Foot Flatbed / 48 Foot Step Deck
|
|
|
* - 温控: #minimum-temperature / #maximum-temperature
|
|
|
* - FTL 附加: #cb_as_ftl1 / #cb_as_ftl2 / #cb_as_ftl3
|
|
|
*/
|
|
|
|
|
|
import type { Locator, Page } from "playwright";
|
|
|
|
|
|
const CLICK = { force: true, noWaitAfter: true as const };
|
|
|
|
|
|
const OPEN_DECK_OPTION_RE =
|
|
|
/Foot Flatbed|Foot Step Deck|Removable Gooseneck|Double Drop|Conestoga|Hot Shot/i;
|
|
|
const EXCLUDED_SELECT_ID_RE =
|
|
|
/PickupLocation|DropoffLocation|freight-shipment|freight-package|freight-type/i;
|
|
|
/** F04 人工录制:Open Deck Type 在 Feathery 误命名 select #PickupLocation3 */
|
|
|
export const OPEN_DECK_TYPE_SELECT_SELECTOR = "#PickupLocation3";
|
|
|
|
|
|
/** 逻辑子类型 → 页面 option 文本模糊匹配 */
|
|
|
export function normalizeOpenDeckSubtypeQuery(subtype: string): string {
|
|
|
const s = subtype.trim();
|
|
|
if (/Foot Flatbed|Foot Step Deck|Removable Gooseneck|Double Drop|Conestoga|Hot Shot/i.test(s)) {
|
|
|
return s;
|
|
|
}
|
|
|
const aliases: Record<string, string> = {
|
|
|
Flatbed: "Foot Flatbed",
|
|
|
"Step Deck": "Foot Step Deck",
|
|
|
"Goose Neck RGN": "Removable Gooseneck",
|
|
|
Gooseneck: "Removable Gooseneck",
|
|
|
};
|
|
|
for (const [k, v] of Object.entries(aliases)) {
|
|
|
if (s === k) return v;
|
|
|
}
|
|
|
return s;
|
|
|
}
|
|
|
|
|
|
function optionMatchesSubtype(optionText: string, subtype: string): boolean {
|
|
|
const opt = optionText.trim().toLowerCase();
|
|
|
const sub = subtype.trim().toLowerCase();
|
|
|
const normalized = normalizeOpenDeckSubtypeQuery(subtype).toLowerCase();
|
|
|
return (
|
|
|
opt === sub ||
|
|
|
opt.includes(sub) ||
|
|
|
sub.includes(opt) ||
|
|
|
opt.includes(normalized) ||
|
|
|
normalized.includes(opt)
|
|
|
);
|
|
|
}
|
|
|
|
|
|
export async function waitForOpenDeckSubtypeUi(page: Page): Promise<void> {
|
|
|
const started = Date.now();
|
|
|
while (Date.now() - started < 25_000) {
|
|
|
const ready = await page.evaluate(function (args) {
|
|
|
const optionRe = new RegExp(args.optionReSource, "i");
|
|
|
const excludeRe = new RegExp(args.excludeReSource, "i");
|
|
|
const container = document.querySelector("#container-mh");
|
|
|
if (!container) return false;
|
|
|
const deckSel = container.querySelector("#PickupLocation3");
|
|
|
if (deckSel instanceof HTMLSelectElement && deckSel.options.length >= 2) return true;
|
|
|
const selects = container.querySelectorAll("select");
|
|
|
for (let s = 0; s < selects.length; s += 1) {
|
|
|
const el = selects[s];
|
|
|
let deckOptions = 0;
|
|
|
for (let j = 0; j < el.options.length; j += 1) {
|
|
|
if (optionRe.test(el.options[j].text.trim())) deckOptions += 1;
|
|
|
}
|
|
|
if (excludeRe.test(el.id) && deckOptions < 2) continue;
|
|
|
if (deckOptions >= 2) return true;
|
|
|
}
|
|
|
return /Open Deck Type|Foot Flatbed|Foot Step Deck/i.test(
|
|
|
container.textContent || "",
|
|
|
);
|
|
|
}, {
|
|
|
optionReSource: OPEN_DECK_OPTION_RE.source,
|
|
|
excludeReSource: EXCLUDED_SELECT_ID_RE.source,
|
|
|
});
|
|
|
if (ready) return;
|
|
|
await page.waitForTimeout(400);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
export async function waitForFeatheryCheckbox(
|
|
|
page: Page,
|
|
|
selector: string,
|
|
|
timeoutMs = 25_000,
|
|
|
): Promise<void> {
|
|
|
await page
|
|
|
.locator(`#container-mh ${selector}`)
|
|
|
.first()
|
|
|
.waitFor({ state: "attached", timeout: timeoutMs });
|
|
|
await page.waitForTimeout(600);
|
|
|
}
|
|
|
|
|
|
/** Feathery 隐藏 checkbox — DOM 强制勾选并派发事件(避免 headless click 无效) */
|
|
|
export async function forceFeatheryCheckbox(
|
|
|
page: Page,
|
|
|
selector: string,
|
|
|
): Promise<boolean> {
|
|
|
return page.evaluate(function (sel) {
|
|
|
const root = document.querySelector("#container-mh");
|
|
|
if (!root) return false;
|
|
|
const cb = root.querySelector(sel);
|
|
|
if (!(cb instanceof HTMLInputElement) || cb.type !== "checkbox") return false;
|
|
|
if (!cb.checked) {
|
|
|
cb.checked = true;
|
|
|
cb.dispatchEvent(new Event("input", { bubbles: true }));
|
|
|
cb.dispatchEvent(new Event("change", { bubbles: true }));
|
|
|
cb.dispatchEvent(new Event("click", { bubbles: true }));
|
|
|
}
|
|
|
return cb.checked;
|
|
|
}, selector);
|
|
|
}
|
|
|
|
|
|
export function freightClassOptionMatches(
|
|
|
optionText: string,
|
|
|
expected: string,
|
|
|
): boolean {
|
|
|
const t = optionText.trim().toLowerCase();
|
|
|
const w = expected.trim().toLowerCase();
|
|
|
if (t === w || optionText.trim() === expected.trim()) return true;
|
|
|
if (w === "not sure" && t.includes("not sure")) return true;
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
/** form-logic-map → FTL_CONDITIONAL_BRANCHES.ftl_open_deck.openDeckSubtype */
|
|
|
export async function selectOpenDeckSubtype(
|
|
|
page: Page,
|
|
|
subtype: string,
|
|
|
): Promise<{ locator: Locator; selectedText: string }> {
|
|
|
await page.waitForTimeout(1200);
|
|
|
await waitForOpenDeckSubtypeUi(page);
|
|
|
|
|
|
const query = normalizeOpenDeckSubtypeQuery(subtype);
|
|
|
const deckLoc = page.locator(`#container-mh ${OPEN_DECK_TYPE_SELECT_SELECTOR}`).first();
|
|
|
if (await deckLoc.count()) {
|
|
|
await deckLoc.selectOption({ label: query }).catch(() =>
|
|
|
deckLoc.selectOption({ label: subtype.trim() }),
|
|
|
);
|
|
|
await page.waitForTimeout(400);
|
|
|
const selectedText = await readSelectVisibleText(deckLoc);
|
|
|
if (optionMatchesSubtype(selectedText, subtype)) {
|
|
|
console.log(
|
|
|
`[form-interactions] Open Deck Type → ${selectedText} (${OPEN_DECK_TYPE_SELECT_SELECTOR})`,
|
|
|
);
|
|
|
return { locator: deckLoc, selectedText };
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const picked = await page.evaluate(
|
|
|
function (args) {
|
|
|
const excludeRe = new RegExp(args.excludeReSource, "i");
|
|
|
const optionRe = new RegExp(args.optionReSource, "i");
|
|
|
const container = document.querySelector("#container-mh");
|
|
|
if (!container) return null;
|
|
|
|
|
|
const selects = container.querySelectorAll("select");
|
|
|
for (let s = 0; s < selects.length; s += 1) {
|
|
|
const sel = selects[s];
|
|
|
let deckOptions = 0;
|
|
|
for (let j = 0; j < sel.options.length; j += 1) {
|
|
|
if (optionRe.test(sel.options[j].text.trim())) deckOptions += 1;
|
|
|
}
|
|
|
if (excludeRe.test(sel.id) && deckOptions < 2) continue;
|
|
|
for (let i = 0; i < sel.options.length; i += 1) {
|
|
|
const opt = sel.options[i];
|
|
|
const optText = opt.text.trim().toLowerCase();
|
|
|
const needle = args.sub.trim().toLowerCase();
|
|
|
if (optText.indexOf(needle) < 0 && needle.indexOf(optText) < 0) continue;
|
|
|
sel.selectedIndex = i;
|
|
|
sel.value = opt.value;
|
|
|
sel.dispatchEvent(new Event("input", { bubbles: true }));
|
|
|
sel.dispatchEvent(new Event("change", { bubbles: true }));
|
|
|
return { id: sel.id, text: opt.text.trim(), index: s };
|
|
|
}
|
|
|
}
|
|
|
return null;
|
|
|
},
|
|
|
{
|
|
|
sub: query,
|
|
|
excludeReSource: EXCLUDED_SELECT_ID_RE.source,
|
|
|
optionReSource: OPEN_DECK_OPTION_RE.source,
|
|
|
},
|
|
|
);
|
|
|
|
|
|
if (picked) {
|
|
|
console.log(
|
|
|
`[form-interactions] Open Deck Type → ${picked.text} (#${picked.id || picked.index})`,
|
|
|
);
|
|
|
const locator = picked.id
|
|
|
? page.locator(`#container-mh #${picked.id}`)
|
|
|
: page.locator("#container-mh select").nth(picked.index);
|
|
|
return { locator, selectedText: picked.text };
|
|
|
}
|
|
|
|
|
|
throw new Error(`Open Deck Type 未找到选项: ${subtype}`);
|
|
|
}
|
|
|
|
|
|
/** form-logic-map → FTL_CONDITIONAL_BRANCHES.ftl_temp */
|
|
|
export async function fillTemperatureRange(
|
|
|
page: Page,
|
|
|
minF: string,
|
|
|
maxF: string,
|
|
|
): Promise<{ min: Locator; max: Locator }> {
|
|
|
const root = page.locator("#container-mh");
|
|
|
const min = root.locator("#minimum-temperature").first();
|
|
|
const max = root.locator("#maximum-temperature").first();
|
|
|
await min.waitFor({ state: "visible", timeout: 15_000 });
|
|
|
await min.fill("");
|
|
|
await min.pressSequentially(minF, { delay: 30 });
|
|
|
await min.evaluate(function (el) {
|
|
|
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
|
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
|
});
|
|
|
await max.fill("");
|
|
|
await max.pressSequentially(maxF, { delay: 30 });
|
|
|
await max.evaluate(function (el) {
|
|
|
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
|
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
|
});
|
|
|
return { min, max };
|
|
|
}
|
|
|
|
|
|
/** Feathery 隐藏 checkbox 是否已勾选(避免 locator.isChecked 90s 超时) */
|
|
|
export async function isFeatheryCheckboxChecked(
|
|
|
page: Page,
|
|
|
selector: string,
|
|
|
): Promise<boolean> {
|
|
|
return page.evaluate(function (sel) {
|
|
|
const root = document.querySelector("#container-mh");
|
|
|
if (!root) return false;
|
|
|
const cb = root.querySelector(sel);
|
|
|
return cb instanceof HTMLInputElement && cb.type === "checkbox" && cb.checked;
|
|
|
}, selector);
|
|
|
}
|
|
|
|
|
|
/** Feathery 卡片黑底高亮 = 已选 */
|
|
|
export async function isFeatheryCardSelected(
|
|
|
page: Page,
|
|
|
label: string,
|
|
|
): Promise<boolean> {
|
|
|
return page.evaluate(function (labelText) {
|
|
|
const nodes = document.querySelectorAll("#container-mh *");
|
|
|
for (let i = 0; i < nodes.length; i += 1) {
|
|
|
const el = nodes[i];
|
|
|
const text = (el.textContent || "").trim();
|
|
|
if (!text.startsWith(labelText) || text.length > 220) continue;
|
|
|
let cur: Element | null = el;
|
|
|
for (let d = 0; d < 6 && cur; d += 1) {
|
|
|
const bg = getComputedStyle(cur).backgroundColor;
|
|
|
if (bg === "rgb(0, 0, 0)" || bg === "black") return true;
|
|
|
cur = cur.parentElement;
|
|
|
}
|
|
|
}
|
|
|
return false;
|
|
|
}, label);
|
|
|
}
|
|
|
|
|
|
/** form-logic-map → FTL_ADDITIONAL_FIELDS */
|
|
|
export async function selectFtlAdditionalService(
|
|
|
page: Page,
|
|
|
selector: string,
|
|
|
label: string,
|
|
|
): Promise<boolean> {
|
|
|
const root = page.locator("#container-mh");
|
|
|
await root
|
|
|
.getByText(/Additional Services/i)
|
|
|
.first()
|
|
|
.scrollIntoViewIfNeeded()
|
|
|
.catch(() => undefined);
|
|
|
await page.waitForTimeout(400);
|
|
|
|
|
|
const cb = root.locator(selector).first();
|
|
|
const labelRe = new RegExp(`^${label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "i");
|
|
|
|
|
|
if (
|
|
|
(await isFeatheryCheckboxChecked(page, selector)) ||
|
|
|
(await isFeatheryCardSelected(page, label))
|
|
|
) {
|
|
|
return true;
|
|
|
}
|
|
|
|
|
|
/** 人工录制 F01:getByText("Full FTL") 点击 Feathery 卡片 */
|
|
|
const textHit = root.getByText(label, { exact: true }).first();
|
|
|
if ((await textHit.count()) > 0) {
|
|
|
await textHit.scrollIntoViewIfNeeded().catch(() => undefined);
|
|
|
await textHit.click(CLICK);
|
|
|
await page.waitForTimeout(500);
|
|
|
if (await isFeatheryCheckboxChecked(page, selector)) return true;
|
|
|
if (await isFeatheryCardSelected(page, label)) return true;
|
|
|
}
|
|
|
|
|
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
|
await forceFeatheryCheckbox(page, selector);
|
|
|
if ((await cb.count()) > 0) {
|
|
|
await cb.click(CLICK).catch(() => undefined);
|
|
|
}
|
|
|
await root.getByText(labelRe).first().click(CLICK).catch(() => undefined);
|
|
|
await page.waitForTimeout(600);
|
|
|
if (await isFeatheryCheckboxChecked(page, selector)) return true;
|
|
|
if (await isFeatheryCardSelected(page, label)) return true;
|
|
|
}
|
|
|
return (
|
|
|
(await isFeatheryCheckboxChecked(page, selector)) ||
|
|
|
(await isFeatheryCardSelected(page, label))
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/** LTL Freight Class — 优先可见 #freight-type,按 option 文本匹配并返回实际操作的 locator */
|
|
|
export async function selectFreightClass(
|
|
|
page: Page,
|
|
|
freightClass: string,
|
|
|
): Promise<Locator> {
|
|
|
const root = page.locator("#container-mh");
|
|
|
await root
|
|
|
.locator("#freight-type, #freight-class")
|
|
|
.first()
|
|
|
.waitFor({ state: "visible", timeout: 10_000 });
|
|
|
|
|
|
const picked = await page.evaluate(function (expected) {
|
|
|
const container = document.querySelector("#container-mh");
|
|
|
if (!container) return null;
|
|
|
const ids = ["freight-type", "freight-class"];
|
|
|
for (let idIdx = 0; idIdx < ids.length; idIdx += 1) {
|
|
|
const el = container.querySelector(
|
|
|
"#" + ids[idIdx],
|
|
|
) as HTMLSelectElement | null;
|
|
|
if (!el) continue;
|
|
|
const r = el.getBoundingClientRect();
|
|
|
if (r.width <= 0 || r.height <= 0) continue;
|
|
|
for (let i = 0; i < el.options.length; i += 1) {
|
|
|
const opt = el.options[i];
|
|
|
if (!opt) continue;
|
|
|
const text = opt.text.trim();
|
|
|
const t = text.toLowerCase();
|
|
|
const w = String(expected).trim().toLowerCase();
|
|
|
let isMatch = t === w || text === String(expected).trim();
|
|
|
if (!isMatch && w === "not sure" && t.indexOf("not sure") >= 0) isMatch = true;
|
|
|
if (!isMatch) continue;
|
|
|
el.selectedIndex = i;
|
|
|
el.value = opt.value;
|
|
|
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
|
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
|
return { text: text, id: el.id };
|
|
|
}
|
|
|
}
|
|
|
return null;
|
|
|
}, freightClass);
|
|
|
|
|
|
const sel = picked?.id
|
|
|
? root.locator(`#${picked.id}`).first()
|
|
|
: root.locator("#freight-type").first();
|
|
|
|
|
|
if (picked) {
|
|
|
console.log(`[form-interactions] Freight Class → ${picked.text} (#${picked.id})`);
|
|
|
} else {
|
|
|
await sel.selectOption({ label: freightClass }).catch(() => sel.selectOption(freightClass));
|
|
|
}
|
|
|
|
|
|
await page.waitForTimeout(400);
|
|
|
let observed = await readSelectVisibleText(sel);
|
|
|
if (!freightClassOptionMatches(observed, freightClass)) {
|
|
|
await sel.selectOption({ label: freightClass }).catch(() => undefined);
|
|
|
await page.waitForTimeout(400);
|
|
|
observed = await readSelectVisibleText(sel);
|
|
|
}
|
|
|
if (!freightClassOptionMatches(observed, freightClass)) {
|
|
|
throw new Error(`Freight Class 未能选中 "${freightClass}"(当前: "${observed}")`);
|
|
|
}
|
|
|
return sel;
|
|
|
}
|
|
|
|
|
|
/** LTL 附加服务 label 文本匹配(纯函数,供单测) */
|
|
|
export function ltlAccessorialLabelMatches(
|
|
|
elementText: string,
|
|
|
label: string,
|
|
|
): boolean {
|
|
|
const text = elementText.replace(/\s+/g, " ").trim().toLowerCase();
|
|
|
const want = label.trim().toLowerCase();
|
|
|
if (!text || !want) return false;
|
|
|
return text === want || text.includes(want);
|
|
|
}
|
|
|
|
|
|
/** canonical 标签 → Feathery 页面可见文案 */
|
|
|
export function ltlAccessorialDisplayText(label: string): string {
|
|
|
if (label === "Limited Access Pickup" || label === "Limited Access Delivery") {
|
|
|
return "Limited Access";
|
|
|
}
|
|
|
return label;
|
|
|
}
|
|
|
|
|
|
/** 附加项属于提货段还是派送段 */
|
|
|
export function ltlAccessorialIsPickupSection(label: string): boolean {
|
|
|
return /Pickup/i.test(label);
|
|
|
}
|
|
|
|
|
|
async function findLtlAccessorialCheckboxSelector(
|
|
|
page: Page,
|
|
|
label: string,
|
|
|
): Promise<string | null> {
|
|
|
const pageText = ltlAccessorialDisplayText(label);
|
|
|
const wantPickup = ltlAccessorialIsPickupSection(label);
|
|
|
return page.evaluate(
|
|
|
function (args) {
|
|
|
const container = document.querySelector("#container-mh");
|
|
|
if (!container) return null;
|
|
|
const cbs = container.querySelectorAll('input[type="checkbox"]');
|
|
|
for (let i = 0; i < cbs.length; i += 1) {
|
|
|
const cb = cbs[i];
|
|
|
if (!(cb instanceof HTMLInputElement)) continue;
|
|
|
let labelText = "";
|
|
|
let node = cb.parentElement;
|
|
|
for (let depth = 0; depth < 12 && node; depth += 1) {
|
|
|
const spans = node.querySelectorAll("span");
|
|
|
for (let s = 0; s < spans.length; s += 1) {
|
|
|
const t = (spans[s].textContent || "").replace(/\s+/g, " ").trim();
|
|
|
if (t.length > 2 && t.length < 50) {
|
|
|
labelText = t;
|
|
|
depth = 99;
|
|
|
break;
|
|
|
}
|
|
|
}
|
|
|
node = node.parentElement;
|
|
|
}
|
|
|
if (labelText !== args.pageText) continue;
|
|
|
if (args.pageText === "Limited Access") {
|
|
|
let limitedSeen = 0;
|
|
|
for (let j = 0; j <= i; j += 1) {
|
|
|
const prior = cbs[j];
|
|
|
if (!(prior instanceof HTMLInputElement)) continue;
|
|
|
let priorText = "";
|
|
|
let priorNode = prior.parentElement;
|
|
|
for (let depth = 0; depth < 12 && priorNode; depth += 1) {
|
|
|
const spans = priorNode.querySelectorAll("span");
|
|
|
for (let s = 0; s < spans.length; s += 1) {
|
|
|
const t = (spans[s].textContent || "").replace(/\s+/g, " ").trim();
|
|
|
if (t.length > 2 && t.length < 50) {
|
|
|
priorText = t;
|
|
|
depth = 99;
|
|
|
break;
|
|
|
}
|
|
|
}
|
|
|
priorNode = priorNode.parentElement;
|
|
|
}
|
|
|
if (priorText === "Limited Access") limitedSeen += 1;
|
|
|
}
|
|
|
const inPickup = limitedSeen === 1;
|
|
|
if (inPickup !== args.wantPickup) continue;
|
|
|
}
|
|
|
return cb.id ? "#" + cb.id : null;
|
|
|
}
|
|
|
return null;
|
|
|
},
|
|
|
{ pageText, wantPickup },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/** 读取 LTL 附加 checkbox 是否已勾选(Feathery #Checkbox* 隐藏 input) */
|
|
|
export async function isLtlAccessorialChecked(
|
|
|
page: Page,
|
|
|
label: string,
|
|
|
): Promise<boolean> {
|
|
|
const selector = await findLtlAccessorialCheckboxSelector(page, label);
|
|
|
if (!selector) return false;
|
|
|
return page.evaluate(function (sel) {
|
|
|
const root = document.querySelector("#container-mh");
|
|
|
if (!root) return false;
|
|
|
const cb = root.querySelector(sel);
|
|
|
return Boolean(cb && cb instanceof HTMLInputElement && cb.type === "checkbox" && cb.checked);
|
|
|
}, selector);
|
|
|
}
|
|
|
|
|
|
/** LTL / Expedited 附加 — Feathery checkbox(P08 实机:#Checkbox15 等 + span 文案) */
|
|
|
export async function toggleLtlAccessorial(
|
|
|
page: Page,
|
|
|
label: string,
|
|
|
): Promise<boolean> {
|
|
|
if (await isLtlAccessorialChecked(page, label)) return true;
|
|
|
|
|
|
const selector = await findLtlAccessorialCheckboxSelector(page, label);
|
|
|
if (selector) {
|
|
|
await forceFeatheryCheckbox(page, selector);
|
|
|
await page.waitForTimeout(300);
|
|
|
if (await isLtlAccessorialChecked(page, label)) return true;
|
|
|
}
|
|
|
|
|
|
const root = page.locator("#container-mh");
|
|
|
const pageText = ltlAccessorialDisplayText(label);
|
|
|
const section = ltlAccessorialIsPickupSection(label)
|
|
|
? root.filter({ hasText: /Pickup Services/i }).first()
|
|
|
: root.filter({ hasText: /Delivery Services/i }).first();
|
|
|
const hit = section.getByText(pageText, { exact: true }).first();
|
|
|
if ((await hit.count()) > 0) {
|
|
|
await hit.scrollIntoViewIfNeeded().catch(() => undefined);
|
|
|
await hit.click(CLICK);
|
|
|
await page.waitForTimeout(400);
|
|
|
}
|
|
|
|
|
|
return isLtlAccessorialChecked(page, label);
|
|
|
}
|
|
|
|
|
|
/** Expedited / FTL 内嵌 Expedited — Large Straight 可选 Liftgate */
|
|
|
export async function selectExpeditedLiftgate(page: Page): Promise<boolean> {
|
|
|
const root = page.locator("#container-mh");
|
|
|
const section = root.filter({ hasText: /附加服务|Additional Services/i }).last();
|
|
|
const lift = section.getByText(/^Liftgate$/i).first();
|
|
|
if ((await lift.count()) === 0) {
|
|
|
const fallback = root.getByText(/^Liftgate$/i).first();
|
|
|
if ((await fallback.count()) === 0) return false;
|
|
|
await fallback.click(CLICK);
|
|
|
return true;
|
|
|
}
|
|
|
await lift.click(CLICK);
|
|
|
await page.waitForTimeout(400);
|
|
|
return true;
|
|
|
}
|
|
|
|
|
|
export async function readSelectVisibleText(locator: Locator): Promise<string> {
|
|
|
return locator.evaluate(function (el) {
|
|
|
const sel = el as HTMLSelectElement;
|
|
|
if (sel.tagName === "SELECT") {
|
|
|
return sel.options[sel.selectedIndex]?.text?.trim() ?? sel.value;
|
|
|
}
|
|
|
return (el as HTMLElement).innerText?.trim() ?? "";
|
|
|
});
|
|
|
}
|
|
|
|
|
|
export function openDeckSelectionOk(observed: string, expected: string): boolean {
|
|
|
if (!observed) return false;
|
|
|
if (/No roof or sides includes/i.test(observed)) return false;
|
|
|
return (
|
|
|
optionMatchesSubtype(observed, expected) ||
|
|
|
/Foot Flatbed|Foot Step Deck|Removable Gooseneck|Double Drop|Conestoga|Hot Shot/i.test(
|
|
|
observed,
|
|
|
)
|
|
|
);
|
|
|
}
|