Skip to content
Source
Sign inSign up

uiStateStore.test.ts

297 lines9.8 KB

1import { ThreadId } from "@t3tools/contracts";
2import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
3​
4import {
5 legacyProjectCwdPreferenceKey,
6 markThreadUnread,
7 markThreadVisited,
8 parsePersistedState,
9 PERSISTED_STATE_KEY,
10 type PersistedUiState,
11 persistState,
12 reorderProjects,
13 resolveProjectExpanded,
14 setProjectExpanded,
15 type UiState,
16} from "./uiStateStore";
17​
18function makeUiState(overrides: Partial<UiState> = {}): UiState {
19 return {
20 projectExpandedById: {},
21 projectOrder: [],
22 sidebarProjectScopeKeys: [],
23 threadLastVisitedAtById: {},
24 threadChangedFilesExpandedById: {},
25 defaultAdvertisedEndpointKey: null,
26 pullRequestMergeMethod: "merge",
27 ...overrides,
28 };
30​
31describe("uiStateStore pure functions", () => {
32 it("stores server timestamps without moving visit state backwards", () => {
33 const threadId = ThreadId.make("thread-1");
34 const initialState = makeUiState();
35 const visited = markThreadVisited(initialState, threadId, "2026-02-25T12:30:00.700Z");
36​
37 expect(visited.threadLastVisitedAtById[threadId]).toBe("2026-02-25T12:30:00.700Z");
38 expect(markThreadVisited(visited, threadId, "2026-02-25T12:30:00.000Z")).toBe(visited);
39 expect(markThreadVisited(visited, threadId, "not-a-date")).toBe(visited);
40 });
41​
42 it("marks a completed thread unread using the server completion timestamp", () => {
43 const threadId = ThreadId.make("thread-1");
44 const initialState = makeUiState({
45 threadLastVisitedAtById: {
46 [threadId]: "2026-02-25T12:35:00.000Z",
47 },
48 });
49​
50 const next = markThreadUnread(initialState, threadId, "2026-02-25T12:30:00.000Z");
51​
52 expect(next.threadLastVisitedAtById[threadId]).toBe("2026-02-25T12:29:59.999Z");
53 expect(markThreadUnread(next, threadId, null)).toBe(next);
54 });
55​
56 it("resolves project expansion from logical, physical, and legacy preference keys", () => {
57 const physicalKey = "environment:/repo/project";
58 const legacyKey = legacyProjectCwdPreferenceKey("/repo/project");
59​
60 expect(resolveProjectExpanded({ logical: false, [physicalKey]: true }, ["logical"])).toBe(
61 false,
62 );
63 expect(resolveProjectExpanded({ [physicalKey]: false }, ["new-logical", physicalKey])).toBe(
64 false,
65 );
66 expect(resolveProjectExpanded({ [legacyKey]: false }, ["new-logical", legacyKey])).toBe(false);
67 expect(resolveProjectExpanded({}, ["new-logical"])).toBe(true);
68 });
69​
70 it("sets expansion for every stable key belonging to a logical project", () => {
71 const initialState = makeUiState();
72 const keys = ["logical", "environment-a:/repo", "environment-b:/repo"];
73​
74 const next = setProjectExpanded(initialState, keys, false);
75​
76 expect(next.projectExpandedById).toEqual({
77 logical: false,
78 "environment-a:/repo": false,
79 "environment-b:/repo": false,
80 });
81 expect(setProjectExpanded(next, keys, false)).toBe(next);
82 });
83​
84 it("moves grouped project members together", () => {
85 const keyALocal = "env-local:proj-a";
86 const keyARemote = "env-remote:proj-a";
87 const keyB = "env-local:proj-b";
88 const keyC = "env-local:proj-c";
89 const currentOrder = [keyALocal, keyARemote, keyB, keyC];
90​
91 const next = reorderProjects(makeUiState(), currentOrder, [keyALocal, keyARemote], [keyC]);
92​
93 expect(next.projectOrder).toEqual([keyB, keyC, keyALocal, keyARemote]);
94 });
95});
96​
97describe("parsePersistedState", () => {
98 it("hydrates raw UI-owned state without server entities", () => {
99 const parsed = parsePersistedState({
100 projectExpandedById: {
101 logical: false,
102 invalid: "no" as unknown as boolean,
103 },
104 projectOrder: ["physical-b", "", "physical-a", "physical-b"],
105 threadLastVisitedAtById: {
106 "environment:thread-1": "2026-02-25T12:35:00.000Z",
107 invalid: "not-a-date",
108 },
109 defaultAdvertisedEndpointKey: "desktop-core:lan:http",
110 threadChangedFilesExpansionVersion: 2,
111 threadChangedFilesExpandedById: {
112 "environment:thread-1": {
113 "turn-1": false,
114 "turn-2": true,
115 },
116 },
117 });
118​
119 expect(parsed).toEqual({
120 projectExpandedById: {
121 logical: false,
122 },
123 projectOrder: ["physical-b", "physical-a"],
124 threadLastVisitedAtById: {
125 "environment:thread-1": "2026-02-25T12:35:00.000Z",
126 },
127 defaultAdvertisedEndpointKey: "desktop-core:lan:http",
128 sidebarProjectScopeKeys: [],
129 pullRequestMergeMethod: "merge",
130 threadChangedFilesExpandedById: {
131 "environment:thread-1": {
132 "turn-1": false,
133 "turn-2": true,
134 },
135 },
136 });
137 });
138​
139 it("keeps a persisted fast-forward merge method", () => {
140 expect(
141 parsePersistedState({ pullRequestMergeMethod: "fast-forward" }).pullRequestMergeMethod,
142 ).toBe("fast-forward");
143 });
144​
145 it.each([undefined, 1])("ignores changed-file expansion version %s", (version) => {
146 const parsed = parsePersistedState({
147 ...(version === undefined ? {} : { threadChangedFilesExpansionVersion: version }),
148 threadChangedFilesExpandedById: {
149 "environment:thread-1": {
150 "turn-1": false,
151 },
152 },
153 });
154​
155 expect(parsed.threadChangedFilesExpandedById).toEqual({});
156 });
157​
158 it("migrates legacy CWD project preferences into local alias keys", () => {
159 const parsed = parsePersistedState({
160 collapsedProjectCwds: ["/repo/b"],
161 expandedProjectCwds: ["/repo/a"],
162 projectOrderCwds: ["/repo/b", "/repo/a"],
163 });
164 const projectAKey = legacyProjectCwdPreferenceKey("/repo/a");
165 const projectBKey = legacyProjectCwdPreferenceKey("/repo/b");
166​
167 expect(parsed.projectOrder).toEqual([projectBKey, projectAKey]);
168 expect(resolveProjectExpanded(parsed.projectExpandedById, [projectAKey])).toBe(true);
169 expect(resolveProjectExpanded(parsed.projectExpandedById, [projectBKey])).toBe(false);
170 expect(resolveProjectExpanded(parsed.projectExpandedById, ["unknown"])).toBe(true);
171 });
172});
173​
174function createLocalStorageStub(): Storage {
175 const store = new Map<string, string>();
176 return {
177 clear: () => {
178 store.clear();
179 },
180 getItem: (key) => store.get(key) ?? null,
181 key: (index) => [...store.keys()][index] ?? null,
182 get length() {
183 return store.size;
184 },
185 removeItem: (key) => {
186 store.delete(key);
187 },
188 setItem: (key, value) => {
189 store.set(key, value);
190 },
191 };
193​
194describe("uiStateStore persistence", () => {
195 let localStorageStub: Storage;
196​
197 beforeEach(() => {
198 localStorageStub = createLocalStorageStub();
199 vi.stubGlobal("window", { localStorage: localStorageStub });
200 vi.stubGlobal("localStorage", localStorageStub);
201 });
202​
203 afterEach(() => {
204 vi.unstubAllGlobals();
205 });
206​
207 it("persists raw UI preferences including thread visit markers", () => {
208 const state = makeUiState({
209 projectExpandedById: {
210 logical: false,
211 },
212 projectOrder: ["physical-b", "physical-a"],
213 threadLastVisitedAtById: {
214 "environment:thread-1": "2026-02-25T12:35:00.000Z",
215 },
216 threadChangedFilesExpandedById: {
217 "environment:thread-1": {
218 "turn-1": false,
219 "turn-2": true,
220 },
221 },
222 defaultAdvertisedEndpointKey: "desktop-core:lan:http",
223 });
224​
225 persistState(state);
226​
227 const persisted = JSON.parse(
228 localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}",
229 ) as PersistedUiState;
230 expect(persisted).toEqual({
231 projectExpandedById: {
232 logical: false,
233 },
234 projectOrder: ["physical-b", "physical-a"],
235 threadLastVisitedAtById: {
236 "environment:thread-1": "2026-02-25T12:35:00.000Z",
237 },
238 defaultAdvertisedEndpointKey: "desktop-core:lan:http",
239 sidebarProjectScopeKeys: [],
240 sidebarProjectScopeKey: null,
241 threadChangedFilesExpansionVersion: 2,
242 threadChangedFilesExpandedById: {
243 "environment:thread-1": {
244 "turn-1": false,
245 "turn-2": true,
246 },
247 },
248 pullRequestMergeMethod: "merge",
249 });
250 expect(parsePersistedState(persisted)).toEqual({
251 ...state,
252 });
253 });
254​
255 it("restores the sidebar project scope across reloads", () => {
256 const keys = ["github.com/pingdotgg/t3code", "github.com/pingdotgg/uploadthing"];
257 persistState(makeUiState({ sidebarProjectScopeKeys: keys }));
258​
259 const persisted = JSON.parse(
260 localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}",
261 ) as PersistedUiState;
262​
263 expect(parsePersistedState(persisted).sidebarProjectScopeKeys).toEqual(keys);
264 });
265​
266 it("keeps a single-project scope readable by older clients", () => {
267 persistState(makeUiState({ sidebarProjectScopeKeys: ["github.com/pingdotgg/t3code"] }));
268​
269 const persisted = JSON.parse(
270 localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}",
271 ) as PersistedUiState;
272​
273 expect(persisted.sidebarProjectScopeKey).toBe("github.com/pingdotgg/t3code");
274 });
275​
276 it("keeps legacy projects omitted from the expanded list collapsed across a save and reload", () => {
277 const projectAKey = legacyProjectCwdPreferenceKey("/repo/a");
278 const projectBKey = legacyProjectCwdPreferenceKey("/repo/b");
279 const migrated = parsePersistedState({ expandedProjectCwds: ["/repo/a"] });
280 expect(resolveProjectExpanded(migrated.projectExpandedById, [projectBKey])).toBe(false);
281​
282 persistState({ ...migrated, pullRequestMergeMethod: "squash" });
283 const reloaded = parsePersistedState(
284 JSON.parse(localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}") as PersistedUiState,
285 );
286​
287 expect(resolveProjectExpanded(reloaded.projectExpandedById, [projectAKey])).toBe(true);
288 expect(resolveProjectExpanded(reloaded.projectExpandedById, [projectBKey])).toBe(false);
289 });
290​
291 it("restores a single-project scope saved before multi-project filtering", () => {
292 expect(
293 parsePersistedState({ sidebarProjectScopeKey: "github.com/pingdotgg/t3code" })
294 .sidebarProjectScopeKeys,
295 ).toEqual(["github.com/pingdotgg/t3code"]);
296 });
297});