@remit/web-client 0.0.281 → 0.0.283

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,84 +1,192 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
- import type { SearchResult } from "@remit/ui";
4
- import { spamOfferForResults } from "./spam-offer";
5
-
6
- const row = (
7
- id: string,
8
- mailboxId: string,
9
- role?: "junk" | "inbox",
10
- ): SearchResult => ({
11
- id,
12
- sender: "Someone",
13
- subject: "Subject",
14
- snippet: "",
15
- date: "",
3
+ import { type SpamMailboxCount, spamOfferFromCounts } from "./spam-offer";
4
+
5
+ const exact = (mailboxId: string, value: number): SpamMailboxCount => ({
6
+ mailboxId,
7
+ count: { kind: "exact", value },
8
+ failed: false,
9
+ });
10
+
11
+ const uncounted = (mailboxId: string): SpamMailboxCount => ({
12
+ mailboxId,
13
+ count: { kind: "unknown" },
14
+ failed: false,
15
+ });
16
+
17
+ const errored = (mailboxId: string): SpamMailboxCount => ({
16
18
  mailboxId,
17
- ...(role ? { folder: { role } } : {}),
19
+ count: { kind: "unknown" },
20
+ failed: true,
21
+ });
22
+
23
+ /** The loaded page held no junk row of its own. */
24
+ const NO_PAGE_ROWS = { pageRowsByMailbox: new Map<string, number>() };
25
+
26
+ const pageRows = (
27
+ entries: Record<string, number>,
28
+ ): { pageRowsByMailbox: ReadonlyMap<string, number> } => ({
29
+ pageRowsByMailbox: new Map(Object.entries(entries)),
18
30
  });
19
31
 
20
- describe("spamOfferForResults", () => {
21
- it("makes no offer when nothing came from Spam", () => {
32
+ describe("spamOfferFromCounts", () => {
33
+ it("makes no offer when there is no junk folder to count", () => {
34
+ assert.equal(spamOfferFromCounts([], NO_PAGE_ROWS), undefined);
35
+ });
36
+
37
+ it("makes no offer when every junk folder counted zero", () => {
22
38
  assert.equal(
23
- spamOfferForResults([row("1", "mb-inbox", "inbox"), row("2", "mb-a")]),
39
+ spamOfferFromCounts(
40
+ [exact("mb-junk-a", 0), exact("mb-junk-b", 0)],
41
+ NO_PAGE_ROWS,
42
+ ),
24
43
  undefined,
25
44
  );
26
45
  });
27
46
 
28
- it("counts the spam rows and names their mailbox", () => {
47
+ it("states the server's count for the one junk folder", () => {
48
+ assert.deepEqual(
49
+ spamOfferFromCounts([exact("mb-junk", 42)], NO_PAGE_ROWS),
50
+ {
51
+ mailboxId: "mb-junk",
52
+ count: { kind: "exact", value: 42 },
53
+ },
54
+ );
55
+ });
56
+
57
+ it("sums every junk folder, so several accounts read as one number", () => {
58
+ assert.deepEqual(
59
+ spamOfferFromCounts(
60
+ [exact("mb-junk-a", 3), exact("mb-junk-b", 7), exact("mb-junk-c", 2)],
61
+ NO_PAGE_ROWS,
62
+ ),
63
+ { mailboxId: "mb-junk-b", count: { kind: "exact", value: 12 } },
64
+ );
65
+ });
66
+
67
+ it("sends the reader to the junk folder holding the most matches", () => {
68
+ assert.equal(
69
+ spamOfferFromCounts(
70
+ [exact("mb-junk-a", 1), exact("mb-junk-b", 9)],
71
+ NO_PAGE_ROWS,
72
+ )?.mailboxId,
73
+ "mb-junk-b",
74
+ );
75
+ });
76
+
77
+ it("breaks a tie on folder order, so the destination does not flip", () => {
78
+ assert.equal(
79
+ spamOfferFromCounts(
80
+ [exact("mb-junk-a", 4), exact("mb-junk-b", 4)],
81
+ NO_PAGE_ROWS,
82
+ )?.mailboxId,
83
+ "mb-junk-a",
84
+ );
85
+ });
86
+
87
+ it("states no number when one junk folder went uncounted", () => {
88
+ // Summing only the folders that answered would state a figure that is exact
89
+ // in form and short in fact.
29
90
  assert.deepEqual(
30
- spamOfferForResults([
31
- row("1", "mb-inbox", "inbox"),
32
- row("2", "mb-junk", "junk"),
33
- row("3", "mb-junk", "junk"),
34
- ]),
35
- { mailboxId: "mb-junk", count: 2 },
91
+ spamOfferFromCounts(
92
+ [exact("mb-junk-a", 5), uncounted("mb-junk-b")],
93
+ NO_PAGE_ROWS,
94
+ ),
95
+ { mailboxId: "mb-junk-a", count: { kind: "unknown" } },
96
+ );
97
+ });
98
+
99
+ it("stands down when nothing is known and the page held no spam either", () => {
100
+ assert.equal(
101
+ spamOfferFromCounts(
102
+ [uncounted("mb-junk-a"), uncounted("mb-junk-b")],
103
+ NO_PAGE_ROWS,
104
+ ),
105
+ undefined,
36
106
  );
37
107
  });
38
108
 
39
- it("targets the Spam folder with the most matches when accounts compete", () => {
109
+ // A failed count is not a folder saying it holds nothing. Dropping the offer
110
+ // on one would make a lapsed session look exactly like a clean search.
111
+ it("keeps the offer when a count request failed", () => {
40
112
  assert.deepEqual(
41
- spamOfferForResults([
42
- row("1", "mb-junk-a", "junk"),
43
- row("2", "mb-junk-b", "junk"),
44
- row("3", "mb-junk-b", "junk"),
45
- ]),
46
- { mailboxId: "mb-junk-b", count: 2 },
113
+ spamOfferFromCounts(
114
+ [exact("mb-junk-a", 0), errored("mb-junk-b")],
115
+ NO_PAGE_ROWS,
116
+ ),
117
+ { mailboxId: "mb-junk-b", count: { kind: "unknown" } },
47
118
  );
48
119
  });
49
120
 
50
- it("breaks a tie on result order, so the offer does not flip", () => {
121
+ it("does not read an uncounted folder as an empty one", () => {
51
122
  assert.deepEqual(
52
- spamOfferForResults([
53
- row("1", "mb-junk-a", "junk"),
54
- row("2", "mb-junk-b", "junk"),
55
- ]),
56
- { mailboxId: "mb-junk-a", count: 1 },
123
+ spamOfferFromCounts(
124
+ [exact("mb-junk-a", 0), uncounted("mb-junk-b")],
125
+ pageRows({ "mb-junk-b": 1 }),
126
+ ),
127
+ { mailboxId: "mb-junk-b", count: { kind: "unknown" } },
128
+ );
129
+ });
130
+ });
131
+
132
+ describe("spamOfferFromCounts destination", () => {
133
+ // The regression: with counts switched off, every folder is `unknown` and the
134
+ // destination fell to whichever folder happened to be first. Two accounts,
135
+ // every held-out row in B's Spam, and the button opened A's — "No matches"
136
+ // over mail the list was holding out on the very same screen.
137
+ it("follows the page's junk rows when no folder was counted", () => {
138
+ assert.equal(
139
+ spamOfferFromCounts(
140
+ [uncounted("mb-junk-a"), uncounted("mb-junk-b")],
141
+ pageRows({ "mb-junk-b": 3 }),
142
+ )?.mailboxId,
143
+ "mb-junk-b",
144
+ );
145
+ });
146
+
147
+ // An exact zero is the one folder known to hold nothing, so it is the one
148
+ // destination guaranteed to show an empty list — below a folder that simply
149
+ // has not answered.
150
+ it("never sends the reader to a folder that counted zero", () => {
151
+ assert.equal(
152
+ spamOfferFromCounts(
153
+ [exact("mb-junk-a", 0), uncounted("mb-junk-b"), errored("mb-junk-c")],
154
+ NO_PAGE_ROWS,
155
+ )?.mailboxId,
156
+ "mb-junk-b",
57
157
  );
58
158
  });
59
159
 
60
- it("picks a destination without claiming to have counted every account", () => {
61
- // Three spam rows across two accounts: the destination is the bigger folder,
62
- // and its share is 2 — deliberately not the total, which the results list
63
- // states for itself so the banner cannot under-report.
64
- const offer = spamOfferForResults([
65
- row("1", "mb-junk-a", "junk"),
66
- row("2", "mb-junk-b", "junk"),
67
- row("3", "mb-junk-b", "junk"),
68
- ]);
69
- assert.equal(offer?.mailboxId, "mb-junk-b");
70
- assert.equal(offer?.count, 2);
160
+ it("prefers a counted match over the folder the page's rows came from", () => {
161
+ assert.equal(
162
+ spamOfferFromCounts(
163
+ [exact("mb-junk-a", 9), uncounted("mb-junk-b")],
164
+ pageRows({ "mb-junk-b": 4 }),
165
+ )?.mailboxId,
166
+ "mb-junk-a",
167
+ );
168
+ });
169
+
170
+ // The mailbox lists are still loading, or the folder arrived after the count
171
+ // requests went out. Its rows are already held out of the list, so it has to
172
+ // be reachable.
173
+ it("offers a junk folder the page names and the counts do not", () => {
174
+ assert.deepEqual(spamOfferFromCounts([], pageRows({ "mb-junk-late": 2 })), {
175
+ mailboxId: "mb-junk-late",
176
+ count: { kind: "unknown" },
177
+ });
71
178
  });
72
179
 
73
- it("ignores a spam row that carries no mailbox to navigate to", () => {
74
- const orphan: SearchResult = {
75
- id: "1",
76
- sender: "Someone",
77
- subject: "Subject",
78
- snippet: "",
79
- date: "",
80
- folder: { role: "junk" },
81
- };
82
- assert.equal(spamOfferForResults([orphan]), undefined);
180
+ // A count that says nothing over a page that is holding junk rows is stale by
181
+ // up to its half-minute. The rows are on screen; the offer stands and states
182
+ // no figure rather than "0 results from Spam".
183
+ it("states no number when an exact zero contradicts the page's rows", () => {
184
+ assert.deepEqual(
185
+ spamOfferFromCounts(
186
+ [exact("mb-junk-a", 0), exact("mb-junk-b", 0)],
187
+ pageRows({ "mb-junk-b": 2 }),
188
+ ),
189
+ { mailboxId: "mb-junk-b", count: { kind: "unknown" } },
190
+ );
83
191
  });
84
192
  });
@@ -1,37 +1,163 @@
1
1
  /**
2
- * Where taking the Spam offer lands.
2
+ * The Spam offer: how much of a search sits in Spam, and where taking the offer
3
+ * lands.
3
4
  *
4
- * Scoping is a route and a route names one mailbox, so the offer has to pick a
5
- * single Spam folder even when several accounts matched; it picks the one
6
- * holding the most matches. `count` is that folder's share, used only to choose
7
- * between folders — the banner states the full number of rows held out, which
8
- * the results list counts for itself.
5
+ * Both answers come from one server count per junk folder the search reached
6
+ * (`useSpamMatchCounts`). The number is their sum, because a search spans every
7
+ * account and every account has its own Spam folder — "N in Spam" that named
8
+ * one folder's share would be smaller than the mail the phrase describes.
9
+ *
10
+ * The destination is a single folder even so: scoping is a route and a route
11
+ * names one mailbox. It is the folder holding the most matches, which is the
12
+ * best a single click can do, and `SpamResultsOffer` says where the button goes
13
+ * rather than promising to show every match. Where no count ranks the folders —
14
+ * a short query, a residual token, a failed request — the page's own junk rows
15
+ * do, because they are the only evidence left of where the mail is.
16
+ *
17
+ * This used to group the search results the header held and count each group.
18
+ * That array is a page, so the figure was a count of the loaded window offered
19
+ * as a count of a mailbox: a Spam match below the page was missing from it, and
20
+ * the number climbed as further pages loaded (#313).
21
+ *
22
+ * A folder whose count did not arrive makes the total `unknown`. Summing the
23
+ * ones that did would state a figure that is exact in form and short in fact,
24
+ * and nothing distinguishes it from a true one on screen.
9
25
  */
10
- import { partitionSpamResults, type SearchResult } from "@remit/ui";
26
+ import type { ResultCount } from "@remit/ui";
27
+
28
+ export interface SpamMailboxCount {
29
+ mailboxId: string;
30
+ count: ResultCount;
31
+ /**
32
+ * The count request failed. Distinct from `unknown`, which also covers a
33
+ * request still in flight or one nobody made: a failure is a folder that
34
+ * could not answer, and it must never read as a folder holding nothing.
35
+ */
36
+ failed: boolean;
37
+ }
11
38
 
12
39
  export interface SpamOffer {
40
+ /** The junk folder "Go to Spam" scopes the search to. */
41
+ mailboxId: string;
42
+ /** Matches across every junk folder, or `unknown` if any went uncounted. */
43
+ count: ResultCount;
44
+ }
45
+
46
+ export interface SpamOfferEvidence {
47
+ /**
48
+ * Junk rows the loaded page held, per junk folder.
49
+ *
50
+ * Never the number the offer states — a page is a page. It is what says
51
+ * spam was reached at all while the counts are absent, and, crucially, WHICH
52
+ * folder the reader is being sent to: with no count to rank the folders by,
53
+ * the rows on screen are the only evidence of where the mail is, and a
54
+ * "Go to Spam" that lands on the wrong account's folder shows "No matches"
55
+ * over mail this very page is holding out.
56
+ */
57
+ pageRowsByMailbox: ReadonlyMap<string, number>;
58
+ }
59
+
60
+ interface Candidate {
13
61
  mailboxId: string;
14
- count: number;
62
+ count: ResultCount;
63
+ failed: boolean;
64
+ pageRows: number;
15
65
  }
16
66
 
17
- export function spamOfferForResults(
18
- results: readonly SearchResult[],
67
+ /**
68
+ * How good a destination a folder is, before ties are broken.
69
+ *
70
+ * A counted match is the best evidence; junk rows on the page are the next
71
+ * best, and they outrank the folder's own count because the rows are on screen
72
+ * while the count may be half a minute old. A folder that answered zero and
73
+ * shows no row ranks LAST — it is the only one known to hold nothing, so
74
+ * sending the reader there is the one choice guaranteed to show an empty list.
75
+ */
76
+ const tier = (candidate: Candidate): number => {
77
+ if (candidate.count.kind === "exact" && candidate.count.value > 0) return 3;
78
+ if (candidate.pageRows > 0) return 2;
79
+ return candidate.count.kind === "unknown" ? 1 : 0;
80
+ };
81
+
82
+ const exactValue = (candidate: Candidate): number =>
83
+ candidate.count.kind === "exact" ? candidate.count.value : 0;
84
+
85
+ /** Strict, so equal candidates leave the earlier one standing. */
86
+ const isBetterDestination = (
87
+ candidate: Candidate,
88
+ standing: Candidate,
89
+ ): boolean => {
90
+ if (tier(candidate) !== tier(standing)) {
91
+ return tier(candidate) > tier(standing);
92
+ }
93
+ if (exactValue(candidate) !== exactValue(standing)) {
94
+ return exactValue(candidate) > exactValue(standing);
95
+ }
96
+ return candidate.pageRows > standing.pageRows;
97
+ };
98
+
99
+ /**
100
+ * The offer these per-folder counts support, or none.
101
+ *
102
+ * None when every junk folder answered and they sum to zero with no junk row on
103
+ * the page either — the search reaches no spam. Anything else is an offer: rows
104
+ * held out of the list are always offered a way back, because hiding mail
105
+ * without one is worse than hiding a number.
106
+ *
107
+ * The figure is the sum, and only when every folder answered. One folder
108
+ * missing takes it off, because summing the folders that replied states a
109
+ * number exact in form and short in fact. A sum of zero over a page that is
110
+ * holding junk rows takes it off too: the two disagree, so neither is stated.
111
+ */
112
+ export function spamOfferFromCounts(
113
+ counts: readonly SpamMailboxCount[],
114
+ { pageRowsByMailbox }: SpamOfferEvidence,
19
115
  ): SpamOffer | undefined {
20
- const { spam } = partitionSpamResults([...results]);
21
- const countByMailbox = new Map<string, number>();
22
- for (const result of spam) {
23
- if (!result.mailboxId) continue;
24
- countByMailbox.set(
25
- result.mailboxId,
26
- (countByMailbox.get(result.mailboxId) ?? 0) + 1,
27
- );
116
+ const candidates: Candidate[] = counts.map((entry) => ({
117
+ ...entry,
118
+ pageRows: pageRowsByMailbox.get(entry.mailboxId) ?? 0,
119
+ }));
120
+ // A folder the page names but the counts do not — the mailbox lists are still
121
+ // loading, or the folder arrived after the requests went out. Its rows are
122
+ // already being held out of the list, so it has to be reachable.
123
+ for (const [mailboxId, pageRows] of pageRowsByMailbox) {
124
+ if (pageRows <= 0) continue;
125
+ if (candidates.some((entry) => entry.mailboxId === mailboxId)) continue;
126
+ candidates.push({
127
+ mailboxId,
128
+ count: { kind: "unknown" },
129
+ failed: false,
130
+ pageRows,
131
+ });
132
+ }
133
+
134
+ const first = candidates[0];
135
+ if (!first) return undefined;
136
+
137
+ let total = 0;
138
+ let everyFolderAnswered = true;
139
+ let best = first;
140
+ for (const candidate of candidates) {
141
+ if (candidate.count.kind === "unknown") everyFolderAnswered = false;
142
+ else total += candidate.count.value;
143
+ if (isBetterDestination(candidate, best)) best = candidate;
28
144
  }
29
145
 
30
- let offer: SpamOffer | undefined;
31
- // Insertion order is result order, so the first-seen folder wins a tie and
32
- // the offer does not flip between renders of the same results.
33
- for (const [mailboxId, count] of countByMailbox) {
34
- if (!offer || count > offer.count) offer = { mailboxId, count };
146
+ const pageHeldSpam = candidates.some((candidate) => candidate.pageRows > 0);
147
+ const anyFailed = candidates.some((candidate) => candidate.failed);
148
+ if (total === 0 && !pageHeldSpam && (everyFolderAnswered || !anyFailed)) {
149
+ return undefined;
35
150
  }
36
- return offer;
151
+
152
+ // The count and the page disagree: an exact zero under rows the list is
153
+ // holding out. The rows are on screen and the count is up to half a minute
154
+ // old, so the offer stands and states nothing.
155
+ const contradicted = everyFolderAnswered && total === 0 && pageHeldSpam;
156
+ return {
157
+ mailboxId: best.mailboxId,
158
+ count:
159
+ everyFolderAnswered && !contradicted
160
+ ? { kind: "exact", value: total }
161
+ : { kind: "unknown" },
162
+ };
37
163
  }