@remit/web-client 0.0.249 → 0.0.250
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.
- package/package.json +1 -1
- package/src/components/mail/DailyBrief.tsx +244 -100
- package/src/components/mail/brief-server-sections.render.test.ts +655 -0
- package/src/hooks/useBriefSections.ts +310 -0
- package/src/lib/brief-criteria.test.ts +126 -0
- package/src/lib/brief-criteria.ts +98 -0
- package/src/lib/brief.test.ts +215 -148
- package/src/lib/brief.ts +116 -53
- package/src/lib/drafts.ts +1 -1
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The brief's sections, counts and ordering come from the server (#312).
|
|
3
|
+
*
|
|
4
|
+
* The brief used to be one non-paginated page of the newest 50 unified rows.
|
|
5
|
+
* Every section was a slice of that page, and the number beside a section label
|
|
6
|
+
* was how many of those 50 rows happened to fall in it — rendered where a
|
|
7
|
+
* category total belongs. On the live instance Personal mail is the bottom third
|
|
8
|
+
* of the inbox by recency, so a whole category could sit entirely below the
|
|
9
|
+
* window: the section rendered empty and its header read zero.
|
|
10
|
+
*
|
|
11
|
+
* The seed here is that mailbox in miniature. The unified listing — the request
|
|
12
|
+
* carrying no category — answers with the newest mail only, and Marketing's mail
|
|
13
|
+
* is all older than every row in it. A brief that groups a page finds no
|
|
14
|
+
* Marketing at all; a brief whose sections are their own queries finds it and
|
|
15
|
+
* states its real size.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import { afterEach, describe, it } from "node:test";
|
|
20
|
+
import type {
|
|
21
|
+
RemitImapMessageCategory,
|
|
22
|
+
RemitImapThreadMessageResponse,
|
|
23
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
24
|
+
import { SECTION_ROW_CAP } from "@remit/ui";
|
|
25
|
+
import {
|
|
26
|
+
type AnyRouter,
|
|
27
|
+
createMemoryHistory,
|
|
28
|
+
createRootRoute,
|
|
29
|
+
createRoute,
|
|
30
|
+
createRouter,
|
|
31
|
+
Outlet,
|
|
32
|
+
RouterProvider,
|
|
33
|
+
} from "@tanstack/react-router";
|
|
34
|
+
import { createElement, type ReactNode, useEffect, useState } from "react";
|
|
35
|
+
import { ComposeProvider } from "@/components/compose/ComposeProvider";
|
|
36
|
+
import { MailContext, type MailContextValue } from "@/lib/mail-context";
|
|
37
|
+
import { MailFreshnessProvider } from "@/lib/mail-freshness";
|
|
38
|
+
import { EMPTY_RESULT_FOLDER_INDEX } from "@/lib/result-folder";
|
|
39
|
+
import { createDomHarness, type DomHarness } from "@/test-support/dom";
|
|
40
|
+
import { makeAccount, makeThreadMessage } from "@/test-support/fixtures";
|
|
41
|
+
import { type HttpCall, type HttpMock, mockFetch } from "@/test-support/http";
|
|
42
|
+
import { DailyBrief } from "./DailyBrief";
|
|
43
|
+
|
|
44
|
+
const ACCOUNT_ID = "acc-1";
|
|
45
|
+
const account = makeAccount({ accountId: ACCOUNT_ID });
|
|
46
|
+
|
|
47
|
+
/** Newest first, the way the server answers. */
|
|
48
|
+
const NEWEST = 1_767_225_600_000;
|
|
49
|
+
const DAY = 86_400_000;
|
|
50
|
+
|
|
51
|
+
interface Seed {
|
|
52
|
+
prefix: string;
|
|
53
|
+
category: RemitImapMessageCategory;
|
|
54
|
+
subject: (index: number) => string;
|
|
55
|
+
/** Messages to make. More than the section's page, so the page is a page. */
|
|
56
|
+
messages: number;
|
|
57
|
+
/** Conversations they belong to — fewer than `messages` when mail threads. */
|
|
58
|
+
threads: number;
|
|
59
|
+
/** How far back the newest of them sits. */
|
|
60
|
+
daysAgo: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const seeded = (seed: Seed): RemitImapThreadMessageResponse[] =>
|
|
64
|
+
Array.from({ length: seed.messages }, (_, index) =>
|
|
65
|
+
makeThreadMessage({
|
|
66
|
+
messageId: `${seed.prefix}-${index}`,
|
|
67
|
+
threadId: `thread-${seed.prefix}-${index % seed.threads}`,
|
|
68
|
+
accountId: ACCOUNT_ID,
|
|
69
|
+
category: seed.category,
|
|
70
|
+
subject: seed.subject(index),
|
|
71
|
+
sentDate: NEWEST - (seed.daysAgo * DAY + index * 1_000),
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* One conversation, twelve messages. The count is thread-distinct, so this
|
|
77
|
+
* category holds exactly one — and a list keying on messageId renders ten rows
|
|
78
|
+
* under a header reading "1", with the rest of the conversation unreachable
|
|
79
|
+
* because "Show all" is withheld on `1 > 10` being false.
|
|
80
|
+
*/
|
|
81
|
+
const PERSONAL: Seed = {
|
|
82
|
+
prefix: "personal",
|
|
83
|
+
category: "personal",
|
|
84
|
+
subject: (index) => `Design review, message ${index}`,
|
|
85
|
+
messages: 12,
|
|
86
|
+
threads: 1,
|
|
87
|
+
daysAgo: 0,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** Every one of these is older than every row above, and there are more than fit. */
|
|
91
|
+
const MARKETING: Seed = {
|
|
92
|
+
prefix: "marketing",
|
|
93
|
+
category: "marketing",
|
|
94
|
+
subject: (index) => `Spring sale, edition ${index}`,
|
|
95
|
+
messages: 14,
|
|
96
|
+
threads: 12,
|
|
97
|
+
daysAgo: 400,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const UNCLASSIFIED: Seed = {
|
|
101
|
+
prefix: "unclassified",
|
|
102
|
+
category: "uncategorized",
|
|
103
|
+
subject: () => "Nothing classified this yet",
|
|
104
|
+
messages: 1,
|
|
105
|
+
threads: 1,
|
|
106
|
+
daysAgo: 10,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const SEEDS: Seed[] = [PERSONAL, MARKETING, UNCLASSIFIED];
|
|
110
|
+
|
|
111
|
+
const ROWS: Record<string, RemitImapThreadMessageResponse[]> =
|
|
112
|
+
Object.fromEntries(SEEDS.map((seed) => [seed.category, seeded(seed)]));
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* What the server would answer `count: true` with: conversations, not messages,
|
|
116
|
+
* over the whole scope rather than the page. Derived from the seed rather than
|
|
117
|
+
* stated, so a header and the rows under it cannot be asserted against two
|
|
118
|
+
* different ideas of what one row is.
|
|
119
|
+
*/
|
|
120
|
+
const countOf = (category: string): number =>
|
|
121
|
+
new Set((ROWS[category] ?? []).map((row) => row.threadId)).size;
|
|
122
|
+
|
|
123
|
+
/** The subjects a section's page actually renders, one per conversation. */
|
|
124
|
+
const renderedSubjects = (category: string, limit: number): string[] => {
|
|
125
|
+
const page = (ROWS[category] ?? []).slice(0, limit);
|
|
126
|
+
const seen = new Set<string>();
|
|
127
|
+
return page
|
|
128
|
+
.filter((row) => {
|
|
129
|
+
if (seen.has(row.threadId)) return false;
|
|
130
|
+
seen.add(row.threadId);
|
|
131
|
+
return true;
|
|
132
|
+
})
|
|
133
|
+
.map((row) => row.subject ?? "");
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The two matches a search has to order against each other: an old newsletter
|
|
138
|
+
* and a mail from this morning that is not a newsletter. Newest first, the way
|
|
139
|
+
* the server answers.
|
|
140
|
+
*/
|
|
141
|
+
const searchMatches = [
|
|
142
|
+
...seeded({
|
|
143
|
+
prefix: "match-new",
|
|
144
|
+
category: "automated",
|
|
145
|
+
subject: () => "Your build passed",
|
|
146
|
+
messages: 1,
|
|
147
|
+
threads: 1,
|
|
148
|
+
daysAgo: 0,
|
|
149
|
+
}),
|
|
150
|
+
...seeded({
|
|
151
|
+
prefix: "match-old",
|
|
152
|
+
category: "newsletter",
|
|
153
|
+
subject: () => "Weekly digest for you",
|
|
154
|
+
messages: 1,
|
|
155
|
+
threads: 1,
|
|
156
|
+
daysAgo: 300,
|
|
157
|
+
}),
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
let harness: DomHarness | undefined;
|
|
161
|
+
let http: HttpMock | undefined;
|
|
162
|
+
|
|
163
|
+
afterEach(() => {
|
|
164
|
+
harness?.close();
|
|
165
|
+
harness = undefined;
|
|
166
|
+
http?.restore();
|
|
167
|
+
http = undefined;
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// The router reads `self` at construction; the shared jsdom globals stop at
|
|
171
|
+
// `window`.
|
|
172
|
+
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
173
|
+
|
|
174
|
+
const paramsOf = (call: HttpCall): URLSearchParams =>
|
|
175
|
+
new URL(call.url, "http://localhost").searchParams;
|
|
176
|
+
|
|
177
|
+
/** Every `/threads` request, split by what it asked for. */
|
|
178
|
+
const threadRequests = (): HttpCall[] =>
|
|
179
|
+
(http?.calls ?? []).filter(
|
|
180
|
+
(call) => new URL(call.url, "http://localhost").pathname === "/threads",
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
const countRequests = (): HttpCall[] =>
|
|
184
|
+
threadRequests().filter((call) => paramsOf(call).get("count") === "true");
|
|
185
|
+
|
|
186
|
+
const rowRequests = (): HttpCall[] =>
|
|
187
|
+
threadRequests().filter((call) => paramsOf(call).get("count") !== "true");
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The field being typed into. A keypress moves `searchInput` and leaves
|
|
191
|
+
* `searchQuery` where it was, which is the state every request must ignore.
|
|
192
|
+
* Driven by an event so the update lands inside the harness's `act`.
|
|
193
|
+
*/
|
|
194
|
+
const TYPED = "test:typed";
|
|
195
|
+
|
|
196
|
+
function BriefUnderTest({ search }: { search: string }) {
|
|
197
|
+
const [input, setInput] = useState(search);
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
const onTyped = (event: Event) => {
|
|
200
|
+
setInput((event as CustomEvent<string>).detail);
|
|
201
|
+
};
|
|
202
|
+
window.addEventListener(TYPED, onTyped);
|
|
203
|
+
return () => window.removeEventListener(TYPED, onTyped);
|
|
204
|
+
}, []);
|
|
205
|
+
return brief(search, input);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const context = (search: string, input: string): MailContextValue => ({
|
|
209
|
+
accounts: [account],
|
|
210
|
+
mailboxNameIndex: new Map(),
|
|
211
|
+
accountNameIndex: new Map(),
|
|
212
|
+
resultFolderIndex: EMPTY_RESULT_FOLDER_INDEX,
|
|
213
|
+
searchQuery: search,
|
|
214
|
+
searchInput: input,
|
|
215
|
+
searchViewKey: "brief",
|
|
216
|
+
onSearchChange: () => undefined,
|
|
217
|
+
onSearchClear: () => undefined,
|
|
218
|
+
onSearchClearQuery: () => undefined,
|
|
219
|
+
intelligenceOpen: false,
|
|
220
|
+
onToggleIntelligence: () => undefined,
|
|
221
|
+
onRaiseIntelligence: () => undefined,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const brief = (search: string, input: string): ReactNode =>
|
|
225
|
+
createElement(MailFreshnessProvider, {
|
|
226
|
+
accountIds: [ACCOUNT_ID],
|
|
227
|
+
// biome-ignore lint/correctness/noChildrenProp: no JSX in a `.ts` test, and createElement's variadic children do not satisfy a required prop
|
|
228
|
+
children: createElement(
|
|
229
|
+
MailContext.Provider,
|
|
230
|
+
{ value: context(search, input) },
|
|
231
|
+
createElement(DailyBrief, {
|
|
232
|
+
accounts: [account],
|
|
233
|
+
onDeleteMessages: () => undefined,
|
|
234
|
+
}),
|
|
235
|
+
),
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const testRouter = (search: string): AnyRouter => {
|
|
239
|
+
const rootRoute = createRootRoute({
|
|
240
|
+
component: () =>
|
|
241
|
+
createElement(ComposeProvider, null, createElement(Outlet)),
|
|
242
|
+
});
|
|
243
|
+
const mailRoute = createRoute({
|
|
244
|
+
getParentRoute: () => rootRoute,
|
|
245
|
+
path: "/mail",
|
|
246
|
+
validateSearch: (query: Record<string, unknown>) => query,
|
|
247
|
+
component: Outlet,
|
|
248
|
+
});
|
|
249
|
+
// Present so the hooks that name it have the route the generated tree gives
|
|
250
|
+
// them.
|
|
251
|
+
const mailboxRoute = createRoute({
|
|
252
|
+
getParentRoute: () => mailRoute,
|
|
253
|
+
path: "/$mailboxId",
|
|
254
|
+
component: Outlet,
|
|
255
|
+
});
|
|
256
|
+
const briefRoute = createRoute({
|
|
257
|
+
getParentRoute: () => mailRoute,
|
|
258
|
+
path: "/brief",
|
|
259
|
+
component: () => createElement(BriefUnderTest, { search }),
|
|
260
|
+
});
|
|
261
|
+
const threadRoute = createRoute({
|
|
262
|
+
getParentRoute: () => briefRoute,
|
|
263
|
+
path: "$threadId",
|
|
264
|
+
component: Outlet,
|
|
265
|
+
});
|
|
266
|
+
const messageRoute = createRoute({
|
|
267
|
+
getParentRoute: () => threadRoute,
|
|
268
|
+
path: "$messageId",
|
|
269
|
+
component: Outlet,
|
|
270
|
+
});
|
|
271
|
+
const routeTree = rootRoute.addChildren([
|
|
272
|
+
mailRoute.addChildren([
|
|
273
|
+
mailboxRoute,
|
|
274
|
+
briefRoute.addChildren([threadRoute.addChildren([messageRoute])]),
|
|
275
|
+
]),
|
|
276
|
+
]);
|
|
277
|
+
return createRouter({
|
|
278
|
+
routeTree,
|
|
279
|
+
history: createMemoryHistory({ initialEntries: ["/mail/brief"] }),
|
|
280
|
+
}) as unknown as AnyRouter;
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
/** Answer this call instead of the seed, or `undefined` to let the seed answer. */
|
|
284
|
+
type Interception = (call: HttpCall) => unknown;
|
|
285
|
+
|
|
286
|
+
const mountWith = async (
|
|
287
|
+
intercept: Interception,
|
|
288
|
+
search = "",
|
|
289
|
+
): Promise<DomHarness> => {
|
|
290
|
+
http = mockFetch((call) => {
|
|
291
|
+
const intercepted = intercept(call);
|
|
292
|
+
if (intercepted !== undefined) return intercepted;
|
|
293
|
+
const url = new URL(call.url, "http://localhost");
|
|
294
|
+
if (url.pathname.endsWith("/config")) return { accounts: [account] };
|
|
295
|
+
if (url.pathname !== "/threads") return { items: [] };
|
|
296
|
+
const params = url.searchParams;
|
|
297
|
+
const categories = params.getAll("category");
|
|
298
|
+
// Search mode: one request, and the server answers with the whole match
|
|
299
|
+
// set in one order.
|
|
300
|
+
if (params.get("query")) return { items: searchMatches };
|
|
301
|
+
// The count is over the whole scope and counts conversations; the rows are
|
|
302
|
+
// a page of messages. Answering the way the server does is what makes the
|
|
303
|
+
// two units visible to the assertions below.
|
|
304
|
+
if (params.get("count") === "true") {
|
|
305
|
+
return {
|
|
306
|
+
items: [],
|
|
307
|
+
count: categories.reduce((sum, category) => sum + countOf(category), 0),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
// A request naming no category is the unified listing the brief used to be:
|
|
311
|
+
// it answers with the newest mail, and Marketing is nowhere in it.
|
|
312
|
+
const rows =
|
|
313
|
+
categories.length === 0
|
|
314
|
+
? (ROWS.personal ?? [])
|
|
315
|
+
: categories.flatMap((category) => ROWS[category] ?? []);
|
|
316
|
+
const limit = Number(params.get("limit") ?? rows.length);
|
|
317
|
+
return { items: rows.slice(0, limit) };
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
const router = testRouter(search);
|
|
321
|
+
await router.load();
|
|
322
|
+
const dom = createDomHarness({ viewportWidth: 1400 });
|
|
323
|
+
harness = dom;
|
|
324
|
+
dom.renderApp(createElement(RouterProvider, { router }));
|
|
325
|
+
await dom.flush();
|
|
326
|
+
await dom.wait(20);
|
|
327
|
+
await dom.flush();
|
|
328
|
+
return dom;
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const mount = (search = ""): Promise<DomHarness> =>
|
|
332
|
+
mountWith(() => undefined, search);
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Type into the field without submitting: the live input moves, the committed
|
|
336
|
+
* query does not.
|
|
337
|
+
*/
|
|
338
|
+
const typeWithoutSubmitting = async (
|
|
339
|
+
dom: DomHarness,
|
|
340
|
+
value: string,
|
|
341
|
+
): Promise<void> => {
|
|
342
|
+
dom.dispatch(
|
|
343
|
+
dom.window,
|
|
344
|
+
new dom.window.CustomEvent(TYPED, { detail: value }),
|
|
345
|
+
);
|
|
346
|
+
await dom.flush();
|
|
347
|
+
await dom.wait(20);
|
|
348
|
+
await dom.flush();
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const settled = async (mounted: DomHarness, text: string): Promise<void> => {
|
|
352
|
+
await mounted.waitFor(
|
|
353
|
+
() => mounted.text().includes(text),
|
|
354
|
+
`the brief to render ${text}`,
|
|
355
|
+
);
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
describe("the brief's sections come from the server (#312)", () => {
|
|
359
|
+
// The regression. Marketing's mail is entirely older than the newest unified
|
|
360
|
+
// rows, so a brief that groups a shared page renders this section empty above
|
|
361
|
+
// a header reading zero.
|
|
362
|
+
it("renders a category whose mail is all older than the newest unified rows", async () => {
|
|
363
|
+
const mounted = await mount();
|
|
364
|
+
await settled(mounted, renderedSubjects("marketing", SECTION_ROW_CAP)[0]);
|
|
365
|
+
|
|
366
|
+
const shown = mounted.text();
|
|
367
|
+
for (const subject of renderedSubjects("marketing", SECTION_ROW_CAP)) {
|
|
368
|
+
assert.ok(shown.includes(subject), `the section lost ${subject}`);
|
|
369
|
+
}
|
|
370
|
+
assert.ok(
|
|
371
|
+
!rowRequests().some(
|
|
372
|
+
(call) => paramsOf(call).getAll("category").length === 0,
|
|
373
|
+
),
|
|
374
|
+
"the brief still asked for one unified page of everything",
|
|
375
|
+
);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("states the category's real size, not the number of rows it loaded", async () => {
|
|
379
|
+
const mounted = await mount();
|
|
380
|
+
await settled(mounted, renderedSubjects("marketing", SECTION_ROW_CAP)[0]);
|
|
381
|
+
|
|
382
|
+
const marketing = mounted.byText("button", "Marketing");
|
|
383
|
+
assert.match(
|
|
384
|
+
marketing.textContent ?? "",
|
|
385
|
+
new RegExp(String(countOf("marketing"))),
|
|
386
|
+
"the Marketing header stated its page length instead of its category",
|
|
387
|
+
);
|
|
388
|
+
assert.ok(
|
|
389
|
+
mounted.text().includes(`Show all ${countOf("marketing")}`),
|
|
390
|
+
"the section offered no way to the rest of the category",
|
|
391
|
+
);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
// The count is thread-distinct (`countThreadsInScope`); the page is messages.
|
|
395
|
+
// One twelve-message conversation is therefore one row under a header reading
|
|
396
|
+
// one — not ten rows under it, with the rest of the conversation behind a
|
|
397
|
+
// "Show all" the component withholds because `1 > 10` is false.
|
|
398
|
+
it("renders one row per conversation, the unit the count is in", async () => {
|
|
399
|
+
assert.equal(
|
|
400
|
+
countOf("personal"),
|
|
401
|
+
1,
|
|
402
|
+
"the seed stopped being one conversation",
|
|
403
|
+
);
|
|
404
|
+
const mounted = await mount();
|
|
405
|
+
await settled(mounted, PERSONAL.subject(0));
|
|
406
|
+
|
|
407
|
+
const personal = mounted.byText("button", "Personal");
|
|
408
|
+
assert.match(personal.textContent ?? "", /1/);
|
|
409
|
+
|
|
410
|
+
const shown = mounted.text();
|
|
411
|
+
const rowsRendered = Array.from({ length: PERSONAL.messages }, (_, index) =>
|
|
412
|
+
PERSONAL.subject(index),
|
|
413
|
+
).filter((subject) => shown.includes(subject));
|
|
414
|
+
assert.deepEqual(
|
|
415
|
+
rowsRendered,
|
|
416
|
+
[PERSONAL.subject(0)],
|
|
417
|
+
"one conversation rendered as several rows, under a header reading one",
|
|
418
|
+
);
|
|
419
|
+
// Marketing holds more than its page and offers the rest; Personal does not
|
|
420
|
+
// and must not, because its one conversation is the whole of it.
|
|
421
|
+
assert.equal(
|
|
422
|
+
(shown.match(/Show all/g) ?? []).length,
|
|
423
|
+
1,
|
|
424
|
+
"a section holding its whole category offered more of it",
|
|
425
|
+
);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it("asks each section for its own category, newest first", async () => {
|
|
429
|
+
const mounted = await mount();
|
|
430
|
+
await settled(mounted, renderedSubjects("marketing", SECTION_ROW_CAP)[0]);
|
|
431
|
+
|
|
432
|
+
const asked = rowRequests().map((call) => ({
|
|
433
|
+
categories: paramsOf(call).getAll("category"),
|
|
434
|
+
order: paramsOf(call).get("order"),
|
|
435
|
+
limit: paramsOf(call).get("limit"),
|
|
436
|
+
}));
|
|
437
|
+
assert.deepEqual(
|
|
438
|
+
asked.flatMap((request) => request.categories).sort(),
|
|
439
|
+
[
|
|
440
|
+
"automated",
|
|
441
|
+
"marketing",
|
|
442
|
+
"newsletter",
|
|
443
|
+
"personal",
|
|
444
|
+
"social",
|
|
445
|
+
"transactional",
|
|
446
|
+
"uncategorized",
|
|
447
|
+
],
|
|
448
|
+
"the brief did not ask once per category",
|
|
449
|
+
);
|
|
450
|
+
for (const request of asked) {
|
|
451
|
+
assert.equal(request.order, "desc");
|
|
452
|
+
assert.equal(request.limit, "10");
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// One count per displayed section, keyed on the criteria alone: no cursor, no
|
|
457
|
+
// limit, so nothing about paging can ask for another.
|
|
458
|
+
it("counts each displayed section once, over the whole scope", async () => {
|
|
459
|
+
const mounted = await mount();
|
|
460
|
+
await settled(mounted, renderedSubjects("marketing", SECTION_ROW_CAP)[0]);
|
|
461
|
+
|
|
462
|
+
const counts = countRequests();
|
|
463
|
+
assert.equal(counts.length, 7, "a section was counted more than once");
|
|
464
|
+
for (const call of counts) {
|
|
465
|
+
const params = paramsOf(call);
|
|
466
|
+
assert.equal(params.get("results"), "false");
|
|
467
|
+
assert.equal(params.get("continuationToken"), null);
|
|
468
|
+
assert.equal(params.get("limit"), null);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const before = counts.length;
|
|
472
|
+
await mounted.flush();
|
|
473
|
+
await mounted.wait(20);
|
|
474
|
+
await mounted.flush();
|
|
475
|
+
assert.equal(
|
|
476
|
+
countRequests().length,
|
|
477
|
+
before,
|
|
478
|
+
"a re-render asked for the counts again",
|
|
479
|
+
);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// D6 / issue #45: `uncategorized` is the pending state's name, and it has its
|
|
483
|
+
// own section under its own label — never folded into Personal.
|
|
484
|
+
it("renders uncategorized mail as its own Unclassified section", async () => {
|
|
485
|
+
const mounted = await mount();
|
|
486
|
+
await settled(mounted, "Nothing classified this yet");
|
|
487
|
+
|
|
488
|
+
const shown = mounted.text();
|
|
489
|
+
assert.ok(shown.includes("Unclassified"));
|
|
490
|
+
const unclassifiedAt = shown.indexOf("Nothing classified this yet");
|
|
491
|
+
const personalAt = shown.indexOf(PERSONAL.subject(0));
|
|
492
|
+
assert.ok(
|
|
493
|
+
personalAt < unclassifiedAt,
|
|
494
|
+
"Unclassified was folded into Personal",
|
|
495
|
+
);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
// Ordering across the seeded windows: the display order is the section order,
|
|
499
|
+
// and within a section it is the order the server answered in. Nothing here
|
|
500
|
+
// re-sorts two truncated lists against each other.
|
|
501
|
+
it("orders sections by display order and rows by the server's answer", async () => {
|
|
502
|
+
const marketing = renderedSubjects("marketing", SECTION_ROW_CAP);
|
|
503
|
+
const mounted = await mount();
|
|
504
|
+
await settled(mounted, marketing[0]);
|
|
505
|
+
|
|
506
|
+
const shown = mounted.text();
|
|
507
|
+
const at = (text: string) => shown.indexOf(text);
|
|
508
|
+
assert.ok(
|
|
509
|
+
at(PERSONAL.subject(0)) < at(marketing[0]),
|
|
510
|
+
"Personal did not come before Marketing",
|
|
511
|
+
);
|
|
512
|
+
for (let i = 1; i < marketing.length; i += 1) {
|
|
513
|
+
assert.ok(
|
|
514
|
+
at(marketing[i - 1]) < at(marketing[i]),
|
|
515
|
+
"the section reordered the rows the server returned",
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
// Seven requests, seven answers. A request that never got one states itself
|
|
521
|
+
// where that category would have been; the six that came back stay on screen.
|
|
522
|
+
//
|
|
523
|
+
// A transport failure, not a 5xx: the fail-fast contract escalates every 5xx
|
|
524
|
+
// to the full-screen overlay and there is no opt-out here, so a 500 would
|
|
525
|
+
// never reach this state in the app however this test is mounted (#1059).
|
|
526
|
+
it("keeps the other sections when one category's request fails", async () => {
|
|
527
|
+
const dom = await mountWith((call) => {
|
|
528
|
+
const params = new URL(call.url, "http://localhost").searchParams;
|
|
529
|
+
if (!params.getAll("category").includes("marketing")) return undefined;
|
|
530
|
+
throw new TypeError("fetch failed");
|
|
531
|
+
});
|
|
532
|
+
await settled(dom, PERSONAL.subject(0));
|
|
533
|
+
|
|
534
|
+
const shown = dom.text();
|
|
535
|
+
assert.ok(
|
|
536
|
+
shown.includes("Couldn't load Marketing"),
|
|
537
|
+
"the failed section said nothing about failing",
|
|
538
|
+
);
|
|
539
|
+
assert.ok(
|
|
540
|
+
shown.includes(PERSONAL.subject(0)),
|
|
541
|
+
"one category's failure blanked a healthy section",
|
|
542
|
+
);
|
|
543
|
+
assert.ok(
|
|
544
|
+
shown.includes("Nothing classified this yet"),
|
|
545
|
+
"one category's failure blanked a healthy section",
|
|
546
|
+
);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
// A total is withheld whenever something narrows the rows after they arrive —
|
|
550
|
+
// here a muted sender. The number goes; the way out of the section must not,
|
|
551
|
+
// or the rest of the category is unreachable.
|
|
552
|
+
it("keeps the way to the whole category when the total is withheld", async () => {
|
|
553
|
+
const dom = await mountWith((call) => {
|
|
554
|
+
const params = new URL(call.url, "http://localhost").searchParams;
|
|
555
|
+
if (params.get("count") === "true") return undefined;
|
|
556
|
+
if (!params.getAll("category").includes("marketing")) return undefined;
|
|
557
|
+
const page = (ROWS.marketing ?? []).slice(0, SECTION_ROW_CAP);
|
|
558
|
+
return {
|
|
559
|
+
items: page.map((row, index) =>
|
|
560
|
+
index === 0 ? { ...row, muted: true } : row,
|
|
561
|
+
),
|
|
562
|
+
};
|
|
563
|
+
});
|
|
564
|
+
await settled(dom, renderedSubjects("marketing", SECTION_ROW_CAP)[1]);
|
|
565
|
+
|
|
566
|
+
const marketing = dom.byText("button", "Marketing");
|
|
567
|
+
assert.doesNotMatch(
|
|
568
|
+
marketing.textContent ?? "",
|
|
569
|
+
new RegExp(String(countOf("marketing"))),
|
|
570
|
+
"a count taken with a muted sender in it was stated as the section's size",
|
|
571
|
+
);
|
|
572
|
+
assert.ok(
|
|
573
|
+
dom.text().includes("Show all"),
|
|
574
|
+
"withholding the number stranded the reader in the section",
|
|
575
|
+
);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
// The requests are built from committed state. Typing is not committing: a
|
|
579
|
+
// half-written `is:unread` must tick its chip and move nothing else, or every
|
|
580
|
+
// keystroke on the way to it fires seven section requests and seven counts.
|
|
581
|
+
it("issues no request for a query that has not been submitted", async () => {
|
|
582
|
+
const dom = await mount();
|
|
583
|
+
await settled(dom, renderedSubjects("marketing", SECTION_ROW_CAP)[0]);
|
|
584
|
+
const before = threadRequests().length;
|
|
585
|
+
|
|
586
|
+
await typeWithoutSubmitting(dom, "is:unread");
|
|
587
|
+
|
|
588
|
+
assert.equal(
|
|
589
|
+
threadRequests().length,
|
|
590
|
+
before,
|
|
591
|
+
"the search field being typed into reached the request criteria",
|
|
592
|
+
);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
// The reading that sent the user here: searching the brief put an old
|
|
596
|
+
// newsletter above a mail from this morning, because the category sections
|
|
597
|
+
// ordered the matches by category first and recency second.
|
|
598
|
+
it("answers a search as one flat list, newest first across categories", async () => {
|
|
599
|
+
const mounted = await mount("digest");
|
|
600
|
+
await settled(mounted, "Weekly digest for you");
|
|
601
|
+
|
|
602
|
+
const shown = mounted.text();
|
|
603
|
+
assert.ok(
|
|
604
|
+
shown.indexOf("Your build passed") <
|
|
605
|
+
shown.indexOf("Weekly digest for you"),
|
|
606
|
+
"an old newsletter still outranked a newer match",
|
|
607
|
+
);
|
|
608
|
+
for (const label of ["Newsletter", "Automated", "Personal"]) {
|
|
609
|
+
assert.ok(
|
|
610
|
+
!shown.includes(label),
|
|
611
|
+
`the search kept the ${label} section header`,
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
assert.equal(
|
|
615
|
+
mounted.queryAll('button[aria-expanded="true"]').length,
|
|
616
|
+
0,
|
|
617
|
+
"the search rendered a section header",
|
|
618
|
+
);
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
it("asks for a search once, with no category scope of its own", async () => {
|
|
622
|
+
const mounted = await mount("digest");
|
|
623
|
+
await settled(mounted, "Weekly digest for you");
|
|
624
|
+
|
|
625
|
+
const searched = threadRequests().filter((call) =>
|
|
626
|
+
paramsOf(call).get("query"),
|
|
627
|
+
);
|
|
628
|
+
assert.equal(searched.length, 1, "the search fanned out over categories");
|
|
629
|
+
assert.deepEqual(paramsOf(searched[0]).getAll("category"), []);
|
|
630
|
+
assert.equal(paramsOf(searched[0]).get("order"), "desc");
|
|
631
|
+
assert.equal(
|
|
632
|
+
countRequests().length,
|
|
633
|
+
0,
|
|
634
|
+
"a search asked for section counts it does not render",
|
|
635
|
+
);
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
// A token with a server parameter is a parameter, not a pass over the rows:
|
|
639
|
+
// `is:unread` narrows every section's query rather than the ten rows it got.
|
|
640
|
+
it("sends a search token that has a server parameter as one", async () => {
|
|
641
|
+
const mounted = await mount("is:unread");
|
|
642
|
+
await settled(mounted, renderedSubjects("marketing", SECTION_ROW_CAP)[0]);
|
|
643
|
+
|
|
644
|
+
const unread = threadRequests().filter(
|
|
645
|
+
(call) => paramsOf(call).get("unread") === "true",
|
|
646
|
+
);
|
|
647
|
+
assert.ok(unread.length > 0, "is:unread never reached the server");
|
|
648
|
+
assert.ok(
|
|
649
|
+
unread.some((call) =>
|
|
650
|
+
paramsOf(call).getAll("category").includes("marketing"),
|
|
651
|
+
),
|
|
652
|
+
"the token did not travel with the section's own category",
|
|
653
|
+
);
|
|
654
|
+
});
|
|
655
|
+
});
|