Skip to content
Open
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
1 change: 1 addition & 0 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
useEffect(() => {
initializeApp();

}, []);

Check warning on line 64 in App.tsx

View workflow job for this annotation

GitHub Actions / lint

React Hook useEffect has a missing dependency: 'initializeApp'. Either include it or remove the dependency array

const ensureAppStoreHydrated = async () => {
const persistApi = useAppStore.persist;
Expand Down Expand Up @@ -139,6 +139,7 @@
progress: progress.progress,
bytesDownloaded: progress.bytesDownloaded,
totalBytes: progress.totalBytes,
ownerDownloadId: progress.downloadId,
});
},
);
Expand Down
150 changes: 146 additions & 4 deletions __tests__/App.test.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,155 @@
/**
* @format
* App startup tests
*/

import React from 'react';
import ReactTestRenderer from 'react-test-renderer';

const appState = {
setDeviceInfo: jest.fn(),
setModelRecommendation: jest.fn(),
setDownloadedModels: jest.fn(),
setDownloadedImageModels: jest.fn(),
clearImageModelDownloading: jest.fn(),
setBackgroundDownload: jest.fn(),
addDownloadedModel: jest.fn(),
setDownloadProgress: jest.fn(),
activeBackgroundDownloads: {
42: {
modelId: 'test/model',
fileName: 'model.gguf',
quantization: 'Q4_K_M',
author: 'test',
totalBytes: 1000,
},
},
};

const authState = {
isEnabled: false,
isLocked: false,
setLocked: jest.fn(),
setLastBackgroundTime: jest.fn(),
};

const mockUseAppStore = Object.assign(
(selector?: (state: typeof appState) => unknown) => (selector ? selector(appState) : appState),
{
getState: () => appState,
persist: { hasHydrated: () => true, rehydrate: jest.fn() },
},
);

const mockUseAuthStore = Object.assign(
(selector?: (state: typeof authState) => unknown) => (selector ? selector(authState) : authState),
{
getState: () => authState,
},
);

const mockUseRemoteServerStore = Object.assign(
() => ({}),
{
persist: { hasHydrated: () => true, rehydrate: jest.fn() },
},
);

const mockModelManager = {
initialize: jest.fn(() => Promise.resolve()),
cleanupMMProjEntries: jest.fn(() => Promise.resolve()),
setBackgroundDownloadMetadataCallback: jest.fn(),
syncBackgroundDownloads: jest.fn(() => Promise.resolve([])),
syncCompletedImageDownloads: jest.fn(() => Promise.resolve([])),
restoreInProgressDownloads: jest.fn((_persisted, onProgress?: (progress: any) => void) => {
onProgress?.({
downloadId: 42,
modelId: 'test/model',
fileName: 'model.gguf',
bytesDownloaded: 600,
totalBytes: 1000,
progress: 0.6,
});
return Promise.resolve([]);
}),
refreshModelLists: jest.fn(() => Promise.resolve({ textModels: [], imageModels: [] })),
watchDownload: jest.fn(),
};

jest.mock('../src/navigation', () => ({
AppNavigator: () => null,
}));

jest.mock('../src/screens', () => ({
LockScreen: () => null,
}));

jest.mock('../src/theme', () => ({
useTheme: () => ({
colors: { background: '#fff', primary: '#000' },
isDark: false,
}),
}));

jest.mock('../src/hooks/useAppState', () => ({
useAppState: jest.fn(),
}));

jest.mock('../src/utils/logger', () => ({
__esModule: true,
default: {
log: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
}));

jest.mock('../src/stores', () => ({
useAppStore: mockUseAppStore,
useAuthStore: mockUseAuthStore,
useRemoteServerStore: mockUseRemoteServerStore,
}));

jest.mock('../src/services', () => ({
hardwareService: {
getDeviceInfo: jest.fn(() => Promise.resolve({ totalMemory: 8 * 1024 * 1024 * 1024 })),
getModelRecommendation: jest.fn(() => ({ maxParameters: 7, recommendedQuantization: 'Q4_K_M' })),
},
modelManager: mockModelManager,
authService: {
hasPassphrase: jest.fn(() => Promise.resolve(false)),
},
ragService: {
ensureReady: jest.fn(() => Promise.resolve()),
},
remoteServerManager: {
initializeProviders: jest.fn(() => Promise.resolve()),
},
}));

import App from '../App';

test('renders correctly', async () => {
await ReactTestRenderer.act(() => {
ReactTestRenderer.create(<App />);
describe('App', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('tags restored download progress with ownerDownloadId during startup restore', async () => {
await ReactTestRenderer.act(async () => {
ReactTestRenderer.create(<App />);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});

expect(mockModelManager.restoreInProgressDownloads).toHaveBeenCalledWith(
appState.activeBackgroundDownloads,
expect.any(Function),
);
expect(appState.setDownloadProgress).toHaveBeenCalledWith('test/model/model.gguf', {
progress: 0.6,
bytesDownloaded: 600,
totalBytes: 1000,
ownerDownloadId: 42,
});
});
});
16 changes: 8 additions & 8 deletions __tests__/rntl/screens/ChatScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,7 @@ const renderChatScreen = () => {
describe('ChatScreen', () => {
afterEach(() => {
cleanup();
jest.useRealTimers();
});

beforeEach(() => {
Expand Down Expand Up @@ -3687,10 +3688,9 @@ describe('ChatScreen', () => {
await act(async () => { fireEvent.press(getByTestId('chat-settings-icon')); });
await act(async () => { fireEvent.press(getByTestId('delete-conversation-btn')); });
await act(async () => { fireEvent.press(getByTestId('alert-button-Delete')); });
await act(async () => { await new Promise<void>(r => setTimeout(() => r(), 200)); });

// llmService.stopGeneration should have been called (was streaming)
expect(llmService.stopGeneration).toHaveBeenCalled();
await waitFor(() => {
expect(llmService.stopGeneration).toHaveBeenCalled();
});
});
});

Expand Down Expand Up @@ -3733,10 +3733,9 @@ describe('ChatScreen', () => {
await act(async () => {
fireEvent.press(getByTestId('send-with-image'));
});
await act(async () => { await new Promise<void>(r => setTimeout(() => r(), 300)); });

// The test exercises handleImageGeneration failure path - no crash
expect(getByTestId('chat-screen')).toBeTruthy();
await waitFor(() => {
expect(getByTestId('chat-screen')).toBeTruthy();
});
});
});

Expand Down Expand Up @@ -3785,6 +3784,7 @@ describe('ChatScreen', () => {
});

// Queue count should appear and clear queue button
await waitFor(() => expect(getByTestId('clear-queue-button')).toBeTruthy());
const clearQueueBtn = getByTestId('clear-queue-button');
await act(async () => {
fireEvent.press(clearQueueBtn);
Expand Down
21 changes: 0 additions & 21 deletions __tests__/rntl/screens/DownloadManagerScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -825,27 +825,6 @@ describe('DownloadManagerScreen', () => {
expect(mockModelManager.getActiveBackgroundDownloads).toHaveBeenCalled();
});

it('handleRefresh reloads models and image models', async () => {
const setDownloadedModels = jest.fn();
const setDownloadedImageModels = jest.fn();
const state = createDefaultState({ setDownloadedModels, setDownloadedImageModels });
mockStoreState(state);

const { UNSAFE_root } = render(<DownloadManagerScreen />);

// Find the FlatList and trigger its RefreshControl onRefresh
const flatList = UNSAFE_root.findAll((node: any) => node.type && node.type.displayName === 'FlatList')[0]
|| UNSAFE_root.findAll((node: any) => node.props?.refreshControl)[0];

if (flatList && flatList.props.refreshControl) {
await act(async () => {
flatList.props.refreshControl.props.onRefresh();
});
}

expect(mockModelManager.getDownloadedModels).toHaveBeenCalled();
expect(mockModelManager.getDownloadedImageModels).toHaveBeenCalled();
});

it('confirming delete model calls deleteModel and removeDownloadedModel', async () => {
const removeDownloadedModel = jest.fn();
Expand Down
11 changes: 4 additions & 7 deletions __tests__/rntl/screens/ModelsScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1664,12 +1664,12 @@ describe('ModelsScreen', () => {
it('filters search results by size', async () => {
mockSearchModels.mockResolvedValue([
createModelInfo({
id: 'test/small-1B',
id: 'test/model-1B',
name: 'Small 1B',
files: [createModelFile({ size: 1000000000 })],
}),
createModelInfo({
id: 'test/large-70B',
id: 'test/model-70B',
name: 'Large 70B',
files: [createModelFile({ size: 4000000000 })],
}),
Expand All @@ -1691,14 +1691,11 @@ describe('ModelsScreen', () => {
fireEvent.press(getByText('1-3B'));
});

// Search
await act(async () => {
fireEvent.changeText(getByTestId('search-input'), 'test');
});

// Size filters auto-trigger search even with an empty query.
await waitFor(() => {
expect(getByText('Small 1B')).toBeTruthy();
});
expect(mockSearchModels).toHaveBeenCalled();
// Large 70B doesn't match 1-3B size filter
expect(queryByText('Large 70B')).toBeNull();
});
Expand Down
46 changes: 46 additions & 0 deletions __tests__/unit/services/modelManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,52 @@ describe('ModelManager', () => {

await expect(modelManager.deleteModel('nonexistent')).rejects.toThrow('Model not found');
});

it('preserves mmproj file when another model still references it', async () => {
const sharedMmproj = '/mock/documents/models/shared-mmproj.gguf';
const storedModels = [
{
id: 'qwen-vl-q4',
name: 'Qwen VL Q4',
filePath: '/mock/documents/models/qwen-vl-q4.gguf',
fileSize: 100,
mmProjPath: sharedMmproj,
},
{
id: 'qwen-vl-q8',
name: 'Qwen VL Q8',
filePath: '/mock/documents/models/qwen-vl-q8.gguf',
fileSize: 200,
mmProjPath: sharedMmproj,
},
];
mockedAsyncStorage.getItem.mockResolvedValue(JSON.stringify(storedModels));
mockedRNFS.exists.mockResolvedValue(true);

await modelManager.deleteModel('qwen-vl-q4');

expect(RNFS.unlink).toHaveBeenCalledWith('/mock/documents/models/qwen-vl-q4.gguf');
expect(RNFS.unlink).not.toHaveBeenCalledWith(sharedMmproj);
});

it('deletes mmproj file when no other model references it', async () => {
const mmprojPath = '/mock/documents/models/solo-mmproj.gguf';
const storedModels = [
{
id: 'qwen-vl-q4',
name: 'Qwen VL Q4',
filePath: '/mock/documents/models/qwen-vl-q4.gguf',
fileSize: 100,
mmProjPath: mmprojPath,
},
];
mockedAsyncStorage.getItem.mockResolvedValue(JSON.stringify(storedModels));
mockedRNFS.exists.mockResolvedValue(true);

await modelManager.deleteModel('qwen-vl-q4');

expect(RNFS.unlink).toHaveBeenCalledWith(mmprojPath);
});
});

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