1import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment";
2import { ThreadId } from "@t3tools/contracts";
3import { beforeEach, describe, expect, it } from "vite-plus/test";
4
5import {
6 migratePersistedTerminalUiStateStoreState,
7 selectThreadTerminalUiState,
8 useTerminalUiStateStore,
9} from "./terminalUiStateStore";
10import { DEFAULT_THREAD_TERMINAL_ID } from "./types";
11
12const THREAD_ID = ThreadId.make("thread-1");
13const THREAD_REF = scopeThreadRef("environment-a" as never, THREAD_ID);
14const OTHER_THREAD_REF = scopeThreadRef("environment-b" as never, THREAD_ID);
15
16describe("terminalUiStateStore actions", () => {
17 beforeEach(() => {
18 useTerminalUiStateStore.persist.clearStorage();
19 useTerminalUiStateStore.setState({
20 terminalUiStateByThreadKey: {},
21 suppressedTerminalIdsByThreadKey: {},
22 });
23 });
24
25 it("returns an empty default terminal UI state for unknown threads", () => {
26 const terminalUiState = selectThreadTerminalUiState(
27 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
28 THREAD_REF,
29 );
30 expect(terminalUiState).toEqual({
31 terminalOpen: false,
32 terminalHeight: 280,
33 terminalIds: [],
34 activeTerminalId: "",
35 terminalGroups: [],
36 activeTerminalGroupId: "",
37 });
38 });
39
40 it("opens and splits terminals into the active group", () => {
41 const store = useTerminalUiStateStore.getState();
42 store.setTerminalOpen(THREAD_REF, true);
43 store.splitTerminal(THREAD_REF, "terminal-2");
44
45 const terminalUiState = selectThreadTerminalUiState(
46 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
47 THREAD_REF,
48 );
49 expect(terminalUiState.terminalOpen).toBe(true);
50 expect(terminalUiState.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "terminal-2"]);
51 expect(terminalUiState.activeTerminalId).toBe("terminal-2");
52 expect(terminalUiState.terminalGroups).toEqual([
53 {
54 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
55 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "terminal-2"],
56 },
57 ]);
58 });
59
60 it("stacks vertically split terminals in the active group", () => {
61 const store = useTerminalUiStateStore.getState();
62 store.setTerminalOpen(THREAD_REF, true);
63 store.splitTerminalVertical(THREAD_REF, "terminal-2");
64
65 const terminalUiState = selectThreadTerminalUiState(
66 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
67 THREAD_REF,
68 );
69 expect(terminalUiState.terminalGroups).toEqual([
70 {
71 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
72 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "terminal-2"],
73 splitDirection: "vertical",
74 },
75 ]);
76 });
77
78 it("materializes the default terminal when opening an empty drawer", () => {
79 useTerminalUiStateStore.getState().setTerminalOpen(THREAD_REF, true);
80
81 const terminalUiState = selectThreadTerminalUiState(
82 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
83 THREAD_REF,
84 );
85 expect(terminalUiState).toEqual({
86 terminalOpen: true,
87 terminalHeight: 280,
88 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
89 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
90 terminalGroups: [
91 {
92 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
93 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
94 },
95 ],
96 activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
97 });
98 });
99
100 it("caps splits at four terminals per group", () => {
101 const store = useTerminalUiStateStore.getState();
102 store.splitTerminal(THREAD_REF, "terminal-2");
103 store.splitTerminal(THREAD_REF, "terminal-3");
104 store.splitTerminal(THREAD_REF, "terminal-4");
105 store.splitTerminal(THREAD_REF, "terminal-5");
106 store.splitTerminal(THREAD_REF, "terminal-6");
107
108 const terminalUiState = selectThreadTerminalUiState(
109 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
110 THREAD_REF,
111 );
112 expect(terminalUiState.terminalIds).toEqual([
113 "terminal-2",
114 "terminal-3",
115 "terminal-4",
116 "terminal-5",
117 ]);
118 expect(terminalUiState.terminalGroups).toEqual([
119 {
120 id: "group-terminal-2",
121 terminalIds: ["terminal-2", "terminal-3", "terminal-4", "terminal-5"],
122 },
123 ]);
124 });
125
126 it("creates new terminals in a separate group", () => {
127 useTerminalUiStateStore.getState().newTerminal(THREAD_REF, "terminal-2");
128
129 const terminalUiState = selectThreadTerminalUiState(
130 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
131 THREAD_REF,
132 );
133 expect(terminalUiState.terminalIds).toEqual(["terminal-2"]);
134 expect(terminalUiState.activeTerminalId).toBe("terminal-2");
135 expect(terminalUiState.activeTerminalGroupId).toBe("group-terminal-2");
136 expect(terminalUiState.terminalGroups).toEqual([
137 { id: "group-terminal-2", terminalIds: ["terminal-2"] },
138 ]);
139 });
140
141 it("ensures unknown server terminals are registered, opened, and activated", () => {
142 const store = useTerminalUiStateStore.getState();
143 store.ensureTerminal(THREAD_REF, "setup-setup", { open: true, active: true });
144
145 const terminalUiState = selectThreadTerminalUiState(
146 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
147 THREAD_REF,
148 );
149 expect(terminalUiState.terminalOpen).toBe(true);
150 expect(terminalUiState.terminalIds).toEqual(["setup-setup"]);
151 expect(terminalUiState.activeTerminalId).toBe("setup-setup");
152 expect(terminalUiState.terminalGroups).toEqual([
153 { id: "group-setup-setup", terminalIds: ["setup-setup"] },
154 ]);
155 });
156
157 it("keeps state isolated per environment when raw thread ids collide", () => {
158 const store = useTerminalUiStateStore.getState();
159 store.setTerminalOpen(THREAD_REF, true);
160 store.newTerminal(OTHER_THREAD_REF, "env-b-terminal");
161
162 expect(
163 selectThreadTerminalUiState(
164 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
165 THREAD_REF,
166 ).terminalOpen,
167 ).toBe(true);
168 expect(
169 selectThreadTerminalUiState(
170 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
171 OTHER_THREAD_REF,
172 ).terminalIds,
173 ).toEqual(["env-b-terminal"]);
174 });
175
176 it("drops persisted entries whose thread keys are not valid scoped keys", () => {
177 const migrated = migratePersistedTerminalUiStateStoreState(
178 {
179 terminalStateByThreadKey: {
180 [scopedThreadKey(THREAD_REF)]: {
181 terminalOpen: true,
182 terminalHeight: 320,
183 terminalIds: ["term-1"],
184 activeTerminalId: "term-1",
185 terminalGroups: [{ id: "group-term-1", terminalIds: ["term-1"] }],
186 activeTerminalGroupId: "group-term-1",
187 },
188 "legacy-thread-id": {
189 terminalOpen: true,
190 terminalHeight: 320,
191 terminalIds: ["term-1"],
192 activeTerminalId: "term-1",
193 terminalGroups: [{ id: "group-term-1", terminalIds: ["term-1"] }],
194 activeTerminalGroupId: "group-term-1",
195 },
196 },
197 },
198 2,
199 );
200
201 expect(migrated).toEqual({
202 terminalUiStateByThreadKey: {
203 [scopedThreadKey(THREAD_REF)]: {
204 terminalOpen: true,
205 terminalHeight: 320,
206 terminalIds: ["term-1"],
207 activeTerminalId: "term-1",
208 terminalGroups: [{ id: "group-term-1", terminalIds: ["term-1"] }],
209 activeTerminalGroupId: "group-term-1",
210 },
211 },
212 });
213 });
214
215 it("resets to default and clears persisted entry when closing the last terminal", () => {
216 const store = useTerminalUiStateStore.getState();
217 store.newTerminal(THREAD_REF, "terminal-only");
218 store.closeTerminal(THREAD_REF, "terminal-only");
219
220 expect(
221 useTerminalUiStateStore.getState().terminalUiStateByThreadKey[scopedThreadKey(THREAD_REF)],
222 ).toBeUndefined();
223 expect(
224 selectThreadTerminalUiState(
225 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
226 THREAD_REF,
227 ).terminalIds,
228 ).toEqual([]);
229 });
230
231 it("keeps a valid active terminal after closing an active split terminal", () => {
232 const store = useTerminalUiStateStore.getState();
233 store.splitTerminal(THREAD_REF, "terminal-2");
234 store.splitTerminal(THREAD_REF, "terminal-3");
235 store.closeTerminal(THREAD_REF, "terminal-3");
236
237 const terminalUiState = selectThreadTerminalUiState(
238 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
239 THREAD_REF,
240 );
241 expect(terminalUiState.activeTerminalId).toBe("terminal-2");
242 expect(terminalUiState.terminalIds).toEqual(["terminal-2"]);
243 expect(terminalUiState.terminalGroups).toEqual([
244 { id: "group-terminal-2", terminalIds: ["terminal-2"] },
245 ]);
246 });
247
248 it("reconciles terminal ids from an external ordered list", () => {
249 const store = useTerminalUiStateStore.getState();
250 store.setTerminalOpen(THREAD_REF, true);
251 store.reconcileTerminalIds(THREAD_REF, ["term-a", "term-b"]);
252
253 const terminalUiState = selectThreadTerminalUiState(
254 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
255 THREAD_REF,
256 );
257 expect(terminalUiState.terminalIds).toEqual(["term-a", "term-b"]);
258 expect(terminalUiState.activeTerminalId).toBe("term-a");
259 expect(terminalUiState.terminalGroups).toEqual([
260 { id: "group-term-a", terminalIds: ["term-a"] },
261 { id: "group-term-b", terminalIds: ["term-b"] },
262 ]);
263 });
264
265 it("does not import a closed panel terminal from stale metadata", () => {
266 const store = useTerminalUiStateStore.getState();
267 store.newTerminal(THREAD_REF, "term-2");
268 store.closeTerminal(THREAD_REF, "term-1");
269
270 store.reconcileTerminalIds(THREAD_REF, ["term-1", "term-2"]);
271
272 expect(
273 selectThreadTerminalUiState(
274 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
275 THREAD_REF,
276 ).terminalIds,
277 ).toEqual(["term-2"]);
278
279 store.newTerminal(THREAD_REF, "term-1");
280 expect(
281 selectThreadTerminalUiState(
282 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
283 THREAD_REF,
284 ).terminalIds,
285 ).toEqual(["term-2", "term-1"]);
286 });
287
288 it("is a no-op when clearing terminal UI state for a thread with no state", () => {
289 const store = useTerminalUiStateStore.getState();
290 const before = useTerminalUiStateStore.getState();
291
292 store.clearTerminalUiState(THREAD_REF);
293
294 expect(useTerminalUiStateStore.getState()).toBe(before);
295 });
296});