feat(spec): merchant-ledger property + login/navigation generators

Property ledgerBalanceMatchesTxns asserts displayed balance equals
sum(Given) - sum(Received) on either ledger screen. Catches the
class of bug where the server-fed CustomerModel.balance diverges
from the local-DB-fed CoreDatabaseDao sums (stale cache, partial
sync, deleted-txn handling glitch, etc.).

Generators are gated by screen state and weighted to push the run
through login -> home -> ledger quickly:
  enterPhone (100), enterOtp (100), openCustomerOrSupplier (80),
  taps (10), swipes (2).

Phone/OTP read from process.env via esbuild defines so credentials
never land in source. cmd/internal-tools/bundle-check is a quick
sanity tool to confirm the spec bundles before running uatu test.
This commit is contained in:
pj committed 2026-04-18 00:08:40 +07:00
1 parent 7aa75f3d9f
commit 4dbad9073e
2 files changed
+130

No files matched your search

+96
View File
@@ -0,0 +1,96 @@
import {
extract,
always,
actions,
weighted,
Tap,
InputText,
taps,
swipes,
} from "@uatu/spec";
import type { State } from "@uatu/spec";
// ── Snapshot extractors ────────────────────────────────────────
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 accessibility tree ────────────────────
const onLoginPhone = extract<boolean>((state) =>
Boolean(state.ax.find("id:login_phone_field")),
);
const onLoginOtp = extract<boolean>((state) =>
Boolean(state.ax.find("id:login_otp_field")),
);
const onHome = extract<boolean>(
(state) =>
state.ax.findAll("id:home_customer_row").length > 0 ||
state.ax.findAll("id:home_supplier_row").length > 0,
);
const onLedger = extract<boolean>(
() => screen.current === "customer_ledger" || screen.current === "supplier_ledger",
);
// ── Properties ─────────────────────────────────────────────────
export const properties = {
// The displayed balance is server-fed; the totals are local-DB-fed.
// Any divergence — stale cache, partial sync, optimistic-update glitch,
// mishandled deleted txn — is a violation.
ledgerBalanceMatchesTxns: always(
() =>
!onLedger.current ||
balance.current === totalGiven.current - totalReceived.current,
),
};
// ── Action generators (gated by screen state) ──────────────────
const enterPhone = actions(() =>
onLoginPhone.current
? [
InputText({
into: "id:login_phone_field",
text: process.env.UATU_TEST_PHONE ?? "",
}),
Tap({ on: "id:login_continue" }),
]
: [],
);
const enterOtp = actions(() =>
onLoginOtp.current
? [
InputText({
into: "id:login_otp_field",
text: process.env.UATU_TEST_OTP ?? "",
}),
]
: [],
);
const openCustomerOrSupplier = actions((): ReturnType<typeof Tap>[] => {
if (!onHome.current) return [];
// The verifier resolves the selector; we just hand back a list of
// candidate row taps weighted equally by the runtime's pickFromResult.
return [
Tap({ on: "id:home_customer_row" }),
Tap({ on: "id:home_supplier_row" }),
];
});
export const actionsRoot = weighted(
[100, enterPhone],
[100, enterOtp],
[80, openCustomerOrSupplier],
[10, taps],
[2, swipes],
);
// Verifier looks for `globalThis.actions`; re-export the weighted root.
(globalThis as { actions?: unknown }).actions = actionsRoot;