| 1 | import { |
| 2 | EnvironmentId, |
| 3 | ProjectId, |
| 4 | type PullRequestFilesViewedResult, |
| 5 | type PullRequestRef, |
| 6 | } from "@t3tools/contracts"; |
| 7 | import { AsyncResult } from "effect/reactivity"; |
| 8 | import { act, StrictMode } from "react"; |
| 9 | import { create, type ReactTestRenderer } from "react-test-renderer"; |
| 10 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 11 | |
| 12 | const { host, setFilesViewed, toastAdd } = vi.hoisted(() => ({ |
| 13 | host: { data: null as unknown, refresh: vi.fn() }, |
| 14 | setFilesViewed: vi.fn(), |
| 15 | toastAdd: vi.fn(), |
| 16 | })); |
| 17 | |
| 18 | vi.mock("~/state/pullRequests", () => ({ |
| 19 | pullRequestEnvironment: { filesViewed: () => null, setFilesViewed: {} }, |
| 20 | })); |
| 21 | vi.mock("~/state/query", () => ({ |
| 22 | useEnvironmentQuery: () => ({ |
| 23 | data: host.data, |
| 24 | error: null, |
| 25 | isPending: false, |
| 26 | isSuccess: true, |
| 27 | refresh: host.refresh, |
| 28 | }), |
| 29 | })); |
| 30 | vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => setFilesViewed })); |
| 31 | vi.mock("../ui/toast", () => ({ toastManager: { add: toastAdd } })); |
| 32 | |
| 33 | import { |
| 34 | usePullRequestFilesViewed, |
| 35 | type PullRequestFilesViewedView, |
| 36 | } from "./usePullRequestFilesViewed"; |
| 37 | |
| 38 | const environmentId = EnvironmentId.make("pr-files-viewed-audit"); |
| 39 | const reference: PullRequestRef = { |
| 40 | projectId: ProjectId.make("project-a"), |
| 41 | repository: "acme/web", |
| 42 | number: 42, |
| 43 | }; |
| 44 | const 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. */ |
| 47 | function answer(state: "unviewed" | "viewed" | "dismissed"): PullRequestFilesViewedResult { |
| 48 | return { files: [{ path: "a.ts", state }], truncated: false }; |
| 49 | } |
| 50 | |
| 51 | let renderer: ReactTestRenderer | null = null; |
| 52 | |
| 53 | function Probe(_props: { readonly view: PullRequestFilesViewedView }) { |
| 54 | return null; |
| 55 | } |
| 56 | |
| 57 | function Surface() { |
| 58 | const view = usePullRequestFilesViewed({ environmentId, reference, enabled: true, paths }); |
| 59 | return <Probe view={view} />; |
| 60 | } |
| 61 | |
| 62 | function 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. */ |
| 67 | async 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 | |
| 78 | beforeEach(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 | |
| 94 | afterEach(async () => { |
| 95 | await act(async () => renderer?.unmount()); |
| 96 | renderer = null; |
| 97 | vi.useRealTimers(); |
| 98 | vi.unstubAllGlobals(); |
| 99 | }); |
| 100 | |
| 101 | describe("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 | |
| 133 | describe("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 | }); |