From ade3e77ff51f306b6a3252de3e358c9a77c1e496 Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 15 Aug 2026 12:45:09 +0530 Subject: [PATCH] fix(folio): drop a name two cards answer to homeTxnCountsOf keyed on the account name and let the last card win, so two accounts the fuzzer named the same collapsed into one entry. a reading that saw one Travel card and a later one that saw both then subtracted two different accounts' counts, and submitCommitsOneTransactionPerAction convicted a healthy app of double-submitting. it is a gated property in folio-run.sh, so that reads as "found the submit bug" over a card scrolling into view. same rule createdAccountHasNonZeroBalance already applies: a name nothing can attribute is no evidence. counted over every card, since an unreadable twin spoils the identity too. --- examples/folio/sanderling/predicates.ts | 16 +++- .../test/folio-txn-count-invariant.test.ts | 90 +++++++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/examples/folio/sanderling/predicates.ts b/examples/folio/sanderling/predicates.ts index 6b061ab..f476238 100644 --- a/examples/folio/sanderling/predicates.ts +++ b/examples/folio/sanderling/predicates.ts @@ -280,10 +280,24 @@ export function homeAccountsOf(cards: readonly CardReading[]): Account[] | null // A card whose name or count is unreadable is left out rather than guessed at; // committedTransactionsExceedSubmits treats a missing account as no evidence. // Every card being unreadable leaves nothing to compare, which is unknown. +// +// A name carried by more than one card is left out for the same reason, the +// rule createdAccountHasNonZeroBalance applies with `matches.length === 1`: +// nothing here can say which of them a count came from. Folio accepts the same +// account name twice and Home lists whatever fits the viewport, so a reading +// that saw one Travel card and a later one that saw two would otherwise +// subtract two DIFFERENT accounts' counts and convict a healthy app of +// double-submitting. The twin does not have to be readable to spoil the +// identity, so duplicates are counted over every card, not just the usable +// ones. Dropping a card can only ever cost a detection. export function homeTxnCountsOf(cards: readonly CardReading[]): Record | null { + const cardsPerName = new Map(); + for (const card of cards) cardsPerName.set(card.name, (cardsPerName.get(card.name) ?? 0) + 1); const counts: Record = {}; for (const card of cards) { - if (card.name !== "" && card.count !== undefined) counts[card.name] = card.count; + if (card.name === "" || card.count === undefined) continue; + if (cardsPerName.get(card.name) !== 1) continue; + counts[card.name] = card.count; } return Object.keys(counts).length === 0 ? null : counts; } diff --git a/pkg/spec/test/folio-txn-count-invariant.test.ts b/pkg/spec/test/folio-txn-count-invariant.test.ts index ed97af0..aea2f60 100644 --- a/pkg/spec/test/folio-txn-count-invariant.test.ts +++ b/pkg/spec/test/folio-txn-count-invariant.test.ts @@ -360,3 +360,93 @@ test("the merged-text card list drops the same pair", () => { false, ); }); + +// Home lists whatever fits the viewport, and Folio lets two accounts share a +// name, so one name can arrive on two cards. Keying counts by name collapsed +// them onto the last card, and the two readings a window compares then came off +// DIFFERENT cards: the probe below is a healthy app, one submit, and a scroll. +test("two cards sharing a name do not become one count", () => { + const before = homeTxnCountsOf([{ name: "Travel", balance: 0, count: 0 }]); + const after = homeTxnCountsOf([ + { name: "Travel", balance: 0, count: 0 }, + { name: "Travel", balance: 500, count: 8 }, + ]); + assert.equal( + committedTransactionsExceedSubmits({ + countsBefore: before, + countsAfter: after, + submitsInWindow: 1, + }), + false, + ); + assert.deepEqual(after, null); +}); + +test("a name on two cards is dropped from both readings", () => { + const before = homeTxnCountsOf([ + { name: "Travel", balance: 0, count: "3" }, + { name: "Travel", balance: 0, count: "9" }, + { name: "Checking", balance: 0, count: "2" }, + ]); + const after = homeTxnCountsOf([ + { name: "Travel", balance: 0, count: "3" }, + { name: "Travel", balance: 0, count: "11" }, + { name: "Checking", balance: 0, count: "2" }, + ]); + assert.deepEqual(before, { Checking: "2" }); + assert.deepEqual(after, { Checking: "2" }); + assert.equal( + committedTransactionsExceedSubmits({ + countsBefore: before, + countsAfter: after, + submitsInWindow: 0, + }), + false, + ); +}); + +// The other card need not be readable to spoil the identity: an unreadable +// count still means the name on the map may not be the card that was read. +test("a duplicate name is dropped even when the twin has no count", () => { + assert.deepEqual( + homeTxnCountsOf([ + { name: "Travel", balance: 0, count: 4 }, + { name: "Travel", balance: 0, count: undefined }, + { name: "Savings", balance: 0, count: 1 }, + ]), + { Savings: 1 }, + ); +}); + +// Dropping the ambiguous name must not disable the property for the rest. +test("a unique name is still counted beside a dropped duplicate", () => { + const before = homeTxnCountsOf([ + { name: "Travel", balance: 0, count: 3 }, + { name: "Travel", balance: 0, count: 1 }, + { name: "Checking", balance: 0, count: 4 }, + ]); + const after = homeTxnCountsOf([ + { name: "Travel", balance: 0, count: 3 }, + { name: "Travel", balance: 0, count: 1 }, + { name: "Checking", balance: 0, count: 6 }, + ]); + assert.deepEqual(before, { Checking: 4 }); + assert.equal( + committedTransactionsExceedSubmits({ + countsBefore: before, + countsAfter: after, + submitsInWindow: 1, + }), + true, + ); +}); + +test("distinct names are all counted", () => { + assert.deepEqual( + homeTxnCountsOf([ + { name: "Checking", balance: 0, count: "3" }, + { name: "Savings", balance: 0, count: "1" }, + ]), + { Checking: "3", Savings: "1" }, + ); +});