From aa0505c488f20af6c5f86cc775208b2433bd54f4 Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Fri, 1 Nov 2024 15:07:24 +0100 Subject: [PATCH] Client observables (#81) * Add react hooks testing * WIP * Make observable query a bit more robust. * Fix compiler issues * Fix the type inference --- Source/JavaScript/Applications/package.json | 1 + .../queries/ObservableQueryResult.ts | 10 + .../when_useObservableQuery/use_effect.ts | 227 ++++++++++++++++++ .../queries/useObservableQuery.ts | 91 +++++-- globals.ts | 4 +- package.json | 3 + 6 files changed, 320 insertions(+), 16 deletions(-) create mode 100644 Source/JavaScript/Applications/queries/ObservableQueryResult.ts create mode 100644 Source/JavaScript/Applications/queries/for_ObservableQuery/when_useObservableQuery/use_effect.ts diff --git a/Source/JavaScript/Applications/package.json b/Source/JavaScript/Applications/package.json index 41ade5c..4ac5d13 100644 --- a/Source/JavaScript/Applications/package.json +++ b/Source/JavaScript/Applications/package.json @@ -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" } } diff --git a/Source/JavaScript/Applications/queries/ObservableQueryResult.ts b/Source/JavaScript/Applications/queries/ObservableQueryResult.ts new file mode 100644 index 0000000..0fadf2f --- /dev/null +++ b/Source/JavaScript/Applications/queries/ObservableQueryResult.ts @@ -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 = { + queryResult: QueryResultWithState; // Represents the data and its state + isSubscribed: boolean; // Indicates if the subscription is active + unsubscribe: () => void; // Function to manually unsubscribe +}; \ No newline at end of file diff --git a/Source/JavaScript/Applications/queries/for_ObservableQuery/when_useObservableQuery/use_effect.ts b/Source/JavaScript/Applications/queries/for_ObservableQuery/when_useObservableQuery/use_effect.ts new file mode 100644 index 0000000..ac6c85a --- /dev/null +++ b/Source/JavaScript/Applications/queries/for_ObservableQuery/when_useObservableQuery/use_effect.ts @@ -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 implements IObservableQueryFor { + defaultValue: TDataType; + route = '/mock'; + routeTemplate: Handlebars.TemplateDelegate = Handlebars.compile(this.route); + requestArguments: string[] = ['arg1', 'arg2']; + + constructor(defaultValue: TDataType = "" as unknown as TDataType) { + this.defaultValue = defaultValue; + } + + subscribe(callback: OnNextResult>, args?: TArguments): ObservableQuerySubscription { + 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; + callback(data); + count++;// Pass data to the callback function + }, 1000); + + // Return an ObservableQuerySubscription with an unsubscribe method + return new ObservableQuerySubscription(new MockObservableQueryConnection(() => clearInterval(intervalId))); + } +} + +// Mock implementation of IObservableQueryConnection +class MockObservableQueryConnection implements IObservableQueryConnection { + constructor(private onDisconnect: () => void = () => {}) { + } + + connect(dataReceived: DataReceived): 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('', { + 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>)); + + // 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>)); + + 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>)); + // 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>)); + + // 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>)); + + // 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>, 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>)); + + 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>)); + + 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 + }); +}); \ No newline at end of file diff --git a/Source/JavaScript/Applications/queries/useObservableQuery.ts b/Source/JavaScript/Applications/queries/useObservableQuery.ts index a24c7d0..835cbc9 100644 --- a/Source/JavaScript/Applications/queries/useObservableQuery.ts +++ b/Source/JavaScript/Applications/queries/useObservableQuery.ts @@ -4,8 +4,10 @@ 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. @@ -13,20 +15,81 @@ import { QueryResult } from './QueryResult'; * @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, TArguments = {}>(query: Constructor, args?: TArguments): [QueryResultWithState] { - const queryInstance = new query() as TQuery; - const [result, setResult] = useState>(QueryResultWithState.empty(queryInstance.defaultValue)); - const argumentsDependency = queryInstance.requestArguments.map(_ => args?.[_]); +export function useObservableQuery, TArguments = {}>( + query: Constructor, + args: TArguments = EMPTY_ARGS as TArguments +): ObservableQueryResult { + // 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.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 }; +} \ No newline at end of file diff --git a/globals.ts b/globals.ts index 6e80556..1346082 100644 --- a/globals.ts +++ b/globals.ts @@ -3,7 +3,7 @@ import { BrowserWindow } from 'electron'; -let _mainWindow: BrowserWindow | null; +let _mainWindow: BrowserWindow | null; export const setMainWindow = (mainWindow: BrowserWindow) => { _mainWindow = mainWindow; @@ -15,4 +15,4 @@ export const getMainWindow = () => { export const clearMainWindow = () => { _mainWindow = null; -}; +}; \ No newline at end of file diff --git a/package.json b/package.json index ca6c611..9482938 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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",