1import { describe, expect, it } from "@effect/vitest";
2
3import {
4 hostReviewerQuery,
5 mergeReviewerCandidates,
6 reviewerCandidateView,
7} from "./pullRequestReviewers.ts";
8
9const reviewer = (
10 login: string,
11 { isRequested = false, name = null, kind = "user" } = {} as {
12 isRequested?: boolean;
13 name?: string | null;
14 kind?: "user" | "team";
15 },
16) => ({ login, name, avatarUrl: null, id: login, kind, isRequested });
17
18describe("mergeReviewerCandidates", () => {
19 it("narrows the loaded candidates by login or name", () => {
20 const loaded = [
21 reviewer("ada"),
22 reviewer("grace", { name: "Grace Hopper" }),
23 reviewer("linus"),
24 ];
25 expect(mergeReviewerCandidates(loaded, [], "HOP").map((entry) => entry.login)).toEqual([
26 "grace",
27 ]);
28 });
29
30 it("adds candidates only the host's search found, after the loaded ones", () => {
31 const loaded = [reviewer("ada"), reviewer("adam")];
32 const searched = [reviewer("adam"), reviewer("adalbert")];
33 expect(mergeReviewerCandidates(loaded, searched, "ada").map((entry) => entry.login)).toEqual([
34 "ada",
35 "adam",
36 "adalbert",
37 ]);
38 });
39
40 it("keeps the loaded entry for somebody both lists name, since a request updates that one", () => {
41 const loaded = [reviewer("ada", { isRequested: true })];
42 expect(mergeReviewerCandidates(loaded, [reviewer("ada")], "ada")).toEqual(loaded);
43 });
44
45 it("keeps a host match on a field absent from the loaded row", () => {
46 const loaded = [reviewer("ada", { isRequested: true })];
47 expect(mergeReviewerCandidates(loaded, [reviewer("ada")], "ada@example.com")).toEqual(loaded);
48 });
49
50 it("tells a team from a person sharing its id", () => {
51 const loaded = [reviewer("core", { kind: "team", isRequested: true })];
52 expect(
53 mergeReviewerCandidates(loaded, [reviewer("core")], "core").map((entry) => entry.kind),
54 ).toEqual(["team", "user"]);
55 });
56});
57
58describe("reviewerCandidateView", () => {
59 const firstPage = { candidates: [reviewer("ada"), reviewer("grace")], truncated: true };
60 const idle = { data: null, isPending: false, error: null };
61
62 it.each([
63 {
64 name: "a host without search, as from an older server, narrows the loaded page itself",
65 searchable: false,
66 query: "zed",
67 debouncedQuery: "zed",
68 search: idle,
69 expected: { hostQuery: null, logins: [], searchPending: false, truncation: "unsearchable" },
70 },
71 {
72 name: "a searchable host is asked once the debounce catches up",
73 searchable: true,
74 query: "zed",
75 debouncedQuery: "zed",
76 search: {
77 data: { candidates: [reviewer("zed")], truncated: false },
78 isPending: false,
79 error: null,
80 },
81 expected: { hostQuery: "zed", logins: ["zed"], searchPending: false, truncation: null },
82 },
83 {
84 name: "results for text the debounce has not reached are neither shown nor a no-match",
85 searchable: true,
86 query: "zed",
87 debouncedQuery: "ze",
88 search: {
89 data: { candidates: [reviewer("zelda")], truncated: false },
90 isPending: false,
91 error: null,
92 },
93 expected: { hostQuery: "ze", logins: [], searchPending: true, truncation: null },
94 },
95 {
96 name: "a failed search falls back to the loaded page and says so",
97 searchable: true,
98 query: "gr",
99 debouncedQuery: "gr",
100 search: { data: null, isPending: false, error: "rate limited" },
101 expected: {
102 hostQuery: "gr",
103 logins: ["grace"],
104 searchPending: false,
105 truncation: "search-failed",
106 },
107 },
108 {
109 name: "an empty query lists the loaded page and offers search",
110 searchable: true,
111 query: "",
112 debouncedQuery: "",
113 search: idle,
114 expected: {
115 hostQuery: null,
116 logins: ["ada", "grace"],
117 searchPending: false,
118 truncation: "searchable",
119 },
120 },
121 ])("$name", ({ searchable, query, debouncedQuery, search, expected }) => {
122 const input = { loaded: firstPage, searchable, query, debouncedQuery };
123 const view = reviewerCandidateView({ ...input, search });
124 expect({
125 hostQuery: hostReviewerQuery(input),
126 logins: view.candidates.map((entry) => entry.login),
127 searchPending: view.searchPending,
128 truncation: view.truncation,
129 }).toEqual(expected);
130 });
131});