1import { SourceControlProviderKind } from "@t3tools/contracts";
2import { ProjectId, type ThreadPullRequestLink, type VcsStatusResult } from "@t3tools/contracts";
3import { describe, expect, it } from "vite-plus/test";
4
5import {
6 presentThreadLinkedPullRequests,
7 presentThreadPr,
8 resolveThreadPrSource,
9} from "./thread-pr-presentation";
10
11const pullRequest: NonNullable<VcsStatusResult["pr"]> = {
12 number: 3774,
13 title: "Desktop-style pull request indicator",
14 url: "https://github.com/t3tools/t3code/pull/3774",
15 baseRef: "main",
16 headRef: "codex/desktop-style-pr-indicator",
17 state: "merged",
18};
19
20describe("presentThreadPr", () => {
21 it("uses the compact pull request number label without a hash prefix", () => {
22 expect(presentThreadPr(pullRequest, undefined)).toMatchObject({
23 label: "3774",
24 accessibilityLabel: "#3774 pull request merged",
25 textClassName: "text-adaptive-violet-600-400",
26 });
27 });
28
29 it("uses merge-request terminology for GitLab", () => {
30 expect(
31 presentThreadPr(pullRequest, {
32 kind: SourceControlProviderKind.make("gitlab"),
33 name: "GitLab",
34 baseUrl: "https://gitlab.com",
35 }),
36 ).toMatchObject({
37 label: "3774",
38 accessibilityLabel: "#3774 merge request merged",
39 });
40 });
41
42 it("uses gray for draft pull requests", () => {
43 expect(
44 presentThreadPr({ ...pullRequest, state: "open", isDraft: true }, undefined),
45 ).toMatchObject({
46 accessibilityLabel: "#3774 pull request draft",
47 textClassName: "text-foreground-muted",
48 });
49 });
50});
51
52function linkedPr(
53 number: number,
54 overrides: Partial<ThreadPullRequestLink> = {},
55): ThreadPullRequestLink {
56 return {
57 host: "github.com",
58 repository: "t3tools/t3code",
59 number,
60 url: `https://github.com/t3tools/t3code/pull/${number}`,
61 source: "manual",
62 linkedAt: "2026-09-08T00:00:00.000Z",
63 stack: null,
64 snapshot: {
65 state: "open",
66 title: `Change ${number}`,
67 headBranch: `change-${number}`,
68 baseBranch: "main",
69 isDraft: false,
70 updatedAt: null,
71 syncedAt: "2026-09-08T00:00:00.000Z",
72 },
73 ...overrides,
74 };
75}
76
77describe("presentThreadLinkedPullRequests", () => {
78 it("renders unsynced links with neutral pending status", () => {
79 expect(presentThreadLinkedPullRequests([linkedPr(1, { snapshot: null })])).toMatchObject({
80 number: 1,
81 label: "1",
82 state: null,
83 textClassName: "text-foreground-muted",
84 accessibilityLabel: "#1 pull request status pending",
85 });
86 });
87
88 it("counts unrelated links without labelling them a stack", () => {
89 expect(presentThreadLinkedPullRequests([linkedPr(1), linkedPr(2)])).toMatchObject({
90 kind: "pull-request",
91 label: "+2",
92 others: 1,
93 state: "open",
94 isDraft: false,
95 textClassName: "text-adaptive-emerald-600-400",
96 });
97 });
98
99 it.each([
100 ["closed", false, "closed", false, "closed", false, "text-adaptive-rose-600-400"],
101 ["open", true, "open", true, "open", true, "text-foreground-muted"],
102 ["open", true, "open", false, "open", false, "text-adaptive-emerald-600-400"],
103 ["closed", false, "open", false, "open", false, "text-adaptive-emerald-600-400"],
104 ["merged", false, "merged", false, "merged", false, "text-adaptive-violet-600-400"],
105 ["closed", false, "merged", false, "closed", false, "text-adaptive-rose-600-400"],
106 ] as const)(
107 "colors linked %s (draft %s) and %s (draft %s) by their aggregate state",
108 (firstState, firstDraft, secondState, secondDraft, state, isDraft, textClassName) => {
109 const first = linkedPr(1);
110 const second = linkedPr(2);
111 expect(
112 presentThreadLinkedPullRequests([
113 { ...first, snapshot: { ...first.snapshot!, state: firstState, isDraft: firstDraft } },
114 {
115 ...second,
116 snapshot: { ...second.snapshot!, state: secondState, isDraft: secondDraft },
117 },
118 ]),
119 ).toMatchObject({
120 label: "+2",
121 state,
122 isDraft,
123 textClassName,
124 accessibilityLabel: `2 linked pull requests, overall ${isDraft ? "draft" : state}`,
125 });
126 },
127 );
128
129 it("uses the top of a derived stack even when its bottom was linked later", () => {
130 const bottom = linkedPr(1, { linkedAt: "2026-09-09T00:00:00.000Z" });
131 const top = linkedPr(2);
132 expect(
133 presentThreadLinkedPullRequests([
134 bottom,
135 {
136 ...top,
137 snapshot: { ...top.snapshot!, baseBranch: "change-1" },
138 },
139 ]),
140 ).toMatchObject({ kind: "stack", label: "2", number: 2, url: top.url });
141 });
142
143 it("hides dismissed stack members", () => {
144 expect(
145 presentThreadLinkedPullRequests([linkedPr(1, { source: "stack-dismissed" })]),
146 ).toBeNull();
147 });
148
149 it("retains merged state from the persisted snapshot", () => {
150 const link = linkedPr(1);
151 expect(
152 presentThreadLinkedPullRequests([
153 { ...link, snapshot: { ...link.snapshot!, state: "merged" } },
154 ]),
155 ).toMatchObject({ state: "merged", textClassName: "text-adaptive-violet-600-400" });
156 });
157});
158
159describe("resolveThreadPrSource compatibility", () => {
160 const legacyRef = {
161 projectId: ProjectId.make("project"),
162 repository: "t3tools/t3code",
163 number: 1,
164 url: "https://github.com/t3tools/t3code/pull/1",
165 };
166 const branchRef = { ...legacyRef, number: 2, url: "https://github.com/t3tools/t3code/pull/2" };
167
168 it("polls the legacy reference when only the older linking capability exists", () => {
169 expect(
170 resolveThreadPrSource(
171 { pullRequests: [], linkedPullRequest: legacyRef, branchPullRequest: branchRef },
172 { threadPullRequestLinking: true },
173 ),
174 ).toEqual({ linkedPresentation: null, pullRequestRef: legacyRef });
175 });
176
177 it("keeps the legacy reference for a server without either capability", () => {
178 expect(resolveThreadPrSource({ pullRequests: [], linkedPullRequest: legacyRef }, {})).toEqual({
179 linkedPresentation: null,
180 pullRequestRef: legacyRef,
181 });
182 });
183
184 it("ignores stale snapshots after reconnecting to a server without array support", () => {
185 expect(
186 resolveThreadPrSource(
187 { pullRequests: [linkedPr(3)], linkedPullRequest: legacyRef },
188 { threadPullRequestLinking: true, threadPullRequests: false },
189 ),
190 ).toEqual({ linkedPresentation: null, pullRequestRef: legacyRef });
191 });
192
193 it("uses snapshots without polling when both capabilities are advertised", () => {
194 expect(
195 resolveThreadPrSource(
196 { pullRequests: [linkedPr(3)], linkedPullRequest: legacyRef, branchPullRequest: branchRef },
197 { threadPullRequestLinking: true, threadPullRequests: true },
198 ),
199 ).toMatchObject({ linkedPresentation: { number: 3 }, pullRequestRef: null });
200 });
201
202 it("does not revive a removed modern link from the compatibility reference", () => {
203 expect(
204 resolveThreadPrSource(
205 { pullRequests: [], linkedPullRequest: legacyRef, branchPullRequest: branchRef },
206 { threadPullRequests: true },
207 ),
208 ).toEqual({ linkedPresentation: null, pullRequestRef: branchRef });
209 });
210
211 it("does not poll when a modern link is waiting for its first snapshot", () => {
212 expect(
213 resolveThreadPrSource(
214 {
215 pullRequests: [linkedPr(3, { snapshot: null })],
216 linkedPullRequest: legacyRef,
217 branchPullRequest: branchRef,
218 },
219 { threadPullRequests: true },
220 ),
221 ).toMatchObject({ linkedPresentation: { number: 3, state: null }, pullRequestRef: null });
222 });
223});