348 lines · 11.0 KB · TypeScript
RawDownload
import { ProjectId, ThreadId } from "@t3tools/contracts";import { describe, expect, it } from "vitest"; import {  clearThreadUi,  markThreadUnread,  reorderProjects,  setProjectExpanded,  setThreadChangedFilesExpanded,  syncProjects,  syncThreads,  type UiState,} from "./uiStateStore"; function makeUiState(overrides: Partial<UiState> = {}): UiState {  return {    projectExpandedById: {},    projectOrder: [],    threadLastVisitedAtById: {},    threadChangedFilesExpandedById: {},    ...overrides,  };} describe("uiStateStore pure functions", () => {  it("markThreadUnread moves lastVisitedAt before completion for a completed thread", () => {    const threadId = ThreadId.make("thread-1");    const latestTurnCompletedAt = "2026-02-25T12:30:00.000Z";    const initialState = makeUiState({      threadLastVisitedAtById: {        [threadId]: "2026-02-25T12:35:00.000Z",      },    });     const next = markThreadUnread(initialState, threadId, latestTurnCompletedAt);     expect(next.threadLastVisitedAtById[threadId]).toBe("2026-02-25T12:29:59.999Z");  });   it("markThreadUnread does not change a thread without a completed turn", () => {    const threadId = ThreadId.make("thread-1");    const initialState = makeUiState({      threadLastVisitedAtById: {        [threadId]: "2026-02-25T12:35:00.000Z",      },    });     const next = markThreadUnread(initialState, threadId, null);     expect(next).toBe(initialState);  });   it("reorderProjects moves a project to a target index", () => {    const project1 = ProjectId.make("project-1");    const project2 = ProjectId.make("project-2");    const project3 = ProjectId.make("project-3");    const initialState = makeUiState({      projectOrder: [project1, project2, project3],    });     const next = reorderProjects(initialState, [project1], [project3]);     expect(next.projectOrder).toEqual([project2, project3, project1]);  });   it("reorderProjects is a no-op when dragged key is not in projectOrder", () => {    const project1 = ProjectId.make("project-1");    const project2 = ProjectId.make("project-2");    const initialState = makeUiState({      projectOrder: [project1, project2],    });     const next = reorderProjects(initialState, [ProjectId.make("missing")], [project2]);     expect(next).toBe(initialState);  });   it("reorderProjects moves all member keys of a multi-member group together", () => {    const keyALocal = "env-local:proj-a";    const keyARemote = "env-remote:proj-a";    const keyB = "env-local:proj-b";    const keyC = "env-local:proj-c";    const initialState = makeUiState({      projectOrder: [keyALocal, keyARemote, keyB, keyC],    });     const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyC]);     expect(next.projectOrder).toEqual([keyB, keyC, keyALocal, keyARemote]);  });   it("reorderProjects handles member keys scattered across projectOrder", () => {    const keyALocal = "env-local:proj-a";    const keyB = "env-local:proj-b";    const keyARemote = "env-remote:proj-a";    const keyC = "env-local:proj-c";    const initialState = makeUiState({      projectOrder: [keyALocal, keyB, keyARemote, keyC],    });     const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyC]);     expect(next.projectOrder).toEqual([keyB, keyC, keyALocal, keyARemote]);  });   it("reorderProjects places group after target when dragged from before a non-last target", () => {    const keyALocal = "env-local:proj-a";    const keyARemote = "env-remote:proj-a";    const keyB = "env-local:proj-b";    const keyC = "env-local:proj-c";    const keyD = "env-local:proj-d";    const initialState = makeUiState({      projectOrder: [keyALocal, keyARemote, keyB, keyC, keyD],    });     const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyC]);     expect(next.projectOrder).toEqual([keyB, keyC, keyALocal, keyARemote, keyD]);  });   it("reorderProjects places group before target when dragged from after", () => {    const keyB = "env-local:proj-b";    const keyC = "env-local:proj-c";    const keyALocal = "env-local:proj-a";    const keyARemote = "env-remote:proj-a";    const initialState = makeUiState({      projectOrder: [keyB, keyC, keyALocal, keyARemote],    });     const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyB]);     expect(next.projectOrder).toEqual([keyALocal, keyARemote, keyB, keyC]);  });   it("reorderProjects with multi-member target inserts after first target occurrence", () => {    const keyALocal = "env-local:proj-a";    const keyARemote = "env-remote:proj-a";    const keyBLocal = "env-local:proj-b";    const keyBRemote = "env-remote:proj-b";    const initialState = makeUiState({      projectOrder: [keyALocal, keyARemote, keyBLocal, keyBRemote],    });     const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyBLocal, keyBRemote]);     // Target members may become non-contiguous; this is fine because the    // sidebar groups by logical key using first-occurrence positioning.    expect(next.projectOrder).toEqual([keyBLocal, keyALocal, keyARemote, keyBRemote]);  });   it("reorderProjects is a no-op when dragged group equals target group", () => {    const key1 = "env-local:proj-a";    const key2 = "env-remote:proj-a";    const initialState = makeUiState({      projectOrder: [key1, key2, "env-local:proj-b"],    });     const next = reorderProjects(initialState, [key1, key2], [key1, key2]);     expect(next).toBe(initialState);  });   it("reorderProjects is a no-op when dragged keys are not in projectOrder", () => {    const initialState = makeUiState({      projectOrder: ["env-local:proj-a", "env-local:proj-b"],    });     const next = reorderProjects(initialState, ["env-local:missing"], ["env-local:proj-b"]);     expect(next).toBe(initialState);  });   it("syncProjects preserves current project order during snapshot recovery", () => {    const project1 = ProjectId.make("project-1");    const project2 = ProjectId.make("project-2");    const project3 = ProjectId.make("project-3");    const initialState = makeUiState({      projectExpandedById: {        [project1]: true,        [project2]: false,      },      projectOrder: [project2, project1],    });     const next = syncProjects(initialState, [      { key: project1, cwd: "/tmp/project-1" },      { key: project2, cwd: "/tmp/project-2" },      { key: project3, cwd: "/tmp/project-3" },    ]);     expect(next.projectOrder).toEqual([project2, project1, project3]);    expect(next.projectExpandedById[project2]).toBe(false);  });   it("syncProjects preserves manual order when a project is recreated with the same cwd", () => {    const oldProject1 = ProjectId.make("project-1");    const oldProject2 = ProjectId.make("project-2");    const recreatedProject2 = ProjectId.make("project-2b");    const initialState = syncProjects(      makeUiState({        projectExpandedById: {          [oldProject1]: true,          [oldProject2]: false,        },        projectOrder: [oldProject2, oldProject1],      }),      [        { key: oldProject1, cwd: "/tmp/project-1" },        { key: oldProject2, cwd: "/tmp/project-2" },      ],    );     const next = syncProjects(initialState, [      { key: oldProject1, cwd: "/tmp/project-1" },      { key: recreatedProject2, cwd: "/tmp/project-2" },    ]);     expect(next.projectOrder).toEqual([recreatedProject2, oldProject1]);    expect(next.projectExpandedById[recreatedProject2]).toBe(false);  });   it("syncProjects returns a new state when only project cwd changes", () => {    const project1 = ProjectId.make("project-1");    const initialState = syncProjects(      makeUiState({        projectExpandedById: {          [project1]: false,        },        projectOrder: [project1],      }),      [{ key: project1, cwd: "/tmp/project-1" }],    );     const next = syncProjects(initialState, [{ key: project1, cwd: "/tmp/project-1-renamed" }]);     expect(next).not.toBe(initialState);    expect(next.projectOrder).toEqual([project1]);    expect(next.projectExpandedById[project1]).toBe(false);  });   it("syncThreads prunes missing thread UI state", () => {    const thread1 = ThreadId.make("thread-1");    const thread2 = ThreadId.make("thread-2");    const initialState = makeUiState({      threadLastVisitedAtById: {        [thread1]: "2026-02-25T12:35:00.000Z",        [thread2]: "2026-02-25T12:36:00.000Z",      },      threadChangedFilesExpandedById: {        [thread1]: {          "turn-1": false,        },        [thread2]: {          "turn-2": false,        },      },    });     const next = syncThreads(initialState, [{ key: thread1 }]);     expect(next.threadLastVisitedAtById).toEqual({      [thread1]: "2026-02-25T12:35:00.000Z",    });    expect(next.threadChangedFilesExpandedById).toEqual({      [thread1]: {        "turn-1": false,      },    });  });   it("syncThreads seeds visit state for unseen snapshot threads", () => {    const thread1 = ThreadId.make("thread-1");    const initialState = makeUiState();     const next = syncThreads(initialState, [      {        key: thread1,        seedVisitedAt: "2026-02-25T12:35:00.000Z",      },    ]);     expect(next.threadLastVisitedAtById).toEqual({      [thread1]: "2026-02-25T12:35:00.000Z",    });  });   it("setProjectExpanded updates expansion without touching order", () => {    const project1 = ProjectId.make("project-1");    const initialState = makeUiState({      projectExpandedById: {        [project1]: true,      },      projectOrder: [project1],    });     const next = setProjectExpanded(initialState, project1, false);     expect(next.projectExpandedById[project1]).toBe(false);    expect(next.projectOrder).toEqual([project1]);  });   it("clearThreadUi removes visit state for deleted threads", () => {    const thread1 = ThreadId.make("thread-1");    const initialState = makeUiState({      threadLastVisitedAtById: {        [thread1]: "2026-02-25T12:35:00.000Z",      },      threadChangedFilesExpandedById: {        [thread1]: {          "turn-1": false,        },      },    });     const next = clearThreadUi(initialState, thread1);     expect(next.threadLastVisitedAtById).toEqual({});    expect(next.threadChangedFilesExpandedById).toEqual({});  });   it("setThreadChangedFilesExpanded stores collapsed turns per thread", () => {    const thread1 = ThreadId.make("thread-1");    const initialState = makeUiState();     const next = setThreadChangedFilesExpanded(initialState, thread1, "turn-1", false);     expect(next.threadChangedFilesExpandedById).toEqual({      [thread1]: {        "turn-1": false,      },    });  });   it("setThreadChangedFilesExpanded removes thread overrides when expanded again", () => {    const thread1 = ThreadId.make("thread-1");    const initialState = makeUiState({      threadChangedFilesExpandedById: {        [thread1]: {          "turn-1": false,        },      },    });     const next = setThreadChangedFilesExpanded(initialState, thread1, "turn-1", true);     expect(next.threadChangedFilesExpandedById).toEqual({});  });});