Skip to content
Source
Sign inSign up
Open History to see the last commit

new-task-project-selection.test.ts

401 lines14.0 KB

1import { EnvironmentId, ProjectId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3​
4import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
5import type { HomeProjectScope } from "../home/homeThreadList";
6import {
7 getProjectScopeSelectionTarget,
8 isSameRepository,
9 newTaskDestination,
10 newTaskFilteredProject,
11 resolveDraftProjectSelection,
12 resolveEnvironmentProjectMatch,
13 resolveNewTaskScratchOption,
14} from "./new-task-project-selection";
15​
16function makeProject(
17 id: string,
18 environmentId = "environment",
19 options: {
20 readonly title?: string;
21 readonly workspaceRoot?: string;
22 readonly repositoryKey?: string;
23 } = {},
24): EnvironmentProject {
25 return {
26 environmentId: EnvironmentId.make(environmentId),
27 id: ProjectId.make(id),
28 title: options.title ?? id,
29 workspaceRoot: options.workspaceRoot ?? `/work/${id}`,
30 repositoryIdentity: options.repositoryKey
31 ? {
32 canonicalKey: options.repositoryKey,
33 locator: {
34 source: "git-remote",
35 remoteName: "origin",
36 remoteUrl: `https://${options.repositoryKey}.git`,
37 },
38 }
39 : null,
40 defaultModelSelection: null,
41 scripts: [],
42 createdAt: "2026-07-01T00:00:00.000Z",
43 updatedAt: "2026-07-01T00:00:00.000Z",
44 };
46​
47function makeScope(projects: ReadonlyArray<EnvironmentProject>): HomeProjectScope {
48 return {
49 key: "github.com/t3tools/t3code",
50 title: "T3 Code",
51 representative: projects[0]!,
52 projects,
53 projectRefs: projects.map((project) => ({
54 environmentId: project.environmentId,
55 projectId: project.id,
56 })),
57 pinned: false,
58 archived: false,
59 };
61​
62describe("getProjectScopeSelectionTarget", () => {
63 it("prefers a connected copy of a project that spans environments", () => {
64 const mac = makeProject("t3code", "mac");
65 const server = makeProject("t3code", "server");
66 const target = (connected: ReadonlyArray<string>, preferred: string | null = null) =>
67 getProjectScopeSelectionTarget(
68 makeScope([mac, server]),
69 preferred === null ? null : EnvironmentId.make(preferred),
70 new Set(connected.map((id) => EnvironmentId.make(id))),
71 );
72 expect(target(["server"])).toBe(server);
73 expect(target(["server"], "mac")).toBe(server);
74 expect(target(["mac", "server"], "server")).toBe(server);
75 expect(target(["mac", "server"])).toBe(mac);
76 // With nothing connected, the usual order still applies.
77 expect(target([], "server")).toBe(server);
78 expect(target([])).toBe(mac);
79 });
80});
81​
82describe("newTaskFilteredProject", () => {
83 it("never starts in an archived copy of the filtered project", () => {
84 const mac = { ...makeProject("t3code", "mac"), archivedAt: "2026-01-01T00:00:00.000Z" };
85 const server = makeProject("t3code", "server");
86 expect(
87 newTaskFilteredProject(
88 makeScope([mac, server]),
89 null,
90 new Set([mac.environmentId, server.environmentId]),
91 { environmentId: "mac", projectId: "t3code" },
92 ),
93 ).toEqual({ environmentId: "server", projectId: "t3code" });
94 });
95​
96 it("prefers the remembered copy of the filtered project", () => {
97 const mac = makeProject("t3code", "mac");
98 const server = makeProject("t3code", "server");
99 const connected = new Set([mac.environmentId, server.environmentId]);
100 const filtered = (last?: { readonly environmentId: string; readonly projectId: string }) =>
101 newTaskFilteredProject(makeScope([mac, server]), null, connected, last);
102 expect(filtered({ environmentId: "server", projectId: "t3code" })).toEqual({
103 environmentId: "server",
104 projectId: "t3code",
105 });
106 expect(filtered({ environmentId: "server", projectId: "other" })).toEqual({
107 environmentId: "mac",
108 projectId: "t3code",
109 });
110 });
111});
112​
113describe("resolveEnvironmentProjectMatch", () => {
114 it("follows the same repository onto the target machine", () => {
115 const selected = makeProject("t3code", "mac", { repositoryKey: "github.com/t3tools/t3code" });
116 const target = [
117 makeProject("other", "server", { repositoryKey: "github.com/t3tools/other" }),
118 makeProject("t3code-clone", "server", { repositoryKey: "github.com/t3tools/t3code" }),
119 ];
120 expect(resolveEnvironmentProjectMatch(target, selected)).toBe(target[1]);
121 expect(isSameRepository(target[1]!, selected)).toBe(true);
122 });
123​
124 it("falls back to workspace basename, then title, for unindexed projects", () => {
125 const selected = makeProject("t3code", "mac", { workspaceRoot: "/Users/me/t3code" });
126 const byBasename = [
127 makeProject("other", "server"),
128 makeProject("srv", "server", { workspaceRoot: "/home/me/t3code" }),
129 ];
130 expect(resolveEnvironmentProjectMatch(byBasename, selected)).toBe(byBasename[1]);
131 expect(isSameRepository(byBasename[1]!, selected)).toBe(false);
132​
133 const byTitle = [
134 makeProject("other", "server"),
135 makeProject("srv", "server", { title: "t3code" }),
136 ];
137 expect(resolveEnvironmentProjectMatch(byTitle, selected)).toBe(byTitle[1]);
138 });
139​
140 it("does not treat a known different repository as a basename or title match", () => {
141 const selected = makeProject("t3code", "mac", {
142 repositoryKey: "github.com/t3tools/t3code",
143 workspaceRoot: "/Users/me/t3code",
144 });
145 const fork = makeProject("fork", "server", {
146 repositoryKey: "github.com/someone/t3code",
147 title: "t3code",
148 workspaceRoot: "/home/me/t3code",
149 });
150 const unindexed = makeProject("unindexed", "server", { workspaceRoot: "/srv/t3code" });
151 expect(resolveEnvironmentProjectMatch([fork, unindexed], selected)).toBe(unindexed);
152 expect(resolveEnvironmentProjectMatch([fork], selected)).toBe(fork);
153 expect(isSameRepository(fork, selected)).toBe(false);
154 });
155});
156​
157describe("resolveDraftProjectSelection", () => {
158 const projects = [makeProject("t3code"), makeProject("t3code-2")];
159 const draft = { incomingShareId: undefined, pickerClosed: false };
160​
161 it("opens the picker instead of auto-selecting the only project", () => {
162 expect(resolveDraftProjectSelection(null, projects, [makeScope(projects)], draft)).toEqual({
163 kind: "pick",
164 });
165 });
166​
167 it("selects the only logical project for shared content", () => {
168 expect(
169 resolveDraftProjectSelection(null, projects, [makeScope(projects)], {
170 ...draft,
171 incomingShareId: "share",
172 }),
173 ).toEqual({ kind: "select", project: projects[0] });
174 });
175​
176 it("dismisses the draft when the first picker closes without a choice", () => {
177 expect(
178 resolveDraftProjectSelection(null, projects, [makeScope(projects)], {
179 ...draft,
180 pickerClosed: true,
181 }),
182 ).toEqual({ kind: "dismiss" });
183 });
184​
185 it("keeps the draft when the picker closes after a choice", () => {
186 expect(
187 resolveDraftProjectSelection("environment:t3code-2", projects, [makeScope(projects)], {
188 ...draft,
189 pickerClosed: true,
190 }),
191 ).toEqual({ kind: "preserve" });
192 });
193​
194 it("keeps a share draft asking for a project when its picker closes without a choice", () => {
195 const scopes = [makeScope([projects[0]!]), makeScope([projects[1]!])];
196 expect(
197 resolveDraftProjectSelection(null, projects, scopes, {
198 incomingShareId: "share",
199 pickerClosed: true,
200 }),
201 ).toEqual({ kind: "pick" });
202 });
203});
204​
205describe("newTaskDestination", () => {
206 const picker = { draft: { requestId: "request" } };
207​
208 function destination(options: {
209 readonly useLastProject: boolean | undefined;
210 readonly projects: ReadonlyArray<EnvironmentProject>;
211 readonly current?: EnvironmentProject;
212 readonly filtered?: EnvironmentProject;
213 readonly last?: { readonly environmentId: string; readonly projectId: string };
214 }) {
215 return newTaskDestination({
216 projects: options.projects,
217 requestId: "request",
218 useLastProject: options.useLastProject,
219 currentProject: options.current && {
220 environmentId: options.current.environmentId,
221 projectId: options.current.id,
222 },
223 filteredProject: options.filtered && {
224 environmentId: options.filtered.environmentId,
225 projectId: options.filtered.id,
226 },
227 lastProject: options.last,
228 });
229 }
230​
231 it("starts in the current thread's project ahead of the filtered and remembered ones", () => {
232 const projects = [makeProject("remembered"), makeProject("current", "other-environment")];
233 expect(
234 destination({
235 useLastProject: true,
236 projects,
237 current: projects[1],
238 filtered: projects[0],
239 last: { environmentId: "environment", projectId: "remembered" },
240 }),
241 ).toEqual({
242 draft: { requestId: "request", environmentId: "other-environment", projectId: "current" },
243 });
244 });
245​
246 it("starts in the thread list's filtered project ahead of the remembered one", () => {
247 const projects = [makeProject("bravo"), makeProject("alpha")];
248 expect(
249 destination({
250 useLastProject: true,
251 projects,
252 filtered: projects[1],
253 last: { environmentId: "environment", projectId: "bravo" },
254 }),
255 ).toEqual({
256 draft: { requestId: "request", environmentId: "environment", projectId: "alpha" },
257 });
258 });
259​
260 it("opens the picker for an archived last project, but keeps an archived current one", () => {
261 const archived = { ...makeProject("archived"), archivedAt: "2026-01-01T00:00:00.000Z" };
262 expect(
263 destination({
264 useLastProject: true,
265 projects: [archived],
266 last: { environmentId: "environment", projectId: "archived" },
267 }),
268 ).toEqual(picker);
269 expect(destination({ useLastProject: true, projects: [archived], current: archived })).toEqual({
270 draft: { requestId: "request", environmentId: "environment", projectId: "archived" },
271 });
272 });
273​
274 it("treats an unset preference as on", () => {
275 const projects = [makeProject("first"), makeProject("current")];
276 expect(destination({ useLastProject: undefined, projects, current: projects[1] })).toEqual({
277 draft: { requestId: "request", environmentId: "environment", projectId: "current" },
278 });
279 });
280​
281 it("starts in the remembered project of its own environment when there is no current one", () => {
282 const projects = [makeProject("chosen"), makeProject("chosen", "other-environment")];
283 expect(
284 destination({
285 useLastProject: true,
286 projects,
287 last: { environmentId: "other-environment", projectId: "chosen" },
288 }),
289 ).toEqual({
290 draft: { requestId: "request", environmentId: "other-environment", projectId: "chosen" },
291 });
292 });
293​
294 it("ignores a remembered project id from another environment", () => {
295 expect(
296 destination({
297 useLastProject: true,
298 projects: [makeProject("chosen")],
299 last: { environmentId: "removed-environment", projectId: "chosen" },
300 }),
301 ).toEqual(picker);
302 });
303​
304 it("opens the picker rather than the first project when nothing matches", () => {
305 expect(
306 destination({
307 useLastProject: true,
308 projects: [makeProject("first"), makeProject("second")],
309 current: makeProject("removed"),
310 }),
311 ).toEqual(picker);
312 });
313​
314 it("always opens the picker when the preference is off", () => {
315 const only = makeProject("only");
316 expect(
317 destination({
318 useLastProject: false,
319 projects: [only],
320 current: only,
321 filtered: only,
322 last: { environmentId: "environment", projectId: "only" },
323 }),
324 ).toEqual(picker);
325 });
326});
327​
328describe("resolveNewTaskScratchOption", () => {
329 const environmentId = EnvironmentId.make("environment");
330 const otherId = EnvironmentId.make("other");
331 const configs = new Map([[environmentId, { scratchWorkspaceRoot: "/scratch" }]]);
332​
333 it("offers the option from the cached config while the environment is still connecting", () => {
334 expect(
335 resolveNewTaskScratchOption({
336 environments: [{ environmentId, connectionState: "connecting" }],
337 serverConfigs: configs,
338 selectedEnvironmentId: null,
339 }),
340 ).toEqual({ environmentId, ready: false });
341 });
342​
343 it("becomes ready without changing environment once connected", () => {
344 expect(
345 resolveNewTaskScratchOption({
346 environments: [{ environmentId, connectionState: "connected" }],
347 serverConfigs: configs,
348 selectedEnvironmentId: null,
349 }),
350 ).toEqual({ environmentId, ready: true });
351 });
352​
353 it("hides the option when no environment reports a scratch root", () => {
354 expect(
355 resolveNewTaskScratchOption({
356 environments: [{ environmentId, connectionState: "connected" }],
357 serverConfigs: new Map([[environmentId, {}]]),
358 selectedEnvironmentId: null,
359 }),
360 ).toBeNull();
361 expect(
362 resolveNewTaskScratchOption({
363 environments: [{ environmentId, connectionState: "connected" }],
364 serverConfigs: new Map(),
365 selectedEnvironmentId: null,
366 }),
367 ).toBeNull();
368 });
369​
370 it("prefers a connected environment over the selected one that is still reconnecting", () => {
371 expect(
372 resolveNewTaskScratchOption({
373 environments: [
374 { environmentId, connectionState: "reconnecting" },
375 { environmentId: otherId, connectionState: "connected" },
376 ],
377 serverConfigs: new Map([
378 [environmentId, { scratchWorkspaceRoot: "/scratch" }],
379 [otherId, { scratchWorkspaceRoot: "/other-scratch" }],
380 ]),
381 selectedEnvironmentId: environmentId,
382 }),
383 ).toEqual({ environmentId: otherId, ready: true });
384 });
385​
386 it("keeps the selected environment when every offering environment is connected", () => {
387 expect(
388 resolveNewTaskScratchOption({
389 environments: [
390 { environmentId: otherId, connectionState: "connected" },
391 { environmentId, connectionState: "connected" },
392 ],
393 serverConfigs: new Map([
394 [environmentId, { scratchWorkspaceRoot: "/scratch" }],
395 [otherId, { scratchWorkspaceRoot: "/other-scratch" }],
396 ]),
397 selectedEnvironmentId: environmentId,
398 }),
399 ).toEqual({ environmentId, ready: true });
400 });
401});