1import type { PullRequestReaction, PullRequestReactionContent } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 applyPendingPullRequestReactions,
6 PULL_REQUEST_REACTION_ORDER,
7 pullRequestReactionEmoji,
8 pullRequestReactionName,
9 pullRequestReactionTooltip,
10} from "./pullRequestReactions.logic";
11
12function reaction(overrides: Partial<PullRequestReaction> = {}): PullRequestReaction {
13 return {
14 content: "heart",
15 count: 1,
16 actors: ["octocat"],
17 viewerHasReacted: false,
18 ...overrides,
19 };
20}
21
22describe("reaction presentation", () => {
23 it("names and draws all eight, in GitHub's picker order", () => {
24 expect(PULL_REQUEST_REACTION_ORDER).toEqual([
25 "thumbs-up",
26 "thumbs-down",
27 "laugh",
28 "hooray",
29 "confused",
30 "heart",
31 "rocket",
32 "eyes",
33 ]);
34 expect(PULL_REQUEST_REACTION_ORDER.map(pullRequestReactionEmoji)).toEqual([
35 "👍",
36 "👎",
37 "😄",
38 "🎉",
39 "😕",
40 "❤️",
41 "🚀",
42 "👀",
43 ]);
44 expect(pullRequestReactionName("thumbs-up")).toBe("thumbs up");
45 expect(pullRequestReactionName("eyes")).toBe("eyes");
46 });
47});
48
49describe("reaction tooltip", () => {
50 it("reads as GitHub's sentence for one, two and three names", () => {
51 expect(
52 pullRequestReactionTooltip(reaction({ content: "thumbs-up", count: 1, actors: ["Bil0000"] })),
53 ).toBe("Bil0000 reacted with thumbs up emoji");
54 expect(pullRequestReactionTooltip(reaction({ count: 2, actors: ["Bil0000", "octocat"] }))).toBe(
55 "Bil0000 and octocat reacted with heart emoji",
56 );
57 expect(
58 pullRequestReactionTooltip(
59 reaction({
60 content: "eyes",
61 count: 3,
62 actors: ["Bil0000", "octocat"],
63 viewerHasReacted: true,
64 }),
65 ),
66 ).toBe("You, Bil0000, and octocat reacted with eyes emoji");
67 });
68
69 it("counts everyone past the third name, including the ones the host never named", () => {
70 expect(
71 pullRequestReactionTooltip(
72 reaction({
73 content: "rocket",
74 count: 15,
75 actors: ["a", "b", "c", "d"],
76 viewerHasReacted: true,
77 }),
78 ),
79 ).toBe("You, a, b, and 12 others reacted with rocket emoji");
80 // A host that counted more than it named still says who is missing.
81 expect(pullRequestReactionTooltip(reaction({ count: 2, actors: ["octocat"] }))).toBe(
82 "octocat and 1 other reacted with heart emoji",
83 );
84 // Nothing named at all leaves the count to speak for itself, and nobody to be "other" than.
85 expect(pullRequestReactionTooltip(reaction({ count: 4, actors: [] }))).toBe(
86 "4 people reacted with heart emoji",
87 );
88 expect(pullRequestReactionTooltip(reaction({ count: 1, actors: [] }))).toBe(
89 "1 person reacted with heart emoji",
90 );
91 });
92
93 it("names the viewer as You, ahead of the other people who reacted", () => {
94 // The host already leaves the viewer's own login out of `actors`.
95 expect(
96 pullRequestReactionTooltip(
97 reaction({ count: 3, actors: ["Bil0000", "octocat"], viewerHasReacted: true }),
98 ),
99 ).toBe("You, Bil0000, and octocat reacted with heart emoji");
100 });
101
102 it("names nobody as You when the viewer has not reacted", () => {
103 expect(
104 pullRequestReactionTooltip(
105 reaction({ count: 2, actors: ["Bil0000", "octocat"], viewerHasReacted: false }),
106 ),
107 ).toBe("Bil0000 and octocat reacted with heart emoji");
108 });
109
110 it("names actors as given, and leaves off You, for a host with no room for the viewer", () => {
111 // `count` says two and `actors` already lists two logins, one of them the viewer's own —
112 // there is no slot left for "You" that wouldn't invent or hide a real reactor.
113 expect(
114 pullRequestReactionTooltip(
115 reaction({ count: 2, actors: ["Bil0000", "octocat"], viewerHasReacted: true }),
116 ),
117 ).toBe("Bil0000 and octocat reacted with heart emoji");
118 // Same shape past the naming cap: the display limit still leaves an honest remainder.
119 expect(
120 pullRequestReactionTooltip(
121 reaction({
122 count: 4,
123 actors: ["Bil0000", "octocat", "hubot", "zzz"],
124 viewerHasReacted: true,
125 }),
126 ),
127 ).toBe("Bil0000, octocat, hubot, and 1 other reacted with heart emoji");
128 });
129
130 it("keeps naming the viewer You when the host does leave them room", () => {
131 // `actors` has fewer logins than `count`, so the viewer fits without being counted twice.
132 expect(
133 pullRequestReactionTooltip(
134 reaction({ count: 2, actors: ["octocat"], viewerHasReacted: true }),
135 ),
136 ).toBe("You and octocat reacted with heart emoji");
137 });
138});
139
140describe("reaction tooltip, with a reaction in flight", () => {
141 it("still says You after an optimistic react, even at full capacity", () => {
142 // The host's last snapshot had every reactor named and nobody left over; the optimistic
143 // bump grows `count` without touching `actors`, so there is now room for "You" and the
144 // sentence should use it rather than reading as a non-compliant host.
145 const applied = applyPendingPullRequestReactions(
146 [reaction({ count: 2, actors: ["a", "b"], viewerHasReacted: false })],
147 new Map([["heart", true] as const]),
148 );
149 expect(pullRequestReactionTooltip(applied[0]!)).toBe("You, a, and b reacted with heart emoji");
150 });
151
152 it("drops You after an optimistic un-react, without treating the host as non-compliant", () => {
153 const applied = applyPendingPullRequestReactions(
154 [reaction({ count: 2, actors: ["octocat"], viewerHasReacted: true })],
155 new Map([["heart", false] as const]),
156 );
157 expect(pullRequestReactionTooltip(applied[0]!)).toBe("octocat reacted with heart emoji");
158 });
159});
160
161describe("pending reactions", () => {
162 const pending = (
163 entries: ReadonlyArray<readonly [PullRequestReactionContent, boolean]>,
164 ): ReadonlyMap<PullRequestReactionContent, boolean> => new Map(entries);
165
166 it("returns the host's list untouched while nothing is in flight", () => {
167 const reactions = [reaction()];
168 expect(applyPendingPullRequestReactions(reactions, pending([]))).toBe(reactions);
169 });
170
171 it("adds a reaction nobody had yet, in picker order", () => {
172 const applied = applyPendingPullRequestReactions(
173 [reaction({ content: "rocket", count: 2, actors: ["a", "b"] })],
174 pending([["thumbs-up", true]]),
175 );
176 expect(applied).toEqual([
177 { content: "thumbs-up", count: 1, actors: [], viewerHasReacted: true },
178 { content: "rocket", count: 2, actors: ["a", "b"], viewerHasReacted: false },
179 ]);
180 });
181
182 it("joins and leaves an existing reaction, and drops the pill nobody is left on", () => {
183 expect(
184 applyPendingPullRequestReactions(
185 [reaction({ count: 2, actors: ["a", "b"] })],
186 pending([["heart", true]]),
187 ),
188 ).toEqual([{ content: "heart", count: 3, actors: ["a", "b"], viewerHasReacted: true }]);
189 expect(
190 applyPendingPullRequestReactions(
191 [reaction({ count: 2, actors: ["a", "b"], viewerHasReacted: true })],
192 pending([["heart", false]]),
193 ),
194 ).toEqual([{ content: "heart", count: 1, actors: ["a", "b"], viewerHasReacted: false }]);
195 expect(
196 applyPendingPullRequestReactions(
197 [reaction({ count: 1, viewerHasReacted: true })],
198 pending([["heart", false]]),
199 ),
200 ).toEqual([]);
201 });
202
203 it("ignores a pending state the host has already caught up with", () => {
204 const reactions = [reaction({ count: 3, viewerHasReacted: true })];
205 expect(applyPendingPullRequestReactions(reactions, pending([["heart", true]]))).toEqual(
206 reactions,
207 );
208 expect(applyPendingPullRequestReactions([], pending([["heart", false]]))).toEqual([]);
209 });
210});