threadSlice.test.ts20.3 KBView on GitHub /**
* Tests for ThreadSlice —
* shallowCompareThreadData (pure function)
* setThreadData (equality skip, temp-draft preservation)
* navigateToNextThread / navigateToPreviousThread
* pushUndo / popUndo (TTL)
* toggleStar / markAsRead / toggleImportant
* populateThreadMetadata (placeholder creation)
*/
import { act } from '@testing-library/react';
import { useCedarStore } from '@/modules/cedar-os/src/store/CedarStore';
import { shallowCompareThreadData } from '@/modules/threads/threadList/store/threadSlice';
import type { ThreadData } from '@/modules/threads/threadList/store/threadSlice';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const makeMessage = (overrides: Record<string, unknown> = {}) => ({
id: 'msg-1',
threadId: 'thread-1',
isDraft: false,
subject: 'Hello',
sender: { email: '<email>', name: 'Alice' },
to: [],
cc: null,
bcc: null,
tls: true,
receivedOn: '2026-01-01T00:00:00.000Z',
unread: false,
processedHtml: '',
blobUrl: '',
tags: [],
snippet: '',
...overrides,
});
const makeThread = (overrides: Partial<ThreadData> = {}): ThreadData => ({
id: 'thread-1',
messages: [makeMessage() as any],
latest: makeMessage() as any,
hasUnread: false,
totalReplies: 1,
labels: [],
lastLoadedAt: Date.now(),
...overrides,
});
const resetThreadState = () =>
useCedarStore.setState((s) => ({
...s,
threadData: {},
selectedThreadId: null,
isThreadOpen: false,
activeConversationId: null,
currentThreadList: [],
focusedIndex: null,
keyboardActive: false,
undoEntry: null,
undoStack: [],
redoStack: [],
bulkSelected: [],
}));
beforeEach(resetThreadState);
afterEach(resetThreadState);
// ---------------------------------------------------------------------------
// shallowCompareThreadData — pure function tests
// ---------------------------------------------------------------------------
describe('shallowCompareThreadData', () => {
it('returns true for identical references', () => {
const t = makeThread();
expect(shallowCompareThreadData(t, t)).toBe(true);
});
it('returns false when one is undefined', () => {
expect(shallowCompareThreadData(makeThread(), undefined)).toBe(false);
expect(shallowCompareThreadData(undefined, makeThread())).toBe(false);
});
it('returns true for deeply-equal threads', () => {
const a = makeThread();
const b = makeThread();
expect(shallowCompareThreadData(a, b)).toBe(true);
});
it('returns false when hasUnread differs', () => {
const a = makeThread({ hasUnread: false });
const b = makeThread({ hasUnread: true });
expect(shallowCompareThreadData(a, b)).toBe(false);
});
it('returns false when totalReplies differs', () => {
const a = makeThread({ totalReplies: 1 });
const b = makeThread({ totalReplies: 2 });
expect(shallowCompareThreadData(a, b)).toBe(false);
});
it('returns false when message count differs', () => {
const a = makeThread({ messages: [makeMessage() as any] });
const b = makeThread({ messages: [makeMessage() as any, makeMessage({ id: 'msg-2' }) as any] });
expect(shallowCompareThreadData(a, b)).toBe(false);
});
it('returns false when latest.id differs', () => {
const a = makeThread({ latest: makeMessage({ id: 'msg-1' }) as any });
const b = makeThread({ latest: makeMessage({ id: 'msg-2' }) as any });
expect(shallowCompareThreadData(a, b)).toBe(false);
});
it('returns false when tag sets differ', () => {
const a = makeThread({ latest: makeMessage({ tags: [{ id: 'STARRED', name: 'STARRED', type: 'system' }] }) as any });
const b = makeThread({ latest: makeMessage({ tags: [] }) as any });
expect(shallowCompareThreadData(a, b)).toBe(false);
});
it('returns true when tag sets are the same regardless of order', () => {
const a = makeThread({
latest: makeMessage({ tags: [{ id: 'A', name: 'A', type: 'system' }, { id: 'B', name: 'B', type: 'system' }] }) as any,
});
const b = makeThread({
latest: makeMessage({ tags: [{ id: 'B', name: 'B', type: 'system' }, { id: 'A', name: 'A', type: 'system' }] }) as any,
});
expect(shallowCompareThreadData(a, b)).toBe(true);
});
});
// ---------------------------------------------------------------------------
// setThreadData — equality skip
// ---------------------------------------------------------------------------
describe('setThreadData equality skip', () => {
it('writes new thread data', () => {
act(() => useCedarStore.getState().setThreadData('thread-1', makeThread()));
expect(useCedarStore.getState().threadData['thread-1']).toBeDefined();
});
it('skips update when data is identical (prevents re-render)', () => {
const thread = makeThread();
act(() => useCedarStore.getState().setThreadData('thread-1', thread));
const ts1 = useCedarStore.getState().threadData['thread-1']?.lastLoadedAt;
// Calling again with the same data should be a no-op (lastLoadedAt unchanged)
act(() => useCedarStore.getState().setThreadData('thread-1', thread));
const ts2 = useCedarStore.getState().threadData['thread-1']?.lastLoadedAt;
expect(ts1).toBe(ts2);
});
it('updates when hasUnread changes', () => {
act(() => useCedarStore.getState().setThreadData('thread-1', makeThread({ hasUnread: false })));
act(() => useCedarStore.getState().setThreadData('thread-1', makeThread({ hasUnread: true })));
expect(useCedarStore.getState().threadData['thread-1']?.hasUnread).toBe(true);
});
});
// ---------------------------------------------------------------------------
// setThreadData — temp-draft preservation
// ---------------------------------------------------------------------------
describe('setThreadData user-edited draft preservation', () => {
it('preserves user-edited local drafts when server data arrives without them', () => {
const localDraft = makeMessage({
id: 'temp-draft-123',
isDraft: true,
draftSessionId: 'sess-1',
userEdited: true,
});
const initialThread = makeThread({ messages: [makeMessage() as any, localDraft as any] });
act(() => useCedarStore.getState().setThreadData('thread-1', initialThread));
// Server sends update without the local draft
const serverThread = makeThread({
hasUnread: true, // trigger an update
messages: [makeMessage() as any],
});
act(() => useCedarStore.getState().setThreadData('thread-1', serverThread));
const messages = useCedarStore.getState().threadData['thread-1']?.messages ?? [];
expect(messages.some((m) => m.id === 'temp-draft-123')).toBe(true);
});
});
// ---------------------------------------------------------------------------
// navigateToNextThread / navigateToPreviousThread
// ---------------------------------------------------------------------------
const seedThreadList = (count: number) => {
const threads = Array.from({ length: count }, (_, i) => ({
id: `thread-${i + 1}`,
historyId: null,
}));
useCedarStore.setState((s) => ({
...s,
currentThreadList: threads,
selectedThreadId: threads[0]?.id ?? null,
focusedIndex: 0,
}));
};
// ---------------------------------------------------------------------------
// setCurrentThreadList — landing on a folder must leave nothing selected
// ---------------------------------------------------------------------------
describe('setCurrentThreadList', () => {
const list = [
{ id: 'thread-1', historyId: null, $raw: { conversationId: 'conv-1' } },
{ id: 'thread-2', historyId: null },
] as never[];
it('does NOT select the first thread when nothing is selected', () => {
act(() => useCedarStore.getState().setCurrentThreadList(list));
expect(useCedarStore.getState().selectedThreadId).toBeNull();
expect(useCedarStore.getState().focusedIndex).toBeNull();
});
it('does NOT push the first thread\'s conversation into activeConversationId', () => {
act(() => useCedarStore.getState().setCurrentThreadList(list));
expect(useCedarStore.getState().activeConversationId).toBeNull();
});
it('keeps focusedIndex aligned with an existing selection', () => {
useCedarStore.setState((s) => ({ ...s, selectedThreadId: 'thread-2' }));
act(() => useCedarStore.getState().setCurrentThreadList(list));
expect(useCedarStore.getState().selectedThreadId).toBe('thread-2');
expect(useCedarStore.getState().focusedIndex).toBe(1);
});
it('leaves `j` landing on the first row even with nothing selected', () => {
act(() => {
useCedarStore.getState().setCurrentThreadList(list);
useCedarStore.getState().navigateToNextThread();
});
expect(useCedarStore.getState().selectedThreadId).toBe('thread-1');
expect(useCedarStore.getState().focusedIndex).toBe(0);
});
});
describe('navigateToNextThread', () => {
it('moves to the next thread', () => {
seedThreadList(3);
act(() => useCedarStore.getState().navigateToNextThread());
expect(useCedarStore.getState().selectedThreadId).toBe('thread-2');
expect(useCedarStore.getState().focusedIndex).toBe(1);
});
it('does not go past the last thread', () => {
seedThreadList(2);
act(() => {
useCedarStore.getState().navigateToNextThread();
useCedarStore.getState().navigateToNextThread(); // attempt to go past end
});
expect(useCedarStore.getState().focusedIndex).toBe(1);
expect(useCedarStore.getState().selectedThreadId).toBe('thread-2');
});
it('clears bulkSelected', () => {
seedThreadList(3);
useCedarStore.setState((s) => ({ ...s, bulkSelected: ['thread-1', 'thread-2'] }));
act(() => useCedarStore.getState().navigateToNextThread());
expect(useCedarStore.getState().bulkSelected).toEqual([]);
});
it('is a no-op when thread list is empty', () => {
expect(() => act(() => useCedarStore.getState().navigateToNextThread())).not.toThrow();
});
});
describe('navigateToPreviousThread', () => {
it('moves to the previous thread', () => {
seedThreadList(3);
useCedarStore.setState((s) => ({ ...s, focusedIndex: 2, selectedThreadId: 'thread-3' }));
act(() => useCedarStore.getState().navigateToPreviousThread());
expect(useCedarStore.getState().selectedThreadId).toBe('thread-2');
expect(useCedarStore.getState().focusedIndex).toBe(1);
});
it('does not go before the first thread', () => {
seedThreadList(3);
useCedarStore.setState((s) => ({ ...s, focusedIndex: 0, selectedThreadId: 'thread-1' }));
act(() => useCedarStore.getState().navigateToPreviousThread());
expect(useCedarStore.getState().focusedIndex).toBe(0);
});
it('starts from the last thread when focusedIndex is null', () => {
seedThreadList(3);
useCedarStore.setState((s) => ({ ...s, focusedIndex: null, selectedThreadId: null }));
act(() => useCedarStore.getState().navigateToPreviousThread());
expect(useCedarStore.getState().focusedIndex).toBe(2);
expect(useCedarStore.getState().selectedThreadId).toBe('thread-3');
});
});
// ---------------------------------------------------------------------------
// pushUndo / popUndo — TTL
// ---------------------------------------------------------------------------
describe('pushUndo / popUndo', () => {
it('pushes and pops an undo entry', () => {
const action = { type: 'markDone' as const, threadIds: ['thread-1'] };
act(() => useCedarStore.getState().pushUndo(action));
const entry = useCedarStore.getState().popUndo();
expect(entry?.action).toEqual(action);
});
it('returns null when no undo entry exists', () => {
expect(useCedarStore.getState().popUndo()).toBeNull();
});
it('clears the entry after popping', () => {
act(() => useCedarStore.getState().pushUndo({ type: 'markDone', threadIds: ['t'] }));
useCedarStore.getState().popUndo();
expect(useCedarStore.getState().undoEntry).toBeNull();
expect(useCedarStore.getState().undoStack).toHaveLength(0);
});
it('returns null and clears entry when TTL (30s) has elapsed', () => {
const action = { type: 'markDone' as const, threadIds: ['thread-1'] };
act(() => useCedarStore.getState().pushUndo(action));
// Backdate the timestamp by more than 30s
useCedarStore.setState((s) => ({
...s,
undoEntry: { action, timestamp: Date.now() - 31_000 },
undoStack: [{ action, timestamp: Date.now() - 31_000 }],
}));
const result = useCedarStore.getState().popUndo();
expect(result).toBeNull();
expect(useCedarStore.getState().undoEntry).toBeNull();
expect(useCedarStore.getState().undoStack).toHaveLength(0);
});
it('supports multiple undos in LIFO order', () => {
const first = { type: 'markDone' as const, threadIds: ['thread-1'] };
const second = { type: 'markDone' as const, threadIds: ['thread-2'] };
const third = { type: 'markDone' as const, threadIds: ['thread-3'] };
act(() => {
useCedarStore.getState().pushUndo(first);
useCedarStore.getState().pushUndo(second);
useCedarStore.getState().pushUndo(third);
});
expect(useCedarStore.getState().popUndo()?.action).toEqual(third);
expect(useCedarStore.getState().popUndo()?.action).toEqual(second);
expect(useCedarStore.getState().popUndo()?.action).toEqual(first);
expect(useCedarStore.getState().popUndo()).toBeNull();
});
it('clears redo stack when pushing a new undo action', () => {
act(() => {
useCedarStore.getState().pushRedo({ type: 'markDone', threadIds: ['thread-0'] });
useCedarStore.getState().pushUndo({ type: 'markDone', threadIds: ['thread-1'] });
});
expect(useCedarStore.getState().redoStack).toHaveLength(0);
});
it('preserves redo stack when pushUndo clearRedo is false', () => {
act(() => {
useCedarStore.getState().pushRedo({ type: 'markDone', threadIds: ['thread-0'] });
useCedarStore.getState().pushUndo(
{ type: 'markDone', threadIds: ['thread-1'] },
{ clearRedo: false },
);
});
expect(useCedarStore.getState().redoStack).toHaveLength(1);
});
});
describe('pushRedo / popRedo', () => {
it('pushes and pops a redo entry', () => {
const action = { type: 'markDone' as const, threadIds: ['thread-1'] };
act(() => useCedarStore.getState().pushRedo(action));
const entry = useCedarStore.getState().popRedo();
expect(entry?.action).toEqual(action);
});
it('returns null when no redo entry exists', () => {
expect(useCedarStore.getState().popRedo()).toBeNull();
});
it('returns null after redo TTL expires', () => {
const action = { type: 'markDone' as const, threadIds: ['thread-1'] };
act(() => useCedarStore.getState().pushRedo(action));
useCedarStore.setState((s) => ({
...s,
redoStack: [{ action, timestamp: Date.now() - 31_000 }],
}));
const entry = useCedarStore.getState().popRedo();
expect(entry).toBeNull();
expect(useCedarStore.getState().redoStack).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// toggleStar / markAsRead / toggleImportant
// ---------------------------------------------------------------------------
const seedThread = (id = 'thread-1', extra: Partial<ThreadData> = {}) => {
useCedarStore.setState((s) => ({
...s,
threadData: {
[id]: makeThread({ id, ...extra }),
},
}));
};
describe('toggleStar', () => {
it('adds STARRED label when starred=true', () => {
seedThread();
act(() => useCedarStore.getState().toggleStar(['thread-1'], true));
expect(useCedarStore.getState().threadData['thread-1']?.labels).toContainEqual(
expect.objectContaining({ name: 'STARRED' }),
);
});
it('removes STARRED label when starred=false', () => {
seedThread('thread-1', {
labels: [{ id: 'STARRED', name: 'STARRED' }],
});
act(() => useCedarStore.getState().toggleStar(['thread-1'], false));
const labels = useCedarStore.getState().threadData['thread-1']?.labels ?? [];
expect(labels.some((l) => l.name === 'STARRED')).toBe(false);
});
it('does not duplicate STARRED label on repeated calls', () => {
seedThread();
act(() => {
useCedarStore.getState().toggleStar(['thread-1'], true);
useCedarStore.getState().toggleStar(['thread-1'], true);
});
const labels = useCedarStore.getState().threadData['thread-1']?.labels ?? [];
expect(labels.filter((l) => l.name === 'STARRED')).toHaveLength(1);
});
it('is a no-op for unknown thread IDs', () => {
expect(() =>
act(() => useCedarStore.getState().toggleStar(['does-not-exist'], true)),
).not.toThrow();
});
});
describe('markAsRead', () => {
it('sets hasUnread to false when read=true', () => {
seedThread('thread-1', { hasUnread: true });
act(() => useCedarStore.getState().markAsRead(['thread-1'], true));
expect(useCedarStore.getState().threadData['thread-1']?.hasUnread).toBe(false);
});
it('sets hasUnread to true when read=false', () => {
seedThread('thread-1', { hasUnread: false });
act(() => useCedarStore.getState().markAsRead(['thread-1'], false));
expect(useCedarStore.getState().threadData['thread-1']?.hasUnread).toBe(true);
});
});
describe('toggleImportant', () => {
it('adds IMPORTANT label when important=true', () => {
seedThread();
act(() => useCedarStore.getState().toggleImportant(['thread-1'], true));
expect(useCedarStore.getState().threadData['thread-1']?.labels).toContainEqual(
expect.objectContaining({ name: 'IMPORTANT' }),
);
});
it('removes IMPORTANT label when important=false', () => {
seedThread('thread-1', {
labels: [{ id: 'IMPORTANT', name: 'IMPORTANT' }],
});
act(() => useCedarStore.getState().toggleImportant(['thread-1'], false));
const labels = useCedarStore.getState().threadData['thread-1']?.labels ?? [];
expect(labels.some((l) => l.name === 'IMPORTANT')).toBe(false);
});
});
// ---------------------------------------------------------------------------
// populateThreadMetadata — placeholder creation
// ---------------------------------------------------------------------------
describe('populateThreadMetadata', () => {
it('creates a skeleton thread when threadData does not exist', () => {
act(() =>
useCedarStore.getState().populateThreadMetadata('thread-new', {
hasUnread: true,
totalReplies: 2,
labels: [],
preview: {
sender: { email: '<email>', name: 'Alice' },
subject: 'Hello',
receivedOn: '2026-01-01T00:00:00.000Z',
to: [],
snippet: '',
},
}),
);
const thread = useCedarStore.getState().threadData['thread-new'];
expect(thread).toBeDefined();
expect(thread?.hasUnread).toBe(true);
});
it('sets latest from preview without placeholder messages for new threads', () => {
act(() =>
useCedarStore.getState().populateThreadMetadata('thread-ph', {
hasUnread: false,
totalReplies: 3,
labels: [],
preview: {
sender: { email: '<email>' },
subject: 'Test',
receivedOn: '2026-01-01T00:00:00.000Z',
to: [],
snippet: '',
},
}),
);
const thread = useCedarStore.getState().threadData['thread-ph'];
expect(thread?.messages).toHaveLength(0);
expect(thread?.latest?.sender.email).toBe('<email>');
expect(thread?.latest?.subject).toBe('Test');
expect(thread?.totalReplies).toBe(3);
});
it('appends placeholders to an existing thread that is missing messages', () => {
seedThread('thread-1', { totalReplies: 1 });
act(() =>
useCedarStore.getState().populateThreadMetadata('thread-1', {
hasUnread: false,
totalReplies: 3,
labels: [],
preview: {
sender: { email: '<email>' },
subject: 'Hello',
receivedOn: '2026-01-01T00:00:00.000Z',
to: [],
snippet: '',
},
}),
);
expect(useCedarStore.getState().threadData['thread-1']?.messages).toHaveLength(3);
});
it('does not add placeholders when messages count already meets totalReplies', () => {
seedThread('thread-1', { totalReplies: 1 });
act(() =>
useCedarStore.getState().populateThreadMetadata('thread-1', {
hasUnread: false,
totalReplies: 1,
labels: [],
}),
);
expect(useCedarStore.getState().threadData['thread-1']?.messages).toHaveLength(1);
});
});