Files
SRCmail/components/email/__tests__/thread-list-item.test.tsx
T

291 lines
9.4 KiB
TypeScript

import { render, screen, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ThreadListItem } from '../thread-list-item';
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
import { useEmailStore } from '@/stores/email-store';
import { groupEmailsByThread } from '@/lib/thread-utils';
import type { Email } from '@/lib/jmap/types';
vi.mock('@/hooks/use-email-drag', () => ({
useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }),
}));
vi.mock('@/stores/auth-store', () => ({
useAuthStore: () => ({ identities: [] }),
}));
const makeEmail = (overrides: Partial<Email> = {}): Email => ({
id: 'email-1',
threadId: 'thread-1',
mailboxIds: { inbox: true },
keywords: { $seen: true },
size: 1000,
receivedAt: '2024-01-15T10:00:00Z',
from: [{ name: 'Alice', email: 'alice@example.com' }],
subject: 'Test Subject',
hasAttachment: false,
...overrides,
});
/**
* A one-message thread, built through the real grouping so the fixture cannot
* drift from what the list actually feeds this component. `ThreadListItem`
* delegates to `SingleEmailItem` at that size, which is what draws every
* single-message row in the app.
*/
function renderRow(email: Email) {
const [thread] = groupEmailsByThread([email]);
return render(
<ThreadListItem
thread={thread}
isExpanded={false}
onToggleExpand={() => {}}
onEmailSelect={() => {}}
/>,
);
}
describe('ThreadListItem tag badge', () => {
beforeEach(() => {
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
mailLayout: 'split',
});
useEmailStore.setState({
selectedEmailIds: new Set<string>(),
selectedMailbox: 'inbox',
});
});
it('does not show a tag badge when the email has no label keyword', () => {
renderRow(makeEmail({ keywords: { $seen: true } }));
expect(screen.getByText('Test Subject')).toBeInTheDocument();
DEFAULT_KEYWORDS.forEach((kw) => {
expect(screen.queryByText(kw.label)).not.toBeInTheDocument();
});
});
it('shows a tag badge for a $label: keyword', () => {
renderRow(makeEmail({ keywords: { $seen: true, '$label:red': true } }));
expect(screen.getByText('Red')).toBeInTheDocument();
});
it('shows a tag badge for the legacy $color: keyword', () => {
renderRow(makeEmail({ keywords: { $seen: true, '$color:blue': true } }));
expect(screen.getByText('Blue')).toBeInTheDocument();
});
it('falls back to the raw id when the tag is not in settings', () => {
// A keyword created by another client, or one whose definition was deleted.
renderRow(makeEmail({ keywords: { $seen: true, '$label:unknown-tag': true } }));
expect(screen.getByText('unknown-tag')).toBeInTheDocument();
});
it('shows a custom tag label', () => {
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS, { id: 'work', label: 'Work', color: 'teal' }],
});
renderRow(makeEmail({ keywords: { $seen: true, '$label:work': true } }));
expect(screen.getByText('Work')).toBeInTheDocument();
});
it('follows a renamed tag definition', () => {
const email = makeEmail({ keywords: { $seen: true, '$label:red': true } });
const { rerender } = renderRow(email);
expect(screen.getByText('Red')).toBeInTheDocument();
act(() => {
useSettingsStore.getState().updateKeyword('red', { label: 'Urgent' });
});
const [thread] = groupEmailsByThread([email]);
rerender(
<ThreadListItem
thread={thread}
isExpanded={false}
onToggleExpand={() => {}}
onEmailSelect={() => {}}
/>,
);
expect(screen.getByText('Urgent')).toBeInTheDocument();
expect(screen.queryByText('Red')).not.toBeInTheDocument();
});
});
describe('ThreadListItem multi-message thread', () => {
beforeEach(() => {
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
mailLayout: 'split',
});
useEmailStore.setState({
selectedEmailIds: new Set<string>(),
selectedMailbox: 'inbox',
});
});
function renderThread(emails: Email[], expanded = false) {
const [thread] = groupEmailsByThread(emails);
return render(
<ThreadListItem
thread={thread}
isExpanded={expanded}
expandedEmails={expanded ? emails : undefined}
onToggleExpand={() => {}}
onEmailSelect={() => {}}
/>,
);
}
it('carries the tags of every message, not just the first', () => {
// A collapsed row stands in for the whole thread, so a tag applied only to
// a later message still has to surface.
renderThread([
makeEmail({ id: 'e1', threadId: 't1', keywords: { '$label:red': true } }),
makeEmail({ id: 'e2', threadId: 't1', keywords: { '$label:blue': true } }),
]);
expect(screen.getByText('Red')).toBeInTheDocument();
expect(screen.getByText('Blue')).toBeInTheDocument();
});
it('names a tag shared by several messages once', () => {
renderThread([
makeEmail({ id: 'e1', threadId: 't1', keywords: { '$label:red': true } }),
makeEmail({ id: 'e2', threadId: 't1', keywords: { '$label:red': true } }),
]);
expect(screen.getAllByText('Red')).toHaveLength(1);
});
it('shows each message its own tags once the thread is expanded', () => {
renderThread(
[
makeEmail({ id: 'e1', threadId: 't1', keywords: { '$label:red': true } }),
makeEmail({ id: 'e2', threadId: 't1', keywords: { '$label:blue': true } }),
],
true,
);
// Once on the header and once on the message that carries it.
expect(screen.getAllByText('Red').length).toBeGreaterThan(1);
});
});
describe('ThreadListItem row content', () => {
beforeEach(() => {
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
mailLayout: 'split',
});
useEmailStore.setState({
selectedEmailIds: new Set<string>(),
selectedMailbox: 'inbox',
});
});
it('renders the subject without a tag', () => {
renderRow(makeEmail({ subject: 'Hello World' }));
expect(screen.getByText('Hello World')).toBeInTheDocument();
});
it('renders preview text inline in the focused layout', () => {
useSettingsStore.setState({ showPreview: true, mailLayout: 'focus' });
const { container } = renderRow(makeEmail({ preview: 'Inline preview content' }));
expect(screen.getByText('Test Subject')).toBeInTheDocument();
expect(screen.getByText(/Inline preview content/)).toBeInTheDocument();
// Focused rows are one line: the preview shares the subject's element
// rather than getting a paragraph of its own.
expect(container.querySelector('p')).toBeNull();
});
});
describe('ThreadListItem shift-range checkbox', () => {
beforeEach(() => {
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
mailLayout: 'split',
});
});
it('shift-clicking the checkbox extends the selection from the anchor', () => {
const e1 = makeEmail({ id: 'e1', threadId: 't1' });
const e2 = makeEmail({ id: 'e2', threadId: 't2' });
const e3 = makeEmail({ id: 'e3', threadId: 't3' });
// Selection mode active so the checkbox renders, with the anchor on e1.
useEmailStore.setState({
emails: [e1, e2, e3],
selectedEmailIds: new Set(['e1']),
lastSelectedEmailId: 'e1',
selectedMailbox: 'inbox',
});
renderRow(e3);
const checkbox = screen.getAllByRole('button')[0];
act(() => {
checkbox.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true }));
});
const selected = useEmailStore.getState().selectedEmailIds;
expect(selected.has('e1')).toBe(true);
expect(selected.has('e2')).toBe(true); // the row in between got filled in
expect(selected.has('e3')).toBe(true);
});
});
describe('ThreadListItem row tint', () => {
const rowClasses = (container: HTMLElement) =>
container.querySelector('[data-email-id="email-1"]')!.className.split(' ');
beforeEach(() => {
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
mailLayout: 'split',
tintListRowsByTag: true,
});
useEmailStore.setState({
selectedEmailIds: new Set(['email-1']),
selectedMailbox: 'inbox',
});
});
it('keeps a checked row tinted, and says so to either theme', () => {
const { container } = renderRow(makeEmail({ keywords: { $seen: true, '$label:red': true } }));
const classes = rowClasses(container);
expect(classes).toContain('bg-red-50');
expect(classes).toContain('dark:bg-red-950/30');
expect(classes).not.toContain('bg-accent/40');
expect(classes).toContain('ring-primary/20');
});
it('washes a checked row that has no tint to keep', () => {
const { container } = renderRow(makeEmail({ keywords: { $seen: true } }));
const classes = rowClasses(container);
expect(classes).toContain('bg-accent/40');
expect(classes).toContain('ring-primary/20');
});
it('leaves the tint alone when the setting is off', () => {
useSettingsStore.setState({ tintListRowsByTag: false });
const { container } = renderRow(makeEmail({ keywords: { $seen: true, '$label:red': true } }));
const classes = rowClasses(container);
expect(classes).not.toContain('bg-red-50');
expect(classes).toContain('bg-accent/40');
});
});