mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-17 06:46:02 +02:00
355 lines
11 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|