| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "reflect" |
| 8 | "testing" |
| 9 | |
| 10 | "reasonix/desktop/internal/workspacestate" |
| 11 | "reasonix/internal/session" |
| 12 | ) |
| 13 | |
| 14 | func TestProjectTreeUnregisteredHistoricalPins(t *testing.T) { |
| 15 | isolateDesktopUserDirs(t) |
| 16 | root, other := t.TempDir(), t.TempDir() |
| 17 | if err := addProject(root, "Legacy"); err != nil { |
| 18 | t.Fatal(err) |
| 19 | } |
| 20 | app := NewApp() |
| 21 | app.ctx = t.Context() |
| 22 | t.Cleanup(app.closeSessionServices) |
| 23 | app.historicalImports.catalog = []historicalCatalogEntry{ |
| 24 | {scope: "project", node: ProjectNode{Key: "source_kept", Root: root, Pinned: true, Source: &SessionSourceRef{HostID: localDesktopHostID, SourceKey: "kept"}}}, |
| 25 | {scope: "project", node: ProjectNode{Key: "source_other", Root: other, Pinned: true, Source: &SessionSourceRef{HostID: localDesktopHostID, SourceKey: "other"}}}, |
| 26 | } |
| 27 | for range 2 { |
| 28 | snapshot := mustProjectTreeSnapshot(t, app) |
| 29 | if len(snapshot.Projects) != 1 || len(snapshot.Projects[0].Children) != 1 || snapshot.Projects[0].Children[0].Key != "source_kept" { |
| 30 | t.Fatalf("unregistered project lost its pin or included another project's pin: %+v", snapshot.Projects) |
| 31 | } |
| 32 | if _, err := os.Stat(app.workspaceRegistry().Path()); !os.IsNotExist(err) { |
| 33 | t.Fatalf("snapshot created a registry: %v", err) |
| 34 | } |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | func TestProjectedShellOrganizationPreservesAdoptedAliasOrder(t *testing.T) { |
| 39 | app, root, refs := canonicalOrganizationFixture(t, "a", "b") |
| 40 | state, err := app.workspaceRegistry().LoadProjection(t.Context()) |
| 41 | if err != nil { |
| 42 | t.Fatal(err) |
| 43 | } |
| 44 | workspaceID := desktopWorkspaceID("project", root) |
| 45 | workspace := state.Workspaces[workspaceID] |
| 46 | workspace.Organization = &workspacestate.Organization{Order: []string{}, Imported: map[string]bool{}} |
| 47 | state.SourceMappings["adopted"] = workspacestate.SourceMapping{WorkspaceID: workspaceID, SessionID: "b", SourceKey: "adopted", Path: filepath.Join(root, "old.jsonl")} |
| 48 | legacy := desktopProject{ManualSessionOrder: true, SessionOrder: []string{"source\x00local\x00adopted", workspacestate.SessionKey("a")}} |
| 49 | org := projectedShellOrganization(workspace, state, nil, legacy) |
| 50 | want := []string{workspacestate.SessionKey("b"), workspacestate.SessionKey("a")} |
| 51 | if !reflect.DeepEqual(org.Order, want) { |
| 52 | t.Fatalf("alias order=%v want=%v", org.Order, want) |
| 53 | } |
| 54 | if workspace.Organization.MigrationVersion != 0 || len(workspace.Organization.Imported) != 0 || len(workspace.Organization.Order) != 0 { |
| 55 | t.Fatal("projection mutated the caller's organization") |
| 56 | } |
| 57 | // The same identity must sort the canonical row, never its retired source. |
| 58 | nodes := []ProjectNode{{Session: &session.SessionRef{HostID: refs["a"].HostID, SessionID: "a"}}, {Session: &session.SessionRef{HostID: refs["b"].HostID, SessionID: "b"}}} |
| 59 | got := filterWorkspaceSessionNodes(ProjectTopicPageRequest{}, org, state, workspaceID, nodes) |
| 60 | if len(got) != 2 || got[0].SortOrder != 1 || got[1].SortOrder != 0 { |
| 61 | t.Fatalf("canonical ranks=%+v", got) |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | func TestProjectTreeUnmigratedManualOrderMatchesExpandedPage(t *testing.T) { |
| 66 | for _, mode := range []string{"session", "topic"} { |
| 67 | t.Run(mode, func(t *testing.T) { |
| 68 | app, root, _ := canonicalOrganizationFixture(t, "a", "b", "c") |
| 69 | if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) { |
| 70 | p := &f.Projects[projectIndexByRoot(f.Projects, root)] |
| 71 | if mode == "topic" { |
| 72 | p.Topics, p.ManualTopicOrder = []string{"c", "a", "b"}, true |
| 73 | } else { |
| 74 | p.SessionOrder = []string{workspacestate.SessionKey("c"), workspacestate.SessionKey("a"), workspacestate.SessionKey("b")} |
| 75 | p.ManualSessionOrder = true |
| 76 | } |
| 77 | return true, nil |
| 78 | }); err != nil { |
| 79 | t.Fatal(err) |
| 80 | } |
| 81 | pinned := true |
| 82 | if err := app.workspaceRegistry().UpdatePresentation(t.Context(), []string{"a", "b", "c"}, nil, &pinned); err != nil { |
| 83 | t.Fatal(err) |
| 84 | } |
| 85 | before, err := os.ReadFile(app.workspaceRegistry().Path()) |
| 86 | if err != nil { |
| 87 | t.Fatal(err) |
| 88 | } |
| 89 | keys := func(nodes []ProjectNode) []string { |
| 90 | result := []string{} |
| 91 | for _, node := range nodes { |
| 92 | result = append(result, projectNodeSessionKey(node)) |
| 93 | } |
| 94 | return result |
| 95 | } |
| 96 | want := []string{workspacestate.SessionKey("c"), workspacestate.SessionKey("a"), workspacestate.SessionKey("b")} |
| 97 | for range 2 { |
| 98 | snapshot := mustProjectTreeSnapshot(t, app) |
| 99 | if len(snapshot.Projects) != 1 || !reflect.DeepEqual(keys(snapshot.Projects[0].Children), want) { |
| 100 | t.Fatalf("cold snapshot order: %+v", snapshot.Projects) |
| 101 | } |
| 102 | } |
| 103 | after, err := os.ReadFile(app.workspaceRegistry().Path()) |
| 104 | if err != nil || !bytes.Equal(before, after) { |
| 105 | t.Fatalf("snapshot persisted migration: %v", err) |
| 106 | } |
| 107 | page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root, Limit: 50}) |
| 108 | if err != nil || !reflect.DeepEqual(keys(page.Items), want) { |
| 109 | t.Fatalf("expanded page order=%v err=%v", keys(page.Items), err) |
| 110 | } |
| 111 | // Once migrated, durable user choices override legacy preferences. |
| 112 | want = []string{workspacestate.SessionKey("b"), workspacestate.SessionKey("a"), workspacestate.SessionKey("c")} |
| 113 | if err := app.ReorderSessions("project", root, want); err != nil { |
| 114 | t.Fatal(err) |
| 115 | } |
| 116 | if got := keys(mustProjectTreeSnapshot(t, app).Projects[0].Children); !reflect.DeepEqual(got, want) { |
| 117 | t.Fatalf("persisted order=%v want=%v", got, want) |
| 118 | } |
| 119 | }) |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | func TestHistoricalProjectionUnregisteredPathAlias(t *testing.T) { |
| 124 | isolateDesktopUserDirs(t) |
| 125 | root := t.TempDir() |
| 126 | alias := filepath.Join(t.TempDir(), "alias") |
| 127 | if err := os.Symlink(root, alias); err != nil { |
| 128 | t.Skipf("symlink unavailable: %v", err) |
| 129 | } |
| 130 | app := NewApp() |
| 131 | app.ctx = t.Context() |
| 132 | app.historicalImports.catalog = []historicalCatalogEntry{{scope: "project", node: ProjectNode{Root: alias, Source: &SessionSourceRef{SourceKey: "alias"}}}} |
| 133 | state, err := app.workspaceRegistry().LoadProjection(t.Context()) |
| 134 | if err != nil { |
| 135 | t.Fatal(err) |
| 136 | } |
| 137 | if got := app.historicalCanonicalTopics("project", root, state); len(got) != 1 { |
| 138 | t.Fatalf("unregistered alias lost: %+v", got) |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | func TestProjectTreeHistoricalManualOrderBeforeRegistration(t *testing.T) { |
| 143 | for _, scope := range []string{"project", "global"} { |
| 144 | t.Run(scope, func(t *testing.T) { |
| 145 | isolateDesktopUserDirs(t) |
| 146 | root := t.TempDir() |
| 147 | if scope == "project" { |
| 148 | if err := addProject(root, "Legacy"); err != nil { |
| 149 | t.Fatal(err) |
| 150 | } |
| 151 | } |
| 152 | app := NewApp() |
| 153 | app.ctx = t.Context() |
| 154 | t.Cleanup(app.closeSessionServices) |
| 155 | for _, id := range []string{"a", "b"} { |
| 156 | path := filepath.Join(root, id+".jsonl") |
| 157 | app.historicalImports.catalog = append(app.historicalImports.catalog, historicalCatalogEntry{scope: scope, node: ProjectNode{Root: root, Key: "source_" + id, TopicID: id, SessionPath: path, Pinned: true, Source: &SessionSourceRef{HostID: localDesktopHostID, SourceKey: id, Path: path}}}) |
| 158 | } |
| 159 | want := []string{"source\x00local\x00b", "source\x00local\x00a"} |
| 160 | if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) { |
| 161 | if scope == "global" { |
| 162 | f.GlobalSessionOrder, f.GlobalManualSessionOrder = want, true |
| 163 | } else { |
| 164 | f.Projects[0].SessionOrder, f.Projects[0].ManualSessionOrder = want, true |
| 165 | } |
| 166 | return true, nil |
| 167 | }); err != nil { |
| 168 | t.Fatal(err) |
| 169 | } |
| 170 | for range 2 { |
| 171 | snapshot := mustProjectTreeSnapshot(t, app) |
| 172 | if len(snapshot.Projects) != 1 { |
| 173 | t.Fatalf("projects=%+v", snapshot.Projects) |
| 174 | } |
| 175 | got := []string{} |
| 176 | for _, node := range snapshot.Projects[0].Children { |
| 177 | got = append(got, projectNodeSessionKey(node)) |
| 178 | } |
| 179 | if !reflect.DeepEqual(got, want) { |
| 180 | t.Fatalf("historical order=%v want=%v", got, want) |
| 181 | } |
| 182 | } |
| 183 | if _, err := os.Stat(app.workspaceRegistry().Path()); !os.IsNotExist(err) { |
| 184 | t.Fatalf("read created registry: %v", err) |
| 185 | } |
| 186 | }) |
| 187 | } |
| 188 | } |
| 189 |