From 4ac969bb3f0ad020e6d31c98bd14c2017d68d6b5 Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 15 Aug 2026 20:49:38 +0530 Subject: [PATCH] fix(folio): attribute a created account by its whole key, not a suffix createdAccountHasNonZeroBalance matched the created card with endsWith, so an older account whose name ends with the typed one ("Emergency Fund" for a typed "Fund") was judged instead whenever the new card was clipped out of the reading. Build both keys the card can carry, the plain name and web's initials + name, and compare them whole. --- examples/folio/sanderling/predicates.ts | 37 ++++++++-- pkg/spec/test/folio-new-account.test.ts | 94 +++++++++++++++++++++++-- 2 files changed, 122 insertions(+), 9 deletions(-) diff --git a/examples/folio/sanderling/predicates.ts b/examples/folio/sanderling/predicates.ts index ab93c60..4d99389 100644 --- a/examples/folio/sanderling/predicates.ts +++ b/examples/folio/sanderling/predicates.ts @@ -259,6 +259,24 @@ export function cardAccountName(args: { return head.slice(0, label.index).trim(); } +// The avatar text that opens a merged card's identity key, mirroring Folio's +// initialsOf (app/shared/.../util/Format.kt). +// +// A mirror because the alternative is a suffix test, and a suffix test cannot +// say which card a name belongs to. Drift can only cost a detection: the result +// is compared whole against a card's key, so initials that stop matching the +// app match no card rather than the wrong one. +export function initialsOf(name: string): string { + // Java's \s, which is what Kotlin's Regex("\\s+") compiles to. JS's \s also + // matches the unicode spaces, and would split names the app keeps whole. + const parts = name.trim().split(/[ \t\n\v\f\r]+/).filter(part => part !== ""); + const first = parts[0]; + const last = parts[parts.length - 1]; + if (first === undefined || last === undefined) return "?"; + if (parts.length === 1) return first.slice(0, 2).toUpperCase(); + return (first.slice(0, 1) + last.slice(0, 1)).toUpperCase(); +} + // One card's transaction count, in the strongest form its SOURCE supports. The // two forms are the whole reason this is not just a number: // @@ -386,10 +404,21 @@ export function createdAccountHasNonZeroBalance(args: { if (typed === "") return false; // Web merges the card into one node whose text opens with the avatar // initials, so the identity key is "INInvestments" where android and iOS give - // "Investments"; endsWith covers both. Two cards answering to the same typed - // name (a second "Travel", or a card the tree exposed twice) leave the - // appearance unattributable, so nothing is judged. - const matches = after.filter(account => account.name.endsWith(typed)); + // "Investments". Both forms are built from the name that was typed and + // compared whole. A suffix test covered both too, and it also let any OTHER + // account ending in those letters answer for the created one: type "Fund" + // next to an existing "Emergency Fund", have the new card clipped out of the + // reading the way Home clips any card, and the old account is convicted for + // money it has held all along. It cost detections as well, because a typed + // name that two cards end with is judged as unattributable rather than as the + // one card that carries it. + // + // Two cards answering to one key stay unattributable: Accounts.name is UNIQUE + // and Repository.createAccount rejects a name already taken, so that pair is + // a card the tree exposed twice, or two names the merged key cannot tell + // apart. + const mergedKey = initialsOf(typed) + typed; + const matches = after.filter(account => account.name === typed || account.name === mergedKey); const created = matches.length === 1 ? matches[0] : undefined; if (created === undefined) return false; if (before.some(account => account.name === created.name)) return false; diff --git a/pkg/spec/test/folio-new-account.test.ts b/pkg/spec/test/folio-new-account.test.ts index d5ee3ab..8c9950c 100644 --- a/pkg/spec/test/folio-new-account.test.ts +++ b/pkg/spec/test/folio-new-account.test.ts @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { createdAccountHasNonZeroBalance } from "../../../examples/folio/sanderling/predicates.ts"; +import { + createdAccountHasNonZeroBalance, + initialsOf, +} from "../../../examples/folio/sanderling/predicates.ts"; const created = { kind: "Tap", @@ -209,9 +212,90 @@ test("the merged web key still matches the name that was typed", () => { ); }); -// Two cards answering to one typed name leave the appearance unattributable: -// the fuzzer creates duplicates from a five-name list, and the tree has been -// seen exposing the same card twice on a transition frame. +// The avatar the merged web key opens with, hand-computed off Format.kt rather +// than off the mirror, because a mirror checked against itself checks nothing. +// A single word gives its first two characters, several give the first letter +// of the first and of the last, and an empty name gives "?". +test("the initials a merged key opens with are the app's", () => { + const named: [string, string][] = [ + ["CH", "Checking"], + ["SA", "Savings"], + ["TR", "Travel"], + ["EF", "Emergency Fund"], + ["IN", "Investments"], + ["FU", "Fund"], + ["T2", "Travel 2024"], + ["A", "a"], + ["X9", "x9"], + ["-1", "-1"], + ["?", ""], + ["?", " "], + ]; + for (const [initials, name] of named) { + assert.equal(initialsOf(name), initials, `initials for ${JSON.stringify(name)}`); + } +}); + +// The attribution used to be a suffix test, and a suffix test hands the verdict +// to whichever OTHER account happens to end with the typed name. Home lists +// what fits the viewport, so the card that was just created is clipped out of +// the reading exactly as easily as any other, and the older account left in it +// is then judged for money it has held all along. +test("an older account whose name ends with the typed one is not the created one", () => { + assert.equal( + createdAccountHasNonZeroBalance({ + route: "home", + lastAction: created, + typedName: "Fund", + before: [account("Checking", 0)], + after: [account("Checking", 0), account("Emergency Fund", 461012300)], + }), + false, + ); +}); + +test("the merged web key is matched whole too, not by its ending", () => { + assert.equal( + createdAccountHasNonZeroBalance({ + route: "home", + lastAction: created, + typedName: "Fund", + before: [account("CHChecking", 0)], + after: [account("CHChecking", 0), account("EFEmergency Fund", 461012300)], + }), + false, + ); +}); + +// The card that was actually asked for is still judged, standing next to the +// account that merely ends with its name. +test("the created card is judged beside an account whose name ends with it", () => { + assert.equal( + createdAccountHasNonZeroBalance({ + route: "home", + lastAction: created, + typedName: "Fund", + before: [account("Emergency Fund", 461012300)], + after: [account("Emergency Fund", 461012300), account("Fund", 5000)], + }), + true, + ); + assert.equal( + createdAccountHasNonZeroBalance({ + route: "home", + lastAction: created, + typedName: "Fund", + before: [account("EFEmergency Fund", 461012300)], + after: [account("EFEmergency Fund", 461012300), account("FUFund", 5000)], + }), + true, + ); +}); + +// Two cards answering to one typed name leave the appearance unattributable. +// Accounts.name is UNIQUE and Repository.createAccount rejects a name already +// taken, so the pair is one card the tree exposed twice on a transition frame, +// or two names the merged web key cannot tell apart. test("two cards matching the typed name are not attributable to the creation", () => { assert.equal( createdAccountHasNonZeroBalance({ @@ -219,7 +303,7 @@ test("two cards matching the typed name are not attributable to the creation", ( lastAction: created, typedName: "Travel", before: [account("Checking", 0)], - after: [account("Checking", 0), account("Travel", 5000), account("MyTravel", 900)], + after: [account("Checking", 0), account("Travel", 5000), account("Travel", 900)], }), false, );