Skip to content

Commit

Permalink
Client observables (#81)
Browse files Browse the repository at this point in the history
* Add react hooks testing

* WIP

* Make observable query a bit more robust.

* Fix compiler issues

* Fix the type inference
  • Loading branch information
smithmx authored Nov 1, 2024
1 parent 127a4ae commit aa0505c
Show file tree
Hide file tree
Showing 6 changed files with 320 additions and 16 deletions.
1 change: 1 addition & 0 deletions Source/JavaScript/Applications/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"dependencies": {
"@aksio/fundamentals": "1.5.0",
"@aksio/typescript": "1.5.0",
"@testing-library/react-hooks": "8.0.1",
"handlebars": "4.7.7"
}
}
10 changes: 10 additions & 0 deletions Source/JavaScript/Applications/queries/ObservableQueryResult.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Copyright (c) Aksio Insurtech. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import { QueryResultWithState } from "./QueryResultWithState";

export type ObservableQueryResult<TDataType> = {
queryResult: QueryResultWithState<TDataType>; // Represents the data and its state
isSubscribed: boolean; // Indicates if the subscription is active
unsubscribe: () => void; // Function to manually unsubscribe
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
// Copyright (c) Aksio Insurtech. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import { JSDOM } from 'jsdom';
import { renderHook, act, cleanup } from '@testing-library/react-hooks';
import * as sinon from 'sinon';
import { expect } from 'chai';
import { useObservableQuery } from '../../useObservableQuery';
import { IObservableQueryFor, OnNextResult } from '../../IObservableQueryFor';
import { ObservableQuerySubscription } from '../../ObservableQuerySubscription';
import { QueryResult } from '../../QueryResult';
import { Constructor } from '@aksio/fundamentals';
import { IObservableQueryConnection } from '../../IObservableQueryConnection';
import { DataReceived } from '../../ObservableQueryConnection';
import Handlebars from 'handlebars';
import {clearInterval} from "node:timers";

// Mock implementation of IObservableQueryFor
class MockObservableQuery<TDataType, TArguments = {}> implements IObservableQueryFor<TDataType, TArguments> {
defaultValue: TDataType;
route = '/mock';
routeTemplate: Handlebars.TemplateDelegate<any> = Handlebars.compile(this.route);
requestArguments: string[] = ['arg1', 'arg2'];

constructor(defaultValue: TDataType = "" as unknown as TDataType) {
this.defaultValue = defaultValue;
}

subscribe(callback: OnNextResult<QueryResult<TDataType>>, args?: TArguments): ObservableQuerySubscription<TDataType> {
let count = 0;
// Simulate an asynchronous data stream using setInterval
const intervalId = setInterval(() => {
const value = count > 0 ? `my string ${count}` : "my string";
const data = { data: value } as QueryResult<TDataType>;
callback(data);
count++;// Pass data to the callback function
}, 1000);

// Return an ObservableQuerySubscription with an unsubscribe method
return new ObservableQuerySubscription(new MockObservableQueryConnection<TDataType>(() => clearInterval(intervalId)));
}
}

// Mock implementation of IObservableQueryConnection
class MockObservableQueryConnection<TDataType> implements IObservableQueryConnection<TDataType> {
constructor(private onDisconnect: () => void = () => {}) {
}

connect(dataReceived: DataReceived<TDataType>): void {
}

disconnect(): void {
this.onDisconnect();
}
}

// Test suite for useObservableQuery
describe('useObservableQuery', () => {
let clock: sinon.SinonFakeTimers;
let jsdom: JSDOM;
let subscribeSpy: sinon.SinonSpy;

beforeEach(() => {
// Set up a new JSDOM instance for each test
jsdom = new JSDOM('<!doctype html><html><body></body></html>', {
url: 'http://localhost',
});

// Assign global variables to mimic the browser environment
global.window = jsdom.window as unknown as Window & typeof globalThis;
global.document = jsdom.window.document;
global.navigator = {
userAgent: 'node.js',
} as Navigator;

clock = sinon.useFakeTimers(); // Use fake timers for testing time-based behavior
// Spy on the subscribe method directly on MockObservableQuery
subscribeSpy = sinon.spy(MockObservableQuery.prototype, 'subscribe');
});

afterEach(() => {
clock.restore(); // Restore real timers
// Clean up the spy after each test
subscribeSpy.restore();
// Use Testing Library's cleanup function to unmount components safely
cleanup();
// Clean up JSDOM and release resources after each test
jsdom.window.close();
delete (global as any).window;
delete (global as any).document;
delete (global as any).navigator;
});


it('should initialize with default result and should be subscribed with a manual unsubscribe function', () => {
const { result } = renderHook(() => useObservableQuery(MockObservableQuery as Constructor<MockObservableQuery<string, any>>));

// Check that the initial result matches the default value
expect(result.current.queryResult.data).to.deep.equal("");
expect(subscribeSpy.called).to.be.true;
expect(result.current.isSubscribed).to.be.true;
expect(typeof result.current.unsubscribe).to.equal('function');
});

it('should call subscribe and receive data', () => {
const { result } = renderHook(() => useObservableQuery(MockObservableQuery as Constructor<MockObservableQuery<string, any>>));

expect(result.current.queryResult.data).to.deep.equal("");

// Now advance the timers to trigger the subscription data
act(() => {
clock.tick(1000); // Advance the fake timer by 1 second
});

// Assert that subscribe was called once
expect(subscribeSpy.calledOnce).to.be.true;
expect(result.current.queryResult.data).to.deep.equal("my string");
expect(result.current.isSubscribed).to.be.true;
expect(typeof result.current.unsubscribe).to.equal('function');
});

it('should unsubscribe when unsubscribe is called manually', () => {
const { result } = renderHook(() => useObservableQuery(MockObservableQuery as Constructor<MockObservableQuery<string, any>>));
// var unsubscribeSpy = sinon.spy(result.current.unsubscribe)

// Call unsubscribe
act(() => {
result.current.unsubscribe(); // Manual unsubscribe function
});

expect(subscribeSpy.calledOnce).to.be.true;
expect(result.current.isSubscribed).to.be.false;
});

it('should clean up subscription on component unmount', async () => {
const { result, unmount, waitFor } = renderHook(() => useObservableQuery(MockObservableQuery as Constructor<MockObservableQuery<string, any>>));

// Initial check for subscription
expect(result.current.isSubscribed).to.be.true;

// Unmount the hook to trigger cleanup
act(() => {
unmount();
});

waitFor(() => !result.current.isSubscribed).then(() => {
expect(subscribeSpy.calledOnce).to.be.true;
});
});

it('should update data with each new subscription result', () => {
const { result } = renderHook(() => useObservableQuery(MockObservableQuery as Constructor<MockObservableQuery<string, any>>));

// Initial data should be empty
expect(result.current.queryResult.data).to.deep.equal("");

// Simulate multiple data updates
act(() => {
clock.tick(1000); // First update
});
expect(result.current.queryResult.data).to.deep.equal("my string");

act(() => {
clock.tick(1000); // Second update
});
expect(result.current.queryResult.data).to.deep.equal("my string 1");

act(() => {
clock.tick(1000); // Second update
});
expect(result.current.queryResult.data).to.deep.equal("my string 2");

expect(subscribeSpy.callCount).to.equal(1);
});

it('should resubscribe when args change', () => {
const { result, rerender } = renderHook(({ args }) => useObservableQuery(MockObservableQuery as Constructor<MockObservableQuery<string, any>>, args), {
initialProps: { args: { arg1: 'initial' } }
});

// Initial subscription check
expect(result.current.isSubscribed).to.be.true;
expect(subscribeSpy.calledOnce).to.be.true;

// Rerender with new args
act(() => {
rerender({ args: { arg1: 'updated' } });
});

// Expect a new subscription to have been triggered
console.log(subscribeSpy.callCount);
expect(subscribeSpy.calledTwice).to.be.true;
});

it('should handle errors in subscription gracefully', () => {
// Modify `MockObservableQuery` to throw an error in `subscribe`
const consoleErrorSpy = sinon.spy(console, 'error');
subscribeSpy.restore();
const errorQuery = sinon.stub(MockObservableQuery.prototype, 'subscribe').throws(new Error('Subscription failed'));

const { result } = renderHook(() => useObservableQuery(MockObservableQuery as Constructor<MockObservableQuery<string, any>>));

expect(result.current.isSubscribed).to.be.false;
expect(errorQuery.threw()).to.be.true;

// Restore stub
errorQuery.restore();
});

it('should handle multiple calls to unsubscribe gracefully', () => {
const { result } = renderHook(() => useObservableQuery(MockObservableQuery as Constructor<MockObservableQuery<string, any>>));

act(() => {
result.current.unsubscribe(); // First call
});

expect(result.current.isSubscribed).to.be.false;

// Call unsubscribe again and verify nothing changes or breaks
act(() => {
result.current.unsubscribe(); // Second call
});

expect(result.current.isSubscribed).to.be.false; // No change
expect(subscribeSpy.calledOnce).to.be.true; // Original subscription only
});
});
91 changes: 77 additions & 14 deletions Source/JavaScript/Applications/queries/useObservableQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,92 @@
import { QueryResultWithState } from './QueryResultWithState';
import { IObservableQueryFor } from './IObservableQueryFor';
import { Constructor } from '@aksio/fundamentals';
import { useState, useEffect } from 'react';
import { QueryResult } from './QueryResult';
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { ObservableQueryResult } from './ObservableQueryResult';

const EMPTY_ARGS = {} as const;

/**
* React hook for working with {@link IObservableQueryFor} within the state management of React.
* @template TDataType Type of model the query is for.
* @template TQuery Type of observable query to use.
* @template TArguments Optional: Arguments for the query, if any
* @param query Query type constructor.
* @returns Tuple of {@link QueryResult} and a {@link PerformQuery} delegate.
* @param args Arguments for the query, defaulting to an empty object
* @returns {@link ObservableQueryResult}.
*/
export function useObservableQuery<TDataType, TQuery extends IObservableQueryFor<TDataType>, TArguments = {}>(query: Constructor<TQuery>, args?: TArguments): [QueryResultWithState<TDataType>] {
const queryInstance = new query() as TQuery;
const [result, setResult] = useState<QueryResultWithState<TDataType>>(QueryResultWithState.empty(queryInstance.defaultValue));
const argumentsDependency = queryInstance.requestArguments.map(_ => args?.[_]);
export function useObservableQuery<TDataType, TQuery extends IObservableQueryFor<TDataType>, TArguments = {}>(
query: Constructor<TQuery>,
args: TArguments = EMPTY_ARGS as TArguments
): ObservableQueryResult<TDataType> {
// Memoize queryInstance only on changes to `query` (and `args` if necessary)
const stableArgs = useMemo(() => args, [JSON.stringify(args)]);
const queryInstance = useMemo(() => new query() as TQuery, [query]);
const [result, setResult] = useState<QueryResultWithState<TDataType>>(
QueryResultWithState.initial(queryInstance.defaultValue)
);
const [isSubscribed, setIsSubscribed] = useState(false);
const subscriptionRef = useRef<{ unsubscribe: () => void } | null>(null);

const cleanupSubscription = () => {
if(!subscriptionRef.current)
{
console.log("CleanUp: No subscription to clean up.");
return;
}

try {
subscriptionRef.current.unsubscribe();
subscriptionRef.current = null;
setIsSubscribed(false);
} catch (error) {
console.error("Error during unsubscription: ", error);
}
};

const unsubscribe = () => {
console.error("Manual unsubscribe requested...");
cleanupSubscription();
};

// Track previous values of dependencies
const prevQueryInstance = useRef(queryInstance);
const prevArgs = useRef(args);

useEffect(() => {
const subscription = queryInstance.subscribe(response => {
setResult(QueryResultWithState.fromQueryResult(response, false));
}, args as any);
// Check which dependency triggered the effect
if (prevQueryInstance.current !== queryInstance) {
console.log("queryInstance changed");
}
if (prevArgs.current !== args) {
console.log("args changed");
}
// Update refs to the current values
prevQueryInstance.current = queryInstance;
prevArgs.current = args;
let isComponentMounted = true;
(async () => {
try {
console.log("Subscribing to observable query...");
const subscription = queryInstance.subscribe(response => {
if (isComponentMounted) {
setResult(QueryResultWithState.fromQueryResult(response, false));
}
}, args as any);
subscriptionRef.current = subscription;
setIsSubscribed(true);
} catch (error) {
console.error('Error during subscription:', error);
setIsSubscribed(false);
}
})();

return () => subscription.unsubscribe();
}, argumentsDependency);
return () => {
console.log("Automatically Unsubscribing from observable query...");
isComponentMounted = false;
cleanupSubscription();
};
}, [query, stableArgs]);

return [result];
}
return { queryResult: result, isSubscribed, unsubscribe };
}
4 changes: 2 additions & 2 deletions globals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import { BrowserWindow } from 'electron';

let _mainWindow: BrowserWindow | null;
let _mainWindow: BrowserWindow | null;

export const setMainWindow = (mainWindow: BrowserWindow) => {
_mainWindow = mainWindow;
Expand All @@ -15,4 +15,4 @@ export const getMainWindow = () => {

export const clearMainWindow = () => {
_mainWindow = null;
};
};
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@
"up": "node ./run-task-on-workspaces.js up"
},
"devDependencies": {
"@testing-library/react-hooks": "^8.0.1",
"@types/chai": "4.3.4",
"@types/chai-as-promised": "7.1.5",
"@types/jsdom": "^21.1.7",
"@types/mocha": "10.0.1",
"@types/node": "18.16.3",
"@types/sinon": "10.0.13",
Expand All @@ -39,6 +41,7 @@
"eslint-plugin-prettier": "4.2.1",
"eslint-plugin-react": "7.31.11",
"glob": "8.0.3",
"jsdom": "^25.0.1",
"mocha": "10.2.0",
"module-alias": "2.2.2",
"npm-check-updates": "16.0.5",
Expand Down

0 comments on commit aa0505c

Please sign in to comment.