| 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 |