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
62 changes: 55 additions & 7 deletions __tests__/rntl/screens/PacksScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,10 @@ const latestModelSource = {
format: 'onnx' as const,
};

const withProgress = expect.objectContaining({ onProgress: expect.any(Function) });

type ReportProgress = (bytesWritten: number, contentLength: number) => void;

describe('PacksScreen', () => {
beforeEach(() => {
jest.clearAllMocks();
Expand Down Expand Up @@ -364,8 +368,8 @@ describe('PacksScreen', () => {
await waitFor(() => expect(getByText('Update available')).toBeTruthy());
fireEvent.press(getByTestId('update-pack-button'));

await waitFor(() => expect(mockAcquireLatestPack).toHaveBeenCalledWith('example-project', {}, readyModel));
expect(mockPrepareMiewidModel).toHaveBeenCalledWith(latestModelSource);
await waitFor(() => expect(mockAcquireLatestPack).toHaveBeenCalledWith('example-project', withProgress, readyModel));
expect(mockPrepareMiewidModel).toHaveBeenCalledWith(latestModelSource, withProgress);
},
);

Expand Down Expand Up @@ -443,7 +447,7 @@ describe('PacksScreen', () => {
await waitFor(() =>
expect(mockAcquireLatestPack).toHaveBeenCalledWith(
'example-project',
{},
withProgress,
readyModel,
),
);
Expand Down Expand Up @@ -543,7 +547,7 @@ describe('PacksScreen', () => {
await waitFor(() =>
expect(mockAcquireLatestPack).toHaveBeenCalledWith(
'example-project',
{},
withProgress,
readyModel,
),
);
Expand All @@ -561,7 +565,7 @@ describe('PacksScreen', () => {
await waitFor(() =>
expect(mockAcquireLatestPack).toHaveBeenCalledWith(
'example-project',
{},
withProgress,
readyModel,
),
);
Expand Down Expand Up @@ -687,11 +691,11 @@ describe('PacksScreen', () => {
await waitFor(() =>
expect(mockAcquireLatestPack).toHaveBeenCalledWith(
'example-project',
{},
withProgress,
readyModel,
),
);
expect(mockPrepareMiewidModel).toHaveBeenCalledWith(latestModelSource);
expect(mockPrepareMiewidModel).toHaveBeenCalledWith(latestModelSource, withProgress);
});

it('alerts and stops when resolving the model source fails', async () => {
Expand Down Expand Up @@ -738,5 +742,49 @@ describe('PacksScreen', () => {

await waitFor(() => expect(alertSpy).toHaveBeenCalled());
});

it('shows model and then pack progress, with a keep-open hint, while downloading', async () => {
let reportModel: ReportProgress | undefined;
let finishModel: ((record: MiewIDModelRecord) => void) | undefined;
mockPrepareMiewidModel.mockImplementation(
(_source: unknown, opts?: { onProgress?: ReportProgress }) => {
reportModel = opts?.onProgress;
return new Promise(resolve => {
finishModel = resolve;
});
},
);
let reportPack: ReportProgress | undefined;
let finishPack: ((result: { ok: true; pack: EmbeddingPack }) => void) | undefined;
mockAcquireLatestPack.mockImplementation(
(_projectId: string, opts?: { onProgress?: ReportProgress }) => {
reportPack = opts?.onProgress;
return new Promise(resolve => {
finishPack = resolve;
});
},
);

const { getByTestId, getByText, queryByTestId } = render(<PacksScreen />);
fireEvent.press(getByTestId('download-pack-button'));
await waitFor(() => expect(reportModel).toBeDefined());
expect(getByText('Preparing download...')).toBeTruthy();

act(() => reportModel?.(85_684_745, 204_011_297));
expect(getByText('Downloading identification model...')).toBeTruthy();
expect(getByText('42% (81.7 of 194.6 MB)')).toBeTruthy();
expect(getByText('Keep EleBook open until this finishes.')).toBeTruthy();

await act(async () => finishModel?.(readyModel));
await waitFor(() => expect(reportPack).toBeDefined());
expect(getByText('Downloading embedding pack...')).toBeTruthy();
expect(queryByTestId('pack-download-amount')).toBeNull();

act(() => reportPack?.(228_081_202, 228_081_202));
expect(getByText('Verifying and installing embedding pack...')).toBeTruthy();

await act(async () => finishPack?.({ ok: true, pack: createPack() }));
await waitFor(() => expect(queryByTestId('pack-download-status')).toBeNull());
});
});
});
50 changes: 50 additions & 0 deletions __tests__/unit/screens/packDownloadProgress.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import {
describeDownloadAmount,
describeDownloadStage,
progressReporter,
} from '../../../src/screens/packDownloadProgress';

const MODEL_BYTES = 204_011_297;
const PACK_BYTES = 228_081_202;

describe('pack download progress text', () => {
it('describes the preparation step before any transfer has started', () => {
expect(describeDownloadStage(null)).toBe('Preparing download...');
expect(describeDownloadAmount(null)).toBeNull();
});

it('shows the percentage and 1024-based megabytes, like the pack card', () => {
const progress = { stage: 'model' as const, bytesWritten: 85_684_745, contentLength: MODEL_BYTES };

expect(describeDownloadStage(progress)).toBe('Downloading identification model...');
expect(describeDownloadAmount(progress)).toBe('42% (81.7 of 194.6 MB)');
});

it('switches to verification once every byte has arrived', () => {
const progress = { stage: 'pack' as const, bytesWritten: PACK_BYTES, contentLength: PACK_BYTES };

expect(describeDownloadStage(progress)).toBe('Verifying and installing embedding pack...');
expect(describeDownloadAmount(progress)).toBeNull();
});

it('shows only the received amount when the server sent no length', () => {
const progress = { stage: 'pack' as const, bytesWritten: 12 * 1024 * 1024, contentLength: 0 };

expect(describeDownloadStage(progress)).toBe('Downloading embedding pack...');
expect(describeDownloadAmount(progress)).toBe('12.0 MB');
});

it('hides the amount until the first bytes arrive', () => {
expect(
describeDownloadAmount({ stage: 'pack', bytesWritten: 0, contentLength: PACK_BYTES }),
).toBeNull();
});

it('tags service progress with its stage', () => {
const onChange = jest.fn();

progressReporter('model', onChange)(10, 20);

expect(onChange).toHaveBeenCalledWith({ stage: 'model', bytesWritten: 10, contentLength: 20 });
});
});
185 changes: 183 additions & 2 deletions __tests__/unit/services/fileDownloadService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,49 @@ jest.mock('react-native-fs', () => ({
moveFile: jest.fn(() => Promise.resolve()),
downloadFile: jest.fn(),
stopDownload: jest.fn(),
resumeDownload: jest.fn(),
completeHandlerIOS: jest.fn(() => Promise.resolve()),
}));

import { AppState, Platform } from 'react-native';
import type { AppStateStatus } from 'react-native';
import RNFS from 'react-native-fs';
import { downloadFileWithIntegrityCheck } from '../../../src/services/fileDownloadService';
import {
MAX_IN_PLACE_RESUMES,
downloadFileWithIntegrityCheck,
} from '../../../src/services/fileDownloadService';

const mockDownloadFile = RNFS.downloadFile as jest.Mock;
const mockStopDownload = RNFS.stopDownload as jest.Mock;
const mockResumeDownload = RNFS.resumeDownload as jest.Mock;
const mockCompleteHandlerIOS = RNFS.completeHandlerIOS as jest.Mock;

// An earlier test restores React Native's AppState mock, which leaves it
// returning undefined under Jest 29; stub it so these tests do not depend on order.
const stubAppStateListeners = () =>
jest.spyOn(AppState, 'addEventListener').mockImplementation(() => ({ remove: jest.fn() }));

interface CapturedDownloadOptions {
resumable?: () => void;
}

/** A native transfer that settles only when the test says so, exposing RNFS's callbacks. */
function controllableDownload(jobId: number) {
const control: {
options: CapturedDownloadOptions;
finish: (result: { statusCode: number; bytesWritten: number }) => void;
} = { options: {}, finish: () => {} };
mockDownloadFile.mockImplementation((options: CapturedDownloadOptions) => {
control.options = options;
return {
jobId,
promise: new Promise(resolve => {
control.finish = resolve;
}),
};
});
return control;
}

const target = {
source: {
Expand Down Expand Up @@ -121,7 +157,6 @@ describe('background transfer', () => {
});

it('re-arms the inactivity deadline when the app returns to the foreground', async () => {
const { AppState } = require('react-native');
const addEventListener = jest.spyOn(AppState, 'addEventListener');
mockDownloadFile.mockReturnValue({
jobId: 1,
Expand All @@ -136,3 +171,149 @@ describe('background transfer', () => {
addEventListener.mockRestore();
});
});

// RNFS on iOS neither resolves nor rejects a transfer that stopped with resume
// data -- it only calls `resumable`. Azure Blob always allows resuming, so any
// interruption used to leave the Packs screen spinning forever.
describe('interrupted iOS transfers', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
stubAppStateListeners();
});

afterEach(() => {
jest.useRealTimers();
});

it('resumes an interrupted transfer in place and completes it', async () => {
const download = controllableDownload(7);

const pending = downloadFileWithIntegrityCheck(target, { maxAttempts: 1 });
await jest.advanceTimersByTimeAsync(0);
download.options.resumable?.();
await jest.advanceTimersByTimeAsync(0);

expect(mockResumeDownload).toHaveBeenCalledWith(7);
download.finish({ statusCode: 200, bytesWritten: 1000 });
await expect(pending).resolves.toMatchObject({ ok: true });
expect(mockDownloadFile).toHaveBeenCalledTimes(1);
});

it(`fails instead of hanging after ${MAX_IN_PLACE_RESUMES} resumes`, async () => {
const download = controllableDownload(7);

const pending = downloadFileWithIntegrityCheck(target, { maxAttempts: 1 });
await jest.advanceTimersByTimeAsync(0);
for (let interruption = 0; interruption <= MAX_IN_PLACE_RESUMES; interruption++) {
download.options.resumable?.();
await jest.advanceTimersByTimeAsync(0);
}

await expect(pending).resolves.toMatchObject({
ok: false,
code: 'network-error',
message: `download interrupted ${MAX_IN_PLACE_RESUMES + 1} times`,
});
expect(mockResumeDownload).toHaveBeenCalledTimes(MAX_IN_PLACE_RESUMES);
});

it('does not resume a transfer that was deliberately stopped', async () => {
const download = controllableDownload(7);
const controller = new AbortController();

const pending = downloadFileWithIntegrityCheck(target, {
maxAttempts: 1,
signal: controller.signal,
});
await jest.advanceTimersByTimeAsync(0);
controller.abort();
// iOS reports the stop itself as resumable.
download.options.resumable?.();

await expect(pending).resolves.toMatchObject({ ok: false, code: 'cancelled' });
expect(mockStopDownload).toHaveBeenCalledWith(7);
expect(mockResumeDownload).not.toHaveBeenCalled();
});

it('reports completion before hashing so the screen can show verification', async () => {
mockDownloadFile.mockReturnValue({
jobId: 1,
promise: Promise.resolve({ statusCode: 200, bytesWritten: 1000 }),
});
const onProgress = jest.fn();

await downloadFileWithIntegrityCheck(target, { onProgress });

expect(onProgress).toHaveBeenLastCalledWith(1000, 1000);
});
});

describe('iOS app lifecycle', () => {
const appState = AppState as unknown as { currentState: unknown };
const originalAppState = appState.currentState;
const originalPlatformOs = Object.getOwnPropertyDescriptor(Platform, 'OS');

beforeEach(() => {
jest.clearAllMocks();
stubAppStateListeners();
});

afterEach(() => {
appState.currentState = originalAppState;
if (originalPlatformOs) {
Object.defineProperty(Platform, 'OS', originalPlatformOs);
}
jest.restoreAllMocks();
});

it('hands iOS its background-session completion handler back after a transfer', async () => {
mockDownloadFile.mockReturnValue({
jobId: 9,
promise: Promise.resolve({ statusCode: 200, bytesWritten: 1000 }),
});

await downloadFileWithIntegrityCheck(target);
await Promise.resolve();

expect(mockCompleteHandlerIOS).toHaveBeenCalledWith(9);
});

it('does not call the iOS-only completion handler on Android', async () => {
Object.defineProperty(Platform, 'OS', { configurable: true, get: () => 'android' });
mockDownloadFile.mockReturnValue({
jobId: 9,
promise: Promise.resolve({ statusCode: 200, bytesWritten: 1000 }),
});

await downloadFileWithIntegrityCheck(target);
await Promise.resolve();

expect(mockCompleteHandlerIOS).not.toHaveBeenCalled();
});

it('starts a transfer only once a backgrounded iOS app is back in the foreground', async () => {
const listeners: Array<(state: AppStateStatus) => void> = [];
jest
.spyOn(AppState, 'addEventListener')
.mockImplementation((_type, listener: (state: AppStateStatus) => void) => {
listeners.push(listener);
return { remove: jest.fn() };
});
appState.currentState = 'background';
mockDownloadFile.mockReturnValue({
jobId: 3,
promise: Promise.resolve({ statusCode: 200, bytesWritten: 1000 }),
});

const pending = downloadFileWithIntegrityCheck(target, { maxAttempts: 1 });
await new Promise(resolve => setImmediate(resolve));
expect(mockDownloadFile).not.toHaveBeenCalled();

appState.currentState = 'active';
listeners.forEach(listener => listener('active'));

await expect(pending).resolves.toMatchObject({ ok: true });
expect(mockDownloadFile).toHaveBeenCalledTimes(1);
});
});
Loading