Files
Remote-Terminal-for-MeshCore/frontend/src/test/appSearchJump.test.tsx
2026-03-08 13:54:06 -07:00

262 lines
6.8 KiB
TypeScript

import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const PUBLIC_CHANNEL_KEY = '8B3387E9C5CDEA6AC9E5EDBAA115CD72';
const mocks = vi.hoisted(() => ({
api: {
getRadioConfig: vi.fn(),
getSettings: vi.fn(),
getUndecryptedPacketCount: vi.fn(),
getChannels: vi.fn(),
getContacts: vi.fn(),
migratePreferences: vi.fn(),
},
useConversationMessagesCalls: vi.fn(),
}));
vi.mock('../api', () => ({
api: mocks.api,
}));
vi.mock('../useWebSocket', () => ({
useWebSocket: vi.fn(),
}));
vi.mock('../hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('../hooks')>();
return {
...actual,
useConversationMessages: (activeConversation: unknown, targetMessageId: number | null) => {
mocks.useConversationMessagesCalls(activeConversation, targetMessageId);
return {
messages: [],
messagesLoading: false,
loadingOlder: false,
hasOlderMessages: false,
hasNewerMessages: false,
loadingNewer: false,
hasNewerMessagesRef: { current: false },
setMessages: vi.fn(),
fetchOlderMessages: vi.fn(async () => {}),
fetchNewerMessages: vi.fn(async () => {}),
jumpToBottom: vi.fn(),
addMessageIfNew: vi.fn(),
updateMessageAck: vi.fn(),
triggerReconcile: vi.fn(),
};
},
useUnreadCounts: () => ({
unreadCounts: {},
mentions: {},
lastMessageTimes: {},
incrementUnread: vi.fn(),
markAllRead: vi.fn(),
trackNewMessage: vi.fn(),
refreshUnreads: vi.fn(),
}),
getMessageContentKey: () => 'content-key',
};
});
vi.mock('../messageCache', () => ({
addMessage: vi.fn(),
updateAck: vi.fn(),
remove: vi.fn(),
}));
vi.mock('../components/StatusBar', () => ({
StatusBar: () => <div data-testid="status-bar" />,
}));
vi.mock('../components/Sidebar', () => ({
Sidebar: ({
onSelectConversation,
activeConversation,
}: {
onSelectConversation: (conv: { type: 'search' | 'channel'; id: string; name: string }) => void;
activeConversation: { type: string; id: string } | null;
}) => (
<div>
<button
type="button"
onClick={() =>
onSelectConversation({
type: 'search',
id: 'search',
name: 'Message Search',
})
}
>
Open Search
</button>
<button
type="button"
onClick={() =>
onSelectConversation({
type: 'channel',
id: PUBLIC_CHANNEL_KEY,
name: 'Public',
})
}
>
Open Public
</button>
<div data-testid="active-conversation">
{activeConversation ? `${activeConversation.type}:${activeConversation.id}` : 'none'}
</div>
</div>
),
}));
vi.mock('../components/ChatHeader', () => ({
ChatHeader: () => <div data-testid="chat-header" />,
}));
vi.mock('../components/MessageList', () => ({
MessageList: () => <div data-testid="message-list" />,
}));
vi.mock('../components/MessageInput', () => ({
MessageInput: React.forwardRef((_props, ref) => {
React.useImperativeHandle(ref, () => ({ appendText: vi.fn() }));
return <div data-testid="message-input" />;
}),
}));
vi.mock('../components/NewMessageModal', () => ({
NewMessageModal: () => null,
}));
vi.mock('../components/SearchView', () => ({
SearchView: ({
onNavigateToMessage,
}: {
onNavigateToMessage: (target: {
id: number;
type: 'CHAN' | 'PRIV';
conversation_key: string;
conversation_name: string;
}) => void;
}) => (
<button
type="button"
onClick={() =>
onNavigateToMessage({
id: 321,
type: 'CHAN',
conversation_key: PUBLIC_CHANNEL_KEY,
conversation_name: 'Public',
})
}
>
Jump Result
</button>
),
}));
vi.mock('../components/SettingsModal', () => ({
SettingsModal: () => null,
}));
vi.mock('../components/RawPacketList', () => ({
RawPacketList: () => null,
}));
vi.mock('../components/ContactInfoPane', () => ({
ContactInfoPane: () => null,
}));
vi.mock('../components/ChannelInfoPane', () => ({
ChannelInfoPane: () => null,
}));
vi.mock('../components/ui/sheet', () => ({
Sheet: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SheetContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SheetHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SheetTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SheetDescription: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/ui/sonner', () => ({
Toaster: () => null,
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
import { App } from '../App';
describe('App search jump target handling', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.api.getRadioConfig.mockResolvedValue({
public_key: 'aa'.repeat(32),
name: 'TestNode',
lat: 0,
lon: 0,
tx_power: 17,
max_tx_power: 22,
radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 },
path_hash_mode: 0,
path_hash_mode_supported: false,
});
mocks.api.getSettings.mockResolvedValue({
max_radio_contacts: 200,
favorites: [],
auto_decrypt_dm_on_advert: false,
sidebar_sort_order: 'recent',
last_message_times: {},
preferences_migrated: true,
advert_interval: 0,
last_advert_time: 0,
});
mocks.api.getUndecryptedPacketCount.mockResolvedValue({ count: 0 });
mocks.api.getChannels.mockResolvedValue([
{
key: PUBLIC_CHANNEL_KEY,
name: 'Public',
is_hashtag: false,
on_radio: false,
last_read_at: null,
},
]);
mocks.api.getContacts.mockResolvedValue([]);
});
it('clears jump target when user selects a non-search conversation', async () => {
render(<App />);
await waitFor(() => {
expect(screen.getAllByText('Open Search').length).toBeGreaterThan(0);
});
fireEvent.click(screen.getAllByText('Open Search')[0]);
await waitFor(() => {
expect(screen.getByText('Jump Result')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Jump Result'));
await waitFor(() => {
expect(mocks.useConversationMessagesCalls.mock.calls.some((call) => call[1] === 321)).toBe(
true
);
});
fireEvent.click(screen.getAllByText('Open Public')[0]);
await waitFor(() => {
const lastCall =
mocks.useConversationMessagesCalls.mock.calls[
mocks.useConversationMessagesCalls.mock.calls.length - 1
];
expect(lastCall?.[1]).toBeNull();
});
});
});