610 lines · 21.1 KB · TypeScript
RawDownload
import { ProjectId, ThreadId } from "@t3tools/contracts";import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import {  clearThreadUi,  hydratePersistedProjectState,  markThreadVisited,  markThreadUnread,  PERSISTED_STATE_KEY,  type PersistedUiState,  persistState,  reorderProjects,  setDefaultAdvertisedEndpointKey,  setProjectExpanded,  setThreadChangedFilesExpanded,  syncProjects,  syncThreads,  type UiState,} from "./uiStateStore"; function makeUiState(overrides: Partial<UiState> = {}): UiState {  return {    projectExpandedById: {},    projectOrder: [],    threadLastVisitedAtById: {},    threadChangedFilesExpandedById: {},    defaultAdvertisedEndpointKey: null,    ...overrides,  };} describe("uiStateStore pure functions", () => {  it("markThreadVisited stores the provided server timestamp", () => {    const threadId = ThreadId.make("thread-1");    const initialState = makeUiState();     const next = markThreadVisited(initialState, threadId, "2026-02-25T12:30:00.700Z");     expect(next.threadLastVisitedAtById[threadId]).toBe("2026-02-25T12:30:00.700Z");  });   it("markThreadVisited does not move visit state backwards under clock skew", () => {    const threadId = ThreadId.make("thread-1");    const initialState = makeUiState({      threadLastVisitedAtById: {        [threadId]: "2026-02-25T12:30:00.700Z",      },    });     const next = markThreadVisited(initialState, threadId, "2026-02-25T12:30:00.000Z");     expect(next).toBe(initialState);  });   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("setDefaultAdvertisedEndpointKey stores endpoint preference by stable key", () => {    const initialState = makeUiState();     const next = setDefaultAdvertisedEndpointKey(initialState, "desktop-core:lan:http");     expect(next.defaultAdvertisedEndpointKey).toBe("desktop-core:lan:http");    expect(setDefaultAdvertisedEndpointKey(next, "desktop-core:lan:http")).toBe(next);    expect(setDefaultAdvertisedEndpointKey(next, "")).toMatchObject({      defaultAdvertisedEndpointKey: null,    });  });   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, logicalKey: project1, cwd: "/tmp/project-1" },      { key: project2, logicalKey: project2, cwd: "/tmp/project-2" },      { key: project3, logicalKey: project3, cwd: "/tmp/project-3" },    ]);     expect(next.projectOrder).toEqual([project2, project1, project3]);    expect(next.projectExpandedById[project2]).toBe(false);  });   it("syncProjects preserves manual order across project id churn at the same cwd", () => {    // Under the current design, physical key and logical key are both    // cwd-derived, so an internal project-id change doesn't alter the store    // keys. This test locks in that stability: re-syncing the same cwds keeps    // manual order and collapse state.    const keyProject1 = "env-local:/tmp/project-1";    const keyProject2 = "env-local:/tmp/project-2";    const initialState = syncProjects(      makeUiState({        projectExpandedById: {          [keyProject1]: true,          [keyProject2]: false,        },        projectOrder: [keyProject2, keyProject1],      }),      [        { key: keyProject1, logicalKey: keyProject1, cwd: "/tmp/project-1" },        { key: keyProject2, logicalKey: keyProject2, cwd: "/tmp/project-2" },      ],    );     const next = syncProjects(initialState, [      { key: keyProject1, logicalKey: keyProject1, cwd: "/tmp/project-1" },      { key: keyProject2, logicalKey: keyProject2, cwd: "/tmp/project-2" },    ]);     expect(next.projectOrder).toEqual([keyProject2, keyProject1]);    expect(next.projectExpandedById[keyProject2]).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, logicalKey: project1, cwd: "/tmp/project-1" }],    );     const next = syncProjects(initialState, [      { key: project1, logicalKey: project1, cwd: "/tmp/project-1-renamed" },    ]);     expect(next).not.toBe(initialState);    expect(next.projectOrder).toEqual([project1]);    expect(next.projectExpandedById[project1]).toBe(false);  });   it("syncProjects keys projectExpandedById by the logical key, not the physical key", () => {    // In repository grouping mode, multiple physical projects (different    // environments or different repo-relative paths) collapse into one    // logical group. The group's expand state must be keyed by the logical    // key so clicks on the grouped row toggle the shared state, and so the    // state survives subsequent syncProjects calls (which rebuild the map    // from incoming inputs).    const physicalLocal = "env-local:/repo/project";    const physicalRemote = "env-remote:/repo/project";    const logicalKey = "repo-canonical-key";     const initial = syncProjects(makeUiState(), [      { key: physicalLocal, logicalKey, cwd: "/repo/project" },      { key: physicalRemote, logicalKey, cwd: "/repo/project" },    ]);     expect(initial.projectExpandedById).toEqual({ [logicalKey]: true });     const afterCollapse = { ...initial, projectExpandedById: { [logicalKey]: false } };    const next = syncProjects(afterCollapse, [      { key: physicalLocal, logicalKey, cwd: "/repo/project" },      { key: physicalRemote, logicalKey, cwd: "/repo/project" },    ]);     expect(next.projectExpandedById[logicalKey]).toBe(false);  });   it("syncProjects preserves expand state when a project's logical key changes", () => {    // Example: late-arriving repo metadata flips grouping identity from the    // physical key to a canonical repository key. The row did not actually    // change, so the user's collapse choice must carry over.    const physicalKey = "env-local:/repo/project";    const previousLogicalKey = physicalKey;    const nextLogicalKey = "repo-canonical-key";     const initial = syncProjects(makeUiState(), [      { key: physicalKey, logicalKey: previousLogicalKey, cwd: "/repo/project" },    ]);     expect(initial.projectExpandedById[previousLogicalKey]).toBe(true);     const afterCollapse = {      ...initial,      projectExpandedById: { [previousLogicalKey]: false },    };    const next = syncProjects(afterCollapse, [      { key: physicalKey, logicalKey: nextLogicalKey, cwd: "/repo/project" },    ]);     expect(next.projectExpandedById[nextLogicalKey]).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({});  });}); function createLocalStorageStub(): Storage {  const store = new Map<string, string>();  return {    clear: () => {      store.clear();    },    getItem: (key) => store.get(key) ?? null,    key: (index) => [...store.keys()][index] ?? null,    get length() {      return store.size;    },    removeItem: (key) => {      store.delete(key);    },    setItem: (key, value) => {      store.set(key, value);    },  };} describe("uiStateStore persistence round-trip", () => {  let localStorageStub: Storage;   beforeEach(() => {    localStorageStub = createLocalStorageStub();    vi.stubGlobal("window", { localStorage: localStorageStub });    vi.stubGlobal("localStorage", localStorageStub);    // Reset module-level persistence state so tests don't bleed into each other.    hydratePersistedProjectState({ collapsedProjectCwds: [], expandedProjectCwds: [] });  });   afterEach(() => {    vi.unstubAllGlobals();  });   it("preserves all-collapsed project state across restart", () => {    // Regression: pre-fix, persistState only wrote `expandedProjectCwds`, so    // an empty array on rehydrate was indistinguishable from a fresh install    // and the syncProjects fallback re-expanded every row.    const projectA = { key: "kA", logicalKey: "kA", cwd: "/projA" };    const projectB = { key: "kB", logicalKey: "kB", cwd: "/projB" };     let state = syncProjects(makeUiState(), [projectA, projectB]);    state = setProjectExpanded(state, projectA.key, false);    state = setProjectExpanded(state, projectB.key, false);    persistState(state);     const persisted = JSON.parse(      localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}",    ) as PersistedUiState;    hydratePersistedProjectState(persisted);    const rehydrated = syncProjects(makeUiState(), [projectA, projectB]); 

Lines 1–500 of 610.