1import {
2 EnvironmentId,
3 ProjectId,
4 type PullRequestFilesViewedResult,
5 type PullRequestRef,
6} from "@t3tools/contracts";
7import { AsyncResult } from "effect/reactivity";
8import { act, StrictMode } from "react";
9import { create, type ReactTestRenderer } from "react-test-renderer";
10import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
11
12const { host, setFilesViewed, toastAdd } = vi.hoisted(() => ({
13 host: { data: null as unknown, refresh: vi.fn() },
14 setFilesViewed: vi.fn(),
15 toastAdd: vi.fn(),
16}));
17
18vi.mock("~/state/pullRequests", () => ({
19 pullRequestEnvironment: { filesViewed: () => null, setFilesViewed: {} },
20}));
21vi.mock("~/state/query", () => ({
22 useEnvironmentQuery: () => ({
23 data: host.data,
24 error: null,
25 isPending: false,
26 isSuccess: true,
27 refresh: host.refresh,
28 }),
29}));
30vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => setFilesViewed }));
31vi.mock("../ui/toast", () => ({ toastManager: { add: toastAdd } }));
32
33import {
34 usePullRequestFilesViewed,
35 type PullRequestFilesViewedView,
36} from "./usePullRequestFilesViewed";
37
38const environmentId = EnvironmentId.make("pr-files-viewed-audit");
39const reference: PullRequestRef = {
40 projectId: ProjectId.make("project-a"),
41 repository: "acme/web",
42 number: 42,
43};
44const paths = ["a.ts"];
45
46/** What the host answers, as a fresh object each time: a read is only a read if it is a new one. */
47function answer(state: "unviewed" | "viewed" | "dismissed"): PullRequestFilesViewedResult {
48 return { files: [{ path: "a.ts", state }], truncated: false };
49}
50
51let renderer: ReactTestRenderer | null = null;
52
53function Probe(_props: { readonly view: PullRequestFilesViewedView }) {
54 return null;
55}
56
57function Surface() {
58 const view = usePullRequestFilesViewed({ environmentId, reference, enabled: true, paths });
59 return <Probe view={view} />;
60}
61
62function view(): PullRequestFilesViewedView {
63 return renderer!.root.findByType(Probe).props.view;
64}
65
66/** The host's next answer landing, which is what a `refresh` ends in. */
67async function reads(state: "unviewed" | "viewed" | "dismissed") {
68 host.data = answer(state);
69 await act(async () =>
70 renderer!.update(
71 <StrictMode>
72 <Surface />
73 </StrictMode>,
74 ),
75 );
76}
77
78beforeEach(async () => {
79 vi.useFakeTimers();
80 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
81 host.data = answer("unviewed");
82 host.refresh.mockReset();
83 toastAdd.mockReset();
84 setFilesViewed.mockReset().mockResolvedValue(AsyncResult.success(undefined));
85 act(() => {
86 renderer = create(
87 <StrictMode>
88 <Surface />
89 </StrictMode>,
90 );
91 });
92});
93
94afterEach(async () => {
95 await act(async () => renderer?.unmount());
96 renderer = null;
97 vi.useRealTimers();
98 vi.unstubAllGlobals();
99});
100
101describe("a mark whose file was pushed to before the read that followed it", () => {
102 it("gives way to the host and shows the file as changed", async () => {
103 view().setViewed("a.ts", true);
104 await act(async () => vi.advanceTimersByTimeAsync(500));
105 expect(setFilesViewed).toHaveBeenCalledExactlyOnceWith({
106 environmentId,
107 input: { ...reference, files: [{ path: "a.ts", viewed: true }] },
108 });
109 expect(host.refresh).toHaveBeenCalled();
110
111 // The push landed between the write and this read, so the host answers `dismissed` rather
112 // than the `viewed` the press asked for.
113 await reads("dismissed");
114
115 expect(view().isViewed("a.ts")).toBe(false);
116 expect(view().isStale("a.ts")).toBe(true);
117 expect(view().viewedCount).toBe(0);
118 });
119
120 it("stays given way to on every later read, having nothing left to recover", async () => {
121 view().setViewed("a.ts", true);
122 await act(async () => vi.advanceTimersByTimeAsync(500));
123 await reads("dismissed");
124
125 view().refresh();
126 await reads("dismissed");
127
128 expect(view().isViewed("a.ts")).toBe(false);
129 expect(view().isStale("a.ts")).toBe(true);
130 });
131});
132
133describe("a mark the host has not answered for yet", () => {
134 it("holds the press while the write is still out", async () => {
135 let land = (_result: unknown) => {};
136 setFilesViewed.mockReturnValueOnce(new Promise((resolve) => (land = resolve)));
137
138 view().setViewed("a.ts", true);
139 await act(async () => vi.advanceTimersByTimeAsync(500));
140
141 // An answer already on its way when the box was ticked must not put it back.
142 await reads("unviewed");
143 expect(view().isViewed("a.ts")).toBe(true);
144
145 await act(async () => land(AsyncResult.success(undefined)));
146 expect(view().isViewed("a.ts")).toBe(true);
147 });
148
149 it("holds a press made since the read that would otherwise answer for it", async () => {
150 view().setViewed("a.ts", true);
151 await act(async () => vi.advanceTimersByTimeAsync(500));
152
153 // Pressed again before the post-write read came back. That press is the one on screen, and
154 // the read that answers for the first one says nothing about it.
155 view().setViewed("a.ts", true);
156 await reads("dismissed");
157
158 expect(view().isViewed("a.ts")).toBe(true);
159 expect(view().isStale("a.ts")).toBe(false);
160 });
161});