Files
Remote-Terminal-for-MeshCore/frontend/src/test/searchView.test.tsx
T
2026-04-19 19:31:26 -07:00

355 lines
11 KiB
TypeScript

import { fireEvent, render, screen, act } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Message } from '../types';
const mockGetMessages = vi.fn<(...args: unknown[]) => Promise<Message[]>>();
vi.mock('../api', () => ({
api: {
getMessages: (...args: unknown[]) => mockGetMessages(...args),
},
isAbortError: (err: unknown) => err instanceof DOMException && err.name === 'AbortError',
}));
import { SearchView } from '../components/SearchView';
function createSearchResult(overrides: Partial<Message> = {}): Message {
return {
id: 1,
type: 'CHAN',
conversation_key: 'ABC123',
text: 'hello world',
sender_timestamp: 1700000000,
received_at: 1700000000,
paths: null,
txt_type: 0,
signature: null,
sender_key: null,
outgoing: false,
acked: 0,
sender_name: 'Alice',
...overrides,
};
}
const defaultProps = {
contacts: [],
channels: [
{
key: 'ABC123',
name: 'Public',
is_hashtag: true,
on_radio: false,
last_read_at: null,
favorite: false,
muted: false,
},
],
onNavigateToMessage: vi.fn(),
};
/** Type the query into the search input and wait for debounced results to render. */
async function typeAndWaitForResults(query: string) {
const input = screen.getByLabelText('Search messages');
// Use fake timers only for the debounce, then switch to real timers for
// React's async state updates and waitFor polling.
vi.useFakeTimers();
await act(async () => {
fireEvent.change(input, { target: { value: query } });
vi.advanceTimersByTime(350);
});
vi.useRealTimers();
// Wait for the mock API promise to resolve and React to commit
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
}
describe('SearchView', () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetMessages.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it('renders empty state with prompt text', () => {
mockGetMessages.mockResolvedValue([]);
render(<SearchView {...defaultProps} />);
expect(screen.getByText('Type to search across all messages')).toBeInTheDocument();
expect(screen.getByText(/Tip: use/i)).toBeInTheDocument();
expect(
screen.getByText(/User-key linkage for group messages is best-effort/i)
).toBeInTheDocument();
});
it('focuses input on mount', () => {
mockGetMessages.mockResolvedValue([]);
render(<SearchView {...defaultProps} />);
expect(screen.getByLabelText('Search messages')).toHaveFocus();
});
it('debounces search input', async () => {
mockGetMessages.mockResolvedValue([]);
vi.useFakeTimers();
render(<SearchView {...defaultProps} />);
const input = screen.getByLabelText('Search messages');
await act(async () => {
fireEvent.change(input, { target: { value: 'hello' } });
});
// Should not have called API yet (within debounce window)
expect(mockGetMessages).not.toHaveBeenCalled();
// Advance past debounce timer
await act(async () => {
vi.advanceTimersByTime(350);
});
vi.useRealTimers();
expect(mockGetMessages).toHaveBeenCalledTimes(1);
expect(mockGetMessages).toHaveBeenCalledWith(
expect.objectContaining({ q: 'hello' }),
expect.any(AbortSignal)
);
});
it('displays search results', async () => {
mockGetMessages.mockResolvedValue([
createSearchResult({ id: 1, text: 'hello world', sender_name: 'Alice' }),
createSearchResult({ id: 2, text: 'hello there', sender_name: 'Bob' }),
]);
render(<SearchView {...defaultProps} />);
await typeAndWaitForResults('hello');
// Text is split by highlightMatch into segments, so use container text content
const buttons = screen.getAllByRole('button');
const texts = buttons.map((b) => b.textContent);
expect(texts.some((t) => t?.includes('hello world') || t?.includes('world'))).toBe(true);
expect(texts.some((t) => t?.includes('hello there') || t?.includes('there'))).toBe(true);
});
it('shows no-results message when search returns empty', async () => {
mockGetMessages.mockResolvedValue([]);
render(<SearchView {...defaultProps} />);
await typeAndWaitForResults('nonexistent');
expect(screen.getByText(/No messages found/)).toBeInTheDocument();
});
it('navigates to message on click', async () => {
const result = createSearchResult({
id: 42,
type: 'CHAN',
conversation_key: 'ABC123',
text: 'click me',
});
mockGetMessages.mockResolvedValue([result]);
const onNavigate = vi.fn();
render(<SearchView {...defaultProps} onNavigateToMessage={onNavigate} />);
await typeAndWaitForResults('click');
const resultBtn = screen.getAllByRole('button').find((b) => b.textContent?.includes('me'));
expect(resultBtn).toBeDefined();
fireEvent.click(resultBtn!);
expect(onNavigate).toHaveBeenCalledWith({
id: 42,
type: 'CHAN',
conversation_key: 'ABC123',
conversation_name: 'Public',
});
});
it('navigates on Enter key', async () => {
mockGetMessages.mockResolvedValue([createSearchResult({ id: 10, text: 'keyboard nav' })]);
const onNavigate = vi.fn();
render(<SearchView {...defaultProps} onNavigateToMessage={onNavigate} />);
await typeAndWaitForResults('keyboard');
const resultEl = screen.getByRole('button', { name: /keyboard nav/i });
fireEvent.keyDown(resultEl, { key: 'Enter' });
expect(onNavigate).toHaveBeenCalled();
});
it('shows load more button when results fill a page', async () => {
const pageResults = Array.from({ length: 50 }, (_, i) =>
createSearchResult({ id: i + 1, text: `result ${i}` })
);
mockGetMessages.mockResolvedValueOnce(pageResults);
render(<SearchView {...defaultProps} />);
await typeAndWaitForResults('result');
expect(screen.getByText('Load more results')).toBeInTheDocument();
});
it('does not show load more when results are less than page size', async () => {
mockGetMessages.mockResolvedValue([createSearchResult({ id: 1, text: 'only one' })]);
render(<SearchView {...defaultProps} />);
await typeAndWaitForResults('only');
const resultBtns = screen.getAllByRole('button');
expect(resultBtns.some((b) => b.textContent?.includes('one'))).toBe(true);
expect(screen.queryByText('Load more results')).not.toBeInTheDocument();
});
it('resolves channel name from channels prop', async () => {
mockGetMessages.mockResolvedValue([
createSearchResult({ id: 1, type: 'CHAN', conversation_key: 'ABC123', text: 'test' }),
]);
render(<SearchView {...defaultProps} />);
await typeAndWaitForResults('test');
expect(screen.getByText('Public')).toBeInTheDocument();
});
it('resolves contact name from contacts prop', async () => {
const contactKey = 'aa'.repeat(32);
mockGetMessages.mockResolvedValue([
createSearchResult({
id: 1,
type: 'PRIV',
conversation_key: contactKey,
text: 'dm test',
}),
]);
render(
<SearchView
{...defaultProps}
contacts={[
{
public_key: contactKey,
name: 'Bob',
type: 1,
flags: 0,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: 0,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
first_seen: null,
last_read_at: null,
},
]}
/>
);
await typeAndWaitForResults('dm');
expect(screen.getByText('Bob')).toBeInTheDocument();
});
it('passes raw operator queries to the API and highlights only free text', async () => {
mockGetMessages.mockResolvedValue([createSearchResult({ text: 'hello world' })]);
render(<SearchView {...defaultProps} />);
await typeAndWaitForResults('user:Alice hello');
expect(mockGetMessages).toHaveBeenCalledWith(
expect.objectContaining({ q: 'user:Alice hello' }),
expect.any(AbortSignal)
);
expect(screen.getByText('hello', { selector: 'mark' })).toBeInTheDocument();
expect(screen.queryByText('user:Alice', { selector: 'mark' })).not.toBeInTheDocument();
});
it('runs a prefilled search immediately', async () => {
mockGetMessages.mockResolvedValue([createSearchResult({ text: 'prefilled result' })]);
render(
<SearchView {...defaultProps} prefillRequest={{ query: 'user:"Alice Smith"', nonce: 1 }} />
);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(screen.getByLabelText('Search messages')).toHaveValue('user:"Alice Smith"');
expect(mockGetMessages).toHaveBeenCalledWith(
expect.objectContaining({ q: 'user:"Alice Smith"' }),
expect.any(AbortSignal)
);
});
it('refetches current results when visibility policy changes', async () => {
mockGetMessages
.mockResolvedValueOnce([createSearchResult({ id: 1, text: 'visible result' })])
.mockResolvedValueOnce([]);
const { rerender } = render(<SearchView {...defaultProps} visibilityVersion={0} />);
await typeAndWaitForResults('visible');
expect(mockGetMessages).toHaveBeenCalledTimes(1);
expect(
screen.getAllByRole('button').some((button) => button.textContent?.includes('visible result'))
).toBe(true);
rerender(<SearchView {...defaultProps} visibilityVersion={1} />);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(mockGetMessages).toHaveBeenCalledTimes(2);
expect(mockGetMessages).toHaveBeenLastCalledWith(
expect.objectContaining({ q: 'visible' }),
expect.any(AbortSignal)
);
expect(screen.getByText(/No messages found/)).toBeInTheDocument();
});
it('aborts the load-more request on unmount', async () => {
const pageResults = Array.from({ length: 50 }, (_, i) =>
createSearchResult({ id: i + 1, text: `result ${i}` })
);
let resolveLoadMore: ((value: Message[]) => void) | null = null;
mockGetMessages.mockResolvedValueOnce(pageResults).mockImplementationOnce(
() =>
new Promise<Message[]>((resolve) => {
resolveLoadMore = resolve;
})
);
const { unmount } = render(<SearchView {...defaultProps} />);
await typeAndWaitForResults('result');
fireEvent.click(screen.getByText('Load more results'));
const loadMoreSignal = mockGetMessages.mock.calls[1]?.[1] as AbortSignal | undefined;
expect(loadMoreSignal).toBeInstanceOf(AbortSignal);
expect(loadMoreSignal?.aborted).toBe(false);
unmount();
expect(loadMoreSignal?.aborted).toBe(true);
await act(async () => {
resolveLoadMore?.([createSearchResult({ id: 99, text: 'late result' })]);
await Promise.resolve();
});
});
});