import { ProjectId, ThreadId } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { legacyProjectCwdPreferenceKey, markThreadUnread, markThreadVisited, parsePersistedState, PERSISTED_STATE_KEY, type PersistedUiState, persistState, reorderProjects, resolveProjectExpanded, setDefaultAdvertisedEndpointKey, setProjectExpanded, setThreadChangedFilesExpanded, type UiState, } from "./uiStateStore"; function makeUiState(overrides: Partial = {}): UiState { return { projectExpandedById: {}, projectOrder: [], threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, defaultAdvertisedEndpointKey: null, ...overrides, }; } describe("uiStateStore pure functions", () => { it("stores server timestamps without moving visit state backwards", () => { const threadId = ThreadId.make("thread-1"); const initialState = makeUiState(); const visited = markThreadVisited(initialState, threadId, "2026-02-25T12:30:00.700Z"); expect(visited.threadLastVisitedAtById[threadId]).toBe("2026-02-25T12:30:00.700Z"); expect(markThreadVisited(visited, threadId, "2026-02-25T12:30:00.000Z")).toBe(visited); expect(markThreadVisited(visited, threadId, "not-a-date")).toBe(visited); }); it("marks a completed thread unread using the server completion timestamp", () => { const threadId = ThreadId.make("thread-1"); const initialState = makeUiState({ threadLastVisitedAtById: { [threadId]: "2026-02-25T12:35:00.000Z", }, }); const next = markThreadUnread(initialState, threadId, "2026-02-25T12:30:00.000Z"); expect(next.threadLastVisitedAtById[threadId]).toBe("2026-02-25T12:29:59.999Z"); expect(markThreadUnread(next, threadId, null)).toBe(next); }); it("resolves project expansion from logical, physical, and legacy preference keys", () => { const physicalKey = "environment:/repo/project"; const legacyKey = legacyProjectCwdPreferenceKey("/repo/project"); expect(resolveProjectExpanded({ logical: false, [physicalKey]: true }, ["logical"])).toBe( false, ); expect(resolveProjectExpanded({ [physicalKey]: false }, ["new-logical", physicalKey])).toBe( false, ); expect(resolveProjectExpanded({ [legacyKey]: false }, ["new-logical", legacyKey])).toBe(false); expect(resolveProjectExpanded({}, ["new-logical"])).toBe(true); }); it("sets expansion for every stable key belonging to a logical project", () => { const initialState = makeUiState(); const keys = ["logical", "environment-a:/repo", "environment-b:/repo"]; const next = setProjectExpanded(initialState, keys, false); expect(next.projectExpandedById).toEqual({ logical: false, "environment-a:/repo": false, "environment-b:/repo": false, }); expect(setProjectExpanded(next, keys, false)).toBe(next); }); it("reorders from the current atom-derived project order", () => { const project1 = ProjectId.make("project-1"); const project2 = ProjectId.make("project-2"); const project3 = ProjectId.make("project-3"); const currentOrder = [project1, project2, project3]; const next = reorderProjects(makeUiState(), currentOrder, [project1], [project3]); expect(next.projectOrder).toEqual([project2, project3, project1]); }); it("moves grouped project members 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 currentOrder = [keyALocal, keyARemote, keyB, keyC]; const next = reorderProjects(makeUiState(), currentOrder, [keyALocal, keyARemote], [keyC]); expect(next.projectOrder).toEqual([keyB, keyC, keyALocal, keyARemote]); }); it("does not reorder missing or identical groups", () => { const currentOrder = ["env-local:proj-a", "env-local:proj-b"]; const state = makeUiState(); expect(reorderProjects(state, currentOrder, ["env-local:missing"], ["env-local:proj-b"])).toBe( state, ); expect(reorderProjects(state, currentOrder, ["env-local:proj-a"], ["env-local:proj-a"])).toBe( state, ); }); it("stores explicit changed-file expansion choices", () => { const threadId = ThreadId.make("thread-1"); const collapsed = setThreadChangedFilesExpanded(makeUiState(), threadId, "turn-1", false); expect(collapsed.threadChangedFilesExpandedById).toEqual({ [threadId]: { "turn-1": false, }, }); expect( setThreadChangedFilesExpanded(collapsed, threadId, "turn-1", true) .threadChangedFilesExpandedById, ).toEqual({ [threadId]: { "turn-1": true, }, }); }); it("stores the endpoint preference by stable key", () => { const next = setDefaultAdvertisedEndpointKey(makeUiState(), "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, }); }); }); describe("parsePersistedState", () => { it("hydrates raw UI-owned state without server entities", () => { const parsed = parsePersistedState({ projectExpandedById: { logical: false, invalid: "no" as unknown as boolean, }, projectOrder: ["physical-b", "", "physical-a", "physical-b"], threadLastVisitedAtById: { "environment:thread-1": "2026-02-25T12:35:00.000Z", invalid: "not-a-date", }, defaultAdvertisedEndpointKey: "desktop-core:lan:http", threadChangedFilesExpansionVersion: 1, threadChangedFilesExpandedById: { "environment:thread-1": { "turn-1": false, "turn-2": true, }, }, }); expect(parsed).toEqual({ projectExpandedById: { logical: false, }, projectOrder: ["physical-b", "physical-a"], threadLastVisitedAtById: { "environment:thread-1": "2026-02-25T12:35:00.000Z", }, defaultAdvertisedEndpointKey: "desktop-core:lan:http", threadChangedFilesExpandedById: { "environment:thread-1": { "turn-1": false, "turn-2": true, }, }, }); }); it("ignores changed-file expansion values saved with legacy folder semantics", () => { const parsed = parsePersistedState({ threadChangedFilesExpandedById: { "environment:thread-1": { "turn-1": false, }, }, }); expect(parsed.threadChangedFilesExpandedById).toEqual({}); }); it("migrates legacy CWD project preferences into local alias keys", () => { const parsed = parsePersistedState({ collapsedProjectCwds: ["/repo/b"], expandedProjectCwds: ["/repo/a"], projectOrderCwds: ["/repo/b", "/repo/a"], }); const projectAKey = legacyProjectCwdPreferenceKey("/repo/a"); const projectBKey = legacyProjectCwdPreferenceKey("/repo/b"); expect(parsed.projectOrder).toEqual([projectBKey, projectAKey]); expect(resolveProjectExpanded(parsed.projectExpandedById, [projectAKey])).toBe(true); expect(resolveProjectExpanded(parsed.projectExpandedById, [projectBKey])).toBe(false); expect(resolveProjectExpanded(parsed.projectExpandedById, ["unknown"])).toBe(true); }); it("preserves legacy expanded-only semantics for one-way migration", () => { const parsed = parsePersistedState({ expandedProjectCwds: ["/repo/a"], }); expect( resolveProjectExpanded(parsed.projectExpandedById, [ legacyProjectCwdPreferenceKey("/repo/a"), ]), ).toBe(true); expect( resolveProjectExpanded(parsed.projectExpandedById, [ legacyProjectCwdPreferenceKey("/repo/b"), ]), ).toBe(false); }); }); function createLocalStorageStub(): Storage { const store = new Map(); 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", () => { let localStorageStub: Storage; beforeEach(() => { localStorageStub = createLocalStorageStub(); vi.stubGlobal("window", { localStorage: localStorageStub }); vi.stubGlobal("localStorage", localStorageStub); }); afterEach(() => { vi.unstubAllGlobals(); }); it("persists raw UI preferences including thread visit markers", () => { const state = makeUiState({ projectExpandedById: { logical: false, }, projectOrder: ["physical-b", "physical-a"], threadLastVisitedAtById: { "environment:thread-1": "2026-02-25T12:35:00.000Z", }, threadChangedFilesExpandedById: { "environment:thread-1": { "turn-1": false, "turn-2": true, }, }, defaultAdvertisedEndpointKey: "desktop-core:lan:http", }); persistState(state); const persisted = JSON.parse( localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", ) as PersistedUiState; expect(persisted).toEqual({ projectExpandedById: { logical: false, }, projectOrder: ["physical-b", "physical-a"], threadLastVisitedAtById: { "environment:thread-1": "2026-02-25T12:35:00.000Z", }, defaultAdvertisedEndpointKey: "desktop-core:lan:http", threadChangedFilesExpansionVersion: 1, threadChangedFilesExpandedById: { "environment:thread-1": { "turn-1": false, "turn-2": true, }, }, }); expect(parsePersistedState(persisted)).toEqual({ ...state, }); }); it("drops the temporary expanded-only migration fallback when rewriting state", () => { const migrated = parsePersistedState({ expandedProjectCwds: ["/repo/a"], }); persistState(migrated); const persisted = JSON.parse( localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", ) as PersistedUiState; expect(resolveProjectExpanded(persisted.projectExpandedById ?? {}, ["unknown"])).toBe(true); }); });