@remit/web-client 0.0.249 → 0.0.251

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,10 +1,12 @@
1
1
  import assert from "node:assert";
2
2
  import { describe, test } from "node:test";
3
3
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
4
- import type { ThreadRowData } from "@remit/ui";
4
+ import type { ResultCount, ThreadCategory, ThreadRowData } from "@remit/ui";
5
5
  import {
6
+ type BriefCategoryResult,
7
+ briefSections,
8
+ briefSectionTotal,
6
9
  excludeMutedSenders,
7
- groupBriefSections,
8
10
  matchesBriefSearch,
9
11
  matchesSearchTokens,
10
12
  mergeSearchRows,
@@ -83,151 +85,172 @@ describe("toThreadRowData", () => {
83
85
  });
84
86
  });
85
87
 
86
- describe("groupBriefSections", () => {
87
- test("returns empty array when no rows", () => {
88
- const sections = groupBriefSections([]);
89
- assert.deepStrictEqual(sections, []);
90
- });
91
-
92
- // --- One section per category ---
93
-
94
- test("personal goes to the personal section", () => {
95
- const r = row({ id: "1", category: "personal" });
96
- const sections = groupBriefSections([r]);
97
- assert.strictEqual(sections.length, 1);
98
- assert.strictEqual(sections[0].id, "personal");
99
- assert.strictEqual(sections[0].label, "Personal");
88
+ describe("briefSections", () => {
89
+ const result = (
90
+ category: ThreadCategory,
91
+ rows: ThreadRowData[],
92
+ total: ResultCount = { kind: "exact", value: rows.length },
93
+ loading = false,
94
+ failed = false,
95
+ atCap = false,
96
+ ): BriefCategoryResult => ({
97
+ category,
98
+ rows,
99
+ total,
100
+ atCap,
101
+ loading,
102
+ failed,
103
+ });
104
+
105
+ test("returns no sections when the brief asked for nothing", () => {
106
+ assert.deepStrictEqual(briefSections([]), []);
107
+ });
108
+
109
+ test("each category answers for its own section, with its own label", () => {
110
+ const labels = new Map<ThreadCategory, string>([
111
+ ["personal", "Personal"],
112
+ ["transactional", "Transactional"],
113
+ ["newsletter", "Newsletter"],
114
+ ["marketing", "Marketing"],
115
+ ["social", "Social"],
116
+ ["automated", "Automated"],
117
+ ["uncategorized", "Unclassified"],
118
+ ]);
119
+ for (const [category, label] of labels) {
120
+ const sections = briefSections([
121
+ result(category, [row({ id: "1", category })]),
122
+ ]);
123
+ assert.strictEqual(sections.length, 1);
124
+ assert.strictEqual(sections[0].id, category);
125
+ assert.strictEqual(sections[0].label, label);
126
+ }
100
127
  });
101
128
 
102
- test("transactional goes to the transactional section", () => {
103
- const r = row({ id: "1", category: "transactional" });
104
- const sections = groupBriefSections([r]);
105
- assert.strictEqual(sections[0].id, "transactional");
106
- assert.strictEqual(sections[0].label, "Transactional");
129
+ // The reading #312 fixes: on a mailbox whose Marketing mail is all older than
130
+ // the newest unified page, the section held nothing and its header read zero.
131
+ // Its own request answers over the whole scope, so it has both.
132
+ test("a category renders its rows and its real size, page order aside", () => {
133
+ const sections = briefSections([
134
+ result("marketing", [row({ id: "old-1" }), row({ id: "old-2" })], {
135
+ kind: "exact",
136
+ value: 3942,
137
+ }),
138
+ ]);
139
+ assert.deepStrictEqual(
140
+ sections[0].threads.map((t) => t.id),
141
+ ["old-1", "old-2"],
142
+ );
143
+ assert.deepStrictEqual(sections[0].total, { kind: "exact", value: 3942 });
107
144
  });
108
145
 
109
- test("newsletter goes to the newsletter section", () => {
110
- const r = row({ id: "1", category: "newsletter" });
111
- const sections = groupBriefSections([r]);
112
- assert.strictEqual(sections[0].id, "newsletter");
113
- assert.strictEqual(sections[0].label, "Newsletter");
146
+ test("the total is the server's, never the number of rows loaded", () => {
147
+ const sections = briefSections([
148
+ result("personal", [row({ id: "1" })], { kind: "exact", value: 4753 }),
149
+ ]);
150
+ assert.deepStrictEqual(sections[0].total, { kind: "exact", value: 4753 });
114
151
  });
115
152
 
116
- test("marketing goes to the marketing section", () => {
117
- const r = row({ id: "1", category: "marketing" });
118
- const sections = groupBriefSections([r]);
119
- assert.strictEqual(sections[0].id, "marketing");
120
- assert.strictEqual(sections[0].label, "Marketing");
153
+ test("a category the scope holds none of has no section", () => {
154
+ assert.deepStrictEqual(briefSections([result("social", [])]), []);
121
155
  });
122
156
 
123
- test("social goes to the social section", () => {
124
- const r = row({ id: "1", category: "social" });
125
- const sections = groupBriefSections([r]);
126
- assert.strictEqual(sections[0].id, "social");
127
- assert.strictEqual(sections[0].label, "Social");
157
+ test("a category still being fetched keeps its section", () => {
158
+ const sections = briefSections([
159
+ result("social", [], { kind: "exact", value: 88 }, true),
160
+ ]);
161
+ assert.strictEqual(sections.length, 1);
162
+ assert.strictEqual(sections[0].loading, true);
128
163
  });
129
164
 
130
- test("automated goes to the automated section", () => {
131
- const r = row({ id: "1", category: "automated" });
132
- const sections = groupBriefSections([r]);
133
- assert.strictEqual(sections[0].id, "automated");
134
- assert.strictEqual(sections[0].label, "Automated");
165
+ // Seven requests are seven answers. One category's failure states itself where
166
+ // that category would have been; it does not take the other six down.
167
+ test("a category whose own request failed keeps its section", () => {
168
+ const sections = briefSections([
169
+ result("marketing", [], { kind: "unknown" }, false, true),
170
+ result("personal", [row({ id: "1", category: "personal" })]),
171
+ ]);
172
+ assert.deepStrictEqual(
173
+ sections.map((s) => s.id),
174
+ ["personal", "marketing"],
175
+ );
176
+ assert.strictEqual(sections.find((s) => s.id === "marketing")?.error, true);
135
177
  });
136
178
 
137
- // --- Read state is not a routing signal ---
138
-
139
- test("read and unread rows of a category share one section", () => {
140
- const rows: ThreadRowData[] = [
141
- row({ id: "1", isRead: false, category: "personal" }),
142
- row({ id: "2", isRead: true, category: "personal" }),
143
- ];
144
- const sections = groupBriefSections(rows);
145
- assert.strictEqual(sections.length, 1);
146
- assert.strictEqual(sections[0].id, "personal");
147
- assert.strictEqual(sections[0].threads.length, 2);
179
+ // The count is withheld whenever something narrows rows after they arrive, and
180
+ // a full page is then the only thing that says the category holds more. Losing
181
+ // it would leave the section with no number and no way out (#312).
182
+ test("carries the full-page flag through to the section", () => {
183
+ const sections = briefSections([
184
+ result(
185
+ "marketing",
186
+ [row({ id: "1" })],
187
+ { kind: "unknown" },
188
+ false,
189
+ false,
190
+ true,
191
+ ),
192
+ ]);
193
+ assert.strictEqual(sections[0].atCap, true);
148
194
  });
149
195
 
150
- // --- Fallback to personal ---
151
-
152
- test("missing category lands in its own Unclassified section", () => {
153
- // Never folded into Personal: unclassified mail is work the classifier
154
- // has not done, and hiding it inside Personal is what made issue #45
155
- // look like a working classifier with a huge personal inbox.
156
- const r = row({ id: "1" });
157
- const sections = groupBriefSections([r]);
196
+ test("a counted category with no rows left keeps its section", () => {
197
+ const sections = briefSections([
198
+ result("personal", [], { kind: "exact", value: 4753 }),
199
+ ]);
158
200
  assert.strictEqual(sections.length, 1);
159
- assert.strictEqual(sections[0].id, "uncategorized");
201
+ assert.deepStrictEqual(sections[0].threads, []);
160
202
  });
161
203
 
162
- test("uncategorized rows do not inflate the personal section", () => {
163
- const sections = groupBriefSections([
164
- row({ id: "1", category: "personal" }),
165
- row({ id: "2", category: "uncategorized" }),
166
- row({ id: "3", category: "uncategorized" }),
167
- ]);
168
- const personal = sections.find((s) => s.id === "personal");
169
- const unclassified = sections.find((s) => s.id === "uncategorized");
170
- assert.strictEqual(personal?.threads.length, 1);
171
- assert.strictEqual(unclassified?.threads.length, 2);
204
+ test("an uncounted category with no rows has nothing to render", () => {
205
+ assert.deepStrictEqual(
206
+ briefSections([result("personal", [], { kind: "unknown" })]),
207
+ [],
208
+ );
172
209
  });
173
210
 
174
- // --- Starred is a row marker, not a section (Flagged lives in the nav) ---
211
+ // --- Row markers do not move a row out of its category ---
175
212
 
176
- test("a starred newsletter stays in the newsletter section", () => {
177
- const r = row({ id: "1", starred: true, category: "newsletter" });
178
- const sections = groupBriefSections([r]);
179
- assert.strictEqual(sections.length, 1);
180
- assert.strictEqual(sections[0].id, "newsletter");
181
- });
182
-
183
- test("a starred personal message stays in the personal section", () => {
184
- const r = row({ id: "1", starred: true, category: "personal" });
185
- const sections = groupBriefSections([r]);
213
+ test("read and unread rows share their category's section", () => {
214
+ const sections = briefSections([
215
+ result("personal", [
216
+ row({ id: "1", isRead: false, category: "personal" }),
217
+ row({ id: "2", isRead: true, category: "personal" }),
218
+ ]),
219
+ ]);
186
220
  assert.strictEqual(sections.length, 1);
187
- assert.strictEqual(sections[0].id, "personal");
221
+ assert.strictEqual(sections[0].threads.length, 2);
188
222
  });
189
223
 
190
224
  test("starred mail never produces a flagged section", () => {
191
- const rows: ThreadRowData[] = [
192
- row({ id: "p", category: "personal" }),
193
- row({ id: "f", starred: true, category: "automated" }),
194
- ];
195
- const sections = groupBriefSections(rows);
225
+ const sections = briefSections([
226
+ result("personal", [row({ id: "p", category: "personal" })]),
227
+ result("automated", [
228
+ row({ id: "f", starred: true, category: "automated" }),
229
+ ]),
230
+ ]);
196
231
  assert.deepStrictEqual(
197
232
  sections.map((s) => s.id),
198
233
  ["personal", "automated"],
199
234
  );
200
235
  });
201
236
 
202
- // --- Trust no longer sections ---
203
-
204
- test("a vip newsletter stays in the newsletter section (trust does not section)", () => {
205
- const r = row({ id: "1", trust: "vip", category: "newsletter" });
206
- const sections = groupBriefSections([r]);
207
- assert.strictEqual(sections.length, 1);
208
- assert.strictEqual(sections[0].id, "newsletter");
209
- });
210
-
211
- test("a wellknown automated row stays in the automated section", () => {
212
- const r = row({ id: "1", trust: "wellknown", category: "automated" });
213
- const sections = groupBriefSections([r]);
214
- assert.strictEqual(sections.length, 1);
215
- assert.strictEqual(sections[0].id, "automated");
216
- });
217
-
218
237
  // --- Section order and omission ---
219
238
 
220
- test("display order is: personal, transactional, newsletter, marketing, social, automated", () => {
221
- const rows: ThreadRowData[] = [
222
- row({ id: "auto", category: "automated" }),
223
- row({ id: "social", category: "social" }),
224
- row({ id: "mkt", category: "marketing" }),
225
- row({ id: "news", category: "newsletter" }),
226
- row({ id: "txn", category: "transactional" }),
227
- row({ id: "pers", category: "personal" }),
228
- row({ id: "star", starred: true, category: "automated" }),
239
+ test("display order is fixed, Unclassified last", () => {
240
+ const seeded: ThreadCategory[] = [
241
+ "automated",
242
+ "social",
243
+ "marketing",
244
+ "newsletter",
245
+ "transactional",
246
+ "personal",
247
+ "uncategorized",
229
248
  ];
230
- const sections = groupBriefSections(rows);
249
+ const sections = briefSections(
250
+ seeded.map((category) =>
251
+ result(category, [row({ id: category, category })]),
252
+ ),
253
+ );
231
254
  assert.deepStrictEqual(
232
255
  sections.map((s) => s.id),
233
256
  [
@@ -237,33 +260,30 @@ describe("groupBriefSections", () => {
237
260
  "marketing",
238
261
  "social",
239
262
  "automated",
263
+ "uncategorized",
240
264
  ],
241
265
  );
242
266
  });
243
267
 
244
- test("empty sections are omitted", () => {
245
- const sections = groupBriefSections([row({ id: "1", category: "social" })]);
246
- assert.deepStrictEqual(
247
- sections.map((s) => s.id),
248
- ["social"],
268
+ // D6 / issue #45: unclassified mail is work the classifier has not done, and
269
+ // hiding it inside Personal is what made #45 look like a working classifier
270
+ // with a huge personal inbox.
271
+ test("uncategorized is its own section and never inflates personal", () => {
272
+ const sections = briefSections([
273
+ result("personal", [row({ id: "1", category: "personal" })]),
274
+ result("uncategorized", [
275
+ row({ id: "2", category: "uncategorized" }),
276
+ row({ id: "3", category: "uncategorized" }),
277
+ ]),
278
+ ]);
279
+ assert.strictEqual(
280
+ sections.find((s) => s.id === "personal")?.threads.length,
281
+ 1,
282
+ );
283
+ assert.strictEqual(
284
+ sections.find((s) => s.id === "uncategorized")?.threads.length,
285
+ 2,
249
286
  );
250
- });
251
-
252
- test("each row appears in exactly one section", () => {
253
- const rows: ThreadRowData[] = [
254
- row({ id: "1", category: "personal" }),
255
- row({ id: "2", starred: true, category: "automated" }),
256
- row({ id: "3", category: "automated" }),
257
- row({ id: "4", category: "transactional" }),
258
- row({ id: "5", starred: true, category: "newsletter" }),
259
- row({ id: "6", category: "newsletter" }),
260
- row({ id: "7", category: "marketing" }),
261
- ];
262
- const sections = groupBriefSections(rows);
263
- const allIds = sections.flatMap((s) => s.threads.map((t) => t.id));
264
- assert.strictEqual(allIds.length, rows.length);
265
- const uniqueIds = new Set(allIds);
266
- assert.strictEqual(uniqueIds.size, rows.length);
267
287
  });
268
288
  });
269
289
 
@@ -310,14 +330,19 @@ describe("excludeMutedSenders", () => {
310
330
  category: "personal",
311
331
  }),
312
332
  ];
313
- const sections = groupBriefSections(
314
- excludeMutedSenders(rows).map(toThreadRowData),
315
- );
333
+ const kept = excludeMutedSenders(rows).map(toThreadRowData);
334
+ const sections = briefSections([
335
+ {
336
+ category: "personal",
337
+ rows: kept,
338
+ total: { kind: "exact", value: kept.length },
339
+ atCap: false,
340
+ loading: false,
341
+ failed: false,
342
+ },
343
+ ]);
316
344
  const allIds = sections.flatMap((s) => s.threads.map((t) => t.id));
317
345
  assert.deepStrictEqual(allIds, ["kept-personal"]);
318
- for (const section of sections) {
319
- assert.ok(!section.threads.some((t) => t.id === "muted-personal"));
320
- }
321
346
  });
322
347
 
323
348
  test("muting every candidate row leaves no sections (brief empty state)", () => {
@@ -335,13 +360,54 @@ describe("excludeMutedSenders", () => {
335
360
  muted: true,
336
361
  }),
337
362
  ];
338
- const sections = groupBriefSections(
339
- excludeMutedSenders(rows).map(toThreadRowData),
340
- );
363
+ const kept = excludeMutedSenders(rows).map(toThreadRowData);
364
+ const sections = briefSections([
365
+ {
366
+ category: "personal",
367
+ rows: kept,
368
+ total: { kind: "exact", value: 0 },
369
+ atCap: false,
370
+ loading: false,
371
+ failed: false,
372
+ },
373
+ ]);
341
374
  assert.deepStrictEqual(sections, []);
342
375
  });
343
376
  });
344
377
 
378
+ // The count is the server's, taken over a scope that includes senders the reader
379
+ // muted — `listAllThreads` has no `muted` parameter. The list drops their mail,
380
+ // so the number would overstate what is on screen.
381
+ describe("briefSectionTotal", () => {
382
+ const counted: ResultCount = { kind: "exact", value: 4753 };
383
+
384
+ test("keeps the count when no sender in the rows is muted", () => {
385
+ assert.deepStrictEqual(
386
+ briefSectionTotal(counted, [
387
+ threadResponse({ threadMessageId: "a" }),
388
+ threadResponse({ threadMessageId: "b", muted: false }),
389
+ ]),
390
+ counted,
391
+ );
392
+ });
393
+
394
+ test("withholds the count when the rows show a muted sender", () => {
395
+ assert.deepStrictEqual(
396
+ briefSectionTotal(counted, [
397
+ threadResponse({ threadMessageId: "a" }),
398
+ threadResponse({ threadMessageId: "b", muted: true }),
399
+ ]),
400
+ { kind: "unknown" },
401
+ );
402
+ });
403
+
404
+ test("a count nobody took stays absent", () => {
405
+ assert.deepStrictEqual(briefSectionTotal({ kind: "unknown" }, []), {
406
+ kind: "unknown",
407
+ });
408
+ });
409
+ });
410
+
345
411
  describe("matchesBriefSearch", () => {
346
412
  const r = row({
347
413
  id: "1",
@@ -580,9 +646,10 @@ describe("matchesSearchTokens", () => {
580
646
  });
581
647
  });
582
648
 
583
- // #49: the brief's list is the unified INBOX, so filtering it client-side found
584
- // only inbox mail. The server's cross-folder search supplies the rest, and the
585
- // two are merged.
649
+ // #49: a listing complete under its own criteria still cannot see a snippet
650
+ // match, and the server's cross-folder search cannot see one either. The two
651
+ // halves are merged. The brief no longer uses this — its sections are pages, and
652
+ // merging a page with a search window orders two truncated lists (#312).
586
653
  describe("mergeSearchRows", () => {
587
654
  test("keeps rows the server found in other folders", () => {
588
655
  const merged = mergeSearchRows(