1import { ProjectId, type PullRequestStack, type ThreadPullRequestLink } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3import {
4 pullRequestStackMerge,
5 savedPullRequestStack,
6 singlePullRequestMergeAllowed,
7} from "./pullRequestStack";
8
9const stack: PullRequestStack = {
10 id: "stack",
11 number: 10,
12 url: "https://github.com/acme/web/pull/10",
13 base: "main",
14 layers: [1, 2, 3].map((number) => ({
15 number,
16 headSha: `sha-${number}`,
17 headBranch: `layer-${number}`,
18 state: "open" as const,
19 isDraft: false,
20 })),
21};
22const reference = {
23 projectId: ProjectId.make("project"),
24 host: "github.com",
25 repository: "acme/web",
26 number: 2,
27};
28const link: ThreadPullRequestLink = {
29 host: "github.com",
30 repository: "acme/web",
31 number: 2,
32 url: "https://github.com/acme/web/pull/2",
33 source: "manual",
34 linkedAt: "2026-10-08T10:00:00Z",
35 snapshot: {
36 title: "Middle layer",
37 headBranch: "layer-2",
38 baseBranch: "layer-1",
39 state: "open",
40 isDraft: false,
41 updatedAt: null,
42 syncedAt: "2026-10-08T10:00:00Z",
43 },
44 stack: { ...stack, kind: "native" },
45};
46
47describe("mobile stack merge scope", () => {
48 it("merges only the prefix through the selected PR, preserving host order", () => {
49 const scope = pullRequestStackMerge(stack, 2);
50 expect(scope.position).toBe(2);
51 expect(scope.heads).toEqual([
52 { number: 1, headSha: "sha-1" },
53 { number: 2, headSha: "sha-2" },
54 ]);
55 expect(scope.reason).toBeNull();
56 expect(pullRequestStackMerge(stack, 3).heads).toHaveLength(3);
57 });
58 it("excludes already merged layers from the expected head revisions", () => {
59 const updated = {
60 ...stack,
61 layers: stack.layers.map((layer) =>
62 layer.number === 1 ? { ...layer, state: "merged" as const, headSha: undefined } : layer,
63 ),
64 };
65 expect(pullRequestStackMerge(updated, 2).heads).toEqual([{ number: 2, headSha: "sha-2" }]);
66 expect(pullRequestStackMerge(updated, 2).reason).toBeNull();
67 expect(pullRequestStackMerge(updated, 1).reason).toContain("no open layers");
68 });
69 it("blocks closed and draft dependencies, but permits them above the target", () => {
70 const closed = {
71 ...stack,
72 layers: stack.layers.map((layer) =>
73 layer.number === 2 ? { ...layer, state: "closed" as const } : layer,
74 ),
75 };
76 expect(pullRequestStackMerge(closed, 3).reason).toContain("Reopen");
77 expect(pullRequestStackMerge(closed, 1).reason).toBeNull();
78 const draft = {
79 ...stack,
80 layers: stack.layers.map((layer) =>
81 layer.number === 2 ? { ...layer, isDraft: true } : layer,
82 ),
83 };
84 expect(pullRequestStackMerge(draft, 3).reason).toContain("ready for review");
85 expect(pullRequestStackMerge(draft, 1).reason).toBeNull();
86 });
87 it("blocks missing revisions and a PR removed from the stack", () => {
88 const unknown = {
89 ...stack,
90 layers: stack.layers.map((layer) => ({ ...layer, headSha: undefined })),
91 };
92 expect(pullRequestStackMerge(unknown, 2).reason).toContain("latest revisions");
93 expect(pullRequestStackMerge(stack, 99).reason).toContain("no longer in the stack");
94 });
95 it("waits for successful discovery before allowing a single PR merge on modern servers", () => {
96 expect(
97 singlePullRequestMergeAllowed({
98 supportsStackActions: true,
99 hasStack: false,
100 isFresh: false,
101 }),
102 ).toBe(false);
103 expect(
104 singlePullRequestMergeAllowed({ supportsStackActions: true, hasStack: false, isFresh: true }),
105 ).toBe(true);
106 expect(
107 singlePullRequestMergeAllowed({ supportsStackActions: true, hasStack: true, isFresh: true }),
108 ).toBe(false);
109 expect(
110 singlePullRequestMergeAllowed({
111 supportsStackActions: false,
112 hasStack: false,
113 isFresh: false,
114 }),
115 ).toBe(true);
116 });
117});
118
119describe("mobile saved stack navigation", () => {
120 it("keeps all saved layers and enriches titles without carrying action revisions", () => {
121 const saved = savedPullRequestStack([link], reference);
122 expect(saved?.layers).toHaveLength(3);
123 expect(saved?.layers[1]).toMatchObject({ number: 2, title: "Middle layer", isDraft: false });
124 expect(saved?.layers.every((layer) => layer.headSha === undefined)).toBe(true);
125 expect(pullRequestStackMerge(saved!, 2).reason).toContain("latest revisions");
126 expect(savedPullRequestStack([link], { ...reference, number: 3 })?.number).toBe(10);
127 });
128 it("never borrows membership from another host or repository", () => {
129 expect(savedPullRequestStack([link], { ...reference, host: "enterprise.example" })).toBeNull();
130 expect(savedPullRequestStack([link], { ...reference, repository: "other/web" })).toBeNull();
131 expect(savedPullRequestStack([link], { ...reference, host: undefined })).toBeNull();
132 });
133 it("honors a newer removal rather than reviving an old stack", () => {
134 const removed = {
135 ...link,
136 stack: null,
137 snapshot: { ...link.snapshot!, syncedAt: "2026-10-08T11:00:00Z" },
138 };
139 expect(savedPullRequestStack([link, removed], reference)).toBeNull();
140 });
141});