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> = {}): 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<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", () => { 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); });});
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> = {}): 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<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", () => {
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);
});
});