mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
Merge branch 'conjunct-that-cannot-fire' into correctness-and-spec-skills
This commit is contained in:
commit
295d39220f
5 files changed
+177
-52
No files matched your search
@@ -145,6 +145,37 @@ test("one submit moving the balance by exactly the typed amount is the app worki
|
||||
}
|
||||
});
|
||||
|
||||
// The frames this bound is actually driven down, replayed off the recorded iOS
|
||||
// run at runs/folio-ios/20260815-102711 (seed 7, 240 steps). It judged 18 of
|
||||
// them and fired on none: every one was a single Tap on TxnSubmit landing back
|
||||
// on the account's own ledger with the balance moved by exactly what was typed,
|
||||
// which is the app working. Three of those readings are below, with the same
|
||||
// frame as it looks when the one action commits twice.
|
||||
//
|
||||
// A double tap is nowhere in that list, and the run took three of them: all
|
||||
// three landed on Home, where this conjunct has no balance to read and
|
||||
// committedTransactionsExceedSubmits convicted instead. What reaches here is
|
||||
// the interleaving where the second commit's pop does not run.
|
||||
test("the ledger landings a real run produces are judged, and a doubled one fires", () => {
|
||||
for (const [prev, typed] of [
|
||||
[357900, 25100],
|
||||
[455800, 7900],
|
||||
[682500, 19300],
|
||||
]) {
|
||||
const judge = (currAccountBalance: number) =>
|
||||
committedAmountExceedsOneSubmit({
|
||||
route: "ledger",
|
||||
lastAction: submit,
|
||||
submitsInWindow: 1,
|
||||
typedAmount: typed!,
|
||||
prevAccountBalance: prev!,
|
||||
currAccountBalance,
|
||||
});
|
||||
assert.equal(judge(prev! + typed!), false, `the recorded ${prev} -> ${prev! + typed!} was convicted`);
|
||||
assert.equal(judge(prev! + 2 * typed!), true, `a second commit on ${prev} went unjudged`);
|
||||
}
|
||||
});
|
||||
|
||||
// A balance that has not moved is a commit still in flight (createTransaction
|
||||
// runs in a coroutine), a submit the app rejected, or a tap that never landed.
|
||||
// None of those is evidence, and an equality would convict all three.
|
||||
@@ -286,8 +317,11 @@ test("Home shows every account's money, so it is not this comparison's scale", (
|
||||
});
|
||||
|
||||
// A step of the walk: the frame it landed on, the balance node that frame
|
||||
// carried, what was in the amount field the step before, and the action that
|
||||
// got there. Driven through the same carrier and window the spec holds.
|
||||
// carried, the amount field as that frame shows it, and the action that got
|
||||
// there. Driven through the same carrier and window the spec holds, `typed`
|
||||
// included: the spec hands the landing frame's field to countSubmitsInWindow
|
||||
// and the previous frame's to the property, and a walk that skips the first
|
||||
// half drives a composition the spec never runs.
|
||||
interface Frame {
|
||||
route: string | null;
|
||||
balanceText?: string;
|
||||
@@ -311,6 +345,7 @@ function walk(frames: readonly Frame[]) {
|
||||
const window = countSubmitsInWindow({
|
||||
previousCount: submits,
|
||||
lastAction: frame.lastAction,
|
||||
amountText: frame.route === "add-transaction" ? (frame.typed ?? "") : undefined,
|
||||
fresh: reading.fresh,
|
||||
});
|
||||
submits = window.next;
|
||||
@@ -358,9 +393,34 @@ test("the Home window cannot judge a walk that never goes Home", () => {
|
||||
}
|
||||
});
|
||||
|
||||
// The same trajectory, judged where the app actually is. The frame the double
|
||||
// tap lands on is the account's own ledger, so the window that closes there
|
||||
// holds exactly the one action.
|
||||
// The trajectory the recorded iOS run took to the frames this property judges,
|
||||
// with the taps that reach TxnSubmit over an empty field: 40 of its 61 submit
|
||||
// taps landed back on the transaction screen, and the field they read is the
|
||||
// one the landing frame shows. Counting those as submits is what the run
|
||||
// measures as the difference between 4 convictions and 0. Here the balance node
|
||||
// is off the viewport while they happen, so nothing resets the window and the
|
||||
// slack survives to the frame that matters.
|
||||
test("submits the app must have refused do not buy a double tap an alibi", () => {
|
||||
const verdicts = walk([
|
||||
{ route: "ledger", balanceText: "$100.00", lastAction: openLedger },
|
||||
{ route: "add-transaction", lastAction: openAddTxn },
|
||||
{ route: "add-transaction", lastAction: submit },
|
||||
{ route: "add-transaction", lastAction: submit },
|
||||
{ route: "add-transaction", typed: "196", lastAction: typeAmount },
|
||||
{ route: "ledger", balanceText: "$492.00", lastAction: doubleSubmit },
|
||||
]);
|
||||
assert.equal(verdicts[5]?.submits, 1);
|
||||
assert.equal(verdicts[5]?.violated, true);
|
||||
});
|
||||
|
||||
// The same trajectory, judged where the app actually is. The double tap sends
|
||||
// two Submit events, and the frame it lands on says which of the two shapes
|
||||
// they took: two commits and two pops reach Home, where the counting invariant
|
||||
// judges them, and two commits with the second pop cancelled by the first stop
|
||||
// on the account's own ledger, which is this one. The recorded iOS run took
|
||||
// three double taps and all three landed on Home, so this frame is reasoned
|
||||
// from the app's code (AddTransactionViewModel.submit commits inside
|
||||
// viewModelScope, then pops) rather than measured.
|
||||
test("the double tap is convicted on the frame it lands on", () => {
|
||||
const verdicts = walk([
|
||||
{ route: "ledger", balanceText: "$100.00", lastAction: openLedger },
|
||||
|
||||
@@ -504,20 +504,23 @@ test("freshness boundary: a window with no submit in it is vacuous", () => {
|
||||
});
|
||||
|
||||
// applied: null is the runner saying it dispatched the tap and never learned
|
||||
// whether it landed. A submit that committed nothing leaves the balance where
|
||||
// it was, so demanding the typed amount of movement for it convicts an app that
|
||||
// did exactly what it should have.
|
||||
test("a submit the runner could not confirm demands no balance move", () => {
|
||||
// whether it landed. Under the bound that buys the app nothing it did not
|
||||
// already have: a submit that committed nothing leaves the balance where it
|
||||
// was, and a balance that has not moved is under any bound. What the window
|
||||
// still promises is that no OTHER submit action could have moved it, because
|
||||
// countSubmitsInWindow counts an unconfirmed tap exactly like a confirmed one.
|
||||
// So a move of twice the typed amount is the same double commit either way.
|
||||
test("a submit the runner could not confirm is still held to the bound", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn, applied: null },
|
||||
lastAction: { kind: "DoubleTap", on: submitOn, applied: null },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 500,
|
||||
prevTotalBalance: 1000,
|
||||
currTotalBalance: 1000,
|
||||
currTotalBalance: 2000,
|
||||
}),
|
||||
true,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -583,20 +586,21 @@ test("the measured double submit still fires under the bound", () => {
|
||||
});
|
||||
|
||||
// relaunched: true is the runner saying its foreground guard restarted the app
|
||||
// after this action. The tap landed, so the window still counts it, but nobody
|
||||
// can promise the process lived long enough for the write to reach sqlite. A
|
||||
// balance still sitting where it was is exactly what a healthy app looks like
|
||||
// across a relaunch, and demanding the typed amount of movement convicts it for
|
||||
// the runner's own restart.
|
||||
test("a submit the runner relaunched across demands no balance move", () => {
|
||||
// after this action, so the two totals being compared were read from two
|
||||
// different processes. SqlLedgerStore starts every one of them on
|
||||
// stateIn(Eagerly, emptyList()) and HomeScreen composes formatCents(total) off
|
||||
// whatever the flow holds, so the restarted app draws $0.00 into TotalBalance
|
||||
// until sqlite answers. That reading is not a total this tap moved, and it is
|
||||
// as far from the last one as the account is rich.
|
||||
test("a total drawn by a restarted process is not compared with the old one", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn, applied: true, relaunched: true },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 500,
|
||||
prevTotalBalance: 1000,
|
||||
currTotalBalance: 1000,
|
||||
prevTotalBalance: 455800,
|
||||
currTotalBalance: 0,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
@@ -89,11 +89,32 @@ test("a submit the app must have refused does not spend the window's budget", ()
|
||||
}
|
||||
});
|
||||
|
||||
// Folio caps a transaction at $1,000,000.00 (MAX_TRANSACTION_AMOUNT_CENTS, in
|
||||
// core/data/Repository.kt), and AddTransactionViewModel.submit refuses anything
|
||||
// over it before a coroutine starts. The fuzzer's corpus carries
|
||||
// "999999999999999999999", AMOUNT_REGEX lets it into the field and it reaches
|
||||
// the button, so this is a refusal the window used to pay for.
|
||||
test("an amount over the app's cap cannot commit", () => {
|
||||
for (const amountText of ["1000000.01", "1,000,001", "999999999999999999999"]) {
|
||||
assert.deepEqual(
|
||||
countSubmitsInWindow({
|
||||
previousCount: 0,
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
amountText,
|
||||
fresh: false,
|
||||
}),
|
||||
{ reported: 0, next: 0 },
|
||||
`amount ${JSON.stringify(amountText)} was counted as a possible commit`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// The field as the landing frame shows it, which is the form state the tap read:
|
||||
// nothing between the two changes it. Anywhere but the transaction screen there
|
||||
// is no field to read, and unknown has to count.
|
||||
// is no field to read, and unknown has to count. The cap itself is an amount the
|
||||
// app takes, so it counts too.
|
||||
test("an amount that could commit, or that nobody could read, spends the budget", () => {
|
||||
for (const amountText of ["5", "0.01", "1,000", "999999999999999999999", undefined]) {
|
||||
for (const amountText of ["5", "0.01", "1,000", "1000000.00", "999999.99", undefined]) {
|
||||
assert.deepEqual(
|
||||
countSubmitsInWindow({
|
||||
previousCount: 0,
|
||||
|
||||
Reference in new issue
Block a user