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 filterProjectScopes,
8 getProjectScopeSelectionTarget,
9 resolveDraftProjectSelection,
10 resolveEnvironmentProjectMatch,
11} from "./new-task-project-selection";
12
13function makeProject(
14 id: string,
15 environmentId = "environment",
16 options: {
17 readonly title?: string;
18 readonly workspaceRoot?: string;
19 readonly repositoryKey?: string;
20 } = {},
21): EnvironmentProject {
22 return {
23 environmentId: EnvironmentId.make(environmentId),
24 id: ProjectId.make(id),
25 title: options.title ?? id,
26 workspaceRoot: options.workspaceRoot ?? `/work/${id}`,
27 repositoryIdentity: options.repositoryKey
28 ? {
29 canonicalKey: options.repositoryKey,
30 locator: {
31 source: "git-remote",
32 remoteName: "origin",
33 remoteUrl: `https://${options.repositoryKey}.git`,
34 },
35 }
36 : null,
37 defaultModelSelection: null,
38 scripts: [],
39 createdAt: "2026-07-01T00:00:00.000Z",
40 updatedAt: "2026-07-01T00:00:00.000Z",
41 };
42}
43
44function makeScope(projects: ReadonlyArray<EnvironmentProject>): HomeProjectScope {
45 return {
46 key: "github.com/t3tools/t3code",
47 title: "T3 Code",
48 representative: projects[0]!,
49 projects,
50 projectRefs: projects.map((project) => ({
51 environmentId: project.environmentId,
52 projectId: project.id,
53 })),
54 };
55}
56
57describe("getProjectScopeSelectionTarget", () => {
58 it("keeps the current environment when it hosts the selected logical project", () => {
59 const projects = [makeProject("t3code-mac", "mac"), makeProject("t3code-server", "server")];
60 expect(getProjectScopeSelectionTarget(makeScope(projects), EnvironmentId.make("server"))).toBe(
61 projects[1],
62 );
63 });
64
65 it("falls back to the representative when the current environment does not host the project", () => {
66 const projects = [makeProject("t3code-mac", "mac"), makeProject("t3code-server", "server")];
67 expect(getProjectScopeSelectionTarget(makeScope(projects), EnvironmentId.make("other"))).toBe(
68 projects[0],
69 );
70 });
71});
72
73describe("resolveEnvironmentProjectMatch", () => {
74 it("follows the same repository onto the target machine", () => {
75 const selected = makeProject("t3code", "mac", { repositoryKey: "github.com/t3tools/t3code" });
76 const target = [
77 makeProject("other", "server", { repositoryKey: "github.com/t3tools/other" }),
78 makeProject("t3code-clone", "server", { repositoryKey: "github.com/t3tools/t3code" }),
79 ];
80 expect(resolveEnvironmentProjectMatch(target, selected)).toBe(target[1]);
81 });
82
83 it("falls back to workspace basename, then title, for unindexed projects", () => {
84 const selected = makeProject("t3code", "mac", { workspaceRoot: "/Users/me/t3code" });
85 const byBasename = [
86 makeProject("other", "server"),
87 makeProject("srv", "server", { workspaceRoot: "/home/me/t3code" }),
88 ];
89 expect(resolveEnvironmentProjectMatch(byBasename, selected)).toBe(byBasename[1]);
90
91 const byTitle = [
92 makeProject("other", "server"),
93 makeProject("srv", "server", { title: "t3code" }),
94 ];
95 expect(resolveEnvironmentProjectMatch(byTitle, selected)).toBe(byTitle[1]);
96 });
97
98 it("does not treat a known different repository as a basename or title match", () => {
99 const selected = makeProject("t3code", "mac", {
100 repositoryKey: "github.com/t3tools/t3code",
101 workspaceRoot: "/Users/me/t3code",
102 });
103 const fork = makeProject("fork", "server", {
104 repositoryKey: "github.com/someone/t3code",
105 title: "t3code",
106 workspaceRoot: "/home/me/t3code",
107 });
108 const unindexed = makeProject("unindexed", "server", { workspaceRoot: "/srv/t3code" });
109 expect(resolveEnvironmentProjectMatch([fork, unindexed], selected)).toBe(unindexed);
110 // Without any weaker match the fork is still the first-project fallback.
111 expect(resolveEnvironmentProjectMatch([fork], selected)).toBe(fork);
112 });
113
114 it("falls back to the first project on the target so the draft has a key to carry over to", () => {
115 const selected = makeProject("t3code", "mac", { repositoryKey: "github.com/t3tools/t3code" });
116 const target = [makeProject("unrelated", "server"), makeProject("also-unrelated", "server")];
117 expect(resolveEnvironmentProjectMatch(target, selected)).toBe(target[0]);
118 expect(resolveEnvironmentProjectMatch([], selected)).toBeNull();
119 });
120});
121
122describe("resolveDraftProjectSelection", () => {
123 it("preserves an explicit project selection", () => {
124 const project = makeProject("t3code");
125 expect(
126 resolveDraftProjectSelection("environment:t3code", [project], [makeScope([project])]),
127 ).toEqual({ kind: "preserve" });
128 });
129
130 it("selects the only physical project when no project was explicitly selected", () => {
131 const project = makeProject("t3code");
132 expect(resolveDraftProjectSelection(null, [project], [makeScope([project])])).toEqual({
133 kind: "select",
134 project,
135 });
136 });
137
138 it("selects one logical project even when it has multiple physical workspaces", () => {
139 const projects = [makeProject("t3code"), makeProject("t3code-2"), makeProject("t3code-3")];
140 expect(resolveDraftProjectSelection(null, projects, [makeScope(projects)])).toEqual({
141 kind: "select",
142 project: projects[0],
143 });
144 });
145
146 it("does not preserve a project key that is missing from the catalog", () => {
147 const project = makeProject("t3code");
148 expect(
149 resolveDraftProjectSelection("environment:removed", [project], [makeScope([project])]),
150 ).toEqual({
151 kind: "select",
152 project,
153 });
154 });
155});
156
157describe("filterProjectScopes", () => {
158 const mac = makeProject("code", "mac", { title: "Desktop checkout" });
159 const server = makeProject("remote-code", "server", { workspaceRoot: "/srv/remote-workspace" });
160 const code = makeScope([mac, server]);
161 const docs = { ...makeScope([makeProject("docs")]), key: "docs", title: "Documentation" };
162 const scopes = [code, docs];
163
164 it("keeps all projects for an empty or whitespace-only query", () => {
165 expect(filterProjectScopes(scopes, "")).toBe(scopes);
166 expect(filterProjectScopes(scopes, " ")).toBe(scopes);
167 });
168
169 it("matches logical names and workspace names or paths without case sensitivity", () => {
170 expect(filterProjectScopes(scopes, " T3 CODE ")).toEqual([code]);
171 expect(filterProjectScopes(scopes, "DESKTOP")).toEqual([code]);
172 expect(filterProjectScopes(scopes, "REMOTE-WORKSPACE")).toEqual([code]);
173 expect(filterProjectScopes(scopes, "documentation")).toEqual([docs]);
174 expect(filterProjectScopes(scopes, "missing-project")).toEqual([]);
175 });
176
177 it("preserves the whole logical project and preferred environment when a workspace matches", () => {
178 const matches = filterProjectScopes(scopes, "REMOTE-WORKSPACE");
179 expect(matches[0]).toBe(code);
180 expect(getProjectScopeSelectionTarget(matches[0]!, EnvironmentId.make("mac"))).toBe(mac);
181 expect(code.projects).toEqual([mac, server]);
182 });
183});