Skip to content
Source
Sign inSign up

environmentGrouping.test.ts

304 lines10.4 KB

1import { EnvironmentId, ProjectId, ProviderInstanceId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3​
4import {
5 deriveLogicalProjectKeyFromSettings,
6 pickableProjectGroups,
7} from "@t3tools/client-runtime/state/project-grouping";
8import {
9 buildSidebarProjectPickerEntries,
10 buildSidebarProjectSnapshots,
11 resolveSidebarProjectScopeKey,
12 getSidebarProjectSettingsKey,
13} from "./sidebarProjectGrouping";
14import type { Project } from "./types";
15​
16const primaryEnvironmentId = EnvironmentId.make("env-primary");
17const remoteEnvironmentId = EnvironmentId.make("env-remote");
18const repositoryIdentity = {
19 canonicalKey: "github.com/example/shared-repo",
20 locator: {
21 source: "git-remote" as const,
22 remoteName: "origin",
23 remoteUrl: "https://github.com/example/shared-repo.git",
24 },
25};
26const defaultGroupingSettings = {
27 sidebarProjectGroupingMode: "repository" as const,
28 sidebarProjectGroupingOverrides: {},
29};
30​
31function makeProject(overrides: Partial<Project> = {}): Project {
32 return {
33 id: ProjectId.make("project-1"),
34 environmentId: primaryEnvironmentId,
35 title: "shared-repo",
36 workspaceRoot: "/tmp/shared-repo",
37 repositoryIdentity: null,
38 defaultModelSelection: {
39 instanceId: ProviderInstanceId.make("codex"),
40 model: "gpt-5-codex",
41 },
42 createdAt: "2026-01-01T00:00:00.000Z",
43 updatedAt: "2026-01-01T00:00:00.000Z",
44 scripts: [],
45 ...overrides,
46 };
48​
49describe("environment grouping", () => {
50 it("opens physical scratch settings from the merged filter, including a remote thread target", () => {
51 const local = makeProject({
52 title: "No project",
53 workspaceRoot: "/local/scratch",
54 isScratch: true,
55 });
56 const remote = makeProject({
57 title: "No project",
58 id: ProjectId.make("remote-scratch"),
59 environmentId: remoteEnvironmentId,
60 workspaceRoot: "/remote/scratch",
61 isScratch: true,
62 });
63 const input = {
64 projects: [local, remote],
65 settings: defaultGroupingSettings,
66 primaryEnvironmentId,
67 resolveEnvironmentLabel: () => null,
68 };
69 const filters = buildSidebarProjectSnapshots({ ...input, groupScratchProjects: true });
70 const settingsGroups = buildSidebarProjectSnapshots(input);
71 expect(filters).toHaveLength(1);
72 expect(settingsGroups).toHaveLength(2);
73 expect(settingsGroups.map((group) => group.memberProjectRefs)).toEqual([
74 [{ environmentId: local.environmentId, projectId: local.id }],
75 [{ environmentId: remote.environmentId, projectId: remote.id }],
76 ]);
77 expect(getSidebarProjectSettingsKey(filters[0]!, defaultGroupingSettings, input.projects)).toBe(
78 settingsGroups[0]?.projectKey,
79 );
80 expect(
81 getSidebarProjectSettingsKey(filters[0]!, defaultGroupingSettings, input.projects, {
82 environmentId: remote.environmentId,
83 projectId: remote.id,
84 }),
85 ).toBe(settingsGroups[1]?.projectKey);
86 });
87​
88 it.each(["repository", "repository_path"] as const)(
89 "migrates a scratch scope selected before config in %s mode",
90 (mode) => {
91 const project = makeProject({
92 workspaceRoot: "/repo/scratch",
93 repositoryIdentity: { ...repositoryIdentity, rootPath: "/repo" },
94 });
95 const settings = { ...defaultGroupingSettings, sidebarProjectGroupingMode: mode };
96 const key = deriveLogicalProjectKeyFromSettings(project, settings);
97 const groups = buildSidebarProjectSnapshots({
98 projects: [{ ...project, isScratch: true }],
99 groupScratchProjects: true,
100 settings,
101 primaryEnvironmentId,
102 resolveEnvironmentLabel: () => null,
103 });
104 expect(
105 resolveSidebarProjectScopeKey({ groups, key, settings, canClearMissingScope: true }),
106 ).toBe(groups[0]?.projectKey);
107 },
108 );
109​
110 it("prefers an existing ordinary repository scope over a scratch alias", () => {
111 const scratch = makeProject({ isScratch: true, repositoryIdentity });
112 const ordinary = makeProject({
113 id: ProjectId.make("ordinary"),
114 workspaceRoot: "/ordinary",
115 repositoryIdentity,
116 });
117 const groups = buildSidebarProjectSnapshots({
118 projects: [scratch, ordinary],
119 groupScratchProjects: true,
120 settings: defaultGroupingSettings,
121 primaryEnvironmentId,
122 resolveEnvironmentLabel: () => null,
123 });
124 const key = deriveLogicalProjectKeyFromSettings(ordinary, defaultGroupingSettings);
125 expect(resolveSidebarProjectScopeKey({ groups, key, canClearMissingScope: true })).toBe(key);
126 });
127​
128 it("dedupes stale project rows with the same environment and workspace path", () => {
129 const duplicate = makeProject({
130 id: ProjectId.make("project-duplicate"),
131 workspaceRoot: "/tmp/shared-repo/",
132 repositoryIdentity,
133 updatedAt: "2026-01-01T00:00:00.000Z",
134 });
135 const primary = makeProject({
136 id: ProjectId.make("project-primary"),
137 repositoryIdentity,
138 updatedAt: "2026-01-02T00:00:00.000Z",
139 });
140 const remote = makeProject({
141 id: ProjectId.make("project-remote"),
142 environmentId: remoteEnvironmentId,
143 workspaceRoot: "/tmp/shared-repo",
144 repositoryIdentity,
145 });
146​
147 const snapshots = buildSidebarProjectSnapshots({
148 projects: [primary, duplicate, remote],
149 settings: defaultGroupingSettings,
150 primaryEnvironmentId,
151 resolveEnvironmentLabel: (environmentId) =>
152 environmentId === remoteEnvironmentId ? "remote" : "primary",
153 });
154​
155 expect(snapshots).toHaveLength(1);
156 expect(snapshots[0]?.groupedProjectCount).toBe(2);
157 expect(snapshots[0]?.memberProjects.map((project) => project.id)).toEqual([
158 primary.id,
159 remote.id,
160 ]);
161 });
162​
163 it("dedupes stale project rows before logical grouping", () => {
164 const staleWithoutRepositoryIdentity = makeProject({
165 id: ProjectId.make("project-stale"),
166 repositoryIdentity: null,
167 updatedAt: "2026-01-01T00:00:00.000Z",
168 });
169 const canonical = makeProject({
170 id: ProjectId.make("project-canonical"),
171 repositoryIdentity,
172 updatedAt: "2026-01-02T00:00:00.000Z",
173 });
174 const remote = makeProject({
175 id: ProjectId.make("project-remote"),
176 environmentId: remoteEnvironmentId,
177 repositoryIdentity,
178 });
179​
180 const snapshots = buildSidebarProjectSnapshots({
181 projects: [staleWithoutRepositoryIdentity, canonical, remote],
182 settings: defaultGroupingSettings,
183 primaryEnvironmentId,
184 resolveEnvironmentLabel: (environmentId) =>
185 environmentId === remoteEnvironmentId ? "remote" : "primary",
186 });
187​
188 expect(snapshots).toHaveLength(1);
189 expect(snapshots[0]?.projectKey).toBe(repositoryIdentity.canonicalKey);
190 expect(snapshots[0]?.memberProjects.map((project) => project.id)).toEqual([
191 canonical.id,
192 remote.id,
193 ]);
194 expect(snapshots[0]?.memberProjectRefs).toEqual([
195 {
196 environmentId: primaryEnvironmentId,
197 projectId: staleWithoutRepositoryIdentity.id,
198 },
199 { environmentId: primaryEnvironmentId, projectId: canonical.id },
200 { environmentId: remoteEnvironmentId, projectId: remote.id },
201 ]);
202​
203 const [pickerEntry] = buildSidebarProjectPickerEntries({
204 groups: snapshots,
205 preferredProjectRef: {
206 environmentId: primaryEnvironmentId,
207 projectId: staleWithoutRepositoryIdentity.id,
208 },
209 });
210 expect(pickerEntry?.isPreferred).toBe(true);
211 expect(pickerEntry?.targetProject.id).toBe(canonical.id);
212 });
213​
214 it("keeps the current environment when available and falls back otherwise", () => {
215 const currentPrimary = makeProject({ repositoryIdentity });
216 const currentRemote = makeProject({
217 id: ProjectId.make("current-remote"),
218 environmentId: remoteEnvironmentId,
219 repositoryIdentity,
220 });
221 const destinationRepositoryIdentity = {
222 canonicalKey: "github.com/example/destination",
223 locator: {
224 source: "git-remote" as const,
225 remoteName: "origin",
226 remoteUrl: "https://github.com/example/destination.git",
227 },
228 };
229 const destinationPrimary = makeProject({
230 id: ProjectId.make("destination-primary"),
231 title: "destination",
232 workspaceRoot: "/tmp/destination",
233 repositoryIdentity: destinationRepositoryIdentity,
234 });
235 const destinationRemote = makeProject({
236 id: ProjectId.make("destination-remote"),
237 environmentId: remoteEnvironmentId,
238 title: "destination",
239 workspaceRoot: "/remote/destination",
240 repositoryIdentity: destinationRepositoryIdentity,
241 });
242 const fallbackPrimary = makeProject({
243 id: ProjectId.make("fallback-primary"),
244 title: "fallback",
245 workspaceRoot: "/tmp/fallback",
246 });
247 const groups = buildSidebarProjectSnapshots({
248 projects: [
249 currentPrimary,
250 currentRemote,
251 destinationPrimary,
252 destinationRemote,
253 fallbackPrimary,
254 ],
255 settings: defaultGroupingSettings,
256 primaryEnvironmentId,
257 resolveEnvironmentLabel: () => null,
258 });
259​
260 const entries = buildSidebarProjectPickerEntries({
261 groups,
262 preferredProjectRef: {
263 environmentId: remoteEnvironmentId,
264 projectId: currentRemote.id,
265 },
266 });
267 const destination = entries.find(
268 (entry) => entry.group.projectKey === destinationRepositoryIdentity.canonicalKey,
269 );
270 const fallback = entries.find((entry) => entry.group.displayName === "fallback");
271​
272 expect(destination?.targetProject).toMatchObject({
273 environmentId: remoteEnvironmentId,
274 id: destinationRemote.id,
275 });
276 expect(fallback?.targetProject).toMatchObject({
277 environmentId: primaryEnvironmentId,
278 id: fallbackPrimary.id,
279 });
280 });
281​
282 it("puts the preferred project before pinned projects", () => {
283 const project = (title: string, pinnedAt: string | null = null) =>
284 makeProject({
285 id: ProjectId.make(title),
286 title,
287 workspaceRoot: `/tmp/${title}`,
288 repositoryIdentity: null,
289 pinnedAt,
290 });
291 const current = project("current");
292 const groups = buildSidebarProjectSnapshots({
293 projects: [project("other"), project("pinned", "2026-01-01T00:00:00.000Z"), current],
294 settings: defaultGroupingSettings,
295 primaryEnvironmentId,
296 resolveEnvironmentLabel: () => null,
297 });
298 const entries = buildSidebarProjectPickerEntries({
299 groups: pickableProjectGroups(groups),
300 preferredProjectRef: { environmentId: current.environmentId, projectId: current.id },
301 });
302 expect(entries.map((entry) => entry.group.displayName)).toEqual(["current", "pinned", "other"]);
303 });
304});