返回 DeepSeek-Reasonix
store_test.go
根目录 / desktop / internal / workspacestate / store_test.go
1 package workspacestate
2
3 import (
4 "encoding/json"
5 "errors"
6 "os"
7 "path/filepath"
8 "testing"
9 )
10
11 func TestStorePersistsSessionOrderAndRecoverableArchive(t *testing.T) {
12 store := NewStore(filepath.Join(t.TempDir(), "workspace-state-v1.json"))
13 ctx := t.Context()
14 if err := store.EnsureWorkspace(ctx, Workspace{ID: "project-a", Root: t.TempDir(), Title: "A", Visible: true}); err != nil {
15 t.Fatalf("EnsureWorkspace: %v", err)
16 }
17 if err := store.BeginCreate(ctx, PendingCreate{OperationID: "op-1", WorkspaceID: "project-a", SessionID: "session-1"}); err != nil {
18 t.Fatalf("BeginCreate: %v", err)
19 }
20 if err := store.AttachSession(ctx, "op-1", "project-a", "session-1", ""); err != nil {
21 t.Fatalf("AttachSession(session-1): %v", err)
22 }
23 if err := store.BeginCreate(ctx, PendingCreate{OperationID: "op-2", WorkspaceID: "project-a", SessionID: "session-2"}); err != nil {
24 t.Fatalf("BeginCreate: %v", err)
25 }
26 if err := store.AttachSession(ctx, "op-2", "project-a", "session-2", "session-1"); err != nil {
27 t.Fatalf("AttachSession(session-2): %v", err)
28 }
29
30 if err := store.ArchiveSession(ctx, "session-2"); err != nil {
31 t.Fatalf("ArchiveSession: %v", err)
32 }
33 state, err := store.Load(ctx)
34 if err != nil {
35 t.Fatalf("Load archived: %v", err)
36 }
37 assertStrings(t, state.Workspaces["project-a"].SessionIDs, []string{"session-2", "session-1"})
38 assertStrings(t, state.ArchivedSessionIDs, []string{"session-2"})
39 if len(state.PendingCreates) != 0 {
40 t.Fatalf("pending creates = %#v", state.PendingCreates)
41 }
42
43 if err := store.RestoreSession(ctx, "session-2"); err != nil {
44 t.Fatalf("RestoreSession: %v", err)
45 }
46 reloaded, err := NewStore(store.Path()).Load(ctx)
47 if err != nil {
48 t.Fatalf("reload: %v", err)
49 }
50 assertStrings(t, reloaded.Workspaces["project-a"].SessionIDs, []string{"session-2", "session-1"})
51 if len(reloaded.ArchivedSessionIDs) != 0 {
52 t.Fatalf("archived after restore = %#v", reloaded.ArchivedSessionIDs)
53 }
54 }
55
56 func TestStoreCreateTransactionIsIdempotent(t *testing.T) {
57 store := NewStore(filepath.Join(t.TempDir(), "workspace-state-v1.json"))
58 ctx := t.Context()
59 if err := store.EnsureWorkspace(ctx, Workspace{ID: GlobalWorkspaceID, Title: "Global", Visible: true}); err != nil {
60 t.Fatalf("EnsureWorkspace: %v", err)
61 }
62 pending := PendingCreate{OperationID: "op", WorkspaceID: GlobalWorkspaceID, SessionID: "session"}
63 if err := store.BeginCreate(ctx, pending); err != nil {
64 t.Fatalf("BeginCreate: %v", err)
65 }
66 if err := store.BeginCreate(ctx, pending); err != nil {
67 t.Fatalf("repeat BeginCreate: %v", err)
68 }
69 if err := store.AttachSession(ctx, pending.OperationID, pending.WorkspaceID, pending.SessionID, ""); err != nil {
70 t.Fatalf("AttachSession: %v", err)
71 }
72 if err := store.AttachSession(ctx, pending.OperationID, pending.WorkspaceID, pending.SessionID, ""); err != nil {
73 t.Fatalf("repeat AttachSession: %v", err)
74 }
75 state, err := store.Load(ctx)
76 if err != nil {
77 t.Fatalf("Load: %v", err)
78 }
79 assertStrings(t, state.Workspaces[GlobalWorkspaceID].SessionIDs, []string{"session"})
80 }
81
82 func TestAbortCreateIfOperationCannotEraseNewerReservation(t *testing.T) {
83 store := NewStore(filepath.Join(t.TempDir(), "workspace-state-v1.json"))
84 ctx := t.Context()
85 if err := store.EnsureWorkspace(ctx, Workspace{ID: GlobalWorkspaceID, Title: "Global", Visible: true}); err != nil {
86 t.Fatal(err)
87 }
88 if err := store.BeginCreate(ctx, PendingCreate{OperationID: "new", WorkspaceID: GlobalWorkspaceID, SessionID: "session"}); err != nil {
89 t.Fatal(err)
90 }
91 if err := store.AbortCreateIfOperation(ctx, "session", "old"); err != nil {
92 t.Fatal(err)
93 }
94 state, err := store.Load(ctx)
95 if err != nil {
96 t.Fatal(err)
97 }
98 if pending := state.PendingCreates["session"]; pending.OperationID != "new" {
99 t.Fatalf("newer reservation was removed: %+v", pending)
100 }
101 if err := store.AbortCreateIfOperation(ctx, "session", "new"); err != nil {
102 t.Fatal(err)
103 }
104 state, err = store.Load(ctx)
105 if err != nil {
106 t.Fatal(err)
107 }
108 if _, ok := state.PendingCreates["session"]; ok {
109 t.Fatal("matching reservation was not removed")
110 }
111 }
112
113 func TestWorkspacePresentationCanBeRenamedHiddenAndReordered(t *testing.T) {
114 path := filepath.Join(t.TempDir(), "workspace-state-v1.json")
115 store := NewStore(path)
116 ctx := t.Context()
117 for _, workspace := range []Workspace{
118 {ID: GlobalWorkspaceID, Title: "Global", Visible: true},
119 {ID: "project-a", Title: "A", Visible: true},
120 {ID: "project-b", Title: "B", Visible: true},
121 } {
122 if err := store.EnsureWorkspace(ctx, workspace); err != nil {
123 t.Fatal(err)
124 }
125 }
126 if err := store.RenameWorkspace(ctx, "project-b", "Renamed"); err != nil {
127 t.Fatal(err)
128 }
129 if err := store.SetWorkspaceVisible(ctx, "project-a", false); err != nil {
130 t.Fatal(err)
131 }
132 if err := store.MoveWorkspace(ctx, "project-b", GlobalWorkspaceID); err != nil {
133 t.Fatal(err)
134 }
135 state, err := store.Load(ctx)
136 if err != nil {
137 t.Fatal(err)
138 }
139 assertStrings(t, state.WorkspaceIDs, []string{"project-b", GlobalWorkspaceID, "project-a"})
140 if state.Workspaces["project-b"].Title != "Renamed" || state.Workspaces["project-a"].Visible {
141 t.Fatalf("workspace presentation not persisted: %#v", state.Workspaces)
142 }
143 reopened, err := NewStore(path).Load(ctx)
144 if err != nil {
145 t.Fatal(err)
146 }
147 assertStrings(t, reopened.WorkspaceIDs, []string{"project-b", GlobalWorkspaceID, "project-a"})
148 }
149
150 func TestCommitRotationAttachesReplacementAndArchivesSourceAtomically(t *testing.T) {
151 store := NewStore(filepath.Join(t.TempDir(), "workspace-state-v1.json"))
152 ctx := t.Context()
153 if err := store.EnsureWorkspace(ctx, Workspace{ID: "project-a", Visible: true}); err != nil {
154 t.Fatal(err)
155 }
156 if err := store.AttachSession(ctx, "", "project-a", "source", ""); err != nil {
157 t.Fatal(err)
158 }
159 pending := PendingCreate{OperationID: "rotation", WorkspaceID: "project-a", SessionID: "replacement"}
160 if err := store.BeginCreate(ctx, pending); err != nil {
161 t.Fatal(err)
162 }
163 if err := store.CommitRotation(ctx, pending.OperationID, pending.WorkspaceID, pending.SessionID, "", "source"); err != nil {
164 t.Fatal(err)
165 }
166 state, err := store.Load(ctx)
167 if err != nil {
168 t.Fatal(err)
169 }
170 assertStrings(t, state.Workspaces["project-a"].SessionIDs, []string{"source", "replacement"})
171 assertStrings(t, state.ArchivedSessionIDs, []string{"source"})
172 if len(state.PendingCreates) != 0 {
173 t.Fatalf("pending creates = %#v", state.PendingCreates)
174 }
175 }
176
177 func TestAttachSessionFromSourceIfUnchangedRejectsArchivedSource(t *testing.T) {
178 store := NewStore(filepath.Join(t.TempDir(), "workspace-state-v1.json"))
179 ctx := t.Context()
180 if err := store.EnsureWorkspace(ctx, Workspace{ID: "project-a", Visible: true}); err != nil {
181 t.Fatal(err)
182 }
183 if err := store.AttachSession(ctx, "", "project-a", "source", ""); err != nil {
184 t.Fatal(err)
185 }
186 state, err := store.Load(ctx)
187 if err != nil {
188 t.Fatal(err)
189 }
190 generation := state.SessionStates["source"].Generation
191 if err := store.BeginCreate(ctx, PendingCreate{
192 OperationID: "copy", WorkspaceID: "project-a", SessionID: "child",
193 }); err != nil {
194 t.Fatal(err)
195 }
196 if err := store.ArchiveSession(ctx, "source"); err != nil {
197 t.Fatal(err)
198 }
199 err = store.AttachSessionFromSourceIfUnchanged(
200 ctx, "copy", "project-a", "child", "", "source", generation,
201 )
202 if !errors.Is(err, ErrMutationConflict) {
203 t.Fatalf("guarded attach error = %v, want mutation conflict", err)
204 }
205 state, err = store.Load(ctx)
206 if err != nil {
207 t.Fatal(err)
208 }
209 if contains(state.Workspaces["project-a"].SessionIDs, "child") {
210 t.Fatal("child attached after source lifecycle changed")
211 }
212 if _, pending := state.PendingCreates["child"]; !pending {
213 t.Fatal("failed guarded attach discarded recovery journal")
214 }
215 }
216
217 func TestStorePreservesUnknownTopLevelAndWorkspaceFields(t *testing.T) {
218 path := filepath.Join(t.TempDir(), "workspace-state-v1.json")
219 original := `{"version":1,"generation":2,"initialized":true,"workspaceIds":["global"],"workspaces":{"global":{"id":"global","root":"","title":"Global","sessionIds":[],"visible":true,"createdAt":"2026-01-01T00:00:00Z","updatedAt":"2026-01-01T00:00:00Z","futureWorkspace":{"enabled":true}}},"archivedSessionIds":[],"pendingCreates":{},"futureTop":{"value":7}}`
220 if err := os.WriteFile(path, []byte(original), 0o600); err != nil {
221 t.Fatalf("WriteFile: %v", err)
222 }
223 store := NewStore(path)
224 if err := store.RenameWorkspace(t.Context(), GlobalWorkspaceID, "Renamed"); err != nil {
225 t.Fatalf("RenameWorkspace: %v", err)
226 }
227 body, err := os.ReadFile(path)
228 if err != nil {
229 t.Fatalf("ReadFile: %v", err)
230 }
231 var decoded map[string]json.RawMessage
232 if err := json.Unmarshal(body, &decoded); err != nil {
233 t.Fatalf("Unmarshal top: %v", err)
234 }
235 if _, ok := decoded["futureTop"]; !ok {
236 t.Fatal("futureTop was dropped")
237 }
238 var workspaces map[string]map[string]json.RawMessage
239 if err := json.Unmarshal(decoded["workspaces"], &workspaces); err != nil {
240 t.Fatalf("Unmarshal workspaces: %v", err)
241 }
242 if _, ok := workspaces[GlobalWorkspaceID]["futureWorkspace"]; !ok {
243 t.Fatal("futureWorkspace was dropped")
244 }
245 }
246
247 func TestStoreRefusesUnknownVersionWithoutOverwriting(t *testing.T) {
248 path := filepath.Join(t.TempDir(), "workspace-state-v1.json")
249 original := []byte(`{"version":99,"future":true}`)
250 if err := os.WriteFile(path, original, 0o600); err != nil {
251 t.Fatalf("WriteFile: %v", err)
252 }
253 store := NewStore(path)
254 if err := store.EnsureWorkspace(t.Context(), Workspace{ID: GlobalWorkspaceID}); !errors.Is(err, ErrUnsupportedVersion) {
255 t.Fatalf("EnsureWorkspace err = %v", err)
256 }
257 after, err := os.ReadFile(path)
258 if err != nil {
259 t.Fatalf("ReadFile: %v", err)
260 }
261 if string(after) != string(original) {
262 t.Fatalf("unknown version was overwritten: %s", after)
263 }
264 }
265
266 func assertStrings(t *testing.T, got, want []string) {
267 t.Helper()
268 if len(got) != len(want) {
269 t.Fatalf("strings = %#v, want %#v", got, want)
270 }
271 for i := range want {
272 if got[i] != want[i] {
273 t.Fatalf("strings = %#v, want %#v", got, want)
274 }
275 }
276 }
277
277 lines GO