Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 168 additions & 0 deletions __tests__/rntl/screens/ModelsScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
import { NavigationContainer } from '@react-navigation/native';
import { useAppStore } from '../../../src/stores/appStore';
import { resetStores } from '../../utils/testHelpers';

// Mirror constants from ModelsScreen so test assertions stay in sync with the source
const VISION_PIPELINE_TAG = 'image-text-to-text';
const CODE_FALLBACK_QUERY = 'coder';
import {
createDownloadedModel,
createONNXImageModel,
Expand Down Expand Up @@ -1341,7 +1345,7 @@
// ============================================================================
// Multiple download badge
// ============================================================================
describe('download badge', () => {

Check warning on line 1348 in __tests__/rntl/screens/ModelsScreen.test.tsx

View workflow job for this annotation

GitHub Actions / lint

Describe block title is used multiple times in the same describe block
it('shows badge with count for multiple models', async () => {
const models = [
createDownloadedModel({ id: 'model-1' }),
Expand Down Expand Up @@ -2575,6 +2579,170 @@
});
});

// ============================================================================
// recommended toggle and backend filter behaviour
// ============================================================================
describe('image model recommended toggle and backend filter', () => {
const mnnModel = {
id: 'cpu-model',
name: 'cpu-model',
displayName: 'CPU Model',
backend: 'mnn' as const,
fileName: 'cpu.zip',
downloadUrl: 'https://example.com/cpu.zip',
size: 500000000,
repo: 'test/cpu-model',
};
const qnnModel = {
id: 'npu-model',
name: 'npu-model',
displayName: 'NPU Model',
backend: 'qnn' as const,
fileName: 'npu.zip',
downloadUrl: 'https://example.com/npu.zip',
size: 500000000,
repo: 'test/npu-model',
};

it('hides qnn model when showRecommendedOnly is on and recommendedBackend is mnn', async () => {
mockFetchAvailableModels.mockResolvedValue([mnnModel, qnnModel]);

const { queryByText, getByText } = renderModelsScreen();

await act(async () => {
fireEvent.press(getByText('Image Models'));
});

// Allow async state (imageRec + models) to fully settle
await act(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 100));
});

// CPU Model (mnn) matches recommendedBackend='mnn' → visible
// NPU Model (qnn) does not match → filtered out by showRecommendedOnly
expect(queryByText('NPU Model')).toBeNull();
});

it('dismisses first-time hint when rec-toggle is pressed', async () => {
mockFetchAvailableModels.mockResolvedValue([mnnModel]);

const { getByText, getByTestId, queryByText } = renderModelsScreen();

await act(async () => {
fireEvent.press(getByText('Image Models'));
});

await waitFor(() => {
expect(getByText(/RAM/)).toBeTruthy();
});

// Hint should be visible on first open (showRecHint=true, showRecommendedOnly=true)
expect(queryByText(/Showing recommended models only/)).toBeTruthy();

// Pressing the toggle dismisses the hint and turns off recommended mode
await act(async () => {
fireEvent.press(getByTestId('rec-toggle'));
});

await waitFor(() => {
expect(queryByText(/Showing recommended models only/)).toBeNull();
});
});
});

// ============================================================================
// handleSearch with filters
// ============================================================================
describe('handleSearch with active filters', () => {
it('triggers HuggingFace search when vision type filter is set and query is empty', async () => {
const { getByText, getByTestId } = renderModelsScreen();

await waitFor(() => {
expect(getByText(/Recommended for your device/)).toBeTruthy();
});

// Open filter bar
await act(async () => {
fireEvent.press(getByTestId('text-filter-toggle'));
});

// Select Vision type filter
await act(async () => {
fireEvent.press(getByText(/^Type/));
});

await act(async () => {
fireEvent.press(getByText('Vision'));
});

// Hit search with empty query but vision filter active
await act(async () => {
fireEvent.press(getByTestId('search-button'));
});

await waitFor(() => {
expect(mockSearchModels).toHaveBeenCalledWith(
'', // empty query
expect.objectContaining({ pipelineTag: VISION_PIPELINE_TAG }),
);
});
});

it('does not trigger HuggingFace search when query is empty and no filters are active', async () => {
const { getByText, getByTestId } = renderModelsScreen();

await waitFor(() => {
expect(getByText(/Recommended for your device/)).toBeTruthy();
});

mockSearchModels.mockClear();

// Hit search with empty query and no filters
await act(async () => {
fireEvent.press(getByTestId('search-button'));
});

expect(mockSearchModels).not.toHaveBeenCalled();
// Should still show recommended section
await waitFor(() => {
expect(getByText(/Recommended for your device/)).toBeTruthy();
});
});

it('triggers HuggingFace search with "coder" keyword when code filter is set and query is empty', async () => {
const { getByText, getByTestId } = renderModelsScreen();

await waitFor(() => {
expect(getByText(/Recommended for your device/)).toBeTruthy();
});

// Open filter bar
await act(async () => {
fireEvent.press(getByTestId('text-filter-toggle'));
});

// Select Code type filter
await act(async () => {
fireEvent.press(getByText(/^Type/));
});

await act(async () => {
fireEvent.press(getByText('Code'));
});

await act(async () => {
fireEvent.press(getByTestId('search-button'));
});

await waitFor(() => {
expect(mockSearchModels).toHaveBeenCalledWith(
CODE_FALLBACK_QUERY,
expect.objectContaining({ limit: 30 }),
);
});
});
});

// ============================================================================
// formatNumber utility
// ============================================================================
Expand Down
40 changes: 40 additions & 0 deletions __tests__/unit/services/huggingface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,46 @@ describe('HuggingFaceService', () => {
const url = mockFetch.mock.calls[0][0];
expect(url).toContain('limit=10');
});

it('appends pipeline_tag when pipelineTag option is provided', async () => {
const mockFetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve([]),
});
(global as any).fetch = mockFetch;

await huggingFaceService.searchModels('', { pipelineTag: 'image-text-to-text' });

const url = mockFetch.mock.calls[0][0];
expect(url).toContain('pipeline_tag=image-text-to-text');
});

it('does not append pipeline_tag when option is not provided', async () => {
const mockFetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve([]),
});
(global as any).fetch = mockFetch;

await huggingFaceService.searchModels('test');

const url = mockFetch.mock.calls[0][0];
expect(url).not.toContain('pipeline_tag');
});

it('combines query and pipeline_tag in the same request', async () => {
const mockFetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve([]),
});
(global as any).fetch = mockFetch;

await huggingFaceService.searchModels('qwen', { pipelineTag: 'image-text-to-text' });

const url = mockFetch.mock.calls[0][0];
expect(url).toContain('search=qwen');
expect(url).toContain('pipeline_tag=image-text-to-text');
});
});

// ============================================================================
Expand Down
Loading
Loading