mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
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.
This commit is contained in:
1 parent
11f72a722a
commit
4ac969bb3f
2 files changed
+122
-9
No files matched your search
@@ -259,6 +259,24 @@ export function cardAccountName(args: {
|
|||||||
return head.slice(0, label.index).trim();
|
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
|
// 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:
|
// two forms are the whole reason this is not just a number:
|
||||||
//
|
//
|
||||||
@@ -386,10 +404,21 @@ export function createdAccountHasNonZeroBalance(args: {
|
|||||||
if (typed === "") return false;
|
if (typed === "") return false;
|
||||||
// Web merges the card into one node whose text opens with the avatar
|
// 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
|
// initials, so the identity key is "INInvestments" where android and iOS give
|
||||||
// "Investments"; endsWith covers both. Two cards answering to the same typed
|
// "Investments". Both forms are built from the name that was typed and
|
||||||
// name (a second "Travel", or a card the tree exposed twice) leave the
|
// compared whole. A suffix test covered both too, and it also let any OTHER
|
||||||
// appearance unattributable, so nothing is judged.
|
// account ending in those letters answer for the created one: type "Fund"
|
||||||
const matches = after.filter(account => account.name.endsWith(typed));
|
// 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;
|
const created = matches.length === 1 ? matches[0] : undefined;
|
||||||
if (created === undefined) return false;
|
if (created === undefined) return false;
|
||||||
if (before.some(account => account.name === created.name)) return false;
|
if (before.some(account => account.name === created.name)) return false;
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
|
|
||||||
import { createdAccountHasNonZeroBalance } from "../../../examples/folio/sanderling/predicates.ts";
|
import {
|
||||||
|
createdAccountHasNonZeroBalance,
|
||||||
|
initialsOf,
|
||||||
|
} from "../../../examples/folio/sanderling/predicates.ts";
|
||||||
|
|
||||||
const created = {
|
const created = {
|
||||||
kind: "Tap",
|
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 avatar the merged web key opens with, hand-computed off Format.kt rather
|
||||||
// the fuzzer creates duplicates from a five-name list, and the tree has been
|
// than off the mirror, because a mirror checked against itself checks nothing.
|
||||||
// seen exposing the same card twice on a transition frame.
|
// 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", () => {
|
test("two cards matching the typed name are not attributable to the creation", () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
createdAccountHasNonZeroBalance({
|
createdAccountHasNonZeroBalance({
|
||||||
@@ -219,7 +303,7 @@ test("two cards matching the typed name are not attributable to the creation", (
|
|||||||
lastAction: created,
|
lastAction: created,
|
||||||
typedName: "Travel",
|
typedName: "Travel",
|
||||||
before: [account("Checking", 0)],
|
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,
|
false,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in new issue
Block a user