返回 DeepSeek-Reasonix
project_tree_organization_test.go
根目录 / desktop / project_tree_organization_test.go
1 package main
2
3 import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "reflect"
8 "testing"
9
10 "reasonix/desktop/internal/workspacestate"
11 "reasonix/internal/session"
12 )
13
14 func TestCompleteTopicOrderPreservesTopicsMissingFromPartialClient(t *testing.T) {
15 got, err := completeTopicOrder([]string{"c", "a"}, []string{"a", "b", "c"})
16 if err != nil {
17 t.Fatal(err)
18 }
19 if want := []string{"c", "b", "a"}; !reflect.DeepEqual(got, want) {
20 t.Fatalf("order = %v, want %v", got, want)
21 }
22 if _, err := completeTopicOrder([]string{"a", "a"}, []string{"a", "b"}); err == nil {
23 t.Fatal("duplicate topic order must fail")
24 }
25 if _, err := completeTopicOrder([]string{"unknown"}, []string{"a", "b"}); err == nil {
26 t.Fatal("unknown topic must not be persisted")
27 }
28 }
29
30 func TestReorderTopicsEnablesManualOrderOnlyAfterExplicitDrag(t *testing.T) {
31 isolateDesktopUserDirs(t)
32 root := t.TempDir()
33 if err := addProject(root, "Project"); err != nil {
34 t.Fatal(err)
35 }
36 if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
37 i := projectIndexByRoot(f.Projects, root)
38 f.Projects[i].Topics = []string{"a", "b", "c"}
39 return true, nil
40 }); err != nil {
41 t.Fatal(err)
42 }
43 app := NewApp()
44 for _, topic := range app.metadataProjectTopics("project", root) {
45 if topic.SortOrder != -1 {
46 t.Fatalf("preference-free sortOrder = %d, want -1", topic.SortOrder)
47 }
48 }
49 if err := app.ReorderTopics("project", root, []string{"c", "a"}); err != nil {
50 t.Fatal(err)
51 }
52 project := loadProjectsFile().Projects[projectIndexByRoot(loadProjectsFile().Projects, root)]
53 if want := []string{"c", "b", "a"}; !reflect.DeepEqual(project.Topics, want) {
54 t.Fatalf("topics = %v, want %v", project.Topics, want)
55 }
56 if !project.ManualTopicOrder {
57 t.Fatal("manual topic order flag was not persisted")
58 }
59 for index, topic := range app.metadataProjectTopics("project", root) {
60 if topic.SortOrder != index {
61 t.Fatalf("topic %q sortOrder = %d, want %d", topic.TopicID, topic.SortOrder, index)
62 }
63 }
64 }
65
66 func TestSessionGroupsPersistExclusiveMembership(t *testing.T) {
67 app, root, refs := canonicalOrganizationFixture(t, "a", "b", "c")
68 groups := []desktopGroup{
69 {ID: "one", Title: "One", TopicIDs: []string{"a", "b"}},
70 {ID: "two", Title: "Two", TopicIDs: []string{"c"}},
71 }
72 if err := app.SaveSessionGroups("project", root, groups); err != nil {
73 t.Fatal(err)
74 }
75 got, err := app.ListProjectGroups("project", root)
76 if err != nil {
77 t.Fatal(err)
78 }
79 want := []desktopGroup{
80 {ID: "one", Title: "One", SessionKeys: []string{workspacestate.SessionKey("a"), workspacestate.SessionKey("b")}},
81 {ID: "two", Title: "Two", SessionKeys: []string{workspacestate.SessionKey("c")}},
82 }
83 if !reflect.DeepEqual(got, want) {
84 t.Fatalf("groups = %#v, want explicit members %#v", got, want)
85 }
86 if err := app.ArchiveCanonicalSession(refs["b"]); err != nil {
87 t.Fatal(err)
88 }
89 page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root, GroupFilter: "group", GroupID: "one", Limit: 10})
90 if err != nil || len(page.Items) != 1 || page.Items[0].Session == nil || page.Items[0].Session.SessionID != "a" {
91 t.Fatalf("group after B archive = %#v, err=%v", page, err)
92 }
93 if err := app.SaveSessionGroups("project", root, []desktopGroup{
94 {ID: "one", Title: "One", SessionKeys: []string{workspacestate.SessionKey("a")}},
95 {ID: "two", Title: "Two", SessionKeys: []string{workspacestate.SessionKey("a")}},
96 }); err == nil {
97 t.Fatal("a session cannot belong to multiple groups")
98 }
99 if err := app.SaveSessionGroups("project", root, []desktopGroup{
100 {ID: "one", Title: "One", TopicIDs: []string{"a"}},
101 {ID: "two", Title: "Two", TopicIDs: []string{"a"}},
102 }); err == nil {
103 t.Fatal("legacy duplicate topic membership must still fail")
104 }
105 if _, err := app.ListProjectGroups("other", root); err == nil {
106 t.Fatal("unsupported scope must fail")
107 }
108 }
109
110 func TestProjectOrganizationSurvivesOlderBuildRoundTrip(t *testing.T) {
111 app, root, _ := canonicalOrganizationFixture(t, "a", "b", "c")
112 legacyGroups := []desktopGroup{{ID: "important", Title: "Important", TopicIDs: []string{"a", "c"}}}
113 if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
114 p := &f.Projects[projectIndexByRoot(f.Projects, root)]
115 p.Topics = []string{"c", "a", "b"}
116 p.ManualTopicOrder = true
117 p.SessionOrder = []string{workspacestate.SessionKey("c"), workspacestate.SessionKey("a"), workspacestate.SessionKey("b")}
118 p.ManualSessionOrder = true
119 p.Groups = legacyGroups
120 return true, nil
121 }); err != nil {
122 t.Fatal(err)
123 }
124 workspace := SessionOrganizationWorkspace{Scope: "project", WorkspaceRoot: root}
125 imported, err := app.GetSessionOrganization(workspace)
126 if err != nil {
127 t.Fatal(err)
128 }
129 if len(imported.Groups) != 1 || !reflect.DeepEqual(imported.Groups[0].SessionKeys, []string{workspacestate.SessionKey("a"), workspacestate.SessionKey("c")}) {
130 t.Fatalf("legacy grouping was not imported: %#v", imported)
131 }
132 if _, err := os.Stat(filepath.Join(desktopConfigDir(), desktopProjectOrganizationFile)); err != nil {
133 t.Fatalf("legacy import sidecar missing: %v", err)
134 }
135 if err := app.ReorderSessions("project", root, []string{workspacestate.SessionKey("b"), workspacestate.SessionKey("c"), workspacestate.SessionKey("a")}); err != nil {
136 t.Fatal(err)
137 }
138 if err := app.SaveSessionGroups("project", root, []desktopGroup{{ID: "important", Title: "Independent", SessionKeys: []string{workspacestate.SessionKey("c")}}}); err != nil {
139 t.Fatal(err)
140 }
141 before, err := app.GetSessionOrganization(workspace)
142 if err != nil {
143 t.Fatal(err)
144 }
145 // An older project's typed round trip drops its unknown organization fields.
146 // The migrated registry must retain the user's newer independent choices.
147 projectsPath := filepath.Join(desktopConfigDir(), desktopProjectsFile)
148 b, err := os.ReadFile(projectsPath)
149 if err != nil {
150 t.Fatal(err)
151 }
152 var old map[string]any
153 if err := json.Unmarshal(b, &old); err != nil {
154 t.Fatal(err)
155 }
156 delete(old, "globalManualTopicOrder")
157 delete(old, "globalGroups")
158 for _, value := range old["projects"].([]any) {
159 p := value.(map[string]any)
160 for _, field := range []string{"manualTopicOrder", "groups", "sessionOrder", "manualSessionOrder"} {
161 delete(p, field)
162 }
163 }
164 b, err = json.MarshalIndent(old, "", " ")
165 if err != nil {
166 t.Fatal(err)
167 }
168 if err := os.WriteFile(projectsPath, b, 0600); err != nil {
169 t.Fatal(err)
170 }
171 after, err := app.GetSessionOrganization(workspace)
172 if err != nil {
173 t.Fatal(err)
174 }
175 if !reflect.DeepEqual(after, before) {
176 t.Fatalf("legacy project write overwrote registry organization: before=%#v after=%#v", before, after)
177 }
178 state, err := app.workspaceRegistry().Load(t.Context())
179 if err != nil {
180 t.Fatal(err)
181 }
182 if state.Version != 3 {
183 t.Fatalf("registry version=%d, want downgrade-protected v3", state.Version)
184 }
185 }
186
187 func TestVersionedSessionGroupsRejectStaleFullState(t *testing.T) {
188 app, root, refs := canonicalOrganizationFixture(t, "a")
189 key := workspacestate.SessionKey("a")
190 if err := app.SaveSessionGroups("project", root, []desktopGroup{{ID: "one", Title: "One", SessionKeys: []string{key}}}); err != nil {
191 t.Fatal(err)
192 }
193 base, err := app.GetProjectGroups("project", root)
194 if err != nil {
195 t.Fatal(err)
196 }
197 first, err := app.SaveSessionGroupsVersioned("project", root, base.Revision, []desktopGroup{{ID: "one", Title: "Renamed", SessionKeys: []string{key}}})
198 if err != nil || !first.Applied {
199 t.Fatalf("first CAS=%#v err=%v", first, err)
200 }
201 // A second app window shares the registry but retains the old revision.
202 second := NewApp()
203 t.Cleanup(second.closeSessionServices)
204 second.ctx = t.Context()
205 second.desktopSessions.root = app.desktopSessions.root
206 second.desktopSessions.workspaceState = workspacestate.NewStore(app.workspaceRegistry().Path())
207 stale, err := second.SaveSessionGroupsVersioned("project", root, base.Revision, []desktopGroup{{ID: "one", Title: "One", SessionKeys: []string{key}}, {ID: "two", Title: "Two"}})
208 if err != nil {
209 t.Fatal(err)
210 }
211 if stale.Applied || stale.Revision != first.Revision || len(stale.Groups) != 1 || stale.Groups[0].Title != "Renamed" {
212 t.Fatalf("stale CAS=%#v", stale)
213 }
214 rebased := append(nonNilGroups(stale.Groups), desktopGroup{ID: "two", Title: "Two"})
215 merged, err := second.SaveSessionGroupsVersioned("project", root, stale.Revision, rebased)
216 if err != nil || !merged.Applied || len(merged.Groups) != 2 || merged.Groups[0].Title != "Renamed" {
217 t.Fatalf("rebased CAS=%#v err=%v", merged, err)
218 }
219 beforeArchive, err := app.GetProjectGroups("project", root)
220 if err != nil {
221 t.Fatal(err)
222 }
223 if err := app.ArchiveCanonicalSession(refs["a"]); err != nil {
224 t.Fatal(err)
225 }
226 // Explicitly removing the archived member is another window's newer choice.
227 removed, err := second.SaveSessionGroupsVersioned("project", root, beforeArchive.Revision, []desktopGroup{{ID: "one", Title: "Renamed"}, {ID: "two", Title: "Two"}})
228 if err != nil || !removed.Applied {
229 t.Fatalf("remove archived membership=%#v err=%v", removed, err)
230 }
231 resurrect, err := app.SaveSessionGroupsVersioned("project", root, beforeArchive.Revision, beforeArchive.Groups)
232 if err != nil {
233 t.Fatal(err)
234 }
235 if resurrect.Applied || len(resurrect.Groups[0].SessionKeys) != 0 {
236 t.Fatalf("stale save overwrote archived membership removal: %#v", resurrect)
237 }
238 // A legacy topic payload cannot bring an archived canonical member back.
239 if err := app.SaveSessionGroups("project", root, []desktopGroup{{ID: "one", Title: "Renamed", TopicIDs: []string{"a"}}, {ID: "two", Title: "Two"}}); err != nil {
240 t.Fatal(err)
241 }
242 legacy, err := app.GetProjectGroups("project", root)
243 if err != nil {
244 t.Fatal(err)
245 }
246 if len(legacy.Groups[0].SessionKeys) != 0 {
247 t.Fatalf("legacy save resurrected archived membership: %#v", legacy)
248 }
249 state, err := app.workspaceRegistry().Load(t.Context())
250 if err != nil || state.SessionStates["a"].Lifecycle != workspacestate.Archived {
251 t.Fatalf("archive lifecycle lost after group writes: %#v err=%v", state.SessionStates["a"], err)
252 }
253 page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root, Limit: 10})
254 if err != nil || len(page.Items) != 0 {
255 t.Fatalf("archived session reappeared: %#v err=%v", page, err)
256 }
257 }
258
259 func TestListProjectGroupsMissingProjectReturnsJSONArray(t *testing.T) {
260 isolateDesktopUserDirs(t)
261 groups, err := NewApp().ListProjectGroups("project", t.TempDir())
262 if err != nil {
263 t.Fatal(err)
264 }
265 b, err := json.Marshal(groups)
266 if err != nil {
267 t.Fatal(err)
268 }
269 if string(b) != "[]" {
270 t.Fatalf("missing project groups JSON = %s, want []", b)
271 }
272 }
273
274 func TestReorderSessionsPersistsIndependentSharedTopicOrder(t *testing.T) {
275 app, root, _ := canonicalOrganizationFixtureWithTopic(t, "shared", "a", "b")
276 keys := []string{workspacestate.SessionKey("b"), workspacestate.SessionKey("a")}
277 if err := app.ReorderSessions("project", root, keys); err != nil {
278 t.Fatal(err)
279 }
280 state, err := app.workspaceRegistry().Load(t.Context())
281 if err != nil {
282 t.Fatal(err)
283 }
284 organization := state.Workspaces[desktopWorkspaceID("project", root)].Organization
285 if organization == nil || !organization.ManualOrderEnabled || !reflect.DeepEqual(organization.Order, keys) {
286 t.Fatalf("registry order=%#v", organization)
287 }
288 page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root, Limit: 10})
289 if err != nil {
290 t.Fatal(err)
291 }
292 if len(page.Items) != 2 || page.Items[0].Session.SessionID != "b" || page.Items[1].Session.SessionID != "a" {
293 t.Fatalf("sidebar order=%#v", page.Items)
294 }
295 if err := app.ReorderSessions("project", root, []string{"path\x00/does-not-exist.jsonl"}); err == nil {
296 t.Fatal("unknown source identity must be rejected")
297 }
298 }
299
300 func canonicalOrganizationFixture(t *testing.T, ids ...string) (*App, string, map[string]session.SessionRef) {
301 t.Helper()
302 return canonicalOrganizationFixtureWithTopic(t, "", ids...)
303 }
304
305 func canonicalOrganizationFixtureWithTopic(t *testing.T, sharedTopic string, ids ...string) (*App, string, map[string]session.SessionRef) {
306 t.Helper()
307 isolateDesktopUserDirs(t)
308 root := t.TempDir()
309 if err := addProject(root, "Organization test"); err != nil {
310 t.Fatal(err)
311 }
312 app := NewApp()
313 t.Cleanup(app.closeSessionServices)
314 app.ctx = t.Context()
315 app.desktopSessions.root = filepath.Join(root, "by-id")
316 app.desktopSessions.workspaceState = workspacestate.NewStore(filepath.Join(root, "workspace-state.json"))
317 workspaceID, err := app.ensureDesktopWorkspace(t.Context(), "project", root)
318 if err != nil {
319 t.Fatal(err)
320 }
321 refs := map[string]session.SessionRef{}
322 for _, id := range ids {
323 runtime, err := app.desktopSessionService("").Create(t.Context(), session.CreateOptions{SessionID: id, CWD: root, Origin: session.SessionOriginNew})
324 if err != nil {
325 t.Fatal(err)
326 }
327 if err := app.workspaceRegistry().AttachSession(t.Context(), "", workspaceID, id, ""); err != nil {
328 t.Fatal(err)
329 }
330 topic := id
331 if sharedTopic != "" {
332 topic = sharedTopic
333 }
334 if err := app.workspaceRegistry().EnsureSessionTopic(t.Context(), id, topic, id); err != nil {
335 t.Fatal(err)
336 }
337 refs[id] = runtime.Ref()
338 }
339 return app, root, refs
340 }
341
341 lines GO