Files
webmail/stores/__tests__/email-selection.test.ts
T

229 lines
8.8 KiB
TypeScript

import { describe, it, expect, beforeEach } from 'vitest';
import { useEmailStore } from '../email-store';
function makeEmail(id: string, threadId = `thread-${id}`) {
return {
id,
threadId,
mailboxIds: { inbox: true },
keywords: {},
size: 100,
receivedAt: new Date().toISOString(),
from: [{ name: 'Test', email: 'test@example.com' }],
to: [{ name: 'User', email: 'user@example.com' }],
subject: `Email ${id}`,
preview: 'preview',
hasAttachment: false,
textBody: [],
htmlBody: [],
bodyValues: {},
};
}
describe('email-store selection', () => {
beforeEach(() => {
useEmailStore.setState({
emails: [makeEmail('a'), makeEmail('b'), makeEmail('c'), makeEmail('d'), makeEmail('e')],
selectedEmailKeys: new Set(),
lastSelectedEmailKey: null,
selectedEmail: null,
listRows: null,
});
});
describe('toggleEmailSelection', () => {
it('should add email to selection', () => {
useEmailStore.getState().toggleEmailSelection({ id: 'b' });
expect(useEmailStore.getState().selectedEmailKeys.has('b')).toBe(true);
expect(useEmailStore.getState().lastSelectedEmailKey).toBe('b');
});
it('should remove email from selection when toggled again', () => {
useEmailStore.getState().toggleEmailSelection({ id: 'b' });
useEmailStore.getState().toggleEmailSelection({ id: 'b' });
expect(useEmailStore.getState().selectedEmailKeys.has('b')).toBe(false);
});
it('should support selecting multiple emails', () => {
useEmailStore.getState().toggleEmailSelection({ id: 'a' });
useEmailStore.getState().toggleEmailSelection({ id: 'c' });
const ids = useEmailStore.getState().selectedEmailKeys;
expect(ids.has('a')).toBe(true);
expect(ids.has('c')).toBe(true);
expect(ids.size).toBe(2);
});
});
describe('selectRangeEmails', () => {
it('should select range from last selected to target (forward)', () => {
useEmailStore.getState().toggleEmailSelection({ id: 'b' }); // anchor at index 1
useEmailStore.getState().selectRangeEmails({ id: 'd' }); // target at index 3
const ids = useEmailStore.getState().selectedEmailKeys;
expect(ids.has('b')).toBe(true);
expect(ids.has('c')).toBe(true);
expect(ids.has('d')).toBe(true);
expect(ids.size).toBe(3);
});
it('should select range backward', () => {
useEmailStore.getState().toggleEmailSelection({ id: 'd' }); // anchor at index 3
useEmailStore.getState().selectRangeEmails({ id: 'b' }); // target at index 1
const ids = useEmailStore.getState().selectedEmailKeys;
expect(ids.has('b')).toBe(true);
expect(ids.has('c')).toBe(true);
expect(ids.has('d')).toBe(true);
expect(ids.size).toBe(3);
});
it('should use first email as anchor when no previous selection', () => {
useEmailStore.getState().selectRangeEmails({ id: 'c' }); // no anchor → uses first email 'a'
const ids = useEmailStore.getState().selectedEmailKeys;
expect(ids.has('a')).toBe(true);
expect(ids.has('b')).toBe(true);
expect(ids.has('c')).toBe(true);
expect(ids.size).toBe(3);
});
it('should add to existing selection', () => {
useEmailStore.getState().toggleEmailSelection({ id: 'a' });
useEmailStore.getState().toggleEmailSelection({ id: 'b' }); // anchor now at 'b'
useEmailStore.getState().selectRangeEmails({ id: 'd' });
const ids = useEmailStore.getState().selectedEmailKeys;
// 'a' still selected, plus b-d range
expect(ids.has('a')).toBe(true);
expect(ids.has('b')).toBe(true);
expect(ids.has('c')).toBe(true);
expect(ids.has('d')).toBe(true);
expect(ids.size).toBe(4);
});
it('should handle single-item range', () => {
useEmailStore.getState().toggleEmailSelection({ id: 'c' });
useEmailStore.getState().selectRangeEmails({ id: 'c' });
const ids = useEmailStore.getState().selectedEmailKeys;
expect(ids.has('c')).toBe(true);
expect(ids.size).toBe(1);
});
});
// #1230: the list shows a thread once, at its newest message. A range used
// to walk the flat date order, so a thread whose older message fell between
// the two ends got pulled in although its row was outside the range.
describe('selectRangeEmails over list rows', () => {
// Flat date order: t3 17:00, A 16:45, B 16:00, C 15:15, t2 15:00, D 14:30, t1 13:00.
const flat = ['t3', 'A', 'B', 'C', 't2', 'D', 't1'];
const rows = [
{ key: 't3', members: ['t3', 't2', 't1'] },
{ key: 'A', members: ['A'] },
{ key: 'B', members: ['B'] },
{ key: 'C', members: ['C'] },
{ key: 'D', members: ['D'] },
];
beforeEach(() => {
useEmailStore.setState({
emails: flat.map((id) => makeEmail(id, id.startsWith('t') ? 'thread-t' : `thread-${id}`)),
listRows: rows,
});
});
it('selects the rows between the two ends, not a thread dated between them', () => {
useEmailStore.getState().toggleEmailSelection({ id: 'D' });
useEmailStore.getState().selectRangeEmails({ id: 'B' });
expect([...useEmailStore.getState().selectedEmailKeys].sort()).toEqual(['B', 'C', 'D']);
});
it('selects every message of a thread whose row is in the range', () => {
useEmailStore.getState().toggleEmailSelection({ id: 'B' });
useEmailStore.getState().selectRangeEmails({ id: 't3' });
expect([...useEmailStore.getState().selectedEmailKeys].sort()).toEqual(['A', 'B', 't1', 't2', 't3']);
});
it('finds a thread row from an older message of that thread', () => {
useEmailStore.setState({ lastSelectedEmailKey: 't1' });
useEmailStore.getState().selectRangeEmails({ id: 'A' });
expect([...useEmailStore.getState().selectedEmailKeys].sort()).toEqual(['A', 't1', 't2', 't3']);
});
});
// #992: Shift+Arrow grows the selection from the open message one row at a
// time and shrinks it again when the direction reverses.
describe('extendSelection', () => {
const keys = () => [...useEmailStore.getState().selectedEmailKeys].sort();
beforeEach(() => {
// Opening a message makes it the anchor, as selectEmail does.
useEmailStore.setState({ lastSelectedEmailKey: 'b', selectionFocus: null });
});
it('selects the open message and the next one on the first press', () => {
useEmailStore.getState().extendSelection(1);
expect(keys()).toEqual(['b', 'c']);
});
it('keeps growing in the same direction', () => {
useEmailStore.getState().extendSelection(1);
useEmailStore.getState().extendSelection(1);
expect(keys()).toEqual(['b', 'c', 'd']);
});
it('shrinks back when the direction reverses', () => {
useEmailStore.getState().extendSelection(1);
useEmailStore.getState().extendSelection(1);
useEmailStore.getState().extendSelection(-1);
expect(keys()).toEqual(['b', 'c']);
});
it('crosses the anchor and grows the other way', () => {
useEmailStore.getState().extendSelection(1);
useEmailStore.getState().extendSelection(-1);
useEmailStore.getState().extendSelection(-1);
expect(keys()).toEqual(['a', 'b']);
});
it('stops at the end of the list', () => {
for (let i = 0; i < 10; i++) useEmailStore.getState().extendSelection(1);
expect(keys()).toEqual(['b', 'c', 'd', 'e']);
});
it('continues a shift-click range', () => {
useEmailStore.getState().selectRangeEmails({ id: 'd' });
useEmailStore.getState().extendSelection(-1);
expect(keys()).toEqual(['b', 'c']);
});
it('walks list rows, taking a whole thread with its row', () => {
useEmailStore.setState({
emails: ['t2', 'x', 't1', 'y'].map((id) => makeEmail(id, id.startsWith('t') ? 'thread-t' : `thread-${id}`)),
listRows: [
{ key: 'x', members: ['x'] },
{ key: 't2', members: ['t2', 't1'] },
{ key: 'y', members: ['y'] },
],
lastSelectedEmailKey: 'x',
});
useEmailStore.getState().extendSelection(1);
expect(keys()).toEqual(['t1', 't2', 'x']);
useEmailStore.getState().extendSelection(-1);
expect(keys()).toEqual(['x']);
});
});
describe('selectAllEmails', () => {
it('should select all emails', () => {
useEmailStore.getState().selectAllEmails();
expect(useEmailStore.getState().selectedEmailKeys.size).toBe(5);
});
});
describe('clearSelection', () => {
it('should clear all selections and reset anchor', () => {
useEmailStore.getState().toggleEmailSelection({ id: 'a' });
useEmailStore.getState().toggleEmailSelection({ id: 'b' });
useEmailStore.getState().clearSelection();
expect(useEmailStore.getState().selectedEmailKeys.size).toBe(0);
expect(useEmailStore.getState().lastSelectedEmailKey).toBeNull();
});
});
});