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.
This commit is contained in:
pj committed 2026-08-15 12:45:09 +05:30
1 parent ea4d40e507
commit ade3e77ff5
2 files changed
+105 -1

No files matched your search

+15 -1
View File
@@ -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<string, TxnCount> | null {
const cardsPerName = new Map<string, number>();
for (const card of cards) cardsPerName.set(card.name, (cardsPerName.get(card.name) ?? 0) + 1);
const counts: Record<string, TxnCount> = {};
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;
}
@@ -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" },
);
});