Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RJS-2867: Add useProgress and tests #6804

Merged
merged 22 commits into from
Aug 8, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
Add useProgress and tests
  • Loading branch information
gagik committed Jul 22, 2024
commit 5982384ffa739e54f3ba28e68775ad933f24c896
9 changes: 9 additions & 0 deletions packages/realm-react/src/__tests__/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,12 @@ export function randomRealmPath() {
const tempDirPath = fs.mkdtempSync(path.join(os.tmpdir(), "realm-react-tests-"));
return path.join(tempDirPath, "test.realm");
}

/**
* Adapted from integration-tests
* @param ms For how long should the promise be pending?
* @returns A promise that returns after `ms` milliseconds.
*/
export function sleep(ms = 1000): Promise<void> {
gagik marked this conversation as resolved.
Show resolved Hide resolved
return new Promise((resolve) => setTimeout(resolve, ms));
}
47 changes: 33 additions & 14 deletions packages/realm-react/src/__tests__/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import { act } from "@testing-library/react-native";
import { EstimateProgressNotificationCallback, ProgressRealmPromise, Realm } from "realm";
import { SyncSession } from "realm/dist/public-types/internal";

/**
* Mocks {@link Realm.ProgressRealmPromise} with a custom
Expand Down Expand Up @@ -48,6 +49,28 @@ export class MockedProgressRealmPromise extends Promise<Realm> implements Progre
};
}

export function emitMockedProgressNotifications(
callback: EstimateProgressNotificationCallback,
timeFrame: number,
progressValues: number[] = [0, 0.25, 0.5, 0.75, 1],
): NodeJS.Timeout {
let progressIndex = 0;
let progressInterval: NodeJS.Timeout | undefined = undefined;
const sendProgress = () => {
// Uses act as this causes a component state update.
act(() => callback(progressValues[progressIndex]));
progressIndex++;

if (progressIndex >= progressValues.length) {
// Send the next progress update in equidistant time
clearInterval(progressInterval);
}
};
progressInterval = setInterval(sendProgress, timeFrame / progressValues.length);
sendProgress();
return progressInterval;
}

/**
* Mocks the Realm.open operation with a delayed, predictable Realm creation.
* If `options.progressValues` is specified, passes it through an equal interval to
Expand All @@ -63,27 +86,14 @@ export function mockRealmOpen(
} = {},
): MockedProgressRealmPromise {
const { progressValues, delay = 100 } = options;
let progressIndex = 0;

const progressRealmPromise = new MockedProgressRealmPromise(
(resolve) => {
setTimeout(() => resolve(new Realm()), delay);
},
{
progress: (callback) => {
if (progressValues instanceof Array) {
const sendProgress = () => {
// Uses act as this causes a component state update.
act(() => callback(progressValues[progressIndex]));
progressIndex++;

if (progressIndex <= progressValues.length) {
// Send the next progress update in equidistant time
setTimeout(sendProgress, delay / progressValues.length);
}
};
sendProgress();
}
emitMockedProgressNotifications(callback, delay, progressValues);
},
},
);
Expand All @@ -92,3 +102,12 @@ export function mockRealmOpen(
delayedRealmOpen.mockImplementation(() => progressRealmPromise);
return progressRealmPromise;
}

export function createMockedSyncedRealm({ syncSession }: { syncSession: Partial<SyncSession> }) {
const mockedSyncedRealm = new Realm();

//@ts-expect-error The mock currently supports supplying a subset of methods
jest.replaceProperty(mockedSyncedRealm, "syncSession", syncSession);

return mockedSyncedRealm;
}
156 changes: 156 additions & 0 deletions packages/realm-react/src/__tests__/useProgress.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
////////////////////////////////////////////////////////////////////////////
//
// Copyright 2023 Realm Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
////////////////////////////////////////////////////////////////////////////

import React, { PropsWithChildren } from "react";
import {
EstimateProgressNotificationCallback,
ProgressDirection,
ProgressMode,
ProgressNotificationCallback,
} from "realm";
import { render } from "@testing-library/react-native";

import { createMockedSyncedRealm, emitMockedProgressNotifications } from "./mocks";
import { RealmProvider, useProgress } from "..";
import { sleep } from "./helpers";
import { Text } from "react-native";

const expectedProgress = {
[ProgressMode.ForCurrentlyOutstandingWork]: {
[ProgressDirection.Download]: [1, 0.2, 0.6, 1],
[ProgressDirection.Upload]: [1, 0.3, 0.7, 1],
},
[ProgressMode.ReportIndefinitely]: {
[ProgressDirection.Download]: [0, 0.25, 0.65, 1],
[ProgressDirection.Upload]: [0, 0.35, 0.75, 1],
},
};

const progressTestDuration = 100;

const createMockedSyncedRealmWithProgress = () => {
const progressNotifiers: Map<ProgressNotificationCallback, NodeJS.Timeout> = new Map();
return createMockedSyncedRealm({
syncSession: {
addProgressNotification(direction, mode, callback) {
progressNotifiers.set(
callback,
emitMockedProgressNotifications(
callback as EstimateProgressNotificationCallback,
// Make download progress "quicker" to compare different testing cases.
direction == ProgressDirection.Download ? progressTestDuration - 10 : progressTestDuration,
expectedProgress[mode][direction],
),
);
},
removeProgressNotification: (callback) => {
clearInterval(progressNotifiers.get(callback));
progressNotifiers.delete(callback);
},
},
});
};

describe("useProgress", () => {
describe("all methods are callable and report a state", () => {
afterEach(() => {
jest.clearAllMocks();
});

(
[
[ProgressMode.ReportIndefinitely, ProgressDirection.Download],
[ProgressMode.ReportIndefinitely, ProgressDirection.Upload],
[ProgressMode.ForCurrentlyOutstandingWork, ProgressDirection.Download],
[ProgressMode.ForCurrentlyOutstandingWork, ProgressDirection.Upload],
] as [ProgressMode, ProgressDirection][]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm sure this could be written without the type assertion 🤞

Copy link
Contributor Author

@gagik gagik Aug 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unfortunately not, it thinks of itself as an array of (ProgressMode | ProgressDirection), I wish typescript was a little smarter.

Copy link
Member

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh cool! I remember using as const before but dind't think of this in that scenario

).forEach(async ([mode, direction]) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a fan of forEach as it's not obvious that the callback is called synchronously.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actually this function itself doesn't have to be defined as async, would that be okay then?

Copy link
Member

@kraenhansen kraenhansen Aug 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather that this was a for-of loop. I personally never use the forEach for this reason (that no guarantees are made on how the callback is invoked and this code expects it to be invoked synchronously).

it(`should provide correct progress with ${mode} and ${direction}`, async () => {
const realm = createMockedSyncedRealmWithProgress();

const renderedProgressValues: (number | null)[] = [];

const RealmProviderWrapper = ({ children }: PropsWithChildren) => {
return <RealmProvider realm={realm}>{children}</RealmProvider>;
};

const ProgressText: React.FC = () => {
const progress = useProgress({
direction,
mode,
});
renderedProgressValues.push(progress);
return progress;
};
render(<ProgressText />, { wrapper: RealmProviderWrapper });

await sleep(progressTestDuration);

expect(renderedProgressValues).toStrictEqual([null, ...expectedProgress[mode][direction]]);
});
});

it("should handle multiple useProgress hooks with different options", async () => {
const realm = createMockedSyncedRealmWithProgress();

const renderedProgressValues: [number | null, number | null][] = [];

const RealmProviderWrapper = ({ children }: PropsWithChildren) => {
return <RealmProvider realm={realm}>{children}</RealmProvider>;
};

const ProgressText: React.FC = () => {
const progressA = useProgress({
direction: ProgressDirection.Download,
mode: ProgressMode.ForCurrentlyOutstandingWork,
});
const progressB = useProgress({
direction: ProgressDirection.Upload,
mode: ProgressMode.ReportIndefinitely,
});

renderedProgressValues.push([progressA, progressB]);
return (
<Text>
{progressA} | {progressB}
</Text>
);
};
render(<ProgressText />, { wrapper: RealmProviderWrapper });

await sleep(progressTestDuration);

const expectedA = expectedProgress[ProgressMode.ForCurrentlyOutstandingWork][ProgressDirection.Download];
const expectedB = expectedProgress[ProgressMode.ReportIndefinitely][ProgressDirection.Upload];
const expectedValues = [
[null, null],
// They will both have a callback right away on first render, afterwards it'll be an interval where
// progressA is always a couple milliseconds quicker.
[expectedA[0], expectedB[0]],
[expectedA[1], expectedB[0]],
[expectedA[1], expectedB[1]],
[expectedA[2], expectedB[1]],
[expectedA[2], expectedB[2]],
[expectedA[3], expectedB[2]],
[expectedA[3], expectedB[3]],
];

expect(renderedProgressValues).toStrictEqual(expectedValues);
});
});
});
1 change: 1 addition & 0 deletions packages/realm-react/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,4 @@ export { useUser, UserProvider } from "./UserProvider";
export * from "./useAuth";
export * from "./useEmailPasswordAuth";
export * from "./types";
export * from "./useProgress";
51 changes: 51 additions & 0 deletions packages/realm-react/src/useProgress.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
////////////////////////////////////////////////////////////////////////////
//
// Copyright 2023 Realm Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
////////////////////////////////////////////////////////////////////////////

import { ProgressDirection, ProgressMode } from "realm";
import { useRealm } from ".";
import { useEffect, useState } from "react";
import { EstimateProgressNotificationCallback } from "realm/dist/public-types/internal";

type UserProgressHook = {
direction: ProgressDirection;
mode: ProgressMode;
};

/**
*
* @returns An object containing operations and state for authenticating with an Atlas App.
gagik marked this conversation as resolved.
Show resolved Hide resolved
*/
export function useProgress({ direction, mode }: UserProgressHook): number | null {
gagik marked this conversation as resolved.
Show resolved Hide resolved
const realm = useRealm();
const [progress, setProgress] = useState<number | null>(null);

useEffect(() => {
if (!realm.syncSession) {
throw new Error("No sync session found.");
}
const callback: EstimateProgressNotificationCallback = (estimate) => {
setProgress(estimate);
};

realm.syncSession.addProgressNotification(direction, mode, callback);

return () => realm.syncSession?.removeProgressNotification(callback);
}, [realm, direction, mode]);

return progress;
}
Loading