From 1e12d9c1ab82c376cb6e777134eda5c2a4cb854e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Adrian=20Or=C5=82=C3=B3w?= <adrian.orlow@fishbrain.com>
Date: Fri, 3 Nov 2023 19:13:16 +0100
Subject: [PATCH] test: add global query manager tests

---
 .../listeners/onMapPositionChange.test.ts     | 72 +++++++++++++++++
 .../utils/listeners/onMapPositionChange.ts    |  8 +-
 .../utils/listeners/useOlMapListeners.ts      |  5 +-
 src/utils/map/latLngToPoint.test.ts           | 79 +++++++++++++++++++
 src/utils/map/latLngToPoint.ts                |  2 +-
 src/utils/number/boundNumber.test.ts          | 17 ++++
 src/utils/number/degreesToRadians.test.ts     | 16 ++++
 src/utils/number/radiansToDegrees.test.ts     |  6 +-
 src/utils/object/getPointMerged.test.ts       | 22 ++++++
 .../object/getTruthyObjectOrUndefined.test.ts | 45 +++++++++++
 .../object/getTruthyObjectOrUndefined.ts      | 15 +++-
 src/utils/query-manager/getQueryData.test.ts  | 69 ++++++++++++++++
 src/utils/query-manager/getQueryData.ts       |  2 +-
 .../useReduxBusQueryManager.test.ts           | 79 +++++++++++++++++++
 14 files changed, 421 insertions(+), 16 deletions(-)
 create mode 100644 src/components/Map/MapViewer/utils/listeners/onMapPositionChange.test.ts
 create mode 100644 src/utils/map/latLngToPoint.test.ts
 create mode 100644 src/utils/number/boundNumber.test.ts
 create mode 100644 src/utils/number/degreesToRadians.test.ts
 create mode 100644 src/utils/object/getPointMerged.test.ts
 create mode 100644 src/utils/object/getTruthyObjectOrUndefined.test.ts
 create mode 100644 src/utils/query-manager/getQueryData.test.ts
 create mode 100644 src/utils/query-manager/useReduxBusQueryManager.test.ts

diff --git a/src/components/Map/MapViewer/utils/listeners/onMapPositionChange.test.ts b/src/components/Map/MapViewer/utils/listeners/onMapPositionChange.test.ts
new file mode 100644
index 00000000..c7a7ed78
--- /dev/null
+++ b/src/components/Map/MapViewer/utils/listeners/onMapPositionChange.test.ts
@@ -0,0 +1,72 @@
+import { useAppDispatch } from '@/redux/hooks/useAppDispatch';
+import { mapDataSelector } from '@/redux/map/map.selectors';
+import { MapSize } from '@/redux/map/map.types';
+import { Point } from '@/types/map';
+import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore';
+import { renderHook } from '@testing-library/react';
+import { ObjectEvent } from 'openlayers';
+import { onMapPositionChange } from './onMapPositionChange';
+
+const getEvent = (targetValues: ObjectEvent['target']['values_']): ObjectEvent =>
+  ({
+    target: {
+      values_: targetValues,
+    },
+  }) as unknown as ObjectEvent;
+
+/* eslint-disable no-magic-numbers */
+describe('onMapPositionChange - util', () => {
+  const cases: [MapSize, ObjectEvent['target']['values_'], Point][] = [
+    [
+      {
+        width: 26779.25,
+        height: 13503,
+        tileSize: 256,
+        minZoom: 2,
+        maxZoom: 9,
+      },
+      {
+        center: [-18320768.57141088, 18421138.0064355],
+        zoom: 6,
+      },
+      {
+        x: 9177,
+        y: 8641,
+        z: 6,
+      },
+    ],
+    [
+      {
+        width: 5170,
+        height: 1535.1097689075634,
+        tileSize: 256,
+        minZoom: 2,
+        maxZoom: 7,
+      },
+      {
+        center: [-17172011.827663105, 18910737.010646995],
+        zoom: 6.68620779943448,
+      },
+      {
+        x: 2957,
+        y: 1163,
+        z: 7,
+      },
+    ],
+  ];
+
+  it.each(cases)(
+    'should set map data position to valid one',
+    (mapSize, targetValues, lastPosition) => {
+      const { Wrapper, store } = getReduxWrapperWithStore();
+      const { result } = renderHook(() => useAppDispatch(), { wrapper: Wrapper });
+      const dispatch = result.current;
+      const event = getEvent(targetValues);
+
+      onMapPositionChange(mapSize, dispatch)(event);
+
+      const { position } = mapDataSelector(store.getState());
+      expect(position.last).toMatchObject(lastPosition);
+    },
+  );
+});
diff --git a/src/components/Map/MapViewer/utils/listeners/onMapPositionChange.ts b/src/components/Map/MapViewer/utils/listeners/onMapPositionChange.ts
index a0164f94..3873bf5b 100644
--- a/src/components/Map/MapViewer/utils/listeners/onMapPositionChange.ts
+++ b/src/components/Map/MapViewer/utils/listeners/onMapPositionChange.ts
@@ -1,25 +1,25 @@
 import { setMapData } from '@/redux/map/map.slice';
 import { MapSize } from '@/redux/map/map.types';
 import { AppDispatch } from '@/redux/store';
-import { Point } from '@/types/map';
 import { latLngToPoint } from '@/utils/map/latLngToPoint';
 import { toLonLat } from 'ol/proj';
 import { ObjectEvent } from 'openlayers';
 
 /* prettier-ignore */
 export const onMapPositionChange =
-  (mapSize: MapSize, mapPosition: Point, dispatch: AppDispatch) =>
+  (mapSize: MapSize, dispatch: AppDispatch) =>
     (e: ObjectEvent): void => {
       // eslint-disable-next-line no-underscore-dangle
       const { center, zoom } = e.target.values_;
       const [lng, lat] = toLonLat(center);
-      const value = latLngToPoint([lat, lng], mapSize, { rounded: true });
+      const { x, y } = latLngToPoint([lat, lng], mapSize, { rounded: true });
 
       dispatch(
         setMapData({
           position: {
             last: {
-              ...value,
+              x,
+              y,
               z: Math.round(zoom),
             }
           }
diff --git a/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.ts b/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.ts
index ec886195..8bea0c90 100644
--- a/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.ts
+++ b/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.ts
@@ -1,6 +1,6 @@
 import { OPTIONS } from '@/constants/map';
 import { useAppDispatch } from '@/redux/hooks/useAppDispatch';
-import { mapDataLastPositionSelector, mapDataSizeSelector } from '@/redux/map/map.selectors';
+import { mapDataSizeSelector } from '@/redux/map/map.selectors';
 import { View } from 'ol';
 import { useEffect } from 'react';
 import { useSelector } from 'react-redux';
@@ -13,11 +13,10 @@ interface UseOlMapListenersInput {
 
 export const useOlMapListeners = ({ view }: UseOlMapListenersInput): void => {
   const mapSize = useSelector(mapDataSizeSelector);
-  const mapLastPosition = useSelector(mapDataLastPositionSelector);
   const dispatch = useAppDispatch();
 
   const handleChangeCenter = useDebouncedCallback(
-    onMapPositionChange(mapSize, mapLastPosition, dispatch),
+    onMapPositionChange(mapSize, dispatch),
     OPTIONS.queryPersistTime,
     { leading: false },
   );
diff --git a/src/utils/map/latLngToPoint.test.ts b/src/utils/map/latLngToPoint.test.ts
new file mode 100644
index 00000000..35e89dd6
--- /dev/null
+++ b/src/utils/map/latLngToPoint.test.ts
@@ -0,0 +1,79 @@
+/* eslint-disable no-magic-numbers */
+import { LatLng, Point } from '@/types/map';
+import { Options, latLngToPoint } from './latLngToPoint';
+
+describe('latLngToPoint - util', () => {
+  describe('on map with default tileSize = 256', () => {
+    const mapSize = {
+      width: 5170,
+      height: 1535.1097689075634,
+      tileSize: 256,
+      minZoom: 2,
+      maxZoom: 7,
+    };
+
+    const cases: [LatLng, Point, Options][] = [
+      [
+        [84.480312233386, -159.90463877126223],
+        {
+          x: 2308.7337233905396,
+          y: 719.5731221907884,
+        },
+        {
+          rounded: false,
+        },
+      ],
+      [
+        [84.20644283660516, -153.43406886300772],
+        {
+          x: 3052,
+          y: 1039,
+        },
+        {
+          rounded: true,
+        },
+      ],
+    ];
+
+    it.each(cases)('should return valid point', (latLng, point, options) =>
+      expect(latLngToPoint(latLng, mapSize, options)).toStrictEqual(point),
+    );
+  });
+
+  describe('on map with non-default tileSize = 128', () => {
+    const mapSize = {
+      width: 26779.25,
+      height: 13503,
+      tileSize: 256,
+      minZoom: 2,
+      maxZoom: 9,
+    };
+
+    const cases: [LatLng, Point, Options][] = [
+      [
+        [843.480312233386, -84.90463877126223],
+        {
+          x: 56590.721159659464,
+          y: 66154.2246606772,
+        },
+        {
+          rounded: false,
+        },
+      ],
+      [
+        [32443.4536345435, -5546654.543645645],
+        {
+          x: -3300676187,
+          y: 78350,
+        },
+        {
+          rounded: true,
+        },
+      ],
+    ];
+
+    it.each(cases)('should return valid point', (latLng, point, options) =>
+      expect(latLngToPoint(latLng, mapSize, options)).toStrictEqual(point),
+    );
+  });
+});
diff --git a/src/utils/map/latLngToPoint.ts b/src/utils/map/latLngToPoint.ts
index aa799201..69cca9a7 100644
--- a/src/utils/map/latLngToPoint.ts
+++ b/src/utils/map/latLngToPoint.ts
@@ -9,7 +9,7 @@ import { getPointOffset } from './getPointOffset';
 const FULL_CIRCLE_DEGREES = 360;
 const SIN_Y_LIMIT = 0.9999;
 
-interface Options {
+export interface Options {
   rounded?: boolean;
 }
 
diff --git a/src/utils/number/boundNumber.test.ts b/src/utils/number/boundNumber.test.ts
new file mode 100644
index 00000000..2daccca2
--- /dev/null
+++ b/src/utils/number/boundNumber.test.ts
@@ -0,0 +1,17 @@
+/* eslint-disable no-magic-numbers */
+import { boundNumber } from './boundNumber';
+
+describe('boundNumber - util', () => {
+  const cases = [
+    [1, 0, 2, 1],
+    [1, 2, 2, 2],
+    [2, 0, 1, 1],
+  ];
+
+  it.each(cases)(
+    'should return valid bounded number | v = %s, minMax = (%s, %s), final = %s',
+    (value, minVal, maxVal, finalVal) => {
+      expect(boundNumber(value, minVal, maxVal)).toEqual(finalVal);
+    },
+  );
+});
diff --git a/src/utils/number/degreesToRadians.test.ts b/src/utils/number/degreesToRadians.test.ts
new file mode 100644
index 00000000..6d224aa6
--- /dev/null
+++ b/src/utils/number/degreesToRadians.test.ts
@@ -0,0 +1,16 @@
+/* eslint-disable no-magic-numbers */
+import { degreesToRadians } from './degreesToRadians';
+
+describe('degreesToRadians - util', () => {
+  it('coverts positive degree to close positive radians', () => {
+    expect(degreesToRadians(180)).toBeCloseTo(3.14159);
+  });
+
+  it('coverts negative degree to close negative radians', () => {
+    expect(degreesToRadians(-203)).toBeCloseTo(-3.54302);
+  });
+
+  it('coverts zero degree to zero radians', () => {
+    expect(degreesToRadians(0)).toBe(0);
+  });
+});
diff --git a/src/utils/number/radiansToDegrees.test.ts b/src/utils/number/radiansToDegrees.test.ts
index 7d0c7c3e..79839f12 100644
--- a/src/utils/number/radiansToDegrees.test.ts
+++ b/src/utils/number/radiansToDegrees.test.ts
@@ -2,15 +2,15 @@
 import { radiansToDegrees } from './radiansToDegrees';
 
 describe('radiansToDegrees - util', () => {
-  it('coverts positive degree to close positive radians', () => {
+  it('coverts positive radian to close positive degrees', () => {
     expect(radiansToDegrees(10)).toBeCloseTo(572.958);
   });
 
-  it('coverts negative degree to close negative radians', () => {
+  it('coverts negative radian to close negative degrees', () => {
     expect(radiansToDegrees(-6.45772)).toBeCloseTo(-370);
   });
 
-  it('coverts zero degree to zero radians', () => {
+  it('coverts zero radian to zero degrees', () => {
     expect(radiansToDegrees(0)).toBe(0);
   });
 });
diff --git a/src/utils/object/getPointMerged.test.ts b/src/utils/object/getPointMerged.test.ts
new file mode 100644
index 00000000..9363eb87
--- /dev/null
+++ b/src/utils/object/getPointMerged.test.ts
@@ -0,0 +1,22 @@
+import { Point } from '@/types/map';
+import { getPointMerged } from './getPointMerged';
+
+describe('getPointMerged', () => {
+  const cases: [Partial<Point>, Point, Point][] = [
+    [
+      { x: 1, y: 1, z: 0 },
+      { x: 0, y: 0, z: 0 },
+      { x: 1, y: 1, z: 0 },
+    ],
+    [
+      { x: 0, y: 0 },
+      { x: 1, y: 0, z: 1 },
+      { x: 0, y: 0, z: 1 },
+    ],
+    [{ x: 0 }, { x: 1, y: 1, z: 0 }, { x: 0, y: 1, z: 0 }],
+  ];
+
+  it.each(cases)('should return valid merged point', (primaryPoint, secondaryPoint, finalPoint) => {
+    expect(getPointMerged(primaryPoint, secondaryPoint)).toStrictEqual(finalPoint);
+  });
+});
diff --git a/src/utils/object/getTruthyObjectOrUndefined.test.ts b/src/utils/object/getTruthyObjectOrUndefined.test.ts
new file mode 100644
index 00000000..0ae63dec
--- /dev/null
+++ b/src/utils/object/getTruthyObjectOrUndefined.test.ts
@@ -0,0 +1,45 @@
+import { getTruthyObjectOrUndefined } from './getTruthyObjectOrUndefined';
+
+describe('getTruthyObjectOrUndefined - util', () => {
+  it('shoud return a truthy object if the object is truthy', () => {
+    const objectAllValuesTruthy = {
+      someKey: 1,
+      otherKey: '',
+      someOtherKey: {
+        value: 'isTruthy',
+      },
+    };
+
+    expect(getTruthyObjectOrUndefined(objectAllValuesTruthy)).toStrictEqual(objectAllValuesTruthy);
+  });
+
+  it('shoud return a truthy object if the object is empty', () => {
+    const objectNoneValues = {};
+
+    expect(getTruthyObjectOrUndefined(objectNoneValues)).toStrictEqual(objectNoneValues);
+  });
+
+  it('shoud return undefined if the object is partially truthy', () => {
+    const objectSomeValuesTruthy = {
+      someKey: undefined,
+      otherKey: null,
+      someOtherKey: {
+        value: 'isTruthy',
+      },
+    };
+
+    expect(getTruthyObjectOrUndefined(objectSomeValuesTruthy)).toBe(undefined);
+  });
+
+  it("shoud return undefined if the objects's nested objects is partially truthy", () => {
+    const objectNestedValuesTruthy = {
+      someKey: 1,
+      otherKey: '',
+      someOtherKey: {
+        value: null,
+      },
+    };
+
+    expect(getTruthyObjectOrUndefined(objectNestedValuesTruthy)).toBe(undefined);
+  });
+});
diff --git a/src/utils/object/getTruthyObjectOrUndefined.ts b/src/utils/object/getTruthyObjectOrUndefined.ts
index e8fe8153..505a411b 100644
--- a/src/utils/object/getTruthyObjectOrUndefined.ts
+++ b/src/utils/object/getTruthyObjectOrUndefined.ts
@@ -3,9 +3,16 @@ import { WithoutNullableKeys } from '@/types/utils';
 export const getTruthyObjectOrUndefined = <I extends object>(
   obj: I,
 ): WithoutNullableKeys<I> | undefined => {
-  const isAllValuesTruthy = Object.entries(obj).every(
-    ([, value]) => value !== null && value !== undefined,
-  );
+  const isValueTruthy = (value: unknown): boolean =>
+    value !== null && value !== undefined && typeof value !== 'undefined';
 
-  return isAllValuesTruthy ? (obj as WithoutNullableKeys<I>) : undefined;
+  const isObjectEntriesAreTruthy = ([, value]: [string, unknown]): boolean =>
+    typeof value === 'object' && value !== null
+      ? Object.entries(value).every(isObjectEntriesAreTruthy)
+      : isValueTruthy(value);
+
+  const isObjectTruthy = (value: object): boolean =>
+    Object.entries(value).every(isObjectEntriesAreTruthy);
+
+  return isObjectTruthy(obj) ? (obj as WithoutNullableKeys<I>) : undefined;
 };
diff --git a/src/utils/query-manager/getQueryData.test.ts b/src/utils/query-manager/getQueryData.test.ts
new file mode 100644
index 00000000..6c5051d6
--- /dev/null
+++ b/src/utils/query-manager/getQueryData.test.ts
@@ -0,0 +1,69 @@
+/* eslint-disable no-magic-numbers */
+import { QueryData } from '@/types/query';
+import { ParsedUrlQuery } from 'querystring';
+import { getQueryData, getQueryFieldNumberCurry } from './getQueryData';
+
+describe('getQueryFieldNumber - util', () => {
+  const query: ParsedUrlQuery = {
+    numberString: '123',
+    stringString: 'abcd',
+    emptyString: '',
+  };
+
+  const getQueryFieldNumber = getQueryFieldNumberCurry(query);
+
+  it('should return the number on key value is number string', () => {
+    expect(getQueryFieldNumber('numberString')).toBe(123);
+  });
+
+  it('should return undefined on key value is non-number string', () => {
+    expect(getQueryFieldNumber('stringString')).toBe(undefined);
+  });
+
+  it('should return undefined on key value is empty', () => {
+    expect(getQueryFieldNumber('emptyString')).toBe(undefined);
+  });
+});
+
+describe('getQueryData - util', () => {
+  it('should return valid query data on valid query params', () => {
+    const queryParams: ParsedUrlQuery = {
+      x: '2354',
+      y: '5321',
+      z: '6',
+      modelId: '54',
+      backgroundId: '13',
+    };
+
+    const queryData: QueryData = {
+      modelId: 54,
+      backgroundId: 13,
+      initialPosition: {
+        x: 2354,
+        y: 5321,
+        z: 6,
+      },
+    };
+
+    expect(getQueryData(queryParams)).toMatchObject(queryData);
+  });
+
+  it('should return partial query data on partial query params', () => {
+    const queryParams: ParsedUrlQuery = {
+      x: '2354',
+      modelId: '54',
+    };
+
+    const queryData: QueryData = {
+      modelId: 54,
+      backgroundId: undefined,
+      initialPosition: {
+        x: 2354,
+        y: undefined,
+        z: undefined,
+      },
+    };
+
+    expect(getQueryData(queryParams)).toMatchObject(queryData);
+  });
+});
diff --git a/src/utils/query-manager/getQueryData.ts b/src/utils/query-manager/getQueryData.ts
index 762118e6..8c8c1785 100644
--- a/src/utils/query-manager/getQueryData.ts
+++ b/src/utils/query-manager/getQueryData.ts
@@ -2,7 +2,7 @@ import { QueryData } from '@/types/query';
 import { ParsedUrlQuery } from 'querystring';
 
 /* prettier-ignore */
-const getQueryFieldNumberCurry =
+export const getQueryFieldNumberCurry =
   (query: ParsedUrlQuery) =>
     (key: string): number | undefined =>
       parseInt(`${query?.[key]}`, 10) || undefined;
diff --git a/src/utils/query-manager/useReduxBusQueryManager.test.ts b/src/utils/query-manager/useReduxBusQueryManager.test.ts
new file mode 100644
index 00000000..2963f999
--- /dev/null
+++ b/src/utils/query-manager/useReduxBusQueryManager.test.ts
@@ -0,0 +1,79 @@
+import { MAP_DATA_INITIAL_STATE } from '@/redux/map/map.constants';
+import { Loading } from '@/types/loadingState';
+import { renderHook, waitFor } from '@testing-library/react';
+import mockRouter from 'next-router-mock';
+import { getReduxWrapperWithStore } from '../testing/getReduxWrapperWithStore';
+import { useReduxBusQueryManager } from './useReduxBusQueryManager';
+
+describe('useReduxBusQueryManager - util', () => {
+  describe('on init when data is NOT loaded', () => {
+    const { Wrapper } = getReduxWrapperWithStore();
+
+    jest.mock('./../../redux/root/init.selectors', () => ({
+      initDataLoadingFinished: jest.fn().mockImplementation(() => false),
+    }));
+
+    it('should not update query', () => {
+      const routerReplaceSpy = jest.spyOn(mockRouter, 'replace');
+      renderHook(() => useReduxBusQueryManager(), { wrapper: Wrapper });
+      expect(routerReplaceSpy).not.toHaveBeenCalled();
+    });
+  });
+
+  describe('on init when data is loaded', () => {
+    const loadedDataMock = {
+      data: [],
+      loading: 'succeeded' as Loading,
+      error: { name: '', message: '' },
+    };
+
+    const { Wrapper } = getReduxWrapperWithStore({
+      project: {
+        ...loadedDataMock,
+        data: undefined,
+      },
+      map: {
+        ...loadedDataMock,
+        data: {
+          ...MAP_DATA_INITIAL_STATE,
+          modelId: 54,
+          backgroundId: 13,
+          position: {
+            initial: {
+              x: 0,
+              y: 0,
+              z: 0,
+            },
+            last: {
+              x: 1245,
+              y: 6422,
+              z: 3,
+            },
+          },
+        },
+      },
+      backgrounds: loadedDataMock,
+      models: loadedDataMock,
+      overlays: loadedDataMock,
+    });
+
+    it('should update query', async () => {
+      const routerReplaceSpy = jest.spyOn(mockRouter, 'replace');
+      renderHook(() => useReduxBusQueryManager(), { wrapper: Wrapper });
+      await waitFor(() => expect(routerReplaceSpy).toHaveBeenCalled());
+    });
+
+    it('should update query params to valid ones', async () => {
+      renderHook(() => useReduxBusQueryManager(), { wrapper: Wrapper });
+      await waitFor(() =>
+        expect(mockRouter.query).toMatchObject({
+          backgroundId: 13,
+          modelId: 54,
+          x: 1245,
+          y: 6422,
+          z: 3,
+        }),
+      );
+    });
+  });
+});
-- 
GitLab