1import { SourceControlProviderKind } from "@t3tools/contracts";
2import { ProjectId, type PullRequestSummary, type VcsStatusResult } from "@t3tools/contracts";
3import { describe, expect, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";
5import { AtomRegistry } from "effect/reactivity";
6import type { AnimationEvent } from "react";
7
8import {
9 ChangeRequestStatusIcon,
10 nextThreadChangeRequestSnapshot,
11 prStatusIndicator,
12 resolveDisplayedThreadPr,
13 resolveDisplayedThreadPrProvider,
14 resolveThreadPr,
15 threadChangeRequestSnapshotsEqual,
16 threadChangeRequestSnapshotsAtom,
17 type ThreadChangeRequestSnapshot,
18 resolveThreadPullRequestBadgePresentation,
19 synchronizeTerminalPulse,
20} from "./ThreadStatusIndicators";
21import { newestPullRequestSummary } from "../state/pullRequests";
22import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
23
24describe("synchronizeTerminalPulse", () => {
25 it("pins only the status pulse to the document clock", () => {
26 const pulse = { animationName: "status-pulse", startTime: 975 } as CSSAnimation;
27 const otherCss = { animationName: "other-animation", startTime: 125 } as CSSAnimation;
28 const otherAnimation = { startTime: 250 } as Animation;
29
30 synchronizeTerminalPulse({
31 animationName: "status-pulse",
32 currentTarget: { getAnimations: () => [pulse, otherCss, otherAnimation] },
33 } as AnimationEvent<SVGSVGElement>);
34
35 expect([pulse.startTime, otherCss.startTime, otherAnimation.startTime]).toEqual([0, 125, 250]);
36 });
37});
38
39describe("ChangeRequestStatusIcon", () => {
40 it.each([
41 ["open", "open", false, PullRequestGlyph.pullRequest],
42 ["draft", "open", true, PullRequestGlyph.draft],
43 ["closed", "closed", false, PullRequestGlyph.closed],
44 ["merged", "merged", false, PullRequestGlyph.merged],
45 ] as const)("uses the %s pull request glyph", (_label, state, isDraft, expectedIcon) => {
46 expect(ChangeRequestStatusIcon({ state, isDraft }).type).toBe(expectedIcon);
47 });
48});
49
50function status(overrides: Partial<VcsStatusResult> = {}): VcsStatusResult {
51 return {
52 isRepo: true,
53 hasPrimaryRemote: true,
54 isDefaultRef: false,
55 refName: "feature/current",
56 hasWorkingTreeChanges: false,
57 workingTree: { files: [], insertions: 0, deletions: 0 },
58 hasUpstream: true,
59 aheadCount: 0,
60 behindCount: 0,
61 pr: {
62 number: 42,
63 title: "PR branch",
64 url: "https://github.com/pingdotgg/t3code/pull/42",
65 baseRef: "main",
66 headRef: "feature/current",
67 state: "open",
68 },
69 ...overrides,
70 };
71}
72
73function mergedFeaturePr(): NonNullable<VcsStatusResult["pr"]> {
74 return {
75 number: 42,
76 title: "Feature PR",
77 url: "https://github.com/pingdotgg/t3code/pull/42",
78 baseRef: "main",
79 headRef: "feature/current",
80 state: "merged",
81 };
82}
83
84function snapshotFor(
85 branch: string,
86 pr: NonNullable<VcsStatusResult["pr"]>,
87 sourceControlProvider?: VcsStatusResult["sourceControlProvider"],
88): ThreadChangeRequestSnapshot {
89 return { branch, pr, sourceControlProvider };
90}
91
92function pullRequestSummary(
93 state: PullRequestSummary["state"],
94 updatedAt: string,
95): PullRequestSummary {
96 return {
97 provider: SourceControlProviderKind.make("github"),
98 projectId: ProjectId.make("project-1"),
99 repository: "pingdotgg/t3code",
100 number: 42,
101 title: "Feature PR",
102 url: "https://github.com/pingdotgg/t3code/pull/42",
103 state,
104 headBranch: "feature/current",
105 baseBranch: "main",
106 updatedAt,
107 };
108}
109
110describe("shared pull request state", () => {
111 it("shows a panel-observed merge instead of an older sidebar summary", () => {
112 const open = pullRequestSummary("open", "2026-09-03T01:00:00.000Z");
113 const merged = pullRequestSummary("merged", "2026-09-03T01:01:00.000Z");
114
115 expect(newestPullRequestSummary(open, merged)).toBe(merged);
116 });
117
118 it("never lets a stale open response regress a merged observation", () => {
119 const merged = pullRequestSummary("merged", "2026-09-03T01:01:00.000Z");
120 const staleOpen = pullRequestSummary("open", "2026-09-03T01:00:00.000Z");
121
122 expect(newestPullRequestSummary(merged, staleOpen)).toBe(merged);
123 });
124
125 it("accepts a newer open state after a closed pull request is reopened", () => {
126 const closed = pullRequestSummary("closed", "2026-09-03T01:00:00.000Z");
127 const reopened = pullRequestSummary("open", "2026-09-03T01:01:00.000Z");
128
129 expect(newestPullRequestSummary(closed, reopened)).toBe(reopened);
130 });
131});
132
133describe("resolveThreadPr", () => {
134 it("keeps local-checkout PR indicators scoped to the stored thread branch", () => {
135 expect(
136 resolveThreadPr({
137 threadBranch: "feature/other",
138 gitStatus: status(),
139 }),
140 ).toBeNull();
141 });
142
143 it("hides PR indicators when a dedicated worktree has switched away from the thread branch", () => {
144 expect(
145 resolveThreadPr({
146 threadBranch: "stack/base",
147 gitStatus: status(),
148 }),
149 ).toBeNull();
150 });
151
152 it("hides PR indicators when thread branch metadata is missing", () => {
153 expect(
154 resolveThreadPr({
155 threadBranch: null,
156 gitStatus: status(),
157 }),
158 ).toBeNull();
159 });
160
161 it("shows the PR when the live checkout matches the stored thread branch", () => {
162 const gitStatus = status();
163
164 expect(
165 resolveThreadPr({
166 threadBranch: "feature/current",
167 gitStatus,
168 }),
169 ).toBe(gitStatus.pr);
170 });
171});
172
173describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => {
174 const featureBranch = "feature/current";
175 const mergedPr = mergedFeaturePr();
176 const linkedPullRequest = {
177 projectId: ProjectId.make("project-1"),
178 repository: "pingdotgg/t3code",
179 number: 42,
180 url: "https://github.com/pingdotgg/t3code/pull/42",
181 };
182 const provider = {
183 kind: SourceControlProviderKind.make("github"),
184 name: "GitHub",
185 baseUrl: "https://github.com",
186 };
187
188 it("returns the live merged PR when the checkout matches the feature branch", () => {
189 const gitStatus = status({
190 refName: featureBranch,
191 pr: mergedPr,
192 sourceControlProvider: provider,
193 });
194
195 expect(
196 resolveDisplayedThreadPr({
197 threadBranch: featureBranch,
198 gitStatus,
199 snapshot: undefined,
200 retainTerminalOnBranchMismatch: true,
201 }),
202 ).toBe(mergedPr);
203 expect(
204 resolveDisplayedThreadPrProvider({
205 threadBranch: featureBranch,
206 gitStatus,
207 snapshot: undefined,
208 retainTerminalOnBranchMismatch: true,
209 }),
210 ).toEqual(provider);
211 });
212
213 it("shows a linked pull request when the checkout has a different branch", () => {
214 const linkedPullRequestStatus = {
215 pr: mergedPr,
216 sourceControlProvider: provider,
217 };
218
219 expect(
220 resolveDisplayedThreadPr({
221 threadBranch: "feature/other",
222 gitStatus: status({ refName: "feature/other", pr: null }),
223 snapshot: undefined,
224 retainTerminalOnBranchMismatch: false,
225 linkedPullRequest,
226 linkedPullRequestStatus,
227 }),
228 ).toEqual(mergedPr);
229 expect(
230 resolveDisplayedThreadPrProvider({
231 threadBranch: "feature/other",
232 gitStatus: status({ refName: "feature/other", pr: null }),
233 snapshot: undefined,
234 retainTerminalOnBranchMismatch: false,
235 linkedPullRequest,
236 linkedPullRequestStatus,
237 }),
238 ).toEqual(provider);
239 expect(
240 nextThreadChangeRequestSnapshot({
241 threadBranch: "feature/other",
242 gitStatus: status({ refName: "feature/other", pr: null }),
243 snapshot: undefined,
244 retainTerminalOnBranchMismatch: false,
245 linkedPullRequest,
246 linkedPullRequestStatus,
247 }),
248 ).toEqual({
249 branch: "feature/other",
250 pr: mergedPr,
251 sourceControlProvider: provider,
252 linkedPullRequest,
253 });
254 });
255
256 it("keeps a matching linked pull request snapshot while its status reloads", () => {
257 const snapshot = {
258 ...snapshotFor(featureBranch, mergedPr, provider),
259 linkedPullRequest,
260 };
261
262 expect(
263 resolveDisplayedThreadPr({
264 threadBranch: null,
265 gitStatus: null,
266 snapshot,
267 retainTerminalOnBranchMismatch: false,
268 linkedPullRequest,
269 linkedPullRequestStatus: null,
270 }),
271 ).toEqual(mergedPr);
272 expect(
273 nextThreadChangeRequestSnapshot({
274 threadBranch: null,
275 gitStatus: null,
276 snapshot,
277 retainTerminalOnBranchMismatch: false,
278 linkedPullRequest,
279 linkedPullRequestStatus: null,
280 }),
281 ).toBeUndefined();
282 });
283
284 it("clears an old snapshot when a different pull request is linked", () => {
285 const snapshot = {
286 ...snapshotFor(featureBranch, mergedPr, provider),
287 linkedPullRequest: { ...linkedPullRequest, number: 41 },
288 };
289
290 expect(
291 nextThreadChangeRequestSnapshot({
292 threadBranch: featureBranch,
293 gitStatus: null,
294 snapshot,
295 retainTerminalOnBranchMismatch: true,
296 linkedPullRequest,
297 linkedPullRequestStatus: null,
298 }),
299 ).toBeNull();
300 });
301
302 it("removes a linked pull request snapshot after the link is cleared", () => {
303 const snapshot = {
304 ...snapshotFor(featureBranch, mergedPr, provider),
305 linkedPullRequest,
306 };
307
308 expect(
309 resolveDisplayedThreadPr({
310 threadBranch: featureBranch,
311 gitStatus: status({ refName: "main", pr: null }),
312 snapshot,
313 retainTerminalOnBranchMismatch: true,
314 }),
315 ).toBeNull();
316 expect(
317 nextThreadChangeRequestSnapshot({
318 threadBranch: featureBranch,
319 gitStatus: null,
320 snapshot,
321 retainTerminalOnBranchMismatch: true,
322 }),
323 ).toBeNull();
324 });
325
326 it("after caching a merged PR, resolves main status back to the cached feature PR", () => {
327 const matchingStatus = status({
328 refName: featureBranch,
329 pr: mergedPr,
330 sourceControlProvider: provider,
331 });
332 const cached = nextThreadChangeRequestSnapshot({
333 threadBranch: featureBranch,
334 gitStatus: matchingStatus,
335 snapshot: undefined,
336 retainTerminalOnBranchMismatch: true,
337 });
338 expect(cached).toEqual(snapshotFor(featureBranch, mergedPr, provider));
339
340 const mainStatus = status({
341 refName: "main",
342 isDefaultRef: true,
343 pr: {
344 number: 99,
345 title: "Unrelated main PR",
346 url: "https://github.com/pingdotgg/t3code/pull/99",
347 baseRef: "main",
348 headRef: "main",
349 state: "open",
350 },
351 sourceControlProvider: provider,
352 });
353
354 expect(
355 resolveDisplayedThreadPr({
356 threadBranch: featureBranch,
357 gitStatus: mainStatus,
358 snapshot: cached as ThreadChangeRequestSnapshot,
359 retainTerminalOnBranchMismatch: true,
360 }),
361 ).toEqual(mergedPr);
362 expect(
363 resolveDisplayedThreadPrProvider({
364 threadBranch: featureBranch,
365 gitStatus: mainStatus,
366 snapshot: cached as ThreadChangeRequestSnapshot,
367 retainTerminalOnBranchMismatch: true,
368 }),
369 ).toEqual(provider);
370 });
371
372 it("never attaches a PR reported by main to the feature thread", () => {
373 const mainPr = {
374 number: 99,
375 title: "Unrelated main PR",
376 url: "https://github.com/pingdotgg/t3code/pull/99",
377 baseRef: "develop",
378 headRef: "main",
379 state: "merged" as const,
380 };
381 expect(
382 resolveDisplayedThreadPr({
383 threadBranch: featureBranch,
384 gitStatus: status({ refName: "main", pr: mainPr }),
385 snapshot: undefined,
386 retainTerminalOnBranchMismatch: true,
387 }),
388 ).toBeNull();
389 expect(
390 nextThreadChangeRequestSnapshot({
391 threadBranch: featureBranch,
392 gitStatus: status({ refName: "main", pr: mainPr }),
393 snapshot: undefined,
394 retainTerminalOnBranchMismatch: true,
395 }),
396 ).toBeNull();
397 });
398
399 it("does not show a cached open PR across a branch mismatch", () => {
400 const openSnapshot = snapshotFor(featureBranch, {
401 ...mergedPr,
402 state: "open",
403 title: "Still open",
404 });
405
406 expect(
407 resolveDisplayedThreadPr({
408 threadBranch: featureBranch,
409 gitStatus: status({ refName: "main", pr: null }),
410 snapshot: openSnapshot,
411 retainTerminalOnBranchMismatch: true,
412 }),
413 ).toBeNull();
414 });
415
416 it("retains a cached closed PR across a branch mismatch", () => {
417 const closedPr = { ...mergedPr, state: "closed" as const, title: "Closed feature" };
418 const closedSnapshot = snapshotFor(featureBranch, closedPr, provider);
419
420 expect(
421 resolveDisplayedThreadPr({
422 threadBranch: featureBranch,
423 gitStatus: status({ refName: "main", pr: null }),
424 snapshot: closedSnapshot,
425 retainTerminalOnBranchMismatch: true,
426 }),
427 ).toEqual(closedPr);
428 });
429
430 it("does not retain or display a terminal PR when a worktree switches branches", () => {
431 const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider);
432 const mismatchedStatus = status({ refName: "feature/other", pr: null });
433
434 expect(
435 resolveDisplayedThreadPr({
436 threadBranch: featureBranch,
437 gitStatus: mismatchedStatus,
438 snapshot: terminalSnapshot,
439 retainTerminalOnBranchMismatch: false,
440 }),
441 ).toBeNull();
442 expect(
443 resolveDisplayedThreadPrProvider({
444 threadBranch: featureBranch,
445 gitStatus: mismatchedStatus,
446 snapshot: terminalSnapshot,
447 retainTerminalOnBranchMismatch: false,
448 }),
449 ).toBeUndefined();
450 expect(
451 nextThreadChangeRequestSnapshot({
452 threadBranch: featureBranch,
453 gitStatus: mismatchedStatus,
454 snapshot: terminalSnapshot,
455 retainTerminalOnBranchMismatch: false,
456 }),
457 ).toBeNull();
458 });
459
460 it("retains a local terminal snapshot when thread metadata follows the new branch", () => {
461 const otherBranchSnapshot = snapshotFor("feature/other", mergedPr, provider);
462
463 expect(
464 resolveDisplayedThreadPr({
465 threadBranch: featureBranch,
466 gitStatus: status({ refName: "main", pr: null }),
467 snapshot: otherBranchSnapshot,
468 retainTerminalOnBranchMismatch: true,
469 }),
470 ).toEqual(mergedPr);
471 });
472
473 it("retains a terminal snapshot when a local thread and status move to a branch with no PR", () => {
474 const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider);
475
476 expect(
477 nextThreadChangeRequestSnapshot({
478 threadBranch: "main",
479 gitStatus: status({ refName: "main", pr: null }),
480 snapshot: terminalSnapshot,
481 retainTerminalOnBranchMismatch: true,
482 }),
483 ).toBeUndefined();
484 expect(
485 resolveDisplayedThreadPr({
486 threadBranch: "main",
487 gitStatus: status({ refName: "main", pr: null }),
488 snapshot: terminalSnapshot,
489 retainTerminalOnBranchMismatch: true,
490 }),
491 ).toEqual(mergedPr);
492 });
493
494 it("clears an open snapshot when a local thread moves to a branch with no PR", () => {
495 const openSnapshot = snapshotFor(featureBranch, { ...mergedPr, state: "open" });
496
497 expect(
498 nextThreadChangeRequestSnapshot({
499 threadBranch: "main",
500 gitStatus: status({ refName: "main", pr: null }),
501 snapshot: openSnapshot,
502 retainTerminalOnBranchMismatch: true,
503 }),
504 ).toBeNull();
505 });
506
507 it("clears an open snapshot when a local checkout moves to a different branch", () => {
508 const openSnapshot = snapshotFor(featureBranch, { ...mergedPr, state: "open" });
509
510 expect(
511 nextThreadChangeRequestSnapshot({
512 threadBranch: featureBranch,
513 gitStatus: status({ refName: "main", pr: null }),
514 snapshot: openSnapshot,
515 retainTerminalOnBranchMismatch: true,
516 }),
517 ).toBeNull();
518 });
519
520 it("clears a retained snapshot when the thread branch is cleared", () => {
521 const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider);
522
523 expect(
524 nextThreadChangeRequestSnapshot({
525 threadBranch: null,
526 gitStatus: status({ refName: "main", pr: null }),
527 snapshot: terminalSnapshot,
528 retainTerminalOnBranchMismatch: true,
529 }),
530 ).toBeNull();
531 expect(
532 resolveDisplayedThreadPr({
533 threadBranch: null,
534 gitStatus: status({ refName: "main", pr: null }),
535 snapshot: terminalSnapshot,
536 retainTerminalOnBranchMismatch: true,
537 }),
538 ).toBeNull();
539 expect(
540 resolveDisplayedThreadPrProvider({
541 threadBranch: null,
542 gitStatus: status({ refName: "main", pr: null }),
543 snapshot: terminalSnapshot,
544 retainTerminalOnBranchMismatch: true,
545 }),
546 ).toBeUndefined();
547 });
548
549 it("does not erase a terminal snapshot when VCS data is missing", () => {
550 const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider);
551
552 expect(
553 nextThreadChangeRequestSnapshot({
554 threadBranch: featureBranch,
555 gitStatus: null,
556 snapshot: terminalSnapshot,
557 retainTerminalOnBranchMismatch: true,
558 }),
559 ).toBeUndefined();
560 expect(
561 resolveDisplayedThreadPr({
562 threadBranch: featureBranch,
563 gitStatus: null,
564 snapshot: terminalSnapshot,
565 retainTerminalOnBranchMismatch: true,
566 }),
567 ).toEqual(mergedPr);
568 });
569
570 it("shows the last matching PR snapshot while its live status lease is released", () => {
571 const openPr = { ...mergedPr, state: "open" as const };
572 const input = {
573 threadBranch: featureBranch,
574 gitStatus: null,
575 snapshot: snapshotFor(featureBranch, openPr, provider),
576 retainTerminalOnBranchMismatch: false,
577 };
578 expect(resolveDisplayedThreadPr(input)).toEqual(openPr);
579 expect(resolveDisplayedThreadPrProvider(input)).toEqual(provider);
580 expect(resolveDisplayedThreadPr({ ...input, threadBranch: "feature/other" })).toBeNull();
581 expect(
582 resolveDisplayedThreadPr({
583 ...input,
584 snapshot: { ...input.snapshot, linkedPullRequest },
585 }),
586 ).toBeNull();
587 });
588
589 it("retains a merged PR after a main checkout", () => {
590 const matchingStatus = status({
591 refName: featureBranch,
592 pr: mergedPr,
593 sourceControlProvider: provider,
594 });
595 const cached = nextThreadChangeRequestSnapshot({
596 threadBranch: featureBranch,
597 gitStatus: matchingStatus,
598 snapshot: undefined,
599 retainTerminalOnBranchMismatch: true,
600 });
601 expect(cached).not.toBeNull();
602 expect(cached).not.toBeUndefined();
603
604 const mainStatus = status({ refName: "main", pr: null, isDefaultRef: true });
605 const displayed = resolveDisplayedThreadPr({
606 threadBranch: "main",
607 gitStatus: mainStatus,
608 snapshot: cached as ThreadChangeRequestSnapshot,
609 retainTerminalOnBranchMismatch: true,
610 });
611 expect(displayed?.state).toBe("merged");
612 });
613
614 it("refreshes a cached snapshot when a pull request becomes ready", () => {
615 const readyPr = { ...mergedPr, state: "open" as const };
616 const draftPr = { ...readyPr, isDraft: true };
617
618 expect(
619 threadChangeRequestSnapshotsEqual(
620 snapshotFor(featureBranch, draftPr),
621 snapshotFor(featureBranch, readyPr),
622 ),
623 ).toBe(false);
624 });
625});
626
627describe("threadChangeRequestSnapshotsAtom", () => {
628 it.effect("retains snapshots while sidebar and chat consumers are unmounted", () =>
629 Effect.gen(function* () {
630 const registry = AtomRegistry.make();
631 const threadKey = "environment-1:thread-1";
632 const snapshot = snapshotFor("feature/current", mergedFeaturePr());
633
634 const unmount = registry.mount(threadChangeRequestSnapshotsAtom);
635 registry.set(threadChangeRequestSnapshotsAtom, new Map([[threadKey, snapshot]]));
636 unmount();
637
638 yield* Effect.yieldNow;
639
640 const remount = registry.mount(threadChangeRequestSnapshotsAtom);
641 expect(registry.get(threadChangeRequestSnapshotsAtom).get(threadKey)).toEqual(snapshot);
642
643 remount();
644 registry.dispose();
645 }),
646 );
647});
648
649describe("prStatusIndicator", () => {
650 it("formats PR tooltips with number, uppercase status, and title", () => {
651 expect(prStatusIndicator(status().pr, undefined)).toMatchObject({
652 tooltip: "PR #42 - Open: PR branch",
653 tooltipLead: "PR #42 - Open",
654 tooltipTitle: "PR branch",
655 });
656 });
657
658 it("uses red for closed pull requests", () => {
659 const closedPr = status().pr;
660 if (!closedPr) throw new Error("Expected pull request fixture");
661
662 expect(prStatusIndicator({ ...closedPr, state: "closed" }, undefined)?.colorClass).toContain(
663 "text-red-600",
664 );
665 });
666
667 it("uses gray and draft wording for draft pull requests", () => {
668 const draftPr = status().pr;
669 if (!draftPr) throw new Error("Expected pull request fixture");
670
671 expect(prStatusIndicator({ ...draftPr, isDraft: true }, undefined)).toMatchObject({
672 label: "PR draft",
673 colorClass: "text-zinc-500 dark:text-zinc-400/80",
674 tooltipLead: "PR #42 - Draft",
675 });
676 });
677});
678
679describe("resolveThreadPullRequestBadgePresentation", () => {
680 const url = "https://github.com/pingdotgg/t3code/pull/42";
681
682 it("returns the pending pull-request badge when no snapshot is available", () => {
683 expect(
684 resolveThreadPullRequestBadgePresentation({
685 badge: null,
686 number: 42,
687 url,
688 status: null,
689 }),
690 ).toEqual({
691 Icon: PullRequestGlyph.pullRequest,
692 toneClassName: "text-muted-foreground",
693 label: "PR #42, status pending",
694 text: 42,
695 });
696 });
697
698 it.each([
699 [
700 "open",
701 { state: "open", isDraft: false },
702 PullRequestGlyph.pullRequest,
703 "text-emerald-600 dark:text-emerald-300/90",
704 "PR #42 - Open: PR branch",
705 ],
706 [
707 "draft",
708 { state: "open", isDraft: true },
709 PullRequestGlyph.draft,
710 "text-zinc-500 dark:text-zinc-400/80",
711 "PR #42 - Draft: PR branch",
712 ],
713 [
714 "closed",
715 { state: "closed", isDraft: false },
716 PullRequestGlyph.closed,
717 "text-red-600 dark:text-red-300/90",
718 "PR #42 - Closed: PR branch",
719 ],
720 [
721 "merged",
722 { state: "merged", isDraft: false },
723 PullRequestGlyph.merged,
724 "text-violet-600 dark:text-violet-300/90",
725 "PR #42 - Merged: PR branch",
726 ],
727 ] as const)(
728 "keeps the %s state for one linked pull request",
729 (_state, prOverrides, expectedIcon, expectedToneClassName, expectedLabel) => {
730 const fixture = status().pr;
731 if (!fixture) throw new Error("Expected pull request fixture");
732 const prStatus = prStatusIndicator({ ...fixture, ...prOverrides }, undefined);
733 if (!prStatus) throw new Error("Expected pull request status");
734
735 expect(
736 resolveThreadPullRequestBadgePresentation({
737 badge: { kind: "pull-request", others: 0, state: "open" },
738 number: fixture.number,
739 url: fixture.url,
740 status: prStatus,
741 }),
742 ).toEqual({
743 Icon: expectedIcon,
744 toneClassName: expectedToneClassName,
745 label: expectedLabel,
746 text: fixture.number,
747 });
748 },
749 );
750
751 it.each([
752 ["open", "text-emerald-600 dark:text-emerald-300/90"],
753 ["draft", "text-zinc-500 dark:text-zinc-400/80"],
754 ["merged", "text-violet-600 dark:text-violet-300/90"],
755 ] as const)(
756 "uses a layers badge with the %s stack tone without a link identity",
757 (state, expectedToneClassName) => {
758 expect(
759 resolveThreadPullRequestBadgePresentation({
760 badge: { kind: "stack", layers: 3, state },
761 status: null,
762 }),
763 ).toEqual({
764 Icon: PullRequestGlyph.stack,
765 toneClassName: expectedToneClassName,
766 label: `Stack of 3 pull requests, ${state}`,
767 text: 3,
768 });
769 },
770 );
771
772 it.each([
773 ["open", PullRequestGlyph.pullRequest, "text-emerald-600 dark:text-emerald-300/90"],
774 ["draft", PullRequestGlyph.draft, "text-zinc-500 dark:text-zinc-400/80"],
775 ["merged", PullRequestGlyph.merged, "text-violet-600 dark:text-violet-300/90"],
776 ] as const)(
777 "draws the count of unrelated linked pull requests with their %s aggregate state",
778 (state, expectedIcon, expectedToneClassName) => {
779 const fixture = status().pr;
780 if (!fixture) throw new Error("Expected pull request fixture");
781 const closedStatus = prStatusIndicator(
782 { ...fixture, state: "closed", isDraft: false },
783 undefined,
784 );
785 if (!closedStatus) throw new Error("Expected pull request status");
786
787 expect(
788 resolveThreadPullRequestBadgePresentation({
789 badge: { kind: "pull-request", others: 2, state },
790 number: fixture.number,
791 url: fixture.url,
792 status: closedStatus,
793 }),
794 ).toEqual({
795 Icon: expectedIcon,
796 toneClassName: expectedToneClassName,
797 label: `PR #42 - Closed: PR branch, and 2 more linked; overall ${state}`,
798 text: "+3",
799 });
800 },
801 );
802
803 it("omits the control when neither a stack nor a linked identity can be shown", () => {
804 expect(resolveThreadPullRequestBadgePresentation({ badge: null, status: null })).toBeNull();
805 });
806});