mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
169 lines
6.2 KiB
TypeScript
169 lines
6.2 KiB
TypeScript
import {
|
||
extract,
|
||
always,
|
||
actions,
|
||
weighted,
|
||
Tap,
|
||
InputText,
|
||
taps,
|
||
swipes,
|
||
} from "@uatu/spec";
|
||
|
||
// ── Snapshot extractors (fed by UatuExtractors on Android) ─────
|
||
const screen = extract<string>((state) => (state.snapshots.screen as string) ?? "");
|
||
const balance = extract<number>(
|
||
(state) => (state.snapshots["ledger.balance"] as number) ?? 0,
|
||
);
|
||
const totalGiven = extract<number>(
|
||
(state) => (state.snapshots["ledger.totalGiven"] as number) ?? 0,
|
||
);
|
||
const totalReceived = extract<number>(
|
||
(state) => (state.snapshots["ledger.totalReceived"] as number) ?? 0,
|
||
);
|
||
|
||
// ── Screen detection via uiautomator hierarchy ─────────────────
|
||
const onLanguageSelect = extract<boolean>((state) =>
|
||
Boolean(state.ax.find("id:select_language")),
|
||
);
|
||
const englishTile = extract((state) =>
|
||
state.ax.find("id:select_language") ? state.ax.find("text:English") : undefined,
|
||
);
|
||
|
||
const onEnterMobile = extract<boolean>((state) =>
|
||
Boolean(state.ax.find("id:etMobileNumber")),
|
||
);
|
||
const mobileField = extract((state) => state.ax.find("id:etMobileNumber"));
|
||
const termsCheckbox = extract((state) => state.ax.find("id:checkBoxTerms"));
|
||
const continueButton = extract((state) => state.ax.find("id:buttonLogin"));
|
||
|
||
// OTP screen uses six single-digit EditTexts sharing resource-id "otp".
|
||
// Typing into the first one auto-fills the rest.
|
||
const otpFirstBox = extract((state) => state.ax.findAll("id:otp")[0]);
|
||
const onOtp = extract<boolean>(() => Boolean(otpFirstBox.current));
|
||
|
||
// Multi-device dialog that gates the Home screen when another device is
|
||
// signed in. Spec auto-signs-out the other device.
|
||
const signOutOthersButton = extract((state) => state.ax.find("text:Sign Out Other Devices"));
|
||
const confirmSignOutButton = extract((state) => {
|
||
const cancel = state.ax.find("text:Cancel");
|
||
if (!cancel) return undefined;
|
||
return state.ax.find("text:Sign Out");
|
||
});
|
||
|
||
// Notification-permission modal on Home.
|
||
const skipNotificationsButton = extract((state) => state.ax.find("text:Skip"));
|
||
|
||
// Back arrow visible on customer/supplier ledger toolbars; tapping it
|
||
// returns to the Home tab list so the spec can pick another counterparty.
|
||
const ledgerBackArrow = extract((state) =>
|
||
onLedger.current ? state.ax.find("desc:Back") : undefined,
|
||
);
|
||
|
||
// Home screen customer/supplier rows. Compose testTag appears as
|
||
// content-desc; rows are suffixed with a stable UUID.
|
||
const customerRows = extract((state) => state.ax.findAll("descPrefix:customer_row_"));
|
||
const supplierRows = extract((state) => state.ax.findAll("descPrefix:supplier_row_"));
|
||
const onHome = extract<boolean>(
|
||
() => customerRows.current.length > 0 || supplierRows.current.length > 0,
|
||
);
|
||
|
||
const onLedger = extract<boolean>(
|
||
() => screen.current === "customer_ledger" || screen.current === "supplier_ledger",
|
||
);
|
||
|
||
// ── Properties ─────────────────────────────────────────────────
|
||
export const properties = {
|
||
// Sign convention discovered empirically against merchant-android:
|
||
// customer ledger: balance == received − given (debt customer owes)
|
||
// supplier ledger: balance == given − received (debt merchant owes)
|
||
// The two screens flip because "given/received" is recorded from the
|
||
// merchant's POV in both DAOs, but each summary balance is stored from
|
||
// the counterparty's POV.
|
||
ledgerBalanceMatchesTxns: always(() => {
|
||
if (screen.current === "customer_ledger") {
|
||
return balance.current === totalReceived.current - totalGiven.current;
|
||
}
|
||
if (screen.current === "supplier_ledger") {
|
||
return balance.current === totalGiven.current - totalReceived.current;
|
||
}
|
||
return true;
|
||
}),
|
||
};
|
||
|
||
// ── Action generators ──────────────────────────────────────────
|
||
const selectEnglish = actions(() => {
|
||
if (!onLanguageSelect.current) return [];
|
||
return englishTile.current ? [Tap({ on: englishTile.current })] : [];
|
||
});
|
||
|
||
const enterMobile = actions(() => {
|
||
if (!onEnterMobile.current) return [];
|
||
const field = mobileField.current;
|
||
const terms = termsCheckbox.current;
|
||
const button = continueButton.current;
|
||
const phone = process.env.UATU_TEST_PHONE ?? "";
|
||
if (!field || !phone) return [];
|
||
// Step by screen state so we never tap a disabled button, append to the
|
||
// phone field, or toggle the terms checkbox off again.
|
||
if (field.text !== phone) {
|
||
return [InputText({ into: field, text: phone })];
|
||
}
|
||
if (terms && !terms.checked) {
|
||
return [Tap({ on: terms })];
|
||
}
|
||
if (button && button.enabled) {
|
||
return [Tap({ on: button })];
|
||
}
|
||
return [];
|
||
});
|
||
|
||
const enterOtp = actions(() => {
|
||
if (!onOtp.current) return [];
|
||
const field = otpFirstBox.current;
|
||
const otp = process.env.UATU_TEST_OTP ?? "";
|
||
if (!field || !otp) return [];
|
||
// The first box shows the entered digit once filled; stop re-typing then.
|
||
if (field.text && field.text.length > 0) return [];
|
||
return [InputText({ into: field, text: otp })];
|
||
});
|
||
|
||
const dismissMultiDevice = actions(() => {
|
||
if (confirmSignOutButton.current) return [Tap({ on: confirmSignOutButton.current })];
|
||
if (signOutOthersButton.current) return [Tap({ on: signOutOthersButton.current })];
|
||
return [];
|
||
});
|
||
|
||
const dismissNotifications = actions(() => {
|
||
return skipNotificationsButton.current
|
||
? [Tap({ on: skipNotificationsButton.current })]
|
||
: [];
|
||
});
|
||
|
||
const openCustomerOrSupplier = actions(() => {
|
||
if (!onHome.current) return [];
|
||
const rows = [...customerRows.current, ...supplierRows.current];
|
||
if (rows.length === 0) return [];
|
||
return rows.map((row) => Tap({ on: row }));
|
||
});
|
||
|
||
const leaveLedger = actions(() => {
|
||
return ledgerBackArrow.current ? [Tap({ on: ledgerBackArrow.current })] : [];
|
||
});
|
||
|
||
export const actionsRoot = weighted(
|
||
[100, selectEnglish],
|
||
[100, enterMobile],
|
||
[100, enterOtp],
|
||
[100, dismissMultiDevice],
|
||
[100, dismissNotifications],
|
||
[80, openCustomerOrSupplier],
|
||
// leaveLedger is lighter so the property gets several evaluations on each
|
||
// ledger before we navigate away to a new one.
|
||
[25, leaveLedger],
|
||
[10, taps],
|
||
[2, swipes],
|
||
);
|
||
|
||
(globalThis as { actions?: unknown; properties?: unknown }).actions = actionsRoot;
|
||
(globalThis as { properties?: unknown }).properties = properties;
|