test(folio): replace tautological properties with 4 domain invariants (#28)

* test(folio): replace tautological properties with 4 domain invariants

Drop properties that can't fail (e.g. List.size >= 0, balances are Long
integers) or that just check the extractor itself (account_count equals
accounts.length). Keep auth routing liveness, error-clear liveness, and
reachability goals.

Add four properties that target the real code paths:
- balanceMatchesTransactionDelta: a new ledger row shifts the balance
  by exactly its signed amount (credit +, debit -).
- totalEqualsSumOfAccounts: home total equals sum of per-account
  balances at every state, not only on home.
- balanceChangeRequiresActiveAccount: an account's balance can only
  change while that account is the active one in the navigator.
- duplicateAccountNamesRejected: account names are unique under the
  app's actual dedup rule (case-insensitive after trim).

* chore(folio): upgrade sdk-android to io.github.priyanshujain.sanderling:0.0.1-rc4

Group id moved from io.github.priyanshujain to
io.github.priyanshujain.sanderling in the rc4 publish.

* chore(folio): pin @sanderling/spec to 0.0.1-rc4
This commit is contained in:
pj authored and GitHub committed 2026-04-21 17:53:55 +07:00
1 parent 75780db3fa
commit faebfe379d
4 files changed
+46 -131

No files matched your search

+1 -1
View File
@@ -7,4 +7,4 @@ android.nonTransitiveRClass=true
# Pin the sanderling SDK version consumed by this app. Override with # Pin the sanderling SDK version consumed by this app. Override with
# `-Psanderling.version=<version>`. # `-Psanderling.version=<version>`.
sanderling.version=0.0.0-dev sanderling.version=0.0.1-rc4
+4 -4
View File
@@ -6,13 +6,13 @@
"": { "": {
"name": "folio", "name": "folio",
"devDependencies": { "devDependencies": {
"@sanderling/spec": "next" "@sanderling/spec": "^0.0.1-rc4"
} }
}, },
"node_modules/@sanderling/spec": { "node_modules/@sanderling/spec": {
"version": "0.0.1-rc3", "version": "0.0.1-rc4",
"resolved": "https://registry.npmjs.org/@sanderling/spec/-/spec-0.0.1-rc3.tgz", "resolved": "https://registry.npmjs.org/@sanderling/spec/-/spec-0.0.1-rc4.tgz",
"integrity": "sha512-mCayssOqM2wfzrQDnFGE8FAlUlDzBl1fx2L/MLDIuPrQHBMlUC350xBB8Z7I/DYI98eCWJMqjtw3iU6yVWKtIQ==", "integrity": "sha512-iv08d2duxQMowqEnDtOeWRGLmiv7188D68g/5ei+cU1BL33cVXwGscLYznfsUv4PgK7XI5Vihwbnp7ME4+Lh1w==",
"dev": true, "dev": true,
"license": "Apache-2.0" "license": "Apache-2.0"
} }
+1 -1
View File
@@ -3,6 +3,6 @@
"private": true, "private": true,
"description": "Minimal sanderling integration example.", "description": "Minimal sanderling integration example.",
"devDependencies": { "devDependencies": {
"@sanderling/spec": "next" "@sanderling/spec": "^0.0.1-rc4"
} }
} }
+40 -125
View File
@@ -34,9 +34,6 @@ interface LedgerRow {
const loggedIn = extract<boolean>( const loggedIn = extract<boolean>(
(state) => (state.snapshots.logged_in as boolean) ?? false, (state) => (state.snapshots.logged_in as boolean) ?? false,
); );
const authStatus = extract<string>(
(state) => (state.snapshots.auth_status as string) ?? "",
);
const route = extract<string>( const route = extract<string>(
(state) => (state.snapshots.route as string) ?? "", (state) => (state.snapshots.route as string) ?? "",
); );
@@ -46,9 +43,6 @@ const accounts = extract<AccountSnapshot[]>(
const totalBalance = extract<number>( const totalBalance = extract<number>(
(state) => (state.snapshots.total_balance as number) ?? 0, (state) => (state.snapshots.total_balance as number) ?? 0,
); );
const accountCount = extract<number>(
(state) => (state.snapshots.account_count as number) ?? 0,
);
const activeAccountId = extract<string | null>( const activeAccountId = extract<string | null>(
(state) => (state.snapshots.active_account_id as string | null) ?? null, (state) => (state.snapshots.active_account_id as string | null) ?? null,
); );
@@ -64,9 +58,6 @@ const focusedInput = extract<string | null>(
const txnFormType = extract<string | null>( const txnFormType = extract<string | null>(
(state) => (state.snapshots.txn_form_type as string | null) ?? null, (state) => (state.snapshots.txn_form_type as string | null) ?? null,
); );
const txnFormAccountId = extract<string | null>(
(state) => (state.snapshots.txn_form_account_id as string | null) ?? null,
);
const loginError = extract<string>( const loginError = extract<string>(
(state) => (state.snapshots.login_error as string) ?? "", (state) => (state.snapshots.login_error as string) ?? "",
); );
@@ -94,81 +85,45 @@ const allAccountCards = extract((state) =>
state.ax.findAll("descPrefix:account_card:"), state.ax.findAll("descPrefix:account_card:"),
); );
const accountCountNonNegative = always(() => accountCount.current >= 0); const balanceMatchesTransactionDelta = always(
now(() => activeAccountId.current !== null).implies(
next(() => {
const prevActive = activeAccountId.previous;
if (prevActive === null || prevActive === undefined) return true;
if (prevActive !== activeAccountId.current) return true;
const prevRows = ledgerRows.previous ?? [];
const curRows = ledgerRows.current;
if (curRows.length !== prevRows.length + 1) return true;
const prevIds = new Set(prevRows.map((r) => r.id));
const added = curRows.filter((r) => !prevIds.has(r.id));
if (added.length !== 1) return true;
const delta = ledgerBalance.current - (ledgerBalance.previous ?? 0);
return delta === added[0].signed;
}),
),
);
const onHome = () => route.current === "home"; const totalEqualsSumOfAccounts = always(() => {
const onLedger = () =>
route.current === "ledger" || route.current === "add-transaction";
const isInteger = (n: number) => Number.isFinite(n) && Math.floor(n) === n;
const totalBalanceMatchesAccounts = always(
now(onHome).implies(
now(() => {
const sum = accounts.current.reduce((acc, a) => acc + a.balance, 0); const sum = accounts.current.reduce((acc, a) => acc + a.balance, 0);
return sum === totalBalance.current; return sum === totalBalance.current;
});
const balanceChangeRequiresActiveAccount = always(
now(() => true).implies(
next(() => {
const prevAccounts = accounts.previous ?? [];
const prevActive = activeAccountId.previous ?? null;
for (const cur of accounts.current) {
const prev = prevAccounts.find((a) => a.id === cur.id);
if (!prev) continue;
if (cur.balance !== prev.balance && prevActive !== cur.id) return false;
}
return true;
}), }),
), ),
); );
const ledgerBalanceMatchesRows = always( const duplicateAccountNamesRejected = always(() => {
now(onLedger).implies(
now(() => {
const sum = ledgerRows.current.reduce((acc, r) => acc + r.signed, 0);
return sum === ledgerBalance.current;
}),
),
);
const ledgerRowsWellFormed = always(() => {
for (const row of ledgerRows.current) {
if (row.type !== "credit" && row.type !== "debit") return false;
if (!(row.amount > 0)) return false;
const expected = row.type === "credit" ? row.amount : -row.amount;
if (row.signed !== expected) return false;
}
return true;
});
const balancesAreIntegerCents = always(() => {
if (!isInteger(totalBalance.current)) return false;
if (!isInteger(ledgerBalance.current)) return false;
for (const a of accounts.current) if (!isInteger(a.balance)) return false;
for (const r of ledgerRows.current) {
if (!isInteger(r.amount) || !isInteger(r.signed)) return false;
}
return true;
});
const accountCountMatchesList = always(
() => accountCount.current === accounts.current.length,
);
const ledgerCountMatchesRows = always(
now(onLedger).implies(
now(() => {
const active = activeAccountId.current;
if (active === null) return true;
const fromAccounts = accounts.current.find((a) => a.id === active);
if (!fromAccounts) return true;
return fromAccounts.txnCount === ledgerRows.current.length;
}),
),
);
const zeroTxnsMeansZeroBalance = always(() => {
for (const a of accounts.current) {
if (a.txnCount === 0 && a.balance !== 0) return false;
}
return true;
});
const noOrphanTransactions = always(() => {
const active = activeAccountId.current;
if (active === null) return ledgerRows.current.length === 0;
return ledgerRows.current.every((r) => r.accountId === active);
});
const uniqueAccountNames = always(() => {
const seen = new Set<string>(); const seen = new Set<string>();
for (const a of accounts.current) { for (const a of accounts.current) {
const key = a.name.trim().toLowerCase(); const key = a.name.trim().toLowerCase();
@@ -178,48 +133,13 @@ const uniqueAccountNames = always(() => {
return true; return true;
}); });
const accountingInvariants = { const domainInvariants = {
totalBalanceMatchesAccounts, balanceMatchesTransactionDelta,
ledgerBalanceMatchesRows, totalEqualsSumOfAccounts,
ledgerRowsWellFormed, balanceChangeRequiresActiveAccount,
balancesAreIntegerCents, duplicateAccountNamesRejected,
accountCountMatchesList,
ledgerCountMatchesRows,
zeroTxnsMeansZeroBalance,
noOrphanTransactions,
uniqueAccountNames,
}; };
const accountsOnlyGrow = always(
now(() => true).implies(
next(() => accounts.current.length >= (accounts.previous?.length ?? 0)),
),
);
const ledgerOnlyGrowsPerAccount = always(
now(() => activeAccountId.current !== null).implies(
next(() => {
if (activeAccountId.current !== activeAccountId.previous) return true;
return ledgerRows.current.length >= (ledgerRows.previous?.length ?? 0);
}),
),
);
const authStatusIsKnown = always(
() => authStatus.current === "logged-in" || authStatus.current === "logged-out",
);
const routeIsKnown = always(() => {
const r = route.current;
return (
r === "login" ||
r === "home" ||
r === "add-account" ||
r === "ledger" ||
r === "add-transaction"
);
});
const loggedInLeavesLogin = always( const loggedInLeavesLogin = always(
now(() => loggedIn.current).implies( now(() => loggedIn.current).implies(
eventually(() => route.current !== "login").within(3, "seconds"), eventually(() => route.current !== "login").within(3, "seconds"),
@@ -232,11 +152,7 @@ const loggedOutReachesLogin = always(
), ),
); );
const stateMachine = { const authRouting = {
accountsOnlyGrow,
ledgerOnlyGrowsPerAccount,
authStatusIsKnown,
routeIsKnown,
loggedInLeavesLogin, loggedInLeavesLogin,
loggedOutReachesLogin, loggedOutReachesLogin,
}; };
@@ -410,9 +326,8 @@ const openAddTxn = actions(() => {
}); });
export const properties = { export const properties = {
accountCountNonNegative, ...domainInvariants,
...accountingInvariants, ...authRouting,
...stateMachine,
...liveness, ...liveness,
noUncaughtExceptions, noUncaughtExceptions,
}; };